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