@sanoma-ai/workflows 0.9.0 → 0.10.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 (205) hide show
  1. package/README.md +169 -52
  2. package/dist/apply-steps.js +5 -2
  3. package/dist/apply-steps.js.map +1 -1
  4. package/dist/apply.d.ts +3 -0
  5. package/dist/apply.js +35 -21
  6. package/dist/apply.js.map +1 -1
  7. package/dist/approvals.d.ts +1 -0
  8. package/dist/approvals.js +6 -1
  9. package/dist/approvals.js.map +1 -1
  10. package/dist/apps.d.ts +7 -24
  11. package/dist/apps.js +51 -67
  12. package/dist/apps.js.map +1 -1
  13. package/dist/ast.d.ts +9 -7
  14. package/dist/ast.js +2 -0
  15. package/dist/ast.js.map +1 -1
  16. package/dist/authentication.d.ts +24 -0
  17. package/dist/authentication.js +68 -0
  18. package/dist/authentication.js.map +1 -0
  19. package/dist/call-workflow.d.ts +5 -0
  20. package/dist/call-workflow.js +4 -3
  21. package/dist/call-workflow.js.map +1 -1
  22. package/dist/call.d.ts +3 -2
  23. package/dist/call.js +34 -7
  24. package/dist/call.js.map +1 -1
  25. package/dist/cedar.js +14 -1
  26. package/dist/cedar.js.map +1 -1
  27. package/dist/change-steps.js +7 -5
  28. package/dist/change-steps.js.map +1 -1
  29. package/dist/change.d.ts +7 -0
  30. package/dist/change.js +7 -0
  31. package/dist/change.js.map +1 -1
  32. package/dist/client.d.ts +10 -6
  33. package/dist/client.js +96 -43
  34. package/dist/client.js.map +1 -1
  35. package/dist/collection.d.ts +7 -0
  36. package/dist/collection.js +5 -2
  37. package/dist/collection.js.map +1 -1
  38. package/dist/config.d.ts +14 -3
  39. package/dist/config.js +55 -14
  40. package/dist/config.js.map +1 -1
  41. package/dist/connection.d.ts +168 -0
  42. package/dist/connection.js +327 -0
  43. package/dist/connection.js.map +1 -0
  44. package/dist/credentials.d.ts +7 -53
  45. package/dist/credentials.js +84 -247
  46. package/dist/credentials.js.map +1 -1
  47. package/dist/cucumber.d.ts +4 -2
  48. package/dist/cucumber.js +8 -3
  49. package/dist/cucumber.js.map +1 -1
  50. package/dist/db.js +21 -2
  51. package/dist/db.js.map +1 -1
  52. package/dist/define.d.ts +9 -2
  53. package/dist/define.js.map +1 -1
  54. package/dist/definitions.d.ts +20 -0
  55. package/dist/definitions.js +16 -0
  56. package/dist/definitions.js.map +1 -0
  57. package/dist/deployment-key.d.ts +2 -0
  58. package/dist/deployment-key.js +22 -0
  59. package/dist/deployment-key.js.map +1 -0
  60. package/dist/describe.d.ts +9 -177
  61. package/dist/describe.js +71 -34
  62. package/dist/describe.js.map +1 -1
  63. package/dist/description.d.ts +532 -0
  64. package/dist/description.js +381 -0
  65. package/dist/description.js.map +1 -0
  66. package/dist/direct.js +3 -0
  67. package/dist/direct.js.map +1 -1
  68. package/dist/drift.d.ts +1 -1
  69. package/dist/drift.js +1 -1
  70. package/dist/drift.js.map +1 -1
  71. package/dist/env.d.ts +2 -0
  72. package/dist/env.js +2 -2
  73. package/dist/env.js.map +1 -1
  74. package/dist/index.d.ts +10 -2
  75. package/dist/index.js +10 -2
  76. package/dist/index.js.map +1 -1
  77. package/dist/ledger.d.ts +2 -0
  78. package/dist/ledger.js +1 -1
  79. package/dist/ledger.js.map +1 -1
  80. package/dist/lint.d.ts +0 -9
  81. package/dist/lint.js +1 -16
  82. package/dist/lint.js.map +1 -1
  83. package/dist/load.d.ts +74 -0
  84. package/dist/load.js +357 -0
  85. package/dist/load.js.map +1 -0
  86. package/dist/lockfile.d.ts +2 -0
  87. package/dist/lockfile.js +8 -3
  88. package/dist/lockfile.js.map +1 -1
  89. package/dist/op.d.ts +10 -1
  90. package/dist/op.js +1 -1
  91. package/dist/op.js.map +1 -1
  92. package/dist/outline.js +13 -4
  93. package/dist/outline.js.map +1 -1
  94. package/dist/paths.d.ts +18 -0
  95. package/dist/paths.js +21 -3
  96. package/dist/paths.js.map +1 -1
  97. package/dist/plugin.d.ts +0 -11
  98. package/dist/plugin.js +3 -18
  99. package/dist/plugin.js.map +1 -1
  100. package/dist/policy.d.ts +2 -0
  101. package/dist/policy.js.map +1 -1
  102. package/dist/resource.d.ts +27 -4
  103. package/dist/resource.js +49 -4
  104. package/dist/resource.js.map +1 -1
  105. package/dist/resources.d.ts +33 -76
  106. package/dist/resources.js +219 -227
  107. package/dist/resources.js.map +1 -1
  108. package/dist/run.d.ts +2 -0
  109. package/dist/run.js.map +1 -1
  110. package/dist/sandbox.d.ts +1 -1
  111. package/dist/sandbox.js +7 -3
  112. package/dist/sandbox.js.map +1 -1
  113. package/dist/scenario.d.ts +7 -0
  114. package/dist/scenario.js +33 -3
  115. package/dist/scenario.js.map +1 -1
  116. package/dist/scenarios.js +7 -1
  117. package/dist/scenarios.js.map +1 -1
  118. package/dist/schema.js +8 -0
  119. package/dist/schema.js.map +1 -1
  120. package/dist/secret-store-check.d.ts +11 -0
  121. package/dist/secret-store-check.js +76 -0
  122. package/dist/secret-store-check.js.map +1 -0
  123. package/dist/secret-store.d.ts +106 -0
  124. package/dist/secret-store.js +228 -0
  125. package/dist/secret-store.js.map +1 -0
  126. package/dist/shared.d.ts +17 -11
  127. package/dist/shared.js +34 -7
  128. package/dist/shared.js.map +1 -1
  129. package/dist/sites.d.ts +4 -93
  130. package/dist/sites.js +61 -84
  131. package/dist/sites.js.map +1 -1
  132. package/dist/source.d.ts +6 -0
  133. package/dist/source.js +13 -0
  134. package/dist/source.js.map +1 -1
  135. package/dist/steps.d.ts +3 -1
  136. package/dist/steps.js.map +1 -1
  137. package/dist/version.d.ts +2 -1
  138. package/dist/version.js +2 -1
  139. package/dist/version.js.map +1 -1
  140. package/dist/wakeup-driver.js +15 -6
  141. package/dist/wakeup-driver.js.map +1 -1
  142. package/dist/worker.js +12 -8
  143. package/dist/worker.js.map +1 -1
  144. package/dist/writer.d.ts +53 -14
  145. package/dist/writer.js +113 -43
  146. package/dist/writer.js.map +1 -1
  147. package/oxlint.json +0 -5
  148. package/package.json +6 -2
  149. package/src/apply-steps.ts +5 -2
  150. package/src/apply.ts +70 -29
  151. package/src/approvals.ts +7 -1
  152. package/src/apps.ts +54 -85
  153. package/src/ast.ts +6 -6
  154. package/src/authentication.ts +74 -0
  155. package/src/call-workflow.ts +5 -4
  156. package/src/call.ts +53 -9
  157. package/src/cedar.ts +14 -1
  158. package/src/change-steps.ts +7 -4
  159. package/src/change.ts +10 -0
  160. package/src/client.ts +108 -41
  161. package/src/collection.ts +10 -2
  162. package/src/config.ts +72 -13
  163. package/src/connection.ts +446 -0
  164. package/src/credentials.ts +94 -281
  165. package/src/cucumber.ts +16 -3
  166. package/src/db.ts +21 -2
  167. package/src/define.ts +7 -2
  168. package/src/definitions.ts +31 -0
  169. package/src/deployment-key.ts +23 -0
  170. package/src/describe.ts +102 -230
  171. package/src/description.ts +426 -0
  172. package/src/direct.ts +3 -0
  173. package/src/drift.ts +10 -2
  174. package/src/env.ts +4 -2
  175. package/src/index.ts +42 -1
  176. package/src/ledger.ts +3 -1
  177. package/src/lint.ts +1 -16
  178. package/src/load.ts +437 -0
  179. package/src/lockfile.ts +10 -3
  180. package/src/op.ts +10 -1
  181. package/src/outline.ts +18 -5
  182. package/src/paths.ts +24 -2
  183. package/src/plugin.ts +3 -24
  184. package/src/policy.ts +2 -0
  185. package/src/resource.ts +67 -6
  186. package/src/resources.ts +246 -301
  187. package/src/run.ts +2 -0
  188. package/src/sandbox.ts +11 -3
  189. package/src/scenario.ts +45 -3
  190. package/src/scenarios.ts +7 -1
  191. package/src/schema.ts +10 -0
  192. package/src/secret-store-check.ts +93 -0
  193. package/src/secret-store.ts +314 -0
  194. package/src/shared.ts +35 -10
  195. package/src/sites.ts +70 -180
  196. package/src/source.ts +14 -0
  197. package/src/steps.ts +3 -1
  198. package/src/version.ts +2 -1
  199. package/src/wakeup-driver.ts +22 -6
  200. package/src/worker.ts +12 -8
  201. package/src/writer.ts +145 -56
  202. package/dist/datafile.d.ts +0 -97
  203. package/dist/datafile.js +0 -479
  204. package/dist/datafile.js.map +0 -1
  205. package/src/datafile.ts +0 -601
