@tea-agent/loop-agent 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +3 -2
  3. package/dist/application/dag/generate-task-dag.js +15 -0
  4. package/dist/application/dag/run-dag.js +10 -0
  5. package/dist/application/dag/validate-dag.js +11 -0
  6. package/dist/commands/init.js +74 -7
  7. package/dist/shared/package-metadata.js +135 -0
  8. package/dist/task/config-types.js +1 -0
  9. package/dist/worker/cli.js +3 -1
  10. package/dist/worker/observability/event-history.js +216 -0
  11. package/dist/worker/observability/read-model.js +312 -83
  12. package/dist/worker/observe/paths.js +17 -0
  13. package/dist/worker/observe/routes.js +165 -21
  14. package/dist/worker/observe/server.js +59 -1
  15. package/dist/worker/observe/static/api.js +27 -0
  16. package/dist/worker/observe/static/app.js +120 -2598
  17. package/dist/worker/observe/static/constants.js +148 -0
  18. package/dist/worker/observe/static/copy.js +67 -0
  19. package/dist/worker/observe/static/dag-helpers.js +172 -0
  20. package/dist/worker/observe/static/dag-model.js +72 -0
  21. package/dist/worker/observe/static/dom.js +61 -0
  22. package/dist/worker/observe/static/format-pool.js +67 -0
  23. package/dist/worker/observe/static/format.js +292 -0
  24. package/dist/worker/observe/static/index.html +300 -82
  25. package/dist/worker/observe/static/kpi.js +94 -0
  26. package/dist/worker/observe/static/relations.js +128 -0
  27. package/dist/worker/observe/static/router.js +85 -0
  28. package/dist/worker/observe/static/run-processing.js +148 -0
  29. package/dist/worker/observe/static/shell-chrome.js +68 -0
  30. package/dist/worker/observe/static/state.js +253 -0
  31. package/dist/worker/observe/static/styles.css +1719 -495
  32. package/dist/worker/observe/static/views/batch.js +226 -0
  33. package/dist/worker/observe/static/views/dag-graph.js +172 -0
  34. package/dist/worker/observe/static/views/dag-inspector.js +477 -0
  35. package/dist/worker/observe/static/views/dag.js +362 -0
  36. package/dist/worker/observe/static/views/dashboard.js +442 -0
  37. package/dist/worker/observe/static/views/failures.js +143 -0
  38. package/dist/worker/observe/static/views/feature.js +453 -0
  39. package/dist/worker/observe/static/views/pool.js +347 -0
  40. package/dist/worker/observe/static/views/run.js +453 -0
  41. package/dist/worker/observe/static/views/session-timeline.js +205 -0
  42. package/dist/worker/observe/static/views/shell.js +7 -0
  43. package/dist/worker/observe/static/views/task.js +260 -0
  44. package/dist/worker/observe/static/views/timeline.js +163 -0
  45. package/dist/workflows/dag/controller-identity.js +104 -0
  46. package/dist/workflows/dag/init-hybrid.js +396 -3
  47. package/dist/workflows/dag/node-execution.js +123 -29
  48. package/dist/workflows/dag/repair-artifact.js +91 -0
  49. package/dist/workflows/dag/report.js +50 -0
  50. package/dist/workflows/dag/retry-policy.js +138 -0
  51. package/dist/workflows/dag/runner.js +32 -0
  52. package/dist/workflows/dag/runtime-contract.js +87 -0
  53. package/dist/workflows/dag/skill-snapshot.js +2 -0
  54. package/dist/workflows/dag/types.js +44 -1
  55. package/dist/workflows/dag/validate.js +68 -4
  56. package/docs/agent-dag-runner.md +26 -1
  57. package/docs/architecture/dag-execution.md +6 -0
  58. package/docs/architecture/evolution.md +4 -3
  59. package/docs/architecture/facts-and-state.md +1 -1
  60. package/docs/design/README.md +4 -3
  61. package/docs/exec-plans/active/README.md +1 -3
  62. package/docs/exec-plans/completed/README.md +11 -0
  63. package/docs/feature-workflow.md +28 -0
  64. package/docs/progress/README.md +18 -0
  65. package/docs/reports/README.md +8 -2
  66. package/docs/templates/agent-dag-report.schema.json +17 -0
  67. package/docs/templates/agent-dag.schema.json +69 -1
  68. package/docs/templates/agent-dag.supervised-implementation.json +8 -2
  69. package/docs/templates/backend-test-dag.generate-pytest.prompt.md +139 -0
  70. package/docs/templates/backend-test-dag.json +276 -0
  71. package/docs/templates/backend-test-dag.retrospect.prompt.md +125 -0
  72. package/docs/templates/backend-test-dag.review-cases.prompt.md +81 -0
  73. package/package.json +1 -1
  74. package/skills/loop-agent/references/command-reference.md +1 -0
  75. package/skills/loop-agent/references/hybrid-dag.md +22 -3
  76. package/skills/loop-agent/references/verification-and-failure-handling.md +6 -0
