@sanoma-ai/workflows 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. package/README.md +49 -15
  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 +47 -5
  23. package/dist/config.js +71 -7
  24. package/dist/config.js.map +1 -1
  25. package/dist/database.d.ts +83 -0
  26. package/dist/database.js +92 -0
  27. package/dist/database.js.map +1 -0
  28. package/dist/db.d.ts +31 -1
  29. package/dist/db.js +89 -7
  30. package/dist/db.js.map +1 -1
  31. package/dist/deadlines.d.ts +88 -0
  32. package/dist/deadlines.js +171 -0
  33. package/dist/deadlines.js.map +1 -0
  34. package/dist/define.d.ts +25 -4
  35. package/dist/define.js +40 -0
  36. package/dist/define.js.map +1 -1
  37. package/dist/demo.d.ts +28 -0
  38. package/dist/demo.js +72 -0
  39. package/dist/demo.js.map +1 -0
  40. package/dist/describe.d.ts +6 -3
  41. package/dist/describe.js +11 -7
  42. package/dist/describe.js.map +1 -1
  43. package/dist/errors.d.ts +10 -1
  44. package/dist/errors.js +13 -0
  45. package/dist/errors.js.map +1 -1
  46. package/dist/fake.d.ts +8 -0
  47. package/dist/fake.js +11 -1
  48. package/dist/fake.js.map +1 -1
  49. package/dist/grid.d.ts +34 -0
  50. package/dist/grid.js +62 -0
  51. package/dist/grid.js.map +1 -0
  52. package/dist/host.d.ts +13 -2
  53. package/dist/host.js +2 -2
  54. package/dist/host.js.map +1 -1
  55. package/dist/index.d.ts +16 -8
  56. package/dist/index.js +12 -5
  57. package/dist/index.js.map +1 -1
  58. package/dist/ledger.d.ts +11 -2
  59. package/dist/ledger.js.map +1 -1
  60. package/dist/lint.js +1 -1
  61. package/dist/lint.js.map +1 -1
  62. package/dist/op.d.ts +14 -2
  63. package/dist/op.js.map +1 -1
  64. package/dist/policy.d.ts +7 -1
  65. package/dist/policy.js +2 -1
  66. package/dist/policy.js.map +1 -1
  67. package/dist/resource.d.ts +13 -1
  68. package/dist/resource.js +9 -1
  69. package/dist/resource.js.map +1 -1
  70. package/dist/resources.d.ts +2 -0
  71. package/dist/resources.js +10 -1
  72. package/dist/resources.js.map +1 -1
  73. package/dist/run.d.ts +5 -0
  74. package/dist/run.js.map +1 -1
  75. package/dist/sandbox.d.ts +38 -10
  76. package/dist/sandbox.js +66 -35
  77. package/dist/sandbox.js.map +1 -1
  78. package/dist/scenario.d.ts +18 -1
  79. package/dist/scenario.js +77 -27
  80. package/dist/scenario.js.map +1 -1
  81. package/dist/scenarios.d.ts +10 -1
  82. package/dist/scenarios.js +19 -4
  83. package/dist/scenarios.js.map +1 -1
  84. package/dist/schedules.d.ts +66 -0
  85. package/dist/schedules.js +158 -0
  86. package/dist/schedules.js.map +1 -0
  87. package/dist/schema.d.ts +6 -0
  88. package/dist/schema.js +13 -3
  89. package/dist/schema.js.map +1 -1
  90. package/dist/shared.d.ts +38 -2
  91. package/dist/shared.js +50 -4
  92. package/dist/shared.js.map +1 -1
  93. package/dist/sites.d.ts +38 -1
  94. package/dist/sites.js +153 -26
  95. package/dist/sites.js.map +1 -1
  96. package/dist/source.d.ts +61 -2
  97. package/dist/source.js.map +1 -1
  98. package/dist/supervisor.d.ts +38 -0
  99. package/dist/supervisor.js +77 -0
  100. package/dist/supervisor.js.map +1 -0
  101. package/dist/version.d.ts +3 -1
  102. package/dist/version.js +3 -1
  103. package/dist/version.js.map +1 -1
  104. package/dist/wakeup-service.d.ts +83 -0
  105. package/dist/wakeup-service.js +421 -0
  106. package/dist/wakeup-service.js.map +1 -0
  107. package/dist/wakeup.d.ts +84 -0
  108. package/dist/wakeup.js +87 -0
  109. package/dist/wakeup.js.map +1 -0
  110. package/dist/worker.d.ts +39 -1
  111. package/dist/worker.js +130 -39
  112. package/dist/worker.js.map +1 -1
  113. package/oxlint.json +2 -1
  114. package/package.json +8 -2
  115. package/src/apply.ts +25 -6
  116. package/src/approvals.ts +89 -16
  117. package/src/call.ts +40 -29
  118. package/src/cedar.ts +24 -6
  119. package/src/change.ts +222 -0
  120. package/src/chats.ts +114 -0
  121. package/src/client.ts +145 -55
  122. package/src/config.ts +110 -13
  123. package/src/database.ts +152 -0
  124. package/src/db.ts +108 -9
  125. package/src/deadlines.ts +234 -0
  126. package/src/define.ts +71 -4
  127. package/src/demo.ts +87 -0
  128. package/src/describe.ts +19 -7
  129. package/src/errors.ts +14 -0
  130. package/src/fake.ts +12 -0
  131. package/src/grid.ts +100 -0
  132. package/src/host.ts +13 -4
  133. package/src/index.ts +62 -3
  134. package/src/ledger.ts +15 -3
  135. package/src/lint.ts +1 -1
  136. package/src/op.ts +15 -2
  137. package/src/policy.ts +7 -1
  138. package/src/resource.ts +22 -3
  139. package/src/resources.ts +12 -1
  140. package/src/run.ts +5 -0
  141. package/src/sandbox.ts +97 -37
  142. package/src/scenario.ts +97 -28
  143. package/src/scenarios.ts +21 -5
  144. package/src/schedules.ts +206 -0
  145. package/src/schema.ts +16 -3
  146. package/src/shared.ts +62 -4
  147. package/src/sites.ts +198 -33
  148. package/src/source.ts +58 -3
  149. package/src/supervisor.ts +107 -0
  150. package/src/version.ts +3 -1
  151. package/src/wakeup-service.ts +511 -0
  152. package/src/wakeup.ts +150 -0
  153. package/src/worker.ts +181 -44
