diff --git a/packages/app/src/screens/workspace/workspace-route-state.test.ts b/packages/app/src/screens/workspace/workspace-route-state.test.ts index 9c254c071d..734476ab9d 100644 --- a/packages/app/src/screens/workspace/workspace-route-state.test.ts +++ b/packages/app/src/screens/workspace/workspace-route-state.test.ts @@ -128,6 +128,19 @@ describe("resolveWorkspaceRouteState", () => { hostName: "Laptop", message: "The workspace directory cannot be recreated.", }); + + expect( + resolve({ + recovery: { + kind: "unsupportedAction", + action: "repair_from_snapshot", + }, + }), + ).toEqual({ + kind: "recoveryUnavailable", + hostName: "Laptop", + message: "Update Paseo to recover this workspace.", + }); }); it("keeps loading visible when the descriptor arrives, then uses it as the success transition", () => { diff --git a/packages/app/src/screens/workspace/workspace-route-state.ts b/packages/app/src/screens/workspace/workspace-route-state.ts index a5399296cb..15372edc0c 100644 --- a/packages/app/src/screens/workspace/workspace-route-state.ts +++ b/packages/app/src/screens/workspace/workspace-route-state.ts @@ -76,6 +76,12 @@ export function resolveWorkspaceRouteState(input: { hostName: input.hostName, message: input.recovery.recovery.message, }; + case "unsupportedAction": + return { + kind: "recoveryUnavailable", + hostName: input.hostName, + message: "Update Paseo to recover this workspace.", + }; case "inspectionFailed": return { kind: "recoveryInspectionFailed", diff --git a/packages/app/src/workspace-recovery/model.test.ts b/packages/app/src/workspace-recovery/model.test.ts new file mode 100644 index 0000000000..c4a6de7a21 --- /dev/null +++ b/packages/app/src/workspace-recovery/model.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { resolveWorkspaceRecoveryModel } from "./model"; + +describe("resolveWorkspaceRecoveryModel", () => { + it("keeps newer recovery actions visible but non-actionable", () => { + expect( + resolveWorkspaceRecoveryModel({ + enabled: true, + connected: true, + hasClient: true, + hasServerInfo: true, + supportsRecovery: true, + inspection: { + pending: false, + error: null, + data: { + kind: "recoverable", + workspaceId: "workspace-1", + workspaceName: "Feature branch", + action: "repair_from_snapshot", + branch: "feature", + }, + }, + restore: { pending: false, error: null }, + }), + ).toEqual({ + kind: "unsupportedAction", + action: "repair_from_snapshot", + }); + }); +}); diff --git a/packages/app/src/workspace-recovery/model.ts b/packages/app/src/workspace-recovery/model.ts index f17849db27..5d5ebf15e0 100644 --- a/packages/app/src/workspace-recovery/model.ts +++ b/packages/app/src/workspace-recovery/model.ts @@ -1,15 +1,27 @@ import type { WorkspaceRecoveryState as AuthoritativeWorkspaceRecoveryState } from "@getpaseo/protocol/messages"; +type AuthoritativeRecoverableWorkspace = Extract< + AuthoritativeWorkspaceRecoveryState, + { kind: "recoverable" } +>; + +export type SupportedWorkspaceRecoveryAction = "unarchive" | "restore"; + +type SupportedRecoverableWorkspace = Omit & { + action: SupportedWorkspaceRecoveryAction; +}; + export type WorkspaceRecoveryModel = | { kind: "idle" } | { kind: "checking" } | { kind: "needsHostUpgrade" } | { kind: "recoverable"; - recovery: Extract; + recovery: SupportedRecoverableWorkspace; phase: "ready" | "restoring" | "failed"; error: string | null; } + | { kind: "unsupportedAction"; action: string } | { kind: "unavailable"; recovery: Extract; @@ -35,6 +47,18 @@ function resolveRecoveryPhase(input: { return "ready"; } +function getSupportedRecovery( + recovery: AuthoritativeWorkspaceRecoveryState | undefined, +): SupportedRecoverableWorkspace | null { + if (recovery?.kind !== "recoverable") { + return null; + } + if (recovery.action !== "restore" && recovery.action !== "unarchive") { + return null; + } + return { ...recovery, action: recovery.action }; +} + export function resolveWorkspaceRecoveryModel(input: { enabled: boolean; connected: boolean; @@ -48,10 +72,11 @@ export function resolveWorkspaceRecoveryModel(input: { }; restore: { pending: boolean; error: string | null }; }): WorkspaceRecoveryModel { - if (input.restore.pending && input.inspection.data?.kind === "recoverable") { + const supportedRecovery = getSupportedRecovery(input.inspection.data); + if (input.restore.pending && supportedRecovery) { return { kind: "recoverable", - recovery: input.inspection.data, + recovery: supportedRecovery, phase: "restoring", error: null, }; @@ -74,10 +99,13 @@ export function resolveWorkspaceRecoveryModel(input: { if (input.inspection.data?.kind === "unavailable") { return { kind: "unavailable", recovery: input.inspection.data }; } - if (input.inspection.data?.kind === "recoverable") { + if (input.inspection.data?.kind === "recoverable" && !supportedRecovery) { + return { kind: "unsupportedAction", action: input.inspection.data.action }; + } + if (supportedRecovery) { return { kind: "recoverable", - recovery: input.inspection.data, + recovery: supportedRecovery, phase: resolveRecoveryPhase(input.restore), error: input.restore.error, }; diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 6d6ee22208..49757b0c76 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -1488,7 +1488,7 @@ export const WorkspaceRecoveryStateSchema = z.discriminatedUnion("kind", [ kind: z.literal("recoverable"), workspaceId: z.string(), workspaceName: z.string(), - action: z.enum(["unarchive", "restore"]), + action: z.string(), branch: z.string().nullable(), }), z.object({ diff --git a/packages/protocol/src/messages.workspace-recovery.test.ts b/packages/protocol/src/messages.workspace-recovery.test.ts index 56c3ac44c6..2dc54acce2 100644 --- a/packages/protocol/src/messages.workspace-recovery.test.ts +++ b/packages/protocol/src/messages.workspace-recovery.test.ts @@ -107,4 +107,29 @@ describe("workspace recovery protocol", () => { }, }); }); + + test("accepts recovery actions added by newer daemons", () => { + expect( + SessionOutboundMessageSchema.parse({ + type: "workspace.recovery.inspect.response", + payload: { + requestId: "inspect-future-action", + state: { + kind: "recoverable", + workspaceId: "workspace-1", + workspaceName: "Feature branch", + action: "repair_from_snapshot", + branch: "feature", + }, + }, + }), + ).toMatchObject({ + payload: { + state: { + kind: "recoverable", + action: "repair_from_snapshot", + }, + }, + }); + }); }); diff --git a/packages/server/src/server/daemon-client.e2e.test.ts b/packages/server/src/server/daemon-client.e2e.test.ts index 5c411e9939..40f7bc8c40 100644 --- a/packages/server/src/server/daemon-client.e2e.test.ts +++ b/packages/server/src/server/daemon-client.e2e.test.ts @@ -1008,6 +1008,8 @@ test("receives server_info on websocket connect", async () => { expect(serverInfo?.features?.["terminal-restore-modes"]).toBe(true); expect(serverInfo?.desktopManaged).toBe(false); expect(serverInfo?.features?.daemonSelfUpdate).toBe(true); + expect(serverInfo?.features?.worktreeRestore).toBe(true); + expect(serverInfo?.features?.workspaceRecovery).toBe(true); await client.close(); }, 15000); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index bf7f56b6c7..35b4ea3990 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -240,6 +240,7 @@ import { CreateAgentLifecycleDispatch } from "./agent/create-agent-lifecycle-dis // the entire session message if they encounter an unknown provider. const LEGACY_PROVIDER_IDS = new Set(["claude", "codex", "opencode"]); const MIN_VERSION_ALL_PROVIDERS = "0.1.45"; +const MIN_VERSION_EXPLICIT_WORKSPACE_RECOVERY = "0.1.105"; function errorToFriendlyMessage(error: unknown): string { if (error instanceof Error) return error.message; if (typeof error === "string") return error; @@ -319,6 +320,12 @@ function clientSupportsAllProviders(appVersion: string | null): boolean { return isAppVersionAtLeast(appVersion, MIN_VERSION_ALL_PROVIDERS); } +function clientUsesLegacyWorkspaceRestore(appVersion: string | null): boolean { + return ( + appVersion !== null && !isAppVersionAtLeast(appVersion, MIN_VERSION_EXPLICIT_WORKSPACE_RECOVERY) + ); +} + type DeleteFencedAgentStorage = AgentStorage & { beginDelete(agentId: string): void; }; @@ -2443,10 +2450,7 @@ export class Session { request: Extract, ): Promise { try { - await this.workspaceRecovery.restore(request.workspaceId); - await this.emitWorkspaceUpdatesForWorkspaceIds([request.workspaceId], { - skipReconcile: true, - }); + await this.restoreWorkspaceAndEmit(request.workspaceId); this.emit({ type: "workspace.recovery.restore.response", payload: { @@ -2836,6 +2840,7 @@ export class Session { this.sessionLogger.info({ agentId }, `Refreshing agent ${agentId} from persistence`); try { + await this.restoreOwningWorkspaceForLegacyAgentRefresh(agentId); await unarchiveAgentState(this.agentStorage, this.agentManager, agentId); let snapshot: ManagedAgent; const existing = this.agentManager.getAgent(agentId); @@ -4005,6 +4010,30 @@ export class Session { } } + private async restoreWorkspaceAndEmit(workspaceId: string): Promise { + await this.workspaceRecovery.restore(workspaceId); + await this.emitWorkspaceUpdatesForWorkspaceIds([workspaceId], { + skipReconcile: true, + }); + } + + private async restoreOwningWorkspaceForLegacyAgentRefresh(agentId: string): Promise { + // COMPAT(worktreeRestore): clients older than v0.1.105 used refresh_agent_request + // as their explicit recovery RPC. Remove after 2027-01-11. + if (!clientUsesLegacyWorkspaceRestore(this.appVersion)) { + return; + } + const record = await this.agentStorage.get(agentId); + if (!record?.workspaceId) { + return; + } + const recovery = await this.workspaceRecovery.inspect(record.workspaceId); + if (recovery.kind !== "recoverable") { + return; + } + await this.restoreWorkspaceAndEmit(record.workspaceId); + } + private async createPaseoWorktree( input: CreatePaseoWorktreeInput, options?: { diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index c92f39844b..fd9bf9defb 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -4609,6 +4609,7 @@ test("refresh_agent_request leaves workspace archival independent when its direc test("refresh_agent_request does not recreate or unarchive a deleted worktree", async () => { const emitted: SessionOutboundMessage[] = []; const session = createSessionForWorkspaceTests({ + appVersion: "0.1.105", onMessage: (message) => { if (isSessionOutboundMessage(message)) emitted.push(message); }, @@ -4811,7 +4812,7 @@ function createRecreateWorktreeRepo(): { tempDir: string; repoDir: string } { return { tempDir, repoDir }; } -test("refresh_agent_request leaves a real deleted worktree for explicit workspace recovery", async () => { +test("legacy refresh_agent_request restores a real deleted worktree", async () => { const { tempDir, repoDir } = createRecreateWorktreeRepo(); const branch = "feature/keep"; execFileSync("git", ["branch", branch], { cwd: repoDir, stdio: "pipe" }); @@ -4834,6 +4835,7 @@ test("refresh_agent_request leaves a real deleted worktree for explicit workspac const emitted: SessionOutboundMessage[] = []; const session = createSessionForWorkspaceTests({ + appVersion: "0.1.104", paseoHome, worktreesRoot, onMessage: (message) => { @@ -4920,10 +4922,10 @@ test("refresh_agent_request leaves a real deleted worktree for explicit workspac }); expect(findByType(emitted, "rpc_error")).toBeUndefined(); - expect(existsSync(worktreePath)).toBe(false); + expect(existsSync(worktreePath)).toBe(true); expect(workspaces.get(workspaceId)?.workspaceId).toBe(workspaceId); expect(workspaces.get(workspaceId)?.cwd).toBe(worktreePath); - expect(workspaces.get(workspaceId)?.archivedAt).toBe("2026-03-10T00:00:00.000Z"); + expect(workspaces.get(workspaceId)?.archivedAt).toBeNull(); rmSync(tempDir, { recursive: true, force: true }); }); diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index a501217990..cf5820f1e5 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1228,6 +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): keep through 2027-01-11 for clients older than v0.1.105. + 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.