mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-07 16:11:46 +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>
202 lines
5.5 KiB
TypeScript
202 lines
5.5 KiB
TypeScript
import { execFileSync } from "node:child_process";
|
|
import type { ServerGitInfo, ServerGitLocalChanges, ServerInfoSnapshot } from "@paperclipai/shared";
|
|
import { parseBuildCommit, readBuildCommit } from "./build-commit.js";
|
|
|
|
export type { ServerGitInfo, ServerInfoSnapshot };
|
|
|
|
type GitCommand = () => string;
|
|
type BuildCommitCommand = () => string | null;
|
|
|
|
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 defaultGitBranchCommand() {
|
|
return execFileSync(
|
|
"git",
|
|
["symbolic-ref", "--quiet", "--short", "HEAD"],
|
|
{
|
|
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,
|
|
branchName: string | null,
|
|
localChanges: ServerGitLocalChanges,
|
|
): ServerGitInfo {
|
|
const [fullSha = "", shortSha = "", subject = "", committedAt = ""] = output
|
|
.trimEnd()
|
|
.split("\n");
|
|
const parsedFullSha = parseBuildCommit(fullSha);
|
|
const committedAtTime = Date.parse(committedAt);
|
|
|
|
if (!parsedFullSha || !SHORT_SHA_RE.test(shortSha)) {
|
|
return { available: false, unavailableReason: "invalid_git_metadata" };
|
|
}
|
|
|
|
return {
|
|
available: true,
|
|
fullSha: parsedFullSha,
|
|
shortSha,
|
|
branchName,
|
|
subject: subject.trim() || "No commit subject",
|
|
committedAt: Number.isNaN(committedAtTime) ? null : new Date(committedAtTime).toISOString(),
|
|
localChanges,
|
|
};
|
|
}
|
|
|
|
function readGitInfo(
|
|
gitCommand: GitCommand = defaultGitCommand,
|
|
gitStatusCommand: GitCommand = defaultGitStatusCommand,
|
|
gitBranchCommand: GitCommand = defaultGitBranchCommand,
|
|
buildCommitCommand: BuildCommitCommand = readBuildCommit,
|
|
): ServerGitInfo {
|
|
try {
|
|
const output = gitCommand();
|
|
const localChanges = getGitLocalChanges(gitStatusCommand);
|
|
let branchName: string | null = null;
|
|
try {
|
|
branchName = gitBranchCommand().trim() || null;
|
|
} catch {
|
|
branchName = null;
|
|
}
|
|
return parseGitInfo(output, branchName, localChanges);
|
|
} catch {
|
|
const buildCommit = parseBuildCommit(buildCommitCommand());
|
|
if (!buildCommit) {
|
|
return { available: false, unavailableReason: "git_unavailable" };
|
|
}
|
|
|
|
return {
|
|
available: true,
|
|
fullSha: buildCommit,
|
|
shortSha: buildCommit.slice(0, 7),
|
|
branchName: null,
|
|
subject: "Source build",
|
|
committedAt: null,
|
|
localChanges: {
|
|
available: false,
|
|
unavailableReason: "git_status_unavailable",
|
|
},
|
|
};
|
|
}
|
|
}
|
|
|
|
export function createServerInfoSnapshot(
|
|
opts: {
|
|
now?: Date;
|
|
gitCommand?: GitCommand;
|
|
gitStatusCommand?: GitCommand;
|
|
gitBranchCommand?: GitCommand;
|
|
buildCommitCommand?: BuildCommitCommand;
|
|
} = {},
|
|
): ServerInfoSnapshot {
|
|
return {
|
|
processStartedAt: (opts.now ?? new Date()).toISOString(),
|
|
git: readGitInfo(
|
|
opts.gitCommand,
|
|
opts.gitStatusCommand,
|
|
opts.gitBranchCommand,
|
|
opts.buildCommitCommand,
|
|
),
|
|
};
|
|
}
|
|
|
|
// 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;
|
|
gitBranchCommand?: GitCommand;
|
|
buildCommitCommand?: BuildCommitCommand;
|
|
} = {},
|
|
): ServerInfoSnapshot {
|
|
const now = opts.now ?? Date.now();
|
|
if (!gitInfoCache || now >= gitInfoCache.expiresAt) {
|
|
gitInfoCache = {
|
|
value: readGitInfo(
|
|
opts.gitCommand,
|
|
opts.gitStatusCommand,
|
|
opts.gitBranchCommand,
|
|
opts.buildCommitCommand,
|
|
),
|
|
expiresAt: now + GIT_INFO_CACHE_TTL_MS,
|
|
};
|
|
}
|
|
return { processStartedAt, git: gitInfoCache.value };
|
|
}
|
|
|
|
export function resetServerInfoCacheForTests(): void {
|
|
gitInfoCache = null;
|
|
}
|