@olivervea/pipelines 2.0.0-beta.2 → 2.0.0-beta.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +125 -62
  2. package/manifest.schema.json +208 -11
  3. package/package.json +6 -4
  4. package/src/assets.d.ts +2 -2
  5. package/src/build/build.ts +121 -51
  6. package/src/build/cli.ts +29 -22
  7. package/src/build/elf.ts +8 -7
  8. package/src/build/hashes.ts +82 -20
  9. package/src/build/project.ts +95 -28
  10. package/src/builder/pipeline.ts +344 -88
  11. package/src/builder/refs.ts +15 -5
  12. package/src/core/brands.ts +7 -5
  13. package/src/core/config-refs.ts +103 -0
  14. package/src/core/config.ts +224 -0
  15. package/src/core/external.ts +86 -0
  16. package/src/core/handlers.ts +61 -15
  17. package/src/core/models.ts +32 -15
  18. package/src/core/origin.ts +23 -0
  19. package/src/core/secrets.ts +2 -2
  20. package/src/core/steps.ts +89 -38
  21. package/src/core/types.ts +5 -1
  22. package/src/core/webhooks.ts +21 -7
  23. package/src/ctx/files.ts +111 -40
  24. package/src/ctx/index.ts +84 -31
  25. package/src/ctx/sh.ts +105 -44
  26. package/src/ctx/types.ts +12 -4
  27. package/src/describe/describe.ts +701 -133
  28. package/src/describe/manifest-schema.ts +258 -61
  29. package/src/describe/mapper-source.ts +249 -104
  30. package/src/describe/record.ts +67 -37
  31. package/src/dev/cli.ts +72 -35
  32. package/src/harness/env.ts +45 -25
  33. package/src/harness/execute.ts +58 -29
  34. package/src/harness/index.ts +22 -9
  35. package/src/harness/main.ts +239 -78
  36. package/src/harness/result.ts +38 -22
  37. package/src/harness/validate.ts +77 -15
  38. package/src/index.ts +124 -11
  39. package/src/library/checks.ts +28 -0
  40. package/src/library/compose-deploy.ts +113 -0
  41. package/src/library/github-source.ts +100 -47
  42. package/src/library/helm-deploy-ssh.ts +452 -108
  43. package/src/library/helm-deploy.ts +449 -0
  44. package/src/library/http-health.ts +7 -5
  45. package/src/library/image-build.ts +499 -105
  46. package/src/library/index.ts +48 -12
  47. package/src/library/models.ts +33 -9
  48. package/src/library/ssh.ts +67 -0
  49. package/src/testing/index.ts +74 -36
  50. package/src/util/canonical.ts +7 -4
  51. package/src/util/handler-hash.ts +17 -7
  52. package/src/util/hash.ts +5 -5
  53. package/src/util/tar.ts +86 -36
  54. package/src/library/script.ts +0 -85
@@ -1,34 +1,57 @@
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
- import { z } from "zod";
4
- import { isModel, isOptional, MODEL_NAME, type AnyModel } from "../core/models.ts";
5
- import { SECRET_NAME, type Secret } from "../core/secrets.ts";
6
- import { RESERVED_STEP_NAMES, STEP_NAME, isStep, type AnyStep } from "../core/steps.ts";
7
- import { pipelineData, type MapperFunction, type StageNode, type StageStepNode } from "../builder/pipeline.ts";
8
- import { FailureContext } from "../library/models.ts";
9
- import { canonicalize, canonicalJson } from "../util/canonical.ts";
10
- import { libraryHandlerHash, type HarnessInfo } from "../util/handler-hash.ts";
11
- import { analyzeMapperSource } from "./mapper-source.ts";
12
- import { MapperError, recordMapper, type Mapping } from "./record.ts";
13
- import { API_VERSION, Manifest, type ManifestT, type ModelSchemasT, type Stage, type StepDescriptorT, type TriggerT } from "./manifest-schema.ts";
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';
7
+ import { isModel, isOptional, MODEL_NAME, type AnyModel } from '../core/models.ts';
8
+ import { isExternal } from '../core/external.ts';
9
+ import { SECRET_NAME, type Secret } from '../core/secrets.ts';
10
+ import { RESERVED_STEP_NAMES, STEP_NAME, isStep, type AnyStep } from '../core/steps.ts';
11
+ import { isImplementation } from '../core/handlers.ts';
12
+ import { recordConfigRefs } from '../core/config-refs.ts';
13
+ import {
14
+ pipelineData,
15
+ type MapperFunction,
16
+ type StageNode,
17
+ type StageStepNode,
18
+ } from '../builder/pipeline.ts';
19
+ import { FailureContext } from '../library/models.ts';
20
+ import { canonicalize, canonicalJson } from '../util/canonical.ts';
21
+ import { libraryHandlerHash, type HarnessInfo } from '../util/handler-hash.ts';
22
+ import { analyzeMapperSource } from './mapper-source.ts';
23
+ import { MapperError, recordMapper, type Mapping } from './record.ts';
24
+ import {
25
+ API_VERSION,
26
+ Manifest,
27
+ type ManifestT,
28
+ type ModelSchemasT,
29
+ type Stage,
30
+ type StepDescriptorT,
31
+ type TriggerT,
32
+ } from './manifest-schema.ts';
14
33
 
15
34
  export class DescribeError extends Error {
16
- override name = "DescribeError";
35
+ override name = 'DescribeError';
17
36
  constructor(readonly problems: readonly string[]) {
18
- super(`describe found ${problems.length} problem(s):\n${problems.map((p) => ` - ${p}`).join("\n")}`);
37
+ super(
38
+ `describe found ${problems.length} problem(s):\n${problems.map((p) => ` - ${p}`).join('\n')}`,
39
+ );
19
40
  }
20
41
  }
21
42
 
22
43
  export interface DescribeOptions {
23
- /** Step names that have a handler file (`handlers/<name>.ts`). */
44
+ /** Step names that have a handler (a `handlers/*.ts` default-exporting `implement(step, …)`). */
24
45
  handlerNames: Iterable<string>;
25
46
  /** Step name → handler hash (from the build; includes the harness identity). */
26
47
  handlerHashes: Readonly<Record<string, string>>;
48
+ /** Kind → hash of the code that implements the steps of that kind which carry their implementation (from the build). */
49
+ kindHashes?: Readonly<Record<string, string>>;
27
50
  harness: HarnessInfo;
28
51
  }
29
52
 
30
53
  /** Root name used in failure-handler mappings (the service supplies FailureContext under it). */
31
- export const FAILURE_ROOT = "failure";
54
+ export const FAILURE_ROOT = 'failure';
32
55
  const RESERVED = new Set([...RESERVED_STEP_NAMES, FAILURE_ROOT]);
33
56
 
34
57
  type JsonSchema = Record<string, unknown>;
