@outerlayer/cli 0.5.0 → 0.6.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 (70) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +9 -0
  3. package/dist/{agent-setup-IFH3FB5X.js → agent-setup-USZ2SAXC.js} +2 -2
  4. package/dist/build-info.json +1 -1
  5. package/dist/{check-AXCXDYO2.js → check-CCVPBN7A.js} +26 -14
  6. package/dist/{chunk-5Y3QQRUH.js → chunk-3DD6AOWC.js} +37 -0
  7. package/dist/{chunk-WOBNRVQT.js → chunk-3DEVYTNQ.js} +1 -1
  8. package/dist/{chunk-L24G4PLP.js → chunk-43J3M6KB.js} +486 -21
  9. package/dist/{chunk-FC7TMRNA.js → chunk-4NLKY4WR.js} +2 -2
  10. package/dist/{lima-XN65D7GN.js → chunk-77MM5RYD.js} +0 -1
  11. package/dist/{chunk-D2KWEAN6.js → chunk-7DBI6SNT.js} +1 -1
  12. package/dist/{chunk-QDQEUVUF.js → chunk-AIW6GLET.js} +1 -1
  13. package/dist/{chunk-EX6M5JPR.js → chunk-ANK2B36Y.js} +131 -15
  14. package/dist/{chunk-RMAZ2NWA.js → chunk-BNMCXPG7.js} +14 -2
  15. package/dist/{chunk-UFFXNXLQ.js → chunk-FA2BOX4O.js} +31 -29
  16. package/dist/chunk-FBTE2A2X.js +38 -0
  17. package/dist/{chunk-DVDEBNQJ.js → chunk-GQD22KKN.js} +17 -6
  18. package/dist/{chunk-BCJNHMZT.js → chunk-HHJDN6AU.js} +18 -3
  19. package/dist/{chunk-JLDZTG3A.js → chunk-IASWYRVG.js} +1 -1
  20. package/dist/{install-cmd-7WXOIFDO.js → chunk-JWOQNNN2.js} +0 -1
  21. package/dist/{chunk-ZLB7GCTY.js → chunk-MIGEDH5P.js} +19 -11
  22. package/dist/{chunk-OHFDWLJP.js → chunk-MXB5VB4Y.js} +18 -6
  23. package/dist/chunk-NQXFAGHU.js +211 -0
  24. package/dist/chunk-OUUINEYB.js +27 -0
  25. package/dist/{chunk-OR6AEEDO.js → chunk-PMAMOSTP.js} +3 -3
  26. package/dist/{chunk-RJ467PDQ.js → chunk-RBOMWNOG.js} +1 -1
  27. package/dist/{chunk-4NMBDRBS.js → chunk-RZSF5B46.js} +289 -130
  28. package/dist/chunk-T6OIJQRT.js +31 -0
  29. package/dist/chunk-UGP76IZ5.js +34 -0
  30. package/dist/{chunk-EGABNHZG.js → chunk-UMFWVGJO.js} +148 -18
  31. package/dist/{chunk-RWOTHXYE.js → chunk-VMJ3MMBX.js} +206 -71
  32. package/dist/chunk-YYYRBYWG.js +32 -0
  33. package/dist/chunk-ZILGTTY6.js +75 -0
  34. package/dist/{cli-NNWPFP5S.js → cli-7WCNPBYC.js} +147 -69
  35. package/dist/command-ARFK3OYF.js +162 -0
  36. package/dist/{config-XVJYZ4IQ.js → config-HL2AIR3K.js} +1 -1
  37. package/dist/{connect-cmd-OJBFHZWP.js → connect-cmd-PCESZLTR.js} +4 -4
  38. package/dist/{context-adopt-UMA4O5NY.js → context-adopt-G5JAELE7.js} +2 -2
  39. package/dist/{context-materialize-G2FUFC72.js → context-materialize-3HLPZAF6.js} +2 -2
  40. package/dist/{dist-WDV7U7UJ.js → dist-RDLP4GGJ.js} +1 -1
  41. package/dist/{doctor-J3GIMR7K.js → doctor-VOMYOHQU.js} +47 -13
  42. package/dist/{emit-artifact-cmd-FDWYKURA.js → emit-artifact-cmd-TBF46VXZ.js} +6 -7
  43. package/dist/{emit-criteria-cmd-OCXHLK34.js → emit-criteria-cmd-3427XKFW.js} +3 -3
  44. package/dist/{emit-finding-cmd-POW7PKIA.js → emit-finding-cmd-GSI75K36.js} +3 -3
  45. package/dist/{emit-result-cmd-ID35SH53.js → emit-result-cmd-3PRQKB55.js} +6 -7
  46. package/dist/{hook-fast-EYENPK6F.js → hook-fast-NJBBZQ5Q.js} +1 -1
  47. package/dist/index.js +3 -2
  48. package/dist/{init-CZX7ZDWF.js → init-OHYFIKZD.js} +27 -56
  49. package/dist/{init-3ZQLFPNC.js → init-WVR4BZQ4.js} +8 -8
  50. package/dist/{init-cmd-W5NOLQPX.js → init-cmd-HMFNIFPI.js} +7 -7
  51. package/dist/inspect-XAUSB7L3.js +63 -0
  52. package/dist/install-DSUZE2TJ.js +13 -0
  53. package/dist/install-cmd-453C7IZT.js +8 -0
  54. package/dist/lima-7NPCNGWT.js +6 -0
  55. package/dist/{loop-VGM772I7.js → loop-UAJB34KA.js} +26 -11
  56. package/dist/{machine-5WEJA36F.js → machine-2JOWGRSZ.js} +7 -7
  57. package/dist/record-C43U4PKS.js +7 -0
  58. package/dist/session-sender-ED26V45B.js +28 -0
  59. package/dist/{status-KBDVUYQ3.js → status-6WNXX2O7.js} +42 -10
  60. package/dist/{sync-cmd-SWQ5KLPL.js → sync-cmd-YONVGUYS.js} +6 -7
  61. package/dist/{watch-3YW6Z3DQ.js → watch-HHKAF26L.js} +6 -7
  62. package/dist/{work-claim-cmd-BQPPURBW.js → work-claim-cmd-LXBZTMJA.js} +6 -6
  63. package/dist/{work-cmd-NUR4CAG6.js → work-cmd-OVNF44FY.js} +3 -3
  64. package/dist/{work-comment-cmd-6TTTYGHM.js → work-comment-cmd-6DGQNTAH.js} +3 -3
  65. package/dist/{work-launch-YI4CJDAP.js → work-launch-R5HTAP6X.js} +1 -1
  66. package/dist/{work-open-pr-cmd-KZ7QQFLD.js → work-open-pr-cmd-FCZHFHZS.js} +4 -4
  67. package/dist/{work-pr-cmd-QNUBE6IQ.js → work-pr-cmd-QJJVHZDC.js} +2 -2
  68. package/package.json +2 -1
  69. package/dist/chunk-ZOC7JSYC.js +0 -83
  70. package/dist/{chunk-XDDW4FRS.js → chunk-OIRNH6UO.js} +1 -1
