@outerlayer/cli 0.4.1 → 0.5.1

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 (50) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/{build-TZUUTRGH.js → build-PAEKVYVK.js} +1 -1
  3. package/dist/build-info.json +1 -1
  4. package/dist/{check-7MFH6HNC.js → check-4Q33B5ZE.js} +15 -13
  5. package/dist/{chunk-5Y3QQRUH.js → chunk-3DD6AOWC.js} +37 -0
  6. package/dist/{chunk-RFUPN6KX.js → chunk-CHLVJNTE.js} +5 -5
  7. package/dist/{chunk-SY4GK4SP.js → chunk-D2KWEAN6.js} +1 -1
  8. package/dist/{chunk-LT5TZHYH.js → chunk-EGABNHZG.js} +3 -3
  9. package/dist/{chunk-4C4THABW.js → chunk-EX6M5JPR.js} +31 -29
  10. package/dist/{chunk-QY22NZUW.js → chunk-F4NU4H3V.js} +401 -20
  11. package/dist/{chunk-TWNWNS2Q.js → chunk-H4CK2YK4.js} +8 -8
  12. package/dist/{chunk-7WERLFVR.js → chunk-JLDZTG3A.js} +1 -1
  13. package/dist/{chunk-Y7KJLXYN.js → chunk-OR6AEEDO.js} +3 -3
  14. package/dist/{chunk-W5FVUZXV.js → chunk-RMAZ2NWA.js} +2 -2
  15. package/dist/{chunk-3TFPDVTI.js → chunk-RTXKYLCA.js} +2 -2
  16. package/dist/{chunk-HZRNMGLF.js → chunk-T52PKE2Y.js} +144 -127
  17. package/dist/chunk-UGP76IZ5.js +34 -0
  18. package/dist/{chunk-DM3VFDS3.js → chunk-UWH5DSUP.js} +32 -20
  19. package/dist/{chunk-YFHIIH4B.js → chunk-W7UTFZDH.js} +2 -2
  20. package/dist/{chunk-WFJ65NUD.js → chunk-WOBNRVQT.js} +1 -1
  21. package/dist/chunk-YYYRBYWG.js +32 -0
  22. package/dist/{chunk-ZNA27WEV.js → chunk-Z62HNKEJ.js} +3 -3
  23. package/dist/{chunk-LYCDNMOH.js → chunk-ZOC7JSYC.js} +1 -1
  24. package/dist/{chunk-QDQEUVUF.js → chunk-ZXBITXTS.js} +1 -1
  25. package/dist/{cli-W62AFRSW.js → cli-LHSUYYMI.js} +56 -51
  26. package/dist/{config-XVJYZ4IQ.js → config-HL2AIR3K.js} +1 -1
  27. package/dist/{connect-cmd-ZTJONP37.js → connect-cmd-OJBFHZWP.js} +4 -4
  28. package/dist/{dist-44CQVWGT.js → dist-WDV7U7UJ.js} +1 -1
  29. package/dist/{doctor-ZLGZYMDK.js → doctor-MOA3RIEP.js} +23 -9
  30. package/dist/{emit-artifact-cmd-AKZP7TMF.js → emit-artifact-cmd-FDWYKURA.js} +4 -4
  31. package/dist/{emit-criteria-cmd-7MXQVCKD.js → emit-criteria-cmd-OCXHLK34.js} +1 -1
  32. package/dist/{emit-finding-cmd-N2FTGCVC.js → emit-finding-cmd-POW7PKIA.js} +1 -1
  33. package/dist/{emit-result-cmd-4CPDBMN2.js → emit-result-cmd-ID35SH53.js} +4 -4
  34. package/dist/index.js +1 -1
  35. package/dist/{init-XXEKWZQP.js → init-2IPOIXJV.js} +8 -8
  36. package/dist/{init-55Y4LHWF.js → init-FYDJAKTM.js} +7 -7
  37. package/dist/{init-cmd-WLST2F7G.js → init-cmd-KLIDRVXQ.js} +6 -6
  38. package/dist/{loop-JFIBP4B7.js → loop-NEBUGD2Q.js} +12 -10
  39. package/dist/{machine-ZBPT2J3R.js → machine-ECU3QCZD.js} +6 -6
  40. package/dist/record-C43U4PKS.js +7 -0
  41. package/dist/{status-UJXWZRN5.js → status-CLATHCTQ.js} +27 -9
  42. package/dist/{sync-cmd-4G4A7EVN.js → sync-cmd-SWQ5KLPL.js} +4 -4
  43. package/dist/{watch-7SKJKGAA.js → watch-3YW6Z3DQ.js} +4 -4
  44. package/dist/{work-claim-cmd-5F7VZ42N.js → work-claim-cmd-3OPWA77E.js} +6 -6
  45. package/dist/{work-cmd-QAUZ7MTD.js → work-cmd-NUR4CAG6.js} +3 -3
  46. package/dist/{work-comment-cmd-BNCSBTLH.js → work-comment-cmd-6TTTYGHM.js} +3 -3
  47. package/dist/{work-open-pr-cmd-VFYZHIIW.js → work-open-pr-cmd-KZ7QQFLD.js} +4 -4
  48. package/dist/{work-pr-cmd-PV32ZLSI.js → work-pr-cmd-QNUBE6IQ.js} +2 -2
  49. package/package.json +1 -1
  50. package/skill-pack/maintained/emitting-evidence/SKILL.md +4 -4
@@ -1,22 +1,25 @@
1
1
  #!/usr/bin/env node
