Recreate deleted worktrees from their branch on restore
Archiving a Paseo-owned worktree deletes its directory, so reopening its agent previously dead-ended even after the workspace record came back — there was no folder to return to. Restore now recreates the worktree at its original path from the kept branch (the branch is never deleted on archive), re-points the same workspace record, and clears archivedAt — preserving the workspaceId so the agent stays linked. The directory is recreated before the un-archive so the reconciler can't re-archive it for a missing directory. A deleted or already-checked-out branch surfaces a typed failure and leaves the workspace archived. Gated behind a worktreeRestore capability flag; older hosts get an explicit "update your host" state instead of a misleading failure.
This commit is contained in:
@@ -335,6 +335,7 @@ export const ar: TranslationResources = {
|
||||
cannotReachHost: "لا يمكن الوصول إلى{{hostName}}",
|
||||
hostStatus: "حالة Host:{{status}}",
|
||||
missing: "لم يتم العثور على Workspace",
|
||||
needsHostUpgrade: "قم بتحديث مضيفك لاستعادة مساحة العمل هذه",
|
||||
manageHost: "إدارة المضيف",
|
||||
},
|
||||
hoverCard: {
|
||||
|
||||
@@ -335,6 +335,7 @@ export const en = {
|
||||
cannotReachHost: "Cannot reach {{hostName}}",
|
||||
hostStatus: "Host status: {{status}}",
|
||||
missing: "Workspace not found",
|
||||
needsHostUpgrade: "Update your host to restore this workspace",
|
||||
manageHost: "Manage host",
|
||||
},
|
||||
hoverCard: {
|
||||
|
||||
@@ -339,6 +339,7 @@ export const es: TranslationResources = {
|
||||
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",
|
||||
},
|
||||
hoverCard: {
|
||||
|
||||
@@ -339,6 +339,7 @@ export const fr: TranslationResources = {
|
||||
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",
|
||||
},
|
||||
hoverCard: {
|
||||
|
||||
@@ -338,6 +338,7 @@ export const ru: TranslationResources = {
|
||||
cannotReachHost: "Невозможно связаться с{{hostName}}",
|
||||
hostStatus: "Статус Host:{{status}}",
|
||||
missing: "Workspace не найден",
|
||||
needsHostUpgrade: "Обновите хост, чтобы восстановить эту рабочую область",
|
||||
manageHost: "Управление хостом",
|
||||
},
|
||||
hoverCard: {
|
||||
|
||||
@@ -335,6 +335,7 @@ export const zhCN: TranslationResources = {
|
||||
cannotReachHost: "无法连接 {{hostName}}",
|
||||
hostStatus: "Host 状态:{{status}}",
|
||||
missing: "Workspace 未找到",
|
||||
needsHostUpgrade: "更新你的 Host 以恢复此 workspace",
|
||||
manageHost: "管理 Host",
|
||||
},
|
||||
hoverCard: {
|
||||
|
||||
@@ -23,6 +23,14 @@ export function renderWorkspaceRouteGate(input: {
|
||||
return <WorkspaceConnecting hostName={input.state.hostName} />;
|
||||
case "restoring":
|
||||
return <WorkspaceRestoring hostName={input.state.hostName} />;
|
||||
case "needsHostUpgrade":
|
||||
return (
|
||||
<WorkspaceEmptyState
|
||||
titleKey="workspace.route.needsHostUpgrade"
|
||||
hostName={input.state.hostName}
|
||||
onDismiss={input.actions.onDismissMissingWorkspace}
|
||||
/>
|
||||
);
|
||||
case "unreachable":
|
||||
return (
|
||||
<WorkspaceUnreachable
|
||||
@@ -33,9 +41,11 @@ export function renderWorkspaceRouteGate(input: {
|
||||
);
|
||||
case "missing":
|
||||
return (
|
||||
<WorkspaceMissing
|
||||
<WorkspaceEmptyState
|
||||
titleKey={
|
||||
input.state.restoreFailed ? "workspace.route.restoreFailed" : "workspace.route.missing"
|
||||
}
|
||||
hostName={input.state.hostName}
|
||||
restoreFailed={input.state.restoreFailed}
|
||||
onDismiss={input.actions.onDismissMissingWorkspace}
|
||||
/>
|
||||
);
|
||||
@@ -142,13 +152,16 @@ function WorkspaceUnreachable({
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceMissing({
|
||||
function WorkspaceEmptyState({
|
||||
titleKey,
|
||||
hostName,
|
||||
restoreFailed,
|
||||
onDismiss,
|
||||
}: {
|
||||
titleKey:
|
||||
| "workspace.route.missing"
|
||||
| "workspace.route.restoreFailed"
|
||||
| "workspace.route.needsHostUpgrade";
|
||||
hostName: string;
|
||||
restoreFailed: boolean;
|
||||
onDismiss: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
@@ -156,9 +169,7 @@ function WorkspaceMissing({
|
||||
return (
|
||||
<View style={styles.emptyState}>
|
||||
<View style={styles.textStack}>
|
||||
<Text style={styles.title}>
|
||||
{restoreFailed ? t("workspace.route.restoreFailed") : t("workspace.route.missing")}
|
||||
</Text>
|
||||
<Text style={styles.title}>{t(titleKey)}</Text>
|
||||
<Text style={styles.description}>{hostName}</Text>
|
||||
</View>
|
||||
<View style={styles.actions}>
|
||||
|
||||
@@ -127,6 +127,19 @@ describe("resolveWorkspaceRouteState", () => {
|
||||
).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({
|
||||
|
||||
@@ -17,6 +17,7 @@ export type WorkspaceRouteState =
|
||||
}
|
||||
| { kind: "loading"; hostName: string }
|
||||
| { kind: "restoring"; hostName: string }
|
||||
| { kind: "needsHostUpgrade"; hostName: string }
|
||||
| { kind: "missing"; hostName: string; restoreFailed: boolean };
|
||||
|
||||
export function resolveWorkspaceRouteState(input: {
|
||||
@@ -25,7 +26,7 @@ export function resolveWorkspaceRouteState(input: {
|
||||
lastError: string | null;
|
||||
workspace: WorkspaceDescriptor | null;
|
||||
hasHydratedWorkspaces: boolean;
|
||||
restoreStatus: "restoring" | "failed" | null;
|
||||
restoreStatus: "restoring" | "failed" | "needs-host-upgrade" | null;
|
||||
}): WorkspaceRouteState {
|
||||
if (input.workspace) {
|
||||
if (input.connectionStatus === "online") {
|
||||
@@ -45,6 +46,10 @@ export function resolveWorkspaceRouteState(input: {
|
||||
return { kind: "restoring", hostName: input.hostName };
|
||||
}
|
||||
|
||||
if (input.restoreStatus === "needs-host-upgrade") {
|
||||
return { kind: "needsHostUpgrade", hostName: input.hostName };
|
||||
}
|
||||
|
||||
if (input.hasHydratedWorkspaces) {
|
||||
return {
|
||||
kind: "missing",
|
||||
|
||||
@@ -287,7 +287,7 @@ export interface AgentTimelineCursorState {
|
||||
endSeq: number;
|
||||
}
|
||||
|
||||
export type WorkspaceRestoreStatus = "restoring" | "failed";
|
||||
export type WorkspaceRestoreStatus = "restoring" | "failed" | "needs-host-upgrade";
|
||||
|
||||
// Per-session state
|
||||
export interface SessionState {
|
||||
|
||||
@@ -7,8 +7,10 @@ import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
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.
|
||||
const RESTORE_TIMEOUT_MS = 7000;
|
||||
// re-emitting a workspace_update (the directory-gone case), so the gate never spins
|
||||
// forever. Recreating a worktree can require a git fetch, so the budget is generous
|
||||
// to avoid flashing a false "failed" on a capable daemon doing slow real work.
|
||||
const RESTORE_TIMEOUT_MS = 30000;
|
||||
|
||||
function restoreArchivedWorkspace(serverId: string, agentId: string, workspaceId: string): void {
|
||||
const snapshot = getHostRuntimeStore().getSnapshot(serverId);
|
||||
@@ -33,6 +35,15 @@ function restoreArchivedWorkspace(serverId: string, agentId: string, workspaceId
|
||||
return;
|
||||
}
|
||||
|
||||
// COMPAT(worktreeRestore): added in v0.1.98, drop the gate when floor >= v0.1.98
|
||||
// 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(
|
||||
|
||||
@@ -27,15 +27,21 @@ const SERVER_ID = "server-1";
|
||||
const AGENT_ID = "agent-1";
|
||||
const WORKSPACE_ID = "workspace-1";
|
||||
|
||||
function status(): "restoring" | "failed" | null {
|
||||
function status(): "restoring" | "failed" | "needs-host-upgrade" | null {
|
||||
return (
|
||||
useSessionStore.getState().sessions[SERVER_ID]?.restoringWorkspaces.get(WORKSPACE_ID) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function seedArchivedAgent(): void {
|
||||
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, {
|
||||
@@ -133,10 +139,21 @@ describe("restoreArchivedWorkspace via navigateToAgent", () => {
|
||||
await Promise.resolve();
|
||||
expect(status()).toBe("restoring");
|
||||
|
||||
await vi.advanceTimersByTimeAsync(7000);
|
||||
await vi.advanceTimersByTimeAsync(30000);
|
||||
expect(status()).toBe("failed");
|
||||
});
|
||||
|
||||
it("marks the workspace needs-host-upgrade without refreshing when the daemon lacks worktreeRestore", () => {
|
||||
useSessionStore.getState().clearSession(SERVER_ID);
|
||||
seedArchivedAgent({ worktreeRestore: false });
|
||||
refreshAgent.mockImplementation(() => new Promise(() => {}));
|
||||
|
||||
trigger();
|
||||
|
||||
expect(refreshAgent).not.toHaveBeenCalled();
|
||||
expect(status()).toBe("needs-host-upgrade");
|
||||
});
|
||||
|
||||
it("is a no-op when the workspace descriptor is already present", () => {
|
||||
refreshAgent.mockImplementation(() => new Promise(() => {}));
|
||||
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [
|
||||
|
||||
Reference in New Issue
Block a user