package/src/load.ts ADDED
@@ -0,0 +1,437 @@
1
+ import { readFileSync, realpathSync, statSync } from "node:fs";
2
+ import { createRequire, registerHooks } from "node:module";
3
+ import { dirname, isAbsolute, join, relative, sep } from "node:path";
4
+ import { fileURLToPath, pathToFileURL } from "node:url";
5
+ import { collectionIn } from "./collection.ts";
6
+ import { callerFile } from "./define.ts";
7
+ import { defineApp, defineSite } from "./definitions.ts";
8
+ import { type Connector, VENDOR } from "./op.ts";
9
+ import { APPS_DIR, appOfPath, DATA_ENTRIES, inside, relPath, SECRET_ENTRIES, SITE_ENTRIES } from "./paths.ts";
10
+ import { type Declared, isDeclared, isObject, joinPath, type Resource, resourceTypesOf } from "./resource.ts";
11
+ import { secret } from "./secret.ts";
12
+ import { errorMessage } from "./shared.ts";
13
+
14
+ // A company's files, loaded: synchronously (`require` of an ES module, Node's type stripping and
15
+ // `module.registerHooks`), since what reads them is (`describeConfig`, `readPeople`). Each load
16
+ // is a *generation*: its files are modules of their own (`file:…?sanoma-load=<id>`), so an edit
17
+ // is read once a new generation starts, and what they import from the runtime (constructors,
18
+ // `collection`, `defineSite`, `defineApp`) is served from the loader's own objects, which record
19
+ // where each value was made. A superseded generation's modules stay in Node's cache: each edit
20
+ // costs one copy of the files it read.
21
+
22
+ /** The scheme of the loader's own modules: an entry, `sanoma-load:<id>/<file URL>`, and a runtime module, `sanoma-load:<id>/<specifier>`. */
23
+ const SCHEME = "sanoma-load:";
24
+ /** The query that tags a company file with its generation: `file:///…/github.ts?sanoma-load=<id>`. */
25
+ const QUERY = "sanoma-load";
26
+
27
+ /** Where a value a company file made was made, by the constructor that made it. */
28
+ export interface Made {
29
+ /** The file that called the constructor, relative to the root, with `/`: `resources/github.ts`. */
30
+ file: string;
31
+ /** Its place among everything its generation made, from 1: in each file, the order written. */
32
+ seq: number;
33
+ /**
34
+ * Each declared resource its fields name, by the field's dotted path (a list item's with its
35
+ * index, `teams.0`), as the constructor's `refs` names them.
36
+ */
37
+ refers: Record<string, Declared>;
38
+ }
39
+
40
+ /**
41
+ * What loading a file gave: its exports, the values it made first in the order made, then the
42
+ * rest by name, less any value another file loaded with it made and exports (a re-export); or
43
+ * the error it threw, placed in the file (from 1, UTF-16 columns) when its stack says where; or,
44
+ * when the error is another file's (a file it imports threw), that file, where it is reported.
45
+ */
46
+ export type Loaded =
47
+ | { exports: [name: string, value: unknown][] }
48
+ | { error: unknown; line?: number; column?: number }
49
+ | { failedIn: string };
50
+
51
+ /** A load of a company's files: its modules, the runtime's modules it serves them, and what each file's stamp was. */
52
+ interface Generation {
53
+ id: string;
54
+ /** Real (`realpathSync`): the ES module resolver gives real paths. */
55
+ root: string;
56
+ /** What it serves the runtime's modules for: its constructors' identities and packages. */
57
+ typesKey: string;
58
+ /** The runtime's modules it serves, by specifier: each module's exports. */
59
+ virtual: Map<string, Record<string, unknown>>;
60
+ /** Each company file it resolved or was asked for, and each one's directory, by its size and modification time. */
61
+ stamps: Map<string, string>;
62
+ /** Every value its files have made, in the order made. */
63
+ made: object[];
64
+ /** Each company file its files imported, directly or not, by its path relative to the root. */
65
+ imported: Set<string>;
66
+ /** What loading each file gave, by its path relative to the root: an entry's, or a file an error is placed in. */
67
+ loaded: Map<string, Loaded>;
68
+ /** Each error reported, to the file it is reported in. */
69
+ reported: Map<unknown, string>;
70
+ /** What each call returned, by its `only` (the generation itself for none) and then the paths: the same map while nothing changed. */
71
+ answers: WeakMap<object, Map<string, Map<string, Loaded>>>;
72
+ }
73
+
74
+ /** The loader's state, one per process: two copies of this package share it (and one set of hooks). */
75
+ interface Registry {
76
+ generations: Map<string, Generation>;
77
+ seq: number;
78
+ /** An id for each constructor object, for `typesKey`. */
79
+ ids: WeakMap<object, number>;
80
+ /** Where each value a company file made was made. */
81
+ made: WeakMap<object, Made>;
82
+ hooked?: boolean;
83
+ }
84
+
85
+ const registry = ((globalThis as Record<symbol, unknown>)[Symbol.for("sanoma.load")] ??= {
86
+ generations: new Map(),
87
+ seq: 0,
88
+ ids: new WeakMap(),
89
+ made: new WeakMap(),
90
+ }) as Registry;
91
+
92
+ const require = createRequire(import.meta.url);
93
+
94
+ /** Where `value` was made, when a company file made it with a constructor the loader served. */
95
+ export const madeOf = (value: unknown): Made | undefined =>
96
+ typeof value === "object" && value !== null ? registry.made.get(value) : undefined;
97
+
98
+ /** Where `defineApp` comes from, served only to an app's `app.ts`. */
99
+ const APP_ENTRIES: ReadonlySet<string> = new Set(["sanoma", "@sanoma-ai/workflows"]);
100
+
101
+ /**
102
+ * Loads `paths` (absolute, under `root`) as the generation of `root` and the constructors of
103
+ * `connectors` (`types`, by default theirs) has them, and says what each gave, by its path
104
+ * relative to the root with `/`, sorted. The same generation, and the same answer for the same
105
+ * paths, while no file it read, nor any file's directory, changed (by size and modification time:
106
+ * one `stat` each); else a new one, so a caller may key what it makes of the answer by it (and
107
+ * never changes it). `only` loads only the files whose text it passes; the answer is kept by its
108
+ * identity, so pass one function, not a new one per call.
109
+ *
110
+ * Served to the files, by specifier: `sanoma/resources` and `@sanoma-ai/workflows/resources`
111
+ * (`sanoma`, the constructors of the vendor `sanoma`, and `secret`); each connector's
112
+ * `<info.package>/resources` (its vendor's constructors, under its id); `sanoma/data` and
113
+ * `@sanoma-ai/workflows/data` (`collection`); `sanoma/site` and `@sanoma-ai/app/site`
114
+ * (`defineSite`); and to an app's `apps/<app>/app.ts` alone, `sanoma` and `@sanoma-ai/workflows`
115
+ * (`defineApp`). Anything else is resolved as Node does.
116
+ */
117
+ export function loadModules(
118
+ root: string,
119
+ paths: readonly string[],
120
+ connectors: readonly Connector<any, any>[],
121
+ {
122
+ only,
123
+ types = resourceTypesOf(connectors),
124
+ }: { only?: (text: string) => boolean; types?: ReadonlyMap<string, Resource> } = {},
125
+ ): Map<string, Loaded> {
126
+ // Nothing to load, and a root that may not be there yet (a company not made): nothing to resolve.
127
+ if (!paths.length) return new Map();
128
+ hook();
129
+ const real = realpathSync(root);
130
+ const gen = generationOf(real, connectors, types);
131
+ const files = paths.map((path) => join(real, relative(root, path)));
132
+ const byPaths = gen.answers.get(only ?? gen) ?? new Map<string, Map<string, Loaded>>();
133
+ gen.answers.set(only ?? gen, byPaths);
134
+ const key = files.join("\0");
135
+ const answered = byPaths.get(key);
136
+ if (answered) return answered;
137
+
138
+ const answer = new Map<string, Loaded>();
139
+ for (const file of files) {
140
+ touch(gen, file);
141
+ if (only && !only(readFileSync(file, "utf8"))) continue;
142
+ const rel = relPath(gen.root, file);
143
+ const loaded = loadOne(gen, file, rel);
144
+ answer.set(rel, loaded);
145
+ if ("failedIn" in loaded) answer.set(loaded.failedIn, gen.loaded.get(loaded.failedIn)!);
146
+ }
147
+ const result = new Map([...answer].toSorted(([a], [b]) => byOrder(a, b)));
148
+ // A value made in one file and exported by another is listed at the file that made it.
149
+ const owner = new Map<unknown, string>();
150
+ for (const [rel, l] of result) {
151
+ if (!("exports" in l)) continue;
152
+ for (const [, value] of l.exports) if (madeOf(value)?.file === rel) owner.set(value, rel);
153
+ }
154
+ for (const [rel, l] of result) {
155
+ if (!("exports" in l)) continue;
156
+ const exports = l.exports
157
+ .filter(([, value]) => (owner.get(value) ?? rel) === rel)
158
+ .toSorted(([, a], [, b]) => byOrder(madeOf(a)?.seq ?? Infinity, madeOf(b)?.seq ?? Infinity));
159
+ result.set(rel, { exports });
160
+ }
161
+ byPaths.set(key, result);
162
+ answerGenerations.set(result, gen);
163
+ return result;
164
+ }
165
+
166
+ /**
167
+ * What `answer` (a `loadModules` answer) gave for `file`: its exports, or the error it threw (or a
168
+ * helper it imports threw) as a problem on one line, at the file that threw it; undefined when the
169
+ * answer does not have the file.
170
+ */
171
+ export function exportsOf(
172
+ answer: Map<string, Loaded>,
173
+ file: string,
174
+ ):
175
+ | { exports: [name: string, value: unknown][] }
176
+ | { problem: { file: string; line?: number; column?: number; message: string } }
177
+ | undefined {
178
+ const loaded = answer.get(file);
179
+ if (loaded === undefined) return undefined;
180
+ if ("failedIn" in loaded) return exportsOf(answer, loaded.failedIn);
181
+ if ("exports" in loaded) return loaded;
182
+ const { error, line, column } = loaded;
183
+ const message = errorMessage(error).replaceAll(/\s*\n\s*/g, " ");
184
+ return { problem: { file, ...(line !== undefined && { line, column: column ?? 1 }), message } };
185
+ }
186
+
187
+ /** The generation each answer is of. */
188
+ const answerGenerations = new WeakMap<Map<string, Loaded>, Generation>();
189
+
190
+ /** Every value the files of `answer` (a `loadModules` answer) made, exported or not, in the order made. */
191
+ export const madeWith = (answer: Map<string, Loaded>): object[] =>
192
+ (answerGenerations.get(answer)?.made ?? []).filter((value) => answer.has(madeOf(value)!.file));
193
+
194
+ /**
195
+ * The company files `answer` (a `loadModules` answer) was read from, by path relative to the root:
196
+ * its files and every company file they import, directly or not (or that another answer of its
197
+ * generation imported: never fewer).
198
+ */
199
+ export const filesRead = (answer: Map<string, Loaded>): ReadonlySet<string> =>
200
+ new Set([...answer.keys(), ...(answerGenerations.get(answer)?.imported ?? [])]);
201
+
202
+ /** One file of a generation, required once; an error placed at its own file, and reported there once. */
203
+ function loadOne(gen: Generation, file: string, rel: string): Loaded {
204
+ const done = gen.loaded.get(rel);
205
+ if (done) return done;
206
+ let loaded: Loaded;
207
+ try {
208
+ const { ns } = require(`${SCHEME}${gen.id}/${pathToFileURL(file).href}`) as { ns: Record<string, unknown> };
209
+ loaded = { exports: Object.entries(ns) };
210
+ } catch (error) {
211
+ // A file that imports one that threw throws the same object: reported once, where it was thrown.
212
+ const at = typeof error === "object" ? gen.reported.get(error) : undefined;
213
+ if (at !== undefined) loaded = { failedIn: at };
214
+ else {
215
+ const placed = placeOf(gen, error);
216
+ // Never over a file that loaded (a stack cut short at `Error.stackTraceLimit`): then it is this file's.
217
+ const there = placed && gen.loaded.get(placed.file);
218
+ const { file: own = rel, line, column } = there && "exports" in there ? {} : (placed ?? {});
219
+ if (typeof error === "object") gen.reported.set(error, own);
220
+ const report = { error, ...(line !== undefined && { line }), ...(column !== undefined && { column }) };
221
+ gen.loaded.set(own, report);
222
+ loaded = own === rel ? report : { failedIn: own };
223
+ }
224
+ }
225
+ gen.loaded.set(rel, loaded);
226
+ return loaded;
227
+ }
228
+
229
+ /** A frame of a generation's company file in a stack: its URL, without the query, then the tag, line and column. */
230
+ const TAGGED_FRAME = new RegExp(`(file://[^\\s()?]*)\\?${QUERY}=([\\w-]+):(\\d+):(\\d+)`, "g");
231
+
232
+ /**
233
+ * Where an error belongs: the last (outermost) frame of its stack in one of the generation's
234
+ * files, the top level of the file that was running, not a helper it called in another file; for
235
+ * a syntax error, the file and line its stack starts with (the caret's column); none for an error
236
+ * thrown before any file ran (top-level `await`, an import Node cannot find).
237
+ */
238
+ function placeOf(gen: Generation, error: unknown): { file: string; line?: number; column?: number } | undefined {
239
+ const stack = isObject(error) && typeof error.stack === "string" ? error.stack : undefined;
240
+ if (stack === undefined) return undefined;
241
+ const frame = [...stack.matchAll(TAGGED_FRAME)].findLast(([, , tag]) => tag === gen.id);
242
+ if (frame) {
243
+ const [, url, , line, column] = frame;
244
+ return { file: relPath(gen.root, fileURLToPath(url!)), line: Number(line), column: Number(column) };
245
+ }
246
+ // A syntax error's stack starts `<path or URL>:<line>`, then the source and a caret under the column.
247
+ const [first = "", ...lines] = stack.split("\n");
248
+ const syntax = /^(.+):(\d+)$/.exec(first);
249
+ const path = syntax && (syntax[1]!.startsWith("file:") ? fileURLToPath(syntax[1]!) : syntax[1]!);
250
+ if (syntax && path && isAbsolute(path) && inside(gen.root, path)) {
251
+ const caret = lines.slice(0, lines.indexOf("")).findLast((line) => /^\s*\^+\s*$/.test(line));
252
+ return { file: relPath(gen.root, path), line: Number(syntax[2]), ...(caret && { column: caret.indexOf("^") + 1 }) };
253
+ }
254
+ return undefined;
255
+ }
256
+
257
+ const byOrder = <T extends string | number>(a: T, b: T) => (a < b ? -1 : a > b ? 1 : 0);
258
+
259
+ /** A file's size and modification time, or that it is gone. */
260
+ function stampOf(path: string): string {
261
+ const stat = statSync(path, { bigint: true, throwIfNoEntry: false });
262
+ return stat ? `${stat.size} ${stat.mtimeNs}` : "gone";
263
+ }
264
+
265
+ /** Records a file of the generation, and its directory, as they are now: before it is read, so an edit after is seen. */
266
+ function touch(gen: Generation, file: string) {
267
+ for (const path of [file, dirname(file)]) if (!gen.stamps.has(path)) gen.stamps.set(path, stampOf(path));
268
+ }
269
+
270
+ /** The generation of `root` and these constructors: the current one while nothing it stamped changed, else a new one. */
271
+ function generationOf(
272
+ root: string,
273
+ connectors: readonly Connector<any, any>[],
274
+ types: ReadonlyMap<string, Resource>,
275
+ ): Generation {
276
+ const packages = connectors.flatMap((c) => {
277
+ const { id, info } = c[VENDOR];
278
+ return info?.package ? [`${info.package}/resources`, id] : [];
279
+ });
280
+ const typesKey = [...packages, ...[...types.values()].map(idOf)].join(" ");
281
+ for (const gen of registry.generations.values()) {
282
+ if (gen.root !== root || gen.typesKey !== typesKey) continue;
283
+ if ([...gen.stamps].every(([path, stamp]) => stampOf(path) === stamp)) return gen;
284
+ registry.generations.delete(gen.id);
285
+ }
286
+ const gen: Generation = {
287
+ id: String(++registry.seq),
288
+ root,
289
+ typesKey,
290
+ virtual: new Map(),
291
+ stamps: new Map(),
292
+ made: [],
293
+ imported: new Set(),
294
+ loaded: new Map(),
295
+ reported: new Map(),
296
+ answers: new WeakMap(),
297
+ };
298
+ gen.virtual = runtimeModules(gen, connectors, types);
299
+ registry.generations.set(gen.id, gen);
300
+ return gen;
301
+ }
302
+
303
+ function idOf(object: object): number {
304
+ let id = registry.ids.get(object);
305
+ if (id === undefined) registry.ids.set(object, (id = ++registry.seq));
306
+ return id;
307
+ }
308
+
309
+ /** The runtime's modules a generation serves its files, by specifier: each constructor wrapped to record where it was called. */
310
+ function runtimeModules(
311
+ gen: Generation,
312
+ connectors: readonly Connector<any, any>[],
313
+ types: ReadonlyMap<string, Resource>,
314
+ ): Map<string, Record<string, unknown>> {
315
+ const byVendor = new Map<string, Record<string, unknown>>();
316
+ for (const resource of types.values()) {
317
+ const constructors = byVendor.get(resource.vendor) ?? {};
318
+ // Frame 2 of `callerFile` is the wrapper's caller: the company file.
319
+ constructors[resource.type] = (fields: unknown, options?: unknown) =>
320
+ record(gen, callerFile(), (resource as (f: unknown, o: unknown) => Declared)(fields, options), refersIn(fields));
321
+ byVendor.set(resource.vendor, constructors);
322
+ }
323
+ // A type the vendor does not declare is refused where it is called for, as `<vendor>.<type>`;
324
+ // an object's own names (`toString`, `toJSON`) are not types, so printing the vendor still works.
325
+ const constructorsOf = (vendor: string) =>
326
+ new Proxy(byVendor.get(vendor) ?? {}, {
327
+ get(target, key) {
328
+ if (typeof key !== "string" || key in target || key === "toJSON") return Reflect.get(target, key);
329
+ const known = Object.keys(target);
330
+ throw new TypeError(`${vendor} has no resource type ${key}: its types are ${known.join(", ") || "none"}`);
331
+ },
332
+ });
333
+ const modules = new Map<string, Record<string, unknown>>();
334
+ for (const connector of connectors) {
335
+ const { id, info } = connector[VENDOR];
336
+ if (info?.package) modules.set(`${info.package}/resources`, { [id]: constructorsOf(id) });
337
+ }
338
+ for (const entry of SECRET_ENTRIES) modules.set(entry, { sanoma: constructorsOf("sanoma"), secret });
339
+ const collection = (declaration: Parameters<typeof collectionIn>[1]) => {
340
+ const file = callerFile();
341
+ return record(
342
+ gen,
343
+ file,
344
+ collectionIn(file === undefined ? undefined : appOfPath(relPath(gen.root, file)), declaration),
345
+ );
346
+ };
347
+ for (const entry of DATA_ENTRIES) modules.set(entry, { collection });
348
+ const site = (definition: Parameters<typeof defineSite>[0]) => record(gen, callerFile(), defineSite(definition));
349
+ for (const entry of SITE_ENTRIES) modules.set(entry, { defineSite: site });
350
+ const app = (definition: Parameters<typeof defineApp>[0]) => record(gen, callerFile(), defineApp(definition));
351
+ for (const entry of APP_ENTRIES) modules.set(entry, { defineApp: app });
352
+ return modules;
353
+ }
354
+
355
+ /** Records where `value` was made, and returns it. */
356
+ function record<T extends object>(gen: Generation, file: string | undefined, value: T, refers = {}): T {
357
+ gen.made.push(value);
358
+ registry.made.set(value, { file: file === undefined ? "" : relPath(gen.root, file), seq: gen.made.length, refers });
359
+ return value;
360
+ }
361
+
362
+ /** Each declared resource in a constructor's fields, by its dotted path, as the constructor walks them. */
363
+ function refersIn(value: unknown, at = "", into: Record<string, Declared> = {}): Record<string, Declared> {
364
+ if (isDeclared(value)) into[at] = value;
365
+ else if (Array.isArray(value)) value.forEach((item, i) => refersIn(item, joinPath(at, String(i)), into));
366
+ else if (isObject(value))
367
+ for (const [field, item] of Object.entries(value)) refersIn(item, joinPath(at, field), into);
368
+ return into;
369
+ }
370
+
371
+ /** True for an app's `apps/<app>/app.ts`, by URL. */
372
+ const isAppFile = (gen: Generation, url: string) => {
373
+ const [top, app, file, ...rest] = relPath(gen.root, fileURLToPath(url)).split("/");
374
+ return top === APPS_DIR && app !== undefined && file === "app.ts" && !rest.length;
375
+ };
376
+
377
+ /** The generation a module URL belongs to: the query on a company file's, the first segment of the loader's own. */
378
+ function tagOf(url: string | undefined): string | null {
379
+ if (url?.startsWith("file:")) return new URL(url).searchParams.get(QUERY);
380
+ if (url?.startsWith(SCHEME)) return url.slice(SCHEME.length, url.indexOf("/"));
381
+ return null;
382
+ }
383
+
384
+ /**
385
+ * Registers the loader's hooks, once per process (the registry's flag, for two copies of the
386
+ * package). A file of a generation resolves the runtime's modules to the generation's, and each
387
+ * company file (under the root, outside `node_modules`) to itself tagged with the generation,
388
+ * recorded and stamped; an entry is a module exporting the tagged file's namespace as `ns`, since
389
+ * `require` of a `file:` URL drops its query (the CommonJS loader keys by file name).
390
+ */
391
+ function hook() {
392
+ if (registry.hooked) return;
393
+ registry.hooked = true;
394
+ registerHooks({
395
+ resolve(specifier, context, next) {
396
+ if (specifier.startsWith(SCHEME)) return { url: specifier, shortCircuit: true };
397
+ const tag = tagOf(context.parentURL);
398
+ const gen = tag === null ? undefined : registry.generations.get(tag);
399
+ if (gen === undefined) return next(specifier, context);
400
+ if (
401
+ gen.virtual.has(specifier) &&
402
+ (!APP_ENTRIES.has(specifier) || (context.parentURL!.startsWith("file:") && isAppFile(gen, context.parentURL!)))
403
+ ) {
404
+ return { url: `${SCHEME}${gen.id}/${specifier}`, shortCircuit: true };
405
+ }
406
+ const resolved = next(specifier, context);
407
+ if (!resolved.url.startsWith("file:")) return resolved;
408
+ const url = new URL(resolved.url);
409
+ const file = fileURLToPath(url);
410
+ if (!inside(gen.root, file) || file.split(sep).includes("node_modules")) return resolved;
411
+ touch(gen, file);
412
+ gen.imported.add(relPath(gen.root, file));
413
+ url.searchParams.set(QUERY, gen.id);
414
+ return { ...resolved, url: url.href };
415
+ },
416
+ load(url, context, next) {
417
+ if (!url.startsWith(SCHEME)) return next(url, context);
418
+ const tag = tagOf(url)!;
419
+ const rest = url.slice(url.indexOf("/") + 1);
420
+ if (rest.startsWith("file:")) {
421
+ const target = new URL(rest);
422
+ target.searchParams.set(QUERY, tag);
423
+ return { format: "module", source: `export * as ns from ${JSON.stringify(target.href)};`, shortCircuit: true };
424
+ }
425
+ // Read from the registry by the module itself, so a company file gets the generation's own objects.
426
+ const from = `globalThis[Symbol.for("sanoma.load")].generations.get(${JSON.stringify(tag)}).virtual.get(${JSON.stringify(rest)})`;
427
+ const names = Object.keys(registry.generations.get(tag)?.virtual.get(rest) ?? {});
428
+ const source = [
429
+ `const m = ${from};`,
430
+ ...names.map(
431
+ (name, i) => `const e${i} = m[${JSON.stringify(name)}];\nexport { e${i} as ${JSON.stringify(name)} };`,
432
+ ),
433
+ ].join("\n");
434
+ return { format: "module", source, shortCircuit: true };
435
+ },
436
+ });
437
+ }
package/src/lockfile.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { connectionRefSchema, type ConnectionRef } from "./connection.ts";
1
2
  import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
