mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 10:26:26 +01:00
## 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>
137 lines
4.1 KiB
TypeScript
137 lines
4.1 KiB
TypeScript
import { execFileSync } from "node:child_process";
|
|
import type { ServerGitInfo, ServerGitLocalChanges, 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 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");
|
|
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(),
|
|
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; gitStatusCommand?: GitCommand } = {},
|
|
): ServerInfoSnapshot {
|
|
return {
|
|
processStartedAt: (opts.now ?? new Date()).toISOString(),
|
|
git: readGitInfo(opts.gitCommand, opts.gitStatusCommand),
|
|
};
|
|
}
|
|
|
|
// 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(
|
|
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;
|
|
}
|