mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 11:01:16 +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>
321 lines
9.3 KiB
TypeScript
321 lines
9.3 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import { flushSync } from "react-dom";
|
|
import { createRoot, type Root } from "react-dom/client";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { SidebarServerInfo } from "./SidebarServerInfo";
|
|
|
|
const mockHealthApi = vi.hoisted(() => ({
|
|
get: vi.fn(),
|
|
}));
|
|
const mockInstanceSettingsApi = vi.hoisted(() => ({
|
|
getExperimental: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("@/api/health", () => ({
|
|
healthApi: mockHealthApi,
|
|
}));
|
|
|
|
vi.mock("@/api/instanceSettings", () => ({
|
|
instanceSettingsApi: mockInstanceSettingsApi,
|
|
}));
|
|
|
|
async function flushReact() {
|
|
for (let index = 0; index < 6; index += 1) {
|
|
await Promise.resolve();
|
|
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
|
}
|
|
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,
|
|
});
|
|
}
|
|
|
|
describe("SidebarServerInfo", () => {
|
|
let container: HTMLDivElement;
|
|
let root: Root | null = null;
|
|
|
|
async function render() {
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false } },
|
|
});
|
|
root = createRoot(container);
|
|
flushSync(() => {
|
|
root!.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<SidebarServerInfo />
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
await flushReact();
|
|
}
|
|
|
|
beforeEach(() => {
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
mockHealthApi.get.mockReset();
|
|
mockInstanceSettingsApi.getExperimental.mockReset();
|
|
});
|
|
|
|
afterEach(() => {
|
|
flushSync(() => {
|
|
root?.unmount();
|
|
});
|
|
root = null;
|
|
container.remove();
|
|
document.body.replaceChildren();
|
|
vi.clearAllMocks();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("renders nothing while the experimental flag is disabled", async () => {
|
|
mockEnabledSettings(false);
|
|
|
|
await render();
|
|
|
|
expect(container.textContent).toBe("");
|
|
expect(mockHealthApi.get).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("shows restart and commit rows from health data when enabled", async () => {
|
|
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,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
await render();
|
|
|
|
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(
|
|
<QueryClientProvider client={queryClient}>
|
|
<SidebarServerInfo />
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
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(
|
|
<QueryClientProvider client={queryClient}>
|
|
<SidebarServerInfo />
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
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 () => {
|
|
mockEnabledSettings(true);
|
|
mockHealthApi.get.mockResolvedValue({
|
|
status: "ok",
|
|
serverInfo: {
|
|
processStartedAt: "2026-06-26T00:00:00.000Z",
|
|
git: {
|
|
available: false,
|
|
unavailableReason: "git_unavailable",
|
|
},
|
|
},
|
|
});
|
|
|
|
await render();
|
|
|
|
expect(container.querySelector('time[dateTime="2026-06-26T00:00:00.000Z"]')).not.toBeNull();
|
|
expect(container.textContent).toContain("Commit unavailable");
|
|
});
|
|
});
|