@hyperfixation/workflows 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,208 @@
1
+ import { DBOS, DBOSClient } from "@dbos-inc/dbos-sdk";
2
+ import { appPaused, createStepPool, runBootChecks, } from "@hyperfixation/db";
3
+ import { createControlPool } from "./control-pool.js";
4
+ import { registerLangfuse } from "./langfuse.js";
5
+ import { setPausedQueueConcurrency } from "./queue-concurrency.js";
6
+ import { reconcile, startReconciler } from "./reconcile.js";
7
+ import { acquireWorkerLock } from "./worker-lock.js";
8
+ import { setWorkerRuntime } from "./worker-runtime.js";
9
+ /** The value of `HF_PROCESS` in the one process shape allowed to launch DBOS. */
10
+ export const WORKER_PROCESS = "worker";
11
+ /** Enough of a commit sha to be a version; `hf dev` sets `dev-<timestamp>`. */
12
+ export const MIN_BUILD_SHA_LENGTH = 7;
13
+ export const SYSTEM_DATABASE_SCHEMA = "dbos";
14
+ export const SYSTEM_DATABASE_POOL_SIZE = 5;
15
+ /**
16
+ * The reconciler's own client. Deliberately not `getClient()`: that singleton is the web's, and
17
+ * calling it here would run the boot checks a second time and import this module back.
18
+ */
19
+ export const RECONCILER_POOL_SIZE = 2;
20
+ /**
21
+ * The three queues, by name and concurrency. There is no flow registry yet; when there is,
22
+ * `defineFlow` names one of these and nothing else may.
23
+ */
24
+ export const QUEUES = [
25
+ { name: "llm", globalConcurrency: 4 },
26
+ { name: "actions", globalConcurrency: 2 },
27
+ { name: "resolve", globalConcurrency: 1 },
28
+ ];
29
+ /**
30
+ * Logged on either side of the one `DBOS.launch()` call in the system. Redeploy case 6
31
+ * asserts the first never appears in a second worker's output.
32
+ */
33
+ export const LAUNCHING_MARKER = "hf-worker: calling DBOS.launch";
34
+ export const LAUNCHED_MARKER = "hf-worker: DBOS launched";
35
+ /** One per SIGTERM the handler acts on; redeploy case 11 counts them. */
36
+ export const SHUTDOWN_MARKER = "hf-worker: SIGTERM, calling DBOS.shutdown";
37
+ export const SHUTDOWN_IGNORED_MARKER = "hf-worker: SIGTERM ignored, already draining";
38
+ export const SHUTDOWN_FAILED_MARKER = "hf-worker: DBOS.shutdown rejected";
39
+ /** How long the drain waits for workflows running here before it abandons them. */
40
+ export const DRAIN_TIMEOUT_MS = 60_000;
41
+ /**
42
+ * The handler's own bound, past the drain. Compose's `stop_grace_period: 90s` SIGKILL is the
43
+ * line after this one, and the advisory lock is released by neither before the process dies.
44
+ */
45
+ export const SHUTDOWN_WATCHDOG_MS = 75_000;
46
+ export class NotAWorkerProcess extends Error {
47
+ hfProcess;
48
+ constructor(hfProcess) {
49
+ super(`NotAWorkerProcess: startWorker() needs HF_PROCESS=${WORKER_PROCESS}, got ` +
50
+ `${hfProcess === undefined ? "an unset HF_PROCESS" : JSON.stringify(hfProcess)}`);
51
+ this.name = "NotAWorkerProcess";
52
+ this.hfProcess = hfProcess;
53
+ }
54
+ }
55
+ export class MissingBuildSha extends Error {
56
+ constructor(buildSha) {
57
+ super(`MissingBuildSha: HF_BUILD_SHA must be at least ${MIN_BUILD_SHA_LENGTH} characters, got ` +
58
+ `${buildSha === undefined ? "an unset HF_BUILD_SHA" : JSON.stringify(buildSha)}`);
59
+ this.name = "MissingBuildSha";
60
+ }
61
+ }
62
+ /** Set when the keys are present; the SIGTERM handler flushes through it. */
63
+ let langfuse;
64
+ /**
65
+ * The only place `DBOS.launch()` runs. Boot checks, then the two pools, then the advisory
66
+ * lock, then launch — a worker that cannot prove it is alone never reaches the launch.
67
+ */
68
+ export async function startWorker(options) {
69
+ if (process.env.HF_PROCESS !== WORKER_PROCESS) {
70
+ throw new NotAWorkerProcess(process.env.HF_PROCESS);
71
+ }
72
+ // Ahead of the boot checks only because it opens no connection — E001-E006 are still the
73
+ // first statements this process issues. A missing version is a build misconfiguration, and
74
+ // failing on it before taking a cluster-wide lock keeps the failure cheap.
75
+ const applicationVersion = requireBuildSha();
76
+ await runBootChecks({
77
+ databaseUrl: options.databaseUrl,
78
+ recordTables: options.recordTables,
79
+ appMigrationsDir: options.appMigrationsDir,
80
+ });
81
+ const steps = createStepPool({ connectionString: options.databaseUrl });
82
+ const control = createControlPool({ connectionString: options.databaseUrl });
83
+ // Before the lock and launch: DBOS recovery can run a flow the instant `launch()` returns,
84
+ // and a recovered flow reaches `workerRuntime()` the same as a freshly dispatched one.
85
+ setWorkerRuntime({
86
+ appName: options.appName,
87
+ applicationVersion,
88
+ steps,
89
+ control,
90
+ approvalNotifier: options.approvalNotifier,
91
+ });
92
+ let client;
93
+ try {
94
+ // Before `setConfig`, so DBOS finds a registered provider and a working context manager and
95
+ // keeps ours: both globals are first-one-wins.
96
+ langfuse = registerLangfuse();
97
+ const lock = await acquireWorkerLock(options.databaseUrl, options.appName);
98
+ DBOS.setConfig({
99
+ name: options.appName,
100
+ systemDatabaseUrl: options.databaseUrl,
101
+ systemDatabaseSchemaName: SYSTEM_DATABASE_SCHEMA,
102
+ systemDatabasePoolSize: SYSTEM_DATABASE_POOL_SIZE,
103
+ applicationVersion,
104
+ executorID: WORKER_PROCESS,
105
+ enablePatching: false,
106
+ runAdminServer: false,
107
+ maxConcurrentQueueDispatches: 1,
108
+ runMigrations: false,
109
+ // Without a Langfuse destination DBOS's spans are stubs, which is the cheaper default.
110
+ tracingEnabled: langfuse !== undefined,
111
+ });
112
+ // Installed before `launch`, so a SIGTERM arriving during it is this process's to drain.
113
+ process.on("SIGTERM", handleSigterm);
114
+ console.info(LAUNCHING_MARKER, applicationVersion);
115
+ await DBOS.launch();
116
+ console.info(LAUNCHED_MARKER, applicationVersion);
117
+ // `registerQueue` writes the queue's row through the system database, so it can only run
118
+ // once DBOS is launched.
119
+ const queues = {};
120
+ for (const queue of QUEUES) {
121
+ queues[queue.name] = await DBOS.registerQueue(queue.name, {
122
+ globalConcurrency: queue.globalConcurrency,
123
+ });
124
+ }
125
+ client = await DBOSClient.create({
126
+ systemDatabaseUrl: options.databaseUrl,
127
+ systemDatabaseSchemaName: SYSTEM_DATABASE_SCHEMA,
128
+ systemDatabasePoolSize: RECONCILER_POOL_SIZE,
129
+ applicationName: options.appName,
130
+ });
131
+ // `registerQueue` above re-wrote each queue's row at its registered concurrency, which on a
132
+ // deploy into a paused app would undo `pause`'s queue half and let the new worker start
133
+ // dispatching. The pause flag is still what makes it correct — every dispatched step
134
+ // suspends at the gate — but a paused app should not be dequeuing at all.
135
+ if (await appPaused(control.pool))
136
+ await setPausedQueueConcurrency(client, true);
137
+ // Once before the worker is ready, so a redeploy's backlog is moved onto this version
138
+ // before anything else is dispatched, and every minute after. A failure here fails the
139
+ // boot: a worker that cannot reconcile is a worker the previous version's runs are
140
+ // stranded behind.
141
+ await reconcile(control.pool, client, { applicationVersion });
142
+ const reconciler = startReconciler(control.pool, client, { applicationVersion });
143
+ return {
144
+ appName: options.appName,
145
+ applicationVersion,
146
+ steps,
147
+ control,
148
+ lock,
149
+ queues,
150
+ client,
151
+ reconciler,
152
+ };
153
+ }
154
+ catch (error) {
155
+ // The lock connection is not closed here either: if it was taken, the lock belongs to
156
+ // this process until it dies, whatever went wrong afterwards.
157
+ await client?.destroy().catch(() => undefined);
158
+ await steps.end().catch(() => undefined);
159
+ await control.end().catch(() => undefined);
160
+ throw error;
161
+ }
162
+ }
163
+ /**
164
+ * Process-wide rather than per-worker because `DBOS.shutdown()` is static and carries no
165
+ * re-entry guard of its own: a second delivery that reached it ends in node-pg's "Called end
166
+ * on pool more than once" (round-3 finding 9).
167
+ */
168
+ let shuttingDown = false;
169
+ /**
170
+ * Deliberately not `async`. An `await`ed `DBOS.shutdown()` that rejects leaves an unhandled
171
+ * rejection, which Sentry's default listener swallows — the process then sits out the whole
172
+ * watchdog instead of exiting (round-3 finding 10). `.then()` with both arms explicit never
173
+ * produces one.
174
+ *
175
+ * The lock connection is untouched here on purpose: the drain abandons unfinished workflows,
176
+ * so step bodies of this process can still be writing, and process death is the only release
177
+ * of the advisory lock that cannot let the next worker in underneath them.
178
+ */
179
+ function handleSigterm() {
180
+ if (shuttingDown) {
181
+ console.info(SHUTDOWN_IGNORED_MARKER);
182
+ return;
183
+ }
184
+ shuttingDown = true;
185
+ // Armed before anything that could yield, so a `shutdown()` that never settles still ends
186
+ // the process. `unref` so the watchdog is never itself a reason to stay up.
187
+ setTimeout(() => process.exit(1), SHUTDOWN_WATCHDOG_MS).unref();
188
+ console.info(SHUTDOWN_MARKER);
189
+ DBOS.shutdown({ workflowCompletionTimeoutMS: DRAIN_TIMEOUT_MS }).then(() => flushThenExit(0), (error) => {
190
+ console.error(SHUTDOWN_FAILED_MARKER, error);
191
+ flushThenExit(1);
192
+ });
193
+ }
194
+ /**
195
+ * The last span batch would otherwise die with the process. Both arms exit with the code the
196
+ * drain earned: a Langfuse flush that fails is not a reason to change it.
197
+ */
198
+ function flushThenExit(code) {
199
+ const flushed = langfuse?.shutdown() ?? Promise.resolve();
200
+ flushed.then(() => process.exit(code), () => process.exit(code));
201
+ }
202
+ function requireBuildSha() {
203
+ const buildSha = process.env.HF_BUILD_SHA;
204
+ if (buildSha === undefined || buildSha.length < MIN_BUILD_SHA_LENGTH) {
205
+ throw new MissingBuildSha(buildSha);
206
+ }
207
+ return buildSha;
208
+ }
package/dist/step.d.ts ADDED
@@ -0,0 +1,29 @@
1
+ import { type StepDatabase } from "@hyperfixation/db";
2
+ /** The only database handle a step body is given. */
3
+ export interface StepContext {
4
+ readonly runId: string;
5
+ readonly attempt: number;
6
+ readonly workflowId: string;
7
+ /** The step's own key: what a ledger row or an action log is keyed by. */
8
+ readonly key: string;
9
+ tx<T>(work: (db: StepDatabase) => Promise<T>): Promise<T>;
10
+ }
11
+ export interface StepOptions {
12
+ /** Distinguishes two calls of the same `name`, as a loop over records does. */
13
+ key?: string;
14
+ }
15
+ /** The pause gate and the fencing token, read together so one round trip answers both. */
16
+ export declare const STEP_GATE_STATEMENT: string;
17
+ /**
18
+ * A checkpointed function, preceded by a checkpointed read of the pause flag and the run's
19
+ * fencing token.
20
+ *
21
+ * The gate is a step that returns the *reading* and throws nothing. Were the throw inside it,
22
+ * DBOS would checkpoint the error through `serialize-error` and a replay would revive a plain
23
+ * `Error`, so `defineFlow`'s `instanceof Suspend` would stop catching it after a recovery.
24
+ *
25
+ * Retries are off because a step body here is a database write or a billable provider call:
26
+ * "at least once" is the guarantee the ledger and the `ctx.tx` fence are built to survive,
27
+ * and a silent in-process retry would add attempts neither of them can see.
28
+ */
29
+ export declare function step<T>(name: string, fn: (ctx: StepContext) => Promise<T>, options?: StepOptions): Promise<T>;
package/dist/step.js ADDED
@@ -0,0 +1,54 @@
1
+ import { DBOS } from "@dbos-inc/dbos-sdk";
2
+ import { StaleAttempt } from "@hyperfixation/db";
3
+ import { currentRun } from "./run-context.js";
4
+ import { concludeRun } from "./run-status.js";
5
+ import { Suspend } from "./suspend.js";
6
+ import { workerRuntime } from "./worker-runtime.js";
7
+ /** The pause gate and the fencing token, read together so one round trip answers both. */
8
+ export const STEP_GATE_STATEMENT = "SELECT COALESCE((SELECT paused FROM hf_app_state WHERE id = 1), false) AS paused, " +
9
+ "(SELECT current_workflow_id FROM hf_run WHERE run_id = $1) AS current_workflow_id";
10
+ /**
11
+ * A checkpointed function, preceded by a checkpointed read of the pause flag and the run's
12
+ * fencing token.
13
+ *
14
+ * The gate is a step that returns the *reading* and throws nothing. Were the throw inside it,
15
+ * DBOS would checkpoint the error through `serialize-error` and a replay would revive a plain
16
+ * `Error`, so `defineFlow`'s `instanceof Suspend` would stop catching it after a recovery.
17
+ *
18
+ * Retries are off because a step body here is a database write or a billable provider call:
19
+ * "at least once" is the guarantee the ledger and the `ctx.tx` fence are built to survive,
20
+ * and a silent in-process retry would add attempts neither of them can see.
21
+ */
22
+ export async function step(name, fn, options = {}) {
23
+ const run = currentRun(`step(${name})`);
24
+ const runtime = workerRuntime(`step(${name})`);
25
+ const key = options.key ?? name;
26
+ const gate = await DBOS.runStep(() => readGate(runtime, run.runId), {
27
+ name: `${name}:gate`,
28
+ retriesAllowed: false,
29
+ });
30
+ if (gate.paused) {
31
+ await concludeRun(runtime.control.pool, run.runId, run.workflowId, "paused", null);
32
+ throw new Suspend(run.runId, "paused", `the app is paused, before step ${key}`);
33
+ }
34
+ if (gate.currentWorkflowId !== run.workflowId) {
35
+ throw new StaleAttempt(run.runId, run.workflowId);
36
+ }
37
+ const context = {
38
+ runId: run.runId,
39
+ attempt: run.attempt,
40
+ workflowId: run.workflowId,
41
+ key,
42
+ tx: (work) => runtime.steps.tx(run.runId, run.workflowId, work),
43
+ };
44
+ return await DBOS.runStep(() => fn(context), {
45
+ name: key === name ? name : `${name}:${key}`,
46
+ retriesAllowed: false,
47
+ });
48
+ }
49
+ /** A plain read, so the step pool passes it without a tag. */
50
+ async function readGate(runtime, runId) {
51
+ const { rows } = await runtime.steps.pool.query(STEP_GATE_STATEMENT, [runId]);
52
+ const row = rows[0];
53
+ return { paused: row.paused, currentWorkflowId: row.current_workflow_id };
54
+ }
@@ -0,0 +1,17 @@
1
+ /** The two run statuses an attempt can end in without being finished. */
2
+ export declare const SUSPEND_STATUSES: readonly ["waiting", "paused"];
3
+ export type SuspendStatus = (typeof SUSPEND_STATUSES)[number];
4
+ /**
5
+ * Ends the current attempt without failing it: the flow stops here and the run is left for a
6
+ * later attempt to pick up. Thrown by the pause gate in `step()`, and by `waitForApproval`
7
+ * when that lands.
8
+ *
9
+ * It must be thrown from the flow body rather than from inside a `DBOS.runStep` body: a step
10
+ * that throws has its error checkpointed through `serialize-error`, and a replay revives a
11
+ * plain `Error` that no `instanceof Suspend` would catch.
12
+ */
13
+ export declare class Suspend extends Error {
14
+ readonly runId: string;
15
+ readonly status: SuspendStatus;
16
+ constructor(runId: string, status: SuspendStatus, reason: string);
17
+ }
@@ -0,0 +1,21 @@
1
+ /** The two run statuses an attempt can end in without being finished. */
2
+ export const SUSPEND_STATUSES = ["waiting", "paused"];
3
+ /**
4
+ * Ends the current attempt without failing it: the flow stops here and the run is left for a
5
+ * later attempt to pick up. Thrown by the pause gate in `step()`, and by `waitForApproval`
6
+ * when that lands.
7
+ *
8
+ * It must be thrown from the flow body rather than from inside a `DBOS.runStep` body: a step
9
+ * that throws has its error checkpointed through `serialize-error`, and a replay revives a
10
+ * plain `Error` that no `instanceof Suspend` would catch.
11
+ */
12
+ export class Suspend extends Error {
13
+ runId;
14
+ status;
15
+ constructor(runId, status, reason) {
16
+ super(`Suspend: run ${runId} ends this attempt ${status} — ${reason}`);
17
+ this.name = "Suspend";
18
+ this.runId = runId;
19
+ this.status = status;
20
+ }
21
+ }
@@ -0,0 +1,91 @@
1
+ import { type ApprovalDecisionKind, type DecideOptions, type DecideResult } from "./approvals.js";
2
+ /**
3
+ * The callback half of Telegram, and only that half: no bot, no polling, nothing sent. A bot
4
+ * that writes the message carrying these buttons is Phase 6's, along with `hf_telegram_link`
5
+ * and a `TELEGRAM_BOT_TOKEN`; what Phase 2 owns is what happens when a button is pressed.
6
+ */
7
+ /** Telegram's own cap on `callback_data`, in bytes — not characters (Bot API "1-64 bytes"). */
8
+ export declare const CALLBACK_DATA_MAX_BYTES = 64;
9
+ /** Leads every `callback_data` this package writes, so a foreign button is ignored, not decoded. */
10
+ export declare const CALLBACK_DATA_VERSION = "hf1";
11
+ /** What a button can carry. Expiry and cancellation are nobody's button — they are sweeps. */
12
+ export type TelegramDecision = Extract<ApprovalDecisionKind, "approved" | "rejected">;
13
+ export interface TelegramCallbackData {
14
+ approvalId: number;
15
+ decision: TelegramDecision;
16
+ /** Per-message, minted by whoever sent the message; the `decisionKey`'s second half. */
17
+ nonce: string;
18
+ }
19
+ export declare class CallbackDataTooLong extends Error {
20
+ constructor(data: string);
21
+ }
22
+ /**
23
+ * `hf1:<a|r>:<approvalId>:<nonce>`. Fixed-width fields would leave less room for the nonce than
24
+ * the separator does, and the version leads so a button from an older deploy decodes or is
25
+ * ignored rather than being misread.
26
+ */
27
+ export declare function encodeCallbackData(data: TelegramCallbackData): string;
28
+ /** The longest nonce that still fits beside this approval id; how a sender picks its width. */
29
+ export declare function maxNonceLength(approvalId: number): number;
30
+ /** `null` for anything that is not one of ours — nothing here throws on a foreign button. */
31
+ export declare function decodeCallbackData(data: string | undefined): TelegramCallbackData | null;
32
+ /** The approval and the message that offered it, which is what makes a redelivery a replay. */
33
+ export declare function decisionKeyFor(data: TelegramCallbackData): string;
34
+ /** As much of Telegram's `User` as a decision needs; the rest of the object rides along. */
35
+ export interface TelegramCallbackFrom {
36
+ id: number;
37
+ username?: string;
38
+ }
39
+ export interface TelegramCallbackOptions {
40
+ /**
41
+ * `app.approvals.decide` — the one way an approval is decided. Handed in because `workflows`
42
+ * cannot import `core`, and because nothing here owns a control pool or a `DBOSClient`.
43
+ */
44
+ decide(options: DecideOptions): Promise<DecideResult>;
45
+ /**
46
+ * The `X-Telegram-Bot-Api-Secret-Token` pair: what the webhook was registered with, and what
47
+ * this request carried. Both halves together, because a configured secret with nothing to
48
+ * compare it against would be a webhook anybody can post to.
49
+ */
50
+ secret?: {
51
+ expected: string;
52
+ received: string | undefined;
53
+ };
54
+ /**
55
+ * The Telegram user to the app user id written as `decided_by`, which Phase 6's
56
+ * `hf_telegram_link` will answer. Undefined leaves the decision unattributed.
57
+ */
58
+ userFor?: (from: TelegramCallbackFrom) => string | null | undefined;
59
+ /** True when the resolved user holds the admin role — `DecideOptions.admin`'s meaning. */
60
+ admin?: boolean;
61
+ }
62
+ /**
63
+ * `decided` covers the replay too: one delivery or three, the outcome is the decision that was
64
+ * written. `refused` is a callback that named nothing decidable — an unknown approval, a row
65
+ * someone else already decided, a stale nonce. `ignored` is an update this handler has no
66
+ * business with at all.
67
+ */
68
+ export type TelegramCallbackOutcome = "decided" | "refused" | "ignored";
69
+ export interface TelegramCallbackResult {
70
+ outcome: TelegramCallbackOutcome;
71
+ /** `callback_query.id`, which Phase 6's bot answers; null when the update carried none. */
72
+ callbackQueryId: string | null;
73
+ approvalId: number | null;
74
+ decision: TelegramDecision | null;
75
+ decisionKey: string | null;
76
+ /** True when this exact callback had already been decided: two deliveries, one decision. */
77
+ replayed: boolean;
78
+ /** Why nothing was decided; null when something was. */
79
+ reason: string | null;
80
+ }
81
+ /**
82
+ * A Telegram `callback_query` update, decoded and turned into one `decide()` call with
83
+ * `via: 'telegram'` and `decisionKey = <approvalId>:<nonce>`, so a redelivery of the same button
84
+ * press returns the first decision and writes nothing.
85
+ *
86
+ * Every permanent failure comes back as a result: a webhook that answers Telegram with an error
87
+ * is redelivered, and a callback that can never succeed would be redelivered forever. A
88
+ * transient failure — a lost commit, a deadlock, a dead connection — still throws, because that
89
+ * one *should* be retried, and so does a wiring bug.
90
+ */
91
+ export declare function handleTelegramCallback(update: unknown, options: TelegramCallbackOptions): Promise<TelegramCallbackResult>;
@@ -0,0 +1,161 @@
1
+ import { timingSafeEqual } from "node:crypto";
2
+ import * as z from "zod";
3
+ import { ApprovalBatchRefused, } from "./approvals.js";
4
+ /**
5
+ * The callback half of Telegram, and only that half: no bot, no polling, nothing sent. A bot
6
+ * that writes the message carrying these buttons is Phase 6's, along with `hf_telegram_link`
7
+ * and a `TELEGRAM_BOT_TOKEN`; what Phase 2 owns is what happens when a button is pressed.
8
+ */
9
+ /** Telegram's own cap on `callback_data`, in bytes — not characters (Bot API "1-64 bytes"). */
10
+ export const CALLBACK_DATA_MAX_BYTES = 64;
11
+ /** Leads every `callback_data` this package writes, so a foreign button is ignored, not decoded. */
12
+ export const CALLBACK_DATA_VERSION = "hf1";
13
+ /** The nonce's charset: one byte per character, and no `:` to confuse the separator with. */
14
+ const NONCE_PATTERN = /^[A-Za-z0-9_-]+$/;
15
+ /** No leading zeros and no sign, so one approval id has exactly one encoding. */
16
+ const APPROVAL_ID_PATTERN = /^[1-9][0-9]*$/;
17
+ const DECISION_CODES = { approved: "a", rejected: "r" };
18
+ const DECISIONS_BY_CODE = { a: "approved", r: "rejected" };
19
+ export class CallbackDataTooLong extends Error {
20
+ constructor(data) {
21
+ super(`CallbackDataTooLong: ${JSON.stringify(data)} is ${Buffer.byteLength(data)} bytes, over ` +
22
+ `Telegram's ${CALLBACK_DATA_MAX_BYTES}-byte limit on callback_data — shorten the nonce`);
23
+ this.name = "CallbackDataTooLong";
24
+ }
25
+ }
26
+ /**
27
+ * `hf1:<a|r>:<approvalId>:<nonce>`. Fixed-width fields would leave less room for the nonce than
28
+ * the separator does, and the version leads so a button from an older deploy decodes or is
29
+ * ignored rather than being misread.
30
+ */
31
+ export function encodeCallbackData(data) {
32
+ if (!APPROVAL_ID_PATTERN.test(String(data.approvalId))) {
33
+ throw new TypeError(`encodeCallbackData: ${data.approvalId} is not an approval id`);
34
+ }
35
+ if (!NONCE_PATTERN.test(data.nonce)) {
36
+ throw new TypeError(`encodeCallbackData: nonce ${JSON.stringify(data.nonce)} is not [A-Za-z0-9_-]+`);
37
+ }
38
+ const encoded = `${CALLBACK_DATA_VERSION}:${DECISION_CODES[data.decision]}:` +
39
+ `${data.approvalId}:${data.nonce}`;
40
+ if (Buffer.byteLength(encoded) > CALLBACK_DATA_MAX_BYTES)
41
+ throw new CallbackDataTooLong(encoded);
42
+ return encoded;
43
+ }
44
+ /** The longest nonce that still fits beside this approval id; how a sender picks its width. */
45
+ export function maxNonceLength(approvalId) {
46
+ return CALLBACK_DATA_MAX_BYTES - `${CALLBACK_DATA_VERSION}:a:${approvalId}:`.length;
47
+ }
48
+ /** `null` for anything that is not one of ours — nothing here throws on a foreign button. */
49
+ export function decodeCallbackData(data) {
50
+ if (data === undefined)
51
+ return null;
52
+ if (Buffer.byteLength(data) > CALLBACK_DATA_MAX_BYTES)
53
+ return null;
54
+ const parts = data.split(":");
55
+ if (parts.length !== 4)
56
+ return null;
57
+ const [version, code, id, nonce] = parts;
58
+ if (version !== CALLBACK_DATA_VERSION)
59
+ return null;
60
+ if (!Object.hasOwn(DECISIONS_BY_CODE, code))
61
+ return null;
62
+ if (!APPROVAL_ID_PATTERN.test(id))
63
+ return null;
64
+ const approvalId = Number(id);
65
+ if (!Number.isSafeInteger(approvalId))
66
+ return null;
67
+ if (!NONCE_PATTERN.test(nonce))
68
+ return null;
69
+ return {
70
+ approvalId,
71
+ decision: DECISIONS_BY_CODE[code],
72
+ nonce,
73
+ };
74
+ }
75
+ /** The approval and the message that offered it, which is what makes a redelivery a replay. */
76
+ export function decisionKeyFor(data) {
77
+ return `${data.approvalId}:${data.nonce}`;
78
+ }
79
+ const CallbackUpdate = z.looseObject({
80
+ callback_query: z.looseObject({
81
+ id: z.string(),
82
+ data: z.string().optional(),
83
+ from: z.looseObject({ id: z.number(), username: z.string().optional() }),
84
+ }),
85
+ });
86
+ /**
87
+ * A Telegram `callback_query` update, decoded and turned into one `decide()` call with
88
+ * `via: 'telegram'` and `decisionKey = <approvalId>:<nonce>`, so a redelivery of the same button
89
+ * press returns the first decision and writes nothing.
90
+ *
91
+ * Every permanent failure comes back as a result: a webhook that answers Telegram with an error
92
+ * is redelivered, and a callback that can never succeed would be redelivered forever. A
93
+ * transient failure — a lost commit, a deadlock, a dead connection — still throws, because that
94
+ * one *should* be retried, and so does a wiring bug.
95
+ */
96
+ export async function handleTelegramCallback(update, options) {
97
+ // Before the payload is looked at, let alone trusted.
98
+ if (options.secret !== undefined && !secretMatches(options.secret)) {
99
+ return ignored(null, "the request's secret token did not match the webhook's");
100
+ }
101
+ const parsed = CallbackUpdate.safeParse(update);
102
+ if (!parsed.success)
103
+ return ignored(null, "the update carries no callback_query");
104
+ const query = parsed.data.callback_query;
105
+ const data = decodeCallbackData(query.data);
106
+ if (data === null) {
107
+ return ignored(query.id, `callback data ${JSON.stringify(query.data ?? null)} is not ours`);
108
+ }
109
+ const decisionKey = decisionKeyFor(data);
110
+ try {
111
+ const result = await options.decide({
112
+ ids: [data.approvalId],
113
+ decision: data.decision,
114
+ via: "telegram",
115
+ decisionKey,
116
+ userId: options.userFor?.(query.from) ?? null,
117
+ ...(options.admin === undefined ? {} : { admin: options.admin }),
118
+ });
119
+ return {
120
+ outcome: "decided",
121
+ callbackQueryId: query.id,
122
+ approvalId: data.approvalId,
123
+ decision: data.decision,
124
+ decisionKey,
125
+ replayed: result.replayed,
126
+ reason: null,
127
+ };
128
+ }
129
+ catch (error) {
130
+ if (!(error instanceof ApprovalBatchRefused))
131
+ throw error;
132
+ return {
133
+ outcome: "refused",
134
+ callbackQueryId: query.id,
135
+ approvalId: data.approvalId,
136
+ decision: data.decision,
137
+ decisionKey,
138
+ replayed: false,
139
+ reason: error.reasons.map((r) => `${r.approvalId} ${r.reason}`).join("; "),
140
+ };
141
+ }
142
+ }
143
+ function secretMatches(secret) {
144
+ if (secret.received === undefined)
145
+ return false;
146
+ const expected = Buffer.from(secret.expected);
147
+ const received = Buffer.from(secret.received);
148
+ // `timingSafeEqual` throws on a length mismatch, which is not secret anyway.
149
+ return expected.length === received.length && timingSafeEqual(expected, received);
150
+ }
151
+ function ignored(callbackQueryId, reason) {
152
+ return {
153
+ outcome: "ignored",
154
+ callbackQueryId,
155
+ approvalId: null,
156
+ decision: null,
157
+ decisionKey: null,
158
+ replayed: false,
159
+ reason,
160
+ };
161
+ }
@@ -0,0 +1,37 @@
1
+ import { Client } from "pg";
2
+ /**
3
+ * Session-level, so the lock is held by the connection rather than by a transaction, and
4
+ * released only when that connection goes away. Advisory locks are scoped to the database,
5
+ * and the key is namespaced by app name on top of that.
6
+ */
7
+ export declare const WORKER_LOCK_STATEMENT = "SELECT pg_try_advisory_lock(hashtext('hf-worker:' || $1::text)) AS acquired, now() AS acquired_at";
8
+ /**
9
+ * `<marker> <appName> <iso>`, where the timestamp is the *database's*, read in the same
10
+ * statement as the lock. Redeploy case 7 orders it against the previous worker's last
11
+ * committed write, which is a database timestamp too — two process clocks could not be
12
+ * compared at all.
13
+ */
14
+ export declare const LOCK_ACQUIRED_MARKER = "hf-worker: advisory lock acquired";
15
+ export declare class WorkerLockUnavailable extends Error {
16
+ readonly appName: string;
17
+ constructor(appName: string);
18
+ }
19
+ /**
20
+ * `Client` with `end` typed away. The lock must not be free while any step body of this
21
+ * process can still write — `DBOS.shutdown()`'s drain abandons unfinished workflows, so the
22
+ * only safe release is process death (round-2 finding 1, process half). Omitting `end` makes
23
+ * "never closed by code" a type error for any caller, not just a doc comment the SIGTERM
24
+ * handler has to remember to honor.
25
+ */
26
+ export type HeldLockConnection = Omit<Client, "end">;
27
+ export interface WorkerLock {
28
+ readonly appName: string;
29
+ readonly connection: HeldLockConnection;
30
+ /** The database clock at acquisition, not this process's. */
31
+ readonly acquiredAt: Date;
32
+ }
33
+ /**
34
+ * Taken on a connection of its own, not one of either pool's: a pooled client is returned,
35
+ * recycled and eventually closed, any of which would drop the lock under a running worker.
36
+ */
37
+ export declare function acquireWorkerLock(databaseUrl: string, appName: string): Promise<WorkerLock>;
@@ -0,0 +1,48 @@
1
+ import { Client } from "pg";
2
+ /**
3
+ * Session-level, so the lock is held by the connection rather than by a transaction, and
4
+ * released only when that connection goes away. Advisory locks are scoped to the database,
5
+ * and the key is namespaced by app name on top of that.
6
+ */
7
+ export const WORKER_LOCK_STATEMENT = "SELECT pg_try_advisory_lock(hashtext('hf-worker:' || $1::text)) AS acquired, now() AS acquired_at";
8
+ /**
9
+ * `<marker> <appName> <iso>`, where the timestamp is the *database's*, read in the same
10
+ * statement as the lock. Redeploy case 7 orders it against the previous worker's last
11
+ * committed write, which is a database timestamp too — two process clocks could not be
12
+ * compared at all.
13
+ */
14
+ export const LOCK_ACQUIRED_MARKER = "hf-worker: advisory lock acquired";
15
+ export class WorkerLockUnavailable extends Error {
16
+ appName;
17
+ constructor(appName) {
18
+ super(`WorkerLockUnavailable: another worker process holds the advisory lock for ${appName}; ` +
19
+ "two workers for one app never run at once");
20
+ this.name = "WorkerLockUnavailable";
21
+ this.appName = appName;
22
+ }
23
+ }
24
+ /**
25
+ * Taken on a connection of its own, not one of either pool's: a pooled client is returned,
26
+ * recycled and eventually closed, any of which would drop the lock under a running worker.
27
+ */
28
+ export async function acquireWorkerLock(databaseUrl, appName) {
29
+ const connection = new Client({ connectionString: databaseUrl });
30
+ await connection.connect();
31
+ let row;
32
+ try {
33
+ const result = await connection.query(WORKER_LOCK_STATEMENT, [appName]);
34
+ row = result.rows[0];
35
+ }
36
+ catch (error) {
37
+ await connection.end().catch(() => undefined);
38
+ throw error;
39
+ }
40
+ if (row?.acquired !== true) {
41
+ // Nothing is held on this connection, so it is not a lock connection and closing it here
42
+ // costs nothing — the holder is some other process.
43
+ await connection.end().catch(() => undefined);
44
+ throw new WorkerLockUnavailable(appName);
45
+ }
46
+ console.info(LOCK_ACQUIRED_MARKER, appName, row.acquired_at.toISOString());
47
+ return { appName, connection, acquiredAt: row.acquired_at };
48
+ }