@sanoma-ai/workflows 0.7.0 → 0.8.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 (168) hide show
  1. package/README.md +43 -12
  2. package/dist/apps-list.d.ts +90 -0
  3. package/dist/apps-list.js +109 -0
  4. package/dist/apps-list.js.map +1 -0
  5. package/dist/apps.d.ts +42 -0
  6. package/dist/apps.js +137 -0
  7. package/dist/apps.js.map +1 -0
  8. package/dist/call-workflow.js +5 -6
  9. package/dist/call-workflow.js.map +1 -1
  10. package/dist/call.js +6 -3
  11. package/dist/call.js.map +1 -1
  12. package/dist/cedar.d.ts +1 -1
  13. package/dist/cedar.js +6 -4
  14. package/dist/cedar.js.map +1 -1
  15. package/dist/change-steps.d.ts +10 -0
  16. package/dist/change-steps.js +204 -0
  17. package/dist/change-steps.js.map +1 -0
  18. package/dist/change-workflow.d.ts +54 -0
  19. package/dist/change-workflow.js +161 -0
  20. package/dist/change-workflow.js.map +1 -0
  21. package/dist/change.d.ts +121 -24
  22. package/dist/change.js +196 -17
  23. package/dist/change.js.map +1 -1
  24. package/dist/claims.d.ts +58 -0
  25. package/dist/claims.js +27 -0
  26. package/dist/claims.js.map +1 -0
  27. package/dist/client.d.ts +92 -0
  28. package/dist/client.js +268 -6
  29. package/dist/client.js.map +1 -1
  30. package/dist/collection.d.ts +6 -1
  31. package/dist/collection.js +11 -3
  32. package/dist/collection.js.map +1 -1
  33. package/dist/config.d.ts +56 -8
  34. package/dist/config.js +71 -29
  35. package/dist/config.js.map +1 -1
  36. package/dist/constructors.d.ts +37 -1
  37. package/dist/constructors.js +6 -2
  38. package/dist/constructors.js.map +1 -1
  39. package/dist/db.d.ts +21 -21
  40. package/dist/db.js +31 -54
  41. package/dist/db.js.map +1 -1
  42. package/dist/deadlines.d.ts +18 -12
  43. package/dist/deadlines.js +18 -24
  44. package/dist/deadlines.js.map +1 -1
  45. package/dist/define.d.ts +30 -14
  46. package/dist/define.js +9 -1
  47. package/dist/define.js.map +1 -1
  48. package/dist/demo.d.ts +15 -4
  49. package/dist/demo.js +27 -12
  50. package/dist/demo.js.map +1 -1
  51. package/dist/describe.d.ts +16 -2
  52. package/dist/describe.js +39 -4
  53. package/dist/describe.js.map +1 -1
  54. package/dist/direct.d.ts +4 -3
  55. package/dist/direct.js +7 -4
  56. package/dist/direct.js.map +1 -1
  57. package/dist/errors.d.ts +2 -0
  58. package/dist/errors.js +2 -0
  59. package/dist/errors.js.map +1 -1
  60. package/dist/fake.d.ts +26 -0
  61. package/dist/fake.js +123 -0
  62. package/dist/fake.js.map +1 -1
  63. package/dist/host.d.ts +2 -3
  64. package/dist/host.js +1 -1
  65. package/dist/host.js.map +1 -1
  66. package/dist/index.d.ts +8 -7
  67. package/dist/index.js +7 -6
  68. package/dist/index.js.map +1 -1
  69. package/dist/lint.js +20 -6
  70. package/dist/lint.js.map +1 -1
  71. package/dist/op.d.ts +8 -1
  72. package/dist/op.js.map +1 -1
  73. package/dist/outline.js +13 -6
  74. package/dist/outline.js.map +1 -1
  75. package/dist/paths.d.ts +16 -0
  76. package/dist/paths.js +54 -2
  77. package/dist/paths.js.map +1 -1
  78. package/dist/person.d.ts +5 -0
  79. package/dist/person.js +3 -1
  80. package/dist/person.js.map +1 -1
  81. package/dist/resources.d.ts +7 -2
  82. package/dist/resources.js +27 -10
  83. package/dist/resources.js.map +1 -1
  84. package/dist/run.d.ts +7 -0
  85. package/dist/run.js.map +1 -1
  86. package/dist/scenario.d.ts +2 -2
  87. package/dist/scenario.js +10 -4
  88. package/dist/scenario.js.map +1 -1
  89. package/dist/scenarios.d.ts +1 -1
  90. package/dist/scenarios.js.map +1 -1
  91. package/dist/sdk.d.ts +1 -1
  92. package/dist/sdk.js +19 -5
  93. package/dist/sdk.js.map +1 -1
  94. package/dist/shared.d.ts +43 -2
  95. package/dist/shared.js +46 -4
  96. package/dist/shared.js.map +1 -1
  97. package/dist/sites.d.ts +26 -5
  98. package/dist/sites.js +56 -20
  99. package/dist/sites.js.map +1 -1
  100. package/dist/source.d.ts +179 -5
  101. package/dist/source.js +13 -0
  102. package/dist/source.js.map +1 -1
  103. package/dist/supervisor.d.ts +5 -4
  104. package/dist/supervisor.js +2 -11
  105. package/dist/supervisor.js.map +1 -1
  106. package/dist/version.d.ts +8 -0
  107. package/dist/version.js +21 -13
  108. package/dist/version.js.map +1 -1
  109. package/dist/wakeup-driver.d.ts +128 -0
  110. package/dist/wakeup-driver.js +216 -0
  111. package/dist/wakeup-driver.js.map +1 -0
  112. package/dist/wakeup-service.d.ts +4 -4
  113. package/dist/wakeup-service.js +19 -25
  114. package/dist/wakeup-service.js.map +1 -1
  115. package/dist/wakeup-type.d.ts +43 -0
  116. package/dist/wakeup-type.js +80 -0
  117. package/dist/wakeup-type.js.map +1 -0
  118. package/dist/wakeup.d.ts +1 -8
  119. package/dist/wakeup.js +2 -11
  120. package/dist/wakeup.js.map +1 -1
  121. package/dist/worker.d.ts +2 -2
  122. package/dist/worker.js +50 -7
  123. package/dist/worker.js.map +1 -1
  124. package/oxlint.json +3 -2
  125. package/package.json +3 -1
  126. package/src/apps-list.ts +119 -0
  127. package/src/apps.ts +162 -0
  128. package/src/call-workflow.ts +10 -2
  129. package/src/call.ts +6 -3
  130. package/src/cedar.ts +9 -7
  131. package/src/change-steps.ts +274 -0
  132. package/src/change-workflow.ts +198 -0
  133. package/src/change.ts +257 -27
  134. package/src/claims.ts +79 -0
  135. package/src/client.ts +320 -4
  136. package/src/collection.ts +22 -3
  137. package/src/config.ts +104 -35
  138. package/src/constructors.ts +8 -2
  139. package/src/db.ts +39 -66
  140. package/src/deadlines.ts +27 -30
  141. package/src/define.ts +31 -12
  142. package/src/demo.ts +55 -24
  143. package/src/describe.ts +67 -4
  144. package/src/direct.ts +9 -5
  145. package/src/errors.ts +3 -0
  146. package/src/fake.ts +137 -0
  147. package/src/host.ts +3 -4
  148. package/src/index.ts +22 -3
  149. package/src/lint.ts +21 -6
  150. package/src/op.ts +8 -1
  151. package/src/outline.ts +15 -6
  152. package/src/paths.ts +57 -2
  153. package/src/person.ts +4 -1
  154. package/src/resources.ts +31 -11
  155. package/src/run.ts +5 -0
  156. package/src/scenario.ts +14 -4
  157. package/src/scenarios.ts +1 -1
  158. package/src/sdk.ts +21 -7
  159. package/src/shared.ts +60 -5
  160. package/src/sites.ts +70 -24
  161. package/src/source.ts +168 -5
  162. package/src/supervisor.ts +7 -12
  163. package/src/version.ts +25 -16
  164. package/src/wakeup-driver.ts +270 -0
  165. package/src/wakeup-service.ts +21 -28
  166. package/src/wakeup-type.ts +90 -0
  167. package/src/wakeup.ts +6 -17
  168. package/src/worker.ts +48 -9
