@sanoma-ai/workflows 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (168) hide show
  1. package/README.md +43 -12
  2. package/dist/apps-list.d.ts +90 -0
  3. package/dist/apps-list.js +109 -0
  4. package/dist/apps-list.js.map +1 -0
  5. package/dist/apps.d.ts +42 -0
  6. package/dist/apps.js +137 -0
  7. package/dist/apps.js.map +1 -0
  8. package/dist/call-workflow.js +5 -6
  9. package/dist/call-workflow.js.map +1 -1
  10. package/dist/call.js +6 -3
  11. package/dist/call.js.map +1 -1
  12. package/dist/cedar.d.ts +1 -1
  13. package/dist/cedar.js +6 -4
  14. package/dist/cedar.js.map +1 -1
  15. package/dist/change-steps.d.ts +10 -0
  16. package/dist/change-steps.js +204 -0
  17. package/dist/change-steps.js.map +1 -0
  18. package/dist/change-workflow.d.ts +54 -0
  19. package/dist/change-workflow.js +161 -0
  20. package/dist/change-workflow.js.map +1 -0
  21. package/dist/change.d.ts +121 -24
  22. package/dist/change.js +196 -17
  23. package/dist/change.js.map +1 -1
  24. package/dist/claims.d.ts +58 -0
  25. package/dist/claims.js +27 -0
  26. package/dist/claims.js.map +1 -0
  27. package/dist/client.d.ts +92 -0
  28. package/dist/client.js +268 -6
  29. package/dist/client.js.map +1 -1
  30. package/dist/collection.d.ts +6 -1
  31. package/dist/collection.js +11 -3
  32. package/dist/collection.js.map +1 -1
  33. package/dist/config.d.ts +56 -8
  34. package/dist/config.js +71 -29
  35. package/dist/config.js.map +1 -1
  36. package/dist/constructors.d.ts +37 -1
  37. package/dist/constructors.js +6 -2
  38. package/dist/constructors.js.map +1 -1
  39. package/dist/db.d.ts +21 -21
  40. package/dist/db.js +31 -54
  41. package/dist/db.js.map +1 -1
  42. package/dist/deadlines.d.ts +18 -12
  43. package/dist/deadlines.js +18 -24
  44. package/dist/deadlines.js.map +1 -1
  45. package/dist/define.d.ts +30 -14
  46. package/dist/define.js +9 -1
  47. package/dist/define.js.map +1 -1
  48. package/dist/demo.d.ts +15 -4
  49. package/dist/demo.js +27 -12
  50. package/dist/demo.js.map +1 -1
  51. package/dist/describe.d.ts +16 -2
  52. package/dist/describe.js +39 -4
  53. package/dist/describe.js.map +1 -1
  54. package/dist/direct.d.ts +4 -3
  55. package/dist/direct.js +7 -4
  56. package/dist/direct.js.map +1 -1
  57. package/dist/errors.d.ts +2 -0
  58. package/dist/errors.js +2 -0
  59. package/dist/errors.js.map +1 -1
  60. package/dist/fake.d.ts +26 -0
  61. package/dist/fake.js +123 -0
  62. package/dist/fake.js.map +1 -1
  63. package/dist/host.d.ts +2 -3
  64. package/dist/host.js +1 -1
  65. package/dist/host.js.map +1 -1
  66. package/dist/index.d.ts +8 -7
  67. package/dist/index.js +7 -6
  68. package/dist/index.js.map +1 -1
  69. package/dist/lint.js +20 -6
  70. package/dist/lint.js.map +1 -1
  71. package/dist/op.d.ts +8 -1
  72. package/dist/op.js.map +1 -1
  73. package/dist/outline.js +13 -6
  74. package/dist/outline.js.map +1 -1
  75. package/dist/paths.d.ts +16 -0
  76. package/dist/paths.js +54 -2
  77. package/dist/paths.js.map +1 -1
  78. package/dist/person.d.ts +5 -0
  79. package/dist/person.js +3 -1
  80. package/dist/person.js.map +1 -1
  81. package/dist/resources.d.ts +7 -2
  82. package/dist/resources.js +27 -10
  83. package/dist/resources.js.map +1 -1
  84. package/dist/run.d.ts +7 -0
  85. package/dist/run.js.map +1 -1
  86. package/dist/scenario.d.ts +2 -2
  87. package/dist/scenario.js +10 -4
  88. package/dist/scenario.js.map +1 -1
  89. package/dist/scenarios.d.ts +1 -1
  90. package/dist/scenarios.js.map +1 -1
  91. package/dist/sdk.d.ts +1 -1
  92. package/dist/sdk.js +19 -5
  93. package/dist/sdk.js.map +1 -1
  94. package/dist/shared.d.ts +43 -2
  95. package/dist/shared.js +46 -4
  96. package/dist/shared.js.map +1 -1
  97. package/dist/sites.d.ts +26 -5
  98. package/dist/sites.js +56 -20
  99. package/dist/sites.js.map +1 -1
  100. package/dist/source.d.ts +179 -5
  101. package/dist/source.js +13 -0
  102. package/dist/source.js.map +1 -1
  103. package/dist/supervisor.d.ts +5 -4
  104. package/dist/supervisor.js +2 -11
  105. package/dist/supervisor.js.map +1 -1
  106. package/dist/version.d.ts +8 -0
  107. package/dist/version.js +21 -13
  108. package/dist/version.js.map +1 -1
  109. package/dist/wakeup-driver.d.ts +128 -0
  110. package/dist/wakeup-driver.js +216 -0
  111. package/dist/wakeup-driver.js.map +1 -0
  112. package/dist/wakeup-service.d.ts +4 -4
  113. package/dist/wakeup-service.js +19 -25
  114. package/dist/wakeup-service.js.map +1 -1
  115. package/dist/wakeup-type.d.ts +43 -0
  116. package/dist/wakeup-type.js +80 -0
  117. package/dist/wakeup-type.js.map +1 -0
  118. package/dist/wakeup.d.ts +1 -8
  119. package/dist/wakeup.js +2 -11
  120. package/dist/wakeup.js.map +1 -1
  121. package/dist/worker.d.ts +2 -2
  122. package/dist/worker.js +50 -7
  123. package/dist/worker.js.map +1 -1
  124. package/oxlint.json +3 -2
  125. package/package.json +3 -1
  126. package/src/apps-list.ts +119 -0
  127. package/src/apps.ts +162 -0
  128. package/src/call-workflow.ts +10 -2
  129. package/src/call.ts +6 -3
  130. package/src/cedar.ts +9 -7
  131. package/src/change-steps.ts +274 -0
  132. package/src/change-workflow.ts +198 -0
  133. package/src/change.ts +257 -27
  134. package/src/claims.ts +79 -0
  135. package/src/client.ts +320 -4
  136. package/src/collection.ts +22 -3
  137. package/src/config.ts +104 -35
  138. package/src/constructors.ts +8 -2
  139. package/src/db.ts +39 -66
  140. package/src/deadlines.ts +27 -30
  141. package/src/define.ts +31 -12
  142. package/src/demo.ts +55 -24
  143. package/src/describe.ts +67 -4
  144. package/src/direct.ts +9 -5
  145. package/src/errors.ts +3 -0
  146. package/src/fake.ts +137 -0
  147. package/src/host.ts +3 -4
  148. package/src/index.ts +22 -3
  149. package/src/lint.ts +21 -6
  150. package/src/op.ts +8 -1
  151. package/src/outline.ts +15 -6
  152. package/src/paths.ts +57 -2
  153. package/src/person.ts +4 -1
  154. package/src/resources.ts +31 -11
  155. package/src/run.ts +5 -0
  156. package/src/scenario.ts +14 -4
  157. package/src/scenarios.ts +1 -1
  158. package/src/sdk.ts +21 -7
  159. package/src/shared.ts +60 -5
  160. package/src/sites.ts +70 -24
  161. package/src/source.ts +168 -5
  162. package/src/supervisor.ts +7 -12
  163. package/src/version.ts +25 -16
  164. package/src/wakeup-driver.ts +270 -0
  165. package/src/wakeup-service.ts +21 -28
  166. package/src/wakeup-type.ts +90 -0
  167. package/src/wakeup.ts +6 -17
  168. package/src/worker.ts +48 -9
