@sanoma-ai/workflows 0.5.0 → 0.7.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/README.md +49 -15
- package/dist/apply.d.ts +2 -1
- package/dist/apply.js +29 -7
- package/dist/apply.js.map +1 -1
- package/dist/approvals.d.ts +27 -2
- package/dist/approvals.js +73 -9
- package/dist/approvals.js.map +1 -1
- package/dist/call.d.ts +3 -2
- package/dist/call.js +38 -30
- package/dist/call.js.map +1 -1
- package/dist/cedar.js +24 -9
- package/dist/cedar.js.map +1 -1
- package/dist/change.d.ts +219 -0
- package/dist/change.js +150 -0
- package/dist/change.js.map +1 -0
- package/dist/chats.d.ts +43 -0
- package/dist/chats.js +48 -0
- package/dist/chats.js.map +1 -0
- package/dist/client.d.ts +52 -6
- package/dist/client.js +88 -47
- package/dist/client.js.map +1 -1
- package/dist/config.d.ts +47 -5
- package/dist/config.js +71 -7
- package/dist/config.js.map +1 -1
- package/dist/database.d.ts +83 -0
- package/dist/database.js +92 -0
- package/dist/database.js.map +1 -0
- package/dist/db.d.ts +31 -1
- package/dist/db.js +89 -7
- package/dist/db.js.map +1 -1
- package/dist/deadlines.d.ts +88 -0
- package/dist/deadlines.js +171 -0
- package/dist/deadlines.js.map +1 -0
- package/dist/define.d.ts +25 -4
- package/dist/define.js +40 -0
- package/dist/define.js.map +1 -1
- package/dist/demo.d.ts +28 -0
- package/dist/demo.js +72 -0
- package/dist/demo.js.map +1 -0
- package/dist/describe.d.ts +6 -3
- package/dist/describe.js +11 -7
- package/dist/describe.js.map +1 -1
- package/dist/errors.d.ts +10 -1
- package/dist/errors.js +13 -0
- package/dist/errors.js.map +1 -1
- package/dist/fake.d.ts +8 -0
- package/dist/fake.js +11 -1
- package/dist/fake.js.map +1 -1
- package/dist/grid.d.ts +34 -0
- package/dist/grid.js +62 -0
- package/dist/grid.js.map +1 -0
- package/dist/host.d.ts +13 -2
- package/dist/host.js +2 -2
- package/dist/host.js.map +1 -1
- package/dist/index.d.ts +16 -8
- package/dist/index.js +12 -5
- package/dist/index.js.map +1 -1
- package/dist/ledger.d.ts +11 -2
- package/dist/ledger.js.map +1 -1
- package/dist/lint.js +1 -1
- package/dist/lint.js.map +1 -1
- package/dist/op.d.ts +14 -2
- package/dist/op.js.map +1 -1
- package/dist/policy.d.ts +7 -1
- package/dist/policy.js +2 -1
- package/dist/policy.js.map +1 -1
- package/dist/resource.d.ts +13 -1
- package/dist/resource.js +9 -1
- package/dist/resource.js.map +1 -1
- package/dist/resources.d.ts +2 -0
- package/dist/resources.js +10 -1
- package/dist/resources.js.map +1 -1
- package/dist/run.d.ts +5 -0
- package/dist/run.js.map +1 -1
- package/dist/sandbox.d.ts +38 -10
- package/dist/sandbox.js +66 -35
- package/dist/sandbox.js.map +1 -1
- package/dist/scenario.d.ts +18 -1
- package/dist/scenario.js +77 -27
- package/dist/scenario.js.map +1 -1
- package/dist/scenarios.d.ts +10 -1
- package/dist/scenarios.js +19 -4
- package/dist/scenarios.js.map +1 -1
- package/dist/schedules.d.ts +66 -0
- package/dist/schedules.js +158 -0
- package/dist/schedules.js.map +1 -0
- package/dist/schema.d.ts +6 -0
- package/dist/schema.js +13 -3
- package/dist/schema.js.map +1 -1
- package/dist/shared.d.ts +38 -2
- package/dist/shared.js +50 -4
- package/dist/shared.js.map +1 -1
- package/dist/sites.d.ts +38 -1
- package/dist/sites.js +153 -26
- package/dist/sites.js.map +1 -1
- package/dist/source.d.ts +61 -2
- package/dist/source.js.map +1 -1
- package/dist/supervisor.d.ts +38 -0
- package/dist/supervisor.js +77 -0
- package/dist/supervisor.js.map +1 -0
- package/dist/version.d.ts +3 -1
- package/dist/version.js +3 -1
- package/dist/version.js.map +1 -1
- package/dist/wakeup-service.d.ts +83 -0
- package/dist/wakeup-service.js +421 -0
- package/dist/wakeup-service.js.map +1 -0
- package/dist/wakeup.d.ts +84 -0
- package/dist/wakeup.js +87 -0
- package/dist/wakeup.js.map +1 -0
- package/dist/worker.d.ts +39 -1
- package/dist/worker.js +130 -39
- package/dist/worker.js.map +1 -1
- package/oxlint.json +2 -1
- package/package.json +8 -2
- package/src/apply.ts +25 -6
- package/src/approvals.ts +89 -16
- package/src/call.ts +40 -29
- package/src/cedar.ts +24 -6
- package/src/change.ts +222 -0
- package/src/chats.ts +114 -0
- package/src/client.ts +145 -55
- package/src/config.ts +110 -13
- package/src/database.ts +152 -0
- package/src/db.ts +108 -9
- package/src/deadlines.ts +234 -0
- package/src/define.ts +71 -4
- package/src/demo.ts +87 -0
- package/src/describe.ts +19 -7
- package/src/errors.ts +14 -0
- package/src/fake.ts +12 -0
- package/src/grid.ts +100 -0
- package/src/host.ts +13 -4
- package/src/index.ts +62 -3
- package/src/ledger.ts +15 -3
- package/src/lint.ts +1 -1
- package/src/op.ts +15 -2
- package/src/policy.ts +7 -1
- package/src/resource.ts +22 -3
- package/src/resources.ts +12 -1
- package/src/run.ts +5 -0
- package/src/sandbox.ts +97 -37
- package/src/scenario.ts +97 -28
- package/src/scenarios.ts +21 -5
- package/src/schedules.ts +206 -0
- package/src/schema.ts +16 -3
- package/src/shared.ts +62 -4
- package/src/sites.ts +198 -33
- package/src/source.ts +58 -3
- package/src/supervisor.ts +107 -0
- package/src/version.ts +3 -1
- package/src/wakeup-service.ts +511 -0
- package/src/wakeup.ts +150 -0
- package/src/worker.ts +181 -44
package/src/deadlines.ts
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { DBOS } from "@dbos-inc/dbos-sdk";
|
|
2
|
+
import { dbosStatusesOf } from "./client.ts";
|
|
3
|
+
import type { Db } from "./db.ts";
|
|
4
|
+
import { nextWakeupGeneration, recordWakeupError, recordWakeupRegistration, recordWakeupStop } from "./db.ts";
|
|
5
|
+
import type { ResolvedConfig } from "./config.ts";
|
|
6
|
+
import { warn } from "./log.ts";
|
|
7
|
+
import type { CallContext, DriverFn, Op } from "./op.ts";
|
|
8
|
+
import type { Secrets } from "./secret.ts";
|
|
9
|
+
import { errorMessage, WAKEUP_AGENT } from "./shared.ts";
|
|
10
|
+
import type { WakeupRegistration } from "./wakeup.ts";
|
|
11
|
+
|
|
12
|
+
// What a worker knows of its own runs that DBOS cannot tell it: which are executing and which are
|
|
13
|
+
// blocked in a `ctx` wait, and until when. DBOS sees both as PENDING. A worker that registers a
|
|
14
|
+
// wakeup stops itself once nothing can run before the next deadline (`stopWhenIdle`).
|
|
15
|
+
|
|
16
|
+
/** What a run blocked in a `ctx` wait waits for: a sleep, until its deadline, or an approval's decision, which has none. */
|
|
17
|
+
export type Wait = { kind: "sleep"; until: number } | { kind: "approval" };
|
|
18
|
+
|
|
19
|
+
/** A deadline besides the sleeping runs', such as a schedule's next fire; undefined for none. */
|
|
20
|
+
export type DeadlineSource = () => Date | undefined;
|
|
21
|
+
|
|
22
|
+
/** What a worker shows of its runs' deadlines (`Worker.deadlines`). */
|
|
23
|
+
export interface Deadlines {
|
|
24
|
+
/**
|
|
25
|
+
* Adds a deadline besides the sleeping runs', read each time `nextDeadline` is: a fixed time
|
|
26
|
+
* (a schedule's next fire), never one counted from now, which would never let the worker stop.
|
|
27
|
+
*/
|
|
28
|
+
addSource(source: DeadlineSource): void;
|
|
29
|
+
/** The earliest deadline: a sleeping run's `until`, or a source's. Undefined when there is none. */
|
|
30
|
+
nextDeadline(): Date | undefined;
|
|
31
|
+
/** How many runs are inside their body and in no wrapped wait: executing, or replaying. */
|
|
32
|
+
readonly busy: number;
|
|
33
|
+
/** What the run waits for while it is blocked in a wrapped wait; undefined otherwise. */
|
|
34
|
+
waitOf(runId: string): Wait | undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The registry a worker's runs report to, through `running` and `waiting`. */
|
|
38
|
+
export interface DeadlineRegistry extends Deadlines {
|
|
39
|
+
/** Runs a run's body, counted busy but while it is in `waiting`. */
|
|
40
|
+
running<T>(runId: string, body: () => Promise<T>): Promise<T>;
|
|
41
|
+
/** Runs `fn`, the live wait itself (`DBOS.sleep`, `DBOS.recv`), with the run counted as waiting for `wait`. */
|
|
42
|
+
waiting<T>(runId: string, wait: Wait, fn: () => Promise<T>): Promise<T>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A registry of its own for each worker: two copies of the package in one process never share one. */
|
|
46
|
+
export function deadlineRegistry(): DeadlineRegistry {
|
|
47
|
+
// Each run inside its body, with what it waits for while it is blocked.
|
|
48
|
+
const runs = new Map<string, Wait | undefined>();
|
|
49
|
+
const sources: DeadlineSource[] = [];
|
|
50
|
+
return {
|
|
51
|
+
addSource(source) {
|
|
52
|
+
sources.push(source);
|
|
53
|
+
},
|
|
54
|
+
nextDeadline() {
|
|
55
|
+
const times = [
|
|
56
|
+
...[...runs.values()].flatMap((wait) => (wait?.kind === "sleep" ? [wait.until] : [])),
|
|
57
|
+
...sources.flatMap((source) => source()?.getTime() ?? []),
|
|
58
|
+
];
|
|
59
|
+
return times.length ? new Date(Math.min(...times)) : undefined;
|
|
60
|
+
},
|
|
61
|
+
get busy() {
|
|
62
|
+
return [...runs.values()].filter((wait) => wait === undefined).length;
|
|
63
|
+
},
|
|
64
|
+
waitOf: (runId) => runs.get(runId),
|
|
65
|
+
async running(runId, body) {
|
|
66
|
+
runs.set(runId, undefined);
|
|
67
|
+
try {
|
|
68
|
+
return await body();
|
|
69
|
+
} finally {
|
|
70
|
+
runs.delete(runId);
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
async waiting(runId, wait, fn) {
|
|
74
|
+
// Every wait is inside a run's body; one outside is left uncounted rather than counted busy for good.
|
|
75
|
+
if (!runs.has(runId)) return fn();
|
|
76
|
+
runs.set(runId, wait);
|
|
77
|
+
try {
|
|
78
|
+
return await fn();
|
|
79
|
+
} finally {
|
|
80
|
+
if (runs.has(runId)) runs.set(runId, undefined);
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The worker `idle` asks about: the app, the version it runs, and its queue. */
|
|
87
|
+
export type IdleScope = Pick<ResolvedConfig, "appName" | "version" | "queueName">;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* True when a run on `queue` (none for a run started outside a queue) is this worker's to run: on
|
|
91
|
+
* its own queue, or on one of DBOS's, where recovery puts runs.
|
|
92
|
+
*/
|
|
93
|
+
export const onOwnQueue = (queue: string | undefined, queueName: string): boolean =>
|
|
94
|
+
!queue || queue === queueName || queue.startsWith("_dbos_");
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* True when nothing this worker would run can run within `horizonMs`: no run of its is executing
|
|
98
|
+
* or replaying; none of its version is queued (or queued without one, while it is the latest) on
|
|
99
|
+
* its queue or DBOS's own (recovery queues there); each PENDING run of its version is blocked in
|
|
100
|
+
* a wrapped wait; and the next deadline is past the horizon. A run of another version or queue
|
|
101
|
+
* is not this worker's to run, so it never holds the worker up.
|
|
102
|
+
*/
|
|
103
|
+
export async function idle(deadlines: Deadlines, scope: IdleScope, horizonMs: number): Promise<boolean> {
|
|
104
|
+
if (deadlines.busy > 0) return false;
|
|
105
|
+
const next = deadlines.nextDeadline();
|
|
106
|
+
if (next && next.getTime() <= Date.now() + horizonMs) return false;
|
|
107
|
+
const runs = await DBOS.listWorkflows({
|
|
108
|
+
applicationName: scope.appName,
|
|
109
|
+
status: dbosStatusesOf("queued", "running"),
|
|
110
|
+
loadInput: false,
|
|
111
|
+
loadOutput: false,
|
|
112
|
+
});
|
|
113
|
+
const ours = runs.filter((r) => onOwnQueue(r.queueName, scope.queueName));
|
|
114
|
+
if (
|
|
115
|
+
ours.some(
|
|
116
|
+
(r) => r.status === "PENDING" && r.applicationVersion === scope.version && !deadlines.waitOf(r.workflowID),
|
|
117
|
+
)
|
|
118
|
+
)
|
|
119
|
+
return false;
|
|
120
|
+
const queued = ours.filter((r) => r.status !== "PENDING");
|
|
121
|
+
if (queued.some((r) => r.applicationVersion === scope.version)) return false;
|
|
122
|
+
// DBOS gives a run queued without a version to the app's latest version.
|
|
123
|
+
if (!queued.some((r) => !r.applicationVersion)) return true;
|
|
124
|
+
return (await DBOS.getLatestApplicationVersion()).versionName !== scope.version;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** What `onStopped` hears when the worker stopped itself: the deadline it registered (null for none) and its generation. */
|
|
128
|
+
export interface SelfStop {
|
|
129
|
+
at: string | null;
|
|
130
|
+
generation: number;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** What the stop rule acts on: the wakeup row, the wakeup's register operation, and the worker. */
|
|
134
|
+
export interface StopRule {
|
|
135
|
+
/** The wakeup row's database. */
|
|
136
|
+
db: () => Promise<Db>;
|
|
137
|
+
/** This worker's id on the row (`owner`), fresh for each worker. */
|
|
138
|
+
owner: string;
|
|
139
|
+
/** The declared wakeup's binding, else its declaration's id: what scopes a registration's idempotency key to this instance. */
|
|
140
|
+
wakeup: string;
|
|
141
|
+
/** The wakeup type's register operation, and its driver (none when the connector attaches none). */
|
|
142
|
+
register: { op: Op; fn: DriverFn | undefined };
|
|
143
|
+
secrets: Secrets;
|
|
144
|
+
deadlines: Deadlines;
|
|
145
|
+
idle: () => Promise<boolean>;
|
|
146
|
+
everyMs: number;
|
|
147
|
+
/** Stops the worker (`Worker.stop`). */
|
|
148
|
+
stop: () => Promise<void>;
|
|
149
|
+
onStopped?: ((stopped: SelfStop) => void) | undefined;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Checks every `everyMs` whether the worker is `idle`; when it is, counts the wakeup row's
|
|
154
|
+
* generation up, registers the next deadline with the wakeup (none when there is no deadline at
|
|
155
|
+
* all), and stops the worker, then tells `onStopped`. A failed registration is recorded on the
|
|
156
|
+
* row and logged once for each new message, and the worker keeps running: the next check tries
|
|
157
|
+
* again with a new generation. A run that arrives while it registers keeps the worker up, since
|
|
158
|
+
* the worker must be idle twice, with the same deadline, before it stops. `close` ends the checks.
|
|
159
|
+
*/
|
|
160
|
+
export function stopWhenIdle(rule: StopRule): { close(): void } {
|
|
161
|
+
let closed = false;
|
|
162
|
+
let timer: NodeJS.Timeout | undefined;
|
|
163
|
+
let lastProblem: string | undefined;
|
|
164
|
+
const problem = (message: string) => {
|
|
165
|
+
if (message !== lastProblem) warn(message);
|
|
166
|
+
lastProblem = message;
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const tryStop = async (): Promise<boolean> => {
|
|
170
|
+
if (!(await rule.idle())) return false;
|
|
171
|
+
const next = rule.deadlines.nextDeadline();
|
|
172
|
+
const at = next?.toISOString() ?? null;
|
|
173
|
+
const db = await rule.db();
|
|
174
|
+
const generation = await nextWakeupGeneration(db, rule.owner);
|
|
175
|
+
if (generation === undefined) {
|
|
176
|
+
// A worker claims the row only when it starts, so this one never gets it back.
|
|
177
|
+
warn("another worker registers this instance's wakeups now, so this one keeps running and stops checking");
|
|
178
|
+
closed = true;
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
if (at !== null) {
|
|
182
|
+
try {
|
|
183
|
+
await registerWakeup(rule, { at, generation });
|
|
184
|
+
} catch (err) {
|
|
185
|
+
const message = errorMessage(err);
|
|
186
|
+
await recordWakeupError(db, rule.owner, generation, message);
|
|
187
|
+
problem(`could not register the wakeup at ${at}, so the worker keeps running: ${message}`);
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
await recordWakeupRegistration(db, rule.owner, generation, at);
|
|
191
|
+
}
|
|
192
|
+
lastProblem = undefined;
|
|
193
|
+
if (closed || !(await rule.idle()) || rule.deadlines.nextDeadline()?.toISOString() !== next?.toISOString())
|
|
194
|
+
return false;
|
|
195
|
+
await recordWakeupStop(db, rule.owner, generation);
|
|
196
|
+
await rule.stop().catch((err: unknown) => warn(`the worker could not stop cleanly: ${errorMessage(err)}`));
|
|
197
|
+
rule.onStopped?.({ at, generation });
|
|
198
|
+
return true;
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const check = async () => {
|
|
202
|
+
timer = undefined;
|
|
203
|
+
if (closed) return;
|
|
204
|
+
const stopped = await tryStop().catch((err: unknown) => {
|
|
205
|
+
problem(`could not check whether the worker may stop: ${errorMessage(err)}`);
|
|
206
|
+
return false;
|
|
207
|
+
});
|
|
208
|
+
if (!stopped && !closed) schedule();
|
|
209
|
+
};
|
|
210
|
+
const schedule = () => {
|
|
211
|
+
timer = setTimeout(() => void check(), rule.everyMs);
|
|
212
|
+
timer.unref();
|
|
213
|
+
};
|
|
214
|
+
schedule();
|
|
215
|
+
return {
|
|
216
|
+
close() {
|
|
217
|
+
closed = true;
|
|
218
|
+
clearTimeout(timer);
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Calls the wakeup's register operation, as `SanomaClient.call` calls a driver: its input parsed,
|
|
225
|
+
* a `CallContext` of the runtime's, and no ledger record, which is a run's. The idempotency key
|
|
226
|
+
* is the generation's, so a retried registration is one registration.
|
|
227
|
+
*/
|
|
228
|
+
async function registerWakeup({ register: { op, fn }, secrets, wakeup }: StopRule, registration: WakeupRegistration) {
|
|
229
|
+
if (!fn) throw new Error(`${op.id} has no driver: the connector package must attach one`);
|
|
230
|
+
// Every database counts generations from 0, so the key names the wakeup too.
|
|
231
|
+
const key = `wakeup:${wakeup}:${registration.generation}`;
|
|
232
|
+
const context: CallContext = { idempotencyKey: key, runId: key, opId: op.id, attempt: 1, secrets, by: WAKEUP_AGENT };
|
|
233
|
+
await fn(op.input.parse(registration), context);
|
|
234
|
+
}
|
package/src/define.ts
CHANGED
|
@@ -92,12 +92,23 @@ export interface ApprovalState extends Omit<ApprovalRequest, "covers"> {
|
|
|
92
92
|
input?: unknown;
|
|
93
93
|
/** For a policy request: the policies that held the call, by id, when the policy names them. */
|
|
94
94
|
reasons?: string[];
|
|
95
|
-
|
|
95
|
+
/**
|
|
96
|
+
* `withdrawn`: it no longer stands, as when its change got a new commit. A pending approval is
|
|
97
|
+
* withdrawn by a message (`SanomaClient.withdraw`), an approved one by its run
|
|
98
|
+
* (`withdrawApprovals`); either way it lets nothing through and takes no decision.
|
|
99
|
+
*/
|
|
100
|
+
status: "pending" | "approved" | "rejected" | "withdrawn";
|
|
101
|
+
/** Why it was withdrawn, once it is. */
|
|
102
|
+
withdrawnReason?: string;
|
|
103
|
+
/** When it was withdrawn, once it is. */
|
|
104
|
+
withdrawnAt?: number;
|
|
105
|
+
/** Who withdrew it, when someone did rather than its run (`withdrawApprovals`). */
|
|
106
|
+
withdrawnBy?: string;
|
|
96
107
|
requestedAt: number;
|
|
97
108
|
decidedBy?: string;
|
|
98
109
|
decidedAt?: number;
|
|
99
110
|
note?: string;
|
|
100
|
-
/** The `id` of the decision message the run decided with, when it had one. */
|
|
111
|
+
/** The `id` of the decision or withdrawal message the run decided with, when it had one. */
|
|
101
112
|
decidedWith?: string;
|
|
102
113
|
/** Set when the run's own actor decided it: nobody else could (`mayDecide`). */
|
|
103
114
|
alone?: true;
|
|
@@ -174,7 +185,7 @@ type CtxOps<O extends Op> = {
|
|
|
174
185
|
};
|
|
175
186
|
|
|
176
187
|
interface ApprovalCtx {
|
|
177
|
-
/** Waits, durably, until the approver (or someone in the approver group) approves. Throws `RejectedError` on a rejection. */
|
|
188
|
+
/** Waits, durably, until the approver (or someone in the approver group) approves. Throws `RejectedError` on a rejection, `WithdrawnError` on a withdrawal. */
|
|
178
189
|
approval(title: string, request: ApprovalRequest): Promise<ApprovalResult>;
|
|
179
190
|
}
|
|
180
191
|
|
|
@@ -235,11 +246,19 @@ export function jsonSchemaOf(
|
|
|
235
246
|
});
|
|
236
247
|
}
|
|
237
248
|
|
|
249
|
+
/**
|
|
250
|
+
* How a workflow starts: `manual` (by hand, or from code), or on a schedule, `schedule` a cron
|
|
251
|
+
* expression of five fields (minute, hour, day of month, month, day of week) read in `timezone`,
|
|
252
|
+
* an IANA time zone (`Europe/Amsterdam`), UTC without one. A scheduled run is started by the
|
|
253
|
+
* runtime's `schedule` agent with the input `{}`, as the policy allows; it can still be started by hand.
|
|
254
|
+
*/
|
|
255
|
+
export type Trigger = "manual" | { readonly schedule: string; readonly timezone?: string };
|
|
256
|
+
|
|
238
257
|
export interface WorkflowDefinition<U extends readonly Use[] = readonly Use[], S extends z.ZodType = z.ZodType> {
|
|
239
258
|
readonly kind: "workflow";
|
|
240
259
|
readonly name: string;
|
|
241
260
|
readonly title?: string;
|
|
242
|
-
readonly trigger:
|
|
261
|
+
readonly trigger: Trigger;
|
|
243
262
|
readonly input: S;
|
|
244
263
|
/** Every operation and built-in the workflow may call. `ctx` is built from this list and nothing else. */
|
|
245
264
|
readonly uses: U;
|
|
@@ -260,6 +279,13 @@ export function defineWorkflow<const U extends readonly Use[], S extends z.ZodTy
|
|
|
260
279
|
if (!/^[a-z][a-z0-9-]*$/.test(def.name)) {
|
|
261
280
|
throw new Error(`Workflow name "${def.name}" must be lowercase letters, digits and dashes`);
|
|
262
281
|
}
|
|
282
|
+
checkTrigger(def.name, def.trigger);
|
|
283
|
+
// A scheduled run starts with no input of its own.
|
|
284
|
+
if (def.trigger !== "manual" && !acceptsEmpty(def.input)) {
|
|
285
|
+
throw new Error(
|
|
286
|
+
`Workflow "${def.name}": a scheduled run starts with the input {}, which its input schema refuses: make each field optional or give it a default`,
|
|
287
|
+
);
|
|
288
|
+
}
|
|
263
289
|
const file = callerFile();
|
|
264
290
|
if (file === undefined) {
|
|
265
291
|
throw new Error(
|
|
@@ -270,6 +296,47 @@ export function defineWorkflow<const U extends readonly Use[], S extends z.ZodTy
|
|
|
270
296
|
return Object.freeze({ kind: "workflow", ...def, file });
|
|
271
297
|
}
|
|
272
298
|
|
|
299
|
+
/** True when `{}` parses: a schema zod can check only asynchronously is taken as accepting it, and the run parses it. */
|
|
300
|
+
function acceptsEmpty(schema: z.ZodType): boolean {
|
|
301
|
+
try {
|
|
302
|
+
return schema.safeParse({}).success;
|
|
303
|
+
} catch {
|
|
304
|
+
return true;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Throws what is wrong with a workflow's `trigger`: not `manual` or a schedule, five fields, in a time zone there is. */
|
|
309
|
+
function checkTrigger(name: string, trigger: unknown): void {
|
|
310
|
+
if (trigger === "manual") return;
|
|
311
|
+
const { schedule, timezone } = (typeof trigger === "object" && trigger !== null ? trigger : {}) as {
|
|
312
|
+
schedule?: unknown;
|
|
313
|
+
timezone?: unknown;
|
|
314
|
+
};
|
|
315
|
+
if (typeof schedule !== "string") {
|
|
316
|
+
throw new Error(`Workflow "${name}": its trigger is "manual" or { schedule, timezone? }`);
|
|
317
|
+
}
|
|
318
|
+
if (schedule.trim().split(/\s+/).length !== 5) {
|
|
319
|
+
throw new Error(
|
|
320
|
+
`Workflow "${name}": its schedule "${schedule}" is a cron expression of five fields: minute, hour, day of month, month, day of week`,
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
if (timezone !== undefined && !isTimeZone(timezone)) {
|
|
324
|
+
throw new Error(
|
|
325
|
+
`Workflow "${name}": its timezone "${String(timezone)}" is no IANA time zone, such as "Europe/Amsterdam"`,
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const isTimeZone = (zone: unknown): boolean => {
|
|
331
|
+
// An offset (`+05:30`), which Intl takes too, is no IANA zone name.
|
|
332
|
+
if (typeof zone !== "string" || /^[+-]/.test(zone)) return false;
|
|
333
|
+
try {
|
|
334
|
+
return Boolean(new Intl.DateTimeFormat("en", { timeZone: zone }));
|
|
335
|
+
} catch {
|
|
336
|
+
return false;
|
|
337
|
+
}
|
|
338
|
+
};
|
|
339
|
+
|
|
273
340
|
/**
|
|
274
341
|
* The file of the code that called the function calling this one (`defineWorkflow`'s caller),
|
|
275
342
|
* from V8's call sites, as a path; undefined where there is none.
|
package/src/demo.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { SanomaClient } from "./client.ts";
|
|
2
|
+
import type { ResolvedConfig } from "./config.ts";
|
|
3
|
+
import { DATA_TABLE } from "./db.ts";
|
|
4
|
+
import { applyGivens } from "./sandbox.ts";
|
|
5
|
+
import { loadScenarios, type Moment } from "./scenario.ts";
|
|
6
|
+
import { memorySecrets } from "./secret.ts";
|
|
7
|
+
import { errorMessage } from "./shared.ts";
|
|
8
|
+
|
|
9
|
+
/*
|
|
10
|
+
* A demo: a company repo run on its connectors' fakes, with the world its scenarios describe.
|
|
11
|
+
* Re-exported from `@sanoma-ai/workflows/scenarios`, server-side, since it loads Cucumber.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** What the last `reseed` of an instance did: what the demo's banner shows. */
|
|
15
|
+
export interface DemoState {
|
|
16
|
+
/** When it was seeded, as an ISO timestamp. */
|
|
17
|
+
seededAt: string;
|
|
18
|
+
/** The worlds applied, by scenario name. */
|
|
19
|
+
worlds: string[];
|
|
20
|
+
/** Each moment started and left waiting: who it is for, where they land, its scenario and its run. */
|
|
21
|
+
moments: (Moment & { scenario: string; runId: string })[];
|
|
22
|
+
/** What could not be done, one line each: a feature file that did not load, a world or a moment that failed. */
|
|
23
|
+
problems: string[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Each instance's last reseed, by its resolution. */
|
|
27
|
+
const states = new WeakMap<ResolvedConfig, DemoState>();
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Seeds the demo instance afresh: empties the collections' table, resets the instance's fakes,
|
|
31
|
+
* applies every world (a scenario with no `When`) to the instance (`applyGivens`: its records
|
|
32
|
+
* saved through the collections' driver, its vendor objects put in the fakes), then starts each
|
|
33
|
+
* moment as a sandbox run by its person, left undriven, so its first approval waits for them.
|
|
34
|
+
* `resolved` is the resolution the worker and `client` run on, made with `fakes: "instance"`.
|
|
35
|
+
* A world or a moment that fails is a problem in the state it returns, and the rest go on.
|
|
36
|
+
*/
|
|
37
|
+
export async function reseed(client: SanomaClient, resolved: ResolvedConfig): Promise<DemoState> {
|
|
38
|
+
const fakes = resolved.instanceFakes;
|
|
39
|
+
if (!fakes) throw new Error('reseed seeds an instance on fakes: resolve its config with `fakes: "instance"`');
|
|
40
|
+
// Read first, so scenarios that cannot be read at all leave the instance as it was.
|
|
41
|
+
const { scenarios, errors } = await loadScenarios(resolved);
|
|
42
|
+
if (resolved.dataStore) await (await resolved.dataStore.db()).query(`TRUNCATE ${DATA_TABLE}`);
|
|
43
|
+
for (const fake of fakes.values()) fake.reset();
|
|
44
|
+
const state: DemoState = {
|
|
45
|
+
seededAt: new Date().toISOString(),
|
|
46
|
+
worlds: [],
|
|
47
|
+
moments: [],
|
|
48
|
+
problems: errors.map((e) => e.message),
|
|
49
|
+
};
|
|
50
|
+
const secrets = memorySecrets();
|
|
51
|
+
for (const world of scenarios.filter((s) => s.workflow === undefined)) {
|
|
52
|
+
try {
|
|
53
|
+
await applyGivens(world, {
|
|
54
|
+
kind: "instance",
|
|
55
|
+
drivers: resolved.drivers,
|
|
56
|
+
fakes,
|
|
57
|
+
ops: resolved.ops,
|
|
58
|
+
call: (i, opId) => ({
|
|
59
|
+
idempotencyKey: `world:${world.name}:${i}`,
|
|
60
|
+
runId: `world:${world.name}`,
|
|
61
|
+
opId,
|
|
62
|
+
attempt: 1,
|
|
63
|
+
secrets,
|
|
64
|
+
// Who its records are by: whoever it says is acting, else the runtime's own agent.
|
|
65
|
+
by: world.startedBy ?? "scenarios",
|
|
66
|
+
}),
|
|
67
|
+
});
|
|
68
|
+
state.worlds.push(world.name);
|
|
69
|
+
} catch (err) {
|
|
70
|
+
state.problems.push(`world "${world.name}": ${errorMessage(err)}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
for (const s of scenarios) {
|
|
74
|
+
if (!s.moment || s.workflow === undefined) continue;
|
|
75
|
+
try {
|
|
76
|
+
const runId = await client.start(s.workflow, s.input, { sandbox: s.name, startedBy: s.moment.person });
|
|
77
|
+
state.moments.push({ ...s.moment, scenario: s.name, runId });
|
|
78
|
+
} catch (err) {
|
|
79
|
+
state.problems.push(`moment "${s.name}": ${errorMessage(err)}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
states.set(resolved, state);
|
|
83
|
+
return state;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** What the instance's last `reseed` did, or undefined before its first. */
|
|
87
|
+
export const demoState = (resolved: ResolvedConfig): DemoState | undefined => states.get(resolved);
|
package/src/describe.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { z } from "zod";
|
|
|
3
3
|
import { type LintProblem, readSource, type SourceFile } from "./ast.ts";
|
|
4
4
|
import { type Field, recordSchemaOf } from "./collection.ts";
|
|
5
5
|
import { connectorsOf, resolveConfig, rootOf, type SanomaConfig } from "./config.ts";
|
|
6
|
-
import { type Builtin, jsonSchemaOf, opIdsOf, type Use } from "./define.ts";
|
|
6
|
+
import { type Builtin, jsonSchemaOf, opIdsOf, type Trigger, type Use } from "./define.ts";
|
|
7
7
|
import { isDirect } from "./direct.ts";
|
|
8
8
|
import { lintWorkflow } from "./lint.ts";
|
|
9
9
|
import { type Effect, VENDOR, type VendorInfo } from "./op.ts";
|
|
@@ -63,11 +63,14 @@ export {
|
|
|
63
63
|
type Rebind,
|
|
64
64
|
} from "./lockfile.ts";
|
|
65
65
|
// What the sites call, read from their source: a workflow's Used by.
|
|
66
|
-
export { readSites, type SiteCall, type SiteEntry } from "./sites.ts";
|
|
66
|
+
export { readSites, type SiteCall, type SiteEntry, type SitePage, siteContentPages, siteLocales } from "./sites.ts";
|
|
67
67
|
// The generated SDK, `.sanoma/api.ts`: a site's `api` typed from this description.
|
|
68
68
|
export { API_FILE, generateApi, writeApi, writeGenerated } from "./sdk.ts";
|
|
69
69
|
// The Cedar schema, `.sanoma/schema.cedarschema`: what a company's policies are validated and decided against.
|
|
70
70
|
export { SCHEMA_FILE, schemaOf, schemaText, type CedarSchemaJson } from "./schema.ts";
|
|
71
|
+
// The Permissions grid: the app's page and `sanoma check`'s `checked` event compute it the one way.
|
|
72
|
+
export { columnPrincipal, permissionsGrid, type PermissionsGrid, type PermissionsRow } from "./grid.ts";
|
|
73
|
+
export { previewRequests } from "./shared.ts";
|
|
71
74
|
// The apply workflow's plan and report, which the app renders.
|
|
72
75
|
export type { ApplyInput, ApplyPlan, ApplyReport, ApplyResult, PlanAnswer, PlanStep } from "./apply.ts";
|
|
73
76
|
|
|
@@ -75,7 +78,8 @@ export type { ApplyInput, ApplyPlan, ApplyReport, ApplyResult, PlanAnswer, PlanS
|
|
|
75
78
|
export interface WorkflowEntry {
|
|
76
79
|
name: string;
|
|
77
80
|
title?: string;
|
|
78
|
-
trigger
|
|
81
|
+
/** `manual`, or the schedule it starts on (`defineWorkflow`'s `trigger`). */
|
|
82
|
+
trigger: Trigger;
|
|
79
83
|
/**
|
|
80
84
|
* Set for a workflow the runtime adds itself, `drift`, `apply` or `call`, rather than the config: a label. It
|
|
81
85
|
* starts like any other.
|
|
@@ -359,14 +363,22 @@ function domainProblems(
|
|
|
359
363
|
const others = `${names.length === 2 ? "both " : ""}${names.slice(0, -1).join(", ")}`;
|
|
360
364
|
messages.push(`${domain} is the domain of ${others} and ${names.at(-1)}`);
|
|
361
365
|
}
|
|
362
|
-
if (
|
|
363
|
-
const file = read(join(root, resource.file));
|
|
364
|
-
const at = typeof file === "string" ? {} : file.at(resource.span[0]);
|
|
365
|
-
for (const message of messages) problems.push({ file: resource.file, ...at, message });
|
|
366
|
+
if (root !== undefined) problems.push(...messages.map((message) => problemAt(root, resource, read, message)));
|
|
366
367
|
}
|
|
367
368
|
return problems;
|
|
368
369
|
}
|
|
369
370
|
|
|
371
|
+
/** `message` at a declaration's `export const`. */
|
|
372
|
+
function problemAt(
|
|
373
|
+
root: string,
|
|
374
|
+
resource: DeclaredResource,
|
|
375
|
+
read: (path: string) => SourceFile | string,
|
|
376
|
+
message: string,
|
|
377
|
+
): ResourceProblem {
|
|
378
|
+
const file = read(join(root, resource.file));
|
|
379
|
+
return { file: resource.file, ...(typeof file === "string" ? {} : file.at(resource.span[0])), message };
|
|
380
|
+
}
|
|
381
|
+
|
|
370
382
|
/**
|
|
371
383
|
* A site's `api` names each workflow `apiNameOf(name)` beside the vendors' operations and the
|
|
372
384
|
* collections (`api.data`), so a workflow whose name comes out as another's, a vendor's id or
|
package/src/errors.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { CALL_WORKFLOW, errorMessage } from "./shared.ts";
|
|
|
6
6
|
const CODES = [
|
|
7
7
|
"policy_denied",
|
|
8
8
|
"approval_rejected",
|
|
9
|
+
"approval_withdrawn",
|
|
9
10
|
"not_approver",
|
|
10
11
|
"no_pending_approval",
|
|
11
12
|
"already_decided",
|
|
@@ -132,6 +133,19 @@ export class RejectedError extends SanomaError {
|
|
|
132
133
|
}
|
|
133
134
|
}
|
|
134
135
|
|
|
136
|
+
/** Thrown into the run when the approval it waits on is withdrawn. The run fails with it unless it catches it. */
|
|
137
|
+
export class WithdrawnError extends SanomaError {
|
|
138
|
+
/**
|
|
139
|
+
* @param title The approval's title.
|
|
140
|
+
* @param approvalId The approval's id in the run, such as "approval-2".
|
|
141
|
+
* @param reason Why it was withdrawn.
|
|
142
|
+
*/
|
|
143
|
+
constructor(title: string, approvalId: string, reason: string) {
|
|
144
|
+
super("approval_withdrawn", `"${title}" was withdrawn: ${reason}`, { title, approvalId, reason });
|
|
145
|
+
this.name = "WithdrawnError";
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
135
149
|
/**
|
|
136
150
|
* The wrapper, given the original's own `code` and `data` when it has one of our codes, so
|
|
137
151
|
* `errorCode` reads the same through it: a denied call whose ledger write failed too is still
|
package/src/fake.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
DriverError,
|
|
8
8
|
type OpIdOf,
|
|
9
9
|
type Specs,
|
|
10
|
+
withSetup,
|
|
10
11
|
} from "./op.ts";
|
|
11
12
|
|
|
12
13
|
/*
|
|
@@ -154,3 +155,14 @@ export function defineFake<V extends string, S extends Specs, T extends Record<s
|
|
|
154
155
|
},
|
|
155
156
|
};
|
|
156
157
|
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* `connector` set up with this fake instead of the one its package attaches, for a test or a demo
|
|
161
|
+
* that seeds or reads the fake its runs call: live runs call its driver, and sandbox runs call it
|
|
162
|
+
* too, since its maker gives this very one. A driver swap alone is `withSetup(connector, { driver })`,
|
|
163
|
+
* which keeps the package's fake, so each sandbox run has one of its own. The operations are the
|
|
164
|
+
* same.
|
|
165
|
+
*/
|
|
166
|
+
export function withFake<C extends Connector<any, any>>(connector: C, fake: Fake<any, any>): C {
|
|
167
|
+
return withSetup(connector, { driver: () => fake.driver, fake: () => fake });
|
|
168
|
+
}
|
package/src/grid.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type { ResolvedConfig } from "./config.ts";
|
|
2
|
+
import type { Principal } from "./define.ts";
|
|
3
|
+
import type { ConfigDescription } from "./describe.ts";
|
|
4
|
+
import type { PreviewAnswer, PreviewRequest } from "./policy.ts";
|
|
5
|
+
import {
|
|
6
|
+
AGENTS,
|
|
7
|
+
ANONYMOUS,
|
|
8
|
+
ANONYMOUS_PRINCIPAL,
|
|
9
|
+
groupNamesOf,
|
|
10
|
+
previewRequests,
|
|
11
|
+
VISITORS,
|
|
12
|
+
visitorPrincipal,
|
|
13
|
+
} from "./shared.ts";
|
|
14
|
+
|
|
15
|
+
// The Permissions grid: what the policy would answer each group, for every workflow's start, the
|
|
16
|
+
// operations it may call, and each direct operation. A pure function of a resolved config's policy
|
|
17
|
+
// and its description, so the app's Permissions page and `sanoma check` (a draft's grid, on its
|
|
18
|
+
// `checked` event) compute it the one way, and a change is reviewed as one grid against another.
|
|
19
|
+
|
|
20
|
+
/** A row of the grid: a workflow (its start) or an operation, and the policy's answer for each column. */
|
|
21
|
+
export interface PermissionsRow {
|
|
22
|
+
id: string;
|
|
23
|
+
title?: string;
|
|
24
|
+
cells: PreviewAnswer[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The grid: its columns (`null` for someone in no group, then the declared groups, `agents` and
|
|
29
|
+
* `visitors`, sorted, then `anonymous`), a row for each workflow's start, with a row for each
|
|
30
|
+
* operation it uses (`ops`, in its order), and a row for each operation a person may call
|
|
31
|
+
* directly, each with a cell per column.
|
|
32
|
+
*/
|
|
33
|
+
export interface PermissionsGrid {
|
|
34
|
+
groups: (string | null)[];
|
|
35
|
+
workflows: (PermissionsRow & { ops: PermissionsRow[] })[];
|
|
36
|
+
ops: PermissionsRow[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Whom the `agents` column's agent acts for: a hold `@hold("on-behalf-of")` asks them. Only an
|
|
41
|
+
* agent acts for someone, so in any other column that hold refuses, as it does a person.
|
|
42
|
+
*/
|
|
43
|
+
const ACTS_FOR = "whom it acts for";
|
|
44
|
+
|
|
45
|
+
/** Whom a column of the grid asks about: someone in the group alone, a visitor, or `anonymous`. */
|
|
46
|
+
export function columnPrincipal(group: string | null): Principal {
|
|
47
|
+
if (group === null) return { id: "group:", groups: [] };
|
|
48
|
+
if (group === ANONYMOUS) return ANONYMOUS_PRINCIPAL;
|
|
49
|
+
if (group === VISITORS) return visitorPrincipal("grid", "visitor");
|
|
50
|
+
return { id: `group:${group}`, groups: [group], ...(group === AGENTS && { onBehalfOf: ACTS_FOR }) };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The grid of `resolved`'s policy over `description`: for someone in no group, then for each
|
|
55
|
+
* group, the declared ones, `agents` and `visitors` (the visitors' only beside a public site),
|
|
56
|
+
* then for `anonymous`, what the policy would answer someone in it alone (`columnPrincipal`), the
|
|
57
|
+
* input unknown (`Policy.preview`), for the start of each of the config's own workflows, by name,
|
|
58
|
+
* each with the operations it uses, in the order it declares them (`previewRequests`), then each
|
|
59
|
+
* operation a person may call directly, by id.
|
|
60
|
+
*/
|
|
61
|
+
export function permissionsGrid(
|
|
62
|
+
{ policy }: Pick<ResolvedConfig, "policy">,
|
|
63
|
+
description: ConfigDescription,
|
|
64
|
+
): PermissionsGrid {
|
|
65
|
+
// A visitor's columns only beside a public site, where there are visitors.
|
|
66
|
+
const visitors = description.sites.some((site) => site.public);
|
|
67
|
+
const groups = [
|
|
68
|
+
null,
|
|
69
|
+
...groupNamesOf(description.resources).filter((g) => visitors || g !== VISITORS),
|
|
70
|
+
...(visitors ? [ANONYMOUS] : []),
|
|
71
|
+
];
|
|
72
|
+
const opsById = new Map(description.ops.map((op) => [op.id, op]));
|
|
73
|
+
const workflows = description.workflows.filter((w) => !w.builtin);
|
|
74
|
+
const direct = description.ops.filter((op) => op.direct).toSorted((a, b) => a.id.localeCompare(b.id));
|
|
75
|
+
// Every row's question, in the grid's order: each workflow's start, then the operations it may
|
|
76
|
+
// call, as the run form asks them; then each direct operation.
|
|
77
|
+
const asked = workflows.map((w) => previewRequests(w, opsById));
|
|
78
|
+
const requests: Omit<PreviewRequest, "principal">[] = [
|
|
79
|
+
...asked.flat(),
|
|
80
|
+
...direct.map(({ id, effect }) => ({ action: { id, effect } })),
|
|
81
|
+
];
|
|
82
|
+
const columns = groups.map((group) => {
|
|
83
|
+
const principal = columnPrincipal(group);
|
|
84
|
+
return policy.preview(requests.map((request) => ({ ...request, principal })));
|
|
85
|
+
});
|
|
86
|
+
const rows = requests.map(({ action: { id } }, i): PermissionsRow => ({
|
|
87
|
+
id,
|
|
88
|
+
cells: columns.map((answers) => answers[i]!),
|
|
89
|
+
}));
|
|
90
|
+
// Each workflow's rows, as many as it asked: its start, then one per operation it may call.
|
|
91
|
+
let at = 0;
|
|
92
|
+
return {
|
|
93
|
+
groups,
|
|
94
|
+
workflows: workflows.map(({ name, title }, i) => {
|
|
95
|
+
const [start, ...called] = rows.slice(at, (at += asked[i]!.length));
|
|
96
|
+
return { ...start!, id: name, ...(title !== undefined && { title }), ops: called };
|
|
97
|
+
}),
|
|
98
|
+
ops: rows.slice(at),
|
|
99
|
+
};
|
|
100
|
+
}
|