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