diff --git a/apps/desktop/loopx-control-plane/src-tauri/src/maintenance.rs b/apps/desktop/loopx-control-plane/src-tauri/src/maintenance.rs index 427d2191e7..1ef1fa472c 100644 --- a/apps/desktop/loopx-control-plane/src-tauri/src/maintenance.rs +++ b/apps/desktop/loopx-control-plane/src-tauri/src/maintenance.rs @@ -408,6 +408,7 @@ fn compose_environment( arch: &str, runtime_executable_found: bool, python3: (bool, Option), + node: (bool, Option), ) -> Value { json!({ "os_version": os_version, @@ -415,6 +416,8 @@ fn compose_environment( "runtime_executable_found": runtime_executable_found, "python3_found": python3.0, "python3_version": python3.1, + "node_found": node.0, + "node_version": node.1, }) } @@ -442,6 +445,7 @@ fn detect_environment() -> Value { std::env::consts::ARCH, runtime_executable_found, crate::services::python3_environment(), + crate::services::node_environment(), ) } @@ -1907,19 +1911,22 @@ mod tests { "aarch64", false, (true, Some("3.9.6".to_string())), + (false, None), ); assert_eq!(environment["os_version"], "26.5"); assert_eq!(environment["arch"], "aarch64"); assert_eq!(environment["runtime_executable_found"], false); assert_eq!(environment["python3_found"], true); assert_eq!(environment["python3_version"], "3.9.6"); + assert_eq!(environment["node_found"], false); + assert_eq!(environment["node_version"], Value::Null); // The fresh-Mac signature: no Homebrew/CLT python3 answers a version // probe, and the runtime executable has never been installed. - let fresh = compose_environment(None, "aarch64", false, (false, None)); + let fresh = compose_environment(None, "aarch64", false, (false, None), (false, None)); assert_eq!(fresh["os_version"], Value::Null); assert_eq!(fresh["python3_found"], false); assert_eq!(fresh["python3_version"], Value::Null); - // The block carries exactly the five diagnostic keys — no paths, + // The block carries only coarse diagnostic keys — no paths, // environment variables or free-form output can join it. let keys: Vec<&str> = environment .as_object() @@ -1931,6 +1938,8 @@ mod tests { keys, [ "arch", + "node_found", + "node_version", "os_version", "python3_found", "python3_version", diff --git a/apps/desktop/loopx-control-plane/src-tauri/src/services.rs b/apps/desktop/loopx-control-plane/src-tauri/src/services.rs index 62c8e68c46..4095ff5b54 100644 --- a/apps/desktop/loopx-control-plane/src-tauri/src/services.rs +++ b/apps/desktop/loopx-control-plane/src-tauri/src/services.rs @@ -873,6 +873,14 @@ fn runtime_search_path( paths.extend([ PathBuf::from("/opt/homebrew/bin"), PathBuf::from("/usr/local/bin"), + // Homebrew's supported LTS Node formulae are keg-only. Finder + // does not inherit the shell PATH that activates those runtimes. + // Use the same bounded search for the private installer and the + // services it starts; Core still qualifies the actual version. + PathBuf::from("/opt/homebrew/opt/node@24/bin"), + PathBuf::from("/usr/local/opt/node@24/bin"), + PathBuf::from("/opt/homebrew/opt/node@22/bin"), + PathBuf::from("/usr/local/opt/node@22/bin"), ]); } for path in inherited @@ -908,6 +916,24 @@ pub(crate) fn python3_environment() -> (bool, Option) { (found, version) } +pub(crate) fn node_environment() -> (bool, Option) { + let path = runtime_search_path(env::var_os("HOME"), env::var_os("PATH")); + let executable = resolve_executable_path("node", Some(path.as_os_str())); + let found = executable.is_some(); + let version = executable.and_then(|node| { + let mut probe = Command::new(node); + probe.arg("--version"); + timed_output(probe).filter(|output| output.status.success()).and_then(|output| { + let text = std::str::from_utf8(&output.stdout).ok()?.trim().strip_prefix('v')?; + let fields: Vec<_> = text.split('.').collect(); + (fields.len() == 3 && fields.iter().all(|field| + !field.is_empty() && field.bytes().all(|byte| byte.is_ascii_digit()))) + .then(|| text.to_owned()) + }) + }); + (found, version) +} + const VERSION_PROBE_TIMEOUT: Duration = Duration::from_secs(1); // Installation qualification boots Core, checks the TypeScript runtime and // hashes the installed package. It is not a version-banner query: a valid cold diff --git a/apps/desktop/loopx-control-plane/src-tauri/src/services_tests.rs b/apps/desktop/loopx-control-plane/src-tauri/src/services_tests.rs index e241bace6a..c8c01f2f64 100644 --- a/apps/desktop/loopx-control-plane/src-tauri/src/services_tests.rs +++ b/apps/desktop/loopx-control-plane/src-tauri/src/services_tests.rs @@ -124,6 +124,11 @@ fn finder_runtime_path_includes_tools_without_loading_shell_profiles() { .unwrap() ); assert!(paths.contains(&PathBuf::from("/usr/local/bin"))); + for prefix in ["/opt/homebrew", "/usr/local"] { + for version in ["24", "22"] { + assert!(paths.contains(&PathBuf::from(format!("{prefix}/opt/node@{version}/bin")))); + } + } assert!(paths.contains(&PathBuf::from("/sbin"))); let again = runtime_search_path(Some("/fixture/user".into()), Some(path.clone())); assert_eq!(again, path, "tool search must be idempotent"); diff --git a/apps/desktop/loopx-control-plane/static/boot.js b/apps/desktop/loopx-control-plane/static/boot.js index b40b0f05f5..3c7dbca071 100644 --- a/apps/desktop/loopx-control-plane/static/boot.js +++ b/apps/desktop/loopx-control-plane/static/boot.js @@ -6,6 +6,7 @@ const pageStarted = performance.now(); let lastTiming = null; let timingObservedAt = pageStarted; let bootState = null; +let observedEnvironment = null; function renderStartup(result) { const state = result?.state; bootState = state; @@ -125,6 +126,9 @@ function codeText(code, phase) { return "启动参数 LOOPX_BIN 固定了当前运行时。请通过该安装方式修复它,或移除、修正 LOOPX_BIN 后重新打开 App;清除记住的选择不会改变此参数。"; } if (typeof code === "string" && /^runtime_install_exit_(\d+|signal)$/.test(code)) { + if (code === "runtime_install_exit_1" && observedEnvironment?.node_found === false) { + return "安装程序退出(1);App 的启动环境未找到 Node.js。请启用受支持的 Node.js 后点击「修复当前版本」。"; + } // Exit 2 from install-local.sh is its "no usable Python 3.11+" gate; the // same exit can technically be a usage error, so the wording stays // probabilistic and points at the repair action. @@ -168,9 +172,12 @@ function safeEnvironment(result) { runtime_executable_found: flag(environment.runtime_executable_found), python3_found: flag(environment.python3_found), python3_version: text(environment.python3_version), + node_found: flag(environment.node_found), + node_version: text(environment.node_version), }; } function renderDiagnostics(result) { + observedEnvironment = safeEnvironment(result); const failure = result.last_failure ?? result.state; const text = JSON.stringify({ schema_version: "desktop_recovery_diagnostics_v2", diff --git a/apps/presentation/dashboard/src/data/chat.ts b/apps/presentation/dashboard/src/data/chat.ts index 4e6b92edfd..c3de47b5be 100644 --- a/apps/presentation/dashboard/src/data/chat.ts +++ b/apps/presentation/dashboard/src/data/chat.ts @@ -646,14 +646,17 @@ export async function requestJson(url: string, init?: RequestInit): Promise(`/api/chat/todo/detail?${query}`, {signal})); if (result.goal_id !== goalId || result.todo_id !== todoId) throw new Error("Task source changed"); + if (sourceNamespace && result.source_access?.namespace !== sourceNamespace) throw new Error("Task source changed"); return result; } diff --git a/apps/presentation/dashboard/src/data/status.ts b/apps/presentation/dashboard/src/data/status.ts index 3461e382a3..20218ddc0e 100644 --- a/apps/presentation/dashboard/src/data/status.ts +++ b/apps/presentation/dashboard/src/data/status.ts @@ -539,6 +539,7 @@ export const runRecordSchema = z.object({ }); export const runGoalSchema = z.object({ + source_access: z.object({namespace: z.string(), label: z.string(), read_only: z.literal(true)}).optional(), parent_goal_id: z.string().nullable().optional().catch(null), goal_objective: z.string().nullable().optional().catch(null), zcode_goal_eligible_agent_ids: z.array(z.string()).optional().default([]).catch([]), diff --git a/apps/presentation/dashboard/src/data/workspace-progressive-status.ts b/apps/presentation/dashboard/src/data/workspace-progressive-status.ts index 98a1fa2ca2..d20858cca1 100644 --- a/apps/presentation/dashboard/src/data/workspace-progressive-status.ts +++ b/apps/presentation/dashboard/src/data/workspace-progressive-status.ts @@ -17,6 +17,7 @@ const directorySchema = z.object({ goals: z.array(z.object({ id: z.string(), parent_goal_id: z.string().nullable().optional().catch(null), + source_access: z.object({namespace: z.string(), label: z.string(), read_only: z.literal(true)}).optional(), display_name: z.string(), activation_state: z.enum(["active", "stopped"]), registry_member: z.literal(true), @@ -68,6 +69,7 @@ export function reusableGoalSnapshots( const snapshot = previous.snapshots[goal.id]; if (!earlier || !snapshot || invalidated.has(goal.id)) return []; if (earlier.display_name !== goal.display_name + || earlier.source_access?.namespace !== goal.source_access?.namespace || earlier.parent_goal_id !== goal.parent_goal_id || earlier.activation_state !== goal.activation_state) return []; return [[goal.id, snapshot]]; diff --git a/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx b/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx index a983bb055a..cfcc6ff00a 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/context-drawer.tsx @@ -318,6 +318,8 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention : t("drawer.goalDetails"); const goalId = selection.kind === "proposal" ? selection.item.goalId ?? "manager" : selection.item.goalId; + const taskNamespace = goals.find(goal => goal.goalId === goalId)?.sourceAccess?.namespace; + const taskReadable = !readOnly || Boolean(taskNamespace); const contextLabel = selection.kind === "attention" ? selection.item.goalTitle ?? t("drawer.currentGoal") : selection.kind === "todo" ? selection.item.goalTitle : selection.kind === "run" ? selection.item.goalTitle @@ -642,7 +644,7 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention ) : selection.item.detailMode ? (

