@sanoma-ai/workflows 0.7.1 → 0.8.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 (130) hide show
  1. package/README.md +34 -7
  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 +30 -2
  34. package/dist/config.js +41 -20
  35. package/dist/config.js.map +1 -1
  36. package/dist/db.d.ts +12 -0
  37. package/dist/db.js +15 -0
  38. package/dist/db.js.map +1 -1
  39. package/dist/deadlines.d.ts +5 -2
  40. package/dist/deadlines.js.map +1 -1
  41. package/dist/define.d.ts +30 -14
  42. package/dist/define.js +9 -1
  43. package/dist/define.js.map +1 -1
  44. package/dist/demo.d.ts +15 -4
  45. package/dist/demo.js +27 -12
  46. package/dist/demo.js.map +1 -1
  47. package/dist/describe.d.ts +16 -2
  48. package/dist/describe.js +39 -4
  49. package/dist/describe.js.map +1 -1
  50. package/dist/direct.d.ts +4 -3
  51. package/dist/direct.js +7 -4
  52. package/dist/direct.js.map +1 -1
  53. package/dist/errors.d.ts +2 -0
  54. package/dist/errors.js +2 -0
  55. package/dist/errors.js.map +1 -1
  56. package/dist/index.d.ts +7 -5
  57. package/dist/index.js +6 -3
  58. package/dist/index.js.map +1 -1
  59. package/dist/lint.js +19 -5
  60. package/dist/lint.js.map +1 -1
  61. package/dist/outline.js +13 -6
  62. package/dist/outline.js.map +1 -1
  63. package/dist/paths.d.ts +16 -0
  64. package/dist/paths.js +54 -2
  65. package/dist/paths.js.map +1 -1
  66. package/dist/resources.d.ts +7 -2
  67. package/dist/resources.js +23 -8
  68. package/dist/resources.js.map +1 -1
  69. package/dist/run.d.ts +7 -0
  70. package/dist/run.js.map +1 -1
  71. package/dist/sandbox.js +2 -2
  72. package/dist/sandbox.js.map +1 -1
  73. package/dist/scenario.d.ts +2 -2
  74. package/dist/scenario.js +10 -4
  75. package/dist/scenario.js.map +1 -1
  76. package/dist/scenarios.d.ts +1 -1
  77. package/dist/scenarios.js.map +1 -1
  78. package/dist/sdk.d.ts +1 -1
  79. package/dist/sdk.js +19 -5
  80. package/dist/sdk.js.map +1 -1
  81. package/dist/shared.d.ts +43 -2
  82. package/dist/shared.js +46 -4
  83. package/dist/shared.js.map +1 -1
  84. package/dist/sites.d.ts +26 -5
  85. package/dist/sites.js +56 -20
  86. package/dist/sites.js.map +1 -1
  87. package/dist/source.d.ts +55 -5
  88. package/dist/source.js +4 -0
  89. package/dist/source.js.map +1 -1
  90. package/dist/version.d.ts +8 -0
  91. package/dist/version.js +21 -13
  92. package/dist/version.js.map +1 -1
  93. package/dist/worker.js +50 -3
  94. package/dist/worker.js.map +1 -1
  95. package/oxlint.json +2 -2
  96. package/package.json +1 -1
  97. package/src/apps-list.ts +119 -0
  98. package/src/apps.ts +162 -0
  99. package/src/call-workflow.ts +10 -2
  100. package/src/call.ts +6 -3
  101. package/src/cedar.ts +9 -7
  102. package/src/change-steps.ts +274 -0
  103. package/src/change-workflow.ts +198 -0
  104. package/src/change.ts +257 -27
  105. package/src/claims.ts +79 -0
  106. package/src/client.ts +320 -4
  107. package/src/collection.ts +22 -3
  108. package/src/config.ts +66 -22
  109. package/src/db.ts +23 -0
  110. package/src/deadlines.ts +5 -2
  111. package/src/define.ts +31 -12
  112. package/src/demo.ts +55 -24
  113. package/src/describe.ts +67 -4
  114. package/src/direct.ts +9 -5
  115. package/src/errors.ts +3 -0
  116. package/src/index.ts +14 -0
  117. package/src/lint.ts +20 -5
  118. package/src/outline.ts +15 -6
  119. package/src/paths.ts +57 -2
  120. package/src/resources.ts +27 -9
  121. package/src/run.ts +5 -0
  122. package/src/sandbox.ts +2 -5
  123. package/src/scenario.ts +14 -4
  124. package/src/scenarios.ts +1 -1
  125. package/src/sdk.ts +21 -7
  126. package/src/shared.ts +60 -5
  127. package/src/sites.ts +70 -24
  128. package/src/source.ts +50 -5
  129. package/src/version.ts +25 -16
  130. package/src/worker.ts +46 -3
