mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-06 16:34:36 +00:00
fix(app): wait for workspace hydration before restore
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { router, type Href } from "expo-router";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store";
|
||||
import { getHostRuntimeStore, isHostRuntimeConnected } from "@/runtime/host-runtime";
|
||||
import { resolveNavigateToAgent, type NavigateToAgentInput } from "./resolve";
|
||||
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
|
||||
@@ -12,6 +13,48 @@ export type { NavigateToAgentInput } from "./resolve";
|
||||
// to avoid flashing a false "failed" on a capable daemon doing slow real work.
|
||||
const RESTORE_TIMEOUT_MS = 30000;
|
||||
|
||||
interface PendingWorkspaceRestore {
|
||||
agentId: string;
|
||||
unsubscribe: () => void;
|
||||
}
|
||||
|
||||
const pendingWorkspaceRestores = new Map<string, PendingWorkspaceRestore>();
|
||||
|
||||
function restoreWhenWorkspacesHydrate(
|
||||
serverId: string,
|
||||
agentId: string,
|
||||
workspaceId: string,
|
||||
): void {
|
||||
const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pending = pendingWorkspaceRestores.get(key);
|
||||
if (pending) {
|
||||
pending.agentId = agentId;
|
||||
return;
|
||||
}
|
||||
|
||||
const restore: PendingWorkspaceRestore = {
|
||||
agentId,
|
||||
unsubscribe: () => {},
|
||||
};
|
||||
restore.unsubscribe = useSessionStore.subscribe((state) => {
|
||||
const session = state.sessions[serverId];
|
||||
if (session && !session.hasHydratedWorkspaces) {
|
||||
return;
|
||||
}
|
||||
|
||||
restore.unsubscribe();
|
||||
pendingWorkspaceRestores.delete(key);
|
||||
if (session) {
|
||||
restoreArchivedWorkspace(serverId, restore.agentId, workspaceId);
|
||||
}
|
||||
});
|
||||
pendingWorkspaceRestores.set(key, restore);
|
||||
}
|
||||
|
||||
function restoreArchivedWorkspace(serverId: string, agentId: string, workspaceId: string): void {
|
||||
const snapshot = getHostRuntimeStore().getSnapshot(serverId);
|
||||
const client = snapshot?.client ?? null;
|
||||
@@ -24,10 +67,17 @@ function restoreArchivedWorkspace(serverId: string, agentId: string, workspaceId
|
||||
// History carries restore intent explicitly. Workspace lifecycle is separate
|
||||
// from agent lifecycle, so a closed non-archived agent can legitimately own an
|
||||
// archived workspace. A present workspace or in-flight restore stays a no-op.
|
||||
if (session?.workspaces.has(workspaceId)) {
|
||||
if (!session || session.workspaces.has(workspaceId)) {
|
||||
return;
|
||||
}
|
||||
if (session?.restoringWorkspaces.get(workspaceId) === "restoring") {
|
||||
if (session.restoringWorkspaces.get(workspaceId) === "restoring") {
|
||||
return;
|
||||
}
|
||||
// An empty workspace map is not authoritative during startup. Keep the route on
|
||||
// its existing loading state, then decide whether restoration is needed once the
|
||||
// daemon's workspace snapshot has landed.
|
||||
if (!session.hasHydratedWorkspaces) {
|
||||
restoreWhenWorkspacesHydrate(serverId, agentId, workspaceId);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -35,7 +85,7 @@ function restoreArchivedWorkspace(serverId: string, agentId: string, workspaceId
|
||||
// Single capability read for restore. An old daemon recreates nothing on
|
||||
// refresh_agent, so a gone directory would spin then flash a misleading
|
||||
// "couldn't restore". Surface an explicit "update your host" state instead.
|
||||
if (session?.serverInfo?.features?.worktreeRestore !== true) {
|
||||
if (session.serverInfo?.features?.worktreeRestore !== true) {
|
||||
store.setWorkspaceRestoreStatus(serverId, workspaceId, "needs-host-upgrade");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ vi.mock("@/runtime/host-runtime", () => ({
|
||||
isHostRuntimeConnected: () => connected,
|
||||
}));
|
||||
|
||||
import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||
import { useSessionStore, type Agent, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { navigateToAgent } from "./index";
|
||||
|
||||
const SERVER_ID = "remote-server";
|
||||
@@ -81,6 +81,25 @@ function seedArchivedAgent(options?: { worktreeRestore?: boolean }): void {
|
||||
next.set(AGENT_ID, agent(new Date("2026-01-02T00:00:00.000Z")));
|
||||
return next;
|
||||
});
|
||||
store.setHasHydratedWorkspaces(SERVER_ID, true);
|
||||
}
|
||||
|
||||
function workspace(): WorkspaceDescriptor {
|
||||
return {
|
||||
id: WORKSPACE_ID,
|
||||
projectId: "project-1",
|
||||
projectDisplayName: "Project 1",
|
||||
projectRootPath: MISSING_WORKTREE_CWD,
|
||||
workspaceDirectory: MISSING_WORKTREE_CWD,
|
||||
projectKind: "git",
|
||||
workspaceKind: "local_checkout",
|
||||
name: "main",
|
||||
status: "done",
|
||||
statusEnteredAt: null,
|
||||
archivingAt: null,
|
||||
diffStat: null,
|
||||
scripts: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe("restoreArchivedWorkspace via navigateToAgent", () => {
|
||||
@@ -162,6 +181,50 @@ describe("restoreArchivedWorkspace via navigateToAgent", () => {
|
||||
expect(status()).toBeNull();
|
||||
});
|
||||
|
||||
it("waits for workspace hydration before deciding a running History workspace is missing", () => {
|
||||
const store = useSessionStore.getState();
|
||||
store.setHasHydratedWorkspaces(SERVER_ID, false);
|
||||
store.setAgents(SERVER_ID, (prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(AGENT_ID, { ...agent(null), status: "running" });
|
||||
return next;
|
||||
});
|
||||
refreshAgent.mockImplementation(() => new Promise(() => {}));
|
||||
|
||||
openFromHistory();
|
||||
|
||||
expect(refreshAgent).not.toHaveBeenCalled();
|
||||
expect(status()).toBeNull();
|
||||
|
||||
store.mergeWorkspaces(SERVER_ID, [workspace()]);
|
||||
store.setHasHydratedWorkspaces(SERVER_ID, true);
|
||||
|
||||
expect(refreshAgent).not.toHaveBeenCalled();
|
||||
expect(status()).toBeNull();
|
||||
});
|
||||
|
||||
it("restores a missing History workspace after workspace hydration settles", () => {
|
||||
const store = useSessionStore.getState();
|
||||
store.setHasHydratedWorkspaces(SERVER_ID, false);
|
||||
store.setAgents(SERVER_ID, (prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(AGENT_ID, { ...agent(null), status: "closed" });
|
||||
return next;
|
||||
});
|
||||
refreshAgent.mockImplementation(() => new Promise(() => {}));
|
||||
|
||||
openFromHistory();
|
||||
|
||||
expect(refreshAgent).not.toHaveBeenCalled();
|
||||
expect(status()).toBeNull();
|
||||
|
||||
store.setHasHydratedWorkspaces(SERVER_ID, true);
|
||||
|
||||
expect(refreshAgent).toHaveBeenCalledTimes(1);
|
||||
expect(refreshAgent).toHaveBeenCalledWith(AGENT_ID);
|
||||
expect(status()).toBe("restoring");
|
||||
});
|
||||
|
||||
it("does not fire while disconnected", () => {
|
||||
connected = false;
|
||||
refreshAgent.mockImplementation(() => new Promise(() => {}));
|
||||
@@ -208,23 +271,7 @@ describe("restoreArchivedWorkspace via navigateToAgent", () => {
|
||||
|
||||
it("is a no-op when the workspace descriptor is already present", () => {
|
||||
refreshAgent.mockImplementation(() => new Promise(() => {}));
|
||||
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [
|
||||
{
|
||||
id: WORKSPACE_ID,
|
||||
projectId: "project-1",
|
||||
projectDisplayName: "Project 1",
|
||||
projectRootPath: MISSING_WORKTREE_CWD,
|
||||
workspaceDirectory: MISSING_WORKTREE_CWD,
|
||||
projectKind: "git",
|
||||
workspaceKind: "local_checkout",
|
||||
name: "main",
|
||||
status: "done",
|
||||
statusEnteredAt: null,
|
||||
archivingAt: null,
|
||||
diffStat: null,
|
||||
scripts: [],
|
||||
},
|
||||
]);
|
||||
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [workspace()]);
|
||||
|
||||
openFromHistory();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user