@olivervea/pipelines 2.0.0-beta.20 → 2.0.0-beta.22

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
@@ -47,7 +47,7 @@ bunx olve-build . --out .olve/out
47
47
  ```
48
48
 
49
49
  - The package ships TypeScript sources (Bun runs them directly; `harness.sdkHash` hashes them), so the
50
- same version gives the same `sdkHash` and handler hashes from npm or from `file:../src/sdk`.
50
+ same version gives the same `sdkHash` and `codeHash` from npm or from `file:../src/sdk`.
51
51
  - Inside this repo, `.pipelines/` and `.pipelines-test/` use `"@olivervea/pipelines": "file:../src/sdk"`.
52
52
 
53
53
  ### Releases
@@ -59,17 +59,9 @@ skips when the version exists, or when the pipeline secret `NPM_TOKEN` is unset.
59
59
 
60
60
  ## Authoring
61
61
 
62
- A pipeline directory (the binding path, `.pipelines/` by default) looks like this:
62
+ A pipeline directory (the binding path, `.pipelines/` by default) is an ordinary Bun package. `main` in `package.json` is required and names the entry point, which default-exports the pipeline; every other name is yours. A step carries its implementation, `implement(step({ … }), fn)`, and is imported and staged like any value.
63
63
 
64
- ```
65
- models.ts data models (contract file)
66
- pipeline.ts steps + `export default pipeline(…)` (contract file)
67
- handlers/*.ts one per custom step: `export default implement(step, fn)` (pairs by the step, not the file name)
68
- handlers/lib/… shared helpers (not handlers)
69
- scripts/*.sh embedded with `import s from "../scripts/x.sh" with { type: "file" }`
70
- ```
71
-
72
- Contract files hold contracts only. Describe evaluates them without loading handlers, and they are kept out of every handler's hash.
64
+ Describe evaluates the entry point and everything it imports, so top-level code must be deterministic: no env, time or network. Scripts are embedded with `import s from "./x.sh" with { type: "file" }`.
73
65
 
74
66
  ### Models, secrets, steps
75
67
 
@@ -97,6 +89,7 @@ export const deployBeta = step({
97
89
 
98
90
  Rules:
99
91
 
92
+ - `step()` is the contract. A pipeline stages `implement(step, fn)`; a staged step without an implementation is a describe error.
100
93
  - Models are **nominal**: two models with the same shape are not interchangeable.
101
94
  - There is **one schema per model name**. Two different schemas registered under the same name is a describe error.
102
95
  - **Outputs are validated strictly**: unknown keys are rejected at any depth, not stripped.
@@ -176,13 +169,13 @@ Library steps carry their own implementation and image; they take typed settings
176
169
  | `sshSession(ctx, { host, key })` | Installs openssh-client when the image has no ssh (apk or apt-get), writes the key to `<tmpDir>/ssh/id` (trailing newline, mode 600; host key not verified) and returns `{ run(remote), text(remote), copy(local, remote), shell(remote) }`; `shell` is the quoted ssh command for pipes (`ctx.sh(`printf '%s' "$V" \| ${s.shell("kubectl apply -f -")}`)`). `helmDeployOverSsh` uses it. |
177
170
 
178
171
  ```ts
179
- import { httpHealthCheck, implement } from "@olivervea/pipelines";
180
- export default implement(smoke, async (_, ctx) => { await httpHealthCheck(ctx, "http://app.ns.svc.cluster.local/api/health", { attempts: 10 }); });
172
+ import { httpHealthCheck, implement, step } from "@olivervea/pipelines";
173
+ export const smoke = implement(step({ name: "smoke", description: "", image: "alpine:3.22" }), async (_, ctx) => { await httpHealthCheck(ctx, "http://app.ns.svc.cluster.local/api/health", { attempts: 10 }); });
181
174
  ```
182
175
 
183
176
  ### Reusable steps
184
177
 
185
- A step several pipelines use is a function returning `implement(step(…), handler)`, staged directly: it carries its handler, so it has no `handlers/` file. The library steps are written this way, so a module in a repo or a package of its own works the same.
178
+ A step several pipelines use is a function returning `implement(step(…), handler)`, staged directly. The library steps are written this way, so a module in a repo or a package of its own works the same.
186
179
 
187
180
  ```ts
188
181
  import { implement, resolveConfigRefs, serviceUrl, step, HelmRelease, type Configurable, type LiteralName } from "@olivervea/pipelines";
@@ -195,20 +188,17 @@ export function smokeTest<const N extends string>(def: { name: LiteralName<N>; d
195
188
  }
196
189
  ```
197
190
 
198
- - **`kind`**: a label, required for a step that carries its implementation (`custom` is a step with a handler file). Prefix a package's kinds (`acme/smokeTest`).
191
+ - **`kind`**: a label, `custom` unless the step names one. Prefix a package's kinds (`acme/smokeTest`).
199
192
  - **`settings`**: what the step was set up with, recorded as the step's manifest `config` (so part of its version). JSON values and config references; the step receives every entry its settings reference.
200
- - **Identity**: `implement` and `step` record the module that called them (from the stack; a tail call can name a module further up, which only adds to what is hashed). The build bundles those modules, minus `pipeline.ts` and `models.ts`, like a handler, and the kind's hash (`kindHashes` in the generated `handlers.ts`) goes into each such step's `handlerHash`. A step that carries its implementation and has no other module (defined and implemented in a contract file) is a build error.
201
- - `implement` refuses a step that already carries an implementation; a `handlers/*.ts` file for such a step is a describe error.
193
+ - `implement` refuses a step that already carries an implementation.
202
194
 
203
195
  ### Handlers
204
196
 
205
197
  ```ts
206
- // handlers/deploy-beta.ts
207
- import { implement } from "@olivervea/pipelines";
208
- import { deployBeta } from "../pipeline";
209
- import deployScript from "../scripts/deploy.sh" with { type: "file" };
198
+ import { implement, step } from "@olivervea/pipelines";
199
+ import deployScript from "./scripts/deploy.sh" with { type: "file" };
210
200
 
211
- export default implement(deployBeta, async ({ artifact }, ctx) => {
201
+ export const deployBeta = implement(step({ name: "deploy-beta", /* … */ }), async ({ artifact }, ctx) => {
212
202
  await ctx.sh(deployScript, { env: { IMAGE_TAR: await ctx.files.fetch(artifact.image), CHART_DIR: await ctx.files.fetch(artifact.chart) } });
213
203
  await ctx.retry({ attempts: 5, delaySeconds: 5 }, () => ctx.http.expectOk("https://pipelines-beta.ovea.pro/api/health"));
214
204
  return { release: "olve-pipelines", version: artifact.version, url: "https://pipelines-beta.ovea.pro" }; // or ctx.skip("reason")
@@ -260,13 +250,13 @@ export default pipeline({ name: "Olve.Pipelines", description: "…", input: M.D
260
250
 
261
251
  ```ts
262
252
  import { testHandler, testFileRef } from "@olivervea/pipelines/testing";
263
- import deployBeta from "../handlers/deploy-beta";
253
+ import { deployBeta } from "../steps/deploy-beta";
264
254
 
265
255
  const r = await testHandler(deployBeta, { artifact }, { secrets: { SSH_PRIVATE_KEY: "k" }, config: { target: { host: "h" } }, ctx: { http: fakeHttp }, run: { number: 412 } });
266
256
  expect(r).toMatchObject({ status: "succeeded", output: { release: "olve-pipelines" } }); // r.filesDir, r.logs, r.exitCode
267
257
  ```
268
258
 
269
- `testHandler` uses the same validation path as a real run. Invalid input gives `status: "failed"` with `kind: "input"`; an invalid return value gives `kind: "output"`. Any part of `ctx` can be replaced; whatever you don't replace is the real implementation. `testHandler` takes any implementation: a handler module's default export, a library step or a reusable step.
259
+ `testHandler` uses the same validation path as a real run. Invalid input gives `status: "failed"` with `kind: "input"`; an invalid return value gives `kind: "output"`. Any part of `ctx` can be replaced; whatever you don't replace is the real implementation. `testHandler` takes any implementation: a custom step, a library step or a reusable step.
270
260
 
271
261
  ## Harness contract (service ⇄ step binary)
272
262
 
@@ -346,7 +336,7 @@ For `map-trigger`, `output` is the pipeline input after it has been parsed by th
346
336
  | 65 | `input.json` missing, not JSON, or invalid for the step (or event), including its `$config` | failed / `input` |
347
337
  | 66 | the return value failed strict validation, or an own file ref is missing or changed | failed / `output` |
348
338
  | 74 | the `result.tar.gz` upload failed after retries. Used only when the step itself succeeded; otherwise the step's own code wins. | yes (printed) |
349
- | 78 | the pipeline is invalid (describe problems, or a missing or wrong handler module) | none |
339
+ | 78 | the pipeline is invalid (describe problems, or a step without an implementation) | none |
350
340
  | 130 / 143 | the handler failed after SIGINT / SIGTERM (cancellation, `activeDeadlineSeconds`) | failed / `handler` |
351
341
 
352
342
  ### Result upload (`OLVE_RESULT_URL`)
@@ -377,7 +367,7 @@ Exact format: [`manifest.schema.json`](manifest.schema.json) (JSON Schema 2020-1
377
367
  - **`apiVersion`** is `"2.0"`.
378
368
  - **`harness`** is `{ bun, sdk, sdkHash }`.
379
369
  - **`models[name]`** is `{ output, input? }`.
380
- - **Step `kind`** is `custom` (a handler file) or the kind of a step that carries its implementation (`imageBuild`, `composeDeploy`, a package's `acme/…`). To the service it is a label: any non-empty kind is accepted, so a new library or package step needs no service change.
370
+ - **Step `kind`** is `custom` by default, or what a reusable step names (`imageBuild`, `composeDeploy`, a package's `acme/…`). To the service it is a label: any non-empty kind is accepted, so a new library or package step needs no service change.
381
371
  - `output` is the `z.output` view, with `additionalProperties: false` on every object. It validates outputs, the pool and trigger results.
382
372
  - `input` is the `z.input` view (defaults optional). Use it for trigger forms and manual input. It is emitted only when it differs from `output` beyond `additionalProperties`.
383
373
  - **Step `input`** maps each input key to a mapping:
@@ -388,10 +378,7 @@ Exact format: [`manifest.schema.json`](manifest.schema.json) (JSON Schema 2020-1
388
378
  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"}`).
389
379
  - **Step `externalInputs`** (when present) maps each external input key to `{model, title, description, from}`, `from` being `{kind: "person"}`, `{kind: "callback"}` or `{kind: "poll", url, valuePath, intervalSeconds}`. Those keys are not in `input` or `inputTypes`; the service merges the answers into `input.json`.
390
380
  - **`inputTypes`** declares each input key: `{model, optional}`, or `{schema: {output, input?}}` for a plain schema.
391
- - **`handlerHash`** is the step's implementation identity. A step version can be computed from image + `inputTypes`/model versions + `output` model version + `secrets` + `configEntries` (and those entries' schemas) + `input` mappings + `handlerHash`. Stage placement, `when` and the step's `description` are not part of it.
392
- - Custom steps: `sha256(canonical {bundle, harness})`. `bundle` hashes the handler bundled on its own, with `pipeline.ts`, `models.ts` and `@olivervea/pipelines` external. It is tree-shaken and includes embedded scripts, so it changes only when that handler's code (or code it uses) changes.
393
- - Steps that carry their implementation: `sha256(canonical {library: kind, config, harness, code})`, `code` being the hash of the modules that define and implement steps of that kind (see [Reusable steps](#reusable-steps)).
394
- - Because `harness` is part of both hashes, a Bun or SDK bump changes every handler.
381
+ - **`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`.
395
382
  - **`stages`** is an array whose items are either `{steps: [Step…]}` (parallel, with a barrier) or `{when: {field: [values]}, stages: [...]}`.
396
383
  - **`triggers`**:
397
384
  - `{kind: "push", name: "push"}`;
@@ -407,7 +394,7 @@ Describe runs these checks; reconcile should re-run the manifest-level ones:
407
394
 
408
395
  - step name format and reserved names (`input`, `failure`), and duplicate step names;
409
396
  - one schema per model name, and JSON-representable models;
410
- - missing handler file, a handler file without a step (or for a step that carries its implementation), and a step that carries its implementation without a kind;
397
+ - a staged step without an implementation, and an empty step kind;
411
398
  - availability, ambiguity, maybe-available as required, `optional` without a producer, plain schema without a mapper;
412
399
  - mapper source analysis and recording, unknown fields in refs, mapper keys that are missing or extra;
413
400
  - `when` on an unknown or non-literal field, or with an invalid value;
@@ -420,14 +407,14 @@ Describe runs these checks; reconcile should re-run the manifest-level ones:
420
407
 
421
408
  ```
422
409
  olve-build <pipelineDir> --out <dir> [--target bun-linux-x64|bun-linux-x64-musl] [--upload-url <presigned PUT>] [--sentinels]
423
- olve-build <pipelineDir> --hash-only # print handler hashes (diagnostics)
410
+ olve-build <pipelineDir> --hash-only # print the code hash (diagnostics)
424
411
  ```
425
412
 
426
- 1. Pair each `handlers/*.ts` with the step its `implement(step, …)` names, and hash it (see `handlerHash`); hash the modules of each kind of step that carries its implementation.
427
- 2. Generate `<pipelineDir>/.olve/gen/handlers.ts` (a registry of literal `import()` paths, plus hashes and harness info) and `entry.ts`. Add `.olve/` to `.gitignore`.
413
+ 1. Read the entry point from `main` in `package.json` (a missing `main`, or a `main` whose file is missing, is an error) and hash the build (see `codeHash`).
414
+ 2. Generate `<pipelineDir>/.olve/gen/entry.ts`, which imports the pipeline and calls `runHarness` with it, the code hash and the harness info. Add `.olve/` to `.gitignore`.
428
415
  3. Run `bun build --compile --target=bun-linux-x64 --no-compile-autoload-dotenv --no-compile-autoload-bunfig --bytecode --format=esm` → `<out>/step`. The output is never minified, because mapper source analysis needs real source.
429
416
  4. Check that `PT_INTERP` is `/lib64/ld-linux-x86-64.so.2`. On alpine, Bun silently produces a musl binary; the check fails it.
430
- 5. Run `<out>/step describe` → `<out>/manifest.json`. A describe problem is a build failure with exit 78. **A custom step without a handler file is an error.**
417
+ 5. Run `<out>/step describe` → `<out>/manifest.json`. A describe problem is a build failure with exit 78. **A step with no implementation is an error.**
431
418
  6. Write `<out>/step.tar.zst` (a deterministic tar, `step` at the root, mode 0755, zstd level 3: it unpacks in the step pod far faster than gzip; harness-base also still unpacks the gzip archives of older SDKs; from 2.0.0-beta.7 the SDK needs an instance whose harness-base unpacks zstd, i.e. one deployed on or after 2026-10-25) and `<out>/build.json` (diagnostics).
432
419
  7. With `--upload-url`, PUT `step.tar.zst`, using the same retry rules as the result upload. With `--sentinels`, print the manifest between the manifest sentinels.
433
420
 
@@ -47,6 +47,11 @@
47
47
  "const": "2.0",
48
48
  "type": "string"
49
49
  },
50
+ "codeHash": {
51
+ "description": "Hash of the whole build: the pipeline and its code bundled, plus the harness. Any code change makes a new pipeline version.",
52
+ "pattern": "^sha256:[0-9a-f]{64}$",
53
+ "type": "string"
54
+ },
50
55
  "config": {
51
56
  "description": "Config entries used by steps, sorted by name (omitted when none). Fields of kind secretRef ({secret: NAME}) name a secret.",
52
57
  "items": {
@@ -121,7 +126,7 @@
121
126
  },
122
127
  "harness": {
123
128
  "additionalProperties": false,
124
- "description": "Harness identity. Already folded into every handlerHash.",
129
+ "description": "Harness identity. Already folded into codeHash.",
125
130
  "properties": {
126
131
  "bun": {
127
132
  "description": "Bun runtime version compiled into the binary.",
@@ -227,6 +232,7 @@
227
232
  "name",
228
233
  "input",
229
234
  "harness",
235
+ "codeHash",
230
236
  "models",
231
237
  "secrets",
232
238
  "triggers",
@@ -404,7 +410,7 @@
404
410
  "additionalProperties": {
405
411
  "$ref": "#/$defs/Json"
406
412
  },
407
- "description": "The step's settings (display only; already covered by handlerHash). A config reference is recorded as {\"$config\": \"<entry>.<field>…\"}; the entry is in configEntries.",
413
+ "description": "The step's settings (display only). A config reference is recorded as {\"$config\": \"<entry>.<field>…\"}; the entry is in configEntries.",
408
414
  "propertyNames": {
409
415
  "type": "string"
410
416
  },
@@ -520,11 +526,6 @@
520
526
  },
521
527
  "type": "object"
522
528
  },
523
- "handlerHash": {
524
- "description": "Implementation identity: handler bundle (contract files excluded) + harness (bun + sdkHash); for a step that carries its implementation kind + config + harness + the hash of the code of its kind.",
525
- "pattern": "^sha256:[0-9a-f]{64}$",
526
- "type": "string"
527
- },
528
529
  "image": {
529
530
  "description": "Container image the step runs in.",
530
531
  "minLength": 1,
@@ -551,7 +552,7 @@
551
552
  "type": "object"
552
553
  },
553
554
  "kind": {
554
- "description": "'custom' = the handler is a handlers/*.ts file; any other kind is a step that carries its implementation (an SDK or package step). A label: the service accepts any kind.",
555
+ "description": "A label for the step's kind: 'custom' unless the step names one.",
555
556
  "minLength": 1,
556
557
  "type": "string"
557
558
  },
@@ -601,8 +602,7 @@
601
602
  "inputTypes",
602
603
  "input",
603
604
  "output",
604
- "secrets",
605
- "handlerHash"
605
+ "secrets"
606
606
  ],
607
607
  "type": "object"
608
608
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olivervea/pipelines",
3
- "version": "2.0.0-beta.20",
3
+ "version": "2.0.0-beta.22",
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",
@@ -3,18 +3,12 @@ import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
3
3
  import { join, resolve } from 'node:path';
4
4
  import { canonicalJson } from '../util/canonical.ts';
5
5
  import { sha256File } from '../util/hash.ts';
6
- import type { HarnessInfo } from '../util/handler-hash.ts';
6
+ import type { HarnessInfo } from '../util/code-hash.ts';
7
7
  import { writeTarZst } from '../util/tar.ts';
8
8
  import { MANIFEST_BEGIN, MANIFEST_END, uploadFile } from '../harness/result.ts';
9
9
  import { GLIBC_INTERP, MUSL_INTERP, readInterpreter } from './elf.ts';
10
- import { handlerHashes, kindHashes, type HandlerHash } from './hashes.ts';
11
- import {
12
- harnessInfo,
13
- loadPipeline,
14
- loadProject,
15
- relPosix,
16
- type Project,
17
- } from './project.ts';
10
+ import { codeHash } from './hashes.ts';
11
+ import { harnessInfo, loadProject, relPosix, type Project } from './project.ts';
18
12
 
19
13
  export const BUILD_FLAGS = [
20
14
  '--no-compile-autoload-dotenv',
@@ -50,42 +44,26 @@ export interface BuildResult {
50
44
  archive: string;
51
45
  manifestFile: string;
52
46
  manifest: string;
53
- hashes: Record<string, HandlerHash>;
47
+ codeHash: string;
54
48
  harness: HarnessInfo;
55
49
  }
56
50
 
57
- /** Writes `<dir>/.olve/gen/{handlers,entry}.ts`: the literal-import handler registry + binary entry. */
51
+ /** Writes `<dir>/.olve/gen/entry.ts`, the entry point of the step binary. */
58
52
  export function generateEntry(
59
53
  project: Project,
60
- hashes: Record<string, string>,
54
+ code: string,
61
55
  harness: HarnessInfo,
62
- kinds: Record<string, string> = {},
63
56
  ): string {
64
57
  const gen = join(project.dir, '.olve', 'gen');
65
58
  mkdirSync(gen, { recursive: true });
66
- const registry = project.handlers
67
- .map(
68
- ([name, file]) =>
69
- ` ${JSON.stringify(name)}: () => import(${JSON.stringify(relPosix(gen, file))}),`,
70
- )
71
- .join('\n');
72
- writeFileSync(
73
- join(gen, 'handlers.ts'),
74
- `// GENERATED by olve-build — do not edit.\n` +
75
- `import type { HandlerRegistry, HarnessInfo } from "@olivervea/pipelines/harness";\n\n` +
76
- `export const handlers: HandlerRegistry = {\n${registry}\n};\n\n` +
77
- `export const handlerHashes: Record<string, string> = ${JSON.stringify(hashes, null, 2)};\n\n` +
78
- `export const kindHashes: Record<string, string> = ${JSON.stringify(kinds, null, 2)};\n\n` +
79
- `export const harness: HarnessInfo = ${JSON.stringify(harness, null, 2)};\n`,
80
- );
81
59
  const entry = join(gen, 'entry.ts');
60
+ const config = `{ pipeline, codeHash: ${JSON.stringify(code)}, harness: ${JSON.stringify(harness)} }`;
82
61
  writeFileSync(
83
62
  entry,
84
63
  `// GENERATED by olve-build — do not edit. Entry point of the step binary.\n` +
85
64
  `import pipeline from ${JSON.stringify(relPosix(gen, project.pipelineFile))};\n` +
86
- `import { runHarness } from "@olivervea/pipelines/harness";\n` +
87
- `import { handlers, handlerHashes, kindHashes, harness } from "./handlers.ts";\n\n` +
88
- `process.exit(await runHarness({ pipeline, handlers, handlerHashes, kindHashes, harness }, process.argv.slice(2)));\n`,
65
+ `import { runHarness } from "@olivervea/pipelines/harness";\n\n` +
66
+ `process.exit(await runHarness(${config}, process.argv.slice(2)));\n`,
89
67
  );
90
68
  return entry;
91
69
  }
@@ -101,17 +79,9 @@ export async function build(dir: string, opts: BuildOptions): Promise<BuildResul
101
79
  const harness = harnessInfo();
102
80
 
103
81
  const t0 = performance.now();
104
- const hashes = await handlerHashes(project, harness);
105
- const kinds = await kindHashes(project, await loadPipeline(project));
106
- const entry = generateEntry(
107
- project,
108
- Object.fromEntries(Object.entries(hashes).map(([k, v]) => [k, v.handlerHash])),
109
- harness,
110
- kinds,
111
- );
112
- log(
113
- `olve-build: ${project.handlers.length} handler(s) hashed in ${((performance.now() - t0) / 1000).toFixed(2)}s`,
114
- );
82
+ const code = await codeHash(project, harness);
83
+ const entry = generateEntry(project, code, harness);
84
+ log(`olve-build: hashed the code in ${((performance.now() - t0) / 1000).toFixed(2)}s`);
115
85
 
116
86
  // 1. compile
117
87
  const binary = join(out, 'step');
@@ -178,8 +148,7 @@ export async function build(dir: string, opts: BuildOptions): Promise<BuildResul
178
148
  target,
179
149
  flags: BUILD_FLAGS,
180
150
  harness,
181
- handlers: hashes,
182
- kinds,
151
+ codeHash: code,
183
152
  binarySha256: sha256File(binary),
184
153
  archiveSha256: sha256File(archive),
185
154
  }),
@@ -191,5 +160,5 @@ export async function build(dir: string, opts: BuildOptions): Promise<BuildResul
191
160
  log('olve-build: uploaded step.tar.zst');
192
161
  }
193
162
  if (opts.sentinels) await stdout(`${MANIFEST_BEGIN}\n${manifest}${MANIFEST_END}\n`);
194
- return { binary, archive, manifestFile, manifest, hashes, harness };
163
+ return { binary, archive, manifestFile, manifest, codeHash: code, harness };
195
164
  }
package/src/build/cli.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  import { parseArgs } from 'node:util';
4
4
  import { canonicalJson } from '../util/canonical.ts';
5
5
  import { build, BuildError, type Target } from './build.ts';
6
- import { handlerHashes } from './hashes.ts';
6
+ import { codeHash } from './hashes.ts';
7
7
  import { harnessInfo, loadProject, ProjectError } from './project.ts';
8
8
 
9
9
  const USAGE =
@@ -33,8 +33,11 @@ async function main(): Promise<number> {
33
33
  const dir = positionals[0]!;
34
34
  try {
35
35
  if (values['hash-only']) {
36
- const h = await handlerHashes(await loadProject(dir), harnessInfo());
37
- await Bun.write(Bun.stdout, canonicalJson({ harness: harnessInfo(), handlers: h }));
36
+ const code = await codeHash(await loadProject(dir), harnessInfo());
37
+ await Bun.write(
38
+ Bun.stdout,
39
+ canonicalJson({ harness: harnessInfo(), codeHash: code }),
40
+ );
38
41
  return 0;
39
42
  }
40
43
  if (!values.out) return (console.error(`--out is required\n${USAGE}`), 64);
@@ -1,61 +1,33 @@
1
- // Per-handler hashes: bundle each handler on its own with the contract files and the SDK external
2
- // (tree-shaken → function-level granularity; embedded scripts are bundle outputs too), then hash the
3
- // outputs. Contract changes are versioned through the manifest's model/step entries, SDK changes
4
- // through `harness`; both are combined in composeHandlerHash.
5
- import { dirname, join, relative, resolve } from 'node:path';
1
+ // The build's code hash: everything the pipeline runs, bundled once with the SDK external (the
2
+ // harness identity stands for it).
6
3
  import type { BunPlugin } from 'bun';
7
- import { isImplementation } from '../core/handlers.ts';
8
- import { originsOf } from '../core/origin.ts';
9
- import { pipelineData } from '../builder/pipeline.ts';
10
- import { allSteps } from '../harness/main.ts';
11
- import { composeHandlerHash, type HarnessInfo } from '../util/handler-hash.ts';
12
4
  import { sha256Hex } from '../util/hash.ts';
5
+ import { composeCodeHash, type HarnessInfo } from '../util/code-hash.ts';
13
6
  import type { Project } from './project.ts';
14
7
 
15
- export interface HandlerHash {
16
- /** Hash of the bundle outputs alone. */
17
- bundleHash: string;
18
- /** composeHandlerHash(bundleHash, harness) — what the manifest carries. */
19
- handlerHash: string;
20
- /** Bundle output names (for diagnostics). */
21
- outputs: string[];
22
- }
8
+ const sdkExternal: BunPlugin = {
9
+ name: 'olve-code-hash-external',
10
+ setup(b) {
11
+ b.onResolve({ filter: /^@olivervea\/pipelines(\/.*)?$/ }, (args) => ({
12
+ path: args.path,
13
+ external: true,
14
+ }));
15
+ },
16
+ };
23
17
 
24
- function externals(project: Project): BunPlugin {
25
- const contracts = new Set(project.contractFiles);
26
- return {
27
- name: 'olve-handler-hash-externals',
28
- setup(b) {
29
- b.onResolve({ filter: /^@olivervea\/pipelines(\/.*)?$/ }, (args) => ({
30
- path: args.path,
31
- external: true,
32
- }));
33
- b.onResolve({ filter: /^\.{1,2}\// }, (args) => {
34
- const base = resolve(dirname(args.importer), args.path);
35
- for (const cand of [base, `${base}.ts`, join(base, 'index.ts')])
36
- if (contracts.has(cand)) return { path: args.path, external: true };
37
- return undefined;
38
- });
39
- },
40
- };
41
- }
42
-
43
- export async function bundleHash(
44
- project: Project,
45
- entry: string,
46
- ): Promise<{ hash: string; outputs: string[] }> {
47
- const inside = !relative(project.dir, entry).startsWith('..');
18
+ async function bundleHash(project: Project): Promise<string> {
19
+ const entry = project.pipelineFile;
48
20
  const r = await Bun.build({
49
21
  entrypoints: [entry],
50
22
  target: 'bun',
51
23
  format: 'esm',
52
- root: inside ? project.dir : dirname(entry),
24
+ root: project.dir,
53
25
  naming: {
54
26
  entry: '[dir]/[name].[ext]',
55
27
  chunk: '[dir]/[name].[ext]',
56
28
  asset: '[dir]/[name].[ext]',
57
29
  },
58
- plugins: [externals(project)],
30
+ plugins: [sdkExternal],
59
31
  throw: false,
60
32
  });
61
33
  if (!r.success)
@@ -64,12 +36,10 @@ export async function bundleHash(
64
36
  `bundling ${entry} failed:\n${r.logs.map(String).join('\n')}`,
65
37
  );
66
38
  const parts: string[] = [];
67
- const outputs: string[] = [];
68
39
  for (const o of [...r.outputs].sort((a, b) =>
69
40
  a.path < b.path ? -1 : a.path > b.path ? 1 : 0,
70
41
  )) {
71
42
  const name = o.path.replace(/^\.\//, '');
72
- outputs.push(name);
73
43
  const bytes = new Uint8Array(await o.arrayBuffer());
74
44
  // Bun prefixes each module with a `// <path>` comment relative to the *process cwd*; drop those
75
45
  // so the hash depends only on code, not on where the repo or the build tool runs.
@@ -78,52 +48,9 @@ export async function bundleHash(
78
48
  : bytes;
79
49
  parts.push(`${name}\0${sha256Hex(content)}`);
80
50
  }
81
- return { hash: 'sha256:' + sha256Hex(parts.join('\n')), outputs };
82
- }
83
-
84
- export async function handlerHashes(
85
- project: Project,
86
- harness: HarnessInfo,
87
- ): Promise<Record<string, HandlerHash>> {
88
- const out: Record<string, HandlerHash> = {};
89
- for (const [name, file] of project.handlers) {
90
- const b = await bundleHash(project, file);
91
- out[name] = {
92
- bundleHash: b.hash,
93
- handlerHash: composeHandlerHash(b.hash, harness),
94
- outputs: b.outputs,
95
- };
96
- }
97
- return out;
51
+ return 'sha256:' + sha256Hex(parts.join('\n'));
98
52
  }
99
53
 
100
- /**
101
- * Kind → hash of the modules that implement the steps of that kind which carry their implementation
102
- * (each module bundled like a handler: the SDK and contract files external).
103
- */
104
- export async function kindHashes(
105
- project: Project,
106
- pipeline: unknown,
107
- ): Promise<Record<string, string>> {
108
- const origins = new Map<string, Set<string>>();
109
- for (const s of allSteps(pipelineData(pipeline))) {
110
- if (!isImplementation(s)) continue;
111
- const files = originsOf(s).filter((f) => !project.contractFiles.includes(f));
112
- if (files.length === 0)
113
- throw new Error(
114
- `step '${s.name}' carries its implementation but is defined in ${project.contractFiles.map((f) => relative(project.dir, f)).join(' or ')}; define and implement it in a module of its own (or a package)`,
115
- );
116
- const set = origins.get(s.kind) ?? new Set<string>();
117
- for (const f of files) set.add(f);
118
- origins.set(s.kind, set);
119
- }
120
- const out: Record<string, string> = {};
121
- for (const [kind, files] of [...origins].sort(([a], [b]) =>
122
- a < b ? -1 : a > b ? 1 : 0,
123
- )) {
124
- const parts: string[] = [];
125
- for (const f of [...files].sort()) parts.push((await bundleHash(project, f)).hash);
126
- out[kind] = 'sha256:' + sha256Hex(parts.join('\n'));
127
- }
128
- return out;
54
+ export async function codeHash(project: Project, harness: HarnessInfo): Promise<string> {
55
+ return composeCodeHash(await bundleHash(project), harness);
129
56
  }
@@ -1,19 +1,14 @@
1
1
  // Layout of a pipeline directory and the identity of the SDK building it.
2
2
  import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
3
3
  import { dirname, join, relative, resolve } from 'node:path';
4
- import { isImplementation } from '../core/handlers.ts';
5
4
  import { sha256Hex } from '../util/hash.ts';
6
- import type { HarnessInfo } from '../util/handler-hash.ts';
5
+ import type { HarnessInfo } from '../util/code-hash.ts';
7
6
 
8
7
  export const SDK_ROOT = resolve(import.meta.dir, '../..');
9
8
 
10
9
  export interface Project {
11
10
  dir: string;
12
11
  pipelineFile: string;
13
- /** Contract files (excluded from handler bundles): pipeline.ts, models.ts. */
14
- contractFiles: string[];
15
- /** step name → absolute handler file, sorted by step name. The step is the one the file's `implement(step, …)` names. */
16
- handlers: [string, string][];
17
12
  }
18
13
 
19
14
  export class ProjectError extends Error {
@@ -24,35 +19,28 @@ export async function loadProject(dirArg: string): Promise<Project> {
24
19
  const dir = resolve(dirArg);
25
20
  if (!existsSync(dir) || !statSync(dir).isDirectory())
26
21
  throw new ProjectError(`${dirArg} is not a directory`);
27
- const pipelineFile = join(dir, 'pipeline.ts');
28
- if (!existsSync(pipelineFile))
22
+ return { dir, pipelineFile: entryOf(dirArg, dir) };
23
+ }
24
+
25
+ /** The file `main` in the package's package.json names. */
26
+ function entryOf(dirArg: string, dir: string): string {
27
+ const pkg = join(dir, 'package.json');
28
+ const main = existsSync(pkg)
29
+ ? (JSON.parse(readFileSync(pkg, 'utf8')) as { main?: string }).main
30
+ : undefined;
31
+ if (!main)
29
32
  throw new ProjectError(
30
- `${join(dirArg, 'pipeline.ts')} not found (it must default-export pipeline({...}))`,
33
+ `${join(dirArg, 'package.json')} must name the entry point in \`main\` (a file that default-exports pipeline({...}))`,
31
34
  );
32
- const contractFiles = ['pipeline.ts', 'models.ts']
33
- .map((f) => join(dir, f))
34
- .filter((f) => existsSync(f));
35
- const hdir = join(dir, 'handlers');
36
- const files = existsSync(hdir)
37
- ? readdirSync(hdir)
38
- .filter(
39
- (f) =>
40
- f.endsWith('.ts') &&
41
- !f.endsWith('.test.ts') &&
42
- !f.endsWith('.d.ts') &&
43
- statSync(join(hdir, f)).isFile(),
44
- )
45
- .sort()
46
- : [];
47
- return {
48
- dir,
49
- pipelineFile,
50
- contractFiles,
51
- handlers: await resolveHandlers(hdir, files),
52
- };
35
+ const entry = join(dir, main);
36
+ if (!existsSync(entry))
37
+ throw new ProjectError(
38
+ `${join(dirArg, main)} not found (it must default-export pipeline({...}))`,
39
+ );
40
+ return entry;
53
41
  }
54
42
 
55
- /** The default export of pipeline.ts. */
43
+ /** The default export of the entry point. */
56
44
  export async function loadPipeline(project: Project): Promise<unknown> {
57
45
  const mod = (await import(project.pipelineFile)) as { default?: unknown };
58
46
  if (!mod.default)
@@ -62,37 +50,6 @@ export async function loadPipeline(project: Project): Promise<unknown> {
62
50
  return mod.default;
63
51
  }
64
52
 
65
- /** Pairs each handler file with the step its default export implements (file names don't matter). */
66
- async function resolveHandlers(
67
- hdir: string,
68
- files: string[],
69
- ): Promise<[string, string][]> {
70
- const byStep = new Map<string, string>();
71
- for (const f of files) {
72
- const file = join(hdir, f);
73
- let impl: unknown;
74
- try {
75
- impl = ((await import(file)) as { default?: unknown }).default;
76
- } catch (e) {
77
- throw new ProjectError(
78
- `handlers/${f} failed to load: ${(e as Error)?.message ?? String(e)}`,
79
- );
80
- }
81
- if (!isImplementation(impl))
82
- throw new ProjectError(
83
- `handlers/${f} must default-export implement(<step>, …); put shared code in a subfolder such as handlers/lib/`,
84
- );
85
- const step = (impl.step as { name: string }).name;
86
- const other = byStep.get(step);
87
- if (other)
88
- throw new ProjectError(
89
- `handlers/${other.slice(hdir.length + 1)} and handlers/${f} both implement step '${step}'`,
90
- );
91
- byStep.set(step, file);
92
- }
93
- return [...byStep].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
94
- }
95
-
96
53
  function listFiles(dir: string): string[] {
97
54
  const out: string[] = [];
98
55
  for (const e of readdirSync(dir, { withFileTypes: true })) {
@@ -314,7 +314,7 @@ export function pipelineData(p: unknown): PipelineData {
314
314
  : undefined;
315
315
  if (!d)
316
316
  throw new TypeError(
317
- 'not a pipeline (pipeline.ts must default-export pipeline({...})...)',
317
+ 'not a pipeline (the entry point must default-export pipeline({...})...)',
318
318
  );
319
319
  return d as PipelineData;
320
320
  }
@@ -553,7 +553,7 @@ export class Pipeline<In extends AnyModel, A extends AvailMap> {
553
553
  }
554
554
  }
555
555
 
556
- /** Start a pipeline definition. `pipeline.ts` must default-export the result. */
556
+ /** Start a pipeline definition. The entry point must default-export the result. */
557
557
  export function pipeline<In extends AnyModel>(
558
558
  def: PipelineDef<In>,
559
559
  ): Pipeline<In, InitialAvail<In>> {
@@ -2,7 +2,6 @@
2
2
  // a dev runner and the user's installed package — still recognise each other's objects.
3
3
  export const STEP = Symbol.for('olve.pipelines.step');
4
4
  export const IMPLEMENTATION = Symbol.for('olve.pipelines.implementation');
5
- export const ORIGIN = Symbol.for('olve.pipelines.origin');
6
5
  export const SKIPPED = Symbol.for('olve.pipelines.skipped');
7
6
  export const PIPELINE = Symbol.for('olve.pipelines.pipeline');
8
7
  export const REF_PATH = Symbol.for('olve.pipelines.refPath');
@@ -1,12 +1,10 @@
1
1
  import {
2
2
  IMPLEMENTATION,
3
- ORIGIN,
4
3
  SKIPPED,
5
4
  type ImplementationBrand,
6
5
  type SkippedBrand,
7
6
  } from './brands.ts';
8
7
  import type { Ctx } from '../ctx/types.ts';
9
- import { callerFile, originsOf } from './origin.ts';
10
8
  import type { AnyStep, ConfigOf, OutputValue, SecretsOf, StepInput } from './steps.ts';
11
9
  import type { Awaitable } from './types.ts';
12
10
 
@@ -32,7 +30,7 @@ export type Handler<S extends AnyStep> = (
32
30
  ctx: Ctx<SecretsOf<S>, ConfigOf<S>>,
33
31
  ) => Awaitable<HandlerResult<S>>;
34
32
 
35
- /** A step with its handler. It is itself a step: stage it directly, or default-export it from `handlers/*.ts`. */
33
+ /** A step with its handler. It is itself a step: stage it like any value. */
36
34
  export type Implementation<S extends AnyStep = AnyStep> = S & {
37
35
  readonly [ImplementationBrand]: true;
38
36
  /** The step as defined. */
@@ -50,8 +48,8 @@ type NoExtraFields<R, T> = R extends Skipped | void
50
48
  };
51
49
 
52
50
  /**
53
- * Implement a step. Input, ctx and return types come from the step definition alone. Default-export
54
- * the result from a `handlers/*.ts` file, or stage it directly (a reusable step: give it a `kind`).
51
+ * Implement a step. Input, ctx and return types come from the step definition alone. Stage the
52
+ * result directly; a reusable step gives itself a `kind`.
55
53
  */
56
54
  export function implement<S extends AnyStep, R extends HandlerResult<S>>(
57
55
  step: S &
@@ -67,15 +65,9 @@ export function implement<S extends AnyStep, R extends HandlerResult<S>>(
67
65
  throw new TypeError(
68
66
  `step '${(step as AnyStep).name}' already carries its implementation`,
69
67
  );
70
- const origins = [
71
- ...new Set(
72
- [...originsOf(step), callerFile()].filter((f): f is string => f !== undefined),
73
- ),
74
- ];
75
68
  return Object.freeze({
76
69
  ...step,
77
70
  [IMPLEMENTATION]: true,
78
- [ORIGIN]: origins,
79
71
  step,
80
72
  handler: handler as unknown as Handler<S>,
81
73
  }) as unknown as Implementation<S>;
package/src/core/steps.ts CHANGED
@@ -1,6 +1,5 @@
1
1
  import type { z } from 'zod';
2
- import { ORIGIN, STEP, type StepBrand } from './brands.ts';
3
- import { callerFile } from './origin.ts';
2
+ import { STEP, type StepBrand } from './brands.ts';
4
3
  import type { AnyModel, Infer, Optional } from './models.ts';
5
4
  import type { External } from './external.ts';
6
5
  import type { AnyConfigEntry } from './config.ts';
@@ -29,8 +28,7 @@ export type InferEntry<E> =
29
28
  export type StepInput<I> = Simplify<{ -readonly [K in keyof I]: InferEntry<I[K]> }>;
30
29
 
31
30
  /**
32
- * A label for what a step is, shown in the UI. `custom` (the default) is a step whose handler is in
33
- * `handlers/`; a step that carries its implementation (`implement(step, …)` staged directly) names its
31
+ * A label for what a step is, shown in the UI. `custom` is the default; a reusable step names its
34
32
  * kind, e.g. `gitHubSource`. Packages prefix theirs (`acme/deploy`).
35
33
  */
36
34
  export type StepKind = string;
@@ -113,7 +111,7 @@ export interface StepDef<
113
111
  settings?: Readonly<Record<string, unknown>>;
114
112
  }
115
113
 
116
- function makeStep(def: Record<string, unknown>, origin: string | undefined): AnyStep {
114
+ function makeStep(def: Record<string, unknown>): AnyStep {
117
115
  const config = new Map<string, AnyConfigEntry>();
118
116
  for (const e of (def.config as readonly AnyConfigEntry[] | undefined) ?? [])
119
117
  if (!config.has(e.name)) config.set(e.name, e);
@@ -132,7 +130,6 @@ function makeStep(def: Record<string, unknown>, origin: string | undefined): Any
132
130
  ...(def.retries !== undefined ? { retries: def.retries } : {}),
133
131
  ...(def.clusterAccess === true ? { clusterAccess: true } : {}),
134
132
  ...(def.settings !== undefined ? { settings: def.settings } : {}),
135
- [ORIGIN]: origin === undefined ? [] : [origin],
136
133
  };
137
134
  return Object.freeze(s) as unknown as AnyStep;
138
135
  }
@@ -144,8 +141,8 @@ export function isStep(v: unknown): v is AnyStep {
144
141
  }
145
142
 
146
143
  /**
147
- * Define a step: a contract (name, image, inputs, output, secrets). Its handler is a `handlers/*.ts`
148
- * default-exporting `implement(step, …)`, or, for a reusable step, `implement(step, …)` staged directly.
144
+ * Define a step: a contract (name, image, inputs, output, secrets). `implement(step, fn)` gives it
145
+ * its handler; that is what a pipeline stages.
149
146
  */
150
147
  export function step<
151
148
  const N extends string,
@@ -154,5 +151,5 @@ export function step<
154
151
  const Sec extends readonly Secret[] = [],
155
152
  const Cfg extends readonly AnyConfigEntry[] = [],
156
153
  >(def: StepDef<N, I, O, Sec, Cfg>): Step<N, I, O, Sec[number]['name'], Cfg[number]> {
157
- return makeStep(def as never, callerFile()) as never;
154
+ return makeStep(def as never) as never;
158
155
  }
@@ -1,5 +1,4 @@
1
- // `describe`: evaluates the contracts (models + pipeline, never handlers) into the manifest and runs
2
- // every describe-time check. All problems are collected and reported together.
1
+ // `describe`: evaluates the pipeline into the manifest and runs every describe-time check. All problems are collected and reported together.
3
2
  import { z } from 'zod';
4
3
  import { CONFIG_NAME, isConfigEntry, SECRET_REF_KIND } from '../core/config.ts';
5
4
  import { CONFIG_CASE_KEY, CONFIG_REF_KEY } from '../core/config-refs.ts';
@@ -18,7 +17,7 @@ import {
18
17
  } from '../builder/pipeline.ts';
19
18
  import { FailureContext } from '../library/models.ts';
20
19
  import { canonicalize, canonicalJson } from '../util/canonical.ts';
21
- import { libraryHandlerHash, type HarnessInfo } from '../util/handler-hash.ts';
20
+ import type { HarnessInfo } from '../util/code-hash.ts';
22
21
  import { analyzeMapperSource } from './mapper-source.ts';
23
22
  import { MapperError, recordMapper, type Mapping } from './record.ts';
24
23
  import {
@@ -41,12 +40,8 @@ export class DescribeError extends Error {
41
40
  }
42
41
 
43
42
  export interface DescribeOptions {
44
- /** Step names that have a handler (a `handlers/*.ts` default-exporting `implement(step, …)`). */
45
- handlerNames: Iterable<string>;
46
- /** Step name → handler hash (from the build; includes the harness identity). */
47
- handlerHashes: Readonly<Record<string, string>>;
48
- /** Kind → hash of the code that implements the steps of that kind which carry their implementation (from the build). */
49
- kindHashes?: Readonly<Record<string, string>>;
43
+ /** Hash of the build (from the build; includes the harness identity). */
44
+ codeHash?: string;
50
45
  harness: HarnessInfo;
51
46
  }
52
47
 
@@ -129,7 +124,6 @@ function literalValues(field: JsonSchema | undefined): unknown[] | undefined {
129
124
  export function describePipeline(pipeline: unknown, opts: DescribeOptions): ManifestT {
130
125
  const data = pipelineData(pipeline);
131
126
  const problems: string[] = [...data.problems];
132
- const handlerNames = new Set(opts.handlerNames);
133
127
 
134
128
  // ── models ──
135
129
  const models = new Map<string, { schemas: ModelSchemasT; key: string }>();
@@ -232,20 +226,10 @@ export function describePipeline(pipeline: unknown, opts: DescribeOptions): Mani
232
226
  const prev = placements.get(s.name) ?? [];
233
227
  if (prev.some((p) => !exclusive(p.cases, cases)))
234
228
  problems.push(`duplicate step name '${s.name}'`);
235
- else if (prev.length) {
236
- if (
237
- prev.some(
238
- (p) => p.step !== s && !isImplementation(p.step) && !isImplementation(s),
239
- )
240
- )
241
- problems.push(
242
- `step '${s.name}': two different steps with handler files share the name across select cases; handlers pair by name, so use one step or different names`,
243
- );
244
- if (prev.some((p) => (p.step.output?.name ?? null) !== (s.output?.name ?? null)))
245
- problems.push(
246
- `step '${s.name}': its select cases must give it the same output model`,
247
- );
248
- }
229
+ else if (prev.some((p) => (p.step.output?.name ?? null) !== (s.output?.name ?? null)))
230
+ problems.push(
231
+ `step '${s.name}': its select cases must give it the same output model`,
232
+ );
249
233
  placements.set(s.name, [...prev, { step: s, cases }]);
250
234
  if (!steps.has(s.name)) steps.set(s.name, s);
251
235
  };
@@ -439,15 +423,10 @@ export function describePipeline(pipeline: unknown, opts: DescribeOptions): Mani
439
423
  `${at}: config '${name}' references secret '${ref}' (secretRef), so the step must declare it in secrets`,
440
424
  );
441
425
  }
442
- let handlerHash: string;
443
426
  if (settingsProblem !== undefined)
444
427
  problems.push(`${at}: invalid ${s.kind} settings (${settingsProblem})`);
445
428
  if (typeof s.kind !== 'string' || s.kind === '')
446
429
  problems.push(`${at}: kind must be a non-empty string`);
447
- if (carried && s.kind === 'custom')
448
- problems.push(
449
- `${at} carries its implementation, so it needs a kind (step({ kind: "…", … })); a step with kind custom has its handler in handlers/`,
450
- );
451
430
  for (const { ref, case: kase } of refs) {
452
431
  const [entry, ...path] = ref.split('.') as [string, ...string[]];
453
432
  if (kase !== undefined && !cases.some((c) => c.cond === kase))
@@ -463,20 +442,8 @@ export function describePipeline(pipeline: unknown, opts: DescribeOptions): Mani
463
442
  `${at}: config reference '${ref}': config '${entry}' has no field '${path.join('.')}'`,
464
443
  );
465
444
  }
466
- if (carried) {
467
- const code = opts.kindHashes?.[s.kind];
468
- if (opts.kindHashes && !code)
469
- problems.push(`${at}: no hash was computed for the code of kind '${s.kind}'`);
470
- handlerHash = libraryHandlerHash(s.kind, settings, opts.harness, code);
471
- } else {
472
- if (!handlerNames.has(s.name))
473
- problems.push(
474
- `${at} has no handler (add a handlers/*.ts default-exporting implement(${camel(s.name)}, …))`,
475
- );
476
- handlerHash = opts.handlerHashes[s.name] ?? '';
477
- if (handlerNames.has(s.name) && !handlerHash)
478
- problems.push(`${at}: no handler hash was computed`);
479
- }
445
+ if (!carried)
446
+ problems.push(`${at} has no implementation (stage implement(step({...}), fn))`);
480
447
  if (typeof s.description !== 'string')
481
448
  problems.push(`${at}: description must be a string (it may be empty)`);
482
449
  if (
@@ -496,7 +463,6 @@ export function describePipeline(pipeline: unknown, opts: DescribeOptions): Mani
496
463
  input,
497
464
  output: s.output ? useModel(s.output, `${at} output`) : null,
498
465
  secrets: secretNames,
499
- handlerHash: handlerHash || 'sha256:' + '0'.repeat(64),
500
466
  ...(s.timeoutSeconds !== undefined ? { timeoutSeconds: s.timeoutSeconds } : {}),
501
467
  ...(s.retries !== undefined ? { retries: s.retries } : {}),
502
468
  ...(settings !== undefined
@@ -737,17 +703,6 @@ export function describePipeline(pipeline: unknown, opts: DescribeOptions): Mani
737
703
  })
738
704
  .filter((x) => x !== undefined);
739
705
 
740
- // ── handlers without a step ──
741
- for (const h of handlerNames) {
742
- const s = steps.get(h);
743
- if (!s)
744
- problems.push(`a handler implements step '${h}', which is not in the pipeline`);
745
- else if (isImplementation(s))
746
- problems.push(
747
- `a handler implements '${h}', a ${s.kind} step that carries its own implementation`,
748
- );
749
- }
750
-
751
706
  // ── triggers ──
752
707
  const triggerNames = new Set<string>();
753
708
  const triggers: TriggerT[] = data.triggers.map((t) => {
@@ -818,6 +773,7 @@ export function describePipeline(pipeline: unknown, opts: DescribeOptions): Mani
818
773
  input: inputModel.name,
819
774
  ...(Object.keys(retention).length ? { retention } : {}),
820
775
  harness: opts.harness,
776
+ codeHash: opts.codeHash ?? 'sha256:' + '0'.repeat(64),
821
777
  models: Object.fromEntries([...models].map(([k, v]) => [k, v.schemas])),
822
778
  secrets: [...secrets]
823
779
  .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
@@ -948,7 +904,3 @@ interface CaseOf {
948
904
  value: string;
949
905
  cond: string;
950
906
  }
951
-
952
- function camel(name: string): string {
953
- return name.replace(/-([a-z0-9])/g, (_, c: string) => c.toUpperCase());
954
- }
@@ -94,9 +94,7 @@ export const StepDescriptor = z
94
94
  kind: z
95
95
  .string()
96
96
  .min(1)
97
- .describe(
98
- "'custom' = the handler is a handlers/*.ts file; any other kind is a step that carries its implementation (an SDK or package step). A label: the service accepts any kind.",
99
- ),
97
+ .describe("A label for the step's kind: 'custom' unless the step names one."),
100
98
  image: z.string().min(1).describe('Container image the step runs in.'),
101
99
  inputTypes: z
102
100
  .record(z.string(), InputType)
@@ -114,16 +112,13 @@ export const StepDescriptor = z
114
112
  .describe(
115
113
  'Declared secrets (sorted). Mount exactly these, as env vars of the same name.',
116
114
  ),
117
- handlerHash: hash.describe(
118
- 'Implementation identity: handler bundle (contract files excluded) + harness (bun + sdkHash); for a step that carries its implementation kind + config + harness + the hash of the code of its kind.',
119
- ),
120
115
  timeoutSeconds: z.number().int().positive().optional(),
121
116
  retries: z.number().int().nonnegative().optional(),
122
117
  config: z
123
118
  .record(z.string(), Json)
124
119
  .optional()
125
120
  .describe(
126
- 'The step\'s settings (display only; already covered by handlerHash). A config reference is recorded as {"$config": "<entry>.<field>…"}; the entry is in configEntries.',
121
+ 'The step\'s settings (display only). A config reference is recorded as {"$config": "<entry>.<field>…"}; the entry is in configEntries.',
127
122
  ),
128
123
  configEntries: z
129
124
  .array(configName)
@@ -276,7 +271,10 @@ export const Manifest = z
276
271
  sdk: z.string().describe('@olivervea/pipelines package version.'),
277
272
  sdkHash: hash.describe('Hash of the SDK runtime sources + zod version.'),
278
273
  })
279
- .describe('Harness identity. Already folded into every handlerHash.'),
274
+ .describe('Harness identity. Already folded into codeHash.'),
275
+ codeHash: hash.describe(
276
+ 'Hash of the whole build: the pipeline and its code bundled, plus the harness. Any code change makes a new pipeline version.',
277
+ ),
280
278
  models: z
281
279
  .record(modelName, ModelSchemas)
282
280
  .describe('Every model referenced by the pipeline (input, step inputs/outputs).'),
package/src/dev/cli.ts CHANGED
@@ -15,7 +15,7 @@ import { join, resolve } from 'node:path';
15
15
  import { parseArgs } from 'node:util';
16
16
  import { runHarness, type HarnessConfig } from '../harness/main.ts';
17
17
  import { EXIT } from '../harness/exit.ts';
18
- import { handlerHashes, kindHashes } from '../build/hashes.ts';
18
+ import { codeHash } from '../build/hashes.ts';
19
19
  import {
20
20
  harnessInfo,
21
21
  loadPipeline,
@@ -34,19 +34,9 @@ export async function devConfig(
34
34
  const project = await loadProject(dir);
35
35
  const pipeline = await loadPipeline(project);
36
36
  const harness = harnessInfo();
37
- const hashes = withHashes ? await handlerHashes(project, harness) : {};
38
37
  return {
39
38
  pipeline,
40
- handlers: Object.fromEntries(
41
- project.handlers.map(([name, file]) => [
42
- name,
43
- () => import(file) as Promise<{ default: unknown }>,
44
- ]),
45
- ),
46
- handlerHashes: Object.fromEntries(
47
- Object.entries(hashes).map(([k, v]) => [k, v.handlerHash]),
48
- ),
49
- ...(withHashes ? { kindHashes: await kindHashes(project, pipeline) } : {}),
39
+ ...(withHashes ? { codeHash: await codeHash(project, harness) } : {}),
50
40
  harness,
51
41
  };
52
42
  }
@@ -11,7 +11,7 @@ export const EXIT = {
11
11
  outputInvalid: 66,
12
12
  /** Uploading result.tar.gz to OLVE_RESULT_URL failed (after retries). The envelope was still printed. */
13
13
  uploadFailed: 74,
14
- /** The pipeline is invalid: describe problems, missing/invalid handler module. */
14
+ /** The pipeline is invalid: describe problems, a step without an implementation. */
15
15
  configInvalid: 78,
16
16
  /** Cancelled by SIGINT / SIGTERM (128 + signal). */
17
17
  sigint: 130,
@@ -5,7 +5,6 @@ export {
5
5
  allSteps,
6
6
  resolveStepFunction,
7
7
  type HarnessConfig,
8
- type HandlerRegistry,
9
8
  type HarnessIO,
10
9
  } from './main.ts';
11
10
  export { EXIT } from './exit.ts';
@@ -19,4 +18,4 @@ export {
19
18
  export { RESULT_BEGIN, RESULT_END, MANIFEST_BEGIN, MANIFEST_END } from './result.ts';
20
19
  export { describePipeline, DescribeError, manifestText } from '../describe/describe.ts';
21
20
  export type { ManifestT as Manifest } from '../describe/manifest-schema.ts';
22
- export type { HarnessInfo } from '../util/handler-hash.ts';
21
+ export type { HarnessInfo } from '../util/code-hash.ts';
@@ -11,7 +11,7 @@ import type { WebhookEvent } from '../core/webhooks.ts';
11
11
  import { pipelineData, type PipelineData, type StageNode } from '../builder/pipeline.ts';
12
12
  import { createCtx, formatLog } from '../ctx/index.ts';
13
13
  import { DescribeError, describePipeline, manifestText } from '../describe/describe.ts';
14
- import type { HarnessInfo } from '../util/handler-hash.ts';
14
+ import type { HarnessInfo } from '../util/code-hash.ts';
15
15
  import { callbackUrlsFromEnv, ENV, paths, runInfoFromEnv } from './env.ts';
16
16
  import { EXIT } from './exit.ts';
17
17
  import {
@@ -31,18 +31,11 @@ import {
31
31
  } from './result.ts';
32
32
  import { formatIssues, parseStrict } from './validate.ts';
33
33
 
34
- /** step name → lazy import of `handlers/<name>.ts`. Literal import() paths so Bun bundles them. */
35
- export type HandlerRegistry = Readonly<
36
- Record<string, () => Promise<{ default: unknown }>>
37
- >;
38
-
39
34
  export interface HarnessConfig {
40
- /** Default export of pipeline.ts. */
35
+ /** Default export of the entry point. */
41
36
  pipeline: unknown;
42
- handlers: HandlerRegistry;
43
- handlerHashes: Readonly<Record<string, string>>;
44
- /** Kind → hash of the code of the steps of that kind that carry their implementation. */
45
- kindHashes?: Readonly<Record<string, string>>;
37
+ /** Hash of the build (from the build; includes the harness identity). */
38
+ codeHash?: string;
46
39
  harness: HarnessInfo;
47
40
  }
48
41
 
@@ -130,26 +123,10 @@ export function cancellation(): { signal: AbortSignal; dispose(): void } {
130
123
  };
131
124
  }
132
125
 
133
- /** Resolves the function that implements `step` (carried by the step, or a handler module). */
134
- export async function resolveStepFunction(
135
- step: AnyStep,
136
- handlers: HandlerRegistry,
137
- ): Promise<StepFunction | string> {
126
+ /** The function `step` carries, or what is wrong. */
127
+ export function resolveStepFunction(step: AnyStep): StepFunction | string {
138
128
  if (isImplementation(step)) return step.handler as unknown as StepFunction;
139
- const load = handlers[step.name];
140
- if (!load) return `step '${step.name}' has no handler`;
141
- let mod: { default: unknown };
142
- try {
143
- mod = await load();
144
- } catch (e) {
145
- return `the handler of step '${step.name}' failed to load: ${(e as Error)?.stack ?? String(e)}`;
146
- }
147
- const impl = mod.default;
148
- if (!isImplementation(impl))
149
- return `the handler of step '${step.name}' must default-export implement(<step '${step.name}'>, …)`;
150
- if (impl.step !== step)
151
- return `the handler of step '${step.name}' implements a different step object (it must be implement(<the '${step.name}' step from pipeline.ts>, …))`;
152
- return impl.handler as unknown as StepFunction;
129
+ return `step '${step.name}' has no implementation (stage implement(step({...}), fn))`;
153
130
  }
154
131
 
155
132
  /** Writes output.json, prints the sentinel block and uploads result.tar.gz when OLVE_RESULT_URL is set. */
@@ -190,9 +167,7 @@ async function describeMode(
190
167
  try {
191
168
  const text = manifestText(
192
169
  describePipeline(cfg.pipeline, {
193
- handlerNames: Object.keys(cfg.handlers),
194
- handlerHashes: cfg.handlerHashes,
195
- kindHashes: cfg.kindHashes,
170
+ codeHash: cfg.codeHash,
196
171
  harness: cfg.harness,
197
172
  }),
198
173
  );
@@ -216,7 +191,7 @@ async function runMode(
216
191
  const data = pipelineData(cfg.pipeline);
217
192
  const step = findStep(data, name, selectedCases());
218
193
  if (typeof step === 'string') return (io.stderr(step), EXIT.usage);
219
- const fn = await resolveStepFunction(step, cfg.handlers);
194
+ const fn = resolveStepFunction(step);
220
195
  if (typeof fn === 'string') return (io.stderr(fn), EXIT.configInvalid);
221
196
 
222
197
  const p = paths();
@@ -46,7 +46,7 @@ export type TestResult<S extends AnyStep> = {
46
46
  );
47
47
 
48
48
  /**
49
- * Runs an implementation (a handler module's default export, or a step that carries its own) with `input`.
49
+ * Runs a step that carries its implementation with `input`.
50
50
  * Input is validated like `input.json`, the result like a real run: an invalid output gives
51
51
  * `{ status: "failed", error: { kind: "output" } }`, a throwing handler `kind: "handler"`.
52
52
  */
@@ -57,7 +57,7 @@ export async function testHandler<S extends AnyStep>(
57
57
  ): Promise<TestResult<S>> {
58
58
  if (!isImplementation(target))
59
59
  throw new TypeError(
60
- "testHandler expects implement(…): a handler module's default export or a step that carries its implementation",
60
+ 'testHandler expects implement(…): a step that carries its implementation',
61
61
  );
62
62
  const step: AnyStep = target.step;
63
63
  const fn = target.handler as unknown as StepFunction;
@@ -0,0 +1,17 @@
1
+ import { canonicalJson } from './canonical.ts';
2
+ import { sha256Hex } from './hash.ts';
3
+
4
+ /** Identity of the harness compiled into a binary. */
5
+ export interface HarnessInfo {
6
+ /** Bun version (runtime compiled into the binary). */
7
+ bun: string;
8
+ /** @olivervea/pipelines package version. */
9
+ sdk: string;
10
+ /** `sha256:` hash over the SDK runtime sources and the zod version. */
11
+ sdkHash: string;
12
+ }
13
+
14
+ /** The hash of a whole build: its code bundled with the SDK external, combined with the harness. */
15
+ export function composeCodeHash(bundleHash: string, harness: HarnessInfo): string {
16
+ return 'sha256:' + sha256Hex(canonicalJson({ bundle: bundleHash, harness }));
17
+ }
@@ -1,23 +0,0 @@
1
- import { ORIGIN } from './brands.ts';
2
-
3
- /**
4
- * The file that called the function calling this one, from the stack. A tail call drops its caller's
5
- * frame, so this can name a module further up; the build only uses it to choose what to hash.
6
- */
7
- export function callerFile(): string | undefined {
8
- const files = (new Error().stack ?? '')
9
- .split('\n')
10
- .slice(1)
11
- .map((f) => f.match(FRAME)?.[1]);
12
- const own = files[0];
13
- const callee = files[1];
14
- const file = files.slice(2).find((f) => f && f !== own && f !== callee) ?? undefined;
15
- return file?.startsWith('file://') ? new URL(file).pathname : file;
16
- }
17
- const FRAME = /\(?((?:file:\/\/)?\/[^()]*?):\d+(?::\d+)?\)?\s*$/;
18
-
19
- /** The modules that defined and implemented a step (where `step` and `implement` were called). */
20
- export function originsOf(v: unknown): string[] {
21
- const o = (v as Record<symbol, unknown>)[ORIGIN];
22
- return Array.isArray(o) ? o.filter((x): x is string => typeof x === 'string') : [];
23
- }
@@ -1,32 +0,0 @@
1
- import { canonicalJson } from './canonical.ts';
2
- import { sha256Hex } from './hash.ts';
3
-
4
- /** Identity of the harness compiled into a binary. A Bun or SDK bump legitimately bumps every handler. */
5
- export interface HarnessInfo {
6
- /** Bun version (runtime compiled into the binary). */
7
- bun: string;
8
- /** @olivervea/pipelines package version. */
9
- sdk: string;
10
- /** `sha256:` hash over the SDK runtime sources and the zod version. */
11
- sdkHash: string;
12
- }
13
-
14
- /** Custom step: hash of the handler bundle (contract files + SDK external) combined with the harness. */
15
- export function composeHandlerHash(bundleHash: string, harness: HarnessInfo): string {
16
- return 'sha256:' + sha256Hex(canonicalJson({ bundle: bundleHash, harness }));
17
- }
18
-
19
- /** A step that carries its implementation: kind + canonical settings + harness, and the hash of the code that implements the kind. */
20
- export function libraryHandlerHash(
21
- kind: string,
22
- config: unknown,
23
- harness: HarnessInfo,
24
- code?: string,
25
- ): string {
26
- return (
27
- 'sha256:' +
28
- sha256Hex(
29
- canonicalJson({ library: kind, config, harness, ...(code ? { code } : {}) }),
30
- )
31
- );
32
- }