From c0559413634f0dcdcf5ab48b907bfb8a218a9b6d Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Tue, 10 Nov 2026 13:17:24 +0800 Subject: [PATCH 1/2] feat(research): read the original task from its bound Goal source Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../goal-research-workbench.tsx | 4 + .../goal-source-document.css | 14 ++++ .../goal-source-document.tsx | 70 +++++++++++++++++ docs/development/frontend-delivery.md | 26 +++++++ ...runtime-source-workspace-browser-smoke.mjs | 67 +++++++++++++++- loopx/chat_server.py | 3 +- .../control_plane/effect_runtime_handlers.ts | 2 + .../presentation/goal_source_links.ts | 42 ++++++++++ loopx/control_plane/status/goal_source.py | 76 +++++++++++++++++-- loopx/presentation/goal_source_document.py | 40 ++++++++++ loopx/presentation/goal_source_routing.py | 15 +++- loopx/presentation/runtime_workspace.py | 11 +++ .../goal_source_links.test.ts | 35 ++++++++- tests/presentation/test_goal_source.py | 51 ++++++++++++- .../presentation/test_linked_goal_sources.py | 46 +++++++++++ 15 files changed, 484 insertions(+), 18 deletions(-) create mode 100644 apps/presentation/dashboard/src/features/personal-workspace/goal-source-document.css create mode 100644 apps/presentation/dashboard/src/features/personal-workspace/goal-source-document.tsx create mode 100644 loopx/presentation/goal_source_document.py diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-workbench.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-workbench.tsx index f77d905385..5e4cf6ca9a 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-workbench.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-workbench.tsx @@ -4,6 +4,7 @@ import {GoalExploreResults} from "./goal-explore-results"; import {goalHierarchy} from "./goal-hierarchy"; import type {WorkspaceGoal} from "./personal-workspace-model"; import {ResearchChildGoals, type ResearchChildGoal} from "./research-child-goals"; +import {GoalSourceDocument} from "./goal-source-document"; import './research-case-study.css'; type ResearchGoal = ResearchChildGoal & Pick; @@ -41,6 +42,9 @@ export function GoalResearchWorkbench(props: {goalId: string; active: boolean; z zh={props.zh} onSelect={props.onSelectGoal} directoryUrl={props.directoryReadSourceUrl} active={props.active} refreshRevision={props.refreshRevision}/> : null} {!props.statusNotice ? <> + {props.readSourceUrl && new URL(props.readSourceUrl, window.location.href).searchParams.has("source_namespace") + ? : null} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-source-document.css b/apps/presentation/dashboard/src/features/personal-workspace/goal-source-document.css new file mode 100644 index 0000000000..339d47dc23 --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-source-document.css @@ -0,0 +1,14 @@ +.source-goal-document { margin-block: 0 20px; } +.source-goal-document > summary { display: flex; gap: 8px; align-items: center; width: fit-content; cursor: pointer; min-height: 36px; } +.source-goal-document[open] > summary .source-document-chevron { transform: rotate(90deg); } +.source-goal-document > section { border-block: 1px solid var(--pw-line); padding-block: 20px; margin-top: 8px; min-width: 0; } +.source-goal-document header { display: flex; gap: 12px; align-items: center; } +.source-goal-document header h2 { margin: 0; } +.source-goal-document header > button { margin-left: auto; } +.source-goal-document button { display: inline-flex; align-items: center; justify-content: center; min-height: 44px; min-width: 44px; padding: 8px; border: 1px solid var(--pw-line); border-radius: 6px; background: var(--pw-bg); color: var(--pw-text); font: inherit; cursor: pointer; } +.source-goal-document article { max-width: 80ch; font-size: 16px; line-height: 1.7; overflow-wrap: anywhere; } +.goal-research-workbench .source-goal-document article .personal-md { font-size: var(--text-lead); line-height: var(--leading-prose); } +.source-goal-document article pre, .source-goal-document-provenance pre { max-width: 100%; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; } +.source-goal-document-provenance { margin-top: 24px; font-size: 14px; } +.source-goal-document-provenance dd { margin-inline-start: 0; overflow-wrap: anywhere; } +.source-goal-document-provenance pre { font-size: 14px; line-height: 1.6; } diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-source-document.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-source-document.tsx new file mode 100644 index 0000000000..65c9824db9 --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-source-document.tsx @@ -0,0 +1,70 @@ +import {ChevronRight, FileText, RefreshCw} from "lucide-react"; +import {useEffect, useRef, useState} from "react"; +import {requestJson} from "../../data/chat"; +import {sourceReadApiUrl} from "../../data/local-status-query"; +import {ContextHelp} from "./context-help"; +import {MarkdownText} from "./markdown"; +import "./goal-source-document.css"; + +type Document = {text: string; sha256: string; captured: boolean}; +type Read = {scope: string; document?: Document; error?: string; busy: boolean}; + +/** An optional original-file reader, separate from the Goal's current intent. */ +export function GoalSourceDocument({goalId, originalGoalId, sourceUrl, zh}: { + goalId: string; originalGoalId: string; sourceUrl: string; zh: boolean; +}) { + const namespace = new URL(sourceUrl, window.location.href).searchParams.get("source_namespace"); + const scope = JSON.stringify([goalId, originalGoalId, sourceUrl]); + const details = useRef(null); + const [open, setOpen] = useState(false), [revision, setRevision] = useState(0); + const [read, setRead] = useState({scope, busy: false}); + const current = read.scope === scope ? read : {scope, busy: true}; + useEffect(() => { + if (!open) return; + const controller = new AbortController(); + setRead(previous => ({scope, document: previous.scope === scope ? previous.document : undefined, busy: true})); + const query = new URLSearchParams({goal_id: goalId, source_namespace: namespace ?? ""}); + void requestJson<{ok: boolean; goal_id: string; source: {namespace: string}; document: Document}>( + sourceReadApiUrl(sourceUrl, `/api/chat/goal-source-document?${query}`), {signal: controller.signal}, + ).then(value => { + if (!value.ok || value.goal_id !== originalGoalId || value.source?.namespace !== namespace + || typeof value.document?.text !== "string" || !value.document.text.length + || typeof value.document.captured !== "boolean" + || !/^[a-f0-9]{64}$/.test(value.document.sha256) + || new TextEncoder().encode(value.document.text).length > 128 * 1024) throw new Error("document identity mismatch"); + if (!controller.signal.aborted) setRead({scope, document: value.document, busy: false}); + }).catch(() => { + if (!controller.signal.aborted) setRead(previous => ({scope, + document: previous.scope === scope ? previous.document : undefined, busy: false, + error: zh ? "原始任务文件暂不可读。请恢复此来源或核对文档绑定后重试。" : "The original task file is unavailable. Restore this source or check its document binding, then retry."})); + }); + return () => controller.abort(); + }, [open, revision, scope, goalId, originalGoalId, namespace, sourceUrl, zh]); + return
setOpen(event.currentTarget.open)} + onKeyDown={event => {if (event.key === "Escape") { + event.stopPropagation(); if (details.current) details.current.open = false; + details.current?.querySelector("summary")?.focus(); + }}}> + {zh ? "原始任务" : "Original task"} + {open ?
+
{current.document?.captured ? (zh ? "捕获副本" : "Captured copy") : (zh ? "来源文件" : "Source file")} +

{zh + ? "读取原 Goal 登记的任务文件;它与后来的目标修订和 Agent 策略分别保留。浏览不会执行文档中的命令或启动分析。" + : "The task file declared by the original Goal, kept separately from later objective revisions and Agent strategies. Reading runs no document commands or analysis."}

+ +
+ {current.busy ?

{zh ? "正在读取完整原文…" : "Reading the complete original…"}

: null} + {current.error ?

{current.document ? (zh ? "刷新失败,保留上次读取的原文。" : "Refresh failed; the last original is preserved.") : current.error}

+
: null} + {current.document ? <> +
+
{zh ? "文档来源与版本" : "Document source and version"} + {current.document.captured ?

{zh ? "显示此来源配置的捕获副本,按固定内容版本核对;未重新访问原路径。来源绑定不代表独立核实。" : "Showing this source's configured captured copy, checked against its pinned content version. The original path was not reread; binding does not establish independent verification."}

: null} +
Goal
{originalGoalId}
{zh ? "来源" : "Source"}
{namespace}
SHA-256
{current.document.sha256}
+
{current.document.text}
+
+ : null} +
: null} +
; +} diff --git a/docs/development/frontend-delivery.md b/docs/development/frontend-delivery.md index 79ab85135a..bcf9f56581 100644 --- a/docs/development/frontend-delivery.md +++ b/docs/development/frontend-delivery.md @@ -158,6 +158,32 @@ source-read CLI's `strategies` operation. These reads retain physical namespace and original Goal scope, recheck membership after native history IO, and fail explicitly when complete coverage exceeds the transport's existing bounds. +The optional **Original task** disclosure reads the single +`goal_doc` / `primary_goal_document` declared by the original Goal, separately +from current objective revisions and Agent strategies. It never reconstructs +intent from a display label, a Todo or an analysis. App and prepared readers +use the source CLI's registry owner for declaration reads, including strict +envelopes; the presentation adapter does not decode a parallel registry format. They +share `GET /api/chat/goal-source-document`; prepared readers and their CLI also +support `/api/goal-source/document` and `document`. Reads validate the original +Goal, namespace and declaration again after IO, retain complete UTF-8 text up +to 128 KiB, and fail rather than truncate or substitute another document. + +When a retained source's declared path is no longer accessible, an Agent may +prepare its optional `goal_document_snapshot` within existing source-connection +authority: `{declared_path, path, sha256}` binds one explicit captured file to +that exact declared reference. The source must be scoped to one Goal; paths are +not guessed from basenames. The captured bytes must match the pinned SHA-256. +The reader identifies a configured captured copy in provenance; a binding alone +does not independently verify its lineage. Keep capture proof with the source. +Only this attachment can be updated on an existing link; its connection, Goal +and parent remain fixed, and snapshot admission verifies the declared file and +hash before saving. This attachment does not change physical namespace, original Goal state, +execution authority or existing history/analysis identities. Transport locator +paths stay in the adapter; document text is preserved. Missing files, changed declarations or hashes remain explicit +read failures with retry. Opening, refreshing or closing the disclosure starts +no model or document commands; source switches hide old content immediately. + 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 parent organization and optional workspace routing. Source identity pins diff --git a/examples/personal-workspace-browser/runtime-source-workspace-browser-smoke.mjs b/examples/personal-workspace-browser/runtime-source-workspace-browser-smoke.mjs index 6125fc87a0..062ab5f765 100644 --- a/examples/personal-workspace-browser/runtime-source-workspace-browser-smoke.mjs +++ b/examples/personal-workspace-browser/runtime-source-workspace-browser-smoke.mjs @@ -13,15 +13,26 @@ const output = resolve(root, "output/playwright/runtime-source-workspace"); const temporary = await mkdtemp(resolve(tmpdir(), "loopx-runtime-reader-")); const failFile = resolve(temporary, "unavailable"); const backend = spawn(resolveTestPython(), ["-u", "-c", ` -import json, pathlib, sys, threading +import hashlib, json, pathlib, sys, threading from datetime import datetime, timedelta, timezone from loopx.presentation.runtime_workspace import RuntimeWorkspaceServer -from loopx.control_plane.status.goal_source import GoalSource +from loopx.control_plane.status.goal_source import GoalSource, GoalDocumentSnapshot from loopx.capabilities.explore.result_log import build_explore_node_event, build_explore_edge_event class Source(GoalSource): def __init__(self, name): goal_id = 'other-goal' if name == 'Distinct' else 'same-goal' - super().__init__(name,name,'/registry','/runtime','/cli','/python','/bin','local','host',goal_id) + folder = pathlib.Path(sys.argv[1]).parent / name + folder.mkdir() + document = folder / 'captured.md' + document.write_text('# '+name+' original task\\n\\n## Accepted outcome\\n\\n'+ + 'Compare complete outcomes, including zero, negative evidence and unknown costs. '*45+ + '\\n\\n\\n\\n## Original boundary\\n\\nNo extra execution authority.\\n') + registry = folder / 'registry.json' + declared = '/original/task.md' + registry.write_text(json.dumps(dict(goals=[dict(id=goal_id,repo='/original',authority_sources=[ + dict(kind='goal_doc',role='primary_goal_document',path=declared)])]))) + super().__init__(name,name,str(registry),'/runtime','/cli',sys.executable,'/bin','local','host',goal_id, + goal_document_snapshot=GoalDocumentSnapshot(declared,str(document),hashlib.sha256(document.read_bytes()).hexdigest())) def read_status(self, goal_id=None): self.check_access(goal_id) if pathlib.Path(sys.argv[1]).exists() and self.id == 'Fresh': raise OSError('unavailable fixture') @@ -48,6 +59,9 @@ class Source(GoalSource): adoption_criterion='Independent acceptance.',cost_bound='Existing authorized bound.',minimum_evidence='Original result and complete cost.'), previous_review=dict(lesson='A replan record does not demonstrate adoption.'))))) return dict(ok=True,goal_filter=self.goal_id,goals=[dict(id=self.goal_id,registry_member=True,unique_runs=len(rows),latest_runs=rows)]) + def read_registry_goal(self, goal_id): + self.check_access(goal_id) + return json.loads(pathlib.Path(self.registry).read_text()) def read_events(self, goal_id): self.check_access(goal_id) if self.id == 'Distinct': return [] @@ -81,9 +95,15 @@ try { const url = new URL("/chat/", catalog[0].status_url); url.search = new URLSearchParams({ goalId: "same-goal", view: "overview", statusUrl: process.env.LOOPX_RUNTIME_READER_BEFORE === "1" ? catalog[0].status_url : "/status.json", messageSession: "saved-session", message: "saved-answer" }); - let holdAnalysis = true, failAnalysis = false, releaseAnalysis, readStarted; + let holdAnalysis = process.env.LOOPX_RUNTIME_READER_BEFORE !== "1", failAnalysis = false, releaseAnalysis, readStarted; let analysisWrites = 0; let strategyReads = 0, failStrategies = false; + let documentReads = 0, failDocument = false; + await page.route("**/api/chat/goal-source-document?*", async route => { + assert.equal(route.request().method(),"GET"); documentReads++; + if (failDocument) return route.fulfill({status:503,json:{error:"Synthetic document failure"}}); + return route.continue(); + }); await page.route("**/api/chat/goal-source-strategies?*", async route => { assert.equal(route.request().method(), "GET"); strategyReads++; if (failStrategies) return route.fulfill({status:503,json:{error:"Synthetic source failure"}}); @@ -98,7 +118,11 @@ try { await page.goto(url.toString()); await page.getByRole("heading", { name: "Retained research", exact: true }).waitFor(); if (process.env.LOOPX_RUNTIME_READER_BEFORE === "1") { + await page.locator(".personal-goal-tabs").getByRole("button",{name:"研究",exact:true}).click(); + await page.locator(".research-history-coverage").filter({hasText:"92 / 92"}).waitFor(); await page.screenshot({path:resolve(output,"before-desktop.png")}); + await page.setViewportSize({width:390,height:844}); + await page.screenshot({path:resolve(output,"before-phone.png")}); } else { await page.waitForFunction(() => new URL(location.href).searchParams.get("statusUrl")?.includes("source_namespace")); assert.equal(new URL(page.url()).searchParams.get("messageSession"), "saved-session", "initial alias binding preserves the original answer's Session"); @@ -139,6 +163,28 @@ try { await history.locator(".analysis-read-loading").waitFor({state:"hidden"}); assert.equal(analysisWrites,0,"Read recovery never starts an analysis"); await page.setViewportSize({width:1512,height:982}); + assert.equal(documentReads,0,"Ordinary research never eagerly reads task documents"); + const originalTask = page.locator(".source-goal-document"); + await originalTask.locator(":scope > summary").click(); + await originalTask.getByRole("heading",{name:"Retained original task",exact:true}).waitFor(); + assert.equal(await page.evaluate(()=>window.documentExecuted),undefined,"Original document text cannot execute HTML"); + assert.ok(await originalTask.locator("article p").first().evaluate(element=>parseFloat(getComputedStyle(element).fontSize)>=16)); + await page.screenshot({path:resolve(output,"original-task-desktop.png")}); + await page.setViewportSize({width:390,height:844}); + assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth),false); + await page.screenshot({path:resolve(output,"original-task-phone.png")}); + failDocument = true; + await originalTask.getByRole("button",{name:"刷新原始任务",exact:true}).click(); + await originalTask.getByRole("alert").waitFor(); + await originalTask.getByRole("heading",{name:"Retained original task",exact:true}).waitFor(); + await page.screenshot({path:resolve(output,"original-task-failure-phone.png")}); + failDocument = false; + await originalTask.getByRole("button",{name:"重试读取原始任务",exact:true}).click(); + await originalTask.getByRole("alert").waitFor({state:"hidden"}); + await originalTask.locator("article").focus();await page.keyboard.press("Escape"); + assert.equal(await originalTask.getAttribute("open"),null); + await page.waitForFunction(()=>document.activeElement?.matches('.source-goal-document > summary')); + await page.setViewportSize({width:1512,height:982}); assert.equal(strategyReads,0,"Normal Explore reading does not eagerly read strategy history"); await page.screenshot({path:resolve(output,"strategies-before-desktop.png")}); await page.locator(".research-strategy-entry").click(); @@ -175,6 +221,11 @@ try { await page.getByRole("heading",{name:"Fresh research",exact:true}).waitFor(); assert.equal(new URL(page.url()).searchParams.get("research_state"),null); assert.equal(await strategies.count(),0,"A source switch clears the old strategy reading selection"); + assert.equal(await originalTask.locator("article").count(),0,"A source switch clears the previous document synchronously"); + await originalTask.locator(":scope > summary").click(); + await originalTask.getByRole("heading",{name:"Fresh original task",exact:true}).waitFor(); + assert.equal(await originalTask.getByRole("heading",{name:"Retained original task",exact:true}).count(),0); + await originalTask.locator(":scope > summary").click(); await page.goBack(); await strategies.getByText("Retained original objective 0",{exact:true}).waitFor(); await page.setViewportSize({width:390,height:844}); @@ -319,6 +370,14 @@ try { await page.screenshot({path:resolve(output,"phone.png")}); assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth),false); assert.equal(analysisWrites,0); + await page.evaluate(()=>localStorage.setItem("loopx-pw-locale","en")); + await page.reload(); + await page.getByRole("heading",{name:"Fresh research",exact:true}).waitFor(); + await originalTask.locator(":scope > summary").filter({hasText:"Original task"}).click(); + await originalTask.getByRole("heading",{name:"Fresh original task",exact:true}).waitFor(); + await page.getByRole("region",{name:"Original task file",exact:true}).waitFor(); + await page.screenshot({path:resolve(output,"original-task-phone-en.png")}); + assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth),false); assert.deepEqual(errors,[]); } console.log("runtime source workspace browser smoke: ok"); diff --git a/loopx/chat_server.py b/loopx/chat_server.py index 47d2bd5083..ae9abf8ba3 100644 --- a/loopx/chat_server.py +++ b/loopx/chat_server.py @@ -108,7 +108,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, GOAL_SOURCE_STRATEGIES_PATH, GoalSourceRequestMixin +from .presentation.goal_source_routing import GOAL_SOURCE_LINKS_PATH, GOAL_SOURCE_STRATEGIES_PATH, GOAL_SOURCE_DOCUMENT_PATH, GoalSourceRequestMixin from .chat.todo_detail import TodoDetailRequestMixin from .kiro_cli_goal_mode import KIRO_CLI_BIN from .paths import resolve_runtime_root @@ -1712,6 +1712,7 @@ def do_GET(self) -> None: "/api/chat/request-inventory": self._request_inventory_read, GOAL_SOURCE_LINKS_PATH: self._goal_source_links, GOAL_SOURCE_STRATEGIES_PATH: self._goal_source_strategies, + GOAL_SOURCE_DOCUMENT_PATH: self._goal_source_document, "/api/chat/todo/detail": self._todo_detail, "/api/chat/completed-todos": self._completed_todos, "/api/chat/explore-results": self._explore_results, diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts index b3ccee594a..417951d36c 100644 --- a/loopx/control_plane/effect_runtime_handlers.ts +++ b/loopx/control_plane/effect_runtime_handlers.ts @@ -449,6 +449,8 @@ export function createEffectRuntimeHandlers( ["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.goal_sources.strategies", lazyHandler(() => import("./presentation/goal_source_links.ts"), ({projectGoalSourceStrategies}) => projectGoalSourceStrategies)], + ["presentation.goal_sources.document_plan", lazyHandler(() => import("./presentation/goal_source_links.ts"), ({planGoalSourceDocument}) => planGoalSourceDocument)], + ["presentation.goal_sources.document", lazyHandler(() => import("./presentation/goal_source_links.ts"), ({projectGoalSourceDocument}) => projectGoalSourceDocument)], ["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 5b5253440b..44f8c21099 100644 --- a/loopx/control_plane/presentation/goal_source_links.ts +++ b/loopx/control_plane/presentation/goal_source_links.ts @@ -2,6 +2,48 @@ import {BARE_SHA256_PATTERN} from "../content_digest.ts"; import {canonicalAuthoritySha256} from "../coordination/authority_store_codec.ts"; import {requireJsonObject, requireNonEmptyString, type JsonObject} from "../runtime_decode.ts"; +import {createHash} from "node:crypto"; + +/** Locate a declared document, never reconstruct Goal intent from a title or plan. */ +export function planGoalSourceDocument(input: JsonObject): JsonObject { + const goalId = requireNonEmptyString(input.goal_id, "original Goal"); + const registration = requireJsonObject(input.registration, "source registration"); + if (!Array.isArray(registration.goals) || registration.goals.length !== 1) throw new Error("Choose one registered original Goal"); + const goal = requireJsonObject(registration.goals[0], "original Goal"); + if (goal.id !== goalId || !Array.isArray(goal.authority_sources)) throw new Error("Original Goal declaration is unavailable"); + const documents = goal.authority_sources.map(value => requireJsonObject(value, "declared source")) + .filter(row => row.kind === "goal_doc" && row.role === "primary_goal_document"); + if (documents.length !== 1) throw new Error("Choose one declared primary Goal document"); + const declared = requireNonEmptyString(documents[0].path, "declared document path"); + const repo = typeof goal.repo === "string" ? goal.repo : ""; + let path = declared, expected: string | null = null; + if (input.snapshot !== null && input.snapshot !== undefined) { + const snapshot = requireJsonObject(input.snapshot, "captured document binding"); + if (Object.keys(snapshot).length !== 3 || snapshot.declared_path !== declared + || typeof snapshot.sha256 !== "string" || !BARE_SHA256_PATTERN.test(snapshot.sha256)) { + throw new Error("The captured document must match the original declaration"); + } + path = requireNonEmptyString(snapshot.path, "captured document path"); expected = snapshot.sha256; + } + if ([declared, path, repo].some(value => /[\0\r\n]/.test(value))) throw new Error("Invalid document location"); + return {goal_id: goalId, path, repo, expected_sha256: expected, + registration_digest: canonicalAuthoritySha256(registration)}; +} + +export function projectGoalSourceDocument(input: JsonObject): JsonObject { + const plan = requireJsonObject(input.plan, "document plan"); + const registration = requireJsonObject(input.registration, "source registration"); + if (canonicalAuthoritySha256(registration) !== plan.registration_digest) throw new Error("The original declaration changed during reading"); + const document = requireJsonObject(input.document, "original document"); + const text = document.text; + if (typeof text !== "string" || !text.length || text.includes("\0") || Buffer.byteLength(text, "utf8") > 128 * 1024) { + throw new Error("The complete original document is unavailable"); + } + const digest = createHash("sha256").update(text, "utf8").digest("hex"); + if (plan.expected_sha256 !== null && plan.expected_sha256 !== digest) throw new Error("The captured document changed"); + // Paths remain in the source adapter, not browser payloads or public sinks. + return {document: {text, sha256: digest, captured: plan.expected_sha256 !== null}}; +} export function projectGoalSourceLinks(input: JsonObject): JsonObject { if (!Array.isArray(input.local_goal_ids) || !Array.isArray(input.links) || input.links.length > 32) { diff --git a/loopx/control_plane/status/goal_source.py b/loopx/control_plane/status/goal_source.py index e54e9c1ce4..8e8b0bfbad 100644 --- a/loopx/control_plane/status/goal_source.py +++ b/loopx/control_plane/status/goal_source.py @@ -63,6 +63,19 @@ def _run(argv, *, env=None): return result.stdout.decode("utf-8") +@dataclass(frozen=True) +class GoalDocumentSnapshot: + declared_path: str + path: str + sha256: str + + def __post_init__(self): + _text(self.declared_path, "declared document path") + _text(self.path, "captured document path") + if not isinstance(self.sha256, str) or not BARE_SHA256_PATTERN.fullmatch(self.sha256): + raise ValueError("Use the captured document's exact SHA-256") + + @dataclass(frozen=True) class GoalSource: id: str @@ -77,6 +90,7 @@ class GoalSource: goal_id: str | None host_alias: str | None = None container_id: str | None = None + goal_document_snapshot: GoalDocumentSnapshot | None = None def __post_init__(self): if self.connection not in {"local", "ssh"} or self.environment not in {"host", "container"}: @@ -99,6 +113,11 @@ def __post_init__(self): path_type = Path if self.connection == "local" and self.container_id is None else PurePosixPath if any(not path_type(getattr(self, key)).is_absolute() for key in ("registry", "runtime_root", "cli", "python")): raise ValueError("Source paths must be absolute") + if self.goal_document_snapshot is not None: + if self.goal_id is None or not isinstance(self.goal_document_snapshot, GoalDocumentSnapshot): + raise ValueError("A document snapshot needs one original Goal") + if not path_type(self.goal_document_snapshot.path).is_absolute(): + raise ValueError("Captured document paths must be absolute") @classmethod def parse(cls, raw): @@ -108,6 +127,11 @@ def parse(cls, raw): connection = raw.pop("connection", None) environment = raw.pop("environment", None) scope = raw.pop("scope", None) + document = raw.pop("goal_document_snapshot", None) + if document is not None: + if not isinstance(document, dict) or set(document) != {"declared_path", "path", "sha256"}: + raise ValueError("Use an exact captured document binding") + document = GoalDocumentSnapshot(**document) if not isinstance(connection, dict) or not isinstance(environment, dict): raise ValueError("Declare a connection and an execution environment") if connection.get("kind") == "local" and set(connection) == {"kind"}: @@ -136,12 +160,17 @@ def parse(cls, raw): if set(raw) != expected: raise ValueError("Invalid or missing source manifest fields") return cls(**raw, connection=connection["kind"], environment=environment["kind"], - goal_id=goal_id, host_alias=host_alias, container_id=container_id) + goal_id=goal_id, host_alias=host_alias, container_id=container_id, + goal_document_snapshot=document) @property def namespace(self): # A reused display id cannot retain another authority's cache or cursor. - return sha256(json.dumps(asdict(self), sort_keys=True).encode()).hexdigest() + identity = asdict(self) + # A captured document has its own checked content digest. Adding a + # reading attachment must not rebind the physical Goal or its history. + identity.pop("goal_document_snapshot") + return sha256(json.dumps(identity, sort_keys=True).encode()).hexdigest() def check_scope(self, goal_id=None): """Validate the prepared Goal selection without accessing its connection.""" @@ -208,6 +237,24 @@ def read_todo(self, goal_id, todo_id): "--goal-id", goal_id, "--todo-id", todo_id, ])) + def read_registry_goal(self, goal_id): + selected = self.check_access(goal_id) + if selected is None: + raise SourceScopeError("Choose an original Goal before reading its declaration") + # The native registry owner decodes legacy and strict envelopes and + # enforces their protocol/digest. Do not add a second JSON decoder here. + payload = json.loads(self._execute([ + self.cli, "--registry", self.registry, "--runtime-root", self.runtime_root, + "--format", "json", "registry", + ])) + if payload.get("ok") is not True or not isinstance(payload.get("goals"), list): + raise SourceScopeError("The original declaration is unavailable") + return {"goals": [{"id": goal.get("id"), "repo": goal.get("repo"), "authority_sources": [ + {key: ref.get(key) for key in ("kind", "role", "path")} if isinstance(ref, dict) else None + for ref in goal["authority_sources"] + ] if isinstance(goal.get("authority_sources"), list) else None} + for goal in payload["goals"] if isinstance(goal, dict) and goal.get("id") == selected]} + def read_run_history(self, goal_id): self.check_access(goal_id) # Native history owns index reduction. Its returned coverage is checked @@ -277,7 +324,7 @@ def status(self, goal_id=None, *, fresh=False): def describe(self): return {"ok": True, "source": self.source.describe(), "read_only": True, "scope": {"goal_id": self.source.goal_id} if self.source.goal_id else {"all_registered": True}, - "operations": {"goals": True, "status": True, "history": True, "strategies": True, + "operations": {"goals": True, "status": True, "history": True, "strategies": True, "document": True, "lifecycle": False, "conversation": False, "analysis": False}} def registered_status(self, goal_id, *, fresh=False): @@ -327,6 +374,10 @@ def strategies(self, goal_id): self.registered_status(goal_id, fresh=True) return {"ok": True, "goal_id": goal_id, "source": self.source.describe(), **projected} + def document(self, goal_id): + from ...presentation.goal_source_document import read_goal_document + return read_goal_document(self, goal_id) + def findings(self, goal_id, cursor="", *, projector): """Read saved evidence; this transport has no complete task-list view.""" with self._lock: @@ -407,7 +458,7 @@ def resolve(self, goal_id, registry=None): 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: + if source.namespace not in self._readers or self._readers[source.namespace].source != source: self._readers[source.namespace] = GoalSourceReader(source, history_projector=self._history_projector) reader = self._readers[source.namespace] return row, reader @@ -452,7 +503,14 @@ def proposed(registry): existing = next((item for item in links 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") + # Only a reading attachment may change in place. The original + # connection, Goal and parent must retain their exact contract. + previous, proposed_link = deepcopy(existing), deepcopy(link) + previous["source"].pop("goal_document_snapshot", None) + proposed_link["source"].pop("goal_document_snapshot", None) + if previous != proposed_link: + raise SourceScopeError("The original source identity and parent are already linked") + links[links.index(existing)] = link if existing is None: links.append(link) return links @@ -473,6 +531,8 @@ def admitted(registry): row["id"] == source.goal_id and row.get("registry_member") is True for row in rows ): raise SourceScopeError("Choose a registered original Goal") + if source.goal_document_snapshot is not None: + reader.document(source.goal_id) def apply(registry): registry["goal_source_links"] = admitted(registry) @@ -515,8 +575,8 @@ def read_endpoint(endpoint, operation, *, goal_id=None, cursor="", source_namesp if source_namespace is not None and source_namespace != pinned: raise ValueError("Conflicting source identities") source_namespace = pinned - if operation not in {"describe", "goals", "status", "history", "strategies"}: - raise ValueError("Only describe, goals, status, history and strategies reads are supported") + if operation not in {"describe", "goals", "status", "history", "strategies", "document"}: + raise ValueError("Only describe, goals, status, history, strategies and document reads are supported") base = f"{url.scheme}://{url.netloc}/api/goal-source" opener = build_opener(_NoRedirect()) @@ -563,7 +623,7 @@ def read(path): def main(): parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("operation", choices=("describe", "goals", "status", "history", "strategies")) + parser.add_argument("operation", choices=("describe", "goals", "status", "history", "strategies", "document")) parser.add_argument("--endpoint", required=True, help="Prepared owner-local reader URL") parser.add_argument("--goal-id") parser.add_argument("--cursor", default="") diff --git a/loopx/presentation/goal_source_document.py b/loopx/presentation/goal_source_document.py new file mode 100644 index 0000000000..28769a1ef9 --- /dev/null +++ b/loopx/presentation/goal_source_document.py @@ -0,0 +1,40 @@ +"""Bounded document IO; the presentation owner chooses and verifies the binding.""" +from dataclasses import asdict +import json + +_DOCUMENT = """ +import json, pathlib, sys +path = pathlib.Path(sys.argv[1]) +if not path.is_absolute(): + root = pathlib.Path(sys.argv[2]) + if not root.is_absolute(): raise ValueError('relative document needs its original repository') + path = root / path +with path.open('rb') as f: data = f.read(128 * 1024 + 1) +if len(data) > 128 * 1024: raise ValueError('document too large') +print(json.dumps({'text': data.decode('utf-8')})) +""" + + +def read_goal_document(reader, goal_id): + from ..control_plane.effect_runtime import effect_runtime_result + + if goal_id is None: + raise ValueError("Choose an original Goal before reading its document") + reader.registered_status(goal_id, fresh=True) + source = reader.source + + before = source.read_registry_goal(goal_id) + plan = effect_runtime_result("presentation.goal_sources.document_plan", { + "goal_id": goal_id, "registration": before, + "snapshot": asdict(source.goal_document_snapshot) if source.goal_document_snapshot else None, + }) + source.check_access(goal_id) + document = json.loads(source._execute([ + source.python, "-c", _DOCUMENT, plan["path"], plan["repo"], + ])) + after = source.read_registry_goal(goal_id) + reader.registered_status(goal_id, fresh=True) + projected = effect_runtime_result("presentation.goal_sources.document", { + "plan": plan, "registration": after, "document": document, + }) + return {"ok": True, "goal_id": goal_id, "source": source.describe(), **projected} diff --git a/loopx/presentation/goal_source_routing.py b/loopx/presentation/goal_source_routing.py index 9e9e8594a0..4889efed82 100644 --- a/loopx/presentation/goal_source_routing.py +++ b/loopx/presentation/goal_source_routing.py @@ -6,6 +6,7 @@ GOAL_SOURCE_LINKS_PATH = "/api/chat/goal-sources" GOAL_SOURCE_STRATEGIES_PATH = "/api/chat/goal-source-strategies" +GOAL_SOURCE_DOCUMENT_PATH = "/api/chat/goal-source-document" def goal_source_catalog(server): @@ -31,6 +32,12 @@ def linked_goal_source(server, goal_id): class GoalSourceRequestMixin: def _goal_source_strategies(self): + return self._goal_source_read("strategies") + + def _goal_source_document(self): + return self._goal_source_read("document") + + def _goal_source_read(self, operation): import subprocess from urllib.parse import parse_qs, urlparse @@ -51,14 +58,16 @@ def _goal_source_strategies(self): self._send_error("Source identity changed; refresh the Goal.", status=409) return try: - result = reader.strategies(reader.source.goal_id) + result = getattr(reader, operation)(reader.source.goal_id) latest = linked_goal_source(self.server, goal_id) - if latest is None or latest[1].source.namespace != reader.source.namespace: + if (latest is None or latest[1].source.namespace != reader.source.namespace + or (operation == "document" and latest[1].source != reader.source)): self._send_error("Source identity changed; refresh the Goal.", status=409) return self._send_json(result) except (ValueError, TypeError, KeyError, OSError, RuntimeError, subprocess.SubprocessError): - self._send_error("The original strategy history is unavailable; retry after reconnecting.", status=503) + self._send_error("The original strategy history is unavailable; retry after reconnecting." + if operation == "strategies" else "The original task document is unavailable; check its source binding and retry.", status=503) def _completed_todos(self): import subprocess diff --git a/loopx/presentation/runtime_workspace.py b/loopx/presentation/runtime_workspace.py index 96cce59eeb..a0d918ee36 100644 --- a/loopx/presentation/runtime_workspace.py +++ b/loopx/presentation/runtime_workspace.py @@ -166,6 +166,11 @@ def do_GET(self): self._json({"ok": False, "error": "Choose one original Goal and source."}, 400) else: self._json(self.server.reader.strategies(goal_id)) + elif parsed.path == "/api/goal-source/document": + if query.keys() - {"goal_id", "source_namespace"}: + self._json({"ok": False, "error": "Choose one original Goal and source."}, 400) + else: + self._json(self.server.reader.document(goal_id)) else: self._json({"ok": False, "error": "Read endpoint is not available for this source."}, 404) elif parsed.path == "/status.json": @@ -181,6 +186,12 @@ def do_GET(self): self._json({"ok": False, "error": "Choose one original Goal and source."}, 409) else: self._json(self.server.reader.strategies(goal_id)) + elif parsed.path == "/api/chat/goal-source-document": + if (set(query) != {"goal_id", "source_namespace"} + or query["source_namespace"] != [self.server.source.namespace]): + self._json({"ok": False, "error": "Choose one original Goal and source."}, 409) + else: + self._json(self.server.reader.document(goal_id)) elif parsed.path == "/api/chat/explore-results": from .explore_results_api import _finding_rows diff --git a/tests/control_plane_ts/goal_source_links.test.ts b/tests/control_plane_ts/goal_source_links.test.ts index 57fcdb4e8e..5a0a702f5a 100644 --- a/tests/control_plane_ts/goal_source_links.test.ts +++ b/tests/control_plane_ts/goal_source_links.test.ts @@ -1,6 +1,39 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import {projectGoalSourceLinks, projectGoalSourceStatus, projectGoalSourceStrategies} from '../../loopx/control_plane/presentation/goal_source_links.ts'; +import {planGoalSourceDocument, projectGoalSourceDocument, projectGoalSourceLinks, projectGoalSourceStatus, projectGoalSourceStrategies} from '../../loopx/control_plane/presentation/goal_source_links.ts'; + +const documentRegistration = () => ({goals: [{id: 'original', repo: '/source', authority_sources: [ + {kind: 'goal_doc', role: 'primary_goal_document', path: 'task.md'}, +]}]}); +const abcDigest = 'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad'; + +test('an original document needs an exact declared binding, not a display label or guessed basename', () => { + const registration = documentRegistration(); + const plan = planGoalSourceDocument({goal_id: 'original', registration, + snapshot: {declared_path: 'task.md', path: '/capture/task.md', sha256: abcDigest}}); + assert.deepEqual(projectGoalSourceDocument({plan, registration, document: {text: 'abc'}}), + {document: {text: 'abc', sha256: abcDigest, captured: true}}); + assert.throws(() => projectGoalSourceDocument({plan, registration, document: {text: 'abd'}})); + assert.throws(() => planGoalSourceDocument({goal_id: 'other', registration})); + assert.throws(() => planGoalSourceDocument({goal_id: 'original', registration, + snapshot: {declared_path: 'another/task.md', path: '/capture/task.md', sha256: abcDigest}})); + for (const refs of [[], [...registration.goals[0].authority_sources, ...registration.goals[0].authority_sources]]) { + assert.throws(() => planGoalSourceDocument({goal_id: 'original', registration: {goals: [ + {...registration.goals[0], display_name: 'Use this title instead', authority_sources: refs}, + ]}})); + } +}); + +test('document reads retain whitespace, reject changed declarations and bound complete UTF-8 bytes', () => { + const registration = documentRegistration(), plan = planGoalSourceDocument({goal_id: 'original', registration}); + const text = '\r\n# 原始目标\n\nKeep zero, negative results and `commands` unchanged.\n'; + assert.equal((projectGoalSourceDocument({plan, registration, document: {text}}).document as {text: string}).text, text); + const changed = structuredClone(registration); changed.goals[0].authority_sources[0].path = 'new-task.md'; + assert.throws(() => projectGoalSourceDocument({plan, registration: changed, document: {text}})); + for (const invalid of ['', '\0', '字'.repeat(44000)]) { + assert.throws(() => projectGoalSourceDocument({plan, registration, document: {text: invalid}})); + } +}); const link = () => ({goal_id: 'original-solver', parent_goal_id: 'research-parent', label: 'Original experiment', namespace: 'a'.repeat(64)}); diff --git a/tests/presentation/test_goal_source.py b/tests/presentation/test_goal_source.py index ffd9cfe6c8..99ddd00a91 100644 --- a/tests/presentation/test_goal_source.py +++ b/tests/presentation/test_goal_source.py @@ -7,6 +7,7 @@ import shlex import subprocess import sys +from hashlib import sha256 import pytest @@ -35,6 +36,45 @@ def test_explicit_source_scope_is_required_and_unknown_fields_are_rejected(): GoalSource.parse({key: value for key, value in current.items() if key != "connection"}) +def test_document_attachment_does_not_rebind_existing_source_identity(): + raw = manifest() + source = GoalSource.parse(raw) + legacy_identity = dict(id='source', label='Source', registry='/registry', runtime_root='/runtime', + cli='/bin/loopx', python='/bin/python', path='/bin', connection='local', environment='host', + goal_id='original', host_alias=None, container_id=None) + assert source.namespace == sha256(json.dumps(legacy_identity, sort_keys=True).encode()).hexdigest() + binding = dict(declared_path='/source/task.md', path='/capture/task.md', sha256='a' * 64) + assert GoalSource.parse({**raw, 'goal_document_snapshot': binding}).namespace == source.namespace + for invalid in [{**binding, 'sha256': 'unknown'}, {**binding, 'path': 'relative.md'}, + {**binding, 'extra': True}]: + with pytest.raises(ValueError): + GoalSource.parse({**raw, 'goal_document_snapshot': invalid}) + with pytest.raises(ValueError): + GoalSource.parse({**raw, 'scope': {'all_registered': True}, 'goal_document_snapshot': binding}) + + +def test_document_relative_location_never_falls_back_to_reader_working_directory(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + document = tmp_path / 'task.md' + document.write_text('# A file in the reader directory is not the original declaration\n') + registry = tmp_path / 'registry.json' + goal = {'id': 'original', 'authority_sources': [ + {'kind': 'goal_doc', 'role': 'primary_goal_document', 'path': 'task.md'}]} + registry.write_text(json.dumps({'goals': [goal]})) + source = GoalSource.parse({**manifest(), 'registry': str(registry), 'python': sys.executable}) + monkeypatch.setattr(GoalSource, 'read_status', lambda self, goal_id=None: + {'ok': True, 'run_history': {'goals': [{'id': 'original', 'registry_member': True}]}}) + monkeypatch.setattr(GoalSource, 'read_registry_goal', lambda self, goal_id: + json.loads(registry.read_text())) + reader = GoalSourceReader(source, history_projector=explore_history_rows) + with pytest.raises(subprocess.CalledProcessError): + reader.document('original') + # An absolute declared file needs no guessed repository or process cwd. + goal['authority_sources'][0]['path'] = str(document) + registry.write_text(json.dumps({'goals': [goal]})) + assert reader.document('original')['document']['text'] == document.read_text() + + @pytest.mark.parametrize("connection", ["local", "ssh"]) @pytest.mark.parametrize("environment", ["host", "container"]) def test_connection_and_environment_compose_with_native_read_only_commands(monkeypatch, connection, environment): @@ -47,7 +87,9 @@ def test_connection_and_environment_compose_with_native_read_only_commands(monke def run(argv, *, env=None): commands.append((argv, env)) - return json.dumps({"ok": True, "run_history": {"goals": [{"id": "original"}]}}) + return json.dumps({"ok": True, "run_history": {"goals": [{"id": "original"}]}, + "goals": [{"id": "original", "repo": "/source", "unused": "not projected", "authority_sources": [ + {"kind": "goal_doc", "role": "primary_goal_document", "path": "task.md", "unused": "not projected"}]}]}) monkeypatch.setattr("loopx.control_plane.status.goal_source._run", run) assert source.read_status()["ok"] @@ -62,6 +104,13 @@ def run(argv, *, env=None): assert argv[:2] == ["docker", "exec"] and "b" * 64 in argv assert argv[-4:] == ["json", "status", "--goal-id", "original"] assert argv[argv.index("--registry") + 1] == "/registry with space" + declaration = source.read_registry_goal("original") + assert declaration == {"goals": [{"id": "original", "repo": "/source", "authority_sources": [ + {"kind": "goal_doc", "role": "primary_goal_document", "path": "task.md"}]}]} + registry_argv = commands[-1][0] + if connection == "ssh": + registry_argv = shlex.split(registry_argv[-1]) + assert registry_argv[-1] == "registry", "Use the native registry owner, not a parallel wire decoder" def test_revoked_ssh_alias_invalidates_cached_status_and_pages(monkeypatch): diff --git a/tests/presentation/test_linked_goal_sources.py b/tests/presentation/test_linked_goal_sources.py index 0bbfe5ea3d..2a43662fcf 100644 --- a/tests/presentation/test_linked_goal_sources.py +++ b/tests/presentation/test_linked_goal_sources.py @@ -10,6 +10,7 @@ from pathlib import Path import sys import time +from hashlib import sha256 from urllib.error import HTTPError from urllib.parse import urlencode from urllib.request import Request, urlopen @@ -25,6 +26,51 @@ from loopx.control_plane.status.goal_source import GoalSource +def test_original_document_alias_binding_and_recovery(workspace): + raw, request, protected = native_source(workspace) + registry = Path(raw['registry']) + registered = json.loads(registry.read_text()) + declared = '/source/retained/primary-task.md' + registered['goals'][0]['authority_sources'] = [ + {'kind': 'goal_doc', 'role': 'primary_goal_document', 'path': declared}] + registry.write_text(json.dumps(registered)) + protected[registry] = registry.read_bytes() + document = registry.parent / 'captured-task.md' + original = b'# Original objective\r\n\nKeep the full original task and its constraints.\n' + document.write_bytes(original) + raw['goal_document_snapshot'] = {'declared_path': declared, 'path': str(document), + 'sha256': sha256(original).hexdigest()} + namespace = GoalSource.parse(raw).namespace + request('/api/chat/goal-sources', {'parent_goal_id': 'research-owner', 'source': raw, + 'workspace_goal_id': 'case-alias'}) + path = '/api/chat/goal-source-document?' + urlencode({'goal_id': 'case-alias', 'source_namespace': namespace}) + result = request(path) + assert result['goal_id'] == 'solver-goal' + assert result['source']['namespace'] == namespace + assert result['document'] == {'text': original.decode(), 'sha256': sha256(original).hexdigest(), 'captured': True} + assert 'path' not in result['document'] + document.write_bytes(b'changed after capture') + with pytest.raises(HTTPError) as failed: + request(path) + assert failed.value.code == 503 + document.write_bytes(original) + assert request(path)['document']['text'] == original.decode() + with pytest.raises(HTTPError) as wrong_scope: + request(path.replace(namespace, '0' * 64)) + assert wrong_scope.value.code == 409 + # Updating the attachment keeps physical identity but replaces the cached + # reader, so the original binding cannot silently survive a new capture. + replacement = b'# Another captured original\n' + document.write_bytes(replacement) + raw['goal_document_snapshot']['sha256'] = sha256(replacement).hexdigest() + request('/api/chat/goal-sources', {'parent_goal_id': 'research-owner', 'source': raw, + 'workspace_goal_id': 'case-alias'}) + assert GoalSource.parse(raw).namespace == namespace + assert request(path)['document']['text'] == replacement.decode() + for file, content in protected.items(): + assert file.read_bytes() == content + + def native_source(w, name='original', task_text='Check the original question against its evidence', done=False, task_evidence=None, task_note=None): from loopx.control_plane.todos.active_state_todo_parser import parse_todo_source From fdade144b6d3fecba5678c7dedbabab60ffb16d0 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Tue, 10 Nov 2026 13:22:04 +0800 Subject: [PATCH 2/2] refactor(research): keep original document IO in the source reader Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- loopx/control_plane/status/goal_source.py | 34 ++++++++++++++++-- loopx/presentation/goal_source_document.py | 40 ---------------------- 2 files changed, 32 insertions(+), 42 deletions(-) delete mode 100644 loopx/presentation/goal_source_document.py diff --git a/loopx/control_plane/status/goal_source.py b/loopx/control_plane/status/goal_source.py index 8e8b0bfbad..b4c29bb1ac 100644 --- a/loopx/control_plane/status/goal_source.py +++ b/loopx/control_plane/status/goal_source.py @@ -42,6 +42,17 @@ if len(data) > 16 * 1024 * 1024: raise ValueError('log too large') sys.stdout.buffer.write(data) """ +_READ_DOCUMENT = """ +import json, pathlib, sys +path = pathlib.Path(sys.argv[1]) +if not path.is_absolute(): + root = pathlib.Path(sys.argv[2]) + if not root.is_absolute(): raise ValueError('relative document needs its original repository') + path = root / path +with path.open('rb') as f: data = f.read(128 * 1024 + 1) +if len(data) > 128 * 1024: raise ValueError('document too large') +print(json.dumps({'text': data.decode('utf-8')})) +""" class SourceScopeError(ValueError): @@ -375,8 +386,27 @@ def strategies(self, goal_id): return {"ok": True, "goal_id": goal_id, "source": self.source.describe(), **projected} def document(self, goal_id): - from ...presentation.goal_source_document import read_goal_document - return read_goal_document(self, goal_id) + from ..effect_runtime import effect_runtime_result + + if goal_id is None: + raise ValueError("Choose an original Goal before reading its document") + self.registered_status(goal_id, fresh=True) + source = self.source + before = source.read_registry_goal(goal_id) + plan = effect_runtime_result("presentation.goal_sources.document_plan", { + "goal_id": goal_id, "registration": before, + "snapshot": asdict(source.goal_document_snapshot) if source.goal_document_snapshot else None, + }) + source.check_access(goal_id) + document = json.loads(source._execute([ + source.python, "-c", _READ_DOCUMENT, plan["path"], plan["repo"], + ])) + after = source.read_registry_goal(goal_id) + self.registered_status(goal_id, fresh=True) + projected = effect_runtime_result("presentation.goal_sources.document", { + "plan": plan, "registration": after, "document": document, + }) + return {"ok": True, "goal_id": goal_id, "source": source.describe(), **projected} def findings(self, goal_id, cursor="", *, projector): """Read saved evidence; this transport has no complete task-list view.""" diff --git a/loopx/presentation/goal_source_document.py b/loopx/presentation/goal_source_document.py deleted file mode 100644 index 28769a1ef9..0000000000 --- a/loopx/presentation/goal_source_document.py +++ /dev/null @@ -1,40 +0,0 @@ -"""Bounded document IO; the presentation owner chooses and verifies the binding.""" -from dataclasses import asdict -import json - -_DOCUMENT = """ -import json, pathlib, sys -path = pathlib.Path(sys.argv[1]) -if not path.is_absolute(): - root = pathlib.Path(sys.argv[2]) - if not root.is_absolute(): raise ValueError('relative document needs its original repository') - path = root / path -with path.open('rb') as f: data = f.read(128 * 1024 + 1) -if len(data) > 128 * 1024: raise ValueError('document too large') -print(json.dumps({'text': data.decode('utf-8')})) -""" - - -def read_goal_document(reader, goal_id): - from ..control_plane.effect_runtime import effect_runtime_result - - if goal_id is None: - raise ValueError("Choose an original Goal before reading its document") - reader.registered_status(goal_id, fresh=True) - source = reader.source - - before = source.read_registry_goal(goal_id) - plan = effect_runtime_result("presentation.goal_sources.document_plan", { - "goal_id": goal_id, "registration": before, - "snapshot": asdict(source.goal_document_snapshot) if source.goal_document_snapshot else None, - }) - source.check_access(goal_id) - document = json.loads(source._execute([ - source.python, "-c", _DOCUMENT, plan["path"], plan["repo"], - ])) - after = source.read_registry_goal(goal_id) - reader.registered_status(goal_id, fresh=True) - projected = effect_runtime_result("presentation.goal_sources.document", { - "plan": plan, "registration": after, "document": document, - }) - return {"ok": True, "goal_id": goal_id, "source": source.describe(), **projected}