Recreate deleted worktrees from their branch on restore
Archiving a Paseo-owned worktree deletes its directory, so reopening its agent previously dead-ended even after the workspace record came back — there was no folder to return to. Restore now recreates the worktree at its original path from the kept branch (the branch is never deleted on archive), re-points the same workspace record, and clears archivedAt — preserving the workspaceId so the agent stays linked. The directory is recreated before the un-archive so the reconciler can't re-archive it for a missing directory. A deleted or already-checked-out branch surfaces a typed failure and leaves the workspace archived. Gated behind a worktreeRestore capability flag; older hosts get an explicit "update your host" state instead of a misleading failure.
This commit is contained in:
@@ -335,6 +335,7 @@ export const ar: TranslationResources = {
|
|||||||
cannotReachHost: "لا يمكن الوصول إلى{{hostName}}",
|
cannotReachHost: "لا يمكن الوصول إلى{{hostName}}",
|
||||||
hostStatus: "حالة Host:{{status}}",
|
hostStatus: "حالة Host:{{status}}",
|
||||||
missing: "لم يتم العثور على Workspace",
|
missing: "لم يتم العثور على Workspace",
|
||||||
|
needsHostUpgrade: "قم بتحديث مضيفك لاستعادة مساحة العمل هذه",
|
||||||
manageHost: "إدارة المضيف",
|
manageHost: "إدارة المضيف",
|
||||||
},
|
},
|
||||||
hoverCard: {
|
hoverCard: {
|
||||||
|
|||||||
@@ -335,6 +335,7 @@ export const en = {
|
|||||||
cannotReachHost: "Cannot reach {{hostName}}",
|
cannotReachHost: "Cannot reach {{hostName}}",
|
||||||
hostStatus: "Host status: {{status}}",
|
hostStatus: "Host status: {{status}}",
|
||||||
missing: "Workspace not found",
|
missing: "Workspace not found",
|
||||||
|
needsHostUpgrade: "Update your host to restore this workspace",
|
||||||
manageHost: "Manage host",
|
manageHost: "Manage host",
|
||||||
},
|
},
|
||||||
hoverCard: {
|
hoverCard: {
|
||||||
|
|||||||
@@ -339,6 +339,7 @@ export const es: TranslationResources = {
|
|||||||
cannotReachHost: "No se puede alcanzar{{hostName}}",
|
cannotReachHost: "No se puede alcanzar{{hostName}}",
|
||||||
hostStatus: "Estado deHost:{{status}}",
|
hostStatus: "Estado deHost:{{status}}",
|
||||||
missing: "Workspaceno encontrado",
|
missing: "Workspaceno encontrado",
|
||||||
|
needsHostUpgrade: "Actualiza tu host para restaurar este espacio de trabajo",
|
||||||
manageHost: "Administrar host",
|
manageHost: "Administrar host",
|
||||||
},
|
},
|
||||||
hoverCard: {
|
hoverCard: {
|
||||||
|
|||||||
@@ -339,6 +339,7 @@ export const fr: TranslationResources = {
|
|||||||
cannotReachHost: "Impossible d'atteindre{{hostName}}",
|
cannotReachHost: "Impossible d'atteindre{{hostName}}",
|
||||||
hostStatus: "StatutHost:{{status}}",
|
hostStatus: "StatutHost:{{status}}",
|
||||||
missing: "Workspaceintrouvable",
|
missing: "Workspaceintrouvable",
|
||||||
|
needsHostUpgrade: "Mettez à jour votre hôte pour restaurer cet espace de travail",
|
||||||
manageHost: "Gérer l'hôte",
|
manageHost: "Gérer l'hôte",
|
||||||
},
|
},
|
||||||
hoverCard: {
|
hoverCard: {
|
||||||
|
|||||||
@@ -338,6 +338,7 @@ export const ru: TranslationResources = {
|
|||||||
cannotReachHost: "Невозможно связаться с{{hostName}}",
|
cannotReachHost: "Невозможно связаться с{{hostName}}",
|
||||||
hostStatus: "Статус Host:{{status}}",
|
hostStatus: "Статус Host:{{status}}",
|
||||||
missing: "Workspace не найден",
|
missing: "Workspace не найден",
|
||||||
|
needsHostUpgrade: "Обновите хост, чтобы восстановить эту рабочую область",
|
||||||
manageHost: "Управление хостом",
|
manageHost: "Управление хостом",
|
||||||
},
|
},
|
||||||
hoverCard: {
|
hoverCard: {
|
||||||
|
|||||||
@@ -335,6 +335,7 @@ export const zhCN: TranslationResources = {
|
|||||||
cannotReachHost: "无法连接 {{hostName}}",
|
cannotReachHost: "无法连接 {{hostName}}",
|
||||||
hostStatus: "Host 状态:{{status}}",
|
hostStatus: "Host 状态:{{status}}",
|
||||||
missing: "Workspace 未找到",
|
missing: "Workspace 未找到",
|
||||||
|
needsHostUpgrade: "更新你的 Host 以恢复此 workspace",
|
||||||
manageHost: "管理 Host",
|
manageHost: "管理 Host",
|
||||||
},
|
},
|
||||||
hoverCard: {
|
hoverCard: {
|
||||||
|
|||||||
@@ -23,6 +23,14 @@ export function renderWorkspaceRouteGate(input: {
|
|||||||
return <WorkspaceConnecting hostName={input.state.hostName} />;
|
return <WorkspaceConnecting hostName={input.state.hostName} />;
|
||||||
case "restoring":
|
case "restoring":
|
||||||
return <WorkspaceRestoring hostName={input.state.hostName} />;
|
return <WorkspaceRestoring hostName={input.state.hostName} />;
|
||||||
|
case "needsHostUpgrade":
|
||||||
|
return (
|
||||||
|
<WorkspaceEmptyState
|
||||||
|
titleKey="workspace.route.needsHostUpgrade"
|
||||||
|
hostName={input.state.hostName}
|
||||||
|
onDismiss={input.actions.onDismissMissingWorkspace}
|
||||||
|
/>
|
||||||
|
);
|
||||||
case "unreachable":
|
case "unreachable":
|
||||||
return (
|
return (
|
||||||
<WorkspaceUnreachable
|
<WorkspaceUnreachable
|
||||||
@@ -33,9 +41,11 @@ export function renderWorkspaceRouteGate(input: {
|
|||||||
);
|
);
|
||||||
case "missing":
|
case "missing":
|
||||||
return (
|
return (
|
||||||
<WorkspaceMissing
|
<WorkspaceEmptyState
|
||||||
|
titleKey={
|
||||||
|
input.state.restoreFailed ? "workspace.route.restoreFailed" : "workspace.route.missing"
|
||||||
|
}
|
||||||
hostName={input.state.hostName}
|
hostName={input.state.hostName}
|
||||||
restoreFailed={input.state.restoreFailed}
|
|
||||||
onDismiss={input.actions.onDismissMissingWorkspace}
|
onDismiss={input.actions.onDismissMissingWorkspace}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -142,13 +152,16 @@ function WorkspaceUnreachable({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function WorkspaceMissing({
|
function WorkspaceEmptyState({
|
||||||
|
titleKey,
|
||||||
hostName,
|
hostName,
|
||||||
restoreFailed,
|
|
||||||
onDismiss,
|
onDismiss,
|
||||||
}: {
|
}: {
|
||||||
|
titleKey:
|
||||||
|
| "workspace.route.missing"
|
||||||
|
| "workspace.route.restoreFailed"
|
||||||
|
| "workspace.route.needsHostUpgrade";
|
||||||
hostName: string;
|
hostName: string;
|
||||||
restoreFailed: boolean;
|
|
||||||
onDismiss: () => void;
|
onDismiss: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -156,9 +169,7 @@ function WorkspaceMissing({
|
|||||||
return (
|
return (
|
||||||
<View style={styles.emptyState}>
|
<View style={styles.emptyState}>
|
||||||
<View style={styles.textStack}>
|
<View style={styles.textStack}>
|
||||||
<Text style={styles.title}>
|
<Text style={styles.title}>{t(titleKey)}</Text>
|
||||||
{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}>
|
||||||
|
|||||||
@@ -127,6 +127,19 @@ describe("resolveWorkspaceRouteState", () => {
|
|||||||
).toEqual({ kind: "restoring", hostName: "Laptop" });
|
).toEqual({ kind: "restoring", hostName: "Laptop" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns needsHostUpgrade when the daemon lacks the restore capability", () => {
|
||||||
|
expect(
|
||||||
|
resolveWorkspaceRouteState({
|
||||||
|
hostName: "Laptop",
|
||||||
|
connectionStatus: "online",
|
||||||
|
lastError: null,
|
||||||
|
workspace: null,
|
||||||
|
hasHydratedWorkspaces: true,
|
||||||
|
restoreStatus: "needs-host-upgrade",
|
||||||
|
}),
|
||||||
|
).toEqual({ kind: "needsHostUpgrade", hostName: "Laptop" });
|
||||||
|
});
|
||||||
|
|
||||||
it("falls back to a restore-failed missing state once the restore times out", () => {
|
it("falls back to a restore-failed missing state once the restore times out", () => {
|
||||||
expect(
|
expect(
|
||||||
resolveWorkspaceRouteState({
|
resolveWorkspaceRouteState({
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export type WorkspaceRouteState =
|
|||||||
}
|
}
|
||||||
| { kind: "loading"; hostName: string }
|
| { kind: "loading"; hostName: string }
|
||||||
| { kind: "restoring"; hostName: string }
|
| { kind: "restoring"; hostName: string }
|
||||||
|
| { kind: "needsHostUpgrade"; hostName: string }
|
||||||
| { kind: "missing"; hostName: string; restoreFailed: boolean };
|
| { kind: "missing"; hostName: string; restoreFailed: boolean };
|
||||||
|
|
||||||
export function resolveWorkspaceRouteState(input: {
|
export function resolveWorkspaceRouteState(input: {
|
||||||
@@ -25,7 +26,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;
|
restoreStatus: "restoring" | "failed" | "needs-host-upgrade" | null;
|
||||||
}): WorkspaceRouteState {
|
}): WorkspaceRouteState {
|
||||||
if (input.workspace) {
|
if (input.workspace) {
|
||||||
if (input.connectionStatus === "online") {
|
if (input.connectionStatus === "online") {
|
||||||
@@ -45,6 +46,10 @@ export function resolveWorkspaceRouteState(input: {
|
|||||||
return { kind: "restoring", hostName: input.hostName };
|
return { kind: "restoring", hostName: input.hostName };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (input.restoreStatus === "needs-host-upgrade") {
|
||||||
|
return { kind: "needsHostUpgrade", hostName: input.hostName };
|
||||||
|
}
|
||||||
|
|
||||||
if (input.hasHydratedWorkspaces) {
|
if (input.hasHydratedWorkspaces) {
|
||||||
return {
|
return {
|
||||||
kind: "missing",
|
kind: "missing",
|
||||||
|
|||||||
@@ -287,7 +287,7 @@ export interface AgentTimelineCursorState {
|
|||||||
endSeq: number;
|
endSeq: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WorkspaceRestoreStatus = "restoring" | "failed";
|
export type WorkspaceRestoreStatus = "restoring" | "failed" | "needs-host-upgrade";
|
||||||
|
|
||||||
// Per-session state
|
// Per-session state
|
||||||
export interface SessionState {
|
export interface SessionState {
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ 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
|
// 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.
|
// re-emitting a workspace_update (the directory-gone case), so the gate never spins
|
||||||
const RESTORE_TIMEOUT_MS = 7000;
|
// forever. Recreating a worktree can require a git fetch, so the budget is generous
|
||||||
|
// to avoid flashing a false "failed" on a capable daemon doing slow real work.
|
||||||
|
const RESTORE_TIMEOUT_MS = 30000;
|
||||||
|
|
||||||
function restoreArchivedWorkspace(serverId: string, agentId: string, workspaceId: string): void {
|
function restoreArchivedWorkspace(serverId: string, agentId: string, workspaceId: string): void {
|
||||||
const snapshot = getHostRuntimeStore().getSnapshot(serverId);
|
const snapshot = getHostRuntimeStore().getSnapshot(serverId);
|
||||||
@@ -33,6 +35,15 @@ function restoreArchivedWorkspace(serverId: string, agentId: string, workspaceId
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// COMPAT(worktreeRestore): added in v0.1.98, drop the gate when floor >= v0.1.98
|
||||||
|
// Single capability read for restore. An old daemon recreates nothing on
|
||||||
|
// refresh_agent, so a gone directory would spin then flash a misleading
|
||||||
|
// "couldn't restore". Surface an explicit "update your host" state instead.
|
||||||
|
if (session?.serverInfo?.features?.worktreeRestore !== true) {
|
||||||
|
store.setWorkspaceRestoreStatus(serverId, workspaceId, "needs-host-upgrade");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
store.setWorkspaceRestoreStatus(serverId, workspaceId, "restoring");
|
store.setWorkspaceRestoreStatus(serverId, workspaceId, "restoring");
|
||||||
// The reducer guards "failed" so a late timeout after the descriptor lands is a no-op.
|
// The reducer guards "failed" so a late timeout after the descriptor lands is a no-op.
|
||||||
setTimeout(
|
setTimeout(
|
||||||
|
|||||||
@@ -27,15 +27,21 @@ const SERVER_ID = "server-1";
|
|||||||
const AGENT_ID = "agent-1";
|
const AGENT_ID = "agent-1";
|
||||||
const WORKSPACE_ID = "workspace-1";
|
const WORKSPACE_ID = "workspace-1";
|
||||||
|
|
||||||
function status(): "restoring" | "failed" | null {
|
function status(): "restoring" | "failed" | "needs-host-upgrade" | null {
|
||||||
return (
|
return (
|
||||||
useSessionStore.getState().sessions[SERVER_ID]?.restoringWorkspaces.get(WORKSPACE_ID) ?? null
|
useSessionStore.getState().sessions[SERVER_ID]?.restoringWorkspaces.get(WORKSPACE_ID) ?? null
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function seedArchivedAgent(): void {
|
function seedArchivedAgent(options?: { worktreeRestore?: boolean }): void {
|
||||||
const store = useSessionStore.getState();
|
const store = useSessionStore.getState();
|
||||||
store.initializeSession(SERVER_ID, null as unknown as DaemonClient);
|
store.initializeSession(SERVER_ID, null as unknown as DaemonClient);
|
||||||
|
store.updateSessionServerInfo(SERVER_ID, {
|
||||||
|
serverId: SERVER_ID,
|
||||||
|
hostname: "host",
|
||||||
|
version: "0.1.98",
|
||||||
|
features: { worktreeRestore: options?.worktreeRestore ?? true },
|
||||||
|
} as unknown as Parameters<typeof store.updateSessionServerInfo>[1]);
|
||||||
store.setAgents(SERVER_ID, (prev) => {
|
store.setAgents(SERVER_ID, (prev) => {
|
||||||
const next = new Map(prev);
|
const next = new Map(prev);
|
||||||
next.set(AGENT_ID, {
|
next.set(AGENT_ID, {
|
||||||
@@ -133,10 +139,21 @@ describe("restoreArchivedWorkspace via navigateToAgent", () => {
|
|||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
expect(status()).toBe("restoring");
|
expect(status()).toBe("restoring");
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(7000);
|
await vi.advanceTimersByTimeAsync(30000);
|
||||||
expect(status()).toBe("failed");
|
expect(status()).toBe("failed");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("marks the workspace needs-host-upgrade without refreshing when the daemon lacks worktreeRestore", () => {
|
||||||
|
useSessionStore.getState().clearSession(SERVER_ID);
|
||||||
|
seedArchivedAgent({ worktreeRestore: false });
|
||||||
|
refreshAgent.mockImplementation(() => new Promise(() => {}));
|
||||||
|
|
||||||
|
trigger();
|
||||||
|
|
||||||
|
expect(refreshAgent).not.toHaveBeenCalled();
|
||||||
|
expect(status()).toBe("needs-host-upgrade");
|
||||||
|
});
|
||||||
|
|
||||||
it("is a no-op when the workspace descriptor is already present", () => {
|
it("is a no-op when the workspace descriptor is already present", () => {
|
||||||
refreshAgent.mockImplementation(() => new Promise(() => {}));
|
refreshAgent.mockImplementation(() => new Promise(() => {}));
|
||||||
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [
|
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [
|
||||||
|
|||||||
@@ -2256,6 +2256,8 @@ export const ServerInfoStatusPayloadSchema = z
|
|||||||
checkoutRefresh: z.boolean().optional(),
|
checkoutRefresh: z.boolean().optional(),
|
||||||
// COMPAT(workspaceMultiplicity): added in v0.1.97, drop the gate when floor >= v0.1.97
|
// COMPAT(workspaceMultiplicity): added in v0.1.97, drop the gate when floor >= v0.1.97
|
||||||
workspaceMultiplicity: z.boolean().optional(),
|
workspaceMultiplicity: z.boolean().optional(),
|
||||||
|
// COMPAT(worktreeRestore): added in v0.1.98, drop the gate when floor >= v0.1.98
|
||||||
|
worktreeRestore: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ import {
|
|||||||
type CreatePaseoWorktreeDeps,
|
type CreatePaseoWorktreeDeps,
|
||||||
} from "./paseo-worktree-service.js";
|
} from "./paseo-worktree-service.js";
|
||||||
import { readPaseoWorktreeMetadata } from "../utils/worktree-metadata.js";
|
import { readPaseoWorktreeMetadata } from "../utils/worktree-metadata.js";
|
||||||
|
import { createWorktree } from "../utils/worktree.js";
|
||||||
import { isPlatform } from "../test-utils/platform.js";
|
import { isPlatform } from "../test-utils/platform.js";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
|
||||||
const cleanupPaths: string[] = [];
|
const cleanupPaths: string[] = [];
|
||||||
|
|
||||||
@@ -503,6 +505,109 @@ test("does not mutate registries or broadcast when core worktree creation fails"
|
|||||||
expect(deps.workspaces.size).toBe(0);
|
expect(deps.workspaces.size).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Worktree restore (Unit 3): recreate a deleted Paseo-owned worktree from its
|
||||||
|
// kept branch via createWorktree's checkout-branch source.
|
||||||
|
test.skipIf(isPlatform("win32"))(
|
||||||
|
"recreates a deleted worktree on the same kept branch without creating a suffixed branch",
|
||||||
|
async () => {
|
||||||
|
const { repoDir, tempDir } = createGitRepo();
|
||||||
|
cleanupPaths.push(tempDir);
|
||||||
|
const paseoHome = path.join(tempDir, ".paseo");
|
||||||
|
|
||||||
|
execFileSync("git", ["branch", "restore-me"], { cwd: repoDir, stdio: "pipe" });
|
||||||
|
|
||||||
|
const created = await createWorktree({
|
||||||
|
cwd: repoDir,
|
||||||
|
worktreeSlug: "restore-me",
|
||||||
|
source: { kind: "checkout-branch", branchName: "restore-me" },
|
||||||
|
runSetup: false,
|
||||||
|
paseoHome,
|
||||||
|
});
|
||||||
|
expect(existsSync(created.worktreePath)).toBe(true);
|
||||||
|
|
||||||
|
execFileSync("git", ["worktree", "remove", created.worktreePath, "--force"], {
|
||||||
|
cwd: repoDir,
|
||||||
|
stdio: "pipe",
|
||||||
|
});
|
||||||
|
expect(existsSync(created.worktreePath)).toBe(false);
|
||||||
|
|
||||||
|
const recreated = await createWorktree({
|
||||||
|
cwd: repoDir,
|
||||||
|
worktreeSlug: "restore-me",
|
||||||
|
source: { kind: "checkout-branch", branchName: "restore-me" },
|
||||||
|
runSetup: false,
|
||||||
|
paseoHome,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(recreated.worktreePath).toBe(created.worktreePath);
|
||||||
|
expect(existsSync(recreated.worktreePath)).toBe(true);
|
||||||
|
expect(
|
||||||
|
execFileSync("git", ["branch", "--show-current"], {
|
||||||
|
cwd: recreated.worktreePath,
|
||||||
|
stdio: "pipe",
|
||||||
|
})
|
||||||
|
.toString()
|
||||||
|
.trim(),
|
||||||
|
).toBe("restore-me");
|
||||||
|
const branches = execFileSync("git", ["branch", "--list", "restore-me*"], {
|
||||||
|
cwd: repoDir,
|
||||||
|
stdio: "pipe",
|
||||||
|
})
|
||||||
|
.toString()
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => line.replace(/^[*+ ]+/, "").trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
expect(branches).toEqual(["restore-me"]);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test.skipIf(isPlatform("win32"))(
|
||||||
|
"rejects with UnknownBranchError when the kept branch no longer exists",
|
||||||
|
async () => {
|
||||||
|
const { repoDir, tempDir } = createGitRepo();
|
||||||
|
cleanupPaths.push(tempDir);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
createWorktree({
|
||||||
|
cwd: repoDir,
|
||||||
|
worktreeSlug: "gone-branch",
|
||||||
|
source: { kind: "checkout-branch", branchName: "gone-branch" },
|
||||||
|
runSetup: false,
|
||||||
|
paseoHome: path.join(tempDir, ".paseo"),
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ name: "UnknownBranchError" });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test.skipIf(isPlatform("win32"))(
|
||||||
|
"rejects with BranchAlreadyCheckedOutError when the kept branch is checked out elsewhere",
|
||||||
|
async () => {
|
||||||
|
const { repoDir, tempDir } = createGitRepo();
|
||||||
|
cleanupPaths.push(tempDir);
|
||||||
|
const paseoHome = path.join(tempDir, ".paseo");
|
||||||
|
|
||||||
|
execFileSync("git", ["branch", "busy-branch"], { cwd: repoDir, stdio: "pipe" });
|
||||||
|
const first = await createWorktree({
|
||||||
|
cwd: repoDir,
|
||||||
|
worktreeSlug: "busy-branch",
|
||||||
|
source: { kind: "checkout-branch", branchName: "busy-branch" },
|
||||||
|
runSetup: false,
|
||||||
|
paseoHome,
|
||||||
|
});
|
||||||
|
expect(existsSync(first.worktreePath)).toBe(true);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
createWorktree({
|
||||||
|
cwd: repoDir,
|
||||||
|
worktreeSlug: "busy-branch-again",
|
||||||
|
source: { kind: "checkout-branch", branchName: "busy-branch" },
|
||||||
|
runSetup: false,
|
||||||
|
paseoHome,
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ name: "BranchAlreadyCheckedOutError" });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
interface TestDeps extends CreatePaseoWorktreeDeps {
|
interface TestDeps extends CreatePaseoWorktreeDeps {
|
||||||
projectRegistry: Pick<ProjectRegistry, "get" | "list" | "upsert">;
|
projectRegistry: Pick<ProjectRegistry, "get" | "list" | "upsert">;
|
||||||
projects: Map<string, PersistedProjectRecord>;
|
projects: Map<string, PersistedProjectRecord>;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { v4 as uuidv4 } from "uuid";
|
|||||||
import { realpathSync } from "node:fs";
|
import { realpathSync } from "node:fs";
|
||||||
import type { FSWatcher } from "node:fs";
|
import type { FSWatcher } from "node:fs";
|
||||||
import { stat } from "node:fs/promises";
|
import { stat } from "node:fs/promises";
|
||||||
import { resolve, sep } from "path";
|
import { basename, normalize, resolve, sep } from "path";
|
||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import type { ToolSet } from "ai";
|
import type { ToolSet } from "ai";
|
||||||
@@ -271,7 +271,12 @@ import {
|
|||||||
handleWorkspaceSetupStatusRequest as handleWorkspaceSetupStatusRequestMessage,
|
handleWorkspaceSetupStatusRequest as handleWorkspaceSetupStatusRequestMessage,
|
||||||
} from "./worktree-session.js";
|
} from "./worktree-session.js";
|
||||||
import { archiveByScope, type ActiveWorkspaceRef } from "./workspace-archive-service.js";
|
import { archiveByScope, type ActiveWorkspaceRef } from "./workspace-archive-service.js";
|
||||||
import { toWorktreeWireError } from "./worktree-errors.js";
|
import {
|
||||||
|
WorktreeRequestError,
|
||||||
|
toWorktreeRequestError,
|
||||||
|
toWorktreeWireError,
|
||||||
|
} from "./worktree-errors.js";
|
||||||
|
import { type WorktreeConfig, createWorktree } from "../utils/worktree.js";
|
||||||
import { CreateAgentLifecycleDispatch } from "./agent/create-agent-lifecycle-dispatch.js";
|
import { CreateAgentLifecycleDispatch } from "./agent/create-agent-lifecycle-dispatch.js";
|
||||||
|
|
||||||
const WORKSPACE_GIT_WATCH_REMOVED_STATE_KEY = "__removed__";
|
const WORKSPACE_GIT_WATCH_REMOVED_STATE_KEY = "__removed__";
|
||||||
@@ -3555,7 +3560,7 @@ export class Session {
|
|||||||
requestId,
|
requestId,
|
||||||
requestType: msg.type,
|
requestType: msg.type,
|
||||||
error: message,
|
error: message,
|
||||||
code: "agent_refresh_failed",
|
code: error instanceof WorktreeRequestError ? error.code : "agent_refresh_failed",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -7072,18 +7077,69 @@ export class Session {
|
|||||||
if (!record?.workspaceId) {
|
if (!record?.workspaceId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const directoryExists = await this.filesystem.isDirectory(record.cwd).catch(() => false);
|
|
||||||
if (!directoryExists) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const workspace = await this.workspaceRegistry.get(record.workspaceId);
|
const workspace = await this.workspaceRegistry.get(record.workspaceId);
|
||||||
if (!workspace?.archivedAt) {
|
if (!workspace?.archivedAt) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const directoryExists = await this.filesystem.isDirectory(record.cwd).catch(() => false);
|
||||||
|
if (!directoryExists) {
|
||||||
|
if (workspace.kind !== "worktree" || !workspace.branch) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Recreate the worktree directory from its kept branch BEFORE clearing
|
||||||
|
// archivedAt — the reconciler re-archives workspaces whose directory is
|
||||||
|
// missing, so the record must point at a real directory first.
|
||||||
|
await this.recreateOwningWorktreeForRestore(workspace, workspace.branch);
|
||||||
|
}
|
||||||
|
|
||||||
await this.ensureWorkspaceRecordUnarchived(workspace);
|
await this.ensureWorkspaceRecordUnarchived(workspace);
|
||||||
await this.emitWorkspaceUpdatesForWorkspaceIds([workspace.workspaceId]);
|
await this.emitWorkspaceUpdatesForWorkspaceIds([workspace.workspaceId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async recreateOwningWorktreeForRestore(
|
||||||
|
workspace: PersistedWorkspaceRecord,
|
||||||
|
branch: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const project = await this.projectRegistry.get(workspace.projectId);
|
||||||
|
if (!project) {
|
||||||
|
throw new WorktreeRequestError({
|
||||||
|
code: "unknown",
|
||||||
|
message: `Project ${workspace.projectId} not found for workspace ${workspace.workspaceId}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const projectRootExists = await this.filesystem
|
||||||
|
.isDirectory(project.rootPath)
|
||||||
|
.catch(() => false);
|
||||||
|
if (!projectRootExists) {
|
||||||
|
throw new WorktreeRequestError({
|
||||||
|
code: "unknown",
|
||||||
|
message: `Project root is missing for ${workspace.projectId}: ${project.rootPath}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let result: WorktreeConfig;
|
||||||
|
try {
|
||||||
|
result = await createWorktree({
|
||||||
|
cwd: project.rootPath,
|
||||||
|
worktreeSlug: basename(workspace.cwd),
|
||||||
|
source: { kind: "checkout-branch", branchName: branch },
|
||||||
|
runSetup: false,
|
||||||
|
paseoHome: this.paseoHome,
|
||||||
|
worktreesRoot: this.worktreesRoot,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw toWorktreeRequestError(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalize(result.worktreePath) !== normalize(workspace.cwd)) {
|
||||||
|
throw new WorktreeRequestError({
|
||||||
|
code: "unknown",
|
||||||
|
message: `Recreated worktree diverged from ${workspace.cwd}: ${result.worktreePath}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async ensureWorkspaceRecordUnarchived(
|
private async ensureWorkspaceRecordUnarchived(
|
||||||
workspace: PersistedWorkspaceRecord,
|
workspace: PersistedWorkspaceRecord,
|
||||||
): Promise<PersistedWorkspaceRecord> {
|
): Promise<PersistedWorkspaceRecord> {
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
import { execFileSync } from "node:child_process";
|
import { execFileSync } from "node:child_process";
|
||||||
import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
import {
|
||||||
|
existsSync,
|
||||||
|
mkdirSync,
|
||||||
|
mkdtempSync,
|
||||||
|
realpathSync,
|
||||||
|
rmSync,
|
||||||
|
statSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { afterEach, expect, test, vi } from "vitest";
|
import { afterEach, expect, test, vi } from "vitest";
|
||||||
@@ -22,7 +30,8 @@ import type {
|
|||||||
AgentSessionConfig,
|
AgentSessionConfig,
|
||||||
AgentStreamEvent,
|
AgentStreamEvent,
|
||||||
} from "./agent/agent-sdk-types.js";
|
} from "./agent/agent-sdk-types.js";
|
||||||
import { createWorktree } from "../utils/worktree.js";
|
import { createWorktree, UnknownBranchError } from "../utils/worktree.js";
|
||||||
|
import { WorktreeRequestError, toWorktreeRequestError } from "./worktree-errors.js";
|
||||||
import type { WorkspaceGitRuntimeSnapshot } from "./workspace-git-service.js";
|
import type { WorkspaceGitRuntimeSnapshot } from "./workspace-git-service.js";
|
||||||
import type { GeneratedWorkspaceName } from "./worktree-branch-name-generator.js";
|
import type { GeneratedWorkspaceName } from "./worktree-branch-name-generator.js";
|
||||||
import type { GitHubService } from "../services/github-service.js";
|
import type { GitHubService } from "../services/github-service.js";
|
||||||
@@ -106,6 +115,10 @@ interface SessionTestAccess {
|
|||||||
agentUpdatesSubscription: unknown;
|
agentUpdatesSubscription: unknown;
|
||||||
workspaceUpdatesSubscription: unknown;
|
workspaceUpdatesSubscription: unknown;
|
||||||
interruptAgentIfRunning(agentId: string): unknown;
|
interruptAgentIfRunning(agentId: string): unknown;
|
||||||
|
recreateOwningWorktreeForRestore(
|
||||||
|
workspace: PersistedWorkspaceRecord,
|
||||||
|
branch: string,
|
||||||
|
): Promise<void>;
|
||||||
reconcileActiveWorkspaceRecords(...args: unknown[]): Promise<Set<string>>;
|
reconcileActiveWorkspaceRecords(...args: unknown[]): Promise<Set<string>>;
|
||||||
reconcileWorkspaceRecord(workspaceId: string): Promise<{
|
reconcileWorkspaceRecord(workspaceId: string): Promise<{
|
||||||
changed: boolean;
|
changed: boolean;
|
||||||
@@ -4232,6 +4245,411 @@ test("refresh_agent_request leaves the owning workspace archived when its direct
|
|||||||
expect(projects.get(cwd)?.archivedAt).toBe("2026-03-10T00:00:00.000Z");
|
expect(projects.get(cwd)?.archivedAt).toBe("2026-03-10T00:00:00.000Z");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("refresh_agent_request recreates a deleted worktree directory and unarchives the same workspace", 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-deleted-worktree-dir");
|
||||||
|
session.filesystem.isDirectory = async () => false;
|
||||||
|
const workspaceId = "ws-deleted-worktree";
|
||||||
|
const agentId = "agent-deleted-worktree";
|
||||||
|
projects.set(
|
||||||
|
cwd,
|
||||||
|
createPersistedProjectRecord({
|
||||||
|
projectId: cwd,
|
||||||
|
rootPath: cwd,
|
||||||
|
kind: "git",
|
||||||
|
displayName: "worktree-project",
|
||||||
|
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: "worktree",
|
||||||
|
branch: "feature/keep",
|
||||||
|
displayName: "feature/keep",
|
||||||
|
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 recreateCalls: string[] = [];
|
||||||
|
session.recreateOwningWorktreeForRestore = async (
|
||||||
|
workspace: PersistedWorkspaceRecord,
|
||||||
|
): Promise<void> => {
|
||||||
|
recreateCalls.push(workspace.workspaceId);
|
||||||
|
};
|
||||||
|
|
||||||
|
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-recreate-worktree",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(recreateCalls).toEqual([workspaceId]);
|
||||||
|
expect(workspaces.get(workspaceId)?.archivedAt).toBeNull();
|
||||||
|
expect(workspaces.get(workspaceId)?.workspaceId).toBe(workspaceId);
|
||||||
|
expect(unarchivedWorkspaceIds).toContainEqual([workspaceId]);
|
||||||
|
expect(findByType(emitted, "rpc_error")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("refresh_agent_request leaves the worktree archived and surfaces a typed error when recreation fails", 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-deleted-worktree-fail");
|
||||||
|
session.filesystem.isDirectory = async () => false;
|
||||||
|
const workspaceId = "ws-deleted-worktree-fail";
|
||||||
|
const agentId = "agent-deleted-worktree-fail";
|
||||||
|
projects.set(
|
||||||
|
cwd,
|
||||||
|
createPersistedProjectRecord({
|
||||||
|
projectId: cwd,
|
||||||
|
rootPath: cwd,
|
||||||
|
kind: "git",
|
||||||
|
displayName: "worktree-project",
|
||||||
|
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: "worktree",
|
||||||
|
branch: "feature/gone",
|
||||||
|
displayName: "feature/gone",
|
||||||
|
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 () => {};
|
||||||
|
|
||||||
|
session.recreateOwningWorktreeForRestore = async (): Promise<void> => {
|
||||||
|
throw toWorktreeRequestError(new UnknownBranchError({ branchName: "feature/gone", cwd }));
|
||||||
|
};
|
||||||
|
|
||||||
|
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-recreate-fail",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(workspaces.get(workspaceId)?.archivedAt).toBe("2026-03-10T00:00:00.000Z");
|
||||||
|
const rpcError = findByType(emitted, "rpc_error");
|
||||||
|
expect(rpcError).toBeDefined();
|
||||||
|
expect((rpcError?.payload as { code?: string } | undefined)?.code).toBe("unknown_branch");
|
||||||
|
});
|
||||||
|
|
||||||
|
function createRecreateWorktreeRepo(): { tempDir: string; repoDir: string } {
|
||||||
|
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-recreate-worktree-")));
|
||||||
|
const repoDir = path.join(tempDir, "repo");
|
||||||
|
execFileSync("git", ["init", "-b", "main", repoDir], { stdio: "pipe" });
|
||||||
|
execFileSync("git", ["config", "user.email", "test@getpaseo.local"], {
|
||||||
|
cwd: repoDir,
|
||||||
|
stdio: "pipe",
|
||||||
|
});
|
||||||
|
execFileSync("git", ["config", "user.name", "Paseo Test"], { cwd: repoDir, stdio: "pipe" });
|
||||||
|
execFileSync("git", ["config", "commit.gpgsign", "false"], { cwd: repoDir, stdio: "pipe" });
|
||||||
|
writeFileSync(path.join(repoDir, "README.md"), "main\n");
|
||||||
|
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
|
||||||
|
execFileSync("git", ["commit", "-m", "initial"], { cwd: repoDir, stdio: "pipe" });
|
||||||
|
return { tempDir, repoDir };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("refresh_agent_request recreates a real deleted worktree against a temp git repo and unarchives the same workspace", async () => {
|
||||||
|
const { tempDir, repoDir } = createRecreateWorktreeRepo();
|
||||||
|
const branch = "feature/keep";
|
||||||
|
execFileSync("git", ["branch", branch], { cwd: repoDir, stdio: "pipe" });
|
||||||
|
|
||||||
|
const worktreesRoot = path.join(tempDir, "worktrees");
|
||||||
|
const paseoHome = path.join(tempDir, "paseo-home");
|
||||||
|
const created = await createWorktree({
|
||||||
|
cwd: repoDir,
|
||||||
|
worktreeSlug: "keep",
|
||||||
|
source: { kind: "checkout-branch", branchName: branch },
|
||||||
|
runSetup: false,
|
||||||
|
paseoHome,
|
||||||
|
worktreesRoot,
|
||||||
|
});
|
||||||
|
const worktreePath = realpathSync(created.worktreePath);
|
||||||
|
// Simulate archive: drop the worktree dir but keep the branch.
|
||||||
|
rmSync(worktreePath, { recursive: true, force: true });
|
||||||
|
execFileSync("git", ["worktree", "prune"], { cwd: repoDir, stdio: "pipe" });
|
||||||
|
expect(existsSync(worktreePath)).toBe(false);
|
||||||
|
|
||||||
|
const emitted: SessionOutboundMessage[] = [];
|
||||||
|
const session = createSessionForWorkspaceTests({
|
||||||
|
paseoHome,
|
||||||
|
worktreesRoot,
|
||||||
|
onMessage: (message) => {
|
||||||
|
if (isSessionOutboundMessage(message)) emitted.push(message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// Real directory probe so the missing worktree reads as gone and the repo root as present.
|
||||||
|
session.filesystem.isDirectory = async (target: string) =>
|
||||||
|
existsSync(target) && statSync(target).isDirectory();
|
||||||
|
|
||||||
|
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
|
||||||
|
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
|
||||||
|
const workspaceId = "ws-real-recreate";
|
||||||
|
const agentId = "agent-real-recreate";
|
||||||
|
const projectId = repoDir;
|
||||||
|
projects.set(
|
||||||
|
projectId,
|
||||||
|
createPersistedProjectRecord({
|
||||||
|
projectId,
|
||||||
|
rootPath: repoDir,
|
||||||
|
kind: "git",
|
||||||
|
displayName: "worktree-project",
|
||||||
|
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: worktreePath,
|
||||||
|
kind: "worktree",
|
||||||
|
branch,
|
||||||
|
displayName: branch,
|
||||||
|
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: worktreePath, updatedAt: "2026-03-10T00:00:00.000Z" }),
|
||||||
|
workspaceId,
|
||||||
|
archivedAt: "2026-03-10T00:00:00.000Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
session.projectRegistry.get = async (id: string) => projects.get(id) ?? null;
|
||||||
|
session.projectRegistry.upsert = async (
|
||||||
|
record: ReturnType<typeof createPersistedProjectRecord>,
|
||||||
|
) => {
|
||||||
|
projects.set(record.projectId, record);
|
||||||
|
};
|
||||||
|
session.workspaceRegistry.get = async (id: string) => workspaces.get(id) ?? 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: worktreePath,
|
||||||
|
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-real-recreate",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(findByType(emitted, "rpc_error")).toBeUndefined();
|
||||||
|
expect(existsSync(worktreePath)).toBe(true);
|
||||||
|
const headBranch = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
|
||||||
|
cwd: worktreePath,
|
||||||
|
stdio: "pipe",
|
||||||
|
})
|
||||||
|
.toString()
|
||||||
|
.trim();
|
||||||
|
expect(headBranch).toBe(branch);
|
||||||
|
expect(workspaces.get(workspaceId)?.workspaceId).toBe(workspaceId);
|
||||||
|
expect(workspaces.get(workspaceId)?.cwd).toBe(worktreePath);
|
||||||
|
expect(workspaces.get(workspaceId)?.archivedAt).toBeNull();
|
||||||
|
|
||||||
|
rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("recreateOwningWorktreeForRestore throws a typed WorktreeRequestError and leaves the workspace archived when the project root is missing", async () => {
|
||||||
|
const { tempDir, repoDir } = createRecreateWorktreeRepo();
|
||||||
|
const branch = "feature/keep";
|
||||||
|
execFileSync("git", ["branch", branch], { cwd: repoDir, stdio: "pipe" });
|
||||||
|
|
||||||
|
const worktreesRoot = path.join(tempDir, "worktrees");
|
||||||
|
const paseoHome = path.join(tempDir, "paseo-home");
|
||||||
|
const created = await createWorktree({
|
||||||
|
cwd: repoDir,
|
||||||
|
worktreeSlug: "keep",
|
||||||
|
source: { kind: "checkout-branch", branchName: branch },
|
||||||
|
runSetup: false,
|
||||||
|
paseoHome,
|
||||||
|
worktreesRoot,
|
||||||
|
});
|
||||||
|
const worktreePath = realpathSync(created.worktreePath);
|
||||||
|
|
||||||
|
const session = createSessionForWorkspaceTests({ paseoHome, worktreesRoot });
|
||||||
|
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
|
||||||
|
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
|
||||||
|
const workspaceId = "ws-missing-root";
|
||||||
|
const projectId = repoDir;
|
||||||
|
const missingRoot = path.join(tempDir, "does-not-exist");
|
||||||
|
projects.set(
|
||||||
|
projectId,
|
||||||
|
createPersistedProjectRecord({
|
||||||
|
projectId,
|
||||||
|
rootPath: missingRoot,
|
||||||
|
kind: "git",
|
||||||
|
displayName: "worktree-project",
|
||||||
|
createdAt: "2026-03-01T12:00:00.000Z",
|
||||||
|
updatedAt: "2026-03-10T00:00:00.000Z",
|
||||||
|
archivedAt: "2026-03-10T00:00:00.000Z",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const workspaceRecord = createPersistedWorkspaceRecord({
|
||||||
|
workspaceId,
|
||||||
|
projectId,
|
||||||
|
cwd: worktreePath,
|
||||||
|
kind: "worktree",
|
||||||
|
branch,
|
||||||
|
displayName: branch,
|
||||||
|
createdAt: "2026-03-01T12:00:00.000Z",
|
||||||
|
updatedAt: "2026-03-10T00:00:00.000Z",
|
||||||
|
archivedAt: "2026-03-10T00:00:00.000Z",
|
||||||
|
});
|
||||||
|
workspaces.set(workspaceId, workspaceRecord);
|
||||||
|
|
||||||
|
session.projectRegistry.get = async (id: string) => projects.get(id) ?? null;
|
||||||
|
session.workspaceRegistry.get = async (id: string) => workspaces.get(id) ?? null;
|
||||||
|
session.filesystem.isDirectory = async (target: string) =>
|
||||||
|
existsSync(target) && statSync(target).isDirectory();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
session.recreateOwningWorktreeForRestore(workspaceRecord, branch),
|
||||||
|
).rejects.toBeInstanceOf(WorktreeRequestError);
|
||||||
|
// Guard fires before createWorktree, so archivedAt is untouched.
|
||||||
|
expect(workspaces.get(workspaceId)?.archivedAt).toBe("2026-03-10T00:00:00.000Z");
|
||||||
|
|
||||||
|
rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
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();
|
||||||
|
|||||||
@@ -1157,6 +1157,8 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
checkoutRefresh: true,
|
checkoutRefresh: true,
|
||||||
// COMPAT(workspaceMultiplicity): added in v0.1.97, drop the gate when floor >= v0.1.97
|
// COMPAT(workspaceMultiplicity): added in v0.1.97, drop the gate when floor >= v0.1.97
|
||||||
workspaceMultiplicity: true,
|
workspaceMultiplicity: true,
|
||||||
|
// COMPAT(worktreeRestore): added in v0.1.98, drop the gate when floor >= v0.1.98
|
||||||
|
worktreeRestore: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user