@sanoma-ai/workflows 0.6.0 → 0.7.1
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 +52 -14
- 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 +75 -8
- package/dist/config.js +103 -13
- package/dist/config.js.map +1 -1
- package/dist/constructors.d.ts +37 -1
- package/dist/constructors.js +6 -2
- package/dist/constructors.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 +19 -1
- package/dist/db.js +51 -7
- package/dist/db.js.map +1 -1
- package/dist/deadlines.d.ts +91 -0
- package/dist/deadlines.js +165 -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 +5 -2
- package/dist/describe.js +10 -6
- 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 +34 -0
- package/dist/fake.js +134 -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 +12 -2
- package/dist/host.js +2 -2
- package/dist/host.js.map +1 -1
- package/dist/index.d.ts +15 -8
- package/dist/index.js +10 -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 +2 -2
- package/dist/lint.js.map +1 -1
- package/dist/op.d.ts +21 -2
- package/dist/op.js.map +1 -1
- package/dist/person.d.ts +5 -0
- package/dist/person.js +3 -1
- package/dist/person.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 +15 -4
- 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 +30 -2
- package/dist/shared.js +42 -4
- package/dist/shared.js.map +1 -1
- package/dist/source.d.ts +185 -2
- package/dist/source.js +9 -0
- package/dist/source.js.map +1 -1
- package/dist/supervisor.d.ts +39 -0
- package/dist/supervisor.js +68 -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-driver.d.ts +128 -0
- package/dist/wakeup-driver.js +216 -0
- package/dist/wakeup-driver.js.map +1 -0
- package/dist/wakeup-service.d.ts +83 -0
- package/dist/wakeup-service.js +415 -0
- package/dist/wakeup-service.js.map +1 -0
- package/dist/wakeup-type.d.ts +43 -0
- package/dist/wakeup-type.js +80 -0
- package/dist/wakeup-type.js.map +1 -0
- package/dist/wakeup.d.ts +77 -0
- package/dist/wakeup.js +78 -0
- package/dist/wakeup.js.map +1 -0
- package/dist/worker.d.ts +39 -1
- package/dist/worker.js +125 -38
- package/dist/worker.js.map +1 -1
- package/oxlint.json +3 -1
- package/package.json +10 -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 +149 -21
- package/src/constructors.ts +8 -2
- package/src/database.ts +152 -0
- package/src/db.ts +58 -9
- package/src/deadlines.ts +228 -0
- package/src/define.ts +71 -4
- package/src/demo.ts +87 -0
- package/src/describe.ts +18 -6
- package/src/errors.ts +14 -0
- package/src/fake.ts +149 -0
- package/src/grid.ts +100 -0
- package/src/host.ts +12 -4
- package/src/index.ts +66 -3
- package/src/ledger.ts +15 -3
- package/src/lint.ts +2 -2
- package/src/op.ts +22 -2
- package/src/person.ts +4 -1
- package/src/policy.ts +7 -1
- package/src/resource.ts +22 -3
- package/src/resources.ts +17 -4
- 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 +51 -4
- package/src/source.ts +176 -3
- package/src/supervisor.ts +102 -0
- package/src/version.ts +3 -1
- package/src/wakeup-driver.ts +270 -0
- package/src/wakeup-service.ts +504 -0
- package/src/wakeup-type.ts +90 -0
- package/src/wakeup.ts +139 -0
- package/src/worker.ts +176 -43
package/src/deadlines.ts
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { DBOS } from "@dbos-inc/dbos-sdk";
|
|
3
|
+
import { dbosStatusesOf } from "./client.ts";
|
|
4
|
+
import type { Db } from "./db.ts";
|
|
5
|
+
import { recordWakeupError, recordWakeupRegistration, recordWakeupStop } from "./db.ts";
|
|
6
|
+
import type { ResolvedConfig } from "./config.ts";
|
|
7
|
+
import { warn } from "./log.ts";
|
|
8
|
+
import type { CallContext, DriverFn, Op } from "./op.ts";
|
|
9
|
+
import type { Secrets } from "./secret.ts";
|
|
10
|
+
import { errorMessage, WAKEUP_AGENT } from "./shared.ts";
|
|
11
|
+
import type { WakeupRegistration } from "./wakeup.ts";
|
|
12
|
+
|
|
13
|
+
// What a worker knows of its own runs that DBOS cannot tell it: which are executing and which are
|
|
14
|
+
// blocked in a `ctx` wait, and until when. DBOS sees both as PENDING. A worker that registers a
|
|
15
|
+
// wakeup stops itself once nothing can run before the next deadline (`stopWhenIdle`).
|
|
16
|
+
|
|
17
|
+
/** What a run blocked in a `ctx` wait waits for: a sleep, until its deadline, or an approval's decision, which has none. */
|
|
18
|
+
export type Wait = { kind: "sleep"; until: number } | { kind: "approval" };
|
|
19
|
+
|
|
20
|
+
/** A deadline besides the sleeping runs', such as a schedule's next fire; undefined for none. */
|
|
21
|
+
export type DeadlineSource = () => Date | undefined;
|
|
22
|
+
|
|
23
|
+
/** What a worker shows of its runs' deadlines (`Worker.deadlines`). */
|
|
24
|
+
export interface Deadlines {
|
|
25
|
+
/**
|
|
26
|
+
* Adds a deadline besides the sleeping runs', read each time `nextDeadline` is: a fixed time
|
|
27
|
+
* (a schedule's next fire), never one counted from now, which would never let the worker stop.
|
|
28
|
+
*/
|
|
29
|
+
addSource(source: DeadlineSource): void;
|
|
30
|
+
/** The earliest deadline: a sleeping run's `until`, or a source's. Undefined when there is none. */
|
|
31
|
+
nextDeadline(): Date | undefined;
|
|
32
|
+
/** How many runs are inside their body and in no wrapped wait: executing, or replaying. */
|
|
33
|
+
readonly busy: number;
|
|
34
|
+
/** What the run waits for while it is blocked in a wrapped wait; undefined otherwise. */
|
|
35
|
+
waitOf(runId: string): Wait | undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The registry a worker's runs report to, through `running` and `waiting`. */
|
|
39
|
+
export interface DeadlineRegistry extends Deadlines {
|
|
40
|
+
/** Runs a run's body, counted busy but while it is in `waiting`. */
|
|
41
|
+
running<T>(runId: string, body: () => Promise<T>): Promise<T>;
|
|
42
|
+
/** Runs `fn`, the live wait itself (`DBOS.sleep`, `DBOS.recv`), with the run counted as waiting for `wait`. */
|
|
43
|
+
waiting<T>(runId: string, wait: Wait, fn: () => Promise<T>): Promise<T>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A registry of its own for each worker: two copies of the package in one process never share one. */
|
|
47
|
+
export function deadlineRegistry(): DeadlineRegistry {
|
|
48
|
+
// Each run inside its body, with what it waits for while it is blocked.
|
|
49
|
+
const runs = new Map<string, Wait | undefined>();
|
|
50
|
+
const sources: DeadlineSource[] = [];
|
|
51
|
+
return {
|
|
52
|
+
addSource(source) {
|
|
53
|
+
sources.push(source);
|
|
54
|
+
},
|
|
55
|
+
nextDeadline() {
|
|
56
|
+
const times = [
|
|
57
|
+
...[...runs.values()].flatMap((wait) => (wait?.kind === "sleep" ? [wait.until] : [])),
|
|
58
|
+
...sources.flatMap((source) => source()?.getTime() ?? []),
|
|
59
|
+
];
|
|
60
|
+
return times.length ? new Date(Math.min(...times)) : undefined;
|
|
61
|
+
},
|
|
62
|
+
get busy() {
|
|
63
|
+
return [...runs.values()].filter((wait) => wait === undefined).length;
|
|
64
|
+
},
|
|
65
|
+
waitOf: (runId) => runs.get(runId),
|
|
66
|
+
async running(runId, body) {
|
|
67
|
+
runs.set(runId, undefined);
|
|
68
|
+
try {
|
|
69
|
+
return await body();
|
|
70
|
+
} finally {
|
|
71
|
+
runs.delete(runId);
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
async waiting(runId, wait, fn) {
|
|
75
|
+
// Every wait is inside a run's body; one outside is left uncounted rather than counted busy for good.
|
|
76
|
+
if (!runs.has(runId)) return fn();
|
|
77
|
+
runs.set(runId, wait);
|
|
78
|
+
try {
|
|
79
|
+
return await fn();
|
|
80
|
+
} finally {
|
|
81
|
+
if (runs.has(runId)) runs.set(runId, undefined);
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The worker `idle` asks about: the app, the version it runs, and its queue. */
|
|
88
|
+
export type IdleScope = Pick<ResolvedConfig, "appName" | "version" | "queueName">;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* True when a run on `queue` (none for a run started outside a queue) is this worker's to run: on
|
|
92
|
+
* its own queue, or on one of DBOS's, where recovery puts runs.
|
|
93
|
+
*/
|
|
94
|
+
export const onOwnQueue = (queue: string | undefined, queueName: string): boolean =>
|
|
95
|
+
!queue || queue === queueName || queue.startsWith("_dbos_");
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* True when nothing this worker would run can run within `horizonMs`: no run of its is executing
|
|
99
|
+
* or replaying; none of its version is queued (or queued without one, while it is the latest) on
|
|
100
|
+
* its queue or DBOS's own (recovery queues there); each PENDING run of its version is blocked in
|
|
101
|
+
* a wrapped wait; and the next deadline is past the horizon. A run of another version or queue
|
|
102
|
+
* is not this worker's to run, so it never holds the worker up.
|
|
103
|
+
*/
|
|
104
|
+
export async function idle(deadlines: Deadlines, scope: IdleScope, horizonMs: number): Promise<boolean> {
|
|
105
|
+
if (deadlines.busy > 0) return false;
|
|
106
|
+
const next = deadlines.nextDeadline();
|
|
107
|
+
if (next && next.getTime() <= Date.now() + horizonMs) return false;
|
|
108
|
+
const runs = await DBOS.listWorkflows({
|
|
109
|
+
applicationName: scope.appName,
|
|
110
|
+
status: dbosStatusesOf("queued", "running"),
|
|
111
|
+
loadInput: false,
|
|
112
|
+
loadOutput: false,
|
|
113
|
+
});
|
|
114
|
+
const ours = runs.filter((r) => onOwnQueue(r.queueName, scope.queueName));
|
|
115
|
+
if (
|
|
116
|
+
ours.some(
|
|
117
|
+
(r) => r.status === "PENDING" && r.applicationVersion === scope.version && !deadlines.waitOf(r.workflowID),
|
|
118
|
+
)
|
|
119
|
+
)
|
|
120
|
+
return false;
|
|
121
|
+
const queued = ours.filter((r) => r.status !== "PENDING");
|
|
122
|
+
if (queued.some((r) => r.applicationVersion === scope.version)) return false;
|
|
123
|
+
// DBOS gives a run queued without a version to the app's latest version.
|
|
124
|
+
if (!queued.some((r) => !r.applicationVersion)) return true;
|
|
125
|
+
return (await DBOS.getLatestApplicationVersion()).versionName !== scope.version;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** What `onStopped` hears when the worker stopped itself: the deadline it registered, null for none. */
|
|
129
|
+
export interface SelfStop {
|
|
130
|
+
at: string | null;
|
|
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
|
+
/** The declared wakeup's binding, else its declaration's id: what scopes a registration's idempotency key to this instance. */
|
|
138
|
+
wakeup: string;
|
|
139
|
+
/** The wakeup type's register operation, and its driver (none when the connector attaches none). */
|
|
140
|
+
register: { op: Op; fn: DriverFn | undefined };
|
|
141
|
+
secrets: Secrets;
|
|
142
|
+
deadlines: Deadlines;
|
|
143
|
+
idle: () => Promise<boolean>;
|
|
144
|
+
everyMs: number;
|
|
145
|
+
/** Stops the worker (`Worker.stop`). */
|
|
146
|
+
stop: () => Promise<void>;
|
|
147
|
+
onStopped?: ((stopped: SelfStop) => void) | undefined;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Checks every `everyMs` whether the worker is `idle`; when it is, registers the next deadline
|
|
152
|
+
* with the wakeup, replacing what it held (none when there is no deadline at all), and stops the
|
|
153
|
+
* worker, then tells `onStopped`. A failed registration is recorded on the row and logged once for
|
|
154
|
+
* each new message, and the worker keeps running: the next check tries again. A run that arrives
|
|
155
|
+
* while it registers keeps the worker up, since the worker must be idle twice, with the same
|
|
156
|
+
* deadline, before it stops. `close` ends the checks.
|
|
157
|
+
*/
|
|
158
|
+
export function stopWhenIdle(rule: StopRule): { close(): void } {
|
|
159
|
+
let closed = false;
|
|
160
|
+
let timer: NodeJS.Timeout | undefined;
|
|
161
|
+
let lastProblem: string | undefined;
|
|
162
|
+
const problem = (message: string) => {
|
|
163
|
+
if (message !== lastProblem) warn(message);
|
|
164
|
+
lastProblem = message;
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const tryStop = async (): Promise<boolean> => {
|
|
168
|
+
if (!(await rule.idle())) return false;
|
|
169
|
+
const next = rule.deadlines.nextDeadline();
|
|
170
|
+
const at = next?.toISOString() ?? null;
|
|
171
|
+
const db = await rule.db();
|
|
172
|
+
if (at !== null) {
|
|
173
|
+
try {
|
|
174
|
+
await registerWakeup(rule, { at });
|
|
175
|
+
} catch (err) {
|
|
176
|
+
const message = errorMessage(err);
|
|
177
|
+
await recordWakeupError(db, message);
|
|
178
|
+
problem(`could not register the wakeup at ${at}, so the worker keeps running: ${message}`);
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
await recordWakeupRegistration(db, at);
|
|
182
|
+
}
|
|
183
|
+
lastProblem = undefined;
|
|
184
|
+
if (closed || !(await rule.idle()) || rule.deadlines.nextDeadline()?.toISOString() !== next?.toISOString())
|
|
185
|
+
return false;
|
|
186
|
+
await recordWakeupStop(db);
|
|
187
|
+
await rule.stop().catch((err: unknown) => warn(`the worker could not stop cleanly: ${errorMessage(err)}`));
|
|
188
|
+
rule.onStopped?.({ at });
|
|
189
|
+
return true;
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const check = async () => {
|
|
193
|
+
timer = undefined;
|
|
194
|
+
if (closed) return;
|
|
195
|
+
const stopped = await tryStop().catch((err: unknown) => {
|
|
196
|
+
problem(`could not check whether the worker may stop: ${errorMessage(err)}`);
|
|
197
|
+
return false;
|
|
198
|
+
});
|
|
199
|
+
if (!stopped && !closed) schedule();
|
|
200
|
+
};
|
|
201
|
+
const schedule = () => {
|
|
202
|
+
timer = setTimeout(() => void check(), rule.everyMs);
|
|
203
|
+
timer.unref();
|
|
204
|
+
};
|
|
205
|
+
schedule();
|
|
206
|
+
return {
|
|
207
|
+
close() {
|
|
208
|
+
closed = true;
|
|
209
|
+
clearTimeout(timer);
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Calls the wakeup's register operation, as `SanomaClient.call` calls a driver: its input parsed,
|
|
216
|
+
* a `CallContext` of the runtime's, and no ledger record, which is a run's.
|
|
217
|
+
*/
|
|
218
|
+
export async function registerWakeup(
|
|
219
|
+
{ register: { op, fn }, secrets, wakeup }: Pick<StopRule, "register" | "secrets" | "wakeup">,
|
|
220
|
+
registration: WakeupRegistration,
|
|
221
|
+
) {
|
|
222
|
+
if (!fn) throw new Error(`${op.id} has no driver: the connector package must attach one`);
|
|
223
|
+
// Fresh each time: a repeat of an earlier `at` after another registration must re-arm it, so no
|
|
224
|
+
// two registrations share a key (real drivers never dedupe on it anyway).
|
|
225
|
+
const key = `wakeup:${wakeup}:${randomUUID()}`;
|
|
226
|
+
const context: CallContext = { idempotencyKey: key, runId: key, opId: op.id, attempt: 1, secrets, by: WAKEUP_AGENT };
|
|
227
|
+
await fn(op.input.parse(registration), context);
|
|
228
|
+
}
|
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";
|
|
@@ -68,6 +68,9 @@ export { readSites, type SiteCall, type SiteEntry, type SitePage, siteContentPag
|
|
|
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,7 +7,11 @@ import {
|
|
|
7
7
|
DriverError,
|
|
8
8
|
type OpIdOf,
|
|
9
9
|
type Specs,
|
|
10
|
+
withSetup,
|
|
10
11
|
} from "./op.ts";
|
|
12
|
+
import { fail, type Instance, UNREGISTERED, type WakeupApi, wakeupCalls } from "./wakeup-driver.ts";
|
|
13
|
+
import { INSTANCE_TOKEN, instanceUrl } from "./wakeup-service.ts";
|
|
14
|
+
import { wakeupOps } from "./wakeup-type.ts";
|
|
11
15
|
|
|
12
16
|
/*
|
|
13
17
|
* Fake vendors for tests, at `@sanoma-ai/workflows/fake`. Kept off the main entry so the
|
|
@@ -154,3 +158,148 @@ export function defineFake<V extends string, S extends Specs, T extends Record<s
|
|
|
154
158
|
},
|
|
155
159
|
};
|
|
156
160
|
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* `connector` set up with this fake instead of the one its package attaches, for a test or a demo
|
|
164
|
+
* that seeds or reads the fake its runs call: live runs call its driver, and sandbox runs call it
|
|
165
|
+
* too, since its maker gives this very one. A driver swap alone is `withSetup(connector, { driver })`,
|
|
166
|
+
* which keeps the package's fake, so each sandbox run has one of its own. The operations are the
|
|
167
|
+
* same.
|
|
168
|
+
*/
|
|
169
|
+
export function withFake<C extends Connector<any, any>>(connector: C, fake: Fake<any, any>): C {
|
|
170
|
+
return withSetup(connector, { driver: () => fake.driver, fake: () => fake });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// The built-in `sanoma.wakeup`'s fake: an in-memory wakeup service that calls the host's `/wake`.
|
|
174
|
+
|
|
175
|
+
/** An instance of the fake wakeup service, as it keeps one. */
|
|
176
|
+
interface FakeInstance extends Instance {
|
|
177
|
+
token: string;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
interface FakeWakeupState extends Record<string, unknown> {
|
|
181
|
+
seq: number;
|
|
182
|
+
instances: Record<string, FakeInstance>;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** How long the fake waits before it sends a wake again that the host did not answer. */
|
|
186
|
+
const RETRY_MS = 250;
|
|
187
|
+
/** setTimeout's longest delay: a longer one fires at once. */
|
|
188
|
+
const MAX_DELAY_MS = 2 ** 31 - 1;
|
|
189
|
+
|
|
190
|
+
/** The URL as the service keeps it (`instanceUrl`, its own rule), or the service's 400. */
|
|
191
|
+
function checkedUrl(value: string): string {
|
|
192
|
+
const checked = instanceUrl(value);
|
|
193
|
+
if ("error" in checked) throw fail(checked.error, 400);
|
|
194
|
+
return checked.url;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* An in-memory wakeup service for tests, as `sanoma wakeup` answers: instances in
|
|
199
|
+
* `state.instances`, ids made in order (UUID-shaped), each named by the token it was posted with
|
|
200
|
+
* (the call's `SANOMA_WAKEUP_TOKEN`), so posting it again moves it. `register` takes only the
|
|
201
|
+
* instance's own token and replaces the wake it had; it then sends `GET {host}/wake` at `at`,
|
|
202
|
+
* again every 250 ms until the host answers 2xx. `fire()` sends every armed wake now; `close()`
|
|
203
|
+
* (and `reset()`) disarms them.
|
|
204
|
+
*/
|
|
205
|
+
export function fakeSanomaWakeup(options: FakeOptions = {}) {
|
|
206
|
+
/** The wake each instance waits to send, by its id. */
|
|
207
|
+
const armed = new Map<string, { at: number; url: string; timeout?: NodeJS.Timeout; sending?: boolean }>();
|
|
208
|
+
const disarm = (id: string) => {
|
|
209
|
+
clearTimeout(armed.get(id)?.timeout);
|
|
210
|
+
armed.delete(id);
|
|
211
|
+
};
|
|
212
|
+
/** Sends `id`'s armed wake once it is due, while it is still the one armed, until the host answers. */
|
|
213
|
+
const send = async (id: string, pending = armed.get(id)) => {
|
|
214
|
+
// One request at a time: the one in flight sends again if it is not answered.
|
|
215
|
+
if (!pending || armed.get(id) !== pending || pending.sending) return;
|
|
216
|
+
const due = pending.at - Date.now();
|
|
217
|
+
if (due > 0) {
|
|
218
|
+
pending.timeout = setTimeout(() => void send(id, pending), Math.min(due, MAX_DELAY_MS)).unref();
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
pending.sending = true;
|
|
222
|
+
const answered = await fetch(`${pending.url}/wake`).then(
|
|
223
|
+
(res) => res.ok,
|
|
224
|
+
() => false,
|
|
225
|
+
);
|
|
226
|
+
pending.sending = false;
|
|
227
|
+
if (armed.get(id) !== pending) return;
|
|
228
|
+
if (answered) armed.delete(id);
|
|
229
|
+
else pending.timeout = setTimeout(() => void send(id, pending), RETRY_MS).unref();
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const api = (state: FakeWakeupState): WakeupApi => ({
|
|
233
|
+
async put(url, token) {
|
|
234
|
+
const checked = checkedUrl(url);
|
|
235
|
+
if (!INSTANCE_TOKEN.test(token)) throw fail("token must be an instance's token", 400);
|
|
236
|
+
const mine = Object.values(state.instances).find((i) => i.token === token);
|
|
237
|
+
if (mine) {
|
|
238
|
+
// The same instance, moved: its wake there forgotten.
|
|
239
|
+
if (mine.url !== checked) {
|
|
240
|
+
Object.assign(mine, { url: checked, ...UNREGISTERED });
|
|
241
|
+
disarm(mine.id);
|
|
242
|
+
}
|
|
243
|
+
return { id: mine.id };
|
|
244
|
+
}
|
|
245
|
+
const id = `00000000-0000-4000-8000-${String(++state.seq).padStart(12, "0")}`;
|
|
246
|
+
state.instances[id] = { id, url: checked, ...UNREGISTERED, token };
|
|
247
|
+
return { id };
|
|
248
|
+
},
|
|
249
|
+
async get(id) {
|
|
250
|
+
const found = state.instances[id];
|
|
251
|
+
if (!found) return undefined;
|
|
252
|
+
const { token: _, ...shown } = found;
|
|
253
|
+
return shown;
|
|
254
|
+
},
|
|
255
|
+
async remove(id) {
|
|
256
|
+
delete state.instances[id];
|
|
257
|
+
disarm(id);
|
|
258
|
+
},
|
|
259
|
+
async wake({ at }, token) {
|
|
260
|
+
// The token names the instance.
|
|
261
|
+
const mine = Object.values(state.instances).find((i) => i.token === token);
|
|
262
|
+
if (!mine) throw fail("the token is no instance's", 401);
|
|
263
|
+
Object.assign(mine, { at: new Date(at).toISOString(), lastError: null });
|
|
264
|
+
disarm(mine.id);
|
|
265
|
+
armed.set(mine.id, { at: Date.parse(at), url: mine.url });
|
|
266
|
+
void send(mine.id);
|
|
267
|
+
},
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
const fake = defineFake(
|
|
271
|
+
wakeupOps,
|
|
272
|
+
{
|
|
273
|
+
initial: (): FakeWakeupState => ({ seq: 0, instances: {} }),
|
|
274
|
+
ops: (state) => ({ wakeup: wakeupCalls(() => api(state)) }),
|
|
275
|
+
},
|
|
276
|
+
options,
|
|
277
|
+
);
|
|
278
|
+
const close = () => {
|
|
279
|
+
for (const id of armed.keys()) disarm(id);
|
|
280
|
+
};
|
|
281
|
+
const { reset } = fake;
|
|
282
|
+
return Object.assign(fake, {
|
|
283
|
+
/** Sends every armed wake now, whatever its `at`; resolves once the hosts answered them, or fails after 60 s. */
|
|
284
|
+
async fire() {
|
|
285
|
+
const fired = [...armed].map(([id, pending]) => {
|
|
286
|
+
clearTimeout(pending.timeout);
|
|
287
|
+
pending.at = 0;
|
|
288
|
+
void send(id, pending);
|
|
289
|
+
return pending;
|
|
290
|
+
});
|
|
291
|
+
const until = Date.now() + 60_000;
|
|
292
|
+
while (fired.some((p) => [...armed.values()].includes(p))) {
|
|
293
|
+
if (Date.now() > until) throw new Error("sanoma.wakeup fake: a fired wake was not answered in 60 s");
|
|
294
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
295
|
+
}
|
|
296
|
+
},
|
|
297
|
+
/** Disarms every armed wake. */
|
|
298
|
+
close,
|
|
299
|
+
/** Empties the state and disarms every armed wake. */
|
|
300
|
+
reset() {
|
|
301
|
+
close();
|
|
302
|
+
reset();
|
|
303
|
+
},
|
|
304
|
+
});
|
|
305
|
+
}
|