@sanoma-ai/workflows 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. package/README.md +121 -62
  2. package/dist/approvals.d.ts +11 -6
  3. package/dist/approvals.js +38 -10
  4. package/dist/approvals.js.map +1 -1
  5. package/dist/call-workflow.d.ts +16 -0
  6. package/dist/call-workflow.js +35 -0
  7. package/dist/call-workflow.js.map +1 -0
  8. package/dist/call.d.ts +33 -4
  9. package/dist/call.js +107 -42
  10. package/dist/call.js.map +1 -1
  11. package/dist/cedar.d.ts +87 -0
  12. package/dist/cedar.js +365 -0
  13. package/dist/cedar.js.map +1 -0
  14. package/dist/client.d.ts +85 -7
  15. package/dist/client.js +289 -25
  16. package/dist/client.js.map +1 -1
  17. package/dist/collection.d.ts +107 -0
  18. package/dist/collection.js +306 -0
  19. package/dist/collection.js.map +1 -0
  20. package/dist/config.d.ts +42 -14
  21. package/dist/config.js +82 -18
  22. package/dist/config.js.map +1 -1
  23. package/dist/constructors.d.ts +2 -0
  24. package/dist/constructors.js +5 -0
  25. package/dist/constructors.js.map +1 -0
  26. package/dist/data.d.ts +2 -0
  27. package/dist/data.js +3 -0
  28. package/dist/data.js.map +1 -0
  29. package/dist/datafile.d.ts +28 -2
  30. package/dist/datafile.js +118 -19
  31. package/dist/datafile.js.map +1 -1
  32. package/dist/db.d.ts +5 -3
  33. package/dist/db.js +88 -21
  34. package/dist/db.js.map +1 -1
  35. package/dist/define.d.ts +10 -4
  36. package/dist/define.js +1 -5
  37. package/dist/define.js.map +1 -1
  38. package/dist/describe.d.ts +45 -5
  39. package/dist/describe.js +72 -7
  40. package/dist/describe.js.map +1 -1
  41. package/dist/direct.d.ts +9 -0
  42. package/dist/direct.js +11 -0
  43. package/dist/direct.js.map +1 -0
  44. package/dist/errors.d.ts +16 -1
  45. package/dist/errors.js +30 -1
  46. package/dist/errors.js.map +1 -1
  47. package/dist/expectations.d.ts +3 -3
  48. package/dist/expectations.js +16 -7
  49. package/dist/expectations.js.map +1 -1
  50. package/dist/index.d.ts +8 -4
  51. package/dist/index.js +10 -4
  52. package/dist/index.js.map +1 -1
  53. package/dist/ledger.d.ts +12 -1
  54. package/dist/ledger.js +18 -11
  55. package/dist/ledger.js.map +1 -1
  56. package/dist/lint.d.ts +5 -4
  57. package/dist/lint.js +24 -7
  58. package/dist/lint.js.map +1 -1
  59. package/dist/op.d.ts +6 -0
  60. package/dist/op.js.map +1 -1
  61. package/dist/outline.d.ts +2 -0
  62. package/dist/outline.js +1 -1
  63. package/dist/outline.js.map +1 -1
  64. package/dist/paths.d.ts +4 -0
  65. package/dist/paths.js +17 -3
  66. package/dist/paths.js.map +1 -1
  67. package/dist/person.d.ts +61 -0
  68. package/dist/person.js +69 -0
  69. package/dist/person.js.map +1 -0
  70. package/dist/plugin.js +1 -1
  71. package/dist/plugin.js.map +1 -1
  72. package/dist/policy.d.ts +51 -55
  73. package/dist/policy.js +14 -36
  74. package/dist/policy.js.map +1 -1
  75. package/dist/resource.d.ts +21 -5
  76. package/dist/resource.js +12 -3
  77. package/dist/resource.js.map +1 -1
  78. package/dist/resources.d.ts +58 -6
  79. package/dist/resources.js +119 -26
  80. package/dist/resources.js.map +1 -1
  81. package/dist/run.d.ts +17 -1
  82. package/dist/run.js.map +1 -1
  83. package/dist/scenario.d.ts +16 -9
  84. package/dist/scenario.js +93 -20
  85. package/dist/scenario.js.map +1 -1
  86. package/dist/scenarios.d.ts +10 -6
  87. package/dist/scenarios.js +8 -4
  88. package/dist/scenarios.js.map +1 -1
  89. package/dist/schema.d.ts +32 -0
  90. package/dist/schema.js +225 -0
  91. package/dist/schema.js.map +1 -0
  92. package/dist/sdk.d.ts +34 -0
  93. package/dist/sdk.js +168 -0
  94. package/dist/sdk.js.map +1 -0
  95. package/dist/shared.d.ts +181 -5
  96. package/dist/shared.js +160 -7
  97. package/dist/shared.js.map +1 -1
  98. package/dist/sites.d.ts +52 -0
  99. package/dist/sites.js +174 -0
  100. package/dist/sites.js.map +1 -0
  101. package/dist/version.d.ts +3 -2
  102. package/dist/version.js +3 -2
  103. package/dist/version.js.map +1 -1
  104. package/dist/worker.d.ts +3 -2
  105. package/dist/worker.js +24 -4
  106. package/dist/worker.js.map +1 -1
  107. package/oxlint.json +5 -16
  108. package/package.json +8 -3
  109. package/src/approvals.ts +49 -13
  110. package/src/call-workflow.ts +44 -0
  111. package/src/call.ts +157 -45
  112. package/src/cedar.ts +493 -0
  113. package/src/client.ts +368 -37
  114. package/src/collection.ts +431 -0
  115. package/src/config.ts +131 -34
  116. package/src/constructors.ts +4 -0
  117. package/src/data.ts +10 -0
  118. package/src/datafile.ts +141 -21
  119. package/src/db.ts +90 -22
  120. package/src/define.ts +11 -9
  121. package/src/describe.ts +113 -10
  122. package/src/direct.ts +15 -0
  123. package/src/errors.ts +36 -1
  124. package/src/expectations.ts +17 -8
  125. package/src/index.ts +33 -2
  126. package/src/ledger.ts +35 -17
  127. package/src/lint.ts +29 -12
  128. package/src/op.ts +6 -0
  129. package/src/outline.ts +1 -1
  130. package/src/paths.ts +18 -3
  131. package/src/person.ts +75 -0
  132. package/src/plugin.ts +1 -1
  133. package/src/policy.ts +59 -63
  134. package/src/resource.ts +35 -8
  135. package/src/resources.ts +165 -24
  136. package/src/run.ts +18 -1
  137. package/src/scenario.ts +128 -33
  138. package/src/scenarios.ts +15 -8
  139. package/src/schema.ts +261 -0
  140. package/src/sdk.ts +211 -0
  141. package/src/shared.ts +271 -8
  142. package/src/sites.ts +220 -0
  143. package/src/version.ts +3 -2
  144. package/src/worker.ts +29 -6
