@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.
- package/README.md +2 -2
- package/package.json +1 -1
- package/src/describe/config-schema.ts +98 -0
- package/src/describe/context.ts +182 -0
- package/src/describe/describe.ts +37 -851
- package/src/describe/failure-handlers.ts +34 -0
- package/src/describe/schemas.ts +71 -0
- package/src/describe/stages.ts +165 -0
- package/src/describe/step.ts +395 -0
- package/src/describe/triggers.ts +72 -0
- package/src/library/image-build.ts +31 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { isStep, type AnyStep } from '../core/steps.ts';
|
|
2
|
+
import type { FailureHandlerNode } from '../builder/pipeline.ts';
|
|
3
|
+
import type { ManifestT } from './manifest-schema.ts';
|
|
4
|
+
import type { DescribeContext } from './context.ts';
|
|
5
|
+
import { describeStep } from './step.ts';
|
|
6
|
+
|
|
7
|
+
/** Describes the `onFailure` handlers; call after the stages, so their scopes can name the stage steps. */
|
|
8
|
+
export function describeFailureHandlers(
|
|
9
|
+
ctx: DescribeContext,
|
|
10
|
+
handlers: readonly FailureHandlerNode[],
|
|
11
|
+
): ManifestT['failureHandlers'] {
|
|
12
|
+
const stageStepNames = new Set(ctx.steps.keys());
|
|
13
|
+
const seen = new Map<string, AnyStep>();
|
|
14
|
+
return handlers
|
|
15
|
+
.map((fh) => {
|
|
16
|
+
if (!isStep(fh.step)) {
|
|
17
|
+
ctx.problems.push('onFailure: handler is not a step');
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
if (seen.get(fh.step.name) !== fh.step) ctx.registerStep(fh.step, 'onFailure');
|
|
21
|
+
seen.set(fh.step.name, fh.step);
|
|
22
|
+
const scope = fh.steps === null ? null : fh.steps.map((x) => x.name);
|
|
23
|
+
for (const n of scope ?? [])
|
|
24
|
+
if (!stageStepNames.has(n))
|
|
25
|
+
ctx.problems.push(
|
|
26
|
+
`onFailure(${fh.step.name}): '${n}' is not a step of this pipeline`,
|
|
27
|
+
);
|
|
28
|
+
return {
|
|
29
|
+
step: describeStep(ctx, { step: fh.step }, new Map(), true),
|
|
30
|
+
steps: scope,
|
|
31
|
+
};
|
|
32
|
+
})
|
|
33
|
+
.filter((x) => x !== undefined);
|
|
34
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { canonicalize } from '../util/canonical.ts';
|
|
3
|
+
import type { ModelSchemasT } from './manifest-schema.ts';
|
|
4
|
+
|
|
5
|
+
export type JsonSchema = Record<string, unknown>;
|
|
6
|
+
|
|
7
|
+
function stripStrict(v: unknown): unknown {
|
|
8
|
+
if (Array.isArray(v)) return v.map(stripStrict);
|
|
9
|
+
if (v !== null && typeof v === 'object') {
|
|
10
|
+
const out: Record<string, unknown> = {};
|
|
11
|
+
for (const [k, x] of Object.entries(v))
|
|
12
|
+
if (!(k === 'additionalProperties' && x === false)) out[k] = stripStrict(x);
|
|
13
|
+
return out;
|
|
14
|
+
}
|
|
15
|
+
return v;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** JSON Schema views of a zod schema. Throws if it is not representable (dates, transforms, …). */
|
|
19
|
+
export function schemaViews(schema: z.ZodType): ModelSchemasT {
|
|
20
|
+
const view = (io: 'input' | 'output') => {
|
|
21
|
+
const js = z.toJSONSchema(schema, { io, target: 'draft-2020-12' }) as JsonSchema;
|
|
22
|
+
delete js.$schema; // constant noise; apiVersion pins the dialect
|
|
23
|
+
return canonicalize(js) as JsonSchema;
|
|
24
|
+
};
|
|
25
|
+
const output = view('output');
|
|
26
|
+
const input = view('input');
|
|
27
|
+
const same = JSON.stringify(stripStrict(output)) === JSON.stringify(input);
|
|
28
|
+
return same ? { output } : { output, input };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Whether `segments` lead to a field of a JSON Schema. */
|
|
32
|
+
export function resolveField(
|
|
33
|
+
schema: JsonSchema | undefined,
|
|
34
|
+
segments: readonly string[],
|
|
35
|
+
): boolean {
|
|
36
|
+
if (!schema) return false;
|
|
37
|
+
if (segments.length === 0) return true;
|
|
38
|
+
const [seg, ...rest] = segments as [string, ...string[]];
|
|
39
|
+
if (Array.isArray(schema.anyOf))
|
|
40
|
+
return (schema.anyOf as JsonSchema[]).some((b) => resolveField(b, segments));
|
|
41
|
+
if (Array.isArray(schema.oneOf))
|
|
42
|
+
return (schema.oneOf as JsonSchema[]).some((b) => resolveField(b, segments));
|
|
43
|
+
if (Array.isArray(schema.allOf))
|
|
44
|
+
return (schema.allOf as JsonSchema[]).some((b) => resolveField(b, segments));
|
|
45
|
+
const props = schema.properties as Record<string, JsonSchema> | undefined;
|
|
46
|
+
if (props && Object.hasOwn(props, seg)) return resolveField(props[seg], rest);
|
|
47
|
+
const extra = schema.additionalProperties;
|
|
48
|
+
if (extra && typeof extra === 'object') return resolveField(extra as JsonSchema, rest);
|
|
49
|
+
// A schema without type/properties/anyOf accepts anything (z.unknown / z.any).
|
|
50
|
+
return Object.keys(schema).every(
|
|
51
|
+
(k) => k === 'description' || k === 'title' || k === 'default',
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function literalValues(field: JsonSchema | undefined): unknown[] | undefined {
|
|
56
|
+
if (!field) return undefined;
|
|
57
|
+
if (Array.isArray(field.enum)) return field.enum as unknown[];
|
|
58
|
+
if (field.const !== undefined) return [field.const];
|
|
59
|
+
if (field.type === 'boolean') return [true, false];
|
|
60
|
+
const alts = (field.anyOf ?? field.oneOf) as JsonSchema[] | undefined;
|
|
61
|
+
if (Array.isArray(alts)) {
|
|
62
|
+
const all: unknown[] = [];
|
|
63
|
+
for (const a of alts) {
|
|
64
|
+
const l = literalValues(a);
|
|
65
|
+
if (!l) return undefined;
|
|
66
|
+
all.push(...l);
|
|
67
|
+
}
|
|
68
|
+
return all;
|
|
69
|
+
}
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { configRefPath, configRefTarget, isConfigRef, unionAt } from '../core/config.ts';
|
|
2
|
+
import { isStep } from '../core/steps.ts';
|
|
3
|
+
import type { StageNode } from '../builder/pipeline.ts';
|
|
4
|
+
import type { Stage, StepDescriptorT } from './manifest-schema.ts';
|
|
5
|
+
import { literalValues, type JsonSchema } from './schemas.ts';
|
|
6
|
+
import type { Avail, CaseOf, DescribeContext } from './context.ts';
|
|
7
|
+
import { describeStep } from './step.ts';
|
|
8
|
+
|
|
9
|
+
type StageGroup = Extract<StageNode, { kind: 'stage' }>;
|
|
10
|
+
type WhenNode = Extract<StageNode, { kind: 'when' }>;
|
|
11
|
+
type SelectNode = Extract<StageNode, { kind: 'select' }>;
|
|
12
|
+
|
|
13
|
+
/** Describes stages, `when` blocks and selects in order, adding what each makes available to `avail`. */
|
|
14
|
+
export function describeStages(
|
|
15
|
+
ctx: DescribeContext,
|
|
16
|
+
input: { name: string },
|
|
17
|
+
nodes: readonly StageNode[],
|
|
18
|
+
avail: Map<string, Avail>,
|
|
19
|
+
cases: readonly CaseOf[] = [],
|
|
20
|
+
): Stage[] {
|
|
21
|
+
const out: Stage[] = [];
|
|
22
|
+
for (const n of nodes) {
|
|
23
|
+
if (n.kind === 'select') {
|
|
24
|
+
out.push(describeSelect(ctx, input, n, avail, cases, ctx.nextSelectId()));
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (n.kind === 'stage') {
|
|
28
|
+
const stage = describeStage(ctx, n, avail, cases);
|
|
29
|
+
if (stage) out.push(stage);
|
|
30
|
+
} else out.push(describeWhen(ctx, input, n, avail, cases));
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function describeStage(
|
|
36
|
+
ctx: DescribeContext,
|
|
37
|
+
n: StageGroup,
|
|
38
|
+
avail: Map<string, Avail>,
|
|
39
|
+
cases: readonly CaseOf[],
|
|
40
|
+
): Stage | undefined {
|
|
41
|
+
const descs = n.steps
|
|
42
|
+
.map((ss) => {
|
|
43
|
+
if (!isStep(ss.step)) {
|
|
44
|
+
ctx.problems.push('a stage entry is not a step');
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
ctx.registerStep(ss.step, 'stage', cases);
|
|
48
|
+
return describeStep(ctx, ss, avail, false, cases);
|
|
49
|
+
})
|
|
50
|
+
.filter((d): d is StepDescriptorT => d !== undefined);
|
|
51
|
+
// Outputs become available only after the barrier (same-stage siblings can't see each other).
|
|
52
|
+
for (const ss of n.steps)
|
|
53
|
+
if (isStep(ss.step))
|
|
54
|
+
avail.set(ss.step.name, {
|
|
55
|
+
model: ss.step.output?.name ?? null,
|
|
56
|
+
maybe: false,
|
|
57
|
+
});
|
|
58
|
+
return descs.length ? { steps: descs } : undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function describeWhen(
|
|
62
|
+
ctx: DescribeContext,
|
|
63
|
+
input: { name: string },
|
|
64
|
+
n: WhenNode,
|
|
65
|
+
avail: Map<string, Avail>,
|
|
66
|
+
cases: readonly CaseOf[],
|
|
67
|
+
): Stage {
|
|
68
|
+
const problems = ctx.problems;
|
|
69
|
+
const when: Record<string, (string | number | boolean)[]> = {};
|
|
70
|
+
const props = (ctx.models.get(input.name)?.schemas.output.properties ?? {}) as Record<
|
|
71
|
+
string,
|
|
72
|
+
JsonSchema
|
|
73
|
+
>;
|
|
74
|
+
for (const [field, raw] of Object.entries(n.cond)) {
|
|
75
|
+
const values = (Array.isArray(raw) ? raw : [raw]) as (string | number | boolean)[];
|
|
76
|
+
if (!Object.hasOwn(props, field))
|
|
77
|
+
problems.push(
|
|
78
|
+
`when: '${field}' is not a field of the pipeline input ${input.name}`,
|
|
79
|
+
);
|
|
80
|
+
else {
|
|
81
|
+
const literals = literalValues(props[field]);
|
|
82
|
+
if (!literals)
|
|
83
|
+
problems.push(
|
|
84
|
+
`when: '${field}' is not a literal-like field (enum, boolean or literal)`,
|
|
85
|
+
);
|
|
86
|
+
else
|
|
87
|
+
for (const v of values)
|
|
88
|
+
if (!literals.includes(v))
|
|
89
|
+
problems.push(
|
|
90
|
+
`when: ${JSON.stringify(v)} is not a valid value of '${field}' (${literals.map((l) => JSON.stringify(l)).join(', ')})`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
if (values.length === 0) problems.push(`when: '${field}' has no values`);
|
|
94
|
+
when[field] = values;
|
|
95
|
+
}
|
|
96
|
+
const inner = new Map(avail);
|
|
97
|
+
const stages = describeStages(ctx, input, n.nodes, inner, cases);
|
|
98
|
+
for (const [name, a] of inner)
|
|
99
|
+
if (!avail.has(name)) avail.set(name, { model: a.model, maybe: true });
|
|
100
|
+
return { when, stages };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function describeSelect(
|
|
104
|
+
ctx: DescribeContext,
|
|
105
|
+
input: { name: string },
|
|
106
|
+
n: SelectNode,
|
|
107
|
+
avail: Map<string, Avail>,
|
|
108
|
+
cases: readonly CaseOf[],
|
|
109
|
+
id: number,
|
|
110
|
+
): Stage {
|
|
111
|
+
const problems = ctx.problems;
|
|
112
|
+
if (!isConfigRef(n.on)) {
|
|
113
|
+
problems.push('select: the first argument must be a config reference');
|
|
114
|
+
return { select: '', discriminator: '', cases: {} };
|
|
115
|
+
}
|
|
116
|
+
const ref = configRefPath(n.on);
|
|
117
|
+
const { entry, case: kase } = configRefTarget(n.on);
|
|
118
|
+
ctx.useConfig(entry, `select '${ref}'`);
|
|
119
|
+
if (kase !== undefined && !cases.some((c) => c.cond === kase))
|
|
120
|
+
problems.push(
|
|
121
|
+
`select '${ref}': the reference is narrowed to the case '${kase}' and can only be used inside it`,
|
|
122
|
+
);
|
|
123
|
+
const union = unionAt(n.on);
|
|
124
|
+
if (!union)
|
|
125
|
+
problems.push(
|
|
126
|
+
`select '${ref}': it must reference a z.discriminatedUnion in config '${entry.name}'`,
|
|
127
|
+
);
|
|
128
|
+
else {
|
|
129
|
+
for (const v of union.values)
|
|
130
|
+
if (!Object.hasOwn(n.cases, v))
|
|
131
|
+
problems.push(`select '${ref}': no case for ${union.discriminator} '${v}'`);
|
|
132
|
+
for (const v of Object.keys(n.cases))
|
|
133
|
+
if (!union.values.includes(v))
|
|
134
|
+
problems.push(
|
|
135
|
+
`select '${ref}': '${v}' is not a value of ${union.discriminator} (${union.values.join(', ')})`,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
const discriminator = union?.discriminator ?? '';
|
|
139
|
+
const branches = Object.entries(n.cases).map(([value, nodes]) => {
|
|
140
|
+
const inner = new Map(avail);
|
|
141
|
+
const stages = describeStages(ctx, input, nodes, inner, [
|
|
142
|
+
...cases,
|
|
143
|
+
{ select: id, value, cond: `${ref}.${discriminator}=${value}` },
|
|
144
|
+
]);
|
|
145
|
+
return { value, inner, stages };
|
|
146
|
+
});
|
|
147
|
+
// A step every case has is as available as in its cases; one only some cases have is maybe-available.
|
|
148
|
+
const added = new Set(
|
|
149
|
+
branches.flatMap((b) => [...b.inner.keys()].filter((k) => !avail.has(k))),
|
|
150
|
+
);
|
|
151
|
+
for (const name of added) {
|
|
152
|
+
const present = branches
|
|
153
|
+
.map((b) => b.inner.get(name))
|
|
154
|
+
.filter((a): a is Avail => a !== undefined);
|
|
155
|
+
avail.set(name, {
|
|
156
|
+
model: present[0]!.model,
|
|
157
|
+
maybe: present.length < branches.length || present.some((a) => a.maybe),
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
return {
|
|
161
|
+
select: ref,
|
|
162
|
+
discriminator,
|
|
163
|
+
cases: Object.fromEntries(branches.map((b) => [b.value, b.stages])),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { isModel, isOptional } from '../core/models.ts';
|
|
3
|
+
import { isExternal } from '../core/external.ts';
|
|
4
|
+
import type { AnyStep } from '../core/steps.ts';
|
|
5
|
+
import { isImplementation } from '../core/handlers.ts';
|
|
6
|
+
import { recordConfigRefs } from '../core/config-refs.ts';
|
|
7
|
+
import type { MapperFunction, StageStepNode } from '../builder/pipeline.ts';
|
|
8
|
+
import { FailureContext } from '../library/models.ts';
|
|
9
|
+
import { canonicalize } from '../util/canonical.ts';
|
|
10
|
+
import { analyzeMapperSource } from './mapper-source.ts';
|
|
11
|
+
import { MapperError, recordMapper, type Mapping } from './record.ts';
|
|
12
|
+
import type { StepDescriptorT } from './manifest-schema.ts';
|
|
13
|
+
import { recordedConfigRefs, secretRefsOf, subSchemas } from './config-schema.ts';
|
|
14
|
+
import { resolveField, schemaViews } from './schemas.ts';
|
|
15
|
+
import {
|
|
16
|
+
FAILURE_ROOT,
|
|
17
|
+
type Avail,
|
|
18
|
+
type CaseOf,
|
|
19
|
+
type DescribeContext,
|
|
20
|
+
} from './context.ts';
|
|
21
|
+
|
|
22
|
+
type InputTypes = StepDescriptorT['inputTypes'];
|
|
23
|
+
type ExternalInputs = NonNullable<StepDescriptorT['externalInputs']>;
|
|
24
|
+
type InputEntries = [string, unknown][];
|
|
25
|
+
|
|
26
|
+
export function describeStep(
|
|
27
|
+
ctx: DescribeContext,
|
|
28
|
+
node: StageStepNode,
|
|
29
|
+
avail: Map<string, Avail>,
|
|
30
|
+
failureHandler: boolean,
|
|
31
|
+
cases: readonly CaseOf[] = [],
|
|
32
|
+
): StepDescriptorT {
|
|
33
|
+
const problems = ctx.problems;
|
|
34
|
+
const s = node.step;
|
|
35
|
+
const at = `step '${s.name}'`;
|
|
36
|
+
if (typeof s.image !== 'string' || s.image.trim() === '')
|
|
37
|
+
problems.push(`${at}: image must be a non-empty string`);
|
|
38
|
+
|
|
39
|
+
const allEntries = Object.entries(s.input as Record<string, unknown>);
|
|
40
|
+
const externalInputs = describeExternalInputs(ctx, at, allEntries, failureHandler);
|
|
41
|
+
const entries = allEntries.filter(([, e]) => !isExternal(e));
|
|
42
|
+
const inputTypes = describeInputTypes(ctx, at, entries);
|
|
43
|
+
|
|
44
|
+
let input: Record<string, Mapping>;
|
|
45
|
+
if (failureHandler) input = failureHandlerInput(ctx, at, entries, node);
|
|
46
|
+
else if (node.mapper) input = mapStep(ctx, at, s, node.mapper, avail);
|
|
47
|
+
else input = matchInputsByModel(ctx, at, entries, avail);
|
|
48
|
+
|
|
49
|
+
for (const key of Object.keys(s.input as Record<string, unknown>))
|
|
50
|
+
if (key.startsWith('$'))
|
|
51
|
+
problems.push(`${at}: input '${key}': keys starting with '$' are reserved`);
|
|
52
|
+
const secretNames = s.secrets.map((x) => ctx.useSecret(x, at)).sort();
|
|
53
|
+
const configNames = [
|
|
54
|
+
...new Set(
|
|
55
|
+
(s.config ?? [])
|
|
56
|
+
.map((c) => ctx.useConfig(c, at))
|
|
57
|
+
.filter((n): n is string => n !== undefined),
|
|
58
|
+
),
|
|
59
|
+
].sort();
|
|
60
|
+
const carried = isImplementation(s);
|
|
61
|
+
const { settings, settingsProblem } = recordSettings(s, carried);
|
|
62
|
+
const refs = settings ? recordedConfigRefs(settings) : [];
|
|
63
|
+
checkConfigSecrets(ctx, at, configNames, refs, secretNames);
|
|
64
|
+
if (settingsProblem !== undefined)
|
|
65
|
+
problems.push(`${at}: invalid ${s.kind} settings (${settingsProblem})`);
|
|
66
|
+
if (typeof s.kind !== 'string' || s.kind === '')
|
|
67
|
+
problems.push(`${at}: kind must be a non-empty string`);
|
|
68
|
+
checkConfigRefs(ctx, at, refs, configNames, cases);
|
|
69
|
+
if (!carried)
|
|
70
|
+
problems.push(`${at} has no implementation (stage implement(step({...}), fn))`);
|
|
71
|
+
if (typeof s.description !== 'string')
|
|
72
|
+
problems.push(`${at}: description must be a string (it may be empty)`);
|
|
73
|
+
if (
|
|
74
|
+
s.timeoutSeconds !== undefined &&
|
|
75
|
+
!(Number.isInteger(s.timeoutSeconds) && s.timeoutSeconds > 0)
|
|
76
|
+
)
|
|
77
|
+
problems.push(`${at}: timeoutSeconds must be a positive integer`);
|
|
78
|
+
if (s.retries !== undefined && !(Number.isInteger(s.retries) && s.retries >= 0))
|
|
79
|
+
problems.push(`${at}: retries must be a non-negative integer`);
|
|
80
|
+
|
|
81
|
+
const out: StepDescriptorT = {
|
|
82
|
+
name: s.name,
|
|
83
|
+
description: typeof s.description === 'string' ? s.description : '',
|
|
84
|
+
kind: s.kind,
|
|
85
|
+
image: s.image,
|
|
86
|
+
inputTypes,
|
|
87
|
+
input,
|
|
88
|
+
output: s.output ? ctx.useModel(s.output, `${at} output`) : null,
|
|
89
|
+
secrets: secretNames,
|
|
90
|
+
...(s.timeoutSeconds !== undefined ? { timeoutSeconds: s.timeoutSeconds } : {}),
|
|
91
|
+
...(s.retries !== undefined ? { retries: s.retries } : {}),
|
|
92
|
+
...(settings !== undefined
|
|
93
|
+
? { config: settings as Record<string, z.core.util.JSONType> }
|
|
94
|
+
: {}),
|
|
95
|
+
...(configNames.length ? { configEntries: configNames } : {}),
|
|
96
|
+
...(s.clusterAccess === true ? { clusterAccess: true as const } : {}),
|
|
97
|
+
...(Object.keys(externalInputs).length ? { externalInputs } : {}),
|
|
98
|
+
};
|
|
99
|
+
if (s.clusterAccess !== undefined && typeof s.clusterAccess !== 'boolean')
|
|
100
|
+
problems.push(`${at}: clusterAccess must be a boolean`);
|
|
101
|
+
if (s.output && !isModel(s.output)) problems.push(`${at}: output must be a model`);
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function describeExternalInputs(
|
|
106
|
+
ctx: DescribeContext,
|
|
107
|
+
at: string,
|
|
108
|
+
allEntries: InputEntries,
|
|
109
|
+
failureHandler: boolean,
|
|
110
|
+
): ExternalInputs {
|
|
111
|
+
const problems = ctx.problems;
|
|
112
|
+
const externalInputs: ExternalInputs = {};
|
|
113
|
+
for (const [key, e] of allEntries) {
|
|
114
|
+
if (!isExternal(e)) continue;
|
|
115
|
+
if (failureHandler)
|
|
116
|
+
problems.push(
|
|
117
|
+
`${at}: failure handlers run unattended and cannot wait for input ('${key}')`,
|
|
118
|
+
);
|
|
119
|
+
if (typeof e.title !== 'string' || e.title.trim() === '')
|
|
120
|
+
problems.push(`${at} external input '${key}': title must not be empty`);
|
|
121
|
+
if (e.from.length === 0)
|
|
122
|
+
problems.push(`${at} external input '${key}': from must list at least one source`);
|
|
123
|
+
if (new Set(e.from.map((f) => f.kind)).size !== e.from.length)
|
|
124
|
+
problems.push(`${at} external input '${key}': each source kind may be listed once`);
|
|
125
|
+
if (e.from.some((f) => f.kind === 'callback') && !/^[A-Za-z0-9_-]+$/.test(key))
|
|
126
|
+
problems.push(
|
|
127
|
+
`${at} external input '${key}': a callback input's key must be letters, digits, '_' or '-' (it is part of the URL)`,
|
|
128
|
+
);
|
|
129
|
+
for (const f of e.from) {
|
|
130
|
+
if (f.kind !== 'poll') continue;
|
|
131
|
+
if (!/^https?:\/\//.test(f.url))
|
|
132
|
+
problems.push(
|
|
133
|
+
`${at} external input '${key}': poll url must be an absolute http(s) URL`,
|
|
134
|
+
);
|
|
135
|
+
if (!Number.isInteger(f.intervalSeconds) || f.intervalSeconds < 10)
|
|
136
|
+
problems.push(
|
|
137
|
+
`${at} external input '${key}': poll intervalSeconds must be an integer of at least 10`,
|
|
138
|
+
);
|
|
139
|
+
if (f.valuePath.trim() === '')
|
|
140
|
+
problems.push(`${at} external input '${key}': poll valuePath must not be empty`);
|
|
141
|
+
}
|
|
142
|
+
externalInputs[key] = {
|
|
143
|
+
model: ctx.useModel(e.model, `${at} external input '${key}'`),
|
|
144
|
+
title: e.title,
|
|
145
|
+
description: e.description,
|
|
146
|
+
from: e.from.map((f) => ({ ...f })),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
return externalInputs;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function describeInputTypes(
|
|
153
|
+
ctx: DescribeContext,
|
|
154
|
+
at: string,
|
|
155
|
+
entries: InputEntries,
|
|
156
|
+
): InputTypes {
|
|
157
|
+
const inputTypes: InputTypes = {};
|
|
158
|
+
for (const [key, e] of entries) {
|
|
159
|
+
if (isOptional(e))
|
|
160
|
+
inputTypes[key] = {
|
|
161
|
+
model: ctx.useModel(e.model, `${at} input '${key}'`),
|
|
162
|
+
optional: true,
|
|
163
|
+
};
|
|
164
|
+
else if (isModel(e))
|
|
165
|
+
inputTypes[key] = {
|
|
166
|
+
model: ctx.useModel(e, `${at} input '${key}'`),
|
|
167
|
+
optional: false,
|
|
168
|
+
};
|
|
169
|
+
else if (e instanceof z.ZodType) {
|
|
170
|
+
try {
|
|
171
|
+
inputTypes[key] = { schema: schemaViews(e) };
|
|
172
|
+
} catch (err) {
|
|
173
|
+
ctx.problems.push(
|
|
174
|
+
`${at} input '${key}': schema is not representable as JSON Schema (${(err as Error).message})`,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
} else
|
|
178
|
+
ctx.problems.push(
|
|
179
|
+
`${at} input '${key}': must be a model, optional(model) or a zod schema`,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
return inputTypes;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function failureHandlerInput(
|
|
186
|
+
ctx: DescribeContext,
|
|
187
|
+
at: string,
|
|
188
|
+
entries: InputEntries,
|
|
189
|
+
node: StageStepNode,
|
|
190
|
+
): Record<string, Mapping> {
|
|
191
|
+
const input: Record<string, Mapping> = {};
|
|
192
|
+
for (const [key, e] of entries) {
|
|
193
|
+
if (e !== FailureContext && !(isModel(e) && e.name === FailureContext.name))
|
|
194
|
+
ctx.problems.push(
|
|
195
|
+
`${at}: a failure handler's inputs must all be FailureContext (input '${key}' is not)`,
|
|
196
|
+
);
|
|
197
|
+
input[key] = { ref: FAILURE_ROOT };
|
|
198
|
+
}
|
|
199
|
+
if (node.mapper) ctx.problems.push(`${at}: failure handlers take no mapper`);
|
|
200
|
+
return input;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function matchInputsByModel(
|
|
204
|
+
ctx: DescribeContext,
|
|
205
|
+
at: string,
|
|
206
|
+
entries: InputEntries,
|
|
207
|
+
avail: Map<string, Avail>,
|
|
208
|
+
): Record<string, Mapping> {
|
|
209
|
+
const problems = ctx.problems;
|
|
210
|
+
const input: Record<string, Mapping> = {};
|
|
211
|
+
for (const [key, e] of entries) {
|
|
212
|
+
const optional = isOptional(e);
|
|
213
|
+
const m = optional ? e.model : isModel(e) ? e : undefined;
|
|
214
|
+
if (!m) {
|
|
215
|
+
problems.push(
|
|
216
|
+
`${at}: input '${key}' is a plain schema, not a model, so it cannot be matched by model; add a mapper ([step, from => ({ ... })])`,
|
|
217
|
+
);
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
const producers = [...avail].filter(([, a]) => a.model === m.name);
|
|
221
|
+
if (producers.length === 0) {
|
|
222
|
+
problems.push(
|
|
223
|
+
`${at}: ${m.name} (input '${key}') is not available at this stage${optional ? ' (optional() means maybe-skipped; there must still be an earlier producer)' : ''}`,
|
|
224
|
+
);
|
|
225
|
+
} else if (producers.length > 1) {
|
|
226
|
+
problems.push(
|
|
227
|
+
`${at}: ${m.name} (input '${key}') has several producers (${producers
|
|
228
|
+
.map(([n]) => n)
|
|
229
|
+
.sort()
|
|
230
|
+
.join(', ')}); use a mapper to pick one`,
|
|
231
|
+
);
|
|
232
|
+
} else {
|
|
233
|
+
const [producer, a] = producers[0]!;
|
|
234
|
+
if (a.maybe && !optional)
|
|
235
|
+
problems.push(
|
|
236
|
+
`${at}: ${m.name} (input '${key}') is only maybe-available (produced inside a when block); declare it optional(${m.name})`,
|
|
237
|
+
);
|
|
238
|
+
input[key] = { ref: producer };
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return input;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function mapStep(
|
|
245
|
+
ctx: DescribeContext,
|
|
246
|
+
at: string,
|
|
247
|
+
s: AnyStep,
|
|
248
|
+
mapper: MapperFunction,
|
|
249
|
+
avail: Map<string, Avail>,
|
|
250
|
+
): Record<string, Mapping> {
|
|
251
|
+
const problems = ctx.problems;
|
|
252
|
+
try {
|
|
253
|
+
analyzeMapperSource(Function.prototype.toString.call(mapper));
|
|
254
|
+
} catch (e) {
|
|
255
|
+
if (e instanceof MapperError) problems.push(`${at}: ${e.message}`);
|
|
256
|
+
else throw e;
|
|
257
|
+
return {};
|
|
258
|
+
}
|
|
259
|
+
const roots = new Set([...avail].filter(([, a]) => a.model !== null).map(([n]) => n));
|
|
260
|
+
let recorded: Record<string, Mapping>;
|
|
261
|
+
try {
|
|
262
|
+
recorded = recordMapper(mapper, roots);
|
|
263
|
+
} catch (e) {
|
|
264
|
+
if (e instanceof MapperError) problems.push(`${at}: ${e.message}`);
|
|
265
|
+
else problems.push(`${at}: mapper threw: ${(e as Error)?.message ?? String(e)}`);
|
|
266
|
+
return {};
|
|
267
|
+
}
|
|
268
|
+
const declared = Object.fromEntries(
|
|
269
|
+
Object.entries(s.input as Record<string, unknown>).filter(([, e]) => !isExternal(e)),
|
|
270
|
+
);
|
|
271
|
+
for (const k of Object.keys(declared))
|
|
272
|
+
if (!(k in recorded)) problems.push(`${at}: mapper does not provide input '${k}'`);
|
|
273
|
+
for (const k of Object.keys(recorded))
|
|
274
|
+
if (!(k in declared))
|
|
275
|
+
problems.push(`${at}: mapper provides '${k}', which is not an input of the step`);
|
|
276
|
+
for (const [k, m] of Object.entries(recorded))
|
|
277
|
+
checkMapping(ctx, `${at} input '${k}'`, m, avail, isOptional(declared[k]), true);
|
|
278
|
+
return recorded;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function checkMapping(
|
|
282
|
+
ctx: DescribeContext,
|
|
283
|
+
at: string,
|
|
284
|
+
m: Mapping,
|
|
285
|
+
avail: Map<string, Avail>,
|
|
286
|
+
optionalTarget: boolean,
|
|
287
|
+
topLevel: boolean,
|
|
288
|
+
): void {
|
|
289
|
+
const problems = ctx.problems;
|
|
290
|
+
const keys = Object.keys(m);
|
|
291
|
+
if (
|
|
292
|
+
keys.length === 1 &&
|
|
293
|
+
keys[0] === 'ref' &&
|
|
294
|
+
typeof (m as { ref: unknown }).ref === 'string'
|
|
295
|
+
) {
|
|
296
|
+
const [root, ...path] = (m as { ref: string }).ref.split('.') as [
|
|
297
|
+
string,
|
|
298
|
+
...string[],
|
|
299
|
+
];
|
|
300
|
+
const a = avail.get(root);
|
|
301
|
+
if (!a) return void problems.push(`${at}: '${root}' is not available at this stage`);
|
|
302
|
+
if (!resolveField(ctx.outputViewOf(a.model), path))
|
|
303
|
+
problems.push(
|
|
304
|
+
`${at}: '${(m as { ref: string }).ref}' — model ${a.model} has no field '${path.join('.')}'`,
|
|
305
|
+
);
|
|
306
|
+
if (a.maybe && !(optionalTarget && topLevel))
|
|
307
|
+
problems.push(
|
|
308
|
+
`${at}: '${(m as { ref: string }).ref}' is only maybe-available (produced inside a when block); map it whole into an optional(...) input`,
|
|
309
|
+
);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
if (keys.length === 1 && keys[0] === 'const') return;
|
|
313
|
+
for (const [k, x] of Object.entries(m as Record<string, Mapping>))
|
|
314
|
+
checkMapping(ctx, `${at}.${k}`, x, avail, optionalTarget, false);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function recordSettings(
|
|
318
|
+
s: AnyStep,
|
|
319
|
+
carried: boolean,
|
|
320
|
+
): { settings?: Record<string, unknown>; settingsProblem?: string } {
|
|
321
|
+
if (s.settings === undefined && !carried) return {};
|
|
322
|
+
try {
|
|
323
|
+
return {
|
|
324
|
+
settings: canonicalize(recordConfigRefs(s.settings ?? {})) as Record<
|
|
325
|
+
string,
|
|
326
|
+
unknown
|
|
327
|
+
>,
|
|
328
|
+
};
|
|
329
|
+
} catch (e) {
|
|
330
|
+
return { settings: {}, settingsProblem: (e as Error).message };
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
type ConfigRefUse = { ref: string; case?: string };
|
|
335
|
+
|
|
336
|
+
// A step whose settings reference parts of an entry needs the secrets those parts name; any other
|
|
337
|
+
// step receiving an entry needs every secret the entry names.
|
|
338
|
+
function checkConfigSecrets(
|
|
339
|
+
ctx: DescribeContext,
|
|
340
|
+
at: string,
|
|
341
|
+
configNames: readonly string[],
|
|
342
|
+
refs: readonly ConfigRefUse[],
|
|
343
|
+
secretNames: readonly string[],
|
|
344
|
+
): void {
|
|
345
|
+
for (const name of configNames) {
|
|
346
|
+
const into = refs.filter((r) => r.ref.split('.')[0] === name);
|
|
347
|
+
const needed =
|
|
348
|
+
into.length === 0
|
|
349
|
+
? (ctx.configs.get(name)?.secretRefs ?? [])
|
|
350
|
+
: [
|
|
351
|
+
...new Set(
|
|
352
|
+
into.flatMap((r) =>
|
|
353
|
+
secretRefsOf(
|
|
354
|
+
subSchemas(
|
|
355
|
+
ctx.configs.get(name)?.schema,
|
|
356
|
+
r.ref.split('.').slice(1),
|
|
357
|
+
r.case,
|
|
358
|
+
name,
|
|
359
|
+
),
|
|
360
|
+
),
|
|
361
|
+
),
|
|
362
|
+
),
|
|
363
|
+
].sort();
|
|
364
|
+
for (const ref of needed)
|
|
365
|
+
if (!secretNames.includes(ref))
|
|
366
|
+
ctx.problems.push(
|
|
367
|
+
`${at}: config '${name}' references secret '${ref}' (secretRef), so the step must declare it in secrets`,
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function checkConfigRefs(
|
|
373
|
+
ctx: DescribeContext,
|
|
374
|
+
at: string,
|
|
375
|
+
refs: readonly ConfigRefUse[],
|
|
376
|
+
configNames: readonly string[],
|
|
377
|
+
cases: readonly CaseOf[],
|
|
378
|
+
): void {
|
|
379
|
+
const problems = ctx.problems;
|
|
380
|
+
for (const { ref, case: kase } of refs) {
|
|
381
|
+
const [entry, ...path] = ref.split('.') as [string, ...string[]];
|
|
382
|
+
if (kase !== undefined && !cases.some((c) => c.cond === kase))
|
|
383
|
+
problems.push(
|
|
384
|
+
`${at}: config reference '${ref}' is narrowed to the select case '${kase}' and can only be used inside it`,
|
|
385
|
+
);
|
|
386
|
+
if (!configNames.includes(entry))
|
|
387
|
+
problems.push(
|
|
388
|
+
`${at}: config reference '${ref}': the step does not receive config entry '${entry}'`,
|
|
389
|
+
);
|
|
390
|
+
else if (!resolveField(ctx.configs.get(entry)?.schema, path))
|
|
391
|
+
problems.push(
|
|
392
|
+
`${at}: config reference '${ref}': config '${entry}' has no field '${path.join('.')}'`,
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
}
|