Files
PaperClipAI/server/src/server-info.ts
T
DottaandPaperclip 16b95eece5 fix(server): preserve source SHA without Git metadata (#9638)
## 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>
2026-07-15 20:03:52 -05:00

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;
}