@olivervea/pipelines 2.0.0-beta.34 → 2.0.0-beta.36

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 CHANGED
@@ -98,6 +98,7 @@ Rules:
98
98
  - **`optional(M)`** means "maybe skipped": `M` must still have an earlier producer.
99
99
  - **`cells(M)`** reads every cell of a matrix step whose output is `M` (see "Matrix steps").
100
100
  - **`external(M, { title, description?, from? })`** is answered from outside the run; `from` lists `callback()` (another system POSTs to the URL an earlier step gets from `ctx.inputUrl(step, input)`), `poll({ url, valuePath, intervalSeconds? })` and `person()` (the default). It needs no producer and no mapper entry; the step waits until it is answered, then the value (validated against `M`) is in its input like any other. Failure handlers can't have one. `link: { input, path?, label? }` is the link shown on the step while it waits: `input` is one of the same step's regular (non-external) inputs (typed to their names), `path` a dot path inside its value (`url` → `input.review.url`; none = the value itself is the URL), `label` defaults to `title`. A value that is not an absolute http(s) URL sets no link; the step waits all the same.
101
+ - **`implement(step, handler, { preflight: async (input, ctx) => … })`** runs before a step with an external input waits: it hands the work and `ctx.inputUrl(step, input)` to whatever answers, and its `ctx.link` links join the waiting links. It gets the step's regular inputs only, and the handler's `ctx` without `output` and `skip`. Throwing fails the attempt without waiting. Only a step with an external input takes one (a type error, and a describe problem otherwise).
101
102
 
102
103
  ### Config
103
104
 
@@ -308,9 +309,10 @@ This section is everything the C# side needs in order to run the binary.
308
309
  |---|---|---|---|
309
310
  | `describe [--sentinels]` | the binary itself | stdout: the manifest (canonical JSON) | With `--sentinels`, stdout gets the manifest between `---OLVE-MANIFEST-BEGIN---` and `---OLVE-MANIFEST-END---`. Needs no env and no files. |
310
311
  | `run <step>` | `$OLVE_DIR/input.json` | `$OLVE_DIR/work/output.json`, `$OLVE_DIR/work/files/…` | Steps in stages and failure-handler steps. A matrix step's cell runs as `run <step>` (the step's own name) with `OLVE_CELL`. |
312
+ | `preflight <step>` | `$OLVE_DIR/input.json` | `$OLVE_DIR/work/output.json` | A step's preflight (manifest `preflight: true`). Validates only the step's regular inputs (external values already in the input are ignored) and writes `{"status":"succeeded","output":null}` with the preflight's `links`, or a failed envelope whose message names the preflight. Keeps no files; the service sets no `OLVE_RESULT_URL` and reads the envelope from the log. |
311
313
  | `map-trigger <trigger>` | `$OLVE_DIR/input.json` (the event) | `$OLVE_DIR/work/output.json` | `<trigger>` is the trigger's `name` from the manifest. |
312
314
 
313
- `run` and `map-trigger` also print the envelope on stdout, as one compact JSON line between `---OLVE-RESULT-BEGIN---` and `---OLVE-RESULT-END---`. The block always comes last. Use it as a fallback channel from the pod log. User output and stderr logs also go to the pod log, so never parse anything outside the sentinels.
315
+ `run`, `preflight` and `map-trigger` also print the envelope on stdout, as one compact JSON line between `---OLVE-RESULT-BEGIN---` and `---OLVE-RESULT-END---`. The block always comes last. Use it as a fallback channel from the pod log. User output and stderr logs also go to the pod log, so never parse anything outside the sentinels.
314
316
 
315
317
  ### Files (`$OLVE_DIR`, default `/olve`)
316
318
 
