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