@olivervea/pipelines 2.0.0-beta.2 → 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.
@@ -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";
@@ -20,7 +23,7 @@ export class DescribeError extends Error {
20
23
  }
21
24
 
22
25
  export interface DescribeOptions {
23
- /** Step names that have a handler file (`handlers/<name>.ts`). */
26
+ /** Step names that have a handler (a `handlers/*.ts` default-exporting `implement(step, …)`). */
24
27
  handlerNames: Iterable<string>;
25
28
  /** Step name → handler hash (from the build; includes the harness identity). */
26
29
  handlerHashes: Readonly<Record<string, string>>;
@@ -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,11 +229,30 @@ 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") {
201
- if (!handlerNames.has(s.name)) problems.push(`${at} has no handler (expected handlers/${s.name}.ts default-exporting implement(${camel(s.name)}, …))`);
255
+ if (!handlerNames.has(s.name)) problems.push(`${at} has no handler (add a handlers/*.ts default-exporting implement(${camel(s.name)}, …))`);
202
256
  handlerHash = opts.handlerHashes[s.name] ?? "";
203
257
  if (handlerNames.has(s.name) && !handlerHash) problems.push(`${at}: no handler hash was computed`);
204
258
  } else if (s.builtin) {
@@ -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 ──
@@ -329,11 +428,11 @@ export function describePipeline(pipeline: unknown, opts: DescribeOptions): Mani
329
428
  return { step: describeStep({ step: fh.step }, new Map(), true), steps: scope };
330
429
  }).filter((x) => x !== undefined);
331
430
 
332
- // ── handler files without a step ──
431
+ // ── handlers without a step ──
333
432
  for (const h of handlerNames) {
334
433
  const s = steps.get(h);
335
- if (!s) problems.push(`handlers/${h}.ts does not match any step of the pipeline`);
336
- else if (s.kind !== "custom") problems.push(`handlers/${h}.ts: '${h}' is a library step (${s.kind}) and must not have a handler file`);
434
+ if (!s) problems.push(`a handler implements step '${h}', which is not in the pipeline`);
435
+ else if (s.kind !== "custom") problems.push(`a handler implements '${h}', a library step (${s.kind}) that carries its own implementation`);
337
436
  }
338
437
 
339
438
  // ── triggers ──
@@ -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
  }
@@ -7,6 +7,7 @@ export const API_VERSION = "2.0";
7
7
  const stepName = z.string().regex(/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/).describe("Step name: lower-case kebab, 1-63 chars. The step's identity.");
8
8
  const modelName = z.string().regex(/^[A-Za-z][A-Za-z0-9_]*$/).describe("Model name (key of `models`).");
9
9
  const secretName = z.string().regex(/^[A-Z_][A-Z0-9_]*$/).describe("Secret name; also the env var name in the step container.");
10
+ const configName = z.string().regex(/^[a-z][a-zA-Z0-9]*$/).describe("Config entry name; a key of the pipeline's config document.");
10
11
  const hash = z.string().regex(/^sha256:[0-9a-f]{64}$/);
11
12
  const Json = z.json().meta({ id: "Json", description: "Any JSON value." });
12
13
  const jsonSchema = z.record(z.string(), z.unknown()).describe("A JSON Schema (draft 2020-12) object, canonical (sorted keys, no $schema).");
@@ -50,7 +51,7 @@ export const StepDescriptor = z
50
51
  .strictObject({
51
52
  name: stepName,
52
53
  description: z.string().describe("What the step does, shown in the UI (may be empty)."),
53
- kind: z.enum(["custom", "script", "gitHubSource", "imageBuild", "helmDeployOverSsh"]).describe("'custom' = handlers/<name>.ts; others are SDK library steps."),
54
+ kind: z.string().min(1).describe("'custom' = handlers/<name>.ts; others are SDK library steps. A label: the service accepts any kind."),
54
55
  image: z.string().min(1).describe("Container image the step runs in."),
55
56
  inputTypes: z.record(z.string(), InputType).describe("Declared input entries, keyed like input.json."),
56
57
  input: z.record(z.string(), Mapping).describe("How the service assembles input.json: one mapping per input key (same keys as inputTypes)."),
@@ -59,11 +60,25 @@ export const StepDescriptor = z
59
60
  handlerHash: hash.describe("Implementation identity: handler bundle (contract files excluded) + harness (bun + sdkHash); for library steps kind + config + harness."),
60
61
  timeoutSeconds: z.number().int().positive().optional(),
61
62
  retries: z.number().int().nonnegative().optional(),
62
- config: z.record(z.string(), Json).optional().describe("Library-step configuration (display only; already covered by handlerHash)."),
63
+ config: z
64
+ .record(z.string(), Json)
65
+ .optional()
66
+ .describe("Library-step configuration (display only; already covered by handlerHash). A config reference is recorded as {\"$config\": \"<entry>.<field>…\"}; the entry is in configEntries."),
67
+ configEntries: z
68
+ .array(configName)
69
+ .optional()
70
+ .describe("Config entries the step receives (sorted; omitted when none). The service delivers them in input.json under '$config'."),
71
+ clusterAccess: z
72
+ .literal(true)
73
+ .optional()
74
+ .describe("The step pod gets the instance's step ServiceAccount token (Kubernetes:StepServiceAccount), if the instance grants one. Omitted = no token."),
63
75
  })
