@osolmaz/pi-workflows 0.1.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 (171) hide show
  1. package/README.md +107 -26
  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.d.ts +14 -1
  51. package/dist/extension/executor.js +12 -2
  52. package/dist/extension/executor.js.map +1 -1
  53. package/dist/extension/index.d.ts +7 -0
  54. package/dist/extension/index.js +1118 -89
  55. package/dist/extension/index.js.map +1 -1
  56. package/dist/extension/recorder.d.ts +91 -0
  57. package/dist/extension/recorder.js +537 -0
  58. package/dist/extension/recorder.js.map +1 -0
  59. package/dist/extension/session-events.d.ts +134 -0
  60. package/dist/extension/session-events.js +60 -0
  61. package/dist/extension/session-events.js.map +1 -0
  62. package/dist/extension/widget.js +25 -24
  63. package/dist/extension/widget.js.map +1 -1
  64. package/dist/extension/workflow-tool.d.ts +28 -0
  65. package/dist/extension/workflow-tool.js +33 -0
  66. package/dist/extension/workflow-tool.js.map +1 -0
  67. package/dist/host/processes.d.ts +24 -0
  68. package/dist/host/processes.js +114 -0
  69. package/dist/host/processes.js.map +1 -0
  70. package/dist/host/rpc-bridge.d.ts +9 -0
  71. package/dist/host/rpc-bridge.js +39 -0
  72. package/dist/host/rpc-bridge.js.map +1 -0
  73. package/dist/host/rpc-executor.d.ts +38 -0
  74. package/dist/host/rpc-executor.js +254 -0
  75. package/dist/host/rpc-executor.js.map +1 -0
  76. package/dist/host/runner.d.ts +49 -0
  77. package/dist/host/runner.js +350 -0
  78. package/dist/host/runner.js.map +1 -0
  79. package/dist/render/canvas.d.ts +1 -1
  80. package/dist/render/canvas.js +5 -0
  81. package/dist/render/canvas.js.map +1 -1
  82. package/dist/render/graph-render.d.ts +5 -0
  83. package/dist/render/graph-render.js +211 -48
  84. package/dist/render/graph-render.js.map +1 -1
  85. package/dist/viewer/cli.d.ts +7 -3
  86. package/dist/viewer/cli.js +150 -19
  87. package/dist/viewer/cli.js.map +1 -1
  88. package/dist/viewer/render.js +19 -3
  89. package/dist/viewer/render.js.map +1 -1
  90. package/dist/viewer/session-reducer.d.ts +45 -0
  91. package/dist/viewer/session-reducer.js +266 -0
  92. package/dist/viewer/session-reducer.js.map +1 -0
  93. package/dist/workflows/artifacts.d.ts +40 -0
  94. package/dist/workflows/artifacts.js +155 -0
  95. package/dist/workflows/artifacts.js.map +1 -0
  96. package/dist/workflows/engine.d.ts +38 -0
  97. package/dist/workflows/engine.js +281 -20
  98. package/dist/workflows/engine.js.map +1 -1
  99. package/dist/workflows/errors.d.ts +23 -0
  100. package/dist/workflows/errors.js +38 -0
  101. package/dist/workflows/errors.js.map +1 -1
  102. package/dist/workflows/graph.js +0 -5
  103. package/dist/workflows/graph.js.map +1 -1
  104. package/dist/workflows/index.d.ts +3 -2
  105. package/dist/workflows/index.js +2 -1
  106. package/dist/workflows/index.js.map +1 -1
  107. package/dist/workflows/loader.d.ts +5 -3
  108. package/dist/workflows/loader.js +10 -1
  109. package/dist/workflows/loader.js.map +1 -1
  110. package/dist/workflows/schema.js +1 -1
  111. package/dist/workflows/schema.js.map +1 -1
  112. package/dist/workflows/store.d.ts +98 -10
  113. package/dist/workflows/store.js +921 -46
  114. package/dist/workflows/store.js.map +1 -1
  115. package/dist/workflows/types.d.ts +136 -3
  116. package/docs/CONTROLLERS.md +215 -0
  117. package/docs/development.md +51 -24
  118. package/docs/live-replay-protocol.md +155 -0
  119. package/docs/plans/2026-08-04-controller-runtime-plan.md +169 -0
  120. package/docs/plans/2026-08-05-always-on-workflows-plan.md +125 -0
  121. package/docs/plans/2026-08-10-agent-managed-monitor-workflows-plan.md +184 -0
  122. package/docs/plans/piw-viewer-experience-implementation-plan.md +674 -0
  123. package/docs/plans/replayable-run-bundles-implementation-plan.md +65 -0
  124. package/docs/plans/session-event-replay-implementation-plan.md +494 -0
  125. package/docs/plans/tui-viewer-implementation-plan.md +64 -0
  126. package/docs/run-bundles.md +359 -55
  127. package/docs/session-event-journal.md +470 -0
  128. package/docs/tui-viewer.md +218 -0
  129. package/docs/workflows.md +131 -11
  130. package/examples/controllers/pull-request.controller.ts +215 -0
  131. package/package.json +11 -2
  132. package/src/builtins/monitor.workflow.ts +278 -0
  133. package/src/controllers/conditions.ts +110 -0
  134. package/src/controllers/definition.ts +65 -0
  135. package/src/controllers/effects.ts +123 -0
  136. package/src/controllers/errors.ts +27 -0
  137. package/src/controllers/index.ts +90 -0
  138. package/src/controllers/json.ts +62 -0
  139. package/src/controllers/loader.ts +104 -0
  140. package/src/controllers/manager.ts +533 -0
  141. package/src/controllers/results.ts +46 -0
  142. package/src/controllers/sqlite.ts +1427 -0
  143. package/src/controllers/store.ts +160 -0
  144. package/src/controllers/types.ts +183 -0
  145. package/src/controllers/workflow-engine-scheduler.ts +145 -0
  146. package/src/controllers/workflows.ts +152 -0
  147. package/src/extension/controller-host.ts +163 -0
  148. package/src/extension/executor.ts +29 -2
  149. package/src/extension/index.ts +1315 -109
  150. package/src/extension/recorder.ts +662 -0
  151. package/src/extension/session-events.ts +119 -0
  152. package/src/extension/widget.ts +26 -24
  153. package/src/extension/workflow-tool.ts +59 -0
  154. package/src/host/processes.ts +119 -0
  155. package/src/host/rpc-bridge.ts +44 -0
  156. package/src/host/rpc-executor.ts +299 -0
  157. package/src/host/runner.ts +406 -0
  158. package/src/render/canvas.ts +19 -1
  159. package/src/render/graph-render.ts +277 -44
  160. package/src/viewer/cli.ts +167 -21
  161. package/src/viewer/render.ts +21 -3
  162. package/src/viewer/session-reducer.ts +347 -0
  163. package/src/workflows/artifacts.ts +188 -0
  164. package/src/workflows/engine.ts +365 -19
  165. package/src/workflows/errors.ts +45 -0
  166. package/src/workflows/graph.ts +0 -5
  167. package/src/workflows/index.ts +15 -0
  168. package/src/workflows/loader.ts +13 -3
  169. package/src/workflows/schema.ts +1 -1
  170. package/src/workflows/store.ts +1157 -48
  171. package/src/workflows/types.ts +151 -3
