@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.
Files changed (175) hide show
  1. package/README.md +18 -2
  2. package/dist/channels/adapter-entry.d.ts +3 -0
  3. package/dist/channels/adapter-entry.js +203 -0
  4. package/dist/channels/adapter-entry.js.map +1 -0
  5. package/dist/channels/config.d.ts +43 -0
  6. package/dist/channels/config.js +234 -0
  7. package/dist/channels/config.js.map +1 -0
  8. package/dist/channels/protocol.d.ts +94 -0
  9. package/dist/channels/protocol.js +219 -0
  10. package/dist/channels/protocol.js.map +1 -0
  11. package/dist/channels/telegram.d.ts +46 -0
  12. package/dist/channels/telegram.js +285 -0
  13. package/dist/channels/telegram.js.map +1 -0
  14. package/dist/client/client.d.ts +1 -0
  15. package/dist/client/client.js +5 -1
  16. package/dist/client/client.js.map +1 -1
  17. package/dist/client/index.d.ts +1 -1
  18. package/dist/client/protocol.d.ts +1 -1
  19. package/dist/client/protocol.js +11 -5
  20. package/dist/client/protocol.js.map +1 -1
  21. package/dist/client/view.d.ts +48 -16
  22. package/dist/client/view.js +1 -0
  23. package/dist/client/view.js.map +1 -1
  24. package/dist/controllers/index.d.ts +1 -1
  25. package/dist/controllers/index.js.map +1 -1
  26. package/dist/controllers/sqlite.d.ts +20 -165
  27. package/dist/controllers/sqlite.js +178 -436
  28. package/dist/controllers/sqlite.js.map +1 -1
  29. package/dist/extension/index.d.ts +23 -2
  30. package/dist/extension/index.js +637 -534
  31. package/dist/extension/index.js.map +1 -1
  32. package/dist/extension/recorder.d.ts +95 -0
  33. package/dist/extension/recorder.js +530 -0
  34. package/dist/extension/recorder.js.map +1 -0
  35. package/dist/extension/remote-recorder-store.d.ts +25 -0
  36. package/dist/extension/remote-recorder-store.js +81 -0
  37. package/dist/extension/remote-recorder-store.js.map +1 -0
  38. package/dist/extension/session-events.d.ts +134 -0
  39. package/dist/extension/session-events.js +60 -0
  40. package/dist/extension/session-events.js.map +1 -0
  41. package/dist/extension/session-view.d.ts +2 -0
  42. package/dist/extension/session-view.js +60 -2
  43. package/dist/extension/session-view.js.map +1 -1
  44. package/dist/extension/step-message.d.ts +6 -4
  45. package/dist/extension/step-message.js +12 -6
  46. package/dist/extension/step-message.js.map +1 -1
  47. package/dist/extension/widget.d.ts +2 -2
  48. package/dist/extension/widget.js +28 -18
  49. package/dist/extension/widget.js.map +1 -1
  50. package/dist/extension/workflow-message-coordinator.d.ts +28 -0
  51. package/dist/extension/workflow-message-coordinator.js +346 -0
  52. package/dist/extension/workflow-message-coordinator.js.map +1 -0
  53. package/dist/host/channel-effects.d.ts +72 -0
  54. package/dist/host/channel-effects.js +271 -0
  55. package/dist/host/channel-effects.js.map +1 -0
  56. package/dist/host/channel-supervisor.d.ts +21 -0
  57. package/dist/host/channel-supervisor.js +54 -0
  58. package/dist/host/channel-supervisor.js.map +1 -0
  59. package/dist/host/runner.d.ts +50 -8
  60. package/dist/host/runner.js +1632 -341
  61. package/dist/host/runner.js.map +1 -1
  62. package/dist/host/state.d.ts +19 -16
  63. package/dist/host/state.js +251 -63
  64. package/dist/host/state.js.map +1 -1
  65. package/dist/host/view.d.ts +19 -9
  66. package/dist/host/view.js +227 -119
  67. package/dist/host/view.js.map +1 -1
  68. package/dist/host/worker-entry.d.ts +4 -1
  69. package/dist/host/worker-entry.js +23 -24
  70. package/dist/host/worker-entry.js.map +1 -1
  71. package/dist/host/worker-protocol.d.ts +15 -0
  72. package/dist/host/worker-protocol.js.map +1 -1
  73. package/dist/state/prune.js +3 -25
  74. package/dist/state/prune.js.map +1 -1
  75. package/dist/state/schema.js +76 -97
  76. package/dist/state/schema.js.map +1 -1
  77. package/dist/state/workflow-messages.d.ts +98 -0
  78. package/dist/state/workflow-messages.js +366 -0
  79. package/dist/state/workflow-messages.js.map +1 -0
  80. package/dist/viewer/render.js +1 -1
  81. package/dist/viewer/render.js.map +1 -1
  82. package/dist/viewer/tui.d.ts +1 -1
  83. package/dist/viewer/tui.js +65 -8
  84. package/dist/viewer/tui.js.map +1 -1
  85. package/dist/workflows/composition.js +0 -4
  86. package/dist/workflows/composition.js.map +1 -1
  87. package/dist/workflows/definition.js +0 -8
  88. package/dist/workflows/definition.js.map +1 -1
  89. package/dist/workflows/engine.js +4 -5
  90. package/dist/workflows/engine.js.map +1 -1
  91. package/dist/workflows/human-decision.d.ts +2 -0
  92. package/dist/workflows/human-decision.js +8 -2
  93. package/dist/workflows/human-decision.js.map +1 -1
  94. package/dist/workflows/index.d.ts +1 -1
  95. package/dist/workflows/index.js.map +1 -1
  96. package/dist/workflows/schema.js +0 -4
  97. package/dist/workflows/schema.js.map +1 -1
  98. package/dist/workflows/settings.d.ts +1 -8
  99. package/dist/workflows/settings.js.map +1 -1
  100. package/dist/workflows/store.d.ts +20 -19
  101. package/dist/workflows/store.js +163 -354
  102. package/dist/workflows/store.js.map +1 -1
  103. package/dist/workflows/tool-input.d.ts +21 -0
  104. package/dist/workflows/tool-input.js +23 -1
  105. package/dist/workflows/tool-input.js.map +1 -1
  106. package/dist/workflows/types.d.ts +6 -8
  107. package/dist/workflows/workflow-message-content.d.ts +38 -0
  108. package/dist/workflows/workflow-message-content.js +157 -0
  109. package/dist/workflows/workflow-message-content.js.map +1 -0
  110. package/docs/2026-08-18-herdr-piw-plan.md +2 -1
  111. package/docs/2026-08-20-durable-workflow-launch-plan.md +1 -1
  112. package/docs/2026-08-25-workflow-follow-ups.md +26 -50
  113. package/docs/2026-08-30-out-of-process-workflow-host-plan.md +3 -3
  114. package/docs/2026-09-01-restore-session-delivery-controls-plan.md +4 -0
  115. package/docs/2026-09-01-unified-workflow-client-plan.md +5 -1
  116. package/docs/2026-09-02-installed-live-e2e-plan.md +15 -1
  117. package/docs/2026-09-02-unify-workflow-messages-plan.md +562 -0
  118. package/docs/2026-09-04-workflow-run-state-plan.md +363 -0
  119. package/docs/DEFERRED_TURNS.md +66 -294
  120. package/docs/HUMAN_DECISIONS.md +29 -30
  121. package/docs/HUMAN_DECISION_PRESENTATIONS.md +4 -11
  122. package/docs/SQLITE_STATE.md +25 -17
  123. package/docs/WORKFLOW_HOST.md +105 -31
  124. package/docs/WORKFLOW_STEP_MESSAGES.md +166 -75
  125. package/docs/live-replay-protocol.md +8 -4
  126. package/docs/tui-viewer.md +3 -1
  127. package/docs/workflows.md +44 -28
  128. package/herdr-plugin.toml +1 -1
  129. package/package.json +1 -1
  130. package/plugins/herdr/viewer.mjs +14 -5
  131. package/src/channels/adapter-entry.ts +220 -0
  132. package/src/channels/config.ts +296 -0
  133. package/src/channels/protocol.ts +333 -0
  134. package/src/channels/telegram.ts +335 -0
  135. package/src/client/client.ts +6 -2
  136. package/src/client/index.ts +2 -1
  137. package/src/client/protocol.ts +11 -5
  138. package/src/client/view.ts +51 -18
  139. package/src/controllers/index.ts +0 -1
  140. package/src/controllers/sqlite.ts +247 -845
  141. package/src/extension/index.ts +712 -647
  142. package/src/extension/recorder.ts +687 -0
  143. package/src/extension/remote-recorder-store.ts +126 -0
  144. package/src/extension/session-events.ts +119 -0
  145. package/src/extension/session-view.ts +86 -3
  146. package/src/extension/step-message.ts +19 -9
  147. package/src/extension/widget.ts +37 -17
  148. package/src/extension/workflow-message-coordinator.ts +398 -0
  149. package/src/host/channel-effects.ts +465 -0
  150. package/src/host/channel-supervisor.ts +73 -0
  151. package/src/host/runner.ts +2163 -525
  152. package/src/host/state.ts +333 -94
  153. package/src/host/view.ts +254 -122
  154. package/src/host/worker-entry.ts +38 -30
  155. package/src/host/worker-protocol.ts +11 -0
  156. package/src/state/prune.ts +3 -31
  157. package/src/state/schema.ts +76 -97
  158. package/src/state/workflow-messages.ts +599 -0
  159. package/src/viewer/render.ts +1 -5
  160. package/src/viewer/tui.ts +71 -6
  161. package/src/workflows/composition.ts +0 -5
  162. package/src/workflows/definition.ts +0 -8
  163. package/src/workflows/engine.ts +4 -6
  164. package/src/workflows/human-decision.ts +18 -2
  165. package/src/workflows/index.ts +0 -1
  166. package/src/workflows/schema.ts +0 -6
  167. package/src/workflows/settings.ts +1 -20
  168. package/src/workflows/store.ts +237 -489
  169. package/src/workflows/tool-input.ts +36 -1
  170. package/src/workflows/types.ts +6 -8
  171. package/src/workflows/workflow-message-content.ts +197 -0
  172. package/dist/extension/decision-channels.d.ts +0 -134
  173. package/dist/extension/decision-channels.js +0 -1307
  174. package/dist/extension/decision-channels.js.map +0 -1
  175. package/src/extension/decision-channels.ts +0 -1826
