@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.
Files changed (176) hide show
  1. package/LICENSE +105 -0
  2. package/README.md +544 -2
  3. package/dist/apply-steps.d.ts +6 -0
  4. package/dist/apply-steps.js +93 -0
  5. package/dist/apply-steps.js.map +1 -0
  6. package/dist/apply.d.ts +197 -0
  7. package/dist/apply.js +470 -0
  8. package/dist/apply.js.map +1 -0
  9. package/dist/approvals.d.ts +46 -0
  10. package/dist/approvals.js +111 -0
  11. package/dist/approvals.js.map +1 -0
  12. package/dist/ast.d.ts +57 -0
  13. package/dist/ast.js +81 -0
  14. package/dist/ast.js.map +1 -0
  15. package/dist/call.d.ts +37 -0
  16. package/dist/call.js +417 -0
  17. package/dist/call.js.map +1 -0
  18. package/dist/client.d.ts +203 -0
  19. package/dist/client.js +457 -0
  20. package/dist/client.js.map +1 -0
  21. package/dist/config.d.ts +126 -0
  22. package/dist/config.js +264 -0
  23. package/dist/config.js.map +1 -0
  24. package/dist/credentials.d.ts +55 -0
  25. package/dist/credentials.js +261 -0
  26. package/dist/credentials.js.map +1 -0
  27. package/dist/cucumber.d.ts +64 -0
  28. package/dist/cucumber.js +158 -0
  29. package/dist/cucumber.js.map +1 -0
  30. package/dist/datafile.d.ts +71 -0
  31. package/dist/datafile.js +380 -0
  32. package/dist/datafile.js.map +1 -0
  33. package/dist/db.d.ts +58 -0
  34. package/dist/db.js +179 -0
  35. package/dist/db.js.map +1 -0
  36. package/dist/define.d.ts +205 -0
  37. package/dist/define.js +103 -0
  38. package/dist/define.js.map +1 -0
  39. package/dist/describe.d.ts +141 -0
  40. package/dist/describe.js +135 -0
  41. package/dist/describe.js.map +1 -0
  42. package/dist/drift.d.ts +127 -0
  43. package/dist/drift.js +167 -0
  44. package/dist/drift.js.map +1 -0
  45. package/dist/env.d.ts +49 -0
  46. package/dist/env.js +83 -0
  47. package/dist/env.js.map +1 -0
  48. package/dist/errors.d.ts +88 -0
  49. package/dist/errors.js +148 -0
  50. package/dist/errors.js.map +1 -0
  51. package/dist/expectations.d.ts +26 -0
  52. package/dist/expectations.js +162 -0
  53. package/dist/expectations.js.map +1 -0
  54. package/dist/fake.d.ts +55 -0
  55. package/dist/fake.js +87 -0
  56. package/dist/fake.js.map +1 -0
  57. package/dist/fill.d.ts +9 -0
  58. package/dist/fill.js +17 -0
  59. package/dist/fill.js.map +1 -0
  60. package/dist/host.d.ts +56 -0
  61. package/dist/host.js +45 -0
  62. package/dist/host.js.map +1 -0
  63. package/dist/index.d.ts +23 -0
  64. package/dist/index.js +29 -0
  65. package/dist/index.js.map +1 -0
  66. package/dist/ledger.d.ts +217 -0
  67. package/dist/ledger.js +251 -0
  68. package/dist/ledger.js.map +1 -0
  69. package/dist/lint.d.ts +32 -0
  70. package/dist/lint.js +168 -0
  71. package/dist/lint.js.map +1 -0
  72. package/dist/lockfile.d.ts +36 -0
  73. package/dist/lockfile.js +69 -0
  74. package/dist/lockfile.js.map +1 -0
  75. package/dist/log.d.ts +24 -0
  76. package/dist/log.js +83 -0
  77. package/dist/log.js.map +1 -0
  78. package/dist/op.d.ts +260 -0
  79. package/dist/op.js +188 -0
  80. package/dist/op.js.map +1 -0
  81. package/dist/outline.d.ts +179 -0
  82. package/dist/outline.js +506 -0
  83. package/dist/outline.js.map +1 -0
  84. package/dist/paths.d.ts +11 -0
  85. package/dist/paths.js +30 -0
  86. package/dist/paths.js.map +1 -0
  87. package/dist/plugin.d.ts +28 -0
  88. package/dist/plugin.js +25 -0
  89. package/dist/plugin.js.map +1 -0
  90. package/dist/policy.d.ts +114 -0
  91. package/dist/policy.js +60 -0
  92. package/dist/policy.js.map +1 -0
  93. package/dist/resource.d.ts +378 -0
  94. package/dist/resource.js +430 -0
  95. package/dist/resource.js.map +1 -0
  96. package/dist/resources.d.ts +77 -0
  97. package/dist/resources.js +244 -0
  98. package/dist/resources.js.map +1 -0
  99. package/dist/run.d.ts +101 -0
  100. package/dist/run.js +7 -0
  101. package/dist/run.js.map +1 -0
  102. package/dist/sandbox.d.ts +33 -0
  103. package/dist/sandbox.js +127 -0
  104. package/dist/sandbox.js.map +1 -0
  105. package/dist/scenario.d.ts +221 -0
  106. package/dist/scenario.js +1104 -0
  107. package/dist/scenario.js.map +1 -0
  108. package/dist/scenarios.d.ts +55 -0
  109. package/dist/scenarios.js +71 -0
  110. package/dist/scenarios.js.map +1 -0
  111. package/dist/secret.d.ts +42 -0
  112. package/dist/secret.js +58 -0
  113. package/dist/secret.js.map +1 -0
  114. package/dist/shared.d.ts +69 -0
  115. package/dist/shared.js +84 -0
  116. package/dist/shared.js.map +1 -0
  117. package/dist/source.d.ts +58 -0
  118. package/dist/source.js +54 -0
  119. package/dist/source.js.map +1 -0
  120. package/dist/steps.d.ts +76 -0
  121. package/dist/steps.js +23 -0
  122. package/dist/steps.js.map +1 -0
  123. package/dist/version.d.ts +29 -0
  124. package/dist/version.js +76 -0
  125. package/dist/version.js.map +1 -0
  126. package/dist/worker.d.ts +37 -0
  127. package/dist/worker.js +228 -0
  128. package/dist/worker.js.map +1 -0
  129. package/dist/writer.d.ts +35 -0
  130. package/dist/writer.js +201 -0
  131. package/dist/writer.js.map +1 -0
  132. package/oxlint.json +128 -0
  133. package/package.json +96 -3
  134. package/src/apply-steps.ts +110 -0
  135. package/src/apply.ts +626 -0
  136. package/src/approvals.ts +157 -0
  137. package/src/ast.ts +115 -0
  138. package/src/call.ts +483 -0
  139. package/src/client.ts +604 -0
  140. package/src/config.ts +404 -0
  141. package/src/credentials.ts +296 -0
  142. package/src/cucumber.ts +230 -0
  143. package/src/datafile.ts +481 -0
  144. package/src/db.ts +197 -0
  145. package/src/define.ts +285 -0
  146. package/src/describe.ts +282 -0
  147. package/src/drift.ts +259 -0
  148. package/src/env.ts +122 -0
  149. package/src/errors.ts +202 -0
  150. package/src/expectations.ts +191 -0
  151. package/src/fake.ts +156 -0
  152. package/src/fill.ts +24 -0
  153. package/src/host.ts +96 -0
  154. package/src/index.ts +143 -0
  155. package/src/ledger.ts +419 -0
  156. package/src/lint.ts +208 -0
  157. package/src/lockfile.ts +95 -0
  158. package/src/log.ts +82 -0
  159. package/src/op.ts +402 -0
  160. package/src/outline.ts +620 -0
  161. package/src/paths.ts +32 -0
  162. package/src/plugin.ts +37 -0
  163. package/src/policy.ts +139 -0
  164. package/src/resource.ts +670 -0
  165. package/src/resources.ts +311 -0
  166. package/src/run.ts +106 -0
  167. package/src/sandbox.ts +161 -0
  168. package/src/scenario.ts +1365 -0
  169. package/src/scenarios.ts +111 -0
  170. package/src/secret.ts +92 -0
  171. package/src/shared.ts +126 -0
  172. package/src/source.ts +82 -0
  173. package/src/steps.ts +90 -0
  174. package/src/version.ts +86 -0
  175. package/src/worker.ts +264 -0
  176. package/src/writer.ts +225 -0
