diff --git a/apps/presentation/dashboard/docs/assets/goal-map-readback/current-desktop.png b/apps/presentation/dashboard/docs/assets/goal-map-readback/current-desktop.png new file mode 100644 index 0000000000..fbfc87cadd Binary files /dev/null and b/apps/presentation/dashboard/docs/assets/goal-map-readback/current-desktop.png differ diff --git a/apps/presentation/dashboard/docs/assets/goal-map-readback/stale-mobile.png b/apps/presentation/dashboard/docs/assets/goal-map-readback/stale-mobile.png new file mode 100644 index 0000000000..73d958dec1 Binary files /dev/null and b/apps/presentation/dashboard/docs/assets/goal-map-readback/stale-mobile.png differ diff --git a/apps/presentation/dashboard/docs/assets/goal-map-readback/unavailable-desktop.png b/apps/presentation/dashboard/docs/assets/goal-map-readback/unavailable-desktop.png new file mode 100644 index 0000000000..6bda6ef307 Binary files /dev/null and b/apps/presentation/dashboard/docs/assets/goal-map-readback/unavailable-desktop.png differ diff --git a/apps/presentation/dashboard/smoke/delivery-review-smoke.mjs b/apps/presentation/dashboard/smoke/delivery-review-smoke.mjs index 8abc3d04e5..97c0de3423 100644 --- a/apps/presentation/dashboard/smoke/delivery-review-smoke.mjs +++ b/apps/presentation/dashboard/smoke/delivery-review-smoke.mjs @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { deliveryReviewMarkdown, filterReviewNodes, parseDeliveryReview, reviewCoverageIncomplete } from "../node_modules/.cache/delivery-review/data/delivery-review.js"; +import { deliveryReviewMarkdown, deliveryReviewWarning, filterReviewNodes, parseDeliveryReview, reviewCoverageIncomplete } from "../node_modules/.cache/delivery-review/data/delivery-review.js"; import { deliveryReviewCopy } from "../node_modules/.cache/delivery-review/features/personal-workspace/delivery-review-copy.js"; const snapshot = { @@ -23,6 +23,23 @@ const snapshot = { }, }; const before = JSON.stringify(snapshot); +const envelope = { + schema_version: "loopx_projection_envelope_v0", projection: "delivery_review", + observed_at: snapshot.observed_at, served_at: snapshot.observed_at, fresh: true, complete: true, + alert: false, alert_reasons: [], sources: [{source_id: "goal_todos", last_read_at: snapshot.observed_at, status: "fresh"}], + coverage: {scope: "selected_goal_delivery", expected_count: 3, included_count: 3, omitted: []}, +}; +for (const copy of Object.values(deliveryReviewCopy)) { + assert.equal(deliveryReviewWarning(parseDeliveryReview(snapshot, "review-demo"), copy), copy.sourceUnknown); + const current = parseDeliveryReview({...snapshot, projection_envelope: envelope}, "review-demo"); + assert.equal(deliveryReviewWarning(current, copy), null); + const stale = parseDeliveryReview({...snapshot, projection_envelope: {...envelope, fresh: false, alert: true}}, "review-demo"); + assert.equal(deliveryReviewWarning(stale, copy), copy.sourceWarning); + assert.ok(deliveryReviewMarkdown(stale, copy).includes('"fresh": false')); + assert.equal(deliveryReviewWarning({...current, projection_envelope: {...envelope, complete: false}}, copy), copy.coverageWarning); +} +assert.throws(() => parseDeliveryReview({...snapshot, projection_envelope: {...envelope, projection: "status"}}, "review-demo")); +assert.throws(() => parseDeliveryReview({...snapshot, projection_envelope: {...envelope, fresh: "true"}}, "review-demo")); const parsed = parseDeliveryReview(snapshot, "review-demo"); const graph = parsed.graph; assert.equal(graph.edges.length, 4, "Parallel lineage and dependency edges must survive"); diff --git a/apps/presentation/dashboard/src/data/delivery-review.ts b/apps/presentation/dashboard/src/data/delivery-review.ts index 3eb32199f2..f40702d2cd 100644 --- a/apps/presentation/dashboard/src/data/delivery-review.ts +++ b/apps/presentation/dashboard/src/data/delivery-review.ts @@ -1,6 +1,16 @@ import { z } from "zod"; import { goalAcceptanceDisplayStatus, goalAcceptanceObservationSchema, type GoalAcceptanceContract } from "./goal-acceptance-observation.js"; +// Consumer validation only; freshness and coverage remain kernel decisions. +const projectionEnvelopeSchema = z.object({ + schema_version: z.literal("loopx_projection_envelope_v0"), projection: z.string(), + observed_at: z.string().datetime({offset: true}), served_at: z.string().datetime({offset: true}), + fresh: z.boolean(), complete: z.boolean(), alert: z.boolean(), alert_reasons: z.array(z.string()), + sources: z.array(z.object({source_id: z.string(), last_read_at: z.string().nullable(), status: z.string()}).passthrough()), + coverage: z.object({scope: z.string(), expected_count: z.number().int().nonnegative().nullable(), + included_count: z.number().int().nonnegative(), omitted: z.array(z.object({reason: z.string(), count: z.number().int().positive()}).passthrough())}).passthrough(), +}).passthrough(); + const refsSchema = z.record(z.string(), z.array(z.string())); const nodeSchema = z.object({ node_id: z.string().min(1), @@ -45,6 +55,7 @@ const goalMapNodeSchema = z.object({ }); const goalMapSchema = z.object({ schema_version: z.literal("goal_task_map_v0"), mode: z.literal("read_only"), goal_id: z.string(), + projection_envelope: projectionEnvelopeSchema.optional(), limits: z.object({ node_limit: z.number().int().positive(), emitted_node_count: z.number().int().nonnegative(), omitted_node_count: z.number().int().nonnegative(), source_truncated: z.boolean(), @@ -66,6 +77,7 @@ const goalMapSchema = z.object({ }); const snapshotSchema = z.object({ ok: z.literal(true), goal_id: z.string(), observed_at: z.string().datetime({ offset: true }), + projection_envelope: projectionEnvelopeSchema.optional(), graph: graphSchema.nullable(), goal_map: goalMapSchema.nullable().optional(), acceptance: goalAcceptanceObservationSchema.nullable(), }); @@ -81,7 +93,9 @@ export function parseDeliveryReview(value: unknown, goalId: string): DeliveryRev const result = snapshotSchema.parse(value); if (result.goal_id !== goalId || (result.graph && result.graph.goal_id !== goalId) || (result.goal_map && result.goal_map.goal_id !== goalId) - || (result.acceptance && result.acceptance.goal_id !== goalId)) { + || (result.acceptance && result.acceptance.goal_id !== goalId) + || (result.projection_envelope && result.projection_envelope.projection !== "delivery_review") + || (result.goal_map?.projection_envelope && result.goal_map.projection_envelope.projection !== "goal_task_map")) { throw new Error("Review source does not match the selected Goal"); } return result; @@ -136,15 +150,27 @@ export type ReviewExportLabels = { acceptance: string; acceptanceBoundary: string; noGraph: string; incomplete: string; unavailable: string; refs: string; required: string; guards: string; next: string; owner: string; reason: string; historical: string; checks: string; missingSources: string; observedScope: string; + sourceUnknown: string; sourceWarning: string; coverageWarning: string; kind: Record; state: Record; relation: Record; workMap: { title: string; boundary: string; kind: Record; relation: Record } & Record; }; +/** Display the owner's flags, including unknown older responses; do not age a second clock. */ +export function deliveryReviewWarning(snapshot: DeliveryReviewSnapshot, labels: Pick) { + const envelope = snapshot.projection_envelope; + return !envelope ? labels.sourceUnknown : !envelope.fresh ? labels.sourceWarning + : !envelope.complete ? labels.coverageWarning : null; +} + /** Export the entire validated snapshot, never the search-filtered screen. */ export function deliveryReviewMarkdown(snapshot: DeliveryReviewSnapshot, labels: ReviewExportLabels) { const line = (value: unknown) => String(value ?? labels.unavailable).replace(/[\\`*_{}[\]<>|#]/g, "\\$&").replace(/[\r\n]+/g, " "); const rows = [`# ${labels.title}`, "", `Goal: ${line(snapshot.goal_id)}`, `${labels.observed}: ${line(snapshot.observed_at)}`, "", labels.scope, "", labels.acceptanceBoundary, "", `## ${labels.chain}`, ""]; + const warning = deliveryReviewWarning(snapshot, labels); + if (warning) rows.push(warning, ""); + if (snapshot.projection_envelope) rows.push("```json", JSON.stringify(snapshot.projection_envelope, null, 2), "```", ""); const graph = snapshot.graph; if (!graph) rows.push(labels.noGraph); else { @@ -164,6 +190,7 @@ export function deliveryReviewMarkdown(snapshot: DeliveryReviewSnapshot, labels: const map = snapshot.goal_map; if (map) { rows.push("", `## ${labels.workMap.title}`, "", labels.workMap.boundary, "", "```json", JSON.stringify(map.limits, null, 2), "```", ""); + if (map.projection_envelope) rows.push("```json", JSON.stringify(map.projection_envelope, null, 2), "```", ""); const names = new Map(map.nodes.map(node => [node.node_id, node.title])); for (const node of map.nodes) rows.push(`- ${line(node.title)} · ${labels.workMap.kind[node.kind]} · ${labels.state[node.state]}${node.owner_agent ? ` · ${line(node.owner_agent)}` : ""} (${line(node.refs.todo_ids?.join(", "))})`); if (map.edges.length) rows.push(""); diff --git a/apps/presentation/dashboard/src/features/personal-workspace/delivery-review-copy.ts b/apps/presentation/dashboard/src/features/personal-workspace/delivery-review-copy.ts index 575c450d83..3d69f2f23c 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/delivery-review-copy.ts +++ b/apps/presentation/dashboard/src/features/personal-workspace/delivery-review-copy.ts @@ -32,6 +32,9 @@ const en = { contract: goalAcceptanceContractCopy.en, title: "Delivery & evidence", scope: "The delivery chain covers current work, a few predecessors and their evidence. The work map covers every task that is not archived.", observed: "Snapshot read", chain: "Delivery chain", relations: "Relationships", acceptance: "Acceptance observations", + sourceUnknown: "Source freshness and coverage are unknown in this snapshot. Refresh to read them.", + sourceWarning: "Some sources are outdated or unreadable. Refresh before relying on this snapshot.", + coverageWarning: "This snapshot does not cover all requested information. Check the missing or unexpanded sources below.", acceptanceBoundary: "Completed tasks and recorded evidence do not certify Goal acceptance.", noGraph: "No delivery chain is available in this snapshot. This does not mean all work is complete.", incomplete: "Some related information is missing or not expanded.", @@ -59,7 +62,7 @@ const en = { tasksDone: "tasks done", blocked: "blocked", waiting: "waiting", decisions: "need your decision", watches: "watching", hidden: "finished or deferred, hidden", unlinked: "Not linked to other work", empty: "This Goal has no tasks yet.", owner: "Owner", showMore: "Show {count} more", showFewer: "Show fewer", doneBefore: "+{count} done before", noLinks: "No task links are recorded between these items yet.", - unavailable: "This source does not provide a work map.", + unavailable: "The work map is unavailable. Refresh to retry.", kind: { deliverable: "Task", gate: "Decision", monitor: "Watch" }, tone: { decision: "Needs you", blocked: "Blocked", open: "Open", waiting: "Waiting", done: "Done", unknown: "Unknown" }, before: "Before this", after: "After this", nothingBefore: "No recorded prerequisites.", nothingAfter: "Nothing recorded depends on this yet.", @@ -74,6 +77,9 @@ const zh: typeof en = { contract: goalAcceptanceContractCopy["zh-CN"], title: "交付与依据", scope: "交付链仅含当前工作、少量前序及其证据;工作地图包含全部未归档任务。", observed: "快照读取时间", chain: "交付链", relations: "关联关系", acceptance: "验收观察", + sourceUnknown: "当前快照的来源新鲜度与覆盖范围未知,请刷新重新读取。", + sourceWarning: "部分来源已过期或不可读,请刷新后再据此判断。", + coverageWarning: "当前快照未覆盖全部请求信息,请查看下方缺失或未展开的来源。", acceptanceBoundary: "任务完成、已有证据均不等于 Goal 已通过验收。", noGraph: "当前快照没有可展示的交付链,这不代表工作已经全部完成。", incomplete: "部分关联信息缺失或未展开。", @@ -101,7 +107,7 @@ const zh: typeof en = { tasksDone: "项任务已完成", blocked: "受阻", waiting: "等待", decisions: "需你决策", watches: "持续监控", hidden: "项已完成或延后的工作已隐藏", unlinked: "未与其他工作关联", empty: "此 Goal 还没有任务。", owner: "负责人", showMore: "显示其余 {count} 项", showFewer: "收起", doneBefore: "另有 {count} 项已完成前序", noLinks: "这些事项之间还没有记录的任务关联。", - unavailable: "此来源不提供工作地图。", + unavailable: "工作地图暂不可用,请刷新重试。", kind: { deliverable: "任务", gate: "决策", monitor: "监控" }, tone: { decision: "需要你", blocked: "受阻", open: "待处理", waiting: "等待", done: "已完成", unknown: "未知" }, before: "之前", after: "之后", nothingBefore: "未记录前置工作。", nothingAfter: "暂无记录依赖此项的工作。", diff --git a/apps/presentation/dashboard/src/features/personal-workspace/delivery-review.tsx b/apps/presentation/dashboard/src/features/personal-workspace/delivery-review.tsx index cb4f360eea..5cdddbb262 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/delivery-review.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/delivery-review.tsx @@ -1,6 +1,6 @@ import { useId, useMemo, useRef, useState } from "react"; import { ArrowRight, CircleAlert, Download, ExternalLink, RefreshCw, Search } from "lucide-react"; -import { deliveryReviewMarkdown, filterReviewNodes, reviewCoverageIncomplete, reviewNodeColumn, type ReviewFocus, type ReviewGraph, type ReviewNode } from "../../data/delivery-review"; +import { deliveryReviewMarkdown, deliveryReviewWarning, filterReviewNodes, reviewCoverageIncomplete, reviewNodeColumn, type ReviewFocus, type ReviewGraph, type ReviewNode } from "../../data/delivery-review"; import type { WorkspaceDrawerSelection, WorkspaceGoal, WorkspaceModel, WorkspaceTimelineItem } from "./personal-workspace-model"; import { GoalAcceptanceObservationCard } from "./goal-acceptance-observation-card"; import { GoalAcceptanceContractSection } from "./goal-acceptance-contract"; @@ -13,6 +13,14 @@ import "./delivery-review.css"; type Copy = typeof deliveryReviewCopy.en; +function SourceNotice({review, copy, mapOnly = false}: {review: DeliveryReviewRead; copy: Copy; mapOnly?: boolean}) { + const snapshot = review.snapshot; + const warning = snapshot ? deliveryReviewWarning(mapOnly ? {...snapshot, + projection_envelope: snapshot.goal_map?.projection_envelope} : snapshot, copy) : null; + const envelope = mapOnly ? snapshot?.goal_map?.projection_envelope : snapshot?.projection_envelope; + return warning ?

