diff --git a/apps/macos/src/main/local-mode.ts b/apps/macos/src/main/local-mode.ts index 4d4e6bc1181..8aabadd1962 100644 --- a/apps/macos/src/main/local-mode.ts +++ b/apps/macos/src/main/local-mode.ts @@ -11,6 +11,7 @@ import { resolveLockfilePaths, runHatch, runRetire, + runWake, upsertLockfileAssistant, type CliInvocation, type LockfileWriteResult, @@ -54,6 +55,11 @@ interface RetireResult { error?: string; } +interface WakeResult { + ok: boolean; + error?: string; +} + /** * Resolve how to invoke the CLI. Precedence: * 1. `VELLUM_CLI_PATH` env var override @@ -115,6 +121,22 @@ async function retire(assistantId: string): Promise { return result.ok ? { ok: true } : { ok: false, error: result.error }; } +/** + * Wake (start/restart) a local assistant's daemon and gateway, re-seeding its + * guardian token. The non-destructive repair primitive. Mirrors `hatch`'s + * never-reject contract. + */ +async function wake(assistantId: string): Promise { + let invocation: CliInvocation; + try { + invocation = await resolveCliInvocation(); + } catch (err) { + return { ok: false, error: (err as Error).message }; + } + const result = await runWake(invocation, assistantId); + return result.ok ? { ok: true } : { ok: false, error: result.error }; +} + // A persisted assistant entry as it crosses the IPC boundary. The // package's lockfile parser owns the real field-level contract; here we // only assert the renderer sent an object, so unknown/forward-compat @@ -187,6 +209,11 @@ export const installLocalMode = (): void => { return retire(assistantId); }); + handle("vellum:localMode:wake", assistantIdArgs, ([assistantId]) => { + if (!assistantId) return { ok: false, error: "Missing assistantId" }; + return wake(assistantId); + }); + handle( "vellum:localMode:guardianToken", assistantIdArgs, diff --git a/apps/macos/src/preload/index.ts b/apps/macos/src/preload/index.ts index ce5e72bca0c..28851a5bbd4 100644 --- a/apps/macos/src/preload/index.ts +++ b/apps/macos/src/preload/index.ts @@ -163,6 +163,13 @@ export interface VellumBridge { * on failure rather than rejecting. */ retire(assistantId: string): Promise<{ ok: boolean; error?: string }>; + /** + * Wake (start/restart) a local assistant's daemon and gateway via the + * Vellum CLI's `wake`, re-seeding its guardian token. The non-destructive + * repair primitive used to recover a stopped or mis-seeded assistant in + * place. Mirrors `retire`'s never-reject contract. + */ + wake(assistantId: string): Promise<{ ok: boolean; error?: string }>; /** * Acquire a fresh guardian access token for a local assistant, reading * the token file from disk and refreshing it via the CLI when expired. @@ -290,6 +297,11 @@ const bridge: VellumBridge = { "vellum:localMode:replacePlatformAssistants", platformAssistants, ) as Promise, + wake: (assistantId: string) => + ipcRenderer.invoke("vellum:localMode:wake", assistantId) as Promise<{ + ok: boolean; + error?: string; + }>, retire: (assistantId: string) => ipcRenderer.invoke("vellum:localMode:retire", assistantId) as Promise<{ ok: boolean; diff --git a/apps/web/src/lib/local-mode-repair.test.ts b/apps/web/src/lib/local-mode-repair.test.ts new file mode 100644 index 00000000000..24f73da79d1 --- /dev/null +++ b/apps/web/src/lib/local-mode-repair.test.ts @@ -0,0 +1,126 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; + +import { useLockfileStore } from "@/stores/lockfile-store"; +import type { Lockfile, LockfileAssistant } from "@/runtime/local-mode-host"; + +// The wrapper under test orchestrates the real connect primitive, so we drive +// its external seams rather than the primitive itself: the guardian-token read +// (which decides success/failure) and the wake repair call. Everything else in +// the primitive (gateway token exchange, self-hosted connection write) is +// stubbed to no-op so a successful prime resolves cleanly. +const host = await import("@/runtime/local-mode-host"); + +let primeShouldSucceed: () => boolean; +let fetchGuardianTokenHost = mock(async (_id: string) => "tok"); +let wakeLocalAssistantHost = mock(async (_id: string) => ({ ok: true })); + +mock.module("@/runtime/local-mode-host", () => ({ + ...host, + fetchGuardianTokenHost: (id: string) => fetchGuardianTokenHost(id), + wakeLocalAssistantHost: (id: string) => wakeLocalAssistantHost(id), +})); + +mock.module("@/lib/auth/gateway-session", () => ({ + clearGatewayToken: () => {}, + ensureGatewayToken: async () => {}, + getGatewayToken: () => "gateway-tok", + getLocalTokenUrl: () => "http://127.0.0.1:7830/token", +})); + +mock.module("@/lib/self-hosted/connection", () => ({ + setSelfHostedConnection: () => {}, +})); + +const { GuardianTokenError } = host; +const { primeLocalGatewayConnectionWithRepair } = await import("@/lib/local-mode"); + +const localAssistant: LockfileAssistant = { + assistantId: "local-a", + cloud: "local", + resources: { gatewayPort: 7830 }, +} as LockfileAssistant; + +function selectLocalAssistant(): void { + const lockfile: Lockfile = { + assistants: [localAssistant], + activeAssistant: "local-a", + }; + useLockfileStore.setState({ lockfile }); + localStorage.setItem("vellum:local:selected-assistant", "local-a"); +} + +beforeEach(() => { + primeShouldSucceed = () => true; + fetchGuardianTokenHost = mock(async (_id: string) => { + if (!primeShouldSucceed()) throw new GuardianTokenError(404, "token gone"); + return "tok"; + }); + wakeLocalAssistantHost = mock(async (_id: string) => ({ ok: true })); + selectLocalAssistant(); +}); + +afterEach(() => { + useLockfileStore.setState({ lockfile: null }); + localStorage.clear(); +}); + +describe("primeLocalGatewayConnectionWithRepair", () => { + test("a clean first attempt never wakes the assistant", async () => { + await primeLocalGatewayConnectionWithRepair(); + expect(wakeLocalAssistantHost).not.toHaveBeenCalled(); + }); + + test("a repairable failure wakes once, then retries and succeeds", async () => { + let attempts = 0; + // Fail the first prime (missing token), succeed once wake has run. + primeShouldSucceed = () => attempts++ > 0; + + await primeLocalGatewayConnectionWithRepair(); + + expect(wakeLocalAssistantHost).toHaveBeenCalledTimes(1); + expect(wakeLocalAssistantHost).toHaveBeenCalledWith("local-a"); + // One failing attempt + one succeeding retry. + expect(fetchGuardianTokenHost).toHaveBeenCalledTimes(2); + }); + + test("a still-failing retry surfaces the original error and wakes only once", async () => { + primeShouldSucceed = () => false; + + const err = await primeLocalGatewayConnectionWithRepair().catch( + (e: unknown) => e, + ); + + expect(err).toBeInstanceOf(GuardianTokenError); + expect(wakeLocalAssistantHost).toHaveBeenCalledTimes(1); + }); + + test("a failed wake surfaces the original error without retrying", async () => { + primeShouldSucceed = () => false; + wakeLocalAssistantHost = mock(async () => ({ + ok: false, + error: "no sibling env", + })); + + const err = await primeLocalGatewayConnectionWithRepair().catch( + (e: unknown) => e, + ); + + expect(err).toBeInstanceOf(GuardianTokenError); + // The first prime failed and wake failed — the connection is never retried. + expect(fetchGuardianTokenHost).toHaveBeenCalledTimes(1); + }); + + test("a non-repairable 403 surfaces immediately and never wakes", async () => { + fetchGuardianTokenHost = mock(async () => { + throw new GuardianTokenError(403, "forbidden"); + }); + + const err = await primeLocalGatewayConnectionWithRepair().catch( + (e: unknown) => e, + ); + + expect(err).toBeInstanceOf(GuardianTokenError); + expect((err as InstanceType).status).toBe(403); + expect(wakeLocalAssistantHost).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/lib/local-mode.ts b/apps/web/src/lib/local-mode.ts index 074e64b7992..129e015cb32 100644 --- a/apps/web/src/lib/local-mode.ts +++ b/apps/web/src/lib/local-mode.ts @@ -13,11 +13,13 @@ import { setSelfHostedConnection } from "@/lib/self-hosted/connection"; import { useLockfileStore } from "@/stores/lockfile-store"; import { fetchGuardianTokenHost, + GuardianTokenError, loadLockfileHost, parseLockfile, replacePlatformAssistantsHost, retireLocalAssistantHost, saveLockfileAssistantHost, + wakeLocalAssistantHost, } from "@/runtime/local-mode-host"; import type { Lockfile, @@ -281,3 +283,41 @@ export async function primeLocalGatewayConnection(): Promise { token: getGatewayToken(), }); } + +/** + * Classify a connect failure as repairable by `wake`. A `403` means the host + * refused the loopback boundary — a security decision wake can't change — so + * it surfaces as-is. Every other failure (a missing/expired/malformed guardian + * token, or an unreachable or stopped gateway) is something `wake` can fix by + * re-seeding the token and restarting the daemon + gateway. + */ +function isRepairableConnectError(error: unknown): boolean { + if (error instanceof GuardianTokenError) return error.status !== 403; + return true; +} + +/** + * Prime the local gateway connection, transparently repairing the assistant in + * place when the first attempt fails for a repairable reason. + * + * This mirrors the native client's bootstrap, which re-pairs a stopped, + * expired, or mis-seeded local assistant before the failure ever reaches the + * user: on a repairable failure it runs `wake` (re-seeds the guardian token + * and restarts the daemon + gateway, leaving the assistant's data and identity + * untouched), then primes the connection once more. A non-repairable failure, + * a wake that itself fails, or a still-failing retry propagate the original + * error so the existing connect-error UI surfaces it unchanged. + */ +export async function primeLocalGatewayConnectionWithRepair(): Promise { + try { + await primeLocalGatewayConnection(); + return; + } catch (error) { + if (!isRepairableConnectError(error)) throw error; + const assistantId = getSelectedAssistant()?.assistantId; + if (!assistantId) throw error; + const repair = await wakeLocalAssistantHost(assistantId); + if (!repair.ok) throw error; + await primeLocalGatewayConnection(); + } +} diff --git a/apps/web/src/runtime/is-electron.ts b/apps/web/src/runtime/is-electron.ts index c6bfdb5c5a1..4ac14054a67 100644 --- a/apps/web/src/runtime/is-electron.ts +++ b/apps/web/src/runtime/is-electron.ts @@ -73,6 +73,10 @@ declare global { platformAssistants: Array>, ): Promise; retire(assistantId: string): Promise<{ ok: boolean; error?: string }>; + // Optional: older Electron shells predate the wake IPC channel. The + // macOS app and web bundle don't release together, so a newer renderer + // can run against an older preload; callers must guard on its presence. + wake?(assistantId: string): Promise<{ ok: boolean; error?: string }>; guardianToken( assistantId: string, ): Promise< diff --git a/apps/web/src/runtime/local-mode-host.test.ts b/apps/web/src/runtime/local-mode-host.test.ts index 66b92062202..24f95c433f8 100644 --- a/apps/web/src/runtime/local-mode-host.test.ts +++ b/apps/web/src/runtime/local-mode-host.test.ts @@ -13,6 +13,7 @@ const { saveLockfileAssistantHost, replacePlatformAssistantsHost, retireLocalAssistantHost, + wakeLocalAssistantHost, fetchGuardianTokenHost, } = await import("./local-mode-host"); @@ -208,6 +209,41 @@ describe("retireLocalAssistantHost", () => { }); }); +describe("wakeLocalAssistantHost", () => { + test("web/dev host POSTs the assistant id to the wake middleware", async () => { + const fetchMock = mock(async () => ({ json: async () => ({ ok: true }) })); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + expect(await wakeLocalAssistantHost("a-1")).toEqual({ ok: true }); + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + expect(url).toBe("/assistant/__local/wake"); + expect(init.method).toBe("POST"); + expect(JSON.parse(init.body as string)).toEqual({ assistantId: "a-1" }); + }); + + test("Electron host wakes through the bridge and never touches fetch", async () => { + const wake = mock(async () => ({ ok: true })); + const fetchMock = mock(async () => { + throw new Error("fetch must not run on the Electron branch"); + }); + globalThis.fetch = fetchMock as unknown as typeof fetch; + setElectronBridge({ wake }); + + expect(await wakeLocalAssistantHost("a-1")).toEqual({ ok: true }); + expect(wake).toHaveBeenCalledWith("a-1"); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test("older Electron shell without the wake channel reports an unsupported failure", async () => { + // The macOS app and web bundle don't release together: a newer renderer + // can run against a preload that predates the wake IPC channel. + setElectronBridge({}); + + const result = await wakeLocalAssistantHost("a-1"); + expect(result.ok).toBe(false); + }); +}); + describe("fetchGuardianTokenHost", () => { test("web/dev host GETs the guardian-token middleware and returns the access token", async () => { const fetchMock = mock(async () => ({ diff --git a/apps/web/src/runtime/local-mode-host.ts b/apps/web/src/runtime/local-mode-host.ts index f8027b1bcc7..8af5b025544 100644 --- a/apps/web/src/runtime/local-mode-host.ts +++ b/apps/web/src/runtime/local-mode-host.ts @@ -62,6 +62,11 @@ export interface LocalRetireResult { error?: string; } +export interface LocalWakeResult { + ok: boolean; + error?: string; +} + /** * Thrown by {@link fetchGuardianTokenHost} when a host returns a structured * guardian-token failure. Carries the host's `status` so callers can branch on @@ -188,6 +193,37 @@ export async function retireLocalAssistantHost( return res.json() as Promise; } +/** + * Wake (start/restart) a local assistant's daemon and gateway, re-seeding its + * guardian token. Both hosts drive the Vellum CLI's `wake` in a trusted + * process and return the same `{ ok, error }` contract. + * + * This is the non-destructive repair primitive: it revives a stopped or + * mis-seeded assistant in place without touching its data or identity, the + * counterpart to {@link retireLocalAssistantHost}'s destructive removal. + * Older Electron hosts that predate this IPC channel resolve `wake` as + * `undefined`; callers treat that as a no-op repair and fall through to the + * underlying connect error. + */ +export async function wakeLocalAssistantHost( + assistantId: string, +): Promise { + if (isElectron()) { + const wake = window.vellum!.localMode.wake; + if (!wake) { + return { ok: false, error: "Wake is not supported by this app version" }; + } + return wake(assistantId); + } + + const res = await fetch("/assistant/__local/wake", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ assistantId }), + }); + return res.json() as Promise; +} + /** * Acquire a fresh guardian access token for a local assistant, used to * authorize the gateway token exchange. Reading the token file and refreshing diff --git a/apps/web/src/stores/auth-store.test.ts b/apps/web/src/stores/auth-store.test.ts index f5d0e2e2826..b63eb5a7497 100644 --- a/apps/web/src/stores/auth-store.test.ts +++ b/apps/web/src/stores/auth-store.test.ts @@ -22,6 +22,9 @@ const setSelectedAssistantIdMock = mock((_id: string) => {}); const primeLocalGatewayConnectionMock = mock(async () => { if (mockPrimeError) throw mockPrimeError; }); +const primeLocalGatewayConnectionWithRepairMock = mock(async () => { + if (mockPrimeError) throw mockPrimeError; +}); const syncOnboardingUserMock = mock((_userId: string | null) => {}); const clearOnboardingFlagsMock = mock(() => {}); const clearOrganizationMock = mock(() => {}); @@ -68,6 +71,8 @@ mock.module("@/lib/local-mode", () => ({ clearSelectedAssistant: () => {}, setSelectedAssistantId: setSelectedAssistantIdMock, primeLocalGatewayConnection: primeLocalGatewayConnectionMock, + primeLocalGatewayConnectionWithRepair: + primeLocalGatewayConnectionWithRepairMock, syncPlatformAssistantsToLockfile: async () => {}, })); @@ -146,6 +151,7 @@ beforeEach(() => { mockPrimeError = null; setSelectedAssistantIdMock.mockClear(); primeLocalGatewayConnectionMock.mockClear(); + primeLocalGatewayConnectionWithRepairMock.mockClear(); syncOnboardingUserMock.mockClear(); clearOnboardingFlagsMock.mockClear(); clearOrganizationMock.mockClear(); @@ -352,7 +358,7 @@ describe("connectLocalAssistant", () => { await useAuthStore.getState().connectLocalAssistant("local-a"); expect(setSelectedAssistantIdMock).toHaveBeenCalledWith("local-a"); - expect(primeLocalGatewayConnectionMock).toHaveBeenCalledTimes(1); + expect(primeLocalGatewayConnectionWithRepairMock).toHaveBeenCalledTimes(1); expect(useAuthStore.getState().isLoggedIn).toBe(true); expect(useAuthStore.getState().user?.id).toBe("gateway-local"); // No platform assistants — the probe resolves synchronously. diff --git a/apps/web/src/stores/auth-store.ts b/apps/web/src/stores/auth-store.ts index cdfb75d6b14..3ec4b1031ae 100644 --- a/apps/web/src/stores/auth-store.ts +++ b/apps/web/src/stores/auth-store.ts @@ -34,6 +34,7 @@ import { clearSelectedAssistant, setSelectedAssistantId, primeLocalGatewayConnection, + primeLocalGatewayConnectionWithRepair, syncPlatformAssistantsToLockfile, } from "@/lib/local-mode"; import { listAssistants } from "@/assistant/api"; @@ -303,13 +304,15 @@ const useAuthStoreBase = create()((set) => ({ * Unlike {@link AuthActions.initSession}, which is the best-effort boot * probe and swallows failures, this rethrows so the caller can surface the * reason — including the typed `GuardianTokenError` from the host seam — and - * offer recovery instead of dead-ending. Both paths share - * `primeLocalGatewayConnection`, so the guardian-token and gateway exchange - * happen exactly once per connect. + * offer recovery instead of dead-ending. It primes through + * `primeLocalGatewayConnectionWithRepair`, which self-heals a stopped or + * mis-seeded assistant via `wake` before surfacing any error — matching the + * native client's re-pair-on-connect bootstrap. The boot probe deliberately + * stays on the plain primitive so app launch never spawns daemon processes. */ connectLocalAssistant: async (assistantId: string) => { setSelectedAssistantId(assistantId); - await primeLocalGatewayConnection(); + await primeLocalGatewayConnectionWithRepair(); set({ isLoggedIn: true, isLoading: false, user: GATEWAY_LOCAL_USER }); if (!isLocalMode() || getPlatformAssistants().length > 0) { probePlatformSession(set); diff --git a/apps/web/vite-plugin-local-mode.ts b/apps/web/vite-plugin-local-mode.ts index b21ac35114a..1f1f029bf40 100644 --- a/apps/web/vite-plugin-local-mode.ts +++ b/apps/web/vite-plugin-local-mode.ts @@ -12,6 +12,7 @@ import { replacePlatformAssistants, runHatch, runRetire, + runWake, getGuardianAccessToken, resolveGatewayProxyTarget, readAllowedGatewayPorts, @@ -46,6 +47,7 @@ export function localModePlugin(env: Record): Plugin { server.middlewares.use(lockfileMiddleware(config.lockfilePaths)); server.middlewares.use(hatchMiddleware(baseDir)); server.middlewares.use(retireMiddleware(baseDir)); + server.middlewares.use(wakeMiddleware(baseDir)); server.middlewares.use( guardianTokenMiddleware(config.configDir, baseDir, env), ); @@ -307,6 +309,72 @@ function retireMiddleware(baseDir: string): Connect.NextHandleFunction { }; } +function wakeMiddleware(baseDir: string): Connect.NextHandleFunction { + return (req, res, next) => { + if (req.url !== "/assistant/__local/wake" && req.url !== "/__local/wake") + return next(); + + if (rejectUnlessLoopback(req, res)) return; + + if (req.method !== "POST") { + res.statusCode = 405; + res.end(); + return; + } + + const chunks: Buffer[] = []; + req.on("data", (chunk: Buffer) => chunks.push(chunk)); + req.on("end", () => { + let assistantId: string | undefined; + if (chunks.length > 0) { + try { + const body = JSON.parse(Buffer.concat(chunks).toString()) as { + assistantId?: string; + }; + assistantId = body.assistantId; + } catch { + res.statusCode = 400; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({ ok: false, error: "Invalid JSON body" })); + return; + } + } + + if (!assistantId) { + res.statusCode = 400; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({ ok: false, error: "Missing assistantId" })); + return; + } + + let invocation: CliInvocation; + try { + invocation = resolveDevCliInvocation(baseDir, import.meta.url); + } catch (err) { + res.statusCode = 500; + res.setHeader("Content-Type", "application/json"); + res.end( + JSON.stringify({ + ok: false, + error: err instanceof Error ? err.message : String(err), + }), + ); + return; + } + + runWake(invocation, assistantId).then((result) => { + res.statusCode = result.ok ? 200 : result.status; + res.setHeader("Content-Type", "application/json"); + res.end( + JSON.stringify( + result.ok ? { ok: true } : { ok: false, error: result.error }, + ), + ); + }); + }); + }; +} + function guardianTokenMiddleware( configDir: string, baseDir: string, diff --git a/packages/local-mode/src/__tests__/wake.test.ts b/packages/local-mode/src/__tests__/wake.test.ts new file mode 100644 index 00000000000..00bbf2746e8 --- /dev/null +++ b/packages/local-mode/src/__tests__/wake.test.ts @@ -0,0 +1,66 @@ +import { afterEach, beforeAll, describe, expect, mock, test } from "bun:test"; +import { EventEmitter } from "node:events"; + +import type { CliInvocation } from "../util"; + +class FakeChild extends EventEmitter { + stdout = new EventEmitter(); + stderr = new EventEmitter(); + kill = mock(() => true); +} + +let lastChild: FakeChild; +const spawnArgs: Array<[string, string[]]> = []; +const spawnMock = mock((command: string, args: string[]) => { + spawnArgs.push([command, args]); + lastChild = new FakeChild(); + return lastChild; +}); + +mock.module("node:child_process", () => ({ spawn: spawnMock })); + +let runWake: typeof import("../wake").runWake; + +beforeAll(async () => { + ({ runWake } = await import("../wake")); +}); + +afterEach(() => { + spawnArgs.length = 0; + spawnMock.mockClear(); +}); + +const invocation: CliInvocation = { command: "bun", baseArgs: ["run", "cli"] }; + +describe("runWake", () => { + test("spawns the CLI wake command for the assistant and resolves ok on exit 0", async () => { + const pending = runWake(invocation, "asst-42"); + lastChild.emit("close", 0); + + expect(await pending).toEqual({ ok: true }); + expect(spawnArgs[0]).toEqual(["bun", ["run", "cli", "wake", "asst-42"]]); + }); + + test("a non-zero exit resolves to a failure carrying the CLI's output", async () => { + const pending = runWake(invocation, "asst-42"); + lastChild.stderr.emit("data", Buffer.from("no sibling environment to seed from")); + lastChild.emit("close", 1); + + expect(await pending).toEqual({ + ok: false, + status: 500, + error: "no sibling environment to seed from", + }); + }); + + test("a spawn failure resolves to a failure rather than rejecting", async () => { + const pending = runWake(invocation, "asst-42"); + lastChild.emit("error", new Error("ENOENT")); + + const result = await pending; + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain("ENOENT"); + } + }); +}); diff --git a/packages/local-mode/src/index.ts b/packages/local-mode/src/index.ts index 1fd3fcbda0f..9798ecf70ca 100644 --- a/packages/local-mode/src/index.ts +++ b/packages/local-mode/src/index.ts @@ -1,7 +1,7 @@ /** * @vellumai/local-mode — shared host library for serving the local-assistant * surface (lockfile reads, guardian-token issuance, gateway proxying, and the - * hatch/retire lifecycle ops) over a loopback HTTP boundary. Consumed by the + * hatch/retire/wake lifecycle ops) over a loopback HTTP boundary. Consumed by the * CLI `client` server and the web app's dev-server middleware so the local * endpoint behaviour is defined exactly once instead of one host reaching into * another's source tree. Depends only on `@vellumai/environments`. @@ -32,6 +32,8 @@ export { runHatch } from "./hatch"; export type { HatchResult } from "./hatch"; export { runRetire } from "./retire"; export type { RetireResult } from "./retire"; +export { runWake } from "./wake"; +export type { WakeResult } from "./wake"; export { getGuardianAccessToken } from "./guardian-token"; export type { TokenResult } from "./guardian-token"; export { diff --git a/packages/local-mode/src/wake.ts b/packages/local-mode/src/wake.ts new file mode 100644 index 00000000000..d9bcdb475e5 --- /dev/null +++ b/packages/local-mode/src/wake.ts @@ -0,0 +1,78 @@ +import { spawn } from "node:child_process"; + +import type { CliInvocation } from "./util"; + +// `wake` cold-starts a stopped assistant, so it can legitimately run far +// longer than a teardown like `retire`: the CLI waits up to 60s for the daemon +// to answer (plus another 60s if it falls back to a source daemon) and up to +// 30s for the gateway. The wrapper timeout is a safety net for a truly hung +// process, so it must sit above those documented readiness windows — otherwise +// a slow-but-succeeding wake gets killed and misreported as a timeout. +const WAKE_TIMEOUT_MS = 180_000; + +export type WakeResult = + | { ok: true } + | { ok: false; status: number; error: string }; + +/** + * Start (or restart) a local assistant's daemon and gateway via the CLI's + * `wake`, which also re-seeds the guardian token from a sibling environment. + * + * This is the non-destructive repair primitive: it revives a stopped or + * mis-seeded local assistant in place without touching its data or identity, + * the same way the native client re-pairs on a failed connection. Mirrors + * {@link runRetire}'s never-reject contract so each host wires transport once + * and surfaces a structured failure rather than a thrown error. + */ +export function runWake( + invocation: CliInvocation, + assistantId: string, +): Promise { + return new Promise((resolve) => { + const child = spawn( + invocation.command, + [...invocation.baseArgs, "wake", assistantId], + { stdio: ["ignore", "pipe", "pipe"] }, + ); + + let stdout = ""; + let stderr = ""; + let done = false; + + const finish = (result: WakeResult) => { + if (done) return; + done = true; + clearTimeout(timeout); + resolve(result); + }; + + const timeout = setTimeout(() => { + child.kill("SIGTERM"); + finish({ + ok: false, + status: 500, + error: `Wake timed out after ${WAKE_TIMEOUT_MS / 1000} seconds`, + }); + }, WAKE_TIMEOUT_MS); + + child.stdout.on("data", (data: Buffer) => { + stdout += data.toString(); + }); + + child.stderr.on("data", (data: Buffer) => { + stderr += data.toString(); + }); + + child.on("close", (code) => { + if (code === 0) { + finish({ ok: true }); + } else { + finish({ ok: false, status: 500, error: stderr || stdout }); + } + }); + + child.on("error", (err) => { + finish({ ok: false, status: 500, error: `Failed to spawn CLI: ${err.message}` }); + }); + }); +}