@sanoma-ai/workflows 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. package/README.md +49 -15
  2. package/dist/apply.d.ts +2 -1
  3. package/dist/apply.js +29 -7
  4. package/dist/apply.js.map +1 -1
  5. package/dist/approvals.d.ts +27 -2
  6. package/dist/approvals.js +73 -9
  7. package/dist/approvals.js.map +1 -1
  8. package/dist/call.d.ts +3 -2
  9. package/dist/call.js +38 -30
  10. package/dist/call.js.map +1 -1
  11. package/dist/cedar.js +24 -9
  12. package/dist/cedar.js.map +1 -1
  13. package/dist/change.d.ts +219 -0
  14. package/dist/change.js +150 -0
  15. package/dist/change.js.map +1 -0
  16. package/dist/chats.d.ts +43 -0
  17. package/dist/chats.js +48 -0
  18. package/dist/chats.js.map +1 -0
  19. package/dist/client.d.ts +52 -6
  20. package/dist/client.js +88 -47
  21. package/dist/client.js.map +1 -1
  22. package/dist/config.d.ts +47 -5
  23. package/dist/config.js +71 -7
  24. package/dist/config.js.map +1 -1
  25. package/dist/database.d.ts +83 -0
  26. package/dist/database.js +92 -0
  27. package/dist/database.js.map +1 -0
  28. package/dist/db.d.ts +31 -1
  29. package/dist/db.js +89 -7
  30. package/dist/db.js.map +1 -1
  31. package/dist/deadlines.d.ts +88 -0
  32. package/dist/deadlines.js +171 -0
  33. package/dist/deadlines.js.map +1 -0
  34. package/dist/define.d.ts +25 -4
  35. package/dist/define.js +40 -0
  36. package/dist/define.js.map +1 -1
  37. package/dist/demo.d.ts +28 -0
  38. package/dist/demo.js +72 -0
  39. package/dist/demo.js.map +1 -0
  40. package/dist/describe.d.ts +6 -3
  41. package/dist/describe.js +11 -7
  42. package/dist/describe.js.map +1 -1
  43. package/dist/errors.d.ts +10 -1
  44. package/dist/errors.js +13 -0
  45. package/dist/errors.js.map +1 -1
  46. package/dist/fake.d.ts +8 -0
  47. package/dist/fake.js +11 -1
  48. package/dist/fake.js.map +1 -1
  49. package/dist/grid.d.ts +34 -0
  50. package/dist/grid.js +62 -0
  51. package/dist/grid.js.map +1 -0
  52. package/dist/host.d.ts +13 -2
  53. package/dist/host.js +2 -2
  54. package/dist/host.js.map +1 -1
  55. package/dist/index.d.ts +16 -8
  56. package/dist/index.js +12 -5
  57. package/dist/index.js.map +1 -1
  58. package/dist/ledger.d.ts +11 -2
  59. package/dist/ledger.js.map +1 -1
  60. package/dist/lint.js +1 -1
  61. package/dist/lint.js.map +1 -1
  62. package/dist/op.d.ts +14 -2
  63. package/dist/op.js.map +1 -1
  64. package/dist/policy.d.ts +7 -1
  65. package/dist/policy.js +2 -1
  66. package/dist/policy.js.map +1 -1
  67. package/dist/resource.d.ts +13 -1
  68. package/dist/resource.js +9 -1
  69. package/dist/resource.js.map +1 -1
  70. package/dist/resources.d.ts +2 -0
  71. package/dist/resources.js +10 -1
  72. package/dist/resources.js.map +1 -1
  73. package/dist/run.d.ts +5 -0
  74. package/dist/run.js.map +1 -1
  75. package/dist/sandbox.d.ts +38 -10
  76. package/dist/sandbox.js +66 -35
  77. package/dist/sandbox.js.map +1 -1
  78. package/dist/scenario.d.ts +18 -1
  79. package/dist/scenario.js +77 -27
  80. package/dist/scenario.js.map +1 -1
  81. package/dist/scenarios.d.ts +10 -1
  82. package/dist/scenarios.js +19 -4
  83. package/dist/scenarios.js.map +1 -1
  84. package/dist/schedules.d.ts +66 -0
  85. package/dist/schedules.js +158 -0
  86. package/dist/schedules.js.map +1 -0
  87. package/dist/schema.d.ts +6 -0
  88. package/dist/schema.js +13 -3
  89. package/dist/schema.js.map +1 -1
  90. package/dist/shared.d.ts +38 -2
  91. package/dist/shared.js +50 -4
  92. package/dist/shared.js.map +1 -1
  93. package/dist/sites.d.ts +38 -1
  94. package/dist/sites.js +153 -26
  95. package/dist/sites.js.map +1 -1
  96. package/dist/source.d.ts +61 -2
  97. package/dist/source.js.map +1 -1
  98. package/dist/supervisor.d.ts +38 -0
  99. package/dist/supervisor.js +77 -0
  100. package/dist/supervisor.js.map +1 -0
  101. package/dist/version.d.ts +3 -1
  102. package/dist/version.js +3 -1
  103. package/dist/version.js.map +1 -1
  104. package/dist/wakeup-service.d.ts +83 -0
  105. package/dist/wakeup-service.js +421 -0
  106. package/dist/wakeup-service.js.map +1 -0
  107. package/dist/wakeup.d.ts +84 -0
  108. package/dist/wakeup.js +87 -0
  109. package/dist/wakeup.js.map +1 -0
  110. package/dist/worker.d.ts +39 -1
  111. package/dist/worker.js +130 -39
  112. package/dist/worker.js.map +1 -1
  113. package/oxlint.json +2 -1
  114. package/package.json +8 -2
  115. package/src/apply.ts +25 -6
  116. package/src/approvals.ts +89 -16
  117. package/src/call.ts +40 -29
  118. package/src/cedar.ts +24 -6
  119. package/src/change.ts +222 -0
  120. package/src/chats.ts +114 -0
  121. package/src/client.ts +145 -55
  122. package/src/config.ts +110 -13
  123. package/src/database.ts +152 -0
  124. package/src/db.ts +108 -9
  125. package/src/deadlines.ts +234 -0
  126. package/src/define.ts +71 -4
  127. package/src/demo.ts +87 -0
  128. package/src/describe.ts +19 -7
  129. package/src/errors.ts +14 -0
  130. package/src/fake.ts +12 -0
  131. package/src/grid.ts +100 -0
  132. package/src/host.ts +13 -4
  133. package/src/index.ts +62 -3
  134. package/src/ledger.ts +15 -3
  135. package/src/lint.ts +1 -1
  136. package/src/op.ts +15 -2
  137. package/src/policy.ts +7 -1
  138. package/src/resource.ts +22 -3
  139. package/src/resources.ts +12 -1
  140. package/src/run.ts +5 -0
  141. package/src/sandbox.ts +97 -37
  142. package/src/scenario.ts +97 -28
  143. package/src/scenarios.ts +21 -5
  144. package/src/schedules.ts +206 -0
  145. package/src/schema.ts +16 -3
  146. package/src/shared.ts +62 -4
  147. package/src/sites.ts +198 -33
  148. package/src/source.ts +58 -3
  149. package/src/supervisor.ts +107 -0
  150. package/src/version.ts +3 -1
  151. package/src/wakeup-service.ts +511 -0
  152. package/src/wakeup.ts +150 -0
  153. package/src/worker.ts +181 -44
