Restore an archived workspace when its agent is reopened
Reopening an archived agent previously dead-ended at "workspace not found": the agent record was un-archived but its workspace stayed archived and was never sent to the client. The daemon now un-archives the owning workspace alongside the agent when its backing directory still exists, and the app auto-fires the restore ahead of the missing-workspace gate so the user lands back in the workspace rather than a dead end. Worktrees whose directory was deleted on archive are not yet restorable (recreating them from their branch is a follow-up); the workspace record now persists its base branch as groundwork for that.
This commit is contained in:
@@ -1,10 +1,51 @@
|
||||
import { router, type Href } from "expo-router";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { getHostRuntimeStore, isHostRuntimeConnected } from "@/runtime/host-runtime";
|
||||
import { resolveNavigateToAgent, type NavigateToAgentInput } from "./resolve";
|
||||
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
|
||||
export type { NavigateToAgentInput } from "./resolve";
|
||||
|
||||
// Clears the transient restoring state if the daemon resolves refreshAgent without
|
||||
// re-emitting a workspace_update (the directory-gone case), so the gate never spins forever.
|
||||
const RESTORE_TIMEOUT_MS = 7000;
|
||||
|
||||
function restoreArchivedWorkspace(serverId: string, agentId: string, workspaceId: string): void {
|
||||
const snapshot = getHostRuntimeStore().getSnapshot(serverId);
|
||||
const client = snapshot?.client ?? null;
|
||||
if (!client || !isHostRuntimeConnected(snapshot)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const store = useSessionStore.getState();
|
||||
const session = store.sessions[serverId];
|
||||
// Self-gate: only an archived agent whose workspace is absent needs restoring.
|
||||
// A still-present workspace or an in-flight restore is a no-op; fire-once is
|
||||
// derived from store state.
|
||||
const agent = session?.agents.get(agentId) ?? session?.agentDetails.get(agentId);
|
||||
if (!agent?.archivedAt) {
|
||||
return;
|
||||
}
|
||||
if (session?.workspaces.has(workspaceId)) {
|
||||
return;
|
||||
}
|
||||
if (session?.restoringWorkspaces.get(workspaceId) === "restoring") {
|
||||
return;
|
||||
}
|
||||
|
||||
store.setWorkspaceRestoreStatus(serverId, workspaceId, "restoring");
|
||||
// The reducer guards "failed" so a late timeout after the descriptor lands is a no-op.
|
||||
setTimeout(
|
||||
() => useSessionStore.getState().setWorkspaceRestoreStatus(serverId, workspaceId, "failed"),
|
||||
RESTORE_TIMEOUT_MS,
|
||||
);
|
||||
client
|
||||
.refreshAgent(agentId)
|
||||
.catch(() =>
|
||||
useSessionStore.getState().setWorkspaceRestoreStatus(serverId, workspaceId, "failed"),
|
||||
);
|
||||
}
|
||||
|
||||
export function navigateToAgent(input: NavigateToAgentInput): string {
|
||||
return resolveNavigateToAgent(input, {
|
||||
readAgentNavTarget: ({ serverId, agentId }) => {
|
||||
@@ -18,5 +59,8 @@ export function navigateToAgent(input: NavigateToAgentInput): string {
|
||||
router.navigate(route as Href);
|
||||
},
|
||||
navigateToPreparedWorkspaceTab,
|
||||
restoreArchivedWorkspace: ({ serverId, agentId, workspaceId }) => {
|
||||
restoreArchivedWorkspace(serverId, agentId, workspaceId);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -16,16 +16,25 @@ interface RecordedHostNav {
|
||||
|
||||
interface RecordedTabNav extends NavigateToPreparedWorkspaceTabInput {}
|
||||
|
||||
interface RecordedRestore {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
function createFakeNavigators(target: AgentNavTarget): {
|
||||
deps: NavigateToAgentDeps;
|
||||
hostNavigations: RecordedHostNav[];
|
||||
tabNavigations: RecordedTabNav[];
|
||||
restores: RecordedRestore[];
|
||||
} {
|
||||
const hostNavigations: RecordedHostNav[] = [];
|
||||
const tabNavigations: RecordedTabNav[] = [];
|
||||
const restores: RecordedRestore[] = [];
|
||||
return {
|
||||
hostNavigations,
|
||||
tabNavigations,
|
||||
restores,
|
||||
deps: {
|
||||
readAgentNavTarget: () => target,
|
||||
navigateToHostAgent: (route) => {
|
||||
@@ -35,6 +44,9 @@ function createFakeNavigators(target: AgentNavTarget): {
|
||||
tabNavigations.push(input);
|
||||
return `/h/${input.serverId}/workspace/${input.workspaceId}`;
|
||||
},
|
||||
restoreArchivedWorkspace: (input) => {
|
||||
restores.push(input);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -63,6 +75,55 @@ describe("resolveNavigateToAgent", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("delegates a restore attempt whenever a workspaceId resolves", () => {
|
||||
const { deps, restores, tabNavigations } = createFakeNavigators({
|
||||
agentWorkspaceId: WORKSPACE_ID,
|
||||
});
|
||||
|
||||
resolveNavigateToAgent({ serverId: SERVER_ID, agentId: AGENT_ID, pin: true }, deps);
|
||||
|
||||
expect(restores).toEqual([
|
||||
{ serverId: SERVER_ID, agentId: AGENT_ID, workspaceId: WORKSPACE_ID },
|
||||
]);
|
||||
expect(tabNavigations).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("uses the input workspaceId without reading the nav target", () => {
|
||||
const readTargets: { serverId: string; agentId: string }[] = [];
|
||||
const { deps, tabNavigations } = createFakeNavigators({ agentWorkspaceId: null });
|
||||
deps.readAgentNavTarget = (input) => {
|
||||
readTargets.push(input);
|
||||
return { agentWorkspaceId: null };
|
||||
};
|
||||
|
||||
resolveNavigateToAgent(
|
||||
{ serverId: SERVER_ID, agentId: AGENT_ID, workspaceId: WORKSPACE_ID },
|
||||
deps,
|
||||
);
|
||||
|
||||
expect(readTargets).toEqual([]);
|
||||
expect(tabNavigations).toEqual([
|
||||
{
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
target: { kind: "agent", agentId: AGENT_ID },
|
||||
currentPathname: undefined,
|
||||
pin: undefined,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not trigger a restore when no workspaceId resolves", () => {
|
||||
const { deps, restores, hostNavigations } = createFakeNavigators({
|
||||
agentWorkspaceId: null,
|
||||
});
|
||||
|
||||
resolveNavigateToAgent({ serverId: SERVER_ID, agentId: AGENT_ID }, deps);
|
||||
|
||||
expect(restores).toEqual([]);
|
||||
expect(hostNavigations).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("falls back to the host agent route when the agent has no workspaceId", () => {
|
||||
const { deps, hostNavigations, tabNavigations } = createFakeNavigators({
|
||||
agentWorkspaceId: null,
|
||||
|
||||
@@ -5,6 +5,9 @@ import type { NavigateToPreparedWorkspaceTabInput } from "@/utils/prepare-worksp
|
||||
export interface NavigateToAgentInput {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
// Used as the workspace target when the agent is not yet in the session store
|
||||
// (cold deep-links). Otherwise the workspace is read from the store.
|
||||
workspaceId?: string | null;
|
||||
currentPathname?: string | null;
|
||||
pin?: boolean;
|
||||
}
|
||||
@@ -17,16 +20,20 @@ export interface NavigateToAgentDeps {
|
||||
readAgentNavTarget: (input: { serverId: string; agentId: string }) => AgentNavTarget;
|
||||
navigateToHostAgent: (route: string) => void;
|
||||
navigateToPreparedWorkspaceTab: (input: NavigateToPreparedWorkspaceTabInput) => string;
|
||||
restoreArchivedWorkspace: (input: {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
workspaceId: string;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export function resolveNavigateToAgent(
|
||||
input: NavigateToAgentInput,
|
||||
deps: NavigateToAgentDeps,
|
||||
): string {
|
||||
const { agentWorkspaceId } = deps.readAgentNavTarget({
|
||||
serverId: input.serverId,
|
||||
agentId: input.agentId,
|
||||
});
|
||||
const agentWorkspaceId =
|
||||
input.workspaceId ??
|
||||
deps.readAgentNavTarget({ serverId: input.serverId, agentId: input.agentId }).agentWorkspaceId;
|
||||
const workspaceId = normalizeWorkspaceOpaqueId(agentWorkspaceId);
|
||||
|
||||
if (!workspaceId) {
|
||||
@@ -35,6 +42,14 @@ export function resolveNavigateToAgent(
|
||||
return route;
|
||||
}
|
||||
|
||||
// Restore self-gates on the agent being archived with its workspace absent, so
|
||||
// ordinary navigations are a cheap no-op.
|
||||
deps.restoreArchivedWorkspace({
|
||||
serverId: input.serverId,
|
||||
agentId: input.agentId,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
return deps.navigateToPreparedWorkspaceTab({
|
||||
serverId: input.serverId,
|
||||
workspaceId,
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { HostRuntimeSnapshot } from "@/runtime/host-runtime";
|
||||
|
||||
const refreshAgent = vi.fn<(agentId: string) => Promise<unknown>>();
|
||||
let connected = true;
|
||||
|
||||
vi.mock("expo-router", () => ({
|
||||
router: { navigate: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("@/utils/workspace-navigation", () => ({
|
||||
navigateToPreparedWorkspaceTab: vi.fn(() => ""),
|
||||
}));
|
||||
|
||||
vi.mock("@/runtime/host-runtime", () => ({
|
||||
getHostRuntimeStore: () => ({
|
||||
getSnapshot: () => ({ client: { refreshAgent } }) as unknown as HostRuntimeSnapshot,
|
||||
}),
|
||||
isHostRuntimeConnected: () => connected,
|
||||
}));
|
||||
|
||||
import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||
import { navigateToAgent } from "./index";
|
||||
|
||||
const SERVER_ID = "server-1";
|
||||
const AGENT_ID = "agent-1";
|
||||
const WORKSPACE_ID = "workspace-1";
|
||||
|
||||
function status(): "restoring" | "failed" | null {
|
||||
return (
|
||||
useSessionStore.getState().sessions[SERVER_ID]?.restoringWorkspaces.get(WORKSPACE_ID) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function seedArchivedAgent(): void {
|
||||
const store = useSessionStore.getState();
|
||||
store.initializeSession(SERVER_ID, null as unknown as DaemonClient);
|
||||
store.setAgents(SERVER_ID, (prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(AGENT_ID, {
|
||||
id: AGENT_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
archivedAt: new Date(),
|
||||
} as unknown as Agent);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
describe("restoreArchivedWorkspace via navigateToAgent", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
refreshAgent.mockReset();
|
||||
connected = true;
|
||||
seedArchivedAgent();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllTimers();
|
||||
vi.useRealTimers();
|
||||
useSessionStore.getState().clearSession(SERVER_ID);
|
||||
});
|
||||
|
||||
function trigger(): void {
|
||||
navigateToAgent({ serverId: SERVER_ID, agentId: AGENT_ID });
|
||||
}
|
||||
|
||||
it("calls refreshAgent once and marks the workspace restoring", () => {
|
||||
refreshAgent.mockImplementation(() => new Promise(() => {}));
|
||||
|
||||
trigger();
|
||||
|
||||
expect(refreshAgent).toHaveBeenCalledTimes(1);
|
||||
expect(refreshAgent).toHaveBeenCalledWith(AGENT_ID);
|
||||
expect(status()).toBe("restoring");
|
||||
});
|
||||
|
||||
it("does not re-fire while a restore for the same workspace is in flight", () => {
|
||||
refreshAgent.mockImplementation(() => new Promise(() => {}));
|
||||
|
||||
trigger();
|
||||
trigger();
|
||||
trigger();
|
||||
|
||||
expect(refreshAgent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not fire for a non-archived agent", () => {
|
||||
const store = useSessionStore.getState();
|
||||
store.setAgents(SERVER_ID, (prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(AGENT_ID, {
|
||||
id: AGENT_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
archivedAt: undefined,
|
||||
} as unknown as Agent);
|
||||
return next;
|
||||
});
|
||||
refreshAgent.mockImplementation(() => new Promise(() => {}));
|
||||
|
||||
trigger();
|
||||
|
||||
expect(refreshAgent).not.toHaveBeenCalled();
|
||||
expect(status()).toBeNull();
|
||||
});
|
||||
|
||||
it("does not fire while disconnected", () => {
|
||||
connected = false;
|
||||
refreshAgent.mockImplementation(() => new Promise(() => {}));
|
||||
|
||||
trigger();
|
||||
|
||||
expect(refreshAgent).not.toHaveBeenCalled();
|
||||
expect(status()).toBeNull();
|
||||
});
|
||||
|
||||
it("flips to failed when refreshAgent rejects", async () => {
|
||||
refreshAgent.mockImplementation(() => Promise.reject(new Error("dir gone")));
|
||||
|
||||
trigger();
|
||||
expect(status()).toBe("restoring");
|
||||
|
||||
await vi.runAllTicks();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(status()).toBe("failed");
|
||||
});
|
||||
|
||||
it("flips to failed via the timeout when refreshAgent resolves without a workspace update", async () => {
|
||||
refreshAgent.mockImplementation(() => Promise.resolve({}));
|
||||
|
||||
trigger();
|
||||
await Promise.resolve();
|
||||
expect(status()).toBe("restoring");
|
||||
|
||||
await vi.advanceTimersByTimeAsync(7000);
|
||||
expect(status()).toBe("failed");
|
||||
});
|
||||
|
||||
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: "/repo",
|
||||
workspaceDirectory: "/repo",
|
||||
projectKind: "git",
|
||||
workspaceKind: "local_checkout",
|
||||
name: "main",
|
||||
status: "done",
|
||||
statusEnteredAt: null,
|
||||
archivingAt: null,
|
||||
diffStat: null,
|
||||
scripts: [],
|
||||
},
|
||||
]);
|
||||
|
||||
trigger();
|
||||
|
||||
expect(refreshAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user