@@ -0,0 +1,662 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import {
4
+ SESSION_BINDING_SCHEMA,
5
+ SESSION_CAPTURE_SCHEMA,
6
+ SESSION_EVENT_MAX_BYTES,
7
+ SESSION_EVENT_SCHEMA,
8
+ type WorkflowRunStore,
9
+ } from "../workflows/store.js";
10
+ import type {
11
+ AgentStepContract,
12
+ ConversationRange,
13
+ WorkflowSessionCaptureFailure,
14
+ WorkflowSessionEventRecord,
15
+ WorkflowSessionEventType,
16
+ } from "../workflows/types.js";
17
+ import {
18
+ messageRole,
19
+ normalizeAssistantEvent,
20
+ toolCallIdFromAssistantEvent,
21
+ toolFinishedPayload,
22
+ toolStartedPayload,
23
+ turnFinishedPayload,
24
+ type MessageEndEventLike,
25
+ type MessageStartEventLike,
26
+ type MessageUpdateEventLike,
27
+ type ToolExecutionEndEventLike,
28
+ type ToolExecutionStartEventLike,
29
+ type ToolExecutionUpdateEventLike,
30
+ type TurnEndEventLike,
31
+ type TurnStartEventLike,
32
+ } from "./session-events.js";
33
+
34
+ const FLUSH_INTERVAL_MS = 25;
35
+ const FINISH_TURN_TIMEOUT_MS = 30_000;
36
+ const FLUSH_MAX_RECORDS = 256;
37
+ const FLUSH_MAX_BYTES = 256 * 1024;
38
+ const QUEUE_MAX_RECORDS = 8_192;
39
+ const QUEUE_MAX_BYTES = 16 * 1024 * 1024;
40
+
41
+ type AttemptOwner = { nodeId: string; attemptId: string };
42
+ type TurnOwner = AttemptOwner & { turnId: string; turnIndex: number };
43
+ type MessageOwner = TurnOwner & { messageId: string; role: string };
44
+ type QueuedEvent = { record: WorkflowSessionEventRecord; bytes: number };
45
+ type PendingMessageEnd = { queued: QueuedEvent; role: string; message: unknown };
46
+ type RecordedEntry = { id: string; entry: Record<string, unknown>; claimed: boolean };
47
+
48
+ function objectKey(value: unknown): object | null {
49
+ return typeof value === "object" && value !== null ? value : null;
50
+ }
51
+
52
+ function stableMessageKey(value: unknown): string | null {
53
+ if (typeof value !== "object" || value === null) {
54
+ return null;
55
+ }
56
+ const message = value as { id?: unknown; role?: unknown; timestamp?: unknown };
57
+ if (typeof message.id === "string" && message.id.length > 0) {
58
+ return `id:${message.id}`;
59
+ }
60
+ if (
61
+ (typeof message.timestamp === "number" || typeof message.timestamp === "string") &&
62
+ typeof message.role === "string"
63
+ ) {
64
+ return `timestamp:${message.role}:${message.timestamp}`;
65
+ }
66
+ return null;
67
+ }
68
+
69
+ function entryRole(entry: Record<string, unknown>): string {
70
+ const message = entry.message;
71
+ return messageRole(message);
72
+ }
73
+
74
+ function failureMessage(error: unknown): string {
75
+ return error instanceof Error ? error.message : String(error);
76
+ }
77
+
78
+ /**
79
+ * Records both settled Pi entries and normalized temporal events into one run
80
+ * bundle. The hot event path only stamps and queues records; filesystem work
81
+ * runs on a separate ordered append chain.
82
+ */
83
+ export class SessionRecorder {
84
+ private readonly store: WorkflowRunStore;
85
+ private readonly runDir: string;
86
+ private readonly runId: string;
87
+ /**
88
+ * Capture segment for this recorder. Undefined writes the legacy flat
89
+ * stream; a second recorder attaching to the same bundle (resume or
90
+ * handoff) writes a segment instead so the first capture stays intact.
91
+ */
92
+ private segmentId: string | undefined;
93
+ private cursor: string | null = null;
94
+ private readonly recorded: string[] = [];
95
+ private readonly unclaimedEntries: RecordedEntry[] = [];
96
+ private bound = false;
97
+ private acceptingEntries = true;
98
+ private acceptingEvents = true;
99
+ private entryChain: Promise<unknown> = Promise.resolve();
100
+ private stopPromise: Promise<void> | null = null;
101
+
102
+ private nextEventSeq = 1;
103
+ private nextTurnId = 1;
104
+ private nextMessageId = 1;
105
+ private currentAttempt: AttemptOwner | null = null;
106
+ private currentTurn: TurnOwner | null = null;
107
+ private currentMessage: MessageOwner | null = null;
108
+ private lastFinishedAttempt: AttemptOwner | null = null;
109
+ private finishPromise: Promise<void> | null = null;
110
+ private resolveFinish: (() => void) | null = null;
111
+ private finishTimer: NodeJS.Timeout | null = null;
112
+ private readonly messageOwners = new WeakMap<object, MessageOwner>();
113
+ private readonly stableMessageOwners = new Map<string, MessageOwner>();
114
+ private readonly toolOwners = new Map<string, MessageOwner>();
115
+ private readonly turnToolCallIds = new Map<string, string[]>();
116
+
117
+ private eventQueue: QueuedEvent[] = [];
118
+ private readonly pendingMessageEnds: PendingMessageEnd[] = [];
119
+ private outstandingRecords = 0;
120
+ private outstandingBytes = 0;
121
+ private flushTimer: NodeJS.Timeout | null = null;
122
+ private flushPromise: Promise<void> | null = null;
123
+ private captureFailure: WorkflowSessionCaptureFailure | null = null;
124
+
125
+ constructor(store: WorkflowRunStore, runDir: string, runId: string) {
126
+ this.store = store;
127
+ this.runDir = runDir;
128
+ this.runId = runId;
129
+ }
130
+
131
+ async bind(ctx: ExtensionContext): Promise<void> {
132
+ if (this.bound) {
133
+ return;
134
+ }
135
+ this.bound = true;
136
+ this.cursor = ctx.sessionManager.getLeafId();
137
+ const sessionFile = ctx.sessionManager.getSessionFile();
138
+ // A bundle that already has a binding belongs to an earlier capture
139
+ // attempt; this recorder writes its own segment under session/segments.
140
+ if (await this.store.hasSessionBinding(this.runDir)) {
141
+ this.segmentId = randomUUID();
142
+ }
143
+ await this.store.writeSessionBinding(
144
+ this.runDir,
145
+ {
146
+ schema: SESSION_BINDING_SCHEMA,
147
+ runId: this.runId,
148
+ piSessionId: ctx.sessionManager.getSessionId(),
149
+ ...(sessionFile !== undefined ? { piSessionFile: sessionFile } : {}),
150
+ cwd: ctx.cwd,
151
+ boundAt: new Date().toISOString(),
152
+ },
153
+ this.segmentId,
154
+ );
155
+ await this.store.writeSessionCapture(
156
+ this.runDir,
157
+ {
158
+ schema: SESSION_CAPTURE_SCHEMA,
159
+ eventSchema: SESSION_EVENT_SCHEMA,
160
+ status: "recording",
161
+ eventCount: 0,
162
+ entryCount: 0,
163
+ lastEventSeq: 0,
164
+ },
165
+ this.segmentId,
166
+ );
167
+ }
168
+
169
+ /** Fix the owner before the executor delivers an agent-step prompt. */
170
+ beginAttempt(contract: AgentStepContract): void {
171
+ this.currentAttempt = { nodeId: contract.nodeId, attemptId: contract.attemptId };
172
+ }
173
+
174
+ /** Release ownership of the turn that Pi has fully settled. */
175
+ settleAttempt(): void {
176
+ const finished = this.lastFinishedAttempt;
177
+ this.lastFinishedAttempt = null;
178
+ if (
179
+ this.currentTurn === null &&
180
+ (finished === null ||
181
+ (this.currentAttempt?.nodeId === finished.nodeId &&
182
+ this.currentAttempt.attemptId === finished.attemptId))
183
+ ) {
184
+ this.currentAttempt = null;
185
+ }
186
+ }
187
+
188
+ handleTurnStart(event: TurnStartEventLike): void {
189
+ const owner = this.currentAttempt;
190
+ if (!owner) {
191
+ return;
192
+ }
193
+ const turn: TurnOwner = {
194
+ ...owner,
195
+ turnId: `t${this.nextTurnId}`,
196
+ turnIndex: event.turnIndex,
197
+ };
198
+ this.nextTurnId += 1;
199
+ this.currentTurn = turn;
200
+ this.turnToolCallIds.set(turn.turnId, []);
201
+ this.enqueue(turn, "turn_started", { turnIndex: event.turnIndex });
202
+ }
203
+
204
+ async handleTurnEnd(event: TurnEndEventLike, ctx: ExtensionContext): Promise<void> {
205
+ const turn = this.currentTurn;
206
+ if (!turn) {
207
+ return;
208
+ }
209
+ const message = this.ownerForMessage(event.message);
210
+ try {
211
+ await this.synchronize(ctx);
212
+ this.enqueue(
213
+ turn,
214
+ "turn_finished",
215
+ turnFinishedPayload(event, message?.messageId, this.turnToolCallIds.get(turn.turnId) ?? []),
216
+ );
217
+ } catch (error) {
218
+ this.failCapture("entry_write_failed", failureMessage(error));
219
+ }
220
+ this.currentTurn = null;
221
+ this.currentMessage = null;
222
+ this.lastFinishedAttempt = { nodeId: turn.nodeId, attemptId: turn.attemptId };
223
+ if (this.finishPromise) {
224
+ await this.stop();
225
+ }
226
+ }
227
+
228
+ async handleMessageStart(event: MessageStartEventLike, ctx: ExtensionContext): Promise<void> {
229
+ const turn = this.currentTurn;
230
+ if (!turn) {
231
+ return;
232
+ }
233
+ await this.synchronize(ctx);
234
+ const owner: MessageOwner = {
235
+ ...turn,
236
+ messageId: `m${this.nextMessageId}`,
237
+ role: messageRole(event.message),
238
+ };
239
+ this.nextMessageId += 1;
240
+ const key = objectKey(event.message);
241
+ if (key) {
242
+ this.messageOwners.set(key, owner);
243
+ }
244
+ const stableKey = stableMessageKey(event.message);
245
+ if (stableKey) {
246
+ this.stableMessageOwners.set(stableKey, owner);
247
+ }
248
+ this.currentMessage = owner;
249
+ this.enqueue(owner, "message_started", { role: owner.role });
250
+ }
251
+
252
+ handleMessageUpdate(event: MessageUpdateEventLike): void {
253
+ const owner = this.ownerForMessage(event.message);
254
+ if (!owner) {
255
+ return;
256
+ }
257
+ const normalized = normalizeAssistantEvent(event.assistantMessageEvent);
258
+ const toolCallId = toolCallIdFromAssistantEvent(normalized);
259
+ if (toolCallId) {
260
+ this.toolOwners.set(toolCallId, owner);
261
+ const ids = this.turnToolCallIds.get(owner.turnId) ?? [];
262
+ if (!ids.includes(toolCallId)) {
263
+ ids.push(toolCallId);
264
+ this.turnToolCallIds.set(owner.turnId, ids);
265
+ }
266
+ }
267
+ this.enqueue(owner, "assistant_event", normalized as unknown as Record<string, unknown>);
268
+ }
269
+
270
+ handleMessageEnd(event: MessageEndEventLike): void {
271
+ const owner = this.ownerForMessage(event.message);
272
+ if (!owner) {
273
+ return;
274
+ }
275
+ // Pi appends the final session entry after message_end handlers return.
276
+ // Keep this record and every later event queued until a documented hook
277
+ // with synchronized session state can attach the exact entry id.
278
+ this.clearFlushTimer();
279
+ const queued = this.enqueue(
280
+ owner,
281
+ "message_finished",
282
+ { role: owner.role, settled: false },
283
+ undefined,
284
+ {},
285
+ true,
286
+ );
287
+ if (queued) {
288
+ this.pendingMessageEnds.push({ queued, role: owner.role, message: event.message });
289
+ this.clearFlushTimer();
290
+ }
291
+ if (this.currentMessage?.messageId === owner.messageId) {
292
+ this.currentMessage = null;
293
+ }
294
+ }
295
+
296
+ handleToolStart(event: ToolExecutionStartEventLike): void {
297
+ const owner = this.toolOwners.get(event.toolCallId) ?? this.currentMessage;
298
+ if (!owner) {
299
+ return;
300
+ }
301
+ this.toolOwners.set(event.toolCallId, owner);
302
+ this.enqueue(owner, "tool_execution_started", toolStartedPayload(event), undefined, {
303
+ toolCallId: event.toolCallId,
304
+ });
305
+ }
306
+
307
+ handleToolUpdate(event: ToolExecutionUpdateEventLike): void {
308
+ const owner = this.toolOwners.get(event.toolCallId);
309
+ if (!owner) {
310
+ return;
311
+ }
312
+ this.enqueue(owner, "tool_execution_updated", {}, undefined, {
313
+ toolCallId: event.toolCallId,
314
+ });
315
+ }
316
+
317
+ handleToolEnd(event: ToolExecutionEndEventLike): void {
318
+ const owner = this.toolOwners.get(event.toolCallId);
319
+ if (!owner) {
320
+ return;
321
+ }
322
+ this.enqueue(owner, "tool_execution_finished", toolFinishedPayload(event), undefined, {
323
+ toolCallId: event.toolCallId,
324
+ });
325
+ }
326
+
327
+ /** Flush new entries on the current branch into the bundle. */
328
+ record(ctx: ExtensionContext): Promise<RecordedEntry[]> {
329
+ if (!this.bound || !this.acceptingEntries) {
330
+ return Promise.resolve([]);
331
+ }
332
+ const task = this.entryChain.then(async () => {
333
+ const branch = ctx.sessionManager.getBranch() as unknown as Array<
334
+ Record<string, unknown> & { id: string }
335
+ >;
336
+ let startIndex = 0;
337
+ if (this.cursor !== null) {
338
+ const cursorIndex = branch.findIndex((entry) => entry.id === this.cursor);
339
+ if (cursorIndex === -1) {
340
+ this.cursor = branch.at(-1)?.id ?? this.cursor;
341
+ return [];
342
+ }
343
+ startIndex = cursorIndex + 1;
344
+ }
345
+ const appended: RecordedEntry[] = [];
346
+ for (const entry of branch.slice(startIndex)) {
347
+ await this.store.appendSessionEntry(this.runDir, entry, this.segmentId);
348
+ const recorded = { id: entry.id, entry, claimed: false };
349
+ appended.push(recorded);
350
+ this.unclaimedEntries.push(recorded);
351
+ this.recorded.push(entry.id);
352
+ this.cursor = entry.id;
353
+ }
354
+ return appended;
355
+ });
356
+ this.entryChain = task.catch((error: unknown) => {
357
+ this.failCapture("entry_write_failed", failureMessage(error));
358
+ });
359
+ return task;
360
+ }
361
+
362
+ /** Record durable entries, then release deferred message-end events in order. */
363
+ async synchronize(ctx: ExtensionContext): Promise<void> {
364
+ if (this.pendingMessageEnds.length === 0) {
365
+ return;
366
+ }
367
+ await this.record(ctx);
368
+ for (const pending of this.pendingMessageEnds.splice(0)) {
369
+ const entryId = this.claimEntry(pending.role, pending.message);
370
+ pending.queued.record.payload = entryId
371
+ ? { role: pending.role, settled: true, entryId }
372
+ : { role: pending.role, settled: false };
373
+ const bytes = Buffer.byteLength(JSON.stringify(pending.queued.record), "utf8") + 1;
374
+ this.outstandingBytes += bytes - pending.queued.bytes;
375
+ pending.queued.bytes = bytes;
376
+ }
377
+ if (this.eventQueue.length >= FLUSH_MAX_RECORDS || this.queuedBytes() >= FLUSH_MAX_BYTES) {
378
+ this.startFlush();
379
+ } else {
380
+ this.scheduleFlush();
381
+ }
382
+ }
383
+
384
+ /**
385
+ * Finalize immediately when no Pi turn is active. Otherwise wait for
386
+ * `turn_end`, which is the last documented lifecycle boundary needed to
387
+ * include the workflow tool result and any following assistant output.
388
+ */
389
+ async finish(): Promise<void> {
390
+ if (this.stopPromise || this.currentTurn === null) {
391
+ return await this.stop();
392
+ }
393
+ if (!this.finishPromise) {
394
+ this.finishPromise = new Promise<void>((resolve) => {
395
+ this.resolveFinish = resolve;
396
+ });
397
+ this.finishTimer = setTimeout(() => {
398
+ this.failCapture(
399
+ "turn_settle_timeout",
400
+ `active Pi turn did not finish within ${FINISH_TURN_TIMEOUT_MS}ms`,
401
+ );
402
+ void this.stop();
403
+ }, FINISH_TURN_TIMEOUT_MS);
404
+ this.finishTimer.unref?.();
405
+ }
406
+ return await this.finishPromise;
407
+ }
408
+
409
+ async stop(): Promise<void> {
410
+ if (this.stopPromise) {
411
+ return await this.stopPromise;
412
+ }
413
+ if (this.currentTurn !== null) {
414
+ this.failCapture(
415
+ "turn_interrupted",
416
+ "session capture stopped before the active Pi turn finished",
417
+ );
418
+ }
419
+ this.acceptingEntries = false;
420
+ this.acceptingEvents = false;
421
+ // If Pi closes before another synchronized hook, preserve the message-end
422
+ // receipts as explicitly unsettled rather than hanging finalization.
423
+ this.pendingMessageEnds.splice(0);
424
+ this.clearFlushTimer();
425
+ this.stopPromise = (async () => {
426
+ try {
427
+ await this.flushAllEvents();
428
+ await this.entryChain;
429
+ const counts = await this.store.sessionCounts(this.runDir, this.segmentId);
430
+ await this.store.writeSessionCapture(
431
+ this.runDir,
432
+ {
433
+ schema: SESSION_CAPTURE_SCHEMA,
434
+ eventSchema: SESSION_EVENT_SCHEMA,
435
+ status: this.captureFailure ? "failed" : "complete",
436
+ ...counts,
437
+ ...(this.captureFailure ? { failure: this.captureFailure } : {}),
438
+ },
439
+ this.segmentId,
440
+ );
441
+ } catch (error) {
442
+ // Capture is observational. A finalization failure must never reject
443
+ // the workflow's terminal persistence hook.
444
+ this.failCapture("capture_finalize_failed", failureMessage(error));
445
+ try {
446
+ const counts = await this.store.sessionCounts(this.runDir, this.segmentId);
447
+ await this.store.writeSessionCapture(
448
+ this.runDir,
449
+ {
450
+ schema: SESSION_CAPTURE_SCHEMA,
451
+ eventSchema: SESSION_EVENT_SCHEMA,
452
+ status: "failed",
453
+ ...counts,
454
+ failure: this.captureFailure ?? {
455
+ failedAt: new Date().toISOString(),
456
+ code: "capture_finalize_failed",
457
+ message: failureMessage(error),
458
+ },
459
+ },
460
+ this.segmentId,
461
+ );
462
+ } catch {
463
+ // The viewer will report the missing/invalid capture file.
464
+ }
465
+ } finally {
466
+ if (this.finishTimer) {
467
+ clearTimeout(this.finishTimer);
468
+ this.finishTimer = null;
469
+ }
470
+ this.resolveFinish?.();
471
+ this.resolveFinish = null;
472
+ }
473
+ })();
474
+ return await this.stopPromise;
475
+ }
476
+
477
+ mark(): number {
478
+ return this.recorded.length;
479
+ }
480
+
481
+ rangeSince(mark: number): ConversationRange | undefined {
482
+ if (this.recorded.length <= mark) {
483
+ return undefined;
484
+ }
485
+ return {
486
+ firstEntryId: this.recorded[mark] as string,
487
+ lastEntryId: this.recorded.at(-1) as string,
488
+ };
489
+ }
490
+
491
+ private ownerForMessage(message: unknown): MessageOwner | null {
492
+ const key = objectKey(message);
493
+ const stableKey = stableMessageKey(message);
494
+ return (
495
+ (key ? this.messageOwners.get(key) : undefined) ??
496
+ (stableKey ? this.stableMessageOwners.get(stableKey) : undefined) ??
497
+ this.currentMessage
498
+ );
499
+ }
500
+
501
+ private claimEntry(role: string, message: unknown): string | undefined {
502
+ const messageKey = stableMessageKey(message);
503
+ const entry = this.unclaimedEntries.find((candidate) => {
504
+ if (candidate.claimed || entryRole(candidate.entry) !== role) {
505
+ return false;
506
+ }
507
+ return messageKey === null || stableMessageKey(candidate.entry.message) === messageKey;
508
+ });
509
+ if (!entry) {
510
+ return undefined;
511
+ }
512
+ entry.claimed = true;
513
+ return entry.id;
514
+ }
515
+
516
+ private enqueue(
517
+ owner: TurnOwner | MessageOwner,
518
+ type: WorkflowSessionEventType,
519
+ payload: Record<string, unknown>,
520
+ at: string = new Date().toISOString(),
521
+ extra: { toolCallId?: string } = {},
522
+ deferFlush = false,
523
+ ): QueuedEvent | null {
524
+ if (!this.bound || !this.acceptingEvents || this.captureFailure) {
525
+ return null;
526
+ }
527
+ const messageId = "messageId" in owner ? owner.messageId : undefined;
528
+ const record: WorkflowSessionEventRecord = {
529
+ seq: this.nextEventSeq,
530
+ at,
531
+ nodeId: owner.nodeId,
532
+ attemptId: owner.attemptId,
533
+ turnId: owner.turnId,
534
+ ...(messageId === undefined ? {} : { messageId }),
535
+ ...(extra.toolCallId === undefined ? {} : { toolCallId: extra.toolCallId }),
536
+ type,
537
+ payload,
538
+ };
539
+ const bytes = Buffer.byteLength(JSON.stringify(record), "utf8") + 1;
540
+ const externalizable =
541
+ type === "tool_execution_started" ||
542
+ type === "tool_execution_finished" ||
543
+ (type === "assistant_event" && payload.type === "toolcall_end");
544
+ if (bytes > SESSION_EVENT_MAX_BYTES && !externalizable) {
545
+ this.failCapture(
546
+ "event_too_large",
547
+ `session event exceeded ${SESSION_EVENT_MAX_BYTES} bytes`,
548
+ );
549
+ return null;
550
+ }
551
+ if (
552
+ this.outstandingRecords + 1 > QUEUE_MAX_RECORDS ||
553
+ this.outstandingBytes + bytes > QUEUE_MAX_BYTES
554
+ ) {
555
+ this.failCapture("event_queue_overflow", "session event queue limit exceeded");
556
+ return null;
557
+ }
558
+ this.nextEventSeq += 1;
559
+ const queued = { record, bytes };
560
+ this.eventQueue.push(queued);
561
+ this.outstandingRecords += 1;
562
+ this.outstandingBytes += bytes;
563
+ if (!deferFlush) {
564
+ if (this.eventQueue.length >= FLUSH_MAX_RECORDS || this.queuedBytes() >= FLUSH_MAX_BYTES) {
565
+ this.startFlush();
566
+ } else {
567
+ this.scheduleFlush();
568
+ }
569
+ }
570
+ return queued;
571
+ }
572
+
573
+ private queuedBytes(): number {
574
+ return this.eventQueue.reduce((sum, queued) => sum + queued.bytes, 0);
575
+ }
576
+
577
+ private scheduleFlush(): void {
578
+ if (this.pendingMessageEnds.length > 0 || this.flushTimer || this.flushPromise) {
579
+ return;
580
+ }
581
+ this.flushTimer = setTimeout(() => {
582
+ this.flushTimer = null;
583
+ this.startFlush();
584
+ }, FLUSH_INTERVAL_MS);
585
+ this.flushTimer.unref?.();
586
+ }
587
+
588
+ private startFlush(): void {
589
+ if (this.pendingMessageEnds.length > 0 || this.flushPromise || this.eventQueue.length === 0) {
590
+ return;
591
+ }
592
+ this.clearFlushTimer();
593
+ const batch: QueuedEvent[] = [];
594
+ let bytes = 0;
595
+ while (this.eventQueue.length > 0 && batch.length < FLUSH_MAX_RECORDS) {
596
+ const next = this.eventQueue[0] as QueuedEvent;
597
+ if (batch.length > 0 && bytes + next.bytes > FLUSH_MAX_BYTES) {
598
+ break;
599
+ }
600
+ batch.push(this.eventQueue.shift() as QueuedEvent);
601
+ bytes += next.bytes;
602
+ }
603
+ this.flushPromise = this.store
604
+ .appendSessionEventBatch(
605
+ this.runDir,
606
+ batch.map((queued) => queued.record),
607
+ this.segmentId,
608
+ )
609
+ .catch((error: unknown) => {
610
+ const message = failureMessage(error);
611
+ this.failCapture(
612
+ message.includes(`exceeded ${SESSION_EVENT_MAX_BYTES} bytes`)
613
+ ? "event_too_large"
614
+ : "event_write_failed",
615
+ message,
616
+ );
617
+ for (const queued of this.eventQueue.splice(0)) {
618
+ this.outstandingRecords -= 1;
619
+ this.outstandingBytes -= queued.bytes;
620
+ }
621
+ })
622
+ .finally(() => {
623
+ for (const queued of batch) {
624
+ this.outstandingRecords -= 1;
625
+ this.outstandingBytes -= queued.bytes;
626
+ }
627
+ this.flushPromise = null;
628
+ if (this.eventQueue.length > 0 && this.captureFailure?.code !== "event_write_failed") {
629
+ this.startFlush();
630
+ }
631
+ });
632
+ }
633
+
634
+ private async flushAllEvents(): Promise<void> {
635
+ this.clearFlushTimer();
636
+ while (this.eventQueue.length > 0 || this.flushPromise) {
637
+ if (!this.flushPromise) {
638
+ this.startFlush();
639
+ }
640
+ await this.flushPromise;
641
+ }
642
+ }
643
+
644
+ private clearFlushTimer(): void {
645
+ if (this.flushTimer) {
646
+ clearTimeout(this.flushTimer);
647
+ this.flushTimer = null;
648
+ }
649
+ }
650
+
651
+ private failCapture(code: string, message: string): void {
652
+ if (this.captureFailure) {
653
+ return;
654
+ }
655
+ this.captureFailure = {
656
+ failedAt: new Date().toISOString(),
657
+ code,
658
+ message,
659
+ };
660
+ this.acceptingEvents = false;
661
+ }
662
+ }