@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
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DAG 执行轨迹 storyboard(Observe UI R5 / P1)。
|
|
3
|
+
*
|
|
4
|
+
* 将 detail 响应内嵌的有界 executionTrajectory 渲染为自上而下的 lane:
|
|
5
|
+
* 「初始执行」「修复轮次 N-1」「最终收敛」(仅内容 lane)。raw pass=1(scope
|
|
6
|
+
* base 或 convergence-pass)均显示「初始执行」,raw pass>=2 显示「修复轮次
|
|
7
|
+
* N-1」;raw pass 编号仅保留在 occurrence 卡 hover/title 诊断(AC-001/AC-002)。
|
|
8
|
+
* lane 内按真实
|
|
9
|
+
* sequence 左→右;occurrence 卡含节点名/状态/耗时/语义 verdict;多 attempt
|
|
10
|
+
* 显示中文紧凑尝试条;lane 间短箭头加中文原因;不画跨 lane 回环(P2 路由
|
|
11
|
+
* 算法不在 P1 范围)。布局异常降级为纵向事件列表,不空白页。
|
|
12
|
+
*/
|
|
13
|
+
import { el } from "../dom.js";
|
|
14
|
+
import { statusLabel, formatMs } from "../format.js";
|
|
15
|
+
import {
|
|
16
|
+
attemptFailureLabel,
|
|
17
|
+
semanticVerdictLabel,
|
|
18
|
+
transitionReasonLabel,
|
|
19
|
+
} from "../dag-history-labels.js";
|
|
20
|
+
import { setDagGraphViewMode } from "../state.js";
|
|
21
|
+
import { selectDagExecution } from "./dag-inspector.js";
|
|
22
|
+
|
|
23
|
+
export const TRAJECTORY_EMPTY_TEXT = "本次运行没有可展开的执行轨迹";
|
|
24
|
+
export const TRAJECTORY_UNAVAILABLE_TEXT = "执行历史暂不可用";
|
|
25
|
+
export const TRAJECTORY_PARTIAL_TEXT = "历史证据不完整";
|
|
26
|
+
|
|
27
|
+
const SUCCESS_STATUSES = new Set([
|
|
28
|
+
"finished",
|
|
29
|
+
"completed",
|
|
30
|
+
"succeeded",
|
|
31
|
+
"done",
|
|
32
|
+
"success",
|
|
33
|
+
]);
|
|
34
|
+
const FAILURE_STATUSES = new Set([
|
|
35
|
+
"error",
|
|
36
|
+
"failed",
|
|
37
|
+
"partial_failed",
|
|
38
|
+
"partial-failed",
|
|
39
|
+
"interrupted",
|
|
40
|
+
"failure",
|
|
41
|
+
"superseded",
|
|
42
|
+
]);
|
|
43
|
+
const RUNNING_STATUSES = new Set(["running", "started", "paused", "queued"]);
|
|
44
|
+
|
|
45
|
+
function occurrenceStatusBadge(status) {
|
|
46
|
+
const normalized = String(status ?? "").toLowerCase();
|
|
47
|
+
if (SUCCESS_STATUSES.has(normalized)) {
|
|
48
|
+
return { cls: "succeeded", label: "成功" };
|
|
49
|
+
}
|
|
50
|
+
if (FAILURE_STATUSES.has(normalized)) {
|
|
51
|
+
return { cls: "failed", label: "失败" };
|
|
52
|
+
}
|
|
53
|
+
if (RUNNING_STATUSES.has(normalized)) {
|
|
54
|
+
return { cls: "pending", label: "运行中" };
|
|
55
|
+
}
|
|
56
|
+
return { cls: "pending", label: statusLabel(status) ?? status ?? "未知" };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Lane grouping key from an occurrence (pre-sorted by the projection). */
|
|
60
|
+
function laneKeyOf(occurrence) {
|
|
61
|
+
if (occurrence.scope === "final") return "final";
|
|
62
|
+
if (occurrence.scope === "base") return "base";
|
|
63
|
+
return `pass-${occurrence.pass ?? 0}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function laneTitle(laneKey, occurrence) {
|
|
67
|
+
if (laneKey === "base") return "初始执行";
|
|
68
|
+
if (laneKey === "final") return "最终收敛";
|
|
69
|
+
// raw pass=1 的 convergence-pass lane 也显示「初始执行」;raw pass>=2
|
|
70
|
+
// 映射为人类修复轮次 N-1(AC-001/AC-002),主文案不出现 raw 轮次直译。
|
|
71
|
+
const pass = occurrence.pass ?? 0;
|
|
72
|
+
if (pass <= 1) return "初始执行";
|
|
73
|
+
return `修复轮次 ${pass - 1}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** 中文紧凑尝试条:首次尝试 / 第 N 次尝试(AC-002,主 UI 不出现裸 A1/A2)。 */
|
|
77
|
+
function attemptStripLabel(occurrence) {
|
|
78
|
+
const attempts = occurrence.attempts;
|
|
79
|
+
if (!Array.isArray(attempts) || attempts.length <= 1) return null;
|
|
80
|
+
return attempts
|
|
81
|
+
.map((attempt) => {
|
|
82
|
+
const name =
|
|
83
|
+
attempt.attempt === 1 ? "首次尝试" : `第 ${attempt.attempt} 次尝试`;
|
|
84
|
+
// 失败 attempt 显示表一中文原因(AC-001),未知/缺失分类
|
|
85
|
+
// fail-closed 为「其他失败原因」;成功 attempt 保持「成功」。
|
|
86
|
+
const outcome =
|
|
87
|
+
attempt.status === "success"
|
|
88
|
+
? "成功"
|
|
89
|
+
: attemptFailureLabel(attempt.failureCategory);
|
|
90
|
+
return `${name} ${outcome}`;
|
|
91
|
+
})
|
|
92
|
+
.join(" → ");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function buildOccurrenceCard(dagRunId, occurrence) {
|
|
96
|
+
const badge = occurrenceStatusBadge(occurrence.status);
|
|
97
|
+
const recovered =
|
|
98
|
+
occurrence.recovered === true ||
|
|
99
|
+
occurrence.supersededBy?.length > 0 ||
|
|
100
|
+
false;
|
|
101
|
+
const card = document.createElement("button");
|
|
102
|
+
card.type = "button";
|
|
103
|
+
const extraClasses = [
|
|
104
|
+
recovered ? "is-recovered" : "",
|
|
105
|
+
occurrence.availability === "partial" ? "is-partial" : "",
|
|
106
|
+
]
|
|
107
|
+
.filter(Boolean)
|
|
108
|
+
.join(" ");
|
|
109
|
+
card.className = extraClasses
|
|
110
|
+
? `dag-trajectory-occurrence ${extraClasses}`
|
|
111
|
+
: "dag-trajectory-occurrence";
|
|
112
|
+
// 原始 pass/attempt 编号仅出现在 hover/title 诊断中,不作主标题(AC-003)。
|
|
113
|
+
const diagnostic = [
|
|
114
|
+
occurrence.executionId,
|
|
115
|
+
occurrence.status,
|
|
116
|
+
...(occurrence.pass ? [`pass ${occurrence.pass}`] : []),
|
|
117
|
+
...(occurrence.failureCategory
|
|
118
|
+
? [`failureCategory ${occurrence.failureCategory}`]
|
|
119
|
+
: []),
|
|
120
|
+
].join("\n");
|
|
121
|
+
card.setAttribute("title", diagnostic);
|
|
122
|
+
// 语义 verdict 主文案与 aria-label 均显示表二中文(AC-002)。
|
|
123
|
+
const verdictLabel = semanticVerdictLabel(occurrence.semanticVerdict);
|
|
124
|
+
card.setAttribute(
|
|
125
|
+
"aria-label",
|
|
126
|
+
`${occurrence.nodeId},${badge.label}${occurrence.semanticVerdict ? `,语义 ${verdictLabel}` : ""}${recovered ? ",历史失败 · 已恢复/已覆盖" : ""}`,
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
const nodeLine = el("span", "dag-trajectory-occurrence-node", occurrence.nodeId);
|
|
130
|
+
card.appendChild(nodeLine);
|
|
131
|
+
const statusBadge = el(
|
|
132
|
+
"span",
|
|
133
|
+
`badge badge-${badge.cls} dag-trajectory-occurrence-status`,
|
|
134
|
+
badge.label,
|
|
135
|
+
);
|
|
136
|
+
card.appendChild(statusBadge);
|
|
137
|
+
if (occurrence.durationMs !== undefined) {
|
|
138
|
+
card.appendChild(
|
|
139
|
+
el(
|
|
140
|
+
"span",
|
|
141
|
+
"dag-trajectory-occurrence-meta",
|
|
142
|
+
formatMs(occurrence.durationMs),
|
|
143
|
+
),
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
if (occurrence.semanticVerdict) {
|
|
147
|
+
card.appendChild(
|
|
148
|
+
el("span", "dag-trajectory-occurrence-verdict", verdictLabel),
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
const strip = attemptStripLabel(occurrence);
|
|
152
|
+
if (strip) {
|
|
153
|
+
card.appendChild(el("span", "dag-trajectory-attempt-strip", strip));
|
|
154
|
+
}
|
|
155
|
+
if (recovered) {
|
|
156
|
+
const label =
|
|
157
|
+
occurrence.supersededBy?.length > 0
|
|
158
|
+
? "历史失败 · 已覆盖"
|
|
159
|
+
: "历史失败 · 已恢复";
|
|
160
|
+
card.appendChild(el("span", "dag-trajectory-recovered", label));
|
|
161
|
+
}
|
|
162
|
+
if (occurrence.availability === "partial") {
|
|
163
|
+
card.appendChild(
|
|
164
|
+
el("span", "dag-trajectory-partial", TRAJECTORY_PARTIAL_TEXT),
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
card.addEventListener("click", () => {
|
|
168
|
+
// @pass-<n> 映射为 Inspector 既有 pass-<n> key;@base/@final 默认最新。
|
|
169
|
+
selectDagExecution(
|
|
170
|
+
dagRunId,
|
|
171
|
+
occurrence.nodeId,
|
|
172
|
+
occurrence.pass ? `pass-${occurrence.pass}` : null,
|
|
173
|
+
);
|
|
174
|
+
});
|
|
175
|
+
return card;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function buildEmptyState(dagRunId, rerender) {
|
|
179
|
+
const root = el("div", "dag-trajectory-empty");
|
|
180
|
+
root.appendChild(el("p", "dag-trajectory-empty-text", TRAJECTORY_EMPTY_TEXT));
|
|
181
|
+
const back = document.createElement("button");
|
|
182
|
+
back.type = "button";
|
|
183
|
+
back.className = "dag-trajectory-back";
|
|
184
|
+
back.textContent = "返回逻辑拓扑";
|
|
185
|
+
back.addEventListener("click", () => {
|
|
186
|
+
setDagGraphViewMode(dagRunId, "topology");
|
|
187
|
+
rerender();
|
|
188
|
+
});
|
|
189
|
+
root.appendChild(back);
|
|
190
|
+
return root;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* 渲染执行轨迹视图到图区域 viewport。`rerender` 由 dag-graph 注入,用于
|
|
195
|
+
* 空态「返回逻辑拓扑」就地切回拓扑(避免模块环)。
|
|
196
|
+
*/
|
|
197
|
+
export function renderTrajectoryStoryboard(dag, dagRunId, viewport, rerender) {
|
|
198
|
+
const trajectory = dag.executionTrajectory;
|
|
199
|
+
const fallback = (occurrences) => {
|
|
200
|
+
const list = el("ul", "dag-trajectory-fallback");
|
|
201
|
+
const items = Array.isArray(occurrences)
|
|
202
|
+
? occurrences
|
|
203
|
+
: trajectory?.occurrences ?? [];
|
|
204
|
+
if (items.length === 0) {
|
|
205
|
+
list.appendChild(
|
|
206
|
+
el("li", "dag-trajectory-fallback-item", TRAJECTORY_UNAVAILABLE_TEXT),
|
|
207
|
+
);
|
|
208
|
+
} else {
|
|
209
|
+
let rendered = 0;
|
|
210
|
+
for (const occurrence of items) {
|
|
211
|
+
if (!occurrence || typeof occurrence !== "object") continue;
|
|
212
|
+
// 纵向降级列表同样经表三/表二映射拼接,不出现裸英文 token(AC-002)。
|
|
213
|
+
const reason =
|
|
214
|
+
transitionReasonLabel(occurrence.nextReason) ||
|
|
215
|
+
semanticVerdictLabel(occurrence.semanticVerdict) ||
|
|
216
|
+
"";
|
|
217
|
+
list.appendChild(
|
|
218
|
+
el(
|
|
219
|
+
"li",
|
|
220
|
+
"dag-trajectory-fallback-item",
|
|
221
|
+
`${occurrence.nodeId} · ${statusLabel(occurrence.status)}${reason ? ` · ${reason}` : ""}`,
|
|
222
|
+
),
|
|
223
|
+
);
|
|
224
|
+
rendered += 1;
|
|
225
|
+
}
|
|
226
|
+
if (rendered === 0) {
|
|
227
|
+
list.appendChild(
|
|
228
|
+
el(
|
|
229
|
+
"li",
|
|
230
|
+
"dag-trajectory-fallback-item",
|
|
231
|
+
TRAJECTORY_UNAVAILABLE_TEXT,
|
|
232
|
+
),
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
viewport.appendChild(list);
|
|
237
|
+
};
|
|
238
|
+
try {
|
|
239
|
+
if (!trajectory || !Array.isArray(trajectory.occurrences)) {
|
|
240
|
+
// 无 executionTrajectory 或空轨迹:空态 + 返回逻辑拓扑入口。
|
|
241
|
+
viewport.appendChild(buildEmptyState(dagRunId, rerender));
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
const root = el("div", "dag-trajectory");
|
|
245
|
+
if (trajectory.projectionError === true) {
|
|
246
|
+
root.appendChild(
|
|
247
|
+
el("div", "dag-trajectory-banner is-error", TRAJECTORY_UNAVAILABLE_TEXT),
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
if (Array.isArray(trajectory.warnings) && trajectory.warnings.length > 0) {
|
|
251
|
+
root.appendChild(
|
|
252
|
+
el(
|
|
253
|
+
"div",
|
|
254
|
+
"dag-trajectory-banner",
|
|
255
|
+
`${TRAJECTORY_PARTIAL_TEXT}:${trajectory.warnings.join(";")}`,
|
|
256
|
+
),
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
if (trajectory.occurrences.length === 0) {
|
|
260
|
+
root.appendChild(buildEmptyState(dagRunId, rerender));
|
|
261
|
+
viewport.appendChild(root);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
// 按投影排序连续分组为 lane(仅内容 lane),lane 间插入带中文原因的短箭头。
|
|
265
|
+
const lanes = [];
|
|
266
|
+
let currentLaneKey = null;
|
|
267
|
+
for (const occurrence of trajectory.occurrences) {
|
|
268
|
+
const laneKey = laneKeyOf(occurrence);
|
|
269
|
+
if (laneKey !== currentLaneKey) {
|
|
270
|
+
currentLaneKey = laneKey;
|
|
271
|
+
lanes.push({
|
|
272
|
+
laneKey,
|
|
273
|
+
title: laneTitle(laneKey, occurrence),
|
|
274
|
+
occurrences: [],
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
lanes[lanes.length - 1].occurrences.push(occurrence);
|
|
278
|
+
}
|
|
279
|
+
for (const [index, lane] of lanes.entries()) {
|
|
280
|
+
const laneRoot = el("div", "dag-trajectory-lane");
|
|
281
|
+
const head = el("div", "dag-trajectory-lane-head");
|
|
282
|
+
head.appendChild(el("span", "dag-trajectory-lane-title", lane.title));
|
|
283
|
+
head.appendChild(
|
|
284
|
+
el("span", "dag-trajectory-lane-meta", `${lane.occurrences.length} 次执行`),
|
|
285
|
+
);
|
|
286
|
+
laneRoot.appendChild(head);
|
|
287
|
+
const laneCards = el("div", "dag-trajectory-lane-cards");
|
|
288
|
+
for (const occurrence of lane.occurrences) {
|
|
289
|
+
laneCards.appendChild(buildOccurrenceCard(dagRunId, occurrence));
|
|
290
|
+
}
|
|
291
|
+
laneRoot.appendChild(laneCards);
|
|
292
|
+
root.appendChild(laneRoot);
|
|
293
|
+
if (index < lanes.length - 1) {
|
|
294
|
+
const lastOccurrence = lane.occurrences[lane.occurrences.length - 1];
|
|
295
|
+
const reason = transitionReasonLabel(lastOccurrence?.nextReason);
|
|
296
|
+
if (reason) {
|
|
297
|
+
const transition = el("div", "dag-trajectory-transition");
|
|
298
|
+
transition.appendChild(
|
|
299
|
+
el("span", "dag-trajectory-transition-arrow", "↓"),
|
|
300
|
+
);
|
|
301
|
+
transition.appendChild(
|
|
302
|
+
el("span", "dag-trajectory-transition-reason", reason),
|
|
303
|
+
);
|
|
304
|
+
root.appendChild(transition);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
viewport.appendChild(root);
|
|
309
|
+
} catch {
|
|
310
|
+
// 布局/渲染异常降级:纵向事件列表,不空白页(AC-006)。
|
|
311
|
+
fallback(null);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
@@ -62,7 +62,7 @@ import {
|
|
|
62
62
|
mountViewState,
|
|
63
63
|
makeSessionEventIdentity,
|
|
64
64
|
} from "../state.js";
|
|
65
|
-
import { uiState, dagGraphViewportStates } from "../state.js";
|
|
65
|
+
import { uiState, dagGraphViewportStates, dagGraphViewModes } from "../state.js";
|
|
66
66
|
import { sortPoolTasks } from "../format-pool.js";
|
|
67
67
|
import {
|
|
68
68
|
dagMetaVisibility,
|
|
@@ -108,9 +108,12 @@ export async function renderDagDetail(dagRunId, initial = true) {
|
|
|
108
108
|
uiState.sessionEventIdentity = null;
|
|
109
109
|
uiState.dagInspectorOpen = false;
|
|
110
110
|
uiState.dagInspectorTab = "output";
|
|
111
|
+
uiState.dagInspectorAttempt = null;
|
|
111
112
|
uiState.dagGraphViewportState = null;
|
|
112
113
|
uiState.dagTimelineViewportState = null;
|
|
113
114
|
}
|
|
115
|
+
// AC-001:同会话内重新进入同一 dagRunId 保持当前图视图模式(不切回逻辑拓扑)。
|
|
116
|
+
uiState.dagGraphViewMode = dagGraphViewModes.get(dagRunId) ?? "topology";
|
|
114
117
|
|
|
115
118
|
const metaEl = document.getElementById("dag-meta");
|
|
116
119
|
const ranksEl = document.getElementById("dag-ranks");
|
|
@@ -167,6 +170,7 @@ export async function renderDagDetail(dagRunId, initial = true) {
|
|
|
167
170
|
) {
|
|
168
171
|
uiState.selectedDagNodeId = null;
|
|
169
172
|
uiState.dagInspectorOpen = false;
|
|
173
|
+
uiState.dagInspectorAttempt = null;
|
|
170
174
|
if (initial) {
|
|
171
175
|
uiState.sessionEventOffset = 0;
|
|
172
176
|
uiState.sessionEvents = [];
|
|
@@ -325,17 +329,30 @@ export async function renderDagDetail(dagRunId, initial = true) {
|
|
|
325
329
|
node.model ?? "—",
|
|
326
330
|
node.status ? badge(node.status) : "—",
|
|
327
331
|
formatMs(node.durationMs),
|
|
332
|
+
(typeof node.executionSummary?.historyLabel === "string" &&
|
|
333
|
+
node.executionSummary.historyLabel.trim()) ||
|
|
334
|
+
"—",
|
|
328
335
|
node.errorPreview ?? node.label ?? "—",
|
|
329
336
|
],
|
|
330
337
|
};
|
|
331
338
|
});
|
|
332
339
|
const nodeTable = buildTable(
|
|
333
|
-
[
|
|
340
|
+
[
|
|
341
|
+
"序号",
|
|
342
|
+
"节点 ID",
|
|
343
|
+
"依赖层",
|
|
344
|
+
"执行方式",
|
|
345
|
+
"模型",
|
|
346
|
+
"状态",
|
|
347
|
+
"耗时",
|
|
348
|
+
"执行历史",
|
|
349
|
+
"备注",
|
|
350
|
+
],
|
|
334
351
|
rows,
|
|
335
352
|
);
|
|
336
353
|
nodeTable.classList.add("dag-node-table");
|
|
337
354
|
for (const row of nodeTable.tBodies[0]?.rows ?? []) {
|
|
338
|
-
for (const columnIndex of [1, 4,
|
|
355
|
+
for (const columnIndex of [1, 4, 8]) {
|
|
339
356
|
const cell = row.cells[columnIndex];
|
|
340
357
|
if (cell?.textContent) cell.title = cell.textContent;
|
|
341
358
|
}
|
|
@@ -11,12 +11,16 @@ export const backendPytestCollectionFindingSchema = z.object({
|
|
|
11
11
|
detail: z.string().min(1),
|
|
12
12
|
}).strict();
|
|
13
13
|
export const backendPytestCollectionFactsSchema = z.object({
|
|
14
|
-
schemaId: z.literal("backend-test-pytest-collection-
|
|
14
|
+
schemaId: z.literal("backend-test-pytest-collection-v3"),
|
|
15
15
|
phase: z.enum(["initial", "final", "effective"]),
|
|
16
16
|
status: z.enum(["PASS", "REPAIRABLE", "BLOCKED"]),
|
|
17
17
|
repairEligible: z.boolean(),
|
|
18
18
|
repairAttempt: z.number().int().min(0).max(1),
|
|
19
19
|
collectionAttempted: z.boolean(),
|
|
20
|
+
fixtureResolutionAttempted: z.boolean(),
|
|
21
|
+
fixtureResolutionStatus: z.enum(["NOT_RUN", "PASS", "REPAIRABLE", "BLOCKED"]),
|
|
22
|
+
fixtureResolutionExitCode: z.number().int().nullable(),
|
|
23
|
+
repairPaths: z.array(z.string()),
|
|
20
24
|
mappedScripts: z.array(z.string()).min(1),
|
|
21
25
|
existingMappedScripts: z.array(z.string()),
|
|
22
26
|
missingMappedScripts: z.array(z.string()),
|
|
@@ -28,17 +32,37 @@ export const backendPytestCollectionFactsSchema = z.object({
|
|
|
28
32
|
findings: z.array(backendPytestCollectionFindingSchema),
|
|
29
33
|
stdoutExcerpt: z.string(),
|
|
30
34
|
stderrExcerpt: z.string(),
|
|
35
|
+
fixtureStdoutExcerpt: z.string(),
|
|
36
|
+
fixtureStderrExcerpt: z.string(),
|
|
31
37
|
collectionSource: z.enum(["initial", "final"]).optional(),
|
|
32
38
|
}).strict().superRefine((facts, context) => {
|
|
33
39
|
if (facts.status === "PASS") {
|
|
34
|
-
if (!facts.collectionAttempted || facts.pytestExitCode !== 0 || facts.missingMappedScripts.length > 0 || facts.assetFiles.length === 0) {
|
|
35
|
-
context.addIssue({ code: z.ZodIssueCode.custom, message: "backend pytest collection PASS requires
|
|
40
|
+
if (!facts.collectionAttempted || facts.pytestExitCode !== 0 || !facts.fixtureResolutionAttempted || facts.fixtureResolutionStatus !== "PASS" || facts.fixtureResolutionExitCode !== 0 || facts.missingMappedScripts.length > 0 || facts.assetFiles.length === 0) {
|
|
41
|
+
context.addIssue({ code: z.ZodIssueCode.custom, message: "backend pytest collection PASS requires collection and fixture-resolution PASS, complete mapped scripts and bound assets" });
|
|
36
42
|
}
|
|
37
43
|
}
|
|
38
44
|
if (!facts.collectionAttempted && facts.pytestExitCode !== null) {
|
|
39
45
|
context.addIssue({ code: z.ZodIssueCode.custom, message: "backend pytest collection without an attempt cannot have an exit code" });
|
|
40
46
|
}
|
|
47
|
+
if (!facts.fixtureResolutionAttempted && facts.fixtureResolutionExitCode !== null) {
|
|
48
|
+
context.addIssue({ code: z.ZodIssueCode.custom, message: "backend pytest fixture resolution without an attempt cannot have an exit code" });
|
|
49
|
+
}
|
|
41
50
|
});
|
|
51
|
+
export const backendTestExecutionReadinessSchema = z.object({
|
|
52
|
+
schemaId: z.literal("backend-test-execution-readiness-v1"),
|
|
53
|
+
status: z.enum(["PASS", "PARTIAL", "BLOCKED"]),
|
|
54
|
+
collectionStatus: z.literal("PASS"),
|
|
55
|
+
fixtureResolutionStatus: z.literal("PASS"),
|
|
56
|
+
scenarioParamStatus: z.enum(["PASS", "PARTIAL", "FAIL", "UNAVAILABLE"]),
|
|
57
|
+
repairAttempts: z.object({
|
|
58
|
+
collection: z.number().int().min(0).max(1),
|
|
59
|
+
scenarioParam: z.number().int().min(0).max(1),
|
|
60
|
+
}).strict(),
|
|
61
|
+
mappedScripts: z.array(z.string()).min(1),
|
|
62
|
+
collectedItemIds: z.array(z.string()),
|
|
63
|
+
fixtureIssues: z.array(z.string()),
|
|
64
|
+
assetHashes: z.record(z.string(), z.string().regex(SHA256)),
|
|
65
|
+
}).strict();
|
|
42
66
|
function repoRef(workspaceRoot, absolutePath) {
|
|
43
67
|
return path.relative(workspaceRoot, absolutePath).replaceAll(path.sep, "/");
|
|
44
68
|
}
|
|
@@ -106,6 +130,28 @@ function collectionItems(stdout) {
|
|
|
106
130
|
return /^testcase\//.test(line.replaceAll("\\", "/"));
|
|
107
131
|
}).map((line) => line.replaceAll("\\", "/")))];
|
|
108
132
|
}
|
|
133
|
+
function testcasePythonPaths(output) {
|
|
134
|
+
return [...new Set((output.replaceAll("\\", "/").match(/testcase\/[A-Za-z0-9_./-]+\.py/gi) ?? []).map((item) => item.replace(/:\d+$/, "")))].sort();
|
|
135
|
+
}
|
|
136
|
+
function classifyFixtureResolutionFailure(output) {
|
|
137
|
+
const normalized = output.replaceAll("\\", "/");
|
|
138
|
+
const paths = testcasePythonPaths(normalized);
|
|
139
|
+
const generatedProviderPaths = paths.filter((item) => /testcase\/(?:helpers|factories)\//.test(item));
|
|
140
|
+
if (/fixture ['"][^'"]+['"] not found/i.test(normalized) && generatedProviderPaths.length > 0) {
|
|
141
|
+
return {
|
|
142
|
+
status: "REPAIRABLE",
|
|
143
|
+
kind: "missing-generated-fixture",
|
|
144
|
+
detail: "generated pytest fixture dependency or plugin registration is incomplete",
|
|
145
|
+
repairPaths: generatedProviderPaths,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
status: "BLOCKED",
|
|
150
|
+
kind: "unresolved-fixture-dependency",
|
|
151
|
+
detail: "fixture resolution failed without a safely attributable generated provider",
|
|
152
|
+
repairPaths: paths,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
109
155
|
function classifyCollectionFailure(output) {
|
|
110
156
|
const normalized = output.replaceAll("\\", "/");
|
|
111
157
|
const blockedPatterns = [
|
|
@@ -134,14 +180,19 @@ function classifyCollectionFailure(output) {
|
|
|
134
180
|
}
|
|
135
181
|
export function assessBackendPytestCollection(input) {
|
|
136
182
|
const items = collectionItems(input.stdout);
|
|
137
|
-
|
|
183
|
+
const fixtureResolution = input.fixtureResolution ?? { exitCode: 0, stdout: "fixture resolution assumed by direct assessor", stderr: "" };
|
|
184
|
+
if (input.exitCode === 0 && fixtureResolution.exitCode === 0) {
|
|
138
185
|
return backendPytestCollectionFactsSchema.parse({
|
|
139
|
-
schemaId: "backend-test-pytest-collection-
|
|
186
|
+
schemaId: "backend-test-pytest-collection-v3",
|
|
140
187
|
phase: input.phase,
|
|
141
188
|
status: "PASS",
|
|
142
189
|
repairEligible: false,
|
|
143
190
|
repairAttempt: input.phase === "final" ? 1 : 0,
|
|
144
191
|
collectionAttempted: true,
|
|
192
|
+
fixtureResolutionAttempted: true,
|
|
193
|
+
fixtureResolutionStatus: "PASS",
|
|
194
|
+
fixtureResolutionExitCode: 0,
|
|
195
|
+
repairPaths: [],
|
|
145
196
|
mappedScripts: input.inventory.mappedScripts,
|
|
146
197
|
existingMappedScripts: input.inventory.mappedScripts,
|
|
147
198
|
missingMappedScripts: [],
|
|
@@ -153,16 +204,55 @@ export function assessBackendPytestCollection(input) {
|
|
|
153
204
|
findings: [],
|
|
154
205
|
stdoutExcerpt: bounded(input.stdout),
|
|
155
206
|
stderrExcerpt: bounded(input.stderr),
|
|
207
|
+
fixtureStdoutExcerpt: bounded(fixtureResolution.stdout),
|
|
208
|
+
fixtureStderrExcerpt: bounded(fixtureResolution.stderr),
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
if (input.exitCode === 0) {
|
|
212
|
+
const classification = classifyFixtureResolutionFailure(`${fixtureResolution.stdout}\n${fixtureResolution.stderr}`);
|
|
213
|
+
return backendPytestCollectionFactsSchema.parse({
|
|
214
|
+
schemaId: "backend-test-pytest-collection-v3",
|
|
215
|
+
phase: input.phase,
|
|
216
|
+
status: classification.status,
|
|
217
|
+
repairEligible: input.phase === "initial" && classification.status === "REPAIRABLE",
|
|
218
|
+
repairAttempt: input.phase === "final" ? 1 : 0,
|
|
219
|
+
collectionAttempted: true,
|
|
220
|
+
fixtureResolutionAttempted: true,
|
|
221
|
+
fixtureResolutionStatus: classification.status,
|
|
222
|
+
fixtureResolutionExitCode: fixtureResolution.exitCode,
|
|
223
|
+
repairPaths: classification.repairPaths,
|
|
224
|
+
mappedScripts: input.inventory.mappedScripts,
|
|
225
|
+
existingMappedScripts: input.inventory.mappedScripts,
|
|
226
|
+
missingMappedScripts: [],
|
|
227
|
+
assetFiles: input.inventory.assetFiles,
|
|
228
|
+
inputHashes: input.inventory.inputHashes,
|
|
229
|
+
pytestExitCode: input.exitCode,
|
|
230
|
+
collectedItemCount: items.length,
|
|
231
|
+
collectedItemIds: items,
|
|
232
|
+
findings: [{
|
|
233
|
+
kind: classification.kind,
|
|
234
|
+
classification: "test-asset-defect",
|
|
235
|
+
repairability: classification.status === "REPAIRABLE" ? "repairable" : "blocked",
|
|
236
|
+
detail: classification.detail,
|
|
237
|
+
}],
|
|
238
|
+
stdoutExcerpt: bounded(input.stdout),
|
|
239
|
+
stderrExcerpt: bounded(input.stderr),
|
|
240
|
+
fixtureStdoutExcerpt: bounded(fixtureResolution.stdout),
|
|
241
|
+
fixtureStderrExcerpt: bounded(fixtureResolution.stderr),
|
|
156
242
|
});
|
|
157
243
|
}
|
|
158
244
|
const classification = classifyCollectionFailure(`${input.stdout}\n${input.stderr}`);
|
|
159
245
|
return backendPytestCollectionFactsSchema.parse({
|
|
160
|
-
schemaId: "backend-test-pytest-collection-
|
|
246
|
+
schemaId: "backend-test-pytest-collection-v3",
|
|
161
247
|
phase: input.phase,
|
|
162
248
|
status: classification.status,
|
|
163
249
|
repairEligible: input.phase === "initial" && classification.status === "REPAIRABLE",
|
|
164
250
|
repairAttempt: input.phase === "final" ? 1 : 0,
|
|
165
251
|
collectionAttempted: true,
|
|
252
|
+
fixtureResolutionAttempted: false,
|
|
253
|
+
fixtureResolutionStatus: "NOT_RUN",
|
|
254
|
+
fixtureResolutionExitCode: null,
|
|
255
|
+
repairPaths: testcasePythonPaths(`${input.stdout}\n${input.stderr}`),
|
|
166
256
|
mappedScripts: input.inventory.mappedScripts,
|
|
167
257
|
existingMappedScripts: input.inventory.mappedScripts,
|
|
168
258
|
missingMappedScripts: [],
|
|
@@ -179,16 +269,22 @@ export function assessBackendPytestCollection(input) {
|
|
|
179
269
|
}],
|
|
180
270
|
stdoutExcerpt: bounded(input.stdout),
|
|
181
271
|
stderrExcerpt: bounded(input.stderr),
|
|
272
|
+
fixtureStdoutExcerpt: "",
|
|
273
|
+
fixtureStderrExcerpt: "",
|
|
182
274
|
});
|
|
183
275
|
}
|
|
184
276
|
export function assessMissingBackendPytestScripts(input) {
|
|
185
277
|
return backendPytestCollectionFactsSchema.parse({
|
|
186
|
-
schemaId: "backend-test-pytest-collection-
|
|
278
|
+
schemaId: "backend-test-pytest-collection-v3",
|
|
187
279
|
phase: "initial",
|
|
188
280
|
status: "REPAIRABLE",
|
|
189
281
|
repairEligible: true,
|
|
190
282
|
repairAttempt: 0,
|
|
191
283
|
collectionAttempted: false,
|
|
284
|
+
fixtureResolutionAttempted: false,
|
|
285
|
+
fixtureResolutionStatus: "NOT_RUN",
|
|
286
|
+
fixtureResolutionExitCode: null,
|
|
287
|
+
repairPaths: [...input.missingMappedScripts],
|
|
192
288
|
mappedScripts: [...input.mappedScripts],
|
|
193
289
|
existingMappedScripts: [...input.existingMappedScripts],
|
|
194
290
|
missingMappedScripts: [...input.missingMappedScripts],
|
|
@@ -205,6 +301,8 @@ export function assessMissingBackendPytestScripts(input) {
|
|
|
205
301
|
})),
|
|
206
302
|
stdoutExcerpt: "",
|
|
207
303
|
stderrExcerpt: "",
|
|
304
|
+
fixtureStdoutExcerpt: "",
|
|
305
|
+
fixtureStderrExcerpt: "",
|
|
208
306
|
});
|
|
209
307
|
}
|
|
210
308
|
export function renderBackendPytestCollectionReport(facts) {
|
|
@@ -219,6 +317,10 @@ export function renderBackendPytestCollectionReport(facts) {
|
|
|
219
317
|
`- Repair attempt: ${facts.repairAttempt}`,
|
|
220
318
|
`- Collection attempted: ${facts.collectionAttempted}`,
|
|
221
319
|
`- Pytest exit code: ${facts.pytestExitCode ?? "not-run"}`,
|
|
320
|
+
`- Fixture resolution attempted: ${facts.fixtureResolutionAttempted}`,
|
|
321
|
+
`- Fixture resolution status: ${facts.fixtureResolutionStatus}`,
|
|
322
|
+
`- Fixture resolution exit code: ${facts.fixtureResolutionExitCode ?? "not-run"}`,
|
|
323
|
+
`- Repair paths: ${facts.repairPaths.join(", ") || "none"}`,
|
|
222
324
|
`- Mapped scripts: ${facts.mappedScripts.length}`,
|
|
223
325
|
`- Existing mapped scripts: ${facts.existingMappedScripts.length}`,
|
|
224
326
|
`- Missing mapped scripts: ${facts.missingMappedScripts.length}`,
|
|
@@ -245,6 +347,18 @@ export function renderBackendPytestCollectionReport(facts) {
|
|
|
245
347
|
facts.stderrExcerpt,
|
|
246
348
|
"```",
|
|
247
349
|
"",
|
|
350
|
+
"## Fixture Resolution stdout",
|
|
351
|
+
"",
|
|
352
|
+
"```text",
|
|
353
|
+
facts.fixtureStdoutExcerpt,
|
|
354
|
+
"```",
|
|
355
|
+
"",
|
|
356
|
+
"## Fixture Resolution stderr",
|
|
357
|
+
"",
|
|
358
|
+
"```text",
|
|
359
|
+
facts.fixtureStderrExcerpt,
|
|
360
|
+
"```",
|
|
361
|
+
"",
|
|
248
362
|
].join("\n");
|
|
249
363
|
}
|
|
250
364
|
export async function writeBackendPytestCollectionArtifacts(input) {
|
|
@@ -289,6 +403,47 @@ function assertSameInventory(expected, actual) {
|
|
|
289
403
|
throw new Error(`backend pytest collection hash drift: ${file}`);
|
|
290
404
|
}
|
|
291
405
|
}
|
|
406
|
+
export async function materializeBackendTestExecutionReadiness(input) {
|
|
407
|
+
if (input.effective.phase !== "effective" || input.effective.status !== "PASS" || input.effective.fixtureResolutionStatus !== "PASS") {
|
|
408
|
+
throw new Error("backend-test execution readiness requires effective collection and fixture-resolution PASS");
|
|
409
|
+
}
|
|
410
|
+
const current = await buildBackendPytestAssetInventory(input.workspaceRoot, input.effective.mappedScripts);
|
|
411
|
+
assertSameInventory(input.effective, current);
|
|
412
|
+
const status = input.scenarioParamStatus === "FAIL"
|
|
413
|
+
? "BLOCKED"
|
|
414
|
+
: input.scenarioParamStatus === "PASS"
|
|
415
|
+
? "PASS"
|
|
416
|
+
: "PARTIAL";
|
|
417
|
+
const readiness = backendTestExecutionReadinessSchema.parse({
|
|
418
|
+
schemaId: "backend-test-execution-readiness-v1",
|
|
419
|
+
status,
|
|
420
|
+
collectionStatus: "PASS",
|
|
421
|
+
fixtureResolutionStatus: "PASS",
|
|
422
|
+
scenarioParamStatus: input.scenarioParamStatus,
|
|
423
|
+
repairAttempts: {
|
|
424
|
+
collection: input.effective.repairAttempt,
|
|
425
|
+
scenarioParam: input.scenarioParamRepairAttempt,
|
|
426
|
+
},
|
|
427
|
+
mappedScripts: input.effective.mappedScripts,
|
|
428
|
+
collectedItemIds: input.effective.collectedItemIds,
|
|
429
|
+
fixtureIssues: input.effective.findings.filter((item) => /fixture/i.test(item.kind)).map((item) => item.detail),
|
|
430
|
+
assetHashes: input.effective.inputHashes,
|
|
431
|
+
});
|
|
432
|
+
const contractsDir = path.join(input.runDir, "contracts");
|
|
433
|
+
await mkdir(contractsDir, { recursive: true });
|
|
434
|
+
await writeFile(path.join(contractsDir, "backend-test-execution-readiness.json"), `${JSON.stringify(readiness, null, 2)}\n`, "utf8");
|
|
435
|
+
return readiness;
|
|
436
|
+
}
|
|
437
|
+
export async function readBackendTestExecutionReadiness(filePath) {
|
|
438
|
+
return backendTestExecutionReadinessSchema.parse(JSON.parse(await readFile(filePath, "utf8")));
|
|
439
|
+
}
|
|
440
|
+
export async function assertBackendTestExecutionReadinessFresh(workspaceRoot, readiness) {
|
|
441
|
+
if (!["PASS", "PARTIAL"].includes(readiness.status)) {
|
|
442
|
+
throw new Error(`backend pytest execution readiness is ${readiness.status}`);
|
|
443
|
+
}
|
|
444
|
+
const current = await buildBackendPytestAssetInventory(workspaceRoot, readiness.mappedScripts);
|
|
445
|
+
assertSameInventory({ mappedScripts: readiness.mappedScripts, assetFiles: Object.keys(readiness.assetHashes), inputHashes: readiness.assetHashes }, current);
|
|
446
|
+
}
|
|
292
447
|
export async function assertBackendPytestCollectionFresh(workspaceRoot, effective) {
|
|
293
448
|
if (effective.phase !== "effective" || effective.status !== "PASS") {
|
|
294
449
|
throw new Error("backend pytest execution requires effective collection PASS facts");
|