@olivervea/pipelines 2.0.0-beta.3 → 2.0.0-beta.30

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 (64) hide show
  1. package/README.md +163 -78
  2. package/manifest.schema.json +300 -16
  3. package/package.json +6 -4
  4. package/src/assets.d.ts +2 -2
  5. package/src/build/build.ts +98 -64
  6. package/src/build/cli.ts +33 -23
  7. package/src/build/elf.ts +16 -7
  8. package/src/build/hashes.ts +40 -51
  9. package/src/build/project.ts +55 -52
  10. package/src/builder/pipeline.ts +348 -89
  11. package/src/builder/refs.ts +15 -5
  12. package/src/core/brands.ts +6 -5
  13. package/src/core/config-refs.ts +103 -0
  14. package/src/core/config.ts +224 -0
  15. package/src/core/external.ts +86 -0
  16. package/src/core/handlers.ts +62 -15
  17. package/src/core/matrix.ts +150 -0
  18. package/src/core/models.ts +37 -15
  19. package/src/core/secrets.ts +2 -2
  20. package/src/core/steps.ts +113 -38
  21. package/src/core/types.ts +5 -1
  22. package/src/core/webhooks.ts +21 -7
  23. package/src/ctx/files.ts +119 -42
  24. package/src/ctx/index.ts +103 -38
  25. package/src/ctx/links.ts +30 -0
  26. package/src/ctx/sh.ts +104 -44
  27. package/src/ctx/types.ts +24 -4
  28. package/src/describe/config-schema.ts +98 -0
  29. package/src/describe/context.ts +187 -0
  30. package/src/describe/describe.ts +63 -355
  31. package/src/describe/failure-handlers.ts +34 -0
  32. package/src/describe/manifest-schema.ts +304 -61
  33. package/src/describe/mapper-source.ts +252 -105
  34. package/src/describe/record.ts +67 -37
  35. package/src/describe/schemas.ts +71 -0
  36. package/src/describe/stages.ts +166 -0
  37. package/src/describe/step.ts +544 -0
  38. package/src/describe/triggers.ts +72 -0
  39. package/src/dev/cli.ts +62 -35
  40. package/src/harness/env.ts +47 -25
  41. package/src/harness/execute.ts +74 -30
  42. package/src/harness/exit.ts +1 -1
  43. package/src/harness/index.ts +19 -8
  44. package/src/harness/main.ts +234 -98
  45. package/src/harness/result.ts +45 -24
  46. package/src/harness/validate.ts +82 -15
  47. package/src/index.ts +139 -10
  48. package/src/library/checks.ts +11 -6
  49. package/src/library/compose-deploy.ts +115 -0
  50. package/src/library/github-source.ts +100 -47
  51. package/src/library/helm-deploy-ssh.ts +426 -112
  52. package/src/library/helm-deploy.ts +529 -0
  53. package/src/library/http-health.ts +7 -5
  54. package/src/library/image-build.ts +510 -162
  55. package/src/library/index.ts +48 -14
  56. package/src/library/models.ts +35 -14
  57. package/src/library/ssh.ts +44 -18
  58. package/src/testing/index.ts +100 -35
  59. package/src/util/canonical.ts +7 -4
  60. package/src/util/code-hash.ts +17 -0
  61. package/src/util/hash.ts +5 -5
  62. package/src/util/tar.ts +83 -38
  63. package/src/library/script.ts +0 -85
  64. package/src/util/handler-hash.ts +0 -22
