From 7cf1aecd5255e33c17428ad94a83dcc7010b73e7 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sat, 7 Nov 2026 19:20:10 +0800 Subject: [PATCH] fix(workspace): read original state for same-named source Goals Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../smoke/research-analysis-read-smoke.mjs | 21 ++++-- apps/presentation/dashboard/src/data/chat.ts | 6 +- .../presentation/dashboard/src/data/status.ts | 2 +- .../src/data/workspace-progressive-status.ts | 2 +- .../personal-workspace/channel-header.tsx | 2 +- .../completed-task-lane.tsx | 5 +- .../goal-explore-results.tsx | 8 +-- .../goal-research-history.tsx | 27 ++++---- .../goal-research-workbench.tsx | 12 ++-- .../goal-workspace-work.tsx | 2 +- .../personal-workspace-model.ts | 2 +- .../personal-workspace-page.tsx | 2 +- docs/development/frontend-delivery.md | 13 +++- loopx/chat/completed_todos.py | 57 +++++++++------- .../control_plane/effect_runtime_handlers.ts | 1 + .../presentation/goal_source_links.ts | 57 ++++++++++++++-- loopx/control_plane/status/goal_source.py | 42 ++++++++---- loopx/presentation/explore_results_api.py | 5 +- loopx/presentation/goal_source_routing.py | 56 ++++++++++++++- loopx/presentation/research_analysis_api.py | 24 ++++--- .../goal_source_links.test.ts | 33 ++++++++- .../presentation/test_linked_goal_sources.py | 68 +++++++++++++++++-- 22 files changed, 343 insertions(+), 104 deletions(-) diff --git a/apps/presentation/dashboard/smoke/research-analysis-read-smoke.mjs b/apps/presentation/dashboard/smoke/research-analysis-read-smoke.mjs index 601948ebba..cfb660366f 100644 --- a/apps/presentation/dashboard/smoke/research-analysis-read-smoke.mjs +++ b/apps/presentation/dashboard/smoke/research-analysis-read-smoke.mjs @@ -10,10 +10,11 @@ import {renderToStaticMarkup} from "react-dom/server"; import {resolveTestPython} from "../../../../scripts/test-python.mjs"; import {readAnalysisSnapshot, ResearchAnalysisPanel} from "../src/features/personal-workspace/goal-research-analysis.tsx"; import {ResearchCaseStudy} from "../src/features/personal-workspace/research-case-study.tsx"; +import {fetchExploreResults} from "../src/data/chat.ts"; import {ResearchGoalNavigation} from "../src/features/personal-workspace/goal-research-workbench.tsx"; -// A case belongs to a source-scoped child Goal. Parent navigation must never -// combine private-source siblings or turn organization into execution. +// The persisted directory can organize explicitly linked physical sources. +// Navigation must not turn organization into shared evidence or execution. const caseFixture = JSON.parse(await readFile(resolve('src/data/fixtures/case-study-child.json'), 'utf8')); const study = caseFixture.analyses[0].case_study; const renderedStudy = renderToStaticMarkup(createElement(ResearchCaseStudy, { @@ -33,9 +34,21 @@ const navigate = goalId => renderToStaticMarkup(createElement(ResearchGoalNaviga goalId, goals, zh:false, onSelect: () => {throw Error('read navigated');}, })); assert.ok(navigate('parent').includes('Local case')); -assert.ok(!navigate('parent').includes('Foreign case')); +assert.ok(navigate('parent').includes('Foreign case')); assert.ok(navigate('child').includes('Benchmark')); -assert.equal(navigate('foreign'), '', 'An absent same-source parent is not a navigation grant'); +assert.ok(navigate('foreign').includes('Benchmark'), 'Explicit directory links retain their local parent'); + +// Transport uses the workspace view; immutable findings still identify the native Goal. +const sourceFetch = globalThis.fetch, sourceWindow = globalThis.window; +try { + globalThis.window = {location: {href: "http://127.0.0.1:12345/chat/"}}; + globalThis.fetch = async input => { + assert.equal(new URL(input, window.location.href).searchParams.get("goal_id"), "view-a"); + return Response.json({ok: true, goal_id: "native", total: 0, items: [], next_cursor: null}); + }; + assert.equal((await fetchExploreResults("view-a", undefined, undefined, "native")).goal_id, "native"); + await assert.rejects(fetchExploreResults("view-a"), /mismatch/); +} finally { globalThis.fetch = sourceFetch; globalThis.window = sourceWindow; } // A language-specific cache miss must not hide the same registered pack's // older, unspecified-language result or turn a read into model execution. diff --git a/apps/presentation/dashboard/src/data/chat.ts b/apps/presentation/dashboard/src/data/chat.ts index dc51d6bb5a..b2138a47c0 100644 --- a/apps/presentation/dashboard/src/data/chat.ts +++ b/apps/presentation/dashboard/src/data/chat.ts @@ -3017,12 +3017,12 @@ const exploreResultPageSchema = z.object({ })), }); export type ExploreResultPage = z.infer; -export async function fetchExploreResults(goalId: string, cursor?: string, readSourceUrl?: string) { +export async function fetchExploreResults(goalId: string, cursor?: string, readSourceUrl?: string, originalGoalId = goalId) { const query = new URLSearchParams({goal_id: goalId, view: "findings", ...(cursor ? {cursor} : {})}); const page = exploreResultPageSchema.parse(await requestJson(sourceReadApiUrl(readSourceUrl, `/api/chat/explore-results?${query}`))); - if (page.goal_id !== goalId) throw new Error("Explore result Goal mismatch"); + if (page.goal_id !== originalGoalId) throw new Error("Explore result Goal mismatch"); for (const item of page.items) if (item.work_yardstick) normalizeWorkYardstickCapture({ - capture: item.work_yardstick, goal_id: goalId, agent_id: item.agent_id, evidence_refs: item.evidence_refs, + capture: item.work_yardstick, goal_id: originalGoalId, agent_id: item.agent_id, evidence_refs: item.evidence_refs, }); return page; } diff --git a/apps/presentation/dashboard/src/data/status.ts b/apps/presentation/dashboard/src/data/status.ts index 60422bca19..8ab9a00136 100644 --- a/apps/presentation/dashboard/src/data/status.ts +++ b/apps/presentation/dashboard/src/data/status.ts @@ -553,7 +553,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(), + source_access: z.object({namespace: z.string(), label: z.string(), read_only: z.literal(true), original_goal_id: z.string().optional()}).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 009e503c4e..5c771f0df4 100644 --- a/apps/presentation/dashboard/src/data/workspace-progressive-status.ts +++ b/apps/presentation/dashboard/src/data/workspace-progressive-status.ts @@ -17,7 +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(), + source_access: z.object({namespace: z.string(), label: z.string(), read_only: z.literal(true), original_goal_id: z.string().optional()}).optional(), display_name: z.string(), activation_state: z.enum(["active", "stopped"]), registry_member: z.literal(true), diff --git a/apps/presentation/dashboard/src/features/personal-workspace/channel-header.tsx b/apps/presentation/dashboard/src/features/personal-workspace/channel-header.tsx index 8780a616de..b1ca0d2a3f 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/channel-header.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/channel-header.tsx @@ -166,7 +166,7 @@ export function ChannelHeader({ const sourceNamedByTitle = selectedGoal?.title === readOnlySourceLabel; const runtimeControl = scopeUnavailable || agentWorkActive ? null : readOnlySourceLabel ? (
-

