@osolmaz/pi-workflows 0.2.0 → 0.3.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 (133) hide show
  1. package/README.md +71 -5
  2. package/dist/builtins/monitor.workflow.d.ts +71 -0
  3. package/dist/builtins/monitor.workflow.js +234 -0
  4. package/dist/builtins/monitor.workflow.js.map +1 -0
  5. package/dist/controllers/conditions.d.ts +6 -0
  6. package/dist/controllers/conditions.js +68 -0
  7. package/dist/controllers/conditions.js.map +1 -0
  8. package/dist/controllers/definition.d.ts +6 -0
  9. package/dist/controllers/definition.js +45 -0
  10. package/dist/controllers/definition.js.map +1 -0
  11. package/dist/controllers/effects.d.ts +14 -0
  12. package/dist/controllers/effects.js +104 -0
  13. package/dist/controllers/effects.js.map +1 -0
  14. package/dist/controllers/errors.d.ts +12 -0
  15. package/dist/controllers/errors.js +25 -0
  16. package/dist/controllers/errors.js.map +1 -0
  17. package/dist/controllers/index.d.ts +13 -0
  18. package/dist/controllers/index.js +13 -0
  19. package/dist/controllers/index.js.map +1 -0
  20. package/dist/controllers/json.d.ts +5 -0
  21. package/dist/controllers/json.js +57 -0
  22. package/dist/controllers/json.js.map +1 -0
  23. package/dist/controllers/loader.d.ts +23 -0
  24. package/dist/controllers/loader.js +74 -0
  25. package/dist/controllers/loader.js.map +1 -0
  26. package/dist/controllers/manager.d.ts +58 -0
  27. package/dist/controllers/manager.js +399 -0
  28. package/dist/controllers/manager.js.map +1 -0
  29. package/dist/controllers/results.d.ts +5 -0
  30. package/dist/controllers/results.js +32 -0
  31. package/dist/controllers/results.js.map +1 -0
  32. package/dist/controllers/sqlite.d.ts +212 -0
  33. package/dist/controllers/sqlite.js +1009 -0
  34. package/dist/controllers/sqlite.js.map +1 -0
  35. package/dist/controllers/store.d.ts +112 -0
  36. package/dist/controllers/store.js +32 -0
  37. package/dist/controllers/store.js.map +1 -0
  38. package/dist/controllers/types.d.ts +159 -0
  39. package/dist/controllers/types.js +2 -0
  40. package/dist/controllers/types.js.map +1 -0
  41. package/dist/controllers/workflow-engine-scheduler.d.ts +25 -0
  42. package/dist/controllers/workflow-engine-scheduler.js +93 -0
  43. package/dist/controllers/workflow-engine-scheduler.js.map +1 -0
  44. package/dist/controllers/workflows.d.ts +27 -0
  45. package/dist/controllers/workflows.js +109 -0
  46. package/dist/controllers/workflows.js.map +1 -0
  47. package/dist/extension/controller-host.d.ts +47 -0
  48. package/dist/extension/controller-host.js +110 -0
  49. package/dist/extension/controller-host.js.map +1 -0
  50. package/dist/extension/executor.js +1 -1
  51. package/dist/extension/executor.js.map +1 -1
  52. package/dist/extension/index.d.ts +7 -0
  53. package/dist/extension/index.js +1053 -96
  54. package/dist/extension/index.js.map +1 -1
  55. package/dist/extension/recorder.d.ts +6 -0
  56. package/dist/extension/recorder.js +20 -8
  57. package/dist/extension/recorder.js.map +1 -1
  58. package/dist/extension/workflow-tool.d.ts +28 -0
  59. package/dist/extension/workflow-tool.js +33 -0
  60. package/dist/extension/workflow-tool.js.map +1 -0
  61. package/dist/host/processes.d.ts +24 -0
  62. package/dist/host/processes.js +114 -0
  63. package/dist/host/processes.js.map +1 -0
  64. package/dist/host/rpc-bridge.d.ts +9 -0
  65. package/dist/host/rpc-bridge.js +39 -0
  66. package/dist/host/rpc-bridge.js.map +1 -0
  67. package/dist/host/rpc-executor.d.ts +38 -0
  68. package/dist/host/rpc-executor.js +254 -0
  69. package/dist/host/rpc-executor.js.map +1 -0
  70. package/dist/host/runner.d.ts +49 -0
  71. package/dist/host/runner.js +350 -0
  72. package/dist/host/runner.js.map +1 -0
  73. package/dist/viewer/cli.d.ts +7 -3
  74. package/dist/viewer/cli.js +150 -19
  75. package/dist/viewer/cli.js.map +1 -1
  76. package/dist/workflows/engine.d.ts +36 -0
  77. package/dist/workflows/engine.js +244 -14
  78. package/dist/workflows/engine.js.map +1 -1
  79. package/dist/workflows/errors.d.ts +23 -0
  80. package/dist/workflows/errors.js +38 -0
  81. package/dist/workflows/errors.js.map +1 -1
  82. package/dist/workflows/graph.js +0 -5
  83. package/dist/workflows/graph.js.map +1 -1
  84. package/dist/workflows/loader.d.ts +5 -3
  85. package/dist/workflows/loader.js +10 -1
  86. package/dist/workflows/loader.js.map +1 -1
  87. package/dist/workflows/schema.js +1 -1
  88. package/dist/workflows/schema.js.map +1 -1
  89. package/dist/workflows/store.d.ts +50 -6
  90. package/dist/workflows/store.js +446 -51
  91. package/dist/workflows/store.js.map +1 -1
  92. package/dist/workflows/types.d.ts +10 -0
  93. package/docs/CONTROLLERS.md +215 -0
  94. package/docs/development.md +12 -9
  95. package/docs/plans/2026-08-04-controller-runtime-plan.md +169 -0
  96. package/docs/plans/2026-08-05-always-on-workflows-plan.md +125 -0
  97. package/docs/plans/2026-08-10-agent-managed-monitor-workflows-plan.md +184 -0
  98. package/docs/run-bundles.md +66 -27
  99. package/docs/workflows.md +131 -11
  100. package/examples/controllers/pull-request.controller.ts +215 -0
  101. package/package.json +10 -2
  102. package/src/builtins/monitor.workflow.ts +278 -0
  103. package/src/controllers/conditions.ts +110 -0
  104. package/src/controllers/definition.ts +65 -0
  105. package/src/controllers/effects.ts +123 -0
  106. package/src/controllers/errors.ts +27 -0
  107. package/src/controllers/index.ts +90 -0
  108. package/src/controllers/json.ts +62 -0
  109. package/src/controllers/loader.ts +104 -0
  110. package/src/controllers/manager.ts +533 -0
  111. package/src/controllers/results.ts +46 -0
  112. package/src/controllers/sqlite.ts +1427 -0
  113. package/src/controllers/store.ts +160 -0
  114. package/src/controllers/types.ts +183 -0
  115. package/src/controllers/workflow-engine-scheduler.ts +145 -0
  116. package/src/controllers/workflows.ts +152 -0
  117. package/src/extension/controller-host.ts +163 -0
  118. package/src/extension/executor.ts +1 -1
  119. package/src/extension/index.ts +1243 -117
  120. package/src/extension/recorder.ts +65 -36
  121. package/src/extension/workflow-tool.ts +59 -0
  122. package/src/host/processes.ts +119 -0
  123. package/src/host/rpc-bridge.ts +44 -0
  124. package/src/host/rpc-executor.ts +299 -0
  125. package/src/host/runner.ts +406 -0
  126. package/src/viewer/cli.ts +167 -21
  127. package/src/workflows/engine.ts +327 -13
  128. package/src/workflows/errors.ts +45 -0
  129. package/src/workflows/graph.ts +0 -5
  130. package/src/workflows/loader.ts +13 -3
  131. package/src/workflows/schema.ts +1 -1
  132. package/src/workflows/store.ts +555 -46
  133. package/src/workflows/types.ts +10 -0
