@olivervea/pipelines 2.0.0-beta.25 → 2.0.0-beta.26

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olivervea/pipelines",
3
- "version": "2.0.0-beta.25",
3
+ "version": "2.0.0-beta.26",
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",
@@ -6,7 +6,7 @@ import { sha256File } from '../util/hash.ts';
6
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
- import { GLIBC_INTERP, MUSL_INTERP, readInterpreter } from './elf.ts';
9
+ import { loaderProblem } from './elf.ts';
10
10
  import { codeHash } from './hashes.ts';
11
11
  import { harnessInfo, loadProject, relPosix, type Project } from './project.ts';
12
12
 
@@ -112,14 +112,9 @@ export async function build(dir: string, opts: BuildOptions): Promise<BuildResul
112
112
  `olve-build: compiled ${target} in ${((performance.now() - t1) / 1000).toFixed(2)}s`,
113
113
  );
114
114
 
115
- // 2. loader check: the harness launches the binary through /olve/lib/<loader>; a musl binary
116
- // (e.g. compiled on alpine, where bun-linux-x64 silently yields musl) would not start.
117
- const expected = target === 'bun-linux-x64' ? GLIBC_INTERP : MUSL_INTERP;
118
- const interp = readInterpreter(binary);
119
- if (interp !== expected)
120
- throw new BuildError(
121
- `compiled binary has PT_INTERP ${interp ?? '(none)'}, expected ${expected} — compile on a glibc Bun image (oven/bun:<version>-slim), not alpine`,
122
- );
115
+ // 2. loader check: on alpine, bun-linux-x64 silently yields a musl binary, which would not start.
116
+ const problem = loaderProblem(binary, target);
117
+ if (problem) throw new BuildError(problem);
123
118
 
124
119
  // 3. describe (runs the real binary: the manifest is exactly what the step pods will see)
125
120
  const d = Bun.spawnSync([binary, 'describe'], {
package/src/build/elf.ts CHANGED
@@ -34,3 +34,11 @@ export function readInterpreter(path: string): string | null {
34
34
  closeSync(fd);
35
35
  }
36
36
  }
37
+
38
+ /** Why `binary` won't start through `target`'s loader (the harness runs it via /olve/lib/<loader>), or undefined. */
39
+ export function loaderProblem(binary: string, target: string): string | undefined {
40
+ const expected = target === 'bun-linux-x64' ? GLIBC_INTERP : MUSL_INTERP;
41
+ const interp = readInterpreter(binary);
42
+ if (interp === expected) return undefined;
43
+ return `compiled binary has PT_INTERP ${interp ?? '(none)'}, expected ${expected} — compile on a glibc Bun image (oven/bun:<version>-slim), not alpine`;
44
+ }
package/src/ctx/files.ts CHANGED
@@ -37,6 +37,8 @@ export interface FilesDeps {
37
37
  root: string;
38
38
  signal: AbortSignal;
39
39
  log: (message: string, fields?: Record<string, unknown>) => void;
40
+ /** Backoff between download retries (default: real sleep). */
41
+ sleep?: (ms: number) => Promise<void>;
40
42
  }
41
43
 
42
44
  function checkName(name: string, what: string): void {
@@ -122,6 +124,7 @@ async function download(
122
124
  headers: Record<string, string>,
123
125
  signal: AbortSignal,
124
126
  log: FilesDeps['log'],
127
+ sleep: (ms: number) => Promise<void>,
125
128
  ): Promise<void> {
126
129
  const attempts = 4;
127
130
  for (let i = 1; ; i++) {
@@ -143,13 +146,14 @@ async function download(
143
146
  attempt: i,
144
147
  error: (e as Error).message,
145
148
  });
146
- await Bun.sleep(1000 * 2 ** (i - 1));
149
+ await sleep(1000 * 2 ** (i - 1));
147
150
  }
148
151
  }
149
152
  }
150
153
 