@@ -423,6 +425,7 @@ Exact format: [`manifest.schema.json`](manifest.schema.json) (JSON Schema 2020-1
423
425
 
424
426
  An object whose only key is `ref` (with a string value) or `const` is a tag. Describe never emits a nested object with exactly that key set. Model-matched inputs are emitted already resolved (`{"ref": "build"}`).
425
427
  - **Step `externalInputs`** (when present) maps each external input key to `{model, title, description, from, link?}`, `from` being `{kind: "person"}`, `{kind: "callback"}` or `{kind: "poll", url, valuePath, intervalSeconds}`, and `link` `{input, path?, label?}` (omitted when not set, as are its `path` and `label`). Those keys are not in `input` or `inputTypes`; the service merges the answers into `input.json`.
428
+ - **Step `preflight: true`** (omitted otherwise): the step runs `preflight <step>` before it waits for its external inputs.
426
429
  - **`inputTypes`** declares each input key: `{model, optional}`, `{model, optional: false, cells: true}` for a `cells` input (mapped with a whole-step ref to a matrix step whose output is that model), or `{schema: {output, input?}}` for a plain schema.
427
430
  - **Step `matrix`** (matrix steps only, never a failure handler) is `{axes: [{name, values}…], exclude?: [{axis: value…}…], failure: {kind: "keepRunning" | "cascadeFailure"}, maxParallel?}`. `axes` is a list because canonical JSON sorts keys; `exclude` is omitted when empty; `failure.kind` is always written. All of it is part of what the step declares.
428
431
  - **`codeHash`** is the build's identity: `sha256(canonical {bundle, harness})`. `bundle` hashes the entry point bundled with everything it imports, with `@olivervea/pipelines` external. It is tree-shaken and includes embedded scripts and dependencies, so any change to code in use changes it, and with it the manifest, which makes a new pipeline version. A step version covers what the step declares: image, `inputTypes`/model versions, `output` model version, `secrets`, `configEntries` (and those entries' schemas), `input` mappings and `config`.
@@ -433,6 +436,7 @@ Exact format: [`manifest.schema.json`](manifest.schema.json) (JSON Schema 2020-1
433
436
  - `{kind: "webhook", name, verify: {scheme: "hmac-sha256", secret, header, prefix} | {scheme: "bearer", secret}}`. The `map-trigger` event is `{headers, body}`; a `null` mapping yields a `skipped` envelope (the delivery is ignored).
434
437
  - **`failureHandlers`** is a list of `{step: Step, steps: [names] | null}`. Each handler's inputs are all FailureContext (`{"ref": "failure"}`).
435
438
  - **`retention`** (when present) is `{keepLatestRuns?, minRunAgeDays?, buildGraceDays?}`, non-negative integers that replace the instance's thresholds for this pipeline. Omitted when the definition sets none.
439
+ - **`ci`** (when present) is `{description?, input, secrets, stages}`: the checks run on pull requests. `input` is a model, `secrets` the sorted names CI steps may declare (each one a pipeline secret), `stages` shaped like the pipeline's. A step name in both the pipeline and `ci` is one step: everything it declares but its `input` mappings must match. CI steps can't have external inputs. Omitted when there is no CI.
436
440
  - **`secrets`** lists every secret used by steps, polls and webhooks, sorted, as `{name, description}`.
437
441
  - **`config`** lists every config entry steps declare, sorted by name, as `{name, description, required, schema}`. `schema` is the `z.input` view (defaults listed); it validates the entry's value in the config document. `required` is false when the schema accepts a missing value. A `secretRef` field is marked `"x-olve-kind": "secretRef"`, with the secret's name as `properties.secret.const`. Omitted when there are no entries.
438
442
  - **Step `configEntries`** names the entries the step receives (sorted; omitted when none). It is part of the step's content, like `secrets`. The step key `config` is the step's `settings`.
@@ -446,6 +450,7 @@ Describe runs these checks; reconcile should re-run the manifest-level ones:
446
450
  - mapper source analysis and recording, unknown fields in refs, mapper keys that are missing or extra;
447
451
  - `when` on an unknown or non-literal field, or with an invalid value;
448
452
  - an external input's `link` naming anything but one of the step's regular inputs, an empty path segment, a label that is empty or over 100 characters;
453
+ - a preflight on a step without an external input;
449
454
  - a poll header referencing an undeclared secret, duplicate triggers;
450
455
  - conflicting secret descriptions;
451
456
  - config names, one description and schema per config name, a step receiving a `secretRef` without declaring its secret, and input keys starting with `$` (reserved);
@@ -490,6 +495,7 @@ The manifest is the text between the sentinels in the pod log. The manifest text
490
495
  ```
491
496
  olve-dev describe <dir> [--sentinels] # same bytes as the compiled binary's describe
492
497
  olve-dev run <dir> <step> [--input-file f] [--config-file f] [--olve-dir d] [--secret NAME=value]...
498
+ olve-dev preflight <dir> <step> [same options as run]
493
499
  olve-dev map-trigger <dir> <trigger> --event-file f [--olve-dir d]
494
500
  ```
495
501
 
@@ -52,6 +52,41 @@
52
52
  "const": "2.0",
53
53
  "type": "string"
54
54
  },