@@ -2,29 +2,41 @@
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 type { AnyStep } from "../core/steps.ts";
9
- import type { WebhookEvent } from "../core/webhooks.ts";
10
- import { pipelineData, type PipelineData, type StageNode } from "../builder/pipeline.ts";
11
- import { createCtx, formatLog } from "../ctx/index.ts";
12
- import { DescribeError, describePipeline, manifestText } from "../describe/describe.ts";
13
- import type { HarnessInfo } from "../util/handler-hash.ts";
14
- import { ENV, paths, runInfoFromEnv } from "./env.ts";
15
- import { EXIT } from "./exit.ts";
16
- import { executeStep, type Envelope, type Execution, type StepFunction } from "./execute.ts";
17
- import { MANIFEST_BEGIN, MANIFEST_END, packResult, removeStale, sentinelBlock, uploadFile, writeEnvelope } from "./result.ts";
18
- import { formatIssues, parseStrict } from "./validate.ts";
19
-
20
- /** step name → lazy import of `handlers/<name>.ts`. Literal import() paths so Bun bundles them. */
21
- export type HandlerRegistry = Readonly<Record<string, () => Promise<{ default: unknown }>>>;
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 { cellName, parseCell } from '../core/matrix.ts';
11
+ import type { WebhookEvent } from '../core/webhooks.ts';
12
+ import { pipelineData, type PipelineData, type StageNode } from '../builder/pipeline.ts';
13
+ import { createCtx, formatLog } from '../ctx/index.ts';
14
+ import { DescribeError, describePipeline, manifestText } from '../describe/describe.ts';
15
+ import type { HarnessInfo } from '../util/code-hash.ts';
16
+ import { callbackUrlsFromEnv, ENV, paths, runInfoFromEnv } from './env.ts';
17
+ import { EXIT } from './exit.ts';
18
+ import {
19
+ executeStep,
20
+ type Envelope,
21
+ type Execution,
22
+ type StepFunction,
23
+ } from './execute.ts';
24
+ import {
25
+ MANIFEST_BEGIN,
26
+ MANIFEST_END,
27
+ packResult,
28
+ removeStale,
29
+ sentinelBlock,
30
+ uploadFile,
31
+ writeEnvelope,
32
+ } from './result.ts';
33
+ import { formatIssues, parseStrict } from './validate.ts';
22
34
 
23
35
  export interface HarnessConfig {
24
- /** Default export of pipeline.ts. */
36
+ /** Default export of the entry point. */
25
37
  pipeline: unknown;
26
- handlers: HandlerRegistry;
27
- handlerHashes: Readonly<Record<string, string>>;
38
+ /** Hash of the build (from the build; includes the harness identity). */
39
+ codeHash?: string;
28
40
  harness: HarnessInfo;
29
41
  }
30
42
 
@@ -38,57 +50,78 @@ export const defaultIO: HarnessIO = {
38
50
  await Bun.write(Bun.stdout, text);
39
51
  },
40
52
  stderr: (line) => {
41
- process.stderr.write(line.endsWith("\n") ? line : line + "\n");
53
+ process.stderr.write(line.endsWith('\n') ? line : line + '\n');
42
54
  },
43
55
  };
44
56
 
45
- const USAGE = "usage: step describe [--sentinels] | step run <step> | step map-trigger <trigger>";
57
+ const USAGE =
58
+ 'usage: step describe [--sentinels] | step run <step> | step map-trigger <trigger>';
46
59
 
