@catalyst-cloud/cli 0.16.28 → 0.16.30
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 +8 -0
- package/README.md +3 -3
- package/dist/args.js +15 -0
- package/dist/capabilities.js +2 -0
- package/dist/cli-version.js +41 -0
- package/dist/cli.js +23 -0
- package/dist/lease.js +302 -0
- package/dist/onboard-existing.js +12 -3
- package/dist/onboard-next.js +39 -0
- package/dist/onboard-projects.js +3 -3
- package/dist/onboard-repositories.js +10 -3
- package/dist/onboard-runner-disk.js +345 -0
- package/dist/onboard-runner.js +52 -0
- package/dist/onboard-ui.js +5 -1
- package/dist/onboard.js +11 -0
- package/dist/ready.js +47 -1
- package/dist/runner.js +19 -2
- package/dist/setup-onboard-copy.js +41 -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/run-this-project/references/stalls-and-escalation.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/skills/whats-happening/references/what-runs-next.md +1 -1
- package/skills/whats-happening/references/why-is-it-stuck.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
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { isRepositoryId } from "./repository-id.js";
|
|
2
2
|
import { loadConfig, normalizeBaseUrl, readManifest, } from "./config.js";
|
|
3
3
|
import { contractVersionInRange } from "./contract.js";
|
|
4
|
-
import { readExistingOnboardJson, selectedOnboardTeam, } from "./onboard-existing.js";
|
|
4
|
+
import { choiceEvidence, readExistingOnboardJson, selectedOnboardTeam, } from "./onboard-existing.js";
|
|
5
5
|
const part = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,99}$/;
|
|
6
6
|
const id = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
|
|
7
7
|
const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value)
|
|
@@ -12,6 +12,13 @@ const waiting = (reason) => ({
|
|
|
12
12
|
state: "waiting",
|
|
13
13
|
reason,
|
|
14
14
|
});
|
|
15
|
+
/** CTC-5932: a choice no one was there to make, with the repositories it could have been. */
|
|
16
|
+
export const repositoryChoiceWait = (rows) => rows.length
|
|
17
|
+
? {
|
|
18
|
+
...waiting("repository_choice_required"),
|
|
19
|
+
evidence: choiceEvidence(rows.map((row) => `${row.owner}/${row.name}`)),
|
|
20
|
+
}
|
|
21
|
+
: waiting("repository_choice_required");
|
|
15
22
|
const valid = (value) => {
|
|
16
23
|
const row = object(value);
|
|
17
24
|
return (!!row &&
|
|
@@ -381,7 +388,7 @@ export function existingRepositoryAdapter(args, choose) {
|
|
|
381
388
|
? waiting("repository_selection_unverified")
|
|
382
389
|
: choose
|
|
383
390
|
? { state: "pending" }
|
|
384
|
-
:
|
|
391
|
+
: repositoryChoiceWait(list.repositories);
|
|
385
392
|
return {
|
|
386
393
|
state: "done",
|
|
387
394
|
evidence: {
|
|
@@ -400,7 +407,7 @@ export function existingRepositoryAdapter(args, choose) {
|
|
|
400
407
|
if (!list.repositories.length)
|
|
401
408
|
return waiting("repository_inventory_empty");
|
|
402
409
|
if (!choose)
|
|
403
|
-
return
|
|
410
|
+
return repositoryChoiceWait(list.repositories);
|
|
404
411
|
const teamId = list.repositories[0].teamId;
|
|
405
412
|
const offers = list.canRegister
|
|
406
413
|
? await offeredRepositories(ctx, journal, teamId, list.repositories, signal)
|
|
@@ -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-ui.js
CHANGED
|
@@ -2,7 +2,7 @@ import { GITHUB_CONFIRM_ACCESS } from "./onboard-github-copy.js";
|
|
|
2
2
|
import { COMPUTER_CHECKS, standalonePlan, standalonePlanNotes, pendingContinuation, } from "./onboard-standalone-copy.js";
|
|
3
3
|
import { trackedPrompt } from "./setup-prompt-frame.js";
|
|
4
4
|
import { createOnboardInterrupts } from "./onboard-interrupts.js";
|
|
5
|
-
import { CLOUD_WORK, setupStepView, CONNECTION_REPAIR_REASONS, setupFinalScreen, setupBrowserInstruction, setupCommandWords as say, setupWaitsOnAdmin, TIMED_OUT_REASONS, setupPartHeading, setupPartProgress, setupTrackerRows, SETUP_PART_NUMBERS, SETUP_PART_OF, SETUP_PARTS, } from "./setup-onboard-copy.js";
|
|
5
|
+
import { CLOUD_WORK, setupStepView, CONNECTION_REPAIR_REASONS, setupFinalScreen, setupBrowserInstruction, setupCommandWords as say, setupWaitsOnAdmin, TIMED_OUT_REASONS, setupPartHeading, setupPartProgress, setupTrackerRows, unattendedSkipLines, SETUP_PART_NUMBERS, SETUP_PART_OF, SETUP_PARTS, } from "./setup-onboard-copy.js";
|
|
6
6
|
import { createOnboardProgress, } from "./onboard-progress.js";
|
|
7
7
|
import { CREATE_TEAM_CHOICE, suggestTeamKey, teamKeyProblem, teamNameProblem, } from "./onboard-team-create.js";
|
|
8
8
|
import { FIRST_TICKET_SAMPLE, FIRST_TICKET_SKIP, } from "./onboard-first-ticket.js";
|
|
@@ -297,6 +297,10 @@ export function createClackOnboardUi(prompts, streams, deps = {}) {
|
|
|
297
297
|
if (screen.paused)
|
|
298
298
|
renderer.line("Your progress is saved.");
|
|
299
299
|
renderer.tracker(setupTrackerRows(setupPartProgress(journal, planned.scope), "end"));
|
|
300
|
+
// CTC-5932: with no one to ask, setup says what it left out instead of letting it pass unsaid.
|
|
301
|
+
if (!interactive && !screen.paused)
|
|
302
|
+
for (const line of unattendedSkipLines(journal, planned))
|
|
303
|
+
renderer.line(line);
|
|
300
304
|
if (screen.readinessCause)
|
|
301
305
|
renderer.line(say(screen.readinessCause));
|
|
302
306
|
if (mine.length) {
|
package/dist/onboard.js
CHANGED
|
@@ -298,6 +298,9 @@ function normalizeStep(value, fallbackAt) {
|
|
|
298
298
|
"passed",
|
|
299
299
|
"team",
|
|
300
300
|
"teamKey",
|
|
301
|
+
// CTC-5932: the comma-joined choices a run with no one to ask names in its flag advice.
|
|
302
|
+
"choices",
|
|
303
|
+
"choiceTotal",
|
|
301
304
|
"selected",
|
|
302
305
|
"hostName",
|
|
303
306
|
"supervisorImage",
|
|
@@ -324,6 +327,14 @@ function normalizeStep(value, fallbackAt) {
|
|
|
324
327
|
"custodyResultInstalled",
|
|
325
328
|
"custodyAuthorityMatched",
|
|
326
329
|
"custodyTelemetry",
|
|
330
|
+
// CTC-5929: the runner's disk reason and a foreign sampler's paths. Without them the saved
|
|
331
|
+
// step read back as "no disk sampler" on a Linux host that was below its floor.
|
|
332
|
+
"floorGiB",
|
|
333
|
+
"availableGiB",
|
|
334
|
+
"vm",
|
|
335
|
+
"diskDetail",
|
|
336
|
+
"sampleDir",
|
|
337
|
+
"sampleFile",
|
|
327
338
|
]) {
|
|
328
339
|
const item = evidence?.[key];
|
|
329
340
|
if (item === null ||
|
package/dist/ready.js
CHANGED
|
@@ -16,7 +16,9 @@ import { installedBundleVersion } from "./skills.js";
|
|
|
16
16
|
import { onboardingReadyReport, observeCloudOnboarding, } from "./onboard-ready.js";
|
|
17
17
|
import { selectedOnboardTeam } from "./onboard-existing.js";
|
|
18
18
|
import { dispatchGateCopy, teamCheckCopy, valuesFix, whoFixes } from "./ready-copy.js";
|
|
19
|
-
import { onboardStatePath, readOnboardJournal } from "./onboard.js";
|
|
19
|
+
import { onboardStatePath, onboardStateRoot, readOnboardJournal } from "./onboard.js";
|
|
20
|
+
import { dockerRunnerEngine, readEnvFile, RUNNER_HOST_IMAGES } from "./onboard-runner.js";
|
|
21
|
+
import { dockerRootAvailableBytes, runnerDiskCopy, runnerDiskVerdict } from "./onboard-runner-disk.js";
|
|
20
22
|
export { semverOlder };
|
|
21
23
|
/** `ready`'s own wording for the replica. It never recommends adopting the replica — until the
|
|
22
24
|
* mirror's snapshot path is safe for a large tenant, a customer following that advice is the
|
|
@@ -112,6 +114,35 @@ export function catalystCommandCheck(env, platform = process.platform) {
|
|
|
112
114
|
"Keep using catalyst-skills on this machine, or put the npm global bin directory ahead of that one on PATH.",
|
|
113
115
|
};
|
|
114
116
|
}
|
|
117
|
+
/** CTC-5929: the disk admission the supervisor applies, read from the runner installed here. The
|
|
118
|
+
* running catalyst-host project names its own directory, so a moved state root is still found; the
|
|
119
|
+
* state root is only the fallback when Docker shows no such project or cannot be listed.
|
|
120
|
+
* A Mac runs the engine in a VM, so only the native sample counts; Linux measures Docker's data root. */
|
|
121
|
+
async function localRunnerDisk(ctx, engine) {
|
|
122
|
+
let dir = null;
|
|
123
|
+
const containers = await engine.projectContainers?.().catch(() => null);
|
|
124
|
+
if (containers?.some(row => row.service === "supervisor")) {
|
|
125
|
+
const installed = await engine.installation?.(ctx.home, ctx.env.CATALYST_SUPERVISOR_IMAGE ?? RUNNER_HOST_IMAGES.supervisor)
|
|
126
|
+
.catch(() => "unverified");
|
|
127
|
+
if (!installed || typeof installed === "string")
|
|
128
|
+
return { unverified: true };
|
|
129
|
+
dir = installed.dir;
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
try {
|
|
133
|
+
dir = join(onboardStateRoot(ctx.home, ctx.env), "runner");
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const saved = readEnvFile(join(dir, ".env"));
|
|
140
|
+
if (!saved?.CATALYST_SUPERVISOR_IMAGE)
|
|
141
|
+
return containers?.length ? { unverified: true } : null;
|
|
142
|
+
const vm = process.platform === "darwin";
|
|
143
|
+
return { vm, verdict: runnerDiskVerdict({ dir, saved, vm, nowMs: ctx.now().getTime(),
|
|
144
|
+
nativeAvailableBytes: () => dockerRootAvailableBytes(ctx.env) }) };
|
|
145
|
+
}
|
|
115
146
|
export async function readyReport(ctx, deps) {
|
|
116
147
|
const checks = [];
|
|
117
148
|
const facts = deps.runtime ?? detectRuntime();
|
|
@@ -311,6 +342,21 @@ export async function readyReport(ctx, deps) {
|
|
|
311
342
|
who: "you",
|
|
312
343
|
});
|
|
313
344
|
}
|
|
345
|
+
const disk = await (deps.runnerDisk ?? (() => localRunnerDisk(ctx, deps.runnerEngine ?? dockerRunnerEngine({ env: ctx.env }))))();
|
|
346
|
+
if (disk && "unverified" in disk) {
|
|
347
|
+
checks.push({ id: "runner:disk", ok: false, note: true,
|
|
348
|
+
line: "runner disk: a catalyst-host runner is installed here, but ready could not verify its installation to read its disk measurement",
|
|
349
|
+
fix: "run catalyst setup --runner, which checks the installation and names what is wrong" });
|
|
350
|
+
}
|
|
351
|
+
else if (disk) {
|
|
352
|
+
if (disk.verdict.state === "measured")
|
|
353
|
+
checks.push({ id: "runner:disk", ok: true,
|
|
354
|
+
line: `runner disk: ${disk.verdict.availableGiB} GiB free, at least ${disk.verdict.floorGiB} GiB needed` });
|
|
355
|
+
else {
|
|
356
|
+
const copy = runnerDiskCopy(disk.verdict, disk.vm);
|
|
357
|
+
checks.push({ id: "runner:disk", ok: false, line: `runner disk: ${copy.line}`, fix: copy.fix, who: "you" });
|
|
358
|
+
}
|
|
359
|
+
}
|
|
314
360
|
const replica = replicaStatus(ctx, cfg);
|
|
315
361
|
checks.push({
|
|
316
362
|
id: "replica",
|