@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,465 @@
1
+ import { createHash } from "node:crypto";
2
+ import { StateDatabase } from "../state/database.js";
3
+ import { canonicalJson, type JsonValue } from "../state/json.js";
4
+ import { StateMutationStore } from "../state/mutation.js";
5
+ import type { HumanDecisionChannelRequest } from "../workflows/types.js";
6
+
7
+ export type ChannelEffectPurpose = "delivery" | "settlement";
8
+ export type ChannelEffectStatus =
9
+ | "pending"
10
+ | "applying"
11
+ | "applied"
12
+ | "rejected"
13
+ | "ambiguous"
14
+ | "cancelled";
15
+
16
+ export type ChannelEffectPayload = {
17
+ schema: "pi-workflows.channel-effect.v1";
18
+ channelId: string;
19
+ profile: string;
20
+ decisionId: string;
21
+ purpose: ChannelEffectPurpose;
22
+ request: HumanDecisionChannelRequest;
23
+ };
24
+
25
+ export type ChannelEffectRecord = {
26
+ effectId: string;
27
+ resourceId: string;
28
+ sourceResourceId: string;
29
+ status: ChannelEffectStatus;
30
+ attemptNumber: number;
31
+ attemptStartedAt: number;
32
+ settledAt?: number;
33
+ payload: ChannelEffectPayload;
34
+ result?: JsonValue;
35
+ };
36
+
37
+ type EffectRow = {
38
+ effectId: string;
39
+ resourceId: string;
40
+ sourceResourceId: string;
41
+ status: ChannelEffectStatus;
42
+ attemptNumber: number;
43
+ attemptStartedAt: number;
44
+ settledAt: number | null;
45
+ payloadHash: Buffer;
46
+ resultHash: Buffer | null;
47
+ };
48
+
49
+ export class ChannelEffectStore {
50
+ private readonly mutations: StateMutationStore;
51
+
52
+ constructor(private readonly state: StateDatabase) {
53
+ this.mutations = new StateMutationStore(state);
54
+ }
55
+
56
+ ensureApplying(options: {
57
+ channelResourceId: string;
58
+ channelId: string;
59
+ profile: string;
60
+ decisionId: string;
61
+ purpose: ChannelEffectPurpose;
62
+ request: HumanDecisionChannelRequest;
63
+ ownerId: string;
64
+ leaseGeneration: number;
65
+ maxAutomaticAttempts: number;
66
+ }): ChannelEffectRecord {
67
+ const effectId = channelEffectId(
68
+ options.channelId,
69
+ options.decisionId,
70
+ options.request.requestDigest,
71
+ options.purpose,
72
+ );
73
+ const existing = this.read(effectId);
74
+ if (existing !== undefined) {
75
+ if (existing.status === "rejected" && existing.attemptNumber < options.maxAutomaticAttempts) {
76
+ return this.beginAttempt(
77
+ existing,
78
+ options.ownerId,
79
+ options.leaseGeneration,
80
+ "automatic",
81
+ options.ownerId,
82
+ );
83
+ }
84
+ return existing;
85
+ }
86
+ const payload: ChannelEffectPayload = {
87
+ schema: "pi-workflows.channel-effect.v1",
88
+ channelId: options.channelId,
89
+ profile: options.profile,
90
+ decisionId: options.decisionId,
91
+ purpose: options.purpose,
92
+ request: options.request,
93
+ };
94
+ const now = Date.now();
95
+ const payloadHash = this.state.putJson(payload, now);
96
+ const sourceRevision = this.resourceRevision(options.channelResourceId);
97
+ const resourceId = this.mutations.ensureResource("effect", effectId, now);
98
+ this.mutations.mutate(
99
+ {
100
+ resourceId,
101
+ operation: "channel.effect.create",
102
+ actor: { type: "host", id: options.ownerId },
103
+ expectedRevision: 0,
104
+ },
105
+ "effect.applying",
106
+ () => {
107
+ this.state.connection
108
+ .prepare(
109
+ `INSERT INTO effects(
110
+ effect_id, resource_id, source_resource_id, source_revision,
111
+ effect_type, idempotency_key, payload_hash, owner_scope,
112
+ status, attempt_count, created_at, updated_at
113
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, 'channel', 'applying', 1, ?, ?)`,
114
+ )
115
+ .run(
116
+ effectId,
117
+ resourceId,
118
+ options.channelResourceId,
119
+ sourceRevision,
120
+ `channel.${options.purpose}`,
121
+ effectId,
122
+ payloadHash,
123
+ now,
124
+ now,
125
+ );
126
+ this.insertAttempt(effectId, 1, options.ownerId, options.leaseGeneration, now);
127
+ },
128
+ { now, payload: { channelId: options.channelId, decisionId: options.decisionId } },
129
+ );
130
+ return this.require(effectId);
131
+ }
132
+
133
+ settle(options: {
134
+ effectId: string;
135
+ attemptNumber: number;
136
+ outcome: "applied" | "rejected" | "ambiguous";
137
+ result?: JsonValue;
138
+ error?: string;
139
+ actorType: "channel" | "human";
140
+ actorId: string;
141
+ }): ChannelEffectRecord {
142
+ const current = this.require(options.effectId);
143
+ if (current.status !== "applying" || current.attemptNumber !== options.attemptNumber) {
144
+ throw new Error("Channel effect attempt is stale");
145
+ }
146
+ const now = Date.now();
147
+ const resultHash =
148
+ options.result === undefined ? null : this.state.putJson(options.result, now);
149
+ const errorHash = options.error === undefined ? null : this.state.putText(options.error, now);
150
+ this.mutations.mutate(
151
+ {
152
+ resourceId: current.resourceId,
153
+ operation: "channel.effect.settle",
154
+ actor: { type: options.actorType, id: options.actorId },
155
+ expectedRevision: this.resourceRevision(current.resourceId),
156
+ },
157
+ `effect.${options.outcome}`,
158
+ () => {
159
+ const changed = this.state.connection
160
+ .prepare(
161
+ `UPDATE effects
162
+ SET status = ?, result_hash = ?, error_hash = ?, updated_at = ?, settled_at = ?
163
+ WHERE effect_id = ? AND status = 'applying' AND attempt_count = ?`,
164
+ )
165
+ .run(
166
+ options.outcome,
167
+ resultHash,
168
+ errorHash,
169
+ now,
170
+ now,
171
+ options.effectId,
172
+ options.attemptNumber,
173
+ );
174
+ if (changed.changes !== 1) throw new Error("Channel effect attempt changed");
175
+ this.state.connection
176
+ .prepare(
177
+ `UPDATE effect_attempts
178
+ SET finished_at = ?, outcome = ?, result_hash = ?, error_hash = ?
179
+ WHERE effect_id = ? AND attempt_number = ? AND finished_at IS NULL`,
180
+ )
181
+ .run(
182
+ now,
183
+ options.outcome,
184
+ resultHash,
185
+ errorHash,
186
+ options.effectId,
187
+ options.attemptNumber,
188
+ );
189
+ },
190
+ { now, payload: { attemptNumber: options.attemptNumber } },
191
+ );
192
+ return this.require(options.effectId);
193
+ }
194
+
195
+ markApplyingAmbiguous(options: {
196
+ sourceResourceId?: string;
197
+ stableMessageIds?: readonly string[];
198
+ error: string;
199
+ actorId: string;
200
+ }): ChannelEffectRecord[] {
201
+ const rows = this.state.connection
202
+ .prepare(
203
+ `SELECT effect_id AS effectId
204
+ FROM effects
205
+ WHERE owner_scope = 'channel' AND status = 'applying'
206
+ AND (? IS NULL OR source_resource_id = ?)
207
+ ORDER BY created_at`,
208
+ )
209
+ .all(options.sourceResourceId ?? null, options.sourceResourceId ?? null);
210
+ const settled: ChannelEffectRecord[] = [];
211
+ for (const row of rows) {
212
+ if (!isEffectIdRow(row)) continue;
213
+ const current = this.require(row.effectId);
214
+ if (
215
+ options.stableMessageIds !== undefined &&
216
+ !options.stableMessageIds.includes(
217
+ channelEffectAttemptId(current.effectId, current.attemptNumber),
218
+ )
219
+ ) {
220
+ continue;
221
+ }
222
+ settled.push(
223
+ this.settle({
224
+ effectId: current.effectId,
225
+ attemptNumber: current.attemptNumber,
226
+ outcome: "ambiguous",
227
+ error: options.error,
228
+ actorType: "channel",
229
+ actorId: options.actorId,
230
+ }),
231
+ );
232
+ }
233
+ return settled;
234
+ }
235
+
236
+ recover(options: {
237
+ stableMessageId: string;
238
+ action: "confirm" | "retry";
239
+ actorId: string;
240
+ ownerId: string;
241
+ leaseGeneration: number;
242
+ }): ChannelEffectRecord {
243
+ const current = this.listAmbiguous().find(
244
+ (effect) =>
245
+ channelEffectAttemptId(effect.effectId, effect.attemptNumber) === options.stableMessageId,
246
+ );
247
+ if (current === undefined) throw new Error("Ambiguous channel operation was not found");
248
+ if (options.action === "confirm") {
249
+ return this.confirmAmbiguous(current, options.actorId);
250
+ }
251
+ return this.beginAttempt(
252
+ current,
253
+ options.ownerId,
254
+ options.leaseGeneration,
255
+ "explicit",
256
+ options.actorId,
257
+ );
258
+ }
259
+
260
+ read(effectId: string): ChannelEffectRecord | undefined {
261
+ const row = this.state.connection
262
+ .prepare(
263
+ `SELECT e.effect_id AS effectId, e.resource_id AS resourceId,
264
+ e.source_resource_id AS sourceResourceId, e.status,
265
+ e.attempt_count AS attemptNumber, a.started_at AS attemptStartedAt,
266
+ e.settled_at AS settledAt, e.payload_hash AS payloadHash,
267
+ e.result_hash AS resultHash
268
+ FROM effects e JOIN effect_attempts a
269
+ ON a.effect_id = e.effect_id AND a.attempt_number = e.attempt_count
270
+ WHERE e.effect_id = ? AND e.owner_scope = 'channel'`,
271
+ )
272
+ .get(effectId);
273
+ return isEffectRow(row) ? this.materialize(row) : undefined;
274
+ }
275
+
276
+ listAmbiguous(): ChannelEffectRecord[] {
277
+ return this.state.connection
278
+ .prepare(
279
+ `SELECT e.effect_id AS effectId, e.resource_id AS resourceId,
280
+ e.source_resource_id AS sourceResourceId, e.status,
281
+ e.attempt_count AS attemptNumber, a.started_at AS attemptStartedAt,
282
+ e.settled_at AS settledAt, e.payload_hash AS payloadHash,
283
+ e.result_hash AS resultHash
284
+ FROM effects e JOIN effect_attempts a
285
+ ON a.effect_id = e.effect_id AND a.attempt_number = e.attempt_count
286
+ WHERE e.owner_scope = 'channel' AND e.status = 'ambiguous'
287
+ ORDER BY e.created_at`,
288
+ )
289
+ .all()
290
+ .filter(isEffectRow)
291
+ .map((row) => this.materialize(row));
292
+ }
293
+
294
+ private confirmAmbiguous(current: ChannelEffectRecord, actorId: string): ChannelEffectRecord {
295
+ const now = Date.now();
296
+ this.mutations.mutate(
297
+ {
298
+ resourceId: current.resourceId,
299
+ operation: "channel.effect.confirm",
300
+ actor: { type: "human", id: actorId },
301
+ expectedRevision: this.resourceRevision(current.resourceId),
302
+ },
303
+ "effect.recovered_confirmed",
304
+ () => {
305
+ const changed = this.state.connection
306
+ .prepare(
307
+ `UPDATE effects SET status = 'applied', updated_at = ?, settled_at = ?
308
+ WHERE effect_id = ? AND status = 'ambiguous' AND attempt_count = ?`,
309
+ )
310
+ .run(now, now, current.effectId, current.attemptNumber);
311
+ if (changed.changes !== 1) throw new Error("Channel effect recovery is stale");
312
+ },
313
+ { now, payload: { attemptNumber: current.attemptNumber } },
314
+ );
315
+ return this.require(current.effectId);
316
+ }
317
+
318
+ private beginAttempt(
319
+ current: ChannelEffectRecord,
320
+ ownerId: string,
321
+ leaseGeneration: number,
322
+ reason: "automatic" | "explicit",
323
+ actorId: string,
324
+ ): ChannelEffectRecord {
325
+ if (current.status !== "rejected" && current.status !== "ambiguous") {
326
+ throw new Error("Channel effect is not retryable");
327
+ }
328
+ const attemptNumber = current.attemptNumber + 1;
329
+ const now = Date.now();
330
+ this.mutations.mutate(
331
+ {
332
+ resourceId: current.resourceId,
333
+ operation: "channel.effect.retry",
334
+ actor: { type: reason === "explicit" ? "human" : "host", id: actorId },
335
+ expectedRevision: this.resourceRevision(current.resourceId),
336
+ },
337
+ "effect.applying",
338
+ () => {
339
+ const changed = this.state.connection
340
+ .prepare(
341
+ `UPDATE effects
342
+ SET status = 'applying', attempt_count = ?, result_hash = NULL,
343
+ error_hash = NULL, updated_at = ?, settled_at = NULL
344
+ WHERE effect_id = ? AND status = ? AND attempt_count = ?`,
345
+ )
346
+ .run(attemptNumber, now, current.effectId, current.status, current.attemptNumber);
347
+ if (changed.changes !== 1) throw new Error("Channel effect retry is stale");
348
+ this.insertAttempt(current.effectId, attemptNumber, ownerId, leaseGeneration, now);
349
+ },
350
+ { now, payload: { attemptNumber, reason } },
351
+ );
352
+ return this.require(current.effectId);
353
+ }
354
+
355
+ private insertAttempt(
356
+ effectId: string,
357
+ attemptNumber: number,
358
+ ownerId: string,
359
+ leaseGeneration: number,
360
+ now: number,
361
+ ): void {
362
+ this.state.connection
363
+ .prepare(
364
+ `INSERT INTO effect_attempts(
365
+ effect_id, attempt_number, owner_id, lease_generation, started_at
366
+ ) VALUES (?, ?, ?, ?, ?)`,
367
+ )
368
+ .run(effectId, attemptNumber, ownerId, leaseGeneration, now);
369
+ }
370
+
371
+ private require(effectId: string): ChannelEffectRecord {
372
+ const value = this.read(effectId);
373
+ if (value === undefined) throw new Error(`Channel effect is missing: ${effectId}`);
374
+ return value;
375
+ }
376
+
377
+ private materialize(row: EffectRow): ChannelEffectRecord {
378
+ const payload = this.state.readJson(row.payloadHash);
379
+ if (!isChannelEffectPayload(payload)) throw new Error("Channel effect payload is invalid");
380
+ return {
381
+ effectId: row.effectId,
382
+ resourceId: row.resourceId,
383
+ sourceResourceId: row.sourceResourceId,
384
+ status: row.status,
385
+ attemptNumber: row.attemptNumber,
386
+ attemptStartedAt: row.attemptStartedAt,
387
+ ...(row.settledAt === null ? {} : { settledAt: row.settledAt }),
388
+ payload,
389
+ ...(row.resultHash === null ? {} : { result: this.state.readJson(row.resultHash) }),
390
+ };
391
+ }
392
+
393
+ private resourceRevision(resourceId: string): number {
394
+ const row = this.state.connection
395
+ .prepare("SELECT revision FROM resources WHERE resource_id = ?")
396
+ .get(resourceId);
397
+ if (!isRevisionRow(row)) throw new Error(`Resource is missing: ${resourceId}`);
398
+ return row.revision;
399
+ }
400
+ }
401
+
402
+ export function channelEffectId(
403
+ channelId: string,
404
+ decisionId: string,
405
+ requestDigest: string,
406
+ purpose: ChannelEffectPurpose,
407
+ ): string {
408
+ return `effect-${createHash("sha256")
409
+ .update(canonicalJson({ channelId, decisionId, requestDigest, purpose }))
410
+ .digest("hex")
411
+ .slice(0, 40)}`;
412
+ }
413
+
414
+ export function channelEffectAttemptId(effectId: string, attemptNumber: number): string {
415
+ return `channel-${createHash("sha256").update(`${effectId}\0${attemptNumber}`).digest("hex")}`;
416
+ }
417
+
418
+ function isChannelEffectPayload(value: unknown): value is ChannelEffectPayload {
419
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
420
+ const payload = value as Partial<ChannelEffectPayload>;
421
+ return (
422
+ payload.schema === "pi-workflows.channel-effect.v1" &&
423
+ typeof payload.channelId === "string" &&
424
+ typeof payload.profile === "string" &&
425
+ typeof payload.decisionId === "string" &&
426
+ (payload.purpose === "delivery" || payload.purpose === "settlement") &&
427
+ payload.request !== null &&
428
+ typeof payload.request === "object"
429
+ );
430
+ }
431
+
432
+ function isEffectRow(value: unknown): value is EffectRow {
433
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
434
+ const row = value as Partial<EffectRow>;
435
+ return (
436
+ typeof row.effectId === "string" &&
437
+ typeof row.resourceId === "string" &&
438
+ typeof row.sourceResourceId === "string" &&
439
+ typeof row.status === "string" &&
440
+ ["pending", "applying", "applied", "rejected", "ambiguous", "cancelled"].includes(row.status) &&
441
+ typeof row.attemptNumber === "number" &&
442
+ typeof row.attemptStartedAt === "number" &&
443
+ (row.settledAt === null || typeof row.settledAt === "number") &&
444
+ Buffer.isBuffer(row.payloadHash) &&
445
+ (row.resultHash === null || Buffer.isBuffer(row.resultHash))
446
+ );
447
+ }
448
+
449
+ function isEffectIdRow(value: unknown): value is { effectId: string } {
450
+ return (
451
+ value !== null &&
452
+ typeof value === "object" &&
453
+ !Array.isArray(value) &&
454
+ typeof (value as { effectId?: unknown }).effectId === "string"
455
+ );
456
+ }
457
+
458
+ function isRevisionRow(value: unknown): value is { revision: number } {
459
+ return (
460
+ value !== null &&
461
+ typeof value === "object" &&
462
+ !Array.isArray(value) &&
463
+ typeof (value as { revision?: unknown }).revision === "number"
464
+ );
465
+ }
@@ -0,0 +1,73 @@
1
+ import fs from "node:fs";
2
+ import { createRequire } from "node:module";
3
+ import { fileURLToPath } from "node:url";
4
+ import { encodeChannelLaunch } from "../channels/adapter-entry.js";
5
+ import {
6
+ encodeChannelLine,
7
+ parseChannelAdapterMessage,
8
+ type ChannelAdapterLaunch,
9
+ type ChannelAdapterMessage,
10
+ type ChannelAdapterResponse,
11
+ } from "../channels/protocol.js";
12
+ import { ChildWorkerSupervisor, type SupervisedWorkerResult } from "./child-worker-supervisor.js";
13
+ import { HostProcessRegistry, type ProcessIdentity } from "./processes.js";
14
+ import type { WorkerOutcome } from "./state.js";
15
+
16
+ export class ChannelAdapterSupervisor {
17
+ private readonly supervisor: ChildWorkerSupervisor<ChannelAdapterMessage, ChannelAdapterResponse>;
18
+
19
+ constructor(
20
+ readonly launch: ChannelAdapterLaunch,
21
+ private readonly options: {
22
+ registry: HostProcessRegistry;
23
+ onMessage: (message: ChannelAdapterMessage) => Promise<ChannelAdapterResponse>;
24
+ env?: Record<string, string>;
25
+ startupTimeoutMs?: number;
26
+ adapterEntryPath?: string;
27
+ onSpawn?: (identity: ProcessIdentity) => void;
28
+ onDiagnostic?: (message: string) => void;
29
+ },
30
+ ) {
31
+ this.supervisor = new ChildWorkerSupervisor({
32
+ label: `Telegram channel adapter ${launch.profile}`,
33
+ registry: options.registry,
34
+ onMessage: options.onMessage,
35
+ parseMessage: parseChannelAdapterMessage,
36
+ encodeResponse: encodeChannelLine,
37
+ isReady: (message) => message.kind === "channel.ready",
38
+ isTerminal: (message) => message.kind === "channel.exiting",
39
+ ...(options.startupTimeoutMs === undefined
40
+ ? {}
41
+ : { startupTimeoutMs: options.startupTimeoutMs }),
42
+ ...(options.onSpawn === undefined ? {} : { onSpawn: options.onSpawn }),
43
+ ...(options.onDiagnostic === undefined ? {} : { onDiagnostic: options.onDiagnostic }),
44
+ });
45
+ }
46
+
47
+ async start(): Promise<void> {
48
+ const builtEntry = fileURLToPath(new URL("../channels/adapter-entry.js", import.meta.url));
49
+ const sourceEntry = fileURLToPath(new URL("../channels/adapter-entry.ts", import.meta.url));
50
+ const adapterEntry = this.options.adapterEntryPath ?? builtEntry;
51
+ const args =
52
+ this.options.adapterEntryPath === undefined && !fs.existsSync(builtEntry)
53
+ ? ["--import", createRequire(import.meta.url).resolve("tsx"), sourceEntry]
54
+ : [adapterEntry];
55
+ await this.supervisor.start({
56
+ args,
57
+ cwd: process.cwd(),
58
+ env: {
59
+ ...process.env,
60
+ ...this.options.env,
61
+ PI_WORKFLOWS_CHANNEL_LAUNCH: encodeChannelLaunch(this.launch),
62
+ },
63
+ });
64
+ }
65
+
66
+ async wait(): Promise<SupervisedWorkerResult> {
67
+ return await this.supervisor.wait();
68
+ }
69
+
70
+ async stop(outcome: WorkerOutcome = "cancelled"): Promise<void> {
71
+ await this.supervisor.stop(outcome);
72
+ }
73
+ }