@miraland-labs/conduit-bridge 0.16.186 → 0.16.188
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/dist/cli.js +6 -1
- package/dist/ensure-test-evidence.js +188 -2
- package/dist/preflight.js +41 -6
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -780,7 +780,12 @@ function startGateRehearsal(workspace, plan) {
|
|
|
780
780
|
const rehearsalPath = rehearsalWorktreePath(workspace);
|
|
781
781
|
console.log(`Rehearsing the gate in ${workspace}: ${plan.commands.join(", ")}`);
|
|
782
782
|
void createRehearsalWorktree(workspace, plan.commit)
|
|
783
|
-
.then((path) => runGateRehearsal(path, plan
|
|
783
|
+
.then((path) => runGateRehearsal(path, plan, {
|
|
784
|
+
freshWorkspace: (warmUpSeconds) => {
|
|
785
|
+
console.log(`Gate warm-up took ${warmUpSeconds} s; measuring again in a fresh worktree`);
|
|
786
|
+
return createRehearsalWorktree(workspace, plan.commit);
|
|
787
|
+
},
|
|
788
|
+
}))
|
|
784
789
|
.then((rehearsal) => { if (rehearsal === "busy")
|
|
785
790
|
console.log("Gate rehearsal deferred: another runner on this computer is rehearsing"); })
|
|
786
791
|
.catch((error) => console.error(`Gate rehearsal failed: ${redactSecrets(error instanceof Error ? error.message : "unknown error")}`))
|
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { spawn } from "node:child_process";
|
|
7
7
|
import { createHash } from "node:crypto";
|
|
8
|
-
import { mkdir, mkdtemp, readFile, realpath, rm } from "node:fs/promises";
|
|
9
|
-
import {
|
|
8
|
+
import { access, copyFile, mkdir, mkdtemp, readFile, readdir, realpath, rm } from "node:fs/promises";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { join, relative, sep } from "node:path";
|
|
10
11
|
import { untrustedCodeTempRoot, untrustedCodeSpawn, untrustedCodeEnvironment, criterionKey, resolveCriterionReference, signalGroup, STDIO_DRAIN_MS, CHILD_STOP_GRACE_MS, OWN_PROCESS_GROUP, trackProcessGroup, untrackProcessGroup } from "./driver.js";
|
|
11
12
|
import { isBoundedVerificationCommand, isRunnableVerificationCommand } from "./execution-class.js";
|
|
12
13
|
import { workspaceDependenciesMissingFailure } from "./failure-signal.js";
|
|
@@ -417,6 +418,190 @@ export async function fetchLockedDependencies(workspace, options = {}) {
|
|
|
417
418
|
console.error(redactSecrets(`[bridge] cargo fetch --locked exited ${result.code} in ${workspace}: ${result.stderr.trim().split("\n").slice(-10).join("\n")}`));
|
|
418
419
|
return { ran: true, code: result.code };
|
|
419
420
|
}
|
|
421
|
+
const NPM_CI_ARGV = ["ci", "--ignore-scripts", "--no-audit", "--no-fund", "--prefer-offline"];
|
|
422
|
+
/** Root is depth 0. `web/` is depth 1. `packages/<name>/` is depth 2. Nothing deeper. */
|
|
423
|
+
const MAX_PACKAGE_DIRECTORY_DEPTH = 2;
|
|
424
|
+
/** Discovery does not enter these directories. `node_modules` is recorded separately and not entered. */
|
|
425
|
+
const PACKAGE_DISCOVERY_EXCLUDED = new Set([".conduit", ".git", "dist", "target"]);
|
|
426
|
+
/** Package-directory realpath + npm lock sha256 already attempted by this process. */
|
|
427
|
+
const installedLocks = new Set();
|
|
428
|
+
/**
|
|
429
|
+
* The gate runs without network, so a package the lock pins must already be on disk. Node resolves
|
|
430
|
+
* from the directory that holds the gate and walks upward: a repository whose JavaScript lives in
|
|
431
|
+
* `web/` with its own lock, and whose root is not a package, never reaches an install. `npm ci`
|
|
432
|
+
* fills that directory outside the sandbox and refuses to change the lock. Once per lock file per
|
|
433
|
+
* process, including a failed install. A failed install does not stop the gate: its own output
|
|
434
|
+
* names the cause.
|
|
435
|
+
*
|
|
436
|
+
* `package-lock.json` and `npm-shrinkwrap.json` are npm locks. A package with only `yarn.lock` or
|
|
437
|
+
* `pnpm-lock.yaml`, a package with no npm lock, and a package whose `node_modules` git does not
|
|
438
|
+
* ignore are skipped and logged. Discovery does not enter `.git`, `.conduit`, `dist`, `node_modules`,
|
|
439
|
+
* or `target`.
|
|
440
|
+
*/
|
|
441
|
+
function runGit(argv, cwd, env = process.env) {
|
|
442
|
+
return new Promise((resolve) => {
|
|
443
|
+
let settled = false;
|
|
444
|
+
let timer;
|
|
445
|
+
const finish = (code, stdout) => {
|
|
446
|
+
if (settled)
|
|
447
|
+
return;
|
|
448
|
+
settled = true;
|
|
449
|
+
clearTimeout(timer);
|
|
450
|
+
resolve({ code, stdout });
|
|
451
|
+
};
|
|
452
|
+
const child = spawn("git", argv, { cwd, env, stdio: ["ignore", "pipe", "ignore"] });
|
|
453
|
+
let stdout = "";
|
|
454
|
+
child.stdout?.setEncoding("utf8");
|
|
455
|
+
child.stdout?.on("data", (chunk) => {
|
|
456
|
+
if (stdout.length < 8192)
|
|
457
|
+
stdout = `${stdout}${chunk}`.slice(0, 8192);
|
|
458
|
+
});
|
|
459
|
+
timer = setTimeout(() => { child.kill("SIGKILL"); finish(124, stdout); }, VERIFICATION_TIMEOUT_MS);
|
|
460
|
+
child.on("error", () => finish(127, stdout));
|
|
461
|
+
child.on("close", (code) => finish(code ?? 1, stdout));
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
function gitCommandEnv() {
|
|
465
|
+
const env = { ...process.env };
|
|
466
|
+
delete env.GIT_DIR;
|
|
467
|
+
delete env.GIT_WORK_TREE;
|
|
468
|
+
delete env.GIT_INDEX_FILE;
|
|
469
|
+
return env;
|
|
470
|
+
}
|
|
471
|
+
/** A directory outside `workspace`, so a crash cannot leave a probe inside the attempt tree. */
|
|
472
|
+
function probeParentDirectory(workspace) {
|
|
473
|
+
for (const candidate of [tmpdir(), "/tmp", "/var/tmp"]) {
|
|
474
|
+
const rel = relative(workspace, candidate);
|
|
475
|
+
if (rel === ".." || rel.startsWith(`..${sep}`))
|
|
476
|
+
return candidate;
|
|
477
|
+
}
|
|
478
|
+
return tmpdir();
|
|
479
|
+
}
|
|
480
|
+
async function copyGitignoreIfPresent(from, to) {
|
|
481
|
+
try {
|
|
482
|
+
await copyFile(join(from, ".gitignore"), join(to, ".gitignore"));
|
|
483
|
+
return true;
|
|
484
|
+
}
|
|
485
|
+
catch (error) {
|
|
486
|
+
return error.code === "ENOENT";
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* A directory-only rule (`node_modules/`) matches only a directory on disk. The probe directory is
|
|
491
|
+
* created in a temporary work tree, never in the attempt tree. `-c core.excludesFile=/dev/null`
|
|
492
|
+
* keeps the repository's own ignore rules and drops the runner's global excludes, so a personal
|
|
493
|
+
* ignore cannot hide a node_modules git would track.
|
|
494
|
+
*/
|
|
495
|
+
async function nodeModulesIsGitIgnored(directory) {
|
|
496
|
+
const env = gitCommandEnv();
|
|
497
|
+
const located = await runGit(["rev-parse", "--show-toplevel", "--absolute-git-dir", "--show-prefix"], directory, env);
|
|
498
|
+
if (located.code !== 0)
|
|
499
|
+
return false;
|
|
500
|
+
const lines = located.stdout.split("\n");
|
|
501
|
+
const toplevel = lines[0]?.trim() ?? "";
|
|
502
|
+
const gitDir = lines[1]?.trim() ?? "";
|
|
503
|
+
const prefix = lines[2] ?? "";
|
|
504
|
+
if (!toplevel || !gitDir)
|
|
505
|
+
return false;
|
|
506
|
+
const temp = await mkdtemp(join(probeParentDirectory(directory), "conduit-npm-ignore-"));
|
|
507
|
+
try {
|
|
508
|
+
let source = toplevel;
|
|
509
|
+
let target = temp;
|
|
510
|
+
if (!await copyGitignoreIfPresent(source, target))
|
|
511
|
+
return false;
|
|
512
|
+
for (const segment of prefix.split("/").filter(Boolean)) {
|
|
513
|
+
source = join(source, segment);
|
|
514
|
+
target = join(target, segment);
|
|
515
|
+
await mkdir(target, { recursive: true });
|
|
516
|
+
if (!await copyGitignoreIfPresent(source, target))
|
|
517
|
+
return false;
|
|
518
|
+
}
|
|
519
|
+
await mkdir(join(target, "node_modules"));
|
|
520
|
+
const relativeDir = prefix === "" || prefix.endsWith("/") ? prefix : `${prefix}/`;
|
|
521
|
+
const checked = await runGit([
|
|
522
|
+
"--git-dir", gitDir, "--work-tree", temp, "-c", "core.excludesFile=/dev/null",
|
|
523
|
+
"check-ignore", "-q", "--", `${relativeDir}node_modules`,
|
|
524
|
+
], temp, env);
|
|
525
|
+
return checked.code === 0;
|
|
526
|
+
}
|
|
527
|
+
finally {
|
|
528
|
+
await rm(temp, { recursive: true, force: true });
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
async function scanPackageDirectories(root) {
|
|
532
|
+
const packages = [];
|
|
533
|
+
const skipped = [];
|
|
534
|
+
const walk = async (directory, depth) => {
|
|
535
|
+
if (await readFile(join(directory, "package.json")).then(() => true, () => false))
|
|
536
|
+
packages.push(directory);
|
|
537
|
+
if (depth >= MAX_PACKAGE_DIRECTORY_DEPTH)
|
|
538
|
+
return;
|
|
539
|
+
const entries = await readdir(directory, { withFileTypes: true }).catch(() => []);
|
|
540
|
+
for (const entry of entries) {
|
|
541
|
+
if (!entry.isDirectory())
|
|
542
|
+
continue;
|
|
543
|
+
const child = join(directory, entry.name);
|
|
544
|
+
if (entry.name === "node_modules") {
|
|
545
|
+
skipped.push({ directory: child, reason: "package is under node_modules" });
|
|
546
|
+
continue;
|
|
547
|
+
}
|
|
548
|
+
if (PACKAGE_DISCOVERY_EXCLUDED.has(entry.name))
|
|
549
|
+
continue;
|
|
550
|
+
await walk(child, depth + 1);
|
|
551
|
+
}
|
|
552
|
+
};
|
|
553
|
+
await walk(root, 0);
|
|
554
|
+
packages.sort();
|
|
555
|
+
return { packages, skipped };
|
|
556
|
+
}
|
|
557
|
+
/** npm uses `npm-shrinkwrap.json` when both lock files exist. */
|
|
558
|
+
async function readNpmLock(directory) {
|
|
559
|
+
const shrink = await readFile(join(directory, "npm-shrinkwrap.json")).catch(() => null);
|
|
560
|
+
if (shrink)
|
|
561
|
+
return shrink;
|
|
562
|
+
return readFile(join(directory, "package-lock.json")).catch(() => null);
|
|
563
|
+
}
|
|
564
|
+
async function missingNpmLockReason(directory) {
|
|
565
|
+
const yarn = await access(join(directory, "yarn.lock")).then(() => true, () => false);
|
|
566
|
+
const pnpm = await access(join(directory, "pnpm-lock.yaml")).then(() => true, () => false);
|
|
567
|
+
if (yarn && pnpm)
|
|
568
|
+
return "yarn.lock and pnpm-lock.yaml are not npm installs";
|
|
569
|
+
if (yarn)
|
|
570
|
+
return "yarn.lock is not an npm install";
|
|
571
|
+
if (pnpm)
|
|
572
|
+
return "pnpm-lock.yaml is not an npm install";
|
|
573
|
+
return "no package-lock.json or npm-shrinkwrap.json";
|
|
574
|
+
}
|
|
575
|
+
export async function installLockedPackages(workspace, options = {}) {
|
|
576
|
+
const installed = [];
|
|
577
|
+
const skipped = [];
|
|
578
|
+
const run = options.run ?? runFetchOutsideSandbox;
|
|
579
|
+
const scan = await scanPackageDirectories(workspace);
|
|
580
|
+
skipped.push(...scan.skipped);
|
|
581
|
+
for (const directory of scan.packages) {
|
|
582
|
+
const lock = await readNpmLock(directory);
|
|
583
|
+
if (!lock) {
|
|
584
|
+
skipped.push({ directory, reason: await missingNpmLockReason(directory) });
|
|
585
|
+
continue;
|
|
586
|
+
}
|
|
587
|
+
const key = `${await realpath(directory)}\0${createHash("sha256").update(lock).digest("hex")}`;
|
|
588
|
+
if (installedLocks.has(key))
|
|
589
|
+
continue;
|
|
590
|
+
if (!await nodeModulesIsGitIgnored(directory)) {
|
|
591
|
+
skipped.push({ directory, reason: "node_modules is not git-ignored" });
|
|
592
|
+
continue;
|
|
593
|
+
}
|
|
594
|
+
const result = await run("npm", [...NPM_CI_ARGV], directory);
|
|
595
|
+
installedLocks.add(key);
|
|
596
|
+
if (result.code !== 0)
|
|
597
|
+
console.error(redactSecrets(`[bridge] npm ci --ignore-scripts --no-audit --no-fund --prefer-offline exited ${result.code} in ${directory}: ${result.stderr.trim().split("\n").slice(-10).join("\n")}`));
|
|
598
|
+
installed.push({ directory, code: result.code });
|
|
599
|
+
}
|
|
600
|
+
skipped.sort((left, right) => left.directory < right.directory ? -1 : left.directory > right.directory ? 1 : 0);
|
|
601
|
+
for (const skip of skipped)
|
|
602
|
+
console.error(redactSecrets(`[bridge] npm ci skipped ${skip.directory}: ${skip.reason}`));
|
|
603
|
+
return { installed, skipped };
|
|
604
|
+
}
|
|
420
605
|
/**
|
|
421
606
|
* The bounded runner every Bridge-run verification uses: the declared command, in the given
|
|
422
607
|
* workspace, with the verification timeout and buffer. A non-zero exit is a result, not a throw.
|
|
@@ -486,6 +671,7 @@ export async function runBoundedVerificationCommand(command, workspace, timeoutM
|
|
|
486
671
|
};
|
|
487
672
|
const output = () => [lines.text().replace(/\n$/, ""), partial.stdout.text(), partial.stderr.text()].filter(Boolean).join("\n");
|
|
488
673
|
await fetchLockedDependencies(workspace);
|
|
674
|
+
await installLockedPackages(workspace);
|
|
489
675
|
const tempRoot = untrustedCodeTempRoot(workspace);
|
|
490
676
|
await mkdir(tempRoot, { recursive: true });
|
|
491
677
|
const scratchHome = await mkdtemp(join(tempRoot, "gate-home-"));
|
package/dist/preflight.js
CHANGED
|
@@ -7,7 +7,8 @@ import { dirname, join } from "node:path";
|
|
|
7
7
|
import { promisify } from "node:util";
|
|
8
8
|
import { buildWorkspaceBrief, normalizeRepositoryUrl } from "./brief.js";
|
|
9
9
|
import { bridgeConfigDirectory } from "./config.js";
|
|
10
|
-
import { runBoundedVerificationCommand, verificationEvidenceCommands } from "./ensure-test-evidence.js";
|
|
10
|
+
import { runBoundedVerificationCommand, VERIFICATION_TIMEOUT_MS, verificationEvidenceCommands } from "./ensure-test-evidence.js";
|
|
11
|
+
import { lockfileDigest } from "./execution-facts.js";
|
|
11
12
|
import { resolveAgentTimeout, resolveMaxTurns } from "./execution-budget.js";
|
|
12
13
|
import { hasAntigravityLogin, hasClaudeLogin, hasGrokLogin, hasOpenAiLogin, hasOpenCodeLogin, driverExecutable, readSignInStatus, } from "./driver.js";
|
|
13
14
|
import { laneQuota, laneTierBindings, localFuelOnlyDriver, onlineDriverIds, resolveDriverFuel, supportsReadOnlyDiagnosis } from "./drivers.js";
|
|
@@ -315,18 +316,29 @@ async function readGateRehearsals(statePath) {
|
|
|
315
316
|
return {};
|
|
316
317
|
}
|
|
317
318
|
}
|
|
319
|
+
/**
|
|
320
|
+
* The rehearsal's own limit. The first run after a dependency change can compile a whole dependency
|
|
321
|
+
* tree: on 2026-10-22 acme-sim's registry crates took more than ten minutes, the attempt limit
|
|
322
|
+
* stopped the rehearsal mid-compile, the shared target directory stayed cold, and the next attempt
|
|
323
|
+
* stopped at the same limit before any agent started. The rehearsal absorbs that warm-up.
|
|
324
|
+
*/
|
|
325
|
+
export const REHEARSAL_TIMEOUT_MS = 3_600_000;
|
|
318
326
|
/** UTC calendar day: a push to main costs no run, and a new day measures the cache again. */
|
|
319
327
|
function rehearsalDay(at) {
|
|
320
328
|
return new Date(at).toISOString().slice(0, 10);
|
|
321
329
|
}
|
|
322
|
-
/**
|
|
330
|
+
/**
|
|
331
|
+
* The last rehearsal of this workspace's repository, and the gate to run when one is due: once a
|
|
332
|
+
* day, and again when the workspace's lockfiles change, because new dependencies need a new warm-up.
|
|
333
|
+
*/
|
|
323
334
|
export async function planGateRehearsal(brief, options = {}) {
|
|
324
335
|
const commands = verificationEvidenceCommands(brief.verification, brief.declared_verification, null);
|
|
325
336
|
if (!brief.repository || !brief.base_commit || !commands.length)
|
|
326
337
|
return { report: null, due: null };
|
|
327
338
|
const repository = normalizeRepositoryUrl(brief.repository);
|
|
328
339
|
const stored = (await readGateRehearsals(options.statePath ?? gateRehearsalStatePath()))[repository] ?? null;
|
|
329
|
-
const
|
|
340
|
+
const lockfiles = options.workspace ? await lockfileDigest(options.workspace) : null;
|
|
341
|
+
const digest = createHash("sha256").update(commands.join("\n")).update(lockfiles ?? "").digest("hex").slice(0, 16);
|
|
330
342
|
const day = rehearsalDay((options.now ?? Date.now)());
|
|
331
343
|
if (stored && stored.day === day && stored.digest === digest)
|
|
332
344
|
return { report: stored.rehearsal, due: null };
|
|
@@ -342,7 +354,7 @@ export async function planGateRehearsals(checkouts, options = {}) {
|
|
|
342
354
|
const seen = new Set();
|
|
343
355
|
let due = null;
|
|
344
356
|
for (const checkout of checkouts) {
|
|
345
|
-
const planned = await planGateRehearsal(checkout.brief, options);
|
|
357
|
+
const planned = await planGateRehearsal(checkout.brief, { ...options, workspace: checkout.workspace });
|
|
346
358
|
const repository = planned.report?.repository ?? planned.due?.repository;
|
|
347
359
|
if (!repository || seen.has(repository))
|
|
348
360
|
continue;
|
|
@@ -429,13 +441,36 @@ async function rehearseGate(workspace, plan, options) {
|
|
|
429
441
|
const now = options.now ?? Date.now;
|
|
430
442
|
const run = options.runCommand ?? runBoundedVerificationCommand;
|
|
431
443
|
const commands = [];
|
|
444
|
+
let cwd = workspace;
|
|
432
445
|
for (const command of plan.commands) {
|
|
433
446
|
const started = now();
|
|
434
447
|
// A command that cannot start measured nothing; the dry run skips it the same way.
|
|
435
|
-
const result = await run(command,
|
|
448
|
+
const result = await run(command, cwd, REHEARSAL_TIMEOUT_MS).catch(() => null);
|
|
436
449
|
if (!result)
|
|
437
450
|
continue;
|
|
438
|
-
|
|
451
|
+
const elapsed = now() - started;
|
|
452
|
+
const seconds = Math.round(elapsed / 1_000);
|
|
453
|
+
if (result.code !== 0 || result.timedOut === true || elapsed <= VERIFICATION_TIMEOUT_MS) {
|
|
454
|
+
commands.push({ command, seconds, exit_code: result.code, timed_out: result.timedOut === true });
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
// The pass fit only the rehearsal's limit: it was a warm-up. Measure once more, in a fresh
|
|
458
|
+
// checkout with the attempt's limit, so the report shows what an attempt will see.
|
|
459
|
+
if (options.freshWorkspace)
|
|
460
|
+
cwd = await options.freshWorkspace(seconds);
|
|
461
|
+
const again = now();
|
|
462
|
+
const second = await run(command, cwd, VERIFICATION_TIMEOUT_MS).catch(() => null);
|
|
463
|
+
if (!second) {
|
|
464
|
+
commands.push({ command, seconds, exit_code: result.code, timed_out: false });
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
commands.push({
|
|
468
|
+
command,
|
|
469
|
+
seconds: Math.round((now() - again) / 1_000),
|
|
470
|
+
exit_code: second.code,
|
|
471
|
+
timed_out: second.timedOut === true,
|
|
472
|
+
warm_up_seconds: seconds,
|
|
473
|
+
});
|
|
439
474
|
}
|
|
440
475
|
const at = now();
|
|
441
476
|
const rehearsal = commands.length
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@miraland-labs/conduit-bridge",
|
|
3
|
-
"version": "0.16.
|
|
3
|
+
"version": "0.16.188",
|
|
4
4
|
"description": "Conduit Bridge CLI — join, connect, disconnect, multi-driver lanes, and run Claude Code / Codex / Cursor / OpenCode / Pi / Kiro / Antigravity / Grok Build agents for a Conduit organization",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|