@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/index.ts CHANGED
@@ -61,6 +61,9 @@ export {
61
61
  type ResolvedConfig,
62
62
  type SanomaConfig,
63
63
  } from "./config.ts";
64
+ // An app's `app.ts` (`defineApp`), and where the launcher finds an app's workflows and data file.
65
+ export { type AppDefinition, type AppEntry, defineApp, readApps } from "./apps.ts";
66
+ export { appFolders, APPS_DIR, filesUnder, kindDirs } from "./paths.ts";
64
67
  // `sanoma` itself (the people types' constructors) is at `@sanoma-ai/workflows/resources`, for data files.
65
68
  // `collection()` itself is at `@sanoma-ai/workflows/data`, for `resources/data.ts`; this tells one apart, as the launcher does.
66
69
  export { isCollection } from "./collection.ts";
@@ -76,12 +79,15 @@ export {
76
79
  } from "./apply.ts";
77
80
  export { CALL_WORKFLOW, CallInput } from "./call-workflow.ts";
78
81
  export {
82
+ type ChangeDecision,
83
+ type ChangeDeploy,
79
84
  type ChangeEvent,
80
85
  ChangeFacts,
81
86
  ChangeInput,
82
87
  changeOps,
83
88
  type ChangeReport,
84
89
  type ChangeState,
90
+ type ChangeVerified,
85
91
  runtimeHold,
86
92
  touchesOf,
87
93
  } from "./change.ts";
