@sanoma-ai/workflows 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. package/README.md +48 -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 +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 +5 -2
  41. package/dist/describe.js +10 -6
  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 +30 -2
  91. package/dist/shared.js +42 -4
  92. package/dist/shared.js.map +1 -1
  93. package/dist/source.d.ts +61 -2
  94. package/dist/source.js.map +1 -1
  95. package/dist/supervisor.d.ts +38 -0
  96. package/dist/supervisor.js +77 -0
  97. package/dist/supervisor.js.map +1 -0
  98. package/dist/version.d.ts +3 -1
  99. package/dist/version.js +3 -1
  100. package/dist/version.js.map +1 -1
  101. package/dist/wakeup-service.d.ts +83 -0
  102. package/dist/wakeup-service.js +421 -0
  103. package/dist/wakeup-service.js.map +1 -0
  104. package/dist/wakeup.d.ts +84 -0
  105. package/dist/wakeup.js +87 -0
  106. package/dist/wakeup.js.map +1 -0
  107. package/dist/worker.d.ts +39 -1
  108. package/dist/worker.js +130 -39
  109. package/dist/worker.js.map +1 -1
  110. package/oxlint.json +2 -1
  111. package/package.json +8 -2
  112. package/src/apply.ts +25 -6
  113. package/src/approvals.ts +89 -16
  114. package/src/call.ts +40 -29
  115. package/src/cedar.ts +24 -6
  116. package/src/change.ts +222 -0
  117. package/src/chats.ts +114 -0
  118. package/src/client.ts +145 -55
  119. package/src/config.ts +110 -13
  120. package/src/database.ts +152 -0
  121. package/src/db.ts +108 -9
  122. package/src/deadlines.ts +234 -0
  123. package/src/define.ts +71 -4
  124. package/src/demo.ts +87 -0
  125. package/src/describe.ts +18 -6
  126. package/src/errors.ts +14 -0
  127. package/src/fake.ts +12 -0
  128. package/src/grid.ts +100 -0
  129. package/src/host.ts +13 -4
  130. package/src/index.ts +61 -3
  131. package/src/ledger.ts +15 -3
  132. package/src/lint.ts +1 -1
  133. package/src/op.ts +15 -2
  134. package/src/policy.ts +7 -1
  135. package/src/resource.ts +22 -3
  136. package/src/resources.ts +12 -1
  137. package/src/run.ts +5 -0
  138. package/src/sandbox.ts +97 -37
  139. package/src/scenario.ts +97 -28
  140. package/src/scenarios.ts +21 -5
  141. package/src/schedules.ts +206 -0
  142. package/src/schema.ts +16 -3
  143. package/src/shared.ts +51 -4
  144. package/src/source.ts +58 -3
  145. package/src/supervisor.ts +107 -0
  146. package/src/version.ts +3 -1
  147. package/src/wakeup-service.ts +511 -0
  148. package/src/wakeup.ts +150 -0
  149. package/src/worker.ts +181 -44
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 {
@@ -0,0 +1,206 @@
1
+ import { DBOS, type ScheduledWorkflowFn, type WorkflowSchedule } from "@dbos-inc/dbos-sdk";
2
+ import { createRequire } from "node:module";
3
+ import { dirname, join } from "node:path";
4
+ import type { ResolvedConfig } from "./config.ts";
5
+ import { type Db, WAKEUP_TABLE } from "./db.ts";
6
+ import type { Principal, WorkflowDefinition } from "./define.ts";
7
+ import { readPeople, admit } from "./resources.ts";
8
+ import type { WorkerState } from "./run.ts";
9
+ import { errorMessage, SCHEDULE_AGENT } from "./shared.ts";
10
+
11
+ // Server-only: the worker's side of `trigger: { schedule, timezone? }`. DBOS fires the schedules
12
+ // (its scheduler, `DBOS.applySchedules`); this names them, keeps them to the config's, fires a
13
+ // tick missed while the instance was stopped, and decides each scheduled start.
14
+
15
+ /** A workflow on a schedule: its cron expression of five fields, and the IANA time zone it is read in. */
16
+ export interface ScheduledWorkflow {
17
+ name: string;
18
+ schedule: string;
19
+ /** UTC when the trigger names none: DBOS would read the cron in the process's own zone. */
20
+ timezone: string;
21
+ }
22
+
23
+ /** The config's workflows on a schedule, in the config's order. */
24
+ export const scheduledWorkflows = ({ workflows }: Pick<ResolvedConfig, "workflows">): ScheduledWorkflow[] =>
25
+ [...workflows.values()].flatMap(({ name, trigger }) =>
26
+ trigger === "manual" ? [] : [{ name, schedule: trigger.schedule, timezone: trigger.timezone ?? "UTC" }],
27
+ );
28
+
29
+ /**
30
+ * A workflow's schedule's name in DBOS: `<appName>/<workflow>`. Names are unique across every app
31
+ * on the system database, so the app's name comes first, and the app's own are those under it.
32
+ */
33
+ const scheduleName = (appName: string, workflow: string): string => `${appName}/${workflow}`;
34
+
35
+ /**
36
+ * Makes DBOS's schedules of the app the config's: one per scheduled workflow (`scheduleName`),
37
+ * firing it by its registered function in `registry` on the app's queue, with DBOS's automatic
38
+ * backfill (from the schedule's last fire); every other schedule under the app's name is deleted.
39
+ * Each keeps, as its context, from when its ticks are due (`since`). Then, when the instance last
40
+ * stopped (`sanoma_wakeup.stopped_at`), it fires once each tick missed since, of a schedule that
41
+ * has never fired, which DBOS's backfill leaves out, never one from before `since`, and moves
42
+ * `since` to now. A repeat is harmless anyway: a tick's run id is its time, so DBOS starts it once.
43
+ * Only the app's latest version calls this (`startWorker`): an older one must not undo a newer's.
44
+ */
45
+ export async function applyScheduled(
46
+ resolved: Pick<ResolvedConfig, "appName" | "queueName" | "workflows">,
47
+ registry: ReadonlyMap<string, ScheduledWorkflowFn>,
48
+ db: () => Promise<Db>,
49
+ ): Promise<void> {
50
+ const { appName, queueName } = resolved;
51
+ const before = new Map(
52
+ (await DBOS.listSchedules({ scheduleNamePrefix: scheduleName(appName, "") })).map((s) => [s.scheduleName, s]),
53
+ );
54
+ const now = new Date();
55
+ const declared = scheduledWorkflows(resolved).map(({ name, schedule, timezone }) => {
56
+ const named = scheduleName(appName, name);
57
+ // A schedule whose cron or zone changed is a new one: its ticks before now were never due.
58
+ const prior = before.get(named);
59
+ const same = prior?.schedule === schedule && (prior.cronTimezone ?? "UTC") === timezone;
60
+ const since = (same && sinceOf(prior)) || now;
61
+ const context: ScheduleContext = { since: since.toISOString() };
62
+ return {
63
+ since,
64
+ schedule: {
65
+ scheduleName: named,
66
+ workflowFn: registry.get(name)!,
67
+ schedule,
68
+ cronTimezone: timezone,
69
+ automaticBackfill: true,
70
+ queueName,
71
+ context,
72
+ },
73
+ };
74
+ });
75
+ if (declared.length) await DBOS.applySchedules(declared.map((d) => d.schedule));
76
+ const names = new Set(declared.map((d) => d.schedule.scheduleName));
77
+ await Promise.all([...before.keys()].filter((name) => !names.has(name)).map((name) => DBOS.deleteSchedule(name)));
78
+ const unfired = declared.filter((d) => before.get(d.schedule.scheduleName)?.lastFiredAt === null);
79
+ if (!unfired.length) return;
80
+ const stoppedAt = await lastStopped(await db());
81
+ if (!stoppedAt) return;
82
+ for (const { schedule, since } of unfired) {
83
+ const from = stoppedAt > since ? stoppedAt : since;
84
+ if (from >= now) continue;
85
+ await DBOS.backfillSchedule(schedule.scheduleName, from, now);
86
+ // Fired up to now: the next start backfills from here, not over the same ticks again.
87
+ const covered: ScheduleContext = { since: now.toISOString() };
88
+ await DBOS.updateSchedule(schedule.scheduleName, { context: covered });
89
+ }
90
+ }
91
+
92
+ /**
93
+ * What a schedule keeps in DBOS's `context`: from when its ticks are the runtime's to fire, ISO
94
+ * 8601: when it was applied with its cron and zone, or the end of its last backfill.
95
+ */
96
+ interface ScheduleContext {
97
+ since: string;
98
+ }
99
+
100
+ /** When the schedule was first applied, from its context; undefined for none, or one without it. */
101
+ function sinceOf(schedule: WorkflowSchedule | undefined): Date | undefined {
102
+ const since = (schedule?.context as Partial<ScheduleContext> | null | undefined)?.since;
103
+ const at = typeof since === "string" ? new Date(since) : undefined;
104
+ return at && !Number.isNaN(at.getTime()) ? at : undefined;
105
+ }
106
+
107
+ /** When the instance's worker last stopped, from the wakeup's row; undefined while it never has. */
108
+ async function lastStopped(db: Db): Promise<Date | undefined> {
109
+ const { rows } = await db.query<{ stopped_at: Date | null }>(`SELECT stopped_at FROM ${WAKEUP_TABLE} WHERE id = 1`);
110
+ return rows[0]?.stopped_at ?? undefined;
111
+ }
112
+
113
+ /** Who starts a scheduled run, as `scheduledStarter` admitted them, or why they were not. */
114
+ export interface ScheduledStarter {
115
+ startedBy: Principal;
116
+ refused?: string;
117
+ }
118
+
119
+ /**
120
+ * Who starts a scheduled run: the runtime's `schedule` agent as the data files declare it
121
+ * (`admit`), in a step, since the files may change before a replay. A refusal is the step's
122
+ * answer, for the run to throw once it has started, so the ledger records it; the run starts as
123
+ * the agent alone then. A failure of the step itself (the worker stopping) is thrown as it is.
124
+ */
125
+ export async function scheduledStarter(state: Pick<WorkerState, "root">): Promise<ScheduledStarter> {
126
+ const admitted = await DBOS.runStep(
127
+ async (): Promise<{ startedBy: Principal } | { refused: string }> => {
128
+ try {
129
+ return { startedBy: admit({ id: SCHEDULE_AGENT }, readPeople(state.root)) };
130
+ } catch (err) {
131
+ return { refused: errorMessage(err) };
132
+ }
133
+ },
134
+ { name: "schedule:starter" },
135
+ );
136
+ return "refused" in admitted ? { startedBy: { id: SCHEDULE_AGENT }, refused: admitted.refused } : admitted;
137
+ }
138
+
139
+ /** DBOS's cron parser (`TimeMatcher`), as its scheduler and backfill use it. */
140
+ interface TimeMatcher {
141
+ match(date: Date | number): boolean;
142
+ /** The first instant strictly after `date` that matches; one that does not at the end of its search horizon. */
143
+ nextWakeupTime(date: Date | number): Date;
144
+ }
145
+ type TimeMatcherClass = new (pattern: string, timezone?: string) => TimeMatcher;
146
+
147
+ let matcherClass: TimeMatcherClass | undefined;
148
+
149
+ /**
150
+ * DBOS's `TimeMatcher`, from `dist/src/scheduler/crontab.js` beside its main module: the package's
151
+ * exports leave it out (dbos-sdk 5.2.11), so it is loaded by path, and a test holds it there.
152
+ */
153
+ export function timeMatcher(): TimeMatcherClass {
154
+ if (matcherClass) return matcherClass;
155
+ const require = createRequire(import.meta.url);
156
+ const file = join(dirname(require.resolve("@dbos-inc/dbos-sdk")), "scheduler", "crontab.js");
157
+ const loaded = (require(file) as { TimeMatcher?: unknown }).TimeMatcher;
158
+ if (typeof loaded !== "function") throw new Error(`DBOS's cron parser is not in ${file} any more`);
159
+ return (matcherClass = loaded as TimeMatcherClass);
160
+ }
161
+
162
+ /**
163
+ * When `schedule` (a cron expression read in `timezone`, UTC without one) fires next, strictly
164
+ * after `from`: as DBOS's scheduler finds it. Throws for a schedule that never fires.
165
+ */
166
+ export function nextFire(schedule: string, timezone: string | undefined, from: Date): Date {
167
+ const matcher = new (timeMatcher())(schedule, timezone ?? "UTC");
168
+ // Past a horizon of a century DBOS answers an instant that does not match, as its scheduler
169
+ // would wait for it and look again: a few looks find any schedule that ever fires.
170
+ let at = from.getTime();
171
+ for (let look = 0; look < 4; look++) {
172
+ const next = matcher.nextWakeupTime(at);
173
+ if (matcher.match(next)) return next;
174
+ at = next.getTime();
175
+ }
176
+ throw new Error("it never fires");
177
+ }
178
+
179
+ /**
180
+ * Throws when DBOS could not run the workflow's schedule: a cron its parser refuses (a minute of
181
+ * 61), or one that never fires (30 February). `resolveConfig` runs it, so the worker, the app and
182
+ * `sanoma check` refuse the config before any schedule is applied.
183
+ */
184
+ export function checkSchedule({ name, trigger }: Pick<WorkflowDefinition, "name" | "trigger">): void {
185
+ if (trigger === "manual") return;
186
+ try {
187
+ nextFire(trigger.schedule, trigger.timezone, new Date());
188
+ } catch (err) {
189
+ throw new Error(`Workflow "${name}": DBOS cannot run its schedule "${trigger.schedule}": ${errorMessage(err)}`, {
190
+ cause: err,
191
+ });
192
+ }
193
+ }
194
+
195
+ /**
196
+ * The earliest next fire, strictly after `from`, of the config's scheduled workflows; undefined
197
+ * when none is on a schedule: the deadline a schedule sets for a worker that stops when quiet.
198
+ */
199
+ export function nextScheduledFire(resolved: Pick<ResolvedConfig, "workflows">, from: Date): Date | undefined {
200
+ let earliest: Date | undefined;
201
+ for (const { schedule, timezone } of scheduledWorkflows(resolved)) {
202
+ const next = nextFire(schedule, timezone, from);
203
+ if (!earliest || next < earliest) earliest = next;
204
+ }
205
+ return earliest;
206
+ }
package/src/schema.ts CHANGED
@@ -8,11 +8,12 @@ import type {
8
8
  Type,
9
9
  TypeOfAttribute,
10
10
  } from "@cedar-policy/cedar-wasm/nodejs";
11
+ import { ChangeFacts } from "./change.ts";
11
12
  import { recordSchemaOf } from "./collection.ts";
12
13
  import type { ResolvedConfig } from "./config.ts";
13
14
  import { jsonSchemaOf } from "./define.ts";
14
15
  import { isObject } from "./resource.ts";
15
- import { DATA_VENDOR, EFFECTS, groupNamesOf, type PeopleResource, WORKFLOW_ACTION } from "./shared.ts";
16
+ import { CHANGE_MERGE, DATA_VENDOR, EFFECTS, groupNamesOf, type PeopleResource, WORKFLOW_ACTION } from "./shared.ts";
16
17
 
17
18
  // The Cedar schema a company's policies are validated and decided against: made from the
18
19
  // resolved config and the people, in memory for the evaluator, and written as text to
@@ -36,6 +37,9 @@ export const NAMESPACE = "Sanoma";
36
37
  /** The action group every workflow start (`workflow:<name>`) is in. */
37
38
  export const START = "start";
38
39
 
40
+ /** The entity type a change's merge acts on (`Sanoma::Change`), its attributes the change's facts. */
41
+ export const CHANGE = "Change";
42
+
39
43
  const STRING = { type: "EntityOrCommon", name: "String" } as const;
40
44
  const LONG = { type: "EntityOrCommon", name: "Long" } as const;
41
45
  const BOOL = { type: "EntityOrCommon", name: "Bool" } as const;
@@ -72,7 +76,8 @@ const WRITES = new Set(["save", "delete"]);
72
76
  * `Target in [Collection]`; `Workflow`;
73
77
  * - an action per operation id, in its effect's group (`effect:money`), on a `Target`, with
74
78
  * `context` `{ input, before? }`: `input` from the operation's input schema, `before` the record
75
- * as it was for a collection's `save` and `delete`;
79
+ * as it was for a collection's `save` and `delete`; `sanoma.change.merge` on a `Change`, whose
80
+ * attributes are a change's facts (`ChangeFacts`), so a rule reads `resource.touches`;
76
81
  * - `workflow:<name>` per workflow, in the group `start`, on a `Workflow`, with its `input`.
77
82
  *
78
83
  * Zod to Cedar, through the JSON Schema of the parsed value: string and enum `String`, integer `Long`, boolean `Bool`,
@@ -97,7 +102,10 @@ function buildSchema(
97
102
  const before = collection && typeOf(recordSchemaOf(collection));
98
103
  actions[op.id] = {
99
104
  memberOf: [{ id: `effect:${op.effect}` }],
100
- appliesTo: applies("Target", { ...input(op.input), ...(before && { before: optional(before) }) }),
105
+ appliesTo: applies(op.id === CHANGE_MERGE ? CHANGE : "Target", {
106
+ ...input(op.input),
107
+ ...(before && { before: optional(before) }),
108
+ }),
101
109
  };
102
110
  }
103
111
  for (const name of [...resolved.workflows.keys()].toSorted()) {
@@ -112,6 +120,7 @@ function buildSchema(
112
120
  Collection: {},
113
121
  Target: { memberOfTypes: ["Collection"] },
114
122
  Workflow: {},
123
+ [CHANGE]: { shape: typeOf(ChangeFacts) as RecordType<string> & { type: "Record" } },
115
124
  };
116
125
  return { [NAMESPACE]: { entityTypes, actions } };
117
126
  }
@@ -221,6 +230,10 @@ const allowsNull = (schema: unknown): boolean =>
221
230
  /** Cedar's message, with its help when it has one. */
222
231
  export const messageOf = (e: DetailedError) => (e.help ? `${e.message} (${e.help})` : e.message);
223
232
 
233
+ /** The attributes of an entity type in the schema, when it has any (`Change`'s). */
234
+ export const shapeOf = (schema: CedarSchemaJson, type: string) =>
235
+ (schema[NAMESPACE]?.entityTypes[type] as { shape?: RecordType<string> & { type: "Record" } } | undefined)?.shape;
236
+
224
237
  /** An action's context type in the schema: a record, when the action is there. */
225
238
  export const contextTypeOf = (schema: CedarSchemaJson, action: string) =>
226
239
  schema[NAMESPACE]?.actions[action]?.appliesTo?.context as (RecordType<string> & { type: "Record" }) | undefined;
package/src/shared.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  import type { RunSummary } from "./client.ts";
2
2
  import type { CredentialStatus } from "./env.ts";
3
3
  import type { ApprovalState, Approver, Principal } from "./define.ts";
4
+ import type { OpEntry, WorkflowEntry } from "./describe.ts";
4
5
  import type { LedgerRecord } from "./ledger.ts";
5
6
  import type { Effect } from "./op.ts";
7
+ import type { PreviewRequest } from "./policy.ts";
6
8
  import type { DeclaredResource, ResourceProblem } from "./resources.ts";
7
9
  import type { OutlineNode } from "./outline.ts";
8
10
 
@@ -20,6 +22,7 @@ export type { DeclaredResource, ResourceProblem } from "./resources.ts";
20
22
  export type { DriftField, DriftReport, DriftResult, DriftStatus } from "./drift.ts";
21
23
  export type { ApplyPlan, ApplyReport, ApplyResult, PlanAnswer, PlanStep } from "./apply.ts";
22
24
  export type { CredentialStatus } from "./env.ts";
25
+ export type { ChangeEvent, ChangeFacts, ChangeReport, ChangeState } from "./change.ts";
23
26
 
24
27
  // What a UI in the browser may use from the runtime, besides types: `@sanoma-ai/workflows/shared`.
25
28
  // Nothing here imports DBOS, Node or zod, so a browser bundle can carry it; the runtime uses the
@@ -52,6 +55,24 @@ export const EFFECTS = ["read", "write", "publish", "send", "money", "access"] a
52
55
  /** What a workflow start's action id starts with, as a policy names it: `workflow:<name>`. */
53
56
  export const WORKFLOW_ACTION = "workflow:";
54
57
 
58
+ /**
59
+ * What to ask the policy ahead of a run of `workflow` (`Policy.preview`, the principal aside): its
60
+ * start, then each operation it may call, with its effect, in the run (`inRun`). The run form's
61
+ * will-ask line asks it for the person; the Permissions grid (`permissionsGrid`) for each group.
62
+ */
63
+ export function previewRequests(
64
+ workflow: Pick<WorkflowEntry, "name" | "ops">,
65
+ ops: ReadonlyMap<string, Pick<OpEntry, "effect">>,
66
+ ): Omit<PreviewRequest, "principal">[] {
67
+ return [
68
+ { action: { id: `${WORKFLOW_ACTION}${workflow.name}` } },
69
+ ...workflow.ops.map((id) => {
70
+ const effect = ops.get(id)?.effect;
71
+ return { action: effect === undefined ? { id } : { id, effect }, inRun: true };
72
+ }),
73
+ ];
74
+ }
75
+
55
76
  /** A site's folder name under `sites/`, its URL segment: what the app mounts and `readSites` reads. */
56
77
  export const SITE_NAME = /^[a-z][a-z0-9-]*$/;
57
78
 
@@ -184,8 +205,30 @@ function checkField(where: string, field: string, kind: unknown): void {
184
205
  */
185
206
  export const CALL_WORKFLOW = "call";
186
207
 
187
- /** The workflows the runtime adds itself, which a config's own may not be named: drift, apply and call. */
188
- export const BUILT_IN_WORKFLOWS: ReadonlySet<string> = new Set([DRIFT_WORKFLOW, APPLY_WORKFLOW, CALL_WORKFLOW]);
208
+ /**
209
+ * The built-in change workflow's name: the life of a change to the company's repository, from its
210
+ * pull request to its merge. The only workflow that may call `sanoma.change.*`.
211
+ */
212
+ export const CHANGE_WORKFLOW = "change";
213
+
214
+ /** Opens a change: its draft's branch pushed and a pull request opened for it. */
215
+ export const CHANGE_OPEN = "sanoma.change.open";
216
+ /** Reports Sanoma's review of a change to its host (the `sanoma` commit status). */
217
+ export const CHANGE_REVIEW = "sanoma.change.review";
218
+ /** Merges a change; its resource is the `Sanoma::Change`, whose attributes are its facts (`ChangeFacts`). */
219
+ export const CHANGE_MERGE = "sanoma.change.merge";
220
+ /** Closes a change without merging it. */
221
+ export const CHANGE_CLOSE = "sanoma.change.close";
222
+ /** The DBOS event a change run publishes its `ChangeState` on. */
223
+ export const CHANGE_EVENT = "change";
224
+
225
+ /** The workflows the runtime adds itself, which a config's own may not be named: drift, apply, call and change. */
226
+ export const BUILT_IN_WORKFLOWS: ReadonlySet<string> = new Set([
227
+ DRIFT_WORKFLOW,
228
+ APPLY_WORKFLOW,
229
+ CALL_WORKFLOW,
230
+ CHANGE_WORKFLOW,
231
+ ]);
189
232
 
190
233
  /**
191
234
  * Every run status, in the order a run may pass through them: waiting on the queue, running,
@@ -277,8 +320,12 @@ export function visitorPrincipal(login: string, subject: string): Principal {
277
320
  /** Who a public page runs as when nobody is signed in. */
278
321
  export const ANONYMOUS_PRINCIPAL: Principal = Object.freeze({ id: ANONYMOUS, groups: [] });
279
322
 
280
- /** The runtime's own agents (scenarios, the host check, scheduled runs), declared whether a data file names them or not. */
281
- export const RUNTIME_AGENTS: readonly string[] = ["scenarios", "host-check", "schedule"];
323
+ /** The runtime's agent that registers the instance's wakeup (`register`'s `CallContext.by`). */
324
+ export const WAKEUP_AGENT = "wakeup";
325
+ /** The runtime's agent a scheduled run is started by (`trigger: { schedule }`): the policy decides its starts. */
326
+ export const SCHEDULE_AGENT = "schedule";
327
+ /** The runtime's own agents (scenarios, the host check, scheduled runs, the wakeup's registration), declared whether a data file names them or not. */
328
+ export const RUNTIME_AGENTS: readonly string[] = ["scenarios", "host-check", SCHEDULE_AGENT, WAKEUP_AGENT];
282
329
 
283
330
  /** The declared resources of one of the runtime's people types, by `desired`. */
284
331
  const peopleOfType = <T>(resources: readonly PeopleResource[], type: "person" | "agent" | "group") =>