diff --git a/apps/presentation/dashboard/src/data/chat.ts b/apps/presentation/dashboard/src/data/chat.ts index a0403790c2..94951128ae 100644 --- a/apps/presentation/dashboard/src/data/chat.ts +++ b/apps/presentation/dashboard/src/data/chat.ts @@ -923,6 +923,28 @@ export function readLoopXTeamWork(sessionId: string, operationId: string) { method: "POST", body: JSON.stringify({operation: "read", operation_id: operationId}), }); } +export type ManagedGoalResultRow = { + todo_id: string; title: string; producer_agent_id: string; sha256: string; + content_type: string; size_bytes: number; completed_at?: string | null; +}; +export type ManagedGoalResultPage = { + ok: true; items: ManagedGoalResultRow[]; total: number; next_cursor: string | null; + unavailable_count: number; unavailable_todo_ids: string[]; +}; +export type ManagedGoalResultRead = { + ok: true; goal_id: string; todo_id: string; text: string; + result: {sha256: string; content_type: string; producer_agent_id: string}; +}; +export function fetchManagedGoalResults(goalId: string, cursor?: string) { + const params = new URLSearchParams({goal_id: goalId}); + if (cursor) params.set("cursor", cursor); + return requestJson(`/api/chat/goal-results?${params}`); +} +export function readManagedGoalResult(goalId: string, todoId: string) { + return requestJson( + `/api/chat/goal-results/${encodeURIComponent(todoId)}?goal_id=${encodeURIComponent(goalId)}`, + ); +} // Keep inventory and selected-operation labels consistent; unknown states stay unknown. export function delegationStateLabel(row: {status: string; worker_active?: boolean; recovery_required: boolean | null}, zh: boolean) { if (row.status === "unavailable") return zh ? "无法核验" : "Unavailable"; diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-loopx-mode.css b/apps/presentation/dashboard/src/features/personal-workspace/goal-loopx-mode.css index 81e9943124..d405af22ac 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-loopx-mode.css +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-loopx-mode.css @@ -101,6 +101,7 @@ .goal-team-results p { font-size: 12px; line-height: 1.7; color: var(--pw-muted); } .goal-team-results [role="alert"] { color: var(--pw-red, #b42318); } .goal-team-results button, .goal-team-results select { min-height: 44px; padding: 8px 12px; border: 1px solid var(--pw-line, #ebebeb); border-radius: 6px; background: var(--pw-surface, #fff); color: inherit; font: inherit; cursor: pointer; } +.goal-managed-results > header button { flex-shrink: 0; white-space: nowrap; } .goal-team-results button[aria-pressed="true"] { border-color: var(--pw-text); } .goal-team-results button:disabled { opacity: .5; cursor: default; } .goal-team-results summary { cursor: pointer; padding: 12px 0; font-size: 12px; min-height: 44px; } diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-managed-results.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-managed-results.tsx new file mode 100644 index 0000000000..8164198f0e --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-managed-results.tsx @@ -0,0 +1,115 @@ +import {useEffect, useRef, useState} from "react"; +import {FileText, RefreshCw} from "lucide-react"; +import { + fetchManagedGoalResults, readManagedGoalResult, + type ManagedGoalResultPage, type ManagedGoalResultRow, type ManagedGoalResultRead, +} from "../../data/chat"; +import {TeamArtifactReport, managedReportArtifact} from "./team-artifact-content"; + +/** Goal-scoped local reports; an inventory row never stands in for exact acceptance readback. */ +export function GoalManagedResults({goalId, zh}: {goalId: string; zh: boolean}) { + const [page, setPage] = useState(null); + const [selected, setSelected] = useState<{row: ManagedGoalResultRow; read: ManagedGoalResultRead} | null>(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const generation = useRef(0); + const chosen = useRef<{todoId: string; sha256: string} | null>(null); + const reader = useRef(null); + + useEffect(() => { + chosen.current = null; + void load(); + return () => {generation.current++;}; + }, [goalId]); + + async function read(row: ManagedGoalResultRow, current: number, focus = false) { + const result = await readManagedGoalResult(goalId, row.todo_id); + if (current !== generation.current) return; + if (result.todo_id !== row.todo_id || result.goal_id !== goalId || + result.result.sha256 !== row.sha256 || result.result.producer_agent_id !== row.producer_agent_id) { + throw new Error(zh ? "报告版本或验收已变化" : "Report version or acceptance changed"); + } + chosen.current = {todoId: row.todo_id, sha256: row.sha256}; + setSelected({row, read: result}); + if (focus) window.requestAnimationFrame(() => reader.current?.focus()); + } + + async function load(cursor?: string) { + const current = ++generation.current; + if (cursor) chosen.current = null; + setBusy(true); setError(""); setSelected(null); + try { + const next = await fetchManagedGoalResults(goalId, cursor); + if (current !== generation.current) return; + if (!Array.isArray(next.items) || !Number.isInteger(next.total) || + !Number.isInteger(next.unavailable_count) || + !Array.isArray(next.unavailable_todo_ids) || + next.unavailable_count !== next.unavailable_todo_ids.length || + (next.next_cursor !== null && typeof next.next_cursor !== "string")) { + throw new Error(zh ? "报告列表响应不完整" : "Report inventory response is incomplete"); + } + setPage(next); + const previous = chosen.current; + const row = previous + ? next.items.find(item => item.todo_id === previous.todoId && item.sha256 === previous.sha256) + : next.items[0]; + if (previous && !row) { + setError(zh ? "上次报告已不在当前验收结果中。" : "The previous report is no longer in current accepted results."); + } else if (row) { + await read(row, current); + } + } catch (failure) { + if (current === generation.current) { + setPage(null); + setError(`${zh ? "无法核验报告;旧内容已清除。" : "Cannot verify report; previous content was cleared."} ${String(failure)}`); + } + } finally { + if (current === generation.current) setBusy(false); + } + } + + async function select(row: ManagedGoalResultRow) { + const current = ++generation.current; + chosen.current = {todoId: row.todo_id, sha256: row.sha256}; + setBusy(true); setError(""); setSelected(null); + try {await read(row, current, true);} + catch (failure) { + if (current === generation.current) setError(`${zh ? "报告或验收已变化;旧内容已清除。" : "Report or acceptance changed; previous content was cleared."} ${String(failure)}`); + } finally {if (current === generation.current) setBusy(false);} + } + + const artifact = selected ? managedReportArtifact( + selected.row.content_type, selected.row.sha256, selected.read.text) : null; + return
+

{zh ? "团队报告" : "Team reports"}

+

{zh ? "只有仍能通过当前验收的报告会出现在这里。" : "Only reports that still pass current acceptance appear here."}

