@osolmaz/pi-workflows 0.14.0 → 0.15.1

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