fix(worktree): disable automatic database backups (#10520)
## 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 <noreply@paperclip.ing>
This commit is contained in:
parent
276ae3a75d
commit
7cfb655f60
|
|
@ -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'");
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -355,6 +355,7 @@ This command:
|
|||
- creates an isolated instance under `~/.paperclip-worktrees/instances/<worktree-id>/`
|
||||
- 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=<worktree-name>`
|
||||
- `PAPERCLIP_WORKTREE_COLOR=<hex-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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -85,6 +85,64 @@ function formatEnvEntries(entries: Record<string, string>): 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, string>): 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<string, string> = {
|
||||
...existingEnvEntries,
|
||||
const managedEnvEntries: Record<string, string> = {
|
||||
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 };
|
||||
|
|
|
|||
Loading…
Reference in New Issue