mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-06 16:34:36 +00:00
fix: preserve workspace recovery compatibility
This commit is contained in:
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user