package/src/shared.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  import type { RunSummary } from "./client.ts";
2
2
  import type { CredentialStatus } from "./env.ts";
3
3
  import type { ApprovalState, Approver, Principal } from "./define.ts";
4
+ import type { OpEntry, WorkflowEntry } from "./describe.ts";
4
5
  import type { LedgerRecord } from "./ledger.ts";
5
6
  import type { Effect } from "./op.ts";
7
+ import type { PreviewRequest } from "./policy.ts";
6
8
  import type { DeclaredResource, ResourceProblem } from "./resources.ts";
7
9
  import type { OutlineNode } from "./outline.ts";
8
10
 
@@ -20,6 +22,7 @@ export type { DeclaredResource, ResourceProblem } from "./resources.ts";
20
22
  export type { DriftField, DriftReport, DriftResult, DriftStatus } from "./drift.ts";
21
23
  export type { ApplyPlan, ApplyReport, ApplyResult, PlanAnswer, PlanStep } from "./apply.ts";
22
24
  export type { CredentialStatus } from "./env.ts";
25
+ export type { ChangeEvent, ChangeFacts, ChangeReport, ChangeState } from "./change.ts";
23
26
 
24
27
  // What a UI in the browser may use from the runtime, besides types: `@sanoma-ai/workflows/shared`.
25
28
  // Nothing here imports DBOS, Node or zod, so a browser bundle can carry it; the runtime uses the
@@ -52,9 +55,34 @@ export const EFFECTS = ["read", "write", "publish", "send", "money", "access"] a
52
55
  /** What a workflow start's action id starts with, as a policy names it: `workflow:<name>`. */
53
56
  export const WORKFLOW_ACTION = "workflow:";
54
57
 
58
+ /**
59
+ * What to ask the policy ahead of a run of `workflow` (`Policy.preview`, the principal aside): its
60
+ * start, then each operation it may call, with its effect, in the run (`inRun`). The run form's
61
+ * will-ask line asks it for the person; the Permissions grid (`permissionsGrid`) for each group.
62
+ */
63
+ export function previewRequests(
64
+ workflow: Pick<WorkflowEntry, "name" | "ops">,
65
+ ops: ReadonlyMap<string, Pick<OpEntry, "effect">>,
66
+ ): Omit<PreviewRequest, "principal">[] {
67
+ return [
68
+ { action: { id: `${WORKFLOW_ACTION}${workflow.name}` } },
69
+ ...workflow.ops.map((id) => {
70
+ const effect = ops.get(id)?.effect;
71
+ return { action: effect === undefined ? { id } : { id, effect }, inRun: true };
72
+ }),
73
+ ];
74
+ }
75
+
55
76
  /** A site's folder name under `sites/`, its URL segment: what the app mounts and `readSites` reads. */
56
77
  export const SITE_NAME = /^[a-z][a-z0-9-]*$/;
57
78
 
79
+ /**
80
+ * A public site's language, as `defineSite({ locales })` names it: a BCP 47 language tag of a
81
+ * language and subtags (`en`, `fr`, `pt-BR`), which is also its URL segment (`/pt-BR/…`) and a
82
+ * content file's suffix (`<slug>.pt-BR.md`).
83
+ */
84
+ export const LOCALE = /^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$/;
85
+
58
86
  /** A public site's domain: a host name of two labels or more, lowercase (`sanoma.example`). */
59
87
  export const DOMAIN_NAME = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/;
60
88
 
