diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index b39839c1..42e70fff 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -39,6 +39,8 @@ This starts: `pnpm dev` runs the server in watch mode and restarts on changes from workspace packages (including adapter packages). Use `pnpm dev:once` to run without file watching. +`pnpm dev:once` now tracks backend-relevant file changes and pending migrations. When the current boot is stale, the board UI shows a `Restart required` banner. You can also enable guarded auto-restart in `Instance Settings > Experimental`, which waits for queued/running local agent runs to finish before restarting the dev server. + Tailscale/private-auth dev mode: ```sh diff --git a/packages/shared/src/types/instance.ts b/packages/shared/src/types/instance.ts index 3449f46d..562c55b3 100644 --- a/packages/shared/src/types/instance.ts +++ b/packages/shared/src/types/instance.ts @@ -4,6 +4,7 @@ export interface InstanceGeneralSettings { export interface InstanceExperimentalSettings { enableIsolatedWorkspaces: boolean; + autoRestartDevServerWhenIdle: boolean; } export interface InstanceSettings { diff --git a/packages/shared/src/validators/instance.ts b/packages/shared/src/validators/instance.ts index 5511e655..05ee4323 100644 --- a/packages/shared/src/validators/instance.ts +++ b/packages/shared/src/validators/instance.ts @@ -8,6 +8,7 @@ export const patchInstanceGeneralSettingsSchema = instanceGeneralSettingsSchema. export const instanceExperimentalSettingsSchema = z.object({ enableIsolatedWorkspaces: z.boolean().default(false), + autoRestartDevServerWhenIdle: z.boolean().default(false), }).strict(); export const patchInstanceExperimentalSettingsSchema = instanceExperimentalSettingsSchema.partial(); diff --git a/scripts/dev-runner.mjs b/scripts/dev-runner.mjs index 391ddb44..3df034e0 100644 --- a/scripts/dev-runner.mjs +++ b/scripts/dev-runner.mjs @@ -1,10 +1,54 @@ #!/usr/bin/env node import { spawn } from "node:child_process"; +import { existsSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs"; +import path from "node:path"; import { createInterface } from "node:readline/promises"; import { stdin, stdout } from "node:process"; +import { fileURLToPath } from "node:url"; const mode = process.argv[2] === "watch" ? "watch" : "dev"; const cliArgs = process.argv.slice(3); +const scanIntervalMs = 1500; +const autoRestartPollIntervalMs = 2500; +const gracefulShutdownTimeoutMs = 10_000; +const changedPathSampleLimit = 5; +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const devServerStatusFilePath = path.join(repoRoot, ".paperclip", "dev-server-status.json"); + +const watchedDirectories = [ + ".paperclip", + "cli", + "scripts", + "server", + "packages/adapter-utils", + "packages/adapters", + "packages/db", + "packages/plugins/sdk", + "packages/shared", +].map((relativePath) => path.join(repoRoot, relativePath)); + +const watchedFiles = [ + ".env", + "package.json", + "pnpm-workspace.yaml", + "tsconfig.base.json", + "tsconfig.json", + "vitest.config.ts", +].map((relativePath) => path.join(repoRoot, relativePath)); + +const ignoredDirectoryNames = new Set([ + ".git", + ".turbo", + ".vite", + "coverage", + "dist", + "node_modules", + "ui-dist", +]); + +const ignoredRelativePaths = new Set([ + ".paperclip/dev-server-status.json", +]); const tailscaleAuthFlagNames = new Set([ "--tailscale-auth", @@ -34,6 +78,10 @@ const env = { PAPERCLIP_UI_DEV_MIDDLEWARE: "true", }; +if (mode === "dev") { + env.PAPERCLIP_DEV_SERVER_STATUS_FILE = devServerStatusFilePath; +} + if (mode === "watch") { env.PAPERCLIP_MIGRATION_PROMPT ??= "never"; env.PAPERCLIP_MIGRATION_AUTO_APPLY ??= "true"; @@ -50,6 +98,19 @@ if (tailscaleAuth) { } const pnpmBin = process.platform === "win32" ? "pnpm.cmd" : "pnpm"; +let previousSnapshot = collectWatchedSnapshot(); +let dirtyPaths = new Set(); +let pendingMigrations = []; +let lastChangedAt = null; +let lastRestartAt = null; +let scanInFlight = false; +let restartInFlight = false; +let shuttingDown = false; +let childExitWasExpected = false; +let child = null; +let childExitPromise = null; +let scanTimer = null; +let autoRestartTimer = null; function toError(error, context = "Dev runner command failed") { if (error instanceof Error) return error; @@ -82,9 +143,110 @@ function formatPendingMigrationSummary(migrations) { : migrations.join(", "); } +function exitForSignal(signal) { + if (signal === "SIGINT") { + process.exit(130); + } + if (signal === "SIGTERM") { + process.exit(143); + } + process.exit(1); +} + +function toRelativePath(absolutePath) { + return path.relative(repoRoot, absolutePath).split(path.sep).join("/"); +} + +function readSignature(absolutePath) { + const stats = statSync(absolutePath); + return `${Math.trunc(stats.mtimeMs)}:${stats.size}`; +} + +function addFileToSnapshot(snapshot, absolutePath) { + const relativePath = toRelativePath(absolutePath); + if (ignoredRelativePaths.has(relativePath)) return; + snapshot.set(relativePath, readSignature(absolutePath)); +} + +function walkDirectory(snapshot, absoluteDirectory) { + if (!existsSync(absoluteDirectory)) return; + + for (const entry of readdirSync(absoluteDirectory, { withFileTypes: true })) { + if (ignoredDirectoryNames.has(entry.name)) continue; + + const absolutePath = path.join(absoluteDirectory, entry.name); + if (entry.isDirectory()) { + walkDirectory(snapshot, absolutePath); + continue; + } + if (entry.isFile() || entry.isSymbolicLink()) { + addFileToSnapshot(snapshot, absolutePath); + } + } +} + +function collectWatchedSnapshot() { + const snapshot = new Map(); + + for (const absoluteDirectory of watchedDirectories) { + walkDirectory(snapshot, absoluteDirectory); + } + for (const absoluteFile of watchedFiles) { + if (!existsSync(absoluteFile)) continue; + addFileToSnapshot(snapshot, absoluteFile); + } + + return snapshot; +} + +function diffSnapshots(previous, next) { + const changed = new Set(); + + for (const [relativePath, signature] of next) { + if (previous.get(relativePath) !== signature) { + changed.add(relativePath); + } + } + for (const relativePath of previous.keys()) { + if (!next.has(relativePath)) { + changed.add(relativePath); + } + } + + return [...changed].sort(); +} + +function ensureDevStatusDirectory() { + mkdirSync(path.dirname(devServerStatusFilePath), { recursive: true }); +} + +function writeDevServerStatus() { + if (mode !== "dev") return; + + ensureDevStatusDirectory(); + const changedPaths = [...dirtyPaths].sort(); + writeFileSync( + devServerStatusFilePath, + `${JSON.stringify({ + dirty: changedPaths.length > 0 || pendingMigrations.length > 0, + lastChangedAt, + changedPathCount: changedPaths.length, + changedPathsSample: changedPaths.slice(0, changedPathSampleLimit), + pendingMigrations, + lastRestartAt, + }, null, 2)}\n`, + "utf8", + ); +} + +function clearDevServerStatus() { + if (mode !== "dev") return; + rmSync(devServerStatusFilePath, { force: true }); +} + async function runPnpm(args, options = {}) { return await new Promise((resolve, reject) => { - const child = spawn(pnpmBin, args, { + const spawned = spawn(pnpmBin, args, { stdio: options.stdio ?? ["ignore", "pipe", "pipe"], env: options.env ?? process.env, shell: process.platform === "win32", @@ -93,19 +255,19 @@ async function runPnpm(args, options = {}) { let stdoutBuffer = ""; let stderrBuffer = ""; - if (child.stdout) { - child.stdout.on("data", (chunk) => { + if (spawned.stdout) { + spawned.stdout.on("data", (chunk) => { stdoutBuffer += String(chunk); }); } - if (child.stderr) { - child.stderr.on("data", (chunk) => { + if (spawned.stderr) { + spawned.stderr.on("data", (chunk) => { stderrBuffer += String(chunk); }); } - child.on("error", reject); - child.on("exit", (code, signal) => { + spawned.on("error", reject); + spawned.on("exit", (code, signal) => { resolve({ code: code ?? 0, signal, @@ -116,9 +278,7 @@ async function runPnpm(args, options = {}) { }); } -async function maybePreflightMigrations() { - if (mode !== "watch") return; - +async function getMigrationStatusPayload() { const status = await runPnpm( ["--filter", "@paperclipai/db", "exec", "tsx", "src/migration-status.ts", "--json"], { env }, @@ -132,9 +292,8 @@ async function maybePreflightMigrations() { process.exit(status.code); } - let payload; try { - payload = JSON.parse(status.stdout.trim()); + return JSON.parse(status.stdout.trim()); } catch (error) { process.stderr.write( status.stderr || @@ -143,15 +302,31 @@ async function maybePreflightMigrations() { ); throw toError(error, "Unable to parse migration-status JSON output"); } +} - if (payload.status !== "needsMigrations" || payload.pendingMigrations.length === 0) { +async function refreshPendingMigrations() { + const payload = await getMigrationStatusPayload(); + pendingMigrations = + payload.status === "needsMigrations" && Array.isArray(payload.pendingMigrations) + ? payload.pendingMigrations.filter((entry) => typeof entry === "string" && entry.trim().length > 0) + : []; + writeDevServerStatus(); + return payload; +} + +async function maybePreflightMigrations(options = {}) { + const interactive = options.interactive ?? mode === "watch"; + const autoApply = options.autoApply ?? env.PAPERCLIP_MIGRATION_AUTO_APPLY === "true"; + const exitOnDecline = options.exitOnDecline ?? mode === "watch"; + + const payload = await refreshPendingMigrations(); + if (payload.status !== "needsMigrations" || pendingMigrations.length === 0) { return; } - const autoApply = env.PAPERCLIP_MIGRATION_AUTO_APPLY === "true"; let shouldApply = autoApply; - if (!autoApply) { + if (!autoApply && interactive) { if (!stdin.isTTY || !stdout.isTTY) { shouldApply = true; } else { @@ -159,7 +334,7 @@ async function maybePreflightMigrations() { try { const answer = ( await prompt.question( - `Apply pending migrations (${formatPendingMigrationSummary(payload.pendingMigrations)}) now? (y/N): `, + `Apply pending migrations (${formatPendingMigrationSummary(pendingMigrations)}) now? (y/N): `, ) ) .trim() @@ -172,11 +347,14 @@ async function maybePreflightMigrations() { } if (!shouldApply) { - process.stderr.write( - `[paperclip] Pending migrations detected (${formatPendingMigrationSummary(payload.pendingMigrations)}). ` + - "Refusing to start watch mode against a stale schema.\n", - ); - process.exit(1); + if (exitOnDecline) { + process.stderr.write( + `[paperclip] Pending migrations detected (${formatPendingMigrationSummary(pendingMigrations)}). ` + + "Refusing to start watch mode against a stale schema.\n", + ); + process.exit(1); + } + return; } const migrate = spawn(pnpmBin, ["db:migrate"], { @@ -188,15 +366,15 @@ async function maybePreflightMigrations() { migrate.on("exit", (code, signal) => resolve({ code: code ?? 0, signal })); }); if (exit.signal) { - process.kill(process.pid, exit.signal); + exitForSignal(exit.signal); return; } if (exit.code !== 0) { process.exit(exit.code); } -} -await maybePreflightMigrations(); + await refreshPendingMigrations(); +} async function buildPluginSdk() { console.log("[paperclip] building plugin sdk..."); @@ -205,7 +383,7 @@ async function buildPluginSdk() { { stdio: "inherit" }, ); if (result.signal) { - process.kill(process.pid, result.signal); + exitForSignal(result.signal); return; } if (result.code !== 0) { @@ -214,19 +392,192 @@ async function buildPluginSdk() { } } -await buildPluginSdk(); +async function markChildAsCurrent() { + previousSnapshot = collectWatchedSnapshot(); + dirtyPaths = new Set(); + lastChangedAt = null; + lastRestartAt = new Date().toISOString(); + await refreshPendingMigrations(); +} -const serverScript = mode === "watch" ? "dev:watch" : "dev"; -const child = spawn( - pnpmBin, - ["--filter", "@paperclipai/server", serverScript, ...forwardedArgs], - { stdio: "inherit", env, shell: process.platform === "win32" }, -); +async function scanForBackendChanges() { + if (mode !== "dev" || scanInFlight || restartInFlight) return; + scanInFlight = true; + try { + const nextSnapshot = collectWatchedSnapshot(); + const changed = diffSnapshots(previousSnapshot, nextSnapshot); + previousSnapshot = nextSnapshot; + if (changed.length === 0) return; -child.on("exit", (code, signal) => { - if (signal) { - process.kill(process.pid, signal); + for (const relativePath of changed) { + dirtyPaths.add(relativePath); + } + lastChangedAt = new Date().toISOString(); + await refreshPendingMigrations(); + } finally { + scanInFlight = false; + } +} + +async function getDevHealthPayload() { + const serverPort = env.PORT ?? process.env.PORT ?? "3100"; + const response = await fetch(`http://127.0.0.1:${serverPort}/api/health`); + if (!response.ok) { + throw new Error(`Health request failed (${response.status})`); + } + return await response.json(); +} + +async function waitForChildExit() { + if (!childExitPromise) { + return { code: 0, signal: null }; + } + return await childExitPromise; +} + +async function stopChildForRestart() { + if (!child) return { code: 0, signal: null }; + childExitWasExpected = true; + child.kill("SIGTERM"); + const killTimer = setTimeout(() => { + if (child) { + child.kill("SIGKILL"); + } + }, gracefulShutdownTimeoutMs); + try { + return await waitForChildExit(); + } finally { + clearTimeout(killTimer); + } +} + +async function startServerChild() { + await buildPluginSdk(); + + const serverScript = mode === "watch" ? "dev:watch" : "dev"; + child = spawn( + pnpmBin, + ["--filter", "@paperclipai/server", serverScript, ...forwardedArgs], + { stdio: "inherit", env, shell: process.platform === "win32" }, + ); + + childExitPromise = new Promise((resolve, reject) => { + child.on("error", reject); + child.on("exit", (code, signal) => { + const expected = childExitWasExpected; + childExitWasExpected = false; + child = null; + childExitPromise = null; + resolve({ code: code ?? 0, signal }); + + if (restartInFlight || expected || shuttingDown) { + return; + } + if (signal) { + exitForSignal(signal); + return; + } + process.exit(code ?? 0); + }); + }); + + await markChildAsCurrent(); +} + +async function maybeAutoRestartChild() { + if (mode !== "dev" || restartInFlight || !child) return; + if (dirtyPaths.size === 0 && pendingMigrations.length === 0) return; + + let health; + try { + health = await getDevHealthPayload(); + } catch { return; } - process.exit(code ?? 0); + + const devServer = health?.devServer; + if (!devServer?.enabled || devServer.autoRestartEnabled !== true) return; + if ((devServer.activeRunCount ?? 0) > 0) return; + + try { + restartInFlight = true; + await maybePreflightMigrations({ + autoApply: true, + interactive: false, + exitOnDecline: false, + }); + await stopChildForRestart(); + await startServerChild(); + } catch (error) { + const err = toError(error, "Auto-restart failed"); + process.stderr.write(`${err.stack ?? err.message}\n`); + process.exit(1); + } finally { + restartInFlight = false; + } +} + +function installDevIntervals() { + if (mode !== "dev") return; + + scanTimer = setInterval(() => { + void scanForBackendChanges(); + }, scanIntervalMs); + autoRestartTimer = setInterval(() => { + void maybeAutoRestartChild(); + }, autoRestartPollIntervalMs); +} + +function clearDevIntervals() { + if (scanTimer) { + clearInterval(scanTimer); + scanTimer = null; + } + if (autoRestartTimer) { + clearInterval(autoRestartTimer); + autoRestartTimer = null; + } +} + +async function shutdown(signal) { + if (shuttingDown) return; + shuttingDown = true; + clearDevIntervals(); + clearDevServerStatus(); + + if (!child) { + if (signal) { + exitForSignal(signal); + return; + } + process.exit(0); + } + + childExitWasExpected = true; + child.kill(signal); + const exit = await waitForChildExit(); + if (exit.signal) { + exitForSignal(exit.signal); + return; + } + process.exit(exit.code ?? 0); +} + +process.on("SIGINT", () => { + void shutdown("SIGINT"); }); +process.on("SIGTERM", () => { + void shutdown("SIGTERM"); +}); + +await maybePreflightMigrations(); +await startServerChild(); +installDevIntervals(); + +if (mode === "watch") { + const exit = await waitForChildExit(); + if (exit.signal) { + exitForSignal(exit.signal); + } + process.exit(exit.code ?? 0); +} diff --git a/server/src/__tests__/dev-server-status.test.ts b/server/src/__tests__/dev-server-status.test.ts new file mode 100644 index 00000000..d178f941 --- /dev/null +++ b/server/src/__tests__/dev-server-status.test.ts @@ -0,0 +1,66 @@ +import { mkdtempSync, 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"; + +const tempDirs = []; + +function createTempStatusFile(payload: unknown) { + const dir = mkdtempSync(path.join(os.tmpdir(), "paperclip-dev-status-")); + tempDirs.push(dir); + const filePath = path.join(dir, "dev-server-status.json"); + writeFileSync(filePath, `${JSON.stringify(payload)}\n`, "utf8"); + return filePath; +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("dev server status helpers", () => { + it("reads and normalizes persisted supervisor state", () => { + const filePath = createTempStatusFile({ + dirty: true, + lastChangedAt: "2026-03-20T12:00:00.000Z", + changedPathCount: 4, + changedPathsSample: ["server/src/app.ts", "packages/shared/src/index.ts"], + pendingMigrations: ["0040_restart_banner.sql"], + lastRestartAt: "2026-03-20T11:30:00.000Z", + }); + + expect(readPersistedDevServerStatus({ PAPERCLIP_DEV_SERVER_STATUS_FILE: filePath })).toEqual({ + dirty: true, + lastChangedAt: "2026-03-20T12:00:00.000Z", + changedPathCount: 4, + changedPathsSample: ["server/src/app.ts", "packages/shared/src/index.ts"], + pendingMigrations: ["0040_restart_banner.sql"], + lastRestartAt: "2026-03-20T11:30:00.000Z", + }); + }); + + it("derives waiting-for-idle health state", () => { + const health = toDevServerHealthStatus( + { + dirty: true, + lastChangedAt: "2026-03-20T12:00:00.000Z", + changedPathCount: 2, + changedPathsSample: ["server/src/app.ts"], + pendingMigrations: [], + lastRestartAt: "2026-03-20T11:30:00.000Z", + }, + { autoRestartEnabled: true, activeRunCount: 3 }, + ); + + expect(health).toMatchObject({ + enabled: true, + restartRequired: true, + reason: "backend_changes", + autoRestartEnabled: true, + activeRunCount: 3, + waitingForIdle: true, + }); + }); +}); diff --git a/server/src/__tests__/instance-settings-routes.test.ts b/server/src/__tests__/instance-settings-routes.test.ts index 8b56fd67..9668d1bf 100644 --- a/server/src/__tests__/instance-settings-routes.test.ts +++ b/server/src/__tests__/instance-settings-routes.test.ts @@ -38,6 +38,7 @@ describe("instance settings routes", () => { }); mockInstanceSettingsService.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false, + autoRestartDevServerWhenIdle: false, }); mockInstanceSettingsService.updateGeneral.mockResolvedValue({ id: "instance-settings-1", @@ -49,6 +50,7 @@ describe("instance settings routes", () => { id: "instance-settings-1", experimental: { enableIsolatedWorkspaces: true, + autoRestartDevServerWhenIdle: false, }, }); mockInstanceSettingsService.listCompanyIds.mockResolvedValue(["company-1", "company-2"]); @@ -64,7 +66,10 @@ describe("instance settings routes", () => { const getRes = await request(app).get("/api/instance/settings/experimental"); expect(getRes.status).toBe(200); - expect(getRes.body).toEqual({ enableIsolatedWorkspaces: false }); + expect(getRes.body).toEqual({ + enableIsolatedWorkspaces: false, + autoRestartDevServerWhenIdle: false, + }); const patchRes = await request(app) .patch("/api/instance/settings/experimental") @@ -77,6 +82,24 @@ describe("instance settings routes", () => { expect(mockLogActivity).toHaveBeenCalledTimes(2); }); + it("allows local board users to update guarded dev-server auto-restart", async () => { + const app = createApp({ + type: "board", + userId: "local-board", + source: "local_implicit", + isInstanceAdmin: true, + }); + + await request(app) + .patch("/api/instance/settings/experimental") + .send({ autoRestartDevServerWhenIdle: true }) + .expect(200); + + expect(mockInstanceSettingsService.updateExperimental).toHaveBeenCalledWith({ + autoRestartDevServerWhenIdle: true, + }); + }); + it("allows local board users to read and update general settings", async () => { const app = createApp({ type: "board", diff --git a/server/src/dev-server-status.ts b/server/src/dev-server-status.ts new file mode 100644 index 00000000..aecb0fc9 --- /dev/null +++ b/server/src/dev-server-status.ts @@ -0,0 +1,103 @@ +import { existsSync, readFileSync } from "node:fs"; + +export type PersistedDevServerStatus = { + dirty: boolean; + lastChangedAt: string | null; + changedPathCount: number; + changedPathsSample: string[]; + pendingMigrations: string[]; + lastRestartAt: string | null; +}; + +export type DevServerHealthStatus = { + enabled: true; + restartRequired: boolean; + reason: "backend_changes" | "pending_migrations" | "backend_changes_and_pending_migrations" | null; + lastChangedAt: string | null; + changedPathCount: number; + changedPathsSample: string[]; + pendingMigrations: string[]; + autoRestartEnabled: boolean; + activeRunCount: number; + waitingForIdle: boolean; + lastRestartAt: string | null; +}; + +function normalizeStringArray(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value + .filter((entry): entry is string => typeof entry === "string") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); +} + +function normalizeTimestamp(value: unknown): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +export function readPersistedDevServerStatus( + env: NodeJS.ProcessEnv = process.env, +): PersistedDevServerStatus | null { + const filePath = env.PAPERCLIP_DEV_SERVER_STATUS_FILE?.trim(); + if (!filePath || !existsSync(filePath)) return null; + + try { + const raw = JSON.parse(readFileSync(filePath, "utf8")) as Record; + const changedPathsSample = normalizeStringArray(raw.changedPathsSample).slice(0, 5); + const pendingMigrations = normalizeStringArray(raw.pendingMigrations); + const changedPathCountRaw = raw.changedPathCount; + const changedPathCount = + typeof changedPathCountRaw === "number" && Number.isFinite(changedPathCountRaw) + ? Math.max(0, Math.trunc(changedPathCountRaw)) + : changedPathsSample.length; + const dirtyRaw = raw.dirty; + const dirty = + typeof dirtyRaw === "boolean" + ? dirtyRaw + : changedPathCount > 0 || pendingMigrations.length > 0; + + return { + dirty, + lastChangedAt: normalizeTimestamp(raw.lastChangedAt), + changedPathCount, + changedPathsSample, + pendingMigrations, + lastRestartAt: normalizeTimestamp(raw.lastRestartAt), + }; + } catch { + return null; + } +} + +export function toDevServerHealthStatus( + persisted: PersistedDevServerStatus, + opts: { autoRestartEnabled: boolean; activeRunCount: number }, +): DevServerHealthStatus { + const hasPathChanges = persisted.changedPathCount > 0; + const hasPendingMigrations = persisted.pendingMigrations.length > 0; + const reason = + hasPathChanges && hasPendingMigrations + ? "backend_changes_and_pending_migrations" + : hasPendingMigrations + ? "pending_migrations" + : hasPathChanges + ? "backend_changes" + : null; + const restartRequired = persisted.dirty || reason !== null; + + return { + enabled: true, + restartRequired, + reason, + lastChangedAt: persisted.lastChangedAt, + changedPathCount: persisted.changedPathCount, + changedPathsSample: persisted.changedPathsSample, + pendingMigrations: persisted.pendingMigrations, + autoRestartEnabled: opts.autoRestartEnabled, + activeRunCount: opts.activeRunCount, + waitingForIdle: restartRequired && opts.autoRestartEnabled && opts.activeRunCount > 0, + lastRestartAt: persisted.lastRestartAt, + }; +} diff --git a/server/src/routes/health.ts b/server/src/routes/health.ts index 59897a89..0bf6e92f 100644 --- a/server/src/routes/health.ts +++ b/server/src/routes/health.ts @@ -1,8 +1,10 @@ import { Router } from "express"; import type { Db } from "@paperclipai/db"; -import { and, count, eq, gt, isNull, sql } from "drizzle-orm"; -import { instanceUserRoles, invites } 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 { instanceSettingsService } from "../services/instance-settings.js"; import { serverVersion } from "../version.js"; export function healthRoutes( @@ -55,6 +57,23 @@ export function healthRoutes( } } + const persistedDevServerStatus = readPersistedDevServerStatus(); + let devServer: ReturnType | undefined; + if (persistedDevServerStatus) { + const instanceSettings = instanceSettingsService(db); + const experimentalSettings = await instanceSettings.getExperimental(); + const activeRunCount = await db + .select({ count: count() }) + .from(heartbeatRuns) + .where(inArray(heartbeatRuns.status, ["queued", "running"])) + .then((rows) => Number(rows[0]?.count ?? 0)); + + devServer = toDevServerHealthStatus(persistedDevServerStatus, { + autoRestartEnabled: experimentalSettings.autoRestartDevServerWhenIdle ?? false, + activeRunCount, + }); + } + res.json({ status: "ok", version: serverVersion, @@ -66,6 +85,7 @@ export function healthRoutes( features: { companyDeletionEnabled: opts.companyDeletionEnabled, }, + ...(devServer ? { devServer } : {}), }); }); diff --git a/server/src/services/instance-settings.ts b/server/src/services/instance-settings.ts index bbc4df3d..ccefea7c 100644 --- a/server/src/services/instance-settings.ts +++ b/server/src/services/instance-settings.ts @@ -30,10 +30,12 @@ function normalizeExperimentalSettings(raw: unknown): InstanceExperimentalSettin if (parsed.success) { return { enableIsolatedWorkspaces: parsed.data.enableIsolatedWorkspaces ?? false, + autoRestartDevServerWhenIdle: parsed.data.autoRestartDevServerWhenIdle ?? false, }; } return { enableIsolatedWorkspaces: false, + autoRestartDevServerWhenIdle: false, }; } diff --git a/ui/src/api/health.ts b/ui/src/api/health.ts index b1573805..e2725b20 100644 --- a/ui/src/api/health.ts +++ b/ui/src/api/health.ts @@ -1,3 +1,17 @@ +export type DevServerHealthStatus = { + enabled: true; + restartRequired: boolean; + reason: "backend_changes" | "pending_migrations" | "backend_changes_and_pending_migrations" | null; + lastChangedAt: string | null; + changedPathCount: number; + changedPathsSample: string[]; + pendingMigrations: string[]; + autoRestartEnabled: boolean; + activeRunCount: number; + waitingForIdle: boolean; + lastRestartAt: string | null; +}; + export type HealthStatus = { status: "ok"; version?: string; @@ -9,6 +23,7 @@ export type HealthStatus = { features?: { companyDeletionEnabled?: boolean; }; + devServer?: DevServerHealthStatus; }; export const healthApi = { diff --git a/ui/src/components/DevRestartBanner.tsx b/ui/src/components/DevRestartBanner.tsx new file mode 100644 index 00000000..5f8ba8a0 --- /dev/null +++ b/ui/src/components/DevRestartBanner.tsx @@ -0,0 +1,89 @@ +import { AlertTriangle, RotateCcw, TimerReset } from "lucide-react"; +import type { DevServerHealthStatus } from "../api/health"; + +function formatRelativeTimestamp(value: string | null): string | null { + if (!value) return null; + const timestamp = new Date(value).getTime(); + if (Number.isNaN(timestamp)) return null; + + const deltaMs = Date.now() - timestamp; + if (deltaMs < 60_000) return "just now"; + const deltaMinutes = Math.round(deltaMs / 60_000); + if (deltaMinutes < 60) return `${deltaMinutes}m ago`; + const deltaHours = Math.round(deltaMinutes / 60); + if (deltaHours < 24) return `${deltaHours}h ago`; + const deltaDays = Math.round(deltaHours / 24); + return `${deltaDays}d ago`; +} + +function describeReason(devServer: DevServerHealthStatus): string { + if (devServer.reason === "backend_changes_and_pending_migrations") { + return "backend files changed and migrations are pending"; + } + if (devServer.reason === "pending_migrations") { + return "pending migrations need a fresh boot"; + } + return "backend files changed since this server booted"; +} + +export function DevRestartBanner({ devServer }: { devServer?: DevServerHealthStatus }) { + if (!devServer?.enabled || !devServer.restartRequired) return null; + + const changedAt = formatRelativeTimestamp(devServer.lastChangedAt); + const sample = devServer.changedPathsSample.slice(0, 3); + + return ( +
+
+
+
+ + Restart Required + {devServer.autoRestartEnabled ? ( + + Auto-Restart On + + ) : null} +
+

