@tea-agent/loop-agent 0.16.24 → 0.16.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/cli/command-definitions.js +13 -0
  3. package/dist/cli/program.js +4 -0
  4. package/dist/commands/coverage-report.js +50 -0
  5. package/dist/executors/dag-pi-executor.js +63 -9
  6. package/dist/executors/shell-executor.js +30 -0
  7. package/dist/executors/shell-write-guard.js +64 -2
  8. package/dist/worker/delivery/git-transaction.js +43 -8
  9. package/dist/worker/observe/paths.js +81 -0
  10. package/dist/worker/observe/routes.js +127 -19
  11. package/dist/worker/observe/spec-evidence.js +84 -0
  12. package/dist/worker/observe/static/api.js +23 -0
  13. package/dist/worker/observe/static/state.js +26 -0
  14. package/dist/worker/observe/static/styles.css +10 -0
  15. package/dist/worker/observe/static/views/dag-inspector.js +173 -6
  16. package/dist/workflows/dag/backend-test-coverage-contract.js +202 -0
  17. package/dist/workflows/dag/backend-test-execution-contract.js +84 -18
  18. package/dist/workflows/dag/backend-test-stability-contract.js +57 -0
  19. package/dist/workflows/dag/init-hybrid.js +150 -13
  20. package/dist/workflows/dag/l5-report-metrics.js +36 -0
  21. package/dist/workflows/dag/node-execution.js +32 -5
  22. package/dist/workflows/dag/project-governance-context.js +508 -0
  23. package/dist/workflows/dag/prompt.js +46 -1
  24. package/dist/workflows/dag/skill-snapshot.js +1 -0
  25. package/dist/workflows/dag/types.js +10 -0
  26. package/dist/workflows/dag/validate.js +28 -0
  27. package/docs/architecture/evolution.md +3 -1
  28. package/docs/templates/agent-dag.schema.json +15 -0
  29. package/docs/templates/agent-dag.supervised-implementation.json +1 -0
  30. package/docs/templates/backend-test-dag.generate-pytest.prompt.md +2 -0
  31. package/docs/templates/backend-test-dag.json +39 -6
  32. package/docs/templates/backend-test-dag.retrospect.prompt.md +36 -7
  33. package/package.json +1 -1
  34. package/skills/loop-agent/references/command-reference.md +1 -0
@@ -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,8 @@ 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";
13
14
  const ARTIFACT_PREVIEW_MAX_BYTES = 64 * 1024;
14
15
  export function createObserveSnapshotCache() {
15
16
  return { expiresAt: 0 };
@@ -76,6 +77,11 @@ const ROUTES = [
76
77
  pattern: /^\/api\/dag-runs\/([^/]+)\/nodes\/([^/]+)\/spec-evidence$/,
77
78
  handler: handleDagNodeSpecEvidence,
78
79
  },
80
+ {
81
+ method: "GET",
82
+ pattern: /^\/api\/dag-runs\/([^/]+)\/nodes\/([^/]+)\/spec-evidence\/file$/,
83
+ handler: handleDagNodeSpecEvidenceFile,
84
+ },
79
85
  {
80
86
  method: "GET",
81
87
  pattern: /^\/api\/dag-runs\/([^/]+)$/,
@@ -706,6 +712,109 @@ function sendJson(res, status, body) {
706
712
  });
707
713
  res.end(payload);
708
714
  }
