@olivervea/pipelines 2.0.0-beta.33 → 2.0.0-beta.35

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
 
@@ -241,7 +242,7 @@ export const deployBeta = implement(step({ name: "deploy-beta", /* … */ }), as
241
242
  | `ctx.` | |
242
243
  |---|---|
243
244
  | `sh(cmd \| script, { env, cwd, args })`, `sh.text(…)` | Runs a command string, or an embedded script, through the image's shell. The script is copied to `$OLVE_DIR/tmp/scripts/` first. The shell is the first of `/bin/sh`, `/busybox/sh`, `/bin/bash` that exists; a shell-less image gets a clear `ShellNotFoundError`. A script's shebang is honoured when its interpreter exists. A string counts as an embedded script when it is a `/$bunfs/…` path, or an absolute path to an existing `*.sh` or `*.bash` file. Pass `{ script }` to force script mode. `args` are the script's arguments, or `$1`, `$2`… of a command string. A non-zero exit throws a `CommandError` that carries `exitCode`. |
244
- | `exec([argv…], { env, cwd, idleTimeoutSeconds })` | Runs a binary directly, with no shell (works on distroless). `idleTimeoutSeconds` (also on `sh`) is an inactivity watchdog: no stdout/stderr output for that long → SIGTERM, SIGKILL 10 s later, and a `CommandStalledError`. |
245
+ | `exec([argv…], { env, cwd, idleTimeoutSeconds })` | Runs a binary directly, with no shell (works on distroless). `idleTimeoutSeconds` (also on `sh`) is an inactivity watchdog: no stdout/stderr output for that long → SIGTERM, SIGKILL 10 s later, and a `CommandStalledError`. Once a command exits, its output is read for at most 5 s more, so a leftover background child holding the pipes doesn't hang the step. |
245
246
  | `readText(path)`, `readJson(path)` | Paths are relative to the cwd. |
246
247
  | `files.fetch(ref)` | Downloads a file reference and verifies its sha256. Directory refs are extracted. Returns a local path. |
247
248
  | `output.write(name, content)`, `output.file(path, { name })`, `output.dir(path, { name })` | Adds a file to the step's output and returns its `FileRef`. A directory becomes a deterministic tar. |
@@ -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
 
@@ -343,7 +345,7 @@ This section is everything the C# side needs in order to run the binary.
343
345
  | `OLVE_FILES_BASE_URL` (+ `OLVE_FILES_TOKEN`) | optional | Fallback: `GET <base>/<run>/<step>/<attempt>/<object path>` with an optional `Authorization: Bearer <token>`. |
344
346
  | `OLVE_FILES_DIR` | dev | Local pool laid out as `<run>/<step>/<attempt>/files/<object path>`. |
345
347
 
346
- `OLVE_RESULT_URL`, `OLVE_FILE_URLS`, `OLVE_FILES_TOKEN` and `OLVE_CALLBACK_URLS` are removed from the environment of child processes. URLs are never logged past `?`.
348
+ The variables in this table (the `OLVE_*` contract, credentials included) are removed from the environment of child processes `ctx.sh` and `ctx.exec` start; declared secrets are not. URLs are never logged past `?`.
347
349
 
348
350
  **Secret masking** (`run`): with `OLVE_SECRETS` set, the binary runs the step as its own child and replaces every value of those secrets with `***` in the child's stdout and stderr, a line at a time, from the pipeline's first import to its exit. That covers every way of printing: logs, `console`, direct writes, commands and the processes they start. The envelope's failure message, skip reason and links are masked too; outputs and files are data and aren't. A multi-line value is masked line by line, and values (or lines) shorter than 4 characters are left alone. Not masked: a value the step transforms before printing (escaped, encoded, cut short) and a process writing to the pod's stdout directly (`/proc/1/fd/1`).
349
351
 
@@ -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`.
@@ -446,6 +449,7 @@ Describe runs these checks; reconcile should re-run the manifest-level ones:
446
449
  - mapper source analysis and recording, unknown fields in refs, mapper keys that are missing or extra;
447
450
  - `when` on an unknown or non-literal field, or with an invalid value;
448
451
  - 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;
452
+ - a preflight on a step without an external input;
449
453
  - a poll header referencing an undeclared secret, duplicate triggers;
450
454
  - conflicting secret descriptions;
451
455
  - 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 +494,7 @@ The manifest is the text between the sentinels in the pod log. The manifest text
490
494
  ```
491
495
  olve-dev describe <dir> [--sentinels] # same bytes as the compiled binary's describe
492
496
  olve-dev run <dir> <step> [--input-file f] [--config-file f] [--olve-dir d] [--secret NAME=value]...
497
+ olve-dev preflight <dir> <step> [same options as run]
493
498
  olve-dev map-trigger <dir> <trigger> --event-file f [--olve-dir d]
494
499
  ```
495
500
 
@@ -687,6 +687,11 @@
687
687
  ],
688
688
  "description": "Output model, or null if the step produces no output."
689
689
  },
690
+ "preflight": {
691
+ "const": true,
692
+ "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.",
693
+ "type": "boolean"
694
+ },
690
695
  "retries": {
691
696
  "maximum": 9007199254740991,
692
697
  "minimum": 0,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olivervea/pipelines",
3
- "version": "2.0.0-beta.33",
3
+ "version": "2.0.0-beta.35",
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
 
package/src/ctx/sh.ts CHANGED
@@ -52,6 +52,9 @@ export class CommandStalledError extends CommandError {
52
52
  /** Grace between the watchdog's SIGTERM and SIGKILL (a process blocked in a socket read may ignore TERM). */
53
53
  export const STALL_KILL_GRACE_MS = 10_000;
54
54
 
55
+ /** How long output is still read after a command exits; a leftover child (an MSBuild node) may hold the pipes. */
56
+ export const PIPE_GRACE_MS = 5_000;
57
+
55
58
  export function detectShell(candidates: readonly string[] = SHELL_CANDIDATES): string {
56
59
  for (const c of candidates) if (existsSync(c)) return c;
57
60
  throw new ShellNotFoundError();
@@ -80,6 +83,7 @@ export interface ShDeps {
80
83
  tmpDir: string;
81
84
  signal: AbortSignal;
82
85
  shellCandidates?: readonly string[];
86
+ pipeGraceMs?: number;
83
87
  }
84
88
 
85
89
  export function createSh(deps: ShDeps): {
@@ -208,9 +212,16 @@ export function createSh(deps: ShDeps): {
208
212
 
209
213
  try {
210
214
  const code = await proc.exited;
211
- // A killed command's children may still hold the pipes open: don't wait for them after a stall.
212
- if (stalled) await Promise.all(readers.map((r) => r.cancel().catch(() => {})));
213
- else await Promise.all(pumps);
215
+ // A command's leftover children may still hold the pipes open: don't wait for them long.
216
+ if (!stalled) {
217
+ let grace: ReturnType<typeof setTimeout> | undefined;
218
+ await Promise.race([
219
+ Promise.all(pumps),
220
+ new Promise((r) => (grace = setTimeout(r, deps.pipeGraceMs ?? PIPE_GRACE_MS))),
221
+ ]);
222
+ clearTimeout(grace);
223
+ }
224
+ await Promise.all(readers.map((r) => r.cancel().catch(() => {})));
214
225
  if (stalled)
215
226
  throw new CommandStalledError(
216
227
  what,
@@ -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(),
@@ -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')) : {}
@@ -25,12 +25,12 @@ export const ENV = {
25
25
  filesDir: 'OLVE_FILES_DIR',
26
26
  } as const;
27
27
 
28
- /** Env vars never passed to child processes (they carry credentials for the harness only). */
28
+ /** Env vars never passed to child processes: the harness's own contract, credentials included. */
29
29
  export const PRIVATE_ENV: readonly string[] = [
30
- ENV.resultUrl,
31
- ENV.fileUrls,
32
- ENV.filesToken,
33
- ENV.callbackUrls,
30
+ ...Object.values(ENV),
31
+ 'OLVE_CASES',
32
+ 'OLVE_SECRETS',
33
+ 'OLVE_SUPERVISED',
34
34
  ];
35
35
 
36
36
  /** `OLVE_CALLBACK_URLS`, parsed; empty when unset or malformed. */
@@ -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 {