@sanoma-ai/workflows 0.2.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.
- package/README.md +121 -62
- package/dist/approvals.d.ts +11 -6
- package/dist/approvals.js +38 -10
- package/dist/approvals.js.map +1 -1
- package/dist/call-workflow.d.ts +16 -0
- package/dist/call-workflow.js +35 -0
- package/dist/call-workflow.js.map +1 -0
- package/dist/call.d.ts +33 -4
- package/dist/call.js +107 -42
- package/dist/call.js.map +1 -1
- package/dist/cedar.d.ts +87 -0
- package/dist/cedar.js +365 -0
- package/dist/cedar.js.map +1 -0
- package/dist/client.d.ts +85 -7
- package/dist/client.js +289 -25
- package/dist/client.js.map +1 -1
- package/dist/collection.d.ts +107 -0
- package/dist/collection.js +306 -0
- package/dist/collection.js.map +1 -0
- package/dist/config.d.ts +42 -14
- package/dist/config.js +82 -18
- package/dist/config.js.map +1 -1
- package/dist/constructors.d.ts +2 -0
- package/dist/constructors.js +5 -0
- package/dist/constructors.js.map +1 -0
- package/dist/data.d.ts +2 -0
- package/dist/data.js +3 -0
- package/dist/data.js.map +1 -0
- package/dist/datafile.d.ts +28 -2
- package/dist/datafile.js +118 -19
- package/dist/datafile.js.map +1 -1
- package/dist/db.d.ts +5 -3
- package/dist/db.js +88 -21
- package/dist/db.js.map +1 -1
- package/dist/define.d.ts +10 -4
- package/dist/define.js +1 -5
- package/dist/define.js.map +1 -1
- package/dist/describe.d.ts +45 -5
- package/dist/describe.js +72 -7
- package/dist/describe.js.map +1 -1
- package/dist/direct.d.ts +9 -0
- package/dist/direct.js +11 -0
- package/dist/direct.js.map +1 -0
- package/dist/errors.d.ts +16 -1
- package/dist/errors.js +30 -1
- package/dist/errors.js.map +1 -1
- package/dist/expectations.d.ts +3 -3
- package/dist/expectations.js +16 -7
- package/dist/expectations.js.map +1 -1
- package/dist/index.d.ts +8 -4
- package/dist/index.js +10 -4
- package/dist/index.js.map +1 -1
- package/dist/ledger.d.ts +12 -1
- package/dist/ledger.js +18 -11
- package/dist/ledger.js.map +1 -1
- package/dist/lint.d.ts +5 -4
- package/dist/lint.js +24 -7
- package/dist/lint.js.map +1 -1
- package/dist/op.d.ts +6 -0
- package/dist/op.js.map +1 -1
- package/dist/outline.d.ts +2 -0
- package/dist/outline.js +1 -1
- package/dist/outline.js.map +1 -1
- package/dist/paths.d.ts +4 -0
- package/dist/paths.js +17 -3
- package/dist/paths.js.map +1 -1
- package/dist/person.d.ts +61 -0
- package/dist/person.js +69 -0
- package/dist/person.js.map +1 -0
- package/dist/plugin.js +1 -1
- package/dist/plugin.js.map +1 -1
- package/dist/policy.d.ts +51 -55
- package/dist/policy.js +14 -36
- package/dist/policy.js.map +1 -1
- package/dist/resource.d.ts +21 -5
- package/dist/resource.js +12 -3
- package/dist/resource.js.map +1 -1
- package/dist/resources.d.ts +58 -6
- package/dist/resources.js +119 -26
- package/dist/resources.js.map +1 -1
- package/dist/run.d.ts +17 -1
- package/dist/run.js.map +1 -1
- package/dist/scenario.d.ts +16 -9
- package/dist/scenario.js +93 -20
- package/dist/scenario.js.map +1 -1
- package/dist/scenarios.d.ts +10 -6
- package/dist/scenarios.js +8 -4
- package/dist/scenarios.js.map +1 -1
- package/dist/schema.d.ts +32 -0
- package/dist/schema.js +225 -0
- package/dist/schema.js.map +1 -0
- package/dist/sdk.d.ts +34 -0
- package/dist/sdk.js +168 -0
- package/dist/sdk.js.map +1 -0
- package/dist/shared.d.ts +181 -5
- package/dist/shared.js +160 -7
- package/dist/shared.js.map +1 -1
- package/dist/sites.d.ts +52 -0
- package/dist/sites.js +174 -0
- package/dist/sites.js.map +1 -0
- package/dist/version.d.ts +3 -2
- package/dist/version.js +3 -2
- package/dist/version.js.map +1 -1
- package/dist/worker.d.ts +3 -2
- package/dist/worker.js +24 -4
- package/dist/worker.js.map +1 -1
- package/oxlint.json +5 -16
- package/package.json +8 -3
- package/src/approvals.ts +49 -13
- package/src/call-workflow.ts +44 -0
- package/src/call.ts +157 -45
- package/src/cedar.ts +493 -0
- package/src/client.ts +368 -37
- package/src/collection.ts +431 -0
- package/src/config.ts +131 -34
- package/src/constructors.ts +4 -0
- package/src/data.ts +10 -0
- package/src/datafile.ts +141 -21
- package/src/db.ts +90 -22
- package/src/define.ts +11 -9
- package/src/describe.ts +113 -10
- package/src/direct.ts +15 -0
- package/src/errors.ts +36 -1
- package/src/expectations.ts +17 -8
- package/src/index.ts +33 -2
- package/src/ledger.ts +35 -17
- package/src/lint.ts +29 -12
- package/src/op.ts +6 -0
- package/src/outline.ts +1 -1
- package/src/paths.ts +18 -3
- package/src/person.ts +75 -0
- package/src/plugin.ts +1 -1
- package/src/policy.ts +59 -63
- package/src/resource.ts +35 -8
- package/src/resources.ts +165 -24
- package/src/run.ts +18 -1
- package/src/scenario.ts +128 -33
- package/src/scenarios.ts +15 -8
- package/src/schema.ts +261 -0
- package/src/sdk.ts +211 -0
- package/src/shared.ts +271 -8
- package/src/sites.ts +220 -0
- package/src/version.ts +3 -2
- package/src/worker.ts +29 -6
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,
|
|
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 {
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
62
|
-
* rejection. Without `held`, the workflow asked
|
|
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
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
{
|
|
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,14 +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 {
|
|
30
|
-
import type { Run, WorkerState } from "./run.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";
|
|
31
34
|
import { isChangeOp } from "./resource.ts";
|
|
32
|
-
import {
|
|
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";
|
|
33
45
|
|
|
34
46
|
/** DBOS took the run from this execution: it cancelled the run, or another process took it over. */
|
|
35
47
|
const takenByDbos = (err: unknown) =>
|
|
@@ -51,7 +63,7 @@ export function isInfrastructureError(err: unknown, state: Pick<WorkerState, "st
|
|
|
51
63
|
}
|
|
52
64
|
|
|
53
65
|
/** The value as the ledger records it: each of the operation's `opaque` fields as `"<name>"`. */
|
|
54
|
-
function recorded(op: Op, value: unknown): unknown {
|
|
66
|
+
export function recorded(op: Op, value: unknown): unknown {
|
|
55
67
|
if (!op.opaque?.length || typeof value !== "object" || value === null || Array.isArray(value)) return value;
|
|
56
68
|
const out: Record<string, unknown> = { ...value };
|
|
57
69
|
for (const name of op.opaque) if (out[name] !== undefined) out[name] = `<${name}>`;
|
|
@@ -252,16 +264,6 @@ function strict<T extends object>(obj: T, path: string, workflow: string): T {
|
|
|
252
264
|
});
|
|
253
265
|
}
|
|
254
266
|
|
|
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
267
|
/** The resource instance the call acts on, from the operation's `target`, when it declares one. */
|
|
266
268
|
function targetOf(op: Op, input: unknown): { target?: string } {
|
|
267
269
|
if (!op.target) return {};
|
|
@@ -272,35 +274,138 @@ function targetOf(op: Op, input: unknown): { target?: string } {
|
|
|
272
274
|
return { target };
|
|
273
275
|
}
|
|
274
276
|
|
|
275
|
-
/**
|
|
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
|
+
*/
|
|
276
282
|
export async function decide(run: Run, op: Op, input: unknown): Promise<RecordedDecision> {
|
|
277
|
-
const
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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 }),
|
|
284
379
|
};
|
|
285
|
-
|
|
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;
|
|
286
393
|
try {
|
|
287
|
-
|
|
394
|
+
decision = answer();
|
|
288
395
|
} catch (err) {
|
|
289
396
|
// Named here, so the run's error and the ledger say which call the policy failed on, with
|
|
290
397
|
// the code the policy's error had.
|
|
291
|
-
if (isInfrastructureError(err,
|
|
292
|
-
throw keepCode(new Error(`The policy failed deciding ${
|
|
398
|
+
if (isInfrastructureError(err, state)) throw err;
|
|
399
|
+
throw keepCode(new Error(`The policy failed deciding ${id}: ${errorMessage(err)}`, { cause: err }), err);
|
|
293
400
|
}
|
|
294
|
-
|
|
295
|
-
const policyVersion = run.state.policy.version;
|
|
296
|
-
return policyVersion === undefined ? decision : { ...decision, policyVersion };
|
|
401
|
+
return policy.version === undefined ? decision : { ...decision, policyVersion: policy.version };
|
|
297
402
|
}
|
|
298
403
|
|
|
299
404
|
async function callOp(run: Run, id: string, input: unknown, place: Placed) {
|
|
300
405
|
// The worker's declaration, never the workflow's: its effect, schemas and retry setting.
|
|
301
406
|
const op = run.state.ops.get(id);
|
|
302
407
|
// A sandbox run calls its own fakes, never a vendor.
|
|
303
|
-
const fn = run
|
|
408
|
+
const fn = driverOf(run, id);
|
|
304
409
|
if (!op || !fn) {
|
|
305
410
|
throw new Error(
|
|
306
411
|
run.sandbox === undefined
|
|
@@ -324,24 +429,27 @@ async function callOp(run: Run, id: string, input: unknown, place: Placed) {
|
|
|
324
429
|
// The decision is recorded as a step, so a replay reuses it instead of asking a policy
|
|
325
430
|
// that may have changed since. The policy must still be deterministic: a run that fails
|
|
326
431
|
// before the step is recorded asks again.
|
|
327
|
-
const
|
|
432
|
+
const decideStep = () => DBOS.runStep(() => decide(run, op, parsed), { name: `policy:${op.id}` });
|
|
433
|
+
let decision = await decideStep();
|
|
328
434
|
const logged = recorded(op, parsed);
|
|
329
435
|
const call = { type: "op.called", op: op.id, ...place, effect: op.effect, input: logged, decision } as const;
|
|
330
436
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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;
|
|
337
445
|
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
446
|
try {
|
|
341
|
-
|
|
447
|
+
// A hold covers the call it held.
|
|
448
|
+
await awaitApproval(run, title, { approver: decision.approver, covers: [op.id] }, place, {
|
|
342
449
|
op: op.id,
|
|
343
450
|
seq,
|
|
344
451
|
input: logged,
|
|
452
|
+
reasons: decision.reasons,
|
|
345
453
|
});
|
|
346
454
|
} catch (err) {
|
|
347
455
|
if (errorCode(err) === "approval_rejected") {
|
|
@@ -351,6 +459,7 @@ async function callOp(run: Run, id: string, input: unknown, place: Placed) {
|
|
|
351
459
|
}
|
|
352
460
|
throw err;
|
|
353
461
|
}
|
|
462
|
+
decision = await decideStep();
|
|
354
463
|
}
|
|
355
464
|
|
|
356
465
|
const started = Date.now();
|
|
@@ -363,13 +472,15 @@ async function callOp(run: Run, id: string, input: unknown, place: Placed) {
|
|
|
363
472
|
async () => {
|
|
364
473
|
const at = Date.now();
|
|
365
474
|
attempt = DBOS.stepStatus?.currentAttempt ?? 1;
|
|
366
|
-
const secrets = run
|
|
475
|
+
const secrets = secretsOf(run);
|
|
367
476
|
const context: CallContext = {
|
|
368
477
|
idempotencyKey: `${run.id}:${seq}`,
|
|
369
478
|
runId: run.id,
|
|
370
479
|
opId: op.id,
|
|
371
480
|
attempt,
|
|
372
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}`,
|
|
373
484
|
};
|
|
374
485
|
// A live driver's `env` is checked here, inside the step, by the guard `indexDrivers` put
|
|
375
486
|
// on its function: the refusal is the call's failure, recorded and never retried.
|
|
@@ -456,13 +567,14 @@ async function all(run: Run, list: unknown, node: string): Promise<unknown[]> {
|
|
|
456
567
|
|
|
457
568
|
/**
|
|
458
569
|
* Waits, durably, until the time or for the duration the request names, and records when it
|
|
459
|
-
* ends. For a duration that is the
|
|
460
|
-
*
|
|
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
|
|
461
572
|
* the first record with the id, so the record says what the first execution waited for.
|
|
462
573
|
*/
|
|
463
574
|
async function sleep(run: Run, raw: unknown, at: Placed) {
|
|
464
575
|
const req = checkSleep(raw);
|
|
465
576
|
const seq = run.seq++;
|
|
577
|
+
const started = Date.now();
|
|
466
578
|
let until: number;
|
|
467
579
|
let ms: number;
|
|
468
580
|
if ("until" in req) {
|
|
@@ -475,9 +587,9 @@ async function sleep(run: Run, raw: unknown, at: Placed) {
|
|
|
475
587
|
(req.minutes ?? 0) * 60_000 +
|
|
476
588
|
(req.hours ?? 0) * 3_600_000 +
|
|
477
589
|
(req.days ?? 0) * 86_400_000;
|
|
478
|
-
until =
|
|
590
|
+
until = started + ms;
|
|
479
591
|
}
|
|
480
|
-
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 }));
|
|
481
593
|
// A time already past waits not at all, and adds no step. A sandbox run never waits.
|
|
482
594
|
if (ms > 0 && run.sandbox === undefined) await DBOS.sleep(ms);
|
|
483
595
|
}
|