@osolmaz/pi-workflows 0.16.0 → 0.16.1

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 (154) hide show
  1. package/README.md +18 -2
  2. package/dist/channels/adapter-entry.d.ts +3 -0
  3. package/dist/channels/adapter-entry.js +203 -0
  4. package/dist/channels/adapter-entry.js.map +1 -0
  5. package/dist/channels/config.d.ts +43 -0
  6. package/dist/channels/config.js +234 -0
  7. package/dist/channels/config.js.map +1 -0
  8. package/dist/channels/protocol.d.ts +94 -0
  9. package/dist/channels/protocol.js +219 -0
  10. package/dist/channels/protocol.js.map +1 -0
  11. package/dist/channels/telegram.d.ts +46 -0
  12. package/dist/channels/telegram.js +285 -0
  13. package/dist/channels/telegram.js.map +1 -0
  14. package/dist/client/client.d.ts +1 -0
  15. package/dist/client/client.js +5 -1
  16. package/dist/client/client.js.map +1 -1
  17. package/dist/client/index.d.ts +1 -1
  18. package/dist/client/protocol.d.ts +1 -1
  19. package/dist/client/protocol.js +11 -5
  20. package/dist/client/protocol.js.map +1 -1
  21. package/dist/client/view.d.ts +42 -16
  22. package/dist/client/view.js.map +1 -1
  23. package/dist/controllers/index.d.ts +1 -1
  24. package/dist/controllers/index.js.map +1 -1
  25. package/dist/controllers/sqlite.d.ts +12 -165
  26. package/dist/controllers/sqlite.js +124 -435
  27. package/dist/controllers/sqlite.js.map +1 -1
  28. package/dist/extension/index.d.ts +23 -2
  29. package/dist/extension/index.js +619 -535
  30. package/dist/extension/index.js.map +1 -1
  31. package/dist/extension/recorder.d.ts +95 -0
  32. package/dist/extension/recorder.js +530 -0
  33. package/dist/extension/recorder.js.map +1 -0
  34. package/dist/extension/remote-recorder-store.d.ts +25 -0
  35. package/dist/extension/remote-recorder-store.js +81 -0
  36. package/dist/extension/remote-recorder-store.js.map +1 -0
  37. package/dist/extension/session-events.d.ts +134 -0
  38. package/dist/extension/session-events.js +60 -0
  39. package/dist/extension/session-events.js.map +1 -0
  40. package/dist/extension/session-view.d.ts +2 -0
  41. package/dist/extension/session-view.js +60 -2
  42. package/dist/extension/session-view.js.map +1 -1
  43. package/dist/extension/step-message.d.ts +6 -4
  44. package/dist/extension/step-message.js +12 -6
  45. package/dist/extension/step-message.js.map +1 -1
  46. package/dist/extension/widget.d.ts +2 -2
  47. package/dist/extension/widget.js +28 -18
  48. package/dist/extension/widget.js.map +1 -1
  49. package/dist/extension/workflow-message-coordinator.d.ts +27 -0
  50. package/dist/extension/workflow-message-coordinator.js +294 -0
  51. package/dist/extension/workflow-message-coordinator.js.map +1 -0
  52. package/dist/host/channel-effects.d.ts +72 -0
  53. package/dist/host/channel-effects.js +271 -0
  54. package/dist/host/channel-effects.js.map +1 -0
  55. package/dist/host/channel-supervisor.d.ts +21 -0
  56. package/dist/host/channel-supervisor.js +54 -0
  57. package/dist/host/channel-supervisor.js.map +1 -0
  58. package/dist/host/runner.d.ts +43 -8
  59. package/dist/host/runner.js +1567 -331
  60. package/dist/host/runner.js.map +1 -1
  61. package/dist/host/state.d.ts +19 -16
  62. package/dist/host/state.js +251 -63
  63. package/dist/host/state.js.map +1 -1
  64. package/dist/host/view.d.ts +19 -9
  65. package/dist/host/view.js +222 -118
  66. package/dist/host/view.js.map +1 -1
  67. package/dist/state/prune.js +3 -25
  68. package/dist/state/prune.js.map +1 -1
  69. package/dist/state/schema.js +76 -97
  70. package/dist/state/schema.js.map +1 -1
  71. package/dist/state/workflow-messages.d.ts +96 -0
  72. package/dist/state/workflow-messages.js +335 -0
  73. package/dist/state/workflow-messages.js.map +1 -0
  74. package/dist/viewer/render.js +1 -1
  75. package/dist/viewer/render.js.map +1 -1
  76. package/dist/viewer/tui.d.ts +1 -1
  77. package/dist/viewer/tui.js +65 -8
  78. package/dist/viewer/tui.js.map +1 -1
  79. package/dist/workflows/human-decision.d.ts +2 -0
  80. package/dist/workflows/human-decision.js +8 -2
  81. package/dist/workflows/human-decision.js.map +1 -1
  82. package/dist/workflows/index.d.ts +1 -1
  83. package/dist/workflows/index.js.map +1 -1
  84. package/dist/workflows/settings.d.ts +1 -8
  85. package/dist/workflows/settings.js.map +1 -1
  86. package/dist/workflows/store.d.ts +5 -19
  87. package/dist/workflows/store.js +121 -354
  88. package/dist/workflows/store.js.map +1 -1
  89. package/dist/workflows/tool-input.d.ts +21 -0
  90. package/dist/workflows/tool-input.js +23 -1
  91. package/dist/workflows/tool-input.js.map +1 -1
  92. package/dist/workflows/types.d.ts +5 -5
  93. package/dist/workflows/workflow-message-content.d.ts +38 -0
  94. package/dist/workflows/workflow-message-content.js +157 -0
  95. package/dist/workflows/workflow-message-content.js.map +1 -0
  96. package/docs/2026-08-18-herdr-piw-plan.md +2 -1
  97. package/docs/2026-08-20-durable-workflow-launch-plan.md +1 -1
  98. package/docs/2026-08-25-workflow-follow-ups.md +26 -50
  99. package/docs/2026-08-30-out-of-process-workflow-host-plan.md +3 -3
  100. package/docs/2026-09-01-restore-session-delivery-controls-plan.md +4 -0
  101. package/docs/2026-09-01-unified-workflow-client-plan.md +5 -1
  102. package/docs/2026-09-02-installed-live-e2e-plan.md +15 -1
  103. package/docs/2026-09-02-unify-workflow-messages-plan.md +562 -0
  104. package/docs/DEFERRED_TURNS.md +66 -294
  105. package/docs/HUMAN_DECISIONS.md +29 -30
  106. package/docs/HUMAN_DECISION_PRESENTATIONS.md +4 -11
  107. package/docs/SQLITE_STATE.md +19 -15
  108. package/docs/WORKFLOW_HOST.md +93 -28
  109. package/docs/WORKFLOW_STEP_MESSAGES.md +166 -75
  110. package/docs/live-replay-protocol.md +8 -4
  111. package/docs/tui-viewer.md +3 -1
  112. package/docs/workflows.md +34 -27
  113. package/herdr-plugin.toml +1 -1
  114. package/package.json +1 -1
  115. package/plugins/herdr/viewer.mjs +14 -5
  116. package/src/channels/adapter-entry.ts +220 -0
  117. package/src/channels/config.ts +296 -0
  118. package/src/channels/protocol.ts +333 -0
  119. package/src/channels/telegram.ts +335 -0
  120. package/src/client/client.ts +6 -2
  121. package/src/client/index.ts +2 -1
  122. package/src/client/protocol.ts +11 -5
  123. package/src/client/view.ts +43 -18
  124. package/src/controllers/index.ts +0 -1
  125. package/src/controllers/sqlite.ts +164 -844
  126. package/src/extension/index.ts +693 -646
  127. package/src/extension/recorder.ts +687 -0
  128. package/src/extension/remote-recorder-store.ts +126 -0
  129. package/src/extension/session-events.ts +119 -0
  130. package/src/extension/session-view.ts +86 -3
  131. package/src/extension/step-message.ts +19 -9
  132. package/src/extension/widget.ts +37 -17
  133. package/src/extension/workflow-message-coordinator.ts +334 -0
  134. package/src/host/channel-effects.ts +465 -0
  135. package/src/host/channel-supervisor.ts +73 -0
  136. package/src/host/runner.ts +2120 -531
  137. package/src/host/state.ts +333 -94
  138. package/src/host/view.ts +249 -121
  139. package/src/state/prune.ts +3 -31
  140. package/src/state/schema.ts +76 -97
  141. package/src/state/workflow-messages.ts +548 -0
  142. package/src/viewer/render.ts +1 -5
  143. package/src/viewer/tui.ts +71 -6
  144. package/src/workflows/human-decision.ts +18 -2
  145. package/src/workflows/index.ts +0 -1
  146. package/src/workflows/settings.ts +1 -20
  147. package/src/workflows/store.ts +173 -489
  148. package/src/workflows/tool-input.ts +36 -1
  149. package/src/workflows/types.ts +5 -5
  150. package/src/workflows/workflow-message-content.ts +197 -0
  151. package/dist/extension/decision-channels.d.ts +0 -134
  152. package/dist/extension/decision-channels.js +0 -1307
  153. package/dist/extension/decision-channels.js.map +0 -1
  154. package/src/extension/decision-channels.ts +0 -1826