64
76
  .meta({ id: "Step" });
65
77
 
66
- export type Stage = { steps: z.infer<typeof StepDescriptor>[] } | { when: Record<string, (string | number | boolean)[]>; stages: Stage[] };
78
+ export type Stage =
79
+ | { steps: z.infer<typeof StepDescriptor>[] }
80
+ | { when: Record<string, (string | number | boolean)[]>; stages: Stage[] }
81
+ | { select: string; discriminator: string; cases: Record<string, Stage[]> };
67
82
  export const Stage: z.ZodType<Stage> = z
68
83
  .union([
69
84
  z.strictObject({ steps: z.array(StepDescriptor).min(1).describe("Steps run in parallel; the stage is a barrier.") }),
@@ -73,6 +88,13 @@ export const Stage: z.ZodType<Stage> = z
73
88
  ),
74
89
  stages: z.array(z.lazy(() => Stage)),
75
90
  }),
91
+ z.strictObject({
92
+ select: z.string().min(1).describe("Config reference (`<entry>.<field>…`) to a discriminated union; the run's config snapshot picks the case."),
93
+ discriminator: z.string().min(1).describe("The union's literal field whose value names the case."),
94
+ cases: z
95
+ .record(z.string(), z.array(z.lazy(() => Stage)))
96
+ .describe("Discriminator value → stages. Exactly one case runs; a step name may repeat across cases, nowhere else."),
97
+ }),
76
98
  ])
77
99
  .meta({ id: "Stage" });
78
100
 
@@ -117,12 +139,23 @@ export const Manifest = z
117
139
  harness: z
118
140
  .strictObject({
119
141
  bun: z.string().describe("Bun runtime version compiled into the binary."),
120
- sdk: z.string().describe("@olve/pipelines package version."),
142
+ sdk: z.string().describe("@olivervea/pipelines package version."),
121
143
  sdkHash: hash.describe("Hash of the SDK runtime sources + zod version."),
122
144
  })
123
145
  .describe("Harness identity. Already folded into every handlerHash."),
124
146
  models: z.record(modelName, ModelSchemas).describe("Every model referenced by the pipeline (input, step inputs/outputs)."),
125
147
  secrets: z.array(z.strictObject({ name: secretName, description: z.string() })).describe("All declared secrets (steps + poll triggers), sorted by name."),
148
+ config: z
149
+ .array(
150
+ z.strictObject({
151
+ name: configName,
152
+ description: z.string(),
153
+ required: z.boolean().describe("False when the schema accepts a missing value (optional, or a default)."),
154
+ schema: jsonSchema.describe("z.input view: validates the config document's value for this entry (defaults listed)."),
155
+ }),
156
+ )
157
+ .optional()
158
+ .describe("Config entries used by steps, sorted by name (omitted when none). Fields of kind secretRef ({secret: NAME}) name a secret."),
126
159
  triggers: z.array(Trigger),
127
160
  stages: z.array(Stage),