{t("header.readOnlySourceDescription", { source: readOnlySourceLabel })}

+

{t("header.readOnlySourceDescription", { source: readOnlySourceLabel })}

{selectedGoal?.sourceAccess?.original_goal_id ?

{locale === "zh-CN" ? "原始 Goal:" : "Original Goal: "}{selectedGoal.sourceAccess.original_goal_id}

: null}
{sourceNamedByTitle ? null : {readOnlySourceLabel}} {t("common.readOnly")}
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/completed-task-lane.tsx b/apps/presentation/dashboard/src/features/personal-workspace/completed-task-lane.tsx index 2155a9cd7f..f5ecbcf88a 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/completed-task-lane.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/completed-task-lane.tsx @@ -68,7 +68,7 @@ export function CompletedTaskLane({ goal, agentId, seed, enabled, refreshRevisio setViewport({ top: 0, height: scroll.current?.clientHeight ?? 600 }); } const refreshSnapshot = useEffectEvent(() => resetSnapshot(historySearch)); - useEffect(() => { refreshSnapshot(); }, [refreshRevision]); + useEffect(() => { refreshSnapshot(); }, [refreshRevision, goal.sourceAccess?.namespace]); const needsPage = cursor === undefined || viewport.top + viewport.height >= rows.length * rowHeight - rowHeight * 2; useEffect(() => { if (!enabled || !visible || (!needsPage && !more) || cursor === null || error || request.current) return; @@ -77,6 +77,7 @@ export function CompletedTaskLane({ goal, agentId, seed, enabled, refreshRevisio setBusy(true); setMore(false); const query = new URLSearchParams({ goal_id: goal.goalId }); + if (goal.sourceAccess) query.set("source_namespace", goal.sourceAccess.namespace); if (agentId !== "all") query.set("agent_id", agentId); if (cursor) query.set("cursor", cursor); if (historySearch) query.set("q", historySearch); @@ -93,7 +94,7 @@ export function CompletedTaskLane({ goal, agentId, seed, enabled, refreshRevisio }).catch(() => { if (!controller.signal.aborted) setError(true); }).finally(() => { if (!controller.signal.aborted) { request.current = null; setBusy(false); } }); - }, [enabled, visible, needsPage, cursor, error, retry, goal.goalId, agentId, busy, more, historySearch]); + }, [enabled, visible, needsPage, cursor, error, retry, goal.goalId, goal.sourceAccess?.namespace, agentId, busy, more, historySearch]); useEffect(() => { if (scroll.current) scroll.current.scrollTop = 0; setViewport({ top: 0, height: scroll.current?.clientHeight ?? 600 }); diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-explore-results.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-explore-results.tsx index 590a267d8a..f0ba8725ff 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-explore-results.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-explore-results.tsx @@ -37,7 +37,7 @@ function LinkedFindingTask({goalId, todo, statuses, zh, readSourceUrl}: { } /** Saved evidence remains readable after Explore is disabled. No execution here. */ -export function GoalExploreResults({goalId, zh, active = true, readSourceUrl}: {goalId: string; zh: boolean; active?: boolean; readSourceUrl?: string}) { +export function GoalExploreResults({goalId, originalGoalId = goalId, zh, active = true, readSourceUrl}: {goalId: string; originalGoalId?: string; zh: boolean; active?: boolean; readSourceUrl?: string}) { const [page, setPage] = useState(null); const [selectedId, setSelectedId] = useState(""); const [cursor, setCursor] = useState(); @@ -48,17 +48,17 @@ export function GoalExploreResults({goalId, zh, active = true, readSourceUrl}: { const loadedQuery = useRef(""); useEffect(() => { if (!active) return; - const queryKey = JSON.stringify([goalId, readSourceUrl, cursor, revision]); + const queryKey = JSON.stringify([goalId, originalGoalId, readSourceUrl, cursor, revision]); if (loadedQuery.current === queryKey) return; let current = true; setBusy(true); setError(false); setPage(null); - void fetchExploreResults(goalId, cursor, readSourceUrl).then(next => { + void fetchExploreResults(goalId, cursor, readSourceUrl, originalGoalId).then(next => { if (!current) return; loadedQuery.current = queryKey; setPage(next); setSelectedId(id => next.items.some(item => item.finding_id === id) ? id : next.items[0]?.finding_id ?? ""); }, () => {if (current) setError(true);}).finally(() => {if (current) setBusy(false);}); return () => {current = false;}; - }, [goalId, cursor, revision, active, readSourceUrl]); + }, [goalId, originalGoalId, cursor, revision, active, readSourceUrl]); const selected = page?.items.find(item => item.finding_id === selectedId); const sections = selected ? parseExploreFindingSections(selected.summary) : null; const findingCopy = exploreFindingCopy(zh); diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-history.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-history.tsx index b69b0e27ca..d5e6f710bb 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-history.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-history.tsx @@ -52,18 +52,19 @@ const viewFromUrl = (goal: string) => { /** An effect-free history consumer. Its source is Explore, not raw sessions or * a judge. All recorded updates survive; unsupported analysis stays unavailable. */ -export function GoalResearchHistory({goalId, active, zh, refreshRevision = 0, readSourceUrl}: {goalId: string; active: boolean; zh: boolean; refreshRevision?: number; readSourceUrl?: string}) { +export function GoalResearchHistory({goalId, originalGoalId = goalId, preferHistory = false, active, zh, refreshRevision = 0, readSourceUrl}: {goalId: string; originalGoalId?: string; preferHistory?: boolean; active: boolean; zh: boolean; refreshRevision?: number; readSourceUrl?: string}) { const analysis = useResearchAnalysis(goalId, active, zh, readSourceUrl); const readCaseMode = () => { const query = new URLSearchParams(window.location.search); - return query.get("research_goal") !== goalId || query.get("research_reading") !== "history"; + if (query.get("research_goal") === goalId && query.has("research_reading")) return query.get("research_reading") === "case"; + return !preferHistory; }; const [caseReading, setCaseReading] = useState(readCaseMode); useEffect(() => { const changed = () => setCaseReading(readCaseMode()); changed(); window.addEventListener("popstate", changed); return () => window.removeEventListener("popstate", changed); - }, [goalId, readSourceUrl]); + }, [goalId, preferHistory, readSourceUrl]); const setCaseMode = (reading: boolean) => { const url = new URL(window.location.href); url.searchParams.set("research_goal", goalId); @@ -79,16 +80,16 @@ export function GoalResearchHistory({goalId, active, zh, refreshRevision = 0, re useEffect(() => { if (!read.complete || !display || !displayHashSupported) return; let current = true; - const bytes = new TextEncoder().encode(trajectoryDisplaySnapshotText(read.events, goalId, displayNamespace)); + const bytes = new TextEncoder().encode(trajectoryDisplaySnapshotText(read.events, originalGoalId, displayNamespace)); void crypto.subtle.digest("SHA-256", bytes).then(hash => { if (current) setDisplayIdentity({events: read.events, namespace: displayNamespace, key: Array.from(new Uint8Array(hash), value => value.toString(16).padStart(2, "0")).join("")}); }).catch(() => {if (current) setDisplayIdentity({events: read.events, namespace: displayNamespace, key: ""});}); return () => {current = false;}; - }, [read, goalId, displayNamespace, display?.snapshot_key, displayHashSupported]); + }, [read, originalGoalId, displayNamespace, display?.snapshot_key, displayHashSupported]); const displayKey = displayIdentity?.events === read.events && displayIdentity.namespace === displayNamespace ? displayIdentity.key : ""; const displayCheckFailed = !displayHashSupported || (displayIdentity?.events === read.events && displayIdentity.namespace === displayNamespace && !displayIdentity.key); - const currentDisplay = analysis.pack?.applicability === "current" && analysis.pack.scope.goal_id === goalId + const currentDisplay = analysis.pack?.applicability === "current" && analysis.pack.scope.goal_id === originalGoalId && analysis.pack.result?.analysis_key === analysis.pack.analysis_key && analysis.pack.result.method_version === analysis.pack.method_version && JSON.stringify(analysis.pack.result.scope) === JSON.stringify(analysis.pack.scope) && display?.snapshot_key === displayKey && display.source_namespace === displayNamespace && display.language === (zh ? "zh" : "en") ? display : undefined; @@ -198,7 +199,7 @@ export function GoalResearchHistory({goalId, active, zh, refreshRevision = 0, re let cursor = ""; do { const query = new URLSearchParams({goal_id: goalId, view: "history", ...(cursor ? {cursor} : {})}); - const page = parseExploreHistoryPage(await requestJson(sourceReadApiUrl(readSourceUrl, `/api/chat/explore-results?${query}`), {signal: controller.signal}), goalId); + const page = parseExploreHistoryPage(await requestJson(sourceReadApiUrl(readSourceUrl, `/api/chat/explore-results?${query}`), {signal: controller.signal}), originalGoalId); if (total !== -1 && total !== page.total) throw new Error("Explore history snapshot changed"); total = page.total; events.push(...page.items); if (events.length > total || (page.next_cursor && (!page.items.length || cursors.has(page.next_cursor)))) throw new Error("Invalid Explore history pagination"); @@ -209,7 +210,7 @@ export function GoalResearchHistory({goalId, active, zh, refreshRevision = 0, re } while (cursor && current); if (current) { if (cursor || events.length !== total) throw new Error("Explore history coverage mismatch"); - projectExploreHistory(events, goalId); // Verify replay identities before replacing a valid snapshot. + projectExploreHistory(events, originalGoalId); // Verify replay identities before replacing a valid snapshot. setRead({events, total, complete: true}); } } catch (failure) { @@ -220,14 +221,14 @@ export function GoalResearchHistory({goalId, active, zh, refreshRevision = 0, re } finally {if (current) setBusy(false);} })(); return () => {current = false; controller.abort();}; - }, [goalId, active, revision, refreshRevision, readSourceUrl]); + }, [goalId, originalGoalId, active, revision, refreshRevision, readSourceUrl]); const history = useMemo(() => { try { - const canonical = projectExploreHistory(read.events, goalId); - return {canonical, value: displayProjection.events === read.events ? canonical : projectExploreHistory(displayProjection.events, goalId), error: ""}; + const canonical = projectExploreHistory(read.events, originalGoalId); + return {canonical, value: displayProjection.events === read.events ? canonical : projectExploreHistory(displayProjection.events, originalGoalId), error: ""}; } catch (failure) {return {canonical: null, value: null, error: String(failure)};} - }, [read.events, displayProjection, goalId]); + }, [read.events, displayProjection, originalGoalId]); const value = history.value; const measurementFeedback = value?.measurementFeedback ?? []; const measurementSummary = (nodeId: string) => { @@ -629,7 +630,7 @@ export function GoalResearchHistory({goalId, active, zh, refreshRevision = 0, re {value?.unknownKindCount ?

{zh ? "未识别类型保留于已读记录" : "Unrecognized types remain in loaded records"} · {value.unknownKindCount}

: null} : null} {value?.events.length ?
setRecordsOpen(event.currentTarget.open)}>{zh ? "全部已读记录" : "All loaded records"} · {value.events.length} - {recordsOpen ? ; -/** Parentage organizes research within a source; it never combines evidence or execution scope. */ +/** Parentage organizes explicit source views; it never combines evidence or execution scope. */ export function ResearchGoalNavigation({goalId, goals, zh, onSelect}: { goalId: string; goals: ResearchGoal[]; zh: boolean; onSelect: (goalId: string) => void; }) { const selected = goals.find(g => g.goalId === goalId); if (!selected) return null; - const tree = goalHierarchy(goals.filter(g => g.sourceAccess?.namespace === selected.sourceAccess?.namespace)); + // The directory owns these explicit presentation links. Different source + // namespaces remain isolated by each destination's read transport. + const tree = goalHierarchy(goals); const parentId = tree.parents.get(goalId), parent = parentId ? tree.byId.get(parentId) : undefined; const children = tree.children.get(goalId) ?? []; if (!parent && !children.length) return null; @@ -26,13 +28,15 @@ export function ResearchGoalNavigation({goalId, goals, zh, onSelect}: { /** One read surface for the selected original Goal; opening it never starts research. */ export function GoalResearchWorkbench(props: {goalId: string; active: boolean; zh: boolean; refreshRevision: number; readSourceUrl?: string; goals?: ResearchGoal[]; onSelectGoal?: (goalId: string) => void}) { + const source = props.goals?.find(goal => goal.goalId === props.goalId)?.sourceAccess; const [evidenceOpen, setEvidenceOpen] = useState(false); return
{props.goals && props.onSelectGoal ? : null} - +
setEvidenceOpen(event.currentTarget.open)}> {props.zh ? "阅读已保存的结论" : "Read saved findings"} - {evidenceOpen ? : null} + {evidenceOpen ? : null}
; } diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-workspace-work.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-workspace-work.tsx index cb86cccce3..794ca4ae2c 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-workspace-work.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-workspace-work.tsx @@ -199,7 +199,7 @@ export function GoalWorkspaceWork({goal, digest, items, userTodos, selectedTodoI : null; })} {filter === "all" || filter === "done" ? matchesOwner(id) ? todos.get(id) ?? [] : [])} - enabled={!readOnly && active && (placement === "inline" ? inventoryOpen && !railOpen : railOpen)} refreshRevision={historyRefreshRevision} listView onSelect={select} selectedTodoId={selectedTodoId} query={query}/> : null} + enabled={(!readOnly || Boolean(goal.sourceAccess)) && active && (placement === "inline" ? inventoryOpen && !railOpen : railOpen)} refreshRevision={historyRefreshRevision} listView onSelect={select} selectedTodoId={selectedTodoId} query={query}/> : null} :
{activity.length ? activity.map(item =>
: null; const taskBoard = (active: boolean) => selectedGoal ? (` and remain bound to that original diff --git a/loopx/chat/completed_todos.py b/loopx/chat/completed_todos.py index 612c6c9e27..925bd4ab71 100644 --- a/loopx/chat/completed_todos.py +++ b/loopx/chat/completed_todos.py @@ -77,6 +77,36 @@ def _verify_goal_result_page(*, page, registry_path, runtime_root, goal_id): "unavailable_todo_ids": unavailable_todo_ids} +def completed_todo_rows(payload, *, agent_id="", search=""): + """Existing display projection shared by local and linked native reads.""" + ordered = sorted(enumerate(payload["todos"]), key=lambda pair: (str(pair[1].get("completed_at") or ""), pair[0]), reverse=True) + return [ + { + "todo_id": item["todo_id"], + "text": str(item.get("text") or item.get("title") or ""), + "claimed_by": item.get("claimed_by"), + "evidence": item.get("evidence") or item.get("note") or None, + "priority": item.get("priority"), + "task_class": item.get("task_class"), + "done": item["done"], + "status": item["status"], + "task_domain": item.get("task_domain"), + "completed_at": item.get("completed_at"), + "resume_when": item.get("resume_when"), + "resume_ready": item.get("resume_ready"), + "resume_condition": item.get("resume_condition"), + "completion_validation_sha256": item.get("completion_validation_sha256"), + "completion_validation_revision": item.get("completion_validation_revision"), + "completion_validation_revision_history": item.get("completion_validation_revision_history") or [], + } + for _, item in ordered + if item.get("todo_id") and item.get("task_class") != "continuous_monitor" + and (not agent_id or item.get("claimed_by") == agent_id) + and (not search or search in str(item["todo_id"]).casefold() + or search in str(item.get("text") or item.get("title") or "").casefold()) + ] + + class CompletedTodoPages: page_size = 40 max_snapshots = 8 @@ -227,32 +257,7 @@ def load(): role="agent", status="done", read_scope="completed_history", runtime_root_arg=runtime_root_arg, ) - ordered = sorted(enumerate(payload["todos"]), key=lambda pair: (str(pair[1].get("completed_at") or ""), pair[0]), reverse=True) - return [ - { - "todo_id": item["todo_id"], - "text": str(item.get("text") or item.get("title") or ""), - "claimed_by": item.get("claimed_by"), - "evidence": item.get("evidence") or item.get("note") or None, - "priority": item.get("priority"), - "task_class": item.get("task_class"), - "done": item["done"], - "status": item["status"], - "task_domain": item.get("task_domain"), - "completed_at": item.get("completed_at"), - "resume_when": item.get("resume_when"), - "resume_ready": item.get("resume_ready"), - "resume_condition": item.get("resume_condition"), - "completion_validation_sha256": item.get("completion_validation_sha256"), - "completion_validation_revision": item.get("completion_validation_revision"), - "completion_validation_revision_history": item.get("completion_validation_revision_history") or [], - } - for _, item in ordered - if item.get("todo_id") and item.get("task_class") != "continuous_monitor" - and (not agent_id or item.get("claimed_by") == agent_id) - and (not search or search in str(item["todo_id"]).casefold() - or search in str(item.get("text") or item.get("title") or "").casefold()) - ] + return completed_todo_rows(payload, agent_id=agent_id, search=search) self._send_json(self.server.completed_todo_pages.page( scope=(goal_id, agent_id, search), cursor=cursor, load=load, diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts index d00d347138..3ac138a461 100644 --- a/loopx/control_plane/effect_runtime_handlers.ts +++ b/loopx/control_plane/effect_runtime_handlers.ts @@ -444,6 +444,7 @@ export function createEffectRuntimeHandlers( ["presentation.report_review.plan", lazyHandler(() => import("./presentation/report_review.ts"), ({planReportReview}) => planReportReview)], ["presentation.report_review.project", lazyHandler(() => import("./presentation/report_review.ts"), ({projectReportReviews}) => projectReportReviews)], ["presentation.goal_sources.project", lazyHandler(() => import("./presentation/goal_source_links.ts"), ({projectGoalSourceLinks}) => projectGoalSourceLinks)], + ["presentation.goal_sources.status", lazyHandler(() => import("./presentation/goal_source_links.ts"), ({projectGoalSourceStatus}) => projectGoalSourceStatus)], ["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 index 68daa7adcb..7ae11d5c05 100644 --- a/loopx/control_plane/presentation/goal_source_links.ts +++ b/loopx/control_plane/presentation/goal_source_links.ts @@ -1,5 +1,5 @@ /** Local organization of existing source Goals, never execution registration. */ -import type {JsonObject} from "../effect_program.ts"; +import type {JsonObject, JsonValue} from "../effect_program.ts"; import {BARE_SHA256_PATTERN} from "../content_digest.ts"; import {requireJsonObject, requireNonEmptyString} from "../runtime_decode.ts"; @@ -9,25 +9,70 @@ export function projectGoalSourceLinks(input: JsonObject): JsonObject { } const localIds = new Set(input.local_goal_ids.map(id => requireNonEmptyString(id, "local Goal"))); const seen = new Set(); + const sources = 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"); + const workspaceId = link.workspace_goal_id === undefined ? goalId + : requireNonEmptyString(link.workspace_goal_id, "workspace source Goal"); + if (!/^[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,159}$/.test(workspaceId)) { + throw new Error("Invalid workspace source Goal identity"); + } if (!BARE_SHA256_PATTERN.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)) { + if (!localIds.has(parent)) return []; + if (localIds.has(workspaceId)) { + if (link.workspace_goal_id === undefined) return []; + throw new Error("A source view cannot replace a local Goal"); + } + if (seen.has(workspaceId) || sources.has(namespace)) { 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, + seen.add(workspaceId); + sources.add(namespace); + return [{id: workspaceId, parent_goal_id: parent, display_name: label, registry_member: true, activation_state: "active", - source_access: {namespace, label, read_only: true}}]; + source_access: {namespace, label, read_only: true, + ...(workspaceId === goalId ? {} : {original_goal_id: goalId})}}]; }); return {goals}; } + +/** Route a single source's status through a workspace view. Canonical records, + * prose, evidence references and original Task/run ids are never rewritten. */ +export function projectGoalSourceStatus(input: JsonObject): JsonObject { + const status = requireJsonObject(input.status, "native status"); + const row = requireJsonObject(input.row, "source view"); + const access = requireJsonObject(row.source_access, "source access"); + const workspaceId = requireNonEmptyString(row.id, "workspace Goal"); + const originalId = requireNonEmptyString(access.original_goal_id ?? row.id, "original Goal"); + const history = requireJsonObject(status.run_history, "native history"); + if (!Array.isArray(history.goals) || history.goals.length !== 1 + || requireJsonObject(history.goals[0], "native Goal").id !== originalId) { + throw new Error("The source status must contain exactly its original Goal"); + } + // These are status projection routing fields, not source document contents. + // All children of this status were read from the same qualified namespace. + const route = (value: JsonValue, key = ""): JsonValue => { + if ((key === "goal_id" || key === "goal_filter") && value === originalId) return workspaceId; + if (Array.isArray(value)) return value.map(item => key === "goal_ids" && item === originalId + ? workspaceId : route(item)); + if (value && typeof value === "object") return Object.fromEntries( + Object.entries(value).map(([name, item]) => [name, route(item, name)])); + return value; + }; + const projected = (workspaceId === originalId ? status : route(status)) as JsonObject; + const projectedHistory = requireJsonObject(projected.run_history, "native history"); + const goal = requireJsonObject((projectedHistory.goals as JsonValue[])[0], "native Goal"); + goal.native_parent_goal_id = goal.parent_goal_id ?? null; + goal.id = workspaceId; + goal.parent_goal_id = row.parent_goal_id; + goal.source_access = access; + return projected; +} diff --git a/loopx/control_plane/status/goal_source.py b/loopx/control_plane/status/goal_source.py index 10bc3ceed5..a79afc6e91 100644 --- a/loopx/control_plane/status/goal_source.py +++ b/loopx/control_plane/status/goal_source.py @@ -208,6 +208,15 @@ def read_todo(self, goal_id, todo_id): "--goal-id", goal_id, "--todo-id", todo_id, ])) + def read_completed_todos(self, goal_id): + self.check_access(goal_id) + return json.loads(self._execute([ + self.cli, "--format", "json", "--registry", self.registry, + "--runtime-root", self.runtime_root, "todo", "list", + "--goal-id", goal_id, "--role", "agent", "--status", "done", + "--include-archived", + ])) + def read_events(self, goal_id): """Read JSON objects; the capability-owned projector verifies semantics.""" selected = self.check_access(goal_id) @@ -328,13 +337,16 @@ def linked_source_goals(registry): raise ValueError("Invalid Goal source catalog") sources = [] for link in raw: - if not isinstance(link, dict) or set(link) != {"parent_goal_id", "source"}: + if (not isinstance(link, dict) or not {"parent_goal_id", "source"} <= link.keys() + or link.keys() - {"parent_goal_id", "source", "workspace_goal_id"}): 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}) + "namespace": source.namespace, "label": source.label, + **({"workspace_goal_id": link["workspace_goal_id"]} + if "workspace_goal_id" in link else {})}) from ..effect_runtime import effect_runtime_result from ...registry import registry_goals @@ -366,7 +378,7 @@ def resolve(self, goal_id, registry=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) + if link.get("workspace_goal_id", link["source"]["scope"].get("goal_id")) == goal_id) source = GoalSource.parse(raw) with self._lock: namespaces = {goal["source_access"]["namespace"] for goal in rows} @@ -381,14 +393,14 @@ def status(self, goal_id, registry=None): if resolved is None: return None row, reader = resolved - status = reader.registered_status(goal_id) + original_goal_id = reader.source.goal_id + status = reader.registered_status(original_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 goal["id"] == original_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 + from ..effect_runtime import effect_runtime_result + return effect_runtime_result("presentation.goal_sources.status", {"status": status, "row": row}) def analysis_scope(self, goal_id, *, namespace, fresh, verify_source=True): resolved = self.resolve(goal_id) @@ -398,20 +410,23 @@ def analysis_scope(self, goal_id, *, namespace, fresh, verify_source=True): if namespace != reader.source.namespace: raise SourceScopeError("Analysis source identity changed") if verify_source: - reader.registered_status(goal_id, fresh=fresh) + reader.registered_status(reader.source.goal_id, fresh=fresh) return self.runtime_root / "goal-sources" / row["source_access"]["namespace"] - def update(self, *, parent_goal_id, raw_source): + def update(self, *, parent_goal_id, raw_source, workspace_goal_id=None): 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} + if workspace_goal_id is not None: + link["workspace_goal_id"] = workspace_goal_id + selected = workspace_goal_id if workspace_goal_id is not None else source.goal_id 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 item.get("workspace_goal_id", item["source"]["scope"].get("goal_id")) == selected), None) if existing is not None and existing != link: raise SourceScopeError("The original source identity and parent are already linked") if existing is None: @@ -421,7 +436,7 @@ def proposed(registry): 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): + if not any(row["id"] == selected for row in rows): raise SourceScopeError("Choose an existing local parent") return links @@ -448,7 +463,8 @@ 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 (link.get("workspace_goal_id", 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: diff --git a/loopx/presentation/explore_results_api.py b/loopx/presentation/explore_results_api.py index 22260e89b0..a3f5004331 100644 --- a/loopx/presentation/explore_results_api.py +++ b/loopx/presentation/explore_results_api.py @@ -93,8 +93,9 @@ def _explore_results(self): self._send_error("Source identity changed; refresh the Goal.", status=409) return cursor = query.get("cursor", [""])[0] - page = (reader.history(goal_id, cursor) if view == "history" else - reader.findings(goal_id, cursor, projector=_finding_rows)) + original_goal_id = reader.source.goal_id + page = (reader.history(original_goal_id, cursor) if view == "history" else + reader.findings(original_goal_id, cursor, projector=_finding_rows)) 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) diff --git a/loopx/presentation/goal_source_routing.py b/loopx/presentation/goal_source_routing.py index 42d8a0329f..179d47d187 100644 --- a/loopx/presentation/goal_source_routing.py +++ b/loopx/presentation/goal_source_routing.py @@ -29,6 +29,51 @@ def linked_goal_source(server, goal_id): class GoalSourceRequestMixin: + def _completed_todos(self): + import subprocess + from urllib.parse import parse_qs, urlparse + from ..chat.completed_todos import completed_todo_rows + + 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()._completed_todos() + if not self._require_loopback_origin(): + return + row, reader = resolved + if (query.keys() - {"goal_id", "source_namespace", "agent_id", "q", "cursor"} + or any(len(items) != 1 for items in query.values()) + or query.get("source_namespace") != [reader.source.namespace]): + self._send_error("Source identity changed; refresh the Goal.", status=409) + return + goal_id = values[0] + agent = query.get("agent_id", [""])[0] + search = query.get("q", [""])[0].strip().casefold() + try: + if len(search) > 256: + raise ValueError("history_search_too_long") + reader.registered_status(reader.source.goal_id) + def load(): + payload = reader.source.read_completed_todos(reader.source.goal_id) + if payload.get("ok") is not True: + raise ValueError("Source history unavailable") + return completed_todo_rows(payload, agent_id=agent, search=search) + page = self.server.completed_todo_pages.page( + scope=(reader.source.namespace, goal_id, agent, search), + cursor=query.get("cursor", [""])[0], load=load) + latest = linked_goal_source(self.server, goal_id) + if latest is None or latest[1].source.namespace != reader.source.namespace: + raise ValueError("Source identity changed") + self._send_json(page) + except (ValueError, TypeError, KeyError): + self._send_error("Source history changed; refresh the Goal.", status=409) + except (OSError, RuntimeError, subprocess.SubprocessError): + self._send_error("The original history is unavailable; retry after reconnecting.", status=503) + def _todo_detail(self): import subprocess from urllib.parse import parse_qs, urlparse @@ -52,7 +97,7 @@ def _todo_detail(self): return try: goal_id, todo_id = values[0], query["todo_id"][0] - item = reader.source.read_todo(goal_id, todo_id).get("todo") + item = reader.source.read_todo(reader.source.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 @@ -88,8 +133,13 @@ def _goal_source_links(self): ): 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"]) + elif ({"parent_goal_id", "source"} <= body.keys() + and not body.keys() - {"parent_goal_id", "source", "workspace_goal_id"} + and isinstance(body["parent_goal_id"], str)): + if "workspace_goal_id" in body and not isinstance(body["workspace_goal_id"], str): + raise ValueError("Use a workspace source Goal identity") + catalog.update(parent_goal_id=body["parent_goal_id"], raw_source=body["source"], + workspace_goal_id=body.get("workspace_goal_id")) else: raise ValueError("Choose an existing parent and one source Goal") directory = workspace_goal_directory(load_registry(self.server.registry_path)) diff --git a/loopx/presentation/research_analysis_api.py b/loopx/presentation/research_analysis_api.py index 51a6685ab5..fd63c36ce6 100644 --- a/loopx/presentation/research_analysis_api.py +++ b/loopx/presentation/research_analysis_api.py @@ -15,6 +15,10 @@ class ResearchAnalysisRequestMixin: + def _research_analysis_owner_goal(self, goal_id): + linked = linked_goal_source(self.server, goal_id) + return linked[1].source.goal_id if linked is not None else goal_id + 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) @@ -30,9 +34,10 @@ def _research_analysis_query(self, goal_id=None): def _research_analysis_verify_source_goal(self, runtime, goal_id, pack_id, pack=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: + owner_goal_id = self._research_analysis_owner_goal(goal_id) 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: + pack = store.inspect_pack(runtime_root=runtime, owner_goal_id=owner_goal_id, pack_id=pack_id) + if pack["scope"]["goal_id"] != owner_goal_id: raise ValueError("The analysis input belongs to a different source Goal") def _research_analysis_scope(self, goal_id, *, cancel=False): @@ -79,19 +84,20 @@ def _research_analysis_read(self): runtime = self._research_analysis_scope(goal_id) if runtime is None: return + owner_goal_id = self._research_analysis_owner_goal(goal_id) if "compare_pack_id" in values: if "pack_id" not in values: raise ValueError("A comparison requires two explicit captures") - left = store.inspect_pack(runtime_root=runtime, owner_goal_id=goal_id, + left = store.inspect_pack(runtime_root=runtime, owner_goal_id=owner_goal_id, pack_id=values["pack_id"], output_language=language) - right = store.inspect_pack(runtime_root=runtime, owner_goal_id=goal_id, + right = store.inspect_pack(runtime_root=runtime, owner_goal_id=owner_goal_id, pack_id=values["compare_pack_id"], output_language=language) self._research_analysis_verify_source_goal(runtime, goal_id, values["pack_id"], left) self._research_analysis_verify_source_goal(runtime, goal_id, values["compare_pack_id"], right) result = {"ok": True, "comparison": store.compare_captured_usage(left, right)} elif usage_turn: self._research_analysis_verify_source_goal(runtime, goal_id, values["pack_id"]) - result = store.read_usage_turn(runtime_root=runtime, owner_goal_id=goal_id, + result = store.read_usage_turn(runtime_root=runtime, owner_goal_id=owner_goal_id, output_language=language, **values) elif citation: self._research_analysis_verify_source_goal(runtime, goal_id, values["pack_id"]) @@ -99,12 +105,12 @@ def _research_analysis_read(self): last = int(values.pop("last_line")) if first < 1 or last < first: raise ValueError("Invalid citation range") - result = store.read_citation(runtime_root=runtime, owner_goal_id=goal_id, + result = store.read_citation(runtime_root=runtime, owner_goal_id=owner_goal_id, first_line=first, last_line=last, output_language=language, **values) else: - catalog = store.discover(runtime_root=runtime, owner_goal_id=goal_id, output_language=language) + catalog = store.discover(runtime_root=runtime, owner_goal_id=owner_goal_id, output_language=language) pack_id = values.get("pack_id") or catalog["default_pack_id"] - pack = store.inspect_pack(runtime_root=runtime, owner_goal_id=goal_id, pack_id=pack_id, output_language=language) if pack_id else None + pack = store.inspect_pack(runtime_root=runtime, owner_goal_id=owner_goal_id, pack_id=pack_id, output_language=language) if pack_id else None if pack_id: self._research_analysis_verify_source_goal(runtime, goal_id, pack_id, pack) result = {"ok": True, "catalog": catalog, "pack": pack} @@ -142,7 +148,7 @@ def _research_analysis_write(self): 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"], + args = dict(runtime_root=runtime, owner_goal_id=self._research_analysis_owner_goal(body["goal_id"]), pack_id=body["pack_id"], analysis_key=body["analysis_key"], output_language=body.get("output_language")) self._research_analysis_verify_source_goal(runtime, body["goal_id"], body["pack_id"]) # The server's existing threaded request is the asynchronous host. diff --git a/tests/control_plane_ts/goal_source_links.test.ts b/tests/control_plane_ts/goal_source_links.test.ts index a06e230fa1..8bc40a56a7 100644 --- a/tests/control_plane_ts/goal_source_links.test.ts +++ b/tests/control_plane_ts/goal_source_links.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import {projectGoalSourceLinks} from '../../loopx/control_plane/presentation/goal_source_links.ts'; +import {projectGoalSourceLinks, projectGoalSourceStatus} 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)}); @@ -44,3 +44,34 @@ test('a native local Goal takes precedence over a retained source reference', () test('the empty catalog preserves the local-only directory', () => { assert.deepEqual(projectGoalSourceLinks({local_goal_ids: ['research-parent'], links: []}), {goals: []}); }); + +test('distinct workspace views keep repeated original ids physically scoped', () => { + const links = [{...link(), workspace_goal_id: 'run-a'}, + {...link(), workspace_goal_id: 'run-b', namespace: 'b'.repeat(64)}]; + const result = projectGoalSourceLinks({local_goal_ids: ['research-parent'], links}); + assert.deepEqual((result.goals as {id: string; source_access: {original_goal_id: string}}[]) + .map(goal => [goal.id, goal.source_access.original_goal_id]), + [['run-a', 'original-solver'], ['run-b', 'original-solver']]); + for (const invalid of [ + {...links[1], workspace_goal_id: 'run-a'}, + {...links[1], namespace: links[0].namespace}, + {...links[1], workspace_goal_id: 'research-parent'}, + {...links[1], workspace_goal_id: '../other'}, + ]) assert.throws(() => projectGoalSourceLinks({local_goal_ids: ['research-parent'], links: [links[0], invalid]})); +}); + +test('workspace routing preserves native record content and source bytes', () => { + const status = {goal_filter: 'original-solver', run_history: {goals: [{id: 'original-solver', + parent_goal_id: null, title: 'original-solver'}]}, todo_index: {items: [{goal_id: 'original-solver', + todo_id: 'native-task', text: 'Read original-solver evidence'}]}, + agent_management_projection: {agents: [{goal_ids: ['original-solver'], current_todo: {goal_id: 'original-solver'}}]}}; + const before = structuredClone(status); + const row = (projectGoalSourceLinks({local_goal_ids: ['research-parent'], + links: [{...link(), workspace_goal_id: 'case-a'}]}).goals as object[])[0]; + const result = projectGoalSourceStatus({status, row} as never); + assert.equal((result.todo_index as typeof status.todo_index).items[0].goal_id, 'case-a'); + assert.equal((result.todo_index as typeof status.todo_index).items[0].todo_id, 'native-task'); + assert.equal((result.todo_index as typeof status.todo_index).items[0].text, 'Read original-solver evidence'); + assert.deepEqual(status, before); + assert.throws(() => projectGoalSourceStatus({status: {...status, run_history: {goals: [{id: 'another'}]}}, row} as never)); +}); diff --git a/tests/presentation/test_linked_goal_sources.py b/tests/presentation/test_linked_goal_sources.py index 9b1c37dd05..0884169f22 100644 --- a/tests/presentation/test_linked_goal_sources.py +++ b/tests/presentation/test_linked_goal_sources.py @@ -25,18 +25,18 @@ from loopx.control_plane.status.goal_source import GoalSource -def native_source(w): +def native_source(w, name='original', task_text='Check the original question against its evidence', done=False): from loopx.control_plane.todos.active_state_todo_parser import parse_todo_source from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection from tests.control_plane.canonical_authority_fixture import initialize_canonical_authority - source_root = w['tmp_path'] / 'original' + source_root = w['tmp_path'] / name 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') + f'- [{"x" if done else " "}] {task_text}\n' + f' \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': [], @@ -46,7 +46,7 @@ def native_source(w): todo['schema_version'] = 'todo_item_v0' initialize_canonical_authority(runtime, 'solver-goal', build_todo_runtime_shadow_projection( goal_id='solver-goal', todos=rows['agent'], handoff_mode='soft_claim'), state_path=state, provider='sqlite') - for goal in ('research-owner', 'other-owner'): + for goal in (('research-owner', 'other-owner') if name == 'original' else ()): initialize_canonical_authority(w['tmp_path'] / 'runtime', goal, build_todo_runtime_shadow_projection(goal_id=goal, todos=[], handoff_mode='soft_claim'), state_path=w['tmp_path'] / 'active.md', provider='sqlite') @@ -56,7 +56,7 @@ def native_source(w): 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', + raw = {'id': name, '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, @@ -410,3 +410,59 @@ def unlink_during_read(*args, **kwargs): assert replaced.value.code == 409 assert all(path.read_bytes() == data for path, data in before.items()) assert not w['count'].exists() + + +def test_same_named_native_goals_have_isolated_workspace_views(workspace): + w = workspace + first, request, before = native_source(w) + second, _, other_before = native_source(w, 'second', 'Measure the second experiment', done=True) + for view, raw in [('case-a', first), ('case-b', second)]: + assert request('/api/chat/goal-sources', { + 'parent_goal_id': 'research-owner', 'source': raw, 'workspace_goal_id': view})['ok'] + namespaces = [GoalSource.parse(raw).namespace for raw in (first, second)] + directory = request('/status.json?view=workspace-directory') + children = [goal for goal in directory['goals'] if goal.get('source_access')] + assert [goal['id'] for goal in children] == ['case-a', 'case-b'] + assert all(goal['source_access']['original_goal_id'] == 'solver-goal' for goal in children) + for view, namespace, expected in [('case-a', namespaces[0], 'Check the original question against its evidence'), + ('case-b', namespaces[1], 'Measure the second experiment')]: + status = request('/status.json?goal_id=' + view) + assert status['run_history']['goals'][0]['id'] == view + todo = next(item for item in status['todo_index']['items'] if item['todo_id'] == 'todo_original') + assert todo['goal_id'] == view and todo['text'] == expected + query = urlencode({'goal_id': view, 'source_namespace': namespace}) + assert request('/api/chat/todo/detail?' + query + '&todo_id=todo_original')['text'] == expected + history = request('/api/chat/explore-results?' + query + '&view=history') + assert history['goal_id'] == 'solver-goal' and history['total'] == 3 + assert all(item['goal_id'] == 'solver-goal' for item in history['items']) + assert request('/api/chat/research-analysis?' + query)['catalog']['packs'] == [] + completed = request('/api/chat/completed-todos?' + query) + assert completed['total'] == (1 if view == 'case-b' else 0) + if completed['items']: + assert completed['items'][0]['text'] == expected + assert request('/api/chat/completed-todos?' + query + '&q=absent')['total'] == 0 + with pytest.raises(HTTPError): + request('/api/chat/explore-results?' + urlencode({ + 'goal_id': 'case-a', 'source_namespace': namespaces[1], 'view': 'history'})) + with pytest.raises(HTTPError): + request('/api/chat/completed-todos?' + urlencode({ + 'goal_id': 'case-a', 'source_namespace': namespaces[1]})) + # Packs retain their original Goal identity inside the physical source store. + pack = register_pack(runtime_root=w['tmp_path'] / 'runtime' / 'goal-sources' / namespaces[0], + owner_goal_id='solver-goal', input_path=w['tmp_path'] / 'input.json', + source_root=w['sources'], title='First experiment') + base = '/api/chat/research-analysis?' + first_query = urlencode({'goal_id': 'case-a', 'source_namespace': namespaces[0]}) + assert request(base + first_query)['pack']['pack_id'] == pack['pack_id'] + second_query = urlencode({'goal_id': 'case-b', 'source_namespace': namespaces[1]}) + assert request(base + second_query)['pack'] is None + w['mode'].write_text('ok') + result = request(base + 'source_namespace=' + namespaces[0], dict( + goal_id='case-a', pack_id=pack['pack_id'], analysis_key=pack['analysis_key'])) + assert result['job']['status'] == 'succeeded' + assert request(base + first_query)['pack']['scope']['goal_id'] == 'solver-goal' + request('/api/chat/goal-sources', dict(parent_goal_id='research-owner', goal_id='case-a', + source_namespace=namespaces[0])) + assert [goal['id'] for goal in request('/api/chat/goal-sources')['goals']] == ['case-b'] + assert request('/status.json?goal_id=case-b')['run_history']['goals'][0]['id'] == 'case-b' + assert all(path.read_bytes() == data for path, data in {**before, **other_before}.items())