@sanoma-ai/workflows 0.7.1 → 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 (127) 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 +25 -2
  34. package/dist/config.js +36 -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/scenario.d.ts +2 -2
  72. package/dist/scenario.js +10 -4
  73. package/dist/scenario.js.map +1 -1
  74. package/dist/scenarios.d.ts +1 -1
  75. package/dist/scenarios.js.map +1 -1
  76. package/dist/sdk.d.ts +1 -1
  77. package/dist/sdk.js +19 -5
  78. package/dist/sdk.js.map +1 -1
  79. package/dist/shared.d.ts +43 -2
  80. package/dist/shared.js +46 -4
  81. package/dist/shared.js.map +1 -1
  82. package/dist/sites.d.ts +26 -5
  83. package/dist/sites.js +56 -20
  84. package/dist/sites.js.map +1 -1
  85. package/dist/source.d.ts +55 -5
  86. package/dist/source.js +4 -0
  87. package/dist/source.js.map +1 -1
  88. package/dist/version.d.ts +8 -0
  89. package/dist/version.js +21 -13
  90. package/dist/version.js.map +1 -1
  91. package/dist/worker.js +50 -3
  92. package/dist/worker.js.map +1 -1
  93. package/oxlint.json +2 -2
  94. package/package.json +1 -1
  95. package/src/apps-list.ts +119 -0
  96. package/src/apps.ts +162 -0
  97. package/src/call-workflow.ts +10 -2
  98. package/src/call.ts +6 -3
  99. package/src/cedar.ts +9 -7
  100. package/src/change-steps.ts +274 -0
  101. package/src/change-workflow.ts +198 -0
  102. package/src/change.ts +257 -27
  103. package/src/claims.ts +79 -0
  104. package/src/client.ts +320 -4
  105. package/src/collection.ts +22 -3
  106. package/src/config.ts +60 -22
  107. package/src/db.ts +23 -0
  108. package/src/deadlines.ts +5 -2
  109. package/src/define.ts +31 -12
  110. package/src/demo.ts +55 -24
  111. package/src/describe.ts +67 -4
  112. package/src/direct.ts +9 -5
  113. package/src/errors.ts +3 -0
  114. package/src/index.ts +14 -0
  115. package/src/lint.ts +20 -5
  116. package/src/outline.ts +15 -6
  117. package/src/paths.ts +57 -2
  118. package/src/resources.ts +27 -9
  119. package/src/run.ts +5 -0
  120. package/src/scenario.ts +14 -4
  121. package/src/scenarios.ts +1 -1
  122. package/src/sdk.ts +21 -7
  123. package/src/shared.ts +60 -5
  124. package/src/sites.ts +70 -24
  125. package/src/source.ts +50 -5
  126. package/src/version.ts +25 -16
  127. package/src/worker.ts +46 -3
