switchroom 0.19.17 → 0.19.18
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/bin/run-hook.sh +148 -0
- package/bin/workspace-dynamic-hook.sh +147 -38
- package/dist/agent-scheduler/index.js +11 -3
- package/dist/auth-broker/index.js +29 -4
- package/dist/cli/notion-write-pretool.mjs +11 -3
- package/dist/cli/switchroom.js +8306 -7619
- package/dist/host-control/main.js +626 -36
- package/dist/vault/approvals/kernel-server.js +30 -5
- package/dist/vault/broker/server.js +71 -18
- package/package.json +3 -2
- package/profiles/_base/start.sh.hbs +8 -4
- package/profiles/coding/CLAUDE.md.hbs +1 -1
- package/profiles/default/CLAUDE.md.hbs +3 -3
- package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
- package/profiles/health-coach/CLAUDE.md.hbs +1 -1
- package/skills/mental-model-curator/SKILL.md +8 -6
- package/telegram-plugin/bridge/bridge.ts +11 -19
- package/telegram-plugin/bridge/mcp-instructions.ts +87 -0
- package/telegram-plugin/dist/bridge/bridge.js +15 -20
- package/telegram-plugin/dist/gateway/gateway.js +763 -373
- package/telegram-plugin/dist/server.js +19 -20
- package/telegram-plugin/gateway/boot-card.ts +5 -1
- package/telegram-plugin/gateway/boot-probes.ts +113 -0
- package/telegram-plugin/gateway/config-approval-handler.test.ts +54 -0
- package/telegram-plugin/gateway/config-approval-handler.ts +16 -1
- package/telegram-plugin/gateway/disconnect-flush.ts +17 -0
- package/telegram-plugin/gateway/gateway.ts +43 -1
- package/telegram-plugin/gateway/handback-preturn-signal.ts +61 -7
- package/telegram-plugin/gateway/ipc-protocol.ts +5 -0
- package/telegram-plugin/gateway/ipc-server.ts +13 -0
- package/telegram-plugin/gateway/liveness-wiring.ts +125 -5
- package/telegram-plugin/gateway/obligation-ledger.ts +84 -4
- package/telegram-plugin/gateway/resume-inbound-builder.ts +13 -4
- package/telegram-plugin/gateway/stream-render.ts +24 -5
- package/telegram-plugin/hooks/secret-guard-pretool.mjs +249 -76
- package/telegram-plugin/registry/turns-schema.test.ts +8 -3
- package/telegram-plugin/registry/turns-schema.ts +40 -12
- package/telegram-plugin/runtime-metrics.ts +14 -0
- package/telegram-plugin/silence-poke.ts +138 -0
- package/telegram-plugin/tests/boot-probe-drift.test.ts +152 -0
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +32 -0
- package/telegram-plugin/tests/handback-preturn-signal.test.ts +62 -0
- package/telegram-plugin/tests/helpers/liveness-wiring-fixture.ts +178 -0
- package/telegram-plugin/tests/ipc-server-validate-config-approval.test.ts +95 -0
- package/telegram-plugin/tests/mcp-instructions-budget.test.ts +184 -0
- package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +22 -2
- package/telegram-plugin/tests/obligation-determinism.test.ts +114 -3
- package/telegram-plugin/tests/obligation-ledger.test.ts +310 -0
- package/telegram-plugin/tests/registry-turns.test.ts +13 -0
- package/telegram-plugin/tests/resume-inbound-builder.test.ts +15 -0
- package/telegram-plugin/tests/secret-guard-pretool.test.ts +347 -16
- package/telegram-plugin/tests/silence-poke-orphan-reap.test.ts +392 -0
- package/telegram-plugin/tests/silence-poke-teardown-notice.test.ts +301 -0
- package/telegram-plugin/tests/stream-render-golden.test.ts +103 -1
- package/telegram-plugin/tests/tts-normalize.test.ts +43 -0
- package/telegram-plugin/tests/voice-normalize-text.test.ts +212 -3
- package/telegram-plugin/tts-normalize.ts +6 -4
- package/telegram-plugin/voice-normalize-text.ts +168 -11
- package/vendor/hindsight-memory/CHANGELOG.md +73 -0
- package/vendor/hindsight-memory/scripts/lib/config.py +8 -3
- package/vendor/hindsight-memory/scripts/lib/directives.py +62 -4
- package/vendor/hindsight-memory/scripts/recall.py +257 -12
- package/vendor/hindsight-memory/scripts/retain.py +12 -6
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +80 -9
- package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +362 -18
- package/vendor/hindsight-memory/settings.json +1 -1
|
@@ -7598,8 +7598,8 @@ var require_base = __commonJS((exports) => {
|
|
|
7598
7598
|
var _logger = require_logger();
|
|
7599
7599
|
var _logger2 = _interopRequireDefault(_logger);
|
|
7600
7600
|
var _internalProtoAccess = require_proto_access();
|
|
7601
|
-
var
|
|
7602
|
-
exports.VERSION =
|
|
7601
|
+
var VERSION2 = "4.7.9";
|
|
7602
|
+
exports.VERSION = VERSION2;
|
|
7603
7603
|
var COMPILER_REVISION = 8;
|
|
7604
7604
|
exports.COMPILER_REVISION = COMPILER_REVISION;
|
|
7605
7605
|
var LAST_COMPATIBLE_COMPILER_REVISION = 7;
|
|
@@ -18744,7 +18744,7 @@ var AgentMemorySchema = exports_external.object({
|
|
|
18744
18744
|
recall: exports_external.object({
|
|
18745
18745
|
max_memories: exports_external.number().int().min(0).optional().describe("Cap on the number of memories injected into the prompt by " + "auto-recall, regardless of token budget. Plugin default is 12. " + "0 disables the cap (all memories Hindsight returns are injected)."),
|
|
18746
18746
|
cache_ttl_secs: exports_external.number().int().min(0).optional().describe("Per-session recall cache TTL in seconds. When > 0, identical " + "(prompt, bank) within the same session reuse the cached recall " + "result instead of round-tripping to Hindsight. 0 disables. " + "Default is 600 (10 min) for switchroom-managed agents."),
|
|
18747
|
-
min_overlap: exports_external.number().min(0).max(1).optional().describe("Minimum
|
|
18747
|
+
min_overlap: exports_external.number().min(0).max(1).optional().describe("Minimum containment token overlap [0.0–1.0] between the user " + "prompt and a memory's text for the memory to be injected. " + "A cheap FLOOR that removes candidates with (near-)zero lexical " + "relationship to the prompt — NOT a precision control: at the " + "recommended 0.10 the gate is near-passthrough, and the " + "effective precision control is the engine rerank plus the " + "max_memories head-slice. 0.0 disables (default — current " + "behaviour). Use 0.10; values at or above 0.20 measurably " + "starve recall — on production replay 0.20 leaves ~41.9% of " + "turns with NO memories at all, re-creating the bug #3541 " + "fixed. Observe the `overlap_dropped` field via " + "`switchroom memory recall-log`."),
|
|
18748
18748
|
types: exports_external.array(exports_external.string()).optional().describe("Hindsight fact types to recall. Switchroom default is " + '["world", "experience", "observation"] — the synthesized ' + "`observation` tier is on by default. Set to " + '["world", "experience"] to opt out of observation-backed ' + "recall for this agent (or fleet-wide under defaults)."),
|
|
18749
18749
|
additional_banks: exports_external.array(exports_external.string()).optional().describe("Extra Hindsight banks to recall from on every turn, merged into " + "the agent's own bank results — e.g. a shared operator/household " + "profile bank authored via `switchroom memory profile`. Each is " + "recalled with an 8s timeout and is non-fatal on failure. Stays " + "within the single tenant: all banks are the operator's data, in " + "the operator's Hindsight instance (see the `single-tenant` " + "invariant). Defaults to [] (no extra banks)."),
|
|
18750
18750
|
sender_banks: exports_external.record(exports_external.string(), exports_external.string()).optional().describe("Per-speaker recall routing: a map of Telegram sender → extra " + "recall bank. When a message arrives, the agent also recalls the " + "speaker's bank (matched by Telegram username — a leading @ is " + "optional — or numeric user_id), merged " + "into its own results — so each trusted user gets their own " + "profile context. Additive recall scoping within the single " + "tenant: never an access boundary (who may drive an agent stays " + "the per-agent user assignment in `access.allowFrom`). Author the " + "banks via `switchroom memory profile`."),
|
|
@@ -19056,9 +19056,16 @@ var ReactionDispatchSchema = exports_external.object({
|
|
|
19056
19056
|
enabled: exports_external.boolean().optional().describe("Master switch for the reaction-dispatch path. Default false — " + "with no reaction_dispatch block, reactions are persisted (and may " + "feed the `reactions` feedback path) but are NEVER dispatched as " + "event-driven inbound turns."),
|
|
19057
19057
|
emojis: exports_external.array(exports_external.string()).optional().describe('Emoji allowlist that triggers a `<channel event="reaction">` ' + "inbound turn when reacted to any message. Default [] (nothing " + "fires). Cascade mode: REPLACE (not union) — a layer's list " + "replaces lower layers entirely so an operator can narrow per-agent.")
|
|
19058
19058
|
}).optional();
|
|
19059
|
-
var
|
|
19059
|
+
var releaseBlockFields = {
|
|
19060
19060
|
channel: exports_external.enum(["dev", "rc", "latest"]).optional(),
|
|
19061
19061
|
pin: exports_external.string().regex(/^(sha-[0-9a-f]{7,40}|v\d+\.\d+\.\d+)$/).optional()
|
|
19062
|
+
};
|
|
19063
|
+
var ReleaseBlock = exports_external.object(releaseBlockFields).strict().refine((r) => !(r.channel && r.pin), {
|
|
19064
|
+
message: "release.channel and release.pin are mutually exclusive"
|
|
19065
|
+
});
|
|
19066
|
+
var RootReleaseBlock = exports_external.object({
|
|
19067
|
+
...releaseBlockFields,
|
|
19068
|
+
auto_update: exports_external.boolean().optional().describe("Opt-in unattended fleet auto-update (KEN-131). When true, hostd's " + "release watcher polls the published release version and, on a new " + "release, drives the EXISTING staggered canary rollout " + "(`switchroom rollout --pin vX.Y.Z`) unattended: canary-first, " + "per-agent version assert, durable pin persisted only after the " + "canary is green, abort + operator alert card on canary failure, " + "and compose rollback on a failed apply. Default false — with it " + "unset/false, behaviour is unchanged.")
|
|
19062
19069
|
}).strict().refine((r) => !(r.channel && r.pin), {
|
|
19063
19070
|
message: "release.channel and release.pin are mutually exclusive"
|
|
19064
19071
|
});
|
|
@@ -19302,6 +19309,7 @@ var AutoReleaseCheckSchema = exports_external.object({
|
|
|
19302
19309
|
enabled: exports_external.boolean().default(false).describe("When true, hostd polls the remote release tag every " + "`interval_minutes` and applies + restarts the fleet when a new " + "release is detected. Default false — opt-in."),
|
|
19303
19310
|
interval_minutes: exports_external.number().int().min(5).max(1440).default(5).describe("Poll interval in minutes. Floor of 5m matches the agent-config " + "cron rate limit; ceiling of 1440m (24h) is a sanity cap."),
|
|
19304
19311
|
apply_on_detect: exports_external.boolean().default(true).describe("When false, hostd logs `release_detected` but does NOT call " + "update_apply / restart all. Useful for dogfooding the detector " + "without rolling the fleet."),
|
|
19312
|
+
notify_on_detect: exports_external.boolean().default(false).describe("KEN-129 — operator-in-the-loop update prompt. Only consulted " + "when apply_on_detect is false (auto-apply supersedes notify): " + "a newly detected release posts ONE operator approval card " + "('fleet is behind — tap to apply') via an admin agent's " + "gateway; Approve runs hostd's update_apply path (fleet-" + "mutation-locked, durable status rows, get_status-pollable). " + "Dedup on release id: the last-notified id persists in " + "~/.switchroom/release-notify-state.json, so a card that " + "reached the operator is never re-posted for the same release."),
|
|
19305
19313
|
image_ref: exports_external.string().default("ghcr.io/switchroom/switchroom-agent:latest").describe("Image reference whose remote digest is compared to the local " + "image digest. Defaults to the agent image's :latest tag, which " + "is the canonical signal that a release has been promoted.")
|
|
19306
19314
|
});
|
|
19307
19315
|
var HostControlConfigSchema = exports_external.object({
|
|
@@ -19344,7 +19352,7 @@ var SwitchroomConfigSchema = exports_external.object({
|
|
|
19344
19352
|
timezone: exports_external.string().regex(TIMEZONE_REGEX, "timezone must be an IANA zone name like 'Australia/Melbourne' or 'UTC'").optional().describe("Global default IANA timezone applied to every agent unless the " + "agent (or its profile) declares its own. See the per-agent " + "timezone field for the full cascade and auto-detection fallback.")
|
|
19345
19353
|
}),
|
|
19346
19354
|
telegram: TelegramConfigSchema,
|
|
19347
|
-
release:
|
|
19355
|
+
release: RootReleaseBlock.optional().describe("Fleet-wide default release-channel pin / pointer for the update " + "flow. Either `channel` (dev|rc|latest) or `pin` (sha-<hex>|v<semver>) " + "— mutually exclusive. Per-agent `release` REPLACES this entirely " + "(except `auto_update`, which is root-only — a fleet property read by " + "hostd's release watcher, never per-agent)."),
|
|
19348
19356
|
memory: MemoryBackendConfigSchema.optional(),
|
|
19349
19357
|
hindsight: HindsightConfigSchema.optional().describe("Fleet-singleton Hindsight (memory backend) configuration. Currently " + "just the LLM knob (provider + model) used for retain/reflect/" + "consolidation. Both fields optional; when unset the container falls " + "back to provider=claude-code + the hard-coded HINDSIGHT_DEFAULT_MODEL " + "so nothing changes for operators who don't set it. Read at container " + "launch by startHindsight() (src/setup/hindsight.ts) — takes effect on " + "the next `switchroom apply` / `memory setup --recreate`."),
|
|
19350
19358
|
vault: VaultConfigSchema.optional(),
|
|
@@ -20669,6 +20677,9 @@ function allocateAgentUid(name) {
|
|
|
20669
20677
|
return AGENT_UID_MIN + u32 % range;
|
|
20670
20678
|
}
|
|
20671
20679
|
|
|
20680
|
+
// src/build-info.ts
|
|
20681
|
+
var VERSION = "0.19.18";
|
|
20682
|
+
|
|
20672
20683
|
// src/cli/agent-config.ts
|
|
20673
20684
|
import { join as join2 } from "node:path";
|
|
20674
20685
|
import { homedir as homedir2 } from "node:os";
|
|
@@ -20732,7 +20743,23 @@ var HINDSIGHT_HEALTHCHECK_PY = 'import urllib.request,sys; sys.exit(0 if urllib.
|
|
|
20732
20743
|
var HINDSIGHT_HEALTHCHECK_CMD = `python3 -c '${HINDSIGHT_HEALTHCHECK_PY}'`;
|
|
20733
20744
|
|
|
20734
20745
|
// src/memory/hindsight.ts
|
|
20735
|
-
var DEFAULT_RETAIN_MISSION = "Extract
|
|
20746
|
+
var DEFAULT_RETAIN_MISSION = "Extract durable facts that will still be true and useful weeks from now: " + "user preferences and standing rules, ongoing projects and recurring " + "commitments, technical and architectural decisions with their rationale, " + "and people/tool relationships. A preference revealed by a request is " + "durable — record the preference (what the user likes, wants, or always " + `does), not the request itself.
|
|
20747
|
+
|
|
20748
|
+
` + `NEVER extract:
|
|
20749
|
+
` + "- Agent tool-use traces or narration of what the assistant did (e.g. " + `"the assistant used X to query Y", "ran a search", "sent the message").
|
|
20750
|
+
` + "- In-flight workflow/process narration (a sub-task started, paused, or is " + "still running) — retain the outcome only once the task completes or a " + `decision is made.
|
|
20751
|
+
` + `- Operation, request, batch or session IDs, UUIDs, hashes, or error codes.
|
|
20752
|
+
` + "- Hindsight's own errors, retries, backlogs, or internal state — the " + `memory system's self-reports are not memories.
|
|
20753
|
+
` + `- Restatements of the user's current request or the task in progress.
|
|
20754
|
+
` + "- Transient state (unread counts, build status, what is running right now) " + "unless the fact is explicitly dated, in which case record it as a dated " + `observation.
|
|
20755
|
+
` + `- Greetings, acknowledgements, and routine operational chatter.
|
|
20756
|
+
|
|
20757
|
+
` + "If a candidate fact matches an exclusion, drop it rather than rewording " + "it. If nothing durable remains, return an empty facts list.";
|
|
20758
|
+
var SUPERSEDED_RETAIN_MISSIONS = [
|
|
20759
|
+
"Extract technical decisions, architectural choices, user preferences, project context, and people/tool relationships. Ignore routine greetings and transient operational details.",
|
|
20760
|
+
"Extract user preferences, ongoing projects, recurring commitments, " + "important context, and durable facts that should help across future " + "conversations. Skip one-off chatter and temporary task noise.",
|
|
20761
|
+
"Extract user preferences, ongoing projects, recurring commitments, " + "important context, and durable facts that should help across future " + "conversations. Skip one-off chatter and temporary task noise, " + "including in-flight workflow/process narration (a sub-task started, " + "paused, or is still running) — only retain the outcome once a task " + "actually completes or a decision is made."
|
|
20762
|
+
];
|
|
20736
20763
|
var PROFILE_MEMORY_DEFAULTS = {
|
|
20737
20764
|
"health-coach": {
|
|
20738
20765
|
disposition: { skepticism: 2, literalism: 2, empathy: 5 },
|
|
@@ -21202,6 +21229,7 @@ var materializedDirs = new Set;
|
|
|
21202
21229
|
// src/agents/scaffold.ts
|
|
21203
21230
|
var REPO_ROOT = resolve5(import.meta.dirname, "../..");
|
|
21204
21231
|
var CLAUDE_MD_YOURS_PLACEHOLDER = "This space is yours. Add per-agent rules, exceptions, or context the " + "Switchroom template doesn't capture. Everything above the marker line is " + "regenerated on every apply; this section is preserved.";
|
|
21232
|
+
var pendingBankOps = new Set;
|
|
21205
21233
|
var SWITCHROOM_OWNED_SETTINGS_KEYS = new Set([
|
|
21206
21234
|
"permissions",
|
|
21207
21235
|
"mcpServers",
|
|
@@ -26662,11 +26690,6 @@ init_atomic();
|
|
|
26662
26690
|
// src/cli/resolve-version.ts
|
|
26663
26691
|
import { existsSync as existsSync9, readFileSync as readFileSync7 } from "node:fs";
|
|
26664
26692
|
import { dirname as dirname5, join as join7 } from "node:path";
|
|
26665
|
-
|
|
26666
|
-
// src/build-info.ts
|
|
26667
|
-
var VERSION = "0.19.17";
|
|
26668
|
-
|
|
26669
|
-
// src/cli/resolve-version.ts
|
|
26670
26693
|
function readPackageVersion() {
|
|
26671
26694
|
let dir = import.meta.dirname;
|
|
26672
26695
|
for (let i = 0;i < 12 && dir && dir !== "/"; i++) {
|
|
@@ -27934,6 +27957,11 @@ var STATUS_RETENTION_MS = 10 * 60 * 1000;
|
|
|
27934
27957
|
var STATUS_MAX_ENTRIES = 256;
|
|
27935
27958
|
var TAIL_BYTES = 4096;
|
|
27936
27959
|
var ORPHAN_RECONCILE_AGE_MS = 15 * 60 * 1000;
|
|
27960
|
+
var AUTO_ROLLOUT_REQUEST_PREFIX = "auto-rollout-";
|
|
27961
|
+
function isAutoRolloutRequestId(requestId) {
|
|
27962
|
+
return requestId.startsWith(AUTO_ROLLOUT_REQUEST_PREFIX);
|
|
27963
|
+
}
|
|
27964
|
+
var AUTO_ROLLOUT_LATCH_FILENAME = "auto-rollout-latch.json";
|
|
27937
27965
|
|
|
27938
27966
|
class HostdServer {
|
|
27939
27967
|
opts;
|
|
@@ -27943,6 +27971,41 @@ class HostdServer {
|
|
|
27943
27971
|
auditChainState;
|
|
27944
27972
|
idempotencyKeys = new Map;
|
|
27945
27973
|
fleetMutationInFlight = null;
|
|
27974
|
+
lastAutoRolloutFailedPin = null;
|
|
27975
|
+
autoRolloutLatchPath() {
|
|
27976
|
+
return join10(this.hostdDirPath(), AUTO_ROLLOUT_LATCH_FILENAME);
|
|
27977
|
+
}
|
|
27978
|
+
readAutoRolloutLatch() {
|
|
27979
|
+
try {
|
|
27980
|
+
const raw = readFileSync9(this.autoRolloutLatchPath(), "utf-8");
|
|
27981
|
+
const parsed = JSON.parse(raw);
|
|
27982
|
+
if (parsed && parsed.v === 1 && typeof parsed.pin === "string" && parsed.pin.length > 0 && (parsed.outcome === "attempting" || parsed.outcome === "failed")) {
|
|
27983
|
+
return parsed;
|
|
27984
|
+
}
|
|
27985
|
+
return null;
|
|
27986
|
+
} catch {
|
|
27987
|
+
return null;
|
|
27988
|
+
}
|
|
27989
|
+
}
|
|
27990
|
+
autoRolloutLatchedPin() {
|
|
27991
|
+
return this.readAutoRolloutLatch()?.pin ?? this.lastAutoRolloutFailedPin;
|
|
27992
|
+
}
|
|
27993
|
+
writeAutoRolloutLatch(latch) {
|
|
27994
|
+
try {
|
|
27995
|
+
mkdirSync4(this.hostdDirPath(), { recursive: true });
|
|
27996
|
+
writeFileSync5(this.autoRolloutLatchPath(), JSON.stringify(latch, null, 2), {
|
|
27997
|
+
mode: 384
|
|
27998
|
+
});
|
|
27999
|
+
} catch (e) {
|
|
28000
|
+
process.stderr.write(`hostd: auto-rollout latch write failed (falling back to in-memory ` + `latch for this process lifetime): ${e.message}
|
|
28001
|
+
`);
|
|
28002
|
+
}
|
|
28003
|
+
}
|
|
28004
|
+
clearAutoRolloutLatch() {
|
|
28005
|
+
try {
|
|
28006
|
+
unlinkSync2(this.autoRolloutLatchPath());
|
|
28007
|
+
} catch {}
|
|
28008
|
+
}
|
|
27946
28009
|
get rolloutNarrator() {
|
|
27947
28010
|
return this.opts.rolloutNarrator;
|
|
27948
28011
|
}
|
|
@@ -28459,6 +28522,23 @@ class HostdServer {
|
|
|
28459
28522
|
return null;
|
|
28460
28523
|
return deniedResponse(request_id, `refused: this switchroom-hostd image is missing apply-time ` + `assets [${missing.join(", ")}]. hostd's in-container ` + `\`switchroom apply\` cannot scaffold agents without them and ` + `would pull images then strand the fleet on the old image ` + `(the klanker incident). Nothing was pulled or changed. Fix: ` + `rebuild/refresh the hostd image — \`switchroom update\` ` + `(refreshes hostd) or \`switchroom hostd install\` on the ` + `host; meanwhile run \`switchroom update\` host-side.`, Date.now() - started);
|
|
28461
28524
|
}
|
|
28525
|
+
isFleetMutationLocked() {
|
|
28526
|
+
return this.fleetMutationInFlight !== null;
|
|
28527
|
+
}
|
|
28528
|
+
startOperatorApprovedUpdateApply(requestId) {
|
|
28529
|
+
const req = {
|
|
28530
|
+
v: 1,
|
|
28531
|
+
request_id: requestId,
|
|
28532
|
+
op: "update_apply"
|
|
28533
|
+
};
|
|
28534
|
+
const caller = { kind: "operator" };
|
|
28535
|
+
const resp = this.handleUpdateApply(req, caller, Date.now());
|
|
28536
|
+
this.writeAudit({ caller, req, resp }).catch((err2) => {
|
|
28537
|
+
process.stderr.write(`hostd: audit write failed for operator-approved update_apply ` + `(request_id=${requestId}): ${err2.message}
|
|
28538
|
+
`);
|
|
28539
|
+
});
|
|
28540
|
+
return resp;
|
|
28541
|
+
}
|
|
28462
28542
|
handleUpdateApply(req, caller, started) {
|
|
28463
28543
|
const denied = this.checkFleetMutationLock(req.op, req.request_id, started);
|
|
28464
28544
|
if (denied)
|
|
@@ -28590,6 +28670,68 @@ class HostdServer {
|
|
|
28590
28670
|
duration_ms: Date.now() - started
|
|
28591
28671
|
};
|
|
28592
28672
|
}
|
|
28673
|
+
async startAutoRollout(pin) {
|
|
28674
|
+
const started = Date.now();
|
|
28675
|
+
const request_id = `${AUTO_ROLLOUT_REQUEST_PREFIX}${started}`;
|
|
28676
|
+
const diskLatch = this.readAutoRolloutLatch();
|
|
28677
|
+
if (this.lastAutoRolloutFailedPin === pin || diskLatch?.pin === pin) {
|
|
28678
|
+
const why = diskLatch?.pin === pin && diskLatch.outcome === "attempting" ? `a previous unattended roll to ${pin} did not complete (hostd ` + `restarted or crashed mid-roll — the fleet state is unverified)` : `a previous unattended roll to ${pin} FAILED`;
|
|
28679
|
+
return {
|
|
28680
|
+
started: false,
|
|
28681
|
+
reason: `${why} — refusing to retry unattended (the latch is durable ` + `across hostd restarts; fix the cause, then roll manually via ` + `\`switchroom rollout --pin ${pin}\`; a newer release supersedes ` + `the latch)`
|
|
28682
|
+
};
|
|
28683
|
+
}
|
|
28684
|
+
const inFlight = this.fleetMutationInFlight;
|
|
28685
|
+
if (inFlight) {
|
|
28686
|
+
return {
|
|
28687
|
+
started: false,
|
|
28688
|
+
reason: `fleet-mutation lock held by ${inFlight.op} (request_id ` + `"${inFlight.request_id}") — will re-check next tick`
|
|
28689
|
+
};
|
|
28690
|
+
}
|
|
28691
|
+
if (diskLatch && diskLatch.pin !== pin)
|
|
28692
|
+
this.clearAutoRolloutLatch();
|
|
28693
|
+
const caller = { kind: "operator" };
|
|
28694
|
+
if (needsSelfBump(this.opts.selfVersion ?? SWITCHROOM_VERSION, pin)) {
|
|
28695
|
+
const req = {
|
|
28696
|
+
v: 1,
|
|
28697
|
+
request_id,
|
|
28698
|
+
op: "rollout",
|
|
28699
|
+
args: { pin }
|
|
28700
|
+
};
|
|
28701
|
+
this.writeAutoRolloutLatch({
|
|
28702
|
+
v: 1,
|
|
28703
|
+
pin,
|
|
28704
|
+
request_id,
|
|
28705
|
+
outcome: "attempting",
|
|
28706
|
+
at: new Date().toISOString()
|
|
28707
|
+
});
|
|
28708
|
+
const resp = await this.beginSelfBump(req, caller, started);
|
|
28709
|
+
if (resp.result !== "started") {
|
|
28710
|
+
this.clearAutoRolloutLatch();
|
|
28711
|
+
return {
|
|
28712
|
+
started: false,
|
|
28713
|
+
reason: resp.error ?? `self-bump refused (result=${resp.result})`
|
|
28714
|
+
};
|
|
28715
|
+
}
|
|
28716
|
+
return { started: true, request_id };
|
|
28717
|
+
}
|
|
28718
|
+
const assetDenied = this.applyAssetPreflight(request_id, started);
|
|
28719
|
+
if (assetDenied) {
|
|
28720
|
+
return {
|
|
28721
|
+
started: false,
|
|
28722
|
+
reason: assetDenied.error ?? "apply-asset preflight refused"
|
|
28723
|
+
};
|
|
28724
|
+
}
|
|
28725
|
+
this.writeAutoRolloutLatch({
|
|
28726
|
+
v: 1,
|
|
28727
|
+
pin,
|
|
28728
|
+
request_id,
|
|
28729
|
+
outcome: "attempting",
|
|
28730
|
+
at: new Date().toISOString()
|
|
28731
|
+
});
|
|
28732
|
+
this.launchRollout({ pin }, request_id, caller, started);
|
|
28733
|
+
return { started: true, request_id };
|
|
28734
|
+
}
|
|
28593
28735
|
launchRollout(args, request_id, caller, started) {
|
|
28594
28736
|
const argv = ["rollout", "--pin", args.pin];
|
|
28595
28737
|
if (args.agents && args.agents.length > 0) {
|
|
@@ -28813,6 +28955,17 @@ class HostdServer {
|
|
|
28813
28955
|
this.recordStatus(entry2);
|
|
28814
28956
|
process.stderr.write(`hostd: self-bump resume failed: ${why}
|
|
28815
28957
|
`);
|
|
28958
|
+
if (isAutoRolloutRequestId(marker.request_id)) {
|
|
28959
|
+
this.lastAutoRolloutFailedPin = marker.pin;
|
|
28960
|
+
this.writeAutoRolloutLatch({
|
|
28961
|
+
v: 1,
|
|
28962
|
+
pin: marker.pin,
|
|
28963
|
+
request_id: marker.request_id,
|
|
28964
|
+
outcome: "failed",
|
|
28965
|
+
at: new Date().toISOString(),
|
|
28966
|
+
reason: "self-bump"
|
|
28967
|
+
});
|
|
28968
|
+
}
|
|
28816
28969
|
await this.writeTerminalAudit(entry2);
|
|
28817
28970
|
this.pushRolloutTerminal(entry2);
|
|
28818
28971
|
};
|
|
@@ -29395,6 +29548,7 @@ ${output.recovery.stderr}` : "";
|
|
|
29395
29548
|
});
|
|
29396
29549
|
}
|
|
29397
29550
|
spawnRollout(args, entry) {
|
|
29551
|
+
const priorPinAtStart = entry.prior_pin;
|
|
29398
29552
|
const onLine = (line) => {
|
|
29399
29553
|
const phase = parseRolloutPhaseLine(line);
|
|
29400
29554
|
if (phase)
|
|
@@ -29429,6 +29583,24 @@ ${output.recovery.stderr}` : "";
|
|
|
29429
29583
|
delete entry.prior_pin;
|
|
29430
29584
|
}).finally(() => {
|
|
29431
29585
|
this.writeTerminalAudit(entry);
|
|
29586
|
+
if (isAutoRolloutRequestId(entry.request_id) && entry.result !== "completed") {
|
|
29587
|
+
this.lastAutoRolloutFailedPin = entry.pin ?? null;
|
|
29588
|
+
if (entry.pin) {
|
|
29589
|
+
this.writeAutoRolloutLatch({
|
|
29590
|
+
v: 1,
|
|
29591
|
+
pin: entry.pin,
|
|
29592
|
+
request_id: entry.request_id,
|
|
29593
|
+
outcome: "failed",
|
|
29594
|
+
at: new Date().toISOString(),
|
|
29595
|
+
...entry.failed_step ? { reason: entry.failed_step } : {}
|
|
29596
|
+
});
|
|
29597
|
+
}
|
|
29598
|
+
this.finishFailedAutoRollout(entry, priorPinAtStart);
|
|
29599
|
+
return;
|
|
29600
|
+
}
|
|
29601
|
+
if (isAutoRolloutRequestId(entry.request_id)) {
|
|
29602
|
+
this.clearAutoRolloutLatch();
|
|
29603
|
+
}
|
|
29432
29604
|
this.pushRolloutTerminal(entry);
|
|
29433
29605
|
try {
|
|
29434
29606
|
this.rolloutNarrator?.onTerminal(entry);
|
|
@@ -29441,10 +29613,60 @@ ${output.recovery.stderr}` : "";
|
|
|
29441
29613
|
}
|
|
29442
29614
|
});
|
|
29443
29615
|
}
|
|
29444
|
-
|
|
29616
|
+
async finishFailedAutoRollout(entry, priorPin) {
|
|
29617
|
+
const notes = [
|
|
29618
|
+
`Unattended auto-update roll to ${entry.pin ?? "?"} FAILED at ` + `${entry.failed_step ?? "unknown step"}` + `${entry.failed_agent ? ` (agent ${entry.failed_agent})` : ""}. ` + `Unattended retries of this version are disabled; fix the cause, ` + `then roll manually (\`switchroom rollout --pin ${entry.pin ?? "vX.Y.Z"}\`).`
|
|
29619
|
+
];
|
|
29620
|
+
try {
|
|
29621
|
+
notes.push(...await this.recoverFailedAutoRollout(entry, priorPin));
|
|
29622
|
+
} catch (e) {
|
|
29623
|
+
notes.push(`Automatic rollback THREW: ${e.message} — verify compose/` + `canary state host-side.`);
|
|
29624
|
+
} finally {
|
|
29625
|
+
this.pushRolloutTerminal(entry, notes);
|
|
29626
|
+
try {
|
|
29627
|
+
this.rolloutNarrator?.onTerminal(entry);
|
|
29628
|
+
} catch (e) {
|
|
29629
|
+
process.stderr.write(`hostd: rollout narrator onTerminal threw (non-fatal): ${e.message}
|
|
29630
|
+
`);
|
|
29631
|
+
}
|
|
29632
|
+
if (this.fleetMutationInFlight && this.fleetMutationInFlight.request_id === entry.request_id) {
|
|
29633
|
+
this.fleetMutationInFlight = null;
|
|
29634
|
+
}
|
|
29635
|
+
}
|
|
29636
|
+
}
|
|
29637
|
+
async recoverFailedAutoRollout(entry, priorPin) {
|
|
29638
|
+
const notes = [];
|
|
29639
|
+
const rolledCount = (entry.rolled ?? []).length;
|
|
29640
|
+
const beforeAnyAgent = entry.failed_step === "apply" || entry.failed_step === "restart-agent" && rolledCount === 0;
|
|
29641
|
+
if (!beforeAnyAgent) {
|
|
29642
|
+
notes.push(rolledCount > 0 ? `No automatic rollback: ${rolledCount} agent(s) already confirmed on ` + `${entry.pin ?? "the target"} past a green canary ` + `(${(entry.rolled ?? []).join(", ")}). The fleet is mixed — ` + `finish the roll or roll back manually.` : `No automatic rollback: the roll stopped at ` + `${entry.failed_step ?? "an unknown step"} before any change ` + `landed — nothing to revert. Verify host-side if unsure.`);
|
|
29643
|
+
return notes;
|
|
29644
|
+
}
|
|
29645
|
+
if (!priorPin) {
|
|
29646
|
+
notes.push(`No automatic rollback: no prior version-assertable release.pin was ` + `recorded before this roll. Verify compose host-side ` + `(\`switchroom apply\`).`);
|
|
29647
|
+
return notes;
|
|
29648
|
+
}
|
|
29649
|
+
const applyRes = await this.runSwitchroom(["apply", "--pin", priorPin, "--compose-only", "--non-interactive"], { SWITCHROOM_HOSTD_CONTEXT: "1" });
|
|
29650
|
+
notes.push(applyRes.exit_code === 0 ? `Rollback: compose restored to ${priorPin}.` : `Rollback FAILED: compose restore to ${priorPin} exited ` + `${applyRes.exit_code} — run \`switchroom apply\` host-side ` + `(${tail(applyRes.stderr).trim().slice(-300) || "no stderr"}).`);
|
|
29651
|
+
if (entry.failed_step === "restart-agent" && entry.failed_agent) {
|
|
29652
|
+
const restartRes = await this.runSwitchroom([
|
|
29653
|
+
"agent",
|
|
29654
|
+
"restart",
|
|
29655
|
+
entry.failed_agent,
|
|
29656
|
+
"--wait",
|
|
29657
|
+
"--force",
|
|
29658
|
+
"--pin",
|
|
29659
|
+
priorPin
|
|
29660
|
+
], { SWITCHROOM_HOSTD_CONTEXT: "1" });
|
|
29661
|
+
notes.push(restartRes.exit_code === 0 ? `Rollback: canary ${entry.failed_agent} restarted back on ${priorPin}.` : `Rollback FAILED: canary ${entry.failed_agent} did not restart ` + `cleanly on ${priorPin} (exit ${restartRes.exit_code}) — restart ` + `it manually.`);
|
|
29662
|
+
}
|
|
29663
|
+
return notes;
|
|
29664
|
+
}
|
|
29665
|
+
pushRolloutTerminal(entry, extraNotes) {
|
|
29445
29666
|
if (!this.opts.rolloutRelay)
|
|
29446
29667
|
return;
|
|
29447
|
-
|
|
29668
|
+
const relayAgent = entry.caller.kind === "agent" ? entry.caller.name : isAutoRolloutRequestId(entry.request_id) ? this.firstAdminAgentName() : null;
|
|
29669
|
+
if (relayAgent === null)
|
|
29448
29670
|
return;
|
|
29449
29671
|
try {
|
|
29450
29672
|
const text = renderRolloutStatus({
|
|
@@ -29460,14 +29682,26 @@ ${output.recovery.stderr}` : "";
|
|
|
29460
29682
|
});
|
|
29461
29683
|
this.opts.rolloutRelay.postTerminal({
|
|
29462
29684
|
requestId: entry.request_id,
|
|
29463
|
-
agentName:
|
|
29464
|
-
text
|
|
29685
|
+
agentName: relayAgent,
|
|
29686
|
+
text: extraNotes && extraNotes.length > 0 ? text + `
|
|
29687
|
+
|
|
29688
|
+
` + extraNotes.map((n) => `- ${n}`).join(`
|
|
29689
|
+
`) : text
|
|
29465
29690
|
});
|
|
29466
29691
|
} catch (e) {
|
|
29467
29692
|
process.stderr.write(`hostd: rollout terminal push failed (non-fatal): ${e.message}
|
|
29468
29693
|
`);
|
|
29469
29694
|
}
|
|
29470
29695
|
}
|
|
29696
|
+
firstAdminAgentName() {
|
|
29697
|
+
const agents = this.opts.config.agents ?? {};
|
|
29698
|
+
for (const name of Object.keys(agents).sort()) {
|
|
29699
|
+
const a = agents[name];
|
|
29700
|
+
if (a?.admin === true || a?.root === true)
|
|
29701
|
+
return name;
|
|
29702
|
+
}
|
|
29703
|
+
return null;
|
|
29704
|
+
}
|
|
29471
29705
|
handleGetStatus(req, _caller, started) {
|
|
29472
29706
|
const entry = this.statusByRequestId.get(req.args.target_request_id);
|
|
29473
29707
|
if (!entry) {
|
|
@@ -29949,7 +30183,8 @@ class SocketApprovalGateway {
|
|
|
29949
30183
|
agentName: req.agentName,
|
|
29950
30184
|
reason: req.reason,
|
|
29951
30185
|
unifiedDiff: req.unifiedDiff,
|
|
29952
|
-
timeoutMs: req.timeoutMs
|
|
30186
|
+
timeoutMs: req.timeoutMs,
|
|
30187
|
+
...req.title ? { title: req.title } : {}
|
|
29953
30188
|
}) + `
|
|
29954
30189
|
`);
|
|
29955
30190
|
} catch (err2) {
|
|
@@ -30537,8 +30772,16 @@ class ReleaseWatcher {
|
|
|
30537
30772
|
...check.version ? { version: check.version } : {}
|
|
30538
30773
|
});
|
|
30539
30774
|
this.log(`release detected${check.version ? `: ${check.version}` : ""}` + (this.opts.applyOnDetect ? ", auto-applying" : " (apply_on_detect=false; not rolling)"));
|
|
30540
|
-
if (!this.opts.applyOnDetect)
|
|
30775
|
+
if (!this.opts.applyOnDetect) {
|
|
30776
|
+
if (this.opts.notifyFn) {
|
|
30777
|
+
try {
|
|
30778
|
+
await this.opts.notifyFn(check.version ?? "");
|
|
30779
|
+
} catch (err2) {
|
|
30780
|
+
this.log(`notify_failed: ${errMsg(err2)}`);
|
|
30781
|
+
}
|
|
30782
|
+
}
|
|
30541
30783
|
return;
|
|
30784
|
+
}
|
|
30542
30785
|
const applyStarted = this.now();
|
|
30543
30786
|
this.emit({
|
|
30544
30787
|
at: applyStarted,
|
|
@@ -30546,7 +30789,7 @@ class ReleaseWatcher {
|
|
|
30546
30789
|
...check.version ? { version: check.version } : {}
|
|
30547
30790
|
});
|
|
30548
30791
|
try {
|
|
30549
|
-
await this.opts.applyFn();
|
|
30792
|
+
await this.opts.applyFn(check.version);
|
|
30550
30793
|
} catch (err2) {
|
|
30551
30794
|
this.emit({
|
|
30552
30795
|
at: this.now(),
|
|
@@ -30718,6 +30961,13 @@ function makeApply(switchroomBin) {
|
|
|
30718
30961
|
}
|
|
30719
30962
|
};
|
|
30720
30963
|
}
|
|
30964
|
+
function makeUpdatePlan(switchroomBin) {
|
|
30965
|
+
return async () => {
|
|
30966
|
+
const r = await run(switchroomBin, ["update", "--check"], 2 * 60000);
|
|
30967
|
+
const out = r.stdout.trim();
|
|
30968
|
+
return out.length > 4000 ? out.slice(-4000) : out;
|
|
30969
|
+
};
|
|
30970
|
+
}
|
|
30721
30971
|
function makeRestart(switchroomBin) {
|
|
30722
30972
|
return async () => {
|
|
30723
30973
|
const r = await run(switchroomBin, ["restart", "all"], 5 * 60000);
|
|
@@ -30727,6 +30977,248 @@ function makeRestart(switchroomBin) {
|
|
|
30727
30977
|
};
|
|
30728
30978
|
}
|
|
30729
30979
|
|
|
30980
|
+
// src/host-control/update-notifier.ts
|
|
30981
|
+
import { mkdirSync as mkdirSync5, readFileSync as readFileSync10, renameSync as renameSync4, writeFileSync as writeFileSync6 } from "node:fs";
|
|
30982
|
+
import { randomBytes as randomBytes3 } from "node:crypto";
|
|
30983
|
+
import { dirname as dirname8 } from "node:path";
|
|
30984
|
+
var FINALIZE_DETAIL_MAX = 450;
|
|
30985
|
+
var DEFAULT_REPOST_SUPPRESS_MS = 60 * 60000;
|
|
30986
|
+
|
|
30987
|
+
class UpdateNotifier {
|
|
30988
|
+
opts;
|
|
30989
|
+
constructor(opts) {
|
|
30990
|
+
this.opts = opts;
|
|
30991
|
+
}
|
|
30992
|
+
async notifyIfNew(version2) {
|
|
30993
|
+
try {
|
|
30994
|
+
return await this.run(version2);
|
|
30995
|
+
} catch (err2) {
|
|
30996
|
+
this.log(`notify failed: ${errMsg2(err2)}`);
|
|
30997
|
+
return "dispatch_failed";
|
|
30998
|
+
}
|
|
30999
|
+
}
|
|
31000
|
+
async run(version2) {
|
|
31001
|
+
if (version2 === "") {
|
|
31002
|
+
this.log("release detected but no release id was provided — skipping card");
|
|
31003
|
+
return "skipped_no_version";
|
|
31004
|
+
}
|
|
31005
|
+
const state = this.readState();
|
|
31006
|
+
if (state?.last_notified_version === version2) {
|
|
31007
|
+
return "deduped";
|
|
31008
|
+
}
|
|
31009
|
+
const suppressMs = this.opts.repostSuppressMs ?? DEFAULT_REPOST_SUPPRESS_MS;
|
|
31010
|
+
if (state?.pending_version === version2 && typeof state.pending_posted_at === "number" && this.now() - state.pending_posted_at < suppressMs) {
|
|
31011
|
+
this.log(`release ${short(version2)} has a possibly-live card from before a restart — suppressing re-post`);
|
|
31012
|
+
return "pending_recent";
|
|
31013
|
+
}
|
|
31014
|
+
if (this.opts.isFleetMutationInFlight()) {
|
|
31015
|
+
this.log(`release ${short(version2)} detected but a fleet mutation is in flight — not posting a card this tick`);
|
|
31016
|
+
return "locked";
|
|
31017
|
+
}
|
|
31018
|
+
let plan = "";
|
|
31019
|
+
if (this.opts.planFn) {
|
|
31020
|
+
try {
|
|
31021
|
+
plan = await this.opts.planFn();
|
|
31022
|
+
} catch (err2) {
|
|
31023
|
+
this.log(`update --check plan probe failed: ${errMsg2(err2)}`);
|
|
31024
|
+
}
|
|
31025
|
+
}
|
|
31026
|
+
if (this.opts.isFleetMutationInFlight()) {
|
|
31027
|
+
this.log(`fleet mutation started during the plan probe for ${short(version2)} — not posting a card this tick`);
|
|
31028
|
+
return "locked";
|
|
31029
|
+
}
|
|
31030
|
+
const requestId = this.opts.mintRequestId ? this.opts.mintRequestId() : `relnotify-${randomBytes3(4).toString("hex")}`;
|
|
31031
|
+
this.log(`posting update approval card for release ${short(version2)} (requestId=${requestId})`);
|
|
31032
|
+
this.writeState({
|
|
31033
|
+
...this.carryNotified(state),
|
|
31034
|
+
pending_version: version2,
|
|
31035
|
+
pending_posted_at: this.now()
|
|
31036
|
+
});
|
|
31037
|
+
let res;
|
|
31038
|
+
try {
|
|
31039
|
+
res = await this.opts.requestApproval({ requestId, version: version2, plan });
|
|
31040
|
+
} catch (err2) {
|
|
31041
|
+
res = {
|
|
31042
|
+
verdict: "deny",
|
|
31043
|
+
reason: `requestApproval threw: ${errMsg2(err2)}`,
|
|
31044
|
+
denySource: "dispatch_failure",
|
|
31045
|
+
finalize: async () => {}
|
|
31046
|
+
};
|
|
31047
|
+
}
|
|
31048
|
+
if (res.verdict === "deny" && res.denySource === "dispatch_failure") {
|
|
31049
|
+
this.log(`update approval card dispatch failed (${res.reason ?? "unknown"}) — will retry next tick`);
|
|
31050
|
+
this.writeState(this.carryNotified(state));
|
|
31051
|
+
return "dispatch_failed";
|
|
31052
|
+
}
|
|
31053
|
+
this.writeState({
|
|
31054
|
+
last_notified_version: version2,
|
|
31055
|
+
notified_at: this.now()
|
|
31056
|
+
});
|
|
31057
|
+
if (res.verdict === "deny") {
|
|
31058
|
+
this.log(`operator denied update for release ${short(version2)}`);
|
|
31059
|
+
return "denied";
|
|
31060
|
+
}
|
|
31061
|
+
if (res.verdict === "timeout") {
|
|
31062
|
+
this.log(`update approval card expired for release ${short(version2)}`);
|
|
31063
|
+
return "timed_out";
|
|
31064
|
+
}
|
|
31065
|
+
let start;
|
|
31066
|
+
try {
|
|
31067
|
+
start = this.opts.startApply(requestId);
|
|
31068
|
+
} catch (err2) {
|
|
31069
|
+
start = { result: "error", error: errMsg2(err2) };
|
|
31070
|
+
}
|
|
31071
|
+
if (start.result === "started") {
|
|
31072
|
+
this.log(`update_apply started (requestId=${requestId})`);
|
|
31073
|
+
await res.finalize({
|
|
31074
|
+
outcome: "applied",
|
|
31075
|
+
detail: clipDetail(`switchroom update started (request_id ${requestId}) — track progress via get_status / the rollout narration message.`)
|
|
31076
|
+
});
|
|
31077
|
+
return "apply_started";
|
|
31078
|
+
}
|
|
31079
|
+
const why = start.error ?? `unexpected result "${start.result}"`;
|
|
31080
|
+
this.log(`update_apply refused after approval: ${why}`);
|
|
31081
|
+
await res.finalize({
|
|
31082
|
+
outcome: "reconcile_failed_rolled_back",
|
|
31083
|
+
detail: clipDetail(`update did not start: ${why}`)
|
|
31084
|
+
});
|
|
31085
|
+
return "apply_refused";
|
|
31086
|
+
}
|
|
31087
|
+
carryNotified(state) {
|
|
31088
|
+
return state?.last_notified_version !== undefined ? {
|
|
31089
|
+
last_notified_version: state.last_notified_version,
|
|
31090
|
+
notified_at: state.notified_at ?? 0
|
|
31091
|
+
} : {};
|
|
31092
|
+
}
|
|
31093
|
+
now() {
|
|
31094
|
+
return this.opts.now ? this.opts.now() : Date.now();
|
|
31095
|
+
}
|
|
31096
|
+
readState() {
|
|
31097
|
+
try {
|
|
31098
|
+
const raw = readFileSync10(this.opts.statePath, "utf8");
|
|
31099
|
+
const parsed = JSON.parse(raw);
|
|
31100
|
+
if (parsed === null || typeof parsed !== "object")
|
|
31101
|
+
return null;
|
|
31102
|
+
const out = {};
|
|
31103
|
+
if (typeof parsed.last_notified_version === "string") {
|
|
31104
|
+
out.last_notified_version = parsed.last_notified_version;
|
|
31105
|
+
out.notified_at = typeof parsed.notified_at === "number" ? parsed.notified_at : 0;
|
|
31106
|
+
}
|
|
31107
|
+
if (typeof parsed.pending_version === "string") {
|
|
31108
|
+
out.pending_version = parsed.pending_version;
|
|
31109
|
+
out.pending_posted_at = typeof parsed.pending_posted_at === "number" ? parsed.pending_posted_at : 0;
|
|
31110
|
+
}
|
|
31111
|
+
return Object.keys(out).length > 0 ? out : null;
|
|
31112
|
+
} catch {
|
|
31113
|
+
return null;
|
|
31114
|
+
}
|
|
31115
|
+
}
|
|
31116
|
+
writeState(state) {
|
|
31117
|
+
try {
|
|
31118
|
+
mkdirSync5(dirname8(this.opts.statePath), { recursive: true });
|
|
31119
|
+
const tmp = `${this.opts.statePath}.tmp`;
|
|
31120
|
+
writeFileSync6(tmp, JSON.stringify(state) + `
|
|
31121
|
+
`, "utf8");
|
|
31122
|
+
renameSync4(tmp, this.opts.statePath);
|
|
31123
|
+
} catch (err2) {
|
|
31124
|
+
this.log(`failed to persist notify state: ${errMsg2(err2)}`);
|
|
31125
|
+
}
|
|
31126
|
+
}
|
|
31127
|
+
log(m) {
|
|
31128
|
+
if (this.opts.log)
|
|
31129
|
+
this.opts.log(`update-notifier: ${m}`);
|
|
31130
|
+
}
|
|
31131
|
+
}
|
|
31132
|
+
function clipDetail(detail) {
|
|
31133
|
+
return detail.length > FINALIZE_DETAIL_MAX ? detail.slice(0, FINALIZE_DETAIL_MAX - 1) + "…" : detail;
|
|
31134
|
+
}
|
|
31135
|
+
function short(version2) {
|
|
31136
|
+
const hex = version2.startsWith("sha256:") ? version2.slice(7) : version2;
|
|
31137
|
+
return hex.length > 12 ? hex.slice(0, 12) : hex;
|
|
31138
|
+
}
|
|
31139
|
+
function errMsg2(err2) {
|
|
31140
|
+
return err2 instanceof Error ? err2.message : String(err2);
|
|
31141
|
+
}
|
|
31142
|
+
|
|
31143
|
+
// src/host-control/auto-update-check.ts
|
|
31144
|
+
var NPM_LATEST_URL = "https://registry.npmjs.org/switchroom/latest";
|
|
31145
|
+
var FETCH_TIMEOUT_MS = 15000;
|
|
31146
|
+
function versionedImageRef(imageRef, version2) {
|
|
31147
|
+
const lastSlash = imageRef.lastIndexOf("/");
|
|
31148
|
+
const colon = imageRef.indexOf(":", lastSlash + 1);
|
|
31149
|
+
const repo = colon === -1 ? imageRef : imageRef.slice(0, colon);
|
|
31150
|
+
return `${repo}:${version2}`;
|
|
31151
|
+
}
|
|
31152
|
+
async function fetchNpmLatestVersion(url = NPM_LATEST_URL) {
|
|
31153
|
+
const res = await fetch(url, {
|
|
31154
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
31155
|
+
headers: { accept: "application/json" }
|
|
31156
|
+
});
|
|
31157
|
+
if (!res.ok) {
|
|
31158
|
+
throw new Error(`npm registry responded ${res.status} for ${url}`);
|
|
31159
|
+
}
|
|
31160
|
+
const body = await res.json();
|
|
31161
|
+
if (typeof body.version !== "string" || !/^\d+\.\d+\.\d+$/.test(body.version)) {
|
|
31162
|
+
throw new Error(`npm registry document at ${url} has no clean semver version field`);
|
|
31163
|
+
}
|
|
31164
|
+
return body.version;
|
|
31165
|
+
}
|
|
31166
|
+
function makeAutoUpdateCheck(opts) {
|
|
31167
|
+
const log = opts.log ?? (() => {});
|
|
31168
|
+
const fetchLatest = opts.fetchLatestVersion ?? fetchNpmLatestVersion;
|
|
31169
|
+
const imageExists = opts.imageExists ?? (async (ref) => {
|
|
31170
|
+
const probe = await probeRemoteDigest(ref);
|
|
31171
|
+
if (probe.digest === null && probe.error) {
|
|
31172
|
+
log(`auto-update: manifest probe for ${ref} returned no digest ` + `(missing image OR registry error — deferring either way): ` + probe.error);
|
|
31173
|
+
}
|
|
31174
|
+
return probe.digest !== null;
|
|
31175
|
+
});
|
|
31176
|
+
let lastSuppressed = "";
|
|
31177
|
+
const logOnce = (key, m) => {
|
|
31178
|
+
if (lastSuppressed === key)
|
|
31179
|
+
return;
|
|
31180
|
+
lastSuppressed = key;
|
|
31181
|
+
log(m);
|
|
31182
|
+
};
|
|
31183
|
+
return async () => {
|
|
31184
|
+
const latest = `v${(await fetchLatest()).trim().replace(/^v/, "")}`;
|
|
31185
|
+
const channel = opts.getCurrentChannel?.();
|
|
31186
|
+
if (channel !== undefined && channel !== "latest") {
|
|
31187
|
+
logOnce(`channel:${channel}`, `auto-update: release.channel is "${channel}" (not "latest") — the ` + `auto-update path tracks the npm latest dist-tag and persisting a ` + `pin would delete your channel, so no unattended roll will happen. ` + `Remove release.channel (or set it to "latest") to enable it. ` + `Latest published: ${latest}`);
|
|
31188
|
+
return { available: false, version: latest };
|
|
31189
|
+
}
|
|
31190
|
+
const latched = opts.getLatchedPin?.() ?? null;
|
|
31191
|
+
if (latched !== null && latched === latest) {
|
|
31192
|
+
logOnce(`latch:${latched}`, `auto-update: ${latest} is latched from a previous unattended roll ` + `that failed or died mid-flight — not re-reporting it as available. ` + `Fix the cause and roll manually (\`switchroom rollout --pin ` + `${latest}\`), or wait for a newer release.`);
|
|
31193
|
+
return { available: false, version: latest };
|
|
31194
|
+
}
|
|
31195
|
+
const pin = opts.getCurrentPin();
|
|
31196
|
+
if (pin !== undefined) {
|
|
31197
|
+
const cmp = compareReleaseTags(latest, pin);
|
|
31198
|
+
if (cmp === null) {
|
|
31199
|
+
log(`auto-update: current release.pin "${pin}" is not a vX.Y.Z tag — ` + `skipping unattended roll (latest published: ${latest})`);
|
|
31200
|
+
return { available: false, version: latest };
|
|
31201
|
+
}
|
|
31202
|
+
if (cmp <= 0)
|
|
31203
|
+
return { available: false, version: latest };
|
|
31204
|
+
}
|
|
31205
|
+
const target = versionedImageRef(opts.imageRef, latest);
|
|
31206
|
+
if (!await imageExists(target)) {
|
|
31207
|
+
log(`auto-update: ${latest} is published but ${target} is not pullable ` + `yet (docker-images workflow may still be building) — deferring`);
|
|
31208
|
+
return { available: false, version: latest };
|
|
31209
|
+
}
|
|
31210
|
+
return { available: true, version: latest };
|
|
31211
|
+
};
|
|
31212
|
+
}
|
|
31213
|
+
function resolveReleaseWatcherMode(config) {
|
|
31214
|
+
if (config.release?.auto_update === true)
|
|
31215
|
+
return { mode: "auto_update" };
|
|
31216
|
+
if (config.host_control?.auto_release_check?.enabled === true) {
|
|
31217
|
+
return { mode: "legacy" };
|
|
31218
|
+
}
|
|
31219
|
+
return { mode: "off" };
|
|
31220
|
+
}
|
|
31221
|
+
|
|
30730
31222
|
// src/host-control/main.ts
|
|
30731
31223
|
import { appendFile as appendFile2 } from "node:fs/promises";
|
|
30732
31224
|
async function loadConfigResilient() {
|
|
@@ -30846,28 +31338,126 @@ async function main() {
|
|
|
30846
31338
|
`);
|
|
30847
31339
|
let releaseWatcher = null;
|
|
30848
31340
|
const autoRel = config.host_control?.auto_release_check;
|
|
30849
|
-
|
|
31341
|
+
const watcherMode = resolveReleaseWatcherMode(config);
|
|
31342
|
+
if (watcherMode.mode !== "off") {
|
|
30850
31343
|
const eventsLog = join11(homedir6(), ".switchroom", "release-watcher-events.jsonl");
|
|
30851
|
-
|
|
30852
|
-
|
|
30853
|
-
|
|
30854
|
-
|
|
30855
|
-
|
|
30856
|
-
`
|
|
30857
|
-
}),
|
|
30858
|
-
applyFn: makeApply("switchroom"),
|
|
30859
|
-
restartFn: makeRestart("switchroom"),
|
|
30860
|
-
applyOnDetect: autoRel.apply_on_detect,
|
|
30861
|
-
log: (m) => process.stderr.write(`hostd: ${m}
|
|
30862
|
-
`),
|
|
30863
|
-
onEvent: (e) => {
|
|
30864
|
-
appendFile2(eventsLog, JSON.stringify(e) + `
|
|
31344
|
+
const log = (m) => {
|
|
31345
|
+
process.stderr.write(`hostd: ${m}
|
|
31346
|
+
`);
|
|
31347
|
+
};
|
|
31348
|
+
const onEvent = (e) => {
|
|
31349
|
+
appendFile2(eventsLog, JSON.stringify(e) + `
|
|
30865
31350
|
`).catch(() => {});
|
|
31351
|
+
};
|
|
31352
|
+
const intervalMinutes = autoRel?.interval_minutes ?? 5;
|
|
31353
|
+
const imageRef = autoRel?.image_ref ?? "ghcr.io/switchroom/switchroom-agent:latest";
|
|
31354
|
+
if (watcherMode.mode === "auto_update") {
|
|
31355
|
+
if (autoRel?.notify_on_detect === true) {
|
|
31356
|
+
process.stderr.write("hostd: release.auto_update is on, so notify_on_detect is " + "ignored — new releases roll unattended via the canary path and " + "only FAILURES post an operator alert. Turn off " + `release.auto_update if you want the tap-to-apply card instead.
|
|
31357
|
+
`);
|
|
30866
31358
|
}
|
|
30867
|
-
|
|
30868
|
-
|
|
30869
|
-
|
|
31359
|
+
const liveRelease = () => loadConfig().release ?? {};
|
|
31360
|
+
releaseWatcher = new ReleaseWatcher({
|
|
31361
|
+
intervalMs: intervalMinutes * 60000,
|
|
31362
|
+
checkFn: makeAutoUpdateCheck({
|
|
31363
|
+
imageRef,
|
|
31364
|
+
getCurrentPin: () => liveRelease().pin,
|
|
31365
|
+
getCurrentChannel: () => liveRelease().channel,
|
|
31366
|
+
getLatchedPin: () => server.autoRolloutLatchedPin(),
|
|
31367
|
+
log
|
|
31368
|
+
}),
|
|
31369
|
+
applyFn: async (version2) => {
|
|
31370
|
+
if (!version2) {
|
|
31371
|
+
throw new Error("auto-update check reported no target version");
|
|
31372
|
+
}
|
|
31373
|
+
const r = await server.startAutoRollout(version2);
|
|
31374
|
+
if (!r.started) {
|
|
31375
|
+
throw new Error(`auto rollout not started: ${r.reason}`);
|
|
31376
|
+
}
|
|
31377
|
+
log(`auto-update: unattended rollout to ${version2} started ` + `(request_id ${r.request_id})`);
|
|
31378
|
+
},
|
|
31379
|
+
restartFn: async () => {},
|
|
31380
|
+
applyOnDetect: true,
|
|
31381
|
+
log,
|
|
31382
|
+
onEvent
|
|
31383
|
+
});
|
|
31384
|
+
releaseWatcher.start();
|
|
31385
|
+
process.stderr.write(`hostd: auto-update watcher started (release.auto_update) — ` + `checking the published release every ${intervalMinutes}m; new ` + `releases roll via the staggered canary rollout
|
|
31386
|
+
`);
|
|
31387
|
+
} else {
|
|
31388
|
+
const applyOnDetect = autoRel?.apply_on_detect ?? true;
|
|
31389
|
+
const notifyOnDetect = autoRel?.notify_on_detect ?? false;
|
|
31390
|
+
let notifier = null;
|
|
31391
|
+
if (!applyOnDetect && notifyOnDetect) {
|
|
31392
|
+
const adminAgents = Object.keys(config.agents).filter((n) => {
|
|
31393
|
+
const a = config.agents[n];
|
|
31394
|
+
return a.admin === true || a.root === true;
|
|
31395
|
+
}).sort();
|
|
31396
|
+
const cardTimeoutMs = 60 * 60000;
|
|
31397
|
+
if (adminAgents.length === 0) {
|
|
31398
|
+
process.stderr.write("hostd: notify_on_detect is enabled but no agent has " + "admin/root — update approval cards have nowhere to go; " + `notifier disabled
|
|
31399
|
+
`);
|
|
31400
|
+
} else
|
|
31401
|
+
notifier = new UpdateNotifier({
|
|
31402
|
+
statePath: join11(homedir6(), ".switchroom", "release-notify-state.json"),
|
|
31403
|
+
repostSuppressMs: cardTimeoutMs,
|
|
31404
|
+
isFleetMutationInFlight: () => server.isFleetMutationLocked(),
|
|
31405
|
+
startApply: (requestId) => {
|
|
31406
|
+
const res = server.startOperatorApprovedUpdateApply(requestId);
|
|
31407
|
+
return { result: res.result, ...res.error ? { error: res.error } : {} };
|
|
31408
|
+
},
|
|
31409
|
+
planFn: makeUpdatePlan("switchroom"),
|
|
31410
|
+
requestApproval: async ({ requestId, version: version2, plan }) => {
|
|
31411
|
+
let last = {
|
|
31412
|
+
verdict: "deny",
|
|
31413
|
+
reason: "no admin agent with a reachable gateway socket",
|
|
31414
|
+
denySource: "dispatch_failure",
|
|
31415
|
+
finalize: async () => {}
|
|
31416
|
+
};
|
|
31417
|
+
for (const agent of adminAgents) {
|
|
31418
|
+
const res = await approvalGateway.requestApproval({
|
|
31419
|
+
requestId,
|
|
31420
|
+
agentName: agent,
|
|
31421
|
+
title: "⬆️ **Switchroom update available — fleet is behind**",
|
|
31422
|
+
reason: `New release detected for ${imageRef} ` + `(digest ${short(version2)}). Approve to run ` + `switchroom update (the hostd update_apply path).`,
|
|
31423
|
+
unifiedDiff: plan.length > 0 ? plan : `switchroom update --check produced no plan output;
|
|
31424
|
+
` + `remote digest: ${version2}`,
|
|
31425
|
+
timeoutMs: cardTimeoutMs
|
|
31426
|
+
});
|
|
31427
|
+
if (res.verdict === "deny" && res.denySource === "dispatch_failure") {
|
|
31428
|
+
last = res;
|
|
31429
|
+
continue;
|
|
31430
|
+
}
|
|
31431
|
+
return res;
|
|
31432
|
+
}
|
|
31433
|
+
return last;
|
|
31434
|
+
},
|
|
31435
|
+
log: (m) => process.stderr.write(`hostd: ${m}
|
|
31436
|
+
`)
|
|
31437
|
+
});
|
|
31438
|
+
}
|
|
31439
|
+
const notifierRef = notifier;
|
|
31440
|
+
releaseWatcher = new ReleaseWatcher({
|
|
31441
|
+
intervalMs: intervalMinutes * 60000,
|
|
31442
|
+
checkFn: makeReleaseCheck({
|
|
31443
|
+
imageRef,
|
|
31444
|
+
log
|
|
31445
|
+
}),
|
|
31446
|
+
applyFn: makeApply("switchroom"),
|
|
31447
|
+
restartFn: makeRestart("switchroom"),
|
|
31448
|
+
applyOnDetect,
|
|
31449
|
+
...notifierRef ? {
|
|
31450
|
+
notifyFn: async (version2) => {
|
|
31451
|
+
await notifierRef.notifyIfNew(version2);
|
|
31452
|
+
}
|
|
31453
|
+
} : {},
|
|
31454
|
+
log,
|
|
31455
|
+
onEvent
|
|
31456
|
+
});
|
|
31457
|
+
releaseWatcher.start();
|
|
31458
|
+
process.stderr.write(`hostd: release-watcher started — polling ${imageRef} ` + `every ${intervalMinutes}m ` + `(apply_on_detect=${applyOnDetect}, ` + `notify_on_detect=${notifyOnDetect})
|
|
30870
31459
|
`);
|
|
31460
|
+
}
|
|
30871
31461
|
}
|
|
30872
31462
|
let stopping = false;
|
|
30873
31463
|
async function shutdown(reason) {
|