@catalyst-cloud/cli 0.16.28 → 0.16.29
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/CHANGELOG.md +4 -0
- package/README.md +3 -3
- package/dist/cli-version.js +41 -0
- package/dist/cli.js +14 -0
- package/dist/onboard-next.js +25 -0
- package/dist/onboard-runner-disk.js +345 -0
- package/dist/onboard-runner.js +52 -0
- package/dist/onboard.js +8 -0
- package/dist/ready.js +47 -1
- package/dist/runner.js +19 -2
- package/dist/watch/consumer.js +1 -0
- package/dist/watch.js +2 -1
- package/package.json +1 -1
- package/skills/catalyst-github/SKILL.md +1 -1
- package/skills/catalyst-linear/SKILL.md +1 -1
- package/skills/catalyst-onboard/SKILL.md +1 -1
- package/skills/catalyst-onboard/references/reading-ready.md +1 -0
- package/skills/run-this-project/SKILL.md +1 -1
- package/skills/unstick/SKILL.md +1 -1
- package/skills/what-needs-me/SKILL.md +1 -1
- package/skills/what-this-repo-needs/SKILL.md +1 -1
- package/skills/whats-happening/SKILL.md +1 -1
- package/vendor/self-host/darwin-thoughts-custody/manifest.json +2 -2
- package/vendor/self-host/darwin-thoughts-custody/verifier.mjs +90 -31
- package/vendor/self-host/host-disk-sample.py +57 -0
- package/vendor/self-host/launchd/dev.catalystcloud.host-disk.plist +18 -0
- package/vendor/self-host/provenance.json +6 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.16.29
|
|
4
|
+
|
|
5
|
+
The CLI now tells Catalyst Cloud which release you have installed: each request to Catalyst Cloud carries the installed version, and so does `catalyst watch` when it connects. Catalyst Cloud can then tell an out-of-date CLI from a current one. Nothing about your requests changes otherwise. On a Mac self-hosted machine, setup now installs a small agent that measures the Mac's disk, so the machine can take work instead of showing zero free slots. When a machine still shows zero free slots, setup and `catalyst ready` now say why: its disk has not been measured yet, or its free space is below the floor (25 GiB by default), with the numbers and the fix. An updated disk sampler also takes effect straight away: setup reloads the running disk agent whenever the sampler script changes, not only when its schedule file does.
|
|
6
|
+
|
|
3
7
|
## 0.16.28
|
|
4
8
|
|
|
5
9
|
`catalyst events wait-for` can now wait on a repository (`--repo owner/name`), a branch (`--ref owner/name:branch`), a pull request (`--pr owner/name#n`), a work session (`--session KEY-123/phase#n`), events addressed to you (`--to me`) or a family of event types (`--type pr.*`). An agent can wait hours for a review or a conflict on its own branch: the wait holds one connection, prints each matching event as one JSON line, and after a dropped connection picks up where it left off without missing or repeating an event. Two new exit codes say why a wait ended without a match. 2 means the cloud refused the wait, for example on a ticket outside your teams, or the wait failed. 3 means `--from-cache` found no local event cache. A wait still exits 1 on a timeout and 4 on an outage, as before, and on a timeout it names the `--after` to resume from. New: `catalyst messages read --ticket KEY-123` prints the messages sent to a ticket on your teams, with each sender and time.
|
package/README.md
CHANGED
|
@@ -164,7 +164,7 @@ Before any network call, the run checks that every input is present. Each missin
|
|
|
164
164
|
|
|
165
165
|
With a person at the terminal, `catalyst onboard` starts each of these approvals itself and prints the exact link to approve.
|
|
166
166
|
|
|
167
|
-
With `--runner yes`, setup starts the self-hosted runner and verifies enrollment, advertised capacity and team admission. This needs Docker with Compose, pinned host images and an organization key file (`CATALYST_RUNNER_ORG_KEY_FILE`). When images, a key or admission are missing, exit 11 names that gate. A failed setup step exits 10. Use `--runner no` to skip the machine's runner. Unlike the interactive optional runner step, an explicitly requested headless runner must finish before exit 0.
|
|
167
|
+
With `--runner yes`, setup starts the self-hosted runner and verifies enrollment, advertised capacity and team admission. This needs Docker with Compose, pinned host images and an organization key file (`CATALYST_RUNNER_ORG_KEY_FILE`). When images, a key or admission are missing, exit 11 names that gate. A failed setup step exits 10. Use `--runner no` to skip the machine's runner. Unlike the interactive optional runner step, an explicitly requested headless runner must finish before exit 0. On a Mac, setup also installs the `dev.catalystcloud.host-disk` LaunchAgent, which measures the Mac's free disk space every 10 seconds, because Docker's virtual machine cannot see it. It needs Python 3 from Apple's Command Line Tools or Homebrew. When the machine takes no work because the disk Docker uses has less than 25 GiB free (`CATALYST_HOST_FREE_FLOOR_GIB` changes the amount) or has no fresh measurement, exit 11 says which, with the fix, and `catalyst ready` fails its `runner:disk` check the same way. If setup cannot tell which folder holds your Docker engine's disk image, set `CATALYST_HOST_DISK_PATH` to that folder.
|
|
168
168
|
|
|
169
169
|
### Stopping or removing this machine's runner
|
|
170
170
|
|
|
@@ -175,9 +175,9 @@ Run these on the machine itself:
|
|
|
175
175
|
| `catalyst runner stop` | The machine takes no new work. Work it already started finishes or reaches its deadline. Its registration, settings and checkouts are kept, and `catalyst hosts` shows it as `stopped`. |
|
|
176
176
|
| `catalyst runner start` | Starts it again with the same registration. |
|
|
177
177
|
| `catalyst runner status` | Says whether it is running, stopped or not installed. |
|
|
178
|
-
| `catalyst runner uninstall` | Asks first (`--yes` confirms without a terminal; an owner or admin login is needed). It stops new work, revokes the machine's registration and the runner key setup issued, and switches the project's runner admission off when no other machine runs it. Then it removes the runner's containers, their volumes and the session network, and the setup files in the runner folder. On macOS it also removes the storage LaunchAgent and its folders. On Linux it also removes the session-egress units, their script and state, and the `inet catalyst_session` nftables table, through `sudo`. |
|
|
178
|
+
| `catalyst runner uninstall` | Asks first (`--yes` confirms without a terminal; an owner or admin login is needed). It stops new work, revokes the machine's registration and the runner key setup issued, and switches the project's runner admission off when no other machine runs it. Then it removes the runner's containers, their volumes and the session network, and the setup files in the runner folder. On macOS it also removes the storage LaunchAgent and its folders, and the disk-measuring LaunchAgent setup installed. On Linux it also removes the session-egress units, their script and state, and the `inet catalyst_session` nftables table, through `sudo`. |
|
|
179
179
|
|
|
180
|
-
Uninstall removes only what setup created. Anything that does not match what setup wrote, such as a network with another label
|
|
180
|
+
Uninstall removes only what setup created. Anything that does not match what setup wrote, such as a network with another label, a changed unit file or a disk-measuring LaunchAgent that writes somewhere else, is left in place. Your repository and thoughts checkouts are kept unless you add `--delete-checkouts`. The command ends with a "Left behind" list naming everything it did not remove. When a step fails, such as a cloud call while you are offline, it says which one and exits 1. Run it again: it picks up where the last run stopped. Do not delete the runner folder by hand while the runner is installed. That deletes its settings while its containers keep running.
|
|
181
181
|
|
|
182
182
|
With `--json`, stdout carries exactly one document: the onboarding receipt plus a `headless` block (`schema: "catalyst-onboard-headless/1"`). The block holds the resolved `inputs` (the key appears only as its source: `env`, `file` or `saved`), and lists of items under `missing`, `refused`, `failed`, `deferred` and `warnings`. Each item has `id`, `kind` (`input`, `grant` or `step`), `reason` and a one-line `text`, plus `flag`, `env` and `url` where they apply. Everything else goes to stderr.
|
|
183
183
|
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
const HEADER = "x-catalyst-cli-version";
|
|
2
|
+
const VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
|
|
3
|
+
/** Public package metadata on the selected cloud API transport. All fetch semantics stay intact. */
|
|
4
|
+
export function reportCliVersion(transport, version, origins) {
|
|
5
|
+
if (version.length > 80 || !VERSION.test(version))
|
|
6
|
+
return transport;
|
|
7
|
+
return (input, init) => {
|
|
8
|
+
let url;
|
|
9
|
+
try {
|
|
10
|
+
url = new URL(input instanceof Request ? input.url : String(input));
|
|
11
|
+
const selected = origins().some(origin => {
|
|
12
|
+
try {
|
|
13
|
+
return new URL(origin).origin === url.origin;
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
if (!selected || !(url.pathname.startsWith("/api/v1/") || ["/snapshot", "/changes", "/events"].includes(url.pathname)))
|
|
20
|
+
return transport(input, init);
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return transport(input, init);
|
|
24
|
+
}
|
|
25
|
+
return transport(input, { ...init, headers: withVersion(init?.headers ?? (input instanceof Request ? input.headers : undefined), version) });
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
function withVersion(headers, version) {
|
|
29
|
+
// A Headers from another realm or library fails instanceof, and Object.entries would read it as
|
|
30
|
+
// empty and drop every header the caller sent. Anything iterable that is not an array is copied.
|
|
31
|
+
if (headers instanceof Headers || (!Array.isArray(headers) && typeof headers?.forEach === "function")) {
|
|
32
|
+
const copy = new Headers(headers);
|
|
33
|
+
copy.set(HEADER, version);
|
|
34
|
+
return copy;
|
|
35
|
+
}
|
|
36
|
+
if (Array.isArray(headers))
|
|
37
|
+
return [...headers.filter(([name]) => name.toLowerCase() !== HEADER), [HEADER, version]];
|
|
38
|
+
const record = Object.fromEntries(Object.entries(headers ?? {}).filter(([name]) => name.toLowerCase() !== HEADER));
|
|
39
|
+
record[HEADER] = version;
|
|
40
|
+
return record;
|
|
41
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -8,6 +8,7 @@ import { existsSync, openSync, closeSync } from "node:fs";
|
|
|
8
8
|
import { ReadStream as TerminalInput } from "node:tty";
|
|
9
9
|
import { Writable } from "node:stream";
|
|
10
10
|
import { parseArgs, positionals, verbHelp } from "./args.js";
|
|
11
|
+
import { reportCliVersion } from "./cli-version.js";
|
|
11
12
|
import { CONFIG_MODE, DEFAULT_BASE_URL, LEGACY_PACKAGE_NAME, PACKAGE_NAME, cliPath, configPathFor, contractPathFor, defaultCtx, defaultReplicaDbFor, defaultSkillsDirFor, formatMode, installerOwnsSkills, INSTALL_COMMAND, loadConfig, modernCliPath, normalizeBaseUrl, readManifest, requireConfig, saveConfig, writeConfig, } from "./config.js";
|
|
12
13
|
import { loadContract, pickPath } from "./contract.js";
|
|
13
14
|
import { CliError, MeError, UsageError } from "./errors.js";
|
|
@@ -280,6 +281,19 @@ export async function main(argv, ctx = defaultCtx(), deps = {}) {
|
|
|
280
281
|
command: args.command,
|
|
281
282
|
version: manifest.version,
|
|
282
283
|
});
|
|
284
|
+
ctx = {
|
|
285
|
+
...ctx,
|
|
286
|
+
fetch: reportCliVersion(ctx.fetch, manifest.version, () => {
|
|
287
|
+
let configured;
|
|
288
|
+
try {
|
|
289
|
+
configured = loadConfig(ctx.home)?.baseUrl;
|
|
290
|
+
}
|
|
291
|
+
catch {
|
|
292
|
+
// The command reports its existing config error.
|
|
293
|
+
}
|
|
294
|
+
return [args.baseUrl, ctx.env.CATALYST_CLOUD_BASE_URL, configured, DEFAULT_BASE_URL].filter((origin) => typeof origin === "string");
|
|
295
|
+
}),
|
|
296
|
+
};
|
|
283
297
|
try {
|
|
284
298
|
if (args.command !== "onboard") {
|
|
285
299
|
migrateLegacyCliPath(ctx);
|
package/dist/onboard-next.js
CHANGED
|
@@ -3,6 +3,7 @@ import { GITHUB_CONFIRM_ACCESS } from "./onboard-github-copy.js";
|
|
|
3
3
|
import { INSTALL_COMMAND, loadConfig, normalizeBaseUrl } from "./config.js";
|
|
4
4
|
import { githubInstallationPage } from "./onboard-permissions.js";
|
|
5
5
|
import { valuesFacts, valuesFix } from "./ready-copy.js";
|
|
6
|
+
import { DEFAULT_HOST_FREE_FLOOR_GIB, isRunnerDiskUnmeasured, runnerDiskCopy } from "./onboard-runner-disk.js";
|
|
6
7
|
/** What a person reads for a step that is not done. Each line ends in an action they can take. */
|
|
7
8
|
const REASONS = {
|
|
8
9
|
legacy_cleanup_failed: "An earlier Catalyst installation could not be removed. Run catalyst legacy --remove to see the exact item and its blocker, fix that blocker, then run catalyst setup again. Shared data folders are kept.",
|
|
@@ -158,6 +159,10 @@ const REASONS = {
|
|
|
158
159
|
runner_compose_not_running: "The runner's supervisor is not running. Run docker compose -p catalyst-host logs supervisor to see why, then run catalyst onboard again.",
|
|
159
160
|
runner_enrollment_unverified: "The runner started but has not enrolled yet. Run docker compose -p catalyst-host logs supervisor to see why, or run catalyst onboard again in a minute.",
|
|
160
161
|
runner_needs_runner_flag: "This machine was set up to run Catalyst's work, but its runner is not running or not enrolled. To bring it up, run catalyst onboard --runner.",
|
|
162
|
+
runner_host_disk_sampler_python_missing: "Setup needs Python 3 to measure this Mac's disk for the runner. Install Apple's Command Line Tools with xcode-select --install, or python3 from Homebrew, then run catalyst setup --runner.",
|
|
163
|
+
runner_host_disk_location_unverified: "Setup could not tell which disk holds your Docker engine's virtual machine, so it cannot measure this Mac's free space for the runner. Set CATALYST_HOST_DISK_PATH to that folder (for a relocated Docker Desktop disk image, the folder chosen in Settings, Resources, Advanced), then run catalyst setup --runner.",
|
|
164
|
+
runner_host_disk_sampler_failed: "Setup could not start the dev.catalystcloud.host-disk LaunchAgent that measures this Mac's disk, so the runner will advertise zero free slots. Run launchctl print gui/$(id -u)/dev.catalystcloud.host-disk, or user/$(id -u)/dev.catalystcloud.host-disk when no one is logged in at the console, to see why, then run catalyst setup --runner.",
|
|
165
|
+
runner_host_disk_sampler_not_repeating: "Setup loaded the dev.catalystcloud.host-disk LaunchAgent, but launchd did not keep it measuring this Mac's disk: it published no new sample within 25 seconds, so the runner will advertise zero free slots. launchd can hold back an agent loaded from an SSH session. Log in to this Mac's desktop, at the console or over Screen Sharing, and run catalyst setup --runner in Terminal there. To see what launchd did, run launchctl print gui/$(id -u)/dev.catalystcloud.host-disk and read its runs and pended lines.",
|
|
161
166
|
runner_capability_pending: "The runner enrolled but has not reported its capacity yet. Run catalyst onboard again in a minute.",
|
|
162
167
|
runner_admission_unverified: "Setup could not read whether the team's pool admits runner hosts. Run catalyst onboard again to retry.",
|
|
163
168
|
github_app_permissions_outdated: `The existing GitHub App installation needs updated permissions accepted. ${GITHUB_CONFIRM_ACCESS} An organization owner on GitHub opens the App's installation page, chooses Review permissions, then Accept new permissions. Run catalyst onboard afterward.`,
|
|
@@ -454,6 +459,26 @@ export function onboardReasonText(step, context = {}) {
|
|
|
454
459
|
: "on the web app's Account API keys page";
|
|
455
460
|
return `The runner is enrolled, but it takes no work without an organization key. A workspace owner or administrator runs catalyst onboard --runner on this machine; it mints the key and stores it for the runner. If their cloud cannot mint one yet, they create one with the mirror:read, mirror:write and mirror:feed scopes ${where}, save it in a file only they can read, and run catalyst onboard --runner with CATALYST_RUNNER_ORG_KEY_FILE set to that file.`;
|
|
456
461
|
}
|
|
462
|
+
if (reason === "runner_host_disk_sampler_conflict") {
|
|
463
|
+
// Both settings are needed: a custom directory turns off setup's sampler, and the sample file
|
|
464
|
+
// name is then empty unless it is set too.
|
|
465
|
+
const safe = (value) => typeof value === "string" && /^\/[^\x00-\x1f'"]{0,500}$/.test(value) ? value : null;
|
|
466
|
+
const dir = safe(step.evidence?.sampleDir) ?? "<directory the other agent writes to>";
|
|
467
|
+
const file = safe(step.evidence?.sampleFile) ?? "/var/lib/catalyst/host-disk/<its file name>";
|
|
468
|
+
return `Another dev.catalystcloud.host-disk LaunchAgent on this Mac writes its disk sample somewhere else, so setup left it alone and the runner has no disk measurement. To use that agent's sample, set both CATALYST_HOST_DISK_SAMPLE_DIR=${dir} and CATALYST_HOST_DISK_SAMPLE_FILE=${file}, then run catalyst setup --runner; the directory alone leaves the runner without a sample file. Or remove the other agent: run launchctl bootout gui/$(id -u)/dev.catalystcloud.host-disk and launchctl bootout user/$(id -u)/dev.catalystcloud.host-disk (one of them reports it is not loaded), delete ~/Library/LaunchAgents/dev.catalystcloud.host-disk.plist, then run catalyst setup --runner.`;
|
|
469
|
+
}
|
|
470
|
+
if (reason === "runner_host_disk_below_floor" || reason === "runner_host_disk_unmeasured") {
|
|
471
|
+
const e = step.evidence ?? {};
|
|
472
|
+
const num = (value) => typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
|
|
473
|
+
const floorGiB = num(e.floorGiB) ?? DEFAULT_HOST_FREE_FLOOR_GIB, availableGiB = num(e.availableGiB);
|
|
474
|
+
if (reason === "runner_host_disk_below_floor" && availableGiB === null)
|
|
475
|
+
return `The runner advertises zero free slots: the disk it measures has less than ${floorGiB} GiB free. Free disk space, then run catalyst ready to see how much is free.`;
|
|
476
|
+
const detail = typeof e.diskDetail === "string" && isRunnerDiskUnmeasured(e.diskDetail) ? e.diskDetail : "physical_host_disk_sample_required";
|
|
477
|
+
const copy = runnerDiskCopy(reason === "runner_host_disk_below_floor" && availableGiB !== null
|
|
478
|
+
? { state: "below_floor", availableGiB, floorGiB }
|
|
479
|
+
: { state: "unmeasured", detail, floorGiB }, e.vm === true);
|
|
480
|
+
return `${copy.line} ${copy.fix}`;
|
|
481
|
+
}
|
|
457
482
|
if (reason === "runner_host_not_ready") {
|
|
458
483
|
const failing = step.evidence?.failing;
|
|
459
484
|
const checks = typeof failing === "string" && /^[a-z0-9.,_-]{1,500}$/.test(failing)
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, statfsSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { packageRoot } from "./config.js";
|
|
5
|
+
// CTC-5929: a supervisor in a Docker VM cannot measure the Mac's disk, so it refuses all work
|
|
6
|
+
// (`host_disk_unmeasured: physical_host_disk_sample_required`) until a native publisher writes a
|
|
7
|
+
// fresh sample into the read-only `host-disk` bind. Setup installs catalyst-cloud's own publisher
|
|
8
|
+
// (vendor/self-host, byte for byte) as a LaunchAgent, and names a zero-slot disk reason instead of
|
|
9
|
+
// leaving it in the supervisor log. The admission rule itself stays the supervisor's.
|
|
10
|
+
export const HOST_DISK_LABEL = "dev.catalystcloud.host-disk";
|
|
11
|
+
/** The sample path inside the supervisor; compose binds the runner's `host-disk` directory here. */
|
|
12
|
+
export const HOST_DISK_MOUNT = "/var/lib/catalyst/host-disk";
|
|
13
|
+
export const HOST_DISK_SAMPLE_FILE = `${HOST_DISK_MOUNT}/space.json`;
|
|
14
|
+
/** catalyst-cloud apps/host-agent/src/supervisor/disk/host-space.ts and the compose default. */
|
|
15
|
+
const SAMPLE_MAX_AGE_MS = 30_000;
|
|
16
|
+
const SAMPLE_FUTURE_MS = 5_000;
|
|
17
|
+
const MAX_SAMPLE_BYTES = 4_096;
|
|
18
|
+
export const DEFAULT_HOST_FREE_FLOOR_GIB = 25;
|
|
19
|
+
const GIB = 1024 ** 3;
|
|
20
|
+
const gib = (bytes) => Math.floor((bytes / GIB) * 100) / 100;
|
|
21
|
+
function floorGiB(saved) {
|
|
22
|
+
const value = Number(saved?.CATALYST_HOST_FREE_FLOOR_GIB?.trim() || DEFAULT_HOST_FREE_FLOOR_GIB);
|
|
23
|
+
return Number.isFinite(value) && value > 0 ? value : DEFAULT_HOST_FREE_FLOOR_GIB;
|
|
24
|
+
}
|
|
25
|
+
/** The supervisor's sample rules: schema 1, at most 30 seconds old, never ahead of the clock. */
|
|
26
|
+
export function hostDiskSampleBytes(text, nowMs) {
|
|
27
|
+
let sample;
|
|
28
|
+
try {
|
|
29
|
+
if (Buffer.byteLength(text) > MAX_SAMPLE_BYTES)
|
|
30
|
+
return "host_disk_sample_invalid";
|
|
31
|
+
const value = JSON.parse(text);
|
|
32
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
33
|
+
return "host_disk_sample_invalid";
|
|
34
|
+
sample = value;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return "host_disk_sample_invalid";
|
|
38
|
+
}
|
|
39
|
+
const { observedAtMs, availableBytes, totalBytes, path } = sample;
|
|
40
|
+
if (sample.schemaVersion !== 1 || typeof path !== "string" || !path.startsWith("/") ||
|
|
41
|
+
!Number.isSafeInteger(observedAtMs) || observedAtMs <= 0 ||
|
|
42
|
+
!Number.isSafeInteger(availableBytes) || availableBytes < 0 ||
|
|
43
|
+
!Number.isSafeInteger(totalBytes) || totalBytes <= 0 ||
|
|
44
|
+
availableBytes > totalBytes)
|
|
45
|
+
return "host_disk_sample_invalid";
|
|
46
|
+
if (nowMs - observedAtMs > SAMPLE_MAX_AGE_MS || observedAtMs > nowMs + SAMPLE_FUTURE_MS)
|
|
47
|
+
return "host_disk_sample_stale";
|
|
48
|
+
return availableBytes;
|
|
49
|
+
}
|
|
50
|
+
/** Where the supervisor's sample file lives on this machine, or null when it is not in the bind. */
|
|
51
|
+
export function hostDiskSamplePath(dir, saved) {
|
|
52
|
+
const file = saved?.CATALYST_HOST_DISK_SAMPLE_FILE?.trim();
|
|
53
|
+
if (!file || !file.startsWith(`${HOST_DISK_MOUNT}/`) || file.split("/").some(part => part === ".."))
|
|
54
|
+
return null;
|
|
55
|
+
const source = saved?.CATALYST_HOST_DISK_SAMPLE_DIR?.trim() || "./host-disk";
|
|
56
|
+
return join(isAbsolute(source) ? source : resolve(dir, source), file.slice(HOST_DISK_MOUNT.length + 1));
|
|
57
|
+
}
|
|
58
|
+
/** The same verdict the supervisor reaches, read from this machine. Never an admission decision. */
|
|
59
|
+
export function runnerDiskVerdict(input) {
|
|
60
|
+
const floor = floorGiB(input.saved);
|
|
61
|
+
const unmeasured = (detail) => ({ state: "unmeasured", detail, floorGiB: floor });
|
|
62
|
+
let bytes;
|
|
63
|
+
if (input.saved?.CATALYST_HOST_DISK_SAMPLE_FILE?.trim()) {
|
|
64
|
+
const path = hostDiskSamplePath(input.dir, input.saved);
|
|
65
|
+
if (!path)
|
|
66
|
+
return unmeasured("host_disk_unreadable");
|
|
67
|
+
let text;
|
|
68
|
+
try {
|
|
69
|
+
text = (input.readSample ?? ((file) => readFileSync(file, "utf8")))(path);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return unmeasured("host_disk_sample_missing");
|
|
73
|
+
}
|
|
74
|
+
const read = hostDiskSampleBytes(text, input.nowMs);
|
|
75
|
+
if (typeof read === "string")
|
|
76
|
+
return unmeasured(read);
|
|
77
|
+
bytes = read;
|
|
78
|
+
}
|
|
79
|
+
else if (input.vm) {
|
|
80
|
+
return unmeasured("physical_host_disk_sample_required");
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
try {
|
|
84
|
+
bytes = input.nativeAvailableBytes?.() ?? null;
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
bytes = null;
|
|
88
|
+
}
|
|
89
|
+
if (bytes === null || !Number.isSafeInteger(bytes) || bytes < 0)
|
|
90
|
+
return unmeasured("host_disk_unreadable");
|
|
91
|
+
}
|
|
92
|
+
return { state: bytes >= floor * GIB ? "measured" : "below_floor", availableGiB: gib(bytes), floorGiB: floor };
|
|
93
|
+
}
|
|
94
|
+
/** Docker's data root, where the supervisor's named state volume lives on native Linux. */
|
|
95
|
+
export function dockerRootAvailableBytes(env) {
|
|
96
|
+
const read = spawnSync("docker", ["info", "--format", "{{.DockerRootDir}}"], { encoding: "utf8", timeout: 10_000, env });
|
|
97
|
+
const root = read.status === 0 ? read.stdout.trim() : "";
|
|
98
|
+
if (!isAbsolute(root))
|
|
99
|
+
return null;
|
|
100
|
+
const space = statfsSync(root);
|
|
101
|
+
return space.bavail * space.bsize;
|
|
102
|
+
}
|
|
103
|
+
const defaultRun = (cmd, args, timeoutMs) => {
|
|
104
|
+
const r = spawnSync(cmd, args, { encoding: "utf8", timeout: timeoutMs });
|
|
105
|
+
return { status: r.status ?? 1, stdout: r.stdout ?? "", stderr: r.error ? r.error.message : (r.stderr ?? "") };
|
|
106
|
+
};
|
|
107
|
+
const within = (path, root) => {
|
|
108
|
+
const rel = relative(root, path);
|
|
109
|
+
return rel !== "" && !rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel);
|
|
110
|
+
};
|
|
111
|
+
/** Docker Desktop's disk image folder: the relocated one from its settings, else the default. */
|
|
112
|
+
function desktopData(deps, exists) {
|
|
113
|
+
const read = deps.readText ?? ((path) => readFileSync(path, "utf8"));
|
|
114
|
+
for (const name of ["settings-store.json", "settings.json"]) {
|
|
115
|
+
let settings;
|
|
116
|
+
try {
|
|
117
|
+
settings = JSON.parse(read(join(deps.home, "Library/Group Containers/group.com.docker", name)));
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
const folder = settings.DataFolder ?? settings.dataFolder;
|
|
123
|
+
if (folder !== undefined)
|
|
124
|
+
return typeof folder === "string" && isAbsolute(folder) && exists(folder) ? folder : null;
|
|
125
|
+
}
|
|
126
|
+
const fallback = join(deps.home, "Library/Containers/com.docker.docker/Data/vms/0/data");
|
|
127
|
+
return exists(fallback) ? fallback : null;
|
|
128
|
+
}
|
|
129
|
+
/** The filesystem backing the ACTIVE engine's VM disk, from its socket; null when it cannot be
|
|
130
|
+
* verified, so setup asks for CATALYST_HOST_DISK_PATH instead of measuring the wrong disk. */
|
|
131
|
+
export function engineDiskPath(deps) {
|
|
132
|
+
const exists = deps.exists ?? existsSync;
|
|
133
|
+
if (deps.explicitPath !== undefined && deps.explicitPath.trim() !== "")
|
|
134
|
+
return isAbsolute(deps.explicitPath) && exists(deps.explicitPath) ? deps.explicitPath : null;
|
|
135
|
+
if (!deps.dockerHost?.startsWith("unix://"))
|
|
136
|
+
return null;
|
|
137
|
+
let socket;
|
|
138
|
+
try {
|
|
139
|
+
socket = (deps.realpath ?? realpathSync)(deps.dockerHost.slice("unix://".length));
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
const home = deps.home;
|
|
145
|
+
if (within(socket, join(home, ".docker")) || within(socket, join(home, "Library/Containers/com.docker.docker")))
|
|
146
|
+
return desktopData(deps, exists);
|
|
147
|
+
if (within(socket, join(home, ".orbstack"))) {
|
|
148
|
+
const data = join(home, "Library/Group Containers/HUAQ24HBR6.dev.orbstack/data");
|
|
149
|
+
return exists(data) ? data : null;
|
|
150
|
+
}
|
|
151
|
+
// Lima and Colima keep each instance's disk in the instance directory that holds its socket.
|
|
152
|
+
const lima = /^(.*)\/sock\/docker\.sock$/.exec(socket)?.[1];
|
|
153
|
+
if (lima && within(lima, join(home, ".lima")) && exists(lima))
|
|
154
|
+
return lima;
|
|
155
|
+
const colima = /^(.*)\/docker\.sock$/.exec(socket)?.[1];
|
|
156
|
+
if (colima && within(colima, join(home, ".colima"))) {
|
|
157
|
+
const instance = join(home, ".colima", "_lima", `colima${colima.endsWith(`${sep}default`) ? "" : `-${colima.split(sep).at(-1)}`}`);
|
|
158
|
+
return exists(instance) ? instance : null;
|
|
159
|
+
}
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
/** A stable interpreter. /usr/bin/python3 is a stub that opens an install dialog until the
|
|
163
|
+
* Command Line Tools exist, so it is used only after xcode-select confirms them. */
|
|
164
|
+
function python(run, exists) {
|
|
165
|
+
if (exists("/usr/bin/python3") && run("/usr/bin/xcode-select", ["-p"], 5_000).status === 0)
|
|
166
|
+
return "/usr/bin/python3";
|
|
167
|
+
return ["/opt/homebrew/bin/python3", "/usr/local/bin/python3"].find(exists) ?? null;
|
|
168
|
+
}
|
|
169
|
+
const xml = (text) => text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
170
|
+
function plan(dir, deps) {
|
|
171
|
+
const run = deps.run ?? defaultRun;
|
|
172
|
+
const exists = deps.exists ?? existsSync;
|
|
173
|
+
const measured = engineDiskPath(deps);
|
|
174
|
+
if (!measured)
|
|
175
|
+
return "runner_host_disk_location_unverified";
|
|
176
|
+
const interpreter = python(run, exists);
|
|
177
|
+
if (!interpreter)
|
|
178
|
+
return "runner_host_disk_sampler_python_missing";
|
|
179
|
+
const vendor = join(packageRoot(), "vendor", "self-host");
|
|
180
|
+
const script = join(dir, "host-disk-sampler", "host-disk-sample.py");
|
|
181
|
+
const output = join(dir, "host-disk", "space.json");
|
|
182
|
+
const values = {
|
|
183
|
+
__PYTHON__: interpreter, __SCRIPT__: script, __PATH__: measured, __OUTPUT__: output,
|
|
184
|
+
};
|
|
185
|
+
const template = readFileSync(join(vendor, "launchd", `${HOST_DISK_LABEL}.plist`), "utf8");
|
|
186
|
+
const plistText = template.replace(/<string>(__[A-Z]+__)<\/string>/g, (whole, key) => key in values ? `<string>${xml(values[key])}</string>` : whole);
|
|
187
|
+
const program = /<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/.exec(template)?.[1] ?? "";
|
|
188
|
+
const command = [...program.matchAll(/<string>([^<]*)<\/string>/g)].map(m => values[m[1]] ?? m[1]);
|
|
189
|
+
const at = command.indexOf("--interval");
|
|
190
|
+
return {
|
|
191
|
+
script, output, plistText, command, interval: Number(command[at + 1]),
|
|
192
|
+
plist: join(deps.home, "Library", "LaunchAgents", `${HOST_DISK_LABEL}.plist`),
|
|
193
|
+
scriptBytes: readFileSync(join(vendor, "host-disk-sample.py")),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
const readOr = (path) => {
|
|
197
|
+
try {
|
|
198
|
+
return readFileSync(path);
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
/** The file a host-disk agent's plist publishes to (its `--output`), unescaped; null when it names none. */
|
|
205
|
+
export function hostDiskAgentOutput(text) {
|
|
206
|
+
const raw = /<string>--output<\/string>\s*<string>([^<]*)<\/string>/.exec(text)?.[1];
|
|
207
|
+
return raw === undefined ? null : raw.replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&");
|
|
208
|
+
}
|
|
209
|
+
/** A plist with this label that publishes somewhere else belongs to its operator. */
|
|
210
|
+
function foreign(existing, output) {
|
|
211
|
+
return hostDiskAgentOutput(existing.toString("utf8")) !== output;
|
|
212
|
+
}
|
|
213
|
+
/** Where a same-label agent this setup does not own publishes, as the two settings that would
|
|
214
|
+
* point the runner at it: the bind directory and the in-container file name. */
|
|
215
|
+
export function foreignHostDiskSample(home) {
|
|
216
|
+
const text = readOr(join(home, "Library", "LaunchAgents", `${HOST_DISK_LABEL}.plist`))?.toString("utf8");
|
|
217
|
+
const output = text && hostDiskAgentOutput(text);
|
|
218
|
+
if (!output || !isAbsolute(output) || !/^[^\x00-\x1f'"]+$/.test(output))
|
|
219
|
+
return {};
|
|
220
|
+
return { sampleDir: dirname(output), sampleFile: `${HOST_DISK_MOUNT}/${output.split("/").at(-1)}` };
|
|
221
|
+
}
|
|
222
|
+
/** The launchd domain holding the agent: gui when someone is logged in at the console, else user. */
|
|
223
|
+
function loadedIn(run, uid) {
|
|
224
|
+
for (const domain of [`gui/${uid}`, `user/${uid}`])
|
|
225
|
+
if (run("/bin/launchctl", ["print", `${domain}/${HOST_DISK_LABEL}`], 5_000).status === 0)
|
|
226
|
+
return domain;
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
function writeAtomic(path, bytes, mode) {
|
|
230
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o755 });
|
|
231
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
232
|
+
writeFileSync(temporary, bytes, { mode });
|
|
233
|
+
chmodSync(temporary, mode);
|
|
234
|
+
renameSync(temporary, path);
|
|
235
|
+
}
|
|
236
|
+
function observedAt(output) {
|
|
237
|
+
try {
|
|
238
|
+
const value = JSON.parse(readOr(output)?.toString("utf8") ?? "");
|
|
239
|
+
const at = value?.observedAtMs;
|
|
240
|
+
return Number.isSafeInteger(at) ? at : null;
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
const sleepSync = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
247
|
+
/** The positive cadence check: the agent itself publishes two new samples, one interval apart.
|
|
248
|
+
* Loaded is not running: launchd held every launch of an agent bootstrapped over SSH (CTC-5929). */
|
|
249
|
+
function repeats(p, deps) {
|
|
250
|
+
const sleep = deps.sleep ?? sleepSync;
|
|
251
|
+
const seen = new Set();
|
|
252
|
+
const before = observedAt(p.output);
|
|
253
|
+
const deadline = deps.now() + (2 * p.interval + 5) * 1_000;
|
|
254
|
+
while (deps.now() < deadline) {
|
|
255
|
+
const at = observedAt(p.output);
|
|
256
|
+
if (at !== null && at !== before && sampleFresh(p.output, deps.now()))
|
|
257
|
+
seen.add(at);
|
|
258
|
+
if (seen.size >= 2)
|
|
259
|
+
return true;
|
|
260
|
+
sleep(1_000);
|
|
261
|
+
}
|
|
262
|
+
return false;
|
|
263
|
+
}
|
|
264
|
+
function sampleFresh(output, nowMs) {
|
|
265
|
+
const text = readOr(output);
|
|
266
|
+
return text !== null && typeof hostDiskSampleBytes(text.toString("utf8"), nowMs) === "number";
|
|
267
|
+
}
|
|
268
|
+
export function hostDiskSamplerStatus(dir, deps) {
|
|
269
|
+
const p = plan(dir, deps);
|
|
270
|
+
if (typeof p === "string")
|
|
271
|
+
return p;
|
|
272
|
+
const plist = readOr(p.plist);
|
|
273
|
+
if (plist && !plist.equals(Buffer.from(p.plistText)) && foreign(plist, p.output))
|
|
274
|
+
return "runner_host_disk_sampler_conflict";
|
|
275
|
+
return plist?.equals(Buffer.from(p.plistText)) && readOr(p.script)?.equals(p.scriptBytes) &&
|
|
276
|
+
loadedIn(deps.run ?? defaultRun, deps.uid) && sampleFresh(p.output, deps.now()) ? "installed" : "missing";
|
|
277
|
+
}
|
|
278
|
+
/** Installs or repairs the agent in place. Idempotent: an unchanged, loaded agent is left running.
|
|
279
|
+
* A same-label agent publishing elsewhere is never replaced. */
|
|
280
|
+
export function installHostDiskSampler(dir, deps) {
|
|
281
|
+
const run = deps.run ?? defaultRun;
|
|
282
|
+
const p = plan(dir, deps);
|
|
283
|
+
if (typeof p === "string")
|
|
284
|
+
return p;
|
|
285
|
+
const existing = readOr(p.plist);
|
|
286
|
+
const plistChanged = !existing?.equals(Buffer.from(p.plistText));
|
|
287
|
+
if (existing && plistChanged && foreign(existing, p.output))
|
|
288
|
+
return "runner_host_disk_sampler_conflict";
|
|
289
|
+
try {
|
|
290
|
+
// The plist does not carry the script's bytes, so a changed script needs its own reload: a
|
|
291
|
+
// running agent keeps the code it started with until launchd starts it again.
|
|
292
|
+
const scriptChanged = !readOr(p.script)?.equals(p.scriptBytes);
|
|
293
|
+
if (scriptChanged)
|
|
294
|
+
writeAtomic(p.script, p.scriptBytes, 0o644);
|
|
295
|
+
// One synchronous sample proves the interpreter and the measured path before launchd owns it.
|
|
296
|
+
// Without --interval the script publishes once and exits; with it, it loops.
|
|
297
|
+
const loop = p.command.indexOf("--interval");
|
|
298
|
+
const oneShot = loop < 0 ? p.command : [...p.command.slice(0, loop), ...p.command.slice(loop + 2)];
|
|
299
|
+
const once = run(oneShot[0], oneShot.slice(1), 15_000);
|
|
300
|
+
if (once.status !== 0 || !sampleFresh(p.output, deps.now()))
|
|
301
|
+
return "runner_host_disk_sampler_failed";
|
|
302
|
+
if (plistChanged)
|
|
303
|
+
writeAtomic(p.plist, p.plistText, 0o644);
|
|
304
|
+
let domain = loadedIn(run, deps.uid);
|
|
305
|
+
if (domain && (plistChanged || scriptChanged)) {
|
|
306
|
+
run("/bin/launchctl", ["bootout", `${domain}/${HOST_DISK_LABEL}`], 15_000);
|
|
307
|
+
domain = null;
|
|
308
|
+
}
|
|
309
|
+
if (!domain) {
|
|
310
|
+
const gui = run("/bin/launchctl", ["bootstrap", `gui/${deps.uid}`, p.plist], 15_000).status === 0;
|
|
311
|
+
if (!gui && run("/bin/launchctl", ["bootstrap", `user/${deps.uid}`, p.plist], 15_000).status !== 0)
|
|
312
|
+
return "runner_host_disk_sampler_failed";
|
|
313
|
+
}
|
|
314
|
+
domain = loadedIn(run, deps.uid);
|
|
315
|
+
if (!domain)
|
|
316
|
+
return "runner_host_disk_sampler_failed";
|
|
317
|
+
// A demand start: launchd ran a kickstarted agent on mini-2 while it held RunAtLoad and the
|
|
318
|
+
// timer. The script loops by itself once started, and a running agent is left as it is.
|
|
319
|
+
run("/bin/launchctl", ["kickstart", `${domain}/${HOST_DISK_LABEL}`], 15_000);
|
|
320
|
+
return repeats(p, deps) ? "ready" : "runner_host_disk_sampler_not_repeating";
|
|
321
|
+
}
|
|
322
|
+
catch {
|
|
323
|
+
return "runner_host_disk_sampler_failed";
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
export const isRunnerDiskUnmeasured = (value) => value in UNMEASURED_WHY;
|
|
327
|
+
const UNMEASURED_WHY = {
|
|
328
|
+
physical_host_disk_sample_required: "no disk sampler is set up for it",
|
|
329
|
+
host_disk_sample_missing: "the disk sample file is missing",
|
|
330
|
+
host_disk_sample_stale: "the last disk sample is more than 30 seconds old",
|
|
331
|
+
host_disk_sample_invalid: "the disk sample cannot be read",
|
|
332
|
+
host_disk_unreadable: "setup cannot read the disk it measures",
|
|
333
|
+
};
|
|
334
|
+
/** One wording for setup and `catalyst ready`: the reason for zero free slots, then the fix. */
|
|
335
|
+
export function runnerDiskCopy(verdict, vm) {
|
|
336
|
+
if (verdict.state === "below_floor")
|
|
337
|
+
return {
|
|
338
|
+
line: `The runner advertises zero free slots: ${verdict.availableGiB} GiB of disk is free and it needs ${verdict.floorGiB} GiB.`,
|
|
339
|
+
fix: `Free at least ${Math.ceil(verdict.floorGiB - verdict.availableGiB)} GiB on ${vm ? "this Mac" : "the disk that holds Docker's data"}, for example with docker system prune. The runner takes work again by itself; run catalyst ready to confirm.`,
|
|
340
|
+
};
|
|
341
|
+
const line = `The runner advertises zero free slots: it has no fresh measurement of ${vm ? "this Mac's" : "this machine's"} disk, because ${UNMEASURED_WHY[verdict.detail]}.`;
|
|
342
|
+
return vm
|
|
343
|
+
? { line, fix: `Run catalyst setup --runner. It installs and starts the ${HOST_DISK_LABEL} LaunchAgent, which measures the disk every 10 seconds. If it is already installed, run launchctl print gui/$(id -u)/${HOST_DISK_LABEL}, or user/$(id -u)/${HOST_DISK_LABEL} when no one is logged in at the console, to see why it stopped. If you publish your own sample with CATALYST_HOST_DISK_SAMPLE_DIR and CATALYST_HOST_DISK_SAMPLE_FILE, restart that publisher.` }
|
|
344
|
+
: { line, fix: "Run docker compose -p catalyst-host logs supervisor and look for host_disk_admission_refused, then run catalyst ready." };
|
|
345
|
+
}
|
package/dist/onboard-runner.js
CHANGED
|
@@ -8,6 +8,7 @@ import { runnerAdmission, issueRunnerOrgKey, verifyRunnerRoutes } from "./onboar
|
|
|
8
8
|
import { verifyOnboardRoutes } from "./onboard-capabilities.js";
|
|
9
9
|
import { liveTeamKey, onboardTeamAdmission } from "./onboard-capacity.js";
|
|
10
10
|
import { runnerVmCapabilities } from "./onboard-runner-probes.js";
|
|
11
|
+
import { dockerRootAvailableBytes, foreignHostDiskSample, hostDiskSamplerStatus, installHostDiskSampler, runnerDiskVerdict, HOST_DISK_SAMPLE_FILE, } from "./onboard-runner-disk.js";
|
|
11
12
|
import { darwinThoughtsInstallAdvice, darwinThoughtsInstallException, darwinThoughtsInstallRefusal, installDarwinThoughtsCustody } from "./onboard-runner-custody.js";
|
|
12
13
|
import { emitCustodyFailure } from "./onboard-runner-telemetry.js";
|
|
13
14
|
import { canonicalDarwinThoughtsJson, parseDarwinThoughtsAuthority } from "../vendor/self-host/darwin-thoughts-custody/verifier.mjs";
|
|
@@ -69,6 +70,8 @@ const DISK_POLICY_KEYS = [
|
|
|
69
70
|
"CATALYST_SLOT_DISK_MODE", "CATALYST_SLOT_DISK_BUDGET_GIB", "CATALYST_DISK_BUDGET_GIB",
|
|
70
71
|
"CATALYST_WORKSPACE_STORAGE", "CATALYST_WORKSPACE_RETENTION_HOURS",
|
|
71
72
|
"CATALYST_HOST_FREE_FLOOR_GIB", "CATALYST_HOST_DISK_SAMPLE_DIR", "CATALYST_HOST_DISK_SAMPLE_FILE",
|
|
73
|
+
// Not read by Compose: where the Mac sampler measures, kept so unattended checks plan the same path.
|
|
74
|
+
"CATALYST_HOST_DISK_PATH",
|
|
72
75
|
];
|
|
73
76
|
function isUnenrolled(value) {
|
|
74
77
|
return value !== null && "unenrolled" in value;
|
|
@@ -549,6 +552,20 @@ export function dockerRunnerEngine(deps = {}) {
|
|
|
549
552
|
return (read.code === 0 &&
|
|
550
553
|
read.stdout.split("\n").some((line) => line.trim() === "supervisor"));
|
|
551
554
|
},
|
|
555
|
+
async hostDisk(dir, vm) {
|
|
556
|
+
return runnerDiskVerdict({ dir, saved: readEnvFile(join(dir, ".env")), vm, nowMs: Date.now(),
|
|
557
|
+
nativeAvailableBytes: () => dockerRootAvailableBytes(env) });
|
|
558
|
+
},
|
|
559
|
+
...((deps.platform ?? process.platform) === "darwin" ? {
|
|
560
|
+
async hostDiskSamplerStatus(dir, options) {
|
|
561
|
+
return hostDiskSamplerStatus(dir, { home: deps.env?.HOME ?? process.env.HOME ?? "", uid: process.getuid?.() ?? 0,
|
|
562
|
+
now: Date.now, dockerHost: await endpoint(), ...options });
|
|
563
|
+
},
|
|
564
|
+
async installHostDiskSampler(dir, options) {
|
|
565
|
+
return installHostDiskSampler(dir, { home: deps.env?.HOME ?? process.env.HOME ?? "", uid: process.getuid?.() ?? 0,
|
|
566
|
+
now: Date.now, dockerHost: await endpoint(), ...options });
|
|
567
|
+
},
|
|
568
|
+
} : {}),
|
|
552
569
|
};
|
|
553
570
|
}
|
|
554
571
|
const serves = (host, teamKey) => host.teams.includes(teamKey);
|
|
@@ -590,6 +607,7 @@ function parseHosts(body) {
|
|
|
590
607
|
capacity: capability ? capability.runtimeLive
|
|
591
608
|
? capability.placeableCapacity
|
|
592
609
|
: 0 : null,
|
|
610
|
+
live: capability?.runtimeLive === true,
|
|
593
611
|
failing: capability ? capability.failingRequired : [],
|
|
594
612
|
});
|
|
595
613
|
}
|
|
@@ -790,6 +808,7 @@ export function onboardRunnerAdapter(input = {}) {
|
|
|
790
808
|
const gid = p.info.vm ? 0 : await engine.socketGid();
|
|
791
809
|
if (!HOST_NAME.test(name) || gid === null)
|
|
792
810
|
return null;
|
|
811
|
+
const sampler = samplerManaged(p, ctx, saved);
|
|
793
812
|
return {
|
|
794
813
|
DOCKER_SOCKET_PATH: "/var/run/docker.sock",
|
|
795
814
|
DOCKER_SOCKET_GID: String(gid),
|
|
@@ -808,10 +827,22 @@ export function onboardRunnerAdapter(input = {}) {
|
|
|
808
827
|
CATALYST_THOUGHTS_CUSTODY_RESPONSES_DIR: receivedCustody.result.responsesRoot,
|
|
809
828
|
} : {}),
|
|
810
829
|
CATALYST_SESSION_EGRESS_DIR: p.info.vm ? join(p.dir, "session-egress") : NATIVE_EGRESS_DIR,
|
|
830
|
+
...(sampler ? { CATALYST_HOST_DISK_SAMPLE_FILE: HOST_DISK_SAMPLE_FILE } : {}),
|
|
811
831
|
// Saved policy stays in merged(). An explicit value in this run overrides it.
|
|
812
832
|
...Object.fromEntries(DISK_POLICY_KEYS.flatMap(key => ctx.env[key] === undefined ? [] : [[key, ctx.env[key]]])),
|
|
813
833
|
};
|
|
814
834
|
}
|
|
835
|
+
/** CTC-5929: setup publishes the Mac's disk sample into the install's own `host-disk` bind. A
|
|
836
|
+
* custom sample directory or file belongs to its operator, who also runs its publisher. */
|
|
837
|
+
function samplerManaged(p, ctx, saved) {
|
|
838
|
+
const custom = (key) => (ctx.env[key] ?? saved?.[key] ?? "").trim();
|
|
839
|
+
return p.info.vm && engine.installHostDiskSampler !== undefined && custom("CATALYST_HOST_DISK_SAMPLE_DIR") === "" &&
|
|
840
|
+
["", HOST_DISK_SAMPLE_FILE].includes(custom("CATALYST_HOST_DISK_SAMPLE_FILE"));
|
|
841
|
+
}
|
|
842
|
+
const samplerOptions = (ctx, saved) => {
|
|
843
|
+
const explicitPath = ctx.env.CATALYST_HOST_DISK_PATH ?? saved?.CATALYST_HOST_DISK_PATH;
|
|
844
|
+
return explicitPath ? { explicitPath } : {};
|
|
845
|
+
};
|
|
815
846
|
/** The step owns its own keys only; a person's tuning (CATALYST_SLOTS and the rest) is kept. */
|
|
816
847
|
const merged = (saved, want) => ({
|
|
817
848
|
...want,
|
|
@@ -947,6 +978,12 @@ export function onboardRunnerAdapter(input = {}) {
|
|
|
947
978
|
const name = want.CATALYST_HOST_NAME;
|
|
948
979
|
if (p.info.vm && persistedCustody(p.dir, saved) !== "installed")
|
|
949
980
|
return mayAct && !afterAct ? { state: "pending" } : waiting(mayAct ? "runner_thoughts_custody_unavailable" : "runner_needs_runner_flag", { ...selected, hostName: name });
|
|
981
|
+
if (samplerManaged(p, ctx, saved) && engine.hostDiskSamplerStatus) {
|
|
982
|
+
const sampler = await engine.hostDiskSamplerStatus(p.dir, samplerOptions(ctx, saved));
|
|
983
|
+
if (sampler !== "installed")
|
|
984
|
+
return mayAct && !afterAct ? { state: "pending" } : waiting(!mayAct ? "runner_needs_runner_flag" : sampler === "missing" ? "runner_host_disk_sampler_failed" : sampler, { ...selected, hostName: name,
|
|
985
|
+
...(sampler === "runner_host_disk_sampler_conflict" ? foreignHostDiskSample(ctx.home) : {}) });
|
|
986
|
+
}
|
|
950
987
|
let enrolled = null;
|
|
951
988
|
const live = (hosts) => hosts?.find((host) => host.hostId === enrolled?.hostId && serves(host, p.teamKey) && !host.revoked);
|
|
952
989
|
let hosts = await listHosts(ctx, p, signal);
|
|
@@ -1023,6 +1060,15 @@ export function onboardRunnerAdapter(input = {}) {
|
|
|
1023
1060
|
return pending();
|
|
1024
1061
|
if (keyStatus !== "valid")
|
|
1025
1062
|
return waiting(keyStatus === "missing" ? "runner_org_key_missing" : keyStatus === "invalid" ? "runner_org_key_invalid" : "runner_org_key_unavailable", evidence);
|
|
1063
|
+
if (host.capacity === 0 && host.live && host.failing.length === 0) {
|
|
1064
|
+
// The supervisor advertises zero slots when disk admission refuses; its reason stays in its log.
|
|
1065
|
+
const disk = await engine.hostDisk?.(p.dir, p.info.vm);
|
|
1066
|
+
if (disk && disk.state !== "measured")
|
|
1067
|
+
return waiting(disk.state === "below_floor" ? "runner_host_disk_below_floor" : "runner_host_disk_unmeasured", {
|
|
1068
|
+
...evidence, floorGiB: disk.floorGiB, vm: p.info.vm,
|
|
1069
|
+
...(disk.state === "below_floor" ? { availableGiB: disk.availableGiB } : { diskDetail: disk.detail }),
|
|
1070
|
+
});
|
|
1071
|
+
}
|
|
1026
1072
|
if (host.capacity === 0 || host.failing.length > 0)
|
|
1027
1073
|
return waiting("runner_host_not_ready", { ...evidence, failing: host.failing.join(",") });
|
|
1028
1074
|
if (p.cloud) {
|
|
@@ -1166,6 +1212,12 @@ export function onboardRunnerAdapter(input = {}) {
|
|
|
1166
1212
|
(advice ? " " + advice : ""), details, signal);
|
|
1167
1213
|
}
|
|
1168
1214
|
}
|
|
1215
|
+
if (samplerManaged(p, ctx, readEnvFile(join(p.dir, ".env"))) && engine.installHostDiskSampler) {
|
|
1216
|
+
const sampler = await engine.installHostDiskSampler(p.dir, samplerOptions(ctx, readEnvFile(join(p.dir, ".env"))));
|
|
1217
|
+
if (sampler !== "ready")
|
|
1218
|
+
return waiting(sampler, sampler === "runner_host_disk_sampler_conflict"
|
|
1219
|
+
? { ...selected, ...foreignHostDiskSample(ctx.home) } : selected);
|
|
1220
|
+
}
|
|
1169
1221
|
if (!writeEnv(want))
|
|
1170
1222
|
return { state: "failed", reason: "runner_directory_unavailable", evidence: selected };
|
|
1171
1223
|
const network = await engine.network(RUNNER_SESSION_NETWORK, signal);
|
package/dist/onboard.js
CHANGED
|
@@ -324,6 +324,14 @@ function normalizeStep(value, fallbackAt) {
|
|
|
324
324
|
"custodyResultInstalled",
|
|
325
325
|
"custodyAuthorityMatched",
|
|
326
326
|
"custodyTelemetry",
|
|
327
|
+
// CTC-5929: the runner's disk reason and a foreign sampler's paths. Without them the saved
|
|
328
|
+
// step read back as "no disk sampler" on a Linux host that was below its floor.
|
|
329
|
+
"floorGiB",
|
|
330
|
+
"availableGiB",
|
|
331
|
+
"vm",
|
|
332
|
+
"diskDetail",
|
|
333
|
+
"sampleDir",
|
|
334
|
+
"sampleFile",
|
|
327
335
|
]) {
|
|
328
336
|
const item = evidence?.[key];
|
|
329
337
|
if (item === null ||
|