package/src/scenario.ts CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  } from "@cucumber/messages";
14
14
  import type { z } from "zod";
15
15
  import type { RunSummary, SanomaClient } from "./client.ts";
16
+ import type { Approver } from "./define.ts";
16
17
  import type { ResolvedConfig } from "./config.ts";
17
18
  import {
18
19
  buildLibrary,
@@ -25,22 +26,29 @@ import {
25
26
  stepResults,
26
27
  StepStatus,
27
28
  } from "./cucumber.ts";
28
- import { errorInfo, SanomaError } from "./errors.ts";
29
+ import { errorCode, errorInfo, SanomaError } from "./errors.ts";
29
30
  import { type CallRecord, contains, type CoreExpectation, detailOf, isCall } from "./expectations.ts";
30
31
  import type { Fake } from "./fake.ts";
31
32
  import { fill, isBug, seedFrom, shapeOf } from "./fill.ts";
32
33
  import type { LedgerRecord } from "./ledger.ts";
33
34
  import type { Driver, Op } from "./op.ts";
34
35
  import { type OutlineNode, outlineWorkflow } from "./outline.ts";
36
+ import { admit, type DeclaredPeople, readPeople } from "./resources.ts";
35
37
  import { memorySecrets } from "./secret.ts";
36
38
  import {
37
39
  APPLY_WORKFLOW,
38
40
  BUILT_IN_WORKFLOWS,
41
+ DATA_VENDOR,
42
+ declaresPeople,
39
43
  DRIFT_WORKFLOW,
40
44
  errorMessage,
41
45
  fitsOp,
42
46
  flatten,
47
+ groupNamesOf,
43
48
  isEnded,
49
+ principalIdsOf,
50
+ problemLine,
51
+ RUNTIME_AGENTS,
44
52
  } from "./shared.ts";
45
53
  import type { Call, RunView, Seed } from "./steps.ts";
46
54
 
@@ -98,9 +106,11 @@ export interface Decision {
98
106
  /**
99
107
  * What the run should do: call an operation (with at least this input) or not, run the step
100
108
  * `name`d in the workflow's outline or not, or a number of `times`, have the policy deny an
101
- * operation (`denied`) or hold one for an approver (`held`), end a certain way, for a drift
102
- * run, report the resource `declared` (`<file>#<export>`) clean, gone, absent, or drifted in a
103
- * field, or for an apply run, create it, or leave it bound to an object (`bound`, its vendor's id).
109
+ * operation (`denied`, for a `reason` when the step names one) or hold one for an approver
110
+ * (`held`: a person's id or a group's name, or `{ group }` a group's alone), end a certain way,
111
+ * for a drift run, report the resource `declared` (`<file>#<export>`) clean, gone, absent, or
112
+ * drifted in a field, or for an apply run, create it, or leave it bound to an object (`bound`,
113
+ * its vendor's id).
104
114
  * A step whose own code checks the run (a connector's or the company's, or an operation's own
105
115
  * test's) is `custom`, with what it is about.
106
116
  */
@@ -108,8 +118,8 @@ export type Expectation =
108
118
  | { step: string; op: string; input?: unknown; called: boolean }
109
119
  | { step: string; name: string; input?: unknown; called: boolean }
110
120
  | { step: string; name: string; times: number }
111
- | { step: string; op: string; decision: "denied" }
112
- | { step: string; op: string; decision: "held"; approver: string }
121
+ | { step: string; op: string; decision: "denied"; reason?: string }
122
+ | { step: string; op: string; decision: "held"; approver: Approver }
113
123
  | { step: string; outcome: "finished" | "failed"; code?: string }
114
124
  | { step: string; declared: string; status: "clean" | "gone" | "absent" }
115
125
  | { step: string; declared: string; status: "drifted"; field: string }
@@ -132,6 +142,8 @@ export interface Scenario {
132
142
  text: string;
133
143
  /** Every step in order, for a list and for marking graph nodes. */
134
144
  steps: ScenarioStep[];
145
+ /** Who starts the run, by id, when a `Given <who> is acting` says: a declared person or agent. */
146
+ startedBy?: string;
135
147
  given: Given[];
136
148
  input: unknown;
137
149
  decisions: Decision[];
@@ -152,10 +164,14 @@ export interface Check {
152
164
  }
153
165
 
154
166
  /**
155
- * What steps can name: the config's operations, workflows and resource types by id, the steps
156
- * its connectors (and the company) ship, and where the feature files are.
167
+ * What steps can name: the config's operations, workflows, resource types and collections by id,
168
+ * the steps its connectors (and the company) ship, where the feature files are, and the root
169
+ * whose data files declare its people.
157
170
  */
158
- export type Scope = Pick<ResolvedConfig, "ops" | "workflows" | "scenarios" | "types" | "steps">;
171
+ export type Scope = Pick<
172
+ ResolvedConfig,
173
+ "ops" | "workflows" | "scenarios" | "types" | "collections" | "steps" | "root"
174
+ >;
159
175
 
160
176
  type Kind = ScenarioStep["kind"];
161
177
  type Fault = Extract<Seed, { fault: unknown }>["fault"];
@@ -170,9 +186,13 @@ interface Then {
170
186
  verify(run: RunView): string | undefined;
171
187
  }
172
188
 
173
- /** What a step of each kind reads into; `any` is a decision, which a step of any keyword may make. */
189
+ /**
190
+ * What a step of each kind reads into; `any` is a decision, which a step of any keyword may make,
191
+ * and `actor` a `Given` saying who starts the run (`{who} is acting`), by id.
192
+ */
174
193
  interface Items {
175
194
  given: Unstepped<Given>[];
195
+ actor: string;
176
196
  when: When;
177
197
  then: Then;
178
198
  any: Unstepped<Decision>;
@@ -314,7 +334,9 @@ function builtFor(scope: Scope): Built {
314
334
  cached &&
315
335
  cached.scope.workflows === scope.workflows &&
316
336
  cached.scope.types === scope.types &&
317
- cached.scope.steps === scope.steps
337
+ cached.scope.collections === scope.collections &&
338
+ cached.scope.steps === scope.steps &&
339
+ cached.scope.root === scope.root
318
340
  ) {
319
341
  return cached;
320
342
  }
@@ -333,24 +355,59 @@ const expecting = (expect: CoreExpectation): Then => ({ expect, verify: (run) =>
333
355
 
334
356
  /** The rules a scope has, and the parameter types their expressions use. */
335
357
  function rulesOf(scope: Scope): { rules: Rule[]; parameterTypes: ParameterTypeSpec[] } {
336
- const { types } = scope;
358
+ const { types, collections } = scope;
359
+ // A collection's records are seeded as a resource's objects are, by `data.<name>`, through its operations.
360
+ const collectionOf = (id: unknown) =>
361
+ (id as string).startsWith(`${DATA_VENDOR}.`)
362
+ ? collections.get((id as string).slice(DATA_VENDOR.length + 1))
363
+ : undefined;
364
+ const seedable = new Map<string, unknown>([
365
+ ...types,
366
+ ...[...collections.keys()].map((n) => [`${DATA_VENDOR}.${n}`, n] as const),
367
+ ]);
337
368
  const parameterTypes: ParameterTypeSpec[] = [
338
369
  { name: "op", regexp: /\S+/, transformer: known("operation", scope.ops) },
339
370
  { name: "workflow", regexp: /\S+/, transformer: known("workflow", scope.workflows) },
340
371
  { name: "who", regexp: /\S+/, transformer: (s) => s },
341
- { name: "resource", regexp: /\S+/, transformer: known("resource type", types) },
372
+ { name: "resource", regexp: /\S+/, transformer: known("resource type", seedable) },
342
373
  // A declared resource, as a drift report names it: `<file>#<export>`.
343
374
  { name: "declared", regexp: /[^\s#]+#\S+/, transformer: (s) => s },
344
375
  { name: "field", regexp: /\S+/, transformer: (s) => s },
345
376
  ];
346
377
  const opOf = (id: unknown) => scope.ops.get(id as string)!;
347
378
  const workflowOf = (name: unknown) => scope.workflows.get(name as string)!;
379
+ // The people as the root's data files declare them when a step names one (`readPeople`, read
380
+ // again only once a file changed): while they declare no one, any id and any group go.
381
+ /** `who`, as a run's start admits them (`admit`): a declared person or agent. */
382
+ const actorNamed = (who: string) => {
383
+ const declared = readPeople(scope.root);
384
+ try {
385
+ return admit({ id: who }, declared).id;
386
+ } catch (err) {
387
+ if (errorCode(err) !== "not_admitted" || declared.problems.length) throw err;
388
+ const ids = [...principalIdsOf(declared.people), ...RUNTIME_AGENTS].join(", ");
389
+ throw new Error(`${who} is not a declared person or agent; resources/ declares ${ids}`, { cause: err });
390
+ }
391
+ };
392
+ /** `group`, a declared group or `agents`, while the people's files have no problems. */
393
+ const groupNamed = (group: string) => {
394
+ const { people, problems } = readPeople(scope.root);
395
+ if (problems.length) {
396
+ throw new Error(
397
+ `no group can be told while the people's files have problems: ${problems.map(problemLine).join("; ")}`,
398
+ );
399
+ }
400
+ const groups = groupNamesOf(people);
401
+ if (!declaresPeople(people) || groups.includes(group)) return group;
402
+ throw new Error(`no group "${group}"; resources/ declares ${groups.join(", ")}`);
403
+ };
348
404
  /** A resource step's object: its type's id, vendor and type, and its import id. */
349
405
  const object = (id: unknown, name: unknown) => {
350
406
  const { vendor, type } = types.get(id as string)!;
351
407
  return { resource: id as string, vendor, type, name: name as string };
352
408
  };
353
409
  const rules: Rule[] = [
410
+ { kind: "actor", source: "{who} is acting", read: ([who]) => actorNamed(who as string) },
354
411
  {
355
412
  kind: "given",
356
413
  source: "{op} was called with",
@@ -368,21 +425,32 @@ function rulesOf(scope: Scope): { rules: Rule[]; parameterTypes: ParameterTypeSp
368
425
  kind: "given",
369
426
  source: "{resource} {string} has",
370
427
  takes: TAKES_OBJECT,
371
- read: ([resource, name], pickle) => [
372
- {
373
- ...object(resource, name),
374
- fields: fieldsFrom(
375
- pickle,
376
- types.get(resource as string)!.schema,
377
- `${resource as string} "${name as string}"`,
378
- ),
379
- },
380
- ],
428
+ read: ([resource, name], pickle) => {
429
+ const owner = `${resource as string} "${name as string}"`;
430
+ const c = collectionOf(resource);
431
+ if (c) {
432
+ // The record with its key, the fields the step gives, and the rest made up.
433
+ const record = shapeOf(opOf(c.save.id).input).record!;
434
+ const fields = fieldsFrom(pickle, record, owner);
435
+ if (Object.hasOwn(fields, c.key) && fields[c.key] !== name) {
436
+ throw new Error(
437
+ `${c.key} in ${owner} is ${JSON.stringify(fields[c.key])}: it is the key, "${name as string}"`,
438
+ );
439
+ }
440
+ return [{ op: c.save.id, input: { record: fill(record, { ...fields, [c.key]: name }) } }];
441
+ }
442
+ return [
443
+ { ...object(resource, name), fields: fieldsFrom(pickle, types.get(resource as string)!.schema, owner) },
444
+ ];
445
+ },
381
446
  },
382
447
  {
383
448
  kind: "given",
384
449
  source: "{resource} {string} is gone",
385
- read: ([resource, name]) => [{ ...object(resource, name), gone: true }],
450
+ read: ([resource, name]) => {
451
+ const c = collectionOf(resource);
452
+ return [c ? { op: c.delete.id, input: { key: name } } : { ...object(resource, name), gone: true }];
453
+ },
386
454
  },
387
455
  {
388
456
  kind: "when",
@@ -448,11 +516,22 @@ function rulesOf(scope: Scope): { rules: Rule[]; parameterTypes: ParameterTypeSp
448
516
  source: "{op} was denied",
449
517
  read: ([op]) => expecting({ op: op as string, decision: "denied" }),
450
518
  },
519
+ {
520
+ kind: "then",
521
+ source: "{op} was denied with {string}",
522
+ read: ([op, reason]) => expecting({ op: op as string, decision: "denied", reason: reason as string }),
523
+ },
451
524
  {
452
525
  kind: "then",
453
526
  source: "{op} was held for {who}",
454
527
  read: ([op, who]) => expecting({ op: op as string, decision: "held", approver: who as string }),
455
528
  },
529
+ {
530
+ kind: "then",
531
+ source: "{op} was held for group {string}",
532
+ read: ([op, group]) =>
533
+ expecting({ op: op as string, decision: "held", approver: { group: groupNamed(group as string) } }),
534
+ },
456
535
  {
457
536
  kind: "then",
458
537
  source: "step {string} was called with",
@@ -650,7 +729,8 @@ function lastCall(records: readonly LedgerRecord[]): CallRecord | string {
650
729
 
651
730
  /** Every step the scope knows, for an error: the generic ones by keyword, then each operation's phrases and each step library's steps under its name. */
652
731
  function knownSteps(rules: Rule[]): string {
653
- const keyword = (kind: Rule["kind"]) => (kind === "any" ? "Given/When/Then" : kind[0]!.toUpperCase() + kind.slice(1));
732
+ const keyword = (kind: Rule["kind"]) =>
733
+ kind === "any" ? "Given/When/Then" : kind === "actor" ? "Given" : kind[0]!.toUpperCase() + kind.slice(1);
654
734
  const line = (r: Rule) => `${keyword(r.kind)} ${r.source}${r.takes ? ` (and ${r.takes})` : ""}`;
655
735
  const { generic = [], ...groups } = Object.groupBy(rules, (r) => r.group ?? "generic");
656
736
  return [
@@ -804,7 +884,9 @@ const ambiguous = (text: string, sources: string[]) =>
804
884
  function ruleFor(built: Built, expression: string, pickle: PickleStep): Rule {
805
885
  const kind = pickle.type && KINDS[pickle.type];
806
886
  if (!kind) throw new Error(`"${pickle.text}" needs a Given, When or Then before it`);
807
- const rules = built.byExpression.get(expression)!.filter((r) => r.kind === kind || r.kind === "any");
887
+ const rules = built.byExpression
888
+ .get(expression)!
889
+ .filter((r) => r.kind === kind || r.kind === "any" || (r.kind === "actor" && kind === "given"));
808
890
  if (rules.length === 0) throw new Error(noMatch(built, pickle.text));
809
891
  if (rules.length > 1)
810
892
  throw new Error(
@@ -837,6 +919,11 @@ function compileWorld(built: Built, specs: Map<string, Spec>) {
837
919
  const { text } = pickle;
838
920
  const step: ScenarioStep = { text, kind: KINDS[pickle.type!]! };
839
921
  switch (rule.kind) {
922
+ case "actor":
923
+ if (spec.startedBy !== undefined)
924
+ throw new Error(`${spec.startedBy} is acting already: a run has one actor`);
925
+ spec.startedBy = rule.read(args, pickle);
926
+ break;
840
927
  case "given": {
841
928
  const items = rule.read(args, pickle);
842
929
  spec.given.push(...items.map((item) => ({ step: text, ...item }) as Given));
@@ -988,6 +1075,8 @@ function testWorld(built: Built, { driver, fake }: FeatureTarget) {
988
1075
  throw new Error(
989
1076
  `"${text}" decides an approval: a step for a scenario in scenarios/, not an operation's own test`,
990
1077
  );
1078
+ case "actor":
1079
+ throw new Error(`"${text}" says who starts a run: a step for a scenario in scenarios/`);
991
1080
  case "then": {
992
1081
  const detail = rule.read(args, pickle).verify(viewOf(records));
993
1082
  if (detail !== undefined) throw new Error(detail);
@@ -1179,18 +1268,22 @@ async function compile(scope: Scope, dir: string, files: string[]): Promise<Load
1179
1268
  }
1180
1269
 
1181
1270
  /**
1182
- * Each scope's scenarios as last read, with the stamp of the files they were read from. By
1183
- * scope, since what a file means depends on its operations and workflows.
1271
+ * Each scope's scenarios as last read, with the stamp of the files they were read from and the
1272
+ * people they were checked against (`readPeople`'s answer, the same object until a data file
1273
+ * changes). By scope, since what a file means depends on its operations and workflows.
1184
1274
  */
1185
- const loadedByScope = new WeakMap<Scope, { stamp: string; loaded: Promise<LoadedScenarios> }>();
1275
+ const loadedByScope = new WeakMap<
1276
+ Scope,
1277
+ { stamp: string; people: DeclaredPeople | undefined; loaded: Promise<LoadedScenarios> }
1278
+ >();
1186
1279
 
1187
1280
  /**
1188
1281
  * Every scenario in the `.feature` files under `scenarios` (a `file:` URL to a directory),
1189
1282
  * compiled by Cucumber, with an error for each file that does not compile and each name used
1190
1283
  * twice. No directory, or none given, is no scenarios. Rejects when an operation's phrase names
1191
1284
  * a field it lacks. The files are compiled again only once one is added, removed or written
1192
- * (its name, `mtimeMs` or size changes): until then each call returns the same object, which
1193
- * callers must not change.
1285
+ * (its name, `mtimeMs` or size changes), or the people's data files change: until then each
1286
+ * call returns the same object, which callers must not change.
1194
1287
  */
1195
1288
  export async function loadScenarios(scope: Scope): Promise<LoadedScenarios> {
1196
1289
  if (!scope.scenarios) return { scenarios: [], errors: [] };
@@ -1206,10 +1299,12 @@ export async function loadScenarios(scope: Scope): Promise<LoadedScenarios> {
1206
1299
  return [file, mtimeMs, size];
1207
1300
  }),
1208
1301
  ]);
1302
+ // None without a root, which declares no one.
1303
+ const people = scope.root === undefined ? undefined : readPeople(scope.root);
1209
1304
  const cached = loadedByScope.get(scope);
1210
- if (cached?.stamp === stamp) return cached.loaded;
1305
+ if (cached?.stamp === stamp && cached.people === people) return cached.loaded;
1211
1306
  const loaded = files.length ? compile(scope, dir, files) : Promise.resolve({ scenarios: [], errors: [] });
1212
- loadedByScope.set(scope, { stamp, loaded });
1307
+ loadedByScope.set(scope, { stamp, people, loaded });
1213
1308
  // A compile that throws (a bug) is not kept: the next call tries again.
1214
1309
  loaded.catch(() => {
1215
1310
  if (loadedByScope.get(scope)?.loaded === loaded) loadedByScope.delete(scope);
package/src/scenarios.ts CHANGED
@@ -18,21 +18,28 @@ export interface RunScenarioOptions {
18
18
  client: SanomaClient;
19
19
  /** What the scenario was loaded from (`loadScenarios`), whose feature files its run is checked against: the resolved config. */
20
20
  scope: Scope;
21
- /** Who starts the run. Defaults to `{ id: "scenarios" }`. */
22
- startedBy?: Principal;
21
+ /**
22
+ * Who starts the run, by id (`"ada"` or `{ id: "ada" }`), unless the scenario says who is acting.
23
+ * Defaults to `scenarios`, one of the runtime's own agents.
24
+ */
25
+ startedBy?: Principal | string;
23
26
  /** How long the run may take, passed to `drive`. */
24
27
  timeoutMs?: number;
25
28
  }
26
29
 
27
30
  /**
28
- * Runs a scenario as a sandbox run: starts its workflow with its input, decides its approvals
29
- * as it says, and checks the run's ledger against it (`check`: Cucumber runs its `Then` steps).
31
+ * Runs a scenario as a sandbox run: starts its workflow with its input, as whoever it says is
32
+ * acting, decides its approvals as it says, and checks the run's ledger against it (`check`:
33
+ * Cucumber runs its `Then` steps).
30
34
  */
31
35
  export async function runScenario(
32
36
  scenario: Scenario,
33
- { client, scope, startedBy = { id: "scenarios" }, timeoutMs }: RunScenarioOptions,
37
+ { client, scope, startedBy = "scenarios", timeoutMs }: RunScenarioOptions,
34
38
  ): Promise<{ runId: string; run: RunSummary; ledger: LedgerRecord[]; checks: Check[] }> {
35
- const runId = await client.start(scenario.workflow, scenario.input, { startedBy, sandbox: scenario.name });
39
+ const runId = await client.start(scenario.workflow, scenario.input, {
40
+ startedBy: scenario.startedBy ?? startedBy,
41
+ sandbox: scenario.name,
42
+ });
36
43
  const run = await drive(client, runId, scenario, { timeoutMs });
37
44
  const ledger = await client.ledger(runId);
38
45
  return { runId, run, ledger, checks: await check(scope, scenario, ledger) };
@@ -49,8 +56,8 @@ export interface ScenarioFailure {
49
56
  export interface RunScenariosOptions {
50
57
  /** The Postgres the worker and the client run on, instead of the config's: a database of its own. */
51
58
  databaseUrl: string;
52
- /** Who starts the runs. Defaults to `{ id: "scenarios" }`. */
53
- startedBy?: Principal;
59
+ /** Who starts the runs whose scenario does not say who is acting (`runScenario`'s). */
60
+ startedBy?: Principal | string;
54
61
  /** How long each run may take (`drive`'s; 15 seconds by default). */
55
62
  timeoutMs?: number;
56
63
  /** Receives a line per scenario as it ends. */
package/src/schema.ts ADDED
@@ -0,0 +1,261 @@
1
+ import { createRequire } from "node:module";
2
+ import { join } from "node:path";
3
+ import type {
4
+ DetailedError,
5
+ EntityType,
6
+ RecordType,
7
+ SchemaJson,
8
+ Type,
9
+ TypeOfAttribute,
10
+ } from "@cedar-policy/cedar-wasm/nodejs";
11
+ import { recordSchemaOf } from "./collection.ts";
12
+ import type { ResolvedConfig } from "./config.ts";
13
+ import { jsonSchemaOf } from "./define.ts";
14
+ import { isObject } from "./resource.ts";
15
+ import { DATA_VENDOR, EFFECTS, groupNamesOf, type PeopleResource, WORKFLOW_ACTION } from "./shared.ts";
16
+
17
+ // The Cedar schema a company's policies are validated and decided against: made from the
18
+ // resolved config and the people, in memory for the evaluator, and written as text to
19
+ // `.sanoma/schema.cedarschema` for the policies' author.
20
+
21
+ const require = createRequire(import.meta.url);
22
+ let wasm: typeof import("@cedar-policy/cedar-wasm/nodejs") | undefined;
23
+
24
+ /** `@cedar-policy/cedar-wasm`'s Node build, loaded (synchronously, once) on first use, so a process that never decides with Cedar never loads it. */
25
+ export const cedarWasm = () => (wasm ??= require("@cedar-policy/cedar-wasm/nodejs") as typeof wasm)!;
26
+
27
+ /** The schema as Cedar's JSON format has it. */
28
+ export type CedarSchemaJson = SchemaJson<string>;
29
+
30
+ /** Where the schema's text is written, under a company's root. */
31
+ export const SCHEMA_FILE = join(".sanoma", "schema.cedarschema");
32
+
33
+ /** The schema's namespace: policies name `Sanoma::Group::"finance"`, `Sanoma::Action::"effect:money"`. */
34
+ export const NAMESPACE = "Sanoma";
35
+
36
+ /** The action group every workflow start (`workflow:<name>`) is in. */
37
+ export const START = "start";
38
+
39
+ const STRING = { type: "EntityOrCommon", name: "String" } as const;
40
+ const LONG = { type: "EntityOrCommon", name: "Long" } as const;
41
+ const BOOL = { type: "EntityOrCommon", name: "Bool" } as const;
42
+ const entity = (name: string) => ({ type: "Entity", name }) as const;
43
+ const record = (attributes: Record<string, TypeOfAttribute<string>>): RecordType<string> & { type: "Record" } => ({
44
+ type: "Record",
45
+ attributes,
46
+ });
47
+ const optional = (type: Type<string>): TypeOfAttribute<string> => ({ ...type, required: false });
48
+
49
+ /**
50
+ * The Cedar type of a zod schema's parsed value (`io: "output"`: what the policy is given, defaults
51
+ * filled and transforms applied), through its JSON Schema; undefined when it has none.
52
+ */
53
+ const typeOf = (schema: Parameters<typeof jsonSchemaOf>[0]) => {
54
+ const json = jsonSchemaOf(schema, "output");
55
+ return cedarType(json, { root: json, seen: new Set() });
56
+ };
57
+
58
+ /** A context's `input`, typed from a zod schema: none when it has no Cedar type. */
59
+ const input = (schema: Parameters<typeof jsonSchemaOf>[0]): Record<string, TypeOfAttribute<string>> => {
60
+ const type = typeOf(schema);
61
+ return type ? { input: type } : {};
62
+ };
63
+
64
+ /** A collection's operations that change a record, whose context has the record as it was (`before`). */
65
+ const WRITES = new Set(["save", "delete"]);
66
+
67
+ /**
68
+ * The Cedar schema for a resolved config and its people, namespace `Sanoma`:
69
+ *
70
+ * - `entity Group enum [...]`, the declared groups and `agents`, so a policy naming another fails
71
+ * validation; `entity Principal in [Group] { onBehalfOf?: Principal }`; `Collection`;
72
+ * `Target in [Collection]`; `Workflow`;
73
+ * - an action per operation id, in its effect's group (`effect:money`), on a `Target`, with
74
+ * `context` `{ input, before? }`: `input` from the operation's input schema, `before` the record
75
+ * as it was for a collection's `save` and `delete`;
76
+ * - `workflow:<name>` per workflow, in the group `start`, on a `Workflow`, with its `input`.
77
+ *
78
+ * Zod to Cedar, through the JSON Schema of the parsed value: string and enum `String`, integer `Long`, boolean `Bool`,
79
+ * object a record (its optional and nullable fields optional), array a `Set`; a decimal number
80
+ * is a `String`; a field with no Cedar type (a union of types, a map, a recursive type) is left
81
+ * out, so a policy naming it fails validation. Pure: the same config and people give the same schema.
82
+ */
83
+ function buildSchema(
84
+ resolved: Pick<ResolvedConfig, "ops" | "workflows" | "collections">,
85
+ people: readonly PeopleResource[],
86
+ ): CedarSchemaJson {
87
+ const applies = (resource: string, context: Record<string, TypeOfAttribute<string>>) => ({
88
+ principalTypes: ["Principal"],
89
+ resourceTypes: [resource],
90
+ context: record(context),
91
+ });
92
+ const actions: CedarSchemaJson[string]["actions"] = { [START]: {} };
93
+ for (const effect of EFFECTS) actions[`effect:${effect}`] = {};
94
+ for (const op of [...resolved.ops.values()].toSorted((a, b) => a.id.localeCompare(b.id))) {
95
+ const collection =
96
+ op.vendor === DATA_VENDOR && WRITES.has(op.name) ? resolved.collections.get(op.resource) : undefined;
97
+ const before = collection && typeOf(recordSchemaOf(collection));
98
+ actions[op.id] = {
99
+ memberOf: [{ id: `effect:${op.effect}` }],
100
+ appliesTo: applies("Target", { ...input(op.input), ...(before && { before: optional(before) }) }),
101
+ };
102
+ }
103
+ for (const name of [...resolved.workflows.keys()].toSorted()) {
104
+ actions[`${WORKFLOW_ACTION}${name}`] = {
105
+ memberOf: [{ id: START }],
106
+ appliesTo: applies("Workflow", input(resolved.workflows.get(name)!.input)),
107
+ };
108
+ }
109
+ const entityTypes: Record<string, EntityType<string>> = {
110
+ Group: { enum: groupNamesOf(people) },
111
+ Principal: { memberOfTypes: ["Group"], shape: record({ onBehalfOf: optional(entity("Principal")) }) },
112
+ Collection: {},
113
+ Target: { memberOfTypes: ["Collection"] },
114
+ Workflow: {},
115
+ };
116
+ return { [NAMESPACE]: { entityTypes, actions } };
117
+ }
118
+
119
+ const schemas = new WeakMap<object, WeakMap<readonly PeopleResource[], CedarSchemaJson>>();
120
+
121
+ /**
122
+ * The Cedar schema for a resolved config and its people (`buildSchema`), made once for each pair:
123
+ * validating, deciding and writing it share one.
124
+ */
125
+ export function schemaOf(
126
+ resolved: Pick<ResolvedConfig, "ops" | "workflows" | "collections">,
127
+ people: readonly PeopleResource[],
128
+ ): CedarSchemaJson {
129
+ let byPeople = schemas.get(resolved.ops);
130
+ if (!byPeople) schemas.set(resolved.ops, (byPeople = new WeakMap()));
131
+ let schema = byPeople.get(people);
132
+ if (!schema) byPeople.set(people, (schema = buildSchema(resolved, people)));
133
+ return schema;
134
+ }
135
+
136
+ /** The schema as Cedar's text, through the package's own `schemaToText`: what `.sanoma/schema.cedarschema` holds. */
137
+ export function schemaText(schema: CedarSchemaJson): string {
138
+ const answer = cedarWasm().schemaToText(schema);
139
+ if (answer.type === "failure") {
140
+ throw new Error(`The Cedar schema does not print: ${answer.errors.map(messageOf).join("; ")}`);
141
+ }
142
+ return answer.text;
143
+ }
144
+
145
+ interface Typing {
146
+ root: Record<string, unknown>;
147
+ /** The schemas being typed, to leave a recursive one out. */
148
+ seen: Set<unknown>;
149
+ }
150
+
151
+ /** The Cedar type of a JSON Schema (`jsonSchemaOf`'s, draft 2020-12), or undefined when it has none. */
152
+ function cedarType(schema: unknown, typing: Typing): Type<string> | undefined {
153
+ if (!isObject(schema) || typing.seen.has(schema)) return undefined;
154
+ if (typeof schema.$ref === "string") {
155
+ const defs = typing.root.$defs as Record<string, unknown> | undefined;
156
+ const target = schema.$ref.startsWith("#/$defs/") ? defs?.[schema.$ref.slice("#/$defs/".length)] : undefined;
157
+ return within(schema, typing, () => cedarType(target, typing));
158
+ }
159
+ const variants = schema.anyOf ?? schema.oneOf;
160
+ if (Array.isArray(variants)) {
161
+ const each = within(schema, typing, () => variants.filter((v) => !isNull(v)).map((v) => cedarType(v, typing)));
162
+ const first = each[0];
163
+ return first && each.every((t) => t && JSON.stringify(t) === JSON.stringify(first)) ? first : undefined;
164
+ }
165
+ const values = Array.isArray(schema.enum) ? schema.enum : "const" in schema ? [schema.const] : undefined;
166
+ if (values) {
167
+ if (values.every((v) => typeof v === "string")) return STRING;
168
+ if (values.every((v) => Number.isSafeInteger(v))) return LONG;
169
+ return values.every((v) => typeof v === "boolean") ? BOOL : undefined;
170
+ }
171
+ const types = Array.isArray(schema.type) ? schema.type.filter((t) => t !== "null") : [schema.type];
172
+ if (types.length !== 1) return undefined;
173
+ switch (types[0]) {
174
+ case "string":
175
+ case "number":
176
+ // Cedar has no decimal number: one is compared as text.
177
+ return STRING;
178
+ case "integer":
179
+ return LONG;
180
+ case "boolean":
181
+ return BOOL;
182
+ case "array": {
183
+ const element = within(schema, typing, () => cedarType(schema.items, typing));
184
+ return element && { type: "Set", element };
185
+ }
186
+ case "object": {
187
+ // A map's keys are not known: it has no Cedar type.
188
+ if (!isObject(schema.properties)) return schema.additionalProperties ? undefined : record({});
189
+ const required = new Set(Array.isArray(schema.required) ? schema.required : []);
190
+ const attributes: Record<string, TypeOfAttribute<string>> = {};
191
+ for (const [name, property] of Object.entries(schema.properties)) {
192
+ const type = within(schema, typing, () => cedarType(property, typing));
193
+ if (type) attributes[name] = required.has(name) && !allowsNull(property) ? type : optional(type);
194
+ }
195
+ return record(attributes);
196
+ }
197
+ default:
198
+ return undefined;
199
+ }
200
+ }
201
+
202
+ /** `type()` with `schema` marked as being typed, so a reference back to it is not followed. */
203
+ function within<T>(schema: unknown, typing: Typing, type: () => T): T {
204
+ typing.seen.add(schema);
205
+ try {
206
+ return type();
207
+ } finally {
208
+ typing.seen.delete(schema);
209
+ }
210
+ }
211
+
212
+ const isNull = (schema: unknown) => isObject(schema) && schema.type === "null";
213
+
214
+ /** True when a JSON Schema accepts `null`: its value is then optional in Cedar, which has no null. */
215
+ const allowsNull = (schema: unknown): boolean =>
216
+ isObject(schema) &&
217
+ (isNull(schema) ||
218
+ (Array.isArray(schema.type) && schema.type.includes("null")) ||
219
+ [schema.anyOf, schema.oneOf].some((v) => Array.isArray(v) && v.some(isNull)));
220
+
221
+ /** Cedar's message, with its help when it has one. */
222
+ export const messageOf = (e: DetailedError) => (e.help ? `${e.message} (${e.help})` : e.message);
223
+
224
+ /** An action's context type in the schema: a record, when the action is there. */
225
+ export const contextTypeOf = (schema: CedarSchemaJson, action: string) =>
226
+ schema[NAMESPACE]?.actions[action]?.appliesTo?.context as (RecordType<string> & { type: "Record" }) | undefined;
227
+
228
+ /**
229
+ * A value as Cedar takes it for a type `schemaOf` wrote (`cedarType`'s other side): a record's
230
+ * fields it declares only, a set's elements, a number where it is a `String` as text. An unknown
231
+ * (`{ __extn: unknown }`) passes as it is; what does not fit is left out (undefined), as `null` is.
232
+ */
233
+ export function cedarValue(value: unknown, type: Type<string>): unknown {
234
+ if (value === null || value === undefined) return undefined;
235
+ if (isObject(value) && "__extn" in value) return value;
236
+ const t = type as { type: string; name?: string; element?: Type<string>; attributes?: Record<string, Type<string>> };
237
+ if (t.attributes) {
238
+ if (!isObject(value)) return undefined;
239
+ return Object.fromEntries(
240
+ Object.entries(t.attributes).flatMap(([name, field]) => {
241
+ const shaped = cedarValue(value[name], field);
242
+ return shaped === undefined ? [] : [[name, shaped]];
243
+ }),
244
+ );
245
+ }
246
+ if (t.type === "Set") {
247
+ return Array.isArray(value)
248
+ ? value.map((v) => cedarValue(v, t.element!)).filter((v) => v !== undefined)
249
+ : undefined;
250
+ }
251
+ switch (t.name) {
252
+ case STRING.name:
253
+ return typeof value === "string" ? value : typeof value === "number" ? String(value) : undefined;
254
+ case LONG.name:
255
+ return Number.isSafeInteger(value) ? value : undefined;
256
+ case BOOL.name:
257
+ return typeof value === "boolean" ? value : undefined;
258
+ default:
259
+ return undefined;
260
+ }
261
+ }