@sanoma-ai/workflows 0.5.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.
Files changed (153) hide show
  1. package/README.md +49 -15
  2. package/dist/apply.d.ts +2 -1
  3. package/dist/apply.js +29 -7
  4. package/dist/apply.js.map +1 -1
  5. package/dist/approvals.d.ts +27 -2
  6. package/dist/approvals.js +73 -9
  7. package/dist/approvals.js.map +1 -1
  8. package/dist/call.d.ts +3 -2
  9. package/dist/call.js +38 -30
  10. package/dist/call.js.map +1 -1
  11. package/dist/cedar.js +24 -9
  12. package/dist/cedar.js.map +1 -1
  13. package/dist/change.d.ts +219 -0
  14. package/dist/change.js +150 -0
  15. package/dist/change.js.map +1 -0
  16. package/dist/chats.d.ts +43 -0
  17. package/dist/chats.js +48 -0
  18. package/dist/chats.js.map +1 -0
  19. package/dist/client.d.ts +52 -6
  20. package/dist/client.js +88 -47
  21. package/dist/client.js.map +1 -1
  22. package/dist/config.d.ts +47 -5
  23. package/dist/config.js +71 -7
  24. package/dist/config.js.map +1 -1
  25. package/dist/database.d.ts +83 -0
  26. package/dist/database.js +92 -0
  27. package/dist/database.js.map +1 -0
  28. package/dist/db.d.ts +31 -1
  29. package/dist/db.js +89 -7
  30. package/dist/db.js.map +1 -1
  31. package/dist/deadlines.d.ts +88 -0
  32. package/dist/deadlines.js +171 -0
  33. package/dist/deadlines.js.map +1 -0
  34. package/dist/define.d.ts +25 -4
  35. package/dist/define.js +40 -0
  36. package/dist/define.js.map +1 -1
  37. package/dist/demo.d.ts +28 -0
  38. package/dist/demo.js +72 -0
  39. package/dist/demo.js.map +1 -0
  40. package/dist/describe.d.ts +6 -3
  41. package/dist/describe.js +11 -7
  42. package/dist/describe.js.map +1 -1
  43. package/dist/errors.d.ts +10 -1
  44. package/dist/errors.js +13 -0
  45. package/dist/errors.js.map +1 -1
  46. package/dist/fake.d.ts +8 -0
  47. package/dist/fake.js +11 -1
  48. package/dist/fake.js.map +1 -1
  49. package/dist/grid.d.ts +34 -0
  50. package/dist/grid.js +62 -0
  51. package/dist/grid.js.map +1 -0
  52. package/dist/host.d.ts +13 -2
  53. package/dist/host.js +2 -2
  54. package/dist/host.js.map +1 -1
  55. package/dist/index.d.ts +16 -8
  56. package/dist/index.js +12 -5
  57. package/dist/index.js.map +1 -1
  58. package/dist/ledger.d.ts +11 -2
  59. package/dist/ledger.js.map +1 -1
  60. package/dist/lint.js +1 -1
  61. package/dist/lint.js.map +1 -1
  62. package/dist/op.d.ts +14 -2
  63. package/dist/op.js.map +1 -1
  64. package/dist/policy.d.ts +7 -1
  65. package/dist/policy.js +2 -1
  66. package/dist/policy.js.map +1 -1
  67. package/dist/resource.d.ts +13 -1
  68. package/dist/resource.js +9 -1
  69. package/dist/resource.js.map +1 -1
  70. package/dist/resources.d.ts +2 -0
  71. package/dist/resources.js +10 -1
  72. package/dist/resources.js.map +1 -1
  73. package/dist/run.d.ts +5 -0
  74. package/dist/run.js.map +1 -1
  75. package/dist/sandbox.d.ts +38 -10
  76. package/dist/sandbox.js +66 -35
  77. package/dist/sandbox.js.map +1 -1
  78. package/dist/scenario.d.ts +18 -1
  79. package/dist/scenario.js +77 -27
  80. package/dist/scenario.js.map +1 -1
  81. package/dist/scenarios.d.ts +10 -1
  82. package/dist/scenarios.js +19 -4
  83. package/dist/scenarios.js.map +1 -1
  84. package/dist/schedules.d.ts +66 -0
  85. package/dist/schedules.js +158 -0
  86. package/dist/schedules.js.map +1 -0
  87. package/dist/schema.d.ts +6 -0
  88. package/dist/schema.js +13 -3
  89. package/dist/schema.js.map +1 -1
  90. package/dist/shared.d.ts +38 -2
  91. package/dist/shared.js +50 -4
  92. package/dist/shared.js.map +1 -1
  93. package/dist/sites.d.ts +38 -1
  94. package/dist/sites.js +153 -26
  95. package/dist/sites.js.map +1 -1
  96. package/dist/source.d.ts +61 -2
  97. package/dist/source.js.map +1 -1
  98. package/dist/supervisor.d.ts +38 -0
  99. package/dist/supervisor.js +77 -0
  100. package/dist/supervisor.js.map +1 -0
  101. package/dist/version.d.ts +3 -1
  102. package/dist/version.js +3 -1
  103. package/dist/version.js.map +1 -1
  104. package/dist/wakeup-service.d.ts +83 -0
  105. package/dist/wakeup-service.js +421 -0
  106. package/dist/wakeup-service.js.map +1 -0
  107. package/dist/wakeup.d.ts +84 -0
  108. package/dist/wakeup.js +87 -0
  109. package/dist/wakeup.js.map +1 -0
  110. package/dist/worker.d.ts +39 -1
  111. package/dist/worker.js +130 -39
  112. package/dist/worker.js.map +1 -1
  113. package/oxlint.json +2 -1
  114. package/package.json +8 -2
  115. package/src/apply.ts +25 -6
  116. package/src/approvals.ts +89 -16
  117. package/src/call.ts +40 -29
  118. package/src/cedar.ts +24 -6
  119. package/src/change.ts +222 -0
  120. package/src/chats.ts +114 -0
  121. package/src/client.ts +145 -55
  122. package/src/config.ts +110 -13
  123. package/src/database.ts +152 -0
  124. package/src/db.ts +108 -9
  125. package/src/deadlines.ts +234 -0
  126. package/src/define.ts +71 -4
  127. package/src/demo.ts +87 -0
  128. package/src/describe.ts +19 -7
  129. package/src/errors.ts +14 -0
  130. package/src/fake.ts +12 -0
  131. package/src/grid.ts +100 -0
  132. package/src/host.ts +13 -4
  133. package/src/index.ts +62 -3
  134. package/src/ledger.ts +15 -3
  135. package/src/lint.ts +1 -1
  136. package/src/op.ts +15 -2
  137. package/src/policy.ts +7 -1
  138. package/src/resource.ts +22 -3
  139. package/src/resources.ts +12 -1
  140. package/src/run.ts +5 -0
  141. package/src/sandbox.ts +97 -37
  142. package/src/scenario.ts +97 -28
  143. package/src/scenarios.ts +21 -5
  144. package/src/schedules.ts +206 -0
  145. package/src/schema.ts +16 -3
  146. package/src/shared.ts +62 -4
  147. package/src/sites.ts +198 -33
  148. package/src/source.ts +58 -3
  149. package/src/supervisor.ts +107 -0
  150. package/src/version.ts +3 -1
  151. package/src/wakeup-service.ts +511 -0
  152. package/src/wakeup.ts +150 -0
  153. 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. Without `held`, the workflow asked
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
- const raw = await DBOS.recv<unknown>(topicOf(state.id), { timeoutSeconds: 24 * 60 * 60 });
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
- const reason = refusal ?? "not a valid decision message";
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 { isChangeOp } from "./resource.ts";
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`). Runs inside a step, so it must have no side effects
280
- * of its own.
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; `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`, {
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
- if (errorCode(err) === "approval_rejected") {
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, one read of the runtime's
571
- * clock (`Date.now()`, which adds no DBOS call): a replay may compute another, but the store keeps
572
- * the first record with the id, so the record says what the first execution waited for.
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 = Date.now();
578
- let until: number;
579
- let ms: number;
580
- if ("until" in req) {
581
- until = typeof req.until === "number" ? req.until : Date.parse(req.until);
582
- ms = until - (await DBOS.now());
583
- } else {
584
- ms =
585
- (req.ms ?? 0) +
586
- (req.seconds ?? 0) * 1_000 +
587
- (req.minutes ?? 0) * 60_000 +
588
- (req.hours ?? 0) * 3_600_000 +
589
- (req.days ?? 0) * 86_400_000;
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) await DBOS.sleep(ms);
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
- /** What `request` is about: a workflow start on its `Workflow`, an operation on its target, in its collection for `data`. */
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
- ...requestOf(principal, action.id, resource, unknown),
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
+ }