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:
@@ -363,6 +363,51 @@ describe("mergeWorkspaces", () => {
|
||||
expect(after.workspaces).not.toBe(before.workspaces);
|
||||
expect(after.workspaces.get(workspace.id)?.diffStat).toBeNull();
|
||||
});
|
||||
|
||||
it("clears a pending restore status when the matching descriptor lands", () => {
|
||||
const store = useSessionStore.getState();
|
||||
initializeTestSession();
|
||||
store.setWorkspaceRestoreStatus("test-server", "/repo/main", "restoring");
|
||||
expect(getTestSessionReferences().session.restoringWorkspaces.get("/repo/main")).toBe(
|
||||
"restoring",
|
||||
);
|
||||
|
||||
store.mergeWorkspaces("test-server", [createWorkspace({ id: "/repo/main" })]);
|
||||
|
||||
expect(getTestSessionReferences().session.restoringWorkspaces.has("/repo/main")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setWorkspaceRestoreStatus", () => {
|
||||
it("marks restoring then failed while the workspace is still absent", () => {
|
||||
const store = useSessionStore.getState();
|
||||
initializeTestSession();
|
||||
|
||||
store.setWorkspaceRestoreStatus("test-server", "/repo/main", "restoring");
|
||||
store.setWorkspaceRestoreStatus("test-server", "/repo/main", "failed");
|
||||
|
||||
expect(getTestSessionReferences().session.restoringWorkspaces.get("/repo/main")).toBe("failed");
|
||||
});
|
||||
|
||||
it("ignores a late failed once the descriptor has landed (no-op)", () => {
|
||||
const store = useSessionStore.getState();
|
||||
initializeTestSession();
|
||||
store.setWorkspaceRestoreStatus("test-server", "/repo/main", "restoring");
|
||||
store.mergeWorkspaces("test-server", [createWorkspace({ id: "/repo/main" })]);
|
||||
|
||||
store.setWorkspaceRestoreStatus("test-server", "/repo/main", "failed");
|
||||
|
||||
expect(getTestSessionReferences().session.restoringWorkspaces.has("/repo/main")).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores failed when no restore is in flight", () => {
|
||||
const store = useSessionStore.getState();
|
||||
initializeTestSession();
|
||||
|
||||
store.setWorkspaceRestoreStatus("test-server", "/repo/main", "failed");
|
||||
|
||||
expect(getTestSessionReferences().session.restoringWorkspaces.has("/repo/main")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setWorkspaces", () => {
|
||||
|
||||
@@ -287,6 +287,8 @@ export interface AgentTimelineCursorState {
|
||||
endSeq: number;
|
||||
}
|
||||
|
||||
export type WorkspaceRestoreStatus = "restoring" | "failed";
|
||||
|
||||
// Per-session state
|
||||
export interface SessionState {
|
||||
serverId: string;
|
||||
@@ -332,6 +334,9 @@ export interface SessionState {
|
||||
// Project parents with no active workspaces, keyed by projectId. Rendered as
|
||||
// empty project rows in the sidebar.
|
||||
emptyProjects: Map<string, EmptyProjectDescriptor>;
|
||||
// Transient restore state for archived workspaces, keyed by normalized
|
||||
// workspaceId. Cleared in mergeWorkspaces when the descriptor lands.
|
||||
restoringWorkspaces: Map<string, WorkspaceRestoreStatus>;
|
||||
|
||||
// Permissions
|
||||
pendingPermissions: Map<string, PendingPermission>;
|
||||
@@ -455,6 +460,12 @@ interface SessionStoreActions {
|
||||
removeWorkspace: (serverId: string, workspaceId: string) => void;
|
||||
setEmptyProjects: (serverId: string, emptyProjects: Iterable<EmptyProjectDescriptor>) => void;
|
||||
addEmptyProject: (serverId: string, emptyProject: EmptyProjectDescriptor) => void;
|
||||
setWorkspaceRestoreStatus: (
|
||||
serverId: string,
|
||||
workspaceId: string,
|
||||
status: WorkspaceRestoreStatus,
|
||||
) => void;
|
||||
clearWorkspaceRestoreStatus: (serverId: string, workspaceId: string) => void;
|
||||
|
||||
// Agent activity timestamps
|
||||
setAgentLastActivity: (agentId: string, timestamp: Date) => void;
|
||||
@@ -527,6 +538,7 @@ function createInitialSessionState(serverId: string, client: DaemonClient): Sess
|
||||
agentDetails: new Map(),
|
||||
workspaces: new Map(),
|
||||
emptyProjects: new Map(),
|
||||
restoringWorkspaces: new Map(),
|
||||
pendingPermissions: new Map(),
|
||||
fileExplorer: new Map(),
|
||||
queuedMessages: new Map(),
|
||||
@@ -1253,6 +1265,54 @@ export const useSessionStore = create<SessionStore>()(
|
||||
});
|
||||
},
|
||||
|
||||
setWorkspaceRestoreStatus: (serverId, workspaceId, status) => {
|
||||
set((prev) => {
|
||||
const session = prev.sessions[serverId];
|
||||
if (!session) {
|
||||
return prev;
|
||||
}
|
||||
if (session.restoringWorkspaces.get(workspaceId) === status) {
|
||||
return prev;
|
||||
}
|
||||
// A late dir-gone timeout must not override a successful restore:
|
||||
// only mark failed while still restoring and the descriptor is absent.
|
||||
if (
|
||||
status === "failed" &&
|
||||
(session.restoringWorkspaces.get(workspaceId) !== "restoring" ||
|
||||
session.workspaces.has(workspaceId))
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
const next = new Map(session.restoringWorkspaces);
|
||||
next.set(workspaceId, status);
|
||||
return {
|
||||
...prev,
|
||||
sessions: {
|
||||
...prev.sessions,
|
||||
[serverId]: { ...session, restoringWorkspaces: next },
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
clearWorkspaceRestoreStatus: (serverId, workspaceId) => {
|
||||
set((prev) => {
|
||||
const session = prev.sessions[serverId];
|
||||
if (!session || !session.restoringWorkspaces.has(workspaceId)) {
|
||||
return prev;
|
||||
}
|
||||
const next = new Map(session.restoringWorkspaces);
|
||||
next.delete(workspaceId);
|
||||
return {
|
||||
...prev,
|
||||
sessions: {
|
||||
...prev.sessions,
|
||||
[serverId]: { ...session, restoringWorkspaces: next },
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
mergeWorkspaces: (serverId, workspaces) => {
|
||||
const nextEntries = Array.from(workspaces);
|
||||
set((prev) => {
|
||||
@@ -1266,10 +1326,18 @@ export const useSessionStore = create<SessionStore>()(
|
||||
// empty: prune any stale empty descriptor so it stops governing the
|
||||
// project's rendered metadata.
|
||||
const nextEmptyProjects = new Map(session.emptyProjects);
|
||||
// A descriptor arriving is the success signal for a pending restore:
|
||||
// clear it at the source so every entry point converges to "ready".
|
||||
let nextRestoring: Map<string, WorkspaceRestoreStatus> | null = null;
|
||||
for (const workspace of nextEntries) {
|
||||
if (nextEmptyProjects.delete(workspace.projectId)) {
|
||||
changed = true;
|
||||
}
|
||||
if (session.restoringWorkspaces.has(workspace.id)) {
|
||||
nextRestoring ??= new Map(session.restoringWorkspaces);
|
||||
nextRestoring.delete(workspace.id);
|
||||
changed = true;
|
||||
}
|
||||
const existing = next.get(workspace.id);
|
||||
const nextWorkspace = preserveWorkspaceDescriptorIdentity(workspace, existing);
|
||||
if (existing === nextWorkspace) {
|
||||
@@ -1285,7 +1353,12 @@ export const useSessionStore = create<SessionStore>()(
|
||||
...prev,
|
||||
sessions: {
|
||||
...prev.sessions,
|
||||
[serverId]: { ...session, workspaces: next, emptyProjects: nextEmptyProjects },
|
||||
[serverId]: {
|
||||
...session,
|
||||
workspaces: next,
|
||||
emptyProjects: nextEmptyProjects,
|
||||
restoringWorkspaces: nextRestoring ?? session.restoringWorkspaces,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -1485,3 +1558,14 @@ export const useSessionStore = create<SessionStore>()(
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
export function useWorkspaceRestoreStatus(
|
||||
serverId: string | null,
|
||||
workspaceId: string | null,
|
||||
): WorkspaceRestoreStatus | null {
|
||||
return useSessionStore((state) =>
|
||||
serverId && workspaceId
|
||||
? (state.sessions[serverId]?.restoringWorkspaces.get(workspaceId) ?? null)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user