@sanoma-ai/workflows 0.0.0-stage → 0.2.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.
- package/LICENSE +105 -0
- package/README.md +544 -2
- package/dist/apply-steps.d.ts +6 -0
- package/dist/apply-steps.js +93 -0
- package/dist/apply-steps.js.map +1 -0
- package/dist/apply.d.ts +197 -0
- package/dist/apply.js +470 -0
- package/dist/apply.js.map +1 -0
- package/dist/approvals.d.ts +46 -0
- package/dist/approvals.js +111 -0
- package/dist/approvals.js.map +1 -0
- package/dist/ast.d.ts +57 -0
- package/dist/ast.js +81 -0
- package/dist/ast.js.map +1 -0
- package/dist/call.d.ts +37 -0
- package/dist/call.js +417 -0
- package/dist/call.js.map +1 -0
- package/dist/client.d.ts +203 -0
- package/dist/client.js +457 -0
- package/dist/client.js.map +1 -0
- package/dist/config.d.ts +126 -0
- package/dist/config.js +264 -0
- package/dist/config.js.map +1 -0
- package/dist/credentials.d.ts +55 -0
- package/dist/credentials.js +261 -0
- package/dist/credentials.js.map +1 -0
- package/dist/cucumber.d.ts +64 -0
- package/dist/cucumber.js +158 -0
- package/dist/cucumber.js.map +1 -0
- package/dist/datafile.d.ts +71 -0
- package/dist/datafile.js +380 -0
- package/dist/datafile.js.map +1 -0
- package/dist/db.d.ts +58 -0
- package/dist/db.js +179 -0
- package/dist/db.js.map +1 -0
- package/dist/define.d.ts +205 -0
- package/dist/define.js +103 -0
- package/dist/define.js.map +1 -0
- package/dist/describe.d.ts +141 -0
- package/dist/describe.js +135 -0
- package/dist/describe.js.map +1 -0
- package/dist/drift.d.ts +127 -0
- package/dist/drift.js +167 -0
- package/dist/drift.js.map +1 -0
- package/dist/env.d.ts +49 -0
- package/dist/env.js +83 -0
- package/dist/env.js.map +1 -0
- package/dist/errors.d.ts +88 -0
- package/dist/errors.js +148 -0
- package/dist/errors.js.map +1 -0
- package/dist/expectations.d.ts +26 -0
- package/dist/expectations.js +162 -0
- package/dist/expectations.js.map +1 -0
- package/dist/fake.d.ts +55 -0
- package/dist/fake.js +87 -0
- package/dist/fake.js.map +1 -0
- package/dist/fill.d.ts +9 -0
- package/dist/fill.js +17 -0
- package/dist/fill.js.map +1 -0
- package/dist/host.d.ts +56 -0
- package/dist/host.js +45 -0
- package/dist/host.js.map +1 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +29 -0
- package/dist/index.js.map +1 -0
- package/dist/ledger.d.ts +217 -0
- package/dist/ledger.js +251 -0
- package/dist/ledger.js.map +1 -0
- package/dist/lint.d.ts +32 -0
- package/dist/lint.js +168 -0
- package/dist/lint.js.map +1 -0
- package/dist/lockfile.d.ts +36 -0
- package/dist/lockfile.js +69 -0
- package/dist/lockfile.js.map +1 -0
- package/dist/log.d.ts +24 -0
- package/dist/log.js +83 -0
- package/dist/log.js.map +1 -0
- package/dist/op.d.ts +260 -0
- package/dist/op.js +188 -0
- package/dist/op.js.map +1 -0
- package/dist/outline.d.ts +179 -0
- package/dist/outline.js +506 -0
- package/dist/outline.js.map +1 -0
- package/dist/paths.d.ts +11 -0
- package/dist/paths.js +30 -0
- package/dist/paths.js.map +1 -0
- package/dist/plugin.d.ts +28 -0
- package/dist/plugin.js +25 -0
- package/dist/plugin.js.map +1 -0
- package/dist/policy.d.ts +114 -0
- package/dist/policy.js +60 -0
- package/dist/policy.js.map +1 -0
- package/dist/resource.d.ts +378 -0
- package/dist/resource.js +430 -0
- package/dist/resource.js.map +1 -0
- package/dist/resources.d.ts +77 -0
- package/dist/resources.js +244 -0
- package/dist/resources.js.map +1 -0
- package/dist/run.d.ts +101 -0
- package/dist/run.js +7 -0
- package/dist/run.js.map +1 -0
- package/dist/sandbox.d.ts +33 -0
- package/dist/sandbox.js +127 -0
- package/dist/sandbox.js.map +1 -0
- package/dist/scenario.d.ts +221 -0
- package/dist/scenario.js +1104 -0
- package/dist/scenario.js.map +1 -0
- package/dist/scenarios.d.ts +55 -0
- package/dist/scenarios.js +71 -0
- package/dist/scenarios.js.map +1 -0
- package/dist/secret.d.ts +42 -0
- package/dist/secret.js +58 -0
- package/dist/secret.js.map +1 -0
- package/dist/shared.d.ts +69 -0
- package/dist/shared.js +84 -0
- package/dist/shared.js.map +1 -0
- package/dist/source.d.ts +58 -0
- package/dist/source.js +54 -0
- package/dist/source.js.map +1 -0
- package/dist/steps.d.ts +76 -0
- package/dist/steps.js +23 -0
- package/dist/steps.js.map +1 -0
- package/dist/version.d.ts +29 -0
- package/dist/version.js +76 -0
- package/dist/version.js.map +1 -0
- package/dist/worker.d.ts +37 -0
- package/dist/worker.js +228 -0
- package/dist/worker.js.map +1 -0
- package/dist/writer.d.ts +35 -0
- package/dist/writer.js +201 -0
- package/dist/writer.js.map +1 -0
- package/oxlint.json +128 -0
- package/package.json +96 -3
- package/src/apply-steps.ts +110 -0
- package/src/apply.ts +626 -0
- package/src/approvals.ts +157 -0
- package/src/ast.ts +115 -0
- package/src/call.ts +483 -0
- package/src/client.ts +604 -0
- package/src/config.ts +404 -0
- package/src/credentials.ts +296 -0
- package/src/cucumber.ts +230 -0
- package/src/datafile.ts +481 -0
- package/src/db.ts +197 -0
- package/src/define.ts +285 -0
- package/src/describe.ts +282 -0
- package/src/drift.ts +259 -0
- package/src/env.ts +122 -0
- package/src/errors.ts +202 -0
- package/src/expectations.ts +191 -0
- package/src/fake.ts +156 -0
- package/src/fill.ts +24 -0
- package/src/host.ts +96 -0
- package/src/index.ts +143 -0
- package/src/ledger.ts +419 -0
- package/src/lint.ts +208 -0
- package/src/lockfile.ts +95 -0
- package/src/log.ts +82 -0
- package/src/op.ts +402 -0
- package/src/outline.ts +620 -0
- package/src/paths.ts +32 -0
- package/src/plugin.ts +37 -0
- package/src/policy.ts +139 -0
- package/src/resource.ts +670 -0
- package/src/resources.ts +311 -0
- package/src/run.ts +106 -0
- package/src/sandbox.ts +161 -0
- package/src/scenario.ts +1365 -0
- package/src/scenarios.ts +111 -0
- package/src/secret.ts +92 -0
- package/src/shared.ts +126 -0
- package/src/source.ts +82 -0
- package/src/steps.ts +90 -0
- package/src/version.ts +86 -0
- package/src/worker.ts +264 -0
- package/src/writer.ts +225 -0
package/src/config.ts
ADDED
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
import { DBOS } from "@dbos-inc/dbos-sdk";
|
|
5
|
+
import { type ApplyDeclared, applyWorkflow } from "./apply.ts";
|
|
6
|
+
import { applySteps } from "./apply-steps.ts";
|
|
7
|
+
import { callerFile, type Use, type WorkflowDefinition } from "./define.ts";
|
|
8
|
+
import { DRIFT_WORKFLOW, driftWorkflow } from "./drift.ts";
|
|
9
|
+
import { statusesOf, unconfigured } from "./env.ts";
|
|
10
|
+
import type { Fake } from "./fake.ts";
|
|
11
|
+
import type { LedgerStore } from "./ledger.ts";
|
|
12
|
+
import {
|
|
13
|
+
type Connector,
|
|
14
|
+
type ConnectorSetup,
|
|
15
|
+
type Driver,
|
|
16
|
+
DriverError,
|
|
17
|
+
type DriverEnv,
|
|
18
|
+
type DriverFn,
|
|
19
|
+
isOp,
|
|
20
|
+
type Op,
|
|
21
|
+
checkConnector,
|
|
22
|
+
setupOf,
|
|
23
|
+
VENDOR,
|
|
24
|
+
} from "./op.ts";
|
|
25
|
+
import type { Policy } from "./policy.ts";
|
|
26
|
+
import { isChangeOp, type Resource, resourceTypesOf, withoutWriteOnly } from "./resource.ts";
|
|
27
|
+
import { APPLY_WORKFLOW, BUILT_IN_WORKFLOWS } from "./shared.ts";
|
|
28
|
+
import { sourceDriver, sourceOps } from "./source.ts";
|
|
29
|
+
import type { Steps } from "./steps.ts";
|
|
30
|
+
import { computeVersion } from "./version.ts";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* What a worker runs, on which connectors, under which policy: the argument to `startWorker`.
|
|
34
|
+
* A project usually keeps it in `sanoma.config.ts` and passes it in; nothing loads that file
|
|
35
|
+
* automatically. The rest is convention: each connector carries its driver and its fake
|
|
36
|
+
* (`withSetup`), the app's name is its `package.json`'s, and the scenarios and data files are
|
|
37
|
+
* `scenarios/` and `resources/` in `root`.
|
|
38
|
+
*/
|
|
39
|
+
export interface SanomaConfig {
|
|
40
|
+
workflows: WorkflowDefinition<any, any>[];
|
|
41
|
+
/**
|
|
42
|
+
* The connectors (from `defineConnector`, as their packages export them). The worker takes
|
|
43
|
+
* each operation's effect, schemas and retry setting from here, not from the workflow, and
|
|
44
|
+
* refuses a workflow that declares an operation differently. A live run calls the driver its
|
|
45
|
+
* connector's package attaches; a sandbox run calls a fake of its own, which the connector's
|
|
46
|
+
* package attaches the maker of.
|
|
47
|
+
*/
|
|
48
|
+
connectors: Connector<any, any>[];
|
|
49
|
+
/** Checked before every operation call. Required: `allowAll` says that every call is allowed. */
|
|
50
|
+
policy: Policy;
|
|
51
|
+
/**
|
|
52
|
+
* Where the audit record goes. Default: `postgresLedger` on the config's database, which the
|
|
53
|
+
* worker, the app and other processes share; the worker and the client each make their own
|
|
54
|
+
* and close it with themselves. A store given here is never closed by the runtime.
|
|
55
|
+
* `jsonlLedger(dir)` keeps it in files instead; `memoryLedger()` keeps it in this process
|
|
56
|
+
* only, for tests.
|
|
57
|
+
*/
|
|
58
|
+
ledger?: LedgerStore;
|
|
59
|
+
/** Postgres for the runtime. Defaults to `SANOMA_DATABASE_URL`, then the local docker compose database. */
|
|
60
|
+
databaseUrl?: string;
|
|
61
|
+
/** The file that defined the config, absolute, when known. Set by `defineConfig` from its call site. */
|
|
62
|
+
file?: string;
|
|
63
|
+
/**
|
|
64
|
+
* The directory the config's conventions are relative to: its `package.json` (the app's name),
|
|
65
|
+
* `scenarios/` and `resources/`. Defaults to `file`'s directory; set it where that is not the
|
|
66
|
+
* source's, as in a bundle.
|
|
67
|
+
*/
|
|
68
|
+
root?: string;
|
|
69
|
+
/** @deprecated Gone: each connector carries its driver (`withSetup`, from its package). Leave it out. */
|
|
70
|
+
drivers?: never;
|
|
71
|
+
/** @deprecated Gone: each connector carries the maker of its fake (`withSetup`, from its package). Leave it out. */
|
|
72
|
+
fakes?: never;
|
|
73
|
+
/** @deprecated Gone: the app's name is the `name` in `<root>/package.json`, else "sanoma". Leave it out. */
|
|
74
|
+
appName?: never;
|
|
75
|
+
/** @deprecated Gone: the scenarios are `<root>/scenarios/`. Leave it out. */
|
|
76
|
+
scenarios?: never;
|
|
77
|
+
/** The company's own steps for its scenarios (`defineSteps`), besides those its connectors ship. */
|
|
78
|
+
steps?: Steps[];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** A config, checked, with everything a worker, client or app derives from it. */
|
|
82
|
+
export interface ResolvedConfig {
|
|
83
|
+
/**
|
|
84
|
+
* Scopes workflows, queues and versions in the system database: the `name` of
|
|
85
|
+
* `<root>/package.json` when it names one, else "sanoma".
|
|
86
|
+
*/
|
|
87
|
+
appName: string;
|
|
88
|
+
databaseUrl: string;
|
|
89
|
+
/** The DBOS application version: `<appName>@<DBOS__APPVERSION, or a hash of the workflows>`. */
|
|
90
|
+
version: string;
|
|
91
|
+
/** The DBOS queue runs are started on: `sanoma:<appName>`. */
|
|
92
|
+
queueName: string;
|
|
93
|
+
/** The operations the connectors declare, by id, and the built-in `sanoma.source.resolve` when one declares a host type. */
|
|
94
|
+
ops: Map<string, Op>;
|
|
95
|
+
/** The drivers' functions, by operation id, each guarded by its driver's `env` (`indexDrivers`). */
|
|
96
|
+
drivers: Map<string, DriverFn>;
|
|
97
|
+
/**
|
|
98
|
+
* The driver each vendor's connector attaches, made once for this resolution, by vendor id in
|
|
99
|
+
* `connectors` order: what `drivers` indexes and the credentials are read from (`credentialsOf`).
|
|
100
|
+
*/
|
|
101
|
+
driversByVendor: Map<string, Driver>;
|
|
102
|
+
/** The makers of the fakes the connectors attach, by vendor id in `connectors` order: each sandbox run makes its own. */
|
|
103
|
+
fakes: Map<string, () => Fake<any, any>>;
|
|
104
|
+
/** `scenarios/` in `root`, as a `file:` URL, when that directory exists. */
|
|
105
|
+
scenarios?: URL;
|
|
106
|
+
/** The resource types the connectors declare, by `<vendor>.<type>`: what the drift workflow checks and scenarios seed. */
|
|
107
|
+
types: ReadonlyMap<string, Resource>;
|
|
108
|
+
/** The steps scenarios can use besides the generic ones: each connector's (`withSetup`), once each, then the config's own. */
|
|
109
|
+
steps: readonly Steps[];
|
|
110
|
+
/**
|
|
111
|
+
* By name; each checked against `ops` and `drivers`. The config's, and the built-ins `drift`
|
|
112
|
+
* (`DRIFT_WORKFLOW`) and `apply` (`APPLY_WORKFLOW`) when its connectors declare resource types.
|
|
113
|
+
*/
|
|
114
|
+
workflows: Map<string, WorkflowDefinition<any, any>>;
|
|
115
|
+
policy: Policy;
|
|
116
|
+
/** The config's `ledger`, when it gives one; the worker and the client otherwise make `postgresLedger` on `databaseUrl` (`ownedLedger`). */
|
|
117
|
+
ledger?: LedgerStore;
|
|
118
|
+
/**
|
|
119
|
+
* The config's directory, absolute: its `root`, else its `file`'s directory. Its data files
|
|
120
|
+
* are `resources/` in it. Absent when the config has neither, so it has no data files.
|
|
121
|
+
*/
|
|
122
|
+
root?: string;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export const DEFAULT_DATABASE_URL = "postgresql://postgres:dbos@localhost:5433/sanoma";
|
|
126
|
+
|
|
127
|
+
/** Returns the config, with the file it is called from as its `file`: its directory is the config's `root`. */
|
|
128
|
+
export function defineConfig(config: SanomaConfig): SanomaConfig {
|
|
129
|
+
return { ...config, file: config.file ?? callerFile() };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function resolveDatabaseUrl(config: { databaseUrl?: string }, env: NodeJS.ProcessEnv = process.env): string {
|
|
133
|
+
return config.databaseUrl ?? env.SANOMA_DATABASE_URL ?? DEFAULT_DATABASE_URL;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const isStore = (x: unknown): x is LedgerStore =>
|
|
137
|
+
typeof (x as LedgerStore | null)?.append === "function" && typeof (x as LedgerStore).read === "function";
|
|
138
|
+
|
|
139
|
+
/** The config's `ledger`, checked, when it gives one. */
|
|
140
|
+
function ledgerOf(config: SanomaConfig): LedgerStore | undefined {
|
|
141
|
+
if (config.ledger !== undefined && !isStore(config.ledger)) {
|
|
142
|
+
throw new Error(
|
|
143
|
+
"The config's `ledger` must be a ledger store: leave it out for `postgresLedger` on the config's database, " +
|
|
144
|
+
"or use `jsonlLedger(dir)` to keep records in files, or `memoryLedger()` to keep them in memory, for tests",
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
return config.ledger;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The config's directory, absolute: its `root`, else its `file`'s directory; undefined when it
|
|
152
|
+
* has neither. No fallback to the working directory: a config found nowhere has no data files,
|
|
153
|
+
* and says so. What a read that needs only the root calls, rather than `resolveConfig`.
|
|
154
|
+
*/
|
|
155
|
+
export function rootOf(config: Pick<SanomaConfig, "root" | "file">): string | undefined {
|
|
156
|
+
return config.root !== undefined
|
|
157
|
+
? resolve(config.root)
|
|
158
|
+
: config.file !== undefined
|
|
159
|
+
? dirname(config.file)
|
|
160
|
+
: undefined;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Checks a config and derives what the runtime needs from it: the drivers and fakes from the
|
|
165
|
+
* connectors' setups (`withSetup`), and from `root`, the app's name (`package.json`) and the
|
|
166
|
+
* scenarios (`scenarios/`). Throws what `startWorker` would refuse, with the same message, so
|
|
167
|
+
* `describeConfig`, `SanomaClient.connect` and the app fail the same way the worker does. It
|
|
168
|
+
* opens nothing and reads no file outside `root`. Each entry
|
|
169
|
+
* point resolves once: the worker and the client when they start, the app at `startApp`, the
|
|
170
|
+
* `sanoma` bin when it loads the config.
|
|
171
|
+
*/
|
|
172
|
+
export function resolveConfig(config: SanomaConfig): ResolvedConfig {
|
|
173
|
+
if (!Array.isArray(config.connectors)) {
|
|
174
|
+
throw new Error("The config needs `connectors`: the connectors its workflows call, as their packages export them");
|
|
175
|
+
}
|
|
176
|
+
for (const connector of config.connectors as unknown[]) checkConnector(connector);
|
|
177
|
+
if (!Array.isArray(config.workflows)) {
|
|
178
|
+
throw new Error("The config needs `workflows`: an array of defineWorkflow definitions");
|
|
179
|
+
}
|
|
180
|
+
if (typeof config.policy !== "function") {
|
|
181
|
+
throw new Error("The config needs a `policy`; use `allowAll` to allow every operation call");
|
|
182
|
+
}
|
|
183
|
+
// An untyped config naming fake drivers would otherwise run live. Delete once sanoma-ops has migrated.
|
|
184
|
+
if (config.drivers !== undefined || config.fakes !== undefined)
|
|
185
|
+
throw new Error(
|
|
186
|
+
"The config's `drivers` and `fakes` are gone: each connector carries its driver and its fake; leave them out",
|
|
187
|
+
);
|
|
188
|
+
const ledger = ledgerOf(config);
|
|
189
|
+
const root = rootOf(config);
|
|
190
|
+
const appName = appNameAt(root);
|
|
191
|
+
const scenarios = scenariosAt(root);
|
|
192
|
+
const types = resourceTypesOf(config.connectors);
|
|
193
|
+
// Built-in: `sanoma.source.resolve`, which apply and drift call for a host's revision.
|
|
194
|
+
const hosts = [...types.values()].some((type) => type.host);
|
|
195
|
+
const ops = indexConnectors([...config.connectors, ...(hosts ? [sourceOps] : [])]);
|
|
196
|
+
const driversByVendor = new Map([...makersOf(config.connectors, "driver")].map(([vendor, make]) => [vendor, make()]));
|
|
197
|
+
const drivers = indexDrivers([...driversByVendor.values(), ...(hosts ? [sourceDriver] : [])], ops);
|
|
198
|
+
const fakes = makersOf(config.connectors, "fake");
|
|
199
|
+
const names = new Map<string, WorkflowDefinition<any, any>>();
|
|
200
|
+
for (const wf of config.workflows) {
|
|
201
|
+
if (BUILT_IN_WORKFLOWS.has(wf.name)) {
|
|
202
|
+
throw new Error(`A workflow is named "${wf.name}", the name of a built-in workflow; name it otherwise`);
|
|
203
|
+
}
|
|
204
|
+
checkUses(wf, ops, drivers, types);
|
|
205
|
+
const other = names.get(wf.name);
|
|
206
|
+
if (other && other !== wf) {
|
|
207
|
+
throw new Error(
|
|
208
|
+
`Two different workflow definitions are named "${wf.name}"; a name can be registered once per process`,
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
names.set(wf.name, wf);
|
|
212
|
+
}
|
|
213
|
+
// Built-in: checking the resources the data files declare against their vendors, and applying them.
|
|
214
|
+
if (types.size) {
|
|
215
|
+
const read = () => readDeclared(root, config.connectors, types);
|
|
216
|
+
names.set(
|
|
217
|
+
DRIFT_WORKFLOW,
|
|
218
|
+
driftWorkflow(types, ops, drivers, () => DBOS.runStep(read, { name: "drift:data-files" })),
|
|
219
|
+
);
|
|
220
|
+
names.set(APPLY_WORKFLOW, applyWorkflow(types, ops, drivers, applySteps(appName, root, read)));
|
|
221
|
+
}
|
|
222
|
+
return {
|
|
223
|
+
appName,
|
|
224
|
+
databaseUrl: resolveDatabaseUrl(config),
|
|
225
|
+
// The built-in's code and operations are part of the app's version, like the config's own.
|
|
226
|
+
version: computeVersion({ appName, workflows: [...names.values()] }),
|
|
227
|
+
queueName: `sanoma:${appName}`,
|
|
228
|
+
ops,
|
|
229
|
+
drivers,
|
|
230
|
+
driversByVendor,
|
|
231
|
+
fakes,
|
|
232
|
+
...(scenarios && { scenarios }),
|
|
233
|
+
types,
|
|
234
|
+
steps: [...new Set([...config.connectors.flatMap((c) => setupOf(c).steps ?? []), ...(config.steps ?? [])])],
|
|
235
|
+
workflows: names,
|
|
236
|
+
policy: config.policy,
|
|
237
|
+
...(ledger !== undefined && { ledger }),
|
|
238
|
+
...(root !== undefined && { root }),
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* What the connectors' setups attach under `key` (`withSetup`), by vendor id in `connectors`
|
|
244
|
+
* order. One vendor's connectors may name one maker, as copies of one connector do, never two.
|
|
245
|
+
*/
|
|
246
|
+
function makersOf<K extends keyof ConnectorSetup>(
|
|
247
|
+
connectors: readonly Connector<any, any>[],
|
|
248
|
+
key: K,
|
|
249
|
+
): Map<string, NonNullable<ConnectorSetup[K]>> {
|
|
250
|
+
const makers = new Map<string, NonNullable<ConnectorSetup[K]>>();
|
|
251
|
+
for (const connector of connectors) {
|
|
252
|
+
const make = setupOf(connector)[key];
|
|
253
|
+
if (make === undefined) continue;
|
|
254
|
+
const vendor = connector[VENDOR].id;
|
|
255
|
+
const seen = makers.get(vendor);
|
|
256
|
+
if (seen && seen !== make) {
|
|
257
|
+
throw new Error(`Two connectors in \`connectors\` attach a ${key} for "${vendor}"; keep one per vendor`);
|
|
258
|
+
}
|
|
259
|
+
makers.set(vendor, make);
|
|
260
|
+
}
|
|
261
|
+
return makers;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* The `name` of `<root>/package.json`, kept whole (`@acme/ops`), when that file exists and names
|
|
266
|
+
* one; else "sanoma", as for a config with no `root`. Never a `package.json` above the root: a
|
|
267
|
+
* parent project's name must not become the app's. A file that is not JSON is refused, naming it.
|
|
268
|
+
*/
|
|
269
|
+
function appNameAt(root: string | undefined): string {
|
|
270
|
+
const file = root === undefined ? undefined : join(root, "package.json");
|
|
271
|
+
if (file === undefined || !existsSync(file)) return "sanoma";
|
|
272
|
+
let manifest: unknown;
|
|
273
|
+
try {
|
|
274
|
+
manifest = JSON.parse(readFileSync(file, "utf8"));
|
|
275
|
+
} catch (err) {
|
|
276
|
+
throw new Error(`${file} is not JSON, so it names no app: ${(err as Error).message}`, { cause: err });
|
|
277
|
+
}
|
|
278
|
+
const named = (manifest as { name?: unknown } | null)?.name;
|
|
279
|
+
return typeof named === "string" && named.trim() ? named.trim() : "sanoma";
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** `<root>/scenarios/`, as a `file:` URL to the directory (with its trailing slash), when it is a directory. */
|
|
283
|
+
function scenariosAt(root: string | undefined): URL | undefined {
|
|
284
|
+
const dir = root === undefined ? undefined : join(root, "scenarios");
|
|
285
|
+
if (dir === undefined || !statSync(dir, { throwIfNoEntry: false })?.isDirectory()) return undefined;
|
|
286
|
+
return pathToFileURL(`${dir}/`);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* The data files under `root` and the lockfile beside them, in one read, as a drift or apply run
|
|
291
|
+
* records them: each resource with its binding, without its write-only values (a secret marker
|
|
292
|
+
* names one, and stays), which a run never compares and never records, the bindings, the orphans
|
|
293
|
+
* and the problems.
|
|
294
|
+
*/
|
|
295
|
+
async function readDeclared(
|
|
296
|
+
root: string | undefined,
|
|
297
|
+
connectors: readonly Connector<any, any>[],
|
|
298
|
+
types: ReadonlyMap<string, Resource>,
|
|
299
|
+
): Promise<Omit<ApplyDeclared, "rebinds">> {
|
|
300
|
+
// Loaded when a run reads the files, not with the worker: the reader parses with oxc-parser.
|
|
301
|
+
const { readDataFiles } = await import("./resources.ts");
|
|
302
|
+
const { resources, bindings, orphans, problems } = readDataFiles(root, connectors, types);
|
|
303
|
+
return {
|
|
304
|
+
resources: resources.map(({ id, vendor, type, name, identity, binding, desired, refs }) => ({
|
|
305
|
+
id,
|
|
306
|
+
vendor,
|
|
307
|
+
type,
|
|
308
|
+
name,
|
|
309
|
+
...(identity !== undefined && { identity }),
|
|
310
|
+
...(binding !== undefined && { binding }),
|
|
311
|
+
desired: withoutWriteOnly(types.get(`${vendor}.${type}`)!.fields, desired),
|
|
312
|
+
refs,
|
|
313
|
+
})),
|
|
314
|
+
bindings,
|
|
315
|
+
orphans,
|
|
316
|
+
problems,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function indexConnectors(list: Connector<any, any>[]) {
|
|
321
|
+
const map = new Map<string, Op>();
|
|
322
|
+
for (const connector of list) {
|
|
323
|
+
for (const resource of Object.values(connector as Record<string, Record<string, unknown>>)) {
|
|
324
|
+
for (const op of Object.values(resource)) {
|
|
325
|
+
if (!isOp(op)) continue;
|
|
326
|
+
const seen = map.get(op.id);
|
|
327
|
+
if (seen && seen !== op) throw new Error(`Two connectors in \`connectors\` declare ${op.id}`);
|
|
328
|
+
map.set(op.id, op);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return map;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* The drivers' functions by operation id, refusing one no connector declares. A driver that
|
|
337
|
+
* declares an `env` has each function guarded by it (`guarded`); a fake declares none, so a
|
|
338
|
+
* sandbox run's calls are not.
|
|
339
|
+
*/
|
|
340
|
+
export function indexDrivers(list: Driver[], ops: Map<string, Op>) {
|
|
341
|
+
const map = new Map<string, DriverFn>();
|
|
342
|
+
for (const d of list) {
|
|
343
|
+
for (const [key, fn] of Object.entries(d.ops)) {
|
|
344
|
+
const id = `${d.vendor}.${key}`;
|
|
345
|
+
if (!ops.has(id)) {
|
|
346
|
+
throw new Error(`Driver "${d.vendor}" implements ${id}, which no connector in \`connectors\` declares`);
|
|
347
|
+
}
|
|
348
|
+
map.set(id, d.env ? guarded(d.vendor, d.env, fn) : fn);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return map;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* `fn`, refused while the environment leaves a variable `env` declares missing or invalid. The
|
|
356
|
+
* worker started without it (`startWorker` only warns), and stored credentials load into the
|
|
357
|
+
* environment as they are set, so the check is made when the function is called, inside the
|
|
358
|
+
* driver step, and nowhere earlier: not at start, and not before the policy's decision or an
|
|
359
|
+
* approval hold, so a credential set on the connector page while a call waits for approval is
|
|
360
|
+
* used when the approver approves. The refusal is the call's own failure, final: recorded as
|
|
361
|
+
* the call's, and never retried.
|
|
362
|
+
*/
|
|
363
|
+
function guarded(vendor: string, env: DriverEnv, fn: DriverFn): DriverFn {
|
|
364
|
+
return async (input, call) => {
|
|
365
|
+
const why = unconfigured(
|
|
366
|
+
vendor,
|
|
367
|
+
statusesOf(env, (name) => process.env[name]),
|
|
368
|
+
);
|
|
369
|
+
if (why) throw new DriverError(why, { retryable: false });
|
|
370
|
+
return fn(input, call);
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Every operation a workflow uses must be one the config's connectors declare, with a driver.
|
|
376
|
+
* The workflow normally imports the same connector object; a copy is accepted only if it
|
|
377
|
+
* declares the same effect and retry setting, and the worker's declaration is used either way.
|
|
378
|
+
* None may change a declared resource (`isChangeOp`): only the built-in apply workflow does.
|
|
379
|
+
*/
|
|
380
|
+
function checkUses(
|
|
381
|
+
wf: WorkflowDefinition<any, any>,
|
|
382
|
+
ops: Map<string, Op>,
|
|
383
|
+
impls: Map<string, unknown>,
|
|
384
|
+
types: ReadonlyMap<string, Resource>,
|
|
385
|
+
) {
|
|
386
|
+
const problems: string[] = [];
|
|
387
|
+
for (const op of (wf.uses as readonly Use[]).filter(isOp)) {
|
|
388
|
+
const known = ops.get(op.id);
|
|
389
|
+
if (!known) problems.push(`${op.id} is not declared by any connector in \`connectors\``);
|
|
390
|
+
else if (isChangeOp(known, types)) {
|
|
391
|
+
problems.push(
|
|
392
|
+
`${op.id} changes a declared resource, which only ${APPLY_WORKFLOW} does: change the data file instead`,
|
|
393
|
+
);
|
|
394
|
+
} else if (known !== op && (known.effect !== op.effect || known.idempotent !== op.idempotent)) {
|
|
395
|
+
problems.push(
|
|
396
|
+
`${op.id} is declared with effect "${op.effect}"${op.idempotent ? " (idempotent)" : ""}, ` +
|
|
397
|
+
`but its connector says "${known.effect}"${known.idempotent ? " (idempotent)" : ""}`,
|
|
398
|
+
);
|
|
399
|
+
} else if (!impls.has(op.id)) {
|
|
400
|
+
problems.push(`\`${op.id}\` has no driver: the connector package must attach one`);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
if (problems.length) throw new Error(`Workflow "${wf.name}": ${problems.join("; ")}`);
|
|
404
|
+
}
|