package/src/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/fake.ts CHANGED
@@ -9,6 +9,9 @@ import {
9
9
  type Specs,
10
10
  withSetup,
11
11
  } from "./op.ts";
12
+ import { fail, type Instance, UNREGISTERED, type WakeupApi, wakeupCalls } from "./wakeup-driver.ts";
13
+ import { INSTANCE_TOKEN, instanceUrl } from "./wakeup-service.ts";
14
+ import { wakeupOps } from "./wakeup-type.ts";
12
15
 
13
16
  /*
14
17
  * Fake vendors for tests, at `@sanoma-ai/workflows/fake`. Kept off the main entry so the
@@ -166,3 +169,137 @@ export function defineFake<V extends string, S extends Specs, T extends Record<s
166
169
  export function withFake<C extends Connector<any, any>>(connector: C, fake: Fake<any, any>): C {
167
170
  return withSetup(connector, { driver: () => fake.driver, fake: () => fake });
168
171
  }
172
+
173
+ // The built-in `sanoma.wakeup`'s fake: an in-memory wakeup service that calls the host's `/wake`.
174
+
175
+ /** An instance of the fake wakeup service, as it keeps one. */
176
+ interface FakeInstance extends Instance {
177
+ token: string;
178
+ }
179
+
180
+ interface FakeWakeupState extends Record<string, unknown> {
181
+ seq: number;
182
+ instances: Record<string, FakeInstance>;
183
+ }
184
+
185
+ /** How long the fake waits before it sends a wake again that the host did not answer. */
186
+ const RETRY_MS = 250;
187
+ /** setTimeout's longest delay: a longer one fires at once. */
188
+ const MAX_DELAY_MS = 2 ** 31 - 1;
189
+
190
+ /** The URL as the service keeps it (`instanceUrl`, its own rule), or the service's 400. */
191
+ function checkedUrl(value: string): string {
192
+ const checked = instanceUrl(value);
193
+ if ("error" in checked) throw fail(checked.error, 400);
194
+ return checked.url;
195
+ }
196
+
197
+ /**
198
+ * An in-memory wakeup service for tests, as `sanoma wakeup` answers: instances in
199
+ * `state.instances`, ids made in order (UUID-shaped), each named by the token it was posted with
200
+ * (the call's `SANOMA_WAKEUP_TOKEN`), so posting it again moves it. `register` takes only the
201
+ * instance's own token and replaces the wake it had; it then sends `GET {host}/wake` at `at`,
202
+ * again every 250 ms until the host answers 2xx. `fire()` sends every armed wake now; `close()`
203
+ * (and `reset()`) disarms them.
204
+ */
205
+ export function fakeSanomaWakeup(options: FakeOptions = {}) {
206
+ /** The wake each instance waits to send, by its id. */
207
+ const armed = new Map<string, { at: number; url: string; timeout?: NodeJS.Timeout; sending?: boolean }>();
208
+ const disarm = (id: string) => {
209
+ clearTimeout(armed.get(id)?.timeout);
210
+ armed.delete(id);
211
+ };
212
+ /** Sends `id`'s armed wake once it is due, while it is still the one armed, until the host answers. */
213
+ const send = async (id: string, pending = armed.get(id)) => {
214
+ // One request at a time: the one in flight sends again if it is not answered.
215
+ if (!pending || armed.get(id) !== pending || pending.sending) return;
216
+ const due = pending.at - Date.now();
217
+ if (due > 0) {
218
+ pending.timeout = setTimeout(() => void send(id, pending), Math.min(due, MAX_DELAY_MS)).unref();
219
+ return;
220
+ }
221
+ pending.sending = true;
222
+ const answered = await fetch(`${pending.url}/wake`).then(
223
+ (res) => res.ok,
224
+ () => false,
225
+ );
226
+ pending.sending = false;
227
+ if (armed.get(id) !== pending) return;
228
+ if (answered) armed.delete(id);
229
+ else pending.timeout = setTimeout(() => void send(id, pending), RETRY_MS).unref();
230
+ };
231
+
232
+ const api = (state: FakeWakeupState): WakeupApi => ({
233
+ async put(url, token) {
234
+ const checked = checkedUrl(url);
235
+ if (!INSTANCE_TOKEN.test(token)) throw fail("token must be an instance's token", 400);
236
+ const mine = Object.values(state.instances).find((i) => i.token === token);
237
+ if (mine) {
238
+ // The same instance, moved: its wake there forgotten.
239
+ if (mine.url !== checked) {
240
+ Object.assign(mine, { url: checked, ...UNREGISTERED });
241
+ disarm(mine.id);
242
+ }
243
+ return { id: mine.id };
244
+ }
245
+ const id = `00000000-0000-4000-8000-${String(++state.seq).padStart(12, "0")}`;
246
+ state.instances[id] = { id, url: checked, ...UNREGISTERED, token };
247
+ return { id };
248
+ },
249
+ async get(id) {
250
+ const found = state.instances[id];
251
+ if (!found) return undefined;
252
+ const { token: _, ...shown } = found;
253
+ return shown;
254
+ },
255
+ async remove(id) {
256
+ delete state.instances[id];
257
+ disarm(id);
258
+ },
259
+ async wake({ at }, token) {
260
+ // The token names the instance.
261
+ const mine = Object.values(state.instances).find((i) => i.token === token);
262
+ if (!mine) throw fail("the token is no instance's", 401);
263
+ Object.assign(mine, { at: new Date(at).toISOString(), lastError: null });
264
+ disarm(mine.id);
265
+ armed.set(mine.id, { at: Date.parse(at), url: mine.url });
266
+ void send(mine.id);
267
+ },
268
+ });
269
+
270
+ const fake = defineFake(
271
+ wakeupOps,
272
+ {
273
+ initial: (): FakeWakeupState => ({ seq: 0, instances: {} }),
274
+ ops: (state) => ({ wakeup: wakeupCalls(() => api(state)) }),
275
+ },
276
+ options,
277
+ );
278
+ const close = () => {
279
+ for (const id of armed.keys()) disarm(id);
280
+ };
281
+ const { reset } = fake;
282
+ return Object.assign(fake, {
283
+ /** Sends every armed wake now, whatever its `at`; resolves once the hosts answered them, or fails after 60 s. */
284
+ async fire() {
285
+ const fired = [...armed].map(([id, pending]) => {
286
+ clearTimeout(pending.timeout);
287
+ pending.at = 0;
288
+ void send(id, pending);
289
+ return pending;
290
+ });
291
+ const until = Date.now() + 60_000;
292
+ while (fired.some((p) => [...armed.values()].includes(p))) {
293
+ if (Date.now() > until) throw new Error("sanoma.wakeup fake: a fired wake was not answered in 60 s");
294
+ await new Promise((r) => setTimeout(r, 50));
295
+ }
296
+ },
297
+ /** Disarms every armed wake. */
298
+ close,
299
+ /** Empties the state and disarms every armed wake. */
300
+ reset() {
301
+ close();
302
+ reset();
303
+ },
304
+ });
305
+ }
package/src/host.ts CHANGED
@@ -7,14 +7,13 @@ import type { z } from "zod";
7
7
  /**
8
8
  * How a host keeps the runtime up:
9
9
  * - `long-lived`: the process is restarted if it exits, and never stopped for being quiet (Docker).
10
- * - `stops-on-exit`: the host stops the instance when its process exits, and starts it again on
11
- * the next request (Fly).
12
10
  * - `stops-when-quiet`: the host stops the instance after a while without inbound requests, and
13
11
  * starts it again on the next request (a free Render service, Cloudflare Containers).
14
12
  *
15
13
  * A host that stops needs a declared `Wakeup` for what is due at a time while it is stopped.
14
+ * Later: `stops-on-exit`, with a host that stops the instance when its process exits (Fly).
16
15
  */
