mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-06 16:34:36 +00:00
fix(app): require explicit workspace recovery
This commit is contained in:
@@ -54,8 +54,9 @@ 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 restore must therefore follow explicit restore intent for that missing
|
||||
workspace identity; it must not infer workspace lifecycle from `agent.archivedAt`.
|
||||
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
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
expectWorkspaceArchiveOutcome,
|
||||
expectWorkspaceTabHidden,
|
||||
fetchAgentArchivedAt,
|
||||
expectWorkspaceTabVisible,
|
||||
openSessions,
|
||||
openWorkspaceWithAgents,
|
||||
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, {
|
||||
cwd: tempRepo.path,
|
||||
workspaceId,
|
||||
@@ -138,18 +137,17 @@ test.describe("Archive tab reconciliation", () => {
|
||||
await resetSeededPageState(page);
|
||||
await openWorkspaceWithAgents(page, [archived, surviving]);
|
||||
await archiveAgentFromDaemon(client, archived.id);
|
||||
const archivedAt = await fetchAgentArchivedAt(client, archived.id);
|
||||
expect(archivedAt).not.toBeNull();
|
||||
await openSessions(page);
|
||||
await expectSessionRowArchived(page, archived.title);
|
||||
|
||||
await clickSessionRow(page, archived.title);
|
||||
|
||||
await expect
|
||||
.poll(() => fetchAgentArchivedAt(client, archived.id), { timeout: 30_000 })
|
||||
.toBeNull();
|
||||
expect(await fetchAgentArchivedAt(client, archived.id)).toBe(archivedAt);
|
||||
|
||||
await expect(page).toHaveURL(buildHostWorkspaceRoute(getServerId(), archived.workspaceId), {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expectWorkspaceTabVisible(page, archived.id);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -103,12 +103,6 @@ export async function fetchAgentArchivedAt(
|
||||
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> {
|
||||
const seedNonce = randomUUID();
|
||||
const { daemon, preferences } = buildSeededStoragePayload();
|
||||
|
||||
@@ -17,6 +17,7 @@ type NewWorkspaceDaemonClient = Pick<
|
||||
| "fetchWorkspaces"
|
||||
| "getPaseoWorktreeList"
|
||||
| "getDaemonConfig"
|
||||
| "inspectWorkspaceRecovery"
|
||||
| "patchDaemonConfig"
|
||||
| "removeProject"
|
||||
>;
|
||||
|
||||
@@ -156,7 +156,7 @@ export async function installFakeScheduleHost(input: {
|
||||
workspaceMultiplicity: true,
|
||||
projectAdd: true,
|
||||
projectRemove: true,
|
||||
worktreeRestore: true,
|
||||
workspaceRecovery: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -132,11 +132,15 @@ export interface SeedDaemonClient {
|
||||
timeout?: number,
|
||||
): Promise<{ status: string; final?: { lastError?: string | null } | null }>;
|
||||
archiveAgent(agentId: string): Promise<{ archivedAt: string }>;
|
||||
refreshAgent(agentId: string): Promise<unknown>;
|
||||
fetchAgent(options: {
|
||||
agentId: string;
|
||||
}): Promise<{ agent: { id: string; archivedAt?: string | null } } | null>;
|
||||
getLastServerInfoMessage(): {
|
||||
features?: { projectAdd?: boolean; worktreeRestore?: boolean } | null;
|
||||
features?: {
|
||||
projectAdd?: boolean;
|
||||
workspaceRecovery?: boolean;
|
||||
} | null;
|
||||
} | null;
|
||||
fetchAgentHistory(options?: {
|
||||
page?: { limit: number };
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { existsSync } from "node:fs";
|
||||
import { rename } from "node:fs/promises";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, test } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import {
|
||||
archiveAgentFromDaemon,
|
||||
createIdleAgent,
|
||||
expectSessionRowArchived,
|
||||
expectSessionRowNotArchived,
|
||||
fetchAgentArchivedAt,
|
||||
openSessions,
|
||||
} from "./helpers/archive-tab";
|
||||
import {
|
||||
archiveWorkspaceFromDaemon,
|
||||
archiveLocalWorkspaceFromDaemon,
|
||||
connectNewWorkspaceDaemonClient,
|
||||
createWorktreeViaDaemon,
|
||||
openProjectViaDaemon,
|
||||
@@ -35,6 +37,50 @@ test.describe("Worktree 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;
|
||||
}
|
||||
|
||||
test.afterEach(async () => {
|
||||
for (const directory of createdWorktreeDirectories) {
|
||||
await archiveWorkspaceFromDaemon(worktreeClient, directory).catch(() => undefined);
|
||||
@@ -49,7 +95,7 @@ test.describe("Worktree restore", () => {
|
||||
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,
|
||||
}) => {
|
||||
const serverId = getServerId();
|
||||
@@ -69,70 +115,115 @@ test.describe("Worktree restore", () => {
|
||||
});
|
||||
expect(existsSync(worktree.workspaceDirectory)).toBe(true);
|
||||
|
||||
await archiveAgentFromDaemon(client, agent.id);
|
||||
expect(await fetchAgentArchivedAt(client, agent.id)).toBeNull();
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openSessions(page);
|
||||
await expectSessionRowArchived(page, agent.title);
|
||||
await expectSessionRowNotArchived(page, agent.title);
|
||||
|
||||
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);
|
||||
|
||||
// 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);
|
||||
const row = page
|
||||
.locator('[data-testid^="agent-row-"]')
|
||||
.filter({ hasText: agent.title })
|
||||
.first();
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await expect(row).not.toContainText("Archived", { timeout: 30_000 });
|
||||
});
|
||||
|
||||
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,
|
||||
}) => {
|
||||
const serverId = getServerId();
|
||||
const project = await openProjectViaDaemon(worktreeClient, tempRepo.path);
|
||||
createdProjectIds.add(project.projectKey);
|
||||
const worktree = await createWorktreeViaDaemon(worktreeClient, {
|
||||
cwd: tempRepo.path,
|
||||
slug: `restore-recreate-${randomUUID().slice(0, 8)}`,
|
||||
});
|
||||
createdProjectIds.add(worktree.projectKey);
|
||||
createdWorktreeDirectories.add(worktree.workspaceDirectory);
|
||||
const { agent, worktree } = await openArchivedWorkspaceFromHistory(page, "restore-ready");
|
||||
expect(await fetchAgentArchivedAt(client, agent.id)).toBeNull();
|
||||
expect(existsSync(worktree.workspaceDirectory)).toBe(false);
|
||||
await expect(
|
||||
worktreeClient.inspectWorkspaceRecovery(worktree.workspaceId),
|
||||
).resolves.toMatchObject({ kind: "recoverable", action: "restore" });
|
||||
});
|
||||
|
||||
const agent = await createIdleAgent(client, {
|
||||
cwd: worktree.workspaceDirectory,
|
||||
workspaceId: worktree.workspaceId,
|
||||
title: `restore-recreate-${randomUUID().slice(0, 8)}`,
|
||||
});
|
||||
expect(existsSync(worktree.workspaceDirectory)).toBe(true);
|
||||
test("explicit Restore shows loading and opens the recreated workspace", async ({ page }) => {
|
||||
const { agent, worktree } = await openArchivedWorkspaceFromHistory(page, "restore-success");
|
||||
|
||||
// Archive through the default production path the sidebar uses (no explicit
|
||||
// scope). With the restore prune fix, this default path frees the kept branch
|
||||
// so the daemon can re-check-out the worktree on restore.
|
||||
await archiveWorkspaceFromDaemon(worktreeClient, worktree.workspaceDirectory);
|
||||
await expect
|
||||
.poll(() => existsSync(worktree.workspaceDirectory), { timeout: 30_000 })
|
||||
.toBe(false);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openSessions(page);
|
||||
await expectSessionRowArchived(page, agent.title);
|
||||
|
||||
await page.getByTestId(`agent-row-${serverId}-${agent.id}`).click();
|
||||
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 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 });
|
||||
await expect(page.getByTestId("workspace-recovery-action")).toHaveCount(0);
|
||||
expect(await fetchAgentArchivedAt(client, agent.id)).toBeNull();
|
||||
});
|
||||
|
||||
test("restore failure stays visible and permits a successful retry", async ({ page }) => {
|
||||
const { worktree } = await openArchivedWorkspaceFromHistory(page, "restore-retry");
|
||||
const displacedProjectPath = `${tempRepo.path}-temporarily-unavailable`;
|
||||
await rename(tempRepo.path, displacedProjectPath);
|
||||
|
||||
try {
|
||||
await page.getByTestId("workspace-recovery-action").click();
|
||||
await expect(page.getByTestId("workspace-recovery-error")).toHaveText(
|
||||
"The project directory needed to restore this worktree no longer exists.",
|
||||
);
|
||||
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
|
||||
.poll(() => existsSync(worktree.workspaceDirectory), { timeout: 30_000 })
|
||||
.toBe(true);
|
||||
await waitForWorkspaceInSidebar(page, {
|
||||
serverId: getServerId(),
|
||||
workspaceId: worktree.workspaceId,
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -143,7 +143,7 @@ function PushNotificationRouter() {
|
||||
const serverId = target.serverId;
|
||||
const agentId = target.agentId;
|
||||
if (serverId && agentId) {
|
||||
navigateToAgent({ serverId, agentId, pin: true });
|
||||
navigateToAgent({ serverId, agentId });
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -108,6 +108,11 @@ function HostWorkspaceRouteContent() {
|
||||
? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? "")
|
||||
: "";
|
||||
const openValue = getParamValue(globalParams.open);
|
||||
const hasHydratedWorkspaces = useHasHydratedWorkspaces(serverId);
|
||||
const workspaceExists = useWorkspaceExists(serverId, workspaceId);
|
||||
const isOpenIntentWaitingForWorkspace = Boolean(
|
||||
openValue && hasHydratedWorkspaces && !workspaceExists,
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!serverId || !workspaceId) {
|
||||
return;
|
||||
@@ -125,6 +130,9 @@ function HostWorkspaceRouteContent() {
|
||||
if (!hasHydratedWorkspaceLayoutStore) {
|
||||
return;
|
||||
}
|
||||
if (isOpenIntentWaitingForWorkspace) {
|
||||
return;
|
||||
}
|
||||
|
||||
const consumptionKey = `${serverId}:${workspaceId}:${openValue}`;
|
||||
if (consumedIntentRef.current === consumptionKey) {
|
||||
@@ -160,6 +168,7 @@ function HostWorkspaceRouteContent() {
|
||||
setIntentConsumed(true);
|
||||
}, [
|
||||
hasHydratedWorkspaceLayoutStore,
|
||||
isOpenIntentWaitingForWorkspace,
|
||||
navigation,
|
||||
openValue,
|
||||
rootNavigationState?.key,
|
||||
@@ -167,7 +176,11 @@ function HostWorkspaceRouteContent() {
|
||||
workspaceId,
|
||||
]);
|
||||
|
||||
if (openValue && (!intentConsumed || !hasHydratedWorkspaceLayoutStore)) {
|
||||
if (
|
||||
openValue &&
|
||||
!isOpenIntentWaitingForWorkspace &&
|
||||
(!intentConsumed || !hasHydratedWorkspaceLayoutStore)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -394,8 +394,6 @@ export function AgentList({
|
||||
serverId,
|
||||
agentId,
|
||||
workspaceId: agent.workspaceId,
|
||||
restoreWorkspace: { agentArchived: Boolean(agent.archivedAt) },
|
||||
pin: false,
|
||||
});
|
||||
},
|
||||
[isActionSheetVisible, onAgentSelect],
|
||||
|
||||
@@ -489,7 +489,8 @@ describe("translation resources", () => {
|
||||
expect(en.workspace.route.hostOffline).toBe("{{hostName}} is offline");
|
||||
expect(en.workspace.route.cannotReachHost).toBe("Cannot reach {{hostName}}");
|
||||
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.auto).toBe("Context automatically compacted");
|
||||
expect(en.message.compaction.manual).toBe("Context manually compacted");
|
||||
|
||||
@@ -335,15 +335,24 @@ export const ar: TranslationResources = {
|
||||
workspace: {
|
||||
route: {
|
||||
loading: "جارٍ تحميل مساحة العمل",
|
||||
restoring: "جارٍ استعادة مساحة العمل",
|
||||
restoreFailed: "تعذّر استعادة مساحة العمل هذه — ربما تم نقل المجلد أو حذفه",
|
||||
connecting: "الاتصال",
|
||||
hostOffline: "{{hostName}}غير متواجد حالياً",
|
||||
cannotReachHost: "لا يمكن الوصول إلى{{hostName}}",
|
||||
hostStatus: "حالة Host:{{status}}",
|
||||
missing: "لم يتم العثور على Workspace",
|
||||
needsHostUpgrade: "قم بتحديث مضيفك لاستعادة مساحة العمل هذه",
|
||||
manageHost: "إدارة المضيف",
|
||||
recovery: {
|
||||
archivedTitle: "مساحة العمل مؤرشفة",
|
||||
restoreDescription:
|
||||
"تمت أرشفة {{workspaceName}} وإزالة شجرة العمل الخاصة بها. استعد الفرع {{branch}} لفتحها مجددًا.",
|
||||
unarchiveDescription: "{{workspaceName}} مؤرشفة. ألغِ أرشفتها لفتحها مجددًا.",
|
||||
restoreAction: "استعادة",
|
||||
unarchiveAction: "إلغاء الأرشفة",
|
||||
restoringTitle: "جارٍ استعادة مساحة العمل",
|
||||
restoringAction: "جارٍ الاستعادة...",
|
||||
unavailableTitle: "مساحة العمل غير متاحة",
|
||||
checkFailedTitle: "تعذر التحقق من مساحة العمل",
|
||||
},
|
||||
},
|
||||
hoverCard: {
|
||||
scriptsAccessibility: "البرامج النصية Workspace",
|
||||
|
||||
@@ -334,16 +334,24 @@ export const en = {
|
||||
workspace: {
|
||||
route: {
|
||||
loading: "Loading workspace",
|
||||
restoring: "Restoring workspace",
|
||||
restoreFailed:
|
||||
"Couldn't restore this workspace — the directory may have been moved or deleted",
|
||||
connecting: "Connecting",
|
||||
hostOffline: "{{hostName}} is offline",
|
||||
cannotReachHost: "Cannot reach {{hostName}}",
|
||||
hostStatus: "Host status: {{status}}",
|
||||
missing: "Workspace not found",
|
||||
needsHostUpgrade: "Update your host to restore this workspace",
|
||||
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: {
|
||||
scriptsAccessibility: "Workspace scripts",
|
||||
|
||||
@@ -338,16 +338,25 @@ export const es: TranslationResources = {
|
||||
workspace: {
|
||||
route: {
|
||||
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",
|
||||
hostOffline: "{{hostName}}está desconectado",
|
||||
cannotReachHost: "No se puede alcanzar{{hostName}}",
|
||||
hostStatus: "Estado deHost:{{status}}",
|
||||
missing: "Workspaceno encontrado",
|
||||
needsHostUpgrade: "Actualiza tu host para restaurar este espacio de trabajo",
|
||||
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: {
|
||||
scriptsAccessibility: "GuionesWorkspace",
|
||||
|
||||
@@ -338,16 +338,24 @@ export const fr: TranslationResources = {
|
||||
workspace: {
|
||||
route: {
|
||||
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",
|
||||
hostOffline: "{{hostName}}est hors ligne",
|
||||
cannotReachHost: "Impossible d'atteindre{{hostName}}",
|
||||
hostStatus: "StatutHost:{{status}}",
|
||||
missing: "Workspaceintrouvable",
|
||||
needsHostUpgrade: "Mettez à jour votre hôte pour restaurer cet espace de travail",
|
||||
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: {
|
||||
scriptsAccessibility: "ScriptsWorkspace",
|
||||
|
||||
@@ -338,16 +338,25 @@ export const ja: TranslationResources = {
|
||||
workspace: {
|
||||
route: {
|
||||
loading: "ワークスペースを読み込み中",
|
||||
restoring: "ワークスペースを復元中",
|
||||
restoreFailed:
|
||||
"このワークスペースを復元できませんでした。ディレクトリが移動または削除された可能性があります。",
|
||||
connecting: "接続中",
|
||||
hostOffline: "{{hostName}}はオフラインです",
|
||||
cannotReachHost: "{{hostName}}に到達できません",
|
||||
hostStatus: "ホストの状態: {{status}}",
|
||||
missing: "ワークスペースが見つかりません",
|
||||
needsHostUpgrade: "このワークスペースを復元するにはホストを更新してください",
|
||||
manageHost: "ホストを管理",
|
||||
recovery: {
|
||||
archivedTitle: "ワークスペースはアーカイブ済みです",
|
||||
restoreDescription:
|
||||
"{{workspaceName}} はアーカイブされ、worktree が削除されました。ブランチ {{branch}} を復元して再度開きます。",
|
||||
unarchiveDescription:
|
||||
"{{workspaceName}} はアーカイブされています。再度開くにはアーカイブを解除してください。",
|
||||
restoreAction: "復元",
|
||||
unarchiveAction: "アーカイブを解除",
|
||||
restoringTitle: "ワークスペースを復元中",
|
||||
restoringAction: "復元中...",
|
||||
unavailableTitle: "ワークスペースを利用できません",
|
||||
checkFailedTitle: "ワークスペースを確認できませんでした",
|
||||
},
|
||||
},
|
||||
hoverCard: {
|
||||
scriptsAccessibility: "ワークスペーススクリプト",
|
||||
|
||||
@@ -338,16 +338,25 @@ export const ptBR: TranslationResources = {
|
||||
workspace: {
|
||||
route: {
|
||||
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",
|
||||
hostOffline: "{{hostName}} está offline",
|
||||
cannotReachHost: "Não é possível acessar {{hostName}}",
|
||||
hostStatus: "Status do host: {{status}}",
|
||||
missing: "Workspace não encontrado",
|
||||
needsHostUpgrade: "Atualize o host para restaurar este workspace",
|
||||
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: {
|
||||
scriptsAccessibility: "Scripts do workspace",
|
||||
|
||||
@@ -337,16 +337,25 @@ export const ru: TranslationResources = {
|
||||
workspace: {
|
||||
route: {
|
||||
loading: "Загрузка рабочей области",
|
||||
restoring: "Восстановление рабочей области",
|
||||
restoreFailed:
|
||||
"Не удалось восстановить эту рабочую область — каталог мог быть перемещён или удалён",
|
||||
connecting: "Подключение",
|
||||
hostOffline: "{{hostName}}не в сети",
|
||||
cannotReachHost: "Невозможно связаться с{{hostName}}",
|
||||
hostStatus: "Статус Host:{{status}}",
|
||||
missing: "Workspace не найден",
|
||||
needsHostUpgrade: "Обновите хост, чтобы восстановить эту рабочую область",
|
||||
manageHost: "Управление хостом",
|
||||
recovery: {
|
||||
archivedTitle: "Рабочая область в архиве",
|
||||
restoreDescription:
|
||||
"{{workspaceName}} была архивирована, а её worktree удалён. Восстановите ветку {{branch}}, чтобы открыть её снова.",
|
||||
unarchiveDescription:
|
||||
"{{workspaceName}} находится в архиве. Разархивируйте её, чтобы открыть снова.",
|
||||
restoreAction: "Восстановить",
|
||||
unarchiveAction: "Разархивировать",
|
||||
restoringTitle: "Восстановление рабочей области",
|
||||
restoringAction: "Восстановление...",
|
||||
unavailableTitle: "Рабочая область недоступна",
|
||||
checkFailedTitle: "Не удалось проверить рабочую область",
|
||||
},
|
||||
},
|
||||
hoverCard: {
|
||||
scriptsAccessibility: "Скрипты Workspace",
|
||||
|
||||
@@ -335,15 +335,24 @@ export const zhCN: TranslationResources = {
|
||||
workspace: {
|
||||
route: {
|
||||
loading: "正在加载 workspace",
|
||||
restoring: "正在恢复 workspace",
|
||||
restoreFailed: "无法恢复此 workspace — 目录可能已被移动或删除",
|
||||
connecting: "正在连接",
|
||||
hostOffline: "{{hostName}} 已离线",
|
||||
cannotReachHost: "无法连接 {{hostName}}",
|
||||
hostStatus: "Host 状态:{{status}}",
|
||||
missing: "Workspace 未找到",
|
||||
needsHostUpgrade: "更新你的 Host 以恢复此 workspace",
|
||||
manageHost: "管理 Host",
|
||||
recovery: {
|
||||
archivedTitle: "Workspace 已归档",
|
||||
restoreDescription:
|
||||
"{{workspaceName}} 已归档,其 worktree 已移除。恢复分支 {{branch}} 以重新打开。",
|
||||
unarchiveDescription: "{{workspaceName}} 已归档。取消归档以重新打开。",
|
||||
restoreAction: "恢复",
|
||||
unarchiveAction: "取消归档",
|
||||
restoringTitle: "正在恢复 workspace",
|
||||
restoringAction: "正在恢复...",
|
||||
unavailableTitle: "Workspace 不可用",
|
||||
checkFailedTitle: "无法检查 workspace",
|
||||
},
|
||||
},
|
||||
hoverCard: {
|
||||
scriptsAccessibility: "Workspace scripts",
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
import { Text, View } from "react-native";
|
||||
import { ArrowLeftToLine, RotateCw, Settings } from "lucide-react-native";
|
||||
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 { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { formatConnectionStatus } from "@/utils/daemons";
|
||||
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 {
|
||||
onRetryHost: () => void;
|
||||
onManageHost: () => void;
|
||||
onDismissMissingWorkspace: () => void;
|
||||
onRecoverWorkspace: () => void;
|
||||
onRetryRecoveryInspection: () => void;
|
||||
}
|
||||
|
||||
export function renderWorkspaceRouteGate(input: {
|
||||
@@ -21,8 +29,13 @@ export function renderWorkspaceRouteGate(input: {
|
||||
switch (input.state.kind) {
|
||||
case "loading":
|
||||
return <WorkspaceConnecting hostName={input.state.hostName} />;
|
||||
case "restoring":
|
||||
return <WorkspaceRestoring hostName={input.state.hostName} />;
|
||||
case "archived":
|
||||
return (
|
||||
<ArchivedWorkspaceRecovery
|
||||
state={input.state}
|
||||
onRecover={input.actions.onRecoverWorkspace}
|
||||
/>
|
||||
);
|
||||
case "needsHostUpgrade":
|
||||
return (
|
||||
<WorkspaceEmptyState
|
||||
@@ -39,13 +52,19 @@ export function renderWorkspaceRouteGate(input: {
|
||||
onManageHost={input.actions.onManageHost}
|
||||
/>
|
||||
);
|
||||
case "missing":
|
||||
case "recoveryUnavailable":
|
||||
return (
|
||||
<WorkspaceEmptyState
|
||||
titleKey={
|
||||
input.state.restoreFailed ? "workspace.route.restoreFailed" : "workspace.route.missing"
|
||||
}
|
||||
hostName={input.state.hostName}
|
||||
titleKey="workspace.route.recovery.unavailableTitle"
|
||||
description={input.state.message}
|
||||
onDismiss={input.actions.onDismissMissingWorkspace}
|
||||
/>
|
||||
);
|
||||
case "recoveryInspectionFailed":
|
||||
return (
|
||||
<WorkspaceRecoveryInspectionFailed
|
||||
state={input.state}
|
||||
onRetry={input.actions.onRetryRecoveryInspection}
|
||||
onDismiss={input.actions.onDismissMissingWorkspace}
|
||||
/>
|
||||
);
|
||||
@@ -69,12 +88,11 @@ function getWorkspaceHostStateTitle(
|
||||
}
|
||||
|
||||
function WorkspaceConnecting({ hostName }: { hostName: string }) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<View style={styles.emptyState}>
|
||||
<LoadingSpinner size="small" color={theme.colors.foregroundMuted} />
|
||||
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
|
||||
<View style={styles.textStack}>
|
||||
<Text style={styles.title}>{t("workspace.route.loading")}</Text>
|
||||
<Text style={styles.description}>{hostName}</Text>
|
||||
@@ -83,16 +101,90 @@ function WorkspaceConnecting({ hostName }: { hostName: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceRestoring({ hostName }: { hostName: string }) {
|
||||
const { theme } = useUnistyles();
|
||||
function ArchivedWorkspaceRecovery({
|
||||
state,
|
||||
onRecover,
|
||||
}: {
|
||||
state: Extract<WorkspaceRouteState, { kind: "archived" }>;
|
||||
onRecover: () => void;
|
||||
}) {
|
||||
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 (
|
||||
<View style={styles.emptyState}>
|
||||
<LoadingSpinner size="small" color={theme.colors.foregroundMuted} />
|
||||
{isRestoring ? (
|
||||
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
|
||||
) : null}
|
||||
<View style={styles.textStack}>
|
||||
<Text style={styles.title}>{t("workspace.route.restoring")}</Text>
|
||||
<Text style={styles.description}>{hostName}</Text>
|
||||
<Text style={styles.title}>
|
||||
{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>
|
||||
);
|
||||
@@ -107,14 +199,13 @@ function WorkspaceUnreachable({
|
||||
onRetry: () => void;
|
||||
onManageHost: () => void;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const canRetry = state.connectionStatus === "offline" || state.connectionStatus === "error";
|
||||
|
||||
return (
|
||||
<View style={styles.emptyState}>
|
||||
{state.connectionStatus === "connecting" || state.connectionStatus === "idle" ? (
|
||||
<LoadingSpinner size="small" color={theme.colors.foregroundMuted} />
|
||||
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
|
||||
) : null}
|
||||
<View style={styles.textStack}>
|
||||
<Text style={styles.title}>{getWorkspaceHostStateTitle(state, t)}</Text>
|
||||
@@ -155,13 +246,12 @@ function WorkspaceUnreachable({
|
||||
function WorkspaceEmptyState({
|
||||
titleKey,
|
||||
hostName,
|
||||
description,
|
||||
onDismiss,
|
||||
}: {
|
||||
titleKey:
|
||||
| "workspace.route.missing"
|
||||
| "workspace.route.restoreFailed"
|
||||
| "workspace.route.needsHostUpgrade";
|
||||
hostName: string;
|
||||
titleKey: "workspace.route.needsHostUpgrade" | "workspace.route.recovery.unavailableTitle";
|
||||
hostName?: string;
|
||||
description?: string;
|
||||
onDismiss: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
@@ -170,7 +260,7 @@ function WorkspaceEmptyState({
|
||||
<View style={styles.emptyState}>
|
||||
<View style={styles.textStack}>
|
||||
<Text style={styles.title}>{t(titleKey)}</Text>
|
||||
<Text style={styles.description}>{hostName}</Text>
|
||||
<Text style={styles.description}>{description ?? hostName}</Text>
|
||||
</View>
|
||||
<View style={styles.actions}>
|
||||
<Button size="sm" variant="default" leftIcon={ArrowLeftToLine} onPress={onDismiss}>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import type { WorkspaceRecoveryModel } from "@/workspace-recovery/model";
|
||||
import { resolveWorkspaceRouteState } from "./workspace-route-state";
|
||||
|
||||
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", () => {
|
||||
it("returns unreachable when no descriptor is cached and the host is offline", () => {
|
||||
expect(
|
||||
resolveWorkspaceRouteState({
|
||||
hostName: "Laptop",
|
||||
connectionStatus: "offline",
|
||||
lastError: "transport closed",
|
||||
workspace: null,
|
||||
hasHydratedWorkspaces: false,
|
||||
restoreStatus: null,
|
||||
}),
|
||||
).toEqual({
|
||||
it("keeps a missing route unreachable while its host is offline", () => {
|
||||
expect(resolve({ connectionStatus: "offline" })).toEqual({
|
||||
kind: "unreachable",
|
||||
hostName: "Laptop",
|
||||
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(
|
||||
resolveWorkspaceRouteState({
|
||||
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,
|
||||
}),
|
||||
resolve({ workspace: createWorkspaceDescriptor(), connectionStatus: "offline" }),
|
||||
).toEqual({
|
||||
kind: "reconnecting",
|
||||
hostName: "Laptop",
|
||||
@@ -75,93 +71,78 @@ describe("resolveWorkspaceRouteState", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("returns missing after workspace hydration when the host is online", () => {
|
||||
expect(
|
||||
resolveWorkspaceRouteState({
|
||||
hostName: "Laptop",
|
||||
connectionStatus: "online",
|
||||
lastError: null,
|
||||
workspace: null,
|
||||
hasHydratedWorkspaces: true,
|
||||
restoreStatus: null,
|
||||
}),
|
||||
).toEqual({ kind: "missing", hostName: "Laptop", restoreFailed: false });
|
||||
it("waits for workspace hydration and authoritative recovery inspection", () => {
|
||||
expect(resolve({ hasHydratedWorkspaces: false })).toEqual({
|
||||
kind: "loading",
|
||||
hostName: "Laptop",
|
||||
});
|
||||
expect(resolve({ recovery: { kind: "checking" } })).toEqual({
|
||||
kind: "loading",
|
||||
hostName: "Laptop",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns loading before workspace hydration when the host is online", () => {
|
||||
expect(
|
||||
resolveWorkspaceRouteState({
|
||||
hostName: "Laptop",
|
||||
connectionStatus: "online",
|
||||
lastError: null,
|
||||
workspace: null,
|
||||
hasHydratedWorkspaces: false,
|
||||
restoreStatus: null,
|
||||
}),
|
||||
).toEqual({ kind: "loading", hostName: "Laptop" });
|
||||
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("returns ready when the host is online and the descriptor exists", () => {
|
||||
it("distinguishes unsupported and authoritatively unavailable workspaces", () => {
|
||||
expect(resolve({ recovery: { kind: "needsHostUpgrade" } })).toEqual({
|
||||
kind: "needsHostUpgrade",
|
||||
hostName: "Laptop",
|
||||
});
|
||||
expect(
|
||||
resolveWorkspaceRouteState({
|
||||
hostName: "Laptop",
|
||||
connectionStatus: "online",
|
||||
lastError: null,
|
||||
resolve({
|
||||
recovery: {
|
||||
kind: "unavailable",
|
||||
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.",
|
||||
});
|
||||
});
|
||||
|
||||
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(),
|
||||
hasHydratedWorkspaces: true,
|
||||
restoreStatus: null,
|
||||
recovery: restoring,
|
||||
}),
|
||||
).toEqual({ kind: "ready" });
|
||||
});
|
||||
).toEqual({ kind: "archived", hostName: "Laptop", recovery: restoring });
|
||||
|
||||
it("returns restoring while an archived workspace restore is in flight", () => {
|
||||
expect(
|
||||
resolveWorkspaceRouteState({
|
||||
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,
|
||||
resolve({
|
||||
workspace: createWorkspaceDescriptor(),
|
||||
hasHydratedWorkspaces: true,
|
||||
restoreStatus: "restoring",
|
||||
recovery: recoverable,
|
||||
}),
|
||||
).toEqual({ kind: "ready" });
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { HostRuntimeConnectionStatus } from "@/runtime/host-runtime";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import type { WorkspaceRecoveryModel } from "@/workspace-recovery/model";
|
||||
|
||||
export type WorkspaceRouteState =
|
||||
| { kind: "ready" }
|
||||
@@ -16,9 +17,14 @@ export type WorkspaceRouteState =
|
||||
lastError: string | null;
|
||||
}
|
||||
| { kind: "loading"; hostName: string }
|
||||
| { kind: "restoring"; 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: {
|
||||
hostName: string;
|
||||
@@ -26,10 +32,13 @@ export function resolveWorkspaceRouteState(input: {
|
||||
lastError: string | null;
|
||||
workspace: WorkspaceDescriptor | null;
|
||||
hasHydratedWorkspaces: boolean;
|
||||
restoreStatus: "restoring" | "failed" | "needs-host-upgrade" | null;
|
||||
recovery: WorkspaceRecoveryModel;
|
||||
}): WorkspaceRouteState {
|
||||
if (input.workspace) {
|
||||
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" };
|
||||
}
|
||||
|
||||
@@ -41,30 +50,37 @@ export function resolveWorkspaceRouteState(input: {
|
||||
};
|
||||
}
|
||||
|
||||
if (input.connectionStatus === "online") {
|
||||
if (input.restoreStatus === "restoring") {
|
||||
return { kind: "restoring", hostName: input.hostName };
|
||||
}
|
||||
|
||||
if (input.restoreStatus === "needs-host-upgrade") {
|
||||
return { kind: "needsHostUpgrade", hostName: input.hostName };
|
||||
}
|
||||
|
||||
if (input.hasHydratedWorkspaces) {
|
||||
return {
|
||||
kind: "missing",
|
||||
hostName: input.hostName,
|
||||
restoreFailed: input.restoreStatus === "failed",
|
||||
};
|
||||
}
|
||||
if (input.connectionStatus !== "online") {
|
||||
return {
|
||||
kind: "unreachable",
|
||||
hostName: input.hostName,
|
||||
connectionStatus: input.connectionStatus,
|
||||
lastError: input.lastError,
|
||||
};
|
||||
}
|
||||
|
||||
if (!input.hasHydratedWorkspaces || input.recovery.kind === "idle") {
|
||||
return { kind: "loading", hostName: input.hostName };
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "unreachable",
|
||||
hostName: input.hostName,
|
||||
connectionStatus: input.connectionStatus,
|
||||
lastError: input.lastError,
|
||||
};
|
||||
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 "inspectionFailed":
|
||||
return {
|
||||
kind: "recoveryInspectionFailed",
|
||||
hostName: input.hostName,
|
||||
error: input.recovery.error,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,11 +71,7 @@ import { ImportSessionSheet } from "@/components/import-session-sheet";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { selectIsFileExplorerOpen, usePanelStore } from "@/stores/panel-store";
|
||||
import { type ExplorerCheckoutContext } from "@/stores/explorer-checkout-context";
|
||||
import {
|
||||
useSessionStore,
|
||||
useWorkspaceRestoreStatus,
|
||||
type WorkspaceDescriptor,
|
||||
} from "@/stores/session-store";
|
||||
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import {
|
||||
buildWorkspaceTabPersistenceKey,
|
||||
collectAllTabs,
|
||||
@@ -144,6 +140,8 @@ import {
|
||||
type WorkspaceRouteState,
|
||||
} from "@/screens/workspace/workspace-route-state";
|
||||
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 {
|
||||
buildWorkspaceTabSnapshot,
|
||||
deriveWorkspaceAgentVisibility,
|
||||
@@ -1462,9 +1460,9 @@ function useWorkspaceRouteActions(normalizedServerId: string): {
|
||||
|
||||
function useResolvedWorkspaceRouteState(input: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
workspace: WorkspaceDescriptor | null;
|
||||
hasHydratedWorkspaces: boolean;
|
||||
recovery: WorkspaceRecoveryModel;
|
||||
}): WorkspaceRouteState {
|
||||
const hosts = useHosts();
|
||||
const host = useMemo(
|
||||
@@ -1473,8 +1471,6 @@ function useResolvedWorkspaceRouteState(input: {
|
||||
);
|
||||
const hostSnapshot = useHostRuntimeSnapshot(input.serverId);
|
||||
const hostName = useMemo(() => getHostDisplayName(host, input.serverId), [host, input.serverId]);
|
||||
const restoreStatus = useWorkspaceRestoreStatus(input.serverId, input.workspaceId);
|
||||
|
||||
return useMemo(
|
||||
() =>
|
||||
resolveWorkspaceRouteState({
|
||||
@@ -1483,7 +1479,7 @@ function useResolvedWorkspaceRouteState(input: {
|
||||
lastError: hostSnapshot?.lastError ?? null,
|
||||
workspace: input.workspace,
|
||||
hasHydratedWorkspaces: input.hasHydratedWorkspaces,
|
||||
restoreStatus,
|
||||
recovery: input.recovery,
|
||||
}),
|
||||
[
|
||||
hostName,
|
||||
@@ -1491,11 +1487,18 @@ function useResolvedWorkspaceRouteState(input: {
|
||||
hostSnapshot?.lastError,
|
||||
input.workspace,
|
||||
input.hasHydratedWorkspaces,
|
||||
restoreStatus,
|
||||
input.recovery,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
function shouldInspectWorkspaceRecovery(
|
||||
hasHydratedWorkspaces: boolean,
|
||||
workspace: WorkspaceDescriptor | null,
|
||||
): boolean {
|
||||
return hasHydratedWorkspaces && workspace === null;
|
||||
}
|
||||
|
||||
function WorkspaceScreenGateFrame({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
@@ -1752,6 +1755,11 @@ function WorkspaceScreenContent({
|
||||
const hasHydratedWorkspaces = useSessionStore(
|
||||
(state) => state.sessions[normalizedServerId]?.hasHydratedWorkspaces ?? false,
|
||||
);
|
||||
const workspaceRecovery = useWorkspaceRecovery({
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
enabled: shouldInspectWorkspaceRecovery(hasHydratedWorkspaces, workspaceDescriptor),
|
||||
});
|
||||
|
||||
const workspaceAgentVisibility = useStoreWithEqualityFn(
|
||||
useSessionStore,
|
||||
@@ -1827,9 +1835,9 @@ function WorkspaceScreenContent({
|
||||
);
|
||||
const workspaceRouteState = useResolvedWorkspaceRouteState({
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
workspace: workspaceDescriptor,
|
||||
hasHydratedWorkspaces,
|
||||
recovery: workspaceRecovery.state,
|
||||
});
|
||||
const workspaceHeaderCheckoutState = buildWorkspaceHeaderCheckoutState({
|
||||
isCheckoutStatusLoading,
|
||||
@@ -3291,6 +3299,8 @@ function WorkspaceScreenContent({
|
||||
onRetryHost: handleRetryHost,
|
||||
onManageHost: handleManageHost,
|
||||
onDismissMissingWorkspace: handleDismissMissingWorkspace,
|
||||
onRecoverWorkspace: workspaceRecovery.restore,
|
||||
onRetryRecoveryInspection: workspaceRecovery.retryInspection,
|
||||
},
|
||||
});
|
||||
const gatedWorkspaceScreen = renderWorkspaceScreenGateShell({
|
||||
|
||||
@@ -364,51 +364,6 @@ describe("mergeWorkspaces", () => {
|
||||
expect(after.workspaces).not.toBe(before.workspaces);
|
||||
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", () => {
|
||||
|
||||
@@ -317,8 +317,6 @@ export interface AgentTimelineCursorState {
|
||||
endSeq: number;
|
||||
}
|
||||
|
||||
export type WorkspaceRestoreStatus = "restoring" | "failed" | "needs-host-upgrade";
|
||||
|
||||
// Per-session state
|
||||
export interface SessionState {
|
||||
serverId: string;
|
||||
@@ -365,9 +363,6 @@ export interface SessionState {
|
||||
// Project parents with no active workspaces, keyed by projectId. The
|
||||
// `emptyProjects` name is the existing protocol/store projection.
|
||||
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
|
||||
pendingPermissions: Map<string, PendingPermission>;
|
||||
@@ -488,13 +483,6 @@ interface SessionStoreActions {
|
||||
setEmptyProjects: (serverId: string, emptyProjects: Iterable<EmptyProjectDescriptor>) => void;
|
||||
addEmptyProject: (serverId: string, emptyProject: EmptyProjectDescriptor) => void;
|
||||
removeEmptyProject: (serverId: string, projectId: string) => void;
|
||||
setWorkspaceRestoreStatus: (
|
||||
serverId: string,
|
||||
workspaceId: string,
|
||||
status: WorkspaceRestoreStatus,
|
||||
) => void;
|
||||
clearWorkspaceRestoreStatus: (serverId: string, workspaceId: string) => void;
|
||||
|
||||
// Agent activity timestamps
|
||||
setAgentLastActivity: (agentId: string, timestamp: Date) => void;
|
||||
setAgentLastActivityBatch: (
|
||||
@@ -567,7 +555,6 @@ function createInitialSessionState(serverId: string, client: DaemonClient): Sess
|
||||
agentDetails: new Map(),
|
||||
workspaces: new Map(),
|
||||
emptyProjects: new Map(),
|
||||
restoringWorkspaces: new Map(),
|
||||
pendingPermissions: new Map(),
|
||||
fileExplorer: 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) => {
|
||||
const nextEntries = Array.from(workspaces);
|
||||
set((prev) => {
|
||||
@@ -1392,18 +1331,10 @@ export const useSessionStore = create<SessionStore>()(
|
||||
// empty: prune any stale empty descriptor so it stops governing the
|
||||
// project's rendered metadata.
|
||||
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) {
|
||||
if (nextEmptyProjects.delete(workspace.projectId)) {
|
||||
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 nextWorkspace = preserveWorkspaceDescriptorIdentity(workspace, existing);
|
||||
if (existing === nextWorkspace) {
|
||||
@@ -1423,7 +1354,6 @@ export const useSessionStore = create<SessionStore>()(
|
||||
...session,
|
||||
workspaces: next,
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,179 +1,10 @@
|
||||
import { router, type Href } from "expo-router";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store";
|
||||
import { getHostRuntimeStore, isHostRuntimeConnected } from "@/runtime/host-runtime";
|
||||
import {
|
||||
resolveNavigateToAgent,
|
||||
type HistoryRestoreTarget,
|
||||
type NavigateToAgentInput,
|
||||
} from "./resolve";
|
||||
import { resolveNavigateToAgent, type NavigateToAgentInput } from "./resolve";
|
||||
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
|
||||
|
||||
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;
|
||||
|
||||
interface PendingHistoryRestore {
|
||||
target: HistoryRestoreTarget;
|
||||
unsubscribe: () => void;
|
||||
}
|
||||
|
||||
const pendingHydrationRestores = new Map<string, PendingHistoryRestore>();
|
||||
const pendingAgentReopens = new Map<string, PendingHistoryRestore>();
|
||||
|
||||
function restoreWhenWorkspacesHydrate(target: HistoryRestoreTarget): void {
|
||||
const { serverId, workspaceId } = target;
|
||||
if (!workspaceId) {
|
||||
return;
|
||||
}
|
||||
const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pending = pendingHydrationRestores.get(key);
|
||||
if (pending) {
|
||||
pending.target = target;
|
||||
return;
|
||||
}
|
||||
|
||||
const restore: PendingHistoryRestore = {
|
||||
target,
|
||||
unsubscribe: () => {},
|
||||
};
|
||||
restore.unsubscribe = useSessionStore.subscribe((state) => {
|
||||
const session = state.sessions[serverId];
|
||||
if (session && !session.hasHydratedWorkspaces) {
|
||||
return;
|
||||
}
|
||||
|
||||
restore.unsubscribe();
|
||||
pendingHydrationRestores.delete(key);
|
||||
if (session) {
|
||||
restoreHistoryEntry(restore.target);
|
||||
}
|
||||
});
|
||||
pendingHydrationRestores.set(key, restore);
|
||||
}
|
||||
|
||||
function reopenAgentAfterWorkspaceRestore(target: HistoryRestoreTarget): void {
|
||||
const { serverId, workspaceId } = target;
|
||||
if (!workspaceId) {
|
||||
return;
|
||||
}
|
||||
const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pending = pendingAgentReopens.get(key);
|
||||
if (pending) {
|
||||
pending.target = target;
|
||||
return;
|
||||
}
|
||||
|
||||
const restore: PendingHistoryRestore = {
|
||||
target,
|
||||
unsubscribe: () => {},
|
||||
};
|
||||
restore.unsubscribe = useSessionStore.subscribe((state) => {
|
||||
const session = state.sessions[serverId];
|
||||
if (session?.restoringWorkspaces.get(workspaceId) === "restoring") {
|
||||
return;
|
||||
}
|
||||
|
||||
restore.unsubscribe();
|
||||
pendingAgentReopens.delete(key);
|
||||
if (session?.workspaces.has(workspaceId)) {
|
||||
restoreHistoryEntry(restore.target);
|
||||
}
|
||||
});
|
||||
pendingAgentReopens.set(key, restore);
|
||||
}
|
||||
|
||||
function restoreHistoryEntry(target: HistoryRestoreTarget): void {
|
||||
const { serverId, agentId, workspaceId, agentArchived } = target;
|
||||
const snapshot = getHostRuntimeStore().getSnapshot(serverId);
|
||||
const client = snapshot?.client ?? null;
|
||||
if (!client || !isHostRuntimeConnected(snapshot)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const store = useSessionStore.getState();
|
||||
const session = store.sessions[serverId];
|
||||
if (!session) {
|
||||
return;
|
||||
}
|
||||
const liveAgent = session.agents.get(agentId) ?? session.agentDetails.get(agentId);
|
||||
const shouldReopenAgent = agentArchived && (!liveAgent || Boolean(liveAgent.archivedAt));
|
||||
if (!workspaceId) {
|
||||
if (shouldReopenAgent) {
|
||||
client.refreshAgent(agentId).catch((error) => {
|
||||
console.error("[HistoryRestore] Failed to reopen archived agent", {
|
||||
serverId,
|
||||
agentId,
|
||||
error,
|
||||
});
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (session.restoringWorkspaces.get(workspaceId) === "restoring") {
|
||||
if (shouldReopenAgent) {
|
||||
reopenAgentAfterWorkspaceRestore(target);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// An empty workspace map is not authoritative during startup. Keep the route on
|
||||
// its existing loading state, then decide whether restoration is needed once the
|
||||
// daemon's workspace snapshot has landed.
|
||||
if (!session.hasHydratedWorkspaces) {
|
||||
restoreWhenWorkspacesHydrate(target);
|
||||
return;
|
||||
}
|
||||
|
||||
// Workspace and agent archive lifecycles are independent. A missing workspace
|
||||
// must restore even when its agent survived unarchived; an archived agent must
|
||||
// still reopen when its workspace survived.
|
||||
if (session.workspaces.has(workspaceId)) {
|
||||
if (shouldReopenAgent) {
|
||||
client.refreshAgent(agentId).catch((error) => {
|
||||
console.error("[HistoryRestore] Failed to reopen archived agent", {
|
||||
serverId,
|
||||
agentId,
|
||||
error,
|
||||
});
|
||||
});
|
||||
}
|
||||
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 {
|
||||
return resolveNavigateToAgent(input, {
|
||||
readAgentNavTarget: ({ serverId, agentId }) => {
|
||||
@@ -187,6 +18,5 @@ export function navigateToAgent(input: NavigateToAgentInput): string {
|
||||
router.navigate(route as Href);
|
||||
},
|
||||
navigateToWorkspace,
|
||||
restoreHistoryEntry,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -16,26 +16,16 @@ interface RecordedHostNav {
|
||||
|
||||
interface RecordedTabNav extends NavigateToWorkspaceInput {}
|
||||
|
||||
interface RecordedRestore {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
workspaceId: string | null;
|
||||
agentArchived: boolean;
|
||||
}
|
||||
|
||||
function createFakeNavigators(target: AgentNavTarget): {
|
||||
deps: NavigateToAgentDeps;
|
||||
hostNavigations: RecordedHostNav[];
|
||||
tabNavigations: RecordedTabNav[];
|
||||
restores: RecordedRestore[];
|
||||
} {
|
||||
const hostNavigations: RecordedHostNav[] = [];
|
||||
const tabNavigations: RecordedTabNav[] = [];
|
||||
const restores: RecordedRestore[] = [];
|
||||
return {
|
||||
hostNavigations,
|
||||
tabNavigations,
|
||||
restores,
|
||||
deps: {
|
||||
readAgentNavTarget: () => target,
|
||||
navigateToHostAgent: (route) => {
|
||||
@@ -45,9 +35,6 @@ function createFakeNavigators(target: AgentNavTarget): {
|
||||
tabNavigations.push(input);
|
||||
return `/h/${input.serverId}/workspace/${input.workspaceId}`;
|
||||
},
|
||||
restoreHistoryEntry: (input) => {
|
||||
restores.push(input);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -75,43 +62,6 @@ describe("resolveNavigateToAgent", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("delegates a restore attempt when History carries explicit restore intent", () => {
|
||||
const { deps, restores, tabNavigations } = createFakeNavigators({
|
||||
agentWorkspaceId: WORKSPACE_ID,
|
||||
});
|
||||
|
||||
resolveNavigateToAgent(
|
||||
{
|
||||
serverId: SERVER_ID,
|
||||
agentId: AGENT_ID,
|
||||
restoreWorkspace: { agentArchived: true },
|
||||
pin: true,
|
||||
},
|
||||
deps,
|
||||
);
|
||||
|
||||
expect(restores).toEqual([
|
||||
{
|
||||
serverId: SERVER_ID,
|
||||
agentId: AGENT_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
agentArchived: true,
|
||||
},
|
||||
]);
|
||||
expect(tabNavigations).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not delegate restore during ordinary workspace navigation", () => {
|
||||
const { deps, restores, tabNavigations } = createFakeNavigators({
|
||||
agentWorkspaceId: WORKSPACE_ID,
|
||||
});
|
||||
|
||||
resolveNavigateToAgent({ serverId: SERVER_ID, agentId: AGENT_ID }, deps);
|
||||
|
||||
expect(restores).toEqual([]);
|
||||
expect(tabNavigations).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("uses the input workspaceId without reading the nav target", () => {
|
||||
const readTargets: { serverId: string; agentId: string }[] = [];
|
||||
const { deps, tabNavigations } = createFakeNavigators({ agentWorkspaceId: null });
|
||||
@@ -136,42 +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("reopens an archived History agent even when it has no workspaceId", () => {
|
||||
const { deps, restores, hostNavigations } = createFakeNavigators({
|
||||
agentWorkspaceId: null,
|
||||
});
|
||||
|
||||
resolveNavigateToAgent(
|
||||
{
|
||||
serverId: SERVER_ID,
|
||||
agentId: AGENT_ID,
|
||||
restoreWorkspace: { agentArchived: true },
|
||||
},
|
||||
deps,
|
||||
);
|
||||
|
||||
expect(restores).toEqual([
|
||||
{
|
||||
serverId: SERVER_ID,
|
||||
agentId: AGENT_ID,
|
||||
workspaceId: null,
|
||||
agentArchived: true,
|
||||
},
|
||||
]);
|
||||
expect(hostNavigations).toEqual([{ route: "/h/server-1/agent/agent-1" }]);
|
||||
});
|
||||
|
||||
it("falls back to the host agent route when the agent has no workspaceId", () => {
|
||||
const { deps, hostNavigations, tabNavigations } = createFakeNavigators({
|
||||
agentWorkspaceId: null,
|
||||
|
||||
@@ -8,12 +8,6 @@ export interface NavigateToAgentInput {
|
||||
// Used as the workspace target when the agent is not yet in the session store
|
||||
// (cold deep-links). Otherwise the workspace is read from the store.
|
||||
workspaceId?: string | null;
|
||||
// History can point at an agent whose owning workspace has been archived even
|
||||
// when the agent itself was not. Other navigation paths must not refresh an
|
||||
// agent just because their workspace descriptor is temporarily unavailable.
|
||||
restoreWorkspace?: {
|
||||
agentArchived: boolean;
|
||||
};
|
||||
pin?: boolean;
|
||||
}
|
||||
|
||||
@@ -21,18 +15,10 @@ export interface AgentNavTarget {
|
||||
agentWorkspaceId: string | null | undefined;
|
||||
}
|
||||
|
||||
export interface HistoryRestoreTarget {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
workspaceId: string | null;
|
||||
agentArchived: boolean;
|
||||
}
|
||||
|
||||
export interface NavigateToAgentDeps {
|
||||
readAgentNavTarget: (input: { serverId: string; agentId: string }) => AgentNavTarget;
|
||||
navigateToHostAgent: (route: string) => void;
|
||||
navigateToWorkspace: (input: NavigateToWorkspaceInput) => string;
|
||||
restoreHistoryEntry: (input: HistoryRestoreTarget) => void;
|
||||
}
|
||||
|
||||
export function resolveNavigateToAgent(
|
||||
@@ -44,15 +30,6 @@ export function resolveNavigateToAgent(
|
||||
deps.readAgentNavTarget({ serverId: input.serverId, agentId: input.agentId }).agentWorkspaceId;
|
||||
const workspaceId = normalizeWorkspaceOpaqueId(agentWorkspaceId);
|
||||
|
||||
if (input.restoreWorkspace) {
|
||||
deps.restoreHistoryEntry({
|
||||
serverId: input.serverId,
|
||||
agentId: input.agentId,
|
||||
workspaceId,
|
||||
agentArchived: input.restoreWorkspace.agentArchived,
|
||||
});
|
||||
}
|
||||
|
||||
if (!workspaceId) {
|
||||
const route = buildHostAgentDetailRoute(input.serverId, input.agentId);
|
||||
deps.navigateToHostAgent(route);
|
||||
|
||||
@@ -1,374 +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, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { navigateToAgent } from "./index";
|
||||
|
||||
const SERVER_ID = "remote-server";
|
||||
const AGENT_ID = "00000000-0000-4000-8000-000000000001";
|
||||
const SECOND_AGENT_ID = "00000000-0000-4000-8000-000000000002";
|
||||
const WORKSPACE_ID = "wks_history_restore";
|
||||
const MISSING_WORKTREE_CWD = "/home/tester/.paseo/worktrees/missing-history-worktree";
|
||||
|
||||
function agent(archivedAt: Date | null, id = AGENT_ID): Agent {
|
||||
const createdAt = new Date("2026-01-01T00:00:00.000Z");
|
||||
return {
|
||||
serverId: SERVER_ID,
|
||||
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: MISSING_WORKTREE_CWD,
|
||||
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;
|
||||
});
|
||||
store.setHasHydratedWorkspaces(SERVER_ID, true);
|
||||
}
|
||||
|
||||
function workspace(): WorkspaceDescriptor {
|
||||
return {
|
||||
id: WORKSPACE_ID,
|
||||
projectId: "project-1",
|
||||
projectDisplayName: "Project 1",
|
||||
projectRootPath: MISSING_WORKTREE_CWD,
|
||||
workspaceDirectory: MISSING_WORKTREE_CWD,
|
||||
projectKind: "git",
|
||||
workspaceKind: "local_checkout",
|
||||
name: "main",
|
||||
status: "done",
|
||||
statusEnteredAt: null,
|
||||
archivingAt: null,
|
||||
diffStat: null,
|
||||
scripts: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe("restoreArchivedWorkspace via navigateToAgent", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
refreshAgent.mockReset();
|
||||
connected = true;
|
||||
seedArchivedAgent();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllTimers();
|
||||
vi.useRealTimers();
|
||||
useSessionStore.getState().clearSession(SERVER_ID);
|
||||
});
|
||||
|
||||
function openFromHistory(entry = { agentArchived: true }): void {
|
||||
openHistoryAgent(AGENT_ID, entry);
|
||||
}
|
||||
|
||||
function openHistoryAgent(agentId: string, entry = { agentArchived: true }): void {
|
||||
const input = {
|
||||
serverId: SERVER_ID,
|
||||
agentId,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
restoreWorkspace: entry,
|
||||
};
|
||||
navigateToAgent(input);
|
||||
}
|
||||
|
||||
function navigateNormally(): void {
|
||||
navigateToAgent({ serverId: SERVER_ID, agentId: AGENT_ID });
|
||||
}
|
||||
|
||||
it("calls refreshAgent once and marks the workspace restoring", () => {
|
||||
refreshAgent.mockImplementation(() => new Promise(() => {}));
|
||||
|
||||
openFromHistory();
|
||||
|
||||
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(() => {}));
|
||||
|
||||
openFromHistory();
|
||||
openFromHistory();
|
||||
openFromHistory();
|
||||
|
||||
expect(refreshAgent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("reopens the selected archived agent after another agent restores its workspace", () => {
|
||||
const store = useSessionStore.getState();
|
||||
store.setAgents(SERVER_ID, (prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(SECOND_AGENT_ID, agent(new Date("2026-01-02T00:00:00.000Z"), SECOND_AGENT_ID));
|
||||
return next;
|
||||
});
|
||||
refreshAgent.mockImplementation(() => new Promise(() => {}));
|
||||
|
||||
openFromHistory();
|
||||
openHistoryAgent(SECOND_AGENT_ID);
|
||||
|
||||
expect(refreshAgent.mock.calls).toEqual([[AGENT_ID]]);
|
||||
|
||||
store.mergeWorkspaces(SERVER_ID, [workspace()]);
|
||||
|
||||
expect(refreshAgent.mock.calls).toEqual([[AGENT_ID], [SECOND_AGENT_ID]]);
|
||||
});
|
||||
|
||||
it("restores a History-opened closed agent whose archived workspace is missing", () => {
|
||||
const store = useSessionStore.getState();
|
||||
store.setAgents(SERVER_ID, (prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(AGENT_ID, { ...agent(null), status: "closed" });
|
||||
return next;
|
||||
});
|
||||
refreshAgent.mockImplementation(() => new Promise(() => {}));
|
||||
|
||||
openFromHistory({ agentArchived: false });
|
||||
|
||||
expect(refreshAgent).toHaveBeenCalledTimes(1);
|
||||
expect(refreshAgent).toHaveBeenCalledWith(AGENT_ID);
|
||||
expect(status()).toBe("restoring");
|
||||
});
|
||||
|
||||
it("reopens an archived History agent without a workspace identity", () => {
|
||||
const store = useSessionStore.getState();
|
||||
store.setAgents(SERVER_ID, (prev) => {
|
||||
const next = new Map(prev);
|
||||
next.delete(AGENT_ID);
|
||||
return next;
|
||||
});
|
||||
refreshAgent.mockImplementation(() => new Promise(() => {}));
|
||||
|
||||
navigateToAgent({
|
||||
serverId: SERVER_ID,
|
||||
agentId: AGENT_ID,
|
||||
restoreWorkspace: { agentArchived: true },
|
||||
});
|
||||
|
||||
expect(refreshAgent).toHaveBeenCalledTimes(1);
|
||||
expect(refreshAgent).toHaveBeenCalledWith(AGENT_ID);
|
||||
expect(status()).toBeNull();
|
||||
});
|
||||
|
||||
it("does not restore during ordinary active-workspace navigation", () => {
|
||||
const store = useSessionStore.getState();
|
||||
store.setAgents(SERVER_ID, (prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(AGENT_ID, { ...agent(null), status: "closed" });
|
||||
return next;
|
||||
});
|
||||
refreshAgent.mockImplementation(() => new Promise(() => {}));
|
||||
|
||||
navigateNormally();
|
||||
|
||||
expect(refreshAgent).not.toHaveBeenCalled();
|
||||
expect(status()).toBeNull();
|
||||
});
|
||||
|
||||
it("waits for workspace hydration before deciding a running History workspace is missing", () => {
|
||||
const store = useSessionStore.getState();
|
||||
store.setHasHydratedWorkspaces(SERVER_ID, false);
|
||||
store.setAgents(SERVER_ID, (prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(AGENT_ID, { ...agent(null), status: "running" });
|
||||
return next;
|
||||
});
|
||||
refreshAgent.mockImplementation(() => new Promise(() => {}));
|
||||
|
||||
openFromHistory({ agentArchived: false });
|
||||
|
||||
expect(refreshAgent).not.toHaveBeenCalled();
|
||||
expect(status()).toBeNull();
|
||||
|
||||
store.mergeWorkspaces(SERVER_ID, [workspace()]);
|
||||
store.setHasHydratedWorkspaces(SERVER_ID, true);
|
||||
|
||||
expect(refreshAgent).not.toHaveBeenCalled();
|
||||
expect(status()).toBeNull();
|
||||
});
|
||||
|
||||
it("restores a missing History workspace after workspace hydration settles", () => {
|
||||
const store = useSessionStore.getState();
|
||||
store.setHasHydratedWorkspaces(SERVER_ID, false);
|
||||
store.setAgents(SERVER_ID, (prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(AGENT_ID, { ...agent(null), status: "closed" });
|
||||
return next;
|
||||
});
|
||||
refreshAgent.mockImplementation(() => new Promise(() => {}));
|
||||
|
||||
openFromHistory({ agentArchived: false });
|
||||
|
||||
expect(refreshAgent).not.toHaveBeenCalled();
|
||||
expect(status()).toBeNull();
|
||||
|
||||
store.setHasHydratedWorkspaces(SERVER_ID, true);
|
||||
|
||||
expect(refreshAgent).toHaveBeenCalledTimes(1);
|
||||
expect(refreshAgent).toHaveBeenCalledWith(AGENT_ID);
|
||||
expect(status()).toBe("restoring");
|
||||
});
|
||||
|
||||
it("does not fire while disconnected", () => {
|
||||
connected = false;
|
||||
refreshAgent.mockImplementation(() => new Promise(() => {}));
|
||||
|
||||
openFromHistory({ agentArchived: false });
|
||||
|
||||
expect(refreshAgent).not.toHaveBeenCalled();
|
||||
expect(status()).toBeNull();
|
||||
});
|
||||
|
||||
it("flips to failed when refreshAgent rejects", async () => {
|
||||
refreshAgent.mockImplementation(() => Promise.reject(new Error("dir gone")));
|
||||
|
||||
openFromHistory();
|
||||
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({}));
|
||||
|
||||
openFromHistory();
|
||||
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(() => {}));
|
||||
|
||||
openFromHistory();
|
||||
|
||||
expect(refreshAgent).not.toHaveBeenCalled();
|
||||
expect(status()).toBe("needs-host-upgrade");
|
||||
});
|
||||
|
||||
it("reopens an archived History agent when its workspace descriptor is already present", () => {
|
||||
refreshAgent.mockImplementation(() => new Promise(() => {}));
|
||||
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [workspace()]);
|
||||
|
||||
openFromHistory();
|
||||
|
||||
expect(refreshAgent).toHaveBeenCalledTimes(1);
|
||||
expect(refreshAgent).toHaveBeenCalledWith(AGENT_ID);
|
||||
expect(status()).toBeNull();
|
||||
});
|
||||
|
||||
it("reopens an archived History agent omitted from the active session directory", () => {
|
||||
const store = useSessionStore.getState();
|
||||
store.setAgents(SERVER_ID, (prev) => {
|
||||
const next = new Map(prev);
|
||||
next.delete(AGENT_ID);
|
||||
return next;
|
||||
});
|
||||
store.mergeWorkspaces(SERVER_ID, [workspace()]);
|
||||
refreshAgent.mockImplementation(() => new Promise(() => {}));
|
||||
|
||||
openFromHistory();
|
||||
|
||||
expect(refreshAgent).toHaveBeenCalledTimes(1);
|
||||
expect(refreshAgent).toHaveBeenCalledWith(AGENT_ID);
|
||||
expect(status()).toBeNull();
|
||||
});
|
||||
|
||||
it("does not reopen an active agent from a stale archived History row", () => {
|
||||
const store = useSessionStore.getState();
|
||||
store.setAgents(SERVER_ID, (prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(AGENT_ID, agent(null));
|
||||
return next;
|
||||
});
|
||||
store.mergeWorkspaces(SERVER_ID, [workspace()]);
|
||||
refreshAgent.mockImplementation(() => new Promise(() => {}));
|
||||
|
||||
openFromHistory();
|
||||
|
||||
expect(refreshAgent).not.toHaveBeenCalled();
|
||||
expect(status()).toBeNull();
|
||||
});
|
||||
|
||||
it("is a no-op when a non-archived workspace descriptor is already present", () => {
|
||||
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(() => {}));
|
||||
store.mergeWorkspaces(SERVER_ID, [workspace()]);
|
||||
|
||||
openFromHistory({ agentArchived: false });
|
||||
|
||||
expect(refreshAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { WorkspaceRecoveryState as AuthoritativeWorkspaceRecoveryState } from "@getpaseo/protocol/messages";
|
||||
|
||||
export type WorkspaceRecoveryModel =
|
||||
| { kind: "idle" }
|
||||
| { kind: "checking" }
|
||||
| { kind: "needsHostUpgrade" }
|
||||
| {
|
||||
kind: "recoverable";
|
||||
recovery: Extract<AuthoritativeWorkspaceRecoveryState, { kind: "recoverable" }>;
|
||||
phase: "ready" | "restoring" | "failed";
|
||||
error: string | null;
|
||||
}
|
||||
| {
|
||||
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";
|
||||
}
|
||||
|
||||
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 {
|
||||
if (input.restore.pending && input.inspection.data?.kind === "recoverable") {
|
||||
return {
|
||||
kind: "recoverable",
|
||||
recovery: input.inspection.data,
|
||||
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") {
|
||||
return {
|
||||
kind: "recoverable",
|
||||
recovery: input.inspection.data,
|
||||
phase: resolveRecoveryPhase(input.restore),
|
||||
error: input.restore.error,
|
||||
};
|
||||
}
|
||||
return { kind: "checking" };
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
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();
|
||||
const [result] = await Promise.allSettled([
|
||||
client.restoreWorkspace(input.workspaceId),
|
||||
waitForMinimumRecoveryLoadingTime(),
|
||||
]);
|
||||
if (result.status === "rejected") {
|
||||
throw result.reason;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
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 };
|
||||
}
|
||||
@@ -89,6 +89,7 @@ import type {
|
||||
PaseoConfigRaw,
|
||||
PaseoConfigRevision,
|
||||
WorkspaceCreateRequest,
|
||||
WorkspaceRecoveryState,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import type {
|
||||
AgentPermissionRequest,
|
||||
@@ -2389,6 +2390,36 @@ export class DaemonClient {
|
||||
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(
|
||||
handle: AgentPersistenceHandle,
|
||||
overrides?: Partial<AgentSessionConfig>,
|
||||
|
||||
@@ -838,6 +838,18 @@ export const WorkspacePinSetRequestSchema = z.object({
|
||||
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({
|
||||
type: z.literal("set_voice_mode"),
|
||||
enabled: z.boolean(),
|
||||
@@ -1471,6 +1483,47 @@ export const WorkspacePinSetResponseSchema = z.object({
|
||||
payload: WorkspacePinSetResponsePayloadSchema,
|
||||
});
|
||||
|
||||
export const WorkspaceRecoveryStateSchema = z.discriminatedUnion("kind", [
|
||||
z.object({
|
||||
kind: z.literal("recoverable"),
|
||||
workspaceId: z.string(),
|
||||
workspaceName: z.string(),
|
||||
action: z.enum(["unarchive", "restore"]),
|
||||
branch: z.string().nullable(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("unavailable"),
|
||||
workspaceId: z.string(),
|
||||
reason: z.enum([
|
||||
"workspace_not_found",
|
||||
"workspace_not_archived",
|
||||
"project_not_found",
|
||||
"project_directory_missing",
|
||||
"workspace_directory_missing",
|
||||
"worktree_branch_missing",
|
||||
]),
|
||||
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({
|
||||
type: z.literal("set_voice_mode_response"),
|
||||
payload: z.object({
|
||||
@@ -2143,6 +2196,8 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
ProjectRemoveRequestSchema,
|
||||
WorkspaceTitleSetRequestSchema,
|
||||
WorkspacePinSetRequestSchema,
|
||||
WorkspaceRecoveryInspectRequestSchema,
|
||||
WorkspaceRecoveryRestoreRequestSchema,
|
||||
SetVoiceModeMessageSchema,
|
||||
SendAgentMessageRequestSchema,
|
||||
WaitForFinishRequestSchema,
|
||||
@@ -2449,6 +2504,8 @@ export const ServerInfoStatusPayloadSchema = z
|
||||
projectAdd: z.boolean().optional(),
|
||||
// COMPAT(worktreeRestore): added in v0.1.97, drop the gate when floor >= v0.1.97
|
||||
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.
|
||||
providerUsageList: z.boolean().optional(),
|
||||
// COMPAT(agentDetach): added in v0.1.98, remove gate after 2026-12-19 once daemon floor >= v0.1.98.
|
||||
@@ -4496,6 +4553,8 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
ProjectRemoveResponseSchema,
|
||||
WorkspaceTitleSetResponseSchema,
|
||||
WorkspacePinSetResponseSchema,
|
||||
WorkspaceRecoveryInspectResponseSchema,
|
||||
WorkspaceRecoveryRestoreResponseSchema,
|
||||
WaitForFinishResponseMessageSchema,
|
||||
AgentPermissionRequestMessageSchema,
|
||||
AgentPermissionResolvedMessageSchema,
|
||||
@@ -4660,6 +4719,13 @@ export type WorkspaceTitleSetResponsePayload = z.infer<
|
||||
>;
|
||||
export type WorkspacePinSetResponse = z.infer<typeof WorkspacePinSetResponseSchema>;
|
||||
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 WorkspaceCreateResponse = z.infer<typeof WorkspaceCreateResponseSchema>;
|
||||
export type ProjectRenameResponsePayload = z.infer<typeof ProjectRenameResponsePayloadSchema>;
|
||||
@@ -4793,6 +4859,8 @@ export type ProjectRenameRequest = z.infer<typeof ProjectRenameRequestSchema>;
|
||||
export type ProjectRemoveRequest = z.infer<typeof ProjectRemoveRequestSchema>;
|
||||
export type WorkspaceTitleSetRequest = z.infer<typeof WorkspaceTitleSetRequestSchema>;
|
||||
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 SetAgentModelRequestMessage = z.infer<typeof SetAgentModelRequestMessageSchema>;
|
||||
export type SetAgentThinkingRequestMessage = z.infer<typeof SetAgentThinkingRequestMessageSchema>;
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -167,6 +167,10 @@ import {
|
||||
createWorkspaceProvisioningService,
|
||||
type WorkspaceProvisioningService,
|
||||
} from "./session/workspace-provisioning/workspace-provisioning-service.js";
|
||||
import {
|
||||
createWorkspaceRecoveryService,
|
||||
type WorkspaceRecoveryService,
|
||||
} from "./session/workspace-recovery/workspace-recovery-service.js";
|
||||
import {
|
||||
createAgentUpdatesService,
|
||||
matchesAgentUpdatesFilter,
|
||||
@@ -563,6 +567,7 @@ export class Session {
|
||||
private readonly workspaceAutoName: WorkspaceAutoName;
|
||||
private readonly gitMutation: GitMutationService;
|
||||
private readonly workspaceProvisioning: WorkspaceProvisioningService;
|
||||
private readonly workspaceRecovery: WorkspaceRecoveryService;
|
||||
private readonly daemonConfigStore: DaemonConfigStore;
|
||||
private readonly pushTokenStore: PushTokenStore;
|
||||
private unsubscribeAgentEvents: (() => void) | null = null;
|
||||
@@ -697,6 +702,15 @@ export class Session {
|
||||
projectRegistry: this.projectRegistry,
|
||||
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({
|
||||
host: {
|
||||
emit: (msg) => this.emit(msg),
|
||||
@@ -1389,6 +1403,7 @@ export class Session {
|
||||
this.dispatchAgentLifecycleMessage(msg) ??
|
||||
this.dispatchAgentConfigMessage(msg) ??
|
||||
this.dispatchCheckoutMessage(msg) ??
|
||||
this.dispatchWorkspaceRecoveryMessage(msg) ??
|
||||
this.dispatchWorkspaceAndProjectMessage(msg) ??
|
||||
this.dispatchWorkspaceFileMessage(msg) ??
|
||||
this.dispatchProviderMessage(msg) ??
|
||||
@@ -1685,6 +1700,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 {
|
||||
switch (msg.type) {
|
||||
case "list_provider_models_request":
|
||||
@@ -2400,6 +2426,54 @@ 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.workspaceRecovery.restore(request.workspaceId);
|
||||
await this.emitWorkspaceUpdatesForWorkspaceIds([request.workspaceId], {
|
||||
skipReconcile: true,
|
||||
});
|
||||
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)
|
||||
*/
|
||||
@@ -2763,7 +2837,6 @@ export class Session {
|
||||
|
||||
try {
|
||||
await unarchiveAgentState(this.agentStorage, this.agentManager, agentId);
|
||||
await this.unarchiveOwningWorkspaceForAgent(agentId);
|
||||
let snapshot: ManagedAgent;
|
||||
const existing = this.agentManager.getAgent(agentId);
|
||||
if (existing) {
|
||||
@@ -3873,35 +3946,14 @@ export class Session {
|
||||
};
|
||||
}
|
||||
|
||||
private async unarchiveOwningWorkspaceForAgent(agentId: string): Promise<void> {
|
||||
const record = await this.agentStorage.get(agentId);
|
||||
if (!record?.workspaceId) {
|
||||
return;
|
||||
private async recreateArchivedWorktree(workspace: PersistedWorkspaceRecord): Promise<void> {
|
||||
const branch = workspace.branch;
|
||||
if (!branch) {
|
||||
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);
|
||||
if (!project) {
|
||||
throw new WorktreeRequestError({
|
||||
|
||||
@@ -32,13 +32,13 @@ import type {
|
||||
AgentSessionConfig,
|
||||
AgentStreamEvent,
|
||||
} from "./agent/agent-sdk-types.js";
|
||||
import { createWorktree, UnknownBranchError } from "../utils/worktree.js";
|
||||
import { createWorktree } from "../utils/worktree.js";
|
||||
import {
|
||||
readPaseoWorktreeMetadata,
|
||||
writePaseoWorktreeFirstAgentBranchAutoNameMetadata,
|
||||
writePaseoWorktreeMetadata,
|
||||
} 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 { GeneratedWorkspaceName } from "./worktree-branch-name-generator.js";
|
||||
import { WorkspaceAutoName } from "./workspace-auto-name.js";
|
||||
@@ -128,10 +128,7 @@ interface SessionTestAccess {
|
||||
agentUpdates: AgentUpdatesService;
|
||||
workspaceUpdatesSubscription: unknown;
|
||||
interruptAgentIfRunning(agentId: string): unknown;
|
||||
recreateOwningWorktreeForRestore(
|
||||
workspace: PersistedWorkspaceRecord,
|
||||
branch: string,
|
||||
): Promise<void>;
|
||||
recreateArchivedWorktree(workspace: PersistedWorkspaceRecord): Promise<void>;
|
||||
reconcileActiveWorkspaceRecords(...args: unknown[]): Promise<Set<string>>;
|
||||
reconcileWorkspaceRecord(workspaceId: string): Promise<{
|
||||
changed: boolean;
|
||||
@@ -4422,7 +4419,7 @@ test("open_project_request recreates a missing project record when unarchiving i
|
||||
expect(response?.payload.workspace?.projectDisplayName).toBe("repo");
|
||||
});
|
||||
|
||||
test("refresh_agent_request unarchives the owning workspace when its directory exists", async () => {
|
||||
test("refresh_agent_request leaves workspace archival independent when its directory exists", async () => {
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const session = createSessionForWorkspaceTests({
|
||||
onMessage: (message) => {
|
||||
@@ -4514,13 +4511,13 @@ test("refresh_agent_request unarchives the owning workspace when its directory e
|
||||
requestId: "req-refresh-unarchive",
|
||||
});
|
||||
|
||||
expect(workspaces.get(workspaceId)?.archivedAt).toBeNull();
|
||||
expect(projects.get(cwd)?.archivedAt).toBeNull();
|
||||
expect(unarchivedWorkspaceIds).toContainEqual([workspaceId]);
|
||||
expect(workspaces.get(workspaceId)?.archivedAt).toBe("2026-03-10T00:00:00.000Z");
|
||||
expect(projects.get(cwd)?.archivedAt).toBe("2026-03-10T00:00:00.000Z");
|
||||
expect(unarchivedWorkspaceIds).toEqual([]);
|
||||
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 session = createSessionForWorkspaceTests({
|
||||
onMessage: (message) => {
|
||||
@@ -4609,7 +4606,7 @@ test("refresh_agent_request leaves the owning workspace archived when its direct
|
||||
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 session = createSessionForWorkspaceTests({
|
||||
onMessage: (message) => {
|
||||
@@ -4675,13 +4672,6 @@ test("refresh_agent_request recreates a deleted worktree directory and unarchive
|
||||
session.agentStorage.get = async (id: string) => (id === agentId ? storedAgent : null);
|
||||
session.agentStorage.upsert = async () => {};
|
||||
|
||||
const recreateCalls: string[] = [];
|
||||
session.recreateOwningWorktreeForRestore = async (
|
||||
workspace: PersistedWorkspaceRecord,
|
||||
): Promise<void> => {
|
||||
recreateCalls.push(workspace.workspaceId);
|
||||
};
|
||||
|
||||
const managed = makeManagedAgent({
|
||||
id: agentId,
|
||||
cwd,
|
||||
@@ -4709,14 +4699,13 @@ test("refresh_agent_request recreates a deleted worktree directory and unarchive
|
||||
requestId: "req-refresh-recreate-worktree",
|
||||
});
|
||||
|
||||
expect(recreateCalls).toEqual([workspaceId]);
|
||||
expect(workspaces.get(workspaceId)?.archivedAt).toBeNull();
|
||||
expect(workspaces.get(workspaceId)?.archivedAt).toBe("2026-03-10T00:00:00.000Z");
|
||||
expect(workspaces.get(workspaceId)?.workspaceId).toBe(workspaceId);
|
||||
expect(unarchivedWorkspaceIds).toContainEqual([workspaceId]);
|
||||
expect(unarchivedWorkspaceIds).toEqual([]);
|
||||
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 session = createSessionForWorkspaceTests({
|
||||
onMessage: (message) => {
|
||||
@@ -4782,10 +4771,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.upsert = async () => {};
|
||||
|
||||
session.recreateOwningWorktreeForRestore = async (): Promise<void> => {
|
||||
throw toWorktreeRequestError(new UnknownBranchError({ branchName: "feature/gone", cwd }));
|
||||
};
|
||||
|
||||
const managed = makeManagedAgent({
|
||||
id: agentId,
|
||||
cwd,
|
||||
@@ -4807,9 +4792,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");
|
||||
const rpcError = findByType(emitted, "rpc_error");
|
||||
expect(rpcError).toBeDefined();
|
||||
expect((rpcError?.payload as { code?: string } | undefined)?.code).toBe("unknown_branch");
|
||||
expect(findByType(emitted, "rpc_error")).toBeUndefined();
|
||||
});
|
||||
|
||||
function createRecreateWorktreeRepo(): { tempDir: string; repoDir: string } {
|
||||
@@ -4828,7 +4811,7 @@ function createRecreateWorktreeRepo(): { tempDir: string; repoDir: string } {
|
||||
return { tempDir, repoDir };
|
||||
}
|
||||
|
||||
test("refresh_agent_request recreates a real deleted worktree against a temp git repo and unarchives the same workspace", async () => {
|
||||
test("refresh_agent_request leaves a real deleted worktree for explicit workspace recovery", async () => {
|
||||
const { tempDir, repoDir } = createRecreateWorktreeRepo();
|
||||
const branch = "feature/keep";
|
||||
execFileSync("git", ["branch", branch], { cwd: repoDir, stdio: "pipe" });
|
||||
@@ -4937,22 +4920,15 @@ test("refresh_agent_request recreates a real deleted worktree against a temp git
|
||||
});
|
||||
|
||||
expect(findByType(emitted, "rpc_error")).toBeUndefined();
|
||||
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(existsSync(worktreePath)).toBe(false);
|
||||
expect(workspaces.get(workspaceId)?.workspaceId).toBe(workspaceId);
|
||||
expect(workspaces.get(workspaceId)?.cwd).toBe(worktreePath);
|
||||
expect(workspaces.get(workspaceId)?.archivedAt).toBeNull();
|
||||
expect(workspaces.get(workspaceId)?.archivedAt).toBe("2026-03-10T00:00:00.000Z");
|
||||
|
||||
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 branch = "feature/keep";
|
||||
execFileSync("git", ["branch", branch], { cwd: repoDir, stdio: "pipe" });
|
||||
@@ -5005,9 +4981,9 @@ test("recreateOwningWorktreeForRestore throws a typed WorktreeRequestError and l
|
||||
session.filesystem.isDirectory = async (target: string) =>
|
||||
existsSync(target) && statSync(target).isDirectory();
|
||||
|
||||
await expect(
|
||||
session.recreateOwningWorktreeForRestore(workspaceRecord, branch),
|
||||
).rejects.toBeInstanceOf(WorktreeRequestError);
|
||||
await expect(session.recreateArchivedWorktree(workspaceRecord)).rejects.toBeInstanceOf(
|
||||
WorktreeRequestError,
|
||||
);
|
||||
// Guard fires before createWorktree, so archivedAt is untouched.
|
||||
expect(workspaces.get(workspaceId)?.archivedAt).toBe("2026-03-10T00:00:00.000Z");
|
||||
|
||||
|
||||
+131
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -1228,8 +1228,8 @@ export class VoiceAssistantWebSocketServer {
|
||||
projectRemove: true,
|
||||
// COMPAT(projectAdd): added in v0.1.97, drop the gate when floor >= v0.1.97.
|
||||
projectAdd: true,
|
||||
// COMPAT(worktreeRestore): added in v0.1.97, drop the gate when floor >= v0.1.97
|
||||
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.
|
||||
providerUsageList: true,
|
||||
// COMPAT(agentDetach): added in v0.1.98, remove gate after 2026-12-19 once daemon floor >= v0.1.98.
|
||||
|
||||
Reference in New Issue
Block a user