@@ -39,9 +62,10 @@ interface Avail {
39
62
 
40
63
  function stripStrict(v: unknown): unknown {
41
64
  if (Array.isArray(v)) return v.map(stripStrict);
42
- if (v !== null && typeof v === "object") {
65
+ if (v !== null && typeof v === 'object') {
43
66
  const out: Record<string, unknown> = {};
44
- for (const [k, x] of Object.entries(v)) if (!(k === "additionalProperties" && x === false)) out[k] = stripStrict(x);
67
+ for (const [k, x] of Object.entries(v))
68
+ if (!(k === 'additionalProperties' && x === false)) out[k] = stripStrict(x);
45
69
  return out;
46
70
  }
47
71
  return v;
@@ -49,38 +73,46 @@ function stripStrict(v: unknown): unknown {
49
73
 
50
74
  /** JSON Schema views of a zod schema. Throws if it is not representable (dates, transforms, …). */
51
75
  export function schemaViews(schema: z.ZodType): ModelSchemasT {
52
- const view = (io: "input" | "output") => {
53
- const js = z.toJSONSchema(schema, { io, target: "draft-2020-12" }) as JsonSchema;
76
+ const view = (io: 'input' | 'output') => {
77
+ const js = z.toJSONSchema(schema, { io, target: 'draft-2020-12' }) as JsonSchema;
54
78
  delete js.$schema; // constant noise; apiVersion pins the dialect
55
79
  return canonicalize(js) as JsonSchema;
56
80
  };
57
- const output = view("output");
58
- const input = view("input");
81
+ const output = view('output');
82
+ const input = view('input');
59
83
  const same = JSON.stringify(stripStrict(output)) === JSON.stringify(input);
60
84
  return same ? { output } : { output, input };
61
85
  }
62
86
 
63
87
  /** Follows `segments` through a JSON Schema; undefined if the field doesn't exist. */
64
- function resolveField(schema: JsonSchema | undefined, segments: readonly string[]): boolean {
88
+ function resolveField(
89
+ schema: JsonSchema | undefined,
90
+ segments: readonly string[],
91
+ ): boolean {
65
92
  if (!schema) return false;
66
93
  if (segments.length === 0) return true;
67
94
  const [seg, ...rest] = segments as [string, ...string[]];
68
- if (Array.isArray(schema.anyOf)) return (schema.anyOf as JsonSchema[]).some((b) => resolveField(b, segments));
69
- if (Array.isArray(schema.oneOf)) return (schema.oneOf as JsonSchema[]).some((b) => resolveField(b, segments));
70
- if (Array.isArray(schema.allOf)) return (schema.allOf as JsonSchema[]).some((b) => resolveField(b, segments));
95
+ if (Array.isArray(schema.anyOf))
96
+ return (schema.anyOf as JsonSchema[]).some((b) => resolveField(b, segments));
97
+ if (Array.isArray(schema.oneOf))
98
+ return (schema.oneOf as JsonSchema[]).some((b) => resolveField(b, segments));
99
+ if (Array.isArray(schema.allOf))
100
+ return (schema.allOf as JsonSchema[]).some((b) => resolveField(b, segments));
71
101
  const props = schema.properties as Record<string, JsonSchema> | undefined;
72
102
  if (props && Object.hasOwn(props, seg)) return resolveField(props[seg], rest);
73
103
  const extra = schema.additionalProperties;
74
- if (extra && typeof extra === "object") return resolveField(extra as JsonSchema, rest);
104
+ if (extra && typeof extra === 'object') return resolveField(extra as JsonSchema, rest);
75
105
  // A schema without type/properties/anyOf accepts anything (z.unknown / z.any).
76
- return Object.keys(schema).every((k) => k === "description" || k === "title" || k === "default");
106
+ return Object.keys(schema).every(
107
+ (k) => k === 'description' || k === 'title' || k === 'default',
108
+ );
77
109
  }
78
110
 
79
111
  function literalValues(field: JsonSchema | undefined): unknown[] | undefined {
80
112
  if (!field) return undefined;
81
113
  if (Array.isArray(field.enum)) return field.enum as unknown[];
82
114
  if (field.const !== undefined) return [field.const];
83
- if (field.type === "boolean") return [true, false];
115
+ if (field.type === 'boolean') return [true, false];
84
116
  const alts = (field.anyOf ?? field.oneOf) as JsonSchema[] | undefined;
85
117
  if (Array.isArray(alts)) {
86
118
  const all: unknown[] = [];
@@ -102,72 +134,220 @@ export function describePipeline(pipeline: unknown, opts: DescribeOptions): Mani
102
134
  // ── models ──
103
135
  const models = new Map<string, { schemas: ModelSchemasT; key: string }>();
104
136
  const useModel = (m: AnyModel, at: string): string => {
105
- if (!MODEL_NAME.test(m.name)) problems.push(`${at}: model name '${m.name}' must match ${MODEL_NAME}`);
137
+ if (!MODEL_NAME.test(m.name))
138
+ problems.push(`${at}: model name '${m.name}' must match ${MODEL_NAME}`);
106
139
  let schemas: ModelSchemasT;
107
140
  try {
108
141
  schemas = schemaViews(m.schema);
109
142
  } catch (e) {
110
- problems.push(`${at}: model '${m.name}' is not representable as JSON Schema (${(e as Error).message})`);
143
+ problems.push(
144
+ `${at}: model '${m.name}' is not representable as JSON Schema (${(e as Error).message})`,
145
+ );
111
146
  return m.name;
112
147
  }
113
148
  const key = JSON.stringify(schemas);
114
149
  const prev = models.get(m.name);
115
150
  if (!prev) models.set(m.name, { schemas, key });
116
- else if (prev.key !== key) problems.push(`two different schemas are registered under the model name '${m.name}' (one schema per model name)`);
151
+ else if (prev.key !== key)
152
+ problems.push(
153
+ `two different schemas are registered under the model name '${m.name}' (one schema per model name)`,
154
+ );
117
155
  return m.name;
118
156
  };
119
157
 
120
158
  // ── secrets ──
121
159
  const secrets = new Map<string, string>();
122
160
  const useSecret = (s: Secret, at: string): string => {
123
- if (!SECRET_NAME.test(s.name)) problems.push(`${at}: secret name '${s.name}' must match ${SECRET_NAME}`);
161
+ if (!SECRET_NAME.test(s.name))
162
+ problems.push(`${at}: secret name '${s.name}' must match ${SECRET_NAME}`);
124
163
  const prev = secrets.get(s.name);
125
164
  if (prev === undefined) secrets.set(s.name, s.description);
126
- else if (prev !== s.description) problems.push(`secret '${s.name}' is declared twice with different descriptions`);
165
+ else if (prev !== s.description)
166
+ problems.push(`secret '${s.name}' is declared twice with different descriptions`);
127
167
  return s.name;
128
168
  };
129
169
 
170
+ // ── config entries ──
171
+ const configs = new Map<
172
+ string,
173
+ {
174
+ description: string;
175
+ required: boolean;
176
+ schema: JsonSchema;
177
+ key: string;
178
+ secretRefs: string[];
179
+ }
180
+ >();
181
+ const useConfig = (e: unknown, at: string): string | undefined => {
182
+ if (!isConfigEntry(e))
183
+ return void problems.push(
184
+ `${at}: config entries must be config("name", "description", schema)`,
185
+ );
186
+ if (!CONFIG_NAME.test(e.name))
187
+ problems.push(`${at}: config name '${e.name}' must match ${CONFIG_NAME}`);
188
+ let schema: JsonSchema;
189
+ try {
190
+ schema = z.toJSONSchema(e.schema, {
191
+ io: 'input',
192
+ target: 'draft-2020-12',
193
+ }) as JsonSchema;
194
+ delete schema.$schema;
195
+ schema = canonicalize(schema) as JsonSchema;
196
+ } catch (err) {
197
+ problems.push(
198
+ `${at}: config '${e.name}' is not representable as JSON Schema (${(err as Error).message})`,
199
+ );
200
+ return e.name;
201
+ }
202
+ const key = JSON.stringify([e.description, schema]);
203
+ const prev = configs.get(e.name);
204
+ if (!prev)
205
+ configs.set(e.name, {
206
+ description: e.description,
207
+ required: !e.schema.safeParse(undefined).success,
208
+ schema,
209
+ key,
210
+ secretRefs: secretRefsOf(schema),
211
+ });
212
+ else if (prev.key !== key)
213
+ problems.push(
214
+ `two different config entries are named '${e.name}' (one description and schema per config name)`,
215
+ );
216
+ return e.name;
217
+ };
218
+
130
219
  // ── step identity ──
131
220
  const steps = new Map<string, AnyStep>();
132
- const registerStep = (s: AnyStep, where: string) => {
133
- if (typeof s.name !== "string" || !STEP_NAME.test(s.name))
134
- problems.push(`${where}: step name '${String(s.name)}' must be lower-case kebab (${STEP_NAME})`);
135
- 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);
221
+ const placements = new Map<string, { step: AnyStep; cases: readonly CaseOf[] }[]>();
222
+ // Two places are mutually exclusive when they sit in different cases of the same select.
223
+ const exclusive = (a: readonly CaseOf[], b: readonly CaseOf[]) =>
224
+ a.some((x) => b.some((y) => y.select === x.select && y.value !== x.value));
225
+ const registerStep = (s: AnyStep, where: string, cases: readonly CaseOf[] = []) => {
226
+ if (typeof s.name !== 'string' || !STEP_NAME.test(s.name))
227
+ problems.push(
228
+ `${where}: step name '${String(s.name)}' must be lower-case kebab (${STEP_NAME})`,
229
+ );
230
+ if (RESERVED.has(s.name))
231
+ problems.push(`${where}: '${s.name}' is reserved and cannot be a step name`);
232
+ const prev = placements.get(s.name) ?? [];
233
+ if (prev.some((p) => !exclusive(p.cases, cases)))
234
+ problems.push(`duplicate step name '${s.name}'`);
235
+ else if (prev.length) {
236
+ if (
237
+ prev.some(
238
+ (p) => p.step !== s && !isImplementation(p.step) && !isImplementation(s),
239
+ )
240
+ )
241
+ problems.push(
242
+ `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`,
243
+ );
244
+ if (prev.some((p) => (p.step.output?.name ?? null) !== (s.output?.name ?? null)))
245
+ problems.push(
246
+ `step '${s.name}': its select cases must give it the same output model`,
247
+ );
248
+ }
249
+ placements.set(s.name, [...prev, { step: s, cases }]);
250
+ if (!steps.has(s.name)) steps.set(s.name, s);
138
251
  };
139
252
 
140
253
  const inputModel = data.input;
141
- if (!isModel(inputModel)) throw new DescribeError(["pipeline input must be a model (model(\"Name\", z.object({...})))"]);
142
- useModel(inputModel, "pipeline input");
143
- const inputOutputView = (): JsonSchema | undefined => models.get(inputModel.name)?.schemas.output;
144
- const outputViewOf = (modelName: string | null): JsonSchema | undefined => (modelName ? models.get(modelName)?.schemas.output : undefined);
254
+ if (!isModel(inputModel))
255
+ throw new DescribeError([
256
+ 'pipeline input must be a model (model("Name", z.object({...})))',
257
+ ]);
258
+ useModel(inputModel, 'pipeline input');
259
+ const inputOutputView = (): JsonSchema | undefined =>
260
+ models.get(inputModel.name)?.schemas.output;
261
+ const outputViewOf = (modelName: string | null): JsonSchema | undefined =>
262
+ modelName ? models.get(modelName)?.schemas.output : undefined;
145
263
 
146
264
  // ── one step ──
147
- const describeStep = (node: StageStepNode, avail: Map<string, Avail>, failureHandler: boolean): StepDescriptorT => {
265
+ const describeStep = (
266
+ node: StageStepNode,
267
+ avail: Map<string, Avail>,
268
+ failureHandler: boolean,
269
+ cases: readonly CaseOf[] = [],
270
+ ): StepDescriptorT => {
148
271
  const s = node.step;
149
272
  const at = `step '${s.name}'`;
150
- if (typeof s.image !== "string" || s.image.trim() === "") problems.push(`${at}: image must be a non-empty string`);
273
+ if (typeof s.image !== 'string' || s.image.trim() === '')
274
+ problems.push(`${at}: image must be a non-empty string`);
151
275
 
152
- const inputTypes: StepDescriptorT["inputTypes"] = {};
153
- const entries = Object.entries(s.input as Record<string, unknown>);
276
+ const inputTypes: StepDescriptorT['inputTypes'] = {};
277
+ const externalInputs: NonNullable<StepDescriptorT['externalInputs']> = {};
278
+ const allEntries = Object.entries(s.input as Record<string, unknown>);
279
+ for (const [key, e] of allEntries) {
280
+ if (!isExternal(e)) continue;
281
+ if (failureHandler)
282
+ problems.push(
283
+ `${at}: failure handlers run unattended and cannot wait for input ('${key}')`,
284
+ );
285
+ if (typeof e.title !== 'string' || e.title.trim() === '')
286
+ problems.push(`${at} external input '${key}': title must not be empty`);
287
+ if (e.from.length === 0)
288
+ problems.push(
289
+ `${at} external input '${key}': from must list at least one source`,
290
+ );
291
+ if (new Set(e.from.map((f) => f.kind)).size !== e.from.length)
292
+ problems.push(
293
+ `${at} external input '${key}': each source kind may be listed once`,
294
+ );
295
+ if (e.from.some((f) => f.kind === 'callback') && !/^[A-Za-z0-9_-]+$/.test(key))
296
+ problems.push(
297
+ `${at} external input '${key}': a callback input's key must be letters, digits, '_' or '-' (it is part of the URL)`,
298
+ );
299
+ for (const f of e.from) {
300
+ if (f.kind !== 'poll') continue;
301
+ if (!/^https?:\/\//.test(f.url))
302
+ problems.push(
303
+ `${at} external input '${key}': poll url must be an absolute http(s) URL`,
304
+ );
305
+ if (!Number.isInteger(f.intervalSeconds) || f.intervalSeconds < 10)
306
+ problems.push(
307
+ `${at} external input '${key}': poll intervalSeconds must be an integer of at least 10`,
308
+ );
309
+ if (f.valuePath.trim() === '')
310
+ problems.push(
311
+ `${at} external input '${key}': poll valuePath must not be empty`,
312
+ );
313
+ }
314
+ externalInputs[key] = {
315
+ model: useModel(e.model, `${at} external input '${key}'`),
316
+ title: e.title,
317
+ description: e.description,
318
+ from: e.from.map((f) => ({ ...f })),
319
+ };
320
+ }
321
+ const entries = allEntries.filter(([, e]) => !isExternal(e));
154
322
  for (const [key, e] of entries) {
155
- if (isOptional(e)) inputTypes[key] = { model: useModel(e.model, `${at} input '${key}'`), optional: true };
156
- else if (isModel(e)) inputTypes[key] = { model: useModel(e, `${at} input '${key}'`), optional: false };
323
+ if (isOptional(e))
324
+ inputTypes[key] = {
325
+ model: useModel(e.model, `${at} input '${key}'`),
326
+ optional: true,
327
+ };
328
+ else if (isModel(e))
329
+ inputTypes[key] = { model: useModel(e, `${at} input '${key}'`), optional: false };
157
330
  else if (e instanceof z.ZodType) {
158
331
  try {
159
332
  inputTypes[key] = { schema: schemaViews(e) };
160
333
  } catch (err) {
161
- problems.push(`${at} input '${key}': schema is not representable as JSON Schema (${(err as Error).message})`);
334
+ problems.push(
335
+ `${at} input '${key}': schema is not representable as JSON Schema (${(err as Error).message})`,
336
+ );
162
337
  }
163
- } else problems.push(`${at} input '${key}': must be a model, optional(model) or a zod schema`);
338
+ } else
339
+ problems.push(
340
+ `${at} input '${key}': must be a model, optional(model) or a zod schema`,
341
+ );
164
342
  }
165
343
 
166
344
  let input: Record<string, Mapping> = {};
167
345
  if (failureHandler) {
168
346
  for (const [key, e] of entries) {
169
347
  if (e !== FailureContext && !(isModel(e) && e.name === FailureContext.name))
170
- problems.push(`${at}: a failure handler's inputs must all be FailureContext (input '${key}' is not)`);
348
+ problems.push(
349
+ `${at}: a failure handler's inputs must all be FailureContext (input '${key}' is not)`,
350
+ );
171
351
  input[key] = { ref: FAILURE_ROOT };
172
352
  }
173
353
  if (node.mapper) problems.push(`${at}: failure handlers take no mapper`);
@@ -178,64 +358,166 @@ export function describePipeline(pipeline: unknown, opts: DescribeOptions): Mani
178
358
  const optional = isOptional(e);
179
359
  const m = optional ? e.model : isModel(e) ? e : undefined;
180
360
  if (!m) {
181
- problems.push(`${at}: input '${key}' is a plain schema, not a model, so it cannot be matched by model; add a mapper ([step, from => ({ ... })])`);
361
+ problems.push(
362
+ `${at}: input '${key}' is a plain schema, not a model, so it cannot be matched by model; add a mapper ([step, from => ({ ... })])`,
363
+ );
182
364
  continue;
183
365
  }
184
366
  const producers = [...avail].filter(([, a]) => a.model === m.name);
185
367
  if (producers.length === 0) {
186
- problems.push(`${at}: ${m.name} (input '${key}') is not available at this stage${optional ? " (optional() means maybe-skipped; there must still be an earlier producer)" : ""}`);
368
+ problems.push(
369
+ `${at}: ${m.name} (input '${key}') is not available at this stage${optional ? ' (optional() means maybe-skipped; there must still be an earlier producer)' : ''}`,
370
+ );
187
371
  } else if (producers.length > 1) {
188
- problems.push(`${at}: ${m.name} (input '${key}') has several producers (${producers.map(([n]) => n).sort().join(", ")}); use a mapper to pick one`);
372
+ problems.push(
373
+ `${at}: ${m.name} (input '${key}') has several producers (${producers
374
+ .map(([n]) => n)
375
+ .sort()
376
+ .join(', ')}); use a mapper to pick one`,
377
+ );
189
378
  } else {
190
379
  const [producer, a] = producers[0]!;
191
- if (a.maybe && !optional) problems.push(`${at}: ${m.name} (input '${key}') is only maybe-available (produced inside a when block); declare it optional(${m.name})`);
380
+ if (a.maybe && !optional)
381
+ problems.push(
382
+ `${at}: ${m.name} (input '${key}') is only maybe-available (produced inside a when block); declare it optional(${m.name})`,
383
+ );
192
384
  input[key] = { ref: producer };
193
385
  }
194
386
  }
195
387
  }
196
388
 
389
+ for (const key of Object.keys(s.input as Record<string, unknown>))
390
+ if (key.startsWith('$'))
391
+ problems.push(`${at}: input '${key}': keys starting with '$' are reserved`);
197
392
  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) {
393
+ const configNames = [
394
+ ...new Set(
395
+ (s.config ?? [])
396
+ .map((c) => useConfig(c, at))
397
+ .filter((n): n is string => n !== undefined),
398
+ ),
399
+ ].sort();
400
+ const carried = isImplementation(s);
401
+ let settings: Record<string, unknown> | undefined;
402
+ let settingsProblem: string | undefined;
403
+ if (s.settings !== undefined || carried) {
205
404
  try {
206
- config = canonicalize(s.builtin.manifestConfig()) as Record<string, unknown>;
405
+ settings = canonicalize(recordConfigRefs(s.settings ?? {})) as Record<
406
+ string,
407
+ unknown
408
+ >;
207
409
  } catch (e) {
208
- problems.push(`${at}: invalid ${s.kind} configuration (${(e as Error).message})`);
209
- config = {};
410
+ settingsProblem = (e as Error).message;
411
+ settings = {};
210
412
  }
211
- handlerHash = libraryHandlerHash(s.kind, config, opts.harness);
413
+ }
414
+ const refs = settings ? recordedConfigRefs(settings) : [];
415
+ // A step whose settings reference parts of an entry needs the secrets those parts name; any other
416
+ // step receiving an entry needs every secret the entry names.
417
+ for (const name of configNames) {
418
+ const into = refs.filter((r) => r.ref.split('.')[0] === name);
419
+ const needed =
420
+ into.length === 0
421
+ ? (configs.get(name)?.secretRefs ?? [])
422
+ : [
423
+ ...new Set(
424
+ into.flatMap((r) =>
425
+ secretRefsOf(
426
+ subSchemas(
427
+ configs.get(name)?.schema,
428
+ r.ref.split('.').slice(1),
429
+ r.case,
430
+ name,
431
+ ),
432
+ ),
433
+ ),
434
+ ),
435
+ ].sort();
436
+ for (const ref of needed)
437
+ if (!secretNames.includes(ref))
438
+ problems.push(
439
+ `${at}: config '${name}' references secret '${ref}' (secretRef), so the step must declare it in secrets`,
440
+ );
441
+ }
442
+ let handlerHash: string;
443
+ if (settingsProblem !== undefined)
444
+ problems.push(`${at}: invalid ${s.kind} settings (${settingsProblem})`);
445
+ if (typeof s.kind !== 'string' || s.kind === '')
446
+ problems.push(`${at}: kind must be a non-empty string`);
447
+ if (carried && s.kind === 'custom')
448
+ problems.push(
449
+ `${at} carries its implementation, so it needs a kind (step({ kind: "…", … })); a step with kind custom has its handler in handlers/`,
450
+ );
451
+ for (const { ref, case: kase } of refs) {
452
+ const [entry, ...path] = ref.split('.') as [string, ...string[]];
453
+ if (kase !== undefined && !cases.some((c) => c.cond === kase))
454
+ problems.push(
455
+ `${at}: config reference '${ref}' is narrowed to the select case '${kase}' and can only be used inside it`,
456
+ );
457
+ if (!configNames.includes(entry))
458
+ problems.push(
459
+ `${at}: config reference '${ref}': the step does not receive config entry '${entry}'`,
460
+ );
461
+ else if (!resolveField(configs.get(entry)?.schema, path))
462
+ problems.push(
463
+ `${at}: config reference '${ref}': config '${entry}' has no field '${path.join('.')}'`,
464
+ );
465
+ }
466
+ if (carried) {
467
+ const code = opts.kindHashes?.[s.kind];
468
+ if (opts.kindHashes && !code)
469
+ problems.push(`${at}: no hash was computed for the code of kind '${s.kind}'`);
470
+ handlerHash = libraryHandlerHash(s.kind, settings, opts.harness, code);
212
471
  } else {
213
- problems.push(`${at}: library step of kind '${s.kind}' has no implementation`);
214
- handlerHash = "";
472
+ if (!handlerNames.has(s.name))
473
+ problems.push(
474
+ `${at} has no handler (add a handlers/*.ts default-exporting implement(${camel(s.name)}, …))`,
475
+ );
476
+ handlerHash = opts.handlerHashes[s.name] ?? '';
477
+ if (handlerNames.has(s.name) && !handlerHash)
478
+ problems.push(`${at}: no handler hash was computed`);
215
479
  }
216
- if (typeof s.description !== "string") problems.push(`${at}: description must be a string (it may be empty)`);
217
- if (s.timeoutSeconds !== undefined && !(Number.isInteger(s.timeoutSeconds) && s.timeoutSeconds > 0)) problems.push(`${at}: timeoutSeconds must be a positive integer`);
218
- if (s.retries !== undefined && !(Number.isInteger(s.retries) && s.retries >= 0)) problems.push(`${at}: retries must be a non-negative integer`);
480
+ if (typeof s.description !== 'string')
481
+ problems.push(`${at}: description must be a string (it may be empty)`);
482
+ if (
483
+ s.timeoutSeconds !== undefined &&
484
+ !(Number.isInteger(s.timeoutSeconds) && s.timeoutSeconds > 0)
485
+ )
486
+ problems.push(`${at}: timeoutSeconds must be a positive integer`);
487
+ if (s.retries !== undefined && !(Number.isInteger(s.retries) && s.retries >= 0))
488
+ problems.push(`${at}: retries must be a non-negative integer`);
219
489
 
220
490
  const out: StepDescriptorT = {
221
491
  name: s.name,
222
- description: typeof s.description === "string" ? s.description : "",
492
+ description: typeof s.description === 'string' ? s.description : '',
223
493
  kind: s.kind,
224
494
  image: s.image,
225
495
  inputTypes,
226
496
  input,
227
497
  output: s.output ? useModel(s.output, `${at} output`) : null,
228
498
  secrets: secretNames,
229
- handlerHash: handlerHash || "sha256:" + "0".repeat(64),
499
+ handlerHash: handlerHash || 'sha256:' + '0'.repeat(64),
230
500
  ...(s.timeoutSeconds !== undefined ? { timeoutSeconds: s.timeoutSeconds } : {}),
231
501
  ...(s.retries !== undefined ? { retries: s.retries } : {}),
232
- ...(config !== undefined ? { config: config as Record<string, z.core.util.JSONType> } : {}),
502
+ ...(settings !== undefined
503
+ ? { config: settings as Record<string, z.core.util.JSONType> }
504
+ : {}),
505
+ ...(configNames.length ? { configEntries: configNames } : {}),
506
+ ...(s.clusterAccess === true ? { clusterAccess: true as const } : {}),
507
+ ...(Object.keys(externalInputs).length ? { externalInputs } : {}),
233
508
  };
509
+ if (s.clusterAccess !== undefined && typeof s.clusterAccess !== 'boolean')
510
+ problems.push(`${at}: clusterAccess must be a boolean`);
234
511
  if (s.output && !isModel(s.output)) problems.push(`${at}: output must be a model`);
235
512
  return out;
236
513
  };
237
514
 
238
- const mapStep = (at: string, s: AnyStep, mapper: MapperFunction, avail: Map<string, Avail>): Record<string, Mapping> => {
515
+ const mapStep = (
516
+ at: string,
517
+ s: AnyStep,
518
+ mapper: MapperFunction,
519
+ avail: Map<string, Avail>,
520
+ ): Record<string, Mapping> => {
239
521
  try {
240
522
  analyzeMapperSource(Function.prototype.toString.call(mapper));
241
523
  } catch (e) {
@@ -252,113 +534,281 @@ export function describePipeline(pipeline: unknown, opts: DescribeOptions): Mani
252
534
  else problems.push(`${at}: mapper threw: ${(e as Error)?.message ?? String(e)}`);
253
535
  return {};
254
536
  }
255
- const declared = s.input as Record<string, unknown>;
256
- for (const k of Object.keys(declared)) if (!(k in recorded)) problems.push(`${at}: mapper does not provide input '${k}'`);
257
- for (const k of Object.keys(recorded)) if (!(k in declared)) problems.push(`${at}: mapper provides '${k}', which is not an input of the step`);
258
- for (const [k, m] of Object.entries(recorded)) checkMapping(`${at} input '${k}'`, m, avail, isOptional(declared[k]), true);
537
+ const declared = Object.fromEntries(
538
+ Object.entries(s.input as Record<string, unknown>).filter(
539
+ ([, e]) => !isExternal(e),
540
+ ),
541
+ );
542
+ for (const k of Object.keys(declared))
543
+ if (!(k in recorded)) problems.push(`${at}: mapper does not provide input '${k}'`);
544
+ for (const k of Object.keys(recorded))
545
+ if (!(k in declared))
546
+ problems.push(`${at}: mapper provides '${k}', which is not an input of the step`);
547
+ for (const [k, m] of Object.entries(recorded))
548
+ checkMapping(`${at} input '${k}'`, m, avail, isOptional(declared[k]), true);
259
549
  return recorded;
260
550
  };
261
551
 
262
- const checkMapping = (at: string, m: Mapping, avail: Map<string, Avail>, optionalTarget: boolean, topLevel: boolean) => {
552
+ const checkMapping = (
553
+ at: string,
554
+ m: Mapping,
555
+ avail: Map<string, Avail>,
556
+ optionalTarget: boolean,
557
+ topLevel: boolean,
558
+ ) => {
263
559
  const keys = Object.keys(m);
264
- if (keys.length === 1 && keys[0] === "ref" && typeof (m as { ref: unknown }).ref === "string") {
265
- const [root, ...path] = (m as { ref: string }).ref.split(".") as [string, ...string[]];
560
+ if (
561
+ keys.length === 1 &&
562
+ keys[0] === 'ref' &&
563
+ typeof (m as { ref: unknown }).ref === 'string'
564
+ ) {
565
+ const [root, ...path] = (m as { ref: string }).ref.split('.') as [
566
+ string,
567
+ ...string[],
568
+ ];
266
569
  const a = avail.get(root);
267
570
  if (!a) return problems.push(`${at}: '${root}' is not available at this stage`);
268
- if (!resolveField(outputViewOf(a.model), path)) problems.push(`${at}: '${(m as { ref: string }).ref}' — model ${a.model} has no field '${path.join(".")}'`);
571
+ if (!resolveField(outputViewOf(a.model), path))
572
+ problems.push(
573
+ `${at}: '${(m as { ref: string }).ref}' — model ${a.model} has no field '${path.join('.')}'`,
574
+ );
269
575
  if (a.maybe && !(optionalTarget && topLevel))
270
- problems.push(`${at}: '${(m as { ref: string }).ref}' is only maybe-available (produced inside a when block); map it whole into an optional(...) input`);
576
+ problems.push(
577
+ `${at}: '${(m as { ref: string }).ref}' is only maybe-available (produced inside a when block); map it whole into an optional(...) input`,
578
+ );
271
579
  return;
272
580
  }
273
- if (keys.length === 1 && keys[0] === "const") return;
274
- for (const [k, x] of Object.entries(m as Record<string, Mapping>)) checkMapping(`${at}.${k}`, x, avail, optionalTarget, false);
581
+ if (keys.length === 1 && keys[0] === 'const') return;
582
+ for (const [k, x] of Object.entries(m as Record<string, Mapping>))
583
+ checkMapping(`${at}.${k}`, x, avail, optionalTarget, false);
275
584
  };
276
585
 
277
586
  // ── stages ──
278
- const walk = (nodes: readonly StageNode[], avail: Map<string, Avail>): Stage[] => {
587
+ let selects = 0;
588
+ const walk = (
589
+ nodes: readonly StageNode[],
590
+ avail: Map<string, Avail>,
591
+ cases: readonly CaseOf[] = [],
592
+ ): Stage[] => {
279
593
  const out: Stage[] = [];
280
594
  for (const n of nodes) {
281
- if (n.kind === "stage") {
282
- const descs = n.steps.map((ss) => {
283
- if (!isStep(ss.step)) {
284
- problems.push("a stage entry is not a step");
285
- return undefined;
286
- }
287
- registerStep(ss.step, "stage");
288
- return describeStep(ss, avail, false);
289
- }).filter((d): d is StepDescriptorT => d !== undefined);
595
+ if (n.kind === 'select') {
596
+ out.push(describeSelect(n, avail, cases, ++selects));
597
+ continue;
598
+ }
599
+ if (n.kind === 'stage') {
600
+ const descs = n.steps
601
+ .map((ss) => {
602
+ if (!isStep(ss.step)) {
603
+ problems.push('a stage entry is not a step');
604
+ return undefined;
605
+ }
606
+ registerStep(ss.step, 'stage', cases);
607
+ return describeStep(ss, avail, false, cases);
608
+ })
609
+ .filter((d): d is StepDescriptorT => d !== undefined);
290
610
  // Outputs become available only after the barrier (same-stage siblings can't see each other).
291
- for (const ss of n.steps) if (isStep(ss.step)) avail.set(ss.step.name, { model: ss.step.output?.name ?? null, maybe: false });
611
+ for (const ss of n.steps)
612
+ if (isStep(ss.step))
613
+ avail.set(ss.step.name, {
614
+ model: ss.step.output?.name ?? null,
615
+ maybe: false,
616
+ });
292
617
  if (descs.length) out.push({ steps: descs });
293
618
  } else {
294
619
  const when: Record<string, (string | number | boolean)[]> = {};
295
620
  const props = (inputOutputView()?.properties ?? {}) as Record<string, JsonSchema>;
296
621
  for (const [field, raw] of Object.entries(n.cond)) {
297
- const values = (Array.isArray(raw) ? raw : [raw]) as (string | number | boolean)[];
298
- if (!Object.hasOwn(props, field)) problems.push(`when: '${field}' is not a field of the pipeline input ${inputModel.name}`);
622
+ const values = (Array.isArray(raw) ? raw : [raw]) as (
623
+ string | number | boolean
624
+ )[];
625
+ if (!Object.hasOwn(props, field))
626
+ problems.push(
627
+ `when: '${field}' is not a field of the pipeline input ${inputModel.name}`,
628
+ );
299
629
  else {
300
630
  const literals = literalValues(props[field]);
301
- if (!literals) problems.push(`when: '${field}' is not a literal-like field (enum, boolean or literal)`);
302
- else for (const v of values) if (!literals.includes(v)) problems.push(`when: ${JSON.stringify(v)} is not a valid value of '${field}' (${literals.map((l) => JSON.stringify(l)).join(", ")})`);
631
+ if (!literals)
632
+ problems.push(
633
+ `when: '${field}' is not a literal-like field (enum, boolean or literal)`,
634
+ );
635
+ else
636
+ for (const v of values)
637
+ if (!literals.includes(v))
638
+ problems.push(
639
+ `when: ${JSON.stringify(v)} is not a valid value of '${field}' (${literals.map((l) => JSON.stringify(l)).join(', ')})`,
640
+ );
303
641
  }
304
642
  if (values.length === 0) problems.push(`when: '${field}' has no values`);
305
643
  when[field] = values;
306
644
  }
307
645
  const inner = new Map(avail);
308
- const stages = walk(n.nodes, inner);
309
- for (const [name, a] of inner) if (!avail.has(name)) avail.set(name, { model: a.model, maybe: true });
646
+ const stages = walk(n.nodes, inner, cases);
647
+ for (const [name, a] of inner)
648
+ if (!avail.has(name)) avail.set(name, { model: a.model, maybe: true });
310
649
  out.push({ when, stages });
311
650
  }
312
651
  }
313
652
  return out;
314
653
  };
315
- const stages = walk(data.nodes, new Map([["input", { model: inputModel.name, maybe: false }]]));
654
+ const describeSelect = (
655
+ n: Extract<StageNode, { kind: 'select' }>,
656
+ avail: Map<string, Avail>,
657
+ cases: readonly CaseOf[],
658
+ id: number,
659
+ ): Stage => {
660
+ if (!isConfigRef(n.on)) {
661
+ problems.push('select: the first argument must be a config reference');
662
+ return { select: '', discriminator: '', cases: {} };
663
+ }
664
+ const ref = configRefPath(n.on);
665
+ const { entry, case: kase } = configRefTarget(n.on);
666
+ useConfig(entry, `select '${ref}'`);
667
+ if (kase !== undefined && !cases.some((c) => c.cond === kase))
668
+ problems.push(
669
+ `select '${ref}': the reference is narrowed to the case '${kase}' and can only be used inside it`,
670
+ );
671
+ const union = unionAt(n.on);
672
+ if (!union)
673
+ problems.push(
674
+ `select '${ref}': it must reference a z.discriminatedUnion in config '${entry.name}'`,
675
+ );
676
+ else {
677
+ for (const v of union.values)
678
+ if (!Object.hasOwn(n.cases, v))
679
+ problems.push(`select '${ref}': no case for ${union.discriminator} '${v}'`);
680
+ for (const v of Object.keys(n.cases))
681
+ if (!union.values.includes(v))
682
+ problems.push(
683
+ `select '${ref}': '${v}' is not a value of ${union.discriminator} (${union.values.join(', ')})`,
684
+ );
685
+ }
686
+ const discriminator = union?.discriminator ?? '';
687
+ const branches = Object.entries(n.cases).map(([value, nodes]) => {
688
+ const inner = new Map(avail);
689
+ const stages = walk(nodes, inner, [
690
+ ...cases,
691
+ { select: id, value, cond: `${ref}.${discriminator}=${value}` },
692
+ ]);
693
+ return { value, inner, stages };
694
+ });
695
+ // A step every case has is as available as in its cases; one only some cases have is maybe-available.
696
+ const added = new Set(
697
+ branches.flatMap((b) => [...b.inner.keys()].filter((k) => !avail.has(k))),
698
+ );
699
+ for (const name of added) {
700
+ const present = branches
701
+ .map((b) => b.inner.get(name))
702
+ .filter((a): a is Avail => a !== undefined);
703
+ avail.set(name, {
704
+ model: present[0]!.model,
705
+ maybe: present.length < branches.length || present.some((a) => a.maybe),
706
+ });
707
+ }
708
+ return {
709
+ select: ref,
710
+ discriminator,
711
+ cases: Object.fromEntries(branches.map((b) => [b.value, b.stages])),
712
+ };
713
+ };
714
+ const stages = walk(
715
+ data.nodes,
716
+ new Map([['input', { model: inputModel.name, maybe: false }]]),
717
+ );
316
718
 
317
719
  // ── failure handlers ──
318
720
  const stageStepNames = new Set(steps.keys());
319
721
  const seenHandlers = new Map<string, AnyStep>();
320
- const failureHandlers = data.failureHandlers.map((fh) => {
321
- if (!isStep(fh.step)) {
322
- problems.push("onFailure: handler is not a step");
323
- return undefined;
324
- }
325
- if (seenHandlers.get(fh.step.name) !== fh.step) registerStep(fh.step, "onFailure");
326
- seenHandlers.set(fh.step.name, fh.step);
327
- const scope = fh.steps === null ? null : fh.steps.map((x) => x.name);
328
- for (const n of scope ?? []) if (!stageStepNames.has(n)) problems.push(`onFailure(${fh.step.name}): '${n}' is not a step of this pipeline`);
329
- return { step: describeStep({ step: fh.step }, new Map(), true), steps: scope };
330
- }).filter((x) => x !== undefined);
331
-
332
- // ── handler files without a step ──
722
+ const failureHandlers = data.failureHandlers
723
+ .map((fh) => {
724
+ if (!isStep(fh.step)) {
725
+ problems.push('onFailure: handler is not a step');
726
+ return undefined;
727
+ }
728
+ if (seenHandlers.get(fh.step.name) !== fh.step) registerStep(fh.step, 'onFailure');
729
+ seenHandlers.set(fh.step.name, fh.step);
730
+ const scope = fh.steps === null ? null : fh.steps.map((x) => x.name);
731
+ for (const n of scope ?? [])
732
+ if (!stageStepNames.has(n))
733
+ problems.push(
734
+ `onFailure(${fh.step.name}): '${n}' is not a step of this pipeline`,
735
+ );
736
+ return { step: describeStep({ step: fh.step }, new Map(), true), steps: scope };
737
+ })
738
+ .filter((x) => x !== undefined);
739
+
740
+ // ── handlers without a step ──
333
741
  for (const h of handlerNames) {
334
742
  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`);
743
+ if (!s)
744
+ problems.push(`a handler implements step '${h}', which is not in the pipeline`);
745
+ else if (isImplementation(s))
746
+ problems.push(
747
+ `a handler implements '${h}', a ${s.kind} step that carries its own implementation`,
748
+ );
337
749
  }
338
750
 
339
751
  // ── triggers ──
340
752
  const triggerNames = new Set<string>();
341
753
  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 and onWebhook a distinct name)"}`);
754
+ if (triggerNames.has(t.name))
755
+ problems.push(
756
+ `duplicate trigger '${t.name}'${t.kind === 'push' ? ' (onPush may be used once)' : ' (give each onPoll and onWebhook a distinct name)'}`,
757
+ );
343
758
  triggerNames.add(t.name);
344
- if (t.kind === "push") return { kind: "push", name: "push" };
345
- if (t.kind === "webhook") {
346
- if (!STEP_NAME.test(t.name)) problems.push(`webhook trigger name '${t.name}' must be lower-case kebab`);
759
+ if (t.kind === 'push') return { kind: 'push', name: 'push' };
760
+ if (t.kind === 'webhook') {
761
+ if (!STEP_NAME.test(t.name))
762
+ problems.push(`webhook trigger name '${t.name}' must be lower-case kebab`);
347
763
  const secret = useSecret(t.verify.secret, `webhook '${t.name}'`);
348
- if (t.verify.scheme === "bearer") return { kind: "webhook", name: t.name, verify: { scheme: "bearer", secret } };
349
- if (!t.verify.header) problems.push(`webhook '${t.name}': the signature header must not be empty`);
350
- return { kind: "webhook", name: t.name, verify: { scheme: "hmac-sha256", secret, header: t.verify.header, prefix: t.verify.prefix } };
764
+ if (t.verify.scheme === 'bearer')
765
+ return { kind: 'webhook', name: t.name, verify: { scheme: 'bearer', secret } };
766
+ if (!t.verify.header)
767
+ problems.push(`webhook '${t.name}': the signature header must not be empty`);
768
+ return {
769
+ kind: 'webhook',
770
+ name: t.name,
771
+ verify: {
772
+ scheme: 'hmac-sha256',
773
+ secret,
774
+ header: t.verify.header,
775
+ prefix: t.verify.prefix,
776
+ },
777
+ };
351
778
  }
352
- if (!STEP_NAME.test(t.name)) problems.push(`poll trigger name '${t.name}' must be lower-case kebab`);
353
- if (!(Number.isInteger(t.intervalSeconds) && t.intervalSeconds > 0)) problems.push(`poll '${t.name}': intervalSeconds must be a positive integer`);
779
+ if (!STEP_NAME.test(t.name))
780
+ problems.push(`poll trigger name '${t.name}' must be lower-case kebab`);
781
+ if (!(Number.isInteger(t.intervalSeconds) && t.intervalSeconds > 0))
782
+ problems.push(`poll '${t.name}': intervalSeconds must be a positive integer`);
354
783
  const declared = new Set(t.secrets.map((s) => useSecret(s, `poll '${t.name}'`)));
355
784
  for (const [h, v] of Object.entries(t.headers)) {
356
785
  for (const m of v.matchAll(/\$SECRET:([A-Za-z0-9_]+)/g))
357
- if (!declared.has(m[1]!)) problems.push(`poll '${t.name}': header '${h}' references secret '${m[1]}', which is not in the poll's secrets`);
786
+ if (!declared.has(m[1]!))
787
+ problems.push(
788
+ `poll '${t.name}': header '${h}' references secret '${m[1]}', which is not in the poll's secrets`,
789
+ );
358
790
  }
359
- return { kind: "poll", name: t.name, url: t.url, headers: { ...t.headers }, secrets: [...declared].sort(), valuePath: t.valuePath, intervalSeconds: t.intervalSeconds };
791
+ return {
792
+ kind: 'poll',
793
+ name: t.name,
794
+ url: t.url,
795
+ headers: { ...t.headers },
796
+ secrets: [...declared].sort(),
797
+ valuePath: t.valuePath,
798
+ intervalSeconds: t.intervalSeconds,
799
+ };
360
800
  });
361
801
 
802
+ // ── retention ──
803
+ const retention: Record<string, number> = {};
804
+ for (const key of ['keepLatestRuns', 'minRunAgeDays', 'buildGraceDays'] as const) {
805
+ const v = data.retention?.[key];
806
+ if (v === undefined) continue;
807
+ if (!(Number.isInteger(v) && v >= 0))
808
+ problems.push(`retention.${key} must be a non-negative integer`);
809
+ else retention[key] = v;
810
+ }
811
+
362
812
  if (problems.length) throw new DescribeError(problems);
363
813
 
364
814
  const manifest = canonicalize({
@@ -366,21 +816,139 @@ export function describePipeline(pipeline: unknown, opts: DescribeOptions): Mani
366
816
  name: data.name,
367
817
  description: data.description,
368
818
  input: inputModel.name,
819
+ ...(Object.keys(retention).length ? { retention } : {}),
369
820
  harness: opts.harness,
370
821
  models: Object.fromEntries([...models].map(([k, v]) => [k, v.schemas])),
371
- secrets: [...secrets].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)).map(([name, description]) => ({ name, description })),
822
+ secrets: [...secrets]
823
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
824
+ .map(([name, description]) => ({ name, description })),
825
+ ...(configs.size
826
+ ? {
827
+ config: [...configs]
828
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
829
+ .map(([name, c]) => ({
830
+ name,
831
+ description: c.description,
832
+ required: c.required,
833
+ schema: c.schema,
834
+ })),
835
+ }
836
+ : {}),
372
837
  triggers,
373
838
  stages,
374
839
  failureHandlers,
375
840
  });
376
841
  const checked = Manifest.safeParse(manifest);
377
- if (!checked.success) throw new DescribeError([`internal: manifest does not match its schema: ${z.prettifyError(checked.error)}`]);
842
+ if (!checked.success)
843
+ throw new DescribeError([
844
+ `internal: manifest does not match its schema: ${z.prettifyError(checked.error)}`,
845
+ ]);
378
846
  return manifest as ManifestT;
379
847
  }
380
848
 
381
849
  /** Canonical manifest text (what `describe` prints and the service hashes). */
382
850
  export const manifestText = (m: ManifestT): string => canonicalJson(m);
383
851
 
852
+ /**
853
+ * The schemas at `path` inside an entry's schema: every union branch that has the field, except where
854
+ * `kase` (`<entry>.<path>.<discriminator>=<value>`) narrows a union to one branch.
855
+ */
856
+ export function subSchemas(
857
+ schema: unknown,
858
+ path: readonly string[],
859
+ kase: string | undefined,
860
+ entry: string,
861
+ ): unknown[] {
862
+ let narrow: { at: string; discriminator: string; value: string } | undefined;
863
+ if (kase) {
864
+ const eq = kase.lastIndexOf('=');
865
+ const left = kase.slice(0, eq);
866
+ const dot = left.lastIndexOf('.');
867
+ narrow = {
868
+ at: left.slice(0, dot),
869
+ discriminator: left.slice(dot + 1),
870
+ value: kase.slice(eq + 1),
871
+ };
872
+ }
873
+ const walk = (node: unknown, rest: readonly string[], here: string): unknown[] => {
874
+ if (node === null || typeof node !== 'object') return [];
875
+ const o = node as JsonSchema;
876
+ const branches = (o.oneOf ?? o.anyOf) as JsonSchema[] | undefined;
877
+ if (Array.isArray(branches)) {
878
+ const chosen =
879
+ narrow && narrow.at === here
880
+ ? branches.filter(
881
+ (b) =>
882
+ (
883
+ (b.properties as Record<string, JsonSchema> | undefined)?.[
884
+ narrow!.discriminator
885
+ ] as JsonSchema | undefined
886
+ )?.const === narrow!.value,
887
+ )
888
+ : branches;
889
+ return chosen.flatMap((b) => walk(b, rest, here));
890
+ }
891
+ if (rest.length === 0) return [node];
892
+ const props = o.properties as Record<string, JsonSchema> | undefined;
893
+ if (props && Object.hasOwn(props, rest[0]!))
894
+ return walk(props[rest[0]!], rest.slice(1), `${here}.${rest[0]}`);
895
+ return [];
896
+ };
897
+ return walk(schema, path, entry);
898
+ }
899
+
900
+ /** Secret names of every secretRef inside a config entry's JSON Schema. */
901
+ export function secretRefsOf(schema: unknown): string[] {
902
+ const out = new Set<string>();
903
+ const walk = (v: unknown) => {
904
+ if (Array.isArray(v)) return v.forEach(walk);
905
+ if (v === null || typeof v !== 'object') return;
906
+ const o = v as JsonSchema;
907
+ if (o['x-olve-kind'] === SECRET_REF_KIND) {
908
+ const name = (o.properties as { secret?: { const?: unknown } } | undefined)?.secret
909
+ ?.const;
910
+ if (typeof name === 'string') out.add(name);
911
+ }
912
+ Object.values(o).forEach(walk);
913
+ };
914
+ walk(schema);
915
+ return [...out].sort();
916
+ }
917
+
918
+ /** The `{"$config": "<entry>.<path>"[, "$case": …]}` references in a step's recorded settings. */
919
+ export function recordedConfigRefs(
920
+ v: unknown,
921
+ out: { ref: string; case?: string }[] = [],
922
+ ): { ref: string; case?: string }[] {
923
+ if (Array.isArray(v)) v.forEach((x) => recordedConfigRefs(x, out));
924
+ else if (v !== null && typeof v === 'object') {
925
+ const o = v as Record<string, unknown>;
926
+ const keys = Object.keys(o).sort();
927
+ const isRef =
928
+ typeof o[CONFIG_REF_KEY] === 'string' &&
929
+ (keys.length === 1
930
+ ? keys[0] === CONFIG_REF_KEY
931
+ : keys.length === 2 &&
932
+ keys[0] === CONFIG_CASE_KEY &&
933
+ keys[1] === CONFIG_REF_KEY &&
934
+ typeof o[CONFIG_CASE_KEY] === 'string');
935
+ if (isRef)
936
+ out.push({
937
+ ref: o[CONFIG_REF_KEY] as string,
938
+ ...(keys.length === 2 ? { case: o[CONFIG_CASE_KEY] as string } : {}),
939
+ });
940
+ else Object.values(o).forEach((x) => recordedConfigRefs(x, out));
941
+ }
942
+ return out;
943
+ }
944
+
945
+ /** A select case a step sits in: which select (numbered in describe order), which value, and its condition text. */
946
+ interface CaseOf {
947
+ select: number;
948
+ value: string;
949
+ cond: string;
950
+ }
951
+
384
952
  function camel(name: string): string {
385
953
  return name.replace(/-([a-z0-9])/g, (_, c: string) => c.toUpperCase());
386
954
  }