@tea-agent/loop-agent 0.24.5 → 0.24.6

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 CHANGED
@@ -2,6 +2,21 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ### 新增
6
+
7
+ - Observe / Inspect 节点检查器在「节点输出」左侧新增「节点输入」页签:只读投影冻结 `run.json` 顶层节点定义(任务正文、依赖、执行器摘要、边界)与可选 assembled prompt 指纹;完整 assembled prompt 与动态展开子节点仍不在本轮范围
8
+
9
+ ## [0.24.6] - 2026-07-29
10
+
11
+ ### 重点更新
12
+
13
+ - 修复后端测试 Markdown-first 成功路径未投影 `backend-test-result` 的问题,Ready Planner 可再次放行 FE-TEST
14
+
15
+ ### 修复
16
+
17
+ - Markdown-first `markdown-execute-html` 在解析原生 pytest-html 后立即物化 `contracts/backend-test-result.json`(Result v1),避免 styled HTML 覆盖后丢失 JSON island
18
+ - `node-execution` 对 `markdown-execute-html` 绑定 `structuredArtifact*`,Outcome 适配器可投影 `kind: backend-test-result`
19
+
5
20
  ## [0.24.5] - 2026-07-28
6
21
 
7
22
  ### 重点更新
@@ -17,7 +17,7 @@ import { formatFrontendReviewContextStdout, runFrontendReviewContextGate, } from
17
17
  import { materializeFrontendLintAssessment, materializeFrontendLintBaseline, } from "../workflows/dag/frontend-lint-baseline.js";
18
18
  import { formatTraceabilityGateStdout, materializeBackendTestCaseManifest, runBackendTestTraceabilityGate, } from "../workflows/dag/backend-test-case-manifest.js";
19
19
  import { materializeBackendTestExecutionContract } from "../workflows/dag/backend-test-execution-contract.js";
20
- import { materializeBackendTestResultFromRunDir, parsePytestHtmlReport } from "../workflows/dag/backend-test-result-contract.js";
20
+ import { materializeBackendTestResultFromPytestHtml, materializeBackendTestResultFromRunDir, parsePytestHtmlReport } from "../workflows/dag/backend-test-result-contract.js";
21
21
  import { collectBackendTestHumanCaseCatalog, collectBackendTestMappedPytestScripts, hasBlockingBackendMarkdownSafetyFindings, inspectBackendTestEnvironment, requiredBackendMarkdownCaseAcIds, renderBackendTestFacts, renderBackendTestHtml, redactBackendTestOutput, validateBackendMarkdownCases, validateBackendMarkdownTraceability, writeRunReport, } from "../workflows/dag/backend-test-markdown-workflow.js";
22
22
  import { buildBackendTestCanonicalResultFromInitialShellSnippet, materializeBackendTestClassification, } from "../workflows/dag/backend-test-classification-contract.js";
23
23
  import { backendTestSemanticReviewSchema, materializeBackendTestSemanticReview, } from "../workflows/dag/backend-test-semantic-review-contract.js";