@@ -1,1307 +0,0 @@
1
- import { createHash, randomBytes, randomUUID } from "node:crypto";
2
- import fsp from "node:fs/promises";
3
- import os from "node:os";
4
- import path from "node:path";
5
- import { Key, matchesKey, wrapTextWithAnsi } from "@earendil-works/pi-tui";
6
- import { resourceIdFor, tokenHash } from "../state/mutation.js";
7
- import { MAX_PRESENTATION_TRANSPORT_PARTS, decisionDocumentSegments, decisionPresentationFingerprint, digestCanonical, humanDecisionChannelRequest, } from "../workflows/decision-presentation.js";
8
- import { HumanDecisionStore, createHumanDecisionAttemptId } from "../workflows/human-decision.js";
9
- const TELEGRAM_TEXT_LIMIT = 4_096;
10
- const PI_PRESENTATION_WINDOW_LINES = 18;
11
- const DEFAULT_API_BASE = "https://api.telegram.org";
12
- const LEASE_TTL_MS = 60_000;
13
- const LEASE_RENEW_WINDOW_MS = 20_000;
14
- const LEASE_RETRY_MS = 5_000;
15
- const POLL_BACKOFF_MS = 1_000;
16
- const MAX_SETTLEMENT_ATTEMPTS = 3;
17
- const NUMERIC_ID = /^-?[0-9]+$/;
18
- const SIMPLE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/;
19
- export function decisionConfigDir(env = process.env) {
20
- return env.PI_WORKFLOWS_CONFIG_DIR ?? path.join(os.homedir(), ".config", "pi-workflows");
21
- }
22
- export async function loadDecisionChannelConfig(configDir = decisionConfigDir()) {
23
- const channelPath = path.join(configDir, "channels.json");
24
- let channelsRaw;
25
- try {
26
- channelsRaw = JSON.parse(await fsp.readFile(channelPath, "utf8"));
27
- }
28
- catch (error) {
29
- if (error.code === "ENOENT")
30
- return null;
31
- throw error;
32
- }
33
- await requirePrivateFile(channelPath, "pi-workflows channel profile");
34
- const channels = parseChannelConfig(channelsRaw);
35
- const profileNames = Object.keys(channels.telegramProfiles ?? {});
36
- if (profileNames.length === 0)
37
- return { channels, credentials: {}, configDir };
38
- const credentialPath = path.join(configDir, "credentials.json");
39
- await requirePrivateFile(credentialPath, "pi-workflows credential profile");
40
- const credentialConfig = parseCredentialConfig(JSON.parse(await fsp.readFile(credentialPath, "utf8")));
41
- const credentials = {};
42
- for (const [name, credential] of Object.entries(credentialConfig.telegram)) {
43
- if (!path.isAbsolute(credential.tokenFile)) {
44
- throw new Error(`Telegram credential ${name} tokenFile must be absolute`);
45
- }
46
- await requirePrivateFile(credential.tokenFile, `Telegram credential ${name} token file`);
47
- const token = (await fsp.readFile(credential.tokenFile, "utf8")).trim();
48
- if (token.length === 0)
49
- throw new Error(`Telegram credential ${name} token file is empty`);
50
- credentials[name] = token;
51
- }
52
- return { channels, credentials, configDir };
53
- }
54
- export function audienceChannels(config, audience) {
55
- const configured = config?.audiences[audience]?.channels;
56
- return configured === undefined ? ["pi"] : [...configured];
57
- }
58
- export class PiDecisionChannel {
59
- options;
60
- id = "pi";
61
- promptAbort = null;
62
- settlementTask = null;
63
- constructor(options) {
64
- this.options = options;
65
- }
66
- async start() { }
67
- async stop() {
68
- this.promptAbort?.abort();
69
- }
70
- async deliver(request) {
71
- if (await this.alreadyDecided(request.decisionId)) {
72
- return { status: "confirmed", channel: this.id, attemptId: "adopted" };
73
- }
74
- const attemptId = createHumanDecisionAttemptId();
75
- const createdAt = new Date().toISOString();
76
- const controller = new AbortController();
77
- this.promptAbort = controller;
78
- try {
79
- await this.options.store.recordDelivery(request, this.id, deliveryRecord(request, this.id, attemptId, "intent", "intent", createdAt));
80
- const entries = Object.entries(request.choices);
81
- const selectedChoice = await this.options.ui.custom((tui, theme, _keybindings, done) => {
82
- let choiceIndex = 0;
83
- let scroll = 0;
84
- let settled = false;
85
- const finish = (value) => {
86
- if (settled)
87
- return;
88
- settled = true;
89
- done(value);
90
- };
91
- const abort = () => finish(undefined);
92
- controller.signal.addEventListener("abort", abort, { once: true });
93
- return {
94
- render(width) {
95
- const content = renderPiPresentationLines(request, Math.max(20, width), theme);
96
- const maxScroll = Math.max(0, content.length - PI_PRESENTATION_WINDOW_LINES);
97
- scroll = Math.min(scroll, maxScroll);
98
- const visible = content.slice(scroll, scroll + PI_PRESENTATION_WINDOW_LINES);
99
- const lines = [...visible];
100
- if (content.length > PI_PRESENTATION_WINDOW_LINES) {
101
- lines.push(theme.fg("dim", `Decision text ${scroll + 1}-${Math.min(content.length, scroll + PI_PRESENTATION_WINDOW_LINES)}/${content.length} · PgUp/PgDn scroll`));
102
- }
103
- lines.push("");
104
- for (const [index, [, definition]] of entries.entries()) {
105
- const marker = index === choiceIndex ? "›" : " ";
106
- lines.push(...wrapTextWithAnsi(`${theme.fg(index === choiceIndex ? "accent" : "text", `${marker} ${definition.label}`)}`, Math.max(1, width)));
107
- if (definition.input !== undefined) {
108
- lines.push(...wrapTextWithAnsi(theme.fg("dim", ` ${definition.input.prompt}`), Math.max(1, width)));
109
- }
110
- }
111
- lines.push("");
112
- lines.push(theme.fg("dim", "↑/↓ choose · Enter confirm · Esc cancel"));
113
- return lines;
114
- },
115
- invalidate() { },
116
- handleInput(data) {
117
- if (matchesKey(data, Key.escape))
118
- finish(undefined);
119
- else if (matchesKey(data, Key.enter))
120
- finish(entries[choiceIndex]?.[0]);
121
- else if (matchesKey(data, Key.up))
122
- choiceIndex = Math.max(0, choiceIndex - 1);
123
- else if (matchesKey(data, Key.down)) {
124
- choiceIndex = Math.min(entries.length - 1, choiceIndex + 1);
125
- }
126
- else if (matchesKey(data, Key.pageUp)) {
127
- scroll = Math.max(0, scroll - PI_PRESENTATION_WINDOW_LINES);
128
- }
129
- else if (matchesKey(data, Key.pageDown)) {
130
- scroll += PI_PRESENTATION_WINDOW_LINES;
131
- }
132
- else
133
- return;
134
- tui.requestRender();
135
- },
136
- dispose() {
137
- controller.signal.removeEventListener("abort", abort);
138
- },
139
- };
140
- });
141
- if (selectedChoice === undefined) {
142
- const errorCode = controller.signal.aborted
143
- ? "pi_selection_settled_elsewhere"
144
- : "pi_selection_cancelled";
145
- await this.options.store.recordDelivery(request, this.id, deliveryRecord(request, this.id, `${attemptId}-cancelled`, "complete", "failed", createdAt, { errorCode }));
146
- return { status: "failed", channel: this.id, attemptId, errorCode };
147
- }
148
- const definition = request.choices[selectedChoice];
149
- if (definition === undefined)
150
- throw new Error("Pi decision selection is not in the request");
151
- let response = { choice: selectedChoice };
152
- if (definition.input !== undefined) {
153
- const text = await this.options.ui.input(definition.input.prompt, "", {
154
- signal: controller.signal,
155
- });
156
- if (text === undefined) {
157
- const errorCode = controller.signal.aborted
158
- ? "pi_input_settled_elsewhere"
159
- : "pi_input_cancelled";
160
- await this.options.store.recordDelivery(request, this.id, deliveryRecord(request, this.id, `${attemptId}-cancelled`, "complete", "failed", createdAt, { errorCode }));
161
- return { status: "failed", channel: this.id, attemptId, errorCode };
162
- }
163
- response = { choice: selectedChoice, input: { [definition.input.name]: text } };
164
- }
165
- if (await this.alreadyDecided(request.decisionId)) {
166
- return { status: "confirmed", channel: this.id, attemptId: "adopted" };
167
- }
168
- const eventId = createHumanDecisionAttemptId();
169
- await this.options.onAnswer({
170
- request,
171
- response,
172
- source: { channel: this.id, actorId: this.options.actorId, eventId },
173
- idempotencyKey: `${this.id}:${eventId}`,
174
- });
175
- await this.options.store.recordDelivery(request, this.id, deliveryRecord(request, this.id, `${attemptId}-confirmed`, "complete", "confirmed", createdAt, { messageCount: 1 }));
176
- return { status: "confirmed", channel: this.id, attemptId };
177
- }
178
- finally {
179
- if (this.promptAbort === controller)
180
- this.promptAbort = null;
181
- }
182
- }
183
- async alreadyDecided(decisionId) {
184
- return ((await this.options.store.readResolved(decisionId)) !== null ||
185
- (await this.options.store.readCancellation(decisionId)) !== null);
186
- }
187
- async settle(decision) {
188
- this.promptAbort?.abort();
189
- if (this.settlementTask !== null)
190
- return await this.settlementTask;
191
- const task = this.settleOnce(decision);
192
- this.settlementTask = task;
193
- try {
194
- await task;
195
- }
196
- finally {
197
- if (this.settlementTask === task)
198
- this.settlementTask = null;
199
- }
200
- }
201
- async settleOnce(decision) {
202
- const prior = await this.options.store.listSettlements(decision.decisionId, this.id);
203
- if (prior.some((record) => record.state === "confirmed"))
204
- return;
205
- const createdAt = new Date().toISOString();
206
- const record = {
207
- schema: "pi-workflows.human-decision-settlement.v1",
208
- attemptId: createHumanDecisionAttemptId(),
209
- decisionId: decision.decisionId,
210
- requestDigest: decision.requestDigest,
211
- channel: this.id,
212
- state: "confirmed",
213
- createdAt,
214
- finishedAt: createdAt,
215
- };
216
- await this.options.store.recordSettlement(decision.decisionId, this.id, record);
217
- }
218
- }
219
- class TelegramProjection {
220
- state;
221
- profile;
222
- owner;
223
- channelId;
224
- resourceId;
225
- token = randomBytes(32).toString("base64url");
226
- generation = 0;
227
- constructor(state, profile, owner) {
228
- this.state = state;
229
- this.profile = profile;
230
- this.owner = owner;
231
- this.channelId = `telegram-${createHash("sha256").update(profile).digest("hex").slice(0, 40)}`;
232
- this.resourceId = resourceIdFor("channel", this.channelId);
233
- this.state.transaction(() => {
234
- const now = Date.now();
235
- this.state.connection
236
- .prepare(`INSERT INTO resources(
237
- resource_id, resource_type, aggregate_key, revision, created_at, updated_at
238
- ) VALUES (?, 'channel', ?, 1, ?, ?)
239
- ON CONFLICT(resource_type, aggregate_key) DO NOTHING`)
240
- .run(this.resourceId, this.channelId, now, now);
241
- this.state.connection
242
- .prepare("INSERT INTO leases(resource_id, generation) VALUES (?, 0) ON CONFLICT(resource_id) DO NOTHING")
243
- .run(this.resourceId);
244
- this.state.connection
245
- .prepare(`INSERT INTO channels(channel_id, resource_id, adapter_type, profile_key, created_at)
246
- VALUES (?, ?, 'telegram', ?, ?) ON CONFLICT(channel_id) DO NOTHING`)
247
- .run(this.channelId, this.resourceId, this.profile, now);
248
- });
249
- }
250
- close() {
251
- this.release();
252
- }
253
- ownsLease() {
254
- try {
255
- this.assertOwner();
256
- return true;
257
- }
258
- catch {
259
- return false;
260
- }
261
- }
262
- acquire(now = Date.now()) {
263
- const observed = this.lease();
264
- const observedToken = tokenHash(this.token);
265
- const ownedByThis = observed.ownerId === this.owner &&
266
- observed.tokenHash !== null &&
267
- observed.tokenHash.equals(observedToken) &&
268
- observed.expiresAt !== null &&
269
- observed.expiresAt > now;
270
- if (ownedByThis) {
271
- this.generation = observed.generation;
272
- return true;
273
- }
274
- if (observed.ownerId !== null && observed.expiresAt !== null && observed.expiresAt > now) {
275
- return false;
276
- }
277
- return this.state.transaction(() => {
278
- const lease = this.lease();
279
- if (lease.ownerId !== null &&
280
- lease.ownerId !== this.owner &&
281
- lease.expiresAt !== null &&
282
- lease.expiresAt > now) {
283
- return false;
284
- }
285
- const sameToken = lease.ownerId === this.owner &&
286
- lease.tokenHash !== null &&
287
- lease.tokenHash.equals(observedToken);
288
- const generation = sameToken ? lease.generation : lease.generation + 1;
289
- const result = this.state.connection
290
- .prepare(`UPDATE leases
291
- SET generation = ?, owner_type = 'channel', owner_id = ?, token_hash = ?,
292
- acquired_at = COALESCE(acquired_at, ?), heartbeat_at = ?, expires_at = ?
293
- WHERE resource_id = ? AND generation = ?`)
294
- .run(generation, this.owner, observedToken, now, now, now + LEASE_TTL_MS, this.resourceId, lease.generation);
295
- /* istanbul ignore if -- impossible after exact schema and transaction checks */
296
- if (result.changes !== 1)
297
- return false;
298
- this.generation = generation;
299
- this.recordMutation("channel.lease_acquired", { expiresAt: now + LEASE_TTL_MS }, now);
300
- return true;
301
- });
302
- }
303
- renew(now = Date.now()) {
304
- const lease = this.lease();
305
- if (lease.ownerId !== this.owner ||
306
- lease.generation !== this.generation ||
307
- lease.tokenHash === null ||
308
- !lease.tokenHash.equals(tokenHash(this.token)) ||
309
- lease.expiresAt === null ||
310
- lease.expiresAt <= now) {
311
- return false;
312
- }
313
- if (lease.expiresAt - now > LEASE_RENEW_WINDOW_MS)
314
- return true;
315
- return (this.state.connection
316
- .prepare(`UPDATE leases SET heartbeat_at = ?, expires_at = ?
317
- WHERE resource_id = ? AND owner_id = ? AND token_hash = ? AND generation = ?`)
318
- .run(now, now + LEASE_TTL_MS, this.resourceId, this.owner, tokenHash(this.token), this.generation).changes === 1);
319
- }
320
- release() {
321
- this.state.transaction(() => {
322
- const result = this.state.connection
323
- .prepare(`UPDATE leases
324
- SET owner_type = NULL, owner_id = NULL, token_hash = NULL,
325
- acquired_at = NULL, heartbeat_at = NULL, expires_at = NULL
326
- WHERE resource_id = ? AND owner_id = ? AND token_hash = ? AND generation = ?`)
327
- .run(this.resourceId, this.owner, tokenHash(this.token), this.generation);
328
- if (result.changes === 1) {
329
- this.recordMutation("channel.lease_released", {}, Date.now());
330
- }
331
- });
332
- }
333
- offset() {
334
- const row = this.state.connection
335
- .prepare("SELECT cursor_value AS cursorValue FROM channel_cursors WHERE channel_id = ? AND cursor_key = 'telegram_update'")
336
- .get(this.channelId);
337
- return isCursorRow(row) ? Number.parseInt(row.cursorValue, 10) || 0 : 0;
338
- }
339
- setOffset(offset) {
340
- this.mutate("channel.cursor_updated", { offset }, () => {
341
- this.state.connection
342
- .prepare(`INSERT INTO channel_cursors(channel_id, cursor_key, cursor_value, updated_at)
343
- VALUES (?, 'telegram_update', ?, ?)
344
- ON CONFLICT(channel_id, cursor_key) DO UPDATE SET
345
- cursor_value = CAST(MAX(CAST(channel_cursors.cursor_value AS INTEGER), CAST(excluded.cursor_value AS INTEGER)) AS TEXT),
346
- updated_at = excluded.updated_at`)
347
- .run(this.channelId, String(offset), Date.now());
348
- });
349
- }
350
- putCallback(token, binding) {
351
- this.putInbox(`callback:${token}`, "callback", binding);
352
- }
353
- callback(token) {
354
- return this.inbox(`callback:${token}`);
355
- }
356
- putReply(binding) {
357
- this.putInbox(`reply:${binding.chatId}:${binding.promptMessageId}`, "reply", binding);
358
- }
359
- reply(chatId, messageId) {
360
- return this.inbox(`reply:${chatId}:${messageId}`);
361
- }
362
- deleteReply(chatId, messageId) {
363
- this.mutate("channel.reply_consumed", { chatId, messageId }, () => {
364
- this.state.connection
365
- .prepare("DELETE FROM channel_inbox WHERE channel_id = ? AND external_event_id = ?")
366
- .run(this.channelId, `reply:${chatId}:${messageId}`);
367
- });
368
- }
369
- putMessage(decisionId, chatId, messageId) {
370
- const localId = transportMessageId(this.channelId, decisionId, chatId, messageId);
371
- this.mutate("channel.message_confirmed", { decisionId, chatId, messageId }, () => {
372
- const contentHash = this.state.putJson({ chatId, messageId });
373
- this.state.connection
374
- .prepare(`INSERT INTO channel_messages(
375
- message_id, channel_id, decision_id, purpose, content_hash,
376
- external_conversation_ref, external_message_ref, status, created_at, updated_at
377
- ) VALUES (?, ?, ?, 'delivery', ?, ?, ?, 'confirmed', ?, ?)
378
- ON CONFLICT(message_id) DO NOTHING`)
379
- .run(localId, this.channelId, decisionId, contentHash, chatId, messageId, Date.now(), Date.now());
380
- });
381
- }
382
- messages(decisionId) {
383
- const rows = this.state.connection
384
- .prepare(`SELECT external_conversation_ref AS chatId, external_message_ref AS messageId
385
- FROM channel_messages
386
- WHERE channel_id = ? AND decision_id = ? AND purpose = 'delivery'
387
- AND external_conversation_ref IS NOT NULL AND external_message_ref IS NOT NULL
388
- ORDER BY external_conversation_ref, external_message_ref`)
389
- .all(this.channelId, decisionId);
390
- return rows.filter(isTransportMessageRow).map((row) => ({
391
- chat_id: row.chatId,
392
- message_id: row.messageId,
393
- }));
394
- }
395
- putMessagePart(decisionId, recipientIndex, partIndex, contentDigest, chatId, messageId) {
396
- const localId = `telegram-parts-${createHash("sha256")
397
- .update(`${this.channelId}\0${decisionId}`)
398
- .digest("hex")
399
- .slice(0, 40)}`;
400
- this.mutate("channel.message_part_confirmed", { decisionId, recipientIndex, partIndex }, () => {
401
- const parentHash = this.state.putJson({ decisionId });
402
- this.state.connection
403
- .prepare(`INSERT INTO channel_messages(
404
- message_id, channel_id, decision_id, purpose, content_hash,
405
- status, created_at, updated_at
406
- ) VALUES (?, ?, ?, 'delivery', ?, 'confirmed', ?, ?)
407
- ON CONFLICT(message_id) DO NOTHING`)
408
- .run(localId, this.channelId, decisionId, parentHash, Date.now(), Date.now());
409
- const contentHash = this.state.putText(contentDigest);
410
- this.state.connection
411
- .prepare(`INSERT INTO channel_message_parts(
412
- message_id, recipient_index, part_index, content_hash,
413
- external_conversation_ref, external_message_ref
414
- ) VALUES (?, ?, ?, ?, ?, ?)
415
- ON CONFLICT(message_id, recipient_index, part_index) DO UPDATE SET
416
- content_hash = excluded.content_hash,
417
- external_conversation_ref = excluded.external_conversation_ref,
418
- external_message_ref = excluded.external_message_ref`)
419
- .run(localId, recipientIndex, partIndex, contentHash, chatId, messageId);
420
- });
421
- }
422
- messagePart(decisionId, recipientIndex, partIndex) {
423
- const localId = `telegram-parts-${createHash("sha256")
424
- .update(`${this.channelId}\0${decisionId}`)
425
- .digest("hex")
426
- .slice(0, 40)}`;
427
- const row = this.state.connection
428
- .prepare(`SELECT recipient_index AS recipientIndex, part_index AS partIndex,
429
- content_hash AS contentHash,
430
- external_conversation_ref AS chatId,
431
- external_message_ref AS messageId
432
- FROM channel_message_parts
433
- WHERE message_id = ? AND recipient_index = ? AND part_index = ?`)
434
- .get(localId, recipientIndex, partIndex);
435
- if (!isMessagePartProjectionRow(row))
436
- return undefined;
437
- const content = this.state.readBlob(row.contentHash);
438
- /* istanbul ignore if -- foreign key guarantees the content blob */
439
- if (content === undefined)
440
- throw new Error("Telegram part content is missing");
441
- return {
442
- recipient_index: row.recipientIndex,
443
- part_index: row.partIndex,
444
- content_digest: content.content.toString("utf8"),
445
- chat_id: row.chatId,
446
- message_id: row.messageId,
447
- };
448
- }
449
- putInbox(eventId, type, binding) {
450
- this.mutate(`channel.${type}_stored`, { eventId }, () => {
451
- const payloadHash = this.state.putJson(binding);
452
- this.state.connection
453
- .prepare(`INSERT INTO channel_inbox(
454
- channel_id, external_event_id, event_type, payload_hash, received_at
455
- ) VALUES (?, ?, ?, ?, ?)
456
- ON CONFLICT(channel_id, external_event_id) DO UPDATE SET payload_hash = excluded.payload_hash`)
457
- .run(this.channelId, eventId, type, payloadHash, Date.now());
458
- });
459
- }
460
- inbox(eventId) {
461
- const row = this.state.connection
462
- .prepare("SELECT payload_hash AS payloadHash FROM channel_inbox WHERE channel_id = ? AND external_event_id = ?")
463
- .get(this.channelId, eventId);
464
- return isPayloadHashRow(row) ? this.state.readJson(row.payloadHash) : undefined;
465
- }
466
- mutate(type, payload, operation) {
467
- this.state.transaction(() => {
468
- this.assertOwner();
469
- operation();
470
- this.recordMutation(type, payload, Date.now());
471
- });
472
- }
473
- recordMutation(type, payload, now) {
474
- const revisionRow = this.state.connection
475
- .prepare("SELECT revision FROM resources WHERE resource_id = ?")
476
- .get(this.resourceId);
477
- /* istanbul ignore if -- exact schema and internal query shape */
478
- /* istanbul ignore if -- impossible after exact schema and transaction checks */
479
- if (!isRevisionProjectionRow(revisionRow))
480
- throw new Error("Telegram channel resource is missing");
481
- const revision = revisionRow.revision + 1;
482
- this.state.connection
483
- .prepare("UPDATE resources SET revision = ?, updated_at = ? WHERE resource_id = ? AND revision = ?")
484
- .run(revision, now, this.resourceId, revision - 1);
485
- const payloadHash = this.state.putJson(payload, now);
486
- this.state.connection
487
- .prepare(`INSERT INTO events(
488
- event_id, resource_id, resource_revision, event_type, actor_type,
489
- actor_id, lease_generation, payload_hash, recorded_at
490
- ) VALUES (?, ?, ?, ?, 'channel', ?, ?, ?, ?)`)
491
- .run(`event-${randomUUID()}`, this.resourceId, revision, type, this.owner, this.generation === 0 ? null : this.generation, payloadHash, now);
492
- }
493
- assertOwner() {
494
- const lease = this.lease();
495
- if (lease.ownerId !== this.owner ||
496
- lease.generation !== this.generation ||
497
- lease.tokenHash === null ||
498
- !lease.tokenHash.equals(tokenHash(this.token)) ||
499
- lease.expiresAt === null ||
500
- lease.expiresAt <= Date.now()) {
501
- throw new Error(`Telegram profile ${this.profile} is not owned by this channel process`);
502
- }
503
- }
504
- lease() {
505
- const row = this.state.connection
506
- .prepare(`SELECT generation, owner_id AS ownerId, token_hash AS tokenHash,
507
- expires_at AS expiresAt
508
- FROM leases WHERE resource_id = ?`)
509
- .get(this.resourceId);
510
- /* istanbul ignore if -- exact schema and internal query shape */
511
- /* istanbul ignore if -- impossible after exact schema and transaction checks */
512
- if (!isTelegramLeaseRow(row))
513
- throw new Error("Telegram channel lease is missing");
514
- return row;
515
- }
516
- }
517
- function transportMessageId(channelId, decisionId, chatId, messageId) {
518
- return `telegram-message-${createHash("sha256")
519
- .update(`${channelId}\0${decisionId}\0${chatId}\0${messageId}`)
520
- .digest("hex")
521
- .slice(0, 40)}`;
522
- }
523
- function isTelegramLeaseRow(value) {
524
- return isProjectionRecord(value);
525
- }
526
- function isCursorRow(value) {
527
- return isProjectionRecord(value);
528
- }
529
- function isPayloadHashRow(value) {
530
- return isProjectionRecord(value);
531
- }
532
- function isTransportMessageRow(value) {
533
- return isProjectionRecord(value);
534
- }
535
- function isMessagePartProjectionRow(value) {
536
- return isProjectionRecord(value);
537
- }
538
- function isRevisionProjectionRow(value) {
539
- return isProjectionRecord(value);
540
- }
541
- function isProjectionRecord(value) {
542
- return typeof value === "object" && value !== null && !Array.isArray(value);
543
- }
544
- class TelegramCallError extends Error {
545
- ambiguous;
546
- code;
547
- constructor(message, ambiguous, code) {
548
- super(message);
549
- this.ambiguous = ambiguous;
550
- this.code = code;
551
- }
552
- }
553
- export class TelegramDecisionChannel {
554
- id;
555
- profile;
556
- store;
557
- fetchFn;
558
- apiBase;
559
- onAnswer;
560
- projection;
561
- running = false;
562
- pollAbort = null;
563
- pollTask = null;
564
- settlementTasks = new Map();
565
- constructor(options) {
566
- requireSimpleId(options.profileName, "Telegram profile");
567
- if (options.token.trim().length === 0)
568
- throw new Error("Telegram token must not be empty");
569
- validateNumericIds(options.allowedUserIds, "Telegram allowed user IDs");
570
- validateNumericIds(options.allowedChatIds, "Telegram allowed chat IDs");
571
- this.id = `telegram:${options.profileName}`;
572
- this.profile = {
573
- name: options.profileName,
574
- token: options.token,
575
- allowedUserIds: new Set(options.allowedUserIds),
576
- allowedChatIds: new Set(options.allowedChatIds),
577
- };
578
- this.store = options.store;
579
- this.onAnswer = options.onAnswer;
580
- this.fetchFn = options.fetchFn ?? fetch;
581
- this.apiBase = options.apiBase ?? DEFAULT_API_BASE;
582
- this.projection = new TelegramProjection(options.store.state, options.profileName, options.ownerId ?? randomUUID());
583
- }
584
- async start() {
585
- if (this.running)
586
- return;
587
- this.running = true;
588
- this.pollAbort = new AbortController();
589
- this.pollTask = this.poll(this.pollAbort.signal);
590
- }
591
- async stop() {
592
- this.running = false;
593
- this.pollAbort?.abort();
594
- await this.pollTask?.catch(() => undefined);
595
- await Promise.allSettled(this.settlementTasks.values());
596
- this.pollAbort = null;
597
- this.pollTask = null;
598
- this.projection.close();
599
- }
600
- async deliver(request) {
601
- const channelPath = this.channelPath();
602
- if (!this.projection.ownsLease()) {
603
- const attemptId = `queued-${createHash("sha256")
604
- .update(`${this.id}\0${request.decisionId}\0${request.requestDigest}`)
605
- .digest("hex")
606
- .slice(0, 40)}`;
607
- await this.store.recordDelivery(request, channelPath, deliveryRecord(request, this.id, attemptId, "complete", "intent", new Date().toISOString(), {
608
- errorCode: "queued_for_channel_owner",
609
- }));
610
- return {
611
- status: "unknown",
612
- channel: this.id,
613
- attemptId,
614
- errorCode: "queued_for_channel_owner",
615
- };
616
- }
617
- const parts = renderTelegramParts(request);
618
- const partDigests = parts.map((part) => digestCanonical(part));
619
- const prior = await this.store.listDeliveries(request.decisionId, channelPath);
620
- if (prior.some(isCompleteDelivery)) {
621
- this.registerCallbacks(request);
622
- return { status: "confirmed", channel: this.id, attemptId: "adopted" };
623
- }
624
- if (prior.some((record) => record.state === "unknown")) {
625
- this.registerCallbacks(request);
626
- return {
627
- status: "unknown",
628
- channel: this.id,
629
- attemptId: "adopted",
630
- errorCode: "ambiguous_delivery_not_retried",
631
- };
632
- }
633
- if (hasUnsettledDeliveryIntent(prior)) {
634
- this.registerCallbacks(request);
635
- return {
636
- status: "unknown",
637
- channel: this.id,
638
- attemptId: "adopted",
639
- errorCode: "unsettled_delivery_intent_not_retried",
640
- };
641
- }
642
- const attemptId = createHumanDecisionAttemptId();
643
- const createdAt = new Date().toISOString();
644
- await this.store.recordDelivery(request, channelPath, deliveryRecord(request, this.id, attemptId, "intent", "intent", createdAt, {
645
- partCount: parts.length,
646
- }));
647
- const keyboard = this.registerCallbacks(request);
648
- const recipients = [...this.profile.allowedChatIds].sort((left, right) => left.localeCompare(right));
649
- for (const [recipientOffset, chatId] of recipients.entries()) {
650
- const recipientIndex = recipientOffset + 1;
651
- for (const [partOffset, text] of parts.entries()) {
652
- const partIndex = partOffset + 1;
653
- const contentDigest = partDigests[partOffset];
654
- const projected = this.projection.messagePart(request.decisionId, recipientIndex, partIndex);
655
- const confirmed = prior.some((record) => record.phase === "part" &&
656
- record.state === "confirmed" &&
657
- record.recipientIndex === recipientIndex &&
658
- record.partIndex === partIndex &&
659
- record.partCount === parts.length &&
660
- record.contentDigest === contentDigest);
661
- if (confirmed &&
662
- projected?.content_digest === contentDigest &&
663
- projected.chat_id === chatId) {
664
- continue;
665
- }
666
- if (confirmed || projected !== undefined) {
667
- const errorCode = "telegram_part_evidence_mismatch";
668
- 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 }));
669
- return { status: "unknown", channel: this.id, attemptId, errorCode };
670
- }
671
- const partAttemptId = `${attemptId}-r${recipientIndex}-p${partIndex}`;
672
- await this.store.recordDelivery(request, channelPath, deliveryRecord(request, this.id, `${partAttemptId}-intent`, "part", "intent", createdAt, {
673
- recipientIndex,
674
- partIndex,
675
- partCount: parts.length,
676
- contentDigest,
677
- }));
678
- try {
679
- const result = await this.call("sendMessage", {
680
- chat_id: chatId,
681
- text,
682
- ...(partIndex === parts.length ? { reply_markup: { inline_keyboard: keyboard } } : {}),
683
- });
684
- const messageId = telegramMessageId(result);
685
- this.projection.putMessage(request.decisionId, chatId, messageId);
686
- this.projection.putMessagePart(request.decisionId, recipientIndex, partIndex, contentDigest, chatId, messageId);
687
- await this.store.recordDelivery(request, channelPath, deliveryRecord(request, this.id, `${partAttemptId}-confirmed`, "part", "confirmed", createdAt, { recipientIndex, partIndex, partCount: parts.length, contentDigest }));
688
- }
689
- catch (error) {
690
- const callError = normalizeCallError(error);
691
- const state = callError.ambiguous ? "unknown" : "failed";
692
- await this.store.recordDelivery(request, channelPath, deliveryRecord(request, this.id, `${partAttemptId}-${state}`, "part", state, createdAt, {
693
- recipientIndex,
694
- partIndex,
695
- partCount: parts.length,
696
- contentDigest,
697
- errorCode: callError.code,
698
- }));
699
- await this.store.recordDelivery(request, channelPath, deliveryRecord(request, this.id, `${attemptId}-${state}`, "complete", state, createdAt, { partCount: parts.length, errorCode: callError.code }));
700
- return { status: state, channel: this.id, attemptId, errorCode: callError.code };
701
- }
702
- }
703
- }
704
- await this.store.recordDelivery(request, channelPath, deliveryRecord(request, this.id, `${attemptId}-confirmed`, "complete", "confirmed", createdAt, { partCount: parts.length, messageCount: parts.length * recipients.length }));
705
- return { status: "confirmed", channel: this.id, attemptId };
706
- }
707
- async settle(decision) {
708
- const existing = this.settlementTasks.get(decision.decisionId);
709
- if (existing !== undefined)
710
- return await existing;
711
- const task = this.settleOnce(decision);
712
- this.settlementTasks.set(decision.decisionId, task);
713
- try {
714
- await task;
715
- }
716
- finally {
717
- if (this.settlementTasks.get(decision.decisionId) === task) {
718
- this.settlementTasks.delete(decision.decisionId);
719
- }
720
- }
721
- }
722
- async settleOnce(decision) {
723
- const channelPath = this.channelPath();
724
- const prior = await this.store.listSettlements(decision.decisionId, channelPath);
725
- if (prior.some((record) => record.state === "confirmed") ||
726
- prior.length >= MAX_SETTLEMENT_ATTEMPTS) {
727
- return;
728
- }
729
- const attemptId = createHumanDecisionAttemptId();
730
- const createdAt = new Date().toISOString();
731
- let failed = false;
732
- for (const message of this.projection.messages(decision.decisionId)) {
733
- try {
734
- await this.call("editMessageReplyMarkup", {
735
- chat_id: message.chat_id,
736
- message_id: Number(message.message_id),
737
- reply_markup: { inline_keyboard: [] },
738
- });
739
- }
740
- catch (error) {
741
- if (normalizeCallError(error).code !== "telegram_message_not_modified")
742
- failed = true;
743
- }
744
- }
745
- const record = {
746
- schema: "pi-workflows.human-decision-settlement.v1",
747
- attemptId,
748
- decisionId: decision.decisionId,
749
- requestDigest: decision.requestDigest,
750
- channel: this.id,
751
- state: failed ? "failed" : "confirmed",
752
- createdAt,
753
- finishedAt: new Date().toISOString(),
754
- ...(failed ? { errorCode: "telegram_settlement_failed" } : {}),
755
- };
756
- await this.store.recordSettlement(decision.decisionId, channelPath, record);
757
- }
758
- async handleUpdate(value) {
759
- const update = record(value, "Telegram update");
760
- const updateId = String(update.update_id ?? "");
761
- const callback = asRecord(update.callback_query);
762
- if (callback !== null) {
763
- const actorId = String(asRecord(callback.from)?.id ?? "");
764
- const message = asRecord(callback.message);
765
- const chatId = String(asRecord(message?.chat)?.id ?? "");
766
- if (!this.allowed(actorId, chatId))
767
- return;
768
- const binding = this.projection.callback(String(callback.data ?? ""));
769
- if (binding === undefined || this.isStale(binding.request))
770
- return;
771
- const definition = binding.request.choices[binding.choice];
772
- if (definition === undefined)
773
- return;
774
- if (definition.input === undefined) {
775
- await this.onAnswer({
776
- request: binding.request,
777
- response: { choice: binding.choice },
778
- source: { channel: this.id, actorId, eventId: String(callback.id ?? updateId) },
779
- idempotencyKey: `${this.id}:${String(callback.id ?? updateId)}`,
780
- });
781
- }
782
- else {
783
- const prompt = await this.call("sendMessage", {
784
- chat_id: chatId,
785
- text: definition.input.prompt,
786
- reply_markup: { force_reply: true, selective: true },
787
- });
788
- this.projection.putReply({
789
- ...binding,
790
- chatId,
791
- promptMessageId: telegramMessageId(prompt),
792
- });
793
- }
794
- await this.call("answerCallbackQuery", { callback_query_id: callback.id }).catch(() => undefined);
795
- return;
796
- }
797
- const message = asRecord(update.message);
798
- if (message === null)
799
- return;
800
- const actorId = String(asRecord(message.from)?.id ?? "");
801
- const chatId = String(asRecord(message.chat)?.id ?? "");
802
- if (!this.allowed(actorId, chatId))
803
- return;
804
- const replyTo = asRecord(message.reply_to_message);
805
- const replyId = String(replyTo?.message_id ?? "");
806
- const binding = this.projection.reply(chatId, replyId);
807
- if (binding === undefined || this.isStale(binding.request))
808
- return;
809
- const definition = binding.request.choices[binding.choice];
810
- const text = message.text;
811
- if (definition?.input === undefined || typeof text !== "string")
812
- return;
813
- await this.onAnswer({
814
- request: binding.request,
815
- response: { choice: binding.choice, input: { [definition.input.name]: text } },
816
- source: { channel: this.id, actorId, eventId: String(message.message_id ?? updateId) },
817
- idempotencyKey: `${this.id}:${String(message.message_id ?? updateId)}`,
818
- });
819
- this.projection.deleteReply(chatId, replyId);
820
- }
821
- registerCallbacks(request) {
822
- return Object.entries(request.choices).map(([choiceId, definition]) => {
823
- const token = callbackToken(request.decisionId, request.requestDigest, choiceId);
824
- this.projection.putCallback(token, { request, choice: choiceId });
825
- return [{ text: definition.label, callback_data: token }];
826
- });
827
- }
828
- channelPath() {
829
- return this.id.replace(":", "-");
830
- }
831
- allowed(userId, chatId) {
832
- return this.profile.allowedUserIds.has(userId) && this.profile.allowedChatIds.has(chatId);
833
- }
834
- isStale(request) {
835
- return request.expiresAt !== undefined && Date.parse(request.expiresAt) <= Date.now();
836
- }
837
- async poll(signal) {
838
- while (this.running && !signal.aborted) {
839
- try {
840
- if (!this.projection.acquire()) {
841
- await wait(leaseRetryDelay(), signal);
842
- continue;
843
- }
844
- await this.deliverPendingRequests();
845
- const result = await this.call("getUpdates", {
846
- offset: this.projection.offset(),
847
- timeout: 20,
848
- allowed_updates: ["callback_query", "message"],
849
- }, signal);
850
- const updates = Array.isArray(result) ? result : [];
851
- for (const update of updates) {
852
- await this.handleUpdate(update);
853
- const id = Number(asRecord(update)?.update_id);
854
- if (Number.isSafeInteger(id))
855
- this.projection.setOffset(id + 1);
856
- }
857
- if (!this.projection.renew())
858
- this.projection.release();
859
- }
860
- catch {
861
- if (signal.aborted)
862
- return;
863
- try {
864
- await wait(POLL_BACKOFF_MS, signal);
865
- }
866
- catch {
867
- if (signal.aborted)
868
- return;
869
- }
870
- }
871
- }
872
- }
873
- async deliverPendingRequests() {
874
- const channelPath = this.channelPath();
875
- for (const request of await this.store.listRequests()) {
876
- if (request.expiresAt !== undefined && Date.parse(request.expiresAt) <= Date.now())
877
- continue;
878
- if ((await this.store.readResolved(request.decisionId)) !== null)
879
- continue;
880
- if ((await this.store.readCancellation(request.decisionId)) !== null)
881
- continue;
882
- const deliveries = await this.store.listDeliveries(request.decisionId, channelPath);
883
- if (!deliveries.some((record) => record.state === "intent" && record.errorCode === "queued_for_channel_owner")) {
884
- continue;
885
- }
886
- try {
887
- await this.deliver(humanDecisionChannelRequest(request));
888
- }
889
- catch {
890
- // Durable delivery evidence controls retries for each request.
891
- }
892
- }
893
- }
894
- async call(method, payload, signal) {
895
- let response;
896
- try {
897
- response = await this.fetchFn(`${this.apiBase}/bot${encodeURIComponent(this.profile.token)}/${method}`, {
898
- method: "POST",
899
- headers: { "content-type": "application/json" },
900
- body: JSON.stringify(payload),
901
- ...(signal !== undefined ? { signal } : {}),
902
- });
903
- }
904
- catch {
905
- throw new TelegramCallError(`Telegram ${method} request did not return a response`, true, "telegram_response_unknown");
906
- }
907
- const body = record(await response.json(), `Telegram ${method} response`);
908
- if (!response.ok) {
909
- const description = typeof body.description === "string" ? body.description : "";
910
- if (method === "editMessageReplyMarkup" &&
911
- description.toLowerCase().includes("message is not modified")) {
912
- throw new TelegramCallError("Telegram message was already settled", false, "telegram_message_not_modified");
913
- }
914
- throw new TelegramCallError(`Telegram ${method} failed with HTTP ${response.status}`, false, `telegram_http_${response.status}`);
915
- }
916
- if (body.ok !== true) {
917
- throw new TelegramCallError(`Telegram ${method} rejected the request`, false, "telegram_rejected");
918
- }
919
- return body.result;
920
- }
921
- }
922
- export function createTelegramChannels(options) {
923
- const channels = new Map();
924
- for (const [name, profile] of Object.entries(options.config.telegramProfiles ?? {})) {
925
- const token = options.credentials[profile.credential];
926
- if (token === undefined) {
927
- throw new Error(`Telegram profile ${name} references a missing credential`);
928
- }
929
- const channel = new TelegramDecisionChannel({
930
- profileName: name,
931
- token,
932
- allowedUserIds: profile.allowedUserIds,
933
- allowedChatIds: profile.allowedChatIds,
934
- store: options.store,
935
- onAnswer: options.onAnswer,
936
- ...(options.configDir !== undefined ? { configDir: options.configDir } : {}),
937
- ...(options.fetchFn !== undefined ? { fetchFn: options.fetchFn } : {}),
938
- ...(options.apiBase !== undefined ? { apiBase: options.apiBase } : {}),
939
- });
940
- channels.set(channel.id, channel);
941
- }
942
- return channels;
943
- }
944
- export async function verifyTelegramTokenFile(tokenFile, fetchFn, apiBase = DEFAULT_API_BASE) {
945
- /* istanbul ignore next -- global fetch is used only by operator setup */
946
- const request = fetchFn ?? fetch;
947
- if (!path.isAbsolute(tokenFile))
948
- throw new Error("Telegram token file path must be absolute");
949
- await requirePrivateFile(tokenFile, "Telegram token file");
950
- const token = (await fsp.readFile(tokenFile, "utf8")).trim();
951
- if (token.length === 0)
952
- throw new Error("Telegram token file is empty");
953
- let response;
954
- try {
955
- response = await request(`${apiBase}/bot${encodeURIComponent(token)}/getMe`, {
956
- method: "POST",
957
- headers: { "content-type": "application/json" },
958
- body: "{}",
959
- });
960
- }
961
- catch {
962
- throw new Error("Telegram credential verification did not return a response");
963
- }
964
- if (!response.ok)
965
- throw new Error(`Telegram credential verification failed with HTTP ${response.status}`);
966
- const body = record(await response.json(), "Telegram credential verification response");
967
- if (body.ok !== true)
968
- throw new Error("Telegram credential verification was rejected");
969
- }
970
- export async function writeDecisionChannelProfile(options) {
971
- const configDir = options.configDir ?? decisionConfigDir();
972
- requireSimpleId(options.audience, "Decision audience");
973
- requireSimpleId(options.profile, "Telegram profile");
974
- requireSimpleId(options.credential, "Telegram credential");
975
- validateNumericIds(options.allowedUserIds, "Telegram allowed user IDs");
976
- validateNumericIds(options.allowedChatIds, "Telegram allowed chat IDs");
977
- if (!path.isAbsolute(options.tokenFile)) {
978
- throw new Error("Telegram token file path must be absolute");
979
- }
980
- await requirePrivateFile(options.tokenFile, "Telegram token file");
981
- await fsp.mkdir(configDir, { recursive: true, mode: 0o700 });
982
- const channelPath = path.join(configDir, "channels.json");
983
- const credentialPath = path.join(configDir, "credentials.json");
984
- const existingChannels = await readOptionalJson(channelPath);
985
- const existingCredentials = await readOptionalJson(credentialPath);
986
- const parsedChannels = existingChannels === null
987
- ? { schema: "pi-workflows.channels.v1", audiences: {} }
988
- : parseChannelConfig(existingChannels);
989
- const parsedCredentials = existingCredentials === null
990
- ? { schema: "pi-workflows.credentials.v1", telegram: {} }
991
- : parseCredentialConfig(existingCredentials);
992
- const channels = {
993
- schema: "pi-workflows.channels.v1",
994
- audiences: {
995
- ...parsedChannels.audiences,
996
- [options.audience]: {
997
- channels: ["pi", `telegram:${options.profile}`],
998
- accept: "first-valid-answer",
999
- },
1000
- },
1001
- telegramProfiles: {
1002
- ...parsedChannels.telegramProfiles,
1003
- [options.profile]: {
1004
- credential: options.credential,
1005
- allowedUserIds: options.allowedUserIds,
1006
- allowedChatIds: options.allowedChatIds,
1007
- },
1008
- },
1009
- };
1010
- const credentials = {
1011
- schema: "pi-workflows.credentials.v1",
1012
- telegram: {
1013
- ...parsedCredentials.telegram,
1014
- [options.credential]: { tokenFile: options.tokenFile },
1015
- },
1016
- };
1017
- await writePrivateJson(channelPath, channels);
1018
- await writePrivateJson(credentialPath, credentials);
1019
- }
1020
- function parseChannelConfig(value) {
1021
- const config = record(value, "pi-workflows channel config");
1022
- if (config.schema !== "pi-workflows.channels.v1") {
1023
- throw new Error("pi-workflows channel config schema is invalid");
1024
- }
1025
- const telegramProfiles = {};
1026
- if (config.telegramProfiles !== undefined) {
1027
- for (const [name, raw] of Object.entries(record(config.telegramProfiles, "Telegram profiles"))) {
1028
- requireSimpleId(name, "Telegram profile");
1029
- const profile = record(raw, `Telegram profile ${name}`);
1030
- const allowedUserIds = stringArray(profile.allowedUserIds, `Telegram profile ${name} user IDs`);
1031
- const allowedChatIds = stringArray(profile.allowedChatIds, `Telegram profile ${name} chat IDs`);
1032
- validateNumericIds(allowedUserIds, `Telegram profile ${name} user IDs`);
1033
- validateNumericIds(allowedChatIds, `Telegram profile ${name} chat IDs`);
1034
- telegramProfiles[name] = {
1035
- credential: requireSimpleId(profile.credential, `Telegram profile ${name} credential`),
1036
- allowedUserIds,
1037
- allowedChatIds,
1038
- };
1039
- }
1040
- }
1041
- const audiencesRaw = record(config.audiences, "pi-workflows audiences");
1042
- const audiences = {};
1043
- for (const [name, raw] of Object.entries(audiencesRaw)) {
1044
- requireSimpleId(name, "Decision audience");
1045
- const audience = record(raw, `Audience ${name}`);
1046
- if (audience.accept !== "first-valid-answer") {
1047
- throw new Error(`Audience ${name} accept policy must be first-valid-answer`);
1048
- }
1049
- const channels = stringArray(audience.channels, `Audience ${name} channels`);
1050
- if (channels.length === 0)
1051
- throw new Error(`Audience ${name} must have at least one channel`);
1052
- for (const channel of channels) {
1053
- if (channel !== "pi") {
1054
- const match = /^telegram:([A-Za-z0-9][A-Za-z0-9._-]{0,199})$/.exec(channel);
1055
- if (match === null || telegramProfiles[match[1]] === undefined) {
1056
- throw new Error(`Audience ${name} references an unknown channel`);
1057
- }
1058
- }
1059
- }
1060
- audiences[name] = { channels, accept: "first-valid-answer" };
1061
- }
1062
- return {
1063
- schema: "pi-workflows.channels.v1",
1064
- audiences,
1065
- ...(Object.keys(telegramProfiles).length > 0 ? { telegramProfiles } : {}),
1066
- };
1067
- }
1068
- function parseCredentialConfig(value) {
1069
- const config = record(value, "pi-workflows credential config");
1070
- if (config.schema !== "pi-workflows.credentials.v1") {
1071
- throw new Error("pi-workflows credential config schema is invalid");
1072
- }
1073
- const telegram = {};
1074
- for (const [name, raw] of Object.entries(record(config.telegram, "Telegram credentials"))) {
1075
- requireSimpleId(name, "Telegram credential");
1076
- const credential = record(raw, `Telegram credential ${name}`);
1077
- telegram[name] = {
1078
- tokenFile: requireString(credential.tokenFile, `Telegram credential ${name} tokenFile`),
1079
- };
1080
- }
1081
- return { schema: "pi-workflows.credentials.v1", telegram };
1082
- }
1083
- function deliveryRecord(request, channel, attemptId, phase, state, createdAt, extra = {}) {
1084
- return {
1085
- schema: "pi-workflows.human-decision-delivery.v1",
1086
- attemptId,
1087
- decisionId: request.decisionId,
1088
- requestDigest: request.requestDigest,
1089
- presentationDigest: request.presentationDigest,
1090
- channel,
1091
- phase,
1092
- state,
1093
- createdAt,
1094
- ...(state === "intent" ? {} : { finishedAt: new Date().toISOString() }),
1095
- ...extra,
1096
- };
1097
- }
1098
- function isCompleteDelivery(record) {
1099
- return record.state === "confirmed" && record.phase === "complete";
1100
- }
1101
- function hasUnsettledDeliveryIntent(records) {
1102
- return records.some((record) => {
1103
- if (record.phase !== "part" || record.state !== "intent")
1104
- return false;
1105
- return !records.some((other) => other.phase === "part" &&
1106
- other.state !== "intent" &&
1107
- other.recipientIndex === record.recipientIndex &&
1108
- other.partIndex === record.partIndex &&
1109
- other.contentDigest === record.contentDigest);
1110
- });
1111
- }
1112
- export function renderDecisionText(request) {
1113
- const content = decisionDocumentSegments(request)
1114
- .map((segment) => operatorSafeText(segment.text))
1115
- .join("\n\n");
1116
- return `${content}\n\nDecision ${decisionPresentationFingerprint(request)}`;
1117
- }
1118
- export function renderTelegramParts(request, textLimit = TELEGRAM_TEXT_LIMIT) {
1119
- const fingerprint = decisionPresentationFingerprint(request);
1120
- const maximumHeader = telegramPartHeader(MAX_PRESENTATION_TRANSPORT_PARTS, MAX_PRESENTATION_TRANSPORT_PARTS, fingerprint);
1121
- const capacity = textLimit - maximumHeader.length;
1122
- if (capacity < 1)
1123
- throw new Error("Telegram decision text limit is too small for its part header");
1124
- const bodyParts = packDecisionSegments(decisionDocumentSegments(request).map((segment) => operatorSafeText(segment.text)), capacity);
1125
- if (bodyParts.length > MAX_PRESENTATION_TRANSPORT_PARTS) {
1126
- throw new Error(`Decision presentation renders as ${bodyParts.length} Telegram parts; limit is ${MAX_PRESENTATION_TRANSPORT_PARTS}`);
1127
- }
1128
- return bodyParts.map((body, index) => {
1129
- const text = `${telegramPartHeader(index + 1, bodyParts.length, fingerprint)}${body}`;
1130
- if (text.length > textLimit) {
1131
- throw new Error(`Telegram decision part ${index + 1} exceeds the ${textLimit} character limit`);
1132
- }
1133
- return text;
1134
- });
1135
- }
1136
- function renderPiPresentationLines(request, width, theme) {
1137
- const lines = [];
1138
- for (const segment of decisionDocumentSegments(request).filter((candidate) => candidate.kind !== "choices")) {
1139
- const text = operatorSafeText(segment.text);
1140
- const styled = segment.kind === "title"
1141
- ? theme.fg("accent", theme.bold(text))
1142
- : segment.kind === "section"
1143
- ? theme.fg("text", theme.bold(text))
1144
- : segment.kind === "preformatted"
1145
- ? theme.fg("muted", text)
1146
- : theme.fg("text", text);
1147
- for (const rawLine of styled.split("\n")) {
1148
- lines.push(...wrapTextWithAnsi(rawLine.length === 0 ? " " : rawLine, Math.max(1, width)));
1149
- }
1150
- lines.push("");
1151
- }
1152
- lines.push(theme.fg("dim", `Decision ${decisionPresentationFingerprint(request)}`));
1153
- return lines;
1154
- }
1155
- function telegramPartHeader(part, total, fingerprint) {
1156
- return `Part ${part}/${total}\nDecision ${fingerprint}\n\n`;
1157
- }
1158
- function packDecisionSegments(segments, capacity) {
1159
- const parts = [];
1160
- let current = "";
1161
- const flush = () => {
1162
- if (current.length === 0)
1163
- return;
1164
- parts.push(current);
1165
- current = "";
1166
- };
1167
- for (const segment of segments) {
1168
- const pieces = splitTransportText(segment, capacity);
1169
- for (const piece of pieces) {
1170
- const candidate = current.length === 0 ? piece : `${current}\n\n${piece}`;
1171
- if (candidate.length <= capacity) {
1172
- current = candidate;
1173
- }
1174
- else {
1175
- flush();
1176
- current = piece;
1177
- }
1178
- }
1179
- }
1180
- flush();
1181
- return parts.length === 0 ? ["Decision"] : parts;
1182
- }
1183
- function splitTransportText(value, capacity) {
1184
- const parts = [];
1185
- let remaining = value;
1186
- while (remaining.length > capacity) {
1187
- let end = codePointBoundary(remaining, capacity);
1188
- const newline = remaining.lastIndexOf("\n", end);
1189
- if (newline > 0)
1190
- end = newline;
1191
- parts.push(remaining.slice(0, end));
1192
- remaining = remaining.slice(end);
1193
- }
1194
- if (remaining.length > 0)
1195
- parts.push(remaining);
1196
- return parts;
1197
- }
1198
- function codePointBoundary(value, limit) {
1199
- let end = Math.min(limit, value.length);
1200
- const last = value.charCodeAt(end - 1);
1201
- if (last >= 0xd800 && last <= 0xdbff && end < value.length)
1202
- end -= 1;
1203
- return Math.max(1, end);
1204
- }
1205
- function operatorSafeText(value) {
1206
- return value
1207
- .replaceAll("\r\n", "\n")
1208
- .replaceAll("\r", "\n")
1209
- .split("")
1210
- .map((character) => {
1211
- const code = character.charCodeAt(0);
1212
- return code <= 8 || (code >= 11 && code <= 31) || code === 127 ? "�" : character;
1213
- })
1214
- .join("");
1215
- }
1216
- function callbackToken(decisionId, requestDigest, choice) {
1217
- return `piw:${createHash("sha256")
1218
- .update(`${decisionId}\0${requestDigest}\0${choice}`)
1219
- .digest("hex")
1220
- .slice(0, 24)}`;
1221
- }
1222
- function telegramMessageId(value) {
1223
- const message = record(value, "Telegram message");
1224
- const id = message.message_id;
1225
- if (!Number.isSafeInteger(id))
1226
- throw new Error("Telegram message id is missing");
1227
- return String(id);
1228
- }
1229
- function normalizeCallError(error) {
1230
- return error instanceof TelegramCallError
1231
- ? error
1232
- : new TelegramCallError("Telegram request failed", true, "telegram_response_unknown");
1233
- }
1234
- function validateNumericIds(values, label) {
1235
- if (values.length === 0 || values.some((value) => !NUMERIC_ID.test(value))) {
1236
- throw new Error(`${label} must contain numeric IDs`);
1237
- }
1238
- }
1239
- function requireSimpleId(value, label) {
1240
- const text = requireString(value, label);
1241
- if (!SIMPLE_ID.test(text))
1242
- throw new Error(`${label} is invalid`);
1243
- return text;
1244
- }
1245
- function requireString(value, label) {
1246
- if (typeof value !== "string" || value.trim().length === 0) {
1247
- throw new Error(`${label} must be a non-empty string`);
1248
- }
1249
- return value.trim();
1250
- }
1251
- function stringArray(value, label) {
1252
- if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) {
1253
- throw new Error(`${label} must be an array of strings`);
1254
- }
1255
- return [...value];
1256
- }
1257
- function record(value, label) {
1258
- if (value === null || typeof value !== "object" || Array.isArray(value)) {
1259
- throw new Error(`${label} must be an object`);
1260
- }
1261
- return value;
1262
- }
1263
- function asRecord(value) {
1264
- return value !== null && typeof value === "object" && !Array.isArray(value)
1265
- ? value
1266
- : null;
1267
- }
1268
- async function requirePrivateFile(filePath, label) {
1269
- const stat = await fsp.stat(filePath);
1270
- if (!stat.isFile())
1271
- throw new Error(`${label} must be a file`);
1272
- if ((stat.mode & 0o077) !== 0)
1273
- throw new Error(`${label} permissions must be 0600`);
1274
- }
1275
- async function readOptionalJson(filePath) {
1276
- try {
1277
- return JSON.parse(await fsp.readFile(filePath, "utf8"));
1278
- }
1279
- catch (error) {
1280
- if (error.code === "ENOENT")
1281
- return null;
1282
- throw error;
1283
- }
1284
- }
1285
- async function writePrivateJson(filePath, value) {
1286
- const temporary = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
1287
- await fsp.writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
1288
- await fsp.rename(temporary, filePath);
1289
- await fsp.chmod(filePath, 0o600);
1290
- }
1291
- function leaseRetryDelay() {
1292
- return LEASE_RETRY_MS + Math.floor(Math.random() * 1_000);
1293
- }
1294
- async function wait(milliseconds, signal) {
1295
- await new Promise((resolve) => {
1296
- const timer = setTimeout(resolve, milliseconds);
1297
- const abort = () => {
1298
- clearTimeout(timer);
1299
- resolve();
1300
- };
1301
- if (signal.aborted)
1302
- abort();
1303
- else
1304
- signal.addEventListener("abort", abort, { once: true });
1305
- });
1306
- }
1307
- //# sourceMappingURL=decision-channels.js.map