@osolmaz/pi-workflows 0.12.1 → 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 (209) hide show
  1. package/README.md +52 -28
  2. package/dist/builtins/autodoc.workflow.d.ts +4 -4
  3. package/dist/builtins/autoimplement.workflow.d.ts +369 -73
  4. package/dist/builtins/autoplan.workflow.d.ts +126 -13
  5. package/dist/builtins/autoplan.workflow.js +200 -29
  6. package/dist/builtins/autoplan.workflow.js.map +1 -1
  7. package/dist/builtins/catalog.js +6 -4
  8. package/dist/builtins/catalog.js.map +1 -1
  9. package/dist/builtins/index.d.ts +2 -1
  10. package/dist/builtins/index.js +1 -0
  11. package/dist/builtins/index.js.map +1 -1
  12. package/dist/builtins/monitor.workflow.d.ts +56 -21
  13. package/dist/builtins/monitor.workflow.js +497 -216
  14. package/dist/builtins/monitor.workflow.js.map +1 -1
  15. package/dist/builtins/plain-summary.workflow.d.ts +32 -0
  16. package/dist/builtins/plain-summary.workflow.js +134 -0
  17. package/dist/builtins/plain-summary.workflow.js.map +1 -0
  18. package/dist/builtins/plan-change.workflow.d.ts +170 -22
  19. package/dist/builtins/sanity-check.workflow.d.ts +45 -3
  20. package/dist/builtins/sanity-check.workflow.js +45 -7
  21. package/dist/builtins/sanity-check.workflow.js.map +1 -1
  22. package/dist/controllers/effects.d.ts +3 -2
  23. package/dist/controllers/effects.js +8 -1
  24. package/dist/controllers/effects.js.map +1 -1
  25. package/dist/controllers/index.d.ts +1 -1
  26. package/dist/controllers/index.js +1 -1
  27. package/dist/controllers/index.js.map +1 -1
  28. package/dist/controllers/manager.d.ts +1 -0
  29. package/dist/controllers/manager.js +41 -17
  30. package/dist/controllers/manager.js.map +1 -1
  31. package/dist/controllers/sqlite.d.ts +107 -85
  32. package/dist/controllers/sqlite.js +1611 -1486
  33. package/dist/controllers/sqlite.js.map +1 -1
  34. package/dist/controllers/store.d.ts +9 -8
  35. package/dist/controllers/store.js +1 -31
  36. package/dist/controllers/store.js.map +1 -1
  37. package/dist/controllers/types.d.ts +3 -0
  38. package/dist/controllers/workflow-engine-scheduler.d.ts +1 -1
  39. package/dist/controllers/workflow-engine-scheduler.js +3 -5
  40. package/dist/controllers/workflow-engine-scheduler.js.map +1 -1
  41. package/dist/controllers/workflows.d.ts +2 -2
  42. package/dist/controllers/workflows.js +11 -10
  43. package/dist/controllers/workflows.js.map +1 -1
  44. package/dist/extension/controller-host.js +2 -2
  45. package/dist/extension/controller-host.js.map +1 -1
  46. package/dist/extension/decision-channels.d.ts +2 -0
  47. package/dist/extension/decision-channels.js +311 -118
  48. package/dist/extension/decision-channels.js.map +1 -1
  49. package/dist/extension/executor.d.ts +9 -2
  50. package/dist/extension/executor.js +89 -2
  51. package/dist/extension/executor.js.map +1 -1
  52. package/dist/extension/herdr-viewer.d.ts +0 -1
  53. package/dist/extension/herdr-viewer.js +0 -4
  54. package/dist/extension/herdr-viewer.js.map +1 -1
  55. package/dist/extension/index.js +156 -103
  56. package/dist/extension/index.js.map +1 -1
  57. package/dist/extension/recorder.d.ts +3 -10
  58. package/dist/extension/recorder.js +13 -22
  59. package/dist/extension/recorder.js.map +1 -1
  60. package/dist/extension/step-message.d.ts +7 -2
  61. package/dist/extension/step-message.js +94 -2
  62. package/dist/extension/step-message.js.map +1 -1
  63. package/dist/extension/widget.js +6 -0
  64. package/dist/extension/widget.js.map +1 -1
  65. package/dist/host/rpc-executor.d.ts +3 -0
  66. package/dist/host/rpc-executor.js +2 -0
  67. package/dist/host/rpc-executor.js.map +1 -1
  68. package/dist/host/runner.d.ts +3 -6
  69. package/dist/host/runner.js +83 -32
  70. package/dist/host/runner.js.map +1 -1
  71. package/dist/render/graph-render.js +14 -8
  72. package/dist/render/graph-render.js.map +1 -1
  73. package/dist/state/database.d.ts +35 -0
  74. package/dist/state/database.js +287 -0
  75. package/dist/state/database.js.map +1 -0
  76. package/dist/state/index.d.ts +4 -0
  77. package/dist/state/index.js +5 -0
  78. package/dist/state/index.js.map +1 -0
  79. package/dist/state/json.d.ts +6 -0
  80. package/dist/state/json.js +38 -0
  81. package/dist/state/json.js.map +1 -0
  82. package/dist/state/mutation.d.ts +57 -0
  83. package/dist/state/mutation.js +255 -0
  84. package/dist/state/mutation.js.map +1 -0
  85. package/dist/state/schema.d.ts +6 -0
  86. package/dist/state/schema.js +473 -0
  87. package/dist/state/schema.js.map +1 -0
  88. package/dist/viewer/cli.d.ts +2 -2
  89. package/dist/viewer/cli.js +142 -122
  90. package/dist/viewer/cli.js.map +1 -1
  91. package/dist/viewer/render.d.ts +4 -4
  92. package/dist/viewer/render.js +2 -17
  93. package/dist/viewer/render.js.map +1 -1
  94. package/dist/viewer/session-reducer.d.ts +1 -1
  95. package/dist/viewer/session-reducer.js +1 -1
  96. package/dist/viewer/tui.d.ts +3 -3
  97. package/dist/viewer/tui.js +15 -13
  98. package/dist/viewer/tui.js.map +1 -1
  99. package/dist/viewer/watch.d.ts +2 -5
  100. package/dist/viewer/watch.js +13 -13
  101. package/dist/viewer/watch.js.map +1 -1
  102. package/dist/workflows/composition.js +12 -2
  103. package/dist/workflows/composition.js.map +1 -1
  104. package/dist/workflows/definition.d.ts +6 -2
  105. package/dist/workflows/definition.js +17 -0
  106. package/dist/workflows/definition.js.map +1 -1
  107. package/dist/workflows/engine.d.ts +6 -6
  108. package/dist/workflows/engine.js +183 -103
  109. package/dist/workflows/engine.js.map +1 -1
  110. package/dist/workflows/errors.d.ts +3 -3
  111. package/dist/workflows/errors.js +3 -3
  112. package/dist/workflows/human-decision.d.ts +29 -4
  113. package/dist/workflows/human-decision.js +547 -283
  114. package/dist/workflows/human-decision.js.map +1 -1
  115. package/dist/workflows/index.d.ts +4 -5
  116. package/dist/workflows/index.js +3 -4
  117. package/dist/workflows/index.js.map +1 -1
  118. package/dist/workflows/schema.js +17 -1
  119. package/dist/workflows/schema.js.map +1 -1
  120. package/dist/workflows/store.d.ts +83 -103
  121. package/dist/workflows/store.js +902 -951
  122. package/dist/workflows/store.js.map +1 -1
  123. package/dist/workflows/types.d.ts +55 -81
  124. package/docs/CONTROLLERS.md +10 -8
  125. package/docs/DEFERRED_TURNS.md +2 -2
  126. package/docs/DESIGN_PHILOSOPHY.md +4 -2
  127. package/docs/HUMAN_DECISIONS.md +13 -14
  128. package/docs/MONITOR.md +179 -231
  129. package/docs/SQLITE_STATE.md +219 -0
  130. package/docs/WORKFLOW_COMPOSITION.md +19 -19
  131. package/docs/WORKFLOW_STEP_MESSAGES.md +20 -18
  132. package/docs/WORKFLOW_UPDATES.md +10 -9
  133. package/docs/development.md +9 -9
  134. package/docs/live-replay-protocol.md +28 -37
  135. package/docs/plans/2026-08-16-workflow-updates-plan.md +1 -1
  136. package/docs/plans/2026-08-19-human-decision-gates-plan.md +1 -1
  137. package/docs/plans/2026-08-21-autoimplement-timeout-fallback-plan.md +1 -1
  138. package/docs/plans/2026-08-21-plan-change-approval-policy-plan.md +1 -1
  139. package/docs/plans/2026-08-21-sanity-check-plan.md +30 -16
  140. package/docs/plans/2026-08-22-goal-finishing-monitor-plan.md +200 -0
  141. package/docs/plans/2026-08-23-assistant-agent-completion-plan.md +701 -0
  142. package/docs/plans/2026-08-23-sqlite-state-plan.md +970 -0
  143. package/docs/plans/replayable-run-bundles-implementation-plan.md +2 -2
  144. package/docs/plans/session-event-replay-implementation-plan.md +1 -1
  145. package/docs/plans/tui-viewer-implementation-plan.md +1 -1
  146. package/docs/session-event-journal.md +63 -434
  147. package/docs/tui-viewer.md +13 -15
  148. package/docs/workflows.md +104 -50
  149. package/examples/workflows/plain-summary.workflow.ts +1 -0
  150. package/herdr-plugin.toml +1 -1
  151. package/package.json +1 -1
  152. package/plugins/herdr/viewer.mjs +1 -11
  153. package/skills/autoplan/SKILL.md +5 -4
  154. package/skills/monitor/SKILL.md +91 -163
  155. package/skills/pi-workflows/SKILL.md +7 -7
  156. package/skills/sanity-check/SKILL.md +1 -1
  157. package/src/builtins/autoplan.workflow.ts +290 -40
  158. package/src/builtins/catalog.ts +6 -4
  159. package/src/builtins/index.ts +7 -1
  160. package/src/builtins/monitor.workflow.ts +663 -249
  161. package/src/builtins/plain-summary.workflow.ts +185 -0
  162. package/src/builtins/sanity-check.workflow.ts +62 -7
  163. package/src/controllers/effects.ts +7 -0
  164. package/src/controllers/index.ts +0 -6
  165. package/src/controllers/manager.ts +72 -32
  166. package/src/controllers/sqlite.ts +2420 -1948
  167. package/src/controllers/store.ts +17 -38
  168. package/src/controllers/types.ts +3 -0
  169. package/src/controllers/workflow-engine-scheduler.ts +4 -8
  170. package/src/controllers/workflows.ts +45 -21
  171. package/src/extension/controller-host.ts +1 -2
  172. package/src/extension/decision-channels.ts +427 -144
  173. package/src/extension/executor.ts +105 -2
  174. package/src/extension/herdr-viewer.ts +0 -5
  175. package/src/extension/index.ts +180 -118
  176. package/src/extension/recorder.ts +13 -22
  177. package/src/extension/step-message.ts +106 -4
  178. package/src/extension/widget.ts +8 -0
  179. package/src/host/rpc-executor.ts +4 -0
  180. package/src/host/runner.ts +93 -41
  181. package/src/render/graph-render.ts +11 -4
  182. package/src/state/database.ts +375 -0
  183. package/src/state/index.ts +29 -0
  184. package/src/state/json.ts +42 -0
  185. package/src/state/mutation.ts +435 -0
  186. package/src/state/schema.ts +475 -0
  187. package/src/viewer/cli.ts +151 -144
  188. package/src/viewer/render.ts +12 -24
  189. package/src/viewer/session-reducer.ts +1 -1
  190. package/src/viewer/tui.ts +18 -17
  191. package/src/viewer/watch.ts +13 -17
  192. package/src/workflows/composition.ts +12 -3
  193. package/src/workflows/definition.ts +36 -3
  194. package/src/workflows/engine.ts +212 -116
  195. package/src/workflows/errors.ts +3 -3
  196. package/src/workflows/human-decision.ts +795 -340
  197. package/src/workflows/index.ts +15 -19
  198. package/src/workflows/schema.ts +19 -1
  199. package/src/workflows/store.ts +1295 -1163
  200. package/src/workflows/types.ts +61 -81
  201. package/dist/workflows/artifacts.d.ts +0 -40
  202. package/dist/workflows/artifacts.js +0 -155
  203. package/dist/workflows/artifacts.js.map +0 -1
  204. package/dist/workflows/migrate-sources.d.ts +0 -42
  205. package/dist/workflows/migrate-sources.js +0 -133
  206. package/dist/workflows/migrate-sources.js.map +0 -1
  207. package/docs/run-bundles.md +0 -481
  208. package/src/workflows/artifacts.ts +0 -188
  209. package/src/workflows/migrate-sources.ts +0 -178