@@ -36,6 +36,35 @@ const SESSION_BINDING_PATH = `${SESSION_DIR}/binding.json`;
36
36
  const SESSION_ENTRIES_PATH = `${SESSION_DIR}/entries.ndjson`;
37
37
  const SESSION_EVENTS_PATH = `${SESSION_DIR}/events.ndjson`;
38
38
  const SESSION_CAPTURE_PATH = `${SESSION_DIR}/capture.json`;
39
+ const SESSION_SEGMENTS_DIR = `${SESSION_DIR}/segments`;
40
+
41
+ /** Capture file layout for one recorder attempt; "" is the legacy flat stream. */
42
+ function sessionStreamPaths(attemptId?: string): {
43
+ dir: string;
44
+ binding: string;
45
+ entries: string;
46
+ events: string;
47
+ capture: string;
48
+ } {
49
+ if (attemptId === undefined) {
50
+ return {
51
+ dir: SESSION_DIR,
52
+ binding: SESSION_BINDING_PATH,
53
+ entries: SESSION_ENTRIES_PATH,
54
+ events: SESSION_EVENTS_PATH,
55
+ capture: SESSION_CAPTURE_PATH,
56
+ };
57
+ }
58
+ assertValidRunId(attemptId);
59
+ const dir = `${SESSION_SEGMENTS_DIR}/${attemptId}`;
60
+ return {
61
+ dir,
62
+ binding: `${dir}/binding.json`,
63
+ entries: `${dir}/entries.ndjson`,
64
+ events: `${dir}/events.ndjson`,
65
+ capture: `${dir}/capture.json`,
66
+ };
67
+ }
39
68
 
40
69
  /** Runs directory: `$PI_WORKFLOWS_RUNS_DIR` or `~/.pi/agent/workflows/runs`. */
41
70
  export function workflowRunsBaseDir(homeDir: string = os.homedir()): string {
@@ -59,12 +88,19 @@ export function createRunId(workflowName: string, now: Date = new Date()): strin
59
88
  return `${stamp}-${slug || "workflow"}-${randomUUID().slice(0, 8)}`;
60
89
  }
61
90
 
