@olivervea/pipelines 2.0.0-beta.1 → 2.0.0-beta.11

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,30 @@
1
+ import type { Secret } from "./secrets.ts";
2
+
3
+ /** How a webhook trigger authenticates deliveries. Build one with `hmacSha256`, `githubSignature` or `bearer`. */
4
+ export type WebhookVerify =
5
+ | { readonly scheme: "hmac-sha256"; readonly secret: Secret; readonly header: string; readonly prefix: string }
6
+ | { readonly scheme: "bearer"; readonly secret: Secret };
7
+
8
+ /** HMAC-SHA256 of the raw body keyed by `secret`, hex, in `header` after `prefix`. */
9
+ export function hmacSha256(o: { secret: Secret; header: string; prefix?: string }): WebhookVerify {
10
+ return Object.freeze({ scheme: "hmac-sha256", secret: o.secret, header: o.header, prefix: o.prefix ?? "" });
11
+ }
12
+
13
+ /** GitHub's delivery signature: `X-Hub-Signature-256: sha256=<hex>`. */
14
+ export function githubSignature(secret: Secret): WebhookVerify {
15
+ return hmacSha256({ secret, header: "X-Hub-Signature-256", prefix: "sha256=" });
16
+ }
17
+
18
+ /** `Authorization: Bearer <secret>`. */
19
+ export function bearer(secret: Secret): WebhookVerify {
20
+ return Object.freeze({ scheme: "bearer", secret });
21
+ }
22
+
23
+ /**
24
+ * What a webhook trigger mapper receives: the request headers (lower-case names, without `authorization`
25
+ * and the signature header) and the body (parsed JSON, or the raw text when it isn't JSON).
26
+ */
27
+ export interface WebhookEvent {
28
+ readonly headers: Readonly<Record<string, string>>;
29
+ readonly body: unknown;
30
+ }
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/sh.ts CHANGED
@@ -85,7 +85,8 @@ export function createSh(deps: ShDeps): { sh: Sh; exec: (argv: readonly string[]
85
85
  const scriptPath = typeof cmd === "object" ? cmd.script : isEmbeddedScript(cmd) ? cmd : undefined;
86
86
  if (scriptPath === undefined) {
87
87
  const shell = detectShell(deps.shellCandidates);
88
- return { argv: [shell, "-c", cmd as string], what: `command \`${truncate(cmd as string)}\`` };
88
+ // `sh -c cmd sh a b`: the args are $1, $2… of the command.
89
+ return { argv: [shell, "-c", cmd as string, "sh", ...args], what: `command \`${truncate(cmd as string)}\`` };
89
90
  }
90
91
  const file = materialize(scriptPath);
91
92
  const first = readFileSync(file, "utf8").split("\n", 1)[0] ?? "";
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,9 +1,14 @@
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";
10
+ import { isImplementation } from "../core/handlers.ts";
11
+ import { recordConfigRefs } from "../core/config-refs.ts";
7
12
  import { pipelineData, type MapperFunction, type StageNode, type StageStepNode } from "../builder/pipeline.ts";
8
13
  import { FailureContext } from "../library/models.ts";
9
14
  import { canonicalize, canonicalJson } from "../util/canonical.ts";
@@ -20,10 +25,12 @@ export class DescribeError extends Error {
20
25
  }
21
26
 
22
27
  export interface DescribeOptions {
23
- /** Step names that have a handler file (`handlers/<name>.ts`). */
28
+ /** Step names that have a handler (a `handlers/*.ts` default-exporting `implement(step, …)`). */
24
29
  handlerNames: Iterable<string>;
25
30
  /** Step name → handler hash (from the build; includes the harness identity). */
26
31
  handlerHashes: Readonly<Record<string, string>>;
32
+ /** Kind → hash of the code that implements the steps of that kind which carry their implementation (from the build). */
33
+ kindHashes?: Readonly<Record<string, string>>;
27
34
  harness: HarnessInfo;
28
35
  }
29
36
 
@@ -127,14 +134,46 @@ export function describePipeline(pipeline: unknown, opts: DescribeOptions): Mani
127
134
  return s.name;
128
135
  };
129
136
 
137
+ // ── config entries ──
138
+ const configs = new Map<string, { description: string; required: boolean; schema: JsonSchema; key: string; secretRefs: string[] }>();
139
+ const useConfig = (e: unknown, at: string): string | undefined => {
140
+ if (!isConfigEntry(e)) return void problems.push(`${at}: config entries must be config("name", "description", schema)`);
141
+ if (!CONFIG_NAME.test(e.name)) problems.push(`${at}: config name '${e.name}' must match ${CONFIG_NAME}`);
142
+ let schema: JsonSchema;
143
+ try {
144
+ schema = z.toJSONSchema(e.schema, { io: "input", target: "draft-2020-12" }) as JsonSchema;
145
+ delete schema.$schema;
146
+ schema = canonicalize(schema) as JsonSchema;
147
+ } catch (err) {
148
+ problems.push(`${at}: config '${e.name}' is not representable as JSON Schema (${(err as Error).message})`);
149
+ return e.name;
150
+ }
151
+ const key = JSON.stringify([e.description, schema]);
152
+ const prev = configs.get(e.name);
153
+ if (!prev) configs.set(e.name, { description: e.description, required: !e.schema.safeParse(undefined).success, schema, key, secretRefs: secretRefsOf(schema) });
154
+ else if (prev.key !== key) problems.push(`two different config entries are named '${e.name}' (one description and schema per config name)`);
155
+ return e.name;
156
+ };
157
+
130
158
  // ── step identity ──
131
159
  const steps = new Map<string, AnyStep>();
132
- const registerStep = (s: AnyStep, where: string) => {
160
+ const placements = new Map<string, { step: AnyStep; cases: readonly CaseOf[] }[]>();
161
+ // Two places are mutually exclusive when they sit in different cases of the same select.
162
+ const exclusive = (a: readonly CaseOf[], b: readonly CaseOf[]) => a.some((x) => b.some((y) => y.select === x.select && y.value !== x.value));
163
+ const registerStep = (s: AnyStep, where: string, cases: readonly CaseOf[] = []) => {
133
164
  if (typeof s.name !== "string" || !STEP_NAME.test(s.name))
134
165
  problems.push(`${where}: step name '${String(s.name)}' must be lower-case kebab (${STEP_NAME})`);
135
166
  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);
167
+ const prev = placements.get(s.name) ?? [];
168
+ if (prev.some((p) => !exclusive(p.cases, cases))) problems.push(`duplicate step name '${s.name}'`);
169
+ else if (prev.length) {
170
+ if (prev.some((p) => p.step !== s && !isImplementation(p.step) && !isImplementation(s)))
171
+ problems.push(`step '${s.name}': two different steps with handler files share the name across select cases; handlers pair by name, so use one step or different names`);
172
+ if (prev.some((p) => (p.step.output?.name ?? null) !== (s.output?.name ?? null)))
173
+ problems.push(`step '${s.name}': its select cases must give it the same output model`);
174
+ }
175
+ placements.set(s.name, [...prev, { step: s, cases }]);
176
+ if (!steps.has(s.name)) steps.set(s.name, s);
138
177
  };
139
178
 
140
179
  const inputModel = data.input;
@@ -144,7 +183,7 @@ export function describePipeline(pipeline: unknown, opts: DescribeOptions): Mani
144
183
  const outputViewOf = (modelName: string | null): JsonSchema | undefined => (modelName ? models.get(modelName)?.schemas.output : undefined);
145
184
 
146
185
  // ── one step ──
147
- const describeStep = (node: StageStepNode, avail: Map<string, Avail>, failureHandler: boolean): StepDescriptorT => {
186
+ const describeStep = (node: StageStepNode, avail: Map<string, Avail>, failureHandler: boolean, cases: readonly CaseOf[] = []): StepDescriptorT => {
148
187
  const s = node.step;
149
188
  const at = `step '${s.name}'`;
150
189
  if (typeof s.image !== "string" || s.image.trim() === "") problems.push(`${at}: image must be a non-empty string`);
@@ -194,24 +233,51 @@ export function describePipeline(pipeline: unknown, opts: DescribeOptions): Mani
194
233
  }
195
234
  }
196
235
 
236
+ for (const key of Object.keys(s.input as Record<string, unknown>))
237
+ if (key.startsWith("$")) problems.push(`${at}: input '${key}': keys starting with '$' are reserved`);
197
238
  const secretNames = s.secrets.map((x) => useSecret(x, at)).sort();
198
- let handlerHash: string;
199
- let config: Record<string, unknown> | undefined;
200
- 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)}, …))`);
202
- handlerHash = opts.handlerHashes[s.name] ?? "";
203
- if (handlerNames.has(s.name) && !handlerHash) problems.push(`${at}: no handler hash was computed`);
204
- } else if (s.builtin) {
239
+ const configNames = [...new Set((s.config ?? []).map((c) => useConfig(c, at)).filter((n): n is string => n !== undefined))].sort();
240
+ const carried = isImplementation(s);
241
+ let settings: Record<string, unknown> | undefined;
242
+ let settingsProblem: string | undefined;
243
+ if (s.settings !== undefined || carried) {
205
244
  try {
206
- config = canonicalize(s.builtin.manifestConfig()) as Record<string, unknown>;
245
+ settings = canonicalize(recordConfigRefs(s.settings ?? {})) as Record<string, unknown>;
207
246
  } catch (e) {
208
- problems.push(`${at}: invalid ${s.kind} configuration (${(e as Error).message})`);
209
- config = {};
247
+ settingsProblem = (e as Error).message;
248
+ settings = {};
210
249
  }
211
- handlerHash = libraryHandlerHash(s.kind, config, opts.harness);
250
+ }
251
+ const refs = settings ? recordedConfigRefs(settings) : [];
252
+ // A step whose settings reference parts of an entry needs the secrets those parts name; any other
253
+ // step receiving an entry needs every secret the entry names.
254
+ for (const name of configNames) {
255
+ const into = refs.filter((r) => r.ref.split(".")[0] === name);
256
+ const needed = into.length === 0
257
+ ? configs.get(name)?.secretRefs ?? []
258
+ : [...new Set(into.flatMap((r) => secretRefsOf(subSchemas(configs.get(name)?.schema, r.ref.split(".").slice(1), r.case, name))))].sort();
259
+ for (const ref of needed)
260
+ if (!secretNames.includes(ref)) problems.push(`${at}: config '${name}' references secret '${ref}' (secretRef), so the step must declare it in secrets`);
261
+ }
262
+ let handlerHash: string;
263
+ if (settingsProblem !== undefined) problems.push(`${at}: invalid ${s.kind} settings (${settingsProblem})`);
264
+ if (typeof s.kind !== "string" || s.kind === "") problems.push(`${at}: kind must be a non-empty string`);
265
+ if (carried && s.kind === "custom") problems.push(`${at} carries its implementation, so it needs a kind (step({ kind: "…", … })); a step with kind custom has its handler in handlers/`);
266
+ for (const { ref, case: kase } of refs) {
267
+ const [entry, ...path] = ref.split(".") as [string, ...string[]];
268
+ if (kase !== undefined && !cases.some((c) => c.cond === kase))
269
+ problems.push(`${at}: config reference '${ref}' is narrowed to the select case '${kase}' and can only be used inside it`);
270
+ if (!configNames.includes(entry)) problems.push(`${at}: config reference '${ref}': the step does not receive config entry '${entry}'`);
271
+ else if (!resolveField(configs.get(entry)?.schema, path)) problems.push(`${at}: config reference '${ref}': config '${entry}' has no field '${path.join(".")}'`);
272
+ }
273
+ if (carried) {
274
+ const code = opts.kindHashes?.[s.kind];
275
+ if (opts.kindHashes && !code) problems.push(`${at}: no hash was computed for the code of kind '${s.kind}'`);
276
+ handlerHash = libraryHandlerHash(s.kind, settings, opts.harness, code);
212
277
  } else {
213
- problems.push(`${at}: library step of kind '${s.kind}' has no implementation`);
214
- handlerHash = "";
278
+ if (!handlerNames.has(s.name)) problems.push(`${at} has no handler (add a handlers/*.ts default-exporting implement(${camel(s.name)}, …))`);
279
+ handlerHash = opts.handlerHashes[s.name] ?? "";
280
+ if (handlerNames.has(s.name) && !handlerHash) problems.push(`${at}: no handler hash was computed`);
215
281
  }
216
282
  if (typeof s.description !== "string") problems.push(`${at}: description must be a string (it may be empty)`);
217
283
  if (s.timeoutSeconds !== undefined && !(Number.isInteger(s.timeoutSeconds) && s.timeoutSeconds > 0)) problems.push(`${at}: timeoutSeconds must be a positive integer`);
@@ -229,8 +295,11 @@ export function describePipeline(pipeline: unknown, opts: DescribeOptions): Mani
229
295
  handlerHash: handlerHash || "sha256:" + "0".repeat(64),
230
296
  ...(s.timeoutSeconds !== undefined ? { timeoutSeconds: s.timeoutSeconds } : {}),
231
297
  ...(s.retries !== undefined ? { retries: s.retries } : {}),
232
- ...(config !== undefined ? { config: config as Record<string, z.core.util.JSONType> } : {}),
298
+ ...(settings !== undefined ? { config: settings as Record<string, z.core.util.JSONType> } : {}),
299
+ ...(configNames.length ? { configEntries: configNames } : {}),
300
+ ...(s.clusterAccess === true ? { clusterAccess: true as const } : {}),
233
301
  };
302
+ if (s.clusterAccess !== undefined && typeof s.clusterAccess !== "boolean") problems.push(`${at}: clusterAccess must be a boolean`);
234
303
  if (s.output && !isModel(s.output)) problems.push(`${at}: output must be a model`);
235
304
  return out;
236
305
  };
@@ -275,17 +344,22 @@ export function describePipeline(pipeline: unknown, opts: DescribeOptions): Mani
275
344
  };
276
345
 
277
346
  // ── stages ──
278
- const walk = (nodes: readonly StageNode[], avail: Map<string, Avail>): Stage[] => {
347
+ let selects = 0;
348
+ const walk = (nodes: readonly StageNode[], avail: Map<string, Avail>, cases: readonly CaseOf[] = []): Stage[] => {
279
349
  const out: Stage[] = [];
280
350
  for (const n of nodes) {
351
+ if (n.kind === "select") {
352
+ out.push(describeSelect(n, avail, cases, ++selects));
353
+ continue;
354
+ }
281
355
  if (n.kind === "stage") {
282
356
  const descs = n.steps.map((ss) => {
283
357
  if (!isStep(ss.step)) {
284
358
  problems.push("a stage entry is not a step");
285
359
  return undefined;
286
360
  }
287
- registerStep(ss.step, "stage");
288
- return describeStep(ss, avail, false);
361
+ registerStep(ss.step, "stage", cases);
362
+ return describeStep(ss, avail, false, cases);
289
363
  }).filter((d): d is StepDescriptorT => d !== undefined);
290
364
  // Outputs become available only after the barrier (same-stage siblings can't see each other).
291
365
  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 +379,43 @@ export function describePipeline(pipeline: unknown, opts: DescribeOptions): Mani
305
379
  when[field] = values;
306
380
  }
307
381
  const inner = new Map(avail);
308
- const stages = walk(n.nodes, inner);
382
+ const stages = walk(n.nodes, inner, cases);
309
383
  for (const [name, a] of inner) if (!avail.has(name)) avail.set(name, { model: a.model, maybe: true });
310
384
  out.push({ when, stages });
311
385
  }
312
386
  }
313
387
  return out;
314
388
  };
389
+ const describeSelect = (n: Extract<StageNode, { kind: "select" }>, avail: Map<string, Avail>, cases: readonly CaseOf[], id: number): Stage => {
390
+ if (!isConfigRef(n.on)) {
391
+ problems.push("select: the first argument must be a config reference");
392
+ return { select: "", discriminator: "", cases: {} };
393
+ }
394
+ const ref = configRefPath(n.on);
395
+ const { entry, case: kase } = configRefTarget(n.on);
396
+ useConfig(entry, `select '${ref}'`);
397
+ if (kase !== undefined && !cases.some((c) => c.cond === kase))
398
+ problems.push(`select '${ref}': the reference is narrowed to the case '${kase}' and can only be used inside it`);
399
+ const union = unionAt(n.on);
400
+ if (!union) problems.push(`select '${ref}': it must reference a z.discriminatedUnion in config '${entry.name}'`);
401
+ else {
402
+ for (const v of union.values) if (!Object.hasOwn(n.cases, v)) problems.push(`select '${ref}': no case for ${union.discriminator} '${v}'`);
403
+ 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(", ")})`);
404
+ }
405
+ const discriminator = union?.discriminator ?? "";
406
+ const branches = Object.entries(n.cases).map(([value, nodes]) => {
407
+ const inner = new Map(avail);
408
+ const stages = walk(nodes, inner, [...cases, { select: id, value, cond: `${ref}.${discriminator}=${value}` }]);
409
+ return { value, inner, stages };
410
+ });
411
+ // A step every case has is as available as in its cases; one only some cases have is maybe-available.
412
+ const added = new Set(branches.flatMap((b) => [...b.inner.keys()].filter((k) => !avail.has(k))));
413
+ for (const name of added) {
414
+ const present = branches.map((b) => b.inner.get(name)).filter((a): a is Avail => a !== undefined);
415
+ avail.set(name, { model: present[0]!.model, maybe: present.length < branches.length || present.some((a) => a.maybe) });
416
+ }
417
+ return { select: ref, discriminator, cases: Object.fromEntries(branches.map((b) => [b.value, b.stages])) };
418
+ };
315
419
  const stages = walk(data.nodes, new Map([["input", { model: inputModel.name, maybe: false }]]));
316
420
 
317
421
  // ── failure handlers ──
@@ -329,19 +433,26 @@ export function describePipeline(pipeline: unknown, opts: DescribeOptions): Mani
329
433
  return { step: describeStep({ step: fh.step }, new Map(), true), steps: scope };
330
434
  }).filter((x) => x !== undefined);
331
435
 
332
- // ── handler files without a step ──
436
+ // ── handlers without a step ──
333
437
  for (const h of handlerNames) {
334
438
  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`);
439
+ if (!s) problems.push(`a handler implements step '${h}', which is not in the pipeline`);
440
+ else if (isImplementation(s)) problems.push(`a handler implements '${h}', a ${s.kind} step that carries its own implementation`);
337
441
  }
338
442
 
339
443
  // ── triggers ──
340
444
  const triggerNames = new Set<string>();
341
445
  const triggers: TriggerT[] = data.triggers.map((t) => {
342
- if (triggerNames.has(t.name)) problems.push(`duplicate trigger '${t.name}'${t.kind === "push" ? " (onPush may be used once)" : " (give each onPoll a distinct name)"}`);
446
+ if (triggerNames.has(t.name)) problems.push(`duplicate trigger '${t.name}'${t.kind === "push" ? " (onPush may be used once)" : " (give each onPoll and onWebhook a distinct name)"}`);
343
447
  triggerNames.add(t.name);
344
448
  if (t.kind === "push") return { kind: "push", name: "push" };
449
+ if (t.kind === "webhook") {
450
+ if (!STEP_NAME.test(t.name)) problems.push(`webhook trigger name '${t.name}' must be lower-case kebab`);
451
+ const secret = useSecret(t.verify.secret, `webhook '${t.name}'`);
452
+ if (t.verify.scheme === "bearer") return { kind: "webhook", name: t.name, verify: { scheme: "bearer", secret } };
453
+ if (!t.verify.header) problems.push(`webhook '${t.name}': the signature header must not be empty`);
454
+ return { kind: "webhook", name: t.name, verify: { scheme: "hmac-sha256", secret, header: t.verify.header, prefix: t.verify.prefix } };
455
+ }
345
456
  if (!STEP_NAME.test(t.name)) problems.push(`poll trigger name '${t.name}' must be lower-case kebab`);
346
457
  if (!(Number.isInteger(t.intervalSeconds) && t.intervalSeconds > 0)) problems.push(`poll '${t.name}': intervalSeconds must be a positive integer`);
347
458
  const declared = new Set(t.secrets.map((s) => useSecret(s, `poll '${t.name}'`)));
@@ -362,6 +473,13 @@ export function describePipeline(pipeline: unknown, opts: DescribeOptions): Mani
362
473
  harness: opts.harness,
363
474
  models: Object.fromEntries([...models].map(([k, v]) => [k, v.schemas])),
364
475
  secrets: [...secrets].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)).map(([name, description]) => ({ name, description })),
476
+ ...(configs.size
477
+ ? {
478
+ config: [...configs]
479
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
480
+ .map(([name, c]) => ({ name, description: c.description, required: c.required, schema: c.schema })),
481
+ }
482
+ : {}),
365
483
  triggers,
366
484
  stages,
367
485
  failureHandlers,
@@ -374,6 +492,74 @@ export function describePipeline(pipeline: unknown, opts: DescribeOptions): Mani
374
492
  /** Canonical manifest text (what `describe` prints and the service hashes). */
375
493
  export const manifestText = (m: ManifestT): string => canonicalJson(m);
376
494
 
495
+ /**
496
+ * The schemas at `path` inside an entry's schema: every union branch that has the field, except where
497
+ * `kase` (`<entry>.<path>.<discriminator>=<value>`) narrows a union to one branch.
498
+ */
499
+ export function subSchemas(schema: unknown, path: readonly string[], kase: string | undefined, entry: string): unknown[] {
500
+ let narrow: { at: string; discriminator: string; value: string } | undefined;
501
+ if (kase) {
502
+ const eq = kase.lastIndexOf("=");
503
+ const left = kase.slice(0, eq);
504
+ const dot = left.lastIndexOf(".");
505
+ narrow = { at: left.slice(0, dot), discriminator: left.slice(dot + 1), value: kase.slice(eq + 1) };
506
+ }
507
+ const walk = (node: unknown, rest: readonly string[], here: string): unknown[] => {
508
+ if (node === null || typeof node !== "object") return [];
509
+ const o = node as JsonSchema;
510
+ const branches = (o.oneOf ?? o.anyOf) as JsonSchema[] | undefined;
511
+ if (Array.isArray(branches)) {
512
+ const chosen = narrow && narrow.at === here
513
+ ? branches.filter((b) => ((b.properties as Record<string, JsonSchema> | undefined)?.[narrow!.discriminator] as JsonSchema | undefined)?.const === narrow!.value)
514
+ : branches;
515
+ return chosen.flatMap((b) => walk(b, rest, here));
516
+ }
517
+ if (rest.length === 0) return [node];
518
+ const props = o.properties as Record<string, JsonSchema> | undefined;
519
+ if (props && Object.hasOwn(props, rest[0]!)) return walk(props[rest[0]!], rest.slice(1), `${here}.${rest[0]}`);
520
+ return [];
521
+ };
522
+ return walk(schema, path, entry);
523
+ }
524
+
525
+ /** Secret names of every secretRef inside a config entry's JSON Schema. */
526
+ export function secretRefsOf(schema: unknown): string[] {
527
+ const out = new Set<string>();
528
+ const walk = (v: unknown) => {
529
+ if (Array.isArray(v)) return v.forEach(walk);
530
+ if (v === null || typeof v !== "object") return;
531
+ const o = v as JsonSchema;
532
+ if (o["x-olve-kind"] === SECRET_REF_KIND) {
533
+ const name = (o.properties as { secret?: { const?: unknown } } | undefined)?.secret?.const;
534
+ if (typeof name === "string") out.add(name);
535
+ }
536
+ Object.values(o).forEach(walk);
537
+ };
538
+ walk(schema);
539
+ return [...out].sort();
540
+ }
541
+
542
+ /** The `{"$config": "<entry>.<path>"[, "$case": …]}` references in a step's recorded settings. */
543
+ export function recordedConfigRefs(v: unknown, out: { ref: string; case?: string }[] = []): { ref: string; case?: string }[] {
544
+ if (Array.isArray(v)) v.forEach((x) => recordedConfigRefs(x, out));
545
+ else if (v !== null && typeof v === "object") {
546
+ const o = v as Record<string, unknown>;
547
+ const keys = Object.keys(o).sort();
548
+ const isRef = typeof o[CONFIG_REF_KEY] === "string"
549
+ && (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");
550
+ if (isRef) out.push({ ref: o[CONFIG_REF_KEY] as string, ...(keys.length === 2 ? { case: o[CONFIG_CASE_KEY] as string } : {}) });
551
+ else Object.values(o).forEach((x) => recordedConfigRefs(x, out));
552
+ }
553
+ return out;
554
+ }
555
+
556
+ /** A select case a step sits in: which select (numbered in describe order), which value, and its condition text. */
557
+ interface CaseOf {
558
+ select: number;
559
+ value: string;
560
+ cond: string;
561
+ }
562
+
377
563
  function camel(name: string): string {
378
564
  return name.replace(/-([a-z0-9])/g, (_, c: string) => c.toUpperCase());
379
565
  }
@@ -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,20 +51,34 @@ 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' = the handler is a handlers/*.ts file; any other kind is a step that carries its implementation (an SDK or package step). 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)."),
57
58
  output: modelName.nullable().describe("Output model, or null if the step produces no output."),
58
59
  secrets: z.array(secretName).describe("Declared secrets (sorted). Mount exactly these, as env vars of the same name."),
59
- handlerHash: hash.describe("Implementation identity: handler bundle (contract files excluded) + harness (bun + sdkHash); for library steps kind + config + harness."),
60
+ handlerHash: hash.describe("Implementation identity: handler bundle (contract files excluded) + harness (bun + sdkHash); for a step that carries its implementation kind + config + harness + the hash of the code of its kind."),
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("The step's settings (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
 
@@ -90,6 +112,21 @@ export const Trigger = z
90
112
  intervalSeconds: z.number().int().positive(),
91
113
  })
92
114
  .describe("Background poller."),
115
+ z
116
+ .strictObject({
117
+ kind: z.literal("webhook"),
118
+ name: z.string().regex(/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/),
119
+ verify: z.discriminatedUnion("scheme", [
120
+ z.strictObject({
121
+ scheme: z.literal("hmac-sha256"),
122
+ secret: secretName,
123
+ header: z.string().min(1),
124
+ prefix: z.string().describe("Text before the hex digest in the header, e.g. 'sha256='."),
125
+ }),
126
+ z.strictObject({ scheme: z.literal("bearer"), secret: secretName }),
127
+ ]),
128
+ })
129
+ .describe("Inbound HTTP delivery to /api/hooks/<pipeline id>/<name>. Event = {headers, body}; a null mapping ignores it."),
93
130
  ])
