@tea-agent/loop-agent 0.10.0 → 0.12.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 +10 -2
- package/CHANGELOG.md +91 -24
- package/README.md +84 -12
- package/dist/application/dag/args.js +1 -12
- package/dist/application/dag/generate-task-dag.js +38 -2
- package/dist/application/dag/run-dag.js +11 -27
- package/dist/application/dag/validate-dag.js +13 -2
- package/dist/application/loop/run-action.js +0 -4
- package/dist/cli/command-definitions.js +44 -16
- package/dist/cli/program.js +40 -23
- package/dist/cli/update/notifier.js +117 -0
- package/dist/cli/update/npm-client.js +151 -0
- package/dist/cli/update/policy.js +58 -0
- package/dist/cli/update/state.js +68 -0
- package/dist/cli.js +33 -0
- package/dist/commands/cursor-prompt.js +42 -82
- package/dist/commands/dag-approve.js +36 -0
- package/dist/commands/delegate.js +75 -77
- package/dist/commands/doctor.js +0 -18
- package/dist/commands/init.js +547 -95
- package/dist/commands/instructions.js +7 -10
- package/dist/commands/loop.js +4 -20
- package/dist/commands/plan.js +50 -0
- package/dist/executors/config-core.js +0 -51
- package/dist/executors/dag-pi-executor.js +1 -1
- package/dist/executors/dag.js +0 -1
- package/dist/executors/index.js +0 -2
- package/dist/executors/model-routing.js +9 -9
- package/dist/executors/shell-executor.js +1 -1
- package/dist/governance/checks.js +6 -3
- package/dist/governance/exec-plans.js +545 -0
- package/dist/governance/manifest-types.js +24 -2
- package/dist/infrastructure/harness/loop-action-store.js +0 -3
- package/dist/records/harvest.js +2 -23
- package/dist/records/one-shot-runs.js +1 -1
- package/dist/shared/artifacts-core.js +24 -5
- package/dist/shared/output-truncation.js +37 -0
- package/dist/shared/package-metadata.js +488 -0
- package/dist/{executors/cursor-executor.js → sidecars/cursor-prompt/executor.js} +2 -42
- package/dist/sidecars/cursor-prompt/index.js +3 -0
- package/dist/sidecars/cursor-prompt/stream.js +121 -0
- package/dist/task/config-types.js +29 -12
- package/dist/task/delegate.js +9 -21
- package/dist/task/runtime.js +1 -2
- package/dist/worker/cli.js +32 -3
- package/dist/worker/delivery/final-verification.js +47 -11
- package/dist/worker/delivery/package.js +63 -10
- package/dist/worker/feature/run.js +60 -8
- package/dist/worker/loop-agent/loop-agent-client.js +329 -126
- package/dist/worker/observability/event-history.js +216 -0
- package/dist/worker/observability/read-model.js +338 -83
- package/dist/worker/observe/paths.js +17 -0
- package/dist/worker/observe/routes.js +165 -21
- package/dist/worker/observe/server.js +59 -1
- package/dist/worker/observe/static/api.js +27 -0
- package/dist/worker/observe/static/app.js +120 -2317
- 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 +128 -0
- package/dist/worker/observe/static/router.js +85 -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 +1720 -495
- package/dist/worker/observe/static/views/batch.js +226 -0
- package/dist/worker/observe/static/views/dag-graph.js +172 -0
- package/dist/worker/observe/static/views/dag-inspector.js +477 -0
- package/dist/worker/observe/static/views/dag.js +362 -0
- package/dist/worker/observe/static/views/dashboard.js +442 -0
- package/dist/worker/observe/static/views/failures.js +143 -0
- package/dist/worker/observe/static/views/feature.js +453 -0
- package/dist/worker/observe/static/views/pool.js +347 -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 +260 -0
- package/dist/worker/observe/static/views/timeline.js +163 -0
- package/dist/worker/preflight.js +49 -1
- package/dist/worker/run-task/run-task.js +22 -12
- package/dist/worker/runner/run-ready.js +76 -12
- package/dist/worker/task-spec/schema.js +0 -1
- package/dist/workflows/dag/controller-identity.js +104 -0
- package/dist/workflows/dag/convergence/controller.js +1 -1
- package/dist/workflows/dag/executor-registry.js +0 -2
- package/dist/workflows/dag/init-hybrid.js +797 -27
- package/dist/workflows/dag/node-execution.js +183 -35
- 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 +77 -17
- package/dist/workflows/dag/runtime-contract.js +87 -0
- package/dist/workflows/dag/scheduler.js +7 -2
- package/dist/workflows/dag/sdd-embedded.js +128 -0
- package/dist/workflows/dag/skill-instructions.js +5 -4
- package/dist/workflows/dag/skill-snapshot.js +529 -0
- package/dist/workflows/dag/types.js +86 -10
- package/dist/workflows/dag/validate.js +73 -12
- package/dist/workflows/loop/actions/dag-action.js +0 -2
- package/dist/workflows/loop/actions/shared.js +1 -1
- package/dist/workflows/loop/actions.js +14 -31
- package/dist/workflows/loop/benchmark.js +1 -1
- package/dist/workflows/loop/index.js +1 -1
- package/dist/workflows/loop/policy/auto-policy.js +22 -14
- package/dist/workflows/loop/policy/path-patterns.js +13 -0
- package/docs/README.md +36 -33
- package/docs/agent-dag-recovery-playbook.md +1 -1
- package/docs/agent-dag-runner.md +28 -3
- package/docs/architecture/README.md +26 -0
- package/docs/architecture/dag-execution.md +140 -0
- package/docs/architecture/evolution.md +53 -0
- package/docs/architecture/facts-and-state.md +58 -0
- package/docs/architecture/runtime-boundaries.md +45 -17
- package/docs/architecture/system-overview.md +93 -0
- package/docs/architecture/worker-and-feature.md +81 -0
- package/docs/cursor-prompt-sidecar.md +36 -0
- package/docs/decisions/README.md +13 -1
- package/docs/design/README.md +43 -21
- package/docs/development-principles.md +2 -2
- package/docs/exec-plans/active/README.md +1 -3
- package/docs/exec-plans/completed/README.md +23 -0
- package/docs/feature-workflow.md +78 -4
- package/docs/harness-methodology-debugging.md +1 -1
- package/docs/harness-methodology-tdd.md +3 -3
- package/docs/init-surface.manifest.json +60 -25
- package/docs/loop-agent-harness.md +28 -4
- package/docs/progress/README.md +50 -1
- package/docs/reports/README.md +90 -18
- package/docs/skills/README.md +2 -1
- package/docs/skills/vetted-skill-registry.md +2 -1
- package/docs/templates/agent-dag-report.schema.json +23 -6
- package/docs/templates/agent-dag.base.json +0 -5
- package/docs/templates/agent-dag.final-verification.json +0 -5
- package/docs/templates/agent-dag.schema.json +70 -3
- package/docs/templates/agent-dag.supervised-implementation.json +9 -8
- package/docs/templates/backend-test-dag.generate-pytest.prompt.md +139 -0
- package/docs/templates/backend-test-dag.json +276 -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/frontend-design-contract.md +33 -0
- package/docs/templates/frontend-task-constraints.md +25 -0
- package/docs/templates/frontend-task-requirement.md +61 -0
- package/docs/templates/harness.schema.json +10 -12
- package/docs/templates/hybrid-dag.json +1 -6
- package/docs/templates/interactive-ui-round2-experiment.md +1 -1
- package/docs/templates/product-line/task.yaml +0 -1
- package/docs/templates/project-start-checklist.md +2 -2
- package/docs/templates/worker-dogfood-evidence.md +28 -0
- package/docs/templates/worker-dogfood-setup.md +20 -0
- package/docs/verification-matrix.md +10 -0
- package/examples/decision-gate-agent-dag.json +87 -33
- package/examples/example-dag.json +0 -5
- package/examples/hybrid-loop-agent-dag.json +0 -5
- package/harness.json +7 -15
- package/package.json +22 -46
- package/scripts/check-product-line-docs.sh +10 -7
- package/skills/agent-worker/SKILL.md +37 -0
- package/skills/agent-worker/references/agent-worker-operator.md +43 -0
- package/skills/frontend-design-review/SKILL.md +59 -0
- package/skills/frontend-design-review/references/review-checklist.md +37 -0
- package/skills/frontend-implementation/SKILL.md +51 -0
- package/skills/frontend-implementation/references/code-standards.md +34 -0
- package/skills/frontend-implementation/references/design-spec.md +46 -0
- package/skills/frontend-implementation/references/node-contracts.md +32 -0
- package/skills/frontend-review/SKILL.md +53 -0
- package/skills/frontend-review/references/review-findings.md +42 -0
- package/skills/frontend-verification/SKILL.md +40 -0
- package/skills/frontend-verification/references/verification-checklist.md +56 -0
- package/skills/grill-me/SKILL.md +10 -0
- package/skills/grill-with-docs/SKILL.md +88 -0
- package/skills/grill-with-docs/adr-format.md +47 -0
- package/skills/grill-with-docs/context-format.md +60 -0
- package/skills/loop-agent/SKILL.md +11 -9
- package/skills/loop-agent/references/command-reference.md +14 -15
- package/skills/loop-agent/references/docs-converge.md +126 -0
- package/skills/loop-agent/references/harness-policy.md +7 -7
- package/skills/loop-agent/references/hybrid-dag.md +36 -20
- package/skills/loop-agent/references/long-running-loop.md +4 -6
- package/skills/loop-agent/references/multi-worktree.md +6 -6
- package/skills/loop-agent/references/orchestrator-and-interventions.md +3 -3
- package/skills/loop-agent/references/pi-subagent-assisted-mode.md +14 -11
- package/skills/loop-agent/references/task-workflow.md +1 -1
- package/skills/loop-agent/references/verification-and-failure-handling.md +6 -0
- package/skills/using-git-worktrees/SKILL.md +215 -0
- package/dist/commands/cursor-worker.js +0 -43
- package/dist/cursor-worker-entry.js +0 -8
- package/dist/executors/cursor-artifacts.js +0 -33
- package/dist/executors/cursor-execution-log.js +0 -81
- package/dist/executors/cursor-executor-artifacts.js +0 -134
- package/dist/executors/cursor-run.js +0 -115
- package/dist/executors/cursor-tool.js +0 -94
- package/dist/executors/cursor-worker-client.js +0 -223
- package/dist/executors/cursor-worker-protocol.js +0 -18
- package/dist/executors/cursor-worker-server.js +0 -54
- package/dist/executors/cursor-worker.js +0 -3
- package/dist/executors/cursor.js +0 -6
- package/dist/executors/dag-cursor-executor.js +0 -87
- package/dist/workflows/loop/actions/cursor-fix.js +0 -191
- package/dist/workflows/loop/policy/cursor-fix-policy.js +0 -31
- package/docs/cursor-executor-usage.md +0 -25
- package/docs/dynamic-workflow-dag-engine-roadmap.md +0 -1749
|
@@ -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,14 @@
|
|
|
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, } 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
|
+
const ARTIFACT_PREVIEW_MAX_BYTES = 64 * 1024;
|
|
9
12
|
export function createObserveSnapshotCache() {
|
|
10
13
|
return { expiresAt: 0 };
|
|
11
14
|
}
|
|
@@ -13,9 +16,32 @@ const ROUTES = [
|
|
|
13
16
|
{ method: "GET", pattern: /^\/api\/health$/, handler: handleHealth },
|
|
14
17
|
{ method: "GET", pattern: /^\/api\/snapshot$/, handler: handleSnapshot },
|
|
15
18
|
{ method: "GET", pattern: /^\/api\/batches$/, handler: handleBatches },
|
|
16
|
-
{
|
|
19
|
+
{
|
|
20
|
+
method: "GET",
|
|
21
|
+
pattern: /^\/api\/batches\/([^/]+)\/events$/,
|
|
22
|
+
handler: handleBatchEvents,
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
method: "GET",
|
|
26
|
+
pattern: /^\/api\/batches\/([^/]+)$/,
|
|
27
|
+
handler: handleBatchById,
|
|
28
|
+
},
|
|
29
|
+
/**
|
|
30
|
+
* Pool bounded event history (cursor/limit). Distinct from SSE `/api/events/stream`
|
|
31
|
+
* and from run/session `?after=<line-offset>` integer offsets.
|
|
32
|
+
*/
|
|
33
|
+
{ method: "GET", pattern: /^\/api\/events$/, handler: handlePoolEvents },
|
|
17
34
|
{ method: "GET", pattern: /^\/api\/tasks$/, handler: handleTasks },
|
|
18
|
-
{
|
|
35
|
+
{
|
|
36
|
+
method: "GET",
|
|
37
|
+
pattern: /^\/api\/tasks\/([^/]+)\/runs$/,
|
|
38
|
+
handler: handleTaskRuns,
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
method: "GET",
|
|
42
|
+
pattern: /^\/api\/tasks\/([^/]+)$/,
|
|
43
|
+
handler: handleTaskById,
|
|
44
|
+
},
|
|
19
45
|
{ method: "GET", pattern: /^\/api\/runs\/([^/]+)$/, handler: handleRunById },
|
|
20
46
|
{
|
|
21
47
|
method: "GET",
|
|
@@ -32,9 +58,17 @@ const ROUTES = [
|
|
|
32
58
|
pattern: /^\/api\/dag-runs\/([^/]+)\/nodes\/([^/]+)\/session-events$/,
|
|
33
59
|
handler: handleDagNodeSessionEvents,
|
|
34
60
|
},
|
|
35
|
-
{
|
|
61
|
+
{
|
|
62
|
+
method: "GET",
|
|
63
|
+
pattern: /^\/api\/dag-runs\/([^/]+)$/,
|
|
64
|
+
handler: handleDagRunById,
|
|
65
|
+
},
|
|
36
66
|
{ method: "GET", pattern: /^\/api\/artifacts$/, handler: handleArtifactRead },
|
|
37
|
-
{
|
|
67
|
+
{
|
|
68
|
+
method: "GET",
|
|
69
|
+
pattern: /^\/api\/events\/stream$/,
|
|
70
|
+
handler: handleEventStream,
|
|
71
|
+
},
|
|
38
72
|
];
|
|
39
73
|
export async function handleRequest(req, res, ctx) {
|
|
40
74
|
const method = req.method ?? "GET";
|
|
@@ -82,7 +116,8 @@ export async function serveStatic(req, res, staticDir) {
|
|
|
82
116
|
const filePath = path.join(staticDir, pathname);
|
|
83
117
|
const resolvedStatic = path.resolve(staticDir);
|
|
84
118
|
const resolvedFile = path.resolve(filePath);
|
|
85
|
-
if (!resolvedFile.startsWith(resolvedStatic + path.sep) &&
|
|
119
|
+
if (!resolvedFile.startsWith(resolvedStatic + path.sep) &&
|
|
120
|
+
resolvedFile !== resolvedStatic) {
|
|
86
121
|
sendJson(res, 404, { error: "Not found" });
|
|
87
122
|
return;
|
|
88
123
|
}
|
|
@@ -141,6 +176,34 @@ async function handleBatchById(_req, res, match, ctx) {
|
|
|
141
176
|
}
|
|
142
177
|
sendJson(res, 200, batch);
|
|
143
178
|
}
|
|
179
|
+
/**
|
|
180
|
+
* Bounded batch event history. Query: `limit` (default 50, max 200), `cursor`
|
|
181
|
+
* (opaque history cursor). Not the same as run/SSE `after` line offsets.
|
|
182
|
+
*/
|
|
183
|
+
async function handleBatchEvents(_req, res, match, ctx) {
|
|
184
|
+
const batchRunId = match.params.id;
|
|
185
|
+
if (!isSafeObservabilityIdentifier(batchRunId)) {
|
|
186
|
+
sendJson(res, 404, { error: "Batch not found" });
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
const limit = clampEventHistoryLimit(parsePositiveInt(match.query.get("limit"), NaN));
|
|
190
|
+
const cursor = match.query.get("cursor") ?? undefined;
|
|
191
|
+
const page = await listBatchEventHistory(ctx.repoRoot, batchRunId, {
|
|
192
|
+
limit,
|
|
193
|
+
cursor,
|
|
194
|
+
});
|
|
195
|
+
sendJson(res, 200, page);
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Bounded pool-wide event history. Path is `/api/events` (JSON page), not
|
|
199
|
+
* `/api/events/stream` (SSE). Uses `cursor`/`limit`, not integer `after`.
|
|
200
|
+
*/
|
|
201
|
+
async function handlePoolEvents(_req, res, match, ctx) {
|
|
202
|
+
const limit = clampEventHistoryLimit(parsePositiveInt(match.query.get("limit"), NaN));
|
|
203
|
+
const cursor = match.query.get("cursor") ?? undefined;
|
|
204
|
+
const page = await listPoolEventHistory(ctx.repoRoot, { limit, cursor });
|
|
205
|
+
sendJson(res, 200, page);
|
|
206
|
+
}
|
|
144
207
|
async function handleTasks(_req, res, _match, ctx) {
|
|
145
208
|
const snapshot = await getSnapshot(ctx);
|
|
146
209
|
sendJson(res, 200, snapshot.tasks);
|
|
@@ -154,6 +217,33 @@ async function handleTaskById(_req, res, match, ctx) {
|
|
|
154
217
|
}
|
|
155
218
|
sendJson(res, 200, task);
|
|
156
219
|
}
|
|
220
|
+
async function handleTaskRuns(_req, res, match, ctx) {
|
|
221
|
+
const taskId = match.params.id;
|
|
222
|
+
if (!isSafeObservabilityIdentifier(taskId)) {
|
|
223
|
+
sendJson(res, 400, { error: "Invalid task identifier" });
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
const snapshot = await getSnapshot(ctx);
|
|
227
|
+
const known = snapshot.tasks.some((t) => t.taskId === taskId) ||
|
|
228
|
+
snapshot.batches.some((b) => b.tasks.some((t) => t.taskId === taskId));
|
|
229
|
+
if (!known) {
|
|
230
|
+
// Still allow history when ledger has runs but current snapshot has no task row
|
|
231
|
+
const pageProbe = await listTaskRunHistory(ctx.repoRoot, taskId, {
|
|
232
|
+
limit: 1,
|
|
233
|
+
});
|
|
234
|
+
if (pageProbe.runs.length === 0) {
|
|
235
|
+
sendJson(res, 404, { error: "Task not found" });
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
const limit = clampTaskRunHistoryLimit(parsePositiveInt(match.query.get("limit"), 0) || undefined);
|
|
240
|
+
const before = match.query.get("before");
|
|
241
|
+
const page = await listTaskRunHistory(ctx.repoRoot, taskId, {
|
|
242
|
+
limit,
|
|
243
|
+
before: before && before.length > 0 ? before : null,
|
|
244
|
+
});
|
|
245
|
+
sendJson(res, 200, page);
|
|
246
|
+
}
|
|
157
247
|
async function handleRunById(_req, res, match, ctx) {
|
|
158
248
|
if (!isSafeObservabilityIdentifier(match.params.id)) {
|
|
159
249
|
sendJson(res, 400, { error: "Invalid run identifier" });
|
|
@@ -226,7 +316,8 @@ async function handleDagRunById(_req, res, match, ctx) {
|
|
|
226
316
|
async function handleDagNodeSessionEvents(_req, res, match, ctx) {
|
|
227
317
|
const dagRunId = match.params.id;
|
|
228
318
|
const nodeId = match.params.sub;
|
|
229
|
-
if (!isSafeObservabilityIdentifier(dagRunId) ||
|
|
319
|
+
if (!isSafeObservabilityIdentifier(dagRunId) ||
|
|
320
|
+
!isSafeObservabilityIdentifier(nodeId)) {
|
|
230
321
|
sendJson(res, 400, { error: "Invalid dag run or node identifier" });
|
|
231
322
|
return;
|
|
232
323
|
}
|
|
@@ -248,7 +339,7 @@ async function handleDagNodeSessionEvents(_req, res, match, ctx) {
|
|
|
248
339
|
async function handleArtifactRead(_req, res, match, ctx) {
|
|
249
340
|
const relativePath = match.query.get("path") ?? "";
|
|
250
341
|
const resolved = resolveArtifactPath(ctx.repoRoot, relativePath);
|
|
251
|
-
if (!resolved) {
|
|
342
|
+
if (!resolved || !isAllowedArtifactTextPath(relativePath)) {
|
|
252
343
|
sendJson(res, 400, { error: "Invalid artifact path" });
|
|
253
344
|
return;
|
|
254
345
|
}
|
|
@@ -256,18 +347,47 @@ async function handleArtifactRead(_req, res, match, ctx) {
|
|
|
256
347
|
sendJson(res, 404, { error: "Artifact not found" });
|
|
257
348
|
return;
|
|
258
349
|
}
|
|
259
|
-
const
|
|
350
|
+
const artifactStat = await stat(resolved);
|
|
351
|
+
if (!artifactStat.isFile()) {
|
|
352
|
+
sendJson(res, 400, { error: "Artifact is not a readable text file" });
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
const sourceBytes = artifactStat.size;
|
|
356
|
+
const start = Math.max(0, sourceBytes - ARTIFACT_PREVIEW_MAX_BYTES);
|
|
357
|
+
const length = Math.min(sourceBytes, ARTIFACT_PREVIEW_MAX_BYTES);
|
|
358
|
+
const buffer = Buffer.alloc(length);
|
|
359
|
+
const handle = await openFile(resolved, "r");
|
|
360
|
+
let bytesRead = 0;
|
|
361
|
+
try {
|
|
362
|
+
({ bytesRead } = await handle.read(buffer, 0, length, start));
|
|
363
|
+
}
|
|
364
|
+
finally {
|
|
365
|
+
await handle.close();
|
|
366
|
+
}
|
|
367
|
+
let raw = buffer.subarray(0, bytesRead).toString("utf-8");
|
|
368
|
+
let truncated = start > 0;
|
|
369
|
+
if (truncated) {
|
|
370
|
+
const firstCompleteLine = raw.indexOf("\n");
|
|
371
|
+
raw = firstCompleteLine >= 0 ? raw.slice(firstCompleteLine + 1) : "";
|
|
372
|
+
}
|
|
260
373
|
const tail = match.query.get("tail");
|
|
261
374
|
if (tail !== null) {
|
|
262
375
|
const tailLines = parsePositiveInt(tail, 0);
|
|
263
376
|
if (tailLines > 0) {
|
|
264
377
|
const lines = raw.replace(/\n$/, "").split("\n");
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
return;
|
|
378
|
+
truncated = truncated || lines.length > tailLines;
|
|
379
|
+
raw = lines.slice(-tailLines).join("\n");
|
|
268
380
|
}
|
|
269
381
|
}
|
|
270
|
-
|
|
382
|
+
const redacted = redactSecrets(raw);
|
|
383
|
+
const content = truncateUtf8Preview(redacted, ARTIFACT_PREVIEW_MAX_BYTES);
|
|
384
|
+
truncated = truncated || content !== redacted;
|
|
385
|
+
sendJson(res, 200, {
|
|
386
|
+
content,
|
|
387
|
+
truncated,
|
|
388
|
+
sourceBytes,
|
|
389
|
+
maxBytes: ARTIFACT_PREVIEW_MAX_BYTES,
|
|
390
|
+
});
|
|
271
391
|
}
|
|
272
392
|
async function handleEventStream(req, res, match, ctx) {
|
|
273
393
|
const batchRunId = match.query.get("batchRunId");
|
|
@@ -277,16 +397,34 @@ async function handleEventStream(req, res, match, ctx) {
|
|
|
277
397
|
}
|
|
278
398
|
res.writeHead(200, {
|
|
279
399
|
"Content-Type": "text/event-stream",
|
|
280
|
-
"Cache-Control": "no-cache",
|
|
400
|
+
"Cache-Control": "no-cache, no-transform",
|
|
281
401
|
Connection: "keep-alive",
|
|
402
|
+
// Disable proxy buffering (nginx) so heartbeats reach the client.
|
|
403
|
+
"X-Accel-Buffering": "no",
|
|
282
404
|
});
|
|
405
|
+
// Flush headers immediately for proxies that wait on first body byte.
|
|
406
|
+
if (typeof res.flushHeaders === "function") {
|
|
407
|
+
res.flushHeaders();
|
|
408
|
+
}
|
|
283
409
|
const observabilityRoot = path.join(getTaskPoolRoot(ctx.repoRoot), "observability");
|
|
284
410
|
const eventsPath = path.join(observabilityRoot, "events.jsonl");
|
|
285
411
|
let offset = 0;
|
|
286
412
|
let knownFileSize;
|
|
287
413
|
let timer;
|
|
288
414
|
let closed = false;
|
|
415
|
+
let lastHeartbeatAt = 0;
|
|
416
|
+
const heartbeatEveryMs = Math.max(15_000, ctx.pollIntervalMs * 10);
|
|
417
|
+
const writeHeartbeat = () => {
|
|
418
|
+
if (closed || res.writableEnded)
|
|
419
|
+
return;
|
|
420
|
+
const now = Date.now();
|
|
421
|
+
if (now - lastHeartbeatAt < heartbeatEveryMs)
|
|
422
|
+
return;
|
|
423
|
+
lastHeartbeatAt = now;
|
|
424
|
+
res.write(`: keepalive ${new Date(now).toISOString()}\n\n`);
|
|
425
|
+
};
|
|
289
426
|
const sendMatchingEvents = async (initial) => {
|
|
427
|
+
writeHeartbeat();
|
|
290
428
|
let currentFileSize;
|
|
291
429
|
try {
|
|
292
430
|
currentFileSize = (await stat(eventsPath)).size;
|
|
@@ -338,7 +476,9 @@ async function handleEventStream(req, res, match, ctx) {
|
|
|
338
476
|
}
|
|
339
477
|
function findRunByWorkerRunId(tasks, batches, workerRunId) {
|
|
340
478
|
return (tasks.find((task) => task.workerRunId === workerRunId) ??
|
|
341
|
-
batches
|
|
479
|
+
batches
|
|
480
|
+
.flatMap((batch) => batch.tasks)
|
|
481
|
+
.find((task) => task.workerRunId === workerRunId));
|
|
342
482
|
}
|
|
343
483
|
async function readJsonlLines(filePath, after) {
|
|
344
484
|
const page = await readJsonlPage(filePath, after);
|
|
@@ -351,14 +491,17 @@ async function readJsonlPage(filePath, after) {
|
|
|
351
491
|
.split("\n")
|
|
352
492
|
.map((line) => line.trim())
|
|
353
493
|
.filter(Boolean);
|
|
354
|
-
const events = lines
|
|
494
|
+
const events = lines
|
|
495
|
+
.slice(after)
|
|
496
|
+
.map((line) => {
|
|
355
497
|
try {
|
|
356
498
|
return JSON.parse(line);
|
|
357
499
|
}
|
|
358
500
|
catch {
|
|
359
501
|
return undefined;
|
|
360
502
|
}
|
|
361
|
-
})
|
|
503
|
+
})
|
|
504
|
+
.filter((line) => line !== undefined);
|
|
362
505
|
return { events, nextOffset: lines.length };
|
|
363
506
|
}
|
|
364
507
|
catch {
|
|
@@ -367,7 +510,8 @@ async function readJsonlPage(filePath, after) {
|
|
|
367
510
|
}
|
|
368
511
|
const DAG_RUN_LIFECYCLE_DIRS = ["active", "completed", "paused"];
|
|
369
512
|
function resolveSessionEventsPath(repoRoot, dagRunId, nodeId, dagRunDir) {
|
|
370
|
-
if (!isSafeObservabilityIdentifier(dagRunId) ||
|
|
513
|
+
if (!isSafeObservabilityIdentifier(dagRunId) ||
|
|
514
|
+
!isSafeObservabilityIdentifier(nodeId)) {
|
|
371
515
|
return null;
|
|
372
516
|
}
|
|
373
517
|
const dagRunsRoot = path.resolve(repoRoot, ".harness", "dag-runs");
|
|
@@ -407,7 +551,7 @@ function isExpectedSessionEventsRelativePath(dagRunsRoot, resolvedPath, dagRunId
|
|
|
407
551
|
function isPathInside(root, target) {
|
|
408
552
|
const normalizedRoot = root.endsWith(path.sep) ? root : `${root}${path.sep}`;
|
|
409
553
|
const normalizedTarget = path.normalize(target);
|
|
410
|
-
return normalizedTarget === root || normalizedTarget.startsWith(normalizedRoot);
|
|
554
|
+
return (normalizedTarget === root || normalizedTarget.startsWith(normalizedRoot));
|
|
411
555
|
}
|
|
412
556
|
async function readFilteredEvents(filePath, offset, batchRunId, repoRoot) {
|
|
413
557
|
try {
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** Fetch and artifact helpers (Observe UI R5). */
|
|
2
|
+
|
|
3
|
+
export async function fetchJson(url) {
|
|
4
|
+
try {
|
|
5
|
+
const res = await fetch(url);
|
|
6
|
+
if (!res.ok) return null;
|
|
7
|
+
return await res.json();
|
|
8
|
+
} catch {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function artifactUrl(artifactPath) {
|
|
14
|
+
return `/api/artifacts?path=${encodeURIComponent(artifactPath)}&tail=200`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function parseArtifactPreviewResponse(text) {
|
|
18
|
+
const parsed = JSON.parse(text);
|
|
19
|
+
if (!parsed || typeof parsed.content !== "string") {
|
|
20
|
+
throw new Error("Artifact preview response is invalid");
|
|
21
|
+
}
|
|
22
|
+
return {
|
|
23
|
+
content: parsed.content,
|
|
24
|
+
truncated: parsed.truncated === true,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|