fix: preserve workspace recovery compatibility

This commit is contained in:
Mohamed Boudra
2026-07-16 15:16:51 +02:00
parent d3fcef01f1
commit 1e06785c0a
10 changed files with 151 additions and 13 deletions
@@ -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", () => {
@@ -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",
@@ -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",
});
});
});
+33 -5
View File
@@ -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<AuthoritativeRecoverableWorkspace, "action"> & {
action: SupportedWorkspaceRecoveryAction;
};
export type WorkspaceRecoveryModel =
| { kind: "idle" }
| { kind: "checking" }
| { kind: "needsHostUpgrade" }
| {
kind: "recoverable";
recovery: Extract<AuthoritativeWorkspaceRecoveryState, { kind: "recoverable" }>;
recovery: SupportedRecoverableWorkspace;
phase: "ready" | "restoring" | "failed";
error: string | null;
}
| { kind: "unsupportedAction"; action: string }
| {
kind: "unavailable";
recovery: Extract<AuthoritativeWorkspaceRecoveryState, { kind: "unavailable" }>;
@@ -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,
};
+1 -1
View File
@@ -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({
@@ -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",
},
},
});
});
});
@@ -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);
+33 -4
View File
@@ -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<SessionInboundMessage, { type: "workspace.recovery.restore.request" }>,
): Promise<void> {
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<void> {
await this.workspaceRecovery.restore(workspaceId);
await this.emitWorkspaceUpdatesForWorkspaceIds([workspaceId], {
skipReconcile: true,
});
}
private async restoreOwningWorkspaceForLegacyAgentRefresh(agentId: string): Promise<void> {
// 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?: {
@@ -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 });
});
@@ -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.