62
- type RunBundleContext = {
63
- traceSeq: number;
91
+ /** Per-attempt capture stream state; the "" key is the legacy flat stream. */
92
+ type SessionStreamState = {
64
93
  sessionSeq: number;
65
94
  sessionEventSeq: number;
66
95
  sessionBound: boolean;
67
96
  sessionEventsStopped: boolean;
97
+ /** Session events have a separate append chain so token traffic cannot
98
+ * queue ahead of workflow transitions. */
99
+ lock: Promise<unknown>;
100
+ };
101
+
102
+ type RunBundleContext = {
103
+ traceSeq: number;
68
104
  artifacts: ArtifactWriter;
69
105
  /**
70
106
  * Serializes complete transitions (encode, trace append, projections) so
@@ -72,9 +108,7 @@ type RunBundleContext = {
72
108
  * append order.
73
109
  */
74
110
  lock: Promise<unknown>;
75
- /** Session events have a separate append chain so token traffic cannot
76
- * queue ahead of workflow transitions. */
77
- sessionEventLock: Promise<unknown>;
111
+ streams: Map<string, SessionStreamState>;
78
112
  };
79
113
 
80
114
  /**
@@ -85,34 +119,92 @@ type RunBundleContext = {
85
119
  * into content-addressed `artifacts/`. Bundles are private: directories are
86
120
  * 0700 and files 0600.
87
121
  */
122
+ /**
123
+ * A fence proves the writer still owns the run. It is checked before every
124
+ * locked write; it throws (ClaimLostError) when the queue claim was lost, so
125
+ * a stalled runner can never interleave writes with the new claim holder.
126
+ */
127
+ export type RunFence = () => void;
128
+
129
+ export type WorkflowRunStoreOptions = {
130
+ fenceProvider?: (runDir: string) => RunFence | undefined;
131
+ };
132
+
88
133
  export class WorkflowRunStore {
89
134
  readonly outputRoot: string;
135
+ private readonly fenceProvider: ((runDir: string) => RunFence | undefined) | undefined;
90
136
  private readonly contexts = new Map<string, RunBundleContext>();
91
137
 
92
- constructor(outputRoot: string = workflowRunsBaseDir()) {
138
+ constructor(outputRoot: string = workflowRunsBaseDir(), options: WorkflowRunStoreOptions = {}) {
93
139
  this.outputRoot = outputRoot;
140
+ this.fenceProvider = options.fenceProvider;
94
141
  }
95
142
 
96
143
  runDirFor(runId: string): string {
144
+ assertValidRunId(runId);
97
145
  return path.join(this.outputRoot, runId);
98
146
  }
99
147
 
148
+ async quarantineIncompleteRun(runId: string): Promise<string | undefined> {
149
+ const runDir = this.runDirFor(runId);
150
+ let runStat;
151
+ try {
152
+ runStat = await fs.lstat(runDir);
153
+ } catch (error) {
154
+ if (isMissingPath(error)) {
155
+ return undefined;
156
+ }
157
+ throw error;
158
+ }
159
+ if (!runStat.isDirectory() || runStat.isSymbolicLink()) {
160
+ throw new Error(`Reserved workflow run path is not a directory: ${runDir}`);
161
+ }
162
+ try {
163
+ await fs.lstat(path.join(runDir, MANIFEST_PATH));
164
+ throw new Error(`Reserved workflow run has an unreadable manifest: ${runId}`);
165
+ } catch (error) {
166
+ if (!isMissingPath(error)) {
167
+ throw error;
168
+ }
169
+ }
170
+ const quarantineDir = path.join(
171
+ this.outputRoot,
172
+ `.${runId}.incomplete-${randomUUID().slice(0, 8)}`,
173
+ );
174
+ await fs.rename(runDir, quarantineDir);
175
+ this.contexts.delete(runDir);
176
+ return quarantineDir;
177
+ }
178
+
100
179
  private contextFor(runDir: string): RunBundleContext {
101
180
  let context = this.contexts.get(runDir);
102
181
  if (!context) {
103
182
  context = {
104
183
  traceSeq: 0,
184
+ artifacts: new ArtifactWriter(runDir),
185
+ lock: Promise.resolve(),
186
+ streams: new Map(),
187
+ };
188
+ this.contexts.set(runDir, context);
189
+ }
190
+ return context;
191
+ }
192
+
193
+ private streamFor(runDir: string, attemptId?: string): SessionStreamState {
194
+ const context = this.contextFor(runDir);
195
+ const key = attemptId ?? "";
196
+ let stream = context.streams.get(key);
197
+ if (!stream) {
198
+ stream = {
105
199
  sessionSeq: 0,
106
200
  sessionEventSeq: 0,
107
201
  sessionBound: false,
108
202
  sessionEventsStopped: false,
109
- artifacts: new ArtifactWriter(runDir),
110
203
  lock: Promise.resolve(),
111
- sessionEventLock: Promise.resolve(),
112
204
  };
113
- this.contexts.set(runDir, context);
205
+ context.streams.set(key, stream);
114
206
  }
115
- return context;
207
+ return stream;
116
208
  }
117
209
 
118
210
  /**
@@ -121,7 +213,10 @@ export class WorkflowRunStore {
121
213
  */
122
214
  private withRunLock<T>(runDir: string, task: () => Promise<T>): Promise<T> {
123
215
  const context = this.contextFor(runDir);
124
- const result = context.lock.then(task);
216
+ const result = context.lock.then(async () => {
217
+ this.fenceProvider?.(runDir)?.();
218
+ return await task();
219
+ });
125
220
  context.lock = result.then(
126
221
  () => undefined,
127
222
  () => undefined,
@@ -129,10 +224,17 @@ export class WorkflowRunStore {
129
224
  return result;
130
225
  }
131
226
 
132
- private withSessionEventLock<T>(runDir: string, task: () => Promise<T>): Promise<T> {
133
- const context = this.contextFor(runDir);
134
- const result = context.sessionEventLock.then(task);
135
- context.sessionEventLock = result.then(
227
+ private withSessionEventLock<T>(
228
+ runDir: string,
229
+ attemptId: string | undefined,
230
+ task: () => Promise<T>,
231
+ ): Promise<T> {
232
+ const stream = this.streamFor(runDir, attemptId);
233
+ const result = stream.lock.then(async () => {
234
+ this.fenceProvider?.(runDir)?.();
235
+ return await task();
236
+ });
237
+ stream.lock = result.then(
136
238
  () => undefined,
137
239
  () => undefined,
138
240
  );
@@ -144,9 +246,9 @@ export class WorkflowRunStore {
144
246
  state: WorkflowRunState,
145
247
  ): Promise<string> {
146
248
  const runDir = this.runDirFor(state.runId);
147
- this.contexts.delete(runDir);
148
249
  return await this.withRunLock(runDir, async () => {
149
- await fs.mkdir(runDir, { recursive: true, mode: 0o700 });
250
+ await fs.mkdir(this.outputRoot, { recursive: true, mode: 0o700 });
251
+ await fs.mkdir(runDir, { recursive: false, mode: 0o700 });
150
252
  await writeJsonAtomic(
151
253
  path.join(runDir, WORKFLOW_SNAPSHOT_PATH),
152
254
  createDefinitionSnapshot(workflow),
@@ -157,6 +259,176 @@ export class WorkflowRunStore {
157
259
  });
158
260
  }
159
261
 
262
+ /**
263
+ * Prepare an interrupted bundle for resume. Repairs a torn trace tail,
264
+ * drops trace events the state projection never recorded, and seeds the
265
+ * in-process context so new events continue the sequence. The caller must
266
+ * hold the run's queue claim; the fence is verified before any write.
267
+ */
268
+ async prepareRunResume(runId: string): Promise<LoadedRunBundle> {
269
+ const runDir = this.runDirFor(runId);
270
+ this.fenceProvider?.(runDir)?.();
271
+ const bundle = await readRunBundle(runDir);
272
+ if (bundle === null) {
273
+ throw new Error(`Cannot resume unreadable workflow run: ${runId}`);
274
+ }
275
+ if (bundle.state.status !== "running") {
276
+ throw new Error(`Cannot resume workflow run ${runId} with status ${bundle.state.status}`);
277
+ }
278
+ const tracePath = resolveBundlePath(runDir, bundle.manifest.paths.trace, TRACE_PATH);
279
+ await repairTraceFile(tracePath, bundle.state.traceSeq, () => {
280
+ // The repair rewrites the trace; re-verify ownership immediately
281
+ // before the rename so a stalled runner cannot truncate a new claim
282
+ // holder's appended events.
283
+ this.fenceProvider?.(runDir)?.();
284
+ });
285
+
286
+ // A crashed session never finalizes its capture, and resuming recorders
287
+ // always write new segments — so dangling "recording" captures end here
288
+ // instead of reporting the run capture-corrupt forever.
289
+ await this.finalizeRecordingCaptures(runDir, "Workflow host stopped before the run finished");
290
+
291
+ const counts = await this.sessionCounts(runDir);
292
+ const captureFinished =
293
+ bundle.sessionCapture?.status === "complete" || bundle.sessionCapture?.status === "failed";
294
+ this.contexts.set(runDir, {
295
+ traceSeq: bundle.state.traceSeq,
296
+ artifacts: new ArtifactWriter(runDir),
297
+ lock: Promise.resolve(),
298
+ streams: seededStreams({
299
+ sessionSeq: counts.entryCount,
300
+ sessionEventSeq: counts.lastEventSeq,
301
+ sessionBound: bundle.manifest.paths.session !== undefined,
302
+ sessionEventsStopped: captureFinished,
303
+ }),
304
+ });
305
+ const prepared = await readRunBundle(runDir);
306
+ if (prepared === null) {
307
+ throw new Error(`Workflow run ${runId} became unreadable during resume preparation`);
308
+ }
309
+ return prepared;
310
+ }
311
+
312
+ /**
313
+ * Finalize captures left "recording" by a session that is gone, so they
314
+ * report failed with the reason instead of dangling forever.
315
+ */
316
+ private async finalizeRecordingCaptures(
317
+ runDir: string,
318
+ reason: string,
319
+ options: { skipFlat?: boolean } = {},
320
+ ): Promise<void> {
321
+ if (options.skipFlat !== true) {
322
+ const flatCapture = await readJsonFile<WorkflowSessionCapture>(
323
+ path.join(runDir, SESSION_CAPTURE_PATH),
324
+ );
325
+ if (flatCapture?.status === "recording") {
326
+ const counts = await this.sessionCounts(runDir);
327
+ await this.writeSessionCapture(runDir, {
328
+ schema: SESSION_CAPTURE_SCHEMA,
329
+ eventSchema: SESSION_EVENT_SCHEMA,
330
+ status: "failed",
331
+ ...counts,
332
+ failure: {
333
+ failedAt: new Date().toISOString(),
334
+ code: "host_interrupted",
335
+ message: reason,
336
+ },
337
+ });
338
+ }
339
+ }
340
+ for (const segmentId of await this.listSessionSegments(runDir)) {
341
+ const segmentCapture = await readJsonFile<WorkflowSessionCapture>(
342
+ path.join(runDir, sessionStreamPaths(segmentId).capture),
343
+ );
344
+ if (segmentCapture?.status !== "recording") {
345
+ continue;
346
+ }
347
+ const segmentCounts = await this.sessionCounts(runDir, segmentId);
348
+ await this.writeSessionCapture(
349
+ runDir,
350
+ {
351
+ schema: SESSION_CAPTURE_SCHEMA,
352
+ eventSchema: SESSION_EVENT_SCHEMA,
353
+ status: "failed",
354
+ ...segmentCounts,
355
+ failure: {
356
+ failedAt: new Date().toISOString(),
357
+ code: "host_interrupted",
358
+ message: reason,
359
+ },
360
+ },
361
+ segmentId,
362
+ );
363
+ }
364
+ }
365
+
366
+ /** Mark a nonterminal bundle failed and append an interruption event. */
367
+ async markRunInterrupted(
368
+ runId: string,
369
+ reason = "Workflow host stopped before the run finished",
370
+ ): Promise<LoadedRunBundle | null> {
371
+ const runDir = this.runDirFor(runId);
372
+ const bundle = await readRunBundle(runDir);
373
+ if (bundle === null || bundle.state.status !== "running") {
374
+ return bundle;
375
+ }
376
+ const lastTraceEvent = await readLastTraceEvent(runDir, bundle.manifest.paths.trace);
377
+ const counts = await this.sessionCounts(runDir);
378
+ const sessionBound = bundle.manifest.paths.session !== undefined;
379
+ const captureFinished =
380
+ bundle.sessionCapture?.status === "complete" || bundle.sessionCapture?.status === "failed";
381
+ this.contexts.set(runDir, {
382
+ traceSeq: Math.max(bundle.state.traceSeq, lastTraceEvent?.seq ?? 0),
383
+ artifacts: new ArtifactWriter(runDir),
384
+ lock: Promise.resolve(),
385
+ streams: seededStreams({
386
+ sessionSeq: counts.entryCount,
387
+ sessionEventSeq: counts.lastEventSeq,
388
+ sessionBound,
389
+ sessionEventsStopped: captureFinished,
390
+ }),
391
+ });
392
+ const state = bundle.state;
393
+ if (lastTraceEvent !== null && recoverTerminalProjection(state, lastTraceEvent)) {
394
+ await this.writeLoadedProjections(runDir, state);
395
+ return await readRunBundle(runDir);
396
+ }
397
+ if (sessionBound && !captureFinished) {
398
+ await this.writeSessionCapture(runDir, {
399
+ schema: SESSION_CAPTURE_SCHEMA,
400
+ eventSchema: SESSION_EVENT_SCHEMA,
401
+ status: "failed",
402
+ ...counts,
403
+ failure: {
404
+ failedAt: new Date().toISOString(),
405
+ code: "host_interrupted",
406
+ message: reason,
407
+ },
408
+ });
409
+ }
410
+ await this.finalizeRecordingCaptures(runDir, reason, { skipFlat: true });
411
+ state.status = "failed";
412
+ state.finishedAt = new Date().toISOString();
413
+ state.error = reason;
414
+ delete state.currentNode;
415
+ delete state.currentAttemptId;
416
+ delete state.currentNodeStartedAt;
417
+ delete state.statusDetail;
418
+ delete state.paused;
419
+ await this.withRunLock(runDir, async () => {
420
+ const traceEvent = await this.appendTraceEvent(runDir, state.runId, {
421
+ scope: "run",
422
+ type: "run_interrupted",
423
+ payload: { error: reason },
424
+ });
425
+ state.traceSeq = traceEvent.seq;
426
+ state.updatedAt = traceEvent.at;
427
+ await this.writeLoadedProjections(runDir, state);
428
+ });
429
+ return await readRunBundle(runDir);
430
+ }
431
+
160
432
  /**
161
433
  * Persist one transition: append the trace event, then rewrite the
162
434
  * projections reflecting it.
@@ -180,34 +452,71 @@ export class WorkflowRunStore {
180
452
  * append a `session_bound` trace event. Projections catch up on the next
181
453
  * snapshot.
182
454
  */
183
- async writeSessionBinding(runDir: string, binding: WorkflowSessionBinding): Promise<void> {
455
+ /** True when a session binding already exists for this bundle. */
456
+ async hasSessionBinding(runDir: string): Promise<boolean> {
457
+ try {
458
+ await fs.lstat(path.join(runDir, SESSION_BINDING_PATH));
459
+ return true;
460
+ } catch {
461
+ return false;
462
+ }
463
+ }
464
+
465
+ /** List capture segment attempt ids under `session/segments/`. */
466
+ async listSessionSegments(runDir: string): Promise<string[]> {
467
+ try {
468
+ const entries = await fs.readdir(path.join(runDir, SESSION_SEGMENTS_DIR), {
469
+ withFileTypes: true,
470
+ });
471
+ return entries
472
+ .filter((entry) => entry.isDirectory())
473
+ .map((entry) => entry.name)
474
+ .sort();
475
+ } catch {
476
+ return [];
477
+ }
478
+ }
479
+
480
+ async writeSessionBinding(
481
+ runDir: string,
482
+ binding: WorkflowSessionBinding,
483
+ attemptId?: string,
484
+ ): Promise<void> {
184
485
  await this.withRunLock(runDir, async () => {
185
- const context = this.contextFor(runDir);
186
- if (context.sessionBound) {
486
+ const stream = this.streamFor(runDir, attemptId);
487
+ if (stream.sessionBound) {
187
488
  return;
188
489
  }
189
- context.sessionBound = true;
190
- await fs.mkdir(path.join(runDir, SESSION_DIR), { recursive: true, mode: 0o700 });
191
- await writeJsonAtomic(path.join(runDir, SESSION_BINDING_PATH), binding);
490
+ stream.sessionBound = true;
491
+ const paths = sessionStreamPaths(attemptId);
492
+ await fs.mkdir(path.join(runDir, paths.dir), { recursive: true, mode: 0o700 });
493
+ await writeJsonAtomic(path.join(runDir, paths.binding), binding);
192
494
  await this.appendTraceEvent(runDir, binding.runId, {
193
495
  scope: "session",
194
496
  type: "session_bound",
195
- payload: { piSessionId: binding.piSessionId },
497
+ payload: {
498
+ piSessionId: binding.piSessionId,
499
+ ...(attemptId !== undefined ? { captureAttemptId: attemptId } : {}),
500
+ },
196
501
  });
197
502
  });
198
503
  }
199
504
 
200
505
  /** Append one verbatim Pi session entry to `session/entries.ndjson`. */
201
- async appendSessionEntry(runDir: string, entry: Record<string, unknown>): Promise<number> {
506
+ async appendSessionEntry(
507
+ runDir: string,
508
+ entry: Record<string, unknown>,
509
+ attemptId?: string,
510
+ ): Promise<number> {
202
511
  return await this.withRunLock(runDir, async () => {
203
- const context = this.contextFor(runDir);
204
- context.sessionSeq += 1;
512
+ const stream = this.streamFor(runDir, attemptId);
513
+ stream.sessionSeq += 1;
205
514
  const record: WorkflowSessionEntryRecord = {
206
- seq: context.sessionSeq,
515
+ seq: stream.sessionSeq,
207
516
  at: new Date().toISOString(),
208
517
  entry,
209
518
  };
210
- await appendLine(path.join(runDir, SESSION_ENTRIES_PATH), record);
519
+ await appendLine(path.join(runDir, sessionStreamPaths(attemptId).entries), record);
211
520
  return record.seq;
212
521
  });
213
522
  }
@@ -216,17 +525,18 @@ export class WorkflowRunStore {
216
525
  async appendSessionEventBatch(
217
526
  runDir: string,
218
527
  records: WorkflowSessionEventRecord[],
528
+ attemptId?: string,
219
529
  ): Promise<void> {
220
530
  if (records.length === 0) {
221
531
  return;
222
532
  }
223
- await this.withSessionEventLock(runDir, async () => {
224
- const context = this.contextFor(runDir);
225
- if (context.sessionEventsStopped) {
533
+ await this.withSessionEventLock(runDir, attemptId, async () => {
534
+ const stream = this.streamFor(runDir, attemptId);
535
+ if (stream.sessionEventsStopped) {
226
536
  throw new Error("Session event capture has stopped");
227
537
  }
228
538
  try {
229
- let expected = context.sessionEventSeq + 1;
539
+ let expected = stream.sessionEventSeq + 1;
230
540
  for (const record of records) {
231
541
  validateSessionEventRecord(record);
232
542
  if (record.seq !== expected) {
@@ -241,7 +551,7 @@ export class WorkflowRunStore {
241
551
  record.type === "tool_execution_started" ||
242
552
  record.type === "tool_execution_finished" ||
243
553
  (record.type === "assistant_event" && record.payload.type === "toolcall_end")
244
- ? ((await encodeValue(record.payload, context.artifacts)) as Record<
554
+ ? ((await encodeValue(record.payload, this.contextFor(runDir).artifacts)) as Record<
245
555
  string,
246
556
  unknown
247
557
  >)
@@ -253,33 +563,39 @@ export class WorkflowRunStore {
253
563
  throw new Error(`session event exceeded ${SESSION_EVENT_MAX_BYTES} bytes`);
254
564
  }
255
565
  }
256
- await appendLines(path.join(runDir, SESSION_EVENTS_PATH), encoded);
257
- context.sessionEventSeq = records.at(-1)?.seq ?? context.sessionEventSeq;
566
+ await appendLines(path.join(runDir, sessionStreamPaths(attemptId).events), encoded);
567
+ stream.sessionEventSeq = records.at(-1)?.seq ?? stream.sessionEventSeq;
258
568
  } catch (error) {
259
- context.sessionEventsStopped = true;
569
+ stream.sessionEventsStopped = true;
260
570
  throw error;
261
571
  }
262
572
  });
263
573
  }
264
574
 
265
575
  /** Atomically replace the temporal capture integrity projection. */
266
- async writeSessionCapture(runDir: string, capture: WorkflowSessionCapture): Promise<void> {
576
+ async writeSessionCapture(
577
+ runDir: string,
578
+ capture: WorkflowSessionCapture,
579
+ attemptId?: string,
580
+ ): Promise<void> {
267
581
  validateSessionCapture(capture);
268
- await this.withSessionEventLock(runDir, async () => {
269
- const context = this.contextFor(runDir);
582
+ await this.withSessionEventLock(runDir, attemptId, async () => {
583
+ const stream = this.streamFor(runDir, attemptId);
270
584
  if (capture.status !== "recording") {
271
- context.sessionEventsStopped = true;
585
+ stream.sessionEventsStopped = true;
272
586
  }
273
- await writeJsonAtomic(path.join(runDir, SESSION_CAPTURE_PATH), capture);
587
+ await writeJsonAtomic(path.join(runDir, sessionStreamPaths(attemptId).capture), capture);
274
588
  });
275
589
  }
276
590
 
277
591
  /** Count complete durable session records after both writers have drained. */
278
592
  async sessionCounts(
279
593
  runDir: string,
594
+ attemptId?: string,
280
595
  ): Promise<{ eventCount: number; entryCount: number; lastEventSeq: number }> {
281
- const events = await readCompleteNdjson(path.join(runDir, SESSION_EVENTS_PATH));
282
- const entries = await readCompleteNdjson(path.join(runDir, SESSION_ENTRIES_PATH));
596
+ const paths = sessionStreamPaths(attemptId);
597
+ const events = await readCompleteNdjson(path.join(runDir, paths.events));
598
+ const entries = await readCompleteNdjson(path.join(runDir, paths.entries));
283
599
  return {
284
600
  eventCount: events.length,
285
601
  entryCount: entries.length,
@@ -312,12 +628,132 @@ export class WorkflowRunStore {
312
628
  await writeJsonAtomic(
313
629
  path.join(runDir, MANIFEST_PATH),
314
630
  createManifest(state, {
315
- session: context.sessionBound,
631
+ session: [...context.streams.values()].some((stream) => stream.sessionBound),
632
+ }),
633
+ );
634
+ }
635
+
636
+ private async writeLoadedProjections(runDir: string, state: WorkflowRunState): Promise<void> {
637
+ const context = this.contextFor(runDir);
638
+ await writeJsonAtomic(path.join(runDir, STATE_PATH), state);
639
+ await writeJsonAtomic(
640
+ path.join(runDir, MANIFEST_PATH),
641
+ createManifest(state, {
642
+ session: [...context.streams.values()].some((stream) => stream.sessionBound),
316
643
  }),
317
644
  );
318
645
  }
319
646
  }
320
647
 
648
+ /**
649
+ * Truncate a trace file to the longest contiguous valid prefix, then to the
650
+ * event count the state projection recorded. A crash can leave a partial
651
+ * final line or one event appended before its projection write; the repair
652
+ * keeps state and trace consistent so resume can continue the sequence. The
653
+ * rewrite is atomic: a stale writer appending to the old inode cannot
654
+ * interleave with the repaired file.
655
+ */
656
+ async function repairTraceFile(
657
+ tracePath: string,
658
+ keepSeq: number,
659
+ beforeWrite?: () => void,
660
+ ): Promise<void> {
661
+ let raw: string;
662
+ try {
663
+ raw = await fs.readFile(tracePath, "utf8");
664
+ } catch {
665
+ return;
666
+ }
667
+ const lines = raw.split("\n");
668
+ if (lines.at(-1) === "") {
669
+ lines.pop();
670
+ }
671
+ const good: string[] = [];
672
+ let expectedSeq = 1;
673
+ for (const line of lines) {
674
+ if (line.trim().length === 0) {
675
+ break;
676
+ }
677
+ try {
678
+ const event = JSON.parse(line) as { seq?: unknown };
679
+ if (event.seq !== expectedSeq) {
680
+ break;
681
+ }
682
+ good.push(line);
683
+ expectedSeq += 1;
684
+ } catch {
685
+ break;
686
+ }
687
+ }
688
+ const kept = good.slice(0, keepSeq);
689
+ if (kept.length === lines.length) {
690
+ return;
691
+ }
692
+ beforeWrite?.();
693
+ const tempPath = `${tracePath}.${process.pid}.${randomUUID()}.tmp`;
694
+ await fs.writeFile(tempPath, kept.length === 0 ? "" : `${kept.join("\n")}\n`, {
695
+ encoding: "utf8",
696
+ mode: 0o600,
697
+ });
698
+ await fs.rename(tempPath, tracePath);
699
+ }
700
+
701
+ function seededStreams(flat: Omit<SessionStreamState, "lock">): Map<string, SessionStreamState> {
702
+ return new Map([["", { ...flat, lock: Promise.resolve() }]]);
703
+ }
704
+
705
+ function recoverTerminalProjection(state: WorkflowRunState, event: WorkflowTraceEvent): boolean {
706
+ const status = terminalStatusForEvent(event.type);
707
+ if (status === undefined) {
708
+ return false;
709
+ }
710
+ state.traceSeq = event.seq;
711
+ state.status = status;
712
+ state.updatedAt = event.at;
713
+ state.finishedAt = event.at;
714
+ if (typeof event.payload.error === "string") {
715
+ state.error = event.payload.error;
716
+ }
717
+ if (typeof event.payload.waitingOn === "string") {
718
+ state.waitingOn = event.payload.waitingOn;
719
+ }
720
+ if (Object.hasOwn(event.payload, "finalOutput")) {
721
+ state.finalOutput = event.payload.finalOutput;
722
+ }
723
+ delete state.currentNode;
724
+ delete state.currentAttemptId;
725
+ delete state.currentNodeStartedAt;
726
+ return true;
727
+ }
728
+
729
+ function terminalStatusForEvent(type: string): WorkflowRunState["status"] | undefined {
730
+ switch (type) {
731
+ case "run_waiting":
732
+ return "waiting";
733
+ case "run_completed":
734
+ return "completed";
735
+ case "run_failed":
736
+ case "run_interrupted":
737
+ return "failed";
738
+ case "run_timed_out":
739
+ return "timed_out";
740
+ case "run_cancelled":
741
+ return "cancelled";
742
+ default:
743
+ return undefined;
744
+ }
745
+ }
746
+
747
+ function assertValidRunId(runId: string): void {
748
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/.test(runId)) {
749
+ throw new Error(`Invalid workflow run id: ${JSON.stringify(runId)}`);
750
+ }
751
+ }
752
+
753
+ function isMissingPath(error: unknown): boolean {
754
+ return error instanceof Error && "code" in error && error.code === "ENOENT";
755
+ }
756
+
321
757
  async function appendLine(filePath: string, value: unknown): Promise<void> {
322
758
  await fs.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
323
759
  await fs.appendFile(filePath, value === null ? "" : `${JSON.stringify(value)}\n`, {
@@ -545,6 +981,16 @@ export type SessionCaptureIntegrity = {
545
981
  diagnostics: string[];
546
982
  };
547
983
 
984
+ /** One capture attempt: the session data a single recorder wrote. */
985
+ export type SessionCaptureSegment = {
986
+ attemptId: string;
987
+ binding: WorkflowSessionBinding | null;
988
+ entries: WorkflowSessionEntryRecord[];
989
+ events: WorkflowSessionEventRecord[];
990
+ capture: WorkflowSessionCapture | null;
991
+ integrity: SessionCaptureIntegrity;
992
+ };
993
+
548
994
  export type LoadedRunBundle = {
549
995
  runDir: string;
550
996
  manifest: WorkflowRunManifest;
@@ -555,8 +1001,21 @@ export type LoadedRunBundle = {
555
1001
  sessionEvents: WorkflowSessionEventRecord[];
556
1002
  sessionCapture: WorkflowSessionCapture | null;
557
1003
  sessionIntegrity: SessionCaptureIntegrity;
1004
+ /** Per-attempt captures written after a handoff or resume. */
1005
+ sessionSegments: SessionCaptureSegment[];
558
1006
  };
559
1007
 
1008
+ /** Read the final trace record without loading the rest of a run bundle. */
1009
+ export async function readLastTraceEvent(
1010
+ runDir: string,
1011
+ tracePath?: string,
1012
+ ): Promise<WorkflowTraceEvent | null> {
1013
+ const events = await readNdjsonFile<WorkflowTraceEvent>(
1014
+ resolveBundlePath(runDir, tracePath, TRACE_PATH),
1015
+ );
1016
+ return events.records.at(-1) ?? null;
1017
+ }
1018
+
560
1019
  /** Read a run bundle from disk. Returns null when the bundle is unreadable. */
561
1020
  export async function readRunBundle(runDir: string): Promise<LoadedRunBundle | null> {
562
1021
  const manifest = await readJsonFile<WorkflowRunManifest>(path.join(runDir, MANIFEST_PATH));
@@ -589,13 +1048,62 @@ export async function readRunBundle(runDir: string): Promise<LoadedRunBundle | n
589
1048
  const sessionCapture = await readJsonFile<WorkflowSessionCapture>(
590
1049
  path.join(sessionDir, "capture.json"),
591
1050
  );
592
- const sessionIntegrity = assessSessionIntegrity({
1051
+ const flatIntegrity = assessSessionIntegrity({
593
1052
  binding: sessionBinding,
594
1053
  entries,
595
1054
  events,
596
1055
  capture: sessionCapture,
597
1056
  runTerminal: state.status !== "running",
598
1057
  });
1058
+ const sessionSegments: SessionCaptureSegment[] = [];
1059
+ let segmentIds: string[] = [];
1060
+ try {
1061
+ segmentIds = (await fs.readdir(path.join(sessionDir, "segments"), { withFileTypes: true }))
1062
+ .filter((entry) => entry.isDirectory())
1063
+ .map((entry) => entry.name)
1064
+ .sort();
1065
+ } catch {
1066
+ // No segments directory means only the flat stream can exist.
1067
+ }
1068
+ for (const attemptId of segmentIds) {
1069
+ const segmentDir = path.join(sessionDir, "segments", attemptId);
1070
+ const binding = await readJsonFile<WorkflowSessionBinding>(
1071
+ path.join(segmentDir, "binding.json"),
1072
+ );
1073
+ const segmentEntries = await readNdjsonFile<WorkflowSessionEntryRecord>(
1074
+ path.join(segmentDir, "entries.ndjson"),
1075
+ );
1076
+ const segmentEvents = await readNdjsonFile<WorkflowSessionEventRecord>(
1077
+ path.join(segmentDir, "events.ndjson"),
1078
+ );
1079
+ const capture = await readJsonFile<WorkflowSessionCapture>(
1080
+ path.join(segmentDir, "capture.json"),
1081
+ );
1082
+ sessionSegments.push({
1083
+ attemptId,
1084
+ binding,
1085
+ entries: segmentEntries.records,
1086
+ events: segmentEvents.records,
1087
+ capture,
1088
+ integrity: assessSessionIntegrity({
1089
+ binding,
1090
+ entries: segmentEntries,
1091
+ events: segmentEvents,
1092
+ capture,
1093
+ runTerminal: state.status !== "running",
1094
+ }),
1095
+ });
1096
+ }
1097
+ // The headline integrity is the flat stream's when present; otherwise the
1098
+ // chronologically latest capture segment speaks for the run (segment ids
1099
+ // are random, so directory order says nothing about time).
1100
+ sessionSegments.sort((a, b) =>
1101
+ (a.binding?.boundAt ?? "").localeCompare(b.binding?.boundAt ?? ""),
1102
+ );
1103
+ const sessionIntegrity =
1104
+ flatIntegrity.status !== "unavailable" || sessionSegments.length === 0
1105
+ ? flatIntegrity
1106
+ : (sessionSegments.at(-1)?.integrity ?? flatIntegrity);
599
1107
  return {
600
1108
  runDir,
601
1109
  manifest,
@@ -606,6 +1114,7 @@ export async function readRunBundle(runDir: string): Promise<LoadedRunBundle | n
606
1114
  sessionEvents: events.records,
607
1115
  sessionCapture,
608
1116
  sessionIntegrity,
1117
+ sessionSegments,
609
1118
  };
610
1119
  }
611
1120