@@ -1,16 +1,17 @@
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 {
@@ -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();
@@ -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,
@@ -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,12 +630,182 @@ 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
573
638
  .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 = ?",
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 = ?`,
575
645
  )
576
- .get(this.profile, decisionId, recipientIndex, partIndex) as MessagePartRow | undefined;
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
+ };
577
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
705
+ .prepare(
706
+ "UPDATE resources SET revision = ?, updated_at = ? WHERE resource_id = ? AND revision = ?",
707
+ )
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;
755
+ }
756
+ }
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);
578
809
  }
579
810
 
580
811
  class TelegramCallError extends Error {
@@ -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);
@@ -969,6 +1227,7 @@ export class TelegramDecisionChannel implements HumanDecisionChannel {
969
1227
  continue;
970
1228
  }
971
1229
  try {
1230
+ await this.deliverPendingRequests();
972
1231
  const result = await this.call(
973
1232
  "getUpdates",
974
1233
  {
@@ -992,6 +1251,28 @@ export class TelegramDecisionChannel implements HumanDecisionChannel {
992
1251
  }
993
1252
  }
994
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
+
995
1276
  private async call(method: string, payload: unknown, signal?: AbortSignal): Promise<unknown> {
996
1277
  let response: Awaited<ReturnType<TelegramFetch>>;
997
1278
  try {
@@ -1074,16 +1355,18 @@ export function createTelegramChannels(options: {
1074
1355
 
1075
1356
  export async function verifyTelegramTokenFile(
1076
1357
  tokenFile: string,
1077
- fetchFn: TelegramFetch = fetch as TelegramFetch,
1358
+ fetchFn?: TelegramFetch,
1078
1359
  apiBase = DEFAULT_API_BASE,
1079
1360
  ): Promise<void> {
1361
+ /* istanbul ignore next -- global fetch is used only by operator setup */
1362
+ const request = fetchFn ?? (fetch as TelegramFetch);
1080
1363
  if (!path.isAbsolute(tokenFile)) throw new Error("Telegram token file path must be absolute");
1081
1364
  await requirePrivateFile(tokenFile, "Telegram token file");
1082
1365
  const token = (await fsp.readFile(tokenFile, "utf8")).trim();
1083
1366
  if (token.length === 0) throw new Error("Telegram token file is empty");
1084
1367
  let response;
1085
1368
  try {
1086
- response = await fetchFn(`${apiBase}/bot${encodeURIComponent(token)}/getMe`, {
1369
+ response = await request(`${apiBase}/bot${encodeURIComponent(token)}/getMe`, {
1087
1370
  method: "POST",
1088
1371
  headers: { "content-type": "application/json" },
1089
1372
  body: "{}",