From 7cfb655f60a2f98e322d056f27aaeebcafce8d75 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Thu, 30 Jul 2026 17:35:04 -0700 Subject: [PATCH] fix(worktree): disable automatic database backups (#10520) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Paperclip creates isolated instances for linked git worktrees so development does not affect the primary instance > - Those instances inherited the source instance's automatic database-backup setting and also repaired older configs without overriding it > - As worktrees accumulated, each isolated instance could schedule its own backup stream, producing redundant backup churn for disposable database clones > - This pull request makes backup disablement an invariant of worktree config creation and repair > - The benefit is that automatic backups remain focused on the durable primary instance while isolated development instances stop accumulating redundant backup files ## Linked Issues or Issue Description No public GitHub issue exists for this bug, so the report is included here. The closest related open change is Refs #10266, which hardens where worktree config repair may write; this PR changes the backup policy applied by that repair and by worktree initialization. ### What happened? Isolated worktree instances copied `database.backup.enabled` from their source config. When the source instance enabled automatic backups (the normal default), every linked worktree also enabled a scheduled backup stream. Existing worktree configs kept that state during startup repair, so the redundant backups continued after the policy changed. ### Expected behavior Automatic database backups are disabled for isolated worktree instances created by `paperclipai worktree init` or `paperclipai worktree:make`, and legacy worktree configs are migrated to that policy during normal startup repair. The durable primary/default instance keeps its existing backup behavior. ### Steps to reproduce 1. Start from a Paperclip instance whose database backup setting is enabled. 2. Create or initialize a linked worktree with `paperclipai worktree init`. 3. Inspect the generated worktree config and environment. 4. Before this change, the config retained `database.backup.enabled: true` and the environment had no disabling override; after this change, the config is false and `PAPERCLIP_DB_BACKUP_ENABLED=false` is persisted. ### Paperclip version, deployment mode, and environment - Reproduced against `master` before commit `ea5e0a0269`. - Deployment mode: local trusted development with linked git worktrees and embedded PostgreSQL. - Environment: Node.js 22, pnpm workspace install. ## What Changed - Always generate isolated worktree configs with automatic backups disabled. - Persist `PAPERCLIP_DB_BACKUP_ENABLED=false` in generated worktree environments. - Repair existing isolated worktree configs and environments that still enable backups. - Add CLI and server regression coverage for creation and legacy repair paths. - Document the worktree-specific backup policy and primary-instance exception. ## Verification - `pnpm exec vitest run cli/src/__tests__/worktree.test.ts server/src/__tests__/worktree-config.test.ts` — 52 tests passed. - `pnpm -r typecheck` — passed. - `pnpm build` — passed. - All repository commands above were run with inherited worktree runtime identity variables removed. ## Risks - Low operational risk: the change is limited to explicitly isolated worktree instances. - Operators who intentionally relied on automatic backups of disposable worktree databases will now need to run a manual backup or explicitly manage those files outside the scheduled worktree runtime. - No schema, migration, API, UI, lockfile, or workflow changes. > 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 based on GPT-5 (the runtime does not expose a more specific snapshot ID or context-window value), using reasoning, tool use, local code execution, and GitHub CLI integration. ## 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 --- cli/src/__tests__/worktree.test.ts | 2 + cli/src/commands/worktree-lib.ts | 3 +- doc/DEVELOPING.md | 7 ++ server/src/__tests__/worktree-config.test.ts | 64 +++++++++++++++- server/src/worktree-config.ts | 79 +++++++++++++++++++- 5 files changed, 148 insertions(+), 7 deletions(-) diff --git a/cli/src/__tests__/worktree.test.ts b/cli/src/__tests__/worktree.test.ts index b75699caef..bbd055ea1e 100644 --- a/cli/src/__tests__/worktree.test.ts +++ b/cli/src/__tests__/worktree.test.ts @@ -276,6 +276,7 @@ describe("worktree helpers", () => { path.resolve("/tmp/paperclip-worktrees", "instances", "feature-worktree-support", "db"), ); expect(config.database.embeddedPostgresPort).toBe(54339); + expect(config.database.backup.enabled).toBe(false); expect(config.server.port).toBe(3110); expect(config.auth.publicBaseUrl).toBe("http://127.0.0.1:3110/"); expect(config.storage.localDisk.baseDir).toBe( @@ -289,6 +290,7 @@ describe("worktree helpers", () => { expect(env.PAPERCLIP_HOME).toBe(path.resolve("/tmp/paperclip-worktrees")); expect(env.PAPERCLIP_INSTANCE_ID).toBe("feature-worktree-support"); expect(env.PAPERCLIP_IN_WORKTREE).toBe("true"); + expect(env.PAPERCLIP_DB_BACKUP_ENABLED).toBe("false"); expect(env.PAPERCLIP_WORKTREE_NAME).toBe("feature-worktree-support"); expect(env.PAPERCLIP_WORKTREE_COLOR).toBe("#3abf7a"); expect(formatShellExports(env)).toContain("export PAPERCLIP_INSTANCE_ID='feature-worktree-support'"); diff --git a/cli/src/commands/worktree-lib.ts b/cli/src/commands/worktree-lib.ts index 2be4528e50..0d1d60f65e 100644 --- a/cli/src/commands/worktree-lib.ts +++ b/cli/src/commands/worktree-lib.ts @@ -197,7 +197,7 @@ export function buildWorktreeConfig(input: { embeddedPostgresDataDir: paths.embeddedPostgresDataDir, embeddedPostgresPort: databasePort, backup: { - enabled: source?.database.backup.enabled ?? true, + enabled: false, intervalMinutes: source?.database.backup.intervalMinutes ?? 60, retentionDays: source?.database.backup.retentionDays ?? 30, dir: paths.backupDir, @@ -258,6 +258,7 @@ export function buildWorktreeEnvEntries( PAPERCLIP_CONFIG: paths.configPath, PAPERCLIP_CONTEXT: paths.contextPath, PAPERCLIP_IN_WORKTREE: "true", + PAPERCLIP_DB_BACKUP_ENABLED: "false", ...(branding?.name ? { PAPERCLIP_WORKTREE_NAME: branding.name } : {}), ...(branding?.color ? { PAPERCLIP_WORKTREE_COLOR: branding.color } : {}), }; diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index c929036915..0e0fddf80f 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -355,6 +355,7 @@ This command: - creates an isolated instance under `~/.paperclip-worktrees/instances//` - when run inside a linked git worktree, mirrors the effective git hooks into that worktree's private git dir - picks a free app port and embedded PostgreSQL port +- disables automatic database backups for the isolated instance - by default seeds the isolated DB in `minimal` mode from the current effective Paperclip instance/config (repo-local worktree config when present, otherwise the default instance) via a logical SQL snapshot Seed modes: @@ -374,6 +375,7 @@ Provisioned git worktrees also pause seeded routines that still have enabled sch That repo-local env also sets: - `PAPERCLIP_IN_WORKTREE=true` +- `PAPERCLIP_DB_BACKUP_ENABLED=false` - `PAPERCLIP_WORKTREE_NAME=` - `PAPERCLIP_WORKTREE_COLOR=` @@ -655,6 +657,11 @@ schemas. Defaults: - retain 30 days - backup dir: `~/.paperclip/instances/default/data/backups` +Automatic backups are disabled for isolated worktree instances created with +`paperclipai worktree init` or `paperclipai worktree:make`. Existing worktree +configs are migrated to the disabled setting when their server next starts. The +main/default instance keeps the normal enabled-by-default behavior. + Configure these in: ```sh diff --git a/server/src/__tests__/worktree-config.test.ts b/server/src/__tests__/worktree-config.test.ts index ea1a4c8764..1ea6859f99 100644 --- a/server/src/__tests__/worktree-config.test.ts +++ b/server/src/__tests__/worktree-config.test.ts @@ -101,6 +101,10 @@ function buildIsolatedConfig(instanceRoot: string, serverPort: number, databaseP database: { ...config.database, embeddedPostgresPort: databasePort, + backup: { + ...config.database.backup, + enabled: false, + }, }, server: { ...config.server, @@ -155,6 +159,7 @@ describe("worktree config repair", () => { const instanceRoot = path.join(isolatedHome, "instances", "pap-884-ai-commits-component"); expect(repairedConfig.database.embeddedPostgresDataDir).toBe(path.join(instanceRoot, "db")); + expect(repairedConfig.database.backup.enabled).toBe(false); expect(repairedConfig.database.backup.dir).toBe(path.join(instanceRoot, "data", "backups")); expect(repairedConfig.logging.logDir).toBe(path.join(instanceRoot, "logs")); expect(repairedConfig.storage.localDisk.baseDir).toBe(path.join(instanceRoot, "data", "storage")); @@ -163,10 +168,64 @@ describe("worktree config repair", () => { expect(repairedEnv).toContain('PAPERCLIP_INSTANCE_ID="pap-884-ai-commits-component"'); expect(repairedEnv).toContain(`PAPERCLIP_CONFIG=${JSON.stringify(await fs.realpath(configPath))}`); expect(repairedEnv).toContain(`PAPERCLIP_CONTEXT=${JSON.stringify(path.join(isolatedHome, "context.json"))}`); - expect(repairedEnv).toContain('PAPERCLIP_AGENT_JWT_SECRET="shared-secret"'); + expect(repairedEnv).toContain('PAPERCLIP_DB_BACKUP_ENABLED="false"'); + expect(repairedEnv).toContain("PAPERCLIP_AGENT_JWT_SECRET=shared-secret"); expect(process.env.PAPERCLIP_HOME).toBe(isolatedHome); expect(process.env.PORT).toBe("3101"); expect(process.env.PAPERCLIP_INSTANCE_ID).toBe("pap-884-ai-commits-component"); + expect(process.env.PAPERCLIP_DB_BACKUP_ENABLED).toBe("false"); + }); + + it("disables backups in an otherwise isolated existing worktree config", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-backup-migration-")); + const worktreeRoot = path.join(tempRoot, "disable-worktree-backups"); + const paperclipDir = path.join(worktreeRoot, ".paperclip"); + const configPath = path.join(paperclipDir, "config.json"); + const envPath = path.join(paperclipDir, ".env"); + const isolatedHome = path.join(tempRoot, ".paperclip-worktrees"); + const instanceRoot = path.join(isolatedHome, "instances", "disable-worktree-backups"); + + await fs.mkdir(paperclipDir, { recursive: true }); + const legacyIsolatedConfig = buildIsolatedConfig(instanceRoot, 3110, 54339); + legacyIsolatedConfig.database.backup.enabled = true; + await fs.writeFile(configPath, JSON.stringify(legacyIsolatedConfig, null, 2) + "\n", "utf8"); + await fs.writeFile( + envPath, + [ + "# Paperclip environment variables", + "# Keep this operator note during repair", + `PAPERCLIP_HOME=${JSON.stringify(isolatedHome)}`, + 'PAPERCLIP_INSTANCE_ID="disable-worktree-backups"', + `PAPERCLIP_CONFIG=${JSON.stringify(configPath)}`, + 'PAPERCLIP_DB_BACKUP_ENABLED="true" # managed worktree policy', + 'PAPERCLIP_IN_WORKTREE="true"', + 'PAPERCLIP_WORKTREE_NAME="disable-worktree-backups"', + "# Keep this trailing note too", + "", + ].join("\n"), + "utf8", + ); + + process.chdir(worktreeRoot); + process.env.PAPERCLIP_HOME = isolatedHome; + process.env.PAPERCLIP_INSTANCE_ID = "disable-worktree-backups"; + process.env.PAPERCLIP_CONFIG = configPath; + process.env.PAPERCLIP_DB_BACKUP_ENABLED = "true"; + process.env.PAPERCLIP_IN_WORKTREE = "true"; + process.env.PAPERCLIP_WORKTREE_NAME = "disable-worktree-backups"; + + const result = maybeRepairLegacyWorktreeConfigAndEnvFiles(); + const repairedConfig = JSON.parse(await fs.readFile(configPath, "utf8")); + const repairedEnv = await fs.readFile(envPath, "utf8"); + + expect(result).toEqual({ repairedConfig: true, repairedEnv: true }); + expect(repairedConfig.database.backup.enabled).toBe(false); + expect(repairedEnv).toContain( + 'PAPERCLIP_DB_BACKUP_ENABLED="false" # managed worktree policy', + ); + expect(repairedEnv).toContain("# Keep this operator note during repair"); + expect(repairedEnv).toContain("# Keep this trailing note too"); + expect(process.env.PAPERCLIP_DB_BACKUP_ENABLED).toBe("false"); }); it("preserves an externally supplied PORT while repairing worktree config", async () => { @@ -601,7 +660,7 @@ describe("worktree config repair", () => { expect(result).toEqual({ repairedConfig: true, - repairedEnv: false, + repairedEnv: true, }); expect(repairedConfig.database.embeddedPostgresDataDir).toBe(path.join(stableInstanceRoot, "db")); expect(repairedConfig.database.backup.dir).toBe(path.join(stableInstanceRoot, "data", "backups")); @@ -611,6 +670,7 @@ describe("worktree config repair", () => { path.join(stableInstanceRoot, "secrets", "master.key"), ); expect(repairedEnv).toContain(`PAPERCLIP_HOME=${JSON.stringify(isolatedHome)}`); + expect(repairedEnv).toContain('PAPERCLIP_DB_BACKUP_ENABLED="false"'); expect(repairedEnv).not.toContain(`PAPERCLIP_HOME=${JSON.stringify(transientHome)}`); expect(process.env.PAPERCLIP_HOME).toBe(isolatedHome); }); diff --git a/server/src/worktree-config.ts b/server/src/worktree-config.ts index dc5d620528..de8d824c18 100644 --- a/server/src/worktree-config.ts +++ b/server/src/worktree-config.ts @@ -85,6 +85,64 @@ function formatEnvEntries(entries: Record): string { ].join("\n"); } +function trailingEnvComment(rawValue: string): string { + let quote: "\"" | "'" | null = null; + let escaped = false; + + for (let index = 0; index < rawValue.length; index += 1) { + const character = rawValue[index]; + if (escaped) { + escaped = false; + continue; + } + if (quote === "\"" && character === "\\") { + escaped = true; + continue; + } + if (quote !== null) { + if (character === quote) quote = null; + continue; + } + if (character === "\"" || character === "'") { + quote = character; + continue; + } + if (character !== "#" || index === 0 || !/\s/.test(rawValue[index - 1] ?? "")) continue; + + let commentStart = index; + while (commentStart > 0 && /\s/.test(rawValue[commentStart - 1] ?? "")) { + commentStart -= 1; + } + return rawValue.slice(commentStart); + } + + return ""; +} + +function updateEnvFileContents(contents: string, entries: Record): string { + const newline = contents.includes("\r\n") ? "\r\n" : "\n"; + const missingEntries = new Map(Object.entries(entries)); + const lines = contents.split(/\r?\n/).map((rawLine) => { + const match = rawLine.match(/^(\s*(?:export\s+)?)([A-Za-z_][A-Za-z0-9_]*)(\s*=\s*)(.*)$/); + if (!match) return rawLine; + + const [, prefix, key, separator, rawValue] = match; + const value = entries[key]; + if (value === undefined) return rawLine; + + missingEntries.delete(key); + return `${prefix}${key}${separator}${JSON.stringify(value)}${trailingEnvComment(rawValue)}`; + }); + + const insertionIndex = lines.at(-1) === "" ? lines.length - 1 : lines.length; + lines.splice( + insertionIndex, + 0, + ...Array.from(missingEntries, ([key, value]) => `${key}=${JSON.stringify(value)}`), + ); + return lines.join(newline); +} + function isPathInside(candidatePath: string, rootPath: string): boolean { const candidate = path.resolve(candidatePath); const root = path.resolve(rootPath); @@ -355,6 +413,7 @@ function buildIsolatedWorktreeConfig( embeddedPostgresPort: databasePort ?? config.database.embeddedPostgresPort, backup: { ...config.database.backup, + enabled: false, dir: context.backupDir, }, } @@ -399,6 +458,9 @@ function needsWorktreeConfigRepair( context: WorktreeRuntimeContext, ): boolean { if (config.database.mode === "embedded-postgres") { + if (config.database.backup.enabled) { + return true; + } if (!isPathInside(config.database.embeddedPostgresDataDir, context.instanceRoot)) { return true; } @@ -562,23 +624,32 @@ export function maybeRepairLegacyWorktreeConfigAndEnvFiles(): { } const existingEnvEntries = readEnvEntries(context.envPath); - const desiredEnvEntries: Record = { - ...existingEnvEntries, + const managedEnvEntries: Record = { PAPERCLIP_HOME: context.homeDir, PAPERCLIP_INSTANCE_ID: context.instanceId, PAPERCLIP_CONFIG: context.configPath, PAPERCLIP_CONTEXT: context.contextPath, PAPERCLIP_IN_WORKTREE: "true", + PAPERCLIP_DB_BACKUP_ENABLED: "false", PAPERCLIP_WORKTREE_NAME: context.worktreeName, }; - const repairedEnv = Object.entries(desiredEnvEntries).some( + process.env.PAPERCLIP_DB_BACKUP_ENABLED = "false"; + + const repairedEnv = Object.entries(managedEnvEntries).some( ([key, value]) => existingEnvEntries[key] !== value, ); if (repairedEnv) { fs.mkdirSync(path.dirname(context.envPath), { recursive: true }); - fs.writeFileSync(context.envPath, formatEnvEntries(desiredEnvEntries), { mode: 0o600 }); + const existingContents = fs.existsSync(context.envPath) + ? fs.readFileSync(context.envPath, "utf8") + : null; + const repairedContents = + existingContents === null + ? formatEnvEntries(managedEnvEntries) + : updateEnvFileContents(existingContents, managedEnvEntries); + fs.writeFileSync(context.envPath, repairedContents, { mode: 0o600 }); } return { repairedConfig, repairedEnv };