@osolmaz/pi-workflows 0.12.0 → 0.13.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 (257) hide show
  1. package/README.md +56 -31
  2. package/dist/builtins/autodoc.workflow.d.ts +4 -4
  3. package/dist/builtins/autoimplement.workflow.d.ts +797 -142
  4. package/dist/builtins/autoimplement.workflow.js +17 -105
  5. package/dist/builtins/autoimplement.workflow.js.map +1 -1
  6. package/dist/builtins/autoplan.workflow.d.ts +126 -13
  7. package/dist/builtins/autoplan.workflow.js +200 -29
  8. package/dist/builtins/autoplan.workflow.js.map +1 -1
  9. package/dist/builtins/catalog.js +7 -5
  10. package/dist/builtins/catalog.js.map +1 -1
  11. package/dist/builtins/index.d.ts +4 -2
  12. package/dist/builtins/index.js +3 -1
  13. package/dist/builtins/index.js.map +1 -1
  14. package/dist/builtins/monitor.workflow.d.ts +56 -23
  15. package/dist/builtins/monitor.workflow.js +508 -329
  16. package/dist/builtins/monitor.workflow.js.map +1 -1
  17. package/dist/builtins/pi-agent-group.d.ts +72 -0
  18. package/dist/builtins/pi-agent-group.js +1087 -0
  19. package/dist/builtins/pi-agent-group.js.map +1 -0
  20. package/dist/builtins/plain-summary.workflow.d.ts +32 -0
  21. package/dist/builtins/plain-summary.workflow.js +134 -0
  22. package/dist/builtins/plain-summary.workflow.js.map +1 -0
  23. package/dist/builtins/plan-approval.workflow.d.ts +39 -5
  24. package/dist/builtins/plan-approval.workflow.js +92 -14
  25. package/dist/builtins/plan-approval.workflow.js.map +1 -1
  26. package/dist/builtins/plan-change.workflow.d.ts +449 -0
  27. package/dist/builtins/plan-change.workflow.js +256 -0
  28. package/dist/builtins/plan-change.workflow.js.map +1 -0
  29. package/dist/builtins/plan-presentation.js +2 -2
  30. package/dist/builtins/plan-presentation.js.map +1 -1
  31. package/dist/builtins/sanity-check.workflow.d.ts +50 -6
  32. package/dist/builtins/sanity-check.workflow.js +150 -28
  33. package/dist/builtins/sanity-check.workflow.js.map +1 -1
  34. package/dist/controllers/effects.d.ts +3 -2
  35. package/dist/controllers/effects.js +8 -1
  36. package/dist/controllers/effects.js.map +1 -1
  37. package/dist/controllers/index.d.ts +1 -1
  38. package/dist/controllers/index.js +1 -1
  39. package/dist/controllers/index.js.map +1 -1
  40. package/dist/controllers/manager.d.ts +1 -0
  41. package/dist/controllers/manager.js +41 -17
  42. package/dist/controllers/manager.js.map +1 -1
  43. package/dist/controllers/sqlite.d.ts +107 -85
  44. package/dist/controllers/sqlite.js +1611 -1486
  45. package/dist/controllers/sqlite.js.map +1 -1
  46. package/dist/controllers/store.d.ts +9 -8
  47. package/dist/controllers/store.js +1 -31
  48. package/dist/controllers/store.js.map +1 -1
  49. package/dist/controllers/types.d.ts +3 -0
  50. package/dist/controllers/workflow-engine-scheduler.d.ts +1 -1
  51. package/dist/controllers/workflow-engine-scheduler.js +3 -5
  52. package/dist/controllers/workflow-engine-scheduler.js.map +1 -1
  53. package/dist/controllers/workflows.d.ts +2 -2
  54. package/dist/controllers/workflows.js +11 -10
  55. package/dist/controllers/workflows.js.map +1 -1
  56. package/dist/extension/controller-host.js +2 -2
  57. package/dist/extension/controller-host.js.map +1 -1
  58. package/dist/extension/decision-channels.d.ts +4 -2
  59. package/dist/extension/decision-channels.js +333 -146
  60. package/dist/extension/decision-channels.js.map +1 -1
  61. package/dist/extension/executor.d.ts +9 -2
  62. package/dist/extension/executor.js +89 -2
  63. package/dist/extension/executor.js.map +1 -1
  64. package/dist/extension/herdr-viewer.d.ts +0 -1
  65. package/dist/extension/herdr-viewer.js +0 -4
  66. package/dist/extension/herdr-viewer.js.map +1 -1
  67. package/dist/extension/index.js +209 -127
  68. package/dist/extension/index.js.map +1 -1
  69. package/dist/extension/recorder.d.ts +3 -10
  70. package/dist/extension/recorder.js +13 -22
  71. package/dist/extension/recorder.js.map +1 -1
  72. package/dist/extension/session-events.d.ts +2 -2
  73. package/dist/extension/step-message.d.ts +7 -2
  74. package/dist/extension/step-message.js +94 -2
  75. package/dist/extension/step-message.js.map +1 -1
  76. package/dist/extension/widget.js +29 -3
  77. package/dist/extension/widget.js.map +1 -1
  78. package/dist/host/rpc-executor.d.ts +3 -0
  79. package/dist/host/rpc-executor.js +2 -0
  80. package/dist/host/rpc-executor.js.map +1 -1
  81. package/dist/host/runner.d.ts +3 -6
  82. package/dist/host/runner.js +83 -32
  83. package/dist/host/runner.js.map +1 -1
  84. package/dist/render/graph-render.js +15 -10
  85. package/dist/render/graph-render.js.map +1 -1
  86. package/dist/state/database.d.ts +35 -0
  87. package/dist/state/database.js +287 -0
  88. package/dist/state/database.js.map +1 -0
  89. package/dist/state/index.d.ts +4 -0
  90. package/dist/state/index.js +5 -0
  91. package/dist/state/index.js.map +1 -0
  92. package/dist/state/json.d.ts +6 -0
  93. package/dist/state/json.js +38 -0
  94. package/dist/state/json.js.map +1 -0
  95. package/dist/state/mutation.d.ts +57 -0
  96. package/dist/state/mutation.js +255 -0
  97. package/dist/state/mutation.js.map +1 -0
  98. package/dist/state/schema.d.ts +6 -0
  99. package/dist/state/schema.js +473 -0
  100. package/dist/state/schema.js.map +1 -0
  101. package/dist/viewer/cli.d.ts +2 -2
  102. package/dist/viewer/cli.js +142 -122
  103. package/dist/viewer/cli.js.map +1 -1
  104. package/dist/viewer/render.d.ts +4 -4
  105. package/dist/viewer/render.js +9 -23
  106. package/dist/viewer/render.js.map +1 -1
  107. package/dist/viewer/session-reducer.d.ts +1 -1
  108. package/dist/viewer/session-reducer.js +1 -1
  109. package/dist/viewer/tui.d.ts +3 -3
  110. package/dist/viewer/tui.js +15 -13
  111. package/dist/viewer/tui.js.map +1 -1
  112. package/dist/viewer/watch.d.ts +2 -5
  113. package/dist/viewer/watch.js +13 -13
  114. package/dist/viewer/watch.js.map +1 -1
  115. package/dist/workflows/catalog.js +7 -2
  116. package/dist/workflows/catalog.js.map +1 -1
  117. package/dist/workflows/composition.js +20 -2
  118. package/dist/workflows/composition.js.map +1 -1
  119. package/dist/workflows/decision-presentation.d.ts +1 -1
  120. package/dist/workflows/decision-presentation.js +51 -38
  121. package/dist/workflows/decision-presentation.js.map +1 -1
  122. package/dist/workflows/definition.d.ts +6 -2
  123. package/dist/workflows/definition.js +17 -0
  124. package/dist/workflows/definition.js.map +1 -1
  125. package/dist/workflows/engine.d.ts +7 -7
  126. package/dist/workflows/engine.js +196 -115
  127. package/dist/workflows/engine.js.map +1 -1
  128. package/dist/workflows/errors.d.ts +16 -3
  129. package/dist/workflows/errors.js +18 -3
  130. package/dist/workflows/errors.js.map +1 -1
  131. package/dist/workflows/human-decision.d.ts +45 -8
  132. package/dist/workflows/human-decision.js +641 -274
  133. package/dist/workflows/human-decision.js.map +1 -1
  134. package/dist/workflows/index.d.ts +5 -6
  135. package/dist/workflows/index.js +4 -5
  136. package/dist/workflows/index.js.map +1 -1
  137. package/dist/workflows/progress.d.ts +1 -0
  138. package/dist/workflows/progress.js +15 -3
  139. package/dist/workflows/progress.js.map +1 -1
  140. package/dist/workflows/schema.js +27 -1
  141. package/dist/workflows/schema.js.map +1 -1
  142. package/dist/workflows/store.d.ts +83 -103
  143. package/dist/workflows/store.js +907 -951
  144. package/dist/workflows/store.js.map +1 -1
  145. package/dist/workflows/types.d.ts +88 -126
  146. package/docs/CONTROLLERS.md +10 -8
  147. package/docs/DEFERRED_TURNS.md +2 -2
  148. package/docs/DESIGN_PHILOSOPHY.md +4 -2
  149. package/docs/HUMAN_DECISIONS.md +36 -47
  150. package/docs/HUMAN_DECISION_PRESENTATIONS.md +14 -24
  151. package/docs/MONITOR.md +179 -237
  152. package/docs/SQLITE_STATE.md +219 -0
  153. package/docs/WORKFLOW_COMPOSITION.md +27 -26
  154. package/docs/WORKFLOW_STEP_MESSAGES.md +20 -18
  155. package/docs/WORKFLOW_UPDATES.md +10 -9
  156. package/docs/development.md +9 -9
  157. package/docs/live-replay-protocol.md +28 -37
  158. package/docs/plans/2026-08-16-workflow-updates-plan.md +1 -1
  159. package/docs/plans/2026-08-19-human-decision-gates-plan.md +1 -1
  160. package/docs/plans/2026-08-21-autoimplement-timeout-fallback-plan.md +1 -1
  161. package/docs/plans/2026-08-21-plan-change-approval-policy-plan.md +322 -0
  162. package/docs/plans/2026-08-21-sanity-check-plan.md +217 -95
  163. package/docs/plans/2026-08-22-goal-finishing-monitor-plan.md +200 -0
  164. package/docs/plans/2026-08-23-assistant-agent-completion-plan.md +701 -0
  165. package/docs/plans/2026-08-23-sqlite-state-plan.md +970 -0
  166. package/docs/plans/replayable-run-bundles-implementation-plan.md +2 -2
  167. package/docs/plans/session-event-replay-implementation-plan.md +1 -1
  168. package/docs/plans/tui-viewer-implementation-plan.md +1 -1
  169. package/docs/session-event-journal.md +63 -434
  170. package/docs/tui-viewer.md +13 -15
  171. package/docs/workflows.md +127 -53
  172. package/examples/workflows/approved-plan.workflow.ts +19 -46
  173. package/examples/workflows/plain-summary.workflow.ts +1 -0
  174. package/herdr-plugin.toml +1 -1
  175. package/package.json +7 -7
  176. package/plugins/herdr/viewer.mjs +1 -11
  177. package/schemas/human-decision-accepted-v1.schema.json +15 -3
  178. package/schemas/human-decision-continuation-v1.schema.json +10 -1
  179. package/schemas/human-decision-delivery-v1.schema.json +8 -0
  180. package/schemas/human-decision-receipt-v1.schema.json +8 -0
  181. package/schemas/human-decision-request-v1.schema.json +24 -4
  182. package/skills/autoimplement/SKILL.md +27 -0
  183. package/skills/autoplan/SKILL.md +5 -4
  184. package/skills/monitor/SKILL.md +91 -135
  185. package/skills/pi-workflows/SKILL.md +9 -8
  186. package/skills/sanity-check/SKILL.md +44 -0
  187. package/src/builtins/autoimplement.workflow.ts +19 -118
  188. package/src/builtins/autoplan.workflow.ts +290 -40
  189. package/src/builtins/catalog.ts +7 -5
  190. package/src/builtins/index.ts +18 -1
  191. package/src/builtins/monitor.workflow.ts +673 -382
  192. package/src/builtins/pi-agent-group.ts +1407 -0
  193. package/src/builtins/plain-summary.workflow.ts +185 -0
  194. package/src/builtins/plan-approval.workflow.ts +157 -24
  195. package/src/builtins/plan-change.workflow.ts +321 -0
  196. package/src/builtins/plan-presentation.ts +2 -2
  197. package/src/builtins/sanity-check.workflow.ts +248 -48
  198. package/src/controllers/effects.ts +7 -0
  199. package/src/controllers/index.ts +0 -6
  200. package/src/controllers/manager.ts +72 -32
  201. package/src/controllers/sqlite.ts +2420 -1948
  202. package/src/controllers/store.ts +17 -38
  203. package/src/controllers/types.ts +3 -0
  204. package/src/controllers/workflow-engine-scheduler.ts +4 -8
  205. package/src/controllers/workflows.ts +45 -21
  206. package/src/extension/controller-host.ts +1 -2
  207. package/src/extension/decision-channels.ts +456 -203
  208. package/src/extension/executor.ts +105 -2
  209. package/src/extension/herdr-viewer.ts +0 -5
  210. package/src/extension/index.ts +251 -151
  211. package/src/extension/recorder.ts +13 -22
  212. package/src/extension/session-events.ts +2 -2
  213. package/src/extension/step-message.ts +106 -4
  214. package/src/extension/widget.ts +32 -5
  215. package/src/host/rpc-executor.ts +4 -0
  216. package/src/host/runner.ts +93 -41
  217. package/src/render/graph-render.ts +12 -6
  218. package/src/state/database.ts +375 -0
  219. package/src/state/index.ts +29 -0
  220. package/src/state/json.ts +42 -0
  221. package/src/state/mutation.ts +435 -0
  222. package/src/state/schema.ts +475 -0
  223. package/src/viewer/cli.ts +151 -144
  224. package/src/viewer/render.ts +19 -30
  225. package/src/viewer/session-reducer.ts +1 -1
  226. package/src/viewer/tui.ts +18 -17
  227. package/src/viewer/watch.ts +13 -17
  228. package/src/workflows/catalog.ts +7 -2
  229. package/src/workflows/composition.ts +21 -3
  230. package/src/workflows/decision-presentation.ts +56 -43
  231. package/src/workflows/definition.ts +36 -3
  232. package/src/workflows/engine.ts +228 -130
  233. package/src/workflows/errors.ts +27 -3
  234. package/src/workflows/human-decision.ts +921 -349
  235. package/src/workflows/index.ts +20 -30
  236. package/src/workflows/progress.ts +18 -3
  237. package/src/workflows/schema.ts +36 -1
  238. package/src/workflows/store.ts +1300 -1163
  239. package/src/workflows/types.ts +100 -137
  240. package/dist/builtins/sanity-check-session.d.ts +0 -17
  241. package/dist/builtins/sanity-check-session.js +0 -168
  242. package/dist/builtins/sanity-check-session.js.map +0 -1
  243. package/dist/workflows/artifacts.d.ts +0 -40
  244. package/dist/workflows/artifacts.js +0 -155
  245. package/dist/workflows/artifacts.js.map +0 -1
  246. package/dist/workflows/migrate-sources.d.ts +0 -42
  247. package/dist/workflows/migrate-sources.js +0 -133
  248. package/dist/workflows/migrate-sources.js.map +0 -1
  249. package/docs/run-bundles.md +0 -481
  250. package/schemas/human-decision-accepted-v2.schema.json +0 -50
  251. package/schemas/human-decision-delivery-v2.schema.json +0 -36
  252. package/schemas/human-decision-receipt-v2.schema.json +0 -39
  253. package/schemas/human-decision-request-v2.schema.json +0 -69
  254. package/schemas/human-decision-resolution-v2.schema.json +0 -27
  255. package/src/builtins/sanity-check-session.ts +0 -205
  256. package/src/workflows/artifacts.ts +0 -188
  257. package/src/workflows/migrate-sources.ts +0 -178