@@ -1,30 +1,78 @@
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-RWOTHXYE.js';
4
- import { RUNNER_PROTOCOL } from './chunk-QDQEUVUF.js';
5
3
  import { fetchVersionMeta, npmVersion, npmSupportsProvenance, fetchTarballDigest, fetchProvenance, installVersion, auditSignatures } from './chunk-ABIWDUMS.js';
6
4
  import { readMarker, currentVersion, removeMarker, writeMarker, entryIn, versionDir, switchCurrent, cleanVersions, isInLayout, startedThroughCurrent, stagingDir } from './chunk-YYYXRJUW.js';
5
+ import { LEASE_SECONDS, releaseLease, isUnfinished, setStage, jobEnv, runHook, describeHookEnd, provisionMicroVm, deliverCommitHooks, provisionProcess, provisionEnvironment, readProvisionOut, resolveHostBuild, workdirProblem, listenOnBroker, hostEnvironment, hostCommandEnvironment, execArgv, microVmCommandLine, runCommand, execCommandLine, startSender, startHostServices, finishJob, isGroupAlive, outcomeFromExitCode, needsRenew, renewLease, RENEW_INTERVAL_SECONDS, isTimedOut, readCpuUsec, isIdle, stopOrphanedMicroVms, unfinishedJobs, listAdoptableJobs, retryFailedReleases, recoverLostJobs, hookLimitsFor, ALREADY_RELEASED } from './chunk-VMJ3MMBX.js';
6
+ import { writeJobFile, readJobFile, readJobFileChecked } from './chunk-T6OIJQRT.js';
7
+ import { RUNNER_PROTOCOL } from './chunk-AIW6GLET.js';
8
+ import { readHousekeepingRecord, writeHousekeepingRecord } from './chunk-UGP76IZ5.js';
9
+ import { placeholderQueues, placeholderProblem } from './chunk-MXB5VB4Y.js';
10
+ import { freeBytes, formatBytes } from './chunk-YYYRBYWG.js';
7
11
  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-4NMBDRBS.js';
12
+ import { checkFit } from './chunk-OUUINEYB.js';
13
+ import { describeCallFailure, retrySend, shortClaim, relativeToRunner, formatDuration, provisionContainer, lifecycleSteps, lifecycleFailureReason, prepareBranch, containerCommandLine, BuildTokens, classifyRefusal, refusalCode, formatClock, measureDisk, createRunnerLog, setUpRunnerCgroup, ensureTunnel, keepGhConfigCurrent, createContainerRuntime, runnerLabelValue, BUILD_LABEL, RUNNER_LABEL } from './chunk-RZSF5B46.js';
9
14
  import { startTimeOf, claimPidFile, readPidFile, isSameProcess, releasePidFile } from './chunk-N5FOE5PS.js';
10
- import { isSignatureRefusal, WorkCommandError, getJson, postJson } from './chunk-RMAZ2NWA.js';
11
- import { isBuiltinContainer, isBuiltinVm, isBuiltinProcess, loadRunnerConfig, checkHooks } from './chunk-5Y3QQRUH.js';
15
+ import { spawnRun } from './chunk-774HEQL6.js';
16
+ import { isSignatureRefusal, WorkCommandError, getJson, postJson } from './chunk-BNMCXPG7.js';
17
+ import { isBuiltinContainer, isBuiltinVm, isBuiltinProcess, loadRunnerConfig, checkHooks, builtinKind } from './chunk-3DD6AOWC.js';
12
18
  import { exceedsDisk } from './chunk-F6GFZXZ3.js';
13
- import { parseClaimIdentity, sharedUserEnvironment, JOB_MOUNT } from './chunk-AUFG23AA.js';
19
+ import { parseClaimIdentity, sharedUserEnvironment, JOB_MOUNT, claudeCredential } from './chunk-AUFG23AA.js';
14
20
  import { resolveVersion } from './chunk-RA3O54FC.js';
15
21
  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';
22
+ import { RUNNER_DIR_MODE, runnerDir, buildDir, attemptStamp, jobDir, jobsDir, pidFilePath, spoolDir, RUNNER_FILE_MODE, rootfsDir } from './chunk-ZM2IMMYN.js';
17
23
  import { HOOKS_DIR_NAME, writeCommitHooks } from './chunk-XCCLFVXM.js';
