feat(dev): support explicitly empty worktree test instances

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-06 21:37:33 -05:00
1 parent 88dddb8298
commit 1d89022634
3 files changed
+44 -11

No files matched your search

+26
View File
@@ -1472,6 +1472,32 @@ describe("worktree helpers", () => {
}
});
it("creates an explicitly empty worktree without inherited signing secrets or deferred copying", async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-empty-"));
const originalCwd = process.cwd();
const originalJwt = process.env.PAPERCLIP_AGENT_JWT_SECRET;
const originalSigning = process.env.PAPERCLIP_TOOL_ACTION_SIGNING_SECRET;
try {
const repoRoot = path.join(tempRoot, "repo");
fs.mkdirSync(repoRoot, { recursive: true });
process.chdir(repoRoot);
process.env.PAPERCLIP_AGENT_JWT_SECRET = "source-jwt-secret";
process.env.PAPERCLIP_TOOL_ACTION_SIGNING_SECRET = "source-signing-secret";
await worktreeInitCommand({ empty: true, fromConfig: path.join(tempRoot, "missing.json"), home: path.join(tempRoot, "instances") });
const env = fs.readFileSync(path.join(repoRoot, ".paperclip/.env"), "utf8");
expect(env).not.toContain("source-jwt-secret");
expect(env).not.toContain("source-signing-secret");
expect(env).toContain("PAPERCLIP_AGENT_JWT_SECRET=");
expect(fs.existsSync(path.join(repoRoot, ".paperclip/seed-manifest.json"))).toBe(false);
expect(fs.existsSync(path.join(repoRoot, ".paperclip/seed-pending"))).toBe(false);
} finally {
process.chdir(originalCwd);
if (originalJwt === undefined) delete process.env.PAPERCLIP_AGENT_JWT_SECRET; else process.env.PAPERCLIP_AGENT_JWT_SECRET = originalJwt;
if (originalSigning === undefined) delete process.env.PAPERCLIP_TOOL_ACTION_SIGNING_SECRET; else process.env.PAPERCLIP_TOOL_ACTION_SIGNING_SECRET = originalSigning;
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
it("persists the current agent jwt secret into the worktree env file", async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-jwt-"));
const repoRoot = path.join(tempRoot, "repo");
+16 -10
View File
@@ -16,7 +16,7 @@ import {
import os from "node:os";
import path from "node:path";
import { execFileSync } from "node:child_process";
import { randomUUID } from "node:crypto";
import { randomBytes, randomUUID } from "node:crypto";
import { createServer } from "node:net";
import { Readable } from "node:stream";
import * as p from "@clack/prompts";
@@ -118,6 +118,7 @@ type WorktreeInitOptions = {
serverPort?: number;
dbPort?: number;
seed?: boolean;
empty?: boolean;
seedMode?: string;
preserveLiveWork?: boolean;
force?: boolean;
@@ -2434,6 +2435,7 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise<void> {
rmSync(paths.configPath, { force: true });
rmSync(paths.envPath, { force: true });
const seedMarkers = resolveWorktreeSeedMarkerPaths(paths.configPath);
rmSync(seedMarkers.manifest, { force: true });
rmSync(seedMarkers.pending, { force: true });
rmSync(seedMarkers.complete, { force: true });
rmSync(paths.instanceRoot, { recursive: true, force: true });
@@ -2481,18 +2483,20 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise<void> {
};
},
);
markWorktreeSeedPending({
configPath: paths.configPath,
sourceConfigPath,
targetInstanceId: instanceId,
seedMode,
});
if (!opts.empty) {
markWorktreeSeedPending({
configPath: paths.configPath,
sourceConfigPath,
targetInstanceId: instanceId,
seedMode,
});
}
const sourceEnvEntries = readPaperclipEnvEntries(resolvePaperclipEnvFile(sourceConfigPath));
const existingAgentJwtSecret =
nonEmpty(sourceEnvEntries.PAPERCLIP_AGENT_JWT_SECRET) ??
opts.empty ? randomBytes(32).toString("base64url") : nonEmpty(sourceEnvEntries.PAPERCLIP_AGENT_JWT_SECRET) ??
nonEmpty(process.env.PAPERCLIP_AGENT_JWT_SECRET);
const existingToolActionSigningSecret =
nonEmpty(sourceEnvEntries.PAPERCLIP_TOOL_ACTION_SIGNING_SECRET) ??
opts.empty ? randomBytes(32).toString("base64url") : nonEmpty(sourceEnvEntries.PAPERCLIP_TOOL_ACTION_SIGNING_SECRET) ??
nonEmpty(process.env.PAPERCLIP_TOOL_ACTION_SIGNING_SECRET);
mergePaperclipEnvEntries(
{
@@ -2511,7 +2515,7 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise<void> {
let seedExecutionQuarantineSummary: SeededWorktreeExecutionQuarantineSummary | null = null;
let pausedScheduledRoutineCount: number | null = null;
let reboundWorkspaceSummary: SeedWorktreeDatabaseResult["reboundWorkspaces"] = [];
if (opts.seed !== false) {
if (!opts.empty && opts.seed !== false) {
if (!sourceConfig) {
throw new Error(
`Cannot seed worktree database because source config was not found at ${sourceConfigPath}. Use --no-seed or provide --from-config.`,
@@ -4456,6 +4460,7 @@ export function registerWorktreeCommands(program: Command): void {
.option("--seed-mode <mode>", "Seed profile: minimal or full (default: minimal)", "minimal")
.option("--preserve-live-work", "Do not quarantine copied agent work or workspace runtime services in the seeded worktree", false)
.option("--no-seed", "Skip database seeding from the source instance")
.option("--empty", "Create an empty instance without immediate or deferred database copying", false)
.option("--force", "Replace existing repo-local config and isolated instance data", false)
.action(worktreeMakeCommand);
@@ -4473,6 +4478,7 @@ export function registerWorktreeCommands(program: Command): void {
.option("--seed-mode <mode>", "Seed profile: minimal or full (default: minimal)", "minimal")
.option("--preserve-live-work", "Do not quarantine copied agent work or workspace runtime services in the seeded worktree", false)
.option("--no-seed", "Skip database seeding from the source instance")
.option("--empty", "Create an empty instance without immediate or deferred database copying", false)
.option("--force", "Replace existing repo-local config and isolated instance data", false)
.action(worktreeInitCommand);
+2 -1
View File
@@ -885,7 +885,8 @@ Seed modes:
- `minimal` keeps core app state like companies, projects, issues, comments, approvals, and auth state, preserves schema for all tables, but omits row data from heavy operational history such as heartbeat runs, wake requests, activity logs, runtime services, and agent session state
- `full` makes a full logical clone of the source instance
- `--no-seed` creates an empty isolated instance
- `--no-seed` defers database copying until the worktree is first used
- `--empty` creates an empty isolated instance, with fresh signing secrets and no deferred copy; use this for synthetic test drives
Seeded worktree instances quarantine copied live execution by default for both `minimal` and `full` seeds. During restore, Paperclip disables copied agent timer heartbeats, resets copied `running` agents to `idle`, blocks and unassigns copied agent-owned `in_progress` issues, and unassigns copied agent-owned `todo`/`in_review` issues. This keeps a freshly booted worktree from starting agents for work already owned by the source instance. Pass `--preserve-live-work` only when you intentionally want the isolated worktree to resume copied assignments.