@tea-agent/loop-agent 0.16.25 → 0.17.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 (115) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +14 -3
  3. package/dist/cli/command-definitions.js +43 -0
  4. package/dist/cli/program.js +26 -0
  5. package/dist/commands/dag-approve.js +4 -0
  6. package/dist/commands/dag-resume.js +1 -0
  7. package/dist/commands/dag-validate.js +6 -0
  8. package/dist/commands/operator.js +44 -0
  9. package/dist/commands/task-contract.js +271 -0
  10. package/dist/executors/dag-pi-executor.js +118 -16
  11. package/dist/executors/pi-executor.js +206 -13
  12. package/dist/executors/pi-sdk-executor.js +21 -6
  13. package/dist/executors/shell-executor.js +85 -8
  14. package/dist/executors/shell-presets.js +16 -3
  15. package/dist/executors/shell-write-guard.js +64 -2
  16. package/dist/shared/operator/capabilities.js +255 -0
  17. package/dist/shared/operator/envelope.js +59 -0
  18. package/dist/shared/operator/index.js +4 -0
  19. package/dist/shared/operator/registry.js +38 -0
  20. package/dist/shared/operator/types.js +5 -0
  21. package/dist/task/contract/adopt.js +166 -0
  22. package/dist/task/contract/apply.js +326 -0
  23. package/dist/task/contract/canonicalize.js +60 -0
  24. package/dist/task/contract/constants.js +29 -0
  25. package/dist/task/contract/diff.js +177 -0
  26. package/dist/task/contract/hash.js +42 -0
  27. package/dist/task/contract/import-revision.js +96 -0
  28. package/dist/task/contract/index.js +17 -0
  29. package/dist/task/contract/journal.js +155 -0
  30. package/dist/task/contract/lock.js +153 -0
  31. package/dist/task/contract/observe.js +296 -0
  32. package/dist/task/contract/paths.js +19 -0
  33. package/dist/task/contract/project.js +170 -0
  34. package/dist/task/contract/recover.js +312 -0
  35. package/dist/task/contract/request-ledger.js +37 -0
  36. package/dist/task/contract/schema.js +151 -0
  37. package/dist/task/contract/transaction.js +160 -0
  38. package/dist/task/contract/types.js +1 -0
  39. package/dist/task/contract/validate-draft.js +106 -0
  40. package/dist/task/index.js +3 -0
  41. package/dist/task/operator/capabilities.js +6 -0
  42. package/dist/task/operator/envelope.js +2 -0
  43. package/dist/task/operator/index.js +5 -0
  44. package/dist/task/operator/registry.js +2 -0
  45. package/dist/task/operator/types.js +1 -0
  46. package/dist/task/runtime.js +5 -1
  47. package/dist/task/source-references.js +7 -0
  48. package/dist/worker/cli.js +150 -32
  49. package/dist/worker/console/app-data.js +185 -0
  50. package/dist/worker/console/dag-confirmation.js +313 -0
  51. package/dist/worker/console/doctor.js +169 -0
  52. package/dist/worker/console/draft-store.js +80 -0
  53. package/dist/worker/console/index.js +15 -0
  54. package/dist/worker/console/interview/assessment.js +67 -0
  55. package/dist/worker/console/interview/session.js +100 -0
  56. package/dist/worker/console/interview/tools.js +109 -0
  57. package/dist/worker/console/loopback.js +16 -0
  58. package/dist/worker/console/observe-health-match.js +174 -0
  59. package/dist/worker/console/observe-link.js +33 -0
  60. package/dist/worker/console/operation-runner.js +166 -0
  61. package/dist/worker/console/operation-sse.js +158 -0
  62. package/dist/worker/console/operation-store.js +147 -0
  63. package/dist/worker/console/operator-actions.js +769 -0
  64. package/dist/worker/console/pi-readiness.js +94 -0
  65. package/dist/worker/console/recovery-cta.js +133 -0
  66. package/dist/worker/console/repo-fingerprint.js +29 -0
  67. package/dist/worker/console/resource-loader.js +95 -0
  68. package/dist/worker/console/routes.js +368 -0
  69. package/dist/worker/console/security.js +126 -0
  70. package/dist/worker/console/server.js +149 -0
  71. package/dist/worker/console/sibling-controller.js +28 -0
  72. package/dist/worker/console/static/assets/index-CbnMgdWa.js +9 -0
  73. package/dist/worker/console/static/assets/index-Dnj0RVs8.css +1 -0
  74. package/dist/worker/console/static/index.html +13 -0
  75. package/dist/worker/console/vite.config.js +27 -0
  76. package/dist/worker/delivery/git-transaction.js +43 -8
  77. package/dist/worker/observe/health.js +57 -0
  78. package/dist/worker/observe/paths.js +81 -0
  79. package/dist/worker/observe/routes.js +142 -27
  80. package/dist/worker/observe/spec-evidence.js +84 -0
  81. package/dist/worker/observe/static/api.js +23 -0
  82. package/dist/worker/observe/static/state.js +26 -0
  83. package/dist/worker/observe/static/styles.css +10 -0
  84. package/dist/worker/observe/static/views/dag-inspector.js +173 -6
  85. package/dist/workflows/dag/backend-test-analysis-contract.js +34 -9
  86. package/dist/workflows/dag/dynamic-runtime/shared.js +1 -0
  87. package/dist/workflows/dag/frontend-repair.js +1 -10
  88. package/dist/workflows/dag/init-hybrid.js +372 -115
  89. package/dist/workflows/dag/node-execution.js +57 -6
  90. package/dist/workflows/dag/project-governance-context.js +508 -0
  91. package/dist/workflows/dag/prompt.js +46 -1
  92. package/dist/workflows/dag/retry-policy.js +16 -1
  93. package/dist/workflows/dag/runner.js +9 -0
  94. package/dist/workflows/dag/skill-snapshot.js +1 -0
  95. package/dist/workflows/dag/task-contract-binding.js +138 -0
  96. package/dist/workflows/dag/types.js +84 -10
  97. package/dist/workflows/dag/validate.js +53 -7
  98. package/docs/README.md +2 -0
  99. package/docs/architecture/evolution.md +2 -0
  100. package/docs/architecture/system-overview.md +6 -0
  101. package/docs/architecture/worker-and-feature.md +7 -0
  102. package/docs/templates/agent-dag.schema.json +64 -2
  103. package/docs/templates/agent-dag.supervised-implementation.json +1 -0
  104. package/docs/templates/backend-test-dag.classify.prompt.md +1 -1
  105. package/docs/templates/backend-test-dag.generate-pytest.prompt.md +5 -5
  106. package/docs/templates/backend-test-dag.json +26 -154
  107. package/docs/templates/backend-test-dag.retrospect.prompt.md +1 -1
  108. package/docs/templates/backend-test-dag.review-cases.prompt.md +2 -2
  109. package/package.json +8 -2
  110. package/skills/agent-worker/SKILL.md +1 -0
  111. package/skills/agent-worker/references/agent-worker-operator.md +3 -2
  112. package/skills/frontend-design-review/SKILL.md +25 -16
  113. package/skills/frontend-implementation/references/node-contracts.md +5 -5
  114. package/skills/loop-agent/references/command-reference.md +48 -1
  115. package/skills/loop-agent/references/hybrid-dag.md +4 -4
