@olivervea/pipelines 2.0.0-beta.27 → 2.0.0-beta.29

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