@olivervea/pipelines 2.0.0-beta.3 → 2.0.0-beta.6

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.
@@ -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 by library steps 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
+ }
@@ -1,6 +1,6 @@
1
1
  import { IMPLEMENTATION, SKIPPED, type SkippedBrand } from "./brands.ts";
2
2
  import type { Ctx } from "../ctx/types.ts";
3
- import type { AnyStep, LibraryStep, OutputValue, SecretsOf, StepInput } from "./steps.ts";
3
+ import type { AnyStep, ConfigOf, LibraryStep, OutputValue, SecretsOf, StepInput } from "./steps.ts";
4
4
  import type { Awaitable } from "./types.ts";
5
5
 
6
6
  /** Returned by `ctx.skip(reason)`: the step produced no output on purpose. */
@@ -16,7 +16,7 @@ export function isSkipped(v: unknown): v is Skipped {
16
16
  }
17
17
 
18
18
  export type HandlerResult<S extends AnyStep> = OutputValue<S> | Skipped;
19
- export type Handler<S extends AnyStep> = (input: StepInput<S["input"]>, ctx: Ctx<SecretsOf<S>>) => Awaitable<HandlerResult<S>>;
19
+ export type Handler<S extends AnyStep> = (input: StepInput<S["input"]>, ctx: Ctx<SecretsOf<S>, ConfigOf<S>>) => Awaitable<HandlerResult<S>>;
20
20
 
