diff --git a/doc/plans/2026-11-07-online-git-source-fence.md b/doc/plans/2026-11-07-online-git-source-fence.md index e67165c3406..682725fcd97 100644 --- a/doc/plans/2026-11-07-online-git-source-fence.md +++ b/doc/plans/2026-11-07-online-git-source-fence.md @@ -105,6 +105,13 @@ original source, restore uncommitted/staged work, and run fsck. No hosted import hold, cancellation, provider call, or shared-cache Git command is part of these local tests. +A cohort shares its initial nested-source verification only within its held +source and finalizer fences, keyed by physical root, Git roots and the exact +recorded child set (or discovery). After every capture joins, a separate fresh +verification checks each physical root and child set before publication. A +changed child still rejects the whole cohort. Singleton imports keep their +per-import checks. No local-byte proof survives the invocation. + The online migration owner can checkpoint completed immutable nested-snapshot verification in the child snapshot ledger. This optional versioned proof binds the complete encrypted reference, child lineage, source operation and a known diff --git a/server/src/services/remote-workspace-cohort-migration.test.ts b/server/src/services/remote-workspace-cohort-migration.test.ts index 92c353d2eee..cc2c3ce9af4 100644 --- a/server/src/services/remote-workspace-cohort-migration.test.ts +++ b/server/src/services/remote-workspace-cohort-migration.test.ts @@ -139,7 +139,7 @@ describe.skipIf(!support.supported)("bounded workspace cohort migration", () => expect(await git(targetDir, "log", "-1", "--format=%s")).toBe("Original history"); } }, 60_000); - it("shares the merged child verifier across parallel aliases under the complete cohort fence", async () => { + it.each(["unchanged", "changed"] as const)("shares the merged child verifier across parallel aliases under the complete cohort fence (%s child bytes)", async mode => { const f = await fixture(), parent = f.leaves[0], cwd = path.join(parent.cwd!, ".paperclip", "worktrees", "nested"); await git(f.root, "worktree", "add", "-qb", "nested", cwd); const [childIssue, aliasIssue] = await db.insert(issues).values([ @@ -172,10 +172,24 @@ describe.skipIf(!support.supported)("bounded workspace cohort migration", () => return get(...args); }); const capture = vi.spyOn(workspaceArchive, "captureRemoteWorkspaceDirectory"); - await expect(run(f, { workspaceIds: [parent.id, alias.id] })).resolves.toMatchObject({ applied: true }); + const beforeActivity = await db.select().from(activityLog), put = storage.putFile.bind(storage); + let uploads = 0; + if (mode === "changed") vi.spyOn(storage, "putFile").mockImplementation(async input => { + const result = await put(input); + if (++uploads === 2) await fs.writeFile(path.join(cwd, "untracked.txt"), "changed during parent upload\n"); + return result; + }); + const pending = run(f, { workspaceIds: [parent.id, alias.id] }); + if (mode === "unchanged") await expect(pending).resolves.toMatchObject({ applied: true }); + else { + await expect(pending).rejects.toThrow("nested_workspace_source_changed"); + const selected = await db.select().from(executionWorkspaces).where(inArray(executionWorkspaces.id, [parent.id, alias.id])); + expect(selected.every(row => !readRemoteWorkspaceAuthority(row.metadata))).toBe(true); + expect(await db.select().from(activityLog)).toEqual(beforeActivity); + } expect(reads).toBe(1); - expect(capture.mock.calls.filter(([input]) => input.cwd === cwd && path.basename(input.archivePath) === "current.pcws")).toHaveLength(4); - expect(await fs.readFile(path.join(cwd, "untracked.txt"), "utf8")).toBe("nested dirty bytes\n"); + expect(capture.mock.calls.filter(([input]) => input.cwd === cwd && path.basename(input.archivePath) === "current.pcws")).toHaveLength(2); + expect(await fs.readFile(path.join(cwd, "untracked.txt"), "utf8")).toBe(mode === "unchanged" ? "nested dirty bytes\n" : "changed during parent upload\n"); }, 60_000); it.each(["foreign", "journal", "finished"])("refuses a changed %s operation before any authority write", async mode => { const f = await fixture(), put = storage.putFile.bind(storage); let uploaded = 0; diff --git a/server/src/services/remote-workspace-migration-tree.ts b/server/src/services/remote-workspace-migration-tree.ts index 34d88d67c1f..d7e3880c22a 100644 --- a/server/src/services/remote-workspace-migration-tree.ts +++ b/server/src/services/remote-workspace-migration-tree.ts @@ -361,6 +361,7 @@ export function createNestedWorkspaceImportVerifier(input: { }, }; return { + assertOwner, prepare(options: Omit): Promise { if (closed || current) return Promise.reject(new Error("nested_workspace_verifier_unavailable")); const work = (async () => { diff --git a/server/src/services/remote-workspace-migration.ts b/server/src/services/remote-workspace-migration.ts index 258724ccfff..cf068eebde6 100644 --- a/server/src/services/remote-workspace-migration.ts +++ b/server/src/services/remote-workspace-migration.ts @@ -431,17 +431,32 @@ async function lockMigrationRecovery(lock: MigrationTransaction, plans: Migratio for (const reader of readers) if (reader.status === "rejected") throw reader.reason; } -function createMigrationChildVerifier(input: MigrationInput, onlineSource: OnlineWorkspaceSource | undefined, sourceLock: MigrationTransaction) { +function createMigrationChildVerifier(input: MigrationInput, onlineSource: OnlineWorkspaceSource | undefined, sourceLock: MigrationTransaction, cohort = false) { if (onlineSource?.kind !== "git") return undefined; const verifier = createNestedWorkspaceImportVerifier({ db: input.db, storage: input.storage, companyId: input.companyId, sourceLock, projectIds: onlineSource.projectIds, issueIds: onlineSource.issueIds, signal: input.signal, persistVerification: true }); + type Options = Parameters[0]; + const initial = new Map>(), final = new Map>(); let pending = Promise.resolve(); + const prepare = (options: Options) => { + const work = pending.then(() => verifier.prepare(options)); + pending = work.then(() => undefined, () => undefined); + return work; + }; + const shared = async (options: Options, cache: typeof initial) => { + await verifier.assertOwner(); + const root = options.workspace.cwd ?? options.workspace.providerRef; + const key = canonical([root ? await fs.realpath(root) : null, [...(options.gitSourceRoots ?? [])].sort(), options.recordedChildren ?? null]); + let work = cache.get(key); + if (!work) { work = prepare(options); cache.set(key, work); } + const result = await work; + await verifier.assertOwner(); + return result; + }; return { - prepare(options: Parameters[0]) { - const work = pending.then(() => verifier.prepare(options)); - pending = work.then(() => undefined, () => undefined); - return work; - }, + prepare: (options: Options) => cohort ? shared(options, initial) : prepare(options), + finalize: (options: Options) => shared(options, final), + deferFinal: cohort, async close() { await pending; await verifier.close(); }, }; } @@ -511,7 +526,7 @@ async function captureMigration(input: MigrationInput, reserved: ReservedMigrati const current = await captureRemoteWorkspaceDirectory({ cwd: sourceRoot!, archivePath: currentArchive, exclude, requireNoGit: onlineSource?.kind === "ordinary", gitSourceRoots: onlineSource?.gitSourceRoots, signal: input.signal }); if (current.manifestDigest !== captured.manifestDigest) throw new Error("remote_workspace_migration_source_changed"); } - if (nested.length) { + if (nested.length && !childVerifier?.deferFinal) { const verifyScratch = path.join(scratch, "verify-nested"); await fs.mkdir(verifyScratch); const verified = await (childVerifier?.prepare ?? prepareNestedWorkspaceImports)({ ...input, workspace: reserved.workspace, scratch: verifyScratch, recordedChildren: nested, gitSourceRoots: onlineSource?.gitSourceRoots }); @@ -828,7 +843,7 @@ export async function migrateRemoteWorkspaceCohort(input: { cohort = refreshedCohort; } const captures: Array = Array(plans.length); - const childVerifier = createMigrationChildVerifier(plans[0].input, plans[0].onlineSource, lock); + const childVerifier = createMigrationChildVerifier(plans[0].input, plans[0].onlineSource, lock, true); let next = 0, failure: unknown; const worker = async () => { while (!signal.aborted && next < plans.length) { @@ -842,6 +857,15 @@ export async function migrateRemoteWorkspaceCohort(input: { for (const peer of peers) if (peer.status === "rejected") failure ??= peer.reason; if (failure) throw failure; signal.throwIfAborted(); + const nestedReaders = await Promise.allSettled(plans.map(async (plan, index) => { + const captured = captures[index]!; + if (!captured.nested.length) return; + const scratch = path.join(captured.scratch, "verify-nested"); await fs.mkdir(scratch); + const verified = await (childVerifier?.finalize ?? prepareNestedWorkspaceImports)({ ...plan.input, workspace: plan.reserved.workspace, + scratch, recordedChildren: captured.nested, gitSourceRoots: plan.onlineSource?.gitSourceRoots }); + if (canonical(verified) !== canonical(captured.nested)) throw new Error("nested_workspace_source_changed"); + })); + for (const reader of nestedReaders) if (reader.status === "rejected") throw reader.reason; await lock.execute(sql`select 1`); await lock.transaction(async tx => { await lockMigrationParents(tx, companyId, null, plans.flatMap(plan => [