@sanoma-ai/workflows 0.1.0 → 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/README.md +72 -21
- 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/call.d.ts +4 -0
- package/dist/call.js +19 -4
- package/dist/call.js.map +1 -1
- package/dist/client.d.ts +38 -2
- package/dist/client.js +67 -10
- package/dist/client.js.map +1 -1
- package/dist/config.d.ts +3 -3
- package/dist/config.js +33 -16
- package/dist/config.js.map +1 -1
- package/dist/credentials.d.ts +25 -0
- package/dist/credentials.js +73 -5
- package/dist/credentials.js.map +1 -1
- package/dist/datafile.d.ts +11 -2
- package/dist/datafile.js +56 -2
- package/dist/datafile.js.map +1 -1
- package/dist/describe.d.ts +11 -5
- package/dist/describe.js +13 -5
- package/dist/describe.js.map +1 -1
- package/dist/drift.d.ts +64 -12
- package/dist/drift.js +118 -41
- package/dist/drift.js.map +1 -1
- package/dist/expectations.d.ts +2 -2
- package/dist/expectations.js +28 -3
- package/dist/expectations.js.map +1 -1
- 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 +6 -2
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/lockfile.d.ts +36 -0
- package/dist/lockfile.js +69 -0
- package/dist/lockfile.js.map +1 -0
- package/dist/op.d.ts +3 -0
- package/dist/op.js.map +1 -1
- package/dist/resource.d.ts +188 -22
- package/dist/resource.js +173 -28
- package/dist/resource.js.map +1 -1
- package/dist/resources.d.ts +18 -3
- package/dist/resources.js +53 -6
- package/dist/resources.js.map +1 -1
- package/dist/run.d.ts +14 -0
- package/dist/run.js +6 -1
- package/dist/run.js.map +1 -1
- package/dist/sandbox.js +5 -1
- package/dist/sandbox.js.map +1 -1
- package/dist/scenario.d.ts +12 -3
- package/dist/scenario.js +33 -8
- package/dist/scenario.js.map +1 -1
- 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 +8 -0
- package/dist/shared.js +7 -0
- package/dist/shared.js.map +1 -1
- package/dist/source.d.ts +58 -0
- package/dist/source.js +54 -0
- package/dist/source.js.map +1 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker.d.ts +3 -0
- package/dist/worker.js +13 -4
- package/dist/worker.js.map +1 -1
- package/dist/writer.d.ts +35 -0
- package/dist/writer.js +201 -0
- package/dist/writer.js.map +1 -0
- package/package.json +6 -1
- package/src/apply-steps.ts +110 -0
- package/src/apply.ts +626 -0
- package/src/call.ts +19 -4
- package/src/client.ts +83 -13
- package/src/config.ts +48 -25
- package/src/credentials.ts +90 -5
- package/src/datafile.ts +76 -4
- package/src/describe.ts +36 -16
- package/src/drift.ts +155 -45
- package/src/expectations.ts +30 -4
- package/src/host.ts +96 -0
- package/src/index.ts +24 -1
- package/src/lockfile.ts +95 -0
- package/src/op.ts +3 -0
- package/src/resource.ts +270 -47
- package/src/resources.ts +63 -8
- package/src/run.ts +15 -0
- package/src/sandbox.ts +5 -1
- package/src/scenario.ts +50 -13
- package/src/secret.ts +92 -0
- package/src/shared.ts +10 -0
- package/src/source.ts +82 -0
- package/src/version.ts +1 -1
- package/src/worker.ts +16 -5
- package/src/writer.ts +225 -0
package/src/resources.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { dirname, join, relative, resolve, sep } from "node:path";
|
|
|
3
3
|
import { parsed } from "./ast.ts";
|
|
4
4
|
import { rootOf, type SanomaConfig } from "./config.ts";
|
|
5
5
|
import { type DataFile, type DataFileExport, type DataValue, readDataFile, Reference } from "./datafile.ts";
|
|
6
|
+
import { type Binding, type Bindings, LOCKFILE, readLockfile } from "./lockfile.ts";
|
|
6
7
|
import { type Connector, VENDOR } from "./op.ts";
|
|
7
8
|
import type { Span } from "./outline.ts";
|
|
8
9
|
import { type Declared, type Resource, resourceTypesOf } from "./resource.ts";
|
|
@@ -16,8 +17,12 @@ export interface DeclaredResource {
|
|
|
16
17
|
file: string;
|
|
17
18
|
vendor: string;
|
|
18
19
|
type: string;
|
|
19
|
-
/**
|
|
20
|
+
/** What people call it: its identity, else its export's name. */
|
|
20
21
|
name: string;
|
|
22
|
+
/** The import id that finds it unbound: what its type's `find` makes of its fields; absent when the type has none. */
|
|
23
|
+
identity?: string;
|
|
24
|
+
/** The vendor's id of the object the lockfile binds it to (`LOCKFILE`); absent when unbound. */
|
|
25
|
+
binding?: string;
|
|
21
26
|
/** Its `export const` statement, as UTF-16 offsets into the file's text. */
|
|
22
27
|
span: Span;
|
|
23
28
|
/** Its fields, as its type checked them: each resource it names as that resource's `name`. */
|
|
@@ -43,10 +48,14 @@ export interface ResourceProblem {
|
|
|
43
48
|
message: string;
|
|
44
49
|
}
|
|
45
50
|
|
|
46
|
-
/** What the data files declare: each resource read without problems, and the problems. */
|
|
51
|
+
/** What the data files declare: each resource read without problems, the lockfile's bindings, and the problems. */
|
|
47
52
|
export interface DataFiles {
|
|
48
53
|
/** By file and then in file order, less any with a problem, and any that names one. */
|
|
49
54
|
resources: DeclaredResource[];
|
|
55
|
+
/** The lockfile's bindings, by key (`<file>#<export>`), as read: none when it is missing or has a problem. */
|
|
56
|
+
bindings: Bindings;
|
|
57
|
+
/** Each binding whose key no data file declares, by key. */
|
|
58
|
+
orphans: (Binding & { key: string })[];
|
|
50
59
|
/** By file, line and column. */
|
|
51
60
|
problems: ResourceProblem[];
|
|
52
61
|
}
|
|
@@ -82,7 +91,11 @@ interface FileEntry {
|
|
|
82
91
|
*
|
|
83
92
|
* Problems: anything outside the subset, a constructor or data file the config does not know, a
|
|
84
93
|
* 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
|
|
94
|
+
* takes none among them), a reference cycle, and two resources of one type with one identity.
|
|
95
|
+
*
|
|
96
|
+
* Each declaration is joined to its binding in the lockfile (`readLockfile`) by its key,
|
|
97
|
+
* `<file>#<export>`; a binding no export declares is an orphan. Problems: the lockfile's, a
|
|
98
|
+
* binding of another type than its declaration's, and two declarations bound to one object.
|
|
86
99
|
*/
|
|
87
100
|
export function readDataFiles(
|
|
88
101
|
root: string | undefined,
|
|
@@ -95,8 +108,10 @@ export function readDataFiles(
|
|
|
95
108
|
message:
|
|
96
109
|
"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
110
|
});
|
|
98
|
-
return { resources: [], problems };
|
|
111
|
+
return { resources: [], bindings: {}, orphans: [], problems };
|
|
99
112
|
}
|
|
113
|
+
const lock = readLockfile(root);
|
|
114
|
+
problems.push(...lock.problems);
|
|
100
115
|
|
|
101
116
|
const files = new Map<string, FileEntry>();
|
|
102
117
|
for (const path of dataFilesUnder(join(root, "resources"))) {
|
|
@@ -175,7 +190,7 @@ export function readDataFiles(
|
|
|
175
190
|
visiting.push(id);
|
|
176
191
|
const result = build(file, exp);
|
|
177
192
|
visiting.pop();
|
|
178
|
-
if (result) {
|
|
193
|
+
if (result?.declared.name !== undefined) {
|
|
179
194
|
const { vendor, type, name } = result.declared;
|
|
180
195
|
const key = `${vendor}.${type} ${JSON.stringify(name)}`;
|
|
181
196
|
const first = byName.get(key);
|
|
@@ -231,18 +246,58 @@ export function readDataFiles(
|
|
|
231
246
|
}
|
|
232
247
|
}
|
|
233
248
|
|
|
249
|
+
// Each binding against its declaration: of its type, and the only one bound to its object.
|
|
250
|
+
const boundBy = new Map<string, string>();
|
|
251
|
+
for (const [id, { file, exp }] of declarations) {
|
|
252
|
+
const binding = lock.bindings[id];
|
|
253
|
+
// An unknown constructors import was reported with the import.
|
|
254
|
+
const vendor = vendors.get(file)!.get(exp.vendor);
|
|
255
|
+
if (binding === undefined || vendor === undefined) continue;
|
|
256
|
+
const type = `${vendor}.${exp.type}`;
|
|
257
|
+
const object = `${binding.type} ${binding.id}`;
|
|
258
|
+
const first = boundBy.get(object);
|
|
259
|
+
const why =
|
|
260
|
+
binding.type !== type
|
|
261
|
+
? `${id} is bound to a ${binding.type} in ${LOCKFILE}, but declares a ${type}`
|
|
262
|
+
: first !== undefined
|
|
263
|
+
? `${id} is bound to the same ${object} as ${first} in ${LOCKFILE}: bind each object once`
|
|
264
|
+
: undefined;
|
|
265
|
+
if (why === undefined) boundBy.set(object, id);
|
|
266
|
+
else {
|
|
267
|
+
// Left out, as any declaration with a problem, and so is any that names it.
|
|
268
|
+
file.problem(exp.start, why);
|
|
269
|
+
done.set(id, undefined);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
const keys = new Set([...files.values()].flatMap((f) => f.data.declared.map((name) => `${f.rel}#${name}`)));
|
|
273
|
+
const orphans = Object.entries(lock.bindings)
|
|
274
|
+
.filter(([key]) => !keys.has(key))
|
|
275
|
+
.map(([key, binding]) => ({ key, ...binding }));
|
|
276
|
+
|
|
234
277
|
const resources: DeclaredResource[] = [];
|
|
235
278
|
for (const [id, { file, exp }] of declarations) {
|
|
236
279
|
const result = declare(id);
|
|
237
280
|
if (!result) continue;
|
|
238
|
-
const { vendor, type, name, desired } = result.declared;
|
|
239
|
-
|
|
281
|
+
const { vendor, type, name: identity, desired } = result.declared;
|
|
282
|
+
const binding = lock.bindings[id]?.id;
|
|
283
|
+
resources.push({
|
|
284
|
+
id,
|
|
285
|
+
file: file.rel,
|
|
286
|
+
vendor,
|
|
287
|
+
type,
|
|
288
|
+
name: identity ?? exp.name,
|
|
289
|
+
...(identity !== undefined && { identity }),
|
|
290
|
+
...(binding !== undefined && { binding }),
|
|
291
|
+
span: [exp.start, exp.end],
|
|
292
|
+
desired,
|
|
293
|
+
refs: result.refs,
|
|
294
|
+
});
|
|
240
295
|
}
|
|
241
296
|
problems.sort(
|
|
242
297
|
(a, b) =>
|
|
243
298
|
(a.file ?? "").localeCompare(b.file ?? "") || (a.line ?? 0) - (b.line ?? 0) || (a.column ?? 0) - (b.column ?? 0),
|
|
244
299
|
);
|
|
245
|
-
return { resources, problems };
|
|
300
|
+
return { resources, bindings: lock.bindings, orphans, problems };
|
|
246
301
|
}
|
|
247
302
|
|
|
248
303
|
/** The data files under `dir`, sorted: regular `.ts` files, less tests, declarations and `node_modules`; none when `dir` is not a directory. */
|
package/src/run.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
1
2
|
import type { ApprovalState, Principal, WorkflowDefinition } from "./define.ts";
|
|
2
3
|
import type { Fake } from "./fake.ts";
|
|
3
4
|
import type { LedgerStore } from "./ledger.ts";
|
|
@@ -5,6 +6,8 @@ import type { DriverFn, Op } from "./op.ts";
|
|
|
5
6
|
import type { CallNode } from "./outline.ts";
|
|
6
7
|
import type { Policy } from "./policy.ts";
|
|
7
8
|
import type { Resource } from "./resource.ts";
|
|
9
|
+
import type { Secrets } from "./secret.ts";
|
|
10
|
+
import type { Source } from "./source.ts";
|
|
8
11
|
import type { Steps } from "./steps.ts";
|
|
9
12
|
|
|
10
13
|
/** What the DBOS workflow receives: the workflow's input, who started the run, and for a sandbox run its scenario. */
|
|
@@ -31,11 +34,15 @@ export interface WorkerState {
|
|
|
31
34
|
fakes: Map<string, () => Fake<any, any>>;
|
|
32
35
|
/** Where sandbox runs' scenarios are, the resource types their steps may name, and the steps the connectors ship. */
|
|
33
36
|
scenarios?: URL;
|
|
37
|
+
/** The worker's `source`, when it was given one. */
|
|
38
|
+
source?: Source;
|
|
34
39
|
types: ReadonlyMap<string, Resource>;
|
|
35
40
|
steps: readonly Steps[];
|
|
36
41
|
/** Its `version`, when it has one, is recorded with each of its decisions. */
|
|
37
42
|
policy: Policy;
|
|
38
43
|
ledger: LedgerStore;
|
|
44
|
+
/** The stored secrets, for a live run's drivers (`CallContext.secrets`). */
|
|
45
|
+
secrets: Secrets;
|
|
39
46
|
/**
|
|
40
47
|
* The config's workflows by name, the built-in `drift` among them. A run takes its definition
|
|
41
48
|
* from here, by the name it was registered under: a process registers each name with DBOS once,
|
|
@@ -62,6 +69,8 @@ export interface Run {
|
|
|
62
69
|
sandbox?: string;
|
|
63
70
|
/** A sandbox run's own fakes' functions, by operation id, from `seedSandbox`; unset for a live run. */
|
|
64
71
|
fakeDrivers?: Map<string, DriverFn>;
|
|
72
|
+
/** A sandbox run's own secrets, in memory, from `seedSandbox`; unset for a live run. */
|
|
73
|
+
fakeSecrets?: Secrets;
|
|
65
74
|
approvals: ApprovalState[];
|
|
66
75
|
/** The next ledger `seq`. Advanced only outside steps, so a replay counts the same way. */
|
|
67
76
|
seq: number;
|
|
@@ -89,3 +98,9 @@ export interface WorkflowOutline {
|
|
|
89
98
|
lineStarts: readonly number[];
|
|
90
99
|
calls: readonly CallNode[];
|
|
91
100
|
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The run whose workflow body is running: what a built-in's own steps read of it (who started
|
|
104
|
+
* it, whether it is a sandbox run, the worker's source and policy), as no `ctx` member gives them.
|
|
105
|
+
*/
|
|
106
|
+
export const currentRun = new AsyncLocalStorage<Run>();
|
package/src/sandbox.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type { Fake, ResourceFake } from "./fake.ts";
|
|
|
6
6
|
import { entry, type LedgerRecord, write } from "./ledger.ts";
|
|
7
7
|
import type { DriverFn } from "./op.ts";
|
|
8
8
|
import type { Run } from "./run.ts";
|
|
9
|
+
import { memorySecrets } from "./secret.ts";
|
|
9
10
|
import { type Given, loadScenarios, type Scenario } from "./scenario.ts";
|
|
10
11
|
import { errorMessage } from "./shared.ts";
|
|
11
12
|
|
|
@@ -90,6 +91,8 @@ export function seedResource(
|
|
|
90
91
|
export async function seedSandbox(run: Run, name: string): Promise<void> {
|
|
91
92
|
const { state } = run;
|
|
92
93
|
let copies: Map<string, DriverFn> | undefined;
|
|
94
|
+
// The run's secrets, in memory as its fakes are: seeding and its calls share them.
|
|
95
|
+
const secrets = memorySecrets();
|
|
93
96
|
const { seeds } = await DBOS.runStep(
|
|
94
97
|
async () => {
|
|
95
98
|
// Read in the step, so a recovered run never reads a feature file edited or renamed since.
|
|
@@ -126,7 +129,7 @@ export async function seedSandbox(run: Run, name: string): Promise<void> {
|
|
|
126
129
|
for (const [i, given] of scenario.given.entries()) {
|
|
127
130
|
if ("fault" in given) continue;
|
|
128
131
|
if ("op" in given) {
|
|
129
|
-
const call = { idempotencyKey: `${run.id}:seed:${i}`, runId: run.id, opId: given.op, attempt: 1 };
|
|
132
|
+
const call = { idempotencyKey: `${run.id}:seed:${i}`, runId: run.id, opId: given.op, attempt: 1, secrets };
|
|
130
133
|
const output = await drivers.get(given.op)!(given.input, call);
|
|
131
134
|
seeded.push({ op: given.op, input: given.input, output });
|
|
132
135
|
continue;
|
|
@@ -153,5 +156,6 @@ export async function seedSandbox(run: Run, name: string): Promise<void> {
|
|
|
153
156
|
);
|
|
154
157
|
}
|
|
155
158
|
run.fakeDrivers = copies;
|
|
159
|
+
run.fakeSecrets = secrets;
|
|
156
160
|
await write(run, entry(run, { type: "scenario.seeded", scenario: name, seeds }));
|
|
157
161
|
}
|
package/src/scenario.ts
CHANGED
|
@@ -32,7 +32,16 @@ import { fill, isBug, seedFrom, shapeOf } from "./fill.ts";
|
|
|
32
32
|
import type { LedgerRecord } from "./ledger.ts";
|
|
33
33
|
import type { Driver, Op } from "./op.ts";
|
|
34
34
|
import { type OutlineNode, outlineWorkflow } from "./outline.ts";
|
|
35
|
-
import {
|
|
35
|
+
import { memorySecrets } from "./secret.ts";
|
|
36
|
+
import {
|
|
37
|
+
APPLY_WORKFLOW,
|
|
38
|
+
BUILT_IN_WORKFLOWS,
|
|
39
|
+
DRIFT_WORKFLOW,
|
|
40
|
+
errorMessage,
|
|
41
|
+
fitsOp,
|
|
42
|
+
flatten,
|
|
43
|
+
isEnded,
|
|
44
|
+
} from "./shared.ts";
|
|
36
45
|
import type { Call, RunView, Seed } from "./steps.ts";
|
|
37
46
|
|
|
38
47
|
/*
|
|
@@ -89,8 +98,9 @@ export interface Decision {
|
|
|
89
98
|
/**
|
|
90
99
|
* What the run should do: call an operation (with at least this input) or not, run the step
|
|
91
100
|
* `name`d in the workflow's outline or not, or a number of `times`, have the policy deny an
|
|
92
|
-
* operation (`denied`) or hold one for an approver (`held`), end a certain way,
|
|
93
|
-
* run, report the resource `declared` (`<file>#<export>`) clean, gone, or drifted in a
|
|
101
|
+
* operation (`denied`) or hold one for an approver (`held`), end a certain way, for a drift
|
|
102
|
+
* run, report the resource `declared` (`<file>#<export>`) clean, gone, absent, or drifted in a
|
|
103
|
+
* field, or for an apply run, create it, or leave it bound to an object (`bound`, its vendor's id).
|
|
94
104
|
* A step whose own code checks the run (a connector's or the company's, or an operation's own
|
|
95
105
|
* test's) is `custom`, with what it is about.
|
|
96
106
|
*/
|
|
@@ -101,8 +111,10 @@ export type Expectation =
|
|
|
101
111
|
| { step: string; op: string; decision: "denied" }
|
|
102
112
|
| { step: string; op: string; decision: "held"; approver: string }
|
|
103
113
|
| { step: string; outcome: "finished" | "failed"; code?: string }
|
|
104
|
-
| { step: string; declared: string; status: "clean" | "gone" }
|
|
114
|
+
| { step: string; declared: string; status: "clean" | "gone" | "absent" }
|
|
105
115
|
| { step: string; declared: string; status: "drifted"; field: string }
|
|
116
|
+
| { step: string; declared: string; created: true }
|
|
117
|
+
| { step: string; declared: string; bound: string }
|
|
106
118
|
| { step: string; custom: true; op?: string; called?: boolean };
|
|
107
119
|
|
|
108
120
|
export interface Scenario {
|
|
@@ -314,6 +326,8 @@ function builtFor(scope: Scope): Built {
|
|
|
314
326
|
return built;
|
|
315
327
|
}
|
|
316
328
|
|
|
329
|
+
/** The built-in workflows, whose input is never made up: each field is optional, and left out unless given. */
|
|
330
|
+
|
|
317
331
|
/** A `Then` step of the generic steps: its expectation, checked against the run's records. */
|
|
318
332
|
const expecting = (expect: CoreExpectation): Then => ({ expect, verify: (run) => detailOf(expect, run.records) });
|
|
319
333
|
|
|
@@ -376,13 +390,21 @@ function rulesOf(scope: Scope): { rules: Rule[]; parameterTypes: ParameterTypeSp
|
|
|
376
390
|
takes: TAKES_OBJECT,
|
|
377
391
|
read: ([name], pickle) => {
|
|
378
392
|
const { input } = workflowOf(name);
|
|
379
|
-
|
|
393
|
+
const given = fieldsFrom(pickle, input, inputOf(name));
|
|
394
|
+
return {
|
|
395
|
+
workflow: name as string,
|
|
396
|
+
input: BUILT_IN_WORKFLOWS.has(name as string) ? input.parse(given) : fill(input, given),
|
|
397
|
+
};
|
|
380
398
|
},
|
|
381
399
|
},
|
|
382
400
|
{
|
|
383
401
|
kind: "when",
|
|
384
402
|
source: "{workflow} runs",
|
|
385
|
-
|
|
403
|
+
// A built-in's input is optional throughout: what is not given is left out, not made up.
|
|
404
|
+
read: ([name]) => ({
|
|
405
|
+
workflow: name as string,
|
|
406
|
+
input: BUILT_IN_WORKFLOWS.has(name as string) ? {} : fill(workflowOf(name).input, {}),
|
|
407
|
+
}),
|
|
386
408
|
},
|
|
387
409
|
// An operation's own test: a call made to its driver, or its fake, directly.
|
|
388
410
|
{
|
|
@@ -469,11 +491,21 @@ function rulesOf(scope: Scope): { rules: Rule[]; parameterTypes: ParameterTypeSp
|
|
|
469
491
|
read: ([declared, field]) =>
|
|
470
492
|
expecting({ declared: declared as string, status: "drifted", field: field as string }),
|
|
471
493
|
},
|
|
472
|
-
...(["clean", "gone"] as const).map((status): Rule => ({
|
|
494
|
+
...(["clean", "gone", "absent"] as const).map((status): Rule => ({
|
|
473
495
|
kind: "then",
|
|
474
496
|
source: `{declared} is ${status}`,
|
|
475
497
|
read: ([declared]) => expecting({ declared: declared as string, status }),
|
|
476
498
|
})),
|
|
499
|
+
{
|
|
500
|
+
kind: "then",
|
|
501
|
+
source: "apply creates {declared}",
|
|
502
|
+
read: ([declared]) => expecting({ declared: declared as string, created: true }),
|
|
503
|
+
},
|
|
504
|
+
{
|
|
505
|
+
kind: "then",
|
|
506
|
+
source: "{declared} is bound to {string}",
|
|
507
|
+
read: ([declared, id]) => expecting({ declared: declared as string, bound: id as string }),
|
|
508
|
+
},
|
|
477
509
|
{ kind: "then", source: "the run succeeds", read: () => expecting({ outcome: "finished" }) },
|
|
478
510
|
{ kind: "then", source: "the run fails", read: () => expecting({ outcome: "failed" }) },
|
|
479
511
|
{
|
|
@@ -879,6 +911,8 @@ function testWorld(built: Built, { driver, fake }: FeatureTarget) {
|
|
|
879
911
|
const values = new Map<string, unknown>();
|
|
880
912
|
const records: LedgerRecord[] = [];
|
|
881
913
|
let runId = "";
|
|
914
|
+
// The test's own secrets, in memory: a driver resolves `secret()` markers against them.
|
|
915
|
+
const secrets = memorySecrets();
|
|
882
916
|
/** A record of the test, as a run's ledger would hold it. */
|
|
883
917
|
const record = (body: Record<string, unknown>) => {
|
|
884
918
|
const seq = records.length;
|
|
@@ -892,7 +926,7 @@ function testWorld(built: Built, { driver, fake }: FeatureTarget) {
|
|
|
892
926
|
const input = op.input.parse(resolve(given, values));
|
|
893
927
|
const fn = driver.ops[`${op.resource}.${op.name}`];
|
|
894
928
|
if (!fn) throw new Error(`the driver under test does not implement ${id}`);
|
|
895
|
-
const context = { idempotencyKey: `${runId}:${records.length}`, runId, opId: id, attempt: 1 };
|
|
929
|
+
const context = { idempotencyKey: `${runId}:${records.length}`, runId, opId: id, attempt: 1, secrets };
|
|
896
930
|
return { op, input, output: () => fn(input, context) };
|
|
897
931
|
};
|
|
898
932
|
return {
|
|
@@ -1092,11 +1126,14 @@ async function compile(scope: Scope, dir: string, files: string[]): Promise<Load
|
|
|
1092
1126
|
`${where}: scenario "${name}" has no When: say which workflow runs, such as "When <workflow> runs with"`,
|
|
1093
1127
|
);
|
|
1094
1128
|
}
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1129
|
+
// A step about a declared resource reads the report of the built-in it is about.
|
|
1130
|
+
for (const e of spec.expect) {
|
|
1131
|
+
const reads: string = "declared" in e ? ("status" in e ? DRIFT_WORKFLOW : APPLY_WORKFLOW) : workflow;
|
|
1132
|
+
if (reads !== workflow) {
|
|
1133
|
+
throw new Error(
|
|
1134
|
+
`${where}: "${e.step}" reads ${reads === DRIFT_WORKFLOW ? "a drift" : "an apply"} report, so the scenario must run ${reads}, not ${workflow}`,
|
|
1135
|
+
);
|
|
1136
|
+
}
|
|
1100
1137
|
}
|
|
1101
1138
|
inFile.push({
|
|
1102
1139
|
...spec,
|
package/src/secret.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { DriverError } from "./op.ts";
|
|
3
|
+
|
|
4
|
+
// Secrets are named, never written: `secret("NAME")` in a data file is a marker for a value in
|
|
5
|
+
// the company's secrets store (the credentials table), allowed only at a write-only field. The
|
|
6
|
+
// marker is what declarations, checkpoints and the ledger hold; a driver resolves it
|
|
7
|
+
// (`resolveSecrets`) just before the vendor call. The `./resources` entry, which data files import
|
|
8
|
+
// `secret` from (`sanoma/resources` re-exports it).
|
|
9
|
+
|
|
10
|
+
/** What `secret()` makes: a plain object, so it reads the same as JSON and across copies of this package. */
|
|
11
|
+
export interface Secret {
|
|
12
|
+
readonly $secret: string;
|
|
13
|
+
/** When none is stored, apply makes the value (`generateSecret`) and stores it before the vendor call. */
|
|
14
|
+
readonly generate?: true;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** A secret's name: an environment variable's, upper case. */
|
|
18
|
+
export const SECRET_NAME = /^[A-Z][A-Z0-9_]*$/;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* True for one of sanoma's own secrets (`SANOMA_…`, its key and database URLs among them): only a
|
|
22
|
+
* host, which runs the company, is given them, at its `secrets` input (`HostSpec`); never a vendor.
|
|
23
|
+
*/
|
|
24
|
+
export const isSanomaSecret = (name: string) => name.startsWith("SANOMA_");
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Names a stored secret for a write-only field of a data file: `secret("SUPABASE_DB_PASSWORD",
|
|
28
|
+
* { generate: true })`. With `generate`, apply makes the value when none is stored.
|
|
29
|
+
*/
|
|
30
|
+
export function secret(name: string, options?: { generate: true }): Secret {
|
|
31
|
+
if (!SECRET_NAME.test(name)) {
|
|
32
|
+
throw new Error(`secret("${name}"): a secret's name is an environment variable's, upper case: A-Z, 0-9 and _`);
|
|
33
|
+
}
|
|
34
|
+
return Object.freeze(options?.generate ? { $secret: name, generate: true as const } : { $secret: name });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** True for a secret marker, `{ $secret: "NAME" }`. */
|
|
38
|
+
export const isSecret = (value: unknown): value is Secret =>
|
|
39
|
+
typeof value === "object" && value !== null && typeof (value as { $secret?: unknown }).$secret === "string";
|
|
40
|
+
|
|
41
|
+
/** The secrets store, as a driver sees it (`CallContext.secrets`). */
|
|
42
|
+
export interface Secrets {
|
|
43
|
+
/** The stored value, or undefined when there is none. */
|
|
44
|
+
get(name: string): Promise<string | undefined>;
|
|
45
|
+
/** The stored value; when there is none, `make()`'s, stored first. */
|
|
46
|
+
ensure(name: string, make: () => string): Promise<string>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A value for a secret with `generate`: 32 random bytes, base64url. */
|
|
50
|
+
export const generateSecret = (): string => randomBytes(32).toString("base64url");
|
|
51
|
+
|
|
52
|
+
/** `T` with each secret marker's place holding its value: what `resolveSecrets` returns. */
|
|
53
|
+
export type Resolved<T> = T extends Secret
|
|
54
|
+
? string
|
|
55
|
+
: T extends readonly (infer I)[]
|
|
56
|
+
? Resolved<I>[]
|
|
57
|
+
: T extends object
|
|
58
|
+
? { [K in keyof T]: Resolved<T[K]> }
|
|
59
|
+
: T;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* `value` with each secret marker in it replaced by the stored value: generated and stored first
|
|
63
|
+
* for one with `generate` and none stored. A marker naming nothing stored fails the call, for good.
|
|
64
|
+
*/
|
|
65
|
+
export async function resolveSecrets<T>(value: T, secrets: Secrets): Promise<Resolved<T>> {
|
|
66
|
+
const resolve = async (v: unknown): Promise<unknown> => {
|
|
67
|
+
if (isSecret(v)) {
|
|
68
|
+
const stored = v.generate ? await secrets.ensure(v.$secret, generateSecret) : await secrets.get(v.$secret);
|
|
69
|
+
if (stored === undefined) {
|
|
70
|
+
throw new DriverError(`The secret ${v.$secret} is not set: store it before applying`, { retryable: false });
|
|
71
|
+
}
|
|
72
|
+
return stored;
|
|
73
|
+
}
|
|
74
|
+
if (Array.isArray(v)) return Promise.all(v.map(resolve));
|
|
75
|
+
if (typeof v !== "object" || v === null) return v;
|
|
76
|
+
const entries = await Promise.all(Object.entries(v).map(async ([k, item]) => [k, await resolve(item)] as const));
|
|
77
|
+
return Object.fromEntries(entries);
|
|
78
|
+
};
|
|
79
|
+
return (await resolve(value)) as Resolved<T>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** A secrets store in memory, for fakes, sandbox runs and tests. */
|
|
83
|
+
export function memorySecrets(initial: Record<string, string> = {}): Secrets {
|
|
84
|
+
const values = new Map(Object.entries(initial));
|
|
85
|
+
return {
|
|
86
|
+
get: async (name) => values.get(name),
|
|
87
|
+
async ensure(name, make) {
|
|
88
|
+
if (!values.has(name)) values.set(name, make());
|
|
89
|
+
return values.get(name)!;
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
package/src/shared.ts
CHANGED
|
@@ -15,6 +15,7 @@ export type {
|
|
|
15
15
|
} from "./resource.ts";
|
|
16
16
|
export type { DeclaredResource, ResourceProblem } from "./resources.ts";
|
|
17
17
|
export type { DriftField, DriftReport, DriftResult, DriftStatus } from "./drift.ts";
|
|
18
|
+
export type { ApplyPlan, ApplyReport, ApplyResult, PlanAnswer, PlanStep } from "./apply.ts";
|
|
18
19
|
export type { CredentialStatus } from "./env.ts";
|
|
19
20
|
|
|
20
21
|
// What a UI in the browser may use from the runtime, besides types: `@sanoma-ai/workflows/shared`.
|
|
@@ -30,6 +31,15 @@ export const DEFAULT_SLEEP_NAME = "Sleep";
|
|
|
30
31
|
/** The built-in drift workflow's name, which a config's own workflows may not take; a UI and the scenarios name its runs by it. */
|
|
31
32
|
export const DRIFT_WORKFLOW = "drift";
|
|
32
33
|
|
|
34
|
+
/**
|
|
35
|
+
* The built-in apply workflow's name: the only workflow that may call a writable resource type's
|
|
36
|
+
* change operations (`create`, `update`, `delete`).
|
|
37
|
+
*/
|
|
38
|
+
export const APPLY_WORKFLOW = "apply";
|
|
39
|
+
|
|
40
|
+
/** The workflows the runtime adds itself, which a config's own may not be named: drift and apply. */
|
|
41
|
+
export const BUILT_IN_WORKFLOWS: ReadonlySet<string> = new Set([DRIFT_WORKFLOW, APPLY_WORKFLOW]);
|
|
42
|
+
|
|
33
43
|
/**
|
|
34
44
|
* Every run status, in the order a run may pass through them: waiting on the queue, running,
|
|
35
45
|
* waiting for an approval, or ended. `waiting` is `running` with an approval pending.
|
package/src/source.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { defineConnector, defineDriver, DriverError } from "./op.ts";
|
|
5
|
+
|
|
6
|
+
// Where a company's files live, and how a change to them is recorded: a git repository on this
|
|
7
|
+
// machine, or one a host cloned. The runtime's writers (the app's Adopt, apply's lockfile) change
|
|
8
|
+
// files under `root`, then commit them through it. The launcher makes one; a config without one
|
|
9
|
+
// writes the files and commits nothing.
|
|
10
|
+
|
|
11
|
+
/** A company's repository, as the runtime writes to it. */
|
|
12
|
+
export interface Source {
|
|
13
|
+
/** The repository's root directory. */
|
|
14
|
+
root: string;
|
|
15
|
+
/**
|
|
16
|
+
* Records the files at `paths`, relative to `root`, as one change by `author` (who made it, as
|
|
17
|
+
* the app names them: a login or a typed name). Its revision; undefined when nothing changed.
|
|
18
|
+
*/
|
|
19
|
+
commit(change: {
|
|
20
|
+
paths: string[];
|
|
21
|
+
message: string;
|
|
22
|
+
author: string;
|
|
23
|
+
}): { revision: string } | undefined | Promise<{ revision: string } | undefined>;
|
|
24
|
+
/** The revision `ref` names, the checked-out one by default. */
|
|
25
|
+
head(ref?: string): string | Promise<string>;
|
|
26
|
+
/** Pushes `ref` to the repository at `url`, signed in with `token`. */
|
|
27
|
+
push?(target: { url: string; token: string; ref: string }): void | Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The built-in operation `sanoma.source.resolve`: the commit `ref` names in the repository at
|
|
32
|
+
* `url` now, by `git ls-remote` (a branch first, then a tag, peeled; a full commit id is itself).
|
|
33
|
+
* Apply calls it to fill a host's revision before it plans, and drift to see a merge. `resolveConfig`
|
|
34
|
+
* registers it when a connector declares a host type.
|
|
35
|
+
*/
|
|
36
|
+
export const sourceOps = defineConnector("sanoma", {
|
|
37
|
+
source: {
|
|
38
|
+
resolve: {
|
|
39
|
+
effect: "read",
|
|
40
|
+
idempotent: true,
|
|
41
|
+
description: "Resolve a branch or tag of a git repository to the commit it names now",
|
|
42
|
+
input: z.object({ url: z.string().min(1), ref: z.string().min(1) }),
|
|
43
|
+
output: z.object({ revision: z.string() }),
|
|
44
|
+
target: ({ url, ref }: { url: string; ref: string }) => `${url}#${ref}`,
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
export const SOURCE_RESOLVE = "sanoma.source.resolve" satisfies typeof sourceOps.source.resolve.id;
|
|
50
|
+
|
|
51
|
+
/** The driver of `sanoma.source.resolve`: git on this machine, with its own credential helpers. */
|
|
52
|
+
export const sourceDriver = defineDriver(sourceOps, {
|
|
53
|
+
source: { resolve: async ({ url, ref }) => ({ revision: await resolveRevision(url, ref) }) },
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const COMMIT = /^[0-9a-f]{40}([0-9a-f]{24})?$/;
|
|
57
|
+
|
|
58
|
+
/** The commit `ref` (a branch, a tag, `HEAD`, a full ref or a commit id) names in the repository at `url`. */
|
|
59
|
+
export async function resolveRevision(url: string, ref: string): Promise<string> {
|
|
60
|
+
if (COMMIT.test(ref)) return ref;
|
|
61
|
+
const { stdout } = await promisify(execFile)("git", ["ls-remote", "--", url, ref, `${ref}^{}`], {
|
|
62
|
+
// Never a prompt for credentials: a repository that needs them and has none fails.
|
|
63
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
|
|
64
|
+
timeout: 30_000,
|
|
65
|
+
}).catch((err: { stderr?: string; message: string }) => {
|
|
66
|
+
throw new DriverError(`git ls-remote ${url} failed: ${err.stderr?.trim() || err.message}`, {
|
|
67
|
+
retryable: true,
|
|
68
|
+
cause: err,
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
const refs = new Map(
|
|
72
|
+
stdout.split("\n").flatMap((line) => {
|
|
73
|
+
const [sha, name] = line.split("\t");
|
|
74
|
+
return sha && name ? [[name, sha] as const] : [];
|
|
75
|
+
}),
|
|
76
|
+
);
|
|
77
|
+
for (const name of [`refs/heads/${ref}`, `refs/tags/${ref}^{}`, `refs/tags/${ref}`, ref]) {
|
|
78
|
+
const sha = refs.get(name);
|
|
79
|
+
if (sha) return sha;
|
|
80
|
+
}
|
|
81
|
+
throw new DriverError(`${url} has no branch or tag ${ref}`, { retryable: false, vendorCode: "no_such_ref" });
|
|
82
|
+
}
|
package/src/version.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { jsonSchemaOf, opIdsOf, type WorkflowDefinition } from "./define.ts";
|
|
|
6
6
|
import { upFrom } from "./paths.ts";
|
|
7
7
|
|
|
8
8
|
/** This package's version. A unit test keeps it equal to package.json. */
|
|
9
|
-
export const RUNTIME_VERSION = "0.
|
|
9
|
+
export const RUNTIME_VERSION = "0.2.0";
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* The sequence of DBOS calls the runtime makes around each `ctx` call: policy step, approval
|
package/src/worker.ts
CHANGED
|
@@ -2,8 +2,8 @@ import { DBOS } from "@dbos-inc/dbos-sdk";
|
|
|
2
2
|
import { buildCtx, isInfrastructureError } from "./call.ts";
|
|
3
3
|
import { dbosStatusesOf } from "./client.ts";
|
|
4
4
|
import { type ResolvedConfig, resolveConfig, type SanomaConfig } from "./config.ts";
|
|
5
|
-
import { watchCredentials } from "./credentials.ts";
|
|
6
|
-
import { DBOS_DATABASE_OPTIONS, prepareDatabase } from "./db.ts";
|
|
5
|
+
import { storedSecrets, watchCredentials } from "./credentials.ts";
|
|
6
|
+
import { DBOS_DATABASE_OPTIONS, lazyPool, prepareDatabase } from "./db.ts";
|
|
7
7
|
import type { WorkflowDefinition } from "./define.ts";
|
|
8
8
|
import { credentialsOf, unconfigured, unconfiguredSummary } from "./env.ts";
|
|
9
9
|
import { errorInfo, parseOrThrow } from "./errors.ts";
|
|
@@ -11,7 +11,8 @@ import { entry, ownedLedger, skipped, write, writeFailure } from "./ledger.ts";
|
|
|
11
11
|
import { dbosLogger, quietIfSandbox, setLogLevel, warn } from "./log.ts";
|
|
12
12
|
import { lineStartsOf, readSource } from "./ast.ts";
|
|
13
13
|
import { callsOf, outlineWithSource } from "./outline.ts";
|
|
14
|
-
import type
|
|
14
|
+
import { currentRun, type Run, type RunArgs, type WorkerState, type WorkflowOutline } from "./run.ts";
|
|
15
|
+
import type { Source } from "./source.ts";
|
|
15
16
|
import { errorMessage } from "./shared.ts";
|
|
16
17
|
|
|
17
18
|
export interface Worker {
|
|
@@ -45,6 +46,8 @@ export interface WorkerOptions {
|
|
|
45
46
|
* restarting during a rolling deploy would otherwise take the new workers' runs.
|
|
46
47
|
*/
|
|
47
48
|
promote?: boolean;
|
|
49
|
+
/** The company's repository, which apply commits the lockfile to; the launcher passes its checkout. */
|
|
50
|
+
source?: Source;
|
|
48
51
|
}
|
|
49
52
|
|
|
50
53
|
/**
|
|
@@ -60,16 +63,19 @@ export async function startWorker(config: SanomaConfig, options: WorkerOptions =
|
|
|
60
63
|
setLogLevel(options.logLevel);
|
|
61
64
|
// The config's own, or a Postgres store made for this worker, which `stop` then closes.
|
|
62
65
|
const ledger = ownedLedger(resolved);
|
|
66
|
+
const secrets = lazyPool(resolved.databaseUrl, "secrets");
|
|
63
67
|
const state: WorkerState = {
|
|
64
68
|
app: resolved.appName,
|
|
65
69
|
ops: resolved.ops,
|
|
66
70
|
drivers: resolved.drivers,
|
|
67
71
|
fakes: resolved.fakes,
|
|
68
72
|
...(resolved.scenarios && { scenarios: resolved.scenarios }),
|
|
73
|
+
...(options.source && { source: options.source }),
|
|
69
74
|
types: resolved.types,
|
|
70
75
|
steps: resolved.steps,
|
|
71
76
|
policy: resolved.policy,
|
|
72
77
|
ledger: ledger.store,
|
|
78
|
+
secrets: storedSecrets(() => secrets.db(), "sanoma"),
|
|
73
79
|
workflows: resolved.workflows,
|
|
74
80
|
outlines: new Map([...resolved.workflows].map(([name, wf]) => [name, outlineOf(wf)])),
|
|
75
81
|
stopped: false,
|
|
@@ -83,7 +89,12 @@ export async function startWorker(config: SanomaConfig, options: WorkerOptions =
|
|
|
83
89
|
state.stopped = true;
|
|
84
90
|
// Each is closed whatever the one before did; the first failure is what stop throws.
|
|
85
91
|
let failed: { error: unknown } | undefined;
|
|
86
|
-
for (const close of [
|
|
92
|
+
for (const close of [
|
|
93
|
+
() => DBOS.shutdown(),
|
|
94
|
+
() => credentials.close(),
|
|
95
|
+
() => secrets.close(),
|
|
96
|
+
() => ledger.close(),
|
|
97
|
+
]) {
|
|
87
98
|
await close().catch((error: unknown) => (failed ??= { error }));
|
|
88
99
|
}
|
|
89
100
|
if (failed) throw failed.error;
|
|
@@ -237,7 +248,7 @@ async function runWorkflow(name: string, { input, startedBy, sandbox }: RunArgs)
|
|
|
237
248
|
const { seedSandbox } = await import("./sandbox.ts");
|
|
238
249
|
await seedSandbox(run, sandbox);
|
|
239
250
|
}
|
|
240
|
-
output = await wf.run(buildCtx(wf, run), parsed);
|
|
251
|
+
output = await currentRun.run(run, () => wf.run(buildCtx(wf, run), parsed));
|
|
241
252
|
} finally {
|
|
242
253
|
// Before the outcome is written: a call still queued must find the run ended.
|
|
243
254
|
run.ended = true;
|