mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-10 03:08:10 +02:00
## Thinking Path > - Paperclip is the open source control plane people use to manage AI-agent companies > - Operators need to identify the exact source build running from the persistent account menu > - PR #9508 added linked source SHA metadata when the server can inspect its Git checkout > - Production images and packaged deployments may not include a `.git` directory even though their build commit is known > - Falling back to the package version in those environments makes the UI look like a formal release and hides the source SHA > - This pull request reads a validated deployment commit marker when Git metadata is unavailable and uses it consistently for server version and server-info responses > - The benefit is that unreleased deployments keep showing an inspectable SHA without changing exact-tag release versions ## Linked Issues or Issue Description Follow-up to #9508. ### Pre-submission checklist - [x] I searched existing open and closed issues and found no duplicate for the no-`.git` deployment fallback. - [x] The behavior reproduces when the server runs without Git metadata but has a known build commit. - [x] The behavior originates in Paperclip's core server build metadata handling, not an adapter, provider, or local configuration. ### What happened? PR #9508 displays source branch and SHA metadata for unreleased builds, but server version and server-info resolution still fall back to the package version when the runtime has no `.git` directory. This is common in production images and packaged deployments. ### Expected behavior When a validated deployment commit is available through `PAPERCLIP_BUILD_COMMIT` or `/app/.paperclip-build-commit`, the server should retain a derived source version and expose SHA metadata even if Git commands are unavailable. Exact release tags should continue using the formal package version. ### Steps to reproduce 1. Build or run Paperclip without a `.git` directory. 2. Provide a full commit SHA through `PAPERCLIP_BUILD_COMMIT` or `/app/.paperclip-build-commit`. 3. Start the server and inspect the version and server-info output. 4. Observe that current `master` returns only the package version and reports Git metadata unavailable. ### Paperclip version or commit Current `master` after #9508. ### Deployment mode Packaged or containerized deployments without runtime Git metadata. ### Installation method Built from source or deployment image. ## What Changed - Add validated build-commit parsing from `PAPERCLIP_BUILD_COMMIT` and `/app/.paperclip-build-commit`. - Preserve source-derived server versions when Git commands are unavailable. - Expose fallback SHA metadata through server-info with an explicit unavailable local-status state. - Keep exact release-tag builds on the formal package version. - Add focused regression tests for parsing, version resolution, and server-info fallback behavior. ## Verification - `pnpm --filter @paperclipai/server exec vitest run src/__tests__/build-commit.test.ts src/__tests__/server-info.test.ts src/__tests__/version.test.ts` - `pnpm --filter @paperclipai/server typecheck` - `git diff --check public/master...HEAD` ## Risks - Low risk: only full 40-character hexadecimal commit values are accepted; malformed or truncated markers preserve the existing fallback behavior. - Deployment tooling must set `PAPERCLIP_BUILD_COMMIT` or write `/app/.paperclip-build-commit` for the fallback to activate. - Fallback server-info cannot provide branch, subject, commit time, or working-tree status without Git metadata, so those fields remain explicitly unavailable. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex using GPT-5.4 with medium reasoning, repository/tool access, shell execution, and code editing; context-window size was not exposed by the runtime. ## 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 pass - [x] Greptile review is 5/5 with no open P2-or-higher comments, recommendations, or follow-ups --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
204 lines
6.1 KiB
TypeScript
204 lines
6.1 KiB
TypeScript
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", () => {
|
|
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"),
|
|
gitBranchCommand: () => "feature/server-info\n",
|
|
gitStatusCommand: () => "",
|
|
});
|
|
|
|
expect(snapshot).toEqual({
|
|
processStartedAt: "2026-06-26T00:00:00.000Z",
|
|
git: {
|
|
available: true,
|
|
fullSha: "0123456789abcdef0123456789abcdef01234567",
|
|
shortSha: "0123456",
|
|
branchName: "feature/server-info",
|
|
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",
|
|
},
|
|
});
|
|
});
|
|
|
|
it("keeps commit metadata available when HEAD is detached", () => {
|
|
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"),
|
|
gitBranchCommand: () => {
|
|
throw new Error("detached HEAD");
|
|
},
|
|
gitStatusCommand: () => "",
|
|
});
|
|
|
|
expect(snapshot.git).toMatchObject({
|
|
available: true,
|
|
branchName: null,
|
|
shortSha: "0123456",
|
|
});
|
|
});
|
|
|
|
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");
|
|
},
|
|
buildCommitCommand: () => null,
|
|
});
|
|
|
|
expect(snapshot).toEqual({
|
|
processStartedAt: "2026-06-26T00:00:00.000Z",
|
|
git: {
|
|
available: false,
|
|
unavailableReason: "git_unavailable",
|
|
},
|
|
});
|
|
});
|
|
|
|
it("uses deployment commit metadata when the runtime has no git directory", () => {
|
|
const snapshot = createServerInfoSnapshot({
|
|
now: new Date("2026-06-26T00:00:00.000Z"),
|
|
gitCommand: () => {
|
|
throw new Error("fatal: not a git repository");
|
|
},
|
|
buildCommitCommand: () => "0123456789abcdef0123456789abcdef01234567",
|
|
});
|
|
|
|
expect(snapshot).toEqual({
|
|
processStartedAt: "2026-06-26T00:00:00.000Z",
|
|
git: {
|
|
available: true,
|
|
fullSha: "0123456789abcdef0123456789abcdef01234567",
|
|
shortSha: "0123456",
|
|
branchName: null,
|
|
subject: "Source build",
|
|
committedAt: null,
|
|
localChanges: {
|
|
available: false,
|
|
unavailableReason: "git_status_unavailable",
|
|
},
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|