@tea-agent/loop-agent 0.11.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.
Files changed (123) hide show
  1. package/CHANGELOG.md +74 -1
  2. package/README.md +33 -4
  3. package/dist/application/dag/generate-task-dag.js +45 -0
  4. package/dist/application/dag/run-dag.js +10 -0
  5. package/dist/application/dag/validate-dag.js +11 -0
  6. package/dist/cli/command-definitions.js +10 -3
  7. package/dist/commands/init.js +74 -7
  8. package/dist/commands/knowledge.js +129 -31
  9. package/dist/governance/manifest-types.js +3 -0
  10. package/dist/shared/package-metadata.js +135 -0
  11. package/dist/task/config-types.js +6 -1
  12. package/dist/worker/cli.js +99 -2
  13. package/dist/worker/delivery/package.js +3 -3
  14. package/dist/worker/feature/decision-loader.js +37 -6
  15. package/dist/worker/feature/next-action.js +10 -2
  16. package/dist/worker/feature/ready-plan-projection.js +81 -0
  17. package/dist/worker/feature/reducer.js +2 -1
  18. package/dist/worker/feature/review.js +19 -2
  19. package/dist/worker/feature/run.js +27 -2
  20. package/dist/worker/follow-up/approve.js +5 -2
  21. package/dist/worker/follow-up/factory.js +1 -1
  22. package/dist/worker/observability/event-history.js +216 -0
  23. package/dist/worker/observability/read-model.js +552 -118
  24. package/dist/worker/observe/paths.js +17 -0
  25. package/dist/worker/observe/routes.js +310 -23
  26. package/dist/worker/observe/server.js +59 -1
  27. package/dist/worker/observe/spec-evidence.js +281 -0
  28. package/dist/worker/observe/static/api.js +46 -0
  29. package/dist/worker/observe/static/app.js +120 -2598
  30. package/dist/worker/observe/static/constants.js +148 -0
  31. package/dist/worker/observe/static/copy.js +67 -0
  32. package/dist/worker/observe/static/dag-helpers.js +172 -0
  33. package/dist/worker/observe/static/dag-model.js +72 -0
  34. package/dist/worker/observe/static/dom.js +61 -0
  35. package/dist/worker/observe/static/format-pool.js +67 -0
  36. package/dist/worker/observe/static/format.js +292 -0
  37. package/dist/worker/observe/static/index.html +300 -82
  38. package/dist/worker/observe/static/kpi.js +94 -0
  39. package/dist/worker/observe/static/relations.js +133 -0
  40. package/dist/worker/observe/static/router.js +93 -0
  41. package/dist/worker/observe/static/run-processing.js +148 -0
  42. package/dist/worker/observe/static/shell-chrome.js +68 -0
  43. package/dist/worker/observe/static/state.js +253 -0
  44. package/dist/worker/observe/static/styles.css +1731 -495
  45. package/dist/worker/observe/static/views/batch.js +227 -0
  46. package/dist/worker/observe/static/views/dag-graph.js +172 -0
  47. package/dist/worker/observe/static/views/dag-inspector.js +596 -0
  48. package/dist/worker/observe/static/views/dag.js +362 -0
  49. package/dist/worker/observe/static/views/dashboard.js +445 -0
  50. package/dist/worker/observe/static/views/failures.js +143 -0
  51. package/dist/worker/observe/static/views/feature.js +492 -0
  52. package/dist/worker/observe/static/views/pool.js +350 -0
  53. package/dist/worker/observe/static/views/run.js +453 -0
  54. package/dist/worker/observe/static/views/session-timeline.js +205 -0
  55. package/dist/worker/observe/static/views/shell.js +7 -0
  56. package/dist/worker/observe/static/views/task.js +314 -0
  57. package/dist/worker/observe/static/views/timeline.js +163 -0
  58. package/dist/worker/pool/doctor.js +165 -0
  59. package/dist/worker/pool/migrate-state.js +303 -0
  60. package/dist/worker/pool/run-store.js +205 -17
  61. package/dist/worker/pool/types.js +17 -1
  62. package/dist/worker/pool/validation.js +100 -15
  63. package/dist/worker/report/morning-report.js +12 -2
  64. package/dist/worker/runner/run-ready.js +41 -26
  65. package/dist/worker/task-graph/ready-planner.js +136 -0
  66. package/dist/workflows/dag/controller-identity.js +104 -0
  67. package/dist/workflows/dag/convergence/controller.js +16 -8
  68. package/dist/workflows/dag/failure-routing.js +12 -1
  69. package/dist/workflows/dag/init-hybrid.js +1233 -11
  70. package/dist/workflows/dag/node-execution.js +123 -29
  71. package/dist/workflows/dag/repair-artifact.js +91 -0
  72. package/dist/workflows/dag/report.js +50 -0
  73. package/dist/workflows/dag/retry-policy.js +138 -0
  74. package/dist/workflows/dag/runner.js +32 -0
  75. package/dist/workflows/dag/runtime-contract.js +87 -0
  76. package/dist/workflows/dag/skill-snapshot.js +2 -0
  77. package/dist/workflows/dag/types.js +45 -1
  78. package/dist/workflows/dag/validate.js +68 -4
  79. package/docs/README.md +1 -1
  80. package/docs/agent-dag-recovery-playbook.md +9 -0
  81. package/docs/agent-dag-runner.md +26 -1
  82. package/docs/architecture/dag-execution.md +6 -0
  83. package/docs/architecture/evolution.md +7 -5
  84. package/docs/architecture/facts-and-state.md +15 -2
  85. package/docs/architecture/worker-and-feature.md +6 -2
  86. package/docs/decisions/README.md +3 -0
  87. package/docs/design/README.md +12 -3
  88. package/docs/exec-plans/active/README.md +2 -2
  89. package/docs/exec-plans/completed/README.md +12 -0
  90. package/docs/feature-workflow.md +108 -2
  91. package/docs/loop-agent-harness.md +15 -4
  92. package/docs/progress/README.md +22 -0
  93. package/docs/reports/README.md +14 -2
  94. package/docs/templates/agent-dag-report.schema.json +17 -0
  95. package/docs/templates/agent-dag.schema.json +69 -1
  96. package/docs/templates/agent-dag.supervised-implementation.json +8 -2
  97. package/docs/templates/backend-test-dag.generate-pytest.prompt.md +139 -0
  98. package/docs/templates/backend-test-dag.json +288 -0
  99. package/docs/templates/backend-test-dag.retrospect.prompt.md +125 -0
  100. package/docs/templates/backend-test-dag.review-cases.prompt.md +81 -0
  101. package/docs/templates/knowledge-graph-bootstrap-dag.json +118 -0
  102. package/docs/templates/knowledge-sync-dag.json +177 -0
  103. package/docs/templates/knowledge-sync-draft.schema.json +71 -0
  104. package/docs/verification-matrix.md +2 -1
  105. package/package.json +8 -2
  106. package/scripts/kb-bootstrap-init-skeleton.sh +239 -0
  107. package/scripts/kb-graph-incremental-prepare.mjs +372 -0
  108. package/scripts/kb-graph-incremental-prepare.sh +5 -0
  109. package/scripts/kb-graph-materialize.mjs +105 -0
  110. package/scripts/kb-graph-materialize.sh +4 -0
  111. package/scripts/kb-graph-promote.mjs +153 -0
  112. package/scripts/kb-graph-promote.sh +4 -0
  113. package/scripts/kb-query.mjs +554 -0
  114. package/scripts/kb-query.sh +5 -0
  115. package/skills/agent-worker/SKILL.md +3 -1
  116. package/skills/agent-worker/references/agent-worker-operator.md +18 -1
  117. package/skills/frontend-design-review/SKILL.md +26 -24
  118. package/skills/frontend-implementation/SKILL.md +29 -26
  119. package/skills/frontend-implementation/references/node-contracts.md +50 -19
  120. package/skills/frontend-review/SKILL.md +1 -1
  121. package/skills/loop-agent/references/command-reference.md +2 -0
  122. package/skills/loop-agent/references/hybrid-dag.md +22 -3
  123. package/skills/loop-agent/references/verification-and-failure-handling.md +6 -0
