@olivervea/pipelines 2.0.0-beta.17 → 2.0.0-beta.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +2 -0
  2. package/manifest.schema.json +22 -0
  3. package/package.json +4 -2
  4. package/src/assets.d.ts +2 -2
  5. package/src/build/build.ts +111 -43
  6. package/src/build/cli.ts +28 -21
  7. package/src/build/elf.ts +8 -7
  8. package/src/build/hashes.ts +61 -28
  9. package/src/build/project.ts +69 -30
  10. package/src/builder/pipeline.ts +288 -102
  11. package/src/builder/refs.ts +12 -6
  12. package/src/core/brands.ts +6 -6
  13. package/src/core/config-refs.ts +46 -20
  14. package/src/core/config.ts +110 -33
  15. package/src/core/external.ts +30 -12
  16. package/src/core/handlers.ts +54 -14
  17. package/src/core/models.ts +29 -14
  18. package/src/core/origin.ts +7 -4
  19. package/src/core/secrets.ts +2 -2
  20. package/src/core/steps.ts +39 -23
  21. package/src/core/types.ts +5 -1
  22. package/src/core/webhooks.ts +21 -7
  23. package/src/ctx/files.ts +111 -40
  24. package/src/ctx/index.ts +75 -34
  25. package/src/ctx/sh.ts +104 -44
  26. package/src/ctx/types.ts +4 -4
  27. package/src/describe/describe.ts +558 -194
  28. package/src/describe/manifest-schema.ts +217 -79
  29. package/src/describe/mapper-source.ts +249 -104
  30. package/src/describe/record.ts +67 -37
  31. package/src/dev/cli.ts +64 -34
  32. package/src/harness/env.ts +33 -27
  33. package/src/harness/execute.ts +55 -29
  34. package/src/harness/index.ts +21 -8
  35. package/src/harness/main.ts +197 -74
  36. package/src/harness/result.ts +38 -22
  37. package/src/harness/validate.ts +62 -19
  38. package/src/index.ts +123 -14
  39. package/src/library/checks.ts +11 -6
  40. package/src/library/compose-deploy.ts +40 -23
  41. package/src/library/github-source.ts +69 -39
  42. package/src/library/helm-deploy-ssh.ts +213 -76
  43. package/src/library/helm-deploy.ts +263 -85
  44. package/src/library/http-health.ts +7 -5
  45. package/src/library/image-build.ts +293 -117
  46. package/src/library/index.ts +47 -15
  47. package/src/library/models.ts +17 -9
  48. package/src/library/ssh.ts +31 -14
  49. package/src/testing/index.ts +63 -24
  50. package/src/util/canonical.ts +7 -4
  51. package/src/util/handler-hash.ts +15 -5
  52. package/src/util/hash.ts +5 -5
  53. package/src/util/tar.ts +74 -37
@@ -2,24 +2,39 @@
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
4
  // map-trigger <trigger> → $OLVE_DIR/input.json (event) → pipeline input → $OLVE_DIR/work/output.json
