fix(app): require explicit workspace recovery

This commit is contained in:
Mohamed Boudra
2026-07-16 15:14:30 +02:00
parent b1e9ac0009
commit 05cb0f99e5
39 changed files with 1293 additions and 1129 deletions
+3 -2
View File
@@ -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
+4 -6
View File
@@ -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);
});
});
-6
View File
@@ -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,
},
}),
);
+5 -1
View File
@@ -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 };
+140 -49
View File
@@ -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);
}
});
});
+1 -1
View File
@@ -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],
+2 -1
View File
@@ -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");
+12 -3
View File
@@ -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",
+12 -4
View File
@@ -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",
+13 -4
View File
@@ -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",
+12 -4
View File
@@ -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",
+13 -4
View File
@@ -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: "ワークスペーススクリプト",
+13 -4
View File
@@ -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",
+13 -4
View File
@@ -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",
+12 -3
View File
@@ -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", () => {
-81
View File
@@ -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 };
}
+31
View File
@@ -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>,
+68
View File
@@ -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);
});
});
+81 -29
View File
@@ -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");
@@ -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.