@@ -0,0 +1,13 @@
1
+ <!doctype html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Loop Operator Console</title>
7
+ <script type="module" crossorigin src="/assets/index-CbnMgdWa.js"></script>
8
+ <link rel="stylesheet" crossorigin href="/assets/index-Dnj0RVs8.css">
9
+ </head>
10
+ <body>
11
+ <div id="root"></div>
12
+ </body>
13
+ </html>
@@ -0,0 +1,27 @@
1
+ import path from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+ import { defineConfig } from "vite";
4
+ const consoleDir = path.dirname(fileURLToPath(import.meta.url));
5
+ const repoRoot = path.resolve(consoleDir, "../../..");
6
+ /**
7
+ * Vite SPA build for Loop Operator Console.
8
+ * Output: dist/worker/console/static (no source maps in publish tarball).
9
+ */
10
+ export default defineConfig({
11
+ root: path.join(consoleDir, "static-src"),
12
+ base: "/",
13
+ build: {
14
+ outDir: path.join(repoRoot, "dist/worker/console/static"),
15
+ emptyOutDir: true,
16
+ sourcemap: false,
17
+ // Keep Phase 1 shell small; no code-split complexity required.
18
+ rollupOptions: {
19
+ input: path.join(consoleDir, "static-src/index.html"),
20
+ },
21
+ },
22
+ // React is a devDependency; esbuild jsx transform avoids needing @vitejs/plugin-react.
23
+ esbuild: {
24
+ jsx: "automatic",
25
+ jsxImportSource: "react",
26
+ },
27
+ });
@@ -122,22 +122,51 @@ export async function finalizeGitTask(transaction, outcome) {
122
122
  const changes = await readChanges(transaction.repoRoot);
123
123
  const newIgnored = currentIgnored.map((entry) => entry.path).filter((entry) => !ignoredBaselineByPath.has(entry));
124
124
  if (outcome.status === "succeeded") {
125
- if (changes.length === 0)
125
+ const allowedPaths = expandAllowedPathsForWorkflow(resolveWorkflow(outcome.taskSpec).workflow, outcome.taskSpec.constraints.allowed_paths);
126
+ // Evidence under allowed_paths may be gitignored for local convenience (e.g.
127
+ // reports/welcome/** smoke JSON) but must still checkpoint for Task Pool Done.
128
+ // Sensitive / out-of-scope ignored files remain fail-closed; ephemeral tool
129
+ // caches neither block nor force-add.
130
+ const forceAddIgnored = [];
131
+ const blockedIgnored = [];
132
+ for (const entry of newIgnored) {
133
+ if (isEphemeralToolCachePath(entry))
134
+ continue;
135
+ if (isSensitivePath(entry)) {
136
+ blockedIgnored.push(entry);
137
+ continue;
138
+ }
139
+ if (allowedPaths.some((glob) => matchesGlob(entry, glob))) {
140
+ forceAddIgnored.push(entry);
141
+ }
142
+ else {
143
+ blockedIgnored.push(entry);
144
+ }
145
+ }
146
+ if (blockedIgnored.length > 0) {
147
+ throw new Error(`task created ignored files outside the Git checkpoint: ${blockedIgnored.join(", ")}`);
148
+ }
149
+ const checkpointFiles = [...changes, ...forceAddIgnored];
150
+ if (checkpointFiles.length === 0) {
126
151
  throw new Error(`successful task produced no checkpointable changes: ${outcome.taskSpec.id}`);
127
- if (newIgnored.length > 0)
128
- throw new Error(`task created ignored files outside the Git checkpoint: ${newIgnored.join(", ")}`);
129
- auditChangedPaths(changes, outcome.taskSpec);
130
- await git(transaction.repoRoot, ["add", "--", ...changes]);
152
+ }
153
+ auditChangedPaths(checkpointFiles, outcome.taskSpec);
154
+ if (changes.length > 0) {
155
+ await git(transaction.repoRoot, ["add", "--", ...changes]);
156
+ }
157
+ if (forceAddIgnored.length > 0) {
158
+ await git(transaction.repoRoot, ["add", "-f", "--", ...forceAddIgnored]);
159
+ }
131
160
  const message = commitMessage(outcome.taskSpec, outcome.workerRunId);
132
161
  await git(transaction.repoRoot, ["commit", "-m", message]);
133
162
  const commit = await git(transaction.repoRoot, ["rev-parse", "HEAD"]);
134
- const checkpoint = { taskId: outcome.taskSpec.id, workerRunId: outcome.workerRunId, commit, changedFiles: changes, createdAt: (outcome.now ?? new Date()).toISOString() };
163
+ const checkpoint = { taskId: outcome.taskSpec.id, workerRunId: outcome.workerRunId, commit, changedFiles: checkpointFiles, createdAt: (outcome.now ?? new Date()).toISOString() };
135
164
  const previousCheckpoint = current.lastCheckpoint;
136
165
  try {
137
166
  await assertTransactionPosition(transaction.repoRoot, { ...current, lastCheckpoint: commit });
138
167
  await assertCheckpointMetadata(transaction.repoRoot, commit, outcome.taskSpec, outcome.workerRunId);
139
168
  const afterCommitIgnored = await readIgnoredBaseline(transaction.repoRoot);
140
- assertIgnoredBaselineUnchanged(current.ignoredBaseline, afterCommitIgnored);
169
+ assertIgnoredBaselineUnchanged(current.ignoredBaseline.filter((entry) => !isEphemeralToolCachePath(entry.path)), afterCommitIgnored.filter((entry) => !isEphemeralToolCachePath(entry.path)));
141
170
  }
142
171
  catch (error) {
143
172
  const branch = await git(transaction.repoRoot, ["branch", "--show-current"]).catch(() => "");
@@ -162,7 +191,13 @@ export async function finalizeGitTask(transaction, outcome) {
162
191
  }
163
192
  transaction.record = current;
164
193
  await assertClean(transaction.repoRoot);
165
- return { status: "checkpointed", taskId: outcome.taskSpec.id, workerRunId: outcome.workerRunId, commit, changedFiles: changes };
194
+ return {
195
+ status: "checkpointed",
196
+ taskId: outcome.taskSpec.id,
197
+ workerRunId: outcome.workerRunId,
198
+ commit,
199
+ changedFiles: checkpointFiles,
200
+ };
166
201
  }
167
202
  const artifactDir = path.join(path.dirname(transaction.recordPath), "failures", outcome.workerRunId);
168
203
  await captureFailureArtifacts(transaction.repoRoot, artifactDir, changes, newIgnored, outcome);
@@ -0,0 +1,57 @@
1
+ import { fileURLToPath } from "node:url";
2
+ import path from "node:path";
3
+ import { findPackageRoot, readPackageName, readPackageVersion, } from "../../shared/package-metadata.js";
4
+ import { repoFingerprintV1 } from "../console/repo-fingerprint.js";
5
+ /** Observe `/api/health` schema version (independent of Console product health). */
6
+ export const OBSERVE_HEALTH_SCHEMA_VERSION = 1;
7
+ function routeExists(routes, method, pathname) {
8
+ return routes.some((route) => route.method === method && route.pattern.test(pathname));
9
+ }
10
+ /**
11
+ * Derive capability flags from live ROUTES matchers (single source of truth).
12
+ * Synthetic paths exercise patterns; do not maintain a second handwritten table.
13
+ */
14
+ export function deriveObserveRouteCapabilities(routes) {
15
+ return {
16
+ dagRun: routeExists(routes, "GET", "/api/dag-runs/example-run"),
17
+ task: routeExists(routes, "GET", "/api/tasks/example-task") ||
18
+ routeExists(routes, "GET", "/api/features/example-feature/tasks/example-task"),
19
+ feature: routeExists(routes, "GET", "/api/features/example-feature/tasks/example-task"),
20
+ workerRun: routeExists(routes, "GET", "/api/runs/example-run"),
21
+ batch: routeExists(routes, "GET", "/api/batches/example-batch"),
22
+ };
23
+ }
24
+ export function resolveObservePackageIdentity(fromFileUrl = import.meta.url) {
25
+ const startDir = path.dirname(fileURLToPath(fromFileUrl));
26
+ const packageRoot = findPackageRoot(startDir);
27
+ const packageName = packageRoot
28
+ ? (readPackageName(packageRoot) ?? "unknown")
29
+ : "unknown";
30
+ const packageVersion = packageRoot
31
+ ? (readPackageVersion(packageRoot) ?? "0.0.0")
32
+ : "0.0.0";
33
+ return { packageName, packageVersion };
34
+ }
35
+ /**
36
+ * Build versioned Observe health DTO for GET /api/health.
37
+ */
38
+ export function buildObserveHealthV1(input) {
39
+ const { packageName, packageVersion } = resolveObservePackageIdentity(input.fromFileUrl);
40
+ let repoFingerprint;
41
+ try {
42
+ repoFingerprint = repoFingerprintV1(input.repoRoot);
43
+ }
44
+ catch (error) {
45
+ repoFingerprint = `error:${error instanceof Error ? error.message : String(error)}`;
46
+ }
47
+ return {
48
+ ok: true,
49
+ schemaVersion: OBSERVE_HEALTH_SCHEMA_VERSION,
50
+ repoRoot: input.repoRoot,
51
+ repoFingerprint,
52
+ packageName,
53
+ packageVersion,
54
+ routeCapabilities: deriveObserveRouteCapabilities(input.routes),
55
+ generatedAt: input.generatedAt ?? new Date().toISOString(),
56
+ };
57
+ }
@@ -1,4 +1,5 @@
1
1
  import { existsSync, realpathSync } from "node:fs";
2
+ import { open, stat } from "node:fs/promises";
2
3
  import path from "node:path";
3
4
  const ALLOWED_ARTIFACT_EXTENSIONS = new Set([
4
5
  ".csv",
@@ -91,3 +92,83 @@ function isPathInside(root, target) {
91
92
  const normalizedTarget = path.normalize(target);
92
93
  return normalizedTarget === root || normalizedTarget.startsWith(normalizedRoot);
93
94
  }
95
+ /**
96
+ * Safe repo-relative file resolver for spec-evidence previews (binding sources).
97
+ *
98
+ * Enforces AC-003: rejects `..`, absolute paths (POSIX + Windows drive),
99
+ * directory, binary content, and symlink escape via realpath re-check.
100
+ *
101
+ * Unlike resolveArtifactPath (which only allows .harness artifact roots), this
102
+ * allows any repo-relative text file that was already validated as an evidence
103
+ * member by the caller. Returns { absPath, size } on success, throws otherwise.
104
+ */
105
+ export async function resolveRepoFilePreview(repoRoot, relPath) {
106
+ if (!relPath || typeof relPath !== "string") {
107
+ throw new Error("Invalid path");
108
+ }
109
+ if (path.isAbsolute(relPath)) {
110
+ throw new Error("Absolute paths are not allowed");
111
+ }
112
+ // Windows drive letters (e.g. C:\) or POSIX absolute already covered, plus
113
+ // explicit backslash-drive form used in tests.
114
+ if (/^[A-Za-z]:[\\/]/.test(relPath)) {
115
+ throw new Error("Absolute paths are not allowed");
116
+ }
117
+ // Reject any path segment that is ".." (handles ../, nested .., ./..).
118
+ const preSegments = relPath.split(/[\\/]/).filter(Boolean);
119
+ if (preSegments.includes("..")) {
120
+ throw new Error("Path traversal is not allowed");
121
+ }
122
+ if (!isAllowedArtifactTextPath(relPath)) {
123
+ throw new Error("Binary or disallowed file type");
124
+ }
125
+ const resolvedRepoRoot = path.resolve(repoRoot);
126
+ let realRepoRoot;
127
+ try {
128
+ realRepoRoot = realpathSync(resolvedRepoRoot);
129
+ }
130
+ catch {
131
+ realRepoRoot = resolvedRepoRoot;
132
+ }
133
+ const candidate = path.resolve(realRepoRoot, relPath);
134
+ const relative = path.relative(realRepoRoot, candidate);
135
+ if (relative === "" ||
136
+ relative === ".." ||
137
+ relative.startsWith(`..${path.sep}`) ||
138
+ path.isAbsolute(relative)) {
139
+ throw new Error("Path escapes repo root");
140
+ }
141
+ if (!existsSync(candidate)) {
142
+ throw new Error("File not found");
143
+ }
144
+ let realCandidate;
145
+ try {
146
+ realCandidate = realpathSync(candidate);
147
+ }
148
+ catch {
149
+ throw new Error("File not found");
150
+ }
151
+ if (!isPathInside(realRepoRoot, realCandidate)) {
152
+ throw new Error("Symlink escapes repo root");
153
+ }
154
+ const fileStat = await stat(realCandidate);
155
+ if (!fileStat.isFile()) {
156
+ throw new Error("Path is not a regular file");
157
+ }
158
+ const sampleLength = Math.min(fileStat.size, 8 * 1024);
159
+ if (sampleLength > 0) {
160
+ const sample = Buffer.alloc(sampleLength);
161
+ const handle = await open(realCandidate, "r");
162
+ let bytesRead = 0;
163
+ try {
164
+ ({ bytesRead } = await handle.read(sample, 0, sampleLength, 0));
165
+ }
166
+ finally {
167
+ await handle.close();
168
+ }
169
+ if (sample.subarray(0, bytesRead).includes(0)) {
170
+ throw new Error("Binary file content is not allowed");
171
+ }
172
+ }
173
+ return { absPath: realCandidate, size: fileStat.size };
174
+ }
@@ -1,4 +1,5 @@
1
1
  import { existsSync } from "node:fs";
2
+ import { createHash } from "node:crypto";
2
3
  import { open as openFile, readFile, stat } from "node:fs/promises";
3
4
  import path from "node:path";
4
5
  import { redactSecrets, truncateUtf8Preview } from "../../shared/preview.js";
@@ -8,8 +9,9 @@ import { clampEventHistoryLimit, listBatchEventHistory, listPoolEventHistory, }
8
9
  import { buildGlobalSnapshot, clampTaskRunHistoryLimit, listTaskRunHistory, resolveLegacyTask, } from "../observability/read-model.js";
9
10
  import { dagSourceBindingSchema } from "../../workflows/dag/types.js";
10
11
  import { getTaskPoolRoot } from "../pool/run-store.js";
11
- import { isAllowedArtifactTextPath, resolveArtifactPath, toRepoRelativeArtifactPath, } from "./paths.js";
12
- import { extractSpecEvidence, } from "./spec-evidence.js";
12
+ import { isAllowedArtifactTextPath, resolveArtifactPath, resolveRepoFilePreview, toRepoRelativeArtifactPath, } from "./paths.js";
13
+ import { extractSpecEvidence, extractSpecReadContent, } from "./spec-evidence.js";
14
+ import { buildObserveHealthV1 } from "./health.js";
13
15
  const ARTIFACT_PREVIEW_MAX_BYTES = 64 * 1024;
14
16
  export function createObserveSnapshotCache() {
15
17
  return { expiresAt: 0 };
@@ -76,6 +78,11 @@ const ROUTES = [
76
78
  pattern: /^\/api\/dag-runs\/([^/]+)\/nodes\/([^/]+)\/spec-evidence$/,
77
79
  handler: handleDagNodeSpecEvidence,
78
80
  },
81
+ {
82
+ method: "GET",
83
+ pattern: /^\/api\/dag-runs\/([^/]+)\/nodes\/([^/]+)\/spec-evidence\/file$/,
84
+ handler: handleDagNodeSpecEvidenceFile,
85
+ },
79
86
  {
80
87
  method: "GET",
81
88
  pattern: /^\/api\/dag-runs\/([^/]+)$/,
@@ -149,11 +156,10 @@ export async function serveStatic(req, res, staticDir) {
149
156
  res.end(content);
150
157
  }
151
158
  async function handleHealth(_req, res, _match, ctx) {
152
- sendJson(res, 200, {
153
- ok: true,
159
+ sendJson(res, 200, buildObserveHealthV1({
154
160
  repoRoot: ctx.repoRoot,
155
- generatedAt: new Date().toISOString(),
156
- });
161
+ routes: ROUTES,
162
+ }));
157
163
  }
158
164
  async function getSnapshot(ctx) {
159
165
  const now = Date.now();
@@ -706,35 +712,142 @@ function sendJson(res, status, body) {
706
712
  });
707
713
  res.end(payload);
708
714
  }
709
- async function handleDagNodeSpecEvidence(_req, res, match, ctx) {
710
- const dagRunId = match.params.id;
711
- const nodeId = match.params.sub;
712
- if (!isSafeObservabilityIdentifier(dagRunId) || !isSafeObservabilityIdentifier(nodeId)) {
713
- sendJson(res, 400, { error: "Invalid dag run or node identifier" });
714
- return;
715
- }
716
- // Extract skill injection info from the DAG run spec (run.json)
717
- const skillInjection = { skills: [], references: [] };
718
- let sourceBinding;
719
- const dagRunsRoot = path.resolve(ctx.repoRoot, ".harness", "dag-runs");
715
+ async function loadDagRunSourceBinding(repoRoot, dagRunId) {
716
+ const dagRunsRoot = path.resolve(repoRoot, ".harness", "dag-runs");
720
717
  for (const lifecycle of ["active", "completed", "paused"]) {
721
718
  const runJsonPath = path.join(dagRunsRoot, lifecycle, dagRunId, "run.json");
722
719
  try {
723
720
  const runRaw = await readFile(runJsonPath, "utf-8");
724
721
  const runSpec = JSON.parse(runRaw);
725
- const parsedSourceBinding = dagSourceBindingSchema.safeParse(runSpec.sourceBinding);
726
- if (parsedSourceBinding.success)
727
- sourceBinding = parsedSourceBinding.data;
728
- const task = runSpec.tasks?.find((t) => t.id === nodeId);
729
- if (task?.skills && Array.isArray(task.skills)) {
730
- skillInjection.skills = task.skills;
731
- }
732
- break;
722
+ const parsed = dagSourceBindingSchema.safeParse(runSpec.sourceBinding);
723
+ if (parsed.success)
724
+ return parsed.data;
725
+ return undefined;
733
726
  }
734
727
  catch {
735
728
  // run.json may not exist; continue to next lifecycle
736
729
  }
737
730
  }
731
+ return undefined;
732
+ }
733
+ /**
734
+ * Read-only spec-evidence file preview (AC-001..AC-005). Re-validates evidence
735
+ * membership server-side: binding paths must be in sourceBinding.sources, read
736
+ * paths must have a successful paired read in session events. Enforces AC-003
737
+ * path safety and AC-004 truncation + redaction.
738
+ */
739
+ async function handleDagNodeSpecEvidenceFile(_req, res, match, ctx) {
740
+ const dagRunId = match.params.id;
741
+ const nodeId = match.params.sub;
742
+ if (!isSafeObservabilityIdentifier(dagRunId) ||
743
+ !isSafeObservabilityIdentifier(nodeId)) {
744
+ sendJson(res, 400, { error: "Invalid dag run or node identifier" });
745
+ return;
746
+ }
747
+ const source = match.query.get("source");
748
+ const rawPath = match.query.get("path");
749
+ if (source !== "binding" && source !== "read") {
750
+ sendJson(res, 400, { error: "Invalid source" });
751
+ return;
752
+ }
753
+ if (!rawPath) {
754
+ sendJson(res, 400, { error: "path is required" });
755
+ return;
756
+ }
757
+ const maxBytes = ARTIFACT_PREVIEW_MAX_BYTES;
758
+ if (source === "binding") {
759
+ const sourceBinding = await loadDagRunSourceBinding(ctx.repoRoot, dagRunId);
760
+ const member = sourceBinding?.sources?.find((s) => s.path === rawPath);
761
+ if (!sourceBinding || !member) {
762
+ sendJson(res, 404, { error: "Path is not a bound source" });
763
+ return;
764
+ }
765
+ let resolved;
766
+ try {
767
+ resolved = await resolveRepoFilePreview(ctx.repoRoot, rawPath);
768
+ }
769
+ catch {
770
+ sendJson(res, 400, { error: "Invalid or unsafe path" });
771
+ return;
772
+ }
773
+ let raw;
774
+ try {
775
+ raw = await readFile(resolved.absPath, "utf-8");
776
+ }
777
+ catch {
778
+ sendJson(res, 404, { error: "File not found" });
779
+ return;
780
+ }
781
+ const redacted = redactSecrets(raw);
782
+ const content = truncateUtf8Preview(redacted, maxBytes);
783
+ const truncated = content !== redacted;
784
+ const currentSha256 = createHash("sha256")
785
+ .update(raw, "utf-8")
786
+ .digest("hex");
787
+ sendJson(res, 200, {
788
+ source: "binding",
789
+ path: rawPath,
790
+ sha256: member.sha256,
791
+ currentSha256,
792
+ hashMatch: currentSha256 === member.sha256,
793
+ content,
794
+ truncated,
795
+ contentBytes: Buffer.byteLength(content, "utf-8"),
796
+ maxBytes,
797
+ });
798
+ return;
799
+ }
800
+ // source === "read"
801
+ const record = await extractSpecReadContent(ctx.repoRoot, dagRunId, nodeId, rawPath);
802
+ if (!record) {
803
+ sendJson(res, 404, { error: "Path was not successfully read" });
804
+ return;
805
+ }
806
+ const redacted = redactSecrets(record.content);
807
+ const content = truncateUtf8Preview(redacted, maxBytes);
808
+ const truncated = content !== redacted;
809
+ sendJson(res, 200, {
810
+ source: "read",
811
+ path: rawPath,
812
+ content,
813
+ truncated,
814
+ contentBytes: Buffer.byteLength(content, "utf-8"),
815
+ maxBytes,
816
+ readAt: record.timestamp ?? null,
817
+ });
818
+ }
819
+ async function handleDagNodeSpecEvidence(_req, res, match, ctx) {
820
+ const dagRunId = match.params.id;
821
+ const nodeId = match.params.sub;
822
+ if (!isSafeObservabilityIdentifier(dagRunId) ||
823
+ !isSafeObservabilityIdentifier(nodeId)) {
824
+ sendJson(res, 400, { error: "Invalid dag run or node identifier" });
825
+ return;
826
+ }
827
+ // Extract skill injection info from the DAG run spec (run.json)
828
+ const skillInjection = {
829
+ skills: [],
830
+ references: [],
831
+ };
832
+ const sourceBinding = await loadDagRunSourceBinding(ctx.repoRoot, dagRunId);
833
+ {
834
+ const dagRunsRoot = path.resolve(ctx.repoRoot, ".harness", "dag-runs");
835
+ for (const lifecycle of ["active", "completed", "paused"]) {
836
+ const runJsonPath = path.join(dagRunsRoot, lifecycle, dagRunId, "run.json");
837
+ try {
838
+ const runRaw = await readFile(runJsonPath, "utf-8");
839
+ const runSpec = JSON.parse(runRaw);
840
+ const task = runSpec.tasks?.find((t) => t.id === nodeId);
841
+ if (task?.skills && Array.isArray(task.skills)) {
842
+ skillInjection.skills = task.skills;
843
+ }
844
+ break;
845
+ }
846
+ catch {
847
+ // run.json may not exist; continue to next lifecycle
848
+ }
849
+ }
850
+ }
738
851
  const evidence = await extractSpecEvidence(ctx.repoRoot, dagRunId, nodeId);
739
852
  if (!evidence) {
740
853
  const hasSourceBinding = Boolean(sourceBinding);
@@ -756,7 +869,8 @@ async function handleDagNodeSpecEvidence(_req, res, match, ctx) {
756
869
  evidence.skillInjection = skillInjection;
757
870
  evidence.sourceBinding = sourceBinding;
758
871
  // Recompute status considering skill injection
759
- if (evidence.specReads.length === 0 && evidence.knowledgeBaseQueries.length === 0) {
872
+ if (evidence.specReads.length === 0 &&
873
+ evidence.knowledgeBaseQueries.length === 0) {
760
874
  if (evidence.specSearches.length > 0) {
761
875
  evidence.status = "search-only";
762
876
  }
@@ -766,7 +880,8 @@ async function handleDagNodeSpecEvidence(_req, res, match, ctx) {
766
880
  }
767
881
  else if (skillInjection.skills.length > 0) {
768
882
  evidence.status = "spec-injected";
769
- evidence.summary = "规范 skill 已注入但未观察到规范文件读取或知识库查询。";
883
+ evidence.summary =
884
+ "规范 skill 已注入但未观察到规范文件读取或知识库查询。";
770
885
  }
771
886
  }
772
887
  sendJson(res, 200, evidence);
@@ -132,6 +132,90 @@ async function readSessionEvents(filePath) {
132
132
  return [];
133
133
  }
134
134
  }
135
+ /**
136
+ * Coerce a `tool_execution_end` result body (toolResult.content or result.content)
137
+ * into a string. Accepts plain strings, `{type:"text", text}[]` arrays, and
138
+ * message.content shapes. Returns null when no usable body is present.
139
+ */
140
+ function resultContentToString(content) {
141
+ if (content == null)
142
+ return null;
143
+ if (typeof content === "string")
144
+ return content;
145
+ if (Array.isArray(content)) {
146
+ const parts = [];
147
+ for (const part of content) {
148
+ if (typeof part === "string") {
149
+ parts.push(part);
150
+ }
151
+ else if (part && typeof part === "object" && typeof part.text === "string") {
152
+ parts.push(part.text);
153
+ }
154
+ }
155
+ return parts.length > 0 ? parts.join("\n") : null;
156
+ }
157
+ return null;
158
+ }
159
+ /**
160
+ * Scan session events for successful paired `read` tool calls and return the
161
+ * most recent result body + timestamp whose repo-relative path matches
162
+ * `relPath` exactly. Used for AC-002: show the content the model actually
163
+ * received at read time, never the current workspace file.
164
+ */
165
+ export async function extractSpecReadContent(repoRoot, dagRunId, nodeId, relPath) {
166
+ if (!relPath || !isSafeObservabilityIdentifier(dagRunId) || !isSafeObservabilityIdentifier(nodeId)) {
167
+ return null;
168
+ }
169
+ // The preview endpoint must expose exactly the same membership as the
170
+ // `specReads` list, not every file the node happened to read.
171
+ if (!isSpecFilePath(relPath))
172
+ return null;
173
+ const eventsPath = resolveSessionEventsPath(repoRoot, dagRunId, nodeId);
174
+ if (!eventsPath)
175
+ return null;
176
+ const events = await readSessionEvents(eventsPath);
177
+ const startMap = new Map();
178
+ let latest = null;
179
+ for (const event of events) {
180
+ const type = event.type;
181
+ const toolName = resolveToolName(event);
182
+ const toolCallId = event.toolCallId;
183
+ if (type === "tool_execution_start" && toolCallId) {
184
+ startMap.set(toolCallId, { toolName, input: eventToolArgs(event) });
185
+ continue;
186
+ }
187
+ if (type !== "tool_execution_end" || toolName !== "read" || !toolCallId)
188
+ continue;
189
+ const start = startMap.get(toolCallId);
190
+ if (!start || start.toolName !== "read")
191
+ continue;
192
+ const isErrored = event.isError === true ||
193
+ event.toolResult?.error != null ||
194
+ event.toolResult?.ok === false ||
195
+ event.result?.error != null ||
196
+ event.result?.ok === false;
197
+ if (isErrored) {
198
+ startMap.delete(toolCallId);
199
+ continue;
200
+ }
201
+ const startInput = start.input ?? {};
202
+ const filePath = typeof startInput.path === "string" ? startInput.path : undefined;
203
+ if (!filePath) {
204
+ startMap.delete(toolCallId);
205
+ continue;
206
+ }
207
+ const rel = toRepoRelative(repoRoot, filePath);
208
+ startMap.delete(toolCallId);
209
+ if (!rel || rel !== relPath)
210
+ continue;
211
+ const body = resultContentToString(event.toolResult?.content) ??
212
+ resultContentToString(event.result?.content);
213
+ if (body == null)
214
+ continue;
215
+ latest = { content: body, timestamp: eventTimestamp(event) };
216
+ }
217
+ return latest;
218
+ }
135
219
  export async function extractSpecEvidence(repoRoot, dagRunId, nodeId) {
136
220
  if (!isSafeObservabilityIdentifier(dagRunId) || !isSafeObservabilityIdentifier(nodeId)) {
137
221
  return null;
@@ -29,6 +29,29 @@ export async function fetchJsonResult(url) {
29
29
  }
30
30
  }
31
31
 
32
+ /**
33
+ * Fetch a spec-evidence file preview (binding source or successful read).
34
+ * Resolves to the parsed JSON body, or throws an Error carrying the HTTP
35
+ * status and server message so the UI can render an accessible error state.
36
+ */
37
+ export async function fetchSpecEvidenceFile(runId, nodeId, source, relPath) {
38
+ const url = `/api/dag-runs/${encodeURIComponent(runId)}/nodes/${encodeURIComponent(nodeId)}/spec-evidence/file?source=${encodeURIComponent(source)}&path=${encodeURIComponent(relPath)}`;
39
+ const res = await fetch(url);
40
+ let body = null;
41
+ try {
42
+ body = await res.json();
43
+ } catch {
44
+ body = null;
45
+ }
46
+ if (!res.ok) {
47
+ const message = body?.error || `加载失败:HTTP ${res.status}`;
48
+ const error = new Error(message);
49
+ error.status = res.status;
50
+ throw error;
51
+ }
52
+ return body;
53
+ }
54
+
32
55
  export function artifactUrl(artifactPath) {
33
56
  return `/api/artifacts?path=${encodeURIComponent(artifactPath)}&tail=200`;
34
57
  }
@@ -46,6 +46,13 @@ export const uiState = {
46
46
  dagTimelineViewportState: null,
47
47
  dagNodeOutputViewportState: null,
48
48
  runOutputViewportState: null,
49
+ /**
50
+ * Spec-evidence file preview drill-down. null = list view; otherwise
51
+ * { source, path, loading, error, data, triggerId } for the active preview.
52
+ */
53
+ specEvidenceDetail: null,
54
+ /** Last-rendered spec-evidence list, used to restore the list synchronously. */
55
+ specEvidenceListEvidence: null,
49
56
  pollingGeneration: 0,
50
57
  };
51
58
 
@@ -271,3 +278,22 @@ export function bumpPollingGeneration() {
271
278
  uiState.pollingGeneration += 1;
272
279
  return uiState.pollingGeneration;
273
280
  }
281
+
282
+ /** Open an in-inspector spec-evidence file preview (AC-001/002). */
283
+ export function openSpecEvidenceDetail(source, path, triggerEl) {
284
+ uiState.specEvidenceDetail = {
285
+ source,
286
+ path,
287
+ loading: true,
288
+ error: null,
289
+ data: null,
290
+ triggerId: triggerEl?.id ?? null,
291
+ };
292
+ }
293
+
294
+ /** Close the preview; caller restores focus to the trigger element. */
295
+ export function closeSpecEvidenceDetail() {
296
+ const detail = uiState.specEvidenceDetail;
297
+ uiState.specEvidenceDetail = null;
298
+ return detail;
299
+ }
@@ -1863,6 +1863,16 @@ body.is-resizing-dag-graph {
1863
1863
  .spec-evidence-time { margin-left: auto; color: var(--muted); font-size: 10px; font-variant-numeric: tabular-nums; white-space: nowrap; }
1864
1864
  .spec-evidence-warning { display: flex; align-items: flex-start; gap: 8px; margin-top: var(--space-4); padding: 10px 12px; border: 1px solid color-mix(in srgb, var(--amber) 50%, var(--hairline)); border-radius: var(--radius-sm); background: #fff8e7; color: var(--body); font-size: 12px; line-height: 1.6; }
1865
1865
  .spec-evidence-warning > i { flex-shrink: 0; margin-top: 2px; color: var(--amber); }
1866
+ .spec-evidence-file-btn { display: flex; align-items: flex-start; gap: 6px; width: 100%; padding: 4px 0; background: none; border: none; text-align: left; cursor: pointer; color: inherit; font: inherit; line-height: 1.5; overflow-wrap: anywhere; border-radius: var(--radius-sm); }
1867
+ .spec-evidence-file-btn:hover, .spec-evidence-file-btn:focus-visible { background: color-mix(in srgb, var(--accent, #2563eb) 8%, transparent); outline: none; }
1868
+ .spec-evidence-file-btn:focus-visible { box-shadow: 0 0 0 2px var(--accent, #2563eb); }
1869
+ .spec-evidence-detail { display: grid; gap: var(--space-3); }
1870
+ .spec-evidence-back { display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; border: 1px solid var(--hairline); border-radius: var(--radius-sm); background: var(--surface, #fff); color: var(--ink); font-size: 12px; cursor: pointer; }
1871
+ .spec-evidence-back:hover, .spec-evidence-back:focus-visible { background: color-mix(in srgb, var(--accent, #2563eb) 8%, var(--surface, #fff)); }
1872
+ .spec-evidence-detail-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; font-size: 12px; color: var(--body); }
1873
+ .spec-evidence-detail-content { min-height: 0; max-width: 100%; margin: 0; padding: var(--space-3); border: 1px solid var(--hairline); border-radius: var(--radius-sm); background: var(--surface, #fff); white-space: pre-wrap; overflow-wrap: anywhere; font-size: 12px; line-height: 1.6; }
1874
+ .spec-evidence-detail-error { margin: 0; color: var(--red, #c00); font-size: 12px; }
1875
+ .spec-evidence-detail-truncated { margin: 0; color: var(--muted); font-size: 11px; }
1866
1876
 
1867
1877
  .run-layer-process {
1868
1878
  display: grid;