@olivervea/pipelines 0.0.0-stage → 2.0.0-beta.10

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 (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +467 -2
  3. package/manifest.schema.json +674 -0
  4. package/package.json +60 -4
  5. package/src/assets.d.ts +10 -0
  6. package/src/build/build.ts +127 -0
  7. package/src/build/cli.ts +52 -0
  8. package/src/build/elf.ts +35 -0
  9. package/src/build/hashes.ts +96 -0
  10. package/src/build/project.ts +107 -0
  11. package/src/builder/pipeline.ts +373 -0
  12. package/src/builder/refs.ts +26 -0
  13. package/src/core/brands.ts +15 -0
  14. package/src/core/config-refs.ts +77 -0
  15. package/src/core/config.ts +147 -0
  16. package/src/core/handlers.ts +50 -0
  17. package/src/core/models.ts +69 -0
  18. package/src/core/origin.ts +20 -0
  19. package/src/core/secrets.ts +13 -0
  20. package/src/core/steps.ts +138 -0
  21. package/src/core/types.ts +6 -0
  22. package/src/core/webhooks.ts +30 -0
  23. package/src/ctx/files.ts +161 -0
  24. package/src/ctx/index.ts +122 -0
  25. package/src/ctx/sh.ts +197 -0
  26. package/src/ctx/types.ts +116 -0
  27. package/src/describe/describe.ts +565 -0
  28. package/src/describe/manifest-schema.ts +179 -0
  29. package/src/describe/mapper-source.ts +238 -0
  30. package/src/describe/record.ts +107 -0
  31. package/src/dev/cli.ts +87 -0
  32. package/src/harness/env.ts +58 -0
  33. package/src/harness/execute.ts +97 -0
  34. package/src/harness/exit.ts +19 -0
  35. package/src/harness/index.ts +9 -0
  36. package/src/harness/main.ts +270 -0
  37. package/src/harness/result.ts +75 -0
  38. package/src/harness/validate.ts +92 -0
  39. package/src/index.ts +15 -0
  40. package/src/library/checks.ts +23 -0
  41. package/src/library/compose-deploy.ts +96 -0
  42. package/src/library/github-source.ts +100 -0
  43. package/src/library/helm-deploy-ssh.ts +358 -0
  44. package/src/library/helm-deploy.ts +271 -0
  45. package/src/library/http-health.ts +34 -0
  46. package/src/library/image-build.ts +418 -0
  47. package/src/library/index.ts +18 -0
  48. package/src/library/models.ts +79 -0
  49. package/src/library/ssh.ts +50 -0
  50. package/src/testing/index.ts +82 -0
  51. package/src/util/canonical.ts +19 -0
  52. package/src/util/handler-hash.ts +22 -0
  53. package/src/util/hash.ts +22 -0
  54. package/src/util/tar.ts +167 -0
@@ -0,0 +1,373 @@
1
+ import { PIPELINE } from "../core/brands.ts";
2
+ import type { AnyModel, Infer, InferInput, Plain } from "../core/models.ts";
3
+ import type { Secret } from "../core/secrets.ts";
4
+ import type { AnyStep, OutputValue, Step } from "../core/steps.ts";
5
+ import { isStep } from "../core/steps.ts";
6
+ import type { WebhookEvent, WebhookVerify } from "../core/webhooks.ts";
7
+ import type { IsUnion, OrError, Simplify } from "../core/types.ts";
8
+ import type { Optional } from "../core/models.ts";
9
+ import type { FailureContext } from "../library/models.ts";
10
+ import type { MapperResult, Ref } from "./refs.ts";
11
+ import { isConfigRef, narrowConfigRef, unionAt, type ConfigNode, type ConfigRef, type DiscriminatorOf, type VariantOf } from "../core/config.ts";
12
+
13
+ // ─── Availability (builder state, type level) ────────────────────────────────────────────────
14
+
15
+ interface Slot<M extends string | null = string | null, V = unknown, Maybe extends boolean = boolean> {
16
+ model: M;
17
+ value: V;
18
+ maybe: Maybe;
19
+ }
20
+ type AvailMap = { [step: string]: Slot };
21
+ export type InitialAvail<In extends AnyModel> = { input: Slot<In["name"], Infer<In>, false> };
22
+
23
+ /** The mapper argument: keyed by step name + `input`; maybe-available outputs are `Ref<T | undefined>`. */
24
+ export type InRefs<A extends AvailMap> = {
25
+ readonly [K in keyof A as A[K]["model"] extends null ? never : K]: Ref<
26
+ A[K]["maybe"] extends true ? A[K]["value"] | undefined : A[K]["value"]
27
+ >;
28
+ };
29
+
30
+ type ProducersOf<A extends AvailMap, MN> = { [K in keyof A]: A[K]["model"] extends MN ? K : never }[keyof A];
31
+ type DefiniteProducersOf<A extends AvailMap, MN> = {
32
+ [K in keyof A]: A[K]["model"] extends MN ? (A[K]["maybe"] extends false ? K : never) : never;
33
+ }[keyof A];
34
+
35
+ type EntryError<SN extends string, Key extends string, E, A extends AvailMap> =
36
+ E extends Optional<infer M>
37
+ ? [ProducersOf<A, M["name"]>] extends [never]
38
+ ? `❌ ${M["name"]} (input '${Key}' of step '${SN}') is not available at this stage`
39
+ : IsUnion<ProducersOf<A, M["name"]>> extends true
40
+ ? `❌ ${M["name"]} (input '${Key}' of step '${SN}') has several producers; use a mapper to pick one`
41
+ : never
42
+ : E extends AnyModel
43
+ ? [ProducersOf<A, E["name"]>] extends [never]
44
+ ? `❌ ${E["name"]} (input '${Key}' of step '${SN}') is not available at this stage`
45
+ : [DefiniteProducersOf<A, E["name"]>] extends [never]
46
+ ? `❌ ${E["name"]} (input '${Key}' of step '${SN}') is only maybe-available (produced inside a when block); declare it optional(${E["name"]})`
47
+ : IsUnion<ProducersOf<A, E["name"]>> extends true
48
+ ? `❌ ${E["name"]} (input '${Key}' of step '${SN}') has several producers; use a mapper to pick one`
49
+ : never
50
+ : `❌ input '${Key}' of step '${SN}' is a plain schema, not a model, so it cannot be matched by model; add a mapper`;
51
+
52
+ type DefaultMatchError<S extends AnyStep, A extends AvailMap> = {
53
+ [K in keyof S["input"] & string]: EntryError<S["name"], K, S["input"][K], A>;
54
+ }[keyof S["input"] & string] extends infer X extends string ? X : never; // `infer` forces display of the literal
55
+
56
+ type NameError<S extends AnyStep, A extends AvailMap, SiblingNames> =
57
+ // NB: no `string extends S["name"]` test here — it poisons inference of mixed stages;
58
+ // non-literal names are rejected in step() instead.
59
+ S["name"] extends "input" ? "❌ 'input' is reserved for the pipeline input and cannot be a step name"
60
+ : S["name"] extends keyof A ? `❌ duplicate step name '${S["name"]}' (already used in an earlier stage)`
61
+ : S["name"] extends SiblingNames ? `❌ duplicate step name '${S["name"]}' (used twice in this stage)`
62
+ : never;
63
+
64
+ // ─── Stage entries ───────────────────────────────────────────────────────────────────────────
65
+
66
+ export type MapperFn<S extends AnyStep, A extends AvailMap> = (from: InRefs<A>) => MapperResult<S>;
67
+ export type StageEntry<A extends AvailMap> = AnyStep | readonly [AnyStep, (from: InRefs<A>) => unknown];
68
+
69
+ type StepOfEntry<X> = X extends readonly [infer S extends AnyStep, unknown] ? S : X extends AnyStep ? X : never;
70
+ type SiblingNames<E extends readonly unknown[], K> = {
71
+ [J in keyof E]: J extends K ? never : StepOfEntry<E[J]>["name"];
72
+ }[number];
73
+
74
+ /**
75
+ * Per-entry check. Inference constraints:
76
+ * - the [step, mapper] branch must be exactly `readonly [S, (from) => …]` — wrapping S or the whole
77
+ * mapper type in a conditional/intersection breaks inference of E or contextual typing of `from`;
78
+ * so name errors of tuple entries are reported on the mapper's return type instead;
79
+ * - nothing may look at *neighbouring* entries through E except via NoInfer (sibling names only).
80
+ */
81
+ export type CheckEntry<E extends readonly unknown[], K, X, A extends AvailMap> =
82
+ X extends readonly [infer S extends AnyStep, unknown]
83
+ ? readonly [S, (from: InRefs<A>) => OrError<NameError<S, A, SiblingNames<E, K>>, MapperResult<S>>]
84
+ : X extends AnyStep
85
+ ? string extends X["name"] ? X : OrError<NameError<X, A, SiblingNames<E, K>> | DefaultMatchError<X, A>, X>
86
+ : "❌ a stage entry must be a step or a [step, mapper] pair";
87
+
88
+ // NB: the template must mention `E[K]` literally, or reverse-mapped inference of E fails.
89
+ export type CheckStage<E extends readonly unknown[], A extends AvailMap> = { [K in keyof E]: CheckEntry<NoInfer<E>, K, E[K], A> };
90
+
91
+ type OutputsOf<E extends readonly unknown[], Maybe extends boolean> = {
92
+ [S in StepOfEntry<E[number]> as S["name"]]: Slot<
93
+ S["output"] extends AnyModel ? S["output"]["name"] : null,
94
+ OutputValue<S>,
95
+ Maybe
96
+ >;
97
+ };
98
+ type MakeMaybe<X extends AvailMap> = { [K in keyof X]: Slot<X[K]["model"], X[K]["value"], true> };
99
+
100
+ // ─── select (branching on config) ────────────────────────────────────────────────────────────
101
+
102
+ type KeysOfUnion<T> = T extends unknown ? keyof T : never;
103
+ type SlotIn<B, K extends PropertyKey> = B extends { [P in K]: infer X extends Slot } ? X : never;
104
+ /** A case function: the pipeline so far, and the select's reference narrowed to the case's variant. */
105
+ export type SelectCase<In extends AnyModel, A extends AvailMap, R> = (p: Pipeline<In, A>, ref: R) => Pipeline<In, AvailMap>;
106
+ export type SelectCases<In extends AnyModel, A extends AvailMap, U, D extends string> = {
107
+ readonly [V in Extract<U[D & keyof U], string>]: SelectCase<In, A, ConfigNode<VariantOf<U, D, V>>>;
108
+ };
109
+ type CaseAvail<C> = { [K in keyof C]: C[K] extends (...args: never[]) => Pipeline<AnyModel, infer B> ? B : never }[keyof C];
110
+ /**
111
+ * After a select: a step every case has is as available as in its cases (one case always runs);
112
+ * a step only some cases have is maybe-available.
113
+ */
114
+ export type AfterSelect<A extends AvailMap, B> = Simplify<
115
+ A &
116
+ { [K in Exclude<keyof B, keyof A>]: Slot<SlotIn<B, K>["model"], SlotIn<B, K>["value"], SlotIn<B, K>["maybe"]> } &
117
+ { [K in Exclude<KeysOfUnion<B>, keyof B | keyof A>]: Slot<SlotIn<B, K>["model"], SlotIn<B, K>["value"], true> }
118
+ >;
119
+ type SelectOnError<U> = [DiscriminatorOf<U>] extends [never]
120
+ ? "❌ select needs a reference to a discriminated union in config (z.discriminatedUnion)"
121
+ : IsUnion<DiscriminatorOf<U>> extends true ? "❌ the union has more than one literal field; select can't tell which is the discriminator" : never;
122
+
123
+ // ─── Conditions / triggers ───────────────────────────────────────────────────────────────────
124
+
125
+ type IsLiteralLike<T> = [T] extends [string | number | boolean]
126
+ ? string extends T ? false : number extends T ? false : true
127
+ : false;
128
+ export type WhenCond<In extends AnyModel> = {
129
+ [K in keyof Plain<In> & string]?: Exclude<Plain<In>[K], undefined> extends infer L
130
+ ? IsLiteralLike<L> extends true
131
+ ? L | readonly L[]
132
+ : `❌ '${K}' is not a literal-like field (enum, boolean or literal); when cannot match on it`
133
+ : never;
134
+ };
135
+
136
+ /** The push event the service sends a push trigger mapper: exactly these fields, nothing more. */
137
+ export interface PushEvent {
138
+ readonly ref: string;
139
+ readonly after: string;
140
+ readonly repository: { readonly full_name: string };
141
+ }
142
+
143
+ type CheckHeader<V, Allowed extends string> = V extends `$SECRET:${infer X}`
144
+ ? X extends Allowed ? V : `❌ secret '${X}' is not declared in this poll trigger's secrets`
145
+ : V;
146
+
147
+ /** Rejects extra fields in a trigger mapping (arrow-function returns get no excess-property check). */
148
+ type Exact<R, T> = R & { [K in Exclude<keyof R, keyof T>]: `❌ '${K & string}' is not a field of the pipeline input` };
149
+
150
+ type StepNames<A extends AvailMap> = Exclude<keyof A, "input"> & string;
151
+
152
+ // ─── Runtime records (read by describe/harness) ──────────────────────────────────────────────
153
+
154
+ export type MapperFunction = (from: unknown) => unknown;
155
+ export interface StageStepNode {
156
+ readonly step: AnyStep;
157
+ readonly mapper?: MapperFunction;
158
+ }
159
+ export type StageNode =
160
+ | { readonly kind: "stage"; readonly steps: readonly StageStepNode[] }
161
+ | { readonly kind: "when"; readonly cond: Readonly<Record<string, unknown>>; readonly nodes: readonly StageNode[] }
162
+ | { readonly kind: "select"; readonly on: ConfigRef; readonly cases: Readonly<Record<string, readonly StageNode[]>> };
163
+
164
+ export type TriggerNode =
165
+ | { readonly kind: "push"; readonly name: string; readonly map: (event: unknown) => unknown }
166
+ | {
167
+ readonly kind: "poll";
168
+ readonly name: string;
169
+ readonly url: string;
170
+ readonly headers: Readonly<Record<string, string>>;
171
+ readonly secrets: readonly Secret[];
172
+ readonly valuePath: string;
173
+ readonly intervalSeconds: number;
174
+ readonly map: (value: string) => unknown;
175
+ }
176
+ | { readonly kind: "webhook"; readonly name: string; readonly verify: WebhookVerify; readonly map: (event: WebhookEvent) => unknown };
177
+
178
+ export interface FailureHandlerNode {
179
+ readonly step: AnyStep;
180
+ /** null = every step. */
181
+ readonly steps: readonly AnyStep[] | null;
182
+ }
183
+
184
+ export interface PipelineData {
185
+ readonly name: string;
186
+ readonly description: string | undefined;
187
+ readonly input: AnyModel;
188
+ readonly nodes: StageNode[];
189
+ readonly triggers: TriggerNode[];
190
+ readonly failureHandlers: FailureHandlerNode[];
191
+ /** Structural problems found while building (reported by describe). */
192
+ readonly problems: string[];
193
+ }
194
+
195
+ export function pipelineData(p: unknown): PipelineData {
196
+ const d = typeof p === "object" && p !== null ? (p as Record<symbol, unknown>)[PIPELINE] : undefined;
197
+ if (!d) throw new TypeError("not a pipeline (pipeline.ts must default-export pipeline({...})...)");
198
+ return d as PipelineData;
199
+ }
200
+ export function isPipeline(p: unknown): boolean {
201
+ return typeof p === "object" && p !== null && PIPELINE in p;
202
+ }
203
+
204
+ // ─── Pipeline builder ────────────────────────────────────────────────────────────────────────
205
+
206
+ // Builder calls record into shared data and hand back a fresh handle; a handle that was already
207
+ // extended no longer matches its type (it would claim fewer stages than the data has), so reuse is a problem.
208
+ const extended = new WeakSet<object>();
209
+ const ReusedProblem = "a pipeline value was extended twice: chain the calls (pipeline({...}).stage(a).stage(b)) instead of reusing an earlier value";
210
+
211
+ export interface PipelineDef<In extends AnyModel> {
212
+ /** Display name. */
213
+ name: string;
214
+ description?: string;
215
+ /** The pipeline input model (what triggers produce, what `when` matches on). */
216
+ input: In;
217
+ }
218
+
219
+ export class Pipeline<In extends AnyModel, A extends AvailMap> {
220
+ /** Phantom: the availability map accumulated so far. */
221
+ declare readonly __avail: A;
222
+ readonly [PIPELINE]: PipelineData;
223
+
224
+ constructor(def: PipelineDef<In>, shared?: { triggers: TriggerNode[]; failureHandlers: FailureHandlerNode[]; problems: string[] }) {
225
+ this[PIPELINE] = {
226
+ name: def.name,
227
+ description: def.description,
228
+ input: def.input,
229
+ nodes: [],
230
+ triggers: shared?.triggers ?? [],
231
+ failureHandlers: shared?.failureHandlers ?? [],
232
+ problems: shared?.problems ?? [],
233
+ };
234
+ }
235
+
236
+ private next(): never {
237
+ const data = this[PIPELINE];
238
+ if (extended.has(this) && !data.problems.includes(ReusedProblem)) data.problems.push(ReusedProblem);
239
+ extended.add(this);
240
+ const handle = Object.create(Object.getPrototypeOf(this)) as Pipeline<In, A>;
241
+ (handle as { [PIPELINE]: PipelineData })[PIPELINE] = data;
242
+ return handle as never;
243
+ }
244
+
245
+ /** Map a GitHub push to the pipeline input (`z.input` view). Runs as code in a `map-trigger` Job per event. */
246
+ onPush<R extends InferInput<In>>(map: (event: PushEvent) => Exact<R, InferInput<In>>): this {
247
+ this[PIPELINE].triggers.push({ kind: "push", name: "push", map: map as (event: unknown) => unknown });
248
+ return this.next();
249
+ }
250
+
251
+ /**
252
+ * Poll a URL; when the JSON value at `valuePath` changes, map it to the pipeline input.
253
+ * Header values may reference the poll's own declared secrets as `$SECRET:NAME`.
254
+ */
255
+ onPoll<R extends InferInput<In>, const Sec extends readonly Secret[] = [], const H extends Record<string, string> = {}>(
256
+ cfg: {
257
+ /** Trigger id, unique per pipeline (default "poll"). Used by `map-trigger <name>`. */
258
+ name?: string;
259
+ url: string;
260
+ secrets?: Sec;
261
+ headers?: { [K in keyof H]: CheckHeader<H[K], Sec[number]["name"]> };
262
+ valuePath: string;
263
+ intervalSeconds: number;
264
+ },
265
+ map: (value: string) => Exact<R, InferInput<In>>,
266
+ ): this {
267
+ this[PIPELINE].triggers.push({
268
+ kind: "poll",
269
+ name: cfg.name ?? "poll",
270
+ url: cfg.url,
271
+ headers: { ...((cfg.headers ?? {}) as Record<string, string>) },
272
+ secrets: [...(cfg.secrets ?? [])],
273
+ valuePath: cfg.valuePath,
274
+ intervalSeconds: cfg.intervalSeconds,
275
+ map: map as (value: string) => unknown,
276
+ });
277
+ return this.next();
278
+ }
279
+
280
+ /**
281
+ * Start a run from a delivery to `/api/hooks/<pipeline id>/<name>`, authenticated by `verify`. Map it to the
282
+ * pipeline input (`z.input` view), or return `null` to ignore it. Runs as code in a `map-trigger` Job per delivery.
283
+ */
284
+ onWebhook<R extends InferInput<In>>(
285
+ cfg: { name: string; verify: WebhookVerify },
286
+ map: (event: WebhookEvent) => Exact<R, InferInput<In>> | null,
287
+ ): this {
288
+ this[PIPELINE].triggers.push({ kind: "webhook", name: cfg.name, verify: cfg.verify, map: map as (event: WebhookEvent) => unknown });
289
+ return this.next();
290
+ }
291
+
292
+ /**
293
+ * Add a stage (its steps run in parallel). Entries: `step` (inputs matched by model) or
294
+ * `[step, from => ({ … })]` (explicit mapping). Every entry sees only what was available
295
+ * *before* this stage.
296
+ */
297
+ stage<const E extends readonly StageEntry<A>[]>(...entries: CheckStage<E, A>): Pipeline<In, Simplify<A & OutputsOf<E, false>>>;
298
+ stage(...entries: readonly unknown[]): unknown {
299
+ const data = this[PIPELINE];
300
+ const steps: StageStepNode[] = [];
301
+ entries.forEach((e, i) => {
302
+ if (Array.isArray(e) && e.length === 2 && isStep(e[0]) && typeof e[1] === "function") {
303
+ steps.push({ step: e[0], mapper: e[1] as MapperFunction });
304
+ } else if (isStep(e)) {
305
+ steps.push({ step: e });
306
+ } else if (typeof e === "function") {
307
+ data.problems.push(`stage entry ${i + 1} is a bare function; mappers are given as [step, from => ({ ... })] tuples`);
308
+ } else {
309
+ data.problems.push(`stage entry ${i + 1} is neither a step nor a [step, mapper] pair`);
310
+ }
311
+ });
312
+ if (entries.length === 0) data.problems.push("empty stage");
313
+ data.nodes.push({ kind: "stage", steps });
314
+ return this.next();
315
+ }
316
+
317
+ /** Declarative condition on literal-like pipeline-input fields. Outputs produced inside become maybe-available after. */
318
+ when<B extends AvailMap>(cond: WhenCond<In>, block: (p: Pipeline<In, A>) => Pipeline<In, B>): Pipeline<In, Simplify<A & MakeMaybe<Omit<B, keyof A>>>> {
319
+ const data = this[PIPELINE];
320
+ const inner = new Pipeline<In, A>({ name: data.name, input: data.input as In }, data);
321
+ const returned = block(inner);
322
+ if (!isPipeline(returned) || pipelineData(returned) !== inner[PIPELINE])
323
+ data.problems.push("a when block must return the pipeline it was given (p => p.stage(...))");
324
+ data.nodes.push({ kind: "when", cond: { ...(cond as object) }, nodes: inner[PIPELINE].nodes });
325
+ return this.next();
326
+ }
327
+
328
+ /**
329
+ * Branch on config: `on` references a discriminated union in a config entry, and each case gets the
330
+ * pipeline and `on` narrowed to its variant. Exactly one case runs, chosen by the run's config
331
+ * snapshot (not by run input; that is `when`). The same step name may appear in several cases.
332
+ */
333
+ select<U, const C extends SelectCases<In, A, U, DiscriminatorOf<U>>>(
334
+ on: ConfigRef<U> & ([SelectOnError<U>] extends [never] ? unknown : SelectOnError<U>),
335
+ cases: C & { readonly [K in Exclude<keyof C, keyof SelectCases<In, A, U, DiscriminatorOf<U>>>]: `❌ '${K & string}' is not a case of this select` },
336
+ ): Pipeline<In, AfterSelect<A, CaseAvail<C>>> {
337
+ const data = this[PIPELINE];
338
+ const recorded: Record<string, StageNode[]> = {};
339
+ const union = isConfigRef(on) ? unionAt(on) : undefined;
340
+ if (!union) data.problems.push("select: the first argument must reference a z.discriminatedUnion in a config entry");
341
+ for (const [value, fn] of Object.entries(cases as Record<string, unknown>)) {
342
+ if (typeof fn !== "function") {
343
+ data.problems.push(`select: case '${value}' is not a function`);
344
+ continue;
345
+ }
346
+ const inner = new Pipeline<In, A>({ name: data.name, input: data.input as In }, data);
347
+ const returned = (fn as (p: unknown, ref: unknown) => unknown)(inner, union ? narrowConfigRef(on, union.discriminator, value) : on);
348
+ if (!isPipeline(returned) || pipelineData(returned) !== inner[PIPELINE])
349
+ data.problems.push(`select: case '${value}' must return the pipeline it was given ((p, ref) => p.stage(...))`);
350
+ recorded[value] = inner[PIPELINE].nodes;
351
+ }
352
+ data.nodes.push({ kind: "select", on, cases: recorded });
353
+ return this.next();
354
+ }
355
+
356
+ /** Run `handler` (a step whose inputs are all FailureContext) when one of `steps` (default: any) fails. Untracked, best-effort. */
357
+ onFailure<S extends AnyStep>(
358
+ handler: S &
359
+ ([Exclude<S["input"][keyof S["input"]], typeof FailureContext>] extends [never]
360
+ ? unknown
361
+ : "❌ a failure handler's inputs must all be FailureContext"),
362
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
363
+ opts?: { steps?: readonly Step<StepNames<A>, any, any, any, any>[] },
364
+ ): this {
365
+ this[PIPELINE].failureHandlers.push({ step: handler as AnyStep, steps: opts?.steps ? [...(opts.steps as readonly AnyStep[])] : null });
366
+ return this.next();
367
+ }
368
+ }
369
+
370
+ /** Start a pipeline definition. `pipeline.ts` must default-export the result. */
371
+ export function pipeline<In extends AnyModel>(def: PipelineDef<In>): Pipeline<In, InitialAvail<In>> {
372
+ return new Pipeline(def) as never;
373
+ }
@@ -0,0 +1,26 @@
1
+ import type { RefBrand } from "../core/brands.ts";
2
+ import type { AnyStep, InferEntry } from "../core/steps.ts";
3
+ import type { Primitive } from "../core/types.ts";
4
+
5
+ /**
6
+ * A typed reference into the run pool — what mappers see. `symbol &` makes template strings, `+`
7
+ * and `===` against values compile errors (TS2731 / TS2469 / TS2367); only property access and
8
+ * literals remain. Fields of a maybe-undefined ref are themselves maybe-undefined.
9
+ */
10
+ export type Ref<T> = symbol & { readonly [RefBrand]: T } & RefFields<T>;
11
+ type RefFields<T> = [NonNullable<T>] extends [Primitive]
12
+ ? {}
13
+ : NonNullable<T> extends readonly unknown[]
14
+ ? {}
15
+ : { readonly [K in keyof NonNullable<T> & string]-?: Ref<NonNullable<T>[K] | (undefined extends T ? undefined : never)> };
16
+
17
+ /** What a mapper may put where a T is expected: a Ref<T>, a literal, or an object of those. */
18
+ export type Mappable<T> =
19
+ | Ref<T>
20
+ | (T extends Primitive
21
+ ? T
22
+ : T extends readonly unknown[]
23
+ ? never
24
+ : { [K in keyof T]: Mappable<T[K]> } & { readonly [RefBrand]?: never }); // a Ref of another model can't sneak in structurally
25
+
26
+ export type MapperResult<S extends AnyStep> = { [K in keyof S["input"]]: Mappable<InferEntry<S["input"][K]>> };
@@ -0,0 +1,15 @@
1
+ // Runtime markers. `Symbol.for` (not `Symbol()`) so that two copies of the SDK in one process — e.g.
2
+ // a dev runner and the user's installed package — still recognise each other's objects.
3
+ export const STEP = Symbol.for("olve.pipelines.step");
4
+ export const IMPLEMENTATION = Symbol.for("olve.pipelines.implementation");
5
+ export const ORIGIN = Symbol.for("olve.pipelines.origin");
6
+ export const SKIPPED = Symbol.for("olve.pipelines.skipped");
7
+ export const PIPELINE = Symbol.for("olve.pipelines.pipeline");
8
+ export const REF_PATH = Symbol.for("olve.pipelines.refPath");
9
+
10
+ // Type-only brands (never exist at runtime).
11
+ export declare const ModelBrand: unique symbol;
12
+ export declare const RefBrand: unique symbol;
13
+ export declare const StepBrand: unique symbol;
14
+ export declare const SkippedBrand: unique symbol;
15
+ export declare const ImplementationBrand: unique symbol;
@@ -0,0 +1,77 @@
1
+ // Config references inside library-step definitions: collected (the step receives their entries),
2
+ // recorded in the manifest as `{"$config": "<entry>.<path>"}`, and resolved from ctx.config at run time.
3
+ import { configRefPath, configRefTarget, isConfigRef, type AnyConfigEntry, type ConfigRef } from "./config.ts";
4
+
5
+ /** `T` with every config reference replaced by the type of its value. */
6
+ export type Resolved<T> = T extends ConfigRef<infer U>
7
+ ? U
8
+ : T extends readonly (infer E)[]
9
+ ? Resolved<E>[]
10
+ : T extends (...args: never[]) => unknown
11
+ ? T
12
+ : T extends object
13
+ ? { [K in keyof T]: Resolved<T[K]> }
14
+ : T;
15
+
16
+ /** Manifest key of a recorded config reference. */
17
+ export const CONFIG_REF_KEY = "$config";
18
+ /** Beside it on a reference narrowed by a `select` case: the case's condition, `<ref>.<discriminator>=<value>`. */
19
+ export const CONFIG_CASE_KEY = "$case";
20
+
21
+ function isPlain(v: unknown): v is Record<string, unknown> {
22
+ if (typeof v !== "object" || v === null || Array.isArray(v)) return false;
23
+ const proto = Object.getPrototypeOf(v);
24
+ return proto === Object.prototype || proto === null;
25
+ }
26
+
27
+ /** Every config entry referenced anywhere in `v` (plain objects and arrays only), by name. */
28
+ export function configRefsIn(v: unknown, into = new Map<string, AnyConfigEntry>()): AnyConfigEntry[] {
29
+ if (isConfigRef(v)) {
30
+ const { entry } = configRefTarget(v);
31
+ if (!into.has(entry.name)) into.set(entry.name, entry);
32
+ } else if (Array.isArray(v)) for (const x of v) configRefsIn(x, into);
33
+ else if (isPlain(v)) for (const x of Object.values(v)) configRefsIn(x, into);
34
+ return [...into.values()];
35
+ }
36
+
37
+ /** `v` as manifest JSON: references become `{"$config": "target.beta.host"}`; literal keys starting with `$` are reserved. */
38
+ export function recordConfigRefs(v: unknown): unknown {
39
+ if (isConfigRef(v)) {
40
+ const { path, case: kase } = configRefTarget(v);
41
+ for (const seg of path) if (seg === "" || seg.includes(".")) throw new Error(`config reference '${configRefPath(v)}': field names must not be empty or contain '.'`);
42
+ return { [CONFIG_REF_KEY]: configRefPath(v), ...(kase ? { [CONFIG_CASE_KEY]: kase } : {}) };
43
+ }
44
+ if (Array.isArray(v)) return v.map(recordConfigRefs);
45
+ if (isPlain(v)) {
46
+ const out: Record<string, unknown> = {};
47
+ for (const [k, x] of Object.entries(v)) {
48
+ if (k.startsWith("$")) throw new Error(`key '${k}': keys starting with '$' are reserved`);
49
+ out[k] = recordConfigRefs(x);
50
+ }
51
+ return out;
52
+ }
53
+ return v;
54
+ }
55
+
56
+ /** `v` with every reference replaced by its value in `config` (the step's parsed config entries). */
57
+ export function resolveConfigRefs<T>(v: T, config: Readonly<Record<string, unknown>>): Resolved<T> {
58
+ if (isConfigRef(v)) {
59
+ const { entry, path } = configRefTarget(v);
60
+ if (!Object.hasOwn(config, entry.name)) throw new Error(`config entry '${entry.name}' was not delivered to the step`);
61
+ let cur: unknown = config[entry.name];
62
+ for (const seg of path) cur = cur !== null && typeof cur === "object" ? (cur as Record<string, unknown>)[seg] : undefined;
63
+ return cur as Resolved<T>;
64
+ }
65
+ if (Array.isArray(v)) return v.map((x) => resolveConfigRefs(x, config)) as Resolved<T>;
66
+ if (isPlain(v)) {
67
+ const out: Record<string, unknown> = {};
68
+ for (const [k, x] of Object.entries(v)) out[k] = resolveConfigRefs(x, config);
69
+ return out as Resolved<T>;
70
+ }
71
+ return v as Resolved<T>;
72
+ }
73
+
74
+ /** Literal `v` (no reference inside), so define-time checks can run on it. */
75
+ export function isLiteral(v: unknown): boolean {
76
+ return configRefsIn(v).length === 0;
77
+ }
@@ -0,0 +1,147 @@
1
+ import { z } from "zod";
2
+ import type { Secret } from "./secrets.ts";
3
+
4
+ const CONFIG_REF = Symbol.for("olve.configRef");
5
+ declare const VALUE: unique symbol;
6
+
7
+ interface RefTarget {
8
+ readonly entry: AnyConfigEntry;
9
+ readonly path: readonly string[];
10
+ /** Set on a reference narrowed by a `select` case: `<ref>.<discriminator>=<value>`. */
11
+ readonly case?: string;
12
+ }
13
+
14
+ /**
15
+ * A typed reference to a config value (`TARGET.beta.host`), accepted in a step's settings in place of a
16
+ * literal. It is recorded in the manifest as `{"$config": "target.beta.host"}` and resolved from the
17
+ * run's config when the step runs; the step receives the entry automatically.
18
+ */
19
+ export interface ConfigRef<T = unknown> {
20
+ readonly [CONFIG_REF]: RefTarget;
21
+ readonly [VALUE]?: T;
22
+ }
23
+
24
+ /** A literal or a reference to a config value of the same type. */
25
+ export type Configurable<T> = T | ConfigRef<T>;
26
+
27
+ type Fields<T> = T extends readonly unknown[] ? never : T extends object ? T : never;
28
+ type Children<T> = [Fields<NonNullable<T>>] extends [never]
29
+ ? {}
30
+ : { readonly [K in keyof Fields<NonNullable<T>> & string]-?: ConfigNode<Fields<NonNullable<T>>[K] | (undefined extends T ? undefined : never)> };
31
+ /** A reference to a config value, and to each of its fields. */
32
+ export type ConfigNode<T> = ConfigRef<T> & Children<T>;
33
+
34
+ /** Fields of an entry that are its own properties; reach config fields with these names through `$`. */
35
+ export type ReservedConfigField = "kind" | "name" | "description" | "schema" | "$";
36
+
37
+ /** A named, typed value stored on the instance (one YAML document per pipeline), declared by the steps that use it. */
38
+ export interface ConfigEntryBase<N extends string = string, S extends z.ZodType = z.ZodType> {
39
+ readonly kind: "config";
40
+ readonly name: N;
41
+ readonly description: string;
42
+ readonly schema: S;
43
+ }
44
+ export type ConfigEntry<N extends string = string, S extends z.ZodType = z.ZodType> = ConfigEntryBase<N, S> &
45
+ ConfigRef<z.output<S>> &
46
+ Omit<Children<z.output<S>>, ReservedConfigField> & { readonly $: ConfigNode<z.output<S>> };
47
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
48
+ export type AnyConfigEntry = ConfigEntryBase<string, any>;
49
+
50
+ /** Config names are YAML keys: camelCase identifiers. */
51
+ export const CONFIG_NAME = /^[a-z][a-zA-Z0-9]*$/;
52
+
53
+ const NOT_REFS = new Set<PropertyKey>(["then", "toJSON", "constructor", "valueOf", "toString", "asymmetricMatch", "$$typeof", "nodeType"]);
54
+
55
+ function node(entry: AnyConfigEntry, path: readonly string[], kase?: string): unknown {
56
+ const target = Object.freeze({ [CONFIG_REF]: Object.freeze({ entry, path, ...(kase ? { case: kase } : {}) }) });
57
+ return new Proxy(target, {
58
+ get(t, prop) {
59
+ if (prop === CONFIG_REF) return t[CONFIG_REF];
60
+ if (typeof prop === "symbol" || NOT_REFS.has(prop)) return undefined;
61
+ return node(entry, [...path, prop], kase);
62
+ },
63
+ });
64
+ }
65
+
66
+ /** `ref` narrowed to the union member whose `discriminator` is `value` (a `select` case). */
67
+ export function narrowConfigRef(ref: ConfigRef, discriminator: string, value: string): unknown {
68
+ const { entry, path } = ref[CONFIG_REF];
69
+ return node(entry, path, `${[entry.name, ...path].join(".")}.${discriminator}=${value}`);
70
+ }
71
+
72
+ export function config<const N extends string, S extends z.ZodType>(name: N, description: string, schema: S): ConfigEntry<N, S> {
73
+ const ref = { entry: undefined as unknown as AnyConfigEntry, path: [] as readonly string[] };
74
+ const target = Object.freeze({ kind: "config" as const, name, description, schema, [CONFIG_REF]: ref });
75
+ const entry: unknown = new Proxy(target, {
76
+ get(t, prop) {
77
+ if (prop in t) return t[prop as keyof typeof t];
78
+ if (typeof prop === "symbol" || NOT_REFS.has(prop)) return undefined;
79
+ if (prop === "$") return node(entry as AnyConfigEntry, []);
80
+ return node(entry as AnyConfigEntry, [prop]);
81
+ },
82
+ });
83
+ ref.entry = entry as AnyConfigEntry;
84
+ Object.freeze(ref);
85
+ return entry as ConfigEntry<N, S>;
86
+ }
87
+
88
+ export function isConfigEntry(v: unknown): v is AnyConfigEntry {
89
+ return typeof v === "object" && v !== null && (v as { kind?: unknown }).kind === "config" && (v as { schema?: unknown }).schema instanceof z.ZodType;
90
+ }
91
+
92
+ export function isConfigRef(v: unknown): v is ConfigRef {
93
+ return typeof v === "object" && v !== null && (v as Record<symbol, unknown>)[CONFIG_REF] !== undefined;
94
+ }
95
+
96
+ /** The entry and field path a reference points at, and the `select` case it was narrowed by. */
97
+ export function configRefTarget(ref: ConfigRef): { entry: AnyConfigEntry; path: readonly string[]; case?: string } {
98
+ return ref[CONFIG_REF];
99
+ }
100
+
101
+ /** `target.beta.host` */
102
+ export function configRefPath(ref: ConfigRef): string {
103
+ const { entry, path } = configRefTarget(ref);
104
+ return [entry.name, ...path].join(".");
105
+ }
106
+
107
+ export const SECRET_REF_KIND = "secretRef";
108
+
109
+ /** A config field that names a secret: `{ secret: NAME }` in YAML. The value never enters the config. */
110
+ export function secretRef<const N extends string>(s: Secret<N>) {
111
+ return z.strictObject({ secret: z.literal(s.name) }).meta({ "x-olve-kind": SECRET_REF_KIND });
112
+ }
113
+
114
+ /** `ctx.config`: exactly the entries a step declares, after parsing (defaults applied). */
115
+ export type ConfigValues<C extends AnyConfigEntry> = { readonly [E in C as E["name"]]: z.output<E["schema"]> };
116
+
117
+ // ─── select: branching on a config union ────────────────────────────────────────────────────
118
+
119
+ type IsLiteralString<T> = [T] extends [string] ? (string extends T ? false : true) : false;
120
+ /** The fields of union `U` whose type is a string literal in every member: its discriminator(s). */
121
+ export type DiscriminatorOf<U> = { [K in keyof U & string]-?: IsLiteralString<U[K]> extends true ? K : never }[keyof U & string];
122
+ /** The member of `U` whose discriminator `D` is `V`. */
123
+ export type VariantOf<U, D extends string, V> = Extract<U, { readonly [K in D]: V }>;
124
+
125
+ function unwrap(schema: z.ZodType): z.ZodType {
126
+ let s = schema as unknown as { _zod: { def: { innerType?: z.ZodType } } };
127
+ for (let i = 0; i < 16 && s._zod.def.innerType; i++) s = s._zod.def.innerType as never;
128
+ return s as unknown as z.ZodType;
129
+ }
130
+
131
+ /**
132
+ * The discriminated union a reference points at: its discriminator and, per value, nothing else.
133
+ * Undefined when the referenced value is not a `z.discriminatedUnion`.
134
+ */
135
+ export function unionAt(ref: ConfigRef): { discriminator: string; values: string[] } | undefined {
136
+ const { entry, path } = configRefTarget(ref);
137
+ let s = unwrap(entry.schema as z.ZodType) as unknown as { _zod: { def: Record<string, unknown> } };
138
+ for (const seg of path) {
139
+ const shape = s._zod.def.shape as Record<string, z.ZodType> | undefined;
140
+ if (!shape || !(seg in shape)) return undefined;
141
+ s = unwrap(shape[seg]!) as never;
142
+ }
143
+ const def = s._zod.def as { discriminator?: string; options?: { _zod: { propValues?: Record<string, Set<unknown>> } }[] };
144
+ if (typeof def.discriminator !== "string" || !Array.isArray(def.options)) return undefined;
145
+ const values = def.options.flatMap((o) => [...(o._zod.propValues?.[def.discriminator!] ?? [])]).filter((v): v is string => typeof v === "string");
146
+ return { discriminator: def.discriminator, values };
147
+ }