+
+ {busy ?

{zh ? "正在核验报告…" : "Verifying reports…"}

: null} + {error ?

{error}

: null} + {page && page.unavailable_count > 0 ?

{zh + ? `本页有 ${page.unavailable_count} 份报告已无法通过当前核验。` + : `${page.unavailable_count} report(s) on this page cannot pass current verification.`}

: null} + {page && !busy && !page.items.length ?

{page.next_cursor + ? (zh ? "本页没有可核验的报告,可继续下一页。" : "No verifiable reports on this page; continue to the next page.") + : (zh ? "暂无可核验的团队报告。" : "No verifiable team reports yet.")}

: null} + {page?.next_cursor && !page.items.length ? : null} + {page && page.items.length > 0 ?
+ + {artifact && selected ?
+ +

{zh ? "验收任务" : "Accepted Todo"}: {selected.row.todo_id}

+
: null} +
: null} +
; +} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/manager-team-result.tsx b/apps/presentation/dashboard/src/features/personal-workspace/manager-team-result.tsx index 8249354adb..bf3e3fe96a 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/manager-team-result.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/manager-team-result.tsx @@ -1,10 +1,13 @@ import {useEffect, useState} from "react"; -import {ChatApiError, fetchChatSessions, fetchLoopXMode, fetchLoopXTeamWork, readLoopXTeamWork} from "../../data/chat"; -import {TeamArtifactReport, isMarkdownArtifact, type TeamArtifact} from "./team-artifact-content"; +import {ChatApiError, fetchChatSessions, fetchLoopXMode, fetchLoopXTeamWork, fetchManagedGoalResults, readLoopXTeamWork, readManagedGoalResult, type ManagedGoalResultRow} from "../../data/chat"; +import {TeamArtifactReport, isMarkdownArtifact, managedReportArtifact, type TeamArtifact} from "./team-artifact-content"; type Readback = {kind: "waiting" | "unavailable" | "multiple"} | { kind: "adopted"; artifact: TeamArtifact; agentId: string; }; +type ManagedReadback = {kind: "waiting" | "unavailable" | "multiple"} | { + kind: "accepted"; artifact: TeamArtifact; agentId: string; title: string; +}; /** * The Todo identities a Goal conversation itself reports work for, or the @@ -21,6 +24,11 @@ const WORK_INDEX_WINDOW_MS = 30_000; const RELATED_SESSION_LIMIT = 8; const workIndexes = new Map(); const workIndexReads = new Map>(); +/** Goal-wide inventory plus the exact Todo ids the server could not verify, so a + * plan scopes its readback to its own ids instead of the whole Goal's health. */ +type ManagedGoalIndex = {readAt: number; rows: ManagedGoalResultRow[]; unavailableTodoIds: Set}; +const managedIndexes = new Map(); +const managedIndexReads = new Map>(); /** A 4xx is the server declining this conversation's team readback: without a * coordinator identity it cannot own delegation work, so it is unrelated rather @@ -154,11 +162,87 @@ async function readAdoptedResult(goalId: string, todoIds: Set, force: bo return adopted.values().next().value ?? {kind: "waiting"}; } +async function collectManagedGoalIndex(goalId: string): Promise { + let cursor: string | undefined; + let total: number | undefined; + const rows: ManagedGoalResultRow[] = []; + const unavailableTodoIds = new Set(); + // Page until the snapshot ends. The previous fixed eight-page budget hid a + // matching report that happened to sort later in the Goal's history. + for (;;) { + const page = await fetchManagedGoalResults(goalId, cursor); + if (!Array.isArray(page.items) || !Number.isInteger(page.total) || page.total < 0 || + (total !== undefined && page.total !== total) || + !Number.isInteger(page.unavailable_count) || page.unavailable_count < 0 || + !Array.isArray(page.unavailable_todo_ids) || + page.unavailable_count !== page.unavailable_todo_ids.length || + (page.next_cursor !== null && (!page.next_cursor || typeof page.next_cursor !== "string"))) { + throw new Error("managed inventory incomplete"); + } + total = page.total; + rows.push(...page.items); + for (const todoId of page.unavailable_todo_ids) { + if (typeof todoId !== "string" || !todoId) throw new Error("managed inventory incomplete"); + unavailableTodoIds.add(todoId); + } + if (!page.next_cursor) return {readAt: Date.now(), rows, unavailableTodoIds}; + if (page.next_cursor === cursor) throw new Error("managed cursor repeated"); + if (page.items.length === 0 && page.unavailable_count === 0) { + throw new Error("managed inventory made no progress"); + } + cursor = page.next_cursor; + } +} + +/** Share the Goal inventory across plan cards; manual refresh always bypasses the short cache. */ +async function readManagedGoalIndex(goalId: string, force: boolean): Promise { + const running = managedIndexReads.get(goalId); + if (running) return running; + const cached = managedIndexes.get(goalId); + if (!force && cached && Date.now() - cached.readAt < WORK_INDEX_WINDOW_MS) return cached; + const read = collectManagedGoalIndex(goalId) + .then(index => {managedIndexes.set(goalId, index); return index;}) + .finally(() => {managedIndexReads.delete(goalId);}); + managedIndexReads.set(goalId, read); + return read; +} + +/** A confirmed plan supplies the only Todo ids that may return to its source conversation. */ +async function readManagedPlanResult(goalId: string, todoIds: Set, force: boolean): Promise { + try { + const index = await readManagedGoalIndex(goalId, force); + // Only a plan-owned unreadable row can hide this plan's report; unrelated + // historical rows stay the server's and the Files view's concern. + for (const todoId of todoIds) { + if (index.unavailableTodoIds.has(todoId)) return {kind: "unavailable"}; + } + const matched = new Map(); + for (const row of index.rows) { + if (!todoIds.has(row.todo_id)) continue; + if (!row.sha256 || !row.producer_agent_id || !row.title) return {kind: "unavailable"}; + const previous = matched.get(row.todo_id); + if (previous && previous.sha256 !== row.sha256) return {kind: "unavailable"}; + matched.set(row.todo_id, row); + } + if (matched.size > 1) return {kind: "multiple"}; + const row = matched.values().next().value; + if (!row) return {kind: "waiting"}; + const read = await readManagedGoalResult(goalId, row.todo_id); + if (read.goal_id !== goalId || read.todo_id !== row.todo_id || + read.result.sha256 !== row.sha256 || read.result.producer_agent_id !== row.producer_agent_id || + read.result.content_type !== row.content_type || typeof read.text !== "string") return {kind: "unavailable"}; + return {kind: "accepted", artifact: managedReportArtifact(row.content_type, row.sha256, read.text), + agentId: row.producer_agent_id, title: row.title}; + } catch { /* A failed inventory or exact read cannot keep an earlier report visible. */ } + return {kind: "unavailable"}; +} + /** Return only an accepted, currently adopted report to the manager conversation. */ export function ManagerTeamResult({goalId, todoIds, zh, onOpenGoalEvidence}: { goalId: string; todoIds: string[]; zh: boolean; onOpenGoalEvidence: (goalId: string) => void; }) { const [result, setResult] = useState<{key: string; readback: Readback} | null>(null); + const [managed, setManaged] = useState<{key: string; readback: ManagedReadback} | null>(null); const [request, setRequest] = useState({count: 0, force: false}); const todoKey = [...todoIds].sort().join(","); // A new read must withdraw the previous accepted report immediately. The @@ -170,6 +254,9 @@ export function ManagerTeamResult({goalId, todoIds, zh, onOpenGoalEvidence}: { void readAdoptedResult(goalId, new Set(todoKey.split(",")), request.force) .then(value => {if (!cancelled) setResult({key, readback: value});}) .catch(() => {if (!cancelled) setResult({key, readback: {kind: "unavailable"}});}); + void readManagedPlanResult(goalId, new Set(todoKey.split(",")), request.force) + .then(value => {if (!cancelled) setManaged({key, readback: value});}) + .catch(() => {if (!cancelled) setManaged({key, readback: {kind: "unavailable"}});}); return () => {cancelled = true;}; }, [goalId, todoKey, key, request.force]); useEffect(() => { @@ -179,16 +266,25 @@ export function ManagerTeamResult({goalId, todoIds, zh, onOpenGoalEvidence}: { return () => window.clearInterval(timer); }, []); const readback = result?.key === key ? result.readback : null; + const managedReadback = managed?.key === key ? managed.readback : null; if (!goalId || !todoKey) return null; - return
+ return
{!readback ?

{zh ? "正在核验团队结果…" : "Verifying team result…"}

: readback.kind === "adopted" ? <>
{zh ? "团队验收结果" : "Team result"}{goalId} · {readback.agentId}
- :

{readback.kind === "unavailable" + : readback.kind === "waiting" && managedReadback?.kind === "accepted" ? null :

{readback.kind === "unavailable" ? (zh ? "团队结果或采用证据无法核验,请到 Goal 查看版本关系。" : "Team result or adoption evidence cannot be verified; inspect versions in the Goal.") : readback.kind === "multiple" ? (zh ? "有多个已验收的下游结果,请到 Goal 选择要采用的结论。" : "Multiple downstream results are accepted; choose the conclusion in the Goal.") : (zh ? "团队任务已分配,尚无可核验的已采用结果。" : "Team work is assigned; no verifiable adopted result yet.")}

} + {managedReadback?.kind === "accepted" ?
+
{zh ? "托管团队报告 · 已验收,采用尚未核验" : "Managed report · Accepted, adoption not verified"} + {managedReadback.agentId}
+ +
: managedReadback?.kind === "multiple" ?

{zh + ? "这次分配已有多份托管报告;请到 Goal 选择结论。" : "This assignment has multiple managed reports; choose a conclusion in the Goal."}

+ : managedReadback?.kind === "unavailable" ?

{zh + ? "托管报告无法核验;旧内容已撤回。" : "Managed report cannot be verified; previous content was withdrawn."}

: null}
diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx index 80ea58cf9a..a5bbf24b08 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx @@ -32,6 +32,7 @@ import { import { ChannelHeader } from "./channel-header"; import { GoalLoopXMode } from "./goal-loopx-mode"; import { GoalTeamResults } from "./goal-team-results"; +import { GoalManagedResults } from "./goal-managed-results"; import { sendLoopXMessage, type LoopXModeSnapshot } from "../../data/chat"; import { ChannelTimeline } from "./channel-timeline"; import { ContextDrawer } from "./context-drawer"; @@ -187,12 +188,16 @@ function GoalOutputsView({ onSelect, reportState, teamSessionId, + goalId, + localResults, }: { active: boolean; items: Array>; onSelect: (selection: WorkspaceDrawerSelection) => void; reportState?: WorkspaceModel["periodicReports"]; teamSessionId?: string; + goalId: string; + localResults: boolean; }) { const { locale, t } = useWorkspaceI18n(); const [teamSnapshot, setTeamSnapshot] = useState(null); @@ -226,7 +231,7 @@ function GoalOutputsView({ {reportState?.error ? (

{t("files.reportLoadFailed")}: {reportState.error}

) : null} - {!reportState?.loading && !reportState?.error && items.length === 0 && !teamConfigured + {!reportState?.loading && !reportState?.error && items.length === 0 && !teamConfigured && !localResults && (!teamSessionId || Boolean(teamSnapshot)) ? (

{t("files.empty")}

) : null} @@ -242,6 +247,7 @@ function GoalOutputsView({ ].filter(Boolean).join(" · ")} ))} + {localResults ? : null}
{active && teamSessionId && !teamSnapshot && !teamError ?

{t("files.checkingTeam")}

: null} {active && teamSessionId && teamError ?

{t("files.teamLoadFailed")}

: null} @@ -2030,6 +2036,8 @@ export function PersonalWorkspacePage({ onSelect={setSelection} reportState={model.periodicReports} teamSessionId={!readOnly && selectedAgentId === "codex" ? conversationSessionId : undefined} + goalId={selectedGoal.goalId} + localResults={!readOnly && selectedGoalTab === "files"} />), chat: (<> {selectedGoal && activeSessionRun?.goalId === selectedGoal.goalId ? ( diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css index d759b8335e..31531524a0 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace.css @@ -683,6 +683,10 @@ .personal-manager-team-result-actions button { padding: 4px 0; border: 0; background: none; color: var(--pw-link, #0070f3); cursor: pointer; font-size: 12px; } .personal-manager-team-result .goal-team-report { max-height: 280px; overflow: auto; } .personal-manager-team-result .goal-team-artifact { min-width: 0; } +.personal-manager-managed-report { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--pw-line); } +.personal-manager-managed-report > header { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 4px 12px; } +.personal-manager-managed-report > header strong { font-size: 13px; font-weight: 600; } +.personal-manager-managed-report > header small { color: var(--pw-muted); font-size: 11px; } .personal-gated-summary { border: 1px solid #ead39c; border-radius: 14px; background: #fffaf0; } .personal-gated-summary > summary { display: flex; align-items: center; gap: 9px; padding: 12px 14px; color: #6d5620; cursor: pointer; list-style: none; } .personal-gated-summary > summary::-webkit-details-marker { display: none; } diff --git a/apps/presentation/dashboard/src/features/personal-workspace/team-artifact-content.tsx b/apps/presentation/dashboard/src/features/personal-workspace/team-artifact-content.tsx index c35af1ff35..fbb71b6fb6 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/team-artifact-content.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/team-artifact-content.tsx @@ -5,6 +5,13 @@ import {MarkdownText} from "./markdown.js"; export type TeamArtifact = NonNullable[number]; export const isMarkdownArtifact = (ref: string) => /\.(md|markdown)$/i.test(ref); +/** The managed result API carries a content type rather than an artifact filename. */ +export function managedReportArtifact(contentType: string, sha256: string, text: string): TeamArtifact { + const ref = contentType === "text/markdown" ? "accepted-report.md" + : contentType === "application/json" ? "accepted-report.json" : "accepted-report.txt"; + return {ref, sha256, text}; +} + /** Evidence stays inert: no HTML interpretation, embedded images or remote fetches. */ export function TeamArtifactContent({artifact, label, raw = false}: {artifact: TeamArtifact; label: string; raw?: boolean}) { if (!raw && isMarkdownArtifact(artifact.ref)) return
diff --git a/docs/architecture/rfcs/loopx-overall-roadmap-v0.md b/docs/architecture/rfcs/loopx-overall-roadmap-v0.md index b69ef61bc0..fe2c4b8550 100644 --- a/docs/architecture/rfcs/loopx-overall-roadmap-v0.md +++ b/docs/architecture/rfcs/loopx-overall-roadmap-v0.md @@ -483,6 +483,15 @@ desktop/mobile checks pass on the proposed head; maintainer review and CI remain open. Return to the original requester conversation, mixed-team continuation and stop/recovery remain separate unqualified outcomes. +The proposed managed-result readback binds a current accepted Todo report to +canonical completion and its exact digest. Goal Files can open it, and a +manager conversation can show one report only when its confirmed team-plan +receipt names that Todo. Multiple matching reports require selection in the +Goal; failed, stale or cross-Goal reads withdraw the text. This is a report +return, not requester adoption or a synthesized final answer. The original +coordinator still needs a real continuation and explicit adoption over the +accepted result before the one-action research journey is qualified. + **Live team experience is a core S5 outcome.** The [Live Team Workspace RFC](live-team-workspace-v0.md) joins a precise command surface with a spatial research studio: visible artifact exchange, sourced disagreement, conclusion diff --git a/docs/architecture/rfcs/loopx-overall-roadmap-v0.zh-CN.md b/docs/architecture/rfcs/loopx-overall-roadmap-v0.zh-CN.md index 298aa45125..581fdf06e0 100644 --- a/docs/architecture/rfcs/loopx-overall-roadmap-v0.zh-CN.md +++ b/docs/architecture/rfcs/loopx-overall-roadmap-v0.zh-CN.md @@ -406,6 +406,12 @@ CLI 重新核验,打包 Goal「文件」读取按 Goal 限定的本机回环 提议版本已通过本地 File/SQLite 和桌面 / 手机的针对性检查;维护者评审与 CI 仍待完成。向原请求方对话回送、混合团队持续协作及整队停止 / 恢复仍需另行验收。 +待合入的托管结果读回将当前已验收的 Todo 报告绑定到 canonical 完成事实和精确摘要。 +Goal 成果页可打开正文;原管家对话仅在已确认团队计划的回执明确包含该 Todo, +且恰好有一份匹配报告时显示。多份报告留在 Goal 内供选择;失败、过期或跨 Goal +读回会撤下正文。这证明报告返回,不证明请求方采用或最终综合答案。要验收一键 +投研旅程,原协调员还须在真实执行中继续推进并显式采用已验收结果。 + **团队现场是 S5 核心产品目标。** [团队实时工作区 RFC](live-team-workspace-v0.zh-CN.md) 融合精确指挥台与空间研究工作室:展示产物交换、有来源的分歧、结论修订、回放与 语义缩放。它复用既有 owner,不建立第二套编排。下一完整前端切片在打包工作区 diff --git a/examples/managed-research-team/README.md b/examples/managed-research-team/README.md index dd75ce46b5..d04a5e2c38 100644 --- a/examples/managed-research-team/README.md +++ b/examples/managed-research-team/README.md @@ -185,9 +185,19 @@ After reading all configured canonical completions and exact artifact hashes, th lead writes `lead/report.json` with the fields described by `scenario.py` and the acceptance table below. Run `validate-report`, then complete the report through ordinary `todo complete --todo-id todo_lead-report --agent-id lead ---no-follow-up` against this disposable registry/runtime. That command reruns -the bound validator. Retain the original conversation; preparation does not -attach, resume, migrate or impersonate any existing production Agent. +--no-follow-up --result-file "$DEMO_ROOT/lead/report.json"` against this disposable +registry/runtime. That command reruns the bound validator and binds the exact +report bytes to the canonical completion. The local operator can then run +`todo result-read --goal-id synthetic-managed-research --todo-id todo_lead-report` +with the same registry and runtime to read the accepted report. Changed bytes, +missing completion, or a changed acceptance contract reject the read. This +local CLI read does not grant a remote audience access. On the same loopback +Chat server, the packaged Goal **Files** tab now lists only reports that pass +current acceptance and opens their exact text after another version check; +stale content is cleared. This Goal-scoped local view does not yet deliver a +reply to the original conversation or grant remote audience access. Retain the +original conversation; preparation does not attach, resume, migrate or +impersonate any existing production Agent. ### Member relationships diff --git a/examples/managed-research-team/research_team.py b/examples/managed-research-team/research_team.py index 7e5b1b2e3d..29fae9da26 100644 --- a/examples/managed-research-team/research_team.py +++ b/examples/managed-research-team/research_team.py @@ -134,6 +134,7 @@ def complete(root: Path, actor: str, revision: str) -> dict: result = cli(root, "todo", "complete", "--goal-id", GOAL, "--agent-id", actor, "--todo-id", todo_id(actor, revision), "--no-follow-up", "--note", "Bounded artifact task; synthesis consumes dependencies through its separately bound task.", + *(["--result-file", str(root / "lead" / "report.json")] if actor == "lead" else []), workspace=root / "project") require_completed(canonical_tasks(root), actor, revision) return result diff --git a/examples/personal-workspace-browser-smoke.mjs b/examples/personal-workspace-browser-smoke.mjs index 3afb98a579..b7ea92b92f 100644 --- a/examples/personal-workspace-browser-smoke.mjs +++ b/examples/personal-workspace-browser-smoke.mjs @@ -24,6 +24,7 @@ import { import { navigationSortingScenario } from "./personal-workspace-browser/navigation-sorting.mjs"; import { automationCadenceScenario } from "./personal-workspace-browser/automation-cadence.mjs"; import { teamEvidenceScenario } from "./personal-workspace-browser/team-evidence.mjs"; +import { managedGoalResultsScenario } from "./personal-workspace-browser/managed-goal-results.mjs"; import { loopxModeScenario } from "./personal-workspace-browser/loopx-mode.mjs"; import { progressiveLoadingScenario } from "./personal-workspace-browser/progressive-loading.mjs"; import { stewardJourneyScenario } from "./personal-workspace-browser/steward-journey.mjs"; @@ -31,7 +32,7 @@ import { teamPlanScenario } from "./personal-workspace-browser/team-plan.mjs"; import { typedActionsScenario } from "./personal-workspace-browser/typed-actions.mjs"; import { stewardModelSettingsScenario } from "./personal-workspace-browser/steward-model-settings.mjs"; -const scenarioCatalog = [navigationSortingScenario, automationCadenceScenario, chatRecoveryScenario, loopxModeScenario, teamEvidenceScenario, typedActionsScenario, teamPlanScenario, stewardJourneyScenario, executionChipScenario, stewardModelSettingsScenario, progressiveLoadingScenario]; +const scenarioCatalog = [navigationSortingScenario, automationCadenceScenario, chatRecoveryScenario, loopxModeScenario, teamEvidenceScenario, managedGoalResultsScenario, typedActionsScenario, teamPlanScenario, stewardJourneyScenario, executionChipScenario, stewardModelSettingsScenario, progressiveLoadingScenario]; const requestedScenario = process.env.LOOPX_PERSONAL_WORKSPACE_SCENARIO; const scenarios = requestedScenario ? scenarioCatalog.filter((scenario) => scenario.id === requestedScenario) diff --git a/examples/personal-workspace-browser/fixture.mjs b/examples/personal-workspace-browser/fixture.mjs index 61d2174aa5..9191a2e968 100644 --- a/examples/personal-workspace-browser/fixture.mjs +++ b/examples/personal-workspace-browser/fixture.mjs @@ -862,6 +862,10 @@ export async function installApi(page, { goalSubagentConfigurationEnabled = true await route.fulfill({ json: { ok: true, total, items, next_cursor: offset + 40 < total ? String(offset + 40) : null } }); return; } + if (url.pathname === "/api/chat/goal-results") { + await route.fulfill({ json: { ok: true, items: [], total: 0, next_cursor: null, unavailable_count: 0, unavailable_todo_ids: [] } }); + return; + } const periodicConfiguration = { schema_version: "periodic_report_machine_defaults_v0", enabled: true, diff --git a/examples/personal-workspace-browser/managed-goal-results.mjs b/examples/personal-workspace-browser/managed-goal-results.mjs new file mode 100644 index 0000000000..378d781556 --- /dev/null +++ b/examples/personal-workspace-browser/managed-goal-results.mjs @@ -0,0 +1,63 @@ +import assert from "node:assert/strict"; +import {resolve} from "node:path"; +import {outputDir} from "./fixture.mjs"; +import {openWorkspacePage} from "./scenario-context.mjs"; + +export const managedGoalResultsScenario = { + id: "managed-goal-results", + async run({browser, collectCoverage, url}) { + const context = await openWorkspacePage(browser, url, {collectCoverage}); + const {page, api} = context; + const digest = "a".repeat(64); + let stale = false; + let malformed = false; + let reads = 0; + await page.route("**/api/chat/goal-results**", route => { + const request = new URL(route.request().url()); + if (request.pathname.endsWith("/todo_lead-report")) { + reads++; + return stale + ? route.fulfill({status: 409, json: {error: "acceptance changed"}}) + : route.fulfill({json: { + ok: true, goal_id: "product-release", todo_id: "todo_lead-report", + result: {sha256: digest, content_type: "text/markdown", producer_agent_id: "lead"}, + text: "# Revised conclusion\n\n| Measure | Value |\n| --- | --- |\n| Free cash flow | 25 |\n", + }}); + } + if (malformed) return route.fulfill({json: {ok: true}}); + return route.fulfill({json: { + ok: true, items: [{todo_id: "todo_lead-report", title: "Revised cash flow", + producer_agent_id: "lead", sha256: digest, content_type: "text/markdown", size_bytes: 80}], + total: 1, next_cursor: null, unavailable_count: 0, unavailable_todo_ids: [], + }}); + }); + try { + await page.locator(".personal-goal-link", {hasText: "Product Release"}).click(); + await page.getByRole("navigation", {name: "Goal 视图"}).getByRole("button", {name: "成果", exact: true}).click(); + const results = page.getByRole("region", {name: "已验收的团队报告"}); + await results.getByRole("table").waitFor(); + assert.equal(reads, 1, "The selected body must be revalidated after inventory read"); + assert.match(await results.textContent(), /Revised cash flow/); + assert.equal(await results.locator("script,img").count(), 0); + await page.screenshot({path: resolve(outputDir, "managed-goal-results-desktop.png"), animations: "disabled"}); + await page.setViewportSize({width: 390, height: 844}); + assert(await results.evaluate(el => el.scrollWidth <= el.clientWidth), "Files report must fit a phone"); + await page.screenshot({path: resolve(outputDir, "managed-goal-results-mobile.png"), animations: "disabled"}); + stale = true; + await results.getByRole("button", {name: "刷新", exact: true}).click(); + await results.getByRole("alert").waitFor(); + assert.equal(await results.getByRole("table").count(), 0, "Stale content must be cleared"); + malformed = true; + await results.getByRole("button", {name: "刷新", exact: true}).click(); + await results.getByRole("alert").filter({hasText: "报告列表响应不完整"}).waitFor(); + assert.equal(await results.getByRole("table").count(), 0, "Malformed inventory must not restore stale content"); + assert(await page.getByTestId("personal-goal-outputs").getByRole("button", {name: /Product Release milestone report/}).isVisible(), + "Malformed managed results must not crash the existing Files view"); + assert.equal(api.turnRequests.length, 0, "Report reading must not start a model"); + await page.screenshot({path: resolve(outputDir, "managed-goal-results-stale.png"), animations: "disabled"}); + return {note: "Packaged Files shows only exact-read reports and clears stale results", coverageEntries: await context.close()}; + } finally { + if (!page.isClosed()) await context.close(); + } + }, +}; diff --git a/examples/personal-workspace-browser/team-plan.mjs b/examples/personal-workspace-browser/team-plan.mjs index 8685d5f0fa..4e67bf46ab 100644 --- a/examples/personal-workspace-browser/team-plan.mjs +++ b/examples/personal-workspace-browser/team-plan.mjs @@ -283,6 +283,87 @@ export const teamPlanScenario = { const managerResult = page.getByRole("region", {name: "团队结果回到管家"}); await managerResult.getByText("团队任务已分配,尚无可核验的已采用结果。").waitFor(); check(await managerResult.getByRole("table").count() === 0, "an accepted result from another Todo is never returned to the manager"); + const managedDigest = "b".repeat(64); + let managedState = "current"; + await page.route("**/api/chat/goal-results**", route => { + const request = new URL(route.request().url()); + if (request.pathname.endsWith("/todo_a1a1a1a1a1a1")) { + if (managedState === "stale") return route.fulfill({status: 409, json: {error: "acceptance changed"}}); + return route.fulfill({json: {ok: true, + goal_id: managedState === "wrong-goal" ? "other-goal" : GOAL_ID, + todo_id: "todo_a1a1a1a1a1a1", text: "# Verified managed conclusion\n\n| Measure | Value |\n| --- | --- |\n| Cash flow | 25 |\n", + result: {sha256: managedDigest, content_type: "text/markdown", producer_agent_id: "lead"}, + }}); + } + const planRow = {todo_id: "todo_a1a1a1a1a1a1", title: "Cash flow review", + producer_agent_id: "lead", sha256: managedDigest, content_type: "text/markdown", size_bytes: 75}; + if (managedState === "paged-plan-report") { + // A matching report behind the retired eight-page budget: 360 unrelated + // rows first, then the plan's own row on the tenth page. + const unrelated = Array.from({length: 360}, (_, index) => ({todo_id: `todo_history_${index}`, + title: "Historical report", producer_agent_id: "lead", sha256: managedDigest, + content_type: "text/markdown", size_bytes: 75})); + const all = [...unrelated, planRow]; + const offset = Number(request.searchParams.get("cursor") ?? "0"); + return route.fulfill({json: {ok: true, items: all.slice(offset, offset + 40), + total: all.length, unavailable_count: 0, unavailable_todo_ids: [], + next_cursor: offset + 40 < all.length ? String(offset + 40) : null}}); + } + const unavailableTodoIds = managedState === "unrelated-unavailable" ? ["todo_history_unreadable"] : []; + const items = [{todo_id: managedState === "other-todo" ? "todo_unrelated" : "todo_a1a1a1a1a1a1", + title: "Cash flow review", producer_agent_id: "lead", sha256: managedDigest, + content_type: "text/markdown", size_bytes: 75}]; + return route.fulfill({json: {ok: true, items, total: items.length + unavailableTodoIds.length, + next_cursor: null, unavailable_count: unavailableTodoIds.length, + unavailable_todo_ids: unavailableTodoIds}}); + }); + await managerResult.getByRole("button", {name: "刷新结果"}).click(); + await managerResult.getByText("托管团队报告 · 已验收,采用尚未核验").waitFor(); + check((await managerResult.innerText()).includes("Verified managed conclusion"), + "an exact accepted managed Todo report returns to the plan's original manager conversation"); + check((await managerResult.innerText()).includes("采用尚未核验"), + "accepted managed work does not falsely claim requester adoption"); + await page.screenshot({path: resolve(outputDir, "team-plan-manager-managed-result.png"), fullPage: false, animations: "disabled"}); + await page.setViewportSize({width: 390, height: 844}); + check(await managerResult.evaluate(element => element.scrollWidth <= element.clientWidth), + "the managed report fits the original conversation on mobile"); + await page.screenshot({path: resolve(outputDir, "team-plan-manager-managed-result-mobile.png"), fullPage: false, animations: "disabled"}); + await page.setViewportSize({width: 1512, height: 982}); + managedState = "stale"; + await managerResult.getByRole("button", {name: "刷新结果"}).click(); + await managerResult.getByText("托管报告无法核验;旧内容已撤回。").waitFor(); + check(!(await managerResult.innerText()).includes("Verified managed conclusion"), + "a failed exact read withdraws the previous managed report"); + managedState = "wrong-goal"; + await managerResult.getByRole("button", {name: "刷新结果"}).click(); + await managerResult.getByText("托管报告无法核验;旧内容已撤回。").waitFor(); + check(!(await managerResult.innerText()).includes("Verified managed conclusion"), + "a report from another Goal cannot appear in this conversation"); + managedState = "other-todo"; + await managerResult.getByRole("button", {name: "刷新结果"}).click(); + await managerResult.getByText("团队任务已分配,尚无可核验的已采用结果。").waitFor(); + check(!(await managerResult.innerText()).includes("Verified managed conclusion"), + "a report from another Todo cannot appear in this plan"); + managedState = "unrelated-unavailable"; + await managerResult.getByRole("button", {name: "刷新结果"}).click(); + await managerResult.getByText("托管团队报告 · 已验收,采用尚未核验").waitFor(); + check((await managerResult.innerText()).includes("Verified managed conclusion"), + "an unrelated unreadable Goal result must not hide this plan's accepted report"); + // Withdraw first, so the paginated case can only pass on a fresh read + // rather than on the report left over from the previous state. + managedState = "other-todo"; + await managerResult.getByRole("button", {name: "刷新结果"}).click(); + await managerResult.getByText("团队任务已分配,尚无可核验的已采用结果。").waitFor(); + managedState = "paged-plan-report"; + await managerResult.getByRole("button", {name: "刷新结果"}).click(); + await managerResult.getByText("托管团队报告 · 已验收,采用尚未核验").waitFor(); + check((await managerResult.innerText()).includes("Verified managed conclusion"), + "a matching report beyond the retired eight-page budget stays discoverable"); + // Return the fixture to the no-managed-result state the later adoption + // checks were written against. + managedState = "other-todo"; + await managerResult.getByRole("button", {name: "刷新结果"}).click(); + await managerResult.getByText("团队任务已分配,尚无可核验的已采用结果。").waitFor(); const goalSession = [...page.__loopxRuntime.sessions.values()].find(session => session.channel_id === `goal.${GOAL_ID}`); check(Boolean(goalSession), "the original Goal conversation has a session for result readback"); let goalSessionId = ""; @@ -364,6 +445,7 @@ export const teamPlanScenario = { ); const injected = [ `503 ${new URL(url).origin}/api/actions/${MANAGER_PROPOSAL_ID}/apply`, + `409 ${new URL(url).origin}/api/chat/goal-results/todo_a1a1a1a1a1a1?goal_id=${GOAL_ID}`, `503 ${new URL(url).origin}/api/chat/sessions/${goalSessionId}/loopx`, ]; check( diff --git a/loopx/chat_completed_todos.py b/loopx/chat_completed_todos.py index c7389fd72f..99df5e29bf 100644 --- a/loopx/chat_completed_todos.py +++ b/loopx/chat_completed_todos.py @@ -10,6 +10,70 @@ from time import monotonic from urllib.parse import parse_qs, urlparse +from .paths import resolve_runtime_root +from .status_server import is_loopback_host + + +def _goal_result_candidates(*, runtime_root, goal_id): + """Snapshot bounded metadata; exact acceptance is checked per requested page.""" + from .control_plane.coordination.local_authority import read_canonical_todos_if_promoted + + payload = read_canonical_todos_if_promoted(runtime_root=runtime_root, goal_id=goal_id) + if payload is None: + return [] # Only the canonical completion writer can bind result bytes. + candidates = sorted( + ((index, item) for index, item in enumerate(payload["todos"]) + if item.get("role") == "agent" and item.get("status") == "done"), + key=lambda pair: (str(pair[1].get("completed_at") or ""), pair[0]), + reverse=True, + ) + return [ + { + "todo_id": todo["todo_id"], + "title": str(todo.get("title") or todo.get("text") or todo["todo_id"]), + "sha256": todo["completion_result"].get("sha256"), + "producer_agent_id": todo["completion_result"].get("producer_agent_id"), + "completed_at": todo.get("completed_at"), + } + for _, todo in candidates + if todo.get("todo_id") and isinstance(todo.get("completion_result"), dict) + ] + + +def _verify_goal_result_page(*, page, registry_path, runtime_root, goal_id): + from .control_plane.todos.completion_result import read_completion_result + + rows = [] + unavailable_todo_ids = [] + for todo in page["items"]: + todo_id = todo.get("todo_id") + try: + result = read_completion_result( + registry_path=registry_path, runtime_root=runtime_root, + goal_id=goal_id, todo_id=todo_id, + )["result"] + except (OSError, ValueError): + # Name the unverified rows instead of only counting them: a reader + # that only needs its own Todo ids must not be blocked by an + # unrelated unreadable report. + unavailable_todo_ids.append(todo_id) + continue + if (result["sha256"] != todo["sha256"] or + result["producer_agent_id"] != todo["producer_agent_id"]): + unavailable_todo_ids.append(todo_id) + continue + rows.append({ + "todo_id": todo_id, + "title": todo["title"], + "producer_agent_id": result["producer_agent_id"], + "sha256": result["sha256"], + "content_type": result["content_type"], + "size_bytes": result["size_bytes"], + "completed_at": todo["completed_at"], + }) + return {**page, "items": rows, "unavailable_count": len(unavailable_todo_ids), + "unavailable_todo_ids": unavailable_todo_ids} + class CompletedTodoPages: page_size = 40 @@ -55,6 +119,62 @@ def page(self, *, scope, cursor, load): class CompletedTodoRequestMixin: + def _goal_result_scope(self, goal_id): + if not is_loopback_host(str(self.server.server_address[0])): + self._send_error("Goal results require a loopback LoopX Chat server.", status=403) + return None + if not self._require_loopback_origin(): + return None + registry, _goal = self._registry_and_goal(goal_id) + return resolve_runtime_root( + registry, self.server.runtime_root_override, + registry_path=self.server.registry_path, + ) + + def _goal_results(self) -> None: + query = parse_qs(urlparse(self.path).query) + goal_id = query.get("goal_id", [""])[0] + cursor = query.get("cursor", [""])[0] + try: + runtime_root = self._goal_result_scope(goal_id) + if runtime_root is None: + return + page = self.server.completed_todo_pages.page( + scope=("accepted_goal_results", goal_id), cursor=cursor, + load=lambda: _goal_result_candidates(runtime_root=runtime_root, goal_id=goal_id), + ) + self._send_json(_verify_goal_result_page( + page=page, registry_path=self.server.registry_path, + runtime_root=runtime_root, goal_id=goal_id, + )) + except ValueError as exc: + expired = str(exc) == "history_cursor_expired" + self._send_error("history_cursor_expired" if expired else + "Goal results are unavailable.", status=409 if expired else 400) + except (OSError, RuntimeError): + self._send_error("Goal results could not be loaded.", status=503) + + def _goal_result(self, todo_id: str) -> None: + from .control_plane.todos.completion_result import read_completion_result + + goal_id = parse_qs(urlparse(self.path).query).get("goal_id", [""])[0] + try: + runtime_root = self._goal_result_scope(goal_id) + if runtime_root is None: + return + result = read_completion_result( + registry_path=self.server.registry_path, runtime_root=runtime_root, + goal_id=goal_id, todo_id=todo_id, + ) + self._send_json({ + "ok": True, "goal_id": goal_id, "todo_id": todo_id, + "result": result["result"], "text": result["text"], + }) + except ValueError: + self._send_error("The report or its current acceptance could not be verified.", status=409) + except (OSError, RuntimeError): + self._send_error("The report could not be read.", status=503) + def _completed_todos(self) -> None: # This loopback-only workspace read preserves task text and evidence. # Select display fields without returning the authority's internal metadata. diff --git a/loopx/chat_server.py b/loopx/chat_server.py index 579196c31c..e96f444c28 100644 --- a/loopx/chat_server.py +++ b/loopx/chat_server.py @@ -1284,6 +1284,7 @@ def do_GET(self) -> None: ) get_dispatch = { "/api/chat/completed-todos": self._completed_todos, + "/api/chat/goal-results": self._goal_results, CHAT_SESSIONS_PATH: self._list_sessions, CHAT_ACTIONS_PATH: self._action_list, CHAT_GOAL_CONTEXTS_PATH: self._goal_contexts, @@ -1298,6 +1299,9 @@ def do_GET(self) -> None: } if path in get_dispatch: return get_dispatch[path]() + result_parts = path.strip("/").split("/") + if len(result_parts) == 4 and result_parts[:3] == ["api", "chat", "goal-results"]: + return self._goal_result(result_parts[3]) setup_parts = path.strip("/").split("/") if len(setup_parts) == 5 and setup_parts[:4] == ["api", "chat", "lark", "app-setups"]: return self._lark_setup_snapshot(setup_parts[4]) diff --git a/loopx/cli_commands/todo.py b/loopx/cli_commands/todo.py index 08cf6d94b4..64c3227c27 100644 --- a/loopx/cli_commands/todo.py +++ b/loopx/cli_commands/todo.py @@ -2,6 +2,7 @@ import argparse from collections.abc import Callable, Sequence +from operator import itemgetter from pathlib import Path from ..control_plane.coordination.local_authority import ( @@ -24,6 +25,7 @@ from ..control_plane.todos.provider_projection import ( project_current_canonical_todos, ) +from ..control_plane.todos.completion_result import read_completion_result from ..history import load_index, load_registry from ..paths import resolve_runtime_root from ..registry import registry_goals @@ -52,6 +54,7 @@ validate_todo_complete_options, validate_todo_list_options, validate_todo_receipt_options, + validate_todo_result_read_options, validate_todo_project_markdown_options, validate_todo_plan_options, validate_todo_supersede_options, @@ -215,11 +218,13 @@ def handle_todo_command( post_writeback_hooks: Sequence[PostWritebackHookRegistration] | None = None, post_writeback_projection_builder: PostWritebackProjectionBuilder | None = None, ) -> int: - renderer = ( - render_task_planning_packet if args.todo_command == "plan" - else _render_todo_receipt if args.todo_command == "receipt" - else render_todo_markdown - ) + renderer = render_todo_markdown + if args.todo_command == "plan": + renderer = render_task_planning_packet + elif args.todo_command == "receipt": + renderer = _render_todo_receipt + elif args.todo_command == "result-read": + renderer = itemgetter("text") try: if args.todo_command is None: raise ValueError( @@ -266,6 +271,14 @@ def handle_todo_command( raise RuntimeError("canonical operation receipt returned an invalid result") payload = {"ok": result.get("status") in {"found", "missing"}, "command": "receipt", **result} + elif args.todo_command == "result-read": + validate_todo_result_read_options(args) + registry = load_registry(registry_path) + payload = read_completion_result( + registry_path=registry_path, + runtime_root=resolve_runtime_root(registry, runtime_root_arg), + goal_id=args.goal_id, todo_id=args.todo_id, + ) elif args.todo_command == "project-markdown": validate_todo_project_markdown_options(args) registry = load_registry(registry_path) @@ -542,6 +555,7 @@ def handle_todo_command( role=args.role, decision_outcome=args.decision_outcome, evidence=args.evidence, + completion_result_file=Path(args.result_file).expanduser() if args.result_file else None, completion_turn_key=completion_turn_key, completion_identity_source=completion_identity_source, completion_delivery_workspace=completion_delivery_workspace, diff --git a/loopx/cli_commands/todo_argument_validation.py b/loopx/cli_commands/todo_argument_validation.py index 76b836ce5d..db764f9a68 100644 --- a/loopx/cli_commands/todo_argument_validation.py +++ b/loopx/cli_commands/todo_argument_validation.py @@ -310,6 +310,15 @@ def validate_todo_receipt_options(args: argparse.Namespace) -> None: raise ValueError("todo receipt requires --operation-id") +def validate_todo_result_read_options(args: argparse.Namespace) -> None: + if not args.todo_id: + raise ValueError("todo result-read requires --todo-id") + _validate_todo_option_subset( + args, {"todo_id"}, + "todo result-read only accepts --goal-id, --todo-id, and --format; unsupported: ", + ) + + def validate_todo_plan_options(args: argparse.Namespace) -> None: _validate_todo_option_subset( args, {"text", "agent_id"}, @@ -424,6 +433,8 @@ def validate_todo_update_options(args: argparse.Namespace) -> None: def validate_todo_complete_options(args: argparse.Namespace) -> None: if not args.todo_id: raise ValueError("todo complete requires --todo-id") + if args.result_file and args.role == "user": + raise ValueError("--result-file requires an Agent Todo") if args.explore_result_node_refs or args.clear_explore_result_node_refs: raise ValueError("todo complete does not update --explore-result-node-ref; use todo update first") if args.claimed_by and args.clear_claim: @@ -505,6 +516,8 @@ def validate_todo_archive_completed_options(args: argparse.Namespace) -> None: def validate_shared_todo_options(args: argparse.Namespace) -> None: if getattr(args, "operation_id", None) and args.todo_command != "receipt": raise ValueError("--operation-id is supported only by todo receipt") + if args.result_file and args.todo_command != "complete": + raise ValueError("--result-file is supported only by todo complete") agent_id_allowed_for_user_authoring = ( args.todo_command == "add" and args.role == "user" diff --git a/loopx/cli_commands/todo_registration.py b/loopx/cli_commands/todo_registration.py index 237f284e4a..89ff07270c 100644 --- a/loopx/cli_commands/todo_registration.py +++ b/loopx/cli_commands/todo_registration.py @@ -32,6 +32,7 @@ def register_todo_command( "add", "list", "receipt", + "result-read", "claim", "update", "complete", @@ -104,6 +105,7 @@ def register_todo_command( todo_parser.add_argument("--status", choices=["open", "done", "blocked", "deferred"], help="For todo add/update, set the lifecycle status.") todo_parser.add_argument("--note", help="Public-safe note to attach to a lifecycle transition.") todo_parser.add_argument("--evidence", help="Public-safe evidence pointer or short result for complete/update.") + todo_parser.add_argument("--result-file", help="For todo complete, bind a bounded local .json, .md or .txt result to the independently accepted completion.") todo_parser.add_argument( "--validation-command", help=( diff --git a/loopx/control_plane/coordination/coordination_projection.ts b/loopx/control_plane/coordination/coordination_projection.ts index 932288d9f4..f57ae6be30 100644 --- a/loopx/control_plane/coordination/coordination_projection.ts +++ b/loopx/control_plane/coordination/coordination_projection.ts @@ -75,6 +75,51 @@ export interface CoordinationProjectionCommitInput { readonly mutations: readonly CoordinationProjectionMutation[]; } +/** + * Field groups appended to the v0 Todo manifest by released contract + * revisions, oldest first. + * + * The v0 manifest keeps one schema version while revisions add fields, so the + * `contract_fields` a persisted head declares identifies the release that + * wrote it. Naming one group per released revision keeps every previously + * written head exactly reproducible. Deriving the historical shape from the + * current field list instead would silently drop heads written by the release + * that is current today, which is the upgrade path this validation exists to + * protect. + */ +const TODO_CONTRACT_REVISION_FIELDS: readonly (readonly string[])[] = [ + ["completion_validation_revision", "completion_validation_revision_history"], + ["completion_result"], +]; + +interface HistoricalTodoContract { + /** The exact field list a head written before the later revisions declares. */ + readonly fields: readonly string[]; + /** Fields that release had not added yet, so its records must not carry them. */ + readonly absentFields: ReadonlySet; +} + +/** + * Enumerate the released pre-extension shapes of one Todo manifest, newest + * first. This is not a general subset rule: a declaration must equal one + * released field list exactly, so partial revision groups, reordered, + * duplicated and unknown fields all fail. + */ +function historicalTodoContracts( + fields: readonly string[], +): readonly HistoricalTodoContract[] { + const contracts: HistoricalTodoContract[] = []; + const absentFields = new Set(); + for (let index = TODO_CONTRACT_REVISION_FIELDS.length - 1; index >= 0; index -= 1) { + for (const field of TODO_CONTRACT_REVISION_FIELDS[index]!) absentFields.add(field); + contracts.push({ + fields: fields.filter((field) => !absentFields.has(field)), + absentFields: new Set(absentFields), + }); + } + return contracts; +} + function sortedIds(values: Iterable): string[] { return [...values].sort(authorityUnicodeCompare); } @@ -157,18 +202,17 @@ export function validateCoordinationTodoReadModel( throw new AuthorityStoreProtocolError("coordination Todo read-model digest mismatch"); } const fields = domain ? TODO_DOMAIN_RECORD_CONTRACT.fields : TODO_CANONICAL_READ_RECORD_FIELDS; - // Validator revisions extended the v0 manifest without changing its schema. - // Retain that exact pre-extension shape for persisted heads. This is not a - // general subset rule: partial extensions, reordered and unknown fields fail. - const revisionFields = ["completion_validation_revision", "completion_validation_revision_history"]; - const beforeValidatorRevisions = fields.filter((field) => !revisionFields.includes(field)); + // Contract revisions extended the v0 manifest without changing its schema. + // Retain every released pre-extension shape for persisted heads. const declaredFields = canonicalAuthorityBytes(readModel.contract_fields); const currentContract = declaredFields.equals(canonicalAuthorityBytes(fields)); - const historicalContract = declaredFields.equals(canonicalAuthorityBytes(beforeValidatorRevisions)); - if (!currentContract && !historicalContract) { + const historicalContract = currentContract ? undefined : historicalTodoContracts(fields) + .find((contract) => declaredFields.equals(canonicalAuthorityBytes(contract.fields))); + if (!currentContract && historicalContract === undefined) { throw new AuthorityStoreProtocolError("coordination Todo read-model field contract mismatch"); } - if (historicalContract && records.some((record) => revisionFields.some((field) => field in record))) { + if (historicalContract !== undefined && + records.some((record) => [...historicalContract.absentFields].some((field) => field in record))) { throw new AuthorityStoreProtocolError("coordination Todo record exceeds its historical field contract"); } for (const [recordIndex, record] of records.entries()) { diff --git a/loopx/control_plane/coordination/coordination_state_contract.generated.ts b/loopx/control_plane/coordination/coordination_state_contract.generated.ts index 999c611571..66abe04a32 100644 --- a/loopx/control_plane/coordination/coordination_state_contract.generated.ts +++ b/loopx/control_plane/coordination/coordination_state_contract.generated.ts @@ -179,6 +179,7 @@ export const COORDINATION_STATE_CONTRACT = deepFreeze({ "reason", "completed_at", "completion_turn_key", + "completion_result", "updated_at", "superseded_by", "completion_validation_required", diff --git a/loopx/control_plane/coordination/coordination_state_contract_generated.py b/loopx/control_plane/coordination/coordination_state_contract_generated.py index 1b26928739..4237581309 100644 --- a/loopx/control_plane/coordination/coordination_state_contract_generated.py +++ b/loopx/control_plane/coordination/coordination_state_contract_generated.py @@ -76,6 +76,7 @@ def _freeze(value: Any) -> Any: 'reason', 'completed_at', 'completion_turn_key', + 'completion_result', 'updated_at', 'superseded_by', 'completion_validation_required', diff --git a/loopx/control_plane/coordination/coordination_state_contract_v0.json b/loopx/control_plane/coordination/coordination_state_contract_v0.json index 62bd4c2db6..9186169ff4 100644 --- a/loopx/control_plane/coordination/coordination_state_contract_v0.json +++ b/loopx/control_plane/coordination/coordination_state_contract_v0.json @@ -65,6 +65,7 @@ "reason", "completed_at", "completion_turn_key", + "completion_result", "updated_at", "superseded_by", "completion_validation_required", diff --git a/loopx/control_plane/coordination/local_authority_runtime.ts b/loopx/control_plane/coordination/local_authority_runtime.ts index 63611154a9..c51977ea3f 100644 --- a/loopx/control_plane/coordination/local_authority_runtime.ts +++ b/loopx/control_plane/coordination/local_authority_runtime.ts @@ -1351,6 +1351,8 @@ export async function terminalLifecycleLocalCoordinationTodo( goal_acceptance_source_binding: input.goal_acceptance_source_binding == null ? null : requireJsonObject(input.goal_acceptance_source_binding, "goal_acceptance_source_binding"), goal_acceptance_validation_receipts: input.goal_acceptance_validation_receipts, + completion_result: input.completion_result == null + ? null : requireJsonObject(input.completion_result, "completion_result"), completion_policy_request: input.completion_policy_request === null || input.completion_policy_request === undefined ? null : requireJsonObject(input.completion_policy_request, "completion_policy_request"), diff --git a/loopx/control_plane/coordination/todo_terminal_lifecycle.ts b/loopx/control_plane/coordination/todo_terminal_lifecycle.ts index ce847030e7..f6e1d14b76 100644 --- a/loopx/control_plane/coordination/todo_terminal_lifecycle.ts +++ b/loopx/control_plane/coordination/todo_terminal_lifecycle.ts @@ -104,6 +104,7 @@ interface CoordinationTodoTerminalLifecycleBaseInput { readonly validation_receipt: JsonObject | null; readonly goal_acceptance_source_binding?: JsonObject | null; readonly goal_acceptance_validation_receipts?: unknown; + readonly completion_result?: JsonObject | null; readonly completion_policy_request: JsonObject | null; readonly dry_run: boolean; readonly now: Date; @@ -476,6 +477,7 @@ function terminalRequestSha(input: CoordinationTodoTerminalLifecycleInput): stri successor_intents: input.successor_intents, clear_claim: input.clear_claim, completion_policy_request: completionPolicyIdentity, + ...(input.completion_result == null ? {} : {completion_result: input.completion_result}), validation_declaration_sha256: input.user_update !== undefined ? null : input.validation_declaration_sha256 ?? (input.validation_declaration === null ? null : canonicalAuthoritySha256(input.validation_declaration)), @@ -676,6 +678,27 @@ function acceptanceCompletionEvidence(head: JsonObject, input: ResolvedCoordinat return {source_binding: binding, ...evidence, validation_receipts: validationReceipts}; } +function acceptedCompletionResult(input: ResolvedCoordinationTodoTerminalLifecycleInput, + todo: JsonObject, acceptanceEvidence: JsonObject | null): JsonObject | null { + if (input.completion_result == null) return null; + const row = canonicalAuthorityObject(input.completion_result, "completion result"); + acceptanceRequire(input.command === "complete" && todo.role === "agent" && + acceptanceEvidence !== null && input.actor_agent_id === todo.claimed_by, + "A result requires an independently accepted Agent Todo owned by its producer."); + const fields = ["content_type", "provider", "sha256", "size_bytes"]; + acceptanceRequire(Object.keys(row).length === fields.length && fields.every(field => Object.hasOwn(row, field)) && + row.provider === "local_runtime_v0" && + ["application/json", "text/markdown", "text/plain"].includes(String(row.content_type)) && + typeof row.sha256 === "string" && /^[a-f0-9]{64}$/.test(row.sha256) && + Number.isSafeInteger(row.size_bytes) && Number(row.size_bytes) > 0 && Number(row.size_bytes) <= 128000, + "Completion result must be a bounded local content-addressed object."); + return {...row, schema_version: "loopx_completion_result_v0", + producer_agent_id: input.actor_agent_id, todo_id: input.todo_id, + completion_operation_id: input.operation_id, + acceptance_contract_digest: acceptanceEvidence.contract_digest, + acceptance_contract_revision: acceptanceEvidence.contract_revision}; +} + /** Keep the runner's typed failure at the public boundary without exposing its * command, output, workspace path, or caller-controlled summary. */ function acceptanceCriterionFailure(receipts: unknown): JsonObject | null { @@ -910,6 +933,7 @@ function terminalTarget( input: ResolvedCoordinationTodoTerminalLifecycleInput, completion: ReturnType | null, successorIds: readonly string[], + acceptedResult: JsonObject | null, ): { todo: JsonObject; clear_fields: string[] } { const updatedAt = input.now.toISOString().replace(/\.\d{3}Z$/u, "Z"); const next: JsonObject = { @@ -925,6 +949,7 @@ function terminalTarget( ...(input.decision_outcome === null ? {} : {decision_outcome: input.decision_outcome}), ...(input.requested_no_followup ? {no_followup: true} : {}), ...(successorIds.length === 0 ? {} : {successor_todo_ids: successorIds}), + ...(acceptedResult === null ? {} : {completion_result: acceptedResult}), }; if (input.command === "supersede") { next.note = input.note ?? "superseded"; @@ -1445,7 +1470,14 @@ export async function executeCoordinationTodoTerminalLifecycle( const currentLease = projection.leases.get(input.todo_id); const released = releasedLease(currentLease, authority, input); - const target = terminalTarget(todo, input, completion, successorIds); + let completionResult: JsonObject | null; + try { + completionResult = acceptedCompletionResult(input, todo, acceptanceEvidence); + } catch (error) { + return terminalFailure("completion_result_rejected", + error instanceof Error ? error.message : "Completion result rejected", {}, "decision_rejection"); + } + const target = terminalTarget(todo, input, completion, successorIds, completionResult); if (edit !== null) target.clear_fields = [...new Set([...edit.clearFields, ...target.clear_fields])]; const followthrough = input.command === "complete" && todo.role === "user" ? planUserCompletion(todo, [...projection.todos.values()], input.decision_outcome) : null; @@ -1471,6 +1503,7 @@ export async function executeCoordinationTodoTerminalLifecycle( completion_identity_source: completion === null ? null : completion.completion_identity_source, completed_at: target.todo.completed_at, + ...(completionResult === null ? {} : {completion_result: completionResult}), ...(acceptanceEvidence === null ? {} : {goal_acceptance_completion: acceptanceEvidence}), // A preview that omits this would show an unconditional close for work the // real call still gates. Name the criteria the real call must run; never diff --git a/loopx/control_plane/goals/acceptance_contract.ts b/loopx/control_plane/goals/acceptance_contract.ts index 45eb3a4c20..ee409c58e6 100644 --- a/loopx/control_plane/goals/acceptance_contract.ts +++ b/loopx/control_plane/goals/acceptance_contract.ts @@ -175,6 +175,7 @@ const NON_WORK_FIELDS = new Set([ "schema_version", "source_section", "index", "title", "priority", "status", "done", "archive_state", "claimed_by", "created_by", "last_actor_agent_id", "updated_at", "completed_at", "completion_turn_key", "completion_validation_sha256", "completion_recovery", "completion_continuation", "no_followup", "decision_outcome", + "completion_result", "decision_scope_outcomes", "note", "evidence", "reason", "handoff_note", "resume_ready", "resume_monitor_generation", "last_checked_at", "result_hash", "consecutive_no_change", "material_change", "material_change_generation", "monitor_effect_id", diff --git a/loopx/control_plane/todos/completion_result.py b/loopx/control_plane/todos/completion_result.py new file mode 100644 index 0000000000..a0c31878b7 --- /dev/null +++ b/loopx/control_plane/todos/completion_result.py @@ -0,0 +1,122 @@ +"""Local, content-addressed output bytes for accepted Todo completions. + +The canonical Todo owns the binding. This provider owns bytes only and never +turns a file's existence into evidence of completion or acceptance. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +import stat +import tempfile +from pathlib import Path +from typing import Any + +MAX_RESULT_BYTES = 128_000 +_CONTENT_TYPES = {".json": "application/json", ".md": "text/markdown", ".txt": "text/plain"} +_DIGEST = re.compile(r"[a-f0-9]{64}\Z") + + +def _object_path(runtime_root: Path, goal_id: str, digest: str) -> Path: + if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]*", goal_id) or not _DIGEST.fullmatch(digest): + raise ValueError("invalid completion result identity") + return runtime_root / "goals" / goal_id / "result-objects" / digest + + +def _read_regular(path: Path) -> bytes: + with os.fdopen(os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | + getattr(os, "O_NONBLOCK", 0)), "rb") as stream: + if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode): + raise ValueError("completion result must be a regular file") + data = stream.read(MAX_RESULT_BYTES + 1) + if not data or len(data) > MAX_RESULT_BYTES: + raise ValueError("completion result must contain 1..128000 bytes") + data.decode("utf-8") + return data + + +def _install_object(target: Path, data: bytes) -> None: + """Install exact content-addressed bytes with one atomic replace. + + A digest path can only ever hold the bytes it names, so a pre-existing + regular file that does not match is the artifact of an interrupted store + (or a corrupt entry) rather than a competing object. It is replaced by the + complete staged bytes instead of failing the retry with a partial file on + disk. ``os.replace`` installs the staged name without following a link, so + an existing symlink is replaced rather than written through. + """ + try: + if _read_regular(target) == data: + return + except FileNotFoundError: + pass + except (OSError, ValueError): + pass + descriptor, temporary_name = tempfile.mkstemp( + prefix=f".{target.name}.", suffix=".tmp", dir=target.parent + ) + temporary = Path(temporary_name) + try: + with os.fdopen(descriptor, "wb") as stream: + stream.write(data) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, target) + finally: + temporary.unlink(missing_ok=True) + + +def store_completion_result(*, source: Path, runtime_root: Path, goal_id: str, + persist: bool = True) -> dict[str, Any]: + """Stage exact local bytes before the canonical completion transaction.""" + content_type = _CONTENT_TYPES.get(source.suffix.lower()) + if content_type is None: + raise ValueError("completion result supports .json, .md or .txt") + data = _read_regular(source) + if content_type == "application/json": + json.loads(data) + digest = hashlib.sha256(data).hexdigest() + if persist: + target = _object_path(runtime_root, goal_id, digest) + target.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + _install_object(target, data) + return {"provider": "local_runtime_v0", "sha256": digest, + "size_bytes": len(data), "content_type": content_type} + + +def read_completion_result(*, registry_path: Path, runtime_root: Path, + goal_id: str, todo_id: str) -> dict[str, Any]: + """Exact owner-local read; absence, stale acceptance and byte drift fail closed.""" + from ..goals.acceptance import inspect_goal_acceptance + from ...todos import list_goal_todos + + todos = list_goal_todos(registry_path=registry_path, goal_id=goal_id, + todo_id=todo_id, runtime_root_arg=str(runtime_root)) + todo = todos.get("todo") + if not isinstance(todo, dict) or todo.get("status") != "done" or todo.get("done") is not True: + raise ValueError("completion result requires a current completed Todo") + binding = todo.get("completion_result") + if not isinstance(binding, dict) or binding.get("schema_version") != "loopx_completion_result_v0": + raise ValueError("Todo has no accepted completion result") + basis = inspect_goal_acceptance(registry_path=registry_path, goal_id=goal_id, + runtime_root=str(runtime_root)) + if todos.get("authority_read", {}).get("provider_revision") != basis.get("provider_revision"): + raise ValueError("completion result canonical snapshot changed; retry readback") + contract = basis.get("goal_acceptance_contract") + if (not isinstance(contract, dict) or contract.get("enabled") is not True or + contract.get("digest") != binding.get("acceptance_contract_digest") or + contract.get("revision") != binding.get("acceptance_contract_revision")): + raise ValueError("completion result acceptance basis is stale") + if binding.get("todo_id") != todo_id or binding.get("producer_agent_id") != todo.get("last_actor_agent_id"): + raise ValueError("completion result producer or Todo identity changed") + digest = binding.get("sha256") + if not isinstance(digest, str) or not _DIGEST.fullmatch(digest): + raise ValueError("completion result digest is invalid") + data = _read_regular(_object_path(runtime_root, goal_id, digest)) + if hashlib.sha256(data).hexdigest() != digest or len(data) != binding.get("size_bytes"): + raise ValueError("completion result bytes no longer match canonical binding") + return {"ok": True, "goal_id": goal_id, "todo_id": todo_id, + "result": binding, "text": data.decode("utf-8"), "audience": "local_operator"} diff --git a/loopx/control_plane/todos/provider_terminal_lifecycle.py b/loopx/control_plane/todos/provider_terminal_lifecycle.py index d1f0ba0116..cba643db08 100644 --- a/loopx/control_plane/todos/provider_terminal_lifecycle.py +++ b/loopx/control_plane/todos/provider_terminal_lifecycle.py @@ -39,6 +39,7 @@ from .path_resolution import resolve_todo_state_path from .provider_projection import projection_delivery_requires_ack, settle_canonical_todo_projection from .successor_derivation import build_successor_intents +from .completion_result import read_completion_result, store_completion_result _TERMINAL_REQUEST_SCHEMA = "loopx_local_coordination_todo_terminal_lifecycle_request_v3" _ARCHIVE_REQUEST_SCHEMA = "loopx_local_coordination_todo_archive_request_v0" @@ -121,6 +122,7 @@ def _route_terminal_call(command: str, call: Mapping[str, Any]) -> dict[str, Any authority_reason=call.get("authority_reason"), decision_outcome=call.get("decision_outcome") if complete else None, evidence=call.get("evidence") if complete else None, + completion_result_file=call.get("completion_result_file") if complete else None, note=call.get("note") if complete else "superseded", reason=None if complete else call.get("reason"), completion_turn_key=call.get("completion_turn_key") if complete else None, @@ -182,6 +184,8 @@ def routed(*args: Any, **kwargs: Any) -> dict[str, Any]: result = _route_terminal_call(command, bound.arguments) if result is None and bound.arguments.get("terminal_review_basis") is not None: raise ValueError("Reviewed canonical completion cannot fall back to legacy authority; regenerate preview") + if result is None and bound.arguments.get("completion_result_file") is not None: + raise ValueError("Completion results require promoted canonical Todo authority") return result if result is not None else legacy(*args, **kwargs) return routed @@ -254,6 +258,7 @@ def terminal_canonical_todo_if_promoted( authority_reason: str | None, decision_outcome: str | None, evidence: str | None, + completion_result_file: Path | None, note: str | None, reason: str | None, completion_turn_key: str | None, @@ -307,6 +312,22 @@ def terminal_canonical_todo_if_promoted( # The canonical transaction owns missing/role/archive lifecycle decisions. # Keep only the optional local validation facts needed by the host adapter. target = _todo_by_id(todos, todo_id) or {} + result_descriptor = None + if completion_result_file is not None: + try: + result_descriptor = store_completion_result( + source=completion_result_file, runtime_root=runtime_root, + goal_id=goal_id, persist=False, + ) + except FileNotFoundError: + if target.get("status") != "done": + raise + bound = read_completion_result( + registry_path=registry_path, runtime_root=runtime_root, + goal_id=goal_id, todo_id=todo_id, + )["result"] + result_descriptor = {key: bound[key] for key in + ("provider", "sha256", "size_bytes", "content_type")} with authority_registry_source(registry_path) as registry_source: registered, grants = todo_lifecycle_facts(registry_path, goal_id) successor_intents = build_successor_intents( @@ -381,6 +402,7 @@ def terminal_canonical_todo_if_promoted( "successor_intents": successor_intents, "note": note, "evidence": evidence, + "completion_result": result_descriptor, "reason": reason, "clear_claim": clear_claim, "validation_declaration": None, @@ -418,6 +440,20 @@ def terminal_canonical_todo_if_promoted( delivery_workspace=completion_delivery_workspace, validation_workspace_path=completion_validation_workspace_path, )) + if completion_result_file is not None and not dry_run: + receipts = request.get("goal_acceptance_validation_receipts") + passed = (isinstance(receipts, list) and bool(receipts) and + all(isinstance(row, Mapping) and isinstance(row.get("receipt"), Mapping) and + row["receipt"].get("passed") is True for row in receipts)) + caller_receipt = request.get("validation_receipt") + if passed and (caller_receipt is None or + isinstance(caller_receipt, Mapping) and caller_receipt.get("passed") is True): + staged = store_completion_result( + source=completion_result_file, runtime_root=runtime_root, + goal_id=goal_id, + ) + if staged != result_descriptor: + raise ValueError("completion result changed during acceptance validation") completion_validation_executed = True request["observed_at"] = now_local() result = effect_runtime_result( diff --git a/loopx/presentation/chat_bundle.py b/loopx/presentation/chat_bundle.py index ce148c5580..c8dd04c971 100644 --- a/loopx/presentation/chat_bundle.py +++ b/loopx/presentation/chat_bundle.py @@ -52,7 +52,8 @@ def source_inputs(root: Path) -> dict[str, str]: paths.extend( path for path in (root / name).rglob("*") - if path.is_file() and not path.name.endswith(".local.json") + if path.is_file() and path.name != ".gitkeep" + and not path.name.endswith(".local.json") ) # Shared typed contracts imported by the frontend are build inputs too. paths.extend((root / "loopx/control_plane").rglob("*.ts")) diff --git a/loopx/todos.py b/loopx/todos.py index 8cee071875..fbb5bc438f 100644 --- a/loopx/todos.py +++ b/loopx/todos.py @@ -1555,6 +1555,7 @@ def complete_goal_todo( role: str | None = None, decision_outcome: str | None = None, evidence: str | None = None, + completion_result_file: Path | None = None, completion_turn_key: str | None = None, completion_identity_source: str | None = None, terminal_review_basis: Mapping[str, Any] | None = None, diff --git a/tests/control_plane/test_completion_result_objects.py b/tests/control_plane/test_completion_result_objects.py new file mode 100644 index 0000000000..1a44e59980 --- /dev/null +++ b/tests/control_plane/test_completion_result_objects.py @@ -0,0 +1,67 @@ +"""Content-addressed completion result objects install atomically and recover.""" + +from __future__ import annotations + +import hashlib +from pathlib import Path + +import pytest + +from loopx.control_plane.todos.completion_result import store_completion_result + + +def _store(tmp_path: Path, text: str) -> dict: + source = tmp_path / "result.md" + source.write_text(text, encoding="utf-8") + return store_completion_result(source=source, runtime_root=tmp_path / "runtime", goal_id="goal") + + +def _object_path(tmp_path: Path, digest: str) -> Path: + return tmp_path / "runtime" / "goals" / "goal" / "result-objects" / digest + + +def test_store_installs_complete_bytes_and_is_idempotent(tmp_path: Path) -> None: + text = "# Accepted report\n\nComplete bytes.\n" + first = _store(tmp_path, text) + target = _object_path(tmp_path, first["sha256"]) + assert target.read_text(encoding="utf-8") == text + assert not list(target.parent.glob("*.tmp")) + second = _store(tmp_path, text) + assert second == first + assert target.read_text(encoding="utf-8") == text + + +def test_interrupted_object_is_replaced_by_a_complete_retry(tmp_path: Path) -> None: + text = "# Accepted report\n\nComplete bytes.\n" + digest = hashlib.sha256(text.encode("utf-8")).hexdigest() + target = _object_path(tmp_path, digest) + target.parent.mkdir(parents=True, exist_ok=True) + # The artifact of an interrupted legacy store: a partial file at the final + # digest path, which used to make every later retry fail. + target.write_bytes(text.encode("utf-8")[:6]) + stored = _store(tmp_path, text) + assert stored["sha256"] == digest + assert target.read_text(encoding="utf-8") == text + assert not list(target.parent.glob("*.tmp")) + + +def test_object_install_never_writes_through_a_link(tmp_path: Path) -> None: + text = "# Accepted report\n\nComplete bytes.\n" + digest = hashlib.sha256(text.encode("utf-8")).hexdigest() + target = _object_path(tmp_path, digest) + target.parent.mkdir(parents=True, exist_ok=True) + outside = tmp_path / "outside.md" + outside.write_text("untouched\n", encoding="utf-8") + target.symlink_to(outside) + stored = _store(tmp_path, text) + assert stored["sha256"] == digest + assert not target.is_symlink() + assert target.read_text(encoding="utf-8") == text + assert outside.read_text(encoding="utf-8") == "untouched\n" + + +def test_store_rejects_an_oversized_or_empty_source(tmp_path: Path) -> None: + empty = tmp_path / "empty.md" + empty.write_text("", encoding="utf-8") + with pytest.raises(ValueError, match="1\\.\\.128000 bytes"): + store_completion_result(source=empty, runtime_root=tmp_path / "runtime", goal_id="goal") diff --git a/tests/control_plane_ts/coordination_projection.test.ts b/tests/control_plane_ts/coordination_projection.test.ts index 32b899b06a..b882080aa4 100644 --- a/tests/control_plane_ts/coordination_projection.test.ts +++ b/tests/control_plane_ts/coordination_projection.test.ts @@ -421,3 +421,63 @@ for (const native of [false, true]) { assert.deepEqual(updated.todos, [changed]); }); } + +// The release that added the validator revision fields wrote exactly the +// frozen manifest with those two fields restored; upstream inserted both +// directly after completion_validation_sha256. Its persisted heads must stay +// readable once a later release adds another additive field, otherwise an +// ordinary upgrade loses every Goal's Todo list. +const previousReleaseFields: string[] = [...historicalFields.canonical_fields]; +previousReleaseFields.splice( + previousReleaseFields.indexOf("completion_validation_sha256") + 1, 0, + "completion_validation_revision", "completion_validation_revision_history"); + +for (const native of [false, true]) { + test(`Todo head written before a later additive field stays readable (${native ? "native" : "canonical"})`, () => { + const fields = previousReleaseFields.filter((field: string) => + !native || !historicalFields.projection_metadata_fields.includes(field)); + assert.equal(fields.includes("completion_validation_revision"), true); + assert.equal(fields.includes("completion_result"), false, + "the previous release did not declare the later additive field"); + const todo: JsonObject = { + schema_version: native ? TODO_DOMAIN_ITEM_SCHEMA : "todo_item_v0", + todo_id: "todo_previous_release", role: "agent", status: "open", done: false, + text: "Read work accepted before the next additive field", archive_state: "active", + ...(native ? {} : {source_section: "Agent Todo"}), + }; + const schema = native ? TODO_DOMAIN_READ_RECORD_SCHEMA : TODO_CANONICAL_READ_RECORD_SCHEMA; + const head = { + goal_id: "goal-previous-release", todos: [todo], leases: [], + todo_read_model: {schema_version: schema, contract_fields: fields, + todo_count: 1, records_sha256: canonicalAuthoritySha256([todo])}, + }; + assert.deepEqual(validateCoordinationTodoReadModel(head, head.goal_id), head.todo_read_model); + const withResult = {...todo, completion_result: { + schema_version: "loopx_completion_result_v0", sha256: "a".repeat(64), + producer_agent_id: "agent-a"}}; + assert.throws(() => validateCoordinationTodoReadModel({...head, todos: [withResult], + todo_read_model: {...head.todo_read_model, records_sha256: canonicalAuthoritySha256([withResult])}}, + head.goal_id), /exceeds its historical field contract/); + }); +} + +for (const native of [false, true]) { + test(`Todo carrying a completion result reads under the current contract (${native ? "native" : "canonical"})`, () => { + const contract = native ? TODO_DOMAIN_RECORD_CONTRACT.fields : TODO_CANONICAL_READ_RECORD_FIELDS; + assert.equal(contract.includes("completion_result"), true); + const todo: JsonObject = { + schema_version: native ? TODO_DOMAIN_ITEM_SCHEMA : "todo_item_v0", + todo_id: "todo_completion_result", role: "agent", status: "done", done: true, + text: "Accepted managed report", archive_state: "archive", + completion_result: {schema_version: "loopx_completion_result_v0", sha256: "b".repeat(64), + producer_agent_id: "agent-a", source_name: "report.md"}, + ...(native ? {} : {source_section: "Agent Todo"}), + }; + const schema = native ? TODO_DOMAIN_READ_RECORD_SCHEMA : TODO_CANONICAL_READ_RECORD_SCHEMA; + const model = coordinationTodoReadModel([todo], schema); + assert.equal((model.contract_fields as string[]).includes("completion_result"), true); + const head = {goal_id: "goal-completion-result", todos: [todo], leases: [], + todo_read_model: model}; + assert.deepEqual(validateCoordinationTodoReadModel(head, head.goal_id), model); + }); +} diff --git a/tests/control_plane_ts/goal_acceptance_runtime.test.ts b/tests/control_plane_ts/goal_acceptance_runtime.test.ts index 92d0268ddc..eefa2d3a09 100644 --- a/tests/control_plane_ts/goal_acceptance_runtime.test.ts +++ b/tests/control_plane_ts/goal_acceptance_runtime.test.ts @@ -220,6 +220,47 @@ for (const provider of ["file", ...(process.env.LOOPX_TEST_POSTGRES_URL ? ["post assert.equal((await executeCoordinationTodoTerminalLifecycle(store, terminal)).status, "replayed"); }); + test(`${provider}: accepted result binds to the same Todo transaction and producer`, async t => { + const descriptor = {provider: "local_runtime_v0", sha256: "a".repeat(64), + size_bytes: 5, content_type: "text/plain"}; + const withoutAcceptance = await seeded(t, provider, "off", {claimed_by: "agent-a"}); + assert.equal((await executeCoordinationTodoTerminalLifecycle(withoutAcceptance.store, {...terminal, + completion_result: descriptor})).reason_code, "completion_result_rejected"); + assert.equal(((await loaded(withoutAcceptance.store)).head.todos as JsonObject[])[0]!.done, false); + const unclaimed = await seeded(t, provider, "bound"); + const unclaimedPlan = await executeCoordinationTodoTerminalLifecycle(unclaimed.store, {...terminal, + completion_result: descriptor}); + assert.equal(unclaimedPlan.status, "execute_validation"); + const receipts = [{criterion_id: "criterion-a", receipt: runnerReceipt()}]; + const unclaimedAttempt = await executeCoordinationTodoTerminalLifecycle(unclaimed.store, {...terminal, + completion_result: descriptor, + goal_acceptance_source_binding: unclaimedPlan.goal_acceptance_source_binding as JsonObject, + goal_acceptance_validation_receipts: receipts}); + assert.equal(unclaimedAttempt.reason_code, "completion_result_rejected"); + assert.equal(((await loaded(unclaimed.store)).head.todos as JsonObject[])[0]!.done, false); + + const {store} = await seeded(t, provider, "bound", {claimed_by: "agent-a"}); + const plan = await executeCoordinationTodoTerminalLifecycle(store, {...terminal, completion_result: descriptor}); + assert.equal(plan.status, "execute_validation"); + const attempt = {...terminal, completion_result: descriptor, + goal_acceptance_source_binding: plan.goal_acceptance_source_binding as JsonObject, + goal_acceptance_validation_receipts: receipts}; + const malformed = await executeCoordinationTodoTerminalLifecycle(store, {...attempt, + completion_result: {...descriptor, sha256: "not-a-digest"}}); + assert.equal(malformed.reason_code, "completion_result_rejected"); + const committed = await executeCoordinationTodoTerminalLifecycle(store, attempt); + assert.equal(committed.status, "applied"); + const stored = ((await loaded(store)).head.todos as JsonObject[])[0]!.completion_result as JsonObject; + assert.equal(stored.sha256, descriptor.sha256); + assert.equal(stored.producer_agent_id, "agent-a"); + assert.equal(stored.todo_id, "todo_work"); + assert.equal(stored.acceptance_contract_digest, (await loaded(store)).head.goal_acceptance?.digest); + assert.equal(acceptanceWorkGuard((await loaded(store)).head, "goal-a", "todo_work")?.state, "ready"); + assert.equal((await executeCoordinationTodoTerminalLifecycle(store, attempt)).status, "replayed"); + assert.notEqual((await executeCoordinationTodoTerminalLifecycle(store, {...attempt, + completion_result: {...descriptor, sha256: "b".repeat(64)}})).status, "replayed"); + }); + test(`${provider}: intervening head changes require fresh validation, not an old success`, async t => { const {store} = await seeded(t, provider, "bound"); const plan = await executeCoordinationTodoTerminalLifecycle(store, terminal); diff --git a/tests/control_plane_ts/source_projection.test.ts b/tests/control_plane_ts/source_projection.test.ts index 8c668f015c..4481f33a7a 100644 --- a/tests/control_plane_ts/source_projection.test.ts +++ b/tests/control_plane_ts/source_projection.test.ts @@ -1,8 +1,16 @@ import assert from "node:assert/strict"; +import {readFile} from "node:fs/promises"; import test from "node:test"; import {projectCoordinationSource, SOURCE_PROJECTION_REQUEST_SCHEMA} from "../../loopx/control_plane/coordination/source_projection.ts"; import {canonicalAuthoritySha256} from "../../loopx/control_plane/coordination/authority_store_codec.ts"; +// Frozen from the persisted pre-extension contract, not from today's field +// list: deriving the shape by filtering the current manifest would silently +// re-point this test at whichever release is current. +const historicalManifest = JSON.parse(await readFile(new URL( + "../fixtures/coordination/todo-pre-validator-revision-fields.json", import.meta.url, +), "utf8")); + const todo = {schema_version: "todo_item_v0", todo_id: "a", role: "agent", status: "open", done: false, text: "Capture exact state", archive_state: "active", source_section: "Agent Todo"}; function request(extra: Record = {}) { @@ -76,8 +84,7 @@ test("source revalidation preserves the supported pre-validator manifest and rej const source = await fixture(t); const head = projection([legacyTodo()]); const manifest = head.todo_read_model as Record; - manifest.contract_fields = (manifest.contract_fields as string[]).filter( - key => !["completion_validation_revision", "completion_validation_revision_history"].includes(key)); + manifest.contract_fields = historicalManifest.canonical_fields; const before = structuredClone(head); const request = await sourceRequest(source, head); await verifyShadowSourceSnapshot(request as import("../../loopx/control_plane/coordination/runtime_shadow.ts").ShadowRequest); diff --git a/tests/presentation/test_chat_bundle.py b/tests/presentation/test_chat_bundle.py index d78d049a04..42a9ce7a54 100644 --- a/tests/presentation/test_chat_bundle.py +++ b/tests/presentation/test_chat_bundle.py @@ -228,3 +228,20 @@ def test_source_fingerprints_normalize_windows_text_but_not_binary(tmp_path): assert builder.contract.source_digest( image, b"image\r\n" ) != builder.contract.source_digest(image, b"image\n") + + +def test_frontend_source_inputs_ignore_placeholder_checkout_line_endings(tmp_path): + public = tmp_path / "apps/presentation/dashboard/public" + public.mkdir(parents=True) + placeholder = public / ".gitkeep" + asset = public / "icon.svg" + placeholder.write_bytes(b"\n") + asset.write_bytes(b"\n") + original = builder.contract.source_inputs(tmp_path) + + placeholder.write_bytes(b"\r\n") + assert builder.contract.source_inputs(tmp_path) == original + assert "apps/presentation/dashboard/public/.gitkeep" not in original + + asset.write_bytes(b"\n") + assert builder.contract.source_inputs(tmp_path) != original diff --git a/tests/test_chat_completed_todos.py b/tests/test_chat_completed_todos.py index 4132d31bfc..6a1471afec 100644 --- a/tests/test_chat_completed_todos.py +++ b/tests/test_chat_completed_todos.py @@ -6,7 +6,7 @@ import pytest -from loopx.chat_completed_todos import CompletedTodoPages +from loopx.chat_completed_todos import CompletedTodoPages, _verify_goal_result_page from loopx.chat_server import ChatHTTPServer, ChatRequestHandler from loopx.control_plane.todos.contract import encode_metadata_value @@ -57,6 +57,45 @@ def test_snapshot_byte_budget_is_enforced(): assert not pages._snapshots +def test_goal_report_verification_is_bounded_by_requested_page(monkeypatch): + calls = [] + def read(**kwargs): + calls.append(kwargs["todo_id"]) + return {"result": {"sha256": "a" * 64, "producer_agent_id": "lead", + "content_type": "text/markdown", "size_bytes": 12}} + monkeypatch.setattr("loopx.control_plane.todos.completion_result.read_completion_result", read) + pages = CompletedTodoPages() + rows = [{"todo_id": f"todo_report_{index}", "title": "Report", + "sha256": "a" * 64, "producer_agent_id": "lead", "completed_at": None} + for index in range(85)] + first = pages.page(scope=("accepted_goal_results", "goal"), cursor="", load=lambda: rows) + verified = _verify_goal_result_page(page=first, registry_path=None, runtime_root=None, goal_id="goal") + assert len(verified["items"]) == len(calls) == 40 + assert verified["next_cursor"] + assert verified["unavailable_count"] == 0 + assert verified["unavailable_todo_ids"] == [] + + +def test_goal_report_page_names_unavailable_rows_instead_of_hiding_the_rest(monkeypatch): + def read(**kwargs): + if kwargs["todo_id"] == "todo_report_stale": + raise ValueError("completion result acceptance basis is stale") + return {"result": {"sha256": "a" * 64, "producer_agent_id": "lead", + "content_type": "text/markdown", "size_bytes": 12}} + monkeypatch.setattr("loopx.control_plane.todos.completion_result.read_completion_result", read) + pages = CompletedTodoPages() + rows = [{"todo_id": todo_id, "title": "Report", "sha256": "a" * 64, + "producer_agent_id": "lead", "completed_at": None} + for todo_id in ("todo_report_stale", "todo_report_planned")] + page = pages.page(scope=("accepted_goal_results", "goal"), cursor="", load=lambda: rows) + verified = _verify_goal_result_page(page=page, registry_path=None, runtime_root=None, goal_id="goal") + # An unrelated unreadable report is named, not turned into a whole-page failure, + # so a reader that only needs its own Todo ids can still resolve them. + assert verified["unavailable_count"] == 1 + assert verified["unavailable_todo_ids"] == ["todo_report_stale"] + assert [row["todo_id"] for row in verified["items"]] == ["todo_report_planned"] + + @pytest.mark.parametrize("count", [85, 4087]) def test_http_history_reads_real_markdown_without_writes(tmp_path, count): state = tmp_path / "active.md" diff --git a/tests/test_managed_research_team.py b/tests/test_managed_research_team.py index b843e42b45..4fec2c0827 100644 --- a/tests/test_managed_research_team.py +++ b/tests/test_managed_research_team.py @@ -5,6 +5,9 @@ from pathlib import Path import subprocess import sys +import threading +from urllib.error import HTTPError +from urllib.request import Request, urlopen import pytest @@ -16,6 +19,10 @@ from loopx.control_plane.goals.acceptance import ( # noqa: E402 configure_goal_acceptance, inspect_goal_acceptance, verify_goal_acceptance, ) +from loopx.chat_completed_todos import ( # noqa: E402 + CompletedTodoPages, _goal_result_candidates, _verify_goal_result_page, +) +from loopx.chat_server import ChatHTTPServer, ChatRequestHandler # noqa: E402 @pytest.fixture(params=["file", "sqlite"]) @@ -34,6 +41,17 @@ def plan(root, actor, revision): "--scan-root", str(root / actor / revision)) +def listed_results(root): + page = CompletedTodoPages().page( + scope=("accepted_goal_results", demo.GOAL), cursor="", + load=lambda: _goal_result_candidates(runtime_root=root / "runtime", goal_id=demo.GOAL), + ) + return _verify_goal_result_page( + page=page, registry_path=root / "registry.json", + runtime_root=root / "runtime", goal_id=demo.GOAL, + )["items"] + + def test_canonical_delivery_requires_completed_current_dependencies(team, monkeypatch): root = team fixture(root) @@ -108,10 +126,61 @@ def test_canonical_delivery_requires_completed_current_dependencies(team, monkey with pytest.raises(RuntimeError, match="goal_acceptance_validation_rejected"): demo.complete(root, "lead", "report") report.write_bytes(original_report) - demo.complete(root, "lead", "report") + lead_completion = demo.complete(root, "lead", "report") + assert lead_completion["completion_result"]["sha256"] + result_read = demo.cli(root, "todo", "result-read", "--goal-id", demo.GOAL, + "--todo-id", "todo_lead-report") + assert json.loads(result_read["text"]) == json.loads(original_report) + assert result_read["result"]["sha256"] == lead_completion["completion_result"]["sha256"] + server = ChatHTTPServer(("127.0.0.1", 0), ChatRequestHandler) + server.registry_path = root / "registry.json" + server.runtime_root_override = str(root / "runtime") + server.completed_todo_pages = CompletedTodoPages() + server.verbose = False + worker = threading.Thread(target=server.serve_forever, daemon=True) + worker.start() + try: + listing_url = f"http://127.0.0.1:{server.server_port}/api/chat/goal-results?goal_id={demo.GOAL}" + report_url = f"http://127.0.0.1:{server.server_port}/api/chat/goal-results/todo_lead-report?goal_id={demo.GOAL}" + with urlopen(listing_url) as response: + listed = json.load(response) + assert [row["todo_id"] for row in listed["items"]] == ["todo_lead-report"] + with urlopen(report_url) as response: + assert json.load(response)["text"] == result_read["text"] + with pytest.raises(HTTPError) as forbidden: + urlopen(Request(report_url, headers={"Origin": "https://unrelated.example"})) + assert forbidden.value.code == 403 + finally: + server.shutdown() + server.server_close() + worker.join() + report.unlink() + assert demo.complete(root, "lead", "report")["idempotent_replay"] is True + report.write_bytes(original_report) + result_object = root / "runtime" / "goals" / demo.GOAL / "result-objects" / result_read["result"]["sha256"] + result_object.write_text("tampered") + assert listed_results(root) == [] + with pytest.raises(RuntimeError, match="completion result bytes no longer match"): + demo.cli(root, "todo", "result-read", "--goal-id", demo.GOAL, + "--todo-id", "todo_lead-report") + result_object.write_bytes(original_report) assert all(row["done"] for row in canonical_tasks(root).values()) assert verify_goal_acceptance(**route, execute=True)["acceptance_ready"] + demo.cli(root, "todo", "archive-completed", "--goal-id", demo.GOAL, + "--max-active-done", "0", "--execute") + assert [row["todo_id"] for row in listed_results(root)] == ["todo_lead-report"] + assert demo.cli(root, "todo", "result-read", "--goal-id", demo.GOAL, + "--todo-id", "todo_lead-report")["text"] == result_read["text"] assert json.loads((root / "registry.json").read_text())["goals"][0]["status"] == "active" + revised = json.loads((root / "bootstrap.json").read_text())["document"] + revised["objective"] = "Revised owner acceptance basis" + configure_goal_acceptance(**route, document=revised, + expected_provider_revision=inspect_goal_acceptance(**route)["provider_revision"], + execute=True) + with pytest.raises(RuntimeError, match="completion result acceptance basis is stale"): + demo.cli(root, "todo", "result-read", "--goal-id", demo.GOAL, + "--todo-id", "todo_lead-report") + assert listed_results(root) == [] def test_bootstrap_refuses_existing_state(team):