@tea-agent/loop-agent 0.4.0 → 0.6.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 (67) hide show
  1. package/AGENTS.md +2 -2
  2. package/CHANGELOG.md +48 -38
  3. package/README.md +3 -3
  4. package/dist/application/dag/args.js +9 -1
  5. package/dist/application/dag/run-dag.js +16 -2
  6. package/dist/application/dag/validate-dag.js +14 -1
  7. package/dist/cli/command-definitions.js +22 -4
  8. package/dist/cli/help.js +3 -2
  9. package/dist/cli/program.js +7 -5
  10. package/dist/commands/import-prd.js +76 -0
  11. package/dist/commands/init.js +230 -32
  12. package/dist/commands/instructions.js +90 -58
  13. package/dist/executors/config-core.js +3 -2
  14. package/dist/executors/dag-pi-executor.js +1 -0
  15. package/dist/executors/model-routing.js +43 -0
  16. package/dist/executors/pi-sdk-executor.js +63 -1
  17. package/dist/governance/manifest-types.js +9 -1
  18. package/dist/shared/preview.js +39 -0
  19. package/dist/task/source-references.js +221 -0
  20. package/dist/worker/cli.js +62 -1
  21. package/dist/worker/loop-agent/loop-agent-client.js +97 -5
  22. package/dist/worker/materialize/harness-task-materializer.js +162 -5
  23. package/dist/worker/observability/event-store.js +82 -0
  24. package/dist/worker/observability/events.js +79 -0
  25. package/dist/worker/observability/progress-composite.js +33 -0
  26. package/dist/worker/observability/read-model.js +1013 -0
  27. package/dist/worker/observability/snapshot-store.js +43 -0
  28. package/dist/worker/observability/types.js +1 -0
  29. package/dist/worker/observe/paths.js +64 -0
  30. package/dist/worker/observe/routes.js +423 -0
  31. package/dist/worker/observe/server.js +61 -0
  32. package/dist/worker/observe/static/app.js +1419 -0
  33. package/dist/worker/observe/static/index.html +63 -0
  34. package/dist/worker/observe/static/styles.css +613 -0
  35. package/dist/worker/pool/failure-routing.js +41 -6
  36. package/dist/worker/pool/run-store.js +59 -1
  37. package/dist/worker/progress-reporter.js +0 -18
  38. package/dist/worker/run-task/run-task.js +327 -92
  39. package/dist/worker/runner/run-ready.js +112 -4
  40. package/dist/workflows/dag/event-observer.js +132 -0
  41. package/dist/workflows/dag/init-hybrid.js +150 -26
  42. package/dist/workflows/dag/observer-compose.js +52 -0
  43. package/dist/workflows/dag/skill-instructions.js +4 -0
  44. package/dist/workflows/dag/types.js +1 -1
  45. package/dist/workflows/dag/validate.js +3 -2
  46. package/docs/README.md +2 -0
  47. package/docs/architecture/runtime-boundaries.md +18 -3
  48. package/docs/design/README.md +22 -9
  49. package/docs/exec-plans/active/README.md +6 -1
  50. package/docs/exec-plans/completed/README.md +12 -0
  51. package/docs/init-surface.manifest.json +32 -2
  52. package/docs/loop-agent-harness.md +13 -0
  53. package/docs/reports/README.md +4 -0
  54. package/docs/templates/agent-dag.base.json +1 -1
  55. package/docs/templates/agent-dag.final-verification.json +1 -1
  56. package/docs/templates/agent-dag.supervised-implementation.json +1 -1
  57. package/docs/templates/hybrid-dag.json +1 -1
  58. package/docs/templates/worker-dogfood-evidence.md +52 -0
  59. package/docs/templates/worker-dogfood-setup.md +48 -0
  60. package/examples/example-dag.json +1 -1
  61. package/examples/hybrid-loop-agent-dag.json +1 -1
  62. package/harness.json +5 -29
  63. package/package.json +6 -6
  64. package/skills/loop-agent/SKILL.md +5 -3
  65. package/skills/loop-agent/references/command-reference.md +12 -3
  66. package/skills/loop-agent/references/harness-policy.md +7 -3
  67. package/skills/loop-agent/references/task-workflow.md +8 -3
@@ -1,13 +1,16 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { appendFileSync, writeFileSync } from "node:fs";
2
3
  import { mkdir, writeFile } from "node:fs/promises";
