@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/define.ts ADDED
@@ -0,0 +1,285 @@
1
+ import { fileURLToPath } from "node:url";
2
+ import { getCallSites } from "node:util";
3
+ import { z } from "zod";
4
+ import { isOp, type Op } from "./op.ts";
5
+ import { approverLabel } from "./shared.ts";
6
+
7
+ /**
8
+ * Who is acting: the person or service that starts a run or decides an approval. `id` is
9
+ * whatever the deployment identifies people by; `groups` are the roles it vouches for.
10
+ */
11
+ export interface Principal {
12
+ id: string;
13
+ groups?: string[];
14
+ }
15
+
16
+ export const Principal: z.ZodType<Principal> = z.object({
17
+ id: z.string().regex(/\S/, "id must not be blank"),
18
+ groups: z.array(z.string()).optional(),
19
+ });
20
+
21
+ export type Builtin = "approval" | "sleep" | "all";
22
+ export type Use = Op | Builtin;
23
+
24
+ /** Who may decide an approval: one person, by `Principal.id`, or anyone in a group, by `Principal.groups`. */
25
+ export type Approver = string | { group: string };
26
+
27
+ export const Approver: z.ZodType<Approver> = z.union(
28
+ [z.string().regex(/\S/), z.object({ group: z.string().regex(/\S/) })],
29
+ { error: 'needs an approver: a name, or { group: "name" }' },
30
+ );
31
+
32
+ /** Why `by` may not decide the approval, naming who may. */
33
+ export const notApprover = ({ approver }: Pick<ApprovalState, "approver">, by: Principal): string =>
34
+ typeof approver === "string"
35
+ ? `${by.id} is not the approver; ${approverLabel(approver)} is`
36
+ : `${by.id} is not in ${approverLabel(approver)}`;
37
+
38
+ /** True when the two name the same approver: the same id, or the same group. */
39
+ export const sameApprover = (a: Approver, b: Approver): boolean =>
40
+ typeof a === "string" || typeof b === "string" ? a === b : a.group === b.group;
41
+
42
+ const notOps = "covers must be a list of operations";
43
+
44
+ /** `covers` as given (operations), parsed to their ids. */
45
+ export const Covers = z
46
+ .array(
47
+ z.object({ id: z.string() }, { error: notOps }).transform((op) => op.id),
48
+ { error: notOps },
49
+ )
50
+ .optional();
51
+
52
+ export interface ApprovalRequest {
53
+ /** Who must approve: a person's id, or `{ group }` for anyone in it. Phase 4 replaces this with a Cedar decision. */
54
+ approver: Approver;
55
+ /**
56
+ * The operations the approval stands for, so a policy can let their calls through once it
57
+ * is approved (`a.covers.includes(op.id)`). None unless given.
58
+ */
59
+ covers?: Op[];
60
+ links?: string[];
61
+ details?: string;
62
+ }
63
+
64
+ /**
65
+ * Checks a `ctx.approval` request as a workflow may give it, so the UI and the ledger get what
66
+ * the types say, and turns `covers` into op ids.
67
+ */
68
+ export const ApprovalRequestSchema = z.object(
69
+ {
70
+ approver: Approver,
71
+ covers: Covers,
72
+ links: z.array(z.string(), { error: "links must be a list of strings" }).optional(),
73
+ details: z.string({ error: "details must be a string" }).optional(),
74
+ },
75
+ { error: "needs a request: { approver, covers?, links?, details? }" },
76
+ );
77
+
78
+ /** One approval in a run, as the run sees it. Published as the run's "approvals" event. */
79
+ export interface ApprovalState extends Omit<ApprovalRequest, "covers"> {
80
+ id: string;
81
+ title: string;
82
+ /** Who asked: the workflow (`ctx.approval`), or the policy holding an operation call. */
83
+ requestedBy: "workflow" | "policy";
84
+ /**
85
+ * The operations the approval stands for, by id. A policy hold covers the call it held and
86
+ * any `covers` the policy added; a workflow's approval covers the `covers` it named, or none.
87
+ */
88
+ covers: string[];
89
+ /** For a policy request: the operation call held, by op id. */
90
+ op?: string;
91
+ /** For a policy request: the ledger `seq` of the call held, which its `op.called` record has. */
92
+ opSeq?: number;
93
+ /** For a policy request: the held call's input. */
94
+ input?: unknown;
95
+ status: "pending" | "approved" | "rejected";
96
+ requestedAt: number;
97
+ decidedBy?: string;
98
+ decidedAt?: number;
99
+ note?: string;
100
+ /** The `id` of the decision message the run decided with, when it had one. */
101
+ decidedWith?: string;
102
+ /**
103
+ * Messages that were ignored: decisions sent by someone who may not decide the approval,
104
+ * and messages that were not a decision. `by` is the sender, when the message named one, and
105
+ * `id` the decision message's own, when it had one.
106
+ */
107
+ refused: { by?: string; id?: string; at: number; reason: string }[];
108
+ }
109
+
110
+ export interface ApprovalResult {
111
+ approvedBy: string;
112
+ at: number;
113
+ note?: string;
114
+ }
115
+
116
+ /**
117
+ * How long `ctx.sleep` waits: until a time (an ISO 8601 date-time with an offset, such as
118
+ * `2026-10-07T09:00:00Z`, or epoch milliseconds), or for a duration whose parts add up. A time
119
+ * already past waits not at all.
120
+ */
121
+ export type SleepRequest =
122
+ | { until: string | number }
123
+ | { ms?: number; seconds?: number; minutes?: number; hours?: number; days?: number };
124
+
125
+ const time = "must be an ISO 8601 date-time with an offset, such as 2026-10-07T09:00:00Z, or epoch milliseconds";
126
+ const duration = z.number({ error: "must be a number" }).nonnegative("must not be negative").optional();
127
+
128
+ /** Checks a `ctx.sleep` request that names a time. */
129
+ export const SleepUntil = z.strictObject({
130
+ until: z.union([z.iso.datetime({ offset: true, error: time }), z.number({ error: time })], { error: time }),
131
+ }) satisfies z.ZodType<SleepRequest>;
132
+
133
+ /** Checks a `ctx.sleep` request that names a duration. */
134
+ export const SleepFor = z.strictObject({
135
+ ms: duration,
136
+ seconds: duration,
137
+ minutes: duration,
138
+ hours: duration,
139
+ days: duration,
140
+ }) satisfies z.ZodType<SleepRequest>;
141
+
142
+ /** Checks a `ctx.all` argument: a list of functions, each one member. */
143
+ export const AllMembers = z.array(
144
+ z.custom<() => unknown>((m) => typeof m === "function", "each member must be a function, such as () => ctx.…"),
145
+ { error: "needs a list of functions: ctx.all([() => …, () => …])" },
146
+ );
147
+
148
+ /**
149
+ * What a step is called, beside its operation: `ctx.ghost.post.create(input, { name: "Draft" })`,
150
+ * `ctx.sleep({ days: 1 }, { name: "Wait a day" })`. A string literal, or a top-level `const` of
151
+ * the workflow's file holding one, so the outline reads it. Without one, a call is named by its
152
+ * operation's id, a sleep `Sleep`; an approval by its title. Names are unique in a workflow, so a
153
+ * workflow that calls one operation twice names the calls.
154
+ */
155
+ export interface StepOptions {
156
+ name?: string;
157
+ }
158
+
159
+ type OpsOf<U extends readonly Use[]> = Extract<U[number], Op>;
160
+
161
+ type CtxOps<O extends Op> = {
162
+ readonly [V in O["vendor"]]: {
163
+ readonly [R in Extract<O, { vendor: V }>["resource"]]: {
164
+ readonly [N in Extract<O, { vendor: V; resource: R }>["name"]]: Extract<
165
+ O,
166
+ { vendor: V; resource: R; name: N }
167
+ > extends Op<any, any, any, infer I, infer Out>
168
+ ? (input: I, options?: StepOptions) => Promise<Out>
169
+ : never;
170
+ };
171
+ };
172
+ };
173
+
174
+ interface ApprovalCtx {
175
+ /** Waits, durably, until the approver (or someone in the approver group) approves. Throws `RejectedError` on a rejection. */
176
+ approval(title: string, request: ApprovalRequest): Promise<ApprovalResult>;
177
+ }
178
+
179
+ interface SleepCtx {
180
+ /** Waits, durably, until a time or for a duration. Survives worker restarts. */
181
+ sleep(request: SleepRequest, options?: StepOptions): Promise<void>;
182
+ }
183
+
184
+ interface AllCtx {
185
+ /**
186
+ * Runs the members one after another, in order, and returns their outputs in that order. It
187
+ * declares a fan-out for the ledger and the graph; it adds no concurrency. The first member
188
+ * that throws stops the group with its error, and later members never run. Not nestable.
189
+ */
190
+ all<const T extends readonly (() => Promise<unknown>)[]>(
191
+ members: T,
192
+ ): Promise<{ -readonly [K in keyof T]: Awaited<ReturnType<T[K]>> }>;
193
+ }
194
+
195
+ interface BaseCtx {
196
+ /** The current time, recorded so replays see the same value. Use instead of `Date.now()`. */
197
+ now(): Promise<number>;
198
+ readonly runId: string;
199
+ }
200
+
201
+ export type Ctx<U extends readonly Use[]> = BaseCtx &
202
+ CtxOps<OpsOf<U>> &
203
+ ("approval" extends U[number] ? ApprovalCtx : unknown) &
204
+ ("sleep" extends U[number] ? SleepCtx : unknown) &
205
+ ("all" extends U[number] ? AllCtx : unknown);
206
+
207
+ /**
208
+ * A schema as JSON Schema (draft 2020-12). By default it describes what a caller sends
209
+ * (`io: "input"`: defaults stay optional); `io: "output"` describes what parsing returns.
210
+ * What JSON Schema can't express is left open. Throws when zod can't convert the schema at all.
211
+ */
212
+ export function jsonSchemaOf(
213
+ schema: z.ZodType,
214
+ io: "input" | "output" = "input",
215
+ refs?: ReadonlyMap<unknown, string>,
216
+ ): Record<string, unknown> {
217
+ return z.toJSONSchema(schema, {
218
+ io,
219
+ target: "draft-2020-12",
220
+ unrepresentable: "any",
221
+ // A schema `refs` names, inside another, becomes a `$ref` to it (a resource type's state).
222
+ ...(refs?.size && {
223
+ override: ({ zodSchema, jsonSchema, path }) => {
224
+ const ref = path.length > 0 ? refs.get(zodSchema) : undefined;
225
+ if (ref === undefined) return;
226
+ for (const key of Object.keys(jsonSchema)) delete (jsonSchema as Record<string, unknown>)[key];
227
+ Object.assign(jsonSchema, { $ref: ref });
228
+ },
229
+ }),
230
+ });
231
+ }
232
+
233
+ export interface WorkflowDefinition<U extends readonly Use[] = readonly Use[], S extends z.ZodType = z.ZodType> {
234
+ readonly kind: "workflow";
235
+ readonly name: string;
236
+ readonly title?: string;
237
+ readonly trigger: "manual";
238
+ readonly input: S;
239
+ /** Every operation and built-in the workflow may call. `ctx` is built from this list and nothing else. */
240
+ readonly uses: U;
241
+ readonly run: (ctx: Ctx<U>, input: z.output<S>) => Promise<unknown>;
242
+ /**
243
+ * The file that defined it, absolute. Set by defineWorkflow from its call site: the outline is
244
+ * read from it, the worker holds each run to that outline, and the app shows it as the code view.
245
+ */
246
+ readonly file: string;
247
+ }
248
+
249
+ /** The ids of the operations a workflow `uses`, in the order declared. */
250
+ export const opIdsOf = (wf: Pick<WorkflowDefinition, "uses">): string[] => wf.uses.filter(isOp).map((op) => op.id);
251
+
252
+ export function defineWorkflow<const U extends readonly Use[], S extends z.ZodType>(
253
+ def: Omit<WorkflowDefinition<U, S>, "kind" | "file">,
254
+ ): WorkflowDefinition<U, S> {
255
+ if (!/^[a-z][a-z0-9-]*$/.test(def.name)) {
256
+ throw new Error(`Workflow name "${def.name}" must be lowercase letters, digits and dashes`);
257
+ }
258
+ const file = callerFile();
259
+ if (file === undefined) {
260
+ throw new Error(
261
+ `Workflow "${def.name}" was defined from code with no file (an eval, or a bundle without one): ` +
262
+ "its outline is read from the file, and a run is held to the outline",
263
+ );
264
+ }
265
+ return Object.freeze({ kind: "workflow", ...def, file });
266
+ }
267
+
268
+ /**
269
+ * The file of the code that called the function calling this one (`defineWorkflow`'s caller),
270
+ * from V8's call sites, as a path; undefined where there is none.
271
+ */
272
+ export function callerFile(): string | undefined {
273
+ // Frame 0 is this function, frame 1 its caller, frame 2 theirs: by index, since a bundle may hold all three.
274
+ return scriptPath(getCallSites(3)[2]?.scriptName);
275
+ }
276
+
277
+ /** A V8 script name (a `file:` URL for a module, a path for a script) as a path; undefined for none. */
278
+ export function scriptPath(script: string | undefined): string | undefined {
279
+ if (!script?.startsWith("file:")) return script || undefined;
280
+ try {
281
+ return fileURLToPath(script);
282
+ } catch {
283
+ return undefined;
284
+ }
285
+ }
@@ -0,0 +1,282 @@
1
+ import type { z } from "zod";
2
+ import { type LintProblem, readSource, type SourceFile } from "./ast.ts";
3
+ import { resolveConfig, type SanomaConfig } from "./config.ts";
4
+ import { type Builtin, jsonSchemaOf, opIdsOf, type Use } from "./define.ts";
5
+ import { lintWorkflow } from "./lint.ts";
6
+ import { type Effect, VENDOR, type VendorInfo } from "./op.ts";
7
+ import { type Outline, outlineWithSource } from "./outline.ts";
8
+ import { allowAll, policyOpOf } from "./policy.ts";
9
+ import { type ResourceFields, resourceTypesOf } from "./resource.ts";
10
+ import { type DataFiles, type DeclaredResource, readDataFiles, type ResourceProblem } from "./resources.ts";
11
+ import { BUILT_IN_WORKFLOWS } from "./shared.ts";
12
+
13
+ // `@sanoma-ai/workflows/describe`: what a UI renders from, apart from the main entry.
14
+ export { outlineWorkflow, type Outline, type OutlineNode, type Span } from "./outline.ts";
15
+ // The reader parses data files with oxc-parser too.
16
+ export type { LintProblem } from "./ast.ts";
17
+ export {
18
+ readDataFiles,
19
+ readResources,
20
+ type DataFiles,
21
+ type DeclaredResource,
22
+ type ResourceProblem,
23
+ } from "./resources.ts";
24
+ // The writer: setting a resource's fields in its data file, and rendering a new one.
25
+ export { formatOptions, renderDataFile, setFields, type DataFileContent } from "./writer.ts";
26
+ export { Reference, type DataValue } from "./datafile.ts";
27
+ export {
28
+ LOCKFILE,
29
+ readLockfile,
30
+ serializeLockfile,
31
+ writeLockfile,
32
+ diffLockfiles,
33
+ type Binding,
34
+ type Bindings,
35
+ type Rebind,
36
+ } from "./lockfile.ts";
37
+ // The apply workflow's plan and report, which the app renders.
38
+ export type { ApplyInput, ApplyPlan, ApplyReport, ApplyResult, PlanAnswer, PlanStep } from "./apply.ts";
39
+
40
+ /** What a workflow is, read from its definition: enough to draw a start form and show what it may call. */
41
+ export interface WorkflowEntry {
42
+ name: string;
43
+ title?: string;
44
+ trigger: "manual";
45
+ /**
46
+ * Set for a workflow the runtime adds itself, `drift` or `apply`, rather than the config: a label. It
47
+ * starts like any other.
48
+ */
49
+ builtin?: true;
50
+ /** The input, as JSON Schema (draft 2020-12), from the zod schema. */
51
+ input: Record<string, unknown>;
52
+ /** Operation ids the workflow may call, in the order declared. */
53
+ ops: string[];
54
+ /** Built-ins the workflow may call. */
55
+ builtins: Builtin[];
56
+ /** The file that defined it, absolute. */
57
+ file: string;
58
+ /** What its `run` calls, in order, read from its file (`outlineWorkflow`), which the worker holds its runs to. */
59
+ outline: Outline;
60
+ /**
61
+ * What is wrong in its file, by position: what `lintWorkflow` finds (syntax, an import a
62
+ * workflow may not make, `instanceof` against the runtime's error classes, what the outline
63
+ * cannot draw in each `run`), read the one way the outline is, so a check sees what the
64
+ * worker refuses; and, at 1:1, an outline error that is the file's (it cannot be read, or holds
65
+ * no workflow of this name). A file defining several workflows gives each its problems. Empty
66
+ * for the built-in `drift`, which the package's tests hold to the lint.
67
+ */
68
+ problems: LintProblem[];
69
+ /**
70
+ * The text the outline's spans index into: the workflow's file, with `\n` line endings.
71
+ * Absent only when the outline is `{ error }`. Whole files: a server may keep it from clients.
72
+ */
73
+ source?: string;
74
+ }
75
+
76
+ /** What an operation is, read from its connector: enough to badge it and show its contract. */
77
+ export interface OpEntry {
78
+ id: string;
79
+ vendor: string;
80
+ resource: string;
81
+ name: string;
82
+ effect: Effect;
83
+ idempotent: boolean;
84
+ description?: string;
85
+ /** How a scenario's steps name it: Cucumber expressions over its input's fields. */
86
+ phrases?: { given?: string; expect?: string };
87
+ /** What a call sends, as JSON Schema (`io: "input"`: fields with defaults are optional). */
88
+ input: Record<string, unknown>;
89
+ /** What a call returns once its schema has parsed the vendor's reply (`io: "output"`). */
90
+ output: Record<string, unknown>;
91
+ }
92
+
93
+ /** Who a vendor is, from its connector's `VendorInfo`: enough to name it, show its logo and link to its connector. */
94
+ export interface VendorEntry {
95
+ /** The connector's `title`, else the vendor's id. */
96
+ title: string;
97
+ /** The logo as `data:image/svg+xml` URLs, for an `<img>`: `dark` is for dark backgrounds. */
98
+ logo?: { src: string; dark?: string };
99
+ /** The connector's npm package name. */
100
+ package?: string;
101
+ /** The connector package's `homepage` from its package.json: where its code and README are, as an https URL. */
102
+ homepage?: string;
103
+ /** Whether the vendor's connector attaches a fake (`withSetup`), for sandbox runs. */
104
+ fake: boolean;
105
+ }
106
+
107
+ /** A resource type a connector declares with `defineResource`. */
108
+ export interface ResourceTypeEntry {
109
+ /** `<vendor>.<type>`, such as `github.repository`. */
110
+ id: string;
111
+ vendor: string;
112
+ type: string;
113
+ title: string;
114
+ /** How a declared resource's identity is made, such as `name` or `repository_id:pattern`; absent when it is found only by its binding. */
115
+ identity?: string;
116
+ /** Its flagged fields, by dotted path. */
117
+ fields: ResourceFields;
118
+ /**
119
+ * Its state, as a read returns it, as JSON Schema (`io: "output"`), with `$id`
120
+ * `sanoma:resource-type/<id>`: the operations' contracts `$ref` it instead of repeating it.
121
+ */
122
+ schema: Record<string, unknown>;
123
+ /** Its operations' ids, sorted: `<id>.import` and `<id>.read`, and for a writable type its change operations. */
124
+ ops: string[];
125
+ }
126
+
127
+ /**
128
+ * A plain-JSON description of what a config can do: its workflows, the operations they call,
129
+ * and whether a policy gates them. The app server builds it from the config and sends it to the
130
+ * browser, so the UI shows exactly what the worker enforces. Never written by hand.
131
+ */
132
+ export interface ConfigDescription {
133
+ appName: string;
134
+ /** The application version runs started now are stamped with. */
135
+ version: string;
136
+ workflows: WorkflowEntry[];
137
+ ops: OpEntry[];
138
+ /** Every vendor the operations are from, by its id (an `OpEntry`'s `vendor`). */
139
+ vendors: Record<string, VendorEntry>;
140
+ /** The resource types the connectors declare, by `id`. */
141
+ resourceTypes: ResourceTypeEntry[];
142
+ /**
143
+ * The resources the data files under `resources/` declare, by file and then in file order,
144
+ * less any with a problem and any that names one.
145
+ */
146
+ resources: DeclaredResource[];
147
+ /** Each binding in the lockfile (`LOCKFILE`) whose key no data file declares, as `readDataFiles` reports it. */
148
+ orphans: DataFiles["orphans"];
149
+ /** What is wrong in the data files (`readDataFiles`), each at its file, relative to the root, line and column. A workflow's are on its entry. */
150
+ problems: ResourceProblem[];
151
+ /** `defined` is false for `allowAll`. */
152
+ policy: { defined: boolean; version?: string };
153
+ }
154
+
155
+ /**
156
+ * Describes a config. Throws what `startWorker` would refuse (see `resolveConfig`), except missing
157
+ * credentials (`SanomaClient.credentials` gives their statuses) and what is wrong in a workflow's
158
+ * file, which the worker refuses when it reads the outline: that is data, on each workflow's
159
+ * entry, as the data files' problems are, `problems`, beside the resources read without any.
160
+ * Each workflow's file is read and parsed once, however many workflows it defines.
161
+ */
162
+ export function describeConfig(config: SanomaConfig): ConfigDescription {
163
+ const resolved = resolveConfig(config);
164
+ const vendors: Record<string, VendorEntry> = {};
165
+ // A vendor whose operations are split over several connectors is named by the first; its
166
+ // resource types are every connector's.
167
+ for (const connector of config.connectors) {
168
+ const { id, info } = connector[VENDOR];
169
+ vendors[id] ??= vendorEntry(id, info, resolved.fakes.has(id));
170
+ }
171
+ const resources = resourceTypesOf(config.connectors);
172
+ // Each resource type's state is described once, on its entry, and referenced from its operations.
173
+ const refs = new Map<z.ZodType, string>([...resources].map(([id, r]) => [r.schema, resourceTypeUri(id)]));
174
+ const resourceTypes: ResourceTypeEntry[] = [...resources]
175
+ .map(([id, { vendor, type, title, identity, fields, schema, ops }]) => ({
176
+ id,
177
+ vendor,
178
+ type,
179
+ title,
180
+ identity,
181
+ fields,
182
+ schema: { $id: resourceTypeUri(id), ...toJsonSchema(schema, `${id} state`, "output") },
183
+ ops: Object.keys(ops)
184
+ .map((name) => `${id}.${name}`)
185
+ .toSorted(),
186
+ }))
187
+ .toSorted((a, b) => a.id.localeCompare(b.id));
188
+
189
+ const ops: OpEntry[] = [...resolved.ops.values()].map((op) => ({
190
+ ...policyOpOf(op),
191
+ idempotent: op.idempotent,
192
+ description: op.description,
193
+ ...(op.phrases && { phrases: op.phrases }),
194
+ input: toJsonSchema(op.input, `${op.id} input`, "input", refs),
195
+ output: toJsonSchema(op.output, `${op.id} output`, "output", refs),
196
+ }));
197
+ ops.sort((a, b) => a.id.localeCompare(b.id));
198
+
199
+ const read = memoised(readSource);
200
+ const lint = memoised((file: SourceFile) => lintWorkflow(file));
201
+ const workflows: WorkflowEntry[] = [...resolved.workflows.values()].map((wf) => {
202
+ const file = read(wf.file);
203
+ const { outline, source, found } = outlineWithSource(wf, file);
204
+ // What the lint does not report: the file unread, or no workflow of this name in it.
205
+ const own = "error" in outline && !found && (typeof file === "string" || !file.problems.length);
206
+ const builtin = BUILT_IN_WORKFLOWS.has(wf.name);
207
+ const problems = builtin
208
+ ? []
209
+ : [
210
+ ...(typeof file === "string" ? [] : lint(file)),
211
+ ...(own ? [{ line: 1, column: 1, message: outline.error }] : []),
212
+ ];
213
+ return {
214
+ name: wf.name,
215
+ title: wf.title,
216
+ trigger: wf.trigger,
217
+ ...(builtin && { builtin: true as const }),
218
+ input: toJsonSchema(wf.input, `${wf.name} input`, "input"),
219
+ ops: opIdsOf(wf),
220
+ builtins: (wf.uses as Use[]).filter((u): u is Builtin => typeof u === "string"),
221
+ file: wf.file,
222
+ outline,
223
+ problems,
224
+ ...(source === undefined ? {} : { source }),
225
+ };
226
+ });
227
+ workflows.sort((a, b) => a.name.localeCompare(b.name));
228
+ const dataFiles = readDataFiles(resolved.root, config.connectors, resources);
229
+
230
+ return {
231
+ appName: resolved.appName,
232
+ version: resolved.version,
233
+ workflows,
234
+ ops,
235
+ vendors,
236
+ resourceTypes,
237
+ resources: dataFiles.resources,
238
+ orphans: dataFiles.orphans,
239
+ problems: dataFiles.problems,
240
+ policy: {
241
+ defined: resolved.policy !== allowAll,
242
+ ...(resolved.policy.version === undefined ? {} : { version: resolved.policy.version }),
243
+ },
244
+ };
245
+ }
246
+
247
+ /** `fn`, called once per argument for as long as the returned function lives. */
248
+ function memoised<A, R>(fn: (arg: A) => R): (arg: A) => R {
249
+ const seen = new Map<A, R>();
250
+ return (arg) => {
251
+ if (!seen.has(arg)) seen.set(arg, fn(arg));
252
+ return seen.get(arg)!;
253
+ };
254
+ }
255
+
256
+ const svgDataUrl = (svg: string) => `data:image/svg+xml,${encodeURIComponent(svg.trim())}`;
257
+
258
+ const vendorEntry = (id: string, info: VendorInfo | undefined, fake: boolean): VendorEntry => ({
259
+ title: info?.title ?? id,
260
+ ...(info?.logo && {
261
+ logo: { src: svgDataUrl(info.logo.svg), ...(info.logo.dark && { dark: svgDataUrl(info.logo.dark) }) },
262
+ }),
263
+ ...(info?.package && { package: info.package }),
264
+ ...(info?.homepage && { homepage: info.homepage }),
265
+ fake,
266
+ });
267
+
268
+ /** Where a resource type's state schema is, for a `$ref`. */
269
+ const resourceTypeUri = (id: string) => `sanoma:resource-type/${id}`;
270
+
271
+ function toJsonSchema(
272
+ schema: z.ZodType,
273
+ what: string,
274
+ io: "input" | "output",
275
+ refs?: ReadonlyMap<unknown, string>,
276
+ ): Record<string, unknown> {
277
+ try {
278
+ return jsonSchemaOf(schema, io, refs);
279
+ } catch (e) {
280
+ throw new Error(`Cannot describe ${what} as JSON Schema: ${(e as Error).message}`, { cause: e });
281
+ }
282
+ }