@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/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. */
@@ -135,3 +180,121 @@ export async function resolveRevision(url: string, ref: string): Promise<string>
135
180
  }
136
181
  throw new DriverError(`${url} has no branch or tag ${ref}`, { retryable: false, vendorCode: "no_such_ref" });
137
182
  }
183
+
184
+ // A git host's side of a change: the pull request a branch is reviewed in, merged through and
185
+ // closed by, the `sanoma` commit status the review card links from, whether the host refuses a
186
+ // change to `main` that is not a merge, and the host's webhooks. A connector carries it as its
187
+ // remote half (`ConnectorSetup.remote`), the way a login or a model half is carried.
188
+
189
+ /** A repository on a host: `owner/name`. */
190
+ export interface RepoRef {
191
+ owner: string;
192
+ name: string;
193
+ }
194
+
195
+ /** A pull request (a merge request, on a host that calls it so), as a change follows it. */
196
+ export interface PullRequest {
197
+ number: number;
198
+ /** Its page on the host. */
199
+ url: string;
200
+ /** The branch it merges. */
201
+ branch: string;
202
+ /** The branch's commit it was read at. */
203
+ head: string;
204
+ state: "open" | "closed" | "merged";
205
+ /** The commit its merge made on the base branch, once merged. */
206
+ mergeSha?: string;
207
+ }
208
+
209
+ /**
210
+ * Whether the host refuses a change to a branch that does not follow the rules: `pullRequest`
211
+ * when a direct push is refused (every change is a merge), `check` when a merge needs the
212
+ * `sanoma` status to pass. `reason` says why neither can be read or held, such as a plan that
213
+ * has no rules.
214
+ */
215
+ export interface HostEnforcement {
216
+ pullRequest: boolean;
217
+ check: boolean;
218
+ reason?: string;
219
+ }
220
+
221
+ /**
222
+ * One repository on a host, opened by its remote half with the company's credentials, read when
223
+ * a call needs them; a half may keep a token for a few minutes (its README says how long), so a
224
+ * token stored later is used soon after. A failure is a `DriverError` (retryable as the host's
225
+ * status says).
226
+ */
227
+ export interface SourceHost {
228
+ /**
229
+ * A token that writes to the repository: the company's app's on the host when it is installed
230
+ * on the owner, else a person's token (`prefer: "token"` asks for that one first). It is never
231
+ * logged; a push signs in with it. A `DriverError` whose `vendorCode` is `no_token` when there is
232
+ * neither.
233
+ */
234
+ token(prefer?: "app" | "token"): Promise<string>;
235
+ /** The pull request of `branch`: the open one, else the latest; undefined when it has none. */
236
+ pullRequestFor(branch: string): Promise<PullRequest | undefined>;
237
+ pullRequest(number: number): Promise<PullRequest>;
238
+ /** Opens a pull request of `branch` into `base`; the open one when `branch` has one already. */
239
+ openPullRequest(request: { branch: string; base: string; title: string; body: string }): Promise<PullRequest>;
240
+ /**
241
+ * Squash-merges pull request `number` when its branch is still at `sha`, as one commit titled
242
+ * `title`. A `DriverError` whose `vendorCode` is `head_moved` when the branch moved on, and
243
+ * `not_mergeable` when the host refuses the merge (a conflict, a rule).
244
+ */
245
+ mergePullRequest(number: number, merge: { sha: string; method: "squash"; title?: string }): Promise<{ sha: string }>;
246
+ closePullRequest(number: number): Promise<void>;
247
+ /** Deletes `branch`; nothing when it is gone already. */
248
+ deleteBranch?(branch: string): Promise<void>;
249
+ /** Sets the `sanoma` status on commit `sha`, linking to `targetUrl`. */
250
+ setStatus(status: {
251
+ sha: string;
252
+ state: "pending" | "success" | "failure";
253
+ description: string;
254
+ targetUrl?: string;
255
+ }): Promise<void>;
256
+ /** What the host enforces on `base` (see `HostEnforcement`). */
257
+ enforcement(base: string): Promise<HostEnforcement>;
258
+ /** Uploads a PNG the pull request's comment shows; its URL. */
259
+ uploadImage?(png: Uint8Array, name: string): Promise<string>;
260
+ /** Writes the pull request's comment that carries `marker`, once: a second call edits it. */
261
+ upsertComment?(number: number, marker: string, body: string): Promise<void>;
262
+ }
263
+
264
+ /** What a host's webhook says happened: a branch pushed, or a pull request closed (merged or not). */
265
+ export type RemoteEvent =
266
+ | { kind: "pushed"; repo: RepoRef; branch: string; head: string }
267
+ | { kind: "closed"; repo: RepoRef; branch: string; number: number; merged: boolean; sha?: string };
268
+
269
+ /** What a remote half reads the company's credentials with, as a login or a model half reads them. */
270
+ export interface RemoteContext {
271
+ /** Secrets' values by name: the environment's first, then the stored credentials (`LoginContext.secrets`). */
272
+ secrets(names: readonly string[]): Promise<Map<string, string>>;
273
+ }
274
+
275
+ /** A connector's remote half (`ConnectorSetup.remote`): a git host's repositories, made once per resolved config. */
276
+ export interface RemoteHalf {
277
+ /** The repository a clone URL of this host names, or undefined for another host's. */
278
+ matches(url: string): RepoRef | undefined;
279
+ /** The repository `repo`, with the company's credentials. */
280
+ open(repo: RepoRef, context: RemoteContext): SourceHost;
281
+ /**
282
+ * The events a webhook request carries, once its signature is verified: throws for a missing or
283
+ * bad signature, and answers `[]` for an event the change workflow does not follow.
284
+ */
285
+ webhook?(request: Request, context: RemoteContext): Promise<RemoteEvent[]>;
286
+ /** The accounts the company's app on the host is installed on: where it acts with no person's token. */
287
+ owners?(context: RemoteContext): Promise<{ login: string; kind: "user" | "organization" }[]>;
288
+ }
289
+
290
+ /** The remote half among `remotes` (`ResolvedConfig.remotes`) whose host `url` is on, with its vendor and the repository. */
291
+ export function remoteFor(
292
+ remotes: ReadonlyMap<string, RemoteHalf>,
293
+ url: string,
294
+ ): { vendor: string; remote: RemoteHalf; repo: RepoRef } | undefined {
295
+ for (const [vendor, remote] of remotes) {
296
+ const repo = remote.matches(url);
297
+ if (repo) return { vendor, remote, repo };
298
+ }
299
+ return undefined;
300
+ }
package/src/supervisor.ts CHANGED
@@ -1,5 +1,4 @@
1
- import { resolveConfig, type SanomaConfig } from "./config.ts";
2
- import { shouldWake } from "./db.ts";
1
+ import type { SanomaConfig } from "./config.ts";
3
2
  import type { SelfStop } from "./deadlines.ts";
