@tea-agent/loop-agent 0.30.0 → 0.31.1
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.
- package/CHANGELOG.md +58 -0
- package/dist/executors/dag-pi-executor.js +72 -4
- package/dist/executors/pi-sdk-executor.js +61 -0
- package/dist/executors/shell-executor.js +136 -79
- package/dist/worker/observability/dag-execution-trajectory.js +591 -0
- package/dist/worker/observability/read-model.js +258 -31
- package/dist/worker/observe/dag-node-execution-output.js +180 -0
- package/dist/worker/observe/routes.js +53 -6
- package/dist/worker/observe/static/dag-edge-routing.js +368 -0
- package/dist/worker/observe/static/dag-history-labels.js +95 -0
- package/dist/worker/observe/static/dag-layout.d.ts +12 -7
- package/dist/worker/observe/static/dag-layout.js +101 -21
- package/dist/worker/observe/static/favicon.svg +37 -0
- package/dist/worker/observe/static/format.js +31 -1
- package/dist/worker/observe/static/index.html +1 -1
- package/dist/worker/observe/static/state.js +102 -0
- package/dist/worker/observe/static/styles.css +267 -7
- package/dist/worker/observe/static/views/dag-graph.js +414 -154
- package/dist/worker/observe/static/views/dag-inspector.js +478 -27
- package/dist/worker/observe/static/views/dag-trajectory.js +313 -0
- package/dist/worker/observe/static/views/dag.js +20 -3
- package/dist/workflows/dag/backend-test-pytest-collection.js +162 -7
- package/dist/workflows/dag/backend-test-result-contract.js +105 -67
- package/dist/workflows/dag/backend-test-scenario-param.js +92 -30
- package/dist/workflows/dag/backend-test-writer-completeness.js +55 -0
- package/dist/workflows/dag/init-hybrid.js +46 -49
- package/dist/workflows/dag/rerun-task.js +86 -0
- package/docs/architecture/README.md +4 -0
- package/docs/architecture/dag-execution.md +1 -1
- package/docs/architecture/worker-and-feature.md +1 -1
- package/docs/governance/README.md +3 -0
- package/docs/operations/README.md +1 -0
- package/docs/templates/backend-test-dag.json +40 -60
- package/docs/templates/frontend-test-dag.json +1 -1
- package/harness.json +3 -3
- package/package.json +3 -2
- 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
|
-
|
|
852
|
-
|
|
853
|
-
|
|
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 " +
|
|
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"), {
|
|
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 &&
|
|
1557
|
+
if (!initial &&
|
|
1558
|
+
!classification &&
|
|
1559
|
+
!eligibility &&
|
|
1560
|
+
!final &&
|
|
1561
|
+
!effective &&
|
|
1562
|
+
!manifest)
|
|
1549
1563
|
return undefined;
|
|
1550
|
-
const result = (value) => value
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
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" ||
|
|
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
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
},
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
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" ||
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1045
|
+
const lower = pathname.toLowerCase();
|
|
1046
|
+
if (lower.endsWith(".html"))
|
|
1009
1047
|
return "text/html; charset=utf-8";
|
|
1010
|
-
if (
|
|
1048
|
+
if (lower.endsWith(".js") || lower.endsWith(".mjs")) {
|
|
1011
1049
|
return "text/javascript; charset=utf-8";
|
|
1012
|
-
|
|
1050
|
+
}
|
|
1051
|
+
if (lower.endsWith(".css"))
|
|
1013
1052
|
return "text/css; charset=utf-8";
|
|
1014
|
-
if (
|
|
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
|
}
|