@tea-agent/loop-agent 0.29.3 → 0.31.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 (34) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/dist/executors/dag-pi-executor.js +18 -6
  3. package/dist/worker/observability/dag-execution-trajectory.js +591 -0
  4. package/dist/worker/observability/read-model.js +258 -31
  5. package/dist/worker/observe/dag-node-execution-output.js +180 -0
  6. package/dist/worker/observe/routes.js +53 -6
  7. package/dist/worker/observe/static/dag-edge-routing.js +368 -0
  8. package/dist/worker/observe/static/dag-history-labels.js +95 -0
  9. package/dist/worker/observe/static/dag-layout.d.ts +12 -7
  10. package/dist/worker/observe/static/dag-layout.js +101 -21
  11. package/dist/worker/observe/static/favicon.svg +37 -0
  12. package/dist/worker/observe/static/format.js +31 -1
  13. package/dist/worker/observe/static/index.html +1 -1
  14. package/dist/worker/observe/static/state.js +102 -0
  15. package/dist/worker/observe/static/styles.css +267 -7
  16. package/dist/worker/observe/static/views/dag-graph.js +414 -154
  17. package/dist/worker/observe/static/views/dag-inspector.js +478 -27
  18. package/dist/worker/observe/static/views/dag-trajectory.js +313 -0
  19. package/dist/worker/observe/static/views/dag.js +20 -3
  20. package/dist/workflows/dag/backend-test-writer-completeness.js +398 -26
  21. package/dist/workflows/dag/dynamic-runtime/map.js +10 -4
  22. package/dist/workflows/dag/init-hybrid.js +228 -229
  23. package/dist/workflows/dag/node-execution.js +13 -2
  24. package/dist/workflows/dag/types.js +21 -7
  25. package/docs/architecture/README.md +4 -0
  26. package/docs/architecture/dag-execution.md +1 -1
  27. package/docs/architecture/worker-and-feature.md +1 -1
  28. package/docs/governance/README.md +3 -0
  29. package/docs/operations/README.md +1 -0
  30. package/docs/templates/backend-test-dag.json +664 -460
  31. package/docs/templates/frontend-test-dag.json +1 -1
  32. package/harness.json +2 -2
  33. package/package.json +3 -2
  34. package/scripts/kb-bootstrap-init-skeleton.sh +1 -1
