@osolmaz/pi-workflows 0.16.0 → 0.16.2

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 (175) hide show
  1. package/README.md +18 -2
  2. package/dist/channels/adapter-entry.d.ts +3 -0
  3. package/dist/channels/adapter-entry.js +203 -0
  4. package/dist/channels/adapter-entry.js.map +1 -0
  5. package/dist/channels/config.d.ts +43 -0
  6. package/dist/channels/config.js +234 -0
  7. package/dist/channels/config.js.map +1 -0
  8. package/dist/channels/protocol.d.ts +94 -0
  9. package/dist/channels/protocol.js +219 -0
  10. package/dist/channels/protocol.js.map +1 -0
  11. package/dist/channels/telegram.d.ts +46 -0
  12. package/dist/channels/telegram.js +285 -0
  13. package/dist/channels/telegram.js.map +1 -0
  14. package/dist/client/client.d.ts +1 -0
  15. package/dist/client/client.js +5 -1
  16. package/dist/client/client.js.map +1 -1
  17. package/dist/client/index.d.ts +1 -1
  18. package/dist/client/protocol.d.ts +1 -1
  19. package/dist/client/protocol.js +11 -5
  20. package/dist/client/protocol.js.map +1 -1
  21. package/dist/client/view.d.ts +48 -16
  22. package/dist/client/view.js +1 -0
  23. package/dist/client/view.js.map +1 -1
  24. package/dist/controllers/index.d.ts +1 -1
  25. package/dist/controllers/index.js.map +1 -1
  26. package/dist/controllers/sqlite.d.ts +20 -165
  27. package/dist/controllers/sqlite.js +178 -436
  28. package/dist/controllers/sqlite.js.map +1 -1
  29. package/dist/extension/index.d.ts +23 -2
  30. package/dist/extension/index.js +637 -534
  31. package/dist/extension/index.js.map +1 -1
  32. package/dist/extension/recorder.d.ts +95 -0
  33. package/dist/extension/recorder.js +530 -0
  34. package/dist/extension/recorder.js.map +1 -0
  35. package/dist/extension/remote-recorder-store.d.ts +25 -0
  36. package/dist/extension/remote-recorder-store.js +81 -0
  37. package/dist/extension/remote-recorder-store.js.map +1 -0
  38. package/dist/extension/session-events.d.ts +134 -0
  39. package/dist/extension/session-events.js +60 -0
  40. package/dist/extension/session-events.js.map +1 -0
  41. package/dist/extension/session-view.d.ts +2 -0
  42. package/dist/extension/session-view.js +60 -2
  43. package/dist/extension/session-view.js.map +1 -1
  44. package/dist/extension/step-message.d.ts +6 -4
  45. package/dist/extension/step-message.js +12 -6
  46. package/dist/extension/step-message.js.map +1 -1
  47. package/dist/extension/widget.d.ts +2 -2
  48. package/dist/extension/widget.js +28 -18
  49. package/dist/extension/widget.js.map +1 -1
  50. package/dist/extension/workflow-message-coordinator.d.ts +28 -0
  51. package/dist/extension/workflow-message-coordinator.js +346 -0
  52. package/dist/extension/workflow-message-coordinator.js.map +1 -0
  53. package/dist/host/channel-effects.d.ts +72 -0
  54. package/dist/host/channel-effects.js +271 -0
  55. package/dist/host/channel-effects.js.map +1 -0
  56. package/dist/host/channel-supervisor.d.ts +21 -0
  57. package/dist/host/channel-supervisor.js +54 -0
  58. package/dist/host/channel-supervisor.js.map +1 -0
  59. package/dist/host/runner.d.ts +50 -8
  60. package/dist/host/runner.js +1632 -341
  61. package/dist/host/runner.js.map +1 -1
  62. package/dist/host/state.d.ts +19 -16
  63. package/dist/host/state.js +251 -63
  64. package/dist/host/state.js.map +1 -1
  65. package/dist/host/view.d.ts +19 -9
  66. package/dist/host/view.js +227 -119
  67. package/dist/host/view.js.map +1 -1
  68. package/dist/host/worker-entry.d.ts +4 -1
  69. package/dist/host/worker-entry.js +23 -24
  70. package/dist/host/worker-entry.js.map +1 -1
  71. package/dist/host/worker-protocol.d.ts +15 -0
  72. package/dist/host/worker-protocol.js.map +1 -1
  73. package/dist/state/prune.js +3 -25
  74. package/dist/state/prune.js.map +1 -1
  75. package/dist/state/schema.js +76 -97
  76. package/dist/state/schema.js.map +1 -1
  77. package/dist/state/workflow-messages.d.ts +98 -0
  78. package/dist/state/workflow-messages.js +366 -0
  79. package/dist/state/workflow-messages.js.map +1 -0
  80. package/dist/viewer/render.js +1 -1
  81. package/dist/viewer/render.js.map +1 -1
  82. package/dist/viewer/tui.d.ts +1 -1
  83. package/dist/viewer/tui.js +65 -8
  84. package/dist/viewer/tui.js.map +1 -1
  85. package/dist/workflows/composition.js +0 -4
  86. package/dist/workflows/composition.js.map +1 -1
  87. package/dist/workflows/definition.js +0 -8
  88. package/dist/workflows/definition.js.map +1 -1
  89. package/dist/workflows/engine.js +4 -5
  90. package/dist/workflows/engine.js.map +1 -1
  91. package/dist/workflows/human-decision.d.ts +2 -0
  92. package/dist/workflows/human-decision.js +8 -2
  93. package/dist/workflows/human-decision.js.map +1 -1
  94. package/dist/workflows/index.d.ts +1 -1
  95. package/dist/workflows/index.js.map +1 -1
  96. package/dist/workflows/schema.js +0 -4
  97. package/dist/workflows/schema.js.map +1 -1
  98. package/dist/workflows/settings.d.ts +1 -8
  99. package/dist/workflows/settings.js.map +1 -1
  100. package/dist/workflows/store.d.ts +20 -19
  101. package/dist/workflows/store.js +163 -354
  102. package/dist/workflows/store.js.map +1 -1
  103. package/dist/workflows/tool-input.d.ts +21 -0
  104. package/dist/workflows/tool-input.js +23 -1
  105. package/dist/workflows/tool-input.js.map +1 -1
  106. package/dist/workflows/types.d.ts +6 -8
  107. package/dist/workflows/workflow-message-content.d.ts +38 -0
  108. package/dist/workflows/workflow-message-content.js +157 -0
  109. package/dist/workflows/workflow-message-content.js.map +1 -0
  110. package/docs/2026-08-18-herdr-piw-plan.md +2 -1
  111. package/docs/2026-08-20-durable-workflow-launch-plan.md +1 -1
  112. package/docs/2026-08-25-workflow-follow-ups.md +26 -50
  113. package/docs/2026-08-30-out-of-process-workflow-host-plan.md +3 -3
  114. package/docs/2026-09-01-restore-session-delivery-controls-plan.md +4 -0
  115. package/docs/2026-09-01-unified-workflow-client-plan.md +5 -1
  116. package/docs/2026-09-02-installed-live-e2e-plan.md +15 -1
  117. package/docs/2026-09-02-unify-workflow-messages-plan.md +562 -0
  118. package/docs/2026-09-04-workflow-run-state-plan.md +363 -0
  119. package/docs/DEFERRED_TURNS.md +66 -294
  120. package/docs/HUMAN_DECISIONS.md +29 -30
  121. package/docs/HUMAN_DECISION_PRESENTATIONS.md +4 -11
  122. package/docs/SQLITE_STATE.md +25 -17
  123. package/docs/WORKFLOW_HOST.md +105 -31
  124. package/docs/WORKFLOW_STEP_MESSAGES.md +166 -75
  125. package/docs/live-replay-protocol.md +8 -4
  126. package/docs/tui-viewer.md +3 -1
  127. package/docs/workflows.md +44 -28
  128. package/herdr-plugin.toml +1 -1
  129. package/package.json +1 -1
  130. package/plugins/herdr/viewer.mjs +14 -5
  131. package/src/channels/adapter-entry.ts +220 -0
  132. package/src/channels/config.ts +296 -0
  133. package/src/channels/protocol.ts +333 -0
  134. package/src/channels/telegram.ts +335 -0
  135. package/src/client/client.ts +6 -2
  136. package/src/client/index.ts +2 -1
  137. package/src/client/protocol.ts +11 -5
  138. package/src/client/view.ts +51 -18
  139. package/src/controllers/index.ts +0 -1
  140. package/src/controllers/sqlite.ts +247 -845
  141. package/src/extension/index.ts +712 -647
  142. package/src/extension/recorder.ts +687 -0
  143. package/src/extension/remote-recorder-store.ts +126 -0
  144. package/src/extension/session-events.ts +119 -0
  145. package/src/extension/session-view.ts +86 -3
  146. package/src/extension/step-message.ts +19 -9
  147. package/src/extension/widget.ts +37 -17
  148. package/src/extension/workflow-message-coordinator.ts +398 -0
  149. package/src/host/channel-effects.ts +465 -0
  150. package/src/host/channel-supervisor.ts +73 -0
  151. package/src/host/runner.ts +2163 -525
  152. package/src/host/state.ts +333 -94
  153. package/src/host/view.ts +254 -122
  154. package/src/host/worker-entry.ts +38 -30
  155. package/src/host/worker-protocol.ts +11 -0
  156. package/src/state/prune.ts +3 -31
  157. package/src/state/schema.ts +76 -97
  158. package/src/state/workflow-messages.ts +599 -0
  159. package/src/viewer/render.ts +1 -5
  160. package/src/viewer/tui.ts +71 -6
  161. package/src/workflows/composition.ts +0 -5
  162. package/src/workflows/definition.ts +0 -8
  163. package/src/workflows/engine.ts +4 -6
  164. package/src/workflows/human-decision.ts +18 -2
  165. package/src/workflows/index.ts +0 -1
  166. package/src/workflows/schema.ts +0 -6
  167. package/src/workflows/settings.ts +1 -20
  168. package/src/workflows/store.ts +237 -489
  169. package/src/workflows/tool-input.ts +36 -1
  170. package/src/workflows/types.ts +6 -8
  171. package/src/workflows/workflow-message-content.ts +197 -0
  172. package/dist/extension/decision-channels.d.ts +0 -134
  173. package/dist/extension/decision-channels.js +0 -1307
  174. package/dist/extension/decision-channels.js.map +0 -1
  175. package/src/extension/decision-channels.ts +0 -1826
