@osolmaz/pi-workflows 0.16.0 → 0.16.2
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 +18 -2
- package/dist/channels/adapter-entry.d.ts +3 -0
- package/dist/channels/adapter-entry.js +203 -0
- package/dist/channels/adapter-entry.js.map +1 -0
- package/dist/channels/config.d.ts +43 -0
- package/dist/channels/config.js +234 -0
- package/dist/channels/config.js.map +1 -0
- package/dist/channels/protocol.d.ts +94 -0
- package/dist/channels/protocol.js +219 -0
- package/dist/channels/protocol.js.map +1 -0
- package/dist/channels/telegram.d.ts +46 -0
- package/dist/channels/telegram.js +285 -0
- package/dist/channels/telegram.js.map +1 -0
- package/dist/client/client.d.ts +1 -0
- package/dist/client/client.js +5 -1
- package/dist/client/client.js.map +1 -1
- package/dist/client/index.d.ts +1 -1
- package/dist/client/protocol.d.ts +1 -1
- package/dist/client/protocol.js +11 -5
- package/dist/client/protocol.js.map +1 -1
- package/dist/client/view.d.ts +48 -16
- package/dist/client/view.js +1 -0
- package/dist/client/view.js.map +1 -1
- package/dist/controllers/index.d.ts +1 -1
- package/dist/controllers/index.js.map +1 -1
- package/dist/controllers/sqlite.d.ts +20 -165
- package/dist/controllers/sqlite.js +178 -436
- package/dist/controllers/sqlite.js.map +1 -1
- package/dist/extension/index.d.ts +23 -2
- package/dist/extension/index.js +637 -534
- package/dist/extension/index.js.map +1 -1
- package/dist/extension/recorder.d.ts +95 -0
- package/dist/extension/recorder.js +530 -0
- package/dist/extension/recorder.js.map +1 -0
- package/dist/extension/remote-recorder-store.d.ts +25 -0
- package/dist/extension/remote-recorder-store.js +81 -0
- package/dist/extension/remote-recorder-store.js.map +1 -0
- package/dist/extension/session-events.d.ts +134 -0
- package/dist/extension/session-events.js +60 -0
- package/dist/extension/session-events.js.map +1 -0
- package/dist/extension/session-view.d.ts +2 -0
- package/dist/extension/session-view.js +60 -2
- package/dist/extension/session-view.js.map +1 -1
- package/dist/extension/step-message.d.ts +6 -4
- package/dist/extension/step-message.js +12 -6
- package/dist/extension/step-message.js.map +1 -1
- package/dist/extension/widget.d.ts +2 -2
- package/dist/extension/widget.js +28 -18
- package/dist/extension/widget.js.map +1 -1
- package/dist/extension/workflow-message-coordinator.d.ts +28 -0
- package/dist/extension/workflow-message-coordinator.js +346 -0
- package/dist/extension/workflow-message-coordinator.js.map +1 -0
- package/dist/host/channel-effects.d.ts +72 -0
- package/dist/host/channel-effects.js +271 -0
- package/dist/host/channel-effects.js.map +1 -0
- package/dist/host/channel-supervisor.d.ts +21 -0
- package/dist/host/channel-supervisor.js +54 -0
- package/dist/host/channel-supervisor.js.map +1 -0
- package/dist/host/runner.d.ts +50 -8
- package/dist/host/runner.js +1632 -341
- package/dist/host/runner.js.map +1 -1
- package/dist/host/state.d.ts +19 -16
- package/dist/host/state.js +251 -63
- package/dist/host/state.js.map +1 -1
- package/dist/host/view.d.ts +19 -9
- package/dist/host/view.js +227 -119
- package/dist/host/view.js.map +1 -1
- package/dist/host/worker-entry.d.ts +4 -1
- package/dist/host/worker-entry.js +23 -24
- package/dist/host/worker-entry.js.map +1 -1
- package/dist/host/worker-protocol.d.ts +15 -0
- package/dist/host/worker-protocol.js.map +1 -1
- package/dist/state/prune.js +3 -25
- package/dist/state/prune.js.map +1 -1
- package/dist/state/schema.js +76 -97
- package/dist/state/schema.js.map +1 -1
- package/dist/state/workflow-messages.d.ts +98 -0
- package/dist/state/workflow-messages.js +366 -0
- package/dist/state/workflow-messages.js.map +1 -0
- package/dist/viewer/render.js +1 -1
- package/dist/viewer/render.js.map +1 -1
- package/dist/viewer/tui.d.ts +1 -1
- package/dist/viewer/tui.js +65 -8
- package/dist/viewer/tui.js.map +1 -1
- package/dist/workflows/composition.js +0 -4
- package/dist/workflows/composition.js.map +1 -1
- package/dist/workflows/definition.js +0 -8
- package/dist/workflows/definition.js.map +1 -1
- package/dist/workflows/engine.js +4 -5
- package/dist/workflows/engine.js.map +1 -1
- package/dist/workflows/human-decision.d.ts +2 -0
- package/dist/workflows/human-decision.js +8 -2
- package/dist/workflows/human-decision.js.map +1 -1
- package/dist/workflows/index.d.ts +1 -1
- package/dist/workflows/index.js.map +1 -1
- package/dist/workflows/schema.js +0 -4
- package/dist/workflows/schema.js.map +1 -1
- package/dist/workflows/settings.d.ts +1 -8
- package/dist/workflows/settings.js.map +1 -1
- package/dist/workflows/store.d.ts +20 -19
- package/dist/workflows/store.js +163 -354
- package/dist/workflows/store.js.map +1 -1
- package/dist/workflows/tool-input.d.ts +21 -0
- package/dist/workflows/tool-input.js +23 -1
- package/dist/workflows/tool-input.js.map +1 -1
- package/dist/workflows/types.d.ts +6 -8
- package/dist/workflows/workflow-message-content.d.ts +38 -0
- package/dist/workflows/workflow-message-content.js +157 -0
- package/dist/workflows/workflow-message-content.js.map +1 -0
- package/docs/2026-08-18-herdr-piw-plan.md +2 -1
- package/docs/2026-08-20-durable-workflow-launch-plan.md +1 -1
- package/docs/2026-08-25-workflow-follow-ups.md +26 -50
- package/docs/2026-08-30-out-of-process-workflow-host-plan.md +3 -3
- package/docs/2026-09-01-restore-session-delivery-controls-plan.md +4 -0
- package/docs/2026-09-01-unified-workflow-client-plan.md +5 -1
- package/docs/2026-09-02-installed-live-e2e-plan.md +15 -1
- package/docs/2026-09-02-unify-workflow-messages-plan.md +562 -0
- package/docs/2026-09-04-workflow-run-state-plan.md +363 -0
- package/docs/DEFERRED_TURNS.md +66 -294
- package/docs/HUMAN_DECISIONS.md +29 -30
- package/docs/HUMAN_DECISION_PRESENTATIONS.md +4 -11
- package/docs/SQLITE_STATE.md +25 -17
- package/docs/WORKFLOW_HOST.md +105 -31
- package/docs/WORKFLOW_STEP_MESSAGES.md +166 -75
- package/docs/live-replay-protocol.md +8 -4
- package/docs/tui-viewer.md +3 -1
- package/docs/workflows.md +44 -28
- package/herdr-plugin.toml +1 -1
- package/package.json +1 -1
- package/plugins/herdr/viewer.mjs +14 -5
- package/src/channels/adapter-entry.ts +220 -0
- package/src/channels/config.ts +296 -0
- package/src/channels/protocol.ts +333 -0
- package/src/channels/telegram.ts +335 -0
- package/src/client/client.ts +6 -2
- package/src/client/index.ts +2 -1
- package/src/client/protocol.ts +11 -5
- package/src/client/view.ts +51 -18
- package/src/controllers/index.ts +0 -1
- package/src/controllers/sqlite.ts +247 -845
- package/src/extension/index.ts +712 -647
- package/src/extension/recorder.ts +687 -0
- package/src/extension/remote-recorder-store.ts +126 -0
- package/src/extension/session-events.ts +119 -0
- package/src/extension/session-view.ts +86 -3
- package/src/extension/step-message.ts +19 -9
- package/src/extension/widget.ts +37 -17
- package/src/extension/workflow-message-coordinator.ts +398 -0
- package/src/host/channel-effects.ts +465 -0
- package/src/host/channel-supervisor.ts +73 -0
- package/src/host/runner.ts +2163 -525
- package/src/host/state.ts +333 -94
- package/src/host/view.ts +254 -122
- package/src/host/worker-entry.ts +38 -30
- package/src/host/worker-protocol.ts +11 -0
- package/src/state/prune.ts +3 -31
- package/src/state/schema.ts +76 -97
- package/src/state/workflow-messages.ts +599 -0
- package/src/viewer/render.ts +1 -5
- package/src/viewer/tui.ts +71 -6
- package/src/workflows/composition.ts +0 -5
- package/src/workflows/definition.ts +0 -8
- package/src/workflows/engine.ts +4 -6
- package/src/workflows/human-decision.ts +18 -2
- package/src/workflows/index.ts +0 -1
- package/src/workflows/schema.ts +0 -6
- package/src/workflows/settings.ts +1 -20
- package/src/workflows/store.ts +237 -489
- package/src/workflows/tool-input.ts +36 -1
- package/src/workflows/types.ts +6 -8
- package/src/workflows/workflow-message-content.ts +197 -0
- package/dist/extension/decision-channels.d.ts +0 -134
- package/dist/extension/decision-channels.js +0 -1307
- package/dist/extension/decision-channels.js.map +0 -1
- package/src/extension/decision-channels.ts +0 -1826
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { StateDatabase } from "../state/database.js";
|
|
3
|
+
import { canonicalJson, type JsonValue } from "../state/json.js";
|
|
4
|
+
import { StateMutationStore } from "../state/mutation.js";
|
|
5
|
+
import type { HumanDecisionChannelRequest } from "../workflows/types.js";
|
|
6
|
+
|
|
7
|
+
export type ChannelEffectPurpose = "delivery" | "settlement";
|
|
8
|
+
export type ChannelEffectStatus =
|
|
9
|
+
| "pending"
|
|
10
|
+
| "applying"
|
|
11
|
+
| "applied"
|
|
12
|
+
| "rejected"
|
|
13
|
+
| "ambiguous"
|
|
14
|
+
| "cancelled";
|
|
15
|
+
|
|
16
|
+
export type ChannelEffectPayload = {
|
|
17
|
+
schema: "pi-workflows.channel-effect.v1";
|
|
18
|
+
channelId: string;
|
|
19
|
+
profile: string;
|
|
20
|
+
decisionId: string;
|
|
21
|
+
purpose: ChannelEffectPurpose;
|
|
22
|
+
request: HumanDecisionChannelRequest;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type ChannelEffectRecord = {
|
|
26
|
+
effectId: string;
|
|
27
|
+
resourceId: string;
|
|
28
|
+
sourceResourceId: string;
|
|
29
|
+
status: ChannelEffectStatus;
|
|
30
|
+
attemptNumber: number;
|
|
31
|
+
attemptStartedAt: number;
|
|
32
|
+
settledAt?: number;
|
|
33
|
+
payload: ChannelEffectPayload;
|
|
34
|
+
result?: JsonValue;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
type EffectRow = {
|
|
38
|
+
effectId: string;
|
|
39
|
+
resourceId: string;
|
|
40
|
+
sourceResourceId: string;
|
|
41
|
+
status: ChannelEffectStatus;
|
|
42
|
+
attemptNumber: number;
|
|
43
|
+
attemptStartedAt: number;
|
|
44
|
+
settledAt: number | null;
|
|
45
|
+
payloadHash: Buffer;
|
|
46
|
+
resultHash: Buffer | null;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export class ChannelEffectStore {
|
|
50
|
+
private readonly mutations: StateMutationStore;
|
|
51
|
+
|
|
52
|
+
constructor(private readonly state: StateDatabase) {
|
|
53
|
+
this.mutations = new StateMutationStore(state);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
ensureApplying(options: {
|
|
57
|
+
channelResourceId: string;
|
|
58
|
+
channelId: string;
|
|
59
|
+
profile: string;
|
|
60
|
+
decisionId: string;
|
|
61
|
+
purpose: ChannelEffectPurpose;
|
|
62
|
+
request: HumanDecisionChannelRequest;
|
|
63
|
+
ownerId: string;
|
|
64
|
+
leaseGeneration: number;
|
|
65
|
+
maxAutomaticAttempts: number;
|
|
66
|
+
}): ChannelEffectRecord {
|
|
67
|
+
const effectId = channelEffectId(
|
|
68
|
+
options.channelId,
|
|
69
|
+
options.decisionId,
|
|
70
|
+
options.request.requestDigest,
|
|
71
|
+
options.purpose,
|
|
72
|
+
);
|
|
73
|
+
const existing = this.read(effectId);
|
|
74
|
+
if (existing !== undefined) {
|
|
75
|
+
if (existing.status === "rejected" && existing.attemptNumber < options.maxAutomaticAttempts) {
|
|
76
|
+
return this.beginAttempt(
|
|
77
|
+
existing,
|
|
78
|
+
options.ownerId,
|
|
79
|
+
options.leaseGeneration,
|
|
80
|
+
"automatic",
|
|
81
|
+
options.ownerId,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
return existing;
|
|
85
|
+
}
|
|
86
|
+
const payload: ChannelEffectPayload = {
|
|
87
|
+
schema: "pi-workflows.channel-effect.v1",
|
|
88
|
+
channelId: options.channelId,
|
|
89
|
+
profile: options.profile,
|
|
90
|
+
decisionId: options.decisionId,
|
|
91
|
+
purpose: options.purpose,
|
|
92
|
+
request: options.request,
|
|
93
|
+
};
|
|
94
|
+
const now = Date.now();
|
|
95
|
+
const payloadHash = this.state.putJson(payload, now);
|
|
96
|
+
const sourceRevision = this.resourceRevision(options.channelResourceId);
|
|
97
|
+
const resourceId = this.mutations.ensureResource("effect", effectId, now);
|
|
98
|
+
this.mutations.mutate(
|
|
99
|
+
{
|
|
100
|
+
resourceId,
|
|
101
|
+
operation: "channel.effect.create",
|
|
102
|
+
actor: { type: "host", id: options.ownerId },
|
|
103
|
+
expectedRevision: 0,
|
|
104
|
+
},
|
|
105
|
+
"effect.applying",
|
|
106
|
+
() => {
|
|
107
|
+
this.state.connection
|
|
108
|
+
.prepare(
|
|
109
|
+
`INSERT INTO effects(
|
|
110
|
+
effect_id, resource_id, source_resource_id, source_revision,
|
|
111
|
+
effect_type, idempotency_key, payload_hash, owner_scope,
|
|
112
|
+
status, attempt_count, created_at, updated_at
|
|
113
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, 'channel', 'applying', 1, ?, ?)`,
|
|
114
|
+
)
|
|
115
|
+
.run(
|
|
116
|
+
effectId,
|
|
117
|
+
resourceId,
|
|
118
|
+
options.channelResourceId,
|
|
119
|
+
sourceRevision,
|
|
120
|
+
`channel.${options.purpose}`,
|
|
121
|
+
effectId,
|
|
122
|
+
payloadHash,
|
|
123
|
+
now,
|
|
124
|
+
now,
|
|
125
|
+
);
|
|
126
|
+
this.insertAttempt(effectId, 1, options.ownerId, options.leaseGeneration, now);
|
|
127
|
+
},
|
|
128
|
+
{ now, payload: { channelId: options.channelId, decisionId: options.decisionId } },
|
|
129
|
+
);
|
|
130
|
+
return this.require(effectId);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
settle(options: {
|
|
134
|
+
effectId: string;
|
|
135
|
+
attemptNumber: number;
|
|
136
|
+
outcome: "applied" | "rejected" | "ambiguous";
|
|
137
|
+
result?: JsonValue;
|
|
138
|
+
error?: string;
|
|
139
|
+
actorType: "channel" | "human";
|
|
140
|
+
actorId: string;
|
|
141
|
+
}): ChannelEffectRecord {
|
|
142
|
+
const current = this.require(options.effectId);
|
|
143
|
+
if (current.status !== "applying" || current.attemptNumber !== options.attemptNumber) {
|
|
144
|
+
throw new Error("Channel effect attempt is stale");
|
|
145
|
+
}
|
|
146
|
+
const now = Date.now();
|
|
147
|
+
const resultHash =
|
|
148
|
+
options.result === undefined ? null : this.state.putJson(options.result, now);
|
|
149
|
+
const errorHash = options.error === undefined ? null : this.state.putText(options.error, now);
|
|
150
|
+
this.mutations.mutate(
|
|
151
|
+
{
|
|
152
|
+
resourceId: current.resourceId,
|
|
153
|
+
operation: "channel.effect.settle",
|
|
154
|
+
actor: { type: options.actorType, id: options.actorId },
|
|
155
|
+
expectedRevision: this.resourceRevision(current.resourceId),
|
|
156
|
+
},
|
|
157
|
+
`effect.${options.outcome}`,
|
|
158
|
+
() => {
|
|
159
|
+
const changed = this.state.connection
|
|
160
|
+
.prepare(
|
|
161
|
+
`UPDATE effects
|
|
162
|
+
SET status = ?, result_hash = ?, error_hash = ?, updated_at = ?, settled_at = ?
|
|
163
|
+
WHERE effect_id = ? AND status = 'applying' AND attempt_count = ?`,
|
|
164
|
+
)
|
|
165
|
+
.run(
|
|
166
|
+
options.outcome,
|
|
167
|
+
resultHash,
|
|
168
|
+
errorHash,
|
|
169
|
+
now,
|
|
170
|
+
now,
|
|
171
|
+
options.effectId,
|
|
172
|
+
options.attemptNumber,
|
|
173
|
+
);
|
|
174
|
+
if (changed.changes !== 1) throw new Error("Channel effect attempt changed");
|
|
175
|
+
this.state.connection
|
|
176
|
+
.prepare(
|
|
177
|
+
`UPDATE effect_attempts
|
|
178
|
+
SET finished_at = ?, outcome = ?, result_hash = ?, error_hash = ?
|
|
179
|
+
WHERE effect_id = ? AND attempt_number = ? AND finished_at IS NULL`,
|
|
180
|
+
)
|
|
181
|
+
.run(
|
|
182
|
+
now,
|
|
183
|
+
options.outcome,
|
|
184
|
+
resultHash,
|
|
185
|
+
errorHash,
|
|
186
|
+
options.effectId,
|
|
187
|
+
options.attemptNumber,
|
|
188
|
+
);
|
|
189
|
+
},
|
|
190
|
+
{ now, payload: { attemptNumber: options.attemptNumber } },
|
|
191
|
+
);
|
|
192
|
+
return this.require(options.effectId);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
markApplyingAmbiguous(options: {
|
|
196
|
+
sourceResourceId?: string;
|
|
197
|
+
stableMessageIds?: readonly string[];
|
|
198
|
+
error: string;
|
|
199
|
+
actorId: string;
|
|
200
|
+
}): ChannelEffectRecord[] {
|
|
201
|
+
const rows = this.state.connection
|
|
202
|
+
.prepare(
|
|
203
|
+
`SELECT effect_id AS effectId
|
|
204
|
+
FROM effects
|
|
205
|
+
WHERE owner_scope = 'channel' AND status = 'applying'
|
|
206
|
+
AND (? IS NULL OR source_resource_id = ?)
|
|
207
|
+
ORDER BY created_at`,
|
|
208
|
+
)
|
|
209
|
+
.all(options.sourceResourceId ?? null, options.sourceResourceId ?? null);
|
|
210
|
+
const settled: ChannelEffectRecord[] = [];
|
|
211
|
+
for (const row of rows) {
|
|
212
|
+
if (!isEffectIdRow(row)) continue;
|
|
213
|
+
const current = this.require(row.effectId);
|
|
214
|
+
if (
|
|
215
|
+
options.stableMessageIds !== undefined &&
|
|
216
|
+
!options.stableMessageIds.includes(
|
|
217
|
+
channelEffectAttemptId(current.effectId, current.attemptNumber),
|
|
218
|
+
)
|
|
219
|
+
) {
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
settled.push(
|
|
223
|
+
this.settle({
|
|
224
|
+
effectId: current.effectId,
|
|
225
|
+
attemptNumber: current.attemptNumber,
|
|
226
|
+
outcome: "ambiguous",
|
|
227
|
+
error: options.error,
|
|
228
|
+
actorType: "channel",
|
|
229
|
+
actorId: options.actorId,
|
|
230
|
+
}),
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
return settled;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
recover(options: {
|
|
237
|
+
stableMessageId: string;
|
|
238
|
+
action: "confirm" | "retry";
|
|
239
|
+
actorId: string;
|
|
240
|
+
ownerId: string;
|
|
241
|
+
leaseGeneration: number;
|
|
242
|
+
}): ChannelEffectRecord {
|
|
243
|
+
const current = this.listAmbiguous().find(
|
|
244
|
+
(effect) =>
|
|
245
|
+
channelEffectAttemptId(effect.effectId, effect.attemptNumber) === options.stableMessageId,
|
|
246
|
+
);
|
|
247
|
+
if (current === undefined) throw new Error("Ambiguous channel operation was not found");
|
|
248
|
+
if (options.action === "confirm") {
|
|
249
|
+
return this.confirmAmbiguous(current, options.actorId);
|
|
250
|
+
}
|
|
251
|
+
return this.beginAttempt(
|
|
252
|
+
current,
|
|
253
|
+
options.ownerId,
|
|
254
|
+
options.leaseGeneration,
|
|
255
|
+
"explicit",
|
|
256
|
+
options.actorId,
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
read(effectId: string): ChannelEffectRecord | undefined {
|
|
261
|
+
const row = this.state.connection
|
|
262
|
+
.prepare(
|
|
263
|
+
`SELECT e.effect_id AS effectId, e.resource_id AS resourceId,
|
|
264
|
+
e.source_resource_id AS sourceResourceId, e.status,
|
|
265
|
+
e.attempt_count AS attemptNumber, a.started_at AS attemptStartedAt,
|
|
266
|
+
e.settled_at AS settledAt, e.payload_hash AS payloadHash,
|
|
267
|
+
e.result_hash AS resultHash
|
|
268
|
+
FROM effects e JOIN effect_attempts a
|
|
269
|
+
ON a.effect_id = e.effect_id AND a.attempt_number = e.attempt_count
|
|
270
|
+
WHERE e.effect_id = ? AND e.owner_scope = 'channel'`,
|
|
271
|
+
)
|
|
272
|
+
.get(effectId);
|
|
273
|
+
return isEffectRow(row) ? this.materialize(row) : undefined;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
listAmbiguous(): ChannelEffectRecord[] {
|
|
277
|
+
return this.state.connection
|
|
278
|
+
.prepare(
|
|
279
|
+
`SELECT e.effect_id AS effectId, e.resource_id AS resourceId,
|
|
280
|
+
e.source_resource_id AS sourceResourceId, e.status,
|
|
281
|
+
e.attempt_count AS attemptNumber, a.started_at AS attemptStartedAt,
|
|
282
|
+
e.settled_at AS settledAt, e.payload_hash AS payloadHash,
|
|
283
|
+
e.result_hash AS resultHash
|
|
284
|
+
FROM effects e JOIN effect_attempts a
|
|
285
|
+
ON a.effect_id = e.effect_id AND a.attempt_number = e.attempt_count
|
|
286
|
+
WHERE e.owner_scope = 'channel' AND e.status = 'ambiguous'
|
|
287
|
+
ORDER BY e.created_at`,
|
|
288
|
+
)
|
|
289
|
+
.all()
|
|
290
|
+
.filter(isEffectRow)
|
|
291
|
+
.map((row) => this.materialize(row));
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
private confirmAmbiguous(current: ChannelEffectRecord, actorId: string): ChannelEffectRecord {
|
|
295
|
+
const now = Date.now();
|
|
296
|
+
this.mutations.mutate(
|
|
297
|
+
{
|
|
298
|
+
resourceId: current.resourceId,
|
|
299
|
+
operation: "channel.effect.confirm",
|
|
300
|
+
actor: { type: "human", id: actorId },
|
|
301
|
+
expectedRevision: this.resourceRevision(current.resourceId),
|
|
302
|
+
},
|
|
303
|
+
"effect.recovered_confirmed",
|
|
304
|
+
() => {
|
|
305
|
+
const changed = this.state.connection
|
|
306
|
+
.prepare(
|
|
307
|
+
`UPDATE effects SET status = 'applied', updated_at = ?, settled_at = ?
|
|
308
|
+
WHERE effect_id = ? AND status = 'ambiguous' AND attempt_count = ?`,
|
|
309
|
+
)
|
|
310
|
+
.run(now, now, current.effectId, current.attemptNumber);
|
|
311
|
+
if (changed.changes !== 1) throw new Error("Channel effect recovery is stale");
|
|
312
|
+
},
|
|
313
|
+
{ now, payload: { attemptNumber: current.attemptNumber } },
|
|
314
|
+
);
|
|
315
|
+
return this.require(current.effectId);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
private beginAttempt(
|
|
319
|
+
current: ChannelEffectRecord,
|
|
320
|
+
ownerId: string,
|
|
321
|
+
leaseGeneration: number,
|
|
322
|
+
reason: "automatic" | "explicit",
|
|
323
|
+
actorId: string,
|
|
324
|
+
): ChannelEffectRecord {
|
|
325
|
+
if (current.status !== "rejected" && current.status !== "ambiguous") {
|
|
326
|
+
throw new Error("Channel effect is not retryable");
|
|
327
|
+
}
|
|
328
|
+
const attemptNumber = current.attemptNumber + 1;
|
|
329
|
+
const now = Date.now();
|
|
330
|
+
this.mutations.mutate(
|
|
331
|
+
{
|
|
332
|
+
resourceId: current.resourceId,
|
|
333
|
+
operation: "channel.effect.retry",
|
|
334
|
+
actor: { type: reason === "explicit" ? "human" : "host", id: actorId },
|
|
335
|
+
expectedRevision: this.resourceRevision(current.resourceId),
|
|
336
|
+
},
|
|
337
|
+
"effect.applying",
|
|
338
|
+
() => {
|
|
339
|
+
const changed = this.state.connection
|
|
340
|
+
.prepare(
|
|
341
|
+
`UPDATE effects
|
|
342
|
+
SET status = 'applying', attempt_count = ?, result_hash = NULL,
|
|
343
|
+
error_hash = NULL, updated_at = ?, settled_at = NULL
|
|
344
|
+
WHERE effect_id = ? AND status = ? AND attempt_count = ?`,
|
|
345
|
+
)
|
|
346
|
+
.run(attemptNumber, now, current.effectId, current.status, current.attemptNumber);
|
|
347
|
+
if (changed.changes !== 1) throw new Error("Channel effect retry is stale");
|
|
348
|
+
this.insertAttempt(current.effectId, attemptNumber, ownerId, leaseGeneration, now);
|
|
349
|
+
},
|
|
350
|
+
{ now, payload: { attemptNumber, reason } },
|
|
351
|
+
);
|
|
352
|
+
return this.require(current.effectId);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
private insertAttempt(
|
|
356
|
+
effectId: string,
|
|
357
|
+
attemptNumber: number,
|
|
358
|
+
ownerId: string,
|
|
359
|
+
leaseGeneration: number,
|
|
360
|
+
now: number,
|
|
361
|
+
): void {
|
|
362
|
+
this.state.connection
|
|
363
|
+
.prepare(
|
|
364
|
+
`INSERT INTO effect_attempts(
|
|
365
|
+
effect_id, attempt_number, owner_id, lease_generation, started_at
|
|
366
|
+
) VALUES (?, ?, ?, ?, ?)`,
|
|
367
|
+
)
|
|
368
|
+
.run(effectId, attemptNumber, ownerId, leaseGeneration, now);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
private require(effectId: string): ChannelEffectRecord {
|
|
372
|
+
const value = this.read(effectId);
|
|
373
|
+
if (value === undefined) throw new Error(`Channel effect is missing: ${effectId}`);
|
|
374
|
+
return value;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
private materialize(row: EffectRow): ChannelEffectRecord {
|
|
378
|
+
const payload = this.state.readJson(row.payloadHash);
|
|
379
|
+
if (!isChannelEffectPayload(payload)) throw new Error("Channel effect payload is invalid");
|
|
380
|
+
return {
|
|
381
|
+
effectId: row.effectId,
|
|
382
|
+
resourceId: row.resourceId,
|
|
383
|
+
sourceResourceId: row.sourceResourceId,
|
|
384
|
+
status: row.status,
|
|
385
|
+
attemptNumber: row.attemptNumber,
|
|
386
|
+
attemptStartedAt: row.attemptStartedAt,
|
|
387
|
+
...(row.settledAt === null ? {} : { settledAt: row.settledAt }),
|
|
388
|
+
payload,
|
|
389
|
+
...(row.resultHash === null ? {} : { result: this.state.readJson(row.resultHash) }),
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
private resourceRevision(resourceId: string): number {
|
|
394
|
+
const row = this.state.connection
|
|
395
|
+
.prepare("SELECT revision FROM resources WHERE resource_id = ?")
|
|
396
|
+
.get(resourceId);
|
|
397
|
+
if (!isRevisionRow(row)) throw new Error(`Resource is missing: ${resourceId}`);
|
|
398
|
+
return row.revision;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
export function channelEffectId(
|
|
403
|
+
channelId: string,
|
|
404
|
+
decisionId: string,
|
|
405
|
+
requestDigest: string,
|
|
406
|
+
purpose: ChannelEffectPurpose,
|
|
407
|
+
): string {
|
|
408
|
+
return `effect-${createHash("sha256")
|
|
409
|
+
.update(canonicalJson({ channelId, decisionId, requestDigest, purpose }))
|
|
410
|
+
.digest("hex")
|
|
411
|
+
.slice(0, 40)}`;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export function channelEffectAttemptId(effectId: string, attemptNumber: number): string {
|
|
415
|
+
return `channel-${createHash("sha256").update(`${effectId}\0${attemptNumber}`).digest("hex")}`;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function isChannelEffectPayload(value: unknown): value is ChannelEffectPayload {
|
|
419
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
|
|
420
|
+
const payload = value as Partial<ChannelEffectPayload>;
|
|
421
|
+
return (
|
|
422
|
+
payload.schema === "pi-workflows.channel-effect.v1" &&
|
|
423
|
+
typeof payload.channelId === "string" &&
|
|
424
|
+
typeof payload.profile === "string" &&
|
|
425
|
+
typeof payload.decisionId === "string" &&
|
|
426
|
+
(payload.purpose === "delivery" || payload.purpose === "settlement") &&
|
|
427
|
+
payload.request !== null &&
|
|
428
|
+
typeof payload.request === "object"
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function isEffectRow(value: unknown): value is EffectRow {
|
|
433
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
|
|
434
|
+
const row = value as Partial<EffectRow>;
|
|
435
|
+
return (
|
|
436
|
+
typeof row.effectId === "string" &&
|
|
437
|
+
typeof row.resourceId === "string" &&
|
|
438
|
+
typeof row.sourceResourceId === "string" &&
|
|
439
|
+
typeof row.status === "string" &&
|
|
440
|
+
["pending", "applying", "applied", "rejected", "ambiguous", "cancelled"].includes(row.status) &&
|
|
441
|
+
typeof row.attemptNumber === "number" &&
|
|
442
|
+
typeof row.attemptStartedAt === "number" &&
|
|
443
|
+
(row.settledAt === null || typeof row.settledAt === "number") &&
|
|
444
|
+
Buffer.isBuffer(row.payloadHash) &&
|
|
445
|
+
(row.resultHash === null || Buffer.isBuffer(row.resultHash))
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function isEffectIdRow(value: unknown): value is { effectId: string } {
|
|
450
|
+
return (
|
|
451
|
+
value !== null &&
|
|
452
|
+
typeof value === "object" &&
|
|
453
|
+
!Array.isArray(value) &&
|
|
454
|
+
typeof (value as { effectId?: unknown }).effectId === "string"
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function isRevisionRow(value: unknown): value is { revision: number } {
|
|
459
|
+
return (
|
|
460
|
+
value !== null &&
|
|
461
|
+
typeof value === "object" &&
|
|
462
|
+
!Array.isArray(value) &&
|
|
463
|
+
typeof (value as { revision?: unknown }).revision === "number"
|
|
464
|
+
);
|
|
465
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { encodeChannelLaunch } from "../channels/adapter-entry.js";
|
|
5
|
+
import {
|
|
6
|
+
encodeChannelLine,
|
|
7
|
+
parseChannelAdapterMessage,
|
|
8
|
+
type ChannelAdapterLaunch,
|
|
9
|
+
type ChannelAdapterMessage,
|
|
10
|
+
type ChannelAdapterResponse,
|
|
11
|
+
} from "../channels/protocol.js";
|
|
12
|
+
import { ChildWorkerSupervisor, type SupervisedWorkerResult } from "./child-worker-supervisor.js";
|
|
13
|
+
import { HostProcessRegistry, type ProcessIdentity } from "./processes.js";
|
|
14
|
+
import type { WorkerOutcome } from "./state.js";
|
|
15
|
+
|
|
16
|
+
export class ChannelAdapterSupervisor {
|
|
17
|
+
private readonly supervisor: ChildWorkerSupervisor<ChannelAdapterMessage, ChannelAdapterResponse>;
|
|
18
|
+
|
|
19
|
+
constructor(
|
|
20
|
+
readonly launch: ChannelAdapterLaunch,
|
|
21
|
+
private readonly options: {
|
|
22
|
+
registry: HostProcessRegistry;
|
|
23
|
+
onMessage: (message: ChannelAdapterMessage) => Promise<ChannelAdapterResponse>;
|
|
24
|
+
env?: Record<string, string>;
|
|
25
|
+
startupTimeoutMs?: number;
|
|
26
|
+
adapterEntryPath?: string;
|
|
27
|
+
onSpawn?: (identity: ProcessIdentity) => void;
|
|
28
|
+
onDiagnostic?: (message: string) => void;
|
|
29
|
+
},
|
|
30
|
+
) {
|
|
31
|
+
this.supervisor = new ChildWorkerSupervisor({
|
|
32
|
+
label: `Telegram channel adapter ${launch.profile}`,
|
|
33
|
+
registry: options.registry,
|
|
34
|
+
onMessage: options.onMessage,
|
|
35
|
+
parseMessage: parseChannelAdapterMessage,
|
|
36
|
+
encodeResponse: encodeChannelLine,
|
|
37
|
+
isReady: (message) => message.kind === "channel.ready",
|
|
38
|
+
isTerminal: (message) => message.kind === "channel.exiting",
|
|
39
|
+
...(options.startupTimeoutMs === undefined
|
|
40
|
+
? {}
|
|
41
|
+
: { startupTimeoutMs: options.startupTimeoutMs }),
|
|
42
|
+
...(options.onSpawn === undefined ? {} : { onSpawn: options.onSpawn }),
|
|
43
|
+
...(options.onDiagnostic === undefined ? {} : { onDiagnostic: options.onDiagnostic }),
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async start(): Promise<void> {
|
|
48
|
+
const builtEntry = fileURLToPath(new URL("../channels/adapter-entry.js", import.meta.url));
|
|
49
|
+
const sourceEntry = fileURLToPath(new URL("../channels/adapter-entry.ts", import.meta.url));
|
|
50
|
+
const adapterEntry = this.options.adapterEntryPath ?? builtEntry;
|
|
51
|
+
const args =
|
|
52
|
+
this.options.adapterEntryPath === undefined && !fs.existsSync(builtEntry)
|
|
53
|
+
? ["--import", createRequire(import.meta.url).resolve("tsx"), sourceEntry]
|
|
54
|
+
: [adapterEntry];
|
|
55
|
+
await this.supervisor.start({
|
|
56
|
+
args,
|
|
57
|
+
cwd: process.cwd(),
|
|
58
|
+
env: {
|
|
59
|
+
...process.env,
|
|
60
|
+
...this.options.env,
|
|
61
|
+
PI_WORKFLOWS_CHANNEL_LAUNCH: encodeChannelLaunch(this.launch),
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async wait(): Promise<SupervisedWorkerResult> {
|
|
67
|
+
return await this.supervisor.wait();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async stop(outcome: WorkerOutcome = "cancelled"): Promise<void> {
|
|
71
|
+
await this.supervisor.stop(outcome);
|
|
72
|
+
}
|
|
73
|
+
}
|