@@ -414,6 +414,14 @@ async function executeBackendTestPipeline(input, meta) {
414
414
  if (![0, 1].includes(pytestExitCode))
415
415
  throw new Error(`pytest did not complete with a reportable exit code: ${pytestExitCode}`);
416
416
  const parsed = parsePytestHtmlReport(pytestHtmlContent);
417
+ // Bind Result v1 from the native pytest-html report BEFORE overwriting with the
418
+ // styled renderer (which drops the data-jsonblob island).
419
+ const resultArtifact = await materializeBackendTestResultFromPytestHtml({
420
+ runDir: meta.runDir,
421
+ htmlRelativePath: "reports/backend-test.html",
422
+ htmlContent: pytestHtmlContent,
423
+ pytestExitCode,
424
+ });
417
425
  const cases = await collectBackendTestHumanCaseCatalog(input.cwd);
418
426
  const caseValidationSummary = await readRequiredRunReport(reportsDir, "backend-md-case-validation.md");
419
427
  const traceabilitySummary = await readRequiredRunReport(reportsDir, "backend-test-traceability.md");
@@ -430,7 +438,7 @@ async function executeBackendTestPipeline(input, meta) {
430
438
  const markdownPath = await writeRunReport(meta.runDir, "backend-test.md", facts);
431
439
  const factsPath = await writeRunReport(meta.runDir, "backend-test-facts.md", facts);
432
440
  const sanitizedOutputs = results.map((result) => redactBackendTestOutput(result.stdout));
433
- outputs.push(...sanitizedOutputs, `html=${htmlPath}`, `markdown=${markdownPath}`, `facts=${factsPath}`, facts);
441
+ outputs.push(...sanitizedOutputs, `html=${htmlPath}`, `markdown=${markdownPath}`, `facts=${factsPath}`, `result=${resultArtifact.path}`, facts);
434
442
  }
435
443
  else if (pipeline === "contracts") {
436
444
  const wrapperPath = path.join(meta.runDir, "analyze-and-discover-backend-test-pi.json");
@@ -0,0 +1,90 @@
1
+ import { existsSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { isSafeObservabilityIdentifier } from "../observability/event-store.js";
4
+ export const DAG_RUN_LIFECYCLES = ["active", "completed", "paused"];
5
+ function isPathInside(root, target) {
6
+ const normalizedRoot = root.endsWith(path.sep) ? root : `${root}${path.sep}`;
7
+ const normalizedTarget = path.normalize(target);
8
+ return (normalizedTarget === root || normalizedTarget.startsWith(normalizedRoot));
9
+ }
10
+ function normalizeRelativeSegments(relativeSegments) {
11
+ if (!Array.isArray(relativeSegments) || relativeSegments.length === 0) {
12
+ return null;
13
+ }
14
+ const segments = [];
15
+ for (const segment of relativeSegments) {
16
+ if (typeof segment !== "string" || !segment)
17
+ return null;
18
+ if (segment === "." || segment === "..")
19
+ return null;
20
+ if (segment.includes("/") || segment.includes("\\") || segment.includes("\0")) {
21
+ return null;
22
+ }
23
+ segments.push(segment);
24
+ }
25
+ return segments;
26
+ }
27
+ /**
28
+ * Resolve a path under `.harness/dag-runs/{lifecycle}/{dagRunId}/...`.
29
+ * Fail closed on invalid ids, path escape, or the same runId appearing in
30
+ * multiple lifecycle directories.
31
+ */
32
+ export function resolveDagRunArtifact(repoRoot, dagRunId, relativeSegments) {
33
+ if (!isSafeObservabilityIdentifier(dagRunId)) {
34
+ return { ok: false, reason: "invalid-id" };
35
+ }
36
+ const segments = normalizeRelativeSegments(relativeSegments);
37
+ if (!segments) {
38
+ return { ok: false, reason: "unsafe-path" };
39
+ }
40
+ for (const segment of segments) {
41
+ if (segment !== "run.json" &&
42
+ segment !== "state.json" &&
43
+ !isSafeObservabilityIdentifier(segment) &&
44
+ !/^[a-zA-Z0-9._-]+$/.test(segment)) {
45
+ // Allow known artifact filenames under a validated node id prefix.
46
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(segment)) {
47
+ return { ok: false, reason: "unsafe-path" };
48
+ }
49
+ }
50
+ }
51
+ const dagRunsRoot = path.resolve(repoRoot, ".harness", "dag-runs");
52
+ const matches = [];
53
+ for (const lifecycle of DAG_RUN_LIFECYCLES) {
54
+ const runDir = path.join(dagRunsRoot, lifecycle, dagRunId);
55
+ const absolutePath = path.resolve(runDir, ...segments);
56
+ if (!isPathInside(dagRunsRoot, absolutePath))
57
+ continue;
58
+ const relative = path.relative(dagRunsRoot, absolutePath);
59
+ const parts = relative.split(path.sep).filter(Boolean);
60
+ if (parts[0] !== lifecycle || parts[1] !== dagRunId)
61
+ continue;
62
+ if (!existsSync(absolutePath))
63
+ continue;
64
+ matches.push({
65
+ lifecycle,
66
+ runDir,
67
+ absolutePath,
68
+ relativePath: path.join(lifecycle, dagRunId, ...segments),
69
+ });
70
+ }
71
+ if (matches.length === 0) {
72
+ return { ok: false, reason: "not-found" };
73
+ }
74
+ if (matches.length > 1) {
75
+ return { ok: false, reason: "ambiguous" };
76
+ }
77
+ return { ok: true, result: matches[0] };
78
+ }
79
+ export function resolveDagRunJson(repoRoot, dagRunId) {
80
+ return resolveDagRunArtifact(repoRoot, dagRunId, ["run.json"]);
81
+ }
82
+ export function resolveDagNodePromptRedacted(repoRoot, dagRunId, nodeId) {
83
+ if (!isSafeObservabilityIdentifier(nodeId)) {
84
+ return { ok: false, reason: "invalid-id" };
85
+ }
86
+ return resolveDagRunArtifact(repoRoot, dagRunId, [
87
+ nodeId,
88
+ "prompt.redacted.md",
89
+ ]);
90
+ }
@@ -0,0 +1,444 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { redactSecrets, truncateUtf8Preview, } from "../../shared/preview.js";
4
+ import { resolveDagNodeSkills } from "../../workflows/dag/skills.js";
5
+ import { resolveDagNodePromptRedacted, resolveDagRunJson, } from "./dag-run-artifacts.js";
6
+ export const NODE_INPUT_SCHEMA_VERSION = 1;
7
+ export const NODE_INPUT_PREVIEW_MAX_BYTES = 64 * 1024;
8
+ const SHELL_GATE_KEYS = [
9
+ "verdictGate",
10
+ "projectGovernanceGate",
11
+ "requirementCoverageGate",
12
+ "jsonArtifactGate",
13
+ "frontendPrewriteGate",
14
+ "frontendLintBaseline",
15
+ "frontendVerificationBundle",
16
+ "frontendReviewContext",
17
+ "backendTestPipeline",
18
+ "verifyEvidence",
19
+ "repairArtifactGate",
20
+ ];
21
+ const PROMPT_FINGERPRINT_RE = /<prompt\s+sha256=([a-f0-9]{12})\s+len=(\d+)\s*>/i;
22
+ function unavailableBody(dagRunId, nodeId, reason, warnings = []) {
23
+ return {
24
+ schemaVersion: NODE_INPUT_SCHEMA_VERSION,
25
+ dagRunId,
26
+ nodeId,
27
+ available: false,
28
+ availabilityReason: reason,
29
+ source: null,
30
+ summary: null,
31
+ dependency: null,
32
+ prompt: null,
33
+ executorInput: null,
34
+ boundaries: null,
35
+ promptFingerprint: null,
36
+ warnings,
37
+ };
38
+ }
39
+ function redactString(value) {
40
+ if (typeof value !== "string")
41
+ return null;
42
+ return redactSecrets(value);
43
+ }
44
+ function redactStringList(values) {
45
+ if (!Array.isArray(values))
46
+ return [];
47
+ return values
48
+ .filter((item) => typeof item === "string")
49
+ .map((item) => redactSecrets(item));
50
+ }
51
+ function truncatedField(text, maxBytes = NODE_INPUT_PREVIEW_MAX_BYTES) {
52
+ const redacted = redactSecrets(text);
53
+ const truncated = truncateUtf8Preview(redacted, maxBytes);
54
+ return {
55
+ text: truncated,
56
+ truncated: truncated !== redacted,
57
+ maxBytes,
58
+ };
59
+ }
60
+ export function parsePromptRedactedFingerprint(markdown) {
61
+ const match = PROMPT_FINGERPRINT_RE.exec(markdown);
62
+ if (!match) {
63
+ return { ok: false, error: "fingerprint-format-mismatch" };
64
+ }
65
+ const sha256Prefix = match[1];
66
+ const lengthChars = Number(match[2]);
67
+ if (!Number.isSafeInteger(lengthChars) || lengthChars < 0) {
68
+ return { ok: false, error: "fingerprint-length-invalid" };
69
+ }
70
+ return { ok: true, sha256Prefix, lengthChars };
71
+ }
72
+ function isLikelyDynamicExpandedNode(nodeId, tasks) {
73
+ for (const parent of tasks) {
74
+ const parentId = typeof parent.id === "string" ? parent.id : "";
75
+ const expansion = parent.dynamicExpansion;
76
+ if (expansion && typeof expansion === "object") {
77
+ const childIdPrefix = typeof expansion.childIdPrefix ===
78
+ "string"
79
+ ? expansion.childIdPrefix
80
+ : "";
81
+ if (childIdPrefix) {
82
+ const prefix = `${childIdPrefix}-`;
83
+ if (nodeId.startsWith(prefix)) {
84
+ const suffix = nodeId.slice(prefix.length);
85
+ if (/^\d{4}$/.test(suffix) && Number(suffix) >= 1) {
86
+ return true;
87
+ }
88
+ }
89
+ }
90
+ }
91
+ const loop = parent.dynamicLoopUntil;
92
+ if (loop && typeof loop === "object" && parentId) {
93
+ const prefix = `${parentId}-r`;
94
+ if (nodeId.startsWith(prefix)) {
95
+ const rest = nodeId.slice(prefix.length);
96
+ const dash = rest.indexOf("-");
97
+ if (dash > 0) {
98
+ const iterationText = rest.slice(0, dash);
99
+ if (/^\d+$/.test(iterationText) && Number(iterationText) >= 1) {
100
+ return true;
101
+ }
102
+ }
103
+ }
104
+ }
105
+ }
106
+ return false;
107
+ }
108
+ function shellGateNames(shell) {
109
+ const gates = [];
110
+ for (const key of SHELL_GATE_KEYS) {
111
+ if (shell[key] != null)
112
+ gates.push(key);
113
+ }
114
+ return gates;
115
+ }
116
+ function resolveModelHint(spec, task) {
117
+ const complexity = typeof task.complexity === "string" ? task.complexity : null;
118
+ const executorModels = spec.executorModels;
119
+ if (complexity &&
120
+ executorModels &&
121
+ typeof executorModels === "object" &&
122
+ !Array.isArray(executorModels)) {
123
+ const pi = executorModels.pi;
124
+ if (pi && typeof pi === "object" && !Array.isArray(pi)) {
125
+ const model = pi[complexity];
126
+ if (typeof model === "string" && model.trim()) {
127
+ return redactSecrets(model);
128
+ }
129
+ }
130
+ }
131
+ const defaults = spec.defaults;
132
+ if (defaults && typeof defaults === "object" && !Array.isArray(defaults)) {
133
+ const model = defaults.model;
134
+ if (typeof model === "string" && model.trim()) {
135
+ return redactSecrets(model);
136
+ }
137
+ }
138
+ return null;
139
+ }
140
+ function projectTask(dagRunId, nodeId, lifecycle, spec, task, fingerprint, warnings) {
141
+ const defaults = spec.defaults && typeof spec.defaults === "object"
142
+ ? spec.defaults
143
+ : undefined;
144
+ const executorDeclared = typeof task.executor === "string";
145
+ const executor = executorDeclared
146
+ ? String(task.executor)
147
+ : "pi";
148
+ const executorSource = executorDeclared
149
+ ? "task"
150
+ : "schema-default";
151
+ const writePolicyDeclared = typeof task.writePolicy === "string";
152
+ let writePolicy = null;
153
+ let writePolicySource = null;
154
+ if (writePolicyDeclared) {
155
+ writePolicy = String(task.writePolicy);
156
+ writePolicySource = "task";
157
+ }
158
+ else if (typeof defaults?.writePolicy === "string") {
159
+ writePolicy = defaults.writePolicy;
160
+ writePolicySource = "defaults";
161
+ }
162
+ const skillSpec = {
163
+ defaults,
164
+ skillsByRole: spec.skillsByRole && typeof spec.skillsByRole === "object"
165
+ ? spec.skillsByRole
166
+ : undefined,
167
+ };
168
+ const skills = resolveDagNodeSkills(skillSpec, {
169
+ role: typeof task.role === "string" ? task.role : undefined,
170
+ skills: Array.isArray(task.skills)
171
+ ? task.skills.filter((s) => typeof s === "string")
172
+ : undefined,
173
+ }).map((skill) => redactSecrets(skill));
174
+ const promptRaw = typeof task.subtask_prompt === "string" ? task.subtask_prompt : "";
175
+ const promptBody = truncatedField(promptRaw);
176
+ const promptSourceRaw = task.subtask_prompt_source;
177
+ let promptSource = null;
178
+ if (promptSourceRaw && typeof promptSourceRaw === "object") {
179
+ const src = promptSourceRaw;
180
+ if (src.type === "markdown" && typeof src.path === "string") {
181
+ promptSource = {
182
+ type: "markdown",
183
+ path: redactSecrets(src.path),
184
+ sha256Prefix: typeof src.sha256 === "string" && src.sha256.length >= 12
185
+ ? src.sha256.slice(0, 12)
186
+ : null,
187
+ };
188
+ }
189
+ }
190
+ const shellRaw = task.shell && typeof task.shell === "object" && !Array.isArray(task.shell)
191
+ ? task.shell
192
+ : null;
193
+ const staticRaw = task.static && typeof task.static === "object" && !Array.isArray(task.static)
194
+ ? task.static
195
+ : null;
196
+ let kind = "unknown";
197
+ if (executor === "shell" || shellRaw)
198
+ kind = "shell";
199
+ else if (executor === "static" || staticRaw)
200
+ kind = "static";
201
+ else if (executor === "pi")
202
+ kind = "pi";
203
+ const shell = shellRaw
204
+ ? {
205
+ commands: redactStringList(shellRaw.commands),
206
+ preset: typeof shellRaw.preset === "string"
207
+ ? redactSecrets(shellRaw.preset)
208
+ : null,
209
+ cwd: typeof shellRaw.cwd === "string"
210
+ ? redactSecrets(shellRaw.cwd)
211
+ : null,
212
+ timeoutMs: typeof shellRaw.timeoutMs === "number" &&
213
+ Number.isFinite(shellRaw.timeoutMs)
214
+ ? shellRaw.timeoutMs
215
+ : null,
216
+ nonZeroExitPolicy: typeof shellRaw.nonZeroExitPolicy === "string"
217
+ ? shellRaw.nonZeroExitPolicy
218
+ : null,
219
+ gates: shellGateNames(shellRaw),
220
+ }
221
+ : null;
222
+ const staticPreview = staticRaw && typeof staticRaw.resultMarkdown === "string"
223
+ ? truncatedField(staticRaw.resultMarkdown)
224
+ : null;
225
+ const staticBlock = staticRaw
226
+ ? {
227
+ status: typeof staticRaw.status === "string" ? staticRaw.status : null,
228
+ resultPreview: staticPreview?.text ?? "",
229
+ truncated: staticPreview?.truncated ?? false,
230
+ maxBytes: staticPreview?.maxBytes ?? NODE_INPUT_PREVIEW_MAX_BYTES,
231
+ }
232
+ : null;
233
+ return {
234
+ schemaVersion: NODE_INPUT_SCHEMA_VERSION,
235
+ dagRunId,
236
+ nodeId,
237
+ available: true,
238
+ availabilityReason: "available",
239
+ source: {
240
+ kind: "frozen-run-spec",
241
+ artifact: "run.json#tasks",
242
+ lifecycle,
243
+ },
244
+ summary: {
245
+ executor: redactString(executor),
246
+ executorSource,
247
+ role: typeof task.role === "string" ? redactSecrets(task.role) : null,
248
+ complexity: typeof task.complexity === "string"
249
+ ? task.complexity
250
+ : null,
251
+ writePolicy: writePolicy ? redactSecrets(writePolicy) : null,
252
+ writePolicySource,
253
+ toolProfile: typeof task.toolProfile === "string"
254
+ ? redactSecrets(task.toolProfile)
255
+ : null,
256
+ },
257
+ dependency: {
258
+ dependsOn: redactStringList(task.depends_on),
259
+ dependsPolicy: typeof task.dependsPolicy === "string" ? task.dependsPolicy : null,
260
+ failureAwareDependsOn: redactStringList(task.failureAwareDependsOn),
261
+ runIf: typeof task.runIf === "string" ? redactSecrets(task.runIf) : null,
262
+ },
263
+ prompt: {
264
+ format: "markdown",
265
+ text: promptBody.text,
266
+ truncated: promptBody.truncated,
267
+ maxBytes: promptBody.maxBytes,
268
+ source: promptSource,
269
+ },
270
+ executorInput: {
271
+ kind,
272
+ skills,
273
+ modelHint: resolveModelHint(spec, task),
274
+ shell,
275
+ static: staticBlock,
276
+ },
277
+ boundaries: {
278
+ allowedPaths: redactStringList(task.allowedPaths),
279
+ forbiddenPaths: redactStringList(task.forbiddenPaths),
280
+ writeSet: redactStringList(task.writeSet),
281
+ outputContract: typeof task.outputContract === "string"
282
+ ? truncatedField(task.outputContract).text
283
+ : null,
284
+ outputMode: typeof task.outputMode === "string" ? task.outputMode : null,
285
+ firstProtocolLine: typeof task.firstProtocolLine === "string"
286
+ ? redactSecrets(task.firstProtocolLine)
287
+ : null,
288
+ },
289
+ promptFingerprint: fingerprint,
290
+ warnings,
291
+ };
292
+ }
293
+ async function loadPromptFingerprint(repoRoot, dagRunId, nodeId, warnings) {
294
+ const resolved = resolveDagNodePromptRedacted(repoRoot, dagRunId, nodeId);
295
+ if (!resolved.ok) {
296
+ if (resolved.reason === "ambiguous") {
297
+ warnings.push("Ambiguous prompt.redacted.md lifecycle copies.");
298
+ }
299
+ return {
300
+ exists: false,
301
+ sha256Prefix: null,
302
+ lengthChars: null,
303
+ artifact: null,
304
+ };
305
+ }
306
+ const artifact = path.posix.join(nodeId, "prompt.redacted.md");
307
+ try {
308
+ const markdown = await readFile(resolved.result.absolutePath, "utf-8");
309
+ const parsed = parsePromptRedactedFingerprint(markdown);
310
+ if (!parsed.ok) {
311
+ warnings.push(`prompt.redacted.md parse failed: ${parsed.error}`);
312
+ return {
313
+ exists: true,
314
+ sha256Prefix: null,
315
+ lengthChars: null,
316
+ artifact,
317
+ parseError: parsed.error,
318
+ };
319
+ }
320
+ return {
321
+ exists: true,
322
+ sha256Prefix: parsed.sha256Prefix,
323
+ lengthChars: parsed.lengthChars,
324
+ artifact,
325
+ };
326
+ }
327
+ catch {
328
+ warnings.push("prompt.redacted.md could not be read.");
329
+ return {
330
+ exists: true,
331
+ sha256Prefix: null,
332
+ lengthChars: null,
333
+ artifact,
334
+ parseError: "read-failed",
335
+ };
336
+ }
337
+ }
338
+ /**
339
+ * Build schemaVersion 1 node-input projection for Observe / Inspect.
340
+ * Never returns the raw task object.
341
+ */
342
+ export async function buildDagNodeInput(repoRoot, dagRunId, nodeId) {
343
+ const runResolved = resolveDagRunJson(repoRoot, dagRunId);
344
+ if (!runResolved.ok) {
345
+ if (runResolved.reason === "invalid-id") {
346
+ return {
347
+ ok: false,
348
+ status: 400,
349
+ body: { error: "Invalid dag run or node identifier" },
350
+ };
351
+ }
352
+ if (runResolved.reason === "ambiguous") {
353
+ return {
354
+ ok: false,
355
+ status: 409,
356
+ body: { error: "Ambiguous dag run lifecycle" },
357
+ };
358
+ }
359
+ if (runResolved.reason === "not-found") {
360
+ return {
361
+ ok: false,
362
+ status: 404,
363
+ body: { error: "DAG run not found" },
364
+ };
365
+ }
366
+ return {
367
+ ok: false,
368
+ status: 400,
369
+ body: { error: "Invalid or unsafe path" },
370
+ };
371
+ }
372
+ let raw;
373
+ try {
374
+ raw = JSON.parse(await readFile(runResolved.result.absolutePath, "utf-8"));
375
+ }
376
+ catch {
377
+ return {
378
+ ok: false,
379
+ status: 422,
380
+ body: { error: "run.json is unreadable or invalid JSON" },
381
+ };
382
+ }
383
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
384
+ return {
385
+ ok: false,
386
+ status: 422,
387
+ body: { error: "run.json is not a DAG spec object" },
388
+ };
389
+ }
390
+ const spec = raw;
391
+ const tasks = Array.isArray(spec.tasks) ? spec.tasks : null;
392
+ if (!tasks) {
393
+ return {
394
+ ok: true,
395
+ status: 200,
396
+ body: unavailableBody(dagRunId, nodeId, "run-spec-unavailable", [
397
+ "run.json has no tasks[] array.",
398
+ ]),
399
+ };
400
+ }
401
+ const taskRecords = tasks.filter((item) => Boolean(item) && typeof item === "object" && !Array.isArray(item));
402
+ const task = taskRecords.find((item) => typeof item.id === "string" && item.id === nodeId);
403
+ if (!task) {
404
+ if (isLikelyDynamicExpandedNode(nodeId, taskRecords)) {
405
+ return {
406
+ ok: true,
407
+ status: 200,
408
+ body: unavailableBody(dagRunId, nodeId, "unsupported-dynamic-node", [
409
+ "Runtime-expanded node input is not projected in schemaVersion 1.",
410
+ ]),
411
+ };
412
+ }
413
+ return {
414
+ ok: true,
415
+ status: 200,
416
+ body: unavailableBody(dagRunId, nodeId, "task-not-found"),
417
+ };
418
+ }
419
+ const warnings = [];
420
+ const fingerprint = await loadPromptFingerprint(repoRoot, dagRunId, nodeId, warnings);
421
+ // Reject unknown nested leakage by projecting only through allowlist helpers.
422
+ const body = projectTask(dagRunId, nodeId, runResolved.result.lifecycle, spec, task, fingerprint, warnings);
423
+ // Defense-in-depth: ensure raw task never sneaks in.
424
+ if ("task" in body) {
425
+ return {
426
+ ok: false,
427
+ status: 422,
428
+ body: { error: "projection leaked raw task" },
429
+ };
430
+ }
431
+ return { ok: true, status: 200, body };
432
+ }
433
+ /** Test helper: ensure response JSON never contains raw task keys. */
434
+ export function assertNoRawTaskLeak(body) {
435
+ if (!body || typeof body !== "object")
436
+ return true;
437
+ const record = body;
438
+ if ("task" in record)
439
+ return false;
440
+ if ("subtask_prompt" in record)
441
+ return false;
442
+ const serialized = JSON.stringify(body);
443
+ return !serialized.includes('"subtask_prompt":');
444
+ }
@@ -13,6 +13,7 @@ import { dagSourceBindingSchema } from "../../workflows/dag/types.js";
13
13
  import { getTaskPoolRoot } from "../pool/run-store.js";
14
14
  import { isAllowedArtifactTextPath, resolveArtifactPath, resolveRepoFilePreview, toRepoRelativeArtifactPath, } from "./paths.js";
15
15
  import { extractSpecEvidence, extractSpecReadContent, } from "./spec-evidence.js";
16
+ import { buildDagNodeInput } from "./node-input.js";
16
17
  import { buildObserveHealthV1 } from "./health.js";
17
18
  const ARTIFACT_PREVIEW_MAX_BYTES = 64 * 1024;
18
19
  export function createObserveSnapshotCache() {
@@ -86,6 +87,11 @@ export const ROUTES = [
86
87
  pattern: /^\/api\/dag-runs\/([^/]+)\/nodes\/([^/]+)\/session-events$/,
87
88
  handler: handleDagNodeSessionEvents,
88
89
  },
90
+ {
91
+ method: "GET",
92
+ pattern: /^\/api\/dag-runs\/([^/]+)\/nodes\/([^/]+)\/input$/,
93
+ handler: handleDagNodeInput,
94
+ },
89
95
  {
90
96
  method: "GET",
91
97
  pattern: /^\/api\/dag-runs\/([^/]+)\/nodes\/([^/]+)\/spec-evidence$/,
@@ -455,6 +461,17 @@ async function handleDagRunById(_req, res, match, ctx) {
455
461
  }
456
462
  sendJson(res, 200, dagRun);
457
463
  }
464
+ async function handleDagNodeInput(_req, res, match, ctx) {
465
+ const dagRunId = match.params.id;
466
+ const nodeId = match.params.sub;
467
+ if (!isSafeObservabilityIdentifier(dagRunId) ||
468
+ !isSafeObservabilityIdentifier(nodeId)) {
469
+ sendJson(res, 400, { error: "Invalid dag run or node identifier" });
470
+ return;
471
+ }
472
+ const result = await buildDagNodeInput(ctx.repoRoot, dagRunId, nodeId);
473
+ sendJson(res, result.status, result.body);
474
+ }
458
475
  async function handleDagNodeSessionEvents(_req, res, match, ctx) {
459
476
  const dagRunId = match.params.id;
460
477
  const nodeId = match.params.sub;
@@ -29,6 +29,15 @@ export async function fetchJsonResult(url) {
29
29
  }
30
30
  }
31
31
 
32
+ /**
33
+ * Fetch a dag node input projection. Preserves HTTP status so callers can
34
+ * distinguish missing runs, unavailable nodes, and transport errors.
35
+ */
36
+ export async function fetchDagNodeInput(runId, nodeId) {
37
+ const url = `/api/dag-runs/${encodeURIComponent(runId)}/nodes/${encodeURIComponent(nodeId)}/input`;
38
+ return fetchJsonResult(url);
39
+ }
40
+
32
41
  /**
33
42
  * Fetch a spec-evidence file preview (binding source or successful read).
34
43
  * Resolves to the parsed JSON body, or throws an Error carrying the HTTP
@@ -33,6 +33,15 @@ export const UI_TEXT = {
33
33
  noBatches: "暂无 Worker 批次,运行后将出现在这里。",
34
34
  noNodes: "暂无节点。",
35
35
  noOutput: "暂无节点输出。",
36
+ noInput: "暂无节点输入。",
37
+ inputLoading: "正在加载节点输入…",
38
+ inputUnavailable: "未在冻结 DAG tasks[] 中找到该节点输入。",
39
+ inputDynamicUnsupported:
40
+ "该节点为运行时动态展开节点,Phase A 暂不提供完整输入。",
41
+ inputRunSpecUnavailable: "无法读取冻结 DAG 规格(run.json)。",
42
+ inputError: "加载节点输入失败。",
43
+ inputFrozenLabel: "冻结 DAG 节点定义",
44
+ inputFingerprintNote: "仅保存 assembled prompt 指纹,全文未持久化",
36
45
  noSessionEvents: "该节点暂无过程日志",
37
46
  noArtifacts: "暂无产物记录。",
38
47
  noActiveCommand: "当前没有正在执行的命令。",