@osolmaz/pi-workflows 0.12.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +56 -31
- package/dist/builtins/autodoc.workflow.d.ts +4 -4
- package/dist/builtins/autoimplement.workflow.d.ts +797 -142
- package/dist/builtins/autoimplement.workflow.js +17 -105
- package/dist/builtins/autoimplement.workflow.js.map +1 -1
- package/dist/builtins/autoplan.workflow.d.ts +126 -13
- package/dist/builtins/autoplan.workflow.js +200 -29
- package/dist/builtins/autoplan.workflow.js.map +1 -1
- package/dist/builtins/catalog.js +7 -5
- package/dist/builtins/catalog.js.map +1 -1
- package/dist/builtins/index.d.ts +4 -2
- package/dist/builtins/index.js +3 -1
- package/dist/builtins/index.js.map +1 -1
- package/dist/builtins/monitor.workflow.d.ts +56 -23
- package/dist/builtins/monitor.workflow.js +508 -329
- package/dist/builtins/monitor.workflow.js.map +1 -1
- package/dist/builtins/pi-agent-group.d.ts +72 -0
- package/dist/builtins/pi-agent-group.js +1087 -0
- package/dist/builtins/pi-agent-group.js.map +1 -0
- package/dist/builtins/plain-summary.workflow.d.ts +32 -0
- package/dist/builtins/plain-summary.workflow.js +134 -0
- package/dist/builtins/plain-summary.workflow.js.map +1 -0
- package/dist/builtins/plan-approval.workflow.d.ts +39 -5
- package/dist/builtins/plan-approval.workflow.js +92 -14
- package/dist/builtins/plan-approval.workflow.js.map +1 -1
- package/dist/builtins/plan-change.workflow.d.ts +449 -0
- package/dist/builtins/plan-change.workflow.js +256 -0
- package/dist/builtins/plan-change.workflow.js.map +1 -0
- package/dist/builtins/plan-presentation.js +2 -2
- package/dist/builtins/plan-presentation.js.map +1 -1
- package/dist/builtins/sanity-check.workflow.d.ts +50 -6
- package/dist/builtins/sanity-check.workflow.js +150 -28
- package/dist/builtins/sanity-check.workflow.js.map +1 -1
- package/dist/controllers/effects.d.ts +3 -2
- package/dist/controllers/effects.js +8 -1
- package/dist/controllers/effects.js.map +1 -1
- package/dist/controllers/index.d.ts +1 -1
- package/dist/controllers/index.js +1 -1
- package/dist/controllers/index.js.map +1 -1
- package/dist/controllers/manager.d.ts +1 -0
- package/dist/controllers/manager.js +41 -17
- package/dist/controllers/manager.js.map +1 -1
- package/dist/controllers/sqlite.d.ts +107 -85
- package/dist/controllers/sqlite.js +1611 -1486
- package/dist/controllers/sqlite.js.map +1 -1
- package/dist/controllers/store.d.ts +9 -8
- package/dist/controllers/store.js +1 -31
- package/dist/controllers/store.js.map +1 -1
- package/dist/controllers/types.d.ts +3 -0
- package/dist/controllers/workflow-engine-scheduler.d.ts +1 -1
- package/dist/controllers/workflow-engine-scheduler.js +3 -5
- package/dist/controllers/workflow-engine-scheduler.js.map +1 -1
- package/dist/controllers/workflows.d.ts +2 -2
- package/dist/controllers/workflows.js +11 -10
- package/dist/controllers/workflows.js.map +1 -1
- package/dist/extension/controller-host.js +2 -2
- package/dist/extension/controller-host.js.map +1 -1
- package/dist/extension/decision-channels.d.ts +4 -2
- package/dist/extension/decision-channels.js +333 -146
- package/dist/extension/decision-channels.js.map +1 -1
- package/dist/extension/executor.d.ts +9 -2
- package/dist/extension/executor.js +89 -2
- package/dist/extension/executor.js.map +1 -1
- package/dist/extension/herdr-viewer.d.ts +0 -1
- package/dist/extension/herdr-viewer.js +0 -4
- package/dist/extension/herdr-viewer.js.map +1 -1
- package/dist/extension/index.js +209 -127
- package/dist/extension/index.js.map +1 -1
- package/dist/extension/recorder.d.ts +3 -10
- package/dist/extension/recorder.js +13 -22
- package/dist/extension/recorder.js.map +1 -1
- package/dist/extension/session-events.d.ts +2 -2
- package/dist/extension/step-message.d.ts +7 -2
- package/dist/extension/step-message.js +94 -2
- package/dist/extension/step-message.js.map +1 -1
- package/dist/extension/widget.js +29 -3
- package/dist/extension/widget.js.map +1 -1
- package/dist/host/rpc-executor.d.ts +3 -0
- package/dist/host/rpc-executor.js +2 -0
- package/dist/host/rpc-executor.js.map +1 -1
- package/dist/host/runner.d.ts +3 -6
- package/dist/host/runner.js +83 -32
- package/dist/host/runner.js.map +1 -1
- package/dist/render/graph-render.js +15 -10
- package/dist/render/graph-render.js.map +1 -1
- package/dist/state/database.d.ts +35 -0
- package/dist/state/database.js +287 -0
- package/dist/state/database.js.map +1 -0
- package/dist/state/index.d.ts +4 -0
- package/dist/state/index.js +5 -0
- package/dist/state/index.js.map +1 -0
- package/dist/state/json.d.ts +6 -0
- package/dist/state/json.js +38 -0
- package/dist/state/json.js.map +1 -0
- package/dist/state/mutation.d.ts +57 -0
- package/dist/state/mutation.js +255 -0
- package/dist/state/mutation.js.map +1 -0
- package/dist/state/schema.d.ts +6 -0
- package/dist/state/schema.js +473 -0
- package/dist/state/schema.js.map +1 -0
- package/dist/viewer/cli.d.ts +2 -2
- package/dist/viewer/cli.js +142 -122
- package/dist/viewer/cli.js.map +1 -1
- package/dist/viewer/render.d.ts +4 -4
- package/dist/viewer/render.js +9 -23
- package/dist/viewer/render.js.map +1 -1
- package/dist/viewer/session-reducer.d.ts +1 -1
- package/dist/viewer/session-reducer.js +1 -1
- package/dist/viewer/tui.d.ts +3 -3
- package/dist/viewer/tui.js +15 -13
- package/dist/viewer/tui.js.map +1 -1
- package/dist/viewer/watch.d.ts +2 -5
- package/dist/viewer/watch.js +13 -13
- package/dist/viewer/watch.js.map +1 -1
- package/dist/workflows/catalog.js +7 -2
- package/dist/workflows/catalog.js.map +1 -1
- package/dist/workflows/composition.js +20 -2
- package/dist/workflows/composition.js.map +1 -1
- package/dist/workflows/decision-presentation.d.ts +1 -1
- package/dist/workflows/decision-presentation.js +51 -38
- package/dist/workflows/decision-presentation.js.map +1 -1
- package/dist/workflows/definition.d.ts +6 -2
- package/dist/workflows/definition.js +17 -0
- package/dist/workflows/definition.js.map +1 -1
- package/dist/workflows/engine.d.ts +7 -7
- package/dist/workflows/engine.js +196 -115
- package/dist/workflows/engine.js.map +1 -1
- package/dist/workflows/errors.d.ts +16 -3
- package/dist/workflows/errors.js +18 -3
- package/dist/workflows/errors.js.map +1 -1
- package/dist/workflows/human-decision.d.ts +45 -8
- package/dist/workflows/human-decision.js +641 -274
- package/dist/workflows/human-decision.js.map +1 -1
- package/dist/workflows/index.d.ts +5 -6
- package/dist/workflows/index.js +4 -5
- package/dist/workflows/index.js.map +1 -1
- package/dist/workflows/progress.d.ts +1 -0
- package/dist/workflows/progress.js +15 -3
- package/dist/workflows/progress.js.map +1 -1
- package/dist/workflows/schema.js +27 -1
- package/dist/workflows/schema.js.map +1 -1
- package/dist/workflows/store.d.ts +83 -103
- package/dist/workflows/store.js +907 -951
- package/dist/workflows/store.js.map +1 -1
- package/dist/workflows/types.d.ts +88 -126
- package/docs/CONTROLLERS.md +10 -8
- package/docs/DEFERRED_TURNS.md +2 -2
- package/docs/DESIGN_PHILOSOPHY.md +4 -2
- package/docs/HUMAN_DECISIONS.md +36 -47
- package/docs/HUMAN_DECISION_PRESENTATIONS.md +14 -24
- package/docs/MONITOR.md +179 -237
- package/docs/SQLITE_STATE.md +219 -0
- package/docs/WORKFLOW_COMPOSITION.md +27 -26
- package/docs/WORKFLOW_STEP_MESSAGES.md +20 -18
- package/docs/WORKFLOW_UPDATES.md +10 -9
- package/docs/development.md +9 -9
- package/docs/live-replay-protocol.md +28 -37
- package/docs/plans/2026-08-16-workflow-updates-plan.md +1 -1
- package/docs/plans/2026-08-19-human-decision-gates-plan.md +1 -1
- package/docs/plans/2026-08-21-autoimplement-timeout-fallback-plan.md +1 -1
- package/docs/plans/2026-08-21-plan-change-approval-policy-plan.md +322 -0
- package/docs/plans/2026-08-21-sanity-check-plan.md +217 -95
- package/docs/plans/2026-08-22-goal-finishing-monitor-plan.md +200 -0
- package/docs/plans/2026-08-23-assistant-agent-completion-plan.md +701 -0
- package/docs/plans/2026-08-23-sqlite-state-plan.md +970 -0
- package/docs/plans/replayable-run-bundles-implementation-plan.md +2 -2
- package/docs/plans/session-event-replay-implementation-plan.md +1 -1
- package/docs/plans/tui-viewer-implementation-plan.md +1 -1
- package/docs/session-event-journal.md +63 -434
- package/docs/tui-viewer.md +13 -15
- package/docs/workflows.md +127 -53
- package/examples/workflows/approved-plan.workflow.ts +19 -46
- package/examples/workflows/plain-summary.workflow.ts +1 -0
- package/herdr-plugin.toml +1 -1
- package/package.json +7 -7
- package/plugins/herdr/viewer.mjs +1 -11
- package/schemas/human-decision-accepted-v1.schema.json +15 -3
- package/schemas/human-decision-continuation-v1.schema.json +10 -1
- package/schemas/human-decision-delivery-v1.schema.json +8 -0
- package/schemas/human-decision-receipt-v1.schema.json +8 -0
- package/schemas/human-decision-request-v1.schema.json +24 -4
- package/skills/autoimplement/SKILL.md +27 -0
- package/skills/autoplan/SKILL.md +5 -4
- package/skills/monitor/SKILL.md +91 -135
- package/skills/pi-workflows/SKILL.md +9 -8
- package/skills/sanity-check/SKILL.md +44 -0
- package/src/builtins/autoimplement.workflow.ts +19 -118
- package/src/builtins/autoplan.workflow.ts +290 -40
- package/src/builtins/catalog.ts +7 -5
- package/src/builtins/index.ts +18 -1
- package/src/builtins/monitor.workflow.ts +673 -382
- package/src/builtins/pi-agent-group.ts +1407 -0
- package/src/builtins/plain-summary.workflow.ts +185 -0
- package/src/builtins/plan-approval.workflow.ts +157 -24
- package/src/builtins/plan-change.workflow.ts +321 -0
- package/src/builtins/plan-presentation.ts +2 -2
- package/src/builtins/sanity-check.workflow.ts +248 -48
- package/src/controllers/effects.ts +7 -0
- package/src/controllers/index.ts +0 -6
- package/src/controllers/manager.ts +72 -32
- package/src/controllers/sqlite.ts +2420 -1948
- package/src/controllers/store.ts +17 -38
- package/src/controllers/types.ts +3 -0
- package/src/controllers/workflow-engine-scheduler.ts +4 -8
- package/src/controllers/workflows.ts +45 -21
- package/src/extension/controller-host.ts +1 -2
- package/src/extension/decision-channels.ts +456 -203
- package/src/extension/executor.ts +105 -2
- package/src/extension/herdr-viewer.ts +0 -5
- package/src/extension/index.ts +251 -151
- package/src/extension/recorder.ts +13 -22
- package/src/extension/session-events.ts +2 -2
- package/src/extension/step-message.ts +106 -4
- package/src/extension/widget.ts +32 -5
- package/src/host/rpc-executor.ts +4 -0
- package/src/host/runner.ts +93 -41
- package/src/render/graph-render.ts +12 -6
- package/src/state/database.ts +375 -0
- package/src/state/index.ts +29 -0
- package/src/state/json.ts +42 -0
- package/src/state/mutation.ts +435 -0
- package/src/state/schema.ts +475 -0
- package/src/viewer/cli.ts +151 -144
- package/src/viewer/render.ts +19 -30
- package/src/viewer/session-reducer.ts +1 -1
- package/src/viewer/tui.ts +18 -17
- package/src/viewer/watch.ts +13 -17
- package/src/workflows/catalog.ts +7 -2
- package/src/workflows/composition.ts +21 -3
- package/src/workflows/decision-presentation.ts +56 -43
- package/src/workflows/definition.ts +36 -3
- package/src/workflows/engine.ts +228 -130
- package/src/workflows/errors.ts +27 -3
- package/src/workflows/human-decision.ts +921 -349
- package/src/workflows/index.ts +20 -30
- package/src/workflows/progress.ts +18 -3
- package/src/workflows/schema.ts +36 -1
- package/src/workflows/store.ts +1300 -1163
- package/src/workflows/types.ts +100 -137
- package/dist/builtins/sanity-check-session.d.ts +0 -17
- package/dist/builtins/sanity-check-session.js +0 -168
- package/dist/builtins/sanity-check-session.js.map +0 -1
- package/dist/workflows/artifacts.d.ts +0 -40
- package/dist/workflows/artifacts.js +0 -155
- package/dist/workflows/artifacts.js.map +0 -1
- package/dist/workflows/migrate-sources.d.ts +0 -42
- package/dist/workflows/migrate-sources.js +0 -133
- package/dist/workflows/migrate-sources.js.map +0 -1
- package/docs/run-bundles.md +0 -481
- package/schemas/human-decision-accepted-v2.schema.json +0 -50
- package/schemas/human-decision-delivery-v2.schema.json +0 -36
- package/schemas/human-decision-receipt-v2.schema.json +0 -39
- package/schemas/human-decision-request-v2.schema.json +0 -69
- package/schemas/human-decision-resolution-v2.schema.json +0 -27
- package/src/builtins/sanity-check-session.ts +0 -205
- package/src/workflows/artifacts.ts +0 -188
- package/src/workflows/migrate-sources.ts +0 -178
|
@@ -1,20 +1,21 @@
|
|
|
1
|
-
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import fs from "node:fs";
|
|
1
|
+
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
3
2
|
import fsp from "node:fs/promises";
|
|
4
3
|
import os from "node:os";
|
|
5
4
|
import path from "node:path";
|
|
6
5
|
import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
7
6
|
import { Key, matchesKey, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
8
|
-
import
|
|
7
|
+
import type { StateDatabase } from "../state/database.js";
|
|
8
|
+
import { resourceIdFor, tokenHash } from "../state/mutation.js";
|
|
9
9
|
import {
|
|
10
10
|
MAX_PRESENTATION_TRANSPORT_PARTS,
|
|
11
11
|
decisionDocumentSegments,
|
|
12
12
|
decisionPresentationFingerprint,
|
|
13
13
|
digestCanonical,
|
|
14
|
+
humanDecisionChannelRequest,
|
|
14
15
|
} from "../workflows/decision-presentation.js";
|
|
15
16
|
import { HumanDecisionStore, createHumanDecisionAttemptId } from "../workflows/human-decision.js";
|
|
16
17
|
import type {
|
|
17
|
-
|
|
18
|
+
ResolvedHumanDecision,
|
|
18
19
|
HumanDecisionAnswerSource,
|
|
19
20
|
HumanDecisionCancellationRecord,
|
|
20
21
|
HumanDecisionChannelRequest,
|
|
@@ -47,7 +48,7 @@ export type HumanDecisionDeliveryResult = {
|
|
|
47
48
|
errorCode?: string;
|
|
48
49
|
};
|
|
49
50
|
|
|
50
|
-
export type SettledHumanDecision =
|
|
51
|
+
export type SettledHumanDecision = ResolvedHumanDecision | HumanDecisionCancellationRecord;
|
|
51
52
|
|
|
52
53
|
export interface HumanDecisionChannel {
|
|
53
54
|
readonly id: string;
|
|
@@ -154,6 +155,9 @@ export class PiDecisionChannel implements HumanDecisionChannel {
|
|
|
154
155
|
}
|
|
155
156
|
|
|
156
157
|
async deliver(request: HumanDecisionChannelRequest): Promise<HumanDecisionDeliveryResult> {
|
|
158
|
+
if (await this.alreadyDecided(request.decisionId)) {
|
|
159
|
+
return { status: "confirmed", channel: this.id, attemptId: "adopted" };
|
|
160
|
+
}
|
|
157
161
|
const attemptId = createHumanDecisionAttemptId();
|
|
158
162
|
const createdAt = new Date().toISOString();
|
|
159
163
|
const controller = new AbortController();
|
|
@@ -162,7 +166,7 @@ export class PiDecisionChannel implements HumanDecisionChannel {
|
|
|
162
166
|
await this.options.store.recordDelivery(
|
|
163
167
|
request,
|
|
164
168
|
this.id,
|
|
165
|
-
|
|
169
|
+
deliveryRecord(request, this.id, attemptId, "intent", "intent", createdAt),
|
|
166
170
|
);
|
|
167
171
|
const entries = Object.entries(request.choices);
|
|
168
172
|
const selectedChoice = await this.options.ui.custom<string | undefined>(
|
|
@@ -241,7 +245,7 @@ export class PiDecisionChannel implements HumanDecisionChannel {
|
|
|
241
245
|
await this.options.store.recordDelivery(
|
|
242
246
|
request,
|
|
243
247
|
this.id,
|
|
244
|
-
|
|
248
|
+
deliveryRecord(
|
|
245
249
|
request,
|
|
246
250
|
this.id,
|
|
247
251
|
`${attemptId}-cancelled`,
|
|
@@ -267,7 +271,7 @@ export class PiDecisionChannel implements HumanDecisionChannel {
|
|
|
267
271
|
await this.options.store.recordDelivery(
|
|
268
272
|
request,
|
|
269
273
|
this.id,
|
|
270
|
-
|
|
274
|
+
deliveryRecord(
|
|
271
275
|
request,
|
|
272
276
|
this.id,
|
|
273
277
|
`${attemptId}-cancelled`,
|
|
@@ -281,6 +285,9 @@ export class PiDecisionChannel implements HumanDecisionChannel {
|
|
|
281
285
|
}
|
|
282
286
|
response = { choice: selectedChoice, input: { [definition.input.name]: text } };
|
|
283
287
|
}
|
|
288
|
+
if (await this.alreadyDecided(request.decisionId)) {
|
|
289
|
+
return { status: "confirmed", channel: this.id, attemptId: "adopted" };
|
|
290
|
+
}
|
|
284
291
|
const eventId = createHumanDecisionAttemptId();
|
|
285
292
|
await this.options.onAnswer({
|
|
286
293
|
request,
|
|
@@ -291,7 +298,7 @@ export class PiDecisionChannel implements HumanDecisionChannel {
|
|
|
291
298
|
await this.options.store.recordDelivery(
|
|
292
299
|
request,
|
|
293
300
|
this.id,
|
|
294
|
-
|
|
301
|
+
deliveryRecord(
|
|
295
302
|
request,
|
|
296
303
|
this.id,
|
|
297
304
|
`${attemptId}-confirmed`,
|
|
@@ -307,6 +314,13 @@ export class PiDecisionChannel implements HumanDecisionChannel {
|
|
|
307
314
|
}
|
|
308
315
|
}
|
|
309
316
|
|
|
317
|
+
private async alreadyDecided(decisionId: string): Promise<boolean> {
|
|
318
|
+
return (
|
|
319
|
+
(await this.options.store.readResolved(decisionId)) !== null ||
|
|
320
|
+
(await this.options.store.readCancellation(decisionId)) !== null
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
|
|
310
324
|
async settle(decision: SettledHumanDecision): Promise<void> {
|
|
311
325
|
this.promptAbort?.abort();
|
|
312
326
|
if (this.settlementTask !== null) return await this.settlementTask;
|
|
@@ -354,8 +368,6 @@ type ReplyBinding = CallbackBinding & {
|
|
|
354
368
|
promptMessageId: string;
|
|
355
369
|
};
|
|
356
370
|
|
|
357
|
-
type CallbackRow = { request_json: string; choice_id: string };
|
|
358
|
-
type ReplyRow = { request_json: string; choice_id: string; chat_id: string; message_id: string };
|
|
359
371
|
type MessageRow = { chat_id: string; message_id: string };
|
|
360
372
|
type MessagePartRow = {
|
|
361
373
|
recipient_index: number;
|
|
@@ -364,189 +376,214 @@ type MessagePartRow = {
|
|
|
364
376
|
chat_id: string;
|
|
365
377
|
message_id: string;
|
|
366
378
|
};
|
|
367
|
-
type OffsetRow = { next_offset: number };
|
|
368
379
|
|
|
369
380
|
class TelegramProjection {
|
|
370
|
-
private readonly
|
|
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
|
-
|
|
387
|
+
private readonly state: StateDatabase,
|
|
374
388
|
private readonly profile: string,
|
|
375
389
|
private readonly owner: string,
|
|
376
390
|
) {
|
|
377
|
-
|
|
378
|
-
this.
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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
|
-
|
|
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
|
-
|
|
434
|
-
const
|
|
435
|
-
|
|
436
|
-
.
|
|
437
|
-
|
|
438
|
-
|
|
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
|
-
|
|
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(
|
|
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.
|
|
451
|
-
.prepare(
|
|
452
|
-
|
|
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.
|
|
458
|
-
|
|
459
|
-
|
|
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
|
-
|
|
464
|
-
(
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
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.
|
|
474
|
-
.
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
532
|
-
.
|
|
533
|
-
|
|
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.
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
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
|
-
|
|
574
|
+
const rows = this.state.connection
|
|
546
575
|
.prepare(
|
|
547
|
-
|
|
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.
|
|
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
|
-
|
|
561
|
-
.
|
|
562
|
-
|
|
563
|
-
)
|
|
564
|
-
|
|
597
|
+
const localId = `telegram-parts-${createHash("sha256")
|
|
598
|
+
.update(`${this.channelId}\0${decisionId}`)
|
|
599
|
+
.digest("hex")
|
|
600
|
+
.slice(0, 40)}`;
|
|
601
|
+
this.mutate("channel.message_part_confirmed", { decisionId, recipientIndex, partIndex }, () => {
|
|
602
|
+
const parentHash = this.state.putJson({ decisionId });
|
|
603
|
+
this.state.connection
|
|
604
|
+
.prepare(
|
|
605
|
+
`INSERT INTO channel_messages(
|
|
606
|
+
message_id, channel_id, decision_id, purpose, content_hash,
|
|
607
|
+
status, created_at, updated_at
|
|
608
|
+
) VALUES (?, ?, ?, 'delivery', ?, 'confirmed', ?, ?)
|
|
609
|
+
ON CONFLICT(message_id) DO NOTHING`,
|
|
610
|
+
)
|
|
611
|
+
.run(localId, this.channelId, decisionId, parentHash, Date.now(), Date.now());
|
|
612
|
+
const contentHash = this.state.putText(contentDigest);
|
|
613
|
+
this.state.connection
|
|
614
|
+
.prepare(
|
|
615
|
+
`INSERT INTO channel_message_parts(
|
|
616
|
+
message_id, recipient_index, part_index, content_hash,
|
|
617
|
+
external_conversation_ref, external_message_ref
|
|
618
|
+
) VALUES (?, ?, ?, ?, ?, ?)
|
|
619
|
+
ON CONFLICT(message_id, recipient_index, part_index) DO UPDATE SET
|
|
620
|
+
content_hash = excluded.content_hash,
|
|
621
|
+
external_conversation_ref = excluded.external_conversation_ref,
|
|
622
|
+
external_message_ref = excluded.external_message_ref`,
|
|
623
|
+
)
|
|
624
|
+
.run(localId, recipientIndex, partIndex, contentHash, chatId, messageId);
|
|
625
|
+
});
|
|
565
626
|
}
|
|
566
627
|
|
|
567
628
|
messagePart(
|
|
@@ -569,14 +630,184 @@ class TelegramProjection {
|
|
|
569
630
|
recipientIndex: number,
|
|
570
631
|
partIndex: number,
|
|
571
632
|
): MessagePartRow | undefined {
|
|
572
|
-
|
|
633
|
+
const localId = `telegram-parts-${createHash("sha256")
|
|
634
|
+
.update(`${this.channelId}\0${decisionId}`)
|
|
635
|
+
.digest("hex")
|
|
636
|
+
.slice(0, 40)}`;
|
|
637
|
+
const row = this.state.connection
|
|
638
|
+
.prepare(
|
|
639
|
+
`SELECT recipient_index AS recipientIndex, part_index AS partIndex,
|
|
640
|
+
content_hash AS contentHash,
|
|
641
|
+
external_conversation_ref AS chatId,
|
|
642
|
+
external_message_ref AS messageId
|
|
643
|
+
FROM channel_message_parts
|
|
644
|
+
WHERE message_id = ? AND recipient_index = ? AND part_index = ?`,
|
|
645
|
+
)
|
|
646
|
+
.get(localId, recipientIndex, partIndex);
|
|
647
|
+
if (!isMessagePartProjectionRow(row)) return undefined;
|
|
648
|
+
const content = this.state.readBlob(row.contentHash);
|
|
649
|
+
/* istanbul ignore if -- foreign key guarantees the content blob */
|
|
650
|
+
if (content === undefined) throw new Error("Telegram part content is missing");
|
|
651
|
+
return {
|
|
652
|
+
recipient_index: row.recipientIndex,
|
|
653
|
+
part_index: row.partIndex,
|
|
654
|
+
content_digest: content.content.toString("utf8"),
|
|
655
|
+
chat_id: row.chatId,
|
|
656
|
+
message_id: row.messageId,
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
private putInbox(
|
|
661
|
+
eventId: string,
|
|
662
|
+
type: "callback" | "reply",
|
|
663
|
+
binding: CallbackBinding | ReplyBinding,
|
|
664
|
+
): void {
|
|
665
|
+
this.mutate(`channel.${type}_stored`, { eventId }, () => {
|
|
666
|
+
const payloadHash = this.state.putJson(binding);
|
|
667
|
+
this.state.connection
|
|
668
|
+
.prepare(
|
|
669
|
+
`INSERT INTO channel_inbox(
|
|
670
|
+
channel_id, external_event_id, event_type, payload_hash, received_at
|
|
671
|
+
) VALUES (?, ?, ?, ?, ?)
|
|
672
|
+
ON CONFLICT(channel_id, external_event_id) DO UPDATE SET payload_hash = excluded.payload_hash`,
|
|
673
|
+
)
|
|
674
|
+
.run(this.channelId, eventId, type, payloadHash, Date.now());
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
private inbox<T>(eventId: string): T | undefined {
|
|
679
|
+
const row = this.state.connection
|
|
680
|
+
.prepare(
|
|
681
|
+
"SELECT payload_hash AS payloadHash FROM channel_inbox WHERE channel_id = ? AND external_event_id = ?",
|
|
682
|
+
)
|
|
683
|
+
.get(this.channelId, eventId);
|
|
684
|
+
return isPayloadHashRow(row) ? (this.state.readJson(row.payloadHash) as T) : undefined;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
private mutate(type: string, payload: unknown, operation: () => void): void {
|
|
688
|
+
this.state.transaction(() => {
|
|
689
|
+
this.assertOwner();
|
|
690
|
+
operation();
|
|
691
|
+
this.recordMutation(type, payload, Date.now());
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
private recordMutation(type: string, payload: unknown, now: number): void {
|
|
696
|
+
const revisionRow = this.state.connection
|
|
697
|
+
.prepare("SELECT revision FROM resources WHERE resource_id = ?")
|
|
698
|
+
.get(this.resourceId);
|
|
699
|
+
/* istanbul ignore if -- exact schema and internal query shape */
|
|
700
|
+
/* istanbul ignore if -- impossible after exact schema and transaction checks */
|
|
701
|
+
if (!isRevisionProjectionRow(revisionRow))
|
|
702
|
+
throw new Error("Telegram channel resource is missing");
|
|
703
|
+
const revision = revisionRow.revision + 1;
|
|
704
|
+
this.state.connection
|
|
573
705
|
.prepare(
|
|
574
|
-
"
|
|
706
|
+
"UPDATE resources SET revision = ?, updated_at = ? WHERE resource_id = ? AND revision = ?",
|
|
575
707
|
)
|
|
576
|
-
.
|
|
708
|
+
.run(revision, now, this.resourceId, revision - 1);
|
|
709
|
+
const payloadHash = this.state.putJson(payload, now);
|
|
710
|
+
this.state.connection
|
|
711
|
+
.prepare(
|
|
712
|
+
`INSERT INTO events(
|
|
713
|
+
event_id, resource_id, resource_revision, event_type, actor_type,
|
|
714
|
+
actor_id, lease_generation, payload_hash, recorded_at
|
|
715
|
+
) VALUES (?, ?, ?, ?, 'channel', ?, ?, ?, ?)`,
|
|
716
|
+
)
|
|
717
|
+
.run(
|
|
718
|
+
`event-${randomUUID()}`,
|
|
719
|
+
this.resourceId,
|
|
720
|
+
revision,
|
|
721
|
+
type,
|
|
722
|
+
this.owner,
|
|
723
|
+
this.generation === 0 ? null : this.generation,
|
|
724
|
+
payloadHash,
|
|
725
|
+
now,
|
|
726
|
+
);
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
private assertOwner(): void {
|
|
730
|
+
const lease = this.lease();
|
|
731
|
+
if (
|
|
732
|
+
lease.ownerId !== this.owner ||
|
|
733
|
+
lease.generation !== this.generation ||
|
|
734
|
+
lease.tokenHash === null ||
|
|
735
|
+
!lease.tokenHash.equals(tokenHash(this.token)) ||
|
|
736
|
+
lease.expiresAt === null ||
|
|
737
|
+
lease.expiresAt <= Date.now()
|
|
738
|
+
) {
|
|
739
|
+
throw new Error(`Telegram profile ${this.profile} is not owned by this channel process`);
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
private lease(): TelegramLeaseRow {
|
|
744
|
+
const row = this.state.connection
|
|
745
|
+
.prepare(
|
|
746
|
+
`SELECT generation, owner_id AS ownerId, token_hash AS tokenHash,
|
|
747
|
+
expires_at AS expiresAt
|
|
748
|
+
FROM leases WHERE resource_id = ?`,
|
|
749
|
+
)
|
|
750
|
+
.get(this.resourceId);
|
|
751
|
+
/* istanbul ignore if -- exact schema and internal query shape */
|
|
752
|
+
/* istanbul ignore if -- impossible after exact schema and transaction checks */
|
|
753
|
+
if (!isTelegramLeaseRow(row)) throw new Error("Telegram channel lease is missing");
|
|
754
|
+
return row;
|
|
577
755
|
}
|
|
578
756
|
}
|
|
579
757
|
|
|
758
|
+
function transportMessageId(
|
|
759
|
+
channelId: string,
|
|
760
|
+
decisionId: string,
|
|
761
|
+
chatId: string,
|
|
762
|
+
messageId: string,
|
|
763
|
+
): string {
|
|
764
|
+
return `telegram-message-${createHash("sha256")
|
|
765
|
+
.update(`${channelId}\0${decisionId}\0${chatId}\0${messageId}`)
|
|
766
|
+
.digest("hex")
|
|
767
|
+
.slice(0, 40)}`;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
type TelegramLeaseRow = {
|
|
771
|
+
generation: number;
|
|
772
|
+
ownerId: string | null;
|
|
773
|
+
tokenHash: Buffer | null;
|
|
774
|
+
expiresAt: number | null;
|
|
775
|
+
};
|
|
776
|
+
|
|
777
|
+
function isTelegramLeaseRow(value: unknown): value is TelegramLeaseRow {
|
|
778
|
+
return isProjectionRecord(value);
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
function isCursorRow(value: unknown): value is { cursorValue: string } {
|
|
782
|
+
return isProjectionRecord(value);
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
function isPayloadHashRow(value: unknown): value is { payloadHash: Buffer } {
|
|
786
|
+
return isProjectionRecord(value);
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
function isTransportMessageRow(value: unknown): value is { chatId: string; messageId: string } {
|
|
790
|
+
return isProjectionRecord(value);
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
function isMessagePartProjectionRow(value: unknown): value is {
|
|
794
|
+
recipientIndex: number;
|
|
795
|
+
partIndex: number;
|
|
796
|
+
contentHash: Buffer;
|
|
797
|
+
chatId: string;
|
|
798
|
+
messageId: string;
|
|
799
|
+
} {
|
|
800
|
+
return isProjectionRecord(value);
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
function isRevisionProjectionRow(value: unknown): value is { revision: number } {
|
|
804
|
+
return isProjectionRecord(value);
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
function isProjectionRecord(value: unknown): value is Record<string, unknown> {
|
|
808
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
809
|
+
}
|
|
810
|
+
|
|
580
811
|
class TelegramCallError extends Error {
|
|
581
812
|
constructor(
|
|
582
813
|
message: string,
|
|
@@ -628,7 +859,7 @@ export class TelegramDecisionChannel implements HumanDecisionChannel {
|
|
|
628
859
|
this.fetchFn = options.fetchFn ?? (fetch as TelegramFetch);
|
|
629
860
|
this.apiBase = options.apiBase ?? DEFAULT_API_BASE;
|
|
630
861
|
this.projection = new TelegramProjection(
|
|
631
|
-
|
|
862
|
+
options.store.state,
|
|
632
863
|
options.profileName,
|
|
633
864
|
options.ownerId ?? randomUUID(),
|
|
634
865
|
);
|
|
@@ -653,6 +884,33 @@ export class TelegramDecisionChannel implements HumanDecisionChannel {
|
|
|
653
884
|
|
|
654
885
|
async deliver(request: HumanDecisionChannelRequest): Promise<HumanDecisionDeliveryResult> {
|
|
655
886
|
const channelPath = this.channelPath();
|
|
887
|
+
if (!this.projection.ownsLease()) {
|
|
888
|
+
const attemptId = `queued-${createHash("sha256")
|
|
889
|
+
.update(`${this.id}\0${request.decisionId}\0${request.requestDigest}`)
|
|
890
|
+
.digest("hex")
|
|
891
|
+
.slice(0, 40)}`;
|
|
892
|
+
await this.store.recordDelivery(
|
|
893
|
+
request,
|
|
894
|
+
channelPath,
|
|
895
|
+
deliveryRecord(
|
|
896
|
+
request,
|
|
897
|
+
this.id,
|
|
898
|
+
attemptId,
|
|
899
|
+
"complete",
|
|
900
|
+
"intent",
|
|
901
|
+
new Date().toISOString(),
|
|
902
|
+
{
|
|
903
|
+
errorCode: "queued_for_channel_owner",
|
|
904
|
+
},
|
|
905
|
+
),
|
|
906
|
+
);
|
|
907
|
+
return {
|
|
908
|
+
status: "unknown",
|
|
909
|
+
channel: this.id,
|
|
910
|
+
attemptId,
|
|
911
|
+
errorCode: "queued_for_channel_owner",
|
|
912
|
+
};
|
|
913
|
+
}
|
|
656
914
|
const parts = renderTelegramParts(request);
|
|
657
915
|
const partDigests = parts.map((part) => digestCanonical(part));
|
|
658
916
|
const prior = await this.store.listDeliveries(request.decisionId, channelPath);
|
|
@@ -684,7 +942,7 @@ export class TelegramDecisionChannel implements HumanDecisionChannel {
|
|
|
684
942
|
await this.store.recordDelivery(
|
|
685
943
|
request,
|
|
686
944
|
channelPath,
|
|
687
|
-
|
|
945
|
+
deliveryRecord(request, this.id, attemptId, "intent", "intent", createdAt, {
|
|
688
946
|
partCount: parts.length,
|
|
689
947
|
}),
|
|
690
948
|
);
|
|
@@ -704,7 +962,6 @@ export class TelegramDecisionChannel implements HumanDecisionChannel {
|
|
|
704
962
|
);
|
|
705
963
|
const confirmed = prior.some(
|
|
706
964
|
(record) =>
|
|
707
|
-
record.schema === "pi-workflows.human-decision-delivery.v2" &&
|
|
708
965
|
record.phase === "part" &&
|
|
709
966
|
record.state === "confirmed" &&
|
|
710
967
|
record.recipientIndex === recipientIndex &&
|
|
@@ -724,7 +981,7 @@ export class TelegramDecisionChannel implements HumanDecisionChannel {
|
|
|
724
981
|
await this.store.recordDelivery(
|
|
725
982
|
request,
|
|
726
983
|
channelPath,
|
|
727
|
-
|
|
984
|
+
deliveryRecord(
|
|
728
985
|
request,
|
|
729
986
|
this.id,
|
|
730
987
|
`${attemptId}-r${recipientIndex}-p${partIndex}-unknown`,
|
|
@@ -740,15 +997,12 @@ export class TelegramDecisionChannel implements HumanDecisionChannel {
|
|
|
740
997
|
await this.store.recordDelivery(
|
|
741
998
|
request,
|
|
742
999
|
channelPath,
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
createdAt,
|
|
750
|
-
{ recipientIndex, partIndex, partCount: parts.length, contentDigest },
|
|
751
|
-
),
|
|
1000
|
+
deliveryRecord(request, this.id, `${partAttemptId}-intent`, "part", "intent", createdAt, {
|
|
1001
|
+
recipientIndex,
|
|
1002
|
+
partIndex,
|
|
1003
|
+
partCount: parts.length,
|
|
1004
|
+
contentDigest,
|
|
1005
|
+
}),
|
|
752
1006
|
);
|
|
753
1007
|
try {
|
|
754
1008
|
const result = await this.call("sendMessage", {
|
|
@@ -769,7 +1023,7 @@ export class TelegramDecisionChannel implements HumanDecisionChannel {
|
|
|
769
1023
|
await this.store.recordDelivery(
|
|
770
1024
|
request,
|
|
771
1025
|
channelPath,
|
|
772
|
-
|
|
1026
|
+
deliveryRecord(
|
|
773
1027
|
request,
|
|
774
1028
|
this.id,
|
|
775
1029
|
`${partAttemptId}-confirmed`,
|
|
@@ -785,7 +1039,7 @@ export class TelegramDecisionChannel implements HumanDecisionChannel {
|
|
|
785
1039
|
await this.store.recordDelivery(
|
|
786
1040
|
request,
|
|
787
1041
|
channelPath,
|
|
788
|
-
|
|
1042
|
+
deliveryRecord(
|
|
789
1043
|
request,
|
|
790
1044
|
this.id,
|
|
791
1045
|
`${partAttemptId}-${state}`,
|
|
@@ -804,7 +1058,7 @@ export class TelegramDecisionChannel implements HumanDecisionChannel {
|
|
|
804
1058
|
await this.store.recordDelivery(
|
|
805
1059
|
request,
|
|
806
1060
|
channelPath,
|
|
807
|
-
|
|
1061
|
+
deliveryRecord(
|
|
808
1062
|
request,
|
|
809
1063
|
this.id,
|
|
810
1064
|
`${attemptId}-${state}`,
|
|
@@ -821,7 +1075,7 @@ export class TelegramDecisionChannel implements HumanDecisionChannel {
|
|
|
821
1075
|
await this.store.recordDelivery(
|
|
822
1076
|
request,
|
|
823
1077
|
channelPath,
|
|
824
|
-
|
|
1078
|
+
deliveryRecord(
|
|
825
1079
|
request,
|
|
826
1080
|
this.id,
|
|
827
1081
|
`${attemptId}-confirmed`,
|
|
@@ -973,6 +1227,7 @@ export class TelegramDecisionChannel implements HumanDecisionChannel {
|
|
|
973
1227
|
continue;
|
|
974
1228
|
}
|
|
975
1229
|
try {
|
|
1230
|
+
await this.deliverPendingRequests();
|
|
976
1231
|
const result = await this.call(
|
|
977
1232
|
"getUpdates",
|
|
978
1233
|
{
|
|
@@ -996,6 +1251,28 @@ export class TelegramDecisionChannel implements HumanDecisionChannel {
|
|
|
996
1251
|
}
|
|
997
1252
|
}
|
|
998
1253
|
|
|
1254
|
+
private async deliverPendingRequests(): Promise<void> {
|
|
1255
|
+
const channelPath = this.channelPath();
|
|
1256
|
+
for (const request of await this.store.listRequests()) {
|
|
1257
|
+
if (request.expiresAt !== undefined && Date.parse(request.expiresAt) <= Date.now()) continue;
|
|
1258
|
+
if ((await this.store.readResolved(request.decisionId)) !== null) continue;
|
|
1259
|
+
if ((await this.store.readCancellation(request.decisionId)) !== null) continue;
|
|
1260
|
+
const deliveries = await this.store.listDeliveries(request.decisionId, channelPath);
|
|
1261
|
+
if (
|
|
1262
|
+
!deliveries.some(
|
|
1263
|
+
(record) => record.state === "intent" && record.errorCode === "queued_for_channel_owner",
|
|
1264
|
+
)
|
|
1265
|
+
) {
|
|
1266
|
+
continue;
|
|
1267
|
+
}
|
|
1268
|
+
try {
|
|
1269
|
+
await this.deliver(humanDecisionChannelRequest(request));
|
|
1270
|
+
} catch {
|
|
1271
|
+
// Durable delivery evidence controls retries for each request.
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
|
|
999
1276
|
private async call(method: string, payload: unknown, signal?: AbortSignal): Promise<unknown> {
|
|
1000
1277
|
let response: Awaited<ReturnType<TelegramFetch>>;
|
|
1001
1278
|
try {
|
|
@@ -1078,16 +1355,18 @@ export function createTelegramChannels(options: {
|
|
|
1078
1355
|
|
|
1079
1356
|
export async function verifyTelegramTokenFile(
|
|
1080
1357
|
tokenFile: string,
|
|
1081
|
-
fetchFn
|
|
1358
|
+
fetchFn?: TelegramFetch,
|
|
1082
1359
|
apiBase = DEFAULT_API_BASE,
|
|
1083
1360
|
): Promise<void> {
|
|
1361
|
+
/* istanbul ignore next -- global fetch is used only by operator setup */
|
|
1362
|
+
const request = fetchFn ?? (fetch as TelegramFetch);
|
|
1084
1363
|
if (!path.isAbsolute(tokenFile)) throw new Error("Telegram token file path must be absolute");
|
|
1085
1364
|
await requirePrivateFile(tokenFile, "Telegram token file");
|
|
1086
1365
|
const token = (await fsp.readFile(tokenFile, "utf8")).trim();
|
|
1087
1366
|
if (token.length === 0) throw new Error("Telegram token file is empty");
|
|
1088
1367
|
let response;
|
|
1089
1368
|
try {
|
|
1090
|
-
response = await
|
|
1369
|
+
response = await request(`${apiBase}/bot${encodeURIComponent(token)}/getMe`, {
|
|
1091
1370
|
method: "POST",
|
|
1092
1371
|
headers: { "content-type": "application/json" },
|
|
1093
1372
|
body: "{}",
|
|
@@ -1234,29 +1513,26 @@ function parseCredentialConfig(value: unknown): DecisionCredentialConfig {
|
|
|
1234
1513
|
return { schema: "pi-workflows.credentials.v1", telegram };
|
|
1235
1514
|
}
|
|
1236
1515
|
|
|
1237
|
-
type
|
|
1238
|
-
HumanDecisionDeliveryRecord,
|
|
1239
|
-
{ schema: "pi-workflows.human-decision-delivery.v2" }
|
|
1240
|
-
>;
|
|
1516
|
+
type DeliveryRecord = HumanDecisionDeliveryRecord;
|
|
1241
1517
|
|
|
1242
|
-
type
|
|
1518
|
+
type DeliveryRecordExtra = Partial<
|
|
1243
1519
|
Pick<
|
|
1244
|
-
|
|
1520
|
+
DeliveryRecord,
|
|
1245
1521
|
"recipientIndex" | "partIndex" | "partCount" | "contentDigest" | "messageCount" | "errorCode"
|
|
1246
1522
|
>
|
|
1247
1523
|
>;
|
|
1248
1524
|
|
|
1249
|
-
function
|
|
1525
|
+
function deliveryRecord(
|
|
1250
1526
|
request: HumanDecisionChannelRequest,
|
|
1251
1527
|
channel: string,
|
|
1252
1528
|
attemptId: string,
|
|
1253
|
-
phase:
|
|
1254
|
-
state:
|
|
1529
|
+
phase: DeliveryRecord["phase"],
|
|
1530
|
+
state: DeliveryRecord["state"],
|
|
1255
1531
|
createdAt: string,
|
|
1256
|
-
extra:
|
|
1257
|
-
):
|
|
1532
|
+
extra: DeliveryRecordExtra = {},
|
|
1533
|
+
): DeliveryRecord {
|
|
1258
1534
|
return {
|
|
1259
|
-
schema: "pi-workflows.human-decision-delivery.
|
|
1535
|
+
schema: "pi-workflows.human-decision-delivery.v1",
|
|
1260
1536
|
attemptId,
|
|
1261
1537
|
decisionId: request.decisionId,
|
|
1262
1538
|
requestDigest: request.requestDigest,
|
|
@@ -1271,37 +1547,14 @@ function v2DeliveryRecord(
|
|
|
1271
1547
|
}
|
|
1272
1548
|
|
|
1273
1549
|
function isCompleteDelivery(record: HumanDecisionDeliveryRecord): boolean {
|
|
1274
|
-
return
|
|
1275
|
-
record.state === "confirmed" &&
|
|
1276
|
-
(record.schema === "pi-workflows.human-decision-delivery.v1" || record.phase === "complete")
|
|
1277
|
-
);
|
|
1550
|
+
return record.state === "confirmed" && record.phase === "complete";
|
|
1278
1551
|
}
|
|
1279
1552
|
|
|
1280
1553
|
function hasUnsettledDeliveryIntent(records: HumanDecisionDeliveryRecord[]): boolean {
|
|
1281
|
-
const hasLegacyIntent = records.some(
|
|
1282
|
-
(record) =>
|
|
1283
|
-
record.schema === "pi-workflows.human-decision-delivery.v1" && record.state === "intent",
|
|
1284
|
-
);
|
|
1285
|
-
if (
|
|
1286
|
-
hasLegacyIntent &&
|
|
1287
|
-
!records.some(
|
|
1288
|
-
(record) =>
|
|
1289
|
-
record.schema === "pi-workflows.human-decision-delivery.v1" && record.state !== "intent",
|
|
1290
|
-
)
|
|
1291
|
-
) {
|
|
1292
|
-
return true;
|
|
1293
|
-
}
|
|
1294
1554
|
return records.some((record) => {
|
|
1295
|
-
if (
|
|
1296
|
-
record.schema !== "pi-workflows.human-decision-delivery.v2" ||
|
|
1297
|
-
record.phase !== "part" ||
|
|
1298
|
-
record.state !== "intent"
|
|
1299
|
-
) {
|
|
1300
|
-
return false;
|
|
1301
|
-
}
|
|
1555
|
+
if (record.phase !== "part" || record.state !== "intent") return false;
|
|
1302
1556
|
return !records.some(
|
|
1303
1557
|
(other) =>
|
|
1304
|
-
other.schema === "pi-workflows.human-decision-delivery.v2" &&
|
|
1305
1558
|
other.phase === "part" &&
|
|
1306
1559
|
other.state !== "intent" &&
|
|
1307
1560
|
other.recipientIndex === record.recipientIndex &&
|