@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,11 +1,10 @@
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 { Key, matchesKey, wrapTextWithAnsi } from "@earendil-works/pi-tui";
7
- import Database from "better-sqlite3";
8
- import { MAX_PRESENTATION_TRANSPORT_PARTS, decisionDocumentSegments, decisionPresentationFingerprint, digestCanonical, } from "../workflows/decision-presentation.js";
6
+ import { resourceIdFor, tokenHash } from "../state/mutation.js";
7
+ import { MAX_PRESENTATION_TRANSPORT_PARTS, decisionDocumentSegments, decisionPresentationFingerprint, digestCanonical, humanDecisionChannelRequest, } from "../workflows/decision-presentation.js";
9
8
  import { HumanDecisionStore, createHumanDecisionAttemptId } from "../workflows/human-decision.js";
10
9
  const TELEGRAM_TEXT_LIMIT = 4_096;
11
10
  const PI_PRESENTATION_WINDOW_LINES = 18;
@@ -68,12 +67,15 @@ export class PiDecisionChannel {
68
67
  this.promptAbort?.abort();
69
68
  }
70
69
  async deliver(request) {
70
+ if (await this.alreadyDecided(request.decisionId)) {
71
+ return { status: "confirmed", channel: this.id, attemptId: "adopted" };
72
+ }
71
73
  const attemptId = createHumanDecisionAttemptId();
72
74
  const createdAt = new Date().toISOString();
73
75
  const controller = new AbortController();
74
76
  this.promptAbort = controller;
75
77
  try {
76
- await this.options.store.recordDelivery(request, this.id, v2DeliveryRecord(request, this.id, attemptId, "intent", "intent", createdAt));
78
+ await this.options.store.recordDelivery(request, this.id, deliveryRecord(request, this.id, attemptId, "intent", "intent", createdAt));
77
79
  const entries = Object.entries(request.choices);
78
80
  const selectedChoice = await this.options.ui.custom((tui, theme, _keybindings, done) => {
79
81
  let choiceIndex = 0;
@@ -139,7 +141,7 @@ export class PiDecisionChannel {
139
141
  const errorCode = controller.signal.aborted
140
142
  ? "pi_selection_settled_elsewhere"
141
143
  : "pi_selection_cancelled";
142
- await this.options.store.recordDelivery(request, this.id, v2DeliveryRecord(request, this.id, `${attemptId}-cancelled`, "complete", "failed", createdAt, { errorCode }));
144
+ await this.options.store.recordDelivery(request, this.id, deliveryRecord(request, this.id, `${attemptId}-cancelled`, "complete", "failed", createdAt, { errorCode }));
143
145
  return { status: "failed", channel: this.id, attemptId, errorCode };
144
146
  }
145
147
  const definition = request.choices[selectedChoice];
@@ -154,11 +156,14 @@ export class PiDecisionChannel {
154
156
  const errorCode = controller.signal.aborted
155
157
  ? "pi_input_settled_elsewhere"
156
158
  : "pi_input_cancelled";
157
- await this.options.store.recordDelivery(request, this.id, v2DeliveryRecord(request, this.id, `${attemptId}-cancelled`, "complete", "failed", createdAt, { errorCode }));
159
+ await this.options.store.recordDelivery(request, this.id, deliveryRecord(request, this.id, `${attemptId}-cancelled`, "complete", "failed", createdAt, { errorCode }));
158
160
  return { status: "failed", channel: this.id, attemptId, errorCode };
159
161
  }
160
162
  response = { choice: selectedChoice, input: { [definition.input.name]: text } };
161
163
  }
164
+ if (await this.alreadyDecided(request.decisionId)) {
165
+ return { status: "confirmed", channel: this.id, attemptId: "adopted" };
166
+ }
162
167
  const eventId = createHumanDecisionAttemptId();
163
168
  await this.options.onAnswer({
164
169
  request,
@@ -166,7 +171,7 @@ export class PiDecisionChannel {
166
171
  source: { channel: this.id, actorId: this.options.actorId, eventId },
167
172
  idempotencyKey: `${this.id}:${eventId}`,
168
173
  });
169
- await this.options.store.recordDelivery(request, this.id, v2DeliveryRecord(request, this.id, `${attemptId}-confirmed`, "complete", "confirmed", createdAt, { messageCount: 1 }));
174
+ await this.options.store.recordDelivery(request, this.id, deliveryRecord(request, this.id, `${attemptId}-confirmed`, "complete", "confirmed", createdAt, { messageCount: 1 }));
170
175
  return { status: "confirmed", channel: this.id, attemptId };
171
176
  }
172
177
  finally {
@@ -174,6 +179,10 @@ export class PiDecisionChannel {
174
179
  this.promptAbort = null;
175
180
  }
176
181
  }
182
+ async alreadyDecided(decisionId) {
183
+ return ((await this.options.store.readResolved(decisionId)) !== null ||
184
+ (await this.options.store.readCancellation(decisionId)) !== null);
185
+ }
177
186
  async settle(decision) {
178
187
  this.promptAbort?.abort();
179
188
  if (this.settlementTask !== null)
@@ -207,156 +216,301 @@ export class PiDecisionChannel {
207
216
  }
208
217
  }
209
218
  class TelegramProjection {
219
+ state;
210
220
  profile;
211
221
  owner;
212
- db;
213
- constructor(filePath, profile, owner) {
222
+ channelId;
223
+ resourceId;
224
+ token = randomBytes(32).toString("base64url");
225
+ generation = 0;
226
+ constructor(state, profile, owner) {
227
+ this.state = state;
214
228
  this.profile = profile;
215
229
  this.owner = owner;
216
- fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
217
- this.db = new Database(filePath);
218
- fs.chmodSync(filePath, 0o600);
219
- this.db.pragma("journal_mode = WAL");
220
- this.db.pragma("busy_timeout = 5000");
221
- this.db.exec(`
222
- CREATE TABLE IF NOT EXISTS leases (
223
- profile TEXT PRIMARY KEY,
224
- owner TEXT NOT NULL,
225
- expires_at INTEGER NOT NULL
226
- );
227
- CREATE TABLE IF NOT EXISTS offsets (
228
- profile TEXT PRIMARY KEY,
229
- next_offset INTEGER NOT NULL
230
- );
231
- CREATE TABLE IF NOT EXISTS callbacks (
232
- profile TEXT NOT NULL,
233
- token TEXT NOT NULL,
234
- request_json TEXT NOT NULL,
235
- choice_id TEXT NOT NULL,
236
- PRIMARY KEY (profile, token)
237
- );
238
- CREATE TABLE IF NOT EXISTS replies (
239
- profile TEXT NOT NULL,
240
- chat_id TEXT NOT NULL,
241
- message_id TEXT NOT NULL,
242
- request_json TEXT NOT NULL,
243
- choice_id TEXT NOT NULL,
244
- PRIMARY KEY (profile, chat_id, message_id)
245
- );
246
- CREATE TABLE IF NOT EXISTS messages (
247
- profile TEXT NOT NULL,
248
- decision_id TEXT NOT NULL,
249
- chat_id TEXT NOT NULL,
250
- message_id TEXT NOT NULL,
251
- PRIMARY KEY (profile, decision_id, chat_id, message_id)
252
- );
253
- CREATE TABLE IF NOT EXISTS message_parts (
254
- profile TEXT NOT NULL,
255
- decision_id TEXT NOT NULL,
256
- recipient_index INTEGER NOT NULL,
257
- part_index INTEGER NOT NULL,
258
- content_digest TEXT NOT NULL,
259
- chat_id TEXT NOT NULL,
260
- message_id TEXT NOT NULL,
261
- PRIMARY KEY (profile, decision_id, recipient_index, part_index)
262
- );
263
- `);
230
+ this.channelId = `telegram-${createHash("sha256").update(profile).digest("hex").slice(0, 40)}`;
231
+ this.resourceId = resourceIdFor("channel", this.channelId);
232
+ this.state.transaction(() => {
233
+ const now = Date.now();
234
+ this.state.connection
235
+ .prepare(`INSERT INTO resources(
236
+ resource_id, resource_type, aggregate_key, revision, created_at, updated_at
237
+ ) VALUES (?, 'channel', ?, 1, ?, ?)
238
+ ON CONFLICT(resource_type, aggregate_key) DO NOTHING`)
239
+ .run(this.resourceId, this.channelId, now, now);
240
+ this.state.connection
241
+ .prepare("INSERT INTO leases(resource_id, generation) VALUES (?, 0) ON CONFLICT(resource_id) DO NOTHING")
242
+ .run(this.resourceId);
243
+ this.state.connection
244
+ .prepare(`INSERT INTO channels(channel_id, resource_id, adapter_type, profile_key, created_at)
245
+ VALUES (?, ?, 'telegram', ?, ?) ON CONFLICT(channel_id) DO NOTHING`)
246
+ .run(this.channelId, this.resourceId, this.profile, now);
247
+ });
264
248
  }
265
249
  close() {
266
250
  this.release();
267
- this.db.close();
251
+ }
252
+ ownsLease() {
253
+ try {
254
+ this.assertOwner();
255
+ return true;
256
+ }
257
+ catch {
258
+ return false;
259
+ }
268
260
  }
269
261
  acquire(now = Date.now()) {
270
- const transaction = this.db.transaction(() => {
271
- const row = this.db
272
- .prepare("SELECT owner, expires_at FROM leases WHERE profile = ?")
273
- .get(this.profile);
274
- if (row !== undefined && row.owner !== this.owner && row.expires_at > now)
262
+ return this.state.transaction(() => {
263
+ const lease = this.lease();
264
+ if (lease.ownerId !== null &&
265
+ lease.ownerId !== this.owner &&
266
+ lease.expiresAt !== null &&
267
+ lease.expiresAt > now) {
275
268
  return false;
276
- this.db
277
- .prepare("INSERT INTO leases(profile, owner, expires_at) VALUES (?, ?, ?) ON CONFLICT(profile) DO UPDATE SET owner = excluded.owner, expires_at = excluded.expires_at")
278
- .run(this.profile, this.owner, now + LEASE_TTL_MS);
269
+ }
270
+ const generation = lease.ownerId === this.owner ? lease.generation : lease.generation + 1;
271
+ const result = this.state.connection
272
+ .prepare(`UPDATE leases
273
+ SET generation = ?, owner_type = 'channel', owner_id = ?, token_hash = ?,
274
+ acquired_at = COALESCE(acquired_at, ?), heartbeat_at = ?, expires_at = ?
275
+ WHERE resource_id = ? AND generation = ?`)
276
+ .run(generation, this.owner, tokenHash(this.token), now, now, now + LEASE_TTL_MS, this.resourceId, lease.generation);
277
+ /* istanbul ignore if -- impossible after exact schema and transaction checks */
278
+ if (result.changes !== 1)
279
+ return false;
280
+ this.generation = generation;
281
+ this.recordMutation("channel.lease_acquired", { expiresAt: now + LEASE_TTL_MS }, now);
279
282
  return true;
280
283
  });
281
- return transaction();
282
284
  }
283
285
  renew(now = Date.now()) {
284
- return (this.db
285
- .prepare("UPDATE leases SET expires_at = ? WHERE profile = ? AND owner = ?")
286
- .run(now + LEASE_TTL_MS, this.profile, this.owner).changes === 1);
286
+ return (this.state.connection
287
+ .prepare(`UPDATE leases SET heartbeat_at = ?, expires_at = ?
288
+ WHERE resource_id = ? AND owner_id = ? AND token_hash = ? AND generation = ?`)
289
+ .run(now, now + LEASE_TTL_MS, this.resourceId, this.owner, tokenHash(this.token), this.generation).changes === 1);
287
290
  }
288
291
  release() {
289
- this.db
290
- .prepare("DELETE FROM leases WHERE profile = ? AND owner = ?")
291
- .run(this.profile, this.owner);
292
+ this.state.transaction(() => {
293
+ const result = this.state.connection
294
+ .prepare(`UPDATE leases
295
+ SET owner_type = NULL, owner_id = NULL, token_hash = NULL,
296
+ acquired_at = NULL, heartbeat_at = NULL, expires_at = NULL
297
+ WHERE resource_id = ? AND owner_id = ? AND token_hash = ? AND generation = ?`)
298
+ .run(this.resourceId, this.owner, tokenHash(this.token), this.generation);
299
+ if (result.changes === 1) {
300
+ this.recordMutation("channel.lease_released", {}, Date.now());
301
+ }
302
+ });
292
303
  }
293
304
  offset() {
294
- return (this.db.prepare("SELECT next_offset FROM offsets WHERE profile = ?").get(this.profile)?.next_offset ?? 0);
305
+ const row = this.state.connection
306
+ .prepare("SELECT cursor_value AS cursorValue FROM channel_cursors WHERE channel_id = ? AND cursor_key = 'telegram_update'")
307
+ .get(this.channelId);
308
+ return isCursorRow(row) ? Number.parseInt(row.cursorValue, 10) || 0 : 0;
295
309
  }
296
310
  setOffset(offset) {
297
- this.db
298
- .prepare("INSERT INTO offsets(profile, next_offset) VALUES (?, ?) ON CONFLICT(profile) DO UPDATE SET next_offset = MAX(next_offset, excluded.next_offset)")
299
- .run(this.profile, offset);
311
+ this.mutate("channel.cursor_updated", { offset }, () => {
312
+ this.state.connection
313
+ .prepare(`INSERT INTO channel_cursors(channel_id, cursor_key, cursor_value, updated_at)
314
+ VALUES (?, 'telegram_update', ?, ?)
315
+ ON CONFLICT(channel_id, cursor_key) DO UPDATE SET
316
+ cursor_value = CAST(MAX(CAST(channel_cursors.cursor_value AS INTEGER), CAST(excluded.cursor_value AS INTEGER)) AS TEXT),
317
+ updated_at = excluded.updated_at`)
318
+ .run(this.channelId, String(offset), Date.now());
319
+ });
300
320
  }
301
321
  putCallback(token, binding) {
302
- this.db
303
- .prepare("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")
304
- .run(this.profile, token, JSON.stringify(binding.request), binding.choice);
322
+ this.putInbox(`callback:${token}`, "callback", binding);
305
323
  }
306
324
  callback(token) {
307
- const row = this.db
308
- .prepare("SELECT request_json, choice_id FROM callbacks WHERE profile = ? AND token = ?")
309
- .get(this.profile, token);
310
- return row === undefined
311
- ? undefined
312
- : {
313
- request: JSON.parse(row.request_json),
314
- choice: row.choice_id,
315
- };
325
+ return this.inbox(`callback:${token}`);
316
326
  }
317
327
  putReply(binding) {
318
- this.db
319
- .prepare("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")
320
- .run(this.profile, binding.chatId, binding.promptMessageId, JSON.stringify(binding.request), binding.choice);
328
+ this.putInbox(`reply:${binding.chatId}:${binding.promptMessageId}`, "reply", binding);
321
329
  }
322
330
  reply(chatId, messageId) {
323
- const row = this.db
324
- .prepare("SELECT request_json, choice_id, chat_id, message_id FROM replies WHERE profile = ? AND chat_id = ? AND message_id = ?")
325
- .get(this.profile, chatId, messageId);
326
- return row === undefined
327
- ? undefined
328
- : {
329
- request: JSON.parse(row.request_json),
330
- choice: row.choice_id,
331
- chatId: row.chat_id,
332
- promptMessageId: row.message_id,
333
- };
331
+ return this.inbox(`reply:${chatId}:${messageId}`);
334
332
  }
335
333
  deleteReply(chatId, messageId) {
336
- this.db
337
- .prepare("DELETE FROM replies WHERE profile = ? AND chat_id = ? AND message_id = ?")
338
- .run(this.profile, chatId, messageId);
334
+ this.mutate("channel.reply_consumed", { chatId, messageId }, () => {
335
+ this.state.connection
336
+ .prepare("DELETE FROM channel_inbox WHERE channel_id = ? AND external_event_id = ?")
337
+ .run(this.channelId, `reply:${chatId}:${messageId}`);
338
+ });
339
339
  }
340
340
  putMessage(decisionId, chatId, messageId) {
341
- this.db
342
- .prepare("INSERT OR IGNORE INTO messages(profile, decision_id, chat_id, message_id) VALUES (?, ?, ?, ?)")
343
- .run(this.profile, decisionId, chatId, messageId);
341
+ const localId = transportMessageId(this.channelId, decisionId, chatId, messageId);
342
+ this.mutate("channel.message_confirmed", { decisionId, chatId, messageId }, () => {
343
+ const contentHash = this.state.putJson({ chatId, messageId });
344
+ this.state.connection
345
+ .prepare(`INSERT INTO channel_messages(
346
+ message_id, channel_id, decision_id, purpose, content_hash,
347
+ external_conversation_ref, external_message_ref, status, created_at, updated_at
348
+ ) VALUES (?, ?, ?, 'delivery', ?, ?, ?, 'confirmed', ?, ?)
349
+ ON CONFLICT(message_id) DO NOTHING`)
350
+ .run(localId, this.channelId, decisionId, contentHash, chatId, messageId, Date.now(), Date.now());
351
+ });
344
352
  }
345
353
  messages(decisionId) {
346
- return this.db
347
- .prepare("SELECT chat_id, message_id FROM messages WHERE profile = ? AND decision_id = ? ORDER BY chat_id, message_id")
348
- .all(this.profile, decisionId);
354
+ const rows = this.state.connection
355
+ .prepare(`SELECT external_conversation_ref AS chatId, external_message_ref AS messageId
356
+ FROM channel_messages
357
+ WHERE channel_id = ? AND decision_id = ? AND purpose = 'delivery'
358
+ AND external_conversation_ref IS NOT NULL AND external_message_ref IS NOT NULL
359
+ ORDER BY external_conversation_ref, external_message_ref`)
360
+ .all(this.channelId, decisionId);
361
+ return rows.filter(isTransportMessageRow).map((row) => ({
362
+ chat_id: row.chatId,
363
+ message_id: row.messageId,
364
+ }));
349
365
  }
350
366
  putMessagePart(decisionId, recipientIndex, partIndex, contentDigest, chatId, messageId) {
351
- this.db
352
- .prepare("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")
353
- .run(this.profile, decisionId, recipientIndex, partIndex, contentDigest, chatId, messageId);
367
+ const localId = `telegram-parts-${createHash("sha256")
368
+ .update(`${this.channelId}\0${decisionId}`)
369
+ .digest("hex")
370
+ .slice(0, 40)}`;
371
+ this.mutate("channel.message_part_confirmed", { decisionId, recipientIndex, partIndex }, () => {
372
+ const parentHash = this.state.putJson({ decisionId });
373
+ this.state.connection
374
+ .prepare(`INSERT INTO channel_messages(
375
+ message_id, channel_id, decision_id, purpose, content_hash,
376
+ status, created_at, updated_at
377
+ ) VALUES (?, ?, ?, 'delivery', ?, 'confirmed', ?, ?)
378
+ ON CONFLICT(message_id) DO NOTHING`)
379
+ .run(localId, this.channelId, decisionId, parentHash, Date.now(), Date.now());
380
+ const contentHash = this.state.putText(contentDigest);
381
+ this.state.connection
382
+ .prepare(`INSERT INTO channel_message_parts(
383
+ message_id, recipient_index, part_index, content_hash,
384
+ external_conversation_ref, external_message_ref
385
+ ) VALUES (?, ?, ?, ?, ?, ?)
386
+ ON CONFLICT(message_id, recipient_index, part_index) DO UPDATE SET
387
+ content_hash = excluded.content_hash,
388
+ external_conversation_ref = excluded.external_conversation_ref,
389
+ external_message_ref = excluded.external_message_ref`)
390
+ .run(localId, recipientIndex, partIndex, contentHash, chatId, messageId);
391
+ });
354
392
  }
355
393
  messagePart(decisionId, recipientIndex, partIndex) {
356
- return this.db
357
- .prepare("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 = ?")
358
- .get(this.profile, decisionId, recipientIndex, partIndex);
394
+ const localId = `telegram-parts-${createHash("sha256")
395
+ .update(`${this.channelId}\0${decisionId}`)
396
+ .digest("hex")
397
+ .slice(0, 40)}`;
398
+ const row = this.state.connection
399
+ .prepare(`SELECT recipient_index AS recipientIndex, part_index AS partIndex,
400
+ content_hash AS contentHash,
401
+ external_conversation_ref AS chatId,
402
+ external_message_ref AS messageId
403
+ FROM channel_message_parts
404
+ WHERE message_id = ? AND recipient_index = ? AND part_index = ?`)
405
+ .get(localId, recipientIndex, partIndex);
406
+ if (!isMessagePartProjectionRow(row))
407
+ return undefined;
408
+ const content = this.state.readBlob(row.contentHash);
409
+ /* istanbul ignore if -- foreign key guarantees the content blob */
410
+ if (content === undefined)
411
+ throw new Error("Telegram part content is missing");
412
+ return {
413
+ recipient_index: row.recipientIndex,
414
+ part_index: row.partIndex,
415
+ content_digest: content.content.toString("utf8"),
416
+ chat_id: row.chatId,
417
+ message_id: row.messageId,
418
+ };
359
419
  }
420
+ putInbox(eventId, type, binding) {
421
+ this.mutate(`channel.${type}_stored`, { eventId }, () => {
422
+ const payloadHash = this.state.putJson(binding);
423
+ this.state.connection
424
+ .prepare(`INSERT INTO channel_inbox(
425
+ channel_id, external_event_id, event_type, payload_hash, received_at
426
+ ) VALUES (?, ?, ?, ?, ?)
427
+ ON CONFLICT(channel_id, external_event_id) DO UPDATE SET payload_hash = excluded.payload_hash`)
428
+ .run(this.channelId, eventId, type, payloadHash, Date.now());
429
+ });
430
+ }
431
+ inbox(eventId) {
432
+ const row = this.state.connection
433
+ .prepare("SELECT payload_hash AS payloadHash FROM channel_inbox WHERE channel_id = ? AND external_event_id = ?")
434
+ .get(this.channelId, eventId);
435
+ return isPayloadHashRow(row) ? this.state.readJson(row.payloadHash) : undefined;
436
+ }
437
+ mutate(type, payload, operation) {
438
+ this.state.transaction(() => {
439
+ this.assertOwner();
440
+ operation();
441
+ this.recordMutation(type, payload, Date.now());
442
+ });
443
+ }
444
+ recordMutation(type, payload, now) {
445
+ const revisionRow = this.state.connection
446
+ .prepare("SELECT revision FROM resources WHERE resource_id = ?")
447
+ .get(this.resourceId);
448
+ /* istanbul ignore if -- exact schema and internal query shape */
449
+ /* istanbul ignore if -- impossible after exact schema and transaction checks */
450
+ if (!isRevisionProjectionRow(revisionRow))
451
+ throw new Error("Telegram channel resource is missing");
452
+ const revision = revisionRow.revision + 1;
453
+ this.state.connection
454
+ .prepare("UPDATE resources SET revision = ?, updated_at = ? WHERE resource_id = ? AND revision = ?")
455
+ .run(revision, now, this.resourceId, revision - 1);
456
+ const payloadHash = this.state.putJson(payload, now);
457
+ this.state.connection
458
+ .prepare(`INSERT INTO events(
459
+ event_id, resource_id, resource_revision, event_type, actor_type,
460
+ actor_id, lease_generation, payload_hash, recorded_at
461
+ ) VALUES (?, ?, ?, ?, 'channel', ?, ?, ?, ?)`)
462
+ .run(`event-${randomUUID()}`, this.resourceId, revision, type, this.owner, this.generation === 0 ? null : this.generation, payloadHash, now);
463
+ }
464
+ assertOwner() {
465
+ const lease = this.lease();
466
+ if (lease.ownerId !== this.owner ||
467
+ lease.generation !== this.generation ||
468
+ lease.tokenHash === null ||
469
+ !lease.tokenHash.equals(tokenHash(this.token)) ||
470
+ lease.expiresAt === null ||
471
+ lease.expiresAt <= Date.now()) {
472
+ throw new Error(`Telegram profile ${this.profile} is not owned by this channel process`);
473
+ }
474
+ }
475
+ lease() {
476
+ const row = this.state.connection
477
+ .prepare(`SELECT generation, owner_id AS ownerId, token_hash AS tokenHash,
478
+ expires_at AS expiresAt
479
+ FROM leases WHERE resource_id = ?`)
480
+ .get(this.resourceId);
481
+ /* istanbul ignore if -- exact schema and internal query shape */
482
+ /* istanbul ignore if -- impossible after exact schema and transaction checks */
483
+ if (!isTelegramLeaseRow(row))
484
+ throw new Error("Telegram channel lease is missing");
485
+ return row;
486
+ }
487
+ }
488
+ function transportMessageId(channelId, decisionId, chatId, messageId) {
489
+ return `telegram-message-${createHash("sha256")
490
+ .update(`${channelId}\0${decisionId}\0${chatId}\0${messageId}`)
491
+ .digest("hex")
492
+ .slice(0, 40)}`;
493
+ }
494
+ function isTelegramLeaseRow(value) {
495
+ return isProjectionRecord(value);
496
+ }
497
+ function isCursorRow(value) {
498
+ return isProjectionRecord(value);
499
+ }
500
+ function isPayloadHashRow(value) {
501
+ return isProjectionRecord(value);
502
+ }
503
+ function isTransportMessageRow(value) {
504
+ return isProjectionRecord(value);
505
+ }
506
+ function isMessagePartProjectionRow(value) {
507
+ return isProjectionRecord(value);
508
+ }
509
+ function isRevisionProjectionRow(value) {
510
+ return isProjectionRecord(value);
511
+ }
512
+ function isProjectionRecord(value) {
513
+ return typeof value === "object" && value !== null && !Array.isArray(value);
360
514
  }
361
515
  class TelegramCallError extends Error {
362
516
  ambiguous;
@@ -396,7 +550,7 @@ export class TelegramDecisionChannel {
396
550
  this.onAnswer = options.onAnswer;
397
551
  this.fetchFn = options.fetchFn ?? fetch;
398
552
  this.apiBase = options.apiBase ?? DEFAULT_API_BASE;
399
- this.projection = new TelegramProjection(path.join(options.configDir ?? decisionConfigDir(), "telegram-state.sqlite"), options.profileName, options.ownerId ?? randomUUID());
553
+ this.projection = new TelegramProjection(options.store.state, options.profileName, options.ownerId ?? randomUUID());
400
554
  }
401
555
  async start() {
402
556
  if (this.running)
@@ -416,6 +570,21 @@ export class TelegramDecisionChannel {
416
570
  }
417
571
  async deliver(request) {
418
572
  const channelPath = this.channelPath();
573
+ if (!this.projection.ownsLease()) {
574
+ const attemptId = `queued-${createHash("sha256")
575
+ .update(`${this.id}\0${request.decisionId}\0${request.requestDigest}`)
576
+ .digest("hex")
577
+ .slice(0, 40)}`;
578
+ await this.store.recordDelivery(request, channelPath, deliveryRecord(request, this.id, attemptId, "complete", "intent", new Date().toISOString(), {
579
+ errorCode: "queued_for_channel_owner",
580
+ }));
581
+ return {
582
+ status: "unknown",
583
+ channel: this.id,
584
+ attemptId,
585
+ errorCode: "queued_for_channel_owner",
586
+ };
587
+ }
419
588
  const parts = renderTelegramParts(request);
420
589
  const partDigests = parts.map((part) => digestCanonical(part));
421
590
  const prior = await this.store.listDeliveries(request.decisionId, channelPath);
@@ -443,7 +612,7 @@ export class TelegramDecisionChannel {
443
612
  }
444
613
  const attemptId = createHumanDecisionAttemptId();
445
614
  const createdAt = new Date().toISOString();
446
- await this.store.recordDelivery(request, channelPath, v2DeliveryRecord(request, this.id, attemptId, "intent", "intent", createdAt, {
615
+ await this.store.recordDelivery(request, channelPath, deliveryRecord(request, this.id, attemptId, "intent", "intent", createdAt, {
447
616
  partCount: parts.length,
448
617
  }));
449
618
  const keyboard = this.registerCallbacks(request);
@@ -454,8 +623,7 @@ export class TelegramDecisionChannel {
454
623
  const partIndex = partOffset + 1;
455
624
  const contentDigest = partDigests[partOffset];
456
625
  const projected = this.projection.messagePart(request.decisionId, recipientIndex, partIndex);
457
- const confirmed = prior.some((record) => record.schema === "pi-workflows.human-decision-delivery.v2" &&
458
- record.phase === "part" &&
626
+ const confirmed = prior.some((record) => record.phase === "part" &&
459
627
  record.state === "confirmed" &&
460
628
  record.recipientIndex === recipientIndex &&
461
629
  record.partIndex === partIndex &&
@@ -468,11 +636,16 @@ export class TelegramDecisionChannel {
468
636
  }
469
637
  if (confirmed || projected !== undefined) {
470
638
  const errorCode = "telegram_part_evidence_mismatch";
471
- await this.store.recordDelivery(request, channelPath, v2DeliveryRecord(request, this.id, `${attemptId}-r${recipientIndex}-p${partIndex}-unknown`, "part", "unknown", createdAt, { recipientIndex, partIndex, partCount: parts.length, contentDigest, errorCode }));
639
+ await this.store.recordDelivery(request, channelPath, deliveryRecord(request, this.id, `${attemptId}-r${recipientIndex}-p${partIndex}-unknown`, "part", "unknown", createdAt, { recipientIndex, partIndex, partCount: parts.length, contentDigest, errorCode }));
472
640
  return { status: "unknown", channel: this.id, attemptId, errorCode };
473
641
  }
474
642
  const partAttemptId = `${attemptId}-r${recipientIndex}-p${partIndex}`;
475
- await this.store.recordDelivery(request, channelPath, v2DeliveryRecord(request, this.id, `${partAttemptId}-intent`, "part", "intent", createdAt, { recipientIndex, partIndex, partCount: parts.length, contentDigest }));
643
+ await this.store.recordDelivery(request, channelPath, deliveryRecord(request, this.id, `${partAttemptId}-intent`, "part", "intent", createdAt, {
644
+ recipientIndex,
645
+ partIndex,
646
+ partCount: parts.length,
647
+ contentDigest,
648
+ }));
476
649
  try {
477
650
  const result = await this.call("sendMessage", {
478
651
  chat_id: chatId,
@@ -482,24 +655,24 @@ export class TelegramDecisionChannel {
482
655
  const messageId = telegramMessageId(result);
483
656
  this.projection.putMessage(request.decisionId, chatId, messageId);
484
657
  this.projection.putMessagePart(request.decisionId, recipientIndex, partIndex, contentDigest, chatId, messageId);
485
- await this.store.recordDelivery(request, channelPath, v2DeliveryRecord(request, this.id, `${partAttemptId}-confirmed`, "part", "confirmed", createdAt, { recipientIndex, partIndex, partCount: parts.length, contentDigest }));
658
+ await this.store.recordDelivery(request, channelPath, deliveryRecord(request, this.id, `${partAttemptId}-confirmed`, "part", "confirmed", createdAt, { recipientIndex, partIndex, partCount: parts.length, contentDigest }));
486
659
  }
487
660
  catch (error) {
488
661
  const callError = normalizeCallError(error);
489
662
  const state = callError.ambiguous ? "unknown" : "failed";
490
- await this.store.recordDelivery(request, channelPath, v2DeliveryRecord(request, this.id, `${partAttemptId}-${state}`, "part", state, createdAt, {
663
+ await this.store.recordDelivery(request, channelPath, deliveryRecord(request, this.id, `${partAttemptId}-${state}`, "part", state, createdAt, {
491
664
  recipientIndex,
492
665
  partIndex,
493
666
  partCount: parts.length,
494
667
  contentDigest,
495
668
  errorCode: callError.code,
496
669
  }));
497
- await this.store.recordDelivery(request, channelPath, v2DeliveryRecord(request, this.id, `${attemptId}-${state}`, "complete", state, createdAt, { partCount: parts.length, errorCode: callError.code }));
670
+ await this.store.recordDelivery(request, channelPath, deliveryRecord(request, this.id, `${attemptId}-${state}`, "complete", state, createdAt, { partCount: parts.length, errorCode: callError.code }));
498
671
  return { status: state, channel: this.id, attemptId, errorCode: callError.code };
499
672
  }
500
673
  }
501
674
  }
502
- await this.store.recordDelivery(request, channelPath, v2DeliveryRecord(request, this.id, `${attemptId}-confirmed`, "complete", "confirmed", createdAt, { partCount: parts.length, messageCount: parts.length * recipients.length }));
675
+ await this.store.recordDelivery(request, channelPath, deliveryRecord(request, this.id, `${attemptId}-confirmed`, "complete", "confirmed", createdAt, { partCount: parts.length, messageCount: parts.length * recipients.length }));
503
676
  return { status: "confirmed", channel: this.id, attemptId };
504
677
  }
505
678
  async settle(decision) {
@@ -639,6 +812,7 @@ export class TelegramDecisionChannel {
639
812
  continue;
640
813
  }
641
814
  try {
815
+ await this.deliverPendingRequests();
642
816
  const result = await this.call("getUpdates", {
643
817
  offset: this.projection.offset(),
644
818
  timeout: 20,
@@ -661,6 +835,27 @@ export class TelegramDecisionChannel {
661
835
  }
662
836
  }
663
837
  }
838
+ async deliverPendingRequests() {
839
+ const channelPath = this.channelPath();
840
+ for (const request of await this.store.listRequests()) {
841
+ if (request.expiresAt !== undefined && Date.parse(request.expiresAt) <= Date.now())
842
+ continue;
843
+ if ((await this.store.readResolved(request.decisionId)) !== null)
844
+ continue;
845
+ if ((await this.store.readCancellation(request.decisionId)) !== null)
846
+ continue;
847
+ const deliveries = await this.store.listDeliveries(request.decisionId, channelPath);
848
+ if (!deliveries.some((record) => record.state === "intent" && record.errorCode === "queued_for_channel_owner")) {
849
+ continue;
850
+ }
851
+ try {
852
+ await this.deliver(humanDecisionChannelRequest(request));
853
+ }
854
+ catch {
855
+ // Durable delivery evidence controls retries for each request.
856
+ }
857
+ }
858
+ }
664
859
  async call(method, payload, signal) {
665
860
  let response;
666
861
  try {
@@ -711,7 +906,9 @@ export function createTelegramChannels(options) {
711
906
  }
712
907
  return channels;
713
908
  }
714
- export async function verifyTelegramTokenFile(tokenFile, fetchFn = fetch, apiBase = DEFAULT_API_BASE) {
909
+ export async function verifyTelegramTokenFile(tokenFile, fetchFn, apiBase = DEFAULT_API_BASE) {
910
+ /* istanbul ignore next -- global fetch is used only by operator setup */
911
+ const request = fetchFn ?? fetch;
715
912
  if (!path.isAbsolute(tokenFile))
716
913
  throw new Error("Telegram token file path must be absolute");
717
914
  await requirePrivateFile(tokenFile, "Telegram token file");
@@ -720,7 +917,7 @@ export async function verifyTelegramTokenFile(tokenFile, fetchFn = fetch, apiBas
720
917
  throw new Error("Telegram token file is empty");
721
918
  let response;
722
919
  try {
723
- response = await fetchFn(`${apiBase}/bot${encodeURIComponent(token)}/getMe`, {
920
+ response = await request(`${apiBase}/bot${encodeURIComponent(token)}/getMe`, {
724
921
  method: "POST",
725
922
  headers: { "content-type": "application/json" },
726
923
  body: "{}",
@@ -848,9 +1045,9 @@ function parseCredentialConfig(value) {
848
1045
  }
849
1046
  return { schema: "pi-workflows.credentials.v1", telegram };
850
1047
  }
851
- function v2DeliveryRecord(request, channel, attemptId, phase, state, createdAt, extra = {}) {
1048
+ function deliveryRecord(request, channel, attemptId, phase, state, createdAt, extra = {}) {
852
1049
  return {
853
- schema: "pi-workflows.human-decision-delivery.v2",
1050
+ schema: "pi-workflows.human-decision-delivery.v1",
854
1051
  attemptId,
855
1052
  decisionId: request.decisionId,
856
1053
  requestDigest: request.requestDigest,
@@ -864,23 +1061,13 @@ function v2DeliveryRecord(request, channel, attemptId, phase, state, createdAt,
864
1061
  };
865
1062
  }
866
1063
  function isCompleteDelivery(record) {
867
- return (record.state === "confirmed" &&
868
- (record.schema === "pi-workflows.human-decision-delivery.v1" || record.phase === "complete"));
1064
+ return record.state === "confirmed" && record.phase === "complete";
869
1065
  }
870
1066
  function hasUnsettledDeliveryIntent(records) {
871
- const hasLegacyIntent = records.some((record) => record.schema === "pi-workflows.human-decision-delivery.v1" && record.state === "intent");
872
- if (hasLegacyIntent &&
873
- !records.some((record) => record.schema === "pi-workflows.human-decision-delivery.v1" && record.state !== "intent")) {
874
- return true;
875
- }
876
1067
  return records.some((record) => {
877
- if (record.schema !== "pi-workflows.human-decision-delivery.v2" ||
878
- record.phase !== "part" ||
879
- record.state !== "intent") {
1068
+ if (record.phase !== "part" || record.state !== "intent")
880
1069
  return false;
881
- }
882
- return !records.some((other) => other.schema === "pi-workflows.human-decision-delivery.v2" &&
883
- other.phase === "part" &&
1070
+ return !records.some((other) => other.phase === "part" &&
884
1071
  other.state !== "intent" &&
885
1072
  other.recipientIndex === record.recipientIndex &&
886
1073
  other.partIndex === record.partIndex &&