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 { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||||
import { buildHostRootRoute } from "@/utils/host-routes";
|
import { buildHostRootRoute } from "@/utils/host-routes";
|
||||||
import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
|
import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
|
||||||
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
|
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||||
|
|
||||||
export default function HostAgentReadyRoute() {
|
export default function HostAgentReadyRoute() {
|
||||||
return (
|
return (
|
||||||
@@ -50,10 +50,9 @@ function HostAgentReadyRouteContent() {
|
|||||||
|
|
||||||
if (resolvedWorkspaceId) {
|
if (resolvedWorkspaceId) {
|
||||||
redirectedRef.current = true;
|
redirectedRef.current = true;
|
||||||
navigateToPreparedWorkspaceTab({
|
navigateToAgent({
|
||||||
serverId,
|
serverId,
|
||||||
workspaceId: resolvedWorkspaceId,
|
agentId,
|
||||||
target: { kind: "agent", agentId },
|
|
||||||
currentPathname: pathname,
|
currentPathname: pathname,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -93,10 +92,10 @@ function HostAgentReadyRouteContent() {
|
|||||||
const workspaceId = normalizeWorkspaceOpaqueId(result?.agent?.workspaceId);
|
const workspaceId = normalizeWorkspaceOpaqueId(result?.agent?.workspaceId);
|
||||||
redirectedRef.current = true;
|
redirectedRef.current = true;
|
||||||
if (workspaceId) {
|
if (workspaceId) {
|
||||||
navigateToPreparedWorkspaceTab({
|
navigateToAgent({
|
||||||
serverId,
|
serverId,
|
||||||
|
agentId,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
target: { kind: "agent", agentId },
|
|
||||||
currentPathname: pathname,
|
currentPathname: pathname,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -328,6 +328,8 @@ export const ar: TranslationResources = {
|
|||||||
workspace: {
|
workspace: {
|
||||||
route: {
|
route: {
|
||||||
loading: "جارٍ تحميل مساحة العمل",
|
loading: "جارٍ تحميل مساحة العمل",
|
||||||
|
restoring: "جارٍ استعادة مساحة العمل",
|
||||||
|
restoreFailed: "تعذّر استعادة مساحة العمل هذه — ربما تم نقل المجلد أو حذفه",
|
||||||
connecting: "الاتصال",
|
connecting: "الاتصال",
|
||||||
hostOffline: "{{hostName}}غير متواجد حالياً",
|
hostOffline: "{{hostName}}غير متواجد حالياً",
|
||||||
cannotReachHost: "لا يمكن الوصول إلى{{hostName}}",
|
cannotReachHost: "لا يمكن الوصول إلى{{hostName}}",
|
||||||
|
|||||||
@@ -327,6 +327,9 @@ export const en = {
|
|||||||
workspace: {
|
workspace: {
|
||||||
route: {
|
route: {
|
||||||
loading: "Loading workspace",
|
loading: "Loading workspace",
|
||||||
|
restoring: "Restoring workspace",
|
||||||
|
restoreFailed:
|
||||||
|
"Couldn't restore this workspace — the directory may have been moved or deleted",
|
||||||
connecting: "Connecting",
|
connecting: "Connecting",
|
||||||
hostOffline: "{{hostName}} is offline",
|
hostOffline: "{{hostName}} is offline",
|
||||||
cannotReachHost: "Cannot reach {{hostName}}",
|
cannotReachHost: "Cannot reach {{hostName}}",
|
||||||
|
|||||||
@@ -331,6 +331,9 @@ export const es: TranslationResources = {
|
|||||||
workspace: {
|
workspace: {
|
||||||
route: {
|
route: {
|
||||||
loading: "Cargando espacio de trabajo",
|
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",
|
connecting: "Conectando",
|
||||||
hostOffline: "{{hostName}}está desconectado",
|
hostOffline: "{{hostName}}está desconectado",
|
||||||
cannotReachHost: "No se puede alcanzar{{hostName}}",
|
cannotReachHost: "No se puede alcanzar{{hostName}}",
|
||||||
|
|||||||
@@ -331,6 +331,9 @@ export const fr: TranslationResources = {
|
|||||||
workspace: {
|
workspace: {
|
||||||
route: {
|
route: {
|
||||||
loading: "Chargement de l'espace de travail",
|
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",
|
connecting: "De liaison",
|
||||||
hostOffline: "{{hostName}}est hors ligne",
|
hostOffline: "{{hostName}}est hors ligne",
|
||||||
cannotReachHost: "Impossible d'atteindre{{hostName}}",
|
cannotReachHost: "Impossible d'atteindre{{hostName}}",
|
||||||
|
|||||||
@@ -330,6 +330,9 @@ export const ru: TranslationResources = {
|
|||||||
workspace: {
|
workspace: {
|
||||||
route: {
|
route: {
|
||||||
loading: "Загрузка рабочей области",
|
loading: "Загрузка рабочей области",
|
||||||
|
restoring: "Восстановление рабочей области",
|
||||||
|
restoreFailed:
|
||||||
|
"Не удалось восстановить эту рабочую область — каталог мог быть перемещён или удалён",
|
||||||
connecting: "Подключение",
|
connecting: "Подключение",
|
||||||
hostOffline: "{{hostName}}не в сети",
|
hostOffline: "{{hostName}}не в сети",
|
||||||
cannotReachHost: "Невозможно связаться с{{hostName}}",
|
cannotReachHost: "Невозможно связаться с{{hostName}}",
|
||||||
|
|||||||
@@ -328,6 +328,8 @@ export const zhCN: TranslationResources = {
|
|||||||
workspace: {
|
workspace: {
|
||||||
route: {
|
route: {
|
||||||
loading: "正在加载 workspace",
|
loading: "正在加载 workspace",
|
||||||
|
restoring: "正在恢复 workspace",
|
||||||
|
restoreFailed: "无法恢复此 workspace — 目录可能已被移动或删除",
|
||||||
connecting: "正在连接",
|
connecting: "正在连接",
|
||||||
hostOffline: "{{hostName}} 已离线",
|
hostOffline: "{{hostName}} 已离线",
|
||||||
cannotReachHost: "无法连接 {{hostName}}",
|
cannotReachHost: "无法连接 {{hostName}}",
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ export function renderWorkspaceRouteGate(input: {
|
|||||||
switch (input.state.kind) {
|
switch (input.state.kind) {
|
||||||
case "loading":
|
case "loading":
|
||||||
return <WorkspaceConnecting hostName={input.state.hostName} />;
|
return <WorkspaceConnecting hostName={input.state.hostName} />;
|
||||||
|
case "restoring":
|
||||||
|
return <WorkspaceRestoring hostName={input.state.hostName} />;
|
||||||
case "unreachable":
|
case "unreachable":
|
||||||
return (
|
return (
|
||||||
<WorkspaceUnreachable
|
<WorkspaceUnreachable
|
||||||
@@ -33,6 +35,7 @@ export function renderWorkspaceRouteGate(input: {
|
|||||||
return (
|
return (
|
||||||
<WorkspaceMissing
|
<WorkspaceMissing
|
||||||
hostName={input.state.hostName}
|
hostName={input.state.hostName}
|
||||||
|
restoreFailed={input.state.restoreFailed}
|
||||||
onDismiss={input.actions.onDismissMissingWorkspace}
|
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({
|
function WorkspaceUnreachable({
|
||||||
state,
|
state,
|
||||||
onRetry,
|
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();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.emptyState}>
|
<View style={styles.emptyState}>
|
||||||
<View style={styles.textStack}>
|
<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>
|
<Text style={styles.description}>{hostName}</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.actions}>
|
<View style={styles.actions}>
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ describe("resolveWorkspaceRouteState", () => {
|
|||||||
lastError: "transport closed",
|
lastError: "transport closed",
|
||||||
workspace: null,
|
workspace: null,
|
||||||
hasHydratedWorkspaces: false,
|
hasHydratedWorkspaces: false,
|
||||||
|
restoreStatus: null,
|
||||||
}),
|
}),
|
||||||
).toEqual({
|
).toEqual({
|
||||||
kind: "unreachable",
|
kind: "unreachable",
|
||||||
@@ -46,6 +47,7 @@ describe("resolveWorkspaceRouteState", () => {
|
|||||||
lastError: "transport closed",
|
lastError: "transport closed",
|
||||||
workspace: null,
|
workspace: null,
|
||||||
hasHydratedWorkspaces: true,
|
hasHydratedWorkspaces: true,
|
||||||
|
restoreStatus: null,
|
||||||
}),
|
}),
|
||||||
).toEqual({
|
).toEqual({
|
||||||
kind: "unreachable",
|
kind: "unreachable",
|
||||||
@@ -63,6 +65,7 @@ describe("resolveWorkspaceRouteState", () => {
|
|||||||
lastError: "transport closed",
|
lastError: "transport closed",
|
||||||
workspace: createWorkspaceDescriptor(),
|
workspace: createWorkspaceDescriptor(),
|
||||||
hasHydratedWorkspaces: true,
|
hasHydratedWorkspaces: true,
|
||||||
|
restoreStatus: null,
|
||||||
}),
|
}),
|
||||||
).toEqual({
|
).toEqual({
|
||||||
kind: "reconnecting",
|
kind: "reconnecting",
|
||||||
@@ -80,8 +83,9 @@ describe("resolveWorkspaceRouteState", () => {
|
|||||||
lastError: null,
|
lastError: null,
|
||||||
workspace: null,
|
workspace: null,
|
||||||
hasHydratedWorkspaces: true,
|
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", () => {
|
it("returns loading before workspace hydration when the host is online", () => {
|
||||||
@@ -92,6 +96,7 @@ describe("resolveWorkspaceRouteState", () => {
|
|||||||
lastError: null,
|
lastError: null,
|
||||||
workspace: null,
|
workspace: null,
|
||||||
hasHydratedWorkspaces: false,
|
hasHydratedWorkspaces: false,
|
||||||
|
restoreStatus: null,
|
||||||
}),
|
}),
|
||||||
).toEqual({ kind: "loading", hostName: "Laptop" });
|
).toEqual({ kind: "loading", hostName: "Laptop" });
|
||||||
});
|
});
|
||||||
@@ -104,6 +109,46 @@ describe("resolveWorkspaceRouteState", () => {
|
|||||||
lastError: null,
|
lastError: null,
|
||||||
workspace: createWorkspaceDescriptor(),
|
workspace: createWorkspaceDescriptor(),
|
||||||
hasHydratedWorkspaces: true,
|
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" });
|
).toEqual({ kind: "ready" });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ export type WorkspaceRouteState =
|
|||||||
lastError: string | null;
|
lastError: string | null;
|
||||||
}
|
}
|
||||||
| { kind: "loading"; hostName: string }
|
| { kind: "loading"; hostName: string }
|
||||||
| { kind: "missing"; hostName: string };
|
| { kind: "restoring"; hostName: string }
|
||||||
|
| { kind: "missing"; hostName: string; restoreFailed: boolean };
|
||||||
|
|
||||||
export function resolveWorkspaceRouteState(input: {
|
export function resolveWorkspaceRouteState(input: {
|
||||||
hostName: string;
|
hostName: string;
|
||||||
@@ -24,6 +25,7 @@ export function resolveWorkspaceRouteState(input: {
|
|||||||
lastError: string | null;
|
lastError: string | null;
|
||||||
workspace: WorkspaceDescriptor | null;
|
workspace: WorkspaceDescriptor | null;
|
||||||
hasHydratedWorkspaces: boolean;
|
hasHydratedWorkspaces: boolean;
|
||||||
|
restoreStatus: "restoring" | "failed" | null;
|
||||||
}): WorkspaceRouteState {
|
}): WorkspaceRouteState {
|
||||||
if (input.workspace) {
|
if (input.workspace) {
|
||||||
if (input.connectionStatus === "online") {
|
if (input.connectionStatus === "online") {
|
||||||
@@ -39,8 +41,16 @@ export function resolveWorkspaceRouteState(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (input.connectionStatus === "online") {
|
if (input.connectionStatus === "online") {
|
||||||
|
if (input.restoreStatus === "restoring") {
|
||||||
|
return { kind: "restoring", hostName: input.hostName };
|
||||||
|
}
|
||||||
|
|
||||||
if (input.hasHydratedWorkspaces) {
|
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 };
|
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 { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
|
||||||
import { selectIsFileExplorerOpen, usePanelStore } from "@/stores/panel-store";
|
import { selectIsFileExplorerOpen, usePanelStore } from "@/stores/panel-store";
|
||||||
import { type ExplorerCheckoutContext } from "@/stores/explorer-checkout-context";
|
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 {
|
import {
|
||||||
buildWorkspaceTabPersistenceKey,
|
buildWorkspaceTabPersistenceKey,
|
||||||
collectAllTabs,
|
collectAllTabs,
|
||||||
@@ -1487,6 +1491,7 @@ function useWorkspaceRouteActions(normalizedServerId: string): {
|
|||||||
|
|
||||||
function useResolvedWorkspaceRouteState(input: {
|
function useResolvedWorkspaceRouteState(input: {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
|
workspaceId: string;
|
||||||
workspace: WorkspaceDescriptor | null;
|
workspace: WorkspaceDescriptor | null;
|
||||||
hasHydratedWorkspaces: boolean;
|
hasHydratedWorkspaces: boolean;
|
||||||
}): WorkspaceRouteState {
|
}): WorkspaceRouteState {
|
||||||
@@ -1497,6 +1502,7 @@ function useResolvedWorkspaceRouteState(input: {
|
|||||||
);
|
);
|
||||||
const hostSnapshot = useHostRuntimeSnapshot(input.serverId);
|
const hostSnapshot = useHostRuntimeSnapshot(input.serverId);
|
||||||
const hostName = useMemo(() => getHostDisplayName(host, input.serverId), [host, input.serverId]);
|
const hostName = useMemo(() => getHostDisplayName(host, input.serverId), [host, input.serverId]);
|
||||||
|
const restoreStatus = useWorkspaceRestoreStatus(input.serverId, input.workspaceId);
|
||||||
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -1506,6 +1512,7 @@ function useResolvedWorkspaceRouteState(input: {
|
|||||||
lastError: hostSnapshot?.lastError ?? null,
|
lastError: hostSnapshot?.lastError ?? null,
|
||||||
workspace: input.workspace,
|
workspace: input.workspace,
|
||||||
hasHydratedWorkspaces: input.hasHydratedWorkspaces,
|
hasHydratedWorkspaces: input.hasHydratedWorkspaces,
|
||||||
|
restoreStatus,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
hostName,
|
hostName,
|
||||||
@@ -1513,6 +1520,7 @@ function useResolvedWorkspaceRouteState(input: {
|
|||||||
hostSnapshot?.lastError,
|
hostSnapshot?.lastError,
|
||||||
input.workspace,
|
input.workspace,
|
||||||
input.hasHydratedWorkspaces,
|
input.hasHydratedWorkspaces,
|
||||||
|
restoreStatus,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1858,6 +1866,7 @@ function WorkspaceScreenContent({
|
|||||||
);
|
);
|
||||||
const workspaceRouteState = useResolvedWorkspaceRouteState({
|
const workspaceRouteState = useResolvedWorkspaceRouteState({
|
||||||
serverId: normalizedServerId,
|
serverId: normalizedServerId,
|
||||||
|
workspaceId: normalizedWorkspaceId,
|
||||||
workspace: workspaceDescriptor,
|
workspace: workspaceDescriptor,
|
||||||
hasHydratedWorkspaces,
|
hasHydratedWorkspaces,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -363,6 +363,51 @@ describe("mergeWorkspaces", () => {
|
|||||||
expect(after.workspaces).not.toBe(before.workspaces);
|
expect(after.workspaces).not.toBe(before.workspaces);
|
||||||
expect(after.workspaces.get(workspace.id)?.diffStat).toBeNull();
|
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", () => {
|
describe("setWorkspaces", () => {
|
||||||
|
|||||||
@@ -287,6 +287,8 @@ export interface AgentTimelineCursorState {
|
|||||||
endSeq: number;
|
endSeq: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type WorkspaceRestoreStatus = "restoring" | "failed";
|
||||||
|
|
||||||
// Per-session state
|
// Per-session state
|
||||||
export interface SessionState {
|
export interface SessionState {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
@@ -332,6 +334,9 @@ export interface SessionState {
|
|||||||
// Project parents with no active workspaces, keyed by projectId. Rendered as
|
// Project parents with no active workspaces, keyed by projectId. Rendered as
|
||||||
// empty project rows in the sidebar.
|
// empty project rows in the sidebar.
|
||||||
emptyProjects: Map<string, EmptyProjectDescriptor>;
|
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
|
// Permissions
|
||||||
pendingPermissions: Map<string, PendingPermission>;
|
pendingPermissions: Map<string, PendingPermission>;
|
||||||
@@ -455,6 +460,12 @@ interface SessionStoreActions {
|
|||||||
removeWorkspace: (serverId: string, workspaceId: string) => void;
|
removeWorkspace: (serverId: string, workspaceId: string) => void;
|
||||||
setEmptyProjects: (serverId: string, emptyProjects: Iterable<EmptyProjectDescriptor>) => void;
|
setEmptyProjects: (serverId: string, emptyProjects: Iterable<EmptyProjectDescriptor>) => void;
|
||||||
addEmptyProject: (serverId: string, emptyProject: 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
|
// Agent activity timestamps
|
||||||
setAgentLastActivity: (agentId: string, timestamp: Date) => void;
|
setAgentLastActivity: (agentId: string, timestamp: Date) => void;
|
||||||
@@ -527,6 +538,7 @@ function createInitialSessionState(serverId: string, client: DaemonClient): Sess
|
|||||||
agentDetails: new Map(),
|
agentDetails: new Map(),
|
||||||
workspaces: new Map(),
|
workspaces: new Map(),
|
||||||
emptyProjects: new Map(),
|
emptyProjects: new Map(),
|
||||||
|
restoringWorkspaces: new Map(),
|
||||||
pendingPermissions: new Map(),
|
pendingPermissions: new Map(),
|
||||||
fileExplorer: new Map(),
|
fileExplorer: new Map(),
|
||||||
queuedMessages: 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) => {
|
mergeWorkspaces: (serverId, workspaces) => {
|
||||||
const nextEntries = Array.from(workspaces);
|
const nextEntries = Array.from(workspaces);
|
||||||
set((prev) => {
|
set((prev) => {
|
||||||
@@ -1266,10 +1326,18 @@ export const useSessionStore = create<SessionStore>()(
|
|||||||
// empty: prune any stale empty descriptor so it stops governing the
|
// empty: prune any stale empty descriptor so it stops governing the
|
||||||
// project's rendered metadata.
|
// project's rendered metadata.
|
||||||
const nextEmptyProjects = new Map(session.emptyProjects);
|
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) {
|
for (const workspace of nextEntries) {
|
||||||
if (nextEmptyProjects.delete(workspace.projectId)) {
|
if (nextEmptyProjects.delete(workspace.projectId)) {
|
||||||
changed = true;
|
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 existing = next.get(workspace.id);
|
||||||
const nextWorkspace = preserveWorkspaceDescriptorIdentity(workspace, existing);
|
const nextWorkspace = preserveWorkspaceDescriptorIdentity(workspace, existing);
|
||||||
if (existing === nextWorkspace) {
|
if (existing === nextWorkspace) {
|
||||||
@@ -1285,7 +1353,12 @@ export const useSessionStore = create<SessionStore>()(
|
|||||||
...prev,
|
...prev,
|
||||||
sessions: {
|
sessions: {
|
||||||
...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 { router, type Href } from "expo-router";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
|
import { getHostRuntimeStore, isHostRuntimeConnected } from "@/runtime/host-runtime";
|
||||||
import { resolveNavigateToAgent, type NavigateToAgentInput } from "./resolve";
|
import { resolveNavigateToAgent, type NavigateToAgentInput } from "./resolve";
|
||||||
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
|
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
|
||||||
|
|
||||||
export type { NavigateToAgentInput } from "./resolve";
|
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 {
|
export function navigateToAgent(input: NavigateToAgentInput): string {
|
||||||
return resolveNavigateToAgent(input, {
|
return resolveNavigateToAgent(input, {
|
||||||
readAgentNavTarget: ({ serverId, agentId }) => {
|
readAgentNavTarget: ({ serverId, agentId }) => {
|
||||||
@@ -18,5 +59,8 @@ export function navigateToAgent(input: NavigateToAgentInput): string {
|
|||||||
router.navigate(route as Href);
|
router.navigate(route as Href);
|
||||||
},
|
},
|
||||||
navigateToPreparedWorkspaceTab,
|
navigateToPreparedWorkspaceTab,
|
||||||
|
restoreArchivedWorkspace: ({ serverId, agentId, workspaceId }) => {
|
||||||
|
restoreArchivedWorkspace(serverId, agentId, workspaceId);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,16 +16,25 @@ interface RecordedHostNav {
|
|||||||
|
|
||||||
interface RecordedTabNav extends NavigateToPreparedWorkspaceTabInput {}
|
interface RecordedTabNav extends NavigateToPreparedWorkspaceTabInput {}
|
||||||
|
|
||||||
|
interface RecordedRestore {
|
||||||
|
serverId: string;
|
||||||
|
agentId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
}
|
||||||
|
|
||||||
function createFakeNavigators(target: AgentNavTarget): {
|
function createFakeNavigators(target: AgentNavTarget): {
|
||||||
deps: NavigateToAgentDeps;
|
deps: NavigateToAgentDeps;
|
||||||
hostNavigations: RecordedHostNav[];
|
hostNavigations: RecordedHostNav[];
|
||||||
tabNavigations: RecordedTabNav[];
|
tabNavigations: RecordedTabNav[];
|
||||||
|
restores: RecordedRestore[];
|
||||||
} {
|
} {
|
||||||
const hostNavigations: RecordedHostNav[] = [];
|
const hostNavigations: RecordedHostNav[] = [];
|
||||||
const tabNavigations: RecordedTabNav[] = [];
|
const tabNavigations: RecordedTabNav[] = [];
|
||||||
|
const restores: RecordedRestore[] = [];
|
||||||
return {
|
return {
|
||||||
hostNavigations,
|
hostNavigations,
|
||||||
tabNavigations,
|
tabNavigations,
|
||||||
|
restores,
|
||||||
deps: {
|
deps: {
|
||||||
readAgentNavTarget: () => target,
|
readAgentNavTarget: () => target,
|
||||||
navigateToHostAgent: (route) => {
|
navigateToHostAgent: (route) => {
|
||||||
@@ -35,6 +44,9 @@ function createFakeNavigators(target: AgentNavTarget): {
|
|||||||
tabNavigations.push(input);
|
tabNavigations.push(input);
|
||||||
return `/h/${input.serverId}/workspace/${input.workspaceId}`;
|
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", () => {
|
it("falls back to the host agent route when the agent has no workspaceId", () => {
|
||||||
const { deps, hostNavigations, tabNavigations } = createFakeNavigators({
|
const { deps, hostNavigations, tabNavigations } = createFakeNavigators({
|
||||||
agentWorkspaceId: null,
|
agentWorkspaceId: null,
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import type { NavigateToPreparedWorkspaceTabInput } from "@/utils/prepare-worksp
|
|||||||
export interface NavigateToAgentInput {
|
export interface NavigateToAgentInput {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
agentId: 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;
|
currentPathname?: string | null;
|
||||||
pin?: boolean;
|
pin?: boolean;
|
||||||
}
|
}
|
||||||
@@ -17,16 +20,20 @@ export interface NavigateToAgentDeps {
|
|||||||
readAgentNavTarget: (input: { serverId: string; agentId: string }) => AgentNavTarget;
|
readAgentNavTarget: (input: { serverId: string; agentId: string }) => AgentNavTarget;
|
||||||
navigateToHostAgent: (route: string) => void;
|
navigateToHostAgent: (route: string) => void;
|
||||||
navigateToPreparedWorkspaceTab: (input: NavigateToPreparedWorkspaceTabInput) => string;
|
navigateToPreparedWorkspaceTab: (input: NavigateToPreparedWorkspaceTabInput) => string;
|
||||||
|
restoreArchivedWorkspace: (input: {
|
||||||
|
serverId: string;
|
||||||
|
agentId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
}) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveNavigateToAgent(
|
export function resolveNavigateToAgent(
|
||||||
input: NavigateToAgentInput,
|
input: NavigateToAgentInput,
|
||||||
deps: NavigateToAgentDeps,
|
deps: NavigateToAgentDeps,
|
||||||
): string {
|
): string {
|
||||||
const { agentWorkspaceId } = deps.readAgentNavTarget({
|
const agentWorkspaceId =
|
||||||
serverId: input.serverId,
|
input.workspaceId ??
|
||||||
agentId: input.agentId,
|
deps.readAgentNavTarget({ serverId: input.serverId, agentId: input.agentId }).agentWorkspaceId;
|
||||||
});
|
|
||||||
const workspaceId = normalizeWorkspaceOpaqueId(agentWorkspaceId);
|
const workspaceId = normalizeWorkspaceOpaqueId(agentWorkspaceId);
|
||||||
|
|
||||||
if (!workspaceId) {
|
if (!workspaceId) {
|
||||||
@@ -35,6 +42,14 @@ export function resolveNavigateToAgent(
|
|||||||
return route;
|
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({
|
return deps.navigateToPreparedWorkspaceTab({
|
||||||
serverId: input.serverId,
|
serverId: input.serverId,
|
||||||
workspaceId,
|
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.workspaceId).toMatch(/^wks_[0-9a-f]{16}$/);
|
||||||
expect(result.workspace.projectId).toBe("remote:github.com/acme/repo");
|
expect(result.workspace.projectId).toBe("remote:github.com/acme/repo");
|
||||||
expect(result.workspace.displayName).toBe("feature-one");
|
expect(result.workspace.displayName).toBe("feature-one");
|
||||||
|
expect(result.workspace.baseBranch).toBe("main");
|
||||||
expect(deps.workspaceGitService.getSnapshot).not.toHaveBeenCalled();
|
expect(deps.workspaceGitService.getSnapshot).not.toHaveBeenCalled();
|
||||||
expect(events).toEqual([
|
expect(events).toEqual([
|
||||||
"project:remote:github.com/acme/repo",
|
"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,
|
version: 1,
|
||||||
baseRefName: "dev",
|
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(
|
await expect(
|
||||||
attemptFirstAgentBranchAutoName({
|
attemptFirstAgentBranchAutoName({
|
||||||
cwd: created.worktree.worktreePath,
|
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 { getCurrentBranch, localBranchExists, renameCurrentBranch } from "../utils/checkout-git.js";
|
||||||
import {
|
import {
|
||||||
markPaseoWorktreeFirstAgentBranchAutoNameAttempted,
|
markPaseoWorktreeFirstAgentBranchAutoNameAttempted,
|
||||||
|
normalizeBaseRefName,
|
||||||
readPaseoWorktreeMetadata,
|
readPaseoWorktreeMetadata,
|
||||||
writePaseoWorktreeFirstAgentBranchAutoNameMetadata,
|
writePaseoWorktreeFirstAgentBranchAutoNameMetadata,
|
||||||
} from "../utils/worktree-metadata.js";
|
} from "../utils/worktree-metadata.js";
|
||||||
@@ -71,6 +72,7 @@ export async function createPaseoWorktree(
|
|||||||
projectId: input.projectId,
|
projectId: input.projectId,
|
||||||
repoRoot: createdWorktree.repoRoot,
|
repoRoot: createdWorktree.repoRoot,
|
||||||
worktree: createdWorktree.worktree,
|
worktree: createdWorktree.worktree,
|
||||||
|
baseBranch: resolveIntentBaseBranch(createdWorktree.intent),
|
||||||
title: resolveFirstAgentPromptTitle(input.firstAgentContext),
|
title: resolveFirstAgentPromptTitle(input.firstAgentContext),
|
||||||
deps,
|
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: {
|
async function upsertWorkspaceForWorktree(options: {
|
||||||
inputCwd: string;
|
inputCwd: string;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
repoRoot: string;
|
repoRoot: string;
|
||||||
worktree: WorktreeConfig;
|
worktree: WorktreeConfig;
|
||||||
|
baseBranch?: string | null;
|
||||||
title?: string | null;
|
title?: string | null;
|
||||||
deps: Pick<
|
deps: Pick<
|
||||||
CreatePaseoWorktreeDeps,
|
CreatePaseoWorktreeDeps,
|
||||||
@@ -244,6 +260,7 @@ async function upsertWorkspaceForWorktree(options: {
|
|||||||
kind: "worktree",
|
kind: "worktree",
|
||||||
displayName: options.worktree.branchName || normalizedCwd,
|
displayName: options.worktree.branchName || normalizedCwd,
|
||||||
branch: options.worktree.branchName || null,
|
branch: options.worktree.branchName || null,
|
||||||
|
baseBranch: options.baseBranch ?? null,
|
||||||
title: options.title ?? null,
|
title: options.title ?? null,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
|
|||||||
@@ -3503,6 +3503,7 @@ export class Session {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await unarchiveAgentState(this.agentStorage, this.agentManager, agentId);
|
await unarchiveAgentState(this.agentStorage, this.agentManager, agentId);
|
||||||
|
await this.unarchiveOwningWorkspaceForAgent(agentId);
|
||||||
let snapshot: ManagedAgent;
|
let snapshot: ManagedAgent;
|
||||||
const existing = this.agentManager.getAgent(agentId);
|
const existing = this.agentManager.getAgent(agentId);
|
||||||
if (existing) {
|
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(
|
private async ensureWorkspaceRecordUnarchived(
|
||||||
workspace: PersistedWorkspaceRecord,
|
workspace: PersistedWorkspaceRecord,
|
||||||
): Promise<PersistedWorkspaceRecord> {
|
): Promise<PersistedWorkspaceRecord> {
|
||||||
|
|||||||
@@ -83,6 +83,8 @@ interface SessionTestAccess {
|
|||||||
};
|
};
|
||||||
agentManager: {
|
agentManager: {
|
||||||
listAgents(): unknown[];
|
listAgents(): unknown[];
|
||||||
|
getAgent(agentId: string): unknown;
|
||||||
|
reloadAgentSession(agentId: string, overrides?: unknown, options?: unknown): Promise<unknown>;
|
||||||
listImportableSessions(options?: unknown): Promise<unknown[]>;
|
listImportableSessions(options?: unknown): Promise<unknown[]>;
|
||||||
importProviderSession(input: unknown): Promise<unknown>;
|
importProviderSession(input: unknown): Promise<unknown>;
|
||||||
resumeAgentFromPersistence(
|
resumeAgentFromPersistence(
|
||||||
@@ -147,6 +149,9 @@ interface SessionTestAccess {
|
|||||||
peekSnapshot: (cwd: string) => WorkspaceGitRuntimeSnapshot | null;
|
peekSnapshot: (cwd: string) => WorkspaceGitRuntimeSnapshot | null;
|
||||||
registerWorkspace: (params: { cwd: string }, listener: unknown) => { unsubscribe: () => void };
|
registerWorkspace: (params: { cwd: string }, listener: unknown) => { unsubscribe: () => void };
|
||||||
};
|
};
|
||||||
|
filesystem: {
|
||||||
|
isDirectory(cwd: string): Promise<boolean>;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ListFetchResult {
|
interface ListFetchResult {
|
||||||
@@ -4040,6 +4045,193 @@ test("open_project_request unarchives an existing archived workspace and project
|
|||||||
expect(response?.payload.workspace?.id).toBe(workspaceId);
|
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 () => {
|
test.skip("open_project_request collapses a git subdirectory onto the repo root workspace", async () => {
|
||||||
const emitted: SessionOutboundMessage[] = [];
|
const emitted: SessionOutboundMessage[] = [];
|
||||||
const session = createSessionForWorkspaceTests();
|
const session = createSessionForWorkspaceTests();
|
||||||
|
|||||||
@@ -45,6 +45,14 @@ const PersistedWorkspaceRecordSchema = z.object({
|
|||||||
.nullable()
|
.nullable()
|
||||||
.optional()
|
.optional()
|
||||||
.transform((value) => value ?? null),
|
.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(),
|
createdAt: z.string(),
|
||||||
updatedAt: z.string(),
|
updatedAt: z.string(),
|
||||||
archivedAt: z.string().nullable(),
|
archivedAt: z.string().nullable(),
|
||||||
@@ -245,6 +253,7 @@ export function createPersistedWorkspaceRecord(input: {
|
|||||||
displayName: string;
|
displayName: string;
|
||||||
title?: string | null;
|
title?: string | null;
|
||||||
branch?: string | null;
|
branch?: string | null;
|
||||||
|
baseBranch?: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
archivedAt?: string | null;
|
archivedAt?: string | null;
|
||||||
@@ -253,6 +262,7 @@ export function createPersistedWorkspaceRecord(input: {
|
|||||||
...input,
|
...input,
|
||||||
title: input.title ?? null,
|
title: input.title ?? null,
|
||||||
branch: input.branch ?? null,
|
branch: input.branch ?? null,
|
||||||
|
baseBranch: input.baseBranch ?? null,
|
||||||
archivedAt: input.archivedAt ?? null,
|
archivedAt: input.archivedAt ?? null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user