package/src/sites.ts CHANGED
@@ -1,11 +1,11 @@
1
1
  import { existsSync, readdirSync } from "node:fs";
2
- import { join, relative, sep } from "node:path";
2
+ import { basename, join, relative, sep } from "node:path";
3
3
  import { childrenOf, type LintProblem, type Node, readSource, type SourceFile } from "./ast.ts";
4
4
  import { type CedarPolicySource, visitorPermits } from "./cedar.ts";
5
5
  import { keyName } from "./datafile.ts";
6
6
  import type { ConfigDescription } from "./describe.ts";
7
7
  import { stringValue, unwrap } from "./outline.ts";
8
- import { filesUnder, isDirectory } from "./paths.ts";
8
+ import { APPS_DIR, appFolders, filesUnder, isDirectory } from "./paths.ts";
9
9
  import { START } from "./schema.ts";
10
10
  import {
11
11
  ANONYMOUS,
@@ -14,6 +14,7 @@ import {
14
14
  DATA_VENDOR,
15
15
  LOCALE,
16
16
  SITE_NAME,
17
+ siteKey,
17
18
  VISITORS,
18
19
  WORKFLOW_ACTION,
19
20
  } from "./shared.ts";
@@ -64,12 +65,16 @@ export interface SitePage {
64
65
  content?: string;
65
66
  }
66
67
 
67
- /** A site, `sites/<name>/`, and what it calls. */
68
+ /** A site, `sites/<name>/` or an app's `apps/<app>/sites/<name>/`, and what it calls. */
68
69
  export interface SiteEntry {
69
- /** Its folder's name, where it is mounted: `/sites/<name>`. */
70
+ /** Its folder's name, `<app>/<name>` for an app's (`siteKey`): where it is mounted, `/sites/<name>`. */
70
71
  name: string;
72
+ /** The app whose folder holds it (`apps/<app>/sites/`); absent for one at the root. */
73
+ app?: string;
71
74
  /** `defineSite({ title })` in its `site.ts`, when that is a string literal. */
72
75
  title?: string;
76
+ /** `defineSite({ icon })` in its `site.ts`: a Lucide icon's name, as written. */
77
+ icon?: string;
73
78
  /** `defineSite({ public: true })`: served to visitors, not people. */
74
79
  public?: true;
75
80
  /** `defineSite({ login })`, the connector whose login half signs visitors in, as written: one that names none is among `problems`. */
@@ -109,12 +114,14 @@ const PARAMS: ReadonlySet<string> = new Set(["params"]);
109
114
  const ROUTE_MAKERS: ReadonlySet<string> = new Set(["createFileRoute", "createLazyFileRoute"]);
110
115
 
111
116
  /**
112
- * The sites under `root`: each folder of `sites/` with a `routes/` whose name is a URL segment,
113
- * by name (as `@sanoma-ai/app/build`'s `sitesOf` mounts them). Reads `routes/**` and
117
+ * The sites under `root` (`sitesUnder`): each folder of `sites/`, then of each app's
118
+ * `apps/<app>/sites/`, with a `routes/` whose name is a URL segment, by key (as
119
+ * `@sanoma-ai/app/build`'s `sitesOf` mounts them). Reads `routes/**` and
114
120
  * `components/**` (`.ts` and `.tsx`, less tests and declarations) of each with oxc, never running
115
121
  * them: each page (`SitePage`, from a route's id), each `<RunForm workflow="…" />` and each use of `api`, both imported from `sanoma/site`,
116
122
  * called or passed on (`queryFn: api.data.leads.list`):
117
- * `api.<name>` and `api.<name>.<method>` are a workflow, `api.data.<collection>.<op>` a collection,
123
+ * `api.<name>` and `api.<name>.<method>` are a workflow, `api.data.<collection>.<op>` (an app's
124
+ * `api.data.<app>.<collection>.<op>`) a collection,
118
125
  * and `api.<vendor>.<resource>.<op>` an operation. A name that is not written out (a variable, a
119
126
  * template with holes, a computed member) is skipped, as is a use through a namespace import or a
120
127
  * destructured `api`. A call to what `description` does not have, or to an operation a page may
@@ -133,9 +140,11 @@ export function readSites(
133
140
  policies?: readonly CedarPolicySource[],
134
141
  ): SiteEntry[] {
135
142
  if (root === undefined) return [];
136
- const dir = join(root, "sites");
137
143
  const workflows = new Set(description.workflows.map((wf) => wf.name));
138
144
  const collections = new Set(description.collections.map((c) => c.name));
145
+ // `api.data.<app>.<collection>` passed on as a value reads as a call of `<app>`'s method: it is none.
146
+ const isAppCollection = ({ kind, name, method }: Written) =>
147
+ kind === "collection" && collections.has(`${name}.${method}`);
139
148
  const ops = new Map(description.ops.map((op) => [op.id, op]));
140
149
  // A workflow's api name to its name: two of one api name are a problem on the workflows.
141
150
  const byApiName = new Map(description.workflows.map((wf) => [apiNameOf(wf.name), wf.name]));
@@ -193,11 +202,11 @@ export function readSites(
193
202
  return { call: { kind, target, via, ...at }, ...(problem && { problem }) };
194
203
  };
195
204
 
196
- return siteNames(dir).map((name) => {
197
- const read = readSiteFiles(root, name);
198
- const resolved = read.flatMap(({ uses }) => uses.map(resolve));
205
+ return sitesUnder(root).map(({ name, app, dir }) => {
206
+ const read = readSiteFiles(root, dir);
207
+ const resolved = read.flatMap(({ uses }) => uses.filter((use) => !isAppCollection(use)).map(resolve));
199
208
  const pages = pagesOf(read);
200
- const siteFile = `sites/${name}/site.ts`;
209
+ const siteFile = `${dir}/site.ts`;
201
210
  const { problems: optionProblems, ...options } = siteOptionsOf(join(root, siteFile), connectors);
202
211
  const calls = resolved.map((r) => r.call);
203
212
  const problems = [
@@ -212,7 +221,7 @@ export function readSites(
212
221
  const warnings = options.public
213
222
  ? read.flatMap(({ route, warning }) => (warning && !pages.get(route!.path)?.content ? [warning] : []))
214
223
  : [];
215
- return { name, ...options, pages: sorted, calls, problems, warnings };
224
+ return { name, ...(app && { app }), ...options, pages: sorted, calls, problems, warnings };
216
225
  });
217
226
  }
218
227
 
@@ -225,9 +234,9 @@ function pagesOf(read: readonly { route?: SitePage }[]): Map<string, SitePage> {
225
234
  return pages;
226
235
  }
227
236
 
228
- /** Each of site `name`'s files under `root`, as `readSiteFile` reads it, by path. */
229
- const readSiteFiles = (root: string, name: string) =>
230
- SITE_DIRS.flatMap((sub) => filesUnder(join(root, "sites", name, sub), isSource))
237
+ /** Each file of the site in `dir` (relative to `root`), as `readSiteFile` reads it, by path. */
238
+ const readSiteFiles = (root: string, dir: string) =>
239
+ SITE_DIRS.flatMap((sub) => filesUnder(join(root, dir, sub), isSource))
231
240
  .toSorted()
232
241
  .flatMap((path) => {
233
242
  const file = readSource(path);
@@ -241,8 +250,8 @@ const readSiteFiles = (root: string, name: string) =>
241
250
  */
242
251
  export function siteContentPages(root: string): Record<string, string> {
243
252
  return Object.fromEntries(
244
- siteNames(join(root, "sites")).flatMap((name) =>
245
- [...pagesOf(readSiteFiles(root, name)).values()].flatMap(({ path, content }) =>
253
+ sitesUnder(root).flatMap(({ dir }) =>
254
+ [...pagesOf(readSiteFiles(root, dir)).values()].flatMap(({ path, content }) =>
246
255
  content ? [[path, content]] : [],
247
256
  ),
248
257
  ),
@@ -257,13 +266,44 @@ export function siteContentPages(root: string): Record<string, string> {
257
266
  export function siteLocales(root: string): Record<string, string[]> {
258
267
  const none = new Set<string>();
259
268
  return Object.fromEntries(
260
- siteNames(join(root, "sites")).flatMap((name) => {
261
- const { locales } = siteOptionsOf(join(root, "sites", name, "site.ts"), { vendors: none, logins: none });
269
+ sitesUnder(root).flatMap(({ name, dir }) => {
270
+ const { locales } = siteOptionsOf(join(root, dir, "site.ts"), { vendors: none, logins: none });
262
271
  return locales ? [[name, locales]] : [];
263
272
  }),
264
273
  );
265
274
  }
266
275
 
276
+ /**
277
+ * Each site's icon (`defineSite({ icon })`, a Lucide icon's name as written) under `root`, by name,
278
+ * for the sites whose `site.ts` gives one: the icons the app's build bundles (`virtual:sanoma/icons`).
279
+ * Read as `readSites` reads it, never run.
280
+ */
281
+ export function siteIcons(root: string): Record<string, string> {
282
+ const none = new Set<string>();
283
+ return Object.fromEntries(
284
+ sitesUnder(root).flatMap(({ name, dir }) => {
285
+ const { icon } = siteOptionsOf(join(root, dir, "site.ts"), { vendors: none, logins: none });
286
+ return icon === undefined ? [] : [[name, icon]];
287
+ }),
288
+ );
289
+ }
290
+
291
+ /**
292
+ * The sites under `root`, by key (`siteKey`): each folder of `sites/` with a `routes/` whose name
293
+ * is a URL segment, then each app's `apps/<app>/sites/`, with its folder relative to `root`.
294
+ */
295
+ export const sitesUnder = (root: string): { name: string; app?: string; dir: string }[] => [
296
+ ...siteNames(join(root, "sites")).map((name) => ({ name, dir: `sites/${name}` })),
297
+ ...appFolders(root).flatMap((folder) => {
298
+ const app = basename(folder);
299
+ return siteNames(join(folder, "sites")).map((name) => ({
300
+ name: siteKey(app, name),
301
+ app,
302
+ dir: `${APPS_DIR}/${app}/sites/${name}`,
303
+ }));
304
+ }),
305
+ ];
306
+
267
307
  /** The sites in `dir`: each folder with a `routes/` whose name is a URL segment, sorted. */
268
308
  const siteNames = (dir: string): string[] =>
269
309
  isDirectory(dir)
@@ -379,9 +419,12 @@ function contentKind(callee: Node, content: ReadonlySet<string>): string | undef
379
419
  function apiPath(member: Node, api: ReadonlySet<string>): Pick<Written, "kind" | "name" | "method"> | undefined {
380
420
  const path = memberPath(member, api);
381
421
  if (!path?.length) return undefined;
382
- const [first, second, third] = path as [string, ...string[]];
422
+ const [first, second] = path as [string, ...string[]];
423
+ // `api.data.<collection>.<op>`, or an app's, `api.data.<app>.<collection>.<op>`.
383
424
  if (first === DATA_VENDOR)
384
- return path.length === 3 ? { kind: "collection", name: second!, method: third } : undefined;
425
+ return path.length === 3 || path.length === 4
426
+ ? { kind: "collection", name: path.slice(1, -1).join("."), method: path.at(-1) }
427
+ : undefined;
385
428
  if (path.length === 3) return { kind: "op", name: path.join(".") };
386
429
  if (path.length > 2) return undefined;
387
430
  return { kind: "workflow", name: first, ...(second !== undefined && { method: second }) };
@@ -414,14 +457,14 @@ const unread = (what: string, as: string) => `${what} is read without running si
414
457
  /**
415
458
  * What `site.ts` says of its site, read without running it, and what is wrong there: its default
416
459
  * export must be `defineSite({ … })` written out, so it can be read at all; then `title` when it is
417
- * a string, `public`, `login` and `locales`, which must be literals. `login` is kept as written; one on a
460
+ * a string, `icon`, `public`, `login` and `locales`, which must be literals. `login` is kept as written; one on a
418
461
  * site that is not public, or naming no connector of the config with a login half, is a problem.
419
462
  * `locales` is kept only when it has no problem.
420
463
  */
421
464
  function siteOptionsOf(
422
465
  path: string,
423
466
  connectors: SiteConnectors,
424
- ): Pick<SiteEntry, "title" | "public" | "login" | "locales"> & { problems: LintProblem[] } {
467
+ ): Pick<SiteEntry, "title" | "icon" | "public" | "login" | "locales"> & { problems: LintProblem[] } {
425
468
  const file = readSource(path);
426
469
  if (typeof file === "string") return { problems: [] };
427
470
  const problems: LintProblem[] = [...file.problems];
@@ -453,6 +496,8 @@ function siteOptionsOf(
453
496
  options?.properties.findLast((p: Node) => isWritten(p) && keyName(p.key) === name);
454
497
 
455
498
  const title = stringValue(property("title")?.value);
499
+ // Cosmetic: one that is not a string (tsc refuses it) is shown as none, never a problem that stops a site.
500
+ const icon = stringValue(property("icon")?.value);
456
501
  const publicAt = property("public");
457
502
  const flag = unwrap(publicAt?.value);
458
503
  const isPublic = flag?.type === "Literal" && flag.value === true;
@@ -495,6 +540,7 @@ function siteOptionsOf(
495
540
  if (localesMessage) problem(localesAt, localesMessage);
496
541
  return {
497
542
  ...(title !== undefined && { title }),
543
+ ...(icon !== undefined && { icon }),
498
544
  ...(isPublic && { public: true as const }),
499
545
  ...(login !== undefined && { login }),
500
546
  ...(localesAt && !localesMessage && isPublic && !loginAt && { locales }),
package/src/source.ts CHANGED
@@ -28,8 +28,9 @@ export interface Source {
28
28
  /** Pushes `ref` to the repository at `url`, signed in with `token` (a draft by its `branch`). */
29
29
  push?(target: { url: string; token: string; ref: string }): void | Promise<void>;
30
30
  /**
31
- * The draft `id` (letters, digits, `_` and `-`), opened: made from the checked-out revision the
32
- * first time, then the same one, edits and all, until its branch is deleted.
31
+ * The draft `id` (letters, digits, `_` and `-`), opened on its branch (`draftBranch`): made from
32
+ * the checked-out revision the first time, then the same one, edits and all, until its branch is
33
+ * deleted.
33
34
  */
34
35
  draft?(id: string): Promise<Draft>;
35
36
  /** The open drafts' ids, sorted. */
@@ -47,13 +48,27 @@ export interface Source {
47
48
  /** The URL of the repository this one was cloned from (`origin`); undefined when it has none. */
48
49
  remoteUrl?(): Promise<string | undefined>;
49
50
  /**
50
- * Fast-forwards the checked-out branch to `ref` in the repository at `url`, signed in with
51
- * `token`, as `push` is: only when `ref` is checked out, nothing is uncommitted and the remote's
52
- * `ref` contains it; otherwise `skipped`, saying why. The checked-out revision after.
51
+ * Fast-forwards the branch `ref` to `ref` in the repository at `url`, signed in with `token`, as
52
+ * `push` is, where it is checked out: the root's checked-out branch, or an open draft's (by its
53
+ * `branch`), whose new commits on the host it takes so. Only when nothing is uncommitted there
54
+ * and the remote's `ref` contains it; otherwise `skipped`, saying why. The revision after.
53
55
  */
54
56
  follow?(target: { url: string; token: string; ref: string }): Promise<{ revision: string } | { skipped: string }>;
57
+ /**
58
+ * How many commits the source repository of the app `folder` (the URL its `apps/<folder>/app.ts`
59
+ * names) has on its default branch that the checked-out one lacks, of those changing what an
60
+ * update merges (`apps/<folder>/` and `package.json`): fetched now. An error when the app names
61
+ * no source.
62
+ */
63
+ behind?(folder: string): Promise<number>;
55
64
  }
56
65
 
66
+ /** The branch of the draft `id`, as a source names it: `draft/<id>`. */
67
+ export const draftBranch = (id: string) => `draft/${id}`;
68
+
69
+ /** The draft whose branch `branch` is (`draftBranch`), or undefined for a branch that is no draft's. */
70
+ export const draftOf = (branch: string) => (branch.startsWith("draft/") ? branch.slice("draft/".length) : undefined);
71
+
57
72
  /**
58
73
  * A draft: a checkout of its own, at `root`, on `branch`, which `commit` records in as
59
74
  * `Source.commit` does in its root. Its `node_modules` may be the root's own install, linked in:
@@ -63,6 +78,36 @@ export interface Draft {
63
78
  branch: string;
64
79
  root: string;
65
80
  commit: Source["commit"];
81
+ /**
82
+ * Takes an app into the draft: fetches `ref` (the default branch by default) of the repository
83
+ * at `url`, a source (a public company repo with one `apps/<folder>/`), and merges it in with its
84
+ * history (`--allow-unrelated-histories`), as one merge commit by `author`. What it merges is
85
+ * `apps/<folder>/` and `package.json`; every other file stays the draft's. `folder` may be left
86
+ * out when the source has one app. Refused when the draft has `apps/<folder>/` already.
87
+ */
88
+ take?(app: { url: string; ref?: string; folder?: string; author: string }): Promise<AppMerge>;
89
+ /**
90
+ * Merges the new commits of the app `folder`'s source (the URL its `app.ts` names, its default
91
+ * branch) into the draft, by `author`, as `take` merges: `apps/<folder>/` and `package.json`.
92
+ */
93
+ update?(app: { folder: string; author: string }): Promise<AppMerge>;
94
+ }
95
+
96
+ /**
97
+ * What taking or updating an app merged into a draft. A line both sides changed is committed with
98
+ * git's markers in its file (ours, the merge base and theirs), so the draft's checks fail until the
99
+ * agent resolves it; a file one side deleted stays as the draft had it. The merge commit keeps the
100
+ * source's history either way.
101
+ */
102
+ export interface AppMerge {
103
+ /** The app's folder: `apps/<folder>/`. */
104
+ folder: string;
105
+ /** The draft's commit after the merge. */
106
+ revision: string;
107
+ /** The source's commits the merge brought in that change the app's folder or `package.json`; 0 when the draft had them all, and nothing was merged. */
108
+ merged: number;
109
+ /** The files git could not merge, relative to the root, committed with conflict markers; empty when it merged clean. */
110
+ conflicts: string[];
66
111
  }
67
112
 
68
113
  /** A draft's change: the files it touches, relative to the root, and git's `--stat` of it. */
package/src/version.ts CHANGED
@@ -63,24 +63,33 @@ export function computeVersion({
63
63
  }): string {
64
64
  const named = process.env.DBOS__APPVERSION?.trim();
65
65
  if (named) return `${appName}@${named}`;
66
- const workflows = definitions
67
- .map(
68
- (wf) =>
69
- [
70
- wf.name,
71
- wf.run.toString(),
72
- opIdsOf(wf),
73
- // The input schema is parsed outside steps on replay, so a change to it must change the version.
74
- inputSchemaOf(wf),
75
- ] as const,
76
- )
77
- .toSorted(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
78
- const hash = createHash("sha256")
79
- .update(JSON.stringify([appName, RUNTIME_VERSION, STEP_LAYOUT, installedDbosVersion(), ...workflows]))
80
- .digest("hex");
81
- return `${appName}@${hash}`;
66
+ const workflows = definitions.map(codeOf).toSorted(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
67
+ return `${appName}@${sha256([appName, RUNTIME_VERSION, STEP_LAYOUT, installedDbosVersion(), ...workflows])}`;
82
68
  }
83
69
 
70
+ /**
71
+ * A hash of the built-in `change` workflow's own code, which each change run carries (its
72
+ * `changeCode` attribute) so a worker on another version of the app may take it over
73
+ * (`carryChangeRuns` in worker.ts): what `computeVersion` hashes of that one workflow, with
74
+ * `RUNTIME_VERSION`, `STEP_LAYOUT` and the installed DBOS version. Never the company's workflows
75
+ * nor `DBOS__APPVERSION`, so a merge that changes only the company's code leaves it as it is.
76
+ */
77
+ export function changeCodeOf(change: WorkflowDefinition<any, any>): string {
78
+ return sha256([RUNTIME_VERSION, STEP_LAYOUT, installedDbosVersion(), codeOf(change)]);
79
+ }
80
+
81
+ const sha256 = (parts: unknown[]) => createHash("sha256").update(JSON.stringify(parts)).digest("hex");
82
+
83
+ /** What the hashes read of a workflow: its name, the source of its `run`, its operations and its input schema. */
84
+ const codeOf = (wf: WorkflowDefinition<any, any>) =>
85
+ [
86
+ wf.name,
87
+ wf.run.toString(),
88
+ opIdsOf(wf),
89
+ // The input schema is parsed outside steps on replay, so a change to it must change the version.
90
+ inputSchemaOf(wf),
91
+ ] as const;
92
+
84
93
  /** The workflow's input schema as the hash sees it: its JSON Schema, or its text when zod can't convert it. */
85
94
  function inputSchemaOf(wf: WorkflowDefinition<any, any>): unknown {
86
95
  try {
package/src/worker.ts CHANGED
@@ -4,19 +4,19 @@ import { buildCtx, decideStart, isInfrastructureError, startHoldOf } from "./cal
4
4
  import { dbosStatusesOf } from "./client.ts";
5
5
  import { declaredWakeup, type ResolvedConfig, resolveConfig, type SanomaConfig } from "./config.ts";
6
6
  import { storedSecrets, watchCredentials } from "./credentials.ts";
7
- import { DBOS_DATABASE_OPTIONS, lazyPool, prepareDatabase } from "./db.ts";
7
+ import { type Db, DBOS_DATABASE_OPTIONS, lazyPool, prepareDatabase, stampVersion } from "./db.ts";
8
8
  import { deadlineRegistry, type Deadlines, idle, onOwnQueue, type SelfStop, stopWhenIdle } from "./deadlines.ts";
9
9
  import type { WorkflowDefinition } from "./define.ts";
10
10
  import { credentialsOf, unconfigured, unconfiguredSummary } from "./env.ts";
11
11
  import { errorInfo, parseOrThrow, PolicyDeniedError } from "./errors.ts";
12
12
  import { entry, ownedLedger, skipped, write, writeFailure } from "./ledger.ts";
13
- import { dbosLogger, quietIfSandbox, setLogLevel, warn } from "./log.ts";
13
+ import { dbosLogger, info, quietIfSandbox, setLogLevel, warn } from "./log.ts";
14
14
  import { lineStartsOf, readSource } from "./ast.ts";
15
15
  import { callsOf, outlineWithSource } from "./outline.ts";
16
16
  import { currentRun, type Run, type RunArgs, type WorkerState, type WorkflowOutline } from "./run.ts";
17
17
  import { applyScheduled, nextScheduledFire, type ScheduledStarter, scheduledStarter } from "./schedules.ts";
18
18
  import type { Source } from "./source.ts";
19
- import { approverLabel, errorMessage, WORKFLOW_ACTION } from "./shared.ts";
19
+ import { approverLabel, CHANGE_WORKFLOW, errorMessage, WORKFLOW_ACTION } from "./shared.ts";
20
20
 
21
21
  export interface Worker {
22
22
  /**
@@ -144,6 +144,12 @@ export async function startWorker(config: SanomaConfig, options: WorkerOptions =
144
144
  secrets: storedSecrets(() => secrets.db(), "sanoma"),
145
145
  workflows: resolved.workflows,
146
146
  outlines: new Map([...resolved.workflows].map(([name, wf]) => [name, outlineOf(wf)])),
147
+ // `registered` is filled below, before any run starts: every name of this config's is registered then.
148
+ startRun: async (name, runId, args) => {
149
+ const fn = registered.get(name) as unknown as ((args: RunArgs) => Promise<unknown>) | undefined;
150
+ if (!fn) throw new Error(`The worker has no workflow "${name}" to start`);
151
+ return DBOS.startWorkflow(fn, { workflowID: runId })(args);
152
+ },
147
153
  stopped: false,
148
154
  deadlines: deadlineRegistry(),
149
155
  };
@@ -216,6 +222,8 @@ export async function startWorker(config: SanomaConfig, options: WorkerOptions =
216
222
  );
217
223
  }
218
224
  }
225
+ // Change runs move to the latest version only: a rollback's worker, unless promoted, leaves them be.
226
+ if (isLatest) await carryChangeRuns(resolved, () => secrets.db());
219
227
  await DBOS.registerQueue(resolved.queueName);
220
228
  // The schedules are the latest version's: an older worker leaves them as the newer set them.
221
229
  if (isLatest && options.schedules !== false) {
@@ -282,6 +290,41 @@ function outlineOf(wf: WorkflowDefinition<any, any>): WorkflowOutline {
282
290
  return { file: outline.file, lineStarts: lineStartsOf(source!), calls: callsOf(outline.nodes) };
283
291
  }
284
292
 
293
+ /**
294
+ * Takes over the unfinished change runs of other versions whose change code is this worker's,
295
+ * keeping each run's id, history and unread messages: a change's own deploy restarts the worker on
296
+ * the merged code, a new version, which DBOS would never give the run to. Each is stamped with this
297
+ * version (`stampVersion`), and a PENDING one queued again on the app's queue (`resumeWorkflows`);
298
+ * one stopped between the two is PENDING on this version, which DBOS's recovery takes. Not
299
+ * `rewindWorkflow`, which takes only a cancelled run and deletes its unread messages. It assumes one
300
+ * live worker per database, as a deploy does: an older one still running such a run runs it on
301
+ * until it stops. A failure is warned about, and the worker starts.
302
+ */
303
+ async function carryChangeRuns({ appName, version, changeCode, queueName }: ResolvedConfig, db: () => Promise<Db>) {
304
+ try {
305
+ const runs = await DBOS.listWorkflows({
306
+ workflowName: CHANGE_WORKFLOW,
307
+ status: ["PENDING", "ENQUEUED"],
308
+ applicationName: appName,
309
+ attributes: { changeCode },
310
+ loadInput: false,
311
+ loadOutput: false,
312
+ });
313
+ const theirs = runs
314
+ .filter((r) => r.applicationVersion && r.applicationVersion !== version)
315
+ .map((r) => r.workflowID);
316
+ if (!theirs.length) return;
317
+ const carried = await stampVersion(await db(), theirs, version);
318
+ const pending = carried.filter((r) => r.pending).map((r) => r.id);
319
+ if (pending.length) await DBOS.resumeWorkflows(pending, { queueName });
320
+ if (carried.length) {
321
+ info(`carried ${carried.length} change run(s) onto version ${version}: ${carried.map((r) => r.id).join(", ")}`);
322
+ }
323
+ } catch (err) {
324
+ warn(`carrying the change runs of other versions onto ${version} failed, so they stay there: ${errorMessage(err)}`);
325
+ }
326
+ }
327
+
285
328
  /**
286
329
  * Warns about unfinished runs this worker will never pick up: started on another version of
287
330
  * the app, or queued on another queue (such as the single "sanoma" queue before queues were