diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index 1d4ab8dbc..75aed5abb 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -135,6 +135,7 @@ jobs: packages/ui/src/stores/session-generation-recovery.test.ts packages/ui/src/stores/session-pagination.test.ts packages/ui/src/stores/session-pending-state.test.ts + packages/ui/src/stores/session-tree.test.ts packages/ui/src/stores/workspace-load-readiness.test.ts packages/ui/src/types/session.test.ts packages/ui/src/stores/workspace-list-reconciliation-fence.test.ts @@ -155,6 +156,7 @@ jobs: packages/ui/src/stores/session-request-authority.test.ts packages/ui/src/stores/session-send-lifecycle.test.ts packages/ui/src/stores/session-status.test.ts + packages/ui/src/stores/worktree-ready.test.ts - name: Test server run: node --import tsx --test "packages/server/src/**/*.test.ts" diff --git a/packages/server/src/api-types.ts b/packages/server/src/api-types.ts index 9259e2997..db6dd6ee5 100644 --- a/packages/server/src/api-types.ts +++ b/packages/server/src/api-types.ts @@ -93,9 +93,15 @@ export interface WorktreeDescriptor { slug: string /** Absolute directory path on the server host. */ directory: string + /** Equivalent path in the OpenCode service namespace (notably WSL). */ + serviceDirectory?: string + /** Exact path registered in Git's worktree inventory. */ + registeredDirectory?: string kind: WorktreeKind /** Optional VCS branch name when available. */ branch?: string + /** Commit recorded by the Git worktree inventory. */ + head?: string } export interface WorktreeListResponse { @@ -110,6 +116,16 @@ export interface WorktreeCreateRequest { branch?: string } +export interface WorktreeSessionMoveRequest { + worktreeSlug: string +} + +export interface WorktreeSessionMoveResponse { + rootSessionId: string + sessionIds: string[] + worktreeSlug: string +} + export type GitChangeKind = "added" | "modified" | "deleted" | "renamed" | "copied" | "untracked" | "unmerged" export interface WorktreeGitStatusEntry { diff --git a/packages/server/src/server/routes/worktrees.test.ts b/packages/server/src/server/routes/worktrees.test.ts index 4ed3381dc..a8351986a 100644 --- a/packages/server/src/server/routes/worktrees.test.ts +++ b/packages/server/src/server/routes/worktrees.test.ts @@ -1,17 +1,105 @@ import assert from "node:assert/strict" import { execFileSync } from "node:child_process" -import { mkdtempSync, rmSync, writeFileSync } from "node:fs" +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import path from "node:path" import { describe, it } from "node:test" -import Fastify from "fastify" import type { OpenCodeClient, SessionInfo } from "@opencode-ai/client" +import Fastify from "fastify" import type { WorkspaceDescriptor } from "../../api-types" import type { WorkspaceManager } from "../../workspaces/manager" import { registerWorktreeRoutes } from "./worktrees" describe("worktree routes", () => { - it("fails a direct delete call closed when session evacuation fails", async () => { +it("reserves the physical worktree and rejects a HEAD change immediately before deletion", async () => { + const temp = mkdtempSync(path.join(tmpdir(), "codenomad-worktree-route-")) + const repo = path.join(temp, "repo") + const linked = path.join(temp, "feature-worktree") + const workspacePath = path.join(repo, "apps", "web") + const linkedWorkspacePath = path.join(linked, "apps", "web") + const app = Fastify({ logger: false }) + + try { + mkdirSync(repo, { recursive: true }) + execFileSync("git", ["init", "-b", "main", repo], { stdio: "ignore" }) + mkdirSync(workspacePath, { recursive: true }) + writeFileSync(path.join(workspacePath, "README.md"), "nested workspace\n") + execFileSync("git", ["-C", repo, "add", "."], { stdio: "ignore" }) + execFileSync("git", ["-C", repo, "-c", "user.name=CodeNomad", "-c", "user.email=test@example.com", "commit", "--allow-empty", "-m", "init"], { stdio: "ignore" }) + execFileSync("git", ["-C", repo, "worktree", "add", "-b", "feature", linked], { stdio: "ignore" }) + + const current: SessionInfo = { + id: "session", + projectID: "project", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1, updated: 1 }, + location: { directory: linkedWorkspacePath, workspaceID: "native-feature" }, + } + let lists = 0 + const client = { + location: { + get: async ({ location }: { location?: { directory?: string } }) => ({ + directory: location?.directory ?? workspacePath, + workspaceID: path.resolve(location?.directory ?? workspacePath) === path.resolve(linkedWorkspacePath) ? "native-feature" : undefined, + project: { id: "project", directory: workspacePath, canonical: workspacePath }, + }), + }, + session: { + list: async () => { + if (++lists === 3) { + execFileSync("git", ["-C", linked, "-c", "user.name=CodeNomad", "-c", "user.email=test@example.com", "commit", "--allow-empty", "-m", "replace head"], { stdio: "ignore" }) + } + return { data: [structuredClone(current)], cursor: {} } + }, + active: async () => ({}), + move: async ({ directory, workspaceID }: { directory: string; workspaceID?: string }) => { + current.location = { directory, workspaceID } + }, + get: async () => structuredClone(current), + }, + } as unknown as OpenCodeClient + let reserved = "" + let released = false + const manager = { + get: () => ({ + id: "workspace", + path: workspacePath, + status: "ready", + proxyPath: "/workspaces/workspace/instance", + binaryId: "opencode", + binaryLabel: "opencode", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }), + reserveWorktreeDeletion: async (directory: string) => { + reserved = directory + return () => { released = true } + }, + getSharedServiceClient: async () => client, + getServiceDirectory: () => workspacePath, + getServiceDirectoryForPath: async (_id: string, directory: string) => { + assert.notEqual(path.resolve(directory), path.resolve(linked), "OpenCode must receive the mirrored workspace path") + return directory + }, + } as unknown as WorkspaceManager + registerWorktreeRoutes(app, { workspaceManager: manager }) + + const response = await app.inject({ method: "DELETE", url: "/api/workspaces/workspace/worktrees/feature" }) + + assert.equal(response.statusCode, 409) + assert.equal(path.resolve(reserved), path.resolve(linked)) + assert.equal(released, true) + assert.equal(path.resolve(current.location.directory), path.resolve(workspacePath)) + const inventory = execFileSync("git", ["-C", repo, "worktree", "list", "--porcelain"], { encoding: "utf8" }) + assert.ok(inventory.replace(/\\/g, "/").includes(linked.replace(/\\/g, "/"))) + } finally { + await app.close() + rmSync(temp, { recursive: true, force: true }) + } +}) + +it("fails a direct delete call closed when session evacuation fails", async () => { const temp = mkdtempSync(path.join(tmpdir(), "codenomad-delete-worktree-")) const target = path.join(temp, "doomed") const app = Fastify({ logger: false }) @@ -25,8 +113,11 @@ describe("worktree routes", () => { const workspace = { id: "workspace", path: temp, status: "ready" } as WorkspaceDescriptor const nativeSession = { id: "unloaded", projectID: "project", location: { directory: target }, cost: 0, tokens: {}, time: { created: 1, updated: 1 } } as SessionInfo const client = { - project: { - list: async () => [{ id: "project", canonical: temp, sandboxes: [target], time: { created: 1, updated: 1 } }], + location: { + get: async ({ location }: { location?: { directory?: string } }) => ({ + directory: location?.directory ?? temp, + project: { id: "project", canonical: temp, directory: temp }, + }), }, session: { list: async () => ({ data: [nativeSession], cursor: {} }), @@ -34,6 +125,7 @@ describe("worktree routes", () => { move: async (input: { directory: string }) => { if (input.directory === temp) throw new Error("native move failed") }, + get: async () => nativeSession, }, } as unknown as OpenCodeClient const manager = { @@ -41,17 +133,18 @@ describe("worktree routes", () => { getSharedServiceClient: async () => client, getServiceDirectory: () => temp, getServiceDirectoryForPath: async (_id: string, directory: string) => directory, + reserveWorktreeDeletion: async () => () => undefined, } as unknown as WorkspaceManager registerWorktreeRoutes(app, { workspaceManager: manager }) const response = await app.inject({ method: "DELETE", url: "/api/workspaces/workspace/worktrees/doomed" }) - assert.equal(response.statusCode, 400) + assert.equal(response.statusCode, 502) assert.match(response.json().error, /native move failed/) assert.match(execFileSync("git", ["-C", temp, "worktree", "list", "--porcelain"], { encoding: "utf8" }), /doomed/) } finally { await app.close() rmSync(temp, { recursive: true, force: true }) } - }) +}) }) diff --git a/packages/server/src/server/routes/worktrees.ts b/packages/server/src/server/routes/worktrees.ts index a49615c6f..2937c7e82 100644 --- a/packages/server/src/server/routes/worktrees.ts +++ b/packages/server/src/server/routes/worktrees.ts @@ -8,10 +8,18 @@ import { createManagedWorktree, removeWorktree, } from "../../workspaces/git-worktrees" -import type { WorktreeListResponse } from "../../api-types" +import type { + WorktreeListResponse, + WorktreeSessionMoveRequest, + WorktreeSessionMoveResponse, +} from "../../api-types" import { ensureCodenomadGitExclude } from "../../workspaces/worktree-map" -import { createInstanceClient } from "../../workspaces/instance-client" -import { evacuateWorktreeSessions } from "../../workspaces/worktree-session-evacuation" +import { invalidateWorktreeDirectoryCache } from "../../workspaces/worktree-directory" +import { + moveProjectSessionFamily, + ProjectSessionError, + removeProjectWorktree, +} from "../../workspaces/project-session-families" interface RouteDeps { workspaceManager: WorkspaceManager @@ -22,6 +30,10 @@ const WorktreeCreateSchema = z.object({ branch: z.string().trim().min(1).optional(), }) +const WorktreeSessionMoveSchema = z.object({ + worktreeSlug: z.string().trim().min(1), +}) + export function registerWorktreeRoutes(app: FastifyInstance, deps: RouteDeps) { app.get<{ Params: { id: string } }>("/api/workspaces/:id/worktrees", async (request, reply) => { const workspace = deps.workspaceManager.get(request.params.id) @@ -31,7 +43,11 @@ export function registerWorktreeRoutes(app: FastifyInstance, deps: RouteDeps) { } const { repoRoot, isGitRepo } = await resolveRepoRoot(workspace.path, request.log) - const worktrees = await listWorktrees({ repoRoot, workspaceFolder: workspace.path, logger: request.log }) + const listed = await listWorktrees({ repoRoot, workspaceFolder: workspace.path, logger: request.log }) + const worktrees = await Promise.all(listed.map(async (worktree) => ({ + ...worktree, + serviceDirectory: await deps.workspaceManager.getServiceDirectoryForPath(workspace.id, worktree.directory), + }))) const response: WorktreeListResponse = { worktrees, isGitRepo } return response }) @@ -75,6 +91,7 @@ export function registerWorktreeRoutes(app: FastifyInstance, deps: RouteDeps) { slug, logger: request.log, }) + invalidateWorktreeDirectoryCache(workspace.id) reply.code(201) return created @@ -83,6 +100,55 @@ export function registerWorktreeRoutes(app: FastifyInstance, deps: RouteDeps) { } }) + app.post<{ + Params: { id: string; sessionId: string } + Body: WorktreeSessionMoveRequest + }>("/api/workspaces/:id/sessions/:sessionId/worktree", async (request, reply) => { + const workspace = deps.workspaceManager.get(request.params.id) + if (!workspace) { + reply.code(404) + return { error: "Workspace not found" } + } + + try { + const { worktreeSlug } = WorktreeSessionMoveSchema.parse(request.body ?? {}) + const { repoRoot, isGitRepo } = await resolveRepoRoot(workspace.path, request.log) + if (!isGitRepo) throw new ProjectSessionError("Workspace is not a Git repository", 409) + const worktrees = await strictWorktrees({ + repoRoot, + workspaceFolder: workspace.path, + logger: request.log, + failClosed: true, + }) + const target = worktrees.find((worktree) => worktree.slug === worktreeSlug) + if (!target) throw new ProjectSessionError("Worktree not found", 404) + const projectDirectory = deps.workspaceManager.getServiceDirectory(workspace.id) + const targetDirectory = await deps.workspaceManager.getServiceDirectoryForPath(workspace.id, target.directory) + if (!projectDirectory || !targetDirectory) throw new ProjectSessionError("Unable to resolve OpenCode worktree paths", 409) + const moved = await moveProjectSessionFamily({ + client: await deps.workspaceManager.getSharedServiceClient(), + projectDirectory, + sessionId: request.params.sessionId, + targetDirectory, + validateTarget: async () => { + const refreshed = await strictWorktrees({ + repoRoot, + workspaceFolder: workspace.path, + logger: request.log, + failClosed: true, + }) + return refreshed.some((worktree) => worktree.slug === worktreeSlug + && worktree.registeredDirectory === target.registeredDirectory + && worktree.head === target.head) + }, + }) + const response: WorktreeSessionMoveResponse = { ...moved, worktreeSlug } + return response + } catch (error) { + return handleError(error, reply) + } + }) + app.delete<{ Params: { id: string; slug: string }; Querystring: { force?: string } }>( "/api/workspaces/:id/worktrees/:slug", async (request, reply) => { @@ -107,28 +173,73 @@ export function registerWorktreeRoutes(app: FastifyInstance, deps: RouteDeps) { const force = (request.query?.force ?? "").toString().toLowerCase() === "true" try { - const worktrees = await listWorktrees({ repoRoot, workspaceFolder: workspace.path, logger: request.log }) + const worktrees = await strictWorktrees({ + repoRoot, + workspaceFolder: workspace.path, + logger: request.log, + failClosed: true, + }) const match = worktrees.find((wt) => wt.slug === slug) if (!match || match.kind === "root") { reply.code(404) return { error: "Worktree not found" } } - - const [client, targetDirectory] = await Promise.all([ - createInstanceClient(deps.workspaceManager, workspace.id), - deps.workspaceManager.getServiceDirectoryForPath(workspace.id, match.directory), - ]) - const projectDirectory = deps.workspaceManager.getServiceDirectory(workspace.id) - if (!client || !projectDirectory || !targetDirectory) { - throw new Error("Unable to inventory sessions before deleting worktree") + let releaseDeletion: () => void + try { + releaseDeletion = await deps.workspaceManager.reserveWorktreeDeletion(match.registeredDirectory ?? match.directory) + } catch (error) { + throw new ProjectSessionError(error instanceof Error ? error.message : "Unable to reserve worktree deletion", 409) + } + try { + const client = await deps.workspaceManager.getSharedServiceClient() + const projectDirectory = deps.workspaceManager.getServiceDirectory(workspace.id) + const targetHostDirectory = match.registeredDirectory ?? match.directory + const rootHostDirectory = worktrees.find((worktree) => worktree.kind === "root")!.directory + const [targetDirectory, rootDirectory] = await Promise.all([ + deps.workspaceManager.getServiceDirectoryForPath(workspace.id, match.directory), + deps.workspaceManager.getServiceDirectoryForPath(workspace.id, rootHostDirectory), + ]) + if (!projectDirectory || !targetDirectory || !rootDirectory) { + throw new ProjectSessionError("Unable to resolve OpenCode worktree paths", 409) + } + const isTargetRegistered = async () => { + const refreshed = await strictWorktrees({ + repoRoot, + workspaceFolder: workspace.path, + logger: request.log, + failClosed: true, + }) + return refreshed.some((worktree) => worktree.slug === slug + && worktree.kind === "worktree" + && worktree.registeredDirectory === match.registeredDirectory + && worktree.head === match.head) + } + await removeProjectWorktree({ + client, + projectDirectory, + targetDirectory, + rootDirectory, + remove: async () => { + if (!await isTargetRegistered()) { + throw new ProjectSessionError("Worktree changed before deletion", 409) + } + try { + await removeWorktree({ + workspaceFolder: workspace.path, + directory: targetHostDirectory, + force, + logger: request.log, + }) + } catch (error) { + throw new ProjectSessionError(error instanceof Error ? error.message : "Unable to remove worktree", 409) + } + }, + isTargetRegistered, + }) + invalidateWorktreeDirectoryCache(workspace.id) + } finally { + releaseDeletion() } - await evacuateWorktreeSessions({ - client, - projectDirectory, - targetDirectory, - rootDirectory: projectDirectory, - remove: () => removeWorktree({ workspaceFolder: workspace.path, directory: match.directory, force, logger: request.log }), - }) reply.code(204) } catch (error) { @@ -138,7 +249,13 @@ export function registerWorktreeRoutes(app: FastifyInstance, deps: RouteDeps) { ) } +function strictWorktrees(params: Parameters[0]) { + return listWorktrees(params).catch((error) => { + throw new ProjectSessionError(error instanceof Error ? error.message : "Unable to read Git worktree inventory", 502) + }) +} + function handleError(error: unknown, reply: FastifyReply) { - reply.code(400) + reply.code(error instanceof ProjectSessionError ? error.statusCode : 400) return { error: error instanceof Error ? error.message : "Unable to fulfill request" } } diff --git a/packages/server/src/workspaces/git-worktrees.ts b/packages/server/src/workspaces/git-worktrees.ts index 087009015..c2ad75241 100644 --- a/packages/server/src/workspaces/git-worktrees.ts +++ b/packages/server/src/workspaces/git-worktrees.ts @@ -49,7 +49,7 @@ export async function resolveRepoRoot(folder: string, logger?: LogLike): Promise logger?.debug?.({ folder, err: result.error }, "Folder is not a Git repository; using workspace folder as root") return { repoRoot: folder, isGitRepo: false } } - const repoRoot = result.stdout.trim() + const repoRoot = result.stdout.replace(/\r?\n$/, "") if (!repoRoot) { return { repoRoot: folder, isGitRepo: false } } @@ -61,27 +61,30 @@ export async function isGitAvailable(folder: string): Promise { return result.ok || !isGitUnavailableResult(result) } -function parseWorktreePorcelain(output: string): Array<{ worktree: string; branch?: string; head?: string; detached?: boolean }> { - const records: Array<{ worktree: string; branch?: string; head?: string; detached?: boolean }> = [] - const lines = output.split(/\r?\n/) - let current: { worktree?: string; branch?: string; head?: string; detached?: boolean } = {} +function parseWorktreePorcelain(output: string): Array<{ worktree: string; branch?: string; head?: string; detached?: boolean; prunable?: boolean }> { + const records: Array<{ worktree: string; branch?: string; head?: string; detached?: boolean; prunable?: boolean }> = [] + let current: { worktree?: string; branch?: string; head?: string; detached?: boolean; prunable?: boolean } = {} const flush = () => { if (current.worktree) { - records.push({ worktree: current.worktree, branch: current.branch }) + records.push({ + worktree: current.worktree, + branch: current.branch, + head: current.head, + detached: current.detached, + prunable: current.prunable, + }) } current = {} } - for (const line of lines) { - const trimmed = line.trim() - if (!trimmed) { - flush() - continue - } - const [key, ...rest] = trimmed.split(" ") - const value = rest.join(" ").trim() + for (const field of output.split("\0")) { + if (!field) continue + const separator = field.indexOf(" ") + const key = separator === -1 ? field : field.slice(0, separator) + const value = separator === -1 ? "" : field.slice(separator + 1) if (key === "worktree") { + flush() current.worktree = value } else if (key === "branch") { // branch is like refs/heads/foo @@ -90,6 +93,8 @@ function parseWorktreePorcelain(output: string): Array<{ worktree: string; branc current.head = value } else if (key === "detached") { current.detached = true + } else if (key === "prunable") { + current.prunable = true } } flush() @@ -100,27 +105,39 @@ export async function listWorktrees(params: { repoRoot: string workspaceFolder: string logger?: LogLike + failClosed?: boolean }): Promise { const { repoRoot, workspaceFolder, logger } = params - const result = await runGit(["worktree", "list", "--porcelain"], workspaceFolder) + const result = await runGit(["worktree", "list", "--porcelain", "-z"], workspaceFolder) if (!result.ok) { + if (params.failClosed) throw result.error const rootDescriptor: WorktreeDescriptor = { slug: "root", directory: workspaceFolder, kind: "root" } logger?.debug?.({ repoRoot, err: result.error }, "Failed to list git worktrees; returning root only") return [rootDescriptor] } const records = parseWorktreePorcelain(result.stdout) + if (params.failClosed && records.some((record) => record.prunable)) { + throw new Error("Git worktree inventory contains a prunable entry") + } const rootRecord = records.find((record) => path.resolve(record.worktree) === path.resolve(repoRoot)) + if (params.failClosed && !rootRecord) throw new Error("Git worktree inventory is missing the repository root") const rootDescriptor: WorktreeDescriptor = { slug: "root", directory: workspaceFolder, + registeredDirectory: rootRecord?.worktree, kind: "root", branch: rootRecord?.branch, + head: rootRecord?.head, } const worktrees: WorktreeDescriptor[] = [rootDescriptor] const seen = new Set(["root"]) + const relativeWorkspacePath = path.relative(repoRoot, workspaceFolder) + if (params.failClosed && (path.isAbsolute(relativeWorkspacePath) || relativeWorkspacePath.startsWith(`..${path.sep}`) || relativeWorkspacePath === "..")) { + throw new Error("Workspace folder is outside the repository root") + } const normalizeSlug = (record: { branch?: string; head?: string; detached?: boolean; worktree: string }): string => { const branch = (record.branch ?? "").trim() @@ -151,10 +168,18 @@ export async function listWorktrees(params: { continue } if (seen.has(slug)) { + if (params.failClosed) throw new Error(`Git worktree inventory contains duplicate slug: ${slug}`) continue } seen.add(slug) - worktrees.push({ slug, directory: abs, kind: "worktree", branch: record.branch }) + worktrees.push({ + slug, + directory: relativeWorkspacePath ? path.join(abs, relativeWorkspacePath) : abs, + registeredDirectory: abs, + kind: "worktree", + branch: record.branch, + head: record.head, + }) } return worktrees @@ -238,7 +263,7 @@ export async function removeWorktree(params: { logger?: LogLike }): Promise { const { workspaceFolder, logger } = params - const directory = (params.directory ?? "").trim() + const directory = params.directory ?? "" if (!directory) { throw new Error("Invalid worktree directory") } diff --git a/packages/server/src/workspaces/manager.test.ts b/packages/server/src/workspaces/manager.test.ts index 875c75eb1..6719a85b6 100644 --- a/packages/server/src/workspaces/manager.test.ts +++ b/packages/server/src/workspaces/manager.test.ts @@ -13,7 +13,7 @@ import type { OpenCodeEnsureOptions } from "./opencode-service" import path from "node:path" import os from "node:os" import { execFileSync } from "node:child_process" -import { mkdtemp, rm, writeFile } from "node:fs/promises" +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises" function deferred() { let resolve!: (value: T) => void @@ -171,6 +171,21 @@ describe("workspace manager shared service lifecycle", () => { assert.deepEqual(harness.service.evictions, [{ directory: process.cwd() }]) }) + it("refuses deletion while another workspace occupies the worktree", async () => { + const temp = await mkdtemp(path.join(os.tmpdir(), "codenomad-worktree-owner-")) + const worktree = path.join(temp, "worktree") + const nested = path.join(worktree, "apps", "web") + await mkdir(nested, { recursive: true }) + const harness = createHarness() + try { + const { workspace } = await harness.manager.create(nested) + await assert.rejects(() => harness.manager.reserveWorktreeDeletion(worktree), /open as another workspace/) + await harness.manager.delete(workspace.id) + } finally { + await rm(temp, { recursive: true, force: true }) + } + }) + it("keeps a failed eviction retryable and reports shutdown failures", async () => { const harness = createHarness() const { workspace } = await harness.manager.create(process.cwd()) diff --git a/packages/server/src/workspaces/manager.ts b/packages/server/src/workspaces/manager.ts index 00590d9f4..9c0ad0c5d 100644 --- a/packages/server/src/workspaces/manager.ts +++ b/packages/server/src/workspaces/manager.ts @@ -138,6 +138,7 @@ type WorkspaceCreationOwnership = Map export class WorkspaceManager { private readonly workspaces = new Map() private readonly pendingWorkspaceCreations = new Map() + private readonly deletingWorktreeRoots = new Set() private readonly cancelledCreationRequests = new Set() private shuttingDown = false private readonly sharedService: SharedService @@ -181,6 +182,18 @@ export class WorkspaceManager { return this.sharedService.client() } + async reserveWorktreeDeletion(directory: string): Promise<() => void> { + const target = (await resolveWorkspaceIdentity(directory, this.options.rootDir)).workspacePath + if (Array.from(this.deletingWorktreeRoots).some((root) => pathsOverlap(root, target))) { + throw new Error("Worktree deletion is already in progress") + } + if (Array.from(this.workspaces.values()).some((workspace) => pathContains(target, workspace.path))) { + throw new Error("Worktree is open as another workspace") + } + this.deletingWorktreeRoots.add(target) + return () => this.deletingWorktreeRoots.delete(target) + } + async ownsDirectory(id: string, directory: string): Promise { const record = this.workspaces.get(id) if (!record?.[WORKSPACE_STATE].published) return false @@ -327,6 +340,9 @@ export class WorkspaceManager { launchDeadlineAt, launchTimeoutMs, ) + if (Array.from(this.deletingWorktreeRoots).some((root) => pathContains(root, workspacePath))) { + throw new Error("Workspace directory is being removed") + } if (options.requestId && this.cancelledCreationRequests.has(options.requestId)) { throw new Error(`Workspace creation request ${options.requestId} was cancelled`) } @@ -790,3 +806,12 @@ export class WorkspaceManager { return candidates[0] ?? "" } } + +function pathContains(parent: string, child: string): boolean { + const relative = path.relative(parent, child) + return relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) +} + +function pathsOverlap(left: string, right: string): boolean { + return pathContains(left, right) || pathContains(right, left) +} diff --git a/packages/server/src/workspaces/project-session-families.test.ts b/packages/server/src/workspaces/project-session-families.test.ts new file mode 100644 index 000000000..8c3631c7e --- /dev/null +++ b/packages/server/src/workspaces/project-session-families.test.ts @@ -0,0 +1,231 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" +import type { OpenCodeClient, SessionInfo } from "@opencode-ai/client" +import { + listCompleteProjectSessions, + moveProjectSessionFamily, + ProjectSessionError, + removeProjectWorktree, + resolveSessionFamilies, +} from "./project-session-families" + +const ROOT = "/repo" +const WORKTREE = "/repo/.codenomad/worktrees/feature" + +function session(id: string, parentID?: string, directory = ROOT): SessionInfo { + return { + id, + parentID, + projectID: "project", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1, updated: 1 }, + location: { directory }, + } +} + +function clientHarness(initial: SessionInfo[], options: { + active?: string[] | (() => string[]) + failMove?: (sessionId: string, call: number) => boolean + visibilityDelayGets?: number +} = {}) { + const sessions = new Map(initial.map((value) => [value.id, structuredClone(value)])) + const moveCalls: string[] = [] + let moveCall = 0 + const pending = new Map() + const client = { + location: { + get: async ({ location }: { location?: { directory?: string } }) => ({ + directory: location?.directory ?? ROOT, + project: { id: "project", directory: ROOT, canonical: ROOT }, + }), + }, + session: { + list: async () => ({ data: Array.from(sessions.values()).map((value) => structuredClone(value)), cursor: {} }), + active: async () => Object.fromEntries((typeof options.active === "function" ? options.active() : options.active ?? []).map((id) => [id, { type: "running" as const }])), + get: async ({ sessionID }: { sessionID: string }) => { + const update = pending.get(sessionID) + if (update && update.remaining-- <= 0) { + sessions.get(sessionID)!.location = update.location + pending.delete(sessionID) + } + return structuredClone(sessions.get(sessionID)!) + }, + move: async ({ sessionID, directory, workspaceID }: { sessionID: string; directory: string; workspaceID?: string }) => { + moveCall += 1 + moveCalls.push(sessionID) + if (options.failMove?.(sessionID, moveCall)) throw new Error(`move failed: ${sessionID}`) + const location = { directory, workspaceID } + if (options.visibilityDelayGets) pending.set(sessionID, { location, remaining: options.visibilityDelayGets }) + else sessions.get(sessionID)!.location = location + }, + }, + } as unknown as OpenCodeClient + return { client, sessions, moveCalls } +} + +describe("project session families", () => { + it("loads the complete paginated project inventory", async () => { + const calls: Array<{ project?: string; cursor?: string }> = [] + const client = { + session: { + list: async (input: { project?: string; cursor?: string }) => { + calls.push(input) + return input.cursor + ? { data: [session("child", "root")], cursor: {} } + : { data: [session("root")], cursor: { next: "next" } } + }, + }, + } as unknown as OpenCodeClient + + assert.deepEqual((await listCompleteProjectSessions(client, "project")).map(({ id }) => id), ["root", "child"]) + assert.ok(calls.every(({ project }) => project === "project")) + assert.equal(calls[1]?.cursor, "next") + }) + + it("rejects malformed native cursors", async () => { + const client = { session: { list: async () => ({ data: [session("root")], cursor: { next: 42 } }) } } as unknown as OpenCodeClient + await assert.rejects(() => listCompleteProjectSessions(client, "project"), /invalid session inventory cursor/) + }) + + it("resolves complete families and rejects incomplete ancestry", () => { + assert.deepEqual( + Array.from(resolveSessionFamilies([session("child", "root"), session("root")]).values()) + .map((family) => family.map(({ id }) => id)), + [["root", "child"]], + ) + assert.throws(() => resolveSessionFamilies([session("child", "missing")]), /missing parent/) + }) + + it("moves a complete family to the target", async () => { + const harness = clientHarness([session("root"), session("child", "root")]) + const moved = await moveProjectSessionFamily({ + client: harness.client, + projectDirectory: ROOT, + sessionId: "child", + targetDirectory: WORKTREE, + }) + assert.deepEqual(moved.sessionIds, ["root", "child"]) + assert.ok([...harness.sessions.values()].every(({ location }) => location.directory === WORKTREE)) + }) + + it("waits for delayed move visibility", async () => { + const harness = clientHarness([session("root")], { visibilityDelayGets: 2 }) + await moveProjectSessionFamily({ client: harness.client, projectDirectory: ROOT, sessionId: "root", targetDirectory: WORKTREE }) + assert.equal(harness.sessions.get("root")?.location.directory, WORKTREE) + }) + + it("rolls back transaction-owned moves after partial failure", async () => { + const harness = clientHarness([session("root"), session("child", "root")], { + failMove: (id, call) => id === "child" && call === 2, + }) + await assert.rejects(() => moveProjectSessionFamily({ + client: harness.client, + projectDirectory: ROOT, + sessionId: "root", + targetDirectory: WORKTREE, + }), /move failed/) + assert.deepEqual(harness.moveCalls, ["root", "child", "root"]) + assert.equal(harness.sessions.get("root")?.location.directory, ROOT) + }) + + it("blocks deletion while an attached session is active", async () => { + const harness = clientHarness([session("blocked", undefined, WORKTREE)], { active: ["blocked"] }) + await assert.rejects(() => removeProjectWorktree({ + client: harness.client, + projectDirectory: ROOT, + targetDirectory: WORKTREE, + rootDirectory: ROOT, + remove: async () => assert.fail("Git removal must not run"), + isTargetRegistered: async () => true, + }), (error: unknown) => error instanceof ProjectSessionError && error.statusCode === 409) + assert.deepEqual(harness.moveCalls, []) + }) + + it("rolls back when a family member becomes active during evacuation", async () => { + let harness: ReturnType + harness = clientHarness([session("root", undefined, WORKTREE), session("child", "root", WORKTREE)], { + active: () => harness.moveCalls.includes("root") ? ["child"] : [], + }) + await assert.rejects(() => removeProjectWorktree({ + client: harness.client, + projectDirectory: ROOT, + targetDirectory: WORKTREE, + rootDirectory: ROOT, + remove: async () => assert.fail("Git removal must not run"), + isTargetRegistered: async () => true, + }), /Active sessions block/) + assert.equal(harness.sessions.get("root")?.location.directory, WORKTREE) + }) + + it("rolls back from session state when inventory visibility is stale", async () => { + const harness = clientHarness([session("root"), session("child", "root")], { + failMove: (id, call) => id === "child" && call === 2, + }) + const stale = [session("root"), session("child", "root")] + ;(harness.client.session.list as any) = async () => ({ data: structuredClone(stale), cursor: {} }) + await assert.rejects(() => moveProjectSessionFamily({ + client: harness.client, + projectDirectory: ROOT, + sessionId: "root", + targetDirectory: WORKTREE, + }), /move failed/) + assert.equal(harness.sessions.get("root")?.location.directory, ROOT) + }) + + it("treats WSL service directories as case-sensitive POSIX paths", async () => { + const harness = clientHarness([session("upper", undefined, "/home/dev/Foo")]) + let removed = false + await removeProjectWorktree({ + client: harness.client, + projectDirectory: ROOT, + targetDirectory: "/home/dev/foo", + rootDirectory: ROOT, + remove: async () => { removed = true }, + isTargetRegistered: async () => true, + }) + assert.equal(removed, true) + assert.deepEqual(harness.moveCalls, []) + }) + + it("evacuates a complete family before removing its worktree", async () => { + const harness = clientHarness([session("root", undefined, WORKTREE), session("child", "root", WORKTREE)]) + let removed = false + await removeProjectWorktree({ + client: harness.client, + projectDirectory: ROOT, + targetDirectory: WORKTREE, + rootDirectory: ROOT, + remove: async () => { removed = true }, + isTargetRegistered: async () => true, + }) + assert.equal(removed, true) + assert.ok([...harness.sessions.values()].every(({ location }) => location.directory === ROOT)) + }) + + it("rolls back only while the original worktree identity remains", async () => { + const original = clientHarness([session("original", undefined, WORKTREE)]) + await assert.rejects(() => removeProjectWorktree({ + client: original.client, + projectDirectory: ROOT, + targetDirectory: WORKTREE, + rootDirectory: ROOT, + remove: async () => { throw new ProjectSessionError("dirty worktree", 409) }, + isTargetRegistered: async () => true, + }), /dirty worktree/) + assert.equal(original.sessions.get("original")?.location.directory, WORKTREE) + + const replacement = clientHarness([session("replacement", undefined, WORKTREE)]) + let identityChecks = 0 + await assert.rejects(() => removeProjectWorktree({ + client: replacement.client, + projectDirectory: ROOT, + targetDirectory: WORKTREE, + rootDirectory: ROOT, + remove: async () => { throw new ProjectSessionError("worktree changed", 409) }, + isTargetRegistered: async () => ++identityChecks === 1, + }), /worktree changed/) + assert.deepEqual(replacement.moveCalls, ["replacement"]) + assert.equal(replacement.sessions.get("replacement")?.location.directory, ROOT) + }) +}) diff --git a/packages/server/src/workspaces/project-session-families.ts b/packages/server/src/workspaces/project-session-families.ts new file mode 100644 index 000000000..fe175489b --- /dev/null +++ b/packages/server/src/workspaces/project-session-families.ts @@ -0,0 +1,373 @@ +import path from "node:path" +import type { LocationGetOutput, LocationRef, OpenCodeClient, SessionInfo } from "@opencode-ai/client" + +const SESSION_PAGE_LIMIT = 500 +const MAX_SESSION_PAGES = 1000 +const MOVE_VERIFY_ATTEMPTS = 100 +const MOVE_VERIFY_DELAY_MS = 50 +const projectLocks = new Map>() + +export class ProjectSessionError extends Error { + constructor(message: string, readonly statusCode: number) { + super(message) + this.name = "ProjectSessionError" + } +} + +export interface SessionFamilyMoveResult { + rootSessionId: string + sessionIds: string[] +} + +interface ProjectContext { + client: OpenCodeClient + project: LocationGetOutput["project"] +} + +export async function listCompleteProjectSessions( + client: OpenCodeClient, + projectID: string, +): Promise { + const sessions: SessionInfo[] = [] + const cursors = new Set() + let cursor: string | undefined + let page = 0 + do { + if (++page > MAX_SESSION_PAGES) throw new ProjectSessionError("Session inventory exceeded the page limit", 502) + const response = await client.session.list({ project: projectID, limit: SESSION_PAGE_LIMIT, cursor }) + if (!response || !Array.isArray(response.data) || !response.cursor || typeof response.cursor !== "object") { + throw new ProjectSessionError("OpenCode returned an invalid session inventory", 502) + } + for (const session of response.data) { + if (!session?.id || session.projectID !== projectID || !session.location?.directory) { + throw new ProjectSessionError("OpenCode returned a session outside the requested project", 409) + } + sessions.push(session) + } + + const rawNext = response.cursor.next + if (rawNext !== undefined && (typeof rawNext !== "string" || !rawNext.trim())) { + throw new ProjectSessionError("OpenCode returned an invalid session inventory cursor", 502) + } + const next = rawNext + if (next && cursors.has(next)) throw new ProjectSessionError(`Session inventory repeated cursor: ${next}`, 502) + if (next) cursors.add(next) + cursor = next + } while (cursor) + + return sessions +} + +export function resolveSessionFamilies(sessions: SessionInfo[]): Map { + const byId = new Map(sessions.map((session) => [session.id, session])) + if (byId.size !== sessions.length) throw new ProjectSessionError("Session inventory contains duplicate sessions", 409) + const rootById = new Map() + + const rootFor = (session: SessionInfo): string => { + const cached = rootById.get(session.id) + if (cached) return cached + const chain: SessionInfo[] = [] + const seen = new Set() + let current = session + while (current.parentID) { + if (seen.has(current.id)) throw new ProjectSessionError(`Session family contains a cycle at: ${current.id}`, 409) + seen.add(current.id) + chain.push(current) + const parent = byId.get(current.parentID) + if (!parent) throw new ProjectSessionError(`Session family is incomplete; missing parent: ${current.parentID}`, 409) + current = parent + } + if (seen.has(current.id)) throw new ProjectSessionError(`Session family contains a cycle at: ${current.id}`, 409) + rootById.set(current.id, current.id) + for (const member of chain) rootById.set(member.id, current.id) + return current.id + } + + const families = new Map() + for (const session of sessions) { + const root = rootFor(session) + const family = families.get(root) ?? [] + family.push(session) + families.set(root, family) + } + for (const family of families.values()) { + family.sort((left, right) => ancestryDepth(left, byId) - ancestryDepth(right, byId)) + } + return families +} + +export async function moveProjectSessionFamily(params: { + client: OpenCodeClient + projectDirectory: string + sessionId: string + targetDirectory: string + validateTarget?: () => Promise +}): Promise { + return withProject(params.client, params.projectDirectory, async (context) => { + if (params.validateTarget && !await params.validateTarget()) { + throw new ProjectSessionError("Worktree changed before the session move", 409) + } + const inventory = await listCompleteProjectSessions(context.client, context.project.id) + const families = resolveSessionFamilies(inventory) + const family = Array.from(families.entries()).find(([, members]) => members.some(({ id }) => id === params.sessionId)) + if (!family) throw new ProjectSessionError("Session not found in project", 404) + await assertInactive(context.client, family[1]) + if (params.validateTarget && !await params.validateTarget()) { + throw new ProjectSessionError("Worktree changed before the session move", 409) + } + const target = await resolveProjectLocation(context, params.targetDirectory) + await moveWithRollback(context, family[1], target) + return { rootSessionId: family[0], sessionIds: family[1].map(({ id }) => id) } + }) +} + +export async function removeProjectWorktree(params: { + client: OpenCodeClient + projectDirectory: string + targetDirectory: string + rootDirectory: string + remove: () => Promise + isTargetRegistered: () => Promise +}): Promise { + await withProject(params.client, params.projectDirectory, async (context) => { + if (!await params.isTargetRegistered()) { + throw new ProjectSessionError("Worktree changed before deletion", 409) + } + const inventory = await listCompleteProjectSessions(context.client, context.project.id) + const families = Array.from(resolveSessionFamilies(inventory).values()) + .filter((family) => family.some((session) => directoryContains(params.targetDirectory, session.location.directory))) + await assertInactive(context.client, families.flat()) + const original = new Map(families.flat().map((session) => [session.id, session.location])) + const moved: string[] = [] + let root: LocationRef | undefined + + try { + if (families.length) { + const destination = await resolveProjectLocation(context, params.rootDirectory) + root = destination + for (const family of families) await moveMembers(context, family, destination, moved) + await verifyInventory(context, moved, new Map(moved.map((id) => [id, destination]))) + const refreshed = await listCompleteProjectSessions(context.client, context.project.id) + if (refreshed.some((session) => directoryContains(params.targetDirectory, session.location.directory))) { + throw new ProjectSessionError("Sessions remain attached to the worktree after evacuation", 409) + } + } + await assertInactive(context.client, families.flat()) + await params.remove() + } catch (error) { + const changed = root ? await refreshChangedSessionIds(context, moved, root) : [] + if (changed.length) { + let registered: boolean + try { + registered = await params.isTargetRegistered() + } catch (inventoryError) { + throw new ProjectSessionError( + `${errorMessage(error)}; unable to verify worktree registration, rollback skipped: ${errorMessage(inventoryError)}`, + 500, + ) + } + // A mismatched identity may now be a replacement checkout; never move sessions into it. + if (registered) await rollback(context, changed, original, error) + } + throw asProjectError(error, "Unable to remove worktree") + } + }) +} + +async function withProject( + client: OpenCodeClient, + directory: string, + operation: (context: ProjectContext) => Promise, +): Promise { + let location: LocationGetOutput + try { + location = await client.location.get({ location: { directory } }) + } catch (error) { + throw asProjectError(error, "Unable to resolve the workspace project") + } + if (!location?.project?.id) throw new ProjectSessionError("OpenCode could not resolve the workspace project", 502) + const previous = projectLocks.get(location.project.id) ?? Promise.resolve() + const run = previous.catch(() => undefined).then(async () => { + try { + return await operation({ client, project: location.project }) + } catch (error) { + throw asProjectError(error, "Project session operation failed") + } + }) + const tail = run.then(() => undefined, () => undefined) + projectLocks.set(location.project.id, tail) + try { + return await run + } finally { + if (projectLocks.get(location.project.id) === tail) projectLocks.delete(location.project.id) + } +} + +async function resolveProjectLocation(context: ProjectContext, directory: string): Promise { + const location = await context.client.location.get({ location: { directory } }) + if (!location?.directory || location.project?.id !== context.project.id) { + throw new ProjectSessionError("Target worktree does not belong to the workspace project", 409) + } + return { directory: location.directory, workspaceID: location.workspaceID } +} + +async function assertInactive(client: OpenCodeClient, sessions: SessionInfo[]): Promise { + if (!sessions.length) return + const active = await client.session.active() + const blockers = sessions.filter(({ id }) => Object.prototype.hasOwnProperty.call(active, id)).map(({ id }) => id) + if (blockers.length) throw new ProjectSessionError(`Active sessions block this operation: ${blockers.join(", ")}`, 409) +} + +async function moveWithRollback(context: ProjectContext, family: SessionInfo[], target: LocationRef): Promise { + const original = new Map(family.map((session) => [session.id, session.location])) + const moved: string[] = [] + try { + await moveMembers(context, family, target, moved) + const refreshed = await verifyInventory(context, moved, new Map(moved.map((id) => [id, target]))) + const refreshedFamily = resolveSessionFamilies(refreshed).get(family[0]!.id) + if (!refreshedFamily + || !family.every(({ id }) => refreshedFamily.some((session) => session.id === id)) + || !refreshedFamily.every((session) => sameLocation(session.location, target))) { + throw new ProjectSessionError("Session family changed during the move", 409) + } + } catch (error) { + const changed = await refreshChangedSessionIds(context, family.map(({ id }) => id), target) + await rollback(context, changed, original, error) + throw asProjectError(error, "Unable to move session family") + } +} + +async function moveMembers( + context: ProjectContext, + members: SessionInfo[], + target: LocationRef, + moved: string[], +): Promise { + for (const session of members) { + await assertInactive(context.client, [session]) + moved.push(session.id) + await context.client.session.move({ + sessionID: session.id, + directory: target.directory, + workspaceID: target.workspaceID, + }) + await waitForSessionLocation(context, session.id, target, `Session move verification failed: ${session.id}`) + } +} + +async function refreshChangedSessionIds( + context: ProjectContext, + candidates: string[], + transactionLocation: LocationRef, +): Promise { + const changed: string[] = [] + for (const id of candidates) { + try { + const session = await context.client.session.get({ sessionID: id }) + if (session.id !== id || session.projectID !== context.project.id) { + throw new ProjectSessionError(`OpenCode returned the wrong session while determining rollback state: ${id}`, 502) + } + if (sameLocation(session.location, transactionLocation)) { + changed.push(id) + } + } catch (error) { + throw new ProjectSessionError(`Unable to determine rollback state for ${id}: ${errorMessage(error)}`, 500) + } + } + return changed +} + +async function rollback( + context: ProjectContext, + moved: string[], + original: Map, + cause: unknown, +): Promise { + try { + for (const sessionId of [...moved].reverse()) { + const location = original.get(sessionId)! + await context.client.session.move({ sessionID: sessionId, directory: location.directory, workspaceID: location.workspaceID }) + await waitForSessionLocation(context, sessionId, location, `Session rollback verification failed: ${sessionId}`) + } + await verifyInventory(context, moved, original) + } catch (rollbackError) { + throw new ProjectSessionError( + `${errorMessage(cause)}; rollback failed: ${errorMessage(rollbackError)}`, + 500, + ) + } +} + +async function verifyInventory( + context: ProjectContext, + sessionIds: string[], + expected: Map, +): Promise { + for (let attempt = 0; attempt < MOVE_VERIFY_ATTEMPTS; attempt += 1) { + const sessions = await listCompleteProjectSessions(context.client, context.project.id) + const refreshed = new Map(sessions.map((session) => [session.id, session])) + if (sessionIds.every((sessionId) => { + const session = refreshed.get(sessionId) + return Boolean(session && sameLocation(session.location, expected.get(sessionId)!)) + })) return sessions + await new Promise((resolve) => setTimeout(resolve, MOVE_VERIFY_DELAY_MS)) + } + throw new ProjectSessionError("Timed out waiting for session inventory verification", 409) +} + +async function waitForSessionLocation( + context: ProjectContext, + sessionId: string, + expected: LocationRef, + message: string, +): Promise { + for (let attempt = 0; attempt < MOVE_VERIFY_ATTEMPTS; attempt += 1) { + const session = await context.client.session.get({ sessionID: sessionId }) + if (session.id !== sessionId || session.projectID !== context.project.id) { + throw new ProjectSessionError(`OpenCode returned the wrong session after move: ${sessionId}`, 502) + } + if (sameLocation(session.location, expected)) return session + await new Promise((resolve) => setTimeout(resolve, MOVE_VERIFY_DELAY_MS)) + } + throw new ProjectSessionError(message, 409) +} + +function sameLocation(left: LocationRef, right: LocationRef): boolean { + return sameDirectory(left.directory, right.directory) && left.workspaceID === right.workspaceID +} + +function ancestryDepth(session: SessionInfo, byId: Map): number { + let depth = 0 + let current = session + while (current.parentID) { + current = byId.get(current.parentID)! + depth += 1 + } + return depth +} + +function sameDirectory(left: string, right: string): boolean { + if (isWindowsPath(left) !== isWindowsPath(right)) return false + if (!isWindowsPath(left)) return path.posix.resolve(left) === path.posix.resolve(right) + return path.win32.resolve(left).toLowerCase() === path.win32.resolve(right).toLowerCase() +} + +function directoryContains(parent: string, child: string): boolean { + if (isWindowsPath(parent) !== isWindowsPath(child)) return false + const paths = isWindowsPath(parent) ? path.win32 : path.posix + const relative = paths.relative(parent, child) + return relative === "" || (relative !== ".." && !relative.startsWith(`..${paths.sep}`) && !paths.isAbsolute(relative)) +} + +function isWindowsPath(value: string): boolean { + return /^[a-z]:[\\/]/i.test(value) || value.startsWith("\\\\") +} + +function asProjectError(error: unknown, fallback: string): ProjectSessionError { + if (error instanceof ProjectSessionError) return error + return new ProjectSessionError(error instanceof Error ? error.message : fallback, 502) +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} diff --git a/packages/server/src/workspaces/worktree-directory.ts b/packages/server/src/workspaces/worktree-directory.ts index bff0a0f3c..9f52e2a60 100644 --- a/packages/server/src/workspaces/worktree-directory.ts +++ b/packages/server/src/workspaces/worktree-directory.ts @@ -12,6 +12,10 @@ type WorktreeCacheEntry = { const WORKTREE_CACHE_TTL_MS = 2000 const worktreeCache = new Map() +export function invalidateWorktreeDirectoryCache(workspaceId: string): void { + worktreeCache.delete(workspaceId) +} + async function normalizeDirectoryPath(directory: string): Promise { const trimmed = (directory ?? "").trim() if (!trimmed) return "" diff --git a/packages/server/src/workspaces/worktree-session-evacuation.test.ts b/packages/server/src/workspaces/worktree-session-evacuation.test.ts deleted file mode 100644 index 227cfb0a2..000000000 --- a/packages/server/src/workspaces/worktree-session-evacuation.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import assert from "node:assert/strict" -import { describe, it } from "node:test" -import type { OpenCodeClient, SessionInfo } from "@opencode-ai/client" -import { evacuateWorktreeSessions } from "./worktree-session-evacuation" - -function session(id: string, directory: string, parentID?: string): SessionInfo { - return { id, parentID, projectID: "project", location: { directory }, cost: 0, tokens: {}, time: { created: 1, updated: 1 } } as SessionInfo -} - -describe("evacuateWorktreeSessions", () => { - it("finds later-page sessions and waits for their asynchronous moves", async () => { - const moves: Array<{ sessionID: string; directory: string }> = [] - const lists: unknown[] = [] - let listCall = 0 - const root = session("old-root", "/repo/worktree") - const child = session("old-child", "/repo/worktree", root.id) - const grandchild = session("old-grandchild", "/repo/worktree", child.id) - const state = new Map([root, child, grandchild].map((item) => [item.id, item])) - let removed = false - const client = { - project: { - list: async () => [{ id: "project", canonical: "/repo", sandboxes: ["/repo/worktree"], time: { created: 1, updated: 1 } }], - }, - session: { - list: async (input: unknown) => { - lists.push(input) - listCall += 1 - if (listCall === 1) return { data: [session("loaded", "/repo")], cursor: { next: "older" } } - if (listCall === 2) return { data: [root, child, grandchild], cursor: {} } - return { data: [session("loaded", "/repo"), ...state.values()], cursor: {} } - }, - active: async () => ({}), - move: async (input: { sessionID: string; directory: string }) => { - moves.push(input) - setImmediate(() => state.set(input.sessionID, { ...state.get(input.sessionID)!, location: { directory: input.directory } })) - }, - }, - } as unknown as OpenCodeClient - - await evacuateWorktreeSessions({ - client, projectDirectory: "/repo", targetDirectory: "/repo/worktree", rootDirectory: "/repo", - remove: async () => { removed = true }, - }) - - assert.deepEqual(moves.map(({ sessionID }) => sessionID), [root.id, child.id, grandchild.id]) - assert.equal(removed, true) - assert.ok(listCall > 3) - assert.ok(lists.every((input: any) => input.project === "project" && input.directory === undefined)) - }) - - it("rolls sessions back when Git removal fails", async () => { - const current = session("session", "/repo/worktree") - const moves: string[] = [] - const client = { - project: { list: async () => [{ id: "project", canonical: "/repo", sandboxes: ["/repo/worktree"], time: { created: 1, updated: 1 } }] }, - session: { - list: async () => ({ data: [current], cursor: {} }), - active: async () => ({}), - move: async ({ directory }: { directory: string }) => { - moves.push(directory) - current.location = { directory } - }, - }, - } as unknown as OpenCodeClient - - await assert.rejects(evacuateWorktreeSessions({ - client, projectDirectory: "/repo", targetDirectory: "/repo/worktree", rootDirectory: "/repo", - remove: async () => { throw new Error("Git removal failed") }, - }), /Git removal failed/) - assert.deepEqual(moves, ["/repo", "/repo/worktree"]) - assert.equal(current.location.directory, "/repo/worktree") - }) -}) diff --git a/packages/server/src/workspaces/worktree-session-evacuation.ts b/packages/server/src/workspaces/worktree-session-evacuation.ts deleted file mode 100644 index ae1f3f0bd..000000000 --- a/packages/server/src/workspaces/worktree-session-evacuation.ts +++ /dev/null @@ -1,108 +0,0 @@ -import type { OpenCodeClient, SessionInfo } from "@opencode-ai/client" - -const PAGE_SIZE = 200 -const MAX_PAGES = 10_000 -const MAX_SESSIONS = 1_000_000 - -function normalizeDirectory(directory: string): string { - const normalized = directory.trim().replace(/\\/g, "/").replace(/\/+$/, "") || "/" - return /^[A-Za-z]:\//.test(normalized) || normalized.startsWith("//") ? normalized.toLowerCase() : normalized -} - -async function inventorySessions(client: OpenCodeClient, project: string): Promise { - const sessions = new Map() - const cursors = new Set() - let cursor: string | undefined - - for (let pageCount = 0; pageCount < MAX_PAGES; pageCount += 1) { - const page = await client.session.list({ project, limit: PAGE_SIZE, order: "asc", cursor }) - for (const session of page.data) { - sessions.set(session.id, session) - if (sessions.size > MAX_SESSIONS) throw new Error("Session inventory exceeded its safety limit") - } - - cursor = page.cursor.next ?? undefined - if (!cursor) return Array.from(sessions.values()) - if (cursors.has(cursor)) throw new Error(`Repeated session inventory cursor: ${cursor}`) - cursors.add(cursor) - } - - throw new Error("Session inventory exceeded its page limit") -} - -async function waitForInventory( - client: OpenCodeClient, - project: string, - predicate: (sessions: SessionInfo[]) => boolean, -): Promise { - for (let attempt = 0; attempt < 100; attempt += 1) { - if (predicate(await inventorySessions(client, project))) return - await new Promise((resolve) => setTimeout(resolve, 50)) - } - throw new Error("Timed out waiting for session moves") -} - -export async function evacuateWorktreeSessions(params: { - client: OpenCodeClient - projectDirectory: string - targetDirectory: string - rootDirectory: string - remove: () => Promise -}): Promise { - const target = normalizeDirectory(params.targetDirectory) - const project = (await params.client.project.list()).find((candidate) => ( - normalizeDirectory(candidate.canonical) === normalizeDirectory(params.projectDirectory) - || candidate.sandboxes.some((directory) => normalizeDirectory(directory) === target) - )) - if (!project) throw new Error("Unable to resolve the OpenCode project before deleting worktree") - const sessions = await inventorySessions(params.client, project.id) - const affected = sessions.filter((session) => normalizeDirectory(session.location.directory) === target) - const assertInactive = async () => { - const active = await params.client.session.active() - const blockers = affected.filter((session) => Object.prototype.hasOwnProperty.call(active, session.id)) - if (blockers.length) throw new Error(`Active sessions block worktree deletion: ${blockers.map((session) => session.id).join(", ")}`) - } - await assertInactive() - - const moved: SessionInfo[] = [] - try { - for (const session of affected) { - await assertInactive() - const original = { ...session, location: { ...session.location } } - await params.client.session.move({ sessionID: session.id, directory: params.rootDirectory }) - moved.push(original) - } - await waitForInventory(params.client, project.id, (current) => ( - current.every((session) => normalizeDirectory(session.location.directory) !== target) - )) - await assertInactive() - await params.remove() - } catch (error) { - const rollbackErrors: unknown[] = [] - for (const session of moved.reverse()) { - try { - await params.client.session.move({ - sessionID: session.id, - directory: session.location.directory, - workspaceID: session.location.workspaceID, - }) - } catch (rollbackError) { - rollbackErrors.push(rollbackError) - } - } - try { - const expected = new Set(moved.map((session) => session.id)) - await waitForInventory(params.client, project.id, (current) => { - const restored = current.filter((session) => expected.has(session.id)) - return restored.length === expected.size - && restored.every((session) => normalizeDirectory(session.location.directory) === target) - }) - } catch (rollbackError) { - rollbackErrors.push(rollbackError) - } - if (rollbackErrors.length) { - throw new AggregateError([error, ...rollbackErrors], "Session evacuation failed and could not be rolled back") - } - throw error - } -} diff --git a/packages/ui/src/components/session-list.tsx b/packages/ui/src/components/session-list.tsx index 38ca8ce6d..ed4632157 100644 --- a/packages/ui/src/components/session-list.tsx +++ b/packages/ui/src/components/session-list.tsx @@ -32,8 +32,9 @@ import { getSessionSearchThreads, isSessionSearchLoading, } from "../stores/sessions" -import { getGitRepoStatus, getWorktreeSlugForParentSession } from "../stores/worktrees" -import { collectSessionThreadIds, findSessionThread, flattenVisibleSessionThreads, sortSessionIdsDeepestFirst } from "../stores/session-tree" +import { getGitRepoStatus, getWorktreeSlugForParentSession, getWorktrees } from "../stores/worktrees" +import { collectSessionThreadIds, findSessionThread, flattenVisibleSessionThreads, projectSessionFamilies, sortSessionIdsDeepestFirst, type SessionFamilySort } from "../stores/session-tree" +import { normalizeSessionDirectory } from "../stores/session-list-options" import { getLogger } from "../lib/logger" import { copyToClipboard } from "../lib/clipboard" import { useConfig } from "../stores/preferences" @@ -66,6 +67,8 @@ const SessionList: Component = (props) => { const [isRenaming, setIsRenaming] = createSignal(false) const [filterQuery, setFilterQuery] = createSignal("") + const [sortBy, setSortBy] = createSignal("activity") + const [worktreeDirectory, setWorktreeDirectory] = createSignal("") const normalizedQuery = createMemo(() => (props.enableFilterBar ? filterQuery().trim().toLowerCase() : "")) const [selectedSessionIds, setSelectedSessionIds] = createSignal>(new Set()) @@ -186,32 +189,25 @@ const SessionList: Component = (props) => { return sessionId.toLowerCase().includes(query) } - const filterThreadTree = (thread: SessionThread, query: string): SessionThread | null => { - const matchingChildren: SessionThread[] = [] - for (const child of thread.children) { - const filteredChild = filterThreadTree(child, query) - if (filteredChild !== null) matchingChildren.push(filteredChild) - } - if (!sessionMatchesQuery(thread.session.id, query) && matchingChildren.length === 0) return null - return { ...thread, children: matchingChildren } - } - const filteredThreads = createMemo(() => { const query = normalizedQuery() - if (!query) return props.threads - - const searchQuery = getSessionSearchQuery(props.instanceId) - const searchLoading = isSessionSearchLoading(props.instanceId) - if (searchQuery === query && !searchLoading) { - return getSessionSearchThreads(props.instanceId) + const searchThreads = query && getSessionSearchQuery(props.instanceId) === query && !isSessionSearchLoading(props.instanceId) + ? getSessionSearchThreads(props.instanceId) + : props.threads + const worktrees = getWorktrees(props.instanceId) + const getWorktreeLabel = (directory: string) => { + const normalized = normalizeSessionDirectory(directory) + const worktree = worktrees.find((candidate) => normalizeSessionDirectory(candidate.serviceDirectory ?? candidate.directory) === normalized) + return worktree?.kind === "root" ? t("sessionList.worktree.workspace") : worktree?.slug ?? directory } - - const result: SessionThread[] = [] - for (const thread of props.threads) { - const filtered = filterThreadTree(thread, query) - if (filtered !== null) result.push(filtered) - } - return result + return projectSessionFamilies(searchThreads, { + sort: sortBy(), + worktreeDirectory: worktreeDirectory(), + getWorktreeLabel, + ...(query && searchThreads === props.threads + ? { matchesSession: (session) => sessionMatchesQuery(session.id, query) } + : {}), + }) }) const visibleProjection = createMemo(() => { @@ -251,6 +247,14 @@ const SessionList: Component = (props) => { const selectedCount = createMemo(() => selectedSessionIds().size) + createEffect(() => { + const available = new Set(allMatchingSessionIds()) + setSelectedSessionIds((selected) => { + const next = new Set([...selected].filter((id) => available.has(id))) + return next.size === selected.size ? selected : next + }) + }) + const isAllSelected = createMemo(() => { const ids = allMatchingSessionIds() if (ids.length === 0) return false @@ -423,8 +427,7 @@ const SessionList: Component = (props) => { } const getSelectableThreadIds = (sessionId: string): string[] => { - const source = normalizedQuery() ? filteredThreads() : props.threads - const thread = findSessionThread(source, sessionId) + const thread = findSessionThread(filteredThreads(), sessionId) return thread ? collectSessionThreadIds([thread]) : [sessionId] } @@ -528,14 +531,14 @@ const SessionList: Component = (props) => { const worktreeSlug = createMemo(() => { if (isChild()) return "root" - return getWorktreeSlugForParentSession(props.instanceId, sessionId()) + const slug = getWorktreeSlugForParentSession(props.instanceId, sessionId()) + return slug === "root" ? t("sessionList.worktree.workspace") : slug }) const showWorktreeBadge = createMemo(() => { if (isChild()) return false if (getGitRepoStatus(props.instanceId) === false) return false - const slug = worktreeSlug() - return Boolean(slug) && slug !== "root" + return Boolean(worktreeSlug()) }) const isActive = () => props.activeSessionId === sessionId() @@ -691,7 +694,7 @@ const SessionList: Component = (props) => { - + @@ -824,6 +827,30 @@ const SessionList: Component = (props) => { +
+ + +
+ 0}>
+