@catalyst-cloud/cli 0.16.30 → 0.16.32
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 +10 -0
- package/README.md +7 -3
- package/bin/launch.js +28 -0
- package/dist/approved-release.js +210 -0
- package/dist/args.js +7 -0
- package/dist/capabilities.js +3 -0
- package/dist/cli-update-notice.js +7 -4
- package/dist/cli.js +10 -0
- package/dist/published.js +15 -0
- package/dist/ready.js +11 -5
- package/dist/replica.js +43 -6
- package/dist/runner-auto-update.js +3 -1
- package/dist/runner-command.js +95 -11
- package/dist/runner-update.js +93 -19
- package/dist/update-cli.js +234 -0
- package/dist/update-command.js +186 -0
- package/dist/update-inventory.js +96 -0
- package/dist/update-plugin.js +99 -0
- package/dist/update-policy.js +213 -0
- package/dist/update-replica.js +120 -0
- package/dist/update-sdk.js +262 -0
- package/dist/update-skills.js +64 -0
- package/dist/update-windows.js +81 -0
- package/dist/update.js +589 -0
- 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/skill-sources.md +8 -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
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
2
|
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
|
+
import { CLAUDE_PATH_ENV } from "./update-plugin.js";
|
|
4
5
|
// The schedule that runs `catalyst runner update --auto` every 15 minutes (CTC-4646): a LaunchAgent
|
|
5
6
|
// on macOS, a systemd user timer on Linux. It runs as the person who set the runner up, with the
|
|
6
7
|
// docker command they use, and only ever calls this CLI. Turning it off removes exactly these files.
|
|
@@ -40,7 +41,7 @@ export function launchAgentPlist(deps, dir) {
|
|
|
40
41
|
'<plist version="1.0"><dict>',
|
|
41
42
|
`<key>Label</key><string>${AUTO_UPDATE_LABEL}</string>`,
|
|
42
43
|
`<key>ProgramArguments</key><array>${args.map((a) => `<string>${xml(a)}</string>`).join("")}</array>`,
|
|
43
|
-
`<key>EnvironmentVariables</key><dict><key>PATH</key><string>${xml(deps.path)}</string><key>HOME</key><string>${xml(deps.home)}</string
|
|
44
|
+
`<key>EnvironmentVariables</key><dict><key>PATH</key><string>${xml(deps.path)}</string><key>HOME</key><string>${xml(deps.home)}</string>${deps.claude ? `<key>${CLAUDE_PATH_ENV}</key><string>${xml(deps.claude)}</string>` : ""}</dict>`,
|
|
44
45
|
// A calendar schedule, not StartInterval: on a busy Mac launchd held a StartInterval job as
|
|
45
46
|
// "pended nondemand spawn" for hours (mini-2, 2026-10-17), and RunAtLoad was pended the same
|
|
46
47
|
// way. The calendar entries fired on time on that same machine.
|
|
@@ -61,6 +62,7 @@ export function systemdUnits(deps, dir) {
|
|
|
61
62
|
"[Service]",
|
|
62
63
|
"Type=oneshot",
|
|
63
64
|
`Environment=${unitWord(`PATH=${deps.path}`)}`,
|
|
65
|
+
...(deps.claude ? [`Environment=${unitWord(`${CLAUDE_PATH_ENV}=${deps.claude}`)}`] : []),
|
|
64
66
|
`ExecStart=${[deps.cli, "runner", "update", "--auto", "--dir", dir].map(unitWord).join(" ")}`,
|
|
65
67
|
"",
|
|
66
68
|
].join("\n"),
|
package/dist/runner-command.js
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { isAbsolute, resolve } from "node:path";
|
|
2
2
|
import { flagBool, flagString, positionals } from "./args.js";
|
|
3
|
-
import { UsageError } from "./errors.js";
|
|
3
|
+
import { CliError, UsageError } from "./errors.js";
|
|
4
4
|
import { autoUpdateStatus, disableAutoUpdate, enableAutoUpdate, scheduledPath, } from "./runner-auto-update.js";
|
|
5
|
-
import { dockerRunnerUpdateEngine, readReleaseState, runnerDir, runRunnerUpdate, updateResultLines, } from "./runner-update.js";
|
|
5
|
+
import { dockerRunnerUpdateEngine, readReleaseState, RELEASE_COMPONENTS, runnerDir, runRunnerUpdate, updateResultLines, } from "./runner-update.js";
|
|
6
6
|
import { dockerFleetUpdateEngine, runFleetUpdate } from "./runner-update-fleet.js";
|
|
7
|
+
import { runUpdate, sharedReleaseRead, updateReportJson, updateReportLines } from "./update.js";
|
|
8
|
+
import { effectivePolicy, readUpdatePolicy, setComponentPolicy } from "./update-policy.js";
|
|
9
|
+
import { resolveClaude } from "./update-plugin.js";
|
|
10
|
+
import { defaultWindows, windowBounds } from "./update-windows.js";
|
|
7
11
|
import { cmdRunnerLifecycle, RUNNER_LIFECYCLE_ACTIONS } from "./runner.js";
|
|
8
12
|
const USAGE = "catalyst runner [status] | stop | start | uninstall [--yes] [--delete-checkouts] | runner update [--check] [--dir <runner directory>] [--fleet] | runner auto-update [status|on|off]";
|
|
9
13
|
const UPDATE_FLAGS = ["check", "auto", "dir", "fleet"];
|
|
@@ -13,6 +17,7 @@ function schedulerDeps(ctx, deps) {
|
|
|
13
17
|
home: ctx.home,
|
|
14
18
|
cli,
|
|
15
19
|
path: scheduledPath(cli, process.execPath, ctx.env.PATH),
|
|
20
|
+
claude: resolveClaude(ctx.home, ctx.env),
|
|
16
21
|
...deps.scheduler,
|
|
17
22
|
};
|
|
18
23
|
}
|
|
@@ -36,6 +41,17 @@ function statusLine(status) {
|
|
|
36
41
|
return "Automatic runner updates need your systemd user manager, which is not running for you. Run: sudo loginctl enable-linger $USER, then catalyst runner auto-update on";
|
|
37
42
|
return `Automatic runner updates are not available on this machine (${status.detail}). Run catalyst runner update yourself.`;
|
|
38
43
|
}
|
|
44
|
+
/** Why this machine's images must not move now: an owner's hold, or settings that cannot be read
|
|
45
|
+
* (which never count as "everything on"). Null when every image may move. */
|
|
46
|
+
function imageHold(ctx) {
|
|
47
|
+
try {
|
|
48
|
+
const policy = readUpdatePolicy(ctx.home);
|
|
49
|
+
return RELEASE_COMPONENTS.some((id) => !effectivePolicy(policy, id).enabled) ? "owner_setting" : null;
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return "update_policy_unreadable";
|
|
53
|
+
}
|
|
54
|
+
}
|
|
39
55
|
/** Setup's call: turn the schedule on for `dir` and say what happened in one line. */
|
|
40
56
|
export function enableRunnerAutoUpdate(ctx, dir) {
|
|
41
57
|
return statusLine(enableAutoUpdate(schedulerDeps(ctx, {}), dir));
|
|
@@ -60,17 +76,80 @@ export async function cmdRunner(args, ctx, deps = {}) {
|
|
|
60
76
|
const fleet = flagBool(args, "fleet");
|
|
61
77
|
if (fleet && dirFlag === undefined)
|
|
62
78
|
throw new UsageError("--fleet needs --dir, the directory that holds compose.json");
|
|
79
|
+
// One desired-release read serves the image check and the component report (CTC-6708).
|
|
80
|
+
const engine = sharedReleaseRead(deps.engine ?? dockerRunnerUpdateEngine({ env: ctx.env }));
|
|
81
|
+
// The 15-minute schedule runs `runner update --auto`. After the images it carries on with every
|
|
82
|
+
// other component (the CLI, the Cloud pack, the replica, opted-in SDK projects), on the same
|
|
83
|
+
// release read, so one schedule keeps the whole install on the approved release (CTC-6708). An
|
|
84
|
+
// image hold keeps only the images. A check never applies; with --json the components join the
|
|
85
|
+
// one JSON result, otherwise they get one line, and only when a release was read.
|
|
86
|
+
const components = async (imageResult) => {
|
|
87
|
+
if (!auto || fleet || check)
|
|
88
|
+
return null;
|
|
89
|
+
const report = await runUpdate(ctx, { mode: "apply", imagesDir: dir(), engine: () => engine, ...(imageResult ? { imageResult } : {}), ...deps.components });
|
|
90
|
+
if (report.approved === null)
|
|
91
|
+
return null;
|
|
92
|
+
const rows = report.components.filter((c) => !RELEASE_COMPONENTS.some((id) => id === c.id));
|
|
93
|
+
return {
|
|
94
|
+
rows,
|
|
95
|
+
line: `${ctx.now().toISOString()} components ${rows.map((c) => `${c.id}=${c.status}${c.status === "failed" || c.status === "pending" ? `(${c.reason})` : ""}`).join(" ")}`,
|
|
96
|
+
exitCode: rows.some((c) => c.status === "failed") || report.projects.some((p) => p.applied?.outcome === "failed") ? 1 : 0,
|
|
97
|
+
};
|
|
98
|
+
};
|
|
99
|
+
const print = (result, extra) => {
|
|
100
|
+
if (args.json)
|
|
101
|
+
ctx.stdout(JSON.stringify(extra ? { ...result, components: extra.rows } : result));
|
|
102
|
+
else if (auto) {
|
|
103
|
+
ctx.stdout(`${ctx.now().toISOString()} ${result.outcome} ${result.detail} generation=${result.generation ?? "none"}`);
|
|
104
|
+
if (extra)
|
|
105
|
+
ctx.stdout(extra.line);
|
|
106
|
+
}
|
|
107
|
+
else
|
|
108
|
+
for (const line of updateResultLines(result))
|
|
109
|
+
ctx.stdout(line);
|
|
110
|
+
return Math.max(result.exitCode, extra?.exitCode ?? 0);
|
|
111
|
+
};
|
|
112
|
+
// An owner's image hold stops this command's image work too, including the schedule's (CTC-6708).
|
|
113
|
+
const hold = check ? null : imageHold(ctx);
|
|
114
|
+
// The schedule's run takes a newer release only in an update window; an owner's run applies at
|
|
115
|
+
// once (CTC-6709, update-windows.ts).
|
|
116
|
+
if (hold !== null) {
|
|
117
|
+
const retained = { outcome: "retained", detail: hold, generation: readReleaseState(dir()).generation, exitCode: hold === "owner_setting" ? 0 : 1 };
|
|
118
|
+
// Unreadable settings move nothing; a hold leaves the other components to the release check.
|
|
119
|
+
return print(retained, hold === "owner_setting" ? await components(undefined) : null);
|
|
120
|
+
}
|
|
63
121
|
const result = fleet
|
|
64
122
|
? await runFleetUpdate(deps.fleetEngine ?? dockerFleetUpdateEngine({ env: ctx.env }), { dir: dir(), check })
|
|
65
|
-
: await runRunnerUpdate(
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
123
|
+
: await runRunnerUpdate(engine, { dir: dir(), check, ...(auto && !check ? { window: windowBounds(ctx.now().getTime(), readUpdatePolicy(ctx.home).windows ?? defaultWindows()) } : {}) });
|
|
124
|
+
// `runner update --check` also lists every installed component against the approved release, as
|
|
125
|
+
// `catalyst update --check` does. The image fields stay where scripts already read them.
|
|
126
|
+
if (check && !fleet && !auto) {
|
|
127
|
+
let report;
|
|
128
|
+
try {
|
|
129
|
+
report = await runUpdate(ctx, { mode: "check", imagesDir: dir(), engine: () => engine, imageResult: result });
|
|
130
|
+
}
|
|
131
|
+
catch (err) {
|
|
132
|
+
// Unreadable update settings cost the component report only; the image result still prints.
|
|
133
|
+
if (!(err instanceof CliError))
|
|
134
|
+
throw err;
|
|
135
|
+
ctx.stderr(`catalyst: ${err.message}`);
|
|
136
|
+
if (args.json)
|
|
137
|
+
ctx.stdout(JSON.stringify(result));
|
|
138
|
+
else
|
|
139
|
+
for (const line of updateResultLines(result))
|
|
140
|
+
ctx.stdout(line);
|
|
141
|
+
return result.exitCode;
|
|
142
|
+
}
|
|
143
|
+
if (args.json) {
|
|
144
|
+
const { schemaVersion, release, components, projects } = updateReportJson(report);
|
|
145
|
+
ctx.stdout(JSON.stringify({ ...result, schemaVersion, release, components, projects }));
|
|
146
|
+
}
|
|
147
|
+
else
|
|
148
|
+
for (const line of [...updateResultLines(result), ...updateReportLines(report)])
|
|
149
|
+
ctx.stdout(line);
|
|
150
|
+
return result.exitCode;
|
|
151
|
+
}
|
|
152
|
+
return print(result, await components(result));
|
|
74
153
|
}
|
|
75
154
|
if (action === "auto-update" && parts.length <= 2) {
|
|
76
155
|
const mode = parts[1] ?? "status";
|
|
@@ -84,6 +163,11 @@ export async function cmdRunner(args, ctx, deps = {}) {
|
|
|
84
163
|
status = autoUpdateStatus(scheduler);
|
|
85
164
|
else
|
|
86
165
|
throw new UsageError(USAGE);
|
|
166
|
+
// The owner's explicit on/off is also their setting for the three images, so a release check
|
|
167
|
+
// reports them as held (CTC-6708). Setup's own call (enableRunnerAutoUpdate) never lifts a hold.
|
|
168
|
+
if (mode === "off" || (mode === "on" && status.state === "on"))
|
|
169
|
+
for (const id of RELEASE_COMPONENTS)
|
|
170
|
+
setComponentPolicy(ctx.home, id, mode === "on");
|
|
87
171
|
if (args.json) {
|
|
88
172
|
const state = mode === "status" && dirFlag !== undefined ? readReleaseState(dirFlag) : undefined;
|
|
89
173
|
ctx.stdout(JSON.stringify({ ...status, ...(state ? { release: state } : {}) }));
|
package/dist/runner-update.js
CHANGED
|
@@ -11,12 +11,19 @@ import { dockerRunnerEngine, parseEnvText, persistedCustody, prepareRunnerImages
|
|
|
11
11
|
//
|
|
12
12
|
// - the release parses strictly: three digests from the three Catalyst repositories, never a
|
|
13
13
|
// tag or `latest`, and a generation newer than the one this machine last applied;
|
|
14
|
-
// - no phase container exists for this host;
|
|
14
|
+
// - no phase container exists for this host, unless the update is in place (below);
|
|
15
15
|
// - all three images are pulled by digest, natively for this engine, before anything changes;
|
|
16
16
|
// - on a Mac with private thoughts storage, that storage is re-attested on the new supervisor;
|
|
17
17
|
// - with the supervisor stopped, so it admits no new work, there is still no phase container.
|
|
18
18
|
// If one started in between, the supervisor comes back as it was and the update waits.
|
|
19
19
|
//
|
|
20
|
+
// In place (CTC-6709): when the release names its supervisor's journal format and it equals the
|
|
21
|
+
// format of the supervisor a failure would go back to, as recorded when that supervisor was applied,
|
|
22
|
+
// running phases are not waited out. The supervisor is recreated and re-adopts them, and they finish
|
|
23
|
+
// on the image they were granted. A rollback lands on a supervisor that reads the same journal. A
|
|
24
|
+
// format that differs, or is unknown on either side, waits for an idle machine as above, because an
|
|
25
|
+
// older supervisor that meets a newer journal refuses to start.
|
|
26
|
+
//
|
|
20
27
|
// Then it rewrites the three image lines in `.env`, keeps the previous file beside it, and runs
|
|
21
28
|
// `docker compose up`. The release counts as applied only when the running containers are its
|
|
22
29
|
// images and the supervisor logged `host_ready` since it started; a `.env` that already names the
|
|
@@ -26,6 +33,7 @@ import { dockerRunnerEngine, parseEnvText, persistedCustody, prepareRunnerImages
|
|
|
26
33
|
export const RELEASE_STATE_FILE = "release-state.json";
|
|
27
34
|
export const PREVIOUS_ENV_FILE = ".env.release-previous";
|
|
28
35
|
const LOCK_FILE = "update.lock";
|
|
36
|
+
const PENDING_REMOVAL_KEPT = 20;
|
|
29
37
|
const READY_DEADLINE_MS = 5 * 60_000;
|
|
30
38
|
const READY_POLL_MS = 5_000;
|
|
31
39
|
const ATTEMPT_HOST_LABEL = "dev.catalystcloud.attempt.host-id";
|
|
@@ -77,10 +85,18 @@ export function parseDesiredRelease(input) {
|
|
|
77
85
|
return null;
|
|
78
86
|
images[component] = { reference, sourceSha: value.sourceSha };
|
|
79
87
|
}
|
|
88
|
+
// Only the supervisor carries a journal format, a positive integer, as catalyst-cloud's parser.
|
|
89
|
+
const supervisor = input.supervisor;
|
|
90
|
+
if (supervisor.journalFormat !== undefined && !posInt(supervisor.journalFormat))
|
|
91
|
+
return null;
|
|
92
|
+
for (const component of ["runner", "watchdog"])
|
|
93
|
+
if (input[component].journalFormat !== undefined)
|
|
94
|
+
return null;
|
|
95
|
+
const journalFormat = supervisor.journalFormat === undefined ? null : supervisor.journalFormat;
|
|
80
96
|
const id = `hr-${shortDigest(images.runner.reference)}-${shortDigest(images.supervisor.reference)}-${shortDigest(images.watchdog.reference)}`;
|
|
81
97
|
if (input.releaseId !== id)
|
|
82
98
|
return null;
|
|
83
|
-
return { releaseId: id, generation: input.generation, approvedAtMs: input.approvedAtMs, images };
|
|
99
|
+
return { releaseId: id, generation: input.generation, approvedAtMs: input.approvedAtMs, images, journalFormat };
|
|
84
100
|
}
|
|
85
101
|
/** Whether to move. Every answer except a valid, strictly newer generation keeps the current images. */
|
|
86
102
|
export function decideRelease(appliedGeneration, read) {
|
|
@@ -103,7 +119,7 @@ export function decideRelease(appliedGeneration, read) {
|
|
|
103
119
|
return { action: "update", release };
|
|
104
120
|
}
|
|
105
121
|
export function readReleaseState(dir) {
|
|
106
|
-
const empty = { version: 1, generation: null, releaseId: null, appliedAtMs: null, previous: null, last: null };
|
|
122
|
+
const empty = { version: 1, generation: null, releaseId: null, appliedAtMs: null, previous: null, journal: null, pendingRemoval: [], windowSettledMs: null, last: null };
|
|
107
123
|
try {
|
|
108
124
|
const parsed = JSON.parse(readFileSync(join(dir, RELEASE_STATE_FILE), "utf8"));
|
|
109
125
|
if (!isRecord(parsed) || parsed.version !== 1)
|
|
@@ -114,6 +130,13 @@ export function readReleaseState(dir) {
|
|
|
114
130
|
releaseId: typeof parsed.releaseId === "string" ? parsed.releaseId : null,
|
|
115
131
|
appliedAtMs: posInt(parsed.appliedAtMs) ? parsed.appliedAtMs : null,
|
|
116
132
|
previous: isRecord(parsed.previous) ? parsed.previous : null,
|
|
133
|
+
journal: isRecord(parsed.journal) && posInt(parsed.journal.format) && typeof parsed.journal.supervisor === "string"
|
|
134
|
+
? { format: parsed.journal.format, supervisor: parsed.journal.supervisor }
|
|
135
|
+
: null,
|
|
136
|
+
pendingRemoval: Array.isArray(parsed.pendingRemoval)
|
|
137
|
+
? parsed.pendingRemoval.filter((ref) => typeof ref === "string").slice(0, PENDING_REMOVAL_KEPT)
|
|
138
|
+
: [],
|
|
139
|
+
windowSettledMs: posInt(parsed.windowSettledMs) ? parsed.windowSettledMs : null,
|
|
117
140
|
last: isRecord(parsed.last) ? parsed.last : null,
|
|
118
141
|
};
|
|
119
142
|
}
|
|
@@ -150,6 +173,9 @@ export function rewriteEnvText(text, values) {
|
|
|
150
173
|
}
|
|
151
174
|
return lines.join("\n");
|
|
152
175
|
}
|
|
176
|
+
/** Outcomes that settle a window: nothing was pending, or the machine restarted onto (or tried) a
|
|
177
|
+
* release. A busy deferral or a passing refusal leaves the window open for the next check. */
|
|
178
|
+
const SETTLES_WINDOW = new Set(["current", "recorded", "updated", "rolled_back", "failed"]);
|
|
153
179
|
/** One serialized update run. A second concurrent run returns `retained` with `update_running`. */
|
|
154
180
|
export async function runRunnerUpdate(engine, options) {
|
|
155
181
|
const { dir } = options;
|
|
@@ -161,10 +187,13 @@ export async function runRunnerUpdate(engine, options) {
|
|
|
161
187
|
if (!options.check && lock === null)
|
|
162
188
|
return { outcome: "retained", detail: "update_running", generation: readReleaseState(dir).generation, exitCode: 0 };
|
|
163
189
|
try {
|
|
190
|
+
if (!options.check)
|
|
191
|
+
await retryPendingRemoval(engine, dir, options.signal);
|
|
164
192
|
const result = await update(engine, options, now);
|
|
165
193
|
if (!options.check) {
|
|
166
194
|
const state = readReleaseState(dir);
|
|
167
|
-
|
|
195
|
+
const windowSettledMs = options.window && SETTLES_WINDOW.has(result.outcome) ? options.window.lastOpenedMs : state.windowSettledMs;
|
|
196
|
+
writeReleaseState(dir, { ...state, windowSettledMs, last: { atMs: now(), outcome: result.outcome, detail: result.detail, generation: result.generation } });
|
|
168
197
|
}
|
|
169
198
|
return result;
|
|
170
199
|
}
|
|
@@ -173,6 +202,19 @@ export async function runRunnerUpdate(engine, options) {
|
|
|
173
202
|
releaseLock(dir, lock);
|
|
174
203
|
}
|
|
175
204
|
}
|
|
205
|
+
/** Try again to remove images an earlier update replaced but could not remove, never one `.env`
|
|
206
|
+
* names now (a setup run or a person may have put it back). */
|
|
207
|
+
async function retryPendingRemoval(engine, dir, signal) {
|
|
208
|
+
const state = readReleaseState(dir);
|
|
209
|
+
if (state.pendingRemoval.length === 0)
|
|
210
|
+
return;
|
|
211
|
+
const inUse = new Set(Object.values(imagesOf(readEnvFile(join(dir, ".env")) ?? {})));
|
|
212
|
+
const removable = state.pendingRemoval.filter((ref) => !inUse.has(ref));
|
|
213
|
+
if (removable.length === 0)
|
|
214
|
+
return;
|
|
215
|
+
const kept = await engine.removeImages(removable, signal);
|
|
216
|
+
writeReleaseState(dir, { ...state, pendingRemoval: [...new Set([...state.pendingRemoval.filter((ref) => inUse.has(ref)), ...kept])] });
|
|
217
|
+
}
|
|
176
218
|
const imagesOf = (env) => Object.fromEntries(RELEASE_COMPONENTS.map((c) => [c, env[RELEASE_ENV_KEYS[c]] ?? ""]));
|
|
177
219
|
/** The running containers are these images, and the supervisor reported ready since it started. */
|
|
178
220
|
async function running(engine, dir, images, signal) {
|
|
@@ -195,6 +237,13 @@ async function update(engine, options, now) {
|
|
|
195
237
|
? done("current", "not_newer")
|
|
196
238
|
: done("retained", decision.reason, { exitCode: decision.reason === "credential_refused" || decision.reason === "no_credential" ? 1 : 0 });
|
|
197
239
|
const { release } = decision;
|
|
240
|
+
const tagged = { releaseId: release.releaseId };
|
|
241
|
+
// A newer release on the schedule waits for a window no run has settled. A machine that has
|
|
242
|
+
// settled none yet (its first run on this CLI) takes it now, and drift on the applied generation
|
|
243
|
+
// is put right at once, as before.
|
|
244
|
+
const { window } = options;
|
|
245
|
+
if (window && !options.check && decision.action === "update" && state.windowSettledMs !== null && window.lastOpenedMs <= state.windowSettledMs)
|
|
246
|
+
return done("waiting_window", `next_window:${new Date(window.nextOpensMs).toISOString()}`, tagged);
|
|
198
247
|
const target = Object.fromEntries(RELEASE_COMPONENTS.map((c) => [c, release.images[c].reference]));
|
|
199
248
|
const envText = readFileSync(envPath, "utf8");
|
|
200
249
|
const env = readEnvFile(envPath) ?? {};
|
|
@@ -204,12 +253,19 @@ async function update(engine, options, now) {
|
|
|
204
253
|
// (no log read). A match is current; any drift is moved back below like any other update.
|
|
205
254
|
if (decision.action === "verify" && changed.length === 0) {
|
|
206
255
|
const live = await engine.services(dir, signal);
|
|
207
|
-
if (live?.supervisor?.running && live.supervisor.image === target.supervisor && live.watchdog?.image === target.watchdog)
|
|
256
|
+
if (live?.supervisor?.running && live.supervisor.image === target.supervisor && live.watchdog?.image === target.watchdog) {
|
|
257
|
+
// A CLI before 0.16.31 applied this release without recording its journal format. Its
|
|
258
|
+
// supervisor is the one running, so record the format now: the next release can then go in
|
|
259
|
+
// place instead of waiting for one more applied generation.
|
|
260
|
+
if (!options.check && state.journal === null && release.journalFormat !== null)
|
|
261
|
+
writeReleaseState(dir, { ...state, journal: { format: release.journalFormat, supervisor: target.supervisor } });
|
|
208
262
|
return done("current", "not_newer");
|
|
263
|
+
}
|
|
209
264
|
}
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
|
|
265
|
+
const record = (outcome, detail, previous, unremoved = []) => {
|
|
266
|
+
const journal = release.journalFormat === null ? null : { format: release.journalFormat, supervisor: target.supervisor };
|
|
267
|
+
const pendingRemoval = [...new Set([...state.pendingRemoval, ...unremoved])].slice(-PENDING_REMOVAL_KEPT);
|
|
268
|
+
writeReleaseState(dir, { ...state, generation: release.generation, releaseId: release.releaseId, appliedAtMs: now(), previous, journal, pendingRemoval });
|
|
213
269
|
return { outcome, detail, generation: release.generation, exitCode: 0, ...tagged };
|
|
214
270
|
};
|
|
215
271
|
if (options.check)
|
|
@@ -229,12 +285,17 @@ async function update(engine, options, now) {
|
|
|
229
285
|
}
|
|
230
286
|
}
|
|
231
287
|
const rollbackImages = rollbackText === null ? null : imagesOf(parseEnvText(rollbackText));
|
|
288
|
+
// In place only when the supervisor a failure goes back to reads the release's journal format.
|
|
289
|
+
const inPlace = release.journalFormat !== null && state.journal !== null && rollbackImages !== null
|
|
290
|
+
&& state.journal.format === release.journalFormat && state.journal.supervisor === rollbackImages.supervisor;
|
|
232
291
|
const hostId = read.kind === "answered" ? read.hostId : "";
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
292
|
+
if (!inPlace) {
|
|
293
|
+
const busy = await engine.attemptContainers(dir, hostId, signal);
|
|
294
|
+
if (busy === null)
|
|
295
|
+
return done("retained", "docker_unavailable");
|
|
296
|
+
if (busy > 0)
|
|
297
|
+
return done("deferred_busy", `attempt_containers:${busy}`, tagged);
|
|
298
|
+
}
|
|
238
299
|
const unprepared = await engine.prepare(RELEASE_COMPONENTS.map((c) => target[c]), signal);
|
|
239
300
|
if (unprepared !== null)
|
|
240
301
|
return done("retained", unprepared, tagged);
|
|
@@ -257,7 +318,7 @@ async function update(engine, options, now) {
|
|
|
257
318
|
await restoreCustody(rollbackImages ?? current);
|
|
258
319
|
return done("retained", "supervisor_stop_failed", tagged);
|
|
259
320
|
}
|
|
260
|
-
const busyAfterStop = await engine.attemptContainers(dir, hostId, signal);
|
|
321
|
+
const busyAfterStop = inPlace ? 0 : await engine.attemptContainers(dir, hostId, signal);
|
|
261
322
|
if (busyAfterStop !== 0) {
|
|
262
323
|
await engine.composeUp(dir, signal);
|
|
263
324
|
await restoreCustody(rollbackImages ?? current);
|
|
@@ -273,9 +334,12 @@ async function update(engine, options, now) {
|
|
|
273
334
|
// Only once the new release is running and ready: the replaced images free their disk (a runner
|
|
274
335
|
// image is several GB, against a free-space floor that decides whether the machine takes work).
|
|
275
336
|
// A rollback or failure never reaches here, so the previous images stay for it.
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
337
|
+
// An image still in use, such as the runner image under phases left running, is kept for a later run.
|
|
338
|
+
const unremoved = changed.length > 0
|
|
339
|
+
? await engine.removeImages(changed.map((c) => current[c]).filter((ref) => ref !== ""), signal)
|
|
340
|
+
: [];
|
|
341
|
+
const applied = changed.length > 0 ? `updated:${changed.join(",")}` : "reconciled";
|
|
342
|
+
return record("updated", inPlace ? `${applied}:in_place` : applied, changed.length > 0 ? current : state.previous, unremoved);
|
|
279
343
|
}
|
|
280
344
|
if (rollbackText === null || rollbackImages === null)
|
|
281
345
|
return done("failed", "new_supervisor_not_ready_no_previous", { ...tagged, exitCode: 1 });
|
|
@@ -461,8 +525,17 @@ export function dockerRunnerUpdateEngine(deps = {}) {
|
|
|
461
525
|
return (await setup.composeUp(dir, signal)).ok;
|
|
462
526
|
},
|
|
463
527
|
async removeImages(images, signal) {
|
|
464
|
-
|
|
465
|
-
|
|
528
|
+
const kept = [];
|
|
529
|
+
for (const ref of images) {
|
|
530
|
+
if ((await docker(["image", "rm", ref], { signal, timeoutMs: 120_000 })).code === 0)
|
|
531
|
+
continue;
|
|
532
|
+
// Only Docker saying the image does not exist counts as removed. A refusal, or a daemon that
|
|
533
|
+
// did not answer, keeps it for the next run.
|
|
534
|
+
const inspect = await docker(["image", "inspect", "--format", "{{.Id}}", ref], { signal });
|
|
535
|
+
if (inspect.code === 0 || !/No such image/i.test(inspect.stderr ?? ""))
|
|
536
|
+
kept.push(ref);
|
|
537
|
+
}
|
|
538
|
+
return kept;
|
|
466
539
|
},
|
|
467
540
|
async supervisorReadySinceStart(dir, signal) {
|
|
468
541
|
const compose = runnerComposeArgs(dir);
|
|
@@ -494,6 +567,7 @@ const OUTCOME_TEXT = {
|
|
|
494
567
|
updated: "The runner moved to the approved release.",
|
|
495
568
|
planned: "Checked the approved release; nothing was changed.",
|
|
496
569
|
deferred_busy: "A phase is running here, so the update waits for the next check.",
|
|
570
|
+
waiting_window: "An approved release waits for this machine's next update window.",
|
|
497
571
|
retained: "Kept the current images.",
|
|
498
572
|
rolled_back: "The new supervisor did not become ready, so the previous images were restored.",
|
|
499
573
|
failed: "The update could not finish. Look at the runner with docker compose ps.",
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// update-cli.ts — the CLI moves itself to the approved version (CTC-6708). A release check never
|
|
2
|
+
// replaces the code under the process doing the update: it installs the approved CLI into its own
|
|
3
|
+
// directory under ~/.config/catalyst-cloud/cli-runtimes/cli-<version>, checks the package name,
|
|
4
|
+
// the version and that `--version` answers from it, and only then switches one small record
|
|
5
|
+
// (active.json) to it. The person's own `catalyst` command stays where it is: its launcher
|
|
6
|
+
// (bin/launch.js) reads that record and runs the selected version, so npm's bins, scheduled jobs and
|
|
7
|
+
// the saved launcher path never move.
|
|
8
|
+
//
|
|
9
|
+
// A failed install, a wrong package or a version that does not answer leaves the record as it was,
|
|
10
|
+
// so the CLI that was running stays the one that runs. Earlier versions' directories are kept: a
|
|
11
|
+
// replica still running on one keeps its code until it restarts.
|
|
12
|
+
//
|
|
13
|
+
// The launcher only ever moves forward. A record naming a version at or below the launcher's own
|
|
14
|
+
// (an owner who reinstalled a newer CLI by hand, say) is ignored, and so is any record whose
|
|
15
|
+
// directory is not this install's own runtime directory for that version.
|
|
16
|
+
import { spawnSync } from "node:child_process";
|
|
17
|
+
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
18
|
+
import { basename, join } from "node:path";
|
|
19
|
+
import { configDirFor, PACKAGE_NAME } from "./config.js";
|
|
20
|
+
// bin/launch.js imports this module before dist/cli.js, on runtimes too old to load the rest of the
|
|
21
|
+
// CLI, so it imports only config.js and node built-ins. These three mirror approved-release.ts's.
|
|
22
|
+
const isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
23
|
+
const exactVersion = (v) => (typeof v === "string" && /^(0|[1-9]\d{0,5})\.(0|[1-9]\d{0,5})\.(0|[1-9]\d{0,5})$/.test(v) ? v : null);
|
|
24
|
+
function compareVersions(a, b) {
|
|
25
|
+
const pa = a.split(".").map(Number);
|
|
26
|
+
const pb = b.split(".").map(Number);
|
|
27
|
+
for (let i = 0; i < 3; i++) {
|
|
28
|
+
const d = (pa[i] ?? 0) - (pb[i] ?? 0);
|
|
29
|
+
if (d !== 0)
|
|
30
|
+
return d;
|
|
31
|
+
}
|
|
32
|
+
return 0;
|
|
33
|
+
}
|
|
34
|
+
/** Set to a bin's own path, that bin's launcher runs itself and never selects (the staged check). */
|
|
35
|
+
export const ACTIVE_RUNTIME_ENV = "CATALYST_CLI_ACTIVE_RUNTIME";
|
|
36
|
+
export function cliRuntimesDirFor(home) {
|
|
37
|
+
return join(configDirFor(home), "cli-runtimes");
|
|
38
|
+
}
|
|
39
|
+
export function activeRuntimePathFor(home) {
|
|
40
|
+
return join(cliRuntimesDirFor(home), "active.json");
|
|
41
|
+
}
|
|
42
|
+
/** The one directory a given version may live in: a record naming any other path is refused. */
|
|
43
|
+
export function runtimeRootFor(home, version) {
|
|
44
|
+
return join(cliRuntimesDirFor(home), `cli-${version}`);
|
|
45
|
+
}
|
|
46
|
+
const packageDir = (root) => join(root, "node_modules", ...PACKAGE_NAME.split("/"));
|
|
47
|
+
export function readActiveRuntime(home) {
|
|
48
|
+
try {
|
|
49
|
+
const raw = JSON.parse(readFileSync(activeRuntimePathFor(home), "utf8"));
|
|
50
|
+
if (!isRecord(raw) || raw.version !== 1 || typeof raw.root !== "string" || typeof raw.activatedAtMs !== "number")
|
|
51
|
+
return null;
|
|
52
|
+
const cliVersion = exactVersion(raw.cliVersion);
|
|
53
|
+
if (cliVersion === null || raw.root !== runtimeRootFor(home, cliVersion))
|
|
54
|
+
return null;
|
|
55
|
+
const prev = raw.previous;
|
|
56
|
+
const previous = isRecord(prev) && typeof prev.root === "string" && exactVersion(prev.cliVersion) !== null
|
|
57
|
+
? { cliVersion: String(prev.cliVersion), root: prev.root }
|
|
58
|
+
: null;
|
|
59
|
+
return { version: 1, cliVersion, root: raw.root, activatedAtMs: raw.activatedAtMs, previous };
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/** The installed CLI's version in `root`, when its package is the CLI and its directory is private. */
|
|
66
|
+
function runtimeVersionAt(root) {
|
|
67
|
+
try {
|
|
68
|
+
// A directory others can write to could have its code swapped: never run from one.
|
|
69
|
+
if ((statSync(root).mode & 0o022) !== 0)
|
|
70
|
+
return null;
|
|
71
|
+
const manifest = JSON.parse(readFileSync(join(packageDir(root), "package.json"), "utf8"));
|
|
72
|
+
if (!isRecord(manifest) || manifest.name !== PACKAGE_NAME)
|
|
73
|
+
return null;
|
|
74
|
+
return exactVersion(manifest.version);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* The bin the launcher should run instead of its own code, or null to run its own: no record, a
|
|
82
|
+
* record outside this install's runtime directory, a version not newer than `ownVersion`, or a
|
|
83
|
+
* directory whose package does not match the record.
|
|
84
|
+
*/
|
|
85
|
+
export function activeRuntimeBin(home, ownVersion, binFile = "catalyst.js") {
|
|
86
|
+
const active = readActiveRuntime(home);
|
|
87
|
+
const own = exactVersion(ownVersion);
|
|
88
|
+
if (active === null || own === null || compareVersions(active.cliVersion, own) <= 0)
|
|
89
|
+
return null;
|
|
90
|
+
if (runtimeVersionAt(active.root) !== active.cliVersion)
|
|
91
|
+
return null;
|
|
92
|
+
// The same bin file the person ran (bin/launch.js passes its own name), so a deprecated name keeps its notice.
|
|
93
|
+
const bin = join(packageDir(active.root), "bin", basename(binFile));
|
|
94
|
+
return existsSync(bin) ? bin : null;
|
|
95
|
+
}
|
|
96
|
+
export const spawnCliCommand = (argv, cwd, env) => {
|
|
97
|
+
const [cmd, ...args] = argv;
|
|
98
|
+
if (cmd === undefined)
|
|
99
|
+
return { status: 1, stdout: "", stderr: "no command" };
|
|
100
|
+
const run = spawnSync(cmd, args, { cwd, env, stdio: ["ignore", "pipe", "pipe"], encoding: "utf8", timeout: 10 * 60_000 });
|
|
101
|
+
return { status: run.error ? 1 : run.status, stdout: run.stdout ?? "", stderr: run.error ? run.error.message : (run.stderr ?? "") };
|
|
102
|
+
};
|
|
103
|
+
/**
|
|
104
|
+
* Install exact `version` of the CLI into its own directory and check it, without selecting it:
|
|
105
|
+
* its package name and version, and that its `--version` answers with that version. An already
|
|
106
|
+
* checked directory is reused. The skills pack update reads its bundled skills from here too.
|
|
107
|
+
*/
|
|
108
|
+
export function stageCliRuntime(home, version, deps = {}) {
|
|
109
|
+
if (exactVersion(version) === null)
|
|
110
|
+
return { ok: false, detail: "target_invalid" };
|
|
111
|
+
const dir = cliRuntimesDirFor(home);
|
|
112
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
113
|
+
const root = runtimeRootFor(home, version);
|
|
114
|
+
if (runtimeVersionAt(root) === version)
|
|
115
|
+
return { ok: true, root };
|
|
116
|
+
// One staging at a time: another release check could otherwise remove the directory this one just
|
|
117
|
+
// put in place, while processes run from it.
|
|
118
|
+
const lock = join(dir, ".staging.lock");
|
|
119
|
+
if (!takeLock(lock))
|
|
120
|
+
return { ok: false, detail: "update_running" };
|
|
121
|
+
try {
|
|
122
|
+
return stageLocked(dir, root, version, deps);
|
|
123
|
+
}
|
|
124
|
+
finally {
|
|
125
|
+
try {
|
|
126
|
+
unlinkSync(lock);
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
// released already
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/** Longer than npm's own timeout, so a live staging's lock is never taken over. */
|
|
134
|
+
const STAGING_LOCK_STALE_MS = 15 * 60_000;
|
|
135
|
+
function takeLock(path) {
|
|
136
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
137
|
+
try {
|
|
138
|
+
closeSync(openSync(path, "wx", 0o600));
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
// A run killed mid-install leaves its lock behind; past the stale age it is taken over.
|
|
143
|
+
try {
|
|
144
|
+
if (Date.now() - statSync(path).mtimeMs <= STAGING_LOCK_STALE_MS)
|
|
145
|
+
return false;
|
|
146
|
+
unlinkSync(path);
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
// gone already: try again
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
function stageLocked(dir, root, version, deps) {
|
|
156
|
+
const exec = deps.exec ?? spawnCliCommand;
|
|
157
|
+
const env = deps.env ?? process.env;
|
|
158
|
+
// Another check may have finished it while this one waited for the lock.
|
|
159
|
+
if (runtimeVersionAt(root) === version)
|
|
160
|
+
return { ok: true, root };
|
|
161
|
+
const staging = join(dir, `.staging-cli-${version}-${process.pid}-${Date.now()}`);
|
|
162
|
+
const fail = (detail) => {
|
|
163
|
+
rmSync(staging, { recursive: true, force: true });
|
|
164
|
+
return { ok: false, detail };
|
|
165
|
+
};
|
|
166
|
+
mkdirSync(staging, { mode: 0o700 });
|
|
167
|
+
// An exact version of the one CLI package: never a tag or range, never a global install.
|
|
168
|
+
const install = exec(["npm", "install", "--prefix", staging, "--no-save", "--no-audit", "--no-fund", "--omit=dev", `${PACKAGE_NAME}@${version}`], staging, env);
|
|
169
|
+
if (install.status !== 0)
|
|
170
|
+
return fail("install_failed");
|
|
171
|
+
if (runtimeVersionAt(staging) !== version)
|
|
172
|
+
return fail("package_mismatch");
|
|
173
|
+
const stagedBin = join(packageDir(staging), "bin", "catalyst.js");
|
|
174
|
+
const smoke = exec([deps.nodePath ?? process.execPath, stagedBin, "--version"], staging, { ...env, [ACTIVE_RUNTIME_ENV]: stagedBin });
|
|
175
|
+
if (smoke.status !== 0 || !smoke.stdout.split(/\s+/).includes(version))
|
|
176
|
+
return fail("smoke_failed");
|
|
177
|
+
try {
|
|
178
|
+
rmSync(root, { recursive: true, force: true });
|
|
179
|
+
renameSync(staging, root);
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
return fail("activate_failed");
|
|
183
|
+
}
|
|
184
|
+
return { ok: true, root };
|
|
185
|
+
}
|
|
186
|
+
/** The skills a staged CLI package bundles. */
|
|
187
|
+
export const stagedSkillsDir = (root) => join(packageDir(root), "skills");
|
|
188
|
+
/** Install `target` of the CLI beside the running one, check it answers, then select it. */
|
|
189
|
+
export function applyCliUpdate(home, ownVersion, target, deps = {}) {
|
|
190
|
+
const version = exactVersion(target);
|
|
191
|
+
const before = readActiveRuntime(home);
|
|
192
|
+
const running = before && activeRuntimeBin(home, ownVersion) !== null ? before.cliVersion : ownVersion;
|
|
193
|
+
if (version === null)
|
|
194
|
+
return { outcome: "failed", detail: "target_invalid", version: running };
|
|
195
|
+
// Never a move down: a CLI already past the target keeps running.
|
|
196
|
+
if (compareVersions(running, version) >= 0)
|
|
197
|
+
return { outcome: "current", detail: compareVersions(running, version) === 0 ? "current" : "installed_newer", version: running };
|
|
198
|
+
const staged = stageCliRuntime(home, version, deps);
|
|
199
|
+
if (!staged.ok)
|
|
200
|
+
return { outcome: "failed", detail: staged.detail, version: running };
|
|
201
|
+
const root = staged.root;
|
|
202
|
+
const record = {
|
|
203
|
+
version: 1,
|
|
204
|
+
cliVersion: version,
|
|
205
|
+
root,
|
|
206
|
+
activatedAtMs: (deps.now ?? Date.now)(),
|
|
207
|
+
previous: before ? { cliVersion: before.cliVersion, root: before.root } : null,
|
|
208
|
+
};
|
|
209
|
+
const path = activeRuntimePathFor(home);
|
|
210
|
+
const priorBytes = existsSync(path) ? readFileSync(path) : null;
|
|
211
|
+
const write = (bytes) => {
|
|
212
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
213
|
+
try {
|
|
214
|
+
writeFileSync(tmp, bytes, { mode: 0o600 });
|
|
215
|
+
renameSync(tmp, path);
|
|
216
|
+
return true;
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
rmSync(tmp, { force: true });
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
if (!write(`${JSON.stringify(record, null, 2)}\n`))
|
|
224
|
+
return { outcome: "failed", detail: "activate_failed", version: running };
|
|
225
|
+
// Read back what the stable command will now run; put the earlier record back if it is not this one.
|
|
226
|
+
if (compareVersions(version, ownVersion) > 0 && activeRuntimeBin(home, ownVersion) === null) {
|
|
227
|
+
if (priorBytes === null)
|
|
228
|
+
rmSync(path, { force: true });
|
|
229
|
+
else
|
|
230
|
+
write(priorBytes);
|
|
231
|
+
return { outcome: "failed", detail: "activate_failed", version: running };
|
|
232
|
+
}
|
|
233
|
+
return { outcome: "updated", detail: "activated", version };
|
|
234
|
+
}
|