4
3
  import { warn } from "./log.ts";
5
4
  import { errorMessage } from "./shared.ts";
@@ -21,10 +20,11 @@ export interface Supervisor {
21
20
  */
22
21
  resume(): Promise<void>;
23
22
  /**
24
- * A wakeup's `GET /wake?generation=N`: `resume` when the worker stopped with `generation`
25
- * registered (`shouldWake`); a no-op for any other, and while one starts or runs.
23
+ * A wakeup's `GET /wake`: `resume` when the worker stopped itself; a no-op while one starts or
24
+ * runs. A wake that arrives late, twice or for a replaced registration starts a worker that
25
+ * finds nothing to do and stops again on its own check.
26
26
  */
27
- wake(generation: number): Promise<void>;
27
+ wake(): Promise<void>;
28
28
  /** Stops the worker running or starting, and starts none again. */
29
29
  stop(): Promise<void>;
30
30
  }
@@ -53,7 +53,6 @@ export async function superviseWorker(config: SanomaConfig, options: SupervisorO
53
53
  let closed = false;
54
54
  // A write while the worker starts or runs, since it began starting.
55
55
  let heard = false;
56
- let databaseUrl: string | undefined;
57
56
 
58
57
  const launch = async () => {
59
58
  heard = false;
@@ -64,7 +63,7 @@ export async function superviseWorker(config: SanomaConfig, options: SupervisorO
64
63
  const selfStopped = (stopped: SelfStop) => {
65
64
  worker = undefined;
66
65
  if (closed) return;
67
- // Resumed first, so `stopped` is false when `onStopped` (the exit rule) looks.
66
+ // Resumed first, so `stopped` is false when `onStopped` looks.
68
67
  if (heard) void supervisor.resume();
69
68
  onStopped?.(stopped);
70
69
  };
@@ -89,11 +88,7 @@ export async function superviseWorker(config: SanomaConfig, options: SupervisorO
89
88
  starting = undefined;
90
89
  }));
91
90
  },
92
- async wake(generation) {
93
- if (!supervisor.stopped) return;
94
- databaseUrl ??= resolveConfig(config).databaseUrl;
95
- if (await shouldWake(databaseUrl, generation)) await supervisor.resume();
96
- },
91
+ wake: () => (supervisor.stopped ? supervisor.resume() : Promise.resolve()),
97
92
  async stop() {
98
93
  closed = true;
99
94
  await starting;
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 {