{warning}

: null; +} + function ReviewMap({ graph, nodes, selected, onSelect, copy }: { graph: ReviewGraph; nodes: ReviewNode[]; selected: string | null; onSelect: (id: string) => void; copy: Copy; }) { @@ -119,7 +127,8 @@ export function DeliveryReviewContent({ goal, items, userTodos, onSelect, review

{copy.observed} · {state.kind === "loading" || (stale && state.kind !== "error") ? · {copy.updating} : null}

{stale && state.kind === "error" ?

{copy.changed}

: null} - {!hideWorkMap ? {if (selection) onSelect(selection);}} /> : null} + + {!hideWorkMap ? {if (selection) onSelect(selection);}} /> : null}

{copy.scope} {copy.acceptanceBoundary}

{graph ? <> {reviewCoverageIncomplete(graph) ?
{copy.incomplete}
@@ -160,9 +169,10 @@ export function DeliveryReviewContent({ goal, items, userTodos, onSelect, review } /** Same map and exact-request fallback, now usable as an Overview work lens. */ -export function DeliveryWorkMap({goal, userTodos, review, onSelect, selectedTodoId}: { +export function DeliveryWorkMap({goal, userTodos, review, onSelect, selectedTodoId, showSourceNotice = true}: { goal: WorkspaceGoal; userTodos: WorkspaceModel["userTodos"]; review: DeliveryReviewRead; onSelect: (selection: WorkspaceDrawerSelection | null) => void; selectedTodoId?: string | null; + showSourceNotice?: boolean; }) { const {locale} = useWorkspaceI18n(); const copy = deliveryReviewCopy[locale]; @@ -185,13 +195,17 @@ export function DeliveryWorkMap({goal, userTodos, review, onSelect, selectedTodo return { kind: "todo", item: { ...todo, goalId: goal.goalId, goalTitle: goal.title, ownerLabel: todo.claimedBy } }; } const map = review.snapshot?.goal_map; - if (!map) return

{review.kind === "loading" ? copy.loading : copy.workMap.unavailable}

; + if (!map) return
+ {review.kind === "loading" ? copy.loading : <>{copy.workMap.unavailable} + } +
; const selected = selectedTodoId === undefined ? localSelection : selectedTodoId ? map.nodes.find(node => node.refs.todo_ids?.includes(selectedTodoId))?.node_id ?? null : (() => {const node = map.nodes.find(node => node.node_id === localSelection); return node && (node.kind === "gate" || !node.refs.todo_ids?.length) ? localSelection : null;}) (); return <> - {!review.usable ?

{review.kind === "error" ? copy.refreshError : copy.updating}

: null} + {!review.usable && review.kind !== "ready" ?

{review.kind === "error" ? copy.refreshError : copy.updating}

: null} + {showSourceNotice ? : null} { setLocalSelection(id); diff --git a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md index 73d851fc4d..86e758bf16 100644 --- a/docs/architecture/rfcs/typescript-control-plane-migration-v0.md +++ b/docs/architecture/rfcs/typescript-control-plane-migration-v0.md @@ -1049,7 +1049,23 @@ Task-graph topology now shares `work_items/planning_relations.ts` with inventory and horizon. One pure TS request owns relationship discovery, deterministic bounded traversal, edge deduplication and missing/truncated completeness; the Python predecessor indexes, condition parser and traversal are retired. Python -retains status source adaptation and public-safe node/evidence/handoff rendering. +retains status source adaptation and public-safe node/evidence/handoff rendering +for the selected-work graph. The whole-Goal task map now composes its node kinds, +metadata visibility, edges and public map shape inside the same typed topology +request. Python keeps the shared renderable-source, text/state and stable-ID +codecs; metadata is sanitized only for admitted nodes. Those codecs retire when +the remaining selected-work graph and shared source consumers migrate together. +One map still uses one request. Expanded complete inputs use the existing private +snapshot transport instead of truncating the source at the ordinary wire limit. +The HTTP delivery-review composes its existing reads in one managed-runtime code +revision scope; provider reads and freshness checks retain their owners. +The native map now seals actual Todo-read time, unrenderable/display omissions +and unresolved relations through the existing projection envelope owner. +The HTTP wrapper inherits status and map sources in one coarse sealing request; +this adds one runtime crossing to the complete HTTP read, not per-node calls. +The shipped reader discloses stale, unreadable, partial or unknown sources and +preserves their envelope in exports. Original source opening and authorization +owners remain unchanged; metadata is not a new execution or configuration gate. This intentionally distinguishes successor lineage from completion dependencies, corrects unblocks direction, includes Monitor generation conditions and preserves parallel/diamond edges at the node cap. See the [graph contract](../../reference/protocols/task-graph-projection-v0.md#typed-todo-topology). diff --git a/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md b/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md index 2e02b62908..890677c190 100644 --- a/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md +++ b/docs/architecture/rfcs/typescript-control-plane-migration-v0.zh-CN.md @@ -738,7 +738,19 @@ Handoff mode 的 legacy adapter 与原生 CAS/receipt 事务现共用 TS 空 Task graph topology 与 inventory/horizon 共用 `work_items/planning_relations.ts`。 一轮纯 TS 请求拥有关系发现、稳定有界遍历、边去重与缺失/截断完整度;删除 Python 的前驱索引、条件拆解和遍历。Python 保留 status 来源适配及节点、 -evidence/handoff 的脱敏展示。明确的语义修正:successor 谱系不再冒充完成 +evidence/handoff 的脱敏展示,适用于选中工作项的图。整 Goal 任务地图现在在 +同一 TS 拓扑请求内组合节点类型、元数据可见性、边和公共地图结构。Python 暂留共享的 +可展示来源、文本/状态及稳定 ID codec;元数据只对已准入节点做脱敏。剩余的 +选中工作项图和共享来源消费者一起迁移后,这些 codec 退役。一张地图仍只发 +一次请求;扩大的完整输入复用既有私有快照传输,不在普通传输上限处截断来源。 +HTTP delivery-review 的既有读取复用同一托管运行时代码版本作用域;provider +读取和新鲜度检查仍由既有 owner 持有。 +原生地图现经既有 projection envelope owner 封装真实 Todo 读取时间、无法展示或 +超出上限的条目及未解析关系。HTTP 外层用一次整批封装请求继承 status 与地图来源; +完整 HTTP 读取增加一次运行时调用,不增加逐节点调用。已交付读界面披露过期、 +不可读、部分或未知来源,导出保留封套。原来源打开和授权 owner 不变;这些元数据 +不成为新的执行或配置门槛。 +明确的语义修正:successor 谱系不再冒充完成 依赖,unblocks 方向修正,补 Monitor generation 条件,上限处保留平行关系 和菱形汇合边。详见[图协议](../../reference/protocols/task-graph-projection-v0.md#typed-todo-topology)。 不改变生命周期准入、claim/lease 或默认 provider。来源仍可能不完整:本批 diff --git a/docs/reference/contracts/projection-envelope-contract.md b/docs/reference/contracts/projection-envelope-contract.md index 80799f6848..8e11333134 100644 --- a/docs/reference/contracts/projection-envelope-contract.md +++ b/docs/reference/contracts/projection-envelope-contract.md @@ -23,6 +23,16 @@ Python-owned projections do so through `loopx/control_plane/projection_envelope_ | `loopx global-gates` | `global_gates` | `global` | | `loopx global-todos` | `global_todos` | `global` | | `loopx global-risks` | `global_risks` | `global` | +| Goal task map in delivery review | `goal_task_map` | `goal_task_map` | +| `GET /api/chat/delivery-review` | `delivery_review` | `selected_goal_delivery` | + +The Goal map seals actual Todo-read time and source/display omissions in its +existing native composition. Its envelope does not certify task acceptance or +execution readiness. The outer review inherits both the status and map sources; +an unreadable map remains `null` and alerts without hiding healthy components. +Unknown or truncated source scope cannot certify complete coverage. The shipped +reader displays these flags, preserves them in exports and uses the existing +refresh operation. Older responses without the envelope show unknown coverage. Other read models adopt the envelope in the order listed in the RFC; until then they carry no freshness guarantee and consumer rule 1 below applies. diff --git a/docs/reference/protocols/task-graph-projection-v0.md b/docs/reference/protocols/task-graph-projection-v0.md index fbe0692118..81071fc1bf 100644 --- a/docs/reference/protocols/task-graph-projection-v0.md +++ b/docs/reference/protocols/task-graph-projection-v0.md @@ -213,10 +213,13 @@ added to status hot paths. - Edges use the relation mapping in the table above. `enforcement` reuses the `planning_relations` catalog (`typed_lifecycle`, `typed_condition`, `lineage_only`), so a reader can tell a lifecycle link from lineage. -- The TS effect `work_item.task_graph.goal_topology` owns admission, edges and - `depth` (the longest recorded prerequisite path). Admission is deterministic: +- The TS effect `work_item.task_graph.goal_topology` composes the map's node + kinds, metadata visibility, edges, limits and `depth` (the longest recorded + prerequisite path). Python retains shared source/text/state/identity codecs; + owner labels are sanitized only after admission. Admission is deterministic: unfinished work first, then direct completed prerequisites of unfinished - work, then remaining history, up to `node_limit` (at most 200). + work, then remaining history, up to the public map's 120-node limit. The + reusable internal topology lens accepts limits up to 200. - `depends_on` edges order the map. Lineage (`continues`, `supersedes`) carries no ordering obligation: it adds depth only where it agrees with the dependency order. A follow-up recorded opposite to a dependency, such as a @@ -228,6 +231,14 @@ added to status hot paths. nodes. - An unreadable Todo source returns `goal_map: null` and leaves the rest of the delivery review available. +- The native map carries the existing `projection_envelope`, scoped to + `goal_task_map`. Actual Todo read time is distinct from assembly time; + missing read facts stay unknown. Unrenderable Todos, display-limit omissions + and unresolved relations prevent a whole-map completeness claim. A truncated + source has an unknown denominator. The outer delivery review inherits source + age and incomplete coverage from status and the map; an unreadable map alerts. + The UI and Markdown export disclose these flags without changing source-open + or execution authorization. See the [kernel read contract](../contracts/projection-envelope-contract.md). Like the predecessor lens, the map has no write authority. A drawn line is a recorded relation, not a readiness verdict; the resume evaluator and lifecycle diff --git a/examples/personal-workspace-browser/goal-work-map.mjs b/examples/personal-workspace-browser/goal-work-map.mjs index f02721fd46..6a510ceca3 100644 --- a/examples/personal-workspace-browser/goal-work-map.mjs +++ b/examples/personal-workspace-browser/goal-work-map.mjs @@ -6,6 +6,7 @@ import { tmpdir } from "node:os"; import { createInterface } from "node:readline"; import { resolveTestPython } from "../../scripts/test-python.mjs"; import { outputDir, repoRoot } from "./fixture.mjs"; +import { deliveryReadFixture } from "./showcase-workspace.mjs"; import { fetchWorkspaceBackend, openWorkspacePage } from "./scenario-context.mjs"; async function requestAuthority(text) { @@ -123,7 +124,7 @@ export const goalWorkMapScenario = { const routeReview = (api, page) => page.route("**/api/chat/delivery-review?*", route => { const goalId = new URL(route.request().url()).searchParams.get("goal_id"); reads.push(goalId); - return route.fulfill({ json: { ok: true, goal_id: goalId, observed_at: new Date().toISOString(), graph: null, goal_map: goalMap(goalId, limits), acceptance: null } }); + return route.fulfill({ json: deliveryReadFixture({ ok: true, goal_id: goalId, observed_at: new Date().toISOString(), graph: null, goal_map: goalMap(goalId, limits), acceptance: null }) }); }); const beforeGoto = async (api, page) => { await routeReview(api, page); await routeRequest(api, page); }; const desktop = await openWorkspacePage(browser, url, { collectCoverage, beforeGoto }); diff --git a/examples/personal-workspace-browser/goal-workspace-m2.mjs b/examples/personal-workspace-browser/goal-workspace-m2.mjs index cf2204e8f6..d27767df92 100644 --- a/examples/personal-workspace-browser/goal-workspace-m2.mjs +++ b/examples/personal-workspace-browser/goal-workspace-m2.mjs @@ -3,7 +3,7 @@ import {resolve} from "node:path"; import {outputDir, repoRoot} from "./fixture.mjs"; import {readFile} from "node:fs/promises"; import {openWorkspacePage} from "./scenario-context.mjs"; -import {installShowcaseWorkspace, openShowcaseGoal, showcaseGoalId} from "./showcase-workspace.mjs"; +import {deliveryReadFixture, installShowcaseWorkspace, openShowcaseGoal, showcaseGoalId} from "./showcase-workspace.mjs"; export const goalWorkspaceM2Scenario = { id: "goal-workspace-m2", @@ -36,11 +36,11 @@ export const goalWorkspaceM2Scenario = { }; await page.route("**/api/chat/delivery-review?*", async route => { if (!scale) {await route.fallback(); return;} - await route.fulfill({json: {ok:true, goal_id:showcaseGoalId, observed_at:new Date().toISOString(), acceptance:null, graph:null, + await route.fulfill({json: deliveryReadFixture({ok:true, goal_id:showcaseGoalId, observed_at:new Date().toISOString(), acceptance:null, graph:null, goal_map:{schema_version:"goal_task_map_v0",mode:"read_only",goal_id:showcaseGoalId, limits:{node_limit:200,emitted_node_count:200,omitted_node_count:0,source_truncated:false,missing_endpoint_count:0,cycle_edge_count:0,topology_complete:true}, nodes:many.map((todo,i)=>({node_id:todo.todo_id,kind:"deliverable",title:todo.text,state:i===0?"blocked":"open",depth:i%2,refs:{todo_ids:[todo.todo_id]}})), - edges:many.filter((_,i)=>i%2===1).map(todo=>({edge_id:`edge-${todo.todo_id}`,from_node_id:todo.todo_id,to_node_id:`scale-${String(Number(todo.todo_id.slice(6))-1).padStart(3,"0")}`,relation:"depends_on",enforcement:"typed_condition",reason:"Recorded synthetic completion condition"}))}}}); + edges:many.filter((_,i)=>i%2===1).map(todo=>({edge_id:`edge-${todo.todo_id}`,from_node_id:todo.todo_id,to_node_id:`scale-${String(Number(todo.todo_id.slice(6))-1).padStart(3,"0")}`,relation:"depends_on",enforcement:"typed_condition",reason:"Recorded synthetic completion condition"}))}})}); }); page.on("request", request => {if (request.method() !== "GET" && /actions|quota|validat/.test(request.url())) writes.push(request.url());}); }}); diff --git a/examples/personal-workspace-browser/showcase-workspace.mjs b/examples/personal-workspace-browser/showcase-workspace.mjs index 803065dc6e..b21061b77f 100644 --- a/examples/personal-workspace-browser/showcase-workspace.mjs +++ b/examples/personal-workspace-browser/showcase-workspace.mjs @@ -4,6 +4,31 @@ // take their before/after screenshots from this state so reviewers judge the // composition against realistic content rather than fixture placeholders. // Every name, number and title here is invented. +import { sealProjectionEnvelope, PROJECTION_ENVELOPE_SEAL_REQUEST } from "../../loopx/control_plane/projection_envelope.ts"; + +/** Synthetic response metadata, not evidence of real source/backend freshness. */ +export function deliveryReadFixture(snapshot) { + const map = snapshot.goal_map; + const sources = [{source_id: "fixture_delivery", read_status: "read", last_read_at: snapshot.observed_at}]; + const seal = (projection, coverage, upstream = []) => sealProjectionEnvelope({ + schema_version: PROJECTION_ENVELOPE_SEAL_REQUEST, projection, + observed_at: snapshot.observed_at, sources: projection === "goal_task_map" ? sources : [], coverage, upstream, + }); + const omitted = map ? [ + {reason: "map_node_limit", count: map.limits.omitted_node_count}, + {reason: "unresolved_map_relations", count: map.limits.missing_endpoint_count + map.limits.cycle_edge_count}, + ].filter(row => row.count > 0) : []; + const mapEnvelope = map ? seal("goal_task_map", {scope: "goal_task_map", + expected_count: map.limits.source_truncated ? null : map.nodes.length + map.limits.omitted_node_count, + included_count: map.nodes.length, omitted}) : null; + const missing = [snapshot.graph, map, snapshot.acceptance].filter(value => value == null).length; + return {...snapshot, goal_map: map ? {...map, projection_envelope: mapEnvelope} : null, + projection_envelope: seal("delivery_review", {scope: "selected_goal_delivery", expected_count: 3, + included_count: 3 - missing, omitted: missing ? [{reason: "unavailable_components", count: missing}] : []}, + mapEnvelope ? [mapEnvelope] : []), + }; +} + export const showcaseGoalId = "progress-projection"; export const showcaseGoalTitle = "Migrate billing exports to the ledger API"; const goalId = showcaseGoalId; @@ -65,7 +90,7 @@ const mapNode = (id, kind, nodeTitle, state, depth, extra = {}) => const mapEdge = (from, to, relation, enforcement) => ({ edge_id: `map_${from}_${to}_${relation}`, from_node_id: `map_${from}`, to_node_id: `map_${to}`, relation, enforcement, reason: "Recorded task relation." }); function deliverySnapshot() { - return { + return deliveryReadFixture({ ok: true, goal_id: goalId, observed_at: minutesAgo(1), acceptance, goal_map: { schema_version: "goal_task_map_v0", mode: "read_only", goal_id: goalId, @@ -90,7 +115,7 @@ function deliverySnapshot() { ], }, graph: null, - }; + }); } /** Installs the showcase status, usage and delivery reads. `state` stays live: change it, then refresh. */ diff --git a/loopx/chat_status_api.py b/loopx/chat_status_api.py index 8a1fa13a28..9402f41fe7 100644 --- a/loopx/chat_status_api.py +++ b/loopx/chat_status_api.py @@ -4,7 +4,6 @@ import errno import json -from datetime import datetime, timezone from pathlib import Path from typing import Any from urllib.parse import parse_qs, urlparse @@ -24,6 +23,7 @@ EffectRuntimePermanentIOError, EffectRuntimeRemoteError, EffectRuntimeStartupError, + effect_runtime_request_scope, ) from .control_plane.runtime.public_safety import ( public_safe_compact_text, @@ -36,6 +36,8 @@ todo_done_for_status, ) from .control_plane.work_items.task_graph import build_goal_task_map +from .control_plane.projection_envelope_facts import seal_projection_envelope, source_fact +from .control_plane.runtime.time import now_utc_iso from .feedback import validate_goal_id from .history import load_registry from .control_plane.projects.registry_codec import load_project_registry @@ -52,6 +54,7 @@ def _goal_task_map(server: Any, goal_id: str) -> dict[str, Any] | None: runtime_root = server.runtime_root_override + source_read_at = now_utc_iso() try: listed = list_goal_todos( registry_path=server.registry_path, @@ -71,6 +74,7 @@ def _goal_task_map(server: Any, goal_id: str) -> dict[str, Any] | None: todo_done_for_status=todo_done_for_status, todo_status_open=TODO_STATUS_OPEN, monitor_task_class=TODO_TASK_CLASS_MONITOR, + source_read_at=source_read_at, ) @@ -159,7 +163,8 @@ def _send_json(self, payload: dict[str, Any], *, status: int = 200) -> None: raise NotImplementedError def _delivery_review(self) -> None: - self._status(delivery_review=True) + with effect_runtime_request_scope(): + self._status(delivery_review=True) def _status(self, *, delivery_review: bool = False) -> None: query = parse_qs(urlparse(self.path).query, keep_blank_values=True) @@ -275,12 +280,31 @@ def _status(self, *, delivery_review: bool = False) -> None: if row.get("goal_id") == goal_id), {}) goal = next((row for row in projection.get("run_history", {}).get("goals", []) if row.get("id") == goal_id), {}) + goal_map = _goal_task_map(self.server, goal_id) + observed_at = now_utc_iso() + components = [item.get("task_graph_projection"), goal_map, goal.get("acceptance_observation")] + upstream = [value for value in [projection.get("projection_envelope"), + goal_map.get("projection_envelope") if goal_map else None] if isinstance(value, dict)] + sources = [] + if not isinstance(projection.get("projection_envelope"), dict): + sources.append(source_fact("status", read_status="not_read")) + if goal_map is None: + sources.append(source_fact("goal_todos", read_status="unreadable")) + missing = sum(value is None for value in components) + envelope = seal_projection_envelope( + projection="delivery_review", observed_at=observed_at, + sources=sources, upstream=upstream, + coverage={"scope": "selected_goal_delivery", "expected_count": 3, + "included_count": 3 - missing, + "omitted": [{"reason": "unavailable_components", "count": missing}] if missing else []}, + ) projection = { "ok": True, "goal_id": goal_id, - "observed_at": datetime.now(timezone.utc).isoformat(), + "observed_at": observed_at, + "projection_envelope": envelope, "graph": item.get("task_graph_projection"), - "goal_map": _goal_task_map(self.server, goal_id), + "goal_map": goal_map, "acceptance": goal.get("acceptance_observation"), } validate_public_safe_value(projection) diff --git a/loopx/control_plane/effect_runtime.py b/loopx/control_plane/effect_runtime.py index 29ec488dd1..7e592ed798 100644 --- a/loopx/control_plane/effect_runtime.py +++ b/loopx/control_plane/effect_runtime.py @@ -46,6 +46,7 @@ "coordination.local_authority.todo_source", "todo.context.page", "work_item.context.project", + "work_item.task_graph.goal_topology", "work_item.replan_semantics.project", "quota.settlement.read", "goal.checkpoint_read_context.resolve", diff --git a/loopx/control_plane/effect_runtime_handlers.ts b/loopx/control_plane/effect_runtime_handlers.ts index 64b388427f..3f6aa6a551 100644 --- a/loopx/control_plane/effect_runtime_handlers.ts +++ b/loopx/control_plane/effect_runtime_handlers.ts @@ -353,7 +353,7 @@ export function createEffectRuntimeHandlers( ["work_item.planning_horizon.project", lazyHandler(() => import("./work_items/planning_horizon.ts"), ({projectQuotaPlanningHorizon}) => projectQuotaPlanningHorizon)], ["todo.context.page", lazyHandler(() => import("./todos/context_projection.ts"), ({projectTodoContextPage}) => projectTodoContextPage)], ["work_item.task_graph.topology", lazyHandler(() => import("./work_items/task_graph.ts"), ({projectTaskGraphTopology}) => projectTaskGraphTopology)], - ["work_item.task_graph.goal_topology", lazyHandler(() => import("./work_items/task_graph.ts"), ({projectGoalTaskGraphTopology}) => projectGoalTaskGraphTopology)], + ["work_item.task_graph.goal_topology", lazyHandler(() => import("./work_items/task_graph.ts"), ({projectGoalTaskMap}) => projectGoalTaskMap)], ["work_item.planning_inventory.project", lazyHandler(() => import("./work_items/planning_inventory.ts"), ({projectTodoPlanningInventory}) => projectTodoPlanningInventory)], ["work_item.planning_inventory.detail", lazyHandler(() => import("./work_items/planning_inventory.ts"), ({projectTodoPlanningInventoryDetail}) => projectTodoPlanningInventoryDetail)], ["work_item.refresh_recommendation.resolve", lazyHandler(() => import("./work_items/refresh_recommendation.ts"), ({resolveRefreshRecommendation}) => resolveRefreshRecommendation)], diff --git a/loopx/control_plane/effect_runtime_server.ts b/loopx/control_plane/effect_runtime_server.ts index 7ddbff9586..0760268b9e 100644 --- a/loopx/control_plane/effect_runtime_server.ts +++ b/loopx/control_plane/effect_runtime_server.ts @@ -32,6 +32,7 @@ const LOCAL_SNAPSHOT_METHODS = new Set([ "coordination.local_authority.todo_source", "todo.context.page", "work_item.context.project", + "work_item.task_graph.goal_topology", "work_item.replan_semantics.project", "quota.settlement.read", "goal.checkpoint_read_context.resolve", diff --git a/loopx/control_plane/work_items/task_graph.py b/loopx/control_plane/work_items/task_graph.py index 95dc628f46..0f1bcedb90 100644 --- a/loopx/control_plane/work_items/task_graph.py +++ b/loopx/control_plane/work_items/task_graph.py @@ -1,11 +1,12 @@ from __future__ import annotations import hashlib +import json import re from typing import Any, Callable from ..runtime.time import now_utc_iso -from ..effect_runtime import effect_runtime_result +from ..effect_runtime import MAX_REQUEST_BYTES, effect_runtime_result from ..todos.contract import TODO_STATUS_DEFERRED from ..todos.summary_item import todo_planning_source_items @@ -966,10 +967,6 @@ def build_task_graph_projection( } -GOAL_TASK_MAP_SCHEMA_VERSION = "goal_task_map_v0" -GOAL_TASK_MAP_MAX_NODES = 120 - - def build_goal_task_map( *, goal_id: str, @@ -980,79 +977,68 @@ def build_goal_task_map( todo_done_for_status: Callable[[str], bool], todo_status_open: str, monitor_task_class: str, + source_read_at: str | None = None, ) -> dict[str, Any]: - """Read-only map of one Goal's active Todos and their recorded relations.""" + """Adapt shared source/identity codecs to the native Goal-map composer.""" - renderable: dict[str, tuple[dict[str, Any], str, str]] = {} rows = [] + renderable: dict[str, tuple[dict[str, Any], str, str]] = {} for value in todos: tid = public_safe_compact_text(value.get("todo_id"), limit=120) title = public_safe_compact_text(value.get("title") or value.get("text"), limit=160) if not tid or not title or tid in renderable: continue state = _task_graph_todo_state( - value, - normalize_todo_status=normalize_todo_status, - todo_done_for_status=todo_done_for_status, - todo_status_open=todo_status_open, + value, normalize_todo_status=normalize_todo_status, + todo_done_for_status=todo_done_for_status, todo_status_open=todo_status_open, ) renderable[tid] = (value, title, state) - rows.append(_task_graph_topology_row( - tid, value, done=state == "done", public_safe_compact_text=public_safe_compact_text, + rows.append({ + **_task_graph_topology_row( + tid, value, done=state == "done", public_safe_compact_text=public_safe_compact_text, + ), + "is_user": value.get("role") == "user", + "is_monitor": value.get("task_class") == monitor_task_class, + }) + request = { + "schema_version": "goal_task_map_request_v0", "goal_id": goal_id, + "items": rows, "source_truncated": source_truncated, + "read_facts": {"observed_at": now_utc_iso(), "source_read_at": source_read_at, + "source_count": len(todos)}, + } + # Reserve framing bytes while keeping ordinary maps on the inline transport. + use_snapshot = len(json.dumps(request, separators=(",", ":")).encode()) > MAX_REQUEST_BYTES - 4096 + result = effect_runtime_result( + "work_item.task_graph.goal_topology", request, large_local_snapshot=use_snapshot, + ) + if not isinstance(result, dict) or result.get("schema_version") != "goal_task_map_result_v0": + raise RuntimeError("TypeScript goal task map shape mismatch") + projection = result["projection"] + nodes = projection["nodes"] + node_ids = [] + materialized = [] + for node, prefix, fields in zip(nodes, result["node_identity_prefixes"], result["node_metadata_fields"], strict=True): + tid = node["refs"]["todo_ids"][0] + node_ids.append(_task_graph_node_id( + prefix, tid, public_safe_compact_text=public_safe_compact_text, durable_id=tid, )) - result = effect_runtime_result("work_item.task_graph.goal_topology", { - "schema_version": "task_graph_goal_topology_request_v0", - "items": rows, - "node_limit": GOAL_TASK_MAP_MAX_NODES, - "source_truncated": source_truncated, - }) - if not isinstance(result, dict) or result.get("schema_version") != "task_graph_goal_topology_result_v0": - raise RuntimeError("TypeScript goal task graph topology shape mismatch") - nodes: list[dict[str, Any]] = [] - node_ids: dict[str, str] = {} - for entry in result["nodes"]: - tid = entry["todo_id"] value, title, state = renderable[tid] - gate = value.get("role") == "user" - node_ids[tid] = _task_graph_node_id( - "node_gate" if gate else "node_todo", tid, - public_safe_compact_text=public_safe_compact_text, durable_id=tid, - ) - node: dict[str, Any] = { - "node_id": node_ids[tid], - "kind": "gate" if gate else ( - "monitor" if value.get("task_class") == monitor_task_class else "deliverable" - ), - "title": title, - "state": state, - "depth": entry["depth"], - "refs": {"todo_ids": [tid]}, - } + rendered = {"node_id": node_ids[-1], "kind": node["kind"], "title": title, + "state": state, "depth": node["depth"], "refs": node["refs"]} for key, field in (("owner_agent", "claimed_by"), ("task_domain", "task_domain")): text = public_safe_compact_text(value.get(field), limit=80) - if text and not gate: - node[key] = text - nodes.append(node) - edges = [ - { - "edge_id": _task_graph_node_id( - f"edge_{edge['source_relation']}", - f"{node_ids[edge['from_todo_id']]}:{node_ids[edge['to_todo_id']]}", - public_safe_compact_text=public_safe_compact_text, - ), - "from_node_id": node_ids[edge["from_todo_id"]], - "to_node_id": node_ids[edge["to_todo_id"]], - "relation": edge["relation"], - "enforcement": edge["enforcement"], - "reason": edge["reason"], - } - for edge in result["edges"] - ] - return { - "schema_version": GOAL_TASK_MAP_SCHEMA_VERSION, - "mode": "read_only", - "goal_id": goal_id, - "limits": result["completeness"], - "nodes": nodes, - "edges": edges, - } + if text and key in fields: + rendered[key] = text + materialized.append(rendered) + projection["nodes"] = materialized + # These codecs also serve the status graph; keep one owner until that caller migrates. + projection["edges"] = [{ + "edge_id": _task_graph_node_id( + f"edge_{identity['source_relation']}", + f"{node_ids[identity['from_node_index']]}:{node_ids[identity['to_node_index']]}", + public_safe_compact_text=public_safe_compact_text, + ), + "from_node_id": node_ids[identity["from_node_index"]], + "to_node_id": node_ids[identity["to_node_index"]], **edge, + } for edge, identity in zip(projection["edges"], result["edge_identities"], strict=True)] + return projection diff --git a/loopx/control_plane/work_items/task_graph.ts b/loopx/control_plane/work_items/task_graph.ts index aee36f09c5..187909f052 100644 --- a/loopx/control_plane/work_items/task_graph.ts +++ b/loopx/control_plane/work_items/task_graph.ts @@ -4,6 +4,7 @@ import { requireBoolean, requireInteger, requireJsonObject, requireNonEmptyStrin requireStringArray } from "../runtime_decode.ts"; import { projectRelations, todoRef, type PlanningInventoryRelation, type PlanningRelationSource } from "./planning_relations.ts"; +import { PROJECTION_ENVELOPE_SEAL_REQUEST, sealProjectionEnvelope } from "../projection_envelope.ts"; export const TASK_GRAPH_TOPOLOGY_REQUEST = "task_graph_topology_request_v0"; export const TASK_GRAPH_TOPOLOGY_RESULT = "task_graph_topology_result_v0"; @@ -212,3 +213,66 @@ export function projectGoalTaskGraphTopology(value: unknown): JsonObject { }, }; } + +export const GOAL_TASK_MAP_REQUEST = "goal_task_map_request_v0"; +export const GOAL_TASK_MAP_RESULT = "goal_task_map_result_v0"; +const GOAL_TASK_MAP_MAX_NODES = 120; + +function goalMapNode(facts: JsonObject, depth: number): JsonObject { + const gate = requireBoolean(facts.is_user, "goal map row.is_user"); + const monitor = requireBoolean(facts.is_monitor, "goal map row.is_monitor"); + return { + kind: gate ? "gate" : monitor ? "monitor" : "deliverable", + depth, refs: { todo_ids: [facts.todo_id] }, + }; +} + +/** Source and display codecs stay with the caller; native owns map composition. */ +export function projectGoalTaskMap(value: unknown): JsonObject { + const request = requireJsonObject(value, "goal task map request"); + if (request.schema_version !== GOAL_TASK_MAP_REQUEST) { + throw new EffectRuntimeRequestError("Goal task map request schema mismatch"); + } + if (typeof request.goal_id !== "string") throw new EffectRuntimeRequestError("goal_id must be a string"); + const sourceTruncated = requireBoolean(request.source_truncated, "source_truncated"); + const topology = projectGoalTaskGraphTopology({ + schema_version: TASK_GRAPH_GOAL_TOPOLOGY_REQUEST, items: request.items, + node_limit: GOAL_TASK_MAP_MAX_NODES, source_truncated: sourceTruncated, + }); + const rows = request.items as JsonObject[]; + const byId = new Map(rows.map(facts => [facts.todo_id as string, facts])); + const entries = topology.nodes as { todo_id: string; depth: number }[]; + const nodes = entries.map(entry => goalMapNode(byId.get(entry.todo_id)!, entry.depth)); + const indices = new Map(entries.map((entry, index) => [entry.todo_id, index])); + const edges = topology.edges as GraphEdge[]; + const facts = requireJsonObject(request.read_facts, "goal map read facts"); + const sourceCount = requireInteger(facts.source_count, "goal map source_count"); + if (sourceCount < rows.length) throw new EffectRuntimeRequestError("goal map source count is incomplete"); + const limits = topology.completeness as JsonObject; + const omitted = [ + {reason: "unrenderable_todos", count: sourceCount - rows.length}, + {reason: "map_node_limit", count: Number(limits.omitted_node_count)}, + {reason: "unresolved_map_relations", count: Number(limits.missing_endpoint_count) + Number(limits.cycle_edge_count)}, + ].filter(value => value.count > 0); + const envelope = sealProjectionEnvelope({ + schema_version: PROJECTION_ENVELOPE_SEAL_REQUEST, projection: "goal_task_map", + observed_at: facts.observed_at, + sources: [{source_id: "goal_todos", read_status: facts.source_read_at == null ? "not_read" : "read", + last_read_at: facts.source_read_at, item_count: sourceCount}], + coverage: {scope: "goal_task_map", expected_count: sourceTruncated ? null : sourceCount, + included_count: nodes.length, omitted, shown_count: nodes.length, available_count: sourceCount}, + }); + return { + schema_version: GOAL_TASK_MAP_RESULT, + projection: { + schema_version: "goal_task_map_v0", mode: "read_only", goal_id: request.goal_id, + limits: topology.completeness, nodes, + projection_envelope: envelope, + edges: edges.map(edge => ({ relation: edge.relation, enforcement: edge.enforcement, reason: edge.reason })), + }, + node_identity_prefixes: nodes.map(node => node.kind === "gate" ? "node_gate" : "node_todo"), + node_metadata_fields: nodes.map(node => node.kind === "gate" ? [] : ["owner_agent", "task_domain"]), + edge_identities: edges.map(edge => ({ from_node_index: indices.get(edge.from_todo_id)!, + to_node_index: indices.get(edge.to_todo_id)!, source_relation: edge.source_relation })), + }; +} diff --git a/tests/control_plane/test_goal_task_map_composition.py b/tests/control_plane/test_goal_task_map_composition.py new file mode 100644 index 0000000000..73a888ce07 --- /dev/null +++ b/tests/control_plane/test_goal_task_map_composition.py @@ -0,0 +1,174 @@ +"""Public Goal-map composition and its existing compatibility codecs.""" +from __future__ import annotations + +from copy import deepcopy +import json + +import pytest + +from loopx.control_plane.runtime.public_safety import public_safe_compact_text +from loopx.control_plane.todos.contract import ( + TODO_STATUS_OPEN, TODO_TASK_CLASS_MONITOR, normalize_todo_status, todo_done_for_status, +) +from loopx.control_plane.work_items import task_graph + + +def project(rows, *, goal_id="graph-fixture", source_truncated=False, normalizer=normalize_todo_status, + source_read_at=None): + return task_graph.build_goal_task_map( + goal_id=goal_id, todos=rows, source_truncated=source_truncated, + public_safe_compact_text=public_safe_compact_text, + normalize_todo_status=normalizer, todo_done_for_status=todo_done_for_status, + todo_status_open=TODO_STATUS_OPEN, monitor_task_class=TODO_TASK_CLASS_MONITOR, + source_read_at=source_read_at, + ) + + +def test_map_preserves_source_age_and_unknown_or_partial_coverage(monkeypatch): + monkeypatch.setattr(task_graph, "now_utc_iso", lambda: "2026-10-01T00:06:00Z") + rows = [{"todo_id": f"todo_work_{index}", "text": f"Review work {index}"} for index in range(121)] + result = project(rows, source_read_at="2026-10-01T00:00:00Z") + envelope = result["projection_envelope"] + assert not envelope["fresh"] and not envelope["complete"] + assert envelope["sources"][0]["last_read_at"] == "2026-10-01T00:00:00Z" + assert envelope["coverage"]["expected_count"] == 121 + assert envelope["coverage"]["included_count"] == 120 + assert envelope["coverage"]["omitted"] == [{"reason": "map_node_limit", "count": 1, "refs": []}] + unknown = project(rows[:1])["projection_envelope"] + assert not unknown["fresh"] and unknown["alert"] + assert unknown["sources"][0]["read_status"] == "not_read" + partial = project(rows[:1], source_truncated=True, + source_read_at="2026-10-01T00:06:00Z")["projection_envelope"] + assert partial["fresh"] and not partial["complete"] + assert partial["coverage"]["expected_count"] is None + + +@pytest.mark.parametrize(("role", "task_class", "kind", "owner_visible"), [ + ("user", "continuous_monitor", "gate", False), + ("agent", "continuous_monitor", "monitor", True), + ("agent", "advancement_task", "deliverable", True), + (" user ", " continuous_monitor ", "deliverable", True), +]) +def test_role_controls_kind_and_public_owner_fields(role, task_class, kind, owner_visible): + node, = project([{"todo_id": "todo_work", "text": "Review work", "role": role, + "task_class": task_class, "claimed_by": "builder", "task_domain": "release"}])["nodes"] + assert node["kind"] == kind + assert ("owner_agent" in node) is owner_visible + assert ("task_domain" in node) is owner_visible + + +@pytest.mark.parametrize(("status", "done", "expected"), [ + ("deferred", True, "waiting"), ("done", False, "done"), + ("blocked", False, "blocked"), ("open", False, "open"), + (None, ["legacy truthy marker"], "done"), ("unrecognized", False, "open"), +]) +def test_existing_shared_state_codec_remains_authoritative(status, done, expected): + node, = project([{"todo_id": "todo_work", "text": "Review work", "status": status, + "done": done}])["nodes"] + assert node["state"] == expected + + +def test_first_renderable_compact_identity_wins_without_overwriting_metadata(): + result = project([ + {"todo_id": "todo_duplicate", "title": ""}, + {"todo_id": " todo_duplicate ", "text": "First visible", "claimed_by": "first"}, + {"todo_id": "todo_duplicate", "text": "Later duplicate", "claimed_by": "later"}, + ]) + node, = result["nodes"] + assert (node["title"], node["owner_agent"], node["refs"]) == ( + "First visible", "first", {"todo_ids": ["todo_duplicate"]}) + + +def test_discarded_sources_do_not_evaluate_their_status_codec(): + def normalize(value): + if value == "discarded": + raise ValueError("discarded status must stay unread") + return normalize_todo_status(value) + result = project([ + {"todo_id": "todo_missing_title", "status": "discarded"}, + {"todo_id": "todo_work", "text": "Review work", "status": "open"}, + {"todo_id": "todo_work", "text": "Discarded duplicate", "status": "discarded"}, + ], normalizer=normalize) + assert [node["title"] for node in result["nodes"]] == ["Review work"] + + +def test_omitted_sources_keep_opaque_owner_metadata_unread(): + class OpaqueMetadata: + def __str__(self): + raise ValueError("omitted metadata must stay unread") + rows = [{"todo_id": f"todo_{index}", "text": f"Review {index}"} for index in range(120)] + rows.append({"todo_id": "todo_omitted", "text": "Not expanded", "claimed_by": OpaqueMetadata()}) + assert len(project(rows)["nodes"]) == 120 + + +def test_shared_identity_codec_survives_unicode_title_and_owner_changes(): + original = {"todo_id": "todo_世界", "title": "\ufeffReview 🧪 work", "claimed_by": "builder"} + before = deepcopy(original) + first = project([original])["nodes"][0] + second = project([{**original, "title": "Renamed work", "claimed_by": "reviewer"}])["nodes"][0] + assert first["title"] == original["title"] + assert first["node_id"] == second["node_id"] + assert original == before + + +def test_no_opaque_metadata_crosses_the_map_boundary(monkeypatch): + calls = [] + real = task_graph.effect_runtime_result + def observe(method, params, **kwargs): + calls.append((method, params)) + return real(method, params, **kwargs) + monkeypatch.setattr(task_graph, "effect_runtime_result", observe) + source = [{"todo_id": "todo_work", "text": "Review work", "claimed_by": "builder", + "note": "must-not-cross", "evidence": "must-not-cross", + "unknown": {"opaque_integer": 9007199254740993, "amount": 2.0}}] + original = deepcopy(source) + result = project(source) + assert len(calls) == 1 + assert calls[0][0] == "work_item.task_graph.goal_topology" + assert "must-not-cross" not in json.dumps(calls[0][1]) + assert "opaque_integer" not in json.dumps(calls[0][1]) + assert "unknown" not in result["nodes"][0] + assert source == original + + +def test_unsafe_display_rows_are_omitted_without_replacing_the_source(): + private_path = "/" + "home" + "/synthetic/private.txt" + result = project([ + {"todo_id": "todo_private", "text": private_path}, + {"todo_id": "todo_secret", "text": "api_key=synthetic-value"}, + {"todo_id": "todo_visible", "text": "Review work", "claimed_by": private_path}, + ]) + node, = result["nodes"] + assert node["refs"] == {"todo_ids": ["todo_visible"]} + assert "owner_agent" not in node + + +def test_full_source_admission_is_bounded_without_hiding_omitted_nodes(): + rows = [{"todo_id": f"todo_work_{index}", "text": f"Review work {index}"} for index in range(121)] + result = project(rows, source_truncated=True, goal_id=" literal goal scope ") + assert result["goal_id"] == " literal goal scope " + assert len(result["nodes"]) == result["limits"]["node_limit"] == 120 + assert result["limits"]["omitted_node_count"] == 1 + assert result["limits"]["source_truncated"] is True + assert result["limits"]["topology_complete"] is False + + +def test_complete_source_survives_the_ordinary_transport_frame(): + rows = [{"todo_id": f"todo_{index:06d}_" + "x" * 100, + "title": "Review source " + str(index) + " public" * 30, + "claimed_by": "b" * 80, "task_domain": "d" * 80} + for index in range(12000)] + result = project(rows) + assert len(result["nodes"]) == 120 + assert result["limits"]["omitted_node_count"] == 11880 + assert result["limits"]["source_truncated"] is False + assert result["nodes"][0]["owner_agent"] == "b" * 80 + assert result["nodes"][-1]["refs"]["todo_ids"] == [rows[119]["todo_id"]] + + +def test_native_refusal_has_no_python_fallback(monkeypatch): + def refuse(*args, **kwargs): + raise RuntimeError("synthetic native refusal") + monkeypatch.setattr(task_graph, "effect_runtime_result", refuse) + with pytest.raises(RuntimeError, match="synthetic native refusal"): + project([{"todo_id": "todo_work", "text": "Review work"}]) diff --git a/tests/control_plane_ts/task_graph.test.ts b/tests/control_plane_ts/task_graph.test.ts index 95efa22a2f..eded3a6c9d 100644 --- a/tests/control_plane_ts/task_graph.test.ts +++ b/tests/control_plane_ts/task_graph.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { projectGoalTaskGraphTopology, projectTaskGraphTopology, TASK_GRAPH_GOAL_TOPOLOGY_REQUEST, +import { GOAL_TASK_MAP_REQUEST, projectGoalTaskMap, projectGoalTaskGraphTopology, projectTaskGraphTopology, TASK_GRAPH_GOAL_TOPOLOGY_REQUEST, TASK_GRAPH_TOPOLOGY_REQUEST } from "../../loopx/control_plane/work_items/task_graph.ts"; import { projectRelations, todoRef } from "../../loopx/control_plane/work_items/planning_relations.ts"; import type { JsonObject } from "../../loopx/control_plane/effect_program.ts"; @@ -184,3 +184,130 @@ test("production-scale canonical fixture supports mixed ancestry without changin new Set(["lineage_only", "typed_condition"])); assert.equal(JSON.stringify(fixture.projection), before); }); + +const mapRow = (todo_id: string, extra: JsonObject = {}): JsonObject => ({ + ...row(todo_id, {done: false}), is_user: false, is_monitor: false, ...extra, +}); +function goalMap(items: JsonObject[], extra: JsonObject = {}): JsonObject { + return projectGoalTaskMap({schema_version: GOAL_TASK_MAP_REQUEST, goal_id: "goal_test", + source_truncated: false, items, + read_facts: {observed_at: "2026-10-01T00:00:00Z", source_read_at: "2026-10-01T00:00:00Z", + source_count: items.length}, ...extra}); +} +const mapProjection = (response: JsonObject) => response.projection as JsonObject; + +test("goal map composes topology and materialization instructions without emitting source-only facts", () => { + const response = goalMap([ + mapRow("todo_gate", {is_user: true, is_monitor: true, title: "\uFEFF", state: null, owner_agent: [], + task_domain: {}, unblocks_todo_id: "todo_work"}), + mapRow("todo_work", {title: {}, state: "invented", owner_agent: {}, + task_domain: [], private_notes: "must not escape"}), + mapRow("todo_monitor", {is_monitor: true}), + ], {goal_id: " \uFEFFgoal_test\uFEFF "}); + const envelope = mapProjection(response).projection_envelope as JsonObject; + assert.equal(envelope.fresh, true); + assert.equal(envelope.complete, true); + const legacy = structuredClone(response); + delete (legacy.projection as JsonObject).projection_envelope; + assert.deepEqual(legacy, { + schema_version: "goal_task_map_result_v0", + projection: { + schema_version: "goal_task_map_v0", mode: "read_only", goal_id: " \uFEFFgoal_test\uFEFF ", + limits: {node_limit: 120, emitted_node_count: 3, omitted_node_count: 0, source_truncated: false, + missing_endpoint_count: 0, cycle_edge_count: 0, topology_complete: true}, + nodes: [ + {kind: "gate", depth: 0, refs: {todo_ids: ["todo_gate"]}}, + {kind: "deliverable", depth: 1, refs: {todo_ids: ["todo_work"]}}, + {kind: "monitor", depth: 0, refs: {todo_ids: ["todo_monitor"]}}, + ], + edges: [{relation: "depends_on", enforcement: "typed_lifecycle", + reason: "Work has a typed lifecycle dependency on the linked unblocking Todo."}], + }, + node_identity_prefixes: ["node_gate", "node_todo", "node_todo"], + node_metadata_fields: [[], ["owner_agent", "task_domain"], ["owner_agent", "task_domain"]], + edge_identities: [{from_node_index: 1, to_node_index: 0, source_relation: "unblocks"}], + }); +}); + +test("goal map refuses duplicate canonical render-fact IDs", () => { + assert.throws(() => goalMap([ + mapRow("todo_same", {is_user: true}), mapRow("todo_same"), + ]), /Duplicate/); +}); + +test("goal map returns a complete empty map and preserves an empty Goal ID", () => { + const response = goalMap([], {goal_id: ""}); + assert.equal((mapProjection(response).projection_envelope as JsonObject).complete, true); + delete (response.projection as JsonObject).projection_envelope; + assert.deepEqual(response, { + schema_version: "goal_task_map_result_v0", + projection: {schema_version: "goal_task_map_v0", mode: "read_only", goal_id: "", + limits: {node_limit: 120, emitted_node_count: 0, omitted_node_count: 0, source_truncated: false, + missing_endpoint_count: 0, cycle_edge_count: 0, topology_complete: true}, nodes: [], edges: []}, + node_identity_prefixes: [], node_metadata_fields: [], edge_identities: [], + }); +}); + +test("goal map seals source age, unknown reads and unrenderable source coverage in the existing owner", () => { + const facts = {observed_at: "2026-10-01T00:06:00Z", source_read_at: "2026-10-01T00:00:00Z", source_count: 2}; + const envelope = mapProjection(goalMap([mapRow("todo_visible")], {read_facts: facts})).projection_envelope as JsonObject; + assert.equal(envelope.fresh, false); + assert.equal(envelope.complete, false); + assert.deepEqual((envelope.coverage as JsonObject).omitted, + [{reason: "unrenderable_todos", count: 1, refs: []}]); + const unknown = mapProjection(goalMap([], {read_facts: {...facts, source_read_at: null, source_count: 0}})).projection_envelope as JsonObject; + assert.equal(unknown.fresh, false); + assert.equal((unknown.sources as JsonObject[])[0]!.read_status, "not_read"); + assert.throws(() => goalMap([mapRow("todo_visible")], {read_facts: {...facts, source_count: 0}}), /source count/); +}); + +test("goal map carries missing endpoints, source truncation and dependency cycles into limits", () => { + const response = goalMap([ + mapRow("todo_alpha", {resume_when: "todo_done:todo_beta"}), + mapRow("todo_beta", {resume_when: "todo_done:todo_alpha"}), + mapRow("todo_gamma", {resume_when: "todo_done:todo_filtered"}), + ], {source_truncated: true}); + const projection = mapProjection(response); + assert.deepEqual(projection.limits, {node_limit: 120, emitted_node_count: 3, omitted_node_count: 0, + source_truncated: true, missing_endpoint_count: 1, cycle_edge_count: 1, topology_complete: false}); + assert.deepEqual((projection.nodes as JsonObject[]).map(node => node.depth), [1, 0, 0]); + assert.deepEqual(response.edge_identities, [ + {from_node_index: 0, to_node_index: 1, source_relation: "resumes_when"}, + {from_node_index: 1, to_node_index: 0, source_relation: "resumes_when"}, + ]); +}); + +test("goal map admits active work and direct prerequisites before filling the 120-node cap", () => { + const response = goalMap([ + ...Array.from({length: 121}, (_, index) => mapRow(`todo_old_${index}`, {done: true})), + mapRow("todo_open", {resume_when: "todo_done:todo_old_120"}), mapRow("todo_other"), + ]); + const projection = mapProjection(response); + const ids = (projection.nodes as JsonObject[]).map(node => (node.refs as JsonObject).todo_ids); + assert.equal(ids.length, 120); + assert.deepEqual(ids.slice(116), [["todo_old_116"], ["todo_old_120"], ["todo_open"], ["todo_other"]]); + assert.deepEqual(projection.limits, {node_limit: 120, emitted_node_count: 120, omitted_node_count: 3, + source_truncated: false, missing_endpoint_count: 0, cycle_edge_count: 0, topology_complete: false}); + assert.deepEqual(response.edge_identities, + [{from_node_index: 118, to_node_index: 117, source_relation: "resumes_when"}]); +}); + +test("goal map decodes node-kind facts after admission", () => { + const response = goalMap([ + ...Array.from({length: 120}, (_, index) => mapRow(`todo_active_${index}`)), + mapRow("todo_omitted", {done: true, is_user: "invalid", is_monitor: []}), + ]); + assert.equal((mapProjection(response).limits as JsonObject).omitted_node_count, 1); +}); + +test("goal map rejects malformed wire types at each consumed boundary", () => { + for (const extra of [{schema_version: "unknown"}, {goal_id: null}, {source_truncated: "false"}, {items: {}}]) { + assert.throws(() => goalMap([], extra)); + } + assert.throws(() => projectGoalTaskMap({schema_version: GOAL_TASK_MAP_REQUEST, + goal_id: "goal_test", source_truncated: false, items: [null]}), /object/); + for (const extra of [{todo_id: 1}, {todo_id: null}, {todo_id: ""}, {todo_id: undefined}, + {done: "false"}, {successor_todo_ids: [1]}, {unblocks_todo_id: true}, {is_user: "false"}, {is_monitor: 0}]) { + assert.throws(() => goalMap([mapRow("todo_invalid", extra)])); + } +}); diff --git a/tests/test_delivery_review.py b/tests/test_delivery_review.py index bc98f1ff9d..c6458e7565 100644 --- a/tests/test_delivery_review.py +++ b/tests/test_delivery_review.py @@ -9,9 +9,18 @@ import pytest from loopx.chat_server import ChatHTTPServer, ChatRequestHandler +from loopx.control_plane.coordination.local_authority import read_canonical_todos_if_promoted +from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection +from loopx.control_plane.effect_runtime import restart_effect_runtime from loopx.control_plane.runtime.public_safety import validate_public_safe_value +from loopx.control_plane.todos.active_state_todo_parser import parse_todo_source +from loopx.control_plane.todos.todo_summary import canonical_todo_read_record, structured_todo_item from loopx.state_refresh import refresh_state_run from loopx.status import collect_status +from tests.control_plane.canonical_authority_fixture import ( + initialize_canonical_authority, + isolate_sqlite_runtime, +) def make_project(root: Path) -> tuple[Path, Path]: @@ -78,13 +87,62 @@ def get(server, path="/api/chat/delivery-review?goal_id=release-demo", origin=No return response.status, body +def test_http_review_exposes_source_freshness_and_map_coverage(workspace): + server, _, _ = workspace + status, review = get(server) + assert status == 200 + envelope = review["projection_envelope"] + assert envelope["projection"] == "delivery_review" + assert envelope["fresh"] + task_map = review["goal_map"]["projection_envelope"] + assert task_map["projection"] == "goal_task_map" + assert task_map["fresh"] and task_map["complete"] + assert task_map["coverage"]["expected_count"] == 5 + assert task_map["coverage"]["included_count"] == 5 + assert task_map["sources"][0]["last_read_at"] <= task_map["observed_at"] + + +def test_http_review_discloses_unreadable_map_without_hiding_other_components(workspace, monkeypatch): + server, _, _ = workspace + + def unreadable(**_kwargs): + raise PermissionError("synthetic unavailable source") + + monkeypatch.setattr("loopx.chat_status_api.list_goal_todos", unreadable) + status, review = get(server) + assert status == 200 and review["goal_map"] is None and review["graph"] is not None + envelope = review["projection_envelope"] + assert not envelope["fresh"] and not envelope["complete"] + assert "unreadable_sources" in envelope["alert_reasons"] + assert envelope["coverage"]["omitted"] == [{"reason": "unavailable_components", "count": 1, "refs": []}] + + +def test_http_review_inherits_source_age_and_replay_keeps_observation(workspace, monkeypatch): + from datetime import datetime, timedelta + + from loopx.control_plane.status import collection + from loopx.control_plane.projection_envelope_facts import serve_projection_envelope + + server, _, _ = workspace + monkeypatch.setattr(collection, "now_utc_iso", lambda: "2026-10-01T00:00:00Z") + status, review = get(server) + assert status == 200 + envelope = review["projection_envelope"] + assert not envelope["fresh"] and "stale_sources" in envelope["alert_reasons"] + assert review["goal_map"]["projection_envelope"]["fresh"] + later = datetime.fromisoformat(envelope["served_at"].replace("Z", "+00:00")) + timedelta(minutes=6) + replay = serve_projection_envelope(envelope, served_at=later.isoformat()) + assert replay["observed_at"] == envelope["observed_at"] + assert replay["served_from_cache"] and not replay["fresh"] + + def test_real_http_review_matches_cli_sources_without_canonical_writes(workspace): server, registry, runtime = workspace state = registry.parent / "ACTIVE_GOAL_STATE.md" before = (registry.read_bytes(), state.read_bytes()) status, review = get(server) assert status == 200 - assert set(review) == {"ok", "goal_id", "observed_at", "graph", "goal_map", "acceptance"} + assert set(review) == {"ok", "goal_id", "observed_at", "graph", "goal_map", "acceptance", "projection_envelope"} goal_map = review["goal_map"] titles = {node["refs"]["todo_ids"][0]: node for node in goal_map["nodes"]} # The whole-Goal map is not limited to the bounded chain's two expanded gates. @@ -110,6 +168,94 @@ def test_real_http_review_matches_cli_sources_without_canonical_writes(workspace assert (registry.read_bytes(), state.read_bytes()) == before +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +@pytest.mark.parametrize("display", ["current", "missing"]) +def test_canonical_http_goal_map_survives_repeated_reads_and_runtime_restart( + workspace, tmp_path, monkeypatch, provider, display, +): + server, registry, runtime = workspace + isolate_sqlite_runtime(tmp_path, monkeypatch) + state = registry.parent / "ACTIVE_GOAL_STATE.md" + registry_before, source = registry.read_bytes(), state.read_bytes() + groups, _, _ = parse_todo_source(source.decode("utf-8")) + records = [canonical_todo_read_record(structured_todo_item( + item, role=role, source_section=item["source_section"], + )) for role, rows in groups.items() for item in rows] + # This independently specified map covers every source Todo, including the + # third gate omitted from the bounded attention graph. + expected_nodes = [ + {"node_id": node_id, "kind": kind, "title": title, "state": status, + "depth": depth, "refs": {"todo_ids": [todo_id]}, + **({"owner_agent": owner} if owner else {})} + for todo_id, node_id, kind, title, status, depth, owner in [ + ("todo_review", "node_gate_todo_review_32e2a5", "gate", + "Review the release candidate", "open", 0, None), + ("todo_support", "node_gate_todo_support_0bc755", "gate", + "Confirm support coverage", "open", 0, None), + ("todo_publish", "node_gate_todo_publish_6b56a7", "gate", + "Approve the publication boundary", "open", 0, None), + ("todo_integrate", "node_todo_todo_integrate_c547fe", "deliverable", + "Integrate the verified release package", "open", 1, "builder"), + ("todo_design", "node_todo_todo_design_2ab147", "deliverable", + "Specify the release acceptance criteria", "done", 0, "reviewer"), + ] + ] + expected = { + "schema_version": "goal_task_map_v0", "mode": "read_only", "goal_id": "release-demo", + "limits": {"node_limit": 120, "emitted_node_count": 5, "omitted_node_count": 0, + "source_truncated": False, "missing_endpoint_count": 0, + "cycle_edge_count": 0, "topology_complete": True}, + "nodes": sorted(expected_nodes, key=lambda node: node["node_id"]), + "edges": [ + {"edge_id": "edge_successor_node_todo_todo_integrate_c547fe_node_todo_to_87b417_87b417", + "from_node_id": "node_todo_todo_integrate_c547fe", "to_node_id": "node_todo_todo_design_2ab147", + "relation": "continues", "enforcement": "lineage_only", + "reason": "Work continues predecessor lineage; this is not a completion prerequisite."}, + {"edge_id": "edge_unblocks_node_todo_todo_integrate_c547fe_node_todo_to_87b417_87b417", + "from_node_id": "node_todo_todo_integrate_c547fe", "to_node_id": "node_todo_todo_design_2ab147", + "relation": "depends_on", "enforcement": "typed_lifecycle", + "reason": "Work has a typed lifecycle dependency on the linked unblocking Todo."}, + ], + } + try: + initialized = initialize_canonical_authority( + runtime, "release-demo", build_todo_runtime_shadow_projection( + goal_id="release-demo", todos=records, + ), state_path=state, provider=provider, + ) + before = read_canonical_todos_if_promoted(runtime_root=runtime, goal_id="release-demo") + assert before is not None + assert before["source_authority"] == f"{provider}_v0" + assert before["provider_revision"] == initialized["provider_revision"] + assert len(before["todos"]) == 5 + if display == "missing": + state.unlink() + previous_map = None + for restart in (False, False, True): + if restart: + assert restart_effect_runtime(wait_for_exit=True)["status"] == "stopped" + status, review = get(server) + assert status == 200 + goal_map = review["goal_map"] + assert goal_map is not None + envelope = goal_map.pop("projection_envelope") + assert envelope["fresh"] and envelope["complete"] + assert {**goal_map, + "nodes": sorted(goal_map["nodes"], key=lambda node: node["node_id"]), + "edges": sorted(goal_map["edges"], key=lambda edge: edge["edge_id"])} == expected + if previous_map is not None: + assert goal_map == previous_map + previous_map = goal_map + validate_public_safe_value(review) + after = read_canonical_todos_if_promoted(runtime_root=runtime, goal_id="release-demo") + assert after is not None + assert (after["provider_revision"], after["todos"]) == (before["provider_revision"], before["todos"]) + assert registry.read_bytes() == registry_before + assert (state.read_bytes() if state.exists() else None) == (source if display == "current" else None) + finally: + restart_effect_runtime(wait_for_exit=True) + + def test_review_is_cold_and_missing_observations_stay_unknown(workspace, monkeypatch): server, _, _ = workspace calls = []