@@ -1,20 +1,21 @@
1
- import { createHash, randomUUID } from "node:crypto";
2
- import fs from "node:fs";
1
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
3
2
  import fsp from "node:fs/promises";
4
3
  import os from "node:os";
5
4
  import path from "node:path";
6
5
  import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
7
6
  import { Key, matchesKey, wrapTextWithAnsi } from "@earendil-works/pi-tui";
8
- import Database from "better-sqlite3";
7
+ import type { StateDatabase } from "../state/database.js";
8
+ import { resourceIdFor, tokenHash } from "../state/mutation.js";
9
9
  import {
10
10
  MAX_PRESENTATION_TRANSPORT_PARTS,
11
11
  decisionDocumentSegments,
12
12
  decisionPresentationFingerprint,
13
13
  digestCanonical,
14
+ humanDecisionChannelRequest,
14
15
  } from "../workflows/decision-presentation.js";
15
16
  import { HumanDecisionStore, createHumanDecisionAttemptId } from "../workflows/human-decision.js";
16
17
  import type {
17
- AcceptedHumanDecision,
18
+ ResolvedHumanDecision,
18
19
  HumanDecisionAnswerSource,
19
20
  HumanDecisionCancellationRecord,
20
21
  HumanDecisionChannelRequest,
@@ -47,7 +48,7 @@ export type HumanDecisionDeliveryResult = {
47
48
  errorCode?: string;
48
49
  };
49
50
 
50
- export type SettledHumanDecision = AcceptedHumanDecision | HumanDecisionCancellationRecord;
51
+ export type SettledHumanDecision = ResolvedHumanDecision | HumanDecisionCancellationRecord;
51
52
 
52
53
  export interface HumanDecisionChannel {
53
54
  readonly id: string;
@@ -154,6 +155,9 @@ export class PiDecisionChannel implements HumanDecisionChannel {
154
155
  }
155
156
 
156
157
  async deliver(request: HumanDecisionChannelRequest): Promise<HumanDecisionDeliveryResult> {
158
+ if (await this.alreadyDecided(request.decisionId)) {
159
+ return { status: "confirmed", channel: this.id, attemptId: "adopted" };
160
+ }
157
161
  const attemptId = createHumanDecisionAttemptId();
158
162
  const createdAt = new Date().toISOString();
159
163
  const controller = new AbortController();
@@ -162,7 +166,7 @@ export class PiDecisionChannel implements HumanDecisionChannel {
162
166
  await this.options.store.recordDelivery(
163
167
  request,
164
168
  this.id,
165
- v2DeliveryRecord(request, this.id, attemptId, "intent", "intent", createdAt),
169
+ deliveryRecord(request, this.id, attemptId, "intent", "intent", createdAt),
166
170
  );
167
171
  const entries = Object.entries(request.choices);
168
172
  const selectedChoice = await this.options.ui.custom<string | undefined>(
@@ -241,7 +245,7 @@ export class PiDecisionChannel implements HumanDecisionChannel {
241
245
  await this.options.store.recordDelivery(
242
246
  request,
243
247
  this.id,
244
- v2DeliveryRecord(
248
+ deliveryRecord(
245
249
  request,
246
250
  this.id,
247
251
  `${attemptId}-cancelled`,
@@ -267,7 +271,7 @@ export class PiDecisionChannel implements HumanDecisionChannel {
267
271
  await this.options.store.recordDelivery(
268
272
  request,
269
273
  this.id,
270
- v2DeliveryRecord(
274
+ deliveryRecord(
271
275
  request,
272
276
  this.id,
273
277
  `${attemptId}-cancelled`,
@@ -281,6 +285,9 @@ export class PiDecisionChannel implements HumanDecisionChannel {
281
285
  }
282
286
  response = { choice: selectedChoice, input: { [definition.input.name]: text } };
283
287
  }
288
+ if (await this.alreadyDecided(request.decisionId)) {
289
+ return { status: "confirmed", channel: this.id, attemptId: "adopted" };
290
+ }
284
291
  const eventId = createHumanDecisionAttemptId();
285
292
  await this.options.onAnswer({
286
293
  request,
@@ -291,7 +298,7 @@ export class PiDecisionChannel implements HumanDecisionChannel {
291
298
  await this.options.store.recordDelivery(
292
299
  request,
293
300
  this.id,
294
- v2DeliveryRecord(
301
+ deliveryRecord(
295
302
  request,
296
303
  this.id,
297
304
  `${attemptId}-confirmed`,
@@ -307,6 +314,13 @@ export class PiDecisionChannel implements HumanDecisionChannel {
307
314
  }
308
315
  }
309
316
 
317
+ private async alreadyDecided(decisionId: string): Promise<boolean> {
318
+ return (
319
+ (await this.options.store.readResolved(decisionId)) !== null ||
320
+ (await this.options.store.readCancellation(decisionId)) !== null
321
+ );
322
+ }
323
+
310
324
  async settle(decision: SettledHumanDecision): Promise<void> {
311
325
  this.promptAbort?.abort();
312
326
  if (this.settlementTask !== null) return await this.settlementTask;
@@ -354,8 +368,6 @@ type ReplyBinding = CallbackBinding & {
354
368
  promptMessageId: string;
355
369
  };
356
370
 
357
- type CallbackRow = { request_json: string; choice_id: string };
358
- type ReplyRow = { request_json: string; choice_id: string; chat_id: string; message_id: string };
359
371
  type MessageRow = { chat_id: string; message_id: string };
360
372
  type MessagePartRow = {
361
373
  recipient_index: number;
@@ -364,189 +376,214 @@ type MessagePartRow = {
364
376
  chat_id: string;
365
377
  message_id: string;
366
378
  };
367
- type OffsetRow = { next_offset: number };
368
379
 
369
380
  class TelegramProjection {
370
- private readonly db: Database.Database;
381
+ private readonly channelId: string;
382
+ private readonly resourceId: string;
383
+ private readonly token = randomBytes(32).toString("base64url");
384
+ private generation = 0;
371
385
 
372
386
  constructor(
373
- filePath: string,
387
+ private readonly state: StateDatabase,
374
388
  private readonly profile: string,
375
389
  private readonly owner: string,
376
390
  ) {
377
- fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
378
- this.db = new Database(filePath);
379
- fs.chmodSync(filePath, 0o600);
380
- this.db.pragma("journal_mode = WAL");
381
- this.db.pragma("busy_timeout = 5000");
382
- this.db.exec(`
383
- CREATE TABLE IF NOT EXISTS leases (
384
- profile TEXT PRIMARY KEY,
385
- owner TEXT NOT NULL,
386
- expires_at INTEGER NOT NULL
387
- );
388
- CREATE TABLE IF NOT EXISTS offsets (
389
- profile TEXT PRIMARY KEY,
390
- next_offset INTEGER NOT NULL
391
- );
392
- CREATE TABLE IF NOT EXISTS callbacks (
393
- profile TEXT NOT NULL,
394
- token TEXT NOT NULL,
395
- request_json TEXT NOT NULL,
396
- choice_id TEXT NOT NULL,
397
- PRIMARY KEY (profile, token)
398
- );
399
- CREATE TABLE IF NOT EXISTS replies (
400
- profile TEXT NOT NULL,
401
- chat_id TEXT NOT NULL,
402
- message_id TEXT NOT NULL,
403
- request_json TEXT NOT NULL,
404
- choice_id TEXT NOT NULL,
405
- PRIMARY KEY (profile, chat_id, message_id)
406
- );
407
- CREATE TABLE IF NOT EXISTS messages (
408
- profile TEXT NOT NULL,
409
- decision_id TEXT NOT NULL,
410
- chat_id TEXT NOT NULL,
411
- message_id TEXT NOT NULL,
412
- PRIMARY KEY (profile, decision_id, chat_id, message_id)
413
- );
414
- CREATE TABLE IF NOT EXISTS message_parts (
415
- profile TEXT NOT NULL,
416
- decision_id TEXT NOT NULL,
417
- recipient_index INTEGER NOT NULL,
418
- part_index INTEGER NOT NULL,
419
- content_digest TEXT NOT NULL,
420
- chat_id TEXT NOT NULL,
421
- message_id TEXT NOT NULL,
422
- PRIMARY KEY (profile, decision_id, recipient_index, part_index)
423
- );
424
- `);
391
+ this.channelId = `telegram-${createHash("sha256").update(profile).digest("hex").slice(0, 40)}`;
392
+ this.resourceId = resourceIdFor("channel", this.channelId);
393
+ this.state.transaction(() => {
394
+ const now = Date.now();
395
+ this.state.connection
396
+ .prepare(
397
+ `INSERT INTO resources(
398
+ resource_id, resource_type, aggregate_key, revision, created_at, updated_at
399
+ ) VALUES (?, 'channel', ?, 1, ?, ?)
400
+ ON CONFLICT(resource_type, aggregate_key) DO NOTHING`,
401
+ )
402
+ .run(this.resourceId, this.channelId, now, now);
403
+ this.state.connection
404
+ .prepare(
405
+ "INSERT INTO leases(resource_id, generation) VALUES (?, 0) ON CONFLICT(resource_id) DO NOTHING",
406
+ )
407
+ .run(this.resourceId);
408
+ this.state.connection
409
+ .prepare(
410
+ `INSERT INTO channels(channel_id, resource_id, adapter_type, profile_key, created_at)
411
+ VALUES (?, ?, 'telegram', ?, ?) ON CONFLICT(channel_id) DO NOTHING`,
412
+ )
413
+ .run(this.channelId, this.resourceId, this.profile, now);
414
+ });
425
415
  }
426
416
 
427
417
  close(): void {
428
418
  this.release();
429
- this.db.close();
419
+ }
420
+
421
+ ownsLease(): boolean {
422
+ try {
423
+ this.assertOwner();
424
+ return true;
425
+ } catch {
426
+ return false;
427
+ }
430
428
  }
431
429
 
432
430
  acquire(now = Date.now()): boolean {
433
- const transaction = this.db.transaction(() => {
434
- const row = this.db
435
- .prepare("SELECT owner, expires_at FROM leases WHERE profile = ?")
436
- .get(this.profile) as { owner: string; expires_at: number } | undefined;
437
- if (row !== undefined && row.owner !== this.owner && row.expires_at > now) return false;
438
- this.db
431
+ return this.state.transaction(() => {
432
+ const lease = this.lease();
433
+ if (
434
+ lease.ownerId !== null &&
435
+ lease.ownerId !== this.owner &&
436
+ lease.expiresAt !== null &&
437
+ lease.expiresAt > now
438
+ ) {
439
+ return false;
440
+ }
441
+ const generation = lease.ownerId === this.owner ? lease.generation : lease.generation + 1;
442
+ const result = this.state.connection
439
443
  .prepare(
440
- "INSERT INTO leases(profile, owner, expires_at) VALUES (?, ?, ?) ON CONFLICT(profile) DO UPDATE SET owner = excluded.owner, expires_at = excluded.expires_at",
444
+ `UPDATE leases
445
+ SET generation = ?, owner_type = 'channel', owner_id = ?, token_hash = ?,
446
+ acquired_at = COALESCE(acquired_at, ?), heartbeat_at = ?, expires_at = ?
447
+ WHERE resource_id = ? AND generation = ?`,
441
448
  )
442
- .run(this.profile, this.owner, now + LEASE_TTL_MS);
449
+ .run(
450
+ generation,
451
+ this.owner,
452
+ tokenHash(this.token),
453
+ now,
454
+ now,
455
+ now + LEASE_TTL_MS,
456
+ this.resourceId,
457
+ lease.generation,
458
+ );
459
+ /* istanbul ignore if -- impossible after exact schema and transaction checks */
460
+ if (result.changes !== 1) return false;
461
+ this.generation = generation;
462
+ this.recordMutation("channel.lease_acquired", { expiresAt: now + LEASE_TTL_MS }, now);
443
463
  return true;
444
464
  });
445
- return transaction();
446
465
  }
447
466
 
448
467
  renew(now = Date.now()): boolean {
449
468
  return (
450
- this.db
451
- .prepare("UPDATE leases SET expires_at = ? WHERE profile = ? AND owner = ?")
452
- .run(now + LEASE_TTL_MS, this.profile, this.owner).changes === 1
469
+ this.state.connection
470
+ .prepare(
471
+ `UPDATE leases SET heartbeat_at = ?, expires_at = ?
472
+ WHERE resource_id = ? AND owner_id = ? AND token_hash = ? AND generation = ?`,
473
+ )
474
+ .run(
475
+ now,
476
+ now + LEASE_TTL_MS,
477
+ this.resourceId,
478
+ this.owner,
479
+ tokenHash(this.token),
480
+ this.generation,
481
+ ).changes === 1
453
482
  );
454
483
  }
455
484
 
456
485
  release(): void {
457
- this.db
458
- .prepare("DELETE FROM leases WHERE profile = ? AND owner = ?")
459
- .run(this.profile, this.owner);
486
+ this.state.transaction(() => {
487
+ const result = this.state.connection
488
+ .prepare(
489
+ `UPDATE leases
490
+ SET owner_type = NULL, owner_id = NULL, token_hash = NULL,
491
+ acquired_at = NULL, heartbeat_at = NULL, expires_at = NULL
492
+ WHERE resource_id = ? AND owner_id = ? AND token_hash = ? AND generation = ?`,
493
+ )
494
+ .run(this.resourceId, this.owner, tokenHash(this.token), this.generation);
495
+ if (result.changes === 1) {
496
+ this.recordMutation("channel.lease_released", {}, Date.now());
497
+ }
498
+ });
460
499
  }
461
500
 
462
501
  offset(): number {
463
- return (
464
- (
465
- this.db.prepare("SELECT next_offset FROM offsets WHERE profile = ?").get(this.profile) as
466
- | OffsetRow
467
- | undefined
468
- )?.next_offset ?? 0
469
- );
502
+ const row = this.state.connection
503
+ .prepare(
504
+ "SELECT cursor_value AS cursorValue FROM channel_cursors WHERE channel_id = ? AND cursor_key = 'telegram_update'",
505
+ )
506
+ .get(this.channelId);
507
+ return isCursorRow(row) ? Number.parseInt(row.cursorValue, 10) || 0 : 0;
470
508
  }
471
509
 
472
510
  setOffset(offset: number): void {
473
- this.db
474
- .prepare(
475
- "INSERT INTO offsets(profile, next_offset) VALUES (?, ?) ON CONFLICT(profile) DO UPDATE SET next_offset = MAX(next_offset, excluded.next_offset)",
476
- )
477
- .run(this.profile, offset);
511
+ this.mutate("channel.cursor_updated", { offset }, () => {
512
+ this.state.connection
513
+ .prepare(
514
+ `INSERT INTO channel_cursors(channel_id, cursor_key, cursor_value, updated_at)
515
+ VALUES (?, 'telegram_update', ?, ?)
516
+ ON CONFLICT(channel_id, cursor_key) DO UPDATE SET
517
+ cursor_value = CAST(MAX(CAST(channel_cursors.cursor_value AS INTEGER), CAST(excluded.cursor_value AS INTEGER)) AS TEXT),
518
+ updated_at = excluded.updated_at`,
519
+ )
520
+ .run(this.channelId, String(offset), Date.now());
521
+ });
478
522
  }
479
523
 
480
524
  putCallback(token: string, binding: CallbackBinding): void {
481
- this.db
482
- .prepare(
483
- "INSERT INTO callbacks(profile, token, request_json, choice_id) VALUES (?, ?, ?, ?) ON CONFLICT(profile, token) DO UPDATE SET request_json = excluded.request_json, choice_id = excluded.choice_id",
484
- )
485
- .run(this.profile, token, JSON.stringify(binding.request), binding.choice);
525
+ this.putInbox(`callback:${token}`, "callback", binding);
486
526
  }
487
527
 
488
528
  callback(token: string): CallbackBinding | undefined {
489
- const row = this.db
490
- .prepare("SELECT request_json, choice_id FROM callbacks WHERE profile = ? AND token = ?")
491
- .get(this.profile, token) as CallbackRow | undefined;
492
- return row === undefined
493
- ? undefined
494
- : {
495
- request: JSON.parse(row.request_json) as HumanDecisionChannelRequest,
496
- choice: row.choice_id,
497
- };
529
+ return this.inbox<CallbackBinding>(`callback:${token}`);
498
530
  }
499
531
 
500
532
  putReply(binding: ReplyBinding): void {
501
- this.db
502
- .prepare(
503
- "INSERT INTO replies(profile, chat_id, message_id, request_json, choice_id) VALUES (?, ?, ?, ?, ?) ON CONFLICT(profile, chat_id, message_id) DO UPDATE SET request_json = excluded.request_json, choice_id = excluded.choice_id",
504
- )
505
- .run(
506
- this.profile,
507
- binding.chatId,
508
- binding.promptMessageId,
509
- JSON.stringify(binding.request),
510
- binding.choice,
511
- );
533
+ this.putInbox(`reply:${binding.chatId}:${binding.promptMessageId}`, "reply", binding);
512
534
  }
513
535
 
514
536
  reply(chatId: string, messageId: string): ReplyBinding | undefined {
515
- const row = this.db
516
- .prepare(
517
- "SELECT request_json, choice_id, chat_id, message_id FROM replies WHERE profile = ? AND chat_id = ? AND message_id = ?",
518
- )
519
- .get(this.profile, chatId, messageId) as ReplyRow | undefined;
520
- return row === undefined
521
- ? undefined
522
- : {
523
- request: JSON.parse(row.request_json) as HumanDecisionChannelRequest,
524
- choice: row.choice_id,
525
- chatId: row.chat_id,
526
- promptMessageId: row.message_id,
527
- };
537
+ return this.inbox<ReplyBinding>(`reply:${chatId}:${messageId}`);
528
538
  }
529
539
 
530
540
  deleteReply(chatId: string, messageId: string): void {
531
- this.db
532
- .prepare("DELETE FROM replies WHERE profile = ? AND chat_id = ? AND message_id = ?")
533
- .run(this.profile, chatId, messageId);
541
+ this.mutate("channel.reply_consumed", { chatId, messageId }, () => {
542
+ this.state.connection
543
+ .prepare("DELETE FROM channel_inbox WHERE channel_id = ? AND external_event_id = ?")
544
+ .run(this.channelId, `reply:${chatId}:${messageId}`);
545
+ });
534
546
  }
535
547
 
536
548
  putMessage(decisionId: string, chatId: string, messageId: string): void {
537
- this.db
538
- .prepare(
539
- "INSERT OR IGNORE INTO messages(profile, decision_id, chat_id, message_id) VALUES (?, ?, ?, ?)",
540
- )
541
- .run(this.profile, decisionId, chatId, messageId);
549
+ const localId = transportMessageId(this.channelId, decisionId, chatId, messageId);
550
+ this.mutate("channel.message_confirmed", { decisionId, chatId, messageId }, () => {
551
+ const contentHash = this.state.putJson({ chatId, messageId });
552
+ this.state.connection
553
+ .prepare(
554
+ `INSERT INTO channel_messages(
555
+ message_id, channel_id, decision_id, purpose, content_hash,
556
+ external_conversation_ref, external_message_ref, status, created_at, updated_at
557
+ ) VALUES (?, ?, ?, 'delivery', ?, ?, ?, 'confirmed', ?, ?)
558
+ ON CONFLICT(message_id) DO NOTHING`,
559
+ )
560
+ .run(
561
+ localId,
562
+ this.channelId,
563
+ decisionId,
564
+ contentHash,
565
+ chatId,
566
+ messageId,
567
+ Date.now(),
568
+ Date.now(),
569
+ );
570
+ });
542
571
  }
543
572
 
544
573
  messages(decisionId: string): MessageRow[] {
545
- return this.db
574
+ const rows = this.state.connection
546
575
  .prepare(
547
- "SELECT chat_id, message_id FROM messages WHERE profile = ? AND decision_id = ? ORDER BY chat_id, message_id",
576
+ `SELECT external_conversation_ref AS chatId, external_message_ref AS messageId
577
+ FROM channel_messages
578
+ WHERE channel_id = ? AND decision_id = ? AND purpose = 'delivery'
579
+ AND external_conversation_ref IS NOT NULL AND external_message_ref IS NOT NULL
580
+ ORDER BY external_conversation_ref, external_message_ref`,
548
581
  )
549
- .all(this.profile, decisionId) as MessageRow[];
582
+ .all(this.channelId, decisionId);
583
+ return rows.filter(isTransportMessageRow).map((row) => ({
584
+ chat_id: row.chatId,
585
+ message_id: row.messageId,
586
+ }));
550
587
  }
551
588
 
552
589
  putMessagePart(
@@ -557,11 +594,35 @@ class TelegramProjection {
557
594
  chatId: string,
558
595
  messageId: string,
559
596
  ): void {
560
- this.db
561
- .prepare(
562
- "INSERT INTO message_parts(profile, decision_id, recipient_index, part_index, content_digest, chat_id, message_id) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(profile, decision_id, recipient_index, part_index) DO UPDATE SET content_digest = excluded.content_digest, chat_id = excluded.chat_id, message_id = excluded.message_id",
563
- )
564
- .run(this.profile, decisionId, recipientIndex, partIndex, contentDigest, chatId, messageId);
597
+ const localId = `telegram-parts-${createHash("sha256")
598
+ .update(`${this.channelId}\0${decisionId}`)
599
+ .digest("hex")
600
+ .slice(0, 40)}`;
601
+ this.mutate("channel.message_part_confirmed", { decisionId, recipientIndex, partIndex }, () => {
602
+ const parentHash = this.state.putJson({ decisionId });
603
+ this.state.connection
604
+ .prepare(
605
+ `INSERT INTO channel_messages(
606
+ message_id, channel_id, decision_id, purpose, content_hash,
607
+ status, created_at, updated_at
608
+ ) VALUES (?, ?, ?, 'delivery', ?, 'confirmed', ?, ?)
609
+ ON CONFLICT(message_id) DO NOTHING`,
610
+ )
611
+ .run(localId, this.channelId, decisionId, parentHash, Date.now(), Date.now());
612
+ const contentHash = this.state.putText(contentDigest);
613
+ this.state.connection
614
+ .prepare(
615
+ `INSERT INTO channel_message_parts(
616
+ message_id, recipient_index, part_index, content_hash,
617
+ external_conversation_ref, external_message_ref
618
+ ) VALUES (?, ?, ?, ?, ?, ?)
619
+ ON CONFLICT(message_id, recipient_index, part_index) DO UPDATE SET
620
+ content_hash = excluded.content_hash,
621
+ external_conversation_ref = excluded.external_conversation_ref,
622
+ external_message_ref = excluded.external_message_ref`,
623
+ )
624
+ .run(localId, recipientIndex, partIndex, contentHash, chatId, messageId);
625
+ });
565
626
  }
566
627
 
567
628
  messagePart(
@@ -569,14 +630,184 @@ class TelegramProjection {
569
630
  recipientIndex: number,
570
631
  partIndex: number,
571
632
  ): MessagePartRow | undefined {
572
- return this.db
633
+ const localId = `telegram-parts-${createHash("sha256")
634
+ .update(`${this.channelId}\0${decisionId}`)
635
+ .digest("hex")
636
+ .slice(0, 40)}`;
637
+ const row = this.state.connection
638
+ .prepare(
639
+ `SELECT recipient_index AS recipientIndex, part_index AS partIndex,
640
+ content_hash AS contentHash,
641
+ external_conversation_ref AS chatId,
642
+ external_message_ref AS messageId
643
+ FROM channel_message_parts
644
+ WHERE message_id = ? AND recipient_index = ? AND part_index = ?`,
645
+ )
646
+ .get(localId, recipientIndex, partIndex);
647
+ if (!isMessagePartProjectionRow(row)) return undefined;
648
+ const content = this.state.readBlob(row.contentHash);
649
+ /* istanbul ignore if -- foreign key guarantees the content blob */
650
+ if (content === undefined) throw new Error("Telegram part content is missing");
651
+ return {
652
+ recipient_index: row.recipientIndex,
653
+ part_index: row.partIndex,
654
+ content_digest: content.content.toString("utf8"),
655
+ chat_id: row.chatId,
656
+ message_id: row.messageId,
657
+ };
658
+ }
659
+
660
+ private putInbox(
661
+ eventId: string,
662
+ type: "callback" | "reply",
663
+ binding: CallbackBinding | ReplyBinding,
664
+ ): void {
665
+ this.mutate(`channel.${type}_stored`, { eventId }, () => {
666
+ const payloadHash = this.state.putJson(binding);
667
+ this.state.connection
668
+ .prepare(
669
+ `INSERT INTO channel_inbox(
670
+ channel_id, external_event_id, event_type, payload_hash, received_at
671
+ ) VALUES (?, ?, ?, ?, ?)
672
+ ON CONFLICT(channel_id, external_event_id) DO UPDATE SET payload_hash = excluded.payload_hash`,
673
+ )
674
+ .run(this.channelId, eventId, type, payloadHash, Date.now());
675
+ });
676
+ }
677
+
678
+ private inbox<T>(eventId: string): T | undefined {
679
+ const row = this.state.connection
680
+ .prepare(
681
+ "SELECT payload_hash AS payloadHash FROM channel_inbox WHERE channel_id = ? AND external_event_id = ?",
682
+ )
683
+ .get(this.channelId, eventId);
684
+ return isPayloadHashRow(row) ? (this.state.readJson(row.payloadHash) as T) : undefined;
685
+ }
686
+
687
+ private mutate(type: string, payload: unknown, operation: () => void): void {
688
+ this.state.transaction(() => {
689
+ this.assertOwner();
690
+ operation();
691
+ this.recordMutation(type, payload, Date.now());
692
+ });
693
+ }
694
+
695
+ private recordMutation(type: string, payload: unknown, now: number): void {
696
+ const revisionRow = this.state.connection
697
+ .prepare("SELECT revision FROM resources WHERE resource_id = ?")
698
+ .get(this.resourceId);
699
+ /* istanbul ignore if -- exact schema and internal query shape */
700
+ /* istanbul ignore if -- impossible after exact schema and transaction checks */
701
+ if (!isRevisionProjectionRow(revisionRow))
702
+ throw new Error("Telegram channel resource is missing");
703
+ const revision = revisionRow.revision + 1;
704
+ this.state.connection
573
705
  .prepare(
574
- "SELECT recipient_index, part_index, content_digest, chat_id, message_id FROM message_parts WHERE profile = ? AND decision_id = ? AND recipient_index = ? AND part_index = ?",
706
+ "UPDATE resources SET revision = ?, updated_at = ? WHERE resource_id = ? AND revision = ?",
575
707
  )
576
- .get(this.profile, decisionId, recipientIndex, partIndex) as MessagePartRow | undefined;
708
+ .run(revision, now, this.resourceId, revision - 1);
709
+ const payloadHash = this.state.putJson(payload, now);
710
+ this.state.connection
711
+ .prepare(
712
+ `INSERT INTO events(
713
+ event_id, resource_id, resource_revision, event_type, actor_type,
714
+ actor_id, lease_generation, payload_hash, recorded_at
715
+ ) VALUES (?, ?, ?, ?, 'channel', ?, ?, ?, ?)`,
716
+ )
717
+ .run(
718
+ `event-${randomUUID()}`,
719
+ this.resourceId,
720
+ revision,
721
+ type,
722
+ this.owner,
723
+ this.generation === 0 ? null : this.generation,
724
+ payloadHash,
725
+ now,
726
+ );
727
+ }
728
+
729
+ private assertOwner(): void {
730
+ const lease = this.lease();
731
+ if (
732
+ lease.ownerId !== this.owner ||
733
+ lease.generation !== this.generation ||
734
+ lease.tokenHash === null ||
735
+ !lease.tokenHash.equals(tokenHash(this.token)) ||
736
+ lease.expiresAt === null ||
737
+ lease.expiresAt <= Date.now()
738
+ ) {
739
+ throw new Error(`Telegram profile ${this.profile} is not owned by this channel process`);
740
+ }
741
+ }
742
+
743
+ private lease(): TelegramLeaseRow {
744
+ const row = this.state.connection
745
+ .prepare(
746
+ `SELECT generation, owner_id AS ownerId, token_hash AS tokenHash,
747
+ expires_at AS expiresAt
748
+ FROM leases WHERE resource_id = ?`,
749
+ )
750
+ .get(this.resourceId);
751
+ /* istanbul ignore if -- exact schema and internal query shape */
752
+ /* istanbul ignore if -- impossible after exact schema and transaction checks */
753
+ if (!isTelegramLeaseRow(row)) throw new Error("Telegram channel lease is missing");
754
+ return row;
577
755
  }
578
756
  }
579
757
 
758
+ function transportMessageId(
759
+ channelId: string,
760
+ decisionId: string,
761
+ chatId: string,
762
+ messageId: string,
763
+ ): string {
764
+ return `telegram-message-${createHash("sha256")
765
+ .update(`${channelId}\0${decisionId}\0${chatId}\0${messageId}`)
766
+ .digest("hex")
767
+ .slice(0, 40)}`;
768
+ }
769
+
770
+ type TelegramLeaseRow = {
771
+ generation: number;
772
+ ownerId: string | null;
773
+ tokenHash: Buffer | null;
774
+ expiresAt: number | null;
775
+ };
776
+
777
+ function isTelegramLeaseRow(value: unknown): value is TelegramLeaseRow {
778
+ return isProjectionRecord(value);
779
+ }
780
+
781
+ function isCursorRow(value: unknown): value is { cursorValue: string } {
782
+ return isProjectionRecord(value);
783
+ }
784
+
785
+ function isPayloadHashRow(value: unknown): value is { payloadHash: Buffer } {
786
+ return isProjectionRecord(value);
787
+ }
788
+
789
+ function isTransportMessageRow(value: unknown): value is { chatId: string; messageId: string } {
790
+ return isProjectionRecord(value);
791
+ }
792
+
793
+ function isMessagePartProjectionRow(value: unknown): value is {
794
+ recipientIndex: number;
795
+ partIndex: number;
796
+ contentHash: Buffer;
797
+ chatId: string;
798
+ messageId: string;
799
+ } {
800
+ return isProjectionRecord(value);
801
+ }
802
+
803
+ function isRevisionProjectionRow(value: unknown): value is { revision: number } {
804
+ return isProjectionRecord(value);
805
+ }
806
+
807
+ function isProjectionRecord(value: unknown): value is Record<string, unknown> {
808
+ return typeof value === "object" && value !== null && !Array.isArray(value);
809
+ }
810
+
580
811
  class TelegramCallError extends Error {
581
812
  constructor(
582
813
  message: string,
@@ -628,7 +859,7 @@ export class TelegramDecisionChannel implements HumanDecisionChannel {
628
859
  this.fetchFn = options.fetchFn ?? (fetch as TelegramFetch);
629
860
  this.apiBase = options.apiBase ?? DEFAULT_API_BASE;
630
861
  this.projection = new TelegramProjection(
631
- path.join(options.configDir ?? decisionConfigDir(), "telegram-state.sqlite"),
862
+ options.store.state,
632
863
  options.profileName,
633
864
  options.ownerId ?? randomUUID(),
634
865
  );
@@ -653,6 +884,33 @@ export class TelegramDecisionChannel implements HumanDecisionChannel {
653
884
 
654
885
  async deliver(request: HumanDecisionChannelRequest): Promise<HumanDecisionDeliveryResult> {
655
886
  const channelPath = this.channelPath();
887
+ if (!this.projection.ownsLease()) {
888
+ const attemptId = `queued-${createHash("sha256")
889
+ .update(`${this.id}\0${request.decisionId}\0${request.requestDigest}`)
890
+ .digest("hex")
891
+ .slice(0, 40)}`;
892
+ await this.store.recordDelivery(
893
+ request,
894
+ channelPath,
895
+ deliveryRecord(
896
+ request,
897
+ this.id,
898
+ attemptId,
899
+ "complete",
900
+ "intent",
901
+ new Date().toISOString(),
902
+ {
903
+ errorCode: "queued_for_channel_owner",
904
+ },
905
+ ),
906
+ );
907
+ return {
908
+ status: "unknown",
909
+ channel: this.id,
910
+ attemptId,
911
+ errorCode: "queued_for_channel_owner",
912
+ };
913
+ }
656
914
  const parts = renderTelegramParts(request);
657
915
  const partDigests = parts.map((part) => digestCanonical(part));
658
916
  const prior = await this.store.listDeliveries(request.decisionId, channelPath);
@@ -684,7 +942,7 @@ export class TelegramDecisionChannel implements HumanDecisionChannel {
684
942
  await this.store.recordDelivery(
685
943
  request,
686
944
  channelPath,
687
- v2DeliveryRecord(request, this.id, attemptId, "intent", "intent", createdAt, {
945
+ deliveryRecord(request, this.id, attemptId, "intent", "intent", createdAt, {
688
946
  partCount: parts.length,
689
947
  }),
690
948
  );
@@ -704,7 +962,6 @@ export class TelegramDecisionChannel implements HumanDecisionChannel {
704
962
  );
705
963
  const confirmed = prior.some(
706
964
  (record) =>
707
- record.schema === "pi-workflows.human-decision-delivery.v2" &&
708
965
  record.phase === "part" &&
709
966
  record.state === "confirmed" &&
710
967
  record.recipientIndex === recipientIndex &&
@@ -724,7 +981,7 @@ export class TelegramDecisionChannel implements HumanDecisionChannel {
724
981
  await this.store.recordDelivery(
725
982
  request,
726
983
  channelPath,
727
- v2DeliveryRecord(
984
+ deliveryRecord(
728
985
  request,
729
986
  this.id,
730
987
  `${attemptId}-r${recipientIndex}-p${partIndex}-unknown`,
@@ -740,15 +997,12 @@ export class TelegramDecisionChannel implements HumanDecisionChannel {
740
997
  await this.store.recordDelivery(
741
998
  request,
742
999
  channelPath,
743
- v2DeliveryRecord(
744
- request,
745
- this.id,
746
- `${partAttemptId}-intent`,
747
- "part",
748
- "intent",
749
- createdAt,
750
- { recipientIndex, partIndex, partCount: parts.length, contentDigest },
751
- ),
1000
+ deliveryRecord(request, this.id, `${partAttemptId}-intent`, "part", "intent", createdAt, {
1001
+ recipientIndex,
1002
+ partIndex,
1003
+ partCount: parts.length,
1004
+ contentDigest,
1005
+ }),
752
1006
  );
753
1007
  try {
754
1008
  const result = await this.call("sendMessage", {
@@ -769,7 +1023,7 @@ export class TelegramDecisionChannel implements HumanDecisionChannel {
769
1023
  await this.store.recordDelivery(
770
1024
  request,
771
1025
  channelPath,
772
- v2DeliveryRecord(
1026
+ deliveryRecord(
773
1027
  request,
774
1028
  this.id,
775
1029
  `${partAttemptId}-confirmed`,
@@ -785,7 +1039,7 @@ export class TelegramDecisionChannel implements HumanDecisionChannel {
785
1039
  await this.store.recordDelivery(
786
1040
  request,
787
1041
  channelPath,
788
- v2DeliveryRecord(
1042
+ deliveryRecord(
789
1043
  request,
790
1044
  this.id,
791
1045
  `${partAttemptId}-${state}`,
@@ -804,7 +1058,7 @@ export class TelegramDecisionChannel implements HumanDecisionChannel {
804
1058
  await this.store.recordDelivery(
805
1059
  request,
806
1060
  channelPath,
807
- v2DeliveryRecord(
1061
+ deliveryRecord(
808
1062
  request,
809
1063
  this.id,
810
1064
  `${attemptId}-${state}`,
@@ -821,7 +1075,7 @@ export class TelegramDecisionChannel implements HumanDecisionChannel {
821
1075
  await this.store.recordDelivery(
822
1076
  request,
823
1077
  channelPath,
824
- v2DeliveryRecord(
1078
+ deliveryRecord(
825
1079
  request,
826
1080
  this.id,
827
1081
  `${attemptId}-confirmed`,
@@ -973,6 +1227,7 @@ export class TelegramDecisionChannel implements HumanDecisionChannel {
973
1227
  continue;
974
1228
  }
975
1229
  try {
1230
+ await this.deliverPendingRequests();
976
1231
  const result = await this.call(
977
1232
  "getUpdates",
978
1233
  {
@@ -996,6 +1251,28 @@ export class TelegramDecisionChannel implements HumanDecisionChannel {
996
1251
  }
997
1252
  }
998
1253
 
1254
+ private async deliverPendingRequests(): Promise<void> {
1255
+ const channelPath = this.channelPath();
1256
+ for (const request of await this.store.listRequests()) {
1257
+ if (request.expiresAt !== undefined && Date.parse(request.expiresAt) <= Date.now()) continue;
1258
+ if ((await this.store.readResolved(request.decisionId)) !== null) continue;
1259
+ if ((await this.store.readCancellation(request.decisionId)) !== null) continue;
1260
+ const deliveries = await this.store.listDeliveries(request.decisionId, channelPath);
1261
+ if (
1262
+ !deliveries.some(
1263
+ (record) => record.state === "intent" && record.errorCode === "queued_for_channel_owner",
1264
+ )
1265
+ ) {
1266
+ continue;
1267
+ }
1268
+ try {
1269
+ await this.deliver(humanDecisionChannelRequest(request));
1270
+ } catch {
1271
+ // Durable delivery evidence controls retries for each request.
1272
+ }
1273
+ }
1274
+ }
1275
+
999
1276
  private async call(method: string, payload: unknown, signal?: AbortSignal): Promise<unknown> {
1000
1277
  let response: Awaited<ReturnType<TelegramFetch>>;
1001
1278
  try {
@@ -1078,16 +1355,18 @@ export function createTelegramChannels(options: {
1078
1355
 
1079
1356
  export async function verifyTelegramTokenFile(
1080
1357
  tokenFile: string,
1081
- fetchFn: TelegramFetch = fetch as TelegramFetch,
1358
+ fetchFn?: TelegramFetch,
1082
1359
  apiBase = DEFAULT_API_BASE,
1083
1360
  ): Promise<void> {
1361
+ /* istanbul ignore next -- global fetch is used only by operator setup */
1362
+ const request = fetchFn ?? (fetch as TelegramFetch);
1084
1363
  if (!path.isAbsolute(tokenFile)) throw new Error("Telegram token file path must be absolute");
1085
1364
  await requirePrivateFile(tokenFile, "Telegram token file");
1086
1365
  const token = (await fsp.readFile(tokenFile, "utf8")).trim();
1087
1366
  if (token.length === 0) throw new Error("Telegram token file is empty");
1088
1367
  let response;
1089
1368
  try {
1090
- response = await fetchFn(`${apiBase}/bot${encodeURIComponent(token)}/getMe`, {
1369
+ response = await request(`${apiBase}/bot${encodeURIComponent(token)}/getMe`, {
1091
1370
  method: "POST",
1092
1371
  headers: { "content-type": "application/json" },
1093
1372
  body: "{}",
@@ -1234,29 +1513,26 @@ function parseCredentialConfig(value: unknown): DecisionCredentialConfig {
1234
1513
  return { schema: "pi-workflows.credentials.v1", telegram };
1235
1514
  }
1236
1515
 
1237
- type DeliveryV2 = Extract<
1238
- HumanDecisionDeliveryRecord,
1239
- { schema: "pi-workflows.human-decision-delivery.v2" }
1240
- >;
1516
+ type DeliveryRecord = HumanDecisionDeliveryRecord;
1241
1517
 
1242
- type DeliveryV2Extra = Partial<
1518
+ type DeliveryRecordExtra = Partial<
1243
1519
  Pick<
1244
- DeliveryV2,
1520
+ DeliveryRecord,
1245
1521
  "recipientIndex" | "partIndex" | "partCount" | "contentDigest" | "messageCount" | "errorCode"
1246
1522
  >
1247
1523
  >;
1248
1524
 
1249
- function v2DeliveryRecord(
1525
+ function deliveryRecord(
1250
1526
  request: HumanDecisionChannelRequest,
1251
1527
  channel: string,
1252
1528
  attemptId: string,
1253
- phase: DeliveryV2["phase"],
1254
- state: DeliveryV2["state"],
1529
+ phase: DeliveryRecord["phase"],
1530
+ state: DeliveryRecord["state"],
1255
1531
  createdAt: string,
1256
- extra: DeliveryV2Extra = {},
1257
- ): DeliveryV2 {
1532
+ extra: DeliveryRecordExtra = {},
1533
+ ): DeliveryRecord {
1258
1534
  return {
1259
- schema: "pi-workflows.human-decision-delivery.v2",
1535
+ schema: "pi-workflows.human-decision-delivery.v1",
1260
1536
  attemptId,
1261
1537
  decisionId: request.decisionId,
1262
1538
  requestDigest: request.requestDigest,
@@ -1271,37 +1547,14 @@ function v2DeliveryRecord(
1271
1547
  }
1272
1548
 
1273
1549
  function isCompleteDelivery(record: HumanDecisionDeliveryRecord): boolean {
1274
- return (
1275
- record.state === "confirmed" &&
1276
- (record.schema === "pi-workflows.human-decision-delivery.v1" || record.phase === "complete")
1277
- );
1550
+ return record.state === "confirmed" && record.phase === "complete";
1278
1551
  }
1279
1552
 
1280
1553
  function hasUnsettledDeliveryIntent(records: HumanDecisionDeliveryRecord[]): boolean {
1281
- const hasLegacyIntent = records.some(
1282
- (record) =>
1283
- record.schema === "pi-workflows.human-decision-delivery.v1" && record.state === "intent",
1284
- );
1285
- if (
1286
- hasLegacyIntent &&
1287
- !records.some(
1288
- (record) =>
1289
- record.schema === "pi-workflows.human-decision-delivery.v1" && record.state !== "intent",
1290
- )
1291
- ) {
1292
- return true;
1293
- }
1294
1554
  return records.some((record) => {
1295
- if (
1296
- record.schema !== "pi-workflows.human-decision-delivery.v2" ||
1297
- record.phase !== "part" ||
1298
- record.state !== "intent"
1299
- ) {
1300
- return false;
1301
- }
1555
+ if (record.phase !== "part" || record.state !== "intent") return false;
1302
1556
  return !records.some(
1303
1557
  (other) =>
1304
- other.schema === "pi-workflows.human-decision-delivery.v2" &&
1305
1558
  other.phase === "part" &&
1306
1559
  other.state !== "intent" &&
1307
1560
  other.recipientIndex === record.recipientIndex &&