@tea-agent/loop-agent 0.5.0 → 0.7.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/AGENTS.md +142 -142
- package/CHANGELOG.md +132 -98
- package/README.md +195 -195
- package/bin/agent-worker.js +22 -22
- package/bin/loop-agent.js +21 -21
- package/dist/application/dag/args.js +9 -1
- package/dist/application/dag/run-dag.js +16 -2
- package/dist/cli/command-definitions.js +22 -4
- package/dist/cli/help.js +3 -2
- package/dist/cli/program.js +7 -5
- package/dist/commands/import-prd.js +76 -0
- package/dist/commands/init.js +467 -457
- package/dist/commands/instructions.js +90 -58
- package/dist/commands/loop-benchmark.js +11 -11
- package/dist/commands/pi-reuse-benchmark.js +16 -16
- package/dist/executors/cursor-executor.js +1 -1
- package/dist/executors/dag-pi-executor.js +1 -0
- package/dist/executors/pi-sdk-executor.js +63 -1
- package/dist/shared/preview.js +39 -0
- package/dist/task/config-types.js +3 -0
- package/dist/task/runtime.js +27 -27
- package/dist/task/source-references.js +221 -0
- package/dist/worker/cli.js +62 -1
- package/dist/worker/loop-agent/loop-agent-client.js +97 -5
- package/dist/worker/materialize/harness-task-materializer.js +166 -5
- package/dist/worker/observability/event-store.js +82 -0
- package/dist/worker/observability/events.js +79 -0
- package/dist/worker/observability/progress-composite.js +33 -0
- package/dist/worker/observability/read-model.js +1013 -0
- package/dist/worker/observability/snapshot-store.js +43 -0
- package/dist/worker/observability/types.js +1 -0
- package/dist/worker/observe/paths.js +64 -0
- package/dist/worker/observe/routes.js +423 -0
- package/dist/worker/observe/server.js +61 -0
- package/dist/worker/observe/static/app.js +1419 -0
- package/dist/worker/observe/static/index.html +63 -0
- package/dist/worker/observe/static/styles.css +613 -0
- package/dist/worker/pool/failure-routing.js +41 -6
- package/dist/worker/pool/run-store.js +50 -0
- package/dist/worker/progress-reporter.js +0 -18
- package/dist/worker/run-task/run-task.js +327 -92
- package/dist/worker/runner/run-ready.js +112 -4
- package/dist/worker/task-spec/schema.js +2 -1
- package/dist/workflows/dag/canvas-observer.js +275 -275
- package/dist/workflows/dag/event-observer.js +132 -0
- package/dist/workflows/dag/init-hybrid.js +182 -21
- package/dist/workflows/dag/observer-compose.js +52 -0
- package/docs/README.md +75 -72
- package/docs/agent-dag-recovery-playbook.md +184 -184
- package/docs/agent-dag-runner.md +42 -42
- package/docs/architecture/runtime-boundaries.md +162 -147
- package/docs/cursor-executor-usage.md +25 -25
- package/docs/decisions/README.md +3 -3
- package/docs/design/README.md +49 -36
- package/docs/development-principles.md +73 -73
- package/docs/dynamic-workflow-dag-engine-roadmap.md +1749 -1749
- package/docs/exec-plans/README.md +6 -6
- package/docs/exec-plans/active/README.md +12 -7
- package/docs/exec-plans/completed/README.md +32 -19
- package/docs/feature-workflow.md +186 -186
- package/docs/harness-methodology-debugging.md +153 -153
- package/docs/harness-methodology-tdd.md +130 -130
- package/docs/harness-methodology-verification.md +27 -27
- package/docs/init-surface.manifest.json +208 -199
- package/docs/loop-agent-harness.md +55 -42
- package/docs/production-readiness.md +96 -96
- package/docs/progress/README.md +3 -3
- package/docs/reports/README.md +9 -5
- package/docs/skills/README.md +6 -6
- package/docs/skills/vetted-skill-registry.md +26 -26
- package/docs/templates/adr.md +60 -60
- package/docs/templates/agent-dag-authority-surface-audit.prompt.md +94 -94
- package/docs/templates/agent-dag-decision-envelope.schema.json +213 -213
- package/docs/templates/agent-dag-decision-gate-dogfood-report.md +117 -117
- package/docs/templates/agent-dag-decision-gate.prompt.md +246 -246
- package/docs/templates/agent-dag-process-supervisor.prompt.md +98 -98
- package/docs/templates/agent-dag-report.schema.json +454 -454
- package/docs/templates/agent-dag-review-verdict.prompt.md +68 -68
- package/docs/templates/agent-dag.base.json +195 -195
- package/docs/templates/agent-dag.final-verification.json +190 -190
- package/docs/templates/agent-dag.schema.json +316 -316
- package/docs/templates/agent-dag.supervised-implementation.json +500 -500
- package/docs/templates/exec-plan.md +64 -64
- package/docs/templates/feature-spec.md +53 -53
- package/docs/templates/hybrid-dag.json +193 -193
- package/docs/templates/init-evolution-review.md +33 -33
- package/docs/templates/interactive-ui-round2-experiment.md +66 -0
- package/docs/templates/production-readiness-checklist.md +57 -57
- package/docs/templates/progress-log.md +17 -17
- package/docs/templates/project-start-checklist.md +9 -9
- package/docs/templates/qa-report.md +48 -48
- package/docs/templates/sprint-contract.md +29 -29
- package/docs/templates/worker-dogfood-evidence.md +52 -0
- package/docs/templates/worker-dogfood-setup.md +48 -0
- package/docs/verification-matrix.md +41 -41
- package/examples/decision-gate-agent-dag.json +123 -123
- package/examples/example-dag.json +51 -51
- package/examples/hybrid-loop-agent-dag.json +194 -194
- package/harness.json +70 -69
- package/package.json +66 -66
- package/skills/ai-engineering-context/SKILL.md +48 -48
- package/skills/code-review-core/SKILL.md +20 -20
- package/skills/codebase-scout/SKILL.md +19 -19
- package/skills/init-capability-evolution/SKILL.md +69 -69
- package/skills/loop-agent/SKILL.md +149 -147
- package/skills/loop-agent/references/README.md +67 -67
- package/skills/loop-agent/references/command-reference.md +412 -403
- package/skills/loop-agent/references/harness-policy.md +263 -259
- package/skills/loop-agent/references/hybrid-dag.md +216 -216
- package/skills/loop-agent/references/learned/README.md +21 -21
- package/skills/loop-agent/references/long-running-loop.md +59 -59
- package/skills/loop-agent/references/model-routing.md +36 -36
- package/skills/loop-agent/references/multi-worktree.md +54 -54
- package/skills/loop-agent/references/one-shot-runs.md +85 -85
- package/skills/loop-agent/references/orchestrator-and-interventions.md +169 -169
- package/skills/loop-agent/references/pi-prompt.md +23 -23
- package/skills/loop-agent/references/pi-subagent-assisted-mode.md +81 -81
- package/skills/loop-agent/references/post-implementation-and-patterns.md +44 -44
- package/skills/loop-agent/references/task-workflow.md +89 -84
- package/skills/loop-agent/references/verification-and-failure-handling.md +128 -128
- package/skills/requesting-code-review/SKILL.md +101 -101
- package/skills/requesting-code-review/code-reviewer.md +168 -168
- package/skills/systematic-debugging/CREATION-LOG.md +119 -119
- package/skills/systematic-debugging/SKILL.md +296 -296
- package/skills/systematic-debugging/condition-based-waiting-example.ts +158 -158
- package/skills/systematic-debugging/condition-based-waiting.md +115 -115
- package/skills/systematic-debugging/defense-in-depth.md +122 -122
- package/skills/systematic-debugging/find-polluter.sh +63 -63
- package/skills/systematic-debugging/root-cause-tracing.md +169 -169
- package/skills/systematic-debugging/test-academic.md +14 -14
- package/skills/systematic-debugging/test-pressure-1.md +58 -58
- package/skills/systematic-debugging/test-pressure-2.md +68 -68
- package/skills/systematic-debugging/test-pressure-3.md +69 -69
- package/skills/test-driven-development/SKILL.md +20 -20
- package/skills/verification-before-completion/SKILL.md +154 -154
- package/skills/webapp-testing/SKILL.md +19 -19
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
function writeDiagnostic(context, error) {
|
|
5
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
6
|
+
process.stderr.write(`[snapshot-store] ${context}: ${message}\n`);
|
|
7
|
+
}
|
|
8
|
+
export function createSnapshotStore(snapshotPath) {
|
|
9
|
+
return {
|
|
10
|
+
async write(snapshot) {
|
|
11
|
+
const dir = path.dirname(snapshotPath);
|
|
12
|
+
const tempPath = path.join(dir, `.${path.basename(snapshotPath)}.${randomUUID()}.tmp`);
|
|
13
|
+
try {
|
|
14
|
+
await mkdir(dir, { recursive: true });
|
|
15
|
+
await writeFile(tempPath, JSON.stringify(snapshot), "utf-8");
|
|
16
|
+
await rename(tempPath, snapshotPath);
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
writeDiagnostic(`write failed (${snapshotPath})`, error);
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
async read() {
|
|
23
|
+
try {
|
|
24
|
+
const raw = await readFile(snapshotPath, "utf-8");
|
|
25
|
+
return JSON.parse(raw);
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
if (isNotFound(error))
|
|
29
|
+
return undefined;
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
path() {
|
|
34
|
+
return snapshotPath;
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
function isNotFound(error) {
|
|
39
|
+
return (typeof error === "object" &&
|
|
40
|
+
error !== null &&
|
|
41
|
+
"code" in error &&
|
|
42
|
+
error.code === "ENOENT");
|
|
43
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
export function resolveArtifactPath(repoRoot, relative) {
|
|
4
|
+
if (!relative || path.isAbsolute(relative)) {
|
|
5
|
+
return null;
|
|
6
|
+
}
|
|
7
|
+
if (!isAllowedArtifactRelativePath(relative)) {
|
|
8
|
+
return null;
|
|
9
|
+
}
|
|
10
|
+
const resolvedRepoRoot = path.resolve(repoRoot);
|
|
11
|
+
let realRepoRoot;
|
|
12
|
+
try {
|
|
13
|
+
realRepoRoot = realpathSync(resolvedRepoRoot);
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
realRepoRoot = resolvedRepoRoot;
|
|
17
|
+
}
|
|
18
|
+
const candidate = path.resolve(realRepoRoot, relative);
|
|
19
|
+
if (!isPathInside(realRepoRoot, candidate)) {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
if (!existsSync(candidate)) {
|
|
23
|
+
return candidate;
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
const realCandidate = realpathSync(candidate);
|
|
27
|
+
return isPathInside(realRepoRoot, realCandidate) ? realCandidate : null;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function isAllowedArtifactRelativePath(relative) {
|
|
34
|
+
const normalized = path.normalize(relative);
|
|
35
|
+
const segments = normalized.split(path.sep).filter(Boolean);
|
|
36
|
+
if (segments[0] === ".task-pool") {
|
|
37
|
+
return ["artifacts", "failure-handoffs", "reports"].includes(segments[1] ?? "");
|
|
38
|
+
}
|
|
39
|
+
if (segments[0] === ".harness" && segments[1] === "tasks") {
|
|
40
|
+
return segments.includes("artifacts");
|
|
41
|
+
}
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
export function toRepoRelativeArtifactPath(repoRoot, artifactPath) {
|
|
45
|
+
if (!artifactPath)
|
|
46
|
+
return null;
|
|
47
|
+
const resolvedRoot = path.resolve(repoRoot);
|
|
48
|
+
const resolvedArtifact = path.isAbsolute(artifactPath)
|
|
49
|
+
? path.resolve(artifactPath)
|
|
50
|
+
: path.resolve(resolvedRoot, artifactPath);
|
|
51
|
+
const relative = path.relative(resolvedRoot, resolvedArtifact);
|
|
52
|
+
if (relative === "" ||
|
|
53
|
+
relative === ".." ||
|
|
54
|
+
relative.startsWith(`..${path.sep}`) ||
|
|
55
|
+
path.isAbsolute(relative)) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
return relative.split(path.sep).join("/");
|
|
59
|
+
}
|
|
60
|
+
function isPathInside(root, target) {
|
|
61
|
+
const normalizedRoot = root.endsWith(path.sep) ? root : `${root}${path.sep}`;
|
|
62
|
+
const normalizedTarget = path.normalize(target);
|
|
63
|
+
return normalizedTarget === root || normalizedTarget.startsWith(normalizedRoot);
|
|
64
|
+
}
|
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { parseWorkerEventLine } from "../observability/events.js";
|
|
5
|
+
import { isSafeObservabilityIdentifier } from "../observability/event-store.js";
|
|
6
|
+
import { buildGlobalSnapshot } from "../observability/read-model.js";
|
|
7
|
+
import { resolveArtifactPath, toRepoRelativeArtifactPath, } from "./paths.js";
|
|
8
|
+
const ROUTES = [
|
|
9
|
+
{ method: "GET", pattern: /^\/api\/health$/, handler: handleHealth },
|
|
10
|
+
{ method: "GET", pattern: /^\/api\/snapshot$/, handler: handleSnapshot },
|
|
11
|
+
{ method: "GET", pattern: /^\/api\/batches$/, handler: handleBatches },
|
|
12
|
+
{ method: "GET", pattern: /^\/api\/batches\/([^/]+)$/, handler: handleBatchById },
|
|
13
|
+
{ method: "GET", pattern: /^\/api\/tasks$/, handler: handleTasks },
|
|
14
|
+
{ method: "GET", pattern: /^\/api\/tasks\/([^/]+)$/, handler: handleTaskById },
|
|
15
|
+
{ method: "GET", pattern: /^\/api\/runs\/([^/]+)$/, handler: handleRunById },
|
|
16
|
+
{
|
|
17
|
+
method: "GET",
|
|
18
|
+
pattern: /^\/api\/runs\/([^/]+)\/events$/,
|
|
19
|
+
handler: handleRunEvents,
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
method: "GET",
|
|
23
|
+
pattern: /^\/api\/runs\/([^/]+)\/artifacts$/,
|
|
24
|
+
handler: handleRunArtifacts,
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
method: "GET",
|
|
28
|
+
pattern: /^\/api\/dag-runs\/([^/]+)\/nodes\/([^/]+)\/session-events$/,
|
|
29
|
+
handler: handleDagNodeSessionEvents,
|
|
30
|
+
},
|
|
31
|
+
{ method: "GET", pattern: /^\/api\/dag-runs\/([^/]+)$/, handler: handleDagRunById },
|
|
32
|
+
{ method: "GET", pattern: /^\/api\/artifacts$/, handler: handleArtifactRead },
|
|
33
|
+
{ method: "GET", pattern: /^\/api\/events\/stream$/, handler: handleEventStream },
|
|
34
|
+
];
|
|
35
|
+
export async function handleRequest(req, res, ctx) {
|
|
36
|
+
const method = req.method ?? "GET";
|
|
37
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
38
|
+
const pathname = decodeURIComponent(url.pathname);
|
|
39
|
+
if (method !== "GET") {
|
|
40
|
+
sendJson(res, 405, { error: "Method not allowed" });
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
for (const route of ROUTES) {
|
|
44
|
+
if (route.method !== method)
|
|
45
|
+
continue;
|
|
46
|
+
const match = route.pattern.exec(pathname);
|
|
47
|
+
if (!match)
|
|
48
|
+
continue;
|
|
49
|
+
const params = {};
|
|
50
|
+
if (match[1])
|
|
51
|
+
params.id = match[1];
|
|
52
|
+
if (match[2])
|
|
53
|
+
params.sub = match[2];
|
|
54
|
+
try {
|
|
55
|
+
await route.handler(req, res, { params, query: url.searchParams }, ctx);
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
if (!res.headersSent) {
|
|
59
|
+
sendJson(res, 500, {
|
|
60
|
+
error: error instanceof Error ? error.message : String(error),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
if (pathname.startsWith("/api/")) {
|
|
67
|
+
sendJson(res, 404, { error: "Not found" });
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
export async function serveStatic(req, res, staticDir) {
|
|
73
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
74
|
+
let pathname = decodeURIComponent(url.pathname);
|
|
75
|
+
if (pathname === "/") {
|
|
76
|
+
pathname = "/index.html";
|
|
77
|
+
}
|
|
78
|
+
const filePath = path.join(staticDir, pathname);
|
|
79
|
+
const resolvedStatic = path.resolve(staticDir);
|
|
80
|
+
const resolvedFile = path.resolve(filePath);
|
|
81
|
+
if (!resolvedFile.startsWith(resolvedStatic + path.sep) && resolvedFile !== resolvedStatic) {
|
|
82
|
+
sendJson(res, 404, { error: "Not found" });
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (!existsSync(resolvedFile)) {
|
|
86
|
+
sendJson(res, 404, { error: "Not found" });
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const content = await readFile(resolvedFile);
|
|
90
|
+
const contentType = contentTypeFor(pathname);
|
|
91
|
+
res.writeHead(200, { "Content-Type": contentType });
|
|
92
|
+
res.end(content);
|
|
93
|
+
}
|
|
94
|
+
async function handleHealth(_req, res, _match, ctx) {
|
|
95
|
+
sendJson(res, 200, {
|
|
96
|
+
ok: true,
|
|
97
|
+
repoRoot: ctx.repoRoot,
|
|
98
|
+
generatedAt: new Date().toISOString(),
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
async function handleSnapshot(_req, res, _match, ctx) {
|
|
102
|
+
const snapshot = await buildGlobalSnapshot({ repoRoot: ctx.repoRoot });
|
|
103
|
+
sendJson(res, 200, snapshot);
|
|
104
|
+
}
|
|
105
|
+
async function handleBatches(_req, res, _match, ctx) {
|
|
106
|
+
const snapshot = await buildGlobalSnapshot({ repoRoot: ctx.repoRoot });
|
|
107
|
+
sendJson(res, 200, snapshot.batches);
|
|
108
|
+
}
|
|
109
|
+
async function handleBatchById(_req, res, match, ctx) {
|
|
110
|
+
const snapshot = await buildGlobalSnapshot({ repoRoot: ctx.repoRoot });
|
|
111
|
+
const batch = snapshot.batches.find((b) => b.batchRunId === match.params.id);
|
|
112
|
+
if (!batch) {
|
|
113
|
+
sendJson(res, 404, { error: "Batch not found" });
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
sendJson(res, 200, batch);
|
|
117
|
+
}
|
|
118
|
+
async function handleTasks(_req, res, _match, ctx) {
|
|
119
|
+
const snapshot = await buildGlobalSnapshot({ repoRoot: ctx.repoRoot });
|
|
120
|
+
sendJson(res, 200, snapshot.tasks);
|
|
121
|
+
}
|
|
122
|
+
async function handleTaskById(_req, res, match, ctx) {
|
|
123
|
+
const snapshot = await buildGlobalSnapshot({ repoRoot: ctx.repoRoot });
|
|
124
|
+
const task = snapshot.tasks.find((t) => t.taskId === match.params.id);
|
|
125
|
+
if (!task) {
|
|
126
|
+
sendJson(res, 404, { error: "Task not found" });
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
sendJson(res, 200, task);
|
|
130
|
+
}
|
|
131
|
+
async function handleRunById(_req, res, match, ctx) {
|
|
132
|
+
if (!isSafeObservabilityIdentifier(match.params.id)) {
|
|
133
|
+
sendJson(res, 400, { error: "Invalid run identifier" });
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const snapshot = await buildGlobalSnapshot({ repoRoot: ctx.repoRoot });
|
|
137
|
+
const task = findRunByWorkerRunId(snapshot.tasks, snapshot.batches, match.params.id);
|
|
138
|
+
if (!task) {
|
|
139
|
+
sendJson(res, 404, { error: "Run not found" });
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
sendJson(res, 200, task);
|
|
143
|
+
}
|
|
144
|
+
async function handleRunEvents(_req, res, match, ctx) {
|
|
145
|
+
const workerRunId = match.params.id;
|
|
146
|
+
if (!isSafeObservabilityIdentifier(workerRunId)) {
|
|
147
|
+
sendJson(res, 400, { error: "Invalid run identifier" });
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
const after = parseNonNegativeInt(match.query.get("after"), 0);
|
|
151
|
+
const eventsPath = path.join(ctx.repoRoot, ".task-pool", "observability", "runs", workerRunId, "events.jsonl");
|
|
152
|
+
const events = (await readJsonlLines(eventsPath, after)).map((event) => normalizeEventArtifactRefs(ctx.repoRoot, event));
|
|
153
|
+
sendJson(res, 200, { events });
|
|
154
|
+
}
|
|
155
|
+
async function handleRunArtifacts(_req, res, match, ctx) {
|
|
156
|
+
const workerRunId = match.params.id;
|
|
157
|
+
if (!isSafeObservabilityIdentifier(workerRunId)) {
|
|
158
|
+
sendJson(res, 400, { error: "Invalid run identifier" });
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
const snapshot = await buildGlobalSnapshot({ repoRoot: ctx.repoRoot });
|
|
162
|
+
const task = findRunByWorkerRunId(snapshot.tasks, snapshot.batches, workerRunId);
|
|
163
|
+
const artifacts = [];
|
|
164
|
+
const seen = new Set();
|
|
165
|
+
if (task?.artifactRefs) {
|
|
166
|
+
for (const [label, artifactPath] of Object.entries(task.artifactRefs)) {
|
|
167
|
+
if (!artifactPath || seen.has(artifactPath))
|
|
168
|
+
continue;
|
|
169
|
+
seen.add(artifactPath);
|
|
170
|
+
artifacts.push({ path: artifactPath, label });
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
const eventsPath = path.join(ctx.repoRoot, ".task-pool", "observability", "runs", workerRunId, "events.jsonl");
|
|
174
|
+
const runEvents = await readJsonlLines(eventsPath, 0);
|
|
175
|
+
for (const event of runEvents) {
|
|
176
|
+
const parsed = event;
|
|
177
|
+
if (parsed.workerRunId && parsed.workerRunId !== workerRunId)
|
|
178
|
+
continue;
|
|
179
|
+
if (!parsed.artifactRefs)
|
|
180
|
+
continue;
|
|
181
|
+
for (const [label, artifactPath] of Object.entries(parsed.artifactRefs)) {
|
|
182
|
+
const relativePath = toRepoRelativeArtifactPath(ctx.repoRoot, artifactPath);
|
|
183
|
+
if (!relativePath || seen.has(relativePath))
|
|
184
|
+
continue;
|
|
185
|
+
seen.add(relativePath);
|
|
186
|
+
artifacts.push({ path: relativePath, label });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
sendJson(res, 200, { artifacts });
|
|
190
|
+
}
|
|
191
|
+
async function handleDagRunById(_req, res, match, ctx) {
|
|
192
|
+
const snapshot = await buildGlobalSnapshot({ repoRoot: ctx.repoRoot });
|
|
193
|
+
const dagRun = snapshot.dagRuns.find((d) => d.dagRunId === match.params.id);
|
|
194
|
+
if (!dagRun) {
|
|
195
|
+
sendJson(res, 404, { error: "DAG run not found" });
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
sendJson(res, 200, dagRun);
|
|
199
|
+
}
|
|
200
|
+
async function handleDagNodeSessionEvents(_req, res, match, ctx) {
|
|
201
|
+
const dagRunId = match.params.id;
|
|
202
|
+
const nodeId = match.params.sub;
|
|
203
|
+
if (!isSafeObservabilityIdentifier(dagRunId) || !isSafeObservabilityIdentifier(nodeId)) {
|
|
204
|
+
sendJson(res, 400, { error: "Invalid dag run or node identifier" });
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
const after = parseNonNegativeInt(match.query.get("after"), 0);
|
|
208
|
+
const snapshot = await buildGlobalSnapshot({ repoRoot: ctx.repoRoot });
|
|
209
|
+
const dagRun = snapshot.dagRuns.find((d) => d.dagRunId === dagRunId);
|
|
210
|
+
const eventsPath = resolveSessionEventsPath(ctx.repoRoot, dagRunId, nodeId, dagRun?.dagPath);
|
|
211
|
+
if (!eventsPath) {
|
|
212
|
+
sendJson(res, 400, { error: "Invalid session events path" });
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
if (!existsSync(eventsPath)) {
|
|
216
|
+
sendJson(res, 200, { events: [], nextOffset: 0 });
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
const page = await readJsonlPage(eventsPath, after);
|
|
220
|
+
sendJson(res, 200, page);
|
|
221
|
+
}
|
|
222
|
+
async function handleArtifactRead(_req, res, match, ctx) {
|
|
223
|
+
const relativePath = match.query.get("path") ?? "";
|
|
224
|
+
const resolved = resolveArtifactPath(ctx.repoRoot, relativePath);
|
|
225
|
+
if (!resolved) {
|
|
226
|
+
sendJson(res, 400, { error: "Invalid artifact path" });
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
if (!existsSync(resolved)) {
|
|
230
|
+
sendJson(res, 404, { error: "Artifact not found" });
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
const raw = await readFile(resolved, "utf-8");
|
|
234
|
+
const tail = match.query.get("tail");
|
|
235
|
+
if (tail !== null) {
|
|
236
|
+
const tailLines = parsePositiveInt(tail, 0);
|
|
237
|
+
if (tailLines > 0) {
|
|
238
|
+
const lines = raw.replace(/\n$/, "").split("\n");
|
|
239
|
+
const content = lines.slice(-tailLines).join("\n");
|
|
240
|
+
sendJson(res, 200, { content });
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
sendJson(res, 200, { content: raw });
|
|
245
|
+
}
|
|
246
|
+
async function handleEventStream(req, res, match, ctx) {
|
|
247
|
+
const batchRunId = match.query.get("batchRunId");
|
|
248
|
+
if (!batchRunId) {
|
|
249
|
+
sendJson(res, 400, { error: "batchRunId is required" });
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
res.writeHead(200, {
|
|
253
|
+
"Content-Type": "text/event-stream",
|
|
254
|
+
"Cache-Control": "no-cache",
|
|
255
|
+
Connection: "keep-alive",
|
|
256
|
+
});
|
|
257
|
+
const observabilityRoot = path.join(ctx.repoRoot, ".task-pool", "observability");
|
|
258
|
+
const eventsPath = path.join(observabilityRoot, "events.jsonl");
|
|
259
|
+
let offset = 0;
|
|
260
|
+
const sendMatchingEvents = async (initial) => {
|
|
261
|
+
const page = await readFilteredEvents(eventsPath, offset, batchRunId, ctx.repoRoot);
|
|
262
|
+
offset = page.nextOffset;
|
|
263
|
+
for (const event of page.events) {
|
|
264
|
+
if (initial || !res.writableEnded) {
|
|
265
|
+
res.write(`data: ${JSON.stringify(event)}\n\n`);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
await sendMatchingEvents(true);
|
|
270
|
+
const timer = setInterval(() => {
|
|
271
|
+
void sendMatchingEvents(false).catch(() => {
|
|
272
|
+
clearInterval(timer);
|
|
273
|
+
if (!res.writableEnded)
|
|
274
|
+
res.end();
|
|
275
|
+
});
|
|
276
|
+
}, ctx.pollIntervalMs);
|
|
277
|
+
req.on("close", () => {
|
|
278
|
+
clearInterval(timer);
|
|
279
|
+
if (!res.writableEnded)
|
|
280
|
+
res.end();
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
function findRunByWorkerRunId(tasks, batches, workerRunId) {
|
|
284
|
+
return (tasks.find((task) => task.workerRunId === workerRunId) ??
|
|
285
|
+
batches.flatMap((batch) => batch.tasks).find((task) => task.workerRunId === workerRunId));
|
|
286
|
+
}
|
|
287
|
+
async function readJsonlLines(filePath, after) {
|
|
288
|
+
const page = await readJsonlPage(filePath, after);
|
|
289
|
+
return page.events;
|
|
290
|
+
}
|
|
291
|
+
async function readJsonlPage(filePath, after) {
|
|
292
|
+
try {
|
|
293
|
+
const raw = await readFile(filePath, "utf-8");
|
|
294
|
+
const lines = raw
|
|
295
|
+
.split("\n")
|
|
296
|
+
.map((line) => line.trim())
|
|
297
|
+
.filter(Boolean);
|
|
298
|
+
const events = lines.slice(after).map((line) => {
|
|
299
|
+
try {
|
|
300
|
+
return JSON.parse(line);
|
|
301
|
+
}
|
|
302
|
+
catch {
|
|
303
|
+
return undefined;
|
|
304
|
+
}
|
|
305
|
+
}).filter((line) => line !== undefined);
|
|
306
|
+
return { events, nextOffset: lines.length };
|
|
307
|
+
}
|
|
308
|
+
catch {
|
|
309
|
+
return { events: [], nextOffset: after };
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
const DAG_RUN_LIFECYCLE_DIRS = ["active", "completed", "paused"];
|
|
313
|
+
function resolveSessionEventsPath(repoRoot, dagRunId, nodeId, dagRunDir) {
|
|
314
|
+
if (!isSafeObservabilityIdentifier(dagRunId) || !isSafeObservabilityIdentifier(nodeId)) {
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
const dagRunsRoot = path.resolve(repoRoot, ".harness", "dag-runs");
|
|
318
|
+
const candidates = [];
|
|
319
|
+
if (dagRunDir) {
|
|
320
|
+
candidates.push(path.join(dagRunDir, nodeId, "session-events.jsonl"));
|
|
321
|
+
}
|
|
322
|
+
for (const lifecycle of DAG_RUN_LIFECYCLE_DIRS) {
|
|
323
|
+
candidates.push(path.join(dagRunsRoot, lifecycle, dagRunId, nodeId, "session-events.jsonl"));
|
|
324
|
+
}
|
|
325
|
+
for (const candidate of candidates) {
|
|
326
|
+
const resolved = path.resolve(candidate);
|
|
327
|
+
if (!isPathInside(dagRunsRoot, resolved))
|
|
328
|
+
continue;
|
|
329
|
+
if (!isExpectedSessionEventsRelativePath(dagRunsRoot, resolved, dagRunId, nodeId)) {
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
if (existsSync(resolved))
|
|
333
|
+
return resolved;
|
|
334
|
+
}
|
|
335
|
+
const fallback = path.resolve(dagRunsRoot, "active", dagRunId, nodeId, "session-events.jsonl");
|
|
336
|
+
if (isPathInside(dagRunsRoot, fallback) &&
|
|
337
|
+
isExpectedSessionEventsRelativePath(dagRunsRoot, fallback, dagRunId, nodeId)) {
|
|
338
|
+
return fallback;
|
|
339
|
+
}
|
|
340
|
+
return null;
|
|
341
|
+
}
|
|
342
|
+
function isExpectedSessionEventsRelativePath(dagRunsRoot, resolvedPath, dagRunId, nodeId) {
|
|
343
|
+
const relative = path.relative(dagRunsRoot, resolvedPath);
|
|
344
|
+
const parts = relative.split(path.sep).filter(Boolean);
|
|
345
|
+
return (parts.length === 4 &&
|
|
346
|
+
DAG_RUN_LIFECYCLE_DIRS.includes(parts[0]) &&
|
|
347
|
+
parts[1] === dagRunId &&
|
|
348
|
+
parts[2] === nodeId &&
|
|
349
|
+
parts[3] === "session-events.jsonl");
|
|
350
|
+
}
|
|
351
|
+
function isPathInside(root, target) {
|
|
352
|
+
const normalizedRoot = root.endsWith(path.sep) ? root : `${root}${path.sep}`;
|
|
353
|
+
const normalizedTarget = path.normalize(target);
|
|
354
|
+
return normalizedTarget === root || normalizedTarget.startsWith(normalizedRoot);
|
|
355
|
+
}
|
|
356
|
+
async function readFilteredEvents(filePath, offset, batchRunId, repoRoot) {
|
|
357
|
+
try {
|
|
358
|
+
const raw = await readFile(filePath, "utf-8");
|
|
359
|
+
const lines = raw
|
|
360
|
+
.split("\n")
|
|
361
|
+
.map((line) => line.trim())
|
|
362
|
+
.filter(Boolean);
|
|
363
|
+
const events = [];
|
|
364
|
+
for (let i = offset; i < lines.length; i++) {
|
|
365
|
+
const event = parseWorkerEventLine(lines[i]);
|
|
366
|
+
if (event && event.batchRunId === batchRunId) {
|
|
367
|
+
events.push(normalizeEventArtifactRefs(repoRoot, event));
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
return { events, nextOffset: lines.length };
|
|
371
|
+
}
|
|
372
|
+
catch {
|
|
373
|
+
return { events: [], nextOffset: offset };
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
function normalizeEventArtifactRefs(repoRoot, value) {
|
|
377
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
378
|
+
return value;
|
|
379
|
+
const event = value;
|
|
380
|
+
if (!event.artifactRefs)
|
|
381
|
+
return event;
|
|
382
|
+
const artifactRefs = {};
|
|
383
|
+
for (const [key, artifactPath] of Object.entries(event.artifactRefs)) {
|
|
384
|
+
const relativePath = toRepoRelativeArtifactPath(repoRoot, artifactPath);
|
|
385
|
+
if (relativePath)
|
|
386
|
+
artifactRefs[key] = relativePath;
|
|
387
|
+
}
|
|
388
|
+
return {
|
|
389
|
+
...event,
|
|
390
|
+
artifactRefs,
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
function parseNonNegativeInt(value, fallback) {
|
|
394
|
+
if (value === null)
|
|
395
|
+
return fallback;
|
|
396
|
+
const parsed = Number.parseInt(value, 10);
|
|
397
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
|
|
398
|
+
}
|
|
399
|
+
function parsePositiveInt(value, fallback) {
|
|
400
|
+
if (value === null)
|
|
401
|
+
return fallback;
|
|
402
|
+
const parsed = Number.parseInt(value, 10);
|
|
403
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
404
|
+
}
|
|
405
|
+
function sendJson(res, status, body) {
|
|
406
|
+
const payload = JSON.stringify(body);
|
|
407
|
+
res.writeHead(status, {
|
|
408
|
+
"Content-Type": "application/json",
|
|
409
|
+
"Content-Length": Buffer.byteLength(payload),
|
|
410
|
+
});
|
|
411
|
+
res.end(payload);
|
|
412
|
+
}
|
|
413
|
+
function contentTypeFor(pathname) {
|
|
414
|
+
if (pathname.endsWith(".html"))
|
|
415
|
+
return "text/html; charset=utf-8";
|
|
416
|
+
if (pathname.endsWith(".js"))
|
|
417
|
+
return "text/javascript; charset=utf-8";
|
|
418
|
+
if (pathname.endsWith(".css"))
|
|
419
|
+
return "text/css; charset=utf-8";
|
|
420
|
+
if (pathname.endsWith(".json"))
|
|
421
|
+
return "application/json; charset=utf-8";
|
|
422
|
+
return "application/octet-stream";
|
|
423
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { handleRequest, serveStatic } from "./routes.js";
|
|
5
|
+
const STATIC_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "static");
|
|
6
|
+
export function createObserveServer(options) {
|
|
7
|
+
const repoRoot = path.resolve(options.repoRoot);
|
|
8
|
+
const host = options.host ?? "127.0.0.1";
|
|
9
|
+
if (!isLoopbackHost(host)) {
|
|
10
|
+
return Promise.reject(new Error(`observe serve is local-only; refusing non-loopback host ${host}`));
|
|
11
|
+
}
|
|
12
|
+
const port = options.port ?? 0;
|
|
13
|
+
const pollIntervalMs = options.pollIntervalMs ?? 1000;
|
|
14
|
+
return new Promise((resolve, reject) => {
|
|
15
|
+
const server = createServer((req, res) => {
|
|
16
|
+
void dispatch(req, res).catch((error) => {
|
|
17
|
+
if (!res.headersSent) {
|
|
18
|
+
const payload = JSON.stringify({
|
|
19
|
+
error: error instanceof Error ? error.message : String(error),
|
|
20
|
+
});
|
|
21
|
+
res.writeHead(500, {
|
|
22
|
+
"Content-Type": "application/json",
|
|
23
|
+
"Content-Length": Buffer.byteLength(payload),
|
|
24
|
+
});
|
|
25
|
+
res.end(payload);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
async function dispatch(req, res) {
|
|
30
|
+
const handled = await handleRequest(req, res, {
|
|
31
|
+
repoRoot,
|
|
32
|
+
pollIntervalMs,
|
|
33
|
+
staticDir: STATIC_DIR,
|
|
34
|
+
});
|
|
35
|
+
if (!handled) {
|
|
36
|
+
await serveStatic(req, res, STATIC_DIR);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
server.on("error", reject);
|
|
40
|
+
server.listen(port, host, () => {
|
|
41
|
+
const addr = server.address();
|
|
42
|
+
if (!addr || typeof addr === "string") {
|
|
43
|
+
reject(new Error("Failed to get server address"));
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
resolve({
|
|
47
|
+
url: `http://${host}:${addr.port}`,
|
|
48
|
+
close: () => new Promise((closeResolve, closeReject) => {
|
|
49
|
+
server.close((err) => (err ? closeReject(err) : closeResolve()));
|
|
50
|
+
}),
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
function isLoopbackHost(host) {
|
|
56
|
+
const normalized = host.toLowerCase();
|
|
57
|
+
return (normalized === "localhost" ||
|
|
58
|
+
normalized === "::1" ||
|
|
59
|
+
normalized === "[::1]" ||
|
|
60
|
+
/^127(?:\.\d{1,3}){3}$/.test(normalized));
|
|
61
|
+
}
|