@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
package/src/ctx/files.ts CHANGED
@@ -4,19 +4,31 @@
4
4
  // `path` minus the trailing "/" plus ".tar" for directories (a deterministic tar). The step writes
5
5
  // objects to `$OLVE_DIR/work/files/<object path>`; the service stores them under
6
6
  // `runs/<p>/<run>/<step>/<attempt>/files/<object path>`.
7
- import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
8
- import { basename, dirname, join, resolve } from "node:path";
9
- import { fileRefSchema, FILE_PATH, type FileRef } from "../core/models.ts";
10
- import { ENV } from "../harness/env.ts";
11
- import { sha256File } from "../util/hash.ts";
12
- import { dirEntries, extractTar, writeTar } from "../util/tar.ts";
13
- import type { OutputFileOptions, RunInfo } from "./types.ts";
7
+ import {
8
+ copyFileSync,
9
+ existsSync,
10
+ mkdirSync,
11
+ readFileSync,
12
+ renameSync,
13
+ rmSync,
14
+ statSync,
15
+ writeFileSync,
16
+ } from 'node:fs';
17
+ import { basename, dirname, join, resolve } from 'node:path';
18
+ import { fileRefSchema, FILE_PATH, type FileRef } from '../core/models.ts';
19
+ import { ENV } from '../harness/env.ts';
20
+ import { sha256File } from '../util/hash.ts';
21
+ import { dirEntries, extractTar, writeTar } from '../util/tar.ts';
22
+ import type { OutputFileOptions, RunInfo } from './types.ts';
14
23
 
15
- export const objectPath = (refPath: string): string => (refPath.endsWith("/") ? refPath.slice(0, -1) + ".tar" : refPath);
16
- export const fileKey = (ref: Pick<FileRef, "run" | "step" | "attempt" | "path">): string => `${ref.run}/${ref.step}/${ref.attempt}/${ref.path}`;
24
+ export const objectPath = (refPath: string): string =>
25
+ refPath.endsWith('/') ? refPath.slice(0, -1) + '.tar' : refPath;
26
+ export const fileKey = (
27
+ ref: Pick<FileRef, 'run' | 'step' | 'attempt' | 'path'>,
28
+ ): string => `${ref.run}/${ref.step}/${ref.attempt}/${ref.path}`;
17
29
 
18
30
  /** Strips everything from `?` on (pre-signed credentials). */
