@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.
Files changed (166) hide show
  1. package/README.md +52 -14
  2. package/dist/apply.d.ts +2 -1
  3. package/dist/apply.js +29 -7
  4. package/dist/apply.js.map +1 -1
  5. package/dist/approvals.d.ts +27 -2
  6. package/dist/approvals.js +73 -9
  7. package/dist/approvals.js.map +1 -1
  8. package/dist/call.d.ts +3 -2
  9. package/dist/call.js +38 -30
  10. package/dist/call.js.map +1 -1
  11. package/dist/cedar.js +24 -9
  12. package/dist/cedar.js.map +1 -1
  13. package/dist/change.d.ts +219 -0
  14. package/dist/change.js +150 -0
  15. package/dist/change.js.map +1 -0
  16. package/dist/chats.d.ts +43 -0
  17. package/dist/chats.js +48 -0
  18. package/dist/chats.js.map +1 -0
  19. package/dist/client.d.ts +52 -6
  20. package/dist/client.js +88 -47
  21. package/dist/client.js.map +1 -1
  22. package/dist/config.d.ts +75 -8
  23. package/dist/config.js +103 -13
  24. package/dist/config.js.map +1 -1
  25. package/dist/constructors.d.ts +37 -1
  26. package/dist/constructors.js +6 -2
  27. package/dist/constructors.js.map +1 -1
  28. package/dist/database.d.ts +83 -0
  29. package/dist/database.js +92 -0
  30. package/dist/database.js.map +1 -0
  31. package/dist/db.d.ts +19 -1
  32. package/dist/db.js +51 -7
  33. package/dist/db.js.map +1 -1
  34. package/dist/deadlines.d.ts +91 -0
  35. package/dist/deadlines.js +165 -0
  36. package/dist/deadlines.js.map +1 -0
  37. package/dist/define.d.ts +25 -4
  38. package/dist/define.js +40 -0
  39. package/dist/define.js.map +1 -1
  40. package/dist/demo.d.ts +28 -0
  41. package/dist/demo.js +72 -0
  42. package/dist/demo.js.map +1 -0
  43. package/dist/describe.d.ts +5 -2
  44. package/dist/describe.js +10 -6
  45. package/dist/describe.js.map +1 -1
  46. package/dist/errors.d.ts +10 -1
  47. package/dist/errors.js +13 -0
  48. package/dist/errors.js.map +1 -1
  49. package/dist/fake.d.ts +34 -0
  50. package/dist/fake.js +134 -1
  51. package/dist/fake.js.map +1 -1
  52. package/dist/grid.d.ts +34 -0
  53. package/dist/grid.js +62 -0
  54. package/dist/grid.js.map +1 -0
  55. package/dist/host.d.ts +12 -2
  56. package/dist/host.js +2 -2
  57. package/dist/host.js.map +1 -1
  58. package/dist/index.d.ts +15 -8
  59. package/dist/index.js +10 -5
  60. package/dist/index.js.map +1 -1
  61. package/dist/ledger.d.ts +11 -2
  62. package/dist/ledger.js.map +1 -1
  63. package/dist/lint.js +2 -2
  64. package/dist/lint.js.map +1 -1
  65. package/dist/op.d.ts +21 -2
  66. package/dist/op.js.map +1 -1
  67. package/dist/person.d.ts +5 -0
  68. package/dist/person.js +3 -1
  69. package/dist/person.js.map +1 -1
  70. package/dist/policy.d.ts +7 -1
  71. package/dist/policy.js +2 -1
  72. package/dist/policy.js.map +1 -1
  73. package/dist/resource.d.ts +13 -1
  74. package/dist/resource.js +9 -1
  75. package/dist/resource.js.map +1 -1
  76. package/dist/resources.d.ts +2 -0
  77. package/dist/resources.js +15 -4
  78. package/dist/resources.js.map +1 -1
  79. package/dist/run.d.ts +5 -0
  80. package/dist/run.js.map +1 -1
  81. package/dist/sandbox.d.ts +38 -10
  82. package/dist/sandbox.js +66 -35
  83. package/dist/sandbox.js.map +1 -1
  84. package/dist/scenario.d.ts +18 -1
  85. package/dist/scenario.js +77 -27
  86. package/dist/scenario.js.map +1 -1
  87. package/dist/scenarios.d.ts +10 -1
  88. package/dist/scenarios.js +19 -4
  89. package/dist/scenarios.js.map +1 -1
  90. package/dist/schedules.d.ts +66 -0
  91. package/dist/schedules.js +158 -0
  92. package/dist/schedules.js.map +1 -0
  93. package/dist/schema.d.ts +6 -0
  94. package/dist/schema.js +13 -3
  95. package/dist/schema.js.map +1 -1
  96. package/dist/shared.d.ts +30 -2
  97. package/dist/shared.js +42 -4
  98. package/dist/shared.js.map +1 -1
  99. package/dist/source.d.ts +185 -2
  100. package/dist/source.js +9 -0
  101. package/dist/source.js.map +1 -1
  102. package/dist/supervisor.d.ts +39 -0
  103. package/dist/supervisor.js +68 -0
  104. package/dist/supervisor.js.map +1 -0
  105. package/dist/version.d.ts +3 -1
  106. package/dist/version.js +3 -1
  107. package/dist/version.js.map +1 -1
  108. package/dist/wakeup-driver.d.ts +128 -0
  109. package/dist/wakeup-driver.js +216 -0
  110. package/dist/wakeup-driver.js.map +1 -0
  111. package/dist/wakeup-service.d.ts +83 -0
  112. package/dist/wakeup-service.js +415 -0
  113. package/dist/wakeup-service.js.map +1 -0
  114. package/dist/wakeup-type.d.ts +43 -0
  115. package/dist/wakeup-type.js +80 -0
  116. package/dist/wakeup-type.js.map +1 -0
  117. package/dist/wakeup.d.ts +77 -0
  118. package/dist/wakeup.js +78 -0
  119. package/dist/wakeup.js.map +1 -0
  120. package/dist/worker.d.ts +39 -1
  121. package/dist/worker.js +125 -38
  122. package/dist/worker.js.map +1 -1
  123. package/oxlint.json +3 -1
  124. package/package.json +10 -2
  125. package/src/apply.ts +25 -6
  126. package/src/approvals.ts +89 -16
  127. package/src/call.ts +40 -29
  128. package/src/cedar.ts +24 -6
  129. package/src/change.ts +222 -0
  130. package/src/chats.ts +114 -0
  131. package/src/client.ts +145 -55
  132. package/src/config.ts +149 -21
  133. package/src/constructors.ts +8 -2
  134. package/src/database.ts +152 -0
  135. package/src/db.ts +58 -9
  136. package/src/deadlines.ts +228 -0
  137. package/src/define.ts +71 -4
  138. package/src/demo.ts +87 -0
  139. package/src/describe.ts +18 -6
  140. package/src/errors.ts +14 -0
  141. package/src/fake.ts +149 -0
  142. package/src/grid.ts +100 -0
  143. package/src/host.ts +12 -4
  144. package/src/index.ts +66 -3
  145. package/src/ledger.ts +15 -3
  146. package/src/lint.ts +2 -2
  147. package/src/op.ts +22 -2
  148. package/src/person.ts +4 -1
  149. package/src/policy.ts +7 -1
  150. package/src/resource.ts +22 -3
  151. package/src/resources.ts +17 -4
  152. package/src/run.ts +5 -0
  153. package/src/sandbox.ts +97 -37
  154. package/src/scenario.ts +97 -28
  155. package/src/scenarios.ts +21 -5
  156. package/src/schedules.ts +206 -0
  157. package/src/schema.ts +16 -3
  158. package/src/shared.ts +51 -4
  159. package/src/source.ts +176 -3
  160. package/src/supervisor.ts +102 -0
  161. package/src/version.ts +3 -1
  162. package/src/wakeup-driver.ts +270 -0
  163. package/src/wakeup-service.ts +504 -0
  164. package/src/wakeup-type.ts +90 -0
  165. package/src/wakeup.ts +139 -0
  166. 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 Set<string>();
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
- async stop() {
93
- state.stopped = true;
94
- // Each is closed whatever the one before did; the first failure is what stop throws.
95
- let failed: { error: unknown } | undefined;
96
- for (const close of [
97
- () => DBOS.shutdown(),
98
- () => credentials.close(),
99
- () => secrets.close(),
100
- () => ledger.close(),
101
- async () => resolved.dataStore?.close(),
102
- ]) {
103
- await close().catch((error: unknown) => (failed ??= { error }));
104
- }
105
- if (failed) throw failed.error;
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
- /** Registers the name with DBOS: its runs take the definition of that name from the running worker. */
220
- function register(name: string) {
221
- DBOS.registerWorkflow((args: RunArgs) => quietIfSandbox(args.sandbox !== undefined, () => runWorkflow(name, args)), {
222
- name,
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
- /** One run of the workflow registered as `name`, on the state of the worker running when it starts. */
227
- async function runWorkflow(name: string, { input, startedBy, sandbox, hold }: RunArgs): Promise<unknown> {
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(run, entry(run, { type: "run.started", input }));
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
- for (let next = hold; next;) {
258
- const held = { op: action, input, reasons: next.reasons };
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) {