@sanoma-ai/workflows 0.0.0-stage → 0.2.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 (176) hide show
  1. package/LICENSE +105 -0
  2. package/README.md +544 -2
  3. package/dist/apply-steps.d.ts +6 -0
  4. package/dist/apply-steps.js +93 -0
  5. package/dist/apply-steps.js.map +1 -0
  6. package/dist/apply.d.ts +197 -0
  7. package/dist/apply.js +470 -0
  8. package/dist/apply.js.map +1 -0
  9. package/dist/approvals.d.ts +46 -0
  10. package/dist/approvals.js +111 -0
  11. package/dist/approvals.js.map +1 -0
  12. package/dist/ast.d.ts +57 -0
  13. package/dist/ast.js +81 -0
  14. package/dist/ast.js.map +1 -0
  15. package/dist/call.d.ts +37 -0
  16. package/dist/call.js +417 -0
  17. package/dist/call.js.map +1 -0
  18. package/dist/client.d.ts +203 -0
  19. package/dist/client.js +457 -0
  20. package/dist/client.js.map +1 -0
  21. package/dist/config.d.ts +126 -0
  22. package/dist/config.js +264 -0
  23. package/dist/config.js.map +1 -0
  24. package/dist/credentials.d.ts +55 -0
  25. package/dist/credentials.js +261 -0
  26. package/dist/credentials.js.map +1 -0
  27. package/dist/cucumber.d.ts +64 -0
  28. package/dist/cucumber.js +158 -0
  29. package/dist/cucumber.js.map +1 -0
  30. package/dist/datafile.d.ts +71 -0
  31. package/dist/datafile.js +380 -0
  32. package/dist/datafile.js.map +1 -0
  33. package/dist/db.d.ts +58 -0
  34. package/dist/db.js +179 -0
  35. package/dist/db.js.map +1 -0
  36. package/dist/define.d.ts +205 -0
  37. package/dist/define.js +103 -0
  38. package/dist/define.js.map +1 -0
  39. package/dist/describe.d.ts +141 -0
  40. package/dist/describe.js +135 -0
  41. package/dist/describe.js.map +1 -0
  42. package/dist/drift.d.ts +127 -0
  43. package/dist/drift.js +167 -0
  44. package/dist/drift.js.map +1 -0
  45. package/dist/env.d.ts +49 -0
  46. package/dist/env.js +83 -0
  47. package/dist/env.js.map +1 -0
  48. package/dist/errors.d.ts +88 -0
  49. package/dist/errors.js +148 -0
  50. package/dist/errors.js.map +1 -0
  51. package/dist/expectations.d.ts +26 -0
  52. package/dist/expectations.js +162 -0
  53. package/dist/expectations.js.map +1 -0
  54. package/dist/fake.d.ts +55 -0
  55. package/dist/fake.js +87 -0
  56. package/dist/fake.js.map +1 -0
  57. package/dist/fill.d.ts +9 -0
  58. package/dist/fill.js +17 -0
  59. package/dist/fill.js.map +1 -0
  60. package/dist/host.d.ts +56 -0
  61. package/dist/host.js +45 -0
  62. package/dist/host.js.map +1 -0
  63. package/dist/index.d.ts +23 -0
  64. package/dist/index.js +29 -0
  65. package/dist/index.js.map +1 -0
  66. package/dist/ledger.d.ts +217 -0
  67. package/dist/ledger.js +251 -0
  68. package/dist/ledger.js.map +1 -0
  69. package/dist/lint.d.ts +32 -0
  70. package/dist/lint.js +168 -0
  71. package/dist/lint.js.map +1 -0
  72. package/dist/lockfile.d.ts +36 -0
  73. package/dist/lockfile.js +69 -0
  74. package/dist/lockfile.js.map +1 -0
  75. package/dist/log.d.ts +24 -0
  76. package/dist/log.js +83 -0
  77. package/dist/log.js.map +1 -0
  78. package/dist/op.d.ts +260 -0
  79. package/dist/op.js +188 -0
  80. package/dist/op.js.map +1 -0
  81. package/dist/outline.d.ts +179 -0
  82. package/dist/outline.js +506 -0
  83. package/dist/outline.js.map +1 -0
  84. package/dist/paths.d.ts +11 -0
  85. package/dist/paths.js +30 -0
  86. package/dist/paths.js.map +1 -0
  87. package/dist/plugin.d.ts +28 -0
  88. package/dist/plugin.js +25 -0
  89. package/dist/plugin.js.map +1 -0
  90. package/dist/policy.d.ts +114 -0
  91. package/dist/policy.js +60 -0
  92. package/dist/policy.js.map +1 -0
  93. package/dist/resource.d.ts +378 -0
  94. package/dist/resource.js +430 -0
  95. package/dist/resource.js.map +1 -0
  96. package/dist/resources.d.ts +77 -0
  97. package/dist/resources.js +244 -0
  98. package/dist/resources.js.map +1 -0
  99. package/dist/run.d.ts +101 -0
  100. package/dist/run.js +7 -0
  101. package/dist/run.js.map +1 -0
  102. package/dist/sandbox.d.ts +33 -0
  103. package/dist/sandbox.js +127 -0
  104. package/dist/sandbox.js.map +1 -0
  105. package/dist/scenario.d.ts +221 -0
  106. package/dist/scenario.js +1104 -0
  107. package/dist/scenario.js.map +1 -0
  108. package/dist/scenarios.d.ts +55 -0
  109. package/dist/scenarios.js +71 -0
  110. package/dist/scenarios.js.map +1 -0
  111. package/dist/secret.d.ts +42 -0
  112. package/dist/secret.js +58 -0
  113. package/dist/secret.js.map +1 -0
  114. package/dist/shared.d.ts +69 -0
  115. package/dist/shared.js +84 -0
  116. package/dist/shared.js.map +1 -0
  117. package/dist/source.d.ts +58 -0
  118. package/dist/source.js +54 -0
  119. package/dist/source.js.map +1 -0
  120. package/dist/steps.d.ts +76 -0
  121. package/dist/steps.js +23 -0
  122. package/dist/steps.js.map +1 -0
  123. package/dist/version.d.ts +29 -0
  124. package/dist/version.js +76 -0
  125. package/dist/version.js.map +1 -0
  126. package/dist/worker.d.ts +37 -0
  127. package/dist/worker.js +228 -0
  128. package/dist/worker.js.map +1 -0
  129. package/dist/writer.d.ts +35 -0
  130. package/dist/writer.js +201 -0
  131. package/dist/writer.js.map +1 -0
  132. package/oxlint.json +128 -0
  133. package/package.json +96 -3
  134. package/src/apply-steps.ts +110 -0
  135. package/src/apply.ts +626 -0
  136. package/src/approvals.ts +157 -0
  137. package/src/ast.ts +115 -0
  138. package/src/call.ts +483 -0
  139. package/src/client.ts +604 -0
  140. package/src/config.ts +404 -0
  141. package/src/credentials.ts +296 -0
  142. package/src/cucumber.ts +230 -0
  143. package/src/datafile.ts +481 -0
  144. package/src/db.ts +197 -0
  145. package/src/define.ts +285 -0
  146. package/src/describe.ts +282 -0
  147. package/src/drift.ts +259 -0
  148. package/src/env.ts +122 -0
  149. package/src/errors.ts +202 -0
  150. package/src/expectations.ts +191 -0
  151. package/src/fake.ts +156 -0
  152. package/src/fill.ts +24 -0
  153. package/src/host.ts +96 -0
  154. package/src/index.ts +143 -0
  155. package/src/ledger.ts +419 -0
  156. package/src/lint.ts +208 -0
  157. package/src/lockfile.ts +95 -0
  158. package/src/log.ts +82 -0
  159. package/src/op.ts +402 -0
  160. package/src/outline.ts +620 -0
  161. package/src/paths.ts +32 -0
  162. package/src/plugin.ts +37 -0
  163. package/src/policy.ts +139 -0
  164. package/src/resource.ts +670 -0
  165. package/src/resources.ts +311 -0
  166. package/src/run.ts +106 -0
  167. package/src/sandbox.ts +161 -0
  168. package/src/scenario.ts +1365 -0
  169. package/src/scenarios.ts +111 -0
  170. package/src/secret.ts +92 -0
  171. package/src/shared.ts +126 -0
  172. package/src/source.ts +82 -0
  173. package/src/steps.ts +90 -0
  174. package/src/version.ts +86 -0
  175. package/src/worker.ts +264 -0
  176. package/src/writer.ts +225 -0