3
4
  import path from "node:path";
4
5
  import { parseCommandJson } from "./parse-json.js";
5
6
  export const DEFAULT_WORKER_COMMAND_TIMEOUT_MS = 120_000;
7
+ export const DEFAULT_HEARTBEAT_INTERVAL_MS = 15_000;
6
8
  export class LoopAgentClient {
7
9
  loopAgentBin;
8
10
  baseArgs;
9
11
  artifactRoot;
10
12
  defaultTimeoutMs;
13
+ heartbeatIntervalMs;
11
14
  env;
12
15
  constructor(options) {
13
16
  this.loopAgentBin = options.loopAgentBin;
@@ -15,6 +18,8 @@ export class LoopAgentClient {
15
18
  this.artifactRoot = options.artifactRoot;
16
19
  this.defaultTimeoutMs =
17
20
  options.defaultTimeoutMs ?? DEFAULT_WORKER_COMMAND_TIMEOUT_MS;
21
+ this.heartbeatIntervalMs =
22
+ options.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;
18
23
  this.env = options.env;
19
24
  }
20
25
  async run(args, options) {
@@ -27,12 +32,23 @@ export class LoopAgentClient {
27
32
  const startedAt = Date.now();
28
33
  const artifactDir = path.join(this.artifactRoot, sanitizeName(options.artifactName));
29
34
  await mkdir(artifactDir, { recursive: true });
35
+ const stdoutPath = path.join(artifactDir, "stdout.txt");
36
+ const stderrPath = path.join(artifactDir, "stderr.txt");
37
+ const resultPath = path.join(artifactDir, "result.json");
30
38
  const { stdout, stderr, exitCode, timedOut } = await spawnCommand({
31
39
  command,
32
40
  args: commandArgs,
33
41
  cwd: options.cwd,
34
42
  timeoutMs: options.timeoutMs ?? this.defaultTimeoutMs,
35
43
  env: { ...process.env, ...this.env, ...options.env },
44
+ stdoutPath,
45
+ stderrPath,
46
+ resultPath,
47
+ heartbeatIntervalMs: this.heartbeatIntervalMs,
48
+ onSpawn: options.onSpawn,
49
+ onStdout: options.onStdout,
50
+ onStderr: options.onStderr,
51
+ onHeartbeat: options.onHeartbeat,
36
52
  });
37
53
  const result = {
38
54
  ok: exitCode === 0 && !timedOut,
@@ -46,9 +62,9 @@ export class LoopAgentClient {
46
62
  timedOut,
47
63
  artifacts: {
48
64
  dir: artifactDir,
49
- stdoutPath: path.join(artifactDir, "stdout.txt"),
50
- stderrPath: path.join(artifactDir, "stderr.txt"),
51
- resultPath: path.join(artifactDir, "result.json"),
65
+ stdoutPath,
66
+ stderrPath,
67
+ resultPath,
52
68
  },
53
69
  };
54
70
  if (options.expectJson) {
@@ -69,12 +85,30 @@ export class LoopAgentClient {
69
85
  }
70
86
  function spawnCommand(input) {
71
87
  return new Promise((resolve, reject) => {
88
+ const startedAtMs = Date.now();
89
+ const startedAt = new Date(startedAtMs).toISOString();
72
90
  const child = spawn(input.command, input.args, {
73
91
  cwd: input.cwd,
74
92
  env: input.env,
75
93
  shell: false,
76
94
  stdio: ["ignore", "pipe", "pipe"],
77
95
  });
96
+ try {
97
+ writeFileSync(input.stdoutPath, "", "utf-8");
98
+ writeFileSync(input.stderrPath, "", "utf-8");
99
+ }
100
+ catch {
101
+ // best-effort: degrade to in-memory buffer only
102
+ }
103
+ invokeSpawnCallback(input.onSpawn, {
104
+ pid: child.pid,
105
+ startedAt,
106
+ artifactRefs: {
107
+ stdoutPath: input.stdoutPath,
108
+ stderrPath: input.stderrPath,
109
+ resultPath: input.resultPath,
110
+ },
111
+ });
78
112
  let stdout = "";
79
113
  let stderr = "";
80
114
  let timedOut = false;
@@ -82,24 +116,82 @@ function spawnCommand(input) {
82
116
  timedOut = true;
83
117
  child.kill("SIGTERM");
84
118
  }, input.timeoutMs);
119
+ let heartbeatTimer;
120
+ if (input.heartbeatIntervalMs > 0) {
121
+ heartbeatTimer = setInterval(() => {
122
+ invokeHeartbeatCallback(input.onHeartbeat, startedAtMs);
123
+ }, input.heartbeatIntervalMs);
124
+ }
125
+ const clearTimers = () => {
126
+ clearTimeout(timeout);
127
+ if (heartbeatTimer !== undefined) {
128
+ clearInterval(heartbeatTimer);
129
+ heartbeatTimer = undefined;
130
+ }
131
+ };
85
132
  child.stdout.setEncoding("utf8");
86
133
  child.stderr.setEncoding("utf8");
87
134
  child.stdout.on("data", (chunk) => {
88
135
  stdout += chunk;
136
+ appendChunkBestEffort(input.stdoutPath, chunk);
137
+ invokeChunkCallback(input.onStdout, chunk);
89
138
  });
90
139
  child.stderr.on("data", (chunk) => {
91
140
  stderr += chunk;
141
+ appendChunkBestEffort(input.stderrPath, chunk);
142
+ invokeChunkCallback(input.onStderr, chunk);
92
143
  });
93
144
  child.on("error", (error) => {
94
- clearTimeout(timeout);
145
+ clearTimers();
95
146
  reject(error);
96
147
  });
97
148
  child.on("close", (exitCode) => {
98
- clearTimeout(timeout);
149
+ clearTimers();
99
150
  resolve({ stdout, stderr, exitCode, timedOut });
100
151
  });
101
152
  });
102
153
  }
154
+ function appendChunkBestEffort(filePath, chunk) {
155
+ try {
156
+ appendFileSync(filePath, chunk, "utf-8");
157
+ }
158
+ catch {
159
+ // best-effort: degrade to in-memory buffer only
160
+ }
161
+ }
162
+ function invokeSpawnCallback(callback, info) {
163
+ if (!callback) {
164
+ return;
165
+ }
166
+ try {
167
+ callback(info);
168
+ }
169
+ catch (error) {
170
+ process.stderr.write(`LoopAgentClient onSpawn callback error: ${String(error)}\n`);
171
+ }
172
+ }
173
+ function invokeChunkCallback(callback, chunk) {
174
+ if (!callback) {
175
+ return;
176
+ }
177
+ try {
178
+ callback(chunk);
179
+ }
180
+ catch (error) {
181
+ process.stderr.write(`LoopAgentClient output callback error: ${String(error)}\n`);
182
+ }
183
+ }
184
+ function invokeHeartbeatCallback(callback, startedAtMs) {
185
+ if (!callback) {
186
+ return;
187
+ }
188
+ try {
189
+ callback({ at: new Date().toISOString(), elapsedMs: Date.now() - startedAtMs });
190
+ }
191
+ catch (error) {
192
+ process.stderr.write(`LoopAgentClient onHeartbeat callback error: ${String(error)}\n`);
193
+ }
194
+ }
103
195
  function sanitizeName(name) {
104
196
  return name.replace(/[^a-zA-Z0-9._-]+/g, "-");
105
197
  }
@@ -1,4 +1,5 @@
1
- import { mkdir, writeFile } from "node:fs/promises";
1
+ import { createHash } from "node:crypto";
2
+ import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises";
2
3
  import path from "node:path";
3
4
  import YAML from "yaml";
4
5
  import { writeTaskConfig, writeTaskArtifactFile } from "../../infrastructure/harness/task-store.js";
@@ -51,7 +52,25 @@ export async function materializeTaskSpec(options) {
51
52
  taskYamlPath: path.join(paths.sourceDir, "task.yaml"),
52
53
  };
53
54
  await mkdir(paths.sourceDir, { recursive: true });
54
- await writeFile(sourcePaths.requirementPath, renderRequirementMarkdown(options.taskSpec), "utf-8");
55
+ const referencePaths = await materializeSourceDocs({
56
+ taskSpec: options.taskSpec,
57
+ taskSpecPath: options.taskSpecPath,
58
+ sourceDir: paths.sourceDir,
59
+ });
60
+ const sourceDocTrace = await buildSourceDocTrace({
61
+ taskSpec: options.taskSpec,
62
+ taskSpecPath: options.taskSpecPath,
63
+ referencePaths,
64
+ });
65
+ const acceptanceSummaries = await buildAcceptanceSummaries({
66
+ taskSpec: options.taskSpec,
67
+ taskSpecPath: options.taskSpecPath,
68
+ referencePaths,
69
+ });
70
+ await writeFile(sourcePaths.requirementPath, renderRequirementMarkdown(options.taskSpec, {
71
+ sourceDocTrace,
72
+ acceptanceSummaries,
73
+ }), "utf-8");
55
74
  await writeFile(sourcePaths.constraintsPath, renderConstraintsMarkdown(options.taskSpec), "utf-8");
56
75
  await writeFile(sourcePaths.taskYamlPath, YAML.stringify(options.taskSpec), "utf-8");
57
76
  const profileMapping = resolveLoopAgentProfile(options.taskSpec);
@@ -62,11 +81,31 @@ export async function materializeTaskSpec(options) {
62
81
  featureId: options.taskSpec.feature_id,
63
82
  loopAgentProfile: profileMapping.loopAgentProfile,
64
83
  taskConfigPath: paths.taskConfigPath,
65
- source: sourcePaths,
84
+ source: {
85
+ ...sourcePaths,
86
+ // Reference copies make the upstream requirement/design/test cases concrete.
87
+ referencePaths,
88
+ },
66
89
  };
67
90
  await writeTaskArtifactFile(options.repoRoot, harnessTaskId, "materialize-manifest.json", `${JSON.stringify(manifest, null, 2)}\n`);
68
91
  return manifest;
69
92
  }
93
+ async function materializeSourceDocs(input) {
94
+ const referenceDir = path.join(input.sourceDir, "references");
95
+ const taskSpecDir = path.dirname(path.resolve(input.taskSpecPath));
96
+ const referencePaths = {};
97
+ await mkdir(referenceDir, { recursive: true });
98
+ for (const [key, relativePath] of Object.entries(input.taskSpec.source_docs)) {
99
+ if (!relativePath)
100
+ continue;
101
+ const sourcePath = path.resolve(taskSpecDir, relativePath);
102
+ const extension = path.extname(sourcePath) || ".md";
103
+ const targetPath = path.join(referenceDir, `${key}${extension}`);
104
+ await copyFile(sourcePath, targetPath);
105
+ referencePaths[key] = targetPath;
106
+ }
107
+ return referencePaths;
108
+ }
70
109
  export function buildHarnessTaskId(taskSpec, now) {
71
110
  const date = now.toISOString().slice(0, 10);
72
111
  const slug = slugify(`${taskSpec.id}-${taskSpec.title}`);
@@ -81,10 +120,15 @@ function slugify(value) {
81
120
  .replace(/^-+|-+$/g, "")
82
121
  .replace(/-{2,}/g, "-");
83
122
  }
84
- function renderRequirementMarkdown(taskSpec) {
123
+ /** Exported for unit tests: derived contract must point back to immutable references. */
124
+ export function renderRequirementMarkdown(taskSpec, options = {}) {
85
125
  const lines = [
86
126
  `# ${taskSpec.title}`,
87
127
  "",
128
+ "> Derived execution contract from TaskSpec.",
129
+ "> Authoritative sources: `source/references/*` (immutable copies of TaskSpec source_docs).",
130
+ "> On conflict, prefer `source/references/*` over this file. Do not invent missing acceptance text.",
131
+ "",
88
132
  `TaskSpec business id: ${taskSpec.id}`,
89
133
  `Feature id: ${taskSpec.feature_id}`,
90
134
  `Task type: ${taskSpec.type}`,
@@ -104,10 +148,123 @@ function renderRequirementMarkdown(taskSpec) {
104
148
  if (taskSpec.scope.open_questions.length > 0) {
105
149
  lines.push("## Open Questions", "", ...bulletLines(taskSpec.scope.open_questions), "");
106
150
  }
107
- lines.push("## Acceptance References", "", ...bulletLines(taskSpec.acceptance_refs), "");
151
+ const acceptanceLines = options.acceptanceSummaries && options.acceptanceSummaries.length > 0
152
+ ? options.acceptanceSummaries.map((entry) => `- ${entry.id}: ${entry.summary}`)
153
+ : bulletLines(taskSpec.acceptance_refs);
154
+ lines.push("## Acceptance References", "", ...acceptanceLines, "");
155
+ if (options.sourceDocTrace && options.sourceDocTrace.length > 0) {
156
+ lines.push("## Source Docs / Traceability", "", ...options.sourceDocTrace.map((entry) => `- ${entry.key}: ${entry.materializedPath} (from ${entry.sourcePath}; sha256=${entry.sha256.slice(0, 12)}…)`), "");
157
+ }
158
+ else {
159
+ lines.push("## Source Docs / Traceability", "", ...bulletLines(Object.entries(taskSpec.source_docs).map(([key, value]) => `${key}: source/references/${key}.* ← ${value}`)), "");
160
+ }
108
161
  lines.push("## Required Outputs", "", ...bulletLines(taskSpec.outputs.required), "");
109
162
  return `${lines.join("\n").trimEnd()}\n`;
110
163
  }
164
+ async function buildSourceDocTrace(input) {
165
+ const entries = [];
166
+ for (const [key, relativePath] of Object.entries(input.taskSpec.source_docs)) {
167
+ if (!relativePath)
168
+ continue;
169
+ const materializedAbsolute = input.referencePaths[key];
170
+ if (!materializedAbsolute)
171
+ continue;
172
+ const content = await readFile(materializedAbsolute);
173
+ const sha256 = createHash("sha256").update(content).digest("hex");
174
+ const extension = path.extname(materializedAbsolute) || ".md";
175
+ entries.push({
176
+ key,
177
+ sourcePath: relativePath.split(path.sep).join("/"),
178
+ materializedPath: `references/${key}${extension}`,
179
+ sha256,
180
+ });
181
+ }
182
+ entries.sort((left, right) => left.key.localeCompare(right.key));
183
+ return entries;
184
+ }
185
+ async function buildAcceptanceSummaries(input) {
186
+ const acceptanceAbsolute = input.referencePaths.acceptance ??
187
+ path.resolve(path.dirname(path.resolve(input.taskSpecPath)), input.taskSpec.source_docs.acceptance);
188
+ let raw = "";
189
+ try {
190
+ raw = await readFile(acceptanceAbsolute, "utf-8");
191
+ }
192
+ catch {
193
+ return input.taskSpec.acceptance_refs.map((id) => ({
194
+ id,
195
+ summary: "(acceptance source unreadable; see source/references/acceptance.*)",
196
+ }));
197
+ }
198
+ const byId = indexAcceptanceItems(raw);
199
+ return input.taskSpec.acceptance_refs.map((id) => ({
200
+ id,
201
+ summary: byId.get(id) ??
202
+ "(no summary found in acceptance source; re-read source/references/acceptance.*)",
203
+ }));
204
+ }
205
+ /** Pull short human summaries for acceptance IDs from YAML or Markdown sources. */
206
+ export function indexAcceptanceItems(raw) {
207
+ const map = new Map();
208
+ const trimmed = raw.trim();
209
+ if (!trimmed)
210
+ return map;
211
+ // YAML AcceptanceSpec: { acceptance: [ { id, title, then, ... } ] }
212
+ try {
213
+ const parsed = YAML.parse(trimmed);
214
+ const items = extractAcceptanceArray(parsed);
215
+ for (const item of items) {
216
+ if (!item || typeof item !== "object")
217
+ continue;
218
+ const record = item;
219
+ const id = typeof record.id === "string" ? record.id : undefined;
220
+ if (!id)
221
+ continue;
222
+ const title = typeof record.title === "string" ? record.title.trim() : "";
223
+ const then = typeof record.then === "string" ? record.then.trim() : "";
224
+ const given = typeof record.given === "string" ? record.given.trim() : "";
225
+ const when = typeof record.when === "string" ? record.when.trim() : "";
226
+ const parts = [
227
+ title,
228
+ then ? `then: ${then}` : "",
229
+ !then && given ? `given: ${given}` : "",
230
+ !then && when ? `when: ${when}` : "",
231
+ ].filter(Boolean);
232
+ map.set(id, compactSummary(parts.join(" — ") || id));
233
+ }
234
+ if (map.size > 0)
235
+ return map;
236
+ }
237
+ catch {
238
+ // fall through to markdown heuristics
239
+ }
240
+ // Markdown / free text: lines like "AC-BE-001: ..." or "- AC-BE-001 ..."
241
+ for (const line of trimmed.split("\n")) {
242
+ const match = line.match(/(?:^|\s)((?:AC|REQ)[-A-Z0-9]+)\s*[::\-–—]\s*(.+)$/i);
243
+ if (!match)
244
+ continue;
245
+ const id = match[1];
246
+ const summary = compactSummary(match[2]);
247
+ if (!map.has(id))
248
+ map.set(id, summary);
249
+ }
250
+ return map;
251
+ }
252
+ function extractAcceptanceArray(parsed) {
253
+ if (!parsed || typeof parsed !== "object")
254
+ return [];
255
+ const root = parsed;
256
+ if (Array.isArray(root.acceptance))
257
+ return root.acceptance;
258
+ if (Array.isArray(parsed))
259
+ return parsed;
260
+ return [];
261
+ }
262
+ function compactSummary(value, maxChars = 180) {
263
+ const normalized = value.replace(/\s+/g, " ").trim();
264
+ if (normalized.length <= maxChars)
265
+ return normalized;
266
+ return `${normalized.slice(0, maxChars - 1)}…`;
267
+ }
111
268
  function renderConstraintsMarkdown(taskSpec) {
112
269
  const lines = [
113
270
  `# Execution Constraints for ${taskSpec.id}`,
@@ -0,0 +1,82 @@
1
+ import { appendFile, mkdir } from "node:fs/promises";
2
+ import { appendFileSync, mkdirSync } from "node:fs";
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(`[worker-event-store] ${context}: ${message}\n`);
7
+ }
8
+ function serializeEvent(event) {
9
+ return `${JSON.stringify(event)}\n`;
10
+ }
11
+ /** Identifiers used as event-store path segments must not alter the route. */
12
+ export function isSafeObservabilityIdentifier(value) {
13
+ return value !== "." && value !== ".." && !/[\\/\0]/.test(value);
14
+ }
15
+ export function createWorkerEventStore(jsonlPath) {
16
+ return {
17
+ async append(event) {
18
+ try {
19
+ await mkdir(path.dirname(jsonlPath), { recursive: true });
20
+ await appendFile(jsonlPath, serializeEvent(event), "utf-8");
21
+ }
22
+ catch (error) {
23
+ writeDiagnostic(`append failed (${jsonlPath})`, error);
24
+ }
25
+ },
26
+ async appendMany(events) {
27
+ for (const event of events) {
28
+ await this.append(event);
29
+ }
30
+ },
31
+ appendSyncSafe(event) {
32
+ try {
33
+ mkdirSync(path.dirname(jsonlPath), { recursive: true });
34
+ appendFileSync(jsonlPath, serializeEvent(event), "utf-8");
35
+ }
36
+ catch (error) {
37
+ writeDiagnostic(`appendSyncSafe failed (${jsonlPath})`, error);
38
+ }
39
+ },
40
+ path() {
41
+ return jsonlPath;
42
+ },
43
+ };
44
+ }
45
+ export function createRoutedWorkerEventStore(repoRoot) {
46
+ const observabilityRoot = path.join(path.resolve(repoRoot), ".task-pool", "observability");
47
+ const globalStore = createWorkerEventStore(path.join(observabilityRoot, "events.jsonl"));
48
+ let queue = Promise.resolve();
49
+ function storesFor(event) {
50
+ const stores = [globalStore];
51
+ if (event.batchRunId && isSafeObservabilityIdentifier(event.batchRunId)) {
52
+ stores.push(createWorkerEventStore(path.join(observabilityRoot, "batches", event.batchRunId, "events.jsonl")));
53
+ }
54
+ if (event.workerRunId && isSafeObservabilityIdentifier(event.workerRunId)) {
55
+ stores.push(createWorkerEventStore(path.join(observabilityRoot, "runs", event.workerRunId, "events.jsonl")));
56
+ }
57
+ return stores;
58
+ }
59
+ const store = {
60
+ append(event) {
61
+ const write = queue.then(async () => {
62
+ await Promise.all(storesFor(event).map((target) => target.append(event)));
63
+ });
64
+ queue = write.catch(() => { });
65
+ return write;
66
+ },
67
+ async appendMany(events) {
68
+ for (const event of events) {
69
+ await store.append(event);
70
+ }
71
+ },
72
+ appendSyncSafe(event) {
73
+ for (const target of storesFor(event)) {
74
+ target.appendSyncSafe(event);
75
+ }
76
+ },
77
+ path() {
78
+ return globalStore.path();
79
+ },
80
+ };
81
+ return store;
82
+ }
@@ -0,0 +1,79 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { redactSecrets, truncateUtf8Preview } from "../../shared/preview.js";
3
+ const WORKER_EVENT_SOURCES = new Set([
4
+ "worker",
5
+ "loop-agent-command",
6
+ "dag",
7
+ "shell",
8
+ "artifact",
9
+ "observe",
10
+ ]);
11
+ const WORKER_EVENT_TYPES = new Set([
12
+ "batch.started",
13
+ "batch.finished",
14
+ "readyQueue.computed",
15
+ "task.queued",
16
+ "task.started",
17
+ "task.finished",
18
+ "task.reused",
19
+ "step.started",
20
+ "step.heartbeat",
21
+ "step.output",
22
+ "step.finished",
23
+ "command.started",
24
+ "command.output",
25
+ "command.heartbeat",
26
+ "command.finished",
27
+ "dag.run.started",
28
+ "dag.node.started",
29
+ "dag.node.output",
30
+ "dag.node.finished",
31
+ "dag.run.finished",
32
+ "artifact.written",
33
+ "failure.routed",
34
+ "state.updated",
35
+ ]);
36
+ export function createWorkerEvent(input, options) {
37
+ const now = options?.now ?? (() => new Date());
38
+ const randomUuid = options?.randomUuid ?? randomUUID;
39
+ return {
40
+ ...input,
41
+ schemaVersion: 1,
42
+ id: input.id ?? randomUuid(),
43
+ at: input.at ?? now().toISOString(),
44
+ spanId: input.spanId ?? randomUuid(),
45
+ };
46
+ }
47
+ export function parseWorkerEventLine(line) {
48
+ try {
49
+ const parsed = JSON.parse(line);
50
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
51
+ return undefined;
52
+ }
53
+ return isWorkerEvent(parsed) ? parsed : undefined;
54
+ }
55
+ catch {
56
+ return undefined;
57
+ }
58
+ }
59
+ function isWorkerEvent(value) {
60
+ const event = value;
61
+ return (event.schemaVersion === 1 &&
62
+ typeof event.id === "string" &&
63
+ event.id.length > 0 &&
64
+ typeof event.at === "string" &&
65
+ !Number.isNaN(Date.parse(event.at)) &&
66
+ typeof event.spanId === "string" &&
67
+ event.spanId.length > 0 &&
68
+ typeof event.source === "string" &&
69
+ WORKER_EVENT_SOURCES.has(event.source) &&
70
+ typeof event.type === "string" &&
71
+ WORKER_EVENT_TYPES.has(event.type) &&
72
+ typeof event.label === "string");
73
+ }
74
+ export function truncatePreview(text, maxBytes = 4096) {
75
+ return truncateUtf8Preview(text, maxBytes);
76
+ }
77
+ export function redactForPreview(text) {
78
+ return redactSecrets(text);
79
+ }
@@ -0,0 +1,33 @@
1
+ import { createProgressReporter, } from "../progress-reporter.js";
2
+ import { createWorkerEvent } from "./events.js";
3
+ export function createCompositeProgressReporter(options) {
4
+ const textReporter = createProgressReporter({
5
+ quiet: options.quiet,
6
+ now: options.now,
7
+ isTty: options.isTty,
8
+ sink: options.sink,
9
+ });
10
+ const eventStore = options.eventStore;
11
+ const context = options.context ?? {};
12
+ const now = options.now ?? (() => new Date());
13
+ return {
14
+ ...textReporter,
15
+ async event(input) {
16
+ if (!eventStore)
17
+ return;
18
+ try {
19
+ const event = createWorkerEvent({
20
+ ...input,
21
+ batchRunId: input.batchRunId ?? context.batchRunId,
22
+ workerRunId: input.workerRunId ?? context.workerRunId,
23
+ taskId: input.taskId ?? context.taskId,
24
+ }, { now });
25
+ await eventStore.append(event);
26
+ }
27
+ catch (error) {
28
+ const message = error instanceof Error ? error.message : String(error);
29
+ process.stderr.write(`[progress-composite] event append failed: ${message}\n`);
30
+ }
31
+ },
32
+ };
33
+ }