@tea-agent/loop-agent 0.11.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.
Files changed (76) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +3 -2
  3. package/dist/application/dag/generate-task-dag.js +15 -0
  4. package/dist/application/dag/run-dag.js +10 -0
  5. package/dist/application/dag/validate-dag.js +11 -0
  6. package/dist/commands/init.js +74 -7
  7. package/dist/shared/package-metadata.js +135 -0
  8. package/dist/task/config-types.js +1 -0
  9. package/dist/worker/cli.js +3 -1
  10. package/dist/worker/observability/event-history.js +216 -0
  11. package/dist/worker/observability/read-model.js +312 -83
  12. package/dist/worker/observe/paths.js +17 -0
  13. package/dist/worker/observe/routes.js +165 -21
  14. package/dist/worker/observe/server.js +59 -1
  15. package/dist/worker/observe/static/api.js +27 -0
  16. package/dist/worker/observe/static/app.js +120 -2598
  17. package/dist/worker/observe/static/constants.js +148 -0
  18. package/dist/worker/observe/static/copy.js +67 -0
  19. package/dist/worker/observe/static/dag-helpers.js +172 -0
  20. package/dist/worker/observe/static/dag-model.js +72 -0
  21. package/dist/worker/observe/static/dom.js +61 -0
  22. package/dist/worker/observe/static/format-pool.js +67 -0
  23. package/dist/worker/observe/static/format.js +292 -0
  24. package/dist/worker/observe/static/index.html +300 -82
  25. package/dist/worker/observe/static/kpi.js +94 -0
  26. package/dist/worker/observe/static/relations.js +128 -0
  27. package/dist/worker/observe/static/router.js +85 -0
  28. package/dist/worker/observe/static/run-processing.js +148 -0
  29. package/dist/worker/observe/static/shell-chrome.js +68 -0
  30. package/dist/worker/observe/static/state.js +253 -0
  31. package/dist/worker/observe/static/styles.css +1719 -495
  32. package/dist/worker/observe/static/views/batch.js +226 -0
  33. package/dist/worker/observe/static/views/dag-graph.js +172 -0
  34. package/dist/worker/observe/static/views/dag-inspector.js +477 -0
  35. package/dist/worker/observe/static/views/dag.js +362 -0
  36. package/dist/worker/observe/static/views/dashboard.js +442 -0
  37. package/dist/worker/observe/static/views/failures.js +143 -0
  38. package/dist/worker/observe/static/views/feature.js +453 -0
  39. package/dist/worker/observe/static/views/pool.js +347 -0
  40. package/dist/worker/observe/static/views/run.js +453 -0
  41. package/dist/worker/observe/static/views/session-timeline.js +205 -0
  42. package/dist/worker/observe/static/views/shell.js +7 -0
  43. package/dist/worker/observe/static/views/task.js +260 -0
  44. package/dist/worker/observe/static/views/timeline.js +163 -0
  45. package/dist/workflows/dag/controller-identity.js +104 -0
  46. package/dist/workflows/dag/init-hybrid.js +396 -3
  47. package/dist/workflows/dag/node-execution.js +123 -29
  48. package/dist/workflows/dag/repair-artifact.js +91 -0
  49. package/dist/workflows/dag/report.js +50 -0
  50. package/dist/workflows/dag/retry-policy.js +138 -0
  51. package/dist/workflows/dag/runner.js +32 -0
  52. package/dist/workflows/dag/runtime-contract.js +87 -0
  53. package/dist/workflows/dag/skill-snapshot.js +2 -0
  54. package/dist/workflows/dag/types.js +44 -1
  55. package/dist/workflows/dag/validate.js +68 -4
  56. package/docs/agent-dag-runner.md +26 -1
  57. package/docs/architecture/dag-execution.md +6 -0
  58. package/docs/architecture/evolution.md +4 -3
  59. package/docs/architecture/facts-and-state.md +1 -1
  60. package/docs/design/README.md +4 -3
  61. package/docs/exec-plans/active/README.md +1 -3
  62. package/docs/exec-plans/completed/README.md +11 -0
  63. package/docs/feature-workflow.md +28 -0
  64. package/docs/progress/README.md +18 -0
  65. package/docs/reports/README.md +8 -2
  66. package/docs/templates/agent-dag-report.schema.json +17 -0
  67. package/docs/templates/agent-dag.schema.json +69 -1
  68. package/docs/templates/agent-dag.supervised-implementation.json +8 -2
  69. package/docs/templates/backend-test-dag.generate-pytest.prompt.md +139 -0
  70. package/docs/templates/backend-test-dag.json +276 -0
  71. package/docs/templates/backend-test-dag.retrospect.prompt.md +125 -0
  72. package/docs/templates/backend-test-dag.review-cases.prompt.md +81 -0
  73. package/package.json +1 -1
  74. package/skills/loop-agent/references/command-reference.md +1 -0
  75. package/skills/loop-agent/references/hybrid-dag.md +22 -3
  76. 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,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 { buildGlobalSnapshot } from "../observability/read-model.js";
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
- { method: "GET", pattern: /^\/api\/batches\/([^/]+)$/, handler: handleBatchById },
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
- { method: "GET", pattern: /^\/api\/tasks\/([^/]+)$/, handler: handleTaskById },
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
- { method: "GET", pattern: /^\/api\/dag-runs\/([^/]+)$/, handler: handleDagRunById },
61
+ {
62
+ method: "GET",
63
+ pattern: /^\/api\/dag-runs\/([^/]+)$/,
64
+ handler: handleDagRunById,
65
+ },
36
66
  { method: "GET", pattern: /^\/api\/artifacts$/, handler: handleArtifactRead },