55
+ "ci": {
56
+ "additionalProperties": false,
57
+ "description": "Checks run on pull requests (omitted when none). Steps are matched by name: a step in both the pipeline and CI is the same step.",
58
+ "properties": {
59
+ "description": {
60
+ "type": "string"
61
+ },
62
+ "input": {
63
+ "description": "CI input model.",
64
+ "pattern": "^[A-Za-z][A-Za-z0-9_]*$",
65
+ "type": "string"
66
+ },
67
+ "secrets": {
68
+ "description": "The pipeline secrets CI steps may declare, sorted by name.",
69
+ "items": {
70
+ "description": "Secret name; also the env var name in the step container.",
71
+ "pattern": "^[A-Z_][A-Z0-9_]*$",
72
+ "type": "string"
73
+ },
74
+ "type": "array"
75
+ },
76
+ "stages": {
77
+ "items": {
78
+ "$ref": "#/$defs/Stage"
79
+ },
80
+ "type": "array"
81
+ }
82
+ },
83
+ "required": [
84
+ "input",
85
+ "secrets",
86
+ "stages"
87
+ ],
88
+ "type": "object"
89
+ },
55
90
  "codeHash": {
56
91
  "description": "Hash of the whole build: the pipeline and its code bundled, plus the harness. Any code change makes a new pipeline version.",
57
92
  "pattern": "^sha256:[0-9a-f]{64}$",
@@ -687,6 +722,11 @@
687
722
  ],
688
723
  "description": "Output model, or null if the step produces no output."
689
724
  },
725
+ "preflight": {
726
+ "const": true,
727
+ "description": "The step runs its preflight (the harness's 'preflight <step>' mode, on the step's non-external inputs) before it waits for its external inputs; only a step with external inputs has one. Omitted = none.",
728
+ "type": "boolean"
729
+ },
690
730
  "retries": {
691
731
  "maximum": 9007199254740991,
692
732
  "minimum": 0,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olivervea/pipelines",
3
- "version": "2.0.0-beta.34",
3
+ "version": "2.0.0-beta.36",
4
4
  "description": "Olve.Pipelines v2 SDK: typed pipeline authoring, the step harness, and the build tool.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -5,11 +5,13 @@ import {
5
5
  type SkippedBrand,
6
6
  } from './brands.ts';
7
7
  import type { StepCtx } from '../ctx/types.ts';
8
+ import { isExternal, type External } from './external.ts';
8
9
  import type {
9
10
  AnyStep,
10
11
  CellOf,
11
12
  ConfigOf,
12
13
  OutputValue,
14
+ RegularInputKey,
13
15
  SecretsOf,
14
16
  StepInput,
15
17
  } from './steps.ts';
@@ -39,12 +41,40 @@ export type Handler<S extends AnyStep> = (
39
41
  ctx: CtxOf<S>,
40
42
  ) => Awaitable<HandlerResult<S>>;