{t(selection.item.detailMode === "missing_current" ? "drawer.taskReadbackUnavailable" : "drawer.taskRequestOnly")}

- +
) : ( <> @@ -654,8 +656,8 @@ export function ContextDrawer({ agents, attentionHistory = [], onSelectAttention {selection.item.priority ? {selection.item.priority} : null} {selection.item.taskClass === "advancement_task" ? t("drawer.taskAdvancement") : selection.item.taskClass ?? t("drawer.taskOrdinary")} - + {selection.item.status === "blocked" ?

{t("drawer.blockedReason")}

{selection.item.blockedReason ?? t("drawer.blockedReasonMissing")}

diff --git a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx index 06f9b50df0..94f4723b05 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx @@ -673,7 +673,7 @@ const en = { "header.managerOverview": "Overview", "header.managerView": "Manager view", "header.openGoalNavigation": "Open Goal navigation", - "header.readOnlySourceDescription": "{source} is displayed read-only through an SSH tunnel", + "header.readOnlySourceDescription": "Original records from {source}; execution remains on its source", "header.refresh": "Refresh status", "header.refreshDone": "Updated just now", "header.refreshFailed": "Refresh failed", @@ -2267,7 +2267,7 @@ const zhCN: Record = { "header.managerOverview": "总览", "header.managerView": "管家视图", "header.openGoalNavigation": "打开 Goal 导航", - "header.readOnlySourceDescription": "{source} 通过 SSH 隧道只读展示", + "header.readOnlySourceDescription": "显示 {source} 的原始记录;执行控制在原运行环境", "header.refresh": "刷新状态", "header.refreshDone": "刚刚更新", "header.refreshFailed": "刷新失败", diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts index a9cc4b9705..926b3dc20e 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts @@ -162,6 +162,7 @@ export type WorkspaceGoalSubagentConfiguration = { }; export type WorkspaceGoal = { + sourceAccess?: {namespace: string; label: string; readOnly: true}; /** Registry organization in this status source; not Agent ownership or execution dependency. */ parentGoalId?: string | null; /** Bounded, safe text from this Goal's declared source document. */ diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx index 5c789bf1a6..fd9c35c184 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx @@ -916,7 +916,7 @@ export function PersonalWorkspacePage({ managerRuntime, model, researchApi, - readOnly = false, + readOnly: sourceReadOnly = false, typedActionsRevision = 0, selectedAgentId: controlledAgentId, selectedAgentModelId, @@ -963,6 +963,8 @@ export function PersonalWorkspacePage({ }) { const { locale, t } = useWorkspaceI18n(); const [localGoalId, setLocalGoalId] = useState(controlledGoalId ?? null); + const sourceGoal = model.goals.find(goal => goal.goalId === (controlledGoalId === undefined ? localGoalId : controlledGoalId)); + const readOnly = sourceReadOnly || sourceGoal?.sourceAccess?.readOnly === true; const [localAgentId, setLocalAgentId] = useState(controlledAgentId ?? agents.find((agent) => agent.available)?.agentId ?? "codex"); const selectionScope = `${statusSourceControl?.activeSource.statusUrl ?? "/status.json"}:${controlledGoalId === undefined ? localGoalId : controlledGoalId}`; const runtimeSourceUnavailable = statusSourceControl?.activeSource.kind === "runtime" @@ -2733,7 +2735,7 @@ export function PersonalWorkspacePage({ selectedAgentModelReadback={selectedAgentModelReadback} selectedAgentReasoningEffort={selectedAgentReasoningEffort} refreshState={refreshState} - readOnlySourceLabel={readOnly ? statusSourceControl?.activeSource.label : undefined} + readOnlySourceLabel={readOnly ? sourceGoal?.sourceAccess?.label ?? statusSourceControl?.activeSource.label : undefined} selectedGoal={runtimeSourceUnavailable ? null : selectedGoal} selectedGoalTab={selectedGoalTab} goalLifecycleBusy={selectedGoal ? lifecycleBusyGoalIds.has(selectedGoal.goalId) : false} @@ -2804,7 +2806,7 @@ export function PersonalWorkspacePage({ onOpenDetails={() => setSelection({ kind: "goal", item: selectedGoal })} onSelect={setSelection} onView={setSelectedGoalTab} selectedTodoId={drawerSelection?.kind === "todo" ? drawerSelection.item.todoId : null} board={taskBoard(!settingsOpen && selectedGoalTab === "overview")} results={goalResults(!settingsOpen && selectedGoalTab === "overview")} - historyRefreshRevision={historyRefreshRevision} readSourceUrl={statusSourceControl?.activeSource.kind === "runtime" && statusSourceControl.connectionState !== "loading" && statusSourceControl.connectionState !== "error" ? statusSourceControl.activeSource.statusUrl : undefined} proposals={proposals} onPreview={createPreview} />, + historyRefreshRevision={historyRefreshRevision} readSourceUrl={sourceGoal?.sourceAccess ? `/status.json?source_namespace=${sourceGoal.sourceAccess.namespace}` : statusSourceControl?.activeSource.kind === "runtime" && statusSourceControl.connectionState !== "loading" && statusSourceControl.connectionState !== "error" ? statusSourceControl.activeSource.statusUrl : undefined} proposals={proposals} onPreview={createPreview} />, tasks: taskBoard(!settingsOpen && selectedGoalTab === "tasks"), files: goalResults(selectedGoalTab === "files" && !settingsOpen), chat: agentWorkActive ? {locale === "zh-CN" ? "消息处理" : "Message delivery"}{loopxMessageReceipt} : null} {readOnly ? ( -
{t("source.readOnlyNoticeTitle")}{t("source.readOnlyNoticeDescription")}
+ sourceGoal?.sourceAccess ? null :
{t("source.readOnlyNoticeTitle")}{t("source.readOnlyNoticeDescription")}
) : <> {!selectedGoal && !managerChatOpen && managerConversationReceiptVisible && managerMessages.length ? ( >} ); -export function TaskRequest({todo, local, onSave}: {todo: WorkspaceTodo; local: boolean; +export function TaskRequest({todo, local, sourceNamespace, onSave}: {todo: WorkspaceTodo; local: boolean; sourceNamespace?: string; onSave?: PersonalWorkspaceCallbacks["onSaveTodoRequirements"]}) { const {t} = useWorkspaceI18n(); const sourceId = todo.sourceTodoId; - const identity = JSON.stringify([local, todo.goalId, sourceId, todo.requestText, todo.status]); + const identity = JSON.stringify([local, sourceNamespace, todo.goalId, sourceId, todo.requestText, todo.status]); const [read, setRead] = useState(null); const [retry, setRetry] = useState(0); const [draft, setDraft] = useState<{text: string; basis: TodoUpdateSourceBasis} | null>(null); @@ -31,7 +31,7 @@ export function TaskRequest({todo, local, onSave}: {todo: WorkspaceTodo; local: if (!local || !sourceId) return; const controller = new AbortController(); setRead({identity, phase: "loading"}); - void readTodoRequest(todo.goalId, sourceId, controller.signal).then(detail => { + void readTodoRequest(todo.goalId, sourceId, controller.signal, sourceNamespace).then(detail => { if (controller.signal.aborted) return; setRead({identity, phase: "ready", detail}); if (rebaseRef.current && detail.review_basis) { @@ -45,7 +45,7 @@ export function TaskRequest({todo, local, onSave}: {todo: WorkspaceTodo; local: if (!controller.signal.aborted) setRead({identity, phase: "error"}); }); return () => controller.abort(); - }, [identity, local, retry, todo.goalId, sourceId]); + }, [identity, local, retry, todo.goalId, sourceId, sourceNamespace]); const current = local && read?.identity === identity ? read : null; const detail = current?.phase === "ready" ? current.detail : null; const editable = local && onSave && todo.claimedBy && sourceId && detail?.review_basis diff --git a/apps/presentation/dashboard/src/views/dashboard-page.tsx b/apps/presentation/dashboard/src/views/dashboard-page.tsx index ea409513be..7032e4d128 100644 --- a/apps/presentation/dashboard/src/views/dashboard-page.tsx +++ b/apps/presentation/dashboard/src/views/dashboard-page.tsx @@ -1100,6 +1100,7 @@ function buildPersonalHomeModel( acceptanceObservation: goal.acceptance_observation, goalId: goal.id, parentGoalId: goal.parent_goal_id, + sourceAccess: goal.source_access ? {...goal.source_access, readOnly: true} : undefined, latestActivity: row.latestRun?.generated_at ?? "", needsYou, needsYouActionKind: needsYouTodo?.actionKind ?? null, @@ -1279,6 +1280,7 @@ function PersonalGoalHome({ theme: "light" | "dark"; }) { const readOnly = statusSourceControl.activeSource.readOnly + || rows.find(row => row.goal.id === requestedGoalId)?.goal.source_access?.read_only === true || Boolean(rootGoalId && !rows.some(row => row.goal.id === rootGoalId)); const remoteGoalLifecycleHost = statusSourceControl.activeSource.kind === "ssh_tunnel" ? statusSourceControl.activeSource.hostAlias diff --git a/docs/development/assets/runtime-source-analysis/native-app-goals/before.png b/docs/development/assets/runtime-source-analysis/native-app-goals/before.png new file mode 100644 index 0000000000..054eb379a2 Binary files /dev/null and b/docs/development/assets/runtime-source-analysis/native-app-goals/before.png differ diff --git a/docs/development/assets/runtime-source-analysis/native-app-goals/desktop.png b/docs/development/assets/runtime-source-analysis/native-app-goals/desktop.png new file mode 100644 index 0000000000..739789a01f Binary files /dev/null and b/docs/development/assets/runtime-source-analysis/native-app-goals/desktop.png differ diff --git a/docs/development/assets/runtime-source-analysis/native-app-goals/narrow.png b/docs/development/assets/runtime-source-analysis/native-app-goals/narrow.png new file mode 100644 index 0000000000..2246c3a983 Binary files /dev/null and b/docs/development/assets/runtime-source-analysis/native-app-goals/narrow.png differ diff --git a/docs/development/frontend-delivery.md b/docs/development/frontend-delivery.md index efc619a005..df823c236c 100644 --- a/docs/development/frontend-delivery.md +++ b/docs/development/frontend-delivery.md @@ -90,6 +90,46 @@ log read; no lower CPU, process or IO cost is implied. Local host/container readback and SSH command composition are separate validation boundaries; a composed command test does not prove a live remote host/container journey. +### Native App source Goals + +A prepared reader or a separately served workspace is an access adapter. It +does not complete a request to use the installed LoopX App. For that request, +the App's normal Chat service must discover the original Goals through its +persisted directory, keep them beneath the requested parent, and use the same +origin for status, native Task/history reads and explicit analysis. A browser +URL, local-storage source switch or empty local analysis Goal is not a substitute. +Qualify the running installed App and service identities, parent-to-child +navigation, original evidence, analysis readback, source failure and reopening. +Keep backend, browser-renderer and native WebView evidence distinct. + +The registry's `goal_source_links` contains only owner-configured references: +`parent_goal_id` plus the existing explicit source object above, scoped to one +original Goal. It creates no execution Goal, state document, worker or copied +registry entry. The TypeScript presentation owner validates the local parent +and unambiguous original identity; the existing source reader performs IO. +`POST /api/chat/goal-sources` admits that connection through the normal loopback +owner boundary after verifying original membership; `GET` reads safe descriptors. +The same POST removes a link when given `parent_goal_id`, `goal_id` and its +current `source_namespace`; removal preserves the original Goal and local +analysis artifacts and refuses a stale physical identity. +An Agent prepares the connection within the owner's authority. Do not require +the owner to discover runtime paths, write a manifest or copy a tunnel command. +Links survive ordinary registry synchronization and App restart. Ambiguous Goal +ids, unknown parents and source replacement are refused instead of borrowing +another Goal's state. Native execution/configuration remains on the original +runtime; the local App does not acquire those writes through a display link. + +Each linked Goal carries `source_access` in the workspace projection. Its +original Goal id, tasks, explicit graph edges and native measurements remain +intact; the local link owns only parent organization. Source identity pins +history and analysis requests. First-layer reads invoke no model. Optional +second-layer artifacts use the existing toolkit store beneath the local +runtime's `goal-sources/` and remain bound to that original +Goal. Every model start/retry revalidates original membership without the display +cache. Source loss must stay local to the affected Goal and must not launch a +model or strand the rest of the App. Native liveness, causal value and missing +relations cannot be supplied by the local parent or an analysis result. + ## Task-first delivery / 从用户任务开始 Use the existing task/PR delivery brief, not a second checklist or approval form. diff --git a/examples/desktop-recovery-diagnostics-test.mjs b/examples/desktop-recovery-diagnostics-test.mjs index 339707ff24..48f097f417 100644 --- a/examples/desktop-recovery-diagnostics-test.mjs +++ b/examples/desktop-recovery-diagnostics-test.mjs @@ -67,6 +67,22 @@ test('legacy pairing state reaches diagnostics without private detail', () => { assert.ok(!JSON.stringify(value).includes('PRIVATE')); }); +test('missing Node is diagnosed from observation, not inferred from every exit 1', () => { + const {context, elements} = page(); + context.packet = {state: {phase: 'error', details: {code: 'runtime_install_exit_1'}}, + environment: {node_found: false, node_version: null, path: 'PRIVATE'}}; + runInNewContext('renderDiagnostics(packet); render(packet.state)', context); + assert.match(elements.get('#update-status').textContent, /Node.js/); + assert.equal(JSON.parse(elements.get('#diagnostics').value).environment.node_found, false); + assert.ok(!elements.get('#diagnostics').value.includes('PRIVATE')); + context.packet.environment = {node_found: true, node_version: '24.21.0'}; + runInNewContext('renderDiagnostics(packet); render(packet.state)', context); + assert.doesNotMatch(elements.get('#update-status').textContent, /未找到 Node/); + delete context.packet.environment; + runInNewContext('renderDiagnostics(packet); render(packet.state)', context); + assert.doesNotMatch(elements.get('#update-status').textContent, /未找到 Node/); +}); + test('terminal runtime identity failure stops waiting and opens recovery immediately', () => { const {context, elements} = page(); const packet = {state:{phase:'runtime_required',details:{code:'runtime_identity_unavailable',bundled_repair_available:false}}}; diff --git a/loopx/chat_server.py b/loopx/chat_server.py index a7a5399df1..be6029184f 100644 --- a/loopx/chat_server.py +++ b/loopx/chat_server.py @@ -101,6 +101,7 @@ from .chat_completed_todos import CompletedTodoPages, CompletedTodoRequestMixin from .presentation.explore_results_api import ExploreResultsRequestMixin from .presentation.research_analysis_api import RESEARCH_ANALYSIS_PATH, ResearchAnalysisRequestMixin +from .presentation.goal_source_routing import GOAL_SOURCE_LINKS_PATH, GoalSourceRequestMixin from .chat_todo_detail import TodoDetailRequestMixin from .kiro_cli_goal_mode import KIRO_CLI_BIN from .paths import resolve_runtime_root @@ -494,6 +495,7 @@ def server_close(self) -> None: class ChatRequestHandler( + GoalSourceRequestMixin, GoalAcceptanceRequestMixin, ZCodeGoalRequestMixin, PrivateConversationRequestMixin, @@ -1599,6 +1601,7 @@ def do_GET(self) -> None: } ) get_dispatch: dict[str, Callable[[], None]] = { + GOAL_SOURCE_LINKS_PATH: self._goal_source_links, "/api/chat/todo/detail": self._todo_detail, "/api/chat/completed-todos": self._completed_todos, "/api/chat/goal-results": self._goal_results, @@ -1652,6 +1655,7 @@ def do_POST(self) -> None: if self._dispatch_zcode_goal(path, apply=True): return post_dispatch: dict[str, Callable[[], None]] = { + GOAL_SOURCE_LINKS_PATH: self._goal_source_links, RESEARCH_ANALYSIS_PATH: self._research_analysis_write, RESEARCH_ANALYSIS_PATH + "/cancel": self._research_analysis_write, CHAT_SESSIONS_PATH: self._create_session, diff --git a/loopx/chat_status_api.py b/loopx/chat_status_api.py index 9402f41fe7..5893a083b0 100644 --- a/loopx/chat_status_api.py +++ b/loopx/chat_status_api.py @@ -41,7 +41,6 @@ from .feedback import validate_goal_id from .history import load_registry from .control_plane.projects.registry_codec import load_project_registry -from .registry import registry_goals from .status import collect_status from .status_server import ( DEFAULT_EXTENSION_PRESENTATION_SURFACES_PATH, @@ -197,19 +196,16 @@ def _status(self, *, delivery_review: bool = False) -> None: ) return try: + registry = {} if views is not None or requested_goals is not None or delivery_review: # Discovery reads only metadata. Full status/runtime reads keep # their existing lifecycle-only profile refusal. registry = (_discovery_registry(self.server.registry_path) if views == ["workspace-directory"] else load_registry(self.server.registry_path)) - if goal_id and goal_id not in { - str(g.get("id")) for g in registry_goals(registry) - }: + directory = workspace_goal_directory(registry, selected_goal_id=goal_id) + if goal_id and not directory["goals"]: self._send_error("Goal is no longer registered.", status=404) return - directory = workspace_goal_directory( - registry, selected_goal_id=goal_id - ) if views == ["workspace-directory"]: directory["contract"] = _discovery_contract(self.server.registry_path) # Discovery stays metadata-only: redact with the roots this @@ -227,7 +223,15 @@ def _status(self, *, delivery_review: bool = False) -> None: ) self._send_json(directory) return - projection = collect_status( + linked = None + if goal_id and registry.get("goal_source_links"): + from .presentation.goal_source_routing import goal_source_catalog + catalog = goal_source_catalog(self.server) + if delivery_review and catalog.resolve(goal_id, registry): + self._send_error("Execution review belongs to the original Goal runtime.", status=403) + return + linked = catalog.status(goal_id, registry) + projection = linked if linked is not None else collect_status( registry_path=self.server.registry_path, # The Chat service already starts on an absent registry as an # empty workspace (serve_chat); its status reports the same @@ -265,13 +269,14 @@ def _status(self, *, delivery_review: bool = False) -> None: "presentation_surfaces_url": DEFAULT_EXTENSION_PRESENTATION_SURFACES_PATH, "presentation_detail_url": DEFAULT_EXTENSION_PROJECTION_PATH, } - attach_host_thread_activity( - projection, observers=codex_thread_observers() - ) - attach_host_delivery_windows( - projection, - expectations=codex_delivery_expectations, - ) + if linked is None: + attach_host_thread_activity( + projection, observers=codex_thread_observers() + ) + attach_host_delivery_windows( + projection, + expectations=codex_delivery_expectations, + ) if delivery_review: if projection.get("ok") is not True: self._send_error("Delivery review sources are unavailable.", status=503) diff --git a/loopx/chat_workspace_directory.py b/loopx/chat_workspace_directory.py index 74358ba6f9..b6aa741f6d 100644 --- a/loopx/chat_workspace_directory.py +++ b/loopx/chat_workspace_directory.py @@ -3,6 +3,8 @@ from __future__ import annotations from typing import Any +import hashlib +import json from .chat import redact_local_paths from .control_plane.goals.activation import goal_activation_state @@ -31,9 +33,17 @@ def workspace_goal_directory( "registry_member": True, } ) + links = registry.get("goal_source_links", []) + if links: + from .control_plane.status.goal_source import linked_source_goals + goals.extend(goal for goal in linked_source_goals(registry) + if not selected_goal_id or goal["id"] == selected_goal_id) + revision = registry_activation_revision(registry) + if links: + revision += ":" + hashlib.sha256(json.dumps(links, sort_keys=True).encode()).hexdigest()[:24] return { "ok": True, "schema_version": "loopx_workspace_directory_v1", - "registry_revision": registry_activation_revision(registry), + "registry_revision": revision, "goals": goals, } diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts index 71edd1cc47..67e3e48253 100644 --- a/loopx/control_plane/effect_runtime_handlers.ts +++ b/loopx/control_plane/effect_runtime_handlers.ts @@ -465,6 +465,7 @@ export function createEffectRuntimeHandlers( ["presentation.decision_notice.project", lazyHandler(() => import("./presentation/decision_notice.ts"), ({projectDecisionNotice}) => projectDecisionNotice)], ["presentation.decision_notice.validate_references", lazyHandler(() => import("./presentation/decision_notice.ts"), ({validateDecisionNoticeReferences}) => validateDecisionNoticeReferences)], ["presentation.goal_attention.project", lazyHandler(() => import("./presentation/goal_attention.ts"), ({projectGoalAttention}) => projectGoalAttention)], + ["presentation.goal_sources.project", lazyHandler(() => import("./presentation/goal_source_links.ts"), ({projectGoalSourceLinks}) => projectGoalSourceLinks)], ["presentation.owner_attention.plan", lazyHandler(() => import("./presentation/owner_attention.ts"), ({planOwnerAttention}) => planOwnerAttention)], ["presentation.owner_attention.observe", lazyHandler(() => import("./presentation/owner_attention.ts"), ({observeOwnerAttention}) => observeOwnerAttention)], ["presentation.owner_attention.health", lazyHandler(() => import("./presentation/owner_attention.ts"), ({ownerAttentionHealth}) => ownerAttentionHealth)], diff --git a/loopx/control_plane/presentation/goal_source_links.ts b/loopx/control_plane/presentation/goal_source_links.ts new file mode 100644 index 0000000000..1f35d5a1ae --- /dev/null +++ b/loopx/control_plane/presentation/goal_source_links.ts @@ -0,0 +1,32 @@ +/** Local organization of existing source Goals, never execution registration. */ +import type {JsonObject} from "../effect_program.ts"; +import {requireJsonObject, requireNonEmptyString} from "../runtime_decode.ts"; + +export function projectGoalSourceLinks(input: JsonObject): JsonObject { + if (!Array.isArray(input.local_goal_ids) || !Array.isArray(input.links) || input.links.length > 32) { + throw new Error("Use a bounded Goal source catalog"); + } + const localIds = new Set(input.local_goal_ids.map(id => requireNonEmptyString(id, "local Goal"))); + const seen = new Set(); + const goals = input.links.flatMap(value => { + const link = requireJsonObject(value, "source link"); + const goalId = requireNonEmptyString(link.goal_id, "source Goal"); + const parent = requireNonEmptyString(link.parent_goal_id, "parent Goal"); + const namespace = requireNonEmptyString(link.namespace, "source namespace"); + const label = requireNonEmptyString(link.label, "source label"); + if (!/^[a-f0-9]{64}$/.test(namespace)) { + throw new Error("Invalid source identity"); + } + // Parent lifecycle is owned elsewhere. A retained reference must not + // make unrelated local/source Goals unreadable after its parent leaves. + if (!localIds.has(parent) || localIds.has(goalId)) return []; + if (seen.has(goalId)) { + throw new Error("Source Goals need an existing local parent and an unambiguous original identity"); + } + seen.add(goalId); + return [{id: goalId, parent_goal_id: parent, display_name: label, + registry_member: true, activation_state: "active", + source_access: {namespace, label, read_only: true}}]; + }); + return {goals}; +} diff --git a/loopx/control_plane/status/goal_source.py b/loopx/control_plane/status/goal_source.py index d05fc540d2..0dec70150e 100644 --- a/loopx/control_plane/status/goal_source.py +++ b/loopx/control_plane/status/goal_source.py @@ -193,6 +193,16 @@ def read_status(self, goal_id=None): raise SourceScopeError("Only registered Goals are in this source scope") return status + def read_todo(self, goal_id, todo_id): + self.check_access(goal_id) + if not isinstance(todo_id, str) or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.:-]{0,199}", todo_id): + raise SourceScopeError("Invalid source Task id") + return json.loads(self._execute([ + self.cli, "--format", "json", "--registry", self.registry, + "--runtime-root", self.runtime_root, "todo", "list", + "--goal-id", goal_id, "--todo-id", todo_id, + ])) + def read_events(self, goal_id): """Read JSON objects; the capability-owned projector verifies semantics.""" selected = self.check_access(goal_id) @@ -246,6 +256,17 @@ def describe(self): "operations": {"goals": True, "status": True, "history": True, "lifecycle": False, "conversation": False, "analysis": False}} + def registered_status(self, goal_id, *, fresh=False): + with self._lock: + status = self.status(goal_id, fresh=fresh) + if not any(goal["id"] == goal_id and goal.get("registry_member") is True + for goal in status["run_history"]["goals"]): + # A runtime-only historical row must not poison a recovered + # registered Goal's display or model-admission cache. + self._invalidate() + raise SourceScopeError("The original Goal is no longer registered") + return status + def goals(self, goal_id=None, *, fresh=False): status = self.status(goal_id, fresh=fresh) return {"ok": True, "source": self.source.describe(), @@ -274,6 +295,146 @@ def redirect_request(self, *_args, **_kwargs): return None +def linked_source_goals(registry): + """Project explicit source references without reading or creating a Goal.""" + raw = registry.get("goal_source_links", []) + if not raw: + return [] + if not isinstance(raw, list) or len(raw) > 32: + raise ValueError("Invalid Goal source catalog") + sources = [] + for link in raw: + if not isinstance(link, dict) or set(link) != {"parent_goal_id", "source"}: + raise ValueError("Invalid Goal source link") + source = GoalSource.parse(link["source"]) + if source.goal_id is None: + raise ValueError("A child link must select one original Goal") + sources.append({"goal_id": source.goal_id, "parent_goal_id": link["parent_goal_id"], + "namespace": source.namespace, "label": source.label}) + from ..effect_runtime import effect_runtime_result + from ...registry import registry_goals + + return effect_runtime_result("presentation.goal_sources.project", { + "local_goal_ids": [goal["id"] for goal in registry_goals(registry)], "links": sources, + })["goals"] + + +class GoalSourceCatalog: + """App IO over its existing registry's source links, not mirror Goals. + + The TS presentation owner qualifies organization/identity. Native sources + retain all Task, state, history and execution authority. Analysis artifacts + live locally under the same physical namespace used by prepared readers. + """ + def __init__(self, registry_path, runtime_root, *, history_projector): + self.registry_path = Path(registry_path) + self.runtime_root = Path(runtime_root) + self._history_projector = history_projector + self._readers = {} + self._lock = RLock() + + def resolve(self, goal_id, registry=None): + from ...registry import load_registry + + registry = load_registry(self.registry_path) if registry is None else registry + rows = linked_source_goals(registry) + row = next((row for row in rows if row["id"] == goal_id), None) + if row is None: + return None + raw = next(link["source"] for link in registry["goal_source_links"] + if link["source"]["scope"].get("goal_id") == goal_id) + source = GoalSource.parse(raw) + with self._lock: + namespaces = {goal["source_access"]["namespace"] for goal in rows} + self._readers = {key: value for key, value in self._readers.items() if key in namespaces} + if source.namespace not in self._readers: + self._readers[source.namespace] = GoalSourceReader(source, history_projector=self._history_projector) + reader = self._readers[source.namespace] + return row, reader + + def status(self, goal_id, registry=None): + resolved = self.resolve(goal_id, registry) + if resolved is None: + return None + row, reader = resolved + status = reader.registered_status(goal_id) + source_goal = next((goal for goal in status["run_history"]["goals"] + if goal["id"] == goal_id and goal.get("registry_member") is True), None) + if source_goal is None: + raise SourceScopeError("The original Goal is no longer registered") + source_goal["native_parent_goal_id"] = source_goal.get("parent_goal_id") + source_goal.update({key: row[key] for key in ("parent_goal_id", "source_access")}) + return status + + def analysis_scope(self, goal_id, *, namespace, fresh, verify_source=True): + resolved = self.resolve(goal_id) + if resolved is None: + return None + row, reader = resolved + if namespace != reader.source.namespace: + raise SourceScopeError("Analysis source identity changed") + if verify_source: + reader.registered_status(goal_id, fresh=fresh) + return self.runtime_root / "goal-sources" / row["source_access"]["namespace"] + + def update(self, *, parent_goal_id, raw_source): + from ...registry import load_registry + from ..projects.registry_codec import mutate_project_registry + + source = GoalSource.parse(raw_source) + link = {"parent_goal_id": parent_goal_id, "source": raw_source} + + def proposed(registry): + links = list(registry.get("goal_source_links", [])) + existing = next((item for item in links + if item["source"]["scope"].get("goal_id") == source.goal_id), None) + if existing is not None and existing != link: + raise SourceScopeError("The original source identity and parent are already linked") + if existing is None: + links.append(link) + return links + + def admitted(registry): + links = proposed(registry) + rows = linked_source_goals({**registry, "goal_source_links": links}) + if not any(row["id"] == source.goal_id for row in rows): + raise SourceScopeError("Choose an existing local parent") + return links + + before = load_registry(self.registry_path) + admitted(before) + # Qualify original membership after metadata admission, before writing. + reader = GoalSourceReader(source, history_projector=lambda rows, **_: rows) + rows = reader.goals(source.goal_id, fresh=True)["items"] + if source.goal_id is None or not any( + row["id"] == source.goal_id and row.get("registry_member") is True for row in rows + ): + raise SourceScopeError("Choose a registered original Goal") + + def apply(registry): + registry["goal_source_links"] = admitted(registry) + + mutate_project_registry(self.registry_path, operation="goal_source_links", reducer=apply) + return {"ok": True, "goals": linked_source_goals(load_registry(self.registry_path))} + + def remove(self, *, parent_goal_id, goal_id, namespace): + from ..projects.registry_codec import mutate_project_registry + + def apply(registry): + links = [] + for link in registry.get("goal_source_links", []): + source = GoalSource.parse(link["source"]) + if source.goal_id == goal_id and link["parent_goal_id"] == parent_goal_id: + if source.namespace != namespace: + raise SourceScopeError("The source link changed; read its current identity") + else: + links.append(link) + registry["goal_source_links"] = links + linked_source_goals(registry) + + mutate_project_registry(self.registry_path, operation="goal_source_unlink", reducer=apply) + + def read_endpoint(endpoint, operation, *, goal_id=None, cursor="", source_namespace=None): """CLI/Agent client for the same read endpoints consumed by the App adapter.""" url = urlparse(endpoint) diff --git a/loopx/presentation/explore_results_api.py b/loopx/presentation/explore_results_api.py index d1db22b5d3..33c8224dd6 100644 --- a/loopx/presentation/explore_results_api.py +++ b/loopx/presentation/explore_results_api.py @@ -1,6 +1,7 @@ """Loopback read adapter for the canonical Explore result projection.""" from urllib.parse import parse_qs, urlparse +import subprocess from ..control_plane.todos.list_readback import list_goal_todos @@ -60,6 +61,20 @@ def _explore_results(self): query = parse_qs(urlparse(self.path).query) goal_id = query.get("goal_id", [""])[0] try: + from .goal_source_routing import linked_goal_source + resolved = linked_goal_source(self.server, goal_id) + if resolved is not None: + if not self._require_loopback_origin(): + return + if query.get("view", ["history"]) != ["history"]: + self._send_error("Use the original source history.", status=400) + return + row, reader = resolved + if query.get("source_namespace") != [reader.source.namespace]: + self._send_error("Source identity changed; refresh the Goal.", status=409) + return + self._send_json(reader.history(goal_id, query.get("cursor", [""])[0])) + return runtime_root = self._goal_result_scope(goal_id) if runtime_root is None: return @@ -81,5 +96,5 @@ def _explore_results(self): "Result page expired; refresh to read current evidence." if expired else "Explore evidence could not be verified.", status=409, ) - except (OSError, RuntimeError): + except (OSError, RuntimeError, subprocess.SubprocessError): self._send_error("Explore evidence could not be read.", status=503) diff --git a/loopx/presentation/goal_source_routing.py b/loopx/presentation/goal_source_routing.py index 179ef2a70b..62c668192b 100644 --- a/loopx/presentation/goal_source_routing.py +++ b/loopx/presentation/goal_source_routing.py @@ -4,6 +4,99 @@ from ..control_plane.projects.registry_codec import load_project_registry from ..control_plane.runtime.runtime_projection_route import resolve_goal_source_runtime_route +GOAL_SOURCE_LINKS_PATH = "/api/chat/goal-sources" + + +def goal_source_catalog(server): + if not hasattr(server, "registry_path") or not hasattr(server, "runtime_root"): + return None + from ..control_plane.status.goal_source import GoalSourceCatalog + + catalog = getattr(server, "goal_source_catalog", None) + if catalog is None: + from ..capabilities.explore.result_log import explore_history_rows + catalog = GoalSourceCatalog(server.registry_path, server.runtime_root, history_projector=explore_history_rows) + server.goal_source_catalog = catalog + return catalog + + +def linked_goal_source(server, goal_id): + catalog = goal_source_catalog(server) + return catalog.resolve(goal_id) if catalog is not None else None + + +class GoalSourceRequestMixin: + def _todo_detail(self): + import subprocess + from urllib.parse import parse_qs, urlparse + + query = parse_qs(urlparse(self.path).query, keep_blank_values=True) + values = query.get("goal_id", []) + resolved = linked_goal_source(self.server, values[0]) if len(values) == 1 else None + if resolved is None: + if "source_namespace" in query: + self._send_error("Source identity changed; refresh the Goal.", status=409) + return + return super()._todo_detail() + if not self._require_loopback_origin(): + return + if set(query) != {"goal_id", "todo_id", "source_namespace"} or len(query["todo_id"]) != 1: + self._send_error("Choose one original Goal and Task.", status=400) + return + row, reader = resolved + if query["source_namespace"] != [reader.source.namespace]: + self._send_error("Source identity changed; refresh the Goal.", status=409) + return + try: + goal_id, todo_id = values[0], query["todo_id"][0] + item = reader.source.read_todo(goal_id, todo_id).get("todo") + if item is None or item.get("todo_id") != todo_id: + self._send_error("The Task is no longer available in this Goal.", status=404) + return + latest = linked_goal_source(self.server, goal_id) + if latest is None or latest[1].source.namespace != reader.source.namespace: + self._send_error("Source identity changed; refresh the Goal.", status=409) + return + self._send_json({"ok": True, "goal_id": goal_id, "todo_id": todo_id, + "text": item["text"], "status": item["status"], + "archive_state": item.get("archive_state", "active"), + "updated_at": item.get("updated_at"), "source_access": row["source_access"]}) + except (ValueError, TypeError, KeyError): + self._send_error("The source Task could not be verified.", status=409) + except (OSError, RuntimeError, subprocess.SubprocessError): + self._send_error("The original Task is unavailable; retry after reconnecting.", status=503) + + def _goal_source_links(self): + from ..chat_workspace_directory import workspace_goal_directory + from ..registry import load_registry + from urllib.parse import parse_qs, urlparse + import subprocess + + if not self._require_loopback_origin(): + return + try: + if parse_qs(urlparse(self.path).query, keep_blank_values=True): + raise ValueError("Source links use the current workspace registry") + if self.command == "POST": + body = self._read_json(max_bytes=65536) + catalog = goal_source_catalog(self.server) + if set(body) == {"parent_goal_id", "goal_id", "source_namespace"} and all( + isinstance(value, str) and value for value in body.values() + ): + catalog.remove(parent_goal_id=body["parent_goal_id"], goal_id=body["goal_id"], + namespace=body["source_namespace"]) + elif set(body) == {"parent_goal_id", "source"} and isinstance(body["parent_goal_id"], str): + catalog.update(parent_goal_id=body["parent_goal_id"], raw_source=body["source"]) + else: + raise ValueError("Choose an existing parent and one source Goal") + directory = workspace_goal_directory(load_registry(self.server.registry_path)) + self._send_json({"ok": True, "registry_revision": directory["registry_revision"], + "goals": [goal for goal in directory["goals"] if goal.get("source_access")]}) + except (ValueError, TypeError, KeyError): + self._send_error("The original Goal or its parent could not be linked.", status=409) + except (OSError, RuntimeError, subprocess.SubprocessError): + self._send_error("The original Goal source is unavailable; retry after reconnecting.", status=503) + def source_registry_for_goal(invoked: Path, goal_id: str) -> Path: payload = load_project_registry(invoked) diff --git a/loopx/presentation/research_analysis_api.py b/loopx/presentation/research_analysis_api.py index 1a62920144..118e422f4d 100644 --- a/loopx/presentation/research_analysis_api.py +++ b/loopx/presentation/research_analysis_api.py @@ -8,30 +8,54 @@ from ..capabilities.benchmark_toolkit import trajectory_assessment_store as store from ..feedback import validate_goal_id +from .goal_source_routing import goal_source_catalog, linked_goal_source RESEARCH_ANALYSIS_PATH = "/api/chat/research-analysis" class ResearchAnalysisRequestMixin: - def _research_analysis_query(self): + def _research_analysis_query(self, goal_id=None): query = parse_qs(urlparse(self.path).query, keep_blank_values=True) namespace = getattr(self.server, "analysis_source_namespace", None) + if namespace is None: + selected = goal_id or (query.get("goal_id") or [None])[0] + resolved = linked_goal_source(self.server, selected) if selected else None + if resolved is not None: + namespace = resolved[1].source.namespace if namespace is not None and query.pop("source_namespace", []) != [namespace]: raise ValueError("Analysis source identity changed") return query def _research_analysis_verify_source_goal(self, runtime, goal_id, pack_id, pack=None): - if getattr(self.server, "analysis_source_namespace", None) is not None: + linked = linked_goal_source(self.server, goal_id) + if getattr(self.server, "analysis_source_namespace", None) is not None or linked is not None: if pack is None: pack = store.inspect_pack(runtime_root=runtime, owner_goal_id=goal_id, pack_id=pack_id) if pack["scope"]["goal_id"] != goal_id: raise ValueError("The analysis input belongs to a different source Goal") - def _research_analysis_scope(self, goal_id): + def _research_analysis_scope(self, goal_id, *, cancel=False): validate_goal_id(goal_id) selected = getattr(self.server, "selected_goal_id", None) if selected and selected != goal_id: raise ValueError("Analysis is outside this Goal workspace") + import subprocess + + catalog = goal_source_catalog(self.server) + if catalog is not None and catalog.resolve(goal_id) is not None: + if not self._require_loopback_origin(): + return None + values = parse_qs(urlparse(self.path).query, keep_blank_values=True).get("source_namespace", []) + if len(values) != 1: + raise ValueError("Keep the original source identity") + try: + # Stopping this host's existing job needs its pinned local + # scope, not a successful round-trip to the solver host. + return catalog.analysis_scope(goal_id, namespace=values[0], fresh=self.command == "POST", + verify_source=not cancel) + except (OSError, subprocess.SubprocessError): + self._send_error("The original Goal is unavailable; refresh to retry.", status=503) + return None # Reuse the existing registry, loopback and browser-origin checks. return self._goal_result_scope(goal_id) @@ -65,6 +89,8 @@ def _research_analysis_read(self): result = {"ok": True, "catalog": catalog, "pack": pack} if hasattr(self.server, "analysis_execution_enabled"): result["execution_enabled"] = self.server.analysis_execution_enabled + elif linked_goal_source(self.server, goal_id) is not None: + result["execution_enabled"] = True self._send_json(result) except store.PackUnavailable as exc: self._send_error("The registered analysis source is unavailable.", status=409, error_code=exc.reason_code) @@ -85,7 +111,12 @@ def _research_analysis_write(self): raise ValueError("Only registered Goal/pack/key operations are accepted") if not cancel and type(body.get("retry", False)) is not bool: raise ValueError("Retry must be an explicit boolean") - runtime = self._research_analysis_scope(body["goal_id"]) + if getattr(self.server, "analysis_source_namespace", None) is None: + if (linked_goal_source(self.server, body["goal_id"]) is not None + or "source_namespace" in parse_qs(urlparse(self.path).query, keep_blank_values=True)): + if self._research_analysis_query(body["goal_id"]): + raise ValueError("Only the pinned source selector is accepted") + runtime = self._research_analysis_scope(body["goal_id"], cancel=cancel) if runtime is None: return args = dict(runtime_root=runtime, owner_goal_id=body["goal_id"], diff --git a/loopx/presentation/runtime_workspace.py b/loopx/presentation/runtime_workspace.py index 37ff1e4196..e9c3bed7f3 100644 --- a/loopx/presentation/runtime_workspace.py +++ b/loopx/presentation/runtime_workspace.py @@ -103,15 +103,13 @@ def _read_json(self, *, max_bytes): raise ValueError("Invalid analysis request size") return parse_strict_json_object(self.rfile.read(length)) - def _research_analysis_scope(self, goal_id): + def _research_analysis_scope(self, goal_id, *, cancel=False): # Check current access and actual registered membership before local # pack reads or provider effects. No host registry entry is created. try: # Display reads may share their short cache. A provider operation # must check the original source again before admitting any work. - goals = self.server.reader.goals(goal_id, fresh=self.command == "POST")["items"] - if not any(goal["id"] == goal_id and goal.get("registry_member") is True for goal in goals): - raise SourceScopeError("Analysis requires a registered source Goal") + self.server.reader.registered_status(goal_id, fresh=self.command == "POST") except SourceScopeError: self._send_error("Goal or connection is not available in this source.", status=404) return None diff --git a/tests/control_plane_ts/goal_source_links.test.ts b/tests/control_plane_ts/goal_source_links.test.ts new file mode 100644 index 0000000000..a2957cd618 --- /dev/null +++ b/tests/control_plane_ts/goal_source_links.test.ts @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import {projectGoalSourceLinks} from '../../loopx/control_plane/presentation/goal_source_links.ts'; + +const link = () => ({goal_id: 'original-solver', parent_goal_id: 'research-parent', + label: 'Original experiment', namespace: 'a'.repeat(64)}); + +test('an existing source Goal keeps its native id under the local parent', () => { + const result = projectGoalSourceLinks({local_goal_ids: ['research-parent'], links: [link()]}); + assert.deepEqual(result.goals, [{id: 'original-solver', parent_goal_id: 'research-parent', + display_name: 'Original experiment', registry_member: true, activation_state: 'active', + source_access: {label: 'Original experiment', namespace: 'a'.repeat(64), read_only: true}}]); +}); + +test('ambiguous original ids and invalid namespaces are refused', () => { + for (const input of [ + {local_goal_ids: ['research-parent'], links: [link(), {...link(), namespace: 'b'.repeat(64)}]}, + {local_goal_ids: ['research-parent'], links: [{...link(), namespace: 'unverified'}]}, + ]) assert.throws(() => projectGoalSourceLinks(input)); +}); + +test('a removed parent hides only its retained reference', () => { + const other = {...link(), goal_id: 'other-solver', parent_goal_id: 'other-parent', namespace: 'b'.repeat(64)}; + assert.deepEqual(projectGoalSourceLinks({local_goal_ids: ['other-parent'], links: [link(), other]}).goals, + projectGoalSourceLinks({local_goal_ids: ['other-parent'], links: [other]}).goals); +}); + +test('a native local Goal takes precedence over a retained source reference', () => { + assert.deepEqual(projectGoalSourceLinks({local_goal_ids: ['research-parent', 'original-solver'], + links: [link()]}), {goals: []}); +}); + +test('the empty catalog preserves the local-only directory', () => { + assert.deepEqual(projectGoalSourceLinks({local_goal_ids: ['research-parent'], links: []}), {goals: []}); +}); diff --git a/tests/presentation/test_linked_goal_sources.py b/tests/presentation/test_linked_goal_sources.py new file mode 100644 index 0000000000..a6f23f44f3 --- /dev/null +++ b/tests/presentation/test_linked_goal_sources.py @@ -0,0 +1,271 @@ +"""Normal Chat origin -> native Goal source -> local, explicit analysis. + +The source CLI and assessment subprocess are disposable fixtures. No active +solver, model account or installed App is changed by this regression suite. +""" +import copy +from concurrent.futures import ThreadPoolExecutor +import json +import os +from pathlib import Path +import sys +import time +from urllib.error import HTTPError +from urllib.parse import urlencode +from urllib.request import Request, urlopen + +import pytest + +from test_research_analysis_api import workspace as workspace +from loopx.capabilities.benchmark_toolkit.trajectory_assessment_store import register_pack +from loopx.capabilities.explore.result_log import ( + append_explore_result_event, build_explore_edge_event, build_explore_node_event, + explore_history_rows, explore_result_log_path, +) +from loopx.control_plane.status.goal_source import GoalSource + + +def native_source(w): + source_root = w['tmp_path'] / 'original' + source_root.mkdir() + registry, runtime = source_root / 'registry.json', source_root / 'runtime' + state = source_root / 'active.md' + state.write_text('---\nstatus: active\n---\n\n# Original solving Goal\n\n## Agent Todo\n\n' + '- [ ] Check the original question against its evidence\n' + ' \n') + registry.write_text(json.dumps({'schema_version': 1, 'common_runtime_root': str(runtime), 'goals': [ + {'id': 'solver-goal', 'repo': str(source_root), 'state_file': 'active.md', + 'status': 'active', 'domain': 'source-read', 'authority_sources': [], + 'adapter': {'kind': 'test_v0', 'status': 'connected-read-only'}}]})) + log = explore_result_log_path(runtime, 'solver-goal') + for name in ('question', 'attempt'): + append_explore_result_event(log, build_explore_node_event(goal_id='solver-goal', + node_id=name, node_kind='question', title=name, summary='Original native record')) + append_explore_result_event(log, build_explore_edge_event(goal_id='solver-goal', + from_node='attempt', to_node='question', edge_type='depends_on')) + raw = {'id': 'original', 'label': 'Original experiment', + 'connection': {'kind': 'local'}, 'environment': {'kind': 'host'}, + 'scope': {'goal_id': 'solver-goal'}, 'registry': str(registry), 'runtime_root': str(runtime), + 'cli': str(Path(sys.executable).with_name('loopx')), 'python': sys.executable, + 'path': os.environ['PATH']} + w['server'].runtime_root = w['tmp_path'] / 'runtime' + w['server'].selected_goal_id = None + w['server'].scan_roots, w['server'].limit = [], 80 + base = f'http://127.0.0.1:{w["server"].server_port}' + + def request(path, body=None, origin=None): + headers = {'Content-Type': 'application/json'} + if origin: + headers['Origin'] = origin + req = Request(base + path, data=None if body is None else json.dumps(body).encode(), headers=headers) + with urlopen(req, timeout=30) as response: + return json.load(response) + + return raw, request, {path: path.read_bytes() for path in (registry, state, log)} + + +def test_app_parent_directory_reads_original_tasks_history_and_explicit_analysis(workspace): + w = workspace + raw, request, before = native_source(w) + assert request('/api/chat/goal-sources', {'parent_goal_id': 'research-owner', 'source': raw})['ok'] + namespace = GoalSource.parse(raw).namespace + directory = request('/status.json?view=workspace-directory') + child = next(goal for goal in directory['goals'] if goal['id'] == 'solver-goal') + assert child['parent_goal_id'] == 'research-owner' and child['source_access']['read_only'] + assert str(w['tmp_path']) not in json.dumps(child) + snapshot = request('/status.json?goal_id=solver-goal') + assert snapshot['workspace_registry_revision'] == directory['registry_revision'] + actual = snapshot['run_history']['goals'][0] + assert actual['id'] == 'solver-goal' and actual['parent_goal_id'] == 'research-owner' + assert actual['native_parent_goal_id'] is None + task = request('/api/chat/todo/detail?' + urlencode({ + 'goal_id': 'solver-goal', 'todo_id': 'todo_original', 'source_namespace': namespace})) + assert task['text'] == 'Check the original question against its evidence' + assert 'review_basis' not in task + history = request('/api/chat/explore-results?' + urlencode({ + 'goal_id': 'solver-goal', 'source_namespace': namespace, 'view': 'history'})) + assert history['total'] == 3 and any(row.get('edge_type') == 'depends_on' for row in history['items']) + + analysis_runtime = w['server'].runtime_root / 'goal-sources' / namespace + pack = register_pack(runtime_root=analysis_runtime, owner_goal_id='solver-goal', + input_path=w['tmp_path'] / 'input.json', source_root=w['sources'], title='Original source evidence') + query = urlencode({'goal_id': 'solver-goal', 'source_namespace': namespace}) + endpoint = '/api/chat/research-analysis' + initial = request(endpoint + '?' + query) + assert initial['execution_enabled'] and initial['pack']['job'] is None + assert not w['count'].exists() + operation = dict(goal_id='solver-goal', pack_id=pack['pack_id'], analysis_key=pack['analysis_key']) + w['mode'].write_text('ok') + result = request(endpoint + '?source_namespace=' + namespace, operation) + assert result['job']['status'] == 'succeeded' + assert len(w['count'].read_text().splitlines()) == 1 + assert request(endpoint + '?' + query)['pack']['result']['assessments'][0]['verdict'] == 'no' + for path, data in before.items(): + assert path.read_bytes() == data + host_registry = json.loads(w['server'].registry_path.read_text()) + assert [goal['id'] for goal in host_registry['goals']] == ['research-owner', 'other-owner'] + # A new App service reads the persisted relationship, not a browser URL or in-memory mirror. + from loopx.control_plane.status.goal_source import GoalSourceCatalog + w['server'].goal_source_catalog = GoalSourceCatalog(w['server'].registry_path, w['server'].runtime_root, + history_projector=explore_history_rows) + assert request(endpoint + '?' + query)['pack']['job']['status'] == 'succeeded' + assert len(w['count'].read_text().splitlines()) == 1 + bound = w['server'].registry_path.read_bytes() + unlink = dict(parent_goal_id='research-owner', goal_id='solver-goal', source_namespace='f' * 64) + with pytest.raises(HTTPError): + request('/api/chat/goal-sources', unlink) + assert w['server'].registry_path.read_bytes() == bound + request('/api/chat/goal-sources', {**unlink, 'source_namespace': namespace}) + assert not request('/api/chat/goal-sources')['goals'] + with pytest.raises(HTTPError): + request(endpoint + '?source_namespace=' + namespace, operation) + request('/api/chat/goal-sources', {'parent_goal_id': 'research-owner', 'source': raw}) + assert request(endpoint + '?' + query)['pack']['job']['status'] == 'succeeded' + assert len(w['count'].read_text().splitlines()) == 1 + assert all(path.read_bytes() == data for path, data in before.items()) + + +@pytest.mark.parametrize('retry', [False, True]) +@pytest.mark.parametrize('failure', ['removed', 'unavailable']) +def test_app_warm_read_cannot_admit_analysis_after_original_goal_disappears(workspace, retry, failure): + w = workspace + raw, request, before = native_source(w) + request('/api/chat/goal-sources', {'parent_goal_id': 'research-owner', 'source': raw}) + namespace = GoalSource.parse(raw).namespace + query = urlencode({'goal_id': 'solver-goal', 'source_namespace': namespace}) + request('/api/chat/research-analysis?' + query) + source_registry = Path(raw['registry']) + missing = json.loads(source_registry.read_text()) + missing['goals'] = [] + if failure == 'removed': + source_registry.write_text(json.dumps(missing)) + else: + source_registry.unlink() + operation = dict(goal_id='solver-goal', pack_id='a' * 64, analysis_key='b' * 64, retry=retry) + with pytest.raises(HTTPError): + request('/api/chat/research-analysis?source_namespace=' + namespace, operation) + assert not w['count'].exists() + source_registry.write_bytes(before[source_registry]) + assert request('/api/chat/research-analysis?' + query)['catalog']['packs'] == [] + + +def test_app_source_links_reject_other_origins_unknown_parents_and_identity_collisions(workspace): + w = workspace + raw, request, _ = native_source(w) + host = w['server'].registry_path + before = host.read_bytes() + for body, origin in [({'parent_goal_id': 'research-owner', 'source': raw}, 'https://unrelated.example'), + ({'parent_goal_id': 'absent', 'source': raw}, None), + ({'parent_goal_id': 'research-owner', 'source': { + **raw, 'scope': {'goal_id': 'research-owner'}}}, None)]: + with pytest.raises(HTTPError): + request('/api/chat/goal-sources', body, origin) + assert host.read_bytes() == before + request('/api/chat/goal-sources', {'parent_goal_id': 'research-owner', 'source': raw}) + bound = host.read_bytes() + moved = copy.deepcopy(raw) + moved['runtime_root'] += '-different' + with pytest.raises(HTTPError): + request('/api/chat/goal-sources', {'parent_goal_id': 'research-owner', 'source': moved}) + assert host.read_bytes() == bound and not w['count'].exists() + with pytest.raises(HTTPError): + request('/api/chat/research-analysis?goal_id=solver-goal&source_namespace=' + 'f' * 64) + + +def test_original_task_detail_cannot_follow_a_replaced_source(workspace): + raw, request, _ = native_source(workspace) + request('/api/chat/goal-sources', {'parent_goal_id': 'research-owner', 'source': raw}) + old_namespace = GoalSource.parse(raw).namespace + endpoint = '/api/chat/todo/detail?' + query = {'goal_id': 'solver-goal', 'todo_id': 'todo_original'} + with pytest.raises(HTTPError): + request(endpoint + urlencode(query)) + old_url = endpoint + urlencode({**query, 'source_namespace': old_namespace}) + assert request(old_url)['source_access']['namespace'] == old_namespace + request('/api/chat/goal-sources', {'parent_goal_id': 'research-owner', + 'goal_id': 'solver-goal', 'source_namespace': old_namespace}) + with pytest.raises(HTTPError) as removed: + request(old_url) + assert removed.value.code == 409 + replacement = copy.deepcopy(raw) + replacement['id'] = 'replacement' + state = Path(raw['registry']).parent / 'active.md' + state.write_text(state.read_text().replace('Check the original question against its evidence', + 'Check the replacement question against its own evidence')) + request('/api/chat/goal-sources', {'parent_goal_id': 'research-owner', 'source': replacement}) + with pytest.raises(HTTPError) as replaced: + request(old_url) + assert replaced.value.code == 409 + namespace = GoalSource.parse(replacement).namespace + detail = request(endpoint + urlencode({**query, 'source_namespace': namespace})) + assert detail['source_access']['namespace'] == namespace + assert detail['text'] == 'Check the replacement question against its own evidence' + + +def test_analysis_rejects_a_namespace_on_an_unlinked_local_goal(workspace): + _, request, _ = native_source(workspace) + with pytest.raises(HTTPError): + request('/api/chat/research-analysis?source_namespace=' + 'f' * 64, workspace['operation']) + assert not workspace['count'].exists() + + +def test_app_can_cancel_its_local_analysis_while_original_source_is_offline(workspace): + w = workspace + raw, request, before = native_source(w) + request('/api/chat/goal-sources', {'parent_goal_id': 'research-owner', 'source': raw}) + namespace = GoalSource.parse(raw).namespace + pack = register_pack(runtime_root=w['server'].runtime_root / 'goal-sources' / namespace, + owner_goal_id='solver-goal', input_path=w['tmp_path'] / 'input.json', source_root=w['sources'], + title='Fixed original source') + operation = dict(goal_id='solver-goal', pack_id=pack['pack_id'], analysis_key=pack['analysis_key']) + endpoint = '/api/chat/research-analysis?source_namespace=' + namespace + with ThreadPoolExecutor(max_workers=1) as pool: + running = pool.submit(request, endpoint, operation) + until = time.monotonic() + 12 + while not w['count'].exists() and time.monotonic() < until: + time.sleep(.05) + assert w['count'].exists() + source_registry = Path(raw['registry']) + source_registry.unlink() + try: + cancelled = request('/api/chat/research-analysis/cancel?source_namespace=' + namespace, operation) + assert cancelled['job']['cancel_requested'] + assert running.result(timeout=8)['job']['status'] == 'cancelled' + finally: + source_registry.write_bytes(before[source_registry]) + assert len(w['count'].read_text().splitlines()) == 1 + + +def test_removed_parent_does_not_poison_other_local_or_source_goals(workspace): + from loopx.control_plane.projects.registry_codec import mutate_project_registry + + w = workspace + raw, request, _ = native_source(w) + source_registry = Path(raw['registry']) + registry = json.loads(source_registry.read_text()) + (source_registry.parent / 'other.md').write_bytes((source_registry.parent / 'active.md').read_bytes()) + registry['goals'].append({**registry['goals'][0], 'id': 'other-solver', 'state_file': 'other.md'}) + source_registry.write_text(json.dumps(registry)) + other = {**raw, 'id': 'other-source', 'scope': {'goal_id': 'other-solver'}} + request('/api/chat/goal-sources', {'parent_goal_id': 'research-owner', 'source': raw}) + request('/api/chat/goal-sources', {'parent_goal_id': 'other-owner', 'source': other}) + before = source_registry.read_bytes() + # Exercise the registry owner's committed removal, never a source solver write. + mutate_project_registry(w['server'].registry_path, operation='fixture_parent_removal', + reducer=lambda data: data.update(goals=[{**g, 'domain': 'source-read'} + for g in data['goals'] if g['id'] != 'research-owner'])) + directory = request('/status.json?view=workspace-directory') + assert {goal['id'] for goal in directory['goals']} == {'other-owner', 'other-solver'} + local = request('/status.json?goal_id=other-owner') + assert [goal['id'] for goal in local['run_history']['goals']] == ['other-owner'] + assert request('/status.json?goal_id=other-solver')['run_history']['goals'][0]['id'] == 'other-solver' + assert len(request('/api/chat/goal-sources')['goals']) == 1 + retained = json.loads(w['server'].registry_path.read_text()) + assert len(retained['goal_source_links']) == 2 + with pytest.raises(HTTPError): + request('/api/chat/goal-sources', {'parent_goal_id': 'research-owner', 'source': raw}) + assert json.loads(w['server'].registry_path.read_text()) == retained + request('/api/chat/goal-sources', {'parent_goal_id': 'research-owner', 'goal_id': 'solver-goal', + 'source_namespace': GoalSource.parse(raw).namespace}) + assert len(json.loads(w['server'].registry_path.read_text())['goal_source_links']) == 1 + assert source_registry.read_bytes() == before and not w['count'].exists()