5
- import { mkdirSync, readFileSync } from "node:fs";
6
- import { join } from "node:path";
7
- import { isImplementation } from "../core/handlers.ts";
8
- import { configRefPath, isConfigRef, unionAt } from "../core/config.ts";
9
- import type { AnyStep } from "../core/steps.ts";
10
- import type { WebhookEvent } from "../core/webhooks.ts";
11
- import { pipelineData, type PipelineData, type StageNode } from "../builder/pipeline.ts";
12
- import { createCtx, formatLog } from "../ctx/index.ts";
13
- import { DescribeError, describePipeline, manifestText } from "../describe/describe.ts";
14
- import type { HarnessInfo } from "../util/handler-hash.ts";
15
- import { callbackUrlsFromEnv, ENV, paths, runInfoFromEnv } from "./env.ts";
16
- import { EXIT } from "./exit.ts";
17
- import { executeStep, type Envelope, type Execution, type StepFunction } from "./execute.ts";
18
- import { MANIFEST_BEGIN, MANIFEST_END, packResult, removeStale, sentinelBlock, uploadFile, writeEnvelope } from "./result.ts";
19
- import { formatIssues, parseStrict } from "./validate.ts";
5
+ import { mkdirSync, readFileSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+ import { isImplementation } from '../core/handlers.ts';
8
+ import { configRefPath, isConfigRef, unionAt } from '../core/config.ts';
9
+ import type { AnyStep } from '../core/steps.ts';
10
+ import type { WebhookEvent } from '../core/webhooks.ts';
11
+ import { pipelineData, type PipelineData, type StageNode } from '../builder/pipeline.ts';
12
+ import { createCtx, formatLog } from '../ctx/index.ts';
13
+ import { DescribeError, describePipeline, manifestText } from '../describe/describe.ts';
14
+ import type { HarnessInfo } from '../util/handler-hash.ts';
15
+ import { callbackUrlsFromEnv, ENV, paths, runInfoFromEnv } from './env.ts';
16
+ import { EXIT } from './exit.ts';
17
+ import {
18
+ executeStep,
19
+ type Envelope,
20
+ type Execution,
21
+ type StepFunction,
22
+ } from './execute.ts';
23
+ import {
24
+ MANIFEST_BEGIN,
25
+ MANIFEST_END,
26
+ packResult,
27
+ removeStale,
28
+ sentinelBlock,
29
+ uploadFile,
30
+ writeEnvelope,
31
+ } from './result.ts';
32
+ import { formatIssues, parseStrict } from './validate.ts';
20
33
 
21
34
  /** step name → lazy import of `handlers/<name>.ts`. Literal import() paths so Bun bundles them. */
22
- export type HandlerRegistry = Readonly<Record<string, () => Promise<{ default: unknown }>>>;
35
+ export type HandlerRegistry = Readonly<
36
+ Record<string, () => Promise<{ default: unknown }>>
37
+ >;
23
38
 
24
39
  export interface HarnessConfig {
25
40
  /** Default export of pipeline.ts. */
@@ -41,11 +56,12 @@ export const defaultIO: HarnessIO = {
41
56
  await Bun.write(Bun.stdout, text);
42
57
  },
43
58
  stderr: (line) => {
44
- process.stderr.write(line.endsWith("\n") ? line : line + "\n");
59
+ process.stderr.write(line.endsWith('\n') ? line : line + '\n');
45
60
  },
46
61
  };
47
62
 
48
- const USAGE = "usage: step describe [--sentinels] | step run <step> | step map-trigger <trigger>";
63
+ const USAGE =
64
+ 'usage: step describe [--sentinels] | step run <step> | step map-trigger <trigger>';
49
65
 
50
66
  export function allSteps(data: PipelineData): AnyStep[] {
51
67
  const out: AnyStep[] = [];
@@ -58,12 +74,17 @@ export function stepsWithCases(data: PipelineData): { step: AnyStep; cases: stri
58
74
  const out: { step: AnyStep; cases: string[] }[] = [];
59
75
  const walk = (nodes: readonly StageNode[], cases: string[]) => {
60
76
  for (const n of nodes) {
61
- if (n.kind === "stage") out.push(...n.steps.map((s) => ({ step: s.step, cases })));
62
- else if (n.kind === "when") walk(n.nodes, cases);
77
+ if (n.kind === 'stage') out.push(...n.steps.map((s) => ({ step: s.step, cases })));
78
+ else if (n.kind === 'when') walk(n.nodes, cases);
63
79
  else {
64
80
  const union = isConfigRef(n.on) ? unionAt(n.on) : undefined;
65
81
  for (const [value, inner] of Object.entries(n.cases))
66
- walk(inner, union ? [...cases, `${configRefPath(n.on)}.${union.discriminator}=${value}`] : cases);
82
+ walk(
83
+ inner,
84
+ union
85
+ ? [...cases, `${configRefPath(n.on)}.${union.discriminator}=${value}`]
86
+ : cases,
87
+ );
67
88
  }
68
89
  }
69
90
  };
@@ -73,9 +94,15 @@ export function stepsWithCases(data: PipelineData): { step: AnyStep; cases: stri
73
94
  }
74
95
 
75
96
  /** The step `run <name>` executes: the only one so named, or the one in the cases the service selected (`OLVE_CASES`). */
76
- export function findStep(data: PipelineData, name: string, selected: readonly string[]): AnyStep | string {
97
+ export function findStep(
98
+ data: PipelineData,
99
+ name: string,
100
+ selected: readonly string[],
101
+ ): AnyStep | string {
77
102
  const candidates = stepsWithCases(data).filter((c) => c.step.name === name);
78
- const distinct = candidates.filter((c, i) => candidates.findIndex((d) => d.step === c.step) === i);
103
+ const distinct = candidates.filter(
104
+ (c, i) => candidates.findIndex((d) => d.step === c.step) === i,
105
+ );
79
106
  if (distinct.length === 0) return `unknown step '${name}'`;
80
107
  if (distinct.length === 1) return distinct[0]!.step;
81
108
  const chosen = distinct.filter((c) => c.cases.every((k) => selected.includes(k)));
@@ -84,26 +111,30 @@ export function findStep(data: PipelineData, name: string, selected: readonly st
84
111
  }
85
112
 
86
113
  /** The select case conditions of the step being run (JSON array), set by the service for steps inside a select. */
87
- export const ENV_CASES = "OLVE_CASES";
114
+ export const ENV_CASES = 'OLVE_CASES';
88
115
 
89
116
  /** Installs SIGTERM/SIGINT handlers that abort the returned signal (PID 1 ignores signals without them). */
90
117
  export function cancellation(): { signal: AbortSignal; dispose(): void } {
91
118
  const ac = new AbortController();
92
- const onTerm = () => ac.abort("SIGTERM");
93
- const onInt = () => (ac.signal.aborted ? process.exit(EXIT.sigint) : ac.abort("SIGINT"));
94
- process.on("SIGTERM", onTerm);
95
- process.on("SIGINT", onInt);
119
+ const onTerm = () => ac.abort('SIGTERM');
120
+ const onInt = () =>
121
+ ac.signal.aborted ? process.exit(EXIT.sigint) : ac.abort('SIGINT');
122
+ process.on('SIGTERM', onTerm);
123
+ process.on('SIGINT', onInt);
96
124
  return {
97
125
  signal: ac.signal,
98
126
  dispose() {
99
- process.off("SIGTERM", onTerm);
100
- process.off("SIGINT", onInt);
127
+ process.off('SIGTERM', onTerm);
128
+ process.off('SIGINT', onInt);
101
129
  },
102
130
  };
103
131
  }
104
132
 
105
133
  /** Resolves the function that implements `step` (carried by the step, or a handler module). */
106
- export async function resolveStepFunction(step: AnyStep, handlers: HandlerRegistry): Promise<StepFunction | string> {
134
+ export async function resolveStepFunction(
135
+ step: AnyStep,
136
+ handlers: HandlerRegistry,
137
+ ): Promise<StepFunction | string> {
107
138
  if (isImplementation(step)) return step.handler as unknown as StepFunction;
108
139
  const load = handlers[step.name];
109
140
  if (!load) return `step '${step.name}' has no handler`;
@@ -114,7 +145,8 @@ export async function resolveStepFunction(step: AnyStep, handlers: HandlerRegist
114
145
  return `the handler of step '${step.name}' failed to load: ${(e as Error)?.stack ?? String(e)}`;
115
146
  }
116
147
  const impl = mod.default;
117
- if (!isImplementation(impl)) return `the handler of step '${step.name}' must default-export implement(<step '${step.name}'>, …)`;
148
+ if (!isImplementation(impl))
149
+ return `the handler of step '${step.name}' must default-export implement(<step '${step.name}'>, …)`;
118
150
  if (impl.step !== step)
119
151
  return `the handler of step '${step.name}' implements a different step object (it must be implement(<the '${step.name}' step from pipeline.ts>, …))`;
120
152
  return impl.handler as unknown as StepFunction;
@@ -129,7 +161,7 @@ export async function finish(exec: Execution, io: HarnessIO): Promise<number> {
129
161
  if (!url) return exec.exitCode;
130
162
  try {
131
163
  mkdirSync(p.tmp, { recursive: true });
132
- const archive = join(p.tmp, "result.tar.gz");
164
+ const archive = join(p.tmp, 'result.tar.gz');
133
165
  await packResult(p.work, archive);
134
166
  await uploadFile(url, archive, io.stderr);
135
167
  } catch (e) {
@@ -139,18 +171,31 @@ export async function finish(exec: Execution, io: HarnessIO): Promise<number> {
139
171
  return exec.exitCode;
140
172
  }
141
173
 
142
- function readJson(path: string): { ok: true; value: unknown } | { ok: false; message: string } {
174
+ function readJson(
175
+ path: string,
176
+ ): { ok: true; value: unknown } | { ok: false; message: string } {
143
177
  try {
144
- return { ok: true, value: JSON.parse(readFileSync(path, "utf8")) };
178
+ return { ok: true, value: JSON.parse(readFileSync(path, 'utf8')) };
145
179
  } catch (e) {
146
180
  return { ok: false, message: `cannot read ${path}: ${(e as Error).message}` };
147
181
  }
148
182
  }
149
183
 
150
- async function describeMode(cfg: HarnessConfig, args: string[], io: HarnessIO): Promise<number> {
151
- const sentinels = args.includes("--sentinels");
184
+ async function describeMode(
185
+ cfg: HarnessConfig,
186
+ args: string[],
187
+ io: HarnessIO,
188
+ ): Promise<number> {
189
+ const sentinels = args.includes('--sentinels');
152
190
  try {
153
- const text = manifestText(describePipeline(cfg.pipeline, { handlerNames: Object.keys(cfg.handlers), handlerHashes: cfg.handlerHashes, kindHashes: cfg.kindHashes, harness: cfg.harness }));
191
+ const text = manifestText(
192
+ describePipeline(cfg.pipeline, {
193
+ handlerNames: Object.keys(cfg.handlers),
194
+ handlerHashes: cfg.handlerHashes,
195
+ kindHashes: cfg.kindHashes,
196
+ harness: cfg.harness,
197
+ }),
198
+ );
154
199
  await io.stdout(sentinels ? `${MANIFEST_BEGIN}\n${text}${MANIFEST_END}\n` : text);
155
200
  return EXIT.ok;
156
201
  } catch (e) {
@@ -162,13 +207,17 @@ async function describeMode(cfg: HarnessConfig, args: string[], io: HarnessIO):
162
207
  }
163
208
  }
164
209
 
165
- async function runMode(cfg: HarnessConfig, name: string | undefined, io: HarnessIO): Promise<number> {
166
- if (!name) return io.stderr(USAGE), EXIT.usage;
210
+ async function runMode(
211
+ cfg: HarnessConfig,
212
+ name: string | undefined,
213
+ io: HarnessIO,
214
+ ): Promise<number> {
215
+ if (!name) return (io.stderr(USAGE), EXIT.usage);
167
216
  const data = pipelineData(cfg.pipeline);
168
217
  const step = findStep(data, name, selectedCases());
169
- if (typeof step === "string") return io.stderr(step), EXIT.usage;
218
+ if (typeof step === 'string') return (io.stderr(step), EXIT.usage);
170
219
  const fn = await resolveStepFunction(step, cfg.handlers);
171
- if (typeof fn === "string") return io.stderr(fn), EXIT.configInvalid;
220
+ if (typeof fn === 'string') return (io.stderr(fn), EXIT.configInvalid);
172
221
 
173
222
  const p = paths();
174
223
  removeStale(p.work);
@@ -180,11 +229,28 @@ async function runMode(cfg: HarnessConfig, name: string | undefined, io: Harness
180
229
  let exec: Execution;
181
230
  if (!raw.ok) {
182
231
  io.stderr(raw.message);
183
- exec = { envelope: { status: "failed", error: { kind: "input", message: raw.message } }, exitCode: EXIT.inputInvalid };
232
+ exec = {
233
+ envelope: { status: 'failed', error: { kind: 'input', message: raw.message } },
234
+ exitCode: EXIT.inputInvalid,
235
+ };
184
236
  } else {
185
237
  const run = runInfoFromEnv(step.name);
186
- const ctx = createCtx({ run, secrets: step.secrets, paths: { root: p.root, workFiles: p.files, tmp: p.tmp }, signal: cancel.signal, logSink: io.stderr, callbackUrls: callbackUrlsFromEnv() });
187
- exec = await executeStep({ step, fn, rawInput: raw.value, ctx, workFiles: p.files, log: io.stderr });
238
+ const ctx = createCtx({
239
+ run,
240
+ secrets: step.secrets,
241
+ paths: { root: p.root, workFiles: p.files, tmp: p.tmp },
242
+ signal: cancel.signal,
243
+ logSink: io.stderr,
244
+ callbackUrls: callbackUrlsFromEnv(),
245
+ });
246
+ exec = await executeStep({
247
+ step,
248
+ fn,
249
+ rawInput: raw.value,
250
+ ctx,
251
+ workFiles: p.files,
252
+ log: io.stderr,
253
+ });
188
254
  }
189
255
  return await finish(exec, io);
190
256
  } finally {
@@ -193,64 +259,121 @@ async function runMode(cfg: HarnessConfig, name: string | undefined, io: Harness
193
259
  }
194
260
 
195
261
  function isWebhookEvent(v: unknown): boolean {
196
- if (typeof v !== "object" || v === null || Array.isArray(v) || !("body" in v)) return false;
262
+ if (typeof v !== 'object' || v === null || Array.isArray(v) || !('body' in v))
263
+ return false;
197
264
  const headers = (v as { headers?: unknown }).headers;
198
- return typeof headers === "object" && headers !== null && !Array.isArray(headers);
265
+ return typeof headers === 'object' && headers !== null && !Array.isArray(headers);
199
266
  }
200
267
 
201
- async function mapTriggerMode(cfg: HarnessConfig, name: string | undefined, io: HarnessIO): Promise<number> {
202
- if (!name) return io.stderr(USAGE), EXIT.usage;
268
+ async function mapTriggerMode(
269
+ cfg: HarnessConfig,
270
+ name: string | undefined,
271
+ io: HarnessIO,
272
+ ): Promise<number> {
273
+ if (!name) return (io.stderr(USAGE), EXIT.usage);
203
274
  const data = pipelineData(cfg.pipeline);
204
275
  const trigger = data.triggers.find((t) => t.name === name);
205
- if (!trigger) return io.stderr(`unknown trigger '${name}' (known: ${data.triggers.map((t) => t.name).join(", ") || "none"})`), EXIT.usage;
276
+ if (!trigger)
277
+ return (
278
+ io.stderr(
279
+ `unknown trigger '${name}' (known: ${data.triggers.map((t) => t.name).join(', ') || 'none'})`,
280
+ ),
281
+ EXIT.usage
282
+ );
206
283
  const p = paths();
207
284
  removeStale(p.work);
208
285
  mkdirSync(p.work, { recursive: true });
209
286
  const raw = readJson(p.input);
210
- const fail = (kind: "input" | "output" | "handler", message: string, exitCode: number): Execution => {
287
+ const fail = (
288
+ kind: 'input' | 'output' | 'handler',
289
+ message: string,
290
+ exitCode: number,
291
+ ): Execution => {
211
292
  io.stderr(message);
212
- return { envelope: { status: "failed", error: { kind, message } }, exitCode };
293
+ return { envelope: { status: 'failed', error: { kind, message } }, exitCode };
213
294
  };
214
295
  let exec: Execution;
215
- if (!raw.ok) exec = fail("input", raw.message, EXIT.inputInvalid);
216
- else if (trigger.kind === "poll" && (typeof raw.value !== "object" || raw.value === null || typeof (raw.value as { value?: unknown }).value !== "string"))
217
- exec = fail("input", `poll trigger '${name}': the event must be {"value": "<string>"}`, EXIT.inputInvalid);
218
- else if (trigger.kind === "push" && (typeof raw.value !== "object" || raw.value === null || Array.isArray(raw.value)))
219
- exec = fail("input", `push trigger: the event must be the webhook payload object`, EXIT.inputInvalid);
220
- else if (trigger.kind === "webhook" && !isWebhookEvent(raw.value))
221
- exec = fail("input", `webhook trigger '${name}': the event must be {"headers": {...}, "body": ...}`, EXIT.inputInvalid);
296
+ if (!raw.ok) exec = fail('input', raw.message, EXIT.inputInvalid);
297
+ else if (
298
+ trigger.kind === 'poll' &&
299
+ (typeof raw.value !== 'object' ||
300
+ raw.value === null ||
301
+ typeof (raw.value as { value?: unknown }).value !== 'string')
302
+ )
303
+ exec = fail(
304
+ 'input',
305
+ `poll trigger '${name}': the event must be {"value": "<string>"}`,
306
+ EXIT.inputInvalid,
307
+ );
308
+ else if (
309
+ trigger.kind === 'push' &&
310
+ (typeof raw.value !== 'object' || raw.value === null || Array.isArray(raw.value))
311
+ )
312
+ exec = fail(
313
+ 'input',
314
+ `push trigger: the event must be the webhook payload object`,
315
+ EXIT.inputInvalid,
316
+ );
317
+ else if (trigger.kind === 'webhook' && !isWebhookEvent(raw.value))
318
+ exec = fail(
319
+ 'input',
320
+ `webhook trigger '${name}': the event must be {"headers": {...}, "body": ...}`,
321
+ EXIT.inputInvalid,
322
+ );
222
323
  else {
223
324
  let mapped: unknown;
224
325
  let threw: unknown = undefined;
225
326
  try {
226
327
  mapped =
227
- trigger.kind === "poll" ? await trigger.map((raw.value as { value: string }).value)
228
- : trigger.kind === "webhook" ? await trigger.map(raw.value as WebhookEvent)
229
- : await trigger.map(raw.value);
328
+ trigger.kind === 'poll'
329
+ ? await trigger.map((raw.value as { value: string }).value)
330
+ : trigger.kind === 'webhook'
331
+ ? await trigger.map(raw.value as WebhookEvent)
332
+ : await trigger.map(raw.value);
230
333
  } catch (e) {
231
- threw = e ?? new Error("undefined");
334
+ threw = e ?? new Error('undefined');
232
335
  }
233
- if (threw !== undefined) exec = fail("handler", `trigger '${name}' mapper threw: ${(threw as Error)?.message ?? String(threw)}`, EXIT.handlerFailed);
234
- else if (mapped === null && trigger.kind === "webhook") exec = { envelope: { status: "skipped", reason: `trigger '${name}' ignored the delivery` }, exitCode: EXIT.ok };
336
+ if (threw !== undefined)
337
+ exec = fail(
338
+ 'handler',
339
+ `trigger '${name}' mapper threw: ${(threw as Error)?.message ?? String(threw)}`,
340
+ EXIT.handlerFailed,
341
+ );
342
+ else if (mapped === null && trigger.kind === 'webhook')
343
+ exec = {
344
+ envelope: { status: 'skipped', reason: `trigger '${name}' ignored the delivery` },
345
+ exitCode: EXIT.ok,
346
+ };
235
347
  else {
236
- const r = parseStrict(data.input.schema, mapped, "input");
348
+ const r = parseStrict(data.input.schema, mapped, 'input');
237
349
  exec = r.ok
238
- ? { envelope: { status: "succeeded", output: r.value } satisfies Envelope, exitCode: EXIT.ok }
239
- : fail("output", `trigger '${name}': mapped input does not match ${data.input.name}:\n ${r.issues.join("\n ")}`, EXIT.outputInvalid);
350
+ ? {
351
+ envelope: { status: 'succeeded', output: r.value } satisfies Envelope,
352
+ exitCode: EXIT.ok,
353
+ }
354
+ : fail(
355
+ 'output',
356
+ `trigger '${name}': mapped input does not match ${data.input.name}:\n ${r.issues.join('\n ')}`,
357
+ EXIT.outputInvalid,
358
+ );
240
359
  }
241
360
  }
242
361
  return finish(exec, io);
243
362
  }
244
363
 
245
364
  /** Entry point of the compiled binary (and the dev runner). Returns the exit code. */
246
- export async function runHarness(cfg: HarnessConfig, argv: readonly string[], io: HarnessIO = defaultIO): Promise<number> {
365
+ export async function runHarness(
366
+ cfg: HarnessConfig,
367
+ argv: readonly string[],
368
+ io: HarnessIO = defaultIO,
369
+ ): Promise<number> {
247
370
  const [mode, ...rest] = argv;
248
371
  switch (mode) {
249
- case "describe":
372
+ case 'describe':
250
373
  return describeMode(cfg, rest, io);
251
- case "run":
374
+ case 'run':
252
375
  return runMode(cfg, rest[0], io);
253
- case "map-trigger":
376
+ case 'map-trigger':
254
377
  return mapTriggerMode(cfg, rest[0], io);
255
378
  default:
256
379
  io.stderr(USAGE);
@@ -262,8 +385,8 @@ export { formatIssues, formatLog };
262
385
 
263
386
  function selectedCases(): string[] {
264
387
  try {
265
- const v = JSON.parse(process.env[ENV_CASES] ?? "[]") as unknown;
266
- return Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : [];
388
+ const v = JSON.parse(process.env[ENV_CASES] ?? '[]') as unknown;
389
+ return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : [];
267
390
  } catch {
268
391
  return [];
269
392
  }
@@ -1,22 +1,29 @@
1
1
  // Writing, printing and uploading the result of `run` / `map-trigger`.
2
- import { existsSync, mkdirSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
- import { join } from "node:path";
4
- import { canonicalJson } from "../util/canonical.ts";
5
- import { dirEntries, writeTarGz, type TarEntry } from "../util/tar.ts";
6
- import { redactUrl } from "../ctx/files.ts";
7
- import type { Envelope } from "./execute.ts";
2
+ import {
3
+ existsSync,
4
+ mkdirSync,
5
+ readdirSync,
6
+ renameSync,
7
+ rmSync,
8
+ writeFileSync,
9
+ } from 'node:fs';
10
+ import { join } from 'node:path';
11
+ import { canonicalJson } from '../util/canonical.ts';
12
+ import { dirEntries, writeTarGz, type TarEntry } from '../util/tar.ts';
13
+ import { redactUrl } from '../ctx/files.ts';
14
+ import type { Envelope } from './execute.ts';
8
15
 
9
- export const RESULT_BEGIN = "---OLVE-RESULT-BEGIN---";
10
- export const RESULT_END = "---OLVE-RESULT-END---";
11
- export const MANIFEST_BEGIN = "---OLVE-MANIFEST-BEGIN---";
12
- export const MANIFEST_END = "---OLVE-MANIFEST-END---";
16
+ export const RESULT_BEGIN = '---OLVE-RESULT-BEGIN---';
17
+ export const RESULT_END = '---OLVE-RESULT-END---';
18
+ export const MANIFEST_BEGIN = '---OLVE-MANIFEST-BEGIN---';
19
+ export const MANIFEST_END = '---OLVE-MANIFEST-END---';
13
20
 
14
21
  /** Atomically writes the envelope to `<work>/output.json`. */
15
22
  export function writeEnvelope(work: string, envelope: Envelope): string {
16
23
  mkdirSync(work, { recursive: true });
17
- const path = join(work, "output.json");
18
- writeFileSync(path + ".partial", canonicalJson(envelope));
19
- renameSync(path + ".partial", path);
24
+ const path = join(work, 'output.json');
25
+ writeFileSync(path + '.partial', canonicalJson(envelope));
26
+ renameSync(path + '.partial', path);
20
27
  return path;
21
28
  }
22
29
 
@@ -27,9 +34,15 @@ export function sentinelBlock(envelope: Envelope): string {
27
34
 
28
35
  /** Packs output.json + files/ into result.tar.gz (deterministic). */
29
36
  export async function packResult(work: string, out: string): Promise<void> {
30
- const entries: TarEntry[] = [{ path: "output.json", type: "file", mode: 0o644, source: join(work, "output.json") }];
31
- const files = join(work, "files");
32
- if (existsSync(files)) entries.push({ path: "files", type: "dir", mode: 0o755 }, ...dirEntries(files, "files"));
37
+ const entries: TarEntry[] = [
38
+ { path: 'output.json', type: 'file', mode: 0o644, source: join(work, 'output.json') },
39
+ ];
40
+ const files = join(work, 'files');
41
+ if (existsSync(files))
42
+ entries.push(
43
+ { path: 'files', type: 'dir', mode: 0o755 },
44
+ ...dirEntries(files, 'files'),
45
+ );
33
46
  await writeTarGz(out, entries);
34
47
  }
35
48
 
@@ -48,7 +61,7 @@ export async function uploadFile(
48
61
  let status = 0;
49
62
  let detail: string;
50
63
  try {
51
- const res = await fetch(url, { method: "PUT", body: Bun.file(file) });
64
+ const res = await fetch(url, { method: 'PUT', body: Bun.file(file) });
52
65
  status = res.status;
53
66
  if (res.ok) return;
54
67
  detail = `HTTP ${res.status} ${res.statusText}`;
@@ -57,7 +70,8 @@ export async function uploadFile(
57
70
  detail = (e as Error).message;
58
71
  }
59
72
  const transient = status === 0 || TRANSIENT(status);
60
- if (!transient || i >= attempts) throw new Error(`upload to ${redactUrl(url)} failed: ${detail}`);
73
+ if (!transient || i >= attempts)
74
+ throw new Error(`upload to ${redactUrl(url)} failed: ${detail}`);
61
75
  log(`upload to ${redactUrl(url)} failed (${detail}); retry ${i}/${attempts - 1}`);
62
76
  await Bun.sleep(base * 2 ** (i - 1));
63
77
  }
@@ -68,8 +82,10 @@ export async function uploadFile(
68
82
  * *contents* of files/. The directories themselves are kept (olve-init creates them 1777).
69
83
  */
70
84
  export function removeStale(work: string): void {
71
- rmSync(join(work, "output.json"), { force: true });
72
- rmSync(join(work, "output.json.partial"), { force: true });
73
- const files = join(work, "files");
74
- if (existsSync(files)) for (const e of readdirSync(files)) rmSync(join(files, e), { recursive: true, force: true });
85
+ rmSync(join(work, 'output.json'), { force: true });
86
+ rmSync(join(work, 'output.json.partial'), { force: true });
87
+ const files = join(work, 'files');
88
+ if (existsSync(files))
89
+ for (const e of readdirSync(files))
90
+ rmSync(join(files, e), { recursive: true, force: true });
75
91
  }
@@ -1,23 +1,30 @@
1
- import { z } from "zod";
2
- import { isModel, isOptional } from "../core/models.ts";
3
- import { isExternal } from "../core/external.ts";
4
- import type { AnyStep } from "../core/steps.ts";
1
+ import { z } from 'zod';
2
+ import { isModel, isOptional } from '../core/models.ts';
3
+ import { isExternal } from '../core/external.ts';
4
+ import type { AnyStep } from '../core/steps.ts';
5
5
 
6
6
  export type Validation<T> = { ok: true; value: T } | { ok: false; issues: string[] };
7
7
 
8
8
  export function formatIssues(err: z.ZodError, prefix: string): string[] {
9
- return err.issues.map((i) => `${prefix}${i.path.length ? "." + i.path.map(String).join(".") : ""}: ${i.message}`);
9
+ return err.issues.map(
10
+ (i) =>
11
+ `${prefix}${i.path.length ? '.' + i.path.map(String).join('.') : ''}: ${i.message}`,
12
+ );
10
13
  }
11
14
 
12
15
  const isPlainObject = (v: unknown): v is Record<string, unknown> =>
13
- typeof v === "object" && v !== null && !Array.isArray(v) && (Object.getPrototypeOf(v) === Object.prototype || Object.getPrototypeOf(v) === null);
16
+ typeof v === 'object' &&
17
+ v !== null &&
18
+ !Array.isArray(v) &&
19
+ (Object.getPrototypeOf(v) === Object.prototype || Object.getPrototypeOf(v) === null);
14
20
 
15
21
  /**
16
22
  * zod strips unknown keys silently (at every depth). Compares the raw value with the parsed one:
17
23
  * any key present in a raw object but absent from the parsed object at the same place was stripped.
18
24
  */
19
- export function strippedKeys(raw: unknown, parsed: unknown, path = ""): string[] {
20
- if (Array.isArray(raw) && Array.isArray(parsed)) return raw.flatMap((x, i) => strippedKeys(x, parsed[i], `${path}[${i}]`));
25
+ export function strippedKeys(raw: unknown, parsed: unknown, path = ''): string[] {
26
+ if (Array.isArray(raw) && Array.isArray(parsed))
27
+ return raw.flatMap((x, i) => strippedKeys(x, parsed[i], `${path}[${i}]`));
21
28
  if (isPlainObject(raw) && isPlainObject(parsed)) {
22
29
  const out: string[] = [];
23
30
  for (const k of Object.keys(raw)) {
@@ -31,11 +38,21 @@ export function strippedKeys(raw: unknown, parsed: unknown, path = ""): string[]
31
38
  }
32
39
 
33
40
  /** Strict parse: zod validation plus rejection of unknown keys at any depth. */
34
- export function parseStrict(schema: z.ZodType, value: unknown, prefix: string): Validation<unknown> {
41
+ export function parseStrict(
42
+ schema: z.ZodType,
43
+ value: unknown,
44
+ prefix: string,
45
+ ): Validation<unknown> {
35
46
  const r = schema.safeParse(value);
36
47
  if (!r.success) return { ok: false, issues: formatIssues(r.error, prefix) };
37
48
  const extra = strippedKeys(value, r.data);
38
- if (extra.length) return { ok: false, issues: extra.map((k) => `${prefix}.${k}: unknown field (outputs are validated strictly)`) };
49
+ if (extra.length)
50
+ return {
51
+ ok: false,
52
+ issues: extra.map(
53
+ (k) => `${prefix}.${k}: unknown field (outputs are validated strictly)`,
54
+ ),
55
+ };
39
56
  return { ok: true, value: r.data };
40
57
  }
41
58
 
@@ -46,30 +63,51 @@ function entrySchema(e: unknown): z.ZodType {
46
63
  }
47
64
 
48
65
  /** Reserved input.json key holding the step's config entries (written by the service). */
49
- export const CONFIG_KEY = "$config";
66
+ export const CONFIG_KEY = '$config';
50
67
 
51
68
  /** Parses `$config` with each declared entry's schema (defaults applied). Undeclared entries are ignored. */
52
- export function validateConfig(step: AnyStep, raw: unknown): Validation<Record<string, unknown>> {
69
+ export function validateConfig(
70
+ step: AnyStep,
71
+ raw: unknown,
72
+ ): Validation<Record<string, unknown>> {
53
73
  const given = isPlainObject(raw) ? raw[CONFIG_KEY] : undefined;
54
- if (given !== undefined && !isPlainObject(given)) return { ok: false, issues: [`input.${CONFIG_KEY} must be an object keyed by config entry names`] };
74
+ if (given !== undefined && !isPlainObject(given))
75
+ return {
76
+ ok: false,
77
+ issues: [`input.${CONFIG_KEY} must be an object keyed by config entry names`],
78
+ };
55
79
  const issues: string[] = [];
56
80
  const out: Record<string, unknown> = {};
57
81
  for (const e of step.config ?? []) {
58
82
  const value = given?.[e.name];
59
83
  const r = e.schema.safeParse(value);
60
- if (!r.success) issues.push(...formatIssues(r.error, `config.${e.name}`).map((i) => (value === undefined ? `${i} (config entry '${e.name}' is not set)` : i)));
84
+ if (!r.success)
85
+ issues.push(
86
+ ...formatIssues(r.error, `config.${e.name}`).map((i) =>
87
+ value === undefined ? `${i} (config entry '${e.name}' is not set)` : i,
88
+ ),
89
+ );
61
90
  else if (r.data !== undefined) out[e.name] = r.data;
62
91
  }
63
92
  return issues.length ? { ok: false, issues } : { ok: true, value: out };
64
93
  }
65
94
 
66
95
  /** Validates input.json against the step's input entries. Unknown top-level keys are rejected; nested values are parsed (unknown nested keys stripped). */
67
- export function validateInput(step: AnyStep, raw: unknown): Validation<Record<string, unknown>> {
68
- if (!isPlainObject(raw)) return { ok: false, issues: ["input.json must be a JSON object keyed by the step's input names"] };
96
+ export function validateInput(
97
+ step: AnyStep,
98
+ raw: unknown,
99
+ ): Validation<Record<string, unknown>> {
100
+ if (!isPlainObject(raw))
101
+ return {
102
+ ok: false,
103
+ issues: ["input.json must be a JSON object keyed by the step's input names"],
104
+ };
69
105
  const issues: string[] = [];
70
106
  const out: Record<string, unknown> = {};
71
107
  const declared = step.input as Record<string, unknown>;
72
- for (const k of Object.keys(raw)) if (k !== CONFIG_KEY && !Object.hasOwn(declared, k)) issues.push(`input.${k}: not an input of step '${step.name}'`);
108
+ for (const k of Object.keys(raw))
109
+ if (k !== CONFIG_KEY && !Object.hasOwn(declared, k))
110
+ issues.push(`input.${k}: not an input of step '${step.name}'`);
73
111
  for (const [k, e] of Object.entries(declared)) {
74
112
  if (!Object.hasOwn(raw, k)) {
75
113
  if (isOptional(e)) continue;
@@ -87,7 +125,12 @@ export function validateInput(step: AnyStep, raw: unknown): Validation<Record<st
87
125
  export function validateOutput(step: AnyStep, value: unknown): Validation<unknown> {
88
126
  if (!step.output) {
89
127
  if (value === undefined || value === null) return { ok: true, value: null };
90
- return { ok: false, issues: [`step '${step.name}' declares no output model but the handler returned a value`] };
128
+ return {
129
+ ok: false,
130
+ issues: [
131
+ `step '${step.name}' declares no output model but the handler returned a value`,
132
+ ],
133
+ };
91
134
  }
92
- return parseStrict(step.output.schema, value, "output");
135
+ return parseStrict(step.output.schema, value, 'output');
93
136
  }