diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 07f4dd4256..8e5b53d5ae 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -976,6 +976,8 @@ export type { CloudUpstreamWarning, } from "./types/cloud-upstream.js"; +export type { ServerGitInfo, ServerInfoSnapshot } from "./types/server-info.js"; + export { getClosedIsolatedExecutionWorkspaceMessage, isClosedIsolatedExecutionWorkspace, diff --git a/packages/shared/src/types/instance.ts b/packages/shared/src/types/instance.ts index 5013a81c68..12ad91437a 100644 --- a/packages/shared/src/types/instance.ts +++ b/packages/shared/src/types/instance.ts @@ -55,6 +55,7 @@ export interface InstanceExperimentalSettings { enableExperimentalFileViewer: boolean; enableCloudSync: boolean; enableExternalObjects: boolean; + enableServerInfoDebugView: boolean; autoRestartDevServerWhenIdle: boolean; enableIssueGraphLivenessAutoRecovery: boolean; issueGraphLivenessAutoRecoveryLookbackHours: number; diff --git a/packages/shared/src/types/server-info.ts b/packages/shared/src/types/server-info.ts new file mode 100644 index 0000000000..add47f1fda --- /dev/null +++ b/packages/shared/src/types/server-info.ts @@ -0,0 +1,19 @@ +// Shared between the server (which produces the snapshot at boot) and the UI +// (which renders it), so both sides stay in sync on a single definition. +export type ServerGitInfo = + | { + available: true; + fullSha: string; + shortSha: string; + subject: string; + committedAt: string | null; + } + | { + available: false; + unavailableReason: "git_unavailable" | "invalid_git_metadata"; + }; + +export interface ServerInfoSnapshot { + processStartedAt: string; + git: ServerGitInfo; +} diff --git a/packages/shared/src/validators/instance.test.ts b/packages/shared/src/validators/instance.test.ts new file mode 100644 index 0000000000..de84c6f7fa --- /dev/null +++ b/packages/shared/src/validators/instance.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { + instanceExperimentalSettingsSchema, + patchInstanceExperimentalSettingsSchema, +} from "./instance.js"; + +describe("instance experimental settings validators", () => { + it("defaults the server info debug view off", () => { + const settings = instanceExperimentalSettingsSchema.parse({}); + + expect(settings.enableServerInfoDebugView).toBe(false); + }); + + it("accepts server info debug view patches", () => { + expect( + patchInstanceExperimentalSettingsSchema.parse({ + enableServerInfoDebugView: true, + }), + ).toEqual({ + enableServerInfoDebugView: true, + }); + }); +}); diff --git a/packages/shared/src/validators/instance.ts b/packages/shared/src/validators/instance.ts index e7bd518390..4831e7efa7 100644 --- a/packages/shared/src/validators/instance.ts +++ b/packages/shared/src/validators/instance.ts @@ -49,6 +49,7 @@ export const instanceExperimentalSettingsSchema = z.object({ enableExperimentalFileViewer: z.boolean().default(false), enableCloudSync: z.boolean().default(false), enableExternalObjects: z.boolean().default(false), + enableServerInfoDebugView: z.boolean().default(false), autoRestartDevServerWhenIdle: z.boolean().default(false), enableIssueGraphLivenessAutoRecovery: z.boolean().default(false), issueGraphLivenessAutoRecoveryLookbackHours: z diff --git a/server/src/__tests__/health.test.ts b/server/src/__tests__/health.test.ts index 66bf9a7749..42b3705772 100644 --- a/server/src/__tests__/health.test.ts +++ b/server/src/__tests__/health.test.ts @@ -7,15 +7,34 @@ import * as devServerStatus from "../dev-server-status.js"; import { serverVersion } from "../version.js"; const mockReadPersistedDevServerStatus = vi.hoisted(() => vi.fn()); +const testServerInfo = { + processStartedAt: "2026-06-26T00:00:00.000Z", + git: { + available: true, + fullSha: "0123456789abcdef0123456789abcdef01234567", + shortSha: "0123456", + subject: "Add server info debug view", + committedAt: "2026-06-25T23:00:00.000Z", + }, +} as const; vi.mock("../dev-server-status.js", () => ({ readPersistedDevServerStatus: mockReadPersistedDevServerStatus, toDevServerHealthStatus: vi.fn(), })); -function createApp(db?: Db) { +function createApp(db?: Db, serverInfo = testServerInfo) { const app = express(); - app.use("/health", healthRoutes(db)); + app.use( + "/health", + healthRoutes(db, { + deploymentMode: "local_trusted", + deploymentExposure: "private", + authReady: true, + companyDeletionEnabled: true, + serverInfo, + }), + ); return app; } @@ -32,7 +51,7 @@ describe("GET /health", () => { const app = createApp(); const res = await request(app).get("/health"); expect(res.status).toBe(200); - expect(res.body).toEqual({ status: "ok", version: serverVersion }); + expect(res.body).toEqual({ status: "ok", version: serverVersion, serverInfo: testServerInfo }); }, 15_000); it("returns 200 when the database probe succeeds", async () => { @@ -45,7 +64,11 @@ describe("GET /health", () => { expect(res.status).toBe(200); expect(db.execute).toHaveBeenCalledTimes(1); - expect(res.body).toMatchObject({ status: "ok", version: serverVersion }); + expect(res.body).toMatchObject({ + status: "ok", + version: serverVersion, + serverInfo: testServerInfo, + }); }); it("returns 503 when the database probe fails", async () => { @@ -60,7 +83,29 @@ describe("GET /health", () => { expect(res.body).toEqual({ status: "unhealthy", version: serverVersion, - error: "database_unreachable" + error: "database_unreachable", + serverInfo: testServerInfo, + }); + }); + + it("returns safe server info fallbacks when git metadata is unavailable", async () => { + const app = createApp(undefined, { + processStartedAt: "2026-06-26T00:00:00.000Z", + git: { + available: false, + unavailableReason: "git_unavailable", + }, + }); + + const res = await request(app).get("/health"); + + expect(res.status).toBe(200); + expect(res.body.serverInfo).toEqual({ + processStartedAt: "2026-06-26T00:00:00.000Z", + git: { + available: false, + unavailableReason: "git_unavailable", + }, }); }); @@ -88,6 +133,7 @@ describe("GET /health", () => { deploymentExposure: "public", authReady: true, companyDeletionEnabled: false, + serverInfo: testServerInfo, }), ); @@ -101,6 +147,7 @@ describe("GET /health", () => { bootstrapStatus: "ready", bootstrapInviteActive: false, }); + expect(res.body.serverInfo).toBeUndefined(); }); it("redacts detailed metadata when authenticated mode is reached without auth middleware", async () => { @@ -123,6 +170,7 @@ describe("GET /health", () => { deploymentExposure: "public", authReady: true, companyDeletionEnabled: false, + serverInfo: testServerInfo, }), ); @@ -136,6 +184,7 @@ describe("GET /health", () => { bootstrapStatus: "ready", bootstrapInviteActive: false, }); + expect(res.body.serverInfo).toBeUndefined(); }); it("keeps detailed metadata for authenticated requests in authenticated mode", async () => { @@ -162,6 +211,7 @@ describe("GET /health", () => { deploymentExposure: "public", authReady: true, companyDeletionEnabled: false, + serverInfo: testServerInfo, }), ); @@ -179,6 +229,7 @@ describe("GET /health", () => { features: { companyDeletionEnabled: false, }, + serverInfo: testServerInfo, }); }); }); diff --git a/server/src/__tests__/instance-settings-routes.test.ts b/server/src/__tests__/instance-settings-routes.test.ts index 3965dacb97..05c0210e73 100644 --- a/server/src/__tests__/instance-settings-routes.test.ts +++ b/server/src/__tests__/instance-settings-routes.test.ts @@ -81,6 +81,7 @@ describe("instance settings routes", () => { enableIssuePlanDecompositions: false, enableExperimentalFileViewer: false, enableCloudSync: false, + enableServerInfoDebugView: false, autoRestartDevServerWhenIdle: false, enableIssueGraphLivenessAutoRecovery: true, issueGraphLivenessAutoRecoveryLookbackHours: 24, @@ -101,6 +102,7 @@ describe("instance settings routes", () => { enableTaskWatchdogs: false, enableCloudSync: false, enableExternalObjects: false, + enableServerInfoDebugView: false, autoRestartDevServerWhenIdle: false, enableIssueGraphLivenessAutoRecovery: true, issueGraphLivenessAutoRecoveryLookbackHours: 24, @@ -119,6 +121,7 @@ describe("instance settings routes", () => { enableIssuePlanDecompositions: true, enableExperimentalFileViewer: true, enableCloudSync: true, + enableServerInfoDebugView: false, autoRestartDevServerWhenIdle: false, enableIssueGraphLivenessAutoRecovery: true, issueGraphLivenessAutoRecoveryLookbackHours: 24, @@ -144,6 +147,7 @@ describe("instance settings routes", () => { enableTaskWatchdogs: true, enableCloudSync: true, enableExternalObjects: false, + enableServerInfoDebugView: true, autoRestartDevServerWhenIdle: false, enableIssueGraphLivenessAutoRecovery: true, issueGraphLivenessAutoRecoveryLookbackHours: 24, @@ -197,6 +201,7 @@ describe("instance settings routes", () => { enableTaskWatchdogs: false, enableCloudSync: false, enableExternalObjects: false, + enableServerInfoDebugView: false, autoRestartDevServerWhenIdle: false, enableIssueGraphLivenessAutoRecovery: true, issueGraphLivenessAutoRecoveryLookbackHours: 24, @@ -292,6 +297,24 @@ describe("instance settings routes", () => { }); }); + it("allows local board users to update the server info debug view", async () => { + const app = await createApp({ + type: "board", + userId: "local-board", + source: "local_implicit", + isInstanceAdmin: true, + }); + + await request(app) + .patch("/api/instance/settings/experimental") + .send({ enableServerInfoDebugView: true }) + .expect(200); + + expect(mockInstanceSettingsService.updateExperimental).toHaveBeenCalledWith({ + enableServerInfoDebugView: true, + }); + }); + it("allows local board users to update issue graph liveness auto-recovery", async () => { const app = await createApp({ type: "board", diff --git a/server/src/__tests__/instance-settings-service.test.ts b/server/src/__tests__/instance-settings-service.test.ts index 143c6fc9c7..0d00f3c0ef 100644 --- a/server/src/__tests__/instance-settings-service.test.ts +++ b/server/src/__tests__/instance-settings-service.test.ts @@ -10,6 +10,7 @@ describe("instance settings service", () => { enableExperimentalFileViewer: true, enableTaskWatchdogs: true, enableCloudSync: true, + enableServerInfoDebugView: true, autoRestartDevServerWhenIdle: true, enableIssueGraphLivenessAutoRecovery: true, issueGraphLivenessAutoRecoveryLookbackHours: 48, @@ -25,6 +26,7 @@ describe("instance settings service", () => { enableExperimentalFileViewer: true, enableTaskWatchdogs: true, enableCloudSync: true, + enableServerInfoDebugView: true, autoRestartDevServerWhenIdle: true, enableIssueGraphLivenessAutoRecovery: true, issueGraphLivenessAutoRecoveryLookbackHours: 48, @@ -48,6 +50,14 @@ describe("instance settings service", () => { ).toBe(false); }); + it("defaults enableServerInfoDebugView to false for empty and legacy stored settings", () => { + expect(normalizeExperimentalSettings(undefined).enableServerInfoDebugView).toBe(false); + expect(normalizeExperimentalSettings({}).enableServerInfoDebugView).toBe(false); + expect( + normalizeExperimentalSettings({ autoRestartDevServerWhenIdle: true }).enableServerInfoDebugView, + ).toBe(false); + }); + it("round-trips an enableConferenceRoomChat patch through the update merge", () => { // updateExperimental merges `{ ...normalize(current), ...patch }` and // re-normalizes; emulate that to prove the flag survives the roundtrip diff --git a/server/src/__tests__/server-info.test.ts b/server/src/__tests__/server-info.test.ts new file mode 100644 index 0000000000..6cb3ec1d7c --- /dev/null +++ b/server/src/__tests__/server-info.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { createServerInfoSnapshot } from "../server-info.js"; + +describe("server info snapshot", () => { + it("captures process start time and git metadata", () => { + const snapshot = createServerInfoSnapshot({ + now: new Date("2026-06-26T00:00:00.000Z"), + gitCommand: () => + [ + "0123456789abcdef0123456789abcdef01234567", + "0123456", + "Add server info debug view", + "2026-06-25T17:00:00-07:00", + ].join("\n"), + }); + + expect(snapshot).toEqual({ + processStartedAt: "2026-06-26T00:00:00.000Z", + git: { + available: true, + fullSha: "0123456789abcdef0123456789abcdef01234567", + shortSha: "0123456", + subject: "Add server info debug view", + committedAt: "2026-06-26T00:00:00.000Z", + }, + }); + }); + + it("uses sanitized fallback metadata when git is unavailable", () => { + const snapshot = createServerInfoSnapshot({ + now: new Date("2026-06-26T00:00:00.000Z"), + gitCommand: () => { + throw new Error("fatal: not a git repository"); + }, + }); + + expect(snapshot).toEqual({ + processStartedAt: "2026-06-26T00:00:00.000Z", + git: { + available: false, + unavailableReason: "git_unavailable", + }, + }); + }); +}); diff --git a/server/src/routes/health.ts b/server/src/routes/health.ts index f5a99ce396..ecbeb51f3d 100644 --- a/server/src/routes/health.ts +++ b/server/src/routes/health.ts @@ -6,6 +6,7 @@ import { heartbeatRuns, instanceUserRoles, invites } from "@paperclipai/db"; import type { DeploymentExposure, DeploymentMode } from "@paperclipai/shared"; import { readPersistedDevServerStatus, toDevServerHealthStatus, writeDevServerRestartRequest } from "../dev-server-status.js"; import { logger } from "../middleware/logger.js"; +import { getServerInfoSnapshot, type ServerInfoSnapshot } from "../server-info.js"; import { instanceSettingsService } from "../services/instance-settings.js"; import { serverVersion } from "../version.js"; @@ -35,6 +36,7 @@ export function healthRoutes( deploymentExposure: DeploymentExposure; authReady: boolean; companyDeletionEnabled: boolean; + serverInfo?: ServerInfoSnapshot; } = { deploymentMode: "local_trusted", deploymentExposure: "private", @@ -84,13 +86,19 @@ export function healthRoutes( actorType, opts.deploymentMode, ); + // serverInfo (git SHA + process start) rides on the full-details responses + // only, so it reaches board/agent actors in authenticated mode or any caller + // in local_trusted dev — never anonymous authenticated callers. The + // enableServerInfoDebugView experimental flag gates the UI surface, not this + // already access-controlled field. + const serverInfo = opts.serverInfo ?? getServerInfoSnapshot(); const exposeDevServerDetails = exposeFullDetails || hasDevServerStatusToken(req.get("x-paperclip-dev-server-status-token")); if (!db) { res.json( exposeFullDetails - ? { status: "ok", version: serverVersion } + ? { status: "ok", version: serverVersion, serverInfo } : { status: "ok", deploymentMode: opts.deploymentMode }, ); return; @@ -103,7 +111,8 @@ export function healthRoutes( res.status(503).json({ status: "unhealthy", version: serverVersion, - error: "database_unreachable" + error: "database_unreachable", + ...(exposeFullDetails ? { serverInfo } : {}), }); return; } @@ -176,6 +185,7 @@ export function healthRoutes( features: { companyDeletionEnabled: opts.companyDeletionEnabled, }, + serverInfo, ...(devServer ? { devServer } : {}), }); }); diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 08714bb109..2fae791ca0 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -768,6 +768,22 @@ registry.registerPath({ deploymentMode: z.string().optional(), bootstrapStatus: z.enum(["ready", "bootstrap_pending"]).optional(), bootstrapInviteActive: z.boolean().optional(), + serverInfo: z.object({ + processStartedAt: z.string().datetime(), + git: z.union([ + z.object({ + available: z.literal(true), + fullSha: z.string(), + shortSha: z.string(), + subject: z.string(), + committedAt: z.string().datetime().nullable(), + }).strict(), + z.object({ + available: z.literal(false), + unavailableReason: z.enum(["git_unavailable", "invalid_git_metadata"]), + }).strict(), + ]), + }).strict().optional(), })), 503: { description: "Service unavailable", content: { "application/json": { schema: ErrorSchema } } }, }, diff --git a/server/src/server-info.ts b/server/src/server-info.ts new file mode 100644 index 0000000000..dcb0f9567c --- /dev/null +++ b/server/src/server-info.ts @@ -0,0 +1,62 @@ +import { execFileSync } from "node:child_process"; +import type { ServerGitInfo, ServerInfoSnapshot } from "@paperclipai/shared"; + +export type { ServerGitInfo, ServerInfoSnapshot }; + +type GitCommand = () => string; + +const FULL_SHA_RE = /^[0-9a-f]{40}$/i; +const SHORT_SHA_RE = /^[0-9a-f]{7,40}$/i; + +function defaultGitCommand() { + return execFileSync( + "git", + ["show", "-s", "--format=%H%n%h%n%s%n%cI", "HEAD"], + { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + timeout: 1500, + }, + ); +} + +function parseGitInfo(output: string): ServerGitInfo { + const [fullSha = "", shortSha = "", subject = "", committedAt = ""] = output + .trimEnd() + .split("\n"); + const committedAtTime = Date.parse(committedAt); + + if (!FULL_SHA_RE.test(fullSha) || !SHORT_SHA_RE.test(shortSha)) { + return { available: false, unavailableReason: "invalid_git_metadata" }; + } + + return { + available: true, + fullSha, + shortSha, + subject: subject.trim() || "No commit subject", + committedAt: Number.isNaN(committedAtTime) ? null : new Date(committedAtTime).toISOString(), + }; +} + +export function createServerInfoSnapshot( + opts: { now?: Date; gitCommand?: GitCommand } = {}, +): ServerInfoSnapshot { + let git: ServerGitInfo; + try { + git = parseGitInfo((opts.gitCommand ?? defaultGitCommand)()); + } catch { + git = { available: false, unavailableReason: "git_unavailable" }; + } + + return { + processStartedAt: (opts.now ?? new Date()).toISOString(), + git, + }; +} + +const serverInfoSnapshot = createServerInfoSnapshot(); + +export function getServerInfoSnapshot(): ServerInfoSnapshot { + return serverInfoSnapshot; +} diff --git a/server/src/services/instance-settings.ts b/server/src/services/instance-settings.ts index 49fb30e1c4..8b99bdedb7 100644 --- a/server/src/services/instance-settings.ts +++ b/server/src/services/instance-settings.ts @@ -54,6 +54,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta enableTaskWatchdogs: parsed.data.enableTaskWatchdogs ?? false, enableCloudSync: parsed.data.enableCloudSync ?? false, enableExternalObjects: parsed.data.enableExternalObjects ?? false, + enableServerInfoDebugView: parsed.data.enableServerInfoDebugView ?? false, autoRestartDevServerWhenIdle: parsed.data.autoRestartDevServerWhenIdle ?? false, enableIssueGraphLivenessAutoRecovery: parsed.data.enableIssueGraphLivenessAutoRecovery ?? false, issueGraphLivenessAutoRecoveryLookbackHours: @@ -72,6 +73,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta enableExperimentalFileViewer: false, enableCloudSync: false, enableExternalObjects: false, + enableServerInfoDebugView: false, autoRestartDevServerWhenIdle: false, enableIssueGraphLivenessAutoRecovery: false, issueGraphLivenessAutoRecoveryLookbackHours: diff --git a/ui/src/api/health.ts b/ui/src/api/health.ts index 453662c5c8..4bc4fd1a78 100644 --- a/ui/src/api/health.ts +++ b/ui/src/api/health.ts @@ -1,3 +1,5 @@ +import type { ServerInfoSnapshot } from "@paperclipai/shared"; + export type DevServerHealthStatus = { enabled: true; restartRequired: boolean; @@ -23,6 +25,7 @@ export type HealthStatus = { features?: { companyDeletionEnabled?: boolean; }; + serverInfo?: ServerInfoSnapshot; devServer?: DevServerHealthStatus; }; diff --git a/ui/src/components/BreadcrumbBar.tsx b/ui/src/components/BreadcrumbBar.tsx index a82acdd458..cde8220b44 100644 --- a/ui/src/components/BreadcrumbBar.tsx +++ b/ui/src/components/BreadcrumbBar.tsx @@ -18,14 +18,17 @@ import { PluginLauncherOutlet, usePluginLaunchers } from "@/plugins/launchers"; type GlobalToolbarContext = { companyId: string | null; companyPrefix: string | null }; -function GlobalToolbarPlugins({ context }: { context: GlobalToolbarContext }) { +function GlobalToolbar({ context }: { context: GlobalToolbarContext }) { const { slots } = usePluginSlots({ slotTypes: ["globalToolbarButton"], companyId: context.companyId }); const { launchers } = usePluginLaunchers({ placementZones: ["globalToolbarButton"], companyId: context.companyId, enabled: !!context.companyId }); - if (slots.length === 0 && launchers.length === 0) return null; return ( -
+ Server +
++ Show a "Server" section in the account drawer with the current server restart time and running commit. +
+