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.
|
Array of workspace records. A workspace is a specific working directory within a project.
|
||||||
|
|
||||||
| Field | Type | Description |
|
| 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. |
|
| `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 |
|
| `projectId` | `string` | FK to Project.projectId; the workspace's stable project membership |
|
||||||
| `cwd` | `string` | Exact execution directory selected for agents, files, scripts, and setup |
|
| `cwd` | `string` | Exact execution directory selected for agents, files, scripts, and setup |
|
||||||
| `kind` | `"local_checkout" \| "worktree" \| "directory"` | Mutable checkout classification |
|
| `kind` | `"local_checkout" \| "worktree" \| "directory"` | Mutable checkout classification |
|
||||||
| `displayName` | `string` | The human name (the generated/derived title). Decoupled from `branch` by construction. |
|
| `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`". |
|
| `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. |
|
| `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. |
|
| `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 |
|
| `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` |
|
| `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` |
|
| `mainRepoRoot` | `string \| null` | Main repository root for worktree checkouts, independent of both exact `cwd` and backing `worktreeRoot` |
|
||||||
| `createdAt` | `string` (ISO 8601) | |
|
| `createdAt` | `string` (ISO 8601) | |
|
||||||
| `updatedAt` | `string` (ISO 8601) | |
|
| `updatedAt` | `string` (ISO 8601) | |
|
||||||
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete; required nullable |
|
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete; required nullable |
|
||||||
| `pinnedAt` | `string \| null` (ISO 8601) | Pinned-to-top-of-sidebar timestamp; null means "not pinned" |
|
| `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.
|
> **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?: {
|
function createHarness(overrides?: {
|
||||||
autoArchiveAfterMerge?: boolean;
|
autoArchiveAfterMerge?: boolean;
|
||||||
|
autoArchivedChangeRequestUrl?: string | null;
|
||||||
getSnapshot?: () => Promise<WorkspaceGitRuntimeSnapshot | null>;
|
getSnapshot?: () => Promise<WorkspaceGitRuntimeSnapshot | null>;
|
||||||
isPaseoOwnedWorktreeCwd?: ArchiveIfSafeDependencies["isPaseoOwnedWorktreeCwd"];
|
isPaseoOwnedWorktreeCwd?: ArchiveIfSafeDependencies["isPaseoOwnedWorktreeCwd"];
|
||||||
archiveByScope?: ArchiveIfSafeDependencies["archiveByScope"];
|
archiveByScope?: ArchiveIfSafeDependencies["archiveByScope"];
|
||||||
@@ -105,6 +106,9 @@ function createHarness(overrides?: {
|
|||||||
terminalManager: {} as AutoArchiveArchiveOptions["terminalManager"],
|
terminalManager: {} as AutoArchiveArchiveOptions["terminalManager"],
|
||||||
findWorkspaceIdForCwd: vi.fn(async () => "ws-auto-archive"),
|
findWorkspaceIdForCwd: vi.fn(async () => "ws-auto-archive"),
|
||||||
listActiveWorkspaces: vi.fn(async () => []),
|
listActiveWorkspaces: vi.fn(async () => []),
|
||||||
|
getAutoArchivedChangeRequestUrl: vi.fn(
|
||||||
|
async () => overrides?.autoArchivedChangeRequestUrl ?? null,
|
||||||
|
),
|
||||||
archiveWorkspaceRecord: vi.fn(),
|
archiveWorkspaceRecord: vi.fn(),
|
||||||
markWorkspaceArchiving: vi.fn(),
|
markWorkspaceArchiving: vi.fn(),
|
||||||
clearWorkspaceArchiving: vi.fn(),
|
clearWorkspaceArchiving: vi.fn(),
|
||||||
@@ -316,6 +320,7 @@ function createRealOutcomeHarness(input: {
|
|||||||
},
|
},
|
||||||
listActiveWorkspaces: async () =>
|
listActiveWorkspaces: async () =>
|
||||||
active.filter((workspace) => !input.archivedWorkspaceIds.has(workspace.workspaceId)),
|
active.filter((workspace) => !input.archivedWorkspaceIds.has(workspace.workspaceId)),
|
||||||
|
getAutoArchivedChangeRequestUrl: async () => null,
|
||||||
archiveWorkspaceRecord: async (workspaceId: string) => {
|
archiveWorkspaceRecord: async (workspaceId: string) => {
|
||||||
input.archivedWorkspaceIds.add(workspaceId);
|
input.archivedWorkspaceIds.add(workspaceId);
|
||||||
const index = active.findIndex((workspace) => workspace.workspaceId === workspaceId);
|
const index = active.findIndex((workspace) => workspace.workspaceId === workspaceId);
|
||||||
@@ -496,6 +501,45 @@ describe("archiveIfSafe", () => {
|
|||||||
expect(harness.inFlight.has(CWD)).toBe(false);
|
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 () => {
|
test("resolves the merged cwd to a single workspace and does not iterate siblings", async () => {
|
||||||
const harness = createHarness({
|
const harness = createHarness({
|
||||||
resolveWorkspaceIdAtPath: async () => "ws-merged-worktree",
|
resolveWorkspaceIdAtPath: async () => "ws-merged-worktree",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import type {
|
|||||||
import type { ForgeService } from "../../services/forge-service.js";
|
import type { ForgeService } from "../../services/forge-service.js";
|
||||||
import type { TerminalManager } from "../../terminal/terminal-manager.js";
|
import type { TerminalManager } from "../../terminal/terminal-manager.js";
|
||||||
import { isPaseoOwnedWorktreeCwd } from "../../utils/worktree.js";
|
import { isPaseoOwnedWorktreeCwd } from "../../utils/worktree.js";
|
||||||
|
import type { WorkspaceArchiveContext } from "../workspace-registry.js";
|
||||||
|
|
||||||
export interface AutoArchiveArchiveOptions {
|
export interface AutoArchiveArchiveOptions {
|
||||||
paseoHome: string;
|
paseoHome: string;
|
||||||
@@ -28,7 +29,8 @@ export interface AutoArchiveArchiveOptions {
|
|||||||
terminalManager: TerminalManager;
|
terminalManager: TerminalManager;
|
||||||
findWorkspaceIdForCwd: (cwd: string) => Promise<string | null>;
|
findWorkspaceIdForCwd: (cwd: string) => Promise<string | null>;
|
||||||
listActiveWorkspaces: () => Promise<ActiveWorkspaceRef[]>;
|
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;
|
markWorkspaceArchiving: (workspaceIds: Iterable<string>, archivingAt: string) => void;
|
||||||
clearWorkspaceArchiving: (workspaceIds: Iterable<string>) => void;
|
clearWorkspaceArchiving: (workspaceIds: Iterable<string>) => void;
|
||||||
emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds: Iterable<string>) => Promise<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");
|
log.warn({ cwd }, "Auto-archive could not resolve a workspace for cwd; skipping");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const autoArchivedChangeRequestUrl =
|
||||||
|
await options.getAutoArchivedChangeRequestUrl(workspaceId);
|
||||||
|
if (autoArchivedChangeRequestUrl === pullRequest.url) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await deps.archiveByScope(
|
await deps.archiveByScope(
|
||||||
{
|
{
|
||||||
@@ -122,7 +129,10 @@ export async function archiveIfSafe(input: {
|
|||||||
agentStorage: options.agentStorage,
|
agentStorage: options.agentStorage,
|
||||||
findWorkspaceIdForCwd: options.findWorkspaceIdForCwd,
|
findWorkspaceIdForCwd: options.findWorkspaceIdForCwd,
|
||||||
listActiveWorkspaces: options.listActiveWorkspaces,
|
listActiveWorkspaces: options.listActiveWorkspaces,
|
||||||
archiveWorkspaceRecord: options.archiveWorkspaceRecord,
|
archiveWorkspaceRecord: (workspaceIdToArchive) =>
|
||||||
|
options.archiveWorkspaceRecord(workspaceIdToArchive, {
|
||||||
|
autoArchivedChangeRequestUrl: pullRequest.url,
|
||||||
|
}),
|
||||||
emitWorkspaceUpdatesForWorkspaceIds: options.emitWorkspaceUpdatesForWorkspaceIds,
|
emitWorkspaceUpdatesForWorkspaceIds: options.emitWorkspaceUpdatesForWorkspaceIds,
|
||||||
markWorkspaceArchiving: options.markWorkspaceArchiving,
|
markWorkspaceArchiving: options.markWorkspaceArchiving,
|
||||||
clearWorkspaceArchiving: options.clearWorkspaceArchiving,
|
clearWorkspaceArchiving: options.clearWorkspaceArchiving,
|
||||||
|
|||||||
@@ -138,7 +138,11 @@ import type { PaseoToolRuntimeContext } from "./agent/tools/types.js";
|
|||||||
import { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js";
|
import { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js";
|
||||||
import { bootstrapWorkspaceRegistries } from "./workspace-registry-bootstrap.js";
|
import { bootstrapWorkspaceRegistries } from "./workspace-registry-bootstrap.js";
|
||||||
import { WorkspaceReconciliationService } from "./workspace-reconciliation-service.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 { FileBackedChatService } from "./chat/chat-service.js";
|
||||||
import { CheckoutDiffManager } from "./checkout-diff-manager.js";
|
import { CheckoutDiffManager } from "./checkout-diff-manager.js";
|
||||||
import { LoopService } from "./loop-service.js";
|
import { LoopService } from "./loop-service.js";
|
||||||
@@ -876,10 +880,14 @@ export async function createPaseoDaemon(
|
|||||||
paseoHome: config.paseoHome,
|
paseoHome: config.paseoHome,
|
||||||
workspaceGitService,
|
workspaceGitService,
|
||||||
});
|
});
|
||||||
const archiveWorkspaceRecordExternal = async (workspaceId: string) => {
|
const archiveWorkspaceRecordExternal = async (
|
||||||
|
workspaceId: string,
|
||||||
|
context?: WorkspaceArchiveContext,
|
||||||
|
) => {
|
||||||
const existingWorkspace = await archivePersistedWorkspaceRecord({
|
const existingWorkspace = await archivePersistedWorkspaceRecord({
|
||||||
workspaceId,
|
workspaceId,
|
||||||
workspaceRegistry,
|
workspaceRegistry,
|
||||||
|
context,
|
||||||
});
|
});
|
||||||
if (!existingWorkspace || existingWorkspace.archivedAt) return;
|
if (!existingWorkspace || existingWorkspace.archivedAt) return;
|
||||||
teardownArchivedWorkspaceRuntime(workspaceId);
|
teardownArchivedWorkspaceRuntime(workspaceId);
|
||||||
@@ -983,6 +991,8 @@ export async function createPaseoDaemon(
|
|||||||
logger,
|
logger,
|
||||||
findWorkspaceIdForCwd: findWorkspaceIdForCwdExternal,
|
findWorkspaceIdForCwd: findWorkspaceIdForCwdExternal,
|
||||||
listActiveWorkspaces: listActiveWorkspacesExternal,
|
listActiveWorkspaces: listActiveWorkspacesExternal,
|
||||||
|
getAutoArchivedChangeRequestUrl: async (workspaceId) =>
|
||||||
|
(await workspaceRegistry.get(workspaceId))?.autoArchivedChangeRequestUrl ?? null,
|
||||||
archiveWorkspaceRecord: archiveWorkspaceRecordExternal,
|
archiveWorkspaceRecord: archiveWorkspaceRecordExternal,
|
||||||
markWorkspaceArchiving: markWorkspaceArchivingExternal,
|
markWorkspaceArchiving: markWorkspaceArchivingExternal,
|
||||||
clearWorkspaceArchiving: clearWorkspaceArchivingExternal,
|
clearWorkspaceArchiving: clearWorkspaceArchivingExternal,
|
||||||
|
|||||||
+92
-1
@@ -5,7 +5,10 @@ import { mkdirSync, mkdtempSync, rmSync, symlinkSync } from "node:fs";
|
|||||||
import { afterEach, beforeEach, expect, test } from "vitest";
|
import { afterEach, beforeEach, expect, test } from "vitest";
|
||||||
|
|
||||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
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 {
|
import {
|
||||||
FileBackedProjectRegistry,
|
FileBackedProjectRegistry,
|
||||||
FileBackedWorkspaceRegistry,
|
FileBackedWorkspaceRegistry,
|
||||||
@@ -32,6 +35,7 @@ let tmpDir: string;
|
|||||||
let gitRoots: Set<string>;
|
let gitRoots: Set<string>;
|
||||||
let gitBranches: Map<string, string | null>;
|
let gitBranches: Map<string, string | null>;
|
||||||
let checkoutFailure: Error | null;
|
let checkoutFailure: Error | null;
|
||||||
|
let restoredMergedChangeRequestUrl: string | null;
|
||||||
let workspaceRegistry: FileBackedWorkspaceRegistry;
|
let workspaceRegistry: FileBackedWorkspaceRegistry;
|
||||||
let projectRegistry: FileBackedProjectRegistry;
|
let projectRegistry: FileBackedProjectRegistry;
|
||||||
let provisioning: WorkspaceProvisioningService;
|
let provisioning: WorkspaceProvisioningService;
|
||||||
@@ -39,6 +43,25 @@ let provisioning: WorkspaceProvisioningService;
|
|||||||
function gitService() {
|
function gitService() {
|
||||||
return createNoopWorkspaceGitService({
|
return createNoopWorkspaceGitService({
|
||||||
peekSnapshot: () => null,
|
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) => {
|
getCheckout: async (cwd: string) => {
|
||||||
if (checkoutFailure) throw checkoutFailure;
|
if (checkoutFailure) throw checkoutFailure;
|
||||||
let worktreeRoot: string | null = null;
|
let worktreeRoot: string | null = null;
|
||||||
@@ -68,6 +91,7 @@ beforeEach(async () => {
|
|||||||
gitRoots = new Set();
|
gitRoots = new Set();
|
||||||
gitBranches = new Map();
|
gitBranches = new Map();
|
||||||
checkoutFailure = null;
|
checkoutFailure = null;
|
||||||
|
restoredMergedChangeRequestUrl = null;
|
||||||
workspaceRegistry = new FileBackedWorkspaceRegistry(
|
workspaceRegistry = new FileBackedWorkspaceRegistry(
|
||||||
path.join(tmpDir, "projects", "workspaces.json"),
|
path.join(tmpDir, "projects", "workspaces.json"),
|
||||||
logger,
|
logger,
|
||||||
@@ -337,6 +361,73 @@ test("ensureWorkspaceRecordUnarchived restores the owning archived project with
|
|||||||
expect((await projectRegistry.get(created.projectId))?.archivedAt).toBeNull();
|
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 () => {
|
test("does not unarchive either record when checkout refresh fails", async () => {
|
||||||
const repo = path.join(tmpDir, "repo");
|
const repo = path.join(tmpDir, "repo");
|
||||||
gitRoots.add(repo);
|
gitRoots.add(repo);
|
||||||
|
|||||||
+20
-1
@@ -88,7 +88,7 @@ export function createWorkspaceProvisioningService(deps: {
|
|||||||
serverId?: string;
|
serverId?: string;
|
||||||
workspaceRegistry: WorkspaceRegistry;
|
workspaceRegistry: WorkspaceRegistry;
|
||||||
projectRegistry: ProjectRegistry;
|
projectRegistry: ProjectRegistry;
|
||||||
workspaceGitService: Pick<WorkspaceGitService, "getCheckout" | "peekSnapshot">;
|
workspaceGitService: Pick<WorkspaceGitService, "getCheckout" | "getSnapshot" | "peekSnapshot">;
|
||||||
logger: Logger;
|
logger: Logger;
|
||||||
}): WorkspaceProvisioningService {
|
}): WorkspaceProvisioningService {
|
||||||
const { serverId, workspaceRegistry, projectRegistry, workspaceGitService, logger } = deps;
|
const { serverId, workspaceRegistry, projectRegistry, workspaceGitService, logger } = deps;
|
||||||
@@ -323,6 +323,22 @@ export function createWorkspaceProvisioningService(deps: {
|
|||||||
).workspaceId;
|
).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(
|
async function ensureWorkspaceRecordUnarchived(
|
||||||
workspace: PersistedWorkspaceRecord,
|
workspace: PersistedWorkspaceRecord,
|
||||||
): Promise<PersistedWorkspaceRecord> {
|
): Promise<PersistedWorkspaceRecord> {
|
||||||
@@ -333,6 +349,8 @@ export function createWorkspaceProvisioningService(deps: {
|
|||||||
workspace.archivedAt || project.archivedAt
|
workspace.archivedAt || project.archivedAt
|
||||||
? await workspaceGitService.getCheckout(workspace.cwd)
|
? await workspaceGitService.getCheckout(workspace.cwd)
|
||||||
: null;
|
: null;
|
||||||
|
const autoArchivedChangeRequestUrl =
|
||||||
|
await resolveRestoredAutoArchiveChangeRequestUrl(workspace);
|
||||||
let next: PersistedWorkspaceRecord | null = null;
|
let next: PersistedWorkspaceRecord | null = null;
|
||||||
if (workspace.archivedAt && checkout) {
|
if (workspace.archivedAt && checkout) {
|
||||||
const placementUpdate = reconcileWorkspacePlacement({
|
const placementUpdate = reconcileWorkspacePlacement({
|
||||||
@@ -343,6 +361,7 @@ export function createWorkspaceProvisioningService(deps: {
|
|||||||
next = {
|
next = {
|
||||||
...(placementUpdate?.workspace ?? workspace),
|
...(placementUpdate?.workspace ?? workspace),
|
||||||
archivedAt: null,
|
archivedAt: null,
|
||||||
|
autoArchivedChangeRequestUrl,
|
||||||
updatedAt: timestamp,
|
updatedAt: timestamp,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,11 @@ import {
|
|||||||
WorktreeTeardownError,
|
WorktreeTeardownError,
|
||||||
} from "../utils/worktree.js";
|
} from "../utils/worktree.js";
|
||||||
import type { TerminalManager } from "../terminal/terminal-manager.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";
|
import { createRealpathAwarePathMatcher } from "../utils/path.js";
|
||||||
|
|
||||||
export type ActiveWorkspaceRef = Pick<
|
export type ActiveWorkspaceRef = Pick<
|
||||||
@@ -526,6 +530,7 @@ export async function archivePersistedWorkspaceRecord(input: {
|
|||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
workspaceRegistry: Pick<WorkspaceRegistry, "get" | "archive">;
|
workspaceRegistry: Pick<WorkspaceRegistry, "get" | "archive">;
|
||||||
archivedAt?: string;
|
archivedAt?: string;
|
||||||
|
context?: WorkspaceArchiveContext;
|
||||||
}): Promise<PersistedWorkspaceRecord | null> {
|
}): Promise<PersistedWorkspaceRecord | null> {
|
||||||
const existingWorkspace = await input.workspaceRegistry.get(input.workspaceId);
|
const existingWorkspace = await input.workspaceRegistry.get(input.workspaceId);
|
||||||
if (!existingWorkspace) {
|
if (!existingWorkspace) {
|
||||||
@@ -537,7 +542,7 @@ export async function archivePersistedWorkspaceRecord(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const archivedAt = input.archivedAt ?? new Date().toISOString();
|
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;
|
return existingWorkspace;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -630,6 +630,7 @@ describe("WorkspaceReconciliationService", () => {
|
|||||||
createdAt: timestamp,
|
createdAt: timestamp,
|
||||||
updatedAt: expect.any(String),
|
updatedAt: expect.any(String),
|
||||||
archivedAt: expect.any(String),
|
archivedAt: expect.any(String),
|
||||||
|
autoArchivedChangeRequestUrl: null,
|
||||||
});
|
});
|
||||||
expect(projects.get("p1")).toEqual(project);
|
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 () => {
|
test("composes concurrent workspace field updates without losing either change", async () => {
|
||||||
await workspaceRegistry.initialize();
|
await workspaceRegistry.initialize();
|
||||||
await workspaceRegistry.upsert(
|
await workspaceRegistry.upsert(
|
||||||
|
|||||||
@@ -80,6 +80,13 @@ const PersistedWorkspaceRecordSchema = z.object({
|
|||||||
createdAt: z.string(),
|
createdAt: z.string(),
|
||||||
updatedAt: z.string(),
|
updatedAt: z.string(),
|
||||||
archivedAt: z.string().nullable(),
|
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
|
pinnedAt: z
|
||||||
.string()
|
.string()
|
||||||
.nullable()
|
.nullable()
|
||||||
@@ -101,6 +108,10 @@ export interface WorkspaceMutationContext {
|
|||||||
expectsInitialAgent?: boolean;
|
expectsInitialAgent?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WorkspaceArchiveContext {
|
||||||
|
autoArchivedChangeRequestUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ProjectMutation {
|
export interface ProjectMutation {
|
||||||
kind: "upsert" | "archive" | "remove";
|
kind: "upsert" | "archive" | "remove";
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@@ -140,7 +151,11 @@ export interface WorkspaceRegistry {
|
|||||||
updater: (record: PersistedWorkspaceRecord) => PersistedWorkspaceRecord,
|
updater: (record: PersistedWorkspaceRecord) => PersistedWorkspaceRecord,
|
||||||
): Promise<PersistedWorkspaceRecord | null>;
|
): Promise<PersistedWorkspaceRecord | null>;
|
||||||
upsert(record: PersistedWorkspaceRecord, context?: WorkspaceMutationContext): Promise<void>;
|
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>;
|
remove(workspaceId: string): Promise<void>;
|
||||||
/** Central lifecycle seam for daemon-global workspace observers. */
|
/** Central lifecycle seam for daemon-global workspace observers. */
|
||||||
subscribeToMutations?(
|
subscribeToMutations?(
|
||||||
@@ -471,8 +486,19 @@ export class FileBackedWorkspaceRegistry
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
override async archive(workspaceId: string, archivedAt: string): Promise<void> {
|
override async archive(
|
||||||
const workspace = await this.archiveIfPresent(workspaceId, archivedAt);
|
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;
|
if (!workspace) return;
|
||||||
await this.notifyMutation({ kind: "archive", workspaceId, workspace });
|
await this.notifyMutation({ kind: "archive", workspaceId, workspace });
|
||||||
}
|
}
|
||||||
@@ -528,6 +554,7 @@ export function createPersistedWorkspaceRecord(input: {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
archivedAt?: string | null;
|
archivedAt?: string | null;
|
||||||
|
autoArchivedChangeRequestUrl?: string | null;
|
||||||
pinnedAt?: string | null;
|
pinnedAt?: string | null;
|
||||||
}): PersistedWorkspaceRecord {
|
}): PersistedWorkspaceRecord {
|
||||||
return PersistedWorkspaceRecordSchema.parse({
|
return PersistedWorkspaceRecordSchema.parse({
|
||||||
@@ -539,6 +566,7 @@ export function createPersistedWorkspaceRecord(input: {
|
|||||||
isPaseoOwnedWorktree: input.isPaseoOwnedWorktree ?? false,
|
isPaseoOwnedWorktree: input.isPaseoOwnedWorktree ?? false,
|
||||||
mainRepoRoot: input.mainRepoRoot ?? null,
|
mainRepoRoot: input.mainRepoRoot ?? null,
|
||||||
archivedAt: input.archivedAt ?? null,
|
archivedAt: input.archivedAt ?? null,
|
||||||
|
autoArchivedChangeRequestUrl: input.autoArchivedChangeRequestUrl ?? null,
|
||||||
pinnedAt: input.pinnedAt ?? null,
|
pinnedAt: input.pinnedAt ?? null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user