package/src/worker.ts CHANGED
@@ -1,10 +1,12 @@
1
- import { DBOS } from "@dbos-inc/dbos-sdk";
1
+ import { DBOS, type ScheduledWorkflowFn } from "@dbos-inc/dbos-sdk";
2
+ import { randomUUID } from "node:crypto";
2
3
  import { awaitApproval } from "./approvals.ts";
3
4
  import { buildCtx, decideStart, isInfrastructureError, startHoldOf } from "./call.ts";
4
5
  import { dbosStatusesOf } from "./client.ts";
5
- import { type ResolvedConfig, resolveConfig, type SanomaConfig } from "./config.ts";
6
+ import { declaredWakeup, type ResolvedConfig, resolveConfig, type SanomaConfig } from "./config.ts";
6
7
  import { storedSecrets, watchCredentials } from "./credentials.ts";
7
- import { DBOS_DATABASE_OPTIONS, lazyPool, prepareDatabase } from "./db.ts";
8
+ import { claimWakeup, DBOS_DATABASE_OPTIONS, lazyPool, prepareDatabase } from "./db.ts";
9
+ import { deadlineRegistry, type Deadlines, idle, onOwnQueue, type SelfStop, stopWhenIdle } from "./deadlines.ts";
8
10
  import type { WorkflowDefinition } from "./define.ts";
9
11
  import { credentialsOf, unconfigured, unconfiguredSummary } from "./env.ts";
10
12
  import { errorInfo, parseOrThrow, PolicyDeniedError } from "./errors.ts";
@@ -13,8 +15,9 @@ import { dbosLogger, quietIfSandbox, setLogLevel, warn } from "./log.ts";
13
15
  import { lineStartsOf, readSource } from "./ast.ts";
14
16
  import { callsOf, outlineWithSource } from "./outline.ts";
15
17
  import { currentRun, type Run, type RunArgs, type WorkerState, type WorkflowOutline } from "./run.ts";