2
2
  import { createRequire } from 'node:module';
3
- import { LEASE_SECONDS, releaseLease, isUnfinished, writeJobFile, setStage, jobEnv, runHook, describeHookEnd, provisionMicroVm, deliverCommitHooks, provisionProcess, provisionEnvironment, readProvisionOut, resolveHostBuild, workdirProblem, listenOnBroker, hostEnvironment, hostCommandEnvironment, execArgv, microVmCommandLine, runCommand, execCommandLine, startHostServices, finishJob, isGroupAlive, outcomeFromExitCode, needsRenew, renewLease, RENEW_INTERVAL_SECONDS, isTimedOut, readCpuUsec, isIdle, stopOrphanedMicroVms, unfinishedJobs, listAdoptableJobs, retryFailedReleases, recoverLostJobs, readJobFile, hookLimitsFor } from './chunk-DM3VFDS3.js';
4
- import { RUNNER_PROTOCOL } from './chunk-QDQEUVUF.js';
3
+ import { LEASE_SECONDS, releaseLease, isUnfinished, writeJobFile, setStage, jobEnv, runHook, describeHookEnd, provisionMicroVm, deliverCommitHooks, provisionProcess, provisionEnvironment, readProvisionOut, resolveHostBuild, workdirProblem, listenOnBroker, hostEnvironment, hostCommandEnvironment, execArgv, microVmCommandLine, runCommand, execCommandLine, startHostServices, finishJob, isGroupAlive, outcomeFromExitCode, needsRenew, renewLease, RENEW_INTERVAL_SECONDS, isTimedOut, readCpuUsec, isIdle, stopOrphanedMicroVms, unfinishedJobs, listAdoptableJobs, retryFailedReleases, recoverLostJobs, readJobFile, hookLimitsFor, readJobFileChecked, ALREADY_RELEASED } from './chunk-UWH5DSUP.js';
4
+ import { RUNNER_PROTOCOL } from './chunk-ZXBITXTS.js';
5
+ import { readHousekeepingRecord, writeHousekeepingRecord } from './chunk-UGP76IZ5.js';
5
6
  import { fetchVersionMeta, npmVersion, npmSupportsProvenance, fetchTarballDigest, fetchProvenance, installVersion, auditSignatures } from './chunk-ABIWDUMS.js';
6
7
  import { readMarker, currentVersion, removeMarker, writeMarker, entryIn, versionDir, switchCurrent, cleanVersions, isInLayout, startedThroughCurrent, stagingDir } from './chunk-YYYXRJUW.js';
8
+ import { freeBytes, formatBytes } from './chunk-YYYRBYWG.js';
7
9
  import { RepoFilter } from './chunk-TBT347UY.js';
8
- import { describeCallFailure, retrySend, shortClaim, relativeToRunner, formatDuration, provisionContainer, lifecycleSteps, lifecycleFailureReason, prepareBranch, containerCommandLine, BuildTokens, classifyRefusal, refusalCode, formatClock, measureDisk, createRunnerLog, ensureTunnel, keepGhConfigCurrent, createContainerRuntime } from './chunk-HZRNMGLF.js';
10
+ import { describeCallFailure, retrySend, shortClaim, relativeToRunner, formatDuration, provisionContainer, lifecycleSteps, lifecycleFailureReason, prepareBranch, containerCommandLine, BuildTokens, classifyRefusal, refusalCode, formatClock, measureDisk, createRunnerLog, ensureTunnel, keepGhConfigCurrent, createContainerRuntime, runnerLabelValue, BUILD_LABEL, RUNNER_LABEL } from './chunk-T52PKE2Y.js';
9
11
  import { startTimeOf, claimPidFile, readPidFile, isSameProcess, releasePidFile } from './chunk-N5FOE5PS.js';
10
- import { isSignatureRefusal, WorkCommandError, getJson, postJson } from './chunk-W5FVUZXV.js';
11
- import { isBuiltinContainer, isBuiltinVm, isBuiltinProcess, loadRunnerConfig, checkHooks } from './chunk-5Y3QQRUH.js';
12
+ import { spawnRun } from './chunk-774HEQL6.js';
13
+ import { isSignatureRefusal, WorkCommandError, getJson, postJson } from './chunk-RMAZ2NWA.js';
14
+ import { isBuiltinContainer, isBuiltinVm, isBuiltinProcess, loadRunnerConfig, checkHooks, builtinKind } from './chunk-3DD6AOWC.js';
12
15
  import { exceedsDisk } from './chunk-F6GFZXZ3.js';
13
16
  import { parseClaimIdentity, sharedUserEnvironment, JOB_MOUNT } from './chunk-AUFG23AA.js';
14
17
  import { resolveVersion } from './chunk-RA3O54FC.js';
15
18
  import { loadHostKey, ensureHostKey, signerFor } from './chunk-M5POMKKW.js';
16
- import { RUNNER_DIR_MODE, runnerDir, buildDir, attemptStamp, jobDir, jobsDir, pidFilePath, RUNNER_FILE_MODE } from './chunk-ZM2IMMYN.js';
19
+ import { RUNNER_DIR_MODE, runnerDir, buildDir, attemptStamp, jobDir, jobsDir, pidFilePath, spoolDir, RUNNER_FILE_MODE, rootfsDir } from './chunk-ZM2IMMYN.js';
17
20
  import { HOOKS_DIR_NAME, writeCommitHooks } from './chunk-XCCLFVXM.js';
18
21
  import { randomBytes } from 'crypto';