@@ -1,5 +1,22 @@
1
1
  import { existsSync, realpathSync } from "node:fs";
2
2
  import path from "node:path";
3
+ const ALLOWED_ARTIFACT_EXTENSIONS = new Set([
4
+ ".csv",
5
+ ".diff",
6
+ ".json",
7
+ ".jsonl",
8
+ ".log",
9
+ ".md",
10
+ ".patch",
11
+ ".txt",
12
+ ".tsv",
13
+ ".xml",
14
+ ".yaml",
15
+ ".yml",
16
+ ]);
17
+ export function isAllowedArtifactTextPath(artifactPath) {
18
+ return ALLOWED_ARTIFACT_EXTENSIONS.has(path.extname(artifactPath).toLowerCase());
19
+ }
3
20
  export function resolveArtifactPath(repoRoot, artifactPath) {
4
21
  if (!artifactPath) {
5
22
  return null;
@@ -1,11 +1,15 @@
1
1
  import { existsSync } from "node:fs";
2
- import { readFile, stat } from "node:fs/promises";
2
+ import { open as openFile, readFile, stat } from "node:fs/promises";
3
3
  import path from "node:path";
4
+ import { redactSecrets, truncateUtf8Preview } from "../../shared/preview.js";
4
5
  import { parseWorkerEventLine } from "../observability/events.js";
5
6
  import { isSafeObservabilityIdentifier } from "../observability/event-store.js";
6
- import { buildGlobalSnapshot } from "../observability/read-model.js";
7
+ import { clampEventHistoryLimit, listBatchEventHistory, listPoolEventHistory, } from "../observability/event-history.js";
8
+ import { buildGlobalSnapshot, clampTaskRunHistoryLimit, listTaskRunHistory, resolveLegacyTask, } from "../observability/read-model.js";
7
9
  import { getTaskPoolRoot } from "../pool/run-store.js";
8
- import { resolveArtifactPath, toRepoRelativeArtifactPath, } from "./paths.js";
10
+ import { isAllowedArtifactTextPath, resolveArtifactPath, toRepoRelativeArtifactPath, } from "./paths.js";
11
+ import { extractSpecEvidence, } from "./spec-evidence.js";
12
+ const ARTIFACT_PREVIEW_MAX_BYTES = 64 * 1024;
9
13
  export function createObserveSnapshotCache() {
10
14
  return { expiresAt: 0 };
11
15
  }
@@ -13,9 +17,43 @@ const ROUTES = [
13
17
  { method: "GET", pattern: /^\/api\/health$/, handler: handleHealth },
14
18
  { method: "GET", pattern: /^\/api\/snapshot$/, handler: handleSnapshot },
15
19
  { method: "GET", pattern: /^\/api\/batches$/, handler: handleBatches },
16
- { method: "GET", pattern: /^\/api\/batches\/([^/]+)$/, handler: handleBatchById },
20
+ {
21
+ method: "GET",
22
+ pattern: /^\/api\/batches\/([^/]+)\/events$/,
23
+ handler: handleBatchEvents,
24
+ },
25
+ {
26
+ method: "GET",
27
+ pattern: /^\/api\/batches\/([^/]+)$/,
28
+ handler: handleBatchById,
29
+ },
30
+ /**
31
+ * Pool bounded event history (cursor/limit). Distinct from SSE `/api/events/stream`
32
+ * and from run/session `?after=<line-offset>` integer offsets.
33
+ */
34
+ { method: "GET", pattern: /^\/api\/events$/, handler: handlePoolEvents },
17
35
  { method: "GET", pattern: /^\/api\/tasks$/, handler: handleTasks },
18
- { method: "GET", pattern: /^\/api\/tasks\/([^/]+)$/, handler: handleTaskById },
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
+ },
47
+ {
48
+ method: "GET",
49
+ pattern: /^\/api\/tasks\/([^/]+)\/runs$/,
50
+ handler: handleTaskRuns,
51
+ },
52
+ {
53
+ method: "GET",
54
+ pattern: /^\/api\/tasks\/([^/]+)$/,
55
+ handler: handleTaskById,
56
+ },
19
57
  { method: "GET", pattern: /^\/api\/runs\/([^/]+)$/, handler: handleRunById },
20
58
  {
21
59
  method: "GET",
@@ -32,9 +70,22 @@ const ROUTES = [
32
70
  pattern: /^\/api\/dag-runs\/([^/]+)\/nodes\/([^/]+)\/session-events$/,
33
71
  handler: handleDagNodeSessionEvents,
34
72
  },
35
- { method: "GET", pattern: /^\/api\/dag-runs\/([^/]+)$/, handler: handleDagRunById },
73
+ {
74
+ method: "GET",
75
+ pattern: /^\/api\/dag-runs\/([^/]+)\/nodes\/([^/]+)\/spec-evidence$/,
76
+ handler: handleDagNodeSpecEvidence,
77
+ },
78
+ {
79
+ method: "GET",
80
+ pattern: /^\/api\/dag-runs\/([^/]+)$/,
81
+ handler: handleDagRunById,
82
+ },
36
83
  { method: "GET", pattern: /^\/api\/artifacts$/, handler: handleArtifactRead },
37
- { method: "GET", pattern: /^\/api\/events\/stream$/, handler: handleEventStream },
84
+ {
85
+ method: "GET",
86
+ pattern: /^\/api\/events\/stream$/,
87
+ handler: handleEventStream,
88
+ },
38
89
  ];
39
90
  export async function handleRequest(req, res, ctx) {
40
91
  const method = req.method ?? "GET";
@@ -82,7 +133,8 @@ export async function serveStatic(req, res, staticDir) {
82
133
  const filePath = path.join(staticDir, pathname);
83
134
  const resolvedStatic = path.resolve(staticDir);
84
135
  const resolvedFile = path.resolve(filePath);
85
- if (!resolvedFile.startsWith(resolvedStatic + path.sep) && resolvedFile !== resolvedStatic) {
136
+ if (!resolvedFile.startsWith(resolvedStatic + path.sep) &&
137
+ resolvedFile !== resolvedStatic) {
86
138
  sendJson(res, 404, { error: "Not found" });
87
139
  return;
88
140
  }
@@ -141,19 +193,149 @@ async function handleBatchById(_req, res, match, ctx) {
141
193
  }
142
194
  sendJson(res, 200, batch);
143
195
  }
196
+ /**
197
+ * Bounded batch event history. Query: `limit` (default 50, max 200), `cursor`
198
+ * (opaque history cursor). Not the same as run/SSE `after` line offsets.
199
+ */
200
+ async function handleBatchEvents(_req, res, match, ctx) {
201
+ const batchRunId = match.params.id;
202
+ if (!isSafeObservabilityIdentifier(batchRunId)) {
203
+ sendJson(res, 404, { error: "Batch not found" });
204
+ return;
205
+ }
206
+ const limit = clampEventHistoryLimit(parsePositiveInt(match.query.get("limit"), NaN));
207
+ const cursor = match.query.get("cursor") ?? undefined;
208
+ const page = await listBatchEventHistory(ctx.repoRoot, batchRunId, {
209
+ limit,
210
+ cursor,
211
+ });
212
+ sendJson(res, 200, page);
213
+ }
214
+ /**
215
+ * Bounded pool-wide event history. Path is `/api/events` (JSON page), not
216
+ * `/api/events/stream` (SSE). Uses `cursor`/`limit`, not integer `after`.
217
+ */
218
+ async function handlePoolEvents(_req, res, match, ctx) {
219
+ const limit = clampEventHistoryLimit(parsePositiveInt(match.query.get("limit"), NaN));
220
+ const cursor = match.query.get("cursor") ?? undefined;
221
+ const page = await listPoolEventHistory(ctx.repoRoot, { limit, cursor });
222
+ sendJson(res, 200, page);
223
+ }
144
224
  async function handleTasks(_req, res, _match, ctx) {
145
225
  const snapshot = await getSnapshot(ctx);
146
226
  sendJson(res, 200, snapshot.tasks);
147
227
  }
148
- async function handleTaskById(_req, res, match, ctx) {
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
+ }
149
239
  const snapshot = await getSnapshot(ctx);
150
- const task = snapshot.tasks.find((t) => t.taskId === match.params.id);
240
+ const task = snapshot.tasks.find((t) => t.featureId === resolved.featureId && t.taskId === resolved.taskId);
151
241
  if (!task) {
152
242
  sendJson(res, 404, { error: "Task not found" });
153
243
  return;
154
244
  }
155
245
  sendJson(res, 200, task);
156
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
+ }
293
+ async function handleTaskRuns(_req, res, match, ctx) {
294
+ const taskId = match.params.id;
295
+ if (!isSafeObservabilityIdentifier(taskId)) {
296
+ sendJson(res, 400, { error: "Invalid task identifier" });
297
+ return;
298
+ }
299
+ const snapshot = await getSnapshot(ctx);
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;
314
+ }
315
+ const limit = clampTaskRunHistoryLimit(parsePositiveInt(match.query.get("limit"), 0) || undefined);
316
+ const before = match.query.get("before");
317
+ const page = await listTaskRunHistory(ctx.repoRoot, featureId, taskId, {
318
+ limit,
319
+ before: before && before.length > 0 ? before : null,
320
+ });
321
+ sendJson(res, 200, page);
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
+ }
157
339
  async function handleRunById(_req, res, match, ctx) {
158
340
  if (!isSafeObservabilityIdentifier(match.params.id)) {
159
341
  sendJson(res, 400, { error: "Invalid run identifier" });
@@ -226,7 +408,8 @@ async function handleDagRunById(_req, res, match, ctx) {
226
408
  async function handleDagNodeSessionEvents(_req, res, match, ctx) {
227
409
  const dagRunId = match.params.id;
228
410
  const nodeId = match.params.sub;
229
- if (!isSafeObservabilityIdentifier(dagRunId) || !isSafeObservabilityIdentifier(nodeId)) {
411
+ if (!isSafeObservabilityIdentifier(dagRunId) ||
412
+ !isSafeObservabilityIdentifier(nodeId)) {
230
413
  sendJson(res, 400, { error: "Invalid dag run or node identifier" });
231
414
  return;
232
415
  }
@@ -248,7 +431,7 @@ async function handleDagNodeSessionEvents(_req, res, match, ctx) {
248
431
  async function handleArtifactRead(_req, res, match, ctx) {
249
432
  const relativePath = match.query.get("path") ?? "";
250
433
  const resolved = resolveArtifactPath(ctx.repoRoot, relativePath);
251
- if (!resolved) {
434
+ if (!resolved || !isAllowedArtifactTextPath(relativePath)) {
252
435
  sendJson(res, 400, { error: "Invalid artifact path" });
253
436
  return;
254
437
  }
@@ -256,18 +439,47 @@ async function handleArtifactRead(_req, res, match, ctx) {
256
439
  sendJson(res, 404, { error: "Artifact not found" });
257
440
  return;
258
441
  }
259
- const raw = await readFile(resolved, "utf-8");
442
+ const artifactStat = await stat(resolved);
443
+ if (!artifactStat.isFile()) {
444
+ sendJson(res, 400, { error: "Artifact is not a readable text file" });
445
+ return;
446
+ }
447
+ const sourceBytes = artifactStat.size;
448
+ const start = Math.max(0, sourceBytes - ARTIFACT_PREVIEW_MAX_BYTES);
449
+ const length = Math.min(sourceBytes, ARTIFACT_PREVIEW_MAX_BYTES);
450
+ const buffer = Buffer.alloc(length);
451
+ const handle = await openFile(resolved, "r");
452
+ let bytesRead = 0;
453
+ try {
454
+ ({ bytesRead } = await handle.read(buffer, 0, length, start));
455
+ }
456
+ finally {
457
+ await handle.close();
458
+ }
459
+ let raw = buffer.subarray(0, bytesRead).toString("utf-8");
460
+ let truncated = start > 0;
461
+ if (truncated) {
462
+ const firstCompleteLine = raw.indexOf("\n");
463
+ raw = firstCompleteLine >= 0 ? raw.slice(firstCompleteLine + 1) : "";
464
+ }
260
465
  const tail = match.query.get("tail");
261
466
  if (tail !== null) {
262
467
  const tailLines = parsePositiveInt(tail, 0);
263
468
  if (tailLines > 0) {
264
469
  const lines = raw.replace(/\n$/, "").split("\n");
265
- const content = lines.slice(-tailLines).join("\n");
266
- sendJson(res, 200, { content });
267
- return;
470
+ truncated = truncated || lines.length > tailLines;
471
+ raw = lines.slice(-tailLines).join("\n");
268
472
  }
269
473
  }
270
- sendJson(res, 200, { content: raw });
474
+ const redacted = redactSecrets(raw);
475
+ const content = truncateUtf8Preview(redacted, ARTIFACT_PREVIEW_MAX_BYTES);
476
+ truncated = truncated || content !== redacted;
477
+ sendJson(res, 200, {
478
+ content,
479
+ truncated,
480
+ sourceBytes,
481
+ maxBytes: ARTIFACT_PREVIEW_MAX_BYTES,
482
+ });
271
483
  }
272
484
  async function handleEventStream(req, res, match, ctx) {
273
485
  const batchRunId = match.query.get("batchRunId");
@@ -277,16 +489,34 @@ async function handleEventStream(req, res, match, ctx) {
277
489
  }
278
490
  res.writeHead(200, {
279
491
  "Content-Type": "text/event-stream",
280
- "Cache-Control": "no-cache",
492
+ "Cache-Control": "no-cache, no-transform",
281
493
  Connection: "keep-alive",
494
+ // Disable proxy buffering (nginx) so heartbeats reach the client.
495
+ "X-Accel-Buffering": "no",
282
496
  });
497
+ // Flush headers immediately for proxies that wait on first body byte.
498
+ if (typeof res.flushHeaders === "function") {
499
+ res.flushHeaders();
500
+ }
283
501
  const observabilityRoot = path.join(getTaskPoolRoot(ctx.repoRoot), "observability");
284
502
  const eventsPath = path.join(observabilityRoot, "events.jsonl");
285
503
  let offset = 0;
286
504
  let knownFileSize;
287
505
  let timer;
288
506
  let closed = false;
507
+ let lastHeartbeatAt = 0;
508
+ const heartbeatEveryMs = Math.max(15_000, ctx.pollIntervalMs * 10);
509
+ const writeHeartbeat = () => {
510
+ if (closed || res.writableEnded)
511
+ return;
512
+ const now = Date.now();
513
+ if (now - lastHeartbeatAt < heartbeatEveryMs)
514
+ return;
515
+ lastHeartbeatAt = now;
516
+ res.write(`: keepalive ${new Date(now).toISOString()}\n\n`);
517
+ };
289
518
  const sendMatchingEvents = async (initial) => {
519
+ writeHeartbeat();
290
520
  let currentFileSize;
291
521
  try {
292
522
  currentFileSize = (await stat(eventsPath)).size;
@@ -338,7 +568,9 @@ async function handleEventStream(req, res, match, ctx) {
338
568
  }
339
569
  function findRunByWorkerRunId(tasks, batches, workerRunId) {
340
570
  return (tasks.find((task) => task.workerRunId === workerRunId) ??
341
- batches.flatMap((batch) => batch.tasks).find((task) => task.workerRunId === workerRunId));
571
+ batches
572
+ .flatMap((batch) => batch.tasks)
573
+ .find((task) => task.workerRunId === workerRunId));
342
574
  }
343
575
  async function readJsonlLines(filePath, after) {
344
576
  const page = await readJsonlPage(filePath, after);
@@ -351,14 +583,17 @@ async function readJsonlPage(filePath, after) {
351
583
  .split("\n")
352
584
  .map((line) => line.trim())
353
585
  .filter(Boolean);
354
- const events = lines.slice(after).map((line) => {
586
+ const events = lines
587
+ .slice(after)
588
+ .map((line) => {
355
589
  try {
356
590
  return JSON.parse(line);
357
591
  }
358
592
  catch {
359
593
  return undefined;
360
594
  }
361
- }).filter((line) => line !== undefined);
595
+ })
596
+ .filter((line) => line !== undefined);
362
597
  return { events, nextOffset: lines.length };
363
598
  }
364
599
  catch {
@@ -367,7 +602,8 @@ async function readJsonlPage(filePath, after) {
367
602
  }
368
603
  const DAG_RUN_LIFECYCLE_DIRS = ["active", "completed", "paused"];
369
604
  function resolveSessionEventsPath(repoRoot, dagRunId, nodeId, dagRunDir) {
370
- if (!isSafeObservabilityIdentifier(dagRunId) || !isSafeObservabilityIdentifier(nodeId)) {
605
+ if (!isSafeObservabilityIdentifier(dagRunId) ||
606
+ !isSafeObservabilityIdentifier(nodeId)) {
371
607
  return null;
372
608
  }
373
609
  const dagRunsRoot = path.resolve(repoRoot, ".harness", "dag-runs");
@@ -407,7 +643,7 @@ function isExpectedSessionEventsRelativePath(dagRunsRoot, resolvedPath, dagRunId
407
643
  function isPathInside(root, target) {
408
644
  const normalizedRoot = root.endsWith(path.sep) ? root : `${root}${path.sep}`;
409
645
  const normalizedTarget = path.normalize(target);
410
- return normalizedTarget === root || normalizedTarget.startsWith(normalizedRoot);
646
+ return (normalizedTarget === root || normalizedTarget.startsWith(normalizedRoot));
411
647
  }
412
648
  async function readFilteredEvents(filePath, offset, batchRunId, repoRoot) {
413
649
  try {
@@ -469,6 +705,57 @@ function sendJson(res, status, body) {
469
705
  });
470
706
  res.end(payload);
471
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
+ }
472
759
  function contentTypeFor(pathname) {
473
760
  if (pathname.endsWith(".html"))
474
761
  return "text/html; charset=utf-8";
@@ -3,14 +3,18 @@ import path from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { createObserveSnapshotCache, handleRequest, serveStatic, } from "./routes.js";
5
5
  const STATIC_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "static");
6
+ /** Align with common reverse-proxy keepalive (e.g. nginx 75s). */
7
+ const DEFAULT_KEEP_ALIVE_TIMEOUT_MS = 65_000;
6
8
  export function createObserveServer(options) {
7
9
  const repoRoot = path.resolve(options.repoRoot);
8
10
  const host = options.host ?? "127.0.0.1";
9
11
  if (!isLoopbackHost(host)) {
10
- return Promise.reject(new Error(`observe serve is local-only; refusing non-loopback host ${host}`));
12
+ process.stderr.write(`observe serve: binding non-loopback host ${host} (read-only; no auth — expose only on trusted networks)\n`);
11
13
  }
12
14
  const port = options.port ?? 0;
13
15
  const pollIntervalMs = options.pollIntervalMs ?? 1000;
16
+ const debug = options.debug === true;
17
+ const keepAliveTimeoutMs = options.keepAliveTimeoutMs ?? DEFAULT_KEEP_ALIVE_TIMEOUT_MS;
14
18
  const routeContext = {
15
19
  repoRoot,
16
20
  pollIntervalMs,
@@ -19,6 +23,24 @@ export function createObserveServer(options) {
19
23
  };
20
24
  return new Promise((resolve, reject) => {
21
25
  const server = createServer((req, res) => {
26
+ const startedAt = Date.now();
27
+ req.socket?.on("error", () => {
28
+ // Ignore client resets; avoid unhandled socket error noise.
29
+ });
30
+ if (debug) {
31
+ let logged = false;
32
+ const onceLog = (note) => {
33
+ if (logged)
34
+ return;
35
+ logged = true;
36
+ logRequest(req, res, startedAt, note);
37
+ };
38
+ res.on("finish", () => onceLog());
39
+ res.on("close", () => {
40
+ if (!res.writableFinished)
41
+ onceLog("aborted");
42
+ });
43
+ }
22
44
  void dispatch(req, res).catch((error) => {
23
45
  if (!res.headersSent) {
24
46
  const payload = JSON.stringify({
@@ -32,6 +54,19 @@ export function createObserveServer(options) {
32
54
  }
33
55
  });
34
56
  });
57
+ server.keepAliveTimeout = keepAliveTimeoutMs;
58
+ // Must exceed keepAliveTimeout or Node resets the timer incorrectly.
59
+ server.headersTimeout = keepAliveTimeoutMs + 5_000;
60
+ // Allow long-lived SSE; per-request work still finishes promptly.
61
+ server.requestTimeout = 0;
62
+ server.on("clientError", (err, socket) => {
63
+ if (debug) {
64
+ process.stderr.write(`[observe] ${new Date().toISOString()} clientError ${err.message}\n`);
65
+ }
66
+ if (!socket.destroyed) {
67
+ socket.end("HTTP/1.1 400 Bad Request\r\n\r\n");
68
+ }
69
+ });
35
70
  async function dispatch(req, res) {
36
71
  const handled = await handleRequest(req, res, routeContext);
37
72
  if (!handled) {
@@ -45,6 +80,9 @@ export function createObserveServer(options) {
45
80
  reject(new Error("Failed to get server address"));
46
81
  return;
47
82
  }
83
+ if (debug) {
84
+ process.stderr.write(`[observe] debug logging enabled; keepAliveTimeout=${keepAliveTimeoutMs}ms\n`);
85
+ }
48
86
  resolve({
49
87
  url: `http://${host}:${addr.port}`,
50
88
  close: () => new Promise((closeResolve, closeReject) => {
@@ -61,3 +99,23 @@ function isLoopbackHost(host) {
61
99
  normalized === "[::1]" ||
62
100
  /^127(?:\.\d{1,3}){3}$/.test(normalized));
63
101
  }
102
+ function requestSource(req) {
103
+ const forwarded = req.headers["x-forwarded-for"];
104
+ if (typeof forwarded === "string" && forwarded.trim()) {
105
+ return forwarded.split(",")[0]?.trim() || forwarded.trim();
106
+ }
107
+ if (Array.isArray(forwarded) && forwarded[0]) {
108
+ return forwarded[0].split(",")[0]?.trim() || forwarded[0];
109
+ }
110
+ const realIp = req.headers["x-real-ip"];
111
+ if (typeof realIp === "string" && realIp.trim()) {
112
+ return realIp.trim();
113
+ }
114
+ return req.socket.remoteAddress ?? "-";
115
+ }
116
+ function logRequest(req, res, startedAt, note) {
117
+ const ms = Date.now() - startedAt;
118
+ const ua = String(req.headers["user-agent"] ?? "-").slice(0, 120);
119
+ const suffix = note ? ` ${note}` : "";
120
+ process.stderr.write(`[observe] ${new Date().toISOString()} ${req.method ?? "?"} ${req.url ?? "/"} ${res.statusCode} ${ms}ms from=${requestSource(req)} ua=${JSON.stringify(ua)}${suffix}\n`);
121
+ }