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:
parent
433b1eb099
commit
bd059a073d
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -327,6 +327,8 @@ Every local install keeps runtime state directly under the selected instance roo
|
|||
storage/ # local_disk uploads
|
||||
backups/ # automatic DB backups
|
||||
logs/
|
||||
runtime-services/ # managed local-service registry
|
||||
runtime-service-logs/ # append-only managed-service stdout/stderr
|
||||
secrets/master.key # local_encrypted master key
|
||||
workspaces/<agent-id>/ # default agent workspaces
|
||||
projects/ # project execution workspaces
|
||||
|
|
@ -485,15 +487,15 @@ The default `worktree init` still seeds eagerly. A lean worktree (created withou
|
|||
|
||||
- `pnpm paperclipai worktree ensure-seeded` performs the deferred seed **exactly once**. It is lock-guarded and idempotent: only a complete `verified` manifest short-circuits it, so it is safe to call repeatedly and from concurrent processes. Managed workspaces derive the source exclusively from the control-plane-provided base project workspace; manual worktrees must pass `--from-config`.
|
||||
- `paperclipai run` calls `ensureWorktreeSeeded` automatically before doctor/boot. Managed runs transparently seed a lean worktree from their registered base workspace; an unmanaged lean worktree must first run `worktree ensure-seeded --from-config <source-config>`.
|
||||
- Managed git-worktree runtime startup also runs `scripts/provision-worktree-runtime.sh` automatically when a legacy workspace policy has no explicit runtime provision command and the manifest is not verified. An explicitly configured runtime provision command always takes precedence.
|
||||
- Managed Paperclip git worktrees default to the repository's `scripts/provision-worktree.sh` when the strategy omits `provisionCommand`, so the isolated config and pending manifest cannot be silently skipped. Runtime startup also runs `scripts/provision-worktree-runtime.sh` automatically when no explicit runtime provision command is configured and the manifest is not verified. Explicitly configured provision commands still take precedence.
|
||||
- The built-in deferred seed is recorded as its own terminal `workspace_seed` operation. A zero exit code is not enough for success: the operation succeeds only when `.paperclip/seed-manifest.json` contains complete verified evidence; failed, missing, or malformed manifests produce a failed operation with the seed phase in metadata.
|
||||
- Worktrees created before lazy seeding shipped have neither marker; they are treated as already-seeded for backward compatibility (never re-cloned).
|
||||
- Worktrees created before lazy seeding shipped may have neither marker. Paperclip adopts them only after their configured database proves a compatible migration journal and the core Paperclip schema; otherwise managed startup creates a pending manifest and performs the normal verified seed. Manual markerless worktrees must provide `--from-config` so the source remains explicit.
|
||||
|
||||
Both `minimal` and `full` modes use the same terminal data-validation contract. Source validation accepts a migration journal that is a prefix of the checkout's journal and records the source revision in seed diagnostics; it rejects a source that is ahead of the checkout because that would require a downgrade. After restore, Paperclip applies pending migrations and requires the target journal to be current. Both validations also read a credential-backed auth user, instance administrator role, active company membership, and representative cloned company/issue pair. Restore, migrations, execution quarantine, routine pausing, workspace rebinding, and post-restore validation all run under the seed lock. An interruption leaves the exact active phase in terminal `failed` state; it cannot produce readiness evidence.
|
||||
Both `minimal` and `full` modes use the same terminal data-validation contract. Source validation accepts a migration journal that is a prefix of the checkout's journal and records the source revision in seed diagnostics; it rejects a source that is ahead of the checkout because that would require a downgrade. After restore, Paperclip applies pending migrations and requires the target journal to be current. Both validations also read an auth user with an instance administrator role, active company membership, and representative cloned company/issue pair. Authenticated instances additionally require that administrator to have a non-empty credential account. `local_trusted` instances accept the implicit local Board user without an account row because that mode intentionally has no human login flow. Restore, migrations, execution quarantine, routine pausing, workspace rebinding, and post-restore validation all run under the seed lock. An interruption leaves the exact active phase in terminal `failed` state; it cannot produce readiness evidence.
|
||||
|
||||
The seed process must own the target embedded PostgreSQL lifecycle for that entire sequence. It refuses to restore into a target postmaster that is already running, suppresses the embedded provider's process-global exit hooks, and stops its owned target only after validation or failure cleanup. A shutdown detected during restore is recorded as a target-database shutdown diagnostic rather than a generic restore failure.
|
||||
|
||||
The seed manifest never grants source-path authority. Its source path and instance are diagnostic assertions that must exactly match the realpath-canonical registered source before any lock, backup, service stop, spawn, or database mutation. Missing registration, sibling or foreign paths, symlink aliases, source/target identity collisions, and company mismatches fail closed.
|
||||
The seed manifest never grants source-path authority. Its source path and instance are diagnostic assertions derived from the realpath-canonical registered source. Deferred seeding resolves registration independently before taking the seed lock; under that lock it replaces stale source diagnostics from the registered value and then revalidates the manifest before any backup, service stop, spawn, or database mutation. Missing registration, invalid registered paths, source/target identity collisions, target-instance mismatches, and company mismatches fail closed.
|
||||
|
||||
**Unverified-seed guard.** `pnpm dev` (the dev-runner) refuses to boot a worktree whose manifest is pending, running, failed, malformed, or missing required verification evidence and points you at the fix:
|
||||
|
||||
|
|
@ -695,6 +697,11 @@ Heavier setup that is only needed by a managed runtime service can use `workspac
|
|||
|
||||
Managed runtime control actions (`start`, `stop`, `restart`, and job `run`) are mutually exclusive per execution workspace. An overlapping control is rejected with `409 workspace_runtime_control_in_progress` instead of racing the active operation, and authorization is still checked first, so the conflict never widens who may control a workspace.
|
||||
|
||||
Local managed runtime services write stdout and stderr directly to append-only
|
||||
files under the instance's `runtime-service-logs/` directory. The child inherits
|
||||
the file descriptors rather than supervisor-owned pipes, so request-logging
|
||||
servers remain responsive and adoptable when the control plane restarts.
|
||||
|
||||
Every managed control reaches a terminal operation state. Each one stamps the owning server process and pid on its `workspace_operations` row and heartbeats while it runs, and each one carries a wall-clock ceiling (30 minutes for lifecycle controls, 4 hours for workspace jobs) so a hung provider or listener fails the operation rather than leaving it active. When a start fails part-way, Paperclip tears the workspace's runtime services down through the ordinary stop path and records a stopped desired state, so the lane is retryable and a startup reconcile will not resurrect a service that never came up.
|
||||
|
||||
Recovery of stranded controls is bounded and cannot steal a live operation. A `running` control is only terminalized when its owning process is gone, when the owning request in this process no longer exists, or after 60 seconds without a heartbeat; the terminalizing write is a compare-and-swap on `updated_at`, so an owner that heartbeats concurrently keeps its operation. Recovery runs on server startup and before each managed control, appends reconciliation evidence to the workspace-operation log, and stays inside the requested workspace's scope.
|
||||
|
|
|
|||
|
|
@ -13,16 +13,16 @@ describe("forceLoopbackBindInCommand", () => {
|
|||
// PAP-17256 reproduction: this is verbatim what `workspace_runtime_services`
|
||||
// recorded for every lane the broker denied.
|
||||
expect(forceLoopbackBindInCommand("pnpm dev --bind lan")).toBe(
|
||||
"pnpm dev --bind custom --bind-host 127.0.0.1",
|
||||
"pnpm dev --bind loopback",
|
||||
);
|
||||
});
|
||||
|
||||
it("replaces an existing loopback-adjacent bind rather than duplicating it", () => {
|
||||
expect(forceLoopbackBindInCommand("pnpm dev --bind tailnet")).toBe(
|
||||
"pnpm dev --bind custom --bind-host 127.0.0.1",
|
||||
"pnpm dev --bind loopback",
|
||||
);
|
||||
expect(forceLoopbackBindInCommand("pnpm dev --bind custom --bind-host 10.0.0.4")).toBe(
|
||||
"pnpm dev --bind custom --bind-host 127.0.0.1",
|
||||
"pnpm dev --bind loopback",
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -33,7 +33,7 @@ describe("forceLoopbackBindInCommand", () => {
|
|||
|
||||
it("handles the =-separated spelling", () => {
|
||||
expect(forceLoopbackBindInCommand("pnpm dev --bind=lan --bind-host=0.0.0.0")).toBe(
|
||||
"pnpm dev --bind custom --bind-host 127.0.0.1",
|
||||
"pnpm dev --bind loopback",
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -41,7 +41,7 @@ describe("forceLoopbackBindInCommand", () => {
|
|||
// A bare `pnpm dev` lets an old guest runner infer its bind from HOST, so the
|
||||
// flags must be added rather than assumed.
|
||||
expect(forceLoopbackBindInCommand("pnpm dev")).toBe(
|
||||
"pnpm dev --bind custom --bind-host 127.0.0.1",
|
||||
"pnpm dev --bind loopback",
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -49,19 +49,19 @@ describe("forceLoopbackBindInCommand", () => {
|
|||
// `isPaperclipDevRuntimeService` matches `--tailscale-auth` as a substring;
|
||||
// an explicit `--bind` already beats the alias in every dev-runner version.
|
||||
expect(forceLoopbackBindInCommand("pnpm dev:once --tailscale-auth")).toBe(
|
||||
"pnpm dev:once --tailscale-auth --bind custom --bind-host 127.0.0.1",
|
||||
"pnpm dev:once --tailscale-auth --bind loopback",
|
||||
);
|
||||
});
|
||||
|
||||
it("never strips a value that is actually the next flag", () => {
|
||||
expect(forceLoopbackBindInCommand("pnpm dev --bind --verbose")).toBe(
|
||||
"pnpm dev --bind --verbose --bind custom --bind-host 127.0.0.1",
|
||||
"pnpm dev --bind --verbose --bind loopback",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not touch a --bind occurrence that is not a flag boundary", () => {
|
||||
expect(forceLoopbackBindInCommand("pnpm dev --no--bind lan")).toBe(
|
||||
"pnpm dev --no--bind lan --bind custom --bind-host 127.0.0.1",
|
||||
"pnpm dev --no--bind lan --bind loopback",
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -74,8 +74,9 @@ describe("forceLoopbackBindInCommand", () => {
|
|||
expect(forceLoopbackBindInCommand(fixture)).toBe(fixture);
|
||||
});
|
||||
|
||||
it("uses the loopback host constant", () => {
|
||||
expect(forceLoopbackBindInCommand("pnpm dev")).toContain(RUNTIME_EXPOSURE_BIND_HOST);
|
||||
it("uses the loopback preset without turning it into a custom bind", () => {
|
||||
expect(forceLoopbackBindInCommand("pnpm dev")).toBe("pnpm dev --bind loopback");
|
||||
expect(RUNTIME_EXPOSURE_BIND_HOST).toBe("127.0.0.1");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -18,8 +18,8 @@
|
|||
* Pure string functions only — no I/O, no process state.
|
||||
*/
|
||||
|
||||
/** The only address an exposed managed runtime may bind. */
|
||||
export const RUNTIME_EXPOSURE_BIND_MODE = "custom";
|
||||
/** The only bind preset an exposed managed runtime may use. */
|
||||
export const RUNTIME_EXPOSURE_BIND_MODE = "loopback";
|
||||
export const RUNTIME_EXPOSURE_BIND_HOST = "127.0.0.1";
|
||||
|
||||
/**
|
||||
|
|
@ -72,7 +72,7 @@ export function forceLoopbackBindInCommand(command: string): string {
|
|||
if (!isPaperclipDevRunnerCommand(command)) return command;
|
||||
const stripped = command.replace(BIND_SELECTING_ARG, "").trim();
|
||||
if (stripped.length === 0) return command;
|
||||
return `${stripped} --bind ${RUNTIME_EXPOSURE_BIND_MODE} --bind-host ${RUNTIME_EXPOSURE_BIND_HOST}`;
|
||||
return `${stripped} --bind ${RUNTIME_EXPOSURE_BIND_MODE}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -0,0 +1,147 @@
|
|||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
withWorktreePortRegistryLock,
|
||||
withWorktreePortRegistryLockSync,
|
||||
} from "./worktree-port-registry.js";
|
||||
|
||||
const temporaryRoots: string[] = [];
|
||||
|
||||
function makeTemporaryRoot(): string {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-port-registry-lock-"));
|
||||
temporaryRoots.push(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
function deferred(): { promise: Promise<void>; resolve: () => void } {
|
||||
let resolve!: () => void;
|
||||
const promise = new Promise<void>((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of temporaryRoots.splice(0)) {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("worktree port registry lock", () => {
|
||||
it("does not reclaim a stale lock while its fallback ownership probe responds", async () => {
|
||||
const homeDir = makeTemporaryRoot();
|
||||
const lockPath = path.join(homeDir, ".worktree-port-reservations.lock");
|
||||
const firstEntered = deferred();
|
||||
const releaseFirst = deferred();
|
||||
let secondEntered = false;
|
||||
|
||||
const first = withWorktreePortRegistryLock(homeDir, async () => {
|
||||
fs.renameSync(path.join(lockPath, "owner.json"), path.join(lockPath, "owner.unavailable.json"));
|
||||
const backupOwnerPath = path.join(lockPath, "owner.backup.json");
|
||||
const owner = JSON.parse(fs.readFileSync(backupOwnerPath, "utf8"));
|
||||
fs.writeFileSync(backupOwnerPath, `${JSON.stringify({
|
||||
...owner,
|
||||
processIdentity: "unavailable-process-identity",
|
||||
})}\n`);
|
||||
const oldTimestamp = new Date(Date.now() - 10_000);
|
||||
fs.utimesSync(lockPath, oldTimestamp, oldTimestamp);
|
||||
firstEntered.resolve();
|
||||
await releaseFirst.promise;
|
||||
});
|
||||
await firstEntered.promise;
|
||||
|
||||
expect(Date.now() - fs.statSync(lockPath).mtimeMs).toBeGreaterThan(5_000);
|
||||
|
||||
const second = withWorktreePortRegistryLock(homeDir, async () => {
|
||||
secondEntered = true;
|
||||
});
|
||||
await delay(100);
|
||||
|
||||
expect(secondEntered).toBe(false);
|
||||
releaseFirst.resolve();
|
||||
await Promise.all([first, second]);
|
||||
expect(secondEntered).toBe(true);
|
||||
}, 10_000);
|
||||
|
||||
it("refreshes the lease throughout an async critical section", async () => {
|
||||
const homeDir = makeTemporaryRoot();
|
||||
const lockPath = path.join(homeDir, ".worktree-port-reservations.lock");
|
||||
|
||||
await withWorktreePortRegistryLock(homeDir, async () => {
|
||||
await delay(5_250);
|
||||
expect(Date.now() - fs.statSync(lockPath).mtimeMs).toBeLessThan(2_000);
|
||||
});
|
||||
|
||||
expect(fs.existsSync(lockPath)).toBe(false);
|
||||
}, 10_000);
|
||||
|
||||
it("reclaims an old lock after its owner process exits", async () => {
|
||||
const homeDir = makeTemporaryRoot();
|
||||
const lockPath = path.join(homeDir, ".worktree-port-reservations.lock");
|
||||
fs.mkdirSync(lockPath);
|
||||
fs.writeFileSync(
|
||||
path.join(lockPath, "owner.json"),
|
||||
`${JSON.stringify({
|
||||
version: 1,
|
||||
pid: 2_147_483_647,
|
||||
processIdentity: "dead-process",
|
||||
probePort: 1,
|
||||
token: "dead-owner",
|
||||
})}\n`,
|
||||
);
|
||||
const oldTimestamp = new Date(Date.now() - 10_000);
|
||||
fs.utimesSync(lockPath, oldTimestamp, oldTimestamp);
|
||||
|
||||
let entered = false;
|
||||
await withWorktreePortRegistryLock(homeDir, async () => {
|
||||
entered = true;
|
||||
});
|
||||
|
||||
expect(entered).toBe(true);
|
||||
expect(fs.existsSync(lockPath)).toBe(false);
|
||||
});
|
||||
|
||||
it("reclaims an old lock when its pid belongs to a different process", async () => {
|
||||
const homeDir = makeTemporaryRoot();
|
||||
const lockPath = path.join(homeDir, ".worktree-port-reservations.lock");
|
||||
fs.mkdirSync(lockPath);
|
||||
fs.writeFileSync(
|
||||
path.join(lockPath, "owner.json"),
|
||||
`${JSON.stringify({
|
||||
version: 1,
|
||||
pid: process.pid,
|
||||
processIdentity: "reused-pid-owner",
|
||||
probePort: 1,
|
||||
token: "abandoned-owner",
|
||||
})}\n`,
|
||||
);
|
||||
const oldTimestamp = new Date(Date.now() - 10_000);
|
||||
fs.utimesSync(lockPath, oldTimestamp, oldTimestamp);
|
||||
|
||||
let entered = false;
|
||||
await withWorktreePortRegistryLock(homeDir, async () => {
|
||||
entered = true;
|
||||
});
|
||||
|
||||
expect(entered).toBe(true);
|
||||
expect(fs.existsSync(lockPath)).toBe(false);
|
||||
});
|
||||
|
||||
it("refreshes the lease while a synchronous critical section blocks the main thread", () => {
|
||||
const homeDir = makeTemporaryRoot();
|
||||
const lockPath = path.join(homeDir, ".worktree-port-reservations.lock");
|
||||
const blocker = new Int32Array(new SharedArrayBuffer(4));
|
||||
|
||||
withWorktreePortRegistryLockSync(homeDir, () => {
|
||||
const oldTimestamp = new Date(Date.now() - 10_000);
|
||||
fs.utimesSync(lockPath, oldTimestamp, oldTimestamp);
|
||||
Atomics.wait(blocker, 0, 0, 1_500);
|
||||
expect(Date.now() - fs.statSync(lockPath).mtimeMs).toBeLessThan(1_250);
|
||||
});
|
||||
|
||||
expect(fs.existsSync(lockPath)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,411 @@
|
|||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import { Worker } from "node:worker_threads";
|
||||
|
||||
type WorktreePortRegistry = {
|
||||
version: 1;
|
||||
configPaths: string[];
|
||||
};
|
||||
|
||||
const WORKTREE_PORT_REGISTRY_FILE = "worktree-port-reservations.json";
|
||||
const WORKTREE_PORT_REGISTRY_LOCK_DIR = ".worktree-port-reservations.lock";
|
||||
const WORKTREE_PORT_REGISTRY_LOCK_OWNER_FILE = "owner.json";
|
||||
const WORKTREE_PORT_REGISTRY_LOCK_OWNER_BACKUP_FILE = "owner.backup.json";
|
||||
const WORKTREE_PORT_REGISTRY_LOCK_STALE_MS = 5_000;
|
||||
const WORKTREE_PORT_REGISTRY_LOCK_TIMEOUT_MS = 10_000;
|
||||
const WORKTREE_PORT_REGISTRY_LOCK_HEARTBEAT_MS = 1_000;
|
||||
const WORKTREE_PORT_REGISTRY_LOCK_PROBE_TIMEOUT_MS = 500;
|
||||
const sleepSyncBuffer = new Int32Array(new SharedArrayBuffer(4));
|
||||
|
||||
type RegistryLockOwner = {
|
||||
version: 1;
|
||||
pid: number;
|
||||
processIdentity: string;
|
||||
probePort: number;
|
||||
token: string;
|
||||
};
|
||||
|
||||
type RegistryLockLease = {
|
||||
token: string;
|
||||
worker: Worker;
|
||||
control: Int32Array;
|
||||
probePort: number;
|
||||
};
|
||||
|
||||
const REGISTRY_LOCK_HEARTBEAT_SOURCE = `
|
||||
const fs = require("node:fs");
|
||||
const net = require("node:net");
|
||||
const path = require("node:path");
|
||||
const { parentPort, workerData } = require("node:worker_threads");
|
||||
const control = new Int32Array(workerData.control);
|
||||
function touchOwnedLock() {
|
||||
let sawDifferentOwner = false;
|
||||
for (const ownerFile of workerData.ownerFiles) {
|
||||
try {
|
||||
const owner = JSON.parse(fs.readFileSync(path.join(workerData.lockPath, ownerFile), "utf8"));
|
||||
if (owner.token === workerData.token) {
|
||||
try {
|
||||
const now = new Date();
|
||||
fs.utimesSync(workerData.lockPath, now, now);
|
||||
return "refreshed";
|
||||
} catch {
|
||||
return "retry";
|
||||
}
|
||||
}
|
||||
sawDifferentOwner = true;
|
||||
} catch {
|
||||
// The redundant owner file may still be readable.
|
||||
}
|
||||
}
|
||||
return sawDifferentOwner ? "lost" : "retry";
|
||||
}
|
||||
let finished = false;
|
||||
let timer;
|
||||
function complete() {
|
||||
Atomics.store(control, 0, 2);
|
||||
Atomics.notify(control, 0);
|
||||
}
|
||||
function finish() {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
if (timer) clearInterval(timer);
|
||||
server.close(complete);
|
||||
setTimeout(complete, 250).unref();
|
||||
}
|
||||
const server = net.createServer((socket) => {
|
||||
socket.setEncoding("utf8");
|
||||
socket.setTimeout(workerData.probeTimeoutMs, () => socket.destroy());
|
||||
socket.once("data", (candidate) => {
|
||||
socket.end(candidate === workerData.token ? "owned" : "denied");
|
||||
});
|
||||
});
|
||||
parentPort.once("message", finish);
|
||||
server.once("error", () => {
|
||||
Atomics.store(control, 0, -1);
|
||||
Atomics.notify(control, 0);
|
||||
});
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
Atomics.store(control, 0, -1);
|
||||
Atomics.notify(control, 0);
|
||||
return;
|
||||
}
|
||||
Atomics.store(control, 2, address.port);
|
||||
Atomics.store(control, 0, 1);
|
||||
Atomics.notify(control, 0);
|
||||
touchOwnedLock();
|
||||
timer = setInterval(() => {
|
||||
if (Atomics.load(control, 1) !== 0 || touchOwnedLock() === "lost") finish();
|
||||
}, workerData.heartbeatMs);
|
||||
});
|
||||
`;
|
||||
|
||||
const REGISTRY_LOCK_PROBE_SOURCE = `
|
||||
const net = require("node:net");
|
||||
const { workerData } = require("node:worker_threads");
|
||||
const control = new Int32Array(workerData.control);
|
||||
let finished = false;
|
||||
function finish(result) {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
Atomics.store(control, 0, result);
|
||||
Atomics.notify(control, 0);
|
||||
process.exit(0);
|
||||
}
|
||||
const socket = net.createConnection({ host: "127.0.0.1", port: workerData.port });
|
||||
socket.setEncoding("utf8");
|
||||
socket.setTimeout(workerData.timeoutMs, () => finish(2));
|
||||
socket.once("connect", () => socket.write(workerData.token));
|
||||
socket.once("data", (response) => finish(response === "owned" ? 1 : 2));
|
||||
socket.once("error", () => finish(2));
|
||||
socket.once("close", () => finish(2));
|
||||
`;
|
||||
|
||||
function resolveRegistryLockPath(homeDir: string): string {
|
||||
fs.mkdirSync(homeDir, { recursive: true });
|
||||
return path.resolve(homeDir, WORKTREE_PORT_REGISTRY_LOCK_DIR);
|
||||
}
|
||||
|
||||
function readRegistryLockOwner(lockPath: string): RegistryLockOwner | null {
|
||||
for (const ownerFile of [
|
||||
WORKTREE_PORT_REGISTRY_LOCK_OWNER_FILE,
|
||||
WORKTREE_PORT_REGISTRY_LOCK_OWNER_BACKUP_FILE,
|
||||
]) {
|
||||
try {
|
||||
const parsed = JSON.parse(
|
||||
fs.readFileSync(path.join(lockPath, ownerFile), "utf8"),
|
||||
) as Partial<RegistryLockOwner>;
|
||||
if (
|
||||
parsed.version === 1
|
||||
&& Number.isInteger(parsed.pid)
|
||||
&& (parsed.pid ?? 0) > 0
|
||||
&& typeof parsed.processIdentity === "string"
|
||||
&& parsed.processIdentity.length > 0
|
||||
&& Number.isInteger(parsed.probePort)
|
||||
&& (parsed.probePort ?? 0) > 0
|
||||
&& (parsed.probePort ?? 0) <= 65_535
|
||||
&& typeof parsed.token === "string"
|
||||
&& parsed.token.length > 0
|
||||
) {
|
||||
return parsed as RegistryLockOwner;
|
||||
}
|
||||
} catch {
|
||||
// Try the redundant owner record.
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function writeRegistryLockOwner(lockPath: string, owner: RegistryLockOwner): void {
|
||||
const contents = `${JSON.stringify(owner)}\n`;
|
||||
for (const ownerFile of [
|
||||
WORKTREE_PORT_REGISTRY_LOCK_OWNER_FILE,
|
||||
WORKTREE_PORT_REGISTRY_LOCK_OWNER_BACKUP_FILE,
|
||||
]) {
|
||||
const ownerPath = path.join(lockPath, ownerFile);
|
||||
const temporaryPath = `${ownerPath}.${owner.token}.tmp`;
|
||||
fs.writeFileSync(temporaryPath, contents, { mode: 0o600 });
|
||||
fs.renameSync(temporaryPath, ownerPath);
|
||||
}
|
||||
}
|
||||
|
||||
function isProcessAlive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return !(error instanceof Error && "code" in error && error.code === "ESRCH");
|
||||
}
|
||||
}
|
||||
|
||||
function readProcessIdentity(pid: number): string | null {
|
||||
if (!Number.isInteger(pid) || pid <= 0) return null;
|
||||
if (process.platform === "linux") {
|
||||
try {
|
||||
const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf8");
|
||||
const commandEnd = stat.lastIndexOf(")");
|
||||
if (commandEnd < 0) return null;
|
||||
const fields = stat.slice(commandEnd + 1).trim().split(/\s+/);
|
||||
const startTicks = fields[19];
|
||||
if (!startTicks) return null;
|
||||
const bootId = fs.readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim();
|
||||
return bootId ? `linux:${bootId}:${startTicks}` : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (process.platform === "win32") {
|
||||
const ticks = execFileSync("powershell.exe", [
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-Command",
|
||||
`(Get-Process -Id ${pid}).StartTime.ToUniversalTime().Ticks`,
|
||||
], { encoding: "utf8", windowsHide: true }).trim();
|
||||
return ticks ? `win32:${ticks}` : null;
|
||||
}
|
||||
const startedAt = execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], {
|
||||
encoding: "utf8",
|
||||
}).trim();
|
||||
return startedAt ? `${process.platform}:${startedAt}` : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function probeRegistryLockOwner(owner: RegistryLockOwner): boolean {
|
||||
const control = new Int32Array(new SharedArrayBuffer(4));
|
||||
const worker = new Worker(REGISTRY_LOCK_PROBE_SOURCE, {
|
||||
eval: true,
|
||||
execArgv: [],
|
||||
workerData: {
|
||||
control: control.buffer,
|
||||
port: owner.probePort,
|
||||
timeoutMs: WORKTREE_PORT_REGISTRY_LOCK_PROBE_TIMEOUT_MS,
|
||||
token: owner.token,
|
||||
},
|
||||
});
|
||||
Atomics.wait(control, 0, 0, WORKTREE_PORT_REGISTRY_LOCK_PROBE_TIMEOUT_MS + 250);
|
||||
const isOwner = Atomics.load(control, 0) === 1;
|
||||
void worker.terminate();
|
||||
return isOwner;
|
||||
}
|
||||
|
||||
function removeStaleRegistryLock(lockPath: string): boolean {
|
||||
try {
|
||||
const ageMs = Date.now() - fs.statSync(lockPath).mtimeMs;
|
||||
if (ageMs <= WORKTREE_PORT_REGISTRY_LOCK_STALE_MS) return false;
|
||||
const owner = readRegistryLockOwner(lockPath);
|
||||
if (owner && isProcessAlive(owner.pid)) {
|
||||
if (probeRegistryLockOwner(owner)) return false;
|
||||
const currentIdentity = readProcessIdentity(owner.pid);
|
||||
if (owner.processIdentity === currentIdentity) return false;
|
||||
}
|
||||
const currentOwner = readRegistryLockOwner(lockPath);
|
||||
if (owner ? currentOwner?.token !== owner.token : currentOwner !== null) return false;
|
||||
const currentAgeMs = Date.now() - fs.statSync(lockPath).mtimeMs;
|
||||
if (currentAgeMs <= WORKTREE_PORT_REGISTRY_LOCK_STALE_MS) return false;
|
||||
fs.rmSync(lockPath, { recursive: true, force: true });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function startRegistryLockHeartbeat(lockPath: string, token: string): RegistryLockLease {
|
||||
const control = new Int32Array(new SharedArrayBuffer(12));
|
||||
const worker = new Worker(REGISTRY_LOCK_HEARTBEAT_SOURCE, {
|
||||
eval: true,
|
||||
execArgv: [],
|
||||
workerData: {
|
||||
control: control.buffer,
|
||||
heartbeatMs: WORKTREE_PORT_REGISTRY_LOCK_HEARTBEAT_MS,
|
||||
lockPath,
|
||||
ownerFiles: [
|
||||
WORKTREE_PORT_REGISTRY_LOCK_OWNER_FILE,
|
||||
WORKTREE_PORT_REGISTRY_LOCK_OWNER_BACKUP_FILE,
|
||||
],
|
||||
probeTimeoutMs: WORKTREE_PORT_REGISTRY_LOCK_PROBE_TIMEOUT_MS,
|
||||
token,
|
||||
},
|
||||
});
|
||||
Atomics.wait(control, 0, 0, 2_000);
|
||||
if (Atomics.load(control, 0) !== 1) {
|
||||
void worker.terminate();
|
||||
throw new Error(`Failed to start worktree port reservation lock heartbeat at ${lockPath}`);
|
||||
}
|
||||
const probePort = Atomics.load(control, 2);
|
||||
if (probePort <= 0) {
|
||||
void worker.terminate();
|
||||
throw new Error(`Failed to start worktree port reservation lock probe at ${lockPath}`);
|
||||
}
|
||||
return { token, worker, control, probePort };
|
||||
}
|
||||
|
||||
function stopRegistryLockHeartbeat(lease: RegistryLockLease): void {
|
||||
Atomics.store(lease.control, 1, 1);
|
||||
Atomics.notify(lease.control, 1);
|
||||
lease.worker.postMessage("stop");
|
||||
if (Atomics.load(lease.control, 0) === 1) {
|
||||
Atomics.wait(lease.control, 0, 1, 2_000);
|
||||
}
|
||||
void lease.worker.terminate();
|
||||
}
|
||||
|
||||
function acquireRegistryLock(lockPath: string, deadline: number): RegistryLockLease | null {
|
||||
try {
|
||||
fs.mkdirSync(lockPath);
|
||||
const token = `${process.pid}-${randomUUID()}`;
|
||||
let lease: RegistryLockLease | null = null;
|
||||
try {
|
||||
const processIdentity = readProcessIdentity(process.pid);
|
||||
if (!processIdentity) {
|
||||
throw new Error("Cannot determine worktree port reservation lock owner identity");
|
||||
}
|
||||
const owner: RegistryLockOwner = {
|
||||
version: 1,
|
||||
pid: process.pid,
|
||||
processIdentity,
|
||||
probePort: 1,
|
||||
token,
|
||||
};
|
||||
writeRegistryLockOwner(lockPath, owner);
|
||||
lease = startRegistryLockHeartbeat(lockPath, token);
|
||||
writeRegistryLockOwner(lockPath, { ...owner, probePort: lease.probePort });
|
||||
return lease;
|
||||
} catch (error) {
|
||||
if (lease) stopRegistryLockHeartbeat(lease);
|
||||
fs.rmSync(lockPath, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
} catch (error) {
|
||||
const code = error instanceof Error && "code" in error ? error.code : null;
|
||||
if (code !== "EEXIST") throw error;
|
||||
if (removeStaleRegistryLock(lockPath)) return null;
|
||||
if (Date.now() >= deadline) {
|
||||
throw new Error(`Timed out waiting for worktree port reservation lock at ${lockPath}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function releaseRegistryLock(lockPath: string, lease: RegistryLockLease): void {
|
||||
stopRegistryLockHeartbeat(lease);
|
||||
const owner = readRegistryLockOwner(lockPath);
|
||||
if (owner?.token !== lease.token) {
|
||||
return;
|
||||
}
|
||||
fs.rmSync(lockPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
export function withWorktreePortRegistryLockSync<T>(homeDir: string, run: () => T): T {
|
||||
const lockPath = resolveRegistryLockPath(homeDir);
|
||||
const deadline = Date.now() + WORKTREE_PORT_REGISTRY_LOCK_TIMEOUT_MS;
|
||||
let lease: RegistryLockLease | null = null;
|
||||
|
||||
while (!(lease = acquireRegistryLock(lockPath, deadline))) {
|
||||
Atomics.wait(sleepSyncBuffer, 0, 0, 25);
|
||||
}
|
||||
|
||||
try {
|
||||
return run();
|
||||
} finally {
|
||||
releaseRegistryLock(lockPath, lease);
|
||||
}
|
||||
}
|
||||
|
||||
export async function withWorktreePortRegistryLock<T>(
|
||||
homeDir: string,
|
||||
run: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const lockPath = resolveRegistryLockPath(homeDir);
|
||||
const deadline = Date.now() + WORKTREE_PORT_REGISTRY_LOCK_TIMEOUT_MS;
|
||||
let lease: RegistryLockLease | null = null;
|
||||
|
||||
while (!(lease = acquireRegistryLock(lockPath, deadline))) {
|
||||
await delay(25);
|
||||
}
|
||||
|
||||
try {
|
||||
return await run();
|
||||
} finally {
|
||||
releaseRegistryLock(lockPath, lease);
|
||||
}
|
||||
}
|
||||
|
||||
export function readWorktreePortRegistry(homeDir: string): Set<string> {
|
||||
const registryPath = path.resolve(homeDir, WORKTREE_PORT_REGISTRY_FILE);
|
||||
if (!fs.existsSync(registryPath)) return new Set();
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(registryPath, "utf8")) as Partial<WorktreePortRegistry>;
|
||||
if (parsed.version !== 1 || !Array.isArray(parsed.configPaths)) return new Set();
|
||||
return new Set(
|
||||
parsed.configPaths
|
||||
.filter((configPath): configPath is string => typeof configPath === "string" && configPath.length > 0)
|
||||
.map((configPath) => path.resolve(configPath)),
|
||||
);
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
export function writeWorktreePortRegistry(homeDir: string, configPaths: Iterable<string>): void {
|
||||
fs.mkdirSync(homeDir, { recursive: true });
|
||||
const registryPath = path.resolve(homeDir, WORKTREE_PORT_REGISTRY_FILE);
|
||||
const persistedPaths = Array.from(new Set(Array.from(configPaths, (configPath) => path.resolve(configPath))))
|
||||
.filter((configPath) => fs.existsSync(configPath))
|
||||
.sort();
|
||||
const registry: WorktreePortRegistry = {
|
||||
version: 1,
|
||||
configPaths: persistedPaths,
|
||||
};
|
||||
const temporaryPath = `${registryPath}.${process.pid}.tmp`;
|
||||
fs.writeFileSync(temporaryPath, `${JSON.stringify(registry, null, 2)}\n`, { mode: 0o600 });
|
||||
fs.renameSync(temporaryPath, registryPath);
|
||||
}
|
||||
|
|
@ -14,6 +14,13 @@ export type CanonicalWorktreeSeedSource = {
|
|||
targetInstanceId: string;
|
||||
};
|
||||
|
||||
export type RegisteredWorktreeSeedSourceInput = {
|
||||
registeredBaseWorkspaceCwd?: string | null;
|
||||
explicitSourceConfigPath?: string | null;
|
||||
targetConfigPath: string;
|
||||
expectedTargetInstanceId: string;
|
||||
};
|
||||
|
||||
function readInstanceId(configPath: string, label: "source" | "target"): string {
|
||||
const envPath = path.join(path.dirname(configPath), ".env");
|
||||
if (!existsSync(envPath)) {
|
||||
|
|
@ -47,23 +54,10 @@ function canonicalRegularFile(filePath: string, label: string): string {
|
|||
return canonical;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a worktree seed source without granting authority to the seed manifest.
|
||||
*
|
||||
* A managed caller supplies the project-workspace cwd from its server-owned row.
|
||||
* An operator may instead supply an explicit source config. When both are present,
|
||||
* the explicit path must still equal the registered project-workspace config.
|
||||
* Manifest source fields are diagnostic assertions only and can only make this
|
||||
* validation fail; they never select the returned source.
|
||||
*/
|
||||
export function resolveCanonicalWorktreeSeedSource(input: {
|
||||
registeredBaseWorkspaceCwd?: string | null;
|
||||
explicitSourceConfigPath?: string | null;
|
||||
targetConfigPath: string;
|
||||
expectedTargetInstanceId: string;
|
||||
manifestSource: WorktreeSeedSourceDiagnostic | null | undefined;
|
||||
manifestTargetInstanceId?: unknown;
|
||||
}): CanonicalWorktreeSeedSource {
|
||||
/** Resolve the authoritative source and target identities without consulting diagnostics. */
|
||||
export function resolveRegisteredWorktreeSeedSource(
|
||||
input: RegisteredWorktreeSeedSourceInput,
|
||||
): CanonicalWorktreeSeedSource {
|
||||
const registeredCwd = input.registeredBaseWorkspaceCwd?.trim();
|
||||
const explicitSource = input.explicitSourceConfigPath?.trim();
|
||||
if (!registeredCwd && !explicitSource) {
|
||||
|
|
@ -120,6 +114,29 @@ export function resolveCanonicalWorktreeSeedSource(input: {
|
|||
throw new Error("Source and target Paperclip configs name the same instance.");
|
||||
}
|
||||
|
||||
return {
|
||||
baseWorkspaceCwd: canonicalBaseCwd,
|
||||
configPath: canonicalSourceConfigPath,
|
||||
instanceId: sourceInstanceId,
|
||||
targetConfigPath: canonicalTargetConfigPath,
|
||||
targetInstanceId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a worktree seed source without granting authority to the seed manifest.
|
||||
*
|
||||
* A managed caller supplies the project-workspace cwd from its server-owned row.
|
||||
* An operator may instead supply an explicit source config. When both are present,
|
||||
* the explicit path must still equal the registered project-workspace config.
|
||||
* Manifest source fields are diagnostic assertions only and never select the
|
||||
* returned source.
|
||||
*/
|
||||
export function resolveCanonicalWorktreeSeedSource(input: RegisteredWorktreeSeedSourceInput & {
|
||||
manifestSource: WorktreeSeedSourceDiagnostic | null | undefined;
|
||||
manifestTargetInstanceId?: unknown;
|
||||
}): CanonicalWorktreeSeedSource {
|
||||
const registered = resolveRegisteredWorktreeSeedSource(input);
|
||||
const diagnosticPath = typeof input.manifestSource?.configPath === "string"
|
||||
? input.manifestSource.configPath.trim()
|
||||
: "";
|
||||
|
|
@ -130,21 +147,14 @@ export function resolveCanonicalWorktreeSeedSource(input: {
|
|||
diagnosticPath,
|
||||
"Worktree seed manifest source diagnostic",
|
||||
);
|
||||
if (path.resolve(diagnosticPath) !== canonicalSourceConfigPath || canonicalDiagnosticPath !== canonicalSourceConfigPath) {
|
||||
if (path.resolve(diagnosticPath) !== registered.configPath || canonicalDiagnosticPath !== registered.configPath) {
|
||||
throw new Error("Worktree seed manifest source path does not match the registered canonical source.");
|
||||
}
|
||||
if (input.manifestSource?.instanceId !== sourceInstanceId) {
|
||||
if (input.manifestSource?.instanceId !== registered.instanceId) {
|
||||
throw new Error("Worktree seed manifest source instance does not match the registered source instance.");
|
||||
}
|
||||
if (input.manifestTargetInstanceId !== targetInstanceId) {
|
||||
if (input.manifestTargetInstanceId !== registered.targetInstanceId) {
|
||||
throw new Error("Worktree seed manifest target instance does not match the registered target instance.");
|
||||
}
|
||||
|
||||
return {
|
||||
baseWorkspaceCwd: canonicalBaseCwd,
|
||||
configPath: canonicalSourceConfigPath,
|
||||
instanceId: sourceInstanceId,
|
||||
targetConfigPath: canonicalTargetConfigPath,
|
||||
targetInstanceId,
|
||||
};
|
||||
return registered;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -71,7 +71,21 @@ if (cliArgs[0] === "worktree" && cliArgs[1] === "ensure-seeded") {
|
|||
process.exit(${ensureExit});
|
||||
}
|
||||
fs.rmSync(".paperclip/seed-pending", { force: true });
|
||||
fs.writeFileSync(".paperclip/seed-complete", "{}\\n");
|
||||
fs.rmSync(".paperclip/seed-complete", { force: true });
|
||||
fs.writeFileSync(".paperclip/seed-manifest.json", JSON.stringify({
|
||||
version: 2,
|
||||
source: { instanceId: "base-source", configPath: ${JSON.stringify(path.join(baseCwd, ".paperclip", "config.json"))} },
|
||||
snapshotAt: "2026-08-19T00:00:00.000Z",
|
||||
seedMode: "minimal",
|
||||
migrationRevision: "0142_test.sql",
|
||||
targetInstanceId: "target-test",
|
||||
phase: "complete",
|
||||
state: "verified",
|
||||
attemptId: "attempt-test",
|
||||
startedAt: "2026-08-19T00:00:00.000Z",
|
||||
finishedAt: "2026-08-19T00:01:00.000Z",
|
||||
diagnostics: [{ phase: "complete", status: "succeeded", at: "2026-08-19T00:01:00.000Z" }],
|
||||
}) + "\\n");
|
||||
process.exit(0);
|
||||
}
|
||||
process.exit(0);
|
||||
|
|
@ -182,6 +196,45 @@ test("falls back to an isolated config when the base CLI cannot boot", () => {
|
|||
);
|
||||
});
|
||||
|
||||
test("reconciles deployment mode from the registered source when reusing a guest config", () => {
|
||||
const baseCwd = makeBaseWorkspace({ helpExit: 1, initExit: 0 });
|
||||
const { result: first, worktreeCwd, worktreesHome } = runProvision(baseCwd);
|
||||
assert.equal(first.status, 0, first.stderr);
|
||||
assert.equal(readWorktreeConfig(worktreeCwd).server.deploymentMode, "local_trusted");
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(baseCwd, ".paperclip", "config.json"),
|
||||
`${JSON.stringify({
|
||||
server: {
|
||||
deploymentMode: "authenticated",
|
||||
exposure: "private",
|
||||
},
|
||||
}, null, 2)}\n`,
|
||||
);
|
||||
|
||||
const second = spawnSync("bash", [script], {
|
||||
cwd: worktreeCwd,
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
PATH: testPath,
|
||||
HOME: os.homedir(),
|
||||
PAPERCLIP_WORKSPACE_BASE_CWD: baseCwd,
|
||||
PAPERCLIP_WORKSPACE_CWD: worktreeCwd,
|
||||
PAPERCLIP_WORKSPACE_BRANCH: "feature/provision-test",
|
||||
PAPERCLIP_WORKTREES_DIR: worktreesHome,
|
||||
PAPERCLIP_HOME: path.join(worktreesHome, "no-such-instance-home"),
|
||||
PAPERCLIP_PROJECT_WORKSPACE_ID: "project-workspace-1",
|
||||
PAPERCLIP_SEED_EXPECTED_COMPANY_ID: "company-1",
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(second.status, 0, second.stderr);
|
||||
assert.match(second.stderr, /Reusing existing isolated Paperclip worktree config/);
|
||||
assert.match(second.stderr, /Reconciled isolated Paperclip worktree deployment mode/);
|
||||
assert.equal(readWorktreeConfig(worktreeCwd).server.deploymentMode, "authenticated");
|
||||
assert.equal(readWorktreeConfig(worktreeCwd).server.exposure, "private");
|
||||
});
|
||||
|
||||
test("repairs an unhealthy base install under the lock and then uses the CLI", (t) => {
|
||||
const hasTools = ["flock", "git"].every(
|
||||
(tool) => spawnSync("bash", ["-lc", `command -v ${tool}`], { env: { PATH: testPath } }).status === 0,
|
||||
|
|
@ -276,7 +329,10 @@ test("runtime provisioning invokes ensure-seeded once and fast-exits after succe
|
|||
|
||||
const first = runRuntimeProvision(baseCwd, worktreeCwd);
|
||||
assert.equal(first.status, 0, first.stderr);
|
||||
assert.ok(fs.existsSync(path.join(worktreeCwd, ".paperclip", "seed-complete")));
|
||||
assert.equal(
|
||||
JSON.parse(fs.readFileSync(path.join(worktreeCwd, ".paperclip", "seed-manifest.json"), "utf8")).state,
|
||||
"verified",
|
||||
);
|
||||
assert.ok(!fs.existsSync(path.join(worktreeCwd, ".paperclip", "seed-pending")));
|
||||
|
||||
const ensureCallsAfterFirst = readCliInvocations(baseCwd)
|
||||
|
|
@ -287,12 +343,58 @@ test("runtime provisioning invokes ensure-seeded once and fast-exits after succe
|
|||
|
||||
const second = runRuntimeProvision(baseCwd, worktreeCwd);
|
||||
assert.equal(second.status, 0, second.stderr);
|
||||
assert.match(second.stderr, /already seeded.*skipping/);
|
||||
assert.match(second.stderr, /verified seed manifest.*skipping/);
|
||||
const ensureCallsAfterSecond = readCliInvocations(baseCwd)
|
||||
.filter((args) => args[0] === "worktree" && args[1] === "ensure-seeded");
|
||||
assert.equal(ensureCallsAfterSecond.length, 1);
|
||||
});
|
||||
|
||||
test("runtime provisioning seeds a worktree config that has no seed markers", () => {
|
||||
const baseCwd = makeBaseWorkspace({ helpExit: 0, initExit: 0 });
|
||||
const worktreeCwd = makeTempDir("paperclip-provision-runtime-unmarked-config-");
|
||||
fs.mkdirSync(path.join(worktreeCwd, ".paperclip"), { recursive: true });
|
||||
fs.writeFileSync(path.join(worktreeCwd, ".paperclip", "config.json"), "{}\n");
|
||||
|
||||
const result = runRuntimeProvision(baseCwd, worktreeCwd);
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(
|
||||
readCliInvocations(baseCwd)
|
||||
.filter((args) => args[0] === "worktree" && args[1] === "ensure-seeded").length,
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
JSON.parse(fs.readFileSync(path.join(worktreeCwd, ".paperclip", "seed-manifest.json"), "utf8")).state,
|
||||
"verified",
|
||||
);
|
||||
});
|
||||
|
||||
test("runtime provisioning bootstraps and seeds an empty .paperclip directory", () => {
|
||||
const baseCwd = makeBaseWorkspace({ helpExit: 0, initExit: 0 });
|
||||
fs.mkdirSync(path.join(baseCwd, "scripts"), { recursive: true });
|
||||
fs.copyFileSync(script, path.join(baseCwd, "scripts", "provision-worktree.sh"));
|
||||
const worktreeCwd = makeTempDir("paperclip-provision-runtime-empty-state-");
|
||||
fs.mkdirSync(path.join(worktreeCwd, ".paperclip"), { recursive: true });
|
||||
|
||||
const result = runRuntimeProvision(baseCwd, worktreeCwd);
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(result.stderr, /config is missing; running the built-in worktree provisioner/);
|
||||
const invocations = readCliInvocations(baseCwd);
|
||||
assert.equal(
|
||||
invocations.filter((args) => args[0] === "worktree" && args[1] === "init").length,
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
invocations.filter((args) => args[0] === "worktree" && args[1] === "ensure-seeded").length,
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
JSON.parse(fs.readFileSync(path.join(worktreeCwd, ".paperclip", "seed-manifest.json"), "utf8")).state,
|
||||
"verified",
|
||||
);
|
||||
});
|
||||
|
||||
test("runtime provisioning leaves seed-pending in place when ensure-seeded fails", () => {
|
||||
const baseCwd = makeBaseWorkspace({ helpExit: 0, initExit: 0, ensureExit: 4 });
|
||||
const worktreeCwd = makeTempDir("paperclip-provision-runtime-failure-");
|
||||
|
|
|
|||
|
|
@ -6,8 +6,6 @@ worktree_cwd="${PAPERCLIP_WORKSPACE_CWD:?PAPERCLIP_WORKSPACE_CWD is required}"
|
|||
paperclip_dir="$worktree_cwd/.paperclip"
|
||||
worktree_config_path="$paperclip_dir/config.json"
|
||||
seed_manifest_path="$paperclip_dir/seed-manifest.json"
|
||||
seed_pending_marker_path="$paperclip_dir/seed-pending"
|
||||
seed_complete_marker_path="$paperclip_dir/seed-complete"
|
||||
|
||||
if [[ ! -d "$base_cwd" ]]; then
|
||||
echo "Base workspace does not exist: $base_cwd" >&2
|
||||
|
|
@ -48,13 +46,23 @@ EOF
|
|||
echo "Worktree database has a verified seed manifest; skipping runtime provisioning." >&2
|
||||
exit 0
|
||||
fi
|
||||
elif [[ -e "$seed_complete_marker_path" || ! -e "$seed_pending_marker_path" ]]; then
|
||||
echo "Worktree database is already seeded by a legacy marker; skipping runtime provisioning." >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ ! -f "$worktree_config_path" ]]; then
|
||||
echo "Worktree config does not exist: $worktree_config_path" >&2
|
||||
initial_provision_script="$base_cwd/scripts/provision-worktree.sh"
|
||||
if [[ ! -f "$initial_provision_script" ]]; then
|
||||
echo "Worktree config does not exist and the built-in provision script is unavailable: $worktree_config_path" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Worktree config is missing; running the built-in worktree provisioner before database seeding." >&2
|
||||
(
|
||||
cd "$worktree_cwd" &&
|
||||
bash "$initial_provision_script"
|
||||
)
|
||||
fi
|
||||
|
||||
if [[ ! -f "$worktree_config_path" ]]; then
|
||||
echo "Worktree config still does not exist after built-in provisioning: $worktree_config_path" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
|
|
|||
|
|
@ -242,6 +242,51 @@ for (const rawValue of runtimePaths) {
|
|||
EOF
|
||||
}
|
||||
|
||||
reconcile_worktree_deployment_mode() {
|
||||
SOURCE_CONFIG_PATH="$source_config_path" \
|
||||
WORKTREE_CONFIG_PATH="$worktree_config_path" \
|
||||
node <<'EOF'
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const sourceConfigPath = path.resolve(process.env.SOURCE_CONFIG_PATH);
|
||||
const worktreeConfigPath = path.resolve(process.env.WORKTREE_CONFIG_PATH);
|
||||
const sourceConfig = JSON.parse(fs.readFileSync(sourceConfigPath, "utf8"));
|
||||
const worktreeConfig = JSON.parse(fs.readFileSync(worktreeConfigPath, "utf8"));
|
||||
const deploymentMode = sourceConfig?.server?.deploymentMode ?? "local_trusted";
|
||||
if (deploymentMode !== "local_trusted" && deploymentMode !== "authenticated") {
|
||||
throw new Error(`Registered source has unsupported server.deploymentMode: ${deploymentMode}`);
|
||||
}
|
||||
const exposure = deploymentMode === "local_trusted"
|
||||
? "private"
|
||||
: (sourceConfig?.server?.exposure ?? "private");
|
||||
const currentServer = worktreeConfig?.server && typeof worktreeConfig.server === "object"
|
||||
? worktreeConfig.server
|
||||
: {};
|
||||
if (currentServer.deploymentMode === deploymentMode && currentServer.exposure === exposure) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
worktreeConfig.server = {
|
||||
...currentServer,
|
||||
deploymentMode,
|
||||
exposure,
|
||||
};
|
||||
if (worktreeConfig.$meta && typeof worktreeConfig.$meta === "object") {
|
||||
worktreeConfig.$meta.updatedAt = new Date().toISOString();
|
||||
}
|
||||
|
||||
const temporaryPath = `${worktreeConfigPath}.deployment-mode-${process.pid}`;
|
||||
try {
|
||||
fs.writeFileSync(temporaryPath, `${JSON.stringify(worktreeConfig, null, 2)}\n`, { mode: 0o600 });
|
||||
fs.renameSync(temporaryPath, worktreeConfigPath);
|
||||
} finally {
|
||||
fs.rmSync(temporaryPath, { force: true });
|
||||
}
|
||||
console.error(`Reconciled isolated Paperclip worktree deployment mode from ${sourceConfigPath}: ${deploymentMode}/${exposure}`);
|
||||
EOF
|
||||
}
|
||||
|
||||
write_seed_pending_manifest() {
|
||||
SEED_MANIFEST_PATH="$seed_manifest_path" \
|
||||
SEED_PENDING_MARKER_PATH="$seed_pending_marker_path" \
|
||||
|
|
@ -579,6 +624,12 @@ else
|
|||
created_worktree_config=1
|
||||
fi
|
||||
|
||||
# The target config can predate a deployment-mode change on the registered
|
||||
# source, and older/fallback CLI writers may default this field independently.
|
||||
# Reconcile it after either create or reuse so the final guest config always
|
||||
# carries the source's deployment/auth contract without replacing its database.
|
||||
reconcile_worktree_deployment_mode
|
||||
|
||||
if [[ "$created_worktree_config" -eq 1 && ! -e "$seed_manifest_path" && ! -e "$seed_pending_marker_path" && ! -e "$seed_complete_marker_path" ]]; then
|
||||
write_seed_pending_manifest
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -0,0 +1,155 @@
|
|||
import { spawn } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import net from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
resetRuntimeServicesForTests,
|
||||
startRuntimeServicesForWorkspaceControl,
|
||||
} from "../services/workspace-runtime.js";
|
||||
import {
|
||||
doesLocalServiceCommandLineMatch,
|
||||
listLocalServiceRegistryRecords,
|
||||
readLocalServicePortOwner,
|
||||
resolveLocalServiceLogPath,
|
||||
terminateLocalService,
|
||||
} from "../services/local-service-supervisor.js";
|
||||
|
||||
describe("local service supervision", () => {
|
||||
afterEach(async () => {
|
||||
await resetRuntimeServicesForTests({ terminateProcesses: true });
|
||||
});
|
||||
|
||||
it("keeps request-logging runtime stdio usable after the supervisor side closes", async () => {
|
||||
const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-service-stdio-"));
|
||||
const paperclipHome = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-service-home-"));
|
||||
const previousPaperclipHome = process.env.PAPERCLIP_HOME;
|
||||
const previousInstanceId = process.env.PAPERCLIP_INSTANCE_ID;
|
||||
process.env.PAPERCLIP_HOME = paperclipHome;
|
||||
process.env.PAPERCLIP_INSTANCE_ID = `service-stdio-${randomUUID()}`;
|
||||
|
||||
let registryRecord: Awaited<ReturnType<typeof listLocalServiceRegistryRecords>>[number] | null = null;
|
||||
try {
|
||||
const [service] = await startRuntimeServicesForWorkspaceControl({
|
||||
actor: { id: null, name: "Board", companyId: randomUUID() },
|
||||
issue: null,
|
||||
workspace: {
|
||||
baseCwd: workspaceRoot,
|
||||
source: "agent_home",
|
||||
projectId: null,
|
||||
workspaceId: null,
|
||||
repoUrl: null,
|
||||
repoRef: null,
|
||||
strategy: "project_primary",
|
||||
cwd: workspaceRoot,
|
||||
branchName: null,
|
||||
worktreePath: null,
|
||||
warnings: [],
|
||||
created: false,
|
||||
},
|
||||
config: {
|
||||
workspaceRuntime: {
|
||||
services: [{
|
||||
name: "web",
|
||||
command: "node -e \"const http=require('node:http'); process.on('SIGTERM',()=>{}); http.createServer((req,res)=>{ process.stdout.write('request '+req.url+'\\\\n',(error)=>{ if (!error) res.end('ok'); }); }).listen(Number(process.env.PORT), '127.0.0.1')\"",
|
||||
port: { type: "auto" },
|
||||
readiness: {
|
||||
type: "http",
|
||||
urlTemplate: "http://127.0.0.1:{{port}}",
|
||||
timeoutSec: 10,
|
||||
intervalMs: 100,
|
||||
},
|
||||
lifecycle: "shared",
|
||||
reuseScope: "agent",
|
||||
stopPolicy: { type: "manual" },
|
||||
}],
|
||||
},
|
||||
},
|
||||
adapterEnv: {},
|
||||
});
|
||||
|
||||
expect(service?.url).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/);
|
||||
expect(service?.port).toBeTypeOf("number");
|
||||
registryRecord = (await listLocalServiceRegistryRecords({ profileKind: "workspace-runtime" }))
|
||||
.find((record) => record.runtimeServiceId === service!.id) ?? null;
|
||||
expect(registryRecord).not.toBeNull();
|
||||
|
||||
await resetRuntimeServicesForTests({ simulateSupervisorExit: true });
|
||||
|
||||
await expect(fetch(`${service!.url}/after-restart`)).resolves.toMatchObject({ ok: true });
|
||||
const log = await fs.readFile(resolveLocalServiceLogPath(registryRecord!.serviceKey), "utf8");
|
||||
expect(log).toContain("request /after-restart");
|
||||
|
||||
await terminateLocalService(registryRecord!);
|
||||
expect(await readLocalServicePortOwner(service!.port!)).toBeNull();
|
||||
await expect(fetch(service!.url!)).rejects.toThrow();
|
||||
registryRecord = null;
|
||||
} finally {
|
||||
if (registryRecord) await terminateLocalService(registryRecord).catch(() => undefined);
|
||||
if (previousPaperclipHome === undefined) delete process.env.PAPERCLIP_HOME;
|
||||
else process.env.PAPERCLIP_HOME = previousPaperclipHome;
|
||||
if (previousInstanceId === undefined) delete process.env.PAPERCLIP_INSTANCE_ID;
|
||||
else process.env.PAPERCLIP_INSTANCE_ID = previousInstanceId;
|
||||
await fs.rm(paperclipHome, { recursive: true, force: true });
|
||||
await fs.rm(workspaceRoot, { recursive: true, force: true });
|
||||
}
|
||||
}, 15_000);
|
||||
|
||||
it("does not wait for an unrelated process that takes over the service port", async () => {
|
||||
if (process.platform === "win32") return;
|
||||
const unrelatedListener = net.createServer();
|
||||
await new Promise<void>((resolve) => unrelatedListener.listen(0, "127.0.0.1", resolve));
|
||||
const address = unrelatedListener.address();
|
||||
const port = typeof address === "object" && address ? address.port : null;
|
||||
if (!port) throw new Error("Failed to allocate unrelated listener port");
|
||||
const child = spawn(process.execPath, ["-e", "setInterval(()=>{},1000)"], {
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
});
|
||||
child.unref();
|
||||
|
||||
try {
|
||||
await terminateLocalService({
|
||||
pid: child.pid!,
|
||||
processGroupId: child.pid!,
|
||||
port,
|
||||
});
|
||||
expect(await readLocalServicePortOwner(port)).toBe(process.pid);
|
||||
} finally {
|
||||
try {
|
||||
process.kill(-child.pid!, "SIGKILL");
|
||||
} catch {
|
||||
// The target process group was already terminated.
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
unrelatedListener.close((error) => error ? reject(error) : resolve());
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("recognizes a pnpm command after the launcher becomes pnpm.cjs", () => {
|
||||
expect(doesLocalServiceCommandLineMatch({
|
||||
commandLine: "/usr/bin/node /opt/pnpm/pnpm.cjs dev -- --bind custom --bind-host 127.0.0.1",
|
||||
recordedCommand: "pnpm dev -- --bind custom --bind-host 127.0.0.1",
|
||||
serviceName: "paperclip-dev",
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
it("does not accept a different command merely because it uses node", () => {
|
||||
expect(doesLocalServiceCommandLineMatch({
|
||||
commandLine: "/usr/bin/node /workspace/server/dist/index.js",
|
||||
recordedCommand: "pnpm dev -- --bind custom --bind-host 127.0.0.1",
|
||||
serviceName: "paperclip-dev",
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it("does not collapse different script paths to the same basename", () => {
|
||||
expect(doesLocalServiceCommandLineMatch({
|
||||
commandLine: "/usr/bin/node /other-workspace/server.js",
|
||||
recordedCommand: "node ./server.js",
|
||||
serviceName: "web",
|
||||
})).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -448,8 +448,6 @@ describe("resolveRuntimeProvisionCommand", () => {
|
|||
path.join(baseCwd, "scripts", "provision-worktree-runtime.sh"),
|
||||
"#!/usr/bin/env bash\n",
|
||||
);
|
||||
await fs.mkdir(path.join(cwd, ".paperclip"), { recursive: true });
|
||||
await fs.writeFile(path.join(cwd, ".paperclip", "seed-pending"), "{}\n");
|
||||
const workspace = {
|
||||
...buildWorkspace(cwd),
|
||||
baseCwd,
|
||||
|
|
@ -457,6 +455,17 @@ describe("resolveRuntimeProvisionCommand", () => {
|
|||
worktreePath: cwd,
|
||||
};
|
||||
|
||||
expect(resolveRuntimeProvisionCommand({ config: {}, workspace })).toBe(
|
||||
"bash ./scripts/provision-worktree-runtime.sh",
|
||||
);
|
||||
|
||||
await fs.mkdir(path.join(cwd, ".paperclip"), { recursive: true });
|
||||
await fs.writeFile(path.join(cwd, ".paperclip", "config.json"), "{}\n");
|
||||
expect(resolveRuntimeProvisionCommand({ config: {}, workspace })).toBe(
|
||||
"bash ./scripts/provision-worktree-runtime.sh",
|
||||
);
|
||||
|
||||
await fs.writeFile(path.join(cwd, ".paperclip", "seed-pending"), "{}\n");
|
||||
expect(resolveRuntimeProvisionCommand({ config: {}, workspace })).toBe(
|
||||
"bash ./scripts/provision-worktree-runtime.sh",
|
||||
);
|
||||
|
|
@ -466,7 +475,9 @@ describe("resolveRuntimeProvisionCommand", () => {
|
|||
})).toBe("./custom-provision.sh");
|
||||
|
||||
await fs.writeFile(path.join(cwd, ".paperclip", "seed-complete"), "{}\n");
|
||||
expect(resolveRuntimeProvisionCommand({ config: {}, workspace })).toBe("");
|
||||
expect(resolveRuntimeProvisionCommand({ config: {}, workspace })).toBe(
|
||||
"bash ./scripts/provision-worktree-runtime.sh",
|
||||
);
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(cwd, ".paperclip", "seed-manifest.json"),
|
||||
|
|
@ -772,6 +783,55 @@ describe("realizeExecutionWorkspace", () => {
|
|||
expect(second.branchName).toBe(first.branchName);
|
||||
});
|
||||
|
||||
it("defaults the repo-provided worktree provisioner for git worktree strategies", async () => {
|
||||
const repoRoot = await createTempRepo();
|
||||
await fs.mkdir(path.join(repoRoot, "scripts"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(repoRoot, "scripts", "provision-worktree.sh"),
|
||||
[
|
||||
"#!/usr/bin/env bash",
|
||||
"set -euo pipefail",
|
||||
"mkdir -p .paperclip",
|
||||
"printf 'provisioned\\n' > .paperclip/default-provision-ran",
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
await runGit(repoRoot, ["add", "scripts/provision-worktree.sh"]);
|
||||
await runGit(repoRoot, ["commit", "-m", "Add repository worktree provisioner"]);
|
||||
|
||||
const workspace = await realizeExecutionWorkspace({
|
||||
base: {
|
||||
baseCwd: repoRoot,
|
||||
source: "project_primary",
|
||||
projectId: "project-1",
|
||||
workspaceId: "workspace-1",
|
||||
repoUrl: null,
|
||||
repoRef: "HEAD",
|
||||
},
|
||||
config: {
|
||||
workspaceStrategy: {
|
||||
type: "git_worktree",
|
||||
branchTemplate: "{{issue.identifier}}-{{slug}}",
|
||||
},
|
||||
},
|
||||
issue: {
|
||||
id: "issue-default-provision",
|
||||
identifier: "PAP-17684",
|
||||
title: "Default worktree provisioning",
|
||||
},
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
name: "Codex Coder",
|
||||
companyId: "company-1",
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
fs.readFile(path.join(workspace.cwd, ".paperclip", "default-provision-ran"), "utf8"),
|
||||
).resolves.toBe("provisioned\n");
|
||||
});
|
||||
|
||||
it("warns when reusing a git worktree whose base ref has advanced", async () => {
|
||||
const repoRoot = await createTempRepo();
|
||||
|
||||
|
|
@ -7230,7 +7290,7 @@ describeEmbeddedPostgres("workspace runtime startup reconciliation", () => {
|
|||
}
|
||||
}, 40_000);
|
||||
|
||||
it("adopts a live auto-port shared service after runtime state is reset", async () => {
|
||||
it("re-adopts a request-logging service on the same auto port after supervisor stdio closes", async () => {
|
||||
const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-reconcile-"));
|
||||
const paperclipHome = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-home-"));
|
||||
process.env.PAPERCLIP_HOME = paperclipHome;
|
||||
|
|
@ -7239,7 +7299,6 @@ describeEmbeddedPostgres("workspace runtime startup reconciliation", () => {
|
|||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const runId = randomUUID();
|
||||
const executionWorkspaceId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
|
|
@ -7273,6 +7332,16 @@ describeEmbeddedPostgres("workspace runtime startup reconciliation", () => {
|
|||
projectId: null,
|
||||
workspaceId: null,
|
||||
};
|
||||
await fs.writeFile(
|
||||
path.join(workspaceRoot, "request-logger.cjs"),
|
||||
[
|
||||
"const http = require('node:http');",
|
||||
"http.createServer((req, res) => {",
|
||||
" process.stdout.write(`request ${req.url}\\n`, (error) => { if (!error) res.end('ok'); });",
|
||||
"}).listen(Number(process.env.PORT), '127.0.0.1');",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
leasedRunIds.add(runId);
|
||||
|
||||
const services = await ensureRuntimeServicesForRun({
|
||||
|
|
@ -7290,8 +7359,7 @@ describeEmbeddedPostgres("workspace runtime startup reconciliation", () => {
|
|||
services: [
|
||||
{
|
||||
name: "web",
|
||||
command:
|
||||
"node -e \"require('node:http').createServer((req,res)=>res.end('ok')).listen(Number(process.env.PORT), '127.0.0.1')\"",
|
||||
command: "node request-logger.cjs",
|
||||
port: { type: "auto" },
|
||||
readiness: {
|
||||
type: "http",
|
||||
|
|
@ -7315,9 +7383,13 @@ describeEmbeddedPostgres("workspace runtime startup reconciliation", () => {
|
|||
const service = services[0];
|
||||
expect(service?.url).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/);
|
||||
await expect(fetch(service!.url!)).resolves.toMatchObject({ ok: true });
|
||||
const originalPort = service!.port;
|
||||
const originalProviderRef = service!.providerRef;
|
||||
|
||||
await fs.rm(paperclipHome, { recursive: true, force: true });
|
||||
await resetRuntimeServicesForTests();
|
||||
// Closing the parent's pipe endpoints reproduces a real control-plane exit.
|
||||
// A service whose per-request logger still targets those pipes wedges (or
|
||||
// exits) on the reconciliation health probe instead of answering it.
|
||||
await resetRuntimeServicesForTests({ simulateSupervisorExit: true });
|
||||
|
||||
const result = await reconcilePersistedRuntimeServicesOnStartup(db);
|
||||
expect(result).toMatchObject({ reconciled: 1, adopted: 1, stopped: 0 });
|
||||
|
|
@ -7328,17 +7400,221 @@ describeEmbeddedPostgres("workspace runtime startup reconciliation", () => {
|
|||
.where(eq(workspaceRuntimeServices.id, service!.id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
expect(persisted?.status).toBe("running");
|
||||
expect(persisted?.providerRef).toMatch(/^\d+$/);
|
||||
expect(persisted?.port).toBe(originalPort);
|
||||
expect(persisted?.providerRef).toBe(originalProviderRef);
|
||||
await expect(fetch(service!.url!)).resolves.toMatchObject({ ok: true });
|
||||
|
||||
await stopRuntimeServicesForExecutionWorkspace({
|
||||
db,
|
||||
executionWorkspaceId,
|
||||
workspaceCwd: workspace.cwd,
|
||||
});
|
||||
await resetRuntimeServicesForTests({ terminateProcesses: true });
|
||||
leasedRunIds.delete(runId);
|
||||
await fs.rm(paperclipHome, { recursive: true, force: true });
|
||||
await fs.rm(workspaceRoot, { recursive: true, force: true });
|
||||
|
||||
await expect(fetch(service!.url!)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("re-adopts a desired service when pnpm is represented as the pnpm.cjs launcher", async () => {
|
||||
const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-pnpm-reconcile-"));
|
||||
const paperclipHome = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-home-"));
|
||||
const previousPaperclipHome = process.env.PAPERCLIP_HOME;
|
||||
const previousInstanceId = process.env.PAPERCLIP_INSTANCE_ID;
|
||||
process.env.PAPERCLIP_HOME = paperclipHome;
|
||||
process.env.PAPERCLIP_INSTANCE_ID = `runtime-pnpm-reconcile-${randomUUID()}`;
|
||||
|
||||
const portProbe = net.createServer();
|
||||
await new Promise<void>((resolve) => portProbe.listen(0, "127.0.0.1", resolve));
|
||||
const address = portProbe.address();
|
||||
const port = typeof address === "object" && address ? address.port : null;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
portProbe.close((error) => error ? reject(error) : resolve());
|
||||
});
|
||||
if (!port) throw new Error("Failed to reserve pnpm reconciliation test port");
|
||||
|
||||
const companyId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
const executionWorkspaceId = randomUUID();
|
||||
const runtimeServiceId = randomUUID();
|
||||
const command = "pnpm dev";
|
||||
const service = {
|
||||
name: "web",
|
||||
command,
|
||||
port,
|
||||
readiness: {
|
||||
type: "http",
|
||||
urlTemplate: "http://127.0.0.1:{{port}}",
|
||||
timeoutSec: 10,
|
||||
intervalMs: 50,
|
||||
},
|
||||
expose: null,
|
||||
lifecycle: "shared",
|
||||
reuseScope: "execution_workspace",
|
||||
stopPolicy: { type: "manual" },
|
||||
};
|
||||
const reuseKey = createHash("sha256")
|
||||
.update(stableStringifyForTest({
|
||||
scopeType: "execution_workspace",
|
||||
scopeId: executionWorkspaceId,
|
||||
serviceName: service.name,
|
||||
command,
|
||||
cwd: workspaceRoot,
|
||||
port,
|
||||
env: {},
|
||||
expose: null,
|
||||
}))
|
||||
.digest("hex");
|
||||
const wrapperPath = path.join(workspaceRoot, "pnpm.cjs");
|
||||
await fs.writeFile(
|
||||
wrapperPath,
|
||||
[
|
||||
"const http = require('node:http');",
|
||||
"const port = Number(process.argv[3]);",
|
||||
"http.createServer((_req, res) => res.end('ok')).listen(port, '127.0.0.1');",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
const child = spawn(process.execPath, [wrapperPath, "dev", String(port)], {
|
||||
cwd: workspaceRoot,
|
||||
detached: process.platform !== "win32",
|
||||
stdio: "ignore",
|
||||
});
|
||||
child.unref();
|
||||
|
||||
try {
|
||||
for (let attempt = 0; attempt < 50; attempt += 1) {
|
||||
try {
|
||||
if ((await fetch(`http://127.0.0.1:${port}`)).ok) break;
|
||||
} catch {
|
||||
// Wait for the simulated package-manager launcher to bind.
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
await expect(fetch(`http://127.0.0.1:${port}`)).resolves.toMatchObject({ ok: true });
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(projects).values({
|
||||
id: projectId,
|
||||
companyId,
|
||||
name: "Runtime pnpm reconciliation",
|
||||
status: "in_progress",
|
||||
});
|
||||
await db.insert(executionWorkspaces).values({
|
||||
id: executionWorkspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
projectWorkspaceId: null,
|
||||
mode: "isolated_workspace",
|
||||
strategyType: "git_worktree",
|
||||
name: "Runtime pnpm reconciliation workspace",
|
||||
status: "active",
|
||||
cwd: workspaceRoot,
|
||||
providerType: "local_fs",
|
||||
providerRef: workspaceRoot,
|
||||
metadata: {
|
||||
config: {
|
||||
workspaceRuntime: { services: [service] },
|
||||
desiredState: "running",
|
||||
serviceStates: { "0": "running" },
|
||||
},
|
||||
},
|
||||
});
|
||||
await db.insert(workspaceRuntimeServices).values({
|
||||
id: runtimeServiceId,
|
||||
companyId,
|
||||
projectId,
|
||||
projectWorkspaceId: null,
|
||||
executionWorkspaceId,
|
||||
issueId: null,
|
||||
scopeType: "execution_workspace",
|
||||
scopeId: executionWorkspaceId,
|
||||
serviceName: service.name,
|
||||
status: "running",
|
||||
lifecycle: "shared",
|
||||
reuseKey,
|
||||
command,
|
||||
cwd: workspaceRoot,
|
||||
port,
|
||||
url: `http://127.0.0.1:${port}`,
|
||||
provider: "local_process",
|
||||
providerRef: String(child.pid),
|
||||
ownerAgentId: null,
|
||||
startedByRunId: null,
|
||||
lastUsedAt: new Date(),
|
||||
startedAt: new Date(),
|
||||
stoppedAt: null,
|
||||
stopPolicy: { type: "manual" },
|
||||
healthStatus: "healthy",
|
||||
});
|
||||
await writeLocalServiceRegistryRecord({
|
||||
version: 1,
|
||||
serviceKey: `workspace-runtime-web-${randomUUID()}`,
|
||||
profileKind: "workspace-runtime",
|
||||
serviceName: service.name,
|
||||
command,
|
||||
cwd: workspaceRoot,
|
||||
envFingerprint: reuseKey,
|
||||
port,
|
||||
url: `http://127.0.0.1:${port}`,
|
||||
pid: child.pid!,
|
||||
processGroupId: child.pid ?? null,
|
||||
provider: "local_process",
|
||||
runtimeServiceId,
|
||||
reuseKey,
|
||||
startedAt: new Date().toISOString(),
|
||||
lastSeenAt: new Date().toISOString(),
|
||||
metadata: { executionWorkspaceId },
|
||||
});
|
||||
|
||||
const result = await reconcilePersistedRuntimeServicesOnStartup(db);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
reconciled: 1,
|
||||
adopted: 1,
|
||||
stopped: 0,
|
||||
restarted: 0,
|
||||
restartFailed: 0,
|
||||
});
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(workspaceRuntimeServices)
|
||||
.where(eq(workspaceRuntimeServices.executionWorkspaceId, executionWorkspaceId));
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]).toMatchObject({
|
||||
id: runtimeServiceId,
|
||||
status: "running",
|
||||
healthStatus: "healthy",
|
||||
port,
|
||||
providerRef: String(child.pid),
|
||||
});
|
||||
expect(await readLocalServicePortOwner(port)).toBe(child.pid);
|
||||
await expect(fetch(`http://127.0.0.1:${port}`)).resolves.toMatchObject({ ok: true });
|
||||
} finally {
|
||||
await stopRuntimeServicesForExecutionWorkspace({
|
||||
db,
|
||||
executionWorkspaceId,
|
||||
workspaceCwd: workspaceRoot,
|
||||
}).catch(() => undefined);
|
||||
if (child.pid) {
|
||||
try {
|
||||
if (process.platform === "win32") process.kill(child.pid, "SIGKILL");
|
||||
else process.kill(-child.pid, "SIGKILL");
|
||||
} catch {
|
||||
// The adopted runtime was already stopped through the managed path.
|
||||
}
|
||||
}
|
||||
await resetRuntimeServicesForTests();
|
||||
if (previousPaperclipHome === undefined) delete process.env.PAPERCLIP_HOME;
|
||||
else process.env.PAPERCLIP_HOME = previousPaperclipHome;
|
||||
if (previousInstanceId === undefined) delete process.env.PAPERCLIP_INSTANCE_ID;
|
||||
else process.env.PAPERCLIP_INSTANCE_ID = previousInstanceId;
|
||||
await fs.rm(paperclipHome, { recursive: true, force: true });
|
||||
await fs.rm(workspaceRoot, { recursive: true, force: true });
|
||||
}
|
||||
}, 20_000);
|
||||
|
||||
it("does not reuse a stopped auto-port service port while another process owns it", async () => {
|
||||
const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-unhealthy-adopt-"));
|
||||
const paperclipHome = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-home-"));
|
||||
|
|
|
|||
|
|
@ -444,13 +444,27 @@ describe("worktree config repair", () => {
|
|||
const firstConfigPath = path.join(firstWorktreeRoot, ".paperclip", "config.json");
|
||||
const secondConfigPath = path.join(secondWorktreeRoot, ".paperclip", "config.json");
|
||||
|
||||
const writeWorktree = async (worktreeRoot: string, name: string) => {
|
||||
const writeWorktree = async (
|
||||
worktreeRoot: string,
|
||||
name: string,
|
||||
databaseMode: "embedded-postgres" | "postgres" = "embedded-postgres",
|
||||
) => {
|
||||
const paperclipDir = path.join(worktreeRoot, ".paperclip");
|
||||
const instanceRoot = path.join(isolatedHome, "instances", name.toLowerCase());
|
||||
const config = buildIsolatedConfig(instanceRoot, 45439, 55439);
|
||||
await fs.mkdir(paperclipDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(paperclipDir, "config.json"),
|
||||
`${JSON.stringify(buildIsolatedConfig(instanceRoot, 45439, 55439), null, 2)}\n`,
|
||||
`${JSON.stringify({
|
||||
...config,
|
||||
database: {
|
||||
...config.database,
|
||||
mode: databaseMode,
|
||||
...(databaseMode === "postgres"
|
||||
? { connectionString: "postgres://paperclip:paperclip@127.0.0.1:55439/paperclip" }
|
||||
: {}),
|
||||
},
|
||||
}, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(
|
||||
|
|
@ -480,7 +494,7 @@ describe("worktree config repair", () => {
|
|||
delete process.env.DATABASE_URL;
|
||||
};
|
||||
|
||||
await writeWorktree(firstWorktreeRoot, "PAP-14013-import-bulk-skills");
|
||||
await writeWorktree(firstWorktreeRoot, "PAP-14013-import-bulk-skills", "postgres");
|
||||
await writeWorktree(secondWorktreeRoot, "PAP-14069-port-conflicts");
|
||||
const staleLockPath = path.join(isolatedHome, ".worktree-port-reservations.lock");
|
||||
await fs.mkdir(staleLockPath, { recursive: true });
|
||||
|
|
|
|||
|
|
@ -0,0 +1,229 @@
|
|||
import { execFile } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
ensureWorktreeSeeded,
|
||||
readWorktreeSeedManifest,
|
||||
} from "../../../cli/src/commands/worktree.ts";
|
||||
import { realizeExecutionWorkspace } from "../services/workspace-runtime.ts";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const cleanup: string[] = [];
|
||||
const originalConfig = process.env.PAPERCLIP_CONFIG;
|
||||
const originalWorktreesDir = process.env.PAPERCLIP_WORKTREES_DIR;
|
||||
|
||||
async function runGit(cwd: string, args: string[]) {
|
||||
await execFileAsync("git", args, { cwd });
|
||||
}
|
||||
|
||||
async function writeConfig(configPath: string, instanceId: string) {
|
||||
const instanceRoot = path.dirname(configPath);
|
||||
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
||||
await fs.writeFile(configPath, `${JSON.stringify({
|
||||
$meta: { version: 1, updatedAt: "2026-08-19T00:00:00.000Z", source: "configure" },
|
||||
database: {
|
||||
mode: "embedded-postgres",
|
||||
embeddedPostgresDataDir: path.join(instanceRoot, "db"),
|
||||
embeddedPostgresPort: 54329,
|
||||
backup: { enabled: false, intervalMinutes: 60, retentionDays: 30, dir: path.join(instanceRoot, "backups") },
|
||||
},
|
||||
logging: { mode: "file", logDir: path.join(instanceRoot, "logs") },
|
||||
server: {
|
||||
deploymentMode: "local_trusted",
|
||||
exposure: "private",
|
||||
host: "127.0.0.1",
|
||||
port: 3100,
|
||||
allowedHostnames: [],
|
||||
serveUi: true,
|
||||
},
|
||||
}, null, 2)}\n`, "utf8");
|
||||
await fs.writeFile(
|
||||
path.join(path.dirname(configPath), ".env"),
|
||||
`PAPERCLIP_INSTANCE_ID=${instanceId}\n`,
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
function verifiedSeedResult() {
|
||||
return {
|
||||
backupSummary: "snapshot.sql",
|
||||
snapshotAt: "2026-08-19T00:00:00.000Z",
|
||||
migrationRevision: "0223_test.sql",
|
||||
pausedScheduledRoutines: 0,
|
||||
executionQuarantine: {
|
||||
disabledTimerHeartbeats: 0,
|
||||
resetRunningAgents: 0,
|
||||
quarantinedInProgressIssues: 0,
|
||||
unassignedTodoIssues: 0,
|
||||
unassignedReviewIssues: 0,
|
||||
stoppedProjectWorkspaceRuntimes: 0,
|
||||
stoppedExecutionWorkspaceRuntimes: 0,
|
||||
stoppedRuntimeServices: 0,
|
||||
},
|
||||
reboundWorkspaces: [],
|
||||
validation: {
|
||||
authUserCount: 1,
|
||||
credentialAccountCount: 1,
|
||||
instanceAdminCount: 1,
|
||||
activeMembershipCount: 1,
|
||||
companyCount: 1,
|
||||
issueCount: 1,
|
||||
representativeCompanyId: "00000000-0000-4000-8000-000000000001",
|
||||
representativeIssueId: "00000000-0000-4000-8000-000000000002",
|
||||
migrationRevision: "0223_test.sql",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
if (originalConfig === undefined) delete process.env.PAPERCLIP_CONFIG;
|
||||
else process.env.PAPERCLIP_CONFIG = originalConfig;
|
||||
if (originalWorktreesDir === undefined) delete process.env.PAPERCLIP_WORKTREES_DIR;
|
||||
else process.env.PAPERCLIP_WORKTREES_DIR = originalWorktreesDir;
|
||||
for (const dir of cleanup.splice(0)) {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("managed worktree seed source through the server spawn path", () => {
|
||||
it("re-derives an ambient-instance manifest written before provisioning", async () => {
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-server-seed-source-"));
|
||||
cleanup.push(tempRoot);
|
||||
const repoRoot = path.join(tempRoot, "repo");
|
||||
const hooksDir = path.join(tempRoot, "hooks");
|
||||
const ambientConfigPath = path.join(tempRoot, "ambient", "config.json");
|
||||
const registeredConfigPath = path.join(repoRoot, ".paperclip", "config.json");
|
||||
const worktreeHome = path.join(tempRoot, "worktree-home");
|
||||
|
||||
await fs.mkdir(repoRoot, { recursive: true });
|
||||
await runGit(repoRoot, ["init", "-q"]);
|
||||
await runGit(repoRoot, ["config", "user.email", "paperclip@example.com"]);
|
||||
await runGit(repoRoot, ["config", "user.name", "Paperclip Test"]);
|
||||
await fs.mkdir(path.join(repoRoot, "scripts"), { recursive: true });
|
||||
await fs.writeFile(path.join(repoRoot, "README.md"), "server spawn regression\n", "utf8");
|
||||
await fs.copyFile(
|
||||
fileURLToPath(new URL("../../../scripts/provision-worktree.sh", import.meta.url)),
|
||||
path.join(repoRoot, "scripts", "provision-worktree.sh"),
|
||||
);
|
||||
await fs.chmod(path.join(repoRoot, "scripts", "provision-worktree.sh"), 0o755);
|
||||
await runGit(repoRoot, ["add", "README.md", "scripts/provision-worktree.sh"]);
|
||||
await runGit(repoRoot, ["commit", "-qm", "Add managed provision script"]);
|
||||
await writeConfig(registeredConfigPath, "registered-source");
|
||||
await writeConfig(ambientConfigPath, "ambient-instance");
|
||||
await fs.mkdir(worktreeHome, { recursive: true });
|
||||
|
||||
// Reproduce the integrated lane's ordering with a checkout hook: an earlier
|
||||
// worktree-init-style writer inherits the server environment and leaves a valid
|
||||
// target config whose manifest diagnostic points at the ambient instance.
|
||||
await fs.mkdir(hooksDir, { recursive: true });
|
||||
const hookPath = path.join(hooksDir, "post-checkout");
|
||||
await fs.writeFile(
|
||||
hookPath,
|
||||
`#!/usr/bin/env node
|
||||
const crypto = require("node:crypto");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const cwd = process.cwd();
|
||||
if (cwd === ${JSON.stringify(repoRoot)}) process.exit(0);
|
||||
const stateDir = path.join(cwd, ".paperclip");
|
||||
const normalized = path.basename(cwd).trim().toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/-+/g, "-").replace(/^[-_]+|[-_]+$/g, "");
|
||||
const instanceId = \`${"${(normalized || \"worktree\").slice(0, 48)}"}-\${crypto.createHash("sha256").update(path.resolve(cwd)).digest("hex").slice(0, 12)}\`;
|
||||
const targetConfigPath = path.join(stateDir, "config.json");
|
||||
const instanceRoot = path.join(process.env.PAPERCLIP_WORKTREES_DIR, "instances", instanceId);
|
||||
fs.mkdirSync(stateDir, { recursive: true });
|
||||
fs.mkdirSync(instanceRoot, { recursive: true });
|
||||
fs.writeFileSync(targetConfigPath, JSON.stringify({
|
||||
$meta: { version: 1, updatedAt: "2026-08-19T00:00:00.000Z", source: "configure" },
|
||||
database: {
|
||||
mode: "embedded-postgres",
|
||||
embeddedPostgresDataDir: path.join(instanceRoot, "db"),
|
||||
embeddedPostgresPort: 54330,
|
||||
backup: { enabled: false, intervalMinutes: 60, retentionDays: 30, dir: path.join(instanceRoot, "backups") },
|
||||
},
|
||||
logging: { mode: "file", logDir: path.join(instanceRoot, "logs") },
|
||||
server: {
|
||||
deploymentMode: "local_trusted",
|
||||
exposure: "private",
|
||||
host: "127.0.0.1",
|
||||
port: 3101,
|
||||
allowedHostnames: [],
|
||||
serveUi: true,
|
||||
},
|
||||
}, null, 2) + "\\n");
|
||||
fs.writeFileSync(path.join(stateDir, ".env"), [
|
||||
"PAPERCLIP_HOME=" + JSON.stringify(process.env.PAPERCLIP_WORKTREES_DIR),
|
||||
"PAPERCLIP_INSTANCE_ID=" + JSON.stringify(instanceId),
|
||||
"PAPERCLIP_CONFIG=" + JSON.stringify(targetConfigPath),
|
||||
"",
|
||||
].join("\\n"));
|
||||
fs.writeFileSync(path.join(stateDir, "seed-manifest.json"), JSON.stringify({
|
||||
version: 2,
|
||||
source: { instanceId: "ambient-instance", configPath: process.env.PAPERCLIP_CONFIG },
|
||||
snapshotAt: null,
|
||||
seedMode: "minimal",
|
||||
migrationRevision: null,
|
||||
targetInstanceId: instanceId,
|
||||
phase: "pending",
|
||||
state: "pending",
|
||||
attemptId: "ambient-writer",
|
||||
startedAt: null,
|
||||
finishedAt: null,
|
||||
diagnostics: [{ phase: "pending", status: "succeeded", at: new Date().toISOString() }],
|
||||
}, null, 2) + "\\n");
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
await fs.chmod(hookPath, 0o755);
|
||||
await runGit(repoRoot, ["config", "core.hooksPath", hooksDir]);
|
||||
|
||||
process.env.PAPERCLIP_CONFIG = ambientConfigPath;
|
||||
process.env.PAPERCLIP_WORKTREES_DIR = worktreeHome;
|
||||
const workspace = await realizeExecutionWorkspace({
|
||||
base: {
|
||||
baseCwd: repoRoot,
|
||||
source: "project_primary",
|
||||
projectId: "project-1",
|
||||
workspaceId: "project-workspace-1",
|
||||
repoUrl: null,
|
||||
repoRef: "HEAD",
|
||||
},
|
||||
config: {
|
||||
workspaceStrategy: {
|
||||
type: "git_worktree",
|
||||
branchTemplate: "{{issue.identifier}}-{{slug}}",
|
||||
provisionCommand: "bash ./scripts/provision-worktree.sh",
|
||||
},
|
||||
},
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-17681",
|
||||
title: "Recover ambient seed source",
|
||||
},
|
||||
agent: { id: "agent-1", name: "Coder", companyId: "company-1" },
|
||||
});
|
||||
|
||||
expect(readWorktreeSeedManifest(path.join(workspace.cwd, ".paperclip", "config.json"))).toMatchObject({
|
||||
source: { instanceId: "ambient-instance", configPath: ambientConfigPath },
|
||||
state: "pending",
|
||||
});
|
||||
|
||||
await expect(ensureWorktreeSeeded({
|
||||
config: path.join(workspace.cwd, ".paperclip", "config.json"),
|
||||
registeredBaseWorkspaceCwd: repoRoot,
|
||||
registeredProjectWorkspaceId: "project-workspace-1",
|
||||
expectedCompanyId: "company-1",
|
||||
}, {
|
||||
seedDatabase: async () => verifiedSeedResult(),
|
||||
})).resolves.toMatchObject({ seeded: true, reason: "seeded" });
|
||||
|
||||
expect(readWorktreeSeedManifest(path.join(workspace.cwd, ".paperclip", "config.json"))).toMatchObject({
|
||||
source: { instanceId: "registered-source", configPath: registeredConfigPath },
|
||||
state: "verified",
|
||||
phase: "complete",
|
||||
});
|
||||
}, 20_000);
|
||||
});
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { execFile } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import fsSync from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
|
|
@ -63,6 +64,32 @@ function getRuntimeServicesDir() {
|
|||
return path.resolve(resolvePaperclipInstanceRoot(), "runtime-services");
|
||||
}
|
||||
|
||||
function getRuntimeServiceLogsDir() {
|
||||
return path.resolve(resolvePaperclipInstanceRoot(), "runtime-service-logs");
|
||||
}
|
||||
|
||||
export function resolveLocalServiceLogPath(serviceKey: string) {
|
||||
if (!/^[a-z0-9._-]+$/.test(serviceKey)) {
|
||||
throw new Error("Invalid local service key for log path");
|
||||
}
|
||||
return path.resolve(getRuntimeServiceLogsDir(), `${serviceKey}.log`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a managed service's durable append-only output file.
|
||||
*
|
||||
* The returned descriptor is intended to be passed directly to spawn(). The
|
||||
* child receives its own duplicate, so the caller can close this handle as soon
|
||||
* as spawn returns without tying the service's stdio lifetime to Paperclip's.
|
||||
*/
|
||||
export async function openLocalServiceLogFile(serviceKey: string) {
|
||||
await fs.mkdir(getRuntimeServiceLogsDir(), { recursive: true });
|
||||
const logPath = resolveLocalServiceLogPath(serviceKey);
|
||||
const handle = await fs.open(logPath, "a+", 0o600);
|
||||
const startOffset = (await handle.stat()).size;
|
||||
return { handle, logPath, startOffset };
|
||||
}
|
||||
|
||||
function getRuntimeServiceRegistryPath(serviceKey: string) {
|
||||
return path.resolve(getRuntimeServicesDir(), `${serviceKey}.json`);
|
||||
}
|
||||
|
|
@ -230,10 +257,91 @@ export function isProcessGroupAlive(processGroupId: number | null | undefined) {
|
|||
if (typeof processGroupId !== "number" || !Number.isInteger(processGroupId) || processGroupId <= 0) return false;
|
||||
try {
|
||||
process.kill(-processGroupId, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (process.platform === "linux") {
|
||||
const liveMember = readLinuxProcessGroupActivity(processGroupId);
|
||||
if (liveMember !== null) return liveMember;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function readLinuxProcessGroupActivity(processGroupId: number): boolean | null {
|
||||
let entries: fsSync.Dirent[];
|
||||
try {
|
||||
entries = fsSync.readdirSync("/proc", { withFileTypes: true });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
let foundMember = false;
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || !/^\d+$/.test(entry.name)) continue;
|
||||
try {
|
||||
const stat = fsSync.readFileSync(`/proc/${entry.name}/stat`, "utf8");
|
||||
const commandEnd = stat.lastIndexOf(")");
|
||||
if (commandEnd < 0) continue;
|
||||
const fields = stat.slice(commandEnd + 1).trim().split(/\s+/);
|
||||
const state = fields[0];
|
||||
const memberProcessGroupId = Number.parseInt(fields[2] ?? "", 10);
|
||||
if (memberProcessGroupId !== processGroupId) continue;
|
||||
foundMember = true;
|
||||
if (state !== "Z" && state !== "X") return true;
|
||||
} catch {
|
||||
// The process can exit while /proc is scanned.
|
||||
}
|
||||
}
|
||||
|
||||
// kill(-pgid, 0) also succeeds for a group that contains only zombies. Such
|
||||
// processes cannot run or own a listener and are waiting only for their
|
||||
// parent to reap them, so termination is complete for service-control use.
|
||||
return foundMember ? false : null;
|
||||
}
|
||||
|
||||
function tokenizeCommandLine(value: string) {
|
||||
return value.match(/"(?:\\.|[^"\\])*"|'[^']*'|\S+/g) ?? [];
|
||||
}
|
||||
|
||||
function normalizeCommandToken(value: string) {
|
||||
const unquoted = value.replace(/^["']|["']$/g, "");
|
||||
const basename = path.basename(unquoted.replace(/\\/g, "/"));
|
||||
const launcher = basename.replace(/\.(?:cjs|mjs|js|cmd|exe)$/i, "");
|
||||
return /^(?:bun|node|nodejs|npm|npx|pnpm|yarn)$/i.test(launcher) ? launcher : unquoted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare a configured service command with the argv exposed by the OS.
|
||||
*
|
||||
* Package-manager launchers commonly replace `pnpm dev` with
|
||||
* `node /path/to/pnpm.cjs dev` after the shell starts. A literal substring
|
||||
* check rejects that surviving process even though the executable and all
|
||||
* configured arguments are still present. Normalize executable paths and
|
||||
* script extensions, then require the configured argv to remain contiguous.
|
||||
*/
|
||||
export function doesLocalServiceCommandLineMatch(input: {
|
||||
commandLine: string;
|
||||
recordedCommand: string;
|
||||
serviceName: string;
|
||||
}) {
|
||||
const normalize = (value: string) => value.replace(/["']/g, "").replace(/\s+/g, " ").trim();
|
||||
const normalizedCommandLine = normalize(input.commandLine);
|
||||
const normalizedRecordedCommand = normalize(input.recordedCommand);
|
||||
if (
|
||||
normalizedCommandLine.includes(normalizedRecordedCommand)
|
||||
|| normalizedCommandLine.includes(input.serviceName)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const actualTokens = tokenizeCommandLine(input.commandLine).map(normalizeCommandToken);
|
||||
const recordedTokens = tokenizeCommandLine(input.recordedCommand).map(normalizeCommandToken);
|
||||
if (recordedTokens.length === 0 || recordedTokens.length > actualTokens.length) return false;
|
||||
|
||||
return actualTokens.some((_, start) => recordedTokens.every(
|
||||
(token, offset) => actualTokens[start + offset] === token,
|
||||
));
|
||||
}
|
||||
|
||||
async function isLikelyMatchingCommand(record: LocalServiceRegistryRecord) {
|
||||
|
|
@ -242,10 +350,11 @@ async function isLikelyMatchingCommand(record: LocalServiceRegistryRecord) {
|
|||
const { stdout } = await execFileAsync("ps", ["-o", "command=", "-p", String(record.pid)]);
|
||||
const commandLine = stdout.trim();
|
||||
if (!commandLine) return false;
|
||||
const normalize = (value: string) => value.replace(/["']/g, "").replace(/\s+/g, " ").trim();
|
||||
const normalizedCommandLine = normalize(commandLine);
|
||||
const normalizedRecordedCommand = normalize(record.command);
|
||||
return normalizedCommandLine.includes(normalizedRecordedCommand) || normalizedCommandLine.includes(record.serviceName);
|
||||
return doesLocalServiceCommandLineMatch({
|
||||
commandLine,
|
||||
recordedCommand: record.command,
|
||||
serviceName: record.serviceName,
|
||||
});
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
|
|
@ -396,11 +505,34 @@ export async function touchLocalServiceRegistryRecord(
|
|||
}
|
||||
|
||||
export async function terminateLocalService(
|
||||
record: Pick<LocalServiceRegistryRecord, "pid" | "processGroupId">,
|
||||
opts?: { signal?: NodeJS.Signals; forceAfterMs?: number },
|
||||
record: Pick<LocalServiceRegistryRecord, "pid" | "processGroupId"> &
|
||||
Partial<Pick<LocalServiceRegistryRecord, "port">>,
|
||||
opts?: { signal?: NodeJS.Signals; forceAfterMs?: number; verifyAfterMs?: number },
|
||||
) {
|
||||
const signal = opts?.signal ?? "SIGTERM";
|
||||
const targetProcessGroup = process.platform !== "win32" && record.processGroupId && record.processGroupId > 0;
|
||||
|
||||
const targetIsGone = async () => {
|
||||
const targetAlive = targetProcessGroup
|
||||
? isProcessGroupAlive(record.processGroupId)
|
||||
: isPidAlive(record.pid);
|
||||
if (targetAlive) return false;
|
||||
if (!record.port) return true;
|
||||
const portOwnerPid = await readLocalServicePortOwner(record.port);
|
||||
if (!portOwnerPid) return true;
|
||||
const ownerProcessId = targetProcessGroup ? record.processGroupId! : record.pid;
|
||||
return !(await isLocalServiceProcessOwnedBy(portOwnerPid, ownerProcessId));
|
||||
};
|
||||
|
||||
const waitUntilGone = async (timeoutMs: number) => {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
do {
|
||||
if (await targetIsGone()) return true;
|
||||
await delay(100);
|
||||
} while (Date.now() < deadline);
|
||||
return await targetIsGone();
|
||||
};
|
||||
|
||||
try {
|
||||
if (targetProcessGroup) {
|
||||
process.kill(-record.processGroupId!, signal);
|
||||
|
|
@ -408,24 +540,10 @@ export async function terminateLocalService(
|
|||
process.kill(record.pid, signal);
|
||||
}
|
||||
} catch {
|
||||
return;
|
||||
if (await targetIsGone()) return;
|
||||
}
|
||||
|
||||
const deadline = Date.now() + (opts?.forceAfterMs ?? 2_000);
|
||||
while (Date.now() < deadline) {
|
||||
const targetAlive = targetProcessGroup
|
||||
? isProcessGroupAlive(record.processGroupId)
|
||||
: isPidAlive(record.pid);
|
||||
if (!targetAlive) {
|
||||
return;
|
||||
}
|
||||
await delay(100);
|
||||
}
|
||||
|
||||
const stillAlive = targetProcessGroup
|
||||
? isProcessGroupAlive(record.processGroupId)
|
||||
: isPidAlive(record.pid);
|
||||
if (!stillAlive) return;
|
||||
if (await waitUntilGone(opts?.forceAfterMs ?? 2_000)) return;
|
||||
try {
|
||||
if (targetProcessGroup) {
|
||||
process.kill(-record.processGroupId!, "SIGKILL");
|
||||
|
|
@ -435,6 +553,14 @@ export async function terminateLocalService(
|
|||
} catch {
|
||||
// Ignore cleanup races.
|
||||
}
|
||||
|
||||
if (await waitUntilGone(opts?.verifyAfterMs ?? 2_000)) return;
|
||||
|
||||
const target = targetProcessGroup
|
||||
? `process group ${record.processGroupId}`
|
||||
: `process ${record.pid}`;
|
||||
const listener = record.port ? ` and listener on port ${record.port}` : "";
|
||||
throw new Error(`Failed to terminate local service ${target}${listener}`);
|
||||
}
|
||||
|
||||
export async function readLocalServicePortOwner(port: number) {
|
||||
|
|
|
|||
|
|
@ -162,7 +162,7 @@ describe("diagnoseRuntimeListenerBinds against live listeners", () => {
|
|||
expect(diagnosis).toContain(`port ${appPort}`);
|
||||
// Node's hostless listen is dual-stack, so /proc shows :: and/or 0.0.0.0.
|
||||
expect(diagnosis).toMatch(/0\.0\.0\.0|::/);
|
||||
expect(diagnosis).toContain("--bind custom --bind-host 127.0.0.1");
|
||||
expect(diagnosis).toContain("--bind loopback");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -160,6 +160,6 @@ export async function diagnoseRuntimeListenerBinds(ports: number[]): Promise<str
|
|||
`${violations.join("; ")} instead of loopback only. The broker will not expose a listener `
|
||||
+ "reachable off-loopback. This means the workspace checkout's dev server ignored the managed "
|
||||
+ "loopback bind — a checkout that predates managed HTTPS exposure overwrites PAPERCLIP_BIND "
|
||||
+ "from its own --bind argv, so the start command must pass --bind custom --bind-host 127.0.0.1."
|
||||
+ "from its own --bind argv, so the start command must pass --bind loopback."
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -120,6 +120,10 @@ beforeAll(async () => {
|
|||
await fs.writeFile(path.join(guestDir, "dev-runner.mjs"), PRE_MANAGED_EXPOSURE_GUEST);
|
||||
await fs.writeFile(path.join(guestDir, "dev-runner-legacy.mjs"), ALWAYS_WILDCARD_GUEST);
|
||||
await fs.writeFile(path.join(guestDir, "dev-runner-wildcard-hmr.mjs"), WILDCARD_HMR_GUEST);
|
||||
await fs.writeFile(
|
||||
path.join(guestDir, "dev-runner-bind-conflict.mjs"),
|
||||
'process.stderr.write("local_trusted requires server.bind=loopback\\n"); process.exit(1);\n',
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
|
|
@ -474,7 +478,7 @@ describe("loopback bind is forced on the guest, not merely requested (PAP-17256)
|
|||
}));
|
||||
|
||||
// The launched command carries the loopback bind, replacing `--bind lan`.
|
||||
expect(runtime.command).toContain("--bind custom --bind-host 127.0.0.1");
|
||||
expect(runtime.command).toContain("--bind loopback");
|
||||
expect(runtime.command).not.toContain("--bind lan");
|
||||
|
||||
// And because the guest honoured it, both listeners are loopback-only, so
|
||||
|
|
@ -530,7 +534,7 @@ describe("loopback bind is forced on the guest, not merely requested (PAP-17256)
|
|||
// The bare code alone is what cost PAP-17254 three diagnostic cycles.
|
||||
expect(error!.message).toContain("listener_ownership_mismatch");
|
||||
expect(error!.message).toContain("loopback");
|
||||
expect(error!.message).toContain("--bind custom --bind-host 127.0.0.1");
|
||||
expect(error!.message).toContain("--bind loopback");
|
||||
}, 20_000);
|
||||
|
||||
it("leaves a non-Paperclip service's --bind argument alone", async () => {
|
||||
|
|
@ -547,7 +551,7 @@ describe("loopback bind is forced on the guest, not merely requested (PAP-17256)
|
|||
}));
|
||||
|
||||
expect(runtime.command).toBe(declared);
|
||||
expect(runtime.command).not.toContain("--bind custom");
|
||||
expect(runtime.command).not.toContain("--bind loopback");
|
||||
expect(calls.slice(0, 2)).toEqual(["reserve", "expose"]);
|
||||
expect(runtime.exposure?.state).toBe("ready");
|
||||
}, 20_000);
|
||||
|
|
@ -567,6 +571,20 @@ describe("loopback bind is forced on the guest, not merely requested (PAP-17256)
|
|||
expect(calls).toEqual([]);
|
||||
expect(runtime.command).toBe(declared);
|
||||
}, 20_000);
|
||||
|
||||
it("surfaces a deployment/bind conflict from a guest that exits during startup", async () => {
|
||||
const { broker } = createBroker();
|
||||
installDeps({ broker });
|
||||
|
||||
await expect(startRuntimeServicesForWorkspaceControl(startInput({
|
||||
serviceName: "paperclip-dev",
|
||||
command: guestCommand("dev-runner-bind-conflict.mjs"),
|
||||
expose: LEGACY_HTTP_EXPOSE,
|
||||
port: { type: "auto", envKey: "PORT" },
|
||||
}))).rejects.toThrow(
|
||||
/deployment\/bind conflict: local_trusted requires server\.bind=loopback.*output: local_trusted requires server\.bind=loopback/s,
|
||||
);
|
||||
}, 20_000);
|
||||
});
|
||||
|
||||
describe("readiness probes loopback for an exposed runtime (PAP-17256)", () => {
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ import {
|
|||
findAdoptableLocalService,
|
||||
isLocalServiceProcessOwnedBy,
|
||||
isLocalServiceProcessInWorkspace,
|
||||
openLocalServiceLogFile,
|
||||
readLocalServiceProcessCwd,
|
||||
readLocalServicePortOwner,
|
||||
removeLocalServiceRegistryRecord,
|
||||
|
|
@ -475,7 +476,7 @@ type ProcessOutputAccumulator = {
|
|||
* broker fake handles the removal rather than the real host broker.
|
||||
*/
|
||||
export async function resetRuntimeServicesForTests(
|
||||
opts: { terminateProcesses?: boolean } = {},
|
||||
opts: { terminateProcesses?: boolean; simulateSupervisorExit?: boolean } = {},
|
||||
) {
|
||||
if (opts.terminateProcesses) {
|
||||
for (const serviceId of [...runtimeServicesById.keys()]) {
|
||||
|
|
@ -484,6 +485,13 @@ export async function resetRuntimeServicesForTests(
|
|||
}
|
||||
for (const record of runtimeServicesById.values()) {
|
||||
clearIdleTimer(record);
|
||||
if (opts.simulateSupervisorExit) {
|
||||
// A real supervisor exit closes its side of every inherited pipe. Tests
|
||||
// use this to prove surviving request-logging services do not depend on
|
||||
// Paperclip keeping an anonymous stdio peer alive.
|
||||
record.child?.stdout?.destroy();
|
||||
record.child?.stderr?.destroy();
|
||||
}
|
||||
}
|
||||
runtimeServicesById.clear();
|
||||
runtimeServicesByReuseKey.clear();
|
||||
|
|
@ -2757,6 +2765,20 @@ function quoteShellArg(value: string) {
|
|||
return `'${value.replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
const BUILTIN_WORKSPACE_PROVISION_COMMAND = "bash ./scripts/provision-worktree.sh";
|
||||
|
||||
function resolveWorkspaceProvisionCommand(
|
||||
strategy: Record<string, unknown>,
|
||||
repoRoot: string,
|
||||
) {
|
||||
const configuredCommand = asString(strategy.provisionCommand, "").trim();
|
||||
if (configuredCommand) return configuredCommand;
|
||||
|
||||
return existsSync(path.join(repoRoot, "scripts", "provision-worktree.sh"))
|
||||
? BUILTIN_WORKSPACE_PROVISION_COMMAND
|
||||
: "";
|
||||
}
|
||||
|
||||
function resolveRepoManagedWorkspaceCommand(command: string, repoRoot: string) {
|
||||
const patterns = [
|
||||
/^(?<prefix>(?:bash|sh|zsh)\s+)(?<quote>["']?)(?<relative>\.\/[^"'\s]+)\k<quote>(?<suffix>(?:\s.*)?)$/s,
|
||||
|
|
@ -2953,7 +2975,7 @@ async function provisionExecutionWorktree(input: {
|
|||
created: boolean;
|
||||
recorder?: WorkspaceOperationRecorder | null;
|
||||
}) {
|
||||
const provisionCommand = asString(input.strategy.provisionCommand, "").trim();
|
||||
const provisionCommand = resolveWorkspaceProvisionCommand(input.strategy, input.repoRoot);
|
||||
if (!provisionCommand) return;
|
||||
const resolvedProvisionCommand = resolveRepoManagedWorkspaceCommand(provisionCommand, input.repoRoot);
|
||||
|
||||
|
|
@ -3434,22 +3456,20 @@ export async function ensurePersistedExecutionWorkspaceAvailable(input: {
|
|||
});
|
||||
realized.warnings = [...repairWarnings, ...baseRefreshWarnings, ...baseDrift.warnings];
|
||||
realized.baseRefSha = refresh.baseRefSha ?? recordedBaseRefSha ?? baseDrift.branchBaseRefSha ?? baseDrift.currentBaseRefSha;
|
||||
if (provisionCommand) {
|
||||
await provisionExecutionWorktree({
|
||||
strategy: {
|
||||
type: "git_worktree",
|
||||
provisionCommand,
|
||||
},
|
||||
base: input.base,
|
||||
repoRoot,
|
||||
worktreePath: realized.worktreePath ?? cwd,
|
||||
branchName: realized.branchName ?? "",
|
||||
issue: input.issue,
|
||||
agent: input.agent,
|
||||
created: false,
|
||||
recorder: input.recorder ?? null,
|
||||
});
|
||||
}
|
||||
await provisionExecutionWorktree({
|
||||
strategy: {
|
||||
type: "git_worktree",
|
||||
...(provisionCommand ? { provisionCommand } : {}),
|
||||
},
|
||||
base: input.base,
|
||||
repoRoot,
|
||||
worktreePath: realized.worktreePath ?? cwd,
|
||||
branchName: realized.branchName ?? "",
|
||||
issue: input.issue,
|
||||
agent: input.agent,
|
||||
created: false,
|
||||
recorder: input.recorder ?? null,
|
||||
});
|
||||
return realized;
|
||||
}
|
||||
|
||||
|
|
@ -5139,17 +5159,12 @@ export function resolveRuntimeProvisionCommand(input: {
|
|||
|
||||
const stateDir = path.join(input.workspace.cwd, ".paperclip");
|
||||
const manifestPath = path.join(stateDir, "seed-manifest.json");
|
||||
const pendingMarker = path.join(stateDir, "seed-pending");
|
||||
const completeMarker = path.join(stateDir, "seed-complete");
|
||||
const provisionScript = path.join(
|
||||
input.workspace.baseCwd,
|
||||
"scripts",
|
||||
"provision-worktree-runtime.sh",
|
||||
);
|
||||
let needsSeed = existsSync(pendingMarker) && !existsSync(completeMarker);
|
||||
if (existsSync(manifestPath)) {
|
||||
needsSeed = !hasVerifiedWorktreeSeedManifest(manifestPath);
|
||||
}
|
||||
const needsSeed = !existsSync(manifestPath) || !hasVerifiedWorktreeSeedManifest(manifestPath);
|
||||
if (!needsSeed || !existsSync(provisionScript)) {
|
||||
return "";
|
||||
}
|
||||
|
|
@ -5535,7 +5550,7 @@ async function spawnLocalRuntimeService(input: StartLocalRuntimeServiceInput): P
|
|||
// still rejected by the broker unless /proc proves loopback-only listeners.
|
||||
//
|
||||
// Three independent layers force the loopback bind, because a guest checkout
|
||||
// can be arbitrarily old (PAP-17256): the `--bind custom --bind-host` argv
|
||||
// can be arbitrarily old (PAP-17256): the `--bind loopback` argv
|
||||
// added above, these env vars for a runner that reads them, and HOST for one
|
||||
// old enough to ignore both and infer its bind mode from HOST alone.
|
||||
env.PAPERCLIP_BIND = RUNTIME_EXPOSURE_BIND_MODE;
|
||||
|
|
@ -5748,12 +5763,21 @@ async function spawnLocalRuntimeService(input: StartLocalRuntimeServiceInput): P
|
|||
}
|
||||
|
||||
const shell = resolveShell();
|
||||
const child = spawn(shell, ["-lc", command], {
|
||||
cwd: serviceCwd,
|
||||
env,
|
||||
detached: process.platform !== "win32",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
const serviceLog = await openLocalServiceLogFile(serviceKey);
|
||||
let child: ChildProcess;
|
||||
try {
|
||||
child = spawn(shell, ["-lc", command], {
|
||||
cwd: serviceCwd,
|
||||
env,
|
||||
detached: process.platform !== "win32",
|
||||
// The service receives duplicate append-only file descriptors. Closing
|
||||
// Paperclip (or this parent handle below) cannot strand a request logger
|
||||
// on an orphaned socketpair during startup reconciliation.
|
||||
stdio: ["ignore", serviceLog.handle.fd, serviceLog.handle.fd],
|
||||
});
|
||||
} finally {
|
||||
await serviceLog.handle.close();
|
||||
}
|
||||
record.child = child;
|
||||
record.providerRef = child.pid ? String(child.pid) : null;
|
||||
record.processGroupId = child.pid ?? null;
|
||||
|
|
@ -5763,24 +5787,23 @@ async function spawnLocalRuntimeService(input: StartLocalRuntimeServiceInput): P
|
|||
});
|
||||
});
|
||||
const earlyExitPromise = new Promise<never>((_, reject) => {
|
||||
child.once("exit", (code, signal) => {
|
||||
// `close` follows `exit` after the child's inherited stdout/stderr file
|
||||
// descriptors are closed. Waiting for it makes the startup log excerpt
|
||||
// deterministic instead of racing the final validation line.
|
||||
child.once("close", (code, signal) => {
|
||||
reject(new Error(
|
||||
`service process exited before readiness (code ${code ?? "unknown"}, signal ${signal ?? "none"})`,
|
||||
));
|
||||
});
|
||||
});
|
||||
let stderrExcerpt = "";
|
||||
let stdoutExcerpt = "";
|
||||
child.stdout?.on("data", async (chunk) => {
|
||||
const text = String(chunk);
|
||||
stdoutExcerpt = (stdoutExcerpt + text).slice(-4096);
|
||||
if (input.onLog) await input.onLog("stdout", `[service:${serviceName}] ${text}`);
|
||||
});
|
||||
child.stderr?.on("data", async (chunk) => {
|
||||
const text = String(chunk);
|
||||
stderrExcerpt = (stderrExcerpt + text).slice(-4096);
|
||||
if (input.onLog) await input.onLog("stderr", `[service:${serviceName}] ${text}`);
|
||||
});
|
||||
const readServiceOutputExcerpt = async () => {
|
||||
try {
|
||||
const contents = await fs.readFile(serviceLog.logPath);
|
||||
return contents.subarray(Math.max(serviceLog.startOffset, contents.length - 4096)).toString("utf8");
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
if (child.pid) {
|
||||
await writeLocalServiceRegistryRecord({
|
||||
|
|
@ -5907,6 +5930,10 @@ async function spawnLocalRuntimeService(input: StartLocalRuntimeServiceInput): P
|
|||
record.healthStatus = "healthy";
|
||||
record.lastUsedAt = new Date().toISOString();
|
||||
record.stoppedAt = null;
|
||||
const serviceOutputExcerpt = await readServiceOutputExcerpt();
|
||||
if (serviceOutputExcerpt && input.onLog) {
|
||||
await input.onLog("stdout", `[service:${serviceName}] ${serviceOutputExcerpt}`);
|
||||
}
|
||||
await touchLocalServiceRegistryRecord(record.serviceKey, {
|
||||
runtimeServiceId: record.id,
|
||||
lastSeenAt: record.lastUsedAt,
|
||||
|
|
@ -5914,18 +5941,20 @@ async function spawnLocalRuntimeService(input: StartLocalRuntimeServiceInput): P
|
|||
}).catch(async (err) => {
|
||||
releasePortReservation(reservedPort);
|
||||
releasePortReservation(claimedIdentityPort);
|
||||
const failureMessage = err instanceof Error ? err.message : String(err);
|
||||
const failureMessage = err instanceof Error ? err.message : String(err);
|
||||
const serviceOutputExcerpt = await readServiceOutputExcerpt();
|
||||
const bindCollision = !exposureConfig && (
|
||||
err instanceof RuntimeServicePortBindCollision || Boolean(
|
||||
port
|
||||
&& (input.allowFixedPortFallback || portType === "auto")
|
||||
&& /(?:EADDRINUSE|address already in use)/i.test(`${failureMessage}\n${stderrExcerpt}`),
|
||||
&& /(?:EADDRINUSE|address already in use)/i.test(`${failureMessage}\n${serviceOutputExcerpt}`),
|
||||
)
|
||||
);
|
||||
if (child.pid) {
|
||||
await terminateLocalService({
|
||||
pid: child.pid,
|
||||
processGroupId: child.pid,
|
||||
port,
|
||||
});
|
||||
}
|
||||
await cleanupRecordExposure(record, { preserveFailure: true });
|
||||
|
|
@ -5938,8 +5967,14 @@ async function spawnLocalRuntimeService(input: StartLocalRuntimeServiceInput): P
|
|||
await persistRuntimeServiceRecord(record.db, record).catch(() => undefined);
|
||||
}
|
||||
if (bindCollision && port) throw new RuntimeServicePortBindCollision(port);
|
||||
const deploymentBindConflict = /local_trusted requires server\.bind=loopback/i.test(
|
||||
`${failureMessage}\n${serviceOutputExcerpt}`,
|
||||
);
|
||||
const actionableFailure = deploymentBindConflict
|
||||
? `${failureMessage} | deployment/bind conflict: local_trusted requires server.bind=loopback; the managed runtime requested an incompatible bind mode`
|
||||
: failureMessage;
|
||||
throw new Error(
|
||||
`Failed to start runtime service "${serviceName}": ${failureMessage}${stderrExcerpt ? ` | stderr: ${stderrExcerpt.trim()}` : ""}`,
|
||||
`Failed to start runtime service "${serviceName}": ${actionableFailure}${serviceOutputExcerpt ? ` | output: ${serviceOutputExcerpt.trim()}` : ""}`,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -6125,19 +6160,14 @@ async function stopRuntimeService(serviceId: string) {
|
|||
const record = runtimeServicesById.get(serviceId);
|
||||
if (!record) return;
|
||||
clearIdleTimer(record);
|
||||
record.status = "stopped";
|
||||
record.healthStatus = "unknown";
|
||||
record.lastUsedAt = new Date().toISOString();
|
||||
record.stoppedAt = new Date().toISOString();
|
||||
runtimeServicesById.delete(serviceId);
|
||||
if (record.reuseKey && runtimeServicesByReuseKey.get(record.reuseKey) === record.id) {
|
||||
runtimeServicesByReuseKey.delete(record.reuseKey);
|
||||
}
|
||||
// Remove any public exposure first, but keep the process registered and the
|
||||
// row non-stopped until verified termination succeeds.
|
||||
await cleanupRecordExposure(record);
|
||||
if (record.child && record.child.pid) {
|
||||
await terminateLocalService({
|
||||
pid: record.child.pid,
|
||||
processGroupId: record.processGroupId ?? record.child.pid,
|
||||
port: record.port,
|
||||
});
|
||||
} else if (record.providerRef) {
|
||||
const pid = Number.parseInt(record.providerRef, 10);
|
||||
|
|
@ -6145,9 +6175,18 @@ async function stopRuntimeService(serviceId: string) {
|
|||
await terminateLocalService({
|
||||
pid,
|
||||
processGroupId: record.processGroupId,
|
||||
port: record.port,
|
||||
});
|
||||
}
|
||||
}
|
||||
record.status = "stopped";
|
||||
record.healthStatus = "unknown";
|
||||
record.lastUsedAt = new Date().toISOString();
|
||||
record.stoppedAt = new Date().toISOString();
|
||||
runtimeServicesById.delete(serviceId);
|
||||
if (record.reuseKey && runtimeServicesByReuseKey.get(record.reuseKey) === record.id) {
|
||||
runtimeServicesByReuseKey.delete(record.reuseKey);
|
||||
}
|
||||
await removeLocalServiceRegistryRecord(record.serviceKey);
|
||||
await persistRuntimeServiceRecord(record.db, record);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,11 @@ import {
|
|||
type PaperclipConfig,
|
||||
} from "@paperclipai/shared";
|
||||
import { updateEnvFileContents, writeEnvFileAtomicallyIfChanged } from "@paperclipai/shared/env-file";
|
||||
import {
|
||||
readWorktreePortRegistry,
|
||||
withWorktreePortRegistryLockSync,
|
||||
writeWorktreePortRegistry,
|
||||
} from "@paperclipai/shared/worktree-port-registry";
|
||||
import { resolvePaperclipConfigPath, resolvePaperclipEnvPath } from "./paths.js";
|
||||
import { rewriteUrlPort } from "./url-utils.js";
|
||||
|
||||
|
|
@ -99,89 +104,6 @@ type WorktreeRuntimeContext = {
|
|||
secretsKeyFilePath: string;
|
||||
};
|
||||
|
||||
type WorktreePortRegistry = {
|
||||
version: 1;
|
||||
configPaths: string[];
|
||||
};
|
||||
|
||||
const WORKTREE_PORT_REGISTRY_FILE = "worktree-port-reservations.json";
|
||||
const WORKTREE_PORT_REGISTRY_LOCK_DIR = ".worktree-port-reservations.lock";
|
||||
const WORKTREE_PORT_REGISTRY_LOCK_STALE_MS = 5_000;
|
||||
const WORKTREE_PORT_REGISTRY_LOCK_TIMEOUT_MS = 10_000;
|
||||
const sleepSyncBuffer = new Int32Array(new SharedArrayBuffer(4));
|
||||
|
||||
function sleepSync(durationMs: number): void {
|
||||
Atomics.wait(sleepSyncBuffer, 0, 0, durationMs);
|
||||
}
|
||||
|
||||
function withWorktreePortRegistryLock<T>(homeDir: string, run: () => T): T {
|
||||
fs.mkdirSync(homeDir, { recursive: true });
|
||||
const lockPath = path.resolve(homeDir, WORKTREE_PORT_REGISTRY_LOCK_DIR);
|
||||
const deadline = Date.now() + WORKTREE_PORT_REGISTRY_LOCK_TIMEOUT_MS;
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
fs.mkdirSync(lockPath);
|
||||
break;
|
||||
} catch (error) {
|
||||
const code = error instanceof Error && "code" in error ? error.code : null;
|
||||
if (code !== "EEXIST") throw error;
|
||||
|
||||
try {
|
||||
const ageMs = Date.now() - fs.statSync(lockPath).mtimeMs;
|
||||
if (ageMs > WORKTREE_PORT_REGISTRY_LOCK_STALE_MS) {
|
||||
fs.rmSync(lockPath, { recursive: true, force: true });
|
||||
continue;
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Date.now() >= deadline) {
|
||||
throw new Error(`Timed out waiting for worktree port reservation lock at ${lockPath}`);
|
||||
}
|
||||
sleepSync(25);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return run();
|
||||
} finally {
|
||||
fs.rmSync(lockPath, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function readWorktreePortRegistry(homeDir: string): Set<string> {
|
||||
const registryPath = path.resolve(homeDir, WORKTREE_PORT_REGISTRY_FILE);
|
||||
if (!fs.existsSync(registryPath)) return new Set();
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(registryPath, "utf8")) as Partial<WorktreePortRegistry>;
|
||||
if (parsed.version !== 1 || !Array.isArray(parsed.configPaths)) return new Set();
|
||||
return new Set(
|
||||
parsed.configPaths
|
||||
.filter((configPath): configPath is string => typeof configPath === "string" && configPath.length > 0)
|
||||
.map((configPath) => path.resolve(configPath)),
|
||||
);
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
function writeWorktreePortRegistry(homeDir: string, configPaths: Iterable<string>): void {
|
||||
const registryPath = path.resolve(homeDir, WORKTREE_PORT_REGISTRY_FILE);
|
||||
const persistedPaths = Array.from(new Set(Array.from(configPaths, (configPath) => path.resolve(configPath))))
|
||||
.filter((configPath) => fs.existsSync(configPath))
|
||||
.sort();
|
||||
const registry: WorktreePortRegistry = {
|
||||
version: 1,
|
||||
configPaths: persistedPaths,
|
||||
};
|
||||
const temporaryPath = `${registryPath}.${process.pid}.tmp`;
|
||||
fs.writeFileSync(temporaryPath, `${JSON.stringify(registry, null, 2)}\n`, { mode: 0o600 });
|
||||
fs.renameSync(temporaryPath, registryPath);
|
||||
}
|
||||
|
||||
function resolveWorktreeRuntimeContext(
|
||||
env: NodeJS.ProcessEnv,
|
||||
overrideConfigPath?: string,
|
||||
|
|
@ -354,7 +276,6 @@ function collectSiblingWorktreePorts(
|
|||
serverPorts.add(siblingConfig.server.port);
|
||||
}
|
||||
if (
|
||||
siblingConfig.database.mode === "embedded-postgres" &&
|
||||
Number.isInteger(siblingConfig.database.embeddedPostgresPort) &&
|
||||
siblingConfig.database.embeddedPostgresPort > 0
|
||||
) {
|
||||
|
|
@ -542,7 +463,7 @@ export function maybeRepairLegacyWorktreeConfigAndEnvFiles(): {
|
|||
let repairedConfig = false;
|
||||
if (fs.existsSync(context.configPath)) {
|
||||
try {
|
||||
const runtimeConfig = withWorktreePortRegistryLock(context.homeDir, () => {
|
||||
const runtimeConfig = withWorktreePortRegistryLockSync(context.homeDir, () => {
|
||||
const parsed = JSON.parse(fs.readFileSync(context.configPath, "utf8")) as PaperclipConfig;
|
||||
let selectedConfig = parsed;
|
||||
const registeredConfigPaths = readWorktreePortRegistry(context.homeDir);
|
||||
|
|
@ -671,4 +592,3 @@ export function maybePersistWorktreeRuntimePorts(input: {
|
|||
writeConfigFile(context.configPath, config);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue