@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.
- package/README.md +186 -76
- package/dist/apply-steps.d.ts +6 -0
- package/dist/apply-steps.js +93 -0
- package/dist/apply-steps.js.map +1 -0
- package/dist/apply.d.ts +197 -0
- package/dist/apply.js +470 -0
- package/dist/apply.js.map +1 -0
- 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 +35 -2
- package/dist/call.js +123 -43
- 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 +123 -9
- package/dist/client.js +355 -34
- 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 +109 -28
- 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/credentials.d.ts +25 -0
- package/dist/credentials.js +73 -5
- package/dist/credentials.js.map +1 -1
- 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 +38 -3
- package/dist/datafile.js +166 -13
- 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 +54 -8
- package/dist/describe.js +84 -11
- 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/drift.d.ts +64 -12
- package/dist/drift.js +118 -41
- package/dist/drift.js.map +1 -1
- 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 +4 -4
- package/dist/expectations.js +43 -9
- package/dist/expectations.js.map +1 -1
- package/dist/host.d.ts +56 -0
- package/dist/host.js +45 -0
- package/dist/host.js.map +1 -0
- package/dist/index.d.ts +13 -5
- package/dist/index.js +15 -5
- 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/lockfile.d.ts +36 -0
- package/dist/lockfile.js +69 -0
- package/dist/lockfile.js.map +1 -0
- package/dist/op.d.ts +9 -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 +207 -25
- package/dist/resource.js +184 -30
- package/dist/resource.js.map +1 -1
- package/dist/resources.d.ts +76 -9
- package/dist/resources.js +170 -30
- package/dist/resources.js.map +1 -1
- package/dist/run.d.ts +31 -1
- package/dist/run.js +6 -1
- package/dist/run.js.map +1 -1
- package/dist/sandbox.js +5 -1
- package/dist/sandbox.js.map +1 -1
- package/dist/scenario.d.ts +25 -9
- package/dist/scenario.js +125 -27
- 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/secret.d.ts +42 -0
- package/dist/secret.js +58 -0
- package/dist/secret.js.map +1 -0
- package/dist/shared.d.ts +188 -4
- package/dist/shared.js +165 -5
- 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/source.d.ts +58 -0
- package/dist/source.js +54 -0
- package/dist/source.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 +6 -2
- package/dist/worker.js +37 -8
- package/dist/worker.js.map +1 -1
- package/dist/writer.d.ts +35 -0
- package/dist/writer.js +201 -0
- package/dist/writer.js.map +1 -0
- package/oxlint.json +5 -16
- package/package.json +11 -1
- package/src/apply-steps.ts +110 -0
- package/src/apply.ts +626 -0
- package/src/approvals.ts +49 -13
- package/src/call-workflow.ts +44 -0
- package/src/call.ts +173 -46
- package/src/cedar.ts +493 -0
- package/src/client.ts +450 -49
- package/src/collection.ts +431 -0
- package/src/config.ts +168 -48
- package/src/constructors.ts +4 -0
- package/src/credentials.ts +90 -5
- package/src/data.ts +10 -0
- package/src/datafile.ts +207 -15
- package/src/db.ts +90 -22
- package/src/define.ts +11 -9
- package/src/describe.ts +146 -23
- package/src/direct.ts +15 -0
- package/src/drift.ts +155 -45
- package/src/errors.ts +36 -1
- package/src/expectations.ts +46 -11
- package/src/host.ts +96 -0
- package/src/index.ts +56 -2
- package/src/ledger.ts +35 -17
- package/src/lint.ts +29 -12
- package/src/lockfile.ts +95 -0
- package/src/op.ts +9 -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 +302 -52
- package/src/resources.ts +226 -30
- package/src/run.ts +33 -1
- package/src/sandbox.ts +5 -1
- package/src/scenario.ts +175 -43
- package/src/scenarios.ts +15 -8
- package/src/schema.ts +261 -0
- package/src/sdk.ts +211 -0
- package/src/secret.ts +92 -0
- package/src/shared.ts +279 -6
- package/src/sites.ts +220 -0
- package/src/source.ts +82 -0
- package/src/version.ts +3 -2
- package/src/worker.ts +45 -11
- 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,
|
|
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,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 {
|
|
30
|
-
import type { Run, WorkerState } from "./run.ts";
|
|
31
|
-
import {
|
|
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
|
-
/**
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
291
|
-
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);
|
|
292
400
|
}
|
|
293
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
445
|
-
*
|
|
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 =
|
|
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
|
}
|