41
43
 
44
+ /** What a preflight receives: the step's non-external inputs (the external values don't exist yet). */
45
+ export type PreflightInput<S extends AnyStep> = StepInput<
46
+ Pick<S['input'], RegularInputKey<S['input']>>
47
+ >;
48
+ /** The ctx a preflight of `S` gets: the handler's, without output files and `skip` (the step still waits). */
49
+ export type PreflightCtxOf<S extends AnyStep> = Omit<CtxOf<S>, 'output' | 'skip'>;
50
+ export type Preflight<S extends AnyStep> = (
51
+ input: PreflightInput<S>,
52
+ ctx: PreflightCtxOf<S>,
53
+ ) => Awaitable<void>;
54
+
55
+ /** True when some input of the step is `external(...)`. */
56
+ type HasExternalInput<I> = true extends {
57
+ [K in keyof I]-?: I[K] extends External ? true : false;
58
+ }[keyof I]
59
+ ? true
60
+ : false;
61
+
62
+ export interface ImplementOptions<S extends AnyStep> {
63
+ /**
64
+ * Runs before the step starts waiting for its external inputs, e.g. to hand work and
65
+ * `ctx.inputUrl(...)` to the system that will answer. Throwing fails the attempt without waiting.
66
+ */
67
+ preflight?: Preflight<S>;
68
+ }
69
+
42
70
  /** A step with its handler. It is itself a step: stage it like any value. */
43
71
  export type Implementation<S extends AnyStep = AnyStep> = S & {
44
72
  readonly [ImplementationBrand]: true;
45
73
  /** The step as defined. */
46
74
  readonly step: S;
47
75
  readonly handler: Handler<S>;
76
+ /** Runs before the step waits for its external inputs; absent when none. */
77
+ readonly preflight?: Preflight<S>;
48
78
  };
49
79
 
50
80
  /** Block-bodied/async returns get no excess-property check; this restores it for handler outputs. */
@@ -69,16 +99,29 @@ export function implement<S extends AnyStep, R extends HandlerResult<S>>(
69
99
  input: StepInput<S['input']>,
70
100
  ctx: CtxOf<S>,
71
101
  ) => Awaitable<R & NoExtraFields<R, OutputValue<S>>>,
102
+ options?: HasExternalInput<S['input']> extends true
103
+ ? ImplementOptions<S>
104
+ : '❌ a preflight needs a step with an external(...) input',
72
105
  ): Implementation<S> {
73
106
  if (isImplementation(step))
74
107
  throw new TypeError(
75
108
  `step '${(step as AnyStep).name}' already carries its implementation`,
76
109
  );
110
+ const preflight =
111
+ typeof options === 'object' ? (options as ImplementOptions<S>).preflight : undefined;
112
+ if (
113
+ preflight !== undefined &&
114
+ !Object.values((step as AnyStep).input as Record<string, unknown>).some(isExternal)
115
+ )
116
+ throw new TypeError(
117
+ `step '${(step as AnyStep).name}': a preflight needs a step with an external(...) input`,
118
+ );
77
119
  return Object.freeze({
78
120
  ...step,
79
121
  [IMPLEMENTATION]: true,
80
122
  step,
81
123
  handler: handler as unknown as Handler<S>,
124
+ ...(preflight !== undefined ? { preflight } : {}),
82
125
  }) as unknown as Implementation<S>;
83
126
  }
84
127
 
@@ -180,6 +180,12 @@ export const StepDescriptor = z
180
180
  .describe(
181
181
  "The step pod gets the instance's step ServiceAccount token (Kubernetes:StepServiceAccount), if the instance grants one. Omitted = no token.",
182
182
  ),
183
+ preflight: z
184
+ .literal(true)
185
+ .optional()
186
+ .describe(
187
+ "The step runs its preflight (the harness's 'preflight <step>' mode, on the step's non-external inputs) before it waits for its external inputs; only a step with external inputs has one. Omitted = none.",
188
+ ),
183
189
  externalInputs: z
