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

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

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

View File

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