From ef63dd212bd16482494f9d369054dc49c0059487 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 1 Aug 2026 12:42:18 +0200 Subject: [PATCH] Keep restored merged workspaces active (#2714) * fix(workspaces): keep restored merged workspaces active Auto-archive previously reacted to every merged pull request snapshot, so restoring a workspace immediately archived it again. Persist the consumed change request with the archive transition so explicit recovery remains authoritative. * test(e2e): clean partial archive scenarios Track workspace resources as soon as creation succeeds so later setup failures archive the worktree and remove the registered project. * test(workspaces): pin archive latch restoration Cover the persisted archive marker across restore in CI and make the real browser assertion observe active workspace stability after refresh. * fix(workspaces): latch restored legacy archives Resolve the current merged change request while restoring records created before the archive marker existed, then persist it with the unarchive mutation. * fix(workspaces): refresh latch on restore When an archived workspace now points at a different merged change request, record that URL during restore so the restored workspace remains active. * test(e2e): make archive latch setup fail loudly Remove authentication-gated skipping and register each external fixture cleanup immediately after allocation so setup failures cannot leak resources. * test(workspaces): include archive latch in reconciliation --- docs/data-model.md | 35 ++-- .../e2e/auto-archive-after-merge.real.spec.ts | 175 ++++++++++++++++++ .../archive-if-safe.test.ts | 44 +++++ .../auto-archive-on-merge/archive-if-safe.ts | 14 +- packages/server/src/server/bootstrap.ts | 14 +- .../workspace-provisioning-service.test.ts | 93 +++++++++- .../workspace-provisioning-service.ts | 21 ++- .../src/server/workspace-archive-service.ts | 9 +- .../workspace-reconciliation-service.test.ts | 1 + .../src/server/workspace-registry.test.ts | 29 +++ .../server/src/server/workspace-registry.ts | 34 +++- 11 files changed, 441 insertions(+), 28 deletions(-) create mode 100644 packages/app/e2e/auto-archive-after-merge.real.spec.ts diff --git a/docs/data-model.md b/docs/data-model.md index 64e2d125c..1568d13d5 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -490,23 +490,24 @@ workspace together with its owning project. Array of workspace records. A workspace is a specific working directory within a project. -| Field | Type | Description | -| ---------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `workspaceId` | `string` | Opaque stable identifier (`wks_`), generated independently of the directory. MUST NOT be treated as a path; compare by exact equality. Use the `cwd` field for directory access. | -| `projectId` | `string` | FK to Project.projectId; the workspace's stable project membership | -| `cwd` | `string` | Exact execution directory selected for agents, files, scripts, and setup | -| `kind` | `"local_checkout" \| "worktree" \| "directory"` | Mutable checkout classification | -| `displayName` | `string` | The human name (the generated/derived title). Decoupled from `branch` by construction. | -| `title` | `string \| null` | User-set name override layered over `displayName`. Null means "use `displayName`". | -| `branch` | `string \| null` | The current Git branch for git-backed workspaces. Separate from `displayName`/`title`; a background branch refresh never rewrites the name. | -| `worktreeRoot` | `string \| null` | Backing checkout/worktree root. May differ from `cwd` for exact subprojects and remains persisted after the worktree is deleted so restore can reproduce the placement. | -| `baseBranch` | `string \| null` | Normalized branch the Paseo worktree was created from; null for directories, local checkouts, and checkout-branch worktrees | -| `isPaseoOwnedWorktree` | `boolean` | Whether Paseo owns and may remove/recreate the backing `worktreeRoot` | -| `mainRepoRoot` | `string \| null` | Main repository root for worktree checkouts, independent of both exact `cwd` and backing `worktreeRoot` | -| `createdAt` | `string` (ISO 8601) | | -| `updatedAt` | `string` (ISO 8601) | | -| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete; required nullable | -| `pinnedAt` | `string \| null` (ISO 8601) | Pinned-to-top-of-sidebar timestamp; null means "not pinned" | +| Field | Type | Description | +| ------------------------------ | ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `workspaceId` | `string` | Opaque stable identifier (`wks_`), generated independently of the directory. MUST NOT be treated as a path; compare by exact equality. Use the `cwd` field for directory access. | +| `projectId` | `string` | FK to Project.projectId; the workspace's stable project membership | +| `cwd` | `string` | Exact execution directory selected for agents, files, scripts, and setup | +| `kind` | `"local_checkout" \| "worktree" \| "directory"` | Mutable checkout classification | +| `displayName` | `string` | The human name (the generated/derived title). Decoupled from `branch` by construction. | +| `title` | `string \| null` | User-set name override layered over `displayName`. Null means "use `displayName`". | +| `branch` | `string \| null` | The current Git branch for git-backed workspaces. Separate from `displayName`/`title`; a background branch refresh never rewrites the name. | +| `worktreeRoot` | `string \| null` | Backing checkout/worktree root. May differ from `cwd` for exact subprojects and remains persisted after the worktree is deleted so restore can reproduce the placement. | +| `baseBranch` | `string \| null` | Normalized branch the Paseo worktree was created from; null for directories, local checkouts, and checkout-branch worktrees | +| `isPaseoOwnedWorktree` | `boolean` | Whether Paseo owns and may remove/recreate the backing `worktreeRoot` | +| `mainRepoRoot` | `string \| null` | Main repository root for worktree checkouts, independent of both exact `cwd` and backing `worktreeRoot` | +| `createdAt` | `string` (ISO 8601) | | +| `updatedAt` | `string` (ISO 8601) | | +| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete; required nullable | +| `autoArchivedChangeRequestUrl` | `string \| null` | Change request whose merged state triggered auto-archive. Restore replaces it with the current merged change request, when present, so repeated snapshots cannot archive the workspace again. | +| `pinnedAt` | `string \| null` (ISO 8601) | Pinned-to-top-of-sidebar timestamp; null means "not pinned" | > **Opaque-ID invariant:** `workspaceId` is opaque identity, never a filesystem path. Filesystem and git operations take `cwd`/`workspaceDirectory` only — never the id. A compatibility-only first-materialization bootstrap still groups pre-registry agent records by path and Git remote so existing installs retain their legacy records. That grouping never runs against a live registry, and its keys are not runtime project or workspace identity. diff --git a/packages/app/e2e/auto-archive-after-merge.real.spec.ts b/packages/app/e2e/auto-archive-after-merge.real.spec.ts new file mode 100644 index 000000000..bd68c77b5 --- /dev/null +++ b/packages/app/e2e/auto-archive-after-merge.real.spec.ts @@ -0,0 +1,175 @@ +import { existsSync } from "node:fs"; +import { expect, test, type Page } from "./fixtures"; +import { gotoAppShell } from "./helpers/app"; +import { clickSessionRow, expectSessionRowArchived, openSessions } from "./helpers/archive-tab"; +import { cloneGithubRepoDefaultBranchOnly, createTempGithubRepo } from "./helpers/github-fixtures"; +import { + archiveWorkspaceFromDaemon, + connectNewWorkspaceDaemonClient, +} from "./helpers/new-workspace"; +import { connectSeedClient } from "./helpers/seed-client"; +import { getServerId } from "./helpers/server-id"; +import { waitForSidebarHydration, waitForWorkspaceInSidebar } from "./helpers/workspace-ui"; + +test.describe("Auto-archive after pull request merge", () => { + test.describe.configure({ retries: 0, timeout: 180_000 }); + + test("manual restore latches and keeps a merged pull request workspace active", async ({ + page, + }) => { + const scenario = await createMergedPullRequestScenario(); + + try { + await scenario.autoArchive(); + await openArchivedWorkspaceFromHistory(page, scenario.agentTitle); + await restoreWorkspace(page, scenario.workspaceId); + + await scenario.refreshMergedPullRequest(); + await scenario.expectWorkspaceRemainsActive(); + await waitForWorkspaceInSidebar(page, { + serverId: getServerId(), + workspaceId: scenario.workspaceId, + }); + } finally { + await scenario.cleanup(); + } + }); +}); + +interface MergedPullRequestScenario { + agentTitle: string; + workspaceId: string; + autoArchive(): Promise; + refreshMergedPullRequest(): Promise; + expectWorkspaceRemainsActive(): Promise; + cleanup(): Promise; +} + +async function createMergedPullRequestScenario(): Promise { + const cleanups: Array<() => Promise> = []; + + try { + const repo = await createTempGithubRepo({ + category: "auto-archive-latch", + prs: [{ title: "Auto-archive latch", state: "merged" }], + }); + cleanups.push(() => repo.cleanup()); + const checkout = await cloneGithubRepoDefaultBranchOnly(repo); + cleanups.push(() => checkout.cleanup()); + const workspaceClient = await connectNewWorkspaceDaemonClient(); + cleanups.push(() => workspaceClient.close()); + const agentClient = await connectSeedClient(); + cleanups.push(() => agentClient.close()); + const previousConfig = await workspaceClient.getDaemonConfig(); + await workspaceClient.patchDaemonConfig({ autoArchiveAfterMerge: false }); + cleanups.push(() => + workspaceClient.patchDaemonConfig({ + autoArchiveAfterMerge: previousConfig.config.autoArchiveAfterMerge, + }), + ); + + const pullRequest = repo.prs[0]; + if (!pullRequest) { + throw new Error("Expected the merged pull request fixture"); + } + const created = await workspaceClient.createPaseoWorktree({ + cwd: checkout.path, + action: "checkout", + checkoutSource: { + kind: "change_request", + forge: "github", + number: pullRequest.number, + }, + worktreeSlug: `auto-archive-latch-${Date.now()}`, + }); + if (!created.workspace || created.error) { + throw new Error(created.error ?? "Failed to create merged pull request workspace"); + } + + const workspace = created.workspace; + cleanups.push(() => workspaceClient.removeProject(workspace.projectId)); + cleanups.push(() => archiveWorkspaceFromDaemon(workspaceClient, workspace.workspaceDirectory)); + const agentTitle = `Auto-archive latch ${Date.now()}`; + const agent = await agentClient.createAgent({ + provider: "mock", + model: "ten-second-stream", + modeId: "load-test", + cwd: workspace.workspaceDirectory, + workspaceId: workspace.id, + title: agentTitle, + }); + await agentClient.waitForAgentUpsert( + agent.id, + (snapshot) => snapshot.status === "idle", + 30_000, + ); + + return { + agentTitle, + workspaceId: workspace.id, + autoArchive: async () => { + await workspaceClient.patchDaemonConfig({ autoArchiveAfterMerge: true }); + await workspaceClient.checkoutRefresh(workspace.workspaceDirectory); + await expect + .poll(() => agentClient.fetchAgent({ agentId: agent.id }), { timeout: 30_000 }) + .toMatchObject({ agent: { archivedAt: expect.any(String) } }); + await expect + .poll(() => existsSync(workspace.workspaceDirectory), { timeout: 30_000 }) + .toBe(false); + }, + refreshMergedPullRequest: async () => { + const refresh = await workspaceClient.checkoutRefresh(workspace.workspaceDirectory); + if (!refresh.success) { + throw new Error(`Failed to refresh restored workspace: ${String(refresh.error)}`); + } + }, + expectWorkspaceRemainsActive: async () => { + const observationDeadline = Date.now() + 5_000; + await expect + .poll( + async () => { + const descriptor = (await workspaceClient.fetchWorkspaces()).entries.find( + (entry) => entry.id === workspace.id, + ); + if (!descriptor) { + return "archived"; + } + return Date.now() >= observationDeadline ? "active" : "observing"; + }, + { timeout: 10_000, intervals: [100, 250, 500] }, + ) + .toBe("active"); + }, + cleanup: () => runScenarioCleanups(cleanups), + }; + } catch (error) { + await runScenarioCleanups(cleanups); + throw error; + } +} + +async function runScenarioCleanups(cleanups: Array<() => Promise>): Promise { + while (cleanups.length > 0) { + await cleanups + .pop()?.() + .catch(() => undefined); + } +} + +async function openArchivedWorkspaceFromHistory(page: Page, agentTitle: string): Promise { + await gotoAppShell(page); + await waitForSidebarHydration(page); + await openSessions(page); + await expectSessionRowArchived(page, agentTitle); + await clickSessionRow(page, agentTitle); + await expect(page.getByText("Workspace archived", { exact: true })).toBeVisible({ + timeout: 30_000, + }); +} + +async function restoreWorkspace(page: Page, workspaceId: string): Promise { + const action = page.getByTestId("workspace-recovery-action"); + await expect(action).toHaveText("Restore"); + await action.click(); + await waitForWorkspaceInSidebar(page, { serverId: getServerId(), workspaceId }); +} diff --git a/packages/server/src/server/auto-archive-on-merge/archive-if-safe.test.ts b/packages/server/src/server/auto-archive-on-merge/archive-if-safe.test.ts index 9f83c1ce4..5e8cf6c61 100644 --- a/packages/server/src/server/auto-archive-on-merge/archive-if-safe.test.ts +++ b/packages/server/src/server/auto-archive-on-merge/archive-if-safe.test.ts @@ -79,6 +79,7 @@ function createLogger(): Logger { function createHarness(overrides?: { autoArchiveAfterMerge?: boolean; + autoArchivedChangeRequestUrl?: string | null; getSnapshot?: () => Promise; isPaseoOwnedWorktreeCwd?: ArchiveIfSafeDependencies["isPaseoOwnedWorktreeCwd"]; archiveByScope?: ArchiveIfSafeDependencies["archiveByScope"]; @@ -105,6 +106,9 @@ function createHarness(overrides?: { terminalManager: {} as AutoArchiveArchiveOptions["terminalManager"], findWorkspaceIdForCwd: vi.fn(async () => "ws-auto-archive"), listActiveWorkspaces: vi.fn(async () => []), + getAutoArchivedChangeRequestUrl: vi.fn( + async () => overrides?.autoArchivedChangeRequestUrl ?? null, + ), archiveWorkspaceRecord: vi.fn(), markWorkspaceArchiving: vi.fn(), clearWorkspaceArchiving: vi.fn(), @@ -316,6 +320,7 @@ function createRealOutcomeHarness(input: { }, listActiveWorkspaces: async () => active.filter((workspace) => !input.archivedWorkspaceIds.has(workspace.workspaceId)), + getAutoArchivedChangeRequestUrl: async () => null, archiveWorkspaceRecord: async (workspaceId: string) => { input.archivedWorkspaceIds.add(workspaceId); const index = active.findIndex((workspace) => workspace.workspaceId === workspaceId); @@ -496,6 +501,45 @@ describe("archiveIfSafe", () => { expect(harness.inFlight.has(CWD)).toBe(false); }); + test("does not archive a merge event already consumed by this workspace", async () => { + const harness = createHarness({ + autoArchivedChangeRequestUrl: "https://github.com/acme/repo/pull/123", + }); + + await runArchiveIfSafe(harness); + + expect(harness.deps.archiveByScope).not.toHaveBeenCalled(); + }); + + test("archives a different merged change request", async () => { + const harness = createHarness({ + autoArchivedChangeRequestUrl: "https://github.com/acme/repo/pull/122", + }); + + await runArchiveIfSafe(harness); + + expect(harness.deps.archiveByScope).toHaveBeenCalledTimes(1); + }); + + test("records the consumed change request in the workspace archive mutation", async () => { + const harness = createHarness({ + archiveByScope: async (dependencies) => { + await dependencies.archiveWorkspaceRecord("ws-auto-archive"); + return { + archivedAgentIds: [], + archivedWorkspaceIds: ["ws-auto-archive"], + removedDirectory: false, + }; + }, + }); + + await runArchiveIfSafe(harness); + + expect(harness.options.archiveWorkspaceRecord).toHaveBeenCalledWith("ws-auto-archive", { + autoArchivedChangeRequestUrl: "https://github.com/acme/repo/pull/123", + }); + }); + test("resolves the merged cwd to a single workspace and does not iterate siblings", async () => { const harness = createHarness({ resolveWorkspaceIdAtPath: async () => "ws-merged-worktree", diff --git a/packages/server/src/server/auto-archive-on-merge/archive-if-safe.ts b/packages/server/src/server/auto-archive-on-merge/archive-if-safe.ts index 126f615bc..02c44bb9b 100644 --- a/packages/server/src/server/auto-archive-on-merge/archive-if-safe.ts +++ b/packages/server/src/server/auto-archive-on-merge/archive-if-safe.ts @@ -16,6 +16,7 @@ import type { import type { ForgeService } from "../../services/forge-service.js"; import type { TerminalManager } from "../../terminal/terminal-manager.js"; import { isPaseoOwnedWorktreeCwd } from "../../utils/worktree.js"; +import type { WorkspaceArchiveContext } from "../workspace-registry.js"; export interface AutoArchiveArchiveOptions { paseoHome: string; @@ -28,7 +29,8 @@ export interface AutoArchiveArchiveOptions { terminalManager: TerminalManager; findWorkspaceIdForCwd: (cwd: string) => Promise; listActiveWorkspaces: () => Promise; - archiveWorkspaceRecord: (workspaceId: string) => Promise; + getAutoArchivedChangeRequestUrl: (workspaceId: string) => Promise; + archiveWorkspaceRecord: (workspaceId: string, context?: WorkspaceArchiveContext) => Promise; markWorkspaceArchiving: (workspaceIds: Iterable, archivingAt: string) => void; clearWorkspaceArchiving: (workspaceIds: Iterable) => void; emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds: Iterable) => Promise; @@ -111,6 +113,11 @@ export async function archiveIfSafe(input: { log.warn({ cwd }, "Auto-archive could not resolve a workspace for cwd; skipping"); return; } + const autoArchivedChangeRequestUrl = + await options.getAutoArchivedChangeRequestUrl(workspaceId); + if (autoArchivedChangeRequestUrl === pullRequest.url) { + return; + } await deps.archiveByScope( { @@ -122,7 +129,10 @@ export async function archiveIfSafe(input: { agentStorage: options.agentStorage, findWorkspaceIdForCwd: options.findWorkspaceIdForCwd, listActiveWorkspaces: options.listActiveWorkspaces, - archiveWorkspaceRecord: options.archiveWorkspaceRecord, + archiveWorkspaceRecord: (workspaceIdToArchive) => + options.archiveWorkspaceRecord(workspaceIdToArchive, { + autoArchivedChangeRequestUrl: pullRequest.url, + }), emitWorkspaceUpdatesForWorkspaceIds: options.emitWorkspaceUpdatesForWorkspaceIds, markWorkspaceArchiving: options.markWorkspaceArchiving, clearWorkspaceArchiving: options.clearWorkspaceArchiving, diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index f2027766f..beee901ef 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -138,7 +138,11 @@ import type { PaseoToolRuntimeContext } from "./agent/tools/types.js"; import { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js"; import { bootstrapWorkspaceRegistries } from "./workspace-registry-bootstrap.js"; import { WorkspaceReconciliationService } from "./workspace-reconciliation-service.js"; -import { FileBackedProjectRegistry, FileBackedWorkspaceRegistry } from "./workspace-registry.js"; +import { + FileBackedProjectRegistry, + FileBackedWorkspaceRegistry, + type WorkspaceArchiveContext, +} from "./workspace-registry.js"; import { FileBackedChatService } from "./chat/chat-service.js"; import { CheckoutDiffManager } from "./checkout-diff-manager.js"; import { LoopService } from "./loop-service.js"; @@ -876,10 +880,14 @@ export async function createPaseoDaemon( paseoHome: config.paseoHome, workspaceGitService, }); - const archiveWorkspaceRecordExternal = async (workspaceId: string) => { + const archiveWorkspaceRecordExternal = async ( + workspaceId: string, + context?: WorkspaceArchiveContext, + ) => { const existingWorkspace = await archivePersistedWorkspaceRecord({ workspaceId, workspaceRegistry, + context, }); if (!existingWorkspace || existingWorkspace.archivedAt) return; teardownArchivedWorkspaceRuntime(workspaceId); @@ -983,6 +991,8 @@ export async function createPaseoDaemon( logger, findWorkspaceIdForCwd: findWorkspaceIdForCwdExternal, listActiveWorkspaces: listActiveWorkspacesExternal, + getAutoArchivedChangeRequestUrl: async (workspaceId) => + (await workspaceRegistry.get(workspaceId))?.autoArchivedChangeRequestUrl ?? null, archiveWorkspaceRecord: archiveWorkspaceRecordExternal, markWorkspaceArchiving: markWorkspaceArchivingExternal, clearWorkspaceArchiving: clearWorkspaceArchivingExternal, diff --git a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts index 8c08093f2..a97e82f85 100644 --- a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts +++ b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts @@ -5,7 +5,10 @@ import { mkdirSync, mkdtempSync, rmSync, symlinkSync } from "node:fs"; import { afterEach, beforeEach, expect, test } from "vitest"; import { createTestLogger } from "../../../test-utils/test-logger.js"; -import { createNoopWorkspaceGitService } from "../../test-utils/workspace-git-service-stub.js"; +import { + createNoGitWorkspaceRuntimeSnapshot, + createNoopWorkspaceGitService, +} from "../../test-utils/workspace-git-service-stub.js"; import { FileBackedProjectRegistry, FileBackedWorkspaceRegistry, @@ -32,6 +35,7 @@ let tmpDir: string; let gitRoots: Set; let gitBranches: Map; let checkoutFailure: Error | null; +let restoredMergedChangeRequestUrl: string | null; let workspaceRegistry: FileBackedWorkspaceRegistry; let projectRegistry: FileBackedProjectRegistry; let provisioning: WorkspaceProvisioningService; @@ -39,6 +43,25 @@ let provisioning: WorkspaceProvisioningService; function gitService() { return createNoopWorkspaceGitService({ peekSnapshot: () => null, + getSnapshot: async (cwd: string) => { + const snapshot = createNoGitWorkspaceRuntimeSnapshot(cwd); + if (!restoredMergedChangeRequestUrl) return snapshot; + return { + ...snapshot, + forge: { + ...snapshot.forge, + featuresEnabled: true, + pullRequest: { + url: restoredMergedChangeRequestUrl, + title: "Merged change request", + state: "merged", + baseRefName: "main", + headRefName: "workspace-archive-latch", + isMerged: true, + }, + }, + }; + }, getCheckout: async (cwd: string) => { if (checkoutFailure) throw checkoutFailure; let worktreeRoot: string | null = null; @@ -68,6 +91,7 @@ beforeEach(async () => { gitRoots = new Set(); gitBranches = new Map(); checkoutFailure = null; + restoredMergedChangeRequestUrl = null; workspaceRegistry = new FileBackedWorkspaceRegistry( path.join(tmpDir, "projects", "workspaces.json"), logger, @@ -337,6 +361,73 @@ test("ensureWorkspaceRecordUnarchived restores the owning archived project with expect((await projectRegistry.get(created.projectId))?.archivedAt).toBeNull(); }); +test("ensureWorkspaceRecordUnarchived preserves the consumed auto-archive change request", async () => { + const repo = path.join(tmpDir, "repo"); + const changeRequestUrl = "https://github.com/getpaseo/paseo/pull/2714"; + gitRoots.add(repo); + const created = await provisioning.findOrCreateWorkspaceForDirectory(repo); + await workspaceRegistry.archive(created.workspaceId, ARCHIVED_AT, { + autoArchivedChangeRequestUrl: changeRequestUrl, + }); + const archivedWorkspace = await workspaceRegistry.get(created.workspaceId); + + const unarchived = await provisioning.ensureWorkspaceRecordUnarchived(archivedWorkspace!); + + expect(unarchived).toMatchObject({ + archivedAt: null, + autoArchivedChangeRequestUrl: changeRequestUrl, + }); + expect(await workspaceRegistry.get(created.workspaceId)).toMatchObject({ + archivedAt: null, + autoArchivedChangeRequestUrl: changeRequestUrl, + }); +}); + +test("ensureWorkspaceRecordUnarchived acknowledges a merged change request for a legacy archive", async () => { + const repo = path.join(tmpDir, "repo"); + const changeRequestUrl = "https://github.com/getpaseo/paseo/pull/2714"; + gitRoots.add(repo); + const created = await provisioning.findOrCreateWorkspaceForDirectory(repo); + await workspaceRegistry.archive(created.workspaceId, ARCHIVED_AT); + const archivedWorkspace = await workspaceRegistry.get(created.workspaceId); + restoredMergedChangeRequestUrl = changeRequestUrl; + + const unarchived = await provisioning.ensureWorkspaceRecordUnarchived(archivedWorkspace!); + + expect(unarchived).toMatchObject({ + archivedAt: null, + autoArchivedChangeRequestUrl: changeRequestUrl, + }); + expect(await workspaceRegistry.get(created.workspaceId)).toMatchObject({ + archivedAt: null, + autoArchivedChangeRequestUrl: changeRequestUrl, + }); +}); + +test("ensureWorkspaceRecordUnarchived refreshes the latch for a different merged change request", async () => { + const repo = path.join(tmpDir, "repo"); + const previousChangeRequestUrl = "https://github.com/getpaseo/paseo/pull/2713"; + const currentChangeRequestUrl = "https://github.com/getpaseo/paseo/pull/2714"; + gitRoots.add(repo); + const created = await provisioning.findOrCreateWorkspaceForDirectory(repo); + await workspaceRegistry.archive(created.workspaceId, ARCHIVED_AT, { + autoArchivedChangeRequestUrl: previousChangeRequestUrl, + }); + const archivedWorkspace = await workspaceRegistry.get(created.workspaceId); + restoredMergedChangeRequestUrl = currentChangeRequestUrl; + + const unarchived = await provisioning.ensureWorkspaceRecordUnarchived(archivedWorkspace!); + + expect(unarchived).toMatchObject({ + archivedAt: null, + autoArchivedChangeRequestUrl: currentChangeRequestUrl, + }); + expect(await workspaceRegistry.get(created.workspaceId)).toMatchObject({ + archivedAt: null, + autoArchivedChangeRequestUrl: currentChangeRequestUrl, + }); +}); + test("does not unarchive either record when checkout refresh fails", async () => { const repo = path.join(tmpDir, "repo"); gitRoots.add(repo); diff --git a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts index 61a25b96b..90b0c7c12 100644 --- a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts +++ b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts @@ -88,7 +88,7 @@ export function createWorkspaceProvisioningService(deps: { serverId?: string; workspaceRegistry: WorkspaceRegistry; projectRegistry: ProjectRegistry; - workspaceGitService: Pick; + workspaceGitService: Pick; logger: Logger; }): WorkspaceProvisioningService { const { serverId, workspaceRegistry, projectRegistry, workspaceGitService, logger } = deps; @@ -323,6 +323,22 @@ export function createWorkspaceProvisioningService(deps: { ).workspaceId; } + async function resolveRestoredAutoArchiveChangeRequestUrl( + workspace: PersistedWorkspaceRecord, + ): Promise { + if (!workspace.archivedAt) { + return workspace.autoArchivedChangeRequestUrl; + } + const snapshot = await workspaceGitService.getSnapshot(workspace.cwd, { + force: true, + includeForge: true, + reason: "workspace-restore-auto-archive-latch", + }); + return snapshot.forge.pullRequest?.isMerged + ? snapshot.forge.pullRequest.url + : workspace.autoArchivedChangeRequestUrl; + } + async function ensureWorkspaceRecordUnarchived( workspace: PersistedWorkspaceRecord, ): Promise { @@ -333,6 +349,8 @@ export function createWorkspaceProvisioningService(deps: { workspace.archivedAt || project.archivedAt ? await workspaceGitService.getCheckout(workspace.cwd) : null; + const autoArchivedChangeRequestUrl = + await resolveRestoredAutoArchiveChangeRequestUrl(workspace); let next: PersistedWorkspaceRecord | null = null; if (workspace.archivedAt && checkout) { const placementUpdate = reconcileWorkspacePlacement({ @@ -343,6 +361,7 @@ export function createWorkspaceProvisioningService(deps: { next = { ...(placementUpdate?.workspace ?? workspace), archivedAt: null, + autoArchivedChangeRequestUrl, updatedAt: timestamp, }; } diff --git a/packages/server/src/server/workspace-archive-service.ts b/packages/server/src/server/workspace-archive-service.ts index 8b6045a44..cfdb59ffc 100644 --- a/packages/server/src/server/workspace-archive-service.ts +++ b/packages/server/src/server/workspace-archive-service.ts @@ -13,7 +13,11 @@ import { WorktreeTeardownError, } from "../utils/worktree.js"; import type { TerminalManager } from "../terminal/terminal-manager.js"; -import type { PersistedWorkspaceRecord, WorkspaceRegistry } from "./workspace-registry.js"; +import type { + PersistedWorkspaceRecord, + WorkspaceArchiveContext, + WorkspaceRegistry, +} from "./workspace-registry.js"; import { createRealpathAwarePathMatcher } from "../utils/path.js"; export type ActiveWorkspaceRef = Pick< @@ -526,6 +530,7 @@ export async function archivePersistedWorkspaceRecord(input: { workspaceId: string; workspaceRegistry: Pick; archivedAt?: string; + context?: WorkspaceArchiveContext; }): Promise { const existingWorkspace = await input.workspaceRegistry.get(input.workspaceId); if (!existingWorkspace) { @@ -537,7 +542,7 @@ export async function archivePersistedWorkspaceRecord(input: { } const archivedAt = input.archivedAt ?? new Date().toISOString(); - await input.workspaceRegistry.archive(input.workspaceId, archivedAt); + await input.workspaceRegistry.archive(input.workspaceId, archivedAt, input.context); return existingWorkspace; } diff --git a/packages/server/src/server/workspace-reconciliation-service.test.ts b/packages/server/src/server/workspace-reconciliation-service.test.ts index 8acab4b61..9abe80e74 100644 --- a/packages/server/src/server/workspace-reconciliation-service.test.ts +++ b/packages/server/src/server/workspace-reconciliation-service.test.ts @@ -630,6 +630,7 @@ describe("WorkspaceReconciliationService", () => { createdAt: timestamp, updatedAt: expect.any(String), archivedAt: expect.any(String), + autoArchivedChangeRequestUrl: null, }); expect(projects.get("p1")).toEqual(project); }); diff --git a/packages/server/src/server/workspace-registry.test.ts b/packages/server/src/server/workspace-registry.test.ts index 7c5f13ef2..75d0097b0 100644 --- a/packages/server/src/server/workspace-registry.test.ts +++ b/packages/server/src/server/workspace-registry.test.ts @@ -420,6 +420,35 @@ describe("workspace registries", () => { }); }); + test("persists the consumed change request with the workspace archive", async () => { + await workspaceRegistry.initialize(); + await workspaceRegistry.upsert( + createPersistedWorkspaceRecord({ + workspaceId: "workspace-auto-archive", + projectId: "project-one", + cwd: "/tmp/repo", + kind: "worktree", + displayName: "feature", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + }), + ); + + await workspaceRegistry.archive("workspace-auto-archive", "2026-03-02T00:00:00.000Z", { + autoArchivedChangeRequestUrl: "https://github.com/acme/repo/pull/123", + }); + + const reloaded = new FileBackedWorkspaceRegistry( + path.join(tmpDir, "projects", "workspaces.json"), + logger, + ); + await reloaded.initialize(); + expect(await reloaded.get("workspace-auto-archive")).toMatchObject({ + archivedAt: "2026-03-02T00:00:00.000Z", + autoArchivedChangeRequestUrl: "https://github.com/acme/repo/pull/123", + }); + }); + test("composes concurrent workspace field updates without losing either change", async () => { await workspaceRegistry.initialize(); await workspaceRegistry.upsert( diff --git a/packages/server/src/server/workspace-registry.ts b/packages/server/src/server/workspace-registry.ts index f122d0854..5233e77e8 100644 --- a/packages/server/src/server/workspace-registry.ts +++ b/packages/server/src/server/workspace-registry.ts @@ -80,6 +80,13 @@ const PersistedWorkspaceRecordSchema = z.object({ createdAt: z.string(), updatedAt: z.string(), archivedAt: z.string().nullable(), + // COMPAT(autoArchivedChangeRequestUrl): added in v0.2.6, remove optional parsing after 2027-01-31. + // Records the merged change request whose automatic archive was consumed. + autoArchivedChangeRequestUrl: z + .string() + .nullable() + .optional() + .transform((value) => value ?? null), pinnedAt: z .string() .nullable() @@ -101,6 +108,10 @@ export interface WorkspaceMutationContext { expectsInitialAgent?: boolean; } +export interface WorkspaceArchiveContext { + autoArchivedChangeRequestUrl?: string; +} + export interface ProjectMutation { kind: "upsert" | "archive" | "remove"; projectId: string; @@ -140,7 +151,11 @@ export interface WorkspaceRegistry { updater: (record: PersistedWorkspaceRecord) => PersistedWorkspaceRecord, ): Promise; upsert(record: PersistedWorkspaceRecord, context?: WorkspaceMutationContext): Promise; - archive(workspaceId: string, archivedAt: string): Promise; + archive( + workspaceId: string, + archivedAt: string, + context?: WorkspaceArchiveContext, + ): Promise; remove(workspaceId: string): Promise; /** Central lifecycle seam for daemon-global workspace observers. */ subscribeToMutations?( @@ -471,8 +486,19 @@ export class FileBackedWorkspaceRegistry }); } - override async archive(workspaceId: string, archivedAt: string): Promise { - const workspace = await this.archiveIfPresent(workspaceId, archivedAt); + override async archive( + workspaceId: string, + archivedAt: string, + context?: WorkspaceArchiveContext, + ): Promise { + const workspace = await super.update(workspaceId, (existing) => ({ + ...existing, + updatedAt: archivedAt, + archivedAt, + ...(context?.autoArchivedChangeRequestUrl + ? { autoArchivedChangeRequestUrl: context.autoArchivedChangeRequestUrl } + : {}), + })); if (!workspace) return; await this.notifyMutation({ kind: "archive", workspaceId, workspace }); } @@ -528,6 +554,7 @@ export function createPersistedWorkspaceRecord(input: { createdAt: string; updatedAt: string; archivedAt?: string | null; + autoArchivedChangeRequestUrl?: string | null; pinnedAt?: string | null; }): PersistedWorkspaceRecord { return PersistedWorkspaceRecordSchema.parse({ @@ -539,6 +566,7 @@ export function createPersistedWorkspaceRecord(input: { isPaseoOwnedWorktree: input.isPaseoOwnedWorktree ?? false, mainRepoRoot: input.mainRepoRoot ?? null, archivedAt: input.archivedAt ?? null, + autoArchivedChangeRequestUrl: input.autoArchivedChangeRequestUrl ?? null, pinnedAt: input.pinnedAt ?? null, }); }