@tea-agent/loop-agent 0.12.0 → 0.13.0-alpha.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.
- package/CHANGELOG.md +50 -2
- package/README.md +30 -2
- package/dist/application/dag/generate-task-dag.js +30 -0
- package/dist/cli/command-definitions.js +10 -3
- package/dist/commands/knowledge.js +129 -31
- package/dist/governance/manifest-types.js +3 -0
- package/dist/task/config-types.js +5 -1
- package/dist/worker/cli.js +96 -1
- package/dist/worker/delivery/package.js +3 -3
- package/dist/worker/feature/decision-loader.js +37 -6
- package/dist/worker/feature/next-action.js +10 -2
- package/dist/worker/feature/ready-plan-projection.js +81 -0
- package/dist/worker/feature/reducer.js +2 -1
- package/dist/worker/feature/review.js +19 -2
- package/dist/worker/feature/run.js +27 -2
- package/dist/worker/follow-up/approve.js +5 -2
- package/dist/worker/follow-up/factory.js +1 -1
- package/dist/worker/observability/read-model.js +246 -41
- package/dist/worker/observe/routes.js +158 -15
- package/dist/worker/observe/spec-evidence.js +281 -0
- package/dist/worker/observe/static/api.js +19 -0
- package/dist/worker/observe/static/app.js +2 -2
- package/dist/worker/observe/static/relations.js +17 -12
- package/dist/worker/observe/static/router.js +8 -0
- package/dist/worker/observe/static/styles.css +12 -0
- package/dist/worker/observe/static/views/batch.js +3 -2
- package/dist/worker/observe/static/views/dag-inspector.js +123 -4
- package/dist/worker/observe/static/views/dashboard.js +8 -5
- package/dist/worker/observe/static/views/feature.js +43 -4
- package/dist/worker/observe/static/views/pool.js +5 -2
- package/dist/worker/observe/static/views/run.js +1 -1
- package/dist/worker/observe/static/views/task.js +69 -15
- package/dist/worker/pool/doctor.js +165 -0
- package/dist/worker/pool/migrate-state.js +303 -0
- package/dist/worker/pool/run-store.js +205 -17
- package/dist/worker/pool/types.js +17 -1
- package/dist/worker/pool/validation.js +100 -15
- package/dist/worker/report/morning-report.js +12 -2
- package/dist/worker/runner/run-ready.js +41 -26
- package/dist/worker/task-graph/ready-planner.js +136 -0
- package/dist/workflows/dag/convergence/controller.js +16 -8
- package/dist/workflows/dag/failure-routing.js +12 -1
- package/dist/workflows/dag/init-hybrid.js +837 -8
- package/dist/workflows/dag/types.js +1 -0
- package/docs/README.md +1 -1
- package/docs/agent-dag-recovery-playbook.md +9 -0
- package/docs/architecture/evolution.md +4 -3
- package/docs/architecture/facts-and-state.md +14 -1
- package/docs/architecture/worker-and-feature.md +6 -2
- package/docs/decisions/README.md +3 -0
- package/docs/design/README.md +8 -0
- package/docs/exec-plans/active/README.md +2 -0
- package/docs/exec-plans/completed/README.md +3 -2
- package/docs/feature-workflow.md +80 -2
- package/docs/loop-agent-harness.md +15 -4
- package/docs/progress/README.md +4 -0
- package/docs/reports/README.md +6 -0
- package/docs/templates/backend-test-dag.json +12 -0
- package/docs/templates/knowledge-graph-bootstrap-dag.json +118 -0
- package/docs/templates/knowledge-sync-dag.json +177 -0
- package/docs/templates/knowledge-sync-draft.schema.json +71 -0
- package/docs/verification-matrix.md +2 -1
- package/package.json +8 -2
- package/scripts/kb-bootstrap-init-skeleton.sh +239 -0
- package/scripts/kb-graph-incremental-prepare.mjs +372 -0
- package/scripts/kb-graph-incremental-prepare.sh +5 -0
- package/scripts/kb-graph-materialize.mjs +105 -0
- package/scripts/kb-graph-materialize.sh +4 -0
- package/scripts/kb-graph-promote.mjs +153 -0
- package/scripts/kb-graph-promote.sh +4 -0
- package/scripts/kb-query.mjs +554 -0
- package/scripts/kb-query.sh +5 -0
- package/skills/agent-worker/SKILL.md +3 -1
- package/skills/agent-worker/references/agent-worker-operator.md +18 -1
- package/skills/frontend-design-review/SKILL.md +26 -24
- package/skills/frontend-implementation/SKILL.md +29 -26
- package/skills/frontend-implementation/references/node-contracts.md +50 -19
- package/skills/frontend-review/SKILL.md +1 -1
- package/skills/loop-agent/references/command-reference.md +1 -0
|
@@ -5,9 +5,10 @@ import { redactSecrets, truncateUtf8Preview } from "../../shared/preview.js";
|
|
|
5
5
|
import { parseWorkerEventLine } from "../observability/events.js";
|
|
6
6
|
import { isSafeObservabilityIdentifier } from "../observability/event-store.js";
|
|
7
7
|
import { clampEventHistoryLimit, listBatchEventHistory, listPoolEventHistory, } from "../observability/event-history.js";
|
|
8
|
-
import { buildGlobalSnapshot, clampTaskRunHistoryLimit, listTaskRunHistory, } from "../observability/read-model.js";
|
|
8
|
+
import { buildGlobalSnapshot, clampTaskRunHistoryLimit, listTaskRunHistory, resolveLegacyTask, } from "../observability/read-model.js";
|
|
9
9
|
import { getTaskPoolRoot } from "../pool/run-store.js";
|
|
10
10
|
import { isAllowedArtifactTextPath, resolveArtifactPath, toRepoRelativeArtifactPath, } from "./paths.js";
|
|
11
|
+
import { extractSpecEvidence, } from "./spec-evidence.js";
|
|
11
12
|
const ARTIFACT_PREVIEW_MAX_BYTES = 64 * 1024;
|
|
12
13
|
export function createObserveSnapshotCache() {
|
|
13
14
|
return { expiresAt: 0 };
|
|
@@ -32,6 +33,17 @@ const ROUTES = [
|
|
|
32
33
|
*/
|
|
33
34
|
{ method: "GET", pattern: /^\/api\/events$/, handler: handlePoolEvents },
|
|
34
35
|
{ method: "GET", pattern: /^\/api\/tasks$/, handler: handleTasks },
|
|
36
|
+
// Canonical feature-scoped task routes (must precede legacy /api/tasks/:id).
|
|
37
|
+
{
|
|
38
|
+
method: "GET",
|
|
39
|
+
pattern: /^\/api\/features\/([^/]+)\/tasks\/([^/]+)\/runs$/,
|
|
40
|
+
handler: handleFeatureTaskRuns,
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
method: "GET",
|
|
44
|
+
pattern: /^\/api\/features\/([^/]+)\/tasks\/([^/]+)$/,
|
|
45
|
+
handler: handleFeatureTaskById,
|
|
46
|
+
},
|
|
35
47
|
{
|
|
36
48
|
method: "GET",
|
|
37
49
|
pattern: /^\/api\/tasks\/([^/]+)\/runs$/,
|
|
@@ -58,6 +70,11 @@ const ROUTES = [
|
|
|
58
70
|
pattern: /^\/api\/dag-runs\/([^/]+)\/nodes\/([^/]+)\/session-events$/,
|
|
59
71
|
handler: handleDagNodeSessionEvents,
|
|
60
72
|
},
|
|
73
|
+
{
|
|
74
|
+
method: "GET",
|
|
75
|
+
pattern: /^\/api\/dag-runs\/([^/]+)\/nodes\/([^/]+)\/spec-evidence$/,
|
|
76
|
+
handler: handleDagNodeSpecEvidence,
|
|
77
|
+
},
|
|
61
78
|
{
|
|
62
79
|
method: "GET",
|
|
63
80
|
pattern: /^\/api\/dag-runs\/([^/]+)$/,
|
|
@@ -208,15 +225,71 @@ async function handleTasks(_req, res, _match, ctx) {
|
|
|
208
225
|
const snapshot = await getSnapshot(ctx);
|
|
209
226
|
sendJson(res, 200, snapshot.tasks);
|
|
210
227
|
}
|
|
211
|
-
async function
|
|
228
|
+
async function handleFeatureTaskById(_req, res, match, ctx) {
|
|
229
|
+
const resolved = resolveFeatureTaskParams(match);
|
|
230
|
+
if (!resolved) {
|
|
231
|
+
sendJson(res, 400, { error: "Invalid task identifier" });
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
if (!isSafeObservabilityIdentifier(resolved.featureId) ||
|
|
235
|
+
!isSafeObservabilityIdentifier(resolved.taskId)) {
|
|
236
|
+
sendJson(res, 400, { error: "Invalid task identifier" });
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
212
239
|
const snapshot = await getSnapshot(ctx);
|
|
213
|
-
const task = snapshot.tasks.find((t) => t.taskId ===
|
|
240
|
+
const task = snapshot.tasks.find((t) => t.featureId === resolved.featureId && t.taskId === resolved.taskId);
|
|
214
241
|
if (!task) {
|
|
215
242
|
sendJson(res, 404, { error: "Task not found" });
|
|
216
243
|
return;
|
|
217
244
|
}
|
|
218
245
|
sendJson(res, 200, task);
|
|
219
246
|
}
|
|
247
|
+
async function handleFeatureTaskRuns(_req, res, match, ctx) {
|
|
248
|
+
const resolved = resolveFeatureTaskParams(match);
|
|
249
|
+
if (!resolved) {
|
|
250
|
+
sendJson(res, 400, { error: "Invalid task identifier" });
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
if (!isSafeObservabilityIdentifier(resolved.featureId) ||
|
|
254
|
+
!isSafeObservabilityIdentifier(resolved.taskId)) {
|
|
255
|
+
sendJson(res, 400, { error: "Invalid task identifier" });
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
const limit = clampTaskRunHistoryLimit(parsePositiveInt(match.query.get("limit"), 0) || undefined);
|
|
259
|
+
const before = match.query.get("before");
|
|
260
|
+
const page = await listTaskRunHistory(ctx.repoRoot, resolved.featureId, resolved.taskId, {
|
|
261
|
+
limit,
|
|
262
|
+
before: before && before.length > 0 ? before : null,
|
|
263
|
+
});
|
|
264
|
+
// 404 only when neither snapshot row nor ledger history exists for this composite id.
|
|
265
|
+
if (page.runs.length === 0) {
|
|
266
|
+
const snapshot = await getSnapshot(ctx);
|
|
267
|
+
const known = snapshot.tasks.some((t) => t.featureId === resolved.featureId && t.taskId === resolved.taskId);
|
|
268
|
+
if (!known) {
|
|
269
|
+
sendJson(res, 404, { error: "Task not found" });
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
sendJson(res, 200, page);
|
|
274
|
+
}
|
|
275
|
+
async function handleTaskById(_req, res, match, ctx) {
|
|
276
|
+
const taskId = match.params.id;
|
|
277
|
+
if (!isSafeObservabilityIdentifier(taskId)) {
|
|
278
|
+
sendJson(res, 400, { error: "Invalid task identifier" });
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
const snapshot = await getSnapshot(ctx);
|
|
282
|
+
const resolved = resolveLegacyTask(snapshot.tasks, taskId);
|
|
283
|
+
if (resolved.kind === "missing") {
|
|
284
|
+
sendJson(res, 404, { error: "Task not found" });
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
if (resolved.kind === "ambiguous") {
|
|
288
|
+
sendJson(res, 409, ambiguousTaskBody(taskId, resolved.candidates));
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
sendJson(res, 200, resolved.task);
|
|
292
|
+
}
|
|
220
293
|
async function handleTaskRuns(_req, res, match, ctx) {
|
|
221
294
|
const taskId = match.params.id;
|
|
222
295
|
if (!isSafeObservabilityIdentifier(taskId)) {
|
|
@@ -224,26 +297,45 @@ async function handleTaskRuns(_req, res, match, ctx) {
|
|
|
224
297
|
return;
|
|
225
298
|
}
|
|
226
299
|
const snapshot = await getSnapshot(ctx);
|
|
227
|
-
const
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
300
|
+
const resolved = resolveLegacyTask(snapshot.tasks, taskId);
|
|
301
|
+
if (resolved.kind === "ambiguous") {
|
|
302
|
+
sendJson(res, 409, ambiguousTaskBody(taskId, resolved.candidates));
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
if (resolved.kind === "missing") {
|
|
306
|
+
// No snapshot row: do not guess featureId for history.
|
|
307
|
+
sendJson(res, 404, { error: "Task not found" });
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
const featureId = resolved.task.featureId;
|
|
311
|
+
if (!featureId) {
|
|
312
|
+
sendJson(res, 404, { error: "Task not found" });
|
|
313
|
+
return;
|
|
238
314
|
}
|
|
239
315
|
const limit = clampTaskRunHistoryLimit(parsePositiveInt(match.query.get("limit"), 0) || undefined);
|
|
240
316
|
const before = match.query.get("before");
|
|
241
|
-
const page = await listTaskRunHistory(ctx.repoRoot, taskId, {
|
|
317
|
+
const page = await listTaskRunHistory(ctx.repoRoot, featureId, taskId, {
|
|
242
318
|
limit,
|
|
243
319
|
before: before && before.length > 0 ? before : null,
|
|
244
320
|
});
|
|
245
321
|
sendJson(res, 200, page);
|
|
246
322
|
}
|
|
323
|
+
function ambiguousTaskBody(taskId, candidates) {
|
|
324
|
+
return {
|
|
325
|
+
error: "Task identity ambiguous",
|
|
326
|
+
taskId,
|
|
327
|
+
candidates,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
/** Extract featureId/taskId from /api/features/:featureId/tasks/:taskId[...]. */
|
|
331
|
+
function resolveFeatureTaskParams(match) {
|
|
332
|
+
// handleRequest maps capture groups to params.id (group 1) and params.sub (group 2).
|
|
333
|
+
const featureId = match.params.featureId ?? match.params.id;
|
|
334
|
+
const taskId = match.params.taskId ?? match.params.sub;
|
|
335
|
+
if (!featureId || !taskId)
|
|
336
|
+
return null;
|
|
337
|
+
return { featureId, taskId };
|
|
338
|
+
}
|
|
247
339
|
async function handleRunById(_req, res, match, ctx) {
|
|
248
340
|
if (!isSafeObservabilityIdentifier(match.params.id)) {
|
|
249
341
|
sendJson(res, 400, { error: "Invalid run identifier" });
|
|
@@ -613,6 +705,57 @@ function sendJson(res, status, body) {
|
|
|
613
705
|
});
|
|
614
706
|
res.end(payload);
|
|
615
707
|
}
|
|
708
|
+
async function handleDagNodeSpecEvidence(_req, res, match, ctx) {
|
|
709
|
+
const dagRunId = match.params.id;
|
|
710
|
+
const nodeId = match.params.sub;
|
|
711
|
+
if (!isSafeObservabilityIdentifier(dagRunId) || !isSafeObservabilityIdentifier(nodeId)) {
|
|
712
|
+
sendJson(res, 400, { error: "Invalid dag run or node identifier" });
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
// Extract skill injection info from the DAG run spec (run.json)
|
|
716
|
+
const skillInjection = { skills: [], references: [] };
|
|
717
|
+
const dagRunsRoot = path.resolve(ctx.repoRoot, ".harness", "dag-runs");
|
|
718
|
+
for (const lifecycle of ["active", "completed", "paused"]) {
|
|
719
|
+
const runJsonPath = path.join(dagRunsRoot, lifecycle, dagRunId, "run.json");
|
|
720
|
+
try {
|
|
721
|
+
const runRaw = await readFile(runJsonPath, "utf-8");
|
|
722
|
+
const runSpec = JSON.parse(runRaw);
|
|
723
|
+
const task = runSpec.tasks?.find((t) => t.id === nodeId);
|
|
724
|
+
if (task?.skills && Array.isArray(task.skills)) {
|
|
725
|
+
skillInjection.skills = task.skills;
|
|
726
|
+
}
|
|
727
|
+
break;
|
|
728
|
+
}
|
|
729
|
+
catch {
|
|
730
|
+
// run.json may not exist; continue to next lifecycle
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
const evidence = await extractSpecEvidence(ctx.repoRoot, dagRunId, nodeId);
|
|
734
|
+
if (!evidence) {
|
|
735
|
+
sendJson(res, 200, {
|
|
736
|
+
dagRunId,
|
|
737
|
+
nodeId,
|
|
738
|
+
status: "no-evidence",
|
|
739
|
+
skillInjection: { skills: [], references: [] },
|
|
740
|
+
specReads: [],
|
|
741
|
+
specSearches: [],
|
|
742
|
+
knowledgeBaseQueries: [],
|
|
743
|
+
summary: "未找到该节点的 session events 记录。",
|
|
744
|
+
});
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
evidence.skillInjection = skillInjection;
|
|
748
|
+
// Recompute status considering skill injection
|
|
749
|
+
if (evidence.specReads.length === 0 && evidence.knowledgeBaseQueries.length === 0) {
|
|
750
|
+
if (evidence.specSearches.length > 0) {
|
|
751
|
+
evidence.status = "search-only";
|
|
752
|
+
}
|
|
753
|
+
else if (skillInjection.skills.length > 0) {
|
|
754
|
+
evidence.status = "spec-injected";
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
sendJson(res, 200, evidence);
|
|
758
|
+
}
|
|
616
759
|
function contentTypeFor(pathname) {
|
|
617
760
|
if (pathname.endsWith(".html"))
|
|
618
761
|
return "text/html; charset=utf-8";
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { isSafeObservabilityIdentifier } from "../observability/event-store.js";
|
|
5
|
+
const DAG_RUN_LIFECYCLE_DIRS = ["active", "completed", "paused"];
|
|
6
|
+
/**
|
|
7
|
+
* Known knowledge-base connector tool names.
|
|
8
|
+
*/
|
|
9
|
+
const KB_CONNECTOR_TOOLS = new Set([
|
|
10
|
+
"knowledge-base-query",
|
|
11
|
+
"kb_query",
|
|
12
|
+
"kb-search",
|
|
13
|
+
"knowledge_base_search",
|
|
14
|
+
"rag_search",
|
|
15
|
+
"vector_search",
|
|
16
|
+
"mcp__knowledge",
|
|
17
|
+
"mcp__kb",
|
|
18
|
+
]);
|
|
19
|
+
/**
|
|
20
|
+
* Pattern for detecting spec-related files:
|
|
21
|
+
* - openSpec/** files
|
|
22
|
+
* - *.spec.md / *.spec.ts / *.spec.tsx
|
|
23
|
+
* - project-specs/**
|
|
24
|
+
* - design-spec.md, code-standards.md, review-checklist.md, etc.
|
|
25
|
+
* - SKILL.md files
|
|
26
|
+
* - docs/** specification files
|
|
27
|
+
*/
|
|
28
|
+
const SPEC_FILE_PATTERNS = [
|
|
29
|
+
/openspec\//i,
|
|
30
|
+
/\/openSpec\//i,
|
|
31
|
+
/\/project-specs\//i,
|
|
32
|
+
/\/spec\//i,
|
|
33
|
+
/\.spec\.(md|tsx?|jsx?)$/i,
|
|
34
|
+
/\bdesign-spec\b/i,
|
|
35
|
+
/\bcode-standards\b/i,
|
|
36
|
+
/\breview-checklist\b/i,
|
|
37
|
+
/\bverification-checklist\b/i,
|
|
38
|
+
/\bnode-contracts\b/i,
|
|
39
|
+
/\breview-findings\b/i,
|
|
40
|
+
/\bSKILL\.md$/i,
|
|
41
|
+
/\bskills\//i,
|
|
42
|
+
/\bdocs\/design\//i,
|
|
43
|
+
/\bdocs\/templates\//i,
|
|
44
|
+
];
|
|
45
|
+
function isSpecFilePath(filePath) {
|
|
46
|
+
return SPEC_FILE_PATTERNS.some((pattern) => pattern.test(filePath));
|
|
47
|
+
}
|
|
48
|
+
function isKnowledgeBaseTool(toolName) {
|
|
49
|
+
return KB_CONNECTOR_TOOLS.has(toolName);
|
|
50
|
+
}
|
|
51
|
+
function resolveSessionEventsPath(repoRoot, dagRunId, nodeId) {
|
|
52
|
+
if (!isSafeObservabilityIdentifier(dagRunId) || !isSafeObservabilityIdentifier(nodeId)) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
const dagRunsRoot = path.resolve(repoRoot, ".harness", "dag-runs");
|
|
56
|
+
const candidates = [];
|
|
57
|
+
for (const lifecycle of DAG_RUN_LIFECYCLE_DIRS) {
|
|
58
|
+
candidates.push(path.join(dagRunsRoot, lifecycle, dagRunId, nodeId, "session-events.jsonl"));
|
|
59
|
+
}
|
|
60
|
+
for (const candidate of candidates) {
|
|
61
|
+
const resolved = path.resolve(candidate);
|
|
62
|
+
if (!isPathInside(dagRunsRoot, resolved))
|
|
63
|
+
continue;
|
|
64
|
+
if (!isExpectedSessionEventsRelativePath(dagRunsRoot, resolved, dagRunId, nodeId)) {
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (existsSync(resolved))
|
|
68
|
+
return resolved;
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
function isExpectedSessionEventsRelativePath(dagRunsRoot, resolvedPath, dagRunId, nodeId) {
|
|
73
|
+
const relative = path.relative(dagRunsRoot, resolvedPath);
|
|
74
|
+
const parts = relative.split(path.sep).filter(Boolean);
|
|
75
|
+
return (parts.length === 4 &&
|
|
76
|
+
DAG_RUN_LIFECYCLE_DIRS.includes(parts[0]) &&
|
|
77
|
+
parts[1] === dagRunId &&
|
|
78
|
+
parts[2] === nodeId &&
|
|
79
|
+
parts[3] === "session-events.jsonl");
|
|
80
|
+
}
|
|
81
|
+
function isPathInside(root, target) {
|
|
82
|
+
const normalizedRoot = root.endsWith(path.sep) ? root : `${root}${path.sep}`;
|
|
83
|
+
const normalizedTarget = path.normalize(target);
|
|
84
|
+
return normalizedTarget === root || normalizedTarget.startsWith(normalizedRoot);
|
|
85
|
+
}
|
|
86
|
+
function resolveToolName(event) {
|
|
87
|
+
return (event.toolName ?? "").toLowerCase();
|
|
88
|
+
}
|
|
89
|
+
function eventTimestamp(event) {
|
|
90
|
+
return event.timestamp ?? event.at;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Parse a tool input path to a repo-relative path.
|
|
94
|
+
* Handles both absolute paths and paths that are already repo-relative.
|
|
95
|
+
*/
|
|
96
|
+
function toRepoRelative(repoRoot, filePath) {
|
|
97
|
+
if (!filePath)
|
|
98
|
+
return null;
|
|
99
|
+
try {
|
|
100
|
+
const resolved = path.resolve(repoRoot, filePath);
|
|
101
|
+
const rel = path.relative(repoRoot, resolved);
|
|
102
|
+
if (rel === "" || rel.startsWith("..") || path.isAbsolute(rel)) {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
return rel.replaceAll(path.sep, "/");
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
async function readSessionEvents(filePath) {
|
|
112
|
+
try {
|
|
113
|
+
const raw = await readFile(filePath, "utf-8");
|
|
114
|
+
return raw
|
|
115
|
+
.split("\n")
|
|
116
|
+
.map((line) => line.trim())
|
|
117
|
+
.filter(Boolean)
|
|
118
|
+
.map((line) => {
|
|
119
|
+
try {
|
|
120
|
+
return JSON.parse(line);
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
})
|
|
126
|
+
.filter((event) => event !== null);
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return [];
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
export async function extractSpecEvidence(repoRoot, dagRunId, nodeId) {
|
|
133
|
+
if (!isSafeObservabilityIdentifier(dagRunId) || !isSafeObservabilityIdentifier(nodeId)) {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
const eventsPath = resolveSessionEventsPath(repoRoot, dagRunId, nodeId);
|
|
137
|
+
if (!eventsPath) {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
const events = await readSessionEvents(eventsPath);
|
|
141
|
+
// Track successful read calls with their paths (dedup by path)
|
|
142
|
+
const readPaths = new Map();
|
|
143
|
+
const searches = [];
|
|
144
|
+
const kbQueries = [];
|
|
145
|
+
// Track tool_execution_start events by toolCallId for pairing.
|
|
146
|
+
// Only tool_execution_end events with a matching start are counted as reads.
|
|
147
|
+
const startMap = new Map();
|
|
148
|
+
// Scan events for tool calls
|
|
149
|
+
for (const event of events) {
|
|
150
|
+
const type = event.type;
|
|
151
|
+
const toolName = resolveToolName(event);
|
|
152
|
+
const toolCallId = event.toolCallId;
|
|
153
|
+
const toolInput = event.toolInput ?? event.input ?? {};
|
|
154
|
+
const ts = eventTimestamp(event);
|
|
155
|
+
// Collect tool_execution_start events by toolCallId
|
|
156
|
+
if (type === "tool_execution_start" && toolCallId) {
|
|
157
|
+
startMap.set(toolCallId, { toolName, input: toolInput });
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
// Process tool_execution_end events
|
|
161
|
+
if (type === "tool_execution_end") {
|
|
162
|
+
// Successful read calls: must have matching start event and no error
|
|
163
|
+
if (toolName === "read") {
|
|
164
|
+
const isErrored = event.isError === true ||
|
|
165
|
+
event.toolResult?.error != null ||
|
|
166
|
+
event.toolResult?.ok === false;
|
|
167
|
+
if (isErrored)
|
|
168
|
+
continue;
|
|
169
|
+
let filePath;
|
|
170
|
+
// Only paired read calls qualify: must have toolCallId, matching start
|
|
171
|
+
// with same toolName, and start must be a "read" tool.
|
|
172
|
+
if (toolCallId) {
|
|
173
|
+
const start = startMap.get(toolCallId);
|
|
174
|
+
if (start && start.toolName === "read") {
|
|
175
|
+
const startInput = start.input ?? {};
|
|
176
|
+
filePath = typeof startInput.path === "string" ? startInput.path : undefined;
|
|
177
|
+
startMap.delete(toolCallId);
|
|
178
|
+
}
|
|
179
|
+
// No fallback: unmatched end, mismatched toolName, or missing start → skip
|
|
180
|
+
}
|
|
181
|
+
// No legacy path: toolCallId-less read ends never qualify as spec reads
|
|
182
|
+
if (filePath) {
|
|
183
|
+
const relPath = toRepoRelative(repoRoot, filePath);
|
|
184
|
+
if (relPath && isSpecFilePath(relPath) && !readPaths.has(relPath)) {
|
|
185
|
+
readPaths.set(relPath, ts ?? "");
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
// Search/scan tool calls (grep, find, ls, glob)
|
|
190
|
+
if (["grep", "find", "ls", "glob"].includes(toolName)) {
|
|
191
|
+
const query = typeof toolInput.pattern === "string"
|
|
192
|
+
? toolInput.pattern
|
|
193
|
+
: typeof toolInput.query === "string"
|
|
194
|
+
? toolInput.query
|
|
195
|
+
: typeof toolInput.path === "string"
|
|
196
|
+
? toolInput.path
|
|
197
|
+
: toolName;
|
|
198
|
+
searches.push({
|
|
199
|
+
tool: toolName,
|
|
200
|
+
query,
|
|
201
|
+
timestamp: ts,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
// Knowledge base connector calls
|
|
205
|
+
if (isKnowledgeBaseTool(toolName)) {
|
|
206
|
+
kbQueries.push({
|
|
207
|
+
connector: toolName,
|
|
208
|
+
timestamp: ts,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
// Determine status
|
|
214
|
+
let status;
|
|
215
|
+
const specReads = Array.from(readPaths.entries()).map(([p, t]) => ({
|
|
216
|
+
path: p,
|
|
217
|
+
timestamp: t || undefined,
|
|
218
|
+
}));
|
|
219
|
+
const hasInjection = true; // DAG nodes always have skills/resolved instructions injected
|
|
220
|
+
if (specReads.length > 0) {
|
|
221
|
+
status = "spec-read";
|
|
222
|
+
}
|
|
223
|
+
else if (kbQueries.length > 0) {
|
|
224
|
+
status = "kb-queried";
|
|
225
|
+
}
|
|
226
|
+
else if (searches.length > 0) {
|
|
227
|
+
status = "search-only";
|
|
228
|
+
}
|
|
229
|
+
else if (hasInjection) {
|
|
230
|
+
status = "spec-injected";
|
|
231
|
+
}
|
|
232
|
+
else {
|
|
233
|
+
status = "no-evidence";
|
|
234
|
+
}
|
|
235
|
+
// Build summary
|
|
236
|
+
const summaryLines = [];
|
|
237
|
+
if (status === "spec-read") {
|
|
238
|
+
summaryLines.push(`已读取 ${specReads.length} 个规范文件:${specReads.map((r) => r.path).join("、")}`);
|
|
239
|
+
}
|
|
240
|
+
if (searches.length > 0) {
|
|
241
|
+
summaryLines.push(`已执行 ${searches.length} 次检索操作。`);
|
|
242
|
+
}
|
|
243
|
+
if (kbQueries.length > 0) {
|
|
244
|
+
summaryLines.push(`已观察到 ${kbQueries.length} 次知识库查询。`);
|
|
245
|
+
}
|
|
246
|
+
if (status === "spec-injected") {
|
|
247
|
+
summaryLines.push("规范 skill 已注入但未观察到规范文件读取或知识库查询。");
|
|
248
|
+
}
|
|
249
|
+
if (status === "no-evidence") {
|
|
250
|
+
summaryLines.push("未观察到任何规范证据:无 skill 注入、无文件读取、无检索操作。");
|
|
251
|
+
}
|
|
252
|
+
return {
|
|
253
|
+
dagRunId,
|
|
254
|
+
nodeId,
|
|
255
|
+
status,
|
|
256
|
+
skillInjection: {
|
|
257
|
+
skills: [],
|
|
258
|
+
references: [],
|
|
259
|
+
},
|
|
260
|
+
specReads,
|
|
261
|
+
specSearches: searches,
|
|
262
|
+
knowledgeBaseQueries: kbQueries,
|
|
263
|
+
summary: summaryLines.join(" "),
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
export async function extractSpecEvidenceWithSkills(repoRoot, dagRunId, nodeId, skillInjection) {
|
|
267
|
+
const base = await extractSpecEvidence(repoRoot, dagRunId, nodeId);
|
|
268
|
+
if (!base)
|
|
269
|
+
return null;
|
|
270
|
+
base.skillInjection = skillInjection;
|
|
271
|
+
// Recompute status considering skill injection knowledge
|
|
272
|
+
if (base.status === "no-evidence" && skillInjection.skills.length > 0) {
|
|
273
|
+
base.status = "spec-injected";
|
|
274
|
+
base.summary = "规范 skill 已注入但未观察到规范文件读取或知识库查询。";
|
|
275
|
+
}
|
|
276
|
+
else if (base.status === "spec-injected" ||
|
|
277
|
+
(base.status === "no-evidence" && skillInjection.skills.length === 0)) {
|
|
278
|
+
base.summary = base.summary || "规范 skill 已注入但未观察到规范文件读取或知识库查询。";
|
|
279
|
+
}
|
|
280
|
+
return base;
|
|
281
|
+
}
|
|
@@ -10,6 +10,25 @@ export async function fetchJson(url) {
|
|
|
10
10
|
}
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Read-only JSON fetch that preserves HTTP status so callers can distinguish
|
|
15
|
+
* 404 (missing) from 409 (ambiguous task identity). Body is parsed best-effort.
|
|
16
|
+
*/
|
|
17
|
+
export async function fetchJsonResult(url) {
|
|
18
|
+
try {
|
|
19
|
+
const res = await fetch(url);
|
|
20
|
+
let body = null;
|
|
21
|
+
try {
|
|
22
|
+
body = await res.json();
|
|
23
|
+
} catch {
|
|
24
|
+
body = null;
|
|
25
|
+
}
|
|
26
|
+
return { ok: res.ok, status: res.status, body };
|
|
27
|
+
} catch {
|
|
28
|
+
return { ok: false, status: 0, body: null };
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
13
32
|
export function artifactUrl(artifactPath) {
|
|
14
33
|
return `/api/artifacts?path=${encodeURIComponent(artifactPath)}&tail=200`;
|
|
15
34
|
}
|
|
@@ -128,7 +128,7 @@ function route() {
|
|
|
128
128
|
} else if (r.view === "pool") {
|
|
129
129
|
startPoolPolling();
|
|
130
130
|
} else if (r.view === "task") {
|
|
131
|
-
startTaskPolling(r.taskId);
|
|
131
|
+
startTaskPolling(r.featureId, r.taskId);
|
|
132
132
|
} else if (r.view === "feature") {
|
|
133
133
|
void renderFeatureDetail(r.featureId);
|
|
134
134
|
}
|
|
@@ -145,6 +145,6 @@ if (typeof window !== "undefined") {
|
|
|
145
145
|
if (r.view === "run") startRunPolling(r.workerRunId, false);
|
|
146
146
|
if (r.view === "dag") startDagPolling(r.dagRunId, false);
|
|
147
147
|
if (r.view === "pool") startPoolPolling();
|
|
148
|
-
if (r.view === "task") startTaskPolling(r.taskId);
|
|
148
|
+
if (r.view === "task") startTaskPolling(r.featureId, r.taskId);
|
|
149
149
|
});
|
|
150
150
|
}
|
|
@@ -2,13 +2,15 @@
|
|
|
2
2
|
import { el } from "./dom.js";
|
|
3
3
|
import { navigate } from "./router.js";
|
|
4
4
|
|
|
5
|
-
export function objectExistsInSnapshot(kind, id, snapshot) {
|
|
5
|
+
export function objectExistsInSnapshot(kind, id, snapshot, featureId) {
|
|
6
6
|
if (!id) return false;
|
|
7
7
|
if (kind === "feature") {
|
|
8
8
|
return (snapshot?.features ?? []).some((f) => f.featureId === id);
|
|
9
9
|
}
|
|
10
10
|
if (kind === "task") {
|
|
11
|
-
return (snapshot?.tasks ?? []).some(
|
|
11
|
+
return (snapshot?.tasks ?? []).some(
|
|
12
|
+
(t) => t.taskId === id && (!featureId || t.featureId === featureId),
|
|
13
|
+
);
|
|
12
14
|
}
|
|
13
15
|
if (kind === "batch") {
|
|
14
16
|
return (snapshot?.batches ?? []).some((b) => b.batchRunId === id);
|
|
@@ -27,8 +29,11 @@ export function objectExistsInSnapshot(kind, id, snapshot) {
|
|
|
27
29
|
return false;
|
|
28
30
|
}
|
|
29
31
|
|
|
30
|
-
export function objectRoutePath(kind, id) {
|
|
32
|
+
export function objectRoutePath(kind, id, featureId) {
|
|
31
33
|
if (kind === "feature") return `/feature/${encodeURIComponent(id)}`;
|
|
34
|
+
if (kind === "task" && featureId) {
|
|
35
|
+
return `/feature/${encodeURIComponent(featureId)}/task/${encodeURIComponent(id)}`;
|
|
36
|
+
}
|
|
32
37
|
if (kind === "task") return `/task/${encodeURIComponent(id)}`;
|
|
33
38
|
if (kind === "batch") return `/batch/${encodeURIComponent(id)}`;
|
|
34
39
|
if (kind === "run") return `/run/${encodeURIComponent(id)}`;
|
|
@@ -37,11 +42,11 @@ export function objectRoutePath(kind, id) {
|
|
|
37
42
|
}
|
|
38
43
|
|
|
39
44
|
/** Hash-route link only when id is present and snapshot contains the object. */
|
|
40
|
-
export function objectRouteLink(kind, id, snapshot) {
|
|
45
|
+
export function objectRouteLink(kind, id, snapshot, featureId) {
|
|
41
46
|
if (!id) return el("span", "muted", "缺失");
|
|
42
|
-
const exists = objectExistsInSnapshot(kind, id, snapshot);
|
|
47
|
+
const exists = objectExistsInSnapshot(kind, id, snapshot, featureId);
|
|
43
48
|
if (!exists) return el("span", "muted", id);
|
|
44
|
-
const path = objectRoutePath(kind, id);
|
|
49
|
+
const path = objectRoutePath(kind, id, featureId);
|
|
45
50
|
const a = el("a", "object-link", id);
|
|
46
51
|
a.href = `#${path}`;
|
|
47
52
|
a.addEventListener("click", (e) => {
|
|
@@ -80,7 +85,7 @@ export function buildObjectRelations(ids, snapshot) {
|
|
|
80
85
|
const input = ids ?? {};
|
|
81
86
|
const candidates = [
|
|
82
87
|
{ kind: "feature", id: input.featureId },
|
|
83
|
-
{ kind: "task", id: input.taskId },
|
|
88
|
+
{ kind: "task", id: input.taskId, featureId: input.featureId },
|
|
84
89
|
{ kind: "batch", id: input.batchRunId },
|
|
85
90
|
{ kind: "run", id: input.workerRunId },
|
|
86
91
|
{ kind: "dag", id: input.dagRunId },
|
|
@@ -89,13 +94,14 @@ export function buildObjectRelations(ids, snapshot) {
|
|
|
89
94
|
for (const kind of RELATION_KIND_ORDER) {
|
|
90
95
|
const entry = candidates.find((c) => c.kind === kind);
|
|
91
96
|
if (!entry?.id) continue;
|
|
92
|
-
if (!objectExistsInSnapshot(kind, entry.id, snapshot)) continue;
|
|
97
|
+
if (!objectExistsInSnapshot(kind, entry.id, snapshot, entry.featureId)) continue;
|
|
93
98
|
out.push({
|
|
94
99
|
kind,
|
|
95
100
|
id: entry.id,
|
|
96
101
|
label: RELATION_LABELS[kind] ?? kind,
|
|
97
|
-
|
|
98
|
-
|
|
102
|
+
featureId: entry.featureId,
|
|
103
|
+
href: `#${objectRoutePath(kind, entry.id, entry.featureId)}`,
|
|
104
|
+
path: objectRoutePath(kind, entry.id, entry.featureId),
|
|
99
105
|
});
|
|
100
106
|
}
|
|
101
107
|
return out;
|
|
@@ -117,7 +123,7 @@ export function renderObjectRelationBar(relations, snapshot) {
|
|
|
117
123
|
item.appendChild(el("span", "object-relation-label", rel.label));
|
|
118
124
|
item.appendChild(document.createTextNode(" "));
|
|
119
125
|
if (rel.href && rel.id) {
|
|
120
|
-
item.appendChild(objectRouteLink(rel.kind, rel.id, snapshot));
|
|
126
|
+
item.appendChild(objectRouteLink(rel.kind, rel.id, snapshot, rel.featureId));
|
|
121
127
|
} else {
|
|
122
128
|
item.appendChild(el("span", "muted", rel.id ?? "缺失"));
|
|
123
129
|
}
|
|
@@ -125,4 +131,3 @@ export function renderObjectRelationBar(relations, snapshot) {
|
|
|
125
131
|
});
|
|
126
132
|
return bar;
|
|
127
133
|
}
|
|
128
|
-
|
|
@@ -14,6 +14,14 @@ export function parseHashRoute(hashValue) {
|
|
|
14
14
|
if (dagMatch) {
|
|
15
15
|
return { view: "dag", dagRunId: decodeURIComponent(dagMatch[1]) };
|
|
16
16
|
}
|
|
17
|
+
const featureTaskMatch = /^\/feature\/([^/]+)\/task\/([^/]+)$/.exec(hash);
|
|
18
|
+
if (featureTaskMatch) {
|
|
19
|
+
return {
|
|
20
|
+
view: "task",
|
|
21
|
+
featureId: decodeURIComponent(featureTaskMatch[1]),
|
|
22
|
+
taskId: decodeURIComponent(featureTaskMatch[2]),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
17
25
|
const taskMatch = /^\/task\/([^/]+)$/.exec(hash);
|
|
18
26
|
if (taskMatch) {
|
|
19
27
|
return { view: "task", taskId: decodeURIComponent(taskMatch[1]) };
|
|
@@ -1351,6 +1351,18 @@ body.is-resizing-dag-graph {
|
|
|
1351
1351
|
font-weight: 700;
|
|
1352
1352
|
}
|
|
1353
1353
|
|
|
1354
|
+
.spec-evidence-section { padding-bottom: var(--space-4); border-bottom: 1px solid var(--hairline); }
|
|
1355
|
+
.spec-evidence-section + .spec-evidence-section { margin-top: var(--space-4); }
|
|
1356
|
+
.spec-evidence-section h4 { margin: 0 0 var(--space-2); color: var(--ink); font-size: 12px; font-weight: 700; }
|
|
1357
|
+
.spec-evidence-summary { margin: var(--space-2) 0 0; color: var(--body); font-size: 12px; line-height: 1.6; }
|
|
1358
|
+
.spec-evidence-list { display: grid; gap: 6px; margin: var(--space-2) 0 0; padding: 0; list-style: none; }
|
|
1359
|
+
.spec-evidence-list li { display: flex; align-items: flex-start; gap: 6px; color: var(--body); font-size: 12px; line-height: 1.5; overflow-wrap: anywhere; }
|
|
1360
|
+
.spec-evidence-list li > i { flex-shrink: 0; margin-top: 2px; color: var(--muted); font-size: 13px; }
|
|
1361
|
+
.spec-evidence-list code { font-size: 11px; }
|
|
1362
|
+
.spec-evidence-time { margin-left: auto; color: var(--muted); font-size: 10px; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
|
1363
|
+
.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; }
|
|
1364
|
+
.spec-evidence-warning > i { flex-shrink: 0; margin-top: 2px; color: var(--amber); }
|
|
1365
|
+
|
|
1354
1366
|
.run-layer-process {
|
|
1355
1367
|
display: grid;
|
|
1356
1368
|
gap: var(--space-4);
|