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",