Add guarded dev restart handling
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user