Improve the archived workspace restore flow (#2002)

* fix(app): restore missing workspaces from History

Workspace and agent archival are separate lifecycles. Carry restore intent from History so closed agents can reopen archived workspaces without changing ordinary navigation.

* fix(app): wait for workspace hydration before restore

* fix(app): preserve History agent unarchive

* fix(app): reopen archived History agents outside active directory

History entries are not merged into the active session maps. Carry the row archive state through explicit restore intent so live workspaces still reopen without broadening ordinary navigation.

* fix(app): ignore stale History archive state for active agents

A cached History row can outlive a successful reopen. When the workspace exists, live session state now wins so an active agent is not interrupted by another refresh.

* fix(app): reopen every selected archived History agent

History entries without workspace IDs returned before restoration, and a second archived agent sharing an in-flight workspace restore was dropped. Preserve both agent-only and deferred reopen intent.

* fix(app): require explicit workspace recovery

* fix: preserve workspace recovery contracts

* fix: preserve workspace recovery compatibility

* fix: preserve recovered workspace session state

* fix: preserve terminal navigation timing

* fix(server): preserve successful workspace recovery

* fix: preserve workspace recovery intent
This commit is contained in:
Mohamed Boudra
2026-07-16 17:22:37 +02:00
committed by GitHub
parent d42ab91971
commit 5da6548aff
47 changed files with 1769 additions and 823 deletions

View File

@@ -52,6 +52,12 @@ Archiving runs through `AgentManager.archiveAgent` (`packages/server/src/server/
Cascade is what keeps subagent fleets from outliving their orchestrator. Cascade is what keeps subagent fleets from outliving their orchestrator.
Workspace archive is a separate lifecycle. Archiving or removing a worktree can close a surviving
agent record without setting the agent's `archivedAt`, while its `workspaceId` still points at the
archived workspace. History navigation must not infer workspace lifecycle from `agent.archivedAt`
or mutate either lifecycle. The workspace route asks the daemon for authoritative recovery state;
only the route's explicit Unarchive or Restore action changes the archived workspace.
## Tabs vs archive ## Tabs vs archive
These are two distinct concepts that used to be conflated: These are two distinct concepts that used to be conflated:

View File

@@ -15,7 +15,6 @@ import {
expectWorkspaceArchiveOutcome, expectWorkspaceArchiveOutcome,
expectWorkspaceTabHidden, expectWorkspaceTabHidden,
fetchAgentArchivedAt, fetchAgentArchivedAt,
expectWorkspaceTabVisible,
openSessions, openSessions,
openWorkspaceWithAgents, openWorkspaceWithAgents,
primeAdditionalPage, primeAdditionalPage,
@@ -123,7 +122,7 @@ test.describe("Archive tab reconciliation", () => {
} }
}); });
test("clicking an archived session unarchives it and opens the agent", async ({ page }) => { test("clicking an archived session navigates without unarchiving it", async ({ page }) => {
const archived = await createIdleAgent(client, { const archived = await createIdleAgent(client, {
cwd: tempRepo.path, cwd: tempRepo.path,
workspaceId, workspaceId,
@@ -138,18 +137,17 @@ test.describe("Archive tab reconciliation", () => {
await resetSeededPageState(page); await resetSeededPageState(page);
await openWorkspaceWithAgents(page, [archived, surviving]); await openWorkspaceWithAgents(page, [archived, surviving]);
await archiveAgentFromDaemon(client, archived.id); await archiveAgentFromDaemon(client, archived.id);
const archivedAt = await fetchAgentArchivedAt(client, archived.id);
expect(archivedAt).not.toBeNull();
await openSessions(page); await openSessions(page);
await expectSessionRowArchived(page, archived.title); await expectSessionRowArchived(page, archived.title);
await clickSessionRow(page, archived.title); await clickSessionRow(page, archived.title);
await expect expect(await fetchAgentArchivedAt(client, archived.id)).toBe(archivedAt);
.poll(() => fetchAgentArchivedAt(client, archived.id), { timeout: 30_000 })
.toBeNull();
await expect(page).toHaveURL(buildHostWorkspaceRoute(getServerId(), archived.workspaceId), { await expect(page).toHaveURL(buildHostWorkspaceRoute(getServerId(), archived.workspaceId), {
timeout: 30_000, timeout: 30_000,
}); });
await expectWorkspaceTabVisible(page, archived.id);
}); });
}); });

View File

@@ -103,12 +103,6 @@ export async function fetchAgentArchivedAt(
return result?.agent.archivedAt ?? null; return result?.agent.archivedAt ?? null;
} }
export function getWorktreeRestoreFeature(client: {
getLastServerInfoMessage(): { features?: { worktreeRestore?: boolean } | null } | null;
}): boolean {
return client.getLastServerInfoMessage()?.features?.worktreeRestore === true;
}
export async function primeAdditionalPage(page: Page): Promise<void> { export async function primeAdditionalPage(page: Page): Promise<void> {
const seedNonce = randomUUID(); const seedNonce = randomUUID();
const { daemon, preferences } = buildSeededStoragePayload(); const { daemon, preferences } = buildSeededStoragePayload();

View File

@@ -17,6 +17,8 @@ type NewWorkspaceDaemonClient = Pick<
| "fetchWorkspaces" | "fetchWorkspaces"
| "getPaseoWorktreeList" | "getPaseoWorktreeList"
| "getDaemonConfig" | "getDaemonConfig"
| "inspectWorkspaceRecovery"
| "on"
| "patchDaemonConfig" | "patchDaemonConfig"
| "removeProject" | "removeProject"
>; >;

View File

@@ -156,7 +156,7 @@ export async function installFakeScheduleHost(input: {
workspaceMultiplicity: true, workspaceMultiplicity: true,
projectAdd: true, projectAdd: true,
projectRemove: true, projectRemove: true,
worktreeRestore: true, workspaceRecovery: true,
}, },
}), }),
); );

View File

@@ -132,11 +132,15 @@ export interface SeedDaemonClient {
timeout?: number, timeout?: number,
): Promise<{ status: string; final?: { lastError?: string | null } | null }>; ): Promise<{ status: string; final?: { lastError?: string | null } | null }>;
archiveAgent(agentId: string): Promise<{ archivedAt: string }>; archiveAgent(agentId: string): Promise<{ archivedAt: string }>;
refreshAgent(agentId: string): Promise<unknown>;
fetchAgent(options: { fetchAgent(options: {
agentId: string; agentId: string;
}): Promise<{ agent: { id: string; archivedAt?: string | null } } | null>; }): Promise<{ agent: { id: string; archivedAt?: string | null } } | null>;
getLastServerInfoMessage(): { getLastServerInfoMessage(): {
features?: { projectAdd?: boolean; worktreeRestore?: boolean } | null; features?: {
projectAdd?: boolean;
workspaceRecovery?: boolean;
} | null;
} | null; } | null;
fetchAgentHistory(options?: { fetchAgentHistory(options?: {
page?: { limit: number }; page?: { limit: number };

View File

@@ -78,13 +78,8 @@ export async function navigateToTerminal(
{ timeout: 30_000 }, { timeout: 30_000 },
); );
// Wait for daemon connection (sidebar shows host label)
await page
.getByText("localhost", { exact: true })
.first()
.waitFor({ state: "visible", timeout: 30_000 });
// The open intent should have prepared and focused the exact pre-created terminal tab. // The open intent should have prepared and focused the exact pre-created terminal tab.
// Its presence is the user-visible proof that workspace and terminal state have hydrated.
// The tab reconciliation effect also auto-creates terminal tabs once hydration completes, // The tab reconciliation effect also auto-creates terminal tabs once hydration completes,
// so we give it enough time for the full workspace hydration + tab creation cycle. // so we give it enough time for the full workspace hydration + tab creation cycle.
const terminalTab = page.locator(`[data-testid="workspace-tab-terminal_${input.terminalId}"]`); const terminalTab = page.locator(`[data-testid="workspace-tab-terminal_${input.terminalId}"]`);

View File

@@ -1,16 +1,25 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { execFileSync } from "node:child_process";
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import { rename } from "node:fs/promises";
import type { Page } from "@playwright/test";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { expect, test } from "./fixtures"; import { expect, test } from "./fixtures";
import { gotoAppShell } from "./helpers/app"; import { gotoAppShell } from "./helpers/app";
import { import {
archiveAgentFromDaemon, expectWorkspaceBranch,
openChangesPanel,
switchBranchFromChangesPanel,
} from "./helpers/branch-switcher";
import {
createIdleAgent, createIdleAgent,
expectSessionRowArchived, expectSessionRowNotArchived,
fetchAgentArchivedAt, fetchAgentArchivedAt,
openSessions, openSessions,
} from "./helpers/archive-tab"; } from "./helpers/archive-tab";
import { import {
archiveWorkspaceFromDaemon, archiveWorkspaceFromDaemon,
archiveLocalWorkspaceFromDaemon,
connectNewWorkspaceDaemonClient, connectNewWorkspaceDaemonClient,
createWorktreeViaDaemon, createWorktreeViaDaemon,
openProjectViaDaemon, openProjectViaDaemon,
@@ -35,6 +44,63 @@ test.describe("Worktree restore", () => {
tempRepo = await createTempGitRepo("wt-restore-"); tempRepo = await createTempGitRepo("wt-restore-");
}); });
async function createArchivedMissingWorktree(prefix: string) {
const project = await openProjectViaDaemon(worktreeClient, tempRepo.path);
createdProjectIds.add(project.projectKey);
const worktree = await createWorktreeViaDaemon(worktreeClient, {
cwd: tempRepo.path,
slug: `${prefix}-${randomUUID().slice(0, 8)}`,
});
createdProjectIds.add(worktree.projectKey);
createdWorktreeDirectories.add(worktree.workspaceDirectory);
const agent = await createIdleAgent(client, {
cwd: worktree.workspaceDirectory,
workspaceId: worktree.workspaceId,
title: `${prefix}-${randomUUID().slice(0, 8)}`,
});
await archiveWorkspaceFromDaemon(worktreeClient, worktree.workspaceDirectory);
await expect
.poll(() => existsSync(worktree.workspaceDirectory), { timeout: 30_000 })
.toBe(false);
// Match the remote cloud-race record: workspace archived and absent, while
// the surviving closed agent record is not agent-archived. Refresh now owns
// only agent lifecycle, so its expected cwd failure cannot recover the workspace.
await client.refreshAgent(agent.id).catch(() => undefined);
await expect.poll(() => fetchAgentArchivedAt(client, agent.id), { timeout: 30_000 }).toBeNull();
expect(existsSync(worktree.workspaceDirectory)).toBe(false);
return { agent, worktree };
}
async function openArchivedWorkspaceFromHistory(page: Page, prefix: string) {
const seeded = await createArchivedMissingWorktree(prefix);
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openSessions(page);
await expectSessionRowNotArchived(page, seeded.agent.title);
await page.getByTestId(`agent-row-${getServerId()}-${seeded.agent.id}`).click();
await expect(page.getByText("Workspace archived", { exact: true })).toBeVisible({
timeout: 30_000,
});
await expect(page.getByTestId("workspace-recovery-action")).toHaveText("Restore");
return seeded;
}
async function openArchivedAgentBeforeWorkspaceHydration(page: Page, prefix: string) {
const seeded = await createArchivedMissingWorktree(prefix);
const workspaceRoute = buildHostWorkspaceRoute(getServerId(), seeded.worktree.workspaceId);
const openAgent = encodeURIComponent(`agent:${seeded.agent.id}`);
await page.goto(`${workspaceRoute}?open=${openAgent}`);
await expect(page.getByText("Workspace archived", { exact: true })).toBeVisible({
timeout: 30_000,
});
await expect(page.getByTestId("workspace-recovery-action")).toHaveText("Restore");
return seeded;
}
test.afterEach(async () => { test.afterEach(async () => {
for (const directory of createdWorktreeDirectories) { for (const directory of createdWorktreeDirectories) {
await archiveWorkspaceFromDaemon(worktreeClient, directory).catch(() => undefined); await archiveWorkspaceFromDaemon(worktreeClient, directory).catch(() => undefined);
@@ -49,7 +115,7 @@ test.describe("Worktree restore", () => {
await tempRepo?.cleanup().catch(() => undefined); await tempRepo?.cleanup().catch(() => undefined);
}); });
test("archiving an agent, then clicking it in History unarchives it in place (worktree dir untouched)", async ({ test("opening an active History agent navigates without restoring or unarchiving", async ({
page, page,
}) => { }) => {
const serverId = getServerId(); const serverId = getServerId();
@@ -69,70 +135,176 @@ test.describe("Worktree restore", () => {
}); });
expect(existsSync(worktree.workspaceDirectory)).toBe(true); expect(existsSync(worktree.workspaceDirectory)).toBe(true);
await archiveAgentFromDaemon(client, agent.id); expect(await fetchAgentArchivedAt(client, agent.id)).toBeNull();
await gotoAppShell(page); await gotoAppShell(page);
await waitForSidebarHydration(page); await waitForSidebarHydration(page);
await openSessions(page); await openSessions(page);
await expectSessionRowArchived(page, agent.title); await expectSessionRowNotArchived(page, agent.title);
await page.getByTestId(`agent-row-${serverId}-${agent.id}`).click(); await page.getByTestId(`agent-row-${serverId}-${agent.id}`).click();
await expect.poll(() => fetchAgentArchivedAt(client, agent.id), { timeout: 30_000 }).toBeNull(); await expect(
page.getByTestId(`workspace-tab-agent_${agent.id}`).filter({ visible: true }).first(),
).toBeVisible({ timeout: 30_000 });
await expect(page.getByRole("button", { name: "Unarchive" })).toHaveCount(0);
expect(await fetchAgentArchivedAt(client, agent.id)).toBeNull();
expect(existsSync(worktree.workspaceDirectory)).toBe(true); expect(existsSync(worktree.workspaceDirectory)).toBe(true);
// The History list is a cached react-query snapshot, so the cleared Archived
// badge only renders after a cold refetch. Reload to remount the query fresh.
await page.reload();
await waitForSidebarHydration(page);
await openSessions(page); await openSessions(page);
const row = page await expectSessionRowNotArchived(page, agent.title);
.locator('[data-testid^="agent-row-"]') await page.getByTestId(`agent-row-${serverId}-${agent.id}`).click();
.filter({ hasText: agent.title })
.first(); await expect(
await expect(row).toBeVisible({ timeout: 30_000 }); page.getByTestId(`workspace-tab-agent_${agent.id}`).filter({ visible: true }).first(),
await expect(row).not.toContainText("Archived", { timeout: 30_000 }); ).toBeVisible({ timeout: 30_000 });
await expect(
page.getByTestId(`workspace-deck-entry-${serverId}:${worktree.workspaceId}`),
).toHaveCount(1);
expect(await fetchAgentArchivedAt(client, agent.id)).toBeNull();
}); });
test("archiving a worktree (dir deleted), then clicking its agent in History recreates the worktree", async ({ test("opening a recoverable archived workspace shows an explicit Restore action without mutating it", async ({
page, page,
}) => { }) => {
const serverId = getServerId(); const { agent, worktree } = await openArchivedWorkspaceFromHistory(page, "restore-ready");
const project = await openProjectViaDaemon(worktreeClient, tempRepo.path); expect(await fetchAgentArchivedAt(client, agent.id)).toBeNull();
createdProjectIds.add(project.projectKey); expect(existsSync(worktree.workspaceDirectory)).toBe(false);
const worktree = await createWorktreeViaDaemon(worktreeClient, { await expect(
worktreeClient.inspectWorkspaceRecovery(worktree.workspaceId),
).resolves.toMatchObject({ kind: "recoverable", action: "restore" });
});
test("explicit Restore shows loading and opens the recreated workspace", async ({ page }) => {
const { agent, worktree } = await openArchivedAgentBeforeWorkspaceHydration(
page,
"restore-success",
);
await worktreeClient.fetchWorkspaces({
subscribe: { subscriptionId: `restore-secondary-${randomUUID()}` },
});
let updateTimeout: ReturnType<typeof setTimeout> | null = null;
let unsubscribeSecondaryWorkspaceUpdate = () => {};
const secondaryWorkspaceUpdate = new Promise<void>((resolve, reject) => {
updateTimeout = setTimeout(
() => reject(new Error("Secondary client did not receive the restored workspace")),
30_000,
);
unsubscribeSecondaryWorkspaceUpdate = worktreeClient.on("workspace_update", (message) => {
if (
message.payload.kind === "upsert" &&
message.payload.workspace.id === worktree.workspaceId
) {
resolve();
}
});
});
try {
await page.getByTestId("workspace-recovery-action").click();
await expect(page.getByText("Restoring workspace", { exact: true })).toBeVisible();
await expect
.poll(() => existsSync(worktree.workspaceDirectory), { timeout: 30_000 })
.toBe(true);
await secondaryWorkspaceUpdate;
await waitForWorkspaceInSidebar(page, {
serverId: getServerId(),
workspaceId: worktree.workspaceId,
});
await expect(
page.getByTestId(`workspace-tab-agent_${agent.id}`).filter({ visible: true }).first(),
).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId("workspace-recovery-action")).toHaveCount(0);
expect(await fetchAgentArchivedAt(client, agent.id)).toBeNull();
} finally {
unsubscribeSecondaryWorkspaceUpdate();
if (updateTimeout) {
clearTimeout(updateTimeout);
}
}
const switchedBranch = `restored-live-${randomUUID().slice(0, 8)}`;
execFileSync("git", ["branch", switchedBranch], {
cwd: tempRepo.path, cwd: tempRepo.path,
slug: `restore-recreate-${randomUUID().slice(0, 8)}`, stdio: "pipe",
}); });
createdProjectIds.add(worktree.projectKey); await openChangesPanel(page);
createdWorktreeDirectories.add(worktree.workspaceDirectory); await expectWorkspaceBranch(page, worktree.workspaceName);
await switchBranchFromChangesPanel(page, {
const agent = await createIdleAgent(client, { from: worktree.workspaceName,
cwd: worktree.workspaceDirectory, to: switchedBranch,
workspaceId: worktree.workspaceId,
title: `restore-recreate-${randomUUID().slice(0, 8)}`,
}); });
expect(existsSync(worktree.workspaceDirectory)).toBe(true); await expectWorkspaceBranch(page, switchedBranch);
await expect(
page.getByTestId("workspace-header-title").filter({ visible: true }).first(),
).toHaveText(switchedBranch, { timeout: 30_000 });
});
// Archive through the default production path the sidebar uses (no explicit test("restore failure stays visible and permits a successful retry", async ({ page }) => {
// scope). With the restore prune fix, this default path frees the kept branch const { agent, worktree } = await openArchivedWorkspaceFromHistory(page, "restore-retry");
// so the daemon can re-check-out the worktree on restore. const displacedProjectPath = `${tempRepo.path}-temporarily-unavailable`;
await archiveWorkspaceFromDaemon(worktreeClient, worktree.workspaceDirectory); await rename(tempRepo.path, displacedProjectPath);
await expect
.poll(() => existsSync(worktree.workspaceDirectory), { timeout: 30_000 })
.toBe(false);
await gotoAppShell(page); try {
await waitForSidebarHydration(page); await page.getByTestId("workspace-recovery-action").click();
await openSessions(page); await expect(page.getByTestId("workspace-recovery-error")).toHaveText(
await expectSessionRowArchived(page, agent.title); "The project directory needed to restore this worktree no longer exists.",
);
await page.getByTestId(`agent-row-${serverId}-${agent.id}`).click(); await expect(page.getByTestId("workspace-recovery-action")).toHaveText("Retry");
expect(existsSync(worktree.workspaceDirectory)).toBe(false);
} finally {
await rename(displacedProjectPath, tempRepo.path);
}
await page.getByTestId("workspace-recovery-action").click();
await expect await expect
.poll(() => existsSync(worktree.workspaceDirectory), { timeout: 30_000 }) .poll(() => existsSync(worktree.workspaceDirectory), { timeout: 30_000 })
.toBe(true); .toBe(true);
await waitForWorkspaceInSidebar(page, { serverId, workspaceId: worktree.workspaceId }); await waitForWorkspaceInSidebar(page, {
serverId: getServerId(),
workspaceId: worktree.workspaceId,
});
await expect(
page.getByTestId(`workspace-tab-agent_${agent.id}`).filter({ visible: true }).first(),
).toBeVisible({ timeout: 30_000 });
});
test("an unrecoverable missing workspace shows no misleading recovery action", async ({
page,
}) => {
const project = await openProjectViaDaemon(worktreeClient, tempRepo.path);
createdProjectIds.add(project.projectKey);
const agent = await createIdleAgent(client, {
cwd: project.workspaceDirectory,
workspaceId: project.workspaceId,
title: `unrecoverable-${randomUUID().slice(0, 8)}`,
});
await archiveLocalWorkspaceFromDaemon(worktreeClient, project.workspaceId);
await client.refreshAgent(agent.id).catch(() => undefined);
await expect.poll(() => fetchAgentArchivedAt(client, agent.id), { timeout: 30_000 }).toBeNull(); await expect.poll(() => fetchAgentArchivedAt(client, agent.id), { timeout: 30_000 }).toBeNull();
const displacedProjectPath = `${tempRepo.path}-missing`;
await rename(tempRepo.path, displacedProjectPath);
try {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openSessions(page);
await expectSessionRowNotArchived(page, agent.title);
await page.getByTestId(`agent-row-${getServerId()}-${agent.id}`).click();
await expect(page.getByText("Workspace unavailable", { exact: true })).toBeVisible({
timeout: 30_000,
});
await expect(
page.getByText(
"The archived workspace directory no longer exists and cannot be recreated.",
{ exact: true },
),
).toBeVisible();
await expect(page.getByTestId("workspace-recovery-action")).toHaveCount(0);
} finally {
await rename(displacedProjectPath, tempRepo.path);
}
}); });
}); });

View File

@@ -108,6 +108,12 @@ function HostWorkspaceRouteContent() {
? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? "") ? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? "")
: ""; : "";
const openValue = getParamValue(globalParams.open); const openValue = getParamValue(globalParams.open);
const hasHydratedWorkspaces = useHasHydratedWorkspaces(serverId);
const workspaceExists = useWorkspaceExists(serverId, workspaceId);
const isAgentOpenIntent = parseWorkspaceOpenIntent(openValue)?.kind === "agent";
const isOpenIntentWaitingForWorkspace = Boolean(
isAgentOpenIntent && (!hasHydratedWorkspaces || !workspaceExists),
);
useEffect(() => { useEffect(() => {
if (!serverId || !workspaceId) { if (!serverId || !workspaceId) {
return; return;
@@ -125,6 +131,9 @@ function HostWorkspaceRouteContent() {
if (!hasHydratedWorkspaceLayoutStore) { if (!hasHydratedWorkspaceLayoutStore) {
return; return;
} }
if (isOpenIntentWaitingForWorkspace) {
return;
}
const consumptionKey = `${serverId}:${workspaceId}:${openValue}`; const consumptionKey = `${serverId}:${workspaceId}:${openValue}`;
if (consumedIntentRef.current === consumptionKey) { if (consumedIntentRef.current === consumptionKey) {
@@ -160,6 +169,7 @@ function HostWorkspaceRouteContent() {
setIntentConsumed(true); setIntentConsumed(true);
}, [ }, [
hasHydratedWorkspaceLayoutStore, hasHydratedWorkspaceLayoutStore,
isOpenIntentWaitingForWorkspace,
navigation, navigation,
openValue, openValue,
rootNavigationState?.key, rootNavigationState?.key,
@@ -167,14 +177,18 @@ function HostWorkspaceRouteContent() {
workspaceId, workspaceId,
]); ]);
if (openValue && (!intentConsumed || !hasHydratedWorkspaceLayoutStore)) { if (
openValue &&
!isOpenIntentWaitingForWorkspace &&
(!intentConsumed || !hasHydratedWorkspaceLayoutStore)
) {
return null; return null;
} }
return <WorkspaceDeck />; return <WorkspaceDeck recoveryRequested={isAgentOpenIntent} />;
} }
function WorkspaceDeck() { function WorkspaceDeck({ recoveryRequested }: { recoveryRequested: boolean }) {
const activeSelection = useActiveWorkspaceSelection(); const activeSelection = useActiveWorkspaceSelection();
const [mountedSelections, setMountedSelections] = useState<ActiveWorkspaceSelection[]>(() => const [mountedSelections, setMountedSelections] = useState<ActiveWorkspaceSelection[]>(() =>
activeSelection ? [activeSelection] : [], activeSelection ? [activeSelection] : [],
@@ -219,6 +233,7 @@ function WorkspaceDeck() {
key={getWorkspaceSelectionKey(selection)} key={getWorkspaceSelectionKey(selection)}
selection={selection} selection={selection}
activeSelection={activeSelection} activeSelection={activeSelection}
recoveryRequested={recoveryRequested}
onUnmountInactive={unmountWorkspaceSelection} onUnmountInactive={unmountWorkspaceSelection}
/> />
); );
@@ -230,10 +245,12 @@ function WorkspaceDeck() {
function WorkspaceDeckEntry({ function WorkspaceDeckEntry({
selection, selection,
activeSelection, activeSelection,
recoveryRequested,
onUnmountInactive, onUnmountInactive,
}: { }: {
selection: ActiveWorkspaceSelection; selection: ActiveWorkspaceSelection;
activeSelection: ActiveWorkspaceSelection; activeSelection: ActiveWorkspaceSelection;
recoveryRequested: boolean;
onUnmountInactive: (selection: ActiveWorkspaceSelection) => void; onUnmountInactive: (selection: ActiveWorkspaceSelection) => void;
}) { }) {
const isActive = areWorkspaceSelectionsEqual(selection, activeSelection); const isActive = areWorkspaceSelectionsEqual(selection, activeSelection);
@@ -264,6 +281,7 @@ function WorkspaceDeckEntry({
serverId={selection.serverId} serverId={selection.serverId}
workspaceId={selection.workspaceId} workspaceId={selection.workspaceId}
isRouteFocused={isActive} isRouteFocused={isActive}
recoveryRequested={isActive && recoveryRequested}
/> />
</RetainedPanel> </RetainedPanel>
); );

View File

@@ -21,8 +21,6 @@ import { Archive, ChevronRight } from "lucide-react-native";
import { getProviderIcon } from "@/components/provider-icons"; import { getProviderIcon } from "@/components/provider-icons";
import { navigateToAgent } from "@/utils/navigate-to-agent"; import { navigateToAgent } from "@/utils/navigate-to-agent";
import { useArchiveAgent } from "@/hooks/use-archive-agent"; import { useArchiveAgent } from "@/hooks/use-archive-agent";
import { useQueryClient } from "@tanstack/react-query";
import { agentHistoryQueryKey } from "@/hooks/agent-history-query-key";
interface AgentListProps { interface AgentListProps {
agents: AggregatedAgent[]; agents: AggregatedAgent[];
@@ -374,7 +372,6 @@ export function AgentList({
const [actionAgent, setActionAgent] = useState<AggregatedAgent | null>(null); const [actionAgent, setActionAgent] = useState<AggregatedAgent | null>(null);
const isMobile = useIsCompactFormFactor(); const isMobile = useIsCompactFormFactor();
const { archiveAgent } = useArchiveAgent(); const { archiveAgent } = useArchiveAgent();
const queryClient = useQueryClient();
const actionClient = useSessionStore((state) => const actionClient = useSessionStore((state) =>
actionAgent?.serverId ? (state.sessions[actionAgent.serverId]?.client ?? null) : null, actionAgent?.serverId ? (state.sessions[actionAgent.serverId]?.client ?? null) : null,
@@ -391,35 +388,15 @@ export function AgentList({
const serverId = agent.serverId; const serverId = agent.serverId;
const agentId = agent.id; const agentId = agent.id;
const openAgent = () => {
onAgentSelect?.();
navigateToAgent({
serverId,
agentId,
workspaceId: agent.workspaceId,
pin: false,
});
};
if (agent.archivedAt) { onAgentSelect?.();
const client = useSessionStore.getState().sessions[serverId]?.client ?? null; navigateToAgent({
if (client) { serverId,
void client agentId,
.refreshAgent(agentId) workspaceId: agent.workspaceId,
.then(() => { });
openAgent();
return queryClient.invalidateQueries({
queryKey: agentHistoryQueryKey(serverId),
});
})
.catch(() => {});
}
return;
}
openAgent();
}, },
[isActionSheetVisible, onAgentSelect, queryClient], [isActionSheetVisible, onAgentSelect],
); );
const handleAgentLongPress = useCallback( const handleAgentLongPress = useCallback(

View File

@@ -489,7 +489,8 @@ describe("translation resources", () => {
expect(en.workspace.route.hostOffline).toBe("{{hostName}} is offline"); expect(en.workspace.route.hostOffline).toBe("{{hostName}} is offline");
expect(en.workspace.route.cannotReachHost).toBe("Cannot reach {{hostName}}"); expect(en.workspace.route.cannotReachHost).toBe("Cannot reach {{hostName}}");
expect(en.workspace.route.hostStatus).toBe("Host status: {{status}}"); expect(en.workspace.route.hostStatus).toBe("Host status: {{status}}");
expect(en.workspace.route.missing).toBe("Workspace not found"); expect(en.workspace.route.recovery.archivedTitle).toBe("Workspace archived");
expect(en.workspace.route.recovery.unavailableTitle).toBe("Workspace unavailable");
expect(en.message.compaction.loading).toBe("Compacting..."); expect(en.message.compaction.loading).toBe("Compacting...");
expect(en.message.compaction.auto).toBe("Context automatically compacted"); expect(en.message.compaction.auto).toBe("Context automatically compacted");
expect(en.message.compaction.manual).toBe("Context manually compacted"); expect(en.message.compaction.manual).toBe("Context manually compacted");

View File

@@ -335,15 +335,24 @@ export const ar: TranslationResources = {
workspace: { workspace: {
route: { route: {
loading: "جارٍ تحميل مساحة العمل", loading: "جارٍ تحميل مساحة العمل",
restoring: "جارٍ استعادة مساحة العمل",
restoreFailed: "تعذّر استعادة مساحة العمل هذه — ربما تم نقل المجلد أو حذفه",
connecting: "الاتصال", connecting: "الاتصال",
hostOffline: "{{hostName}}غير متواجد حالياً", hostOffline: "{{hostName}}غير متواجد حالياً",
cannotReachHost: "لا يمكن الوصول إلى{{hostName}}", cannotReachHost: "لا يمكن الوصول إلى{{hostName}}",
hostStatus: "حالة Host:{{status}}", hostStatus: "حالة Host:{{status}}",
missing: "لم يتم العثور على Workspace",
needsHostUpgrade: "قم بتحديث مضيفك لاستعادة مساحة العمل هذه", needsHostUpgrade: "قم بتحديث مضيفك لاستعادة مساحة العمل هذه",
manageHost: "إدارة المضيف", manageHost: "إدارة المضيف",
recovery: {
archivedTitle: "مساحة العمل مؤرشفة",
restoreDescription:
"تمت أرشفة {{workspaceName}} وإزالة شجرة العمل الخاصة بها. استعد الفرع {{branch}} لفتحها مجددًا.",
unarchiveDescription: "{{workspaceName}} مؤرشفة. ألغِ أرشفتها لفتحها مجددًا.",
restoreAction: "استعادة",
unarchiveAction: "إلغاء الأرشفة",
restoringTitle: "جارٍ استعادة مساحة العمل",
restoringAction: "جارٍ الاستعادة...",
unavailableTitle: "مساحة العمل غير متاحة",
checkFailedTitle: "تعذر التحقق من مساحة العمل",
},
}, },
hoverCard: { hoverCard: {
scriptsAccessibility: "البرامج النصية Workspace", scriptsAccessibility: "البرامج النصية Workspace",

View File

@@ -334,16 +334,24 @@ export const en = {
workspace: { workspace: {
route: { route: {
loading: "Loading workspace", loading: "Loading workspace",
restoring: "Restoring workspace",
restoreFailed:
"Couldn't restore this workspace — the directory may have been moved or deleted",
connecting: "Connecting", connecting: "Connecting",
hostOffline: "{{hostName}} is offline", hostOffline: "{{hostName}} is offline",
cannotReachHost: "Cannot reach {{hostName}}", cannotReachHost: "Cannot reach {{hostName}}",
hostStatus: "Host status: {{status}}", hostStatus: "Host status: {{status}}",
missing: "Workspace not found",
needsHostUpgrade: "Update your host to restore this workspace", needsHostUpgrade: "Update your host to restore this workspace",
manageHost: "Manage host", manageHost: "Manage host",
recovery: {
archivedTitle: "Workspace archived",
restoreDescription:
"{{workspaceName}} was archived and its worktree was removed. Restore branch {{branch}} to open it again.",
unarchiveDescription: "{{workspaceName}} is archived. Unarchive it to open it again.",
restoreAction: "Restore",
unarchiveAction: "Unarchive",
restoringTitle: "Restoring workspace",
restoringAction: "Restoring...",
unavailableTitle: "Workspace unavailable",
checkFailedTitle: "Couldn't check workspace",
},
}, },
hoverCard: { hoverCard: {
scriptsAccessibility: "Workspace scripts", scriptsAccessibility: "Workspace scripts",

View File

@@ -338,16 +338,25 @@ export const es: TranslationResources = {
workspace: { workspace: {
route: { route: {
loading: "Cargando espacio de trabajo", loading: "Cargando espacio de trabajo",
restoring: "Restaurando espacio de trabajo",
restoreFailed:
"No se pudo restaurar este espacio de trabajo — es posible que el directorio se haya movido o eliminado",
connecting: "Conectando", connecting: "Conectando",
hostOffline: "{{hostName}}está desconectado", hostOffline: "{{hostName}}está desconectado",
cannotReachHost: "No se puede alcanzar{{hostName}}", cannotReachHost: "No se puede alcanzar{{hostName}}",
hostStatus: "Estado deHost:{{status}}", hostStatus: "Estado deHost:{{status}}",
missing: "Workspaceno encontrado",
needsHostUpgrade: "Actualiza tu host para restaurar este espacio de trabajo", needsHostUpgrade: "Actualiza tu host para restaurar este espacio de trabajo",
manageHost: "Administrar host", manageHost: "Administrar host",
recovery: {
archivedTitle: "Espacio de trabajo archivado",
restoreDescription:
"{{workspaceName}} se archivó y se eliminó su worktree. Restaura la rama {{branch}} para volver a abrirlo.",
unarchiveDescription:
"{{workspaceName}} está archivado. Desarchívalo para volver a abrirlo.",
restoreAction: "Restaurar",
unarchiveAction: "Desarchivar",
restoringTitle: "Restaurando espacio de trabajo",
restoringAction: "Restaurando...",
unavailableTitle: "Espacio de trabajo no disponible",
checkFailedTitle: "No se pudo comprobar el espacio de trabajo",
},
}, },
hoverCard: { hoverCard: {
scriptsAccessibility: "GuionesWorkspace", scriptsAccessibility: "GuionesWorkspace",

View File

@@ -338,16 +338,24 @@ export const fr: TranslationResources = {
workspace: { workspace: {
route: { route: {
loading: "Chargement de l'espace de travail", loading: "Chargement de l'espace de travail",
restoring: "Restauration de l'espace de travail",
restoreFailed:
"Impossible de restaurer cet espace de travail — le répertoire a peut-être été déplacé ou supprimé",
connecting: "De liaison", connecting: "De liaison",
hostOffline: "{{hostName}}est hors ligne", hostOffline: "{{hostName}}est hors ligne",
cannotReachHost: "Impossible d'atteindre{{hostName}}", cannotReachHost: "Impossible d'atteindre{{hostName}}",
hostStatus: "StatutHost:{{status}}", hostStatus: "StatutHost:{{status}}",
missing: "Workspaceintrouvable",
needsHostUpgrade: "Mettez à jour votre hôte pour restaurer cet espace de travail", needsHostUpgrade: "Mettez à jour votre hôte pour restaurer cet espace de travail",
manageHost: "Gérer l'hôte", manageHost: "Gérer l'hôte",
recovery: {
archivedTitle: "Espace de travail archivé",
restoreDescription:
"{{workspaceName}} a été archivé et son worktree supprimé. Restaurez la branche {{branch}} pour le rouvrir.",
unarchiveDescription: "{{workspaceName}} est archivé. Désarchivez-le pour le rouvrir.",
restoreAction: "Restaurer",
unarchiveAction: "Désarchiver",
restoringTitle: "Restauration de l'espace de travail",
restoringAction: "Restauration...",
unavailableTitle: "Espace de travail indisponible",
checkFailedTitle: "Impossible de vérifier l'espace de travail",
},
}, },
hoverCard: { hoverCard: {
scriptsAccessibility: "ScriptsWorkspace", scriptsAccessibility: "ScriptsWorkspace",

View File

@@ -338,16 +338,25 @@ export const ja: TranslationResources = {
workspace: { workspace: {
route: { route: {
loading: "ワークスペースを読み込み中", loading: "ワークスペースを読み込み中",
restoring: "ワークスペースを復元中",
restoreFailed:
"このワークスペースを復元できませんでした。ディレクトリが移動または削除された可能性があります。",
connecting: "接続中", connecting: "接続中",
hostOffline: "{{hostName}}はオフラインです", hostOffline: "{{hostName}}はオフラインです",
cannotReachHost: "{{hostName}}に到達できません", cannotReachHost: "{{hostName}}に到達できません",
hostStatus: "ホストの状態: {{status}}", hostStatus: "ホストの状態: {{status}}",
missing: "ワークスペースが見つかりません",
needsHostUpgrade: "このワークスペースを復元するにはホストを更新してください", needsHostUpgrade: "このワークスペースを復元するにはホストを更新してください",
manageHost: "ホストを管理", manageHost: "ホストを管理",
recovery: {
archivedTitle: "ワークスペースはアーカイブ済みです",
restoreDescription:
"{{workspaceName}} はアーカイブされ、worktree が削除されました。ブランチ {{branch}} を復元して再度開きます。",
unarchiveDescription:
"{{workspaceName}} はアーカイブされています。再度開くにはアーカイブを解除してください。",
restoreAction: "復元",
unarchiveAction: "アーカイブを解除",
restoringTitle: "ワークスペースを復元中",
restoringAction: "復元中...",
unavailableTitle: "ワークスペースを利用できません",
checkFailedTitle: "ワークスペースを確認できませんでした",
},
}, },
hoverCard: { hoverCard: {
scriptsAccessibility: "ワークスペーススクリプト", scriptsAccessibility: "ワークスペーススクリプト",

View File

@@ -338,16 +338,25 @@ export const ptBR: TranslationResources = {
workspace: { workspace: {
route: { route: {
loading: "Carregando workspace", loading: "Carregando workspace",
restoring: "Restaurando workspace",
restoreFailed:
"Não foi possível restaurar este workspace — o diretório pode ter sido movido ou excluído",
connecting: "Conectando", connecting: "Conectando",
hostOffline: "{{hostName}} está offline", hostOffline: "{{hostName}} está offline",
cannotReachHost: "Não é possível acessar {{hostName}}", cannotReachHost: "Não é possível acessar {{hostName}}",
hostStatus: "Status do host: {{status}}", hostStatus: "Status do host: {{status}}",
missing: "Workspace não encontrado",
needsHostUpgrade: "Atualize o host para restaurar este workspace", needsHostUpgrade: "Atualize o host para restaurar este workspace",
manageHost: "Gerenciar host", manageHost: "Gerenciar host",
recovery: {
archivedTitle: "Workspace arquivado",
restoreDescription:
"{{workspaceName}} foi arquivado e sua worktree foi removida. Restaure a branch {{branch}} para abri-lo novamente.",
unarchiveDescription:
"{{workspaceName}} está arquivado. Desarquive-o para abri-lo novamente.",
restoreAction: "Restaurar",
unarchiveAction: "Desarquivar",
restoringTitle: "Restaurando workspace",
restoringAction: "Restaurando...",
unavailableTitle: "Workspace indisponível",
checkFailedTitle: "Não foi possível verificar o workspace",
},
}, },
hoverCard: { hoverCard: {
scriptsAccessibility: "Scripts do workspace", scriptsAccessibility: "Scripts do workspace",

View File

@@ -337,16 +337,25 @@ export const ru: TranslationResources = {
workspace: { workspace: {
route: { route: {
loading: "Загрузка рабочей области", loading: "Загрузка рабочей области",
restoring: "Восстановление рабочей области",
restoreFailed:
"Не удалось восстановить эту рабочую область — каталог мог быть перемещён или удалён",
connecting: "Подключение", connecting: "Подключение",
hostOffline: "{{hostName}}не в сети", hostOffline: "{{hostName}}не в сети",
cannotReachHost: "Невозможно связаться с{{hostName}}", cannotReachHost: "Невозможно связаться с{{hostName}}",
hostStatus: "Статус Host:{{status}}", hostStatus: "Статус Host:{{status}}",
missing: "Workspace не найден",
needsHostUpgrade: "Обновите хост, чтобы восстановить эту рабочую область", needsHostUpgrade: "Обновите хост, чтобы восстановить эту рабочую область",
manageHost: "Управление хостом", manageHost: "Управление хостом",
recovery: {
archivedTitle: "Рабочая область в архиве",
restoreDescription:
"{{workspaceName}} была архивирована, а её worktree удалён. Восстановите ветку {{branch}}, чтобы открыть её снова.",
unarchiveDescription:
"{{workspaceName}} находится в архиве. Разархивируйте её, чтобы открыть снова.",
restoreAction: "Восстановить",
unarchiveAction: "Разархивировать",
restoringTitle: "Восстановление рабочей области",
restoringAction: "Восстановление...",
unavailableTitle: "Рабочая область недоступна",
checkFailedTitle: "Не удалось проверить рабочую область",
},
}, },
hoverCard: { hoverCard: {
scriptsAccessibility: "Скрипты Workspace", scriptsAccessibility: "Скрипты Workspace",

View File

@@ -335,15 +335,24 @@ export const zhCN: TranslationResources = {
workspace: { workspace: {
route: { route: {
loading: "正在加载 workspace", loading: "正在加载 workspace",
restoring: "正在恢复 workspace",
restoreFailed: "无法恢复此 workspace — 目录可能已被移动或删除",
connecting: "正在连接", connecting: "正在连接",
hostOffline: "{{hostName}} 已离线", hostOffline: "{{hostName}} 已离线",
cannotReachHost: "无法连接 {{hostName}}", cannotReachHost: "无法连接 {{hostName}}",
hostStatus: "Host 状态:{{status}}", hostStatus: "Host 状态:{{status}}",
missing: "Workspace 未找到",
needsHostUpgrade: "更新你的 Host 以恢复此 workspace", needsHostUpgrade: "更新你的 Host 以恢复此 workspace",
manageHost: "管理 Host", manageHost: "管理 Host",
recovery: {
archivedTitle: "Workspace 已归档",
restoreDescription:
"{{workspaceName}} 已归档,其 worktree 已移除。恢复分支 {{branch}} 以重新打开。",
unarchiveDescription: "{{workspaceName}} 已归档。取消归档以重新打开。",
restoreAction: "恢复",
unarchiveAction: "取消归档",
restoringTitle: "正在恢复 workspace",
restoringAction: "正在恢复...",
unavailableTitle: "Workspace 不可用",
checkFailedTitle: "无法检查 workspace",
},
}, },
hoverCard: { hoverCard: {
scriptsAccessibility: "Workspace scripts", scriptsAccessibility: "Workspace scripts",

View File

@@ -77,4 +77,32 @@ describe("navigateToHostWorkspaceRoute", () => {
}, },
}); });
}); });
it("preserves a workspace open intent in the POP_TO target", () => {
const { navigationRef, dispatch } = createNavigationRef({
key: "root-stack",
routes: [{ key: "host-server-1", name: "h/[serverId]" }],
});
registerWorkspaceRouteNavigationRef(navigationRef);
navigateToHostWorkspaceRoute("/h/server-1/workspace/workspace-a?open=agent%3Aagent-1");
expect(dispatch).toHaveBeenCalledWith({
type: "POP_TO",
target: "root-stack",
payload: {
name: "h/[serverId]",
params: {
serverId: "server-1",
screen: "workspace/[workspaceId]/index",
params: {
serverId: "server-1",
workspaceId: "workspace-a",
open: "agent:agent-1",
},
pop: true,
},
},
});
});
}); });

View File

@@ -2,6 +2,7 @@ import type { NavigationAction, NavigationContainerRefWithCurrent } from "@react
import { router, type Href } from "expo-router"; import { router, type Href } from "expo-router";
import { import {
encodeWorkspaceIdForPathSegment, encodeWorkspaceIdForPathSegment,
getHostWorkspaceOpenParamFromPathname,
parseHostWorkspaceRouteFromPathname, parseHostWorkspaceRouteFromPathname,
} from "@/utils/host-routes"; } from "@/utils/host-routes";
@@ -82,6 +83,7 @@ function dispatchHostWorkspacePopTo(route: string): boolean {
if (!target) { if (!target) {
return false; return false;
} }
const open = getHostWorkspaceOpenParamFromPathname(route);
const action: NavigationAction = { const action: NavigationAction = {
type: "POP_TO", type: "POP_TO",
@@ -94,6 +96,7 @@ function dispatchHostWorkspacePopTo(route: string): boolean {
params: { params: {
serverId: selection.serverId, serverId: selection.serverId,
workspaceId: encodeWorkspaceIdForPathSegment(selection.workspaceId), workspaceId: encodeWorkspaceIdForPathSegment(selection.workspaceId),
...(open ? { open } : {}),
}, },
// React Navigation consumes this nested hint when resolving the host child screen. // React Navigation consumes this nested hint when resolving the host child screen.
// The browser-route canonicalizer strips the resulting ?pop=true URL artifact. // The browser-route canonicalizer strips the resulting ?pop=true URL artifact.

View File

@@ -1,17 +1,25 @@
import { Text, View } from "react-native"; import { Text, View } from "react-native";
import { ArrowLeftToLine, RotateCw, Settings } from "lucide-react-native"; import { ArrowLeftToLine, RotateCw, Settings } from "lucide-react-native";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { formatConnectionStatus } from "@/utils/daemons"; import { formatConnectionStatus } from "@/utils/daemons";
import type { WorkspaceRouteState } from "@/screens/workspace/workspace-route-state"; import type { WorkspaceRouteState } from "@/screens/workspace/workspace-route-state";
import type { Theme } from "@/styles/theme";
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
interface WorkspaceRouteStateActions { interface WorkspaceRouteStateActions {
onRetryHost: () => void; onRetryHost: () => void;
onManageHost: () => void; onManageHost: () => void;
onDismissMissingWorkspace: () => void; onDismissMissingWorkspace: () => void;
onRecoverWorkspace: () => void;
onRetryRecoveryInspection: () => void;
} }
export function renderWorkspaceRouteGate(input: { export function renderWorkspaceRouteGate(input: {
@@ -21,8 +29,21 @@ export function renderWorkspaceRouteGate(input: {
switch (input.state.kind) { switch (input.state.kind) {
case "loading": case "loading":
return <WorkspaceConnecting hostName={input.state.hostName} />; return <WorkspaceConnecting hostName={input.state.hostName} />;
case "restoring": case "missing":
return <WorkspaceRestoring hostName={input.state.hostName} />; return (
<WorkspaceEmptyState
titleKey="workspace.route.recovery.unavailableTitle"
hostName={input.state.hostName}
onDismiss={input.actions.onDismissMissingWorkspace}
/>
);
case "archived":
return (
<ArchivedWorkspaceRecovery
state={input.state}
onRecover={input.actions.onRecoverWorkspace}
/>
);
case "needsHostUpgrade": case "needsHostUpgrade":
return ( return (
<WorkspaceEmptyState <WorkspaceEmptyState
@@ -39,13 +60,19 @@ export function renderWorkspaceRouteGate(input: {
onManageHost={input.actions.onManageHost} onManageHost={input.actions.onManageHost}
/> />
); );
case "missing": case "recoveryUnavailable":
return ( return (
<WorkspaceEmptyState <WorkspaceEmptyState
titleKey={ titleKey="workspace.route.recovery.unavailableTitle"
input.state.restoreFailed ? "workspace.route.restoreFailed" : "workspace.route.missing" description={input.state.message}
} onDismiss={input.actions.onDismissMissingWorkspace}
hostName={input.state.hostName} />
);
case "recoveryInspectionFailed":
return (
<WorkspaceRecoveryInspectionFailed
state={input.state}
onRetry={input.actions.onRetryRecoveryInspection}
onDismiss={input.actions.onDismissMissingWorkspace} onDismiss={input.actions.onDismissMissingWorkspace}
/> />
); );
@@ -69,12 +96,11 @@ function getWorkspaceHostStateTitle(
} }
function WorkspaceConnecting({ hostName }: { hostName: string }) { function WorkspaceConnecting({ hostName }: { hostName: string }) {
const { theme } = useUnistyles();
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<View style={styles.emptyState}> <View style={styles.emptyState}>
<LoadingSpinner size="small" color={theme.colors.foregroundMuted} /> <ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
<View style={styles.textStack}> <View style={styles.textStack}>
<Text style={styles.title}>{t("workspace.route.loading")}</Text> <Text style={styles.title}>{t("workspace.route.loading")}</Text>
<Text style={styles.description}>{hostName}</Text> <Text style={styles.description}>{hostName}</Text>
@@ -83,16 +109,90 @@ function WorkspaceConnecting({ hostName }: { hostName: string }) {
); );
} }
function WorkspaceRestoring({ hostName }: { hostName: string }) { function ArchivedWorkspaceRecovery({
const { theme } = useUnistyles(); state,
onRecover,
}: {
state: Extract<WorkspaceRouteState, { kind: "archived" }>;
onRecover: () => void;
}) {
const { t } = useTranslation(); const { t } = useTranslation();
const { recovery } = state;
const isRestoring = recovery.phase === "restoring";
let actionLabel = t("workspace.route.recovery.unarchiveAction");
if (recovery.recovery.action === "restore") {
actionLabel = t("workspace.route.recovery.restoreAction");
}
if (recovery.phase === "failed") {
actionLabel = t("common.actions.retry");
}
const description =
recovery.recovery.action === "restore"
? t("workspace.route.recovery.restoreDescription", {
workspaceName: recovery.recovery.workspaceName,
branch: recovery.recovery.branch,
})
: t("workspace.route.recovery.unarchiveDescription", {
workspaceName: recovery.recovery.workspaceName,
});
return ( return (
<View style={styles.emptyState}> <View style={styles.emptyState}>
<LoadingSpinner size="small" color={theme.colors.foregroundMuted} /> {isRestoring ? (
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
) : null}
<View style={styles.textStack}> <View style={styles.textStack}>
<Text style={styles.title}>{t("workspace.route.restoring")}</Text> <Text style={styles.title}>
<Text style={styles.description}>{hostName}</Text> {isRestoring
? t("workspace.route.recovery.restoringTitle")
: t("workspace.route.recovery.archivedTitle")}
</Text>
<Text style={styles.description}>{description}</Text>
{recovery.error ? (
<Text style={styles.error} testID="workspace-recovery-error">
{recovery.error}
</Text>
) : null}
</View>
<View style={styles.actions}>
<Button
size="sm"
variant="default"
leftIcon={isRestoring ? undefined : RotateCw}
onPress={onRecover}
disabled={isRestoring}
testID="workspace-recovery-action"
>
{isRestoring ? t("workspace.route.recovery.restoringAction") : actionLabel}
</Button>
</View>
</View>
);
}
function WorkspaceRecoveryInspectionFailed({
state,
onRetry,
onDismiss,
}: {
state: Extract<WorkspaceRouteState, { kind: "recoveryInspectionFailed" }>;
onRetry: () => void;
onDismiss: () => void;
}) {
const { t } = useTranslation();
return (
<View style={styles.emptyState}>
<View style={styles.textStack}>
<Text style={styles.title}>{t("workspace.route.recovery.checkFailedTitle")}</Text>
<Text style={styles.error}>{state.error}</Text>
</View>
<View style={styles.actions}>
<Button size="sm" variant="default" leftIcon={RotateCw} onPress={onRetry}>
{t("common.actions.retry")}
</Button>
<Button size="sm" variant="outline" leftIcon={ArrowLeftToLine} onPress={onDismiss}>
{t("common.actions.back")}
</Button>
</View> </View>
</View> </View>
); );
@@ -107,14 +207,13 @@ function WorkspaceUnreachable({
onRetry: () => void; onRetry: () => void;
onManageHost: () => void; onManageHost: () => void;
}) { }) {
const { theme } = useUnistyles();
const { t } = useTranslation(); const { t } = useTranslation();
const canRetry = state.connectionStatus === "offline" || state.connectionStatus === "error"; const canRetry = state.connectionStatus === "offline" || state.connectionStatus === "error";
return ( return (
<View style={styles.emptyState}> <View style={styles.emptyState}>
{state.connectionStatus === "connecting" || state.connectionStatus === "idle" ? ( {state.connectionStatus === "connecting" || state.connectionStatus === "idle" ? (
<LoadingSpinner size="small" color={theme.colors.foregroundMuted} /> <ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
) : null} ) : null}
<View style={styles.textStack}> <View style={styles.textStack}>
<Text style={styles.title}>{getWorkspaceHostStateTitle(state, t)}</Text> <Text style={styles.title}>{getWorkspaceHostStateTitle(state, t)}</Text>
@@ -155,13 +254,12 @@ function WorkspaceUnreachable({
function WorkspaceEmptyState({ function WorkspaceEmptyState({
titleKey, titleKey,
hostName, hostName,
description,
onDismiss, onDismiss,
}: { }: {
titleKey: titleKey: "workspace.route.needsHostUpgrade" | "workspace.route.recovery.unavailableTitle";
| "workspace.route.missing" hostName?: string;
| "workspace.route.restoreFailed" description?: string;
| "workspace.route.needsHostUpgrade";
hostName: string;
onDismiss: () => void; onDismiss: () => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -170,7 +268,7 @@ function WorkspaceEmptyState({
<View style={styles.emptyState}> <View style={styles.emptyState}>
<View style={styles.textStack}> <View style={styles.textStack}>
<Text style={styles.title}>{t(titleKey)}</Text> <Text style={styles.title}>{t(titleKey)}</Text>
<Text style={styles.description}>{hostName}</Text> <Text style={styles.description}>{description ?? hostName}</Text>
</View> </View>
<View style={styles.actions}> <View style={styles.actions}>
<Button size="sm" variant="default" leftIcon={ArrowLeftToLine} onPress={onDismiss}> <Button size="sm" variant="default" leftIcon={ArrowLeftToLine} onPress={onDismiss}>

View File

@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { WorkspaceDescriptor } from "@/stores/session-store"; import type { WorkspaceDescriptor } from "@/stores/session-store";
import type { WorkspaceRecoveryModel } from "@/workspace-recovery/model";
import { resolveWorkspaceRouteState } from "./workspace-route-state"; import { resolveWorkspaceRouteState } from "./workspace-route-state";
function createWorkspaceDescriptor(): WorkspaceDescriptor { function createWorkspaceDescriptor(): WorkspaceDescriptor {
@@ -20,18 +21,38 @@ function createWorkspaceDescriptor(): WorkspaceDescriptor {
}; };
} }
function resolve(input: {
workspace?: WorkspaceDescriptor | null;
connectionStatus?: "online" | "offline";
hasHydratedWorkspaces?: boolean;
recovery?: WorkspaceRecoveryModel;
}) {
return resolveWorkspaceRouteState({
hostName: "Laptop",
connectionStatus: input.connectionStatus ?? "online",
lastError: input.connectionStatus === "offline" ? "transport closed" : null,
workspace: input.workspace ?? null,
hasHydratedWorkspaces: input.hasHydratedWorkspaces ?? true,
recovery: input.recovery ?? { kind: "checking" },
});
}
const recoverable = {
kind: "recoverable",
recovery: {
kind: "recoverable",
workspaceId: "workspace-1",
workspaceName: "Feature branch",
action: "restore",
branch: "feature",
},
phase: "ready",
error: null,
} as const satisfies WorkspaceRecoveryModel;
describe("resolveWorkspaceRouteState", () => { describe("resolveWorkspaceRouteState", () => {
it("returns unreachable when no descriptor is cached and the host is offline", () => { it("keeps a missing route unreachable while its host is offline", () => {
expect( expect(resolve({ connectionStatus: "offline" })).toEqual({
resolveWorkspaceRouteState({
hostName: "Laptop",
connectionStatus: "offline",
lastError: "transport closed",
workspace: null,
hasHydratedWorkspaces: false,
restoreStatus: null,
}),
).toEqual({
kind: "unreachable", kind: "unreachable",
hostName: "Laptop", hostName: "Laptop",
connectionStatus: "offline", connectionStatus: "offline",
@@ -39,34 +60,9 @@ describe("resolveWorkspaceRouteState", () => {
}); });
}); });
it("keeps offline routes unreachable after workspace hydration", () => { it("keeps a cached workspace visible while its host reconnects", () => {
expect( expect(
resolveWorkspaceRouteState({ resolve({ workspace: createWorkspaceDescriptor(), connectionStatus: "offline" }),
hostName: "Laptop",
connectionStatus: "offline",
lastError: "transport closed",
workspace: null,
hasHydratedWorkspaces: true,
restoreStatus: null,
}),
).toEqual({
kind: "unreachable",
hostName: "Laptop",
connectionStatus: "offline",
lastError: "transport closed",
});
});
it("returns reconnecting when the descriptor is cached and the host is offline", () => {
expect(
resolveWorkspaceRouteState({
hostName: "Laptop",
connectionStatus: "offline",
lastError: "transport closed",
workspace: createWorkspaceDescriptor(),
hasHydratedWorkspaces: true,
restoreStatus: null,
}),
).toEqual({ ).toEqual({
kind: "reconnecting", kind: "reconnecting",
hostName: "Laptop", hostName: "Laptop",
@@ -75,93 +71,102 @@ describe("resolveWorkspaceRouteState", () => {
}); });
}); });
it("returns missing after workspace hydration when the host is online", () => { it("waits for workspace hydration and authoritative recovery inspection", () => {
expect( expect(resolve({ hasHydratedWorkspaces: false })).toEqual({
resolveWorkspaceRouteState({ kind: "loading",
hostName: "Laptop", hostName: "Laptop",
connectionStatus: "online", });
lastError: null, expect(resolve({ recovery: { kind: "checking" } })).toEqual({
workspace: null, kind: "loading",
hasHydratedWorkspaces: true, hostName: "Laptop",
restoreStatus: null, });
}),
).toEqual({ kind: "missing", hostName: "Laptop", restoreFailed: false });
}); });
it("returns loading before workspace hydration when the host is online", () => { it("keeps an ordinary missing route distinct from an explicit recovery request", () => {
expect( expect(resolve({ recovery: { kind: "idle" } })).toEqual({
resolveWorkspaceRouteState({ kind: "missing",
hostName: "Laptop", hostName: "Laptop",
connectionStatus: "online", });
lastError: null, expect(resolve({ recovery: { kind: "needsHostUpgrade" } })).toEqual({
workspace: null, kind: "needsHostUpgrade",
hasHydratedWorkspaces: false, hostName: "Laptop",
restoreStatus: null, });
}),
).toEqual({ kind: "loading", hostName: "Laptop" });
}); });
it("returns ready when the host is online and the descriptor exists", () => { it("renders the authoritative archived-workspace state through ready, restoring, and failed phases", () => {
expect(resolve({ recovery: recoverable })).toEqual({
kind: "archived",
hostName: "Laptop",
recovery: recoverable,
});
const restoring = { ...recoverable, phase: "restoring" as const };
expect(resolve({ recovery: restoring })).toMatchObject({
kind: "archived",
recovery: { phase: "restoring" },
});
const failed = {
...recoverable,
phase: "failed" as const,
error: "Project root is missing",
};
expect(resolve({ recovery: failed })).toMatchObject({
kind: "archived",
recovery: { phase: "failed", error: "Project root is missing" },
});
});
it("distinguishes unsupported and authoritatively unavailable workspaces", () => {
expect(resolve({ recovery: { kind: "needsHostUpgrade" } })).toEqual({
kind: "needsHostUpgrade",
hostName: "Laptop",
});
expect( expect(
resolveWorkspaceRouteState({ resolve({
hostName: "Laptop", recovery: {
connectionStatus: "online", kind: "unavailable",
lastError: null, recovery: {
kind: "unavailable",
workspaceId: "workspace-1",
reason: "workspace_directory_missing",
message: "The workspace directory cannot be recreated.",
},
},
}),
).toEqual({
kind: "recoveryUnavailable",
hostName: "Laptop",
message: "The workspace directory cannot be recreated.",
});
expect(
resolve({
recovery: {
kind: "unsupportedAction",
action: "repair_from_snapshot",
},
}),
).toEqual({
kind: "recoveryUnavailable",
hostName: "Laptop",
message: "Update Paseo to recover this workspace.",
});
});
it("keeps loading visible when the descriptor arrives, then uses it as the success transition", () => {
const restoring = { ...recoverable, phase: "restoring" as const };
expect(
resolve({
workspace: createWorkspaceDescriptor(), workspace: createWorkspaceDescriptor(),
hasHydratedWorkspaces: true, recovery: restoring,
restoreStatus: null,
}), }),
).toEqual({ kind: "ready" }); ).toEqual({ kind: "archived", hostName: "Laptop", recovery: restoring });
});
it("returns restoring while an archived workspace restore is in flight", () => {
expect( expect(
resolveWorkspaceRouteState({ resolve({
hostName: "Laptop",
connectionStatus: "online",
lastError: null,
workspace: null,
hasHydratedWorkspaces: true,
restoreStatus: "restoring",
}),
).toEqual({ kind: "restoring", hostName: "Laptop" });
});
it("returns needsHostUpgrade when the daemon lacks the restore capability", () => {
expect(
resolveWorkspaceRouteState({
hostName: "Laptop",
connectionStatus: "online",
lastError: null,
workspace: null,
hasHydratedWorkspaces: true,
restoreStatus: "needs-host-upgrade",
}),
).toEqual({ kind: "needsHostUpgrade", hostName: "Laptop" });
});
it("falls back to a restore-failed missing state once the restore times out", () => {
expect(
resolveWorkspaceRouteState({
hostName: "Laptop",
connectionStatus: "online",
lastError: null,
workspace: null,
hasHydratedWorkspaces: true,
restoreStatus: "failed",
}),
).toEqual({ kind: "missing", hostName: "Laptop", restoreFailed: true });
});
it("resolves to ready when the descriptor arrives even while restoring", () => {
expect(
resolveWorkspaceRouteState({
hostName: "Laptop",
connectionStatus: "online",
lastError: null,
workspace: createWorkspaceDescriptor(), workspace: createWorkspaceDescriptor(),
hasHydratedWorkspaces: true, recovery: recoverable,
restoreStatus: "restoring",
}), }),
).toEqual({ kind: "ready" }); ).toEqual({ kind: "ready" });
}); });

View File

@@ -1,5 +1,6 @@
import type { HostRuntimeConnectionStatus } from "@/runtime/host-runtime"; import type { HostRuntimeConnectionStatus } from "@/runtime/host-runtime";
import type { WorkspaceDescriptor } from "@/stores/session-store"; import type { WorkspaceDescriptor } from "@/stores/session-store";
import type { WorkspaceRecoveryModel } from "@/workspace-recovery/model";
export type WorkspaceRouteState = export type WorkspaceRouteState =
| { kind: "ready" } | { kind: "ready" }
@@ -16,9 +17,15 @@ export type WorkspaceRouteState =
lastError: string | null; lastError: string | null;
} }
| { kind: "loading"; hostName: string } | { kind: "loading"; hostName: string }
| { kind: "restoring"; hostName: string } | { kind: "missing"; hostName: string }
| { kind: "needsHostUpgrade"; hostName: string } | { kind: "needsHostUpgrade"; hostName: string }
| { kind: "missing"; hostName: string; restoreFailed: boolean }; | {
kind: "archived";
hostName: string;
recovery: Extract<WorkspaceRecoveryModel, { kind: "recoverable" }>;
}
| { kind: "recoveryUnavailable"; hostName: string; message: string }
| { kind: "recoveryInspectionFailed"; hostName: string; error: string };
export function resolveWorkspaceRouteState(input: { export function resolveWorkspaceRouteState(input: {
hostName: string; hostName: string;
@@ -26,10 +33,13 @@ export function resolveWorkspaceRouteState(input: {
lastError: string | null; lastError: string | null;
workspace: WorkspaceDescriptor | null; workspace: WorkspaceDescriptor | null;
hasHydratedWorkspaces: boolean; hasHydratedWorkspaces: boolean;
restoreStatus: "restoring" | "failed" | "needs-host-upgrade" | null; recovery: WorkspaceRecoveryModel;
}): WorkspaceRouteState { }): WorkspaceRouteState {
if (input.workspace) { if (input.workspace) {
if (input.connectionStatus === "online") { if (input.connectionStatus === "online") {
if (input.recovery.kind === "recoverable" && input.recovery.phase === "restoring") {
return { kind: "archived", hostName: input.hostName, recovery: input.recovery };
}
return { kind: "ready" }; return { kind: "ready" };
} }
@@ -41,30 +51,46 @@ export function resolveWorkspaceRouteState(input: {
}; };
} }
if (input.connectionStatus === "online") { if (input.connectionStatus !== "online") {
if (input.restoreStatus === "restoring") { return {
return { kind: "restoring", hostName: input.hostName }; kind: "unreachable",
} hostName: input.hostName,
connectionStatus: input.connectionStatus,
if (input.restoreStatus === "needs-host-upgrade") { lastError: input.lastError,
return { kind: "needsHostUpgrade", hostName: input.hostName }; };
}
if (input.hasHydratedWorkspaces) {
return {
kind: "missing",
hostName: input.hostName,
restoreFailed: input.restoreStatus === "failed",
};
}
return { kind: "loading", hostName: input.hostName };
} }
return { if (!input.hasHydratedWorkspaces) {
kind: "unreachable", return { kind: "loading", hostName: input.hostName };
hostName: input.hostName, }
connectionStatus: input.connectionStatus, if (input.recovery.kind === "idle") {
lastError: input.lastError, return { kind: "missing", hostName: input.hostName };
}; }
switch (input.recovery.kind) {
case "checking":
return { kind: "loading", hostName: input.hostName };
case "needsHostUpgrade":
return { kind: "needsHostUpgrade", hostName: input.hostName };
case "recoverable":
return { kind: "archived", hostName: input.hostName, recovery: input.recovery };
case "unavailable":
return {
kind: "recoveryUnavailable",
hostName: input.hostName,
message: input.recovery.recovery.message,
};
case "unsupportedAction":
return {
kind: "recoveryUnavailable",
hostName: input.hostName,
message: "Update Paseo to recover this workspace.",
};
case "inspectionFailed":
return {
kind: "recoveryInspectionFailed",
hostName: input.hostName,
error: input.recovery.error,
};
}
} }

View File

@@ -71,11 +71,7 @@ import { ImportSessionSheet } from "@/components/import-session-sheet";
import { useToast } from "@/contexts/toast-context"; import { useToast } from "@/contexts/toast-context";
import { selectIsFileExplorerOpen, usePanelStore } from "@/stores/panel-store"; import { selectIsFileExplorerOpen, usePanelStore } from "@/stores/panel-store";
import { type ExplorerCheckoutContext } from "@/stores/explorer-checkout-context"; import { type ExplorerCheckoutContext } from "@/stores/explorer-checkout-context";
import { import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
useSessionStore,
useWorkspaceRestoreStatus,
type WorkspaceDescriptor,
} from "@/stores/session-store";
import { import {
buildWorkspaceTabPersistenceKey, buildWorkspaceTabPersistenceKey,
collectAllTabs, collectAllTabs,
@@ -144,6 +140,8 @@ import {
type WorkspaceRouteState, type WorkspaceRouteState,
} from "@/screens/workspace/workspace-route-state"; } from "@/screens/workspace/workspace-route-state";
import { renderWorkspaceRouteGate } from "@/screens/workspace/workspace-route-state-views"; import { renderWorkspaceRouteGate } from "@/screens/workspace/workspace-route-state-views";
import { useWorkspaceRecovery } from "@/workspace-recovery/use-workspace-recovery";
import type { WorkspaceRecoveryModel } from "@/workspace-recovery/model";
import { import {
buildWorkspaceTabSnapshot, buildWorkspaceTabSnapshot,
deriveWorkspaceAgentVisibility, deriveWorkspaceAgentVisibility,
@@ -283,10 +281,12 @@ interface WorkspaceScreenProps {
serverId: string; serverId: string;
workspaceId: string; workspaceId: string;
isRouteFocused?: boolean; isRouteFocused?: boolean;
recoveryRequested?: boolean;
} }
type WorkspaceScreenContentProps = WorkspaceScreenProps & { type WorkspaceScreenContentProps = WorkspaceScreenProps & {
isRouteFocused: boolean; isRouteFocused: boolean;
recoveryRequested: boolean;
}; };
function trimNonEmpty(value: string | null | undefined): string | null { function trimNonEmpty(value: string | null | undefined): string | null {
@@ -896,6 +896,7 @@ export const WorkspaceScreen = memo(function WorkspaceScreen({
serverId, serverId,
workspaceId, workspaceId,
isRouteFocused, isRouteFocused,
recoveryRequested,
}: WorkspaceScreenProps) { }: WorkspaceScreenProps) {
const navigationFocused = useIsFocused(); const navigationFocused = useIsFocused();
return ( return (
@@ -903,6 +904,7 @@ export const WorkspaceScreen = memo(function WorkspaceScreen({
serverId={serverId} serverId={serverId}
workspaceId={workspaceId} workspaceId={workspaceId}
isRouteFocused={isRouteFocused ?? navigationFocused} isRouteFocused={isRouteFocused ?? navigationFocused}
recoveryRequested={recoveryRequested ?? false}
/> />
); );
}); });
@@ -1462,9 +1464,9 @@ function useWorkspaceRouteActions(normalizedServerId: string): {
function useResolvedWorkspaceRouteState(input: { function useResolvedWorkspaceRouteState(input: {
serverId: string; serverId: string;
workspaceId: string;
workspace: WorkspaceDescriptor | null; workspace: WorkspaceDescriptor | null;
hasHydratedWorkspaces: boolean; hasHydratedWorkspaces: boolean;
recovery: WorkspaceRecoveryModel;
}): WorkspaceRouteState { }): WorkspaceRouteState {
const hosts = useHosts(); const hosts = useHosts();
const host = useMemo( const host = useMemo(
@@ -1473,8 +1475,6 @@ function useResolvedWorkspaceRouteState(input: {
); );
const hostSnapshot = useHostRuntimeSnapshot(input.serverId); const hostSnapshot = useHostRuntimeSnapshot(input.serverId);
const hostName = useMemo(() => getHostDisplayName(host, input.serverId), [host, input.serverId]); const hostName = useMemo(() => getHostDisplayName(host, input.serverId), [host, input.serverId]);
const restoreStatus = useWorkspaceRestoreStatus(input.serverId, input.workspaceId);
return useMemo( return useMemo(
() => () =>
resolveWorkspaceRouteState({ resolveWorkspaceRouteState({
@@ -1483,7 +1483,7 @@ function useResolvedWorkspaceRouteState(input: {
lastError: hostSnapshot?.lastError ?? null, lastError: hostSnapshot?.lastError ?? null,
workspace: input.workspace, workspace: input.workspace,
hasHydratedWorkspaces: input.hasHydratedWorkspaces, hasHydratedWorkspaces: input.hasHydratedWorkspaces,
restoreStatus, recovery: input.recovery,
}), }),
[ [
hostName, hostName,
@@ -1491,11 +1491,19 @@ function useResolvedWorkspaceRouteState(input: {
hostSnapshot?.lastError, hostSnapshot?.lastError,
input.workspace, input.workspace,
input.hasHydratedWorkspaces, input.hasHydratedWorkspaces,
restoreStatus, input.recovery,
], ],
); );
} }
function shouldInspectWorkspaceRecovery(
hasHydratedWorkspaces: boolean,
workspace: WorkspaceDescriptor | null,
recoveryRequested: boolean,
): boolean {
return recoveryRequested && hasHydratedWorkspaces && workspace === null;
}
function WorkspaceScreenGateFrame({ children }: { children: ReactNode }) { function WorkspaceScreenGateFrame({ children }: { children: ReactNode }) {
return ( return (
<> <>
@@ -1691,6 +1699,7 @@ function WorkspaceScreenContent({
serverId, serverId,
workspaceId, workspaceId,
isRouteFocused, isRouteFocused,
recoveryRequested,
}: WorkspaceScreenContentProps) { }: WorkspaceScreenContentProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const _insets = useSafeAreaInsets(); const _insets = useSafeAreaInsets();
@@ -1752,6 +1761,15 @@ function WorkspaceScreenContent({
const hasHydratedWorkspaces = useSessionStore( const hasHydratedWorkspaces = useSessionStore(
(state) => state.sessions[normalizedServerId]?.hasHydratedWorkspaces ?? false, (state) => state.sessions[normalizedServerId]?.hasHydratedWorkspaces ?? false,
); );
const workspaceRecovery = useWorkspaceRecovery({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
enabled: shouldInspectWorkspaceRecovery(
hasHydratedWorkspaces,
workspaceDescriptor,
recoveryRequested,
),
});
const workspaceAgentVisibility = useStoreWithEqualityFn( const workspaceAgentVisibility = useStoreWithEqualityFn(
useSessionStore, useSessionStore,
@@ -1827,9 +1845,9 @@ function WorkspaceScreenContent({
); );
const workspaceRouteState = useResolvedWorkspaceRouteState({ const workspaceRouteState = useResolvedWorkspaceRouteState({
serverId: normalizedServerId, serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
workspace: workspaceDescriptor, workspace: workspaceDescriptor,
hasHydratedWorkspaces, hasHydratedWorkspaces,
recovery: workspaceRecovery.state,
}); });
const workspaceHeaderCheckoutState = buildWorkspaceHeaderCheckoutState({ const workspaceHeaderCheckoutState = buildWorkspaceHeaderCheckoutState({
isCheckoutStatusLoading, isCheckoutStatusLoading,
@@ -3291,6 +3309,8 @@ function WorkspaceScreenContent({
onRetryHost: handleRetryHost, onRetryHost: handleRetryHost,
onManageHost: handleManageHost, onManageHost: handleManageHost,
onDismissMissingWorkspace: handleDismissMissingWorkspace, onDismissMissingWorkspace: handleDismissMissingWorkspace,
onRecoverWorkspace: workspaceRecovery.restore,
onRetryRecoveryInspection: workspaceRecovery.retryInspection,
}, },
}); });
const gatedWorkspaceScreen = renderWorkspaceScreenGateShell({ const gatedWorkspaceScreen = renderWorkspaceScreenGateShell({

View File

@@ -11,8 +11,8 @@ import {
navigateToLastWorkspace as navigateToLastWorkspacePure, navigateToLastWorkspace as navigateToLastWorkspacePure,
navigateToWorkspace as navigateToWorkspacePure, navigateToWorkspace as navigateToWorkspacePure,
parseActiveWorkspaceSelection, parseActiveWorkspaceSelection,
type NavigateToWorkspaceInput,
type NavigateToWorkspaceDeps, type NavigateToWorkspaceDeps,
type NavigateToWorkspaceInput,
} from "./navigation"; } from "./navigation";
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";

View File

@@ -134,6 +134,24 @@ describe("workspace navigation", () => {
]); ]);
}); });
it("defers an agent tab until a missing workspace is recovered", () => {
const { deps, navigations, openedTabs } = createFakeDeps({
getSessionWorkspaces: () => new Map(),
});
navigateToWorkspace(
{
serverId: "server-1",
workspaceId: "workspace-a",
target: { kind: "agent", agentId: "agent-1" },
},
deps,
);
expect(openedTabs).toEqual([]);
expect(navigations).toEqual(["/h/server-1/workspace/workspace-a?open=agent%3Aagent-1"]);
});
it("reads the active workspace from the current route", () => { it("reads the active workspace from the current route", () => {
const selection = parseActiveWorkspaceSelection({ const selection = parseActiveWorkspaceSelection({
pathname: "/h/server-1/workspace/workspace-a", pathname: "/h/server-1/workspace/workspace-a",

View File

@@ -1,6 +1,7 @@
import type { Agent, WorkspaceDescriptor } from "@/stores/session-store"; import type { Agent, WorkspaceDescriptor } from "@/stores/session-store";
import { pickAttentionAgent } from "@/utils/agent-attention"; import { pickAttentionAgent } from "@/utils/agent-attention";
import { import {
buildHostWorkspaceOpenRoute,
buildHostWorkspaceRoute, buildHostWorkspaceRoute,
decodeWorkspaceIdFromPathSegment, decodeWorkspaceIdFromPathSegment,
parseHostWorkspaceRouteFromPathname, parseHostWorkspaceRouteFromPathname,
@@ -82,14 +83,16 @@ export function navigateToWorkspace(
input: NavigateToWorkspaceInput, input: NavigateToWorkspaceInput,
deps: NavigateToWorkspaceDeps, deps: NavigateToWorkspaceDeps,
): string { ): string {
const workspaces = deps.getSessionWorkspaces(input.serverId);
const resolvedWorkspaceId = resolveWorkspaceMapKeyByIdentity({
workspaces,
workspaceId: input.workspaceId,
});
if (input.target) { if (input.target) {
prepareWorkspaceTab({ ...input, target: input.target }, deps); if (resolvedWorkspaceId || input.target.kind !== "agent") {
prepareWorkspaceTab({ ...input, target: input.target }, deps);
}
} else { } else {
const workspaces = deps.getSessionWorkspaces(input.serverId);
const resolvedWorkspaceId = resolveWorkspaceMapKeyByIdentity({
workspaces,
workspaceId: input.workspaceId,
});
const workspaceAgents = resolvedWorkspaceId const workspaceAgents = resolvedWorkspaceId
? Array.from(deps.getSessionAgents(input.serverId)).filter( ? Array.from(deps.getSessionAgents(input.serverId)).filter(
(agent) => normalizeWorkspaceOpaqueId(agent.workspaceId) === resolvedWorkspaceId, (agent) => normalizeWorkspaceOpaqueId(agent.workspaceId) === resolvedWorkspaceId,
@@ -104,7 +107,14 @@ export function navigateToWorkspace(
} }
} }
const route = buildHostWorkspaceRoute(input.serverId, input.workspaceId); const route =
input.target?.kind === "agent" && !resolvedWorkspaceId
? buildHostWorkspaceOpenRoute(
input.serverId,
input.workspaceId,
`agent:${input.target.agentId}`,
)
: buildHostWorkspaceRoute(input.serverId, input.workspaceId);
deps.rememberLastWorkspace({ serverId: input.serverId, workspaceId: input.workspaceId }); deps.rememberLastWorkspace({ serverId: input.serverId, workspaceId: input.workspaceId });
deps.navigateToRoute(route); deps.navigateToRoute(route);
return route; return route;

View File

@@ -364,51 +364,6 @@ describe("mergeWorkspaces", () => {
expect(after.workspaces).not.toBe(before.workspaces); expect(after.workspaces).not.toBe(before.workspaces);
expect(after.workspaces.get(workspace.id)?.diffStat).toBeNull(); expect(after.workspaces.get(workspace.id)?.diffStat).toBeNull();
}); });
it("clears a pending restore status when the matching descriptor lands", () => {
const store = useSessionStore.getState();
initializeTestSession();
store.setWorkspaceRestoreStatus("test-server", "/repo/main", "restoring");
expect(getTestSessionReferences().session.restoringWorkspaces.get("/repo/main")).toBe(
"restoring",
);
store.mergeWorkspaces("test-server", [createWorkspace({ id: "/repo/main" })]);
expect(getTestSessionReferences().session.restoringWorkspaces.has("/repo/main")).toBe(false);
});
});
describe("setWorkspaceRestoreStatus", () => {
it("marks restoring then failed while the workspace is still absent", () => {
const store = useSessionStore.getState();
initializeTestSession();
store.setWorkspaceRestoreStatus("test-server", "/repo/main", "restoring");
store.setWorkspaceRestoreStatus("test-server", "/repo/main", "failed");
expect(getTestSessionReferences().session.restoringWorkspaces.get("/repo/main")).toBe("failed");
});
it("ignores a late failed once the descriptor has landed (no-op)", () => {
const store = useSessionStore.getState();
initializeTestSession();
store.setWorkspaceRestoreStatus("test-server", "/repo/main", "restoring");
store.mergeWorkspaces("test-server", [createWorkspace({ id: "/repo/main" })]);
store.setWorkspaceRestoreStatus("test-server", "/repo/main", "failed");
expect(getTestSessionReferences().session.restoringWorkspaces.has("/repo/main")).toBe(false);
});
it("ignores failed when no restore is in flight", () => {
const store = useSessionStore.getState();
initializeTestSession();
store.setWorkspaceRestoreStatus("test-server", "/repo/main", "failed");
expect(getTestSessionReferences().session.restoringWorkspaces.has("/repo/main")).toBe(false);
});
}); });
describe("setWorkspaces", () => { describe("setWorkspaces", () => {

View File

@@ -317,8 +317,6 @@ export interface AgentTimelineCursorState {
endSeq: number; endSeq: number;
} }
export type WorkspaceRestoreStatus = "restoring" | "failed" | "needs-host-upgrade";
// Per-session state // Per-session state
export interface SessionState { export interface SessionState {
serverId: string; serverId: string;
@@ -365,9 +363,6 @@ export interface SessionState {
// Project parents with no active workspaces, keyed by projectId. The // Project parents with no active workspaces, keyed by projectId. The
// `emptyProjects` name is the existing protocol/store projection. // `emptyProjects` name is the existing protocol/store projection.
emptyProjects: Map<string, EmptyProjectDescriptor>; emptyProjects: Map<string, EmptyProjectDescriptor>;
// Transient restore state for archived workspaces, keyed by normalized
// workspaceId. Cleared in mergeWorkspaces when the descriptor lands.
restoringWorkspaces: Map<string, WorkspaceRestoreStatus>;
// Permissions // Permissions
pendingPermissions: Map<string, PendingPermission>; pendingPermissions: Map<string, PendingPermission>;
@@ -488,13 +483,6 @@ interface SessionStoreActions {
setEmptyProjects: (serverId: string, emptyProjects: Iterable<EmptyProjectDescriptor>) => void; setEmptyProjects: (serverId: string, emptyProjects: Iterable<EmptyProjectDescriptor>) => void;
addEmptyProject: (serverId: string, emptyProject: EmptyProjectDescriptor) => void; addEmptyProject: (serverId: string, emptyProject: EmptyProjectDescriptor) => void;
removeEmptyProject: (serverId: string, projectId: string) => void; removeEmptyProject: (serverId: string, projectId: string) => void;
setWorkspaceRestoreStatus: (
serverId: string,
workspaceId: string,
status: WorkspaceRestoreStatus,
) => void;
clearWorkspaceRestoreStatus: (serverId: string, workspaceId: string) => void;
// Agent activity timestamps // Agent activity timestamps
setAgentLastActivity: (agentId: string, timestamp: Date) => void; setAgentLastActivity: (agentId: string, timestamp: Date) => void;
setAgentLastActivityBatch: ( setAgentLastActivityBatch: (
@@ -567,7 +555,6 @@ function createInitialSessionState(serverId: string, client: DaemonClient): Sess
agentDetails: new Map(), agentDetails: new Map(),
workspaces: new Map(), workspaces: new Map(),
emptyProjects: new Map(), emptyProjects: new Map(),
restoringWorkspaces: new Map(),
pendingPermissions: new Map(), pendingPermissions: new Map(),
fileExplorer: new Map(), fileExplorer: new Map(),
queuedMessages: new Map(), queuedMessages: new Map(),
@@ -1331,54 +1318,6 @@ export const useSessionStore = create<SessionStore>()(
}); });
}, },
setWorkspaceRestoreStatus: (serverId, workspaceId, status) => {
set((prev) => {
const session = prev.sessions[serverId];
if (!session) {
return prev;
}
if (session.restoringWorkspaces.get(workspaceId) === status) {
return prev;
}
// A late dir-gone timeout must not override a successful restore:
// only mark failed while still restoring and the descriptor is absent.
if (
status === "failed" &&
(session.restoringWorkspaces.get(workspaceId) !== "restoring" ||
session.workspaces.has(workspaceId))
) {
return prev;
}
const next = new Map(session.restoringWorkspaces);
next.set(workspaceId, status);
return {
...prev,
sessions: {
...prev.sessions,
[serverId]: { ...session, restoringWorkspaces: next },
},
};
});
},
clearWorkspaceRestoreStatus: (serverId, workspaceId) => {
set((prev) => {
const session = prev.sessions[serverId];
if (!session || !session.restoringWorkspaces.has(workspaceId)) {
return prev;
}
const next = new Map(session.restoringWorkspaces);
next.delete(workspaceId);
return {
...prev,
sessions: {
...prev.sessions,
[serverId]: { ...session, restoringWorkspaces: next },
},
};
});
},
mergeWorkspaces: (serverId, workspaces) => { mergeWorkspaces: (serverId, workspaces) => {
const nextEntries = Array.from(workspaces); const nextEntries = Array.from(workspaces);
set((prev) => { set((prev) => {
@@ -1392,18 +1331,10 @@ export const useSessionStore = create<SessionStore>()(
// empty: prune any stale empty descriptor so it stops governing the // empty: prune any stale empty descriptor so it stops governing the
// project's rendered metadata. // project's rendered metadata.
const nextEmptyProjects = new Map(session.emptyProjects); const nextEmptyProjects = new Map(session.emptyProjects);
// A descriptor arriving is the success signal for a pending restore:
// clear it at the source so every entry point converges to "ready".
let nextRestoring: Map<string, WorkspaceRestoreStatus> | null = null;
for (const workspace of nextEntries) { for (const workspace of nextEntries) {
if (nextEmptyProjects.delete(workspace.projectId)) { if (nextEmptyProjects.delete(workspace.projectId)) {
changed = true; changed = true;
} }
if (session.restoringWorkspaces.has(workspace.id)) {
nextRestoring ??= new Map(session.restoringWorkspaces);
nextRestoring.delete(workspace.id);
changed = true;
}
const existing = next.get(workspace.id); const existing = next.get(workspace.id);
const nextWorkspace = preserveWorkspaceDescriptorIdentity(workspace, existing); const nextWorkspace = preserveWorkspaceDescriptorIdentity(workspace, existing);
if (existing === nextWorkspace) { if (existing === nextWorkspace) {
@@ -1423,7 +1354,6 @@ export const useSessionStore = create<SessionStore>()(
...session, ...session,
workspaces: next, workspaces: next,
emptyProjects: nextEmptyProjects, emptyProjects: nextEmptyProjects,
restoringWorkspaces: nextRestoring ?? session.restoringWorkspaces,
}, },
}, },
}; };
@@ -1642,14 +1572,3 @@ export const useSessionStore = create<SessionStore>()(
}; };
}), }),
); );
export function useWorkspaceRestoreStatus(
serverId: string | null,
workspaceId: string | null,
): WorkspaceRestoreStatus | null {
return useSessionStore((state) =>
serverId && workspaceId
? (state.sessions[serverId]?.restoringWorkspaces.get(workspaceId) ?? null)
: null,
);
}

View File

@@ -174,11 +174,16 @@ export function parseWorkspaceOpenIntent(
export function parseHostWorkspaceOpenIntentFromPathname( export function parseHostWorkspaceOpenIntentFromPathname(
pathname: string, pathname: string,
): WorkspaceOpenIntent | null { ): WorkspaceOpenIntent | null {
return parseWorkspaceOpenIntent(getHostWorkspaceOpenParamFromPathname(pathname));
}
export function getHostWorkspaceOpenParamFromPathname(pathname: string): string | null {
const search = extractSearch(pathname); const search = extractSearch(pathname);
if (!search) { if (!search) {
return null; return null;
} }
return parseWorkspaceOpenIntent(new URLSearchParams(search).get("open")); const open = new URLSearchParams(search).get("open");
return parseWorkspaceOpenIntent(open) ? open : null;
} }
export function encodeWorkspaceIdForPathSegment(workspaceId: string): string { export function encodeWorkspaceIdForPathSegment(workspaceId: string): string {

View File

@@ -1,62 +1,10 @@
import { router, type Href } from "expo-router"; import { router, type Href } from "expo-router";
import { useSessionStore } from "@/stores/session-store";
import { getHostRuntimeStore, isHostRuntimeConnected } from "@/runtime/host-runtime";
import { resolveNavigateToAgent, type NavigateToAgentInput } from "./resolve";
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store"; import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
import { useSessionStore } from "@/stores/session-store";
import { resolveNavigateToAgent, type NavigateToAgentInput } from "./resolve";
export type { NavigateToAgentInput } from "./resolve"; export type { NavigateToAgentInput } from "./resolve";
// Clears the transient restoring state if the daemon resolves refreshAgent without
// re-emitting a workspace_update (the directory-gone case), so the gate never spins
// forever. Recreating a worktree can require a git fetch, so the budget is generous
// to avoid flashing a false "failed" on a capable daemon doing slow real work.
const RESTORE_TIMEOUT_MS = 30000;
function restoreArchivedWorkspace(serverId: string, agentId: string, workspaceId: string): void {
const snapshot = getHostRuntimeStore().getSnapshot(serverId);
const client = snapshot?.client ?? null;
if (!client || !isHostRuntimeConnected(snapshot)) {
return;
}
const store = useSessionStore.getState();
const session = store.sessions[serverId];
// Self-gate: only an archived agent whose workspace is absent needs restoring.
// A still-present workspace or an in-flight restore is a no-op; fire-once is
// derived from store state.
const agent = session?.agents.get(agentId) ?? session?.agentDetails.get(agentId);
if (!agent?.archivedAt) {
return;
}
if (session?.workspaces.has(workspaceId)) {
return;
}
if (session?.restoringWorkspaces.get(workspaceId) === "restoring") {
return;
}
// COMPAT(worktreeRestore): added in v0.1.97, drop the gate when floor >= v0.1.97
// Single capability read for restore. An old daemon recreates nothing on
// refresh_agent, so a gone directory would spin then flash a misleading
// "couldn't restore". Surface an explicit "update your host" state instead.
if (session?.serverInfo?.features?.worktreeRestore !== true) {
store.setWorkspaceRestoreStatus(serverId, workspaceId, "needs-host-upgrade");
return;
}
store.setWorkspaceRestoreStatus(serverId, workspaceId, "restoring");
// The reducer guards "failed" so a late timeout after the descriptor lands is a no-op.
setTimeout(
() => useSessionStore.getState().setWorkspaceRestoreStatus(serverId, workspaceId, "failed"),
RESTORE_TIMEOUT_MS,
);
client
.refreshAgent(agentId)
.catch(() =>
useSessionStore.getState().setWorkspaceRestoreStatus(serverId, workspaceId, "failed"),
);
}
export function navigateToAgent(input: NavigateToAgentInput): string { export function navigateToAgent(input: NavigateToAgentInput): string {
return resolveNavigateToAgent(input, { return resolveNavigateToAgent(input, {
readAgentNavTarget: ({ serverId, agentId }) => { readAgentNavTarget: ({ serverId, agentId }) => {
@@ -70,8 +18,5 @@ export function navigateToAgent(input: NavigateToAgentInput): string {
router.navigate(route as Href); router.navigate(route as Href);
}, },
navigateToWorkspace, navigateToWorkspace,
restoreArchivedWorkspace: ({ serverId, agentId, workspaceId }) => {
restoreArchivedWorkspace(serverId, agentId, workspaceId);
},
}); });
} }

View File

@@ -16,25 +16,16 @@ interface RecordedHostNav {
interface RecordedTabNav extends NavigateToWorkspaceInput {} interface RecordedTabNav extends NavigateToWorkspaceInput {}
interface RecordedRestore {
serverId: string;
agentId: string;
workspaceId: string;
}
function createFakeNavigators(target: AgentNavTarget): { function createFakeNavigators(target: AgentNavTarget): {
deps: NavigateToAgentDeps; deps: NavigateToAgentDeps;
hostNavigations: RecordedHostNav[]; hostNavigations: RecordedHostNav[];
tabNavigations: RecordedTabNav[]; tabNavigations: RecordedTabNav[];
restores: RecordedRestore[];
} { } {
const hostNavigations: RecordedHostNav[] = []; const hostNavigations: RecordedHostNav[] = [];
const tabNavigations: RecordedTabNav[] = []; const tabNavigations: RecordedTabNav[] = [];
const restores: RecordedRestore[] = [];
return { return {
hostNavigations, hostNavigations,
tabNavigations, tabNavigations,
restores,
deps: { deps: {
readAgentNavTarget: () => target, readAgentNavTarget: () => target,
navigateToHostAgent: (route) => { navigateToHostAgent: (route) => {
@@ -44,9 +35,6 @@ function createFakeNavigators(target: AgentNavTarget): {
tabNavigations.push(input); tabNavigations.push(input);
return `/h/${input.serverId}/workspace/${input.workspaceId}`; return `/h/${input.serverId}/workspace/${input.workspaceId}`;
}, },
restoreArchivedWorkspace: (input) => {
restores.push(input);
},
}, },
}; };
} }
@@ -74,19 +62,6 @@ describe("resolveNavigateToAgent", () => {
]); ]);
}); });
it("delegates a restore attempt whenever a workspaceId resolves", () => {
const { deps, restores, tabNavigations } = createFakeNavigators({
agentWorkspaceId: WORKSPACE_ID,
});
resolveNavigateToAgent({ serverId: SERVER_ID, agentId: AGENT_ID, pin: true }, deps);
expect(restores).toEqual([
{ serverId: SERVER_ID, agentId: AGENT_ID, workspaceId: WORKSPACE_ID },
]);
expect(tabNavigations).toHaveLength(1);
});
it("uses the input workspaceId without reading the nav target", () => { it("uses the input workspaceId without reading the nav target", () => {
const readTargets: { serverId: string; agentId: string }[] = []; const readTargets: { serverId: string; agentId: string }[] = [];
const { deps, tabNavigations } = createFakeNavigators({ agentWorkspaceId: null }); const { deps, tabNavigations } = createFakeNavigators({ agentWorkspaceId: null });
@@ -111,17 +86,6 @@ describe("resolveNavigateToAgent", () => {
]); ]);
}); });
it("does not trigger a restore when no workspaceId resolves", () => {
const { deps, restores, hostNavigations } = createFakeNavigators({
agentWorkspaceId: null,
});
resolveNavigateToAgent({ serverId: SERVER_ID, agentId: AGENT_ID }, deps);
expect(restores).toEqual([]);
expect(hostNavigations).toHaveLength(1);
});
it("falls back to the host agent route when the agent has no workspaceId", () => { it("falls back to the host agent route when the agent has no workspaceId", () => {
const { deps, hostNavigations, tabNavigations } = createFakeNavigators({ const { deps, hostNavigations, tabNavigations } = createFakeNavigators({
agentWorkspaceId: null, agentWorkspaceId: null,

View File

@@ -19,11 +19,6 @@ export interface NavigateToAgentDeps {
readAgentNavTarget: (input: { serverId: string; agentId: string }) => AgentNavTarget; readAgentNavTarget: (input: { serverId: string; agentId: string }) => AgentNavTarget;
navigateToHostAgent: (route: string) => void; navigateToHostAgent: (route: string) => void;
navigateToWorkspace: (input: NavigateToWorkspaceInput) => string; navigateToWorkspace: (input: NavigateToWorkspaceInput) => string;
restoreArchivedWorkspace: (input: {
serverId: string;
agentId: string;
workspaceId: string;
}) => void;
} }
export function resolveNavigateToAgent( export function resolveNavigateToAgent(
@@ -41,14 +36,6 @@ export function resolveNavigateToAgent(
return route; return route;
} }
// Restore self-gates on the agent being archived with its workspace absent, so
// ordinary navigations are a cheap no-op.
deps.restoreArchivedWorkspace({
serverId: input.serverId,
agentId: input.agentId,
workspaceId,
});
return deps.navigateToWorkspace({ return deps.navigateToWorkspace({
serverId: input.serverId, serverId: input.serverId,
workspaceId, workspaceId,

View File

@@ -1,206 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { HostRuntimeSnapshot } from "@/runtime/host-runtime";
const refreshAgent = vi.fn<(agentId: string) => Promise<unknown>>();
let connected = true;
vi.mock("expo-router", () => ({
router: { navigate: vi.fn() },
}));
vi.mock("@/stores/navigation-active-workspace-store", () => ({
navigateToWorkspace: vi.fn(() => ""),
}));
vi.mock("@/runtime/host-runtime", () => ({
getHostRuntimeStore: () => ({
getSnapshot: () => ({ client: { refreshAgent } }) as unknown as HostRuntimeSnapshot,
}),
isHostRuntimeConnected: () => connected,
}));
import { useSessionStore, type Agent } from "@/stores/session-store";
import { navigateToAgent } from "./index";
const SERVER_ID = "server-1";
const AGENT_ID = "agent-1";
const WORKSPACE_ID = "workspace-1";
function agent(archivedAt: Date | null): Agent {
const createdAt = new Date("2026-01-01T00:00:00.000Z");
return {
serverId: SERVER_ID,
id: AGENT_ID,
provider: "codex",
status: "idle",
createdAt,
updatedAt: createdAt,
lastUserMessageAt: null,
lastActivityAt: createdAt,
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
currentModeId: null,
availableModes: [],
pendingPermissions: [],
persistence: null,
title: null,
cwd: "/repo",
workspaceId: WORKSPACE_ID,
model: null,
archivedAt,
parentAgentId: null,
labels: {},
};
}
function status(): "restoring" | "failed" | "needs-host-upgrade" | null {
return (
useSessionStore.getState().sessions[SERVER_ID]?.restoringWorkspaces.get(WORKSPACE_ID) ?? null
);
}
function seedArchivedAgent(options?: { worktreeRestore?: boolean }): void {
const store = useSessionStore.getState();
store.initializeSession(SERVER_ID, null as unknown as DaemonClient);
store.updateSessionServerInfo(SERVER_ID, {
serverId: SERVER_ID,
hostname: "host",
version: "0.1.98",
features: { worktreeRestore: options?.worktreeRestore ?? true },
} as unknown as Parameters<typeof store.updateSessionServerInfo>[1]);
store.setAgents(SERVER_ID, (prev) => {
const next = new Map(prev);
next.set(AGENT_ID, agent(new Date("2026-01-02T00:00:00.000Z")));
return next;
});
}
describe("restoreArchivedWorkspace via navigateToAgent", () => {
beforeEach(() => {
vi.useFakeTimers();
refreshAgent.mockReset();
connected = true;
seedArchivedAgent();
});
afterEach(() => {
vi.clearAllTimers();
vi.useRealTimers();
useSessionStore.getState().clearSession(SERVER_ID);
});
function trigger(): void {
navigateToAgent({ serverId: SERVER_ID, agentId: AGENT_ID });
}
it("calls refreshAgent once and marks the workspace restoring", () => {
refreshAgent.mockImplementation(() => new Promise(() => {}));
trigger();
expect(refreshAgent).toHaveBeenCalledTimes(1);
expect(refreshAgent).toHaveBeenCalledWith(AGENT_ID);
expect(status()).toBe("restoring");
});
it("does not re-fire while a restore for the same workspace is in flight", () => {
refreshAgent.mockImplementation(() => new Promise(() => {}));
trigger();
trigger();
trigger();
expect(refreshAgent).toHaveBeenCalledTimes(1);
});
it("does not fire for a non-archived agent", () => {
const store = useSessionStore.getState();
store.setAgents(SERVER_ID, (prev) => {
const next = new Map(prev);
next.set(AGENT_ID, agent(null));
return next;
});
refreshAgent.mockImplementation(() => new Promise(() => {}));
trigger();
expect(refreshAgent).not.toHaveBeenCalled();
expect(status()).toBeNull();
});
it("does not fire while disconnected", () => {
connected = false;
refreshAgent.mockImplementation(() => new Promise(() => {}));
trigger();
expect(refreshAgent).not.toHaveBeenCalled();
expect(status()).toBeNull();
});
it("flips to failed when refreshAgent rejects", async () => {
refreshAgent.mockImplementation(() => Promise.reject(new Error("dir gone")));
trigger();
expect(status()).toBe("restoring");
await vi.runAllTicks();
await Promise.resolve();
expect(status()).toBe("failed");
});
it("flips to failed via the timeout when refreshAgent resolves without a workspace update", async () => {
refreshAgent.mockImplementation(() => Promise.resolve({}));
trigger();
await Promise.resolve();
expect(status()).toBe("restoring");
await vi.advanceTimersByTimeAsync(30000);
expect(status()).toBe("failed");
});
it("marks the workspace needs-host-upgrade without refreshing when the daemon lacks worktreeRestore", () => {
useSessionStore.getState().clearSession(SERVER_ID);
seedArchivedAgent({ worktreeRestore: false });
refreshAgent.mockImplementation(() => new Promise(() => {}));
trigger();
expect(refreshAgent).not.toHaveBeenCalled();
expect(status()).toBe("needs-host-upgrade");
});
it("is a no-op when the workspace descriptor is already present", () => {
refreshAgent.mockImplementation(() => new Promise(() => {}));
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [
{
id: WORKSPACE_ID,
projectId: "project-1",
projectDisplayName: "Project 1",
projectRootPath: "/repo",
workspaceDirectory: "/repo",
projectKind: "git",
workspaceKind: "local_checkout",
name: "main",
status: "done",
statusEnteredAt: null,
archivingAt: null,
diffStat: null,
scripts: [],
},
]);
trigger();
expect(refreshAgent).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { resolveWorkspaceRecoveryModel } from "./model";
describe("resolveWorkspaceRecoveryModel", () => {
it("keeps newer recovery actions visible but non-actionable", () => {
expect(
resolveWorkspaceRecoveryModel({
enabled: true,
connected: true,
hasClient: true,
hasServerInfo: true,
supportsRecovery: true,
inspection: {
pending: false,
error: null,
data: {
kind: "recoverable",
workspaceId: "workspace-1",
workspaceName: "Feature branch",
action: "repair_from_snapshot",
branch: "feature",
},
},
restore: { pending: false, error: null },
}),
).toEqual({
kind: "unsupportedAction",
action: "repair_from_snapshot",
});
});
});

View File

@@ -0,0 +1,114 @@
import type { WorkspaceRecoveryState as AuthoritativeWorkspaceRecoveryState } from "@getpaseo/protocol/messages";
type AuthoritativeRecoverableWorkspace = Extract<
AuthoritativeWorkspaceRecoveryState,
{ kind: "recoverable" }
>;
export type SupportedWorkspaceRecoveryAction = "unarchive" | "restore";
type SupportedRecoverableWorkspace = Omit<AuthoritativeRecoverableWorkspace, "action"> & {
action: SupportedWorkspaceRecoveryAction;
};
export type WorkspaceRecoveryModel =
| { kind: "idle" }
| { kind: "checking" }
| { kind: "needsHostUpgrade" }
| {
kind: "recoverable";
recovery: SupportedRecoverableWorkspace;
phase: "ready" | "restoring" | "failed";
error: string | null;
}
| { kind: "unsupportedAction"; action: string }
| {
kind: "unavailable";
recovery: Extract<AuthoritativeWorkspaceRecoveryState, { kind: "unavailable" }>;
}
| { kind: "inspectionFailed"; error: string };
export interface WorkspaceRecoveryController {
state: WorkspaceRecoveryModel;
restore: () => void;
retryInspection: () => void;
}
function resolveRecoveryPhase(input: {
pending: boolean;
error: string | null;
}): "ready" | "restoring" | "failed" {
if (input.pending) {
return "restoring";
}
if (input.error) {
return "failed";
}
return "ready";
}
function getSupportedRecovery(
recovery: AuthoritativeWorkspaceRecoveryState | undefined,
): SupportedRecoverableWorkspace | null {
if (recovery?.kind !== "recoverable") {
return null;
}
if (recovery.action !== "restore" && recovery.action !== "unarchive") {
return null;
}
return { ...recovery, action: recovery.action };
}
export function resolveWorkspaceRecoveryModel(input: {
enabled: boolean;
connected: boolean;
hasClient: boolean;
hasServerInfo: boolean;
supportsRecovery: boolean;
inspection: {
pending: boolean;
error: string | null;
data: AuthoritativeWorkspaceRecoveryState | undefined;
};
restore: { pending: boolean; error: string | null };
}): WorkspaceRecoveryModel {
const supportedRecovery = getSupportedRecovery(input.inspection.data);
if (input.restore.pending && supportedRecovery) {
return {
kind: "recoverable",
recovery: supportedRecovery,
phase: "restoring",
error: null,
};
}
if (!input.enabled || !input.connected || !input.hasClient) {
return { kind: "idle" };
}
if (!input.hasServerInfo) {
return { kind: "checking" };
}
if (!input.supportsRecovery) {
return { kind: "needsHostUpgrade" };
}
if (input.inspection.pending) {
return { kind: "checking" };
}
if (input.inspection.error) {
return { kind: "inspectionFailed", error: input.inspection.error };
}
if (input.inspection.data?.kind === "unavailable") {
return { kind: "unavailable", recovery: input.inspection.data };
}
if (input.inspection.data?.kind === "recoverable" && !supportedRecovery) {
return { kind: "unsupportedAction", action: input.inspection.data.action };
}
if (supportedRecovery) {
return {
kind: "recoverable",
recovery: supportedRecovery,
phase: resolveRecoveryPhase(input.restore),
error: input.restore.error,
};
}
return { kind: "checking" };
}

View File

@@ -0,0 +1,105 @@
import { useCallback, useMemo } from "react";
import { useMutation } from "@tanstack/react-query";
import { useFetchQuery } from "@/data/query";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store";
import { toErrorMessage } from "@/utils/error-messages";
import { resolveWorkspaceRecoveryModel, type WorkspaceRecoveryController } from "./model";
export type { WorkspaceRecoveryController, WorkspaceRecoveryModel } from "./model";
const MIN_RECOVERY_LOADING_MS = 350;
function waitForAnimationFrame(): Promise<void> {
return new Promise((resolve) => requestAnimationFrame(() => resolve()));
}
async function waitForRecoveryLoadingPresentation(): Promise<void> {
// The first callback runs before paint. Waiting for the following frame gives
// React a paint opportunity before a fast daemon response adds the workspace.
await waitForAnimationFrame();
await waitForAnimationFrame();
}
function waitForMinimumRecoveryLoadingTime(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, MIN_RECOVERY_LOADING_MS));
}
export function useWorkspaceRecovery(input: {
serverId: string;
workspaceId: string;
enabled: boolean;
}): WorkspaceRecoveryController {
const client = useHostRuntimeClient(input.serverId);
const isConnected = useHostRuntimeIsConnected(input.serverId);
const serverInfo = useSessionStore((store) => store.sessions[input.serverId]?.serverInfo ?? null);
const supportsRecovery = serverInfo?.features?.workspaceRecovery === true;
const inspection = useFetchQuery({
queryKey: ["workspaceRecovery", input.serverId, input.workspaceId],
dataShape: "value",
staleTimeMs: 5_000,
enabled: Boolean(input.enabled && client && isConnected && supportsRecovery),
queryFn: async () => {
if (!client) {
throw new Error("The host client is unavailable.");
}
return client.inspectWorkspaceRecovery(input.workspaceId);
},
retry: false,
});
const restoreMutation = useMutation({
mutationFn: async () => {
if (!client || !isConnected) {
throw new Error("The host disconnected before the workspace could be recovered.");
}
await waitForRecoveryLoadingPresentation();
await waitForMinimumRecoveryLoadingTime();
await client.restoreWorkspace(input.workspaceId);
},
});
const state = useMemo(
() =>
resolveWorkspaceRecoveryModel({
enabled: input.enabled,
connected: isConnected,
hasClient: client !== null,
hasServerInfo: serverInfo !== null,
supportsRecovery,
inspection: {
pending: inspection.isPending,
error: inspection.isError ? toErrorMessage(inspection.error) : null,
data: inspection.data,
},
restore: {
pending: restoreMutation.isPending,
error: restoreMutation.isError ? toErrorMessage(restoreMutation.error) : null,
},
}),
[
client,
input.enabled,
inspection.data,
inspection.error,
inspection.isError,
inspection.isPending,
isConnected,
restoreMutation.error,
restoreMutation.isError,
restoreMutation.isPending,
serverInfo,
supportsRecovery,
],
);
const restore = useCallback(() => {
restoreMutation.mutate();
}, [restoreMutation]);
const retryInspection = useCallback(() => {
void inspection.refetch();
}, [inspection]);
return { state, restore, retryInspection };
}

View File

@@ -89,6 +89,7 @@ import type {
PaseoConfigRaw, PaseoConfigRaw,
PaseoConfigRevision, PaseoConfigRevision,
WorkspaceCreateRequest, WorkspaceCreateRequest,
WorkspaceRecoveryState,
} from "@getpaseo/protocol/messages"; } from "@getpaseo/protocol/messages";
import type { import type {
AgentPermissionRequest, AgentPermissionRequest,
@@ -2451,6 +2452,36 @@ export class DaemonClient {
return { pinnedAt: payload.pinnedAt }; return { pinnedAt: payload.pinnedAt };
} }
async inspectWorkspaceRecovery(
workspaceId: string,
requestId?: string,
): Promise<WorkspaceRecoveryState> {
const payload =
await this.sendNamespacedCorrelatedSessionRequest<"workspace.recovery.inspect.response">({
requestId,
message: {
type: "workspace.recovery.inspect.request",
workspaceId,
},
});
return payload.state;
}
async restoreWorkspace(workspaceId: string, requestId?: string): Promise<void> {
const payload =
await this.sendNamespacedCorrelatedSessionRequest<"workspace.recovery.restore.response">({
requestId,
message: {
type: "workspace.recovery.restore.request",
workspaceId,
},
timeout: 150_000,
});
if (!payload.accepted) {
throw new Error(payload.error ?? "Workspace recovery was rejected by the host");
}
}
async resumeAgent( async resumeAgent(
handle: AgentPersistenceHandle, handle: AgentPersistenceHandle,
overrides?: Partial<AgentSessionConfig>, overrides?: Partial<AgentSessionConfig>,

View File

@@ -840,6 +840,18 @@ export const WorkspacePinSetRequestSchema = z.object({
requestId: z.string(), requestId: z.string(),
}); });
export const WorkspaceRecoveryInspectRequestSchema = z.object({
type: z.literal("workspace.recovery.inspect.request"),
workspaceId: z.string(),
requestId: z.string(),
});
export const WorkspaceRecoveryRestoreRequestSchema = z.object({
type: z.literal("workspace.recovery.restore.request"),
workspaceId: z.string(),
requestId: z.string(),
});
export const SetVoiceModeMessageSchema = z.object({ export const SetVoiceModeMessageSchema = z.object({
type: z.literal("set_voice_mode"), type: z.literal("set_voice_mode"),
enabled: z.boolean(), enabled: z.boolean(),
@@ -1473,6 +1485,40 @@ export const WorkspacePinSetResponseSchema = z.object({
payload: WorkspacePinSetResponsePayloadSchema, payload: WorkspacePinSetResponsePayloadSchema,
}); });
export const WorkspaceRecoveryStateSchema = z.discriminatedUnion("kind", [
z.object({
kind: z.literal("recoverable"),
workspaceId: z.string(),
workspaceName: z.string(),
action: z.string(),
branch: z.string().nullable(),
}),
z.object({
kind: z.literal("unavailable"),
workspaceId: z.string(),
reason: z.string(),
message: z.string(),
}),
]);
export const WorkspaceRecoveryInspectResponseSchema = z.object({
type: z.literal("workspace.recovery.inspect.response"),
payload: z.object({
requestId: z.string(),
state: WorkspaceRecoveryStateSchema,
}),
});
export const WorkspaceRecoveryRestoreResponseSchema = z.object({
type: z.literal("workspace.recovery.restore.response"),
payload: z.object({
requestId: z.string(),
workspaceId: z.string(),
accepted: z.boolean(),
error: z.string().nullable(),
}),
});
export const SetVoiceModeResponseMessageSchema = z.object({ export const SetVoiceModeResponseMessageSchema = z.object({
type: z.literal("set_voice_mode_response"), type: z.literal("set_voice_mode_response"),
payload: z.object({ payload: z.object({
@@ -2145,6 +2191,8 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
ProjectRemoveRequestSchema, ProjectRemoveRequestSchema,
WorkspaceTitleSetRequestSchema, WorkspaceTitleSetRequestSchema,
WorkspacePinSetRequestSchema, WorkspacePinSetRequestSchema,
WorkspaceRecoveryInspectRequestSchema,
WorkspaceRecoveryRestoreRequestSchema,
SetVoiceModeMessageSchema, SetVoiceModeMessageSchema,
SendAgentMessageRequestSchema, SendAgentMessageRequestSchema,
WaitForFinishRequestSchema, WaitForFinishRequestSchema,
@@ -2451,6 +2499,8 @@ export const ServerInfoStatusPayloadSchema = z
projectAdd: z.boolean().optional(), projectAdd: z.boolean().optional(),
// COMPAT(worktreeRestore): added in v0.1.97, drop the gate when floor >= v0.1.97 // COMPAT(worktreeRestore): added in v0.1.97, drop the gate when floor >= v0.1.97
worktreeRestore: z.boolean().optional(), worktreeRestore: z.boolean().optional(),
// COMPAT(workspaceRecovery): added in v0.1.105, remove after 2027-01-11 once daemon floor >= v0.1.105.
workspaceRecovery: z.boolean().optional(),
// COMPAT(providerUsageList): added in v0.1.98, drop the gate when daemon floor >= v0.1.98. // COMPAT(providerUsageList): added in v0.1.98, drop the gate when daemon floor >= v0.1.98.
providerUsageList: z.boolean().optional(), providerUsageList: z.boolean().optional(),
// COMPAT(agentDetach): added in v0.1.98, remove gate after 2026-12-19 once daemon floor >= v0.1.98. // COMPAT(agentDetach): added in v0.1.98, remove gate after 2026-12-19 once daemon floor >= v0.1.98.
@@ -4500,6 +4550,8 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
ProjectRemoveResponseSchema, ProjectRemoveResponseSchema,
WorkspaceTitleSetResponseSchema, WorkspaceTitleSetResponseSchema,
WorkspacePinSetResponseSchema, WorkspacePinSetResponseSchema,
WorkspaceRecoveryInspectResponseSchema,
WorkspaceRecoveryRestoreResponseSchema,
WaitForFinishResponseMessageSchema, WaitForFinishResponseMessageSchema,
AgentPermissionRequestMessageSchema, AgentPermissionRequestMessageSchema,
AgentPermissionResolvedMessageSchema, AgentPermissionResolvedMessageSchema,
@@ -4664,6 +4716,13 @@ export type WorkspaceTitleSetResponsePayload = z.infer<
>; >;
export type WorkspacePinSetResponse = z.infer<typeof WorkspacePinSetResponseSchema>; export type WorkspacePinSetResponse = z.infer<typeof WorkspacePinSetResponseSchema>;
export type WorkspacePinSetResponsePayload = z.infer<typeof WorkspacePinSetResponsePayloadSchema>; export type WorkspacePinSetResponsePayload = z.infer<typeof WorkspacePinSetResponsePayloadSchema>;
export type WorkspaceRecoveryState = z.infer<typeof WorkspaceRecoveryStateSchema>;
export type WorkspaceRecoveryInspectResponse = z.infer<
typeof WorkspaceRecoveryInspectResponseSchema
>;
export type WorkspaceRecoveryRestoreResponse = z.infer<
typeof WorkspaceRecoveryRestoreResponseSchema
>;
export type WorkspaceCreateRequest = z.infer<typeof WorkspaceCreateRequestSchema>; export type WorkspaceCreateRequest = z.infer<typeof WorkspaceCreateRequestSchema>;
export type WorkspaceCreateResponse = z.infer<typeof WorkspaceCreateResponseSchema>; export type WorkspaceCreateResponse = z.infer<typeof WorkspaceCreateResponseSchema>;
export type ProjectRenameResponsePayload = z.infer<typeof ProjectRenameResponsePayloadSchema>; export type ProjectRenameResponsePayload = z.infer<typeof ProjectRenameResponsePayloadSchema>;
@@ -4797,6 +4856,8 @@ export type ProjectRenameRequest = z.infer<typeof ProjectRenameRequestSchema>;
export type ProjectRemoveRequest = z.infer<typeof ProjectRemoveRequestSchema>; export type ProjectRemoveRequest = z.infer<typeof ProjectRemoveRequestSchema>;
export type WorkspaceTitleSetRequest = z.infer<typeof WorkspaceTitleSetRequestSchema>; export type WorkspaceTitleSetRequest = z.infer<typeof WorkspaceTitleSetRequestSchema>;
export type WorkspacePinSetRequest = z.infer<typeof WorkspacePinSetRequestSchema>; export type WorkspacePinSetRequest = z.infer<typeof WorkspacePinSetRequestSchema>;
export type WorkspaceRecoveryInspectRequest = z.infer<typeof WorkspaceRecoveryInspectRequestSchema>;
export type WorkspaceRecoveryRestoreRequest = z.infer<typeof WorkspaceRecoveryRestoreRequestSchema>;
export type SetAgentModeRequestMessage = z.infer<typeof SetAgentModeRequestMessageSchema>; export type SetAgentModeRequestMessage = z.infer<typeof SetAgentModeRequestMessageSchema>;
export type SetAgentModelRequestMessage = z.infer<typeof SetAgentModelRequestMessageSchema>; export type SetAgentModelRequestMessage = z.infer<typeof SetAgentModelRequestMessageSchema>;
export type SetAgentThinkingRequestMessage = z.infer<typeof SetAgentThinkingRequestMessageSchema>; export type SetAgentThinkingRequestMessage = z.infer<typeof SetAgentThinkingRequestMessageSchema>;

View File

@@ -0,0 +1,135 @@
import { describe, expect, test } from "vitest";
import {
SessionInboundMessageSchema,
SessionOutboundMessageSchema,
ServerInfoStatusPayloadSchema,
} from "./messages.js";
describe("workspace recovery protocol", () => {
test("parses the read-only recovery inspection exchange", () => {
expect(
SessionInboundMessageSchema.parse({
type: "workspace.recovery.inspect.request",
requestId: "inspect-1",
workspaceId: "wks_15a1b5630ebaab33",
}),
).toMatchObject({ type: "workspace.recovery.inspect.request" });
expect(
SessionOutboundMessageSchema.parse({
type: "workspace.recovery.inspect.response",
payload: {
requestId: "inspect-1",
state: {
kind: "recoverable",
workspaceId: "wks_15a1b5630ebaab33",
workspaceName: "Codex TDD reproduction",
action: "restore",
branch: "diagnose-repro-tdd",
},
},
}),
).toMatchObject({
payload: { state: { kind: "recoverable", action: "restore" } },
});
});
test("parses explicit restore success and retryable failure responses", () => {
expect(
SessionInboundMessageSchema.parse({
type: "workspace.recovery.restore.request",
requestId: "restore-1",
workspaceId: "wks_15a1b5630ebaab33",
}),
).toMatchObject({ type: "workspace.recovery.restore.request" });
for (const payload of [
{
requestId: "restore-1",
workspaceId: "wks_15a1b5630ebaab33",
accepted: true,
error: null,
},
{
requestId: "restore-2",
workspaceId: "wks_15a1b5630ebaab33",
accepted: false,
error: "Project root is missing: /repo",
},
]) {
expect(
SessionOutboundMessageSchema.parse({
type: "workspace.recovery.restore.response",
payload,
}),
).toMatchObject({ payload });
}
});
test("keeps the capability optional for older daemons", () => {
expect(
ServerInfoStatusPayloadSchema.parse({
status: "server_info",
serverId: "old-host",
features: {},
}).features?.workspaceRecovery,
).toBeUndefined();
expect(
ServerInfoStatusPayloadSchema.parse({
status: "server_info",
serverId: "new-host",
features: { workspaceRecovery: true },
}).features?.workspaceRecovery,
).toBe(true);
});
test("accepts unavailable reasons added by newer daemons", () => {
expect(
SessionOutboundMessageSchema.parse({
type: "workspace.recovery.inspect.response",
payload: {
requestId: "inspect-future",
state: {
kind: "unavailable",
workspaceId: "workspace-1",
reason: "future_recovery_constraint",
message: "This host cannot recover the workspace yet.",
},
},
}),
).toMatchObject({
payload: {
state: {
kind: "unavailable",
reason: "future_recovery_constraint",
},
},
});
});
test("accepts recovery actions added by newer daemons", () => {
expect(
SessionOutboundMessageSchema.parse({
type: "workspace.recovery.inspect.response",
payload: {
requestId: "inspect-future-action",
state: {
kind: "recoverable",
workspaceId: "workspace-1",
workspaceName: "Feature branch",
action: "repair_from_snapshot",
branch: "feature",
},
},
}),
).toMatchObject({
payload: {
state: {
kind: "recoverable",
action: "repair_from_snapshot",
},
},
});
});
});

View File

@@ -1008,6 +1008,8 @@ test("receives server_info on websocket connect", async () => {
expect(serverInfo?.features?.["terminal-restore-modes"]).toBe(true); expect(serverInfo?.features?.["terminal-restore-modes"]).toBe(true);
expect(serverInfo?.desktopManaged).toBe(false); expect(serverInfo?.desktopManaged).toBe(false);
expect(serverInfo?.features?.daemonSelfUpdate).toBe(true); expect(serverInfo?.features?.daemonSelfUpdate).toBe(true);
expect(serverInfo?.features?.worktreeRestore).toBe(true);
expect(serverInfo?.features?.workspaceRecovery).toBe(true);
await client.close(); await client.close();
}, 15000); }, 15000);

View File

@@ -167,6 +167,10 @@ import {
createWorkspaceProvisioningService, createWorkspaceProvisioningService,
type WorkspaceProvisioningService, type WorkspaceProvisioningService,
} from "./session/workspace-provisioning/workspace-provisioning-service.js"; } from "./session/workspace-provisioning/workspace-provisioning-service.js";
import {
createWorkspaceRecoveryService,
type WorkspaceRecoveryService,
} from "./session/workspace-recovery/workspace-recovery-service.js";
import { import {
createAgentUpdatesService, createAgentUpdatesService,
matchesAgentUpdatesFilter, matchesAgentUpdatesFilter,
@@ -236,6 +240,7 @@ import { CreateAgentLifecycleDispatch } from "./agent/create-agent-lifecycle-dis
// the entire session message if they encounter an unknown provider. // the entire session message if they encounter an unknown provider.
const LEGACY_PROVIDER_IDS = new Set(["claude", "codex", "opencode"]); const LEGACY_PROVIDER_IDS = new Set(["claude", "codex", "opencode"]);
const MIN_VERSION_ALL_PROVIDERS = "0.1.45"; const MIN_VERSION_ALL_PROVIDERS = "0.1.45";
const MIN_VERSION_EXPLICIT_WORKSPACE_RECOVERY = "0.1.105";
function errorToFriendlyMessage(error: unknown): string { function errorToFriendlyMessage(error: unknown): string {
if (error instanceof Error) return error.message; if (error instanceof Error) return error.message;
if (typeof error === "string") return error; if (typeof error === "string") return error;
@@ -315,6 +320,12 @@ function clientSupportsAllProviders(appVersion: string | null): boolean {
return isAppVersionAtLeast(appVersion, MIN_VERSION_ALL_PROVIDERS); return isAppVersionAtLeast(appVersion, MIN_VERSION_ALL_PROVIDERS);
} }
function clientUsesLegacyWorkspaceRestore(appVersion: string | null): boolean {
return (
appVersion !== null && !isAppVersionAtLeast(appVersion, MIN_VERSION_EXPLICIT_WORKSPACE_RECOVERY)
);
}
type DeleteFencedAgentStorage = AgentStorage & { type DeleteFencedAgentStorage = AgentStorage & {
beginDelete(agentId: string): void; beginDelete(agentId: string): void;
}; };
@@ -420,6 +431,7 @@ export interface SessionOptions {
onBinaryMessage?: (frame: Uint8Array) => void; onBinaryMessage?: (frame: Uint8Array) => void;
getTransportBufferedAmount?: () => number | null; getTransportBufferedAmount?: () => number | null;
onLifecycleIntent?: (intent: SessionLifecycleIntent) => void; onLifecycleIntent?: (intent: SessionLifecycleIntent) => void;
onWorkspaceRecovered?: (workspace: PersistedWorkspaceRecord) => Promise<void>;
logger: pino.Logger; logger: pino.Logger;
downloadTokenStore: DownloadTokenStore; downloadTokenStore: DownloadTokenStore;
pushTokenStore: PushTokenStore; pushTokenStore: PushTokenStore;
@@ -548,6 +560,9 @@ export class Session {
private readonly onBinaryMessage: ((frame: Uint8Array) => void) | null; private readonly onBinaryMessage: ((frame: Uint8Array) => void) | null;
private readonly getTransportBufferedAmount: () => number | null; private readonly getTransportBufferedAmount: () => number | null;
private readonly onLifecycleIntent: ((intent: SessionLifecycleIntent) => void) | null; private readonly onLifecycleIntent: ((intent: SessionLifecycleIntent) => void) | null;
private readonly onWorkspaceRecovered:
| ((workspace: PersistedWorkspaceRecord) => Promise<void>)
| null;
private readonly sessionLogger: pino.Logger; private readonly sessionLogger: pino.Logger;
private readonly paseoHome: string; private readonly paseoHome: string;
private readonly worktreesRoot: string | undefined; private readonly worktreesRoot: string | undefined;
@@ -563,6 +578,7 @@ export class Session {
private readonly workspaceAutoName: WorkspaceAutoName; private readonly workspaceAutoName: WorkspaceAutoName;
private readonly gitMutation: GitMutationService; private readonly gitMutation: GitMutationService;
private readonly workspaceProvisioning: WorkspaceProvisioningService; private readonly workspaceProvisioning: WorkspaceProvisioningService;
private readonly workspaceRecovery: WorkspaceRecoveryService;
private readonly daemonConfigStore: DaemonConfigStore; private readonly daemonConfigStore: DaemonConfigStore;
private readonly pushTokenStore: PushTokenStore; private readonly pushTokenStore: PushTokenStore;
private unsubscribeAgentEvents: (() => void) | null = null; private unsubscribeAgentEvents: (() => void) | null = null;
@@ -611,6 +627,7 @@ export class Session {
onBinaryMessage, onBinaryMessage,
getTransportBufferedAmount, getTransportBufferedAmount,
onLifecycleIntent, onLifecycleIntent,
onWorkspaceRecovered,
logger, logger,
downloadTokenStore, downloadTokenStore,
pushTokenStore, pushTokenStore,
@@ -660,6 +677,7 @@ export class Session {
this.onBinaryMessage = onBinaryMessage ?? null; this.onBinaryMessage = onBinaryMessage ?? null;
this.getTransportBufferedAmount = getTransportBufferedAmount ?? (() => 0); this.getTransportBufferedAmount = getTransportBufferedAmount ?? (() => 0);
this.onLifecycleIntent = onLifecycleIntent ?? null; this.onLifecycleIntent = onLifecycleIntent ?? null;
this.onWorkspaceRecovered = onWorkspaceRecovered ?? null;
this.pushTokenStore = pushTokenStore; this.pushTokenStore = pushTokenStore;
this.paseoHome = paseoHome; this.paseoHome = paseoHome;
this.worktreesRoot = worktreesRoot; this.worktreesRoot = worktreesRoot;
@@ -697,6 +715,15 @@ export class Session {
projectRegistry: this.projectRegistry, projectRegistry: this.projectRegistry,
workspaceGitService: this.workspaceGitService, workspaceGitService: this.workspaceGitService,
}); });
this.workspaceRecovery = createWorkspaceRecoveryService({
getWorkspace: (workspaceId) => this.workspaceRegistry.get(workspaceId),
getProject: (projectId) => this.projectRegistry.get(projectId),
isDirectory: (path) => this.filesystem.isDirectory(path),
recreateWorktree: (workspace) => this.recreateArchivedWorktree(workspace),
unarchiveWorkspace: async (workspace) => {
await this.workspaceProvisioning.ensureWorkspaceRecordUnarchived(workspace);
},
});
this.checkoutSession = new CheckoutSession({ this.checkoutSession = new CheckoutSession({
host: { host: {
emit: (msg) => this.emit(msg), emit: (msg) => this.emit(msg),
@@ -974,6 +1001,27 @@ export class Session {
await this.workspaceGitObserver.warmGitData(workspace); await this.workspaceGitObserver.warmGitData(workspace);
} }
async refreshRecoveredWorkspaceForExternalMutation(
workspace: PersistedWorkspaceRecord,
): Promise<void> {
try {
await this.workspaceGitObserver.warmGitData(workspace);
} catch (error) {
this.sessionLogger.warn(
{ err: error, workspaceId: workspace.workspaceId },
"Failed to warm git observer after workspace recovery",
);
try {
await this.emitWorkspaceUpdateForWorkspaceId(workspace.workspaceId);
} catch (emitError) {
this.sessionLogger.warn(
{ err: emitError, workspaceId: workspace.workspaceId },
"Failed to emit workspace update after recovery",
);
}
}
}
/** /**
* Get the client's current activity state * Get the client's current activity state
*/ */
@@ -1389,6 +1437,7 @@ export class Session {
this.dispatchAgentLifecycleMessage(msg) ?? this.dispatchAgentLifecycleMessage(msg) ??
this.dispatchAgentConfigMessage(msg) ?? this.dispatchAgentConfigMessage(msg) ??
this.dispatchCheckoutMessage(msg) ?? this.dispatchCheckoutMessage(msg) ??
this.dispatchWorkspaceRecoveryMessage(msg) ??
this.dispatchWorkspaceAndProjectMessage(msg) ?? this.dispatchWorkspaceAndProjectMessage(msg) ??
this.dispatchWorkspaceFileMessage(msg) ?? this.dispatchWorkspaceFileMessage(msg) ??
this.dispatchProviderMessage(msg) ?? this.dispatchProviderMessage(msg) ??
@@ -1685,6 +1734,17 @@ export class Session {
} }
} }
private dispatchWorkspaceRecoveryMessage(msg: SessionInboundMessage): Promise<void> | undefined {
switch (msg.type) {
case "workspace.recovery.inspect.request":
return this.handleWorkspaceRecoveryInspectRequest(msg);
case "workspace.recovery.restore.request":
return this.handleWorkspaceRecoveryRestoreRequest(msg);
default:
return undefined;
}
}
private dispatchProviderMessage(msg: SessionInboundMessage): Promise<void> | undefined { private dispatchProviderMessage(msg: SessionInboundMessage): Promise<void> | undefined {
switch (msg.type) { switch (msg.type) {
case "list_provider_models_request": case "list_provider_models_request":
@@ -2400,6 +2460,51 @@ export class Session {
} }
} }
private async handleWorkspaceRecoveryInspectRequest(
request: Extract<SessionInboundMessage, { type: "workspace.recovery.inspect.request" }>,
): Promise<void> {
const state = await this.workspaceRecovery.inspect(request.workspaceId);
this.emit({
type: "workspace.recovery.inspect.response",
payload: {
requestId: request.requestId,
state,
},
});
}
private async handleWorkspaceRecoveryRestoreRequest(
request: Extract<SessionInboundMessage, { type: "workspace.recovery.restore.request" }>,
): Promise<void> {
try {
await this.restoreWorkspaceAndEmit(request.workspaceId);
this.emit({
type: "workspace.recovery.restore.response",
payload: {
requestId: request.requestId,
workspaceId: request.workspaceId,
accepted: true,
error: null,
},
});
} catch (error) {
const message = getErrorMessageOr(error, "Failed to recover workspace");
this.sessionLogger.warn(
{ err: error, workspaceId: request.workspaceId, requestId: request.requestId },
"session: workspace.recovery.restore.request rejected",
);
this.emit({
type: "workspace.recovery.restore.response",
payload: {
requestId: request.requestId,
workspaceId: request.workspaceId,
accepted: false,
error: message,
},
});
}
}
/** /**
* Handle text message to agent (with optional image attachments) * Handle text message to agent (with optional image attachments)
*/ */
@@ -2762,8 +2867,8 @@ export class Session {
this.sessionLogger.info({ agentId }, `Refreshing agent ${agentId} from persistence`); this.sessionLogger.info({ agentId }, `Refreshing agent ${agentId} from persistence`);
try { try {
await this.restoreOwningWorkspaceForLegacyAgentRefresh(agentId);
await unarchiveAgentState(this.agentStorage, this.agentManager, agentId); await unarchiveAgentState(this.agentStorage, this.agentManager, agentId);
await this.unarchiveOwningWorkspaceForAgent(agentId);
let snapshot: ManagedAgent; let snapshot: ManagedAgent;
const existing = this.agentManager.getAgent(agentId); const existing = this.agentManager.getAgent(agentId);
if (existing) { if (existing) {
@@ -3873,35 +3978,14 @@ export class Session {
}; };
} }
private async unarchiveOwningWorkspaceForAgent(agentId: string): Promise<void> { private async recreateArchivedWorktree(workspace: PersistedWorkspaceRecord): Promise<void> {
const record = await this.agentStorage.get(agentId); const branch = workspace.branch;
if (!record?.workspaceId) { if (!branch) {
return; throw new WorktreeRequestError({
code: "unknown",
message: `Workspace ${workspace.workspaceId} has no branch to restore`,
});
} }
const workspace = await this.workspaceRegistry.get(record.workspaceId);
if (!workspace?.archivedAt) {
return;
}
const directoryExists = await this.filesystem.isDirectory(record.cwd).catch(() => false);
if (!directoryExists) {
if (workspace.kind !== "worktree" || !workspace.branch) {
return;
}
// Recreate the worktree directory from its kept branch BEFORE clearing
// archivedAt — the reconciler re-archives workspaces whose directory is
// missing, so the record must point at a real directory first.
await this.recreateOwningWorktreeForRestore(workspace, workspace.branch);
}
await this.workspaceProvisioning.ensureWorkspaceRecordUnarchived(workspace);
await this.emitWorkspaceUpdatesForWorkspaceIds([workspace.workspaceId]);
}
private async recreateOwningWorktreeForRestore(
workspace: PersistedWorkspaceRecord,
branch: string,
): Promise<void> {
const project = await this.projectRegistry.get(workspace.projectId); const project = await this.projectRegistry.get(workspace.projectId);
if (!project) { if (!project) {
throw new WorktreeRequestError({ throw new WorktreeRequestError({
@@ -3953,6 +4037,43 @@ export class Session {
} }
} }
private async restoreWorkspaceAndEmit(workspaceId: string): Promise<void> {
await this.workspaceRecovery.restore(workspaceId);
const workspace = await this.workspaceRegistry.get(workspaceId);
if (!workspace) {
throw new Error(`Recovered workspace record not found: ${workspaceId}`);
}
if (this.onWorkspaceRecovered) {
try {
await this.onWorkspaceRecovered(workspace);
return;
} catch (error) {
this.sessionLogger.warn(
{ err: error, workspaceId },
"Failed to publish workspace recovery to active sessions",
);
}
}
await this.refreshRecoveredWorkspaceForExternalMutation(workspace);
}
private async restoreOwningWorkspaceForLegacyAgentRefresh(agentId: string): Promise<void> {
// COMPAT(worktreeRestore): clients older than v0.1.105 used refresh_agent_request
// as their explicit recovery RPC. Remove after 2027-01-11.
if (!clientUsesLegacyWorkspaceRestore(this.appVersion)) {
return;
}
const record = await this.agentStorage.get(agentId);
if (!record?.workspaceId) {
return;
}
const recovery = await this.workspaceRecovery.inspect(record.workspaceId);
if (recovery.kind !== "recoverable") {
return;
}
await this.restoreWorkspaceAndEmit(record.workspaceId);
}
private async createPaseoWorktree( private async createPaseoWorktree(
input: CreatePaseoWorktreeInput, input: CreatePaseoWorktreeInput,
options?: { options?: {

View File

@@ -32,13 +32,13 @@ import type {
AgentSessionConfig, AgentSessionConfig,
AgentStreamEvent, AgentStreamEvent,
} from "./agent/agent-sdk-types.js"; } from "./agent/agent-sdk-types.js";
import { createWorktree, UnknownBranchError } from "../utils/worktree.js"; import { createWorktree } from "../utils/worktree.js";
import { import {
readPaseoWorktreeMetadata, readPaseoWorktreeMetadata,
writePaseoWorktreeFirstAgentBranchAutoNameMetadata, writePaseoWorktreeFirstAgentBranchAutoNameMetadata,
writePaseoWorktreeMetadata, writePaseoWorktreeMetadata,
} from "../utils/worktree-metadata.js"; } from "../utils/worktree-metadata.js";
import { WorktreeRequestError, toWorktreeRequestError } from "./worktree-errors.js"; import { WorktreeRequestError } from "./worktree-errors.js";
import type { WorkspaceGitRuntimeSnapshot } from "./workspace-git-service.js"; import type { WorkspaceGitRuntimeSnapshot } from "./workspace-git-service.js";
import type { GeneratedWorkspaceName } from "./worktree-branch-name-generator.js"; import type { GeneratedWorkspaceName } from "./worktree-branch-name-generator.js";
import { WorkspaceAutoName } from "./workspace-auto-name.js"; import { WorkspaceAutoName } from "./workspace-auto-name.js";
@@ -128,10 +128,7 @@ interface SessionTestAccess {
agentUpdates: AgentUpdatesService; agentUpdates: AgentUpdatesService;
workspaceUpdatesSubscription: unknown; workspaceUpdatesSubscription: unknown;
interruptAgentIfRunning(agentId: string): unknown; interruptAgentIfRunning(agentId: string): unknown;
recreateOwningWorktreeForRestore( recreateArchivedWorktree(workspace: PersistedWorkspaceRecord): Promise<void>;
workspace: PersistedWorkspaceRecord,
branch: string,
): Promise<void>;
reconcileActiveWorkspaceRecords(...args: unknown[]): Promise<Set<string>>; reconcileActiveWorkspaceRecords(...args: unknown[]): Promise<Set<string>>;
reconcileWorkspaceRecord(workspaceId: string): Promise<{ reconcileWorkspaceRecord(workspaceId: string): Promise<{
changed: boolean; changed: boolean;
@@ -530,6 +527,7 @@ function createSessionForWorkspaceTests(
options: { options: {
appVersion?: string | null; appVersion?: string | null;
onMessage?: (message: SessionOutboundMessage) => void; onMessage?: (message: SessionOutboundMessage) => void;
onWorkspaceRecovered?: SessionOptions["onWorkspaceRecovered"];
workspaceGitService?: ReturnType<typeof createNoopWorkspaceGitService>; workspaceGitService?: ReturnType<typeof createNoopWorkspaceGitService>;
terminalManager?: TerminalManager | null; terminalManager?: TerminalManager | null;
projectRegistry?: SessionOptions["projectRegistry"]; projectRegistry?: SessionOptions["projectRegistry"];
@@ -616,6 +614,7 @@ function createSessionForWorkspaceTests(
clientId: "test-client", clientId: "test-client",
appVersion: options.appVersion ?? null, appVersion: options.appVersion ?? null,
onMessage: options.onMessage ?? vi.fn(), onMessage: options.onMessage ?? vi.fn(),
onWorkspaceRecovered: options.onWorkspaceRecovered,
logger: asSessionLogger(logger), logger: asSessionLogger(logger),
downloadTokenStore: asDownloadTokenStore(), downloadTokenStore: asDownloadTokenStore(),
pushTokenStore: asPushTokenStore(), pushTokenStore: asPushTokenStore(),
@@ -4422,7 +4421,84 @@ test("open_project_request recreates a missing project record when unarchiving i
expect(response?.payload.workspace?.projectDisplayName).toBe("repo"); expect(response?.payload.workspace?.projectDisplayName).toBe("repo");
}); });
test("refresh_agent_request unarchives the owning workspace when its directory exists", async () => { test("workspace recovery stays accepted when git observer warming fails", async () => {
const emitted: SessionOutboundMessage[] = [];
const workspaceGitService = createNoopWorkspaceGitService({
registerWorkspace: () => {
throw new Error("git watcher unavailable");
},
});
const session = createSessionForWorkspaceTests({
appVersion: "0.1.105",
workspaceGitService,
onMessage: (message) => {
if (isSessionOutboundMessage(message)) emitted.push(message);
},
});
const archivedAt = "2026-03-10T00:00:00.000Z";
let project = createPersistedProjectRecord({
projectId: REPO_CWD,
rootPath: REPO_CWD,
kind: "git",
displayName: "repo",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: archivedAt,
archivedAt,
});
let workspace = createPersistedWorkspaceRecord({
workspaceId: "ws-recovery-warm-failure",
projectId: project.projectId,
cwd: REPO_CWD,
kind: "directory",
displayName: "repo",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: archivedAt,
archivedAt,
});
session.filesystem.isDirectory = async () => true;
session.projectRegistry.get = async (projectId: string) =>
projectId === project.projectId ? project : null;
session.projectRegistry.list = async () => [project];
session.projectRegistry.upsert = async (record: PersistedProjectRecord) => {
project = record;
};
session.workspaceRegistry.get = async (workspaceId: string) =>
workspaceId === workspace.workspaceId ? workspace : null;
session.workspaceRegistry.list = async () => [workspace];
session.workspaceRegistry.upsert = async (record: PersistedWorkspaceRecord) => {
workspace = record;
};
session.workspaceUpdatesSubscription = {
subscriptionId: "sub-recovery-warm-failure",
filter: undefined,
isBootstrapping: false,
pendingUpdatesByWorkspaceId: new Map(),
lastEmittedByWorkspaceId: new Map(),
};
session.reconcileActiveWorkspaceRecords = async () => new Set();
session.listAgentPayloads = async () => [];
await session.handleMessage({
type: "workspace.recovery.restore.request",
requestId: "req-recovery-warm-failure",
workspaceId: workspace.workspaceId,
});
expect(workspace.archivedAt).toBeNull();
expect(findByType(emitted, "workspace.recovery.restore.response")?.payload).toEqual({
requestId: "req-recovery-warm-failure",
workspaceId: workspace.workspaceId,
accepted: true,
error: null,
});
expect(findByType(emitted, "workspace_update")?.payload).toMatchObject({
kind: "upsert",
workspace: { id: workspace.workspaceId },
});
});
test("refresh_agent_request leaves workspace archival independent when its directory exists", async () => {
const emitted: SessionOutboundMessage[] = []; const emitted: SessionOutboundMessage[] = [];
const session = createSessionForWorkspaceTests({ const session = createSessionForWorkspaceTests({
onMessage: (message) => { onMessage: (message) => {
@@ -4514,13 +4590,13 @@ test("refresh_agent_request unarchives the owning workspace when its directory e
requestId: "req-refresh-unarchive", requestId: "req-refresh-unarchive",
}); });
expect(workspaces.get(workspaceId)?.archivedAt).toBeNull(); expect(workspaces.get(workspaceId)?.archivedAt).toBe("2026-03-10T00:00:00.000Z");
expect(projects.get(cwd)?.archivedAt).toBeNull(); expect(projects.get(cwd)?.archivedAt).toBe("2026-03-10T00:00:00.000Z");
expect(unarchivedWorkspaceIds).toContainEqual([workspaceId]); expect(unarchivedWorkspaceIds).toEqual([]);
expect(findByType(emitted, "rpc_error")).toBeUndefined(); expect(findByType(emitted, "rpc_error")).toBeUndefined();
}); });
test("refresh_agent_request leaves the owning workspace archived when its directory is missing", async () => { test("refresh_agent_request leaves workspace archival independent when its directory is missing", async () => {
const emitted: SessionOutboundMessage[] = []; const emitted: SessionOutboundMessage[] = [];
const session = createSessionForWorkspaceTests({ const session = createSessionForWorkspaceTests({
onMessage: (message) => { onMessage: (message) => {
@@ -4609,9 +4685,10 @@ test("refresh_agent_request leaves the owning workspace archived when its direct
expect(projects.get(cwd)?.archivedAt).toBe("2026-03-10T00:00:00.000Z"); expect(projects.get(cwd)?.archivedAt).toBe("2026-03-10T00:00:00.000Z");
}); });
test("refresh_agent_request recreates a deleted worktree directory and unarchives the same workspace", async () => { test("refresh_agent_request does not recreate or unarchive a deleted worktree", async () => {
const emitted: SessionOutboundMessage[] = []; const emitted: SessionOutboundMessage[] = [];
const session = createSessionForWorkspaceTests({ const session = createSessionForWorkspaceTests({
appVersion: "0.1.105",
onMessage: (message) => { onMessage: (message) => {
if (isSessionOutboundMessage(message)) emitted.push(message); if (isSessionOutboundMessage(message)) emitted.push(message);
}, },
@@ -4675,13 +4752,6 @@ test("refresh_agent_request recreates a deleted worktree directory and unarchive
session.agentStorage.get = async (id: string) => (id === agentId ? storedAgent : null); session.agentStorage.get = async (id: string) => (id === agentId ? storedAgent : null);
session.agentStorage.upsert = async () => {}; session.agentStorage.upsert = async () => {};
const recreateCalls: string[] = [];
session.recreateOwningWorktreeForRestore = async (
workspace: PersistedWorkspaceRecord,
): Promise<void> => {
recreateCalls.push(workspace.workspaceId);
};
const managed = makeManagedAgent({ const managed = makeManagedAgent({
id: agentId, id: agentId,
cwd, cwd,
@@ -4709,14 +4779,13 @@ test("refresh_agent_request recreates a deleted worktree directory and unarchive
requestId: "req-refresh-recreate-worktree", requestId: "req-refresh-recreate-worktree",
}); });
expect(recreateCalls).toEqual([workspaceId]); expect(workspaces.get(workspaceId)?.archivedAt).toBe("2026-03-10T00:00:00.000Z");
expect(workspaces.get(workspaceId)?.archivedAt).toBeNull();
expect(workspaces.get(workspaceId)?.workspaceId).toBe(workspaceId); expect(workspaces.get(workspaceId)?.workspaceId).toBe(workspaceId);
expect(unarchivedWorkspaceIds).toContainEqual([workspaceId]); expect(unarchivedWorkspaceIds).toEqual([]);
expect(findByType(emitted, "rpc_error")).toBeUndefined(); expect(findByType(emitted, "rpc_error")).toBeUndefined();
}); });
test("refresh_agent_request leaves the worktree archived and surfaces a typed error when recreation fails", async () => { test("refresh_agent_request does not inspect an archived worktree branch", async () => {
const emitted: SessionOutboundMessage[] = []; const emitted: SessionOutboundMessage[] = [];
const session = createSessionForWorkspaceTests({ const session = createSessionForWorkspaceTests({
onMessage: (message) => { onMessage: (message) => {
@@ -4782,10 +4851,6 @@ test("refresh_agent_request leaves the worktree archived and surfaces a typed er
session.agentStorage.get = async (id: string) => (id === agentId ? storedAgent : null); session.agentStorage.get = async (id: string) => (id === agentId ? storedAgent : null);
session.agentStorage.upsert = async () => {}; session.agentStorage.upsert = async () => {};
session.recreateOwningWorktreeForRestore = async (): Promise<void> => {
throw toWorktreeRequestError(new UnknownBranchError({ branchName: "feature/gone", cwd }));
};
const managed = makeManagedAgent({ const managed = makeManagedAgent({
id: agentId, id: agentId,
cwd, cwd,
@@ -4807,9 +4872,7 @@ test("refresh_agent_request leaves the worktree archived and surfaces a typed er
}); });
expect(workspaces.get(workspaceId)?.archivedAt).toBe("2026-03-10T00:00:00.000Z"); expect(workspaces.get(workspaceId)?.archivedAt).toBe("2026-03-10T00:00:00.000Z");
const rpcError = findByType(emitted, "rpc_error"); expect(findByType(emitted, "rpc_error")).toBeUndefined();
expect(rpcError).toBeDefined();
expect((rpcError?.payload as { code?: string } | undefined)?.code).toBe("unknown_branch");
}); });
function createRecreateWorktreeRepo(): { tempDir: string; repoDir: string } { function createRecreateWorktreeRepo(): { tempDir: string; repoDir: string } {
@@ -4828,7 +4891,7 @@ function createRecreateWorktreeRepo(): { tempDir: string; repoDir: string } {
return { tempDir, repoDir }; return { tempDir, repoDir };
} }
test("refresh_agent_request recreates a real deleted worktree against a temp git repo and unarchives the same workspace", async () => { test("legacy refresh_agent_request restores a real deleted worktree", async () => {
const { tempDir, repoDir } = createRecreateWorktreeRepo(); const { tempDir, repoDir } = createRecreateWorktreeRepo();
const branch = "feature/keep"; const branch = "feature/keep";
execFileSync("git", ["branch", branch], { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["branch", branch], { cwd: repoDir, stdio: "pipe" });
@@ -4851,6 +4914,7 @@ test("refresh_agent_request recreates a real deleted worktree against a temp git
const emitted: SessionOutboundMessage[] = []; const emitted: SessionOutboundMessage[] = [];
const session = createSessionForWorkspaceTests({ const session = createSessionForWorkspaceTests({
appVersion: "0.1.104",
paseoHome, paseoHome,
worktreesRoot, worktreesRoot,
onMessage: (message) => { onMessage: (message) => {
@@ -4938,13 +5002,6 @@ test("refresh_agent_request recreates a real deleted worktree against a temp git
expect(findByType(emitted, "rpc_error")).toBeUndefined(); expect(findByType(emitted, "rpc_error")).toBeUndefined();
expect(existsSync(worktreePath)).toBe(true); expect(existsSync(worktreePath)).toBe(true);
const headBranch = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
cwd: worktreePath,
stdio: "pipe",
})
.toString()
.trim();
expect(headBranch).toBe(branch);
expect(workspaces.get(workspaceId)?.workspaceId).toBe(workspaceId); expect(workspaces.get(workspaceId)?.workspaceId).toBe(workspaceId);
expect(workspaces.get(workspaceId)?.cwd).toBe(worktreePath); expect(workspaces.get(workspaceId)?.cwd).toBe(worktreePath);
expect(workspaces.get(workspaceId)?.archivedAt).toBeNull(); expect(workspaces.get(workspaceId)?.archivedAt).toBeNull();
@@ -4952,7 +5009,7 @@ test("refresh_agent_request recreates a real deleted worktree against a temp git
rmSync(tempDir, { recursive: true, force: true }); rmSync(tempDir, { recursive: true, force: true });
}); });
test("recreateOwningWorktreeForRestore throws a typed WorktreeRequestError and leaves the workspace archived when the project root is missing", async () => { test("recreateArchivedWorktree throws a typed WorktreeRequestError when the project root is missing", async () => {
const { tempDir, repoDir } = createRecreateWorktreeRepo(); const { tempDir, repoDir } = createRecreateWorktreeRepo();
const branch = "feature/keep"; const branch = "feature/keep";
execFileSync("git", ["branch", branch], { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["branch", branch], { cwd: repoDir, stdio: "pipe" });
@@ -5005,9 +5062,9 @@ test("recreateOwningWorktreeForRestore throws a typed WorktreeRequestError and l
session.filesystem.isDirectory = async (target: string) => session.filesystem.isDirectory = async (target: string) =>
existsSync(target) && statSync(target).isDirectory(); existsSync(target) && statSync(target).isDirectory();
await expect( await expect(session.recreateArchivedWorktree(workspaceRecord)).rejects.toBeInstanceOf(
session.recreateOwningWorktreeForRestore(workspaceRecord, branch), WorktreeRequestError,
).rejects.toBeInstanceOf(WorktreeRequestError); );
// Guard fires before createWorktree, so archivedAt is untouched. // Guard fires before createWorktree, so archivedAt is untouched.
expect(workspaces.get(workspaceId)?.archivedAt).toBe("2026-03-10T00:00:00.000Z"); expect(workspaces.get(workspaceId)?.archivedAt).toBe("2026-03-10T00:00:00.000Z");

View File

@@ -0,0 +1,131 @@
import { describe, expect, test } from "vitest";
import {
createPersistedProjectRecord,
createPersistedWorkspaceRecord,
type PersistedProjectRecord,
type PersistedWorkspaceRecord,
} from "../../workspace-registry.js";
import { createWorkspaceRecoveryService } from "./workspace-recovery-service.js";
const NOW = "2026-07-11T10:12:30.752Z";
function createProject(): PersistedProjectRecord {
return createPersistedProjectRecord({
projectId: "/repo",
rootPath: "/repo",
kind: "git",
displayName: "repo",
createdAt: NOW,
updatedAt: NOW,
});
}
function createWorkspace(
overrides: Partial<PersistedWorkspaceRecord> = {},
): PersistedWorkspaceRecord {
return createPersistedWorkspaceRecord({
workspaceId: "wks_15a1b5630ebaab33",
projectId: "/repo",
cwd: "/worktrees/trigger-1525443412986298439",
kind: "worktree",
displayName: "diagnose-repro-tdd",
title: "Codex TDD reproduction",
branch: "diagnose-repro-tdd",
createdAt: NOW,
updatedAt: NOW,
archivedAt: NOW,
...overrides,
});
}
function createHarness(input?: {
workspace?: PersistedWorkspaceRecord | null;
project?: PersistedProjectRecord | null;
directories?: string[];
recreate?: (workspace: PersistedWorkspaceRecord) => Promise<void>;
}) {
const workspace = input?.workspace === undefined ? createWorkspace() : input.workspace;
const project = input?.project === undefined ? createProject() : input.project;
const directories = new Set(input?.directories ?? ["/repo"]);
const unarchived: string[] = [];
const recreated: string[] = [];
const service = createWorkspaceRecoveryService({
getWorkspace: async (workspaceId) =>
workspace?.workspaceId === workspaceId ? workspace : null,
getProject: async (projectId) => (project?.projectId === projectId ? project : null),
isDirectory: async (path) => directories.has(path),
recreateWorktree: async (record) => {
recreated.push(record.workspaceId);
await input?.recreate?.(record);
},
unarchiveWorkspace: async (record) => {
unarchived.push(record.workspaceId);
},
});
return { service, recreated, unarchived };
}
describe("workspace recovery", () => {
test("authoritatively describes the archived missing worktree from the failed cloud run", async () => {
const { service, recreated, unarchived } = createHarness();
await expect(service.inspect("wks_15a1b5630ebaab33")).resolves.toEqual({
kind: "recoverable",
workspaceId: "wks_15a1b5630ebaab33",
workspaceName: "Codex TDD reproduction",
action: "restore",
branch: "diagnose-repro-tdd",
});
expect(recreated).toEqual([]);
expect(unarchived).toEqual([]);
});
test("describes an archived workspace whose directory remains as unarchivable", async () => {
const workspace = createWorkspace({ kind: "directory", branch: null });
const { service } = createHarness({
workspace,
directories: ["/repo", workspace.cwd],
});
await expect(service.inspect(workspace.workspaceId)).resolves.toMatchObject({
kind: "recoverable",
action: "unarchive",
});
});
test("does not offer recovery for a missing non-worktree directory", async () => {
const workspace = createWorkspace({ kind: "directory", branch: null });
const { service } = createHarness({ workspace });
await expect(service.inspect(workspace.workspaceId)).resolves.toEqual({
kind: "unavailable",
workspaceId: workspace.workspaceId,
reason: "workspace_directory_missing",
message: "The archived workspace directory no longer exists and cannot be recreated.",
});
});
test("keeps the workspace archived when recreation fails so restore can be retried", async () => {
let attempts = 0;
const { service, recreated, unarchived } = createHarness({
recreate: async () => {
attempts += 1;
if (attempts === 1) {
throw new Error("git branch diagnose-repro-tdd is unavailable");
}
},
});
await expect(service.restore("wks_15a1b5630ebaab33")).rejects.toThrow(
"git branch diagnose-repro-tdd is unavailable",
);
expect(unarchived).toEqual([]);
await expect(service.restore("wks_15a1b5630ebaab33")).resolves.toEqual({
workspaceId: "wks_15a1b5630ebaab33",
action: "restore",
});
expect(recreated).toEqual(["wks_15a1b5630ebaab33", "wks_15a1b5630ebaab33"]);
expect(unarchived).toEqual(["wks_15a1b5630ebaab33"]);
});
});

View File

@@ -0,0 +1,135 @@
import {
resolveWorkspaceDisplayName,
type PersistedProjectRecord,
type PersistedWorkspaceRecord,
} from "../../workspace-registry.js";
export type WorkspaceRecoveryAction = "unarchive" | "restore";
export type WorkspaceRecoveryState =
| {
kind: "recoverable";
workspaceId: string;
workspaceName: string;
action: WorkspaceRecoveryAction;
branch: string | null;
}
| {
kind: "unavailable";
workspaceId: string;
reason:
| "workspace_not_found"
| "workspace_not_archived"
| "project_not_found"
| "project_directory_missing"
| "workspace_directory_missing"
| "worktree_branch_missing";
message: string;
};
export interface WorkspaceRecoveryService {
inspect(workspaceId: string): Promise<WorkspaceRecoveryState>;
restore(workspaceId: string): Promise<{ workspaceId: string; action: WorkspaceRecoveryAction }>;
}
export function createWorkspaceRecoveryService(deps: {
getWorkspace: (workspaceId: string) => Promise<PersistedWorkspaceRecord | null>;
getProject: (projectId: string) => Promise<PersistedProjectRecord | null>;
isDirectory: (path: string) => Promise<boolean>;
recreateWorktree: (workspace: PersistedWorkspaceRecord) => Promise<void>;
unarchiveWorkspace: (workspace: PersistedWorkspaceRecord) => Promise<void>;
}): WorkspaceRecoveryService {
async function inspect(workspaceId: string): Promise<WorkspaceRecoveryState> {
const workspace = await deps.getWorkspace(workspaceId);
if (!workspace) {
return {
kind: "unavailable",
workspaceId,
reason: "workspace_not_found",
message: "This workspace is no longer known to the host.",
};
}
if (!workspace.archivedAt) {
return {
kind: "unavailable",
workspaceId,
reason: "workspace_not_archived",
message: "This workspace is not archived, but it is unavailable from the host.",
};
}
const project = await deps.getProject(workspace.projectId);
if (!project) {
return {
kind: "unavailable",
workspaceId,
reason: "project_not_found",
message: "The project for this archived workspace no longer exists.",
};
}
if (await deps.isDirectory(workspace.cwd)) {
return {
kind: "recoverable",
workspaceId,
workspaceName: resolveWorkspaceDisplayName(workspace),
action: "unarchive",
branch: workspace.branch,
};
}
if (workspace.kind !== "worktree") {
return {
kind: "unavailable",
workspaceId,
reason: "workspace_directory_missing",
message: "The archived workspace directory no longer exists and cannot be recreated.",
};
}
if (!workspace.branch) {
return {
kind: "unavailable",
workspaceId,
reason: "worktree_branch_missing",
message: "The archived worktree has no branch recorded, so it cannot be restored.",
};
}
if (!(await deps.isDirectory(project.rootPath))) {
return {
kind: "unavailable",
workspaceId,
reason: "project_directory_missing",
message: "The project directory needed to restore this worktree no longer exists.",
};
}
return {
kind: "recoverable",
workspaceId,
workspaceName: resolveWorkspaceDisplayName(workspace),
action: "restore",
branch: workspace.branch,
};
}
async function restore(
workspaceId: string,
): Promise<{ workspaceId: string; action: WorkspaceRecoveryAction }> {
const state = await inspect(workspaceId);
if (state.kind === "unavailable") {
throw new Error(state.message);
}
const workspace = await deps.getWorkspace(workspaceId);
if (!workspace?.archivedAt) {
throw new Error("The archived workspace changed before it could be recovered.");
}
if (state.action === "restore") {
await deps.recreateWorktree(workspace);
}
await deps.unarchiveWorkspace(workspace);
return { workspaceId, action: state.action };
}
return { inspect, restore };
}

View File

@@ -1009,6 +1009,13 @@ export class VoiceAssistantWebSocketServer {
onLifecycleIntent: (intent) => { onLifecycleIntent: (intent) => {
this.onLifecycleIntent?.(intent); this.onLifecycleIntent?.(intent);
}, },
onWorkspaceRecovered: async (workspace) => {
await Promise.all(
this.listActiveSessions().map((activeSession) =>
activeSession.refreshRecoveredWorkspaceForExternalMutation(workspace),
),
);
},
logger: connectionLogger.child({ module: "session" }), logger: connectionLogger.child({ module: "session" }),
downloadTokenStore: this.downloadTokenStore, downloadTokenStore: this.downloadTokenStore,
pushTokenStore: this.pushTokenStore, pushTokenStore: this.pushTokenStore,
@@ -1229,8 +1236,10 @@ export class VoiceAssistantWebSocketServer {
projectRemove: true, projectRemove: true,
// COMPAT(projectAdd): added in v0.1.97, drop the gate when floor >= v0.1.97. // COMPAT(projectAdd): added in v0.1.97, drop the gate when floor >= v0.1.97.
projectAdd: true, projectAdd: true,
// COMPAT(worktreeRestore): added in v0.1.97, drop the gate when floor >= v0.1.97 // COMPAT(worktreeRestore): keep through 2027-01-11 for clients older than v0.1.105.
worktreeRestore: true, worktreeRestore: true,
// COMPAT(workspaceRecovery): added in v0.1.105, remove after 2027-01-11 once daemon floor >= v0.1.105.
workspaceRecovery: true,
// COMPAT(providerUsageList): added in v0.1.98, drop the gate when daemon floor >= v0.1.98. // COMPAT(providerUsageList): added in v0.1.98, drop the gate when daemon floor >= v0.1.98.
providerUsageList: true, providerUsageList: true,
// COMPAT(agentDetach): added in v0.1.98, remove gate after 2026-12-19 once daemon floor >= v0.1.98. // COMPAT(agentDetach): added in v0.1.98, remove gate after 2026-12-19 once daemon floor >= v0.1.98.