19
- export const redactUrl = (url: string): string => url.replace(/[?#].*$/, "?…");
31
+ export const redactUrl = (url: string): string => url.replace(/[?#].*$/, '?…');
20
32
 
21
33
  export interface FilesDeps {
22
34
  run: RunInfo;
@@ -25,45 +37,59 @@ export interface FilesDeps {
25
37
  root: string;
26
38
  signal: AbortSignal;
27
39
  log: (message: string, fields?: Record<string, unknown>) => void;
40
+ /** Backoff between download retries (default: real sleep). */
41
+ sleep?: (ms: number) => Promise<void>;
28
42
  }
29
43
 
30
44
  function checkName(name: string, what: string): void {
31
- if (!FILE_PATH.test(name) || name.endsWith("/")) throw new Error(`${what}: '${name}' is not a valid relative file name (no leading '/', no '..', no trailing '/')`);
45
+ if (!FILE_PATH.test(name) || name.endsWith('/'))
46
+ throw new Error(
47
+ `${what}: '${name}' is not a valid relative file name (no leading '/', no '..', no trailing '/')`,
48
+ );
32
49
  }
33
50
 
34
51
  export function createOutput(deps: FilesDeps) {
35
52
  const used = new Set<string>();
36
53
  const claim = (obj: string) => {
37
- if (used.has(obj) || existsSync(join(deps.workFiles, obj))) throw new Error(`output file '${obj}' already exists`);
54
+ if (used.has(obj) || existsSync(join(deps.workFiles, obj)))
55
+ throw new Error(`output file '${obj}' already exists`);
38
56
  used.add(obj);
39
57
  const dest = join(deps.workFiles, obj);
40
58
  mkdirSync(dirname(dest), { recursive: true });
41
59
  return dest;
42
60
  };
43
61
  const ref = (path: string, dest: string): FileRef =>
44
- ({ run: deps.run.number, step: deps.run.step, attempt: deps.run.attempt, path, sha256: sha256File(dest) }) as FileRef;
62
+ ({
63
+ run: deps.run.number,
64
+ step: deps.run.step,
65
+ attempt: deps.run.attempt,
66
+ path,
67
+ sha256: sha256File(dest),
68
+ }) as FileRef;
45
69
 
46
70
  return {
47
71
  async file(path: string, opts: OutputFileOptions = {}): Promise<FileRef> {
48
72
  const src = resolve(path);
49
- if (!statSync(src).isFile()) throw new Error(`ctx.output.file: '${path}' is not a file`);
73
+ if (!statSync(src).isFile())
74
+ throw new Error(`ctx.output.file: '${path}' is not a file`);
50
75
  const name = opts.name ?? basename(src);
51
- checkName(name, "ctx.output.file");
76
+ checkName(name, 'ctx.output.file');
52
77
  const dest = claim(name);
53
78
  copyFileSync(src, dest);
54
79
  return ref(name, dest);
55
80
  },
56
81
  async dir(path: string, opts: OutputFileOptions = {}): Promise<FileRef> {
57
82
  const src = resolve(path);
58
- if (!statSync(src).isDirectory()) throw new Error(`ctx.output.dir: '${path}' is not a directory`);
83
+ if (!statSync(src).isDirectory())
84
+ throw new Error(`ctx.output.dir: '${path}' is not a directory`);
59
85
  const name = opts.name ?? basename(src);
60
- checkName(name, "ctx.output.dir");
61
- const dest = claim(objectPath(name + "/"));
86
+ checkName(name, 'ctx.output.dir');
87
+ const dest = claim(objectPath(name + '/'));
62
88
  writeTar(dest, dirEntries(src));
63
- return ref(name + "/", dest);
89
+ return ref(name + '/', dest);
64
90
  },
65
91
  async write(name: string, content: string | Uint8Array): Promise<FileRef> {
66
- checkName(name, "ctx.output.write");
92
+ checkName(name, 'ctx.output.write');
67
93
  const dest = claim(name);
68
94
  writeFileSync(dest, content);
69
95
  return ref(name, dest);
@@ -72,20 +98,34 @@ export function createOutput(deps: FilesDeps) {
72
98
  }
73
99
 
74
100
  function urlMap(root: string): Record<string, string> {
75
- const raw = process.env[ENV.fileUrls] ?? (existsSync(join(root, "files.json")) ? readFileSync(join(root, "files.json"), "utf8") : undefined);
101
+ const raw =
102
+ process.env[ENV.fileUrls] ??
103
+ (existsSync(join(root, 'files.json'))
104
+ ? readFileSync(join(root, 'files.json'), 'utf8')
105
+ : undefined);
76
106
  if (!raw) return {};
77
107
  try {
78
108
  const m = JSON.parse(raw) as unknown;
79
- if (typeof m !== "object" || m === null || Array.isArray(m)) throw new Error("not an object");
109
+ if (typeof m !== 'object' || m === null || Array.isArray(m))
110
+ throw new Error('not an object');
80
111
  return m as Record<string, string>;
81
112
  } catch (e) {
82
- throw new Error(`${ENV.fileUrls} / files.json is not a JSON object of file key → URL (${(e as Error).message})`);
113
+ throw new Error(
114
+ `${ENV.fileUrls} / files.json is not a JSON object of file key → URL (${(e as Error).message})`,
115
+ );
83
116
  }
84
117
  }
85
118
 
86
119
  const TRANSIENT = (status: number) => status >= 500 || status === 408 || status === 429;
87
120
 
88
- async function download(url: string, dest: string, headers: Record<string, string>, signal: AbortSignal, log: FilesDeps["log"]): Promise<void> {
121
+ async function download(
122
+ url: string,
123
+ dest: string,
124
+ headers: Record<string, string>,
125
+ signal: AbortSignal,
126
+ log: FilesDeps['log'],
127
+ sleep: (ms: number) => Promise<void>,
128
+ ): Promise<void> {
89
129
  const attempts = 4;
90
130
  for (let i = 1; ; i++) {
91
131
  let status = 0;
@@ -93,46 +133,81 @@ async function download(url: string, dest: string, headers: Record<string, strin
93
133
  const res = await fetch(url, { headers, signal });
94
134
  status = res.status;
95
135
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
96
- await Bun.write(dest + ".partial", res);
97
- renameSync(dest + ".partial", dest);
136
+ await Bun.write(dest + '.partial', res);
137
+ renameSync(dest + '.partial', dest);
98
138
  return;
99
139
  } catch (e) {
100
- rmSync(dest + ".partial", { force: true });
140
+ rmSync(dest + '.partial', { force: true });
101
141
  const transient = status === 0 || TRANSIENT(status);
102
- if (signal.aborted || !transient || i >= attempts) throw new Error(`download of ${redactUrl(url)} failed: ${(e as Error).message}`);
103
- log("download failed, retrying", { url: redactUrl(url), attempt: i, error: (e as Error).message });
104
- await Bun.sleep(1000 * 2 ** (i - 1));
142
+ if (signal.aborted || !transient || i >= attempts)
143
+ throw new Error(`download of ${redactUrl(url)} failed: ${(e as Error).message}`);
144
+ log('download failed, retrying', {
145
+ url: redactUrl(url),
146
+ attempt: i,
147
+ error: (e as Error).message,
148
+ });
149
+ await sleep(1000 * 2 ** (i - 1));
105
150
  }
106
151
  }
107
152
  }
108
153
 
109
154
  export function createFetch(deps: FilesDeps) {
110
155
  const cache = new Map<string, string>();
156
+ const sleep = deps.sleep ?? ((ms: number) => Bun.sleep(ms));
111
157
  return async (input: FileRef): Promise<string> => {
112
158
  const parsed = fileRefSchema.safeParse(input);
113
- if (!parsed.success) throw new Error(`ctx.files.fetch: not a valid file reference: ${JSON.stringify(input)}`);
159
+ if (!parsed.success)
160
+ throw new Error(
161
+ `ctx.files.fetch: not a valid file reference: ${JSON.stringify(input)}`,
162
+ );
114
163
  const ref = parsed.data;
115
164
  const key = fileKey(ref);
116
165
  const hit = cache.get(key);
117
166
  if (hit) return hit;
118
167
 
119
168
  const obj = objectPath(ref.path);
120
- const base = join(deps.tmpDir, "files", String(ref.run), ref.step, String(ref.attempt));
121
- const objFile = join(base, ".objects", obj);
169
+ const base = join(
170
+ deps.tmpDir,
171
+ 'files',
172
+ String(ref.run),
173
+ ref.step,
174
+ String(ref.attempt),
175
+ );
176
+ const objFile = join(base, '.objects', obj);
122
177
  mkdirSync(dirname(objFile), { recursive: true });
123
178
 
124
- const own = ref.run === deps.run.number && ref.step === deps.run.step && ref.attempt === deps.run.attempt;
179
+ const own =
180
+ ref.run === deps.run.number &&
181
+ ref.step === deps.run.step &&
182
+ ref.attempt === deps.run.attempt;
125
183
  const urls = urlMap(deps.root);
126
184
  const localDir = process.env[ENV.filesDir];
127
185
  const baseUrl = process.env[ENV.filesBaseUrl];
128
- if (own && existsSync(join(deps.workFiles, obj))) copyFileSync(join(deps.workFiles, obj), objFile);
129
- else if (urls[key]) await download(urls[key], objFile, {}, deps.signal, deps.log);
130
- else if (localDir && existsSync(join(localDir, String(ref.run), ref.step, String(ref.attempt), "files", obj)))
131
- copyFileSync(join(localDir, String(ref.run), ref.step, String(ref.attempt), "files", obj), objFile);
186
+ if (own && existsSync(join(deps.workFiles, obj)))
187
+ copyFileSync(join(deps.workFiles, obj), objFile);
188
+ else if (urls[key])
189
+ await download(urls[key], objFile, {}, deps.signal, deps.log, sleep);
190
+ else if (
191
+ localDir &&
192
+ existsSync(
193
+ join(localDir, String(ref.run), ref.step, String(ref.attempt), 'files', obj),
194
+ )
195
+ )
196
+ copyFileSync(
197
+ join(localDir, String(ref.run), ref.step, String(ref.attempt), 'files', obj),
198
+ objFile,
199
+ );
132
200
  else if (baseUrl) {
133
201
  const token = process.env[ENV.filesToken];
134
- const url = `${baseUrl.replace(/\/+$/, "")}/${ref.run}/${encodeURIComponent(ref.step)}/${ref.attempt}/${obj.split("/").map(encodeURIComponent).join("/")}`;
135
- await download(url, objFile, token ? { authorization: `Bearer ${token}` } : {}, deps.signal, deps.log);
202
+ const url = `${baseUrl.replace(/\/+$/, '')}/${ref.run}/${encodeURIComponent(ref.step)}/${ref.attempt}/${obj.split('/').map(encodeURIComponent).join('/')}`;
203
+ await download(
204
+ url,
205
+ objFile,
206
+ token ? { authorization: `Bearer ${token}` } : {},
207
+ deps.signal,
208
+ deps.log,
209
+ sleep,
210
+ );
136
211
  } else {
137
212
  throw new Error(
138
213
  `ctx.files.fetch: no source for ${key} (set ${ENV.fileUrls} / $OLVE_DIR/files.json, ${ENV.filesBaseUrl} or ${ENV.filesDir})`,
@@ -142,10 +217,12 @@ export function createFetch(deps: FilesDeps) {
142
217
  const actual = sha256File(objFile);
143
218
  if (actual !== ref.sha256) {
144
219
  rmSync(objFile, { force: true });
145
- throw new Error(`ctx.files.fetch: ${key} has sha256 ${actual}, expected ${ref.sha256}`);
220
+ throw new Error(
221
+ `ctx.files.fetch: ${key} has sha256 ${actual}, expected ${ref.sha256}`,
222
+ );
146
223
  }
147
224
  let result = objFile;
148
- if (ref.path.endsWith("/")) {
225
+ if (ref.path.endsWith('/')) {
149
226
  result = join(base, ref.path.slice(0, -1));
150
227
  rmSync(result, { recursive: true, force: true });
151
228
  mkdirSync(result, { recursive: true });
package/src/ctx/index.ts CHANGED
@@ -1,10 +1,12 @@
1
- import { mkdirSync, readFileSync } from "node:fs";
2
- import { resolve } from "node:path";
3
- import { skipped } from "../core/handlers.ts";
4
- import type { Secret } from "../core/secrets.ts";
5
- import { createFetch, createOutput, redactUrl } from "./files.ts";
6
- import { createSh } from "./sh.ts";
7
- import type { Ctx, Http, RetryOptions, RunInfo } from "./types.ts";
1
+ import { mkdirSync, readFileSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ import { skipped } from '../core/handlers.ts';
4
+ import { createLinks } from './links.ts';
5
+ import type { Secret } from '../core/secrets.ts';
6
+ import type { Cell } from '../core/matrix.ts';
7
+ import { createFetch, createOutput, redactUrl } from './files.ts';
8
+ import { createSh } from './sh.ts';
9
+ import type { Ctx, Http, RetryOptions, RunInfo } from './types.ts';
8
10
 
9
11
  export interface CtxOptions {
10
12
  run: RunInfo;
@@ -12,25 +14,40 @@ export interface CtxOptions {
12
14
  secrets: readonly Secret[];
13
15
  /** Secret values that take precedence over the environment (tests). */
14
16
  secretValues?: Readonly<Record<string, string>>;
17
+ /** Parsed config entries of the step (`ctx.config`). */
18
+ config?: Readonly<Record<string, unknown>>;
15
19
  paths: { root: string; workFiles: string; tmp: string };
16
20
  signal: AbortSignal;
17
21
  /** Where log lines go (default stderr). */
18
22
  logSink?: (line: string) => void;
23
+ /** `<step>/<input>` → callback URL (`ctx.inputUrl`). */
24
+ callbackUrls?: Readonly<Record<string, string>>;
25
+ /** A matrix step's cell (`ctx.cell`). */
26
+ cell?: Cell;
19
27
  }
20
28
 
21
29
  export function formatLog(message: string, fields?: Record<string, unknown>): string {
22
- const parts = Object.entries(fields ?? {}).map(([k, v]) => `${k}=${typeof v === "string" && !/\s|"/.test(v) ? v : JSON.stringify(v)}`);
23
- return parts.length ? `${message} ${parts.join(" ")}` : message;
30
+ const parts = Object.entries(fields ?? {}).map(
31
+ ([k, v]) =>
32
+ `${k}=${typeof v === 'string' && !/\s|"/.test(v) ? v : JSON.stringify(v)}`,
33
+ );
34
+ return parts.length ? `${message} ${parts.join(' ')}` : message;
24
35
  }
25
36
 
26
- export function createSecrets(declared: readonly Secret[], values?: Readonly<Record<string, string>>): Record<string, string> {
37
+ export function createSecrets(
38
+ declared: readonly Secret[],
39
+ values?: Readonly<Record<string, string>>,
40
+ ): Record<string, string> {
27
41
  const obj: Record<string, string> = {};
28
42
  for (const s of declared) {
29
43
  Object.defineProperty(obj, s.name, {
30
44
  enumerable: true,
31
45
  get() {
32
46
  const v = values?.[s.name] ?? process.env[s.name];
33
- if (v === undefined) throw new Error(`secret ${s.name} is not set (the harness reads it from the environment variable ${s.name})`);
47
+ if (v === undefined)
48
+ throw new Error(
49
+ `secret ${s.name} is not set (the harness reads it from the environment variable ${s.name})`,
50
+ );
34
51
  return v;
35
52
  },
36
53
  });
@@ -39,53 +56,81 @@ export function createSecrets(declared: readonly Secret[], values?: Readonly<Rec
39
56
  }
40
57
 
41
58
  async function httpError(res: Response, url: string): Promise<Error> {
42
- let body = "";
59
+ let body = '';
43
60
  try {
44
61
  body = (await res.text()).slice(0, 500);
45
62
  } catch {
46
63
  /* ignore */
47
64
  }
48
- return new Error(`HTTP ${res.status} ${res.statusText} from ${redactUrl(url)}${body ? `: ${body}` : ""}`);
65
+ return new Error(
66
+ `HTTP ${res.status} ${res.statusText} from ${redactUrl(url)}${body ? `: ${body}` : ''}`,
67
+ );
49
68
  }
50
69
 
51
70
  export function createHttp(signal: AbortSignal): Http {
52
- const f = (url: string, init?: RequestInit) => fetch(url, { ...init, signal: init?.signal ?? signal });
71
+ const f = (url: string, init?: RequestInit) =>
72
+ fetch(url, { ...init, signal: init?.signal ?? signal });
53
73
  return {
54
74
  fetch: f,
55
- async getJson<T>(url: string, init?: { headers?: Record<string, string> }): Promise<T> {
56
- const res = await f(url, { headers: { accept: "application/json", ...init?.headers } });
75
+ async getJson<T>(
76
+ url: string,
77
+ init?: { headers?: Record<string, string> },
78
+ ): Promise<T> {
79
+ const res = await f(url, {
80
+ headers: { accept: 'application/json', ...init?.headers },
81
+ });
57
82
  if (!res.ok) throw await httpError(res, url);
58
83
  return (await res.json()) as T;
59
84
  },
60
- async expectOk(url: string, init?: { headers?: Record<string, string>; method?: string }): Promise<Response> {
61
- const res = await f(url, { method: init?.method ?? "GET", headers: init?.headers });
85
+ async expectOk(
86
+ url: string,
87
+ init?: { headers?: Record<string, string>; method?: string },
88
+ ): Promise<Response> {
89
+ const res = await f(url, { method: init?.method ?? 'GET', headers: init?.headers });
62
90
  if (!res.ok) throw await httpError(res, url);
63
91
  return res;
64
92
  },
65
93
  };
66
94
  }
67
95
 
68
- export function createRetry(signal: AbortSignal, log: (m: string, f?: Record<string, unknown>) => void) {
69
- return async <T>(opts: RetryOptions, fn: (attempt: number) => Promise<T>): Promise<T> => {
70
- if (!(Number.isInteger(opts.attempts) && opts.attempts >= 1)) throw new Error("retry: attempts must be a positive integer");
96
+ /** Waits `ms`, or rejects at once when the step is cancelled. */
97
+ function cancellableSleep(ms: number, signal: AbortSignal): Promise<void> {
98
+ return new Promise<void>((res, rej) => {
99
+ const t = setTimeout(() => {
100
+ signal.removeEventListener('abort', onAbort);
101
+ res();
102
+ }, ms);
103
+ const onAbort = () => {
104
+ clearTimeout(t);
105
+ rej(new Error('retry cancelled: the step was cancelled'));
106
+ };
107
+ signal.addEventListener('abort', onAbort, { once: true });
108
+ });
109
+ }
110
+
111
+ export function createRetry(
112
+ signal: AbortSignal,
113
+ log: (m: string, f?: Record<string, unknown>) => void,
114
+ sleep: (ms: number, signal: AbortSignal) => Promise<void> = cancellableSleep,
115
+ ) {
116
+ return async <T>(
117
+ opts: RetryOptions,
118
+ fn: (attempt: number) => Promise<T>,
119
+ ): Promise<T> => {
120
+ if (!(Number.isInteger(opts.attempts) && opts.attempts >= 1))
121
+ throw new Error('retry: attempts must be a positive integer');
71
122
  let delay = Math.max(0, opts.delaySeconds) * 1000;
72
123
  for (let attempt = 1; ; attempt++) {
73
124
  try {
74
125
  return await fn(attempt);
75
126
  } catch (e) {
76
127
  if (attempt >= opts.attempts || signal.aborted) throw e;
77
- log("retrying", { attempt, of: opts.attempts, error: (e as Error)?.message ?? String(e) });
78
- await new Promise<void>((res, rej) => {
79
- const t = setTimeout(() => {
80
- signal.removeEventListener("abort", onAbort);
81
- res();
82
- }, delay);
83
- const onAbort = () => {
84
- clearTimeout(t);
85
- rej(new Error("retry cancelled: the step was cancelled"));
86
- };
87
- signal.addEventListener("abort", onAbort, { once: true });
128
+ log('retrying', {
129
+ attempt,
130
+ of: opts.attempts,
131
+ error: (e as Error)?.message ?? String(e),
88
132
  });
133
+ await sleep(delay, signal);
89
134
  delay *= opts.backoff ?? 1;
90
135
  }
91
136
  }
@@ -96,16 +141,26 @@ export function createRetry(signal: AbortSignal, log: (m: string, f?: Record<str
96
141
  export function createCtx(o: CtxOptions): Ctx {
97
142
  mkdirSync(o.paths.tmp, { recursive: true });
98
143
  mkdirSync(o.paths.workFiles, { recursive: true });
99
- const sink = o.logSink ?? ((line: string) => process.stderr.write(line + "\n"));
100
- const log = (message: string, fields?: Record<string, unknown>) => sink(formatLog(message, fields));
144
+ const sink = o.logSink ?? ((line: string) => process.stderr.write(line + '\n'));
145
+ const log = (message: string, fields?: Record<string, unknown>) =>
146
+ sink(formatLog(message, fields));
101
147
  const { sh, exec } = createSh({ tmpDir: o.paths.tmp, signal: o.signal });
102
- const filesDeps = { run: o.run, workFiles: o.paths.workFiles, tmpDir: o.paths.tmp, root: o.paths.root, signal: o.signal, log };
148
+ const filesDeps = {
149
+ run: o.run,
150
+ workFiles: o.paths.workFiles,
151
+ tmpDir: o.paths.tmp,
152
+ root: o.paths.root,
153
+ signal: o.signal,
154
+ log,
155
+ };
103
156
  return {
104
- secrets: createSecrets(o.secrets, o.secretValues) as Ctx["secrets"],
157
+ ...(o.cell ? { cell: o.cell } : {}),
158
+ secrets: createSecrets(o.secrets, o.secretValues) as Ctx['secrets'],
159
+ config: Object.freeze({ ...o.config }) as Ctx['config'],
105
160
  sh,
106
161
  exec,
107
- readText: async (p) => readFileSync(resolve(p), "utf8"),
108
- readJson: async <T>(p: string) => JSON.parse(readFileSync(resolve(p), "utf8")) as T,
162
+ readText: async (p) => readFileSync(resolve(p), 'utf8'),
163
+ readJson: async <T>(p: string) => JSON.parse(readFileSync(resolve(p), 'utf8')) as T,
109
164
  files: { fetch: createFetch(filesDeps) },
110
165
  output: createOutput(filesDeps),
111
166
  http: createHttp(o.signal),
@@ -115,5 +170,15 @@ export function createCtx(o: CtxOptions): Ctx {
115
170
  run: Object.freeze({ ...o.run }),
116
171
  tmpDir: o.paths.tmp,
117
172
  skip: skipped,
173
+ link: createLinks().link,
174
+ inputUrl: (step, input) => {
175
+ const name = typeof step === 'string' ? step : step.name;
176
+ const url = o.callbackUrls?.[`${name}/${input}`];
177
+ if (!url)
178
+ throw new Error(
179
+ `no callback URL for input '${input}' of step '${name}': it must list callback() in from, and the instance needs a public base URL`,
180
+ );
181
+ return url;
182
+ },
118
183
  };
119
184
  }
@@ -0,0 +1,30 @@
1
+ export interface StepLink {
2
+ url: string;
3
+ label?: string;
4
+ }
5
+
6
+ export const MAX_LINKS = 10;
7
+ export const MAX_LINK_URL = 2048;
8
+ export const MAX_LINK_LABEL = 100;
9
+
10
+ /** `ctx.link`, collecting into `links`. */
11
+ export function createLinks(): {
12
+ links: StepLink[];
13
+ link: (url: string, label?: string) => void;
14
+ } {
15
+ const links: StepLink[] = [];
16
+ const link = (url: string, label?: string) => {
17
+ if (typeof url !== 'string' || !/^https?:\/\/[^/\s]/i.test(url) || !URL.canParse(url))
18
+ throw new Error(`ctx.link: '${String(url)}' is not an absolute http or https URL`);
19
+ if (url.length > MAX_LINK_URL)
20
+ throw new Error(`ctx.link: the URL is longer than ${MAX_LINK_URL} characters`);
21
+ if (label !== undefined && typeof label !== 'string')
22
+ throw new Error('ctx.link: the label must be a string');
23
+ if (label !== undefined && label.length > MAX_LINK_LABEL)
24
+ throw new Error(`ctx.link: the label is longer than ${MAX_LINK_LABEL} characters`);
25
+ if (links.length >= MAX_LINKS)
26
+ throw new Error(`ctx.link: a step can add at most ${MAX_LINKS} links`);
27
+ links.push(label ? { url, label } : { url });
28
+ };
29
+ return { links, link };
30
+ }