@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/wakeup.ts
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import type { z } from "zod";
|
|
2
|
+
import { hasPath } from "./host.ts";
|
|
3
|
+
import type { Op } from "./op.ts";
|
|
4
|
+
import type { References, Resource } from "./resource.ts";
|
|
5
|
+
import type { DeclaredResource } from "./resources.ts";
|
|
6
|
+
|
|
7
|
+
// The `Wakeup` contract: what a resource type that wakes a stopped instance at a time declares
|
|
8
|
+
// about itself, beside `Host`. Declaring one in a data file is the whole switch: the worker then
|
|
9
|
+
// registers its next deadline with it before it stops, and the wakeup calls the host's
|
|
10
|
+
// `GET {url}/wake` then: a wake is "resume if stopped", so one that arrives late, twice or for a
|
|
11
|
+
// replaced registration starts a worker that finds nothing to do and stops again. The contract is
|
|
12
|
+
// here; the worker's side is `deadlines.ts`.
|
|
13
|
+
|
|
14
|
+
/** What a wakeup's state says of it. */
|
|
15
|
+
export interface WakeupOutputs {
|
|
16
|
+
healthy: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** What the runtime calls a wakeup's `register` operation with. */
|
|
20
|
+
export interface WakeupRegistration {
|
|
21
|
+
/** When to wake the instance, as an ISO 8601 date-time. */
|
|
22
|
+
at: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A resource type's `wakeup`: the field it names the host in, the operation the runtime
|
|
27
|
+
* registers each deadline through, and how its state reads.
|
|
28
|
+
*/
|
|
29
|
+
export interface WakeupSpec {
|
|
30
|
+
inputs: {
|
|
31
|
+
/**
|
|
32
|
+
* The dotted path of the declared field that names the host's declaration: a
|
|
33
|
+
* `fields.references` entry, resolved `by` a field of the host's state holding its URL.
|
|
34
|
+
*/
|
|
35
|
+
host: string;
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* The id of the type's own register operation, `<vendor>.<type>.<op>`: an operation in the
|
|
39
|
+
* type's own group of its connector, beside `read` and `import` (the way Supabase adds
|
|
40
|
+
* `project.health` beside `project`). The runtime calls it with `WakeupRegistration`,
|
|
41
|
+
* at least once per registration; each replaces whatever the wakeup held.
|
|
42
|
+
*/
|
|
43
|
+
register: string;
|
|
44
|
+
outputs: (state: Record<string, unknown>) => WakeupOutputs;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The operations `defineResource` gives a type itself: never its register, or a database's `storeUrl`. */
|
|
48
|
+
export const RESOURCE_OPS = new Set(["read", "import", "plan", "create", "update", "delete"]);
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Throws what is wrong with a resource type's `wakeup`: a type apply cannot change (not
|
|
52
|
+
* `writable`), a host (stopped when its wake is due), a `host` that names no field or no
|
|
53
|
+
* reference resolved `by` a field, a `register` that is not an operation of the type's own group
|
|
54
|
+
* or is one of its resource operations, `outputs` that is not a function. Whether the connector
|
|
55
|
+
* has that operation, and the reference names a host, is `checkWakeupOps`'s, which
|
|
56
|
+
* `resolveConfig` runs.
|
|
57
|
+
*/
|
|
58
|
+
export function checkWakeup(
|
|
59
|
+
what: string,
|
|
60
|
+
wakeup: WakeupSpec,
|
|
61
|
+
type: {
|
|
62
|
+
vendor: string;
|
|
63
|
+
type: string;
|
|
64
|
+
schema: z.ZodObject;
|
|
65
|
+
fields: { references?: References };
|
|
66
|
+
writable?: boolean;
|
|
67
|
+
host?: unknown;
|
|
68
|
+
},
|
|
69
|
+
): void {
|
|
70
|
+
const problems: string[] = [];
|
|
71
|
+
if (!type.writable) problems.push("it must be writable, so apply can create and update it");
|
|
72
|
+
if (type.host) problems.push("a host cannot be its own wakeup: it is stopped when the wake is due");
|
|
73
|
+
const host = wakeup.inputs?.host;
|
|
74
|
+
const reference = typeof host === "string" ? type.fields.references?.[host] : undefined;
|
|
75
|
+
if (typeof host !== "string" || !hasPath(type.schema, host)) problems.push(`inputs.host names no field (${host})`);
|
|
76
|
+
else if (!reference) problems.push(`inputs.host must be a reference to the host (fields.references), not ${host}`);
|
|
77
|
+
else if (!reference.by?.length)
|
|
78
|
+
problems.push("inputs.host must be resolved `by` the field of the host's state that holds its URL");
|
|
79
|
+
const group = `${type.vendor}.${type.type}.`;
|
|
80
|
+
const register: unknown = wakeup.register;
|
|
81
|
+
const name = typeof register === "string" && register.startsWith(group) ? register.slice(group.length) : "";
|
|
82
|
+
if (!/^\w+$/.test(name))
|
|
83
|
+
problems.push(`register must name an operation of its own group, ${group}<op> (${String(register)})`);
|
|
84
|
+
else if (RESOURCE_OPS.has(name)) problems.push(`register must be an operation of its own, not the type's ${name}`);
|
|
85
|
+
if (typeof wakeup.outputs !== "function") problems.push("outputs must be a function of the state");
|
|
86
|
+
if (problems.length) throw new Error(`${what}: its wakeup: ${problems.join("; ")}`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** A registration every register operation must take: the input `checkWakeupOps` tries. */
|
|
90
|
+
const SAMPLE: WakeupRegistration = { at: new Date(0).toISOString() };
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* True when `op`'s input takes `sample` and keeps each of its fields: one that strips a field
|
|
94
|
+
* takes none of it. A wakeup's `register` is checked with it.
|
|
95
|
+
*/
|
|
96
|
+
function keepsInput(op: Op, sample: Record<string, unknown>): boolean {
|
|
97
|
+
try {
|
|
98
|
+
const parsed = op.input.safeParse(sample);
|
|
99
|
+
const data = parsed.data as Record<string, unknown> | undefined;
|
|
100
|
+
return parsed.success && Object.keys(sample).every((key) => data?.[key] !== undefined);
|
|
101
|
+
} catch {
|
|
102
|
+
// An async refinement, which a sync parse cannot run: no input the runtime can check.
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The host types a wakeup type may name: its `inputs.host` reference's. */
|
|
108
|
+
export const wakeupHostTypes = (type: Pick<Resource, "fields" | "wakeup">): string[] =>
|
|
109
|
+
type.wakeup ? [type.fields.references?.[type.wakeup.inputs.host]?.type ?? []].flat() : [];
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Throws when a wakeup type's host reference names a type of the config that is no host, or its
|
|
113
|
+
* `register` is not among the config's operations, or does not take
|
|
114
|
+
* `WakeupRegistration`. Here rather than in `defineConnector`, which sees one connector before
|
|
115
|
+
* its package merges in the operations it adds to a resource's group (Supabase's pattern).
|
|
116
|
+
*/
|
|
117
|
+
export function checkWakeupOps(types: ReadonlyMap<string, Resource>, ops: ReadonlyMap<string, Op>): void {
|
|
118
|
+
for (const [id, type] of types) {
|
|
119
|
+
if (!type.wakeup) continue;
|
|
120
|
+
// The types it may name that the config has, each a host; one it lacks no data file can declare.
|
|
121
|
+
const notHosts = wakeupHostTypes(type).filter((t) => types.has(t) && !types.get(t)!.host);
|
|
122
|
+
if (notHosts.length) throw new Error(`${id}: its wakeup's host may name ${notHosts.join(", ")}, which is no host`);
|
|
123
|
+
const op = ops.get(type.wakeup.register);
|
|
124
|
+
if (!op) throw new Error(`${id}: its wakeup's register, ${type.wakeup.register}, is no operation of its connector`);
|
|
125
|
+
if (!keepsInput(op, { ...SAMPLE })) throw new Error(`${id}: its wakeup's register, ${op.id}, must take { at }`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The one wakeup the data files declare: its declaration, its type, and its binding when it is bound. */
|
|
130
|
+
export interface DeclaredWakeup {
|
|
131
|
+
/** `<file>#<export>`, as `DeclaredResource.id`. */
|
|
132
|
+
id: string;
|
|
133
|
+
type: Resource;
|
|
134
|
+
declared: DeclaredResource;
|
|
135
|
+
/** The vendor's id of the object the lockfile binds it to; absent when unbound. */
|
|
136
|
+
binding?: string;
|
|
137
|
+
/** The declared host its `inputs.host` names: its declaration's id and its type, a host's (`checkWakeupOps`). */
|
|
138
|
+
host?: { id: string; type: Resource };
|
|
139
|
+
}
|
package/src/worker.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { DBOS } from "@dbos-inc/dbos-sdk";
|
|
1
|
+
import { DBOS, type ScheduledWorkflowFn } from "@dbos-inc/dbos-sdk";
|
|
2
2
|
import { awaitApproval } from "./approvals.ts";
|
|
3
3
|
import { buildCtx, decideStart, isInfrastructureError, startHoldOf } from "./call.ts";
|
|
4
4
|
import { dbosStatusesOf } from "./client.ts";
|
|
5
|
-
import { type ResolvedConfig, resolveConfig, type SanomaConfig } from "./config.ts";
|
|
5
|
+
import { declaredWakeup, type ResolvedConfig, resolveConfig, type SanomaConfig } from "./config.ts";
|
|
6
6
|
import { storedSecrets, watchCredentials } from "./credentials.ts";
|
|
7
7
|
import { DBOS_DATABASE_OPTIONS, lazyPool, prepareDatabase } from "./db.ts";
|
|
8
|
+
import { deadlineRegistry, type Deadlines, idle, onOwnQueue, type SelfStop, stopWhenIdle } from "./deadlines.ts";
|
|
8
9
|
import type { WorkflowDefinition } from "./define.ts";
|
|
9
10
|
import { credentialsOf, unconfigured, unconfiguredSummary } from "./env.ts";
|
|
10
11
|
import { errorInfo, parseOrThrow, PolicyDeniedError } from "./errors.ts";
|
|
@@ -13,8 +14,9 @@ import { dbosLogger, quietIfSandbox, setLogLevel, warn } from "./log.ts";
|
|
|
13
14
|
import { lineStartsOf, readSource } from "./ast.ts";
|
|
14
15
|
import { callsOf, outlineWithSource } from "./outline.ts";
|
|
15
16
|
import { currentRun, type Run, type RunArgs, type WorkerState, type WorkflowOutline } from "./run.ts";
|
|
17
|
+
import { applyScheduled, nextScheduledFire, type ScheduledStarter, scheduledStarter } from "./schedules.ts";
|
|
16
18
|
import type { Source } from "./source.ts";
|
|
17
|
-
import { errorMessage, WORKFLOW_ACTION } from "./shared.ts";
|
|
19
|
+
import { approverLabel, errorMessage, WORKFLOW_ACTION } from "./shared.ts";
|
|
18
20
|
|
|
19
21
|
export interface Worker {
|
|
20
22
|
/**
|
|
@@ -23,14 +25,36 @@ export interface Worker {
|
|
|
23
25
|
* The worker names them in one line at start, unless started `quiet`.
|
|
24
26
|
*/
|
|
25
27
|
readonly unconfigured: readonly string[];
|
|
28
|
+
/**
|
|
29
|
+
* Its runs' deadlines: each sleeping run's, and the sources added here (a schedule's next
|
|
30
|
+
* fire), which a stop registers the earliest of with the wakeup.
|
|
31
|
+
*/
|
|
32
|
+
readonly deadlines: Deadlines;
|
|
33
|
+
/**
|
|
34
|
+
* True when nothing this worker would run can run soon (`idle` in deadlines.ts): no run executing,
|
|
35
|
+
* none queued, every unfinished one blocked in a sleep or an approval, the next deadline past
|
|
36
|
+
* `idleHorizonMs`. False once stopped.
|
|
37
|
+
*/
|
|
38
|
+
idle(): Promise<boolean>;
|
|
39
|
+
/** Stops the worker; a second call waits for the first. */
|
|
26
40
|
stop(): Promise<void>;
|
|
27
41
|
}
|
|
28
42
|
|
|
43
|
+
/**
|
|
44
|
+
* How many times DBOS may start a run again before it gives up on it
|
|
45
|
+
* (`MAX_RECOVERY_ATTEMPTS_EXCEEDED`). Every start of a worker counts one for each unfinished run
|
|
46
|
+
* of its version, and a worker that stops when idle starts again at each wake and each request:
|
|
47
|
+
* at one every five minutes, DBOS's default of 100 would fail a run waiting on an approval within
|
|
48
|
+
* nine hours. 100,000 is a year of that; a run that crashes its process at every recovery is still
|
|
49
|
+
* given up on, after as many restarts.
|
|
50
|
+
*/
|
|
51
|
+
const MAX_RECOVERY_ATTEMPTS = 100_000;
|
|
52
|
+
|
|
29
53
|
// DBOS registrations are process-wide and survive shutdown, so a name is registered once, and
|
|
30
54
|
// its function reads the state of the worker running when a run starts, definition and all; the
|
|
31
|
-
// run keeps that state.
|
|
55
|
+
// run keeps that state. Each registered function, by name, is what a schedule fires.
|
|
32
56
|
let current: WorkerState | undefined;
|
|
33
|
-
const registered = new
|
|
57
|
+
const registered = new Map<string, ScheduledWorkflowFn>();
|
|
34
58
|
|
|
35
59
|
export interface WorkerOptions {
|
|
36
60
|
/** The least severe level of the runtime's and DBOS's messages to show, for the whole process: `warn` by default. */
|
|
@@ -50,6 +74,31 @@ export interface WorkerOptions {
|
|
|
50
74
|
promote?: boolean;
|
|
51
75
|
/** The company's repository, which apply commits the lockfile to; the launcher passes its checkout. */
|
|
52
76
|
source?: Source;
|
|
77
|
+
/**
|
|
78
|
+
* How soon a deadline keeps the worker up (`idle`): 60 s by default, so apply's short sleeps
|
|
79
|
+
* and other short waits never stop it.
|
|
80
|
+
*/
|
|
81
|
+
idleHorizonMs?: number;
|
|
82
|
+
/** How often a worker whose data files declare a wakeup checks whether it may stop: every 15 s by default. */
|
|
83
|
+
stopCheckMs?: number;
|
|
84
|
+
/**
|
|
85
|
+
* Given, and with a wakeup declared, the worker stops itself when idle (`stopCheckMs`); without
|
|
86
|
+
* it, nobody would start another, so it never does. Called once the worker has stopped itself, with the deadline it registered with the
|
|
87
|
+
* wakeup (null for none): a supervisor starts a fresh worker on the next write or wake, since a
|
|
88
|
+
* stopped one cannot launch again.
|
|
89
|
+
*/
|
|
90
|
+
onStopped?: (stopped: SelfStop) => void;
|
|
91
|
+
/**
|
|
92
|
+
* Apply the workflows' schedules (`trigger: { schedule }`): on by default. `runScenarios` turns
|
|
93
|
+
* it off, on a database of its own, so no schedule is applied there to start a live run.
|
|
94
|
+
*/
|
|
95
|
+
schedules?: boolean;
|
|
96
|
+
/**
|
|
97
|
+
* The config as the caller resolved it (`resolveConfig(config, options)`), for a worker that
|
|
98
|
+
* shares its drivers with the caller: the instance fakes of `fakes: "instance"`, which a demo's
|
|
99
|
+
* client and reseed call too. The caller closes its `dataStore`, as it made it.
|
|
100
|
+
*/
|
|
101
|
+
resolved?: ResolvedConfig;
|
|
53
102
|
}
|
|
54
103
|
|
|
55
104
|
/**
|
|
@@ -61,16 +110,29 @@ export interface WorkerOptions {
|
|
|
61
110
|
*/
|
|
62
111
|
export async function startWorker(config: SanomaConfig, options: WorkerOptions = {}): Promise<Worker> {
|
|
63
112
|
// Check everything before touching the state a running worker reads.
|
|
64
|
-
const resolved = resolveConfig(config);
|
|
113
|
+
const resolved = options.resolved ?? resolveConfig(config);
|
|
65
114
|
setLogLevel(options.logLevel);
|
|
115
|
+
// Read once, when it matters: a worker stops itself, registering its next deadline there, only
|
|
116
|
+
// when its caller hears it stop (`onStopped`).
|
|
117
|
+
const wakeup = options.onStopped || config.wakeup === true ? await declaredWakeup(config) : undefined;
|
|
118
|
+
// The wakeup service wakes the instances that stop: one that runs it must not stop with its worker.
|
|
119
|
+
if (config.wakeup === true && wakeup) {
|
|
120
|
+
throw new Error(
|
|
121
|
+
"The config runs the wakeup service (`wakeup: true`) and declares a wakeup of its own: an instance that runs the service must not stop, so leave out one of them",
|
|
122
|
+
);
|
|
123
|
+
}
|
|
66
124
|
// The config's own, or a Postgres store made for this worker, which `stop` then closes.
|
|
67
125
|
const ledger = ownedLedger(resolved);
|
|
68
126
|
const secrets = lazyPool(resolved.databaseUrl, "secrets");
|
|
127
|
+
let stopRule: { close(): void } | undefined;
|
|
128
|
+
let stopping: Promise<void> | undefined;
|
|
129
|
+
const horizonMs = options.idleHorizonMs ?? 60_000;
|
|
69
130
|
const state: WorkerState = {
|
|
70
131
|
app: resolved.appName,
|
|
71
132
|
ops: resolved.ops,
|
|
72
133
|
drivers: resolved.drivers,
|
|
73
134
|
fakes: resolved.fakes,
|
|
135
|
+
...(resolved.instanceFakes && { instanceFakes: resolved.instanceFakes }),
|
|
74
136
|
...(resolved.scenarios && { scenarios: resolved.scenarios }),
|
|
75
137
|
...(options.source && { source: options.source }),
|
|
76
138
|
types: resolved.types,
|
|
@@ -83,26 +145,38 @@ export async function startWorker(config: SanomaConfig, options: WorkerOptions =
|
|
|
83
145
|
workflows: resolved.workflows,
|
|
84
146
|
outlines: new Map([...resolved.workflows].map(([name, wf]) => [name, outlineOf(wf)])),
|
|
85
147
|
stopped: false,
|
|
148
|
+
deadlines: deadlineRegistry(),
|
|
86
149
|
};
|
|
87
150
|
const credentials = await watchCredentials(resolved.databaseUrl, resolved.driversByVendor);
|
|
88
151
|
const worker: Worker = {
|
|
89
152
|
unconfigured: [...credentialsOf(resolved.driversByVendor, (name) => process.env[name])]
|
|
90
153
|
.filter(([vendor, statuses]) => unconfigured(vendor, statuses))
|
|
91
154
|
.map(([vendor]) => vendor),
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
155
|
+
deadlines: state.deadlines,
|
|
156
|
+
async idle() {
|
|
157
|
+
return !state.stopped && idle(state.deadlines, resolved, horizonMs);
|
|
158
|
+
},
|
|
159
|
+
stop() {
|
|
160
|
+
// A stop that failed may be tried again.
|
|
161
|
+
return (stopping ??= (async () => {
|
|
162
|
+
state.stopped = true;
|
|
163
|
+
stopRule?.close();
|
|
164
|
+
// Each is closed whatever the one before did; the first failure is what stop throws.
|
|
165
|
+
let failed: { error: unknown } | undefined;
|
|
166
|
+
for (const close of [
|
|
167
|
+
() => DBOS.shutdown(),
|
|
168
|
+
() => credentials.close(),
|
|
169
|
+
() => secrets.close(),
|
|
170
|
+
() => ledger.close(),
|
|
171
|
+
async () => (options.resolved ? undefined : resolved.dataStore?.close()),
|
|
172
|
+
]) {
|
|
173
|
+
await close().catch((error: unknown) => (failed ??= { error }));
|
|
174
|
+
}
|
|
175
|
+
if (failed) {
|
|
176
|
+
stopping = undefined;
|
|
177
|
+
throw failed.error;
|
|
178
|
+
}
|
|
179
|
+
})());
|
|
106
180
|
},
|
|
107
181
|
};
|
|
108
182
|
// Runs recovered during launch start before it returns, so they must already find this
|
|
@@ -112,10 +186,7 @@ export async function startWorker(config: SanomaConfig, options: WorkerOptions =
|
|
|
112
186
|
let launched = false;
|
|
113
187
|
try {
|
|
114
188
|
for (const name of resolved.workflows.keys()) {
|
|
115
|
-
if (!registered.has(name))
|
|
116
|
-
register(name);
|
|
117
|
-
registered.add(name);
|
|
118
|
-
}
|
|
189
|
+
if (!registered.has(name)) registered.set(name, register(name));
|
|
119
190
|
}
|
|
120
191
|
current = state;
|
|
121
192
|
// Before DBOS's pool connects, so its connections start with the database's idle-transaction
|
|
@@ -135,6 +206,7 @@ export async function startWorker(config: SanomaConfig, options: WorkerOptions =
|
|
|
135
206
|
// DBOS gives runs queued without a version only to the app's latest version, which is the
|
|
136
207
|
// newest one registered. A worker started on code seen before (a rollback) is not it.
|
|
137
208
|
const latest = (await DBOS.getLatestApplicationVersion()).versionName;
|
|
209
|
+
const isLatest = latest === resolved.version || options.promote === true;
|
|
138
210
|
if (latest !== resolved.version) {
|
|
139
211
|
if (options.promote) await DBOS.setLatestApplicationVersion(resolved.version);
|
|
140
212
|
else {
|
|
@@ -145,12 +217,34 @@ export async function startWorker(config: SanomaConfig, options: WorkerOptions =
|
|
|
145
217
|
}
|
|
146
218
|
}
|
|
147
219
|
await DBOS.registerQueue(resolved.queueName);
|
|
220
|
+
// The schedules are the latest version's: an older worker leaves them as the newer set them.
|
|
221
|
+
if (isLatest && options.schedules !== false) {
|
|
222
|
+
await applyScheduled(resolved, registered, () => secrets.db());
|
|
223
|
+
// A schedule's next tick is a deadline: a worker that stops when quiet registers it with its wakeup.
|
|
224
|
+
state.deadlines.addSource(() => nextScheduledFire(resolved, new Date()));
|
|
225
|
+
}
|
|
148
226
|
await warnAboutStrandedRuns(resolved);
|
|
149
227
|
const summary = unconfiguredSummary(worker.unconfigured, resolved.driversByVendor.size);
|
|
150
228
|
if (summary && !options.quiet) warn(summary);
|
|
151
229
|
if (resolved.policyProblems.length && !options.quiet) {
|
|
152
230
|
warn(`the policies do not hold, so every call and start is refused:\n ${resolved.policyProblems.join("\n ")}`);
|
|
153
231
|
}
|
|
232
|
+
if (wakeup && options.onStopped) {
|
|
233
|
+
const registerId = wakeup.type.wakeup!.register;
|
|
234
|
+
stopRule = stopWhenIdle({
|
|
235
|
+
// The row is one more of the runtime's tables: the secrets' pool, on the same database.
|
|
236
|
+
db: () => secrets.db(),
|
|
237
|
+
wakeup: wakeup.binding ?? wakeup.id,
|
|
238
|
+
// `resolveConfig` checked the operation is the config's (`checkWakeupOps`).
|
|
239
|
+
register: { op: resolved.ops.get(registerId)!, fn: resolved.drivers.get(registerId) },
|
|
240
|
+
secrets: state.secrets,
|
|
241
|
+
deadlines: state.deadlines,
|
|
242
|
+
idle: () => worker.idle(),
|
|
243
|
+
everyMs: options.stopCheckMs ?? 15_000,
|
|
244
|
+
stop: () => worker.stop(),
|
|
245
|
+
onStopped: options.onStopped,
|
|
246
|
+
});
|
|
247
|
+
}
|
|
154
248
|
} catch (err) {
|
|
155
249
|
// Once DBOS is launched, the caller gets no worker to stop: stop it here, so no run goes on
|
|
156
250
|
// in a worker nobody holds, and the next startWorker launches afresh.
|
|
@@ -202,9 +296,7 @@ async function warnAboutStrandedRuns({ appName, version, queueName }: ResolvedCo
|
|
|
202
296
|
});
|
|
203
297
|
const otherVersion = runs.filter((r) => r.applicationVersion && r.applicationVersion !== version);
|
|
204
298
|
// Recovery moves a worker's own runs to DBOS's internal queues, which are fine.
|
|
205
|
-
const otherQueue = runs.filter(
|
|
206
|
-
(r) => !otherVersion.includes(r) && r.queueName && r.queueName !== queueName && !r.queueName.startsWith("_dbos_"),
|
|
207
|
-
);
|
|
299
|
+
const otherQueue = runs.filter((r) => !otherVersion.includes(r) && !onOwnQueue(r.queueName, queueName));
|
|
208
300
|
if (!otherVersion.length && !otherQueue.length) return;
|
|
209
301
|
const ids = [...otherVersion, ...otherQueue].map((r) => r.workflowID);
|
|
210
302
|
warn(
|
|
@@ -216,22 +308,46 @@ async function warnAboutStrandedRuns({ appName, version, queueName }: ResolvedCo
|
|
|
216
308
|
);
|
|
217
309
|
}
|
|
218
310
|
|
|
219
|
-
/**
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
311
|
+
/**
|
|
312
|
+
* Registers the name with DBOS: its runs take the definition of that name from the running worker.
|
|
313
|
+
* A start passes its `RunArgs`; a schedule's tick passes the time it is for (and DBOS's context).
|
|
314
|
+
*/
|
|
315
|
+
function register(name: string): ScheduledWorkflowFn {
|
|
316
|
+
const fn = DBOS.registerWorkflow(
|
|
317
|
+
(start: RunArgs | Date) =>
|
|
318
|
+
start instanceof Date
|
|
319
|
+
? runWorkflow(name, start)
|
|
320
|
+
: quietIfSandbox(start.sandbox !== undefined, () => runWorkflow(name, start)),
|
|
321
|
+
{ name, maxRecoveryAttempts: MAX_RECOVERY_ATTEMPTS },
|
|
322
|
+
);
|
|
323
|
+
return fn as unknown as ScheduledWorkflowFn;
|
|
224
324
|
}
|
|
225
325
|
|
|
226
|
-
/**
|
|
227
|
-
|
|
326
|
+
/**
|
|
327
|
+
* One run of the workflow registered as `name`, on the state of the worker running when it
|
|
328
|
+
* starts, counted busy in its deadlines but while it waits: started with its `RunArgs`, or by its
|
|
329
|
+
* schedule for the time `start`, as the `schedule` agent with the input `{}`, the policy deciding
|
|
330
|
+
* that start at the top of the run.
|
|
331
|
+
*/
|
|
332
|
+
async function runWorkflow(name: string, start: RunArgs | Date): Promise<unknown> {
|
|
228
333
|
const state = current;
|
|
229
334
|
// A stopped worker's state stays current while DBOS shuts down.
|
|
230
335
|
if (!state || state.stopped) throw new Error(`Run of "${name}" started with no worker running`);
|
|
336
|
+
return state.deadlines.running(DBOS.workflowID!, () => runOn(state, name, start));
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async function runOn(state: WorkerState, name: string, start: RunArgs | Date): Promise<unknown> {
|
|
231
340
|
const wf = state.workflows.get(name);
|
|
232
341
|
const outline = state.outlines.get(name);
|
|
233
342
|
// Registered by an earlier worker in this process, whose config had it; this one's has not.
|
|
234
343
|
if (!wf || !outline) throw new Error(`Run of "${name}" refused: this worker's config has no workflow of that name`);
|
|
344
|
+
let scheduled: ({ at: Date } & ScheduledStarter) | undefined;
|
|
345
|
+
let args: RunArgs;
|
|
346
|
+
if (start instanceof Date) {
|
|
347
|
+
scheduled = { at: start, ...(await scheduledStarter(state)) };
|
|
348
|
+
args = { input: {}, startedBy: scheduled.startedBy };
|
|
349
|
+
} else args = start;
|
|
350
|
+
const { input, startedBy, sandbox, hold } = args;
|
|
235
351
|
const run: Run = {
|
|
236
352
|
id: DBOS.workflowID!,
|
|
237
353
|
workflow: wf.name,
|
|
@@ -245,25 +361,42 @@ async function runWorkflow(name: string, { input, startedBy, sandbox, hold }: Ru
|
|
|
245
361
|
outline,
|
|
246
362
|
state,
|
|
247
363
|
};
|
|
248
|
-
await write(
|
|
364
|
+
await write(
|
|
365
|
+
run,
|
|
366
|
+
entry(run, { type: "run.started", input, ...(scheduled && { scheduledFor: scheduled.at.toISOString() }) }),
|
|
367
|
+
);
|
|
249
368
|
let output: unknown;
|
|
250
369
|
try {
|
|
251
370
|
try {
|
|
252
371
|
// The one parse of the input: the client checked it, but sent it as given.
|
|
253
372
|
const parsed = parseOrThrow(wf.input, input, `The input does not match ${wf.name}'s schema`);
|
|
254
|
-
// The policy held the start: the body waits for each hold's approval in turn, the policy
|
|
255
|
-
// asked again once one is approved, and a rejection or a deny fails the run.
|
|
256
373
|
const action = `${WORKFLOW_ACTION}${wf.name}`;
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
await awaitApproval(run, next.title, { approver: next.approver, covers: [action] }, undefined, held);
|
|
374
|
+
/** The policy's answer to this start, in a step; a deny fails the run. */
|
|
375
|
+
const decide = async () => {
|
|
260
376
|
const decision = await DBOS.runStep(
|
|
261
377
|
async () => decideStart(state.policy, wf.name, parsed, startedBy, run.approvals),
|
|
262
|
-
{
|
|
263
|
-
name: `policy:${action}`,
|
|
264
|
-
},
|
|
378
|
+
{ name: `policy:${action}` },
|
|
265
379
|
);
|
|
266
380
|
if (decision.kind === "deny") throw new PolicyDeniedError(action, decision.reason);
|
|
381
|
+
return decision;
|
|
382
|
+
};
|
|
383
|
+
if (scheduled) {
|
|
384
|
+
if (scheduled.refused) throw new Error(`The schedule agent was not admitted: ${scheduled.refused}`);
|
|
385
|
+
// A schedule has no one to wait for, and its next tick would start another run meanwhile.
|
|
386
|
+
const decision = await decide();
|
|
387
|
+
if (decision.kind === "approve") {
|
|
388
|
+
throw new PolicyDeniedError(
|
|
389
|
+
action,
|
|
390
|
+
`it needs ${approverLabel(decision.approver)} to approve it, and a scheduled start waits for no approval`,
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
// The policy held the start: the body waits for each hold's approval in turn, the policy
|
|
395
|
+
// asked again once one is approved, and a rejection or a deny fails the run.
|
|
396
|
+
for (let next = hold; next;) {
|
|
397
|
+
const held = { op: action, input, reasons: next.reasons };
|
|
398
|
+
await awaitApproval(run, next.title, { approver: next.approver, covers: [action] }, undefined, held);
|
|
399
|
+
const decision = await decide();
|
|
267
400
|
next = decision.kind === "approve" ? startHoldOf(wf.name, decision) : undefined;
|
|
268
401
|
}
|
|
269
402
|
if (sandbox !== undefined) {
|