diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index e1e8a5e58b..ddb7ade519 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -983,7 +983,7 @@ export type { CloudUpstreamWarning, } from "./types/cloud-upstream.js"; -export type { ServerGitInfo, ServerInfoSnapshot } from "./types/server-info.js"; +export type { ServerGitInfo, ServerGitLocalChanges, ServerInfoSnapshot } from "./types/server-info.js"; export { getClosedIsolatedExecutionWorkspaceMessage, diff --git a/packages/shared/src/types/server-info.ts b/packages/shared/src/types/server-info.ts index add47f1fda..852404d0cd 100644 --- a/packages/shared/src/types/server-info.ts +++ b/packages/shared/src/types/server-info.ts @@ -1,5 +1,18 @@ // 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 ServerGitLocalChanges = + | { + available: true; + hasLocalChanges: boolean; + stagedFileCount: number; + unstagedFileCount: number; + untrackedFileCount: number; + } + | { + available: false; + unavailableReason: "git_status_unavailable"; + }; + export type ServerGitInfo = | { available: true; @@ -7,6 +20,7 @@ export type ServerGitInfo = shortSha: string; subject: string; committedAt: string | null; + localChanges: ServerGitLocalChanges; } | { available: false; diff --git a/server/src/__tests__/health.test.ts b/server/src/__tests__/health.test.ts index 42b3705772..29abea1059 100644 --- a/server/src/__tests__/health.test.ts +++ b/server/src/__tests__/health.test.ts @@ -15,6 +15,13 @@ const testServerInfo = { shortSha: "0123456", subject: "Add server info debug view", committedAt: "2026-06-25T23:00:00.000Z", + localChanges: { + available: true, + hasLocalChanges: false, + stagedFileCount: 0, + unstagedFileCount: 0, + untrackedFileCount: 0, + }, }, } as const; diff --git a/server/src/__tests__/server-info.test.ts b/server/src/__tests__/server-info.test.ts index 6cb3ec1d7c..a64b62bece 100644 --- a/server/src/__tests__/server-info.test.ts +++ b/server/src/__tests__/server-info.test.ts @@ -1,5 +1,14 @@ -import { describe, expect, it } from "vitest"; -import { createServerInfoSnapshot } from "../server-info.js"; +import { beforeEach, describe, expect, it } from "vitest"; +import { + createServerInfoSnapshot, + getServerInfoSnapshot, + resetServerInfoCacheForTests, +} from "../server-info.js"; + +function gitCommandFor(shortSha: string, subject: string): () => string { + return () => + [shortSha.padEnd(40, "0"), shortSha, subject, "2026-06-25T17:00:00-07:00"].join("\n"); +} describe("server info snapshot", () => { it("captures process start time and git metadata", () => { @@ -12,6 +21,7 @@ describe("server info snapshot", () => { "Add server info debug view", "2026-06-25T17:00:00-07:00", ].join("\n"), + gitStatusCommand: () => "", }); expect(snapshot).toEqual({ @@ -22,6 +32,68 @@ describe("server info snapshot", () => { shortSha: "0123456", subject: "Add server info debug view", committedAt: "2026-06-26T00:00:00.000Z", + localChanges: { + available: true, + hasLocalChanges: false, + stagedFileCount: 0, + unstagedFileCount: 0, + untrackedFileCount: 0, + }, + }, + }); + }); + + it("summarizes local checkout changes without exposing file paths", () => { + 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"), + gitStatusCommand: () => + [ + "M packages/shared/src/types/server-info.ts", + " M ui/src/components/SidebarServerInfo.tsx", + "MM server/src/server-info.ts", + "?? server/src/__tests__/server-info.test.ts", + ].join("\n"), + }); + + expect(snapshot.git).toMatchObject({ + available: true, + localChanges: { + available: true, + hasLocalChanges: true, + stagedFileCount: 2, + unstagedFileCount: 2, + untrackedFileCount: 1, + }, + }); + }); + + it("keeps commit metadata available when git status is unavailable", () => { + 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"), + gitStatusCommand: () => { + throw new Error("status unavailable"); + }, + }); + + expect(snapshot.git).toMatchObject({ + available: true, + localChanges: { + available: false, + unavailableReason: "git_status_unavailable", }, }); }); @@ -43,3 +115,37 @@ describe("server info snapshot", () => { }); }); }); + +describe("getServerInfoSnapshot", () => { + beforeEach(() => { + resetServerInfoCacheForTests(); + }); + + it("re-reads the running commit after the cache TTL expires", () => { + const first = getServerInfoSnapshot({ + now: 0, + gitCommand: gitCommandFor("aaaaaaa", "First boot"), + }); + expect(first.git).toMatchObject({ shortSha: "aaaaaaa", subject: "First boot" }); + + // Within the TTL window the cached commit is reused. + const cached = getServerInfoSnapshot({ + now: 1000, + gitCommand: gitCommandFor("bbbbbbb", "After restart"), + }); + expect(cached.git).toMatchObject({ shortSha: "aaaaaaa", subject: "First boot" }); + + // Past the TTL the new HEAD is picked up without a process restart. + const refreshed = getServerInfoSnapshot({ + now: 3000, + gitCommand: gitCommandFor("bbbbbbb", "After restart"), + }); + expect(refreshed.git).toMatchObject({ shortSha: "bbbbbbb", subject: "After restart" }); + }); + + it("keeps processStartedAt stable across refreshes", () => { + const first = getServerInfoSnapshot({ now: 0, gitCommand: gitCommandFor("aaaaaaa", "a") }); + const second = getServerInfoSnapshot({ now: 5000, gitCommand: gitCommandFor("bbbbbbb", "b") }); + expect(second.processStartedAt).toBe(first.processStartedAt); + }); +}); diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 2fae791ca0..35b7f73af7 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -777,6 +777,19 @@ registry.registerPath({ shortSha: z.string(), subject: z.string(), committedAt: z.string().datetime().nullable(), + localChanges: z.union([ + z.object({ + available: z.literal(true), + hasLocalChanges: z.boolean(), + stagedFileCount: z.number().int().nonnegative(), + unstagedFileCount: z.number().int().nonnegative(), + untrackedFileCount: z.number().int().nonnegative(), + }).strict(), + z.object({ + available: z.literal(false), + unavailableReason: z.enum(["git_status_unavailable"]), + }).strict(), + ]), }).strict(), z.object({ available: z.literal(false), diff --git a/server/src/server-info.ts b/server/src/server-info.ts index dcb0f9567c..369cb1435a 100644 --- a/server/src/server-info.ts +++ b/server/src/server-info.ts @@ -1,5 +1,5 @@ import { execFileSync } from "node:child_process"; -import type { ServerGitInfo, ServerInfoSnapshot } from "@paperclipai/shared"; +import type { ServerGitInfo, ServerGitLocalChanges, ServerInfoSnapshot } from "@paperclipai/shared"; export type { ServerGitInfo, ServerInfoSnapshot }; @@ -20,7 +20,54 @@ function defaultGitCommand() { ); } -function parseGitInfo(output: string): ServerGitInfo { +function defaultGitStatusCommand() { + return execFileSync( + "git", + ["status", "--porcelain=v1", "--untracked-files=normal"], + { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + timeout: 1500, + }, + ); +} + +function parseGitLocalChanges(output: string): ServerGitLocalChanges { + let stagedFileCount = 0; + let unstagedFileCount = 0; + let untrackedFileCount = 0; + + for (const line of output.split(/\r?\n/)) { + if (!line) continue; + const indexStatus = line[0] ?? " "; + const worktreeStatus = line[1] ?? " "; + + if (indexStatus === "?" && worktreeStatus === "?") { + untrackedFileCount += 1; + continue; + } + if (indexStatus !== " " && indexStatus !== "?") stagedFileCount += 1; + if (worktreeStatus !== " " && worktreeStatus !== "?") unstagedFileCount += 1; + } + + return { + available: true, + hasLocalChanges: stagedFileCount + unstagedFileCount + untrackedFileCount > 0, + stagedFileCount, + unstagedFileCount, + untrackedFileCount, + }; +} + +function getGitLocalChanges(gitStatusCommand: GitCommand): ServerGitLocalChanges { + try { + return parseGitLocalChanges(gitStatusCommand()); + } catch { + return { available: false, unavailableReason: "git_status_unavailable" }; + } +} + +function parseGitInfo(output: string, localChanges: ServerGitLocalChanges): ServerGitInfo { const [fullSha = "", shortSha = "", subject = "", committedAt = ""] = output .trimEnd() .split("\n"); @@ -36,27 +83,54 @@ function parseGitInfo(output: string): ServerGitInfo { shortSha, subject: subject.trim() || "No commit subject", committedAt: Number.isNaN(committedAtTime) ? null : new Date(committedAtTime).toISOString(), + localChanges, }; } +function readGitInfo( + gitCommand: GitCommand = defaultGitCommand, + gitStatusCommand: GitCommand = defaultGitStatusCommand, +): ServerGitInfo { + try { + const output = gitCommand(); + const localChanges = getGitLocalChanges(gitStatusCommand); + return parseGitInfo(output, localChanges); + } catch { + return { available: false, unavailableReason: "git_unavailable" }; + } +} + export function createServerInfoSnapshot( - opts: { now?: Date; gitCommand?: GitCommand } = {}, + opts: { now?: Date; gitCommand?: GitCommand; gitStatusCommand?: 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, + git: readGitInfo(opts.gitCommand, opts.gitStatusCommand), }; } -const serverInfoSnapshot = createServerInfoSnapshot(); +// processStartedAt is a true boot constant, but the running commit can change +// without the Node process restarting: a managed dev-server restart re-runs the +// code while keeping this module alive, so a commit captured once at boot goes +// stale. Re-read git HEAD on demand, throttled by a short TTL so frequent health +// polls don't spawn git on every request. +const GIT_INFO_CACHE_TTL_MS = 3000; +const processStartedAt = new Date().toISOString(); +let gitInfoCache: { value: ServerGitInfo; expiresAt: number } | null = null; -export function getServerInfoSnapshot(): ServerInfoSnapshot { - return serverInfoSnapshot; +export function getServerInfoSnapshot( + opts: { now?: number; gitCommand?: GitCommand; gitStatusCommand?: GitCommand } = {}, +): ServerInfoSnapshot { + const now = opts.now ?? Date.now(); + if (!gitInfoCache || now >= gitInfoCache.expiresAt) { + gitInfoCache = { + value: readGitInfo(opts.gitCommand, opts.gitStatusCommand), + expiresAt: now + GIT_INFO_CACHE_TTL_MS, + }; + } + return { processStartedAt, git: gitInfoCache.value }; +} + +export function resetServerInfoCacheForTests(): void { + gitInfoCache = null; } diff --git a/ui/src/components/SidebarServerInfo.test.tsx b/ui/src/components/SidebarServerInfo.test.tsx index 665c1fd45e..5f4f8ef718 100644 --- a/ui/src/components/SidebarServerInfo.test.tsx +++ b/ui/src/components/SidebarServerInfo.test.tsx @@ -29,6 +29,14 @@ async function flushReact() { flushSync(() => {}); } +async function flushReactMicrotasks() { + for (let index = 0; index < 6; index += 1) { + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(0); + } + flushSync(() => {}); +} + function mockEnabledSettings(enabled: boolean) { mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableServerInfoDebugView: enabled, @@ -69,6 +77,7 @@ describe("SidebarServerInfo", () => { container.remove(); document.body.replaceChildren(); vi.clearAllMocks(); + vi.useRealTimers(); }); it("renders nothing while the experimental flag is disabled", async () => { @@ -105,6 +114,13 @@ describe("SidebarServerInfo", () => { shortSha: "abcdef1", subject: "Add server info debug view", committedAt: "2026-06-25T23:00:00.000Z", + localChanges: { + available: true, + hasLocalChanges: false, + stagedFileCount: 0, + unstagedFileCount: 0, + untrackedFileCount: 0, + }, }, }, }); @@ -113,9 +129,174 @@ describe("SidebarServerInfo", () => { expect(container.textContent).toContain("Last restarted"); expect(container.textContent).toContain("Running commit"); + expect(container.textContent).toContain("Checkout state"); expect(container.querySelector('time[dateTime="2026-06-26T01:15:00.000Z"]')).not.toBeNull(); expect(container.textContent).toContain("abcdef1"); expect(container.textContent).toContain("Add server info debug view"); + expect(container.textContent).toContain("Clean checkout"); + }); + + it("polls health while the drawer is open and the dev server is active", async () => { + vi.useFakeTimers(); + mockEnabledSettings(true); + mockHealthApi.get.mockResolvedValue({ + status: "ok", + devServer: { + enabled: true, + restartRequired: false, + reason: null, + lastChangedAt: null, + changedPathCount: 0, + changedPathsSample: [], + pendingMigrations: [], + autoRestartEnabled: false, + activeRunCount: 0, + waitingForIdle: false, + lastRestartAt: "2026-06-26T01:15:00.000Z", + }, + serverInfo: { + processStartedAt: "2026-06-26T00:00:00.000Z", + git: { + available: true, + fullSha: "abcdef1234567890abcdef1234567890abcdef12", + shortSha: "abcdef1", + subject: "Add server info debug view", + committedAt: "2026-06-25T23:00:00.000Z", + localChanges: { + available: true, + hasLocalChanges: false, + stagedFileCount: 0, + unstagedFileCount: 0, + untrackedFileCount: 0, + }, + }, + }, + }); + + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + root = createRoot(container); + flushSync(() => { + root!.render( + + + , + ); + }); + await flushReactMicrotasks(); + + expect(mockHealthApi.get).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(2000); + await flushReactMicrotasks(); + + expect(mockHealthApi.get).toHaveBeenCalledTimes(2); + }); + + it("shows path-free local change counts from health data", async () => { + mockEnabledSettings(true); + mockHealthApi.get.mockResolvedValue({ + status: "ok", + serverInfo: { + processStartedAt: "2026-06-26T00:00:00.000Z", + git: { + available: true, + fullSha: "abcdef1234567890abcdef1234567890abcdef12", + shortSha: "abcdef1", + subject: "Add server info debug view", + committedAt: "2026-06-25T23:00:00.000Z", + localChanges: { + available: true, + hasLocalChanges: true, + stagedFileCount: 3, + unstagedFileCount: 2, + untrackedFileCount: 1, + }, + }, + }, + }); + + await render(); + + expect(container.textContent).toContain("Local changes present (3 staged, 2 unstaged, 1 untracked)"); + expect(container.textContent).not.toContain("server-info.ts"); + }); + + it("refetches fresh health each time the drawer reopens, even within staleTime", async () => { + mockEnabledSettings(true); + const baseHealth = { + status: "ok" as const, + serverInfo: { + processStartedAt: "2026-06-26T00:00:00.000Z", + git: { + available: true, + fullSha: "1111111111111111111111111111111111111111", + shortSha: "1111111", + subject: "First boot", + committedAt: "2026-06-25T23:00:00.000Z", + localChanges: { + available: true, + hasLocalChanges: false, + stagedFileCount: 0, + unstagedFileCount: 0, + untrackedFileCount: 0, + }, + }, + }, + }; + mockHealthApi.get.mockResolvedValue(baseHealth); + + // A long staleTime mirrors the production QueryClient: without + // refetchOnMount "always", reopening the drawer would show cached data. + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: 30_000 } }, + }); + + async function mountDrawer() { + const drawerRoot = createRoot(container); + flushSync(() => { + drawerRoot.render( + + + , + ); + }); + await flushReact(); + return drawerRoot; + } + + const firstOpen = await mountDrawer(); + expect(container.textContent).toContain("First boot"); + flushSync(() => firstOpen.unmount()); + + // Server restarted: a fresh commit and process start should appear on reopen. + mockHealthApi.get.mockResolvedValue({ + ...baseHealth, + serverInfo: { + processStartedAt: "2026-06-26T02:00:00.000Z", + git: { + available: true, + fullSha: "2222222222222222222222222222222222222222", + shortSha: "2222222", + subject: "After restart", + committedAt: "2026-06-26T01:30:00.000Z", + localChanges: { + available: true, + hasLocalChanges: true, + stagedFileCount: 1, + unstagedFileCount: 0, + untrackedFileCount: 0, + }, + }, + }, + }); + + const secondOpen = await mountDrawer(); + expect(container.textContent).toContain("After restart"); + expect(container.textContent).toContain("Local changes present (1 staged)"); + expect(container.textContent).not.toContain("First boot"); + flushSync(() => secondOpen.unmount()); }); it("falls back to process start and unavailable commit copy", async () => { diff --git a/ui/src/components/SidebarServerInfo.tsx b/ui/src/components/SidebarServerInfo.tsx index 8fc843b2e6..88e845adfe 100644 --- a/ui/src/components/SidebarServerInfo.tsx +++ b/ui/src/components/SidebarServerInfo.tsx @@ -1,5 +1,5 @@ import { useQuery } from "@tanstack/react-query"; -import { Clock3, GitCommit, type LucideIcon } from "lucide-react"; +import { Clock3, FileDiff, GitCommit, type LucideIcon } from "lucide-react"; import { healthApi, type HealthStatus } from "@/api/health"; import { instanceSettingsApi } from "@/api/instanceSettings"; import { queryKeys } from "@/lib/queryKeys"; @@ -28,6 +28,25 @@ function commitLabel(health: HealthStatus | undefined): string { return `${git.shortSha} ยท ${git.subject}`; } +function localChangesLabel(health: HealthStatus | undefined): string { + const git = health?.serverInfo?.git; + if (!git?.available) return "Unavailable"; + const localChanges = git.localChanges; + if (!localChanges) return "Change status unavailable"; + if (!localChanges.available) return "Change status unavailable"; + if (!localChanges.hasLocalChanges) return "Clean checkout"; + + const parts = [ + [localChanges.stagedFileCount, "staged"], + [localChanges.unstagedFileCount, "unstaged"], + [localChanges.untrackedFileCount, "untracked"], + ] + .filter(([count]) => Number(count) > 0) + .map(([count, label]) => `${count} ${label}`); + + return parts.length > 0 ? `Local changes present (${parts.join(", ")})` : "Local changes present"; +} + function ServerInfoRow({ icon: Icon, label, @@ -68,6 +87,15 @@ export function SidebarServerInfo() { queryKey: queryKeys.health, queryFn: () => healthApi.get(), enabled, + // The drawer only mounts while the account popover is open, so it cannot + // rely on Layout's background health poll (which is itself gated on + // devServer.enabled). Always refetch on open and poll while open so a server + // restart is reflected without leaving stale boot-time serverInfo on screen. + refetchOnMount: "always", + refetchInterval: (query) => { + const data = query.state.data as HealthStatus | undefined; + return data?.devServer?.enabled ? 2000 : false; + }, }); if (!enabled) return null; @@ -87,6 +115,11 @@ export function SidebarServerInfo() { : isWaitingForHealth ? "Loading..." : commitLabel(health); + const localChanges = healthUnavailable + ? "Health unavailable" + : isWaitingForHealth + ? "Loading..." + : localChangesLabel(health); return (
@@ -100,6 +133,7 @@ export function SidebarServerInfo() { dateTime={!healthUnavailable && !isWaitingForHealth && restartedAtIsValid ? restartedAt : null} /> +
); }