@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,333 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import type { JsonValue } from "../state/json.js";
|
|
3
|
+
import type { HumanDecisionChannelRequest, HumanDecisionResponse } from "../workflows/types.js";
|
|
4
|
+
|
|
5
|
+
export const CHANNEL_ADAPTER_PROTOCOL_SCHEMA = "pi-workflows.channel-adapter.v1" as const;
|
|
6
|
+
|
|
7
|
+
export type TelegramMessageReference = {
|
|
8
|
+
chatId: string;
|
|
9
|
+
messageId: string;
|
|
10
|
+
recipientIndex: number;
|
|
11
|
+
partIndex: number;
|
|
12
|
+
contentDigest: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
type ChannelAdapterMessageBase = {
|
|
16
|
+
schema: typeof CHANNEL_ADAPTER_PROTOCOL_SCHEMA;
|
|
17
|
+
adapterEpoch: string;
|
|
18
|
+
profile: string;
|
|
19
|
+
sequence: number;
|
|
20
|
+
expectedRevision: number;
|
|
21
|
+
stableMessageId: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type ChannelAdapterMessage =
|
|
25
|
+
| (ChannelAdapterMessageBase & {
|
|
26
|
+
kind: "channel.ready";
|
|
27
|
+
cursor: number;
|
|
28
|
+
})
|
|
29
|
+
| (ChannelAdapterMessageBase & {
|
|
30
|
+
kind: "channel.present";
|
|
31
|
+
decisionId: string;
|
|
32
|
+
requestDigest: string;
|
|
33
|
+
attemptId: string;
|
|
34
|
+
state: "confirmed" | "failed" | "unknown";
|
|
35
|
+
messages: TelegramMessageReference[];
|
|
36
|
+
errorCode?: string;
|
|
37
|
+
})
|
|
38
|
+
| (ChannelAdapterMessageBase & {
|
|
39
|
+
kind: "channel.answer";
|
|
40
|
+
decisionId: string;
|
|
41
|
+
requestDigest: string;
|
|
42
|
+
response: HumanDecisionResponse;
|
|
43
|
+
actorId: string;
|
|
44
|
+
chatId: string;
|
|
45
|
+
eventId: string;
|
|
46
|
+
idempotencyKey: string;
|
|
47
|
+
cursor: number;
|
|
48
|
+
})
|
|
49
|
+
| (ChannelAdapterMessageBase & {
|
|
50
|
+
kind: "channel.settle";
|
|
51
|
+
decisionId: string;
|
|
52
|
+
requestDigest: string;
|
|
53
|
+
attemptId: string;
|
|
54
|
+
state: "confirmed" | "failed" | "unknown";
|
|
55
|
+
errorCode?: string;
|
|
56
|
+
})
|
|
57
|
+
| (ChannelAdapterMessageBase & {
|
|
58
|
+
kind: "channel.exiting";
|
|
59
|
+
cursor: number;
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
export type ChannelAdapterCommand =
|
|
63
|
+
| {
|
|
64
|
+
kind: "channel.present";
|
|
65
|
+
stableMessageId: string;
|
|
66
|
+
attemptId: string;
|
|
67
|
+
request: HumanDecisionChannelRequest;
|
|
68
|
+
}
|
|
69
|
+
| {
|
|
70
|
+
kind: "channel.settle";
|
|
71
|
+
stableMessageId: string;
|
|
72
|
+
attemptId: string;
|
|
73
|
+
request: HumanDecisionChannelRequest;
|
|
74
|
+
outcome: "accepted" | "cancelled" | "expired";
|
|
75
|
+
response?: HumanDecisionResponse;
|
|
76
|
+
messages: TelegramMessageReference[];
|
|
77
|
+
}
|
|
78
|
+
| {
|
|
79
|
+
kind: "channel.poll";
|
|
80
|
+
cursor: number;
|
|
81
|
+
requests: HumanDecisionChannelRequest[];
|
|
82
|
+
}
|
|
83
|
+
| { kind: "channel.stop" };
|
|
84
|
+
|
|
85
|
+
export type ChannelAdapterResponse = {
|
|
86
|
+
schema: typeof CHANNEL_ADAPTER_PROTOCOL_SCHEMA;
|
|
87
|
+
type: "response";
|
|
88
|
+
sequence: number;
|
|
89
|
+
outcome: "accepted" | "rejected";
|
|
90
|
+
revision: number;
|
|
91
|
+
command: ChannelAdapterCommand | null;
|
|
92
|
+
error?: string;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
export type ChannelAdapterLaunch = {
|
|
96
|
+
schema: "pi-workflows.channel-adapter-launch.v1";
|
|
97
|
+
adapterEpoch: string;
|
|
98
|
+
profile: string;
|
|
99
|
+
token: string;
|
|
100
|
+
allowedUserIds: string[];
|
|
101
|
+
allowedChatIds: string[];
|
|
102
|
+
apiBase?: string;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
export function encodeChannelLine(value: ChannelAdapterMessage | ChannelAdapterResponse): Buffer {
|
|
106
|
+
return Buffer.from(`${JSON.stringify(value)}\n`, "utf8");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function parseChannelAdapterMessage(frame: Buffer): ChannelAdapterMessage {
|
|
110
|
+
const value = parseObject(frame, "channel adapter message");
|
|
111
|
+
requireBase(value);
|
|
112
|
+
const kind = value.kind;
|
|
113
|
+
if (kind === "channel.ready" || kind === "channel.exiting") {
|
|
114
|
+
requireNonNegativeInteger(value.cursor, "channel cursor");
|
|
115
|
+
} else if (kind === "channel.present") {
|
|
116
|
+
requireText(value.decisionId, "decisionId");
|
|
117
|
+
requireText(value.requestDigest, "requestDigest");
|
|
118
|
+
requireText(value.attemptId, "attemptId");
|
|
119
|
+
requireOneOf(value.state, ["confirmed", "failed", "unknown"], "presentation state");
|
|
120
|
+
if (!Array.isArray(value.messages)) throw new Error("Channel message references are invalid");
|
|
121
|
+
value.messages.forEach(validateMessageReference);
|
|
122
|
+
requireOptionalText(value.errorCode, "errorCode");
|
|
123
|
+
} else if (kind === "channel.answer") {
|
|
124
|
+
requireText(value.decisionId, "decisionId");
|
|
125
|
+
requireText(value.requestDigest, "requestDigest");
|
|
126
|
+
validateDecisionResponse(value.response);
|
|
127
|
+
requireText(value.actorId, "actorId");
|
|
128
|
+
requireText(value.chatId, "chatId");
|
|
129
|
+
requireText(value.eventId, "eventId");
|
|
130
|
+
requireText(value.idempotencyKey, "idempotencyKey");
|
|
131
|
+
requireNonNegativeInteger(value.cursor, "channel cursor");
|
|
132
|
+
} else if (kind === "channel.settle") {
|
|
133
|
+
requireText(value.decisionId, "decisionId");
|
|
134
|
+
requireText(value.requestDigest, "requestDigest");
|
|
135
|
+
requireText(value.attemptId, "attemptId");
|
|
136
|
+
requireOneOf(value.state, ["confirmed", "failed", "unknown"], "settlement state");
|
|
137
|
+
requireOptionalText(value.errorCode, "errorCode");
|
|
138
|
+
} else {
|
|
139
|
+
throw new Error("Channel adapter message kind is invalid");
|
|
140
|
+
}
|
|
141
|
+
return value as unknown as ChannelAdapterMessage;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function parseChannelAdapterResponse(frame: Buffer): ChannelAdapterResponse {
|
|
145
|
+
const value = parseObject(frame, "channel adapter response");
|
|
146
|
+
if (
|
|
147
|
+
value.schema !== CHANNEL_ADAPTER_PROTOCOL_SCHEMA ||
|
|
148
|
+
value.type !== "response" ||
|
|
149
|
+
!Number.isSafeInteger(value.sequence) ||
|
|
150
|
+
(value.outcome !== "accepted" && value.outcome !== "rejected") ||
|
|
151
|
+
!Number.isSafeInteger(value.revision)
|
|
152
|
+
) {
|
|
153
|
+
throw new Error("Channel adapter response is invalid");
|
|
154
|
+
}
|
|
155
|
+
requirePositiveInteger(value.sequence, "channel response sequence");
|
|
156
|
+
requireNonNegativeInteger(value.revision, "channel response revision");
|
|
157
|
+
requireOptionalText(value.error, "channel response error");
|
|
158
|
+
validateCommand(value.command);
|
|
159
|
+
return value as unknown as ChannelAdapterResponse;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function parseChannelAdapterLaunch(value: unknown): ChannelAdapterLaunch {
|
|
163
|
+
const launch = requireObject(value, "channel adapter launch envelope");
|
|
164
|
+
if (launch.schema !== "pi-workflows.channel-adapter-launch.v1") {
|
|
165
|
+
throw new Error("Channel adapter launch envelope is invalid");
|
|
166
|
+
}
|
|
167
|
+
requireText(launch.adapterEpoch, "adapterEpoch");
|
|
168
|
+
requireText(launch.profile, "profile");
|
|
169
|
+
requireText(launch.token, "token");
|
|
170
|
+
requireStringArray(launch.allowedUserIds, "allowedUserIds");
|
|
171
|
+
requireStringArray(launch.allowedChatIds, "allowedChatIds");
|
|
172
|
+
requireOptionalText(launch.apiBase, "apiBase");
|
|
173
|
+
return launch as ChannelAdapterLaunch;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function channelResponse(
|
|
177
|
+
message: ChannelAdapterMessage,
|
|
178
|
+
outcome: ChannelAdapterResponse["outcome"],
|
|
179
|
+
revision: number,
|
|
180
|
+
command: ChannelAdapterCommand | null,
|
|
181
|
+
error?: string,
|
|
182
|
+
): ChannelAdapterResponse {
|
|
183
|
+
return {
|
|
184
|
+
schema: CHANNEL_ADAPTER_PROTOCOL_SCHEMA,
|
|
185
|
+
type: "response",
|
|
186
|
+
sequence: message.sequence,
|
|
187
|
+
outcome,
|
|
188
|
+
revision,
|
|
189
|
+
command,
|
|
190
|
+
...(error === undefined ? {} : { error }),
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function channelStableMessageId(parts: readonly string[]): string {
|
|
195
|
+
return `channel-${createHash("sha256").update(parts.join("\0")).digest("hex")}`;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function requireBase(value: Record<string, unknown>): void {
|
|
199
|
+
if (value.schema !== CHANNEL_ADAPTER_PROTOCOL_SCHEMA) {
|
|
200
|
+
throw new Error("Channel adapter message is invalid");
|
|
201
|
+
}
|
|
202
|
+
requireText(value.adapterEpoch, "adapterEpoch");
|
|
203
|
+
requireText(value.profile, "profile");
|
|
204
|
+
requirePositiveInteger(value.sequence, "channel sequence");
|
|
205
|
+
requireNonNegativeInteger(value.expectedRevision, "channel expected revision");
|
|
206
|
+
requireText(value.stableMessageId, "stableMessageId");
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function validateCommand(value: unknown): void {
|
|
210
|
+
if (value === null) return;
|
|
211
|
+
const command = requireObject(value, "channel command");
|
|
212
|
+
if (command.kind === "channel.stop") return;
|
|
213
|
+
if (command.kind === "channel.present") {
|
|
214
|
+
requireText(command.stableMessageId, "stableMessageId");
|
|
215
|
+
requireText(command.attemptId, "attemptId");
|
|
216
|
+
validateChannelRequest(command.request);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
if (command.kind === "channel.settle") {
|
|
220
|
+
requireText(command.stableMessageId, "stableMessageId");
|
|
221
|
+
requireText(command.attemptId, "attemptId");
|
|
222
|
+
validateChannelRequest(command.request);
|
|
223
|
+
requireOneOf(command.outcome, ["accepted", "cancelled", "expired"], "settlement outcome");
|
|
224
|
+
if (command.response !== undefined) validateDecisionResponse(command.response);
|
|
225
|
+
if (!Array.isArray(command.messages)) throw new Error("Channel message references are invalid");
|
|
226
|
+
command.messages.forEach(validateMessageReference);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
if (command.kind === "channel.poll") {
|
|
230
|
+
requireNonNegativeInteger(command.cursor, "channel cursor");
|
|
231
|
+
if (!Array.isArray(command.requests)) throw new Error("Channel poll requests are invalid");
|
|
232
|
+
command.requests.forEach(validateChannelRequest);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
throw new Error("Channel adapter command kind is invalid");
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function validateChannelRequest(value: unknown): void {
|
|
239
|
+
const request = requireObject(value, "human decision channel request");
|
|
240
|
+
if (
|
|
241
|
+
request.schema !== "pi-workflows.human-decision-channel-request.v1" ||
|
|
242
|
+
request.sourceSchema !== "pi-workflows.human-decision-request.v1" ||
|
|
243
|
+
Object.hasOwn(request, "subject")
|
|
244
|
+
) {
|
|
245
|
+
throw new Error("Human decision channel request is invalid");
|
|
246
|
+
}
|
|
247
|
+
for (const field of [
|
|
248
|
+
"decisionId",
|
|
249
|
+
"requestDigest",
|
|
250
|
+
"runId",
|
|
251
|
+
"workflowName",
|
|
252
|
+
"nodeId",
|
|
253
|
+
"attemptId",
|
|
254
|
+
"audience",
|
|
255
|
+
"title",
|
|
256
|
+
"presentationDigest",
|
|
257
|
+
"createdAt",
|
|
258
|
+
]) {
|
|
259
|
+
requireText(request[field], field);
|
|
260
|
+
}
|
|
261
|
+
requireNonNegativeInteger(request.revision, "decision revision");
|
|
262
|
+
requireObject(request.presentation, "decision presentation");
|
|
263
|
+
requireObject(request.choices, "decision choices");
|
|
264
|
+
requireOptionalText(request.expiresAt, "decision expiry");
|
|
265
|
+
if (request.defaultResponse !== undefined) validateDecisionResponse(request.defaultResponse);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function validateDecisionResponse(value: unknown): void {
|
|
269
|
+
const response = requireObject(value, "human decision response");
|
|
270
|
+
requireText(response.choice, "decision choice");
|
|
271
|
+
if (response.input === undefined) return;
|
|
272
|
+
const input = requireObject(response.input, "decision input");
|
|
273
|
+
if (Object.values(input).some((item) => typeof item !== "string")) {
|
|
274
|
+
throw new Error("Decision input values must be strings");
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function validateMessageReference(value: unknown): void {
|
|
279
|
+
const reference = requireObject(value, "Telegram message reference");
|
|
280
|
+
requireText(reference.chatId, "Telegram chat ID");
|
|
281
|
+
requireText(reference.messageId, "Telegram message ID");
|
|
282
|
+
requireNonNegativeInteger(reference.recipientIndex, "Telegram recipient index");
|
|
283
|
+
requireNonNegativeInteger(reference.partIndex, "Telegram part index");
|
|
284
|
+
requireText(reference.contentDigest, "Telegram content digest");
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function requireObject(value: unknown, label: string): Record<string, unknown> {
|
|
288
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
289
|
+
throw new Error(`${label} must be an object`);
|
|
290
|
+
}
|
|
291
|
+
return value as Record<string, unknown>;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function requireText(value: unknown, label: string): asserts value is string {
|
|
295
|
+
if (typeof value !== "string" || value.length === 0) throw new Error(`${label} is invalid`);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function requireOptionalText(value: unknown, label: string): void {
|
|
299
|
+
if (value !== undefined) requireText(value, label);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function requirePositiveInteger(value: unknown, label: string): void {
|
|
303
|
+
if (!Number.isSafeInteger(value) || (value as number) <= 0)
|
|
304
|
+
throw new Error(`${label} is invalid`);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function requireNonNegativeInteger(value: unknown, label: string): void {
|
|
308
|
+
if (!Number.isSafeInteger(value) || (value as number) < 0) throw new Error(`${label} is invalid`);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function requireStringArray(value: unknown, label: string): asserts value is string[] {
|
|
312
|
+
if (
|
|
313
|
+
!Array.isArray(value) ||
|
|
314
|
+
value.length === 0 ||
|
|
315
|
+
value.some((item) => typeof item !== "string" || item.length === 0)
|
|
316
|
+
) {
|
|
317
|
+
throw new Error(`${label} is invalid`);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function requireOneOf(value: unknown, allowed: readonly string[], label: string): void {
|
|
322
|
+
if (typeof value !== "string" || !allowed.includes(value)) throw new Error(`${label} is invalid`);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function parseObject(frame: Buffer, label: string): Record<string, unknown> {
|
|
326
|
+
let value: JsonValue;
|
|
327
|
+
try {
|
|
328
|
+
value = JSON.parse(frame.toString("utf8")) as JsonValue;
|
|
329
|
+
} catch {
|
|
330
|
+
throw new Error(`${label} is not valid JSON`);
|
|
331
|
+
}
|
|
332
|
+
return requireObject(value, label);
|
|
333
|
+
}
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import { createHmac } from "node:crypto";
|
|
2
|
+
import { decisionDocumentSegments, digestCanonical } from "../workflows/decision-presentation.js";
|
|
3
|
+
import type { HumanDecisionChannelRequest, HumanDecisionResponse } from "../workflows/types.js";
|
|
4
|
+
import type { TelegramFetch } from "./config.js";
|
|
5
|
+
import type { TelegramMessageReference } from "./protocol.js";
|
|
6
|
+
|
|
7
|
+
const DEFAULT_API_BASE = "https://api.telegram.org";
|
|
8
|
+
const TELEGRAM_TEXT_LIMIT = 4_096;
|
|
9
|
+
const CALLBACK_PREFIX = "pw:";
|
|
10
|
+
const REPLY_TOKEN_PREFIX = "Reply token: ";
|
|
11
|
+
|
|
12
|
+
export type TelegramVerifiedAnswer = {
|
|
13
|
+
request: HumanDecisionChannelRequest;
|
|
14
|
+
response: HumanDecisionResponse;
|
|
15
|
+
actorId: string;
|
|
16
|
+
chatId: string;
|
|
17
|
+
eventId: string;
|
|
18
|
+
cursor: number;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export class TelegramAdapter {
|
|
22
|
+
private requests: HumanDecisionChannelRequest[] = [];
|
|
23
|
+
private readonly allowedUsers: Set<string>;
|
|
24
|
+
private readonly allowedChats: Set<string>;
|
|
25
|
+
private readonly request: TelegramFetch;
|
|
26
|
+
|
|
27
|
+
constructor(
|
|
28
|
+
private readonly options: {
|
|
29
|
+
profile: string;
|
|
30
|
+
token: string;
|
|
31
|
+
allowedUserIds: readonly string[];
|
|
32
|
+
allowedChatIds: readonly string[];
|
|
33
|
+
fetchFn?: TelegramFetch;
|
|
34
|
+
apiBase?: string;
|
|
35
|
+
},
|
|
36
|
+
) {
|
|
37
|
+
this.allowedUsers = new Set(options.allowedUserIds);
|
|
38
|
+
this.allowedChats = new Set(options.allowedChatIds);
|
|
39
|
+
this.request = options.fetchFn ?? (fetch as TelegramFetch);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
setRequests(requests: readonly HumanDecisionChannelRequest[]): void {
|
|
43
|
+
this.requests = [...requests];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async present(request: HumanDecisionChannelRequest): Promise<TelegramMessageReference[]> {
|
|
47
|
+
const parts = renderTelegramParts(request);
|
|
48
|
+
const references: TelegramMessageReference[] = [];
|
|
49
|
+
const keyboard = this.keyboard(request);
|
|
50
|
+
for (const [recipientIndex, chatId] of [...this.allowedChats].entries()) {
|
|
51
|
+
for (const [partIndex, text] of parts.entries()) {
|
|
52
|
+
const result = await this.call("sendMessage", {
|
|
53
|
+
chat_id: chatId,
|
|
54
|
+
text,
|
|
55
|
+
...(partIndex === parts.length - 1
|
|
56
|
+
? { reply_markup: { inline_keyboard: keyboard } }
|
|
57
|
+
: {}),
|
|
58
|
+
});
|
|
59
|
+
const messageId = integerField(result, "message_id", "Telegram sendMessage result");
|
|
60
|
+
references.push({
|
|
61
|
+
chatId,
|
|
62
|
+
messageId: String(messageId),
|
|
63
|
+
recipientIndex,
|
|
64
|
+
partIndex,
|
|
65
|
+
contentDigest: digestCanonical(text),
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return references;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async poll(cursor: number): Promise<{ cursor: number; answers: TelegramVerifiedAnswer[] }> {
|
|
73
|
+
const result = await this.call("getUpdates", {
|
|
74
|
+
offset: cursor,
|
|
75
|
+
timeout: 1,
|
|
76
|
+
allowed_updates: ["callback_query", "message"],
|
|
77
|
+
});
|
|
78
|
+
if (!Array.isArray(result)) throw new Error("Telegram getUpdates result must be an array");
|
|
79
|
+
let nextCursor = cursor;
|
|
80
|
+
const answers: TelegramVerifiedAnswer[] = [];
|
|
81
|
+
for (const raw of result) {
|
|
82
|
+
const update = asRecord(raw);
|
|
83
|
+
if (update === null || !Number.isSafeInteger(update.update_id)) continue;
|
|
84
|
+
const updateId = update.update_id as number;
|
|
85
|
+
nextCursor = Math.max(nextCursor, updateId + 1);
|
|
86
|
+
const answer = await this.handleUpdate(update, updateId + 1);
|
|
87
|
+
if (answer !== undefined) answers.push(answer);
|
|
88
|
+
}
|
|
89
|
+
return { cursor: nextCursor, answers };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async settle(
|
|
93
|
+
outcome: "accepted" | "cancelled" | "expired",
|
|
94
|
+
response: HumanDecisionResponse | undefined,
|
|
95
|
+
messages: readonly TelegramMessageReference[],
|
|
96
|
+
): Promise<void> {
|
|
97
|
+
const seenChats = new Set<string>();
|
|
98
|
+
for (const message of messages) {
|
|
99
|
+
await this.call("editMessageReplyMarkup", {
|
|
100
|
+
chat_id: message.chatId,
|
|
101
|
+
message_id: Number.parseInt(message.messageId, 10),
|
|
102
|
+
reply_markup: { inline_keyboard: [] },
|
|
103
|
+
});
|
|
104
|
+
if (seenChats.has(message.chatId)) continue;
|
|
105
|
+
seenChats.add(message.chatId);
|
|
106
|
+
const selection = response?.choice === undefined ? "" : ` (${response.choice})`;
|
|
107
|
+
await this.call("sendMessage", {
|
|
108
|
+
chat_id: message.chatId,
|
|
109
|
+
text: `Decision ${outcome}${selection}.`,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private async handleUpdate(
|
|
115
|
+
update: Record<string, unknown>,
|
|
116
|
+
cursor: number,
|
|
117
|
+
): Promise<TelegramVerifiedAnswer | undefined> {
|
|
118
|
+
const callback = asRecord(update.callback_query);
|
|
119
|
+
if (callback !== null) return await this.handleCallback(callback, cursor);
|
|
120
|
+
const message = asRecord(update.message);
|
|
121
|
+
if (message !== null) return await this.handleReply(message, cursor);
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private async handleCallback(
|
|
126
|
+
callback: Record<string, unknown>,
|
|
127
|
+
cursor: number,
|
|
128
|
+
): Promise<TelegramVerifiedAnswer | undefined> {
|
|
129
|
+
const callbackId = stringField(callback, "id");
|
|
130
|
+
const actor = asRecord(callback.from);
|
|
131
|
+
const message = asRecord(callback.message);
|
|
132
|
+
const chat = asRecord(message?.chat);
|
|
133
|
+
const actorId = numericText(actor?.id);
|
|
134
|
+
const chatId = numericText(chat?.id);
|
|
135
|
+
const data = typeof callback.data === "string" ? callback.data : "";
|
|
136
|
+
if (
|
|
137
|
+
callbackId === undefined ||
|
|
138
|
+
actorId === undefined ||
|
|
139
|
+
chatId === undefined ||
|
|
140
|
+
!this.allowedUsers.has(actorId) ||
|
|
141
|
+
!this.allowedChats.has(chatId)
|
|
142
|
+
) {
|
|
143
|
+
if (callbackId !== undefined) {
|
|
144
|
+
await this.answerCallback(callbackId, "This decision answer is not authorized.");
|
|
145
|
+
}
|
|
146
|
+
return undefined;
|
|
147
|
+
}
|
|
148
|
+
const binding = this.callbackBinding(data);
|
|
149
|
+
if (binding === undefined) {
|
|
150
|
+
await this.answerCallback(callbackId, "This decision choice is no longer active.");
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
const choice = binding.request.choices[binding.choice];
|
|
154
|
+
if (choice === undefined) return undefined;
|
|
155
|
+
if (choice.input !== undefined) {
|
|
156
|
+
const token = this.replyToken(binding.request, binding.choice);
|
|
157
|
+
await this.call("sendMessage", {
|
|
158
|
+
chat_id: chatId,
|
|
159
|
+
text: `${choice.input.prompt}\n${REPLY_TOKEN_PREFIX}${token}`,
|
|
160
|
+
reply_markup: { force_reply: true, selective: true },
|
|
161
|
+
});
|
|
162
|
+
await this.answerCallback(callbackId, "Reply to the new prompt.");
|
|
163
|
+
return undefined;
|
|
164
|
+
}
|
|
165
|
+
await this.answerCallback(callbackId, "Answer received.");
|
|
166
|
+
return {
|
|
167
|
+
request: binding.request,
|
|
168
|
+
response: { choice: binding.choice },
|
|
169
|
+
actorId,
|
|
170
|
+
chatId,
|
|
171
|
+
eventId: callbackId,
|
|
172
|
+
cursor,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private async handleReply(
|
|
177
|
+
message: Record<string, unknown>,
|
|
178
|
+
cursor: number,
|
|
179
|
+
): Promise<TelegramVerifiedAnswer | undefined> {
|
|
180
|
+
const actorId = numericText(asRecord(message.from)?.id);
|
|
181
|
+
const chatId = numericText(asRecord(message.chat)?.id);
|
|
182
|
+
const text = typeof message.text === "string" ? message.text.trim() : "";
|
|
183
|
+
const replyText = asRecord(message.reply_to_message)?.text;
|
|
184
|
+
const eventId = numericText(message.message_id);
|
|
185
|
+
if (
|
|
186
|
+
actorId === undefined ||
|
|
187
|
+
chatId === undefined ||
|
|
188
|
+
eventId === undefined ||
|
|
189
|
+
text.length === 0 ||
|
|
190
|
+
typeof replyText !== "string" ||
|
|
191
|
+
!this.allowedUsers.has(actorId) ||
|
|
192
|
+
!this.allowedChats.has(chatId)
|
|
193
|
+
) {
|
|
194
|
+
return undefined;
|
|
195
|
+
}
|
|
196
|
+
const marker = replyText.lastIndexOf(REPLY_TOKEN_PREFIX);
|
|
197
|
+
if (marker < 0) return undefined;
|
|
198
|
+
const token = replyText
|
|
199
|
+
.slice(marker + REPLY_TOKEN_PREFIX.length)
|
|
200
|
+
.trim()
|
|
201
|
+
.split(/\s/u)[0];
|
|
202
|
+
if (token === undefined) return undefined;
|
|
203
|
+
const binding = this.replyBinding(token);
|
|
204
|
+
if (binding === undefined) return undefined;
|
|
205
|
+
const choice = binding.request.choices[binding.choice];
|
|
206
|
+
if (choice?.input === undefined) return undefined;
|
|
207
|
+
return {
|
|
208
|
+
request: binding.request,
|
|
209
|
+
response: { choice: binding.choice, input: { [choice.input.name]: text } },
|
|
210
|
+
actorId,
|
|
211
|
+
chatId,
|
|
212
|
+
eventId,
|
|
213
|
+
cursor,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
private keyboard(request: HumanDecisionChannelRequest): Array<Array<Record<string, string>>> {
|
|
218
|
+
return Object.entries(request.choices).map(([choice, definition]) => [
|
|
219
|
+
{
|
|
220
|
+
text: definition.label,
|
|
221
|
+
callback_data: `${CALLBACK_PREFIX}${this.callbackToken(request, choice)}`,
|
|
222
|
+
},
|
|
223
|
+
]);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private callbackBinding(
|
|
227
|
+
data: string,
|
|
228
|
+
): { request: HumanDecisionChannelRequest; choice: string } | undefined {
|
|
229
|
+
if (!data.startsWith(CALLBACK_PREFIX)) return undefined;
|
|
230
|
+
const token = data.slice(CALLBACK_PREFIX.length);
|
|
231
|
+
for (const request of this.requests) {
|
|
232
|
+
for (const choice of Object.keys(request.choices)) {
|
|
233
|
+
if (this.callbackToken(request, choice) === token) return { request, choice };
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return undefined;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
private replyBinding(
|
|
240
|
+
token: string,
|
|
241
|
+
): { request: HumanDecisionChannelRequest; choice: string } | undefined {
|
|
242
|
+
for (const request of this.requests) {
|
|
243
|
+
for (const [choice, definition] of Object.entries(request.choices)) {
|
|
244
|
+
if (definition.input !== undefined && this.replyToken(request, choice) === token) {
|
|
245
|
+
return { request, choice };
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return undefined;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
private callbackToken(request: HumanDecisionChannelRequest, choice: string): string {
|
|
253
|
+
return this.token("callback", request, choice);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
private replyToken(request: HumanDecisionChannelRequest, choice: string): string {
|
|
257
|
+
return this.token("reply", request, choice);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
private token(
|
|
261
|
+
purpose: "callback" | "reply",
|
|
262
|
+
request: HumanDecisionChannelRequest,
|
|
263
|
+
choice: string,
|
|
264
|
+
): string {
|
|
265
|
+
return createHmac("sha256", this.options.token)
|
|
266
|
+
.update(`${purpose}\0${this.options.profile}\0${request.requestDigest}\0${choice}`)
|
|
267
|
+
.digest("base64url")
|
|
268
|
+
.slice(0, 32);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
private async answerCallback(callbackQueryId: string, text: string): Promise<void> {
|
|
272
|
+
await this.call("answerCallbackQuery", { callback_query_id: callbackQueryId, text });
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
private async call(method: string, payload: Record<string, unknown>): Promise<unknown> {
|
|
276
|
+
const apiBase = this.options.apiBase ?? DEFAULT_API_BASE;
|
|
277
|
+
const response = await this.request(
|
|
278
|
+
`${apiBase}/bot${encodeURIComponent(this.options.token)}/${method}`,
|
|
279
|
+
{
|
|
280
|
+
method: "POST",
|
|
281
|
+
headers: { "content-type": "application/json" },
|
|
282
|
+
body: JSON.stringify(payload),
|
|
283
|
+
},
|
|
284
|
+
);
|
|
285
|
+
if (!response.ok) throw new Error(`Telegram ${method} failed with HTTP ${response.status}`);
|
|
286
|
+
const body = asRecord(await response.json());
|
|
287
|
+
if (body?.ok !== true) throw new Error(`Telegram ${method} was rejected`);
|
|
288
|
+
return body.result;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export function renderDecisionText(request: HumanDecisionChannelRequest): string {
|
|
293
|
+
return decisionDocumentSegments(request)
|
|
294
|
+
.map((segment) => segment.text)
|
|
295
|
+
.join("\n\n");
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export function renderTelegramParts(request: HumanDecisionChannelRequest): string[] {
|
|
299
|
+
const text = renderDecisionText(request);
|
|
300
|
+
if (text.length <= TELEGRAM_TEXT_LIMIT) return [text];
|
|
301
|
+
const parts: string[] = [];
|
|
302
|
+
let remaining = text;
|
|
303
|
+
while (remaining.length > TELEGRAM_TEXT_LIMIT) {
|
|
304
|
+
const window = remaining.slice(0, TELEGRAM_TEXT_LIMIT);
|
|
305
|
+
const boundary = Math.max(window.lastIndexOf("\n\n"), window.lastIndexOf("\n"));
|
|
306
|
+
const cut = boundary > TELEGRAM_TEXT_LIMIT / 2 ? boundary : TELEGRAM_TEXT_LIMIT;
|
|
307
|
+
parts.push(remaining.slice(0, cut));
|
|
308
|
+
remaining = remaining.slice(cut).replace(/^\n+/u, "");
|
|
309
|
+
}
|
|
310
|
+
if (remaining.length > 0) parts.push(remaining);
|
|
311
|
+
return parts;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function integerField(value: unknown, field: string, label: string): number {
|
|
315
|
+
const record = asRecord(value);
|
|
316
|
+
const result = record?.[field];
|
|
317
|
+
if (!Number.isSafeInteger(result)) throw new Error(`${label} ${field} must be an integer`);
|
|
318
|
+
return result as number;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function stringField(value: Record<string, unknown>, field: string): string | undefined {
|
|
322
|
+
return typeof value[field] === "string" ? (value[field] as string) : undefined;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function numericText(value: unknown): string | undefined {
|
|
326
|
+
if (typeof value === "number" && Number.isSafeInteger(value)) return String(value);
|
|
327
|
+
if (typeof value === "string" && /^-?[0-9]+$/u.test(value)) return value;
|
|
328
|
+
return undefined;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
332
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
333
|
+
? (value as Record<string, unknown>)
|
|
334
|
+
: null;
|
|
335
|
+
}
|
package/src/client/client.ts
CHANGED
|
@@ -182,12 +182,16 @@ export class WorkflowClient {
|
|
|
182
182
|
async watchSession(
|
|
183
183
|
sessionId: string,
|
|
184
184
|
listener: (event: ClientEvent) => void,
|
|
185
|
-
options: { subscriptionId?: string } = {},
|
|
185
|
+
options: { subscriptionId?: string; coordinator?: boolean } = {},
|
|
186
186
|
): Promise<() => Promise<void>> {
|
|
187
187
|
return await this.subscribe(
|
|
188
188
|
"view.session.watch",
|
|
189
189
|
undefined,
|
|
190
|
-
{
|
|
190
|
+
{
|
|
191
|
+
subscriptionId: options.subscriptionId ?? randomUUID(),
|
|
192
|
+
sessionId,
|
|
193
|
+
...(options.coordinator === undefined ? {} : { coordinator: options.coordinator }),
|
|
194
|
+
},
|
|
191
195
|
listener,
|
|
192
196
|
);
|
|
193
197
|
}
|
package/src/client/index.ts
CHANGED
|
@@ -15,10 +15,11 @@ export {
|
|
|
15
15
|
type ClientResponse,
|
|
16
16
|
} from "./protocol.js";
|
|
17
17
|
export type {
|
|
18
|
-
|
|
18
|
+
WorkflowBranchReport,
|
|
19
19
|
WorkflowDisplay,
|
|
20
20
|
WorkflowDisplayStatus,
|
|
21
21
|
WorkflowRunSummary,
|
|
22
22
|
WorkflowRunView,
|
|
23
23
|
WorkflowSessionView,
|
|
24
|
+
WorkflowTurnReport,
|
|
24
25
|
} from "./view.js";
|