184
190
  .record(
185
191
  z.string(),
@@ -384,6 +390,19 @@ export const Manifest = z
384
390
  ),
385
391
  triggers: z.array(Trigger),
386
392
  stages: z.array(Stage),
393
+ ci: z
394
+ .strictObject({
395
+ description: z.string().optional(),
396
+ input: modelName.describe('CI input model.'),
397
+ secrets: z
398
+ .array(secretName)
399
+ .describe('The pipeline secrets CI steps may declare, sorted by name.'),
400
+ stages: z.array(Stage),
401
+ })
402
+ .optional()
403
+ .describe(
404
+ 'Checks run on pull requests (omitted when none). Steps are matched by name: a step in both the pipeline and CI is the same step.',
405
+ ),
387
406
  failureHandlers: z.array(
388
407
  z.strictObject({
389
408
  step: StepDescriptor.describe(
@@ -68,6 +68,7 @@ export function describeStep(
68
68
  ),
69
69
  ].sort();
70
70
  const carried = isImplementation(s);
71
+ const preflight = carried && typeof s.preflight === 'function';
71
72
  const { settings, settingsProblem } = recordSettings(s, carried);
72
73
  const refs = settings ? recordedConfigRefs(settings) : [];
73
74
  checkConfigSecrets(ctx, at, configNames, refs, secretNames);
@@ -109,7 +110,10 @@ export function describeStep(
109
110
  ...(configNames.length ? { configEntries: configNames } : {}),
110
111
  ...(s.clusterAccess === true ? { clusterAccess: true as const } : {}),
111
112
  ...(Object.keys(externalInputs).length ? { externalInputs } : {}),
113
+ ...(preflight ? { preflight: true as const } : {}),
112
114
  };
115
+ if (preflight && Object.keys(externalInputs).length === 0)
116
+ problems.push(`${at}: a preflight needs at least one external input`);
113
117
  if (s.clusterAccess !== undefined && typeof s.clusterAccess !== 'boolean')
114
118
  problems.push(`${at}: clusterAccess must be a boolean`);
115
119
  if (s.output && !isModel(s.output)) problems.push(`${at}: output must be a model`);
package/src/dev/cli.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  // olve-dev — run a pipeline from source through the same harness path as the compiled binary.
3
3
  // olve-dev describe <dir> [--sentinels]
4
4
  // olve-dev run <dir> <step> [--input-file f] [--config-file f] [--olve-dir d] [--secret NAME=value]...
5
+ // olve-dev preflight <dir> <step> [same options as run]
5
6
  // olve-dev map-trigger <dir> <trigger> --event-file f [--olve-dir d]
6
7
  // $OLVE_DIR defaults to <dir>/.olve; outputs land in <dir>/.olve/work/.
7
8
  import {
@@ -25,6 +26,7 @@ import {
25
26
 
26
27
  const USAGE = `usage: olve-dev describe <dir> [--sentinels]
27
28
  olve-dev run <dir> <step> [--input-file f] [--config-file f] [--olve-dir d] [--secret NAME=value]...
29
+ olve-dev preflight <dir> <step> [--input-file f] [--config-file f] [--olve-dir d] [--secret NAME=value]...
28
30
  olve-dev map-trigger <dir> <trigger> --event-file f [--olve-dir d]`;
29
31
 
30
32
  export async function devConfig(
@@ -71,10 +73,10 @@ export async function devMain(argv: string[]): Promise<number> {
71
73
  'describe',
72
74
  ...(values.sentinels ? ['--sentinels'] : []),
73
75
  ]);
74
- if (mode !== 'run' && mode !== 'map-trigger')
76
+ if (mode !== 'run' && mode !== 'preflight' && mode !== 'map-trigger')
75
77
  return (console.error(USAGE), EXIT.usage);
76
78
  if (!name) return (console.error(USAGE), EXIT.usage);
77
- const file = mode === 'run' ? values['input-file'] : values['event-file'];
79
+ const file = mode === 'map-trigger' ? values['event-file'] : values['input-file'];
78
80
  if (mode === 'map-trigger' && !file)
79
81
  return (console.error('--event-file is required'), EXIT.usage);
80
82
  const olveDir = resolve(values['olve-dir'] ?? join(dir, '.olve'));
@@ -88,7 +90,7 @@ export async function devMain(argv: string[]): Promise<number> {
88
90
  process.env[s.slice(0, i)] = s.slice(i + 1);
89
91
  }
90
92
  if (file) copyFileSync(resolve(file), join(olveDir, 'input.json'));
91
- if (mode === 'run' && values['config-file']) {
93
+ if (mode !== 'map-trigger' && values['config-file']) {
92
94
  const inputFile = join(olveDir, 'input.json');
93
95
  const input = (
94
96
  existsSync(inputFile) ? JSON.parse(readFileSync(inputFile, 'utf8')) : {}
@@ -3,13 +3,14 @@
3
3
  import { existsSync } from 'node:fs';
4
4
  import { join } from 'node:path';
5
5
  import { isSkipped } from '../core/handlers.ts';
6
+ import { isExternal } from '../core/external.ts';
6
7
  import type { AnyStep } from '../core/steps.ts';
7
8
  import type { Ctx, RunInfo } from '../ctx/types.ts';
8
9
  import { objectPath } from '../ctx/files.ts';
9
10
  import { createLinks, type StepLink } from '../ctx/links.ts';
10
11
  import { sha256File } from '../util/hash.ts';
11
12
  import { EXIT } from './exit.ts';
12
- import { validateConfig, validateInput, validateOutput } from './validate.ts';
13
+ import { CONFIG_KEY, validateConfig, validateInput, validateOutput } from './validate.ts';
13
14
 
14
15
  export type FailureKind = 'handler' | 'input' | 'output';
15
16
  export type Envelope = (
@@ -136,3 +137,78 @@ export async function executeStep(args: {
136
137
  exitCode: EXIT.ok,
137
138
  };
138
139
  }
140
+
141
+ /**
142
+ * A step's preflight: validates the step's non-external inputs (external values already given are
143
+ * ignored), runs the preflight and reports its links. Succeeds with `output: null`; nothing is validated
144
+ * as output and no files are kept.
145
+ */
146
+ export async function executePreflight(args: {
147
+ step: AnyStep;
148
+ fn: StepFunction;
149
+ rawInput: unknown;
150
+ ctx: Ctx;
151
+ workFiles: string;
152
+ log: (line: string) => void;
153
+ }): Promise<Execution> {
154
+ const { step, log } = args;
155
+ const entries = Object.entries(step.input as Record<string, unknown>);
156
+ const external = new Set(entries.filter(([, e]) => isExternal(e)).map(([k]) => k));
157
+ const regular = {
158
+ ...step,
159
+ input: Object.fromEntries(entries.filter(([k]) => !external.has(k))),
160
+ };
161
+ const raw =
162
+ typeof args.rawInput === 'object' &&
163
+ args.rawInput !== null &&
164
+ !Array.isArray(args.rawInput)
165
+ ? Object.fromEntries(
166
+ Object.entries(args.rawInput).filter(
167
+ ([k]) => k === CONFIG_KEY || !external.has(k),
168
+ ),
169
+ )
170
+ : args.rawInput;
171
+ const input = validateInput(regular, raw);
172
+ const config = validateConfig(step, raw);
173
+ if (!input.ok || !config.ok) {
174
+ const issues = [
175
+ ...(input.ok ? [] : input.issues),
176
+ ...(config.ok ? [] : config.issues),
177
+ ];
178
+ const message = `step '${step.name}' preflight: input does not match the step's inputs:\n ${issues.join('\n ')}`;
179
+ log(message);
180
+ return {
181
+ envelope: { status: 'failed', error: { kind: 'input', message } },
182
+ exitCode: EXIT.inputInvalid,
183
+ };
184
+ }
185
+ const collected = createLinks();
186
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
187
+ const { output: _output, skip: _skip, ...rest } = args.ctx;
188
+ const ctx = {
189
+ ...rest,
190
+ config: Object.freeze(config.value) as Ctx['config'],
191
+ link: collected.link,
192
+ };
193
+ const withLinks = (e: Envelope): Envelope =>
194
+ collected.links.length ? { ...e, links: [...collected.links] } : e;
195
+ try {
196
+ await args.fn(input.value, ctx as Ctx);
197
+ } catch (e) {
198
+ const cancelled = cancelExitCode(ctx.signal);
199
+ const message = cancelled
200
+ ? `step '${step.name}' preflight was cancelled (${String(ctx.signal.reason)}): ${errorMessage(e)}`
201
+ : `step '${step.name}' preflight failed: ${errorMessage(e)}`;
202
+ log(message);
203
+ if (e instanceof Error && e.stack && !cancelled) log(e.stack);
204
+ return {
205
+ envelope: withLinks({ status: 'failed', error: { kind: 'handler', message } }),
206
+ exitCode: cancelled ?? EXIT.handlerFailed,
207
+ };
208
+ }
209
+ log(`step '${step.name}' preflight succeeded`);
210
+ return {
211
+ envelope: withLinks({ status: 'succeeded', output: null }),
212
+ exitCode: EXIT.ok,
213
+ };
214
+ }
@@ -1,6 +1,7 @@
1
1
  // The harness: one executable, three modes.
2
2
  // describe [--sentinels] → manifest (canonical JSON) on stdout
3
3
  // run <step> → $OLVE_DIR/input.json → handler → $OLVE_DIR/work/output.json (+ result upload)
4
+ // preflight <step> → the step's non-external inputs → preflight → envelope (links only, no output)
4
5
  // map-trigger <trigger> → $OLVE_DIR/input.json (event) → pipeline input → $OLVE_DIR/work/output.json
5
6
  import { mkdirSync, readFileSync } from 'node:fs';
6
7
  import { join } from 'node:path';
@@ -17,6 +18,7 @@ import type { HarnessInfo } from '../util/code-hash.ts';
17
18
  import { callbackUrlsFromEnv, ENV, paths, runInfoFromEnv } from './env.ts';
18
19
  import { EXIT } from './exit.ts';
19
20
  import {
21
+ executePreflight,
20
22
  executeStep,
21
23
  type Envelope,
22
24
  type Execution,
@@ -56,7 +58,7 @@ export const defaultIO: HarnessIO = {
56
58
  };
57
59
 
58
60
  const USAGE =
59
- 'usage: step describe [--sentinels] | step run <step> | step map-trigger <trigger>';
61
+ 'usage: step describe [--sentinels] | step run <step> | step preflight <step> | step map-trigger <trigger>';
60
62
 
61
63
  /** Every step with the `select` case conditions (`<ref>.<discriminator>=<value>`) it sits in. */
62
64
  export function stepsWithCases(data: PipelineData): { step: AnyStep; cases: string[] }[] {
@@ -125,6 +127,13 @@ export function resolveStepFunction(step: AnyStep): StepFunction | string {
125
127
  return `step '${step.name}' has no implementation (stage implement(step({...}), fn))`;
126
128
  }
127
129
 
130
+ /** The preflight `step` carries, or what is wrong. */
131
+ export function resolvePreflight(step: AnyStep): StepFunction | string {
132
+ if (isImplementation(step) && step.preflight)
133
+ return step.preflight as unknown as StepFunction;
134
+ return `step '${step.name}' has no preflight (implement(step, fn, { preflight }))`;
135
+ }
136
+
128
137
  /** Writes output.json, prints the sentinel block and uploads result.tar.gz when OLVE_RESULT_URL is set. */
129
138
  export async function finish(exec: Execution, io: HarnessIO): Promise<number> {
130
139
  const p = paths();
@@ -182,12 +191,13 @@ async function runMode(
182
191
  cfg: HarnessConfig,
183
192
  name: string | undefined,
184
193
  io: HarnessIO,
194
+ mode: 'run' | 'preflight' = 'run',
185
195
  ): Promise<number> {
186
196
  if (!name) return (io.stderr(USAGE), EXIT.usage);
187
197
  const data = pipelineData(cfg.pipeline);
188
198
  const step = findStep(data, name, selectedCases());
189
199
  if (typeof step === 'string') return (io.stderr(step), EXIT.usage);
190
- const fn = resolveStepFunction(step);
200
+ const fn = mode === 'run' ? resolveStepFunction(step) : resolvePreflight(step);
191
201
  if (typeof fn === 'string') return (io.stderr(fn), EXIT.configInvalid);
192
202
 
193
203
  const p = paths();
@@ -225,17 +235,18 @@ async function runMode(
225
235
  callbackUrls: callbackUrlsFromEnv(),
226
236
  ...(cell?.ok ? { cell: cell.cell } : {}),
227
237
  });
228
- exec = await executeStep({
238
+ const args = {
229
239
  step,
230
240
  fn,
231
241
  rawInput: raw.value,
232
242
  ctx,
233
243
  workFiles: p.files,
234
244
  log: io.stderr,
235
- });
245
+ };
246
+ exec = await (mode === 'run' ? executeStep(args) : executePreflight(args));
236
247
  }
237
248
  if (uncaught !== undefined && exec.envelope.status !== 'failed') {
238
- const message = `step '${step.name}' failed: an error nothing caught: ${uncaught instanceof Error ? uncaught.message : String(uncaught)}`;
249
+ const message = `step '${step.name}'${mode === 'preflight' ? ' preflight' : ''} failed: an error nothing caught: ${uncaught instanceof Error ? uncaught.message : String(uncaught)}`;
239
250
  io.stderr(message);
240
251
  exec = {
241
252
  envelope: { status: 'failed', error: { kind: 'handler', message } },
@@ -365,6 +376,8 @@ export async function runHarness(
365
376
  return describeMode(cfg, rest, io);
366
377
  case 'run':
367
378
  return runMode(cfg, rest[0], io);
379
+ case 'preflight':
380
+ return runMode(cfg, rest[0], io, 'preflight');
368
381
  case 'map-trigger':
369
382
  return mapTriggerMode(cfg, rest[0], io);
370
383
  default:
@@ -30,10 +30,11 @@ function ownArgv(): string[] {
30
30
 
31
31
  /**
32
32
  * Runs the step as a masked child and answers its exit code, or undefined when this process should
33
- * run the step itself: it is the child, the mode isn't `run`, or there is nothing to mask.
33
+ * run the step itself: it is the child, the mode isn't `run` or `preflight`, or there is nothing to mask.
34
34
  */
35
35
  export async function supervise(argv: readonly string[]): Promise<number | undefined> {
36
- if (argv[0] !== 'run' || process.env[SUPERVISED]) return undefined;
36
+ if ((argv[0] !== 'run' && argv[0] !== 'preflight') || process.env[SUPERVISED])
37
+ return undefined;
37
38
  const patterns = secretPatterns();
38
39
  if (patterns.length === 0) return undefined;
39
40
 
package/src/index.ts CHANGED
@@ -79,6 +79,10 @@ export {
79
79
  type CtxOf,
80
80
  type Handler,
81
81
  type HandlerResult,
82
+ type ImplementOptions,
83
+ type Preflight,
84
+ type PreflightCtxOf,
85
+ type PreflightInput,
82
86
  type Skipped,
83
87
  } from './core/handlers.ts';
84
88
  export type {