@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/LICENSE +21 -0
- package/dist/actions.d.ts +60 -0
- package/dist/actions.js +175 -0
- package/dist/approval-notifier.d.ts +31 -0
- package/dist/approval-notifier.js +51 -0
- package/dist/approvals.d.ts +137 -0
- package/dist/approvals.js +350 -0
- package/dist/bump.d.ts +18 -0
- package/dist/bump.js +29 -0
- package/dist/client.d.ts +25 -0
- package/dist/client.js +43 -0
- package/dist/control-pool.d.ts +21 -0
- package/dist/control-pool.js +18 -0
- package/dist/define-flow.d.ts +40 -0
- package/dist/define-flow.js +83 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.js +16 -0
- package/dist/langfuse.d.ts +16 -0
- package/dist/langfuse.js +24 -0
- package/dist/queue-concurrency.d.ts +32 -0
- package/dist/queue-concurrency.js +36 -0
- package/dist/reconcile.d.ts +157 -0
- package/dist/reconcile.js +434 -0
- package/dist/run-context.d.ts +12 -0
- package/dist/run-context.js +25 -0
- package/dist/run-status.d.ts +18 -0
- package/dist/run-status.js +28 -0
- package/dist/runs.d.ts +18 -0
- package/dist/runs.js +18 -0
- package/dist/start-worker.d.ts +83 -0
- package/dist/start-worker.js +208 -0
- package/dist/step.d.ts +29 -0
- package/dist/step.js +54 -0
- package/dist/suspend.d.ts +17 -0
- package/dist/suspend.js +21 -0
- package/dist/telegram.d.ts +91 -0
- package/dist/telegram.js +161 -0
- package/dist/worker-lock.d.ts +37 -0
- package/dist/worker-lock.js +48 -0
- package/dist/worker-runtime.d.ts +18 -0
- package/dist/worker-runtime.js +22 -0
- package/package.json +57 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Graham Lutz
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { StepContext } from "./step.js";
|
|
2
|
+
/** What a channel is handed. `idempotencyKey` is stable across attempts, so a provider that
|
|
3
|
+
* supports one dedupes a re-send rather than sending twice. */
|
|
4
|
+
export interface ActionDispatch<Req = unknown> {
|
|
5
|
+
idempotencyKey: string;
|
|
6
|
+
runId: string;
|
|
7
|
+
key: string;
|
|
8
|
+
request: Req;
|
|
9
|
+
}
|
|
10
|
+
export interface ActionResult {
|
|
11
|
+
externalId?: string;
|
|
12
|
+
response?: unknown;
|
|
13
|
+
}
|
|
14
|
+
/** `Req` is what this channel is sent; left off, a channel takes `unknown` as it always has. */
|
|
15
|
+
export interface ActionChannel<Req = unknown> {
|
|
16
|
+
readonly name: string;
|
|
17
|
+
/** Whether a re-send with the same `idempotencyKey` is delivered at most once by the provider. */
|
|
18
|
+
readonly dedupes: boolean;
|
|
19
|
+
send(dispatch: ActionDispatch<Req>): Promise<ActionResult>;
|
|
20
|
+
}
|
|
21
|
+
export interface ActionsPerformOptions<Req = unknown> {
|
|
22
|
+
/** Unique within the run and stable across attempts, exactly as `llm.run`'s is. */
|
|
23
|
+
key: string;
|
|
24
|
+
channel: ActionChannel<Req>;
|
|
25
|
+
request?: Req;
|
|
26
|
+
recordType?: string;
|
|
27
|
+
recordId?: string;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* A row left in flight by an attempt that is gone, on a channel that cannot dedupe: nothing was
|
|
31
|
+
* re-sent and an `hf_task` asks a human whether the first send went out. Terminal by design — it
|
|
32
|
+
* reaches the flow wrapper's catch and the run ends `failed`, because no code can decide this.
|
|
33
|
+
*
|
|
34
|
+
* `taskId` is null only for a row that went `uncertain` before this package opened tasks for it.
|
|
35
|
+
*/
|
|
36
|
+
export declare class ActionUncertain extends Error {
|
|
37
|
+
readonly runId: string;
|
|
38
|
+
readonly key: string;
|
|
39
|
+
readonly actionLogId: number;
|
|
40
|
+
readonly taskId: number | null;
|
|
41
|
+
constructor(runId: string, key: string, actionLogId: number, taskId: number | null);
|
|
42
|
+
}
|
|
43
|
+
export declare function idempotencyKey(runId: string, key: string): string;
|
|
44
|
+
/**
|
|
45
|
+
* One outbound side effect, ledgered on `hf_action_log` with the same `started`-row pattern as
|
|
46
|
+
* `llm.run` and the same two-transaction shape — the lock order here is `hf_run` (via `ctx.tx`)
|
|
47
|
+
* → `hf_action_log` → `hf_task`/`hf_activity`, which sit in the last tier.
|
|
48
|
+
*
|
|
49
|
+
* Re-entry of a row this attempt did not insert is where the channel's `dedupes` declaration is
|
|
50
|
+
* spent. A channel the provider dedupes for is simply re-taken and re-sent. One that does not is
|
|
51
|
+
* never re-sent: the row goes `uncertain`, a task asks a human whether the first send went out,
|
|
52
|
+
* and `ActionUncertain` ends the run. A first dispatch always sends, so a non-deduping channel
|
|
53
|
+
* gets at most one send per row ever.
|
|
54
|
+
*/
|
|
55
|
+
export declare function perform<Req>(ctx: StepContext, options: ActionsPerformOptions<Req>): Promise<ActionResult>;
|
|
56
|
+
export declare const actions: {
|
|
57
|
+
perform: typeof perform;
|
|
58
|
+
};
|
|
59
|
+
/** The only channel chunk 10 ships: it dispatches nothing and succeeds. */
|
|
60
|
+
export declare function stubChannel(name?: string): ActionChannel;
|
package/dist/actions.js
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { sql } from "drizzle-orm";
|
|
2
|
+
/**
|
|
3
|
+
* A row left in flight by an attempt that is gone, on a channel that cannot dedupe: nothing was
|
|
4
|
+
* re-sent and an `hf_task` asks a human whether the first send went out. Terminal by design — it
|
|
5
|
+
* reaches the flow wrapper's catch and the run ends `failed`, because no code can decide this.
|
|
6
|
+
*
|
|
7
|
+
* `taskId` is null only for a row that went `uncertain` before this package opened tasks for it.
|
|
8
|
+
*/
|
|
9
|
+
export class ActionUncertain extends Error {
|
|
10
|
+
runId;
|
|
11
|
+
key;
|
|
12
|
+
actionLogId;
|
|
13
|
+
taskId;
|
|
14
|
+
constructor(runId, key, actionLogId, taskId) {
|
|
15
|
+
super(`ActionUncertain: action ${key} on run ${runId} (hf_action_log ${actionLogId}) was left ` +
|
|
16
|
+
"in flight by an attempt that is gone and its channel cannot dedupe; nothing was " +
|
|
17
|
+
`re-sent and hf_task ${taskId ?? "(none)"} asks a human to confirm`);
|
|
18
|
+
this.name = "ActionUncertain";
|
|
19
|
+
this.runId = runId;
|
|
20
|
+
this.key = key;
|
|
21
|
+
this.actionLogId = actionLogId;
|
|
22
|
+
this.taskId = taskId;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export function idempotencyKey(runId, key) {
|
|
26
|
+
return `${runId}:${key}`;
|
|
27
|
+
}
|
|
28
|
+
function titleOf(ctx, options) {
|
|
29
|
+
return `Confirm ${options.channel.name} send ${options.key} for run ${ctx.runId}`;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The task for one uncertain action row. `DO NOTHING` on the partial unique index rather than an
|
|
33
|
+
* upsert: whoever asked first owns the wording, and the id is looked up so the error can name it.
|
|
34
|
+
*
|
|
35
|
+
* The target columns follow the action row's, NULL included — a stand-in record type would fail
|
|
36
|
+
* E002 at the next boot, and `origin_ref` is what points the task back at the row.
|
|
37
|
+
*/
|
|
38
|
+
async function openTask(db, row, title) {
|
|
39
|
+
const inserted = await db.execute(sql `
|
|
40
|
+
INSERT INTO hf_task (record_type, record_id, title, origin, origin_ref)
|
|
41
|
+
VALUES (${row.record_type}, ${row.record_id}, ${title}, 'flow', ${row.id})
|
|
42
|
+
ON CONFLICT (origin, origin_ref) WHERE origin_ref IS NOT NULL DO NOTHING
|
|
43
|
+
RETURNING id
|
|
44
|
+
`);
|
|
45
|
+
if (inserted.rows[0] !== undefined)
|
|
46
|
+
return Number(inserted.rows[0].id);
|
|
47
|
+
return existingTaskId(db, row.id);
|
|
48
|
+
}
|
|
49
|
+
/** Either writer may own it, so the lookup is not predicated on `origin`. */
|
|
50
|
+
async function existingTaskId(db, actionLogId) {
|
|
51
|
+
const found = await db.execute(sql `
|
|
52
|
+
SELECT id FROM hf_task WHERE origin_ref = ${actionLogId} AND origin IN ('flow', 'sweep')
|
|
53
|
+
ORDER BY id LIMIT 1
|
|
54
|
+
`);
|
|
55
|
+
const row = found.rows[0];
|
|
56
|
+
return row === undefined ? null : Number(row.id);
|
|
57
|
+
}
|
|
58
|
+
/** A row somebody else already moved to `uncertain`: report it, write nothing. */
|
|
59
|
+
async function uncertainOf(db, ctx, options, row) {
|
|
60
|
+
const taskId = await existingTaskId(db, row.id);
|
|
61
|
+
return new ActionUncertain(ctx.runId, options.key, Number(row.id), taskId);
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* One outbound side effect, ledgered on `hf_action_log` with the same `started`-row pattern as
|
|
65
|
+
* `llm.run` and the same two-transaction shape — the lock order here is `hf_run` (via `ctx.tx`)
|
|
66
|
+
* → `hf_action_log` → `hf_task`/`hf_activity`, which sit in the last tier.
|
|
67
|
+
*
|
|
68
|
+
* Re-entry of a row this attempt did not insert is where the channel's `dedupes` declaration is
|
|
69
|
+
* spent. A channel the provider dedupes for is simply re-taken and re-sent. One that does not is
|
|
70
|
+
* never re-sent: the row goes `uncertain`, a task asks a human whether the first send went out,
|
|
71
|
+
* and `ActionUncertain` ends the run. A first dispatch always sends, so a non-deduping channel
|
|
72
|
+
* gets at most one send per row ever.
|
|
73
|
+
*/
|
|
74
|
+
export async function perform(ctx, options) {
|
|
75
|
+
const taken = await ctx.tx(async (db) => {
|
|
76
|
+
const insert = await db.execute(sql `
|
|
77
|
+
INSERT INTO hf_action_log
|
|
78
|
+
(run_id, key, workflow_id, channel, idempotency_key, status, request, record_type, record_id)
|
|
79
|
+
VALUES (${ctx.runId}, ${options.key}, ${ctx.workflowId}, ${options.channel.name},
|
|
80
|
+
${idempotencyKey(ctx.runId, options.key)}, 'started',
|
|
81
|
+
${JSON.stringify(options.request) ?? null}::jsonb,
|
|
82
|
+
${options.recordType ?? null}, ${options.recordId ?? null})
|
|
83
|
+
ON CONFLICT (run_id, key) DO NOTHING
|
|
84
|
+
`);
|
|
85
|
+
const read = await db.execute(sql `
|
|
86
|
+
SELECT id::text AS id, status, external_id, response, record_type, record_id
|
|
87
|
+
FROM hf_action_log WHERE run_id = ${ctx.runId} AND key = ${options.key}
|
|
88
|
+
`);
|
|
89
|
+
const row = read.rows[0];
|
|
90
|
+
if (row.status === "ok") {
|
|
91
|
+
return { result: { externalId: row.external_id ?? undefined, response: row.response } };
|
|
92
|
+
}
|
|
93
|
+
// Already asked about, whatever the channel says: a second send behind the human's back is
|
|
94
|
+
// the one thing the task exists to prevent.
|
|
95
|
+
if (row.status === "uncertain")
|
|
96
|
+
return { uncertain: await uncertainOf(db, ctx, options, row) };
|
|
97
|
+
if (insert.rowCount !== 1) {
|
|
98
|
+
if (options.channel.dedupes) {
|
|
99
|
+
await db.execute(sql `
|
|
100
|
+
UPDATE hf_action_log
|
|
101
|
+
SET status = 'started', workflow_id = ${ctx.workflowId}, finished_at = NULL
|
|
102
|
+
WHERE run_id = ${ctx.runId} AND key = ${options.key}
|
|
103
|
+
`);
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
// `failed` joins `started` here: a channel that threw after delivering leaves exactly that,
|
|
107
|
+
// so it is as unknown as a row nobody finished.
|
|
108
|
+
const moved = await db.execute(sql `
|
|
109
|
+
UPDATE hf_action_log SET status = 'uncertain', finished_at = now()
|
|
110
|
+
WHERE run_id = ${ctx.runId} AND key = ${options.key}
|
|
111
|
+
AND status IN ('started', 'failed')
|
|
112
|
+
`);
|
|
113
|
+
// The transition is the serialization point: a pass that moved the row first owns the task.
|
|
114
|
+
if (moved.rowCount !== 1)
|
|
115
|
+
return { uncertain: await uncertainOf(db, ctx, options, row) };
|
|
116
|
+
const taskId = await openTask(db, row, titleOf(ctx, options));
|
|
117
|
+
await db.execute(sql `
|
|
118
|
+
INSERT INTO hf_activity (record_type, record_id, kind, run_id, meta)
|
|
119
|
+
VALUES (${row.record_type}, ${row.record_id}, 'action.uncertain', ${ctx.runId},
|
|
120
|
+
${JSON.stringify({
|
|
121
|
+
actionLogId: Number(row.id),
|
|
122
|
+
key: options.key,
|
|
123
|
+
channel: options.channel.name,
|
|
124
|
+
taskId,
|
|
125
|
+
})}::jsonb)
|
|
126
|
+
`);
|
|
127
|
+
return { uncertain: new ActionUncertain(ctx.runId, options.key, Number(row.id), taskId) };
|
|
128
|
+
}
|
|
129
|
+
return undefined;
|
|
130
|
+
});
|
|
131
|
+
// Outside the transaction: `ctx.tx` rolls back on anything thrown inside `work`, and the
|
|
132
|
+
// `uncertain` row and its task are the whole point of this branch.
|
|
133
|
+
if (taken?.uncertain !== undefined)
|
|
134
|
+
throw taken.uncertain;
|
|
135
|
+
if (taken?.result !== undefined)
|
|
136
|
+
return taken.result;
|
|
137
|
+
let result;
|
|
138
|
+
try {
|
|
139
|
+
result = await options.channel.send({
|
|
140
|
+
idempotencyKey: idempotencyKey(ctx.runId, options.key),
|
|
141
|
+
runId: ctx.runId,
|
|
142
|
+
key: options.key,
|
|
143
|
+
// `request` stays optional here, as it always was; a channel that declares a `Req` is
|
|
144
|
+
// saying it will not be performed without one.
|
|
145
|
+
request: options.request,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
await ctx.tx(async (db) => {
|
|
150
|
+
await db.execute(sql `
|
|
151
|
+
UPDATE hf_action_log SET status = 'failed', finished_at = now()
|
|
152
|
+
WHERE run_id = ${ctx.runId} AND key = ${options.key}
|
|
153
|
+
`);
|
|
154
|
+
});
|
|
155
|
+
throw error;
|
|
156
|
+
}
|
|
157
|
+
await ctx.tx(async (db) => {
|
|
158
|
+
await db.execute(sql `
|
|
159
|
+
UPDATE hf_action_log
|
|
160
|
+
SET status = 'ok', external_id = ${result.externalId ?? null},
|
|
161
|
+
response = ${JSON.stringify(result.response) ?? null}::jsonb, finished_at = now()
|
|
162
|
+
WHERE run_id = ${ctx.runId} AND key = ${options.key}
|
|
163
|
+
`);
|
|
164
|
+
});
|
|
165
|
+
return result;
|
|
166
|
+
}
|
|
167
|
+
export const actions = { perform };
|
|
168
|
+
/** The only channel chunk 10 ships: it dispatches nothing and succeeds. */
|
|
169
|
+
export function stubChannel(name = "stub") {
|
|
170
|
+
return {
|
|
171
|
+
name,
|
|
172
|
+
dedupes: true,
|
|
173
|
+
send: (dispatch) => Promise.resolve({ externalId: dispatch.idempotencyKey, response: { stub: true } }),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { ApprovalNotice, ApprovalNotifier } from "./approvals.js";
|
|
2
|
+
import type { StepContext } from "./step.js";
|
|
3
|
+
/** Text only: a notification is a line and a link, and an HTML body is a second thing to escape. */
|
|
4
|
+
export interface ApprovalMessage {
|
|
5
|
+
to: string[];
|
|
6
|
+
subject: string;
|
|
7
|
+
text: string;
|
|
8
|
+
/** Also in `text`; separate so a channel that renders its own button has it. */
|
|
9
|
+
url: string;
|
|
10
|
+
}
|
|
11
|
+
export interface ApprovalNotifierOptions {
|
|
12
|
+
/** The workspace's base URL; a trailing slash is trimmed rather than doubled into the path. */
|
|
13
|
+
appUrl: string;
|
|
14
|
+
/**
|
|
15
|
+
* Who to tell. Called with the step's context, so the read belongs to the step's own fenced
|
|
16
|
+
* transaction — `ctx.tx` — and not to a pool of the notifier's own.
|
|
17
|
+
*/
|
|
18
|
+
recipients(notice: ApprovalNotice, ctx: StepContext): Promise<string[]>;
|
|
19
|
+
send(message: ApprovalMessage): Promise<void>;
|
|
20
|
+
}
|
|
21
|
+
/** `<marker> <json>`: an approval nobody could be told about, which is a wiring problem. */
|
|
22
|
+
export declare const NO_RECIPIENTS_MARKER = "hf-approval-notifier: no recipients";
|
|
23
|
+
/**
|
|
24
|
+
* The notifier a worker hands `startWorker({ approvalNotifier })`: it resolves the recipients,
|
|
25
|
+
* builds the one message and sends it.
|
|
26
|
+
*
|
|
27
|
+
* Zero recipients is warned about and sent to nobody. It is deliberately not a throw: the step
|
|
28
|
+
* stamps `notified_at` once this returns, and failing here would leave the gate re-notifying
|
|
29
|
+
* every attempt over a list that is not going to fill itself.
|
|
30
|
+
*/
|
|
31
|
+
export declare function createApprovalNotifier(options: ApprovalNotifierOptions): ApprovalNotifier;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the workspace serves one approval. Inline until `@hyperfixation/core/workspace` owns
|
|
3
|
+
* the route; this notifier is then to take the path from its `approvalPath()`.
|
|
4
|
+
*/
|
|
5
|
+
function approvalPath(approvalId) {
|
|
6
|
+
return `/w/approvals/${approvalId}`;
|
|
7
|
+
}
|
|
8
|
+
/** `<marker> <json>`: an approval nobody could be told about, which is a wiring problem. */
|
|
9
|
+
export const NO_RECIPIENTS_MARKER = "hf-approval-notifier: no recipients";
|
|
10
|
+
/**
|
|
11
|
+
* The notifier a worker hands `startWorker({ approvalNotifier })`: it resolves the recipients,
|
|
12
|
+
* builds the one message and sends it.
|
|
13
|
+
*
|
|
14
|
+
* Zero recipients is warned about and sent to nobody. It is deliberately not a throw: the step
|
|
15
|
+
* stamps `notified_at` once this returns, and failing here would leave the gate re-notifying
|
|
16
|
+
* every attempt over a list that is not going to fill itself.
|
|
17
|
+
*/
|
|
18
|
+
export function createApprovalNotifier(options) {
|
|
19
|
+
const base = options.appUrl.replace(/\/+$/, "");
|
|
20
|
+
return async (notice, ctx) => {
|
|
21
|
+
const url = `${base}${approvalPath(notice.approvalId)}`;
|
|
22
|
+
// Before the send, so a bump that arrives while the gate is being re-entered is refused by
|
|
23
|
+
// `ctx.tx`'s fence with nothing delivered.
|
|
24
|
+
const to = await options.recipients(notice, ctx);
|
|
25
|
+
if (to.length === 0) {
|
|
26
|
+
console.warn(`${NO_RECIPIENTS_MARKER} ${JSON.stringify({
|
|
27
|
+
approvalId: notice.approvalId,
|
|
28
|
+
runId: notice.runId,
|
|
29
|
+
key: notice.key,
|
|
30
|
+
assigneeId: notice.assigneeId,
|
|
31
|
+
})}`);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
await options.send({
|
|
35
|
+
to,
|
|
36
|
+
subject: `Approval needed: ${notice.type}`,
|
|
37
|
+
text: body(notice, url),
|
|
38
|
+
url,
|
|
39
|
+
});
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
function body(notice, url) {
|
|
43
|
+
const lines = [`${notice.type} is waiting for a decision.`];
|
|
44
|
+
if (notice.recordType !== null && notice.recordId !== null) {
|
|
45
|
+
lines.push(`Record: ${notice.recordType} ${notice.recordId}`);
|
|
46
|
+
}
|
|
47
|
+
if (notice.expiresAt !== null)
|
|
48
|
+
lines.push(`Expires: ${notice.expiresAt.toISOString()}`);
|
|
49
|
+
lines.push("", `Decide: ${url}`, "");
|
|
50
|
+
return lines.join("\n");
|
|
51
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import type { DBOSClient } from "@dbos-inc/dbos-sdk";
|
|
2
|
+
import { type ApprovalStatus, type ApprovalVia } from "@hyperfixation/db";
|
|
3
|
+
import type { Pool } from "pg";
|
|
4
|
+
import { type ZodType } from "zod";
|
|
5
|
+
import { type StepContext } from "./step.js";
|
|
6
|
+
/** Named in `ControlPlaneInWorkflow` and `CommitLost`. */
|
|
7
|
+
export declare const DECIDE_OPERATION = "approvals.decide";
|
|
8
|
+
/** The statuses `decide()` may write; `pending` is the only one it accepts as input. */
|
|
9
|
+
export declare const APPROVAL_DECISIONS: readonly ["approved", "rejected", "expired", "cancelled"];
|
|
10
|
+
export type ApprovalDecisionKind = (typeof APPROVAL_DECISIONS)[number];
|
|
11
|
+
export interface ApprovalNotice {
|
|
12
|
+
approvalId: number;
|
|
13
|
+
runId: string;
|
|
14
|
+
key: string;
|
|
15
|
+
type: string;
|
|
16
|
+
draft: unknown;
|
|
17
|
+
/** The four below are read back from the row, so `expires_at` is the one the database computed. */
|
|
18
|
+
assigneeId: string | null;
|
|
19
|
+
recordType: string | null;
|
|
20
|
+
recordId: string | null;
|
|
21
|
+
expiresAt: Date | null;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* The step context is the notifier's only database handle: a recipient read goes through
|
|
25
|
+
* `ctx.tx`, so it is fenced by the same `FOR SHARE` on `hf_run` as the rest of the step.
|
|
26
|
+
*/
|
|
27
|
+
export type ApprovalNotifier = (notice: ApprovalNotice, ctx: StepContext) => Promise<void>;
|
|
28
|
+
export interface WaitForApprovalOptions {
|
|
29
|
+
/** Unique within the run and stable across attempts, exactly as a ledger key is. */
|
|
30
|
+
key: string;
|
|
31
|
+
/** What is being approved; Phase 2 resolves it to a Zod schema for the edited draft. */
|
|
32
|
+
type: string;
|
|
33
|
+
draft?: unknown;
|
|
34
|
+
assigneeId?: string;
|
|
35
|
+
recordType?: string;
|
|
36
|
+
recordId?: string;
|
|
37
|
+
/** From the row's creation; `reconcile()` step (5) expires the row once it is past. */
|
|
38
|
+
expiresInMs?: number;
|
|
39
|
+
/** Overrides the worker's `approvalNotifier`, which is what the gate falls back to. */
|
|
40
|
+
notify?: ApprovalNotifier;
|
|
41
|
+
}
|
|
42
|
+
export interface ApprovalDecision {
|
|
43
|
+
approvalId: number;
|
|
44
|
+
key: string;
|
|
45
|
+
status: Exclude<ApprovalStatus, "pending">;
|
|
46
|
+
/** The edited draft when a decider replaced it, otherwise the one the flow proposed. */
|
|
47
|
+
draft: unknown;
|
|
48
|
+
decidedBy: string | null;
|
|
49
|
+
decidedVia: ApprovalVia | null;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The gate a flow opts into. Not a `step()` itself but a pair of them plus a `Suspend`: the
|
|
53
|
+
* throw has to leave from the flow body, because a step that throws has its error checkpointed
|
|
54
|
+
* through `serialize-error` and a replay would revive a plain `Error` that `defineFlow`'s
|
|
55
|
+
* `instanceof Suspend` no longer catches.
|
|
56
|
+
*
|
|
57
|
+
* A decided row returns its decision and the flow carries on — that is how the attempt
|
|
58
|
+
* `decide()` enqueued passes through the gate it stopped at. Everything before this call runs
|
|
59
|
+
* again on that attempt and must be idempotent.
|
|
60
|
+
*/
|
|
61
|
+
export declare function waitForApproval(options: WaitForApprovalOptions): Promise<ApprovalDecision>;
|
|
62
|
+
/** What an approval type's `schema` has to be: anything zod can `safeParse` an edit with. */
|
|
63
|
+
export type ApprovalDraftSchema = ZodType;
|
|
64
|
+
export interface DecideOptions {
|
|
65
|
+
ids: number[];
|
|
66
|
+
decision: ApprovalDecisionKind;
|
|
67
|
+
via: ApprovalVia;
|
|
68
|
+
/** The replay token: a second call carrying one already on a row writes nothing. */
|
|
69
|
+
decisionKey: string;
|
|
70
|
+
userId?: string | null;
|
|
71
|
+
/** Per-approval replacement drafts, each parsed against its type's schema before any write. */
|
|
72
|
+
edits?: Record<number, unknown>;
|
|
73
|
+
/**
|
|
74
|
+
* True when the decider holds the admin role, which lets them decide a row assigned to
|
|
75
|
+
* someone else. The caller's session decides that; `decide()` has no idea who is an admin.
|
|
76
|
+
*/
|
|
77
|
+
admin?: boolean;
|
|
78
|
+
/**
|
|
79
|
+
* An approval `type` to the schema its edited draft must parse against. Sync and pure — it
|
|
80
|
+
* runs inside the locked transaction. Required whenever `edits` is non-empty; the schemas
|
|
81
|
+
* live in `core`'s registry, which `workflows` cannot import.
|
|
82
|
+
*/
|
|
83
|
+
schemaFor?: (type: string) => ApprovalDraftSchema | undefined;
|
|
84
|
+
lockTimeout?: string;
|
|
85
|
+
}
|
|
86
|
+
export interface DecidedApproval {
|
|
87
|
+
approvalId: number;
|
|
88
|
+
runId: string;
|
|
89
|
+
key: string;
|
|
90
|
+
status: ApprovalDecisionKind;
|
|
91
|
+
/** The attempt this decision enqueued to carry the run on. */
|
|
92
|
+
resumeWorkflowId: string;
|
|
93
|
+
}
|
|
94
|
+
export interface DecideResult {
|
|
95
|
+
/** True when the batch was already decided under this `decisionKey`; nothing was written. */
|
|
96
|
+
replayed: boolean;
|
|
97
|
+
decided: DecidedApproval[];
|
|
98
|
+
reattempted: {
|
|
99
|
+
runId: string;
|
|
100
|
+
attempt: number;
|
|
101
|
+
workflowId: string;
|
|
102
|
+
}[];
|
|
103
|
+
/** Stamped on every row of a batch of more than one; null for a batch of one. */
|
|
104
|
+
batchId: string | null;
|
|
105
|
+
}
|
|
106
|
+
export declare class ApprovalBatchRefused extends Error {
|
|
107
|
+
readonly reasons: {
|
|
108
|
+
approvalId: number;
|
|
109
|
+
reason: string;
|
|
110
|
+
}[];
|
|
111
|
+
constructor(reasons: {
|
|
112
|
+
approvalId: number;
|
|
113
|
+
reason: string;
|
|
114
|
+
}[]);
|
|
115
|
+
}
|
|
116
|
+
export declare class ApprovalRunMoved extends Error {
|
|
117
|
+
constructor(approvalId: number);
|
|
118
|
+
}
|
|
119
|
+
export declare class ApprovalWriteLost extends Error {
|
|
120
|
+
constructor(approvalId: number, rowCount: number);
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* The one way an approval is decided, whoever decides it: the inbox, the admin, a Telegram
|
|
124
|
+
* callback, `records.archive()` and `reconcile()`'s expiry sweep all come through here.
|
|
125
|
+
*
|
|
126
|
+
* A control-plane operation, and one transaction: the decision, the attempt bump and the
|
|
127
|
+
* enqueue of the resume workflow commit together or not at all, so a crash anywhere before the
|
|
128
|
+
* tag-asserted `COMMIT` leaves the approval `pending` and the run untouched, and a retry with
|
|
129
|
+
* the same `decisionKey` starts over. **Nothing in here catches** — a swallowed error would
|
|
130
|
+
* leave the transaction aborted and Postgres would answer `COMMIT` with a `ROLLBACK` tag
|
|
131
|
+
* (round-3 finding 5).
|
|
132
|
+
*/
|
|
133
|
+
export declare function decide(pool: Pool, dbosClient: DBOSClient, options: DecideOptions): Promise<DecideResult>;
|
|
134
|
+
export declare const approvals: {
|
|
135
|
+
decide: typeof decide;
|
|
136
|
+
waitForApproval: typeof waitForApproval;
|
|
137
|
+
};
|