@agent-flow-board/bridge 0.2.0 → 0.3.0

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 (51) hide show
  1. package/README.md +16 -0
  2. package/dist/checks/appdir.js +105 -0
  3. package/dist/checks/approve.js +213 -0
  4. package/dist/checks/audit.js +3 -2
  5. package/dist/checks/bind.js +3 -2
  6. package/dist/checks/broker.js +439 -0
  7. package/dist/checks/browser.js +137 -32
  8. package/dist/checks/browserpool.js +221 -0
  9. package/dist/checks/cache.js +710 -0
  10. package/dist/checks/command.js +75 -88
  11. package/dist/checks/commands.js +219 -0
  12. package/dist/checks/connections.js +4 -3
  13. package/dist/checks/copy.js +320 -0
  14. package/dist/checks/dbpool.js +710 -0
  15. package/dist/checks/doctor.js +649 -0
  16. package/dist/checks/envPolicy.js +50 -1
  17. package/dist/checks/envrules.js +322 -0
  18. package/dist/checks/evidence.js +114 -0
  19. package/dist/checks/fsretry.js +29 -0
  20. package/dist/checks/importlegacy.js +300 -0
  21. package/dist/checks/install.js +150 -0
  22. package/dist/checks/live.js +76 -0
  23. package/dist/checks/lock.js +28 -4
  24. package/dist/checks/login.js +203 -0
  25. package/dist/checks/mailguard-hook.cjs +216 -0
  26. package/dist/checks/mailguard.js +98 -0
  27. package/dist/checks/paths.js +24 -0
  28. package/dist/checks/preview.js +418 -191
  29. package/dist/checks/profiles.js +110 -0
  30. package/dist/checks/provision.js +1 -1
  31. package/dist/checks/recipe.js +19 -5
  32. package/dist/checks/registry.js +548 -0
  33. package/dist/checks/repoenv.js +162 -0
  34. package/dist/checks/roots.js +144 -0
  35. package/dist/checks/routing.js +68 -0
  36. package/dist/checks/run.js +243 -53
  37. package/dist/checks/sandbox.js +32 -10
  38. package/dist/checks/scheduler.js +383 -0
  39. package/dist/checks/scope.js +119 -30
  40. package/dist/checks/sealedNames.js +39 -0
  41. package/dist/checks/secrets.js +452 -0
  42. package/dist/checks/secretscmd.js +80 -0
  43. package/dist/checks/service.js +589 -0
  44. package/dist/checks/signin.js +7 -1
  45. package/dist/checks/singleflight.js +307 -0
  46. package/dist/checks/store.js +92 -0
  47. package/dist/checks/surface.js +124 -0
  48. package/dist/checks/warm.js +342 -0
  49. package/dist/checks/watch.js +190 -63
  50. package/dist/checks/wire.js +87 -0
  51. package/package.json +1 -1
@@ -1,119 +1,47 @@
1
1
  // THE PREVIEW OF ONE RUN: A PRIVATE COPY OF THE REPO, BUILT AND SERVED FROM ITS OWN FOLDER ON A FREE PORT, WITH AN ENVIRONMENT THE TOOL BUILT (envPolicy.ts).
2
- // A run builds and serves from its own copy so it can never take down the preview a person is looking at, and two runs never share a folder. The copy is
3
- // made from the files git lists (tracked, and untracked that are not ignored), never from .git; env files are not copied, they are read from the source repo.
4
- // A dependency cache was left out on purpose: a fresh install per run is slower but cannot serve a stale module to a run.
2
+ // A run builds and serves from its own copy so it can never take down the preview a person is looking at, and two runs never share a folder. The copy
3
+ // is a local clone of the git root at HEAD plus the working-tree changes (copy.ts); env files are not copied, they are read from the source repo.
4
+ // With an app folder the copy is still made from the git root, but install, build and start run in the app folder of the copy. A live site (cfg.url)
5
+ // builds and starts nothing: the run is unverified. A dependency cache is not made here: a fresh install per run cannot serve a stale module.
5
6
  // -- DEP-MODULES -- //
6
- import { execFile, spawn } from 'node:child_process';
7
+ import { spawn } from 'node:child_process';
7
8
  import { createHash } from 'node:crypto';
8
- import { closeSync, copyFileSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync } from 'node:fs';
9
- import { dirname, join } from 'node:path';
9
+ import { closeSync, lstatSync, openSync, readFileSync } from 'node:fs';
10
+ import { join } from 'node:path';
10
11
  // -- MODULES -- //
11
- import { buildPreviewEnv } from './envPolicy.js';
12
+ import { appDirProblem, sandboxAppRoot } from './appdir.js';
13
+ import { git, listFiles, makePrivateCopy } from './copy.js';
14
+ import { buildAdoptedEnv, buildPreviewEnv } from './envPolicy.js';
15
+ import { describeFence, fenceProblem, readFence, withMailGuard } from './mailguard.js';
16
+ import { omitKeys, resolveSealedEnv } from './secrets.js';
12
17
  import { FORBIDDEN_PORTS } from './guardrails.js';