@@ -0,0 +1,398 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import type { WorkflowClient } from "../client/client.js";
4
+ import {
5
+ WORKFLOW_TURN_REPORT_RECEIPT_SCHEMA,
6
+ type WorkflowSessionView,
7
+ type WorkflowTurnReport,
8
+ type WorkflowTurnReportReceipt,
9
+ } from "../client/view.js";
10
+ import type { JsonValue } from "../state/json.js";
11
+ import {
12
+ WORKFLOW_TURN_SCHEMA,
13
+ type WorkflowMessage,
14
+ type WorkflowTurnStopReason,
15
+ } from "../state/workflow-messages.js";
16
+
17
+ const WORKFLOW_MESSAGE_ID_FIELD = "workflowMessageId";
18
+
19
+ type PendingTurn = {
20
+ workflowTurnId: string;
21
+ workflowMessageId: string | null;
22
+ runId: string | null;
23
+ message: WorkflowMessage | null;
24
+ startedReported: boolean;
25
+ end: { stopReason: WorkflowTurnStopReason; responseSessionEntryId: string | null } | null;
26
+ };
27
+
28
+ /** Adds every host-owned workflow message to one Pi session through one public API path. */
29
+ export class WorkflowMessageCoordinator {
30
+ private readonly queued = new Set<string>();
31
+ private readonly closedTurnMessages = new Set<string>();
32
+ private synchronizing = false;
33
+ private lastBranchEpoch: string | null = null;
34
+ private view: WorkflowSessionView | null = null;
35
+ private awaitingTurnMessage: WorkflowMessage | null = null;
36
+ private turn: PendingTurn | null = null;
37
+
38
+ updateView(view: WorkflowSessionView): void {
39
+ this.view = view;
40
+ const visibleIds = new Set(view.workflowMessages.map((message) => message.workflowMessageId));
41
+ for (const messageId of this.closedTurnMessages) {
42
+ if (!visibleIds.has(messageId)) this.closedTurnMessages.delete(messageId);
43
+ }
44
+ for (const message of view.workflowMessages) {
45
+ if (message.status === "sent" || message.status === "cancelled") {
46
+ this.queued.delete(message.workflowMessageId);
47
+ }
48
+ }
49
+ if (
50
+ this.turn !== null &&
51
+ this.turn.workflowMessageId === null &&
52
+ view.openWorkflowMessageId !== null
53
+ ) {
54
+ const message = messageById(view, view.openWorkflowMessageId);
55
+ if (message !== undefined && messageStartsTurn(message)) this.bindTurn(message);
56
+ }
57
+ }
58
+
59
+ branchChanged(): void {
60
+ this.lastBranchEpoch = null;
61
+ }
62
+
63
+ startTurn(): void {
64
+ const awaited = this.awaitingTurnMessage;
65
+ this.awaitingTurnMessage = null;
66
+ this.turn = {
67
+ workflowTurnId: `workflow-turn-${randomUUID()}`,
68
+ workflowMessageId: null,
69
+ runId: null,
70
+ message: null,
71
+ startedReported: false,
72
+ end: null,
73
+ };
74
+ const candidate = awaited ?? this.turnCandidate();
75
+ if (candidate !== undefined) this.bindTurn(candidate);
76
+ }
77
+
78
+ endTurn(stopReason: WorkflowTurnStopReason, responseSessionEntryId: string | null): void {
79
+ if (this.turn === null) this.startTurn();
80
+ if (this.turn !== null) this.turn.end = { stopReason, responseSessionEntryId };
81
+ }
82
+
83
+ activeTurnMessage(): WorkflowMessage | undefined {
84
+ if (this.turn === null || !this.turn.startedReported) return undefined;
85
+ return this.turn.message ?? undefined;
86
+ }
87
+
88
+ async synchronize(
89
+ pi: ExtensionAPI,
90
+ client: WorkflowClient,
91
+ ctx: Pick<ExtensionContext, "hasPendingMessages" | "isIdle" | "sessionManager">,
92
+ ): Promise<void> {
93
+ if (this.synchronizing || this.view === null) return;
94
+ this.synchronizing = true;
95
+ try {
96
+ const view = this.view;
97
+ if (!view.coordinatorActive || view.coordinatorEpoch === null) return;
98
+ if (this.turn === null && !ctx.isIdle() && view.openWorkflowTurn !== null) {
99
+ this.turn = {
100
+ workflowTurnId: view.openWorkflowTurn.workflowTurnId,
101
+ workflowMessageId: view.openWorkflowTurn.workflowMessageId,
102
+ runId: view.openWorkflowTurn.runId,
103
+ message: messageById(view, view.openWorkflowTurn.workflowMessageId) ?? null,
104
+ startedReported: true,
105
+ end: null,
106
+ };
107
+ }
108
+ if (
109
+ view.branchReportRequired ||
110
+ this.lastBranchEpoch !== view.coordinatorEpoch ||
111
+ this.hasUnconfirmedBranchEntry(ctx, view)
112
+ ) {
113
+ await this.reportBranch(client, ctx, view);
114
+ }
115
+ await this.flushTurn(client, view);
116
+ const messageId = view.nextWorkflowMessageId;
117
+ if (messageId === null || this.queued.has(messageId)) return;
118
+ const message = messageById(view, messageId);
119
+ if (message === undefined || message.status !== "pending") return;
120
+ if (!ctx.isIdle() || ctx.hasPendingMessages()) return;
121
+
122
+ this.queued.add(messageId);
123
+ const existingEntry = branchWorkflowEntries(ctx.sessionManager.getBranch()).get(messageId);
124
+ if (existingEntry !== undefined) {
125
+ await this.reportBranch(client, ctx, view);
126
+ return;
127
+ }
128
+
129
+ // There must be no asynchronous boundary between these final checks and
130
+ // the documented Pi sendMessage call.
131
+ if (
132
+ this.view !== view ||
133
+ !view.coordinatorActive ||
134
+ view.coordinatorEpoch === null ||
135
+ view.nextWorkflowMessageId !== messageId ||
136
+ !ctx.isIdle() ||
137
+ ctx.hasPendingMessages() ||
138
+ branchWorkflowEntries(ctx.sessionManager.getBranch()).has(messageId)
139
+ ) {
140
+ this.queued.delete(messageId);
141
+ return;
142
+ }
143
+ if (message.content.triggerTurn && messageStartsTurn(message)) {
144
+ this.awaitingTurnMessage = message;
145
+ }
146
+ try {
147
+ pi.sendMessage(
148
+ {
149
+ customType: message.content.customType,
150
+ content: message.content.content,
151
+ display: message.content.display,
152
+ details: message.content.details,
153
+ },
154
+ { triggerTurn: message.content.triggerTurn },
155
+ );
156
+ } catch (error) {
157
+ this.queued.delete(messageId);
158
+ if (this.awaitingTurnMessage?.workflowMessageId === messageId) {
159
+ this.awaitingTurnMessage = null;
160
+ }
161
+ throw error;
162
+ }
163
+ await this.reportBranch(client, ctx, view);
164
+ await this.flushTurn(client, view);
165
+ } finally {
166
+ this.synchronizing = false;
167
+ }
168
+ }
169
+
170
+ clear(): void {
171
+ this.queued.clear();
172
+ this.closedTurnMessages.clear();
173
+ this.view = null;
174
+ this.awaitingTurnMessage = null;
175
+ this.turn = null;
176
+ this.lastBranchEpoch = null;
177
+ this.synchronizing = false;
178
+ }
179
+
180
+ private turnCandidate(): WorkflowMessage | undefined {
181
+ const view = this.view;
182
+ if (view === null) return undefined;
183
+ if (view.openWorkflowMessageId !== null) {
184
+ const open = messageById(view, view.openWorkflowMessageId);
185
+ if (
186
+ open !== undefined &&
187
+ !this.closedTurnMessages.has(open.workflowMessageId) &&
188
+ messageStartsTurn(open)
189
+ ) {
190
+ return open;
191
+ }
192
+ }
193
+ for (const messageId of this.queued) {
194
+ const queued = messageById(view, messageId);
195
+ if (queued?.status === "pending" && messageStartsTurn(queued)) return queued;
196
+ }
197
+ return undefined;
198
+ }
199
+
200
+ private bindTurn(message: WorkflowMessage): void {
201
+ if (this.turn === null) return;
202
+ this.turn.workflowMessageId = message.workflowMessageId;
203
+ this.turn.runId = message.runId;
204
+ this.turn.message = message;
205
+ }
206
+
207
+ private async flushTurn(client: WorkflowClient, view: WorkflowSessionView): Promise<void> {
208
+ const pending = this.turn;
209
+ if (
210
+ pending === null ||
211
+ pending.workflowMessageId === null ||
212
+ pending.runId === null ||
213
+ view.coordinatorEpoch === null ||
214
+ this.lastBranchEpoch !== view.coordinatorEpoch
215
+ ) {
216
+ return;
217
+ }
218
+ let message = pending.message;
219
+ if (!pending.startedReported) {
220
+ message = messageById(view, pending.workflowMessageId) ?? null;
221
+ if (message?.status !== "sent") return;
222
+ try {
223
+ const receipt = await reportTurn(client, {
224
+ state: "started",
225
+ workflowMessageId: pending.workflowMessageId,
226
+ workflowTurnId: pending.workflowTurnId,
227
+ runId: pending.runId,
228
+ targetSessionId: view.sessionId,
229
+ coordinatorEpoch: view.coordinatorEpoch,
230
+ });
231
+ if (receipt.ownership !== "active") {
232
+ this.turn = null;
233
+ return;
234
+ }
235
+ pending.message = message;
236
+ pending.startedReported = true;
237
+ } catch (error) {
238
+ this.turn = null;
239
+ throw error;
240
+ }
241
+ }
242
+ if (pending.end === null) return;
243
+ try {
244
+ await reportTurn(client, {
245
+ state: "ended",
246
+ workflowMessageId: pending.workflowMessageId,
247
+ workflowTurnId: pending.workflowTurnId,
248
+ runId: pending.runId,
249
+ targetSessionId: view.sessionId,
250
+ coordinatorEpoch: view.coordinatorEpoch,
251
+ stopReason: pending.end.stopReason,
252
+ responseSessionEntryId: pending.end.responseSessionEntryId,
253
+ });
254
+ } catch (error) {
255
+ this.turn = null;
256
+ throw error;
257
+ }
258
+ if (message?.kind === "terminal" || message?.kind === "followUp") {
259
+ this.closedTurnMessages.add(pending.workflowMessageId);
260
+ }
261
+ for (const current of new Set([view, this.view])) {
262
+ if (current?.openWorkflowTurn?.workflowTurnId === pending.workflowTurnId) {
263
+ current.openWorkflowTurn = null;
264
+ }
265
+ if (
266
+ current?.openWorkflowMessageId === pending.workflowMessageId &&
267
+ (message?.kind === "terminal" || message?.kind === "followUp")
268
+ ) {
269
+ current.openWorkflowMessageId = null;
270
+ }
271
+ }
272
+ this.turn = null;
273
+ }
274
+
275
+ private async reportBranch(
276
+ client: WorkflowClient,
277
+ ctx: Pick<ExtensionContext, "hasPendingMessages" | "isIdle" | "sessionManager">,
278
+ view: WorkflowSessionView,
279
+ ): Promise<void> {
280
+ if (view.coordinatorEpoch === null) return;
281
+ const allowed = new Set(view.workflowMessages.map((message) => message.workflowMessageId));
282
+ const branch = branchWorkflowEntries(ctx.sessionManager.getBranch());
283
+ const entries = [...branch]
284
+ .filter(([workflowMessageId]) => allowed.has(workflowMessageId))
285
+ .map(([workflowMessageId, piSessionEntryId]) => ({ workflowMessageId, piSessionEntryId }));
286
+ const isIdle = ctx.isIdle();
287
+ const hasPendingMessages = ctx.hasPendingMessages();
288
+ const response = await client.request({
289
+ operation: "workflowMessage.reportBranch",
290
+ payload: {
291
+ targetSessionId: view.sessionId,
292
+ coordinatorEpoch: view.coordinatorEpoch,
293
+ entries,
294
+ isIdle,
295
+ hasPendingMessages,
296
+ },
297
+ });
298
+ if (response.outcome !== "accepted" && response.outcome !== "adopted") {
299
+ throw new Error(response.error ?? "Workflow host rejected the Pi branch report");
300
+ }
301
+ for (const entry of entries) {
302
+ const message = messageById(view, entry.workflowMessageId);
303
+ if (message !== undefined) {
304
+ message.status = "sent";
305
+ message.piSessionEntryId = entry.piSessionEntryId;
306
+ }
307
+ this.queued.delete(entry.workflowMessageId);
308
+ }
309
+ this.lastBranchEpoch = view.coordinatorEpoch;
310
+ }
311
+
312
+ private hasUnconfirmedBranchEntry(
313
+ ctx: Pick<ExtensionContext, "sessionManager">,
314
+ view: WorkflowSessionView,
315
+ ): boolean {
316
+ const branch = branchWorkflowEntries(ctx.sessionManager.getBranch());
317
+ return view.workflowMessages.some(
318
+ (message) => message.status !== "sent" && branch.has(message.workflowMessageId),
319
+ );
320
+ }
321
+ }
322
+
323
+ export function branchWorkflowEntries(entries: readonly unknown[]): Map<string, string> {
324
+ const found = new Map<string, string>();
325
+ for (const value of entries) {
326
+ if (!isRecord(value) || typeof value.id !== "string") continue;
327
+ if (value.type !== "custom_message" && value.role !== "custom") continue;
328
+ if (!isRecord(value.details)) continue;
329
+ const workflowMessageId = value.details[WORKFLOW_MESSAGE_ID_FIELD];
330
+ if (typeof workflowMessageId === "string") found.set(workflowMessageId, value.id);
331
+ }
332
+ return found;
333
+ }
334
+
335
+ export function responseEntryId(entries: readonly unknown[]): string | null {
336
+ for (const value of [...entries].reverse()) {
337
+ if (!isRecord(value) || typeof value.id !== "string") continue;
338
+ if (value.type === "message" && isRecord(value.message) && value.message.role === "assistant") {
339
+ return value.id;
340
+ }
341
+ if (value.role === "assistant") return value.id;
342
+ }
343
+ return null;
344
+ }
345
+
346
+ function messageById(
347
+ view: WorkflowSessionView,
348
+ workflowMessageId: string,
349
+ ): WorkflowMessage | undefined {
350
+ return view.workflowMessages.find((message) => message.workflowMessageId === workflowMessageId);
351
+ }
352
+
353
+ function messageStartsTurn(message: WorkflowMessage): boolean {
354
+ return message.kind === "step" || message.kind === "terminal" || message.kind === "followUp";
355
+ }
356
+
357
+ async function reportTurn(
358
+ client: WorkflowClient,
359
+ report: WorkflowTurnReport,
360
+ ): Promise<WorkflowTurnReportReceipt> {
361
+ const response = await client.request({
362
+ operation: "workflowTurn.report",
363
+ runId: report.runId,
364
+ payload: report as unknown as JsonValue,
365
+ });
366
+ if (response.outcome !== "accepted" && response.outcome !== "adopted") {
367
+ throw new Error(response.error ?? "Workflow host rejected the model-turn report");
368
+ }
369
+ const receipt = response.receipt;
370
+ if (
371
+ !isRecord(receipt) ||
372
+ receipt.schema !== WORKFLOW_TURN_REPORT_RECEIPT_SCHEMA ||
373
+ !["active", "settled", "absent"].includes(String(receipt.ownership))
374
+ ) {
375
+ throw new Error("Workflow host returned an invalid model-turn receipt");
376
+ }
377
+ const ownership = receipt.ownership as WorkflowTurnReportReceipt["ownership"];
378
+ if (ownership === "absent") {
379
+ if (receipt.turn !== null) {
380
+ throw new Error("Workflow host returned an invalid model-turn receipt");
381
+ }
382
+ } else if (
383
+ !isRecord(receipt.turn) ||
384
+ receipt.turn.schema !== WORKFLOW_TURN_SCHEMA ||
385
+ receipt.turn.workflowTurnId !== report.workflowTurnId ||
386
+ receipt.turn.workflowMessageId !== report.workflowMessageId ||
387
+ receipt.turn.runId !== report.runId ||
388
+ receipt.turn.targetSessionId !== report.targetSessionId ||
389
+ receipt.turn.state !== (ownership === "active" ? "started" : "ended")
390
+ ) {
391
+ throw new Error("Workflow host returned an invalid model-turn receipt");
392
+ }
393
+ return receipt as unknown as WorkflowTurnReportReceipt;
394
+ }
395
+
396
+ function isRecord(value: unknown): value is Record<string, unknown> {
397
+ return typeof value === "object" && value !== null && !Array.isArray(value);
398
+ }