@catalyst-cloud/cli 0.16.14 → 0.16.16
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/dist/args.js +15 -0
- package/dist/cli.js +8 -0
- package/dist/onboard-next.js +21 -1
- package/dist/onboard-runner-custody.js +48 -11
- package/dist/onboard-runner.js +65 -6
- package/dist/seat.js +45 -0
- package/dist/stage.js +824 -0
- package/dist/transport.js +13 -2
- package/dist/write.js +49 -7
- package/package.json +4 -4
- package/skills/catalyst-github/SKILL.md +1 -1
- package/skills/catalyst-linear/SKILL.md +1 -1
- package/skills/catalyst-linear/references/writing-to-linear.md +2 -2
- package/skills/catalyst-linear/scripts/label.mjs +6 -5
- package/skills/catalyst-onboard/SKILL.md +7 -6
- package/skills/catalyst-onboard/references/connecting-this-machine.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 +2 -2
- package/skills/whats-happening/scripts/snapshot.mjs +128 -34
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.16.16
|
|
4
|
+
|
|
5
|
+
Publishing a browser verification recording now saves its proof card with the required attachment details. Update the CLI before using `browser-verify --publish`.
|
|
6
|
+
|
|
7
|
+
## 0.16.15
|
|
8
|
+
|
|
9
|
+
Command-line workflows can report stage progress with `catalyst stage start`, `renew`, `done`, and `fail`, and choose an eligible AI account and model for a local session with `catalyst seat checkout`. Retries reuse the same stage attempt. `catalyst write create --label` and `write label --add`/`--remove` now accept a label by name, and a refused write exits non-zero with a JSON error on stdout under `--json`.
|
|
10
|
+
|
|
3
11
|
## 0.16.14
|
|
4
12
|
|
|
5
13
|
Setup uses the updated macOS folder checks and verifies the required release image before starting work. Re-run setup to update this computer.
|
package/dist/args.js
CHANGED
|
@@ -327,6 +327,13 @@ export const FLAG_TABLES = {
|
|
|
327
327
|
},
|
|
328
328
|
},
|
|
329
329
|
accounts: {},
|
|
330
|
+
seat: {
|
|
331
|
+
size: { value: true, help: "model size: big or med" },
|
|
332
|
+
stage: { value: true, help: "sized relay stage; defaults from size" },
|
|
333
|
+
harness: { value: true, help: "comma-separated runnable harnesses" },
|
|
334
|
+
"session-id": { value: true, help: "stable id for this local session; generated when omitted" },
|
|
335
|
+
"key-env": { value: true, help: "read the bearer from this environment variable, never argv" },
|
|
336
|
+
},
|
|
330
337
|
env: {
|
|
331
338
|
root: {
|
|
332
339
|
value: true,
|
|
@@ -483,6 +490,12 @@ export const FLAG_TABLES = {
|
|
|
483
490
|
help: "with --class: at most this many tickets (the cloud caps it at 25)",
|
|
484
491
|
},
|
|
485
492
|
},
|
|
493
|
+
stage: {
|
|
494
|
+
artifact: { value: true, help: "done/fail: path to the phase artifact" },
|
|
495
|
+
evidence: { value: true, help: "done: JSON file with phase-specific receipt evidence" },
|
|
496
|
+
summary: { value: true, help: "done/fail: concise outcome summary" },
|
|
497
|
+
assertion: { value: true, help: "renew: work completed since the previous renewal" },
|
|
498
|
+
},
|
|
486
499
|
};
|
|
487
500
|
export const VERB_USAGE = {
|
|
488
501
|
hosts: "hosts [list] | hosts remove <name-or-id> [--yes] | hosts rename <name-or-id> <new-name> [--json]",
|
|
@@ -507,6 +520,7 @@ export const VERB_USAGE = {
|
|
|
507
520
|
ask: "ask <raise --team --title [--context] [--option]... [--default] --blocks <ticket>...|--nothing-to-block [--ask-key] [--gates-pr <n>... --released-by <letter>... [--gates-label hold|hold:preview]... [--gates-repo <owner/name>]] | accept <askTicket> --answer <commentId> --role <role> | list [--anyone] [--json]>",
|
|
508
521
|
ready: "ready [--onboarding] [--local-sync] [--json] [--offline]",
|
|
509
522
|
accounts: "accounts [--json]",
|
|
523
|
+
seat: "seat checkout --size big|med --harness claude,codex,opencode [--session-id ID] [--stage STAGE] [--key-env NAME]",
|
|
510
524
|
mcp: "mcp <add <name> --url URL <--auth none|--bearer SECRET_NAME|--header NAME=SECRET_NAME...>|list|remove <name>> [--json]",
|
|
511
525
|
team: "team <list|check <KEY>|check --all|map <KEY> [--stage role=StateName]... [--yes --plan-hash H]|adopt <KEY> [--undo] [--yes --plan-hash H]|migrate <KEY> [--choice sourceId=destinationId]... [--retire] [--yes --plan-hash H]|checklist <KEY>> [--json]",
|
|
512
526
|
env: "env <inventory [path] | check <file> | migrate [catalyst.env.json] | draft [--root DIR] [--diff] [--write]> [--json] (THIS repository, offline — no login, no network)",
|
|
@@ -520,6 +534,7 @@ export const VERB_USAGE = {
|
|
|
520
534
|
legacy: "legacy [--remove [--data] [--yes]] [--json]",
|
|
521
535
|
connections: "connections personal <linear|github> <start|status> [--wait <seconds>] [--json]",
|
|
522
536
|
release: "release <ticket> --because <what changed> [--retry-unchanged] [--dry-run] [--json] | release --class <failure-class> --team <K> --because <what changed> [--retry-unchanged] [--dry-run] [--limit N] [--json]",
|
|
537
|
+
stage: "stage start|renew|done|fail <ticket> <phase> [--artifact <path>] [--evidence <json>] [--summary <text>] [--assertion <work done>]",
|
|
523
538
|
};
|
|
524
539
|
export function parseArgs(argv) {
|
|
525
540
|
const out = {
|
package/dist/cli.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { cmdHosts } from "./hosts.js";
|
|
2
|
+
import { cmdSeat } from "./seat.js";
|
|
2
3
|
// cli.ts — the `catalyst` dispatcher (npm: @catalyst-cloud/cli). Verbs live in
|
|
3
4
|
// their own modules; this file keeps every export the 0.1 tests and the bin import.
|
|
4
5
|
import { createSetupRenderer, setupBanner, } from "./setup-render.js";
|
|
@@ -34,6 +35,7 @@ import { cmdWrite } from "./write.js";
|
|
|
34
35
|
import { cmdAsk } from "./ask.js";
|
|
35
36
|
import { cmdMcp } from "./mcp.js";
|
|
36
37
|
import { cmdRelease } from "./release.js";
|
|
38
|
+
import { cmdStage } from "./stage.js";
|
|
37
39
|
import { cmdEnvironment } from "./environment.js";
|
|
38
40
|
import { cmdEnv } from "./env.js";
|
|
39
41
|
import { cmdSecret } from "./secret.js";
|
|
@@ -80,6 +82,7 @@ export function usageText() {
|
|
|
80
82
|
" catalyst onboard [--resume-from <step>] [--only <step>] [--runner|--no-runner] [--yes] [--dry-run] [--json]",
|
|
81
83
|
` catalyst ${SETUP_USAGE}`,
|
|
82
84
|
" catalyst status | notice | me | ready | accounts",
|
|
85
|
+
" catalyst seat checkout --size big|med --harness claude,codex,opencode [--session-id ID] [--stage STAGE]",
|
|
83
86
|
" catalyst hosts [list] | hosts remove <name-or-id> [--yes] | hosts rename <name-or-id> <new-name>",
|
|
84
87
|
" catalyst mcp add|list|remove (vault references only)",
|
|
85
88
|
" catalyst contract [--refresh] [--path <a.b.c>]",
|
|
@@ -99,6 +102,7 @@ export function usageText() {
|
|
|
99
102
|
" catalyst team <list|check|map|adopt|migrate|checklist> ...",
|
|
100
103
|
" catalyst capabilities [--json] (what this CLI can do, the role each verb needs, and whether this cloud serves it)",
|
|
101
104
|
" catalyst project list [--json] (all workspace projects, including those with no stage mapping)",
|
|
105
|
+
" catalyst stage <start|renew|done|fail> <ticket> <phase> (a cloud-claimed local phase)",
|
|
102
106
|
" catalyst project wip-limit <get|set <n>|set default> [--team K] (a project's new-start WIP limit; members read, owners and admins set)",
|
|
103
107
|
" catalyst repo <agents-block <path> [--write]|agent-setup <path> [--apply] [--with-check]> (a checkout's AGENTS.md block and portable agent layout; working tree only)",
|
|
104
108
|
" catalyst legacy [--remove [--data] [--yes]] (leftovers of the old local Catalyst runtime: list them; remove them only on a yes)",
|
|
@@ -491,8 +495,12 @@ export async function main(argv, ctx = defaultCtx(), deps = {}) {
|
|
|
491
495
|
return await cmdMcp(args, ctx);
|
|
492
496
|
case "accounts":
|
|
493
497
|
return await cmdAccounts(args, ctx);
|
|
498
|
+
case "seat":
|
|
499
|
+
return await cmdSeat(args, ctx);
|
|
494
500
|
case "release":
|
|
495
501
|
return await cmdRelease(args, ctx);
|
|
502
|
+
case "stage":
|
|
503
|
+
return await cmdStage(args, ctx);
|
|
496
504
|
case "environment":
|
|
497
505
|
return await cmdEnvironment(args, ctx, deps.environment ?? {});
|
|
498
506
|
case "env":
|
package/dist/onboard-next.js
CHANGED
|
@@ -150,7 +150,8 @@ const REASONS = {
|
|
|
150
150
|
runner_org_key_invalid: "The runner account key is invalid, belongs to another workspace, or lacks mirror read, write and feed permissions. Supply a scoped key for this workspace with CATALYST_RUNNER_ORG_KEY_FILE, then run catalyst onboard --runner.",
|
|
151
151
|
runner_org_key_unavailable: "The runner account key could not be checked with Catalyst. Run catalyst onboard --runner to retry.",
|
|
152
152
|
runner_org_key_write_failed: "Setup could not place the organization key on the runner. Check that Docker is running, then run catalyst onboard again.",
|
|
153
|
-
runner_compose_failed: "Docker Compose could not start the runner. Run docker compose -p catalyst-host logs to see why, then run catalyst onboard again.",
|
|
153
|
+
runner_compose_failed: "Docker Compose could not start the runner project catalyst-host. Run docker compose -p catalyst-host logs to see why, then run catalyst onboard --runner again.",
|
|
154
|
+
runner_compose_paused: "The existing catalyst-host Compose project's containers are paused. Run docker compose -p catalyst-host unpause to resume them, then run catalyst onboard --runner again. Setup removed nothing.",
|
|
154
155
|
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.",
|
|
155
156
|
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.",
|
|
156
157
|
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.",
|
|
@@ -450,6 +451,25 @@ export function onboardReasonText(step, context = {}) {
|
|
|
450
451
|
: "";
|
|
451
452
|
return `The runner is enrolled but its readiness checks fail${checks}. Run docker compose -p catalyst-host logs supervisor for the remedy, then run catalyst onboard again.`;
|
|
452
453
|
}
|
|
454
|
+
// CTC-5721: a Compose failure, or a paused older project, carries the evidence the adapter recorded.
|
|
455
|
+
if (reason === "runner_compose_paused") {
|
|
456
|
+
const services = typeof step.evidence?.services === "string" &&
|
|
457
|
+
/^[a-z0-9][a-z0-9_-]{0,63}(,[a-z0-9][a-z0-9_-]{0,63})*$/.test(step.evidence.services)
|
|
458
|
+
? ` (${step.evidence.services.split(",").join(", ")})`
|
|
459
|
+
: "";
|
|
460
|
+
return `Setup found the existing catalyst-host Compose project's containers${services} paused and could not resume them. Run docker compose -p catalyst-host unpause to resume them, then run catalyst onboard --runner again. Setup removed nothing.`;
|
|
461
|
+
}
|
|
462
|
+
if (reason === "runner_compose_failed") {
|
|
463
|
+
const project = typeof step.evidence?.project === "string" &&
|
|
464
|
+
/^[a-z0-9][a-z0-9-]{0,63}$/.test(step.evidence.project)
|
|
465
|
+
? step.evidence.project
|
|
466
|
+
: "catalyst-host";
|
|
467
|
+
const error = typeof step.evidence?.composeError === "string" &&
|
|
468
|
+
/^[\x20-\x7e]{1,500}$/.test(step.evidence.composeError)
|
|
469
|
+
? step.evidence.composeError.replace(/\.+$/, "")
|
|
470
|
+
: undefined;
|
|
471
|
+
return `Docker Compose could not start the runner project ${project}${error ? `: ${error}` : ""}. Run docker compose -p ${project} logs to see why, then run catalyst onboard --runner again.`;
|
|
472
|
+
}
|
|
453
473
|
if (reason === "runner_admission_operator")
|
|
454
474
|
return `The admission policy for team ${onboardTeamKey(context.journal) ?? "<TEAM KEY>"} needs a Catalyst operator to check it. Setup changes only restricted admission and preserves existing policy fields. Ask Catalyst support to confirm a compatible policy, then run the same setup command.`;
|
|
455
475
|
// CTC-4744: the cloud has not read the settings yet. Setup already waited a minute; this is
|
|
@@ -144,6 +144,39 @@ class Custody {
|
|
|
144
144
|
}
|
|
145
145
|
this.hold(value, false);
|
|
146
146
|
}
|
|
147
|
+
/** Atomically re-records an already-held private file. The replacement is created inside the
|
|
148
|
+
* held directory and renamed over the target, then re-held, so every custody invariant
|
|
149
|
+
* (single-owner 0600, nlink 1, no symlinks) is re-verified on the new bytes. */
|
|
150
|
+
replace(value, bytes) {
|
|
151
|
+
const held = this.hold(value, false);
|
|
152
|
+
this.recheck();
|
|
153
|
+
const temporary = value + "." + randomBytes(16).toString("hex") + ".tmp";
|
|
154
|
+
let renamed = false;
|
|
155
|
+
try {
|
|
156
|
+
const fd = fs.openSync(temporary, fs.constants.O_WRONLY | fs.constants.O_CREAT |
|
|
157
|
+
fs.constants.O_EXCL | fs.constants.O_NOFOLLOW, 0o600);
|
|
158
|
+
try {
|
|
159
|
+
fs.writeFileSync(fd, bytes);
|
|
160
|
+
fs.fsyncSync(fd);
|
|
161
|
+
}
|
|
162
|
+
finally {
|
|
163
|
+
fs.closeSync(fd);
|
|
164
|
+
}
|
|
165
|
+
fs.renameSync(temporary, value);
|
|
166
|
+
renamed = true;
|
|
167
|
+
}
|
|
168
|
+
finally {
|
|
169
|
+
if (!renamed)
|
|
170
|
+
try {
|
|
171
|
+
fs.unlinkSync(temporary);
|
|
172
|
+
}
|
|
173
|
+
catch { /* The original error propagates. */ }
|
|
174
|
+
}
|
|
175
|
+
fs.closeSync(held.fd);
|
|
176
|
+
this.held.delete(value);
|
|
177
|
+
this.hold(value, false);
|
|
178
|
+
this.recheck();
|
|
179
|
+
}
|
|
147
180
|
recheck() {
|
|
148
181
|
for (const [value, held] of this.held) {
|
|
149
182
|
const current = this.ports.stat(value, fs.fstatSync(held.fd, { bigint: true }));
|
|
@@ -244,7 +277,7 @@ function factory(ports) {
|
|
|
244
277
|
const nodeHash = executable(executables.nodeExecutable, principal, roots);
|
|
245
278
|
const dockerHash = executable(executables.dockerExecutable, principal, roots);
|
|
246
279
|
let authority;
|
|
247
|
-
let
|
|
280
|
+
let enrolledSupervisorImage;
|
|
248
281
|
if (existing) {
|
|
249
282
|
custody.walk(state, false, 0o700, true);
|
|
250
283
|
const runtime = record(parse(custody.read(join(state, "runtime.json"), 4096)), ["version", "nodeExecutable", "nodeExecutableSha256"]);
|
|
@@ -274,20 +307,19 @@ function factory(ports) {
|
|
|
274
307
|
}
|
|
275
308
|
if (key.asymmetricKeyType !== "ed25519" || createPublicKey(key).export({ type: "spki", format: "der" }).toString("base64") !== authority.publicKey)
|
|
276
309
|
fail("private_key_authority_mismatch");
|
|
277
|
-
// The
|
|
278
|
-
|
|
310
|
+
// The recorded helper image only authenticates the existing state. When it is not the
|
|
311
|
+
// current pin, the producer is re-attested on the current pin below, before the proof.
|
|
312
|
+
enrolledSupervisorImage = row.supervisorImage;
|
|
279
313
|
}
|
|
280
314
|
custody.recheck();
|
|
281
315
|
alive();
|
|
282
316
|
const engine = await ports.engineIdentity({ ...executables, principal,
|
|
283
|
-
supervisorImage: input.supervisorImage,
|
|
317
|
+
supervisorImage: input.supervisorImage,
|
|
284
318
|
runnerImage: input.runnerImage, deadlineMs: input.deadlineMs, signal });
|
|
285
319
|
alive();
|
|
286
320
|
if (!/^unix:\/\/\/[^,\0]+$/.test(engine.endpoint) || normalize(engine.endpoint.slice(7)) !== engine.endpoint.slice(7) ||
|
|
287
321
|
!engine.cachedSupervisor || !engine.cachedRunner || !/^[A-Za-z0-9][A-Za-z0-9_.:-]{0,255}$/.test(engine.daemonId))
|
|
288
322
|
fail("engine_endpoint_or_cache_invalid");
|
|
289
|
-
if (engine.cachedProducerSupervisor !== true)
|
|
290
|
-
fail("producer_helper_cache_unavailable");
|
|
291
323
|
if (engine.runnerCustodyVersion !== "v1")
|
|
292
324
|
fail("runner_custody_label_unqualified");
|
|
293
325
|
if (authority && (authority.endpoint !== engine.endpoint || authority.daemonId !== engine.daemonId))
|
|
@@ -311,6 +343,15 @@ function factory(ports) {
|
|
|
311
343
|
dockerExecutable: executables.dockerExecutable, dockerExecutableSha256: dockerHash,
|
|
312
344
|
})));
|
|
313
345
|
}
|
|
346
|
+
else if (enrolledSupervisorImage !== input.supervisorImage) {
|
|
347
|
+
// Re-attest the enrolled producer on the current pinned helper, exactly as first
|
|
348
|
+
// enrollment records it before proving it: same authority, key and executable pins,
|
|
349
|
+
// same repository, cache-only, and the signed bootstrap below runs on the new pin.
|
|
350
|
+
custody.replace(configPath, Buffer.from(canonicalDarwinThoughtsJson({
|
|
351
|
+
version: 1, authority, privateKeyFile, supervisorImage: input.supervisorImage,
|
|
352
|
+
dockerExecutable: executables.dockerExecutable, dockerExecutableSha256: dockerHash,
|
|
353
|
+
})));
|
|
354
|
+
}
|
|
314
355
|
const artifacts = ports.artifacts();
|
|
315
356
|
for (const [name, bytes] of [["producer.mjs", artifacts.producer], ["watchdog.mjs", artifacts.watchdog],
|
|
316
357
|
["watchdog-deadline.mjs", artifacts.deadline]]) {
|
|
@@ -344,7 +385,7 @@ function factory(ports) {
|
|
|
344
385
|
await ports.service({ ...executables, uid: principal.uid, serviceLabel: LABEL, plistPath, deadlineMs: input.deadlineMs, signal });
|
|
345
386
|
alive();
|
|
346
387
|
const receipt = await ports.receiveBootstrap({ ...executables, authority, configPath,
|
|
347
|
-
supervisorImage:
|
|
388
|
+
supervisorImage: input.supervisorImage, deadlineMs: input.deadlineMs, signal });
|
|
348
389
|
alive();
|
|
349
390
|
if (ports.now() >= input.deadlineMs)
|
|
350
391
|
fail("startup_proof_expired");
|
|
@@ -470,9 +511,6 @@ async function actualEngine(input) {
|
|
|
470
511
|
const run = (args) => command(input.dockerExecutable, ["--config", config, "--host", selected, ...args], input.deadlineMs, 16384, input.signal);
|
|
471
512
|
const daemon = await run(["info", "--format", "{{.ID}}"]);
|
|
472
513
|
const supervisor = await run(["image", "inspect", "--format", "{{.Id}}", input.supervisorImage]);
|
|
473
|
-
const producerSupervisorImage = input.producerSupervisorImage ?? input.supervisorImage;
|
|
474
|
-
const producerSupervisor = producerSupervisorImage === input.supervisorImage ? supervisor :
|
|
475
|
-
await run(["image", "inspect", "--format", "{{.Id}}", producerSupervisorImage]);
|
|
476
514
|
const runner = await run(["image", "inspect", "--format", "{{json .}}", input.runnerImage]);
|
|
477
515
|
if (daemon.code !== 0)
|
|
478
516
|
fail("engine_identity_unavailable");
|
|
@@ -506,7 +544,6 @@ async function actualEngine(input) {
|
|
|
506
544
|
}
|
|
507
545
|
return { endpoint: selected, daemonId: daemon.stdout,
|
|
508
546
|
cachedSupervisor: supervisor.code === 0 && /^sha256:[a-f0-9]{64}$/.test(supervisor.stdout),
|
|
509
|
-
cachedProducerSupervisor: producerSupervisor.code === 0 && /^sha256:[a-f0-9]{64}$/.test(producerSupervisor.stdout),
|
|
510
547
|
cachedRunner: runnerId !== undefined,
|
|
511
548
|
...(runnerCustodyVersion === undefined ? {} : { runnerCustodyVersion }) };
|
|
512
549
|
}
|
package/dist/onboard-runner.js
CHANGED
|
@@ -91,6 +91,18 @@ const spawnDocker = (args, options) => new Promise((resolve, reject) => {
|
|
|
91
91
|
child.on("close", (code) => resolve({ code: code ?? 1, stdout, stderr }));
|
|
92
92
|
child.stdin.end(options.input ?? "");
|
|
93
93
|
});
|
|
94
|
+
/** Compose's own error lines, reduced to short printable text a person can act on. */
|
|
95
|
+
function composeErrorText(raw) {
|
|
96
|
+
if (!raw)
|
|
97
|
+
return undefined;
|
|
98
|
+
const lines = raw
|
|
99
|
+
.split(/\r?\n/)
|
|
100
|
+
.map((line) => line.replace(/[^\x20-\x7e]/g, "").trim())
|
|
101
|
+
.filter((line) => line !== "" && !line.startsWith("time="));
|
|
102
|
+
const errors = lines.filter((line) => /error|failed|cannot|conflict|paused|in use|refused|denied|invalid/i.test(line));
|
|
103
|
+
const text = (errors.length > 0 ? errors : lines).slice(0, 3).join(" | ").slice(0, 480);
|
|
104
|
+
return text !== "" ? text : undefined;
|
|
105
|
+
}
|
|
94
106
|
/** The real engine, through the docker CLI. Compose reads `.env` only: the caller's CATALYST_ and
|
|
95
107
|
* DOCKER_SOCKET_ variables are removed, because Compose prefers an exported value over the file. */
|
|
96
108
|
export function dockerRunnerEngine(deps = {}) {
|
|
@@ -273,6 +285,27 @@ export function dockerRunnerEngine(deps = {}) {
|
|
|
273
285
|
return "unverified";
|
|
274
286
|
}
|
|
275
287
|
},
|
|
288
|
+
async projectContainers(signal) {
|
|
289
|
+
const read = await docker(["ps", "--all",
|
|
290
|
+
"--filter", `label=com.docker.compose.project=${RUNNER_PROJECT}`,
|
|
291
|
+
"--format", '{{.Label "com.docker.compose.service"}}\t{{.State}}'], { signal });
|
|
292
|
+
if (read.code !== 0)
|
|
293
|
+
return null;
|
|
294
|
+
const rows = [];
|
|
295
|
+
for (const line of read.stdout.split("\n")) {
|
|
296
|
+
const [service, state] = line.trim().split("\t");
|
|
297
|
+
if (service && state &&
|
|
298
|
+
/^[a-z][a-z0-9_-]{0,63}$/.test(service) && /^[a-z]+$/.test(state))
|
|
299
|
+
rows.push({ service, state });
|
|
300
|
+
}
|
|
301
|
+
return rows;
|
|
302
|
+
},
|
|
303
|
+
async composeUnpause(dir, signal) {
|
|
304
|
+
const files = compose(dir);
|
|
305
|
+
if (!files)
|
|
306
|
+
return false;
|
|
307
|
+
return (await docker([...files, "unpause"], { cwd: dir, signal, timeoutMs: 120_000 })).code === 0;
|
|
308
|
+
},
|
|
276
309
|
async imageArch(ref, signal) {
|
|
277
310
|
if (!IMAGE_REF.test(ref))
|
|
278
311
|
return null;
|
|
@@ -495,9 +528,11 @@ export function dockerRunnerEngine(deps = {}) {
|
|
|
495
528
|
async composeUp(dir, signal) {
|
|
496
529
|
const files = compose(dir);
|
|
497
530
|
if (!files)
|
|
498
|
-
return false;
|
|
499
|
-
|
|
500
|
-
|
|
531
|
+
return { ok: false };
|
|
532
|
+
const read = await docker([...files, "up", "--detach"], { cwd: dir, signal, timeoutMs: 600_000 });
|
|
533
|
+
return read.code === 0
|
|
534
|
+
? { ok: true }
|
|
535
|
+
: { ok: false, error: composeErrorText(read.stderr || read.stdout) };
|
|
501
536
|
},
|
|
502
537
|
async composeRunning(dir, signal) {
|
|
503
538
|
const files = compose(dir);
|
|
@@ -942,6 +977,7 @@ export function onboardRunnerAdapter(input = {}) {
|
|
|
942
977
|
return p;
|
|
943
978
|
if (!mayAct)
|
|
944
979
|
return waiting("runner_needs_runner_flag", selected);
|
|
980
|
+
let replacingContainers = false;
|
|
945
981
|
try {
|
|
946
982
|
mkdirSync(p.dir, { recursive: true, mode: 0o700 });
|
|
947
983
|
if (lstatSync(p.dir).isSymbolicLink())
|
|
@@ -968,8 +1004,10 @@ export function onboardRunnerAdapter(input = {}) {
|
|
|
968
1004
|
catch {
|
|
969
1005
|
/* first run */
|
|
970
1006
|
}
|
|
971
|
-
if (!current?.equals(compose))
|
|
1007
|
+
if (!current?.equals(compose)) {
|
|
1008
|
+
replacingContainers = current !== null;
|
|
972
1009
|
writeFileSync(join(p.dir, "compose.yaml"), compose, { mode: 0o644 });
|
|
1010
|
+
}
|
|
973
1011
|
}
|
|
974
1012
|
catch {
|
|
975
1013
|
return { state: "failed", reason: "runner_directory_unavailable", evidence: selected };
|
|
@@ -1114,8 +1152,29 @@ export function onboardRunnerAdapter(input = {}) {
|
|
|
1114
1152
|
return waiting("runner_admission_unverified", selected);
|
|
1115
1153
|
}
|
|
1116
1154
|
}
|
|
1117
|
-
|
|
1118
|
-
|
|
1155
|
+
// CTC-5721: an older catalyst-host project must never meet Compose as a surprise exit.
|
|
1156
|
+
// Its paused containers cannot be started by Compose: adopt them, or name the exact command.
|
|
1157
|
+
const containers = (await engine.projectContainers?.(signal)) ?? null;
|
|
1158
|
+
const paused = containers?.filter((row) => row.state === "paused") ?? [];
|
|
1159
|
+
const pausedNames = [...new Set(paused.map((row) => row.service))];
|
|
1160
|
+
if (paused.length > 0) {
|
|
1161
|
+
const resumed = (await engine.composeUnpause?.(p.dir, signal)) === true;
|
|
1162
|
+
if (!resumed)
|
|
1163
|
+
return waiting("runner_compose_paused", { ...selected, hostName: name,
|
|
1164
|
+
project: RUNNER_PROJECT, services: pausedNames.join(",") });
|
|
1165
|
+
input.message?.(`Resumed the paused containers (${pausedNames.join(", ")}) of the existing ${RUNNER_PROJECT} project; setup adopts it.`);
|
|
1166
|
+
}
|
|
1167
|
+
else if (containers && containers.length > 0 && replacingContainers) {
|
|
1168
|
+
input.message?.(`Replacing the existing ${RUNNER_PROJECT} project's containers (${[...new Set(containers.map((row) => row.service))].join(", ")}) with this release's Compose definitions.`);
|
|
1169
|
+
}
|
|
1170
|
+
else if (containers && containers.some((row) => row.state !== "running")) {
|
|
1171
|
+
const stopped = [...new Set(containers.filter((row) => row.state !== "running").map((row) => row.service))];
|
|
1172
|
+
input.message?.(`Starting the existing ${RUNNER_PROJECT} project's stopped containers (${stopped.join(", ")}); setup adopts it.`);
|
|
1173
|
+
}
|
|
1174
|
+
const up = await engine.composeUp(p.dir, signal);
|
|
1175
|
+
if (!up.ok)
|
|
1176
|
+
return { state: "failed", reason: "runner_compose_failed", evidence: { ...selected,
|
|
1177
|
+
project: RUNNER_PROJECT, ...(up.error ? { composeError: up.error } : {}) } };
|
|
1119
1178
|
acted = true;
|
|
1120
1179
|
return { state: "done" };
|
|
1121
1180
|
},
|
package/dist/seat.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { flagString, positionals } from "./args.js";
|
|
3
|
+
import { DEFAULT_BASE_URL, requireConfig } from "./config.js";
|
|
4
|
+
import { UsageError } from "./errors.js";
|
|
5
|
+
import { ApiClient, apiClient } from "./transport.js";
|
|
6
|
+
const HARNESSES = new Set(["claude", "codex", "opencode"]);
|
|
7
|
+
/** The full response goes to stdout for a launcher to capture in an owner-only file. */
|
|
8
|
+
export async function cmdSeat(args, ctx) {
|
|
9
|
+
const [action, ...extra] = positionals(args);
|
|
10
|
+
if (action !== "checkout" || extra.length > 0)
|
|
11
|
+
throw new UsageError("seat checkout --size big|med --harness claude,codex,opencode [--session-id ID] [--stage STAGE]");
|
|
12
|
+
const size = flagString(args, "size");
|
|
13
|
+
const stage = flagString(args, "stage");
|
|
14
|
+
if (size !== undefined && size !== "big" && size !== "med")
|
|
15
|
+
throw new UsageError("--size must be big or med");
|
|
16
|
+
if (size === undefined && stage === undefined)
|
|
17
|
+
throw new UsageError("--size or --stage is required");
|
|
18
|
+
const harnesses = flagString(args, "harness")?.split(",");
|
|
19
|
+
if (!harnesses || harnesses.length === 0 || harnesses.some((h) => !HARNESSES.has(h)))
|
|
20
|
+
throw new UsageError("--harness must list claude, codex or opencode, separated by commas");
|
|
21
|
+
const sessionId = flagString(args, "session-id") ?? randomUUID();
|
|
22
|
+
if (!/^[A-Za-z0-9._-]{1,128}$/.test(sessionId))
|
|
23
|
+
throw new UsageError("--session-id must be one token of at most 128 letters, digits, dots, underscores or hyphens");
|
|
24
|
+
const keyEnv = flagString(args, "key-env");
|
|
25
|
+
if (keyEnv !== undefined && !/^[A-Z][A-Z0-9_]*$/.test(keyEnv))
|
|
26
|
+
throw new UsageError("--key-env must name an uppercase environment variable");
|
|
27
|
+
const key = keyEnv === undefined ? undefined : ctx.env[keyEnv];
|
|
28
|
+
if (keyEnv !== undefined && !key)
|
|
29
|
+
throw new UsageError(`--key-env ${keyEnv} is empty`);
|
|
30
|
+
const api = key === undefined
|
|
31
|
+
? apiClient(requireConfig(ctx), ctx)
|
|
32
|
+
: new ApiClient(ctx.env.CATALYST_CLOUD_BASE_URL ?? DEFAULT_BASE_URL, async () => key, ctx.fetch);
|
|
33
|
+
const response = await api.postJson("/api/v1/agent/credential-checkout", {
|
|
34
|
+
sessionId,
|
|
35
|
+
...(size === undefined ? {} : { size }),
|
|
36
|
+
...(stage === undefined ? {} : { stage }),
|
|
37
|
+
harnesses,
|
|
38
|
+
}, { accept: [409] });
|
|
39
|
+
if (response.status !== 200 || response.body.outcome !== "issued") {
|
|
40
|
+
ctx.stderr(`seat checkout refused: ${response.body.reason ?? "unexpected response"}`);
|
|
41
|
+
return 2;
|
|
42
|
+
}
|
|
43
|
+
ctx.stdout(JSON.stringify({ ...response.body, sessionId }));
|
|
44
|
+
return 0;
|
|
45
|
+
}
|