13
- import { copiesDir, ensureDir, runsDir, under } from './paths.js';
14
- import { freePort, makeThrowawayDatabase } from './provision.js';
18
+ import { KEEP_BUILDS, KEEP_DEPS, buildKey, depsKey, evict, hasEntry, publishBuildAsync, publishDepsAsync, restoreBuildAsync, restoreDepsAsync, restoreOrNull, snapshotAsync, treeId } from './cache.js';
19
+ import { installCacheable, installCommand, installDirFor, lockHash, packageManagerOf, packageManagerVersion, pathOf, programArgv } from './install.js';
20
+ import { cacheDirFor, copiesDir, ensureDir, logDirFor, runsDir, under } from './paths.js';
21
+ import { pinFiles } from './recipe.js';
22
+ import { redact as redactText } from './redact.js';
23
+ import { InfraError, singleFlight } from './singleflight.js';
24
+ import { provisionDatabase } from './dbpool.js';
25
+ import { freePort } from './provision.js';
15
26
  import { killTree, removeTree } from './scope.js';
16
27
  // -- CONSTANTS -- //
17
- /** A file bigger than this is not copied (a build output, a dump, a video). */
18
- const MAX_COPY_BYTES = 5 * 1024 * 1024;
19
28
  const BUILD_TIMEOUT_MS = 10 * 60_000;
20
29
  const READY_TIMEOUT_MS = 120_000;
21
- /** Env files are read from the source repo by envPolicy, never copied. An example file holds no secrets and is copied. */
30
+ /** Env files are never part of what is hashed. */
22
31
  const ENV_FILE = /(^|\/)\.env(\.[^/]*)?$/i;
23
- const ENV_EXAMPLE = /\.(example|sample|template)$/i;
32
+ /** Host names that mean this machine. */
33
+ const LOOPBACK = new Set([
34
+ 'localhost',
35
+ '127.0.0.1',
36
+ '::1',
37
+ '[::1]',
38
+ '0.0.0.0',
39
+ 'host.docker.internal'
40
+ ]);
24
41
  // -- FUNCTIONS -- //
25
- /** Runs git with an argument list and no shell. Resolves with the exit status; never throws. */
26
- function git(cwd, args) {
27
- return new Promise((resolve) => {
28
- execFile('git', args, {
29
- cwd,
30
- windowsHide: true,
31
- encoding: 'buffer',
32
- maxBuffer: 256 * 1024 * 1024,
33
- timeout: 120_000
34
- }, (err, stdout) => {
35
- const code = err
36
- ? typeof err.code === 'number'
37
- ? err.code
38
- : -1
39
- : 0;
40
- resolve({ status: code, stdout: stdout });
41
- });
42
- });
43
- }
44
- /** The tracked and the untracked-not-ignored files of a repo, as paths relative to it. Null when the folder is not a git repo. */
45
- async function listFiles(repo) {
46
- const r = await git(repo, ['ls-files', '--cached', '--others', '--exclude-standard', '-z']);
47
- if (r.status !== 0)
48
- return null;
49
- return [...new Set(r.stdout.toString('utf8').split('\0').filter(Boolean))];
50
- }
51
- /**
52
- * Copies the listed files of `repo` into `dest`. A link anywhere in the repo's files (the file itself or a folder above it) is refused by name,
53
- * because a copy that follows a link could carry files from outside the repo into the build.
54
- */
55
- export async function makePrivateCopy(repo, dest, log = () => { }) {
56
- const files = await listFiles(repo);
57
- if (!files)
58
- throw new Error('The project folder is not a git repository, so the tool cannot tell which files belong to it.');
59
- const checkedDirs = new Set();
60
- let copied = 0;
61
- let skipped = 0;
62
- mkdirSync(dest, { recursive: true, mode: 0o700 });
63
- for (const rel of files) {
64
- const parts = rel.split('/');
65
- if (parts[0] === '.git' || parts.includes('..'))
66
- throw new Error(`The file "${rel.slice(0, 80)}" is not a file the tool copies.`);
67
- // Every folder above the file must be a real folder
68
- for (let i = 1; i < parts.length; i++) {
69
- const folder = parts.slice(0, i).join('/');
70
- if (checkedDirs.has(folder))
71
- continue;
72
- let st;
73
- try {
74
- st = lstatSync(join(repo, folder));
75
- }
76
- catch {
77
- break;
78
- }
79
- if (st.isSymbolicLink())
80
- throw new Error(`"${folder.slice(0, 80)}" is a link. A check does not copy links; remove it or ignore it in git.`);
81
- checkedDirs.add(folder);
82
- }
83
- const from = join(repo, ...parts);
84
- let st;
85
- try {
86
- st = lstatSync(from);
87
- }
88
- catch {
89
- continue; // Listed by git but deleted in the working tree
90
- }
91
- if (st.isSymbolicLink())
92
- throw new Error(`"${rel.slice(0, 80)}" is a link. A check does not copy links; remove it or ignore it in git.`);
93
- if (!st.isFile())
94
- continue;
95
- if ((ENV_FILE.test(rel) && !ENV_EXAMPLE.test(rel)) || st.size > MAX_COPY_BYTES) {
96
- skipped++;
97
- continue;
98
- }
99
- const to = under(dest, ...parts);
100
- mkdirSync(dirname(to), { recursive: true, mode: 0o700 });
101
- copyFileSync(from, to);
102
- copied++;
103
- }
104
- if (skipped)
105
- log(`${skipped} file(s) were not copied (env files and files over 5 MB).`);
106
- return { files: copied, skipped };
107
- }
108
- /** The commit of HEAD, or null when the folder has no commit. */
109
- async function headCommit(repo) {
110
- const r = await git(repo, ['rev-parse', 'HEAD']);
111
- return r.status === 0 ? r.stdout.toString('utf8').trim() || null : null;
112
- }
113
42
  /** A hash over the uncommitted changes (`git diff HEAD`) and the contents of untracked files. Null when the folder is not a git repo. */