+ {describeReason(devServer)} + {changedAt ? ` ยท updated ${changedAt}` : ""} +

+
+ {sample.length > 0 ? ( + + Changed: {sample.join(", ")} + {devServer.changedPathCount > sample.length ? ` +${devServer.changedPathCount - sample.length} more` : ""} + + ) : null} + {devServer.pendingMigrations.length > 0 ? ( + + Pending migrations: {devServer.pendingMigrations.slice(0, 2).join(", ")} + {devServer.pendingMigrations.length > 2 ? ` +${devServer.pendingMigrations.length - 2} more` : ""} + + ) : null} +
+
+ +
+ {devServer.waitingForIdle ? ( +
+ + Waiting for {devServer.activeRunCount} live run{devServer.activeRunCount === 1 ? "" : "s"} to finish +
+ ) : devServer.autoRestartEnabled ? ( +
+ + Auto-restart will trigger when the instance is idle +
+ ) : ( +
+ + Restart `pnpm dev:once` after the active work is safe to interrupt +
+ )} +
+
+
+ ); +} diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index 8bae6920..8761b71c 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -15,6 +15,7 @@ import { NewAgentDialog } from "./NewAgentDialog"; import { ToastViewport } from "./ToastViewport"; import { MobileBottomNav } from "./MobileBottomNav"; import { WorktreeBanner } from "./WorktreeBanner"; +import { DevRestartBanner } from "./DevRestartBanner"; import { useDialog } from "../context/DialogContext"; import { usePanel } from "../context/PanelContext"; import { useCompany } from "../context/CompanyContext"; @@ -78,6 +79,11 @@ export function Layout() { queryKey: queryKeys.health, queryFn: () => healthApi.get(), retry: false, + refetchInterval: (query) => { + const data = query.state.data as { devServer?: { enabled?: boolean } } | undefined; + return data?.devServer?.enabled ? 2000 : false; + }, + refetchIntervalInBackground: true, }); useEffect(() => { @@ -266,6 +272,7 @@ export function Layout() { Skip to Main Content +
{isMobile && sidebarOpen && ( +
+ ); }