17
- export type HostLifetime = "long-lived" | "stops-on-exit" | "stops-when-quiet";
16
+ export type HostLifetime = "long-lived" | "stops-when-quiet";
18
17
 
19
18
  /** What a host can do, for choosing one. */
20
19
  export interface HostCapabilities {
@@ -62,7 +61,7 @@ export interface HostSpec {
62
61
  outputs: (state: Record<string, unknown>) => HostOutputs;
63
62
  }
64
63
 
65
- const LIFETIMES: readonly HostLifetime[] = ["long-lived", "stops-on-exit", "stops-when-quiet"];
64
+ const LIFETIMES: readonly HostLifetime[] = ["long-lived", "stops-when-quiet"];
66
65
 
67
66
  /** True when `schema` has a field at the dotted `path`, looking through optional, nullable and default wrappers. */
68
67
  export function hasPath(schema: z.ZodType, path: string): boolean {
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,
@@ -149,8 +156,6 @@ export type { RunArgs } from "./run.ts";
149
156
  export { startWorker, type Worker, type WorkerOptions } from "./worker.ts";
150
157
  export { superviseWorker, type Supervisor, type SupervisorOptions } from "./supervisor.ts";
151
158
  export type { DeadlineSource, Deadlines, SelfStop, Wait } from "./deadlines.ts";
152
- // Whether a wake is the one the instance's worker last registered, for the `/wake` route.
153
- export { shouldWake } from "./db.ts";
154
159
  export { nextFire, nextScheduledFire } from "./schedules.ts";
155
160
  // `secret` itself is at `@sanoma-ai/workflows/resources`, for data files; this is the drivers' side.
156
161
  export {
@@ -163,12 +168,23 @@ export {
163
168
  type Secrets,
164
169
  } from "./secret.ts";
165
170
  export {
171
+ type AppMerge,
166
172
  type CheckResult,
167
173
  type Draft,
174
+ draftBranch,
168
175
  type DraftDiff,
176
+ draftOf,
177
+ type HostEnforcement,
178
+ type PullRequest,
179
+ type RemoteContext,
180
+ type RemoteEvent,
181
+ type RemoteHalf,
182
+ remoteFor,
183
+ type RepoRef,
169
184
  resolveRevision,
170
185
  SOURCE_RESOLVE,
171
186
  type Source,
187
+ type SourceHost,
172
188
  } from "./source.ts";
173
189
  export type { HostCapabilities, HostLifetime, HostOutputs, HostSpec } from "./host.ts";
174
190
  export {
@@ -186,11 +202,12 @@ export {
186
202
  type WakeupOutputs,
187
203
  type WakeupRegistration,
188
204
  type WakeupSpec,
189
- wakeGeneration,
190
205
  wakeupHostTypes,
191
206
  } from "./wakeup.ts";
192
207
  export {
193
208
  type CallOptions,
209
+ type ChangesFilter,
210
+ type ChangeView,
194
211
  type ClientOptions,
195
212
  SanomaClient,
196
213
  type RunsFilter,
@@ -212,12 +229,14 @@ export {
212
229
  CHANGE_MERGE,
213
230
  CHANGE_OPEN,
214
231
  CHANGE_REVIEW,
232
+ CHANGE_TOPIC,
215
233
  CHANGE_WORKFLOW,
216
234
  declaresPeople,
217
235
  type PeopleResource,
218
236
  personOf,
219
237
  principalOf,
220
238
  RUNTIME_AGENTS,
239
+ HOST_CHECK_AGENT,
221
240
  SCHEDULE_AGENT,
222
241
  credentialReady,
223
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
 
@@ -29,7 +29,7 @@ const ALLOWED_PACKAGE = new RegExp(`^(${[...AUTHOR_PACKAGES, "@sanoma-ai/connect
29
29
  const REFUSED_PACKAGES: [RegExp, string][] = [
30
30
  [/^@sanoma-ai\/testing(\/|$)/, `fakes and test helpers belong in tests; ${THROUGH_CTX}`],
31
31
  [/^@sanoma-ai\/app(\/|$)/, "the app starts runs and decides approvals, so a workflow could approve itself"],
32
- [/^@sanoma-ai\/connector-[^/]+\/(fake|driver)(\/|$)/, `${THROUGH_CTX}; import the connector itself`],
32
+ [/^@sanoma-ai\/connector-[^/]+\/(fake|driver|remote)(\/|$)/, `${THROUGH_CTX}; import the connector itself`],
33
33
  [/^sanoma\//, "`sanoma` has no entry points for a workflow: import its names from `sanoma` itself"],
34
34
  ];
35
35
 
@@ -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/op.ts CHANGED
@@ -2,6 +2,7 @@ import type { ProviderV4 } from "@ai-sdk/provider";
2
2
  import type { z } from "zod";
3
3
  import type { Fake } from "./fake.ts";
4
4
  import type { Secrets } from "./secret.ts";
5
+ import type { RemoteHalf } from "./source.ts";
5
6
  import type { Steps } from "./steps.ts";
6
7
 
7
8
  /**
@@ -308,7 +309,8 @@ export interface Driver {
308
309
  * to call: the config then names the connector alone, and never a driver, a fake, steps or a
309
310
  * login. Any may be missing: a workflow using an operation of a connector with no driver is
310
311
  * refused, a connector with no fake has none for sandbox runs, one with no login half signs no
311
- * visitor in, and one with no model half gives the app no models.
312
+ * visitor in, one with no model half gives the app no models, and one with no remote half hosts
313
+ * no repository a change is reviewed on.
312
314
  */
313
315
  export interface ConnectorSetup {
314
316
  /** Makes the live driver, which reads its credentials from the environment when called: once per resolved config. */
@@ -331,6 +333,11 @@ export interface ConnectorSetup {
331
333
  * and a missing one fails that call.
332
334
  */
333
335
  model?: (context: ModelContext) => ProviderV4 | Promise<ProviderV4>;
336
+ /**
337
+ * Makes the connector's remote half, a git host's pull requests, statuses and webhooks: once per
338
+ * resolved config (`ResolvedConfig.remotes`). It reads its credentials when called.
339
+ */
340
+ remote?: () => RemoteHalf;
334
341
  }
335
342
 
336
343
  /** What the app gives a connector's model half (`ConnectorSetup.model`): the secrets, as a login half reads them. */