18
24
  import { randomBytes } from 'crypto';
19
- import { mkdirSync, existsSync, chmodSync, statSync, readdirSync, readFileSync, rmSync, renameSync, writeFileSync } from 'fs';
25
+ import { mkdirSync, existsSync, chmodSync, statSync, readdirSync, readFileSync, rmSync, renameSync, writeFileSync, closeSync, openSync, constants, lstatSync } from 'fs';
20
26
  import { join, dirname } from 'path';
27
+ import { totalmem } from 'os';
21
28
 
22
29
  createRequire(import.meta.url);
30
+ function openKvm() {
31
+ try {
32
+ closeSync(openSync("/dev/kvm", constants.O_RDWR));
33
+ return "usable";
34
+ } catch (err) {
35
+ return err.code === "ENOENT" ? "absent" : "no-permission";
36
+ }
37
+ }
38
+ function realBlockedProbes() {
39
+ return { env: process.env, kvm: openKvm, totalMemoryBytes: totalmem };
40
+ }
41
+ var DEFAULT_BUILD_MEMORY = "8g";
42
+ function checkBlocked(config, probes) {
43
+ const placeholders = placeholderQueues(config);
44
+ if (placeholders.length > 0) return { reason: `${placeholderProblem(placeholders)}. The runner takes work again once the config is edited` };
45
+ const kind = builtinKind(config.hooks.provision);
46
+ if (kind === "container" || kind === "vm") {
47
+ const fit = checkFit({
48
+ concurrency: config.concurrency,
49
+ buildMemory: config.build?.memory ?? DEFAULT_BUILD_MEMORY,
50
+ usableBytes: probes.totalMemoryBytes()
51
+ });
52
+ if (!fit.fits) return { reason: `${fit.message}. Lower runner.concurrency or runner.build.memory` };
53
+ }
54
+ const kvm = kind === "vm" ? probes.kvm() : "usable";
55
+ if (kvm === "absent") {
56
+ return { reason: "/dev/kvm does not exist on this machine, so builtin:vm cannot start a microVM. Use a host with KVM enabled, or builtin:container hooks" };
57
+ }
58
+ if (kvm !== "usable") {
59
+ return {
60
+ reason: "the runner cannot open /dev/kvm, so builtin:vm cannot start a microVM. Add the runner's account to the kvm group, then restart the service, because a service reads its groups only when it starts"
61
+ };
62
+ }
63
+ if (kind !== "own" && claudeCredential(probes.env) === void 0) {
64
+ return {
65
+ reason: "there is no Claude credential in the runner's environment. Put CLAUDE_CODE_OAUTH_TOKEN (from `claude setup-token`) or ANTHROPIC_API_KEY in the runner.env beside the config, then restart the service"
66
+ };
67
+ }
68
+ return void 0;
69
+ }
23
70
 
24
71
  // src/runner/heartbeat.ts
25
72
  var HEARTBEAT_TIMEOUT_MS = 1e4;
26
73
  var ERROR_CODE_MAX = 100;
27
74
  var ERROR_MESSAGE_MAX = 500;
75
+ var BLOCKED_REASON_MAX = 500;
28
76
  var UNREACHABLE_MESSAGE = "the request did not reach the platform";
29
77
  function noPlatformMessage(status) {
30
78
  return `the request got HTTP ${status} with no error message from the platform`;
@@ -68,7 +116,11 @@ async function sendHeartbeat(args, fetchImpl) {
68
116
  outcome: args.lastUpdate.outcome,
69
117
  reason: args.lastUpdate.reason,
70
118
  ...args.lastUpdate.at !== void 0 ? { at: args.lastUpdate.at } : {}
71
- }
119
+ },
120
+ // Free disk on the runner's file system, in bytes. Sent only while held, so a host with room sends nothing about its disk.
121
+ lowDisk: args.lowDisk === void 0 ? void 0 : { free: args.lowDisk.free, needed: args.lowDisk.needed },
122
+ // Why the runner claims nothing. Sent only while it holds, so a runner that takes work says nothing about it.
123
+ blocked: args.blocked === void 0 ? void 0 : { reason: cutForGateway(args.blocked.reason, BLOCKED_REASON_MAX) }
72
124
  };