114
43
  async function hashChanges(repo) {
115
- const files = await listFiles(repo);
116
- if (!files)
44
+ if (!(await listFiles(repo)))
117
45
  return null;
118
46
  const h = createHash('sha256');
119
47
  const diff = await git(repo, ['diff', 'HEAD']);
@@ -136,63 +64,92 @@ async function hashChanges(repo) {
136
64
  }
137
65
  /**
138
66
  * Starts a program from an argument list with no shell. On Windows the package managers are .cmd shims, which a plain spawn cannot find (ENOENT) and
139
- * Node refuses to start directly; only then is the shim run through `cmd.exe /d /s /c`. The arguments came from a command that was already checked
140
- * (words, flags and a port, no metacharacters), so they are not interpreted by cmd.exe. Resolves once the process exists.
67
+ * Node refuses to start directly; those go through `cmd.exe /d /s /c` (install.ts programArgv). The arguments came from a command that was already
68
+ * checked (words, flags and a port, no metacharacters), so they are not interpreted by cmd.exe. Resolves once the process exists.
141
69
  */
142
70
  export function launch(argv, opts) {
143
- const [file, ...args] = argv;
71
+ const [file, ...args] = programArgv(argv, { pathEnv: pathOf(opts.env) });
144
72
  if (!file)
145
73
  return Promise.reject(new Error('The command is empty.'));
146
- const base = {
147
- cwd: opts.cwd,
148
- env: opts.env,
149
- stdio: ['ignore', opts.out, opts.out],
150
- windowsHide: true,
151
- shell: false,
152
- detached: opts.detached
153
- };
154
- const attempt = (f, a) => new Promise((resolve, reject) => {
155
- const child = spawn(f, a, base);
74
+ return new Promise((resolve, reject) => {
75
+ const child = spawn(file, args, {
76
+ cwd: opts.cwd,
77
+ env: opts.env,
78
+ stdio: ['ignore', opts.out, opts.out],
79
+ windowsHide: true,
80
+ shell: false,
81
+ detached: opts.detached
82
+ });
156
83
  child.once('error', reject);
157
84
  child.once('spawn', () => {
158
85
  child.off('error', reject);
159
86
  resolve(child);
160
87
  });
161
88
  });
162
- return attempt(file, args).catch((e) => {
163
- if (e.code === 'ENOENT' && process.platform === 'win32' && /^(yarn|npm|pnpm|npx)$/.test(file))
164
- return attempt('cmd.exe', ['/d', '/s', '/c', `${file}.cmd`, ...args]);
165
- throw e;
166
- });
167
89
  }
168
- /** Runs a command to its end and resolves with its exit code (or -1 when it timed out). The output goes to the open file `out`. */
169
- async function runToEnd(argv, opts) {
170
- const child = await launch(argv, { ...opts, detached: false });
90
+ /** The message of a stopped run. */
91
+ const STOPPED = 'The run was stopped.';
92
+ /**
93
+ * The error of an install or build command that ended without success. No exit code at all (-1: it timed out, was killed or was stopped) is the machine's trouble and
94
+ * not the code's, so it is an InfraError: a run that waited for this one takes over instead of being handed the same failure. A real exit code is the code's failure
95
+ * and is shared with every waiter, with the last lines of the log.
96
+ */
97
+ export function stageError(what, code, aborted, tail) {
98
+ if (code === -1)
99
+ return new InfraError(aborted ? STOPPED : `The ${what} timed out or was stopped.`);
100
+ return new Error(`The ${what} failed (exit ${code}). Last lines:\n${tail()}`);
101
+ }
102
+ /**
103
+ * Runs a command to its end and resolves with its exit code (or -1 when it timed out or was killed). The output goes to the open file `out`.
104
+ * The process is registered on the scope while it runs, so a stop signal or a crash of the run cannot leave it (or the cmd.exe and node under it) behind;
105
+ * an abort kills its whole tree at once. A command that cannot be started at all (no such program, no disk, no handles) is an InfraError.
106
+ */
107
+ export async function runToEnd(argv, opts) {
108
+ if (opts.signal?.aborted)
109
+ throw new InfraError(STOPPED);
110
+ let child;
111
+ try {
112
+ child = await launch(argv, { ...opts, detached: false });
113
+ }
114
+ catch (e) {
115
+ throw new InfraError(`The command could not be started: ${e.code ?? 'an error'}.`, { cause: e });
116
+ }
117
+ let gone = false;
118
+ const pid = child.pid;
119
+ if (pid)
120
+ opts.scope?.add(opts.name ?? 'build process', () => (gone ? undefined : killTree(pid)));
171
121
  return new Promise((resolve) => {
172
- const timer = setTimeout(() => {
173
- if (child.pid)
174
- void killTree(child.pid).then(() => resolve(-1));
175
- else
176
- resolve(-1);
177
- }, opts.timeoutMs);
178
- child.once('exit', (code) => {
122
+ const finish = (code) => {
123
+ gone = true;
179
124
  clearTimeout(timer);
180
- resolve(code ?? -1);
181
- });
125
+ opts.signal?.removeEventListener('abort', onAbort);
126
+ resolve(code);
127
+ };
128
+ // A process the tool killed (a timeout, an abort) is -1 whatever code the kill made it exit with (taskkill ends it with 1): the exit event
129
+ // can come before the kill call returns
130
+ let killed = false;
131
+ const kill = () => {
132
+ killed = true;
133
+ if (pid && !gone)
134
+ void killTree(pid).then(() => finish(-1));
135
+ else
136
+ finish(-1);
137
+ };
138
+ const onAbort = () => kill();
139
+ const timer = setTimeout(kill, opts.timeoutMs);
140
+ child.once('exit', (code) => finish(killed ? -1 : (code ?? -1)));
141
+ if (opts.signal) {
142
+ if (opts.signal.aborted)
143
+ kill();
144
+ else
145
+ opts.signal.addEventListener('abort', onAbort, { once: true });
146
+ }
182
147
  });
183
148
  }
184
149
  const words = (command) => command.split(' ').filter(Boolean);
185
- /** The install command for the copy, from the lock file it holds, or null when there is no lock file (a frozen install needs one). */
186
- export function installCommand(dir) {
187
- if (!existsSync(join(dir, 'package.json')))
188
- return null;
189
- if (existsSync(join(dir, 'yarn.lock')))
190
- return ['yarn', 'install', '--immutable'];
191
- if (existsSync(join(dir, 'pnpm-lock.yaml')))
192
- return ['pnpm', 'install', '--frozen-lockfile'];
193
- if (existsSync(join(dir, 'package-lock.json')))
194
- return ['npm', 'ci'];
195
- return null;
150
+ /** Logs how long one stage of a run took, as `Stage <name>: <seconds>s` (the benchmark reads these lines). */
151
+ function stageDone(log, name, since) {
152
+ log(`Stage ${name}: ${((Date.now() - since) / 1000).toFixed(1)}s`);
196
153
  }
197
154
  /** The last lines of a log file with every value of the preview environment hidden. */
198
155
  export function tailOf(file, secrets, lines = 20) {
@@ -207,12 +164,15 @@ export function tailOf(file, secrets, lines = 20) {
207
164
  for (const s of [...secrets].sort((a, b) => b.length - a.length))
208
165
  if (s.length >= 4)
209
166
  out = out.split(s).join('[hidden]');
210
- return out;
167
+ // The raw value is not the only way a secret shows in a log: URL encoded, base64 and the shape of a token are removed as well
168
+ return redactText(out, secrets);
211
169
  }
212
- /** Waits until GET url answers with a status below 500. Gives up after `timeoutMs`, or at once when `exited` says the process is gone. */
170
+ /** Waits until GET url answers with a status below 500. Gives up after `timeoutMs`, at once when `exited` says the process is gone, or when the signal aborts. */
213
171
  export async function waitReady(url, opts) {
214
172
  const until = Date.now() + opts.timeoutMs;
215
173
  while (Date.now() < until) {
174
+ if (opts.signal?.aborted)
175
+ return 'aborted';
216
176
  if (opts.exited())
217
177
  return 'exited';
218
178
  try {
@@ -226,6 +186,8 @@ export async function waitReady(url, opts) {
226
186
  }
227
187
  await new Promise((r) => setTimeout(r, opts.intervalMs ?? 500));
228
188
  }
189
+ if (opts.signal?.aborted)
190
+ return 'aborted';
229
191
  return opts.exited() ? 'exited' : 'timeout';
230
192
  }
231
193
  /** Picks a free port on 127.0.0.1 and refuses a forbidden one. */
@@ -235,22 +197,92 @@ export async function pickPort() {
235
197
  throw new Error(`Port ${port} is not one a check may use.`);
236
198
  return port;
237
199
  }
200
+ /**
201
+ * The preview of a live site: nothing is copied, built or started. A loopback address on a port a check never uses is refused. The run is unverified:
202
+ * there is no commit and no diff hash, and the note says so.
203
+ */
204
+ export function liveSitePreview(cfg) {
205
+ let u;
206
+ try {
207
+ u = new URL(String(cfg.url));
208
+ }
209
+ catch {
210
+ throw new Error('The live site address is not a web address.');
211
+ }
212
+ if (u.protocol !== 'http:' && u.protocol !== 'https:')
213
+ throw new Error('The live site address must start with http:// or https://.');
214
+ if (LOOPBACK.has(u.hostname.toLowerCase())) {
215
+ const port = u.port ? Number(u.port) : u.protocol === 'https:' ? 443 : 80;
216
+ if (FORBIDDEN_PORTS.has(port))
217
+ throw new Error(`Port ${port} is not one a check may use.`);
218
+ }
219
+ return {
220
+ baseUrl: cfg.url,
221
+ commit: null,
222
+ diffHash: null,
223
+ testUser: cfg.testUser ?? null,
224
+ secrets: cfg.testUser ? [cfg.testUser.password] : [],
225
+ note: 'This is a live site: nothing was built or started, so the run is unverified (no commit is recorded).'
226
+ };
227
+ }
238
228
  export async function startPreview(cfg, opts) {
239
- const { runId, scope, log } = opts;
229
+ const { runId, scope, log, signal } = opts;
230
+ const stopIfAborted = () => {
231
+ if (signal?.aborted)
232
+ throw new Error(STOPPED);
233
+ };
234
+ // -- 0. A LIVE SITE: NOTHING TO MAKE -- //
235
+ if (cfg.url)
236
+ return liveSitePreview(cfg);
237
+ // The app folder is checked in the live repo first, so a bad one stops before anything is copied
238
+ if (cfg.appDir) {
239
+ const problem = appDirProblem(cfg.repo, cfg.appDir);
240
+ if (problem)
241
+ throw new Error(problem);
242
+ }
240
243
  // -- 1. THE MARKER COMES FIRST -- //
241
244
  scope.start();
242
- const runDir = ensureDir(under(runsDir(), runId));
245
+ // The run folder is the child's to write; the logs hold what the app printed and live where the child cannot read (paths.ts logsDir)
246
+ ensureDir(under(runsDir(), runId));
247
+ const logDir = ensureDir(logDirFor(runId));
243
248
  const notes = [];
244
249
  const secrets = [];
250
+ const known = opts.needles ?? [];
251
+ const learn = (...values) => {
252
+ for (const v of values)
253
+ if (typeof v === 'string' && v.length >= 6 && !known.includes(v))
254
+ known.push(v);
255
+ };
245
256
  // -- 2. A PRIVATE COPY -- //
246
257
  const copyDir = under(copiesDir(), runId);
247
258
  scope.setInfo({ copyDir });
248
259
  scope.add('private copy', () => removeTree(copyDir));
249
- const made = await makePrivateCopy(cfg.repo, copyDir, log);
250
- log(`Made a private copy of ${made.files} file(s).`);
260
+ const copyStart = Date.now();
261
+ // The tree is hashed before the copy and again after it: a file saved while the copy was made would otherwise be tested but described by another hash
262
+ let hashBefore = await hashChanges(cfg.repo);
263
+ let calls = 0;
264
+ const copyOnce = () => makePrivateCopy(cfg.repo, copyDir, {
265
+ log,
266
+ afterCopy: opts.afterCopy ? () => opts.afterCopy?.(++calls) : undefined
267
+ });
268
+ let made = await copyOnce();
269
+ let diffHash = await hashChanges(cfg.repo);
270
+ if (diffHash !== hashBefore) {
271
+ log('The repo changed while it was being copied; copying again.');
272
+ removeTree(copyDir);
273
+ hashBefore = diffHash;
274
+ made = await copyOnce();
275
+ diffHash = await hashChanges(cfg.repo);
276
+ if (diffHash !== hashBefore)
277
+ notes.push('The repo kept changing while it was copied, so the diff hash may not describe exactly the files that were tested.');
278
+ }
279
+ stopIfAborted();
280
+ log(`Made a private copy of ${made.files} changed file(s) at ${made.head ? made.head.slice(0, 8) : 'no commit'}.`);
281
+ stageDone(log, 'copy', copyStart);
282
+ // The app folder is walked again inside the copy: a link or a missing package.json there stops the run
283
+ const appCwd = sandboxAppRoot(made.repoInCopy, cfg.appDir);
251
284
  // -- 3. WHAT WAS TESTED -- //
252
- const commit = await headCommit(cfg.repo);
253
- const diffHash = await hashChanges(cfg.repo);
285
+ const commit = made.head;
254
286
  // -- 4. A FREE PORT -- //
255
287
  const port = await pickPort();
256
288
  const baseUrl = `http://127.0.0.1:${port}`;
@@ -258,62 +290,240 @@ export async function startPreview(cfg, opts) {
258
290
  let databaseUrl;
259
291
  let ownerUrl;
260
292
  if (cfg.needsDatabase) {
261
- const db = await makeThrowawayDatabase(runId, scope, log, { runtimeRole: cfg.runtimeRole });
293
+ const databaseStart = Date.now();
294
+ // One pool container per machine and a clone per run. The app migrates and seeds itself at start (AFB_AUTO_MIGRATE), so the template
295
+ // is an empty database that every run clones; it falls back to a container per run when the pool is not available
296
+ const db = await provisionDatabase(runId, scope, log, {
297
+ runtimeRole: cfg.runtimeRole,
298
+ template: {
299
+ recipeHash: createHash('sha256')
300
+ .update(JSON.stringify([cfg.build, cfg.start, cfg.runtimeRole ?? '']))
301
+ .digest('hex')
302
+ .slice(0, 16),
303
+ migrationHash: 'self-migrating',
304
+ warm: async () => undefined
305
+ }
306
+ });
262
307
  databaseUrl = db.databaseUrl;
263
308
  ownerUrl = db.ownerUrl;
264
309
  secrets.push(db.databaseUrl, db.ownerUrl);
310
+ learn(db.databaseUrl, db.ownerUrl);
265
311
  notes.push('A throwaway database was made for this run.');
312
+ stageDone(log, 'database', databaseStart);
266
313
  }
267
314
  // -- ENVIRONMENT -- //
268
- const built = buildPreviewEnv({
269
- repo: cfg.repo,
270
- recipeEnv: cfg.env,
271
- port,
272
- baseUrl,
273
- databaseUrl,
274
- databaseOwnerUrl: ownerUrl
275
- });
276
- notes.push(...built.notes);
277
- const redact = Object.values(built.env).filter((v) => v.length >= 8);
278
- if (cfg.testUser)
315
+ // Sealed values ({secret:NAME}) are opened from this registration's own store only; service and mail keys are for the start alone, the build never sees them
316
+ let recipeEnv = cfg.env;
317
+ let startOnly = [];
318
+ let sealedHosts = [];
319
+ let sealedReds = [];
320
+ let sealedNames = [];
321
+ let mailInbox = null;
322
+ if (cfg.registration && Object.values(cfg.env).some((v) => v.includes('{secret:'))) {
323
+ const opened = resolveSealedEnv(cfg.registration.org, cfg.registration.name, cfg.env);
324
+ recipeEnv = opened.env;
325
+ startOnly = opened.startOnly;
326
+ sealedHosts = opened.hosts;
327
+ sealedReds = opened.redactions;
328
+ sealedNames = [...opened.filled];
329
+ mailInbox = opened.mailInbox;
330
+ secrets.push(...opened.redactions);
331
+ learn(...opened.redactions);
332
+ notes.push(`Opened ${opened.filled.length} sealed value(s) (names only: ${opened.filled.join(', ')}).`);
333
+ }
334
+ // A registration whose owner approved it adopts the whole repo .env (database values are refused, not stripped); any other keeps the stricter rule
335
+ let startEnv;
336
+ let buildEnv;
337
+ let adoptedReds = [];
338
+ if (cfg.adoptEnv) {
339
+ const adopted = buildAdoptedEnv({
340
+ repo: cfg.repo,
341
+ appDir: cfg.appDir,
342
+ recipeEnv,
343
+ port,
344
+ baseUrl,
345
+ databaseUrl,
346
+ databaseOwnerUrl: ownerUrl,
347
+ sealedNames,
348
+ testHost: sealedHosts[0]
349
+ });
350
+ if (adopted.problems.length)
351
+ throw new Error(adopted.problems.join(' '));
352
+ startEnv = adopted.env;
353
+ buildEnv = adopted.buildEnv;
354
+ // The address of the preview itself is not a secret: keeping it out of the redactions keeps the run log readable
355
+ adoptedReds = adopted.redactions.filter((v) => !v.includes(baseUrl));
356
+ notes.push(...adopted.notes);
357
+ }
358
+ else {
359
+ const built = buildPreviewEnv({
360
+ repo: cfg.repo,
361
+ recipeEnv,
362
+ port,
363
+ baseUrl,
364
+ databaseUrl,
365
+ databaseOwnerUrl: ownerUrl
366
+ });
367
+ startEnv = built.env;
368
+ buildEnv = built.env;
369
+ notes.push(...built.notes);
370
+ }
371
+ buildEnv = omitKeys(buildEnv, startOnly);
372
+ const mailLog = join(logDir, 'mail-guard.jsonl');
373
+ if (mailInbox)
374
+ startEnv = withMailGuard(startEnv, { inbox: mailInbox, log: mailLog });
375
+ const redact = [
376
+ ...Object.values(startEnv).filter((v) => v.length >= 6),
377
+ ...Object.values(buildEnv).filter((v) => v.length >= 6),
378
+ ...secrets,
379
+ ...sealedReds,
380
+ ...adoptedReds
381
+ ];
382
+ secrets.push(...adoptedReds);
383
+ if (cfg.testUser) {
279
384
  secrets.push(cfg.testUser.password);
385
+ redact.push(cfg.testUser.password);
386
+ }
387
+ learn(...redact);
388
+ stopIfAborted();
280
389
  // -- 6. INSTALL AND BUILD -- //
281
- const buildLog = openSync(join(runDir, 'build.log'), 'a');
390
+ const buildLogFile = join(logDir, 'build.log');
391
+ const buildLog = openSync(buildLogFile, 'a');
392
+ // The command of a stage ended without an exit code: it timed out, was killed or was stopped. That is the machine's trouble, not the code's, so a run that waited for it may try again
393
+ const stageFailed = (what, code) => stageError(what, code, Boolean(signal?.aborted), () => tailOf(buildLogFile, redact));
282
394
  try {
283
- const install = installCommand(copyDir);
284
- if (install) {
285
- log('Installing dependencies in the private copy.');
286
- const code = await runToEnd(install, {
287
- cwd: copyDir,
288
- env: built.env,
289
- out: buildLog,
290
- timeoutMs: BUILD_TIMEOUT_MS
291
- });
292
- if (code !== 0)
293
- throw new Error(`The install failed (exit ${code}). Last lines:\n${tailOf(join(runDir, 'build.log'), redact)}`);
395
+ const installStart = Date.now();
396
+ // A monorepo keeps its lock file above the app folder: the install runs where the lock file is
397
+ const installDir = installDirFor(appCwd, copyDir);
398
+ const install = installDir ? installCommand(installDir) : null;
399
+ if (!install || !installDir)
400
+ log('No lockfile, install skipped.');
401
+ if (install && installDir) {
402
+ const doInstall = async () => {
403
+ stopIfAborted();
404
+ log('Installing dependencies in the private copy.');
405
+ const code = await runToEnd(install, {
406
+ cwd: installDir,
407
+ env: buildEnv,
408
+ out: buildLog,
409
+ timeoutMs: BUILD_TIMEOUT_MS,
410
+ scope,
411
+ name: 'install process',
412
+ signal
413
+ });
414
+ if (code !== 0)
415
+ throw stageFailed('install', code);
416
+ };
417
+ // Dependencies are kept per registration by lockfile hash, node, platform, architecture and package manager. Runs that need the same set
418
+ // install once (single flight) and the others restore it; a pnpm install is never kept (its modules name the store location)
419
+ const pm = packageManagerOf(installDir);
420
+ const depsCacheRoot = cfg.registration
421
+ ? cacheDirFor(cfg.registration.org, cfg.registration.name)
422
+ : null;
423
+ const dkey = depsCacheRoot && pm && installCacheable(installDir)
424
+ ? depsKey({
425
+ lockHash: lockHash(installDir),
426
+ node: process.version,
427
+ platform: process.platform,
428
+ arch: process.arch,
429
+ pm,
430
+ pmVersion: await packageManagerVersion(pm, installDir, buildEnv)
431
+ })
432
+ : null;
433
+ if (depsCacheRoot && dkey) {
434
+ const flight = await singleFlight(`deps-${dkey.slice(0, 32)}`, async () => {
435
+ if (hasEntry(depsCacheRoot, 'deps', dkey))
436
+ return { hit: true };
437
+ await doInstall();
438
+ await publishDepsAsync(depsCacheRoot, dkey, installDir);
439
+ return { hit: false };
440
+ }, { signal });
441
+ const installedHere = flight.role === 'builder' && !flight.result.hit;
442
+ if (!installedHere) {
443
+ const restored = await restoreOrNull('dependencies', () => restoreDepsAsync(depsCacheRoot, dkey, installDir, { link: 'auto' }), log);
444
+ if (restored)
445
+ log(`Restored the kept dependencies (${restored.files} files).`);
446
+ else
447
+ await doInstall();
448
+ }
449
+ evict(depsCacheRoot, { builds: KEEP_BUILDS, deps: KEEP_DEPS, maxAgeDays: 14 });
450
+ }
451
+ else
452
+ await doInstall();
294
453
  }
454
+ stageDone(log, 'install', installStart);
455
+ stopIfAborted();
456
+ const buildStart = Date.now();
295
457
  if (cfg.build) {
296
- log('Building the project.');
297
- const code = await runToEnd(words(cfg.build), {
298
- cwd: copyDir,
299
- env: built.env,
300
- out: buildLog,
301
- timeoutMs: BUILD_TIMEOUT_MS
302
- });
303
- if (code !== 0)
304
- throw new Error(`The build failed (exit ${code}). Last lines:\n${tailOf(join(runDir, 'build.log'), redact)}`);
458
+ const doBuild = async () => {
459
+ stopIfAborted();
460
+ log('Building the project.');
461
+ const code = await runToEnd(words(cfg.build), {
462
+ cwd: appCwd,
463
+ env: buildEnv,
464
+ out: buildLog,
465
+ timeoutMs: BUILD_TIMEOUT_MS,
466
+ scope,
467
+ name: 'build process',
468
+ signal
469
+ });
470
+ if (code !== 0)
471
+ throw stageFailed('build', code);
472
+ };
473
+ // A finished build is kept per registration when nothing that decides it is per run (no port, base url, database url or secret in the
474
+ // command or the environment, adopted values included). Runs that need the same build build it once and the others restore it
475
+ const buildRoot = cfg.registration
476
+ ? cacheDirFor(cfg.registration.org, cfg.registration.name)
477
+ : null;
478
+ const tree = treeId({ commit, diffHash });
479
+ const bkey = buildRoot && tree
480
+ ? buildKey({
481
+ org: cfg.registration?.org ?? '',
482
+ name: cfg.registration?.name ?? '',
483
+ treeId: tree,
484
+ surface: pinFiles(cfg.repo),
485
+ build: cfg.build,
486
+ env: buildEnv,
487
+ secretValues: secrets,
488
+ appDir: cfg.appDir ?? ''
489
+ })
490
+ : null;
491
+ if (buildRoot && bkey) {
492
+ const before = await snapshotAsync(appCwd);
493
+ const flight = await singleFlight(`build-${bkey.slice(0, 32)}`, async () => {
494
+ if (hasEntry(buildRoot, 'builds', bkey))
495
+ return { hit: true };
496
+ await doBuild();
497
+ await publishBuildAsync(buildRoot, bkey, appCwd, before);
498
+ return { hit: false };
499
+ }, { signal });
500
+ const builtHere = flight.role === 'builder' && !flight.result.hit;
501
+ if (!builtHere) {
502
+ const restored = await restoreOrNull('build', () => restoreBuildAsync(buildRoot, bkey, appCwd, { link: 'auto' }), log);
503
+ if (restored)
504
+ log(`Restored the kept build (${restored.files} files).`);
505
+ else
506
+ await doBuild();
507
+ }
508
+ }
509
+ else
510
+ await doBuild();
305
511
  }
512
+ stageDone(log, 'build', buildStart);
306
513
  }
307
514
  finally {
308
515
  closeSync(buildLog);
309
516
  }
517
+ stopIfAborted();
310
518
  // -- 7. START -- //
311
- const startLog = openSync(join(runDir, 'preview.log'), 'a');
519
+ const startStageStart = Date.now();
520
+ const previewLogFile = join(logDir, 'preview.log');
521
+ const startLog = openSync(previewLogFile, 'a');
312
522
  let child;
313
523
  try {
314
524
  child = await launch(words(cfg.start.split('{port}').join(String(port))), {
315
- cwd: copyDir,
316
- env: built.env,
525
+ cwd: appCwd,
526
+ env: startEnv,
317
527
  out: startLog,
318
528
  detached: process.platform !== 'win32'
319
529
  });
@@ -330,14 +540,29 @@ export async function startPreview(cfg, opts) {
330
540
  }
331
541
  // -- 8. WAIT UNTIL IT ANSWERS -- //
332
542
  const readyUrl = baseUrl + (cfg.ready ?? '/');
333
- const state = await waitReady(readyUrl, { timeoutMs: READY_TIMEOUT_MS, exited: () => exited });
543
+ const state = await waitReady(readyUrl, {
544
+ timeoutMs: READY_TIMEOUT_MS,
545
+ exited: () => exited,
546
+ signal
547
+ });
548
+ if (state === 'aborted')
549
+ throw new Error(STOPPED);
334
550
  if (state !== 'ready') {
335
551
  const why = state === 'exited'
336
552
  ? 'The preview stopped before it answered.'
337
553
  : 'The preview did not answer within 120 seconds.';
338
- throw new Error(`${why} Last lines:\n${tailOf(join(runDir, 'preview.log'), redact)}`);
554
+ throw new Error(`${why} Last lines:\n${tailOf(previewLogFile, redact)}`);
339
555
  }
340
556
  log('The preview answers.');
557
+ stageDone(log, 'start-and-ready', startStageStart);
558
+ // The mail guard must have reported listening on the preview port, or mail may not have been fenced and the run does not count
559
+ if (mailInbox) {
560
+ const fence = readFence(mailLog, port);
561
+ const problem = fenceProblem(true, fence);
562
+ if (problem)
563
+ throw new Error(problem);
564
+ notes.push(describeFence(mailInbox, fence));
565
+ }
341
566
  // -- 9. RESULT -- //
342
567
  return {
343
568
  baseUrl,
@@ -345,6 +570,8 @@ export async function startPreview(cfg, opts) {
345
570
  diffHash,
346
571
  testUser: cfg.testUser ?? null,
347
572
  secrets,
348
- note: notes.join(' ')
573
+ note: notes.join(' '),
574
+ extraHosts: sealedHosts,
575
+ ...(mailInbox ? { mail: { inbox: mailInbox, log: mailLog, port } } : {})
349
576
  };
350
577
  }