@sanoma-ai/workflows 0.1.0 → 0.4.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 (184) hide show
  1. package/README.md +186 -76
  2. package/dist/apply-steps.d.ts +6 -0
  3. package/dist/apply-steps.js +93 -0
  4. package/dist/apply-steps.js.map +1 -0
  5. package/dist/apply.d.ts +197 -0
  6. package/dist/apply.js +470 -0
  7. package/dist/apply.js.map +1 -0
  8. package/dist/approvals.d.ts +11 -6
  9. package/dist/approvals.js +38 -10
  10. package/dist/approvals.js.map +1 -1
  11. package/dist/call-workflow.d.ts +16 -0
  12. package/dist/call-workflow.js +35 -0
  13. package/dist/call-workflow.js.map +1 -0
  14. package/dist/call.d.ts +35 -2
  15. package/dist/call.js +123 -43
  16. package/dist/call.js.map +1 -1
  17. package/dist/cedar.d.ts +87 -0
  18. package/dist/cedar.js +365 -0
  19. package/dist/cedar.js.map +1 -0
  20. package/dist/client.d.ts +123 -9
  21. package/dist/client.js +355 -34
  22. package/dist/client.js.map +1 -1
  23. package/dist/collection.d.ts +107 -0
  24. package/dist/collection.js +306 -0
  25. package/dist/collection.js.map +1 -0
  26. package/dist/config.d.ts +42 -14
  27. package/dist/config.js +109 -28
  28. package/dist/config.js.map +1 -1
  29. package/dist/constructors.d.ts +2 -0
  30. package/dist/constructors.js +5 -0
  31. package/dist/constructors.js.map +1 -0
  32. package/dist/credentials.d.ts +25 -0
  33. package/dist/credentials.js +73 -5
  34. package/dist/credentials.js.map +1 -1
  35. package/dist/data.d.ts +2 -0
  36. package/dist/data.js +3 -0
  37. package/dist/data.js.map +1 -0
  38. package/dist/datafile.d.ts +38 -3
  39. package/dist/datafile.js +166 -13
  40. package/dist/datafile.js.map +1 -1
  41. package/dist/db.d.ts +5 -3
  42. package/dist/db.js +88 -21
  43. package/dist/db.js.map +1 -1
  44. package/dist/define.d.ts +10 -4
  45. package/dist/define.js +1 -5
  46. package/dist/define.js.map +1 -1
  47. package/dist/describe.d.ts +54 -8
  48. package/dist/describe.js +84 -11
  49. package/dist/describe.js.map +1 -1
  50. package/dist/direct.d.ts +9 -0
  51. package/dist/direct.js +11 -0
  52. package/dist/direct.js.map +1 -0
  53. package/dist/drift.d.ts +64 -12
  54. package/dist/drift.js +118 -41
  55. package/dist/drift.js.map +1 -1
  56. package/dist/errors.d.ts +16 -1
  57. package/dist/errors.js +30 -1
  58. package/dist/errors.js.map +1 -1
  59. package/dist/expectations.d.ts +4 -4
  60. package/dist/expectations.js +43 -9
  61. package/dist/expectations.js.map +1 -1
  62. package/dist/host.d.ts +56 -0
  63. package/dist/host.js +45 -0
  64. package/dist/host.js.map +1 -0
  65. package/dist/index.d.ts +13 -5
  66. package/dist/index.js +15 -5
  67. package/dist/index.js.map +1 -1
  68. package/dist/ledger.d.ts +12 -1
  69. package/dist/ledger.js +18 -11
  70. package/dist/ledger.js.map +1 -1
  71. package/dist/lint.d.ts +5 -4
  72. package/dist/lint.js +24 -7
  73. package/dist/lint.js.map +1 -1
  74. package/dist/lockfile.d.ts +36 -0
  75. package/dist/lockfile.js +69 -0
  76. package/dist/lockfile.js.map +1 -0
  77. package/dist/op.d.ts +9 -0
  78. package/dist/op.js.map +1 -1
  79. package/dist/outline.d.ts +2 -0
  80. package/dist/outline.js +1 -1
  81. package/dist/outline.js.map +1 -1
  82. package/dist/paths.d.ts +4 -0
  83. package/dist/paths.js +17 -3
  84. package/dist/paths.js.map +1 -1
  85. package/dist/person.d.ts +61 -0
  86. package/dist/person.js +69 -0
  87. package/dist/person.js.map +1 -0
  88. package/dist/plugin.js +1 -1
  89. package/dist/plugin.js.map +1 -1
  90. package/dist/policy.d.ts +51 -55
  91. package/dist/policy.js +14 -36
  92. package/dist/policy.js.map +1 -1
  93. package/dist/resource.d.ts +207 -25
  94. package/dist/resource.js +184 -30
  95. package/dist/resource.js.map +1 -1
  96. package/dist/resources.d.ts +76 -9
  97. package/dist/resources.js +170 -30
  98. package/dist/resources.js.map +1 -1
  99. package/dist/run.d.ts +31 -1
  100. package/dist/run.js +6 -1
  101. package/dist/run.js.map +1 -1
  102. package/dist/sandbox.js +5 -1
  103. package/dist/sandbox.js.map +1 -1
  104. package/dist/scenario.d.ts +25 -9
  105. package/dist/scenario.js +125 -27
  106. package/dist/scenario.js.map +1 -1
  107. package/dist/scenarios.d.ts +10 -6
  108. package/dist/scenarios.js +8 -4
  109. package/dist/scenarios.js.map +1 -1
  110. package/dist/schema.d.ts +32 -0
  111. package/dist/schema.js +225 -0
  112. package/dist/schema.js.map +1 -0
  113. package/dist/sdk.d.ts +34 -0
  114. package/dist/sdk.js +168 -0
  115. package/dist/sdk.js.map +1 -0
  116. package/dist/secret.d.ts +42 -0
  117. package/dist/secret.js +58 -0
  118. package/dist/secret.js.map +1 -0
  119. package/dist/shared.d.ts +188 -4
  120. package/dist/shared.js +165 -5
  121. package/dist/shared.js.map +1 -1
  122. package/dist/sites.d.ts +52 -0
  123. package/dist/sites.js +174 -0
  124. package/dist/sites.js.map +1 -0
  125. package/dist/source.d.ts +58 -0
  126. package/dist/source.js +54 -0
  127. package/dist/source.js.map +1 -0
  128. package/dist/version.d.ts +3 -2
  129. package/dist/version.js +3 -2
  130. package/dist/version.js.map +1 -1
  131. package/dist/worker.d.ts +6 -2
  132. package/dist/worker.js +37 -8
  133. package/dist/worker.js.map +1 -1
  134. package/dist/writer.d.ts +35 -0
  135. package/dist/writer.js +201 -0
  136. package/dist/writer.js.map +1 -0
  137. package/oxlint.json +5 -16
  138. package/package.json +11 -1
  139. package/src/apply-steps.ts +110 -0
  140. package/src/apply.ts +626 -0
  141. package/src/approvals.ts +49 -13
  142. package/src/call-workflow.ts +44 -0
  143. package/src/call.ts +173 -46
  144. package/src/cedar.ts +493 -0
  145. package/src/client.ts +450 -49
  146. package/src/collection.ts +431 -0
  147. package/src/config.ts +168 -48
  148. package/src/constructors.ts +4 -0
  149. package/src/credentials.ts +90 -5
  150. package/src/data.ts +10 -0
  151. package/src/datafile.ts +207 -15
  152. package/src/db.ts +90 -22
  153. package/src/define.ts +11 -9
  154. package/src/describe.ts +146 -23
  155. package/src/direct.ts +15 -0
  156. package/src/drift.ts +155 -45
  157. package/src/errors.ts +36 -1
  158. package/src/expectations.ts +46 -11
  159. package/src/host.ts +96 -0
  160. package/src/index.ts +56 -2
  161. package/src/ledger.ts +35 -17
  162. package/src/lint.ts +29 -12
  163. package/src/lockfile.ts +95 -0
  164. package/src/op.ts +9 -0
  165. package/src/outline.ts +1 -1
  166. package/src/paths.ts +18 -3
  167. package/src/person.ts +75 -0
  168. package/src/plugin.ts +1 -1
  169. package/src/policy.ts +59 -63
  170. package/src/resource.ts +302 -52
  171. package/src/resources.ts +226 -30
  172. package/src/run.ts +33 -1
  173. package/src/sandbox.ts +5 -1
  174. package/src/scenario.ts +175 -43
  175. package/src/scenarios.ts +15 -8
  176. package/src/schema.ts +261 -0
  177. package/src/sdk.ts +211 -0
  178. package/src/secret.ts +92 -0
  179. package/src/shared.ts +279 -6
  180. package/src/sites.ts +220 -0
  181. package/src/source.ts +82 -0
  182. package/src/version.ts +3 -2
  183. package/src/worker.ts +45 -11
  184. package/src/writer.ts +225 -0
