fix(sessions): restore archived worktree sessions (#3325)
* fix(sessions): restore archived worktree sessions * fix(sessions): re-move worktree restore roots stranded by a failed move
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<string, "available" | "missing" | "unknown">()
|
||||
const sessionUpdateResultsById = new Map<string, Session | undefined>()
|
||||
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<string, unknown>) => {
|
||||
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<string, unknown>) => {
|
||||
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<string, { type: string }>(),
|
||||
}),
|
||||
},
|
||||
}))
|
||||
|
||||
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<T> = {
|
||||
getState: () => T
|
||||
setState: (patch: Partial<T> | ((state: T) => Partial<T>)) => void
|
||||
}
|
||||
|
||||
function createStore(
|
||||
permissions: Record<string, PermissionRequest[]>,
|
||||
state?: Partial<DirectoryStore>,
|
||||
): StoreApi<DirectoryStore> {
|
||||
return create<DirectoryStore>()((set) => ({
|
||||
): TestStoreApi<DirectoryStore> {
|
||||
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<DirectoryStore> | ((current: DirectoryStore) => Partial<DirectoryStore>)) {
|
||||
const nextPatch = typeof patch === "function" ? patch(currentState) : patch
|
||||
currentState = { ...currentState, ...nextPatch }
|
||||
}
|
||||
|
||||
return {
|
||||
getState: () => currentState,
|
||||
setState,
|
||||
}
|
||||
}
|
||||
|
||||
function createChildStores(entries: Array<[string, StoreApi<DirectoryStore>]>) {
|
||||
function createChildStores(entries: Array<[string, TestStoreApi<DirectoryStore>]>) {
|
||||
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,
|
||||
|
||||
@@ -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<void> {
|
||||
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<string | null> {
|
||||
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<MissingWorktreeRestore | null> {
|
||||
const ownedDirectory = resolveSessionOwnedDirectory(session)
|
||||
const projectWorktree = session.project?.worktree?.trim()
|
||||
if (!ownedDirectory || !projectWorktree) return null
|
||||
|
||||
let availability: Awaited<ReturnType<typeof opencodeClient.getDirectoryAvailability>>
|
||||
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<string, Session>()
|
||||
|
||||
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<boolean> {
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user