@sanoma-ai/workflows 0.6.0 → 0.7.1

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 (166) hide show
  1. package/README.md +52 -14
  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 +75 -8
  23. package/dist/config.js +103 -13
  24. package/dist/config.js.map +1 -1
  25. package/dist/constructors.d.ts +37 -1
  26. package/dist/constructors.js +6 -2
  27. package/dist/constructors.js.map +1 -1
  28. package/dist/database.d.ts +83 -0
  29. package/dist/database.js +92 -0
  30. package/dist/database.js.map +1 -0
  31. package/dist/db.d.ts +19 -1
  32. package/dist/db.js +51 -7
  33. package/dist/db.js.map +1 -1
  34. package/dist/deadlines.d.ts +91 -0
  35. package/dist/deadlines.js +165 -0
  36. package/dist/deadlines.js.map +1 -0
  37. package/dist/define.d.ts +25 -4
  38. package/dist/define.js +40 -0
  39. package/dist/define.js.map +1 -1
  40. package/dist/demo.d.ts +28 -0
  41. package/dist/demo.js +72 -0
  42. package/dist/demo.js.map +1 -0
  43. package/dist/describe.d.ts +5 -2
  44. package/dist/describe.js +10 -6
  45. package/dist/describe.js.map +1 -1
  46. package/dist/errors.d.ts +10 -1
  47. package/dist/errors.js +13 -0
  48. package/dist/errors.js.map +1 -1
  49. package/dist/fake.d.ts +34 -0
  50. package/dist/fake.js +134 -1
  51. package/dist/fake.js.map +1 -1
  52. package/dist/grid.d.ts +34 -0
  53. package/dist/grid.js +62 -0
  54. package/dist/grid.js.map +1 -0
  55. package/dist/host.d.ts +12 -2
  56. package/dist/host.js +2 -2
  57. package/dist/host.js.map +1 -1
  58. package/dist/index.d.ts +15 -8
  59. package/dist/index.js +10 -5
  60. package/dist/index.js.map +1 -1
  61. package/dist/ledger.d.ts +11 -2
  62. package/dist/ledger.js.map +1 -1
  63. package/dist/lint.js +2 -2
  64. package/dist/lint.js.map +1 -1
  65. package/dist/op.d.ts +21 -2
  66. package/dist/op.js.map +1 -1
  67. package/dist/person.d.ts +5 -0
  68. package/dist/person.js +3 -1
  69. package/dist/person.js.map +1 -1
  70. package/dist/policy.d.ts +7 -1
  71. package/dist/policy.js +2 -1
  72. package/dist/policy.js.map +1 -1
  73. package/dist/resource.d.ts +13 -1
  74. package/dist/resource.js +9 -1
  75. package/dist/resource.js.map +1 -1
  76. package/dist/resources.d.ts +2 -0
  77. package/dist/resources.js +15 -4
  78. package/dist/resources.js.map +1 -1
  79. package/dist/run.d.ts +5 -0
  80. package/dist/run.js.map +1 -1
  81. package/dist/sandbox.d.ts +38 -10
  82. package/dist/sandbox.js +66 -35
  83. package/dist/sandbox.js.map +1 -1
  84. package/dist/scenario.d.ts +18 -1
  85. package/dist/scenario.js +77 -27
  86. package/dist/scenario.js.map +1 -1
  87. package/dist/scenarios.d.ts +10 -1
  88. package/dist/scenarios.js +19 -4
  89. package/dist/scenarios.js.map +1 -1
  90. package/dist/schedules.d.ts +66 -0
  91. package/dist/schedules.js +158 -0
  92. package/dist/schedules.js.map +1 -0
  93. package/dist/schema.d.ts +6 -0
  94. package/dist/schema.js +13 -3
  95. package/dist/schema.js.map +1 -1
  96. package/dist/shared.d.ts +30 -2
  97. package/dist/shared.js +42 -4
  98. package/dist/shared.js.map +1 -1
  99. package/dist/source.d.ts +185 -2
  100. package/dist/source.js +9 -0
  101. package/dist/source.js.map +1 -1
  102. package/dist/supervisor.d.ts +39 -0
  103. package/dist/supervisor.js +68 -0
  104. package/dist/supervisor.js.map +1 -0
  105. package/dist/version.d.ts +3 -1
  106. package/dist/version.js +3 -1
  107. package/dist/version.js.map +1 -1
  108. package/dist/wakeup-driver.d.ts +128 -0
  109. package/dist/wakeup-driver.js +216 -0
  110. package/dist/wakeup-driver.js.map +1 -0
  111. package/dist/wakeup-service.d.ts +83 -0
  112. package/dist/wakeup-service.js +415 -0
  113. package/dist/wakeup-service.js.map +1 -0
  114. package/dist/wakeup-type.d.ts +43 -0
  115. package/dist/wakeup-type.js +80 -0
  116. package/dist/wakeup-type.js.map +1 -0
  117. package/dist/wakeup.d.ts +77 -0
  118. package/dist/wakeup.js +78 -0
  119. package/dist/wakeup.js.map +1 -0
  120. package/dist/worker.d.ts +39 -1
  121. package/dist/worker.js +125 -38
  122. package/dist/worker.js.map +1 -1
  123. package/oxlint.json +3 -1
  124. package/package.json +10 -2
  125. package/src/apply.ts +25 -6
  126. package/src/approvals.ts +89 -16
  127. package/src/call.ts +40 -29
  128. package/src/cedar.ts +24 -6
  129. package/src/change.ts +222 -0
  130. package/src/chats.ts +114 -0
  131. package/src/client.ts +145 -55
  132. package/src/config.ts +149 -21
  133. package/src/constructors.ts +8 -2
  134. package/src/database.ts +152 -0
  135. package/src/db.ts +58 -9
  136. package/src/deadlines.ts +228 -0
  137. package/src/define.ts +71 -4
  138. package/src/demo.ts +87 -0
  139. package/src/describe.ts +18 -6
  140. package/src/errors.ts +14 -0
  141. package/src/fake.ts +149 -0
  142. package/src/grid.ts +100 -0
  143. package/src/host.ts +12 -4
  144. package/src/index.ts +66 -3
  145. package/src/ledger.ts +15 -3
  146. package/src/lint.ts +2 -2
  147. package/src/op.ts +22 -2
  148. package/src/person.ts +4 -1
  149. package/src/policy.ts +7 -1
  150. package/src/resource.ts +22 -3
  151. package/src/resources.ts +17 -4
  152. package/src/run.ts +5 -0
  153. package/src/sandbox.ts +97 -37
  154. package/src/scenario.ts +97 -28
  155. package/src/scenarios.ts +21 -5
  156. package/src/schedules.ts +206 -0
  157. package/src/schema.ts +16 -3
  158. package/src/shared.ts +51 -4
  159. package/src/source.ts +176 -3
  160. package/src/supervisor.ts +102 -0
  161. package/src/version.ts +3 -1
  162. package/src/wakeup-driver.ts +270 -0
  163. package/src/wakeup-service.ts +504 -0
  164. package/src/wakeup-type.ts +90 -0
  165. package/src/wakeup.ts +139 -0
  166. package/src/worker.ts +176 -43
