@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.
- package/CHANGELOG.md +74 -1
- package/README.md +33 -4
- package/dist/application/dag/generate-task-dag.js +45 -0
- package/dist/application/dag/run-dag.js +10 -0
- package/dist/application/dag/validate-dag.js +11 -0
- package/dist/cli/command-definitions.js +10 -3
- package/dist/commands/init.js +74 -7
- package/dist/commands/knowledge.js +129 -31
- package/dist/governance/manifest-types.js +3 -0
- package/dist/shared/package-metadata.js +135 -0
- package/dist/task/config-types.js +6 -1
- package/dist/worker/cli.js +99 -2
- 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/event-history.js +216 -0
- package/dist/worker/observability/read-model.js +552 -118
- package/dist/worker/observe/paths.js +17 -0
- package/dist/worker/observe/routes.js +310 -23
- package/dist/worker/observe/server.js +59 -1
- package/dist/worker/observe/spec-evidence.js +281 -0
- package/dist/worker/observe/static/api.js +46 -0
- package/dist/worker/observe/static/app.js +120 -2598
- package/dist/worker/observe/static/constants.js +148 -0
- package/dist/worker/observe/static/copy.js +67 -0
- package/dist/worker/observe/static/dag-helpers.js +172 -0
- package/dist/worker/observe/static/dag-model.js +72 -0
- package/dist/worker/observe/static/dom.js +61 -0
- package/dist/worker/observe/static/format-pool.js +67 -0
- package/dist/worker/observe/static/format.js +292 -0
- package/dist/worker/observe/static/index.html +300 -82
- package/dist/worker/observe/static/kpi.js +94 -0
- package/dist/worker/observe/static/relations.js +133 -0
- package/dist/worker/observe/static/router.js +93 -0
- package/dist/worker/observe/static/run-processing.js +148 -0
- package/dist/worker/observe/static/shell-chrome.js +68 -0
- package/dist/worker/observe/static/state.js +253 -0
- package/dist/worker/observe/static/styles.css +1731 -495
- package/dist/worker/observe/static/views/batch.js +227 -0
- package/dist/worker/observe/static/views/dag-graph.js +172 -0
- package/dist/worker/observe/static/views/dag-inspector.js +596 -0
- package/dist/worker/observe/static/views/dag.js +362 -0
- package/dist/worker/observe/static/views/dashboard.js +445 -0
- package/dist/worker/observe/static/views/failures.js +143 -0
- package/dist/worker/observe/static/views/feature.js +492 -0
- package/dist/worker/observe/static/views/pool.js +350 -0
- package/dist/worker/observe/static/views/run.js +453 -0
- package/dist/worker/observe/static/views/session-timeline.js +205 -0
- package/dist/worker/observe/static/views/shell.js +7 -0
- package/dist/worker/observe/static/views/task.js +314 -0
- package/dist/worker/observe/static/views/timeline.js +163 -0
- 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/controller-identity.js +104 -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 +1233 -11
- package/dist/workflows/dag/node-execution.js +123 -29
- package/dist/workflows/dag/repair-artifact.js +91 -0
- package/dist/workflows/dag/report.js +50 -0
- package/dist/workflows/dag/retry-policy.js +138 -0
- package/dist/workflows/dag/runner.js +32 -0
- package/dist/workflows/dag/runtime-contract.js +87 -0
- package/dist/workflows/dag/skill-snapshot.js +2 -0
- package/dist/workflows/dag/types.js +45 -1
- package/dist/workflows/dag/validate.js +68 -4
- package/docs/README.md +1 -1
- package/docs/agent-dag-recovery-playbook.md +9 -0
- package/docs/agent-dag-runner.md +26 -1
- package/docs/architecture/dag-execution.md +6 -0
- package/docs/architecture/evolution.md +7 -5
- package/docs/architecture/facts-and-state.md +15 -2
- package/docs/architecture/worker-and-feature.md +6 -2
- package/docs/decisions/README.md +3 -0
- package/docs/design/README.md +12 -3
- package/docs/exec-plans/active/README.md +2 -2
- package/docs/exec-plans/completed/README.md +12 -0
- package/docs/feature-workflow.md +108 -2
- package/docs/loop-agent-harness.md +15 -4
- package/docs/progress/README.md +22 -0
- package/docs/reports/README.md +14 -2
- package/docs/templates/agent-dag-report.schema.json +17 -0
- package/docs/templates/agent-dag.schema.json +69 -1
- package/docs/templates/agent-dag.supervised-implementation.json +8 -2
- package/docs/templates/backend-test-dag.generate-pytest.prompt.md +139 -0
- package/docs/templates/backend-test-dag.json +288 -0
- package/docs/templates/backend-test-dag.retrospect.prompt.md +125 -0
- package/docs/templates/backend-test-dag.review-cases.prompt.md +81 -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 +2 -0
- package/skills/loop-agent/references/hybrid-dag.md +22 -3
- 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 {
|
|
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
|
-
{
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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) &&
|
|
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
|
|
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 ===
|
|
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) ||
|
|
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
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
return;
|
|
470
|
+
truncated = truncated || lines.length > tailLines;
|
|
471
|
+
raw = lines.slice(-tailLines).join("\n");
|
|
268
472
|
}
|
|
269
473
|
}
|
|
270
|
-
|
|
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
|
|
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
|
|
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
|
-
})
|
|
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) ||
|
|
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
|
-
|
|
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
|
+
}
|