Add guarded dev restart handling

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta
2026-03-20 08:43:47 -05:00
parent dd44f69e2b
commit 8fc399f511
13 changed files with 758 additions and 43 deletions

View File

@@ -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` 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: Tailscale/private-auth dev mode:
```sh ```sh

View File

@@ -4,6 +4,7 @@ export interface InstanceGeneralSettings {
export interface InstanceExperimentalSettings { export interface InstanceExperimentalSettings {
enableIsolatedWorkspaces: boolean; enableIsolatedWorkspaces: boolean;
autoRestartDevServerWhenIdle: boolean;
} }
export interface InstanceSettings { export interface InstanceSettings {

View File

@@ -8,6 +8,7 @@ export const patchInstanceGeneralSettingsSchema = instanceGeneralSettingsSchema.
export const instanceExperimentalSettingsSchema = z.object({ export const instanceExperimentalSettingsSchema = z.object({
enableIsolatedWorkspaces: z.boolean().default(false), enableIsolatedWorkspaces: z.boolean().default(false),
autoRestartDevServerWhenIdle: z.boolean().default(false),
}).strict(); }).strict();
export const patchInstanceExperimentalSettingsSchema = instanceExperimentalSettingsSchema.partial(); export const patchInstanceExperimentalSettingsSchema = instanceExperimentalSettingsSchema.partial();

View File

@@ -1,10 +1,54 @@
#!/usr/bin/env node #!/usr/bin/env node
import { spawn } from "node:child_process"; 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 { createInterface } from "node:readline/promises";
import { stdin, stdout } from "node:process"; import { stdin, stdout } from "node:process";
import { fileURLToPath } from "node:url";
const mode = process.argv[2] === "watch" ? "watch" : "dev"; const mode = process.argv[2] === "watch" ? "watch" : "dev";
const cliArgs = process.argv.slice(3); 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([ const tailscaleAuthFlagNames = new Set([
"--tailscale-auth", "--tailscale-auth",
@@ -34,6 +78,10 @@ const env = {
PAPERCLIP_UI_DEV_MIDDLEWARE: "true", PAPERCLIP_UI_DEV_MIDDLEWARE: "true",
}; };
if (mode === "dev") {
env.PAPERCLIP_DEV_SERVER_STATUS_FILE = devServerStatusFilePath;
}
if (mode === "watch") { if (mode === "watch") {
env.PAPERCLIP_MIGRATION_PROMPT ??= "never"; env.PAPERCLIP_MIGRATION_PROMPT ??= "never";
env.PAPERCLIP_MIGRATION_AUTO_APPLY ??= "true"; env.PAPERCLIP_MIGRATION_AUTO_APPLY ??= "true";
@@ -50,6 +98,19 @@ if (tailscaleAuth) {
} }
const pnpmBin = process.platform === "win32" ? "pnpm.cmd" : "pnpm"; 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") { function toError(error, context = "Dev runner command failed") {
if (error instanceof Error) return error; if (error instanceof Error) return error;
@@ -82,9 +143,110 @@ function formatPendingMigrationSummary(migrations) {
: migrations.join(", "); : 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 = {}) { async function runPnpm(args, options = {}) {
return await new Promise((resolve, reject) => { return await new Promise((resolve, reject) => {
const child = spawn(pnpmBin, args, { const spawned = spawn(pnpmBin, args, {
stdio: options.stdio ?? ["ignore", "pipe", "pipe"], stdio: options.stdio ?? ["ignore", "pipe", "pipe"],
env: options.env ?? process.env, env: options.env ?? process.env,
shell: process.platform === "win32", shell: process.platform === "win32",
@@ -93,19 +255,19 @@ async function runPnpm(args, options = {}) {
let stdoutBuffer = ""; let stdoutBuffer = "";
let stderrBuffer = ""; let stderrBuffer = "";
if (child.stdout) { if (spawned.stdout) {
child.stdout.on("data", (chunk) => { spawned.stdout.on("data", (chunk) => {
stdoutBuffer += String(chunk); stdoutBuffer += String(chunk);
}); });
} }
if (child.stderr) { if (spawned.stderr) {
child.stderr.on("data", (chunk) => { spawned.stderr.on("data", (chunk) => {
stderrBuffer += String(chunk); stderrBuffer += String(chunk);
}); });
} }
child.on("error", reject); spawned.on("error", reject);
child.on("exit", (code, signal) => { spawned.on("exit", (code, signal) => {
resolve({ resolve({
code: code ?? 0, code: code ?? 0,
signal, signal,
@@ -116,9 +278,7 @@ async function runPnpm(args, options = {}) {
}); });
} }
async function maybePreflightMigrations() { async function getMigrationStatusPayload() {
if (mode !== "watch") return;
const status = await runPnpm( const status = await runPnpm(
["--filter", "@paperclipai/db", "exec", "tsx", "src/migration-status.ts", "--json"], ["--filter", "@paperclipai/db", "exec", "tsx", "src/migration-status.ts", "--json"],
{ env }, { env },
@@ -132,9 +292,8 @@ async function maybePreflightMigrations() {
process.exit(status.code); process.exit(status.code);
} }
let payload;
try { try {
payload = JSON.parse(status.stdout.trim()); return JSON.parse(status.stdout.trim());
} catch (error) { } catch (error) {
process.stderr.write( process.stderr.write(
status.stderr || status.stderr ||
@@ -143,15 +302,31 @@ async function maybePreflightMigrations() {
); );
throw toError(error, "Unable to parse migration-status JSON output"); 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; return;
} }
const autoApply = env.PAPERCLIP_MIGRATION_AUTO_APPLY === "true";
let shouldApply = autoApply; let shouldApply = autoApply;
if (!autoApply) { if (!autoApply && interactive) {
if (!stdin.isTTY || !stdout.isTTY) { if (!stdin.isTTY || !stdout.isTTY) {
shouldApply = true; shouldApply = true;
} else { } else {
@@ -159,7 +334,7 @@ async function maybePreflightMigrations() {
try { try {
const answer = ( const answer = (
await prompt.question( await prompt.question(
`Apply pending migrations (${formatPendingMigrationSummary(payload.pendingMigrations)}) now? (y/N): `, `Apply pending migrations (${formatPendingMigrationSummary(pendingMigrations)}) now? (y/N): `,
) )
) )
.trim() .trim()
@@ -172,11 +347,14 @@ async function maybePreflightMigrations() {
} }
if (!shouldApply) { if (!shouldApply) {
process.stderr.write( if (exitOnDecline) {
`[paperclip] Pending migrations detected (${formatPendingMigrationSummary(payload.pendingMigrations)}). ` + process.stderr.write(
"Refusing to start watch mode against a stale schema.\n", `[paperclip] Pending migrations detected (${formatPendingMigrationSummary(pendingMigrations)}). ` +
); "Refusing to start watch mode against a stale schema.\n",
process.exit(1); );
process.exit(1);
}
return;
} }
const migrate = spawn(pnpmBin, ["db:migrate"], { const migrate = spawn(pnpmBin, ["db:migrate"], {
@@ -188,15 +366,15 @@ async function maybePreflightMigrations() {
migrate.on("exit", (code, signal) => resolve({ code: code ?? 0, signal })); migrate.on("exit", (code, signal) => resolve({ code: code ?? 0, signal }));
}); });
if (exit.signal) { if (exit.signal) {
process.kill(process.pid, exit.signal); exitForSignal(exit.signal);
return; return;
} }
if (exit.code !== 0) { if (exit.code !== 0) {
process.exit(exit.code); process.exit(exit.code);
} }
}
await maybePreflightMigrations(); await refreshPendingMigrations();
}
async function buildPluginSdk() { async function buildPluginSdk() {
console.log("[paperclip] building plugin sdk..."); console.log("[paperclip] building plugin sdk...");
@@ -205,7 +383,7 @@ async function buildPluginSdk() {
{ stdio: "inherit" }, { stdio: "inherit" },
); );
if (result.signal) { if (result.signal) {
process.kill(process.pid, result.signal); exitForSignal(result.signal);
return; return;
} }
if (result.code !== 0) { 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"; async function scanForBackendChanges() {
const child = spawn( if (mode !== "dev" || scanInFlight || restartInFlight) return;
pnpmBin, scanInFlight = true;
["--filter", "@paperclipai/server", serverScript, ...forwardedArgs], try {
{ stdio: "inherit", env, shell: process.platform === "win32" }, const nextSnapshot = collectWatchedSnapshot();
); const changed = diffSnapshots(previousSnapshot, nextSnapshot);
previousSnapshot = nextSnapshot;
if (changed.length === 0) return;
child.on("exit", (code, signal) => { for (const relativePath of changed) {
if (signal) { dirtyPaths.add(relativePath);
process.kill(process.pid, signal); }
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; 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);
}

View File

@@ -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,
});
});
});

View File

@@ -38,6 +38,7 @@ describe("instance settings routes", () => {
}); });
mockInstanceSettingsService.getExperimental.mockResolvedValue({ mockInstanceSettingsService.getExperimental.mockResolvedValue({
enableIsolatedWorkspaces: false, enableIsolatedWorkspaces: false,
autoRestartDevServerWhenIdle: false,
}); });
mockInstanceSettingsService.updateGeneral.mockResolvedValue({ mockInstanceSettingsService.updateGeneral.mockResolvedValue({
id: "instance-settings-1", id: "instance-settings-1",
@@ -49,6 +50,7 @@ describe("instance settings routes", () => {
id: "instance-settings-1", id: "instance-settings-1",
experimental: { experimental: {
enableIsolatedWorkspaces: true, enableIsolatedWorkspaces: true,
autoRestartDevServerWhenIdle: false,
}, },
}); });
mockInstanceSettingsService.listCompanyIds.mockResolvedValue(["company-1", "company-2"]); 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"); const getRes = await request(app).get("/api/instance/settings/experimental");
expect(getRes.status).toBe(200); expect(getRes.status).toBe(200);
expect(getRes.body).toEqual({ enableIsolatedWorkspaces: false }); expect(getRes.body).toEqual({
enableIsolatedWorkspaces: false,
autoRestartDevServerWhenIdle: false,
});
const patchRes = await request(app) const patchRes = await request(app)
.patch("/api/instance/settings/experimental") .patch("/api/instance/settings/experimental")
@@ -77,6 +82,24 @@ describe("instance settings routes", () => {
expect(mockLogActivity).toHaveBeenCalledTimes(2); 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 () => { it("allows local board users to read and update general settings", async () => {
const app = createApp({ const app = createApp({
type: "board", type: "board",

View File

@@ -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<string, unknown>;
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,
};
}

View File

@@ -1,8 +1,10 @@
import { Router } from "express"; import { Router } from "express";
import type { Db } from "@paperclipai/db"; import type { Db } from "@paperclipai/db";
import { and, count, eq, gt, isNull, sql } from "drizzle-orm"; import { and, count, eq, gt, inArray, isNull, sql } from "drizzle-orm";
import { instanceUserRoles, invites } from "@paperclipai/db"; import { heartbeatRuns, instanceUserRoles, invites } from "@paperclipai/db";
import type { DeploymentExposure, DeploymentMode } from "@paperclipai/shared"; 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"; import { serverVersion } from "../version.js";
export function healthRoutes( export function healthRoutes(
@@ -55,6 +57,23 @@ export function healthRoutes(
} }
} }
const persistedDevServerStatus = readPersistedDevServerStatus();
let devServer: ReturnType<typeof toDevServerHealthStatus> | 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({ res.json({
status: "ok", status: "ok",
version: serverVersion, version: serverVersion,
@@ -66,6 +85,7 @@ export function healthRoutes(
features: { features: {
companyDeletionEnabled: opts.companyDeletionEnabled, companyDeletionEnabled: opts.companyDeletionEnabled,
}, },
...(devServer ? { devServer } : {}),
}); });
}); });

View File

@@ -30,10 +30,12 @@ function normalizeExperimentalSettings(raw: unknown): InstanceExperimentalSettin
if (parsed.success) { if (parsed.success) {
return { return {
enableIsolatedWorkspaces: parsed.data.enableIsolatedWorkspaces ?? false, enableIsolatedWorkspaces: parsed.data.enableIsolatedWorkspaces ?? false,
autoRestartDevServerWhenIdle: parsed.data.autoRestartDevServerWhenIdle ?? false,
}; };
} }
return { return {
enableIsolatedWorkspaces: false, enableIsolatedWorkspaces: false,
autoRestartDevServerWhenIdle: false,
}; };
} }

View File

@@ -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 = { export type HealthStatus = {
status: "ok"; status: "ok";
version?: string; version?: string;
@@ -9,6 +23,7 @@ export type HealthStatus = {
features?: { features?: {
companyDeletionEnabled?: boolean; companyDeletionEnabled?: boolean;
}; };
devServer?: DevServerHealthStatus;
}; };
export const healthApi = { export const healthApi = {

View File

@@ -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 (
<div className="border-b border-amber-300/60 bg-amber-50 text-amber-950 dark:border-amber-500/25 dark:bg-amber-500/10 dark:text-amber-100">
<div className="flex flex-col gap-3 px-3 py-2.5 md:flex-row md:items-center md:justify-between">
<div className="min-w-0">
<div className="flex items-center gap-2 text-[12px] font-semibold uppercase tracking-[0.18em]">
<AlertTriangle className="h-3.5 w-3.5 shrink-0" />
<span>Restart Required</span>
{devServer.autoRestartEnabled ? (
<span className="rounded-full bg-amber-900/10 px-2 py-0.5 text-[10px] tracking-[0.14em] dark:bg-amber-100/10">
Auto-Restart On
</span>
) : null}
</div>
<p className="mt-1 text-sm">
{describeReason(devServer)}
{changedAt ? ` · updated ${changedAt}` : ""}
</p>
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-amber-900/80 dark:text-amber-100/75">
{sample.length > 0 ? (
<span>
Changed: {sample.join(", ")}
{devServer.changedPathCount > sample.length ? ` +${devServer.changedPathCount - sample.length} more` : ""}
</span>
) : null}
{devServer.pendingMigrations.length > 0 ? (
<span>
Pending migrations: {devServer.pendingMigrations.slice(0, 2).join(", ")}
{devServer.pendingMigrations.length > 2 ? ` +${devServer.pendingMigrations.length - 2} more` : ""}
</span>
) : null}
</div>
</div>
<div className="flex shrink-0 items-center gap-2 text-xs font-medium">
{devServer.waitingForIdle ? (
<div className="inline-flex items-center gap-2 rounded-full bg-amber-900/10 px-3 py-1.5 dark:bg-amber-100/10">
<TimerReset className="h-3.5 w-3.5" />
<span>Waiting for {devServer.activeRunCount} live run{devServer.activeRunCount === 1 ? "" : "s"} to finish</span>
</div>
) : devServer.autoRestartEnabled ? (
<div className="inline-flex items-center gap-2 rounded-full bg-amber-900/10 px-3 py-1.5 dark:bg-amber-100/10">
<RotateCcw className="h-3.5 w-3.5" />
<span>Auto-restart will trigger when the instance is idle</span>
</div>
) : (
<div className="inline-flex items-center gap-2 rounded-full bg-amber-900/10 px-3 py-1.5 dark:bg-amber-100/10">
<RotateCcw className="h-3.5 w-3.5" />
<span>Restart `pnpm dev:once` after the active work is safe to interrupt</span>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -15,6 +15,7 @@ import { NewAgentDialog } from "./NewAgentDialog";
import { ToastViewport } from "./ToastViewport"; import { ToastViewport } from "./ToastViewport";
import { MobileBottomNav } from "./MobileBottomNav"; import { MobileBottomNav } from "./MobileBottomNav";
import { WorktreeBanner } from "./WorktreeBanner"; import { WorktreeBanner } from "./WorktreeBanner";
import { DevRestartBanner } from "./DevRestartBanner";
import { useDialog } from "../context/DialogContext"; import { useDialog } from "../context/DialogContext";
import { usePanel } from "../context/PanelContext"; import { usePanel } from "../context/PanelContext";
import { useCompany } from "../context/CompanyContext"; import { useCompany } from "../context/CompanyContext";
@@ -78,6 +79,11 @@ export function Layout() {
queryKey: queryKeys.health, queryKey: queryKeys.health,
queryFn: () => healthApi.get(), queryFn: () => healthApi.get(),
retry: false, retry: false,
refetchInterval: (query) => {
const data = query.state.data as { devServer?: { enabled?: boolean } } | undefined;
return data?.devServer?.enabled ? 2000 : false;
},
refetchIntervalInBackground: true,
}); });
useEffect(() => { useEffect(() => {
@@ -266,6 +272,7 @@ export function Layout() {
Skip to Main Content Skip to Main Content
</a> </a>
<WorktreeBanner /> <WorktreeBanner />
<DevRestartBanner devServer={health?.devServer} />
<div className={cn("min-h-0 flex-1", isMobile ? "w-full" : "flex overflow-hidden")}> <div className={cn("min-h-0 flex-1", isMobile ? "w-full" : "flex overflow-hidden")}>
{isMobile && sidebarOpen && ( {isMobile && sidebarOpen && (
<button <button

View File

@@ -24,11 +24,14 @@ export function InstanceExperimentalSettings() {
}); });
const toggleMutation = useMutation({ const toggleMutation = useMutation({
mutationFn: async (enabled: boolean) => mutationFn: async (patch: { enableIsolatedWorkspaces?: boolean; autoRestartDevServerWhenIdle?: boolean }) =>
instanceSettingsApi.updateExperimental({ enableIsolatedWorkspaces: enabled }), instanceSettingsApi.updateExperimental(patch),
onSuccess: async () => { onSuccess: async () => {
setActionError(null); setActionError(null);
await queryClient.invalidateQueries({ queryKey: queryKeys.instance.experimentalSettings }); await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.instance.experimentalSettings }),
queryClient.invalidateQueries({ queryKey: queryKeys.health }),
]);
}, },
onError: (error) => { onError: (error) => {
setActionError(error instanceof Error ? error.message : "Failed to update experimental settings."); setActionError(error instanceof Error ? error.message : "Failed to update experimental settings.");
@@ -50,6 +53,7 @@ export function InstanceExperimentalSettings() {
} }
const enableIsolatedWorkspaces = experimentalQuery.data?.enableIsolatedWorkspaces === true; const enableIsolatedWorkspaces = experimentalQuery.data?.enableIsolatedWorkspaces === true;
const autoRestartDevServerWhenIdle = experimentalQuery.data?.autoRestartDevServerWhenIdle === true;
return ( return (
<div className="max-w-4xl space-y-6"> <div className="max-w-4xl space-y-6">
@@ -86,7 +90,7 @@ export function InstanceExperimentalSettings() {
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-60", "relative inline-flex h-5 w-9 items-center rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-60",
enableIsolatedWorkspaces ? "bg-green-600" : "bg-muted", enableIsolatedWorkspaces ? "bg-green-600" : "bg-muted",
)} )}
onClick={() => toggleMutation.mutate(!enableIsolatedWorkspaces)} onClick={() => toggleMutation.mutate({ enableIsolatedWorkspaces: !enableIsolatedWorkspaces })}
> >
<span <span
className={cn( className={cn(
@@ -97,6 +101,37 @@ export function InstanceExperimentalSettings() {
</button> </button>
</div> </div>
</section> </section>
<section className="rounded-xl border border-border bg-card p-5">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1.5">
<h2 className="text-sm font-semibold">Auto-Restart Dev Server When Idle</h2>
<p className="max-w-2xl text-sm text-muted-foreground">
In `pnpm dev:once`, wait for all queued and running local agent runs to finish, then restart the server
automatically when backend changes or migrations make the current boot stale.
</p>
</div>
<button
type="button"
aria-label="Toggle guarded dev-server auto-restart"
disabled={toggleMutation.isPending}
className={cn(
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-60",
autoRestartDevServerWhenIdle ? "bg-green-600" : "bg-muted",
)}
onClick={() =>
toggleMutation.mutate({ autoRestartDevServerWhenIdle: !autoRestartDevServerWhenIdle })
}
>
<span
className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
autoRestartDevServerWhenIdle ? "translate-x-4.5" : "translate-x-0.5",
)}
/>
</button>
</div>
</section>
</div> </div>
); );
} }