@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.
- package/CHANGELOG.md +20 -0
- package/dist/cli/command-definitions.js +13 -0
- package/dist/cli/program.js +4 -0
- package/dist/commands/coverage-report.js +50 -0
- package/dist/executors/dag-pi-executor.js +63 -9
- package/dist/executors/shell-executor.js +30 -0
- package/dist/executors/shell-write-guard.js +64 -2
- package/dist/worker/delivery/git-transaction.js +43 -8
- package/dist/worker/observe/paths.js +81 -0
- package/dist/worker/observe/routes.js +127 -19
- package/dist/worker/observe/spec-evidence.js +84 -0
- package/dist/worker/observe/static/api.js +23 -0
- package/dist/worker/observe/static/state.js +26 -0
- package/dist/worker/observe/static/styles.css +10 -0
- package/dist/worker/observe/static/views/dag-inspector.js +173 -6
- package/dist/workflows/dag/backend-test-coverage-contract.js +202 -0
- package/dist/workflows/dag/backend-test-execution-contract.js +84 -18
- package/dist/workflows/dag/backend-test-stability-contract.js +57 -0
- package/dist/workflows/dag/init-hybrid.js +150 -13
- package/dist/workflows/dag/l5-report-metrics.js +36 -0
- package/dist/workflows/dag/node-execution.js +32 -5
- package/dist/workflows/dag/project-governance-context.js +508 -0
- package/dist/workflows/dag/prompt.js +46 -1
- package/dist/workflows/dag/skill-snapshot.js +1 -0
- package/dist/workflows/dag/types.js +10 -0
- package/dist/workflows/dag/validate.js +28 -0
- package/docs/architecture/evolution.md +3 -1
- package/docs/templates/agent-dag.schema.json +15 -0
- package/docs/templates/agent-dag.supervised-implementation.json +1 -0
- package/docs/templates/backend-test-dag.generate-pytest.prompt.md +2 -0
- package/docs/templates/backend-test-dag.json +39 -6
- package/docs/templates/backend-test-dag.retrospect.prompt.md +36 -7
- package/package.json +1 -1
- 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
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
const
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
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
|
-
|
|
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
|
-
|
|
292
|
+
button.append(icon, el("code", null, read.path));
|
|
258
293
|
if (read.timestamp) {
|
|
259
|
-
|
|
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
|
)
|