18
+ import { applyScheduled, nextScheduledFire, type ScheduledStarter, scheduledStarter } from "./schedules.ts";
16
19
  import type { Source } from "./source.ts";
17
- import { errorMessage, WORKFLOW_ACTION } from "./shared.ts";
20
+ import { approverLabel, errorMessage, WORKFLOW_ACTION } from "./shared.ts";
18
21
 
19
22
  export interface Worker {
20
23
  /**
@@ -23,14 +26,36 @@ export interface Worker {
23
26
  * The worker names them in one line at start, unless started `quiet`.
24
27
  */
25
28
  readonly unconfigured: readonly string[];
29
+ /**
30
+ * Its runs' deadlines: each sleeping run's, and the sources added here (a schedule's next
31
+ * fire), which a stop registers the earliest of with the wakeup.
32
+ */
33
+ readonly deadlines: Deadlines;
34
+ /**
35
+ * True when nothing this worker would run can run soon (`idle` in deadlines.ts): no run executing,
36
+ * none queued, every unfinished one blocked in a sleep or an approval, the next deadline past
37
+ * `idleHorizonMs`. False once stopped.
38
+ */
39
+ idle(): Promise<boolean>;
40
+ /** Stops the worker; a second call waits for the first. */
26
41
  stop(): Promise<void>;
27
42
  }
28
43
 
44
+ /**
45
+ * How many times DBOS may start a run again before it gives up on it
46
+ * (`MAX_RECOVERY_ATTEMPTS_EXCEEDED`). Every start of a worker counts one for each unfinished run
47
+ * of its version, and a worker that stops when idle starts again at each wake and each request:
48
+ * at one every five minutes, DBOS's default of 100 would fail a run waiting on an approval within
49
+ * nine hours. 100,000 is a year of that; a run that crashes its process at every recovery is still
50
+ * given up on, after as many restarts.
51
+ */
52
+ const MAX_RECOVERY_ATTEMPTS = 100_000;
53
+
29
54
  // DBOS registrations are process-wide and survive shutdown, so a name is registered once, and
30
55
  // its function reads the state of the worker running when a run starts, definition and all; the
31
- // run keeps that state.
56
+ // run keeps that state. Each registered function, by name, is what a schedule fires.
32
57
  let current: WorkerState | undefined;
33
- const registered = new Set<string>();
58
+ const registered = new Map<string, ScheduledWorkflowFn>();
34
59
 