3
  import { join } from "node:path";
3
4
  import { z } from "zod";
@@ -13,6 +14,7 @@ export const LOCKFILE = "resources/sanoma.lock.json";
13
14
  export interface Binding {
14
15
  type: string;
15
16
  id: string;
17
+ connection?: ConnectionRef;
16
18
  }
17
19
 
18
20
  /** The lockfile's content: by declaration key, `<file>#<export>` (the file relative to the config's root, with `/`). */
@@ -20,7 +22,11 @@ export type Bindings = Record<string, Binding>;
20
22
 
21
23
  const lockfile = z.record(
22
24
  z.string().regex(/^resources\/[^#]+#[^#]+$/),
23
- z.strictObject({ type: z.string().regex(/^[^.\s]+\.[^.\s]+$/), id: z.string().min(1) }),
25
+ z.strictObject({
26
+ type: z.string().regex(/^[^.\s]+\.[^.\s]+$/),
27
+ id: z.string().min(1),
28
+ connection: connectionRefSchema.optional(),
29
+ }),
24
30
  );
25
31
 
26
32
  /**
@@ -51,7 +57,7 @@ export function readLockfile(root: string): { bindings: Bindings; problems: Reso
51
57
  export function serializeLockfile(bindings: Bindings): string {
52
58
  const sorted = Object.entries(bindings)
53
59
  .toSorted(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
54
- .map(([key, { type, id }]) => [key, { type, id }]);
60
+ .map(([key, { type, id, connection }]) => [key, { type, id, ...(connection && { connection }) }]);
55
61
  return `${JSON.stringify(Object.fromEntries(sorted), null, 2)}\n`;
56
62
  }
57
63
 
@@ -89,7 +95,8 @@ export function diffLockfiles(before: Bindings, after: Bindings): Rebind[] {
89
95
  const now = after[key];
90
96
  if (!now) return was ? [{ key, type: was.type, kind: "unbind", from: was.id }] : [];
91
97
  if (!was) return [{ key, type: now.type, kind: "bind", to: now.id }];
92
- if (was.id === now.id && was.type === now.type) return [];
98
+ if (was.id === now.id && was.type === now.type && JSON.stringify(was.connection) === JSON.stringify(now.connection))
99
+ return [];
93
100
  return [{ key, type: now.type, kind: "rebind", from: was.id, to: now.id }];
94
101
  });
95
102
  }
package/src/op.ts CHANGED
@@ -1,3 +1,6 @@
1
+ import type { Principal } from "./define.ts";
2
+ import type { Authentication } from "./authentication.ts";
3
+ import type { ConnectionSnapshot } from "./connection.ts";
1
4
  import type { ProviderV4 } from "@ai-sdk/provider";
2
5
  import type { z } from "zod";
3
6
  import type { Fake } from "./fake.ts";
@@ -108,7 +111,7 @@ export type Connector<V extends string, S extends Specs> = {
108
111
  * and `resolveConfig` reads only a record of its own shape (`checkConnector`): change it with
109
112
  * the record, so a build refuses a connector another build made in another shape.
110
113
  */
111
- export const CONNECTOR_FORMAT = 1;
114
+ export const CONNECTOR_FORMAT = 2;
112
115
 
113
116
  /**
114
117
  * A connector's vendor: its id, who it is (`defineConnector`'s third argument, for a UI), its
@@ -236,6 +239,10 @@ export function defineConnector<const V extends string, const S extends Specs>(
236
239
  * not repeat the side effect.
237
240
  */
238
241
  export interface CallContext {
242
+ /** Authenticated initiating principal; never supplied by operation input. */
243
+ principal?: Principal;
244
+ /** Authorized account metadata. Credential material stays inside the driver boundary. */
245
+ connection?: ConnectionSnapshot;
239
246
  idempotencyKey: string;
240
247
  runId: string;
241
248
  opId: string;
@@ -313,6 +320,8 @@ export interface Driver {
313
320
  * no repository a change is reviewed on.
314
321
  */
315
322
  export interface ConnectorSetup {
323
+ /** Auth methods available to named connections; login identity is separate. */
324
+ authentication?: Readonly<Record<string, Authentication>>;
316
325
  /** Makes the live driver, which reads its credentials from the environment when called: once per resolved config. */
317
326
  driver?: () => Driver;
318
327
  /** Makes a fake of the vendor (`defineFake`): once per sandbox run, its own. */
package/src/outline.ts CHANGED
@@ -391,12 +391,25 @@ export function outlineBody(
391
391
  * A step's name as the call gives it, in `{ name: "…" }` after the call's input: a string
392
392
  * literal or a top-level const, not an operation's id or `Sleep`, which unnamed calls are called.
393
393
  */
394
- const nameIn = (options: Node | undefined): string | undefined => {
394
+ const nameIn = (options: Node | undefined, connection = false): string | undefined => {
395
395
  if (options === undefined) return undefined;
396
396
  const literal = unwrap(options);
397
- const property =
398
- literal?.type === "ObjectExpression" && literal.properties.length === 1 ? literal.properties[0] : undefined;
399
- const key = property?.type === "Property" && !property.computed ? (property.key.name ?? property.key.value) : "";
397
+ if (
398
+ literal?.type !== "ObjectExpression" ||
399
+ literal.properties.some(
400
+ (p: Node) =>
401
+ p.type !== "Property" ||
402
+ p.computed ||
403
+ !(connection ? ["name", "connection"] : ["name"]).includes(p.key.name ?? p.key.value),
404
+ )
405
+ ) {
406
+ problem(options, NAME);
407
+ return undefined;
408
+ }
409
+ const property = literal.properties.find((p: Node) => (p.key.name ?? p.key.value) === "name");
410
+ if (!property && literal.properties.some((p: Node) => (p.key.name ?? p.key.value) === "connection"))
411
+ return undefined;
412
+ const key = property?.key.name ?? property?.key.value;
400
413
  const name = key === "name" ? literalOf(property.value, consts) : undefined;
401
414
  if (name === undefined || !/\S/.test(name)) problem(options, NAME);
402
415
  else if (name === DEFAULT_SLEEP_NAME || OP_ID.test(name)) problem(options, DEFAULT_LIKE(name));
@@ -428,7 +441,7 @@ export function outlineBody(
428
441
  default:
429
442
  if (isOpPath(members)) {
430
443
  // A computed id's own name is the operation it calls, known only in a run.
431
- const name = nameIn(args[1]) ?? (id.includes("*") ? undefined : id);
444
+ const name = nameIn(args[1], true) ?? (id.includes("*") ? undefined : id);
432
445
  return [
433
446
  ...walk(args, at),
434
447
  { kind: "op", path, id, span: span(node), ...(name === undefined ? {} : { name }) },
package/src/paths.ts CHANGED
@@ -6,6 +6,21 @@ import { APP_NAME } from "./shared.ts";
6
6
  // the directories and the one walk down (`filesUnder`). Node's `path` and `fs` only, no parser:
7
7
  // the oxlint plugin loads it.
8
8
 
9
+ /**
10
+ * Where a data file imports `secret` from, and `sanoma`, the runtime's own resource constructors
11
+ * (its people: `sanoma.person`, `.agent`, `.group`): what the loader serves for them.
12
+ */
13
+ export const SECRET_ENTRIES: ReadonlySet<string> = new Set(["sanoma/resources", "@sanoma-ai/workflows/resources"]);
14
+ /** Where a site imports `api`, `RunForm` and `defineSite` from: `sanoma/site`, which re-exports the app's. The loader serves `defineSite`. */
15
+ export const SITE_ENTRIES: ReadonlySet<string> = new Set(["sanoma/site", "@sanoma-ai/app/site"]);
16
+ /** Where `resources/data.ts` imports `collection` from: what the loader serves for it. */
17
+ export const DATA_ENTRIES: ReadonlySet<string> = new Set(["sanoma/data", "@sanoma-ai/workflows/data"]);
18
+ /**
19
+ * The one data file that holds collections, relative to `resources/`. Workflows and the runtime
20
+ * import it, so it holds collections only.
21
+ */
22
+ export const COLLECTIONS_FILE = "data.ts";
23
+
9
24
  /** A relative import specifier: `./x.ts`, `../y.ts`. */
10
25
  export const RELATIVE = /^\.\.?\//;
11
26
 
@@ -34,6 +49,9 @@ export function inside(dir: string, path: string): boolean {
34
49
  return rel === "" || (rel.split(sep)[0] !== ".." && !isAbsolute(rel));
35
50
  }
36
51
 
52
+ /** `path`, under `root`, relative to it with `/`: `resources/github.ts`. */
53
+ export const relPath = (root: string, path: string): string => relative(root, path).split(sep).join("/");
54
+
37
55
  /** True when `path` is a directory. */
38
56
  export const isDirectory = (path: string): boolean => statSync(path, { throwIfNoEntry: false })?.isDirectory() ?? false;
39
57
 
@@ -77,8 +95,12 @@ export function appOfFile(file: string | undefined): string | undefined {
77
95
  // Found roots only: a directory that is no company's yet may become one (`sanoma` in an empty folder).
78
96
  if (root !== null) roots.set(dir, root);
79
97
  }
80
- if (root === null) return undefined;
81
- const [top, app, ...rest] = relative(root, file).split(sep);
98
+ return root === null ? undefined : appOfPath(relPath(root, file));
99
+ }
100
+
101
+ /** The app a path relative to a company's root is in: `<app>` under `apps/<app>/`; undefined for any other. */
102
+ export function appOfPath(rel: string): string | undefined {
103
+ const [top, app, ...rest] = rel.split("/");
82
104
  return top === APPS_DIR && app !== undefined && rest.length && APP_NAME.test(app) ? app : undefined;
83
105
  }
84
106
 
package/src/plugin.ts CHANGED
@@ -1,9 +1,6 @@
1
- import { readDataFile } from "./datafile.ts";
2
-
3
- // An oxlint JS plugin with two rules, on the trees oxlint has already parsed, which the package's
4
- // `oxlint.json` turns on: `sanoma/data-file` for `resources/**`, the data-file subset
5
- // `readResources` reads, the same check as `lintResources`; and `sanoma/date` for `workflows/**`,
6
- // `Date` reading the clock. Typed here by the little they use, so the package needs no oxlint types.
1
+ // An oxlint JS plugin with one rule, on the trees oxlint has already parsed, which the package's
2
+ // `oxlint.json` turns on for `workflows/**`: `sanoma/date`, `Date` reading the clock. Typed here by
3
+ // the little it uses, so the package needs no oxlint types.
7
4
 
8
5
  interface Context {
9
6
  filename: string;
@@ -65,24 +62,6 @@ function readsNoClock(node: Node): boolean {
65
62
  const plugin = {
66
63
  meta: { name: "sanoma" },
67
64
  rules: {
68
- "data-file": {
69
- meta: {
70
- type: "problem",
71
- docs: {
72
- description:
73
- "A data file under resources/ holds only imports, `export const <name> = <vendor>.<type>({ … })` with literal fields, and `export default [ … ]`; resources/data.ts holds only collections, `collection({ … })` from sanoma/data",
74
- },
75
- },
76
- create(context: Context) {
77
- return {
78
- Program(program: unknown) {
79
- for (const { start, end, message } of readDataFile(program, context.filename).problems) {
80
- context.report({ message, node: { range: [start, end] } });
81
- }
82
- },
83
- };
84
- },
85
- },
86
65
  // `Date` reads the clock in `Date.now()`, `new Date()` and `Date()`, which a replay would read
87
66
  // again; a workflow reads it with `ctx.now()`, recorded. Every other use of `Date` is pure:
88
67
  // `new Date(value)` (of `await ctx.now()`, or of a date the workflow has), `Date.parse`, `Date.UTC`.
package/src/policy.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { ConnectionSnapshot } from "./connection.ts";
1
2
  import { type ApprovalState, type Approver, type Principal, sameApprover } from "./define.ts";
2
3
  import type { Effect, Op } from "./op.ts";
3
4
  import { CALL_WORKFLOW, isVisitor } from "./shared.ts";
@@ -16,6 +17,7 @@ export const policyOpOf = ({ id, vendor, resource, name, effect }: Op): PolicyOp
16
17
 
17
18
  /** What a policy sees for each operation a run calls: plain data. */
18
19
  export interface PolicyCall {
20
+ connection?: ConnectionSnapshot;
19
21
  op: PolicyOp;
20
22
  /** The same as `op.effect`. */
21
23
  effect: Effect;