@@ -756,8 +756,7 @@ function isDagRunActiveForHealth(dag) {
756
756
  return false;
757
757
  if (lifecycle === "completed")
758
758
  return false;
759
- if (dag.effectiveStatus &&
760
- TERMINAL_RUN_STATUSES.has(dag.effectiveStatus)) {
759
+ if (dag.effectiveStatus && TERMINAL_RUN_STATUSES.has(dag.effectiveStatus)) {
761
760
  return false;
762
761
  }
763
762
  // Real execute pause remains overview-visible (AC-003).
@@ -847,10 +846,10 @@ function computeDagHealth(dagRuns) {
847
846
  pausedRuns++;
848
847
  if (dag.effectiveStatus === "failed")
849
848
  failedRuns++;
850
- if (liveness === "stale"
851
- || liveness === "orphaned"
852
- || liveness === "suspected-stall"
853
- || liveness === "needs-attention") {
849
+ if (liveness === "stale" ||
850
+ liveness === "orphaned" ||
851
+ liveness === "suspected-stall" ||
852
+ liveness === "needs-attention") {
854
853
  staleRuns++;
855
854
  }
856
855
  if (dag.effectiveStatus === "interrupted")
@@ -1085,13 +1084,22 @@ async function appendJsonlEventsBounded(filePath, events, warnings) {
1085
1084
  }
1086
1085
  }
1087
1086
  if (tail.truncated) {
1088
- warnings.push("truncated event tail for " + path.basename(path.dirname(filePath)) + "/" + path.basename(filePath) + " to newest " + accepted + " valid events");
1087
+ warnings.push("truncated event tail for " +
1088
+ path.basename(path.dirname(filePath)) +
1089
+ "/" +
1090
+ path.basename(filePath) +
1091
+ " to newest " +
1092
+ accepted +
1093
+ " valid events");
1089
1094
  }
1090
1095
  }
1091
1096
  async function loadLedgerRuns(repoRoot) {
1092
1097
  const runs = [];
1093
1098
  const warnings = [];
1094
- const tail = await readBoundedJsonlTail(path.join(getTaskPoolRoot(repoRoot), "runs.jsonl"), { maxBytes: SNAPSHOT_LEDGER_MAX_BYTES, maxLines: SNAPSHOT_LEDGER_MAX_LINES });
1099
+ const tail = await readBoundedJsonlTail(path.join(getTaskPoolRoot(repoRoot), "runs.jsonl"), {
1100
+ maxBytes: SNAPSHOT_LEDGER_MAX_BYTES,
1101
+ maxLines: SNAPSHOT_LEDGER_MAX_LINES,
1102
+ });
1095
1103
  if (tail.truncated) {
1096
1104
  warnings.push("runs.jsonl projection truncated to newest " +
1097
1105
  SNAPSHOT_LEDGER_MAX_LINES +
@@ -1380,6 +1388,7 @@ function mergeDagNode(existing, incoming) {
1380
1388
  failureCategory: incoming.failureCategory ?? existing.failureCategory,
1381
1389
  originKind: incoming.originKind ?? existing.originKind,
1382
1390
  passes: incoming.passes ?? existing.passes,
1391
+ executionSummary: incoming.executionSummary ?? existing.executionSummary,
1383
1392
  };
1384
1393
  }
1385
1394
  function computeDurationMs(startedAt, finishedAt) {
@@ -1545,19 +1554,28 @@ async function loadBackendTestProjection(runDir, nodes) {
1545
1554
  readContract("backend-test-result.json"),
1546
1555
  readContract("backend-test-case-manifest.json"),
1547
1556
  ]);
1548
- if (!initial && !classification && !eligibility && !final && !effective && !manifest)
1557
+ if (!initial &&
1558
+ !classification &&
1559
+ !eligibility &&
1560
+ !final &&
1561
+ !effective &&
1562
+ !manifest)
1549
1563
  return undefined;
1550
- const result = (value) => value ? {
1551
- outcome: readString(value, "outcome"),
1552
- passed: readNumber(value, "passed"),
1553
- failed: readNumber(value, "failed"),
1554
- error: readNumber(value, "error"),
1555
- } : undefined;
1564
+ const result = (value) => value
1565
+ ? {
1566
+ outcome: readString(value, "outcome"),
1567
+ passed: readNumber(value, "passed"),
1568
+ failed: readNumber(value, "failed"),
1569
+ error: readNumber(value, "error"),
1570
+ }
1571
+ : undefined;
1556
1572
  const repairNode = nodes.find((node) => node.nodeId === "repair-backend-pytest-pi");
1557
1573
  const safetyNode = nodes.find((node) => node.nodeId === "validate-repair-safety-and-traceability-shell");
1558
1574
  const eligible = eligibility ? eligibility.eligible === true : undefined;
1559
1575
  const repairNodeStatus = repairNode?.status?.toLowerCase();
1560
- const repairAttempted = repairNodeStatus === "running" || repairNodeStatus === "finished" || repairNodeStatus === "error";
1576
+ const repairAttempted = repairNodeStatus === "running" ||
1577
+ repairNodeStatus === "finished" ||
1578
+ repairNodeStatus === "error";
1561
1579
  let repairStatus = "not-needed";
1562
1580
  if (repairNodeStatus === "running")
1563
1581
  repairStatus = "repairing";
@@ -1571,18 +1589,36 @@ async function loadBackendTestProjection(runDir, nodes) {
1571
1589
  const legacy = Boolean(eligibility || final || repairNode || safetyNode);
1572
1590
  return {
1573
1591
  ...(initial ? { initial: result(initial) } : {}),
1574
- ...(classification ? { classification: { category: readString(classification, "category"), confidence: readNumber(classification, "confidence") } } : {}),
1575
- ...(legacy ? {
1576
- repair: {
1577
- eligible,
1578
- reason: eligibility ? readString(eligibility, "reason") : undefined,
1579
- attempt: final || repairAttempted ? 1 : 0,
1580
- status: repairStatus,
1581
- },
1582
- ...(final ? { final: result(final) } : {}),
1583
- } : {}),
1584
- ...(effective ? { effective: { source: effectiveSource, outcome: readString(effective, "outcome") } } : {}),
1585
- ...(manifest && manifest.coverageSummary ? { coverage: manifest.coverageSummary } : {}),
1592
+ ...(classification
1593
+ ? {
1594
+ classification: {
1595
+ category: readString(classification, "category"),
1596
+ confidence: readNumber(classification, "confidence"),
1597
+ },
1598
+ }
1599
+ : {}),
1600
+ ...(legacy
1601
+ ? {
1602
+ repair: {
1603
+ eligible,
1604
+ reason: eligibility ? readString(eligibility, "reason") : undefined,
1605
+ attempt: final || repairAttempted ? 1 : 0,
1606
+ status: repairStatus,
1607
+ },
1608
+ ...(final ? { final: result(final) } : {}),
1609
+ }
1610
+ : {}),
1611
+ ...(effective
1612
+ ? {
1613
+ effective: {
1614
+ source: effectiveSource,
1615
+ outcome: readString(effective, "outcome"),
1616
+ },
1617
+ }
1618
+ : {}),
1619
+ ...(manifest && manifest.coverageSummary
1620
+ ? { coverage: manifest.coverageSummary }
1621
+ : {}),
1586
1622
  };
1587
1623
  }
1588
1624
  async function loadFrontendLintProjection(runDir) {
@@ -1639,6 +1675,7 @@ async function parseDagStateFile(statePath, now) {
1639
1675
  const controlEdges = await deriveControlLoopEdges(runSpecPath, nodes, state);
1640
1676
  const convergence = projectConvergenceSummary(state);
1641
1677
  applyNodePasses(nodes, state);
1678
+ applyNodeExecutionSummaries(nodes, state);
1642
1679
  const liveness = assessDagRunLiveness({ state, now });
1643
1680
  const effectiveStatus = lifecycle
1644
1681
  ? deriveDagRunEffectiveStatus({
@@ -1687,7 +1724,8 @@ async function parseDagStateFile(statePath, now) {
1687
1724
  effectiveFromNodeId: continuationRecord.effectiveFromNodeId,
1688
1725
  operatorSelectedNodeId: continuationRecord.operatorSelectedNodeId,
1689
1726
  ...(verification &&
1690
- (verification.mode === "inherited" || verification.mode === "rerun") &&
1727
+ (verification.mode === "inherited" ||
1728
+ verification.mode === "rerun") &&
1691
1729
  Array.isArray(verification.nodeIds) &&
1692
1730
  verification.nodeIds.every((nodeId) => typeof nodeId === "string")
1693
1731
  ? {
@@ -1937,6 +1975,175 @@ async function loadDagNodeModels(runPath) {
1937
1975
  }
1938
1976
  return models;
1939
1977
  }
1978
+ const EXECUTED_NODE_STATUSES = new Set([
1979
+ "finished",
1980
+ "completed",
1981
+ "succeeded",
1982
+ "done",
1983
+ "error",
1984
+ "failed",
1985
+ "partial_failed",
1986
+ "running",
1987
+ "started",
1988
+ "paused",
1989
+ ]);
1990
+ function isExecutedNodeStatus(status) {
1991
+ if (!status)
1992
+ return false;
1993
+ return EXECUTED_NODE_STATUSES.has(status.toLowerCase());
1994
+ }
1995
+ const PASS_SUCCESS_STATUSES = new Set([
1996
+ "finished",
1997
+ "completed",
1998
+ "succeeded",
1999
+ "done",
2000
+ ]);
2001
+ const PASS_FAILURE_STATUSES = new Set([
2002
+ "error",
2003
+ "failed",
2004
+ "partial_failed",
2005
+ "interrupted",
2006
+ ]);
2007
+ /**
2008
+ * Project the lightweight execution history summary for one DAG node.
2009
+ * Precedence: recorded attempts > convergence passes > implicit single
2010
+ * execution > no summary (legacy). Pure and deterministic; never throws on
2011
+ * legacy shapes (missing attempts/passes/ok flags).
2012
+ */
2013
+ export function projectExecutionSummary(options) {
2014
+ const rawAttempts = Array.isArray(options.attempts)
2015
+ ? options.attempts
2016
+ : undefined;
2017
+ if (rawAttempts && rawAttempts.length > 0) {
2018
+ const attempts = rawAttempts.map((raw, index) => {
2019
+ const record = raw && typeof raw === "object" && !Array.isArray(raw)
2020
+ ? raw
2021
+ : {};
2022
+ const ok = record.ok === true;
2023
+ const failureCategory = readString(record, "failureCategory");
2024
+ const durationMs = readNumber(record, "durationMs");
2025
+ const artifactPath = readString(record, "artifactPath");
2026
+ return {
2027
+ attempt: index + 1,
2028
+ status: ok ? "success" : "failure",
2029
+ ...(failureCategory ? { failureCategory } : {}),
2030
+ ...(durationMs !== undefined ? { durationMs } : {}),
2031
+ ...(artifactPath ? { artifactPath } : {}),
2032
+ };
2033
+ });
2034
+ const totalAttemptCount = attempts.length;
2035
+ const firstOutcome = attempts[0].status;
2036
+ const finalOutcome = attempts[totalAttemptCount - 1].status;
2037
+ const recovered = totalAttemptCount > 1 &&
2038
+ firstOutcome === "failure" &&
2039
+ finalOutcome === "success";
2040
+ // 一次就成功/失败:卡片不展示 historyLabel;Inspector 仍可拿 attempts 元数据。
2041
+ const historyLabel = totalAttemptCount <= 1
2042
+ ? undefined
2043
+ : recovered
2044
+ ? `${totalAttemptCount} 次 · 先失败后成功`
2045
+ : `${totalAttemptCount} 次 · 最终${finalOutcome === "success" ? "成功" : "失败"}`;
2046
+ return {
2047
+ totalAttemptCount,
2048
+ ...(recovered ? { recovered: true } : {}),
2049
+ firstOutcome,
2050
+ finalOutcome,
2051
+ ...(historyLabel ? { historyLabel } : {}),
2052
+ attempts,
2053
+ };
2054
+ }
2055
+ const passes = (options.passes ?? []).filter((pass) => Number.isFinite(pass) && pass >= 1);
2056
+ if (passes.length > 0) {
2057
+ const byPass = new Map((options.passRecords ?? [])
2058
+ .filter((record) => passes.includes(record.pass))
2059
+ .map((record) => [record.pass, record]));
2060
+ const previews = passes.map((pass) => {
2061
+ const record = byPass.get(pass);
2062
+ return {
2063
+ pass,
2064
+ ...(record?.status ? { status: record.status } : {}),
2065
+ ...(record?.failureCategory
2066
+ ? { failureCategory: record.failureCategory }
2067
+ : {}),
2068
+ ...(record?.nodeRecordPath
2069
+ ? { nodeRecordPath: record.nodeRecordPath }
2070
+ : {}),
2071
+ };
2072
+ });
2073
+ const finalRecord = byPass.get(passes[passes.length - 1]);
2074
+ const finalStatus = finalRecord?.status;
2075
+ let finalOutcome;
2076
+ if (finalStatus) {
2077
+ const normalized = finalStatus.toLowerCase();
2078
+ if (PASS_SUCCESS_STATUSES.has(normalized))
2079
+ finalOutcome = "success";
2080
+ else if (PASS_FAILURE_STATUSES.has(normalized))
2081
+ finalOutcome = "failure";
2082
+ }
2083
+ // 仅一轮 pass 时不在卡片展示(无叙事价值);多轮才展示中文「修复轮次 N」
2084
+ // 主文案,裸 P1/P2 编号不再作为主标题(原始编号仅 hover/title/诊断)。
2085
+ const historyLabel = passes.length <= 1
2086
+ ? undefined
2087
+ : `修复轮次 ${passes.join(",")}${finalOutcome === "success"
2088
+ ? " · 最终成功"
2089
+ : finalOutcome === "failure"
2090
+ ? " · 最终失败"
2091
+ : ""}`;
2092
+ return {
2093
+ totalAttemptCount: passes.length,
2094
+ ...(finalOutcome ? { finalOutcome } : {}),
2095
+ ...(historyLabel ? { historyLabel } : {}),
2096
+ passes: previews,
2097
+ };
2098
+ }
2099
+ // 一次执行成功/失败不需要卡片历史行;不投影无叙事的 "1 次"。
2100
+ if (isExecutedNodeStatus(options.status)) {
2101
+ return undefined;
2102
+ }
2103
+ return undefined;
2104
+ }
2105
+ /**
2106
+ * Fill executionSummary for nodes without attempt records: passes-driven
2107
+ * summaries from convergence passHistory statuses, then implicit single
2108
+ * execution for executed legacy nodes. Attempt-driven summaries projected in
2109
+ * parseDagNodes take precedence and are never overwritten.
2110
+ */
2111
+ function applyNodeExecutionSummaries(nodes, state) {
2112
+ const passStatusByNode = buildNodePassStatusIndex(state);
2113
+ for (const node of nodes) {
2114
+ if (node.executionSummary)
2115
+ continue;
2116
+ const summary = projectExecutionSummary({
2117
+ passes: node.passes,
2118
+ status: node.status,
2119
+ passRecords: passStatusByNode.get(node.nodeId),
2120
+ });
2121
+ if (summary)
2122
+ node.executionSummary = summary;
2123
+ }
2124
+ }
2125
+ function buildNodePassStatusIndex(state) {
2126
+ const index = new Map();
2127
+ for (const pass of state.convergence?.passHistory ?? []) {
2128
+ for (const ref of pass.artifactRefs) {
2129
+ if (!ref.nodeId)
2130
+ continue;
2131
+ // Copy before push: passHistory artifactRefs are readonly in the run
2132
+ // state shape, so the accumulator must not alias the source array.
2133
+ const records = [...(index.get(ref.nodeId) ?? [])];
2134
+ records.push({
2135
+ pass: pass.pass,
2136
+ ...(ref.status ? { status: ref.status } : {}),
2137
+ ...(ref.failureCategory
2138
+ ? { failureCategory: ref.failureCategory }
2139
+ : {}),
2140
+ ...(ref.nodeRecordPath ? { nodeRecordPath: ref.nodeRecordPath } : {}),
2141
+ });
2142
+ index.set(ref.nodeId, records);
2143
+ }
2144
+ }
2145
+ return index;
2146
+ }
1940
2147
  function parseDagNodes(parsed, rankByNode, modelByNode) {
1941
2148
  const nodes = [];
1942
2149
  const nodesValue = parsed.nodes;
@@ -1952,7 +2159,16 @@ function parseDagNodes(parsed, rankByNode, modelByNode) {
1952
2159
  const outputPreview = nodeOutputPreview(node);
1953
2160
  const errorPreview = nodeErrorPreview(node, nodeStatus);
1954
2161
  const attempts = Array.isArray(node.attempts) ? node.attempts : undefined;
1955
- const origin = node.origin && typeof node.origin === "object" && !Array.isArray(node.origin)
2162
+ // Only attempt-driven summaries are projected here; pass-driven and
2163
+ // implicit single-execution summaries are filled afterwards by
2164
+ // applyNodeExecutionSummaries so they are never shadowed by the
2165
+ // implicit-single fallback.
2166
+ const executionSummary = attempts && attempts.length > 0
2167
+ ? projectExecutionSummary({ attempts, status: nodeStatus })
2168
+ : undefined;
2169
+ const origin = node.origin &&
2170
+ typeof node.origin === "object" &&
2171
+ !Array.isArray(node.origin)
1956
2172
  ? node.origin
1957
2173
  : undefined;
1958
2174
  nodes.push({
@@ -1970,6 +2186,7 @@ function parseDagNodes(parsed, rankByNode, modelByNode) {
1970
2186
  ...(attempts && attempts.length > 0
1971
2187
  ? { attemptCount: attempts.length }
1972
2188
  : {}),
2189
+ ...(executionSummary ? { executionSummary } : {}),
1973
2190
  ...(readString(node, "failureCategory")
1974
2191
  ? { failureCategory: readString(node, "failureCategory") }
1975
2192
  : {}),
@@ -1987,7 +2204,16 @@ function parseDagNodes(parsed, rankByNode, modelByNode) {
1987
2204
  const outputPreview = nodeOutputPreview(node);
1988
2205
  const errorPreview = nodeErrorPreview(node, nodeStatus);
1989
2206
  const attempts = Array.isArray(node.attempts) ? node.attempts : undefined;
1990
- const origin = node.origin && typeof node.origin === "object" && !Array.isArray(node.origin)
2207
+ // Only attempt-driven summaries are projected here; pass-driven and
2208
+ // implicit single-execution summaries are filled afterwards by
2209
+ // applyNodeExecutionSummaries so they are never shadowed by the
2210
+ // implicit-single fallback.
2211
+ const executionSummary = attempts && attempts.length > 0
2212
+ ? projectExecutionSummary({ attempts, status: nodeStatus })
2213
+ : undefined;
2214
+ const origin = node.origin &&
2215
+ typeof node.origin === "object" &&
2216
+ !Array.isArray(node.origin)
1991
2217
  ? node.origin
1992
2218
  : undefined;
1993
2219
  nodes.push({
@@ -2005,6 +2231,7 @@ function parseDagNodes(parsed, rankByNode, modelByNode) {
2005
2231
  ...(attempts && attempts.length > 0
2006
2232
  ? { attemptCount: attempts.length }
2007
2233
  : {}),
2234
+ ...(executionSummary ? { executionSummary } : {}),
2008
2235
  ...(readString(node, "failureCategory")
2009
2236
  ? { failureCategory: readString(node, "failureCategory") }
2010
2237
  : {}),
@@ -0,0 +1,180 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { redactSecrets, truncateUtf8Preview } from "../../shared/preview.js";
3
+ import { isSafeObservabilityIdentifier } from "../observability/event-store.js";
4
+ import { resolveDagRunArtifact } from "./dag-run-artifacts.js";
5
+ /** Align with Observe artifact preview bound (routes.ts ARTIFACT_PREVIEW_MAX_BYTES). */
6
+ export const EXECUTION_OUTPUT_MAX_BYTES = 64 * 1024;
7
+ const ATTEMPT_KEY = /^attempt-(\d+)$/;
8
+ const PASS_KEY = /^pass-(\d+)$/;
9
+ /**
10
+ * Parse an Inspector execution key. Clients may only send attempt-N or pass-N;
11
+ * arbitrary artifact paths are never accepted.
12
+ */
13
+ export function parseExecutionOutputKey(raw) {
14
+ if (raw == null || String(raw).trim() === "") {
15
+ return { ok: false, reason: "missing" };
16
+ }
17
+ const key = String(raw).trim();
18
+ const attempt = ATTEMPT_KEY.exec(key);
19
+ if (attempt) {
20
+ const index = Number(attempt[1]);
21
+ if (!Number.isInteger(index) || index < 1) {
22
+ return { ok: false, reason: "invalid" };
23
+ }
24
+ return { ok: true, kind: "attempt", index, key: `attempt-${index}` };
25
+ }
26
+ const pass = PASS_KEY.exec(key);
27
+ if (pass) {
28
+ const index = Number(pass[1]);
29
+ if (!Number.isInteger(index) || index < 1) {
30
+ return { ok: false, reason: "invalid" };
31
+ }
32
+ return { ok: true, kind: "pass", index, key: `pass-${index}` };
33
+ }
34
+ return { ok: false, reason: "invalid" };
35
+ }
36
+ /**
37
+ * Map a validated execution key to relative segments under the target run dir.
38
+ * attempt-N → <nodeId>/attempt-N.json
39
+ * pass-N → convergence/pass-N/<nodeId>.json
40
+ */
41
+ export function executionOutputRelativeSegments(nodeId, parsed) {
42
+ if (parsed.kind === "attempt") {
43
+ return [nodeId, `attempt-${parsed.index}.json`];
44
+ }
45
+ return ["convergence", `pass-${parsed.index}`, `${nodeId}.json`];
46
+ }
47
+ function asOptionalString(value) {
48
+ return typeof value === "string" ? value : null;
49
+ }
50
+ function firstTimestamp(record) {
51
+ for (const field of [
52
+ "finishedAt",
53
+ "completedAt",
54
+ "endedAt",
55
+ "startedAt",
56
+ "timestamp",
57
+ "updatedAt",
58
+ ]) {
59
+ const value = record[field];
60
+ if (typeof value === "string" && value.trim())
61
+ return value;
62
+ }
63
+ return null;
64
+ }
65
+ function boundField(value) {
66
+ if (value == null)
67
+ return { text: null, truncated: false };
68
+ const redacted = redactSecrets(value);
69
+ const truncated = truncateUtf8Preview(redacted, EXECUTION_OUTPUT_MAX_BYTES);
70
+ return {
71
+ text: truncated,
72
+ truncated: truncated !== redacted,
73
+ };
74
+ }
75
+ /**
76
+ * Load redacted, bounded assistantText/stdout/stderr for a node execution key.
77
+ * Fail-closed on invalid ids, invalid keys, path escape, missing or ambiguous runs.
78
+ * Never accepts a client-supplied artifact path.
79
+ */
80
+ export async function loadDagNodeExecutionOutput(repoRoot, dagRunId, nodeId, rawKey) {
81
+ if (!isSafeObservabilityIdentifier(dagRunId) ||
82
+ !isSafeObservabilityIdentifier(nodeId)) {
83
+ return {
84
+ status: 400,
85
+ body: { error: "Invalid dag run or node identifier", reason: "invalid-id" },
86
+ };
87
+ }
88
+ const parsed = parseExecutionOutputKey(rawKey);
89
+ if (!parsed.ok) {
90
+ return {
91
+ status: 400,
92
+ body: {
93
+ error: parsed.reason === "missing"
94
+ ? "Missing execution key (expected attempt-N or pass-N)"
95
+ : "Invalid execution key (expected attempt-N or pass-N)",
96
+ reason: parsed.reason === "missing" ? "missing-key" : "invalid-key",
97
+ },
98
+ };
99
+ }
100
+ const segments = executionOutputRelativeSegments(nodeId, parsed);
101
+ const resolved = resolveDagRunArtifact(repoRoot, dagRunId, segments);
102
+ if (!resolved.ok) {
103
+ if (resolved.reason === "invalid-id") {
104
+ return {
105
+ status: 400,
106
+ body: {
107
+ error: "Invalid dag run or node identifier",
108
+ reason: "invalid-id",
109
+ },
110
+ };
111
+ }
112
+ if (resolved.reason === "unsafe-path") {
113
+ return {
114
+ status: 400,
115
+ body: { error: "Unsafe execution artifact path", reason: "unsafe-path" },
116
+ };
117
+ }
118
+ if (resolved.reason === "ambiguous") {
119
+ return {
120
+ status: 409,
121
+ body: {
122
+ error: "Ambiguous dag run lifecycle for execution artifact",
123
+ reason: "ambiguous",
124
+ },
125
+ };
126
+ }
127
+ return {
128
+ status: 404,
129
+ body: {
130
+ error: "Execution output artifact not found",
131
+ reason: "not-found",
132
+ },
133
+ };
134
+ }
135
+ let raw;
136
+ try {
137
+ raw = await readFile(resolved.result.absolutePath, "utf8");
138
+ }
139
+ catch {
140
+ return {
141
+ status: 404,
142
+ body: {
143
+ error: "Execution output artifact not found",
144
+ reason: "not-found",
145
+ },
146
+ };
147
+ }
148
+ let record = {};
149
+ try {
150
+ const parsedJson = JSON.parse(raw);
151
+ if (parsedJson && typeof parsedJson === "object" && !Array.isArray(parsedJson)) {
152
+ record = parsedJson;
153
+ }
154
+ }
155
+ catch {
156
+ // Tolerant reader: non-JSON artifacts still return empty body fields.
157
+ record = {};
158
+ }
159
+ const assistant = boundField(asOptionalString(record.assistantText));
160
+ const stdout = boundField(asOptionalString(record.stdout));
161
+ const stderr = boundField(asOptionalString(record.stderr));
162
+ const truncated = assistant.truncated || stdout.truncated || stderr.truncated;
163
+ return {
164
+ status: 200,
165
+ body: {
166
+ dagRunId,
167
+ nodeId,
168
+ key: parsed.key,
169
+ kind: parsed.kind,
170
+ index: parsed.index,
171
+ source: resolved.result.relativePath.replace(/\\/g, "/"),
172
+ timestamp: firstTimestamp(record),
173
+ assistantText: assistant.text,
174
+ stdout: stdout.text,
175
+ stderr: stderr.text,
176
+ truncated,
177
+ maxBytes: EXECUTION_OUTPUT_MAX_BYTES,
178
+ },
179
+ };
180
+ }
@@ -8,12 +8,14 @@ import { parseWorkerEventLine } from "../observability/events.js";
8
8
  import { isSafeObservabilityIdentifier } from "../observability/event-store.js";
9
9
  import { clampEventHistoryLimit, listBatchEventHistory, listPoolEventHistory, } from "../observability/event-history.js";
10
10
  import { buildGlobalSnapshot, clampTaskRunHistoryLimit, listTaskRunHistory, resolveLegacyTask, } from "../observability/read-model.js";
11
+ import { loadDagRunExecutionTrajectory } from "../observability/dag-execution-trajectory.js";
11
12
  import { resolveDagTaskSourcePath } from "../../task/dag-source-paths.js";
12
13
  import { dagSourceBindingSchema } from "../../workflows/dag/types.js";
13
14
  import { getTaskPoolRoot } from "../pool/run-store.js";
14
15
  import { isAllowedArtifactTextPath, resolveArtifactPath, resolveRepoFilePreview, toRepoRelativeArtifactPath, } from "./paths.js";
15
16
  import { extractSpecEvidence, extractSpecReadContent, } from "./spec-evidence.js";
16
17
  import { buildDagNodeInput } from "./node-input.js";
18
+ import { loadDagNodeExecutionOutput } from "./dag-node-execution-output.js";
17
19
  import { buildObserveHealthV1 } from "./health.js";
18
20
  import { buildNightJobDetail, buildNightJobsDoctor, buildNightJobsList, buildNightJobsMorning, } from "./night-jobs.js";
19
21
  const ARTIFACT_PREVIEW_MAX_BYTES = 64 * 1024;
@@ -93,6 +95,11 @@ export const ROUTES = [
93
95
  pattern: /^\/api\/dag-runs\/([^/]+)\/nodes\/([^/]+)\/input$/,
94
96
  handler: handleDagNodeInput,
95
97
  },
98
+ {
99
+ method: "GET",
100
+ pattern: /^\/api\/dag-runs\/([^/]+)\/nodes\/([^/]+)\/execution-output$/,
101
+ handler: handleDagNodeExecutionOutput,
102
+ },
96
103
  {
97
104
  method: "GET",
98
105
  pattern: /^\/api\/dag-runs\/([^/]+)\/nodes\/([^/]+)\/spec-evidence$/,
@@ -500,14 +507,25 @@ async function handleRunArtifacts(_req, res, match, ctx) {
500
507
  }
501
508
  sendJson(res, 200, { artifacts });
502
509
  }
503
- async function handleDagRunById(_req, res, match, ctx) {
510
+ export async function handleDagRunById(_req, res, match, ctx) {
504
511
  const snapshot = await getSnapshot(ctx);
505
512
  const dagRun = snapshot.dagRuns.find((d) => d.dagRunId === match.params.id);
506
513
  if (!dagRun) {
507
514
  sendJson(res, 404, { error: "DAG run not found" });
508
515
  return;
509
516
  }
510
- sendJson(res, 200, dagRun);
517
+ const payload = { ...dagRun };
518
+ // P1 契约:在 detail 响应中内嵌有界 executionTrajectory(不新增端点、
519
+ // 不接收客户端 artifact path)。仅当存在真实执行证据/警告/投影异常时附加;
520
+ // legacy 或纯事件 run 省略该字段,前端据此显示「本次运行没有可展开的执行轨迹」。
521
+ const trajectory = await loadDagRunExecutionTrajectory(ctx.repoRoot, dagRun.dagRunId);
522
+ if (trajectory &&
523
+ (trajectory.occurrences.length > 0 ||
524
+ trajectory.projectionError === true ||
525
+ trajectory.warnings.length > 0)) {
526
+ payload.executionTrajectory = trajectory;
527
+ }
528
+ sendJson(res, 200, payload);
511
529
  }
512
530
  async function handleDagNodeInput(_req, res, match, ctx) {
513
531
  const dagRunId = match.params.id;
@@ -520,6 +538,25 @@ async function handleDagNodeInput(_req, res, match, ctx) {
520
538
  const result = await buildDagNodeInput(ctx.repoRoot, dagRunId, nodeId);
521
539
  sendJson(res, result.status, result.body);
522
540
  }
541
+ /**
542
+ * Read-only execution body by attempt-N / pass-N key only.
543
+ * Clients must never submit artifact paths; server maps key → run-dir segments.
544
+ */
545
+ async function handleDagNodeExecutionOutput(_req, res, match, ctx) {
546
+ const dagRunId = match.params.id;
547
+ const nodeId = match.params.sub;
548
+ const key = match.query.get("key");
549
+ // Reject path-shaped inputs even if smuggled via key/query aliases.
550
+ if (match.query.has("path") || match.query.has("artifactPath")) {
551
+ sendJson(res, 400, {
552
+ error: "Artifact path is not accepted; use key=attempt-N|pass-N",
553
+ reason: "path-not-allowed",
554
+ });
555
+ return;
556
+ }
557
+ const result = await loadDagNodeExecutionOutput(ctx.repoRoot, dagRunId, nodeId, key);
558
+ sendJson(res, result.status, result.body);
559
+ }
523
560
  async function handleDagNodeSessionEvents(_req, res, match, ctx) {
524
561
  const dagRunId = match.params.id;
525
562
  const nodeId = match.params.sub;
@@ -1005,13 +1042,23 @@ async function handleDagNodeSpecEvidence(_req, res, match, ctx) {
1005
1042
  sendJson(res, 200, evidence);
1006
1043
  }
1007
1044
  function contentTypeFor(pathname) {
1008
- if (pathname.endsWith(".html"))
1045
+ const lower = pathname.toLowerCase();
1046
+ if (lower.endsWith(".html"))
1009
1047
  return "text/html; charset=utf-8";
1010
- if (pathname.endsWith(".js"))
1048
+ if (lower.endsWith(".js") || lower.endsWith(".mjs")) {
1011
1049
  return "text/javascript; charset=utf-8";
1012
- if (pathname.endsWith(".css"))
1050
+ }
1051
+ if (lower.endsWith(".css"))
1013
1052
  return "text/css; charset=utf-8";
1014
- if (pathname.endsWith(".json"))
1053
+ if (lower.endsWith(".json"))
1015
1054
  return "application/json; charset=utf-8";
1055
+ if (lower.endsWith(".svg"))
1056
+ return "image/svg+xml";
1057
+ if (lower.endsWith(".png"))
1058
+ return "image/png";
1059
+ if (lower.endsWith(".ico"))
1060
+ return "image/x-icon";
1061
+ if (lower.endsWith(".woff2"))
1062
+ return "font/woff2";
1016
1063
  return "application/octet-stream";
1017
1064
  }