package/src/sandbox.ts CHANGED
@@ -4,7 +4,7 @@ import { opIdsOf } from "./define.ts";
4
4
  import { SanomaError } from "./errors.ts";
5
5
  import type { Fake, ResourceFake } from "./fake.ts";
6
6
  import { entry, type LedgerRecord, write } from "./ledger.ts";
7
- import type { DriverFn } from "./op.ts";
7
+ import type { CallContext, DriverFn, Op } from "./op.ts";
8
8
  import type { Run } from "./run.ts";
9
9
  import { memorySecrets } from "./secret.ts";
10
10
  import { type Given, loadScenarios, type Scenario } from "./scenario.ts";
@@ -58,7 +58,7 @@ export function seedResource(
58
58
  scenario: string,
59
59
  copy: ResourceFake,
60
60
  given: Extract<Given, { resource: string }>,
61
- ): Extract<LedgerRecord, { type: "scenario.seeded" }>["seeds"][number] {
61
+ ): Seeds[number] {
62
62
  const { resource, type, name } = given;
63
63
  try {
64
64
  if ("gone" in given) {
@@ -77,16 +77,81 @@ export function seedResource(
77
77
  }
78
78
  }
79
79
 
80
+ /** What a scenario's `Given` steps seeded, as its `scenario.seeded` record has it. */
81
+ export type Seeds = Extract<LedgerRecord, { type: "scenario.seeded" }>["seeds"];
82
+
83
+ /**
84
+ * Where a scenario's `Given` steps go (`applyGivens`): a sandbox run's own fakes (`run`), or the
85
+ * instance a demo runs (`instance`), its collections in the config's database and every vendor
86
+ * on its one fake, which every run shares, so a fault, injected into a call some other run would
87
+ * make, is refused there.
88
+ */
89
+ export interface GivenTarget {
90
+ kind: "run" | "instance";
91
+ /** What a `Given` operation is called through, by operation id. */
92
+ drivers: ReadonlyMap<string, DriverFn>;
93
+ /** The fakes, by vendor id: what a `Given` resource is put in, and a `run`'s faults injected into. */
94
+ fakes: ReadonlyMap<string, Fake<unknown>>;
95
+ /** The operations by id, for a fault's vendor. */
96
+ ops: ReadonlyMap<string, Op>;
97
+ /** The context of the call the `i`th `Given` makes. */
98
+ call(i: number, op: string): CallContext;
99
+ }
100
+
101
+ /**
102
+ * Applies the scenario's `Given` steps to the target: calls each operation through its drivers,
103
+ * puts or removes each resource in its vendor's fake, then, for a `run`, injects the faults.
104
+ * Checks first that every operation it seeds has a driver there, that every resource's vendor has
105
+ * a fake holding resources, and, for an `instance`, that it injects no fault. Returns what it
106
+ * seeded, in order.
107
+ */
108
+ export async function applyGivens(scenario: Pick<Scenario, "name" | "given">, target: GivenTarget): Promise<Seeds> {
109
+ const { name, given } = scenario;
110
+ const fault = given.find((g) => "fault" in g);
111
+ if (fault && target.kind === "instance") {
112
+ throw new SanomaError(
113
+ "invalid_input",
114
+ `Scenario "${name}" injects a fault ("${fault.step}"), but its Givens go to the instance, whose fakes every run shares`,
115
+ { scenario: name, step: fault.step },
116
+ );
117
+ }
118
+ const missing = [...new Set(given.flatMap((g) => ("op" in g && !target.drivers.has(g.op) ? [g.op] : [])))];
119
+ if (missing.length) {
120
+ throw new SanomaError(
121
+ "invalid_input",
122
+ `Scenario "${name}" seeds ${missing.join(", ")}, but no fake its connectors attach implements ${missing.length > 1 ? "them" : "it"}`,
123
+ { scenario: name, missing },
124
+ );
125
+ }
126
+ const plain = missingResourceFake(scenario, target.fakes);
127
+ if (plain) throw plain;
128
+ const seeded: Seeds = [];
129
+ for (const [i, g] of given.entries()) {
130
+ if ("fault" in g) continue;
131
+ if ("op" in g) {
132
+ const output = await target.drivers.get(g.op)!(g.input, target.call(i, g.op));
133
+ seeded.push({ op: g.op, input: g.input, output });
134
+ continue;
135
+ }
136
+ // Checked above: the vendor's fake holds resources.
137
+ seeded.push(seedResource(name, target.fakes.get(g.vendor) as Fake<unknown> & ResourceFake, g));
138
+ }
139
+ for (const g of given) {
140
+ if ("fault" in g) target.fakes.get(target.ops.get(g.op)!.vendor)![g.fault](g.op);
141
+ }
142
+ return seeded;
143
+ }
144
+
80
145
  /**
81
146
  * Seeds a sandbox run of the scenario `name`, from the worker, in one step: reads the scenario,
82
- * checks that it runs this workflow, makes the run's own fakes (each vendor's maker, called once
83
- * for the run), checks that every operation the workflow uses or the scenario seeds has one and
84
- * that every resource it seeds has a fake holding resources, calls each `Given` operation
85
- * through them, puts or removes each `Given` resource in them, injects the faults into them,
86
- * then puts their functions on the run. The run then calls those only, so sandbox runs share no
87
- * state when the makers make a new fake each. The step's checkpoint never stands in for them: a
88
- * run whose seeding is replayed from it has none, and fails, since a sandbox run does not
89
- * survive a worker restart.
147
+ * checks that it runs this workflow, picks the run's fakes, checks that every operation the
148
+ * workflow uses has one, then applies the scenario's `Given` steps to them
149
+ * (`applyGivens`) and puts their functions on the run. The run then calls those only. Its fakes
150
+ * are its own (each vendor's maker, called once for the run), so sandbox runs share no state when
151
+ * the makers make a new fake each; on an instance that runs on fakes (`instanceFakes`) they are
152
+ * the instance's, which its sandbox runs share, as its live drivers are. The step's checkpoint
153
+ * never stands in for a run's own: a run whose seeding is replayed from it has none, and fails,
154
+ * since a sandbox run does not survive a worker restart; on an instance's fakes it goes on with them.
90
155
  */
91
156
  export async function seedSandbox(run: Run, name: string): Promise<void> {
92
157
  const { state } = run;
@@ -103,19 +168,22 @@ export async function seedSandbox(run: Run, name: string): Promise<void> {
103
168
  throw new SanomaError("invalid_input", `No scenario named "${name}"${why}`, { scenario: name });
104
169
  }
105
170
  if (scenario.workflow !== run.workflow) {
106
- throw new SanomaError("invalid_input", `Scenario "${name}" runs ${scenario.workflow}, not ${run.workflow}`, {
171
+ const runs = scenario.workflow === undefined ? "is a world, which runs nothing" : `runs ${scenario.workflow}`;
172
+ throw new SanomaError("invalid_input", `Scenario "${name}" ${runs}, not ${run.workflow}`, {
107
173
  scenario: name,
108
- workflow: scenario.workflow,
174
+ ...(scenario.workflow !== undefined && { workflow: scenario.workflow }),
109
175
  });
110
176
  }
111
- const fakes = new Map([...state.fakes].map(([vendor, make]) => [vendor, make()]));
112
- const drivers = indexDrivers(
113
- [...fakes.values()].map((f) => f.driver),
114
- state.ops,
115
- );
116
- const uses = opIdsOf(state.workflows.get(run.workflow)!);
117
- const seeding = scenario.given.flatMap((g) => ("op" in g ? [g.op] : []));
118
- const missing = [...new Set([...uses, ...seeding])].filter((id) => !drivers.has(id));
177
+ const instance = state.instanceFakes;
178
+ const fakes = instance ?? new Map([...state.fakes].map(([vendor, make]) => [vendor, make()]));
179
+ const drivers = instance
180
+ ? state.drivers
181
+ : indexDrivers(
182
+ [...fakes.values()].map((f) => f.driver),
183
+ state.ops,
184
+ );
185
+ // What the scenario seeds is checked by `applyGivens`.
186
+ const missing = opIdsOf(state.workflows.get(run.workflow)!).filter((id) => !drivers.has(id));
119
187
  if (missing.length) {
120
188
  throw new SanomaError(
121
189
  "invalid_input",
@@ -123,28 +191,20 @@ export async function seedSandbox(run: Run, name: string): Promise<void> {
123
191
  { scenario: name, missing },
124
192
  );
125
193
  }
126
- const plain = missingResourceFake(scenario, fakes);
127
- if (plain) throw plain;
128
- const seeded: Extract<LedgerRecord, { type: "scenario.seeded" }>["seeds"] = [];
129
- for (const [i, given] of scenario.given.entries()) {
130
- if ("fault" in given) continue;
131
- if ("op" in given) {
132
- const call = { idempotencyKey: `${run.id}:seed:${i}`, runId: run.id, opId: given.op, attempt: 1, secrets };
133
- const output = await drivers.get(given.op)!(given.input, call);
134
- seeded.push({ op: given.op, input: given.input, output });
135
- continue;
136
- }
137
- // Checked above: the vendor's fake holds resources.
138
- seeded.push(seedResource(name, fakes.get(given.vendor) as Fake<unknown> & ResourceFake, given));
139
- }
140
- for (const given of scenario.given) {
141
- if ("fault" in given) fakes.get(state.ops.get(given.op)!.vendor)![given.fault](given.op);
142
- }
194
+ const seeded = await applyGivens(scenario, {
195
+ kind: instance ? "instance" : "run",
196
+ drivers,
197
+ fakes,
198
+ ops: state.ops,
199
+ call: (i, opId) => ({ idempotencyKey: `${run.id}:seed:${i}`, runId: run.id, opId, attempt: 1, secrets }),
200
+ });
143
201
  copies = drivers;
144
202
  return { seeds: seeded };
145
203
  },
146
204
  { name: "sandbox:seed" },
147
205
  );
206
+ // Replayed on an instance that runs on fakes: they are the instance's, still there in this process.
207
+ if (!copies && state.instanceFakes) copies = state.drivers;
148
208
  if (!copies) {
149
209
  // Replayed from its checkpoint (after a worker restart, or in a fork), so the run has no fakes.
150
210
  // The records written before the restart hold the seqs from here on: the failure goes after them.
package/src/scenario.ts CHANGED
@@ -135,7 +135,19 @@ export interface Scenario {
135
135
  file: string;
136
136
  /** Its line in the file: its keyword's, or for an outline's row, the row's. */
137
137
  line: number;
138
- workflow: string;
138
+ /**
139
+ * The workflow its `When` runs. Absent for a scenario with no `When`, a *world*: its `Given`
140
+ * steps alone, which a demo applies to the instance (`applyGivens`) and nothing runs or checks.
141
+ */
142
+ workflow?: string;
143
+ /** Its tags, as written (`@slow`), the feature's and its own. */
144
+ tags: string[];
145
+ /**
146
+ * When it is tagged `@moment(<person>,<path>)` (no space: a Gherkin tag has none): a moment,
147
+ * which a demo starts as a sandbox run by `person`, a declared person, and leaves waiting, for
148
+ * a visitor signed in as them to land on at `path`.
149
+ */
150
+ moment?: Moment;
139
151
  /**
140
152
  * The scenario's own lines of its feature file, for display: from its keyword line to the end
141
153
  * of its last step (a step's doc string or table included, and an outline's examples), less
@@ -158,6 +170,12 @@ export interface Scenario {
158
170
  expect: Expectation[];
159
171
  }
160
172
 
173
+ /** A moment's visitor and where they land: `@moment(<person>,<path>)`. */
174
+ export interface Moment {
175
+ person: string;
176
+ path: string;
177
+ }
178
+
161
179
  /** The result of one expectation against a run's ledger. */
162
180
  export interface Check {
163
181
  step: string;
@@ -380,6 +398,22 @@ function builtFor(scope: Scope): Built {
380
398
  /** A `Then` step of the generic steps: its expectation, checked against the run's records. */
381
399
  const expecting = (expect: CoreExpectation): Then => ({ expect, verify: (run) => detailOf(expect, run.records) });
382
400
 
401
+ /**
402
+ * `who`, as a run's start admits them (`admit`): a declared person or agent, or `anonymous`. The
403
+ * people are the root's data files' (`readPeople`, read again only once a file changed): while
404
+ * they declare no one, any id goes.
405
+ */
406
+ function actorNamed(scope: Scope, who: string): string {
407
+ const declared = readPeople(scope.root);
408
+ try {
409
+ return admit({ id: who }, declared).id;
410
+ } catch (err) {
411
+ if (errorCode(err) !== "not_admitted" || declared.problems.length) throw err;
412
+ const ids = [...principalIdsOf(declared.people), ...RUNTIME_AGENTS].join(", ");
413
+ throw new Error(`${who} is not a declared person or agent; resources/ declares ${ids}`, { cause: err });
414
+ }
415
+ }
416
+
383
417
  /** The rules a scope has, and the parameter types their expressions use. */
384
418
  function rulesOf(scope: Scope): { rules: Rule[]; parameterTypes: ParameterTypeSpec[] } {
385
419
  const { types, collections } = scope;
@@ -403,20 +437,10 @@ function rulesOf(scope: Scope): { rules: Rule[]; parameterTypes: ParameterTypeSp
403
437
  ];
404
438
  const opOf = (id: unknown) => scope.ops.get(id as string)!;
405
439
  const workflowOf = (name: unknown) => scope.workflows.get(name as string)!;
406
- // The people as the root's data files declare them when a step names one (`readPeople`, read
407
- // again only once a file changed): while they declare no one, any id and any group go.
408
- /** `who`, as a run's start admits them (`admit`): a declared person or agent, or `anonymous`. */
409
- const actorNamed = (who: string) => {
410
- const declared = readPeople(scope.root);
411
- try {
412
- return admit({ id: who }, declared).id;
413
- } catch (err) {
414
- if (errorCode(err) !== "not_admitted" || declared.problems.length) throw err;
415
- const ids = [...principalIdsOf(declared.people), ...RUNTIME_AGENTS].join(", ");
416
- throw new Error(`${who} is not a declared person or agent; resources/ declares ${ids}`, { cause: err });
417
- }
418
- };
419
- /** `group`, a declared group or `agents`, while the people's files have no problems. */
440
+ /**
441
+ * `group`, a declared group or `agents`, while the people's files have no problems (`readPeople`,
442
+ * read again only once a file changed): while they declare no one, any group goes.
443
+ */
420
444
  const groupNamed = (group: string) => {
421
445
  const { people, problems } = readPeople(scope.root);
422
446
  if (problems.length) {
@@ -434,7 +458,7 @@ function rulesOf(scope: Scope): { rules: Rule[]; parameterTypes: ParameterTypeSp
434
458
  return { resource: id as string, vendor, type, name: name as string };
435
459
  };
436
460
  const rules: Rule[] = [
437
- { kind: "actor", source: "{who} is acting", read: ([who]) => actorNamed(who as string) },
461
+ { kind: "actor", source: "{who} is acting", read: ([who]) => actorNamed(scope, who as string) },
438
462
  // A visitor of a public site, as a login would vouch for one; `anonymous` is a `{who}`.
439
463
  { kind: "actor", source: "a visitor is acting", read: () => visitorPrincipal("scenario", "visitor").id },
440
464
  {
@@ -881,7 +905,7 @@ function namesOf(report: Report): Map<string, string> {
881
905
  }
882
906
 
883
907
  /** What one scenario's steps said, as compiling it records them. */
884
- type Spec = Omit<Scenario, "workflow" | "approvals" | "file" | "line" | "text"> & { workflow?: string };
908
+ type Spec = Omit<Scenario, "approvals" | "file" | "line" | "text" | "tags" | "moment">;
885
909
 
886
910
  /** The run's records, as a `Then` step reads them. */
887
911
  function viewOf(records: readonly LedgerRecord[]): RunView {
@@ -1177,6 +1201,28 @@ function stepError(built: Built, result: StepResult | undefined, step: PickleSte
1177
1201
  }
1178
1202
  }
1179
1203
 
1204
+ /**
1205
+ * The moment its tags make a scenario (`@moment(<person>,<path>)`), the person admitted as a run's
1206
+ * starter is, or undefined for none; throws, at `where`, for a malformed one or a second.
1207
+ */
1208
+ function momentOf(scope: Scope, tags: readonly string[], where: string): Moment | undefined {
1209
+ const tagged = tags.filter((t) => t === "@moment" || t.startsWith("@moment("));
1210
+ if (tagged.length === 0) return undefined;
1211
+ if (tagged.length > 1) throw new Error(`${where}: ${tagged.join(" and ")}: a moment has one person and one page`);
1212
+ const [, person, path] = /^@moment\(([^,()]+),(\/[^()]*)\)$/.exec(tagged[0]!) ?? [];
1213
+ if (person === undefined || path === undefined) {
1214
+ throw new Error(
1215
+ `${where}: ${tagged[0]} needs a person and a path, as in @moment(alex,/sites/portal/requests), with no space: a tag has none`,
1216
+ );
1217
+ }
1218
+ try {
1219
+ return { person: actorNamed(scope, person), path };
1220
+ } catch (err) {
1221
+ if (isBug(err)) throw err;
1222
+ throw new Error(`${where}: ${tagged[0]}: ${errorMessage(err)}`, { cause: err });
1223
+ }
1224
+ }
1225
+
1180
1226
  /** The `.feature` files under `dir`, relative to it, with `/` between directories, in order. */
1181
1227
  function featureFiles(dir: string): string[] {
1182
1228
  return readdirSync(dir, { recursive: true, encoding: "utf8" })
@@ -1197,8 +1243,9 @@ const parseErrorOf = ({ uri, line, message }: Report["parseErrors"][number]) =>
1197
1243
 
1198
1244
  /**
1199
1245
  * Compiles the feature files `files` (relative to `dir`) into scenarios: Cucumber runs each,
1200
- * and its steps record what they say. A file with an error has none of its scenarios: the
1201
- * first error in it (a step that fails or matches no step or two, a scenario with no `When`,
1246
+ * and its steps record what they say. A file none of whose scenarios has a `When` is of worlds.
1247
+ * A file with an error has none of its scenarios: the
1248
+ * first error in it (a step that fails or matches no step or two, a scenario with no `When` beside one with,
1202
1249
  * a name used twice, a file Gherkin cannot parse, no scenarios at all) is its error.
1203
1250
  */
1204
1251
  async function compile(scope: Scope, dir: string, files: string[]): Promise<LoadedScenarios> {
@@ -1234,6 +1281,8 @@ async function compile(scope: Scope, dir: string, files: string[]): Promise<Load
1234
1281
  try {
1235
1282
  const list = pickles.get(file) ?? [];
1236
1283
  if (list.length === 0) throw new Error(`${file}: no scenarios; add one with "Scenario: <name>"`);
1284
+ // A feature none of whose scenarios has a When is of worlds; elsewhere a missing When is a mistake.
1285
+ const ofWorlds = list.every((p) => specs.get(p.id)!.workflow === undefined);
1237
1286
  for (const pickle of list) {
1238
1287
  const line = lineOf(report, pickle);
1239
1288
  const scenario = query.findLineageBy(pickle)!.scenario!;
@@ -1242,16 +1291,37 @@ async function compile(scope: Scope, dir: string, files: string[]): Promise<Load
1242
1291
  const error = stepError(built, results.get(step.id), step, whereOf(report, file, step));
1243
1292
  if (error) throw new Error(error);
1244
1293
  }
1245
- const { workflow, ...spec } = specs.get(pickle.id)!;
1294
+ const spec = specs.get(pickle.id)!;
1295
+ const { workflow } = spec;
1246
1296
  const name = names.get(pickle.id)!;
1247
1297
  if (inFile.some((s) => s.name === name)) {
1248
1298
  throw new Error(`${where}: a second scenario named "${name}"; give each its own name`);
1249
1299
  }
1250
- if (workflow === undefined) {
1300
+ const tags = pickle.tags.map((t) => t.name);
1301
+ const moment = momentOf(scope, tags, where);
1302
+ const common = { name, file, line, tags, text: textOf(scenario, report.texts.get(file) ?? "") };
1303
+ if (workflow === undefined && !ofWorlds) {
1251
1304
  throw new Error(
1252
- `${where}: scenario "${name}" has no When: say which workflow runs, such as "When <workflow> runs with"`,
1305
+ `${where}: scenario "${name}" has no When: say which workflow runs, such as "When <workflow> runs with" (a feature with no When in any scenario is of worlds)`,
1253
1306
  );
1254
1307
  }
1308
+ if (workflow === undefined) {
1309
+ // A world: its Givens alone, which nothing runs or checks.
1310
+ const runs = moment
1311
+ ? "is a moment, which starts a run"
1312
+ : spec.expect[0]
1313
+ ? `checks a run ("${spec.expect[0].step}")`
1314
+ : spec.decisions[0]
1315
+ ? `decides an approval ("${spec.decisions[0].step}")`
1316
+ : undefined;
1317
+ if (runs) {
1318
+ throw new Error(
1319
+ `${where}: scenario "${name}" has no When, so it is a world, Givens only, but it ${runs}: say which workflow runs, such as "When <workflow> runs with"`,
1320
+ );
1321
+ }
1322
+ inFile.push({ ...spec, ...common, approvals: [] });
1323
+ continue;
1324
+ }
1255
1325
  // A step about a declared resource reads the report of the built-in it is about.
1256
1326
  for (const e of spec.expect) {
1257
1327
  const reads: string = "declared" in e ? ("status" in e ? DRIFT_WORKFLOW : APPLY_WORKFLOW) : workflow;
@@ -1263,11 +1333,9 @@ async function compile(scope: Scope, dir: string, files: string[]): Promise<Load
1263
1333
  }
1264
1334
  inFile.push({
1265
1335
  ...spec,
1266
- name,
1267
- file,
1268
- line,
1336
+ ...common,
1269
1337
  workflow,
1270
- text: textOf(scenario, report.texts.get(file) ?? ""),
1338
+ ...(moment && { moment }),
1271
1339
  approvals: approvalsOf(scope.workflows.get(workflow)!),
1272
1340
  });
1273
1341
  }
@@ -1358,10 +1426,11 @@ export async function loadScenarios(scope: Scope): Promise<LoadedScenarios> {
1358
1426
  */
1359
1427
  export async function check(scope: Scope, scenario: Scenario, records: readonly LedgerRecord[]): Promise<Check[]> {
1360
1428
  if (!scope.scenarios) throw new Error(`No scenarios directory to check scenario "${scenario.name}" from`);
1429
+ const { file, line, name, workflow } = scenario;
1430
+ if (workflow === undefined) throw new Error(`Scenario "${name}" is a world: it runs nothing to check`);
1361
1431
  const built = builtFor(scope);
1362
- const { file, line, name } = scenario;
1363
1432
  const verdicts = new Map<string, string | undefined>();
1364
- const report = await runFeatures(built.library, checkWorld(built, scenario.workflow, viewOf(records), verdicts), {
1433
+ const report = await runFeatures(built.library, checkWorld(built, workflow, viewOf(records), verdicts), {
1365
1434
  cwd: fileURLToPath(scope.scenarios),
1366
1435
  files: [file],
1367
1436
  line,
package/src/scenarios.ts CHANGED
@@ -6,11 +6,13 @@ import { type Check, check, drive, loadScenarios, type Scenario, type Scope } fr
6
6
  import { errorMessage } from "./shared.ts";
7
7
  import { startWorker } from "./worker.ts";
8
8
 
9
+ export { type DemoState, demoState, reseed } from "./demo.ts";
10
+
9
11
  /*
10
12
  * `@sanoma-ai/workflows/scenarios`: runs a config's scenarios as sandbox runs, with no test
11
13
  * framework. Server-side only, like `./scenario`: it loads Cucumber and starts a worker.
12
14
  * `sanoma check` runs every scenario through `runScenarios`; `@sanoma-ai/testing/scenarios` wraps
13
- * `runScenario` in a vitest test per scenario.
15
+ * `runScenario` in a vitest test per scenario. A demo seeds its instance with `reseed`.
14
16
  */
15
17
 
16
18
  export interface RunScenarioOptions {
@@ -30,12 +32,15 @@ export interface RunScenarioOptions {
30
32
  /**
31
33
  * Runs a scenario as a sandbox run: starts its workflow with its input, as whoever it says is
32
34
  * acting, decides its approvals as it says, and checks the run's ledger against it (`check`:
33
- * Cucumber runs its `Then` steps).
35
+ * Cucumber runs its `Then` steps). Refuses a world, which runs nothing.
34
36
  */
35
37
  export async function runScenario(
36
38
  scenario: Scenario,
37
39
  { client, scope, startedBy = "scenarios", timeoutMs }: RunScenarioOptions,
38
40
  ): Promise<{ runId: string; run: RunSummary; ledger: LedgerRecord[]; checks: Check[] }> {
41
+ if (scenario.workflow === undefined) {
42
+ throw new Error(`Scenario "${scenario.name}" is a world: it seeds a demo's instance, and runs nothing`);
43
+ }
39
44
  const runId = await client.start(scenario.workflow, scenario.input, {
40
45
  startedBy: scenario.startedBy ?? startedBy,
41
46
  sandbox: scenario.name,
@@ -64,6 +69,13 @@ export interface RunScenariosOptions {
64
69
  log?: (line: string) => void;
65
70
  }
66
71
 
72
+ /**
73
+ * True for a scenario that is a test: one that runs a workflow (not a world), and not a moment
74
+ * that decides nothing, which a demo leaves waiting at its first approval.
75
+ */
76
+ export const isTest = (scenario: Scenario): scenario is Scenario & { workflow: string } =>
77
+ scenario.workflow !== undefined && !(scenario.moment && scenario.decisions.length === 0);
78
+
67
79
  /**
68
80
  * Runs every scenario in `scenarios/` in the config's root, one after another, as sandbox runs
69
81
  * on a worker it starts on `databaseUrl` with an in-memory ledger (the config's `ledger` is not
@@ -72,6 +84,7 @@ export interface RunScenariosOptions {
72
84
  * could not be read, with why. A scenario whose run cannot be driven to its end (an approval
73
85
  * with no decision, a run still going after `timeoutMs`) fails with that error at its `When`
74
86
  * step. A config whose root has no `scenarios/`, or none in it, passes nothing and fails nothing.
87
+ * Worlds and moments that decide nothing are no tests (`isTest`): they are left out, each logged as skipped.
75
88
  */
76
89
  export async function runScenarios(
77
90
  config: SanomaConfig,
@@ -79,7 +92,9 @@ export async function runScenarios(
79
92
  ): Promise<{ passed: string[]; failed: ScenarioFailure[] }> {
80
93
  const shared: SanomaConfig = { ...config, databaseUrl, ledger: memoryLedger() };
81
94
  const resolved = resolveConfig(shared);
82
- const { scenarios, errors } = await loadScenarios(resolved);
95
+ const { scenarios: loaded, errors } = await loadScenarios(resolved);
96
+ const scenarios = loaded.filter(isTest);
97
+ for (const s of loaded) if (!isTest(s)) log?.(`skipped ${s.file} › ${s.name}: ${s.moment ? "a moment" : "a world"}`);
83
98
  const failed: ScenarioFailure[] = errors.map(({ file, message }) => ({
84
99
  scenario: file,
85
100
  step: "did not load",
@@ -88,8 +103,9 @@ export async function runScenarios(
88
103
  const passed: string[] = [];
89
104
  if (scenarios.length === 0) return { passed, failed };
90
105
 
91
- // Quiet: the scenarios call the fakes alone, so no vendor need be configured.
92
- const worker = await startWorker(shared, { promote: true, quiet: true });
106
+ // Quiet: the scenarios call the fakes alone, so no vendor need be configured; and no schedule
107
+ // is applied, since a tick's run would be live.
108
+ const worker = await startWorker(shared, { promote: true, quiet: true, schedules: false });
93
109
  try {
94
110
  const client = await SanomaClient.connect(shared);
95
111
  try {