@sanoma-ai/workflows 0.0.0-stage → 0.1.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 (148) hide show
  1. package/LICENSE +105 -0
  2. package/README.md +493 -2
  3. package/dist/approvals.d.ts +46 -0
  4. package/dist/approvals.js +111 -0
  5. package/dist/approvals.js.map +1 -0
  6. package/dist/ast.d.ts +57 -0
  7. package/dist/ast.js +81 -0
  8. package/dist/ast.js.map +1 -0
  9. package/dist/call.d.ts +33 -0
  10. package/dist/call.js +402 -0
  11. package/dist/call.js.map +1 -0
  12. package/dist/client.d.ts +167 -0
  13. package/dist/client.js +400 -0
  14. package/dist/client.js.map +1 -0
  15. package/dist/config.d.ts +126 -0
  16. package/dist/config.js +247 -0
  17. package/dist/config.js.map +1 -0
  18. package/dist/credentials.d.ts +30 -0
  19. package/dist/credentials.js +193 -0
  20. package/dist/credentials.js.map +1 -0
  21. package/dist/cucumber.d.ts +64 -0
  22. package/dist/cucumber.js +158 -0
  23. package/dist/cucumber.js.map +1 -0
  24. package/dist/datafile.d.ts +62 -0
  25. package/dist/datafile.js +326 -0
  26. package/dist/datafile.js.map +1 -0
  27. package/dist/db.d.ts +58 -0
  28. package/dist/db.js +179 -0
  29. package/dist/db.js.map +1 -0
  30. package/dist/define.d.ts +205 -0
  31. package/dist/define.js +103 -0
  32. package/dist/define.js.map +1 -0
  33. package/dist/describe.d.ts +135 -0
  34. package/dist/describe.js +127 -0
  35. package/dist/describe.js.map +1 -0
  36. package/dist/drift.d.ts +75 -0
  37. package/dist/drift.js +90 -0
  38. package/dist/drift.js.map +1 -0
  39. package/dist/env.d.ts +49 -0
  40. package/dist/env.js +83 -0
  41. package/dist/env.js.map +1 -0
  42. package/dist/errors.d.ts +88 -0
  43. package/dist/errors.js +148 -0
  44. package/dist/errors.js.map +1 -0
  45. package/dist/expectations.d.ts +26 -0
  46. package/dist/expectations.js +137 -0
  47. package/dist/expectations.js.map +1 -0
  48. package/dist/fake.d.ts +55 -0
  49. package/dist/fake.js +87 -0
  50. package/dist/fake.js.map +1 -0
  51. package/dist/fill.d.ts +9 -0
  52. package/dist/fill.js +17 -0
  53. package/dist/fill.js.map +1 -0
  54. package/dist/index.d.ts +19 -0
  55. package/dist/index.js +25 -0
  56. package/dist/index.js.map +1 -0
  57. package/dist/ledger.d.ts +217 -0
  58. package/dist/ledger.js +251 -0
  59. package/dist/ledger.js.map +1 -0
  60. package/dist/lint.d.ts +32 -0
  61. package/dist/lint.js +168 -0
  62. package/dist/lint.js.map +1 -0
  63. package/dist/log.d.ts +24 -0
  64. package/dist/log.js +83 -0
  65. package/dist/log.js.map +1 -0
  66. package/dist/op.d.ts +257 -0
  67. package/dist/op.js +188 -0
  68. package/dist/op.js.map +1 -0
  69. package/dist/outline.d.ts +179 -0
  70. package/dist/outline.js +506 -0
  71. package/dist/outline.js.map +1 -0
  72. package/dist/paths.d.ts +11 -0
  73. package/dist/paths.js +30 -0
  74. package/dist/paths.js.map +1 -0
  75. package/dist/plugin.d.ts +28 -0
  76. package/dist/plugin.js +25 -0
  77. package/dist/plugin.js.map +1 -0
  78. package/dist/policy.d.ts +114 -0
  79. package/dist/policy.js +60 -0
  80. package/dist/policy.js.map +1 -0
  81. package/dist/resource.d.ts +212 -0
  82. package/dist/resource.js +285 -0
  83. package/dist/resource.js.map +1 -0
  84. package/dist/resources.d.ts +62 -0
  85. package/dist/resources.js +197 -0
  86. package/dist/resources.js.map +1 -0
  87. package/dist/run.d.ts +87 -0
  88. package/dist/run.js +2 -0
  89. package/dist/run.js.map +1 -0
  90. package/dist/sandbox.d.ts +33 -0
  91. package/dist/sandbox.js +123 -0
  92. package/dist/sandbox.js.map +1 -0
  93. package/dist/scenario.d.ts +212 -0
  94. package/dist/scenario.js +1079 -0
  95. package/dist/scenario.js.map +1 -0
  96. package/dist/scenarios.d.ts +55 -0
  97. package/dist/scenarios.js +71 -0
  98. package/dist/scenarios.js.map +1 -0
  99. package/dist/shared.d.ts +61 -0
  100. package/dist/shared.js +77 -0
  101. package/dist/shared.js.map +1 -0
  102. package/dist/steps.d.ts +76 -0
  103. package/dist/steps.js +23 -0
  104. package/dist/steps.js.map +1 -0
  105. package/dist/version.d.ts +29 -0
  106. package/dist/version.js +76 -0
  107. package/dist/version.js.map +1 -0
  108. package/dist/worker.d.ts +34 -0
  109. package/dist/worker.js +219 -0
  110. package/dist/worker.js.map +1 -0
  111. package/oxlint.json +128 -0
  112. package/package.json +91 -3
  113. package/src/approvals.ts +157 -0
  114. package/src/ast.ts +115 -0
  115. package/src/call.ts +468 -0
  116. package/src/client.ts +534 -0
  117. package/src/config.ts +381 -0
  118. package/src/credentials.ts +211 -0
  119. package/src/cucumber.ts +230 -0
  120. package/src/datafile.ts +409 -0
  121. package/src/db.ts +197 -0
  122. package/src/define.ts +285 -0
  123. package/src/describe.ts +262 -0
  124. package/src/drift.ts +149 -0
  125. package/src/env.ts +122 -0
  126. package/src/errors.ts +202 -0
  127. package/src/expectations.ts +165 -0
  128. package/src/fake.ts +156 -0
  129. package/src/fill.ts +24 -0
  130. package/src/index.ts +120 -0
  131. package/src/ledger.ts +419 -0
  132. package/src/lint.ts +208 -0
  133. package/src/log.ts +82 -0
  134. package/src/op.ts +399 -0
  135. package/src/outline.ts +620 -0
  136. package/src/paths.ts +32 -0
  137. package/src/plugin.ts +37 -0
  138. package/src/policy.ts +139 -0
  139. package/src/resource.ts +447 -0
  140. package/src/resources.ts +256 -0
  141. package/src/run.ts +91 -0
  142. package/src/sandbox.ts +157 -0
  143. package/src/scenario.ts +1328 -0
  144. package/src/scenarios.ts +111 -0
  145. package/src/shared.ts +116 -0
  146. package/src/steps.ts +90 -0
  147. package/src/version.ts +86 -0
  148. package/src/worker.ts +253 -0
