@sanoma-ai/workflows 0.6.0 → 0.7.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 +48 -14
- package/dist/apply.d.ts +2 -1
- package/dist/apply.js +29 -7
- package/dist/apply.js.map +1 -1
- package/dist/approvals.d.ts +27 -2
- package/dist/approvals.js +73 -9
- package/dist/approvals.js.map +1 -1
- package/dist/call.d.ts +3 -2
- package/dist/call.js +38 -30
- package/dist/call.js.map +1 -1
- package/dist/cedar.js +24 -9
- package/dist/cedar.js.map +1 -1
- package/dist/change.d.ts +219 -0
- package/dist/change.js +150 -0
- package/dist/change.js.map +1 -0
- package/dist/chats.d.ts +43 -0
- package/dist/chats.js +48 -0
- package/dist/chats.js.map +1 -0
- package/dist/client.d.ts +52 -6
- package/dist/client.js +88 -47
- package/dist/client.js.map +1 -1
- package/dist/config.d.ts +47 -5
- package/dist/config.js +71 -7
- package/dist/config.js.map +1 -1
- package/dist/database.d.ts +83 -0
- package/dist/database.js +92 -0
- package/dist/database.js.map +1 -0
- package/dist/db.d.ts +31 -1
- package/dist/db.js +89 -7
- package/dist/db.js.map +1 -1
- package/dist/deadlines.d.ts +88 -0
- package/dist/deadlines.js +171 -0
- package/dist/deadlines.js.map +1 -0
- package/dist/define.d.ts +25 -4
- package/dist/define.js +40 -0
- package/dist/define.js.map +1 -1
- package/dist/demo.d.ts +28 -0
- package/dist/demo.js +72 -0
- package/dist/demo.js.map +1 -0
- package/dist/describe.d.ts +5 -2
- package/dist/describe.js +10 -6
- package/dist/describe.js.map +1 -1
- package/dist/errors.d.ts +10 -1
- package/dist/errors.js +13 -0
- package/dist/errors.js.map +1 -1
- package/dist/fake.d.ts +8 -0
- package/dist/fake.js +11 -1
- package/dist/fake.js.map +1 -1
- package/dist/grid.d.ts +34 -0
- package/dist/grid.js +62 -0
- package/dist/grid.js.map +1 -0
- package/dist/host.d.ts +13 -2
- package/dist/host.js +2 -2
- package/dist/host.js.map +1 -1
- package/dist/index.d.ts +16 -8
- package/dist/index.js +12 -5
- package/dist/index.js.map +1 -1
- package/dist/ledger.d.ts +11 -2
- package/dist/ledger.js.map +1 -1
- package/dist/lint.js +1 -1
- package/dist/lint.js.map +1 -1
- package/dist/op.d.ts +14 -2
- package/dist/op.js.map +1 -1
- package/dist/policy.d.ts +7 -1
- package/dist/policy.js +2 -1
- package/dist/policy.js.map +1 -1
- package/dist/resource.d.ts +13 -1
- package/dist/resource.js +9 -1
- package/dist/resource.js.map +1 -1
- package/dist/resources.d.ts +2 -0
- package/dist/resources.js +10 -1
- package/dist/resources.js.map +1 -1
- package/dist/run.d.ts +5 -0
- package/dist/run.js.map +1 -1
- package/dist/sandbox.d.ts +38 -10
- package/dist/sandbox.js +66 -35
- package/dist/sandbox.js.map +1 -1
- package/dist/scenario.d.ts +18 -1
- package/dist/scenario.js +77 -27
- package/dist/scenario.js.map +1 -1
- package/dist/scenarios.d.ts +10 -1
- package/dist/scenarios.js +19 -4
- package/dist/scenarios.js.map +1 -1
- package/dist/schedules.d.ts +66 -0
- package/dist/schedules.js +158 -0
- package/dist/schedules.js.map +1 -0
- package/dist/schema.d.ts +6 -0
- package/dist/schema.js +13 -3
- package/dist/schema.js.map +1 -1
- package/dist/shared.d.ts +30 -2
- package/dist/shared.js +42 -4
- package/dist/shared.js.map +1 -1
- package/dist/source.d.ts +61 -2
- package/dist/source.js.map +1 -1
- package/dist/supervisor.d.ts +38 -0
- package/dist/supervisor.js +77 -0
- package/dist/supervisor.js.map +1 -0
- package/dist/version.d.ts +3 -1
- package/dist/version.js +3 -1
- package/dist/version.js.map +1 -1
- package/dist/wakeup-service.d.ts +83 -0
- package/dist/wakeup-service.js +421 -0
- package/dist/wakeup-service.js.map +1 -0
- package/dist/wakeup.d.ts +84 -0
- package/dist/wakeup.js +87 -0
- package/dist/wakeup.js.map +1 -0
- package/dist/worker.d.ts +39 -1
- package/dist/worker.js +130 -39
- package/dist/worker.js.map +1 -1
- package/oxlint.json +2 -1
- package/package.json +8 -2
- package/src/apply.ts +25 -6
- package/src/approvals.ts +89 -16
- package/src/call.ts +40 -29
- package/src/cedar.ts +24 -6
- package/src/change.ts +222 -0
- package/src/chats.ts +114 -0
- package/src/client.ts +145 -55
- package/src/config.ts +110 -13
- package/src/database.ts +152 -0
- package/src/db.ts +108 -9
- package/src/deadlines.ts +234 -0
- package/src/define.ts +71 -4
- package/src/demo.ts +87 -0
- package/src/describe.ts +18 -6
- package/src/errors.ts +14 -0
- package/src/fake.ts +12 -0
- package/src/grid.ts +100 -0
- package/src/host.ts +13 -4
- package/src/index.ts +61 -3
- package/src/ledger.ts +15 -3
- package/src/lint.ts +1 -1
- package/src/op.ts +15 -2
- package/src/policy.ts +7 -1
- package/src/resource.ts +22 -3
- package/src/resources.ts +12 -1
- package/src/run.ts +5 -0
- package/src/sandbox.ts +97 -37
- package/src/scenario.ts +97 -28
- package/src/scenarios.ts +21 -5
- package/src/schedules.ts +206 -0
- package/src/schema.ts +16 -3
- package/src/shared.ts +51 -4
- package/src/source.ts +58 -3
- package/src/supervisor.ts +107 -0
- package/src/version.ts +3 -1
- package/src/wakeup-service.ts +511 -0
- package/src/wakeup.ts +150 -0
- package/src/worker.ts +181 -44
package/src/approvals.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { DBOS } from "@dbos-inc/dbos-sdk";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { type ApprovalRequest, type ApprovalResult, type ApprovalState, Principal } from "./define.ts";
|
|
4
|
-
import { RejectedError } from "./errors.ts";
|
|
4
|
+
import { RejectedError, WithdrawnError } 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
9
|
import { readPeople } from "./resources.ts";
|
|
10
|
-
import { approverLabel, OWN_CHANGE, refusalOf } from "./shared.ts";
|
|
10
|
+
import { approverLabel, isVisitor, OWN_CHANGE, refusalOf } from "./shared.ts";
|
|
11
11
|
|
|
12
12
|
// The wire contract between a run waiting on approvals and whoever decides them. The client
|
|
13
13
|
// imports this file and never the worker's.
|
|
@@ -37,8 +37,31 @@ export const ApprovalMessage = z.object({
|
|
|
37
37
|
});
|
|
38
38
|
export type ApprovalMessage = z.infer<typeof ApprovalMessage>;
|
|
39
39
|
|
|
40
|
+
/**
|
|
41
|
+
* A withdrawal of a pending approval, as sent to a run on the approval's topic: `withdraw` says
|
|
42
|
+
* why, and `by` who withdraws it, which the run checks (`withdrawalRefusal`) and records. `id`
|
|
43
|
+
* names the message as a decision's does; `SanomaClient.withdraw` sets one.
|
|
44
|
+
*/
|
|
45
|
+
export const ApprovalWithdrawal = z.object({
|
|
46
|
+
withdraw: z.string().trim().min(1),
|
|
47
|
+
by: Principal,
|
|
48
|
+
id: z.string().optional(),
|
|
49
|
+
});
|
|
50
|
+
export type ApprovalWithdrawal = z.infer<typeof ApprovalWithdrawal>;
|
|
51
|
+
|
|
40
52
|
const statusOf = (decision: ApprovalMessage["decision"]) => (decision === "approve" ? "approved" : "rejected");
|
|
41
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Why `by` may not withdraw the approval of a run `starter` started, or undefined when they may:
|
|
56
|
+
* the starter may withdraw what their own run asks (never a visitor, as every anonymous one shares
|
|
57
|
+
* an id), anyone else only as one who may decide it.
|
|
58
|
+
*/
|
|
59
|
+
export const withdrawalRefusal = (
|
|
60
|
+
approval: Pick<ApprovalState, "approver">,
|
|
61
|
+
by: Principal,
|
|
62
|
+
starter: string | undefined,
|
|
63
|
+
): string | undefined => (by.id === starter && !isVisitor(by) ? undefined : refusalOf(approval, by, {}, []));
|
|
64
|
+
|
|
42
65
|
/** The sender's id, from a message that may not be a valid decision. */
|
|
43
66
|
function senderOf(raw: unknown): string | undefined {
|
|
44
67
|
const by = typeof raw === "object" && raw !== null ? (raw as { by?: unknown }).by : undefined;
|
|
@@ -64,7 +87,8 @@ export interface HeldCall {
|
|
|
64
87
|
|
|
65
88
|
/**
|
|
66
89
|
* 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
|
|
90
|
+
* only while nobody else could). Throws `RejectedError` on a rejection and `WithdrawnError` on a
|
|
91
|
+
* withdrawal (`ApprovalWithdrawal`). Without `held`, the workflow asked
|
|
68
92
|
* (`ctx.approval`); with it, the policy did, and `place` is the held call's outline node and name
|
|
69
93
|
* (none for a run's start, which is no call in the outline).
|
|
70
94
|
*/
|
|
@@ -111,7 +135,10 @@ export async function awaitApproval(
|
|
|
111
135
|
),
|
|
112
136
|
);
|
|
113
137
|
for (;;) {
|
|
114
|
-
|
|
138
|
+
// A decision comes from a request (`SanomaClient.decide`), which wakes a stopped worker: no deadline.
|
|
139
|
+
const raw = await run.state.deadlines.waiting(run.id, { kind: "approval" }, () =>
|
|
140
|
+
DBOS.recv<unknown>(topicOf(state.id), { timeoutSeconds: 24 * 60 * 60 }),
|
|
141
|
+
);
|
|
115
142
|
if (raw === null || raw === undefined) {
|
|
116
143
|
warn(`run ${run.id}: approval ${state.id} ("${title}") is still waiting for ${approverLabel(state.approver)}`);
|
|
117
144
|
continue;
|
|
@@ -119,6 +146,18 @@ export async function awaitApproval(
|
|
|
119
146
|
const at = await DBOS.now();
|
|
120
147
|
const parsed = ApprovalMessage.safeParse(raw);
|
|
121
148
|
const msg = parsed.success ? parsed.data : undefined;
|
|
149
|
+
const withdrawal = msg ? undefined : ApprovalWithdrawal.safeParse(raw).data;
|
|
150
|
+
if (withdrawal) {
|
|
151
|
+
const { withdraw: reason, by, id } = withdrawal;
|
|
152
|
+
const refused = withdrawalRefusal(state, by, run.actor.id);
|
|
153
|
+
if (refused === undefined) {
|
|
154
|
+
state.decidedWith = id;
|
|
155
|
+
await withdrawn(run, [state], reason, at, by.id);
|
|
156
|
+
throw new WithdrawnError(title, state.id, reason);
|
|
157
|
+
}
|
|
158
|
+
await refuse(run, state, { by: by.id, ...(id === undefined ? {} : { id }), at, reason: refused });
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
122
161
|
// The people matter only when the run's own actor decides (`refusalOf`): then they are read as
|
|
123
162
|
// the data files declare them now, in a step, so a replay decides the same. While those files
|
|
124
163
|
// have problems, someone else may be missing from them, so the starter is refused.
|
|
@@ -135,22 +174,12 @@ export async function awaitApproval(
|
|
|
135
174
|
: (refusalOf(state, msg.by, run, []) ?? null);
|
|
136
175
|
if (!msg || refusal !== null) {
|
|
137
176
|
const by = msg?.by.id ?? senderOf(raw);
|
|
138
|
-
|
|
139
|
-
state.refused.push({
|
|
177
|
+
await refuse(run, state, {
|
|
140
178
|
...(by === undefined ? {} : { by }),
|
|
141
179
|
...(msg?.id === undefined ? {} : { id: msg.id }),
|
|
142
180
|
at,
|
|
143
|
-
reason,
|
|
181
|
+
reason: refusal ?? "not a valid decision message",
|
|
144
182
|
});
|
|
145
|
-
await DBOS.setEvent(APPROVALS_EVENT, all);
|
|
146
|
-
await write(
|
|
147
|
-
run,
|
|
148
|
-
entry(
|
|
149
|
-
run,
|
|
150
|
-
{ type: "approval.refused", approval: state.id, ...(by === undefined ? {} : { by }), reason },
|
|
151
|
-
{ key: `${state.id}:refused:${state.refused.length}`, at },
|
|
152
|
-
),
|
|
153
|
-
);
|
|
154
183
|
continue;
|
|
155
184
|
}
|
|
156
185
|
// The run's own actor decides only when nobody else could, which the record says.
|
|
@@ -191,3 +220,47 @@ const heldOf = ({ seq, reasons }: HeldCall) => ({
|
|
|
191
220
|
...(seq !== undefined && { opSeq: seq }),
|
|
192
221
|
...(reasons !== undefined && { reasons }),
|
|
193
222
|
});
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Withdraws every approval of the run that is approved and covers the operation `covering`, so
|
|
226
|
+
* none lifts a hold on it again (`approvedFor` reads only approved ones): as a change does when it
|
|
227
|
+
* gets a new commit. Called from the run's own body, never a step: the approvals it finds and the
|
|
228
|
+
* records it writes depend only on the run's history, so a replay does the same.
|
|
229
|
+
*/
|
|
230
|
+
export async function withdrawApprovals(run: Run, covering: string, reason: string): Promise<void> {
|
|
231
|
+
const retired = run.approvals.filter((a) => a.status === "approved" && a.covers.includes(covering));
|
|
232
|
+
if (!retired.length) return;
|
|
233
|
+
await withdrawn(run, retired, reason, await DBOS.now());
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Records a message the approval ignored, on the approval and in the ledger. */
|
|
237
|
+
async function refuse(run: Run, state: ApprovalState, refused: ApprovalState["refused"][number]) {
|
|
238
|
+
state.refused.push(refused);
|
|
239
|
+
await DBOS.setEvent(APPROVALS_EVENT, run.approvals);
|
|
240
|
+
const { by, reason, at } = refused;
|
|
241
|
+
await write(
|
|
242
|
+
run,
|
|
243
|
+
entry(
|
|
244
|
+
run,
|
|
245
|
+
{ type: "approval.refused", approval: state.id, ...(by === undefined ? {} : { by }), reason },
|
|
246
|
+
{ key: `${state.id}:refused:${state.refused.length}`, at },
|
|
247
|
+
),
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Withdraws the approvals (by `by`, when someone rather than the run does) and publishes them as a
|
|
253
|
+
* decision is: the list, then the ledger, then each one's decision event.
|
|
254
|
+
*/
|
|
255
|
+
async function withdrawn(run: Run, approvals: readonly ApprovalState[], reason: string, at: number, by?: string) {
|
|
256
|
+
const who = by === undefined ? {} : { by };
|
|
257
|
+
for (const a of approvals) {
|
|
258
|
+
Object.assign(a, { status: "withdrawn", withdrawnReason: reason, withdrawnAt: at });
|
|
259
|
+
if (by !== undefined) a.withdrawnBy = by;
|
|
260
|
+
}
|
|
261
|
+
await DBOS.setEvent(APPROVALS_EVENT, run.approvals);
|
|
262
|
+
for (const a of approvals) {
|
|
263
|
+
await write(run, entry(run, { type: "approval.withdrawn", approval: a.id, ...who, reason }, { key: a.id, at }));
|
|
264
|
+
}
|
|
265
|
+
for (const a of approvals) await DBOS.setEvent(decisionEventOf(a.id), a);
|
|
266
|
+
}
|
package/src/call.ts
CHANGED
|
@@ -31,11 +31,12 @@ import { type CallContext, isOp, type Op } from "./op.ts";
|
|
|
31
31
|
import { callAt, callName, type CallNode } from "./outline.ts";
|
|
32
32
|
import { allowAll, type Decision, type Policy, type PolicyCall, policyOpOf, type RecordedDecision } from "./policy.ts";
|
|
33
33
|
import type { Run, StartHold, WorkerState } from "./run.ts";
|
|
34
|
-
import {
|
|
34
|
+
import { reservedFor, runtimeHold } from "./change.ts";
|
|
35
|
+
import { readPeople } from "./resources.ts";
|
|
35
36
|
import {
|
|
36
|
-
APPLY_WORKFLOW,
|
|
37
37
|
approverLabel,
|
|
38
38
|
CALL_WORKFLOW,
|
|
39
|
+
CHANGE_MERGE,
|
|
39
40
|
DATA_VENDOR,
|
|
40
41
|
errorMessage,
|
|
41
42
|
fitsOp,
|
|
@@ -276,11 +277,16 @@ function targetOf(op: Op, input: unknown): { target?: string } {
|
|
|
276
277
|
|
|
277
278
|
/**
|
|
278
279
|
* Asks the policy about one call, with the record a collection's `save` or
|
|
279
|
-
* `delete` replaces as `before` (`beforeOf`).
|
|
280
|
-
*
|
|
280
|
+
* `delete` replaces as `before` (`beforeOf`). The runtime's own rule first (`runtimeHold`, over
|
|
281
|
+
* the running instance's people): while it holds or denies, the company's policy is not asked.
|
|
282
|
+
* Runs inside a step, so it must have no side effects of its own.
|
|
281
283
|
*/
|
|
282
284
|
export async function decide(run: Run, op: Op, input: unknown): Promise<RecordedDecision> {
|
|
283
285
|
const seen = { id: run.id, workflow: run.workflow, approvals: structuredClone(run.approvals) };
|
|
286
|
+
// Only a merge is the rule's: no other call reads the people's files.
|
|
287
|
+
const held =
|
|
288
|
+
op.id === CHANGE_MERGE ? runtimeHold(op.id, input, seen.approvals, readPeople(run.state.root).people) : undefined;
|
|
289
|
+
if (held) return held;
|
|
284
290
|
const { policy } = run.state;
|
|
285
291
|
// Keyed by the call (`seq` was taken for it), so a fake answers each read afresh.
|
|
286
292
|
const before = await beforeOf(policy, op, input, run.state.collections, (get, key) =>
|
|
@@ -299,7 +305,7 @@ const driverOf = (run: Run, id: string) => (run.sandbox === undefined ? run.stat
|
|
|
299
305
|
/** The secrets a run's calls read: its own in a sandbox run, else the stored ones. */
|
|
300
306
|
const secretsOf = (run: Run) => (run.sandbox === undefined ? run.state.secrets : run.fakeSecrets!);
|
|
301
307
|
|
|
302
|
-
/** One call of `op` as `actor` in `run`, as a policy sees it. */
|
|
308
|
+
/** One call of `op` as `actor` in `run`, as a policy sees it: a merge's `entity`, the change's facts. */
|
|
303
309
|
const callOf = (op: Op, input: unknown, actor: Principal, run: PolicyCall["run"]): PolicyCall => ({
|
|
304
310
|
op: policyOpOf(op),
|
|
305
311
|
effect: op.effect,
|
|
@@ -307,6 +313,7 @@ const callOf = (op: Op, input: unknown, actor: Principal, run: PolicyCall["run"]
|
|
|
307
313
|
input,
|
|
308
314
|
actor,
|
|
309
315
|
run,
|
|
316
|
+
...(op.id === CHANGE_MERGE && { entity: (input as { facts: Record<string, unknown> }).facts }),
|
|
310
317
|
});
|
|
311
318
|
|
|
312
319
|
/**
|
|
@@ -413,9 +420,11 @@ async function callOp(run: Run, id: string, input: unknown, place: Placed) {
|
|
|
413
420
|
: `No connector or fake for ${id} in this worker: no fake its connectors attach implements it`,
|
|
414
421
|
);
|
|
415
422
|
}
|
|
416
|
-
// Only apply changes a declared resource
|
|
417
|
-
|
|
418
|
-
|
|
423
|
+
// Only apply changes a declared resource, and only change takes a change's steps; `resolveConfig`
|
|
424
|
+
// refuses any other workflow that uses such an operation.
|
|
425
|
+
const reserved = reservedFor(op, run.state.types);
|
|
426
|
+
if (reserved && run.workflow !== reserved.workflow) {
|
|
427
|
+
throw new SanomaError("invalid_input", `${op.id} ${reserved.does}, which only ${reserved.workflow} does`, {
|
|
419
428
|
op: op.id,
|
|
420
429
|
workflow: run.workflow,
|
|
421
430
|
});
|
|
@@ -452,7 +461,9 @@ async function callOp(run: Run, id: string, input: unknown, place: Placed) {
|
|
|
452
461
|
reasons: decision.reasons,
|
|
453
462
|
});
|
|
454
463
|
} catch (err) {
|
|
455
|
-
|
|
464
|
+
// A rejection or a withdrawal stops the call: it is recorded as failed, naming the approval.
|
|
465
|
+
const code = errorCode(err);
|
|
466
|
+
if (code === "approval_rejected" || code === "approval_withdrawn") {
|
|
456
467
|
const approval = (err as SanomaError).data.approvalId as string;
|
|
457
468
|
const record = entry(run, { ...call, approval, error: errorInfo(err), durationMs: 0 }, { seq });
|
|
458
469
|
await writeFailure(run, record, err);
|
|
@@ -567,29 +578,29 @@ async function all(run: Run, list: unknown, node: string): Promise<unknown[]> {
|
|
|
567
578
|
|
|
568
579
|
/**
|
|
569
580
|
* Waits, durably, until the time or for the duration the request names, and records when it
|
|
570
|
-
* ends. For a duration that is the record's `at` plus the duration
|
|
571
|
-
*
|
|
572
|
-
* the
|
|
581
|
+
* ends. For a duration that is the record's `at` plus the duration. The start is DBOS's clock
|
|
582
|
+
* (`DBOS.now()`, a step), so a replay reads back the first execution's and computes the same
|
|
583
|
+
* deadline: the one the worker's deadlines register while the run waits.
|
|
573
584
|
*/
|
|
574
585
|
async function sleep(run: Run, raw: unknown, at: Placed) {
|
|
575
586
|
const req = checkSleep(raw);
|
|
576
587
|
const seq = run.seq++;
|
|
577
|
-
const started =
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
until = started + ms;
|
|
591
|
-
}
|
|
588
|
+
const started = await DBOS.now();
|
|
589
|
+
const until =
|
|
590
|
+
"until" in req
|
|
591
|
+
? typeof req.until === "number"
|
|
592
|
+
? req.until
|
|
593
|
+
: Date.parse(req.until)
|
|
594
|
+
: started +
|
|
595
|
+
(req.ms ?? 0) +
|
|
596
|
+
(req.seconds ?? 0) * 1_000 +
|
|
597
|
+
(req.minutes ?? 0) * 60_000 +
|
|
598
|
+
(req.hours ?? 0) * 3_600_000 +
|
|
599
|
+
(req.days ?? 0) * 86_400_000;
|
|
600
|
+
const ms = until - started;
|
|
592
601
|
await write(run, entry(run, { type: "sleep.started", ...at, until }, { seq, at: started }));
|
|
593
|
-
// A time already past waits not at all, and adds no step. A sandbox run never waits.
|
|
594
|
-
if (ms > 0 && run.sandbox === undefined)
|
|
602
|
+
// A time already past waits not at all, and adds no sleep step. A sandbox run never waits.
|
|
603
|
+
if (ms > 0 && run.sandbox === undefined) {
|
|
604
|
+
await run.state.deadlines.waiting(run.id, { kind: "sleep", until }, () => DBOS.sleep(ms));
|
|
605
|
+
}
|
|
595
606
|
}
|
package/src/cedar.ts
CHANGED
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
VISITOR_DENIED,
|
|
22
22
|
} from "./policy.ts";
|
|
23
23
|
import { admit, readPeople } from "./resources.ts";
|
|
24
|
-
import { cedarValue, cedarWasm, contextTypeOf, messageOf, NAMESPACE, schemaOf } from "./schema.ts";
|
|
24
|
+
import { cedarValue, cedarWasm, contextTypeOf, messageOf, NAMESPACE, schemaOf, shapeOf } from "./schema.ts";
|
|
25
25
|
import {
|
|
26
26
|
ANONYMOUS,
|
|
27
27
|
approverLabel,
|
|
@@ -287,17 +287,30 @@ export function cedarPolicy(
|
|
|
287
287
|
return { refusing: refusing?.p, holds };
|
|
288
288
|
};
|
|
289
289
|
|
|
290
|
-
/**
|
|
290
|
+
/** The entity type `action` acts on, as the schema has it. */
|
|
291
|
+
const appliesTo = (action: string) => schema[NAMESPACE]?.actions[action]?.appliesTo?.resourceTypes?.[0];
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* What `request` is about: a workflow start on its `Workflow`; an operation on its target, in its
|
|
295
|
+
* collection for `data`; one on an entity type with attributes (a merge's `Change`) with those `entity` gives.
|
|
296
|
+
*/
|
|
291
297
|
const requestOf = (
|
|
292
298
|
principal: Principal,
|
|
293
299
|
action: string,
|
|
294
300
|
target: string | undefined,
|
|
295
301
|
context: (names: string[]) => Record<string, unknown>,
|
|
302
|
+
entity: (names: string[]) => Record<string, unknown>,
|
|
296
303
|
) => {
|
|
297
304
|
const entities = directory.entities(principal);
|
|
305
|
+
const on = appliesTo(action);
|
|
306
|
+
const shape = on === undefined ? undefined : shapeOf(schema, on);
|
|
298
307
|
let resource;
|
|
299
308
|
if (action.startsWith(WORKFLOW_ACTION)) {
|
|
300
309
|
resource = uid("Workflow", action.slice(WORKFLOW_ACTION.length));
|
|
310
|
+
} else if (shape) {
|
|
311
|
+
resource = uid(on!, target ?? action);
|
|
312
|
+
const attrs = cedarValue(entity(Object.keys(shape.attributes)), shape) ?? {};
|
|
313
|
+
entities.push({ uid: resource, attrs: attrs as Record<string, CedarValueJson>, parents: [] });
|
|
301
314
|
} else {
|
|
302
315
|
resource = uid("Target", target ?? action);
|
|
303
316
|
const op = resolved.ops.get(action);
|
|
@@ -316,7 +329,8 @@ export function cedarPolicy(
|
|
|
316
329
|
|
|
317
330
|
/**
|
|
318
331
|
* May `actor` take `action` on `target` with `context`, given the run's `approvals` so far; at a
|
|
319
|
-
* start or a direct call (`atStart`), a visitor only where a company permit lets it in.
|
|
332
|
+
* start or a direct call (`atStart`), a visitor only where a company permit lets it in. `entity`,
|
|
333
|
+
* the attributes of what it acts on, when that has any (a `Change`).
|
|
320
334
|
*/
|
|
321
335
|
const authorize = (
|
|
322
336
|
actor: Principal,
|
|
@@ -325,13 +339,15 @@ export function cedarPolicy(
|
|
|
325
339
|
context: Record<string, unknown>,
|
|
326
340
|
approvals: PolicyCall["run"]["approvals"],
|
|
327
341
|
atStart: boolean,
|
|
342
|
+
entity: Record<string, unknown> = {},
|
|
328
343
|
): Decision => {
|
|
329
344
|
const principal = directory.principal(actor);
|
|
345
|
+
const attrs = () => entity;
|
|
330
346
|
/** The answer of the policy set `id`. */
|
|
331
347
|
const ask = (id: string) => {
|
|
332
348
|
const check = (asked: Record<string, unknown>) =>
|
|
333
349
|
cedar.statefulIsAuthorized({
|
|
334
|
-
...requestOf(principal, action, target, () => asked),
|
|
350
|
+
...requestOf(principal, action, target, () => asked, attrs),
|
|
335
351
|
preparsedSchemaName: set,
|
|
336
352
|
validateRequest: true,
|
|
337
353
|
preparsedPolicySetId: id,
|
|
@@ -342,7 +358,7 @@ export function cedarPolicy(
|
|
|
342
358
|
// action's groups given as entities: a policy reading a field the record lacks fails, so denies.
|
|
343
359
|
const { before, ...rest } = context;
|
|
344
360
|
if (answer.type === "success" || before === undefined || check(rest).type === "failure") return answer;
|
|
345
|
-
const request = requestOf(principal, action, target, () => context);
|
|
361
|
+
const request = requestOf(principal, action, target, () => context, attrs);
|
|
346
362
|
const groups = schema[NAMESPACE]?.actions[action]?.memberOf ?? [];
|
|
347
363
|
request.entities.push({ uid: request.action, attrs: {}, parents: groups.map((g) => uid("Action", g.id)) });
|
|
348
364
|
return cedar.statefulIsAuthorized({ ...request, preparsedPolicySetId: id });
|
|
@@ -388,6 +404,7 @@ export function cedarPolicy(
|
|
|
388
404
|
{ input: call.input, ...(call.before !== undefined && { before: call.before }) },
|
|
389
405
|
call.run.approvals,
|
|
390
406
|
call.run.workflow === CALL_WORKFLOW,
|
|
407
|
+
call.entity,
|
|
391
408
|
);
|
|
392
409
|
|
|
393
410
|
const start = (actor: Principal, workflow: string, input: unknown, approvals: readonly ApprovalState[] = []) =>
|
|
@@ -398,7 +415,8 @@ export function cedarPolicy(
|
|
|
398
415
|
const principal = directory.principal(actor);
|
|
399
416
|
const partial = (asked: Record<string, string>) => {
|
|
400
417
|
const answer = cedar.isAuthorizedPartial({
|
|
401
|
-
|
|
418
|
+
// A change's facts unknown too: a rule on them depends, one on any change holds.
|
|
419
|
+
...requestOf(principal, action.id, resource, unknown, unknown),
|
|
402
420
|
schema,
|
|
403
421
|
validateRequest: false,
|
|
404
422
|
policies: { staticPolicies: asked },
|
package/src/change.ts
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { ApprovalState, Approver } from "./define.ts";
|
|
3
|
+
import { defineConnector, defineDriver, type Driver, DriverError } from "./op.ts";
|
|
4
|
+
import { approve, approvedFor, type Decision, deny } from "./policy.ts";
|
|
5
|
+
import { isChangeOp, type Resource } from "./resource.ts";
|
|
6
|
+
import { APPLY_WORKFLOW, CHANGE_MERGE, CHANGE_WORKFLOW, groupNamesOf, type PeopleResource } from "./shared.ts";
|
|
7
|
+
import type { CheckResult } from "./source.ts";
|
|
8
|
+
|
|
9
|
+
// A change: how a draft of the company's repository goes live, as the built-in `change` workflow
|
|
10
|
+
// runs it (lane C5): its four operations on the built-in connector `sanoma`, the facts a policy
|
|
11
|
+
// decides its merge on (the attributes of its `Sanoma::Change`), and the runtime's own rule that
|
|
12
|
+
// a change to the rules waits for `admin`, before any company policy is asked.
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* What the planner finds in a change, before the policy is asked: the attributes of the
|
|
16
|
+
* `Sanoma::Change` that `sanoma.change.merge` acts on, so a rule reads `resource.touches`.
|
|
17
|
+
*/
|
|
18
|
+
export const ChangeFacts = z.object({
|
|
19
|
+
/** Every path the change edits and each of its parent directories (`touchesOf`). */
|
|
20
|
+
touches: z.array(z.string()),
|
|
21
|
+
/** How many waiting runs are of a workflow the change edits, which the publish cannot move. */
|
|
22
|
+
strandedRuns: z.number().int(),
|
|
23
|
+
/** Those runs' ids. */
|
|
24
|
+
stranded: z.array(z.string()),
|
|
25
|
+
/** Whether `sanoma check` passed on the change. */
|
|
26
|
+
checksOk: z.boolean(),
|
|
27
|
+
/** Whether the change edits the rules: a policy or a people file, which wait for `admin` (`runtimeHold`). */
|
|
28
|
+
rules: z.boolean(),
|
|
29
|
+
});
|
|
30
|
+
export type ChangeFacts = z.infer<typeof ChangeFacts>;
|
|
31
|
+
|
|
32
|
+
/** What opening a change takes: its draft's id, and the pull request's title, body and the change page's link. */
|
|
33
|
+
export const ChangeInput = z.object({
|
|
34
|
+
id: z.string().regex(/^[\w-]{1,64}$/),
|
|
35
|
+
title: z.string().min(1),
|
|
36
|
+
body: z.string().optional(),
|
|
37
|
+
page: z.string().url().optional(),
|
|
38
|
+
});
|
|
39
|
+
export type ChangeInput = z.infer<typeof ChangeInput>;
|
|
40
|
+
|
|
41
|
+
/** The change a call acts on: its policy target. */
|
|
42
|
+
const target = ({ id }: { id: string }) => `change:${id}`;
|
|
43
|
+
/** A change once it has a pull request: its draft's id and the pull request's number. */
|
|
44
|
+
const opened = { id: ChangeInput.shape.id, number: z.number().int().positive() };
|
|
45
|
+
|
|
46
|
+
/** The operations of a change, on the built-in connector `sanoma`: only the `change` workflow calls them. */
|
|
47
|
+
export const changeOps = defineConnector("sanoma", {
|
|
48
|
+
change: {
|
|
49
|
+
open: {
|
|
50
|
+
effect: "write",
|
|
51
|
+
idempotent: true,
|
|
52
|
+
description: "Open a change: push its draft's branch and open a pull request for it",
|
|
53
|
+
input: ChangeInput,
|
|
54
|
+
output: z.object({ number: z.number().int(), url: z.string(), head: z.string(), branch: z.string() }),
|
|
55
|
+
target,
|
|
56
|
+
phrases: { expect: "change {id} is opened" },
|
|
57
|
+
},
|
|
58
|
+
review: {
|
|
59
|
+
effect: "write",
|
|
60
|
+
idempotent: true,
|
|
61
|
+
description: "Report Sanoma's review of a change on its pull request",
|
|
62
|
+
input: z.object({
|
|
63
|
+
...opened,
|
|
64
|
+
head: z.string(),
|
|
65
|
+
state: z.enum(["pending", "failure"]),
|
|
66
|
+
description: z.string(),
|
|
67
|
+
page: z.string().url().optional(),
|
|
68
|
+
}),
|
|
69
|
+
output: z.object({}),
|
|
70
|
+
target,
|
|
71
|
+
phrases: { expect: "change {id} is reviewed" },
|
|
72
|
+
},
|
|
73
|
+
merge: {
|
|
74
|
+
effect: "write",
|
|
75
|
+
idempotent: true,
|
|
76
|
+
description: "Merge a change's pull request",
|
|
77
|
+
input: z.object({ ...opened, head: z.string(), title: z.string(), facts: ChangeFacts }),
|
|
78
|
+
output: z.object({
|
|
79
|
+
outcome: z.enum(["merged", "closed", "moved", "blocked"]),
|
|
80
|
+
sha: z.string().optional(),
|
|
81
|
+
reason: z.string().optional(),
|
|
82
|
+
}),
|
|
83
|
+
target,
|
|
84
|
+
phrases: { expect: "change {id} is merged" },
|
|
85
|
+
},
|
|
86
|
+
close: {
|
|
87
|
+
effect: "write",
|
|
88
|
+
idempotent: true,
|
|
89
|
+
description: "Close a change's pull request without merging it",
|
|
90
|
+
input: z.object(opened),
|
|
91
|
+
output: z.object({ closed: z.literal(true) }),
|
|
92
|
+
target,
|
|
93
|
+
phrases: { expect: "change {id} is closed" },
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The built-in workflow that alone may call `op`, what the operation does and what to do instead:
|
|
100
|
+
* `apply` for a writable resource type's change operations (`isChangeOp`), `change` for
|
|
101
|
+
* `sanoma.change.*`; undefined for any other. `resolveConfig` refuses a workflow that uses one, and
|
|
102
|
+
* a run's call of one outside its workflow fails.
|
|
103
|
+
*/
|
|
104
|
+
export function reservedFor(
|
|
105
|
+
op: { readonly vendor: string; readonly resource: string; readonly name: string },
|
|
106
|
+
types: ReadonlyMap<string, Pick<Resource, "writable">>,
|
|
107
|
+
): { workflow: string; does: string; instead: string } | undefined {
|
|
108
|
+
if (isChangeOp(op, types)) {
|
|
109
|
+
return { workflow: APPLY_WORKFLOW, does: "changes a declared resource", instead: "change the data file" };
|
|
110
|
+
}
|
|
111
|
+
if (op.vendor === "sanoma" && op.resource === "change") {
|
|
112
|
+
return { workflow: CHANGE_WORKFLOW, does: "changes the company's repository", instead: "send a draft for review" };
|
|
113
|
+
}
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const notBuilt = async (): Promise<never> => {
|
|
118
|
+
throw new DriverError("not built", { retryable: false });
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/** A placeholder: lane C5 replaces it with the driver over the repository's host. */
|
|
122
|
+
export function changeDriver(): Driver {
|
|
123
|
+
return defineDriver(changeOps, { change: { open: notBuilt, review: notBuilt, merge: notBuilt, close: notBuilt } });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Each path, relative to the root with `/` between its parts, and each of its parent directories
|
|
128
|
+
* with a trailing `/`, sorted, each once: what a rule asks with `touches.contains("resources/finance/")`.
|
|
129
|
+
*/
|
|
130
|
+
export function touchesOf(files: readonly string[]): string[] {
|
|
131
|
+
const touches = new Set<string>();
|
|
132
|
+
for (const file of files) {
|
|
133
|
+
touches.add(file);
|
|
134
|
+
for (let end = file.indexOf("/"); end > 0; end = file.indexOf("/", end + 1)) touches.add(file.slice(0, end + 1));
|
|
135
|
+
}
|
|
136
|
+
return [...touches].toSorted();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The group the runtime holds a change to the rules for. */
|
|
140
|
+
const ADMIN = "admin";
|
|
141
|
+
/** The paths in `touches` that are the rules whatever `rules` says: the policies, and the template's people file. */
|
|
142
|
+
const RULES = ["policies/", "resources/people.ts"];
|
|
143
|
+
/** The runtime rule's id, as a hold's `reasons` name it. */
|
|
144
|
+
const RULES_WAIT_FOR_ADMIN = "sanoma:rules-wait-for-admin";
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The runtime's own rule, asked before any company policy and under `allowAll` too: a merge of a
|
|
148
|
+
* change to the rules (`facts.rules`, or `touches` naming `policies/` or `resources/people.ts`) waits for `admin`, whoever made it, until an approval
|
|
149
|
+
* addressed to `admin` covering the merge is approved; denied while no data file declares the
|
|
150
|
+
* group. Undefined for anything else, which the company's policy alone decides. `people` are the
|
|
151
|
+
* running instance's, never the change's, so a change cannot add its author to `admin`.
|
|
152
|
+
*/
|
|
153
|
+
export function runtimeHold(
|
|
154
|
+
opId: string,
|
|
155
|
+
input: unknown,
|
|
156
|
+
approvals: readonly ApprovalState[],
|
|
157
|
+
people: readonly PeopleResource[],
|
|
158
|
+
): Decision | undefined {
|
|
159
|
+
const facts = opId === CHANGE_MERGE ? (input as { facts?: ChangeFacts }).facts : undefined;
|
|
160
|
+
// The planner's `rules`, or what `touches` already says: a fact left false cannot skip the rule.
|
|
161
|
+
if (!facts || !(facts.rules || RULES.some((path) => facts.touches.includes(path)))) return undefined;
|
|
162
|
+
if (!groupNamesOf(people).includes(ADMIN)) {
|
|
163
|
+
return deny(
|
|
164
|
+
`A change to the policies or the people waits for the group "${ADMIN}", which no data file declares: ` +
|
|
165
|
+
`declare it in resources/people.ts`,
|
|
166
|
+
[RULES_WAIT_FOR_ADMIN],
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
const approver: Approver = { group: ADMIN };
|
|
170
|
+
if (approvedFor(approvals, CHANGE_MERGE, approver)) return undefined;
|
|
171
|
+
return approve(approver, { title: "A change to the rules waits for admin", reasons: [RULES_WAIT_FOR_ADMIN] });
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** What a change run is told while it waits: new commits on its branch, a close asked for, or its pull request closed or merged on the host. */
|
|
175
|
+
export type ChangeEvent =
|
|
176
|
+
| { kind: "commits" }
|
|
177
|
+
| { kind: "close"; by: string }
|
|
178
|
+
| { kind: "closed" }
|
|
179
|
+
| { kind: "merged"; sha: string };
|
|
180
|
+
|
|
181
|
+
/** Where a change is, as its run publishes it (`CHANGE_EVENT`) for the app. */
|
|
182
|
+
export interface ChangeState {
|
|
183
|
+
id: string;
|
|
184
|
+
branch: string;
|
|
185
|
+
title: string;
|
|
186
|
+
page?: string;
|
|
187
|
+
phase:
|
|
188
|
+
| "opening"
|
|
189
|
+
| "planning"
|
|
190
|
+
| "checking"
|
|
191
|
+
| "reviewing"
|
|
192
|
+
| "waiting"
|
|
193
|
+
| "merging"
|
|
194
|
+
| "deploying"
|
|
195
|
+
| "merged"
|
|
196
|
+
| "closed"
|
|
197
|
+
| "blocked";
|
|
198
|
+
pullRequest?: { number: number; url: string };
|
|
199
|
+
head?: string;
|
|
200
|
+
facts?: ChangeFacts;
|
|
201
|
+
check?: CheckResult;
|
|
202
|
+
decision?: {
|
|
203
|
+
answer: "allow" | "hold" | "deny";
|
|
204
|
+
approver?: Approver;
|
|
205
|
+
title?: string;
|
|
206
|
+
reasons?: string[];
|
|
207
|
+
reason?: string;
|
|
208
|
+
};
|
|
209
|
+
/** The review card on the host: unavailable in this version. */
|
|
210
|
+
card: { status: "unavailable"; reason: string };
|
|
211
|
+
merged?: { sha: string; at: number; deploy?: { runId?: string; followed?: string; skipped?: string } };
|
|
212
|
+
blocked?: string;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** How a change run ended. */
|
|
216
|
+
export interface ChangeReport {
|
|
217
|
+
outcome: "merged" | "closed";
|
|
218
|
+
head: string;
|
|
219
|
+
pullRequest: { number: number; url: string };
|
|
220
|
+
sha?: string;
|
|
221
|
+
deploy?: { runId?: string };
|
|
222
|
+
}
|