From 84c85b930b491cd102e60360a48f92c67f4e2c49 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 17 May 2026 21:12:48 -0500 Subject: [PATCH] Add manual dev restart action Co-Authored-By: Paperclip --- scripts/dev-runner.ts | 20 +++- .../src/__tests__/dev-server-status.test.ts | 31 ++++++- .../__tests__/health-dev-server-token.test.ts | 79 +++++++++++++++- server/src/dev-server-status.ts | 28 +++++- server/src/routes/health.ts | 36 +++++++- ui/src/api/health.ts | 11 +++ ui/src/components/DevRestartBanner.test.tsx | 91 +++++++++++++++++++ ui/src/components/DevRestartBanner.tsx | 39 +++++++- 8 files changed, 324 insertions(+), 11 deletions(-) create mode 100644 ui/src/components/DevRestartBanner.test.tsx diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts index e5d73794bde..8516a9c57f4 100644 --- a/scripts/dev-runner.ts +++ b/scripts/dev-runner.ts @@ -36,6 +36,7 @@ const autoRestartPollIntervalMs = 2500; const gracefulShutdownTimeoutMs = 10_000; const changedPathSampleLimit = 5; const devServerStatusFilePath = path.join(repoRoot, ".paperclip", "dev-server-status.json"); +const devServerRestartRequestFilePath = path.join(repoRoot, ".paperclip", "dev-server-restart-request.json"); const devServerStatusToken = mode === "dev" ? randomUUID() : null; const devServerStatusTokenHeader = "x-paperclip-dev-server-status-token"; @@ -70,6 +71,7 @@ const ignoredDirectoryNames = new Set([ ]); const ignoredRelativePaths = new Set([ + ".paperclip/dev-server-restart-request.json", ".paperclip/dev-server-status.json", ]); @@ -348,6 +350,13 @@ function writeDevServerStatus() { function clearDevServerStatus() { if (mode !== "dev") return; rmSync(devServerStatusFilePath, { force: true }); + rmSync(devServerRestartRequestFilePath, { force: true }); +} + +function consumeDevServerRestartRequest() { + if (mode !== "dev" || !existsSync(devServerRestartRequestFilePath)) return false; + rmSync(devServerRestartRequestFilePath, { force: true }); + return true; } async function updateDevServiceRecord(extra?: Record) { @@ -633,7 +642,8 @@ async function startServerChild() { async function maybeAutoRestartChild() { if (mode !== "dev" || restartInFlight || !child) return; - if (dirtyPaths.size === 0 && pendingMigrations.length === 0) return; + const manualRestartRequested = consumeDevServerRestartRequest(); + if (!manualRestartRequested && dirtyPaths.size === 0 && pendingMigrations.length === 0) return; restartInFlight = true; let health: { devServer?: { enabled?: boolean; autoRestartEnabled?: boolean; activeRunCount?: number } } | null = null; @@ -645,11 +655,15 @@ async function maybeAutoRestartChild() { } const devServer = health?.devServer; - if (!devServer?.enabled || devServer.autoRestartEnabled !== true) { + if (!devServer?.enabled) { restartInFlight = false; return; } - if ((devServer.activeRunCount ?? 0) > 0) { + if (!manualRestartRequested && devServer.autoRestartEnabled !== true) { + restartInFlight = false; + return; + } + if (!manualRestartRequested && (devServer.activeRunCount ?? 0) > 0) { restartInFlight = false; return; } diff --git a/server/src/__tests__/dev-server-status.test.ts b/server/src/__tests__/dev-server-status.test.ts index 52eef3870f4..052eacad73a 100644 --- a/server/src/__tests__/dev-server-status.test.ts +++ b/server/src/__tests__/dev-server-status.test.ts @@ -1,8 +1,13 @@ -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { readPersistedDevServerStatus, toDevServerHealthStatus } from "../dev-server-status.js"; +import { + getDevServerRestartRequestFilePath, + readPersistedDevServerStatus, + toDevServerHealthStatus, + writeDevServerRestartRequest, +} from "../dev-server-status.js"; const tempDirs = []; @@ -73,4 +78,26 @@ describe("dev server status helpers", () => { expect(readPersistedDevServerStatus({ PAPERCLIP_DEV_SERVER_STATUS_FILE: filePath })).toBeNull(); }); + + it("writes restart requests next to the persisted status file", () => { + const filePath = createTempStatusFile({ + dirty: true, + changedPathsSample: ["server/src/app.ts"], + pendingMigrations: [], + }); + + const env = { PAPERCLIP_DEV_SERVER_STATUS_FILE: filePath }; + expect(writeDevServerRestartRequest({ + requestedAt: "2026-03-20T12:05:00.000Z", + reason: "manual_restart_now", + }, env)).toBe(true); + + const requestPath = getDevServerRestartRequestFilePath(env); + expect(requestPath).toBe(path.join(path.dirname(filePath), "dev-server-restart-request.json")); + expect(requestPath && existsSync(requestPath)).toBe(true); + expect(JSON.parse(readFileSync(requestPath!, "utf8"))).toEqual({ + requestedAt: "2026-03-20T12:05:00.000Z", + reason: "manual_restart_now", + }); + }); }); diff --git a/server/src/__tests__/health-dev-server-token.test.ts b/server/src/__tests__/health-dev-server-token.test.ts index 7c768a35a09..536ef66c3be 100644 --- a/server/src/__tests__/health-dev-server-token.test.ts +++ b/server/src/__tests__/health-dev-server-token.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import express from "express"; @@ -126,3 +126,80 @@ describe("GET /health dev-server supervisor access", () => { } }); }); + +describe("POST /health/dev-server/restart", () => { + it("records a manual restart request for the dev runner", async () => { + const previousFile = process.env.PAPERCLIP_DEV_SERVER_STATUS_FILE; + process.env.PAPERCLIP_DEV_SERVER_STATUS_FILE = createDevServerStatusFile({ + dirty: true, + lastChangedAt: "2026-03-20T12:00:00.000Z", + changedPathCount: 1, + changedPathsSample: ["server/src/routes/health.ts"], + pendingMigrations: [], + lastRestartAt: "2026-03-20T11:30:00.000Z", + }); + + try { + const app = express(); + app.use("/health", healthRoutes(undefined)); + + const res = await request(app).post("/health/dev-server/restart"); + + expect(res.status).toBe(202); + expect(res.body).toEqual({ status: "restart_requested" }); + + const requestPath = path.join( + path.dirname(process.env.PAPERCLIP_DEV_SERVER_STATUS_FILE), + "dev-server-restart-request.json", + ); + expect(existsSync(requestPath)).toBe(true); + expect(JSON.parse(readFileSync(requestPath, "utf8"))).toMatchObject({ + reason: "manual_restart_now", + }); + } finally { + if (previousFile === undefined) { + delete process.env.PAPERCLIP_DEV_SERVER_STATUS_FILE; + } else { + process.env.PAPERCLIP_DEV_SERVER_STATUS_FILE = previousFile; + } + } + }); + + it("rejects unauthenticated manual restarts in authenticated mode", async () => { + const previousFile = process.env.PAPERCLIP_DEV_SERVER_STATUS_FILE; + process.env.PAPERCLIP_DEV_SERVER_STATUS_FILE = createDevServerStatusFile({ + dirty: true, + changedPathCount: 1, + changedPathsSample: ["server/src/routes/health.ts"], + pendingMigrations: [], + }); + + try { + const app = express(); + app.use((req, _res, next) => { + (req as any).actor = { type: "none", source: "none" }; + next(); + }); + app.use( + "/health", + healthRoutes(undefined, { + deploymentMode: "authenticated", + deploymentExposure: "private", + authReady: true, + companyDeletionEnabled: true, + }), + ); + + const res = await request(app).post("/health/dev-server/restart"); + + expect(res.status).toBe(403); + expect(res.body).toEqual({ error: "board_access_required" }); + } finally { + if (previousFile === undefined) { + delete process.env.PAPERCLIP_DEV_SERVER_STATUS_FILE; + } else { + process.env.PAPERCLIP_DEV_SERVER_STATUS_FILE = previousFile; + } + } + }); +}); diff --git a/server/src/dev-server-status.ts b/server/src/dev-server-status.ts index ec78bfe8c4e..2e20a441f0a 100644 --- a/server/src/dev-server-status.ts +++ b/server/src/dev-server-status.ts @@ -1,4 +1,5 @@ -import { existsSync, readFileSync, statSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import path from "node:path"; const MAX_PERSISTED_DEV_SERVER_STATUS_BYTES = 64 * 1024; @@ -25,6 +26,31 @@ export type DevServerHealthStatus = { lastRestartAt: string | null; }; +export type DevServerRestartRequest = { + requestedAt: string; + reason: "manual_restart_now"; +}; + +export function getDevServerRestartRequestFilePath( + env: NodeJS.ProcessEnv = process.env, +): string | null { + const statusFilePath = env.PAPERCLIP_DEV_SERVER_STATUS_FILE?.trim(); + if (!statusFilePath) return null; + return path.join(path.dirname(statusFilePath), "dev-server-restart-request.json"); +} + +export function writeDevServerRestartRequest( + request: DevServerRestartRequest, + env: NodeJS.ProcessEnv = process.env, +): boolean { + const filePath = getDevServerRestartRequestFilePath(env); + if (!filePath) return false; + + mkdirSync(path.dirname(filePath), { recursive: true }); + writeFileSync(filePath, `${JSON.stringify(request, null, 2)}\n`, "utf8"); + return true; +} + function normalizeStringArray(value: unknown): string[] { if (!Array.isArray(value)) return []; return value diff --git a/server/src/routes/health.ts b/server/src/routes/health.ts index 388585a5bdf..80a2dd7016e 100644 --- a/server/src/routes/health.ts +++ b/server/src/routes/health.ts @@ -4,7 +4,7 @@ import type { Db } from "@paperclipai/db"; import { and, count, eq, gt, inArray, isNull, sql } from "drizzle-orm"; import { heartbeatRuns, instanceUserRoles, invites } from "@paperclipai/db"; import type { DeploymentExposure, DeploymentMode } from "@paperclipai/shared"; -import { readPersistedDevServerStatus, toDevServerHealthStatus } from "../dev-server-status.js"; +import { readPersistedDevServerStatus, toDevServerHealthStatus, writeDevServerRestartRequest } from "../dev-server-status.js"; import { logger } from "../middleware/logger.js"; import { instanceSettingsService } from "../services/instance-settings.js"; import { serverVersion } from "../version.js"; @@ -44,6 +44,40 @@ export function healthRoutes( ) { const router = Router(); + router.post("/dev-server/restart", async (req, res) => { + const actorType = "actor" in req ? req.actor?.type : null; + if (opts.deploymentMode === "authenticated" && actorType !== "board") { + res.status(403).json({ error: "board_access_required" }); + return; + } + + const persistedDevServerStatus = readPersistedDevServerStatus(); + if (!persistedDevServerStatus) { + res.status(404).json({ error: "dev_server_supervisor_unavailable" }); + return; + } + + const restartRequired = + persistedDevServerStatus.dirty || + persistedDevServerStatus.changedPathCount > 0 || + persistedDevServerStatus.pendingMigrations.length > 0; + if (!restartRequired) { + res.status(409).json({ error: "restart_not_required" }); + return; + } + + const written = writeDevServerRestartRequest({ + requestedAt: new Date().toISOString(), + reason: "manual_restart_now", + }); + if (!written) { + res.status(404).json({ error: "dev_server_supervisor_unavailable" }); + return; + } + + res.status(202).json({ status: "restart_requested" }); + }); + router.get("/", async (req, res) => { const actorType = "actor" in req ? req.actor?.type : null; const exposeFullDetails = shouldExposeFullHealthDetails( diff --git a/ui/src/api/health.ts b/ui/src/api/health.ts index e2725b209d7..453662c5c8a 100644 --- a/ui/src/api/health.ts +++ b/ui/src/api/health.ts @@ -38,4 +38,15 @@ export const healthApi = { } return res.json(); }, + requestDevServerRestart: async (): Promise => { + const res = await fetch("/api/health/dev-server/restart", { + method: "POST", + credentials: "include", + headers: { Accept: "application/json" }, + }); + if (!res.ok) { + const payload = await res.json().catch(() => null) as { error?: string } | null; + throw new Error(payload?.error ?? `Failed to request restart (${res.status})`); + } + }, }; diff --git a/ui/src/components/DevRestartBanner.test.tsx b/ui/src/components/DevRestartBanner.test.tsx new file mode 100644 index 00000000000..f9e6809151b --- /dev/null +++ b/ui/src/components/DevRestartBanner.test.tsx @@ -0,0 +1,91 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { DevRestartBanner } from "./DevRestartBanner"; + +const mockHealthApi = vi.hoisted(() => ({ + requestDevServerRestart: vi.fn(), +})); + +vi.mock("../api/health", () => ({ + healthApi: mockHealthApi, +})); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +let root: ReturnType | null = null; +let container: HTMLDivElement | null = null; + +const devServer = { + enabled: true as const, + restartRequired: true, + reason: "backend_changes" as const, + lastChangedAt: "2026-03-20T12:00:00.000Z", + changedPathCount: 1, + changedPathsSample: ["server/src/routes/health.ts"], + pendingMigrations: [], + autoRestartEnabled: true, + activeRunCount: 1, + waitingForIdle: true, + lastRestartAt: "2026-03-20T11:30:00.000Z", +}; + +beforeEach(() => { + vi.spyOn(window, "confirm").mockReturnValue(true); + vi.spyOn(window, "alert").mockImplementation(() => undefined); + mockHealthApi.requestDevServerRestart.mockResolvedValue(undefined); +}); + +afterEach(() => { + if (root) { + act(() => root?.unmount()); + } + root = null; + container?.remove(); + container = null; + vi.restoreAllMocks(); + mockHealthApi.requestDevServerRestart.mockReset(); +}); + +function render() { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + act(() => root?.render()); + return container; +} + +describe("DevRestartBanner", () => { + it("confirms and requests an immediate restart while waiting for live runs", async () => { + const node = render(); + const button = [...node.querySelectorAll("button")] + .find((entry) => entry.textContent?.includes("Restart now")); + + expect(node.textContent).toContain("Waiting for 1 live run to finish"); + expect(button).toBeTruthy(); + + await act(async () => { + button?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(window.confirm).toHaveBeenCalledWith("Restart Paperclip now? This may interrupt 1 live run."); + expect(mockHealthApi.requestDevServerRestart).toHaveBeenCalledTimes(1); + expect(node.textContent).toContain("Restart requested"); + }); + + it("does not request restart when confirmation is declined", async () => { + vi.mocked(window.confirm).mockReturnValue(false); + const node = render(); + const button = [...node.querySelectorAll("button")] + .find((entry) => entry.textContent?.includes("Restart now")); + + await act(async () => { + button?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(mockHealthApi.requestDevServerRestart).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/src/components/DevRestartBanner.tsx b/ui/src/components/DevRestartBanner.tsx index 2ff666d9bf5..d7c36e32d5e 100644 --- a/ui/src/components/DevRestartBanner.tsx +++ b/ui/src/components/DevRestartBanner.tsx @@ -1,5 +1,6 @@ +import { useState } from "react"; import { AlertTriangle, RotateCcw, TimerReset } from "lucide-react"; -import type { DevServerHealthStatus } from "../api/health"; +import { healthApi, type DevServerHealthStatus } from "../api/health"; function formatRelativeTimestamp(value: string | null): string | null { if (!value) return null; @@ -27,10 +28,31 @@ function describeReason(devServer: DevServerHealthStatus): string { } export function DevRestartBanner({ devServer }: { devServer?: DevServerHealthStatus }) { + const [restartPending, setRestartPending] = useState(false); if (!devServer?.enabled || !devServer.restartRequired) return null; + const currentDevServer = devServer; const changedAt = formatRelativeTimestamp(devServer.lastChangedAt); const sample = devServer.changedPathsSample.slice(0, 3); + const activeRunLabel = `${devServer.activeRunCount} live run${ + devServer.activeRunCount === 1 ? "" : "s" + }`; + + async function requestRestartNow() { + const warning = + currentDevServer.activeRunCount > 0 + ? `Restart Paperclip now? This may interrupt ${activeRunLabel}.` + : "Restart Paperclip now?"; + if (!window.confirm(warning)) return; + + setRestartPending(true); + try { + await healthApi.requestDevServerRestart(); + } catch (error) { + setRestartPending(false); + window.alert(error instanceof Error ? error.message : "Failed to request restart"); + } + } return (
@@ -65,11 +87,11 @@ export function DevRestartBanner({ devServer }: { devServer?: DevServerHealthSta
-
+
{devServer.waitingForIdle ? (
- Waiting for {devServer.activeRunCount} live run{devServer.activeRunCount === 1 ? "" : "s"} to finish + Waiting for {activeRunLabel} to finish
) : devServer.autoRestartEnabled ? (
@@ -82,6 +104,17 @@ export function DevRestartBanner({ devServer }: { devServer?: DevServerHealthSta Restart pnpm dev:once after the active work is safe to interrupt
)} +