@@ -0,0 +1,256 @@
1
+ import { readdirSync, readFileSync, statSync } from "node:fs";
2
+ import { dirname, join, relative, resolve, sep } from "node:path";
3
+ import { parsed } from "./ast.ts";
4
+ import { rootOf, type SanomaConfig } from "./config.ts";
5
+ import { type DataFile, type DataFileExport, type DataValue, readDataFile, Reference } from "./datafile.ts";
6
+ import { type Connector, VENDOR } from "./op.ts";
7
+ import type { Span } from "./outline.ts";
8
+ import { type Declared, type Resource, resourceTypesOf } from "./resource.ts";
9
+ import { problemLine } from "./shared.ts";
10
+
11
+ /** A resource a data file declares, as read from the file without running it. */
12
+ export interface DeclaredResource {
13
+ /** `<file>#<export>`, the file relative to the config's root, with `/`: `resources/identity/github.ts#website`. */
14
+ id: string;
15
+ /** The data file it is declared in, as `id` names it: `resources/identity/github.ts`. */
16
+ file: string;
17
+ vendor: string;
18
+ type: string;
19
+ /** Its import id: what the type's `find` makes of its fields. */
20
+ name: string;
21
+ /** Its `export const` statement, as UTF-16 offsets into the file's text. */
22
+ span: Span;
23
+ /** Its fields, as its type checked them: each resource it names as that resource's `name`. */
24
+ desired: Record<string, unknown>;
25
+ /**
26
+ * Where it names another declared resource: the field's dotted path (a list item's with its
27
+ * index, `teams.0`) to that resource's id.
28
+ */
29
+ refs: Record<string, string>;
30
+ }
31
+
32
+ /** Something wrong in the data files, or in finding them. */
33
+ export interface ResourceProblem {
34
+ /**
35
+ * The data file, relative to the config's root, with `/`: `resources/identity/github.ts`.
36
+ * Absent, with `line` and `column`, when the problem is the config's: it has no root.
37
+ */
38
+ file?: string;
39
+ /** From 1. */
40
+ line?: number;
41
+ /** From 1, in UTF-16 code units. */
42
+ column?: number;
43
+ message: string;
44
+ }
45
+
46
+ /** What the data files declare: each resource read without problems, and the problems. */
47
+ export interface DataFiles {
48
+ /** By file and then in file order, less any with a problem, and any that names one. */
49
+ resources: DeclaredResource[];
50
+ /** By file, line and column. */
51
+ problems: ResourceProblem[];
52
+ }
53
+
54
+ /**
55
+ * The resources the config's data files declare, read without importing or running them; throws
56
+ * one error listing every problem, each at `file:line:column`, for a test or CI to fail on.
57
+ * `describeConfig` lists the same, with the problems beside them instead.
58
+ */
59
+ export function readResources(config: SanomaConfig): DeclaredResource[] {
60
+ const { resources, problems } = readDataFiles(rootOf(config), config.connectors);
61
+ if (problems.length) {
62
+ const lines = problems.map((p) => ` ${problemLine(p)}`);
63
+ throw new Error(`The resources' data files have problems:\n${lines.join("\n")}`);
64
+ }
65
+ return resources;
66
+ }
67
+
68
+ interface FileEntry {
69
+ /** Relative to the config's root, with `/`. */
70
+ rel: string;
71
+ data: DataFile;
72
+ /** Records a problem at an offset into the file. */
73
+ problem: (offset: number, message: string) => void;
74
+ }
75
+
76
+ /**
77
+ * Reads `resources/` under `root`, the config's directory: each `.ts` file in it but
78
+ * `*.test.ts`, `*.d.ts` and `node_modules`, parsed with oxc and held to the data-file subset
79
+ * (`readDataFile`). A constructor import (`@sanoma-ai/connector-github/resources`) is matched to the
80
+ * connector whose `info.package` it names, never imported; each resource is checked by calling
81
+ * its type, as running the file would, with each resource it names given as that resource.
82
+ *
83
+ * Problems: anything outside the subset, a constructor or data file the config does not know, a
84
+ * type the connector does not declare, a value the type refuses (a reference where its type
85
+ * takes none among them), a reference cycle, and two resources of one type with one name.
86
+ */
87
+ export function readDataFiles(
88
+ root: string | undefined,
89
+ connectors: readonly Connector<any, any>[],
90
+ types: ReadonlyMap<string, Resource> = resourceTypesOf(connectors),
91
+ ): DataFiles {
92
+ const problems: ResourceProblem[] = [];
93
+ if (root === undefined) {
94
+ problems.push({
95
+ message:
96
+ "The config has no root, so its data files cannot be found: make it with defineConfig, which records its file, or set its `root`",
97
+ });
98
+ return { resources: [], problems };
99
+ }
100
+
101
+ const files = new Map<string, FileEntry>();
102
+ for (const path of dataFilesUnder(join(root, "resources"))) {
103
+ const rel = relative(root, path).split(sep).join("/");
104
+ const source = readFileSync(path, "utf8");
105
+ const { program, problems: syntax, at } = parsed(source, path);
106
+ const data = readDataFile(program, path, join(root, "resources"));
107
+ const problem = (offset: number, message: string) => problems.push({ file: rel, ...at(offset), message });
108
+ for (const p of syntax) problems.push({ file: rel, ...p });
109
+ for (const p of data.problems) problem(p.start, p.message);
110
+ files.set(path, { rel, data, problem });
111
+ }
112
+
113
+ // Each connector's constructors entry, `<info.package>/resources`, to its vendor.
114
+ const entries = new Map<string, string>();
115
+ for (const connector of connectors) {
116
+ const { id, info } = connector[VENDOR];
117
+ if (info?.package) entries.set(`${info.package}/resources`, id);
118
+ }
119
+
120
+ // What each file's names stand for, and every resource read by its id.
121
+ const vendors = new Map<FileEntry, Map<string, string>>();
122
+ const named = new Map<FileEntry, Map<string, string>>();
123
+ const declarations = new Map<string, { file: FileEntry; exp: DataFileExport }>();
124
+ for (const [path, file] of files) {
125
+ const fileVendors = new Map<string, string>();
126
+ const fileNamed = new Map<string, string>();
127
+ for (const imp of file.data.imports) {
128
+ if (imp.kind === "constructors") {
129
+ const vendor = entries.get(imp.source);
130
+ if (vendor === undefined) {
131
+ file.problem(
132
+ imp.start,
133
+ `"${imp.source}" is not the resources entry of a connector in the config's \`connectors\`: add the connector, and import from its package's \`/resources\``,
134
+ );
135
+ } else if (imp.imported !== vendor) {
136
+ file.problem(
137
+ imp.start,
138
+ `"${imp.source}" exports its constructors as ${vendor}: \`import { ${vendor} } from "${imp.source}"\``,
139
+ );
140
+ } else {
141
+ fileVendors.set(imp.local, vendor);
142
+ }
143
+ continue;
144
+ }
145
+ const other = files.get(resolve(dirname(path), imp.source));
146
+ if (!other) {
147
+ file.problem(imp.start, `"${imp.source}" is not a data file under resources/`);
148
+ } else if (!other.data.declared.includes(imp.imported)) {
149
+ file.problem(imp.start, `${imp.imported} is not a resource ${other.rel} declares`);
150
+ } else if (other.data.exports.some((e) => e.name === imp.imported)) {
151
+ fileNamed.set(imp.local, `${other.rel}#${imp.imported}`);
152
+ }
153
+ // Else its value was refused, and reported in its file: a reference to it fails quietly.
154
+ }
155
+ for (const exp of file.data.exports) {
156
+ const id = `${file.rel}#${exp.name}`;
157
+ fileNamed.set(exp.name, id);
158
+ declarations.set(id, { file, exp });
159
+ }
160
+ vendors.set(file, fileVendors);
161
+ named.set(file, fileNamed);
162
+ }
163
+
164
+ // Each resource after the ones it names, which it is given as they are declared.
165
+ const done = new Map<string, { declared: Declared; refs: Record<string, string> } | undefined>();
166
+ const visiting: string[] = [];
167
+ const byName = new Map<string, string>();
168
+ function declare(id: string) {
169
+ if (done.has(id)) return done.get(id);
170
+ const { file, exp } = declarations.get(id)!;
171
+ if (visiting.includes(id)) {
172
+ file.problem(exp.start, `${id} refers to itself: ${[...visiting.slice(visiting.indexOf(id)), id].join(" → ")}`);
173
+ return undefined;
174
+ }
175
+ visiting.push(id);
176
+ const result = build(file, exp);
177
+ visiting.pop();
178
+ if (result) {
179
+ const { vendor, type, name } = result.declared;
180
+ const key = `${vendor}.${type} ${JSON.stringify(name)}`;
181
+ const first = byName.get(key);
182
+ if (first === undefined) byName.set(key, id);
183
+ else {
184
+ file.problem(
185
+ exp.start,
186
+ `${key} is declared twice, also as ${first}: declare each resource once, and import it where it is used`,
187
+ );
188
+ done.set(id, undefined);
189
+ return undefined;
190
+ }
191
+ }
192
+ done.set(id, result);
193
+ return result;
194
+ }
195
+
196
+ function build(file: FileEntry, exp: DataFileExport) {
197
+ // An unknown constructors import was reported with the import.
198
+ const vendor = vendors.get(file)!.get(exp.vendor);
199
+ if (vendor === undefined) return undefined;
200
+ const resource = types.get(`${vendor}.${exp.type}`);
201
+ if (!resource) {
202
+ const known = [...types.values()].filter((r) => r.vendor === vendor).map((r) => r.type);
203
+ file.problem(exp.start, `${vendor} has no resource type ${exp.type}: its types are ${known.join(", ")}`);
204
+ return undefined;
205
+ }
206
+ const refs: Record<string, string> = {};
207
+ let ok = true;
208
+ // The fields with each name of a resource as that resource, and where each is named.
209
+ const given = (v: DataValue, at: string): unknown => {
210
+ if (v instanceof Reference) {
211
+ // A name that did not resolve was reported where it is bound.
212
+ const target = named.get(file)!.get(v.name);
213
+ const declared = target === undefined ? undefined : declare(target)?.declared;
214
+ if (!declared) ok = false;
215
+ else refs[at] = target!;
216
+ return declared;
217
+ }
218
+ if (Array.isArray(v)) return v.map((item, i) => given(item, `${at}.${i}`));
219
+ if (v === null || typeof v !== "object") return v;
220
+ return Object.fromEntries(Object.entries(v).map(([k, item]) => [k, given(item, at ? `${at}.${k}` : k)]));
221
+ };
222
+ const fields = given(exp.desired, "");
223
+ if (!ok) return undefined;
224
+ try {
225
+ // Checked as running the file would: by the resource type, which allows a resource only where its `fields.references` say.
226
+ return { declared: resource(fields as never), refs };
227
+ } catch (e) {
228
+ // One line per problem: zod's own spans lines.
229
+ file.problem(exp.start, `export const ${exp.name}: ${(e as Error).message.replaceAll(/\s*\n\s*/g, " ")}`);
230
+ return undefined;
231
+ }
232
+ }
233
+
234
+ const resources: DeclaredResource[] = [];
235
+ for (const [id, { file, exp }] of declarations) {
236
+ const result = declare(id);
237
+ if (!result) continue;
238
+ const { vendor, type, name, desired } = result.declared;
239
+ resources.push({ id, file: file.rel, vendor, type, name, span: [exp.start, exp.end], desired, refs: result.refs });
240
+ }
241
+ problems.sort(
242
+ (a, b) =>
243
+ (a.file ?? "").localeCompare(b.file ?? "") || (a.line ?? 0) - (b.line ?? 0) || (a.column ?? 0) - (b.column ?? 0),
244
+ );
245
+ return { resources, problems };
246
+ }
247
+
248
+ /** The data files under `dir`, sorted: regular `.ts` files, less tests, declarations and `node_modules`; none when `dir` is not a directory. */
249
+ function dataFilesUnder(dir: string): string[] {
250
+ if (!statSync(dir, { throwIfNoEntry: false })?.isDirectory()) return [];
251
+ return readdirSync(dir, { recursive: true, withFileTypes: true })
252
+ .filter((d) => d.isFile() && d.name.endsWith(".ts") && !d.name.endsWith(".test.ts") && !d.name.endsWith(".d.ts"))
253
+ .map((d) => join(d.parentPath, d.name))
254
+ .filter((path) => !relative(dir, path).split(sep).includes("node_modules"))
255
+ .toSorted();
256
+ }
package/src/run.ts ADDED
@@ -0,0 +1,91 @@
1
+ import type { ApprovalState, Principal, WorkflowDefinition } from "./define.ts";
2
+ import type { Fake } from "./fake.ts";
3
+ import type { LedgerStore } from "./ledger.ts";
4
+ import type { DriverFn, Op } from "./op.ts";
5
+ import type { CallNode } from "./outline.ts";
6
+ import type { Policy } from "./policy.ts";
7
+ import type { Resource } from "./resource.ts";
8
+ import type { Steps } from "./steps.ts";
9
+
10
+ /** What the DBOS workflow receives: the workflow's input, who started the run, and for a sandbox run its scenario. */
11
+ export interface RunArgs {
12
+ input: unknown;
13
+ startedBy: Principal;
14
+ /** The scenario a sandbox run is seeded from; the run calls the fakes instead of the drivers. */
15
+ sandbox?: string;
16
+ }
17
+
18
+ /**
19
+ * What one `startWorker` call runs with. DBOS registrations are process-wide and outlive a
20
+ * shutdown, so each run takes the state current when it starts and keeps it: a run left over
21
+ * from a stopped worker never reads a newer worker's drivers or policy.
22
+ */
23
+ export interface WorkerState {
24
+ /** The config's `appName`, recorded on every ledger record. */
25
+ app: string;
26
+ /** The operations as the config's connectors declare them, by id. */
27
+ ops: Map<string, Op>;
28
+ /** The drivers' functions, by operation id, each guarded by its driver's `env` (`indexDrivers` in config.ts). */
29
+ drivers: Map<string, DriverFn>;
30
+ /** The makers of the config's fake vendors, by vendor id: a sandbox run makes its own of each when it is seeded. */
31
+ fakes: Map<string, () => Fake<any, any>>;
32
+ /** Where sandbox runs' scenarios are, the resource types their steps may name, and the steps the connectors ship. */
33
+ scenarios?: URL;
34
+ types: ReadonlyMap<string, Resource>;
35
+ steps: readonly Steps[];
36
+ /** Its `version`, when it has one, is recorded with each of its decisions. */
37
+ policy: Policy;
38
+ ledger: LedgerStore;
39
+ /**
40
+ * The config's workflows by name, the built-in `drift` among them. A run takes its definition
41
+ * from here, by the name it was registered under: a process registers each name with DBOS once,
42
+ * and each worker's config says what it runs. Scenarios name them too.
43
+ */
44
+ workflows: Map<string, WorkflowDefinition<any, any>>;
45
+ /** Each workflow's outline, by name: read once when the worker starts, every run is held to it (`call_not_in_outline`). */
46
+ outlines: Map<string, WorkflowOutline>;
47
+ /**
48
+ * Set when the worker stops, before DBOS shuts down. DBOS abandons a stopped worker's run
49
+ * functions, which then fail as their next DBOS call finds the database closed. Such a failure,
50
+ * one with none of our codes, is not recorded: the run recovered on the next worker records
51
+ * how it really ends (see `isInfrastructureError`).
52
+ */
53
+ stopped: boolean;
54
+ }
55
+
56
+ /** What a run keeps in memory. Rebuilt the same way when DBOS replays the run. */
57
+ export interface Run {
58
+ id: string;
59
+ workflow: string;
60
+ actor: Principal;
61
+ /** The scenario a sandbox run is seeded from; unset for a live run. */
62
+ sandbox?: string;
63
+ /** A sandbox run's own fakes' functions, by operation id, from `seedSandbox`; unset for a live run. */
64
+ fakeDrivers?: Map<string, DriverFn>;
65
+ approvals: ApprovalState[];
66
+ /** The next ledger `seq`. Advanced only outside steps, so a replay counts the same way. */
67
+ seq: number;
68
+ /**
69
+ * The last `ctx` call queued. Each call waits for the one before it, so a run's calls run one
70
+ * at a time, in program order, even under `Promise.all`. Never rejects.
71
+ */
72
+ tail: Promise<unknown>;
73
+ /**
74
+ * Set while a `ctx.all` runs, so a second one started before it settles is refused. The
75
+ * member a call belongs to travels with the call (`currentGroup` in ledger.ts), not here.
76
+ */
77
+ inAll: boolean;
78
+ /** Set when the workflow body has returned or thrown. A call still queued then is refused. */
79
+ ended: boolean;
80
+ /** The workflow's outline, which every `ctx` call is placed in (`placeCall`). */
81
+ outline: WorkflowOutline;
82
+ state: WorkerState;
83
+ }
84
+
85
+ /** What `placeCall` holds a run's `ctx` calls to: the file's lines, so a frame's line and column become an offset, and the outline's call nodes. */
86
+ export interface WorkflowOutline {
87
+ file: string;
88
+ /** `lineStartsOf` the file's text, `\n` line endings. */
89
+ lineStarts: readonly number[];
90
+ calls: readonly CallNode[];
91
+ }
package/src/sandbox.ts ADDED
@@ -0,0 +1,157 @@
1
+ import { DBOS } from "@dbos-inc/dbos-sdk";
2
+ import { indexDrivers } from "./config.ts";
3
+ import { opIdsOf } from "./define.ts";
4
+ import { SanomaError } from "./errors.ts";
5
+ import type { Fake, ResourceFake } from "./fake.ts";
6
+ import { entry, type LedgerRecord, write } from "./ledger.ts";
7
+ import type { DriverFn } from "./op.ts";
8
+ import type { Run } from "./run.ts";
9
+ import { type Given, loadScenarios, type Scenario } from "./scenario.ts";
10
+ import { errorMessage } from "./shared.ts";
11
+
12
+ /*
13
+ * What a worker does for a sandbox run, imported only when it starts one: kept apart from
14
+ * `scenario.ts`, which the app reads too, because it needs DBOS and the ledger.
15
+ */
16
+
17
+ /** True for a fake that holds resources (`ResourceFake`, as `@sanoma-ai/bridge`'s `tfFake` is). */
18
+ const holdsResources = (fake: Fake<unknown>): fake is Fake<unknown> & ResourceFake =>
19
+ "override" in fake && "put" in fake && "remove" in fake;
20
+
21
+ /**
22
+ * The error a sandbox run of the scenario fails with when it seeds a resource of a vendor whose
23
+ * fake (one of `fakes`, by vendor) holds none, or whose connector attaches no fake; undefined
24
+ * when every vendor it seeds a resource of has a `ResourceFake`. Pure.
25
+ */
26
+ export function missingResourceFake(
27
+ scenario: Pick<Scenario, "name" | "given">,
28
+ fakes: ReadonlyMap<string, Fake<unknown>>,
29
+ ): SanomaError | undefined {
30
+ const plain = scenario.given.filter((g): g is Extract<Given, { resource: string }> => {
31
+ if (!("resource" in g)) return false;
32
+ const fake = fakes.get(g.vendor);
33
+ return !fake || !holdsResources(fake);
34
+ });
35
+ if (plain.length === 0) return undefined;
36
+ const resources = [...new Set(plain.map((g) => g.resource))];
37
+ const vendors = [...new Set(plain.map((g) => g.vendor))];
38
+ const why = vendors.map((v) =>
39
+ fakes.has(v) ? `${v}'s fake holds no resources (it is not a tfFake)` : `${v}'s connector attaches no fake`,
40
+ );
41
+ return new SanomaError(
42
+ "invalid_input",
43
+ `Scenario "${scenario.name}" seeds ${resources.join(", ")}, but ${why.join("; ")}`,
44
+ {
45
+ scenario: scenario.name,
46
+ resources,
47
+ },
48
+ );
49
+ }
50
+
51
+ /**
52
+ * Seeds one resource into the run's own fake of its vendor: put with its fields, or removed.
53
+ * A seed the fake refuses (a field the provider's shape lacks, say) fails the scenario, naming
54
+ * the step, as the other seed-time refusals do.
55
+ */
56
+ export function seedResource(
57
+ scenario: string,
58
+ copy: ResourceFake,
59
+ given: Extract<Given, { resource: string }>,
60
+ ): Extract<LedgerRecord, { type: "scenario.seeded" }>["seeds"][number] {
61
+ const { resource, type, name } = given;
62
+ try {
63
+ if ("gone" in given) {
64
+ copy.remove(type, name);
65
+ return { resource, name, gone: true };
66
+ }
67
+ copy.put(type, name, given.fields);
68
+ return { resource, name, fields: given.fields };
69
+ } catch (err) {
70
+ throw new SanomaError(
71
+ "invalid_input",
72
+ `Scenario "${scenario}" cannot seed ${resource} "${name}" ("${given.step}"): ${errorMessage(err)}`,
73
+ { scenario, step: given.step, resource, name },
74
+ { cause: err },
75
+ );
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Seeds a sandbox run of the scenario `name`, from the worker, in one step: reads the scenario,
81
+ * checks that it runs this workflow, makes the run's own fakes (each vendor's maker, called once
82
+ * for the run), checks that every operation the workflow uses or the scenario seeds has one and
83
+ * that every resource it seeds has a fake holding resources, calls each `Given` operation
84
+ * through them, puts or removes each `Given` resource in them, injects the faults into them,
85
+ * then puts their functions on the run. The run then calls those only, so sandbox runs share no
86
+ * state when the makers make a new fake each. The step's checkpoint never stands in for them: a
87
+ * run whose seeding is replayed from it has none, and fails, since a sandbox run does not
88
+ * survive a worker restart.
89
+ */
90
+ export async function seedSandbox(run: Run, name: string): Promise<void> {
91
+ const { state } = run;
92
+ let copies: Map<string, DriverFn> | undefined;
93
+ const { seeds } = await DBOS.runStep(
94
+ async () => {
95
+ // Read in the step, so a recovered run never reads a feature file edited or renamed since.
96
+ const { scenarios, errors } = await loadScenarios(state);
97
+ const scenario = scenarios.find((s) => s.name === name);
98
+ if (!scenario) {
99
+ const why = errors.length ? `; these files did not load: ${errors.map((e) => e.message).join("; ")}` : "";
100
+ throw new SanomaError("invalid_input", `No scenario named "${name}"${why}`, { scenario: name });
101
+ }
102
+ if (scenario.workflow !== run.workflow) {
103
+ throw new SanomaError("invalid_input", `Scenario "${name}" runs ${scenario.workflow}, not ${run.workflow}`, {
104
+ scenario: name,
105
+ workflow: scenario.workflow,
106
+ });
107
+ }
108
+ const fakes = new Map([...state.fakes].map(([vendor, make]) => [vendor, make()]));
109
+ const drivers = indexDrivers(
110
+ [...fakes.values()].map((f) => f.driver),
111
+ state.ops,
112
+ );
113
+ const uses = opIdsOf(state.workflows.get(run.workflow)!);
114
+ const seeding = scenario.given.flatMap((g) => ("op" in g ? [g.op] : []));
115
+ const missing = [...new Set([...uses, ...seeding])].filter((id) => !drivers.has(id));
116
+ if (missing.length) {
117
+ throw new SanomaError(
118
+ "invalid_input",
119
+ `A sandbox run of ${run.workflow} has no fake for ${missing.join(", ")}: no fake its connectors attach implements ${missing.length > 1 ? "them" : "it"}`,
120
+ { scenario: name, missing },
121
+ );
122
+ }
123
+ const plain = missingResourceFake(scenario, fakes);
124
+ if (plain) throw plain;
125
+ const seeded: Extract<LedgerRecord, { type: "scenario.seeded" }>["seeds"] = [];
126
+ for (const [i, given] of scenario.given.entries()) {
127
+ if ("fault" in given) continue;
128
+ if ("op" in given) {
129
+ const call = { idempotencyKey: `${run.id}:seed:${i}`, runId: run.id, opId: given.op, attempt: 1 };
130
+ const output = await drivers.get(given.op)!(given.input, call);
131
+ seeded.push({ op: given.op, input: given.input, output });
132
+ continue;
133
+ }
134
+ // Checked above: the vendor's fake holds resources.
135
+ seeded.push(seedResource(name, fakes.get(given.vendor) as Fake<unknown> & ResourceFake, given));
136
+ }
137
+ for (const given of scenario.given) {
138
+ if ("fault" in given) fakes.get(state.ops.get(given.op)!.vendor)![given.fault](given.op);
139
+ }
140
+ copies = drivers;
141
+ return { seeds: seeded };
142
+ },
143
+ { name: "sandbox:seed" },
144
+ );
145
+ if (!copies) {
146
+ // Replayed from its checkpoint (after a worker restart, or in a fork), so the run has no fakes.
147
+ // The records written before the restart hold the seqs from here on: the failure goes after them.
148
+ run.seq = Math.max(run.seq, ...(await state.ledger.read(run.id)).map((r) => r.seq + 1));
149
+ throw new SanomaError(
150
+ "invalid_input",
151
+ `Sandbox run ${run.id} was interrupted by a worker restart and the fakes' state is gone; start the scenario again`,
152
+ { runId: run.id, scenario: name },
153
+ );
154
+ }
155
+ run.fakeDrivers = copies;
156
+ await write(run, entry(run, { type: "scenario.seeded", scenario: name, seeds }));
157
+ }