switchroom 0.17.1 → 0.17.2
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/dist/cli/switchroom.js +11 -5
- package/dist/host-control/main.js +371 -18
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +105 -279
package/dist/cli/switchroom.js
CHANGED
|
@@ -28894,6 +28894,12 @@ function resolveOperatorUid() {
|
|
|
28894
28894
|
if (uid > 0)
|
|
28895
28895
|
return uid;
|
|
28896
28896
|
}
|
|
28897
|
+
const hostdUid = process.env.SWITCHROOM_HOSTD_OPERATOR_UID;
|
|
28898
|
+
if (hostdUid !== undefined) {
|
|
28899
|
+
const parsed = parseInt(hostdUid, 10);
|
|
28900
|
+
if (Number.isFinite(parsed) && parsed > 0)
|
|
28901
|
+
return parsed;
|
|
28902
|
+
}
|
|
28897
28903
|
return;
|
|
28898
28904
|
}
|
|
28899
28905
|
function operatorOwnedPaths(home2) {
|
|
@@ -63150,7 +63156,7 @@ var init_server4 = __esm(() => {
|
|
|
63150
63156
|
},
|
|
63151
63157
|
{
|
|
63152
63158
|
name: "rollout",
|
|
63153
|
-
description: "SAFELY roll the fleet to a pinned SEMVER version, staggered and " + "canary-gated (#2487). Unlike update_apply (a blunt all-at-once " + "recreate), this restarts agents one at a time canary-first, asserts " + "each agent's in-container `switchroom --version` matches the target, " + "and STOPS on the first mismatch \u2014 so a bad build fails on the canary " + "before touching the rest of the fleet. The durable release.pin is " + "persisted only AFTER the canary confirms (a failed canary never " + "strands a bad pin). `pin` MUST be a tagged semver (vX.Y.Z) \u2014 sha " + "pins are rejected because the version assert needs a semver to " + "compare against.
|
|
63159
|
+
description: "SAFELY roll the fleet to a pinned SEMVER version, staggered and " + "canary-gated (#2487). Unlike update_apply (a blunt all-at-once " + "recreate), this restarts agents one at a time canary-first, asserts " + "each agent's in-container `switchroom --version` matches the target, " + "and STOPS on the first mismatch \u2014 so a bad build fails on the canary " + "before touching the rest of the fleet. The durable release.pin is " + "persisted only AFTER the canary confirms (a failed canary never " + "strands a bad pin). `pin` MUST be a tagged semver (vX.Y.Z) \u2014 sha " + "pins are rejected because the version assert needs a semver to " + "compare against. When the target pin is NEWER than hostd's own CLI " + "(every freshly tagged release), hostd SELF-BUMPS first (#2645): its " + "container restarts on the target image (~30-60s blip on this MCP " + "socket), then the roll resumes AUTOMATICALLY under the SAME " + "request_id \u2014 do NOT re-issue the rollout during the blip; poll " + "get_status with the returned request_id once the socket is back. " + "The web refresh stays DEFERRED on this path; the terminal warnings " + "name exactly what is still on the prior version (web, host operator " + "CLI) and the host-side commands to finish. By default downgrade pins " + "are rejected \u2014 pass `allow_downgrade: true` for the operator-approved " + "rollback path to a known-good earlier tag; all other safety rails " + "(canary order, version-assert, stop-on-mismatch) apply unchanged. " + "Admin-only at the wire layer AND deliberately NOT pre-approved \u2014 every " + "call surfaces a Telegram approval card for the operator to tap. " + "ALWAYS pass a one-line `reason` explaining why you're rolling the " + "fleet to this pin; it renders on the card's `why:` line so the " + "operator can decide in context. " + "Returns `started`; poll get_status for the structured outcome " + "(which agents rolled / where it stopped).",
|
|
63154
63160
|
inputSchema: {
|
|
63155
63161
|
type: "object",
|
|
63156
63162
|
required: ["pin"],
|
|
@@ -63729,8 +63735,8 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
63729
63735
|
import { dirname, join } from "node:path";
|
|
63730
63736
|
|
|
63731
63737
|
// src/build-info.ts
|
|
63732
|
-
var VERSION = "0.17.
|
|
63733
|
-
var COMMIT_SHA = "
|
|
63738
|
+
var VERSION = "0.17.2";
|
|
63739
|
+
var COMMIT_SHA = "f06074f4";
|
|
63734
63740
|
|
|
63735
63741
|
// src/cli/resolve-version.ts
|
|
63736
63742
|
function readPackageVersion() {
|
|
@@ -84226,7 +84232,7 @@ function registerRolloutCommand(program3) {
|
|
|
84226
84232
|
}
|
|
84227
84233
|
if (shouldRefuseStaleCli(SWITCHROOM_VERSION, target)) {
|
|
84228
84234
|
const cli = normalizeVersion(SWITCHROOM_VERSION);
|
|
84229
|
-
const refusal = `rollout refused: the driving switchroom CLI is v${cli}, OLDER than ` + `the --pin target ${target}. An older CLI regenerates the compose file ` + `with its own (stale) generator and version stamp, which would boot ` + `agents on v${cli}, fail the canary, and leave compose downgraded ` + `below what's running. ` + (hostdCtx ? `Refresh hostd's CLI
|
|
84235
|
+
const refusal = `rollout refused: the driving switchroom CLI is v${cli}, OLDER than ` + `the --pin target ${target}. An older CLI regenerates the compose file ` + `with its own (stale) generator and version stamp, which would boot ` + `agents on v${cli}, fail the canary, and leave compose downgraded ` + `below what's running. ` + (hostdCtx ? `Normally hostd SELF-BUMPS before this child ever spawns ` + `(#2645) \u2014 seeing this refusal means the self-bump was ` + `skipped or resumed while still stale. Refresh hostd's CLI ` + `host-side: \`switchroom hostd install --tag ${target}\` \u2014 ` + `then re-run the roll.` : `Upgrade this CLI to >= ${target} first (e.g. \`switchroom update ` + `--pin ${target}\`, or rebuild your checkout), then re-run.`) + ` Nothing was changed.`;
|
|
84230
84236
|
process.stderr.write(refusal + `
|
|
84231
84237
|
`);
|
|
84232
84238
|
if (hostdCtx) {
|
|
@@ -84320,7 +84326,7 @@ function registerRolloutCommand(program3) {
|
|
|
84320
84326
|
`);
|
|
84321
84327
|
const result = executeRollout(steps, target, deps, { hostdContext: hostdCtx });
|
|
84322
84328
|
if (hostdCtx) {
|
|
84323
|
-
result.warnings.push(
|
|
84329
|
+
result.warnings.push(`still on the PRIOR version after this roll: switchroom-web ` + `(host-side: \`switchroom webd install --tag ${target}\`) and the ` + `host operator CLI (\`sudo npm i -g switchroom@${normalizeVersion(target)}\`). ` + `hostd's compose was tag-bumped by the self-bump when one ran; a ` + `full hostd template regen (only needed when a release changes ` + `hostd's mounts/env) is \`switchroom hostd install --tag ${target}\` ` + `host-side. An agent-invoked rollout cannot recreate its own hostd ` + `container mid-roll without killing itself.`);
|
|
84324
84330
|
process.stdout.write(encodeRolloutPhaseLine({ phase: "hostd-web-deferred", target }) + `
|
|
84325
84331
|
`);
|
|
84326
84332
|
}
|
|
@@ -17257,7 +17257,9 @@ import {
|
|
|
17257
17257
|
ftruncateSync,
|
|
17258
17258
|
writeSync as writeSync2,
|
|
17259
17259
|
fsyncSync as fsyncSync2,
|
|
17260
|
-
closeSync as closeSync3
|
|
17260
|
+
closeSync as closeSync3,
|
|
17261
|
+
copyFileSync,
|
|
17262
|
+
unlinkSync
|
|
17261
17263
|
} from "node:fs";
|
|
17262
17264
|
import { join as join5, dirname as dirname6, resolve as resolve6 } from "node:path";
|
|
17263
17265
|
import { createHash as createHash5, randomUUID as randomUUID2, randomBytes } from "node:crypto";
|
|
@@ -22644,12 +22646,31 @@ var import_yaml3 = __toESM(require_dist(), 1);
|
|
|
22644
22646
|
// src/cli/rollout.ts
|
|
22645
22647
|
init_atomic();
|
|
22646
22648
|
|
|
22649
|
+
// src/config/release-resolve.ts
|
|
22650
|
+
function parseSemverTag(tag) {
|
|
22651
|
+
if (!tag)
|
|
22652
|
+
return null;
|
|
22653
|
+
const m = /^v(\d+)\.(\d+)\.(\d+)$/.exec(tag.trim());
|
|
22654
|
+
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
|
|
22655
|
+
}
|
|
22656
|
+
function compareReleaseTags(a, b) {
|
|
22657
|
+
const pa = parseSemverTag(a);
|
|
22658
|
+
const pb = parseSemverTag(b);
|
|
22659
|
+
if (!pa || !pb)
|
|
22660
|
+
return null;
|
|
22661
|
+
for (let i = 0;i < 3; i++) {
|
|
22662
|
+
if (pa[i] !== pb[i])
|
|
22663
|
+
return pa[i] < pb[i] ? -1 : 1;
|
|
22664
|
+
}
|
|
22665
|
+
return 0;
|
|
22666
|
+
}
|
|
22667
|
+
|
|
22647
22668
|
// src/cli/resolve-version.ts
|
|
22648
22669
|
import { existsSync as existsSync6, readFileSync as readFileSync4 } from "node:fs";
|
|
22649
22670
|
import { dirname as dirname4, join as join2 } from "node:path";
|
|
22650
22671
|
|
|
22651
22672
|
// src/build-info.ts
|
|
22652
|
-
var VERSION = "0.17.
|
|
22673
|
+
var VERSION = "0.17.2";
|
|
22653
22674
|
|
|
22654
22675
|
// src/cli/resolve-version.ts
|
|
22655
22676
|
function readPackageVersion() {
|
|
@@ -22813,7 +22834,9 @@ function parseRolloutPhaseLine(line) {
|
|
|
22813
22834
|
"agent-start",
|
|
22814
22835
|
"agent-done",
|
|
22815
22836
|
"persist-pin",
|
|
22816
|
-
"hostd-web-deferred"
|
|
22837
|
+
"hostd-web-deferred",
|
|
22838
|
+
"self-bump",
|
|
22839
|
+
"self-bump-done"
|
|
22817
22840
|
]);
|
|
22818
22841
|
if (typeof o.phase !== "string" || !PHASES.has(o.phase))
|
|
22819
22842
|
return null;
|
|
@@ -22848,6 +22871,117 @@ function parseRolloutResultLine(stdout) {
|
|
|
22848
22871
|
return null;
|
|
22849
22872
|
}
|
|
22850
22873
|
|
|
22874
|
+
// src/host-control/self-bump.ts
|
|
22875
|
+
var SELF_BUMP_MARKER_FILENAME = "pending-rollout.json";
|
|
22876
|
+
var SELF_BUMP_HELPER_CONTAINER = "switchroom-hostd-selfbump";
|
|
22877
|
+
var SELF_BUMP_LOG_FILENAME = "selfbump.log";
|
|
22878
|
+
var SELF_BUMP_MARKER_MAX_AGE_MS = 15 * 60 * 1000;
|
|
22879
|
+
var SEMVER_PIN_RE = /^v\d+\.\d+\.\d+$/;
|
|
22880
|
+
function needsSelfBump(ownVersion, pin) {
|
|
22881
|
+
if (!ownVersion)
|
|
22882
|
+
return false;
|
|
22883
|
+
const cmp = compareReleaseTags(`v${ownVersion.trim().replace(/^v/, "")}`, `v${pin.trim().replace(/^v/, "")}`);
|
|
22884
|
+
return cmp !== null && cmp < 0;
|
|
22885
|
+
}
|
|
22886
|
+
function bumpHostdComposeImageTag(yaml, pin) {
|
|
22887
|
+
const re = /^(\s*image:\s*)(\S*switchroom-hostd):(\S+)\s*$/m;
|
|
22888
|
+
const m = yaml.match(re);
|
|
22889
|
+
if (!m)
|
|
22890
|
+
return null;
|
|
22891
|
+
const oldImageRef = `${m[2]}:${m[3]}`;
|
|
22892
|
+
const newImageRef = `${m[2]}:${pin}`;
|
|
22893
|
+
return {
|
|
22894
|
+
yaml: yaml.replace(re, `$1${newImageRef}`),
|
|
22895
|
+
oldImageRef,
|
|
22896
|
+
newImageRef
|
|
22897
|
+
};
|
|
22898
|
+
}
|
|
22899
|
+
function encodePendingRolloutMarker(m) {
|
|
22900
|
+
return JSON.stringify(m, null, 2) + `
|
|
22901
|
+
`;
|
|
22902
|
+
}
|
|
22903
|
+
function parsePendingRolloutMarker(raw) {
|
|
22904
|
+
let obj;
|
|
22905
|
+
try {
|
|
22906
|
+
obj = JSON.parse(raw);
|
|
22907
|
+
} catch {
|
|
22908
|
+
return null;
|
|
22909
|
+
}
|
|
22910
|
+
if (typeof obj !== "object" || obj === null)
|
|
22911
|
+
return null;
|
|
22912
|
+
const o = obj;
|
|
22913
|
+
if (o.v !== 1)
|
|
22914
|
+
return null;
|
|
22915
|
+
if (typeof o.request_id !== "string" || o.request_id.length === 0)
|
|
22916
|
+
return null;
|
|
22917
|
+
if (typeof o.pin !== "string" || !SEMVER_PIN_RE.test(o.pin))
|
|
22918
|
+
return null;
|
|
22919
|
+
if (typeof o.created_at !== "string" || !Number.isFinite(Date.parse(o.created_at))) {
|
|
22920
|
+
return null;
|
|
22921
|
+
}
|
|
22922
|
+
if (typeof o.prior_hostd_version !== "string")
|
|
22923
|
+
return null;
|
|
22924
|
+
const caller = o.caller;
|
|
22925
|
+
if (typeof caller !== "object" || caller === null)
|
|
22926
|
+
return null;
|
|
22927
|
+
if (caller.kind === "agent") {
|
|
22928
|
+
if (typeof caller.name !== "string" || caller.name.length === 0)
|
|
22929
|
+
return null;
|
|
22930
|
+
} else if (caller.kind !== "operator") {
|
|
22931
|
+
return null;
|
|
22932
|
+
}
|
|
22933
|
+
if (o.agents !== undefined) {
|
|
22934
|
+
if (!Array.isArray(o.agents) || o.agents.length === 0 || !o.agents.every((a) => typeof a === "string" && /^[\w-]+$/.test(a))) {
|
|
22935
|
+
return null;
|
|
22936
|
+
}
|
|
22937
|
+
}
|
|
22938
|
+
if (o.skip_web !== undefined && typeof o.skip_web !== "boolean")
|
|
22939
|
+
return null;
|
|
22940
|
+
if (o.allow_downgrade !== undefined && typeof o.allow_downgrade !== "boolean") {
|
|
22941
|
+
return null;
|
|
22942
|
+
}
|
|
22943
|
+
return {
|
|
22944
|
+
v: 1,
|
|
22945
|
+
request_id: o.request_id,
|
|
22946
|
+
pin: o.pin,
|
|
22947
|
+
...o.agents !== undefined ? { agents: o.agents } : {},
|
|
22948
|
+
...o.skip_web !== undefined ? { skip_web: o.skip_web } : {},
|
|
22949
|
+
...o.allow_downgrade !== undefined ? { allow_downgrade: o.allow_downgrade } : {},
|
|
22950
|
+
caller: caller.kind === "agent" ? { kind: "agent", name: caller.name } : { kind: "operator" },
|
|
22951
|
+
created_at: o.created_at,
|
|
22952
|
+
prior_hostd_version: o.prior_hostd_version
|
|
22953
|
+
};
|
|
22954
|
+
}
|
|
22955
|
+
function isMarkerFresh(marker, nowMs) {
|
|
22956
|
+
const created = Date.parse(marker.created_at);
|
|
22957
|
+
if (!Number.isFinite(created))
|
|
22958
|
+
return false;
|
|
22959
|
+
return nowMs - created < SELF_BUMP_MARKER_MAX_AGE_MS;
|
|
22960
|
+
}
|
|
22961
|
+
function selfBumpHelperArgs(opts) {
|
|
22962
|
+
const composePath = `${opts.hostdDirHostPath}/docker-compose.yml`;
|
|
22963
|
+
const logPath = `${opts.hostdDirHostPath}/${SELF_BUMP_LOG_FILENAME}`;
|
|
22964
|
+
const script = `sleep 2; ` + `echo "selfbump $(date -u) -> ${opts.targetImage}" >> "${logPath}" 2>&1; ` + `docker compose -p switchroom-hostd -f "${composePath}" pull >> "${logPath}" 2>&1 && ` + `docker compose -p switchroom-hostd -f "${composePath}" up -d >> "${logPath}" 2>&1; ` + `rc=$?; echo "selfbump done rc=$rc" >> "${logPath}" 2>&1; exit $rc`;
|
|
22965
|
+
return [
|
|
22966
|
+
"run",
|
|
22967
|
+
"-d",
|
|
22968
|
+
"--rm",
|
|
22969
|
+
"--name",
|
|
22970
|
+
SELF_BUMP_HELPER_CONTAINER,
|
|
22971
|
+
"--label",
|
|
22972
|
+
"switchroom.hostd-selfbump=true",
|
|
22973
|
+
"-v",
|
|
22974
|
+
"/var/run/docker.sock:/var/run/docker.sock",
|
|
22975
|
+
"-v",
|
|
22976
|
+
`${opts.hostdDirHostPath}:${opts.hostdDirHostPath}`,
|
|
22977
|
+
"--entrypoint",
|
|
22978
|
+
"/bin/sh",
|
|
22979
|
+
opts.helperImage,
|
|
22980
|
+
"-c",
|
|
22981
|
+
script
|
|
22982
|
+
];
|
|
22983
|
+
}
|
|
22984
|
+
|
|
22851
22985
|
// node_modules/.bun/commander@13.1.0/node_modules/commander/esm.mjs
|
|
22852
22986
|
var import__3 = __toESM(require_commander(), 1);
|
|
22853
22987
|
|
|
@@ -23503,6 +23637,10 @@ function phaseLine(s) {
|
|
|
23503
23637
|
return "persisting pin";
|
|
23504
23638
|
case "hostd-web-deferred":
|
|
23505
23639
|
return "hostd/web refresh deferred (run host-side)";
|
|
23640
|
+
case "self-bump":
|
|
23641
|
+
return "hostd refreshing itself to the target (brief control blip)…";
|
|
23642
|
+
case "self-bump-done":
|
|
23643
|
+
return "hostd refreshed — resuming the roll";
|
|
23506
23644
|
default:
|
|
23507
23645
|
return "starting";
|
|
23508
23646
|
}
|
|
@@ -23686,6 +23824,10 @@ class HostdServer {
|
|
|
23686
23824
|
await chmod(hostdDir, 493).catch(() => {
|
|
23687
23825
|
return;
|
|
23688
23826
|
});
|
|
23827
|
+
await this.resumePendingSelfBumpRollout().catch((e) => {
|
|
23828
|
+
process.stderr.write(`hostd: self-bump resume failed (non-fatal): ${e.message}
|
|
23829
|
+
`);
|
|
23830
|
+
});
|
|
23689
23831
|
const agentNames = Object.keys(this.opts.agentUids).sort();
|
|
23690
23832
|
if (agentNames.length === 0) {
|
|
23691
23833
|
return;
|
|
@@ -23936,7 +24078,7 @@ class HostdServer {
|
|
|
23936
24078
|
resp = this.handleApply(req, caller, started);
|
|
23937
24079
|
break;
|
|
23938
24080
|
case "rollout":
|
|
23939
|
-
resp = this.handleRollout(req, caller, started);
|
|
24081
|
+
resp = await this.handleRollout(req, caller, started);
|
|
23940
24082
|
break;
|
|
23941
24083
|
case "agent_start":
|
|
23942
24084
|
resp = await this.handleAgentStart(req, started);
|
|
@@ -24208,21 +24350,34 @@ class HostdServer {
|
|
|
24208
24350
|
duration_ms: Date.now() - started
|
|
24209
24351
|
};
|
|
24210
24352
|
}
|
|
24211
|
-
handleRollout(req, caller, started) {
|
|
24353
|
+
async handleRollout(req, caller, started) {
|
|
24212
24354
|
const denied = this.checkFleetMutationLock(req.op, req.request_id, started);
|
|
24213
24355
|
if (denied)
|
|
24214
24356
|
return denied;
|
|
24357
|
+
if (needsSelfBump(this.opts.selfVersion ?? SWITCHROOM_VERSION, req.args.pin)) {
|
|
24358
|
+
return this.beginSelfBump(req, caller, started);
|
|
24359
|
+
}
|
|
24215
24360
|
const assetDenied = this.applyAssetPreflight(req.request_id, started);
|
|
24216
24361
|
if (assetDenied)
|
|
24217
24362
|
return assetDenied;
|
|
24218
|
-
const
|
|
24219
|
-
|
|
24220
|
-
|
|
24221
|
-
|
|
24222
|
-
|
|
24223
|
-
|
|
24224
|
-
|
|
24225
|
-
|
|
24363
|
+
const entry = this.launchRollout(req.args, req.request_id, caller, started);
|
|
24364
|
+
return {
|
|
24365
|
+
v: 1,
|
|
24366
|
+
request_id: entry.request_id,
|
|
24367
|
+
result: "started",
|
|
24368
|
+
exit_code: null,
|
|
24369
|
+
duration_ms: Date.now() - started
|
|
24370
|
+
};
|
|
24371
|
+
}
|
|
24372
|
+
launchRollout(args, request_id, caller, started) {
|
|
24373
|
+
const argv = ["rollout", "--pin", args.pin];
|
|
24374
|
+
if (args.agents && args.agents.length > 0) {
|
|
24375
|
+
argv.push("--agents", args.agents.join(","));
|
|
24376
|
+
}
|
|
24377
|
+
if (args.skip_web)
|
|
24378
|
+
argv.push("--skip-web");
|
|
24379
|
+
if (args.allow_downgrade)
|
|
24380
|
+
argv.push("--allow-downgrade");
|
|
24226
24381
|
const installCtx = readCachedInstallType(this.opts.bindRoot ?? this.opts.homeDir);
|
|
24227
24382
|
let priorPin;
|
|
24228
24383
|
try {
|
|
@@ -24233,16 +24388,16 @@ class HostdServer {
|
|
|
24233
24388
|
}
|
|
24234
24389
|
} catch {}
|
|
24235
24390
|
const entry = {
|
|
24236
|
-
request_id
|
|
24391
|
+
request_id,
|
|
24237
24392
|
caller,
|
|
24238
|
-
op:
|
|
24393
|
+
op: "rollout",
|
|
24239
24394
|
result: "started",
|
|
24240
24395
|
exit_code: null,
|
|
24241
24396
|
started_at: started,
|
|
24242
24397
|
finished_at: null,
|
|
24243
24398
|
stdout_tail: "",
|
|
24244
24399
|
stderr_tail: "",
|
|
24245
|
-
pin:
|
|
24400
|
+
pin: args.pin,
|
|
24246
24401
|
install_context: {
|
|
24247
24402
|
install_type: installCtx.install_type,
|
|
24248
24403
|
detected_at: installCtx.detected_at
|
|
@@ -24250,19 +24405,214 @@ class HostdServer {
|
|
|
24250
24405
|
...priorPin ? { prior_pin: priorPin } : {}
|
|
24251
24406
|
};
|
|
24252
24407
|
this.recordStatus(entry);
|
|
24408
|
+
this.fleetMutationInFlight = {
|
|
24409
|
+
op: "rollout",
|
|
24410
|
+
request_id,
|
|
24411
|
+
started_at: started
|
|
24412
|
+
};
|
|
24413
|
+
this.spawnRollout(argv, entry);
|
|
24414
|
+
return entry;
|
|
24415
|
+
}
|
|
24416
|
+
hostdDirPath() {
|
|
24417
|
+
return join5(this.opts.homeDir, ".switchroom", "hostd");
|
|
24418
|
+
}
|
|
24419
|
+
hostdDirHostPath() {
|
|
24420
|
+
const hostHome = this.opts.hostHomeDir ?? process.env.SWITCHROOM_HOST_HOME?.trim() ?? this.opts.homeDir;
|
|
24421
|
+
return join5(hostHome, ".switchroom", "hostd");
|
|
24422
|
+
}
|
|
24423
|
+
async beginSelfBump(req, caller, started) {
|
|
24424
|
+
const ownVersion = this.opts.selfVersion ?? SWITCHROOM_VERSION;
|
|
24425
|
+
const composePath = join5(this.hostdDirPath(), "docker-compose.yml");
|
|
24426
|
+
if (!existsSync8(composePath)) {
|
|
24427
|
+
return deniedResponse(req.request_id, `rollout: hostd's CLI (v${ownVersion}) is older than the target ` + `${req.args.pin} and needs a self-bump first, but its compose file ` + `is missing at ${composePath}. Run \`switchroom hostd install ` + `--tag ${req.args.pin}\` on the host, then re-run the roll. ` + `Nothing was changed.`, Date.now() - started);
|
|
24428
|
+
}
|
|
24429
|
+
const before = readFileSync6(composePath, "utf8");
|
|
24430
|
+
const bumped = bumpHostdComposeImageTag(before, req.args.pin);
|
|
24431
|
+
if (!bumped) {
|
|
24432
|
+
return deniedResponse(req.request_id, `rollout: hostd needs a self-bump to ${req.args.pin} but no ` + `switchroom-hostd image line was found in ${composePath} ` + `(hand-edited compose?). Run \`switchroom hostd install --tag ` + `${req.args.pin}\` on the host, then re-run the roll. Nothing ` + `was changed.`, Date.now() - started);
|
|
24433
|
+
}
|
|
24434
|
+
const probe = await this.runDocker([
|
|
24435
|
+
"manifest",
|
|
24436
|
+
"inspect",
|
|
24437
|
+
bumped.newImageRef
|
|
24438
|
+
]);
|
|
24439
|
+
if (probe.exit_code !== 0) {
|
|
24440
|
+
return deniedResponse(req.request_id, `rollout: hostd needs a self-bump to ${req.args.pin} but the target ` + `image ${bumped.newImageRef} is not pullable yet (docker manifest ` + `inspect failed: ${tail(probe.stderr).trim() || "unknown error"}). ` + `The docker-images workflow may still be building — retry in a ` + `few minutes. Nothing was changed.`, Date.now() - started);
|
|
24441
|
+
}
|
|
24442
|
+
await this.runDocker(["rm", "-f", SELF_BUMP_HELPER_CONTAINER]).catch(() => {
|
|
24443
|
+
return;
|
|
24444
|
+
});
|
|
24445
|
+
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
|
24446
|
+
const bakPath = `${composePath}.bak-selfbump-${ts}`;
|
|
24447
|
+
try {
|
|
24448
|
+
const baks = readdirSync2(this.hostdDirPath()).filter((f) => f.startsWith("docker-compose.yml.bak-selfbump-")).sort();
|
|
24449
|
+
for (const f of baks.slice(0, Math.max(0, baks.length - 4))) {
|
|
24450
|
+
unlinkSync(join5(this.hostdDirPath(), f));
|
|
24451
|
+
}
|
|
24452
|
+
} catch {}
|
|
24453
|
+
const markerPath = join5(this.hostdDirPath(), SELF_BUMP_MARKER_FILENAME);
|
|
24454
|
+
const marker = {
|
|
24455
|
+
v: 1,
|
|
24456
|
+
request_id: req.request_id,
|
|
24457
|
+
pin: req.args.pin,
|
|
24458
|
+
...req.args.agents && req.args.agents.length > 0 ? { agents: req.args.agents } : {},
|
|
24459
|
+
...req.args.skip_web ? { skip_web: true } : {},
|
|
24460
|
+
...req.args.allow_downgrade ? { allow_downgrade: true } : {},
|
|
24461
|
+
caller: caller.kind === "agent" ? { kind: "agent", name: caller.name } : { kind: "operator" },
|
|
24462
|
+
created_at: new Date().toISOString(),
|
|
24463
|
+
prior_hostd_version: ownVersion
|
|
24464
|
+
};
|
|
24465
|
+
copyFileSync(composePath, bakPath);
|
|
24466
|
+
writeFileSync3(composePath, bumped.yaml, "utf8");
|
|
24467
|
+
writeFileSync3(markerPath, encodePendingRolloutMarker(marker), {
|
|
24468
|
+
mode: 384
|
|
24469
|
+
});
|
|
24470
|
+
const entry = {
|
|
24471
|
+
request_id: req.request_id,
|
|
24472
|
+
caller,
|
|
24473
|
+
op: req.op,
|
|
24474
|
+
result: "started",
|
|
24475
|
+
exit_code: null,
|
|
24476
|
+
started_at: started,
|
|
24477
|
+
finished_at: null,
|
|
24478
|
+
stdout_tail: "",
|
|
24479
|
+
stderr_tail: "",
|
|
24480
|
+
pin: req.args.pin
|
|
24481
|
+
};
|
|
24482
|
+
this.recordStatus(entry);
|
|
24253
24483
|
this.fleetMutationInFlight = {
|
|
24254
24484
|
op: "rollout",
|
|
24255
24485
|
request_id: req.request_id,
|
|
24256
24486
|
started_at: started
|
|
24257
24487
|
};
|
|
24258
|
-
this.
|
|
24488
|
+
this.onRolloutPhase(entry, { phase: "self-bump", target: req.args.pin });
|
|
24489
|
+
const rollbackBump = () => {
|
|
24490
|
+
try {
|
|
24491
|
+
copyFileSync(bakPath, composePath);
|
|
24492
|
+
unlinkSync(markerPath);
|
|
24493
|
+
} catch (e) {
|
|
24494
|
+
process.stderr.write(`hostd: self-bump rollback failed (compose/marker may need manual ` + `restore from ${bakPath}): ${e.message}
|
|
24495
|
+
`);
|
|
24496
|
+
}
|
|
24497
|
+
};
|
|
24498
|
+
const helper = await this.runDocker(selfBumpHelperArgs({
|
|
24499
|
+
hostdDirHostPath: this.hostdDirHostPath(),
|
|
24500
|
+
helperImage: bumped.oldImageRef,
|
|
24501
|
+
targetImage: bumped.newImageRef
|
|
24502
|
+
})).catch((e) => ({
|
|
24503
|
+
exit_code: -1,
|
|
24504
|
+
stdout: "",
|
|
24505
|
+
stderr: e.message
|
|
24506
|
+
}));
|
|
24507
|
+
if (helper.exit_code !== 0) {
|
|
24508
|
+
rollbackBump();
|
|
24509
|
+
entry.result = "error";
|
|
24510
|
+
entry.finished_at = Date.now();
|
|
24511
|
+
entry.error = `self-bump helper spawn failed: ${tail(helper.stderr).trim()}`;
|
|
24512
|
+
this.fleetMutationInFlight = null;
|
|
24513
|
+
this.writeTerminalAudit(entry);
|
|
24514
|
+
return {
|
|
24515
|
+
v: 1,
|
|
24516
|
+
request_id: req.request_id,
|
|
24517
|
+
result: "error",
|
|
24518
|
+
exit_code: null,
|
|
24519
|
+
duration_ms: Date.now() - started,
|
|
24520
|
+
error: `rollout: could not spawn the hostd self-bump helper container ` + `(${tail(helper.stderr).trim() || "unknown error"}). The compose ` + `bump was rolled back — nothing is changed. Fallback: ` + `\`switchroom hostd install --tag ${req.args.pin}\` on the host, ` + `then re-run the roll.`
|
|
24521
|
+
};
|
|
24522
|
+
}
|
|
24523
|
+
this.runDocker(["wait", SELF_BUMP_HELPER_CONTAINER]).then((w) => {
|
|
24524
|
+
const code = Number.parseInt(w.stdout.trim(), 10);
|
|
24525
|
+
if (w.exit_code !== 0 || !Number.isFinite(code)) {
|
|
24526
|
+
process.stderr.write(`hostd: self-bump watcher could not read the helper's exit ` + `code (docker wait rc=${w.exit_code}): ${tail(w.stderr).trim()}
|
|
24527
|
+
`);
|
|
24528
|
+
return;
|
|
24529
|
+
}
|
|
24530
|
+
if (code === 0)
|
|
24531
|
+
return;
|
|
24532
|
+
rollbackBump();
|
|
24533
|
+
entry.result = "error";
|
|
24534
|
+
entry.finished_at = Date.now();
|
|
24535
|
+
entry.error = `self-bump helper failed (container exit ${Number.isFinite(code) ? code : "unknown"}). ` + `See ${join5(this.hostdDirHostPath(), SELF_BUMP_LOG_FILENAME)} on the host. ` + `Compose bump rolled back. Fallback: \`switchroom hostd install ` + `--tag ${req.args.pin}\` host-side, then re-run the roll.`;
|
|
24536
|
+
if (this.fleetMutationInFlight && this.fleetMutationInFlight.request_id === entry.request_id) {
|
|
24537
|
+
this.fleetMutationInFlight = null;
|
|
24538
|
+
}
|
|
24539
|
+
this.writeTerminalAudit(entry);
|
|
24540
|
+
this.pushRolloutTerminal(entry);
|
|
24541
|
+
}).catch(() => {
|
|
24542
|
+
return;
|
|
24543
|
+
});
|
|
24259
24544
|
return {
|
|
24260
24545
|
v: 1,
|
|
24261
24546
|
request_id: req.request_id,
|
|
24262
24547
|
result: "started",
|
|
24263
24548
|
exit_code: null,
|
|
24264
|
-
duration_ms: Date.now() - started
|
|
24549
|
+
duration_ms: Date.now() - started,
|
|
24550
|
+
payload: JSON.stringify({
|
|
24551
|
+
phase: "self-bump",
|
|
24552
|
+
pin: req.args.pin,
|
|
24553
|
+
rolled: [],
|
|
24554
|
+
note: `hostd's CLI (v${ownVersion}) is older than ${req.args.pin}, so ` + `hostd is refreshing itself first: its container restarts on the ` + `target image (~30-60s blip on this socket), then the roll resumes ` + `AUTOMATICALLY under this same request_id. Poll get_status with ` + `this request_id after the blip; do NOT re-issue the rollout.`
|
|
24555
|
+
})
|
|
24556
|
+
};
|
|
24557
|
+
}
|
|
24558
|
+
async resumePendingSelfBumpRollout() {
|
|
24559
|
+
const markerPath = join5(this.hostdDirPath(), SELF_BUMP_MARKER_FILENAME);
|
|
24560
|
+
if (!existsSync8(markerPath))
|
|
24561
|
+
return;
|
|
24562
|
+
let raw;
|
|
24563
|
+
try {
|
|
24564
|
+
raw = readFileSync6(markerPath, "utf8");
|
|
24565
|
+
} finally {
|
|
24566
|
+
try {
|
|
24567
|
+
unlinkSync(markerPath);
|
|
24568
|
+
} catch {}
|
|
24569
|
+
}
|
|
24570
|
+
const marker = parsePendingRolloutMarker(raw);
|
|
24571
|
+
if (!marker) {
|
|
24572
|
+
process.stderr.write(`hostd: pending-rollout marker at ${markerPath} was malformed — ` + `dropped without resuming. The originating roll will surface via ` + `the orphan sweep.
|
|
24573
|
+
`);
|
|
24574
|
+
return;
|
|
24575
|
+
}
|
|
24576
|
+
const caller = marker.caller.kind === "agent" ? { kind: "agent", name: marker.caller.name } : { kind: "operator" };
|
|
24577
|
+
const failResume = async (why) => {
|
|
24578
|
+
const entry2 = {
|
|
24579
|
+
request_id: marker.request_id,
|
|
24580
|
+
caller,
|
|
24581
|
+
op: "rollout",
|
|
24582
|
+
result: "error",
|
|
24583
|
+
exit_code: null,
|
|
24584
|
+
started_at: Date.parse(marker.created_at) || Date.now(),
|
|
24585
|
+
finished_at: Date.now(),
|
|
24586
|
+
stdout_tail: "",
|
|
24587
|
+
stderr_tail: "",
|
|
24588
|
+
pin: marker.pin,
|
|
24589
|
+
failed_step: "self-bump",
|
|
24590
|
+
error: why
|
|
24591
|
+
};
|
|
24592
|
+
this.recordStatus(entry2);
|
|
24593
|
+
process.stderr.write(`hostd: self-bump resume failed: ${why}
|
|
24594
|
+
`);
|
|
24595
|
+
await this.writeTerminalAudit(entry2);
|
|
24596
|
+
this.pushRolloutTerminal(entry2);
|
|
24265
24597
|
};
|
|
24598
|
+
const ownVersion = this.opts.selfVersion ?? SWITCHROOM_VERSION;
|
|
24599
|
+
if (!isMarkerFresh(marker, Date.now())) {
|
|
24600
|
+
await failResume(`pending-rollout marker for ${marker.pin} (request_id ` + `${marker.request_id}) was older than the resume cutoff — the ` + `self-bump helper likely stalled. hostd is now on v${ownVersion}. ` + `Re-issue the rollout to try again.`);
|
|
24601
|
+
return;
|
|
24602
|
+
}
|
|
24603
|
+
if (needsSelfBump(ownVersion, marker.pin)) {
|
|
24604
|
+
await failResume(`hostd is still on v${ownVersion} after the self-bump to ` + `${marker.pin} (helper failed? see ` + `${join5(this.hostdDirHostPath(), SELF_BUMP_LOG_FILENAME)}). ` + `Fallback: \`switchroom hostd install --tag ${marker.pin}\` ` + `host-side, then re-issue the rollout.`);
|
|
24605
|
+
return;
|
|
24606
|
+
}
|
|
24607
|
+
process.stderr.write(`hostd: resuming rollout ${marker.request_id} → ${marker.pin} after ` + `self-bump (v${marker.prior_hostd_version} → v${ownVersion})
|
|
24608
|
+
`);
|
|
24609
|
+
const entry = this.launchRollout({
|
|
24610
|
+
pin: marker.pin,
|
|
24611
|
+
...marker.agents ? { agents: marker.agents } : {},
|
|
24612
|
+
...marker.skip_web ? { skip_web: true } : {},
|
|
24613
|
+
...marker.allow_downgrade ? { allow_downgrade: true } : {}
|
|
24614
|
+
}, marker.request_id, caller, Date.now());
|
|
24615
|
+
this.onRolloutPhase(entry, { phase: "self-bump-done", target: marker.pin });
|
|
24266
24616
|
}
|
|
24267
24617
|
async handleAgentStart(req, started) {
|
|
24268
24618
|
const res = await this.runSwitchroom(["agent", "start", req.args.name]);
|
|
@@ -25519,6 +25869,9 @@ class LogTailRolloutNarrator {
|
|
|
25519
25869
|
}
|
|
25520
25870
|
static seqFor(phase) {
|
|
25521
25871
|
switch (phase.phase) {
|
|
25872
|
+
case "self-bump":
|
|
25873
|
+
case "self-bump-done":
|
|
25874
|
+
return 0;
|
|
25522
25875
|
case "apply":
|
|
25523
25876
|
return 0;
|
|
25524
25877
|
case "canary-start":
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "switchroom",
|
|
3
3
|
"//version": "NOT the release version — source of truth is the git tag, resolved by scripts/build.mjs:resolveVersion() (see CLAUDE.md > Standard release process). This field is stale by design and only the Layer-4 dev/non-tag fallback for build.mjs + src/cli/resolve-version.ts; do NOT bump it expecting a release to pick it up. npm-pack tarball naming needs a real version — do that as an UNCOMMITTED pack-time bump (see release step 6), never a committed one.",
|
|
4
|
-
"version": "0.17.
|
|
4
|
+
"version": "0.17.2",
|
|
5
5
|
"description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|