19
- import { mkdirSync, existsSync, chmodSync, statSync, readdirSync, readFileSync, rmSync, renameSync, writeFileSync } from 'fs';
22
+ import { mkdirSync, existsSync, chmodSync, statSync, readdirSync, readFileSync, rmSync, renameSync, writeFileSync, lstatSync } from 'fs';
20
23
  import { join, dirname } from 'path';
21
24
 
22
25
  createRequire(import.meta.url);
@@ -68,7 +71,9 @@ async function sendHeartbeat(args, fetchImpl) {
68
71
  outcome: args.lastUpdate.outcome,
69
72
  reason: args.lastUpdate.reason,
70
73
  ...args.lastUpdate.at !== void 0 ? { at: args.lastUpdate.at } : {}
71
- }
74
+ },
75
+ // Free disk on the runner's file system, in bytes. Sent only while held, so a host with room sends nothing about its disk.
76
+ lowDisk: args.lowDisk === void 0 ? void 0 : { free: args.lowDisk.free, needed: args.lowDisk.needed }
72
77
  };
73
78
  try {
74
79
  const response = await postJson(
@@ -371,9 +376,9 @@ function retryFailure(version, err) {
371
376
  }
372
377
  return { outcome: "retrying", reason: messageOf(err) };
373
378
  }
374
- function finish(args, failure) {
375
- let update = { from: args.own, to: args.version, outcome: failure.outcome, reason: failure.reason, at: isoAt(args.now) };
376
- if (failure.outcome === "retrying") {
379
+ function finish(args, failure2) {
380
+ let update = { from: args.own, to: args.version, outcome: failure2.outcome, reason: failure2.reason, at: isoAt(args.now) };
381
+ if (failure2.outcome === "retrying") {
377
382
  const previous = readLastUpdate(args.configPath);
378
383
  const attempts = previous?.to === args.version && previous.outcome === "retrying" ? (previous.attempts ?? 1) + 1 : 1;
379
384
  update = { ...update, attempts };
@@ -383,16 +388,16 @@ function finish(args, failure) {
383
388
  } catch (err) {
384
389
  args.log.error(`could not record the update attempt: ${messageOf(err)}`);
385
390
  }
386
- if (failure.outcome === "retrying") {
391
+ if (failure2.outcome === "retrying") {
387
392
  return {
388
393
  kind: "retrying",
389
- reason: failure.reason,
390
- detail: failure.detail ?? failure.reason,
394
+ reason: failure2.reason,
395
+ detail: failure2.detail ?? failure2.reason,
391
396
  attempts: update.attempts ?? 1,
392
397
  retryAtMs: retryAfterMs(update)
393
398
  };
394
399
  }
395
- return { kind: "failed", outcome: failure.outcome, reason: failure.reason };
400
+ return { kind: "failed", outcome: failure2.outcome, reason: failure2.reason };
396
401
  }
397
402
  async function earlyFailure(args) {
398
403
  const current = currentVersion(args.configPath);
@@ -423,8 +428,8 @@ async function earlyFailure(args) {
423
428
  return void 0;
424
429
  }
425
430
  async function preflightUpdate(args) {
426
- const failure = await earlyFailure(args);
427
- return failure === void 0 ? void 0 : finish(args, failure);
431
+ const failure2 = await earlyFailure(args);
432
+ return failure2 === void 0 ? void 0 : finish(args, failure2);
428
433
  }
429
434
  async function performUpdate(args) {
430
435
  const { configPath, own, version, deps } = args;
@@ -525,12 +530,325 @@ function settleUpdate(args) {
525
530
  }
526
531
  }
527
532
 
533
+ // src/runner/housekeeping/docker.ts
534
+ var KIND_LABEL = "ai.outerlayer.kind";
535
+ function failure(result, what) {
536
+ return new Error(`${what}: ${result.stderr.trim() || `docker exited ${result.status}`}`);
537
+ }
538
+ function labelled(stdout, runnerId) {
539
+ const found = [];
540
+ for (const line of stdout.split("\n")) {
541
+ const [key = "", build = "", runner = ""] = line.replace(/\r?\n$/, "").split(" ");
542
+ if (key.trim() === "" || build === "") continue;
543
+ if (runner !== "" && runner !== runnerId) continue;
544
+ found.push({ key: key.trim(), build, owned: runner === runnerId });
545
+ }
546
+ return found;
547
+ }
548
+ function decimalUnits() {
549
+ return { b: 1, kb: 1e3, mb: 1e6, gb: 1e9, tb: 1e12, pb: 1e15 };
550
+ }
551
+ function parsePrunedBytes(stdout) {
552
+ const match = /^Total(?: reclaimed space)?:\s*([\d.]+)\s*([A-Za-z]*)\s*$/im.exec(stdout);
553
+ if (match === null) return 0;
554
+ const unit = decimalUnits()[match[2].toLowerCase() || "b"];
555
+ return unit === void 0 ? 0 : Math.round(Number(match[1]) * unit);
556
+ }
557
+ function createHousekeepingDocker(deps) {
558
+ const run = deps.run ?? spawnRun;
559
+ return {
560
+ async listBuildContainers() {
561
+ const result = await run("docker", [
562
+ "ps",
563
+ "--all",
564
+ "--no-trunc",
565
+ "--filter",
566
+ `label=${BUILD_LABEL}`,
567
+ "--format",
568
+ `{{.ID}} {{.Label "${BUILD_LABEL}"}} {{.Label "${RUNNER_LABEL}"}}`
569
+ ]);
570
+ if (result.status !== 0) throw failure(result, "docker ps");
571
+ return labelled(result.stdout, deps.runnerId).map(({ key, build, owned }) => ({ id: key, build, owned }));
572
+ },
573
+ async listBuildVolumes() {
574
+ const result = await run("docker", [
575
+ "volume",
576
+ "ls",
577
+ "--filter",
578
+ `label=${BUILD_LABEL}`,
579
+ "--format",
580
+ `{{.Name}} {{.Label "${BUILD_LABEL}"}} {{.Label "${RUNNER_LABEL}"}}`
581
+ ]);
582
+ if (result.status !== 0) throw failure(result, "docker volume ls");
583
+ return labelled(result.stdout, deps.runnerId).map(({ key, build, owned }) => ({ name: key, build, owned }));
584
+ },
585
+ async removeContainer(id) {
586
+ const result = await run("docker", ["rm", "--force", "--volumes", id]);
587
+ if (result.status !== 0) throw failure(result, `docker rm ${id}`);
588
+ },
589
+ async removeVolume(name) {
590
+ const result = await run("docker", ["volume", "rm", "--force", name]);
591
+ if (result.status !== 0) throw failure(result, `docker volume rm ${name}`);
592
+ },
593
+ async imageState(ref) {
594
+ const result = await run("docker", ["image", "inspect", "--format", "{{.Id}}", ref]);
595
+ if (result.status === 0) return "present";
596
+ return /no such image|not found/i.test(result.stderr) ? "missing" : "unknown";
597
+ },
598
+ async listDanglingImages() {
599
+ const listed = await run("docker", [
600
+ "image",
601
+ "ls",
602
+ "--all",
603
+ "--no-trunc",
604
+ "--quiet",
605
+ "--filter",
606
+ "dangling=true",
607
+ "--filter",
608
+ `label=${KIND_LABEL}`
609
+ ]);
610
+ if (listed.status !== 0) throw failure(listed, "docker image ls");
611
+ const ids = [...new Set(listed.stdout.split("\n").map((l) => l.trim()).filter(Boolean))];
612
+ if (ids.length === 0) return [];
613
+ const inspected = await run("docker", ["image", "inspect", ...ids]);
614
+ if (inspected.status !== 0) throw failure(inspected, "docker image inspect");
615
+ return JSON.parse(inspected.stdout).map((row) => ({
616
+ id: row.Id,
617
+ size: row.Size ?? 0,
618
+ labels: row.Config?.Labels ?? {}
619
+ }));
620
+ },
621
+ async removeImage(id) {
622
+ const result = await run("docker", ["rmi", id]);
623
+ if (result.status !== 0) throw failure(result, `docker rmi ${id}`);
624
+ },
625
+ async builderPrune(keepBytes) {
626
+ const result = await run("docker", ["builder", "prune", "--force", "--keep-storage", String(keepBytes)]);
627
+ if (result.status !== 0) throw failure(result, "docker builder prune");
628
+ return { freedBytes: parsePrunedBytes(result.stdout) };
629
+ }
630
+ };
631
+ }
632
+
633
+ // src/runner/housekeeping/select.ts
634
+ var DAY_MS = 864e5;
635
+ function spoolMaxAgeMs() {
636
+ return 24 * 36e5;
637
+ }
638
+ function selectJobDirs(entries, nowMs, keepDays) {
639
+ return entries.filter(
640
+ (entry) => (entry.state === "finished" || entry.state === "no_job_file") && nowMs - entry.mtimeMs > keepDays * DAY_MS
641
+ );
642
+ }
643
+ function liveBuildIds(jobs) {
644
+ const ids = /* @__PURE__ */ new Set();
645
+ for (const job of jobs) {
646
+ for (const id of [job.container?.buildId, job.microvm?.buildId, job.build?.buildId]) if (id !== void 0) ids.add(id);
647
+ }
648
+ return ids;
649
+ }
650
+ function finishedBuildIds(jobs) {
651
+ return liveBuildIds(jobs);
652
+ }
653
+ function liveImageIds(jobs) {
654
+ const ids = /* @__PURE__ */ new Set();
655
+ for (const job of jobs) {
656
+ for (const id of [job.container?.imageId, job.microvm?.imageId]) if (id !== void 0) ids.add(id);
657
+ }
658
+ return ids;
659
+ }
660
+ function selectOrphans(items, live, finished) {
661
+ return items.filter((item) => !live.has(item.build) && (item.owned || finished.has(item.build)));
662
+ }
663
+ function rootDiskImageId(fileName) {
664
+ if (!fileName.endsWith(".ext4") || fileName.includes(".partial-")) return void 0;
665
+ const id = fileName.slice(0, -".ext4".length);
666
+ return id === "" ? void 0 : id;
667
+ }
668
+ function selectSpool(files, nowMs, buildRunning) {
669
+ return buildRunning ? [] : files.filter((file) => nowMs - file.mtimeMs > spoolMaxAgeMs());
670
+ }
671
+
672
+ // src/runner/housekeeping/run.ts
673
+ function sizeOf(path) {
674
+ try {
675
+ const stats = lstatSync(path);
676
+ if (!stats.isDirectory()) return stats.size;
677
+ return readdirSync(path).reduce((sum, name) => sum + sizeOf(join(path, name)), 0);
678
+ } catch {
679
+ return 0;
680
+ }
681
+ }
682
+ function listDir(path) {
683
+ try {
684
+ return readdirSync(path);
685
+ } catch {
686
+ return [];
687
+ }
688
+ }
689
+ function mtimeOf(path) {
690
+ try {
691
+ return statSync(path).mtimeMs;
692
+ } catch {
693
+ return void 0;
694
+ }
695
+ }
696
+ function readJobs(jobsPath) {
697
+ const entries = [];
698
+ const unfinished = [];
699
+ const finished = [];
700
+ const unreadable = [];
701
+ for (const dirent of existsSync(jobsPath) ? readdirSync(jobsPath, { withFileTypes: true }) : []) {
702
+ if (!dirent.isDirectory()) continue;
703
+ const dirPath = join(jobsPath, dirent.name);
704
+ const mtimeMs = mtimeOf(dirPath);
705
+ if (mtimeMs === void 0) continue;
706
+ const read = readJobFileChecked(join(dirPath, "job.json"));
707
+ let state;
708
+ if (read.kind === "missing") {
709
+ state = "no_job_file";
710
+ } else if (read.kind === "unreadable") {
711
+ unreadable.push(dirent.name);
712
+ state = "unreadable";
713
+ } else if (isUnfinished(read.job)) {
714
+ unfinished.push(read.job);
715
+ state = "unfinished";
716
+ } else {
717
+ finished.push(read.job);
718
+ state = read.job.releaseFailed === void 0 || read.job.releaseFailed === ALREADY_RELEASED ? "finished" : "release_failed";
719
+ }
720
+ entries.push({ name: dirent.name, mtimeMs, state });
721
+ }
722
+ return { entries, unfinished, finished, unreadable };
723
+ }
724
+ async function removeDanglingImages(docker, log, live) {
725
+ let freed = 0;
726
+ for (const image of await docker.listDanglingImages()) {
727
+ if (image.labels[KIND_LABEL] === void 0) continue;
728
+ if (live.has(image.id)) continue;
729
+ try {
730
+ await docker.removeImage(image.id);
731
+ freed += image.size;
732
+ log.line(`housekeeping: removed dangling image ${image.id.replace(/^sha256:/, "").slice(0, 12)} (${formatBytes(image.size)}), a rebuild replaced it`);
733
+ } catch (err) {
734
+ log.error(`housekeeping: could not remove dangling image ${image.id}: ${describeCallFailure(err)}`);
735
+ }
736
+ }
737
+ return freed;
738
+ }
739
+ async function runHousekeeping(options) {
740
+ const { docker, log } = options;
741
+ const nowMs = options.now().getTime();
742
+ const root = runnerDir(options.configPath);
743
+ let freed = 0;
744
+ let listed;
745
+ try {
746
+ const containers = await docker.listBuildContainers();
747
+ listed = { containers, volumes: await docker.listBuildVolumes() };
748
+ } catch (err) {
749
+ log.error(`housekeeping: Docker could not be asked, so nothing in Docker is removed this pass: ${describeCallFailure(err)}`);
750
+ }
751
+ const jobs = readJobs(jobsDir(options.configPath));
752
+ for (const name of jobs.unreadable) {
753
+ log.error(`housekeeping: job file ${name}/job.json does not parse, so nothing in Docker is removed this pass`);
754
+ }
755
+ const canTell = jobs.unreadable.length === 0;
756
+ for (const entry of selectJobDirs(jobs.entries, nowMs, options.keepDays)) {
757
+ const path = join(jobsDir(options.configPath), entry.name);
758
+ const size = sizeOf(path);
759
+ try {
760
+ rmSync(path, { recursive: true, force: true });
761
+ freed += size;
762
+ log.line(`housekeeping: removed job directory ${entry.name} (${formatBytes(size)}), last changed ${formatDuration(nowMs - entry.mtimeMs)} ago`);
763
+ } catch (err) {
764
+ log.error(`housekeeping: could not remove job directory ${entry.name}: ${describeCallFailure(err)}`);
765
+ }
766
+ }
767
+ const live = liveBuildIds(jobs.unfinished);
768
+ const finished = finishedBuildIds(jobs.finished);
769
+ if (canTell && listed !== void 0) {
770
+ for (const container of selectOrphans(listed.containers, live, finished)) {
771
+ try {
772
+ await docker.removeContainer(container.id);
773
+ log.line(`housekeeping: removed container ${container.id.slice(0, 12)}, no unfinished build ${container.build} names it`);
774
+ } catch (err) {
775
+ log.error(`housekeeping: could not remove container ${container.id}: ${describeCallFailure(err)}`);
776
+ }
777
+ }
778
+ for (const volume of selectOrphans(listed.volumes, live, finished)) {
779
+ try {
780
+ await docker.removeVolume(volume.name);
781
+ log.line(`housekeeping: removed volume ${volume.name}, no unfinished build ${volume.build} names it`);
782
+ } catch (err) {
783
+ log.error(`housekeeping: could not remove volume ${volume.name}: ${describeCallFailure(err)}`);
784
+ }
785
+ }
786
+ }
787
+ if (canTell && listed !== void 0) {
788
+ freed += await removeStaleRootDisks(root, jobs.unfinished, options);
789
+ try {
790
+ freed += await removeDanglingImages(docker, log, liveImageIds(jobs.unfinished));
791
+ } catch (err) {
792
+ log.error(`housekeeping: dangling images could not be listed: ${describeCallFailure(err)}`);
793
+ }
794
+ if (options.buildCacheMax !== "off") {
795
+ try {
796
+ const pruned = await docker.builderPrune(options.buildCacheMax);
797
+ freed += pruned.freedBytes;
798
+ log.line(`housekeeping: pruned the build cache down to ${formatBytes(options.buildCacheMax)}, freed ${formatBytes(pruned.freedBytes)}`);
799
+ } catch (err) {
800
+ log.error(`housekeeping: the build cache could not be pruned: ${describeCallFailure(err)}`);
801
+ }
802
+ }
803
+ }
804
+ const spoolPath = spoolDir(root);
805
+ const spoolFiles = listDir(spoolPath).flatMap((name) => {
806
+ const mtimeMs = mtimeOf(join(spoolPath, name));
807
+ return mtimeMs === void 0 ? [] : [{ name, mtimeMs }];
808
+ });
809
+ for (const file of selectSpool(spoolFiles, nowMs, jobs.unfinished.length > 0)) {
810
+ const path = join(spoolPath, file.name);
811
+ const size = sizeOf(path);
812
+ try {
813
+ rmSync(path, { recursive: true, force: true });
814
+ freed += size;
815
+ log.line(`housekeeping: removed spool file ${file.name} (${formatBytes(size)}), last changed ${formatDuration(nowMs - file.mtimeMs)} ago and no build is running`);
816
+ } catch (err) {
817
+ log.error(`housekeeping: could not remove spool file ${file.name}: ${describeCallFailure(err)}`);
818
+ }
819
+ }
820
+ log.line(`housekeeping: pass finished, freed ${formatBytes(freed)}`);
821
+ return { freedBytes: freed };
822
+ }
823
+ async function removeStaleRootDisks(root, unfinished, options) {
824
+ const dir = rootfsDir(root);
825
+ const inUse = new Set(unfinished.flatMap((job) => job.microvm?.rootfs === void 0 ? [] : [job.microvm.rootfs]));
826
+ let freed = 0;
827
+ for (const name of listDir(dir)) {
828
+ const imageId = rootDiskImageId(name);
829
+ if (imageId === void 0) continue;
830
+ const path = join(dir, name);
831
+ if (inUse.has(path)) continue;
832
+ if (await options.docker.imageState(`sha256:${imageId}`) !== "missing") continue;
833
+ const size = sizeOf(path);
834
+ try {
835
+ rmSync(path, { force: true });
836
+ freed += size;
837
+ options.log.line(`housekeeping: removed root disk ${name} (${formatBytes(size)}), Docker no longer has its image`);
838
+ } catch (err) {
839
+ options.log.error(`housekeeping: could not remove root disk ${name}: ${describeCallFailure(err)}`);
840
+ }
841
+ }
842
+ return freed;
843
+ }
844
+
528
845
  // src/runner/loop.ts
529
846
  var QUEUE_ORDER = ["amend", "implement"];
530
847
  var TRACKER_REPOSITORY_HOSTS = {
531
848
  github: "github.com",
532
849
  gitlab: "gitlab.com"
533
850
  };
851
+ var HOUSEKEEPING_INTERVAL_MS = 36e5;
534
852
  function logOf(state) {
535
853
  state.deps.log ??= createRunnerLog(state.deps.now ?? (() => /* @__PURE__ */ new Date()));
536
854
  return state.deps.log;
@@ -645,7 +963,8 @@ async function sendTickHeartbeat(state, workRequest) {
645
963
  slotsUsed: state.jobs.size,
646
964
  slotsTotal: state.config.concurrency,
647
965
  workRequest: next,
648
- lastUpdate: readLastUpdate(state.config.configPath)
966
+ lastUpdate: readLastUpdate(state.config.configPath),
967
+ lowDisk: state.lowDisk
649
968
  },
650
969
  state.deps.fetchImpl
651
970
  );
@@ -769,9 +1088,64 @@ async function tick(state) {
769
1088
  }
770
1089
  await sendTickHeartbeat(state, state.lastWorkRequest);
771
1090
  }
1091
+ function holdOffForDisk(state) {
1092
+ const minimum = state.config.housekeeping?.minFreeDisk ?? 0;
1093
+ const root = runnerDir(state.config.configPath);
1094
+ const free = minimum === 0 ? Number.POSITIVE_INFINITY : (state.deps.freeBytes ?? freeBytes)(root) ?? Number.POSITIVE_INFINITY;
1095
+ const log = logOf(state);
1096
+ if (free < minimum) {
1097
+ if (state.lowDisk === void 0) {
1098
+ log.line(
1099
+ `holding off new work: ${formatBytes(free)} free on the runner's file system, ${formatBytes(minimum)} needed (runner.housekeeping.minFreeDisk) \xB7 claiming again once there is room`
1100
+ );
1101
+ recordHousekeeping(state, root, { holding: { free, needed: minimum, since: nowOf(state).toISOString() } });
1102
+ }
1103
+ state.lowDisk = { free, needed: minimum };
1104
+ return true;
1105
+ }
1106
+ if (state.lowDisk !== void 0) {
1107
+ state.lowDisk = void 0;
1108
+ log.line("free disk is back above the minimum \xB7 taking work again");
1109
+ recordHousekeeping(state, root, { holding: void 0 });
1110
+ } else if (!state.holdRecordChecked) {
1111
+ if (readHousekeepingRecord(root).holding !== void 0) recordHousekeeping(state, root, { holding: void 0 });
1112
+ }
1113
+ state.holdRecordChecked = true;
1114
+ return false;
1115
+ }
1116
+ function recordHousekeeping(state, root, patch) {
1117
+ try {
1118
+ writeHousekeepingRecord(root, patch);
1119
+ } catch (err) {
1120
+ logOf(state).error(`could not write housekeeping.json: ${describeCallFailure(err)}`);
1121
+ }
1122
+ }
1123
+ async function housekeepingPass(state) {
1124
+ const config = state.config.housekeeping;
1125
+ if (config === void 0 || state.housekeepingRunning) return;
1126
+ state.housekeepingRunning = true;
1127
+ try {
1128
+ state.deps.housekeeping ??= {};
1129
+ state.deps.housekeeping.docker ??= createHousekeepingDocker({ runnerId: runnerLabelValue(state.config.configPath) });
1130
+ const result = await runHousekeeping({
1131
+ configPath: state.config.configPath,
1132
+ keepDays: config.jobsKeepDays,
1133
+ buildCacheMax: config.buildCacheMax,
1134
+ docker: state.deps.housekeeping.docker,
1135
+ now: state.deps.now ?? (() => /* @__PURE__ */ new Date()),
1136
+ log: logOf(state)
1137
+ });
1138
+ recordHousekeeping(state, runnerDir(state.config.configPath), { lastRunAt: nowOf(state).toISOString(), freedBytes: result.freedBytes });
1139
+ } catch (err) {
1140
+ logOf(state).error(`housekeeping failed: ${describeCallFailure(err)}`);
1141
+ } finally {
1142
+ state.housekeepingRunning = false;
1143
+ }
1144
+ }
772
1145
  async function runTick(state) {
773
1146
  reloadConfig(state);
774
1147
  if (await updateWhenIdle(state)) return;
1148
+ if (holdOffForDisk(state)) return;
775
1149
  const log = logOf(state);
776
1150
  const repoFilter = new RepoFilter(state.config.repos);
777
1151
  let filtered = 0;
@@ -825,7 +1199,7 @@ var CLAIM_REFUSAL_CODES = new Set(
825
1199
  "pull_request_from_fork pull_request_on_default_branch pull_request_ambiguous branch_claimed_elsewhere".split(" ")
826
1200
  );
827
1201
  async function tryClaimAndStart(state, item, queue) {
828
- const { postJson: postJson2 } = await import('./work-cmd-QAUZ7MTD.js');
1202
+ const { postJson: postJson2 } = await import('./work-cmd-NUR4CAG6.js');
829
1203
  const log = logOf(state);
830
1204
  const hostKey = await loadHostKey(state.config.configPath);
831
1205
  const claim = await retrySend(
@@ -1525,7 +1899,7 @@ async function start(configPath, deps = { fetchImpl: fetch }) {
1525
1899
  const outcome = await onStartUnderMarker({
1526
1900
  configPath,
1527
1901
  own: ownVersionOf(deps),
1528
- check: update.check ? () => update.check(configPath) : async () => (await import('./check-7MFH6HNC.js')).runRunnerCheck(configPath, { fetchImpl: deps.fetchImpl }),
1902
+ check: update.check ? () => update.check(configPath) : async () => (await import('./check-4Q33B5ZE.js')).runRunnerCheck(configPath, { fetchImpl: deps.fetchImpl }),
1529
1903
  now: () => (deps.now ?? (() => /* @__PURE__ */ new Date()))().getTime(),
1530
1904
  log
1531
1905
  });
@@ -1606,6 +1980,12 @@ async function start(configPath, deps = { fetchImpl: fetch }) {
1606
1980
  let interrupts = 0;
1607
1981
  state.pollTimerSeconds = state.config.pollSeconds;
1608
1982
  const pollTimer = setInterval(() => void tick(state), state.pollTimerSeconds * 1e3);
1983
+ const hooksKind = builtinKind(config.hooks.provision);
1984
+ const housekeepingTimer = hooksKind === "container" || hooksKind === "vm" ? (() => {
1985
+ const everyMs = deps.housekeeping?.intervalMs ?? HOUSEKEEPING_INTERVAL_MS;
1986
+ void housekeepingPass(state);
1987
+ return setInterval(() => void housekeepingPass(state), everyMs);
1988
+ })() : void 0;
1609
1989
  const watchTimer = setInterval(() => {
1610
1990
  const now = nowMs();
1611
1991
  for (const active of state.jobs.values()) {
@@ -1639,6 +2019,7 @@ async function start(configPath, deps = { fetchImpl: fetch }) {
1639
2019
  if (intakeClosed) return;
1640
2020
  intakeClosed = true;
1641
2021
  clearInterval(pollTimer);
2022
+ clearInterval(housekeepingTimer);
1642
2023
  };
1643
2024
  const close = () => {
1644
2025
  stopIntake();
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import { createRequire } from 'node:module';
3
- import { isLinkedRepository } from './chunk-SY4GK4SP.js';
3
+ import { isLinkedRepository } from './chunk-D2KWEAN6.js';
4
4
  import { readStatuslineState, STATE_FRESH_MS } from './chunk-F47JBIAW.js';
5
- import { resolveCredentials, getJson, WorkCommandError } from './chunk-W5FVUZXV.js';
6
- import { sanitizeTerminal } from './chunk-WFJ65NUD.js';
7
- import { resolveRepoIdentity, compareVersions, SUPPORTED_VERSIONS } from './chunk-4C4THABW.js';
5
+ import { resolveCredentials, getJson, WorkCommandError } from './chunk-RMAZ2NWA.js';
6
+ import { sanitizeTerminal } from './chunk-WOBNRVQT.js';
7
+ import { resolveRepoIdentity, compareVersions, SUPPORTED_VERSIONS } from './chunk-EX6M5JPR.js';
8
8
  import { C } from './chunk-YRYMNBGQ.js';
9
9
  import { readSettings, settingsPath, isOurStatuslineSlot, statuslineWrappedOriginal, REGISTERED_EVENTS, isOurHookCommand, ourCommandBins, cliBinResolvable, ourDirectScriptBins, wrappedOriginalCommand } from './chunk-5QRP3MVS.js';
10
10
  import { readPin, readContextManifest, readDegraded, readSourceCache } from './chunk-WIOZAJ2W.js';
@@ -1004,7 +1004,7 @@ function doctorExitCode(checks) {
1004
1004
 
1005
1005
  // src/doctor-report.ts
1006
1006
  async function gatherDoctorChecks() {
1007
- const { defaultRunnerConfigPath, hasRunnerBlock, loadRunnerConfig, runnerVmOf } = await import('./config-XVJYZ4IQ.js');
1007
+ const { defaultRunnerConfigPath, hasRunnerBlock, loadRunnerConfig, runnerVmOf } = await import('./config-HL2AIR3K.js');
1008
1008
  const configPath = defaultRunnerConfigPath();
1009
1009
  const checks = runDoctor({ runnerHost: hasRunnerBlock(configPath) });
1010
1010
  const credential = await credentialChecks();
@@ -1016,7 +1016,7 @@ async function gatherDoctorChecks() {
1016
1016
  const { runnerVmCheck } = await import('./doctor-53F2PYY7.js');
1017
1017
  checks.push(runnerVmCheck(configPath, { lima: realLima(), home: homeFromEnv(), instance: runnerVm.instance }));
1018
1018
  } else if (hasRunnerBlock(configPath)) {
1019
- const { runnerHostChecks } = await import('./doctor-ZLGZYMDK.js');
1019
+ const { runnerHostChecks } = await import('./doctor-MOA3RIEP.js');
1020
1020
  const hooks = (() => {
1021
1021
  try {
1022
1022
  return loadRunnerConfig(configPath).hooks;
@@ -1041,8 +1041,8 @@ async function gatherDoctorChecks() {
1041
1041
  })();
1042
1042
  const runner = (() => {
1043
1043
  try {
1044
- const { concurrency, build } = loadRunnerConfig(configPath);
1045
- return { concurrency, buildMemory: build?.memory ?? "8g" };
1044
+ const { concurrency, build, housekeeping } = loadRunnerConfig(configPath);
1045
+ return { concurrency, buildMemory: build?.memory ?? "8g", minFreeDisk: housekeeping?.minFreeDisk };
1046
1046
  } catch {
1047
1047
  return void 0;
1048
1048
  }
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { createRequire } from 'node:module';
3
3
  import { detectActiveSession } from './chunk-BFESLKPP.js';
4
- import { resolveRepoIdentity } from './chunk-4C4THABW.js';
4
+ import { resolveRepoIdentity } from './chunk-EX6M5JPR.js';
5
5
  import { parseTrackerNumber, trackerNumberError } from './chunk-WZBHBH3J.js';
6
6
  import { defaultFetch } from './chunk-BCLJSQCV.js';
7
7
  import { unauthorizedMessage, resolveGatewayCredentials } from './chunk-BOLTI6LR.js';
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import { createRequire } from 'node:module';
3
- import { dashboardBase, isRepositoryLinked, workPageUrl, notLinkedText } from './chunk-SY4GK4SP.js';
4
- import { sanitizeTerminal } from './chunk-WFJ65NUD.js';
5
- import { remoteLookupFailed } from './chunk-4C4THABW.js';
3
+ import { dashboardBase, isRepositoryLinked, workPageUrl, notLinkedText } from './chunk-D2KWEAN6.js';
4
+ import { sanitizeTerminal } from './chunk-WOBNRVQT.js';
5
+ import { remoteLookupFailed } from './chunk-EX6M5JPR.js';
6
6
  import { resolveGatewayCredentials } from './chunk-BOLTI6LR.js';
7
7
  import { findGitDirs, readRepoConnection, writeRepoConnection } from './chunk-BJ3KTMDH.js';
8
8
  import { createInterface } from 'readline/promises';
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import { createRequire } from 'node:module';
3
3
  import { detectActiveSession } from './chunk-BFESLKPP.js';
4
- import { sanitizeTerminal } from './chunk-WFJ65NUD.js';
5
- import { resolveRepoIdentity } from './chunk-4C4THABW.js';
4
+ import { sanitizeTerminal } from './chunk-WOBNRVQT.js';
5
+ import { resolveRepoIdentity } from './chunk-EX6M5JPR.js';
6
6
  import { parseTrackerNumber, trackerNumberError } from './chunk-WZBHBH3J.js';
7
7
  import { defaultFetch } from './chunk-BCLJSQCV.js';
8
8
  import { resolveGatewayCredentials, unauthorizedMessage } from './chunk-BOLTI6LR.js';
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { createRequire } from 'node:module';
3
- import { MIN_ENGINE_MAJOR } from './chunk-HZRNMGLF.js';
4
- import { firecrackerArch, MICROVM_ARCHES, guestAssetsPinned } from './chunk-ZNA27WEV.js';
3
+ import { MIN_ENGINE_MAJOR } from './chunk-T52PKE2Y.js';
4
+ import { firecrackerArch, MICROVM_ARCHES, guestAssetsPinned } from './chunk-Z62HNKEJ.js';
5
5
  import { spawnSync } from 'child_process';
6
6
  import { existsSync, accessSync, constants, statSync, readFileSync } from 'fs';
7
7