Restore an archived workspace when its agent is reopened

Reopening an archived agent previously dead-ended at "workspace not
found": the agent record was un-archived but its workspace stayed
archived and was never sent to the client.

The daemon now un-archives the owning workspace alongside the agent when
its backing directory still exists, and the app auto-fires the restore
ahead of the missing-workspace gate so the user lands back in the
workspace rather than a dead end.

Worktrees whose directory was deleted on archive are not yet restorable
(recreating them from their branch is a follow-up); the workspace record
now persists its base branch as groundwork for that.
This commit is contained in:
Mohamed Boudra
2026-06-18 20:44:56 +07:00
parent fe15b68e06
commit 31fcde2ea6
22 changed files with 778 additions and 17 deletions
@@ -21,6 +21,8 @@ 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 "unreachable":
return (
<WorkspaceUnreachable
@@ -33,6 +35,7 @@ export function renderWorkspaceRouteGate(input: {
return (
<WorkspaceMissing
hostName={input.state.hostName}
restoreFailed={input.state.restoreFailed}
onDismiss={input.actions.onDismissMissingWorkspace}
/>
);
@@ -70,6 +73,21 @@ function WorkspaceConnecting({ hostName }: { hostName: string }) {
);
}
function WorkspaceRestoring({ hostName }: { hostName: string }) {
const { theme } = useUnistyles();
const { t } = useTranslation();
return (
<View style={styles.emptyState}>
<LoadingSpinner size="small" color={theme.colors.foregroundMuted} />
<View style={styles.textStack}>
<Text style={styles.title}>{t("workspace.route.restoring")}</Text>
<Text style={styles.description}>{hostName}</Text>
</View>
</View>
);
}
function WorkspaceUnreachable({
state,
onRetry,
@@ -124,13 +142,23 @@ function WorkspaceUnreachable({
);
}
function WorkspaceMissing({ hostName, onDismiss }: { hostName: string; onDismiss: () => void }) {
function WorkspaceMissing({
hostName,
restoreFailed,
onDismiss,
}: {
hostName: string;
restoreFailed: boolean;
onDismiss: () => void;
}) {
const { t } = useTranslation();
return (
<View style={styles.emptyState}>
<View style={styles.textStack}>
<Text style={styles.title}>{t("workspace.route.missing")}</Text>
<Text style={styles.title}>
{restoreFailed ? t("workspace.route.restoreFailed") : t("workspace.route.missing")}
</Text>
<Text style={styles.description}>{hostName}</Text>
</View>
<View style={styles.actions}>
@@ -29,6 +29,7 @@ describe("resolveWorkspaceRouteState", () => {
lastError: "transport closed",
workspace: null,
hasHydratedWorkspaces: false,
restoreStatus: null,
}),
).toEqual({
kind: "unreachable",
@@ -46,6 +47,7 @@ describe("resolveWorkspaceRouteState", () => {
lastError: "transport closed",
workspace: null,
hasHydratedWorkspaces: true,
restoreStatus: null,
}),
).toEqual({
kind: "unreachable",
@@ -63,6 +65,7 @@ describe("resolveWorkspaceRouteState", () => {
lastError: "transport closed",
workspace: createWorkspaceDescriptor(),
hasHydratedWorkspaces: true,
restoreStatus: null,
}),
).toEqual({
kind: "reconnecting",
@@ -80,8 +83,9 @@ describe("resolveWorkspaceRouteState", () => {
lastError: null,
workspace: null,
hasHydratedWorkspaces: true,
restoreStatus: null,
}),
).toEqual({ kind: "missing", hostName: "Laptop" });
).toEqual({ kind: "missing", hostName: "Laptop", restoreFailed: false });
});
it("returns loading before workspace hydration when the host is online", () => {
@@ -92,6 +96,7 @@ describe("resolveWorkspaceRouteState", () => {
lastError: null,
workspace: null,
hasHydratedWorkspaces: false,
restoreStatus: null,
}),
).toEqual({ kind: "loading", hostName: "Laptop" });
});
@@ -104,6 +109,46 @@ describe("resolveWorkspaceRouteState", () => {
lastError: null,
workspace: createWorkspaceDescriptor(),
hasHydratedWorkspaces: true,
restoreStatus: null,
}),
).toEqual({ kind: "ready" });
});
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("falls back to a restore-failed missing state once the restore times out", () => {
expect(
resolveWorkspaceRouteState({
hostName: "Laptop",
connectionStatus: "online",
lastError: null,
workspace: null,
hasHydratedWorkspaces: true,
restoreStatus: "failed",
}),
).toEqual({ kind: "missing", hostName: "Laptop", restoreFailed: true });
});
it("resolves to ready when the descriptor arrives even while restoring", () => {
expect(
resolveWorkspaceRouteState({
hostName: "Laptop",
connectionStatus: "online",
lastError: null,
workspace: createWorkspaceDescriptor(),
hasHydratedWorkspaces: true,
restoreStatus: "restoring",
}),
).toEqual({ kind: "ready" });
});
@@ -16,7 +16,8 @@ export type WorkspaceRouteState =
lastError: string | null;
}
| { kind: "loading"; hostName: string }
| { kind: "missing"; hostName: string };
| { kind: "restoring"; hostName: string }
| { kind: "missing"; hostName: string; restoreFailed: boolean };
export function resolveWorkspaceRouteState(input: {
hostName: string;
@@ -24,6 +25,7 @@ export function resolveWorkspaceRouteState(input: {
lastError: string | null;
workspace: WorkspaceDescriptor | null;
hasHydratedWorkspaces: boolean;
restoreStatus: "restoring" | "failed" | null;
}): WorkspaceRouteState {
if (input.workspace) {
if (input.connectionStatus === "online") {
@@ -39,8 +41,16 @@ export function resolveWorkspaceRouteState(input: {
}
if (input.connectionStatus === "online") {
if (input.restoreStatus === "restoring") {
return { kind: "restoring", hostName: input.hostName };
}
if (input.hasHydratedWorkspaces) {
return { kind: "missing", hostName: input.hostName };
return {
kind: "missing",
hostName: input.hostName,
restoreFailed: input.restoreStatus === "failed",
};
}
return { kind: "loading", hostName: input.hostName };
@@ -71,7 +71,11 @@ import { useToast } from "@/contexts/toast-context";
import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
import { selectIsFileExplorerOpen, usePanelStore } from "@/stores/panel-store";
import { type ExplorerCheckoutContext } from "@/stores/explorer-checkout-context";
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
import {
useSessionStore,
useWorkspaceRestoreStatus,
type WorkspaceDescriptor,
} from "@/stores/session-store";
import {
buildWorkspaceTabPersistenceKey,
collectAllTabs,
@@ -1487,6 +1491,7 @@ function useWorkspaceRouteActions(normalizedServerId: string): {
function useResolvedWorkspaceRouteState(input: {
serverId: string;
workspaceId: string;
workspace: WorkspaceDescriptor | null;
hasHydratedWorkspaces: boolean;
}): WorkspaceRouteState {
@@ -1497,6 +1502,7 @@ 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(
() =>
@@ -1506,6 +1512,7 @@ function useResolvedWorkspaceRouteState(input: {
lastError: hostSnapshot?.lastError ?? null,
workspace: input.workspace,
hasHydratedWorkspaces: input.hasHydratedWorkspaces,
restoreStatus,
}),
[
hostName,
@@ -1513,6 +1520,7 @@ function useResolvedWorkspaceRouteState(input: {
hostSnapshot?.lastError,
input.workspace,
input.hasHydratedWorkspaces,
restoreStatus,
],
);
}
@@ -1858,6 +1866,7 @@ function WorkspaceScreenContent({
);
const workspaceRouteState = useResolvedWorkspaceRouteState({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
workspace: workspaceDescriptor,
hasHydratedWorkspaces,
});