package/src/drift.ts ADDED
@@ -0,0 +1,259 @@
1
+ import { z } from "zod";
2
+ import { defineWorkflow, type WorkflowDefinition } from "./define.ts";
3
+ import { isRunControlError } from "./errors.ts";
4
+ import type { Op } from "./op.ts";
5
+ import { type DriftField, diffDeclared, fieldPathOf, type Resource, valueAt } from "./resource.ts";
6
+ import type { DeclaredResource, ResourceProblem } from "./resources.ts";
7
+ import { type DRIFT_WORKFLOW, errorMessage } from "./shared.ts";
8
+ import { SOURCE_RESOLVE, type sourceOps } from "./source.ts";
9
+
10
+ export type { DriftField } from "./resource.ts";
11
+ export { DRIFT_WORKFLOW } from "./shared.ts";
12
+
13
+ // The built-in `drift` workflow: for each resource the data files declare, what the vendor holds
14
+ // now against what is declared. It is a workflow like any other, held to the same lint, so every
15
+ // vendor call goes through `ctx`: the policy sees each (effect `read`) and the ledger records it.
16
+
17
+ /**
18
+ * How a declared resource compares: `clean` when every declared field of its bound object holds
19
+ * its declared value, `drifted` when one does not, `gone` when the vendor no longer has its bound
20
+ * object, `absent` when it is bound to none (whether or not its identity finds one: `found`), and
21
+ * `error` when it could not be read (the vendor's error, a policy denial, or no driver for its type).
22
+ */
23
+ export type DriftStatus = "clean" | "drifted" | "gone" | "absent" | "error";
24
+
25
+ /** One declared resource in a drift report. */
26
+ export interface DriftResult {
27
+ /** The resource's id, `<file>#<export>`: `resources/identity/github.ts#website`. */
28
+ id: string;
29
+ /** Its type within the vendor, such as `repository`. */
30
+ type: string;
31
+ vendor: string;
32
+ /** What people call it: its identity, else its export's name. */
33
+ name: string;
34
+ status: DriftStatus;
35
+ /** The vendor's id of the object the lockfile binds it to; absent when unbound. */
36
+ binding?: string;
37
+ /** For `absent`: the binding id of the object its identity found, which apply may bind it to. */
38
+ found?: string;
39
+ /** The declared fields that differ; empty unless `drifted`. */
40
+ fields: DriftField[];
41
+ /** Why it could not be read, for `error`. */
42
+ error?: string;
43
+ }
44
+
45
+ /** The drift workflow's output. */
46
+ export interface DriftReport {
47
+ /** Epoch milliseconds, by `ctx.now()`. */
48
+ startedAt: number;
49
+ /** Epoch milliseconds, by `ctx.now()`. */
50
+ finishedAt: number;
51
+ /** What was wrong in the data files when the run read them: their resources are not in `resources`. */
52
+ problems: ResourceProblem[];
53
+ /** Each resource the data files declared when the run read them, in their order. */
54
+ resources: DriftResult[];
55
+ }
56
+
57
+ /**
58
+ * The drift workflow's input: nothing. The run reads the data files itself, so a run says what
59
+ * they declared when it ran, and no caller can hand it other resources to check.
60
+ */
61
+ export const DriftInput = z.object({}).strict();
62
+
63
+ /**
64
+ * What a drift run records of the data files, as its first step: each resource without its
65
+ * write-only values, and the problems.
66
+ */
67
+ export interface DriftDeclared {
68
+ resources: Pick<DeclaredResource, "id" | "vendor" | "type" | "name" | "identity" | "binding" | "desired" | "refs">[];
69
+ problems: ResourceProblem[];
70
+ }
71
+
72
+ type State = Record<string, unknown>;
73
+
74
+ // A resource type's import, as `ctx` gives it: by vendor and type, both only known per run.
75
+ type ImportOp = Op<string, string, "import", { id: string }, { id: string; gone: boolean; state?: State }>;
76
+ // The built-in `sanoma.source.resolve`, which a host's check calls for the commit its ref names now.
77
+ export type ResolveOp = typeof sourceOps.source.resolve;
78
+
79
+ /** A clone URL (a scheme's, or scp-like `user@host:path`): what a `by` field of a host's source holds, told from an id. */
80
+ const CLONE_URL = /^((https?|ssh|git|file):\/\/|[\w.-]+@[\w.-]+:)/;
81
+
82
+ /** What one import found: the object's state, that it is gone, or why it could not be read. */
83
+ export type Found = { state: State } | { gone: true } | { error: string };
84
+ /** What a host's ref names now, or why it could not be resolved. */
85
+ export type Revision = { revision: string } | { error: string };
86
+
87
+ // What drift and apply share. Observing a resource is one `ctx` call, which a workflow makes in
88
+ // its own `run` (the outline draws only those): `observeBy` says what each import is called with,
89
+ // `observed` reads its answer, and `compare` turns what every import found into drift's results.
90
+
91
+ /** The `import` ops of the types among `ops` with a driver, by type, sorted: what observing them uses. */
92
+ export function importsOf(
93
+ types: ReadonlyMap<string, Resource>,
94
+ ops: ReadonlyMap<string, Op>,
95
+ drivers: ReadonlyMap<string, unknown>,
96
+ ): ImportOp[] {
97
+ return [...types.keys()].toSorted().flatMap((type) => {
98
+ const op = ops.get(`${type}.import`);
99
+ return op && drivers.has(op.id) ? [op as ImportOp] : [];
100
+ });
101
+ }
102
+
103
+ /**
104
+ * What a declared resource is observed by: the import id (`by`, default its binding, else its
105
+ * identity), or without one what it is found to be: `gone` for none, `error` when its type is
106
+ * not the connectors' or has no import among `imports`.
107
+ */
108
+ export function observeBy(
109
+ { vendor, type, binding, identity }: Pick<DeclaredResource, "vendor" | "type" | "binding" | "identity">,
110
+ types: ReadonlyMap<string, Resource>,
111
+ imports: readonly Pick<Op, "id">[],
112
+ by = binding ?? identity,
113
+ ): { id: string } | Found {
114
+ const key = `${vendor}.${type}`;
115
+ if (!types.has(key)) return { error: `the config's connectors declare no resource type ${key}` };
116
+ if (!imports.some((op) => op.id === `${key}.import`)) return { error: `no driver for ${key}.import` };
117
+ return by === undefined ? { gone: true } : { id: by };
118
+ }
119
+
120
+ /** What an import's answer found. */
121
+ export const observed = (out: { gone: boolean; state?: State }): Found =>
122
+ out.gone || !out.state ? { gone: true } : { state: out.state };
123
+
124
+ /**
125
+ * Each declared resource against what its import `found`: `error`, `absent` (unbound, with the
126
+ * binding id of what its identity found as `found`), `gone`, else the state and the declared
127
+ * fields compared by `diffDeclared`, a reference against the state of the resource it names
128
+ * (so GitHub's node id for a repository counts as its name). A host with a `revisions` entry
129
+ * also compares its revision against the commit its ref names now (or is that entry's `error`).
130
+ */
131
+ export function compare(
132
+ types: ReadonlyMap<string, Resource>,
133
+ resources: DriftDeclared["resources"],
134
+ found: ReadonlyMap<string, Found>,
135
+ revisions: ReadonlyMap<string, Revision> = new Map(),
136
+ ): DriftResult[] {
137
+ return resources.map(({ id, vendor, type, name, binding, desired, refs }): DriftResult => {
138
+ const row = { id, type, vendor, name, ...(binding !== undefined && { binding }) };
139
+ const now = found.get(id)!;
140
+ if ("error" in now) return { ...row, status: "error", fields: [], error: now.error };
141
+ const resource = types.get(`${vendor}.${type}`)!;
142
+ if (binding === undefined) {
143
+ return {
144
+ ...row,
145
+ status: "absent",
146
+ fields: [],
147
+ ...("state" in now && { found: resource.bindingId(now.state) }),
148
+ };
149
+ }
150
+ if ("gone" in now) return { ...row, status: "gone", fields: [] };
151
+ // Each reference, by where it is: the values the vendor may hold there for its resource.
152
+ const accept: Record<string, unknown[]> = {};
153
+ for (const [at, target] of Object.entries(refs)) {
154
+ const by = resource.fields.references?.[fieldPathOf(at)]?.by ?? [];
155
+ const named = found.get(target);
156
+ if (named && "state" in named) accept[at] = by.map((field) => named.state[field]).filter((v) => v != null);
157
+ }
158
+ const fields = diffDeclared(resource, now.state, desired, accept);
159
+ const resolved = revisions.get(id);
160
+ if (resolved && "error" in resolved) return { ...row, status: "error", fields: [], error: resolved.error };
161
+ // A host whose state does not say which commit it runs is not compared.
162
+ const actual = resource.host?.outputs(now.state).revision;
163
+ if (resolved && actual !== undefined && actual !== resolved.revision) {
164
+ fields.push({ path: resource.host!.revision, desired: resolved.revision, actual });
165
+ }
166
+ return { ...row, status: fields.length ? "drifted" : "clean", fields };
167
+ });
168
+ }
169
+
170
+ /**
171
+ * The drift workflow for a config: it `uses` the `import` of every resource type the connectors
172
+ * declare (`types`) that has a driver, so its version changes with them, not with the data
173
+ * files. `resolveConfig` adds it to a config whose connectors declare resource types, with
174
+ * `declared`, the step that reads the data files.
175
+ *
176
+ * A run reads the data files and the lockfile (`declared`, recorded as a step, so a replay checks
177
+ * the same list), then imports each resource by its binding, else by its identity, in the files'
178
+ * order: one vendor call each, none for a resource with neither. A bound resource whose import
179
+ * says gone is `gone`; an unbound one is `absent`, with the binding id of what its identity found
180
+ * as `found`. Else the state and the declared fields are compared by `diffDeclared`, a reference
181
+ * against the state of the resource it names, read in the same run (so GitHub's node id for a
182
+ * repository counts as its name). A failed call is that resource's
183
+ * `error`, and the run goes on to the next; a failure that ends the run (`isRunControlError`)
184
+ * ends it.
185
+ */
186
+ export function driftWorkflow(
187
+ types: ReadonlyMap<string, Resource>,
188
+ ops: ReadonlyMap<string, Op>,
189
+ drivers: ReadonlyMap<string, unknown>,
190
+ declared: () => Promise<DriftDeclared>,
191
+ ): WorkflowDefinition<readonly (ImportOp | ResolveOp)[], typeof DriftInput> {
192
+ const imports = importsOf(types, ops, drivers);
193
+ const resolve = ops.get(SOURCE_RESOLVE) as ResolveOp | undefined;
194
+ const uses = [...imports, ...(resolve ? [resolve] : [])];
195
+ return defineWorkflow({
196
+ // The literal, held to the one name: the outline finds a workflow by a string, or a const of its own file.
197
+ name: "drift" satisfies typeof DRIFT_WORKFLOW,
198
+ title: "Check resources for drift",
199
+ trigger: "manual",
200
+ input: DriftInput,
201
+ uses,
202
+ run: async (ctx) => {
203
+ const startedAt = await ctx.now();
204
+ const { resources, problems } = await declared();
205
+ // Every import first, so a reference compares against what its resource holds now.
206
+ const found = new Map<string, Found>();
207
+ for (const resource of resources) {
208
+ const by = observeBy(resource, types, imports);
209
+ // An if/else, not a `continue`: the outline draws the import as the way past the error.
210
+ if (!("id" in by)) found.set(resource.id, by);
211
+ else {
212
+ try {
213
+ found.set(resource.id, observed(await ctx[resource.vendor]![resource.type]!.import({ id: by.id })));
214
+ } catch (err) {
215
+ if (isRunControlError(err)) throw err;
216
+ found.set(resource.id, { error: errorMessage(err) });
217
+ }
218
+ }
219
+ }
220
+ // A host's revision against the commit its ref names now: a merge is drift on the host.
221
+ const revisions = new Map<string, Revision>();
222
+ for (const { id, vendor, type, desired, refs } of resources) {
223
+ const resource = types.get(`${vendor}.${type}`);
224
+ const host = resource?.host;
225
+ const source = host && sourceUrl(desired, refs[host.inputs.source], resource, found);
226
+ const ref = host && valueAt(desired, host.inputs.ref);
227
+ if (host && "state" in found.get(id)! && source !== undefined && typeof ref === "string" && ref) {
228
+ try {
229
+ revisions.set(id, await ctx.sanoma!.source!.resolve({ url: source, ref }));
230
+ } catch (err) {
231
+ if (isRunControlError(err)) throw err;
232
+ revisions.set(id, { error: errorMessage(err) });
233
+ }
234
+ }
235
+ }
236
+ const results = compare(types, resources, found, revisions);
237
+ return { startedAt, finishedAt: await ctx.now(), problems, resources: results } satisfies DriftReport;
238
+ },
239
+ });
240
+ }
241
+
242
+ /**
243
+ * A host's source: the clone URL its data file gives, or, when it names a declared repository
244
+ * (`target`), the first of the reference's `by` fields that holds one in that repository's state,
245
+ * read in this run.
246
+ */
247
+ export function sourceUrl(
248
+ desired: State,
249
+ target: string | undefined,
250
+ { host, fields }: Resource,
251
+ found: ReadonlyMap<string, Found>,
252
+ ): string | undefined {
253
+ const path = host!.inputs.source;
254
+ const named = target === undefined ? undefined : found.get(target);
255
+ const by = fields.references?.[path]?.by ?? [];
256
+ const values =
257
+ target === undefined ? [valueAt(desired, path)] : named && "state" in named ? by.map((f) => named.state[f]) : [];
258
+ return values.find((v): v is string => typeof v === "string" && CLONE_URL.test(v));
259
+ }
package/src/env.ts ADDED
@@ -0,0 +1,122 @@
1
+ import type { z } from "zod";
2
+ import type { Driver, DriverEnv } from "./op.ts";
3
+ import { credentialReady } from "./shared.ts";
4
+
5
+ // A driver's `env` against an environment: what each declared variable's status is, never its
6
+ // value. The worker, the client, the app and each live call read the declarations through here.
7
+
8
+ /** One environment variable a driver declares in `env`, and whether its value lets the driver run: never the value. */
9
+ export interface CredentialStatus {
10
+ /** The variable's name, such as `GHOST_ADMIN_API_KEY`. */
11
+ name: string;
12
+ /** Its one-line manual, from its schema's `.describe()`. */
13
+ description?: string;
14
+ /** It may be unset. */
15
+ optional: boolean;
16
+ /** `missing` when unset or empty; `invalid` when its schema refuses the value. */
17
+ status: "set" | "missing" | "invalid";
18
+ /** Why it is `invalid`: the schema's first issue, never the value. */
19
+ problem?: string;
20
+ /**
21
+ * Where the value is from, when there is one: the process environment, which wins, or the
22
+ * credentials stored with `SanomaClient.setCredential`. Only `SanomaClient.credentials` says.
23
+ */
24
+ source?: "environment" | "stored";
25
+ /** Who set the stored value (`setCredential`'s `by`). */
26
+ setBy?: string;
27
+ /** When the stored value was set, as an ISO 8601 time. */
28
+ setAt?: string;
29
+ }
30
+
31
+ /**
32
+ * Each vendor's driver's `env` (a resolved config's `driversByVendor`), by vendor in the map's
33
+ * order (a vendor whose driver declares none has no entry), each variable in declared order and
34
+ * checked against the value `lookup` gives for its name: only the declared names are looked up,
35
+ * an empty value counts as unset, and a value is never kept. The one walk of the declarations:
36
+ * the worker, the client and its checks all read them through it.
37
+ */
38
+ export function credentialsOf(
39
+ drivers: ReadonlyMap<string, Driver>,
40
+ lookup: (name: string) => string | undefined,
41
+ ): Map<string, CredentialStatus[]> {
42
+ const map = new Map<string, CredentialStatus[]>();
43
+ for (const [vendor, { env }] of drivers) if (env) map.set(vendor, statusesOf(env, lookup));
44
+ return map;
45
+ }
46
+
47
+ /** One driver's `env`, each variable checked against `lookup`'s value, as `credentialsOf` checks it. */
48
+ export function statusesOf(env: DriverEnv, lookup: (name: string) => string | undefined): CredentialStatus[] {
49
+ return Object.entries(env.shape).map(([name, schema]) => credentialStatus(name, schema, lookup(name) || undefined));
50
+ }
51
+
52
+ /**
53
+ * Why the vendor's drivers cannot run on `statuses`, as a call to one fails (`startWorker` names
54
+ * the vendor alone, `unconfiguredSummary`): `<vendor> is not configured: <problems>; <hint>`, a
55
+ * missing variable (not optional) as `NAME (description)`, an invalid one as `NAME is invalid:
56
+ * <why>`, never a value. The hint says
57
+ * where to set them: in the environment when an invalid value is there (the environment wins
58
+ * over a stored value, so the connector page cannot replace it), else on the connector page.
59
+ * Undefined when the drivers can run.
60
+ */
61
+ export function unconfigured(vendor: string, statuses: readonly CredentialStatus[]): string | undefined {
62
+ const unready = statuses.filter((c) => !credentialReady(c));
63
+ if (!unready.length) return undefined;
64
+ const problems = unready.map((c) =>
65
+ c.status === "invalid"
66
+ ? `${c.name} is invalid: ${c.problem}`
67
+ : `${c.name}${c.description ? ` (${c.description})` : ""}`,
68
+ );
69
+ const inEnvironment = unready.some((c) => c.status === "invalid" && process.env[c.name]);
70
+ const hint = inEnvironment ? "set in the environment" : "set it on the connector page";
71
+ return `${vendor} is not configured: ${problems.join(", ")}; ${hint}`;
72
+ }
73
+
74
+ /**
75
+ * The one line `startWorker` prints for the vendors whose drivers cannot run yet (`vendors`, those
76
+ * `unconfigured` has a message for) among the config's `total`: their names alone, since a call
77
+ * to one fails with its own message, and its connector page says the same. Undefined for none.
78
+ */
79
+ export function unconfiguredSummary(vendors: readonly string[], total: number): string | undefined {
80
+ if (!vendors.length) return undefined;
81
+ return (
82
+ `${vendors.length} of ${total} connectors not configured yet: ${vendors.join(", ")}; ` +
83
+ "set their variables on the connector pages or in the environment"
84
+ );
85
+ }
86
+
87
+ function credentialStatus(
88
+ name: string,
89
+ schema: DriverEnv["shape"][string],
90
+ value: string | undefined,
91
+ ): CredentialStatus {
92
+ const description = descriptionOf(schema);
93
+ const parsed = value === undefined ? undefined : check(schema, value);
94
+ const problem = parsed?.problem;
95
+ return {
96
+ name,
97
+ ...(description !== undefined && { description }),
98
+ optional: check(schema, undefined).success,
99
+ status: !parsed ? "missing" : parsed.success ? "set" : "invalid",
100
+ ...(problem !== undefined && { problem }),
101
+ };
102
+ }
103
+
104
+ /**
105
+ * `schema.safeParse(value)`, with its first issue's message as the problem. A check that throws
106
+ * (zod lets a `.refine()` or `.transform()` throw) fails without its error, which may carry the value.
107
+ */
108
+ function check(schema: z.ZodType, value: string | undefined): { success: boolean; problem?: string } {
109
+ try {
110
+ const { success, error } = schema.safeParse(value);
111
+ return { success, ...(error && { problem: error.issues[0]!.message }) };
112
+ } catch {
113
+ return { success: false, problem: "its check threw" };
114
+ }
115
+ }
116
+
117
+ /** A schema's `.describe()`, also through `.optional()`, `.default()` and `.prefault()` in any order. */
118
+ function descriptionOf(schema: z.ZodType): string | undefined {
119
+ let s: z.ZodType | undefined = schema;
120
+ while (s && !s.description) s = "unwrap" in s ? (s as { unwrap(): z.ZodType }).unwrap() : undefined;
121
+ return s?.description;
122
+ }
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
+ }