@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.
package/dist/index.js ADDED
@@ -0,0 +1,16 @@
1
+ export { startWorker, MissingBuildSha, NotAWorkerProcess, DRAIN_TIMEOUT_MS, LAUNCHED_MARKER, LAUNCHING_MARKER, MIN_BUILD_SHA_LENGTH, QUEUES, RECONCILER_POOL_SIZE, SHUTDOWN_FAILED_MARKER, SHUTDOWN_IGNORED_MARKER, SHUTDOWN_MARKER, SHUTDOWN_WATCHDOG_MS, SYSTEM_DATABASE_POOL_SIZE, SYSTEM_DATABASE_SCHEMA, WORKER_PROCESS, } from "./start-worker.js";
2
+ export { registerLangfuse, LANGFUSE_ENV } from "./langfuse.js";
3
+ export { getClient, resetClient, CLIENT_POOL_SIZE } from "./client.js";
4
+ export { setPausedQueueConcurrency, PAUSED_CONCURRENCY, PAUSED_QUEUES, } from "./queue-concurrency.js";
5
+ export { WorkerLockUnavailable, LOCK_ACQUIRED_MARKER } from "./worker-lock.js";
6
+ export { defineFlow, definedFlows, DuplicateFlow, UnknownQueue, SUPERSEDED_MARKER, } from "./define-flow.js";
7
+ export { step, STEP_GATE_STATEMENT } from "./step.js";
8
+ export { actions, perform, idempotencyKey, stubChannel, ActionUncertain, } from "./actions.js";
9
+ export { approvals, decide, waitForApproval, ApprovalBatchRefused, ApprovalRunMoved, ApprovalWriteLost, APPROVAL_DECISIONS, DECIDE_OPERATION, } from "./approvals.js";
10
+ export { createApprovalNotifier, NO_RECIPIENTS_MARKER, } from "./approval-notifier.js";
11
+ export { handleTelegramCallback, encodeCallbackData, decodeCallbackData, decisionKeyFor, maxNonceLength, CallbackDataTooLong, CALLBACK_DATA_MAX_BYTES, CALLBACK_DATA_VERSION, } from "./telegram.js";
12
+ export { UnknownFlow } from "./bump.js";
13
+ export { Suspend, SUSPEND_STATUSES } from "./suspend.js";
14
+ export { currentRun, OutsideRun } from "./run-context.js";
15
+ export { runsStart, START_RUN_STATEMENT } from "./runs.js";
16
+ export { reconcile, startReconciler, sweepDecisionKey, ABANDON_LLM_CALLS_STATEMENT, DRIFT_STATEMENT, EXPIRED_APPROVALS_STATEMENT, PAUSED_RUNS_STATEMENT, RECONCILE_ACTION_MARKER, RECONCILE_ANOMALY_MARKER, RECONCILE_FAILED_MARKER, RECONCILE_INTERVAL_MS, RECONCILE_PASS_MARKER, RECONCILE_QUEUE_MARKER, RUNNING_RUNS_STATEMENT, UNCERTAIN_ACTIONS_STATEMENT, } from "./reconcile.js";
@@ -0,0 +1,16 @@
1
+ /** The three variables `LangfuseSpanProcessor` reads for itself; all three or nothing. */
2
+ export declare const LANGFUSE_ENV: readonly ["LANGFUSE_PUBLIC_KEY", "LANGFUSE_SECRET_KEY", "LANGFUSE_BASE_URL"];
3
+ export interface LangfuseRegistration {
4
+ /** Flushes the batch. Called from the SIGTERM handler before the process exits. */
5
+ shutdown(): Promise<void>;
6
+ }
7
+ /**
8
+ * Registers the process-wide tracer provider Langfuse exports through, or nothing when the keys
9
+ * are absent — in which case `trace.getTracer()` stays the no-op proxy and every span the AI
10
+ * SDK and DBOS would create costs nothing.
11
+ *
12
+ * `register()` also installs the async-hooks context manager, which is what lets a `gen_ai` span
13
+ * inherit the DBOS step span's trace id; DBOS's own installer then sees a working context and
14
+ * leaves ours alone. Exported so the web's `instrumentation.ts` registers the same way.
15
+ */
16
+ export declare function registerLangfuse(env?: NodeJS.ProcessEnv): LangfuseRegistration | undefined;
@@ -0,0 +1,24 @@
1
+ import { LangfuseSpanProcessor } from "@langfuse/otel";
2
+ import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node";
3
+ /** The three variables `LangfuseSpanProcessor` reads for itself; all three or nothing. */
4
+ export const LANGFUSE_ENV = [
5
+ "LANGFUSE_PUBLIC_KEY",
6
+ "LANGFUSE_SECRET_KEY",
7
+ "LANGFUSE_BASE_URL",
8
+ ];
9
+ /**
10
+ * Registers the process-wide tracer provider Langfuse exports through, or nothing when the keys
11
+ * are absent — in which case `trace.getTracer()` stays the no-op proxy and every span the AI
12
+ * SDK and DBOS would create costs nothing.
13
+ *
14
+ * `register()` also installs the async-hooks context manager, which is what lets a `gen_ai` span
15
+ * inherit the DBOS step span's trace id; DBOS's own installer then sees a working context and
16
+ * leaves ours alone. Exported so the web's `instrumentation.ts` registers the same way.
17
+ */
18
+ export function registerLangfuse(env = process.env) {
19
+ if (LANGFUSE_ENV.some((name) => (env[name] ?? "") === ""))
20
+ return undefined;
21
+ const provider = new NodeTracerProvider({ spanProcessors: [new LangfuseSpanProcessor()] });
22
+ provider.register();
23
+ return { shutdown: () => provider.shutdown() };
24
+ }
@@ -0,0 +1,32 @@
1
+ import type { DBOSClient } from "@dbos-inc/dbos-sdk";
2
+ import { type QueueName } from "./start-worker.js";
3
+ /**
4
+ * The queues `pause` stops. `resolve` is deliberately not one of them: the plan names `llm` and
5
+ * `actions`, the two that spend money and reach the outside world, and a resolution batch is
6
+ * stopped by the step gate at its next step like everything else.
7
+ */
8
+ export declare const PAUSED_QUEUES: readonly ["llm", "actions"];
9
+ export interface QueueConcurrency {
10
+ name: QueueName;
11
+ globalConcurrency: number;
12
+ /**
13
+ * False when no `dbos.queues` row exists yet — no worker has launched — so the concurrency
14
+ * could not be written. The pause itself still stands: `hf_app_state.paused` is what a step
15
+ * reads, and `startWorker()` re-applies the zero on the way up.
16
+ */
17
+ applied: boolean;
18
+ }
19
+ /** What `pause` sets the two queues to. Zero means the dequeue claims nothing at all. */
20
+ export declare const PAUSED_CONCURRENCY = 0;
21
+ /** What a resume — and `reconcile()`'s step (6) — puts a queue back to. */
22
+ export declare function registeredConcurrency(name: QueueName): number;
23
+ /**
24
+ * Sets `llm` and `actions` to zero, or back to their registered concurrency. The queues are
25
+ * database-backed (`DBOS.registerQueue` persists them), so a worker in another process picks
26
+ * the change up on its next reconcile of the queues table — this is callable from the web.
27
+ *
28
+ * It is the *second* half of a pause and the *first* half of a resume in both cases: the
29
+ * correctness mechanism is the pause flag the step gate reads, and this only stops work from
30
+ * being dispatched that the gate would immediately suspend.
31
+ */
32
+ export declare function setPausedQueueConcurrency(client: DBOSClient, paused: boolean): Promise<QueueConcurrency[]>;
@@ -0,0 +1,36 @@
1
+ import { QUEUES } from "./start-worker.js";
2
+ /**
3
+ * The queues `pause` stops. `resolve` is deliberately not one of them: the plan names `llm` and
4
+ * `actions`, the two that spend money and reach the outside world, and a resolution batch is
5
+ * stopped by the step gate at its next step like everything else.
6
+ */
7
+ export const PAUSED_QUEUES = ["llm", "actions"];
8
+ /** What `pause` sets the two queues to. Zero means the dequeue claims nothing at all. */
9
+ export const PAUSED_CONCURRENCY = 0;
10
+ /** What a resume — and `reconcile()`'s step (6) — puts a queue back to. */
11
+ export function registeredConcurrency(name) {
12
+ return QUEUES.find((queue) => queue.name === name).globalConcurrency;
13
+ }
14
+ /**
15
+ * Sets `llm` and `actions` to zero, or back to their registered concurrency. The queues are
16
+ * database-backed (`DBOS.registerQueue` persists them), so a worker in another process picks
17
+ * the change up on its next reconcile of the queues table — this is callable from the web.
18
+ *
19
+ * It is the *second* half of a pause and the *first* half of a resume in both cases: the
20
+ * correctness mechanism is the pause flag the step gate reads, and this only stops work from
21
+ * being dispatched that the gate would immediately suspend.
22
+ */
23
+ export async function setPausedQueueConcurrency(client, paused) {
24
+ const applied = [];
25
+ for (const name of PAUSED_QUEUES) {
26
+ const globalConcurrency = paused ? PAUSED_CONCURRENCY : registeredConcurrency(name);
27
+ const queue = await client.retrieveQueue(name);
28
+ if (queue === null) {
29
+ applied.push({ name, globalConcurrency, applied: false });
30
+ continue;
31
+ }
32
+ await queue.setGlobalConcurrency(globalConcurrency);
33
+ applied.push({ name, globalConcurrency, applied: true });
34
+ }
35
+ return applied;
36
+ }
@@ -0,0 +1,157 @@
1
+ import type { DBOSClient } from "@dbos-inc/dbos-sdk";
2
+ import type { Pool } from "pg";
3
+ import type { QueueName } from "./start-worker.js";
4
+ /** How often the worker runs a pass after the one it runs at boot. */
5
+ export declare const RECONCILE_INTERVAL_MS = 60000;
6
+ /**
7
+ * A `running` run whose `current_workflow_id` has no `dbos.workflow_status` row at all. Every
8
+ * path that writes the column enqueues in the same transaction, so this cannot happen — it is
9
+ * logged at error and counted rather than swallowed, and the attempt is enqueued anyway.
10
+ */
11
+ export declare const RECONCILE_ANOMALY_MARKER = "hf-reconcile: a running run has no workflow row";
12
+ /** One line per run a pass could not finish; the pass carries on with the others. */
13
+ export declare const RECONCILE_FAILED_MARKER = "hf-reconcile: run refused";
14
+ /**
15
+ * One line per run a pass moved, carrying the `run_id`. The pass summary alone would make a
16
+ * redeploy that moved a whole backlog a single number, with no way back to which run went where.
17
+ */
18
+ export declare const RECONCILE_ACTION_MARKER = "hf-reconcile: run moved";
19
+ /**
20
+ * One line per queue a pass re-derived from `hf_app_state.paused`, carrying both concurrencies.
21
+ * Its own marker rather than the action one: nothing about it is a run, and a stall this fixed
22
+ * is diagnosed by reading which direction it went.
23
+ */
24
+ export declare const RECONCILE_QUEUE_MARKER = "hf-reconcile: queue concurrency corrected";
25
+ /** One line per pass, carrying the report. */
26
+ export declare const RECONCILE_PASS_MARKER = "hf-reconcile: pass";
27
+ /**
28
+ * The drift scan. `reconcile()` reports it and never corrects it: correcting a counter is how
29
+ * round-2 finding 3 happened, and per-period drift is exact because a call is billed to the
30
+ * period stamped on its own row.
31
+ */
32
+ export declare const DRIFT_STATEMENT: string;
33
+ /**
34
+ * Step (1)'s scan. A plain `SELECT`, joined against `dbos.workflow_status` rather than asking
35
+ * the SDK per run: one statement for the whole scan, and the application role reads that table
36
+ * under the grants E006 checks.
37
+ */
38
+ export declare const RUNNING_RUNS_STATEMENT: string;
39
+ /** Step (3)'s scan: runs parked by a pause the app has since come out of. */
40
+ export declare const PAUSED_RUNS_STATEMENT: string;
41
+ /**
42
+ * Step (4), ledger half. Round 3 widened the predicate twice over round 2: to `running` runs'
43
+ * non-current rows, and to every row on a `waiting`/`paused` run, whose current workflow has
44
+ * ended so nothing can still be in flight. Idempotent because the predicate is the status.
45
+ *
46
+ * Hygiene and audit, not budget correctness — none of these rows reserves anything by the time
47
+ * this runs, because the reservation only ever counts rows whose `workflow_id` is still their
48
+ * run's `current_workflow_id` on a `running` run.
49
+ */
50
+ export declare const ABANDON_LLM_CALLS_STATEMENT: string;
51
+ /**
52
+ * Step (4), actions half: the same predicate, into the status that already means this. The
53
+ * `RETURNING` is what makes "one task per uncertain row, once" fall out of the status predicate —
54
+ * a row this pass moved is never returned by the next one.
55
+ */
56
+ export declare const UNCERTAIN_ACTIONS_STATEMENT: string;
57
+ /**
58
+ * Step (5)'s scan. A plain `SELECT`: `decide()` locks what it decides, and an approval this
59
+ * scan read a moment before someone decided it is simply not pending any more by then.
60
+ */
61
+ export declare const EXPIRED_APPROVALS_STATEMENT: string;
62
+ export interface ReconcileOptions {
63
+ /** The version this worker runs; an attempt under any other one is dead. */
64
+ applicationVersion: string;
65
+ /** A Postgres interval literal handed to every transaction of the pass. */
66
+ lockTimeout?: string;
67
+ }
68
+ export interface Reattempted {
69
+ runId: string;
70
+ attempt: number;
71
+ workflowId: string;
72
+ /** What the previous attempt was doing when the pass found it. */
73
+ reason: "dead-version" | "cancelled" | "resumed";
74
+ }
75
+ export interface Concluded {
76
+ runId: string;
77
+ status: "done" | "failed";
78
+ dbosStatus: string;
79
+ }
80
+ export interface ReconcileAnomaly {
81
+ runId: string;
82
+ workflowId: string;
83
+ /** False when the enqueue itself was refused; the anomaly is counted either way. */
84
+ enqueued: boolean;
85
+ }
86
+ export interface ReconcileFailure {
87
+ /** Null on a failure no run owns; step (6)'s subject is the app's queues, not a run. */
88
+ runId: string | null;
89
+ step: "reattempt" | "conclude" | "resume" | "anomaly" | "expire" | "queues";
90
+ error: string;
91
+ }
92
+ export interface ExpiredApproval {
93
+ approvalId: number;
94
+ runId: string;
95
+ /** The attempt `decide()` enqueued to tell the run its approval expired. */
96
+ workflowId: string;
97
+ }
98
+ export interface QueueConcurrencyCorrection {
99
+ name: QueueName;
100
+ /** `hf_app_state.paused` as this pass read it, which is what the correction was against. */
101
+ paused: boolean;
102
+ /** What the queue's row said before the pass wrote it; `null` is a registered no-limit. */
103
+ was: number | null;
104
+ now: number;
105
+ }
106
+ export interface PeriodDrift {
107
+ period: string;
108
+ spentUsd: string;
109
+ ledgerUsd: string;
110
+ driftUsd: string;
111
+ }
112
+ export interface ReconcileReport {
113
+ reattempted: Reattempted[];
114
+ concluded: Concluded[];
115
+ anomalies: ReconcileAnomaly[];
116
+ abandonedLlmCalls: number;
117
+ uncertainActions: number;
118
+ expired: ExpiredApproval[];
119
+ queueConcurrency: QueueConcurrencyCorrection[];
120
+ drift: PeriodDrift[];
121
+ failures: ReconcileFailure[];
122
+ }
123
+ /**
124
+ * The app-level half of a redeploy: DBOS's own recovery is version- and executor-scoped, so an
125
+ * old version's workflows are never touched by a new worker. This is what moves those runs on.
126
+ *
127
+ * A control-plane operation — control pool, `assertNotInWorkflow()`, every transaction through
128
+ * the tag-asserting helper — and it **never locks `hf_budget_period`**: its drift read is a
129
+ * plain `SELECT`, which is what keeps the lock order `hf_run → hf_budget_period → ledger` free
130
+ * of a cycle (round-3 finding 8).
131
+ *
132
+ * One run's refusal never ends the pass: the runs are independent, and a pass that stopped at
133
+ * the first one would leave the rest of a backlog stranded until the defect was fixed.
134
+ *
135
+ * Steps (1), (3), (4), (5) and (6) and the drift read; step (2) is deleted, not re-predicated —
136
+ * the enqueue is in the bump's own transaction, so there is no commit-to-enqueue window to
137
+ * backstop.
138
+ */
139
+ export declare function reconcile(pool: Pool, dbosClient: DBOSClient, options: ReconcileOptions): Promise<ReconcileReport>;
140
+ /** Stable across passes, so a re-decided row is a replay rather than a second decision. */
141
+ export declare function sweepDecisionKey(approvalId: number): string;
142
+ export interface Reconciler {
143
+ /** Stops the interval; the pass already in flight is left to finish. */
144
+ stop(): void;
145
+ }
146
+ /**
147
+ * The every-minute schedule. A plain timer rather than a DBOS scheduled workflow: DBOS's
148
+ * scheduler runs its functions *as workflows*, and `reconcile()` is a control-plane operation
149
+ * that `assertNotInWorkflow()` refuses from inside one (round-3 finding 2).
150
+ *
151
+ * Passes never overlap — a pass that outruns the interval would have two reconcilers bumping
152
+ * the same backlog — and a failed pass is logged, never thrown: there is no caller left to
153
+ * throw to, and the next pass reconciles whatever this one did not.
154
+ */
155
+ export declare function startReconciler(pool: Pool, dbosClient: DBOSClient, options: ReconcileOptions & {
156
+ intervalMs?: number;
157
+ }): Reconciler;