mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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:
@@ -5,7 +5,7 @@ import { useSessionStore } from "@/stores/session-store";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { buildHostRootRoute } from "@/utils/host-routes";
|
||||
import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
|
||||
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||
|
||||
export default function HostAgentReadyRoute() {
|
||||
return (
|
||||
@@ -50,10 +50,9 @@ function HostAgentReadyRouteContent() {
|
||||
|
||||
if (resolvedWorkspaceId) {
|
||||
redirectedRef.current = true;
|
||||
navigateToPreparedWorkspaceTab({
|
||||
navigateToAgent({
|
||||
serverId,
|
||||
workspaceId: resolvedWorkspaceId,
|
||||
target: { kind: "agent", agentId },
|
||||
agentId,
|
||||
currentPathname: pathname,
|
||||
});
|
||||
}
|
||||
@@ -93,10 +92,10 @@ function HostAgentReadyRouteContent() {
|
||||
const workspaceId = normalizeWorkspaceOpaqueId(result?.agent?.workspaceId);
|
||||
redirectedRef.current = true;
|
||||
if (workspaceId) {
|
||||
navigateToPreparedWorkspaceTab({
|
||||
navigateToAgent({
|
||||
serverId,
|
||||
agentId,
|
||||
workspaceId,
|
||||
target: { kind: "agent", agentId },
|
||||
currentPathname: pathname,
|
||||
});
|
||||
return;
|
||||
|
||||
@@ -328,6 +328,8 @@ export const ar: TranslationResources = {
|
||||
workspace: {
|
||||
route: {
|
||||
loading: "جارٍ تحميل مساحة العمل",
|
||||
restoring: "جارٍ استعادة مساحة العمل",
|
||||
restoreFailed: "تعذّر استعادة مساحة العمل هذه — ربما تم نقل المجلد أو حذفه",
|
||||
connecting: "الاتصال",
|
||||
hostOffline: "{{hostName}}غير متواجد حالياً",
|
||||
cannotReachHost: "لا يمكن الوصول إلى{{hostName}}",
|
||||
|
||||
@@ -327,6 +327,9 @@ export const en = {
|
||||
workspace: {
|
||||
route: {
|
||||
loading: "Loading workspace",
|
||||
restoring: "Restoring workspace",
|
||||
restoreFailed:
|
||||
"Couldn't restore this workspace — the directory may have been moved or deleted",
|
||||
connecting: "Connecting",
|
||||
hostOffline: "{{hostName}} is offline",
|
||||
cannotReachHost: "Cannot reach {{hostName}}",
|
||||
|
||||
@@ -331,6 +331,9 @@ export const es: TranslationResources = {
|
||||
workspace: {
|
||||
route: {
|
||||
loading: "Cargando espacio de trabajo",
|
||||
restoring: "Restaurando espacio de trabajo",
|
||||
restoreFailed:
|
||||
"No se pudo restaurar este espacio de trabajo — es posible que el directorio se haya movido o eliminado",
|
||||
connecting: "Conectando",
|
||||
hostOffline: "{{hostName}}está desconectado",
|
||||
cannotReachHost: "No se puede alcanzar{{hostName}}",
|
||||
|
||||
@@ -331,6 +331,9 @@ export const fr: TranslationResources = {
|
||||
workspace: {
|
||||
route: {
|
||||
loading: "Chargement de l'espace de travail",
|
||||
restoring: "Restauration de l'espace de travail",
|
||||
restoreFailed:
|
||||
"Impossible de restaurer cet espace de travail — le répertoire a peut-être été déplacé ou supprimé",
|
||||
connecting: "De liaison",
|
||||
hostOffline: "{{hostName}}est hors ligne",
|
||||
cannotReachHost: "Impossible d'atteindre{{hostName}}",
|
||||
|
||||
@@ -330,6 +330,9 @@ export const ru: TranslationResources = {
|
||||
workspace: {
|
||||
route: {
|
||||
loading: "Загрузка рабочей области",
|
||||
restoring: "Восстановление рабочей области",
|
||||
restoreFailed:
|
||||
"Не удалось восстановить эту рабочую область — каталог мог быть перемещён или удалён",
|
||||
connecting: "Подключение",
|
||||
hostOffline: "{{hostName}}не в сети",
|
||||
cannotReachHost: "Невозможно связаться с{{hostName}}",
|
||||
|
||||
@@ -328,6 +328,8 @@ export const zhCN: TranslationResources = {
|
||||
workspace: {
|
||||
route: {
|
||||
loading: "正在加载 workspace",
|
||||
restoring: "正在恢复 workspace",
|
||||
restoreFailed: "无法恢复此 workspace — 目录可能已被移动或删除",
|
||||
connecting: "正在连接",
|
||||
hostOffline: "{{hostName}} 已离线",
|
||||
cannotReachHost: "无法连接 {{hostName}}",
|
||||
|
||||
@@ -21,6 +21,8 @@ export function renderWorkspaceRouteGate(input: {
|
||||
switch (input.state.kind) {
|
||||
case "loading":
|
||||
return <WorkspaceConnecting hostName={input.state.hostName} />;
|
||||
case "restoring":
|
||||
return <WorkspaceRestoring hostName={input.state.hostName} />;
|
||||
case "unreachable":
|
||||
return (
|
||||
<WorkspaceUnreachable
|
||||
@@ -33,6 +35,7 @@ export function renderWorkspaceRouteGate(input: {
|
||||
return (
|
||||
<WorkspaceMissing
|
||||
hostName={input.state.hostName}
|
||||
restoreFailed={input.state.restoreFailed}
|
||||
onDismiss={input.actions.onDismissMissingWorkspace}
|
||||
/>
|
||||
);
|
||||
@@ -70,6 +73,21 @@ function WorkspaceConnecting({ hostName }: { hostName: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceRestoring({ hostName }: { hostName: string }) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<View style={styles.emptyState}>
|
||||
<LoadingSpinner size="small" color={theme.colors.foregroundMuted} />
|
||||
<View style={styles.textStack}>
|
||||
<Text style={styles.title}>{t("workspace.route.restoring")}</Text>
|
||||
<Text style={styles.description}>{hostName}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceUnreachable({
|
||||
state,
|
||||
onRetry,
|
||||
@@ -124,13 +142,23 @@ function WorkspaceUnreachable({
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceMissing({ hostName, onDismiss }: { hostName: string; onDismiss: () => void }) {
|
||||
function WorkspaceMissing({
|
||||
hostName,
|
||||
restoreFailed,
|
||||
onDismiss,
|
||||
}: {
|
||||
hostName: string;
|
||||
restoreFailed: boolean;
|
||||
onDismiss: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<View style={styles.emptyState}>
|
||||
<View style={styles.textStack}>
|
||||
<Text style={styles.title}>{t("workspace.route.missing")}</Text>
|
||||
<Text style={styles.title}>
|
||||
{restoreFailed ? t("workspace.route.restoreFailed") : t("workspace.route.missing")}
|
||||
</Text>
|
||||
<Text style={styles.description}>{hostName}</Text>
|
||||
</View>
|
||||
<View style={styles.actions}>
|
||||
|
||||
@@ -29,6 +29,7 @@ describe("resolveWorkspaceRouteState", () => {
|
||||
lastError: "transport closed",
|
||||
workspace: null,
|
||||
hasHydratedWorkspaces: false,
|
||||
restoreStatus: null,
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "unreachable",
|
||||
@@ -46,6 +47,7 @@ describe("resolveWorkspaceRouteState", () => {
|
||||
lastError: "transport closed",
|
||||
workspace: null,
|
||||
hasHydratedWorkspaces: true,
|
||||
restoreStatus: null,
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "unreachable",
|
||||
@@ -63,6 +65,7 @@ describe("resolveWorkspaceRouteState", () => {
|
||||
lastError: "transport closed",
|
||||
workspace: createWorkspaceDescriptor(),
|
||||
hasHydratedWorkspaces: true,
|
||||
restoreStatus: null,
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "reconnecting",
|
||||
@@ -80,8 +83,9 @@ describe("resolveWorkspaceRouteState", () => {
|
||||
lastError: null,
|
||||
workspace: null,
|
||||
hasHydratedWorkspaces: true,
|
||||
restoreStatus: null,
|
||||
}),
|
||||
).toEqual({ kind: "missing", hostName: "Laptop" });
|
||||
).toEqual({ kind: "missing", hostName: "Laptop", restoreFailed: false });
|
||||
});
|
||||
|
||||
it("returns loading before workspace hydration when the host is online", () => {
|
||||
@@ -92,6 +96,7 @@ describe("resolveWorkspaceRouteState", () => {
|
||||
lastError: null,
|
||||
workspace: null,
|
||||
hasHydratedWorkspaces: false,
|
||||
restoreStatus: null,
|
||||
}),
|
||||
).toEqual({ kind: "loading", hostName: "Laptop" });
|
||||
});
|
||||
@@ -104,6 +109,46 @@ describe("resolveWorkspaceRouteState", () => {
|
||||
lastError: null,
|
||||
workspace: createWorkspaceDescriptor(),
|
||||
hasHydratedWorkspaces: true,
|
||||
restoreStatus: null,
|
||||
}),
|
||||
).toEqual({ kind: "ready" });
|
||||
});
|
||||
|
||||
it("returns restoring while an archived workspace restore is in flight", () => {
|
||||
expect(
|
||||
resolveWorkspaceRouteState({
|
||||
hostName: "Laptop",
|
||||
connectionStatus: "online",
|
||||
lastError: null,
|
||||
workspace: null,
|
||||
hasHydratedWorkspaces: true,
|
||||
restoreStatus: "restoring",
|
||||
}),
|
||||
).toEqual({ kind: "restoring", hostName: "Laptop" });
|
||||
});
|
||||
|
||||
it("falls back to a restore-failed missing state once the restore times out", () => {
|
||||
expect(
|
||||
resolveWorkspaceRouteState({
|
||||
hostName: "Laptop",
|
||||
connectionStatus: "online",
|
||||
lastError: null,
|
||||
workspace: null,
|
||||
hasHydratedWorkspaces: true,
|
||||
restoreStatus: "failed",
|
||||
}),
|
||||
).toEqual({ kind: "missing", hostName: "Laptop", restoreFailed: true });
|
||||
});
|
||||
|
||||
it("resolves to ready when the descriptor arrives even while restoring", () => {
|
||||
expect(
|
||||
resolveWorkspaceRouteState({
|
||||
hostName: "Laptop",
|
||||
connectionStatus: "online",
|
||||
lastError: null,
|
||||
workspace: createWorkspaceDescriptor(),
|
||||
hasHydratedWorkspaces: true,
|
||||
restoreStatus: "restoring",
|
||||
}),
|
||||
).toEqual({ kind: "ready" });
|
||||
});
|
||||
|
||||
@@ -16,7 +16,8 @@ export type WorkspaceRouteState =
|
||||
lastError: string | null;
|
||||
}
|
||||
| { kind: "loading"; hostName: string }
|
||||
| { kind: "missing"; hostName: string };
|
||||
| { kind: "restoring"; hostName: string }
|
||||
| { kind: "missing"; hostName: string; restoreFailed: boolean };
|
||||
|
||||
export function resolveWorkspaceRouteState(input: {
|
||||
hostName: string;
|
||||
@@ -24,6 +25,7 @@ export function resolveWorkspaceRouteState(input: {
|
||||
lastError: string | null;
|
||||
workspace: WorkspaceDescriptor | null;
|
||||
hasHydratedWorkspaces: boolean;
|
||||
restoreStatus: "restoring" | "failed" | null;
|
||||
}): WorkspaceRouteState {
|
||||
if (input.workspace) {
|
||||
if (input.connectionStatus === "online") {
|
||||
@@ -39,8 +41,16 @@ export function resolveWorkspaceRouteState(input: {
|
||||
}
|
||||
|
||||
if (input.connectionStatus === "online") {
|
||||
if (input.restoreStatus === "restoring") {
|
||||
return { kind: "restoring", hostName: input.hostName };
|
||||
}
|
||||
|
||||
if (input.hasHydratedWorkspaces) {
|
||||
return { kind: "missing", hostName: input.hostName };
|
||||
return {
|
||||
kind: "missing",
|
||||
hostName: input.hostName,
|
||||
restoreFailed: input.restoreStatus === "failed",
|
||||
};
|
||||
}
|
||||
|
||||
return { kind: "loading", hostName: input.hostName };
|
||||
|
||||
@@ -71,7 +71,11 @@ import { useToast } from "@/contexts/toast-context";
|
||||
import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
|
||||
import { selectIsFileExplorerOpen, usePanelStore } from "@/stores/panel-store";
|
||||
import { type ExplorerCheckoutContext } from "@/stores/explorer-checkout-context";
|
||||
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import {
|
||||
useSessionStore,
|
||||
useWorkspaceRestoreStatus,
|
||||
type WorkspaceDescriptor,
|
||||
} from "@/stores/session-store";
|
||||
import {
|
||||
buildWorkspaceTabPersistenceKey,
|
||||
collectAllTabs,
|
||||
@@ -1487,6 +1491,7 @@ function useWorkspaceRouteActions(normalizedServerId: string): {
|
||||
|
||||
function useResolvedWorkspaceRouteState(input: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
workspace: WorkspaceDescriptor | null;
|
||||
hasHydratedWorkspaces: boolean;
|
||||
}): WorkspaceRouteState {
|
||||
@@ -1497,6 +1502,7 @@ function useResolvedWorkspaceRouteState(input: {
|
||||
);
|
||||
const hostSnapshot = useHostRuntimeSnapshot(input.serverId);
|
||||
const hostName = useMemo(() => getHostDisplayName(host, input.serverId), [host, input.serverId]);
|
||||
const restoreStatus = useWorkspaceRestoreStatus(input.serverId, input.workspaceId);
|
||||
|
||||
return useMemo(
|
||||
() =>
|
||||
@@ -1506,6 +1512,7 @@ function useResolvedWorkspaceRouteState(input: {
|
||||
lastError: hostSnapshot?.lastError ?? null,
|
||||
workspace: input.workspace,
|
||||
hasHydratedWorkspaces: input.hasHydratedWorkspaces,
|
||||
restoreStatus,
|
||||
}),
|
||||
[
|
||||
hostName,
|
||||
@@ -1513,6 +1520,7 @@ function useResolvedWorkspaceRouteState(input: {
|
||||
hostSnapshot?.lastError,
|
||||
input.workspace,
|
||||
input.hasHydratedWorkspaces,
|
||||
restoreStatus,
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -1858,6 +1866,7 @@ function WorkspaceScreenContent({
|
||||
);
|
||||
const workspaceRouteState = useResolvedWorkspaceRouteState({
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
workspace: workspaceDescriptor,
|
||||
hasHydratedWorkspaces,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -65,6 +65,7 @@ test("creates a worktree and registers it in the source workspace project withou
|
||||
expect(result.workspace.workspaceId).toMatch(/^wks_[0-9a-f]{16}$/);
|
||||
expect(result.workspace.projectId).toBe("remote:github.com/acme/repo");
|
||||
expect(result.workspace.displayName).toBe("feature-one");
|
||||
expect(result.workspace.baseBranch).toBe("main");
|
||||
expect(deps.workspaceGitService.getSnapshot).not.toHaveBeenCalled();
|
||||
expect(events).toEqual([
|
||||
"project:remote:github.com/acme/repo",
|
||||
@@ -425,6 +426,9 @@ test("does not mark checkout branch worktrees as eligible for first-agent rename
|
||||
version: 1,
|
||||
baseRefName: "dev",
|
||||
});
|
||||
// A checkout-branch worktree has no distinct base, so the workspace records a
|
||||
// null baseBranch even though worktree.json's baseRefName is the branch itself.
|
||||
expect(created.workspace.baseBranch).toBe(null);
|
||||
await expect(
|
||||
attemptFirstAgentBranchAutoName({
|
||||
cwd: created.worktree.worktreePath,
|
||||
|
||||
@@ -21,6 +21,7 @@ import { validateBranchSlug, type WorktreeConfig } from "../utils/worktree.js";
|
||||
import { getCurrentBranch, localBranchExists, renameCurrentBranch } from "../utils/checkout-git.js";
|
||||
import {
|
||||
markPaseoWorktreeFirstAgentBranchAutoNameAttempted,
|
||||
normalizeBaseRefName,
|
||||
readPaseoWorktreeMetadata,
|
||||
writePaseoWorktreeFirstAgentBranchAutoNameMetadata,
|
||||
} from "../utils/worktree-metadata.js";
|
||||
@@ -71,6 +72,7 @@ export async function createPaseoWorktree(
|
||||
projectId: input.projectId,
|
||||
repoRoot: createdWorktree.repoRoot,
|
||||
worktree: createdWorktree.worktree,
|
||||
baseBranch: resolveIntentBaseBranch(createdWorktree.intent),
|
||||
title: resolveFirstAgentPromptTitle(input.firstAgentContext),
|
||||
deps,
|
||||
});
|
||||
@@ -197,11 +199,25 @@ function maybeMarkFirstAgentBranchAutoNameEligible(options: {
|
||||
});
|
||||
}
|
||||
|
||||
// The base branch is normalized to match worktree.json's baseRefName (origin/
|
||||
// stripped). checkout-branch worktrees have no distinct base, so they stay null.
|
||||
function resolveIntentBaseBranch(intent: WorktreeCreationIntent): string | null {
|
||||
switch (intent.kind) {
|
||||
case "branch-off":
|
||||
return normalizeBaseRefName(intent.baseBranch);
|
||||
case "checkout-github-pr":
|
||||
return normalizeBaseRefName(intent.baseRefName);
|
||||
case "checkout-branch":
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function upsertWorkspaceForWorktree(options: {
|
||||
inputCwd: string;
|
||||
projectId?: string;
|
||||
repoRoot: string;
|
||||
worktree: WorktreeConfig;
|
||||
baseBranch?: string | null;
|
||||
title?: string | null;
|
||||
deps: Pick<
|
||||
CreatePaseoWorktreeDeps,
|
||||
@@ -244,6 +260,7 @@ async function upsertWorkspaceForWorktree(options: {
|
||||
kind: "worktree",
|
||||
displayName: options.worktree.branchName || normalizedCwd,
|
||||
branch: options.worktree.branchName || null,
|
||||
baseBranch: options.baseBranch ?? null,
|
||||
title: options.title ?? null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
|
||||
@@ -3503,6 +3503,7 @@ export class Session {
|
||||
|
||||
try {
|
||||
await unarchiveAgentState(this.agentStorage, this.agentManager, agentId);
|
||||
await this.unarchiveOwningWorkspaceForAgent(agentId);
|
||||
let snapshot: ManagedAgent;
|
||||
const existing = this.agentManager.getAgent(agentId);
|
||||
if (existing) {
|
||||
@@ -7066,6 +7067,23 @@ export class Session {
|
||||
};
|
||||
}
|
||||
|
||||
private async unarchiveOwningWorkspaceForAgent(agentId: string): Promise<void> {
|
||||
const record = await this.agentStorage.get(agentId);
|
||||
if (!record?.workspaceId) {
|
||||
return;
|
||||
}
|
||||
const directoryExists = await this.filesystem.isDirectory(record.cwd).catch(() => false);
|
||||
if (!directoryExists) {
|
||||
return;
|
||||
}
|
||||
const workspace = await this.workspaceRegistry.get(record.workspaceId);
|
||||
if (!workspace?.archivedAt) {
|
||||
return;
|
||||
}
|
||||
await this.ensureWorkspaceRecordUnarchived(workspace);
|
||||
await this.emitWorkspaceUpdatesForWorkspaceIds([workspace.workspaceId]);
|
||||
}
|
||||
|
||||
private async ensureWorkspaceRecordUnarchived(
|
||||
workspace: PersistedWorkspaceRecord,
|
||||
): Promise<PersistedWorkspaceRecord> {
|
||||
|
||||
@@ -83,6 +83,8 @@ interface SessionTestAccess {
|
||||
};
|
||||
agentManager: {
|
||||
listAgents(): unknown[];
|
||||
getAgent(agentId: string): unknown;
|
||||
reloadAgentSession(agentId: string, overrides?: unknown, options?: unknown): Promise<unknown>;
|
||||
listImportableSessions(options?: unknown): Promise<unknown[]>;
|
||||
importProviderSession(input: unknown): Promise<unknown>;
|
||||
resumeAgentFromPersistence(
|
||||
@@ -147,6 +149,9 @@ interface SessionTestAccess {
|
||||
peekSnapshot: (cwd: string) => WorkspaceGitRuntimeSnapshot | null;
|
||||
registerWorkspace: (params: { cwd: string }, listener: unknown) => { unsubscribe: () => void };
|
||||
};
|
||||
filesystem: {
|
||||
isDirectory(cwd: string): Promise<boolean>;
|
||||
};
|
||||
}
|
||||
|
||||
interface ListFetchResult {
|
||||
@@ -4040,6 +4045,193 @@ test("open_project_request unarchives an existing archived workspace and project
|
||||
expect(response?.payload.workspace?.id).toBe(workspaceId);
|
||||
});
|
||||
|
||||
test("refresh_agent_request unarchives the owning workspace when its directory exists", async () => {
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const session = createSessionForWorkspaceTests({
|
||||
onMessage: (message) => {
|
||||
if (isSessionOutboundMessage(message)) emitted.push(message);
|
||||
},
|
||||
});
|
||||
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
|
||||
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
|
||||
|
||||
const cwd = path.resolve("/tmp/paseo-unit2-existing-dir");
|
||||
session.filesystem.isDirectory = async () => true;
|
||||
const workspaceId = "ws-repo-archived";
|
||||
const agentId = "agent-archived";
|
||||
projects.set(
|
||||
cwd,
|
||||
createPersistedProjectRecord({
|
||||
projectId: cwd,
|
||||
rootPath: cwd,
|
||||
kind: "non_git",
|
||||
displayName: "repo",
|
||||
createdAt: "2026-03-01T12:00:00.000Z",
|
||||
updatedAt: "2026-03-10T00:00:00.000Z",
|
||||
archivedAt: "2026-03-10T00:00:00.000Z",
|
||||
}),
|
||||
);
|
||||
workspaces.set(
|
||||
workspaceId,
|
||||
createPersistedWorkspaceRecord({
|
||||
workspaceId,
|
||||
projectId: cwd,
|
||||
cwd,
|
||||
kind: "directory",
|
||||
displayName: "repo",
|
||||
createdAt: "2026-03-01T12:00:00.000Z",
|
||||
updatedAt: "2026-03-10T00:00:00.000Z",
|
||||
archivedAt: "2026-03-10T00:00:00.000Z",
|
||||
}),
|
||||
);
|
||||
|
||||
const storedAgent: StoredAgentRecord = {
|
||||
...makeStoredAgent({ id: agentId, cwd, updatedAt: "2026-03-10T00:00:00.000Z" }),
|
||||
workspaceId,
|
||||
archivedAt: "2026-03-10T00:00:00.000Z",
|
||||
};
|
||||
|
||||
session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null;
|
||||
session.projectRegistry.upsert = async (
|
||||
record: ReturnType<typeof createPersistedProjectRecord>,
|
||||
) => {
|
||||
projects.set(record.projectId, record);
|
||||
};
|
||||
session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
|
||||
workspaces.get(lookupWorkspaceId) ?? null;
|
||||
session.workspaceRegistry.upsert = async (
|
||||
record: ReturnType<typeof createPersistedWorkspaceRecord>,
|
||||
) => {
|
||||
workspaces.set(record.workspaceId, record);
|
||||
};
|
||||
session.projectRegistry.list = async () => Array.from(projects.values());
|
||||
session.workspaceRegistry.list = async () => Array.from(workspaces.values());
|
||||
|
||||
session.agentStorage.get = async (id: string) => (id === agentId ? storedAgent : null);
|
||||
session.agentStorage.upsert = async () => {};
|
||||
|
||||
const managed = makeManagedAgent({
|
||||
id: agentId,
|
||||
cwd,
|
||||
workspaceId,
|
||||
lifecycle: "idle",
|
||||
updatedAt: "2026-03-10T00:00:00.000Z",
|
||||
});
|
||||
session.agentManager.getAgent = () => managed;
|
||||
session.interruptAgentIfRunning = async () => undefined;
|
||||
session.agentManager.reloadAgentSession = async () => managed;
|
||||
session.agentManager.hydrateTimelineFromProvider = async () => undefined;
|
||||
session.agentManager.getTimeline = () => [];
|
||||
session.forwardAgentUpdate = async () => undefined;
|
||||
|
||||
const unarchivedWorkspaceIds: string[][] = [];
|
||||
const realEmit = session.emitWorkspaceUpdatesForWorkspaceIds.bind(session);
|
||||
session.emitWorkspaceUpdatesForWorkspaceIds = async (ids: string[], ...rest: unknown[]) => {
|
||||
unarchivedWorkspaceIds.push(ids);
|
||||
return realEmit(ids, ...rest);
|
||||
};
|
||||
|
||||
await session.handleMessage({
|
||||
type: "refresh_agent_request",
|
||||
agentId,
|
||||
requestId: "req-refresh-unarchive",
|
||||
});
|
||||
|
||||
expect(workspaces.get(workspaceId)?.archivedAt).toBeNull();
|
||||
expect(projects.get(cwd)?.archivedAt).toBeNull();
|
||||
expect(unarchivedWorkspaceIds).toContainEqual([workspaceId]);
|
||||
expect(findByType(emitted, "rpc_error")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("refresh_agent_request leaves the owning workspace archived when its directory is missing", async () => {
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const session = createSessionForWorkspaceTests({
|
||||
onMessage: (message) => {
|
||||
if (isSessionOutboundMessage(message)) emitted.push(message);
|
||||
},
|
||||
});
|
||||
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
|
||||
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
|
||||
|
||||
const cwd = path.resolve("/tmp/paseo-missing-workspace-dir");
|
||||
session.filesystem.isDirectory = async () => false;
|
||||
const workspaceId = "ws-missing-dir";
|
||||
const agentId = "agent-missing-dir";
|
||||
projects.set(
|
||||
cwd,
|
||||
createPersistedProjectRecord({
|
||||
projectId: cwd,
|
||||
rootPath: cwd,
|
||||
kind: "non_git",
|
||||
displayName: "missing",
|
||||
createdAt: "2026-03-01T12:00:00.000Z",
|
||||
updatedAt: "2026-03-10T00:00:00.000Z",
|
||||
archivedAt: "2026-03-10T00:00:00.000Z",
|
||||
}),
|
||||
);
|
||||
workspaces.set(
|
||||
workspaceId,
|
||||
createPersistedWorkspaceRecord({
|
||||
workspaceId,
|
||||
projectId: cwd,
|
||||
cwd,
|
||||
kind: "directory",
|
||||
displayName: "missing",
|
||||
createdAt: "2026-03-01T12:00:00.000Z",
|
||||
updatedAt: "2026-03-10T00:00:00.000Z",
|
||||
archivedAt: "2026-03-10T00:00:00.000Z",
|
||||
}),
|
||||
);
|
||||
|
||||
const storedAgent: StoredAgentRecord = {
|
||||
...makeStoredAgent({ id: agentId, cwd, updatedAt: "2026-03-10T00:00:00.000Z" }),
|
||||
workspaceId,
|
||||
archivedAt: "2026-03-10T00:00:00.000Z",
|
||||
};
|
||||
|
||||
session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null;
|
||||
session.projectRegistry.upsert = async (
|
||||
record: ReturnType<typeof createPersistedProjectRecord>,
|
||||
) => {
|
||||
projects.set(record.projectId, record);
|
||||
};
|
||||
session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
|
||||
workspaces.get(lookupWorkspaceId) ?? null;
|
||||
session.workspaceRegistry.upsert = async (
|
||||
record: ReturnType<typeof createPersistedWorkspaceRecord>,
|
||||
) => {
|
||||
workspaces.set(record.workspaceId, record);
|
||||
};
|
||||
session.projectRegistry.list = async () => Array.from(projects.values());
|
||||
session.workspaceRegistry.list = async () => Array.from(workspaces.values());
|
||||
|
||||
session.agentStorage.get = async (id: string) => (id === agentId ? storedAgent : null);
|
||||
session.agentStorage.upsert = async () => {};
|
||||
|
||||
const managed = makeManagedAgent({
|
||||
id: agentId,
|
||||
cwd,
|
||||
workspaceId,
|
||||
lifecycle: "idle",
|
||||
updatedAt: "2026-03-10T00:00:00.000Z",
|
||||
});
|
||||
session.agentManager.getAgent = () => managed;
|
||||
session.interruptAgentIfRunning = async () => undefined;
|
||||
session.agentManager.reloadAgentSession = async () => managed;
|
||||
session.agentManager.hydrateTimelineFromProvider = async () => undefined;
|
||||
session.agentManager.getTimeline = () => [];
|
||||
session.forwardAgentUpdate = async () => undefined;
|
||||
|
||||
await session.handleMessage({
|
||||
type: "refresh_agent_request",
|
||||
agentId,
|
||||
requestId: "req-refresh-missing-dir",
|
||||
});
|
||||
|
||||
expect(workspaces.get(workspaceId)?.archivedAt).toBe("2026-03-10T00:00:00.000Z");
|
||||
expect(projects.get(cwd)?.archivedAt).toBe("2026-03-10T00:00:00.000Z");
|
||||
});
|
||||
|
||||
test.skip("open_project_request collapses a git subdirectory onto the repo root workspace", async () => {
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const session = createSessionForWorkspaceTests();
|
||||
|
||||
@@ -45,6 +45,14 @@ const PersistedWorkspaceRecordSchema = z.object({
|
||||
.nullable()
|
||||
.optional()
|
||||
.transform((value) => value ?? null),
|
||||
// The base branch the worktree was created from (normalized like worktree.json's
|
||||
// baseRefName). Only worktree workspaces carry a base branch; checkout-branch
|
||||
// worktrees and directory/local_checkout workspaces leave it null.
|
||||
baseBranch: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.transform((value) => value ?? null),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
archivedAt: z.string().nullable(),
|
||||
@@ -245,6 +253,7 @@ export function createPersistedWorkspaceRecord(input: {
|
||||
displayName: string;
|
||||
title?: string | null;
|
||||
branch?: string | null;
|
||||
baseBranch?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
archivedAt?: string | null;
|
||||
@@ -253,6 +262,7 @@ export function createPersistedWorkspaceRecord(input: {
|
||||
...input,
|
||||
title: input.title ?? null,
|
||||
branch: input.branch ?? null,
|
||||
baseBranch: input.baseBranch ?? null,
|
||||
archivedAt: input.archivedAt ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user