715
+ async function loadDagRunSourceBinding(repoRoot, dagRunId) {
716
+ const dagRunsRoot = path.resolve(repoRoot, ".harness", "dag-runs");
717
+ for (const lifecycle of ["active", "completed", "paused"]) {
718
+ const runJsonPath = path.join(dagRunsRoot, lifecycle, dagRunId, "run.json");
719
+ try {
720
+ const runRaw = await readFile(runJsonPath, "utf-8");
721
+ const runSpec = JSON.parse(runRaw);
722
+ const parsed = dagSourceBindingSchema.safeParse(runSpec.sourceBinding);
723
+ if (parsed.success)
724
+ return parsed.data;
725
+ return undefined;
726
+ }
727
+ catch {
728
+ // run.json may not exist; continue to next lifecycle
729
+ }
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) || !isSafeObservabilityIdentifier(nodeId)) {
743
+ sendJson(res, 400, { error: "Invalid dag run or node identifier" });
744
+ return;
745
+ }
746
+ const source = match.query.get("source");
747
+ const rawPath = match.query.get("path");
748
+ if (source !== "binding" && source !== "read") {
749
+ sendJson(res, 400, { error: "Invalid source" });
750
+ return;
751
+ }
752
+ if (!rawPath) {
753
+ sendJson(res, 400, { error: "path is required" });
754
+ return;
755
+ }
756
+ const maxBytes = ARTIFACT_PREVIEW_MAX_BYTES;
757
+ if (source === "binding") {
758
+ const sourceBinding = await loadDagRunSourceBinding(ctx.repoRoot, dagRunId);
759
+ const member = sourceBinding?.sources?.find((s) => s.path === rawPath);
760
+ if (!sourceBinding || !member) {
761
+ sendJson(res, 404, { error: "Path is not a bound source" });
762
+ return;
763
+ }
764
+ let resolved;
765
+ try {
766
+ resolved = await resolveRepoFilePreview(ctx.repoRoot, rawPath);
767
+ }
768
+ catch {
769
+ sendJson(res, 400, { error: "Invalid or unsafe path" });
770
+ return;
771
+ }
772
+ let raw;
773
+ try {
774
+ raw = await readFile(resolved.absPath, "utf-8");
775
+ }
776
+ catch {
777
+ sendJson(res, 404, { error: "File not found" });
778
+ return;
779
+ }
780
+ const redacted = redactSecrets(raw);
781
+ const content = truncateUtf8Preview(redacted, maxBytes);
782
+ const truncated = content !== redacted;
783
+ const currentSha256 = createHash("sha256")
784
+ .update(raw, "utf-8")
785
+ .digest("hex");
786
+ sendJson(res, 200, {
787
+ source: "binding",
788
+ path: rawPath,
789
+ sha256: member.sha256,
790
+ currentSha256,
791
+ hashMatch: currentSha256 === member.sha256,
792
+ content,
793
+ truncated,
794
+ contentBytes: Buffer.byteLength(content, "utf-8"),
795
+ maxBytes,
796
+ });
797
+ return;
798
+ }
799
+ // source === "read"
800
+ const record = await extractSpecReadContent(ctx.repoRoot, dagRunId, nodeId, rawPath);
801
+ if (!record) {
802
+ sendJson(res, 404, { error: "Path was not successfully read" });
803
+ return;
804
+ }
805
+ const redacted = redactSecrets(record.content);
806
+ const content = truncateUtf8Preview(redacted, maxBytes);
807
+ const truncated = content !== redacted;
808
+ sendJson(res, 200, {
809
+ source: "read",
810
+ path: rawPath,
811
+ content,
812
+ truncated,
813
+ contentBytes: Buffer.byteLength(content, "utf-8"),
814
+ maxBytes,
815
+ readAt: record.timestamp ?? null,
816
+ });
817
+ }
709
818
  async function handleDagNodeSpecEvidence(_req, res, match, ctx) {
710
819
  const dagRunId = match.params.id;
711
820
  const nodeId = match.params.sub;
@@ -715,24 +824,23 @@ async function handleDagNodeSpecEvidence(_req, res, match, ctx) {
715
824
  }
716
825
  // Extract skill injection info from the DAG run spec (run.json)
717
826
  const skillInjection = { skills: [], references: [] };
718
- let sourceBinding;
719
- const dagRunsRoot = path.resolve(ctx.repoRoot, ".harness", "dag-runs");
720
- for (const lifecycle of ["active", "completed", "paused"]) {
721
- const runJsonPath = path.join(dagRunsRoot, lifecycle, dagRunId, "run.json");
722
- try {
723
- const runRaw = await readFile(runJsonPath, "utf-8");
724
- 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;
827
+ const sourceBinding = await loadDagRunSourceBinding(ctx.repoRoot, dagRunId);
828
+ {
829
+ const dagRunsRoot = path.resolve(ctx.repoRoot, ".harness", "dag-runs");
830
+ for (const lifecycle of ["active", "completed", "paused"]) {
831
+ const runJsonPath = path.join(dagRunsRoot, lifecycle, dagRunId, "run.json");
832
+ try {
833
+ const runRaw = await readFile(runJsonPath, "utf-8");
834
+ const runSpec = JSON.parse(runRaw);
835
+ const task = runSpec.tasks?.find((t) => t.id === nodeId);
836
+ if (task?.skills && Array.isArray(task.skills)) {
837
+ skillInjection.skills = task.skills;
838
+ }
839
+ break;
840
+ }
841
+ catch {
842
+ // run.json may not exist; continue to next lifecycle
731
843
  }
732
- break;
733
- }
734
- catch {
735
- // run.json may not exist; continue to next lifecycle
736
844
  }
737
845
  }
738
846
  const evidence = await extractSpecEvidence(ctx.repoRoot, dagRunId, nodeId);
@@ -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;
@@ -10,12 +10,14 @@ import {
10
10
  UI_TEXT,
11
11
  } from "../constants.js";
12
12
  import { clearNode, el } from "../dom.js";
13
- import { fetchJson } from "../api.js";
13
+ import { fetchJson, fetchSpecEvidenceFile } from "../api.js";
14
14
  import { badge } from "../format.js";
15
15
  import { renderMarkdown } from "../markdown-render.js";
16
16
  import {
17
17
  uiState,
18
18
  makeSessionEventIdentity,
19
+ openSpecEvidenceDetail,
20
+ closeSpecEvidenceDetail,
19
21
  updateDagTimelineViewportState,
20
22
  updateDagOutputViewportState,
21
23
  computeFollowLatest,
@@ -44,6 +46,10 @@ export function selectDagNode(dagRunId, nodeId) {
44
46
  if (nodeChanged) {
45
47
  uiState.sessionEventOffset = 0;
46
48
  uiState.sessionEvents = [];
49
+ // Drop any open spec-evidence preview so a stale A detail cannot survive
50
+ // into node B's spec-evidence panel (AC-007 regression guard).
51
+ uiState.specEvidenceDetail = null;
52
+ uiState.specEvidenceListEvidence = null;
47
53
  // Bump the session-event identity token so any in-flight response for the
48
54
  // previous node is rejected before it can mutate the current node cache.
49
55
  uiState.sessionEventIdentity = makeSessionEventIdentity(dagRunId, nodeId);
@@ -188,15 +194,20 @@ export function captureDagTimelineViewportState(panel) {
188
194
  }
189
195
  }
190
196
 
191
- async function renderSpecEvidence(content, dagRunId, nodeId) {
192
- const evidence = await fetchJson(
197
+ async function renderSpecEvidence(content, dagRunId, nodeId, cachedEvidence) {
198
+ const evidence = cachedEvidence ?? await fetchJson(
193
199
  `/api/dag-runs/${encodeURIComponent(dagRunId)}/nodes/${encodeURIComponent(nodeId)}/spec-evidence`,
194
200
  );
195
201
  if (!content.isConnected || content.dataset.dagNodeId !== String(nodeId)) return;
202
+ if (uiState.specEvidenceDetail) {
203
+ renderSpecEvidenceDetail(content, dagRunId, nodeId);
204
+ return;
205
+ }
196
206
  if (!evidence) {
197
207
  content.appendChild(el("p", "empty", "无法加载规范证据。"));
198
208
  return;
199
209
  }
210
+ uiState.specEvidenceListEvidence = evidence;
200
211
 
201
212
  const statusLabels = {
202
213
  "spec-read": "已读取规范",
@@ -241,9 +252,24 @@ async function renderSpecEvidence(content, dagRunId, nodeId) {
241
252
  });
242
253
  appendListSection("已绑定任务源", evidence.sourceBinding?.sources, (source) => {
243
254
  const item = el("li", null);
255
+ const button = document.createElement("button");
256
+ button.type = "button";
257
+ button.className = "spec-evidence-file-btn";
258
+ button.setAttribute("data-source", "binding");
259
+ button.setAttribute("data-path", source.path);
260
+ button.id = `spec-evidence-btn-binding-${source.path}`;
244
261
  const icon = el("i", "ri-links-line");
245
262
  icon.setAttribute("aria-hidden", "true");
246
- item.append(icon, document.createTextNode(` ${source.kind}: `), el("code", null, source.path), document.createTextNode(` · sha256 ${source.sha256.slice(0, 12)}…`));
263
+ button.append(
264
+ icon,
265
+ document.createTextNode(` ${source.kind}: `),
266
+ el("code", null, source.path),
267
+ document.createTextNode(` · sha256 ${source.sha256.slice(0, 12)}…`),
268
+ );
269
+ button.addEventListener("click", () =>
270
+ onSpecEvidenceFileClick(content, dagRunId, nodeId, "binding", source.path, button),
271
+ );
272
+ item.appendChild(button);
247
273
  return item;
248
274
  });
249
275
  appendListSection("显式需求编号", evidence.sourceBinding?.requirementIds, (id) => el("li", null, id));
@@ -252,11 +278,20 @@ async function renderSpecEvidence(content, dagRunId, nodeId) {
252
278
  evidence.specReads,
253
279
  (read) => {
254
280
  const item = el("li", null);
281
+ const button = document.createElement("button");
282
+ button.type = "button";
283
+ button.className = "spec-evidence-file-btn";
284
+ button.setAttribute("data-source", "read");
285
+ button.setAttribute("data-path", read.path);
286
+ if (read.timestamp) {
287
+ button.setAttribute("data-read-at", read.timestamp);
288
+ }
289
+ button.id = `spec-evidence-btn-read-${read.path}`;
255
290
  const icon = el("i", "ri-file-text-line");
256
291
  icon.setAttribute("aria-hidden", "true");
257
- item.append(icon, el("code", null, read.path));
292
+ button.append(icon, el("code", null, read.path));
258
293
  if (read.timestamp) {
259
- item.appendChild(
294
+ button.appendChild(
260
295
  el(
261
296
  "span",
262
297
  "spec-evidence-time",
@@ -264,6 +299,10 @@ async function renderSpecEvidence(content, dagRunId, nodeId) {
264
299
  ),
265
300
  );
266
301
  }
302
+ button.addEventListener("click", () =>
303
+ onSpecEvidenceFileClick(content, dagRunId, nodeId, "read", read.path, button),
304
+ );
305
+ item.appendChild(button);
267
306
  return item;
268
307
  },
269
308
  );
@@ -308,7 +347,135 @@ async function renderSpecEvidence(content, dagRunId, nodeId) {
308
347
  }
309
348
  }
310
349
 
350
+ /**
351
+ * Click handler for spec-evidence file buttons (AC-001/002). Opens the detail
352
+ * view in the same spec-evidence panel (no modal), records the trigger button
353
+ * so the back action can restore keyboard focus (AC-005).
354
+ */
355
+ function onSpecEvidenceFileClick(content, dagRunId, nodeId, source, relPath, button) {
356
+ openSpecEvidenceDetail(source, relPath, button);
357
+ clearNode(content);
358
+ const detail = uiState.specEvidenceDetail;
359
+ renderSpecEvidenceDetail(content, dagRunId, nodeId);
360
+ void (async () => {
361
+ try {
362
+ const data = await fetchSpecEvidenceFile(dagRunId, nodeId, source, relPath);
363
+ if (
364
+ !content.isConnected ||
365
+ content.dataset.dagNodeId !== String(nodeId) ||
366
+ !uiState.specEvidenceDetail ||
367
+ uiState.specEvidenceDetail.source !== source ||
368
+ uiState.specEvidenceDetail.path !== relPath
369
+ ) {
370
+ return;
371
+ }
372
+ detail.loading = false;
373
+ detail.data = data;
374
+ } catch (error) {
375
+ if (
376
+ !content.isConnected ||
377
+ content.dataset.dagNodeId !== String(nodeId) ||
378
+ !uiState.specEvidenceDetail ||
379
+ uiState.specEvidenceDetail.source !== source ||
380
+ uiState.specEvidenceDetail.path !== relPath
381
+ ) {
382
+ return;
383
+ }
384
+ detail.loading = false;
385
+ detail.error = error instanceof Error ? error.message : String(error);
386
+ }
387
+ renderSpecEvidenceDetail(content, dagRunId, nodeId);
388
+ })();
389
+ }
390
+
391
+ /**
392
+ * Render the in-inspector file preview (AC-005). Single scroll container for
393
+ * the body, always-visible back button, and accessible loading/error states.
394
+ */
395
+ function renderSpecEvidenceDetail(content, dagRunId, nodeId) {
396
+ const detail = uiState.specEvidenceDetail;
397
+ clearNode(content);
398
+ if (!detail) {
399
+ renderSpecEvidence(content, dagRunId, nodeId, uiState.specEvidenceListEvidence);
400
+ return;
401
+ }
402
+
403
+ const wrapper = el("div", "spec-evidence-detail");
404
+ const back = document.createElement("button");
405
+ back.type = "button";
406
+ back.className = "spec-evidence-back";
407
+ back.setAttribute("aria-label", "返回规范证据");
408
+ const backIcon = el("i", "ri-arrow-left-line");
409
+ backIcon.setAttribute("aria-hidden", "true");
410
+ back.append(backIcon, document.createTextNode(" 返回规范证据"));
411
+ back.addEventListener("click", () => {
412
+ const closed = closeSpecEvidenceDetail();
413
+ clearNode(content);
414
+ // Restore the list synchronously from the cached evidence so the user
415
+ // sees no flicker and the trigger button is immediately focusable.
416
+ renderSpecEvidence(content, dagRunId, nodeId, uiState.specEvidenceListEvidence);
417
+ if (closed?.triggerId) {
418
+ const trigger = document.getElementById(closed.triggerId);
419
+ trigger?.focus();
420
+ }
421
+ });
422
+ wrapper.appendChild(back);
423
+
424
+ const meta = el("div", "spec-evidence-detail-meta");
425
+ meta.appendChild(el("code", null, detail.path));
426
+ if (detail.source === "binding" && detail.data) {
427
+ meta.appendChild(
428
+ el(
429
+ "span",
430
+ "spec-evidence-detail-hash",
431
+ `绑定 sha256 ${String(detail.data.sha256 ?? "").slice(0, 12)}… · 当前 ${detail.data.hashMatch ? "一致" : "不一致"}`,
432
+ ),
433
+ );
434
+ } else if (detail.source === "read" && detail.data?.readAt) {
435
+ meta.appendChild(
436
+ el(
437
+ "span",
438
+ "spec-evidence-detail-time",
439
+ `读取于 ${formatSessionEventTime({ timestamp: detail.data.readAt })}`,
440
+ ),
441
+ );
442
+ }
443
+ wrapper.appendChild(meta);
444
+
445
+ if (detail.loading) {
446
+ const loading = el("p", "muted", "正在加载文件内容…");
447
+ loading.setAttribute("role", "status");
448
+ loading.setAttribute("aria-live", "polite");
449
+ wrapper.appendChild(loading);
450
+ } else if (detail.error) {
451
+ const error = el("p", "spec-evidence-detail-error", `加载失败:${detail.error}`);
452
+ error.setAttribute("role", "alert");
453
+ wrapper.appendChild(error);
454
+ } else if (detail.data) {
455
+ const body = el("pre", "spec-evidence-detail-content");
456
+ body.textContent = detail.data.content ?? "";
457
+ wrapper.appendChild(body);
458
+ if (detail.data.truncated) {
459
+ const note = el(
460
+ "p",
461
+ "spec-evidence-detail-truncated",
462
+ `已截断,仅显示前 ${detail.data.maxBytes ?? 0} 字节。`,
463
+ );
464
+ wrapper.appendChild(note);
465
+ }
466
+ }
467
+ content.appendChild(wrapper);
468
+ }
469
+
311
470
  function fillDagInspectorContent(content, dagRunId, node) {
471
+ const previousRenderedNode = content.dataset.dagNodeId ?? "";
472
+ const nodeChanged = previousRenderedNode !== String(node.nodeId ?? "");
473
+ if (nodeChanged) {
474
+ // Drop any open spec-evidence preview + cached list so a stale A detail
475
+ // cannot survive into node B (AC-007 regression guard).
476
+ uiState.specEvidenceDetail = null;
477
+ uiState.specEvidenceListEvidence = null;
478
+ }
312
479
  const activeTab = ["timeline", "spec-evidence"].includes(
313
480
  uiState.dagInspectorTab,
314
481
  )