@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
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from "node:util";
|
|
2
|
+
import type { Approver } from "./define.ts";
|
|
3
|
+
import type { ApplyReport } from "./apply.ts";
|
|
4
|
+
import type { DriftReport, DriftResult } from "./drift.ts";
|
|
5
|
+
import type { LedgerRecord } from "./ledger.ts";
|
|
6
|
+
import type { RecordedDecision } from "./policy.ts";
|
|
7
|
+
import type { Expectation } from "./scenario.ts";
|
|
8
|
+
import { approverLabel } from "./shared.ts";
|
|
9
|
+
|
|
10
|
+
/*
|
|
11
|
+
* What the generic `Then` steps check against a run's ledger records: pure, and internal to
|
|
12
|
+
* `scenario.ts`, whose steps call it (its tests do too).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** A call in a run: an `op.called` record. */
|
|
16
|
+
export type CallRecord = Extract<LedgerRecord, { type: "op.called" }>;
|
|
17
|
+
type Request = Extract<LedgerRecord, { type: "approval.requested" }>;
|
|
18
|
+
|
|
19
|
+
export const isCall = (r: LedgerRecord): r is CallRecord => r.type === "op.called";
|
|
20
|
+
const isRequest = (r: LedgerRecord): r is Request => r.type === "approval.requested";
|
|
21
|
+
|
|
22
|
+
/** True when `actual` has every key `expected` has, with an equal value, at every depth. */
|
|
23
|
+
export function contains(actual: unknown, expected: unknown): boolean {
|
|
24
|
+
if (typeof expected !== "object" || expected === null || Array.isArray(expected)) {
|
|
25
|
+
return isDeepStrictEqual(actual, expected);
|
|
26
|
+
}
|
|
27
|
+
if (typeof actual !== "object" || actual === null || Array.isArray(actual)) return false;
|
|
28
|
+
return Object.entries(expected).every(([k, v]) => contains((actual as Record<string, unknown>)[k], v));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The records of the step named `name` that are not calls: its sleeps, and the approvals the
|
|
33
|
+
* workflow asked for by that title. A policy's hold carries the held call's name, and is that
|
|
34
|
+
* call's, not a run of its own.
|
|
35
|
+
*/
|
|
36
|
+
const waitsOf = (records: readonly LedgerRecord[], name: string) =>
|
|
37
|
+
records.filter(
|
|
38
|
+
(r) =>
|
|
39
|
+
(r.type === "sleep.started" || (r.type === "approval.requested" && r.requestedBy === "workflow")) &&
|
|
40
|
+
r.name === name,
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Why a call expected (`what`) is missing: that, the calls made among `tries` (with their
|
|
45
|
+
* inputs), and the ones attempted, failed (denied, rejected, or failed at the vendor for good).
|
|
46
|
+
*/
|
|
47
|
+
function missing(what: string, tries: CallRecord[]): string {
|
|
48
|
+
const made = tries.filter((r) => !r.error);
|
|
49
|
+
const tried = tries.flatMap(({ error }) => (error ? [`${error.code ?? error.name}: ${error.message}`] : []));
|
|
50
|
+
return [
|
|
51
|
+
what,
|
|
52
|
+
...(made.length ? [`its calls had ${inputs(made)}`] : []),
|
|
53
|
+
...(tried.length ? [`attempted: ${tried.join(", ")}`] : []),
|
|
54
|
+
].join("; ");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** `once`, `twice`, `3 times`. */
|
|
58
|
+
const times = (n: number) => (n === 1 ? "once" : n === 2 ? "twice" : `${n} times`);
|
|
59
|
+
|
|
60
|
+
/** Why an expectation about a named step does not hold against a run's records, or undefined when it does. */
|
|
61
|
+
function namedDetail(e: Extract<CoreExpectation, { name: string }>, records: readonly LedgerRecord[]) {
|
|
62
|
+
const tries = records.filter(isCall).filter((r) => r.name === e.name);
|
|
63
|
+
const calls = tries.filter((r) => !r.error);
|
|
64
|
+
const runs = calls.length + waitsOf(records, e.name).length;
|
|
65
|
+
const step = `step "${e.name}"`;
|
|
66
|
+
if ("times" in e) return runs === e.times ? undefined : `${step} ran ${times(runs)}, not ${times(e.times)}`;
|
|
67
|
+
const matched = e.input === undefined ? runs : calls.filter((r) => contains(r.input, e.input)).length;
|
|
68
|
+
if (matched > 0 === e.called) return undefined;
|
|
69
|
+
if (!e.called) return calls.length ? `${step} was called with ${inputs(calls)}` : `${step} ran ${times(runs)}`;
|
|
70
|
+
return missing(
|
|
71
|
+
e.input === undefined ? `${step} was not called` : `${step} was not called with ${JSON.stringify(e.input)}`,
|
|
72
|
+
tries,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The inputs of the calls, as JSON, for a detail. */
|
|
77
|
+
const inputs = (list: CallRecord[]) => list.map((r) => JSON.stringify(r.input)).join(", ");
|
|
78
|
+
|
|
79
|
+
/** A policy's decision as words: `allowed`, `denied`, `held for finance`. */
|
|
80
|
+
const words = (d: RecordedDecision) =>
|
|
81
|
+
d.kind === "allow" ? "allowed" : d.kind === "deny" ? "denied" : `held for ${approverLabel(d.approver)}`;
|
|
82
|
+
|
|
83
|
+
/** What the policy decided for the calls, each decision once: `allowed and held for finance`. */
|
|
84
|
+
const decisions = (calls: CallRecord[]) => [...new Set(calls.map((r) => words(r.decision)))].join(" and ");
|
|
85
|
+
|
|
86
|
+
/** True when `who` names the approver: a person by id, or a group by its name. */
|
|
87
|
+
const isApprover = (approver: Approver, who: string) =>
|
|
88
|
+
typeof approver === "string" ? approver === who : approver.group === who;
|
|
89
|
+
|
|
90
|
+
/** How a drift report found a resource, as words: `clean`, `gone`, `drifted in has_wiki, topics`. */
|
|
91
|
+
const found = (r: DriftResult) =>
|
|
92
|
+
r.status === "drifted" ? `drifted in ${r.fields.map((f) => f.path).join(", ")}` : r.status;
|
|
93
|
+
|
|
94
|
+
/** An expectation of the generic steps, without its step's text. */
|
|
95
|
+
export type CoreExpectation = Unstepped<Exclude<Expectation, { custom: true }>>;
|
|
96
|
+
type Unstepped<T> = T extends unknown ? Omit<T, "step"> : never;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Why a drift expectation fails against the report a finished drift run returned as its
|
|
100
|
+
* output, or undefined when the report found the resource as expected.
|
|
101
|
+
*/
|
|
102
|
+
function driftDetail(
|
|
103
|
+
e: Extract<CoreExpectation, { status: string }>,
|
|
104
|
+
end: Extract<LedgerRecord, { type: "run.finished" }>,
|
|
105
|
+
): string | undefined {
|
|
106
|
+
const report = end.output as Partial<DriftReport> | null | undefined;
|
|
107
|
+
if (!Array.isArray(report?.resources)) return "the run's output is not a drift report";
|
|
108
|
+
const resource = report.resources.find((r) => r.id === e.declared);
|
|
109
|
+
if (!resource) {
|
|
110
|
+
return `no resource ${e.declared} in the report; it has ${report.resources.map((r) => r.id).join(", ") || "none"}`;
|
|
111
|
+
}
|
|
112
|
+
if (resource.status === "error") return `${e.declared} could not be read: ${resource.error}`;
|
|
113
|
+
const ok =
|
|
114
|
+
resource.status === e.status && (e.status !== "drifted" || resource.fields.some((f) => f.path === e.field));
|
|
115
|
+
if (ok) return undefined;
|
|
116
|
+
return `${e.declared} is ${found(resource)}, not ${e.status === "drifted" ? `drifted in ${e.field}` : e.status}`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Why an apply expectation fails against the report a finished apply run returned as its output:
|
|
121
|
+
* the resource `created` (a step that created it, done), or `bound` to an object in the lockfile it ended with.
|
|
122
|
+
*/
|
|
123
|
+
function applyDetail(
|
|
124
|
+
e: Extract<CoreExpectation, { created: true } | { bound: string }>,
|
|
125
|
+
end: Extract<LedgerRecord, { type: "run.finished" }>,
|
|
126
|
+
): string | undefined {
|
|
127
|
+
const report = end.output as Partial<ApplyReport> | null | undefined;
|
|
128
|
+
if (!Array.isArray(report?.results) || !report.lockfile) return "the run's output is not an apply report";
|
|
129
|
+
if ("bound" in e) {
|
|
130
|
+
const binding = report.lockfile.bindings[e.declared];
|
|
131
|
+
if (binding?.id === e.bound) return undefined;
|
|
132
|
+
return binding ? `${e.declared} is bound to ${binding.id}, not ${e.bound}` : `${e.declared} is not bound`;
|
|
133
|
+
}
|
|
134
|
+
const result = report.results.find((r) => r.id === e.declared);
|
|
135
|
+
// A gone object recreated is bound again; one forgotten is not.
|
|
136
|
+
const created =
|
|
137
|
+
result?.status === "done" && (result.action === "create" || (result.action === "gone" && !!result.binding));
|
|
138
|
+
if (created) return undefined;
|
|
139
|
+
if (!result) return `apply did nothing with ${e.declared}`;
|
|
140
|
+
return `apply's ${result.action} of ${e.declared} ${result.status === "failed" ? `failed: ${result.error}` : `was ${result.status}`}`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Why an expectation of the generic steps does not hold against a run's ledger records, or
|
|
145
|
+
* undefined when it does. A call is an `op.called` record without an `error`; one with an error
|
|
146
|
+
* (a policy denial, a rejected approval, a vendor's final failure) was attempted, not made. An
|
|
147
|
+
* operation `was denied` when the policy's decision on some call to it is `deny`, and `was held
|
|
148
|
+
* for` an approver when an approval for that approver holds it: one the policy asked for on its
|
|
149
|
+
* call, or one that `covers` it. A drift or apply expectation reads the report a finished run
|
|
150
|
+
* of it returned. A step named in the workflow's outline is read by the `name` its records carry
|
|
151
|
+
* (its calls made, and `waitsOf`); a record written before steps had names has none.
|
|
152
|
+
*/
|
|
153
|
+
export function detailOf(e: CoreExpectation, records: readonly LedgerRecord[]): string | undefined {
|
|
154
|
+
if ("name" in e) return namedDetail(e, records);
|
|
155
|
+
const end = records.find((r) => r.type === "run.finished" || r.type === "run.failed");
|
|
156
|
+
const calls = records.filter(isCall);
|
|
157
|
+
const callsTo = (op: string) => calls.filter((r) => r.op === op);
|
|
158
|
+
if ("decision" in e) {
|
|
159
|
+
const tries = callsTo(e.op);
|
|
160
|
+
if (e.decision === "denied") {
|
|
161
|
+
if (tries.some((r) => r.decision.kind === "deny")) return undefined;
|
|
162
|
+
return tries.length ? `${e.op} was ${decisions(tries)}, not denied` : `no call to ${e.op}`;
|
|
163
|
+
}
|
|
164
|
+
const holds = records.filter(isRequest).filter((r) => r.op === e.op || r.covers.includes(e.op));
|
|
165
|
+
if (holds.some((r) => isApprover(r.approver, e.approver))) return undefined;
|
|
166
|
+
return holds.length
|
|
167
|
+
? `${e.op} was held for ${[...new Set(holds.map((r) => approverLabel(r.approver)))].join(" and ")}, not ${e.approver}`
|
|
168
|
+
: tries.length
|
|
169
|
+
? `${e.op} was ${decisions(tries)}, not held`
|
|
170
|
+
: `no call to ${e.op}`;
|
|
171
|
+
}
|
|
172
|
+
if ("called" in e) {
|
|
173
|
+
const tries = callsTo(e.op);
|
|
174
|
+
const made = tries.filter((r) => !r.error);
|
|
175
|
+
const matching = made.filter((r) => e.input === undefined || contains(r.input, e.input));
|
|
176
|
+
if (matching.length > 0 === e.called) return undefined;
|
|
177
|
+
if (!e.called) return `${e.op} was called with ${inputs(matching)}`;
|
|
178
|
+
return missing(`no call to ${e.input === undefined ? e.op : `${e.op} with ${JSON.stringify(e.input)}`}`, tries);
|
|
179
|
+
}
|
|
180
|
+
// The rest read how the run ended.
|
|
181
|
+
if (!end) return "run not ended";
|
|
182
|
+
if ("outcome" in e && e.outcome === "failed") {
|
|
183
|
+
if (end.type !== "run.failed") return "the run finished";
|
|
184
|
+
if (e.code === undefined || end.error.code === e.code) return undefined;
|
|
185
|
+
return `the run failed with ${end.error.code ?? "no code"}: ${end.error.message}`;
|
|
186
|
+
}
|
|
187
|
+
// A finished run: what `the run succeeds` expects, and what a drift expectation reads its report from.
|
|
188
|
+
if (end.type === "run.failed") return `the run failed: ${end.error.message}`;
|
|
189
|
+
if (!("declared" in e)) return undefined;
|
|
190
|
+
return "status" in e ? driftDetail(e, end) : applyDetail(e, end);
|
|
191
|
+
}
|
package/src/fake.ts
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type Connector,
|
|
3
|
+
defineDriver,
|
|
4
|
+
type Driver,
|
|
5
|
+
type DriverFn,
|
|
6
|
+
type DriverImpl,
|
|
7
|
+
DriverError,
|
|
8
|
+
type OpIdOf,
|
|
9
|
+
type Specs,
|
|
10
|
+
} from "./op.ts";
|
|
11
|
+
|
|
12
|
+
/*
|
|
13
|
+
* Fake vendors for tests, at `@sanoma-ai/workflows/fake`. Kept off the main entry so the
|
|
14
|
+
* runtime bundle carries no test tooling, and so the lint can refuse it in workflow files.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** One call a fake received. */
|
|
18
|
+
export interface FakeCall {
|
|
19
|
+
op: string;
|
|
20
|
+
input: unknown;
|
|
21
|
+
/** When the call arrived, as an ISO timestamp. */
|
|
22
|
+
at: string;
|
|
23
|
+
idempotencyKey: string;
|
|
24
|
+
attempt: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface FakeOptions {
|
|
28
|
+
/** Log calls into this array instead of the fake's own, so several fakes share one ordered log. */
|
|
29
|
+
calls?: FakeCall[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** An in-memory vendor for tests, with faults a test can inject into its next calls. */
|
|
33
|
+
export interface Fake<T, Id extends string = string> {
|
|
34
|
+
driver: Driver;
|
|
35
|
+
/** The vendor's state. */
|
|
36
|
+
readonly state: T;
|
|
37
|
+
/** Empties the state, the remembered replies and the pending faults, and drops this fake's calls from the log. */
|
|
38
|
+
reset(): void;
|
|
39
|
+
/** Changes the vendor's state as someone at the vendor would, so the next call sees it. */
|
|
40
|
+
update(change: (state: T) => void): void;
|
|
41
|
+
/** Every call received, in order, including failed ones and repeats of an idempotency key. */
|
|
42
|
+
calls: FakeCall[];
|
|
43
|
+
/** The next call to `opId` throws `err` (default: a retryable `DriverError`) and changes nothing. */
|
|
44
|
+
failNext(opId: Id, err?: unknown): void;
|
|
45
|
+
/** The next call to `opId` takes effect, then throws once, as if its reply was lost. */
|
|
46
|
+
loseReply(opId: Id): void;
|
|
47
|
+
/** The next call to `opId` throws a retryable `DriverError` with status 429 and changes nothing. */
|
|
48
|
+
rateLimit(opId: Id): void;
|
|
49
|
+
/** The next call to `opId` waits, before it takes effect, until the returned function is called. */
|
|
50
|
+
hold(opId: Id): () => void;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A fake that holds its vendor's objects by resource type (`repository`) and import id, as
|
|
55
|
+
* `@sanoma-ai/bridge`'s `tfFake` does, with fields in the resource's shape: what a scenario's
|
|
56
|
+
* resource steps seed on a sandbox run's own fake. `put` sets the fields over the object there, or
|
|
57
|
+
* makes one; `remove` deletes it, and is nothing when there is none.
|
|
58
|
+
*/
|
|
59
|
+
export interface ResourceFake {
|
|
60
|
+
override(type: string, id: string, fields: Record<string, unknown>): void;
|
|
61
|
+
put(type: string, id: string, fields: Record<string, unknown>): void;
|
|
62
|
+
remove(type: string, id: string): void;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
type Fault =
|
|
66
|
+
| { kind: "fail"; error: unknown }
|
|
67
|
+
| { kind: "loseReply" }
|
|
68
|
+
| { kind: "rateLimit" }
|
|
69
|
+
| { kind: "hold"; released: Promise<void> };
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Builds a fake vendor for a connector, for tests and demos. `ops` implements the operations
|
|
73
|
+
* against `state`, typed by the connector as `defineDriver` does. The fake adds what a real
|
|
74
|
+
* vendor does around them: a repeated idempotency key gets the first reply and changes
|
|
75
|
+
* nothing. A test can make the next call fail, lose its reply, hit a rate limit or wait.
|
|
76
|
+
*/
|
|
77
|
+
export function defineFake<V extends string, S extends Specs, T extends Record<string, unknown>>(
|
|
78
|
+
connector: Connector<V, S>,
|
|
79
|
+
definition: { initial: () => T; ops: (state: T) => DriverImpl<S> },
|
|
80
|
+
options: FakeOptions = {},
|
|
81
|
+
): Fake<T, OpIdOf<V, S>> {
|
|
82
|
+
const state = definition.initial();
|
|
83
|
+
let replies: Record<string, unknown> = {};
|
|
84
|
+
// Replaced in place, so the object `ops` closes over stays the live state.
|
|
85
|
+
const replace = (next: T) => {
|
|
86
|
+
for (const key of Object.keys(state)) delete state[key];
|
|
87
|
+
Object.assign(state, next);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const inner = defineDriver(connector, definition.ops(state));
|
|
91
|
+
const { vendor } = inner;
|
|
92
|
+
const calls = options.calls ?? [];
|
|
93
|
+
const faults = new Map<string, Fault[]>();
|
|
94
|
+
const inject = (opId: string, fault: Fault) => {
|
|
95
|
+
if (!opId.startsWith(`${vendor}.`) || !Object.hasOwn(inner.ops, opId.slice(vendor.length + 1))) {
|
|
96
|
+
throw new Error(`fake ${vendor}: no operation ${opId}`);
|
|
97
|
+
}
|
|
98
|
+
faults.set(opId, [...(faults.get(opId) ?? []), fault]);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const ops: Record<string, DriverFn> = {};
|
|
102
|
+
for (const [key, fn] of Object.entries(inner.ops)) {
|
|
103
|
+
const opId = `${vendor}.${key}`;
|
|
104
|
+
ops[key] = async (input, call) => {
|
|
105
|
+
const { idempotencyKey, attempt } = call;
|
|
106
|
+
calls.push({ op: opId, input, at: new Date().toISOString(), idempotencyKey, attempt });
|
|
107
|
+
const fault = faults.get(opId)?.shift();
|
|
108
|
+
if (fault?.kind === "fail") throw fault.error;
|
|
109
|
+
if (fault?.kind === "rateLimit") {
|
|
110
|
+
throw new DriverError(`fake ${vendor}: ${opId} was rate limited`, {
|
|
111
|
+
retryable: true,
|
|
112
|
+
status: 429,
|
|
113
|
+
vendorCode: "rate_limited",
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
if (fault?.kind === "hold") await fault.released;
|
|
117
|
+
if (Object.hasOwn(replies, idempotencyKey)) return structuredClone(replies[idempotencyKey]);
|
|
118
|
+
const output = await fn(input, call);
|
|
119
|
+
replies[idempotencyKey] = output;
|
|
120
|
+
if (fault?.kind === "loseReply") {
|
|
121
|
+
throw new DriverError(`fake ${vendor}: the reply to ${opId} was lost`, { retryable: true });
|
|
122
|
+
}
|
|
123
|
+
return output;
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
driver: { vendor, ops },
|
|
129
|
+
state,
|
|
130
|
+
update(change) {
|
|
131
|
+
change(state);
|
|
132
|
+
},
|
|
133
|
+
reset() {
|
|
134
|
+
replace(definition.initial());
|
|
135
|
+
replies = {};
|
|
136
|
+
faults.clear();
|
|
137
|
+
for (let i = calls.length - 1; i >= 0; i--) if (calls[i]?.op.startsWith(`${vendor}.`)) calls.splice(i, 1);
|
|
138
|
+
},
|
|
139
|
+
calls,
|
|
140
|
+
failNext(opId, err) {
|
|
141
|
+
const error = err ?? new DriverError(`fake ${vendor}: ${opId} failed`, { retryable: true });
|
|
142
|
+
inject(opId, { kind: "fail", error });
|
|
143
|
+
},
|
|
144
|
+
loseReply(opId) {
|
|
145
|
+
inject(opId, { kind: "loseReply" });
|
|
146
|
+
},
|
|
147
|
+
rateLimit(opId) {
|
|
148
|
+
inject(opId, { kind: "rateLimit" });
|
|
149
|
+
},
|
|
150
|
+
hold(opId) {
|
|
151
|
+
let release!: () => void;
|
|
152
|
+
inject(opId, { kind: "hold", released: new Promise<void>((resolve) => (release = resolve)) });
|
|
153
|
+
return release;
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
}
|
package/src/fill.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { faker } from "@faker-js/faker";
|
|
3
|
+
import { fake, seed, setFaker } from "zod-schema-faker/v4";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
|
|
6
|
+
// Made-up values for zod schemas: scenarios' inputs. Internal.
|
|
7
|
+
|
|
8
|
+
setFaker(faker);
|
|
9
|
+
|
|
10
|
+
/** Seeds the generator from a name, so one name always makes up the same values. */
|
|
11
|
+
export const seedFrom = (name: string) => seed(createHash("sha256").update(name).digest().readUInt32BE(0));
|
|
12
|
+
|
|
13
|
+
/** The schema's value with every field `given` does not set made up, parsed by the schema. */
|
|
14
|
+
export function fill(schema: z.ZodType, given: Record<string, unknown>): unknown {
|
|
15
|
+
return schema.parse({ ...(fake(schema) as object), ...given });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** An object schema's fields by name; none for any other schema. */
|
|
19
|
+
export const shapeOf = (schema: z.ZodType): Record<string, z.ZodType> =>
|
|
20
|
+
schema instanceof z.ZodObject ? schema.shape : {};
|
|
21
|
+
|
|
22
|
+
/** A mistake in code (ours, or a fake's), not in what it was given: thrown as it is, never reported as data. */
|
|
23
|
+
export const isBug = (err: unknown) =>
|
|
24
|
+
err instanceof TypeError || err instanceof RangeError || err instanceof ReferenceError;
|
package/src/host.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { z } from "zod";
|
|
2
|
+
|
|
3
|
+
// The `Host` contract: what a resource type that runs the company (a container, a Render service,
|
|
4
|
+
// a Fly app) declares about itself, so hosts are swappable and one conformance kit decides
|
|
5
|
+
// whether one works. A host is a resource type like any other; this is what sets it apart.
|
|
6
|
+
|
|
7
|
+
/** How a host keeps the runtime up: `long-lived`, or (later) woken on a schedule. */
|
|
8
|
+
export type HostLifetime = "long-lived" | "wake-on-schedule";
|
|
9
|
+
|
|
10
|
+
/** What a host can do, for choosing one. */
|
|
11
|
+
export interface HostCapabilities {
|
|
12
|
+
lifetime: HostLifetime;
|
|
13
|
+
/** Its `url` is reachable from the internet, not only from this machine. */
|
|
14
|
+
publicUrl: boolean;
|
|
15
|
+
/** It builds the repository from git itself, at the revision apply gives it. */
|
|
16
|
+
buildsFromGit: boolean;
|
|
17
|
+
/** Its outbound connections come from fixed addresses, for a vendor's allow-list. */
|
|
18
|
+
staticEgress: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** What a host's state says of it: where the app answers, which commit it runs, and whether it is up. */
|
|
22
|
+
export interface HostOutputs {
|
|
23
|
+
url: string;
|
|
24
|
+
/** The commit it runs, when its state says. */
|
|
25
|
+
revision?: string;
|
|
26
|
+
healthy: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A resource type's `host`: the fields it takes the runtime's inputs in, by dotted path into its
|
|
31
|
+
* declared fields, and how its state reads as `HostOutputs`. A host is drifted when
|
|
32
|
+
* `outputs(state).revision` differs from what `sanoma.source.resolve` says `ref` is now.
|
|
33
|
+
*/
|
|
34
|
+
export interface HostSpec {
|
|
35
|
+
capabilities: HostCapabilities;
|
|
36
|
+
inputs: {
|
|
37
|
+
/** The repository's clone URL, or a reference to a declared repository whose state holds one (a `by` field). */
|
|
38
|
+
source: string;
|
|
39
|
+
/** The branch (or tag) it runs: `main`, never a commit. */
|
|
40
|
+
ref: string;
|
|
41
|
+
/** The secrets it passes the runtime (`SANOMA_DATABASE_URL`, `SANOMA_SECRETS_KEY`): a write-only field. */
|
|
42
|
+
secrets: string;
|
|
43
|
+
/** The port the app listens on. */
|
|
44
|
+
port: string;
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* The field apply fills with the commit `ref` resolves to, before it plans: hidden, as a data
|
|
48
|
+
* file may not declare it, so a merge to `ref` is a change to the host.
|
|
49
|
+
*/
|
|
50
|
+
revision: string;
|
|
51
|
+
outputs: (state: Record<string, unknown>) => HostOutputs;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const LIFETIMES: readonly HostLifetime[] = ["long-lived", "wake-on-schedule"];
|
|
55
|
+
|
|
56
|
+
/** True when `schema` has a field at the dotted `path`, looking through optional, nullable and default wrappers. */
|
|
57
|
+
function hasPath(schema: z.ZodType, path: string): boolean {
|
|
58
|
+
let at: z.ZodType | undefined = schema;
|
|
59
|
+
for (const key of path.split(".")) {
|
|
60
|
+
at = unwrap(at);
|
|
61
|
+
const shape = (at as Partial<z.ZodObject>).shape;
|
|
62
|
+
at = shape?.[key];
|
|
63
|
+
if (!at) return false;
|
|
64
|
+
}
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function unwrap(schema: z.ZodType): z.ZodType {
|
|
69
|
+
let at = schema;
|
|
70
|
+
while ("unwrap" in at && typeof at.unwrap === "function") at = at.unwrap() as z.ZodType;
|
|
71
|
+
return at;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Throws what is wrong with a resource type's `host`: an input or `revision` that names no field,
|
|
76
|
+
* `secrets` that is not write-only, a `revision` that is, an unknown lifetime, a type apply cannot
|
|
77
|
+
* change (not `writable`).
|
|
78
|
+
*/
|
|
79
|
+
export function checkHost(
|
|
80
|
+
what: string,
|
|
81
|
+
host: HostSpec,
|
|
82
|
+
type: { schema: z.ZodObject; fields: { writeOnly: readonly string[] }; writable?: boolean },
|
|
83
|
+
): void {
|
|
84
|
+
const problems: string[] = [];
|
|
85
|
+
if (!type.writable) problems.push("it must be writable, so apply can create and update it");
|
|
86
|
+
if (!LIFETIMES.includes(host.capabilities?.lifetime)) {
|
|
87
|
+
problems.push(`capabilities.lifetime is one of ${LIFETIMES.join(", ")}`);
|
|
88
|
+
}
|
|
89
|
+
for (const [input, path] of [...Object.entries(host.inputs ?? {}), ["revision", host.revision] as const]) {
|
|
90
|
+
if (typeof path !== "string" || !hasPath(type.schema, path)) problems.push(`${input} names no field (${path})`);
|
|
91
|
+
}
|
|
92
|
+
if (!type.fields.writeOnly.includes(host.inputs?.secrets)) problems.push("inputs.secrets must be a write-only field");
|
|
93
|
+
if (type.fields.writeOnly.includes(host.revision)) problems.push("revision must not be write-only: drift reads it");
|
|
94
|
+
if (typeof host.outputs !== "function") problems.push("outputs must be a function of the state");
|
|
95
|
+
if (problems.length) throw new Error(`${what}: its host: ${problems.join("; ")}`);
|
|
96
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
export {
|
|
2
|
+
type CallContext,
|
|
3
|
+
type Connector,
|
|
4
|
+
type ConnectorSetup,
|
|
5
|
+
type ConnectorVendor,
|
|
6
|
+
type Driver,
|
|
7
|
+
type DriverEnv,
|
|
8
|
+
type DriverFn,
|
|
9
|
+
defineConnector,
|
|
10
|
+
DriverError,
|
|
11
|
+
type Effect,
|
|
12
|
+
isOp,
|
|
13
|
+
type Op,
|
|
14
|
+
type OpSpec,
|
|
15
|
+
type ResourceGroup,
|
|
16
|
+
retryableStatus,
|
|
17
|
+
type VendorInfo,
|
|
18
|
+
withSetup,
|
|
19
|
+
} from "./op.ts";
|
|
20
|
+
export {
|
|
21
|
+
defineWorkflow,
|
|
22
|
+
Principal,
|
|
23
|
+
type ApprovalRequest,
|
|
24
|
+
type ApprovalResult,
|
|
25
|
+
type ApprovalState,
|
|
26
|
+
type Approver,
|
|
27
|
+
type Builtin,
|
|
28
|
+
type Ctx,
|
|
29
|
+
type SleepRequest,
|
|
30
|
+
type StepOptions,
|
|
31
|
+
type Use,
|
|
32
|
+
type WorkflowDefinition,
|
|
33
|
+
} from "./define.ts";
|
|
34
|
+
export {
|
|
35
|
+
compareDeclared,
|
|
36
|
+
type Declared,
|
|
37
|
+
type Declaring,
|
|
38
|
+
defineResource,
|
|
39
|
+
diffDeclared,
|
|
40
|
+
type FieldReference,
|
|
41
|
+
type PlanAction,
|
|
42
|
+
type References,
|
|
43
|
+
type Resource,
|
|
44
|
+
type ResourceFields,
|
|
45
|
+
type ResourceOps,
|
|
46
|
+
type ResourceSpec,
|
|
47
|
+
withoutWriteOnly,
|
|
48
|
+
} from "./resource.ts";
|
|
49
|
+
export { defineConfig, resolveConfig, resolveDatabaseUrl, type ResolvedConfig, type SanomaConfig } from "./config.ts";
|
|
50
|
+
export { type CredentialStatus, credentialsOf } from "./env.ts";
|
|
51
|
+
export {
|
|
52
|
+
APPLY_WORKFLOW,
|
|
53
|
+
ApplyInput,
|
|
54
|
+
type ApplyPlan,
|
|
55
|
+
type ApplyReport,
|
|
56
|
+
type ApplyResult,
|
|
57
|
+
type PlanAnswer,
|
|
58
|
+
type PlanStep,
|
|
59
|
+
} from "./apply.ts";
|
|
60
|
+
export {
|
|
61
|
+
DRIFT_WORKFLOW,
|
|
62
|
+
type DriftDeclared,
|
|
63
|
+
type DriftField,
|
|
64
|
+
type DriftReport,
|
|
65
|
+
type DriftResult,
|
|
66
|
+
type DriftStatus,
|
|
67
|
+
} from "./drift.ts";
|
|
68
|
+
// readResources, which parses data files, is at `@sanoma-ai/workflows/describe`.
|
|
69
|
+
export type { DeclaredResource, ResourceProblem } from "./resources.ts";
|
|
70
|
+
export {
|
|
71
|
+
allow,
|
|
72
|
+
allowAll,
|
|
73
|
+
approve,
|
|
74
|
+
approvedFor,
|
|
75
|
+
type Decision,
|
|
76
|
+
definePolicy,
|
|
77
|
+
deny,
|
|
78
|
+
type Policy,
|
|
79
|
+
type PolicyCall,
|
|
80
|
+
type PolicyOp,
|
|
81
|
+
policyOpOf,
|
|
82
|
+
type RecordedDecision,
|
|
83
|
+
} from "./policy.ts";
|
|
84
|
+
export {
|
|
85
|
+
errorCode,
|
|
86
|
+
type ErrorCode,
|
|
87
|
+
type ErrorInfo,
|
|
88
|
+
type InputIssue,
|
|
89
|
+
invalidInput,
|
|
90
|
+
PolicyDeniedError,
|
|
91
|
+
RejectedError,
|
|
92
|
+
SanomaError,
|
|
93
|
+
} from "./errors.ts";
|
|
94
|
+
export {
|
|
95
|
+
jsonlLedger,
|
|
96
|
+
memoryLedger,
|
|
97
|
+
postgresLedger,
|
|
98
|
+
type LedgerBody,
|
|
99
|
+
type LedgerGroup,
|
|
100
|
+
type LedgerRecord,
|
|
101
|
+
type LedgerStore,
|
|
102
|
+
} from "./ledger.ts";
|
|
103
|
+
export { ApprovalMessage, APPROVALS_EVENT, decisionEventOf } from "./approvals.ts";
|
|
104
|
+
export { RUNTIME_VERSION } from "./version.ts";
|
|
105
|
+
export type { RunArgs } from "./run.ts";
|
|
106
|
+
export { startWorker, type Worker, type WorkerOptions } from "./worker.ts";
|
|
107
|
+
// `secret` itself is at `@sanoma-ai/workflows/resources`, for data files; this is the drivers' side.
|
|
108
|
+
export {
|
|
109
|
+
generateSecret,
|
|
110
|
+
isSecret,
|
|
111
|
+
memorySecrets,
|
|
112
|
+
type Resolved,
|
|
113
|
+
resolveSecrets,
|
|
114
|
+
type Secret,
|
|
115
|
+
type Secrets,
|
|
116
|
+
} from "./secret.ts";
|
|
117
|
+
export { resolveRevision, SOURCE_RESOLVE, type Source } from "./source.ts";
|
|
118
|
+
export type { HostCapabilities, HostLifetime, HostOutputs, HostSpec } from "./host.ts";
|
|
119
|
+
export { SanomaClient, type RunsFilter, type RunSummary, type StartOptions, type StoredSecret } from "./client.ts";
|
|
120
|
+
// A raw DBOS client on the runtime's database, for tests and tools that read DBOS directly.
|
|
121
|
+
export { dbosClient } from "./db.ts";
|
|
122
|
+
// Also at `@sanoma-ai/workflows/shared`, which a browser bundle can import.
|
|
123
|
+
export {
|
|
124
|
+
approverLabel,
|
|
125
|
+
credentialReady,
|
|
126
|
+
DEFAULT_SLEEP_NAME,
|
|
127
|
+
ENDED_STATUSES,
|
|
128
|
+
errorMessage,
|
|
129
|
+
isEnded,
|
|
130
|
+
isLoopback,
|
|
131
|
+
mayDecide,
|
|
132
|
+
problemAt,
|
|
133
|
+
problemLine,
|
|
134
|
+
RUN_STATUSES,
|
|
135
|
+
type RunStatus,
|
|
136
|
+
} from "./shared.ts";
|
|
137
|
+
// describeConfig, with the outline and every problem lintWorkflow finds, lives at
|
|
138
|
+
// `@sanoma-ai/workflows/describe`: what a UI renders from, apart from what runs. The worker reads
|
|
139
|
+
// each workflow's outline too, to hold its runs to it.
|
|
140
|
+
export { defineDriver, type DriverImpl, type OpIdOf } from "./op.ts";
|
|
141
|
+
// Steps for Gherkin tests, as data; Cucumber runs them from `@sanoma-ai/workflows/scenario`.
|
|
142
|
+
export { defineSteps, type Expect, type RunView, type Seed, type Steps } from "./steps.ts";
|
|
143
|
+
// defineFake lives at `@sanoma-ai/workflows/fake`: test tooling, not runtime.
|