37
- { method: "GET", pattern: /^\/api\/events\/stream$/, handler: handleEventStream },
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) && resolvedFile !== resolvedStatic) {
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) || !isSafeObservabilityIdentifier(nodeId)) {
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 raw = await readFile(resolved, "utf-8");
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
- const content = lines.slice(-tailLines).join("\n");
266
- sendJson(res, 200, { content });
267
- return;
378
+ truncated = truncated || lines.length > tailLines;
379
+ raw = lines.slice(-tailLines).join("\n");
268
380
  }
269
381
  }
270
- sendJson(res, 200, { content: raw });
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.flatMap((batch) => batch.tasks).find((task) => task.workerRunId === workerRunId));
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.slice(after).map((line) => {
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
- }).filter((line) => line !== undefined);
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) || !isSafeObservabilityIdentifier(nodeId)) {
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
- return Promise.reject(new Error(`observe serve is local-only; refusing non-loopback host ${host}`));
12
+ process.stderr.write(`observe serve: binding non-loopback host ${host} (read-only; no auth — expose only on trusted networks)\n`);
11
13
  }
12
14
  const port = options.port ?? 0;
13
15
  const pollIntervalMs = options.pollIntervalMs ?? 1000;
16
+ const debug = options.debug === true;
17
+ const keepAliveTimeoutMs = options.keepAliveTimeoutMs ?? DEFAULT_KEEP_ALIVE_TIMEOUT_MS;
14
18
  const routeContext = {
15
19
  repoRoot,
16
20
  pollIntervalMs,
@@ -19,6 +23,24 @@ export function createObserveServer(options) {
19
23
  };
20
24
  return new Promise((resolve, reject) => {
21
25
  const server = createServer((req, res) => {
26
+ const startedAt = Date.now();
27
+ req.socket?.on("error", () => {
28
+ // Ignore client resets; avoid unhandled socket error noise.
29
+ });
30
+ if (debug) {
31
+ let logged = false;
32
+ const onceLog = (note) => {
33
+ if (logged)
34
+ return;
35
+ logged = true;
36
+ logRequest(req, res, startedAt, note);
37
+ };
38
+ res.on("finish", () => onceLog());
39
+ res.on("close", () => {
40
+ if (!res.writableFinished)
41
+ onceLog("aborted");
42
+ });
43
+ }
22
44
  void dispatch(req, res).catch((error) => {
23
45
  if (!res.headersSent) {
24
46
  const payload = JSON.stringify({
@@ -32,6 +54,19 @@ export function createObserveServer(options) {
32
54
  }
33
55
  });
34
56
  });
57
+ server.keepAliveTimeout = keepAliveTimeoutMs;
58
+ // Must exceed keepAliveTimeout or Node resets the timer incorrectly.
59
+ server.headersTimeout = keepAliveTimeoutMs + 5_000;
60
+ // Allow long-lived SSE; per-request work still finishes promptly.
61
+ server.requestTimeout = 0;
62
+ server.on("clientError", (err, socket) => {
63
+ if (debug) {
64
+ process.stderr.write(`[observe] ${new Date().toISOString()} clientError ${err.message}\n`);
65
+ }
66
+ if (!socket.destroyed) {
67
+ socket.end("HTTP/1.1 400 Bad Request\r\n\r\n");
68
+ }
69
+ });
35
70
  async function dispatch(req, res) {
36
71
  const handled = await handleRequest(req, res, routeContext);
37
72
  if (!handled) {
@@ -45,6 +80,9 @@ export function createObserveServer(options) {
45
80
  reject(new Error("Failed to get server address"));
46
81
  return;
47
82
  }
83
+ if (debug) {
84
+ process.stderr.write(`[observe] debug logging enabled; keepAliveTimeout=${keepAliveTimeoutMs}ms\n`);
85
+ }
48
86
  resolve({
49
87
  url: `http://${host}:${addr.port}`,
50
88
  close: () => new Promise((closeResolve, closeReject) => {
@@ -61,3 +99,23 @@ function isLoopbackHost(host) {
61
99
  normalized === "[::1]" ||
62
100
  /^127(?:\.\d{1,3}){3}$/.test(normalized));
63
101
  }
102
+ function requestSource(req) {
103
+ const forwarded = req.headers["x-forwarded-for"];
104
+ if (typeof forwarded === "string" && forwarded.trim()) {
105
+ return forwarded.split(",")[0]?.trim() || forwarded.trim();
106
+ }
107
+ if (Array.isArray(forwarded) && forwarded[0]) {
108
+ return forwarded[0].split(",")[0]?.trim() || forwarded[0];
109
+ }
110
+ const realIp = req.headers["x-real-ip"];
111
+ if (typeof realIp === "string" && realIp.trim()) {
112
+ return realIp.trim();
113
+ }
114
+ return req.socket.remoteAddress ?? "-";
115
+ }
116
+ function logRequest(req, res, startedAt, note) {
117
+ const ms = Date.now() - startedAt;
118
+ const ua = String(req.headers["user-agent"] ?? "-").slice(0, 120);
119
+ const suffix = note ? ` ${note}` : "";
120
+ process.stderr.write(`[observe] ${new Date().toISOString()} ${req.method ?? "?"} ${req.url ?? "/"} ${res.statusCode} ${ms}ms from=${requestSource(req)} ua=${JSON.stringify(ua)}${suffix}\n`);
121
+ }
@@ -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
+