diff --git a/packages/ui/src/sync/DOCUMENTATION.md b/packages/ui/src/sync/DOCUMENTATION.md index ed862913..b2465c7d 100644 --- a/packages/ui/src/sync/DOCUMENTATION.md +++ b/packages/ui/src/sync/DOCUMENTATION.md @@ -339,6 +339,26 @@ feedback stays truthful. Callers whose confirmation can span a runtime switch may pass an `expectedRuntimeKey` captured earlier; ordinary callers are guarded by default. +When the session being restored belongs to a worktree that no longer exists, +writing `time.archived = 0` alone would leave it grouped under a directory the +sidebar can never surface. Restore therefore probes the session's owned +directory with `getDirectoryAvailability` and, only on an exact `missing` +result, relocates it: it resolves the owning OpenCode project's primary +directory by the session's server `projectID` (from `project.list()`, never a +local project ID or the active project), then unarchives and moves the whole +subtree still stranded in the missing directory to that project directory +through `moveSessionToDirectory(..., false)`. `available`, `unknown`, an +availability probe failure, a missing project record, and non-worktree sessions +keep the plain restore path. The subtree is drawn from the global cache so +archived descendants that never materialized in a live child store are still +relocated, and a node is kept while it is archived **or** still owns the +missing directory, so a retry after a partial restore (root already unarchived +but not yet moved) completes the move instead of reporting a false success. +`moveSessionToDirectory` accepts the captured `expectedRuntimeKey` and skips all +local store/routing publication when the runtime changed during the +control-plane request, so the server move can complete without seeding the new +runtime with stale directory state. + Deletion needs this guard more than archiving does. Session IDs are not unique across runtimes, and a committed deletion does more than hide a row: it evicts the session from every live store, removes it from the global cache, clears the diff --git a/packages/ui/src/sync/session-actions.test.ts b/packages/ui/src/sync/session-actions.test.ts index 01e703a1..ce5215f2 100644 --- a/packages/ui/src/sync/session-actions.test.ts +++ b/packages/ui/src/sync/session-actions.test.ts @@ -20,6 +20,7 @@ let afterUnrevertCall: ((sessionId: string) => void) | null = null let sessionDeleteError: unknown | null = null let beforeSessionUpdateResolve: ((sessionId: string) => void) | null = null let beforeSessionDeleteResolve: ((sessionId: string) => void) | null = null +let beforeControlPlaneMoveResolve: ((sessionId: string) => void) | null = null const globalUpsertedSessions: unknown[] = [] const globalUpsertedSessionBatches: Session[][] = [] const globalRemovedSessionIds: string[] = [] @@ -33,6 +34,12 @@ let archiveBatchResponse: { status: number; body: unknown } = { } const deletedCleanupIdentities: Array<{ runtimeKey: string; directory: string; sessionId: string }> = [] const movedSessionDirectories: Array<{ sessionID: string; directory: string }> = [] +const globalArchivedSessions: Session[] = [] +const openCodeProjects: Project[] = [] +const directoryAvailability = new Map() +const sessionUpdateResultsById = new Map() +let runtimeKey = "default-runtime" +const AMBIGUOUS_TRANSPORT_FAILURE = Symbol("ambiguous-transport-failure") const mockScopedClient = { permission: { @@ -68,10 +75,17 @@ const mockSdk = { controlPlane: { moveSession: mock((params: Record) => { replyCalls.push({ method: "controlPlane.moveSession", params }) + beforeControlPlaneMoveResolve?.(String(params.sessionID)) return Promise.resolve({}) }), }, }, + project: { + list: mock(() => { + replyCalls.push({ method: "project.list", params: {} }) + return Promise.resolve({ data: openCodeProjects }) + }), + }, session: { messages: mock((params: Record) => { replyCalls.push({ method: "session.messages", params }) @@ -146,6 +160,7 @@ mock.module("@/lib/opencode/client", () => ({ return mockScopedClient }, getDirectory: () => "/test/project", + getDirectoryAvailability: mock(async (directory: string) => directoryAvailability.get(directory) ?? "available"), getFilesystemHome: mock(async () => "/home/test"), getSdkClient: () => mockSdk, getSessionMessages: mock((sessionId: string, _limit?: number, directory?: string | null) => { @@ -176,7 +191,7 @@ mock.module("@/lib/opencode/client", () => ({ // Lets a test mutate global runtime state while the SDK call is in flight, // so the action observes the switch only after awaiting the response. beforeSessionUpdateResolve?.(sessionId) - return Promise.resolve(sessionUpdateResult.data) + return Promise.resolve(sessionUpdateResultsById.get(sessionId) ?? sessionUpdateResult.data) }), deleteSession: mock((sessionId: string, directory?: string | null) => { replyCalls.push({ method: "session.delete", params: { sessionID: sessionId, directory } }) @@ -238,6 +253,22 @@ mock.module("./input-store", () => ({ }, })) +mock.module("@/stores/useInlineCommentDraftStore", () => ({ + useInlineCommentDraftStore: { + getState: () => ({ + getDrafts: () => [], + clearDrafts: () => {}, + restoreDrafts: () => {}, + addDraft: () => {}, + }), + }, +})) + +mock.module("@/lib/messages/contextParts", () => ({ + draftFromContextPayload: () => null, + readContextPart: () => null, +})) + mock.module("@/stores/useGlobalSessionsStore", () => ({ resolveGlobalSessionDirectory: (session: SessionWithDirectory) => session.directory ?? session.project?.worktree ?? null, mergeSessionDirectoryMetadata: (incoming: Session, existing?: SessionWithDirectory | null): SessionWithDirectory => { @@ -253,7 +284,7 @@ mock.module("@/stores/useGlobalSessionsStore", () => ({ useGlobalSessionsStore: { getState: () => ({ activeSessions: globalActiveSessions, - archivedSessions: [], + archivedSessions: globalArchivedSessions, upsertSession: (session: unknown) => { globalUpsertedSessions.push(session) }, @@ -280,6 +311,81 @@ mock.module("@/lib/runtime-fetch", () => ({ }, })) +mock.module("./global-session-status", () => ({ + useGlobalSessionStatusStore: { + getState: () => ({ + statusById: new Map(), + }), + }, +})) + +mock.module("./session-message-loader", () => ({ + getImperativeSessionMessageLoader: () => ({ + invalidateSession: () => {}, + ensure: async () => {}, + refreshTail: async () => {}, + getSnapshot: () => ({ status: "ready" as const }), + }), +})) + +mock.module("../lib/runtime-switch", () => ({ + getRuntimeKey: () => runtimeKey, + switchRuntimeEndpoint: ({ runtimeKey: nextRuntimeKey }: { runtimeKey: string }) => { + runtimeKey = nextRuntimeKey + }, + subscribeRuntimeEndpointWillChange: () => () => {}, + subscribeRuntimeEndpointChanged: () => () => {}, +})) + +mock.module("@/lib/relay/transport-error", () => ({ + markAmbiguousTransportFailure: (error: Error) => Object.assign(error, { [AMBIGUOUS_TRANSPORT_FAILURE]: true }), + isAmbiguousTransportFailure: (error: unknown) => Boolean( + error + && typeof error === "object" + && (error as { [AMBIGUOUS_TRANSPORT_FAILURE]?: boolean })[AMBIGUOUS_TRANSPORT_FAILURE], + ), +})) + +mock.module("./send-failure-classification", () => ({ + getErrorStatus: (error: unknown) => { + if (!error || typeof error !== "object") return null + const direct = (error as { status?: unknown }).status + if (typeof direct === "number") return direct + const response = (error as { response?: { status?: unknown } }).response + return typeof response?.status === "number" ? response.status : null + }, + isAmbiguousSendFailure: (error: unknown) => { + if (error && typeof error === "object" && (error as { [AMBIGUOUS_TRANSPORT_FAILURE]?: boolean })[AMBIGUOUS_TRANSPORT_FAILURE]) { + return true + } + + const status = error && typeof error === "object" + ? ((error as { status?: unknown }).status ?? (error as { response?: { status?: unknown } }).response?.status) + : undefined + if (status === 503 || status === 504 || status === 408) return true + if (error instanceof TypeError) return true + if (error instanceof DOMException && (error.name === "AbortError" || error.name === "TimeoutError")) return true + + const message = error instanceof Error + ? error.message.toLowerCase() + : typeof error === "string" + ? error.toLowerCase() + : "" + return message.includes("timeout") + || message.includes("timed out") + || message.includes("failed to fetch") + || message.includes("networkerror") + || message.includes("network error") + || message.includes("gateway timeout") + || message.includes("econnreset") + || message.includes("socket hang up") + }, +})) + +mock.module("@/lib/chatDirectories", () => ({ + deleteChatDirectory: async () => {}, +})) + mock.module("./session-deletion-cleanup", () => ({ cleanupPersistedSessionState: (identity: { runtimeKey: string; directory: string; sessionId: string }) => { deletedCleanupIdentities.push(identity) @@ -293,10 +399,9 @@ mock.module("./sync-refs", () => ({ }, })) -import { create, type StoreApi } from "zustand" import { INITIAL_STATE } from "./types" import type { DirectoryStore } from "./child-store" -import type { Message, OpencodeClient, Part, Session } from "@opencode-ai/sdk/v2/client" +import type { Message, OpencodeClient, Part, Project, Session } from "@opencode-ai/sdk/v2/client" type OptimisticAddCall = { sessionID: string; directory?: string | null; message: Message; parts: Part[] } type OptimisticRemoveCall = { sessionID: string; directory?: string | null; messageID: string } @@ -305,20 +410,37 @@ type SessionWithDirectory = Session & { project?: { worktree?: string | null } } +type TestStoreApi = { + getState: () => T + setState: (patch: Partial | ((state: T) => Partial)) => void +} + function createStore( permissions: Record, state?: Partial, -): StoreApi { - return create()((set) => ({ +): TestStoreApi { + let currentState: DirectoryStore = { ...INITIAL_STATE, ...state, permission: permissions, - patch: (partial) => set(partial), - replace: (next) => set(next), - })) + patch: (partial) => setState(partial), + replace: (next) => { + currentState = { ...currentState, ...next } + }, + } + + function setState(patch: Partial | ((current: DirectoryStore) => Partial)) { + const nextPatch = typeof patch === "function" ? patch(currentState) : patch + currentState = { ...currentState, ...nextPatch } + } + + return { + getState: () => currentState, + setState, + } } -function createChildStores(entries: Array<[string, StoreApi]>) { +function createChildStores(entries: Array<[string, TestStoreApi]>) { return { children: new Map(entries), ensureChild: (dir: string) => { @@ -419,12 +541,14 @@ describe("confirmed session removal", () => { deletedCleanupIdentities.length = 0 sessionDeleteError = null sessionUpdateResult = {} + runtimeKey = "default-runtime" beforeSessionUpdateResolve = null beforeSessionDeleteResolve = null globalUpsertedSessionBatches.length = 0 globalActiveSessions = [] archiveBatchRequests.length = 0 archiveBatchResponse = { status: 404, body: { error: 'not found' } } + beforeControlPlaneMoveResolve = null }) test("does not remove live or persisted state when delete fails", async () => { @@ -824,9 +948,17 @@ describe("session restore (unarchive)", () => { beforeEach(() => { replyCalls.length = 0 registeredSessionDirectories.length = 0 + movedSessionDirectories.length = 0 globalUpsertedSessions.length = 0 + globalActiveSessions.length = 0 + globalArchivedSessions.length = 0 + openCodeProjects.length = 0 + directoryAvailability.clear() + sessionUpdateResultsById.clear() + runtimeKey = "default-runtime" sessionUpdateResult = {} beforeSessionUpdateResolve = null + beforeControlPlaneMoveResolve = null }) test("does not restore locally until the server returns the restored session", async () => { @@ -878,6 +1010,311 @@ describe("session restore (unarchive)", () => { expect(registeredSessionDirectories).toEqual([]) }) + test("keeps an existing worktree restore in place without a control-plane move", async () => { + const worktreeDirectory = "/projects/main/.worktrees/feature-a" + globalArchivedSessions.push({ + id: "session-worktree", + projectID: "project-main", + directory: worktreeDirectory, + project: { worktree: worktreeDirectory }, + time: { created: 1, archived: 2 }, + } as SessionWithDirectory) + directoryAvailability.set(worktreeDirectory, "available") + sessionUpdateResultsById.set("session-worktree", { + id: "session-worktree", + projectID: "project-main", + directory: worktreeDirectory, + project: { worktree: worktreeDirectory }, + time: { created: 1, archived: 0 }, + } as SessionWithDirectory) + + const { unarchiveSession, setActionRefs } = await import("./session-actions") + setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([[worktreeDirectory, createStore({})]]), () => worktreeDirectory) + + expect(await unarchiveSession("session-worktree")).toBe(true) + expect(replyCalls.filter((call) => call.method === "controlPlane.moveSession")).toEqual([]) + expect(registeredSessionDirectories).toEqual([{ sessionID: "session-worktree", directory: worktreeDirectory }]) + expect((globalUpsertedSessions[0] as SessionWithDirectory).directory).toBe(worktreeDirectory) + }) + + test("moves a restored missing-worktree subtree to its matching project directory without changing descendants or cached transcript state", async () => { + const missingWorktreeDirectory = "/projects/main/.worktrees/deleted-branch" + const destinationDirectory = "/projects/main" + const rootMessage = { + id: "message-root", + sessionID: "session-root", + role: "user", + time: { created: 10 }, + } as Message + const rootPart = { id: "part-root", messageID: rootMessage.id, type: "text", text: "root" } as Part + const childMessage = { + id: "message-child", + sessionID: "session-child", + role: "assistant", + time: { created: 11 }, + } as Message + const childPart = { id: "part-child", messageID: childMessage.id, type: "text", text: "child" } as Part + const rootSession = { + id: "session-root", + projectID: "project-main", + directory: missingWorktreeDirectory, + project: { worktree: destinationDirectory }, + time: { created: 1, archived: 2 }, + } as SessionWithDirectory + const childSession = { + id: "session-child", + parentID: "session-root", + projectID: "project-main", + directory: missingWorktreeDirectory, + project: { worktree: destinationDirectory }, + time: { created: 2, archived: 3 }, + } as SessionWithDirectory + globalArchivedSessions.push(rootSession, childSession) + openCodeProjects.push({ id: "project-main", worktree: destinationDirectory } as Project) + directoryAvailability.set(missingWorktreeDirectory, "missing") + sessionUpdateResultsById.set("session-root", { + ...rootSession, + time: { created: 1, updated: 1, archived: 0 }, + }) + sessionUpdateResultsById.set("session-child", { + ...childSession, + time: { created: 2, updated: 2, archived: 0 }, + }) + + const source = createStore({}, { + session: [rootSession, childSession], + sessionTotal: 2, + message: { + "session-root": [rootMessage], + "session-child": [childMessage], + }, + part: { + [rootMessage.id]: [rootPart], + [childMessage.id]: [childPart], + }, + }) + const destination = createStore({}) + const { unarchiveSession, setActionRefs } = await import("./session-actions") + setActionRefs( + mockSdk as unknown as OpencodeClient, + createChildStores([[missingWorktreeDirectory, source], [destinationDirectory, destination]]), + () => missingWorktreeDirectory, + ) + + expect(await unarchiveSession("session-root")).toBe(true) + expect(replyCalls.filter((call) => call.method === "controlPlane.moveSession")).toEqual([ + { + method: "controlPlane.moveSession", + params: { + sessionID: "session-root", + destination: { directory: destinationDirectory }, + moveChanges: false, + }, + }, + { + method: "controlPlane.moveSession", + params: { + sessionID: "session-child", + destination: { directory: destinationDirectory }, + moveChanges: false, + }, + }, + ]) + expect(source.getState().session).toEqual([]) + expect(destination.getState().session.map((session) => ({ + id: session.id, + parentID: (session as SessionWithDirectory).parentID ?? null, + directory: (session as SessionWithDirectory).directory ?? null, + }))).toEqual([ + { id: "session-root", parentID: null, directory: destinationDirectory }, + { id: "session-child", parentID: "session-root", directory: destinationDirectory }, + ]) + expect(destination.getState().message["session-root"]?.[0]?.id).toBe(rootMessage.id) + expect(destination.getState().message["session-child"]?.[0]?.id).toBe(childMessage.id) + expect(destination.getState().part[rootMessage.id]?.[0]?.id).toBe(rootPart.id) + expect(destination.getState().part[childMessage.id]?.[0]?.id).toBe(childPart.id) + expect(destination.getState().session.every((session) => !session.time?.archived)).toBe(true) + expect(registeredSessionDirectories).toEqual([ + { sessionID: "session-root", directory: destinationDirectory }, + { sessionID: "session-child", directory: destinationDirectory }, + ]) + expect(movedSessionDirectories).toEqual([ + { sessionID: "session-root", directory: destinationDirectory }, + { sessionID: "session-child", directory: destinationDirectory }, + ]) + expect(globalUpsertedSessions.map((session) => ({ + id: (session as SessionWithDirectory).id, + parentID: (session as SessionWithDirectory).parentID ?? null, + directory: (session as SessionWithDirectory).directory ?? null, + }))).toEqual([ + { id: "session-root", parentID: null, directory: destinationDirectory }, + { id: "session-child", parentID: "session-root", directory: destinationDirectory }, + ]) + }) + + test("restores missing-worktree descendants from the global cache when their directory store is unavailable", async () => { + const missingWorktreeDirectory = "/projects/main/.worktrees/deleted-branch" + const destinationDirectory = "/projects/main" + const rootSession = { + id: "session-root", + projectID: "proj_main", + directory: missingWorktreeDirectory, + project: { worktree: destinationDirectory }, + time: { created: 1, archived: 2 }, + } as SessionWithDirectory + const childSession = { + id: "session-child", + parentID: rootSession.id, + projectID: "proj_main", + directory: missingWorktreeDirectory, + project: { worktree: destinationDirectory }, + time: { created: 2, archived: 3 }, + } as SessionWithDirectory + globalArchivedSessions.push(rootSession, childSession) + openCodeProjects.push({ id: "proj_main", worktree: destinationDirectory } as Project) + directoryAvailability.set(missingWorktreeDirectory, "missing") + sessionUpdateResultsById.set("session-root", { ...rootSession, time: { created: 1, updated: 1, archived: 0 } }) + sessionUpdateResultsById.set("session-child", { ...childSession, time: { created: 2, updated: 2, archived: 0 } }) + + const destination = createStore({}) + const { unarchiveSession, setActionRefs } = await import("./session-actions") + setActionRefs( + mockSdk as unknown as OpencodeClient, + createChildStores([[destinationDirectory, destination]]), + () => missingWorktreeDirectory, + ) + + expect(await unarchiveSession(rootSession.id)).toBe(true) + expect(replyCalls.filter((call) => call.method === "controlPlane.moveSession").map((call) => call.params.sessionID)) + .toEqual([rootSession.id, childSession.id]) + expect(destination.getState().session.map((session) => session.id)).toEqual([rootSession.id, childSession.id]) + expect(destination.getState().session.every((session) => !session.time?.archived)).toBe(true) + }) + + test("does not publish a missing-worktree move after the runtime changes during the control-plane request", async () => { + const missingWorktreeDirectory = "/projects/main/.worktrees/deleted-branch" + const destinationDirectory = "/projects/main" + const session = { + id: "session-runtime-switch", + projectID: "proj_main", + directory: missingWorktreeDirectory, + project: { worktree: destinationDirectory }, + time: { created: 1, archived: 2 }, + } as SessionWithDirectory + globalArchivedSessions.push(session) + openCodeProjects.push({ id: "proj_main", worktree: destinationDirectory } as Project) + directoryAvailability.set(missingWorktreeDirectory, "missing") + sessionUpdateResultsById.set(session.id, { ...session, time: { created: 1, updated: 1, archived: 0 } }) + beforeControlPlaneMoveResolve = () => { + runtimeKey = "new-runtime" + } + + const destination = createStore({}) + const { unarchiveSession, setActionRefs } = await import("./session-actions") + setActionRefs( + mockSdk as unknown as OpencodeClient, + createChildStores([[destinationDirectory, destination]]), + () => missingWorktreeDirectory, + ) + + expect(await unarchiveSession(session.id)).toBe(false) + expect(destination.getState().session).toEqual([]) + expect(registeredSessionDirectories).toEqual([]) + expect(globalUpsertedSessions).toEqual([]) + }) + + test("re-moves a root left stranded in a missing worktree after a partial restore", async () => { + const missingWorktreeDirectory = "/projects/main/.worktrees/deleted-branch" + const destinationDirectory = "/projects/main" + // A previous restore attempt already unarchived the root (server echo made + // it active), then the control-plane move failed, leaving it stranded in the + // deleted worktree. The retry must still relocate it, not report a false + // success because the root is no longer archived. + const strandedRoot = { + id: "session-root", + projectID: "proj_main", + directory: missingWorktreeDirectory, + project: { worktree: destinationDirectory }, + time: { created: 1, archived: 0 }, + } as SessionWithDirectory + globalActiveSessions.push(strandedRoot) + openCodeProjects.push({ id: "proj_main", worktree: destinationDirectory } as Project) + directoryAvailability.set(missingWorktreeDirectory, "missing") + sessionUpdateResultsById.set("session-root", { ...strandedRoot, time: { created: 1, updated: 1, archived: 0 } }) + + const destination = createStore({}) + const { unarchiveSession, setActionRefs } = await import("./session-actions") + setActionRefs( + mockSdk as unknown as OpencodeClient, + createChildStores([[destinationDirectory, destination]]), + () => missingWorktreeDirectory, + ) + + expect(await unarchiveSession("session-root")).toBe(true) + expect(replyCalls.filter((call) => call.method === "controlPlane.moveSession")).toEqual([ + { + method: "controlPlane.moveSession", + params: { + sessionID: "session-root", + destination: { directory: destinationDirectory }, + moveChanges: false, + }, + }, + ]) + expect(destination.getState().session.map((session) => session.id)).toEqual(["session-root"]) + }) + + test("does not move a restored project session that is not a worktree", async () => { + const projectDirectory = "/projects/main" + globalArchivedSessions.push({ + id: "session-project", + projectID: "project-main", + directory: projectDirectory, + time: { created: 1, archived: 2 }, + } as SessionWithDirectory) + directoryAvailability.set(projectDirectory, "missing") + sessionUpdateResultsById.set("session-project", { + id: "session-project", + projectID: "project-main", + directory: projectDirectory, + time: { created: 1, archived: 0 }, + } as SessionWithDirectory) + + const { unarchiveSession, setActionRefs } = await import("./session-actions") + setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([[projectDirectory, createStore({})]]), () => projectDirectory) + + expect(await unarchiveSession("session-project")).toBe(true) + expect(replyCalls.filter((call) => call.method === "controlPlane.moveSession")).toEqual([]) + expect(registeredSessionDirectories).toEqual([{ sessionID: "session-project", directory: projectDirectory }]) + }) + + test("does not fall back to the parent project when worktree availability is unknown", async () => { + const worktreeDirectory = "/projects/main/.worktrees/offline-branch" + globalArchivedSessions.push({ + id: "session-offline", + projectID: "project-main", + directory: worktreeDirectory, + project: { worktree: "/projects/main" }, + time: { created: 1, archived: 2 }, + } as SessionWithDirectory) + directoryAvailability.set(worktreeDirectory, "unknown") + sessionUpdateResultsById.set("session-offline", { + id: "session-offline", + projectID: "project-main", + directory: worktreeDirectory, + project: { worktree: "/projects/main" }, + time: { created: 1, archived: 0 }, + } as SessionWithDirectory) + + const { unarchiveSession, setActionRefs } = await import("./session-actions") + setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([[worktreeDirectory, createStore({})]]), () => worktreeDirectory) + + expect(await unarchiveSession("session-offline")).toBe(true) + expect(replyCalls.filter((call) => call.method === "controlPlane.moveSession")).toEqual([]) + expect(registeredSessionDirectories).toEqual([{ sessionID: "session-offline", directory: worktreeDirectory }]) + expect((globalUpsertedSessions[0] as SessionWithDirectory).directory).toBe(worktreeDirectory) + }) + test("rejects a restore response that arrives after a runtime switch", async () => { sessionUpdateResult = { data: { id: "session-a", directory: "/test/project", time: { created: 1, archived: 0 } } as Session, diff --git a/packages/ui/src/sync/session-actions.ts b/packages/ui/src/sync/session-actions.ts index 85ddbd37..768ba6a5 100644 --- a/packages/ui/src/sync/session-actions.ts +++ b/packages/ui/src/sync/session-actions.ts @@ -303,8 +303,11 @@ function reconcileSessionMove( const destinationStore = stores?.ensureChild(destinationDirectory, { bootstrap: false }) const sourceState = sourceStore?.getState() const destinationState = destinationStore?.getState() - const liveSession = sourceState?.session.find((candidate) => candidate.id === session.id) ?? session - const movedSession = { ...liveSession, directory: destinationDirectory } as Session + const liveSession = sourceState?.session.find((candidate) => candidate.id === session.id) + const movedSession = { + ...mergeSessionDirectoryMetadata(session, liveSession), + directory: destinationDirectory, + } as Session if (!destinationStore || !destinationState || sourceStore === destinationStore) { return movedSession @@ -370,6 +373,7 @@ export async function moveSessionToDirectory( sourceDirectory: string, destinationDirectory: string, moveChanges = true, + expectedRuntimeKey?: string, ): Promise { const result = await opencodeClient.getSdkClient().experimental.controlPlane.moveSession({ sessionID: session.id, @@ -378,6 +382,10 @@ export async function moveSessionToDirectory( }) assertSdkSuccess(result, "Move session") + // If the runtime changed during the control-plane request, the server move + // already happened, but we must not publish stale local state to the UI/stores. + if (isStaleRuntime(expectedRuntimeKey)) return + invalidateSessionLoads(session.id, [sourceDirectory, destinationDirectory]) const moved = reconcileSessionMove(session, sourceDirectory, destinationDirectory) @@ -1490,6 +1498,65 @@ function commitArchivedSessions(sessions: Session[], directory: string): void { */ const UNARCHIVED_TIMESTAMP = 0 +async function getProjectPrimaryDirectory(projectID?: string): Promise { + if (!projectID) return null + + try { + const result = await sdk().project.list() + const projects = assertSdkData(result, "project.list") + const projectDirectory = projects.find((candidate) => candidate.id === projectID)?.worktree?.trim() + return projectDirectory ? normalizePath(projectDirectory) ?? projectDirectory : null + } catch { + return null + } +} + +type MissingWorktreeRestore = { sourceDirectory: string; destinationDirectory: string } + +async function resolveMissingWorktreeRestore( + session: Session & { project?: { worktree?: string | null } | null }, +): Promise { + const ownedDirectory = resolveSessionOwnedDirectory(session) + const projectWorktree = session.project?.worktree?.trim() + if (!ownedDirectory || !projectWorktree) return null + + let availability: Awaited> + try { + availability = await opencodeClient.getDirectoryAvailability(ownedDirectory) + } catch { + return null + } + if (availability !== "missing") return null + + const projectDirectory = await getProjectPrimaryDirectory(session.projectID) + if (!projectDirectory || projectDirectory === ownedDirectory) return null + return { sourceDirectory: ownedDirectory, destinationDirectory: projectDirectory } +} + +function getRestoreSubtree(rootSession: Session, sourceDirectory: string): Array<{ session: Session; sourceDirectory: string }> { + const global = useGlobalSessionsStore.getState() + const sessionsById = new Map() + + for (const session of [...global.activeSessions, ...global.archivedSessions]) { + const current = sessionsById.get(session.id) + if (!current || Boolean(session.time?.archived)) sessionsById.set(session.id, session) + } + sessionsById.set(rootSession.id, rootSession) + + return [...computeSubtreeIds([...sessionsById.values()], rootSession.id)] + .map((id) => sessionsById.get(id)) + .filter((session): session is Session => Boolean(session)) + .map((session) => ({ session, ownedDirectory: resolveSessionOwnedDirectory(session) })) + // Keep a node while it is still archived or still stranded in the + // confirmed-missing worktree. The second clause matters on retry: a prior + // attempt may have already unarchived the root (server echo made it active) + // but failed to move it, so filtering on `archived` alone would drop the + // root and report a false success while it stays in the deleted worktree. + .filter((entry) => Boolean(entry.session.time?.archived) || entry.ownedDirectory === sourceDirectory) + .map((entry) => (entry.ownedDirectory ? { session: entry.session, sourceDirectory: entry.ownedDirectory } : null)) + .filter((entry): entry is { session: Session; sourceDirectory: string } => entry !== null) +} + /** * Restore one archived session back to the active list. * @@ -1502,8 +1569,34 @@ const UNARCHIVED_TIMESTAMP = 0 */ export async function unarchiveSession(sessionId: string, expectedRuntimeKey = getRuntimeKey()): Promise { if (isStaleRuntime(expectedRuntimeKey)) return false + const globalSession = getGlobalSessionSnapshot(sessionId) const sessionDirectory = getSessionDirectory(sessionId) try { + const restore = globalSession + ? await resolveMissingWorktreeRestore(globalSession) + : null + if (isStaleRuntime(expectedRuntimeKey)) return false + + if (globalSession && restore) { + for (const { session, sourceDirectory } of getRestoreSubtree(globalSession, restore.sourceDirectory)) { + const restored = await opencodeClient.updateSession( + session.id, + { time: { archived: UNARCHIVED_TIMESTAMP } }, + sourceDirectory, + ) + if (isStaleRuntime(expectedRuntimeKey)) return false + if (!restored) { + throw new Error("session.update failed: server did not return the restored session") + } + if (restored.time?.archived) { + throw new Error("session.update failed: server kept the session archived") + } + await moveSessionToDirectory(restored, sourceDirectory, restore.destinationDirectory, false, expectedRuntimeKey) + if (isStaleRuntime(expectedRuntimeKey)) return false + } + return true + } + const restored = await opencodeClient.updateSession(sessionId, { time: { archived: UNARCHIVED_TIMESTAMP } }, sessionDirectory) if (isStaleRuntime(expectedRuntimeKey)) return false if (!restored) {