fix: preserve workspace recovery contracts

This commit is contained in:
Mohamed Boudra
2026-07-16 15:16:13 +02:00
parent 05cb0f99e5
commit d3fcef01f1
9 changed files with 78 additions and 18 deletions
+12
View File
@@ -130,6 +130,18 @@ test.describe("Worktree restore", () => {
await expect(page.getByRole("button", { name: "Unarchive" })).toHaveCount(0);
expect(await fetchAgentArchivedAt(client, agent.id)).toBeNull();
expect(existsSync(worktree.workspaceDirectory)).toBe(true);
await openSessions(page);
await expectSessionRowNotArchived(page, agent.title);
await page.getByTestId(`agent-row-${serverId}-${agent.id}`).click();
await expect(
page.getByTestId(`workspace-tab-agent_${agent.id}`).filter({ visible: true }).first(),
).toBeVisible({ timeout: 30_000 });
await expect(
page.getByTestId(`workspace-deck-entry-${serverId}:${worktree.workspaceId}`),
).toHaveCount(1);
expect(await fetchAgentArchivedAt(client, agent.id)).toBeNull();
});
test("opening a recoverable archived workspace shows an explicit Restore action without mutating it", async ({
@@ -77,4 +77,32 @@ describe("navigateToHostWorkspaceRoute", () => {
},
});
});
it("preserves a workspace open intent in the POP_TO target", () => {
const { navigationRef, dispatch } = createNavigationRef({
key: "root-stack",
routes: [{ key: "host-server-1", name: "h/[serverId]" }],
});
registerWorkspaceRouteNavigationRef(navigationRef);
navigateToHostWorkspaceRoute("/h/server-1/workspace/workspace-a?open=agent%3Aagent-1");
expect(dispatch).toHaveBeenCalledWith({
type: "POP_TO",
target: "root-stack",
payload: {
name: "h/[serverId]",
params: {
serverId: "server-1",
screen: "workspace/[workspaceId]/index",
params: {
serverId: "server-1",
workspaceId: "workspace-a",
open: "agent:agent-1",
},
pop: true,
},
},
});
});
});
@@ -2,6 +2,7 @@ import type { NavigationAction, NavigationContainerRefWithCurrent } from "@react
import { router, type Href } from "expo-router";
import {
encodeWorkspaceIdForPathSegment,
getHostWorkspaceOpenParamFromPathname,
parseHostWorkspaceRouteFromPathname,
} from "@/utils/host-routes";
@@ -82,6 +83,7 @@ function dispatchHostWorkspacePopTo(route: string): boolean {
if (!target) {
return false;
}
const open = getHostWorkspaceOpenParamFromPathname(route);
const action: NavigationAction = {
type: "POP_TO",
@@ -94,6 +96,7 @@ function dispatchHostWorkspacePopTo(route: string): boolean {
params: {
serverId: selection.serverId,
workspaceId: encodeWorkspaceIdForPathSegment(selection.workspaceId),
...(open ? { open } : {}),
},
// React Navigation consumes this nested hint when resolving the host child screen.
// The browser-route canonicalizer strips the resulting ?pop=true URL artifact.
@@ -11,8 +11,8 @@ import {
navigateToLastWorkspace as navigateToLastWorkspacePure,
navigateToWorkspace as navigateToWorkspacePure,
parseActiveWorkspaceSelection,
type NavigateToWorkspaceInput,
type NavigateToWorkspaceDeps,
type NavigateToWorkspaceInput,
} from "./navigation";
import { useSessionStore } from "@/stores/session-store";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
+6 -1
View File
@@ -174,11 +174,16 @@ export function parseWorkspaceOpenIntent(
export function parseHostWorkspaceOpenIntentFromPathname(
pathname: string,
): WorkspaceOpenIntent | null {
return parseWorkspaceOpenIntent(getHostWorkspaceOpenParamFromPathname(pathname));
}
export function getHostWorkspaceOpenParamFromPathname(pathname: string): string | null {
const search = extractSearch(pathname);
if (!search) {
return null;
}
return parseWorkspaceOpenIntent(new URLSearchParams(search).get("open"));
const open = new URLSearchParams(search).get("open");
return parseWorkspaceOpenIntent(open) ? open : null;
}
export function encodeWorkspaceIdForPathSegment(workspaceId: string): string {
@@ -1,7 +1,7 @@
import { router, type Href } from "expo-router";
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
import { useSessionStore } from "@/stores/session-store";
import { resolveNavigateToAgent, type NavigateToAgentInput } from "./resolve";
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
export type { NavigateToAgentInput } from "./resolve";
@@ -55,13 +55,8 @@ export function useWorkspaceRecovery(input: {
throw new Error("The host disconnected before the workspace could be recovered.");
}
await waitForRecoveryLoadingPresentation();
const [result] = await Promise.allSettled([
client.restoreWorkspace(input.workspaceId),
waitForMinimumRecoveryLoadingTime(),
]);
if (result.status === "rejected") {
throw result.reason;
}
await waitForMinimumRecoveryLoadingTime();
await client.restoreWorkspace(input.workspaceId);
},
});
+1 -8
View File
@@ -1494,14 +1494,7 @@ export const WorkspaceRecoveryStateSchema = z.discriminatedUnion("kind", [
z.object({
kind: z.literal("unavailable"),
workspaceId: z.string(),
reason: z.enum([
"workspace_not_found",
"workspace_not_archived",
"project_not_found",
"project_directory_missing",
"workspace_directory_missing",
"worktree_branch_missing",
]),
reason: z.string(),
message: z.string(),
}),
]);
@@ -83,4 +83,28 @@ describe("workspace recovery protocol", () => {
}).features?.workspaceRecovery,
).toBe(true);
});
test("accepts unavailable reasons added by newer daemons", () => {
expect(
SessionOutboundMessageSchema.parse({
type: "workspace.recovery.inspect.response",
payload: {
requestId: "inspect-future",
state: {
kind: "unavailable",
workspaceId: "workspace-1",
reason: "future_recovery_constraint",
message: "This host cannot recover the workspace yet.",
},
},
}),
).toMatchObject({
payload: {
state: {
kind: "unavailable",
reason: "future_recovery_constraint",
},
},
});
});
});