@olivervea/pipelines 2.0.0-beta.17 → 2.0.0-beta.19
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 -0
- package/manifest.schema.json +22 -0
- package/package.json +4 -2
- package/src/assets.d.ts +2 -2
- package/src/build/build.ts +111 -43
- package/src/build/cli.ts +28 -21
- package/src/build/elf.ts +8 -7
- package/src/build/hashes.ts +61 -28
- package/src/build/project.ts +69 -30
- package/src/builder/pipeline.ts +288 -102
- package/src/builder/refs.ts +12 -6
- package/src/core/brands.ts +6 -6
- package/src/core/config-refs.ts +46 -20
- package/src/core/config.ts +110 -33
- package/src/core/external.ts +30 -12
- package/src/core/handlers.ts +54 -14
- package/src/core/models.ts +29 -14
- package/src/core/origin.ts +7 -4
- package/src/core/secrets.ts +2 -2
- package/src/core/steps.ts +39 -23
- package/src/core/types.ts +5 -1
- package/src/core/webhooks.ts +21 -7
- package/src/ctx/files.ts +111 -40
- package/src/ctx/index.ts +75 -34
- package/src/ctx/sh.ts +104 -44
- package/src/ctx/types.ts +4 -4
- package/src/describe/describe.ts +558 -194
- package/src/describe/manifest-schema.ts +217 -79
- package/src/describe/mapper-source.ts +249 -104
- package/src/describe/record.ts +67 -37
- package/src/dev/cli.ts +64 -34
- package/src/harness/env.ts +33 -27
- package/src/harness/execute.ts +55 -29
- package/src/harness/index.ts +21 -8
- package/src/harness/main.ts +197 -74
- package/src/harness/result.ts +38 -22
- package/src/harness/validate.ts +62 -19
- package/src/index.ts +123 -14
- package/src/library/checks.ts +11 -6
- package/src/library/compose-deploy.ts +40 -23
- package/src/library/github-source.ts +69 -39
- package/src/library/helm-deploy-ssh.ts +213 -76
- package/src/library/helm-deploy.ts +263 -85
- package/src/library/http-health.ts +7 -5
- package/src/library/image-build.ts +293 -117
- package/src/library/index.ts +47 -15
- package/src/library/models.ts +17 -9
- package/src/library/ssh.ts +31 -14
- package/src/testing/index.ts +63 -24
- package/src/util/canonical.ts +7 -4
- package/src/util/handler-hash.ts +15 -5
- package/src/util/hash.ts +5 -5
- package/src/util/tar.ts +74 -37
package/src/builder/refs.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import type { RefBrand } from
|
|
2
|
-
import type { AnyStep, InferEntry } from
|
|
3
|
-
import type { External } from
|
|
4
|
-
import type { Primitive } from
|
|
1
|
+
import type { RefBrand } from '../core/brands.ts';
|
|
2
|
+
import type { AnyStep, InferEntry } from '../core/steps.ts';
|
|
3
|
+
import type { External } from '../core/external.ts';
|
|
4
|
+
import type { Primitive } from '../core/types.ts';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* A typed reference into the run pool — what mappers see. `symbol &` makes template strings, `+`
|
|
@@ -13,7 +13,11 @@ type RefFields<T> = [NonNullable<T>] extends [Primitive]
|
|
|
13
13
|
? {}
|
|
14
14
|
: NonNullable<T> extends readonly unknown[]
|
|
15
15
|
? {}
|
|
16
|
-
: {
|
|
16
|
+
: {
|
|
17
|
+
readonly [K in keyof NonNullable<T> & string]-?: Ref<
|
|
18
|
+
NonNullable<T>[K] | (undefined extends T ? undefined : never)
|
|
19
|
+
>;
|
|
20
|
+
};
|
|
17
21
|
|
|
18
22
|
/** What a mapper may put where a T is expected: a Ref<T>, a literal, or an object of those. */
|
|
19
23
|
export type Mappable<T> =
|
|
@@ -26,5 +30,7 @@ export type Mappable<T> =
|
|
|
26
30
|
|
|
27
31
|
/** What a mapper provides: every input except the external ones, which come from outside the run. */
|
|
28
32
|
export type MapperResult<S extends AnyStep> = {
|
|
29
|
-
[K in keyof S[
|
|
33
|
+
[K in keyof S['input'] as S['input'][K] extends External ? never : K]: Mappable<
|
|
34
|
+
InferEntry<S['input'][K]>
|
|
35
|
+
>;
|
|
30
36
|
};
|
package/src/core/brands.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
// Runtime markers. `Symbol.for` (not `Symbol()`) so that two copies of the SDK in one process — e.g.
|
|
2
2
|
// a dev runner and the user's installed package — still recognise each other's objects.
|
|
3
|
-
export const STEP = Symbol.for(
|
|
4
|
-
export const IMPLEMENTATION = Symbol.for(
|
|
5
|
-
export const ORIGIN = Symbol.for(
|
|
6
|
-
export const SKIPPED = Symbol.for(
|
|
7
|
-
export const PIPELINE = Symbol.for(
|
|
8
|
-
export const REF_PATH = Symbol.for(
|
|
3
|
+
export const STEP = Symbol.for('olve.pipelines.step');
|
|
4
|
+
export const IMPLEMENTATION = Symbol.for('olve.pipelines.implementation');
|
|
5
|
+
export const ORIGIN = Symbol.for('olve.pipelines.origin');
|
|
6
|
+
export const SKIPPED = Symbol.for('olve.pipelines.skipped');
|
|
7
|
+
export const PIPELINE = Symbol.for('olve.pipelines.pipeline');
|
|
8
|
+
export const REF_PATH = Symbol.for('olve.pipelines.refPath');
|
|
9
9
|
|
|
10
10
|
// Type-only brands (never exist at runtime).
|
|
11
11
|
export declare const ModelBrand: unique symbol;
|
package/src/core/config-refs.ts
CHANGED
|
@@ -1,31 +1,41 @@
|
|
|
1
1
|
// Config references inside library-step definitions: collected (the step receives their entries),
|
|
2
2
|
// recorded in the manifest as `{"$config": "<entry>.<path>"}`, and resolved from ctx.config at run time.
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
configRefPath,
|
|
5
|
+
configRefTarget,
|
|
6
|
+
isConfigRef,
|
|
7
|
+
type AnyConfigEntry,
|
|
8
|
+
type ConfigRef,
|
|
9
|
+
} from './config.ts';
|
|
4
10
|
|
|
5
11
|
/** `T` with every config reference replaced by the type of its value. */
|
|
6
|
-
export type Resolved<T> =
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
export type Resolved<T> =
|
|
13
|
+
T extends ConfigRef<infer U>
|
|
14
|
+
? U
|
|
15
|
+
: T extends readonly (infer E)[]
|
|
16
|
+
? Resolved<E>[]
|
|
17
|
+
: T extends (...args: never[]) => unknown
|
|
18
|
+
? T
|
|
19
|
+
: T extends object
|
|
20
|
+
? { [K in keyof T]: Resolved<T[K]> }
|
|
21
|
+
: T;
|
|
15
22
|
|
|
16
23
|
/** Manifest key of a recorded config reference. */
|
|
17
|
-
export const CONFIG_REF_KEY =
|
|
24
|
+
export const CONFIG_REF_KEY = '$config';
|
|
18
25
|
/** Beside it on a reference narrowed by a `select` case: the case's condition, `<ref>.<discriminator>=<value>`. */
|
|
19
|
-
export const CONFIG_CASE_KEY =
|
|
26
|
+
export const CONFIG_CASE_KEY = '$case';
|
|
20
27
|
|
|
21
28
|
function isPlain(v: unknown): v is Record<string, unknown> {
|
|
22
|
-
if (typeof v !==
|
|
29
|
+
if (typeof v !== 'object' || v === null || Array.isArray(v)) return false;
|
|
23
30
|
const proto = Object.getPrototypeOf(v);
|
|
24
31
|
return proto === Object.prototype || proto === null;
|
|
25
32
|
}
|
|
26
33
|
|
|
27
34
|
/** Every config entry referenced anywhere in `v` (plain objects and arrays only), by name. */
|
|
28
|
-
export function configRefsIn(
|
|
35
|
+
export function configRefsIn(
|
|
36
|
+
v: unknown,
|
|
37
|
+
into = new Map<string, AnyConfigEntry>(),
|
|
38
|
+
): AnyConfigEntry[] {
|
|
29
39
|
if (isConfigRef(v)) {
|
|
30
40
|
const { entry } = configRefTarget(v);
|
|
31
41
|
if (!into.has(entry.name)) into.set(entry.name, entry);
|
|
@@ -38,14 +48,22 @@ export function configRefsIn(v: unknown, into = new Map<string, AnyConfigEntry>(
|
|
|
38
48
|
export function recordConfigRefs(v: unknown): unknown {
|
|
39
49
|
if (isConfigRef(v)) {
|
|
40
50
|
const { path, case: kase } = configRefTarget(v);
|
|
41
|
-
for (const seg of path)
|
|
42
|
-
|
|
51
|
+
for (const seg of path)
|
|
52
|
+
if (seg === '' || seg.includes('.'))
|
|
53
|
+
throw new Error(
|
|
54
|
+
`config reference '${configRefPath(v)}': field names must not be empty or contain '.'`,
|
|
55
|
+
);
|
|
56
|
+
return {
|
|
57
|
+
[CONFIG_REF_KEY]: configRefPath(v),
|
|
58
|
+
...(kase ? { [CONFIG_CASE_KEY]: kase } : {}),
|
|
59
|
+
};
|
|
43
60
|
}
|
|
44
61
|
if (Array.isArray(v)) return v.map(recordConfigRefs);
|
|
45
62
|
if (isPlain(v)) {
|
|
46
63
|
const out: Record<string, unknown> = {};
|
|
47
64
|
for (const [k, x] of Object.entries(v)) {
|
|
48
|
-
if (k.startsWith(
|
|
65
|
+
if (k.startsWith('$'))
|
|
66
|
+
throw new Error(`key '${k}': keys starting with '$' are reserved`);
|
|
49
67
|
out[k] = recordConfigRefs(x);
|
|
50
68
|
}
|
|
51
69
|
return out;
|
|
@@ -54,12 +72,20 @@ export function recordConfigRefs(v: unknown): unknown {
|
|
|
54
72
|
}
|
|
55
73
|
|
|
56
74
|
/** `v` with every reference replaced by its value in `config` (the step's parsed config entries). */
|
|
57
|
-
export function resolveConfigRefs<T>(
|
|
75
|
+
export function resolveConfigRefs<T>(
|
|
76
|
+
v: T,
|
|
77
|
+
config: Readonly<Record<string, unknown>>,
|
|
78
|
+
): Resolved<T> {
|
|
58
79
|
if (isConfigRef(v)) {
|
|
59
80
|
const { entry, path } = configRefTarget(v);
|
|
60
|
-
if (!Object.hasOwn(config, entry.name))
|
|
81
|
+
if (!Object.hasOwn(config, entry.name))
|
|
82
|
+
throw new Error(`config entry '${entry.name}' was not delivered to the step`);
|
|
61
83
|
let cur: unknown = config[entry.name];
|
|
62
|
-
for (const seg of path)
|
|
84
|
+
for (const seg of path)
|
|
85
|
+
cur =
|
|
86
|
+
cur !== null && typeof cur === 'object'
|
|
87
|
+
? (cur as Record<string, unknown>)[seg]
|
|
88
|
+
: undefined;
|
|
63
89
|
return cur as Resolved<T>;
|
|
64
90
|
}
|
|
65
91
|
if (Array.isArray(v)) return v.map((x) => resolveConfigRefs(x, config)) as Resolved<T>;
|
package/src/core/config.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { z } from
|
|
2
|
-
import type { Secret } from
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { Secret } from './secrets.ts';
|
|
3
3
|
|
|
4
|
-
const CONFIG_REF = Symbol.for(
|
|
4
|
+
const CONFIG_REF = Symbol.for('olve.configRef');
|
|
5
5
|
declare const VALUE: unique symbol;
|
|
6
6
|
|
|
7
7
|
interface RefTarget {
|
|
@@ -27,56 +27,100 @@ export type Configurable<T> = T | ConfigRef<T>;
|
|
|
27
27
|
type Fields<T> = T extends readonly unknown[] ? never : T extends object ? T : never;
|
|
28
28
|
type Children<T> = [Fields<NonNullable<T>>] extends [never]
|
|
29
29
|
? {}
|
|
30
|
-
: {
|
|
30
|
+
: {
|
|
31
|
+
readonly [K in keyof Fields<NonNullable<T>> & string]-?: ConfigNode<
|
|
32
|
+
Fields<NonNullable<T>>[K] | (undefined extends T ? undefined : never)
|
|
33
|
+
>;
|
|
34
|
+
};
|
|
31
35
|
/** A reference to a config value, and to each of its fields. */
|
|
32
36
|
export type ConfigNode<T> = ConfigRef<T> & Children<T>;
|
|
33
37
|
|
|
34
38
|
/** Fields of an entry that are its own properties; reach config fields with these names through `$`. */
|
|
35
|
-
export type ReservedConfigField =
|
|
39
|
+
export type ReservedConfigField = 'kind' | 'name' | 'description' | 'schema' | '$';
|
|
36
40
|
|
|
37
41
|
/** A named, typed value stored on the instance (one YAML document per pipeline), declared by the steps that use it. */
|
|
38
|
-
export interface ConfigEntryBase<
|
|
39
|
-
|
|
42
|
+
export interface ConfigEntryBase<
|
|
43
|
+
N extends string = string,
|
|
44
|
+
S extends z.ZodType = z.ZodType,
|
|
45
|
+
> {
|
|
46
|
+
readonly kind: 'config';
|
|
40
47
|
readonly name: N;
|
|
41
48
|
readonly description: string;
|
|
42
49
|
readonly schema: S;
|
|
43
50
|
}
|
|
44
|
-
export type ConfigEntry<
|
|
51
|
+
export type ConfigEntry<
|
|
52
|
+
N extends string = string,
|
|
53
|
+
S extends z.ZodType = z.ZodType,
|
|
54
|
+
> = ConfigEntryBase<N, S> &
|
|
45
55
|
ConfigRef<z.output<S>> &
|
|
46
|
-
Omit<Children<z.output<S>>, ReservedConfigField> & {
|
|
56
|
+
Omit<Children<z.output<S>>, ReservedConfigField> & {
|
|
57
|
+
readonly $: ConfigNode<z.output<S>>;
|
|
58
|
+
};
|
|
47
59
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
48
60
|
export type AnyConfigEntry = ConfigEntryBase<string, any>;
|
|
49
61
|
|
|
50
62
|
/** Config names are YAML keys: camelCase identifiers. */
|
|
51
63
|
export const CONFIG_NAME = /^[a-z][a-zA-Z0-9]*$/;
|
|
52
64
|
|
|
53
|
-
const NOT_REFS = new Set<PropertyKey>([
|
|
65
|
+
const NOT_REFS = new Set<PropertyKey>([
|
|
66
|
+
'then',
|
|
67
|
+
'toJSON',
|
|
68
|
+
'constructor',
|
|
69
|
+
'valueOf',
|
|
70
|
+
'toString',
|
|
71
|
+
'asymmetricMatch',
|
|
72
|
+
'$$typeof',
|
|
73
|
+
'nodeType',
|
|
74
|
+
]);
|
|
54
75
|
|
|
55
76
|
function node(entry: AnyConfigEntry, path: readonly string[], kase?: string): unknown {
|
|
56
|
-
const target = Object.freeze({
|
|
77
|
+
const target = Object.freeze({
|
|
78
|
+
[CONFIG_REF]: Object.freeze({ entry, path, ...(kase ? { case: kase } : {}) }),
|
|
79
|
+
});
|
|
57
80
|
return new Proxy(target, {
|
|
58
81
|
get(t, prop) {
|
|
59
82
|
if (prop === CONFIG_REF) return t[CONFIG_REF];
|
|
60
|
-
if (typeof prop ===
|
|
83
|
+
if (typeof prop === 'symbol' || NOT_REFS.has(prop)) return undefined;
|
|
61
84
|
return node(entry, [...path, prop], kase);
|
|
62
85
|
},
|
|
63
86
|
});
|
|
64
87
|
}
|
|
65
88
|
|
|
66
89
|
/** `ref` narrowed to the union member whose `discriminator` is `value` (a `select` case). */
|
|
67
|
-
export function narrowConfigRef(
|
|
90
|
+
export function narrowConfigRef(
|
|
91
|
+
ref: ConfigRef,
|
|
92
|
+
discriminator: string,
|
|
93
|
+
value: string,
|
|
94
|
+
): unknown {
|
|
68
95
|
const { entry, path } = ref[CONFIG_REF];
|
|
69
|
-
return node(
|
|
96
|
+
return node(
|
|
97
|
+
entry,
|
|
98
|
+
path,
|
|
99
|
+
`${[entry.name, ...path].join('.')}.${discriminator}=${value}`,
|
|
100
|
+
);
|
|
70
101
|
}
|
|
71
102
|
|
|
72
|
-
export function config<const N extends string, S extends z.ZodType>(
|
|
73
|
-
|
|
74
|
-
|
|
103
|
+
export function config<const N extends string, S extends z.ZodType>(
|
|
104
|
+
name: N,
|
|
105
|
+
description: string,
|
|
106
|
+
schema: S,
|
|
107
|
+
): ConfigEntry<N, S> {
|
|
108
|
+
const ref = {
|
|
109
|
+
entry: undefined as unknown as AnyConfigEntry,
|
|
110
|
+
path: [] as readonly string[],
|
|
111
|
+
};
|
|
112
|
+
const target = Object.freeze({
|
|
113
|
+
kind: 'config' as const,
|
|
114
|
+
name,
|
|
115
|
+
description,
|
|
116
|
+
schema,
|
|
117
|
+
[CONFIG_REF]: ref,
|
|
118
|
+
});
|
|
75
119
|
const entry: unknown = new Proxy(target, {
|
|
76
120
|
get(t, prop) {
|
|
77
121
|
if (prop in t) return t[prop as keyof typeof t];
|
|
78
|
-
if (typeof prop ===
|
|
79
|
-
if (prop ===
|
|
122
|
+
if (typeof prop === 'symbol' || NOT_REFS.has(prop)) return undefined;
|
|
123
|
+
if (prop === '$') return node(entry as AnyConfigEntry, []);
|
|
80
124
|
return node(entry as AnyConfigEntry, [prop]);
|
|
81
125
|
},
|
|
82
126
|
});
|
|
@@ -86,39 +130,62 @@ export function config<const N extends string, S extends z.ZodType>(name: N, des
|
|
|
86
130
|
}
|
|
87
131
|
|
|
88
132
|
export function isConfigEntry(v: unknown): v is AnyConfigEntry {
|
|
89
|
-
return
|
|
133
|
+
return (
|
|
134
|
+
typeof v === 'object' &&
|
|
135
|
+
v !== null &&
|
|
136
|
+
(v as { kind?: unknown }).kind === 'config' &&
|
|
137
|
+
(v as { schema?: unknown }).schema instanceof z.ZodType
|
|
138
|
+
);
|
|
90
139
|
}
|
|
91
140
|
|
|
92
141
|
export function isConfigRef(v: unknown): v is ConfigRef {
|
|
93
|
-
return
|
|
142
|
+
return (
|
|
143
|
+
typeof v === 'object' &&
|
|
144
|
+
v !== null &&
|
|
145
|
+
(v as Record<symbol, unknown>)[CONFIG_REF] !== undefined
|
|
146
|
+
);
|
|
94
147
|
}
|
|
95
148
|
|
|
96
149
|
/** The entry and field path a reference points at, and the `select` case it was narrowed by. */
|
|
97
|
-
export function configRefTarget(ref: ConfigRef): {
|
|
150
|
+
export function configRefTarget(ref: ConfigRef): {
|
|
151
|
+
entry: AnyConfigEntry;
|
|
152
|
+
path: readonly string[];
|
|
153
|
+
case?: string;
|
|
154
|
+
} {
|
|
98
155
|
return ref[CONFIG_REF];
|
|
99
156
|
}
|
|
100
157
|
|
|
101
158
|
/** `target.beta.host` */
|
|
102
159
|
export function configRefPath(ref: ConfigRef): string {
|
|
103
160
|
const { entry, path } = configRefTarget(ref);
|
|
104
|
-
return [entry.name, ...path].join(
|
|
161
|
+
return [entry.name, ...path].join('.');
|
|
105
162
|
}
|
|
106
163
|
|
|
107
|
-
export const SECRET_REF_KIND =
|
|
164
|
+
export const SECRET_REF_KIND = 'secretRef';
|
|
108
165
|
|
|
109
166
|
/** A config field that names a secret: `{ secret: NAME }` in YAML. The value never enters the config. */
|
|
110
167
|
export function secretRef<const N extends string>(s: Secret<N>) {
|
|
111
|
-
return z
|
|
168
|
+
return z
|
|
169
|
+
.strictObject({ secret: z.literal(s.name) })
|
|
170
|
+
.meta({ 'x-olve-kind': SECRET_REF_KIND });
|
|
112
171
|
}
|
|
113
172
|
|
|
114
173
|
/** `ctx.config`: exactly the entries a step declares, after parsing (defaults applied). */
|
|
115
|
-
export type ConfigValues<C extends AnyConfigEntry> = {
|
|
174
|
+
export type ConfigValues<C extends AnyConfigEntry> = {
|
|
175
|
+
readonly [E in C as E['name']]: z.output<E['schema']>;
|
|
176
|
+
};
|
|
116
177
|
|
|
117
178
|
// ─── select: branching on a config union ────────────────────────────────────────────────────
|
|
118
179
|
|
|
119
|
-
type IsLiteralString<T> = [T] extends [string]
|
|
180
|
+
type IsLiteralString<T> = [T] extends [string]
|
|
181
|
+
? string extends T
|
|
182
|
+
? false
|
|
183
|
+
: true
|
|
184
|
+
: false;
|
|
120
185
|
/** The fields of union `U` whose type is a string literal in every member: its discriminator(s). */
|
|
121
|
-
export type DiscriminatorOf<U> = {
|
|
186
|
+
export type DiscriminatorOf<U> = {
|
|
187
|
+
[K in keyof U & string]-?: IsLiteralString<U[K]> extends true ? K : never;
|
|
188
|
+
}[keyof U & string];
|
|
122
189
|
/** The member of `U` whose discriminator `D` is `V`. */
|
|
123
190
|
export type VariantOf<U, D extends string, V> = Extract<U, { readonly [K in D]: V }>;
|
|
124
191
|
|
|
@@ -132,16 +199,26 @@ function unwrap(schema: z.ZodType): z.ZodType {
|
|
|
132
199
|
* The discriminated union a reference points at: its discriminator and, per value, nothing else.
|
|
133
200
|
* Undefined when the referenced value is not a `z.discriminatedUnion`.
|
|
134
201
|
*/
|
|
135
|
-
export function unionAt(
|
|
202
|
+
export function unionAt(
|
|
203
|
+
ref: ConfigRef,
|
|
204
|
+
): { discriminator: string; values: string[] } | undefined {
|
|
136
205
|
const { entry, path } = configRefTarget(ref);
|
|
137
|
-
let s = unwrap(entry.schema as z.ZodType) as unknown as {
|
|
206
|
+
let s = unwrap(entry.schema as z.ZodType) as unknown as {
|
|
207
|
+
_zod: { def: Record<string, unknown> };
|
|
208
|
+
};
|
|
138
209
|
for (const seg of path) {
|
|
139
210
|
const shape = s._zod.def.shape as Record<string, z.ZodType> | undefined;
|
|
140
211
|
if (!shape || !(seg in shape)) return undefined;
|
|
141
212
|
s = unwrap(shape[seg]!) as never;
|
|
142
213
|
}
|
|
143
|
-
const def = s._zod.def as {
|
|
144
|
-
|
|
145
|
-
|
|
214
|
+
const def = s._zod.def as {
|
|
215
|
+
discriminator?: string;
|
|
216
|
+
options?: { _zod: { propValues?: Record<string, Set<unknown>> } }[];
|
|
217
|
+
};
|
|
218
|
+
if (typeof def.discriminator !== 'string' || !Array.isArray(def.options))
|
|
219
|
+
return undefined;
|
|
220
|
+
const values = def.options
|
|
221
|
+
.flatMap((o) => [...(o._zod.propValues?.[def.discriminator!] ?? [])])
|
|
222
|
+
.filter((v): v is string => typeof v === 'string');
|
|
146
223
|
return { discriminator: def.discriminator, values };
|
|
147
224
|
}
|
package/src/core/external.ts
CHANGED
|
@@ -1,14 +1,19 @@
|
|
|
1
|
-
import { isModel, type AnyModel } from
|
|
1
|
+
import { isModel, type AnyModel } from './models.ts';
|
|
2
2
|
|
|
3
3
|
/** Who or what may supply an external input's value. */
|
|
4
4
|
export type ExternalSource =
|
|
5
|
-
| { readonly kind:
|
|
6
|
-
| { readonly kind:
|
|
7
|
-
| {
|
|
5
|
+
| { readonly kind: 'person' }
|
|
6
|
+
| { readonly kind: 'callback' }
|
|
7
|
+
| {
|
|
8
|
+
readonly kind: 'poll';
|
|
9
|
+
readonly url: string;
|
|
10
|
+
readonly valuePath: string;
|
|
11
|
+
readonly intervalSeconds: number;
|
|
12
|
+
};
|
|
8
13
|
|
|
9
14
|
/** A signed-in user answers, in the UI or with `pl input`. */
|
|
10
15
|
export function person(): ExternalSource {
|
|
11
|
-
return Object.freeze({ kind:
|
|
16
|
+
return Object.freeze({ kind: 'person' as const });
|
|
12
17
|
}
|
|
13
18
|
|
|
14
19
|
/**
|
|
@@ -16,7 +21,7 @@ export function person(): ExternalSource {
|
|
|
16
21
|
* with `ctx.inputUrl(step, input)`. The body is the value as JSON, or `{"$decline": "reason"}`.
|
|
17
22
|
*/
|
|
18
23
|
export function callback(): ExternalSource {
|
|
19
|
-
return Object.freeze({ kind:
|
|
24
|
+
return Object.freeze({ kind: 'callback' as const });
|
|
20
25
|
}
|
|
21
26
|
|
|
22
27
|
export interface PollOptions {
|
|
@@ -30,7 +35,12 @@ export interface PollOptions {
|
|
|
30
35
|
|
|
31
36
|
/** The service polls `url` while the step waits and answers with the value at `valuePath` once it validates. */
|
|
32
37
|
export function poll(options: PollOptions): ExternalSource {
|
|
33
|
-
return Object.freeze({
|
|
38
|
+
return Object.freeze({
|
|
39
|
+
kind: 'poll' as const,
|
|
40
|
+
url: options.url,
|
|
41
|
+
valuePath: options.valuePath,
|
|
42
|
+
intervalSeconds: options.intervalSeconds ?? 30,
|
|
43
|
+
});
|
|
34
44
|
}
|
|
35
45
|
|
|
36
46
|
/**
|
|
@@ -38,7 +48,7 @@ export function poll(options: PollOptions): ExternalSource {
|
|
|
38
48
|
* validated against `model`), then runs with the value in its input like any other.
|
|
39
49
|
*/
|
|
40
50
|
export interface External<M extends AnyModel = AnyModel> {
|
|
41
|
-
readonly kind:
|
|
51
|
+
readonly kind: 'external';
|
|
42
52
|
readonly model: M;
|
|
43
53
|
readonly title: string;
|
|
44
54
|
readonly description: string;
|
|
@@ -53,16 +63,24 @@ export interface ExternalOptions {
|
|
|
53
63
|
from?: readonly ExternalSource[];
|
|
54
64
|
}
|
|
55
65
|
|
|
56
|
-
export function external<M extends AnyModel>(
|
|
66
|
+
export function external<M extends AnyModel>(
|
|
67
|
+
model: M,
|
|
68
|
+
options: ExternalOptions,
|
|
69
|
+
): External<M> {
|
|
57
70
|
return Object.freeze({
|
|
58
|
-
kind:
|
|
71
|
+
kind: 'external' as const,
|
|
59
72
|
model,
|
|
60
73
|
title: options.title,
|
|
61
|
-
description: options.description ??
|
|
74
|
+
description: options.description ?? '',
|
|
62
75
|
from: Object.freeze([...(options.from ?? [person()])]),
|
|
63
76
|
});
|
|
64
77
|
}
|
|
65
78
|
|
|
66
79
|
export function isExternal(v: unknown): v is External {
|
|
67
|
-
return
|
|
80
|
+
return (
|
|
81
|
+
typeof v === 'object' &&
|
|
82
|
+
v !== null &&
|
|
83
|
+
(v as { kind?: unknown }).kind === 'external' &&
|
|
84
|
+
isModel((v as { model?: unknown }).model)
|
|
85
|
+
);
|
|
68
86
|
}
|
package/src/core/handlers.ts
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
import {
|
|
2
|
+
IMPLEMENTATION,
|
|
3
|
+
ORIGIN,
|
|
4
|
+
SKIPPED,
|
|
5
|
+
type ImplementationBrand,
|
|
6
|
+
type SkippedBrand,
|
|
7
|
+
} from './brands.ts';
|
|
8
|
+
import type { Ctx } from '../ctx/types.ts';
|
|
9
|
+
import { callerFile, originsOf } from './origin.ts';
|
|
10
|
+
import type { AnyStep, ConfigOf, OutputValue, SecretsOf, StepInput } from './steps.ts';
|
|
11
|
+
import type { Awaitable } from './types.ts';
|
|
6
12
|
|
|
7
13
|
/** Returned by `ctx.skip(reason)`: the step produced no output on purpose. */
|
|
8
14
|
export interface Skipped {
|
|
@@ -13,11 +19,18 @@ export function skipped(reason: string): Skipped {
|
|
|
13
19
|
return Object.freeze({ [SKIPPED]: true, reason }) as unknown as Skipped;
|
|
14
20
|
}
|
|
15
21
|
export function isSkipped(v: unknown): v is Skipped {
|
|
16
|
-
return
|
|
22
|
+
return (
|
|
23
|
+
typeof v === 'object' &&
|
|
24
|
+
v !== null &&
|
|
25
|
+
(v as Record<symbol, unknown>)[SKIPPED] === true
|
|
26
|
+
);
|
|
17
27
|
}
|
|
18
28
|
|
|
19
29
|
export type HandlerResult<S extends AnyStep> = OutputValue<S> | Skipped;
|
|
20
|
-
export type Handler<S extends AnyStep> = (
|
|
30
|
+
export type Handler<S extends AnyStep> = (
|
|
31
|
+
input: StepInput<S['input']>,
|
|
32
|
+
ctx: Ctx<SecretsOf<S>, ConfigOf<S>>,
|
|
33
|
+
) => Awaitable<HandlerResult<S>>;
|
|
21
34
|
|
|
22
35
|
/** A step with its handler. It is itself a step: stage it directly, or default-export it from `handlers/*.ts`. */
|
|
23
36
|
export type Implementation<S extends AnyStep = AnyStep> = S & {
|
|
@@ -30,21 +43,48 @@ export type Implementation<S extends AnyStep = AnyStep> = S & {
|
|
|
30
43
|
/** Block-bodied/async returns get no excess-property check; this restores it for handler outputs. */
|
|
31
44
|
type NoExtraFields<R, T> = R extends Skipped | void
|
|
32
45
|
? unknown
|
|
33
|
-
: {
|
|
46
|
+
: {
|
|
47
|
+
[
|
|
48
|
+
K in Exclude<keyof R, keyof T>
|
|
49
|
+
]: `❌ '${K & string}' is not a field of the output model`;
|
|
50
|
+
};
|
|
34
51
|
|
|
35
52
|
/**
|
|
36
53
|
* Implement a step. Input, ctx and return types come from the step definition alone. Default-export
|
|
37
54
|
* the result from a `handlers/*.ts` file, or stage it directly (a reusable step: give it a `kind`).
|
|
38
55
|
*/
|
|
39
56
|
export function implement<S extends AnyStep, R extends HandlerResult<S>>(
|
|
40
|
-
step: S &
|
|
41
|
-
|
|
57
|
+
step: S &
|
|
58
|
+
(S extends { readonly [ImplementationBrand]: true }
|
|
59
|
+
? '❌ this step already carries its implementation'
|
|
60
|
+
: unknown),
|
|
61
|
+
handler: (
|
|
62
|
+
input: StepInput<S['input']>,
|
|
63
|
+
ctx: Ctx<SecretsOf<S>, ConfigOf<S>>,
|
|
64
|
+
) => Awaitable<R & NoExtraFields<R, OutputValue<S>>>,
|
|
42
65
|
): Implementation<S> {
|
|
43
|
-
if (isImplementation(step))
|
|
44
|
-
|
|
45
|
-
|
|
66
|
+
if (isImplementation(step))
|
|
67
|
+
throw new TypeError(
|
|
68
|
+
`step '${(step as AnyStep).name}' already carries its implementation`,
|
|
69
|
+
);
|
|
70
|
+
const origins = [
|
|
71
|
+
...new Set(
|
|
72
|
+
[...originsOf(step), callerFile()].filter((f): f is string => f !== undefined),
|
|
73
|
+
),
|
|
74
|
+
];
|
|
75
|
+
return Object.freeze({
|
|
76
|
+
...step,
|
|
77
|
+
[IMPLEMENTATION]: true,
|
|
78
|
+
[ORIGIN]: origins,
|
|
79
|
+
step,
|
|
80
|
+
handler: handler as unknown as Handler<S>,
|
|
81
|
+
}) as unknown as Implementation<S>;
|
|
46
82
|
}
|
|
47
83
|
|
|
48
84
|
export function isImplementation(v: unknown): v is Implementation {
|
|
49
|
-
return
|
|
85
|
+
return (
|
|
86
|
+
typeof v === 'object' &&
|
|
87
|
+
v !== null &&
|
|
88
|
+
(v as Record<symbol, unknown>)[IMPLEMENTATION] === true
|
|
89
|
+
);
|
|
50
90
|
}
|
package/src/core/models.ts
CHANGED
|
@@ -1,30 +1,40 @@
|
|
|
1
|
-
import { z } from
|
|
2
|
-
import type { ModelBrand } from
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { ModelBrand } from './brands.ts';
|
|
3
3
|
|
|
4
4
|
/** A named, nominal zod schema. The name is its identity in the manifest (one schema per name). */
|
|
5
5
|
export interface Model<N extends string = string, S extends z.ZodType = z.ZodType> {
|
|
6
|
-
readonly kind:
|
|
6
|
+
readonly kind: 'model';
|
|
7
7
|
readonly name: N;
|
|
8
8
|
readonly schema: S;
|
|
9
9
|
}
|
|
10
10
|
export type AnyModel = Model<string, z.ZodType>;
|
|
11
11
|
|
|
12
12
|
/** Output view of a model (`z.output`), nominally branded with the model name. The brand is optional, so plain literals still assign. */
|
|
13
|
-
export type Infer<M extends AnyModel> = z.output<M[
|
|
13
|
+
export type Infer<M extends AnyModel> = z.output<M['schema']> & {
|
|
14
|
+
readonly [ModelBrand]?: M['name'];
|
|
15
|
+
};
|
|
14
16
|
/** Input view of a model (`z.input`: fields with defaults are optional). What trigger mappers produce. */
|
|
15
|
-
export type InferInput<M extends AnyModel> = z.input<M[
|
|
17
|
+
export type InferInput<M extends AnyModel> = z.input<M['schema']>;
|
|
16
18
|
/** Output view without the nominal brand. */
|
|
17
|
-
export type Plain<M extends AnyModel> = z.output<M[
|
|
19
|
+
export type Plain<M extends AnyModel> = z.output<M['schema']>;
|
|
18
20
|
|
|
19
21
|
/** Model names: identifier-like, so they are safe as JSON keys, C# names and URL segments. */
|
|
20
22
|
export const MODEL_NAME = /^[A-Za-z][A-Za-z0-9_]*$/;
|
|
21
23
|
|
|
22
|
-
export function model<const N extends string, S extends z.ZodType>(
|
|
23
|
-
|
|
24
|
+
export function model<const N extends string, S extends z.ZodType>(
|
|
25
|
+
name: N,
|
|
26
|
+
schema: S,
|
|
27
|
+
): Model<N, S> {
|
|
28
|
+
return Object.freeze({ kind: 'model' as const, name, schema });
|
|
24
29
|
}
|
|
25
30
|
|
|
26
31
|
export function isModel(v: unknown): v is AnyModel {
|
|
27
|
-
return
|
|
32
|
+
return (
|
|
33
|
+
typeof v === 'object' &&
|
|
34
|
+
v !== null &&
|
|
35
|
+
(v as { kind?: unknown }).kind === 'model' &&
|
|
36
|
+
(v as { schema?: unknown }).schema instanceof z.ZodType
|
|
37
|
+
);
|
|
28
38
|
}
|
|
29
39
|
|
|
30
40
|
// ─── File references ─────────────────────────────────────────────────────────────────────────
|
|
@@ -47,8 +57,8 @@ const FileRefSchema = z
|
|
|
47
57
|
path: z.string().regex(FILE_PATH),
|
|
48
58
|
sha256: z.string().regex(/^[0-9a-f]{64}$/),
|
|
49
59
|
})
|
|
50
|
-
.brand<
|
|
51
|
-
.meta({
|
|
60
|
+
.brand<'FileRef'>()
|
|
61
|
+
.meta({ 'x-olve-kind': 'fileRef' });
|
|
52
62
|
|
|
53
63
|
/** A file or directory in some step attempt's output (`runs/<p>/<run>/<step>/<attempt>/files/<path>`). Branded: only `ctx.output.*` or a mapper ref produce one. */
|
|
54
64
|
export type FileRef = z.output<typeof FileRefSchema>;
|
|
@@ -60,12 +70,17 @@ export const fileRefSchema = FileRefSchema;
|
|
|
60
70
|
|
|
61
71
|
/** A step input that may be missing because its producer was *maybe skipped* (inside a `when`). There must still be a producer. */
|
|
62
72
|
export interface Optional<M extends AnyModel = AnyModel> {
|
|
63
|
-
readonly kind:
|
|
73
|
+
readonly kind: 'optional';
|
|
64
74
|
readonly model: M;
|
|
65
75
|
}
|
|
66
76
|
export function optional<M extends AnyModel>(model: M): Optional<M> {
|
|
67
|
-
return Object.freeze({ kind:
|
|
77
|
+
return Object.freeze({ kind: 'optional' as const, model });
|
|
68
78
|
}
|
|
69
79
|
export function isOptional(v: unknown): v is Optional {
|
|
70
|
-
return
|
|
80
|
+
return (
|
|
81
|
+
typeof v === 'object' &&
|
|
82
|
+
v !== null &&
|
|
83
|
+
(v as { kind?: unknown }).kind === 'optional' &&
|
|
84
|
+
isModel((v as { model?: unknown }).model)
|
|
85
|
+
);
|
|
71
86
|
}
|