128
161
  failureHandlers: z.array(
package/src/dev/cli.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env bun
2
2
  // olve-dev — run a pipeline from source through the same harness path as the compiled binary.
3
3
  // olve-dev describe <dir> [--sentinels]
4
- // olve-dev run <dir> <step> [--input-file f] [--olve-dir d] [--secret NAME=value]...
4
+ // olve-dev run <dir> <step> [--input-file f] [--config-file f] [--olve-dir d] [--secret NAME=value]...
5
5
  // olve-dev map-trigger <dir> <trigger> --event-file f [--olve-dir d]
6
6
  // $OLVE_DIR defaults to <dir>/.olve; outputs land in <dir>/.olve/work/.
7
- import { copyFileSync, mkdirSync } from "node:fs";
7
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
8
8
  import { join, resolve } from "node:path";
9
9
  import { parseArgs } from "node:util";
10
10
  import { runHarness, type HarnessConfig } from "../harness/main.ts";
@@ -13,11 +13,11 @@ import { handlerHashes } from "../build/hashes.ts";
13
13
  import { harnessInfo, loadProject, ProjectError } from "../build/project.ts";
14
14
 
15
15
  const USAGE = `usage: olve-dev describe <dir> [--sentinels]
16
- olve-dev run <dir> <step> [--input-file f] [--olve-dir d] [--secret NAME=value]...
16
+ olve-dev run <dir> <step> [--input-file f] [--config-file f] [--olve-dir d] [--secret NAME=value]...
17
17
  olve-dev map-trigger <dir> <trigger> --event-file f [--olve-dir d]`;
18
18
 
19
19
  export async function devConfig(dir: string, withHashes: boolean): Promise<HarnessConfig> {
20
- const project = loadProject(dir);
20
+ const project = await loadProject(dir);
21
21
  const mod = (await import(project.pipelineFile)) as { default?: unknown };
22
22
  if (!mod.default) throw new ProjectError(`${project.pipelineFile} has no default export (export default pipeline({...})...)`);
23
23
  const harness = harnessInfo();
@@ -38,6 +38,7 @@ export async function devMain(argv: string[]): Promise<number> {
38
38
  allowPositionals: true,
39
39
  options: {
40
40
  "input-file": { type: "string" },
41
+ "config-file": { type: "string" },
41
42
  "event-file": { type: "string" },
42
43
  "olve-dir": { type: "string" },
43
44
  secret: { type: "string", multiple: true },
@@ -68,6 +69,12 @@ export async function devMain(argv: string[]): Promise<number> {
68
69
  }
69
70
  const file = mode === "run" ? values["input-file"] : values["event-file"];
70
71
  if (file) copyFileSync(resolve(file), join(olveDir, "input.json"));
72
+ if (mode === "run" && values["config-file"]) {
73
+ const inputFile = join(olveDir, "input.json");
74
+ const input = (existsSync(inputFile) ? JSON.parse(readFileSync(inputFile, "utf8")) : {}) as Record<string, unknown>;
75
+ input.$config = JSON.parse(readFileSync(resolve(values["config-file"]), "utf8"));
76
+ writeFileSync(inputFile, JSON.stringify(input));
77
+ }
71
78
  else if (mode === "map-trigger") return console.error("--event-file is required"), EXIT.usage;
72
79
  return await runHarness(await devConfig(dir, false), [mode, name]);
73
80
  } catch (e) {
@@ -8,7 +8,7 @@ import type { Ctx, RunInfo } from "../ctx/types.ts";
8
8
  import { objectPath } from "../ctx/files.ts";
9
9
  import { sha256File } from "../util/hash.ts";
10
10
  import { EXIT } from "./exit.ts";
11
- import { validateInput, validateOutput } from "./validate.ts";
11
+ import { validateConfig, validateInput, validateOutput } from "./validate.ts";
12
12
 
13
13
  export type FailureKind = "handler" | "input" | "output";
14
14
  export type Envelope =
@@ -58,13 +58,16 @@ export async function executeStep(args: {
58
58
  workFiles: string;
59
59
  log: (line: string) => void;
60
60
  }): Promise<Execution> {
61
- const { step, ctx, log } = args;
61
+ const { step, log } = args;
62
62
  const input = validateInput(step, args.rawInput);
63
- if (!input.ok) {
64
- const message = `step '${step.name}': input does not match the step's inputs:\n ${input.issues.join("\n ")}`;
63
+ const config = validateConfig(step, args.rawInput);
64
+ if (!input.ok || !config.ok) {
65
+ const issues = [...(input.ok ? [] : input.issues), ...(config.ok ? [] : config.issues)];
66
+ const message = `step '${step.name}': input does not match the step's inputs:\n ${issues.join("\n ")}`;
65
67
  log(message);
66
68
  return { envelope: { status: "failed", error: { kind: "input", message } }, exitCode: EXIT.inputInvalid };
67
69
  }
70
+ const ctx: Ctx = { ...args.ctx, config: Object.freeze(config.value) as Ctx["config"] };
68
71
 
69
72
  let value: unknown;
70
73
  try {
@@ -1,4 +1,4 @@
1
- // @olve/pipelines/harness — the runtime the build tool compiles into the step binary.
1
+ // @olivervea/pipelines/harness — the runtime the build tool compiles into the step binary.
2
2
  export { runHarness, defaultIO, allSteps, resolveStepFunction, type HarnessConfig, type HandlerRegistry, type HarnessIO } from "./main.ts";
3
3
  export { EXIT } from "./exit.ts";
4
4
  export { ENV, paths, runInfoFromEnv } from "./env.ts";
@@ -5,6 +5,7 @@
5
5
  import { mkdirSync, readFileSync } from "node:fs";
6
6
  import { join } from "node:path";
7
7
  import { isImplementation } from "../core/handlers.ts";
8
+ import { configRefPath, isConfigRef, unionAt } from "../core/config.ts";
8
9
  import type { AnyStep } from "../core/steps.ts";
9
10
  import type { WebhookEvent } from "../core/webhooks.ts";
10
11
  import { pipelineData, type PipelineData, type StageNode } from "../builder/pipeline.ts";
@@ -46,14 +47,43 @@ const USAGE = "usage: step describe [--sentinels] | step run <step> | step map-t
46
47
 
47
48
  export function allSteps(data: PipelineData): AnyStep[] {
48
49
  const out: AnyStep[] = [];
49
- const walk = (nodes: readonly StageNode[]) => {
50
- for (const n of nodes) n.kind === "stage" ? out.push(...n.steps.map((s) => s.step)) : walk(n.nodes);
50
+ for (const { step } of stepsWithCases(data)) if (!out.includes(step)) out.push(step);
51
+ return out;
52
+ }
53
+
54
+ /** Every step with the `select` case conditions (`<ref>.<discriminator>=<value>`) it sits in. */
55
+ export function stepsWithCases(data: PipelineData): { step: AnyStep; cases: string[] }[] {
56
+ const out: { step: AnyStep; cases: string[] }[] = [];
57
+ const walk = (nodes: readonly StageNode[], cases: string[]) => {
58
+ for (const n of nodes) {
59
+ if (n.kind === "stage") out.push(...n.steps.map((s) => ({ step: s.step, cases })));
60
+ else if (n.kind === "when") walk(n.nodes, cases);
61
+ else {
62
+ const union = isConfigRef(n.on) ? unionAt(n.on) : undefined;
63
+ for (const [value, inner] of Object.entries(n.cases))
64
+ walk(inner, union ? [...cases, `${configRefPath(n.on)}.${union.discriminator}=${value}`] : cases);
65
+ }
66
+ }
51
67
  };
52
- walk(data.nodes);
53
- for (const fh of data.failureHandlers) if (!out.includes(fh.step)) out.push(fh.step);
68
+ walk(data.nodes, []);
69
+ for (const fh of data.failureHandlers) out.push({ step: fh.step, cases: [] });
54
70
  return out;
55
71
  }
56
72
 
73
+ /** The step `run <name>` executes: the only one so named, or the one in the cases the service selected (`OLVE_CASES`). */
74
+ export function findStep(data: PipelineData, name: string, selected: readonly string[]): AnyStep | string {
75
+ const candidates = stepsWithCases(data).filter((c) => c.step.name === name);
76
+ const distinct = candidates.filter((c, i) => candidates.findIndex((d) => d.step === c.step) === i);
77
+ if (distinct.length === 0) return `unknown step '${name}'`;
78
+ if (distinct.length === 1) return distinct[0]!.step;
79
+ const chosen = distinct.filter((c) => c.cases.every((k) => selected.includes(k)));
80
+ if (chosen.length === 1) return chosen[0]!.step;
81
+ return `step '${name}' is in several select cases; ${ENV_CASES} must name the selected one (got ${JSON.stringify(selected)})`;
82
+ }
83
+
84
+ /** The select case conditions of the step being run (JSON array), set by the service for steps inside a select. */
85
+ export const ENV_CASES = "OLVE_CASES";
86
+
57
87
  /** Installs SIGTERM/SIGINT handlers that abort the returned signal (PID 1 ignores signals without them). */
58
88
  export function cancellation(): { signal: AbortSignal; dispose(): void } {
59
89
  const ac = new AbortController();
@@ -77,17 +107,17 @@ export async function resolveStepFunction(step: AnyStep, handlers: HandlerRegist
77
107
  return step.builtin.run as StepFunction;
78
108
  }
79
109
  const load = handlers[step.name];
80
- if (!load) return `step '${step.name}' has no handler (expected handlers/${step.name}.ts)`;
110
+ if (!load) return `step '${step.name}' has no handler`;
81
111
  let mod: { default: unknown };
82
112
  try {
83
113
  mod = await load();
84
114
  } catch (e) {
85
- return `handlers/${step.name}.ts failed to load: ${(e as Error)?.stack ?? String(e)}`;
115
+ return `the handler of step '${step.name}' failed to load: ${(e as Error)?.stack ?? String(e)}`;
86
116
  }
87
117
  const impl = mod.default;
88
- if (!isImplementation(impl)) return `handlers/${step.name}.ts must default-export implement(<step '${step.name}'>, …)`;
118
+ if (!isImplementation(impl)) return `the handler of step '${step.name}' must default-export implement(<step '${step.name}'>, …)`;
89
119
  if (impl.step !== step)
90
- return `handlers/${step.name}.ts implements step '${(impl.step as AnyStep)?.name}', not '${step.name}' (the default export must be implement(<the '${step.name}' step from pipeline.ts>, …))`;
120
+ return `the handler of step '${step.name}' implements a different step object (it must be implement(<the '${step.name}' step from pipeline.ts>, …))`;
91
121
  return impl.handler as unknown as StepFunction;
92
122
  }
93
123
 
@@ -136,8 +166,8 @@ async function describeMode(cfg: HarnessConfig, args: string[], io: HarnessIO):
136
166
  async function runMode(cfg: HarnessConfig, name: string | undefined, io: HarnessIO): Promise<number> {
137
167
  if (!name) return io.stderr(USAGE), EXIT.usage;
138
168
  const data = pipelineData(cfg.pipeline);
139
- const step = allSteps(data).find((s) => s.name === name);
140
- if (!step) return io.stderr(`unknown step '${name}'`), EXIT.usage;
169
+ const step = findStep(data, name, selectedCases());
170
+ if (typeof step === "string") return io.stderr(step), EXIT.usage;
141
171
  const fn = await resolveStepFunction(step, cfg.handlers);
142
172
  if (typeof fn === "string") return io.stderr(fn), EXIT.configInvalid;
143
173
 
@@ -230,3 +260,12 @@ export async function runHarness(cfg: HarnessConfig, argv: readonly string[], io
230
260
  }
231
261
 
232
262
  export { formatIssues, formatLog };
263
+
264
+ function selectedCases(): string[] {
265
+ try {
266
+ const v = JSON.parse(process.env[ENV_CASES] ?? "[]") as unknown;
267
+ return Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : [];
268
+ } catch {
269
+ return [];
270
+ }
271
+ }
@@ -44,13 +44,31 @@ function entrySchema(e: unknown): z.ZodType {
44
44
  return e as z.ZodType;
45
45
  }
46
46
 
47
+ /** Reserved input.json key holding the step's config entries (written by the service). */
48
+ export const CONFIG_KEY = "$config";
49
+
50
+ /** Parses `$config` with each declared entry's schema (defaults applied). Undeclared entries are ignored. */
51
+ export function validateConfig(step: AnyStep, raw: unknown): Validation<Record<string, unknown>> {
52
+ const given = isPlainObject(raw) ? raw[CONFIG_KEY] : undefined;
53
+ if (given !== undefined && !isPlainObject(given)) return { ok: false, issues: [`input.${CONFIG_KEY} must be an object keyed by config entry names`] };
54
+ const issues: string[] = [];
55
+ const out: Record<string, unknown> = {};
56
+ for (const e of step.config ?? []) {
57
+ const value = given?.[e.name];
58
+ const r = e.schema.safeParse(value);
59
+ if (!r.success) issues.push(...formatIssues(r.error, `config.${e.name}`).map((i) => (value === undefined ? `${i} (config entry '${e.name}' is not set)` : i)));
60
+ else if (r.data !== undefined) out[e.name] = r.data;
61
+ }
62
+ return issues.length ? { ok: false, issues } : { ok: true, value: out };
63
+ }
64
+
47
65
  /** Validates input.json against the step's input entries. Unknown top-level keys are rejected; nested values are parsed (unknown nested keys stripped). */
48
66
  export function validateInput(step: AnyStep, raw: unknown): Validation<Record<string, unknown>> {
49
67
  if (!isPlainObject(raw)) return { ok: false, issues: ["input.json must be a JSON object keyed by the step's input names"] };
50
68
  const issues: string[] = [];
51
69
  const out: Record<string, unknown> = {};
52
70
  const declared = step.input as Record<string, unknown>;
53
- for (const k of Object.keys(raw)) if (!Object.hasOwn(declared, k)) issues.push(`input.${k}: not an input of step '${step.name}'`);
71
+ for (const k of Object.keys(raw)) if (k !== CONFIG_KEY && !Object.hasOwn(declared, k)) issues.push(`input.${k}: not an input of step '${step.name}'`);
54
72
  for (const [k, e] of Object.entries(declared)) {
55
73
  if (!Object.hasOwn(raw, k)) {
56
74
  if (isOptional(e)) continue;
package/src/index.ts CHANGED
@@ -1,12 +1,13 @@
1
1
  /// <reference path="./assets.d.ts" />
2
- // @olve/pipelines — authoring API (contracts, handlers, pipeline builder).
2
+ // @olivervea/pipelines — authoring API (contracts, handlers, pipeline builder).
3
3
  export { z } from "zod";
4
4
  export { model, fileRef, optional, type Model, type AnyModel, type Infer, type InferInput, type Plain, type FileRef, type Optional } from "./core/models.ts";
5
5
  export { secret, type Secret, type DeclaredSecrets } from "./core/secrets.ts";
6
+ export { config, secretRef, type ConfigEntry, type AnyConfigEntry, type ConfigValues, type ConfigRef, type ConfigNode, type Configurable, type DiscriminatorOf, type VariantOf } from "./core/config.ts";
6
7
  export { hmacSha256, githubSignature, bearer, type WebhookVerify, type WebhookEvent } from "./core/webhooks.ts";
7
- export { step, type Step, type AnyStep, type StepDef, type StepInput, type StepKind, type InputEntry, type InputSpec, type LibraryStep, type OutputValue } from "./core/steps.ts";
8
+ export { step, type Step, type AnyStep, type StepDef, type StepInput, type StepKind, type ConfigOf, type InputEntry, type InputSpec, type LibraryStep, type OutputValue } from "./core/steps.ts";
8
9
  export { implement, type Implementation, type Handler, type HandlerResult, type Skipped } from "./core/handlers.ts";
9
10
  export type { Ctx, RunInfo, Sh, ShOptions, ExecOptions, Http, HttpInit, RetryOptions, OutputFileOptions } from "./ctx/types.ts";
10
11
  export type { Ref, Mappable, MapperResult } from "./builder/refs.ts";
11
12
  export { pipeline, Pipeline, type PipelineDef, type InRefs, type InitialAvail, type WhenCond, type PushEvent, type StageEntry, type CheckStage, type MapperFn } from "./builder/pipeline.ts";
12
- export { steps, Source, BuiltImage, HelmRelease, FailureContext, type RunValue, type ScriptDef, type GitHubSourceDef, type ImageBuildDef, type HelmDeployOverSshDef, httpHealthCheck, type HttpHealthCheckOptions } from "./library/index.ts";
13
+ export { steps, Source, BuiltImage, PushedImage, HelmRelease, FailureContext, type RunValue, type ScriptDef, type GitHubSourceDef, type ImageBuildDef, type ImageBuildTarballDef, type ImageBuildPushDef, type RegistryAuth, type HelmDeployOverSshDef, helmTarget, deployTarget, type ComposeTargetValue, type HelmDeployInClusterDef, type HelmDeploySshTargetDef, type HelmTargetValue, type InClusterTargetValue, type SshTargetValue, registryTag, registryDigest, type RegistryAuthConfig, type RegistryAccess, npmDistTag, npmHasVersion, httpHealthCheck, type HttpHealthCheckOptions, sshSession, shq, type SshSession, serviceUrl, checkSpa } from "./library/index.ts";