Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 67 additions & 3 deletions apps/memos-local-plugin/server/routes/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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.
Expand All @@ -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);
}
Expand All @@ -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);
Expand All @@ -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) {
Expand Down
15 changes: 14 additions & 1 deletion apps/memos-local-plugin/server/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
}

Expand Down
107 changes: 106 additions & 1 deletion apps/memos-local-plugin/tests/unit/server/admin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -74,7 +75,7 @@ describe("admin lifecycle routes", () => {
{ core: {} as MemoryCore },
{
agent: "hermes",
lifecycle: { supervised: false, requestShutdown },
lifecycle: { supervised: false, requestShutdown, platform: "linux" },
},
);

Expand Down Expand Up @@ -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();
});
});
52 changes: 51 additions & 1 deletion apps/memos-local-plugin/tests/unit/viewer/restart.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -22,6 +26,7 @@ describe("viewer restart flow", () => {
vi.useFakeTimers();
fakeWindow.location.href = "";
health.value = { ok: true, agent: "hermes" };
restartState.value = { phase: "idle" };
});

afterEach(() => {
Expand Down Expand Up @@ -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("");
});
});
36 changes: 22 additions & 14 deletions apps/memos-local-plugin/viewer/src/components/RestartOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div
Expand All @@ -47,7 +55,7 @@ function FullScreenSpinner() {
gap:16px;max-width:400px;text-align:center;
`}
>
{s.phase !== "restartFailed" ? (
{!isTerminal ? (
<div
style={`
width:36px;height:36px;
Expand All @@ -62,7 +70,7 @@ function FullScreenSpinner() {
)}
<div style="font-size:15px;font-weight:600">{message}</div>
<div style="font-size:12px;opacity:.6">{hint}</div>
{s.phase === "restartFailed" && (
{isTerminal && (
<button
class="btn btn--ghost btn--sm"
onClick={dismissRestartBanner}
Expand Down
Loading
Loading