21
21
  export interface Implementation<S extends AnyStep = AnyStep> {
22
22
  readonly step: S;
@@ -34,7 +34,7 @@ type NoExtraFields<R, T> = R extends Skipped | void
34
34
  */
35
35
  export function implement<S extends AnyStep, R extends HandlerResult<S>>(
36
36
  step: S & (S extends LibraryStep ? "❌ library steps already carry their implementation" : unknown),
37
- handler: (input: StepInput<S["input"]>, ctx: Ctx<SecretsOf<S>>) => Awaitable<R & NoExtraFields<R, OutputValue<S>>>,
37
+ handler: (input: StepInput<S["input"]>, ctx: Ctx<SecretsOf<S>, ConfigOf<S>>) => Awaitable<R & NoExtraFields<R, OutputValue<S>>>,
38
38
  ): Implementation<S> {
39
39
  return Object.freeze({ [IMPLEMENTATION]: true, step, handler: handler as unknown as Handler<S> }) as Implementation<S>;
40
40
  }
package/src/core/steps.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import type { z } from "zod";
2
2
  import { STEP, type StepBrand } from "./brands.ts";
3
3
  import type { AnyModel, Infer, Optional } from "./models.ts";
4
+ import type { AnyConfigEntry } from "./config.ts";
5
+ import { configRefsIn } from "./config-refs.ts";
4
6
  import type { Secret } from "./secrets.ts";
5
7
  import type { Simplify } from "./types.ts";
6
8
 
@@ -17,13 +19,14 @@ export type InferEntry<E> =
17
19
  export type StepInput<I> = Simplify<{ -readonly [K in keyof I]: InferEntry<I[K]> }>;
18
20
 
19
21
  /** `custom` steps have a handler file; the others are library steps implemented by the SDK. */
20
- export type StepKind = "custom" | "script" | "gitHubSource" | "imageBuild" | "helmDeployOverSsh";
22
+ export type StepKind = "custom" | "script" | "gitHubSource" | "imageBuild" | "helmDeployOverSsh" | "helmDeployInCluster" | "composeDeploy";
21
23
 
22
24
  export interface Step<
23
25
  N extends string = string,
24
26
  I extends InputSpec = InputSpec,
25
27
  O extends AnyModel | null = AnyModel | null,
26
28
  Sec extends string = string,
29
+ Cfg extends AnyConfigEntry = never,
27
30
  > {
28
31
  readonly [StepBrand]: true;
29
32
  readonly kind: StepKind;
@@ -33,8 +36,12 @@ export interface Step<
33
36
  readonly input: I;
34
37
  readonly output: O;
35
38
  readonly secrets: readonly Secret<Sec>[];
39
+ /** Config entries the step receives as `ctx.config`. */
40
+ readonly config: readonly Cfg[];
36
41
  readonly timeoutSeconds?: number;
37
42
  readonly retries?: number;
43
+ /** The step pod gets the instance's step ServiceAccount token (`Kubernetes:StepServiceAccount`). */
44
+ readonly clusterAccess?: boolean;
38
45
  /** Library steps carry their own implementation and config. */
39
46
  readonly builtin?: LibraryImplementation;
40
47
  }
@@ -47,10 +54,12 @@ export interface LibraryImplementation {
47
54
  }
48
55
  export type LibraryStep = { readonly builtin: LibraryImplementation };
49
56
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
50
- export type AnyStep = Step<string, any, AnyModel | null, any>;
57
+ export type AnyStep = Step<string, any, AnyModel | null, any, any>;
51
58
 
52
59
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
53
- export type SecretsOf<S> = S extends Step<any, any, any, infer Sec> ? Sec : never;
60
+ export type SecretsOf<S> = S extends Step<any, any, any, infer Sec, any> ? Sec : never;
61
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
62
+ export type ConfigOf<S> = S extends Step<any, any, any, any, infer C> ? C : never;
54
63
  export type OutputValue<S extends AnyStep> = S["output"] extends AnyModel ? Infer<S["output"]> : void;
55
64
 
56
65
  /** Step names must be literals (identity, duplicate detection and mapper keys depend on it). */
@@ -61,7 +70,7 @@ export const STEP_NAME = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
61
70
  /** Names that cannot be steps: `input` is the pipeline input's root in mappings. */
62
71
  export const RESERVED_STEP_NAMES: readonly string[] = ["input"];
63
72
 
64
- export interface StepDef<N extends string, I extends InputSpec, O extends AnyModel | null, Sec extends readonly Secret[]> {
73
+ export interface StepDef<N extends string, I extends InputSpec, O extends AnyModel | null, Sec extends readonly Secret[], Cfg extends readonly AnyConfigEntry[] = []> {
65
74
  name: LiteralName<N>;
66
75
  /** What the step does, shown in the UI. Required; may be empty. */
67
76
  description: string;
@@ -70,12 +79,23 @@ export interface StepDef<N extends string, I extends InputSpec, O extends AnyMod
70
79
  input?: I;
71
80
  output?: O;
72
81
  secrets?: Sec;
82
+ /** Config entries delivered as `ctx.config` (typed to exactly these). */
83
+ config?: Cfg;
73
84
  timeoutSeconds?: number;
74
85
  /** Automatic retries (an idempotency claim). Default 0. */
75
86
  retries?: number;
87
+ /**
88
+ * Ask for a Kubernetes API token in the step pod: the instance's step ServiceAccount
89
+ * (`Kubernetes:StepServiceAccount`), if it grants one. Default false (no token).
90
+ */
91
+ clusterAccess?: boolean;
76
92
  }
77
93
 
78
- export function makeStep(kind: StepKind, def: Record<string, unknown>, builtin?: LibraryImplementation): AnyStep {
94
+ /** `configFrom`: a library step's definition; the config entries it references are added to the step's `config`. */
95
+ export function makeStep(kind: StepKind, def: Record<string, unknown>, builtin?: LibraryImplementation, configFrom?: unknown): AnyStep {
96
+ const config = new Map<string, AnyConfigEntry>();
97
+ for (const e of (def.config as readonly AnyConfigEntry[] | undefined) ?? []) if (!config.has(e.name)) config.set(e.name, e);
98
+ configRefsIn(configFrom, config);
79
99
  const s = {
80
100
  [STEP]: true,
81
101
  kind,
@@ -85,8 +105,10 @@ export function makeStep(kind: StepKind, def: Record<string, unknown>, builtin?:
85
105
  input: Object.freeze({ ...((def.input as object | undefined) ?? {}) }),
86
106
  output: def.output ?? null,
87
107
  secrets: Object.freeze([...((def.secrets as readonly Secret[] | undefined) ?? [])]),
108
+ config: Object.freeze([...config.values()]),
88
109
  ...(def.timeoutSeconds !== undefined ? { timeoutSeconds: def.timeoutSeconds } : {}),
89
110
  ...(def.retries !== undefined ? { retries: def.retries } : {}),
111
+ ...(def.clusterAccess === true ? { clusterAccess: true } : {}),
90
112
  ...(builtin ? { builtin } : {}),
91
113
  };
92
114
  return Object.freeze(s) as unknown as AnyStep;
@@ -102,6 +124,7 @@ export function step<
102
124
  const I extends InputSpec = {},
103
125
  O extends AnyModel | null = null,
104
126
  const Sec extends readonly Secret[] = [],
105
- >(def: StepDef<N, I, O, Sec>): Step<N, I, O, Sec[number]["name"]> {
127
+ const Cfg extends readonly AnyConfigEntry[] = [],
128
+ >(def: StepDef<N, I, O, Sec, Cfg>): Step<N, I, O, Sec[number]["name"], Cfg[number]> {
106
129
  return makeStep("custom", def as never) as never;
107
130
  }
package/src/ctx/index.ts CHANGED
@@ -12,6 +12,8 @@ export interface CtxOptions {
12
12
  secrets: readonly Secret[];
13
13
  /** Secret values that take precedence over the environment (tests). */
14
14
  secretValues?: Readonly<Record<string, string>>;
15
+ /** Parsed config entries of the step (`ctx.config`). */
16
+ config?: Readonly<Record<string, unknown>>;
15
17
  paths: { root: string; workFiles: string; tmp: string };
16
18
  signal: AbortSignal;
17
19
  /** Where log lines go (default stderr). */
@@ -102,6 +104,7 @@ export function createCtx(o: CtxOptions): Ctx {
102
104
  const filesDeps = { run: o.run, workFiles: o.paths.workFiles, tmpDir: o.paths.tmp, root: o.paths.root, signal: o.signal, log };
103
105
  return {
104
106
  secrets: createSecrets(o.secrets, o.secretValues) as Ctx["secrets"],
107
+ config: Object.freeze({ ...o.config }) as Ctx["config"],
105
108
  sh,
106
109
  exec,
107
110
  readText: async (p) => readFileSync(resolve(p), "utf8"),
package/src/ctx/types.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { Skipped } from "../core/handlers.ts";
2
2
  import type { FileRef } from "../core/models.ts";
3
+ import type { AnyConfigEntry, ConfigValues } from "../core/config.ts";
3
4
  import type { DeclaredSecrets } from "../core/secrets.ts";
4
5
 
5
6
  /** Facts about the current execution, from the service (see README "Environment"). */
@@ -79,9 +80,11 @@ export interface OutputFileOptions {
79
80
  name?: string;
80
81
  }
81
82
 
82
- export interface Ctx<Sec extends string = string> {
83
+ export interface Ctx<Sec extends string = string, Cfg extends AnyConfigEntry = never> {
83
84
  /** Exactly the secrets the step declares, read from the environment. */
84
85
  readonly secrets: DeclaredSecrets<Sec>;
86
+ /** Exactly the config entries the step declares, from the run's config snapshot (defaults applied). */
87
+ readonly config: ConfigValues<Cfg>;
85
88
  readonly sh: Sh;
86
89
  /** Run a binary directly, without a shell (works in shell-less images). Rejects on non-zero exit. */
87
90
  exec(argv: readonly string[], opts?: ExecOptions): Promise<void>;
@@ -1,6 +1,9 @@
1
1
  // `describe`: evaluates the contracts (models + pipeline, never handlers) into the manifest and runs
2
2
  // every describe-time check. All problems are collected and reported together.
3
3
  import { z } from "zod";
4
+ import { CONFIG_NAME, isConfigEntry, SECRET_REF_KIND } from "../core/config.ts";
5
+ import { CONFIG_CASE_KEY, CONFIG_REF_KEY } from "../core/config-refs.ts";
6
+ import { configRefPath, configRefTarget, isConfigRef, unionAt } from "../core/config.ts";
4
7
  import { isModel, isOptional, MODEL_NAME, type AnyModel } from "../core/models.ts";
5
8
  import { SECRET_NAME, type Secret } from "../core/secrets.ts";
6
9
  import { RESERVED_STEP_NAMES, STEP_NAME, isStep, type AnyStep } from "../core/steps.ts";
@@ -127,14 +130,46 @@ export function describePipeline(pipeline: unknown, opts: DescribeOptions): Mani
127
130
  return s.name;
128
131
  };
129
132
 
133
+ // ── config entries ──
134
+ const configs = new Map<string, { description: string; required: boolean; schema: JsonSchema; key: string; secretRefs: string[] }>();
135
+ const useConfig = (e: unknown, at: string): string | undefined => {
136
+ if (!isConfigEntry(e)) return void problems.push(`${at}: config entries must be config("name", "description", schema)`);
137
+ if (!CONFIG_NAME.test(e.name)) problems.push(`${at}: config name '${e.name}' must match ${CONFIG_NAME}`);
138
+ let schema: JsonSchema;
139
+ try {
140
+ schema = z.toJSONSchema(e.schema, { io: "input", target: "draft-2020-12" }) as JsonSchema;
141
+ delete schema.$schema;
142
+ schema = canonicalize(schema) as JsonSchema;
143
+ } catch (err) {
144
+ problems.push(`${at}: config '${e.name}' is not representable as JSON Schema (${(err as Error).message})`);
145
+ return e.name;
146
+ }
147
+ const key = JSON.stringify([e.description, schema]);
148
+ const prev = configs.get(e.name);
149
+ if (!prev) configs.set(e.name, { description: e.description, required: !e.schema.safeParse(undefined).success, schema, key, secretRefs: secretRefsOf(schema) });
150
+ else if (prev.key !== key) problems.push(`two different config entries are named '${e.name}' (one description and schema per config name)`);
151
+ return e.name;
152
+ };
153
+
130
154
  // ── step identity ──
131
155
  const steps = new Map<string, AnyStep>();
132
- const registerStep = (s: AnyStep, where: string) => {
156
+ const placements = new Map<string, { step: AnyStep; cases: readonly CaseOf[] }[]>();
157
+ // Two places are mutually exclusive when they sit in different cases of the same select.
158
+ const exclusive = (a: readonly CaseOf[], b: readonly CaseOf[]) => a.some((x) => b.some((y) => y.select === x.select && y.value !== x.value));
159
+ const registerStep = (s: AnyStep, where: string, cases: readonly CaseOf[] = []) => {
133
160
  if (typeof s.name !== "string" || !STEP_NAME.test(s.name))
134
161
  problems.push(`${where}: step name '${String(s.name)}' must be lower-case kebab (${STEP_NAME})`);
135
162
  if (RESERVED.has(s.name)) problems.push(`${where}: '${s.name}' is reserved and cannot be a step name`);
136
- if (steps.has(s.name)) problems.push(`duplicate step name '${s.name}'`);
137
- steps.set(s.name, s);
163
+ const prev = placements.get(s.name) ?? [];
164
+ if (prev.some((p) => !exclusive(p.cases, cases))) problems.push(`duplicate step name '${s.name}'`);
165
+ else if (prev.length) {
166
+ if (prev.some((p) => p.step !== s && p.step.kind === "custom" && s.kind === "custom"))
167
+ problems.push(`step '${s.name}': two different custom steps share the name across select cases; handlers pair by name, so use one step or different names`);
168
+ if (prev.some((p) => (p.step.output?.name ?? null) !== (s.output?.name ?? null)))
169
+ problems.push(`step '${s.name}': its select cases must give it the same output model`);
170
+ }
171
+ placements.set(s.name, [...prev, { step: s, cases }]);
172
+ if (!steps.has(s.name)) steps.set(s.name, s);
138
173
  };
139
174
 
140
175
  const inputModel = data.input;
@@ -144,7 +179,7 @@ export function describePipeline(pipeline: unknown, opts: DescribeOptions): Mani
144
179
  const outputViewOf = (modelName: string | null): JsonSchema | undefined => (modelName ? models.get(modelName)?.schemas.output : undefined);
145
180
 
146
181
  // ── one step ──
147
- const describeStep = (node: StageStepNode, avail: Map<string, Avail>, failureHandler: boolean): StepDescriptorT => {
182
+ const describeStep = (node: StageStepNode, avail: Map<string, Avail>, failureHandler: boolean, cases: readonly CaseOf[] = []): StepDescriptorT => {
148
183
  const s = node.step;
149
184
  const at = `step '${s.name}'`;
150
185
  if (typeof s.image !== "string" || s.image.trim() === "") problems.push(`${at}: image must be a non-empty string`);
@@ -194,7 +229,26 @@ export function describePipeline(pipeline: unknown, opts: DescribeOptions): Mani
194
229
  }
195
230
  }
196
231
 
232
+ for (const key of Object.keys(s.input as Record<string, unknown>))
233
+ if (key.startsWith("$")) problems.push(`${at}: input '${key}': keys starting with '$' are reserved`);
197
234
  const secretNames = s.secrets.map((x) => useSecret(x, at)).sort();
235
+ const configNames = [...new Set((s.config ?? []).map((c) => useConfig(c, at)).filter((n): n is string => n !== undefined))].sort();
236
+ // A library step that references parts of an entry needs the secrets those parts name; any other
237
+ // step receiving an entry needs every secret the entry names.
238
+ let refs: { ref: string; case?: string }[] = [];
239
+ try {
240
+ refs = s.builtin ? recordedConfigRefs(s.builtin.manifestConfig()) : [];
241
+ } catch {
242
+ // reported with the library configuration below
243
+ }
244
+ for (const name of configNames) {
245
+ const into = refs.filter((r) => r.ref.split(".")[0] === name);
246
+ const needed = into.length === 0
247
+ ? configs.get(name)?.secretRefs ?? []
248
+ : [...new Set(into.flatMap((r) => secretRefsOf(subSchemas(configs.get(name)?.schema, r.ref.split(".").slice(1), r.case, name))))].sort();
249
+ for (const ref of needed)
250
+ if (!secretNames.includes(ref)) problems.push(`${at}: config '${name}' references secret '${ref}' (secretRef), so the step must declare it in secrets`);
251
+ }
198
252
  let handlerHash: string;
199
253
  let config: Record<string, unknown> | undefined;
200
254
  if (s.kind === "custom") {
@@ -208,6 +262,13 @@ export function describePipeline(pipeline: unknown, opts: DescribeOptions): Mani
208
262
  problems.push(`${at}: invalid ${s.kind} configuration (${(e as Error).message})`);
209
263
  config = {};
210
264
  }
265
+ for (const { ref, case: kase } of recordedConfigRefs(config)) {
266
+ const [entry, ...path] = ref.split(".") as [string, ...string[]];
267
+ if (kase !== undefined && !cases.some((c) => c.cond === kase))
268
+ problems.push(`${at}: config reference '${ref}' is narrowed to the select case '${kase}' and can only be used inside it`);
269
+ if (!configNames.includes(entry)) problems.push(`${at}: config reference '${ref}': the step does not receive config entry '${entry}'`);
270
+ else if (!resolveField(configs.get(entry)?.schema, path)) problems.push(`${at}: config reference '${ref}': config '${entry}' has no field '${path.join(".")}'`);
271
+ }
211
272
  handlerHash = libraryHandlerHash(s.kind, config, opts.harness);
212
273
  } else {
213
274
  problems.push(`${at}: library step of kind '${s.kind}' has no implementation`);
@@ -230,7 +291,10 @@ export function describePipeline(pipeline: unknown, opts: DescribeOptions): Mani
230
291
  ...(s.timeoutSeconds !== undefined ? { timeoutSeconds: s.timeoutSeconds } : {}),
231
292
  ...(s.retries !== undefined ? { retries: s.retries } : {}),
232
293
  ...(config !== undefined ? { config: config as Record<string, z.core.util.JSONType> } : {}),
294
+ ...(configNames.length ? { configEntries: configNames } : {}),
295
+ ...(s.clusterAccess === true ? { clusterAccess: true as const } : {}),
233
296
  };
297
+ if (s.clusterAccess !== undefined && typeof s.clusterAccess !== "boolean") problems.push(`${at}: clusterAccess must be a boolean`);
234
298
  if (s.output && !isModel(s.output)) problems.push(`${at}: output must be a model`);
235
299
  return out;
236
300
  };
@@ -275,17 +339,22 @@ export function describePipeline(pipeline: unknown, opts: DescribeOptions): Mani
275
339
  };
276
340
 
277
341
  // ── stages ──
278
- const walk = (nodes: readonly StageNode[], avail: Map<string, Avail>): Stage[] => {
342
+ let selects = 0;
343
+ const walk = (nodes: readonly StageNode[], avail: Map<string, Avail>, cases: readonly CaseOf[] = []): Stage[] => {
279
344
  const out: Stage[] = [];
280
345
  for (const n of nodes) {
346
+ if (n.kind === "select") {
347
+ out.push(describeSelect(n, avail, cases, ++selects));
348
+ continue;
349
+ }
281
350
  if (n.kind === "stage") {
282
351
  const descs = n.steps.map((ss) => {
283
352
  if (!isStep(ss.step)) {
284
353
  problems.push("a stage entry is not a step");
285
354
  return undefined;
286
355
  }
287
- registerStep(ss.step, "stage");
288
- return describeStep(ss, avail, false);
356
+ registerStep(ss.step, "stage", cases);
357
+ return describeStep(ss, avail, false, cases);
289
358
  }).filter((d): d is StepDescriptorT => d !== undefined);
290
359
  // Outputs become available only after the barrier (same-stage siblings can't see each other).
291
360
  for (const ss of n.steps) if (isStep(ss.step)) avail.set(ss.step.name, { model: ss.step.output?.name ?? null, maybe: false });
@@ -305,13 +374,43 @@ export function describePipeline(pipeline: unknown, opts: DescribeOptions): Mani
305
374
  when[field] = values;
306
375
  }
307
376
  const inner = new Map(avail);
308
- const stages = walk(n.nodes, inner);
377
+ const stages = walk(n.nodes, inner, cases);
309
378
  for (const [name, a] of inner) if (!avail.has(name)) avail.set(name, { model: a.model, maybe: true });
310
379
  out.push({ when, stages });
311
380
  }
312
381
  }
313
382
  return out;
314
383
  };
384
+ const describeSelect = (n: Extract<StageNode, { kind: "select" }>, avail: Map<string, Avail>, cases: readonly CaseOf[], id: number): Stage => {
385
+ if (!isConfigRef(n.on)) {
386
+ problems.push("select: the first argument must be a config reference");
387
+ return { select: "", discriminator: "", cases: {} };
388
+ }
389
+ const ref = configRefPath(n.on);
390
+ const { entry, case: kase } = configRefTarget(n.on);
391
+ useConfig(entry, `select '${ref}'`);
392
+ if (kase !== undefined && !cases.some((c) => c.cond === kase))
393
+ problems.push(`select '${ref}': the reference is narrowed to the case '${kase}' and can only be used inside it`);
394
+ const union = unionAt(n.on);
395
+ if (!union) problems.push(`select '${ref}': it must reference a z.discriminatedUnion in config '${entry.name}'`);
396
+ else {
397
+ for (const v of union.values) if (!Object.hasOwn(n.cases, v)) problems.push(`select '${ref}': no case for ${union.discriminator} '${v}'`);
398
+ for (const v of Object.keys(n.cases)) if (!union.values.includes(v)) problems.push(`select '${ref}': '${v}' is not a value of ${union.discriminator} (${union.values.join(", ")})`);
399
+ }
400
+ const discriminator = union?.discriminator ?? "";
401
+ const branches = Object.entries(n.cases).map(([value, nodes]) => {
402
+ const inner = new Map(avail);
403
+ const stages = walk(nodes, inner, [...cases, { select: id, value, cond: `${ref}.${discriminator}=${value}` }]);
404
+ return { value, inner, stages };
405
+ });
406
+ // A step every case has is as available as in its cases; one only some cases have is maybe-available.
407
+ const added = new Set(branches.flatMap((b) => [...b.inner.keys()].filter((k) => !avail.has(k))));
408
+ for (const name of added) {
409
+ const present = branches.map((b) => b.inner.get(name)).filter((a): a is Avail => a !== undefined);
410
+ avail.set(name, { model: present[0]!.model, maybe: present.length < branches.length || present.some((a) => a.maybe) });
411
+ }
412
+ return { select: ref, discriminator, cases: Object.fromEntries(branches.map((b) => [b.value, b.stages])) };
413
+ };
315
414
  const stages = walk(data.nodes, new Map([["input", { model: inputModel.name, maybe: false }]]));
316
415
 
317
416
  // ── failure handlers ──
@@ -369,6 +468,13 @@ export function describePipeline(pipeline: unknown, opts: DescribeOptions): Mani
369
468
  harness: opts.harness,
370
469
  models: Object.fromEntries([...models].map(([k, v]) => [k, v.schemas])),
371
470
  secrets: [...secrets].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)).map(([name, description]) => ({ name, description })),
471
+ ...(configs.size
472
+ ? {
473
+ config: [...configs]
474
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
475
+ .map(([name, c]) => ({ name, description: c.description, required: c.required, schema: c.schema })),
476
+ }
477
+ : {}),
372
478
  triggers,
373
479
  stages,
374
480
  failureHandlers,
@@ -381,6 +487,74 @@ export function describePipeline(pipeline: unknown, opts: DescribeOptions): Mani
381
487
  /** Canonical manifest text (what `describe` prints and the service hashes). */
382
488
  export const manifestText = (m: ManifestT): string => canonicalJson(m);
383
489
 
490
+ /**
491
+ * The schemas at `path` inside an entry's schema: every union branch that has the field, except where
492
+ * `kase` (`<entry>.<path>.<discriminator>=<value>`) narrows a union to one branch.
493
+ */
494
+ export function subSchemas(schema: unknown, path: readonly string[], kase: string | undefined, entry: string): unknown[] {
495
+ let narrow: { at: string; discriminator: string; value: string } | undefined;
496
+ if (kase) {
497
+ const eq = kase.lastIndexOf("=");
498
+ const left = kase.slice(0, eq);
499
+ const dot = left.lastIndexOf(".");
500
+ narrow = { at: left.slice(0, dot), discriminator: left.slice(dot + 1), value: kase.slice(eq + 1) };
501
+ }
502
+ const walk = (node: unknown, rest: readonly string[], here: string): unknown[] => {
503
+ if (node === null || typeof node !== "object") return [];
504
+ const o = node as JsonSchema;
505
+ const branches = (o.oneOf ?? o.anyOf) as JsonSchema[] | undefined;
506
+ if (Array.isArray(branches)) {
507
+ const chosen = narrow && narrow.at === here
508
+ ? branches.filter((b) => ((b.properties as Record<string, JsonSchema> | undefined)?.[narrow!.discriminator] as JsonSchema | undefined)?.const === narrow!.value)
509
+ : branches;
510
+ return chosen.flatMap((b) => walk(b, rest, here));
511
+ }
512
+ if (rest.length === 0) return [node];
513
+ const props = o.properties as Record<string, JsonSchema> | undefined;
514
+ if (props && Object.hasOwn(props, rest[0]!)) return walk(props[rest[0]!], rest.slice(1), `${here}.${rest[0]}`);
515
+ return [];
516
+ };
517
+ return walk(schema, path, entry);
518
+ }
519
+
520
+ /** Secret names of every secretRef inside a config entry's JSON Schema. */
521
+ export function secretRefsOf(schema: unknown): string[] {
522
+ const out = new Set<string>();
523
+ const walk = (v: unknown) => {
524
+ if (Array.isArray(v)) return v.forEach(walk);
525
+ if (v === null || typeof v !== "object") return;
526
+ const o = v as JsonSchema;
527
+ if (o["x-olve-kind"] === SECRET_REF_KIND) {
528
+ const name = (o.properties as { secret?: { const?: unknown } } | undefined)?.secret?.const;
529
+ if (typeof name === "string") out.add(name);
530
+ }
531
+ Object.values(o).forEach(walk);
532
+ };
533
+ walk(schema);
534
+ return [...out].sort();
535
+ }
536
+
537
+ /** The `{"$config": "<entry>.<path>"[, "$case": …]}` references in a library step's recorded configuration. */
538
+ export function recordedConfigRefs(v: unknown, out: { ref: string; case?: string }[] = []): { ref: string; case?: string }[] {
539
+ if (Array.isArray(v)) v.forEach((x) => recordedConfigRefs(x, out));
540
+ else if (v !== null && typeof v === "object") {
541
+ const o = v as Record<string, unknown>;
542
+ const keys = Object.keys(o).sort();
543
+ const isRef = typeof o[CONFIG_REF_KEY] === "string"
544
+ && (keys.length === 1 ? keys[0] === CONFIG_REF_KEY : keys.length === 2 && keys[0] === CONFIG_CASE_KEY && keys[1] === CONFIG_REF_KEY && typeof o[CONFIG_CASE_KEY] === "string");
545
+ if (isRef) out.push({ ref: o[CONFIG_REF_KEY] as string, ...(keys.length === 2 ? { case: o[CONFIG_CASE_KEY] as string } : {}) });
546
+ else Object.values(o).forEach((x) => recordedConfigRefs(x, out));
547
+ }
548
+ return out;
549
+ }
550
+
551
+ /** A select case a step sits in: which select (numbered in describe order), which value, and its condition text. */
552
+ interface CaseOf {
553
+ select: number;
554
+ value: string;
555
+ cond: string;
556
+ }
557
+
384
558
  function camel(name: string): string {
385
559
  return name.replace(/-([a-z0-9])/g, (_, c: string) => c.toUpperCase());
386
560
  }