mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-22 08:40:01 +02:00
feat(workspaces): sign the workspace login handoff and gate readiness (#11671)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Managed worktree services run isolated Paperclip instances with cloned databases. > - A reachable service was reported as ready even when its database, runtime identity, or login path was not usable. > - The first candidate added verified database seeding and managed repair in #11665. > - This pull request consolidates that candidate with signed login handoff and a complete readiness contract. > - Post-QA fixes close five defects in repair identity, repair responses, UI retry, seed journal handling, and seed-source trust. > - The benefit is a workspace that either opens safely or reports one accurate recovery action. ## Linked Issues or Issue Description No public GitHub issue exists for this work, so the problem is described here. **What happened** Managed workspace URLs could return HTTP 200 and report ready while login failed. QA also found cases where repair used the wrong instance identity, returned a generic error, left the UI stuck, rejected a safe journal lag, or trusted a mutable workspace manifest. **Expected behavior** Opening a ready workspace signs the board user in to the correct isolated instance. Provisioning and repair use a registered source and report a structured recovery state. **Actual behavior** Entry depended on a password copied into the clone. Several failure paths could publish stale readiness, hide the repair precondition, or trust state that the workspace could modify. **Additional context** This pull request includes the commits first published in #11665. That pull request keeps the original base head for review history. This consolidated pull request is the merge candidate. Related open readiness work includes #11575 and #11621. ## What Changed - Adds a short-lived, signed, single-use login ticket. It binds the user, workspace, instance, and runtime origin. - Exchanges the ticket through Better Auth. It creates the session and cookie through the supported adapter path. - Adds protected workspace readiness fields for the database, clone data, login handoff, seed phase, and runtime identity. - Fails readiness closed when the guest has no company or execution-workspace binding. - Binds ticket issuance to the exact cloned user and active company membership selected for the handoff. - Verifies every current active board identity through the exact-user handoff before publication or reuse. - Gates managed runtime publication on the readiness contract and the recorded worktree instance identity. - Refreshes runtime work products from the live runtime row after a port change. - Adds one workspace access card with ready, degraded, repairing, and failed states. - Uses the runtime response identity for repair. It returns structured repair precondition errors. - Lets a valid source journal lag converge during provisioning. - Binds seed and repair manifests to a source registered outside the agent-writable worktree. - Clears recovered UI errors so a successful retry can open the workspace. - Makes runtime tests register canonical sources and avoid ports owned by live host listeners. - Keeps Vitest on source suites when compiled `dist` trees exist. - Isolates CLI and adapter tests from ambient AWS and runtime API environment variables. - Preserves a 404 response for cross-company workspace ID lookups before runtime authorization. - Makes concurrent single-flight coverage independent of path-canonicalization scheduling order. ## Verification The following checks passed on the integrated head: ```sh pnpm -r typecheck pnpm build pnpm check:token-gates pnpm --filter @paperclipai/db check:migrations ``` - The server source lane passed 420 files and 4,953 tests. Five tests were skipped. - The CLI lane passed 57 files and 385 tests. - The database lane passed 26 files and 97 tests. - The shared package passed 58 files and 506 tests. - The adapter utility lane passed 640 tests. Four tests were skipped. - The Claude adapter passed 220 tests. One test was skipped. - The Codex adapter passed 323 tests. - The OpenClaw adapter passed 13 tests. - The OpenCode adapter passed 42 tests. - The plugin SDK passed 45 tests. - The workspace runtime suite passed 124 tests. - The caller-scoped readiness and handoff suite passed 52 tests. - The workspace provisioning shell suite passed 7 tests. - The runtime exposure suite passed 17 tests while live host mappings occupied fixed test ports. - `git diff --check` passed and the worktree is clean. The serialized route lane will run in GitHub CI with its normal shards. No deployment or active-workspace migration was performed. ## Risks - This is a medium-risk authentication and runtime-readiness change. - The login ticket uses exact origin, workspace, instance, and user binding. It has a short expiry and a one-time nonce. - Runtime publication is stricter. A real readiness, identity, per-user handoff, or control-plane database disagreement now blocks publication. - This pull request supersedes #11665 as the merge candidate. Close #11665 after this pull request merges. - No new database migration is included. The lockfile and workflow files are unchanged. - Deployment and active-workspace migration are intentionally outside this pull request. > 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 Claude Opus 5 (`claude-opus-5[1m]`), 1M context, extended thinking, tool use, and code execution produced the main candidate. OpenAI GPT-5 (`gpt-5`) through Codex, with agentic reasoning, tool use, and code execution, integrated the post-QA fixes and hardened the test gates. The Codex context-window size was not exposed. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
6aaef2998f
commit
a2bf936f9a
59 files changed
+7754
-243
No files matched your search
@@ -139,6 +139,9 @@ describe("secrets CLI helpers", () => {
|
||||
delete process.env.AWS_DEFAULT_REGION;
|
||||
delete process.env.PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID;
|
||||
delete process.env.PAPERCLIP_SECRETS_AWS_KMS_KEY_ID;
|
||||
delete process.env.AWS_ACCESS_KEY_ID;
|
||||
delete process.env.AWS_SECRET_ACCESS_KEY;
|
||||
delete process.env.AWS_SESSION_TOKEN;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -8,13 +8,17 @@ import { eq } from "drizzle-orm";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
agents,
|
||||
authAccounts,
|
||||
authUsers,
|
||||
companies,
|
||||
companyMemberships,
|
||||
createDb,
|
||||
executionWorkspaces,
|
||||
inspectMigrations,
|
||||
issueComments,
|
||||
issues,
|
||||
projectWorkspaces,
|
||||
instanceUserRoles,
|
||||
projects,
|
||||
routines,
|
||||
routineTriggers,
|
||||
@@ -27,6 +31,7 @@ import {
|
||||
markWorktreeSeedPending,
|
||||
pauseSeededScheduledRoutines,
|
||||
quarantineSeededWorktreeExecutionState,
|
||||
readWorktreeSeedManifest,
|
||||
readSourceAttachmentBody,
|
||||
rebindWorkspaceCwd,
|
||||
resolveSourceConfigPath,
|
||||
@@ -35,6 +40,7 @@ import {
|
||||
resolveGitWorktreeAddArgs,
|
||||
resolvePnpmInstallInvocation,
|
||||
resolveCurrentWorktreeEndpoint,
|
||||
resolveWorktreeSeedMigrationRevision,
|
||||
resolveWorktreeSeedBackupEngine,
|
||||
resolveWorktreeMakeTargetPath,
|
||||
worktreeRepairCommand,
|
||||
@@ -64,6 +70,88 @@ const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const itEmbeddedPostgres = embeddedPostgresSupport.supported ? it : it.skip;
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
function mockVerifiedSeedResult() {
|
||||
return {
|
||||
backupSummary: "snapshot.sql",
|
||||
snapshotAt: "2026-08-18T00:00:00.000Z",
|
||||
migrationRevision: "0142_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: "0142_test.sql",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function seedValidWorktreeSource(connectionString: string) {
|
||||
const db = createDb(connectionString);
|
||||
const companyId = randomUUID();
|
||||
const issueId = randomUUID();
|
||||
const now = new Date();
|
||||
await db.insert(authUsers).values({
|
||||
id: "user-existing",
|
||||
email: "existing@paperclip.ing",
|
||||
name: "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,
|
||||
});
|
||||
await db.insert(instanceUserRoles).values({
|
||||
userId: "user-existing",
|
||||
role: "instance_admin",
|
||||
});
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Seed Source",
|
||||
issuePrefix: "SEED",
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(companyMemberships).values({
|
||||
companyId,
|
||||
principalType: "user",
|
||||
principalId: "user-existing",
|
||||
status: "active",
|
||||
});
|
||||
await db.insert(issues).values({
|
||||
id: issueId,
|
||||
companyId,
|
||||
title: "Representative seed issue",
|
||||
status: "backlog",
|
||||
priority: "medium",
|
||||
issueNumber: 1,
|
||||
identifier: "SEED-1",
|
||||
});
|
||||
await db.$client.end({ timeout: 5 });
|
||||
return { companyId, issueId };
|
||||
}
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
`Skipping embedded Postgres worktree CLI tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
||||
@@ -400,7 +488,34 @@ describe("worktree helpers", () => {
|
||||
expect(full.nullifyColumns).toEqual({});
|
||||
});
|
||||
|
||||
it("ensure-seeded seeds once and fast-exits on the seed-complete marker", async () => {
|
||||
it("rejects a source migration journal that is ahead of the code journal", () => {
|
||||
expect(() => resolveWorktreeSeedMigrationRevision({
|
||||
status: "upToDate",
|
||||
tableCount: 1,
|
||||
availableMigrations: ["0001_initial.sql", "0002_current.sql"],
|
||||
appliedMigrations: ["0001_initial.sql", "0002_current.sql"],
|
||||
journalEntryCount: 3,
|
||||
}, "sourcePrefix")).toThrow("Migration journal is ahead of this Paperclip checkout");
|
||||
});
|
||||
|
||||
it("accepts a source migration journal that is multiple revisions behind", () => {
|
||||
expect(resolveWorktreeSeedMigrationRevision({
|
||||
status: "needsMigrations",
|
||||
tableCount: 1,
|
||||
availableMigrations: [
|
||||
"0001_initial.sql",
|
||||
"0002_applied.sql",
|
||||
"0003_pending.sql",
|
||||
"0004_pending.sql",
|
||||
],
|
||||
appliedMigrations: ["0001_initial.sql", "0002_applied.sql"],
|
||||
pendingMigrations: ["0003_pending.sql", "0004_pending.sql"],
|
||||
journalEntryCount: 2,
|
||||
reason: "pending-migrations",
|
||||
}, "sourcePrefix")).toBe("0002_applied.sql");
|
||||
});
|
||||
|
||||
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 {
|
||||
const sourceConfigPath = path.join(tempRoot, "source", "config.json");
|
||||
@@ -421,6 +536,7 @@ describe("worktree helpers", () => {
|
||||
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"),
|
||||
@@ -429,7 +545,7 @@ describe("worktree helpers", () => {
|
||||
markWorktreeSeedPending({ configPath: targetConfigPath, sourceConfigPath });
|
||||
|
||||
const seedDatabase = vi.fn().mockResolvedValue({
|
||||
backupSummary: "snapshot.sql",
|
||||
...mockVerifiedSeedResult(),
|
||||
pausedScheduledRoutines: 2,
|
||||
executionQuarantine: {
|
||||
disabledTimerHeartbeats: 1,
|
||||
@@ -441,15 +557,14 @@ describe("worktree helpers", () => {
|
||||
stoppedExecutionWorkspaceRuntimes: 0,
|
||||
stoppedRuntimeServices: 0,
|
||||
},
|
||||
reboundWorkspaces: [],
|
||||
});
|
||||
|
||||
await expect(
|
||||
ensureWorktreeSeeded({ config: targetConfigPath }, { seedDatabase }),
|
||||
ensureWorktreeSeeded({ config: targetConfigPath, fromConfig: sourceConfigPath }, { seedDatabase }),
|
||||
).resolves.toMatchObject({ seeded: true, reason: "seeded" });
|
||||
await expect(
|
||||
ensureWorktreeSeeded({ config: targetConfigPath }, { seedDatabase }),
|
||||
).resolves.toEqual({ seeded: false, reason: "complete_marker" });
|
||||
).resolves.toEqual({ seeded: false, reason: "verified_manifest" });
|
||||
|
||||
expect(seedDatabase).toHaveBeenCalledTimes(1);
|
||||
expect(seedDatabase).toHaveBeenCalledWith(expect.objectContaining({
|
||||
@@ -458,12 +573,127 @@ describe("worktree helpers", () => {
|
||||
instanceId: "ensure-seeded-test",
|
||||
}));
|
||||
expect(fs.existsSync(path.join(targetRoot, ".paperclip", "seed-pending"))).toBe(false);
|
||||
expect(fs.existsSync(path.join(targetRoot, ".paperclip", "seed-complete"))).toBe(true);
|
||||
expect(fs.existsSync(path.join(targetRoot, ".paperclip", "seed-complete"))).toBe(false);
|
||||
expect(readWorktreeSeedManifest(targetConfigPath)).toMatchObject({
|
||||
version: 2,
|
||||
state: "verified",
|
||||
phase: "complete",
|
||||
migrationRevision: "0142_test.sql",
|
||||
targetInstanceId: "ensure-seeded-test",
|
||||
});
|
||||
} 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 {
|
||||
const baseRoot = path.join(tempRoot, "base");
|
||||
const sourceConfigPath = path.join(baseRoot, ".paperclip", "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: "managed-target",
|
||||
});
|
||||
const sourceConfig = buildSourceConfig();
|
||||
const targetConfig = buildWorktreeConfig({
|
||||
sourceConfig,
|
||||
paths: targetPaths,
|
||||
serverPort: 3195,
|
||||
databasePort: 54995,
|
||||
});
|
||||
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=managed-source\n");
|
||||
fs.writeFileSync(targetConfigPath, `${JSON.stringify(targetConfig)}\n`);
|
||||
fs.writeFileSync(
|
||||
path.join(path.dirname(targetConfigPath), ".env"),
|
||||
`PAPERCLIP_HOME=${targetPaths.homeDir}\nPAPERCLIP_INSTANCE_ID=managed-target\n`,
|
||||
);
|
||||
markWorktreeSeedPending({ configPath: targetConfigPath, sourceConfigPath });
|
||||
const seedDatabase = vi.fn().mockResolvedValue(mockVerifiedSeedResult());
|
||||
|
||||
await expect(ensureWorktreeSeeded({
|
||||
config: targetConfigPath,
|
||||
registeredBaseWorkspaceCwd: baseRoot,
|
||||
registeredProjectWorkspaceId: "project-workspace-1",
|
||||
expectedCompanyId: "company-1",
|
||||
}, { seedDatabase })).resolves.toMatchObject({ seeded: true, reason: "seeded" });
|
||||
|
||||
expect(seedDatabase).toHaveBeenCalledWith(expect.objectContaining({
|
||||
sourceConfigPath,
|
||||
expectedCompanyId: "company-1",
|
||||
}));
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["sibling", "foreign_instance", "symlink", "instance_mismatch"] as const)(
|
||||
"managed ensure-seeded rejects a %s manifest source before lock or seed mutation",
|
||||
async (variant) => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), `paperclip-worktree-managed-${variant}-`));
|
||||
try {
|
||||
const baseRoot = path.join(tempRoot, "base");
|
||||
const canonicalSource = path.join(baseRoot, ".paperclip", "config.json");
|
||||
const targetRoot = path.join(tempRoot, "worktree");
|
||||
const targetConfigPath = path.join(targetRoot, ".paperclip", "config.json");
|
||||
const attackerRoot = path.join(tempRoot, variant);
|
||||
const attackerConfig = path.join(attackerRoot, "config.json");
|
||||
fs.mkdirSync(path.dirname(canonicalSource), { recursive: true });
|
||||
fs.mkdirSync(path.dirname(targetConfigPath), { recursive: true });
|
||||
fs.mkdirSync(attackerRoot, { recursive: true });
|
||||
fs.writeFileSync(canonicalSource, `${JSON.stringify(buildSourceConfig())}\n`);
|
||||
fs.writeFileSync(path.join(path.dirname(canonicalSource), ".env"), "PAPERCLIP_INSTANCE_ID=registered-source\n");
|
||||
fs.writeFileSync(targetConfigPath, `${JSON.stringify(buildSourceConfig())}\n`);
|
||||
fs.writeFileSync(
|
||||
path.join(path.dirname(targetConfigPath), ".env"),
|
||||
`PAPERCLIP_HOME=${path.join(tempRoot, "worktree-home")}\nPAPERCLIP_INSTANCE_ID=managed-target\n`,
|
||||
);
|
||||
fs.writeFileSync(attackerConfig, `${JSON.stringify(buildSourceConfig())}\n`);
|
||||
fs.writeFileSync(
|
||||
path.join(attackerRoot, ".env"),
|
||||
`PAPERCLIP_INSTANCE_ID=${variant === "foreign_instance" ? "foreign" : "registered-source"}\n`,
|
||||
);
|
||||
const diagnosticPath = variant === "instance_mismatch"
|
||||
? canonicalSource
|
||||
: variant === "symlink"
|
||||
? path.join(attackerRoot, "source-link.json")
|
||||
: attackerConfig;
|
||||
if (variant === "symlink") fs.symlinkSync(canonicalSource, diagnosticPath);
|
||||
markWorktreeSeedPending({
|
||||
configPath: targetConfigPath,
|
||||
sourceConfigPath: diagnosticPath,
|
||||
targetInstanceId: "managed-target",
|
||||
});
|
||||
if (variant === "instance_mismatch") {
|
||||
const manifest = readWorktreeSeedManifest(targetConfigPath)!;
|
||||
fs.writeFileSync(
|
||||
path.join(path.dirname(targetConfigPath), "seed-manifest.json"),
|
||||
JSON.stringify({ ...manifest, source: { ...manifest.source, instanceId: "foreign" } }),
|
||||
);
|
||||
}
|
||||
const seedDatabase = vi.fn();
|
||||
|
||||
await expect(ensureWorktreeSeeded({
|
||||
config: targetConfigPath,
|
||||
registeredBaseWorkspaceCwd: baseRoot,
|
||||
registeredProjectWorkspaceId: "project-workspace-1",
|
||||
expectedCompanyId: "company-1",
|
||||
}, { seedDatabase })).rejects.toThrow();
|
||||
|
||||
expect(seedDatabase).not.toHaveBeenCalled();
|
||||
expect(fs.existsSync(path.join(targetRoot, ".paperclip", "seed.lock"))).toBe(false);
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("ensure-seeded keeps the pending marker when seeding fails", async () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-ensure-seeded-failure-"));
|
||||
try {
|
||||
@@ -485,6 +715,7 @@ describe("worktree helpers", () => {
|
||||
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"),
|
||||
@@ -494,12 +725,16 @@ describe("worktree helpers", () => {
|
||||
|
||||
await expect(
|
||||
ensureWorktreeSeeded(
|
||||
{ config: targetConfigPath },
|
||||
{ config: targetConfigPath, fromConfig: sourceConfigPath },
|
||||
{ seedDatabase: vi.fn().mockRejectedValue(new Error("seed failed")) },
|
||||
),
|
||||
).rejects.toThrow("seed failed");
|
||||
|
||||
expect(fs.existsSync(path.join(targetRoot, ".paperclip", "seed-pending"))).toBe(true);
|
||||
expect(readWorktreeSeedManifest(targetConfigPath)).toMatchObject({
|
||||
state: "failed",
|
||||
phase: "pending",
|
||||
});
|
||||
expect(fs.existsSync(path.join(targetRoot, ".paperclip", "seed-pending"))).toBe(false);
|
||||
expect(fs.existsSync(path.join(targetRoot, ".paperclip", "seed-complete"))).toBe(false);
|
||||
expect(fs.existsSync(path.join(targetRoot, ".paperclip", "seed.lock"))).toBe(false);
|
||||
} finally {
|
||||
@@ -528,6 +763,7 @@ describe("worktree helpers", () => {
|
||||
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"),
|
||||
@@ -537,31 +773,17 @@ describe("worktree helpers", () => {
|
||||
|
||||
const seedDatabase = vi.fn(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
return {
|
||||
backupSummary: "snapshot.sql",
|
||||
pausedScheduledRoutines: 0,
|
||||
executionQuarantine: {
|
||||
disabledTimerHeartbeats: 0,
|
||||
resetRunningAgents: 0,
|
||||
quarantinedInProgressIssues: 0,
|
||||
unassignedTodoIssues: 0,
|
||||
unassignedReviewIssues: 0,
|
||||
stoppedProjectWorkspaceRuntimes: 0,
|
||||
stoppedExecutionWorkspaceRuntimes: 0,
|
||||
stoppedRuntimeServices: 0,
|
||||
},
|
||||
reboundWorkspaces: [],
|
||||
};
|
||||
return mockVerifiedSeedResult();
|
||||
});
|
||||
|
||||
const results = await Promise.all([
|
||||
ensureWorktreeSeeded({ config: targetConfigPath }, { seedDatabase }),
|
||||
ensureWorktreeSeeded({ config: targetConfigPath }, { seedDatabase }),
|
||||
ensureWorktreeSeeded({ config: targetConfigPath, fromConfig: sourceConfigPath }, { seedDatabase }),
|
||||
ensureWorktreeSeeded({ config: targetConfigPath, fromConfig: sourceConfigPath }, { seedDatabase }),
|
||||
]);
|
||||
|
||||
expect(results).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ seeded: true, reason: "seeded" }),
|
||||
{ seeded: false, reason: "complete_marker" },
|
||||
{ seeded: false, reason: "verified_manifest" },
|
||||
]));
|
||||
expect(seedDatabase).toHaveBeenCalledTimes(1);
|
||||
expect(fs.existsSync(path.join(targetRoot, ".paperclip", "seed.lock"))).toBe(false);
|
||||
@@ -570,6 +792,59 @@ describe("worktree helpers", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("records an interrupted phase before retrying to a verified terminal state", async () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-interrupted-seed-"));
|
||||
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: "interrupted-seed",
|
||||
});
|
||||
const sourceConfig = buildSourceConfig();
|
||||
const targetConfig = buildWorktreeConfig({
|
||||
sourceConfig,
|
||||
paths: targetPaths,
|
||||
serverPort: 3196,
|
||||
databasePort: 54996,
|
||||
});
|
||||
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`,
|
||||
);
|
||||
markWorktreeSeedPending({ configPath: targetConfigPath, sourceConfigPath });
|
||||
const interrupted = readWorktreeSeedManifest(targetConfigPath)!;
|
||||
fs.writeFileSync(
|
||||
path.join(targetRoot, ".paperclip", "seed-manifest.json"),
|
||||
`${JSON.stringify({ ...interrupted, state: "running", phase: "restore" }, null, 2)}\n`,
|
||||
);
|
||||
|
||||
await expect(ensureWorktreeSeeded(
|
||||
{ config: targetConfigPath, fromConfig: sourceConfigPath },
|
||||
{ seedDatabase: vi.fn().mockResolvedValue(mockVerifiedSeedResult()) },
|
||||
)).resolves.toMatchObject({ seeded: true, reason: "seeded" });
|
||||
|
||||
const verified = readWorktreeSeedManifest(targetConfigPath)!;
|
||||
expect(verified.state).toBe("verified");
|
||||
expect(verified.diagnostics).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
phase: "restore",
|
||||
status: "failed",
|
||||
message: "The previous seed attempt ended without a terminal result.",
|
||||
}),
|
||||
]));
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("fails closed instead of racing to reclaim a stale seed lock", async () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-ensure-seeded-stale-lock-"));
|
||||
try {
|
||||
@@ -969,7 +1244,7 @@ describe("worktree helpers", () => {
|
||||
});
|
||||
|
||||
itEmbeddedPostgres(
|
||||
"seeds authenticated users into minimally cloned worktree instances",
|
||||
"seeds a source whose migration journal is behind the code journal",
|
||||
async () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-worktree-auth-seed-"));
|
||||
const worktreeRoot = path.join(tempRoot, "PAP-999-auth-seed");
|
||||
@@ -983,15 +1258,23 @@ describe("worktree helpers", () => {
|
||||
const sourceDb = await startEmbeddedPostgresTestDatabase("paperclip-worktree-auth-source-");
|
||||
|
||||
try {
|
||||
await seedValidWorktreeSource(sourceDb.connectionString);
|
||||
const sourceDbClient = createDb(sourceDb.connectionString);
|
||||
await sourceDbClient.insert(authUsers).values({
|
||||
id: "user-existing",
|
||||
email: "existing@paperclip.ing",
|
||||
name: "Existing User",
|
||||
emailVerified: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
await sourceDbClient.$client.unsafe(`
|
||||
DELETE FROM "drizzle"."__drizzle_migrations"
|
||||
WHERE "id" = (
|
||||
SELECT max("id") FROM "drizzle"."__drizzle_migrations"
|
||||
)
|
||||
`);
|
||||
await sourceDbClient.$client.end({ timeout: 5 });
|
||||
const laggingMigrationState = await inspectMigrations(sourceDb.connectionString);
|
||||
expect(laggingMigrationState.status).toBe("needsMigrations");
|
||||
if (laggingMigrationState.status !== "needsMigrations") {
|
||||
throw new Error("Expected the source migration journal to lag the code journal");
|
||||
}
|
||||
expect(laggingMigrationState.pendingMigrations).toHaveLength(1);
|
||||
const sourceMigrationRevision = laggingMigrationState.appliedMigrations.at(-1);
|
||||
expect(sourceMigrationRevision).toBeTruthy();
|
||||
|
||||
fs.mkdirSync(path.dirname(sourceKeyPath), { recursive: true });
|
||||
fs.mkdirSync(worktreeRoot, { recursive: true });
|
||||
@@ -1028,6 +1311,19 @@ describe("worktree helpers", () => {
|
||||
const targetConfig = JSON.parse(
|
||||
fs.readFileSync(path.join(worktreeRoot, ".paperclip", "config.json"), "utf8"),
|
||||
) as PaperclipConfig;
|
||||
const manifestText = fs.readFileSync(
|
||||
path.join(worktreeRoot, ".paperclip", "seed-manifest.json"),
|
||||
"utf8",
|
||||
);
|
||||
expect(JSON.parse(manifestText)).toMatchObject({
|
||||
version: 2,
|
||||
seedMode: "minimal",
|
||||
state: "verified",
|
||||
phase: "complete",
|
||||
});
|
||||
expect(manifestText).toContain(`Validated migration ${sourceMigrationRevision}`);
|
||||
expect(manifestText).not.toContain("fixture-password-hash");
|
||||
expect(manifestText).not.toContain("source-master-key");
|
||||
const { default: EmbeddedPostgres } = await import("embedded-postgres");
|
||||
const targetPg = new EmbeddedPostgres({
|
||||
databaseDir: targetConfig.database.embeddedPostgresDataDir,
|
||||
@@ -1284,9 +1580,8 @@ describe("worktree helpers", () => {
|
||||
const originalCwd = process.cwd();
|
||||
const originalPaperclipConfig = process.env.PAPERCLIP_CONFIG;
|
||||
const currentDatabaseReservation = await reserveTestPort();
|
||||
const sourceDatabaseReservation = await reserveTestPort();
|
||||
const currentDatabasePort = currentDatabaseReservation.port;
|
||||
const sourceDatabasePort = sourceDatabaseReservation.port;
|
||||
const sourceDb = await startEmbeddedPostgresTestDatabase("paperclip-worktree-reseed-source-");
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(currentPaths.configPath), { recursive: true });
|
||||
@@ -1301,15 +1596,28 @@ describe("worktree helpers", () => {
|
||||
serverPort: 3114,
|
||||
databasePort: currentDatabasePort,
|
||||
});
|
||||
const sourceConfig = buildWorktreeConfig({
|
||||
sourceConfig: buildSourceConfig(),
|
||||
paths: sourcePaths,
|
||||
serverPort: 3200,
|
||||
databasePort: sourceDatabasePort,
|
||||
});
|
||||
const sourceConfig = buildSourceConfig();
|
||||
sourceConfig.database = {
|
||||
mode: "postgres",
|
||||
embeddedPostgresDataDir: sourcePaths.embeddedPostgresDataDir,
|
||||
embeddedPostgresPort: 54329,
|
||||
backup: {
|
||||
enabled: true,
|
||||
intervalMinutes: 60,
|
||||
retentionDays: 30,
|
||||
dir: sourcePaths.backupDir,
|
||||
},
|
||||
connectionString: sourceDb.connectionString,
|
||||
};
|
||||
sourceConfig.logging.logDir = sourcePaths.logDir;
|
||||
sourceConfig.storage.localDisk.baseDir = sourcePaths.storageDir;
|
||||
sourceConfig.secrets.localEncrypted.keyFilePath = sourcePaths.secretsKeyFilePath;
|
||||
await seedValidWorktreeSource(sourceDb.connectionString);
|
||||
fs.writeFileSync(currentPaths.configPath, JSON.stringify(currentConfig, null, 2), "utf8");
|
||||
fs.writeFileSync(sourcePaths.configPath, JSON.stringify(sourceConfig, null, 2), "utf8");
|
||||
fs.writeFileSync(sourcePaths.secretsKeyFilePath, "source-secret", "utf8");
|
||||
const worktreeSentinelPath = path.join(repoRoot, "user-worktree-file.txt");
|
||||
fs.writeFileSync(worktreeSentinelPath, "preserve me", "utf8");
|
||||
fs.writeFileSync(
|
||||
currentPaths.envPath,
|
||||
[
|
||||
@@ -1325,11 +1633,11 @@ describe("worktree helpers", () => {
|
||||
process.chdir(repoRoot);
|
||||
|
||||
await currentDatabaseReservation.release();
|
||||
await sourceDatabaseReservation.release();
|
||||
|
||||
await worktreeReseedCommand({
|
||||
fromConfig: sourcePaths.configPath,
|
||||
yes: true,
|
||||
backupTarget: true,
|
||||
});
|
||||
|
||||
const rewrittenConfig = JSON.parse(fs.readFileSync(currentPaths.configPath, "utf8"));
|
||||
@@ -1341,9 +1649,13 @@ describe("worktree helpers", () => {
|
||||
expect(rewrittenEnv).toContain(`PAPERCLIP_INSTANCE_ID=${currentInstanceId}`);
|
||||
expect(rewrittenEnv).toContain("PAPERCLIP_WORKTREE_NAME=existing-name");
|
||||
expect(rewrittenEnv).toContain("PAPERCLIP_WORKTREE_COLOR=\"#112233\"");
|
||||
expect(fs.readFileSync(worktreeSentinelPath, "utf8")).toBe("preserve me");
|
||||
expect(
|
||||
fs.readdirSync(path.join(currentPaths.backupDir, "repair")).some((name) => name.endsWith(".sql.gz")),
|
||||
).toBe(true);
|
||||
} finally {
|
||||
await currentDatabaseReservation.release();
|
||||
await sourceDatabaseReservation.release();
|
||||
await sourceDb.cleanup();
|
||||
process.chdir(originalCwd);
|
||||
if (originalPaperclipConfig === undefined) {
|
||||
delete process.env.PAPERCLIP_CONFIG;
|
||||
|
||||
Reference in new issue
Block a user