73
125
  try {
74
126
  const response = await postJson(
@@ -371,9 +423,9 @@ function retryFailure(version, err) {
371
423
  }
372
424
  return { outcome: "retrying", reason: messageOf(err) };
373
425
  }
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") {
426
+ function finish(args, failure2) {
427
+ let update = { from: args.own, to: args.version, outcome: failure2.outcome, reason: failure2.reason, at: isoAt(args.now) };
428
+ if (failure2.outcome === "retrying") {
377
429
  const previous = readLastUpdate(args.configPath);
378
430
  const attempts = previous?.to === args.version && previous.outcome === "retrying" ? (previous.attempts ?? 1) + 1 : 1;
379
431
  update = { ...update, attempts };
@@ -383,16 +435,16 @@ function finish(args, failure) {
383
435
  } catch (err) {
384
436
  args.log.error(`could not record the update attempt: ${messageOf(err)}`);
385
437
  }
386
- if (failure.outcome === "retrying") {
438
+ if (failure2.outcome === "retrying") {
387
439
  return {
388
440
  kind: "retrying",
389
- reason: failure.reason,
390
- detail: failure.detail ?? failure.reason,
441
+ reason: failure2.reason,
442
+ detail: failure2.detail ?? failure2.reason,
391
443
  attempts: update.attempts ?? 1,
392
444
  retryAtMs: retryAfterMs(update)
393
445
  };
394
446
  }
395
- return { kind: "failed", outcome: failure.outcome, reason: failure.reason };
447
+ return { kind: "failed", outcome: failure2.outcome, reason: failure2.reason };
396
448
  }
397
449
  async function earlyFailure(args) {
398
450
  const current = currentVersion(args.configPath);
@@ -423,8 +475,8 @@ async function earlyFailure(args) {
423
475
  return void 0;
424
476
  }
425
477
  async function preflightUpdate(args) {
426
- const failure = await earlyFailure(args);
427
- return failure === void 0 ? void 0 : finish(args, failure);
478
+ const failure2 = await earlyFailure(args);
479
+ return failure2 === void 0 ? void 0 : finish(args, failure2);
428
480
  }
429
481
  async function performUpdate(args) {
430
482
  const { configPath, own, version, deps } = args;
@@ -525,12 +577,325 @@ function settleUpdate(args) {
525
577
  }
526
578
  }
527
579
 
580
+ // src/runner/housekeeping/docker.ts
581
+ var KIND_LABEL = "ai.outerlayer.kind";
582
+ function failure(result, what) {
583
+ return new Error(`${what}: ${result.stderr.trim() || `docker exited ${result.status}`}`);
584
+ }
585
+ function labelled(stdout, runnerId) {
586
+ const found = [];
587
+ for (const line of stdout.split("\n")) {
588
+ const [key = "", build = "", runner = ""] = line.replace(/\r?\n$/, "").split(" ");
589
+ if (key.trim() === "" || build === "") continue;
590
+ if (runner !== "" && runner !== runnerId) continue;
591
+ found.push({ key: key.trim(), build, owned: runner === runnerId });
592
+ }
593
+ return found;
594
+ }
595
+ function decimalUnits() {
596
+ return { b: 1, kb: 1e3, mb: 1e6, gb: 1e9, tb: 1e12, pb: 1e15 };
597
+ }
598
+ function parsePrunedBytes(stdout) {
599
+ const match = /^Total(?: reclaimed space)?:\s*([\d.]+)\s*([A-Za-z]*)\s*$/im.exec(stdout);
600
+ if (match === null) return 0;
601
+ const unit = decimalUnits()[match[2].toLowerCase() || "b"];
602
+ return unit === void 0 ? 0 : Math.round(Number(match[1]) * unit);
603
+ }
604
+ function createHousekeepingDocker(deps) {
605
+ const run = deps.run ?? spawnRun;
606
+ return {
607
+ async listBuildContainers() {
608
+ const result = await run("docker", [
609
+ "ps",
610
+ "--all",
611
+ "--no-trunc",
612
+ "--filter",
613
+ `label=${BUILD_LABEL}`,
614
+ "--format",
615
+ `{{.ID}} {{.Label "${BUILD_LABEL}"}} {{.Label "${RUNNER_LABEL}"}}`
616
+ ]);
617
+ if (result.status !== 0) throw failure(result, "docker ps");
618
+ return labelled(result.stdout, deps.runnerId).map(({ key, build, owned }) => ({ id: key, build, owned }));
619
+ },
620
+ async listBuildVolumes() {
621
+ const result = await run("docker", [
622
+ "volume",
623
+ "ls",
624
+ "--filter",
625
+ `label=${BUILD_LABEL}`,
626
+ "--format",
627
+ `{{.Name}} {{.Label "${BUILD_LABEL}"}} {{.Label "${RUNNER_LABEL}"}}`
628
+ ]);
629
+ if (result.status !== 0) throw failure(result, "docker volume ls");
630
+ return labelled(result.stdout, deps.runnerId).map(({ key, build, owned }) => ({ name: key, build, owned }));
631
+ },
632
+ async removeContainer(id) {
633
+ const result = await run("docker", ["rm", "--force", "--volumes", id]);
634
+ if (result.status !== 0) throw failure(result, `docker rm ${id}`);
635
+ },
636
+ async removeVolume(name) {
637
+ const result = await run("docker", ["volume", "rm", "--force", name]);
638
+ if (result.status !== 0) throw failure(result, `docker volume rm ${name}`);
639
+ },
640
+ async imageState(ref) {
641
+ const result = await run("docker", ["image", "inspect", "--format", "{{.Id}}", ref]);
642
+ if (result.status === 0) return "present";
643
+ return /no such image|not found/i.test(result.stderr) ? "missing" : "unknown";
644
+ },
645
+ async listDanglingImages() {
646
+ const listed = await run("docker", [
647
+ "image",
648
+ "ls",
649
+ "--all",
650
+ "--no-trunc",
651
+ "--quiet",
652
+ "--filter",
653
+ "dangling=true",
654
+ "--filter",
655
+ `label=${KIND_LABEL}`
656
+ ]);
657
+ if (listed.status !== 0) throw failure(listed, "docker image ls");
658
+ const ids = [...new Set(listed.stdout.split("\n").map((l) => l.trim()).filter(Boolean))];
659
+ if (ids.length === 0) return [];
660
+ const inspected = await run("docker", ["image", "inspect", ...ids]);
661
+ if (inspected.status !== 0) throw failure(inspected, "docker image inspect");
662
+ return JSON.parse(inspected.stdout).map((row) => ({
663
+ id: row.Id,
664
+ size: row.Size ?? 0,
665
+ labels: row.Config?.Labels ?? {}
666
+ }));
667
+ },
668
+ async removeImage(id) {
669
+ const result = await run("docker", ["rmi", id]);
670
+ if (result.status !== 0) throw failure(result, `docker rmi ${id}`);
671
+ },
672
+ async builderPrune(keepBytes) {
673
+ const result = await run("docker", ["builder", "prune", "--force", "--keep-storage", String(keepBytes)]);
674
+ if (result.status !== 0) throw failure(result, "docker builder prune");
675
+ return { freedBytes: parsePrunedBytes(result.stdout) };
676
+ }
677
+ };
678
+ }
679
+
680
+ // src/runner/housekeeping/select.ts
681
+ var DAY_MS = 864e5;
682
+ function spoolMaxAgeMs() {
683
+ return 24 * 36e5;
684
+ }
685
+ function selectJobDirs(entries, nowMs, keepDays) {
686
+ return entries.filter(
687
+ (entry) => (entry.state === "finished" || entry.state === "no_job_file") && nowMs - entry.mtimeMs > keepDays * DAY_MS
688
+ );
689
+ }
690
+ function liveBuildIds(jobs) {
691
+ const ids = /* @__PURE__ */ new Set();
692
+ for (const job of jobs) {
693
+ for (const id of [job.container?.buildId, job.microvm?.buildId, job.build?.buildId]) if (id !== void 0) ids.add(id);
694
+ }
695
+ return ids;
696
+ }
697
+ function finishedBuildIds(jobs) {
698
+ return liveBuildIds(jobs);
699
+ }
700
+ function liveImageIds(jobs) {
701
+ const ids = /* @__PURE__ */ new Set();
702
+ for (const job of jobs) {
703
+ for (const id of [job.container?.imageId, job.microvm?.imageId]) if (id !== void 0) ids.add(id);
704
+ }
705
+ return ids;
706
+ }
707
+ function selectOrphans(items, live, finished) {
708
+ return items.filter((item) => !live.has(item.build) && (item.owned || finished.has(item.build)));
709
+ }
710
+ function rootDiskImageId(fileName) {
711
+ if (!fileName.endsWith(".ext4") || fileName.includes(".partial-")) return void 0;
712
+ const id = fileName.slice(0, -".ext4".length);
713
+ return id === "" ? void 0 : id;
714
+ }
715
+ function selectSpool(files, nowMs, buildRunning) {
716
+ return buildRunning ? [] : files.filter((file) => nowMs - file.mtimeMs > spoolMaxAgeMs());
717
+ }
718
+
719
+ // src/runner/housekeeping/run.ts
720
+ function sizeOf(path) {
721
+ try {
722
+ const stats = lstatSync(path);
723
+ if (!stats.isDirectory()) return stats.size;
724
+ return readdirSync(path).reduce((sum, name) => sum + sizeOf(join(path, name)), 0);
725
+ } catch {
726
+ return 0;
727
+ }
728
+ }
729
+ function listDir(path) {
730
+ try {
731
+ return readdirSync(path);
732
+ } catch {
733
+ return [];
734
+ }
735
+ }
736
+ function mtimeOf(path) {
737
+ try {
738
+ return statSync(path).mtimeMs;
739
+ } catch {
740
+ return void 0;
741
+ }
742
+ }
743
+ function readJobs(jobsPath) {
744
+ const entries = [];
745
+ const unfinished = [];
746
+ const finished = [];
747
+ const unreadable = [];
748
+ for (const dirent of existsSync(jobsPath) ? readdirSync(jobsPath, { withFileTypes: true }) : []) {
749
+ if (!dirent.isDirectory()) continue;
750
+ const dirPath = join(jobsPath, dirent.name);
751
+ const mtimeMs = mtimeOf(dirPath);
752
+ if (mtimeMs === void 0) continue;
753
+ const read = readJobFileChecked(join(dirPath, "job.json"));
754
+ let state;
755
+ if (read.kind === "missing") {
756
+ state = "no_job_file";
757
+ } else if (read.kind === "unreadable") {
758
+ unreadable.push(dirent.name);
759
+ state = "unreadable";
760
+ } else if (isUnfinished(read.job)) {
761
+ unfinished.push(read.job);
762
+ state = "unfinished";
763
+ } else {
764
+ finished.push(read.job);
765
+ state = read.job.releaseFailed === void 0 || read.job.releaseFailed === ALREADY_RELEASED ? "finished" : "release_failed";
766
+ }
767
+ entries.push({ name: dirent.name, mtimeMs, state });
768
+ }
769
+ return { entries, unfinished, finished, unreadable };
770
+ }
771
+ async function removeDanglingImages(docker, log, live) {
772
+ let freed = 0;
773
+ for (const image of await docker.listDanglingImages()) {
774
+ if (image.labels[KIND_LABEL] === void 0) continue;
775
+ if (live.has(image.id)) continue;
776
+ try {
777
+ await docker.removeImage(image.id);
778
+ freed += image.size;
779
+ log.line(`housekeeping: removed dangling image ${image.id.replace(/^sha256:/, "").slice(0, 12)} (${formatBytes(image.size)}), a rebuild replaced it`);
780
+ } catch (err) {
781
+ log.error(`housekeeping: could not remove dangling image ${image.id}: ${describeCallFailure(err)}`);
782
+ }
783
+ }
784
+ return freed;
785
+ }
786
+ async function runHousekeeping(options) {
787
+ const { docker, log } = options;
788
+ const nowMs = options.now().getTime();
789
+ const root = runnerDir(options.configPath);
790
+ let freed = 0;
791
+ let listed;
792
+ try {
793
+ const containers = await docker.listBuildContainers();
794
+ listed = { containers, volumes: await docker.listBuildVolumes() };
795
+ } catch (err) {
796
+ log.error(`housekeeping: Docker could not be asked, so nothing in Docker is removed this pass: ${describeCallFailure(err)}`);
797
+ }
798
+ const jobs = readJobs(jobsDir(options.configPath));
799
+ for (const name of jobs.unreadable) {
800
+ log.error(`housekeeping: job file ${name}/job.json does not parse, so nothing in Docker is removed this pass`);
801
+ }
802
+ const canTell = jobs.unreadable.length === 0;
803
+ for (const entry of selectJobDirs(jobs.entries, nowMs, options.keepDays)) {
804
+ const path = join(jobsDir(options.configPath), entry.name);
805
+ const size = sizeOf(path);
806
+ try {
807
+ rmSync(path, { recursive: true, force: true });
808
+ freed += size;
809
+ log.line(`housekeeping: removed job directory ${entry.name} (${formatBytes(size)}), last changed ${formatDuration(nowMs - entry.mtimeMs)} ago`);
810
+ } catch (err) {
811
+ log.error(`housekeeping: could not remove job directory ${entry.name}: ${describeCallFailure(err)}`);
812
+ }
813
+ }
814
+ const live = liveBuildIds(jobs.unfinished);
815
+ const finished = finishedBuildIds(jobs.finished);
816
+ if (canTell && listed !== void 0) {
817
+ for (const container of selectOrphans(listed.containers, live, finished)) {
818
+ try {
819
+ await docker.removeContainer(container.id);
820
+ log.line(`housekeeping: removed container ${container.id.slice(0, 12)}, no unfinished build ${container.build} names it`);
821
+ } catch (err) {
822
+ log.error(`housekeeping: could not remove container ${container.id}: ${describeCallFailure(err)}`);
823
+ }
824
+ }
825
+ for (const volume of selectOrphans(listed.volumes, live, finished)) {
826
+ try {
827
+ await docker.removeVolume(volume.name);
828
+ log.line(`housekeeping: removed volume ${volume.name}, no unfinished build ${volume.build} names it`);
829
+ } catch (err) {
830
+ log.error(`housekeeping: could not remove volume ${volume.name}: ${describeCallFailure(err)}`);
831
+ }
832
+ }
833
+ }
834
+ if (canTell && listed !== void 0) {
835
+ freed += await removeStaleRootDisks(root, jobs.unfinished, options);
836
+ try {
837
+ freed += await removeDanglingImages(docker, log, liveImageIds(jobs.unfinished));
838
+ } catch (err) {
839
+ log.error(`housekeeping: dangling images could not be listed: ${describeCallFailure(err)}`);
840
+ }
841
+ if (options.buildCacheMax !== "off") {
842
+ try {
843
+ const pruned = await docker.builderPrune(options.buildCacheMax);
844
+ freed += pruned.freedBytes;
845
+ log.line(`housekeeping: pruned the build cache down to ${formatBytes(options.buildCacheMax)}, freed ${formatBytes(pruned.freedBytes)}`);
846
+ } catch (err) {
847
+ log.error(`housekeeping: the build cache could not be pruned: ${describeCallFailure(err)}`);
848
+ }
849
+ }
850
+ }
851
+ const spoolPath = spoolDir(root);
852
+ const spoolFiles = listDir(spoolPath).flatMap((name) => {
853
+ const mtimeMs = mtimeOf(join(spoolPath, name));
854
+ return mtimeMs === void 0 ? [] : [{ name, mtimeMs }];
855
+ });
856
+ for (const file of selectSpool(spoolFiles, nowMs, jobs.unfinished.length > 0)) {
857
+ const path = join(spoolPath, file.name);
858
+ const size = sizeOf(path);
859
+ try {
860
+ rmSync(path, { recursive: true, force: true });
861
+ freed += size;
862
+ log.line(`housekeeping: removed spool file ${file.name} (${formatBytes(size)}), last changed ${formatDuration(nowMs - file.mtimeMs)} ago and no build is running`);
863
+ } catch (err) {
864
+ log.error(`housekeeping: could not remove spool file ${file.name}: ${describeCallFailure(err)}`);
865
+ }
866
+ }
867
+ log.line(`housekeeping: pass finished, freed ${formatBytes(freed)}`);
868
+ return { freedBytes: freed };
869
+ }
870
+ async function removeStaleRootDisks(root, unfinished, options) {
871
+ const dir = rootfsDir(root);
872
+ const inUse = new Set(unfinished.flatMap((job) => job.microvm?.rootfs === void 0 ? [] : [job.microvm.rootfs]));
873
+ let freed = 0;
874
+ for (const name of listDir(dir)) {
875
+ const imageId = rootDiskImageId(name);
876
+ if (imageId === void 0) continue;
877
+ const path = join(dir, name);
878
+ if (inUse.has(path)) continue;
879
+ if (await options.docker.imageState(`sha256:${imageId}`) !== "missing") continue;
880
+ const size = sizeOf(path);
881
+ try {
882
+ rmSync(path, { force: true });
883
+ freed += size;
884
+ options.log.line(`housekeeping: removed root disk ${name} (${formatBytes(size)}), Docker no longer has its image`);
885
+ } catch (err) {
886
+ options.log.error(`housekeeping: could not remove root disk ${name}: ${describeCallFailure(err)}`);
887
+ }
888
+ }
889
+ return freed;
890
+ }
891
+
528
892
  // src/runner/loop.ts
529
893
  var QUEUE_ORDER = ["amend", "implement"];
530
894
  var TRACKER_REPOSITORY_HOSTS = {
531
895
  github: "github.com",
532
896
  gitlab: "gitlab.com"
533
897
  };
898
+ var HOUSEKEEPING_INTERVAL_MS = 36e5;
534
899
  function logOf(state) {
535
900
  state.deps.log ??= createRunnerLog(state.deps.now ?? (() => /* @__PURE__ */ new Date()));
536
901
  return state.deps.log;
@@ -645,7 +1010,9 @@ async function sendTickHeartbeat(state, workRequest) {
645
1010
  slotsUsed: state.jobs.size,
646
1011
  slotsTotal: state.config.concurrency,
647
1012
  workRequest: next,
648
- lastUpdate: readLastUpdate(state.config.configPath)
1013
+ lastUpdate: readLastUpdate(state.config.configPath),
1014
+ lowDisk: state.lowDisk,
1015
+ blocked: state.blocked
649
1016
  },
650
1017
  state.deps.fetchImpl
651
1018
  );
@@ -769,9 +1136,84 @@ async function tick(state) {
769
1136
  }
770
1137
  await sendTickHeartbeat(state, state.lastWorkRequest);
771
1138
  }
1139
+ function holdWhenBlocked(state) {
1140
+ const probes = {
1141
+ ...realBlockedProbes(),
1142
+ ...state.deps.container?.hostEnv === void 0 ? {} : { env: state.deps.container.hostEnv },
1143
+ ...state.deps.blocked
1144
+ };
1145
+ const blocked = checkBlocked(state.config, probes);
1146
+ const log = logOf(state);
1147
+ if (blocked !== void 0) {
1148
+ if (state.blocked?.reason !== blocked.reason) log.line(`claiming nothing: ${blocked.reason}`);
1149
+ state.blocked = blocked;
1150
+ return true;
1151
+ }
1152
+ if (state.blocked !== void 0) {
1153
+ state.blocked = void 0;
1154
+ log.line("the runner can run its work again \xB7 taking work");
1155
+ }
1156
+ return false;
1157
+ }
1158
+ function holdOffForDisk(state) {
1159
+ const minimum = state.config.housekeeping?.minFreeDisk ?? 0;
1160
+ const root = runnerDir(state.config.configPath);
1161
+ const free = minimum === 0 ? Number.POSITIVE_INFINITY : (state.deps.freeBytes ?? freeBytes)(root) ?? Number.POSITIVE_INFINITY;
1162
+ const log = logOf(state);
1163
+ if (free < minimum) {
1164
+ if (state.lowDisk === void 0) {
1165
+ log.line(
1166
+ `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`
1167
+ );
1168
+ recordHousekeeping(state, root, { holding: { free, needed: minimum, since: nowOf(state).toISOString() } });
1169
+ }
1170
+ state.lowDisk = { free, needed: minimum };
1171
+ return true;
1172
+ }
1173
+ if (state.lowDisk !== void 0) {
1174
+ state.lowDisk = void 0;
1175
+ log.line("free disk is back above the minimum \xB7 taking work again");
1176
+ recordHousekeeping(state, root, { holding: void 0 });
1177
+ } else if (!state.holdRecordChecked) {
1178
+ if (readHousekeepingRecord(root).holding !== void 0) recordHousekeeping(state, root, { holding: void 0 });
1179
+ }
1180
+ state.holdRecordChecked = true;
1181
+ return false;
1182
+ }
1183
+ function recordHousekeeping(state, root, patch) {
1184
+ try {
1185
+ writeHousekeepingRecord(root, patch);
1186
+ } catch (err) {
1187
+ logOf(state).error(`could not write housekeeping.json: ${describeCallFailure(err)}`);
1188
+ }
1189
+ }
1190
+ async function housekeepingPass(state) {
1191
+ const config = state.config.housekeeping;
1192
+ if (config === void 0 || state.housekeepingRunning) return;
1193
+ state.housekeepingRunning = true;
1194
+ try {
1195
+ state.deps.housekeeping ??= {};
1196
+ state.deps.housekeeping.docker ??= createHousekeepingDocker({ runnerId: runnerLabelValue(state.config.configPath) });
1197
+ const result = await runHousekeeping({
1198
+ configPath: state.config.configPath,
1199
+ keepDays: config.jobsKeepDays,
1200
+ buildCacheMax: config.buildCacheMax,
1201
+ docker: state.deps.housekeeping.docker,
1202
+ now: state.deps.now ?? (() => /* @__PURE__ */ new Date()),
1203
+ log: logOf(state)
1204
+ });
1205
+ recordHousekeeping(state, runnerDir(state.config.configPath), { lastRunAt: nowOf(state).toISOString(), freedBytes: result.freedBytes });
1206
+ } catch (err) {
1207
+ logOf(state).error(`housekeeping failed: ${describeCallFailure(err)}`);
1208
+ } finally {
1209
+ state.housekeepingRunning = false;
1210
+ }
1211
+ }
772
1212
  async function runTick(state) {
773
1213
  reloadConfig(state);
774
1214
  if (await updateWhenIdle(state)) return;
1215
+ if (holdWhenBlocked(state)) return;
1216
+ if (holdOffForDisk(state)) return;
775
1217
  const log = logOf(state);
776
1218
  const repoFilter = new RepoFilter(state.config.repos);
777
1219
  let filtered = 0;
@@ -825,7 +1267,7 @@ var CLAIM_REFUSAL_CODES = new Set(
825
1267
  "pull_request_from_fork pull_request_on_default_branch pull_request_ambiguous branch_claimed_elsewhere".split(" ")
826
1268
  );
827
1269
  async function tryClaimAndStart(state, item, queue) {
828
- const { postJson: postJson2 } = await import('./work-cmd-NUR4CAG6.js');
1270
+ const { postJson: postJson2 } = await import('./work-cmd-OVNF44FY.js');
829
1271
  const log = logOf(state);
830
1272
  const hostKey = await loadHostKey(state.config.configPath);
831
1273
  const claim = await retrySend(
@@ -1304,6 +1746,17 @@ async function startJob(state, item, queue, claimId, itemKey, branch, identity)
1304
1746
  writeJobFile(jobFilePath, job);
1305
1747
  const commandStartedMs = nowOf(state).getTime();
1306
1748
  log.line(`${tag} command started \xB7 pid ${running.pid}`);
1749
+ const sender = await startSender({
1750
+ config: state.config,
1751
+ job,
1752
+ jobDir: dirPath,
1753
+ deps: { spawnFn: state.deps.spawnFn, runtime: job.microvm !== void 0 || job.container !== void 0 ? runtimeOf(state) : void 0, log }
1754
+ });
1755
+ if (sender !== void 0) {
1756
+ job = { ...active.job, sender };
1757
+ active.job = job;
1758
+ writeJobFile(jobFilePath, job);
1759
+ }
1307
1760
  running.onExit.then(({ code, signal, error }) => {
1308
1761
  if (active.ending) return;
1309
1762
  if (error !== void 0) {
@@ -1525,7 +1978,7 @@ async function start(configPath, deps = { fetchImpl: fetch }) {
1525
1978
  const outcome = await onStartUnderMarker({
1526
1979
  configPath,
1527
1980
  own: ownVersionOf(deps),
1528
- check: update.check ? () => update.check(configPath) : async () => (await import('./check-AXCXDYO2.js')).runRunnerCheck(configPath, { fetchImpl: deps.fetchImpl }),
1981
+ check: update.check ? () => update.check(configPath) : async () => (await import('./check-CCVPBN7A.js')).runRunnerCheck(configPath, { fetchImpl: deps.fetchImpl }),
1529
1982
  now: () => (deps.now ?? (() => /* @__PURE__ */ new Date()))().getTime(),
1530
1983
  log
1531
1984
  });
@@ -1544,6 +1997,11 @@ async function start(configPath, deps = { fetchImpl: fetch }) {
1544
1997
  mkdirSync(jobsDir(configPath), { recursive: true, mode: RUNNER_DIR_MODE });
1545
1998
  const self = { pid: process.pid, start: startTimeOf(process.pid) ?? "" };
1546
1999
  claimPidFile(pidFilePath(configPath), self);
2000
+ const { delegated, refusal } = setUpRunnerCgroup();
2001
+ if (refusal !== void 0) log.error(`the kernel refused a cgroup step for the runner's service: ${refusal}`);
2002
+ if (isBuiltinVm(config.hooks.provision) && !delegated) {
2003
+ log.line("microVM builds run with no memory limit of their own: the runner's service is not delegated its cgroup (Delegate=yes in the unit, cgroup v2)");
2004
+ }
1547
2005
  let configMtimeMs;
1548
2006
  try {
1549
2007
  configMtimeMs = statSync(configPath).mtimeMs;
@@ -1606,6 +2064,12 @@ async function start(configPath, deps = { fetchImpl: fetch }) {
1606
2064
  let interrupts = 0;
1607
2065
  state.pollTimerSeconds = state.config.pollSeconds;
1608
2066
  const pollTimer = setInterval(() => void tick(state), state.pollTimerSeconds * 1e3);
2067
+ const hooksKind = builtinKind(config.hooks.provision);
2068
+ const housekeepingTimer = hooksKind === "container" || hooksKind === "vm" ? (() => {
2069
+ const everyMs = deps.housekeeping?.intervalMs ?? HOUSEKEEPING_INTERVAL_MS;
2070
+ void housekeepingPass(state);
2071
+ return setInterval(() => void housekeepingPass(state), everyMs);
2072
+ })() : void 0;
1609
2073
  const watchTimer = setInterval(() => {
1610
2074
  const now = nowMs();
1611
2075
  for (const active of state.jobs.values()) {
@@ -1639,6 +2103,7 @@ async function start(configPath, deps = { fetchImpl: fetch }) {
1639
2103
  if (intakeClosed) return;
1640
2104
  intakeClosed = true;
1641
2105
  clearInterval(pollTimer);
2106
+ clearInterval(housekeepingTimer);
1642
2107
  };
1643
2108
  const close = () => {
1644
2109
  stopIntake();
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { createRequire } from 'node:module';
3
- import { detectMachine, supportsContainerBuilds, reachDockerStep, describeMachineDocker, supportsMicroVmBuilds } from './chunk-RJ467PDQ.js';
4
- import { MIN_ENGINE_MAJOR } from './chunk-4NMBDRBS.js';
3
+ import { detectMachine, supportsContainerBuilds, reachDockerStep, describeMachineDocker, supportsMicroVmBuilds } from './chunk-RBOMWNOG.js';
4
+ import { MIN_ENGINE_MAJOR } from './chunk-RZSF5B46.js';
5
5
  import { RUNNER_DIR_MODE, runnerDir, RUNNER_FILE_MODE } from './chunk-ZM2IMMYN.js';
6
6
  import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'fs';
7
7
  import { dirname } from 'path';
@@ -1,6 +1,5 @@
1
1
  #!/usr/bin/env node
2
2
  import { createRequire } from 'node:module';
3
- import './chunk-Z5GEFMRW.js';
4
3
  import { spawnSync, spawn } from 'child_process';
5
4
  import { statSync, accessSync, constants } from 'fs';
6
5
  import { delimiter, resolve } from 'path';
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { createRequire } from 'node:module';
3
- import { sanitizeTerminal } from './chunk-WOBNRVQT.js';
3
+ import { sanitizeTerminal } from './chunk-3DEVYTNQ.js';
4
4
  import { DEFAULT_DASHBOARD } from './chunk-USO2DKBD.js';
5
5
  import { readCloudConfig } from './chunk-EABW6AJQ.js';
6
6
 
@@ -4,6 +4,6 @@ import { createRequire } from 'node:module';
4
4
  createRequire(import.meta.url);
5
5
 
6
6
  // src/runner/protocol.ts
7
- var RUNNER_PROTOCOL = 12;
7
+ var RUNNER_PROTOCOL = 14;
8
8
 
9
9
  export { RUNNER_PROTOCOL };