47
- export function allSteps(data: PipelineData): AnyStep[] {
48
- const out: AnyStep[] = [];
49
- const walk = (nodes: readonly StageNode[]) => {
50
- for (const n of nodes) n.kind === "stage" ? out.push(...n.steps.map((s) => s.step)) : walk(n.nodes);
60
+ /** Every step with the `select` case conditions (`<ref>.<discriminator>=<value>`) it sits in. */
61
+ export function stepsWithCases(data: PipelineData): { step: AnyStep; cases: string[] }[] {
62
+ const out: { step: AnyStep; cases: string[] }[] = [];
63
+ const walk = (nodes: readonly StageNode[], cases: string[]) => {
64
+ for (const n of nodes) {
65
+ if (n.kind === 'stage') out.push(...n.steps.map((s) => ({ step: s.step, cases })));
66
+ else if (n.kind === 'when') walk(n.nodes, cases);
67
+ else {
68
+ const union = isConfigRef(n.on) ? unionAt(n.on) : undefined;
69
+ for (const [value, inner] of Object.entries(n.cases))
70
+ walk(
71
+ inner,
72
+ union
73
+ ? [...cases, `${configRefPath(n.on)}.${union.discriminator}=${value}`]
74
+ : cases,
75
+ );
76
+ }
77
+ }
51
78
  };
52
- walk(data.nodes);
53
- for (const fh of data.failureHandlers) if (!out.includes(fh.step)) out.push(fh.step);
79
+ walk(data.nodes, []);
80
+ for (const fh of data.failureHandlers) out.push({ step: fh.step, cases: [] });
54
81
  return out;
55
82
  }
56
83
 
84
+ /** The step `run <name>` executes: the only one so named, or the one in the cases the service selected (`OLVE_CASES`). */
85
+ export function findStep(
86
+ data: PipelineData,
87
+ name: string,
88
+ selected: readonly string[],
89
+ ): AnyStep | string {
90
+ const candidates = stepsWithCases(data).filter((c) => c.step.name === name);
91
+ const distinct = candidates.filter(
92
+ (c, i) => candidates.findIndex((d) => d.step === c.step) === i,
93
+ );
94
+ if (distinct.length === 0) return `unknown step '${name}'`;
95
+ if (distinct.length === 1) return distinct[0]!.step;
96
+ const chosen = distinct.filter((c) => c.cases.every((k) => selected.includes(k)));
97
+ if (chosen.length === 1) return chosen[0]!.step;
98
+ return `step '${name}' is in several select cases; ${ENV_CASES} must name the selected one (got ${JSON.stringify(selected)})`;
99
+ }
100
+
101
+ /** The select case conditions of the step being run (JSON array), set by the service for steps inside a select. */
102
+ export const ENV_CASES = 'OLVE_CASES';
103
+
57
104
  /** Installs SIGTERM/SIGINT handlers that abort the returned signal (PID 1 ignores signals without them). */
58
105
  export function cancellation(): { signal: AbortSignal; dispose(): void } {
59
106
  const ac = new AbortController();
60
- const onTerm = () => ac.abort("SIGTERM");
61
- const onInt = () => (ac.signal.aborted ? process.exit(EXIT.sigint) : ac.abort("SIGINT"));
62
- process.on("SIGTERM", onTerm);
63
- process.on("SIGINT", onInt);
107
+ const onTerm = () => ac.abort('SIGTERM');
108
+ const onInt = () =>
109
+ ac.signal.aborted ? process.exit(EXIT.sigint) : ac.abort('SIGINT');
110
+ process.on('SIGTERM', onTerm);
111
+ process.on('SIGINT', onInt);
64
112
  return {
65
113
  signal: ac.signal,
66
114
  dispose() {
67
- process.off("SIGTERM", onTerm);
68
- process.off("SIGINT", onInt);
115
+ process.off('SIGTERM', onTerm);
116
+ process.off('SIGINT', onInt);
69
117
  },
70
118
  };
71
119
  }
72
120
 
73
- /** Resolves the function that implements `step` (library or handler module). */
74
- export async function resolveStepFunction(step: AnyStep, handlers: HandlerRegistry): Promise<StepFunction | string> {
75
- if (step.kind !== "custom") {
76
- if (!step.builtin) return `library step '${step.name}' (${step.kind}) has no implementation`;
77
- return step.builtin.run as StepFunction;
78
- }
79
- const load = handlers[step.name];
80
- if (!load) return `step '${step.name}' has no handler`;
81
- let mod: { default: unknown };
82
- try {
83
- mod = await load();
84
- } catch (e) {
85
- return `the handler of step '${step.name}' failed to load: ${(e as Error)?.stack ?? String(e)}`;
86
- }
87
- const impl = mod.default;
88
- if (!isImplementation(impl)) return `the handler of step '${step.name}' must default-export implement(<step '${step.name}'>, …)`;
89
- if (impl.step !== step)
90
- return `the handler of step '${step.name}' implements a different step object (it must be implement(<the '${step.name}' step from pipeline.ts>, …))`;
91
- return impl.handler as unknown as StepFunction;
121
+ /** The function `step` carries, or what is wrong. */
122
+ export function resolveStepFunction(step: AnyStep): StepFunction | string {
123
+ if (isImplementation(step)) return step.handler as unknown as StepFunction;
124
+ return `step '${step.name}' has no implementation (stage implement(step({...}), fn))`;
92
125
  }
93
126
 
94
127
  /** Writes output.json, prints the sentinel block and uploads result.tar.gz when OLVE_RESULT_URL is set. */
@@ -100,7 +133,7 @@ export async function finish(exec: Execution, io: HarnessIO): Promise<number> {
100
133
  if (!url) return exec.exitCode;
101
134
  try {
102
135
  mkdirSync(p.tmp, { recursive: true });
103
- const archive = join(p.tmp, "result.tar.gz");
136
+ const archive = join(p.tmp, 'result.tar.gz');
104
137
  await packResult(p.work, archive);
105
138
  await uploadFile(url, archive, io.stderr);
106
139
  } catch (e) {
@@ -110,18 +143,29 @@ export async function finish(exec: Execution, io: HarnessIO): Promise<number> {
110
143
  return exec.exitCode;
111
144
  }
112
145
 
113
- function readJson(path: string): { ok: true; value: unknown } | { ok: false; message: string } {
146
+ function readJson(
147
+ path: string,
148
+ ): { ok: true; value: unknown } | { ok: false; message: string } {
114
149
  try {
115
- return { ok: true, value: JSON.parse(readFileSync(path, "utf8")) };
150
+ return { ok: true, value: JSON.parse(readFileSync(path, 'utf8')) };
116
151
  } catch (e) {
117
152
  return { ok: false, message: `cannot read ${path}: ${(e as Error).message}` };
118
153
  }
119
154
  }
120
155
 
121
- async function describeMode(cfg: HarnessConfig, args: string[], io: HarnessIO): Promise<number> {
122
- const sentinels = args.includes("--sentinels");
156
+ async function describeMode(
157
+ cfg: HarnessConfig,
158
+ args: string[],
159
+ io: HarnessIO,
160
+ ): Promise<number> {
161
+ const sentinels = args.includes('--sentinels');
123
162
  try {
124
- const text = manifestText(describePipeline(cfg.pipeline, { handlerNames: Object.keys(cfg.handlers), handlerHashes: cfg.handlerHashes, harness: cfg.harness }));
163
+ const text = manifestText(
164
+ describePipeline(cfg.pipeline, {
165
+ codeHash: cfg.codeHash,
166
+ harness: cfg.harness,
167
+ }),
168
+ );
125
169
  await io.stdout(sentinels ? `${MANIFEST_BEGIN}\n${text}${MANIFEST_END}\n` : text);
126
170
  return EXIT.ok;
127
171
  } catch (e) {
@@ -133,29 +177,55 @@ async function describeMode(cfg: HarnessConfig, args: string[], io: HarnessIO):
133
177
  }
134
178
  }
135
179
 
136
- async function runMode(cfg: HarnessConfig, name: string | undefined, io: HarnessIO): Promise<number> {
137
- if (!name) return io.stderr(USAGE), EXIT.usage;
180
+ async function runMode(
181
+ cfg: HarnessConfig,
182
+ name: string | undefined,
183
+ io: HarnessIO,
184
+ ): Promise<number> {
185
+ if (!name) return (io.stderr(USAGE), EXIT.usage);
138
186
  const data = pipelineData(cfg.pipeline);
139
- const step = allSteps(data).find((s) => s.name === name);
140
- if (!step) return io.stderr(`unknown step '${name}'`), EXIT.usage;
141
- const fn = await resolveStepFunction(step, cfg.handlers);
142
- if (typeof fn === "string") return io.stderr(fn), EXIT.configInvalid;
187
+ const step = findStep(data, name, selectedCases());
188
+ if (typeof step === 'string') return (io.stderr(step), EXIT.usage);
189
+ const fn = resolveStepFunction(step);
190
+ if (typeof fn === 'string') return (io.stderr(fn), EXIT.configInvalid);
143
191
 
144
192
  const p = paths();
145
193
  removeStale(p.work);
146
194
  mkdirSync(p.files, { recursive: true });
147
195
  mkdirSync(p.tmp, { recursive: true });
148
196
  const raw = readJson(p.input);
197
+ const cell = step.matrix
198
+ ? parseCell(step.name, step.matrix, process.env[ENV.cell])
199
+ : undefined;
149
200
  const cancel = cancellation();
150
201
  try {
151
202
  let exec: Execution;
152
- if (!raw.ok) {
153
- io.stderr(raw.message);
154
- exec = { envelope: { status: "failed", error: { kind: "input", message: raw.message } }, exitCode: EXIT.inputInvalid };
203
+ if (!raw.ok || (cell && !cell.ok)) {
204
+ const message = !raw.ok ? raw.message : cell && !cell.ok ? cell.message : '';
205
+ io.stderr(message);
206
+ exec = {
207
+ envelope: { status: 'failed', error: { kind: 'input', message } },
208
+ exitCode: EXIT.inputInvalid,
209
+ };
155
210
  } else {
156
- const run = runInfoFromEnv(step.name);
157
- const ctx = createCtx({ run, secrets: step.secrets, paths: { root: p.root, workFiles: p.files, tmp: p.tmp }, signal: cancel.signal, logSink: io.stderr });
158
- exec = await executeStep({ step, fn, rawInput: raw.value, ctx, workFiles: p.files, log: io.stderr });
211
+ const run = runInfoFromEnv(cell?.ok ? cellName(step.name, cell.cell) : step.name);
212
+ const ctx = createCtx({
213
+ run,
214
+ secrets: step.secrets,
215
+ paths: { root: p.root, workFiles: p.files, tmp: p.tmp },
216
+ signal: cancel.signal,
217
+ logSink: io.stderr,
218
+ callbackUrls: callbackUrlsFromEnv(),
219
+ ...(cell?.ok ? { cell: cell.cell } : {}),
220
+ });
221
+ exec = await executeStep({
222
+ step,
223
+ fn,
224
+ rawInput: raw.value,
225
+ ctx,
226
+ workFiles: p.files,
227
+ log: io.stderr,
228
+ });
159
229
  }
160
230
  return await finish(exec, io);
161
231
  } finally {
@@ -164,64 +234,121 @@ async function runMode(cfg: HarnessConfig, name: string | undefined, io: Harness
164
234
  }
165
235
 
166
236
  function isWebhookEvent(v: unknown): boolean {
167
- if (typeof v !== "object" || v === null || Array.isArray(v) || !("body" in v)) return false;
237
+ if (typeof v !== 'object' || v === null || Array.isArray(v) || !('body' in v))
238
+ return false;
168
239
  const headers = (v as { headers?: unknown }).headers;
169
- return typeof headers === "object" && headers !== null && !Array.isArray(headers);
240
+ return typeof headers === 'object' && headers !== null && !Array.isArray(headers);
170
241
  }
171
242
 
172
- async function mapTriggerMode(cfg: HarnessConfig, name: string | undefined, io: HarnessIO): Promise<number> {
173
- if (!name) return io.stderr(USAGE), EXIT.usage;
243
+ async function mapTriggerMode(
244
+ cfg: HarnessConfig,
245
+ name: string | undefined,
246
+ io: HarnessIO,
247
+ ): Promise<number> {
248
+ if (!name) return (io.stderr(USAGE), EXIT.usage);
174
249
  const data = pipelineData(cfg.pipeline);
175
250
  const trigger = data.triggers.find((t) => t.name === name);
176
- if (!trigger) return io.stderr(`unknown trigger '${name}' (known: ${data.triggers.map((t) => t.name).join(", ") || "none"})`), EXIT.usage;
251
+ if (!trigger)
252
+ return (
253
+ io.stderr(
254
+ `unknown trigger '${name}' (known: ${data.triggers.map((t) => t.name).join(', ') || 'none'})`,
255
+ ),
256
+ EXIT.usage
257
+ );
177
258
  const p = paths();
178
259
  removeStale(p.work);
179
260
  mkdirSync(p.work, { recursive: true });
180
261
  const raw = readJson(p.input);
181
- const fail = (kind: "input" | "output" | "handler", message: string, exitCode: number): Execution => {
262
+ const fail = (
263
+ kind: 'input' | 'output' | 'handler',
264
+ message: string,
265
+ exitCode: number,
266
+ ): Execution => {
182
267
  io.stderr(message);
183
- return { envelope: { status: "failed", error: { kind, message } }, exitCode };
268
+ return { envelope: { status: 'failed', error: { kind, message } }, exitCode };
184
269
  };
185
270
  let exec: Execution;
186
- if (!raw.ok) exec = fail("input", raw.message, EXIT.inputInvalid);
187
- else if (trigger.kind === "poll" && (typeof raw.value !== "object" || raw.value === null || typeof (raw.value as { value?: unknown }).value !== "string"))
188
- exec = fail("input", `poll trigger '${name}': the event must be {"value": "<string>"}`, EXIT.inputInvalid);
189
- else if (trigger.kind === "push" && (typeof raw.value !== "object" || raw.value === null || Array.isArray(raw.value)))
190
- exec = fail("input", `push trigger: the event must be the webhook payload object`, EXIT.inputInvalid);
191
- else if (trigger.kind === "webhook" && !isWebhookEvent(raw.value))
192
- exec = fail("input", `webhook trigger '${name}': the event must be {"headers": {...}, "body": ...}`, EXIT.inputInvalid);
271
+ if (!raw.ok) exec = fail('input', raw.message, EXIT.inputInvalid);
272
+ else if (
273
+ trigger.kind === 'poll' &&
274
+ (typeof raw.value !== 'object' ||
275
+ raw.value === null ||
276
+ typeof (raw.value as { value?: unknown }).value !== 'string')
277
+ )
278
+ exec = fail(
279
+ 'input',
280
+ `poll trigger '${name}': the event must be {"value": "<string>"}`,
281
+ EXIT.inputInvalid,
282
+ );
283
+ else if (
284
+ trigger.kind === 'push' &&
285
+ (typeof raw.value !== 'object' || raw.value === null || Array.isArray(raw.value))
286
+ )
287
+ exec = fail(
288
+ 'input',
289
+ `push trigger: the event must be the webhook payload object`,
290
+ EXIT.inputInvalid,
291
+ );
292
+ else if (trigger.kind === 'webhook' && !isWebhookEvent(raw.value))
293
+ exec = fail(
294
+ 'input',
295
+ `webhook trigger '${name}': the event must be {"headers": {...}, "body": ...}`,
296
+ EXIT.inputInvalid,
297
+ );
193
298
  else {
194
299
  let mapped: unknown;
195
300
  let threw: unknown = undefined;
196
301
  try {
197
302
  mapped =
198
- trigger.kind === "poll" ? await trigger.map((raw.value as { value: string }).value)
199
- : trigger.kind === "webhook" ? await trigger.map(raw.value as WebhookEvent)
200
- : await trigger.map(raw.value);
303
+ trigger.kind === 'poll'
304
+ ? await trigger.map((raw.value as { value: string }).value)
305
+ : trigger.kind === 'webhook'
306
+ ? await trigger.map(raw.value as WebhookEvent)
307
+ : await trigger.map(raw.value);
201
308
  } catch (e) {
202
- threw = e ?? new Error("undefined");
309
+ threw = e ?? new Error('undefined');
203
310
  }
204
- if (threw !== undefined) exec = fail("handler", `trigger '${name}' mapper threw: ${(threw as Error)?.message ?? String(threw)}`, EXIT.handlerFailed);
205
- else if (mapped === null && trigger.kind === "webhook") exec = { envelope: { status: "skipped", reason: `trigger '${name}' ignored the delivery` }, exitCode: EXIT.ok };
311
+ if (threw !== undefined)
312
+ exec = fail(
313
+ 'handler',
314
+ `trigger '${name}' mapper threw: ${(threw as Error)?.message ?? String(threw)}`,
315
+ EXIT.handlerFailed,
316
+ );
317
+ else if (mapped === null && trigger.kind === 'webhook')
318
+ exec = {
319
+ envelope: { status: 'skipped', reason: `trigger '${name}' ignored the delivery` },
320
+ exitCode: EXIT.ok,
321
+ };
206
322
  else {
207
- const r = parseStrict(data.input.schema, mapped, "input");
323
+ const r = parseStrict(data.input.schema, mapped, 'input');
208
324
  exec = r.ok
209
- ? { envelope: { status: "succeeded", output: r.value } satisfies Envelope, exitCode: EXIT.ok }
210
- : fail("output", `trigger '${name}': mapped input does not match ${data.input.name}:\n ${r.issues.join("\n ")}`, EXIT.outputInvalid);
325
+ ? {
326
+ envelope: { status: 'succeeded', output: r.value } satisfies Envelope,
327
+ exitCode: EXIT.ok,
328
+ }
329
+ : fail(
330
+ 'output',
331
+ `trigger '${name}': mapped input does not match ${data.input.name}:\n ${r.issues.join('\n ')}`,
332
+ EXIT.outputInvalid,
333
+ );
211
334
  }
212
335
  }
213
336
  return finish(exec, io);
214
337
  }
215
338
 
216
339
  /** Entry point of the compiled binary (and the dev runner). Returns the exit code. */
217
- export async function runHarness(cfg: HarnessConfig, argv: readonly string[], io: HarnessIO = defaultIO): Promise<number> {
340
+ export async function runHarness(
341
+ cfg: HarnessConfig,
342
+ argv: readonly string[],
343
+ io: HarnessIO = defaultIO,
344
+ ): Promise<number> {
218
345
  const [mode, ...rest] = argv;
219
346
  switch (mode) {
220
- case "describe":
347
+ case 'describe':
221
348
  return describeMode(cfg, rest, io);
222
- case "run":
349
+ case 'run':
223
350
  return runMode(cfg, rest[0], io);
224
- case "map-trigger":
351
+ case 'map-trigger':
225
352
  return mapTriggerMode(cfg, rest[0], io);
226
353
  default:
227
354
  io.stderr(USAGE);
@@ -230,3 +357,12 @@ export async function runHarness(cfg: HarnessConfig, argv: readonly string[], io
230
357
  }
231
358
 
232
359
  export { formatIssues, formatLog };
360
+
361
+ function selectedCases(): string[] {
362
+ try {
363
+ const v = JSON.parse(process.env[ENV_CASES] ?? '[]') as unknown;
364
+ return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : [];
365
+ } catch {
366
+ return [];
367
+ }
368
+ }
@@ -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
 
@@ -40,15 +53,20 @@ export async function uploadFile(
40
53
  url: string,
41
54
  file: string,
42
55
  log: (line: string) => void,
43
- opts: { attempts?: number; baseDelayMs?: number } = {},
56
+ opts: {
57
+ attempts?: number;
58
+ baseDelayMs?: number;
59
+ sleep?: (ms: number) => Promise<void>;
60
+ } = {},
44
61
  ): Promise<void> {
45
62
  const attempts = opts.attempts ?? 5;
46
63
  const base = opts.baseDelayMs ?? 1000;
64
+ const sleep = opts.sleep ?? ((ms: number) => Bun.sleep(ms));
47
65
  for (let i = 1; ; i++) {
48
66
  let status = 0;
49
67
  let detail: string;
50
68
  try {
51
- const res = await fetch(url, { method: "PUT", body: Bun.file(file) });
69
+ const res = await fetch(url, { method: 'PUT', body: Bun.file(file) });
52
70
  status = res.status;
53
71
  if (res.ok) return;
54
72
  detail = `HTTP ${res.status} ${res.statusText}`;
@@ -57,9 +75,10 @@ export async function uploadFile(
57
75
  detail = (e as Error).message;
58
76
  }
59
77
  const transient = status === 0 || TRANSIENT(status);
60
- if (!transient || i >= attempts) throw new Error(`upload to ${redactUrl(url)} failed: ${detail}`);
78
+ if (!transient || i >= attempts)
79
+ throw new Error(`upload to ${redactUrl(url)} failed: ${detail}`);
61
80
  log(`upload to ${redactUrl(url)} failed (${detail}); retry ${i}/${attempts - 1}`);
62
- await Bun.sleep(base * 2 ** (i - 1));
81
+ await sleep(base * 2 ** (i - 1));
63
82
  }
64
83
  }
65
84
 
@@ -68,8 +87,10 @@ export async function uploadFile(
68
87
  * *contents* of files/. The directories themselves are kept (olve-init creates them 1777).
69
88
  */
70
89
  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 });
90
+ rmSync(join(work, 'output.json'), { force: true });
91
+ rmSync(join(work, 'output.json.partial'), { force: true });
92
+ const files = join(work, 'files');
93
+ if (existsSync(files))
94
+ for (const e of readdirSync(files))
95
+ rmSync(join(files, e), { recursive: true, force: true });
75
96
  }