mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 09:07:46 +02:00
fix(workspaces): make managed runtimes reliable across restarts (#11740)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Execution workspaces need isolated databases, ports, and runtime services > - Concurrent workspaces could reuse ports or lose service ownership after a restart > - A markerless worktree also needed seed recovery, but normal markerless instances still needed to boot > - This pull request makes seed, port, and service ownership state explicit and recoverable > - It also checks live process and listener identity before it reclaims shared resources > - The benefit is reliable workspace startup, restart, adoption, and concurrent provisioning ## Linked Issues or Issue Description **What happened?** Managed workspaces could lose runtime service ownership after a control-plane restart. Concurrent worktrees could also reuse a port when their parent paths differed. A seed recovery change made every markerless instance resolve a worktree seed source, so normal instances without a source could not start. **Expected behavior** Paperclip must preserve healthy managed services across restarts. It must reserve unique ports across worktree parents. It must provision a registered markerless worktree, but it must skip seed work for a normal markerless instance. **Steps to reproduce** 1. Start two managed worktrees under different parent paths at the same time. 2. Restart the control plane while a managed service stays alive. 3. Start Paperclip with a config that has no seed markers and no registered worktree source. 4. Observe duplicate port selection, lost service adoption, or a seed-source startup error. **Paperclip version or commit** Current `master` plus the workspace runtime reliability changes in this pull request. **Deployment mode** Local development with managed execution workspaces and embedded Postgres. ## What Changed - Added a shared port registry with lease heartbeats, process identity checks, and live listener probes. - Reserved worktree ports across custom parent paths and repaired duplicate legacy assignments. - Preserved and adopted healthy managed services across control-plane restarts. - Reconciled guest bind modes and verified listener ownership before termination or reuse. - Provisioned registered markerless worktree databases and kept normal markerless instance startup as a no-op. - Added CLI, shared, server, and shell regression tests for seed, port, listener, restart, and adoption behavior. - Updated the worktree development documentation. ## Verification - `pnpm exec vitest run cli/src/__tests__/worktree.test.ts --reporter=verbose` — 63 tests passed. - `pnpm exec vitest run packages/shared/src/worktree-port-registry.test.ts --reporter=verbose` — 5 tests passed. - Focused runtime Vitest set — 199 tests passed across 37 suites. - `node --test scripts/__tests__/provision-worktree-self-heal.test.mjs` — 10 tests passed. - `git diff --check` passed. ## Risks - Port reservation now depends on lease and process identity data. The fallback listener probe prevents early reclamation when process metadata is incomplete. - Runtime adoption is stricter about bind and owner identity. The tests cover healthy adoption, stale records, PID reuse, and unrelated listeners. - Markerless seed detection now separates registered worktrees from normal instances. The tests cover both paths. - There are no database schema migrations. > 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 with the `gpt-5` model family. The serving snapshot and context-window size are not exposed. The agent used reasoning, repository tools, code execution, and test execution. ## 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> Co-authored-by: Dev Agent <dev@paperclip.ing>
This commit is contained in:
21 files changed
+2406
-345
No files matched your search
@@ -30,12 +30,14 @@ import {
|
||||
ensureEmbeddedPostgres,
|
||||
ensureWorktreeSeeded,
|
||||
formatWorktreeSeedFailureDiagnostic,
|
||||
inspectLegacyWorktreeDatabase,
|
||||
markWorktreeSeedPending,
|
||||
pauseSeededScheduledRoutines,
|
||||
quarantineSeededWorktreeExecutionState,
|
||||
readWorktreeSeedManifest,
|
||||
readSourceAttachmentBody,
|
||||
rebindWorkspaceCwd,
|
||||
requiresWorktreeSeedCredentialAccount,
|
||||
resolveSourceConfigPath,
|
||||
resolveWorktreeReseedSource,
|
||||
resolveWorktreeReseedTargetPaths,
|
||||
@@ -103,30 +105,36 @@ function mockVerifiedSeedResult() {
|
||||
};
|
||||
}
|
||||
|
||||
async function seedValidWorktreeSource(connectionString: string) {
|
||||
async function seedValidWorktreeSource(
|
||||
connectionString: string,
|
||||
options: { includeCredentialAccount?: boolean; userId?: string } = {},
|
||||
) {
|
||||
const db = createDb(connectionString);
|
||||
const companyId = randomUUID();
|
||||
const issueId = randomUUID();
|
||||
const userId = options.userId ?? "user-existing";
|
||||
const now = new Date();
|
||||
await db.insert(authUsers).values({
|
||||
id: "user-existing",
|
||||
email: "existing@paperclip.ing",
|
||||
name: "Existing User",
|
||||
id: userId,
|
||||
email: userId === "local-board" ? "local@paperclip.local" : "existing@paperclip.ing",
|
||||
name: userId === "local-board" ? "Board" : "Existing User",
|
||||
emailVerified: true,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
await db.insert(authAccounts).values({
|
||||
id: "credential-existing",
|
||||
accountId: "existing@paperclip.ing",
|
||||
providerId: "credential",
|
||||
userId: "user-existing",
|
||||
password: "fixture-password-hash",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
if (options.includeCredentialAccount !== false) {
|
||||
await db.insert(authAccounts).values({
|
||||
id: "credential-existing",
|
||||
accountId: "existing@paperclip.ing",
|
||||
providerId: "credential",
|
||||
userId,
|
||||
password: "fixture-password-hash",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
await db.insert(instanceUserRoles).values({
|
||||
userId: "user-existing",
|
||||
userId,
|
||||
role: "instance_admin",
|
||||
});
|
||||
await db.insert(companies).values({
|
||||
@@ -138,7 +146,7 @@ async function seedValidWorktreeSource(connectionString: string) {
|
||||
await db.insert(companyMemberships).values({
|
||||
companyId,
|
||||
principalType: "user",
|
||||
principalId: "user-existing",
|
||||
principalId: userId,
|
||||
status: "active",
|
||||
});
|
||||
await db.insert(issues).values({
|
||||
@@ -518,6 +526,22 @@ describe("worktree helpers", () => {
|
||||
.toBe("Seed failed during migrations.");
|
||||
});
|
||||
|
||||
it("surfaces the missing credential artifact for authenticated seed validation", () => {
|
||||
expect(formatWorktreeSeedFailureDiagnostic(
|
||||
"source_validation",
|
||||
new Error(
|
||||
"No auth user has a non-empty credential account, instance-admin role, and active company membership. Authenticated worktree seeding requires a credential-backed instance administrator.",
|
||||
),
|
||||
)).toBe(
|
||||
"Seed validation could not find a credential-backed instance administrator with an active company membership. Authenticated instances must create or sign in an administrator before seeding.",
|
||||
);
|
||||
});
|
||||
|
||||
it("requires credential accounts only for authenticated worktree seeds", () => {
|
||||
expect(requiresWorktreeSeedCredentialAccount("local_trusted")).toBe(false);
|
||||
expect(requiresWorktreeSeedCredentialAccount("authenticated")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a source migration journal that diverges from the code journal", () => {
|
||||
expect(() => resolveWorktreeSeedMigrationRevision({
|
||||
status: "upToDate",
|
||||
@@ -565,6 +589,42 @@ describe("worktree helpers", () => {
|
||||
}, "sourcePrefix")).toBe("0002_applied.sql");
|
||||
});
|
||||
|
||||
itEmbeddedPostgres("recognizes positive legacy database schema evidence", async () => {
|
||||
const tempDb = await startEmbeddedPostgresTestDatabase("paperclip-worktree-legacy-evidence-");
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-legacy-config-"));
|
||||
try {
|
||||
const configPath = path.join(tempRoot, "config.json");
|
||||
const sourceConfig = buildSourceConfig();
|
||||
const config: PaperclipConfig = {
|
||||
...sourceConfig,
|
||||
database: {
|
||||
...sourceConfig.database,
|
||||
mode: "postgres",
|
||||
connectionString: tempDb.connectionString,
|
||||
backup: {
|
||||
...sourceConfig.database.backup,
|
||||
enabled: false,
|
||||
intervalMinutes: 60,
|
||||
retentionDays: 30,
|
||||
dir: path.join(tempRoot, "backups"),
|
||||
},
|
||||
},
|
||||
};
|
||||
fs.writeFileSync(configPath, `${JSON.stringify(config)}\n`);
|
||||
fs.writeFileSync(
|
||||
path.join(tempRoot, ".env"),
|
||||
`PAPERCLIP_INSTANCE_ID=legacy-target\nDATABASE_URL=${JSON.stringify(tempDb.connectionString)}\n`,
|
||||
);
|
||||
|
||||
await expect(inspectLegacyWorktreeDatabase(configPath)).resolves.toEqual({
|
||||
migrationRevision: expect.stringMatching(/\.sql$/),
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
await tempDb.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("ensure-seeded seeds once and fast-exits on the verified manifest", async () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-ensure-seeded-"));
|
||||
try {
|
||||
@@ -636,6 +696,149 @@ describe("worktree helpers", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("treats an unregistered markerless config as a normal non-worktree boot", async () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-unregistered-markerless-"));
|
||||
try {
|
||||
const configPath = path.join(tempRoot, "config.json");
|
||||
fs.writeFileSync(configPath, `${JSON.stringify(buildSourceConfig())}\n`);
|
||||
delete process.env.PAPERCLIP_WORKSPACE_BASE_CWD;
|
||||
delete process.env.PAPERCLIP_PROJECT_WORKSPACE_ID;
|
||||
delete process.env.PAPERCLIP_SEED_EXPECTED_COMPANY_ID;
|
||||
|
||||
const inspectLegacyDatabase = vi.fn();
|
||||
const seedDatabase = vi.fn();
|
||||
|
||||
await expect(ensureWorktreeSeeded(
|
||||
{ config: configPath },
|
||||
{ inspectLegacyDatabase, seedDatabase },
|
||||
)).resolves.toEqual({ seeded: false, reason: "legacy_unmarked" });
|
||||
|
||||
expect(inspectLegacyDatabase).not.toHaveBeenCalled();
|
||||
expect(seedDatabase).not.toHaveBeenCalled();
|
||||
expect(readWorktreeSeedManifest(configPath)).toBeNull();
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("honors a legacy complete marker without resolving a seed source", async () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-complete-marker-"));
|
||||
try {
|
||||
const configPath = path.join(tempRoot, "config.json");
|
||||
fs.writeFileSync(configPath, `${JSON.stringify(buildSourceConfig())}\n`);
|
||||
fs.writeFileSync(path.join(tempRoot, "seed-complete"), "complete\n");
|
||||
delete process.env.PAPERCLIP_WORKSPACE_BASE_CWD;
|
||||
|
||||
const inspectLegacyDatabase = vi.fn();
|
||||
const seedDatabase = vi.fn();
|
||||
|
||||
await expect(ensureWorktreeSeeded(
|
||||
{ config: configPath },
|
||||
{ inspectLegacyDatabase, seedDatabase },
|
||||
)).resolves.toEqual({ seeded: false, reason: "complete_marker" });
|
||||
|
||||
expect(inspectLegacyDatabase).not.toHaveBeenCalled();
|
||||
expect(seedDatabase).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("seeds a configured worktree with no seed markers when no legacy database is present", async () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-unmarked-empty-"));
|
||||
try {
|
||||
const sourceConfigPath = path.join(tempRoot, "source", "config.json");
|
||||
const targetRoot = path.join(tempRoot, "worktree");
|
||||
const targetConfigPath = path.join(targetRoot, ".paperclip", "config.json");
|
||||
const targetPaths = resolveWorktreeLocalPaths({
|
||||
cwd: targetRoot,
|
||||
homeDir: path.join(tempRoot, "worktree-home"),
|
||||
instanceId: "unmarked-empty-target",
|
||||
});
|
||||
const sourceConfig = buildSourceConfig();
|
||||
const targetConfig = buildWorktreeConfig({
|
||||
sourceConfig,
|
||||
paths: targetPaths,
|
||||
serverPort: 3194,
|
||||
databasePort: 54994,
|
||||
});
|
||||
fs.mkdirSync(path.dirname(sourceConfigPath), { recursive: true });
|
||||
fs.mkdirSync(path.dirname(targetConfigPath), { recursive: true });
|
||||
fs.writeFileSync(sourceConfigPath, `${JSON.stringify(sourceConfig)}\n`);
|
||||
fs.writeFileSync(path.join(path.dirname(sourceConfigPath), ".env"), "PAPERCLIP_INSTANCE_ID=source\n");
|
||||
fs.writeFileSync(targetConfigPath, `${JSON.stringify(targetConfig)}\n`);
|
||||
fs.writeFileSync(
|
||||
path.join(targetRoot, ".paperclip", ".env"),
|
||||
`PAPERCLIP_HOME=${targetPaths.homeDir}\nPAPERCLIP_INSTANCE_ID=${targetPaths.instanceId}\n`,
|
||||
);
|
||||
const inspectLegacyDatabase = vi.fn().mockResolvedValue(null);
|
||||
const seedDatabase = vi.fn().mockResolvedValue(mockVerifiedSeedResult());
|
||||
|
||||
await expect(ensureWorktreeSeeded(
|
||||
{ config: targetConfigPath, fromConfig: sourceConfigPath },
|
||||
{ inspectLegacyDatabase, seedDatabase },
|
||||
)).resolves.toMatchObject({ seeded: true, reason: "seeded" });
|
||||
|
||||
expect(inspectLegacyDatabase).toHaveBeenCalledWith(targetConfigPath);
|
||||
expect(seedDatabase).toHaveBeenCalledTimes(1);
|
||||
expect(readWorktreeSeedManifest(targetConfigPath)).toMatchObject({
|
||||
state: "verified",
|
||||
phase: "complete",
|
||||
migrationRevision: "0142_test.sql",
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("adopts a markerless legacy worktree only after validating its database schema", async () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-unmarked-legacy-"));
|
||||
try {
|
||||
const sourceConfigPath = path.join(tempRoot, "source", "config.json");
|
||||
const targetRoot = path.join(tempRoot, "worktree");
|
||||
const targetConfigPath = path.join(targetRoot, ".paperclip", "config.json");
|
||||
const targetPaths = resolveWorktreeLocalPaths({
|
||||
cwd: targetRoot,
|
||||
homeDir: path.join(tempRoot, "worktree-home"),
|
||||
instanceId: "unmarked-legacy-target",
|
||||
});
|
||||
const sourceConfig = buildSourceConfig();
|
||||
const targetConfig = buildWorktreeConfig({
|
||||
sourceConfig,
|
||||
paths: targetPaths,
|
||||
serverPort: 3193,
|
||||
databasePort: 54993,
|
||||
});
|
||||
fs.mkdirSync(path.dirname(sourceConfigPath), { recursive: true });
|
||||
fs.mkdirSync(path.dirname(targetConfigPath), { recursive: true });
|
||||
fs.writeFileSync(sourceConfigPath, `${JSON.stringify(sourceConfig)}\n`);
|
||||
fs.writeFileSync(path.join(path.dirname(sourceConfigPath), ".env"), "PAPERCLIP_INSTANCE_ID=source\n");
|
||||
fs.writeFileSync(targetConfigPath, `${JSON.stringify(targetConfig)}\n`);
|
||||
fs.writeFileSync(
|
||||
path.join(targetRoot, ".paperclip", ".env"),
|
||||
`PAPERCLIP_HOME=${targetPaths.homeDir}\nPAPERCLIP_INSTANCE_ID=${targetPaths.instanceId}\n`,
|
||||
);
|
||||
const seedDatabase = vi.fn();
|
||||
|
||||
await expect(ensureWorktreeSeeded(
|
||||
{ config: targetConfigPath, fromConfig: sourceConfigPath },
|
||||
{
|
||||
inspectLegacyDatabase: vi.fn().mockResolvedValue({ migrationRevision: "0141_legacy.sql" }),
|
||||
seedDatabase,
|
||||
},
|
||||
)).resolves.toEqual({ seeded: false, reason: "legacy_database" });
|
||||
|
||||
expect(seedDatabase).not.toHaveBeenCalled();
|
||||
expect(readWorktreeSeedManifest(targetConfigPath)).toMatchObject({
|
||||
state: "verified",
|
||||
phase: "complete",
|
||||
migrationRevision: "0141_legacy.sql",
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("managed ensure-seeded derives a valid source from the registered base workspace", async () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-managed-seed-"));
|
||||
try {
|
||||
@@ -684,7 +887,7 @@ describe("worktree helpers", () => {
|
||||
});
|
||||
|
||||
it.each(["sibling", "foreign_instance", "symlink", "instance_mismatch"] as const)(
|
||||
"managed ensure-seeded rejects a %s manifest source before lock or seed mutation",
|
||||
"managed ensure-seeded re-derives a stale %s manifest source from registration",
|
||||
async (variant) => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), `paperclip-worktree-managed-${variant}-`));
|
||||
try {
|
||||
@@ -727,16 +930,31 @@ describe("worktree helpers", () => {
|
||||
JSON.stringify({ ...manifest, source: { ...manifest.source, instanceId: "foreign" } }),
|
||||
);
|
||||
}
|
||||
const seedDatabase = vi.fn();
|
||||
const seedDatabase = vi.fn().mockResolvedValue(mockVerifiedSeedResult());
|
||||
|
||||
await expect(ensureWorktreeSeeded({
|
||||
config: targetConfigPath,
|
||||
registeredBaseWorkspaceCwd: baseRoot,
|
||||
registeredProjectWorkspaceId: "project-workspace-1",
|
||||
expectedCompanyId: "company-1",
|
||||
}, { seedDatabase })).rejects.toThrow();
|
||||
}, { seedDatabase })).resolves.toMatchObject({ seeded: true, reason: "seeded" });
|
||||
|
||||
expect(seedDatabase).not.toHaveBeenCalled();
|
||||
expect(seedDatabase).toHaveBeenCalledWith(expect.objectContaining({
|
||||
sourceConfigPath: canonicalSource,
|
||||
expectedCompanyId: "company-1",
|
||||
}));
|
||||
expect(readWorktreeSeedManifest(targetConfigPath)).toMatchObject({
|
||||
source: {
|
||||
configPath: canonicalSource,
|
||||
instanceId: "registered-source",
|
||||
},
|
||||
state: "verified",
|
||||
diagnostics: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
message: "Re-derived seed source diagnostics from the registered canonical source.",
|
||||
}),
|
||||
]),
|
||||
});
|
||||
expect(fs.existsSync(path.join(targetRoot, ".paperclip", "seed.lock"))).toBe(false);
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
@@ -1308,6 +1526,93 @@ describe("worktree helpers", () => {
|
||||
}
|
||||
});
|
||||
|
||||
itEmbeddedPostgres(
|
||||
"seeds a local-trusted implicit board user without a credential account",
|
||||
async () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-local-board-seed-"));
|
||||
const worktreeRoot = path.join(tempRoot, "PAP-17696-local-board-seed");
|
||||
const sourceConfigDir = path.join(tempRoot, "source");
|
||||
const sourceConfigPath = path.join(sourceConfigDir, "config.json");
|
||||
const sourceKeyPath = path.join(sourceConfigDir, "secrets", "master.key");
|
||||
const worktreeHome = path.join(tempRoot, ".paperclip-worktrees");
|
||||
const originalCwd = process.cwd();
|
||||
const sourceDb = await startEmbeddedPostgresTestDatabase("paperclip-worktree-local-board-source-");
|
||||
|
||||
try {
|
||||
await seedValidWorktreeSource(sourceDb.connectionString, {
|
||||
includeCredentialAccount: false,
|
||||
userId: "local-board",
|
||||
});
|
||||
fs.mkdirSync(path.dirname(sourceKeyPath), { recursive: true });
|
||||
fs.mkdirSync(worktreeRoot, { recursive: true });
|
||||
|
||||
const sourceConfig = buildSourceConfig();
|
||||
sourceConfig.database = {
|
||||
...sourceConfig.database,
|
||||
mode: "postgres",
|
||||
connectionString: sourceDb.connectionString,
|
||||
};
|
||||
sourceConfig.server.deploymentMode = "local_trusted";
|
||||
sourceConfig.server.exposure = "private";
|
||||
sourceConfig.auth.baseUrlMode = "auto";
|
||||
delete sourceConfig.auth.publicBaseUrl;
|
||||
sourceConfig.secrets.localEncrypted.keyFilePath = sourceKeyPath;
|
||||
|
||||
fs.writeFileSync(sourceConfigPath, `${JSON.stringify(sourceConfig, null, 2)}\n`, "utf8");
|
||||
fs.writeFileSync(sourceKeyPath, "source-master-key", "utf8");
|
||||
|
||||
process.chdir(worktreeRoot);
|
||||
await worktreeInitCommand({
|
||||
name: "PAP-17696-local-board-seed",
|
||||
home: worktreeHome,
|
||||
fromConfig: sourceConfigPath,
|
||||
force: true,
|
||||
});
|
||||
|
||||
const targetConfigPath = path.join(worktreeRoot, ".paperclip", "config.json");
|
||||
const targetConfig = JSON.parse(fs.readFileSync(targetConfigPath, "utf8")) as PaperclipConfig;
|
||||
expect(readWorktreeSeedManifest(targetConfigPath)).toMatchObject({
|
||||
state: "verified",
|
||||
phase: "complete",
|
||||
});
|
||||
|
||||
const { default: EmbeddedPostgres } = await import("embedded-postgres");
|
||||
const targetPg = new EmbeddedPostgres({
|
||||
databaseDir: targetConfig.database.embeddedPostgresDataDir,
|
||||
user: "paperclip",
|
||||
password: "paperclip",
|
||||
port: targetConfig.database.embeddedPostgresPort,
|
||||
persistent: true,
|
||||
initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"],
|
||||
onLog: () => {},
|
||||
onError: () => {},
|
||||
});
|
||||
|
||||
await targetPg.start();
|
||||
try {
|
||||
const targetDb = createDb(
|
||||
`postgres://paperclip:paperclip@127.0.0.1:${targetConfig.database.embeddedPostgresPort}/paperclip`,
|
||||
);
|
||||
const [seededLocalBoard] = await targetDb
|
||||
.select({ id: authUsers.id })
|
||||
.from(authUsers)
|
||||
.where(eq(authUsers.id, "local-board"));
|
||||
const seededAccounts = await targetDb.select().from(authAccounts);
|
||||
expect(seededLocalBoard?.id).toBe("local-board");
|
||||
expect(seededAccounts).toHaveLength(0);
|
||||
await targetDb.$client.end({ timeout: 5 });
|
||||
} finally {
|
||||
await targetPg.stop();
|
||||
}
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
await sourceDb.cleanup();
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
30_000,
|
||||
);
|
||||
|
||||
itEmbeddedPostgres(
|
||||
"seeds a lagging source whose migration application order differs from filename order",
|
||||
async () => {
|
||||
@@ -1535,6 +1840,61 @@ describe("worktree helpers", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("reserves distinct ports for postgres-mode siblings under a custom worktree parent", async () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-custom-parent-"));
|
||||
const homeDir = path.join(tempRoot, ".paperclip-worktrees");
|
||||
const customParentDir = path.join(tempRoot, "custom", "workspace-lanes");
|
||||
const firstWorktreeRoot = path.join(customParentDir, "lane-one");
|
||||
const secondWorktreeRoot = path.join(customParentDir, "lane-two");
|
||||
const missingSourceConfig = path.join(tempRoot, "missing", "config.json");
|
||||
const firstConfigPath = path.join(firstWorktreeRoot, ".paperclip", "config.json");
|
||||
const secondConfigPath = path.join(secondWorktreeRoot, ".paperclip", "config.json");
|
||||
const originalCwd = process.cwd();
|
||||
|
||||
try {
|
||||
fs.mkdirSync(firstWorktreeRoot, { recursive: true });
|
||||
fs.mkdirSync(secondWorktreeRoot, { recursive: true });
|
||||
|
||||
process.chdir(firstWorktreeRoot);
|
||||
await worktreeInitCommand({
|
||||
name: "lane-one",
|
||||
seed: false,
|
||||
fromConfig: missingSourceConfig,
|
||||
home: homeDir,
|
||||
});
|
||||
|
||||
const firstConfig = JSON.parse(fs.readFileSync(firstConfigPath, "utf8"));
|
||||
firstConfig.database = {
|
||||
...firstConfig.database,
|
||||
mode: "postgres",
|
||||
connectionString: "postgres://paperclip:paperclip@127.0.0.1:54330/paperclip",
|
||||
};
|
||||
fs.writeFileSync(firstConfigPath, `${JSON.stringify(firstConfig, null, 2)}\n`, "utf8");
|
||||
|
||||
process.chdir(secondWorktreeRoot);
|
||||
await worktreeInitCommand({
|
||||
name: "lane-two",
|
||||
seed: false,
|
||||
fromConfig: missingSourceConfig,
|
||||
home: homeDir,
|
||||
});
|
||||
|
||||
const secondConfig = JSON.parse(fs.readFileSync(secondConfigPath, "utf8"));
|
||||
const registry = JSON.parse(
|
||||
fs.readFileSync(path.join(homeDir, "worktree-port-reservations.json"), "utf8"),
|
||||
);
|
||||
|
||||
expect(secondConfig.server.port).not.toBe(firstConfig.server.port);
|
||||
expect(secondConfig.database.embeddedPostgresPort).not.toBe(
|
||||
firstConfig.database.embeddedPostgresPort,
|
||||
);
|
||||
expect(registry.configPaths).toEqual([firstConfigPath, secondConfigPath].sort());
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("defaults the seed source config to the current repo-local Paperclip config", () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-source-config-"));
|
||||
const repoRoot = path.join(tempRoot, "repo");
|
||||
|
||||
+272
-85
@@ -22,7 +22,15 @@ import { Readable } from "node:stream";
|
||||
import * as p from "@clack/prompts";
|
||||
import pc from "picocolors";
|
||||
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||
import { resolveCanonicalWorktreeSeedSource } from "@paperclipai/shared/worktree-seed-source";
|
||||
import {
|
||||
resolveCanonicalWorktreeSeedSource,
|
||||
resolveRegisteredWorktreeSeedSource,
|
||||
} from "@paperclipai/shared/worktree-seed-source";
|
||||
import {
|
||||
readWorktreePortRegistry,
|
||||
withWorktreePortRegistryLock,
|
||||
writeWorktreePortRegistry,
|
||||
} from "@paperclipai/shared/worktree-port-registry";
|
||||
import {
|
||||
applyPendingMigrations,
|
||||
agents,
|
||||
@@ -239,10 +247,19 @@ type SeedWorktreeDatabase = typeof seedWorktreeDatabase;
|
||||
|
||||
export type EnsureWorktreeSeededResult = {
|
||||
seeded: boolean;
|
||||
reason: "seeded" | "verified_manifest" | "complete_marker" | "legacy_unmarked";
|
||||
reason:
|
||||
| "seeded"
|
||||
| "verified_manifest"
|
||||
| "complete_marker"
|
||||
| "legacy_unmarked"
|
||||
| "legacy_database";
|
||||
details?: SeedWorktreeDatabaseResult;
|
||||
};
|
||||
|
||||
export type LegacyWorktreeDatabaseEvidence = {
|
||||
migrationRevision: string;
|
||||
};
|
||||
|
||||
export type SeededWorktreeExecutionQuarantineSummary = {
|
||||
disabledTimerHeartbeats: number;
|
||||
resetRunningAgents: number;
|
||||
@@ -584,13 +601,24 @@ function resolveRepoManagedWorktreesRoot(cwd: string): string | null {
|
||||
return path.resolve(repoRoot, ".paperclip", "worktrees");
|
||||
}
|
||||
|
||||
function collectClaimedWorktreePorts(homeDir: string, currentInstanceId: string, cwd: string): {
|
||||
function collectClaimedWorktreePorts(
|
||||
homeDir: string,
|
||||
currentInstanceId: string,
|
||||
cwd: string,
|
||||
registeredConfigPaths: Iterable<string> = [],
|
||||
): {
|
||||
serverPorts: Set<number>;
|
||||
databasePorts: Set<number>;
|
||||
} {
|
||||
const serverPorts = new Set<number>();
|
||||
const databasePorts = new Set<number>();
|
||||
const configPaths = new Set<string>();
|
||||
for (const configPath of registeredConfigPaths) {
|
||||
const resolvedConfigPath = path.resolve(configPath);
|
||||
if (resolvedConfigPath !== path.resolve(cwd, ".paperclip", "config.json") && existsSync(resolvedConfigPath)) {
|
||||
configPaths.add(resolvedConfigPath);
|
||||
}
|
||||
}
|
||||
const instancesDir = path.resolve(homeDir, "instances");
|
||||
if (existsSync(instancesDir)) {
|
||||
for (const entry of readdirSync(instancesDir, { withFileTypes: true })) {
|
||||
@@ -620,8 +648,13 @@ function collectClaimedWorktreePorts(homeDir: string, currentInstanceId: string,
|
||||
if (config?.server.port) {
|
||||
serverPorts.add(config.server.port);
|
||||
}
|
||||
if (config?.database.mode === "embedded-postgres") {
|
||||
databasePorts.add(config.database.embeddedPostgresPort);
|
||||
const databasePort = config?.database.embeddedPostgresPort;
|
||||
if (
|
||||
typeof databasePort === "number" &&
|
||||
Number.isInteger(databasePort) &&
|
||||
databasePort > 0
|
||||
) {
|
||||
databasePorts.add(databasePort);
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed sibling configs.
|
||||
@@ -1423,6 +1456,12 @@ type WorktreeSeedValidationExpectation = {
|
||||
representativeIssueId: string;
|
||||
};
|
||||
|
||||
export function requiresWorktreeSeedCredentialAccount(
|
||||
deploymentMode: PaperclipConfig["server"]["deploymentMode"],
|
||||
): boolean {
|
||||
return deploymentMode === "authenticated";
|
||||
}
|
||||
|
||||
export function resolveWorktreeSeedMigrationRevision(
|
||||
migrationState: Awaited<ReturnType<typeof inspectMigrations>>,
|
||||
requirement: "sourcePrefix" | "upToDate",
|
||||
@@ -1452,12 +1491,67 @@ export function resolveWorktreeSeedMigrationRevision(
|
||||
return migrationRevision;
|
||||
}
|
||||
|
||||
/**
|
||||
* Markerless worktrees predate the versioned seed manifest. Adopt one only
|
||||
* after proving that its configured database already has a compatible
|
||||
* migration journal and the core Paperclip tables. The physical PG_VERSION
|
||||
* check prevents this read-only probe from initializing a missing embedded
|
||||
* database and then mistaking that empty cluster for legacy evidence.
|
||||
*/
|
||||
export async function inspectLegacyWorktreeDatabase(
|
||||
configPath: string,
|
||||
): Promise<LegacyWorktreeDatabaseEvidence | null> {
|
||||
const config = readConfig(configPath);
|
||||
if (!config) return null;
|
||||
|
||||
const envEntries = readPaperclipEnvEntries(resolvePaperclipEnvFile(configPath));
|
||||
let embeddedHandle: EmbeddedPostgresHandle | null = null;
|
||||
let db: ReturnType<typeof createDb> | null = null;
|
||||
try {
|
||||
if (config.database.mode === "embedded-postgres") {
|
||||
const dataDir = resolveRuntimeLikePath(config.database.embeddedPostgresDataDir, configPath);
|
||||
if (!existsSync(path.join(dataDir, "PG_VERSION"))) return null;
|
||||
embeddedHandle = await ensureEmbeddedPostgres(dataDir, config.database.embeddedPostgresPort);
|
||||
}
|
||||
|
||||
const connectionString = resolveSourceConnectionString(config, envEntries, embeddedHandle?.port);
|
||||
const migrationRevision = resolveWorktreeSeedMigrationRevision(
|
||||
await inspectMigrations(connectionString),
|
||||
"sourcePrefix",
|
||||
);
|
||||
db = createDb(connectionString);
|
||||
await Promise.all([
|
||||
db.select({ id: authUsers.id }).from(authUsers).limit(1),
|
||||
db.select({ id: companies.id }).from(companies).limit(1),
|
||||
db.select({ id: issues.id }).from(issues).limit(1),
|
||||
]);
|
||||
return { migrationRevision };
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
await db?.$client?.end?.({ timeout: 5 }).catch(() => undefined);
|
||||
if (embeddedHandle?.startedByThisProcess) {
|
||||
await embeddedHandle.stop().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function inspectVerifiedSeedDatabase(
|
||||
connectionString: string,
|
||||
expected?: WorktreeSeedValidationExpectation,
|
||||
migrationRequirement: "sourcePrefix" | "upToDate" = "upToDate",
|
||||
requiredCompanyId?: string,
|
||||
options: {
|
||||
deploymentMode: PaperclipConfig["server"]["deploymentMode"];
|
||||
expected?: WorktreeSeedValidationExpectation;
|
||||
migrationRequirement?: "sourcePrefix" | "upToDate";
|
||||
requiredCompanyId?: string;
|
||||
},
|
||||
): Promise<{ summary: WorktreeSeedValidationSummary; expectation: WorktreeSeedValidationExpectation }> {
|
||||
const {
|
||||
deploymentMode,
|
||||
expected,
|
||||
migrationRequirement = "upToDate",
|
||||
requiredCompanyId,
|
||||
} = options;
|
||||
const requiresCredentialAccount = requiresWorktreeSeedCredentialAccount(deploymentMode);
|
||||
const migrationState = await inspectMigrations(connectionString);
|
||||
const migrationRevision = resolveWorktreeSeedMigrationRevision(
|
||||
migrationState,
|
||||
@@ -1499,13 +1593,9 @@ async function inspectVerifiedSeedDatabase(
|
||||
instanceUserRoles,
|
||||
and(eq(instanceUserRoles.userId, authUsers.id), eq(instanceUserRoles.role, "instance_admin")),
|
||||
)
|
||||
.innerJoin(
|
||||
.leftJoin(
|
||||
authAccounts,
|
||||
and(
|
||||
eq(authAccounts.userId, authUsers.id),
|
||||
sql`length(trim(${authAccounts.providerId})) > 0`,
|
||||
sql`length(trim(${authAccounts.accountId})) > 0`,
|
||||
),
|
||||
eq(authAccounts.userId, authUsers.id),
|
||||
)
|
||||
.innerJoin(
|
||||
companyMemberships,
|
||||
@@ -1518,12 +1608,20 @@ async function inspectVerifiedSeedDatabase(
|
||||
.where(and(
|
||||
expected ? eq(authUsers.id, expected.adminUserId) : undefined,
|
||||
requiredCompanyId ? eq(companyMemberships.companyId, requiredCompanyId) : undefined,
|
||||
requiresCredentialAccount
|
||||
? and(
|
||||
sql`length(trim(${authAccounts.providerId})) > 0`,
|
||||
sql`length(trim(${authAccounts.accountId})) > 0`,
|
||||
)
|
||||
: undefined,
|
||||
))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!admin) {
|
||||
throw new Error(
|
||||
"No auth user has a non-empty credential account, instance-admin role, and active company membership.",
|
||||
requiresCredentialAccount
|
||||
? "No auth user has a non-empty credential account, instance-admin role, and active company membership. Authenticated worktree seeding requires a credential-backed instance administrator."
|
||||
: "No auth user has an instance-admin role and active company membership for local-trusted worktree seeding.",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1557,7 +1655,7 @@ async function inspectVerifiedSeedDatabase(
|
||||
};
|
||||
if (
|
||||
summary.authUserCount < 1
|
||||
|| summary.credentialAccountCount < 1
|
||||
|| (requiresCredentialAccount && summary.credentialAccountCount < 1)
|
||||
|| summary.instanceAdminCount < 1
|
||||
|| summary.activeMembershipCount < 1
|
||||
|| summary.companyCount < 1
|
||||
@@ -1613,9 +1711,11 @@ async function seedWorktreeDatabase(input: {
|
||||
input.onPhase?.("source_validation", "started");
|
||||
const sourceValidation = await inspectVerifiedSeedDatabase(
|
||||
sourceConnectionString,
|
||||
undefined,
|
||||
"sourcePrefix",
|
||||
input.expectedCompanyId,
|
||||
{
|
||||
deploymentMode: input.sourceConfig.server.deploymentMode,
|
||||
migrationRequirement: "sourcePrefix",
|
||||
requiredCompanyId: input.expectedCompanyId,
|
||||
},
|
||||
);
|
||||
input.onPhase?.(
|
||||
"source_validation",
|
||||
@@ -1684,7 +1784,10 @@ async function seedWorktreeDatabase(input: {
|
||||
input.onPhase?.("post_restore_validation", "started");
|
||||
const targetValidation = await inspectVerifiedSeedDatabase(
|
||||
targetConnectionString,
|
||||
sourceValidation.expectation,
|
||||
{
|
||||
deploymentMode: input.targetConfig.server.deploymentMode,
|
||||
expected: sourceValidation.expectation,
|
||||
},
|
||||
);
|
||||
input.onPhase?.(
|
||||
"post_restore_validation",
|
||||
@@ -1729,6 +1832,13 @@ export function formatWorktreeSeedFailureDiagnostic(
|
||||
if (phase === "restore" && /Cannot seed target embedded PostgreSQL.+already running/i.test(message)) {
|
||||
return "Target embedded PostgreSQL is owned by a running worktree service. Stop that service and retry the seed.";
|
||||
}
|
||||
if (
|
||||
/No auth user has a non-empty credential account, instance-admin role, and active company membership/i.test(
|
||||
message,
|
||||
)
|
||||
) {
|
||||
return "Seed validation could not find a credential-backed instance administrator with an active company membership. Authenticated instances must create or sign in an administrator before seeding.";
|
||||
}
|
||||
return `Seed failed during ${phase}.`;
|
||||
}
|
||||
|
||||
@@ -1841,6 +1951,7 @@ export function markWorktreeSeedPending(input: {
|
||||
targetInstanceId?: string;
|
||||
seedMode?: WorktreeSeedMode;
|
||||
now?: Date;
|
||||
diagnosticMessage?: string;
|
||||
}): void {
|
||||
const markers = resolveWorktreeSeedMarkerPaths(input.configPath);
|
||||
const at = (input.now ?? new Date()).toISOString();
|
||||
@@ -1859,7 +1970,14 @@ export function markWorktreeSeedPending(input: {
|
||||
attemptId: randomUUID(),
|
||||
startedAt: null,
|
||||
finishedAt: null,
|
||||
diagnostics: [{ phase: "pending", status: "succeeded", at }],
|
||||
diagnostics: [{
|
||||
phase: "pending",
|
||||
status: "succeeded",
|
||||
at,
|
||||
...(input.diagnosticMessage
|
||||
? { message: input.diagnosticMessage.slice(0, WORKTREE_SEED_DIAGNOSTIC_MESSAGE_LIMIT) }
|
||||
: {}),
|
||||
}],
|
||||
});
|
||||
// New manifests are authoritative. Legacy files are removed so no caller can
|
||||
// mistake a stale binary marker for current verified seed state.
|
||||
@@ -2135,11 +2253,14 @@ async function runVerifiedWorktreeSeed(input: {
|
||||
|
||||
export async function ensureWorktreeSeeded(
|
||||
opts: WorktreeEnsureSeededOptions = {},
|
||||
dependencies: { seedDatabase?: SeedWorktreeDatabase } = {},
|
||||
dependencies: {
|
||||
seedDatabase?: SeedWorktreeDatabase;
|
||||
inspectLegacyDatabase?: typeof inspectLegacyWorktreeDatabase;
|
||||
} = {},
|
||||
): Promise<EnsureWorktreeSeededResult> {
|
||||
const configPath = resolveConfigPath(opts.config);
|
||||
const markers = resolveWorktreeSeedMarkerPaths(configPath);
|
||||
let initialManifest = readWorktreeSeedManifest(configPath);
|
||||
const initialManifest = readWorktreeSeedManifest(configPath);
|
||||
if (initialManifest?.state === "verified") {
|
||||
return { seeded: false, reason: "verified_manifest" };
|
||||
}
|
||||
@@ -2149,14 +2270,6 @@ export async function ensureWorktreeSeeded(
|
||||
const legacyPending = !initialManifest && existsSync(markers.pending)
|
||||
? readLegacyWorktreeSeedPendingMarker(markers.pending)
|
||||
: null;
|
||||
if (!initialManifest && !legacyPending) {
|
||||
if (existsSync(markers.lock)) {
|
||||
const releaseExistingLock = await acquireWorktreeSeedLock(markers.lock);
|
||||
await releaseExistingLock();
|
||||
}
|
||||
return { seeded: false, reason: "legacy_unmarked" };
|
||||
}
|
||||
|
||||
const hasExplicitSource = Boolean(opts.fromConfig || opts.fromDataDir || opts.fromInstance);
|
||||
const explicitSourceConfigPath = hasExplicitSource
|
||||
? resolveSourceConfigPath({
|
||||
@@ -2168,6 +2281,13 @@ export async function ensureWorktreeSeeded(
|
||||
const registeredBaseWorkspaceCwd = opts.registeredBaseWorkspaceCwd
|
||||
?? nonEmpty(process.env.PAPERCLIP_WORKSPACE_BASE_CWD)
|
||||
?? null;
|
||||
if (!initialManifest && !legacyPending && !hasExplicitSource && !registeredBaseWorkspaceCwd) {
|
||||
if (existsSync(markers.lock)) {
|
||||
const releaseExistingLock = await acquireWorktreeSeedLock(markers.lock);
|
||||
await releaseExistingLock();
|
||||
}
|
||||
return { seeded: false, reason: "legacy_unmarked" };
|
||||
}
|
||||
const registeredProjectWorkspaceId = opts.registeredProjectWorkspaceId
|
||||
?? nonEmpty(process.env.PAPERCLIP_PROJECT_WORKSPACE_ID)
|
||||
?? null;
|
||||
@@ -2183,26 +2303,21 @@ export async function ensureWorktreeSeeded(
|
||||
|
||||
const targetRoot = path.dirname(path.dirname(configPath));
|
||||
const targetPaths = resolveWorktreeReseedTargetPaths({ configPath, rootPath: targetRoot });
|
||||
const resolveSeedSource = (manifest: WorktreeSeedManifest | null) => {
|
||||
const diagnosticSource = manifest?.source ?? (legacyPending
|
||||
? {
|
||||
configPath: legacyPending.sourceConfigPath,
|
||||
instanceId: resolveSeedInstanceId(legacyPending.sourceConfigPath),
|
||||
}
|
||||
: null);
|
||||
return resolveCanonicalWorktreeSeedSource({
|
||||
registeredBaseWorkspaceCwd,
|
||||
explicitSourceConfigPath,
|
||||
targetConfigPath: configPath,
|
||||
expectedTargetInstanceId: targetPaths.instanceId,
|
||||
manifestSource: diagnosticSource,
|
||||
manifestTargetInstanceId: manifest?.targetInstanceId ?? targetPaths.instanceId,
|
||||
});
|
||||
};
|
||||
const registeredSeedSource = resolveRegisteredWorktreeSeedSource({
|
||||
registeredBaseWorkspaceCwd,
|
||||
explicitSourceConfigPath,
|
||||
targetConfigPath: configPath,
|
||||
expectedTargetInstanceId: targetPaths.instanceId,
|
||||
});
|
||||
|
||||
// Fail before creating the seed lock or rewriting a legacy marker. The manifest
|
||||
// is agent-writable diagnostic evidence and can never select this source.
|
||||
let canonicalSource = resolveSeedSource(initialManifest);
|
||||
if (initialManifest && initialManifest.targetInstanceId !== registeredSeedSource.targetInstanceId) {
|
||||
throw new Error("Worktree seed manifest target instance does not match the registered target instance.");
|
||||
}
|
||||
|
||||
// Resolve all authority-bearing paths before creating the lock. The manifest is
|
||||
// agent-writable diagnostic evidence and never selects the source. A stale source
|
||||
// diagnostic is replaced under the lock from this server/operator registration.
|
||||
let canonicalSource = registeredSeedSource;
|
||||
mkdirSync(path.dirname(markers.lock), { recursive: true });
|
||||
const releaseLock = await acquireWorktreeSeedLock(markers.lock);
|
||||
try {
|
||||
@@ -2213,9 +2328,6 @@ export async function ensureWorktreeSeeded(
|
||||
if (manifest?.state === "verified") {
|
||||
return { seeded: false, reason: "verified_manifest" };
|
||||
}
|
||||
if (!manifest && existsSync(markers.complete)) {
|
||||
return { seeded: false, reason: "complete_marker" };
|
||||
}
|
||||
if (!manifest && existsSync(markers.pending)) {
|
||||
const currentLegacyPending = readLegacyWorktreeSeedPendingMarker(markers.pending);
|
||||
if (currentLegacyPending.sourceConfigPath !== legacyPending?.sourceConfigPath) {
|
||||
@@ -2223,18 +2335,71 @@ export async function ensureWorktreeSeeded(
|
||||
}
|
||||
markWorktreeSeedPending({
|
||||
configPath,
|
||||
sourceConfigPath: canonicalSource.configPath,
|
||||
sourceConfigPath: registeredSeedSource.configPath,
|
||||
targetInstanceId: targetPaths.instanceId,
|
||||
seedMode: "minimal",
|
||||
diagnosticMessage: "Re-derived seed source diagnostics from the registered canonical source.",
|
||||
});
|
||||
manifest = readWorktreeSeedManifest(configPath);
|
||||
}
|
||||
if (!manifest) {
|
||||
// Worktrees created before lazy seeding shipped were seeded eagerly and
|
||||
// have neither marker. Preserve that compatibility without re-cloning.
|
||||
return { seeded: false, reason: "legacy_unmarked" };
|
||||
const legacyEvidence = await (
|
||||
dependencies.inspectLegacyDatabase ?? inspectLegacyWorktreeDatabase
|
||||
)(configPath);
|
||||
if (legacyEvidence) {
|
||||
markWorktreeSeedPending({
|
||||
configPath,
|
||||
sourceConfigPath: registeredSeedSource.configPath,
|
||||
targetInstanceId: targetPaths.instanceId,
|
||||
seedMode: "minimal",
|
||||
diagnosticMessage: "Validated existing legacy worktree database schema before adoption.",
|
||||
});
|
||||
startWorktreeSeedAttempt(configPath);
|
||||
updateWorktreeSeedManifest({
|
||||
configPath,
|
||||
phase: "complete",
|
||||
status: "succeeded",
|
||||
state: "verified",
|
||||
snapshotAt: new Date().toISOString(),
|
||||
migrationRevision: legacyEvidence.migrationRevision,
|
||||
message: "Adopted an existing legacy worktree database after validating its migration journal and core schema.",
|
||||
});
|
||||
return { seeded: false, reason: "legacy_database" };
|
||||
}
|
||||
|
||||
markWorktreeSeedPending({
|
||||
configPath,
|
||||
sourceConfigPath: registeredSeedSource.configPath,
|
||||
targetInstanceId: targetPaths.instanceId,
|
||||
seedMode: "minimal",
|
||||
diagnosticMessage: "No verified seed or compatible legacy database was found; provisioning is required.",
|
||||
});
|
||||
manifest = readWorktreeSeedManifest(configPath);
|
||||
if (!manifest) {
|
||||
throw new Error("Failed to create a pending worktree seed manifest.");
|
||||
}
|
||||
}
|
||||
canonicalSource = resolveSeedSource(manifest);
|
||||
if (
|
||||
manifest.source.configPath !== registeredSeedSource.configPath
|
||||
|| manifest.source.instanceId !== registeredSeedSource.instanceId
|
||||
) {
|
||||
markWorktreeSeedPending({
|
||||
configPath,
|
||||
sourceConfigPath: registeredSeedSource.configPath,
|
||||
targetInstanceId: manifest.targetInstanceId,
|
||||
seedMode: manifest.seedMode,
|
||||
diagnosticMessage: "Re-derived seed source diagnostics from the registered canonical source.",
|
||||
});
|
||||
manifest = readWorktreeSeedManifest(configPath)!;
|
||||
}
|
||||
canonicalSource = resolveCanonicalWorktreeSeedSource({
|
||||
registeredBaseWorkspaceCwd,
|
||||
explicitSourceConfigPath,
|
||||
targetConfigPath: configPath,
|
||||
expectedTargetInstanceId: targetPaths.instanceId,
|
||||
manifestSource: manifest.source,
|
||||
manifestTargetInstanceId: manifest.targetInstanceId,
|
||||
});
|
||||
const sourceConfigPath = canonicalSource.configPath;
|
||||
|
||||
const sourceConfig = readConfig(sourceConfigPath);
|
||||
@@ -2313,22 +2478,48 @@ async function runWorktreeInit(opts: WorktreeInitOptions): Promise<void> {
|
||||
rmSync(paths.instanceRoot, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
const claimedPorts = collectClaimedWorktreePorts(paths.homeDir, paths.instanceId, paths.cwd);
|
||||
const preferredServerPort = opts.serverPort ?? ((sourceConfig?.server.port ?? 3100) + 1);
|
||||
const serverPort = await findAvailablePort(preferredServerPort, claimedPorts.serverPorts);
|
||||
const preferredDbPort = opts.dbPort ?? ((sourceConfig?.database.embeddedPostgresPort ?? 54329) + 1);
|
||||
const databasePort = await findAvailablePort(
|
||||
preferredDbPort,
|
||||
new Set([...claimedPorts.databasePorts, serverPort]),
|
||||
);
|
||||
const targetConfig = buildWorktreeConfig({
|
||||
sourceConfig,
|
||||
paths,
|
||||
serverPort,
|
||||
databasePort,
|
||||
});
|
||||
const { serverPort, databasePort, targetConfig } = await withWorktreePortRegistryLock(
|
||||
paths.homeDir,
|
||||
async () => {
|
||||
const registeredConfigPaths = readWorktreePortRegistry(paths.homeDir);
|
||||
const claimedPorts = collectClaimedWorktreePorts(
|
||||
paths.homeDir,
|
||||
paths.instanceId,
|
||||
paths.cwd,
|
||||
registeredConfigPaths,
|
||||
);
|
||||
const preferredServerPort = opts.serverPort ?? ((sourceConfig?.server.port ?? 3100) + 1);
|
||||
const selectedServerPort = await findAvailablePort(preferredServerPort, claimedPorts.serverPorts);
|
||||
const preferredDbPort = opts.dbPort ?? ((sourceConfig?.database.embeddedPostgresPort ?? 54329) + 1);
|
||||
const selectedDatabasePort = await findAvailablePort(
|
||||
preferredDbPort,
|
||||
new Set([...claimedPorts.databasePorts, selectedServerPort]),
|
||||
);
|
||||
const selectedConfig = buildWorktreeConfig({
|
||||
sourceConfig,
|
||||
paths,
|
||||
serverPort: selectedServerPort,
|
||||
databasePort: selectedDatabasePort,
|
||||
});
|
||||
|
||||
writeConfig(targetConfig, paths.configPath);
|
||||
try {
|
||||
writeConfig(selectedConfig, paths.configPath);
|
||||
writeWorktreePortRegistry(paths.homeDir, [
|
||||
...registeredConfigPaths,
|
||||
paths.configPath,
|
||||
]);
|
||||
} catch (error) {
|
||||
rmSync(paths.configPath, { force: true });
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
serverPort: selectedServerPort,
|
||||
databasePort: selectedDatabasePort,
|
||||
targetConfig: selectedConfig,
|
||||
};
|
||||
},
|
||||
);
|
||||
markWorktreeSeedPending({
|
||||
configPath: paths.configPath,
|
||||
sourceConfigPath,
|
||||
@@ -2436,21 +2627,17 @@ export async function worktreeEnsureSeededCommand(opts: WorktreeEnsureSeededOpti
|
||||
printPaperclipCliBanner();
|
||||
p.intro(pc.bgCyan(pc.black(" paperclipai worktree ensure-seeded ")));
|
||||
|
||||
const markers = resolveWorktreeSeedMarkerPaths(resolveConfigPath(opts.config));
|
||||
if (existsSync(markers.complete) || !existsSync(markers.pending)) {
|
||||
const result = await ensureWorktreeSeeded(opts);
|
||||
const reason = result.reason === "complete_marker"
|
||||
? "Seed-complete marker already present."
|
||||
: "No seed-pending marker found; treating this legacy worktree as already seeded.";
|
||||
p.outro(pc.green(reason));
|
||||
return;
|
||||
}
|
||||
|
||||
const spinner = p.spinner();
|
||||
spinner.start("Seeding isolated worktree database from source instance (minimal)...");
|
||||
spinner.start("Checking isolated worktree database seed state...");
|
||||
try {
|
||||
const result = await ensureWorktreeSeeded(opts);
|
||||
spinner.stop("Seeded isolated worktree database (minimal).");
|
||||
if (result.seeded) {
|
||||
spinner.stop("Seeded isolated worktree database (minimal).");
|
||||
} else if (result.reason === "legacy_database") {
|
||||
spinner.stop("Validated and adopted an existing legacy worktree database.");
|
||||
} else {
|
||||
spinner.stop("Worktree database already has a verified seed manifest.");
|
||||
}
|
||||
if (result.details) {
|
||||
p.log.message(pc.dim(`Seed snapshot: ${result.details.backupSummary}`));
|
||||
p.log.message(
|
||||
|
||||
Reference in new issue
Block a user