package/src/approvals.ts CHANGED
@@ -1,12 +1,13 @@
1
1
  import { DBOS } from "@dbos-inc/dbos-sdk";
2
2
  import { z } from "zod";
3
- import { type ApprovalRequest, type ApprovalResult, type ApprovalState, notApprover, Principal } from "./define.ts";
3
+ import { type ApprovalRequest, type ApprovalResult, type ApprovalState, Principal } from "./define.ts";
4
4
  import { RejectedError } from "./errors.ts";
5
5
  import { entry, write } from "./ledger.ts";
6
6
  import { warn } from "./log.ts";
7
7
  import type { Placed } from "./call.ts";
8
8
  import type { Run } from "./run.ts";
9
- import { approverLabel, mayDecide } from "./shared.ts";
9
+ import { readPeople } from "./resources.ts";
10
+ import { approverLabel, OWN_CHANGE, refusalOf } from "./shared.ts";
10
11
 
11
12
  // The wire contract between a run waiting on approvals and whoever decides them. The client
12
13
  // imports this file and never the worker's.
@@ -50,23 +51,28 @@ export interface CheckedApproval extends Omit<ApprovalRequest, "covers"> {
50
51
  covers: string[];
51
52
  }
52
53
 
53
- /** A policy's hold: the call held, and the ledger `seq` its `op.called` record takes. */
54
+ /**
55
+ * A policy's hold: the call held (an operation, or `workflow:<name>` for a run's start), the
56
+ * ledger `seq` its `op.called` record takes (a start has none), and the rules that held it.
57
+ */
54
58
  export interface HeldCall {
55
59
  op: string;
56
- seq: number;
60
+ seq?: number;
57
61
  input: unknown;
62
+ reasons?: string[] | undefined;
58
63
  }