package/src/config.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync, statSync } from "node:fs";
1
+ import { existsSync, readFileSync } from "node:fs";
2
2
  import { dirname, join, resolve } from "node:path";
3
3
  import { pathToFileURL } from "node:url";
4
4
  import { DBOS } from "@dbos-inc/dbos-sdk";
@@ -6,6 +6,8 @@ import { type ApplyDeclared, applyWorkflow } from "./apply.ts";
6
6
  import { applySteps } from "./apply-steps.ts";
7
7
  import { callWorkflow } from "./call-workflow.ts";
8
8
  import { changeDriver, changeOps, reservedFor } from "./change.ts";
9
+ import { changeSteps } from "./change-steps.ts";
10
+ import { changeWorkflow } from "./change-workflow.ts";
9
11
  import {
10
12
  type Collection,
11
13
  collectionsConnector,
@@ -14,10 +16,11 @@ import {
14
16
  isCollection,
15
17
  } from "./collection.ts";
16
18
  import { lazyPool, type LazyPool } from "./db.ts";
19
+ import { isDirectory, kindDirs, SCENARIOS_DIR } from "./paths.ts";
17
20
  import { callerFile, type Use, type WorkflowDefinition } from "./define.ts";
18
21
  import { DRIFT_WORKFLOW, driftWorkflow } from "./drift.ts";
19
22
  import { statusesOf, unconfigured } from "./env.ts";
20
- import type { Fake } from "./fake.ts";
23
+ import { type Fake, fakeSanomaWakeup } from "./fake.ts";
21
24
  import { peopleConnector } from "./person.ts";
22
25
  import type { LedgerStore } from "./ledger.ts";
23
26
  import {
@@ -32,17 +35,21 @@ import {
32
35
  checkConnector,
33
36
  setupOf,
34
37
  VENDOR,
38
+ withSetup,
35
39
  } from "./op.ts";
36
40
  import { type CedarPolicySource, policyAt } from "./cedar.ts";
37
41
  import type { Policy } from "./policy.ts";
38
42
  import { type Resource, resourceTypesOf, withoutWriteOnly } from "./resource.ts";
39
- import { APPLY_WORKFLOW, BUILT_IN_WORKFLOWS, CALL_WORKFLOW, DATA_VENDOR } from "./shared.ts";
40
- import { sourceDriver, sourceOps } from "./source.ts";
43
+ import { APPLY_WORKFLOW, BUILT_IN_WORKFLOWS, CALL_WORKFLOW, CHANGE_WORKFLOW, DATA_VENDOR } from "./shared.ts";
44
+ import { type RemoteHalf, sourceDriver, sourceOps } from "./source.ts";
45
+ import { appsListDriver, appsListOps } from "./apps-list.ts";
41
46
  import type { Steps } from "./steps.ts";
42
47
  import { checkSchedule } from "./schedules.ts";
43
- import { computeVersion } from "./version.ts";
48
+ import { changeCodeOf, computeVersion } from "./version.ts";
44
49
  import type { DeclaredResource } from "./resources.ts";
45
50
  import { checkWakeupOps, type DeclaredWakeup } from "./wakeup.ts";
51
+ import { sanomaWakeupDriver, wakeupSteps } from "./wakeup-driver.ts";
52
+ import { wakeupOps } from "./wakeup-type.ts";
46
53
  import { checkDatabaseOps } from "./database.ts";
47
54
 
48
55
  /**
@@ -53,7 +60,11 @@ import { checkDatabaseOps } from "./database.ts";
53
60
  * policies are `scenarios/`, `resources/` and `policies/` in `root`.
54
61
  */
55
62
  export interface SanomaConfig {
56
- workflows: WorkflowDefinition<any, any>[];
63
+ /**
64
+ * Workflows besides those the launcher finds (`sanoma` imports every file of `workflows/` and
65
+ * each app's `apps/<app>/workflows/`): listing one it finds as well is no second one.
66
+ */
67
+ workflows?: WorkflowDefinition<any, any>[];
57
68
  /**
58
69
  * The connectors (from `defineConnector`, as their packages export them). The worker takes
59
70
  * each operation's effect, schemas and retry setting from here, not from the workflow, and
@@ -103,6 +114,12 @@ export interface SanomaConfig {
103
114
  * that sets it and declares a wakeup of its own.
104
115
  */
105
116
  wakeup?: true;
117
+ /**
118
+ * What the app says when it runs this repo as a demo (`sanoma demo`): `start`, the URL its
119
+ * banner's "Start with these apps" opens, such as the hosted wizard with the demo's apps
120
+ * chosen; without one, the banner has no such link.
121
+ */
122
+ demo?: { start?: string };
106
123
  }
107
124
 
108
125
  /** A config, checked, with everything a worker, client or app derives from it. */
@@ -115,12 +132,14 @@ export interface ResolvedConfig {
115
132
  databaseUrl: string;
116
133
  /** The DBOS application version: `<appName>@<DBOS__APPVERSION, or a hash of the workflows>`. */
117
134
  version: string;
135
+ /** The hash of the built-in change workflow's own code (`changeCodeOf`), which each change run carries. */
136
+ changeCode: string;
118
137
  /** The DBOS queue runs are started on: `sanoma:<appName>`. */
119
138
  queueName: string;
120
139
  /**
121
140
  * The operations the connectors declare, by id, the built-in `sanoma.change.*` (`changeOps`, in
122
- * every config), `sanoma.source.resolve` when one declares a host type, and the collections'
123
- * (`data.<name>.<op>`).
141
+ * every config), `sanoma.source.resolve` and `sanoma.wakeup`'s when one declares a host type,
142
+ * and the collections' (`data.<name>.<op>`).
124
143
  */
125
144
  ops: Map<string, Op>;
126
145
  /** The drivers' functions, by operation id, each guarded by its driver's `env` (`indexDrivers`). */
@@ -146,9 +165,20 @@ export interface ResolvedConfig {
146
165
  logins: ReadonlyMap<string, NonNullable<ConnectorSetup["login"]>>;
147
166
  /** The makers of the model halves the connectors attach, by vendor id in `connectors` order: the app makes each once for its model registry. */
148
167
  models: ReadonlyMap<string, NonNullable<ConnectorSetup["model"]>>;
149
- /** `scenarios/` in `root`, as a `file:` URL, when that directory exists. */
168
+ /** The remote halves the connectors attach, made once for this resolution, by vendor id in `connectors` order: `remoteFor` finds a URL's. */
169
+ remotes: ReadonlyMap<string, RemoteHalf>;
170
+ /**
171
+ * The resources the data files under `root` declare whose type is a host (`<file>#<export>`),
172
+ * read at each call: what a change's deploy and a push to `main` apply. None when no connector
173
+ * declares a host type.
174
+ */
175
+ hosts(): Promise<string[]>;
176
+ /**
177
+ * `root`, as a `file:` URL, when `scenarios/` or an app's `apps/<app>/scenarios/` in it is a
178
+ * directory: the feature files under those, each named by its path from the root.
179
+ */
150
180
  scenarios?: URL;
151
- /** The resource types the connectors declare, by `<vendor>.<type>`: what the drift workflow checks and scenarios seed. */
181
+ /** The resource types the connectors declare (and `sanoma.wakeup`, beside a host type), by `<vendor>.<type>`: what the drift workflow checks and scenarios seed. */
152
182
  types: ReadonlyMap<string, Resource>;
153
183
  /** The steps scenarios can use besides the generic ones: each connector's (`withSetup`), once each, then the config's own. */
154
184
  steps: readonly Steps[];
@@ -253,8 +283,8 @@ export function resolveConfig(config: SanomaConfig, options: ResolveOptions = {}
253
283
  throw new Error("The config needs `connectors`: the connectors its workflows call, as their packages export them");
254
284
  }
255
285
  for (const connector of config.connectors as unknown[]) checkConnector(connector);
256
- if (!Array.isArray(config.workflows)) {
257
- throw new Error("The config needs `workflows`: an array of defineWorkflow definitions");
286
+ if (config.workflows !== undefined && !Array.isArray(config.workflows)) {
287
+ throw new Error("The config's `workflows`, when given, is an array of defineWorkflow definitions");
258
288
  }
259
289
  // An untyped config naming fake drivers would otherwise run live. Delete once sanoma-ops has migrated.
260
290
  if (config.drivers !== undefined || config.fakes !== undefined)
@@ -270,39 +300,45 @@ export function resolveConfig(config: SanomaConfig, options: ResolveOptions = {}
270
300
  const root = rootOf(config);
271
301
  const appName = appNameAt(root);
272
302
  const scenarios = scenariosAt(root);
273
- const types = resourceTypesOf(config.connectors);
303
+ // With the built-in `sanoma.wakeup` when they declare a host type.
304
+ const running = withBuiltIns(config.connectors);
305
+ const types = resourceTypesOf(running);
274
306
  // Built-in: `sanoma.source.resolve`, which apply and drift call for a host's revision.
275
- const hosts = [...types.values()].some((type) => type.host);
307
+ const hostTyped = [...types.values()].some((type) => type.host);
276
308
  // Built-in: the collections' operations, `data.<name>.<op>`, on the config's database.
277
309
  const databaseUrl = resolveDatabaseUrl(config);
278
310
  const declared = [...collections.values()];
279
311
  const dataStore = declared.length ? lazyPool(databaseUrl, "data") : undefined;
280
- // Built-in, in every config: a change's operations, which the template's policy names (`sanoma.change.merge`).
312
+ // Built-in, in every config: a change's operations, which the template's policy names
313
+ // (`sanoma.change.merge`), and the listing of apps a page may call (`sanoma.apps.list`).
281
314
  const ops = indexConnectors([
282
- ...config.connectors,
315
+ ...running,
283
316
  changeOps,
284
- ...(hosts ? [sourceOps] : []),
317
+ appsListOps,
318
+ ...(hostTyped ? [sourceOps] : []),
285
319
  ...(dataStore ? [collectionsConnector(declared)] : []),
286
320
  ]);
287
321
  checkWakeupOps(types, ops);
288
322
  checkDatabaseOps(types, ops);
289
- const fakes = makersOf(config.connectors, "fake");
290
- const instanceFakes = options.fakes === "instance" ? instanceFakesOf(config.connectors, fakes) : undefined;
323
+ const fakes = makersOf(running, "fake");
324
+ const instanceFakes = options.fakes === "instance" ? instanceFakesOf(running, fakes) : undefined;
291
325
  const driversByVendor = instanceFakes
292
326
  ? new Map([...instanceFakes].map(([vendor, fake]) => [vendor, fake.driver]))
293
- : new Map([...makersOf(config.connectors, "driver")].map(([vendor, make]) => [vendor, make()]));
327
+ : new Map([...makersOf(running, "driver")].map(([vendor, make]) => [vendor, make()]));
328
+ const remotes = new Map([...makersOf(config.connectors, "remote")].map(([vendor, make]) => [vendor, make()]));
294
329
  const drivers = indexDrivers(
295
330
  [
296
331
  ...driversByVendor.values(),
297
- changeDriver(),
298
- ...(hosts ? [sourceDriver] : []),
332
+ changeDriver(remotes),
333
+ appsListDriver,
334
+ ...(hostTyped ? [sourceDriver] : []),
299
335
  ...(dataStore ? [collectionsDriver(declared, dataStore)] : []),
300
336
  ],
301
337
  ops,
302
338
  );
303
339
  if (dataStore) fakes.set(DATA_VENDOR, () => collectionsFake(declared));
304
340
  const names = new Map<string, WorkflowDefinition<any, any>>();
305
- for (const wf of config.workflows) {
341
+ for (const wf of config.workflows ?? []) {
306
342
  if (BUILT_IN_WORKFLOWS.has(wf.name)) {
307
343
  throw new Error(`A workflow is named "${wf.name}", the name of a built-in workflow; name it otherwise`);
308
344
  }
@@ -317,17 +353,21 @@ export function resolveConfig(config: SanomaConfig, options: ResolveOptions = {}
317
353
  names.set(wf.name, wf);
318
354
  }
319
355
  // Built-in: checking the resources the data files declare against their vendors, and applying them.
356
+ const connectors = connectorsOf(config);
357
+ // The types the data files may declare: the vendors' and the people's, whose resources are left out.
358
+ const declarable = resourceTypesOf(connectors);
359
+ const read = () => readDeclared(root, connectors, declarable, types);
320
360
  if (types.size) {
321
- const connectors = connectorsOf(config);
322
- // The types the data files may declare: the vendors' and the people's, whose resources are left out.
323
- const declarable = resourceTypesOf(connectors);
324
- const read = () => readDeclared(root, connectors, declarable, types);
325
361
  names.set(
326
362
  DRIFT_WORKFLOW,
327
363
  driftWorkflow(types, ops, drivers, () => DBOS.runStep(read, { name: "drift:data-files" })),
328
364
  );
329
365
  names.set(APPLY_WORKFLOW, applyWorkflow(types, ops, drivers, applySteps(appName, root, read)));
330
366
  }
367
+ // Built-in, in every config: the life of a change, which deploys to the declared hosts after its merge.
368
+ const hosts = async () =>
369
+ (await read()).resources.filter((r) => types.get(`${r.vendor}.${r.type}`)?.host).map((r) => r.id);
370
+ names.set(CHANGE_WORKFLOW, changeWorkflow(changeOps, changeSteps({ remotes, hosts })));
331
371
  // Built-in: a direct call the policy holds for an approval, made a run, when any operation may be called directly.
332
372
  const call = callWorkflow(ops, drivers, types);
333
373
  if (call.uses.length) names.set(CALL_WORKFLOW, call);
@@ -337,6 +377,7 @@ export function resolveConfig(config: SanomaConfig, options: ResolveOptions = {}
337
377
  databaseUrl,
338
378
  // The built-in's code and operations are part of the app's version, like the config's own.
339
379
  version: computeVersion({ appName, workflows: [...names.values()] }),
380
+ changeCode: changeCodeOf(names.get(CHANGE_WORKFLOW)!),
340
381
  queueName: `sanoma:${appName}`,
341
382
  ops,
342
383
  drivers,
@@ -345,9 +386,11 @@ export function resolveConfig(config: SanomaConfig, options: ResolveOptions = {}
345
386
  ...(instanceFakes && { instanceFakes }),
346
387
  logins: makersOf(config.connectors, "login"),
347
388
  models: makersOf(config.connectors, "model"),
389
+ remotes,
390
+ hosts,
348
391
  ...(scenarios && { scenarios }),
349
392
  types,
350
- steps: [...new Set([...config.connectors.flatMap((c) => setupOf(c).steps ?? []), ...(config.steps ?? [])])],
393
+ steps: [...new Set([...running.flatMap((c) => setupOf(c).steps ?? []), ...(config.steps ?? [])])],
351
394
  collections,
352
395
  ...(dataStore && { dataStore }),
353
396
  workflows: names,
@@ -383,9 +426,32 @@ function collectionsOf(config: SanomaConfig): Map<string, Collection> {
383
426
  return collections;
384
427
  }
385
428
 
386
- /** The config's connectors and the runtime's people types' (`peopleConnector`): what its data files are read against. */
429
+ /**
430
+ * `sanoma.wakeup`, the runtime's own: the type's operations and `sanoma.wakeup.register` (effect
431
+ * `write`), which the worker registers each deadline through before it stops, with its live
432
+ * driver (`sanomaWakeupDriver`), its fake (`fakeSanomaWakeup`) and its step. `withBuiltIns`
433
+ * merges it into every config with a host type.
434
+ */
435
+ export const sanomaWakeup = withSetup(wakeupOps, {
436
+ driver: sanomaWakeupDriver,
437
+ fake: fakeSanomaWakeup,
438
+ steps: wakeupSteps,
439
+ });
440
+
441
+ /**
442
+ * The connectors and, when they declare a host type, the runtime's own wakeup (`sanoma.wakeup`),
443
+ * the fallback for a host with no clock of its own: what a config runs on. The same array when
444
+ * they declare none.
445
+ */
446
+ const withBuiltIns = (connectors: Connector<any, any>[]): Connector<any, any>[] =>
447
+ [...resourceTypesOf(connectors).values()].some((type) => type.host) ? [...connectors, sanomaWakeup] : connectors;
448
+
449
+ /**
450
+ * The config's connectors (with `sanoma.wakeup` when they declare a host type) and the runtime's
451
+ * people types' (`peopleConnector`): what its data files are read against.
452
+ */
387
453
  export const connectorsOf = (config: Pick<SanomaConfig, "connectors">): Connector<any, any>[] => [
388
- ...config.connectors,
454
+ ...withBuiltIns(config.connectors),
389
455
  peopleConnector,
390
456
  ];
391
457
 
@@ -448,18 +514,21 @@ function appNameAt(root: string | undefined): string {
448
514
  return typeof named === "string" && named.trim() ? named.trim() : "sanoma";
449
515
  }
450
516
 
451
- /** `<root>/scenarios/`, as a `file:` URL to the directory (with its trailing slash), when it is a directory. */
517
+ /**
518
+ * The root, as a `file:` URL to the directory (with its trailing slash), when `<root>/scenarios/`
519
+ * or an app's `apps/<app>/scenarios/` is a directory: the scenarios are the feature files there,
520
+ * each named by its path from the root (`scenarioFiles`).
521
+ */
452
522
  function scenariosAt(root: string | undefined): URL | undefined {
453
- const dir = root === undefined ? undefined : join(root, "scenarios");
454
- if (dir === undefined || !statSync(dir, { throwIfNoEntry: false })?.isDirectory()) return undefined;
455
- return pathToFileURL(`${dir}/`);
523
+ if (root === undefined || !kindDirs(root, SCENARIOS_DIR).some(isDirectory)) return undefined;
524
+ return pathToFileURL(`${root}/`);
456
525
  }
457
526
 
458
527
  /**
459
528
  * The one wakeup the config's data files declare, or undefined: with none, and with one the reader
460
529
  * leaves out for a problem (its declaration's or its host's; several wakeups are each one).
461
530
  * Undefined means the instance has no wakeup, so it must not stop. Server-side: it reads the data
462
- * files and the lockfile, only when a connector declares a wakeup type.
531
+ * files and the lockfile, only when the config has a wakeup type (`sanoma.wakeup` beside any host type).
463
532
  */
464
533
  export async function declaredWakeup(config: SanomaConfig): Promise<DeclaredWakeup | undefined> {
465
534
  const connectors = connectorsOf(config);
@@ -1,4 +1,10 @@
1
+ import { sanoma as people } from "./person.ts";
2
+ import { wakeup } from "./wakeup-type.ts";
3
+
1
4
  // `@sanoma-ai/workflows/resources`: what a data file imports from the runtime: `secret`, which
2
- // names a stored secret, and `sanoma`, the constructors of its people types (`sanoma.person`, …).
5
+ // names a stored secret, and `sanoma`, the constructors of its people types (`sanoma.person`, …)
6
+ // and of its wakeup, `sanoma.wakeup({ host })`.
3
7
  export { secret, type Secret } from "./secret.ts";
4
- export { sanoma } from "./person.ts";
8
+
9
+ /** The runtime's own constructors: the people types' and the wakeup's. */
10
+ export const sanoma = { ...people, wakeup };
package/src/db.ts CHANGED
@@ -19,10 +19,9 @@ export const LEDGER_TABLE = "sanoma_ledger";
19
19
  /** Where the collections keep their records (`collection.ts`): one row per collection and key, the record as JSON. */
20
20
  export const DATA_TABLE = "sanoma_data";
21
21
  /**
22
- * The instance's side of its wakeup: one row, made with the table. `owner` is the worker that
23
- * registers, `generation` counts registrations up, `at` is the deadline registered, `stopped_at`
24
- * when the worker last stopped, `registered_at` when the wakeup last took one, `last_error` why
25
- * the last registration failed.
22
+ * The instance's side of its wakeup: one row, made with the table. `at` is the deadline last
23
+ * registered, `stopped_at` when the worker last stopped itself (the schedules backfill from it),
24
+ * `last_error` why the last registration failed.
26
25
  */
27
26
  export const WAKEUP_TABLE = "sanoma_wakeup";
28
27
  /** The operator agent's chats (`chats.ts`): who each is for, its engine and its draft. */
@@ -52,9 +51,8 @@ const TABLES: readonly [what: string, ddl: string][] = [
52
51
  ],
53
52
  [
54
53
  "wakeup",
55
- `CREATE TABLE IF NOT EXISTS ${WAKEUP_TABLE} (id integer PRIMARY KEY CHECK (id = 1), owner uuid, ` +
56
- "generation bigint NOT NULL DEFAULT 0, at timestamptz, stopped_at timestamptz, registered_at timestamptz, " +
57
- `last_error text); INSERT INTO ${WAKEUP_TABLE} (id) VALUES (1) ON CONFLICT (id) DO NOTHING`,
54
+ `CREATE TABLE IF NOT EXISTS ${WAKEUP_TABLE} (id integer PRIMARY KEY CHECK (id = 1), at timestamptz, ` +
55
+ `stopped_at timestamptz, last_error text); INSERT INTO ${WAKEUP_TABLE} (id) VALUES (1) ON CONFLICT (id) DO NOTHING`,
58
56
  ],
59
57
  [
60
58
  "chats",
@@ -103,6 +101,29 @@ export function openPool(databaseUrl: string, what: string): Pool {
103
101
  */
104
102
  export const DBOS_DATABASE_OPTIONS = { systemDatabaseIdleTransactionTimeoutMs: 0 } as const;
105
103
 
104
+ /**
105
+ * Stamps the change runs `ids` still unfinished on another version with the application version
106
+ * `version`, so DBOS gives them to a worker of that version: those it stamped, each with its status
107
+ * (a PENDING one is the caller's to queue again). DBOS has no call for it (a run's version is set at
108
+ * its start, `forkWorkflow` makes a new id, `rewindWorkflow` takes only an ended run), so this is the
109
+ * runtime's one write to DBOS's own tables, in its schema `dbos` (dbos-sdk 5.2.11). One statement:
110
+ * of two workers stamping a run, one does.
111
+ */
112
+ export async function stampVersion(
113
+ db: Db,
114
+ ids: readonly string[],
115
+ version: string,
116
+ ): Promise<{ id: string; pending: boolean }[]> {
117
+ const { rows } = await db.query<{ workflow_uuid: string; status: string }>(
118
+ `UPDATE dbos.workflow_status SET application_version = $1
119
+ WHERE workflow_uuid = ANY($2) AND name = 'change' AND status IN ('PENDING', 'ENQUEUED')
120
+ AND application_version <> $1
121
+ RETURNING workflow_uuid, status`,
122
+ [version, ids],
123
+ );
124
+ return rows.map((r) => ({ id: r.workflow_uuid, pending: r.status === "PENDING" }));
125
+ }
126
+
106
127
  /**
107
128
  * A DBOS client of the app `applicationName` on the database at `databaseUrl`, as every client of
108
129
  * the runtime, its tests and `@sanoma-ai/testing` make one (`DBOS_DATABASE_OPTIONS`).
@@ -297,68 +318,20 @@ export async function createTable(db: Db, ddl: string, what: string): Promise<vo
297
318
  }
298
319
  }
299
320
 
300
- // The wakeup row's reads and writes. Every write after the claim names the owner: a worker
301
- // another process has replaced since (a second instance on the database) changes nothing.
302
-
303
- // `at` is the deadline registered for the row's generation, null until one is: a new generation
304
- // (each start and each stop attempt) clears it, so a wake registered earlier is stale.
305
-
306
- /** Makes `owner` the worker that registers the instance's wakeups; a running worker needs no wake registered before. */
307
- export async function claimWakeup(db: Db, owner: string): Promise<void> {
308
- await db.query(`UPDATE ${WAKEUP_TABLE} SET owner = $1, generation = generation + 1, at = NULL WHERE id = 1`, [owner]);
309
- }
310
-
311
- /** Counts the row's generation up while `owner` still owns it: the new generation, or undefined when another worker owns the row now. */
312
- export async function nextWakeupGeneration(db: Db, owner: string): Promise<number | undefined> {
313
- const { rows } = await db.query<{ generation: string }>(
314
- `UPDATE ${WAKEUP_TABLE} SET generation = generation + 1, at = NULL WHERE id = 1 AND owner = $1 RETURNING generation`,
315
- [owner],
316
- );
317
- return rows[0] && Number(rows[0].generation);
318
- }
319
-
320
- /** Records that the wakeup took `at` for `generation`. */
321
- export async function recordWakeupRegistration(db: Db, owner: string, generation: number, at: string): Promise<void> {
322
- await db.query(
323
- `UPDATE ${WAKEUP_TABLE} SET at = $3, registered_at = now(), last_error = NULL WHERE id = 1 AND owner = $1 AND generation = $2`,
324
- [owner, generation, at],
325
- );
326
- }
321
+ // The wakeup row's writes: one worker registers per instance (the supervisor runs one at a time,
322
+ // and a deploy stops the old container before the new one starts), so none names a worker.
327
323
 
328
- /** Records why registering `generation` failed. */
329
- export async function recordWakeupError(db: Db, owner: string, generation: number, error: string): Promise<void> {
330
- await db.query(`UPDATE ${WAKEUP_TABLE} SET last_error = $3 WHERE id = 1 AND owner = $1 AND generation = $2`, [
331
- owner,
332
- generation,
333
- error,
334
- ]);
324
+ /** Records that the wakeup took `at`. */
325
+ export async function recordWakeupRegistration(db: Db, at: string): Promise<void> {
326
+ await db.query(`UPDATE ${WAKEUP_TABLE} SET at = $1, last_error = NULL WHERE id = 1`, [at]);
335
327
  }
336
328
 
337
- /** Records that the worker stops now, with `generation` registered. */
338
- export async function recordWakeupStop(db: Db, owner: string, generation: number): Promise<void> {
339
- await db.query(`UPDATE ${WAKEUP_TABLE} SET stopped_at = now() WHERE id = 1 AND owner = $1 AND generation = $2`, [
340
- owner,
341
- generation,
342
- ]);
329
+ /** Records why the last registration failed. */
330
+ export async function recordWakeupError(db: Db, error: string): Promise<void> {
331
+ await db.query(`UPDATE ${WAKEUP_TABLE} SET last_error = $1 WHERE id = 1`, [error]);
343
332
  }
344
333
 
345
- /**
346
- * True when `generation` is the wakeup row's and the worker stopped with it: the wake
347
- * `/wake?generation=N` asks for is the one the worker last registered before it stopped, not
348
- * one a later stop replaced, nor one registered by a worker that then kept running. For the
349
- * route's supervisor, which decides on its own what a wake means when no wakeup is declared.
350
- */
351
- export async function shouldWake(databaseUrl: string, generation: number): Promise<boolean> {
352
- await prepareDatabase(databaseUrl);
353
- const conn = await connect(databaseUrl);
354
- try {
355
- const { rows } = await conn.query<{ current: boolean }>(
356
- `SELECT generation = $1 AND at IS NOT NULL AND stopped_at >= registered_at ` +
357
- `AS current FROM ${WAKEUP_TABLE} WHERE id = 1`,
358
- [generation],
359
- );
360
- return rows[0]?.current === true;
361
- } finally {
362
- await conn.end().catch(() => undefined);
363
- }
334
+ /** Records that the worker stops itself now. */
335
+ export async function recordWakeupStop(db: Db): Promise<void> {
336
+ await db.query(`UPDATE ${WAKEUP_TABLE} SET stopped_at = now() WHERE id = 1`);
364
337
  }
package/src/deadlines.ts CHANGED
@@ -1,7 +1,8 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import { DBOS } from "@dbos-inc/dbos-sdk";
2
3
  import { dbosStatusesOf } from "./client.ts";
3
4
  import type { Db } from "./db.ts";
4
- import { nextWakeupGeneration, recordWakeupError, recordWakeupRegistration, recordWakeupStop } from "./db.ts";
5
+ import { recordWakeupError, recordWakeupRegistration, recordWakeupStop } from "./db.ts";
5
6
  import type { ResolvedConfig } from "./config.ts";
6
7
  import { warn } from "./log.ts";
7
8
  import type { CallContext, DriverFn, Op } from "./op.ts";
@@ -13,8 +14,11 @@ import type { WakeupRegistration } from "./wakeup.ts";
13
14
  // blocked in a `ctx` wait, and until when. DBOS sees both as PENDING. A worker that registers a
14
15
  // wakeup stops itself once nothing can run before the next deadline (`stopWhenIdle`).
15
16
 
16
- /** What a run blocked in a `ctx` wait waits for: a sleep, until its deadline, or an approval's decision, which has none. */
17
- export type Wait = { kind: "sleep"; until: number } | { kind: "approval" };
17
+ /**
18
+ * What a run blocked in a wait waits for: a sleep, until its deadline; or, with no deadline, an
19
+ * approval's decision, a message (a change run's `ChangeEvent`), or another run it started.
20
+ */
21
+ export type Wait = { kind: "sleep"; until: number } | { kind: "approval" | "message" | "run" };
18
22
 
19
23
  /** A deadline besides the sleeping runs', such as a schedule's next fire; undefined for none. */
20
24
  export type DeadlineSource = () => Date | undefined;
@@ -124,18 +128,15 @@ export async function idle(deadlines: Deadlines, scope: IdleScope, horizonMs: nu
124
128
  return (await DBOS.getLatestApplicationVersion()).versionName !== scope.version;
125
129
  }
126
130
 
127
- /** What `onStopped` hears when the worker stopped itself: the deadline it registered (null for none) and its generation. */
131
+ /** What `onStopped` hears when the worker stopped itself: the deadline it registered, null for none. */
128
132
  export interface SelfStop {
129
133
  at: string | null;
130
- generation: number;
131
134
  }
132
135
 
133
136
  /** What the stop rule acts on: the wakeup row, the wakeup's register operation, and the worker. */
134
137
  export interface StopRule {
135
138
  /** The wakeup row's database. */
136
139
  db: () => Promise<Db>;
137
- /** This worker's id on the row (`owner`), fresh for each worker. */
138
- owner: string;
139
140
  /** The declared wakeup's binding, else its declaration's id: what scopes a registration's idempotency key to this instance. */
140
141
  wakeup: string;
141
142
  /** The wakeup type's register operation, and its driver (none when the connector attaches none). */
@@ -150,12 +151,12 @@ export interface StopRule {
150
151
  }
151
152
 
152
153
  /**
153
- * Checks every `everyMs` whether the worker is `idle`; when it is, counts the wakeup row's
154
- * generation up, registers the next deadline with the wakeup (none when there is no deadline at
155
- * all), and stops the worker, then tells `onStopped`. A failed registration is recorded on the
156
- * row and logged once for each new message, and the worker keeps running: the next check tries
157
- * again with a new generation. A run that arrives while it registers keeps the worker up, since
158
- * the worker must be idle twice, with the same deadline, before it stops. `close` ends the checks.
154
+ * Checks every `everyMs` whether the worker is `idle`; when it is, registers the next deadline
155
+ * with the wakeup, replacing what it held (none when there is no deadline at all), and stops the
156
+ * worker, then tells `onStopped`. A failed registration is recorded on the row and logged once for
157
+ * each new message, and the worker keeps running: the next check tries again. A run that arrives
158
+ * while it registers keeps the worker up, since the worker must be idle twice, with the same
159
+ * deadline, before it stops. `close` ends the checks.
159
160
  */
160
161
  export function stopWhenIdle(rule: StopRule): { close(): void } {
161
162
  let closed = false;
@@ -171,30 +172,23 @@ export function stopWhenIdle(rule: StopRule): { close(): void } {
171
172
  const next = rule.deadlines.nextDeadline();
172
173
  const at = next?.toISOString() ?? null;
173
174
  const db = await rule.db();
174
- const generation = await nextWakeupGeneration(db, rule.owner);
175
- if (generation === undefined) {
176
- // A worker claims the row only when it starts, so this one never gets it back.
177
- warn("another worker registers this instance's wakeups now, so this one keeps running and stops checking");
178
- closed = true;
179
- return false;
180
- }
181
175
  if (at !== null) {
182
176
  try {
183
- await registerWakeup(rule, { at, generation });
177
+ await registerWakeup(rule, { at });
184
178
  } catch (err) {
185
179
  const message = errorMessage(err);
186
- await recordWakeupError(db, rule.owner, generation, message);
180
+ await recordWakeupError(db, message);
187
181
  problem(`could not register the wakeup at ${at}, so the worker keeps running: ${message}`);
188
182
  return false;
189
183
  }
190
- await recordWakeupRegistration(db, rule.owner, generation, at);
184
+ await recordWakeupRegistration(db, at);
191
185
  }
192
186
  lastProblem = undefined;
193
187
  if (closed || !(await rule.idle()) || rule.deadlines.nextDeadline()?.toISOString() !== next?.toISOString())
194
188
  return false;
195
- await recordWakeupStop(db, rule.owner, generation);
189
+ await recordWakeupStop(db);
196
190
  await rule.stop().catch((err: unknown) => warn(`the worker could not stop cleanly: ${errorMessage(err)}`));
197
- rule.onStopped?.({ at, generation });
191
+ rule.onStopped?.({ at });
198
192
  return true;
199
193
  };
200
194
 
@@ -222,13 +216,16 @@ export function stopWhenIdle(rule: StopRule): { close(): void } {
222
216
 
223
217
  /**
224
218
  * Calls the wakeup's register operation, as `SanomaClient.call` calls a driver: its input parsed,
225
- * a `CallContext` of the runtime's, and no ledger record, which is a run's. The idempotency key
226
- * is the generation's, so a retried registration is one registration.
219
+ * a `CallContext` of the runtime's, and no ledger record, which is a run's.
227
220
  */
228
- async function registerWakeup({ register: { op, fn }, secrets, wakeup }: StopRule, registration: WakeupRegistration) {
221
+ export async function registerWakeup(
222
+ { register: { op, fn }, secrets, wakeup }: Pick<StopRule, "register" | "secrets" | "wakeup">,
223
+ registration: WakeupRegistration,
224
+ ) {
229
225
  if (!fn) throw new Error(`${op.id} has no driver: the connector package must attach one`);
230
- // Every database counts generations from 0, so the key names the wakeup too.
231
- const key = `wakeup:${wakeup}:${registration.generation}`;
226
+ // Fresh each time: a repeat of an earlier `at` after another registration must re-arm it, so no
227
+ // two registrations share a key (real drivers never dedupe on it anyway).
228
+ const key = `wakeup:${wakeup}:${randomUUID()}`;
232
229
  const context: CallContext = { idempotencyKey: key, runId: key, opId: op.id, attempt: 1, secrets, by: WAKEUP_AGENT };
233
230
  await fn(op.input.parse(registration), context);
234
231
  }
package/src/define.ts CHANGED
@@ -2,6 +2,8 @@ import { fileURLToPath } from "node:url";
2
2
  import { getCallSites } from "node:util";
3
3
  import { z } from "zod";
4
4
  import { isOp, type Op } from "./op.ts";
5
+ import { appOfFile } from "./paths.ts";
6
+ import { type DATA_VENDOR, qualified } from "./shared.ts";
5
7
 
6
8
  /**
7
9
  * Who is acting: the person or agent that starts a run, calls an operation or decides an
@@ -171,19 +173,27 @@ export interface StepOptions {
171
173
 
172
174
  type OpsOf<U extends readonly Use[]> = Extract<U[number], Op>;
173
175
 
174
- type CtxOps<O extends Op> = {
175
- readonly [V in O["vendor"]]: {
176
- readonly [R in Extract<O, { vendor: V }>["resource"]]: {
177
- readonly [N in Extract<O, { vendor: V; resource: R }>["name"]]: Extract<
178
- O,
179
- { vendor: V; resource: R; name: N }
180
- > extends Op<any, any, any, infer I, infer Out>
181
- ? (input: I, options?: StepOptions) => Promise<Out>
182
- : never;
183
- };
176
+ type VendorCtx<O extends Op, V> = {
177
+ readonly [R in Extract<O, { vendor: V }>["resource"]]: {
178
+ readonly [N in Extract<O, { vendor: V; resource: R }>["name"]]: Extract<
179
+ O,
180
+ { vendor: V; resource: R; name: N }
181
+ > extends Op<any, any, any, infer I, infer Out>
182
+ ? (input: I, options?: StepOptions) => Promise<Out>
183
+ : never;
184
184
  };
185
185
  };
186
186
 
187
+ /**
188
+ * `ctx.<vendor>.<resource>.<op>`. An app's collections are `ctx.data.<app>.<name>` at run time,
189
+ * a folder's name no type knows: that level is untyped (`any`), the root's collections typed.
190
+ */
191
+ type CtxOps<O extends Op> = {
192
+ readonly [V in O["vendor"]]: V extends typeof DATA_VENDOR
193
+ ? VendorCtx<O, V> & { readonly [app: string]: any }
194
+ : VendorCtx<O, V>;
195
+ };
196
+
187
197
  interface ApprovalCtx {
188
198
  /** Waits, durably, until the approver (or someone in the approver group) approves. Throws `RejectedError` on a rejection, `WithdrawnError` on a withdrawal. */
189
199
  approval(title: string, request: ApprovalRequest): Promise<ApprovalResult>;
@@ -256,7 +266,10 @@ export type Trigger = "manual" | { readonly schedule: string; readonly timezone?
256
266
 
257
267
  export interface WorkflowDefinition<U extends readonly Use[] = readonly Use[], S extends z.ZodType = z.ZodType> {
258
268
  readonly kind: "workflow";
269
+ /** Its name, as written at the root; in an app's folder, `<app>.<name>` (`app`). */
259
270
  readonly name: string;
271
+ /** The app whose folder defined it (`apps/<app>/`), from its `file`; absent for one at the root. */
272
+ readonly app?: string;
260
273
  readonly title?: string;
261
274
  readonly trigger: Trigger;
262
275
  readonly input: S;
@@ -273,8 +286,13 @@ export interface WorkflowDefinition<U extends readonly Use[] = readonly Use[], S
273
286
  /** The ids of the operations a workflow `uses`, in the order declared. */
274
287
  export const opIdsOf = (wf: Pick<WorkflowDefinition, "uses">): string[] => wf.uses.filter(isOp).map((op) => op.id);
275
288
 
289
+ /**
290
+ * Defines a workflow in its own file. Its `name` is a lowercase letter, then lowercase letters,
291
+ * digits and dashes; in an app's folder (`apps/<app>/`, `appOfFile`) the runtime names it
292
+ * `<app>.<name>`, so two apps may each have a `sync`.
293
+ */
276
294
  export function defineWorkflow<const U extends readonly Use[], S extends z.ZodType>(
277
- def: Omit<WorkflowDefinition<U, S>, "kind" | "file">,
295
+ def: Omit<WorkflowDefinition<U, S>, "kind" | "file" | "app">,
278
296
  ): WorkflowDefinition<U, S> {
279
297
  if (!/^[a-z][a-z0-9-]*$/.test(def.name)) {
280
298
  throw new Error(`Workflow name "${def.name}" must be lowercase letters, digits and dashes`);
@@ -293,7 +311,8 @@ export function defineWorkflow<const U extends readonly Use[], S extends z.ZodTy
293
311
  "its outline is read from the file, and a run is held to the outline",
294
312
  );
295
313
  }
296
- return Object.freeze({ kind: "workflow", ...def, file });
314
+ const app = appOfFile(file);
315
+ return Object.freeze({ kind: "workflow", ...def, name: qualified(app, def.name), ...(app && { app }), file });
297
316
  }
298
317
 
299
318
  /** True when `{}` parses: a schema zod can check only asynchronously is taken as accepting it, and the run parses it. */