@sanoma-ai/workflows 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. package/LICENSE +105 -0
  2. package/README.md +493 -2
  3. package/dist/approvals.d.ts +46 -0
  4. package/dist/approvals.js +111 -0
  5. package/dist/approvals.js.map +1 -0
  6. package/dist/ast.d.ts +57 -0
  7. package/dist/ast.js +81 -0
  8. package/dist/ast.js.map +1 -0
  9. package/dist/call.d.ts +33 -0
  10. package/dist/call.js +402 -0
  11. package/dist/call.js.map +1 -0
  12. package/dist/client.d.ts +167 -0
  13. package/dist/client.js +400 -0
  14. package/dist/client.js.map +1 -0
  15. package/dist/config.d.ts +126 -0
  16. package/dist/config.js +247 -0
  17. package/dist/config.js.map +1 -0
  18. package/dist/credentials.d.ts +30 -0
  19. package/dist/credentials.js +193 -0
  20. package/dist/credentials.js.map +1 -0
  21. package/dist/cucumber.d.ts +64 -0
  22. package/dist/cucumber.js +158 -0
  23. package/dist/cucumber.js.map +1 -0
  24. package/dist/datafile.d.ts +62 -0
  25. package/dist/datafile.js +326 -0
  26. package/dist/datafile.js.map +1 -0
  27. package/dist/db.d.ts +58 -0
  28. package/dist/db.js +179 -0
  29. package/dist/db.js.map +1 -0
  30. package/dist/define.d.ts +205 -0
  31. package/dist/define.js +103 -0
  32. package/dist/define.js.map +1 -0
  33. package/dist/describe.d.ts +135 -0
  34. package/dist/describe.js +127 -0
  35. package/dist/describe.js.map +1 -0
  36. package/dist/drift.d.ts +75 -0
  37. package/dist/drift.js +90 -0
  38. package/dist/drift.js.map +1 -0
  39. package/dist/env.d.ts +49 -0
  40. package/dist/env.js +83 -0
  41. package/dist/env.js.map +1 -0
  42. package/dist/errors.d.ts +88 -0
  43. package/dist/errors.js +148 -0
  44. package/dist/errors.js.map +1 -0
  45. package/dist/expectations.d.ts +26 -0
  46. package/dist/expectations.js +137 -0
  47. package/dist/expectations.js.map +1 -0
  48. package/dist/fake.d.ts +55 -0
  49. package/dist/fake.js +87 -0
  50. package/dist/fake.js.map +1 -0
  51. package/dist/fill.d.ts +9 -0
  52. package/dist/fill.js +17 -0
  53. package/dist/fill.js.map +1 -0
  54. package/dist/index.d.ts +19 -0
  55. package/dist/index.js +25 -0
  56. package/dist/index.js.map +1 -0
  57. package/dist/ledger.d.ts +217 -0
  58. package/dist/ledger.js +251 -0
  59. package/dist/ledger.js.map +1 -0
  60. package/dist/lint.d.ts +32 -0
  61. package/dist/lint.js +168 -0
  62. package/dist/lint.js.map +1 -0
  63. package/dist/log.d.ts +24 -0
  64. package/dist/log.js +83 -0
  65. package/dist/log.js.map +1 -0
  66. package/dist/op.d.ts +257 -0
  67. package/dist/op.js +188 -0
  68. package/dist/op.js.map +1 -0
  69. package/dist/outline.d.ts +179 -0
  70. package/dist/outline.js +506 -0
  71. package/dist/outline.js.map +1 -0
  72. package/dist/paths.d.ts +11 -0
  73. package/dist/paths.js +30 -0
  74. package/dist/paths.js.map +1 -0
  75. package/dist/plugin.d.ts +28 -0
  76. package/dist/plugin.js +25 -0
  77. package/dist/plugin.js.map +1 -0
  78. package/dist/policy.d.ts +114 -0
  79. package/dist/policy.js +60 -0
  80. package/dist/policy.js.map +1 -0
  81. package/dist/resource.d.ts +212 -0
  82. package/dist/resource.js +285 -0
  83. package/dist/resource.js.map +1 -0
  84. package/dist/resources.d.ts +62 -0
  85. package/dist/resources.js +197 -0
  86. package/dist/resources.js.map +1 -0
  87. package/dist/run.d.ts +87 -0
  88. package/dist/run.js +2 -0
  89. package/dist/run.js.map +1 -0
  90. package/dist/sandbox.d.ts +33 -0
  91. package/dist/sandbox.js +123 -0
  92. package/dist/sandbox.js.map +1 -0
  93. package/dist/scenario.d.ts +212 -0
  94. package/dist/scenario.js +1079 -0
  95. package/dist/scenario.js.map +1 -0
  96. package/dist/scenarios.d.ts +55 -0
  97. package/dist/scenarios.js +71 -0
  98. package/dist/scenarios.js.map +1 -0
  99. package/dist/shared.d.ts +61 -0
  100. package/dist/shared.js +77 -0
  101. package/dist/shared.js.map +1 -0
  102. package/dist/steps.d.ts +76 -0
  103. package/dist/steps.js +23 -0
  104. package/dist/steps.js.map +1 -0
  105. package/dist/version.d.ts +29 -0
  106. package/dist/version.js +76 -0
  107. package/dist/version.js.map +1 -0
  108. package/dist/worker.d.ts +34 -0
  109. package/dist/worker.js +219 -0
  110. package/dist/worker.js.map +1 -0
  111. package/oxlint.json +128 -0
  112. package/package.json +91 -3
  113. package/src/approvals.ts +157 -0
  114. package/src/ast.ts +115 -0
  115. package/src/call.ts +468 -0
  116. package/src/client.ts +534 -0
  117. package/src/config.ts +381 -0
  118. package/src/credentials.ts +211 -0
  119. package/src/cucumber.ts +230 -0
  120. package/src/datafile.ts +409 -0
  121. package/src/db.ts +197 -0
  122. package/src/define.ts +285 -0
  123. package/src/describe.ts +262 -0
  124. package/src/drift.ts +149 -0
  125. package/src/env.ts +122 -0
  126. package/src/errors.ts +202 -0
  127. package/src/expectations.ts +165 -0
  128. package/src/fake.ts +156 -0
  129. package/src/fill.ts +24 -0
  130. package/src/index.ts +120 -0
  131. package/src/ledger.ts +419 -0
  132. package/src/lint.ts +208 -0
  133. package/src/log.ts +82 -0
  134. package/src/op.ts +399 -0
  135. package/src/outline.ts +620 -0
  136. package/src/paths.ts +32 -0
  137. package/src/plugin.ts +37 -0
  138. package/src/policy.ts +139 -0
  139. package/src/resource.ts +447 -0
  140. package/src/resources.ts +256 -0
  141. package/src/run.ts +91 -0
  142. package/src/sandbox.ts +157 -0
  143. package/src/scenario.ts +1328 -0
  144. package/src/scenarios.ts +111 -0
  145. package/src/shared.ts +116 -0
  146. package/src/steps.ts +90 -0
  147. package/src/version.ts +86 -0
  148. package/src/worker.ts +253 -0