35
60
  export interface WorkerOptions {
36
61
  /** The least severe level of the runtime's and DBOS's messages to show, for the whole process: `warn` by default. */
@@ -50,6 +75,31 @@ export interface WorkerOptions {
50
75
  promote?: boolean;
51
76
  /** The company's repository, which apply commits the lockfile to; the launcher passes its checkout. */
52
77
  source?: Source;
78
+ /**
79
+ * How soon a deadline keeps the worker up (`idle`): 60 s by default, so apply's short sleeps
80
+ * and other short waits never stop it.
81
+ */
82
+ idleHorizonMs?: number;
83
+ /** How often a worker whose data files declare a wakeup checks whether it may stop: every 15 s by default. */
84
+ stopCheckMs?: number;
85
+ /**
86
+ * Given, and with a wakeup declared, the worker stops itself when idle (`stopCheckMs`); without
87
+ * it, nobody would start another, so it never does. Called once the worker has stopped itself, with the deadline it registered with the
88
+ * wakeup (null for none) and that registration's generation: a supervisor starts a fresh
89
+ * worker on the next request or wake (`shouldWake`), since a stopped one cannot launch again.
90
+ */
91
+ onStopped?: (stopped: SelfStop) => void;
92
+ /**
93
+ * Apply the workflows' schedules (`trigger: { schedule }`): on by default. `runScenarios` turns
94
+ * it off, on a database of its own, so no schedule is applied there to start a live run.
95
+ */
96
+ schedules?: boolean;
97
+ /**
98
+ * The config as the caller resolved it (`resolveConfig(config, options)`), for a worker that
99
+ * shares its drivers with the caller: the instance fakes of `fakes: "instance"`, which a demo's
100
+ * client and reseed call too. The caller closes its `dataStore`, as it made it.
101
+ */
102
+ resolved?: ResolvedConfig;
53
103
  }
54
104
 
55
105
  /**
@@ -61,16 +111,29 @@ export interface WorkerOptions {
61
111
  */
62
112
  export async function startWorker(config: SanomaConfig, options: WorkerOptions = {}): Promise<Worker> {
63
113
  // Check everything before touching the state a running worker reads.
64
- const resolved = resolveConfig(config);
114
+ const resolved = options.resolved ?? resolveConfig(config);
65
115
  setLogLevel(options.logLevel);
116
+ // Read once, when it matters: a worker stops itself, registering its next deadline there, only
117
+ // when its caller hears it stop (`onStopped`).
118
+ const wakeup = options.onStopped || config.wakeup === true ? await declaredWakeup(config) : undefined;
119
+ // The wakeup service wakes the instances that stop: one that runs it must not stop with its worker.
120
+ if (config.wakeup === true && wakeup) {
121
+ throw new Error(
122
+ "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",
123
+ );
124
+ }
66
125
  // The config's own, or a Postgres store made for this worker, which `stop` then closes.
67
126
  const ledger = ownedLedger(resolved);
68
127
  const secrets = lazyPool(resolved.databaseUrl, "secrets");
128
+ let stopRule: { close(): void } | undefined;
129
+ let stopping: Promise<void> | undefined;
130
+ const horizonMs = options.idleHorizonMs ?? 60_000;
69
131
  const state: WorkerState = {
70
132
  app: resolved.appName,
71
133
  ops: resolved.ops,
72
134
  drivers: resolved.drivers,
73
135
  fakes: resolved.fakes,
136
+ ...(resolved.instanceFakes && { instanceFakes: resolved.instanceFakes }),
74
137
  ...(resolved.scenarios && { scenarios: resolved.scenarios }),
75
138
  ...(options.source && { source: options.source }),
76
139
  types: resolved.types,
@@ -83,26 +146,38 @@ export async function startWorker(config: SanomaConfig, options: WorkerOptions =
83
146
  workflows: resolved.workflows,
84
147
  outlines: new Map([...resolved.workflows].map(([name, wf]) => [name, outlineOf(wf)])),
85
148
  stopped: false,
149
+ deadlines: deadlineRegistry(),
86
150
  };
87
151
  const credentials = await watchCredentials(resolved.databaseUrl, resolved.driversByVendor);
88
152
  const worker: Worker = {
89
153
  unconfigured: [...credentialsOf(resolved.driversByVendor, (name) => process.env[name])]
90
154
  .filter(([vendor, statuses]) => unconfigured(vendor, statuses))
91
155
  .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;
156
+ deadlines: state.deadlines,
157
+ async idle() {
158
+ return !state.stopped && idle(state.deadlines, resolved, horizonMs);
159
+ },
160
+ stop() {
161
+ // A stop that failed may be tried again.
162
+ return (stopping ??= (async () => {
163
+ state.stopped = true;
164
+ stopRule?.close();
165
+ // Each is closed whatever the one before did; the first failure is what stop throws.
166
+ let failed: { error: unknown } | undefined;
167
+ for (const close of [
168
+ () => DBOS.shutdown(),
169
+ () => credentials.close(),
170
+ () => secrets.close(),
171
+ () => ledger.close(),
172
+ async () => (options.resolved ? undefined : resolved.dataStore?.close()),
173
+ ]) {
174
+ await close().catch((error: unknown) => (failed ??= { error }));
175
+ }
176
+ if (failed) {
177
+ stopping = undefined;
178
+ throw failed.error;
179
+ }
180
+ })());
106
181
  },
107
182
  };
108
183
  // Runs recovered during launch start before it returns, so they must already find this
@@ -112,10 +187,7 @@ export async function startWorker(config: SanomaConfig, options: WorkerOptions =
112
187
  let launched = false;
113
188
  try {
114
189
  for (const name of resolved.workflows.keys()) {
115
- if (!registered.has(name)) {
116
- register(name);
117
- registered.add(name);
118
- }
190
+ if (!registered.has(name)) registered.set(name, register(name));
119
191
  }
120
192
  current = state;
121
193
  // Before DBOS's pool connects, so its connections start with the database's idle-transaction
@@ -135,6 +207,7 @@ export async function startWorker(config: SanomaConfig, options: WorkerOptions =
135
207
  // DBOS gives runs queued without a version only to the app's latest version, which is the
136
208
  // newest one registered. A worker started on code seen before (a rollback) is not it.
137
209
  const latest = (await DBOS.getLatestApplicationVersion()).versionName;
210
+ const isLatest = latest === resolved.version || options.promote === true;
138
211
  if (latest !== resolved.version) {
139
212
  if (options.promote) await DBOS.setLatestApplicationVersion(resolved.version);
140
213
  else {
@@ -145,12 +218,37 @@ export async function startWorker(config: SanomaConfig, options: WorkerOptions =
145
218
  }
146
219
  }
147
220
  await DBOS.registerQueue(resolved.queueName);
221
+ // The schedules are the latest version's: an older worker leaves them as the newer set them.
222
+ if (isLatest && options.schedules !== false) {
223
+ await applyScheduled(resolved, registered, () => secrets.db());
224
+ // A schedule's next tick is a deadline: a worker that stops when quiet registers it with its wakeup.
225
+ state.deadlines.addSource(() => nextScheduledFire(resolved, new Date()));
226
+ }
148
227
  await warnAboutStrandedRuns(resolved);
149
228
  const summary = unconfiguredSummary(worker.unconfigured, resolved.driversByVendor.size);
150
229
  if (summary && !options.quiet) warn(summary);
151
230
  if (resolved.policyProblems.length && !options.quiet) {
152
231
  warn(`the policies do not hold, so every call and start is refused:\n ${resolved.policyProblems.join("\n ")}`);
153
232
  }
233
+ if (wakeup && options.onStopped) {
234
+ const owner = randomUUID();
235
+ await claimWakeup(await secrets.db(), owner);
236
+ const registerId = wakeup.type.wakeup!.register;
237
+ stopRule = stopWhenIdle({
238
+ // The row is one more of the runtime's tables: the secrets' pool, on the same database.
239
+ db: () => secrets.db(),
240
+ owner,
241
+ wakeup: wakeup.binding ?? wakeup.id,
242
+ // `resolveConfig` checked the operation is the config's (`checkWakeupOps`).
243
+ register: { op: resolved.ops.get(registerId)!, fn: resolved.drivers.get(registerId) },
244
+ secrets: state.secrets,
245
+ deadlines: state.deadlines,
246
+ idle: () => worker.idle(),
247
+ everyMs: options.stopCheckMs ?? 15_000,
248
+ stop: () => worker.stop(),
249
+ onStopped: options.onStopped,
250
+ });
251
+ }
154
252
  } catch (err) {
155
253
  // Once DBOS is launched, the caller gets no worker to stop: stop it here, so no run goes on
156
254
  // in a worker nobody holds, and the next startWorker launches afresh.
@@ -202,9 +300,7 @@ async function warnAboutStrandedRuns({ appName, version, queueName }: ResolvedCo
202
300
  });
203
301
  const otherVersion = runs.filter((r) => r.applicationVersion && r.applicationVersion !== version);
204
302
  // 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
- );
303
+ const otherQueue = runs.filter((r) => !otherVersion.includes(r) && !onOwnQueue(r.queueName, queueName));
208
304
  if (!otherVersion.length && !otherQueue.length) return;
209
305
  const ids = [...otherVersion, ...otherQueue].map((r) => r.workflowID);
210
306
  warn(
@@ -216,22 +312,46 @@ async function warnAboutStrandedRuns({ appName, version, queueName }: ResolvedCo
216
312
  );
217
313
  }
218
314
 
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
- });
315
+ /**
316
+ * Registers the name with DBOS: its runs take the definition of that name from the running worker.
317
+ * A start passes its `RunArgs`; a schedule's tick passes the time it is for (and DBOS's context).
318
+ */
319
+ function register(name: string): ScheduledWorkflowFn {
320
+ const fn = DBOS.registerWorkflow(
321
+ (start: RunArgs | Date) =>
322
+ start instanceof Date
323
+ ? runWorkflow(name, start)
324
+ : quietIfSandbox(start.sandbox !== undefined, () => runWorkflow(name, start)),
325
+ { name, maxRecoveryAttempts: MAX_RECOVERY_ATTEMPTS },
326
+ );
327
+ return fn as unknown as ScheduledWorkflowFn;
224
328
  }
225
329
 
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> {
330
+ /**
331
+ * One run of the workflow registered as `name`, on the state of the worker running when it
332
+ * starts, counted busy in its deadlines but while it waits: started with its `RunArgs`, or by its
333
+ * schedule for the time `start`, as the `schedule` agent with the input `{}`, the policy deciding
334
+ * that start at the top of the run.
335
+ */
336
+ async function runWorkflow(name: string, start: RunArgs | Date): Promise<unknown> {
228
337
  const state = current;
229
338
  // A stopped worker's state stays current while DBOS shuts down.
230
339
  if (!state || state.stopped) throw new Error(`Run of "${name}" started with no worker running`);
340
+ return state.deadlines.running(DBOS.workflowID!, () => runOn(state, name, start));
341
+ }
342
+
343
+ async function runOn(state: WorkerState, name: string, start: RunArgs | Date): Promise<unknown> {
231
344
  const wf = state.workflows.get(name);
232
345
  const outline = state.outlines.get(name);
233
346
  // Registered by an earlier worker in this process, whose config had it; this one's has not.
234
347
  if (!wf || !outline) throw new Error(`Run of "${name}" refused: this worker's config has no workflow of that name`);
348
+ let scheduled: ({ at: Date } & ScheduledStarter) | undefined;
349
+ let args: RunArgs;
350
+ if (start instanceof Date) {
351
+ scheduled = { at: start, ...(await scheduledStarter(state)) };
352
+ args = { input: {}, startedBy: scheduled.startedBy };
353
+ } else args = start;
354
+ const { input, startedBy, sandbox, hold } = args;
235
355
  const run: Run = {
236
356
  id: DBOS.workflowID!,
237
357
  workflow: wf.name,
@@ -245,25 +365,42 @@ async function runWorkflow(name: string, { input, startedBy, sandbox, hold }: Ru
245
365
  outline,
246
366
  state,
247
367
  };
248
- await write(run, entry(run, { type: "run.started", input }));
368
+ await write(
369
+ run,
370
+ entry(run, { type: "run.started", input, ...(scheduled && { scheduledFor: scheduled.at.toISOString() }) }),
371
+ );
249
372
  let output: unknown;
250
373
  try {
251
374
  try {
252
375
  // The one parse of the input: the client checked it, but sent it as given.
253
376
  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
377
  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);
378
+ /** The policy's answer to this start, in a step; a deny fails the run. */
379
+ const decide = async () => {
260
380
  const decision = await DBOS.runStep(
261
381
  async () => decideStart(state.policy, wf.name, parsed, startedBy, run.approvals),
262
- {
263
- name: `policy:${action}`,
264
- },
382
+ { name: `policy:${action}` },
265
383
  );
266
384
  if (decision.kind === "deny") throw new PolicyDeniedError(action, decision.reason);
385
+ return decision;
386
+ };
387
+ if (scheduled) {
388
+ if (scheduled.refused) throw new Error(`The schedule agent was not admitted: ${scheduled.refused}`);
389
+ // A schedule has no one to wait for, and its next tick would start another run meanwhile.
390
+ const decision = await decide();
391
+ if (decision.kind === "approve") {
392
+ throw new PolicyDeniedError(
393
+ action,
394
+ `it needs ${approverLabel(decision.approver)} to approve it, and a scheduled start waits for no approval`,
395
+ );
396
+ }
397
+ }
398
+ // The policy held the start: the body waits for each hold's approval in turn, the policy
399
+ // asked again once one is approved, and a rejection or a deny fails the run.
400
+ for (let next = hold; next;) {
401
+ const held = { op: action, input, reasons: next.reasons };
402
+ await awaitApproval(run, next.title, { approver: next.approver, covers: [action] }, undefined, held);
403
+ const decision = await decide();
267
404
  next = decision.kind === "approve" ? startHoldOf(wf.name, decision) : undefined;
268
405
  }
269
406
  if (sandbox !== undefined) {