switchroom 0.16.28 → 0.16.38
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/agent-scheduler/index.js +13 -2
- package/dist/auth-broker/index.js +13 -2
- package/dist/cli/notion-write-pretool.mjs +13 -2
- package/dist/cli/switchroom.js +60648 -59800
- package/dist/host-control/main.js +730 -18
- package/dist/vault/approvals/kernel-server.js +51 -37
- package/dist/vault/broker/server.js +72 -58
- package/package.json +2 -1
- package/profiles/_shared/agent-self-service.md.hbs +7 -2
- package/telegram-plugin/auth-snapshot-format.ts +173 -67
- package/telegram-plugin/auto-fallback-fleet.ts +3 -6
- package/telegram-plugin/card-format.ts +52 -2
- package/telegram-plugin/credits-watch.ts +4 -7
- package/telegram-plugin/dist/gateway/gateway.js +2855 -1034
- package/telegram-plugin/dist/server.js +0 -1
- package/telegram-plugin/format.ts +264 -7
- package/telegram-plugin/gateway/approval-card.ts +7 -14
- package/telegram-plugin/gateway/approvals-commands.ts +6 -9
- package/telegram-plugin/gateway/auth-command.ts +35 -38
- package/telegram-plugin/gateway/boot-card.ts +7 -2
- package/telegram-plugin/gateway/chat-id-fallback.ts +46 -0
- package/telegram-plugin/gateway/config-approval-handler.ts +6 -9
- package/telegram-plugin/gateway/diff-preview-card.ts +3 -6
- package/telegram-plugin/gateway/gateway.ts +1293 -97
- package/telegram-plugin/gateway/ipc-protocol.ts +66 -2
- package/telegram-plugin/gateway/ipc-server.ts +91 -1
- package/telegram-plugin/gateway/linear-activity.ts +2 -5
- package/telegram-plugin/gateway/obligation-turn-end.ts +27 -0
- package/telegram-plugin/gateway/permission-card-store.ts +104 -0
- package/telegram-plugin/gateway/permission-timeout.ts +25 -6
- package/telegram-plugin/gateway/status-pin-store.ts +302 -0
- package/telegram-plugin/gateway/turns-jsonl-rotate.ts +30 -0
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +12 -1
- package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +35 -0
- package/telegram-plugin/gateway/vault-request-access-card.ts +61 -0
- package/telegram-plugin/history.ts +17 -7
- package/telegram-plugin/idle-footer.ts +2 -2
- package/telegram-plugin/issues-card.ts +12 -7
- package/telegram-plugin/model-unavailable.ts +3 -6
- package/telegram-plugin/operator-events.ts +4 -6
- package/telegram-plugin/quota-check.ts +2 -2
- package/telegram-plugin/quota-watch.ts +7 -10
- package/telegram-plugin/server.ts +3 -1
- package/telegram-plugin/status-pin-driver.ts +102 -0
- package/telegram-plugin/status-pin.ts +76 -0
- package/telegram-plugin/stream-reply-handler.ts +14 -1
- package/telegram-plugin/subagent-watcher.ts +6 -3
- package/telegram-plugin/tests/auth-command-format2.test.ts +6 -2
- package/telegram-plugin/tests/auth-command-vernacular.test.ts +18 -0
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +204 -45
- package/telegram-plugin/tests/card-format.test.ts +51 -0
- package/telegram-plugin/tests/chat-id-fallback.test.ts +74 -0
- package/telegram-plugin/tests/codespan-escaping-golden.test.ts +166 -0
- package/telegram-plugin/tests/credits-watch.test.ts +18 -0
- package/telegram-plugin/tests/history.test.ts +38 -2
- package/telegram-plugin/tests/idle-footer.test.ts +53 -9
- package/telegram-plugin/tests/ipc-server-validate-rollout-status.test.ts +66 -0
- package/telegram-plugin/tests/issues-card.test.ts +24 -0
- package/telegram-plugin/tests/obligation-turn-end.test.ts +76 -0
- package/telegram-plugin/tests/paragraph-normalizer.test.ts +200 -1
- package/telegram-plugin/tests/permission-card-single-edit.test.ts +66 -0
- package/telegram-plugin/tests/permission-card-store.test.ts +83 -0
- package/telegram-plugin/tests/permission-timeout.test.ts +37 -6
- package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +23 -6
- package/telegram-plugin/tests/quota-check.test.ts +9 -2
- package/telegram-plugin/tests/quota-watch.test.ts +7 -7
- package/telegram-plugin/tests/rollout-status-wiring.test.ts +81 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +294 -0
- package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +210 -0
- package/telegram-plugin/tests/status-pin-store.test.ts +450 -0
- package/telegram-plugin/tests/status-pin.test.ts +202 -0
- package/telegram-plugin/tests/stream-reply-handler.test.ts +39 -0
- package/telegram-plugin/tests/telegram-format.test.ts +88 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +82 -24
- package/telegram-plugin/tests/turns-jsonl-rotate.test.ts +39 -0
- package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +12 -0
- package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +54 -0
- package/telegram-plugin/tests/vault-request-access-card.test.ts +94 -0
- package/telegram-plugin/tests/voice-normalize-text.test.ts +256 -0
- package/telegram-plugin/tests/voice-ondemand.test.ts +202 -0
- package/telegram-plugin/tests/voice-out-one-send.test.ts +153 -0
- package/telegram-plugin/tests/voice-synthesize-sidecar.test.ts +352 -0
- package/telegram-plugin/tests/voice-transcribe-sidecar.test.ts +332 -0
- package/telegram-plugin/tests/voice-transcribe.test.ts +188 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +28 -10
- package/telegram-plugin/tool-activity-summary.ts +11 -8
- package/telegram-plugin/uat/driver.ts +8 -1
- package/telegram-plugin/voice-normalize-text.ts +340 -0
- package/telegram-plugin/voice-ondemand.ts +138 -0
- package/telegram-plugin/voice-synthesize-sidecar.ts +259 -0
- package/telegram-plugin/voice-synthesize.ts +128 -0
- package/telegram-plugin/voice-transcribe-sidecar.ts +176 -0
- package/telegram-plugin/worker-activity-feed.ts +13 -4
|
@@ -15921,19 +15921,30 @@ var TelegramChannelSchema = exports_external.object({
|
|
|
15921
15921
|
stream_mode: exports_external.enum(["pty", "checklist"]).optional().describe("How live progress is streamed to Telegram during a turn. " + "'pty' (default) surfaces text snapshots of Claude Code's TUI — " + "compatible but can flicker as Ink re-renders. 'checklist' drives " + "a structured progress card from session-tail events — stable " + "order, per-tool status emojis, fires only on semantic transitions."),
|
|
15922
15922
|
stream_throttle_ms: exports_external.number().int().nonnegative().optional().describe("Throttle window in ms between successive in-place stream edits " + "during a turn. Lower = more responsive stream, higher = fewer API " + "calls. Floored at 250 by draft-stream itself. Default 400 ms for DMs " + "and 1000 ms for groups/forums (respects Telegram's ~1 edit/sec/message " + "practical ceiling). Override per-agent if a particular agent needs " + "snappier or quieter streaming."),
|
|
15923
15923
|
clear_status_on_completion: exports_external.boolean().optional().describe("When true, the live activity/status feed (the in-place 'what it's " + "doing' message — Reading X, Searching the web for Y, …) is DELETED " + "when the turn's final answer lands, so only the reply remains. " + "Default false: the status message is left in the chat as a record " + "(its last step marked done) — no post-then-delete. Per-agent " + "override; cascades defaults → profile → agent (per-key)."),
|
|
15924
|
+
pin_status_while_working: exports_external.boolean().optional().describe("When true (default), the framework SILENTLY pins the already-" + "rendered status message while its work is in-flight and auto-unpins " + "it on completion — the per-turn activity/status message (foreground) " + "and the '\uD83D\uDEE0 Worker' background-worker message. Keeps in-flight work " + "in view when the conversation scrolls past it (fast turns, stacked " + "background workers, long turns). No new surface is rendered; it pins " + "a message the chat already owns. The pin never buzzes the device. " + "The ONE sanctioned pin under chat-is-the-single-source-of-truth. " + "Set false to disable. Per-agent override; cascades defaults → " + "profile → agent (per-key)."),
|
|
15924
15925
|
hotReloadStable: exports_external.boolean().optional().describe("If true, the stable workspace prefix (AGENTS.md, SOUL.md, USER.md, " + "IDENTITY.md, TOOLS.md) is re-injected on every turn via " + "the UserPromptSubmit hook instead of baked into --append-system-prompt " + "at session start. Lets workspace edits propagate without a restart. " + "Costs ~5-10% per-turn latency/spend since the stable prefix is no " + "longer prompt-cached."),
|
|
15925
15926
|
inject_on_change: exports_external.boolean().optional().describe("Context-efficiency gate for per-turn hook injection (default true). " + "When true (the default), the turn-pacing directive and dynamic " + "workspace content are only re-emitted when their content changes or " + "the session_id changes — suppressing redundant injection that " + "otherwise triples compaction frequency. Set to false to revert to " + "the legacy always-emit behaviour (every turn injects the full " + "content regardless of whether it changed)."),
|
|
15926
15927
|
orphan_promotion_ms: exports_external.number().int().nonnegative().optional().describe("How long (ms) a parent turn waits for a sub-agent JSONL watcher " + "to deliver sub_agent_started before the heartbeat promotes the spawn " + "to a synthesised 'running' row. Default 5000. Set to 0 to disable " + "orphan promotion entirely."),
|
|
15927
15928
|
cold_sub_agent_threshold_ms: exports_external.number().int().nonnegative().optional().describe("JSONL-cold threshold (ms). When a running sub-agent emits no events " + "for this long, the heartbeat synthesises a turn_end for it so the " + "deferred-completion path can proceed. Default 30000. Set to 0 to " + "disable the synthetic close."),
|
|
15928
15929
|
deferred_completion_timeout_ms: exports_external.number().int().nonnegative().optional().describe("Force-close timeout (ms) for deferred sub-agent completion. After " + "the parent turn_end arrives while sub-agents are still running, the " + "card is force-closed after this many ms even if sub-agents never " + "finish. Watcher-disconnect safety net. Default 180000 (3 min)."),
|
|
15930
|
+
approval_timeout_minutes: exports_external.number().int().nonnegative().optional().describe("Operator approval-card lifetime (minutes) for the tool-use 'Allow " + "once' card and the vault grant decision wait. After this long with " + "no operator tap, the card auto-denies (a TIMEOUT, not a denial — the " + "agent is told not to retry). Default 60. hostd-gated verbs " + "(mcp__hostd__*) keep their own longer window; the hostd " + "config-propose card is not governed by this key."),
|
|
15929
15931
|
sub_agent_tick_interval_ms: exports_external.number().int().nonnegative().optional().describe("Heartbeat tick interval (ms) for sub-agent rendering. Forces a " + "re-render of the elapsed-time counter while sub-agents are running, " + "even during silent stretches between tool calls. Default 10000 (10 s). " + "Set to 0 to disable the elapsed-ticker path."),
|
|
15930
15932
|
edit_budget_threshold: exports_external.number().int().nonnegative().optional().describe("Telegram API edit budget per minute before the progress-card driver " + "falls back to a slower coalesce window. When a chat accumulates more " + "than this many card edits in the trailing 60 s, the driver switches " + "to a wider coalesce interval until the rate drops back. Default 18. " + "Increase if your gateway frequently bumps the Telegram edit-rate ceiling " + "with many parallel sub-agents; decrease for a more conservative buffer."),
|
|
15931
15933
|
stickers: exports_external.record(exports_external.string(), exports_external.string()).optional().describe("Sticker aliases for the `send_sticker` MCP tool (#576). Maps a " + "short alias name (e.g. 'happy', 'thinking') to a Telegram file_id. " + "Operator-curated — capture file_ids from inbound stickers the user " + "sends and add them here. The agent calls send_sticker(chat_id, " + "alias='happy') and the gateway resolves to the file_id at send " + "time. Aliases enable persona-flavored expressiveness without " + "exposing raw file_ids in the agent prompt. Personal-assistant / " + "health-coach personas benefit; coding agents typically don't " + "configure any."),
|
|
15932
15934
|
voice_in: exports_external.object({
|
|
15933
15935
|
enabled: exports_external.boolean().optional().describe("Master switch for voice-message transcription."),
|
|
15934
15936
|
provider: exports_external.enum(["openai"]).optional().describe("Transcription provider. Only 'openai' (Whisper API) supported in the spike (#578); " + "Groq/Deepgram/local-whisper-cli are follow-up choices."),
|
|
15935
|
-
language: exports_external.string().optional().describe("Optional ISO-639-1 language hint (e.g. 'en', 'fr'). Skips Whisper's auto-detection.")
|
|
15936
|
-
|
|
15937
|
+
language: exports_external.string().optional().describe("Optional ISO-639-1 language hint (e.g. 'en', 'fr'). Skips Whisper's auto-detection."),
|
|
15938
|
+
api_key: exports_external.string().optional().describe("Transcription-provider API key, as a `vault:<key>` reference " + "(e.g. 'vault:openai/api-key' — the default if omitted). The " + "gateway resolves it through the vault broker at use-time and " + "never writes the resolved value to disk or the agent prompt. " + "`switchroom enable voice-in` vault-stores the key and writes " + "this reference for you. A literal key is accepted but " + "discouraged — keep secrets in the vault.")
|
|
15939
|
+
}).optional().describe("Inbound voice-message transcription (#578). When enabled, voice/audio " + "messages from allowlisted users are downloaded, transcribed via the " + "configured provider, and surface to the agent as the user's text. " + "The provider API key is a `vault:` reference (`api_key`, default " + "`vault:openai/api-key`) resolved through the vault broker at " + "use-time — opt-in third-party key, honest-exception per the " + "subscription-honest outcome. Off by default — opt-in per agent. " + "Cascades from defaults.channels.telegram.voice_in. " + "(Migrated from per-agent root in #596 — see consistency unification.)"),
|
|
15940
|
+
voice_out: exports_external.object({
|
|
15941
|
+
enabled: exports_external.boolean().optional().describe("Master switch for spoken-reply (TTS) voice notes."),
|
|
15942
|
+
engine: exports_external.enum(["kokoro", "openai"]).default("kokoro").describe("Synthesis engine. 'kokoro' = local voice sidecar (POST /tts, " + "subscription-honest, no third-party key); 'openai' = OpenAI TTS " + "cloud (honest-exception, requires an `api_key` vault ref). " + "'kokoro' is only active when the host voice verdict is local " + "(SWITCHROOM_VOICE_ENGINE === 'local'); otherwise voice-out is a " + "no-op and replies stay text-only."),
|
|
15943
|
+
voice: exports_external.string().optional().describe("Engine-specific voice id (e.g. a Kokoro voice name or an OpenAI " + "voice like 'alloy'). Optional — the sidecar/engine has its own " + "default when omitted."),
|
|
15944
|
+
reply_mode: exports_external.enum(["voice+text", "voice-only", "on-demand"]).default("voice+text").describe("How the spoken reply accompanies the text. 'voice+text' sends " + "both the normal text reply and a voice note; 'voice-only' sends " + "the voice note and suppresses the text body. 'on-demand' sends " + "the text reply with a single '\uD83D\uDD0A Listen' inline button and " + "synthesizes NO audio until the user taps it — zero GPU/sidecar " + "work unless requested, which keeps the voice pipeline " + "subscription-honest and visible (nothing is generated behind the " + "user's back). The Listen button is injected ONLY when the reply " + "carries no agent-authored buttons, to avoid colliding with the " + "single_use keyboard-strip that protects agent buttons from " + "double-fire. In every mode the text reply is ALWAYS still sent " + "when synthesis fails or the reply exceeds max_chars — the user's " + "answer is never dropped silently."),
|
|
15945
|
+
max_chars: exports_external.number().int().positive().optional().describe("Per-voice-note chunk size (chars). Default 600; clamped to the " + "engine's hard cap (1200). A reply LONGER than this is NOT truncated " + "to text — it's split on sentence/paragraph boundaries and spoken " + "across several sequential voice notes, so the full answer is heard " + "even when the user can't read the screen (driving / cycling). Lower " + "it for snappier individual notes; raise it (up to 1200) for fewer, " + "longer notes."),
|
|
15946
|
+
api_key: exports_external.string().optional().describe("OpenAI TTS API key as a `vault:<key>` reference (only used when " + "engine='openai'; default 'vault:openai/api-key'). Resolved through " + "the vault broker at use-time and never written to disk or the agent " + "prompt. Ignored for engine='kokoro' (the sidecar needs no key).")
|
|
15947
|
+
}).optional().describe("Outbound spoken replies via TTS (PR-C2). When enabled, the gateway " + "synthesizes the agent's text reply into an OGG/Opus voice note and " + "sends it alongside (or instead of) the text, per reply_mode. The " + "'kokoro' engine uses the local voice sidecar (POST /tts) and is only " + "active when the host voice verdict is local; the 'openai' engine is " + "an honest-exception cloud path gated on an `api_key` vault ref. Voice " + "is best-effort and fully non-fatal — any TTS error falls back to the " + "text reply. Off by default — opt-in per agent. " + "Cascades from defaults.channels.telegram.voice_out."),
|
|
15937
15948
|
telegraph: exports_external.object({
|
|
15938
15949
|
enabled: exports_external.boolean().optional().describe("Master switch for Telegraph Instant View publishing."),
|
|
15939
15950
|
threshold: exports_external.number().int().positive().optional().describe("Char count above which a reply is published to Telegraph instead of " + "HTML-chunked into multiple Telegram messages. Default 3000 (≈3 chunks)."),
|
|
@@ -22593,7 +22604,7 @@ import { existsSync as existsSync6, readFileSync as readFileSync4 } from "node:f
|
|
|
22593
22604
|
import { dirname as dirname4, join as join2 } from "node:path";
|
|
22594
22605
|
|
|
22595
22606
|
// src/build-info.ts
|
|
22596
|
-
var VERSION = "0.16.
|
|
22607
|
+
var VERSION = "0.16.38";
|
|
22597
22608
|
|
|
22598
22609
|
// src/cli/resolve-version.ts
|
|
22599
22610
|
function readPackageVersion() {
|
|
@@ -22696,15 +22707,77 @@ function parseAuditLine(line) {
|
|
|
22696
22707
|
entry.failed_step = o.failed_step;
|
|
22697
22708
|
if (typeof o.failed_agent === "string")
|
|
22698
22709
|
entry.failed_agent = o.failed_agent;
|
|
22710
|
+
if (typeof o.agent === "string")
|
|
22711
|
+
entry.agent = o.agent;
|
|
22712
|
+
if (typeof o.n === "number")
|
|
22713
|
+
entry.n = o.n;
|
|
22714
|
+
if (typeof o.m === "number")
|
|
22715
|
+
entry.m = o.m;
|
|
22699
22716
|
if (typeof o.prior_pin === "string")
|
|
22700
22717
|
entry.prior_pin = o.prior_pin;
|
|
22701
22718
|
return entry;
|
|
22702
22719
|
}
|
|
22720
|
+
function latestRolloutRowForRequest(raw, requestId) {
|
|
22721
|
+
const ROLLOUT_OPS = new Set(["rollout", "rollout_orphaned"]);
|
|
22722
|
+
let latest = null;
|
|
22723
|
+
for (const line of raw.split(`
|
|
22724
|
+
`)) {
|
|
22725
|
+
const e = parseAuditLine(line);
|
|
22726
|
+
if (e === null)
|
|
22727
|
+
continue;
|
|
22728
|
+
if (e.request_id !== requestId)
|
|
22729
|
+
continue;
|
|
22730
|
+
if (!ROLLOUT_OPS.has(e.op))
|
|
22731
|
+
continue;
|
|
22732
|
+
latest = e;
|
|
22733
|
+
}
|
|
22734
|
+
return latest;
|
|
22735
|
+
}
|
|
22703
22736
|
|
|
22704
22737
|
// src/cli/rollout.ts
|
|
22705
22738
|
function isVersionAssertable(target) {
|
|
22706
22739
|
return /^v?\d+\.\d+\.\d+$/.test(target.trim());
|
|
22707
22740
|
}
|
|
22741
|
+
var ROLLOUT_PHASE_SENTINEL = "SWITCHROOM_ROLLOUT_PHASE:";
|
|
22742
|
+
function parseRolloutPhaseLine(line) {
|
|
22743
|
+
const trimmed = line.trim();
|
|
22744
|
+
if (!trimmed.startsWith(ROLLOUT_PHASE_SENTINEL))
|
|
22745
|
+
return null;
|
|
22746
|
+
let obj;
|
|
22747
|
+
try {
|
|
22748
|
+
obj = JSON.parse(trimmed.slice(ROLLOUT_PHASE_SENTINEL.length));
|
|
22749
|
+
} catch {
|
|
22750
|
+
return null;
|
|
22751
|
+
}
|
|
22752
|
+
if (typeof obj !== "object" || obj === null)
|
|
22753
|
+
return null;
|
|
22754
|
+
const o = obj;
|
|
22755
|
+
const PHASES = new Set([
|
|
22756
|
+
"apply",
|
|
22757
|
+
"canary-start",
|
|
22758
|
+
"canary-pass",
|
|
22759
|
+
"canary-fail",
|
|
22760
|
+
"agent-start",
|
|
22761
|
+
"agent-done",
|
|
22762
|
+
"persist-pin",
|
|
22763
|
+
"hostd-web-deferred"
|
|
22764
|
+
]);
|
|
22765
|
+
if (typeof o.phase !== "string" || !PHASES.has(o.phase))
|
|
22766
|
+
return null;
|
|
22767
|
+
if (typeof o.target !== "string")
|
|
22768
|
+
return null;
|
|
22769
|
+
const out = {
|
|
22770
|
+
phase: o.phase,
|
|
22771
|
+
target: o.target
|
|
22772
|
+
};
|
|
22773
|
+
if (typeof o.agent === "string")
|
|
22774
|
+
out.agent = o.agent;
|
|
22775
|
+
if (typeof o.n === "number")
|
|
22776
|
+
out.n = o.n;
|
|
22777
|
+
if (typeof o.m === "number")
|
|
22778
|
+
out.m = o.m;
|
|
22779
|
+
return out;
|
|
22780
|
+
}
|
|
22708
22781
|
var ROLLOUT_RESULT_SENTINEL = "SWITCHROOM_ROLLOUT_RESULT:";
|
|
22709
22782
|
function parseRolloutResultLine(stdout) {
|
|
22710
22783
|
const lines = stdout.split(`
|
|
@@ -22735,6 +22808,18 @@ import { execFileSync, spawn, spawnSync } from "node:child_process";
|
|
|
22735
22808
|
// src/agents/tmux.ts
|
|
22736
22809
|
var MAX_BYTES = 10 * 1024 * 1024;
|
|
22737
22810
|
|
|
22811
|
+
// src/agents/singleton-reconcile.ts
|
|
22812
|
+
var CORE_SINGLETON_SERVICES = [
|
|
22813
|
+
"vault-broker",
|
|
22814
|
+
"approval-kernel",
|
|
22815
|
+
"switchroom-auth-broker"
|
|
22816
|
+
];
|
|
22817
|
+
var VOICE_SIDECAR_SERVICE = "voice-sidecar";
|
|
22818
|
+
var SINGLETON_SERVICES = [
|
|
22819
|
+
...CORE_SINGLETON_SERVICES,
|
|
22820
|
+
VOICE_SIDECAR_SERVICE
|
|
22821
|
+
];
|
|
22822
|
+
|
|
22738
22823
|
// src/agents/lifecycle.ts
|
|
22739
22824
|
function containerName(name) {
|
|
22740
22825
|
return `switchroom-${name}`;
|
|
@@ -22855,6 +22940,33 @@ function isTargetPathHeader(headerPath, targetBasename) {
|
|
|
22855
22940
|
return false;
|
|
22856
22941
|
return norm === targetBasename || basename2(norm) === targetBasename;
|
|
22857
22942
|
}
|
|
22943
|
+
function normalizeDiffHeadersToBasename(unifiedDiff, targetBasename) {
|
|
22944
|
+
const rewriteHeaderLine = (line, marker) => {
|
|
22945
|
+
const rest = line.slice(marker.length);
|
|
22946
|
+
const tabIdx = rest.indexOf("\t");
|
|
22947
|
+
const pathPart = (tabIdx === -1 ? rest : rest.slice(0, tabIdx)).trim();
|
|
22948
|
+
const tail = tabIdx === -1 ? "" : rest.slice(tabIdx);
|
|
22949
|
+
if (pathPart === "/dev/null")
|
|
22950
|
+
return line;
|
|
22951
|
+
let prefix = "";
|
|
22952
|
+
let p = pathPart;
|
|
22953
|
+
if (p.startsWith("a/") || p.startsWith("b/")) {
|
|
22954
|
+
prefix = p.slice(0, 2);
|
|
22955
|
+
p = p.slice(2);
|
|
22956
|
+
}
|
|
22957
|
+
const newPath = `${prefix}${targetBasename}`;
|
|
22958
|
+
return `${marker}${newPath}${tail}`;
|
|
22959
|
+
};
|
|
22960
|
+
return unifiedDiff.split(`
|
|
22961
|
+
`).map((ln) => {
|
|
22962
|
+
if (ln.startsWith("--- "))
|
|
22963
|
+
return rewriteHeaderLine(ln, "--- ");
|
|
22964
|
+
if (ln.startsWith("+++ "))
|
|
22965
|
+
return rewriteHeaderLine(ln, "+++ ");
|
|
22966
|
+
return ln;
|
|
22967
|
+
}).join(`
|
|
22968
|
+
`);
|
|
22969
|
+
}
|
|
22858
22970
|
function validateShape(unifiedDiff, targetPath) {
|
|
22859
22971
|
const byteLen = Buffer.byteLength(unifiedDiff, "utf8");
|
|
22860
22972
|
if (byteLen > MAX_PATCH_BYTES) {
|
|
@@ -22929,11 +23041,12 @@ function applyPatch(unifiedDiff, configPath, gitBin) {
|
|
|
22929
23041
|
const liveContent = readFileSync5(configPath, "utf8");
|
|
22930
23042
|
const scratchDir = mkdtempSync(join4(tmpdir(), "config-propose-edit-"));
|
|
22931
23043
|
try {
|
|
22932
|
-
const
|
|
22933
|
-
const scratchFile = join4(scratchDir,
|
|
23044
|
+
const targetBasename = configPath.split("/").pop() ?? "switchroom.yaml";
|
|
23045
|
+
const scratchFile = join4(scratchDir, targetBasename);
|
|
22934
23046
|
writeFileSync2(scratchFile, liveContent);
|
|
23047
|
+
const normalizedDiff = normalizeDiffHeadersToBasename(unifiedDiff, targetBasename);
|
|
22935
23048
|
const patchFile = join4(scratchDir, "proposal.patch");
|
|
22936
|
-
writeFileSync2(patchFile,
|
|
23049
|
+
writeFileSync2(patchFile, normalizedDiff);
|
|
22937
23050
|
const bin = gitBin ?? "git";
|
|
22938
23051
|
const baseArgs = [
|
|
22939
23052
|
"apply",
|
|
@@ -23314,6 +23427,43 @@ function classifyBlastRadius(beforeYaml, afterYaml) {
|
|
|
23314
23427
|
};
|
|
23315
23428
|
}
|
|
23316
23429
|
|
|
23430
|
+
// src/host-control/render-rollout-status.ts
|
|
23431
|
+
function phaseLine(s) {
|
|
23432
|
+
switch (s.phase) {
|
|
23433
|
+
case "apply":
|
|
23434
|
+
return "applying — regenerating compose";
|
|
23435
|
+
case "canary-start":
|
|
23436
|
+
return `canary — restarting ${s.agent ?? "canary agent"}`;
|
|
23437
|
+
case "canary-pass":
|
|
23438
|
+
return `canary passed (${s.agent ?? "canary"}) — rolling the rest`;
|
|
23439
|
+
case "canary-fail":
|
|
23440
|
+
return `canary failed (${s.agent ?? "canary"})`;
|
|
23441
|
+
case "agent-start":
|
|
23442
|
+
return `agent ${s.n ?? "?"}/${s.m ?? "?"} — restarting ${s.agent ?? ""}`.trim();
|
|
23443
|
+
case "agent-done":
|
|
23444
|
+
return `agent ${s.n ?? "?"}/${s.m ?? "?"} — ${s.agent ?? ""} done`.trim();
|
|
23445
|
+
case "persist-pin":
|
|
23446
|
+
return "persisting pin";
|
|
23447
|
+
case "hostd-web-deferred":
|
|
23448
|
+
return "hostd/web refresh deferred (run host-side)";
|
|
23449
|
+
default:
|
|
23450
|
+
return "starting";
|
|
23451
|
+
}
|
|
23452
|
+
}
|
|
23453
|
+
function renderRolloutStatus(s) {
|
|
23454
|
+
const head = `Rollout → ${s.target}`;
|
|
23455
|
+
const rolledCount = s.rolled?.length ?? 0;
|
|
23456
|
+
if (s.terminal === "completed") {
|
|
23457
|
+
return `✅ ${head} — done. Rolled ${rolledCount} agent(s): ${(s.rolled ?? []).join(", ") || "none"}.`;
|
|
23458
|
+
}
|
|
23459
|
+
if (s.terminal === "error") {
|
|
23460
|
+
const where = s.failedStep ? ` at ${s.failedStep}${s.failedAgent ? ` (${s.failedAgent} → ${s.got ?? "unreachable"})` : ""}` : "";
|
|
23461
|
+
return `❌ ${head} — STOPPED${where}. ` + `Rolled before stop: ${(s.rolled ?? []).join(", ") || "none"}.`;
|
|
23462
|
+
}
|
|
23463
|
+
const progress = rolledCount > 0 ? ` · ${rolledCount} rolled` : "";
|
|
23464
|
+
return `⏳ ${head} — ${phaseLine(s)}${progress}`;
|
|
23465
|
+
}
|
|
23466
|
+
|
|
23317
23467
|
// src/scheduler/dispatch.ts
|
|
23318
23468
|
import { createHash as createHash4 } from "node:crypto";
|
|
23319
23469
|
function collectScheduleEntries(config) {
|
|
@@ -23424,7 +23574,7 @@ function readCachedInstallType(bindRoot) {
|
|
|
23424
23574
|
} catch {}
|
|
23425
23575
|
return payload;
|
|
23426
23576
|
}
|
|
23427
|
-
var CONFIG_APPROVAL_TIMEOUT_MS =
|
|
23577
|
+
var CONFIG_APPROVAL_TIMEOUT_MS = 60 * 60 * 1000;
|
|
23428
23578
|
function defaultApprovalId() {
|
|
23429
23579
|
return randomBytes(4).toString("hex");
|
|
23430
23580
|
}
|
|
@@ -23467,6 +23617,9 @@ class HostdServer {
|
|
|
23467
23617
|
auditChainState;
|
|
23468
23618
|
idempotencyKeys = new Map;
|
|
23469
23619
|
fleetMutationInFlight = null;
|
|
23620
|
+
get rolloutNarrator() {
|
|
23621
|
+
return this.opts.rolloutNarrator;
|
|
23622
|
+
}
|
|
23470
23623
|
constructor(opts) {
|
|
23471
23624
|
this.opts = opts;
|
|
23472
23625
|
}
|
|
@@ -23779,6 +23932,9 @@ class HostdServer {
|
|
|
23779
23932
|
case "get_status": {
|
|
23780
23933
|
const entry = this.statusByRequestId.get(req.args.target_request_id);
|
|
23781
23934
|
if (!entry) {
|
|
23935
|
+
if (callerAdmin && this.rolloutRowExistsInLog(req.args.target_request_id)) {
|
|
23936
|
+
return null;
|
|
23937
|
+
}
|
|
23782
23938
|
return `get_status: request_id not found or not visible to caller "${caller.name}"`;
|
|
23783
23939
|
}
|
|
23784
23940
|
const ownCall = entry.caller.kind === caller.kind && ("name" in entry.caller && "name" in caller ? entry.caller.name === caller.name : true);
|
|
@@ -24530,7 +24686,12 @@ class HostdServer {
|
|
|
24530
24686
|
});
|
|
24531
24687
|
}
|
|
24532
24688
|
spawnRollout(args, entry) {
|
|
24533
|
-
|
|
24689
|
+
const onLine = (line) => {
|
|
24690
|
+
const phase = parseRolloutPhaseLine(line);
|
|
24691
|
+
if (phase)
|
|
24692
|
+
this.onRolloutPhase(entry, phase);
|
|
24693
|
+
};
|
|
24694
|
+
this.runSwitchroom(args, { SWITCHROOM_HOSTD_CONTEXT: "1" }, onLine).then((res) => {
|
|
24534
24695
|
entry.exit_code = res.exit_code;
|
|
24535
24696
|
entry.finished_at = Date.now();
|
|
24536
24697
|
entry.stdout_tail = tail(res.stdout);
|
|
@@ -24559,27 +24720,111 @@ class HostdServer {
|
|
|
24559
24720
|
delete entry.prior_pin;
|
|
24560
24721
|
}).finally(() => {
|
|
24561
24722
|
this.writeTerminalAudit(entry);
|
|
24723
|
+
this.pushRolloutTerminal(entry);
|
|
24724
|
+
try {
|
|
24725
|
+
this.rolloutNarrator?.onTerminal(entry);
|
|
24726
|
+
} catch (e) {
|
|
24727
|
+
process.stderr.write(`hostd: rollout narrator onTerminal threw (non-fatal): ${e.message}
|
|
24728
|
+
`);
|
|
24729
|
+
}
|
|
24562
24730
|
if (this.fleetMutationInFlight && this.fleetMutationInFlight.request_id === entry.request_id) {
|
|
24563
24731
|
this.fleetMutationInFlight = null;
|
|
24564
24732
|
}
|
|
24565
24733
|
});
|
|
24566
24734
|
}
|
|
24735
|
+
pushRolloutTerminal(entry) {
|
|
24736
|
+
if (!this.opts.rolloutRelay)
|
|
24737
|
+
return;
|
|
24738
|
+
if (entry.caller.kind !== "agent")
|
|
24739
|
+
return;
|
|
24740
|
+
try {
|
|
24741
|
+
const text = renderRolloutStatus({
|
|
24742
|
+
target: entry.pin ?? "",
|
|
24743
|
+
rolled: entry.rolled,
|
|
24744
|
+
terminal: entry.result === "completed" ? "completed" : "error",
|
|
24745
|
+
...entry.failed_step ? { failedStep: entry.failed_step } : {},
|
|
24746
|
+
...entry.failed_agent ? { failedAgent: entry.failed_agent } : {},
|
|
24747
|
+
...entry.got !== undefined ? { got: entry.got } : {}
|
|
24748
|
+
});
|
|
24749
|
+
this.opts.rolloutRelay.postTerminal({
|
|
24750
|
+
requestId: entry.request_id,
|
|
24751
|
+
agentName: entry.caller.name,
|
|
24752
|
+
text
|
|
24753
|
+
});
|
|
24754
|
+
} catch (e) {
|
|
24755
|
+
process.stderr.write(`hostd: rollout terminal push failed (non-fatal): ${e.message}
|
|
24756
|
+
`);
|
|
24757
|
+
}
|
|
24758
|
+
}
|
|
24567
24759
|
handleGetStatus(req, _caller, started) {
|
|
24568
24760
|
const entry = this.statusByRequestId.get(req.args.target_request_id);
|
|
24569
24761
|
if (!entry) {
|
|
24762
|
+
const fromLog = this.rolloutStatusFromLog(req.request_id, req.args.target_request_id, started);
|
|
24763
|
+
if (fromLog)
|
|
24764
|
+
return fromLog;
|
|
24570
24765
|
const legacy = `get_status: internal: entry missing despite gate accept`;
|
|
24571
24766
|
const built = err("E_INTERNAL", "entry missing despite gate accept").op("get_status").build(req.request_id, Date.now() - started);
|
|
24572
24767
|
return { ...built, error: legacy };
|
|
24573
24768
|
}
|
|
24574
24769
|
return this.statusEntryToResponse(req.request_id, entry);
|
|
24575
24770
|
}
|
|
24771
|
+
rolloutRowExistsInLog(targetRequestId) {
|
|
24772
|
+
const path2 = this.auditLogPath();
|
|
24773
|
+
if (!existsSync8(path2))
|
|
24774
|
+
return false;
|
|
24775
|
+
try {
|
|
24776
|
+
const raw = readFileSync6(path2, "utf-8");
|
|
24777
|
+
return latestRolloutRowForRequest(raw, targetRequestId) !== null;
|
|
24778
|
+
} catch {
|
|
24779
|
+
return false;
|
|
24780
|
+
}
|
|
24781
|
+
}
|
|
24782
|
+
rolloutStatusFromLog(responseRequestId, targetRequestId, started) {
|
|
24783
|
+
const path2 = this.auditLogPath();
|
|
24784
|
+
if (!existsSync8(path2))
|
|
24785
|
+
return null;
|
|
24786
|
+
let raw;
|
|
24787
|
+
try {
|
|
24788
|
+
raw = readFileSync6(path2, "utf-8");
|
|
24789
|
+
} catch {
|
|
24790
|
+
return null;
|
|
24791
|
+
}
|
|
24792
|
+
const row = latestRolloutRowForRequest(raw, targetRequestId);
|
|
24793
|
+
if (!row)
|
|
24794
|
+
return null;
|
|
24795
|
+
const isTerminal = row.phase === "terminal";
|
|
24796
|
+
const payload = JSON.stringify({
|
|
24797
|
+
rolled: row.rolled ?? [],
|
|
24798
|
+
...row.phase && !isTerminal ? { phase: row.phase } : {},
|
|
24799
|
+
...row.n !== undefined ? { n: row.n } : {},
|
|
24800
|
+
...row.m !== undefined ? { m: row.m } : {},
|
|
24801
|
+
...row.agent ? { agent: row.agent } : {},
|
|
24802
|
+
...row.failed_step ? { failedStep: row.failed_step } : {},
|
|
24803
|
+
...row.failed_agent ? { failedAgent: row.failed_agent } : {},
|
|
24804
|
+
...row.pin ? { pin: row.pin } : {},
|
|
24805
|
+
...row.prior_pin ? { prior_pin: row.prior_pin } : {}
|
|
24806
|
+
});
|
|
24807
|
+
return {
|
|
24808
|
+
v: 1,
|
|
24809
|
+
request_id: responseRequestId,
|
|
24810
|
+
result: row.result,
|
|
24811
|
+
exit_code: row.exit_code,
|
|
24812
|
+
duration_ms: Date.now() - started,
|
|
24813
|
+
payload
|
|
24814
|
+
};
|
|
24815
|
+
}
|
|
24576
24816
|
statusEntryToResponse(request_id, entry) {
|
|
24577
|
-
const rolloutPayload = entry.op === "rollout" && (entry.rolled !== undefined || entry.failed_step !== undefined || entry.failed_agent !== undefined) ? JSON.stringify({
|
|
24817
|
+
const rolloutPayload = entry.op === "rollout" && (entry.rolled !== undefined || entry.failed_step !== undefined || entry.failed_agent !== undefined || entry.current_phase !== undefined) ? JSON.stringify({
|
|
24578
24818
|
rolled: entry.rolled ?? [],
|
|
24819
|
+
...entry.current_phase ? { phase: entry.current_phase } : {},
|
|
24820
|
+
...entry.phase_n !== undefined ? { n: entry.phase_n } : {},
|
|
24821
|
+
...entry.phase_m !== undefined ? { m: entry.phase_m } : {},
|
|
24822
|
+
...entry.phase_agent ? { agent: entry.phase_agent } : {},
|
|
24579
24823
|
...entry.failed_step ? { failedStep: entry.failed_step } : {},
|
|
24580
24824
|
...entry.failed_agent ? { failedAgent: entry.failed_agent } : {},
|
|
24581
24825
|
...entry.got !== undefined ? { got: entry.got } : {},
|
|
24582
|
-
...entry.pin ? { pin: entry.pin } : {}
|
|
24826
|
+
...entry.pin ? { pin: entry.pin } : {},
|
|
24827
|
+
...entry.prior_pin ? { prior_pin: entry.prior_pin } : {}
|
|
24583
24828
|
}) : undefined;
|
|
24584
24829
|
const updatePayload = entry.op === "update_apply" && (entry.deferred !== undefined || entry.channel !== undefined || entry.pin !== undefined) ? {
|
|
24585
24830
|
...entry.deferred !== undefined ? { deferred: entry.deferred } : {},
|
|
@@ -24681,7 +24926,36 @@ class HostdServer {
|
|
|
24681
24926
|
...entry.prior_pin ? { prior_pin: entry.prior_pin } : {}
|
|
24682
24927
|
});
|
|
24683
24928
|
}
|
|
24684
|
-
|
|
24929
|
+
onRolloutPhase(entry, phase) {
|
|
24930
|
+
entry.current_phase = phase.phase;
|
|
24931
|
+
entry.phase_n = phase.n;
|
|
24932
|
+
entry.phase_m = phase.m;
|
|
24933
|
+
entry.phase_agent = phase.agent;
|
|
24934
|
+
this.writePhaseAudit(entry, phase);
|
|
24935
|
+
try {
|
|
24936
|
+
this.rolloutNarrator?.onPhase(entry, phase);
|
|
24937
|
+
} catch (e) {
|
|
24938
|
+
process.stderr.write(`hostd: rollout narrator onPhase threw (non-fatal): ${e.message}
|
|
24939
|
+
`);
|
|
24940
|
+
}
|
|
24941
|
+
}
|
|
24942
|
+
async writePhaseAudit(entry, phase) {
|
|
24943
|
+
await this.appendAuditRow({
|
|
24944
|
+
ts: new Date().toISOString(),
|
|
24945
|
+
op: entry.op,
|
|
24946
|
+
phase: phase.phase,
|
|
24947
|
+
caller: entry.caller.kind === "agent" ? { kind: "agent", name: entry.caller.name } : { kind: "operator" },
|
|
24948
|
+
request_id: entry.request_id,
|
|
24949
|
+
result: "started",
|
|
24950
|
+
exit_code: null,
|
|
24951
|
+
duration_ms: Date.now() - entry.started_at,
|
|
24952
|
+
...entry.pin ? { pin: entry.pin } : {},
|
|
24953
|
+
...phase.agent !== undefined ? { agent: phase.agent } : {},
|
|
24954
|
+
...phase.n !== undefined ? { n: phase.n } : {},
|
|
24955
|
+
...phase.m !== undefined ? { m: phase.m } : {}
|
|
24956
|
+
});
|
|
24957
|
+
}
|
|
24958
|
+
runSwitchroom(args, extraEnv, onStdoutLine) {
|
|
24685
24959
|
return new Promise((resolve7, reject) => {
|
|
24686
24960
|
const bin = this.opts.switchroomBin ?? "switchroom";
|
|
24687
24961
|
const child = spawn2(bin, args, {
|
|
@@ -24690,14 +24964,49 @@ class HostdServer {
|
|
|
24690
24964
|
});
|
|
24691
24965
|
let stdout = "";
|
|
24692
24966
|
let stderr = "";
|
|
24967
|
+
let lineBuf = "";
|
|
24968
|
+
const drainLines = (flush) => {
|
|
24969
|
+
if (!onStdoutLine)
|
|
24970
|
+
return;
|
|
24971
|
+
let nl;
|
|
24972
|
+
while ((nl = lineBuf.indexOf(`
|
|
24973
|
+
`)) !== -1) {
|
|
24974
|
+
const line = lineBuf.slice(0, nl);
|
|
24975
|
+
lineBuf = lineBuf.slice(nl + 1);
|
|
24976
|
+
try {
|
|
24977
|
+
onStdoutLine(line);
|
|
24978
|
+
} catch (e) {
|
|
24979
|
+
process.stderr.write(`hostd: rollout stdout-tap threw (non-fatal): ${e.message}
|
|
24980
|
+
`);
|
|
24981
|
+
}
|
|
24982
|
+
}
|
|
24983
|
+
if (flush && lineBuf.length > 0) {
|
|
24984
|
+
const line = lineBuf;
|
|
24985
|
+
lineBuf = "";
|
|
24986
|
+
try {
|
|
24987
|
+
onStdoutLine(line);
|
|
24988
|
+
} catch (e) {
|
|
24989
|
+
process.stderr.write(`hostd: rollout stdout-tap threw (non-fatal): ${e.message}
|
|
24990
|
+
`);
|
|
24991
|
+
}
|
|
24992
|
+
}
|
|
24993
|
+
};
|
|
24693
24994
|
child.stdout.on("data", (d) => {
|
|
24694
|
-
|
|
24995
|
+
const s = d.toString("utf8");
|
|
24996
|
+
stdout += s;
|
|
24997
|
+
if (onStdoutLine) {
|
|
24998
|
+
lineBuf += s;
|
|
24999
|
+
drainLines(false);
|
|
25000
|
+
}
|
|
24695
25001
|
});
|
|
24696
25002
|
child.stderr.on("data", (d) => {
|
|
24697
25003
|
stderr += d.toString("utf8");
|
|
24698
25004
|
});
|
|
24699
25005
|
child.on("error", (err2) => reject(err2));
|
|
24700
|
-
child.on("close", (code) =>
|
|
25006
|
+
child.on("close", (code) => {
|
|
25007
|
+
drainLines(true);
|
|
25008
|
+
resolve7({ exit_code: code ?? -1, stdout, stderr });
|
|
25009
|
+
});
|
|
24701
25010
|
});
|
|
24702
25011
|
}
|
|
24703
25012
|
}
|
|
@@ -24929,6 +25238,395 @@ class SocketApprovalGateway {
|
|
|
24929
25238
|
}
|
|
24930
25239
|
}
|
|
24931
25240
|
|
|
25241
|
+
// src/host-control/rollout-relay.ts
|
|
25242
|
+
import { connect as connect2 } from "node:net";
|
|
25243
|
+
|
|
25244
|
+
class SocketRolloutRelay {
|
|
25245
|
+
opts;
|
|
25246
|
+
constructor(opts) {
|
|
25247
|
+
this.opts = opts;
|
|
25248
|
+
}
|
|
25249
|
+
postTerminal(notice) {
|
|
25250
|
+
const log = this.opts.log ?? (() => {});
|
|
25251
|
+
let sockPath;
|
|
25252
|
+
try {
|
|
25253
|
+
sockPath = this.opts.resolveGatewaySocket(notice.agentName);
|
|
25254
|
+
} catch (e) {
|
|
25255
|
+
log(`resolveGatewaySocket threw for ${notice.agentName}: ${e.message}`);
|
|
25256
|
+
return;
|
|
25257
|
+
}
|
|
25258
|
+
if (sockPath === null) {
|
|
25259
|
+
log(`no reachable gateway socket for ${notice.agentName}; dropping terminal ping`);
|
|
25260
|
+
return;
|
|
25261
|
+
}
|
|
25262
|
+
try {
|
|
25263
|
+
const client2 = connect2({ path: sockPath });
|
|
25264
|
+
client2.setTimeout(5000);
|
|
25265
|
+
const done = () => {
|
|
25266
|
+
try {
|
|
25267
|
+
client2.destroy();
|
|
25268
|
+
} catch {}
|
|
25269
|
+
};
|
|
25270
|
+
client2.on("connect", () => {
|
|
25271
|
+
try {
|
|
25272
|
+
client2.write(JSON.stringify({
|
|
25273
|
+
type: "rollout_status_post",
|
|
25274
|
+
requestId: notice.requestId,
|
|
25275
|
+
agentName: notice.agentName,
|
|
25276
|
+
text: notice.text
|
|
25277
|
+
}) + `
|
|
25278
|
+
`);
|
|
25279
|
+
client2.end();
|
|
25280
|
+
} catch (e) {
|
|
25281
|
+
log(`rollout terminal write failed (requestId=${notice.requestId}): ${e.message}`);
|
|
25282
|
+
done();
|
|
25283
|
+
}
|
|
25284
|
+
});
|
|
25285
|
+
client2.on("timeout", () => {
|
|
25286
|
+
log(`rollout terminal connect timed out (requestId=${notice.requestId})`);
|
|
25287
|
+
done();
|
|
25288
|
+
});
|
|
25289
|
+
client2.on("error", (e) => {
|
|
25290
|
+
log(`rollout terminal socket error (requestId=${notice.requestId}): ${e.message}`);
|
|
25291
|
+
done();
|
|
25292
|
+
});
|
|
25293
|
+
client2.on("close", () => {});
|
|
25294
|
+
} catch (e) {
|
|
25295
|
+
log(`rollout terminal connect threw (requestId=${notice.requestId}): ${e.message}`);
|
|
25296
|
+
}
|
|
25297
|
+
}
|
|
25298
|
+
}
|
|
25299
|
+
|
|
25300
|
+
// src/host-control/rollout-narration-relay.ts
|
|
25301
|
+
import { connect as connect3 } from "node:net";
|
|
25302
|
+
var DEFAULT_POST_REPLY_TIMEOUT_MS = 5000;
|
|
25303
|
+
|
|
25304
|
+
class SocketRolloutNarrationRelay {
|
|
25305
|
+
opts;
|
|
25306
|
+
constructor(opts) {
|
|
25307
|
+
this.opts = opts;
|
|
25308
|
+
}
|
|
25309
|
+
post(args) {
|
|
25310
|
+
const log = this.opts.log ?? (() => {});
|
|
25311
|
+
let sockPath;
|
|
25312
|
+
try {
|
|
25313
|
+
sockPath = this.opts.resolveGatewaySocket(args.agentName);
|
|
25314
|
+
} catch (e) {
|
|
25315
|
+
log(`narration resolveGatewaySocket threw: ${e.message}`);
|
|
25316
|
+
return Promise.resolve(null);
|
|
25317
|
+
}
|
|
25318
|
+
if (sockPath === null) {
|
|
25319
|
+
log(`narration post: no reachable gateway for ${args.agentName}`);
|
|
25320
|
+
return Promise.resolve(null);
|
|
25321
|
+
}
|
|
25322
|
+
return new Promise((resolve7) => {
|
|
25323
|
+
let settled = false;
|
|
25324
|
+
const finish = (v) => {
|
|
25325
|
+
if (settled)
|
|
25326
|
+
return;
|
|
25327
|
+
settled = true;
|
|
25328
|
+
try {
|
|
25329
|
+
client2.destroy();
|
|
25330
|
+
} catch {}
|
|
25331
|
+
resolve7(v);
|
|
25332
|
+
};
|
|
25333
|
+
let client2;
|
|
25334
|
+
try {
|
|
25335
|
+
client2 = connect3({ path: sockPath });
|
|
25336
|
+
} catch (e) {
|
|
25337
|
+
log(`narration post connect threw: ${e.message}`);
|
|
25338
|
+
resolve7(null);
|
|
25339
|
+
return;
|
|
25340
|
+
}
|
|
25341
|
+
client2.setTimeout(this.opts.postReplyTimeoutMs ?? DEFAULT_POST_REPLY_TIMEOUT_MS);
|
|
25342
|
+
let buffer = "";
|
|
25343
|
+
client2.on("connect", () => {
|
|
25344
|
+
try {
|
|
25345
|
+
client2.write(JSON.stringify({
|
|
25346
|
+
type: "rollout_status_post",
|
|
25347
|
+
requestId: args.requestId,
|
|
25348
|
+
agentName: args.agentName,
|
|
25349
|
+
text: args.text
|
|
25350
|
+
}) + `
|
|
25351
|
+
`);
|
|
25352
|
+
} catch (e) {
|
|
25353
|
+
log(`narration post write failed: ${e.message}`);
|
|
25354
|
+
finish(null);
|
|
25355
|
+
}
|
|
25356
|
+
});
|
|
25357
|
+
client2.on("data", (chunk2) => {
|
|
25358
|
+
buffer += chunk2.toString("utf8");
|
|
25359
|
+
const lines = buffer.split(`
|
|
25360
|
+
`);
|
|
25361
|
+
buffer = lines.pop() ?? "";
|
|
25362
|
+
for (const line of lines) {
|
|
25363
|
+
if (!line.trim())
|
|
25364
|
+
continue;
|
|
25365
|
+
let obj;
|
|
25366
|
+
try {
|
|
25367
|
+
obj = JSON.parse(line);
|
|
25368
|
+
} catch {
|
|
25369
|
+
continue;
|
|
25370
|
+
}
|
|
25371
|
+
if (obj.type === "rollout_status_posted" && obj.requestId === args.requestId) {
|
|
25372
|
+
if (obj.ok === true && typeof obj.messageId === "number") {
|
|
25373
|
+
finish(obj.messageId);
|
|
25374
|
+
} else {
|
|
25375
|
+
log(`narration post rejected by gateway (requestId=${args.requestId}): ${typeof obj.reason === "string" ? obj.reason : "no message_id"}`);
|
|
25376
|
+
finish(null);
|
|
25377
|
+
}
|
|
25378
|
+
return;
|
|
25379
|
+
}
|
|
25380
|
+
}
|
|
25381
|
+
});
|
|
25382
|
+
client2.on("timeout", () => {
|
|
25383
|
+
log(`narration post reply timed out (requestId=${args.requestId})`);
|
|
25384
|
+
finish(null);
|
|
25385
|
+
});
|
|
25386
|
+
client2.on("error", (e) => {
|
|
25387
|
+
log(`narration post socket error (requestId=${args.requestId}): ${e.message}`);
|
|
25388
|
+
finish(null);
|
|
25389
|
+
});
|
|
25390
|
+
client2.on("close", () => finish(null));
|
|
25391
|
+
});
|
|
25392
|
+
}
|
|
25393
|
+
edit(args) {
|
|
25394
|
+
const log = this.opts.log ?? (() => {});
|
|
25395
|
+
let sockPath;
|
|
25396
|
+
try {
|
|
25397
|
+
sockPath = this.opts.resolveGatewaySocket(args.agentName);
|
|
25398
|
+
} catch (e) {
|
|
25399
|
+
log(`narration edit resolveGatewaySocket threw: ${e.message}`);
|
|
25400
|
+
return;
|
|
25401
|
+
}
|
|
25402
|
+
if (sockPath === null)
|
|
25403
|
+
return;
|
|
25404
|
+
try {
|
|
25405
|
+
const client2 = connect3({ path: sockPath });
|
|
25406
|
+
client2.setTimeout(5000);
|
|
25407
|
+
const done = () => {
|
|
25408
|
+
try {
|
|
25409
|
+
client2.destroy();
|
|
25410
|
+
} catch {}
|
|
25411
|
+
};
|
|
25412
|
+
client2.on("connect", () => {
|
|
25413
|
+
try {
|
|
25414
|
+
client2.write(JSON.stringify({
|
|
25415
|
+
type: "rollout_status_edit",
|
|
25416
|
+
requestId: args.requestId,
|
|
25417
|
+
agentName: args.agentName,
|
|
25418
|
+
messageId: args.messageId,
|
|
25419
|
+
text: args.text
|
|
25420
|
+
}) + `
|
|
25421
|
+
`);
|
|
25422
|
+
client2.end();
|
|
25423
|
+
} catch (e) {
|
|
25424
|
+
log(`narration edit write failed: ${e.message}`);
|
|
25425
|
+
done();
|
|
25426
|
+
}
|
|
25427
|
+
});
|
|
25428
|
+
client2.on("timeout", done);
|
|
25429
|
+
client2.on("error", (e) => {
|
|
25430
|
+
log(`narration edit socket error: ${e.message}`);
|
|
25431
|
+
done();
|
|
25432
|
+
});
|
|
25433
|
+
client2.on("close", () => {});
|
|
25434
|
+
} catch (e) {
|
|
25435
|
+
log(`narration edit connect threw: ${e.message}`);
|
|
25436
|
+
}
|
|
25437
|
+
}
|
|
25438
|
+
}
|
|
25439
|
+
|
|
25440
|
+
// src/host-control/rollout-narrator.ts
|
|
25441
|
+
var DEFAULT_DEBOUNCE_MS = 1500;
|
|
25442
|
+
var MAX_POST_ATTEMPTS = 2;
|
|
25443
|
+
|
|
25444
|
+
class LogTailRolloutNarrator {
|
|
25445
|
+
relay;
|
|
25446
|
+
opts;
|
|
25447
|
+
states = new Map;
|
|
25448
|
+
constructor(relay, opts = {}) {
|
|
25449
|
+
this.relay = relay;
|
|
25450
|
+
this.opts = opts;
|
|
25451
|
+
}
|
|
25452
|
+
static seqFor(phase) {
|
|
25453
|
+
switch (phase.phase) {
|
|
25454
|
+
case "apply":
|
|
25455
|
+
return 0;
|
|
25456
|
+
case "canary-start":
|
|
25457
|
+
return 2 * 1 + 0;
|
|
25458
|
+
case "canary-pass":
|
|
25459
|
+
case "canary-fail":
|
|
25460
|
+
return 2 * 1 + 1;
|
|
25461
|
+
case "persist-pin":
|
|
25462
|
+
return 2 * 1 + 1;
|
|
25463
|
+
case "agent-start":
|
|
25464
|
+
return 2 * (phase.n ?? 1) + 0;
|
|
25465
|
+
case "agent-done":
|
|
25466
|
+
return 2 * (phase.n ?? 1) + 1;
|
|
25467
|
+
case "hostd-web-deferred":
|
|
25468
|
+
return Number.MAX_SAFE_INTEGER - 1;
|
|
25469
|
+
default:
|
|
25470
|
+
return 0;
|
|
25471
|
+
}
|
|
25472
|
+
}
|
|
25473
|
+
onPhase(entry, phase) {
|
|
25474
|
+
try {
|
|
25475
|
+
const st = this.ensureState(entry);
|
|
25476
|
+
if (st.frozen)
|
|
25477
|
+
return;
|
|
25478
|
+
const seq = LogTailRolloutNarrator.seqFor(phase);
|
|
25479
|
+
if (seq < st.lastAppliedSeq)
|
|
25480
|
+
return;
|
|
25481
|
+
st.lastAppliedSeq = seq;
|
|
25482
|
+
st.render = {
|
|
25483
|
+
...st.render,
|
|
25484
|
+
target: phase.target,
|
|
25485
|
+
phase: phase.phase,
|
|
25486
|
+
...phase.n !== undefined ? { n: phase.n } : {},
|
|
25487
|
+
...phase.m !== undefined ? { m: phase.m } : {},
|
|
25488
|
+
...phase.agent !== undefined ? { agent: phase.agent } : {},
|
|
25489
|
+
rolled: entry.rolled
|
|
25490
|
+
};
|
|
25491
|
+
this.scheduleRenderOrPost(entry.request_id);
|
|
25492
|
+
} catch (e) {
|
|
25493
|
+
this.opts.log?.(`onPhase threw (non-fatal): ${e.message}`);
|
|
25494
|
+
}
|
|
25495
|
+
}
|
|
25496
|
+
onTerminal(entry) {
|
|
25497
|
+
try {
|
|
25498
|
+
const st = this.ensureState(entry);
|
|
25499
|
+
st.render = {
|
|
25500
|
+
target: entry.pin ?? st.render.target,
|
|
25501
|
+
rolled: entry.rolled ?? st.render.rolled,
|
|
25502
|
+
terminal: entry.result === "completed" ? "completed" : "error",
|
|
25503
|
+
...entry.failed_step ? { failedStep: entry.failed_step } : {},
|
|
25504
|
+
...entry.failed_agent ? { failedAgent: entry.failed_agent } : {},
|
|
25505
|
+
...entry.got !== undefined ? { got: entry.got } : {}
|
|
25506
|
+
};
|
|
25507
|
+
st.frozen = true;
|
|
25508
|
+
if (st.timer) {
|
|
25509
|
+
clearTimeout(st.timer);
|
|
25510
|
+
st.timer = null;
|
|
25511
|
+
}
|
|
25512
|
+
this.flush(entry.request_id, true);
|
|
25513
|
+
setTimeout(() => this.states.delete(entry.request_id), 1e4).unref?.();
|
|
25514
|
+
} catch (e) {
|
|
25515
|
+
this.opts.log?.(`onTerminal threw (non-fatal): ${e.message}`);
|
|
25516
|
+
}
|
|
25517
|
+
}
|
|
25518
|
+
ensureState(entry) {
|
|
25519
|
+
let st = this.states.get(entry.request_id);
|
|
25520
|
+
if (!st) {
|
|
25521
|
+
const agentName = entry.caller.kind === "agent" ? entry.caller.name : "";
|
|
25522
|
+
st = {
|
|
25523
|
+
agentName,
|
|
25524
|
+
lastAppliedSeq: -1,
|
|
25525
|
+
messageId: null,
|
|
25526
|
+
posting: false,
|
|
25527
|
+
postAttempts: 0,
|
|
25528
|
+
postFailed: false,
|
|
25529
|
+
render: { target: entry.pin ?? "" },
|
|
25530
|
+
frozen: false,
|
|
25531
|
+
timer: null,
|
|
25532
|
+
pendingEditAfterPost: false
|
|
25533
|
+
};
|
|
25534
|
+
this.states.set(entry.request_id, st);
|
|
25535
|
+
}
|
|
25536
|
+
return st;
|
|
25537
|
+
}
|
|
25538
|
+
scheduleRenderOrPost(requestId) {
|
|
25539
|
+
const st = this.states.get(requestId);
|
|
25540
|
+
if (!st)
|
|
25541
|
+
return;
|
|
25542
|
+
if (st.agentName.length === 0)
|
|
25543
|
+
return;
|
|
25544
|
+
if (st.messageId !== null) {
|
|
25545
|
+
this.scheduleDebouncedEdit(requestId);
|
|
25546
|
+
return;
|
|
25547
|
+
}
|
|
25548
|
+
if (st.posting) {
|
|
25549
|
+
st.pendingEditAfterPost = true;
|
|
25550
|
+
return;
|
|
25551
|
+
}
|
|
25552
|
+
if (st.postFailed)
|
|
25553
|
+
return;
|
|
25554
|
+
this.tryPost(requestId, false);
|
|
25555
|
+
}
|
|
25556
|
+
tryPost(requestId, terminal) {
|
|
25557
|
+
const st = this.states.get(requestId);
|
|
25558
|
+
if (!st)
|
|
25559
|
+
return false;
|
|
25560
|
+
if (st.messageId !== null || st.posting)
|
|
25561
|
+
return false;
|
|
25562
|
+
if (st.postAttempts >= MAX_POST_ATTEMPTS)
|
|
25563
|
+
return false;
|
|
25564
|
+
if (st.postFailed && !terminal)
|
|
25565
|
+
return false;
|
|
25566
|
+
st.posting = true;
|
|
25567
|
+
st.postAttempts += 1;
|
|
25568
|
+
const text = renderRolloutStatus(st.render);
|
|
25569
|
+
this.relay.post({ requestId, agentName: st.agentName, text }).then((mid) => {
|
|
25570
|
+
st.posting = false;
|
|
25571
|
+
if (mid === null) {
|
|
25572
|
+
st.postFailed = true;
|
|
25573
|
+
this.opts.log?.(`rollout narration post returned no message_id (requestId=${requestId}); backing off re-posts`);
|
|
25574
|
+
return;
|
|
25575
|
+
}
|
|
25576
|
+
st.messageId = mid;
|
|
25577
|
+
st.postFailed = false;
|
|
25578
|
+
if (st.pendingEditAfterPost) {
|
|
25579
|
+
st.pendingEditAfterPost = false;
|
|
25580
|
+
this.flush(requestId, false);
|
|
25581
|
+
}
|
|
25582
|
+
}).catch((e) => {
|
|
25583
|
+
st.posting = false;
|
|
25584
|
+
st.postFailed = true;
|
|
25585
|
+
this.opts.log?.(`rollout narration post failed (non-fatal): ${e.message}`);
|
|
25586
|
+
});
|
|
25587
|
+
return true;
|
|
25588
|
+
}
|
|
25589
|
+
scheduleDebouncedEdit(requestId) {
|
|
25590
|
+
const st = this.states.get(requestId);
|
|
25591
|
+
if (!st)
|
|
25592
|
+
return;
|
|
25593
|
+
if (st.timer)
|
|
25594
|
+
return;
|
|
25595
|
+
const ms = this.opts.debounceMs ?? DEFAULT_DEBOUNCE_MS;
|
|
25596
|
+
st.timer = setTimeout(() => {
|
|
25597
|
+
st.timer = null;
|
|
25598
|
+
this.flush(requestId, true);
|
|
25599
|
+
}, ms);
|
|
25600
|
+
st.timer.unref?.();
|
|
25601
|
+
}
|
|
25602
|
+
flush(requestId, immediate) {
|
|
25603
|
+
const st = this.states.get(requestId);
|
|
25604
|
+
if (!st)
|
|
25605
|
+
return;
|
|
25606
|
+
if (st.agentName.length === 0)
|
|
25607
|
+
return;
|
|
25608
|
+
if (!immediate) {
|
|
25609
|
+
this.scheduleDebouncedEdit(requestId);
|
|
25610
|
+
return;
|
|
25611
|
+
}
|
|
25612
|
+
if (st.messageId === null) {
|
|
25613
|
+
if (st.posting) {
|
|
25614
|
+
st.pendingEditAfterPost = true;
|
|
25615
|
+
return;
|
|
25616
|
+
}
|
|
25617
|
+
this.tryPost(requestId, true);
|
|
25618
|
+
return;
|
|
25619
|
+
}
|
|
25620
|
+
const text = renderRolloutStatus(st.render);
|
|
25621
|
+
this.relay.edit({
|
|
25622
|
+
requestId,
|
|
25623
|
+
agentName: st.agentName,
|
|
25624
|
+
messageId: st.messageId,
|
|
25625
|
+
text
|
|
25626
|
+
});
|
|
25627
|
+
}
|
|
25628
|
+
}
|
|
25629
|
+
|
|
24932
25630
|
// src/host-control/release-watcher.ts
|
|
24933
25631
|
class ReleaseWatcher {
|
|
24934
25632
|
opts;
|
|
@@ -25194,14 +25892,26 @@ async function main() {
|
|
|
25194
25892
|
process.exit(2);
|
|
25195
25893
|
}
|
|
25196
25894
|
const agentsDir = process.env.SWITCHROOM_AGENTS_DIR ?? join6(homedir4(), ".switchroom", "agents");
|
|
25895
|
+
const resolveGatewaySocket = (agentName) => {
|
|
25896
|
+
const sock = resolve7(agentsDir, agentName, "telegram", "gateway.sock");
|
|
25897
|
+
return existsSync9(sock) ? sock : null;
|
|
25898
|
+
};
|
|
25197
25899
|
const approvalGateway = new SocketApprovalGateway({
|
|
25198
|
-
resolveGatewaySocket
|
|
25199
|
-
const sock = resolve7(agentsDir, agentName, "telegram", "gateway.sock");
|
|
25200
|
-
return existsSync9(sock) ? sock : null;
|
|
25201
|
-
},
|
|
25900
|
+
resolveGatewaySocket,
|
|
25202
25901
|
log: (m) => process.stderr.write(`hostd: approval-gateway — ${m}
|
|
25203
25902
|
`)
|
|
25204
25903
|
});
|
|
25904
|
+
const rolloutRelay = new SocketRolloutRelay({
|
|
25905
|
+
resolveGatewaySocket,
|
|
25906
|
+
log: (m) => process.stderr.write(`hostd: rollout-relay — ${m}
|
|
25907
|
+
`)
|
|
25908
|
+
});
|
|
25909
|
+
const rolloutNarrator = new LogTailRolloutNarrator(new SocketRolloutNarrationRelay({
|
|
25910
|
+
resolveGatewaySocket,
|
|
25911
|
+
log: (m) => process.stderr.write(`hostd: rollout-narration — ${m}
|
|
25912
|
+
`)
|
|
25913
|
+
}), { log: (m) => process.stderr.write(`hostd: rollout-narration — ${m}
|
|
25914
|
+
`) });
|
|
25205
25915
|
const server = new HostdServer({
|
|
25206
25916
|
homeDir: homedir4(),
|
|
25207
25917
|
agentUids,
|
|
@@ -25216,7 +25926,9 @@ async function main() {
|
|
|
25216
25926
|
}
|
|
25217
25927
|
} : {}
|
|
25218
25928
|
},
|
|
25219
|
-
approvalGateway
|
|
25929
|
+
approvalGateway,
|
|
25930
|
+
rolloutRelay,
|
|
25931
|
+
rolloutNarrator
|
|
25220
25932
|
});
|
|
25221
25933
|
await server.start();
|
|
25222
25934
|
const paths = server.getBoundPaths();
|