@@ -125,6 +131,7 @@ export {
125
131
  ApprovalPendingError,
126
132
  errorCode,
127
133
  type ErrorCode,
134
+ vendorCodeOf,
128
135
  type ErrorInfo,
129
136
  type InputIssue,
130
137
  invalidInput,
@@ -161,9 +168,12 @@ export {
161
168
  type Secrets,
162
169
  } from "./secret.ts";
163
170
  export {
171
+ type AppMerge,
164
172
  type CheckResult,
165
173
  type Draft,
174
+ draftBranch,
166
175
  type DraftDiff,
176
+ draftOf,
167
177
  type HostEnforcement,
168
178
  type PullRequest,
169
179
  type RemoteContext,
@@ -196,6 +206,8 @@ export {
196
206
  } from "./wakeup.ts";
197
207
  export {
198
208
  type CallOptions,
209
+ type ChangesFilter,
210
+ type ChangeView,
199
211
  type ClientOptions,
200
212
  SanomaClient,
201
213
  type RunsFilter,
@@ -217,12 +229,14 @@ export {
217
229
  CHANGE_MERGE,
218
230
  CHANGE_OPEN,
219
231
  CHANGE_REVIEW,
232
+ CHANGE_TOPIC,
220
233
  CHANGE_WORKFLOW,
221
234
  declaresPeople,
222
235
  type PeopleResource,
223
236
  personOf,
224
237
  principalOf,
225
238
  RUNTIME_AGENTS,
239
+ HOST_CHECK_AGENT,
226
240
  SCHEDULE_AGENT,
227
241
  credentialReady,
228
242
  DEFAULT_SLEEP_NAME,
package/src/lint.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  import { realpathSync } from "node:fs";
2
- import { basename, dirname, join } from "node:path";
2
+ import { basename, dirname, join, relative, sep } from "node:path";
3
3
  import { byPosition, childrenOf, type LintProblem, type Node, parsed, type SourceFile } from "./ast.ts";
4
4
  import { COLLECTIONS_FILE, readDataFile } from "./datafile.ts";
5
5
  import { constsOf, outlineBody, workflowLiterals } from "./outline.ts";
6
- import { inside, nearestDir, RELATIVE } from "./paths.ts";
6
+ import { APPS_DIR, inside, nearestDir, RELATIVE } from "./paths.ts";
7
7
  // From src/ and from dist/ alike, the package's own oxlint.json: the one list of allowed names.
8
8
  import oxlint from "../oxlint.json" with { type: "json" };
9
9
 
@@ -203,11 +203,14 @@ function whyRefused(spec: string, filename: string | undefined, root: string | u
203
203
  const target = join(dirname(filename), spec);
204
204
  if (inside(root, target)) return undefined;
205
205
  const tree = basename(root) === "workflows";
206
- // The collections, which a workflow names in `uses`: the one data file the runtime imports too.
207
- if (tree && realPath(target) === realPath(join(dirname(root), "resources", COLLECTIONS_FILE))) return undefined;
206
+ // The collections, which a workflow names in `uses`: the one data file the runtime imports too,
207
+ // the root's or an app's (`apps/<app>/resources/data.ts`), wherever the workflow is.
208
+ if (tree && isCollectionsFile(realPath(target), realPath(dirname(root)))) return undefined;
208
209
  return (
209
210
  `it reaches outside ${tree ? `${basename(root)}/` : "this directory"}, toward the config and its drivers; ${THROUGH_CTX}` +
210
- (tree ? "; of the files outside it, only resources/data.ts (the collections) may be imported" : "")
211
+ (tree
212
+ ? "; of the files outside it, only resources/data.ts (the collections, the root's or an app's apps/<app>/resources/data.ts) may be imported"
213
+ : "")
211
214
  );
212
215
  }
213
216
  for (const [pattern, reason] of REFUSED_PACKAGES) if (pattern.test(spec)) return reason;
@@ -215,6 +218,18 @@ function whyRefused(spec: string, filename: string | undefined, root: string | u
215
218
  return "import only from `sanoma` (or `@sanoma-ai/workflows`), a `@sanoma-ai/connector-*` package, zod, or a file beside this one";
216
219
  }
217
220
 
221
+ /**
222
+ * Whether `target` is a collections file of the company whose workflows' tree is in `base` (the
223
+ * root, or an app's `apps/<app>/`): `<root>/resources/data.ts` or `<root>/apps/<app>/resources/data.ts`.
224
+ */
225
+ function isCollectionsFile(target: string, base: string): boolean {
226
+ const root = basename(dirname(base)) === APPS_DIR ? dirname(dirname(base)) : base;
227
+ const parts = relative(root, target).split(sep);
228
+ const [first, app] = parts;
229
+ const rest = first === APPS_DIR && app !== undefined ? parts.slice(2) : parts;
230
+ return rest.length === 2 && rest[0] === "resources" && rest[1] === COLLECTIONS_FILE;
231
+ }
232
+
218
233
  /** A path's real path, or the path itself when it does not exist. */
219
234
  function realPath(path: string): string {
220
235
  try {
package/src/outline.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { childrenOf, type Node, readSource, type SourceFile } from "./ast.ts";
2
2
  import { opIdsOf, type WorkflowDefinition } from "./define.ts";
3
- import { childLists, DEFAULT_SLEEP_NAME, fitsOp, flatten } from "./shared.ts";
3
+ import { childLists, DATA_VENDOR, DEFAULT_SLEEP_NAME, fitsOp, flatten, writtenName } from "./shared.ts";
4
4
 
5
5
  /**
6
6
  * What a workflow's `run` calls through `ctx`, in the order it makes the calls, with the loops,
@@ -118,8 +118,10 @@ export function outlineWithSource(
118
118
  return { outline: { error: `${file.file} could not be parsed: ${why}` }, found: false };
119
119
  }
120
120
  const consts = constsOf(file.program);
121
- const fn = runIn(file.program, wf.name, consts);
122
- if (!fn) return { outline: { error: `${file.file} holds no workflow named "${wf.name}"` }, found: false };
121
+ // An app's workflow is found by the name its file writes, without the app.
122
+ const name = writtenName(wf);
123
+ const fn = runIn(file.program, name, consts);
124
+ if (!fn) return { outline: { error: `${file.file} holds no workflow named "${name}"` }, found: false };
123
125
  const { nodes, problems } = outlineBody(fn, { consts, source: file.source });
124
126
  if (problems.length) {
125
127
  const lines = problems.map((p) => {
@@ -219,8 +221,8 @@ const DEFAULT_LIKE = (name: string) =>
219
221
  /** Two steps given one name, and how to tell them apart. */
220
222
  const TWICE = (name: string, fix: string) =>
221
223
  `two steps of this workflow are named "${name}": a name given is one step's, so a test or the graph can tell them apart; ${fix}`;
222
- /** A name given that reads as an operation's id: what an unnamed call is called. */
223
- const OP_ID = /^[\w-]+(\.[\w-]+){2}$/;
224
+ /** A name given that reads as an operation's id, four segments only for an app's collection: what an unnamed call is called. */
225
+ const OP_ID = new RegExp(String.raw`^([\w-]+(\.[\w-]+){2}|${DATA_VENDOR}(\.[\w-]+){3})$`);
224
226
 
225
227
  /** Where a function's body starts: its own statement rules, nothing of the caller's. */
226
228
  const bodyOf = (fn: Node): Where => ({ fn, inCatch: false });
@@ -424,7 +426,7 @@ export function outlineBody(
424
426
  { kind: "sleep", path, span: span(node), name: nameIn(args[1]) ?? DEFAULT_SLEEP_NAME },
425
427
  ];
426
428
  default:
427
- if (members.length === 3) {
429
+ if (isOpPath(members)) {
428
430
  // A computed id's own name is the operation it calls, known only in a run.
429
431
  const name = nameIn(args[1]) ?? (id.includes("*") ? undefined : id);
430
432
  return [
@@ -564,6 +566,13 @@ function switchArms(node: Node): Node[] {
564
566
  return node.cases.some((c: Node) => c.test === null) ? arms : [...arms, null];
565
567
  }
566
568
 
569
+ /**
570
+ * Whether a path on `ctx` is an operation's: `<vendor>.<resource>.<op>`, or an app's collection's,
571
+ * `data.<app>.<collection>.<op>` (a computed vendor may be either).
572
+ */
573
+ const isOpPath = (members: readonly string[]): boolean =>
574
+ members.length === 3 || (members.length === 4 && (members[0] === DATA_VENDOR || members[0] === "*"));
575
+
567
576
  /** The member names from `ctx` to the callee, `*` for a computed one, when the callee is on `ctx`. */
568
577
  function ctxPath(callee: Node, ctx: string): string[] | undefined {
569
578
  const path: string[] = [];
package/src/paths.ts CHANGED
@@ -1,5 +1,6 @@
1
- import { readdirSync, statSync } from "node:fs";
2
- import { basename, dirname, isAbsolute, join, relative, sep } from "node:path";
1
+ import { existsSync, readdirSync, statSync } from "node:fs";
2
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
3
+ import { APP_NAME } from "./shared.ts";
3
4
 
4
5
  // Where an import may reach, as the lints and the data-file reader all decide it, the one walk up
5
6
  // the directories and the one walk down (`filesUnder`). Node's `path` and `fs` only, no parser:
@@ -45,3 +46,57 @@ export function filesUnder(dir: string, keep: (name: string) => boolean): string
45
46
  .filter((path) => !relative(dir, path).split(sep).includes("node_modules"))
46
47
  .toSorted();
47
48
  }
49
+
50
+ /** Where a company's apps are, under its root: `apps/<app>/`. */
51
+ export const APPS_DIR = "apps";
52
+
53
+ /** Each directory's company root (the nearest one with a `sanoma.config.ts`, short of a `node_modules`), once found. */
54
+ const roots = new Map<string, string>();
55
+
56
+ /** Whether `dir` is a company's root: it holds a `sanoma.config.ts`. */
57
+ const isCompany = (dir: string) => existsSync(join(dir, "sanoma.config.ts"));
58
+
59
+ /**
60
+ * The app a file is in: `<app>` for a file under `<root>/apps/<app>/`, where the root is the
61
+ * nearest directory above it with a `sanoma.config.ts` (an app keeps its own `package.json`,
62
+ * and its own config when merged from a company's repo: then the company is the one whose
63
+ * `apps/` holds it; a `node_modules` ends the search: the file is a package's, not a company's);
64
+ * undefined for a file at the root or outside any company. `defineWorkflow` and `collection()` name what they declare by it.
65
+ */
66
+ export function appOfFile(file: string | undefined): string | undefined {
67
+ if (file === undefined) return undefined;
68
+ const dir = dirname(resolve(file));
69
+ let root: string | null | undefined = roots.get(dir);
70
+ if (root === undefined) {
71
+ root =
72
+ upFrom<string | null>(dir, (d) => (basename(d) === "node_modules" ? null : isCompany(d) ? d : undefined)) ?? null;
73
+ // An app merged from a company's repo keeps that repo's config: the company is the one above.
74
+ if (root !== null && basename(dirname(root)) === APPS_DIR && isCompany(dirname(dirname(root)))) {
75
+ root = dirname(dirname(root));
76
+ }
77
+ // Found roots only: a directory that is no company's yet may become one (`sanoma` in an empty folder).
78
+ if (root !== null) roots.set(dir, root);
79
+ }
80
+ if (root === null) return undefined;
81
+ const [top, app, ...rest] = relative(root, file).split(sep);
82
+ return top === APPS_DIR && app !== undefined && rest.length && APP_NAME.test(app) ? app : undefined;
83
+ }
84
+
85
+ /** The apps' folders under `root` whose names are an `APP_NAME`, sorted: `apps/<app>/`, absolute. */
86
+ export function appFolders(root: string): string[] {
87
+ const dir = join(root, APPS_DIR);
88
+ if (!isDirectory(dir)) return [];
89
+ return readdirSync(dir, { withFileTypes: true })
90
+ .filter((d) => d.isDirectory() && APP_NAME.test(d.name))
91
+ .map((d) => join(dir, d.name))
92
+ .toSorted();
93
+ }
94
+
95
+ /** Where a kind of file is under `root`: `<root>/<kind>`, then each app's `apps/<app>/<kind>`, whether or not they exist. */
96
+ export const kindDirs = (root: string, kind: string): string[] => [
97
+ join(root, kind),
98
+ ...appFolders(root).map((app) => join(app, kind)),
99
+ ];
100
+
101
+ /** Where a company's scenarios are, under its root and each app's folder. */
102
+ export const SCENARIOS_DIR = "scenarios";
package/src/resources.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { readFileSync, statSync } from "node:fs";
2
- import { dirname, join, relative, resolve, sep } from "node:path";
2
+ import { dirname, relative, resolve, sep } from "node:path";
3
3
  import { parsed } from "./ast.ts";
4
4
  import {
5
5
  type DataFile,
@@ -11,7 +11,7 @@ import {
11
11
  } from "./datafile.ts";
12
12
  import { type Binding, type Bindings, LOCKFILE, readLockfile } from "./lockfile.ts";
13
13
  import { type Connector, VENDOR } from "./op.ts";
14
- import { filesUnder } from "./paths.ts";
14
+ import { APPS_DIR, filesUnder, kindDirs } from "./paths.ts";
15
15
  import { peopleConnector, sanoma } from "./person.ts";
16
16
  import { wakeupOps } from "./wakeup-type.ts";
17
17
  import type { Span } from "./outline.ts";
@@ -28,6 +28,7 @@ import {
28
28
  personOf,
29
29
  principalOf,
30
30
  problemLine,
31
+ qualified,
31
32
  RUNTIME_AGENTS,
32
33
  VISITORS,
33
34
  } from "./shared.ts";
@@ -65,9 +66,9 @@ export interface DeclaredResource {
65
66
  export interface DeclaredCollection {
66
67
  /** `<file>#<export>`, as a resource's: `resources/data.ts#outbound`. */
67
68
  id: string;
68
- /** `resources/data.ts`. */
69
+ /** `resources/data.ts`, or an app's `apps/<app>/resources/data.ts`. */
69
70
  file: string;
70
- /** The collection's name, its operations' middle segment (`data.<name>.get`). */
71
+ /** The collection's name, its operations' middle segments (`data.<name>.get`): an app's with its app, `crm.contacts`. */
71
72
  name: string;
72
73
  /** Its `export const` statement, as UTF-16 offsets into the file's text. */
73
74
  span: Span;
@@ -119,7 +120,8 @@ const peopleRead = new Map<string, { stamp: string; read: DeclaredPeople }>();
119
120
 
120
121
  /** Each data file under `root` with its size and modification time: unchanged while the files are. */
121
122
  const filesStamp = (root: string) =>
122
- dataFilesUnder(join(root, "resources"))
123
+ kindDirs(root, "resources")
124
+ .flatMap(dataFilesUnder)
123
125
  .map((path) => {
124
126
  // Gone since it was listed (an editor's save by rename): stamped as such, and read next time.
125
127
  const stat = statSync(path, { bigint: true, throwIfNoEntry: false });
@@ -134,6 +136,13 @@ const RESERVED_GROUPS: ReadonlyMap<string, string> = new Map([
134
136
  [ANONYMOUS, "who a public page runs as when nobody is signed in"],
135
137
  ]);
136
138
 
139
+ /**
140
+ * A person, agent or group declared in an app's data file: refused, because the people are the
141
+ * company's, so neither the policy (`readPeople`) nor `describeConfig` counts it.
142
+ */
143
+ export const inAppPeople = (r: DeclaredResource): boolean =>
144
+ r.vendor === "sanoma" && Object.hasOwn(sanoma, r.type) && r.file.startsWith(`${APPS_DIR}/`);
145
+
137
146
  /**
138
147
  * The people, agents and groups the data files under `root` declare (`sanoma.person`, `.agent`,
139
148
  * `.group`), read without running them, as `principalOf` takes them, with what is wrong in their
@@ -147,9 +156,16 @@ export function readPeople(root: string | undefined): DeclaredPeople {
147
156
  if (cached?.stamp === stamp) return cached.read;
148
157
  // With the wakeup's type, which the same constructors declare: read, and only the people's kept.
149
158
  const read = readDataFiles(root, [peopleConnector, wakeupOps], undefined, (source) => DECLARES_PEOPLE.test(source));
150
- const people = read.resources.filter((r) => Object.hasOwn(sanoma, r.type));
159
+ const ofPeople = read.resources.filter((r) => Object.hasOwn(sanoma, r.type));
160
+ const people = ofPeople.filter((r) => !inAppPeople(r));
151
161
  // The people are never bound, so the lockfile's problems are not theirs.
152
162
  const own = read.problems.filter((p) => p.file !== LOCKFILE);
163
+ for (const r of ofPeople.filter(inAppPeople)) {
164
+ own.push({
165
+ file: r.file,
166
+ message: `${r.id} declares a ${r.type} in an app: the people are the company's, declared in resources/ at the root`,
167
+ });
168
+ }
153
169
  const ids = new Map<string, DeclaredResource>();
154
170
  for (const r of people) {
155
171
  if (r.type === "group") {
@@ -262,7 +278,8 @@ export function readDataFiles(
262
278
 
263
279
  const files = new Map<string, FileEntry>();
264
280
  const left = new Set<string>();
265
- for (const path of dataFilesUnder(join(root, "resources"))) {
281
+ // The root's, then each app's: a data file imports only from its own `resources/`.
282
+ for (const [dir, path] of kindDirs(root, "resources").flatMap((d) => dataFilesUnder(d).map((p) => [d, p] as const))) {
266
283
  const rel = relative(root, path).split(sep).join("/");
267
284
  const source = readFileSync(path, "utf8");
268
285
  if (partial && !partial(source)) {
@@ -270,7 +287,7 @@ export function readDataFiles(
270
287
  continue;
271
288
  }
272
289
  const { program, problems: syntax, at } = parsed(source, path);
273
- const data = readDataFile(program, path, join(root, "resources"));
290
+ const data = readDataFile(program, path, dir);
274
291
  const problem = (offset: number, message: string) => problems.push({ file: rel, ...at(offset), message });
275
292
  for (const p of syntax) problems.push({ file: rel, ...p });
276
293
  for (const p of data.problems) problem(p.start, p.message);
@@ -465,7 +482,8 @@ export function readDataFiles(
465
482
  data.collections.map((c) => ({
466
483
  id: `${rel}#${c.export}`,
467
484
  file: rel,
468
- name: c.name,
485
+ // An app's, `apps/<app>/resources/data.ts`, is named with its app, as `collection()` names it.
486
+ name: qualified(rel.startsWith(`${APPS_DIR}/`) ? rel.split("/")[1] : undefined, c.name),
469
487
  span: [c.start, c.end] as Span,
470
488
  })),
471
489
  );
package/src/run.ts CHANGED
@@ -71,6 +71,11 @@ export interface WorkerState {
71
71
  workflows: Map<string, WorkflowDefinition<any, any>>;
72
72
  /** Each workflow's outline, by name: read once when the worker starts, every run is held to it (`call_not_in_outline`). */
73
73
  outlines: Map<string, WorkflowOutline>;
74
+ /**
75
+ * Starts a run of the workflow `name` as `runId` (an id the run already has returns it): how a
76
+ * built-in starts another as its child, as the change run starts apply and drift.
77
+ */
78
+ startRun(name: string, runId: string, args: RunArgs): Promise<{ getResult(): Promise<unknown> }>;
74
79
  /**
75
80
  * Set when the worker stops, before DBOS shuts down. DBOS abandons a stopped worker's run
76
81
  * functions, which then fail as their next DBOS call finds the database closed. Such a failure,
package/src/sandbox.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { DBOS } from "@dbos-inc/dbos-sdk";
2
- import { indexDrivers } from "./config.ts";
2
+ import { indexDrivers, sandboxDrivers } from "./config.ts";
3
3
  import { opIdsOf } from "./define.ts";
4
4
  import { SanomaError } from "./errors.ts";
5
5
  import type { Fake, ResourceFake } from "./fake.ts";
@@ -178,10 +178,7 @@ export async function seedSandbox(run: Run, name: string): Promise<void> {
178
178
  const fakes = instance ?? new Map([...state.fakes].map(([vendor, make]) => [vendor, make()]));
179
179
  const drivers = instance
180
180
  ? state.drivers
181
- : indexDrivers(
182
- [...fakes.values()].map((f) => f.driver),
183
- state.ops,
184
- );
181
+ : indexDrivers([...Array.from(fakes.values(), (f) => f.driver), ...sandboxDrivers], state.ops);
185
182
  // What the scenario seeds is checked by `applyGivens`.
186
183
  const missing = opIdsOf(state.workflows.get(run.workflow)!).filter((id) => !drivers.has(id));
187
184
  if (missing.length) {
package/src/scenario.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { existsSync, readdirSync, statSync } from "node:fs";
2
- import { basename, dirname, join } from "node:path";
2
+ import { basename, dirname, join, relative, sep } from "node:path";
3
3
  import { setTimeout as delay } from "node:timers/promises";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import type { ISupportCodeLibrary } from "@cucumber/cucumber/api";
@@ -33,6 +33,7 @@ import { fill, isBug, objectUnder, restOf, seedFrom, shapeOf } from "./fill.ts";
33
33
  import type { LedgerRecord } from "./ledger.ts";
34
34
  import type { Driver, Op } from "./op.ts";
35
35
  import { type OutlineNode, outlineWorkflow } from "./outline.ts";
36
+ import { isDirectory, kindDirs, SCENARIOS_DIR } from "./paths.ts";
36
37
  import { isObject } from "./resource.ts";
37
38
  import { admit, type DeclaredPeople, readPeople } from "./resources.ts";
38
39
  import { memorySecrets } from "./secret.ts";
@@ -1231,6 +1232,15 @@ function featureFiles(dir: string): string[] {
1231
1232
  .toSorted();
1232
1233
  }
1233
1234
 
1235
+ /**
1236
+ * A company's scenarios: the `.feature` files under `scenarios/` in `root`, then under each app's
1237
+ * `apps/<app>/scenarios/`, relative to `root`, with `/` between directories.
1238
+ */
1239
+ const scenarioFiles = (root: string): string[] =>
1240
+ kindDirs(root, SCENARIOS_DIR).flatMap((dir) =>
1241
+ isDirectory(dir) ? featureFiles(dir).map((file) => `${relative(root, dir).split(sep).join("/")}/${file}`) : [],
1242
+ );
1243
+
1234
1244
  /** What `loadScenarios` returns. */
1235
1245
  export interface LoadedScenarios {
1236
1246
  scenarios: Scenario[];
@@ -1383,8 +1393,8 @@ const loadedByScope = new WeakMap<
1383
1393
  >();
1384
1394
 
1385
1395
  /**
1386
- * Every scenario in the `.feature` files under `scenarios` (a `file:` URL to a directory),
1387
- * compiled by Cucumber, with an error for each file that does not compile and each name used
1396
+ * Every scenario in the `.feature` files under `scenarios/` and each app's `apps/<app>/scenarios/`
1397
+ * in `scenarios` (a `file:` URL to the root), each named by its path from there, compiled by Cucumber, with an error for each file that does not compile and each name used
1388
1398
  * twice. No directory, or none given, is no scenarios. Rejects when an operation's phrase names
1389
1399
  * a field it lacks. The files are compiled again only once one is added, removed or written
1390
1400
  * (its name, `mtimeMs` or size changes), or the people's data files change: until then each
@@ -1396,7 +1406,7 @@ export async function loadScenarios(scope: Scope): Promise<LoadedScenarios> {
1396
1406
  if (!existsSync(dir)) return { scenarios: [], errors: [] };
1397
1407
  // Built first, so a phrase naming a field its input lacks rejects even with no feature files.
1398
1408
  builtFor(scope);
1399
- const files = featureFiles(dir);
1409
+ const files = scenarioFiles(dir);
1400
1410
  const stamp = JSON.stringify([
1401
1411
  dir,
1402
1412
  files.map((file) => {
package/src/scenarios.ts CHANGED
@@ -6,7 +6,7 @@ import { type Check, check, drive, loadScenarios, type Scenario, type Scope } fr
6
6
  import { errorMessage } from "./shared.ts";
7
7
  import { startWorker } from "./worker.ts";
8
8
 
9
- export { type DemoState, demoState, reseed } from "./demo.ts";
9
+ export { type DemoState, demoState, reseed, type SeedHalf } from "./demo.ts";
10
10
 
11
11
  /*
12
12
  * `@sanoma-ai/workflows/scenarios`: runs a config's scenarios as sandbox runs, with no test
package/src/sdk.ts CHANGED
@@ -3,7 +3,7 @@ import { dirname, join } from "node:path";
3
3
  import type { CollectionEntry, ConfigDescription, OpEntry, WorkflowEntry } from "./describe.ts";
4
4
  import { isObject } from "./resource.ts";
5
5
  import { type CedarSchemaJson, SCHEMA_FILE, schemaText } from "./schema.ts";
6
- import { apiNameOf, DATA_VENDOR, problemLine } from "./shared.ts";
6
+ import { apiNameOf, DATA_VENDOR, problemLine, writtenName } from "./shared.ts";
7
7
 
8
8
  // A company's generated SDK, `.sanoma/api.ts`: the types of a site's `api` from `sanoma/site`,
9
9
  // written from `describeConfig` (types and JSDoc only: `api` itself is one generic client). It
@@ -24,7 +24,7 @@ type Schema = Record<string, unknown>;
24
24
  * `.sanoma/api.ts` for a description: one record type per collection, and `CompanyApi` (not `Api`,
25
25
  * which inside `declare module "sanoma/site"` is the module's own), one member per
26
26
  * workflow (`apiNameOf`), per vendor of the operations a person may call directly (`OpEntry.direct`,
27
- * by resource) and `data` for the collections, each with its JSDoc; then `Register`'s
27
+ * by resource) and `data` for the collections (an app's under its name), each with its JSDoc; then `Register`'s
28
28
  * augmentation. Pure: the same description gives the same text. A workflow whose api name another
29
29
  * has, `describeConfig`'s problem on its entry, is left out, and its problem is in `problems`.
30
30
  */
@@ -46,9 +46,16 @@ export function generateApi(description: ConfigDescription): { source: string; p
46
46
  members.push(member(`${title}: the operations a person may call directly.`, vendor, block(resources)));
47
47
  }
48
48
  if (description.collections.length) {
49
- const data = description.collections.map((c) =>
50
- member(collectionDoc(c), c.name, `CollectionApi<${recordName(c)}>`),
51
- );
49
+ // The root's by name, then each app's under its name: `api.data.crm.contacts`.
50
+ const collectionMember = (c: CollectionEntry) =>
51
+ member(collectionDoc(c), writtenName(c), `CollectionApi<${recordName(c)}>`);
52
+ const byApp = Map.groupBy(description.collections, (c) => c.app);
53
+ const data = [
54
+ ...(byApp.get(undefined) ?? []).map(collectionMember),
55
+ ...[...byApp]
56
+ .filter(([app]) => app !== undefined)
57
+ .map(([app, own]) => member(`The ${app} app's collections.`, app!, block(own.map(collectionMember)))),
58
+ ];
52
59
  members.push(member("The company's collections.", DATA_VENDOR, block(data)));
53
60
  }
54
61
  const source = [
@@ -127,8 +134,15 @@ const opMember = (op: OpEntry) =>
127
134
  const collectionDoc = (c: CollectionEntry) =>
128
135
  `${c.title ?? c.name}: the collection \`${c.name}\`, keyed by \`${c.key}\`.`;
129
136
 
130
- /** A collection's record type's name: `outbound` gives `OutboundRecord`, never `CompanyApi` nor an imported type's. */
131
- const recordName = (c: CollectionEntry) => `${c.name[0]!.toUpperCase()}${c.name.slice(1)}Record`;
137
+ /**
138
+ * A collection's record type's name: `outbound` gives `OutboundRecord`, an app's `crm.contacts`
139
+ * `CrmContactsRecord`; never `CompanyApi` nor an imported type's.
140
+ */
141
+ const recordName = (c: CollectionEntry) =>
142
+ `${c.name
143
+ .split(".")
144
+ .map((part) => `${part[0]!.toUpperCase()}${part.slice(1)}`)
145
+ .join("")}Record`;
132
146
 
133
147
  /**
134
148
  * A JSON Schema as a TypeScript type, for the kinds a start form draws (`fieldsOf` in the app):
package/src/shared.ts CHANGED
@@ -22,12 +22,16 @@ export type { DeclaredResource, ResourceProblem } from "./resources.ts";
22
22
  export type { DriftField, DriftReport, DriftResult, DriftStatus } from "./drift.ts";
23
23
  export type { ApplyPlan, ApplyReport, ApplyResult, PlanAnswer, PlanStep } from "./apply.ts";
24
24
  export type { CredentialStatus } from "./env.ts";
25
- export type { ChangeEvent, ChangeFacts, ChangeReport, ChangeState } from "./change.ts";
25
+ export type { ChangeDeploy, ChangeEvent, ChangeFacts, ChangeReport, ChangeState, ChangeVerified } from "./change.ts";
26
+ export type { AvailableApp, AvailableApps } from "./apps-list.ts";
26
27
 
27
28
  // What a UI in the browser may use from the runtime, besides types: `@sanoma-ai/workflows/shared`.
28
29
  // Nothing here imports DBOS, Node or zod, so a browser bundle can carry it; the runtime uses the
29
30
  // same functions, so a page answers these questions the way a run does.
30
31
 
32
+ // Sanoma's claims, data (`sanoma/claims` re-exports them).
33
+ export * from "./claims.ts";
34
+
31
35
  /**
32
36
  * What a `ctx.sleep` without a `{ name }` is called, in its outline node and its ledger record
33
37
  * (`name`); a name given may not be it.
@@ -76,6 +80,36 @@ export function previewRequests(
76
80
  /** A site's folder name under `sites/`, its URL segment: what the app mounts and `readSites` reads. */
77
81
  export const SITE_NAME = /^[a-z][a-z0-9-]*$/;
78
82
 
83
+ /**
84
+ * An app's folder name under `apps/`: a lowercase letter, then lowercase letters and digits. It
85
+ * prefixes the names of what the folder declares (`qualified`), and is a segment of `ctx.data`,
86
+ * `api.data` and a site's path, so it is an identifier and a URL segment alike.
87
+ */
88
+ export const APP_NAME = /^[a-z][a-z0-9]*$/;
89
+
90
+ /** A name declared under `apps/<app>/` as the runtime has it, `<app>.<name>`; one declared at the root as it is. */
91
+ export const qualified = (app: string | undefined, name: string): string =>
92
+ app === undefined ? name : `${app}.${name}`;
93
+
94
+ /** A workflow's or collection's name as its file writes it, `qualified`'s undone: without its app. */
95
+ export const writtenName = ({ name, app }: { name: string; app?: string }): string =>
96
+ app === undefined ? name : name.slice(app.length + 1);
97
+
98
+ /**
99
+ * A site's key: its folder's name at the root, `<app>/<site>` in an app, so its pages are under
100
+ * `/sites/<key>`. What `readSites` names a site by, and the app's build mounts it at.
101
+ */
102
+ export const siteKey = (app: string | undefined, site: string): string => (app === undefined ? site : `${app}/${site}`);
103
+
104
+ /** A site's key (`siteKey`): a `SITE_NAME`, or an `APP_NAME`, `/` and a `SITE_NAME`, as a host's `domains` names a site. */
105
+ export const SITE_KEY = /^([a-z][a-z0-9]*\/)?[a-z][a-z0-9-]*$/;
106
+
107
+ /** A site's folder, relative to the root, by its key: `sites/<name>`, or an app's `apps/<app>/sites/<name>`. */
108
+ export function siteDir(key: string): string {
109
+ const [app, site] = key.split("/");
110
+ return site === undefined ? `sites/${key}` : `apps/${app}/sites/${site}`;
111
+ }
112
+
79
113
  /**
80
114
  * A public site's language, as `defineSite({ locales })` names it: a BCP 47 language tag of a
81
115
  * language and subtags (`en`, `fr`, `pt-BR`), which is also its URL segment (`/pt-BR/…`) and a
@@ -101,7 +135,7 @@ export function domainsOf(text: string): Record<string, string> {
101
135
  const domains: Record<string, string> = {};
102
136
  for (const pair of text.split(",")) {
103
137
  const [site = "", domain = "", ...rest] = pair.trim().split("=");
104
- if (!SITE_NAME.test(site) || !DOMAIN_NAME.test(domain) || rest.length || Object.hasOwn(domains, site)) {
138
+ if (!SITE_KEY.test(site) || !DOMAIN_NAME.test(domain) || rest.length || Object.hasOwn(domains, site)) {
105
139
  throw new Error(
106
140
  `SANOMA_DOMAINS has "${pair}": write <site>=<hostname>, each site once, such as www=sanoma.example`,
107
141
  );
@@ -211,6 +245,16 @@ export const CALL_WORKFLOW = "call";
211
245
  */
212
246
  export const CHANGE_WORKFLOW = "change";
213
247
 
248
+ /**
249
+ * The draft a change's run is of: `change:<id>` (its first run) or `change:<id>~<n>` (a later
250
+ * one); undefined for any other run id.
251
+ */
252
+ export const changeIdOf = (runId: string): string | undefined => CHANGE_RUN.exec(runId)?.[1];
253
+
254
+ /** What a draft's id may be (`ChangeInput.id`), the part of its branch and its change's run ids after `draft/` and `change:`. */
255
+ export const DRAFT_ID = /^[\w-]{1,64}$/;
256
+ const CHANGE_RUN = new RegExp(`^change:(${DRAFT_ID.source.slice(1, -1)})(?:~\\d+)?$`);
257
+
214
258
  /** Opens a change: its draft's branch pushed and a pull request opened for it. */
215
259
  export const CHANGE_OPEN = "sanoma.change.open";
216
260
  /** Reports Sanoma's review of a change to its host (the `sanoma` commit status). */
@@ -221,6 +265,14 @@ export const CHANGE_MERGE = "sanoma.change.merge";
221
265
  export const CHANGE_CLOSE = "sanoma.change.close";
222
266
  /** The DBOS event a change run publishes its `ChangeState` on. */
223
267
  export const CHANGE_EVENT = "change";
268
+ /**
269
+ * The `recv` topic a change run is told what happened to its change on (a `ChangeEvent`): never an
270
+ * approval's, whose topic is its id (`approval-<n>`).
271
+ */
272
+ export const CHANGE_TOPIC = "change:event";
273
+
274
+ /** Lists the apps on GitHub with Sanoma's claims: the one runtime operation a page may call (`isDirect`). */
275
+ export const APPS_LIST = "sanoma.apps.list";
224
276
 
225
277
  /** The workflows the runtime adds itself, which a config's own may not be named: drift, apply, call and change. */
226
278
  export const BUILT_IN_WORKFLOWS: ReadonlySet<string> = new Set([
@@ -324,8 +376,10 @@ export const ANONYMOUS_PRINCIPAL: Principal = Object.freeze({ id: ANONYMOUS, gro
324
376
  export const WAKEUP_AGENT = "wakeup";
325
377
  /** The runtime's agent a scheduled run is started by (`trigger: { schedule }`): the policy decides its starts. */
326
378
  export const SCHEDULE_AGENT = "schedule";
379
+ /** The runtime's agent that checks and deploys the hosts: drift's, and apply's after a push to `main` (`SanomaClient.remoteEvent`). */
380
+ export const HOST_CHECK_AGENT = "host-check";
327
381
  /** 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];
382
+ export const RUNTIME_AGENTS: readonly string[] = ["scenarios", HOST_CHECK_AGENT, SCHEDULE_AGENT, WAKEUP_AGENT];
329
383
 
330
384
  /** The declared resources of one of the runtime's people types, by `desired`. */
331
385
  const peopleOfType = <T>(resources: readonly PeopleResource[], type: "person" | "agent" | "group") =>
@@ -467,10 +521,11 @@ export const credentialReady = ({ status, optional }: CredentialStatus): boolean
467
521
 
468
522
  /**
469
523
  * A workflow's name in a site's `api`: kebab-case made camelCase, `outbound-next` as
470
- * `api.outboundNext`. `describeConfig` reports two workflows that come out the same, or one that
524
+ * `api.outboundNext`, and an app's workflow one name with its app, `crm.follow-up` as
525
+ * `api.crmFollowUp`. `describeConfig` reports two workflows that come out the same, or one that
471
526
  * comes out as a vendor's id or `data`, as a problem on the workflow.
472
527
  */
473
- export const apiNameOf = (name: string): string => name.replace(/-([a-z0-9])/g, (_, c: string) => c.toUpperCase());
528
+ export const apiNameOf = (name: string): string => name.replace(/[-.]([a-z0-9])/g, (_, c: string) => c.toUpperCase());
474
529
 
475
530
  /**
476
531
  * A workflow as a site's `api` has it (`api.<apiNameOf(name)>`), started as the person on the