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:
Mohamed Boudra
2026-06-18 20:44:57 +07:00
parent 31fcde2ea6
commit b3f81c0bf4
17 changed files with 670 additions and 24 deletions
@@ -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",