59
64
 
60
65
  /**
61
- * Waits, durably, for a decision from someone who may make it. Throws `RejectedError` on a
62
- * rejection. Without `held`, the workflow asked (`ctx.approval`); with it, the policy did, and
63
- * `place` is the held call's outline node and name.
66
+ * Waits, durably, for a decision from someone who may make it (`mayDecide`: the run's own actor
67
+ * only while nobody else could). Throws `RejectedError` on a rejection. Without `held`, the workflow asked
68
+ * (`ctx.approval`); with it, the policy did, and `place` is the held call's outline node and name
69
+ * (none for a run's start, which is no call in the outline).
64
70
  */
65
71
  export async function awaitApproval(
66
72
  run: Run,
67
73
  title: string,
68
74
  req: CheckedApproval,
69
- place: Placed,
75
+ place: Placed | undefined,
70
76
  held?: HeldCall,
71
77
  ): Promise<ApprovalResult> {
72
78
  const { covers } = req;
@@ -79,7 +85,7 @@ export async function awaitApproval(
79
85
  ...(req.details === undefined ? {} : { details: req.details }),
80
86
  requestedBy: held ? "policy" : "workflow",
81
87
  covers,
82
- ...(held ? { op: held.op, opSeq: held.seq, input: held.input } : {}),
88
+ ...(held && { op: held.op, ...heldOf(held), input: held.input }),
83
89
  status: "pending",
84
90
  requestedAt: 0,
85
91
  refused: [],
@@ -99,7 +105,7 @@ export async function awaitApproval(
99
105
  approver: state.approver,
100
106
  requestedBy: state.requestedBy,
101
107
  covers,
102
- ...(held ? { op: held.op, opSeq: held.seq } : {}),
108
+ ...(held && { op: held.op, ...heldOf(held) }),
103
109
  },
104
110
  { key: state.id, at: state.requestedAt },
105
111
  ),
@@ -113,9 +119,23 @@ export async function awaitApproval(
113
119
  const at = await DBOS.now();
114
120
  const parsed = ApprovalMessage.safeParse(raw);
115
121
  const msg = parsed.success ? parsed.data : undefined;
116
- if (!msg || !mayDecide(state, msg.by)) {
122
+ // The people matter only when the run's own actor decides (`refusalOf`): then they are read as
123
+ // the data files declare them now, in a step, so a replay decides the same. While those files
124
+ // have problems, someone else may be missing from them, so the starter is refused.
125
+ const refusal = !msg
126
+ ? null
127
+ : msg.by.id === run.actor.id
128
+ ? await DBOS.runStep(
129
+ async () => {
130
+ const { people, problems } = readPeople(run.state.root);
131
+ return refusalOf(state, msg.by, run, people) ?? (problems.length ? OWN_CHANGE : null);
132
+ },
133
+ { name: `decider:${state.id}` },
134
+ )
135
+ : (refusalOf(state, msg.by, run, []) ?? null);
136
+ if (!msg || refusal !== null) {
117
137
  const by = msg?.by.id ?? senderOf(raw);
118
- const reason = msg ? notApprover(state, msg.by) : "not a valid decision message";
138
+ const reason = refusal ?? "not a valid decision message";
119
139
  state.refused.push({
120
140
  ...(by === undefined ? {} : { by }),
121
141
  ...(msg?.id === undefined ? {} : { id: msg.id }),
@@ -133,12 +153,15 @@ export async function awaitApproval(
133
153
  );
134
154
  continue;
135
155
  }
156
+ // The run's own actor decides only when nobody else could, which the record says.
157
+ const alone = msg.by.id === run.actor.id ? { alone: true as const } : {};
136
158
  Object.assign(state, {
137
159
  status: statusOf(msg.decision),
138
160
  decidedBy: msg.by.id,
139
161
  decidedAt: at,
140
162
  note: msg.note,
141
163
  decidedWith: msg.id,
164
+ ...alone,
142
165
  });
143
166
  // The list first, then the ledger, then the decision event: whoever sees the event sees both.
144
167
  await DBOS.setEvent(APPROVALS_EVENT, all);
@@ -146,7 +169,14 @@ export async function awaitApproval(
146
169
  run,
147
170
  entry(
148
171
  run,
149
- { type: "approval.decided", approval: state.id, decision: msg.decision, by: msg.by.id, note: msg.note },
172
+ {
173
+ type: "approval.decided",
174
+ approval: state.id,
175
+ decision: msg.decision,
176
+ by: msg.by.id,
177
+ note: msg.note,
178
+ ...alone,
179
+ },
150
180
  { key: state.id, at },
151
181
  ),
152
182
  );
@@ -155,3 +185,9 @@ export async function awaitApproval(
155
185
  return { approvedBy: msg.by.id, at, note: msg.note };
156
186
  }
157
187
  }
188
+
189
+ /** What an approval and its request record keep of a hold besides the operation: its `seq` and rules, when it has them. */
190
+ const heldOf = ({ seq, reasons }: HeldCall) => ({
191
+ ...(seq !== undefined && { opSeq: seq }),
192
+ ...(reasons !== undefined && { reasons }),
193
+ });
@@ -0,0 +1,44 @@
1
+ import { z } from "zod";
2
+ import { defineWorkflow, type WorkflowDefinition } from "./define.ts";
3
+ import { isDirect } from "./direct.ts";
4
+ import type { Op } from "./op.ts";
5
+ import type { CALL_WORKFLOW } from "./shared.ts";
6
+
7
+ export { CALL_WORKFLOW } from "./shared.ts";
8
+
9
+ // The built-in `call` workflow: one operation that may be called directly (`isDirect`), as a run.
10
+ // `SanomaClient.call` starts it, as the person, when the policy holds a direct call for an
11
+ // approval, so the approval reaches the Inbox like any run's. It is a workflow like any other,
12
+ // held to the same lint: the call goes through `ctx`, under the policy, in the ledger.
13
+
14
+ /** The call workflow's input: the operation's id, and its input. */
15
+ export const CallInput = z.object({ op: z.string(), input: z.unknown() });
16
+
17
+ /**
18
+ * The call workflow for a config: it `uses` every operation that may be called directly and has
19
+ * a driver; `resolveConfig` adds it when there is one. A run calls `input.op` with `input.input`,
20
+ * and returns its output; inside it the call is made as the person who started the run, so a
21
+ * collection's save records them as `updatedBy`.
22
+ */
23
+ export function callWorkflow(
24
+ ops: ReadonlyMap<string, Op>,
25
+ drivers: ReadonlyMap<string, unknown>,
26
+ types: ReadonlyMap<string, unknown>,
27
+ ): WorkflowDefinition<readonly Op[], typeof CallInput> {
28
+ const uses = [...ops.values()]
29
+ .filter((op) => isDirect(op, types) && drivers.has(op.id))
30
+ .toSorted((a, b) => a.id.localeCompare(b.id));
31
+ return defineWorkflow({
32
+ // The literal, held to the one name: the outline finds a workflow by a string, or a const of its own file.
33
+ name: "call" satisfies typeof CALL_WORKFLOW,
34
+ title: "Call an operation",
35
+ trigger: "manual",
36
+ input: CallInput,
37
+ uses,
38
+ // `ctx` refuses an operation that is not in `uses`: one that may not be called directly, or has no driver.
39
+ run: async (ctx, input) => {
40
+ const [vendor = "", resource = "", name = ""] = input.op.split(".");
41
+ return ctx[vendor]![resource]![name]!(input.input);
42
+ },
43
+ });
44
+ }
package/src/call.ts CHANGED
@@ -3,7 +3,9 @@ import { awaitApproval, type CheckedApproval } from "./approvals.ts";
3
3
  import { offsetOf } from "./ast.ts";
4
4
  import {
5
5
  AllMembers,
6
+ type ApprovalState,
6
7
  type ApprovalRequest,
8
+ type Principal,
7
9
  ApprovalRequestSchema,
8
10
  SleepFor,
9
11
  type SleepRequest,
@@ -22,13 +24,24 @@ import {
22
24
  PolicyDeniedError,
23
25
  SanomaError,
24
26
  } from "./errors.ts";
27
+ import type { Collection } from "./collection.ts";
25
28
  import { currentGroup, entry, skipped, write, writeFailure } from "./ledger.ts";
26
29
  import { shown } from "./log.ts";
27
30
  import { type CallContext, isOp, type Op } from "./op.ts";
28
31
  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
+ import { allowAll, type Decision, type Policy, type PolicyCall, policyOpOf, type RecordedDecision } from "./policy.ts";
33
+ import type { Run, StartHold, WorkerState } from "./run.ts";
34
+ import { isChangeOp } from "./resource.ts";
35
+ import {
36
+ APPLY_WORKFLOW,
37
+ approverLabel,
38
+ CALL_WORKFLOW,
39
+ DATA_VENDOR,
40
+ errorMessage,
41
+ fitsOp,
42
+ type Saved,
43
+ WORKFLOW_ACTION,
44
+ } from "./shared.ts";
32
45
 
33
46
  /** DBOS took the run from this execution: it cancelled the run, or another process took it over. */
34
47
  const takenByDbos = (err: unknown) =>
@@ -50,7 +63,7 @@ export function isInfrastructureError(err: unknown, state: Pick<WorkerState, "st
50
63
  }
51
64
 
52
65
  /** The value as the ledger records it: each of the operation's `opaque` fields as `"<name>"`. */
53
- function recorded(op: Op, value: unknown): unknown {
66
+ export function recorded(op: Op, value: unknown): unknown {
54
67
  if (!op.opaque?.length || typeof value !== "object" || value === null || Array.isArray(value)) return value;
55
68
  const out: Record<string, unknown> = { ...value };
56
69
  for (const name of op.opaque) if (out[name] !== undefined) out[name] = `<${name}>`;
@@ -251,16 +264,6 @@ function strict<T extends object>(obj: T, path: string, workflow: string): T {
251
264
  });
252
265
  }
253
266
 
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
267
  /** The resource instance the call acts on, from the operation's `target`, when it declares one. */
265
268
  function targetOf(op: Op, input: unknown): { target?: string } {
266
269
  if (!op.target) return {};
@@ -271,35 +274,138 @@ function targetOf(op: Op, input: unknown): { target?: string } {
271
274
  return { target };
272
275
  }
273
276
 
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) },
277
+ /**
278
+ * Asks the policy about one call, with the record a collection's `save` or
279
+ * `delete` replaces as `before` (`beforeOf`). Runs inside a step, so it must have no side effects
280
+ * of its own.
281
+ */
282
+ export async function decide(run: Run, op: Op, input: unknown): Promise<RecordedDecision> {
283
+ const seen = { id: run.id, workflow: run.workflow, approvals: structuredClone(run.approvals) };
284
+ const { policy } = run.state;
285
+ // Keyed by the call (`seq` was taken for it), so a fake answers each read afresh.
286
+ const before = await beforeOf(policy, op, input, run.state.collections, (get, key) =>
287
+ driverOf(run, get)?.(
288
+ { key },
289
+ { idempotencyKey: `${run.id}:${run.seq}:before`, runId: run.id, opId: get, attempt: 1, secrets: secretsOf(run) },
290
+ ),
291
+ );
292
+ const call = { ...callOf(op, input, run.actor, seen), ...before };
293
+ return ask(policy, op.id, () => policy(call), run.state);
294
+ }
295
+
296
+ /** The run's function for an operation: its own fake's in a sandbox run, never a vendor's; else the driver's. */
297
+ const driverOf = (run: Run, id: string) => (run.sandbox === undefined ? run.state.drivers : run.fakeDrivers)?.get(id);
298
+
299
+ /** The secrets a run's calls read: its own in a sandbox run, else the stored ones. */
300
+ const secretsOf = (run: Run) => (run.sandbox === undefined ? run.state.secrets : run.fakeSecrets!);
301
+
302
+ /** One call of `op` as `actor` in `run`, as a policy sees it. */
303
+ const callOf = (op: Op, input: unknown, actor: Principal, run: PolicyCall["run"]): PolicyCall => ({
304
+ op: policyOpOf(op),
305
+ effect: op.effect,
306
+ ...targetOf(op, input),
307
+ input,
308
+ actor,
309
+ run,
310
+ });
311
+
312
+ /**
313
+ * For a collection's `save` or `delete`, the record it replaces or removes, read once with the
314
+ * collection's `get` (`read`): `{ before }` when there is one, else nothing, and nothing read
315
+ * under `allowAll`, which reads no call. A failed read is named, keeping its error's code.
316
+ */
317
+ export async function beforeOf(
318
+ policy: Policy,
319
+ op: Op,
320
+ input: unknown,
321
+ collections: ReadonlyMap<string, Collection>,
322
+ read: (get: string, key: unknown) => Promise<unknown> | undefined,
323
+ ): Promise<{ before?: unknown }> {
324
+ const collection = op.vendor === DATA_VENDOR ? collections.get(op.resource) : undefined;
325
+ if (policy === allowAll || !collection || (op.name !== "save" && op.name !== "delete")) return {};
326
+ const key =
327
+ op.name === "save"
328
+ ? (input as { record: Record<string, unknown> }).record[collection.key]
329
+ : (input as { key: unknown }).key;
330
+ let saved: Saved | null | undefined;
331
+ try {
332
+ saved = (await read(`${DATA_VENDOR}.${op.resource}.get`, key)) as Saved | null | undefined;
333
+ } catch (err) {
334
+ if (takenByDbos(err)) throw err;
335
+ throw keepCode(new Error(`Reading the record ${op.id} replaces failed: ${errorMessage(err)}`, { cause: err }), err);
336
+ }
337
+ return saved ? { before: saved.record } : {};
338
+ }
339
+
340
+ /**
341
+ * Asks `policy` about one call of `op` as `actor` in `run`: a direct call
342
+ * (`SanomaClient.call`), with the record a collection's write replaces as `before` (`beforeOf`).
343
+ */
344
+ export function decideCall(
345
+ policy: Policy,
346
+ op: Op,
347
+ input: unknown,
348
+ actor: Principal,
349
+ run: PolicyCall["run"],
350
+ before: Pick<PolicyCall, "before"> = {},
351
+ ): RecordedDecision {
352
+ const call = { ...callOf(op, input, actor, run), ...before };
353
+ return ask(policy, op.id, () => policy(call));
354
+ }
355
+
356
+ /**
357
+ * Asks the policy whether `actor` may start a run of `workflow` with `input` (`Policy.start`, the
358
+ * action `workflow:<name>`), as `SanomaClient.start` does before it queues one.
359
+ */
360
+ export function decideStart(
361
+ policy: Policy,
362
+ workflow: string,
363
+ input: unknown,
364
+ actor: Principal,
365
+ approvals: readonly ApprovalState[] = [],
366
+ ) {
367
+ return ask(policy, `${WORKFLOW_ACTION}${workflow}`, () => policy.start(actor, workflow, input, approvals));
368
+ }
369
+
370
+ /** The approval a start's `approve` answer waits on: whom it asks, its title, and the rules that held it. */
371
+ export function startHoldOf(
372
+ workflow: string,
373
+ { approver, title, reasons }: Extract<Decision, { kind: "approve" }>,
374
+ ): StartHold {
375
+ return {
376
+ approver,
377
+ title: title ?? `${WORKFLOW_ACTION}${workflow} needs ${approverLabel(approver)}`,
378
+ ...(reasons && { reasons }),
283
379
  };
284
- let answer: unknown;
380
+ }
381
+
382
+ /**
383
+ * The policy's answer about `id` (`answer`), with its version. A policy that fails is named in
384
+ * the error, with its error's code, unless the worker is stopping (`state`).
385
+ */
386
+ function ask(
387
+ policy: Policy,
388
+ id: string,
389
+ answer: () => Decision,
390
+ state: Pick<WorkerState, "stopped"> = { stopped: false },
391
+ ): RecordedDecision {
392
+ let decision: Decision;
285
393
  try {
286
- answer = await run.state.policy(call);
394
+ decision = answer();
287
395
  } catch (err) {
288
396
  // Named here, so the run's error and the ledger say which call the policy failed on, with
289
397
  // 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);
398
+ if (isInfrastructureError(err, state)) throw err;
399
+ throw keepCode(new Error(`The policy failed deciding ${id}: ${errorMessage(err)}`, { cause: err }), err);
292
400
  }
293
- const decision = checkDecision(answer, op.id);
294
- const policyVersion = run.state.policy.version;
295
- return policyVersion === undefined ? decision : { ...decision, policyVersion };
401
+ return policy.version === undefined ? decision : { ...decision, policyVersion: policy.version };
296
402
  }
297
403
 
298
404
  async function callOp(run: Run, id: string, input: unknown, place: Placed) {
299
405
  // The worker's declaration, never the workflow's: its effect, schemas and retry setting.
300
406
  const op = run.state.ops.get(id);
301
407
  // 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);
408
+ const fn = driverOf(run, id);
303
409
  if (!op || !fn) {
304
410
  throw new Error(
305
411
  run.sandbox === undefined
@@ -307,6 +413,13 @@ async function callOp(run: Run, id: string, input: unknown, place: Placed) {
307
413
  : `No connector or fake for ${id} in this worker: no fake its connectors attach implements it`,
308
414
  );
309
415
  }
416
+ // Only apply changes a declared resource; `resolveConfig` refuses any other workflow that uses such an operation.
417
+ if (isChangeOp(op, run.state.types) && run.workflow !== APPLY_WORKFLOW) {
418
+ throw new SanomaError("invalid_input", `${op.id} changes a declared resource, which only ${APPLY_WORKFLOW} does`, {
419
+ op: op.id,
420
+ workflow: run.workflow,
421
+ });
422
+ }
310
423
  const parsed = parseOrThrow(op.input, input, `The input to ${op.id} does not match its schema`, { op: op.id });
311
424
  // The call's identity (its ledger seq and id, and the driver's idempotency key) is taken
312
425
  // before the first await, so it depends only on the order the workflow made its calls. A
@@ -316,24 +429,27 @@ async function callOp(run: Run, id: string, input: unknown, place: Placed) {
316
429
  // The decision is recorded as a step, so a replay reuses it instead of asking a policy
317
430
  // that may have changed since. The policy must still be deterministic: a run that fails
318
431
  // before the step is recorded asks again.
319
- const decision = await DBOS.runStep(() => decide(run, op, parsed), { name: `policy:${op.id}` });
432
+ const decideStep = () => DBOS.runStep(() => decide(run, op, parsed), { name: `policy:${op.id}` });
433
+ let decision = await decideStep();
320
434
  const logged = recorded(op, parsed);
321
435
  const call = { type: "op.called", op: op.id, ...place, effect: op.effect, input: logged, decision } as const;
322
436
 
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") {
437
+ // Each hold is lifted in turn: once one is approved, the policy is asked again, with it.
438
+ for (;;) {
439
+ if (decision.kind === "deny") {
440
+ const err = new PolicyDeniedError(op.id, decision.reason);
441
+ await writeFailure(run, entry(run, { ...call, decision, error: errorInfo(err), durationMs: 0 }, { seq }), err);
442
+ throw err;
443
+ }
444
+ if (decision.kind !== "approve") break;
329
445
  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
446
  try {
333
- await awaitApproval(run, title, { approver: decision.approver, covers }, place, {
447
+ // A hold covers the call it held.
448
+ await awaitApproval(run, title, { approver: decision.approver, covers: [op.id] }, place, {
334
449
  op: op.id,
335
450
  seq,
336
451
  input: logged,
452
+ reasons: decision.reasons,
337
453
  });
338
454
  } catch (err) {
339
455
  if (errorCode(err) === "approval_rejected") {
@@ -343,6 +459,7 @@ async function callOp(run: Run, id: string, input: unknown, place: Placed) {
343
459
  }
344
460
  throw err;
345
461
  }
462
+ decision = await decideStep();
346
463
  }
347
464
 
348
465
  const started = Date.now();
@@ -355,7 +472,16 @@ async function callOp(run: Run, id: string, input: unknown, place: Placed) {
355
472
  async () => {
356
473
  const at = Date.now();
357
474
  attempt = DBOS.stepStatus?.currentAttempt ?? 1;
358
- const context: CallContext = { idempotencyKey: `${run.id}:${seq}`, runId: run.id, opId: op.id, attempt };
475
+ const secrets = secretsOf(run);
476
+ const context: CallContext = {
477
+ idempotencyKey: `${run.id}:${seq}`,
478
+ runId: run.id,
479
+ opId: op.id,
480
+ attempt,
481
+ secrets,
482
+ // A call run is the person's direct call held for an approval: made as them, as it would have been.
483
+ by: run.workflow === CALL_WORKFLOW ? run.actor.id : `run:${run.id}`,
484
+ };
359
485
  // A live driver's `env` is checked here, inside the step, by the guard `indexDrivers` put
360
486
  // on its function: the refusal is the call's failure, recorded and never retried.
361
487
  const output = op.output.parse(await fn(parsed, context));
@@ -441,13 +567,14 @@ async function all(run: Run, list: unknown, node: string): Promise<unknown[]> {
441
567
 
442
568
  /**
443
569
  * 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
570
+ * ends. For a duration that is the record's `at` plus the duration, one read of the runtime's
571
+ * clock (`Date.now()`, which adds no DBOS call): a replay may compute another, but the store keeps
446
572
  * the first record with the id, so the record says what the first execution waited for.
447
573
  */
448
574
  async function sleep(run: Run, raw: unknown, at: Placed) {
449
575
  const req = checkSleep(raw);
450
576
  const seq = run.seq++;
577
+ const started = Date.now();
451
578
  let until: number;
452
579
  let ms: number;
453
580
  if ("until" in req) {
@@ -460,9 +587,9 @@ async function sleep(run: Run, raw: unknown, at: Placed) {
460
587
  (req.minutes ?? 0) * 60_000 +
461
588
  (req.hours ?? 0) * 3_600_000 +
462
589
  (req.days ?? 0) * 86_400_000;
463
- until = Date.now() + ms;
590
+ until = started + ms;
464
591
  }
465
- await write(run, entry(run, { type: "sleep.started", ...at, until }, { seq }));
592
+ await write(run, entry(run, { type: "sleep.started", ...at, until }, { seq, at: started }));
466
593
  // A time already past waits not at all, and adds no step. A sandbox run never waits.
467
594
  if (ms > 0 && run.sandbox === undefined) await DBOS.sleep(ms);
468
595
  }