Fix stale server info debug metadata (#8753)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The experimental server info debug view helps local operators
inspect what code a running dev instance is actually serving
> - The view was moved into the account-menu drawer, which only mounts
while that drawer is open
> - That made stale health-query data easier to see after restarts, and
the server was also caching the running commit at process boot
> - A clean commit label alone is incomplete when the checkout has
uncommitted local changes
> - This pull request keeps the drawer health data fresh, refreshes git
metadata on demand, and adds a path-free checkout-state summary
> - The benefit is that the debug view reports restart time, running
commit, and dirty-checkout state without exposing local paths, secrets,
logs, or environment details

## Linked Issues or Issue Description

Fixes: #8752

## What Changed

- `SidebarServerInfo.tsx`: refetch the health query whenever the drawer
opens and poll every 2s while the dev server is active.
- `server-info.ts`: keep `processStartedAt` stable while refreshing git
HEAD through a short TTL cache instead of freezing commit metadata at
module boot.
- Shared health contract/OpenAPI: add `serverInfo.git.localChanges` with
only staged, unstaged, and untracked counts plus safe unavailable
fallbacks.
- `SidebarServerInfo.tsx`: add a `Checkout state` row that renders
clean/dirty/unavailable copy without file paths.
- Tests: cover stale drawer refresh, interval polling, TTL commit
refresh, health response shape, checkout-state count parsing, and
path-free UI rendering.

## Verification

- `npx vitest run server/src/__tests__/server-info.test.ts
server/src/__tests__/health.test.ts
ui/src/components/SidebarServerInfo.test.tsx` -> 3 files / 19 tests
passing.
- `pnpm install --frozen-lockfile --ignore-scripts` -> refreshed stale
workspace links without lockfile/source churn.
- `pnpm --filter @paperclipai/shared --filter @paperclipai/server
--filter @paperclipai/ui typecheck` -> passing.
- `pnpm --filter @paperclipai/ui typecheck` -> passing after the
Greptile test-coverage fix.
- `pnpm check:tokens` -> no forbidden tokens found.
- Local diff scans for obvious secrets, credentials, private URLs, local
paths, and PII patterns -> no matches.
- GitHub PR checks on head `56defd446` -> all green, including `verify`,
canary dry run, e2e, security scans, and Greptile Review.
- Greptile latest summary -> Confidence Score 5/5, 0 new comments; the
prior P2 polling-coverage thread is resolved.

## Risks

Low risk. The UI remains behind the experimental
`enableServerInfoDebugView` flag. The extra git status call is throttled
by the existing server-info TTL and reports only counts, not paths or
file names. If git status is unavailable, the commit row still works and
the checkout-state row shows clear fallback copy.

## Model Used

Claude Opus (claude-opus-4-8), extended thinking, with tool use / code
execution assisted the original stale-metadata fix. OpenAI GPT-5 via
Codex local, with tool use and code execution, added the checkout-state
follow-up, Greptile fix-up, and PR verification.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Devin FoleyandPaperclip authored and GitHub committed 2026-06-29 13:10:58 -07:00
1 parent 5e3d6e3627
commit a7a73d5bc7
8 files changed
+447 -18

No files matched your search

+7
View File
@@ -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;
+108 -2
View File
@@ -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);
});
});
+13
View File
@@ -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),
+88 -14
View File
@@ -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;
}