fix(sessions): keep missing-worktree relocation manual
Remove automatic moves on session activation, terminal failures, and archive restoration while preserving manual moves and worktree deletion. Replace directory listing probes with a stat-only endpoint using Node built-ins, including an isolated module-load regression test for packaged desktop. Validation: focused session, worktree, filesystem, localization, and bridge tests; workspace type-check and lint; web and VS Code builds. Desktop startup and behavior verified by the maintainer.
This commit is contained in:
@@ -1531,134 +1531,6 @@ 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 MissingWorktreeRelocation = { sourceDirectory: string; destinationDirectory: string }
|
||||
|
||||
const isFilesystemRoot = (directory: string): boolean => directory === "/" || /^[A-Za-z]:\/?$/.test(directory)
|
||||
|
||||
async function resolveMissingWorktreeRelocation(
|
||||
session: Session & { project?: { worktree?: string | null } | null },
|
||||
): Promise<MissingWorktreeRelocation | 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
|
||||
// OpenCode files a directory outside any Git repository under its global
|
||||
// project, whose "worktree" is the filesystem root. That is not a home for
|
||||
// a session; a managed chat whose directory vanished stays where it is.
|
||||
if (isFilesystemRoot(projectDirectory)) return null
|
||||
return { sourceDirectory: ownedDirectory, destinationDirectory: projectDirectory }
|
||||
}
|
||||
|
||||
type OwnedSubtreeEntry = { session: Session; ownedDirectory: string | null }
|
||||
|
||||
/**
|
||||
* The root's subtree as the global cache knows it, root first. Drawn from the
|
||||
* global cache rather than a live child store so archived descendants that
|
||||
* never materialized in a directory store are still included.
|
||||
*/
|
||||
function getGlobalSubtree(rootSession: Session): OwnedSubtreeEntry[] {
|
||||
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) }))
|
||||
}
|
||||
|
||||
function getRestoreSubtree(rootSession: Session, sourceDirectory: string): Array<{ session: Session; sourceDirectory: string }> {
|
||||
return getGlobalSubtree(rootSession)
|
||||
// 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)
|
||||
}
|
||||
|
||||
export type MissingDirectoryRelocation =
|
||||
/** The session's directory is gone; its subtree now lives in the project directory. */
|
||||
| { status: "moved"; sourceDirectory: string; destinationDirectory: string; movedSessionIds: string[] }
|
||||
/** The directory is available, its state is unknown, or the session has no project to move to. */
|
||||
| { status: "unchanged" }
|
||||
/** The runtime changed while the relocation was in flight; nothing local was published. */
|
||||
| { status: "stale" }
|
||||
/** A control-plane move failed; `movedSessionIds` already live in the destination. */
|
||||
| { status: "failed"; movedSessionIds: string[]; error: unknown }
|
||||
|
||||
/**
|
||||
* Move an active session whose worktree no longer exists into its project's
|
||||
* primary directory.
|
||||
*
|
||||
* Same gate as the archived-session restore fallback: only a server-confirmed
|
||||
* `missing` directory qualifies, the destination is the OpenCode project the
|
||||
* session belongs to, and `available`, `unknown`, probe failures, and sessions
|
||||
* without a project leave everything untouched. Every session of the root's
|
||||
* subtree still stranded in that directory moves with it, root first, so the
|
||||
* session the user is looking at is usable even if a descendant move fails.
|
||||
* Moves carry no changes (`moveChanges: false`): the directory is gone, so
|
||||
* there is nothing to carry.
|
||||
*/
|
||||
export async function relocateSessionFromMissingDirectory(
|
||||
sessionId: string,
|
||||
expectedRuntimeKey = getRuntimeKey(),
|
||||
): Promise<MissingDirectoryRelocation> {
|
||||
if (isStaleRuntime(expectedRuntimeKey)) return { status: "stale" }
|
||||
const rootSession = getGlobalSessionSnapshot(sessionId)
|
||||
if (!rootSession) return { status: "unchanged" }
|
||||
|
||||
const relocation = await resolveMissingWorktreeRelocation(rootSession)
|
||||
if (isStaleRuntime(expectedRuntimeKey)) return { status: "stale" }
|
||||
if (!relocation) return { status: "unchanged" }
|
||||
|
||||
const stranded = getGlobalSubtree(rootSession)
|
||||
.filter((entry) => entry.ownedDirectory === relocation.sourceDirectory)
|
||||
.map((entry) => entry.session)
|
||||
const movedSessionIds: string[] = []
|
||||
for (const session of stranded) {
|
||||
try {
|
||||
await moveSessionToDirectory(session, relocation.sourceDirectory, relocation.destinationDirectory, false, expectedRuntimeKey)
|
||||
} catch (error) {
|
||||
console.error("[session-actions] relocateSessionFromMissingDirectory failed", error)
|
||||
return { status: "failed", movedSessionIds, error }
|
||||
}
|
||||
if (isStaleRuntime(expectedRuntimeKey)) return { status: "stale" }
|
||||
movedSessionIds.push(session.id)
|
||||
}
|
||||
return { status: "moved", ...relocation, movedSessionIds }
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore one archived session back to the active list.
|
||||
*
|
||||
@@ -1671,34 +1543,8 @@ export async function relocateSessionFromMissingDirectory(
|
||||
*/
|
||||
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 resolveMissingWorktreeRelocation(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