diff --git a/apps/memos-local-plugin/server/routes/admin.ts b/apps/memos-local-plugin/server/routes/admin.ts index f70d52b77..08cec2367 100644 --- a/apps/memos-local-plugin/server/routes/admin.ts +++ b/apps/memos-local-plugin/server/routes/admin.ts @@ -15,6 +15,13 @@ * For Hermes, terminate the active `hermes chat`, then ask the bridge * to shut down gracefully. launchd/systemd owns replacement when the * viewer is supervised; portable viewers retain the detached fallback. + * + * Windows portable installs are a special case: `pkill` and `bash` + * are unavailable, so the historical spawn-then-suicide sequence + * would kill this daemon with no replacement waiting. On Windows + * (without a detectable supervisor) we refuse the restart, keep + * the daemon alive, and return `manualRestartRequired: true` so + * the viewer can prompt the operator to restart Hermes themselves. */ import { spawn } from "node:child_process"; import type { ServerDeps, ServerOptions } from "../types.js"; @@ -27,8 +34,16 @@ export function registerAdminRoutes(routes: Routes, deps: ServerDeps, options: S return { ok: false, error: "database path not configured" }; } const agent = options.agent ?? "unknown"; + const supervised = isSupervisorManaged(options); + const platform = resolvePlatform(options); + // Windows portable install has no pkill/bash equivalent, so we cannot + // kill the chat or spawn a replacement daemon. Wipe the DB and leave + // the process alive; the Hermes chat is NOT killed (no pkill available) + // and the user must restart Hermes manually to obtain a clean state. + const windowsManual = agent === "hermes" && !supervised && isWindowsPlatform(platform); + let killedHermes = false; - if (agent === "hermes") { + if (agent === "hermes" && !windowsManual) { // The viewer daemon and an active Hermes chat have separate Node // bridges. Kill the chat first so its stdio bridge releases any // SQLite handle before we unlink the DB files. @@ -44,7 +59,18 @@ export function registerAdminRoutes(routes: Routes, deps: ServerDeps, options: S if (agent === "hermes" && deps.home?.root) { try { await fs.unlink(`${deps.home.root}/bridge-status.json`); } catch { /* may not exist */ } } - if (agent !== "openclaw" && !isSupervisorManaged(options)) { + if (windowsManual) { + return { + ok: true, + restarting: false, + killedHermes, + manualRestartRequired: true, + platform, + message: + "Data cleared. Restart Hermes manually to re-open the Memory Viewer.", + }; + } + if (agent !== "openclaw" && !supervised) { // Portable Hermes: there is no supervisor to replace this process. await spawnReplacementDaemon(agent); } @@ -58,14 +84,36 @@ export function registerAdminRoutes(routes: Routes, deps: ServerDeps, options: S routes.set("POST /api/v1/admin/restart", async (_ctx) => { const agent = options.agent ?? "unknown"; + const supervised = isSupervisorManaged(options); + const platform = resolvePlatform(options); + if (agent === "openclaw") { setTimeout(() => process.exit(0), 300); return { ok: true, restarting: true }; } if (agent === "hermes") { + if (!supervised && isWindowsPlatform(platform)) { + // Windows without a supervisor: there is no reliable way to + // spawn a replacement (no pkill, no bash) and self-shutdown + // would leave the viewer permanently dark. Keep the daemon + // alive and tell the client to prompt for a manual restart. + // ok: true mirrors the clear-data Windows path so typed SDK + // wrappers that treat `ok` as a success discriminant classify + // this "intentional decline" identically across both routes. + return { + ok: true, + restarting: false, + manualRestartRequired: true, + platform, + message: + "Restart is unavailable on Windows without a supervisor. " + + "Close and reopen Hermes to apply changes.", + }; + } + const killed = await terminateHermesChat(); - if (!isSupervisorManaged(options)) { + if (!supervised) { await spawnReplacementDaemon(agent); } scheduleHermesShutdown(options, 200); @@ -92,10 +140,26 @@ export function isSupervisorManagedProcess( return Boolean(env.INVOCATION_ID?.trim()); } +/** + * True when the current (or provided) platform is Windows. + * + * Exposed for the admin routes and their tests so we can unit-test the + * Windows guard without spoofing `process.platform`. + */ +export function isWindowsPlatform( + platform: NodeJS.Platform = process.platform, +): boolean { + return platform === "win32"; +} + function isSupervisorManaged(options: ServerOptions): boolean { return options.lifecycle?.supervised ?? isSupervisorManagedProcess(); } +function resolvePlatform(options: ServerOptions): NodeJS.Platform { + return options.lifecycle?.platform ?? process.platform; +} + function scheduleHermesShutdown(options: ServerOptions, delayMs: number): void { setTimeout(() => { if (options.lifecycle?.requestShutdown) { diff --git a/apps/memos-local-plugin/server/types.ts b/apps/memos-local-plugin/server/types.ts index 9667e8a99..ffed8cbf7 100644 --- a/apps/memos-local-plugin/server/types.ts +++ b/apps/memos-local-plugin/server/types.ts @@ -45,13 +45,26 @@ export interface ServerOptions { * A supervised Hermes viewer must let launchd/systemd perform the * replacement; spawning a second daemon from the route races the * supervisor's KeepAlive restart. Tests and portable embedders may - * override the auto-detection and shutdown action explicitly. + * override the auto-detection, shutdown action, or platform explicitly. */ lifecycle?: { /** Override launchd/systemd detection. */ supervised?: boolean; /** Request graceful host shutdown after the HTTP response is returned. */ requestShutdown?: () => void; + /** + * Override the runtime platform. Defaults to `process.platform`. + * + * The admin restart route uses this to decide whether the current + * process runs on Windows, where the historical `pkill` + `bash` + * replacement path is a no-op (which used to leave the daemon dead + * with nothing to respawn it). Tests inject this to exercise the + * Windows guard without spoofing `process.platform` globally. + * + * Grouped with `supervised`/`requestShutdown` because they are the + * same category of process/environment override. + */ + platform?: NodeJS.Platform; }; } diff --git a/apps/memos-local-plugin/tests/unit/server/admin.test.ts b/apps/memos-local-plugin/tests/unit/server/admin.test.ts index dc026650e..9661084f0 100644 --- a/apps/memos-local-plugin/tests/unit/server/admin.test.ts +++ b/apps/memos-local-plugin/tests/unit/server/admin.test.ts @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { MemoryCore } from "../../../agent-contract/memory-core.js"; import { isSupervisorManagedProcess, + isWindowsPlatform, registerAdminRoutes, } from "../../../server/routes/admin.js"; import { Routes } from "../../../server/routes/registry.js"; @@ -74,7 +75,7 @@ describe("admin lifecycle routes", () => { { core: {} as MemoryCore }, { agent: "hermes", - lifecycle: { supervised: false, requestShutdown }, + lifecycle: { supervised: false, requestShutdown, platform: "linux" }, }, ); @@ -107,4 +108,108 @@ describe("admin lifecycle routes", () => { expect(isSupervisorManagedProcess({ XPC_SERVICE_NAME: "0" })).toBe(false); expect(isSupervisorManagedProcess({})).toBe(false); }); + + it("recognises Windows via the isWindowsPlatform helper", () => { + expect(isWindowsPlatform("win32")).toBe(true); + expect(isWindowsPlatform("linux")).toBe(false); + expect(isWindowsPlatform("darwin")).toBe(false); + }); + + it("refuses restart on Windows portable and never kills the daemon", async () => { + const requestShutdown = vi.fn(); + const routes = new Routes(); + registerAdminRoutes( + routes, + { core: {} as MemoryCore }, + { + agent: "hermes", + lifecycle: { supervised: false, requestShutdown, platform: "win32" }, + }, + ); + + const restart = routes.getExact("POST /api/v1/admin/restart"); + expect(restart).toBeDefined(); + + const result = await restart!({} as never); + + expect(result).toMatchObject({ + ok: true, + restarting: false, + manualRestartRequired: true, + platform: "win32", + }); + // No pkill, no bash — those are what corrupt the flow on Windows. + expect(spawnMock).not.toHaveBeenCalled(); + + // Advance past every scheduled shutdown window; the daemon must stay alive. + await vi.advanceTimersByTimeAsync(5_000); + expect(requestShutdown).not.toHaveBeenCalled(); + }); + + it("still self-shuts on Windows when a supervisor is present", async () => { + const requestShutdown = vi.fn(); + const routes = new Routes(); + registerAdminRoutes( + routes, + { core: {} as MemoryCore }, + { + agent: "hermes", + lifecycle: { supervised: true, requestShutdown, platform: "win32" }, + }, + ); + + const restart = routes.getExact("POST /api/v1/admin/restart"); + const result = await restart!({} as never); + + expect(result).toMatchObject({ ok: true, restarting: true }); + // Even on Windows, when a supervisor exists (e.g. NSSM-wrapped service), + // shutting down is safe because the supervisor respawns us. + expect(spawnMock).not.toHaveBeenCalledWith( + "bash", + expect.anything(), + expect.anything(), + ); + + await vi.advanceTimersByTimeAsync(200); + expect(requestShutdown).toHaveBeenCalledOnce(); + }); + + it("clear-data on Windows portable wipes DB files without killing the daemon", async () => { + const requestShutdown = vi.fn(); + const shutdown = vi.fn().mockResolvedValue(undefined); + const routes = new Routes(); + + registerAdminRoutes( + routes, + { + core: { shutdown } as unknown as MemoryCore, + home: { + root: "/does/not/exist/nowhere", + dbFile: "/does/not/exist/nowhere/db.sqlite", + }, + }, + { + agent: "hermes", + lifecycle: { supervised: false, requestShutdown, platform: "win32" }, + }, + ); + + const clear = routes.getExact("POST /api/v1/admin/clear-data"); + expect(clear).toBeDefined(); + const result = await clear!({} as never); + + expect(result).toMatchObject({ + ok: true, + restarting: false, + manualRestartRequired: true, + }); + // pkill was not attempted, bash was not attempted. + expect(spawnMock).not.toHaveBeenCalled(); + // MemoryCore.shutdown() still fires so SQLite handles are released + // before the user manually restarts Hermes. + expect(shutdown).toHaveBeenCalledOnce(); + + await vi.advanceTimersByTimeAsync(5_000); + expect(requestShutdown).not.toHaveBeenCalled(); + }); }); diff --git a/apps/memos-local-plugin/tests/unit/viewer/restart.test.ts b/apps/memos-local-plugin/tests/unit/viewer/restart.test.ts index 88e8e426e..7e08e9efa 100644 --- a/apps/memos-local-plugin/tests/unit/viewer/restart.test.ts +++ b/apps/memos-local-plugin/tests/unit/viewer/restart.test.ts @@ -13,7 +13,11 @@ const fakeWindow = { }; import { health } from "../../../viewer/src/stores/health"; -import { triggerRestart } from "../../../viewer/src/stores/restart"; +import { + restartState, + triggerCleared, + triggerRestart, +} from "../../../viewer/src/stores/restart"; describe("viewer restart flow", () => { const originalFetch = globalThis.fetch; @@ -22,6 +26,7 @@ describe("viewer restart flow", () => { vi.useFakeTimers(); fakeWindow.location.href = ""; health.value = { ok: true, agent: "hermes" }; + restartState.value = { phase: "idle" }; }); afterEach(() => { @@ -50,4 +55,49 @@ describe("viewer restart flow", () => { expect(healthChecks).toBe(2); expect(fakeWindow.location.href).toMatch(/^\/\?_t=\d+$/); }); + + it("shows the manual restart state returned by Windows restart", async () => { + globalThis.fetch = vi.fn(async () => + new Response( + JSON.stringify({ + ok: true, + restarting: false, + manualRestartRequired: true, + message: "Close and reopen Hermes.", + }), + { status: 200 }, + ), + ) as typeof fetch; + + await triggerRestart(); + + expect(restartState.value).toEqual({ + phase: "manualRestartRequired", + message: "Close and reopen Hermes.", + }); + expect(globalThis.fetch).toHaveBeenCalledOnce(); + expect(fakeWindow.location.href).toBe(""); + }); + + it("shows the manual restart state returned by Windows clear-data", async () => { + globalThis.fetch = vi.fn(async () => + new Response(null, { status: 200 }), + ) as typeof fetch; + + const clearing = triggerCleared({ + ok: true, + restarting: false, + manualRestartRequired: true, + message: "Restart Hermes manually.", + }); + await vi.runAllTimersAsync(); + await clearing; + + expect(restartState.value).toEqual({ + phase: "manualRestartRequired", + message: "Restart Hermes manually.", + }); + expect(globalThis.fetch).not.toHaveBeenCalled(); + expect(fakeWindow.location.href).toBe(""); + }); }); diff --git a/apps/memos-local-plugin/viewer/src/components/RestartOverlay.tsx b/apps/memos-local-plugin/viewer/src/components/RestartOverlay.tsx index c1aea0701..af0906cd5 100644 --- a/apps/memos-local-plugin/viewer/src/components/RestartOverlay.tsx +++ b/apps/memos-local-plugin/viewer/src/components/RestartOverlay.tsx @@ -16,19 +16,27 @@ function FullScreenSpinner() { const s = restartState.value; const agentType = health.value?.agent === "openclaw" ? "openclaw" : "hermes"; - const message = - s.phase === "restartFailed" - ? t("restart.failed") - : s.phase === "waitingUp" - ? t("restart.waitingUp") - : agentType === "hermes" - ? t("restart.restarting.hermes") - : t("restart.restarting"); + const isTerminal = s.phase === "restartFailed" || s.phase === "manualRestartRequired"; - const hint = - s.phase === "restartFailed" - ? t(`restart.failedHint.${agentType}` as any) - : t("restart.autoRefresh"); + let message: string; + if (s.phase === "manualRestartRequired") { + message = s.message || t("restart.manualRequired"); + } else if (s.phase === "restartFailed") { + message = t("restart.failed"); + } else if (s.phase === "waitingUp") { + message = t("restart.waitingUp"); + } else { + message = agentType === "hermes" ? t("restart.restarting.hermes") : t("restart.restarting"); + } + + let hint: string; + if (s.phase === "manualRestartRequired") { + hint = t("restart.manualRequiredHint"); + } else if (s.phase === "restartFailed") { + hint = t(`restart.failedHint.${agentType}` as any); + } else { + hint = t("restart.autoRefresh"); + } return (
- {s.phase !== "restartFailed" ? ( + {!isTerminal ? (
{message}
{hint}
- {s.phase === "restartFailed" && ( + {isTerminal && (