@@ -0,0 +1,260 @@
1
+ /** Observe UI view module (R5). */
2
+ import {
3
+ UI_TEXT,
4
+ LIVENESS_LABELS,
5
+ ARTIFACT_LABELS,
6
+ ARTIFACT_KEYS,
7
+ STEP_LABELS,
8
+ STEP_ORDER,
9
+ DAG_INSPECTOR_DEFAULT_WIDTH,
10
+ DAG_INSPECTOR_MIN_WIDTH,
11
+ DAG_INSPECTOR_MAX_WIDTH,
12
+ DAG_INSPECTOR_RIGHT_OFFSET,
13
+ DAG_INSPECTOR_VIEWPORT_MARGIN,
14
+ DAG_INSPECTOR_KEYBOARD_STEP,
15
+ TERMINAL_RUN_STATUSES,
16
+ DAG_EFFECTIVE_STATUS_LABELS,
17
+ STATUS_LABELS,
18
+ } from "../constants.js";
19
+ import { clearNode, el, metaGrid, buildTable } from "../dom.js";
20
+ import {
21
+ badge,
22
+ badgeClass,
23
+ statusLabel,
24
+ formatTs,
25
+ formatMs,
26
+ formatDuration,
27
+ formatAgo,
28
+ dagStatusBadge,
29
+ livenessBadge,
30
+ displayDurationMs,
31
+ derivedDurationMs,
32
+ summaryText,
33
+ truncateText,
34
+ repoBaseName,
35
+ parseMarkdownBlocks,
36
+ markdownLinkHref,
37
+ } from "../format.js";
38
+ import { parseRoute, navigate, buildHashPath } from "../router.js";
39
+ import { fetchJson, artifactUrl, parseArtifactPreviewResponse } from "../api.js";
40
+ import {
41
+ objectRouteLink,
42
+ findFeatureInSnapshot,
43
+ featureLookupState,
44
+ buildObjectRelations,
45
+ renderObjectRelationBar,
46
+ } from "../relations.js";
47
+ import { copyText, renderRecommendedCommand } from "../copy.js";
48
+ import {
49
+ isPageVisible,
50
+ detailPollDelay,
51
+ dashboardPollDelay,
52
+ captureDagGraphViewportState,
53
+ updateDagGraphViewportState,
54
+ updateDagTimelineViewportState,
55
+ renderViewState,
56
+ mountViewState,
57
+ } from "../state.js";
58
+ import { uiState } from "../state.js";
59
+ import { sortPoolTasks } from "../format-pool.js";
60
+ import {
61
+ dagMetaVisibility,
62
+ isDagRunActive,
63
+ dashboardVisibleFeatures,
64
+ findDagForRun,
65
+ } from "../dag-model.js";
66
+ import { layoutDag } from "../dag-layout.js";
67
+ import { showView, setBreadcrumb, updateHeaderRefresh, mountRelationBar } from "../shell-chrome.js";
68
+ import { artifactLink } from "../kpi.js";
69
+ import { findBatchRunIdForTask, taskRunHistoryRow } from "../run-processing.js";
70
+
71
+ export async function renderTaskDetail(taskId) {
72
+ showView("task");
73
+ setBreadcrumb([
74
+ { label: UI_TEXT.dashboard, href: "#/" },
75
+ { label: "资源池", href: "#/pool" },
76
+ { label: taskId },
77
+ ]);
78
+
79
+ const titleEl = document.getElementById("task-title");
80
+ const metaEl = document.getElementById("task-meta");
81
+ const linksEl = document.getElementById("task-links");
82
+ const evidenceEl = document.getElementById("task-evidence");
83
+ const runsEl = document.getElementById("task-runs");
84
+ if (!metaEl || !linksEl || !evidenceEl || !runsEl) return false;
85
+ if (titleEl) titleEl.textContent = taskId;
86
+ mountViewState(metaEl, "loading", "加载 Task…");
87
+
88
+ const [task, history, snapshot] = await Promise.all([
89
+ fetchJson(`/api/tasks/${encodeURIComponent(taskId)}`),
90
+ fetchJson(`/api/tasks/${encodeURIComponent(taskId)}/runs?limit=20`),
91
+ fetchJson("/api/snapshot"),
92
+ ]);
93
+ if (snapshot) {
94
+ uiState.lastSnapshot = snapshot;
95
+ updateHeaderRefresh(snapshot.generatedAt);
96
+ }
97
+
98
+ clearNode(metaEl);
99
+ clearNode(linksEl);
100
+ clearNode(evidenceEl);
101
+ clearNode(runsEl);
102
+
103
+ if (!task) {
104
+ mountViewState(metaEl, "error", `未找到 Task:${taskId}`);
105
+ mountRelationBar("task-relations", {}, snapshot);
106
+ return false;
107
+ }
108
+
109
+ metaEl.appendChild(
110
+ metaGrid([
111
+ ["Task ID", task.taskId],
112
+ ["状态", badge(task.status)],
113
+ [
114
+ "Feature",
115
+ task.featureId
116
+ ? objectRouteLink("feature", task.featureId, snapshot)
117
+ : el("span", "muted", "—"),
118
+ ],
119
+ ["更新时间", task.updatedAt ?? task.finishedAt ?? task.startedAt ?? "—"],
120
+ ["最新 worker run", task.workerRunId ?? "—"],
121
+ ["失败分类", task.failureCategory ?? "—"],
122
+ ["建议跟进", task.recommendedFollowUp ?? "—"],
123
+ [
124
+ "活性",
125
+ task.liveness ? (LIVENESS_LABELS[task.liveness] ?? task.liveness) : "—",
126
+ ],
127
+ ]),
128
+ );
129
+
130
+ const batchRunId = findBatchRunIdForTask(snapshot, task);
131
+ const dag = findDagForRun(snapshot, task.workerRunId, task);
132
+ mountRelationBar(
133
+ "task-relations",
134
+ {
135
+ featureId: task.featureId,
136
+ taskId: task.taskId,
137
+ batchRunId: batchRunId ?? undefined,
138
+ workerRunId: task.workerRunId,
139
+ dagRunId: dag?.dagRunId ?? task.dagRunId,
140
+ },
141
+ snapshot,
142
+ );
143
+ linksEl.appendChild(el("h3", "section-heading", "真实关联"));
144
+ const linkList = el("div", "artifact-links");
145
+ linkList.appendChild(objectRouteLink("run", task.workerRunId, snapshot));
146
+ linkList.appendChild(document.createTextNode(" · "));
147
+ linkList.appendChild(
148
+ batchRunId
149
+ ? objectRouteLink("batch", batchRunId, snapshot)
150
+ : el("span", "muted", "batch 缺失"),
151
+ );
152
+ linkList.appendChild(document.createTextNode(" · "));
153
+ linkList.appendChild(
154
+ dag
155
+ ? objectRouteLink("dag", dag.dagRunId, snapshot)
156
+ : task.dagRunId
157
+ ? el("span", "muted", task.dagRunId)
158
+ : el("span", "muted", "DAG 缺失"),
159
+ );
160
+ if (task.featureId) {
161
+ linkList.appendChild(document.createTextNode(" · "));
162
+ linkList.appendChild(objectRouteLink("feature", task.featureId, snapshot));
163
+ }
164
+ linksEl.appendChild(linkList);
165
+
166
+ evidenceEl.appendChild(el("h3", "section-heading", "失败证据"));
167
+ const r = task.artifactRefs ?? {};
168
+ const evidenceLinks = el("div", "artifact-links");
169
+ evidenceLinks.appendChild(artifactLink("报告", r.reportMarkdown));
170
+ evidenceLinks.appendChild(document.createTextNode(" "));
171
+ evidenceLinks.appendChild(artifactLink("诊断", r.doctorMarkdown));
172
+ evidenceLinks.appendChild(document.createTextNode(" "));
173
+ evidenceLinks.appendChild(artifactLink("收尾方案", r.closeoutDraft));
174
+ evidenceLinks.appendChild(document.createTextNode(" "));
175
+ evidenceLinks.appendChild(artifactLink("run 记录", r.runRecordPath));
176
+ if (
177
+ !r.reportMarkdown &&
178
+ !r.doctorMarkdown &&
179
+ !r.closeoutDraft &&
180
+ !r.runRecordPath
181
+ ) {
182
+ evidenceEl.appendChild(el("p", "empty", "暂无 allowlist 内的证据路径。"));
183
+ } else {
184
+ evidenceEl.appendChild(evidenceLinks);
185
+ }
186
+
187
+ runsEl.appendChild(el("h3", "section-heading", "运行历史"));
188
+ const runs = history?.runs ?? [];
189
+ if (runs.length === 0) {
190
+ mountViewState(runsEl, "empty", "暂无 ledger 运行记录。");
191
+ } else {
192
+ const rows = runs.map(taskRunHistoryRow);
193
+ runsEl.appendChild(
194
+ buildTable(
195
+ ["worker run", "状态", "时间", "batch", "retryOf", "失败分类"],
196
+ rows,
197
+ ),
198
+ );
199
+ if (history?.nextBefore) {
200
+ const more = el("button", "link-button", "加载更早的运行");
201
+ more.type = "button";
202
+ more.addEventListener("click", () => {
203
+ void loadOlderTaskRuns(taskId, history.nextBefore, runsEl);
204
+ });
205
+ runsEl.appendChild(more);
206
+ }
207
+ }
208
+
209
+ const active = ["running", "pending", "blocked", "stale"].includes(
210
+ (task.status ?? "").toLowerCase(),
211
+ );
212
+ return active;
213
+ }
214
+
215
+ export async function loadOlderTaskRuns(taskId, before, runsEl) {
216
+ const page = await fetchJson(
217
+ `/api/tasks/${encodeURIComponent(taskId)}/runs?limit=20&before=${encodeURIComponent(before)}`,
218
+ );
219
+ if (!page?.runs?.length) return;
220
+ const table = runsEl.querySelector("table");
221
+ if (!table) return;
222
+ const tbody = table.querySelector("tbody") || table;
223
+ for (const run of page.runs) {
224
+ const tr = document.createElement("tr");
225
+ for (const cell of taskRunHistoryRow(run).cells) {
226
+ const td = document.createElement("td");
227
+ if (cell instanceof Node) td.appendChild(cell);
228
+ else td.textContent = cell;
229
+ tr.appendChild(td);
230
+ }
231
+ tbody.appendChild(tr);
232
+ }
233
+ const btn = runsEl.querySelector("button.link-button");
234
+ if (btn) {
235
+ if (page.nextBefore) {
236
+ btn.onclick = () => {
237
+ void loadOlderTaskRuns(taskId, page.nextBefore, runsEl);
238
+ };
239
+ } else {
240
+ btn.remove();
241
+ }
242
+ }
243
+ }
244
+
245
+ export function startTaskPolling(taskId) {
246
+ if (uiState.taskPollTimer) clearTimeout(uiState.taskPollTimer);
247
+ const generation = ++uiState.pollingGeneration;
248
+ const poll = async () => {
249
+ const active = await renderTaskDetail(taskId);
250
+ if (generation !== uiState.pollingGeneration || !active) return;
251
+ const delay = detailPollDelay("running", isPageVisible());
252
+ if (delay == null) return;
253
+ uiState.taskPollTimer = setTimeout(() => {
254
+ uiState.taskPollTimer = null;
255
+ void poll();
256
+ }, delay);
257
+ };
258
+ void poll();
259
+ }
260
+
@@ -0,0 +1,163 @@
1
+ /** Observe UI view module (R5). */
2
+ import {
3
+ UI_TEXT,
4
+ LIVENESS_LABELS,
5
+ ARTIFACT_LABELS,
6
+ ARTIFACT_KEYS,
7
+ STEP_LABELS,
8
+ STEP_ORDER,
9
+ DAG_INSPECTOR_DEFAULT_WIDTH,
10
+ DAG_INSPECTOR_MIN_WIDTH,
11
+ DAG_INSPECTOR_MAX_WIDTH,
12
+ DAG_INSPECTOR_RIGHT_OFFSET,
13
+ DAG_INSPECTOR_VIEWPORT_MARGIN,
14
+ DAG_INSPECTOR_KEYBOARD_STEP,
15
+ TERMINAL_RUN_STATUSES,
16
+ DAG_EFFECTIVE_STATUS_LABELS,
17
+ STATUS_LABELS,
18
+ } from "../constants.js";
19
+ import { clearNode, el, metaGrid, buildTable } from "../dom.js";
20
+ import {
21
+ badge,
22
+ badgeClass,
23
+ statusLabel,
24
+ formatTs,
25
+ formatMs,
26
+ formatDuration,
27
+ formatAgo,
28
+ dagStatusBadge,
29
+ livenessBadge,
30
+ displayDurationMs,
31
+ derivedDurationMs,
32
+ summaryText,
33
+ truncateText,
34
+ repoBaseName,
35
+ parseMarkdownBlocks,
36
+ markdownLinkHref,
37
+ } from "../format.js";
38
+ import { parseRoute, navigate, buildHashPath } from "../router.js";
39
+ import {
40
+ fetchJson,
41
+ artifactUrl,
42
+ parseArtifactPreviewResponse,
43
+ } from "../api.js";
44
+ import {
45
+ objectRouteLink,
46
+ findFeatureInSnapshot,
47
+ featureLookupState,
48
+ buildObjectRelations,
49
+ renderObjectRelationBar,
50
+ } from "../relations.js";
51
+ import { copyText, renderRecommendedCommand } from "../copy.js";
52
+ import {
53
+ isPageVisible,
54
+ detailPollDelay,
55
+ dashboardPollDelay,
56
+ captureDagGraphViewportState,
57
+ updateDagGraphViewportState,
58
+ updateDagTimelineViewportState,
59
+ renderViewState,
60
+ mountViewState,
61
+ } from "../state.js";
62
+ import { uiState } from "../state.js";
63
+ import { sortPoolTasks } from "../format-pool.js";
64
+ import {
65
+ dagMetaVisibility,
66
+ isDagRunActive,
67
+ dashboardVisibleFeatures,
68
+ findDagForRun,
69
+ } from "../dag-model.js";
70
+ import { layoutDag } from "../dag-layout.js";
71
+
72
+ export async function mountBoundedEventTimeline(container, apiBase, title) {
73
+ clearNode(container);
74
+ container.appendChild(el("h3", "section-heading", title));
75
+ const note = el(
76
+ "p",
77
+ "process-timeline-note",
78
+ "有界历史(默认 50 / 最大 200);分页使用 cursor,与 SSE 行号 offset 分离。",
79
+ );
80
+ container.appendChild(note);
81
+ const scroll = el("div", "process-timeline-scroll event-history-scroll");
82
+ const listHost = el("div", "event-history-list");
83
+ scroll.appendChild(listHost);
84
+ container.appendChild(scroll);
85
+ const footer = el("div", "event-history-footer");
86
+ container.appendChild(footer);
87
+
88
+ let nextCursor = null;
89
+ let loading = false;
90
+ const accumulated = [];
91
+
92
+ const renderList = () => {
93
+ clearNode(listHost);
94
+ if (accumulated.length === 0) {
95
+ mountViewState(listHost, "empty", "暂无事件");
96
+ return;
97
+ }
98
+ const ul = el("ul", "process-timeline");
99
+ for (let index = 0; index < accumulated.length; index += 1) {
100
+ const event = accumulated[index];
101
+ const li = el(
102
+ "li",
103
+ `process-timeline-item process-timeline-event has-time${index === 0 ? " is-first" : ""}${index === accumulated.length - 1 ? " is-last" : ""}`,
104
+ );
105
+ const rail = el("span", "process-timeline-rail");
106
+ rail.appendChild(el("span", "process-timeline-dot"));
107
+ li.appendChild(rail);
108
+ li.appendChild(
109
+ el("span", "process-timeline-time", formatTs(event.at) || "—"),
110
+ );
111
+ const label = `${event.type ?? "event"}${event.taskId ? ` · ${event.taskId}` : ""}${event.workerRunId ? ` · ${event.workerRunId}` : ""}`;
112
+ li.appendChild(el("span", "process-timeline-label", label));
113
+ ul.appendChild(li);
114
+ }
115
+ listHost.appendChild(ul);
116
+ };
117
+
118
+ const loadPage = async (cursor) => {
119
+ if (loading) return;
120
+ loading = true;
121
+ if (accumulated.length === 0) {
122
+ mountViewState(listHost, "loading", "加载事件…");
123
+ }
124
+ const qs = new URLSearchParams();
125
+ qs.set("limit", "50");
126
+ if (cursor) qs.set("cursor", cursor);
127
+ const page = await fetchJson(`${apiBase}?${qs.toString()}`);
128
+ loading = false;
129
+ if (!page) {
130
+ mountViewState(listHost, "error", "无法加载事件历史。");
131
+ return;
132
+ }
133
+ const events = page.events ?? [];
134
+ for (const event of events) accumulated.push(event);
135
+ nextCursor = page.nextCursor ?? null;
136
+ renderList();
137
+ clearNode(footer);
138
+ if (page.corruptLineCount > 0) {
139
+ footer.appendChild(
140
+ renderViewState(
141
+ "degraded",
142
+ `跳过损坏行 ${page.corruptLineCount} 条(failure-safe)。`,
143
+ ),
144
+ );
145
+ }
146
+ if (nextCursor) {
147
+ const btn = el(
148
+ "button",
149
+ "btn-secondary event-history-more",
150
+ "加载更早事件",
151
+ );
152
+ btn.type = "button";
153
+ btn.addEventListener("click", () => {
154
+ void loadPage(nextCursor);
155
+ });
156
+ footer.appendChild(btn);
157
+ } else if (accumulated.length > 0) {
158
+ footer.appendChild(el("p", "muted", "已到历史边界"));
159
+ }
160
+ };
161
+
162
+ await loadPage(null);
163
+ }
@@ -0,0 +1,104 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { writeJsonAtomic } from "../../infrastructure/harness/atomic-write.js";
5
+ import { controllerIdentitiesMatch, resolveRunningControllerIdentity, } from "../../shared/package-metadata.js";
6
+ export const CONTROLLER_IDENTITY_ARTIFACT_NAME = "controller-identity.json";
7
+ function hashArtifact(artifact) {
8
+ return createHash("sha256")
9
+ .update(`${JSON.stringify(artifact, null, 2)}\n`)
10
+ .digest("hex");
11
+ }
12
+ function summaryFields(identity) {
13
+ if (!identity)
14
+ return {};
15
+ return {
16
+ packageName: identity.packageName,
17
+ packageVersion: identity.packageVersion,
18
+ packageFingerprint: identity.packageFingerprint.value,
19
+ binarySha256: identity.binarySha256,
20
+ };
21
+ }
22
+ /**
23
+ * Capture the running controller identity as a run-owned fact and persist it
24
+ * under `runDir/controller-identity.json`. Returns the ref (with content hash)
25
+ * to store on DagRunState. When the identity cannot be resolved, records a
26
+ * `legacy-unpinned` artifact instead of fabricating an identity.
27
+ */
28
+ export async function captureControllerIdentity(input) {
29
+ const capturedAt = (input.now ?? new Date()).toISOString();
30
+ const identity = input.identity !== undefined
31
+ ? input.identity
32
+ : (resolveRunningControllerIdentity({
33
+ ...(input.argv ? { argv: input.argv } : {}),
34
+ ...(input.now ? { now: input.now } : {}),
35
+ }) ?? null);
36
+ const artifact = identity
37
+ ? { schemaVersion: 1, status: "pinned", capturedAt, identity }
38
+ : {
39
+ schemaVersion: 1,
40
+ status: "legacy-unpinned",
41
+ capturedAt,
42
+ identity: null,
43
+ reason: "running controller identity could not be resolved at run creation",
44
+ };
45
+ await writeJsonAtomic(path.join(input.runDir, CONTROLLER_IDENTITY_ARTIFACT_NAME), artifact);
46
+ return {
47
+ schemaVersion: 1,
48
+ path: CONTROLLER_IDENTITY_ARTIFACT_NAME,
49
+ sha256: hashArtifact(artifact),
50
+ capturedAt,
51
+ status: artifact.status,
52
+ ...summaryFields(identity),
53
+ };
54
+ }
55
+ export async function readControllerIdentityArtifact(runDir) {
56
+ try {
57
+ const raw = await readFile(path.join(runDir, CONTROLLER_IDENTITY_ARTIFACT_NAME), "utf-8");
58
+ return JSON.parse(raw);
59
+ }
60
+ catch {
61
+ return undefined;
62
+ }
63
+ }
64
+ function shortIdentity(identity) {
65
+ return `${identity.packageName}@${identity.packageVersion} (fingerprint ${identity.packageFingerprint.value}, binary ${identity.binarySha256.slice(0, 12)})`;
66
+ }
67
+ /**
68
+ * Recompute the current controller identity and compare it to the pinned run
69
+ * fact before resuming. Fails closed on drift; reports (without inventing an
70
+ * identity). Legacy/unpinned runs remain readable but cannot be resumed.
71
+ */
72
+ export async function verifyControllerIdentityForResume(input) {
73
+ const ref = input.state.controllerIdentityRef;
74
+ if (!ref || ref.status === "legacy-unpinned") {
75
+ throw new Error(`dag run ${input.state.runId} has no pinned controller identity; legacy/unpinned runs remain readable but cannot be resumed safely`);
76
+ }
77
+ const artifact = await readControllerIdentityArtifact(input.runDir);
78
+ if (!artifact) {
79
+ throw new Error(`dag run ${input.state.runId} is pinned but ${CONTROLLER_IDENTITY_ARTIFACT_NAME} is missing; refuse to resume`);
80
+ }
81
+ const persistedHash = createHash("sha256")
82
+ .update(`${JSON.stringify(artifact, null, 2)}\n`)
83
+ .digest("hex");
84
+ if (persistedHash !== ref.sha256) {
85
+ throw new Error(`dag run ${input.state.runId} controller identity artifact was tampered with (hash mismatch); refuse to resume`);
86
+ }
87
+ const pinned = artifact.identity;
88
+ if (!pinned) {
89
+ throw new Error(`dag run ${input.state.runId} is pinned but the identity artifact has no identity payload; refuse to resume`);
90
+ }
91
+ const current = input.currentIdentity !== undefined
92
+ ? input.currentIdentity
93
+ : (resolveRunningControllerIdentity({
94
+ ...(input.argv ? { argv: input.argv } : {}),
95
+ ...(input.now ? { now: input.now } : {}),
96
+ }) ?? null);
97
+ if (!current) {
98
+ throw new Error(`dag run ${input.state.runId} was pinned to ${shortIdentity(pinned)} but the current controller identity could not be resolved; refuse to resume`);
99
+ }
100
+ if (!controllerIdentitiesMatch(pinned, current)) {
101
+ throw new Error(`dag run ${input.state.runId} controller identity drifted; pinned=${shortIdentity(pinned)} current=${shortIdentity(current)}; start a new run instead of resuming`);
102
+ }
103
+ return { status: "pinned-match", identity: current };
104
+ }