Add guarded dev restart handling
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
66
server/src/__tests__/dev-server-status.test.ts
Normal file
66
server/src/__tests__/dev-server-status.test.ts
Normal 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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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",
|
||||||
|
|||||||
103
server/src/dev-server-status.ts
Normal file
103
server/src/dev-server-status.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 } : {}),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
89
ui/src/components/DevRestartBanner.tsx
Normal file
89
ui/src/components/DevRestartBanner.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user