94
131
  .meta({ id: "Trigger" });
95
132
 
@@ -102,12 +139,23 @@ export const Manifest = z
102
139
  harness: z
103
140
  .strictObject({
104
141
  bun: z.string().describe("Bun runtime version compiled into the binary."),
105
- sdk: z.string().describe("@olve/pipelines package version."),
142
+ sdk: z.string().describe("@olivervea/pipelines package version."),
106
143
  sdkHash: hash.describe("Hash of the SDK runtime sources + zod version."),
107
144
  })
108
145
  .describe("Harness identity. Already folded into every handlerHash."),
109
146
  models: z.record(modelName, ModelSchemas).describe("Every model referenced by the pipeline (input, step inputs/outputs)."),
110
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."),
111
159
  triggers: z.array(Trigger),
112
160
  stages: z.array(Stage),
113
161
  failureHandlers: z.array(
package/src/dev/cli.ts CHANGED
@@ -1,31 +1,31 @@
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";
11
11
  import { EXIT } from "../harness/exit.ts";
12
- import { handlerHashes } from "../build/hashes.ts";
13
- import { harnessInfo, loadProject, ProjectError } from "../build/project.ts";
12
+ import { handlerHashes, kindHashes } from "../build/hashes.ts";
13
+ import { harnessInfo, loadPipeline, 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);
21
- const mod = (await import(project.pipelineFile)) as { default?: unknown };
22
- if (!mod.default) throw new ProjectError(`${project.pipelineFile} has no default export (export default pipeline({...})...)`);
20
+ const project = await loadProject(dir);
21
+ const pipeline = await loadPipeline(project);
23
22
  const harness = harnessInfo();
24
23
  const hashes = withHashes ? await handlerHashes(project, harness) : {};
25
24
  return {
26
- pipeline: mod.default,
25
+ pipeline,
27
26
  handlers: Object.fromEntries(project.handlers.map(([name, file]) => [name, () => import(file) as Promise<{ default: unknown }>])),
28
27
  handlerHashes: Object.fromEntries(Object.entries(hashes).map(([k, v]) => [k, v.handlerHash])),
28
+ ...(withHashes ? { kindHashes: await kindHashes(project, pipeline) } : {}),
29
29
  harness,
30
30
  };
31
31
  }
@@ -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";