package/src/call.ts ADDED
@@ -0,0 +1,483 @@
1
+ import { DBOS, DBOSWorkflowConflictError, Error as DBOSErrors } from "@dbos-inc/dbos-sdk";
2
+ import { awaitApproval, type CheckedApproval } from "./approvals.ts";
3
+ import { offsetOf } from "./ast.ts";
4
+ import {
5
+ AllMembers,
6
+ type ApprovalRequest,
7
+ ApprovalRequestSchema,
8
+ SleepFor,
9
+ type SleepRequest,
10
+ SleepUntil,
11
+ scriptPath,
12
+ type Use,
13
+ type WorkflowDefinition,
14
+ } from "./define.ts";
15
+ import {
16
+ causes,
17
+ errorCode,
18
+ errorInfo,
19
+ isFinal,
20
+ keepCode,
21
+ parseOrThrow,
22
+ PolicyDeniedError,
23
+ SanomaError,
24
+ } from "./errors.ts";
25
+ import { currentGroup, entry, skipped, write, writeFailure } from "./ledger.ts";
26
+ import { shown } from "./log.ts";
27
+ import { type CallContext, isOp, type Op } from "./op.ts";
28
+ import { callAt, callName, type CallNode } from "./outline.ts";
29
+ import { DecisionSchema, type PolicyCall, policyOpOf, type RecordedDecision } from "./policy.ts";
30
+ import type { Run, WorkerState } from "./run.ts";
31
+ import { isChangeOp } from "./resource.ts";
32
+ import { APPLY_WORKFLOW, approverLabel, errorMessage, fitsOp } from "./shared.ts";
33
+
34
+ /** DBOS took the run from this execution: it cancelled the run, or another process took it over. */
35
+ const takenByDbos = (err: unknown) =>
36
+ causes(err).some((e) => e instanceof DBOSErrors.DBOSWorkflowCancelledError || e instanceof DBOSWorkflowConflictError);
37
+
38
+ /**
39
+ * True for a failure that is not the run's outcome, so the ledger does not record it: DBOS
40
+ * cancelled the run or another process took it over, or the run's worker is stopping and the
41
+ * error has none of our codes. `stop()` marks the worker stopped before DBOS shuts down, so from
42
+ * then on whatever fails without a code (DBOS's "system database has been shut down", pg's
43
+ * closed pool) is the shutdown, and the run recovered on the next worker records how it ends.
44
+ * A coded failure then (a driver's final answer, a denial, a rejection) is still the outcome:
45
+ * DBOS records the run's error and never runs it again, so the ledger records it too. Narrower
46
+ * than `isRunControlError`: DBOS's other errors (a run that is not deterministic) end the run
47
+ * for good, and are recorded.
48
+ */
49
+ export function isInfrastructureError(err: unknown, state: Pick<WorkerState, "stopped">): boolean {
50
+ return takenByDbos(err) || (state.stopped && errorCode(err) === undefined);
51
+ }
52
+
53
+ /** The value as the ledger records it: each of the operation's `opaque` fields as `"<name>"`. */
54
+ function recorded(op: Op, value: unknown): unknown {
55
+ if (!op.opaque?.length || typeof value !== "object" || value === null || Array.isArray(value)) return value;
56
+ const out: Record<string, unknown> = { ...value };
57
+ for (const name of op.opaque) if (out[name] !== undefined) out[name] = `<${name}>`;
58
+ return out;
59
+ }
60
+
61
+ /** How many times an idempotent operation is tried. */
62
+ const MAX_ATTEMPTS = 3;
63
+
64
+ // Matched by name too, since a workflow's schemas may come from another copy of zod.
65
+ const isSchemaError = (err: unknown) => err instanceof Error && err.name === "ZodError";
66
+
67
+ /**
68
+ * Whether DBOS should try an idempotent step again: not when the reply failed the output
69
+ * schema (it would fail again), and not when the driver said the vendor's answer is final
70
+ * (a `DriverError` with `retryable: false`, such as a 4xx). Read by code and property, never
71
+ * `instanceof`: the error may come from another copy of this package.
72
+ */
73
+ function shouldRetry(err: unknown): boolean {
74
+ if (isSchemaError(err)) return false;
75
+ return !(errorCode(err) === "driver_failed" && isFinal(err));
76
+ }
77
+
78
+ /*
79
+ * A run's ctx calls run one at a time, in program order, even when the workflow starts several
80
+ * at once (Promise.all). Every DBOS call (a step, now, sleep, setEvent, recv) takes the run's
81
+ * next function id when it starts, and a replay matches each recorded result to a call by that
82
+ * id. Calls left to run concurrently would take ids in the order their earlier awaits happened
83
+ * to resolve, which differs between the first execution and a replay that reads results back
84
+ * from the database, and so would the ledger's `seq` and the approval ids. Queued, each call
85
+ * starts once the one before it has settled, so the order is the order the workflow made them.
86
+ */
87
+ function serial<T>(run: Run, call: () => Promise<T>): Promise<T> {
88
+ // A failure rejects its caller; the calls queued after it still run, since the workflow may
89
+ // have caught it. Once the workflow body itself has ended, nothing queued may run. The body
90
+ // awaits only ctx calls, so one macrotask after the call before settles, the body has either
91
+ // caught the failure and gone on or ended; the rejection alone reaches it in the same
92
+ // microtask flush that would start this call.
93
+ const next = run.tail
94
+ .then(() => new Promise((r) => setImmediate(r)))
95
+ .then(() => {
96
+ refuseIfEnded(run);
97
+ return call();
98
+ });
99
+ // Settled either way, and holding nothing: the queue keeps neither the output nor the error.
100
+ run.tail = next.then(noop, noop);
101
+ return next;
102
+ }
103
+
104
+ const noop = () => {};
105
+
106
+ /** Throws once the workflow body has returned or thrown: a call left queued must not run then. */
107
+ function refuseIfEnded(run: Run) {
108
+ if (run.ended) {
109
+ throw new SanomaError("run_ended", `run ${run.id} has ended; a call queued behind its failure was not made`, {
110
+ runId: run.id,
111
+ });
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Where a call is, as its records say: its outline node's `path`, and the step's name, as the
117
+ * outline resolved it (`OutlineNode`'s `name`), or for a call the outline could not name, the
118
+ * operation a computed id called, or an approval's title that is no literal.
119
+ */
120
+ export interface Placed {
121
+ node: string;
122
+ name: string;
123
+ }
124
+
125
+ /** The step's name the outline gives a call's node: none for a computed op without one, or an approval's title that is no literal. */
126
+ const named = (node: CallNode) => ("name" in node ? node.name : undefined);
127
+
128
+ /**
129
+ * The run's `ctx`: the operations and built-ins its workflow `uses`, and nothing else. Every
130
+ * member that writes a record is placed in the outline where the workflow calls it (`placeCall`)
131
+ * and goes through the run's queue (`serial`), its records carrying the node and the step's
132
+ * name; `ctx.now()` only reads the clock, queued, and `ctx.runId` is a value. `ctx.all` alone
133
+ * skips the queue: it makes no DBOS call or record itself, it only calls its members, whose ctx
134
+ * calls are queued. Queued, it would hold the queue until its members settled, and their calls,
135
+ * queued behind it, would never start.
136
+ */
137
+ export function buildCtx(wf: WorkflowDefinition<any, any>, run: Run): any {
138
+ const uses = wf.uses as readonly Use[];
139
+ /** A member placed where it is called, then `call`ed with its node. A refusal rejects, as a member's failure always has. */
140
+ const placed = (what: string, call: (node: CallNode, ...args: any[]) => Promise<unknown>) =>
141
+ async function member(...args: unknown[]) {
142
+ return call(placeCall(run, member, what), ...args);
143
+ };
144
+ const members: Record<string, any> = {};
145
+ for (const op of uses.filter(isOp)) {
146
+ const vendor = (members[op.vendor] ??= {});
147
+ const resource = (vendor[op.resource] ??= {});
148
+ resource[op.name] = placed(op.id, (node, input) =>
149
+ serial(run, () => callOp(run, op.id, input, { node: node.path, name: named(node) ?? op.id })),
150
+ );
151
+ }
152
+ members.runId = run.id;
153
+ members.now = () => serial(run, () => DBOS.now());
154
+ if (uses.includes("approval")) {
155
+ members.approval = placed("approval", (node, title: string, req: ApprovalRequest) =>
156
+ serial(run, () =>
157
+ awaitApproval(run, title, checkApproval(title, req), { node: node.path, name: named(node) ?? title }),
158
+ ),
159
+ );
160
+ }
161
+ if (uses.includes("sleep")) {
162
+ members.sleep = placed("sleep", (node, req) =>
163
+ // The outline names every sleep.
164
+ serial(run, () => sleep(run, req, { node: node.path, name: named(node)! })),
165
+ );
166
+ }
167
+ if (uses.includes("all")) members.all = placed("all", (node, list) => all(run, list, node.path));
168
+ return guarded(members, "ctx", wf.name);
169
+ }
170
+
171
+ /** `members`, each object in it included, refusing any member not in it (`strict`). */
172
+ function guarded(members: Record<string, unknown>, path: string, workflow: string): Record<string, unknown> {
173
+ const out: Record<string, unknown> = {};
174
+ for (const [key, value] of Object.entries(members)) {
175
+ out[key] =
176
+ typeof value === "object" && value !== null
177
+ ? guarded(value as Record<string, unknown>, `${path}.${key}`, workflow)
178
+ : value;
179
+ }
180
+ return strict(out, path, workflow);
181
+ }
182
+
183
+ /** Where a `ctx` member was called from, as a stack frame says. */
184
+ interface CallSite {
185
+ file: string;
186
+ line: number;
187
+ column: number;
188
+ }
189
+
190
+ /**
191
+ * The frame above `fn`, the `ctx` member running, read from a stack trace rather than
192
+ * `util.getCallSites`: a test runner that transforms the source maps the trace back to it, and
193
+ * Node runs TypeScript stripped in place, so the position is the file's either way. One frame is
194
+ * captured: the rest would only be formatted and dropped.
195
+ */
196
+ function callSite(fn: Function): CallSite | undefined {
197
+ const holder: { stack?: string } = {};
198
+ const limit = Error.stackTraceLimit;
199
+ Error.stackTraceLimit = 1;
200
+ try {
201
+ Error.captureStackTrace(holder, fn);
202
+ } finally {
203
+ Error.stackTraceLimit = limit;
204
+ }
205
+ // `at run (/dir/file.ts:21:37)`, `at /dir/file.ts:21:37` or `at async run (file:///dir/file.ts:21:37)`.
206
+ const frame = holder.stack?.split("\n")[1]?.trim();
207
+ const m = frame === undefined ? null : /^at (?:.*?\()?(.+?):(\d+):(\d+)\)?$/.exec(frame);
208
+ const file = m && scriptPath(m[1]);
209
+ return file ? { file, line: Number(m[2]), column: Number(m[3]) } : undefined;
210
+ }
211
+
212
+ /**
213
+ * The outline node the workflow calls a `ctx` member at: the call whose code holds the position
214
+ * the workflow called from (`callSite`). `what` is the member's path on `ctx` (`ghost.post.create`,
215
+ * `approval`, `all`). The outline refuses most ways around it before a run (`outlineBody`'s
216
+ * problems); this is the run's own check, for what reaches a `ctx` member from code the outline
217
+ * has no node for: another file, a function defined inside `run` the outline does not read,
218
+ * `arguments`, or a node of another kind. The run fails with `call_not_in_outline`, and every
219
+ * record the call writes names the node, so a run's steps are the outline's calls and nothing else.
220
+ */
221
+ function placeCall(run: Run, fn: Function, what: string): CallNode {
222
+ const { outline, workflow } = run;
223
+ const site = callSite(fn);
224
+ if (!site) throw notInOutline(run, what, site, "which the outline cannot place");
225
+ if (site.file !== outline.file)
226
+ throw notInOutline(run, what, site, `which is not in ${workflow}'s file ${outline.file}`);
227
+ const offset = offsetOf(outline.lineStarts, site.line, site.column);
228
+ const node = offset === undefined ? undefined : callAt(outline.calls, offset);
229
+ if (!node) throw notInOutline(run, what, site, `which is no ctx call in ${workflow}'s outline`);
230
+ // The member called is the node's call: `ctx.all` at an `all` or `each`, an operation at its op.
231
+ if (!fitsOp(callName(node), what)) throw notInOutline(run, what, site, `where the outline has ctx.${callName(node)}`);
232
+ return node;
233
+ }
234
+
235
+ function notInOutline(run: Run, what: string, site: CallSite | undefined, why: string): SanomaError {
236
+ const where = site ? `${site.file}:${site.line}:${site.column}` : "a place the stack does not show";
237
+ return new SanomaError(
238
+ "call_not_in_outline",
239
+ `ctx.${what} was called from ${where}, ${why}: call ctx directly in run, not from a helper or a function ` +
240
+ "defined inside run (inline it), so the run's graph shows the call",
241
+ { call: `ctx.${what}`, site: where, workflow: run.workflow },
242
+ );
243
+ }
244
+
245
+ /** Refuses anything not declared in `uses`, with a message that says so. */
246
+ function strict<T extends object>(obj: T, path: string, workflow: string): T {
247
+ return new Proxy(obj, {
248
+ get(target, key, receiver) {
249
+ if (typeof key === "symbol" || key === "then" || key in target) return Reflect.get(target, key, receiver);
250
+ throw new Error(`${path}.${key} is not available: add it to \`uses\` in workflow "${workflow}"`);
251
+ },
252
+ });
253
+ }
254
+
255
+ /** Checks a policy's answer, and copies it so nothing else the policy returned is recorded. */
256
+ function checkDecision(decision: unknown, opId: string): RecordedDecision {
257
+ const parsed = DecisionSchema.safeParse(decision);
258
+ if (parsed.success) return parsed.data;
259
+ const why = parsed.error.issues[0]?.message ?? "not a decision";
260
+ throw new Error(
261
+ `The policy returned ${shown(decision)} for ${opId}: ${why}; use allow(), deny(reason) or approve(approver)`,
262
+ );
263
+ }
264
+
265
+ /** The resource instance the call acts on, from the operation's `target`, when it declares one. */
266
+ function targetOf(op: Op, input: unknown): { target?: string } {
267
+ if (!op.target) return {};
268
+ const target: unknown = op.target(input);
269
+ if (typeof target !== "string") {
270
+ throw new Error(`${op.id}: \`target\` returned ${typeof target}, not a string`);
271
+ }
272
+ return { target };
273
+ }
274
+
275
+ /** Asks the policy about one call, and checks its answer. Runs inside a step, so it must have no side effects of its own. */
276
+ export async function decide(run: Run, op: Op, input: unknown): Promise<RecordedDecision> {
277
+ const call: PolicyCall = {
278
+ op: policyOpOf(op),
279
+ effect: op.effect,
280
+ ...targetOf(op, input),
281
+ input,
282
+ actor: run.actor,
283
+ run: { id: run.id, workflow: run.workflow, approvals: structuredClone(run.approvals) },
284
+ };
285
+ let answer: unknown;
286
+ try {
287
+ answer = await run.state.policy(call);
288
+ } catch (err) {
289
+ // Named here, so the run's error and the ledger say which call the policy failed on, with
290
+ // the code the policy's error had.
291
+ if (isInfrastructureError(err, run.state)) throw err;
292
+ throw keepCode(new Error(`The policy failed deciding ${op.id}: ${errorMessage(err)}`, { cause: err }), err);
293
+ }
294
+ const decision = checkDecision(answer, op.id);
295
+ const policyVersion = run.state.policy.version;
296
+ return policyVersion === undefined ? decision : { ...decision, policyVersion };
297
+ }
298
+
299
+ async function callOp(run: Run, id: string, input: unknown, place: Placed) {
300
+ // The worker's declaration, never the workflow's: its effect, schemas and retry setting.
301
+ const op = run.state.ops.get(id);
302
+ // A sandbox run calls its own fakes, never a vendor.
303
+ const fn = run.sandbox === undefined ? run.state.drivers.get(id) : run.fakeDrivers?.get(id);
304
+ if (!op || !fn) {
305
+ throw new Error(
306
+ run.sandbox === undefined
307
+ ? `No connector or driver for ${id} in this worker`
308
+ : `No connector or fake for ${id} in this worker: no fake its connectors attach implements it`,
309
+ );
310
+ }
311
+ // Only apply changes a declared resource; `resolveConfig` refuses any other workflow that uses such an operation.
312
+ if (isChangeOp(op, run.state.types) && run.workflow !== APPLY_WORKFLOW) {
313
+ throw new SanomaError("invalid_input", `${op.id} changes a declared resource, which only ${APPLY_WORKFLOW} does`, {
314
+ op: op.id,
315
+ workflow: run.workflow,
316
+ });
317
+ }
318
+ const parsed = parseOrThrow(op.input, input, `The input to ${op.id} does not match its schema`, { op: op.id });
319
+ // The call's identity (its ledger seq and id, and the driver's idempotency key) is taken
320
+ // before the first await, so it depends only on the order the workflow made its calls. A
321
+ // replay skips the step bodies but counts the same way. A call held for an approval is
322
+ // numbered before the approval's records, so its op.called sorts ahead of them.
323
+ const seq = run.seq++;
324
+ // The decision is recorded as a step, so a replay reuses it instead of asking a policy
325
+ // that may have changed since. The policy must still be deterministic: a run that fails
326
+ // before the step is recorded asks again.
327
+ const decision = await DBOS.runStep(() => decide(run, op, parsed), { name: `policy:${op.id}` });
328
+ const logged = recorded(op, parsed);
329
+ const call = { type: "op.called", op: op.id, ...place, effect: op.effect, input: logged, decision } as const;
330
+
331
+ if (decision.kind === "deny") {
332
+ const err = new PolicyDeniedError(op.id, decision.reason);
333
+ await writeFailure(run, entry(run, { ...call, error: errorInfo(err), durationMs: 0 }, { seq }), err);
334
+ throw err;
335
+ }
336
+ if (decision.kind === "approve") {
337
+ const title = decision.title ?? `${op.id} needs ${approverLabel(decision.approver)}`;
338
+ // A hold covers the call it held, and any other operations the policy named.
339
+ const covers = [...new Set([op.id, ...(decision.covers ?? [])])];
340
+ try {
341
+ await awaitApproval(run, title, { approver: decision.approver, covers }, place, {
342
+ op: op.id,
343
+ seq,
344
+ input: logged,
345
+ });
346
+ } catch (err) {
347
+ if (errorCode(err) === "approval_rejected") {
348
+ const approval = (err as SanomaError).data.approvalId as string;
349
+ const record = entry(run, { ...call, approval, error: errorInfo(err), durationMs: 0 }, { seq });
350
+ await writeFailure(run, record, err);
351
+ }
352
+ throw err;
353
+ }
354
+ }
355
+
356
+ const started = Date.now();
357
+ // The try DBOS is on, as it passes it to the step. The last try's error is not retried, so
358
+ // DBOS records and throws it as the vendor gave it, never wrapped with the others.
359
+ let attempt: number | undefined;
360
+ let result: { output: unknown; at: number; durationMs: number; attempt: number };
361
+ try {
362
+ result = await DBOS.runStep(
363
+ async () => {
364
+ const at = Date.now();
365
+ attempt = DBOS.stepStatus?.currentAttempt ?? 1;
366
+ const secrets = run.sandbox === undefined ? run.state.secrets : run.fakeSecrets!;
367
+ const context: CallContext = {
368
+ idempotencyKey: `${run.id}:${seq}`,
369
+ runId: run.id,
370
+ opId: op.id,
371
+ attempt,
372
+ secrets,
373
+ };
374
+ // A live driver's `env` is checked here, inside the step, by the guard `indexDrivers` put
375
+ // on its function: the refusal is the call's failure, recorded and never retried.
376
+ const output = op.output.parse(await fn(parsed, context));
377
+ return { output, at, durationMs: Date.now() - at, attempt };
378
+ },
379
+ {
380
+ name: op.id,
381
+ retriesAllowed: op.idempotent,
382
+ maxAttempts: MAX_ATTEMPTS,
383
+ shouldRetry: (e) => shouldRetry(e) && (attempt ?? 1) < MAX_ATTEMPTS,
384
+ },
385
+ );
386
+ } catch (err) {
387
+ // The step failed for good; on replay DBOS rethrows the recorded error, and this record
388
+ // gets the same id. A success record would too, so a call has one op.called at most.
389
+ const record = entry(
390
+ run,
391
+ { ...call, error: errorInfo(err), durationMs: Date.now() - started, ...(attempt ? { attempt } : {}) },
392
+ { seq, at: started },
393
+ );
394
+ if (isInfrastructureError(err, run.state)) skipped(record, `the call was interrupted (${errorMessage(err)})`);
395
+ else await writeFailure(run, record, err);
396
+ throw err;
397
+ }
398
+ const { output, at, durationMs } = result;
399
+ try {
400
+ await write(
401
+ run,
402
+ entry(run, { ...call, output: recorded(op, output), durationMs, attempt: result.attempt }, { seq, at }),
403
+ );
404
+ } catch (err) {
405
+ // The vendor acted, and nothing records it. A workflow that caught this and called again
406
+ // would repeat the side effect under a new key, so the run makes no further calls.
407
+ run.ended = true;
408
+ throw new Error(`${op.id} succeeded, but the ledger could not record it: ${errorMessage(err)}`, { cause: err });
409
+ }
410
+ return output;
411
+ }
412
+
413
+ /**
414
+ * Checks a `ctx.approval` request before any DBOS call, so a bad one fails the run with
415
+ * `invalid_input`: who may decide it, and the operations it covers, as ids (none unless named).
416
+ */
417
+ function checkApproval(title: string, req: ApprovalRequest): CheckedApproval {
418
+ const checked = parseOrThrow(ApprovalRequestSchema, req, `ctx.approval("${title}")`, { title });
419
+ return { ...checked, covers: checked.covers ?? [] };
420
+ }
421
+
422
+ /** Checks a sleep request before any DBOS call, so a bad one fails the run with `invalid_input`. */
423
+ function checkSleep(req: unknown): SleepRequest {
424
+ const timed = typeof req === "object" && req !== null && "until" in req;
425
+ return parseOrThrow(timed ? SleepUntil : SleepFor, req, `ctx.sleep(${shown(req)})`);
426
+ }
427
+
428
+ /**
429
+ * Calls the members one after another, each awaited before the next starts, each inside its
430
+ * group (`currentGroup`), so every record its calls write carries it. The first failure stops
431
+ * the group and is rethrown as it is. The id is the run's next `seq` when ctx.all begins, which
432
+ * depends only on the calls made before it, so a replay names the group the same.
433
+ */
434
+ async function all(run: Run, list: unknown, node: string): Promise<unknown[]> {
435
+ const outer = currentGroup.getStore();
436
+ if (outer) {
437
+ throw new SanomaError("invalid_input", "ctx.all cannot be nested: a member of a ctx.all called ctx.all", {
438
+ group: outer.id,
439
+ });
440
+ }
441
+ // Two groups' calls would interleave on the run's queue, and neither would read as one fan-out.
442
+ if (run.inAll) throw new SanomaError("invalid_input", "a ctx.all is already running: await it before the next");
443
+ const members = parseOrThrow(AllMembers, list, "ctx.all");
444
+ const id = `all:${run.seq}`;
445
+ const outputs: unknown[] = [];
446
+ run.inAll = true;
447
+ try {
448
+ for (const [index, member] of members.entries()) {
449
+ outputs.push(await currentGroup.run({ id, index, size: members.length, node }, member));
450
+ }
451
+ } finally {
452
+ run.inAll = false;
453
+ }
454
+ return outputs;
455
+ }
456
+
457
+ /**
458
+ * Waits, durably, until the time or for the duration the request names, and records when it
459
+ * ends. For a duration that is the time the sleep started plus the duration, as the runtime saw
460
+ * it (`Date.now()`, which adds no DBOS call): a replay may compute another, but the store keeps
461
+ * the first record with the id, so the record says what the first execution waited for.
462
+ */
463
+ async function sleep(run: Run, raw: unknown, at: Placed) {
464
+ const req = checkSleep(raw);
465
+ const seq = run.seq++;
466
+ let until: number;
467
+ let ms: number;
468
+ if ("until" in req) {
469
+ until = typeof req.until === "number" ? req.until : Date.parse(req.until);
470
+ ms = until - (await DBOS.now());
471
+ } else {
472
+ ms =
473
+ (req.ms ?? 0) +
474
+ (req.seconds ?? 0) * 1_000 +
475
+ (req.minutes ?? 0) * 60_000 +
476
+ (req.hours ?? 0) * 3_600_000 +
477
+ (req.days ?? 0) * 86_400_000;
478
+ until = Date.now() + ms;
479
+ }
480
+ await write(run, entry(run, { type: "sleep.started", ...at, until }, { seq }));
481
+ // A time already past waits not at all, and adds no step. A sandbox run never waits.
482
+ if (ms > 0 && run.sandbox === undefined) await DBOS.sleep(ms);
483
+ }