package/src/errors.ts ADDED
@@ -0,0 +1,202 @@
1
+ import { Error as DBOSErrors } from "@dbos-inc/dbos-sdk";
2
+ import type { z } from "zod";
3
+ import type { Principal } from "./define.ts";
4
+ import { errorMessage } from "./shared.ts";
5
+
6
+ const CODES = [
7
+ "policy_denied",
8
+ "approval_rejected",
9
+ "not_approver",
10
+ "no_pending_approval",
11
+ "already_decided",
12
+ "run_not_found",
13
+ "driver_failed",
14
+ "invalid_input",
15
+ "run_ended",
16
+ "run_running",
17
+ "call_not_in_outline",
18
+ ] as const;
19
+
20
+ /**
21
+ * Why something failed, as a stable string a caller can branch on. Messages are for people
22
+ * and may change; codes are the contract.
23
+ */
24
+ export type ErrorCode = (typeof CODES)[number];
25
+
26
+ const KNOWN = new Set<string>(CODES);
27
+
28
+ /**
29
+ * An error with a `code` and plain-JSON `data`, both own enumerable properties, so they survive
30
+ * the trip through DBOS: a run's error is stored serialized and comes back as a new object that
31
+ * is not an instance of this class. Read the code with `errorCode`, never `instanceof`.
32
+ */
33
+ export class SanomaError extends Error {
34
+ readonly code: ErrorCode;
35
+ readonly data: Record<string, unknown>;
36
+
37
+ constructor(code: ErrorCode, message: string, data: Record<string, unknown> = {}, options?: ErrorOptions) {
38
+ super(message, options);
39
+ this.name = "SanomaError";
40
+ this.code = code;
41
+ this.data = data;
42
+ }
43
+ }
44
+
45
+ /** The error's code, read from its own `code` property, or undefined when it has none of ours. */
46
+ export function errorCode(e: unknown): ErrorCode | undefined {
47
+ const code = own<string>(e, "code", "string");
48
+ return code !== undefined && KNOWN.has(code) ? (code as ErrorCode) : undefined;
49
+ }
50
+
51
+ /**
52
+ * True when the error says trying again would only repeat it (`retryable: false`), as a
53
+ * `DriverError` for a vendor's final answer or a ledger store for a corrupt file does. Read as
54
+ * a property, never `instanceof`: the error may come from another copy of this package.
55
+ */
56
+ export const isFinal = (err: unknown): boolean => (err as { retryable?: unknown } | null)?.retryable === false;
57
+
58
+ /** The error and the ones it was caused by, a few deep. */
59
+ export function causes(err: unknown): Error[] {
60
+ const out: Error[] = [];
61
+ for (let e = err; e instanceof Error && out.length < 5; e = e.cause) out.push(e);
62
+ return out;
63
+ }
64
+
65
+ /**
66
+ * True for a failure that ends the run rather than one call: anything DBOS throws (it cancelled
67
+ * the run, another process took it over, the worker is shutting down, the run is not
68
+ * deterministic) and `run_ended` (a call queued after the run's body ended). Looked for in the
69
+ * error and its causes. A workflow that goes on past a failed call, as `drift` does, rethrows these.
70
+ */
71
+ export const isRunControlError = (err: unknown): boolean =>
72
+ causes(err).some((e) => e instanceof DBOSErrors.DBOSError || errorCode(e) === "run_ended");
73
+
74
+ /** Thrown into the run when the policy denies an operation call. The run fails with it. */
75
+ export class PolicyDeniedError extends SanomaError {
76
+ constructor(op: string, reason: string) {
77
+ super("policy_denied", `${op} was denied by policy: ${reason}`, { op, reason });
78
+ this.name = "PolicyDeniedError";
79
+ }
80
+ }
81
+
82
+ /** Thrown into the run when an approver rejects. The run fails with it. */
83
+ export class RejectedError extends SanomaError {
84
+ /**
85
+ * @param title The approval's title.
86
+ * @param by Who rejected it.
87
+ * @param approvalId The approval's id in the run, such as "approval-2".
88
+ */
89
+ constructor(title: string, by: Principal, note: string | undefined, approvalId: string) {
90
+ super("approval_rejected", `"${title}" was rejected by ${by.id}${note ? `: ${note}` : ""}`, {
91
+ title,
92
+ by,
93
+ ...(note === undefined ? {} : { note }),
94
+ approvalId,
95
+ });
96
+ this.name = "RejectedError";
97
+ }
98
+ }
99
+
100
+ /**
101
+ * The wrapper, given the original's own `code` and `data` when it has one of our codes, so
102
+ * `errorCode` reads the same through it: a denied call whose ledger write failed too is still
103
+ * `policy_denied`.
104
+ */
105
+ export function keepCode<E extends Error>(wrapper: E, original: unknown): E {
106
+ const code = errorCode(original);
107
+ if (code === undefined) return wrapper;
108
+ const data = own<Record<string, unknown>>(original, "data", "object");
109
+ return Object.assign(wrapper, { code }, data === undefined ? {} : { data });
110
+ }
111
+
112
+ /** A zod issue as the ledger and an API can carry it: no symbols in the path. */
113
+ export interface InputIssue {
114
+ path: (string | number)[];
115
+ message: string;
116
+ code: string;
117
+ }
118
+
119
+ /** The value, parsed by the schema, or an `invalid_input` error carrying zod's issues and `data`. */
120
+ export function parseOrThrow<T extends z.ZodType>(
121
+ schema: T,
122
+ value: unknown,
123
+ what: string,
124
+ data?: Record<string, unknown>,
125
+ ): z.output<T> {
126
+ const parsed = schema.safeParse(value);
127
+ if (parsed.success) return parsed.data;
128
+ throw invalidInput(what, parsed.error.issues, data);
129
+ }
130
+
131
+ /** An `invalid_input` error from zod issues, with the issues in `data` and a one-line message. */
132
+ export function invalidInput(
133
+ what: string,
134
+ zodIssues: readonly { path: PropertyKey[]; message: string; code: string }[],
135
+ data: Record<string, unknown> = {},
136
+ ): SanomaError {
137
+ const issues: InputIssue[] = zodIssues.map(({ path, message, code }) => ({
138
+ path: path.filter((p): p is string | number => typeof p !== "symbol"),
139
+ message,
140
+ code,
141
+ }));
142
+ const said = issues.map((i) => (i.path.length ? `${i.path.join(".")}: ${i.message}` : i.message)).join("; ");
143
+ return new SanomaError("invalid_input", `${what}: ${said}`, { ...data, issues });
144
+ }
145
+
146
+ /**
147
+ * What the ledger keeps of an error. `status`, `vendorCode` and `retryable` are a
148
+ * `DriverError`'s account of the vendor's answer; `data` is one of the runtime's errors'. Each is
149
+ * there only when the error has it, and `data`, `status` and `vendorCode` only from those errors.
150
+ */
151
+ export interface ErrorInfo {
152
+ code?: ErrorCode;
153
+ name: string;
154
+ message: string;
155
+ /** The vendor's HTTP status, from a `DriverError`. */
156
+ status?: number;
157
+ /** The vendor's own error code, from a `DriverError`. */
158
+ vendorCode?: string;
159
+ /** Whether trying again could succeed, from a `DriverError` or a ledger store. */
160
+ retryable?: boolean;
161
+ /** A `SanomaError`'s `data`: the operation, the approval, the issues. */
162
+ data?: Record<string, unknown>;
163
+ }
164
+
165
+ /** The error's own property `key`, when it is of the type given. Own: a copy from DBOS has no prototype to read. */
166
+ function own<T>(err: unknown, key: string, type: "number" | "string" | "boolean" | "object"): T | undefined {
167
+ if (typeof err !== "object" || err === null || !Object.hasOwn(err, key)) return undefined;
168
+ const value = (err as Record<string, unknown>)[key];
169
+ return typeof value === type && value !== null ? (value as T) : undefined;
170
+ }
171
+
172
+ const fromDriver = (code: ErrorCode | undefined) => code === "driver_failed";
173
+ const fromRuntime = (code: ErrorCode | undefined) => code !== undefined;
174
+ const fromAny = () => true;
175
+
176
+ /**
177
+ * The fields `errorInfo` copies, with their types, and from which errors. Only the runtime's
178
+ * errors give `data`, and only a `DriverError` the vendor's `status` and `vendorCode`: an error
179
+ * a driver lets escape (an HTTP client's) may carry the vendor's whole response under those
180
+ * names. `retryable` is anyone's, a ledger store's included.
181
+ */
182
+ const KEPT = [
183
+ ["status", "number", fromDriver],
184
+ ["vendorCode", "string", fromDriver],
185
+ ["retryable", "boolean", fromAny],
186
+ ["data", "object", fromRuntime],
187
+ ] as const;
188
+
189
+ export function errorInfo(err: unknown): ErrorInfo {
190
+ const code = errorCode(err);
191
+ const info: ErrorInfo = {
192
+ ...(code === undefined ? {} : { code }),
193
+ name: err instanceof Error ? err.name : "Error",
194
+ message: errorMessage(err),
195
+ };
196
+ for (const [key, type, keeps] of KEPT) {
197
+ const value = keeps(code) ? own(err, key, type) : undefined;
198
+ if (value !== undefined) Object.assign(info, { [key]: value });
199
+ }
200
+ if (info.data && Object.keys(info.data).length === 0) delete info.data;
201
+ return info;
202
+ }
@@ -0,0 +1,165 @@
1
+ import { isDeepStrictEqual } from "node:util";
2
+ import type { Approver } from "./define.ts";
3
+ import type { DriftReport, DriftResult } from "./drift.ts";
4
+ import type { LedgerRecord } from "./ledger.ts";
5
+ import type { RecordedDecision } from "./policy.ts";
6
+ import type { Expectation } from "./scenario.ts";
7
+ import { approverLabel } from "./shared.ts";
8
+
9
+ /*
10
+ * What the generic `Then` steps check against a run's ledger records: pure, and internal to
11
+ * `scenario.ts`, whose steps call it (its tests do too).
12
+ */
13
+
14
+ /** A call in a run: an `op.called` record. */
15
+ export type CallRecord = Extract<LedgerRecord, { type: "op.called" }>;
16
+ type Request = Extract<LedgerRecord, { type: "approval.requested" }>;
17
+
18
+ export const isCall = (r: LedgerRecord): r is CallRecord => r.type === "op.called";
19
+ const isRequest = (r: LedgerRecord): r is Request => r.type === "approval.requested";
20
+
21
+ /** True when `actual` has every key `expected` has, with an equal value, at every depth. */
22
+ export function contains(actual: unknown, expected: unknown): boolean {
23
+ if (typeof expected !== "object" || expected === null || Array.isArray(expected)) {
24
+ return isDeepStrictEqual(actual, expected);
25
+ }
26
+ if (typeof actual !== "object" || actual === null || Array.isArray(actual)) return false;
27
+ return Object.entries(expected).every(([k, v]) => contains((actual as Record<string, unknown>)[k], v));
28
+ }
29
+
30
+ /**
31
+ * The records of the step named `name` that are not calls: its sleeps, and the approvals the
32
+ * workflow asked for by that title. A policy's hold carries the held call's name, and is that
33
+ * call's, not a run of its own.
34
+ */
35
+ const waitsOf = (records: readonly LedgerRecord[], name: string) =>
36
+ records.filter(
37
+ (r) =>
38
+ (r.type === "sleep.started" || (r.type === "approval.requested" && r.requestedBy === "workflow")) &&
39
+ r.name === name,
40
+ );
41
+
42
+ /**
43
+ * Why a call expected (`what`) is missing: that, the calls made among `tries` (with their
44
+ * inputs), and the ones attempted, failed (denied, rejected, or failed at the vendor for good).
45
+ */
46
+ function missing(what: string, tries: CallRecord[]): string {
47
+ const made = tries.filter((r) => !r.error);
48
+ const tried = tries.flatMap(({ error }) => (error ? [`${error.code ?? error.name}: ${error.message}`] : []));
49
+ return [
50
+ what,
51
+ ...(made.length ? [`its calls had ${inputs(made)}`] : []),
52
+ ...(tried.length ? [`attempted: ${tried.join(", ")}`] : []),
53
+ ].join("; ");
54
+ }
55
+
56
+ /** `once`, `twice`, `3 times`. */
57
+ const times = (n: number) => (n === 1 ? "once" : n === 2 ? "twice" : `${n} times`);
58
+
59
+ /** Why an expectation about a named step does not hold against a run's records, or undefined when it does. */
60
+ function namedDetail(e: Extract<CoreExpectation, { name: string }>, records: readonly LedgerRecord[]) {
61
+ const tries = records.filter(isCall).filter((r) => r.name === e.name);
62
+ const calls = tries.filter((r) => !r.error);
63
+ const runs = calls.length + waitsOf(records, e.name).length;
64
+ const step = `step "${e.name}"`;
65
+ if ("times" in e) return runs === e.times ? undefined : `${step} ran ${times(runs)}, not ${times(e.times)}`;
66
+ const matched = e.input === undefined ? runs : calls.filter((r) => contains(r.input, e.input)).length;
67
+ if (matched > 0 === e.called) return undefined;
68
+ if (!e.called) return calls.length ? `${step} was called with ${inputs(calls)}` : `${step} ran ${times(runs)}`;
69
+ return missing(
70
+ e.input === undefined ? `${step} was not called` : `${step} was not called with ${JSON.stringify(e.input)}`,
71
+ tries,
72
+ );
73
+ }
74
+
75
+ /** The inputs of the calls, as JSON, for a detail. */
76
+ const inputs = (list: CallRecord[]) => list.map((r) => JSON.stringify(r.input)).join(", ");
77
+
78
+ /** A policy's decision as words: `allowed`, `denied`, `held for finance`. */
79
+ const words = (d: RecordedDecision) =>
80
+ d.kind === "allow" ? "allowed" : d.kind === "deny" ? "denied" : `held for ${approverLabel(d.approver)}`;
81
+
82
+ /** What the policy decided for the calls, each decision once: `allowed and held for finance`. */
83
+ const decisions = (calls: CallRecord[]) => [...new Set(calls.map((r) => words(r.decision)))].join(" and ");
84
+
85
+ /** True when `who` names the approver: a person by id, or a group by its name. */
86
+ const isApprover = (approver: Approver, who: string) =>
87
+ typeof approver === "string" ? approver === who : approver.group === who;
88
+
89
+ /** How a drift report found a resource, as words: `clean`, `gone`, `drifted in has_wiki, topics`. */
90
+ const found = (r: DriftResult) =>
91
+ r.status === "drifted" ? `drifted in ${r.fields.map((f) => f.path).join(", ")}` : r.status;
92
+
93
+ /** An expectation of the generic steps, without its step's text. */
94
+ export type CoreExpectation = Unstepped<Exclude<Expectation, { custom: true }>>;
95
+ type Unstepped<T> = T extends unknown ? Omit<T, "step"> : never;
96
+
97
+ /**
98
+ * Why a drift expectation fails against the report a finished drift run returned as its
99
+ * output, or undefined when the report found the resource as expected.
100
+ */
101
+ function driftDetail(
102
+ e: Extract<CoreExpectation, { declared: string }>,
103
+ end: Extract<LedgerRecord, { type: "run.finished" }>,
104
+ ): string | undefined {
105
+ const report = end.output as Partial<DriftReport> | null | undefined;
106
+ if (!Array.isArray(report?.resources)) return "the run's output is not a drift report";
107
+ const resource = report.resources.find((r) => r.id === e.declared);
108
+ if (!resource) {
109
+ return `no resource ${e.declared} in the report; it has ${report.resources.map((r) => r.id).join(", ") || "none"}`;
110
+ }
111
+ if (resource.status === "error") return `${e.declared} could not be read: ${resource.error}`;
112
+ const ok =
113
+ resource.status === e.status && (e.status !== "drifted" || resource.fields.some((f) => f.path === e.field));
114
+ if (ok) return undefined;
115
+ return `${e.declared} is ${found(resource)}, not ${e.status === "drifted" ? `drifted in ${e.field}` : e.status}`;
116
+ }
117
+
118
+ /**
119
+ * Why an expectation of the generic steps does not hold against a run's ledger records, or
120
+ * undefined when it does. A call is an `op.called` record without an `error`; one with an error
121
+ * (a policy denial, a rejected approval, a vendor's final failure) was attempted, not made. An
122
+ * operation `was denied` when the policy's decision on some call to it is `deny`, and `was held
123
+ * for` an approver when an approval for that approver holds it: one the policy asked for on its
124
+ * call, or one that `covers` it. A drift expectation reads the report a finished drift run
125
+ * returned. A step named in the workflow's outline is read by the `name` its records carry
126
+ * (its calls made, and `waitsOf`); a record written before steps had names has none.
127
+ */
128
+ export function detailOf(e: CoreExpectation, records: readonly LedgerRecord[]): string | undefined {
129
+ if ("name" in e) return namedDetail(e, records);
130
+ const end = records.find((r) => r.type === "run.finished" || r.type === "run.failed");
131
+ const calls = records.filter(isCall);
132
+ const callsTo = (op: string) => calls.filter((r) => r.op === op);
133
+ if ("decision" in e) {
134
+ const tries = callsTo(e.op);
135
+ if (e.decision === "denied") {
136
+ if (tries.some((r) => r.decision.kind === "deny")) return undefined;
137
+ return tries.length ? `${e.op} was ${decisions(tries)}, not denied` : `no call to ${e.op}`;
138
+ }
139
+ const holds = records.filter(isRequest).filter((r) => r.op === e.op || r.covers.includes(e.op));
140
+ if (holds.some((r) => isApprover(r.approver, e.approver))) return undefined;
141
+ return holds.length
142
+ ? `${e.op} was held for ${[...new Set(holds.map((r) => approverLabel(r.approver)))].join(" and ")}, not ${e.approver}`
143
+ : tries.length
144
+ ? `${e.op} was ${decisions(tries)}, not held`
145
+ : `no call to ${e.op}`;
146
+ }
147
+ if ("called" in e) {
148
+ const tries = callsTo(e.op);
149
+ const made = tries.filter((r) => !r.error);
150
+ const matching = made.filter((r) => e.input === undefined || contains(r.input, e.input));
151
+ if (matching.length > 0 === e.called) return undefined;
152
+ if (!e.called) return `${e.op} was called with ${inputs(matching)}`;
153
+ return missing(`no call to ${e.input === undefined ? e.op : `${e.op} with ${JSON.stringify(e.input)}`}`, tries);
154
+ }
155
+ // The rest read how the run ended.
156
+ if (!end) return "run not ended";
157
+ if ("outcome" in e && e.outcome === "failed") {
158
+ if (end.type !== "run.failed") return "the run finished";
159
+ if (e.code === undefined || end.error.code === e.code) return undefined;
160
+ return `the run failed with ${end.error.code ?? "no code"}: ${end.error.message}`;
161
+ }
162
+ // A finished run: what `the run succeeds` expects, and what a drift expectation reads its report from.
163
+ if (end.type === "run.failed") return `the run failed: ${end.error.message}`;
164
+ return "declared" in e ? driftDetail(e, end) : undefined;
165
+ }
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/index.ts ADDED
@@ -0,0 +1,120 @@
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 References,
42
+ type Resource,
43
+ type ResourceFields,
44
+ type ResourceOps,
45
+ type ResourceSpec,
46
+ } from "./resource.ts";
47
+ export { defineConfig, resolveConfig, resolveDatabaseUrl, type ResolvedConfig, type SanomaConfig } from "./config.ts";
48
+ export { type CredentialStatus, credentialsOf } from "./env.ts";
49
+ export {
50
+ DRIFT_WORKFLOW,
51
+ type DriftDeclared,
52
+ type DriftField,
53
+ type DriftReport,
54
+ type DriftResult,
55
+ type DriftStatus,
56
+ } from "./drift.ts";
57
+ // readResources, which parses data files, is at `@sanoma-ai/workflows/describe`.
58
+ export type { DeclaredResource, ResourceProblem } from "./resources.ts";
59
+ export {
60
+ allow,
61
+ allowAll,
62
+ approve,
63
+ approvedFor,
64
+ type Decision,
65
+ definePolicy,
66
+ deny,
67
+ type Policy,
68
+ type PolicyCall,
69
+ type PolicyOp,
70
+ policyOpOf,
71
+ type RecordedDecision,
72
+ } from "./policy.ts";
73
+ export {
74
+ errorCode,
75
+ type ErrorCode,
76
+ type ErrorInfo,
77
+ type InputIssue,
78
+ invalidInput,
79
+ PolicyDeniedError,
80
+ RejectedError,
81
+ SanomaError,
82
+ } from "./errors.ts";
83
+ export {
84
+ jsonlLedger,
85
+ memoryLedger,
86
+ postgresLedger,
87
+ type LedgerBody,
88
+ type LedgerGroup,
89
+ type LedgerRecord,
90
+ type LedgerStore,
91
+ } from "./ledger.ts";
92
+ export { ApprovalMessage, APPROVALS_EVENT, decisionEventOf } from "./approvals.ts";
93
+ export { RUNTIME_VERSION } from "./version.ts";
94
+ export type { RunArgs } from "./run.ts";
95
+ export { startWorker, type Worker, type WorkerOptions } from "./worker.ts";
96
+ export { SanomaClient, type RunsFilter, type RunSummary, type StartOptions } from "./client.ts";
97
+ // A raw DBOS client on the runtime's database, for tests and tools that read DBOS directly.
98
+ export { dbosClient } from "./db.ts";
99
+ // Also at `@sanoma-ai/workflows/shared`, which a browser bundle can import.
100
+ export {
101
+ approverLabel,
102
+ credentialReady,
103
+ DEFAULT_SLEEP_NAME,
104
+ ENDED_STATUSES,
105
+ errorMessage,
106
+ isEnded,
107
+ isLoopback,
108
+ mayDecide,
109
+ problemAt,
110
+ problemLine,
111
+ RUN_STATUSES,
112
+ type RunStatus,
113
+ } from "./shared.ts";
114
+ // describeConfig, with the outline and every problem lintWorkflow finds, lives at
115
+ // `@sanoma-ai/workflows/describe`: what a UI renders from, apart from what runs. The worker reads
116
+ // each workflow's outline too, to hold its runs to it.
117
+ export { defineDriver, type DriverImpl, type OpIdOf } from "./op.ts";
118
+ // Steps for Gherkin tests, as data; Cucumber runs them from `@sanoma-ai/workflows/scenario`.
119
+ export { defineSteps, type Expect, type RunView, type Seed, type Steps } from "./steps.ts";
120
+ // defineFake lives at `@sanoma-ai/workflows/fake`: test tooling, not runtime.