mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 01:50:39 +09:00
[codex] Harden execution reliability and heartbeat tooling (#3679)
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - Reliable execution depends on heartbeat routing, issue lifecycle semantics, telemetry, and a fast enough local verification loop to keep regressions visible > - The remaining commits on this branch were mostly server/runtime correctness fixes plus test and documentation follow-ups in that area > - Those changes are logically separate from the UI-focused issue-detail and workspace/navigation branches even when they touch overlapping issue APIs > - This pull request groups the execution reliability, heartbeat, telemetry, and tooling changes into one standalone branch > - The benefit is a focused review of the control-plane correctness work, including the follow-up fix that restored the implicit comment-reopen helpers after branch splitting ## What Changed - Hardened issue/heartbeat execution behavior, including self-review stage skipping, deferred mention wakes during active execution, stranded execution recovery, active-run scoping, assignee resolution, and blocked-to-todo wake resumption - Reduced noisy polling/logging overhead by trimming issue run payloads, compacting persisted run logs, silencing high-volume request logs, and capping heartbeat-run queries in dashboard/inbox surfaces - Expanded telemetry and status semantics with adapter/model fields on task completion plus clearer status guidance in docs/onboarding material - Updated test infrastructure and verification defaults with faster route-test module isolation, cheaper default `pnpm test`, e2e isolation from local state, and repo verification follow-ups - Included docs/release housekeeping from the branch and added a small follow-up commit restoring the implicit comment-reopen helpers that were dropped during branch reconstruction ## Verification - `pnpm vitest run server/src/__tests__/issue-comment-reopen-routes.test.ts server/src/__tests__/issue-telemetry-routes.test.ts` - `pnpm vitest run server/src/__tests__/http-log-policy.test.ts server/src/__tests__/heartbeat-run-log.test.ts server/src/__tests__/health.test.ts` - `server/src/__tests__/activity-service.test.ts`, `server/src/__tests__/heartbeat-comment-wake-batching.test.ts`, and `server/src/__tests__/heartbeat-process-recovery.test.ts` were attempted on this host but the embedded Postgres harness reported init-script/data-dir problems and skipped or failed to start, so they are noted as environment-limited ## Risks - Medium: this branch changes core issue/heartbeat routing and reopen/wakeup behavior, so regressions would affect agent execution flow rather than isolated UI polish - Because it also updates verification infrastructure, reviewers should pay attention to whether the new tests are asserting the right failure modes and not just reshaping harness behavior ## Model Used - OpenAI Codex coding agent (GPT-5-class runtime in Codex CLI; exact deployed model ID is not exposed in this environment), reasoning enabled, tool use and local code execution enabled ## 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) - [ ] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
e89076148a
commit
7f893ac4ec
106 changed files with 4682 additions and 713 deletions
|
|
@ -233,11 +233,15 @@ pnpm dev:once # Full dev without file watching
|
|||
pnpm dev:server # Server only
|
||||
pnpm build # Build all
|
||||
pnpm typecheck # Type checking
|
||||
pnpm test:run # Run tests
|
||||
pnpm test # Cheap default test run (Vitest only)
|
||||
pnpm test:watch # Vitest watch mode
|
||||
pnpm test:e2e # Playwright browser suite
|
||||
pnpm db:generate # Generate DB migration
|
||||
pnpm db:migrate # Apply migrations
|
||||
```
|
||||
|
||||
`pnpm test` does not run Playwright. Browser suites stay separate and are typically run only when working on those flows or in CI.
|
||||
|
||||
See [doc/DEVELOPING.md](https://github.com/paperclipai/paperclip/blob/master/doc/DEVELOPING.md) for the full development guide.
|
||||
|
||||
<br/>
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import {
|
|||
resolveWorktreeReseedTargetPaths,
|
||||
resolveGitWorktreeAddArgs,
|
||||
resolveWorktreeMakeTargetPath,
|
||||
worktreeRepairCommand,
|
||||
worktreeInitCommand,
|
||||
worktreeMakeCommand,
|
||||
worktreeReseedCommand,
|
||||
|
|
@ -844,6 +845,113 @@ describe("worktree helpers", () => {
|
|||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
}, 20_000);
|
||||
|
||||
it("no-ops on the primary checkout unless --branch is provided", async () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-repair-primary-"));
|
||||
const repoRoot = path.join(tempRoot, "repo");
|
||||
const originalCwd = process.cwd();
|
||||
|
||||
try {
|
||||
fs.mkdirSync(repoRoot, { recursive: true });
|
||||
execFileSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" });
|
||||
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repoRoot, stdio: "ignore" });
|
||||
execFileSync("git", ["config", "user.name", "Test User"], { cwd: repoRoot, stdio: "ignore" });
|
||||
fs.writeFileSync(path.join(repoRoot, "README.md"), "# temp\n", "utf8");
|
||||
execFileSync("git", ["add", "README.md"], { cwd: repoRoot, stdio: "ignore" });
|
||||
execFileSync("git", ["commit", "-m", "Initial commit"], { cwd: repoRoot, stdio: "ignore" });
|
||||
|
||||
process.chdir(repoRoot);
|
||||
await worktreeRepairCommand({});
|
||||
|
||||
expect(fs.existsSync(path.join(repoRoot, ".paperclip", "config.json"))).toBe(false);
|
||||
expect(fs.existsSync(path.join(repoRoot, ".paperclip", "worktrees"))).toBe(false);
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("repairs the current linked worktree when Paperclip metadata is missing", async () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-repair-current-"));
|
||||
const repoRoot = path.join(tempRoot, "repo");
|
||||
const worktreePath = path.join(repoRoot, ".paperclip", "worktrees", "repair-me");
|
||||
const sourceConfigPath = path.join(tempRoot, "source-config.json");
|
||||
const worktreeHome = path.join(tempRoot, ".paperclip-worktrees");
|
||||
const worktreePaths = resolveWorktreeLocalPaths({
|
||||
cwd: worktreePath,
|
||||
homeDir: worktreeHome,
|
||||
instanceId: sanitizeWorktreeInstanceId(path.basename(worktreePath)),
|
||||
});
|
||||
const originalCwd = process.cwd();
|
||||
|
||||
try {
|
||||
fs.mkdirSync(repoRoot, { recursive: true });
|
||||
execFileSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" });
|
||||
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repoRoot, stdio: "ignore" });
|
||||
execFileSync("git", ["config", "user.name", "Test User"], { cwd: repoRoot, stdio: "ignore" });
|
||||
fs.writeFileSync(path.join(repoRoot, "README.md"), "# temp\n", "utf8");
|
||||
execFileSync("git", ["add", "README.md"], { cwd: repoRoot, stdio: "ignore" });
|
||||
execFileSync("git", ["commit", "-m", "Initial commit"], { cwd: repoRoot, stdio: "ignore" });
|
||||
fs.mkdirSync(path.dirname(worktreePath), { recursive: true });
|
||||
execFileSync("git", ["worktree", "add", "-b", "repair-me", worktreePath, "HEAD"], {
|
||||
cwd: repoRoot,
|
||||
stdio: "ignore",
|
||||
});
|
||||
|
||||
fs.writeFileSync(sourceConfigPath, JSON.stringify(buildSourceConfig(), null, 2), "utf8");
|
||||
fs.mkdirSync(worktreePaths.instanceRoot, { recursive: true });
|
||||
fs.writeFileSync(path.join(worktreePaths.instanceRoot, "marker.txt"), "stale", "utf8");
|
||||
|
||||
process.chdir(worktreePath);
|
||||
await worktreeRepairCommand({
|
||||
fromConfig: sourceConfigPath,
|
||||
home: worktreeHome,
|
||||
noSeed: true,
|
||||
});
|
||||
|
||||
expect(fs.existsSync(path.join(worktreePath, ".paperclip", "config.json"))).toBe(true);
|
||||
expect(fs.existsSync(path.join(worktreePath, ".paperclip", ".env"))).toBe(true);
|
||||
expect(fs.existsSync(path.join(worktreePaths.instanceRoot, "marker.txt"))).toBe(false);
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
}, 20_000);
|
||||
|
||||
it("creates and repairs a missing branch worktree when --branch is provided", async () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-repair-branch-"));
|
||||
const repoRoot = path.join(tempRoot, "repo");
|
||||
const sourceConfigPath = path.join(tempRoot, "source-config.json");
|
||||
const worktreeHome = path.join(tempRoot, ".paperclip-worktrees");
|
||||
const originalCwd = process.cwd();
|
||||
const expectedWorktreePath = path.join(repoRoot, ".paperclip", "worktrees", "feature-repair-me");
|
||||
|
||||
try {
|
||||
fs.mkdirSync(repoRoot, { recursive: true });
|
||||
execFileSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" });
|
||||
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repoRoot, stdio: "ignore" });
|
||||
execFileSync("git", ["config", "user.name", "Test User"], { cwd: repoRoot, stdio: "ignore" });
|
||||
fs.writeFileSync(path.join(repoRoot, "README.md"), "# temp\n", "utf8");
|
||||
execFileSync("git", ["add", "README.md"], { cwd: repoRoot, stdio: "ignore" });
|
||||
execFileSync("git", ["commit", "-m", "Initial commit"], { cwd: repoRoot, stdio: "ignore" });
|
||||
fs.writeFileSync(sourceConfigPath, JSON.stringify(buildSourceConfig(), null, 2), "utf8");
|
||||
|
||||
process.chdir(repoRoot);
|
||||
await worktreeRepairCommand({
|
||||
branch: "feature/repair-me",
|
||||
fromConfig: sourceConfigPath,
|
||||
home: worktreeHome,
|
||||
noSeed: true,
|
||||
});
|
||||
|
||||
expect(fs.existsSync(path.join(expectedWorktreePath, ".git"))).toBe(true);
|
||||
expect(fs.existsSync(path.join(expectedWorktreePath, ".paperclip", "config.json"))).toBe(true);
|
||||
expect(fs.existsSync(path.join(expectedWorktreePath, ".paperclip", ".env"))).toBe(true);
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
}, 20_000);
|
||||
});
|
||||
|
||||
describeEmbeddedPostgres("pauseSeededScheduledRoutines", () => {
|
||||
|
|
|
|||
|
|
@ -130,6 +130,17 @@ type WorktreeReseedOptions = {
|
|||
allowLiveTarget?: boolean;
|
||||
};
|
||||
|
||||
type WorktreeRepairOptions = {
|
||||
branch?: string;
|
||||
home?: string;
|
||||
fromConfig?: string;
|
||||
fromDataDir?: string;
|
||||
fromInstance?: string;
|
||||
seedMode?: string;
|
||||
noSeed?: boolean;
|
||||
allowLiveTarget?: boolean;
|
||||
};
|
||||
|
||||
type EmbeddedPostgresInstance = {
|
||||
initialise(): Promise<void>;
|
||||
start(): Promise<void>;
|
||||
|
|
@ -550,6 +561,46 @@ function detectGitBranchName(cwd: string): string | null {
|
|||
}
|
||||
}
|
||||
|
||||
function validateGitBranchName(cwd: string, branchName: string): string {
|
||||
const value = nonEmpty(branchName);
|
||||
if (!value) {
|
||||
throw new Error("Branch name is required.");
|
||||
}
|
||||
try {
|
||||
execFileSync("git", ["check-ref-format", "--branch", value], {
|
||||
cwd,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error(`Invalid branch name "${branchName}": ${extractExecSyncErrorMessage(error) ?? String(error)}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function isPrimaryGitWorktree(cwd: string): boolean {
|
||||
const workspace = detectGitWorkspaceInfo(cwd);
|
||||
return Boolean(workspace && workspace.gitDir === workspace.commonDir);
|
||||
}
|
||||
|
||||
function resolvePrimaryGitRepoRoot(cwd: string): string {
|
||||
const workspace = detectGitWorkspaceInfo(cwd);
|
||||
if (!workspace) {
|
||||
throw new Error("Current directory is not inside a git repository.");
|
||||
}
|
||||
if (workspace.gitDir === workspace.commonDir) {
|
||||
return workspace.root;
|
||||
}
|
||||
return path.resolve(workspace.commonDir, "..");
|
||||
}
|
||||
|
||||
function resolveRepairWorktreeDirName(branchName: string): string {
|
||||
const normalized = branchName.trim()
|
||||
.replace(/[^A-Za-z0-9._-]+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^[-._]+|[-._]+$/g, "");
|
||||
return normalized || "worktree";
|
||||
}
|
||||
|
||||
function detectGitWorkspaceInfo(cwd: string): GitWorkspaceInfo | null {
|
||||
try {
|
||||
const root = execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
||||
|
|
@ -773,6 +824,21 @@ export function resolveWorktreeReseedSource(input: WorktreeReseedOptions): Resol
|
|||
);
|
||||
}
|
||||
|
||||
function resolveWorktreeRepairSource(input: WorktreeRepairOptions): ResolvedWorktreeReseedSource {
|
||||
const fromConfig = nonEmpty(input.fromConfig);
|
||||
const fromDataDir = nonEmpty(input.fromDataDir);
|
||||
const fromInstance = nonEmpty(input.fromInstance) ?? "default";
|
||||
const configPath = resolveSourceConfigPath({
|
||||
fromConfig: fromConfig ?? undefined,
|
||||
fromDataDir: fromDataDir ?? undefined,
|
||||
fromInstance,
|
||||
});
|
||||
return {
|
||||
configPath,
|
||||
label: configPath,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveWorktreeReseedTargetPaths(input: {
|
||||
configPath: string;
|
||||
rootPath: string;
|
||||
|
|
@ -794,6 +860,105 @@ export function resolveWorktreeReseedTargetPaths(input: {
|
|||
});
|
||||
}
|
||||
|
||||
function resolveExistingGitWorktree(selector: string, cwd: string): MergeSourceChoice | null {
|
||||
const trimmed = selector.trim();
|
||||
if (trimmed.length === 0) return null;
|
||||
|
||||
const directPath = path.resolve(trimmed);
|
||||
if (existsSync(directPath)) {
|
||||
return {
|
||||
worktree: directPath,
|
||||
branch: null,
|
||||
branchLabel: path.basename(directPath),
|
||||
hasPaperclipConfig: existsSync(path.resolve(directPath, ".paperclip", "config.json")),
|
||||
isCurrent: directPath === path.resolve(cwd),
|
||||
};
|
||||
}
|
||||
|
||||
return toMergeSourceChoices(cwd).find((choice) =>
|
||||
choice.worktree === directPath
|
||||
|| path.basename(choice.worktree) === trimmed
|
||||
|| choice.branchLabel === trimmed
|
||||
|| choice.branch === trimmed,
|
||||
) ?? null;
|
||||
}
|
||||
|
||||
async function ensureRepairTargetWorktree(input: {
|
||||
selector?: string;
|
||||
seedMode: WorktreeSeedMode;
|
||||
opts: WorktreeRepairOptions;
|
||||
}): Promise<ResolvedWorktreeRepairTarget | null> {
|
||||
const cwd = process.cwd();
|
||||
const currentRoot = path.resolve(cwd);
|
||||
const currentConfigPath = path.resolve(currentRoot, ".paperclip", "config.json");
|
||||
|
||||
if (!input.selector) {
|
||||
if (isPrimaryGitWorktree(cwd)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
rootPath: currentRoot,
|
||||
configPath: currentConfigPath,
|
||||
label: path.basename(currentRoot),
|
||||
branchName: detectGitBranchName(cwd),
|
||||
created: false,
|
||||
};
|
||||
}
|
||||
|
||||
const existing = resolveExistingGitWorktree(input.selector, cwd);
|
||||
if (existing) {
|
||||
return {
|
||||
rootPath: existing.worktree,
|
||||
configPath: path.resolve(existing.worktree, ".paperclip", "config.json"),
|
||||
label: existing.branchLabel,
|
||||
branchName: existing.branchLabel === "(detached)" ? null : existing.branchLabel,
|
||||
created: false,
|
||||
};
|
||||
}
|
||||
|
||||
const repoRoot = resolvePrimaryGitRepoRoot(cwd);
|
||||
const branchName = validateGitBranchName(repoRoot, input.selector);
|
||||
const targetPath = path.resolve(
|
||||
repoRoot,
|
||||
".paperclip",
|
||||
"worktrees",
|
||||
resolveRepairWorktreeDirName(branchName),
|
||||
);
|
||||
|
||||
if (existsSync(targetPath)) {
|
||||
throw new Error(`Target path already exists but is not a registered git worktree: ${targetPath}`);
|
||||
}
|
||||
|
||||
mkdirSync(path.dirname(targetPath), { recursive: true });
|
||||
|
||||
const spinner = p.spinner();
|
||||
spinner.start(`Creating git worktree for ${branchName}...`);
|
||||
try {
|
||||
execFileSync("git", resolveGitWorktreeAddArgs({
|
||||
branchName,
|
||||
targetPath,
|
||||
branchExists: localBranchExists(repoRoot, branchName),
|
||||
}), {
|
||||
cwd: repoRoot,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
spinner.stop(`Created git worktree at ${targetPath}.`);
|
||||
} catch (error) {
|
||||
spinner.stop(pc.red("Failed to create git worktree."));
|
||||
throw new Error(extractExecSyncErrorMessage(error) ?? String(error));
|
||||
}
|
||||
|
||||
installDependenciesBestEffort(targetPath);
|
||||
|
||||
return {
|
||||
rootPath: targetPath,
|
||||
configPath: path.resolve(targetPath, ".paperclip", "config.json"),
|
||||
label: branchName,
|
||||
branchName,
|
||||
created: true,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveSourceConnectionString(config: PaperclipConfig, envEntries: Record<string, string>, portOverride?: number): string {
|
||||
if (config.database.mode === "postgres") {
|
||||
const connectionString = nonEmpty(envEntries.DATABASE_URL) ?? nonEmpty(config.database.connectionString);
|
||||
|
|
@ -1205,18 +1370,7 @@ export async function worktreeMakeCommand(nameArg: string, opts: WorktreeMakeOpt
|
|||
throw new Error(extractExecSyncErrorMessage(error) ?? String(error));
|
||||
}
|
||||
|
||||
const installSpinner = p.spinner();
|
||||
installSpinner.start("Installing dependencies...");
|
||||
try {
|
||||
execFileSync("pnpm", ["install"], {
|
||||
cwd: targetPath,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
installSpinner.stop("Installed dependencies.");
|
||||
} catch (error) {
|
||||
installSpinner.stop(pc.yellow("Failed to install dependencies (continuing anyway)."));
|
||||
p.log.warning(extractExecSyncErrorMessage(error) ?? String(error));
|
||||
}
|
||||
installDependenciesBestEffort(targetPath);
|
||||
|
||||
const originalCwd = process.cwd();
|
||||
try {
|
||||
|
|
@ -1233,6 +1387,21 @@ export async function worktreeMakeCommand(nameArg: string, opts: WorktreeMakeOpt
|
|||
}
|
||||
}
|
||||
|
||||
function installDependenciesBestEffort(targetPath: string): void {
|
||||
const installSpinner = p.spinner();
|
||||
installSpinner.start("Installing dependencies...");
|
||||
try {
|
||||
execFileSync("pnpm", ["install"], {
|
||||
cwd: targetPath,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
installSpinner.stop("Installed dependencies.");
|
||||
} catch (error) {
|
||||
installSpinner.stop(pc.yellow("Failed to install dependencies (continuing anyway)."));
|
||||
p.log.warning(extractExecSyncErrorMessage(error) ?? String(error));
|
||||
}
|
||||
}
|
||||
|
||||
type WorktreeCleanupOptions = {
|
||||
instance?: string;
|
||||
home?: string;
|
||||
|
|
@ -1266,6 +1435,14 @@ type ResolvedWorktreeReseedSource = {
|
|||
label: string;
|
||||
};
|
||||
|
||||
type ResolvedWorktreeRepairTarget = {
|
||||
rootPath: string;
|
||||
configPath: string;
|
||||
label: string;
|
||||
branchName: string | null;
|
||||
created: boolean;
|
||||
};
|
||||
|
||||
function parseGitWorktreeList(cwd: string): GitWorktreeListEntry[] {
|
||||
const raw = execFileSync("git", ["worktree", "list", "--porcelain"], {
|
||||
cwd,
|
||||
|
|
@ -2707,10 +2884,7 @@ export async function worktreeMergeHistoryCommand(sourceArg: string | undefined,
|
|||
}
|
||||
}
|
||||
|
||||
export async function worktreeReseedCommand(opts: WorktreeReseedOptions): Promise<void> {
|
||||
printPaperclipCliBanner();
|
||||
p.intro(pc.bgCyan(pc.black(" paperclipai worktree reseed ")));
|
||||
|
||||
async function runWorktreeReseed(opts: WorktreeReseedOptions): Promise<void> {
|
||||
const seedMode = opts.seedMode ?? "full";
|
||||
if (!isWorktreeSeedMode(seedMode)) {
|
||||
throw new Error(`Unsupported seed mode "${seedMode}". Expected one of: minimal, full.`);
|
||||
|
|
@ -2790,6 +2964,96 @@ export async function worktreeReseedCommand(opts: WorktreeReseedOptions): Promis
|
|||
}
|
||||
}
|
||||
|
||||
export async function worktreeReseedCommand(opts: WorktreeReseedOptions): Promise<void> {
|
||||
printPaperclipCliBanner();
|
||||
p.intro(pc.bgCyan(pc.black(" paperclipai worktree reseed ")));
|
||||
await runWorktreeReseed(opts);
|
||||
}
|
||||
|
||||
export async function worktreeRepairCommand(opts: WorktreeRepairOptions): Promise<void> {
|
||||
printPaperclipCliBanner();
|
||||
p.intro(pc.bgCyan(pc.black(" paperclipai worktree repair ")));
|
||||
|
||||
const seedMode = opts.seedMode ?? "minimal";
|
||||
if (!isWorktreeSeedMode(seedMode)) {
|
||||
throw new Error(`Unsupported seed mode "${seedMode}". Expected one of: minimal, full.`);
|
||||
}
|
||||
|
||||
const target = await ensureRepairTargetWorktree({
|
||||
selector: nonEmpty(opts.branch) ?? undefined,
|
||||
seedMode,
|
||||
opts,
|
||||
});
|
||||
if (!target) {
|
||||
p.log.warn("Current checkout is the primary repo worktree. Pass --branch to create or repair a linked worktree.");
|
||||
p.outro(pc.yellow("No worktree repaired."));
|
||||
return;
|
||||
}
|
||||
|
||||
const source = resolveWorktreeRepairSource(opts);
|
||||
if (!existsSync(source.configPath)) {
|
||||
throw new Error(`Source config not found at ${source.configPath}.`);
|
||||
}
|
||||
if (path.resolve(source.configPath) === path.resolve(target.configPath)) {
|
||||
throw new Error("Source and target Paperclip configs are the same. Use --from-config/--from-instance to point repair at a different source.");
|
||||
}
|
||||
|
||||
const targetConfig = existsSync(target.configPath) ? readConfig(target.configPath) : null;
|
||||
const targetEnvEntries = readPaperclipEnvEntries(resolvePaperclipEnvFile(target.configPath));
|
||||
const targetHasWorktreeEnv = Boolean(
|
||||
nonEmpty(targetEnvEntries.PAPERCLIP_HOME) && nonEmpty(targetEnvEntries.PAPERCLIP_INSTANCE_ID),
|
||||
);
|
||||
|
||||
if (targetConfig && targetHasWorktreeEnv && opts.noSeed) {
|
||||
p.log.message(pc.dim(`Target ${target.label} already has worktree-local config/env. Skipping reseed because --no-seed was passed.`));
|
||||
p.outro(pc.green(`Worktree metadata already looks healthy for ${target.label}.`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (targetConfig && targetHasWorktreeEnv) {
|
||||
await runWorktreeReseed({
|
||||
fromConfig: source.configPath,
|
||||
to: target.rootPath,
|
||||
seedMode,
|
||||
yes: true,
|
||||
allowLiveTarget: opts.allowLiveTarget,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const repairInstanceId = sanitizeWorktreeInstanceId(path.basename(target.rootPath));
|
||||
const repairPaths = resolveWorktreeLocalPaths({
|
||||
cwd: target.rootPath,
|
||||
homeDir: resolveWorktreeHome(opts.home),
|
||||
instanceId: repairInstanceId,
|
||||
});
|
||||
const runningTargetPid = readRunningPostmasterPid(path.resolve(repairPaths.embeddedPostgresDataDir, "postmaster.pid"));
|
||||
if (runningTargetPid && !opts.allowLiveTarget) {
|
||||
throw new Error(
|
||||
`Target worktree database appears to be running (pid ${runningTargetPid}). Stop Paperclip in ${target.rootPath} before repairing, or re-run with --allow-live-target if you want to override this guard.`,
|
||||
);
|
||||
}
|
||||
if (runningTargetPid && opts.allowLiveTarget) {
|
||||
p.log.warning(`Proceeding even though the target embedded PostgreSQL appears to be running (pid ${runningTargetPid}).`);
|
||||
}
|
||||
|
||||
const originalCwd = process.cwd();
|
||||
try {
|
||||
process.chdir(target.rootPath);
|
||||
await runWorktreeInit({
|
||||
home: opts.home,
|
||||
fromConfig: source.configPath,
|
||||
fromDataDir: opts.fromDataDir,
|
||||
fromInstance: opts.fromInstance,
|
||||
seed: opts.noSeed ? false : true,
|
||||
seedMode,
|
||||
force: true,
|
||||
});
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
}
|
||||
}
|
||||
|
||||
export function registerWorktreeCommands(program: Command): void {
|
||||
const worktree = program.command("worktree").description("Worktree-local Paperclip instance helpers");
|
||||
|
||||
|
|
@ -2865,6 +3129,19 @@ export function registerWorktreeCommands(program: Command): void {
|
|||
.option("--allow-live-target", "Override the guard that requires the target worktree DB to be stopped first", false)
|
||||
.action(worktreeReseedCommand);
|
||||
|
||||
worktree
|
||||
.command("repair")
|
||||
.description("Create or repair a linked worktree-local Paperclip instance without touching the primary checkout")
|
||||
.option("--branch <name>", "Existing branch/worktree selector to repair, or a branch name to create under .paperclip/worktrees")
|
||||
.option("--home <path>", `Home root for worktree instances (env: PAPERCLIP_WORKTREES_DIR, default: ${DEFAULT_WORKTREE_HOME})`)
|
||||
.option("--from-config <path>", "Source config.json to seed from")
|
||||
.option("--from-data-dir <path>", "Source PAPERCLIP_HOME used when deriving the source config")
|
||||
.option("--from-instance <id>", "Source instance id when deriving the source config (default: default)")
|
||||
.option("--seed-mode <mode>", "Seed profile: minimal or full (default: minimal)", "minimal")
|
||||
.option("--no-seed", "Repair metadata only and skip reseeding when bootstrapping a missing worktree config", false)
|
||||
.option("--allow-live-target", "Override the guard that requires the target worktree DB to be stopped first", false)
|
||||
.action(worktreeRepairCommand);
|
||||
|
||||
program
|
||||
.command("worktree:cleanup")
|
||||
.description("Safely remove a worktree, its branch, and its isolated instance data")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue