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
This commit is contained in:
+18
-17
@@ -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_<hex>`), 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_<hex>`), 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.
|
||||
|
||||
|
||||
@@ -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<void>;
|
||||
refreshMergedPullRequest(): Promise<void>;
|
||||
expectWorkspaceRemainsActive(): Promise<void>;
|
||||
cleanup(): Promise<void>;
|
||||
}
|
||||
|
||||
async function createMergedPullRequestScenario(): Promise<MergedPullRequestScenario> {
|
||||
const cleanups: Array<() => Promise<unknown>> = [];
|
||||
|
||||
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<unknown>>): Promise<void> {
|
||||
while (cleanups.length > 0) {
|
||||
await cleanups
|
||||
.pop()?.()
|
||||
.catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function openArchivedWorkspaceFromHistory(page: Page, agentTitle: string): Promise<void> {
|
||||
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<void> {
|
||||
const action = page.getByTestId("workspace-recovery-action");
|
||||
await expect(action).toHaveText("Restore");
|
||||
await action.click();
|
||||
await waitForWorkspaceInSidebar(page, { serverId: getServerId(), workspaceId });
|
||||
}
|
||||
@@ -79,6 +79,7 @@ function createLogger(): Logger {
|
||||
|
||||
function createHarness(overrides?: {
|
||||
autoArchiveAfterMerge?: boolean;
|
||||
autoArchivedChangeRequestUrl?: string | null;
|
||||
getSnapshot?: () => Promise<WorkspaceGitRuntimeSnapshot | null>;
|
||||
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",
|
||||
|
||||
@@ -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<string | null>;
|
||||
listActiveWorkspaces: () => Promise<ActiveWorkspaceRef[]>;
|
||||
archiveWorkspaceRecord: (workspaceId: string) => Promise<void>;
|
||||
getAutoArchivedChangeRequestUrl: (workspaceId: string) => Promise<string | null>;
|
||||
archiveWorkspaceRecord: (workspaceId: string, context?: WorkspaceArchiveContext) => Promise<void>;
|
||||
markWorkspaceArchiving: (workspaceIds: Iterable<string>, archivingAt: string) => void;
|
||||
clearWorkspaceArchiving: (workspaceIds: Iterable<string>) => void;
|
||||
emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds: Iterable<string>) => Promise<void>;
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
+92
-1
@@ -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<string>;
|
||||
let gitBranches: Map<string, string | null>;
|
||||
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);
|
||||
|
||||
+20
-1
@@ -88,7 +88,7 @@ export function createWorkspaceProvisioningService(deps: {
|
||||
serverId?: string;
|
||||
workspaceRegistry: WorkspaceRegistry;
|
||||
projectRegistry: ProjectRegistry;
|
||||
workspaceGitService: Pick<WorkspaceGitService, "getCheckout" | "peekSnapshot">;
|
||||
workspaceGitService: Pick<WorkspaceGitService, "getCheckout" | "getSnapshot" | "peekSnapshot">;
|
||||
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<string | null> {
|
||||
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<PersistedWorkspaceRecord> {
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<WorkspaceRegistry, "get" | "archive">;
|
||||
archivedAt?: string;
|
||||
context?: WorkspaceArchiveContext;
|
||||
}): Promise<PersistedWorkspaceRecord | null> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -630,6 +630,7 @@ describe("WorkspaceReconciliationService", () => {
|
||||
createdAt: timestamp,
|
||||
updatedAt: expect.any(String),
|
||||
archivedAt: expect.any(String),
|
||||
autoArchivedChangeRequestUrl: null,
|
||||
});
|
||||
expect(projects.get("p1")).toEqual(project);
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<PersistedWorkspaceRecord | null>;
|
||||
upsert(record: PersistedWorkspaceRecord, context?: WorkspaceMutationContext): Promise<void>;
|
||||
archive(workspaceId: string, archivedAt: string): Promise<void>;
|
||||
archive(
|
||||
workspaceId: string,
|
||||
archivedAt: string,
|
||||
context?: WorkspaceArchiveContext,
|
||||
): Promise<void>;
|
||||
remove(workspaceId: string): Promise<void>;
|
||||
/** Central lifecycle seam for daemon-global workspace observers. */
|
||||
subscribeToMutations?(
|
||||
@@ -471,8 +486,19 @@ export class FileBackedWorkspaceRegistry
|
||||
});
|
||||
}
|
||||
|
||||
override async archive(workspaceId: string, archivedAt: string): Promise<void> {
|
||||
const workspace = await this.archiveIfPresent(workspaceId, archivedAt);
|
||||
override async archive(
|
||||
workspaceId: string,
|
||||
archivedAt: string,
|
||||
context?: WorkspaceArchiveContext,
|
||||
): Promise<void> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user