package/src/db.ts CHANGED
@@ -101,6 +101,29 @@ export function openPool(databaseUrl: string, what: string): Pool {
101
101
  */
102
102
  export const DBOS_DATABASE_OPTIONS = { systemDatabaseIdleTransactionTimeoutMs: 0 } as const;
103
103
 
104
+ /**
105
+ * Stamps the change runs `ids` still unfinished on another version with the application version
106
+ * `version`, so DBOS gives them to a worker of that version: those it stamped, each with its status
107
+ * (a PENDING one is the caller's to queue again). DBOS has no call for it (a run's version is set at
108
+ * its start, `forkWorkflow` makes a new id, `rewindWorkflow` takes only an ended run), so this is the
109
+ * runtime's one write to DBOS's own tables, in its schema `dbos` (dbos-sdk 5.2.11). One statement:
110
+ * of two workers stamping a run, one does.
111
+ */
112
+ export async function stampVersion(
113
+ db: Db,
114
+ ids: readonly string[],
115
+ version: string,
116
+ ): Promise<{ id: string; pending: boolean }[]> {
117
+ const { rows } = await db.query<{ workflow_uuid: string; status: string }>(
118
+ `UPDATE dbos.workflow_status SET application_version = $1
119
+ WHERE workflow_uuid = ANY($2) AND name = 'change' AND status IN ('PENDING', 'ENQUEUED')
120
+ AND application_version <> $1
121
+ RETURNING workflow_uuid, status`,
122
+ [version, ids],
123
+ );
124
+ return rows.map((r) => ({ id: r.workflow_uuid, pending: r.status === "PENDING" }));
125
+ }
126
+
104
127
  /**
105
128
  * A DBOS client of the app `applicationName` on the database at `databaseUrl`, as every client of
106
129
  * the runtime, its tests and `@sanoma-ai/testing` make one (`DBOS_DATABASE_OPTIONS`).
package/src/deadlines.ts CHANGED
@@ -14,8 +14,11 @@ import type { WakeupRegistration } from "./wakeup.ts";
14
14
  // blocked in a `ctx` wait, and until when. DBOS sees both as PENDING. A worker that registers a
15
15
  // wakeup stops itself once nothing can run before the next deadline (`stopWhenIdle`).
16
16
 
17
- /** What a run blocked in a `ctx` wait waits for: a sleep, until its deadline, or an approval's decision, which has none. */
18
- export type Wait = { kind: "sleep"; until: number } | { kind: "approval" };
17
+ /**
18
+ * What a run blocked in a wait waits for: a sleep, until its deadline; or, with no deadline, an
19
+ * approval's decision, a message (a change run's `ChangeEvent`), or another run it started.
20
+ */
21
+ export type Wait = { kind: "sleep"; until: number } | { kind: "approval" | "message" | "run" };
19
22
 
20
23
  /** A deadline besides the sleeping runs', such as a schedule's next fire; undefined for none. */
21
24
  export type DeadlineSource = () => Date | undefined;
package/src/define.ts CHANGED
@@ -2,6 +2,8 @@ import { fileURLToPath } from "node:url";
2
2
  import { getCallSites } from "node:util";
3
3
  import { z } from "zod";
4
4
  import { isOp, type Op } from "./op.ts";
5
+ import { appOfFile } from "./paths.ts";
6
+ import { type DATA_VENDOR, qualified } from "./shared.ts";
5
7
 
6
8
  /**
7
9
  * Who is acting: the person or agent that starts a run, calls an operation or decides an
@@ -171,19 +173,27 @@ export interface StepOptions {
171
173
 
172
174
  type OpsOf<U extends readonly Use[]> = Extract<U[number], Op>;
173
175
 
174
- type CtxOps<O extends Op> = {
175
- readonly [V in O["vendor"]]: {
176
- readonly [R in Extract<O, { vendor: V }>["resource"]]: {
177
- readonly [N in Extract<O, { vendor: V; resource: R }>["name"]]: Extract<
178
- O,
179
- { vendor: V; resource: R; name: N }
180
- > extends Op<any, any, any, infer I, infer Out>
181
- ? (input: I, options?: StepOptions) => Promise<Out>
182
- : never;
183
- };
176
+ type VendorCtx<O extends Op, V> = {
177
+ readonly [R in Extract<O, { vendor: V }>["resource"]]: {
178
+ readonly [N in Extract<O, { vendor: V; resource: R }>["name"]]: Extract<
179
+ O,
180
+ { vendor: V; resource: R; name: N }
181
+ > extends Op<any, any, any, infer I, infer Out>
182
+ ? (input: I, options?: StepOptions) => Promise<Out>
183
+ : never;
184
184
  };
185
185
  };
186
186
 
187
+ /**
188
+ * `ctx.<vendor>.<resource>.<op>`. An app's collections are `ctx.data.<app>.<name>` at run time,
189
+ * a folder's name no type knows: that level is untyped (`any`), the root's collections typed.
190
+ */
191
+ type CtxOps<O extends Op> = {
192
+ readonly [V in O["vendor"]]: V extends typeof DATA_VENDOR
193
+ ? VendorCtx<O, V> & { readonly [app: string]: any }
194
+ : VendorCtx<O, V>;
195
+ };
196
+
187
197
  interface ApprovalCtx {
188
198
  /** Waits, durably, until the approver (or someone in the approver group) approves. Throws `RejectedError` on a rejection, `WithdrawnError` on a withdrawal. */
189
199
  approval(title: string, request: ApprovalRequest): Promise<ApprovalResult>;
@@ -256,7 +266,10 @@ export type Trigger = "manual" | { readonly schedule: string; readonly timezone?
256
266
 
257
267
  export interface WorkflowDefinition<U extends readonly Use[] = readonly Use[], S extends z.ZodType = z.ZodType> {
258
268
  readonly kind: "workflow";
269
+ /** Its name, as written at the root; in an app's folder, `<app>.<name>` (`app`). */
259
270
  readonly name: string;
271
+ /** The app whose folder defined it (`apps/<app>/`), from its `file`; absent for one at the root. */
272
+ readonly app?: string;
260
273
  readonly title?: string;
261
274
  readonly trigger: Trigger;
262
275
  readonly input: S;
@@ -273,8 +286,13 @@ export interface WorkflowDefinition<U extends readonly Use[] = readonly Use[], S
273
286
  /** The ids of the operations a workflow `uses`, in the order declared. */
274
287
  export const opIdsOf = (wf: Pick<WorkflowDefinition, "uses">): string[] => wf.uses.filter(isOp).map((op) => op.id);
275
288
 
289
+ /**
290
+ * Defines a workflow in its own file. Its `name` is a lowercase letter, then lowercase letters,
291
+ * digits and dashes; in an app's folder (`apps/<app>/`, `appOfFile`) the runtime names it
292
+ * `<app>.<name>`, so two apps may each have a `sync`.
293
+ */
276
294
  export function defineWorkflow<const U extends readonly Use[], S extends z.ZodType>(
277
- def: Omit<WorkflowDefinition<U, S>, "kind" | "file">,
295
+ def: Omit<WorkflowDefinition<U, S>, "kind" | "file" | "app">,
278
296
  ): WorkflowDefinition<U, S> {
279
297
  if (!/^[a-z][a-z0-9-]*$/.test(def.name)) {
280
298
  throw new Error(`Workflow name "${def.name}" must be lowercase letters, digits and dashes`);
@@ -293,7 +311,8 @@ export function defineWorkflow<const U extends readonly Use[], S extends z.ZodTy
293
311
  "its outline is read from the file, and a run is held to the outline",
294
312
  );
295
313
  }
296
- return Object.freeze({ kind: "workflow", ...def, file });
314
+ const app = appOfFile(file);
315
+ return Object.freeze({ kind: "workflow", ...def, name: qualified(app, def.name), ...(app && { app }), file });
297
316
  }
298
317
 
299
318
  /** True when `{}` parses: a schema zod can check only asynchronously is taken as accepting it, and the run parses it. */
package/src/demo.ts CHANGED
@@ -2,9 +2,9 @@ import type { SanomaClient } from "./client.ts";
2
2
  import type { ResolvedConfig } from "./config.ts";
3
3
  import { DATA_TABLE } from "./db.ts";
4
4
  import { applyGivens } from "./sandbox.ts";
5
- import { loadScenarios, type Moment } from "./scenario.ts";
5
+ import { type Given, loadScenarios, type Moment } from "./scenario.ts";
6
6
  import { memorySecrets } from "./secret.ts";
7
- import { errorMessage } from "./shared.ts";
7
+ import { DATA_VENDOR, errorMessage } from "./shared.ts";
8
8
 
9
9
  /*
10
10
  * A demo: a company repo run on its connectors' fakes, with the world its scenarios describe.
@@ -27,20 +27,43 @@ export interface DemoState {
27
27
  const states = new WeakMap<ResolvedConfig, DemoState>();
28
28
 
29
29
  /**
30
- * Seeds the demo instance afresh: empties the collections' table, resets the instance's fakes,
30
+ * A half of a demo's seed (`reseed`'s `only`): what goes into its `database` (the runs cancelled,
31
+ * `sanoma_data` emptied, the worlds' collection records, the moments' runs), or into its `memory`
32
+ * (the instance's fakes reset, the worlds' vendor objects and calls). A copy of a seeded database
33
+ * holds the first half but not the second, which a process over it applies to its own fakes.
34
+ */
35
+ export type SeedHalf = "database" | "memory";
36
+
37
+ /** Which half a world's `Given` goes to: a collection's operation the database, anything else memory; a fault both, each refusing it. */
38
+ const halfOf = (g: Given, resolved: ResolvedConfig): SeedHalf | undefined =>
39
+ "fault" in g ? undefined : "op" in g && resolved.ops.get(g.op)?.vendor === DATA_VENDOR ? "database" : "memory";
40
+
41
+ /**
42
+ * Seeds the demo instance afresh: cancels the app's unfinished runs (`cancelUnfinished`: the last
43
+ * seed's moments among them), empties the collections' table, resets the instance's fakes,
31
44
  * applies every world (a scenario with no `When`) to the instance (`applyGivens`: its records
32
45
  * saved through the collections' driver, its vendor objects put in the fakes), then starts each
33
46
  * moment as a sandbox run by its person, left undriven, so its first approval waits for them.
34
- * `resolved` is the resolution the worker and `client` run on, made with `fakes: "instance"`.
35
- * A world or a moment that fails is a problem in the state it returns, and the rest go on.
47
+ * `only` seeds one half (`SeedHalf`). `resolved` is the resolution the worker and `client` run
48
+ * on, made with `fakes: "instance"`. A world or a moment that fails is a problem in the state it
49
+ * returns, and the rest go on.
36
50
  */
37
- export async function reseed(client: SanomaClient, resolved: ResolvedConfig): Promise<DemoState> {
51
+ export async function reseed(
52
+ client: SanomaClient,
53
+ resolved: ResolvedConfig,
54
+ { only }: { only?: SeedHalf } = {},
55
+ ): Promise<DemoState> {
38
56
  const fakes = resolved.instanceFakes;
39
57
  if (!fakes) throw new Error('reseed seeds an instance on fakes: resolve its config with `fakes: "instance"`');
58
+ const into = (half: SeedHalf) => only === undefined || only === half;
40
59
  // Read first, so scenarios that cannot be read at all leave the instance as it was.
41
60
  const { scenarios, errors } = await loadScenarios(resolved);
42
- if (resolved.dataStore) await (await resolved.dataStore.db()).query(`TRUNCATE ${DATA_TABLE}`);
43
- for (const fake of fakes.values()) fake.reset();
61
+ if (into("database")) {
62
+ // The last seed's moments, and whatever a visitor started since, end here: nothing is kept.
63
+ await client.cancelUnfinished();
64
+ if (resolved.dataStore) await (await resolved.dataStore.db()).query(`TRUNCATE ${DATA_TABLE}`);
65
+ }
66
+ if (into("memory")) for (const fake of fakes.values()) fake.reset();
44
67
  const state: DemoState = {
45
68
  seededAt: new Date().toISOString(),
46
69
  worlds: [],
@@ -49,28 +72,36 @@ export async function reseed(client: SanomaClient, resolved: ResolvedConfig): Pr
49
72
  };
50
73
  const secrets = memorySecrets();
51
74
  for (const world of scenarios.filter((s) => s.workflow === undefined)) {
75
+ // Each Given keeps its place in the world, so its call's idempotency key is the same in either half.
76
+ const kept = [...world.given.entries()].filter(([, g]) => {
77
+ const half = halfOf(g, resolved);
78
+ return half === undefined || into(half);
79
+ });
52
80
  try {
53
- await applyGivens(world, {
54
- kind: "instance",
55
- drivers: resolved.drivers,
56
- fakes,
57
- ops: resolved.ops,
58
- call: (i, opId) => ({
59
- idempotencyKey: `world:${world.name}:${i}`,
60
- runId: `world:${world.name}`,
61
- opId,
62
- attempt: 1,
63
- secrets,
64
- // Who its records are by: whoever it says is acting, else the runtime's own agent.
65
- by: world.startedBy ?? "scenarios",
66
- }),
67
- });
81
+ await applyGivens(
82
+ { name: world.name, given: kept.map(([, g]) => g) },
83
+ {
84
+ kind: "instance",
85
+ drivers: resolved.drivers,
86
+ fakes,
87
+ ops: resolved.ops,
88
+ call: (i, opId) => ({
89
+ idempotencyKey: `world:${world.name}:${kept[i]![0]}`,
90
+ runId: `world:${world.name}`,
91
+ opId,
92
+ attempt: 1,
93
+ secrets,
94
+ // Who its records are by: whoever it says is acting, else the runtime's own agent.
95
+ by: world.startedBy ?? "scenarios",
96
+ }),
97
+ },
98
+ );
68
99
  state.worlds.push(world.name);
69
100
  } catch (err) {
70
101
  state.problems.push(`world "${world.name}": ${errorMessage(err)}`);
71
102
  }
72
103
  }
73
- for (const s of scenarios) {
104
+ for (const s of into("database") ? scenarios : []) {
74
105
  if (!s.moment || s.workflow === undefined) continue;
75
106
  try {
76
107
  const runId = await client.start(s.workflow, s.input, { sandbox: s.name, startedBy: s.moment.person });
package/src/describe.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { join } from "node:path";
2
2
  import type { z } from "zod";
3
+ import { type AppEntry, appsAt } from "./apps.ts";
3
4
  import { type LintProblem, readSource, type SourceFile } from "./ast.ts";
4
5
  import { type Field, recordSchemaOf } from "./collection.ts";
5
6
  import { connectorsOf, resolveConfig, rootOf, type SanomaConfig } from "./config.ts";
@@ -14,6 +15,7 @@ import {
14
15
  type DataFiles,
15
16
  type DeclaredCollection,
16
17
  type DeclaredResource,
18
+ inAppPeople,
17
19
  readDataFiles,
18
20
  type ResourceProblem,
19
21
  } from "./resources.ts";
@@ -62,8 +64,19 @@ export {
62
64
  type Bindings,
63
65
  type Rebind,
64
66
  } from "./lockfile.ts";
67
+ // The apps under `apps/`, read without running their `app.ts`.
68
+ export { crossAppImports, readApps, type AppEntry } from "./apps.ts";
65
69
  // What the sites call, read from their source: a workflow's Used by.
66
- export { readSites, type SiteCall, type SiteEntry, type SitePage, siteContentPages, siteLocales } from "./sites.ts";
70
+ export {
71
+ readSites,
72
+ type SiteCall,
73
+ type SiteEntry,
74
+ type SitePage,
75
+ siteContentPages,
76
+ siteIcons,
77
+ siteLocales,
78
+ sitesUnder,
79
+ } from "./sites.ts";
67
80
  // The generated SDK, `.sanoma/api.ts`: a site's `api` typed from this description.
68
81
  export { API_FILE, generateApi, writeApi, writeGenerated } from "./sdk.ts";
69
82
  // The Cedar schema, `.sanoma/schema.cedarschema`: what a company's policies are validated and decided against.
@@ -76,7 +89,10 @@ export type { ApplyInput, ApplyPlan, ApplyReport, ApplyResult, PlanAnswer, PlanS
76
89
 
77
90
  /** What a workflow is, read from its definition: enough to draw a start form and show what it may call. */
78
91
  export interface WorkflowEntry {
92
+ /** Its name: `<app>.<name>` for one an app's folder defines. */
79
93
  name: string;
94
+ /** The app whose folder defines it (`apps/<app>/workflows/`); absent for one at the root. */
95
+ app?: string;
80
96
  title?: string;
81
97
  /** `manual`, or the schedule it starts on (`defineWorkflow`'s `trigger`). */
82
98
  trigger: Trigger;
@@ -166,7 +182,10 @@ export interface ResourceTypeEntry {
166
182
 
167
183
  /** A collection the config declares (`collection()`). */
168
184
  export interface CollectionEntry {
185
+ /** Its name: `<app>.<name>` for one an app's `resources/data.ts` declares. */
169
186
  name: string;
187
+ /** The app whose folder declares it (`apps/<app>/resources/data.ts`); absent for one at the root. */
188
+ app?: string;
170
189
  title?: string;
171
190
  /** The field each record is found by. */
172
191
  key: string;
@@ -204,8 +223,14 @@ export interface ConfigDescription {
204
223
  resources: DeclaredResource[];
205
224
  /** Each binding in the lockfile (`LOCKFILE`) whose key no data file declares, as `readDataFiles` reports it. */
206
225
  orphans: DataFiles["orphans"];
207
- /** What is wrong in the data files (`readDataFiles`), each at its file, relative to the root, line and column. A workflow's are on its entry. */
226
+ /**
227
+ * What is wrong in the data files (`readDataFiles`) and the apps (a folder of `apps/` that is no
228
+ * app, an `app.ts` that cannot be read, a collection or site at the root named as an app is),
229
+ * each at its file, relative to the root, line and column. A workflow's are on its entry.
230
+ */
208
231
  problems: ResourceProblem[];
232
+ /** The apps under `apps/` (`readApps`), by name. Each workflow, collection and site an app declares names it (`app`). */
233
+ apps: AppEntry[];
209
234
  /**
210
235
  * `defined` when the root has `policies/**\/*.cedar` (else the policy is `allowAll`), with their
211
236
  * `version`, or while they have `problems` (`policyProblems`), none: then every call and start is refused.
@@ -258,6 +283,7 @@ export function describeConfig(config: SanomaConfig): ConfigDescription {
258
283
  const collections: CollectionEntry[] = [...resolved.collections.values()]
259
284
  .map((c) => ({
260
285
  name: c.name,
286
+ ...(c.app !== undefined && { app: c.app }),
261
287
  ...(c.title !== undefined && { title: c.title }),
262
288
  key: c.key,
263
289
  fields: c.fields,
@@ -294,6 +320,7 @@ export function describeConfig(config: SanomaConfig): ConfigDescription {
294
320
  ];
295
321
  return {
296
322
  name: wf.name,
323
+ ...(wf.app !== undefined && { app: wf.app }),
297
324
  title: wf.title,
298
325
  trigger: wf.trigger,
299
326
  ...(builtin && { builtin: true as const }),
@@ -308,6 +335,7 @@ export function describeConfig(config: SanomaConfig): ConfigDescription {
308
335
  });
309
336
  workflows.sort((a, b) => a.name.localeCompare(b.name));
310
337
  addApiNameProblems(workflows, new Set(ops.map((op) => op.vendor)));
338
+ const { apps, problems: appProblems } = appsAt(resolved.root);
311
339
  const sites = readSites(
312
340
  resolved.root,
313
341
  { workflows, collections, ops },
@@ -323,9 +351,16 @@ export function describeConfig(config: SanomaConfig): ConfigDescription {
323
351
  vendors,
324
352
  resourceTypes,
325
353
  collections,
326
- resources: dataFiles.resources,
354
+ // An app's people are refused (`readPeople`), so the grid and the People page never show them.
355
+ resources: dataFiles.resources.filter((r) => !inAppPeople(r)),
327
356
  orphans: dataFiles.orphans,
328
- problems: [...dataFiles.problems, ...domainProblems(resolved.root, dataFiles.resources, resources, sites, read)],
357
+ problems: [
358
+ ...dataFiles.problems,
359
+ ...appProblems,
360
+ ...appClashes(apps, collections, sites),
361
+ ...domainProblems(resolved.root, dataFiles.resources, resources, sites, read),
362
+ ],
363
+ apps,
329
364
  policy: {
330
365
  defined: resolved.policy !== allowAll,
331
366
  ...(resolved.policy.version === undefined ? {} : { version: resolved.policy.version }),
@@ -335,6 +370,34 @@ export function describeConfig(config: SanomaConfig): ConfigDescription {
335
370
  };
336
371
  }
337
372
 
373
+ /** That the root's `kind` `name`, in `file`, is named as an app is. */
374
+ const appClash = (kind: string, name: string, file: string): ResourceProblem => ({
375
+ file,
376
+ line: 1,
377
+ column: 1,
378
+ message: `the ${kind} ${name} is named as the app apps/${name} is: name it otherwise`,
379
+ });
380
+
381
+ /**
382
+ * A collection or site at the root named as an app is: `api.data.<app>` and `/sites/<app>/` are
383
+ * the app's, so the root's would take its place.
384
+ */
385
+ function appClashes(
386
+ apps: readonly AppEntry[],
387
+ collections: readonly CollectionEntry[],
388
+ sites: readonly SiteEntry[],
389
+ ): ResourceProblem[] {
390
+ const names = new Set(apps.map((app) => app.name));
391
+ return [
392
+ ...collections
393
+ .filter((c) => c.app === undefined && names.has(c.name))
394
+ .map((c) => appClash("collection", c.name, c.declared?.file ?? "resources/data.ts")),
395
+ ...sites
396
+ .filter((site) => site.app === undefined && names.has(site.name))
397
+ .map((site) => appClash("site", site.name, `sites/${site.name}`)),
398
+ ];
399
+ }
400
+
338
401
  /**
339
402
  * Each site a host's `domains` (its type's `host.inputs.domains`) names that is not a public
340
403
  * site, and each domain it gives more than one site, at the host's declaration: the app serves
package/src/direct.ts CHANGED
@@ -1,15 +1,19 @@
1
1
  import type { Op } from "./op.ts";
2
2
  import { isResourceOp } from "./resource.ts";
3
- import { DATA_VENDOR } from "./shared.ts";
3
+ import { APPS_LIST, DATA_VENDOR } from "./shared.ts";
4
4
 
5
5
  /**
6
6
  * Whether an operation may be called directly, outside a run (`SanomaClient.call`), as the
7
- * person: a collection's (`data.<name>.<op>`), or a `read` that is neither the runtime's own
8
- * (the vendor `sanoma`) nor a resource type's (`isResourceOp`), which only drift and apply call.
7
+ * person: a collection's (`data.<name>.<op>`), the runtime's listing of apps (`sanoma.apps.list`,
8
+ * which a page calls), or a `read` that is neither the runtime's own (the vendor `sanoma`) nor a
9
+ * resource type's (`isResourceOp`), which only drift and apply call.
9
10
  * Anything else (an effect at a vendor) is a workflow's. The one rule: the built-in `call`
10
11
  * workflow, the SDK and the app read it, from here or from `OpEntry.direct`, and never derive it.
11
12
  */
12
13
  export const isDirect = (
13
- op: Pick<Op, "vendor" | "resource" | "effect">,
14
+ op: Pick<Op, "id" | "vendor" | "resource" | "effect">,
14
15
  types: ReadonlyMap<string, unknown>,
15
- ): boolean => op.vendor === DATA_VENDOR || (op.effect === "read" && op.vendor !== "sanoma" && !isResourceOp(op, types));
16
+ ): boolean =>
17
+ op.vendor === DATA_VENDOR ||
18
+ op.id === APPS_LIST ||
19
+ (op.effect === "read" && op.vendor !== "sanoma" && !isResourceOp(op, types));
package/src/errors.ts CHANGED
@@ -51,6 +51,9 @@ export function errorCode(e: unknown): ErrorCode | undefined {
51
51
  return code !== undefined && KNOWN.has(code) ? (code as ErrorCode) : undefined;
52
52
  }
53
53
 
54
+ /** A `DriverError`'s `vendorCode` (`no_token`, `bad_signature`), read as a property: undefined for anything else. */
55
+ export const vendorCodeOf = (e: unknown): string | undefined => own<string>(e, "vendorCode", "string");
56
+
54
57
  /**
55
58
  * True when the error says trying again would only repeat it (`retryable: false`), as a
56
59
  * `DriverError` for a vendor's final answer or a ledger store for a corrupt file does. Read as
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[] = [];