151
154
  export function createFetch(deps: FilesDeps) {
152
155
  const cache = new Map<string, string>();
156
+ const sleep = deps.sleep ?? ((ms: number) => Bun.sleep(ms));
153
157
  return async (input: FileRef): Promise<string> => {
154
158
  const parsed = fileRefSchema.safeParse(input);
155
159
  if (!parsed.success)
@@ -181,7 +185,8 @@ export function createFetch(deps: FilesDeps) {
181
185
  const baseUrl = process.env[ENV.filesBaseUrl];
182
186
  if (own && existsSync(join(deps.workFiles, obj)))
183
187
  copyFileSync(join(deps.workFiles, obj), objFile);
184
- else if (urls[key]) await download(urls[key], objFile, {}, deps.signal, deps.log);
188
+ else if (urls[key])
189
+ await download(urls[key], objFile, {}, deps.signal, deps.log, sleep);
185
190
  else if (
186
191
  localDir &&
187
192
  existsSync(
@@ -201,6 +206,7 @@ export function createFetch(deps: FilesDeps) {
201
206
  token ? { authorization: `Bearer ${token}` } : {},
202
207
  deps.signal,
203
208
  deps.log,
209
+ sleep,
204
210
  );
205
211
  } else {
206
212
  throw new Error(
package/src/ctx/index.ts CHANGED
@@ -89,9 +89,25 @@ export function createHttp(signal: AbortSignal): Http {
89
89
  };
90
90
  }
91
91
 
92
+ /** Waits `ms`, or rejects at once when the step is cancelled. */
93
+ function cancellableSleep(ms: number, signal: AbortSignal): Promise<void> {
94
+ return new Promise<void>((res, rej) => {
95
+ const t = setTimeout(() => {
96
+ signal.removeEventListener('abort', onAbort);
97
+ res();
98
+ }, ms);
99
+ const onAbort = () => {
100
+ clearTimeout(t);
101
+ rej(new Error('retry cancelled: the step was cancelled'));
102
+ };
103
+ signal.addEventListener('abort', onAbort, { once: true });
104
+ });
105
+ }
106
+
92
107
  export function createRetry(
93
108
  signal: AbortSignal,
94
109
  log: (m: string, f?: Record<string, unknown>) => void,
110
+ sleep: (ms: number, signal: AbortSignal) => Promise<void> = cancellableSleep,
95
111
  ) {
96
112
  return async <T>(
97
113
  opts: RetryOptions,
@@ -110,17 +126,7 @@ export function createRetry(
110
126
  of: opts.attempts,
111
127
  error: (e as Error)?.message ?? String(e),
112
128
  });
113
- await new Promise<void>((res, rej) => {
114
- const t = setTimeout(() => {
115
- signal.removeEventListener('abort', onAbort);
116
- res();
117
- }, delay);
118
- const onAbort = () => {
119
- clearTimeout(t);
120
- rej(new Error('retry cancelled: the step was cancelled'));
121
- };
122
- signal.addEventListener('abort', onAbort, { once: true });
123
- });
129
+ await sleep(delay, signal);
124
130
  delay *= opts.backoff ?? 1;
125
131
  }
126
132
  }
@@ -806,7 +806,7 @@ export function describePipeline(pipeline: unknown, opts: DescribeOptions): Mani
806
806
  export const manifestText = (m: ManifestT): string => canonicalJson(m);
807
807
 
808
808
  /**
809
- * The schemas at `path` inside an entry's schema: every union branch that has the field, except where
809
+ * The schemas at `path` inside an entry's schema (through properties and records): every union branch that has the field, except where
810
810
  * `kase` (`<entry>.<path>.<discriminator>=<value>`) narrows a union to one branch.
811
811
  */
812
812
  export function subSchemas(
@@ -848,6 +848,8 @@ export function subSchemas(
848
848
  const props = o.properties as Record<string, JsonSchema> | undefined;
849
849
  if (props && Object.hasOwn(props, rest[0]!))
850
850
  return walk(props[rest[0]!], rest.slice(1), `${here}.${rest[0]}`);
851
+ if (o.additionalProperties && typeof o.additionalProperties === 'object')
852
+ return walk(o.additionalProperties, rest.slice(1), `${here}.${rest[0]}`);
851
853
  return [];
852
854
  };
853
855
  return walk(schema, path, entry);
@@ -131,8 +131,8 @@ function tokenize(src: string): Tok[] {
131
131
  i = e + 2;
132
132
  continue;
133
133
  }
134
- if (c === '`') throw new MapperError('mapper uses a template string');
135
- if (c === '"' || c === "'") {
134
+ // Bun prints a string constant holding a newline or both quote kinds as a template literal.
135
+ if (c === '"' || c === "'" || c === '`') {
136
136
  let j = i + 1;
137
137
  let v = '';
138
138
  for (;;) {
@@ -140,6 +140,8 @@ function tokenize(src: string): Tok[] {
140
140
  throw new MapperError('mapper source has an unterminated string');
141
141
  const d = src[j]!;
142
142
  if (d === c) break;
143
+ if (c === '`' && d === '$' && src[j + 1] === '{')
144
+ throw new MapperError('mapper uses a template string');
143
145
  if (d === '\\') {
144
146
  v += src.slice(j, j + 2);
145
147
  j += 2;
@@ -2,7 +2,6 @@
2
2
  export {
3
3
  runHarness,
4
4
  defaultIO,
5
- allSteps,
6
5
  resolveStepFunction,
7
6
  type HarnessConfig,
8
7
  type HarnessIO,
@@ -56,12 +56,6 @@ export const defaultIO: HarnessIO = {
56
56
  const USAGE =
57
57
  'usage: step describe [--sentinels] | step run <step> | step map-trigger <trigger>';
58
58
 
59
- export function allSteps(data: PipelineData): AnyStep[] {
60
- const out: AnyStep[] = [];
61
- for (const { step } of stepsWithCases(data)) if (!out.includes(step)) out.push(step);
62
- return out;
63
- }
64
-
65
59
  /** Every step with the `select` case conditions (`<ref>.<discriminator>=<value>`) it sits in. */
66
60
  export function stepsWithCases(data: PipelineData): { step: AnyStep; cases: string[] }[] {
67
61
  const out: { step: AnyStep; cases: string[] }[] = [];
@@ -53,10 +53,15 @@ export async function uploadFile(
53
53
  url: string,
54
54
  file: string,
55
55
  log: (line: string) => void,
56
- opts: { attempts?: number; baseDelayMs?: number } = {},
56
+ opts: {
57
+ attempts?: number;
58
+ baseDelayMs?: number;
59
+ sleep?: (ms: number) => Promise<void>;
60
+ } = {},
57
61
  ): Promise<void> {
58
62
  const attempts = opts.attempts ?? 5;
59
63
  const base = opts.baseDelayMs ?? 1000;
64
+ const sleep = opts.sleep ?? ((ms: number) => Bun.sleep(ms));
60
65
  for (let i = 1; ; i++) {
61
66
  let status = 0;
62
67
  let detail: string;
@@ -73,7 +78,7 @@ export async function uploadFile(
73
78
  if (!transient || i >= attempts)
74
79
  throw new Error(`upload to ${redactUrl(url)} failed: ${detail}`);
75
80
  log(`upload to ${redactUrl(url)} failed (${detail}); retry ${i}/${attempts - 1}`);
76
- await Bun.sleep(base * 2 ** (i - 1));
81
+ await sleep(base * 2 ** (i - 1));
77
82
  }
78
83
  }
79
84
 
package/src/util/tar.ts CHANGED
@@ -20,7 +20,6 @@ import { createGzip } from 'node:zlib';
20
20
 
21
21
  export type TarEntry =
22
22
  | { path: string; type: 'file'; mode: number; source: string }
23
- | { path: string; type: 'bytes'; mode: number; data: Uint8Array }
24
23
  | { path: string; type: 'dir'; mode: number }
25
24
  | { path: string; type: 'symlink'; target: string };
26
25
 
@@ -88,7 +87,6 @@ export function writeTar(out: string, entries: readonly TarEntry[]): void {
88
87
  const link = e.type === 'symlink' ? e.target : '';
89
88
  let size = 0;
90
89
  if (e.type === 'file') size = lstatSync(e.source).size;
91
- else if (e.type === 'bytes') size = e.data.length;
92
90
  const pax: string[] = [];
93
91
  if (enc.encode(name).length > 100) pax.push(paxRecord('path', name));
94
92
  if (enc.encode(link).length > 100) pax.push(paxRecord('linkpath', link));
@@ -104,10 +102,7 @@ export function writeTar(out: string, entries: readonly TarEntry[]): void {
104
102
  write(
105
103
  header(shortName, size, mode, flag, link.length > 100 ? link.slice(0, 99) : link),
106
104
  );
107
- if (e.type === 'bytes') {
108
- write(e.data);
109
- pad(size);
110
- } else if (e.type === 'file') {
105
+ if (e.type === 'file') {
111
106
  const src = openSync(e.source, 'r');
112
107
  try {
113
108
  const buf = new Uint8Array(1 << 20);