@@ -177,8 +205,30 @@ function checkField(where: string, field: string, kind: unknown): void {
177
205
  */
178
206
  export const CALL_WORKFLOW = "call";
179
207
 
180
- /** The workflows the runtime adds itself, which a config's own may not be named: drift, apply and call. */
181
- export const BUILT_IN_WORKFLOWS: ReadonlySet<string> = new Set([DRIFT_WORKFLOW, APPLY_WORKFLOW, CALL_WORKFLOW]);
208
+ /**
209
+ * The built-in change workflow's name: the life of a change to the company's repository, from its
210
+ * pull request to its merge. The only workflow that may call `sanoma.change.*`.
211
+ */
212
+ export const CHANGE_WORKFLOW = "change";
213
+
214
+ /** Opens a change: its draft's branch pushed and a pull request opened for it. */
215
+ export const CHANGE_OPEN = "sanoma.change.open";
216
+ /** Reports Sanoma's review of a change to its host (the `sanoma` commit status). */
217
+ export const CHANGE_REVIEW = "sanoma.change.review";
218
+ /** Merges a change; its resource is the `Sanoma::Change`, whose attributes are its facts (`ChangeFacts`). */
219
+ export const CHANGE_MERGE = "sanoma.change.merge";
220
+ /** Closes a change without merging it. */
221
+ export const CHANGE_CLOSE = "sanoma.change.close";
222
+ /** The DBOS event a change run publishes its `ChangeState` on. */
223
+ export const CHANGE_EVENT = "change";
224
+
225
+ /** The workflows the runtime adds itself, which a config's own may not be named: drift, apply, call and change. */
226
+ export const BUILT_IN_WORKFLOWS: ReadonlySet<string> = new Set([
227
+ DRIFT_WORKFLOW,
228
+ APPLY_WORKFLOW,
229
+ CALL_WORKFLOW,
230
+ CHANGE_WORKFLOW,
231
+ ]);
182
232
 
183
233
  /**
184
234
  * Every run status, in the order a run may pass through them: waiting on the queue, running,
@@ -270,8 +320,12 @@ export function visitorPrincipal(login: string, subject: string): Principal {
270
320
  /** Who a public page runs as when nobody is signed in. */
271
321
  export const ANONYMOUS_PRINCIPAL: Principal = Object.freeze({ id: ANONYMOUS, groups: [] });
272
322
 
273
- /** The runtime's own agents (scenarios, the host check, scheduled runs), declared whether a data file names them or not. */
274
- export const RUNTIME_AGENTS: readonly string[] = ["scenarios", "host-check", "schedule"];
323
+ /** The runtime's agent that registers the instance's wakeup (`register`'s `CallContext.by`). */
324
+ export const WAKEUP_AGENT = "wakeup";
325
+ /** The runtime's agent a scheduled run is started by (`trigger: { schedule }`): the policy decides its starts. */
326
+ export const SCHEDULE_AGENT = "schedule";
327
+ /** The runtime's own agents (scenarios, the host check, scheduled runs, the wakeup's registration), declared whether a data file names them or not. */
328
+ export const RUNTIME_AGENTS: readonly string[] = ["scenarios", "host-check", SCHEDULE_AGENT, WAKEUP_AGENT];
275
329
 
276
330
  /** The declared resources of one of the runtime's people types, by `desired`. */
277
331
  const peopleOfType = <T>(resources: readonly PeopleResource[], type: "person" | "agent" | "group") =>
@@ -395,6 +449,10 @@ export function problemLine(p: { file?: string; line?: number; column?: number;
395
449
  return p.line === undefined ? `${p.file}: ${p.message}` : `${p.file}:${p.line}:${p.column ?? 1}: ${p.message}`;
396
450
  }
397
451
 
452
+ /** A warning as one line, as `problemLine` with `warning: ` before its message: what is worth fixing but not wrong. */
453
+ export const warningLine = (p: Parameters<typeof problemLine>[0]): string =>
454
+ problemLine({ ...p, message: `warning: ${p.message}` });
455
+
398
456
  /**
399
457
  * Whether a host is this machine: `localhost`, `127.x.x.x` or `::1` (bare, or bracketed as a URL's
400
458
  * `hostname` has it). The one rule for what the app listens on and answers to, and for where the
package/src/sites.ts CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  apiNameOf,
13
13
  BUILT_IN_WORKFLOWS,
14
14
  DATA_VENDOR,
15
+ LOCALE,
15
16
  SITE_NAME,
16
17
  VISITORS,
17
18
  WORKFLOW_ACTION,
@@ -51,6 +52,18 @@ export interface SiteCall {
51
52
  page?: string;
52
53
  }
53
54
 
55
+ /** A page of a site: a route file's path, and for a page per content entry the kind it reads. */
56
+ export interface SitePage {
57
+ /** As `SiteCall.page` has it: `/sites/www`, `/sites/www/blog/$slug`. */
58
+ path: string;
59
+ /**
60
+ * For a path with one parameter (`$slug`), the content kind whose entries are its pages: the one
61
+ * kind its file reads by that parameter, `content.<kind>.get(params.slug)` (or `slug`,
62
+ * destructured) with `content` from `sanoma/site`.
63
+ */
64
+ content?: string;
65
+ }
66
+
54
67
  /** A site, `sites/<name>/`, and what it calls. */
55
68
  export interface SiteEntry {
56
69
  /** Its folder's name, where it is mounted: `/sites/<name>`. */
@@ -61,8 +74,15 @@ export interface SiteEntry {
61
74
  public?: true;
62
75
  /** `defineSite({ login })`, the connector whose login half signs visitors in, as written: one that names none is among `problems`. */
63
76
  login?: string;
77
+ /**
78
+ * `defineSite({ locales })`, a public site's languages, the first its default: kept only when
79
+ * every one is a `LOCALE`, written once, on a public site with no login; else a problem.
80
+ */
81
+ locales?: string[];
64
82
  /** The host a public site answers on: set by `startApp` from its `domains`, never by `readSites`. */
65
83
  domain?: string;
84
+ /** Its pages, by path: one for each path its `routes/` files give (a layout and its index are one). */
85
+ pages: SitePage[];
66
86
  /** Its calls, by file and then in the file's order. */
67
87
  calls: SiteCall[];
68
88
  /**
@@ -71,6 +91,11 @@ export interface SiteEntry {
71
91
  * make, at its file; then what is wrong in its `site.ts`.
72
92
  */
73
93
  problems: (LintProblem & { file: string })[];
94
+ /**
95
+ * On a public site, each page with one parameter whose file reads content the sitemap cannot
96
+ * tie to it (`SitePage.content`), so the sitemap lists none of its pages: at the first read.
97
+ */
98
+ warnings: (LintProblem & { file: string })[];
74
99
  }
75
100
 
76
101
  /** Where a site's code is: its pages and its components. */
@@ -78,6 +103,8 @@ const SITE_DIRS = ["routes", "components"];
78
103
  /** Where `api`, `RunForm` and `defineSite` come from: `sanoma/site`, which re-exports the app's. */
79
104
  const SITE_SOURCES: ReadonlySet<string> = new Set(["sanoma/site", "@sanoma-ai/app/site"]);
80
105
  const KIND_NAMES = { workflow: "workflow", collection: "collection", op: "operation" } as const;
106
+ /** What a route's loader and head name its path's parameters by. */
107
+ const PARAMS: ReadonlySet<string> = new Set(["params"]);
81
108
  /** What makes a route from its id, whose first argument is the id. */
82
109
  const ROUTE_MAKERS: ReadonlySet<string> = new Set(["createFileRoute", "createLazyFileRoute"]);
83
110
 
@@ -85,7 +112,7 @@ const ROUTE_MAKERS: ReadonlySet<string> = new Set(["createFileRoute", "createLaz
85
112
  * The sites under `root`: each folder of `sites/` with a `routes/` whose name is a URL segment,
86
113
  * by name (as `@sanoma-ai/app/build`'s `sitesOf` mounts them). Reads `routes/**` and
87
114
  * `components/**` (`.ts` and `.tsx`, less tests and declarations) of each with oxc, never running
88
- * them: each `<RunForm workflow="…" />` and each use of `api`, both imported from `sanoma/site`,
115
+ * them: each page (`SitePage`, from a route's id), each `<RunForm workflow="…" />` and each use of `api`, both imported from `sanoma/site`,
89
116
  * called or passed on (`queryFn: api.data.leads.list`):
90
117
  * `api.<name>` and `api.<name>.<method>` are a workflow, `api.data.<collection>.<op>` a collection,
91
118
  * and `api.<vendor>.<resource>.<op>` an operation. A name that is not written out (a variable, a
@@ -107,7 +134,6 @@ export function readSites(
107
134
  ): SiteEntry[] {
108
135
  if (root === undefined) return [];
109
136
  const dir = join(root, "sites");
110
- if (!isDirectory(dir)) return [];
111
137
  const workflows = new Set(description.workflows.map((wf) => wf.name));
112
138
  const collections = new Set(description.collections.map((c) => c.name));
113
139
  const ops = new Map(description.ops.map((op) => [op.id, op]));
@@ -167,39 +193,105 @@ export function readSites(
167
193
  return { call: { kind, target, via, ...at }, ...(problem && { problem }) };
168
194
  };
169
195
 
170
- return readdirSync(dir, { withFileTypes: true })
171
- .filter((d) => d.isDirectory() && SITE_NAME.test(d.name) && existsSync(join(dir, d.name, "routes")))
172
- .map((d) => d.name)
196
+ return siteNames(dir).map((name) => {
197
+ const read = readSiteFiles(root, name);
198
+ const resolved = read.flatMap(({ uses }) => uses.map(resolve));
199
+ const pages = pagesOf(read);
200
+ const siteFile = `sites/${name}/site.ts`;
201
+ const { problems: optionProblems, ...options } = siteOptionsOf(join(root, siteFile), connectors);
202
+ const calls = resolved.map((r) => r.call);
203
+ const problems = [
204
+ ...resolved.flatMap(({ call, problem }) => {
205
+ const message = problem ?? (options.public ? refused(call, options.login) : undefined);
206
+ return message === undefined ? [] : [{ file: call.file, line: call.line, column: call.column, message }];
207
+ }),
208
+ ...optionProblems.map((p) => ({ file: siteFile, ...p })),
209
+ ];
210
+ const sorted = [...pages.keys()].toSorted().map((path) => pages.get(path)!);
211
+ // A page's file it warns of, unless another of its files (a lazy one, its layout) gives its kind.
212
+ const warnings = options.public
213
+ ? read.flatMap(({ route, warning }) => (warning && !pages.get(route!.path)?.content ? [warning] : []))
214
+ : [];
215
+ return { name, ...options, pages: sorted, calls, problems, warnings };
216
+ });
217
+ }
218
+
219
+ /** A site's pages, by path, from its files: a layout's and its index's (one path) merged. */
220
+ function pagesOf(read: readonly { route?: SitePage }[]): Map<string, SitePage> {
221
+ const pages = new Map<string, SitePage>();
222
+ for (const { route } of read) {
223
+ if (route) pages.set(route.path, { ...pages.get(route.path), ...route });
224
+ }
225
+ return pages;
226
+ }
227
+
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))
173
231
  .toSorted()
174
- .map((name) => {
175
- const files = SITE_DIRS.flatMap((sub) => filesUnder(join(dir, name, sub), isSource)).toSorted();
176
- const resolved = files.flatMap((path) => {
177
- const file = readSource(path);
178
- return typeof file === "string" ? [] : usesIn(file, relative(root, path).split(sep).join("/")).map(resolve);
179
- });
180
- const siteFile = `sites/${name}/site.ts`;
181
- const { problems: optionProblems, ...options } = siteOptionsOf(join(root, siteFile), connectors);
182
- const calls = resolved.map((r) => r.call);
183
- const problems = [
184
- ...resolved.flatMap(({ call, problem }) => {
185
- const message = problem ?? (options.public ? refused(call, options.login) : undefined);
186
- return message === undefined ? [] : [{ file: call.file, line: call.line, column: call.column, message }];
187
- }),
188
- ...optionProblems.map((p) => ({ file: siteFile, ...p })),
189
- ];
190
- return { name, ...options, calls, problems };
232
+ .flatMap((path) => {
233
+ const file = readSource(path);
234
+ return typeof file === "string" ? [] : [readSiteFile(file, relative(root, path).split(sep).join("/"))];
191
235
  });
236
+
237
+ /**
238
+ * Each site's pages under `root` whose entries are a content kind's (`SitePage.content`), path to
239
+ * kind: what the app's build gives its router (`virtual:sanoma/locales`), so a page lists only the
240
+ * languages its entry is in. Read as `readSites` reads them, never run.
241
+ */
242
+ export function siteContentPages(root: string): Record<string, string> {
243
+ return Object.fromEntries(
244
+ siteNames(join(root, "sites")).flatMap((name) =>
245
+ [...pagesOf(readSiteFiles(root, name)).values()].flatMap(({ path, content }) =>
246
+ content ? [[path, content]] : [],
247
+ ),
248
+ ),
249
+ );
250
+ }
251
+
252
+ /**
253
+ * Each site's languages (`defineSite({ locales })`, the first its default) under `root`, by name,
254
+ * for the sites whose `site.ts` gives them without a problem there: what the app's build gives
255
+ * its router (`virtual:sanoma/locales`). Read as `readSites` reads them, never run.
256
+ */
257
+ export function siteLocales(root: string): Record<string, string[]> {
258
+ const none = new Set<string>();
259
+ return Object.fromEntries(
260
+ siteNames(join(root, "sites")).flatMap((name) => {
261
+ const { locales } = siteOptionsOf(join(root, "sites", name, "site.ts"), { vendors: none, logins: none });
262
+ return locales ? [[name, locales]] : [];
263
+ }),
264
+ );
192
265
  }
193
266
 
267
+ /** The sites in `dir`: each folder with a `routes/` whose name is a URL segment, sorted. */
268
+ const siteNames = (dir: string): string[] =>
269
+ isDirectory(dir)
270
+ ? readdirSync(dir, { withFileTypes: true })
271
+ .filter((d) => d.isDirectory() && SITE_NAME.test(d.name) && existsSync(join(dir, d.name, "routes")))
272
+ .map((d) => d.name)
273
+ .toSorted()
274
+ : [];
275
+
194
276
  /** A use of `api` or a `RunForm`, as written: the name it gives, before it is resolved. */
195
277
  type Written = Omit<SiteCall, "target"> & { name: string };
196
278
 
197
- /** The uses in one file, in its order. */
198
- function usesIn({ program, at }: SourceFile, file: string): Written[] {
279
+ /**
280
+ * What one file holds: its uses, in its order, and the page it is, from its route's id, with the
281
+ * content kind whose entry its one parameter names (`SitePage.content`). A file under `routes/` is
282
+ * read whole; any other only when it imports `api` or `RunForm`.
283
+ */
284
+ function readSiteFile(
285
+ { program, at }: SourceFile,
286
+ file: string,
287
+ ): { uses: Written[]; route?: SitePage; warning?: LintProblem & { file: string } } {
199
288
  const api = importedAs(program, SITE_SOURCES, "api");
200
289
  const runForm = importedAs(program, SITE_SOURCES, "RunForm");
201
- if (!api.size && !runForm.size) return [];
290
+ const content = importedAs(program, SITE_SOURCES, "content");
291
+ if (!api.size && !runForm.size && !file.includes("/routes/")) return { uses: [] };
202
292
  const found: Omit<Written, "page">[] = [];
293
+ /** Each `content.<kind>.get(arg)`: its kind, its argument and where it is. */
294
+ const gets: { kind: string; arg: Node; start: number }[] = [];
203
295
  let page: string | undefined;
204
296
  const visit = (node: Node) => {
205
297
  if (!node) return;
@@ -207,12 +299,14 @@ function usesIn({ program, at }: SourceFile, file: string): Written[] {
207
299
  const named = apiPath(node, api);
208
300
  // The outermost member of a path on `api`: what it names, called or not; its inner members are not visited.
209
301
  if (named) return void found.push({ ...named, via: "api", file, ...at(node.start) });
210
- } else if (node.type === "CallExpression" && page === undefined) {
302
+ } else if (node.type === "CallExpression") {
211
303
  const callee = unwrap(node.callee);
212
- if (callee?.type === "Identifier" && ROUTE_MAKERS.has(callee.name)) {
304
+ if (callee?.type === "Identifier" && ROUTE_MAKERS.has(callee.name) && page === undefined) {
213
305
  const id = stringValue(node.arguments[0]);
214
306
  if (id !== undefined) page = pageOf(id);
215
307
  }
308
+ const kind = contentKind(callee, content);
309
+ if (kind !== undefined) gets.push({ kind, arg: unwrap(node.arguments[0]), start: node.start });
216
310
  } else if (node.type === "JSXOpeningElement" && node.name.type === "JSXIdentifier" && runForm.has(node.name.name)) {
217
311
  const attribute = node.attributes.find(
218
312
  (a: Node) => a.type === "JSXAttribute" && a.name.type === "JSXIdentifier" && a.name.name === "workflow",
@@ -224,11 +318,46 @@ function usesIn({ program, at }: SourceFile, file: string): Written[] {
224
318
  for (const child of childrenOf(node)) visit(child);
225
319
  };
226
320
  visit(program);
227
- return found.map((use) => (page === undefined ? use : { ...use, page }));
321
+ const uses = found.map((use) => (page === undefined ? use : { ...use, page }));
322
+ if (page === undefined) return { uses };
323
+ // The one parameter's name (not a splat's), and the kinds whose `get` is given it (`params.slug`, or `slug` destructured).
324
+ const params = page.split("/").filter((segment) => segment.startsWith("$"));
325
+ if (params.length !== 1 || params[0] === "$") return { uses, route: { path: page } };
326
+ const param = params[0]!.slice(1);
327
+ const kinds = new Set(
328
+ gets
329
+ .filter(({ arg }) =>
330
+ arg?.type === "Identifier"
331
+ ? arg.name === param
332
+ : arg?.type === "MemberExpression" && memberPath(arg, PARAMS)?.join(".") === param,
333
+ )
334
+ .map(({ kind }) => kind),
335
+ );
336
+ if (kinds.size === 1) return { uses, route: { path: page, content: [...kinds][0]! } };
337
+ // A read by the parameter in a form the sitemap does not follow (`params.id.toLowerCase()`, two kinds).
338
+ const unfollowed = gets.find(({ arg }) => mentions(arg, param));
339
+ if (!unfollowed) return { uses, route: { path: page } };
340
+ const message =
341
+ `the sitemap lists none of this page's entries: it reads content by the page's parameter in a way it cannot follow. ` +
342
+ `Read one kind by it as written, \`content.<kind>.get(params.${param})\` (or \`${param}\` destructured)`;
343
+ return { uses, route: { path: page }, warning: { file, ...at(unfollowed.start), message } };
228
344
  }
229
345
 
230
- /** What a member path on `api` names; undefined for a path on anything else, a name not written out, or no name. */
231
- function apiPath(member: Node, api: ReadonlySet<string>): Pick<Written, "kind" | "name" | "method"> | undefined {
346
+ /** Whether `node` reads the parameter `param` anywhere in it: `params.<param>`, or `param` destructured (not another parameter, nor a property's name, `post.slug` or `{ slug: x }`). */
347
+ const mentions = (node: Node, param: string): boolean => {
348
+ if (!node) return false;
349
+ if (node.type === "Identifier") return node.name === param;
350
+ if (node.type === "Property" && !node.computed) return mentions(node.value, param);
351
+ if (node.type === "MemberExpression") {
352
+ const path = memberPath(node, PARAMS);
353
+ if (path) return path[0] === param;
354
+ if (!node.computed) return mentions(node.object, param);
355
+ }
356
+ return childrenOf(node).some((child) => mentions(child, param));
357
+ };
358
+
359
+ /** The names of a member path on one of `roots` (`api.data.leads.list` is `data`, `leads`, `list`); undefined for a path on anything else or a name not written out. */
360
+ function memberPath(member: Node, roots: ReadonlySet<string>): string[] | undefined {
232
361
  const path: string[] = [];
233
362
  let node = member;
234
363
  while (node?.type === "MemberExpression") {
@@ -237,7 +366,19 @@ function apiPath(member: Node, api: ReadonlySet<string>): Pick<Written, "kind" |
237
366
  path.unshift(key);
238
367
  node = unwrap(node.object);
239
368
  }
240
- if (node?.type !== "Identifier" || !api.has(node.name)) return undefined;
369
+ return node?.type === "Identifier" && roots.has(node.name) ? path : undefined;
370
+ }
371
+
372
+ /** The kind of `content.<kind>.get`, a callee on `content` imported from `sanoma/site`; undefined for any other. */
373
+ function contentKind(callee: Node, content: ReadonlySet<string>): string | undefined {
374
+ const path = callee?.type === "MemberExpression" ? memberPath(callee, content) : undefined;
375
+ return path?.length === 2 && path[1] === "get" ? path[0] : undefined;
376
+ }
377
+
378
+ /** What a member path on `api` names; undefined for a path on anything else, a name not written out, or no name. */
379
+ function apiPath(member: Node, api: ReadonlySet<string>): Pick<Written, "kind" | "name" | "method"> | undefined {
380
+ const path = memberPath(member, api);
381
+ if (!path?.length) return undefined;
241
382
  const [first, second, third] = path as [string, ...string[]];
242
383
  if (first === DATA_VENDOR)
243
384
  return path.length === 3 ? { kind: "collection", name: second!, method: third } : undefined;
@@ -273,13 +414,14 @@ const unread = (what: string, as: string) => `${what} is read without running si
273
414
  /**
274
415
  * What `site.ts` says of its site, read without running it, and what is wrong there: its default
275
416
  * export must be `defineSite({ … })` written out, so it can be read at all; then `title` when it is
276
- * a string, `public` and `login`, which must be literals. `login` is kept as written; one on a
417
+ * a string, `public`, `login` and `locales`, which must be literals. `login` is kept as written; one on a
277
418
  * site that is not public, or naming no connector of the config with a login half, is a problem.
419
+ * `locales` is kept only when it has no problem.
278
420
  */
279
421
  function siteOptionsOf(
280
422
  path: string,
281
423
  connectors: SiteConnectors,
282
- ): Pick<SiteEntry, "title" | "public" | "login"> & { problems: LintProblem[] } {
424
+ ): Pick<SiteEntry, "title" | "public" | "login" | "locales"> & { problems: LintProblem[] } {
283
425
  const file = readSource(path);
284
426
  if (typeof file === "string") return { problems: [] };
285
427
  const problems: LintProblem[] = [...file.problems];
@@ -329,10 +471,33 @@ function siteOptionsOf(
329
471
  };
330
472
  const message = loginAt && loginProblem();
331
473
  if (message) problem(loginAt, message);
474
+
475
+ const localesAt = property("locales");
476
+ const list = unwrap(localesAt?.value);
477
+ // A hole or a spread is no string.
478
+ const tags: (string | undefined)[] | undefined =
479
+ list?.type === "ArrayExpression" ? list.elements.map((e: Node) => stringValue(e)) : undefined;
480
+ const locales = tags?.every((tag): tag is string => tag !== undefined) ? tags : undefined;
481
+ const localesProblem = (): string | undefined => {
482
+ if (locales === undefined) return unread("`locales`", "an array of strings");
483
+ if (!locales.length) return "`locales` names a site's languages, its default first: give one or more, or remove it";
484
+ const bad = locales.find((locale) => !LOCALE.test(locale));
485
+ if (bad !== undefined) return `"${bad}" is not a language tag such as "en", "fr" or "pt-BR"`;
486
+ const twice = locales.find((locale, i) => locales.indexOf(locale) !== i);
487
+ if (twice !== undefined) return `"${twice}" is in \`locales\` twice`;
488
+ // When `public` is what is wrong, it is said there.
489
+ if ((!isPublic && !publicUnread) || loginAt) {
490
+ return "`locales` are for a public site with no login: add `public: true`, or remove `locales`";
491
+ }
492
+ return undefined;
493
+ };
494
+ const localesMessage = localesAt && localesProblem();
495
+ if (localesMessage) problem(localesAt, localesMessage);
332
496
  return {
333
497
  ...(title !== undefined && { title }),
334
498
  ...(isPublic && { public: true as const }),
335
499
  ...(login !== undefined && { login }),
500
+ ...(localesAt && !localesMessage && isPublic && !loginAt && { locales }),
336
501
  problems,
337
502
  };
338
503
  }
package/src/source.ts CHANGED
@@ -1,12 +1,14 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
3
  import { z } from "zod";
4
+ import type { PermissionsGrid } from "./grid.ts";
4
5
  import { defineConnector, defineDriver, DriverError } from "./op.ts";
5
6
 
6
7
  // Where a company's files live, and how a change to them is recorded: a git repository on this
7
8
  // machine, or one a host cloned. The runtime's writers (the app's Adopt, apply's lockfile) change
8
9
  // files under `root`, then commit them through it. The launcher makes one; a config without one
9
- // writes the files and commits nothing.
10
+ // writes the files and commits nothing. A writer that must leave the live checkout alone writes
11
+ // in a draft instead: a checkout of its own, on a branch of its own.
10
12
 
11
13
  /** A company's repository, as the runtime writes to it. */
12
14
  export interface Source {
@@ -21,10 +23,63 @@ export interface Source {
21
23
  message: string;
22
24
  author: string;
23
25
  }): { revision: string } | undefined | Promise<{ revision: string } | undefined>;
24
- /** The revision `ref` names, the checked-out one by default. */
26
+ /** The revision `ref` names, the checked-out one by default (a draft's by its `branch`). */
25
27
  head(ref?: string): string | Promise<string>;
26
- /** Pushes `ref` to the repository at `url`, signed in with `token`. */
28
+ /** Pushes `ref` to the repository at `url`, signed in with `token` (a draft by its `branch`). */
27
29
  push?(target: { url: string; token: string; ref: string }): void | Promise<void>;
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.
33
+ */
34
+ draft?(id: string): Promise<Draft>;
35
+ /** The open drafts' ids, sorted. */
36
+ drafts?(): Promise<string[]>;
37
+ /** What the draft `id` has committed since its branch left the checked-out one (read from the branch alone). */
38
+ diff?(id: string): Promise<DraftDiff>;
39
+ /** Closes the draft `id`: its checkout removed and its branch deleted, whatever it held; nothing when it is gone. */
40
+ closeDraft?(id: string): Promise<void>;
41
+ /**
42
+ * `sanoma check` on the draft `id`, as a child in its checkout, one at a time in the process. A
43
+ * check that gives no result (a config that does not load, no install, a timeout) is a result
44
+ * of one problem saying why.
45
+ */
46
+ check?(id: string): Promise<CheckResult>;
47
+ /** The URL of the repository this one was cloned from (`origin`); undefined when it has none. */
48
+ remoteUrl?(): Promise<string | undefined>;
49
+ /**
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.
53
+ */
54
+ follow?(target: { url: string; token: string; ref: string }): Promise<{ revision: string } | { skipped: string }>;
55
+ }
56
+
57
+ /**
58
+ * A draft: a checkout of its own, at `root`, on `branch`, which `commit` records in as
59
+ * `Source.commit` does in its root. Its `node_modules` may be the root's own install, linked in:
60
+ * a writer never writes under it.
61
+ */
62
+ export interface Draft {
63
+ branch: string;
64
+ root: string;
65
+ commit: Source["commit"];
66
+ }
67
+
68
+ /** A draft's change: the files it touches, relative to the root, and git's `--stat` of it. */
69
+ export interface DraftDiff {
70
+ files: string[];
71
+ summary: string;
72
+ }
73
+
74
+ /** What `sanoma check` finds in a repo (a draft's too). */
75
+ export interface CheckResult {
76
+ ok: boolean;
77
+ /** One printable line each: `<file>:<line>:<col>: <message>`, or `<scenario> › <step>: <message>`. */
78
+ problems: string[];
79
+ /** What is worth fixing but not wrong, one line each, `<file>:<line>:<col>: warning: <message>`: the sites' `warnings`. */
80
+ warnings: string[];
81
+ /** The repo's Permissions grid (`permissionsGrid`), when its config loaded and its policies hold: what a change is reviewed by. */
82
+ grid?: PermissionsGrid;
28
83
  }
29
84
 
30
85
  /**
@@ -0,0 +1,107 @@
1
+ import { resolveConfig, type SanomaConfig } from "./config.ts";
2
+ import { shouldWake } from "./db.ts";
3
+ import type { SelfStop } from "./deadlines.ts";
4
+ import { warn } from "./log.ts";
5
+ import { errorMessage } from "./shared.ts";
6
+ import { startWorker, type Worker, type WorkerOptions } from "./worker.ts";
7
+
8
+ // One worker at a time in a process that outlives it. A worker that stops itself when idle (a
9
+ // wakeup declared) cannot launch again, so the next write that needs one, or the wakeup's wake,
10
+ // starts a fresh worker. `sanoma serve` runs its worker through it, and so does the testing
11
+ // kit's host in this process.
12
+
13
+ /** A worker under a supervisor (`superviseWorker`). */
14
+ export interface Supervisor {
15
+ /** True from the moment the worker stopped itself until a fresh one starts. */
16
+ readonly stopped: boolean;
17
+ /**
18
+ * Starts a fresh worker when the last one stopped itself, for a write that needs one (a run's
19
+ * start, an approval's decision); while one starts or runs, it is heard (below). Resolves once it runs, or
20
+ * once its failure to start is logged: the next call tries again. Never rejects.
21
+ */
22
+ resume(): Promise<void>;
23
+ /**
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.
26
+ */
27
+ wake(generation: number): Promise<void>;
28
+ /** Stops the worker running or starting, and starts none again. */
29
+ stop(): Promise<void>;
30
+ }
31
+
32
+ export interface SupervisorOptions extends Omit<WorkerOptions, "onStopped"> {
33
+ /**
34
+ * False: the worker never stops itself, as on an app on this machine alone, whose wakeup
35
+ * would wake the instance the host runs. Default true.
36
+ */
37
+ selfStop?: boolean;
38
+ /** Told each time the worker stops itself, once a fresh one is starting for a write it heard, or `stopped` is true. */
39
+ onStopped?: (stopped: SelfStop) => void;
40
+ }
41
+
42
+ /**
43
+ * Starts a worker on `config` that stops itself when idle with a wakeup declared (`onStopped`
44
+ * in `WorkerOptions`), and a fresh one each time `resume` or `wake` asks after it did. Throws
45
+ * when the first does not start. A write heard while a worker starts or runs may land after its last check
46
+ * for being idle, so a worker that stops itself after one is started again at once: it stops
47
+ * again on its own when nothing came of it.
48
+ */
49
+ export async function superviseWorker(config: SanomaConfig, options: SupervisorOptions = {}): Promise<Supervisor> {
50
+ const { onStopped, selfStop = true, ...workerOptions } = options;
51
+ let worker: Worker | undefined;
52
+ let starting: Promise<void> | undefined;
53
+ let closed = false;
54
+ // A write while the worker starts or runs, since it began starting.
55
+ let heard = false;
56
+ let databaseUrl: string | undefined;
57
+
58
+ const launch = async () => {
59
+ heard = false;
60
+ const started = await startWorker(config, { ...workerOptions, ...(selfStop && { onStopped: selfStopped }) });
61
+ if (closed) await started.stop();
62
+ else worker = started;
63
+ };
64
+ const selfStopped = (stopped: SelfStop) => {
65
+ worker = undefined;
66
+ if (closed) return;
67
+ // Resumed first, so `stopped` is false when `onStopped` (the exit rule) looks.
68
+ if (heard) void supervisor.resume();
69
+ onStopped?.(stopped);
70
+ };
71
+
72
+ const supervisor: Supervisor = {
73
+ get stopped() {
74
+ return !closed && !worker && !starting;
75
+ },
76
+ resume() {
77
+ if (closed) return Promise.resolve();
78
+ if (worker) {
79
+ heard = true;
80
+ return Promise.resolve();
81
+ }
82
+ if (starting) {
83
+ heard = true;
84
+ return starting;
85
+ }
86
+ return (starting = launch()
87
+ .catch((err: unknown) => warn(`the worker could not start again: ${errorMessage(err)}`))
88
+ .finally(() => {
89
+ starting = undefined;
90
+ }));
91
+ },
92
+ async wake(generation) {
93
+ if (!supervisor.stopped) return;
94
+ databaseUrl ??= resolveConfig(config).databaseUrl;
95
+ if (await shouldWake(databaseUrl, generation)) await supervisor.resume();
96
+ },
97
+ async stop() {
98
+ closed = true;
99
+ await starting;
100
+ const running = worker;
101
+ worker = undefined;
102
+ await running?.stop();
103
+ },
104
+ };
105
+ await launch();
106
+ return supervisor;
107
+ }
package/src/version.ts CHANGED
@@ -16,8 +16,10 @@ export const RUNTIME_VERSION = (
16
16
  * package release, so a run in flight on the old layout is not replayed against the new one.
17
17
  * 2: calls run in program order; a decision event per approval (2026-10-07).
18
18
  * 3: a start the policy holds waits on its approval before the body (2026-10-21).
19
+ * 4: a sleep for a duration reads DBOS's clock for its start (2026-10-25).
20
+ * 5: a scheduled run admits its starter and asks the policy in two steps before the body (2026-10-25).
19
21
  */
20
- export const STEP_LAYOUT = 3;
22
+ export const STEP_LAYOUT = 5;
21
23
 
22
24
  let dbosVersion: string | undefined;
23
25