@@ -12,12 +12,14 @@ if (!/^[A-Za-z0-9]+:p[A-Za-z0-9]+$/u.test(paneId)) {
12
12
  fail("HERDR_PANE_ID is missing or invalid.");
13
13
  }
14
14
 
15
- const herdr = process.env.HERDR_BIN_PATH ?? "herdr";
15
+ const configuredHerdr = process.env.HERDR_BIN_PATH ?? "herdr";
16
16
  const label = `piw · ${runId}`;
17
- const labeled = spawnSync(herdr, ["pane", "rename", paneId, label], {
18
- encoding: "utf8",
19
- stdio: ["ignore", "ignore", "pipe"],
20
- });
17
+ let herdr = configuredHerdr;
18
+ let labeled = renamePane(herdr, paneId, label);
19
+ if (labeled.error?.code === "ENOENT" && herdr !== "herdr") {
20
+ herdr = "herdr";
21
+ labeled = renamePane(herdr, paneId, label);
22
+ }
21
23
  if (labeled.error) fail(`Could not label the Herdr viewer pane: ${labeled.error.message}`);
22
24
  if (labeled.status !== 0) {
23
25
  fail(`Could not label the Herdr viewer pane: ${bounded(labeled.stderr) || "unknown error"}`);
@@ -32,6 +34,13 @@ viewer.on("exit", (code, signal) => {
32
34
  process.exitCode = signal ? 1 : (code ?? 1);
33
35
  });
34
36
 
37
+ function renamePane(command, targetPaneId, targetLabel) {
38
+ return spawnSync(command, ["pane", "rename", targetPaneId, targetLabel], {
39
+ encoding: "utf8",
40
+ stdio: ["ignore", "ignore", "pipe"],
41
+ });
42
+ }
43
+
35
44
  function fail(message) {
36
45
  process.stderr.write(`${message}\n`);
37
46
  process.exit(1);
@@ -0,0 +1,220 @@
1
+ import { createInterface } from "node:readline";
2
+ import { fileURLToPath } from "node:url";
3
+ import { canonicalJson } from "../state/json.js";
4
+ import { errorMessage } from "../workflows/errors.js";
5
+ import {
6
+ CHANNEL_ADAPTER_PROTOCOL_SCHEMA,
7
+ channelStableMessageId,
8
+ encodeChannelLine,
9
+ parseChannelAdapterLaunch,
10
+ parseChannelAdapterResponse,
11
+ type ChannelAdapterLaunch,
12
+ type ChannelAdapterMessage,
13
+ type ChannelAdapterResponse,
14
+ } from "./protocol.js";
15
+ import { TelegramAdapter } from "./telegram.js";
16
+
17
+ type ChannelAdapterReport = ChannelAdapterMessage extends infer Message
18
+ ? Message extends ChannelAdapterMessage
19
+ ? Omit<Message, "schema" | "adapterEpoch" | "profile" | "sequence" | "expectedRevision">
20
+ : never
21
+ : never;
22
+
23
+ class AdapterHostConnection {
24
+ private sequence = 0;
25
+ private revision = 0;
26
+ private readonly lines = createInterface({ input: process.stdin, crlfDelay: Infinity })[
27
+ Symbol.asyncIterator
28
+ ]();
29
+
30
+ constructor(private readonly launch: ChannelAdapterLaunch) {}
31
+
32
+ async report(message: ChannelAdapterReport): Promise<ChannelAdapterResponse> {
33
+ this.sequence += 1;
34
+ const full = {
35
+ schema: CHANNEL_ADAPTER_PROTOCOL_SCHEMA,
36
+ adapterEpoch: this.launch.adapterEpoch,
37
+ profile: this.launch.profile,
38
+ sequence: this.sequence,
39
+ expectedRevision: this.revision,
40
+ ...message,
41
+ } as ChannelAdapterMessage;
42
+ if (!process.stdout.write(encodeChannelLine(full))) {
43
+ await new Promise<void>((resolve) => process.stdout.once("drain", resolve));
44
+ }
45
+ const next = await this.lines.next();
46
+ if (next.done) throw new Error("Workflow host closed the channel adapter connection");
47
+ const response = parseChannelAdapterResponse(Buffer.from(next.value, "utf8"));
48
+ if (response.sequence !== this.sequence) {
49
+ throw new Error("Workflow host returned a mismatched channel response");
50
+ }
51
+ if (response.outcome !== "accepted") {
52
+ throw new Error(response.error ?? "Workflow host rejected the channel adapter message");
53
+ }
54
+ this.revision = response.revision;
55
+ return response;
56
+ }
57
+ }
58
+
59
+ export async function runChannelAdapter(): Promise<number> {
60
+ const launch = readLaunch();
61
+ const host = new AdapterHostConnection(launch);
62
+ const telegram = new TelegramAdapter({
63
+ profile: launch.profile,
64
+ token: launch.token,
65
+ allowedUserIds: launch.allowedUserIds,
66
+ allowedChatIds: launch.allowedChatIds,
67
+ ...(launch.apiBase === undefined ? {} : { apiBase: launch.apiBase }),
68
+ });
69
+ let cursor = 0;
70
+ let controlSequence = 0;
71
+ const controlId = (kind: "ready" | "exiting") => {
72
+ controlSequence += 1;
73
+ return channelStableMessageId([
74
+ launch.adapterEpoch,
75
+ kind,
76
+ String(controlSequence),
77
+ String(cursor),
78
+ ]);
79
+ };
80
+ let stopping = false;
81
+ process.once("SIGTERM", () => {
82
+ stopping = true;
83
+ });
84
+ process.once("SIGINT", () => {
85
+ stopping = true;
86
+ });
87
+
88
+ let response = await host.report({
89
+ kind: "channel.ready",
90
+ stableMessageId: controlId("ready"),
91
+ cursor,
92
+ });
93
+ while (!stopping) {
94
+ const command = response.command;
95
+ if (command === null) {
96
+ await new Promise((resolve) => setTimeout(resolve, 100));
97
+ response = await host.report({
98
+ kind: "channel.ready",
99
+ stableMessageId: controlId("ready"),
100
+ cursor,
101
+ });
102
+ continue;
103
+ }
104
+ if (command.kind === "channel.stop") {
105
+ stopping = true;
106
+ break;
107
+ }
108
+ if (command.kind === "channel.present") {
109
+ try {
110
+ const messages = await telegram.present(command.request);
111
+ response = await host.report({
112
+ kind: "channel.present",
113
+ stableMessageId: command.stableMessageId,
114
+ decisionId: command.request.decisionId,
115
+ requestDigest: command.request.requestDigest,
116
+ attemptId: command.attemptId,
117
+ state: "confirmed",
118
+ messages,
119
+ });
120
+ } catch (error) {
121
+ response = await host.report({
122
+ kind: "channel.present",
123
+ stableMessageId: command.stableMessageId,
124
+ decisionId: command.request.decisionId,
125
+ requestDigest: command.request.requestDigest,
126
+ attemptId: command.attemptId,
127
+ state: "unknown",
128
+ messages: [],
129
+ errorCode: errorMessage(error),
130
+ });
131
+ }
132
+ continue;
133
+ }
134
+ if (command.kind === "channel.settle") {
135
+ try {
136
+ await telegram.settle(command.outcome, command.response, command.messages);
137
+ response = await host.report({
138
+ kind: "channel.settle",
139
+ stableMessageId: command.stableMessageId,
140
+ decisionId: command.request.decisionId,
141
+ requestDigest: command.request.requestDigest,
142
+ attemptId: command.attemptId,
143
+ state: "confirmed",
144
+ });
145
+ } catch (error) {
146
+ response = await host.report({
147
+ kind: "channel.settle",
148
+ stableMessageId: command.stableMessageId,
149
+ decisionId: command.request.decisionId,
150
+ requestDigest: command.request.requestDigest,
151
+ attemptId: command.attemptId,
152
+ state: "unknown",
153
+ errorCode: errorMessage(error),
154
+ });
155
+ }
156
+ continue;
157
+ }
158
+
159
+ telegram.setRequests(command.requests);
160
+ const polled = await telegram.poll(command.cursor);
161
+ cursor = polled.cursor;
162
+ for (const answer of polled.answers) {
163
+ await host.report({
164
+ kind: "channel.answer",
165
+ stableMessageId: channelStableMessageId([
166
+ launch.profile,
167
+ answer.request.decisionId,
168
+ answer.eventId,
169
+ ]),
170
+ decisionId: answer.request.decisionId,
171
+ requestDigest: answer.request.requestDigest,
172
+ response: answer.response,
173
+ actorId: answer.actorId,
174
+ chatId: answer.chatId,
175
+ eventId: answer.eventId,
176
+ idempotencyKey: `telegram:${launch.profile}:${answer.eventId}`,
177
+ cursor,
178
+ });
179
+ }
180
+ response = await host.report({
181
+ kind: "channel.ready",
182
+ stableMessageId: controlId("ready"),
183
+ cursor,
184
+ });
185
+ }
186
+
187
+ await host.report({
188
+ kind: "channel.exiting",
189
+ stableMessageId: controlId("exiting"),
190
+ cursor,
191
+ });
192
+ return 0;
193
+ }
194
+
195
+ function readLaunch(): ChannelAdapterLaunch {
196
+ const encoded = process.env.PI_WORKFLOWS_CHANNEL_LAUNCH;
197
+ if (encoded === undefined) throw new Error("Channel adapter launch envelope is missing");
198
+ let value: unknown;
199
+ try {
200
+ value = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8")) as unknown;
201
+ } catch {
202
+ throw new Error("Channel adapter launch envelope is invalid");
203
+ }
204
+ return parseChannelAdapterLaunch(value);
205
+ }
206
+
207
+ async function main(): Promise<void> {
208
+ try {
209
+ process.exitCode = await runChannelAdapter();
210
+ } catch (error) {
211
+ process.stderr.write(`${errorMessage(error)}\n`);
212
+ process.exitCode = 1;
213
+ }
214
+ }
215
+
216
+ if (process.argv[1] === fileURLToPath(import.meta.url)) void main();
217
+
218
+ export function encodeChannelLaunch(launch: ChannelAdapterLaunch): string {
219
+ return Buffer.from(canonicalJson(launch)).toString("base64url");
220
+ }
@@ -0,0 +1,296 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+
6
+ const DEFAULT_API_BASE = "https://api.telegram.org";
7
+ const NUMERIC_ID = /^-?[0-9]+$/;
8
+ const SIMPLE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/;
9
+
10
+ export type DecisionChannelConfig = {
11
+ schema: "pi-workflows.channels.v1";
12
+ audiences: Record<string, { channels: string[]; accept: "first-valid-answer" }>;
13
+ telegramProfiles?: Record<
14
+ string,
15
+ {
16
+ credential: string;
17
+ allowedUserIds: string[];
18
+ allowedChatIds: string[];
19
+ }
20
+ >;
21
+ };
22
+
23
+ export type DecisionCredentialConfig = {
24
+ schema: "pi-workflows.credentials.v1";
25
+ telegram: Record<string, { tokenFile: string }>;
26
+ };
27
+
28
+ export type LoadedDecisionChannelConfig = {
29
+ channels: DecisionChannelConfig;
30
+ credentials: Record<string, string>;
31
+ configDir: string;
32
+ };
33
+
34
+ export type TelegramFetch = (
35
+ input: string,
36
+ init?: RequestInit,
37
+ ) => Promise<{ ok: boolean; status: number; json(): Promise<unknown> }>;
38
+
39
+ export function decisionConfigDir(env: NodeJS.ProcessEnv = process.env): string {
40
+ return env.PI_WORKFLOWS_CONFIG_DIR ?? path.join(os.homedir(), ".config", "pi-workflows");
41
+ }
42
+
43
+ export function audienceChannels(config: DecisionChannelConfig | null, audience: string): string[] {
44
+ return [...(config?.audiences[audience]?.channels ?? ["pi"])];
45
+ }
46
+
47
+ export async function loadDecisionChannelConfig(
48
+ configDir = decisionConfigDir(),
49
+ ): Promise<LoadedDecisionChannelConfig | null> {
50
+ const channelPath = path.join(configDir, "channels.json");
51
+ let channelsRaw: unknown;
52
+ try {
53
+ channelsRaw = JSON.parse(await fs.readFile(channelPath, "utf8")) as unknown;
54
+ } catch (error) {
55
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
56
+ throw error;
57
+ }
58
+ await requirePrivateFile(channelPath, "pi-workflows channel profile");
59
+ const channels = parseChannelConfig(channelsRaw);
60
+ const profiles = Object.values(channels.telegramProfiles ?? {});
61
+ if (profiles.length === 0) return { channels, credentials: {}, configDir };
62
+
63
+ const credentialPath = path.join(configDir, "credentials.json");
64
+ await requirePrivateFile(credentialPath, "pi-workflows credential profile");
65
+ const credentialsConfig = parseCredentialConfig(
66
+ JSON.parse(await fs.readFile(credentialPath, "utf8")) as unknown,
67
+ );
68
+ const credentials: Record<string, string> = {};
69
+ for (const profile of profiles) {
70
+ const credential = credentialsConfig.telegram[profile.credential];
71
+ if (credential === undefined) {
72
+ throw new Error(`Telegram credential ${profile.credential} is not configured`);
73
+ }
74
+ if (!path.isAbsolute(credential.tokenFile)) {
75
+ throw new Error(`Telegram credential ${profile.credential} tokenFile must be absolute`);
76
+ }
77
+ await requirePrivateFile(
78
+ credential.tokenFile,
79
+ `Telegram credential ${profile.credential} token file`,
80
+ );
81
+ const token = (await fs.readFile(credential.tokenFile, "utf8")).trim();
82
+ if (token.length === 0) {
83
+ throw new Error(`Telegram credential ${profile.credential} token file is empty`);
84
+ }
85
+ credentials[profile.credential] = token;
86
+ }
87
+ return { channels, credentials, configDir };
88
+ }
89
+
90
+ export async function verifyTelegramTokenFile(
91
+ tokenFile: string,
92
+ fetchFn?: TelegramFetch,
93
+ apiBase = DEFAULT_API_BASE,
94
+ ): Promise<void> {
95
+ const request = fetchFn ?? (fetch as TelegramFetch);
96
+ if (!path.isAbsolute(tokenFile)) throw new Error("Telegram token file path must be absolute");
97
+ await requirePrivateFile(tokenFile, "Telegram token file");
98
+ const token = (await fs.readFile(tokenFile, "utf8")).trim();
99
+ if (token.length === 0) throw new Error("Telegram token file is empty");
100
+ let response: Awaited<ReturnType<TelegramFetch>>;
101
+ try {
102
+ response = await request(`${apiBase}/bot${encodeURIComponent(token)}/getMe`, {
103
+ method: "POST",
104
+ headers: { "content-type": "application/json" },
105
+ body: "{}",
106
+ });
107
+ } catch {
108
+ throw new Error("Telegram credential verification did not return a response");
109
+ }
110
+ if (!response.ok) {
111
+ throw new Error(`Telegram credential verification failed with HTTP ${response.status}`);
112
+ }
113
+ const body = requireRecord(await response.json(), "Telegram credential verification response");
114
+ if (body.ok !== true) throw new Error("Telegram credential verification was rejected");
115
+ }
116
+
117
+ export async function writeDecisionChannelProfile(options: {
118
+ configDir?: string;
119
+ audience: string;
120
+ profile: string;
121
+ credential: string;
122
+ tokenFile: string;
123
+ allowedUserIds: string[];
124
+ allowedChatIds: string[];
125
+ }): Promise<void> {
126
+ const configDir = options.configDir ?? decisionConfigDir();
127
+ requireSimpleId(options.audience, "Decision audience");
128
+ requireSimpleId(options.profile, "Telegram profile");
129
+ requireSimpleId(options.credential, "Telegram credential");
130
+ validateNumericIds(options.allowedUserIds, "Telegram allowed user IDs");
131
+ validateNumericIds(options.allowedChatIds, "Telegram allowed chat IDs");
132
+ if (!path.isAbsolute(options.tokenFile)) {
133
+ throw new Error("Telegram token file path must be absolute");
134
+ }
135
+ await requirePrivateFile(options.tokenFile, "Telegram token file");
136
+ await fs.mkdir(configDir, { recursive: true, mode: 0o700 });
137
+
138
+ const channelPath = path.join(configDir, "channels.json");
139
+ const credentialPath = path.join(configDir, "credentials.json");
140
+ const existingChannels = await readOptionalJson(channelPath);
141
+ const existingCredentials = await readOptionalJson(credentialPath);
142
+ const channels =
143
+ existingChannels === null
144
+ ? ({ schema: "pi-workflows.channels.v1", audiences: {} } satisfies DecisionChannelConfig)
145
+ : parseChannelConfig(existingChannels);
146
+ const credentials =
147
+ existingCredentials === null
148
+ ? ({ schema: "pi-workflows.credentials.v1", telegram: {} } satisfies DecisionCredentialConfig)
149
+ : parseCredentialConfig(existingCredentials);
150
+
151
+ channels.telegramProfiles = {
152
+ ...channels.telegramProfiles,
153
+ [options.profile]: {
154
+ credential: options.credential,
155
+ allowedUserIds: [...options.allowedUserIds],
156
+ allowedChatIds: [...options.allowedChatIds],
157
+ },
158
+ };
159
+ const priorAudience = channels.audiences[options.audience];
160
+ const channelId = `telegram:${options.profile}`;
161
+ channels.audiences[options.audience] = {
162
+ channels: [...new Set([...(priorAudience?.channels ?? ["pi"]), channelId])],
163
+ accept: "first-valid-answer",
164
+ };
165
+ credentials.telegram[options.credential] = { tokenFile: options.tokenFile };
166
+ await writePrivateJson(channelPath, channels);
167
+ await writePrivateJson(credentialPath, credentials);
168
+ }
169
+
170
+ export function parseChannelConfig(value: unknown): DecisionChannelConfig {
171
+ const input = requireRecord(value, "Decision channel configuration");
172
+ if (input.schema !== "pi-workflows.channels.v1") {
173
+ throw new Error("Decision channel configuration schema is invalid");
174
+ }
175
+ const audiencesInput = requireRecord(input.audiences, "Decision channel audiences");
176
+ const audiences: DecisionChannelConfig["audiences"] = {};
177
+ for (const [audience, raw] of Object.entries(audiencesInput)) {
178
+ requireSimpleId(audience, "Decision audience");
179
+ const record = requireRecord(raw, `Decision audience ${audience}`);
180
+ const channels = stringArray(record.channels, `Decision audience ${audience} channels`);
181
+ if (channels.length === 0) throw new Error(`Decision audience ${audience} has no channels`);
182
+ if (record.accept !== "first-valid-answer") {
183
+ throw new Error(`Decision audience ${audience} accept policy is invalid`);
184
+ }
185
+ audiences[audience] = { channels, accept: "first-valid-answer" };
186
+ }
187
+ const telegramProfiles: NonNullable<DecisionChannelConfig["telegramProfiles"]> = {};
188
+ if (input.telegramProfiles !== undefined) {
189
+ const profiles = requireRecord(input.telegramProfiles, "Telegram profiles");
190
+ for (const [name, raw] of Object.entries(profiles)) {
191
+ requireSimpleId(name, "Telegram profile");
192
+ const profile = requireRecord(raw, `Telegram profile ${name}`);
193
+ const credential = requireSimpleId(profile.credential, `Telegram profile ${name} credential`);
194
+ const allowedUserIds = stringArray(
195
+ profile.allowedUserIds,
196
+ `Telegram profile ${name} allowedUserIds`,
197
+ );
198
+ const allowedChatIds = stringArray(
199
+ profile.allowedChatIds,
200
+ `Telegram profile ${name} allowedChatIds`,
201
+ );
202
+ validateNumericIds(allowedUserIds, `Telegram profile ${name} allowedUserIds`);
203
+ validateNumericIds(allowedChatIds, `Telegram profile ${name} allowedChatIds`);
204
+ if (allowedUserIds.length === 0 || allowedChatIds.length === 0) {
205
+ throw new Error(`Telegram profile ${name} must allow at least one user and chat`);
206
+ }
207
+ telegramProfiles[name] = { credential, allowedUserIds, allowedChatIds };
208
+ }
209
+ }
210
+ for (const [audience, rule] of Object.entries(audiences)) {
211
+ for (const channel of rule.channels) {
212
+ if (channel === "pi") continue;
213
+ if (
214
+ !channel.startsWith("telegram:") ||
215
+ telegramProfiles[channel.slice("telegram:".length)] === undefined
216
+ ) {
217
+ throw new Error(`Decision audience ${audience} references unknown channel ${channel}`);
218
+ }
219
+ }
220
+ }
221
+ return {
222
+ schema: "pi-workflows.channels.v1",
223
+ audiences,
224
+ ...(Object.keys(telegramProfiles).length === 0 ? {} : { telegramProfiles }),
225
+ };
226
+ }
227
+
228
+ export function parseCredentialConfig(value: unknown): DecisionCredentialConfig {
229
+ const input = requireRecord(value, "Decision credential configuration");
230
+ if (input.schema !== "pi-workflows.credentials.v1") {
231
+ throw new Error("Decision credential configuration schema is invalid");
232
+ }
233
+ const telegramInput = requireRecord(input.telegram, "Telegram credentials");
234
+ const telegram: DecisionCredentialConfig["telegram"] = {};
235
+ for (const [name, raw] of Object.entries(telegramInput)) {
236
+ requireSimpleId(name, "Telegram credential");
237
+ const credential = requireRecord(raw, `Telegram credential ${name}`);
238
+ telegram[name] = { tokenFile: requireString(credential.tokenFile, "Telegram tokenFile") };
239
+ }
240
+ return { schema: "pi-workflows.credentials.v1", telegram };
241
+ }
242
+
243
+ async function requirePrivateFile(filePath: string, label: string): Promise<void> {
244
+ const stat = await fs.stat(filePath);
245
+ if (!stat.isFile()) throw new Error(`${label} must be a file`);
246
+ if ((stat.mode & 0o077) !== 0) throw new Error(`${label} permissions must be 0600`);
247
+ }
248
+
249
+ async function readOptionalJson(filePath: string): Promise<unknown | null> {
250
+ try {
251
+ return JSON.parse(await fs.readFile(filePath, "utf8")) as unknown;
252
+ } catch (error) {
253
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
254
+ throw error;
255
+ }
256
+ }
257
+
258
+ async function writePrivateJson(filePath: string, value: unknown): Promise<void> {
259
+ const temporary = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
260
+ await fs.writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
261
+ await fs.rename(temporary, filePath);
262
+ await fs.chmod(filePath, 0o600);
263
+ }
264
+
265
+ function validateNumericIds(values: readonly string[], label: string): void {
266
+ if (values.some((value) => !NUMERIC_ID.test(value))) {
267
+ throw new Error(`${label} must contain only numeric IDs`);
268
+ }
269
+ }
270
+
271
+ function requireSimpleId(value: unknown, label: string): string {
272
+ const text = requireString(value, label);
273
+ if (!SIMPLE_ID.test(text)) throw new Error(`${label} is invalid`);
274
+ return text;
275
+ }
276
+
277
+ function requireString(value: unknown, label: string): string {
278
+ if (typeof value !== "string" || value.trim().length === 0) {
279
+ throw new Error(`${label} must be a non-empty string`);
280
+ }
281
+ return value.trim();
282
+ }
283
+
284
+ function stringArray(value: unknown, label: string): string[] {
285
+ if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) {
286
+ throw new Error(`${label} must be an array of strings`);
287
+ }
288
+ return [...value] as string[];
289
+ }
290
+
291
+ function requireRecord(value: unknown, label: string): Record<string, unknown> {
292
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
293
+ throw new Error(`${label} must be an object`);
294
+ }
295
+ return value as Record<string, unknown>;
296
+ }