From 9b18a379947a944fb605c41ffdf54bef2126b622 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Thu, 13 Aug 2026 00:12:48 +0200 Subject: [PATCH 1/7] feat(server): move session families with worktrees Use the shared OpenCode V2 service to inventory complete project session families and move them through the native session.move API. Serialize project operations, verify authoritative locations, and roll back only transaction-owned moves when failures occur. Make worktree deletion transactional by evacuating inactive session families before Git removal. Strict NUL-delimited inventory checks, HEAD revalidation, physical-root workspace reservations, nested workspace projection, and cache invalidation prevent stale or in-use worktrees from being removed. Cover cursor pagination, family resolution, rollback, deletion evacuation, nested logical roots, exact Git slug resolution, and workspace creation races. Validated with server typecheck and the full 237-test server suite. --- packages/server/src/api-types.ts | 14 + .../src/server/routes/worktrees.test.ts | 146 +++++++ .../server/src/server/routes/worktrees.ts | 120 +++++- .../__tests__/git-worktrees.test.ts | 31 +- .../server/src/workspaces/git-worktrees.ts | 59 ++- .../server/src/workspaces/manager.test.ts | 20 + packages/server/src/workspaces/manager.ts | 25 ++ .../project-session-families.test.ts | 186 +++++++++ .../workspaces/project-session-families.ts | 369 ++++++++++++++++++ .../src/workspaces/worktree-directory.ts | 4 + 10 files changed, 932 insertions(+), 42 deletions(-) create mode 100644 packages/server/src/server/routes/worktrees.test.ts create mode 100644 packages/server/src/workspaces/project-session-families.test.ts create mode 100644 packages/server/src/workspaces/project-session-families.ts diff --git a/packages/server/src/api-types.ts b/packages/server/src/api-types.ts index ca9b1a0a4..d9a08974e 100644 --- a/packages/server/src/api-types.ts +++ b/packages/server/src/api-types.ts @@ -93,9 +93,13 @@ export interface WorktreeDescriptor { slug: string /** Absolute directory path on the server host. */ directory: 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 +114,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 new file mode 100644 index 000000000..53bb96c6d --- /dev/null +++ b/packages/server/src/server/routes/worktrees.test.ts @@ -0,0 +1,146 @@ +import assert from "node:assert/strict" +import { execFileSync } from "node:child_process" +import { mkdirSync, mkdtempSync, rmSync } from "node:fs" +import { tmpdir } from "node:os" +import path from "node:path" +import { describe, it } from "node:test" +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("resolves a session move target from the exact Git slug and ignores client paths", async () => { + const temp = mkdtempSync(path.join(tmpdir(), "codenomad-worktree-route-")) + const repo = path.join(temp, "repo") + const linked = path.join(temp, "feature-worktree") + const app = Fastify({ logger: false }) + + try { + mkdirSync(repo, { recursive: true }) + execFileSync("git", ["init", "-b", "main", repo], { 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: "root-session", + projectID: "project", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1, updated: 1 }, + location: { directory: repo }, + } + const locationCalls: string[] = [] + const moveCalls: Array<{ sessionID: string; directory: string; workspaceID?: string }> = [] + const client = { + location: { + get: async ({ location }: { location?: { directory?: string } }) => { + const directory = location?.directory ?? repo + locationCalls.push(directory) + return { + directory, + workspaceID: path.resolve(directory) === path.resolve(linked) ? "native-feature" : undefined, + project: { id: "project", directory: repo, canonical: repo }, + } + }, + }, + session: { + list: async () => ({ data: [structuredClone(current)], cursor: {} }), + active: async () => ({}), + move: async (input: { sessionID: string; directory: string; workspaceID?: string }) => { + moveCalls.push(input) + current.location = { directory: input.directory, workspaceID: input.workspaceID } + }, + get: async () => structuredClone(current), + }, + } as unknown as OpenCodeClient + const workspace: WorkspaceDescriptor = { + id: "workspace", + path: repo, + status: "ready", + proxyPath: "/workspaces/workspace/instance", + binaryId: "opencode", + binaryLabel: "opencode", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + } + const manager = { + get: (id: string) => id === workspace.id ? workspace : undefined, + reserveWorktreeDeletion: async () => () => {}, + getSharedServiceClient: async () => client, + } as unknown as WorkspaceManager + registerWorktreeRoutes(app, { workspaceManager: manager }) + + const response = await app.inject({ + method: "POST", + url: "/api/workspaces/workspace/sessions/root-session/worktree", + payload: { worktreeSlug: "feature", directory: "C:/evil", workspaceID: "evil" }, + }) + + assert.equal(response.statusCode, 200) + assert.equal(path.resolve(locationCalls[1] ?? ""), path.resolve(linked)) + assert.equal(path.resolve(moveCalls[0]?.directory ?? ""), path.resolve(linked)) + assert.equal(moveCalls[0]?.workspaceID, "native-feature") + } finally { + await app.close() + rmSync(temp, { recursive: true, force: true }) + } + }) + + it("refuses to remove a worktree open as another workspace", async () => { + const temp = mkdtempSync(path.join(tmpdir(), "codenomad-worktree-route-")) + const repo = path.join(temp, "repo") + const linked = path.join(temp, "feature-worktree") + const app = Fastify({ logger: false }) + + try { + mkdirSync(repo, { recursive: true }) + execFileSync("git", ["init", "-b", "main", repo], { 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 workspaceFolder = path.join(repo, "apps", "web") + const linkedWorkspaceFolder = path.join(linked, "apps", "web") + mkdirSync(workspaceFolder, { recursive: true }) + mkdirSync(linkedWorkspaceFolder, { recursive: true }) + const workspace = workspaceDescriptor("workspace", workspaceFolder) + const linkedWorkspace = workspaceDescriptor("linked-workspace", linkedWorkspaceFolder) + const manager = { + get: (id: string) => id === workspace.id ? workspace : undefined, + list: () => [workspace, linkedWorkspace], + reserveWorktreeDeletion: async () => { + throw new Error("Worktree is open as another workspace") + }, + getSharedServiceClient: async () => { + throw new Error("OpenCode client must not be requested") + }, + } 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.deepEqual(response.json(), { error: "Worktree is open as another workspace" }) + } finally { + await app.close() + rmSync(temp, { recursive: true, force: true }) + } + }) +}) + +function workspaceDescriptor(id: string, directory: string): WorkspaceDescriptor { + return { + id, + path: directory, + status: "ready", + proxyPath: `/workspaces/${id}/instance`, + binaryId: "opencode", + binaryLabel: "opencode", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + } +} diff --git a/packages/server/src/server/routes/worktrees.ts b/packages/server/src/server/routes/worktrees.ts index bf237d4b9..6bd7b7803 100644 --- a/packages/server/src/server/routes/worktrees.ts +++ b/packages/server/src/server/routes/worktrees.ts @@ -8,8 +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 { invalidateWorktreeDirectoryCache } from "../../workspaces/worktree-directory" +import { + moveProjectSessionFamily, + ProjectSessionError, + removeProjectWorktree, +} from "../../workspaces/project-session-families" interface RouteDeps { workspaceManager: WorkspaceManager @@ -20,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) @@ -73,6 +87,7 @@ export function registerWorktreeRoutes(app: FastifyInstance, deps: RouteDeps) { slug, logger: request.log, }) + invalidateWorktreeDirectoryCache(workspace.id) reply.code(201) return created @@ -81,6 +96,51 @@ 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 moved = await moveProjectSessionFamily({ + client: await deps.workspaceManager.getSharedServiceClient(), + projectDirectory: workspace.path, + sessionId: request.params.sessionId, + targetDirectory: target.directory, + 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) + }, + }) + 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) => { @@ -105,14 +165,60 @@ 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" } } + 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) + } - await removeWorktree({ workspaceFolder: workspace.path, directory: match.directory, force, logger: request.log }) + try { + const client = await deps.workspaceManager.getSharedServiceClient() + await removeProjectWorktree({ + client, + projectDirectory: workspace.path, + targetDirectory: match.registeredDirectory ?? match.directory, + rootDirectory: worktrees.find((worktree) => worktree.kind === "root")!.directory, + remove: async () => { + try { + await removeWorktree({ + workspaceFolder: workspace.path, + directory: match.registeredDirectory ?? match.directory, + force, + logger: request.log, + }) + } catch (error) { + throw new ProjectSessionError(error instanceof Error ? error.message : "Unable to remove worktree", 409) + } + }, + 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) + }, + }) + invalidateWorktreeDirectoryCache(workspace.id) + } finally { + releaseDeletion() + } reply.code(204) } catch (error) { @@ -122,7 +228,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/__tests__/git-worktrees.test.ts b/packages/server/src/workspaces/__tests__/git-worktrees.test.ts index bc6382a1c..8359e9b69 100644 --- a/packages/server/src/workspaces/__tests__/git-worktrees.test.ts +++ b/packages/server/src/workspaces/__tests__/git-worktrees.test.ts @@ -1,5 +1,6 @@ import assert from "node:assert/strict" -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs" +import { execFileSync } from "node:child_process" +import { mkdirSync, mkdtempSync, rmSync } from "node:fs" import { tmpdir } from "node:os" import path from "node:path" import { describe, it } from "node:test" @@ -8,30 +9,15 @@ import { listWorktrees } from "../git-worktrees" describe("listWorktrees", () => { it("uses the selected workspace folder for the root worktree directory", async () => { const temp = mkdtempSync(path.join(tmpdir(), "codenomad-git-worktrees-")) - const binDir = path.join(temp, "bin") const repoRoot = path.join(temp, "repo") const workspaceFolder = path.join(repoRoot, "proj-1") - const originalPath = process.env.PATH + const linkedDirectory = path.join(temp, "feature-worktree") try { - mkdirSync(binDir, { recursive: true }) mkdirSync(workspaceFolder, { recursive: true }) - - const gitPath = path.join(binDir, process.platform === "win32" ? "git.cmd" : "git") - const porcelain = [ - `worktree ${repoRoot}`, - "HEAD 1111111", - "branch refs/heads/main", - "", - ].join("\n") - - if (process.platform === "win32") { - writeFileSync(gitPath, `@echo off\r\nif "%1"=="worktree" if "%2"=="list" if "%3"=="--porcelain" (\r\necho ${porcelain.replace(/\n/g, "\r\necho ")}\r\nexit /b 0\r\n)\r\nexit /b 1\r\n`) - } else { - writeFileSync(gitPath, `#!/bin/sh\nif [ "$1" = "worktree" ] && [ "$2" = "list" ] && [ "$3" = "--porcelain" ]; then\nprintf '%s\n' '${porcelain.replace(/'/g, "'\\''")}'\nexit 0\nfi\nexit 1\n`, { mode: 0o755 }) - } - - process.env.PATH = `${binDir}${path.delimiter}${originalPath ?? ""}` + execFileSync("git", ["init", "-b", "main", repoRoot], { stdio: "ignore" }) + execFileSync("git", ["-C", repoRoot, "-c", "user.name=CodeNomad", "-c", "user.email=test@example.com", "commit", "--allow-empty", "-m", "init"], { stdio: "ignore" }) + execFileSync("git", ["-C", repoRoot, "worktree", "add", "-b", "feature", linkedDirectory], { stdio: "ignore" }) const worktrees = await listWorktrees({ repoRoot, workspaceFolder }) @@ -39,9 +25,12 @@ describe("listWorktrees", () => { assert.equal(worktrees[0]?.directory, workspaceFolder) assert.equal(worktrees[0]?.kind, "root") assert.equal(worktrees[0]?.branch, "main") + assert.equal(path.resolve(worktrees[0]?.registeredDirectory ?? ""), path.resolve(repoRoot)) assert.notEqual(worktrees[0]?.directory, repoRoot) + const linked = worktrees.find(({ slug }) => slug === "feature") + assert.equal(path.resolve(linked?.directory ?? ""), path.resolve(linkedDirectory, "proj-1")) + assert.equal(path.resolve(linked?.registeredDirectory ?? ""), path.resolve(linkedDirectory)) } finally { - process.env.PATH = originalPath rmSync(temp, { recursive: true, force: true }) } }) 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 720e90d46..b6a7038c8 100644 --- a/packages/server/src/workspaces/manager.test.ts +++ b/packages/server/src/workspaces/manager.test.ts @@ -12,6 +12,7 @@ import { import type { OpenCodeEnsureOptions } from "./opencode-service" import path from "node:path" import os from "node:os" +import { mkdirSync, mkdtempSync, rmSync } from "node:fs" function deferred() { let resolve!: (value: T) => void @@ -178,6 +179,25 @@ describe("workspace manager shared service lifecycle", () => { assert.deepEqual(harness.service.evictions, [{ directory: process.cwd() }]) }) + it("blocks workspace creation beneath a reserved worktree deletion", async () => { + const temp = mkdtempSync(path.join(os.tmpdir(), "codenomad-worktree-reservation-")) + const worktree = path.join(temp, "worktree") + const nested = path.join(worktree, "apps", "web") + mkdirSync(nested, { recursive: true }) + const harness = createHarness() + + try { + const release = await harness.manager.reserveWorktreeDeletion(worktree) + await assert.rejects(() => harness.manager.create(nested), /being removed/) + release() + const created = await harness.manager.create(nested) + assert.equal(created.workspace.path, nested) + await harness.manager.delete(created.workspace.id) + } finally { + rmSync(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 f0cd75a5f..cb7215f75 100644 --- a/packages/server/src/workspaces/manager.ts +++ b/packages/server/src/workspaces/manager.ts @@ -125,6 +125,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 @@ -165,6 +166,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 workspace = this.get(id) if (!workspace) return false @@ -269,6 +282,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`) } @@ -693,3 +709,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..8c184098a --- /dev/null +++ b/packages/server/src/workspaces/project-session-families.test.ts @@ -0,0 +1,186 @@ +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[] + failMove?: (sessionId: string, call: number) => boolean + moveGate?: (sessionId: string) => Promise +} = {}) { + const sessions = new Map(initial.map((value) => [value.id, structuredClone(value)])) + const moveCalls: string[] = [] + const listCalls: Array<{ cursor?: string; project?: string; order?: string }> = [] + let moveCall = 0 + const client = { + location: { + get: async ({ location: value }: { location?: { directory?: string } }) => ({ + directory: value?.directory ?? ROOT, + project: { id: "project", directory: ROOT, canonical: ROOT }, + }), + }, + session: { + list: async (input: { cursor?: string; project?: string; order?: string }) => { + listCalls.push(input) + return { data: Array.from(sessions.values()).map((value) => structuredClone(value)), cursor: {} } + }, + active: async () => Object.fromEntries((options.active ?? []).map((id) => [id, { type: "running" as const }])), + get: async ({ sessionID }: { sessionID: string }) => 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}`) + await options.moveGate?.(sessionID) + sessions.get(sessionID)!.location = { directory, workspaceID } + }, + }, + } as unknown as OpenCodeClient + return { client, sessions, moveCalls, listCalls } +} + +describe("project session families", () => { + it("loads every cursor page and rejects repeated cursors", async () => { + const first = session("root") + const second = session("child", "root") + let call = 0 + const paged = { + session: { + list: async () => ++call === 1 + ? { data: [first], cursor: { next: "next" } } + : { data: [second], cursor: {} }, + }, + } as unknown as OpenCodeClient + assert.deepEqual((await listCompleteProjectSessions(paged, "project")).map(({ id }) => id), ["root", "child"]) + assert.equal(call, 2) + + const repeated = { + session: { list: async () => ({ data: [], cursor: { next: "same" } }) }, + } as unknown as OpenCodeClient + await assert.rejects(() => listCompleteProjectSessions(repeated, "project"), /repeated cursor/) + }) + + it("resolves complete root and descendant families and fails closed on missing parents and cycles", () => { + assert.deepEqual( + Array.from(resolveSessionFamilies([session("child", "root"), session("root")]).entries()) + .map(([root, members]) => [root, members.map(({ id }) => id)]), + [["root", ["root", "child"]]], + ) + assert.throws(() => resolveSessionFamilies([session("child", "missing")]), /missing parent/) + assert.throws(() => resolveSessionFamilies([session("a", "b"), session("b", "a")]), /cycle/) + }) + + it("moves root and descendants sequentially and verifies authoritative locations", async () => { + const harness = clientHarness([session("child", "root"), session("root")]) + const result = await moveProjectSessionFamily({ + client: harness.client, + projectDirectory: ROOT, + sessionId: "child", + targetDirectory: WORKTREE, + }) + assert.deepEqual(result, { rootSessionId: "root", sessionIds: ["root", "child"] }) + assert.ok(harness.listCalls.every(({ order }) => order === "asc")) + assert.deepEqual(harness.moveCalls, ["root", "child"]) + assert.equal(harness.sessions.get("root")?.location.directory, WORKTREE) + assert.equal(harness.sessions.get("child")?.location.directory, WORKTREE) + }) + + it("refreshes and rolls back after a partial move 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) + assert.ok(harness.listCalls.length >= 2) + }) + + it("serializes concurrent operations for the same project", async () => { + let release!: () => void + let firstMoveStarted!: () => void + const started = new Promise((resolve) => { firstMoveStarted = resolve }) + const gate = new Promise((resolve) => { release = resolve }) + let held = true + const harness = clientHarness([session("root")], { + moveGate: async () => { + if (!held) return + firstMoveStarted() + await gate + held = false + }, + }) + const first = moveProjectSessionFamily({ client: harness.client, projectDirectory: ROOT, sessionId: "root", targetDirectory: WORKTREE }) + await started + const second = moveProjectSessionFamily({ client: harness.client, projectDirectory: ROOT, sessionId: "root", targetDirectory: ROOT }) + await new Promise((resolve) => setTimeout(resolve, 10)) + assert.deepEqual(harness.moveCalls, ["root"]) + release() + await Promise.all([first, second]) + assert.deepEqual(harness.moveCalls, ["root", "root"]) + }) + + it("evacuates attached families before deletion and blocks active sessions", 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.deepEqual(harness.moveCalls, ["root", "child"]) + + const active = clientHarness([session("blocked", undefined, WORKTREE)], { active: ["blocked"] }) + await assert.rejects(() => removeProjectWorktree({ + client: active.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(active.moveCalls, []) + }) + + it("rolls sessions back when Git removal fails while the worktree remains registered", async () => { + const harness = clientHarness([session("root", undefined, WORKTREE)]) + await assert.rejects(() => removeProjectWorktree({ + client: harness.client, + projectDirectory: ROOT, + targetDirectory: WORKTREE, + rootDirectory: ROOT, + remove: async () => { throw new ProjectSessionError("dirty worktree", 409) }, + isTargetRegistered: async () => true, + }), /dirty worktree/) + assert.deepEqual(harness.moveCalls, ["root", "root"]) + assert.equal(harness.sessions.get("root")?.location.directory, WORKTREE) + }) +}) 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..465ba4ebb --- /dev/null +++ b/packages/server/src/workspaces/project-session-families.ts @@ -0,0 +1,369 @@ +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 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 sessionIds = new Set() + 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, order: "asc", 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) + } + if (sessionIds.has(session.id)) { + throw new ProjectSessionError(`Session inventory contains duplicate session: ${session.id}`, 409) + } + sessionIds.add(session.id) + sessions.push(session) + } + + const next = response.cursor.next || undefined + 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]) + 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) { + root = await resolveProjectLocation(context, params.rootDirectory) + const destination = root + 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) + } + } + if (!await params.isTargetRegistered()) { + throw new ProjectSessionError("Worktree changed before deletion", 409) + } + 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, + ) + } + 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) { + moved.push(session.id) + await context.client.session.move({ + sessionID: session.id, + directory: target.directory, + workspaceID: target.workspaceID, + }) + const current = await context.client.session.get({ sessionID: session.id }) + if (current.id !== session.id || current.projectID !== context.project.id) { + throw new ProjectSessionError(`OpenCode returned the wrong session after move: ${session.id}`, 502) + } + assertLocation(current, target, `Session move verification failed: ${session.id}`) + } +} + +async function refreshChangedSessionIds( + context: ProjectContext, + candidates: string[], + transactionLocation: LocationRef, +): Promise { + try { + const refreshed = new Map((await listCompleteProjectSessions(context.client, context.project.id)).map((session) => [session.id, session])) + return candidates.filter((id) => { + const session = refreshed.get(id) + return Boolean(session && sameLocation(session.location, transactionLocation)) + }) + } catch { + 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 }) + 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 rollback: ${sessionId}`, 502) + } + assertLocation(session, 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 { + const sessions = await listCompleteProjectSessions(context.client, context.project.id) + const refreshed = new Map(sessions.map((session) => [session.id, session])) + for (const sessionId of sessionIds) { + const session = refreshed.get(sessionId) + if (!session) throw new ProjectSessionError(`Session disappeared during verification: ${sessionId}`, 409) + assertLocation(session, expected.get(sessionId)!, `Session inventory verification failed: ${sessionId}`) + } + return sessions +} + +function assertLocation(session: SessionInfo, expected: LocationRef, message: string): void { + if (!sameLocation(session.location, expected)) { + 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 { + const leftPath = path.resolve(left) + const rightPath = path.resolve(right) + return process.platform === "win32" ? leftPath.toLowerCase() === rightPath.toLowerCase() : leftPath === rightPath +} + +function directoryContains(parent: string, child: string): boolean { + const relative = path.relative(parent, child) + return relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) +} + +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 144aed98d..cb8c350e4 100644 --- a/packages/server/src/workspaces/worktree-directory.ts +++ b/packages/server/src/workspaces/worktree-directory.ts @@ -11,6 +11,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 "" From da6378382fd406560e6959bb3fdcd400691901a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Thu, 13 Aug 2026 00:13:03 +0200 Subject: [PATCH 2/7] feat(ui): organize sessions by worktree Treat SessionInfo.location as the authoritative worktree assignment, load every project-scoped cursor page, and project complete session families for search, filtering, and activity/name/worktree sorting. Family moves now run through the server transaction and refresh from OpenCode instead of mutating local paths optimistically. Show localized root and linked-worktree badges, preserve complete ancestry during filters, refresh worktrees and sessions after deletion, and apply the complete authoritative session.moved payload. Expose the desktop file-manager action only for local paths with keyboard support. Add coverage for project query construction, path normalization, family projection, native move events, request authority, serialized family moves, and deletion refresh; register the new runnable tests in CI. Validated with UI typecheck, 35 affected CI-mode tests, and production builds. --- .github/workflows/pr-build.yml | 4 + packages/ui/src/components/session-list.tsx | 89 ++++++++++------ .../ui/src/components/worktree-selector.tsx | 51 ++++++++- packages/ui/src/lib/api-client.ts | 9 ++ .../ui/src/lib/i18n/messages/de/instance.ts | 2 + .../ui/src/lib/i18n/messages/de/session.ts | 9 ++ .../ui/src/lib/i18n/messages/en/instance.ts | 2 + .../ui/src/lib/i18n/messages/en/session.ts | 9 ++ .../ui/src/lib/i18n/messages/es/instance.ts | 2 + .../ui/src/lib/i18n/messages/es/session.ts | 9 ++ .../ui/src/lib/i18n/messages/fr/instance.ts | 2 + .../ui/src/lib/i18n/messages/fr/session.ts | 9 ++ .../ui/src/lib/i18n/messages/he/instance.ts | 2 + .../ui/src/lib/i18n/messages/he/session.ts | 9 ++ .../ui/src/lib/i18n/messages/ja/instance.ts | 2 + .../ui/src/lib/i18n/messages/ja/session.ts | 9 ++ .../ui/src/lib/i18n/messages/ne/instance.ts | 2 + .../ui/src/lib/i18n/messages/ne/session.ts | 9 ++ .../ui/src/lib/i18n/messages/ru/instance.ts | 2 + .../ui/src/lib/i18n/messages/ru/session.ts | 9 ++ .../src/lib/i18n/messages/zh-Hans/instance.ts | 2 + .../src/lib/i18n/messages/zh-Hans/session.ts | 9 ++ packages/ui/src/lib/native/client-state.ts | 12 ++- packages/ui/src/lib/sse-manager.ts | 2 + packages/ui/src/stores/session-actions.ts | 9 -- packages/ui/src/stores/session-api.ts | 37 +++++-- packages/ui/src/stores/session-events.ts | 12 +++ .../src/stores/session-list-options.test.ts | 18 ++++ .../ui/src/stores/session-list-options.ts | 19 ++-- .../src/stores/session-native-events.test.ts | 19 ++++ .../ui/src/stores/session-pagination.test.ts | 29 +++-- .../stores/session-request-authority.test.ts | 17 +-- packages/ui/src/stores/session-state.ts | 5 +- packages/ui/src/stores/session-tree.test.ts | 33 ++++++ packages/ui/src/stores/session-tree.ts | 41 +++++++ packages/ui/src/stores/worktree-ready.test.ts | 74 ++++++++++++- packages/ui/src/stores/worktrees.ts | 100 +++++++++++------- packages/ui/src/types/global.d.ts | 1 + 38 files changed, 551 insertions(+), 129 deletions(-) create mode 100644 packages/ui/src/stores/session-list-options.test.ts diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index a87e16744..84e18163e 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -126,7 +126,9 @@ jobs: packages/ui/src/stores/message-v2/message-status.test.ts packages/ui/src/stores/message-v2/normalizers.test.ts packages/ui/src/stores/session-generation-recovery.test.ts + packages/ui/src/stores/session-list-options.test.ts packages/ui/src/stores/session-pagination.test.ts + packages/ui/src/stores/session-tree.test.ts packages/ui/src/types/session.test.ts packages/ui/src/stores/workspace-list-reconciliation-fence.test.ts @@ -137,8 +139,10 @@ jobs: packages/ui/src/stores/instances-restore-ownership.test.ts packages/ui/src/stores/permission-lifecycle.test.ts packages/ui/src/stores/session-actions.test.ts + packages/ui/src/stores/session-native-events.test.ts packages/ui/src/stores/session-request-authority.test.ts packages/ui/src/stores/session-send-lifecycle.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/ui/src/components/session-list.tsx b/packages/ui/src/components/session-list.tsx index db1549432..81f6186e6 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.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}>
+
diff --git a/packages/ui/src/stores/worktrees.ts b/packages/ui/src/stores/worktrees.ts index 154f5b7c8..834b98dc8 100644 --- a/packages/ui/src/stores/worktrees.ts +++ b/packages/ui/src/stores/worktrees.ts @@ -177,7 +177,7 @@ function normalizeDirectory(directory: string): string { function getWorktreeSlugForParentSession(instanceId: string, parentSessionId: string): string { const directory = sessions().get(instanceId)?.get(parentSessionId)?.location.directory const locationSlug = directory && getWorktrees(instanceId) - .find((worktree) => normalizeDirectory(worktree.directory) === normalizeDirectory(directory))?.slug + .find((worktree) => normalizeDirectory(worktree.serviceDirectory ?? worktree.directory) === normalizeDirectory(directory))?.slug if (locationSlug) return normalizeWorktreeSlug(instanceId, locationSlug) return "root" From 0238009e538fe665c170d2ea7dcf785d5733e6fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Wed, 19 Aug 2026 09:05:15 +0200 Subject: [PATCH 5/7] fix(worktrees): close nested and WSL rollback gaps Resolve OpenCode targets from mirrored nested workspace paths while preserving physical roots for Git operations. Determine rollback ownership from per-session state and compare POSIX service paths with case-sensitive semantics on Windows hosts. Add nested-route, stale-inventory rollback, and WSL case-sensitivity regressions; validate server typecheck, 20 focused tests, and diff cleanliness. --- .../src/server/routes/worktrees.test.ts | 24 ++++++---- .../server/src/server/routes/worktrees.ts | 2 +- .../project-session-families.test.ts | 30 ++++++++++++ .../workspaces/project-session-families.ts | 48 +++++++++---------- 4 files changed, 70 insertions(+), 34 deletions(-) diff --git a/packages/server/src/server/routes/worktrees.test.ts b/packages/server/src/server/routes/worktrees.test.ts index 548acc208..a8351986a 100644 --- a/packages/server/src/server/routes/worktrees.test.ts +++ b/packages/server/src/server/routes/worktrees.test.ts @@ -15,11 +15,16 @@ it("reserves the physical worktree and rejects a HEAD change immediately before 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" }) @@ -29,15 +34,15 @@ it("reserves the physical worktree and rejects a HEAD change immediately before cost: 0, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, time: { created: 1, updated: 1 }, - location: { directory: linked, workspaceID: "native-feature" }, + location: { directory: linkedWorkspacePath, workspaceID: "native-feature" }, } let lists = 0 const client = { location: { get: async ({ location }: { location?: { directory?: string } }) => ({ - directory: location?.directory ?? repo, - workspaceID: path.resolve(location?.directory ?? repo) === path.resolve(linked) ? "native-feature" : undefined, - project: { id: "project", directory: repo, canonical: repo }, + directory: location?.directory ?? workspacePath, + workspaceID: path.resolve(location?.directory ?? workspacePath) === path.resolve(linkedWorkspacePath) ? "native-feature" : undefined, + project: { id: "project", directory: workspacePath, canonical: workspacePath }, }), }, session: { @@ -59,7 +64,7 @@ it("reserves the physical worktree and rejects a HEAD change immediately before const manager = { get: () => ({ id: "workspace", - path: repo, + path: workspacePath, status: "ready", proxyPath: "/workspaces/workspace/instance", binaryId: "opencode", @@ -72,8 +77,11 @@ it("reserves the physical worktree and rejects a HEAD change immediately before return () => { released = true } }, getSharedServiceClient: async () => client, - getServiceDirectory: () => repo, - getServiceDirectoryForPath: async (_id: string, directory: string) => directory, + 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 }) @@ -82,7 +90,7 @@ it("reserves the physical worktree and rejects a HEAD change immediately before 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(repo)) + 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 { diff --git a/packages/server/src/server/routes/worktrees.ts b/packages/server/src/server/routes/worktrees.ts index 8611fc824..2937c7e82 100644 --- a/packages/server/src/server/routes/worktrees.ts +++ b/packages/server/src/server/routes/worktrees.ts @@ -196,7 +196,7 @@ export function registerWorktreeRoutes(app: FastifyInstance, deps: RouteDeps) { 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, targetHostDirectory), + deps.workspaceManager.getServiceDirectoryForPath(workspace.id, match.directory), deps.workspaceManager.getServiceDirectoryForPath(workspace.id, rootHostDirectory), ]) if (!projectDirectory || !targetDirectory || !rootDirectory) { diff --git a/packages/server/src/workspaces/project-session-families.test.ts b/packages/server/src/workspaces/project-session-families.test.ts index 821a3f4b2..8c3631c7e 100644 --- a/packages/server/src/workspaces/project-session-families.test.ts +++ b/packages/server/src/workspaces/project-session-families.test.ts @@ -158,6 +158,36 @@ describe("project session families", () => { 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 diff --git a/packages/server/src/workspaces/project-session-families.ts b/packages/server/src/workspaces/project-session-families.ts index c6799fa97..fe175489b 100644 --- a/packages/server/src/workspaces/project-session-families.ts +++ b/packages/server/src/workspaces/project-session-families.ts @@ -260,29 +260,21 @@ async function refreshChangedSessionIds( candidates: string[], transactionLocation: LocationRef, ): Promise { - try { - const refreshed = new Map((await listCompleteProjectSessions(context.client, context.project.id)).map((session) => [session.id, session])) - return candidates.filter((id) => { - const session = refreshed.get(id) - return Boolean(session && sameLocation(session.location, transactionLocation)) - }) - } catch { - 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) + 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 } + return changed } async function rollback( @@ -355,14 +347,20 @@ function ancestryDepth(session: SessionInfo, byId: Map): nu } function sameDirectory(left: string, right: string): boolean { - const leftPath = path.resolve(left) - const rightPath = path.resolve(right) - return process.platform === "win32" ? leftPath.toLowerCase() === rightPath.toLowerCase() : leftPath === rightPath + 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 { - const relative = path.relative(parent, child) - return relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) + 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 { From ab9f5d8c67db3ff4493aef933841533bdec7c60d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Mon, 31 Aug 2026 01:34:53 +0200 Subject: [PATCH 6/7] fix(worktrees): keep selector actions accessible Move copy, open, and delete controls out of Kobalte Select.Item options into a toolbar for the selected worktree. This removes nested interactive controls and custom event suppression while preserving mouse and keyboard activation. Validated with the UI typecheck and production UI build. --- .../ui/src/components/worktree-selector.tsx | 112 +++++++----------- 1 file changed, 42 insertions(+), 70 deletions(-) diff --git a/packages/ui/src/components/worktree-selector.tsx b/packages/ui/src/components/worktree-selector.tsx index b645135aa..447e0ab5f 100644 --- a/packages/ui/src/components/worktree-selector.tsx +++ b/packages/ui/src/components/worktree-selector.tsx @@ -34,14 +34,6 @@ type DeleteErrorDetails = { nextStep: string } -function preventSelectPress(event: PointerEvent | MouseEvent) { - // Prevent Select.Item from treating this as a selection. - // We intentionally prevent default to stop Kobalte's internal press handling. - event.preventDefault() - event.stopImmediatePropagation?.() - event.stopPropagation() -} - function normalizePath(input: string): string { return (input ?? "").replace(/\\/g, "/").replace(/\/+$/, "") } @@ -170,6 +162,10 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) { // Fallback to root if mapped slug is missing. return worktreeOptions().find((opt) => opt.kind === "worktree" && opt.slug === "root") }) + const selectedWorktree = createMemo(() => { + const option = selectedOption() + return option?.kind === "worktree" ? option : undefined + }) const openDeleteDialog = (opt: WorktreeOption & { kind: "worktree" }) => { if (opt.slug === "root") return @@ -342,25 +338,6 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) { {opt.slug === "root" ? "Workspace" : opt.slug} - - -
{displayPathFor(opt.directory)} - - - -
@@ -452,6 +386,44 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) { + + {(worktree) => ( +
+ + + + + + + +
+ )} +
+ !open && setCreateOpen(false)}> From 9c077dc8420fcf5668dd16dc2abbdcd3a817efe4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Mon, 31 Aug 2026 03:22:39 +0200 Subject: [PATCH 7/7] fix(worktrees): finalize native V2 session safety Make worktree deletion fail closed across complete native session families, shared Shell and PTY locations, concurrent workspace ownership, and final Git identity checks. Preserve rollback safety and WSL path semantics while keeping the OpenCode V2 service lifecycle native. Make the selector actions explicit and accessible with localized labels, and resynchronize worktrees and moved-session catalogs after reconnects and foreground recovery. Exhaust every session page for global sorting while fencing superseded pagination and keeping SDK metadata separate from local runtime state. Cover mixed-workspace families, late blockers, Git replacement races, WSL case sensitivity, multi-page sorting, superseded refreshes, and native metadata separation. Validated server, UI, Electron, and Rust suites plus typechecks, builds, lock integrity, and desktop resource closure. --- package-lock.json | 37 +++--- .../src/server/routes/worktrees.test.ts | 27 +++++ .../server/src/server/routes/worktrees.ts | 52 ++++++-- .../server/src/workspaces/manager.test.ts | 12 ++ packages/server/src/workspaces/manager.ts | 12 ++ .../project-session-families.test.ts | 81 +++++++++++-- .../workspaces/project-session-families.ts | 59 +++++---- packages/ui/src/App.tsx | 2 + packages/ui/src/components/session-list.tsx | 5 +- .../ui/src/components/worktree-selector.tsx | 113 ++++++++++++------ .../ui/src/lib/i18n/messages/de/instance.ts | 14 +++ .../ui/src/lib/i18n/messages/en/instance.ts | 14 +++ .../ui/src/lib/i18n/messages/es/instance.ts | 14 +++ .../ui/src/lib/i18n/messages/fr/instance.ts | 14 +++ .../ui/src/lib/i18n/messages/he/instance.ts | 14 +++ .../ui/src/lib/i18n/messages/ja/instance.ts | 14 +++ .../ui/src/lib/i18n/messages/ne/instance.ts | 14 +++ .../ui/src/lib/i18n/messages/ru/instance.ts | 14 +++ .../ui/src/lib/i18n/messages/tr/instance.ts | 14 +++ .../src/lib/i18n/messages/zh-Hans/instance.ts | 14 +++ packages/ui/src/stores/instances.ts | 1 + packages/ui/src/stores/session-api.ts | 44 ++++++- packages/ui/src/stores/session-events.ts | 24 +++- .../stores/session-request-authority.test.ts | 56 ++++++++- packages/ui/src/stores/sessions.ts | 2 + packages/ui/src/types/session.test.ts | 2 + packages/ui/src/types/session.ts | 7 +- 27 files changed, 570 insertions(+), 106 deletions(-) diff --git a/package-lock.json b/package-lock.json index f877b0710..37bfb270d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3467,16 +3467,16 @@ } }, "node_modules/@opencode-ai/client": { - "version": "0.0.0-beta-18414", - "resolved": "https://registry.npmjs.org/@opencode-ai/client/-/client-0.0.0-beta-18414.tgz", - "integrity": "sha512-G/0UtYjhDq/fkmqFz60yCwlmUB8HTHUTNA0GlfTHzKt4t/sQ1+wqhPG61FWV6UsIvPOB6jWXtYqs5aeKCM+2fA==", + "version": "0.0.0-beta-18684", + "resolved": "https://registry.npmjs.org/@opencode-ai/client/-/client-0.0.0-beta-18684.tgz", + "integrity": "sha512-Qjq07sTId3gZcqqMsPg+gtqU3PXneOWje+BSzBXepimAfPNqL0aIpH+em2Y4KL3lxEuJK3vdfyx8UXI8gzGRlQ==", "license": "MIT", "dependencies": { - "@opencode-ai/protocol": "0.0.0-beta-18414", - "@opencode-ai/schema": "0.0.0-beta-18414" + "@opencode-ai/protocol": "0.0.0-beta-18684", + "@opencode-ai/schema": "0.0.0-beta-18684" }, "peerDependencies": { - "effect": "4.0.0-rc.111", + "effect": "4.0.0-rc.112", "solid-js": ">=1.9.0" }, "peerDependenciesMeta": { @@ -3489,23 +3489,23 @@ } }, "node_modules/@opencode-ai/protocol": { - "version": "0.0.0-beta-18414", - "resolved": "https://registry.npmjs.org/@opencode-ai/protocol/-/protocol-0.0.0-beta-18414.tgz", - "integrity": "sha512-TeckyJdHDgt02/ctNV1Bg86tw1Wkc0OxgGjki/lhv7xWwiskAkgTvpcxA9zFa7QNftVLr0wU+tmibca99aC4hA==", + "version": "0.0.0-beta-18684", + "resolved": "https://registry.npmjs.org/@opencode-ai/protocol/-/protocol-0.0.0-beta-18684.tgz", + "integrity": "sha512-BXeauVOxxcam0zgOxJcpkmoQQTiqq3L6l3i8qF/Wsa3iPf7cA1SCM6+3r6iJ/9akckEEi9ZL85M3b8NhOcU85A==", "license": "MIT", "dependencies": { - "@opencode-ai/schema": "0.0.0-beta-18414", - "effect": "4.0.0-rc.111" + "@opencode-ai/schema": "0.0.0-beta-18684", + "effect": "4.0.0-rc.112" } }, "node_modules/@opencode-ai/schema": { - "version": "0.0.0-beta-18414", - "resolved": "https://registry.npmjs.org/@opencode-ai/schema/-/schema-0.0.0-beta-18414.tgz", - "integrity": "sha512-fuOKoTeqQQNITmFo/Ru4+8Ltc4U9BJpq2c7oipP3Pox6L1I/9p0NN5fAb2JJ73FbBnp9KZszKZCcGYn9cTQ5TQ==", + "version": "0.0.0-beta-18684", + "resolved": "https://registry.npmjs.org/@opencode-ai/schema/-/schema-0.0.0-beta-18684.tgz", + "integrity": "sha512-aoQEG+KBSTAq7IaNVpAfXKNSvcBaRwpjPgPL0DuKoj39g013/NoR/rmbWtjxKkkczBqbWp/91zLGvXW+bIOotQ==", "license": "MIT", "dependencies": { "@standard-schema/spec": "1.1.0", - "effect": "4.0.0-rc.111" + "effect": "4.0.0-rc.112" } }, "node_modules/@pinojs/redact": { @@ -6928,12 +6928,11 @@ } }, "node_modules/effect": { - "version": "4.0.0-rc.111", - "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-rc.111.tgz", - "integrity": "sha512-ASd5L58EIR0CUNueZNKKjSsyOCd+2alxOAIaTcHaqkJkPsaYSsw5Cg/cfANk5K4Jr2YsX756xvX11shzqsreWA==", + "version": "4.0.0-rc.112", + "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-rc.112.tgz", + "integrity": "sha512-wXxwuh1Ywnv4cPRM3Wfa0vDwuOHnZ1TsTgHJkG9XgzND6inhBH9n1vBxhg3iIXOia/OrpmvVmd3lrD4vq6bF3A==", "license": "MIT", "dependencies": { - "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "msgpackr": "^2.0.5" } diff --git a/packages/server/src/server/routes/worktrees.test.ts b/packages/server/src/server/routes/worktrees.test.ts index c51c93606..f4cc28789 100644 --- a/packages/server/src/server/routes/worktrees.test.ts +++ b/packages/server/src/server/routes/worktrees.test.ts @@ -40,6 +40,10 @@ it("reserves the physical worktree and rejects a same-HEAD replacement before de let lists = 0 const client = { project: { list: async () => [{ id: "project" }] }, + debug: { location: { list: async () => [] } }, + shell: { list: async () => ({ data: [] }) }, + pty: { list: async () => ({ data: [] }) }, + experimental: { persistentPty: { list: async () => [] } }, location: { get: async ({ location }: { location?: { directory?: string } }) => ({ directory: location?.directory ?? workspacePath, @@ -120,8 +124,23 @@ it("fails a direct delete call closed when session evacuation fails", async () = 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 + let blocker: "shell" | "pty" | "persistent" | undefined = "shell" const client = { project: { list: async () => [{ id: "project" }] }, + debug: { location: { list: async () => [{ directory: path.join(temp, "unrelated"), workspaceID: "root-location" }] } }, + shell: { list: async ({ location }: { location?: { workspace?: string } }) => ({ + data: blocker === "shell" && location?.workspace === "root-location" + ? [{ id: "sh_blocker", status: "running", cwd: target }] + : [], + }) }, + pty: { list: async ({ location }: { location?: { workspace?: string } }) => ({ + data: blocker === "pty" && location?.workspace === "root-location" + ? [{ id: "pty_blocker", status: "running", cwd: target }] + : [], + }) }, + experimental: { persistentPty: { list: async () => ( + blocker === "persistent" ? [{ id: "pty_persistent", status: "running", cwd: target }] : [] + ) } }, location: { get: async ({ location }: { location?: { directory?: string } }) => ({ directory: location?.directory ?? temp, @@ -147,6 +166,14 @@ it("fails a direct delete call closed when session evacuation fails", async () = } as unknown as WorkspaceManager registerWorktreeRoutes(app, { workspaceManager: manager, worktreeDeletionFence: new WorktreeDeletionFence() }) + for (const [kind, message] of [["shell", "Running Shell"], ["pty", "Running PTY"], ["persistent", "Running persistent PTY"]] as const) { + blocker = kind + const blocked = await app.inject({ method: "DELETE", url: "/api/workspaces/workspace/worktrees/doomed" }) + assert.equal(blocked.statusCode, 409) + assert.match(blocked.json().error, new RegExp(message)) + } + blocker = undefined + const response = await app.inject({ method: "DELETE", url: "/api/workspaces/workspace/worktrees/doomed" }) assert.equal(response.statusCode, 502) diff --git a/packages/server/src/server/routes/worktrees.ts b/packages/server/src/server/routes/worktrees.ts index 389210234..9f4c175db 100644 --- a/packages/server/src/server/routes/worktrees.ts +++ b/packages/server/src/server/routes/worktrees.ts @@ -248,7 +248,7 @@ export function registerWorktreeRoutes(app: FastifyInstance, deps: RouteDeps) { rootDirectory, matchesTarget: async (directory) => servicePathContains(targetServiceRoot, directory), validateBeforeRemove: async (projectID) => { - await assertNoOtherProjectSessions(client, projectID, targetServiceRoot) + await assertNoWorktreeBlockers(client, projectID, targetServiceRoot, targetDirectory) }, runMutation: async (directories, operation) => { const identities = await Promise.all(directories.map(async (directory) => ( @@ -261,9 +261,6 @@ export function registerWorktreeRoutes(app: FastifyInstance, deps: RouteDeps) { return deps.worktreeDeletionFence.run(targetIdentity, identities as string[], operation) }, remove: async () => { - if (!await isTargetRegistered()) { - throw new ProjectSessionError("Worktree changed before deletion", 409) - } try { await removeWorktree({ workspaceFolder: workspace.path, @@ -327,17 +324,50 @@ function servicePathContains(root: string, candidate: string): boolean { return relative === "" || (relative !== ".." && !relative.startsWith(`..${servicePath.sep}`) && !servicePath.isAbsolute(relative)) } -async function assertNoOtherProjectSessions( +async function assertNoWorktreeBlockers( client: Awaited>, currentProjectID: string, targetRoot: string, + targetDirectory: string, ): Promise { - for (const project of await client.project.list()) { - if (project.id === currentProjectID) continue - const blocker = (await listCompleteProjectSessions(client, project.id)) - .find((session) => servicePathContains(targetRoot, session.location.directory)) - if (blocker) { - throw new ProjectSessionError(`Session from another project blocks deletion: ${blocker.id}`, 409) + const [nativeLocations, projects] = await Promise.all([ + client.debug.location.list(), + client.project.list(), + ]) + const locations = new Map() + const candidates: Array<{ directory: string; workspace?: string }> = [{ directory: targetDirectory }, ...nativeLocations + .map(({ directory, workspaceID }) => ({ directory, workspace: workspaceID }))] + for (const location of candidates) { + locations.set(`${location.directory}\0${location.workspace ?? ""}`, location) + } + const resources = await Promise.all([...locations.values()].map(async (location) => Promise.all([ + client.shell.list({ location }), + client.pty.list({ location }), + ]))) + const shell = resources.flatMap(([shells]) => shells.data) + .find((entry) => entry.status === "running" && servicePathContains(targetRoot, entry.cwd)) + if (shell) throw new ProjectSessionError(`Running Shell blocks deletion: ${shell.id}`, 409) + const pty = resources.flatMap(([, ptys]) => ptys.data) + .find((entry) => entry.status === "running" && servicePathContains(targetRoot, entry.cwd)) + if (pty) throw new ProjectSessionError(`Running PTY blocks deletion: ${pty.id}`, 409) + + const sessions = [] + for (const projectID of new Set([currentProjectID, ...projects.map(({ id }) => id)])) { + const projectSessions = await listCompleteProjectSessions(client, projectID) + if (projectID !== currentProjectID) { + const blocker = projectSessions.find((session) => servicePathContains(targetRoot, session.location.directory)) + if (blocker) { + throw new ProjectSessionError(`Session from another project blocks deletion: ${blocker.id}`, 409) + } + } + sessions.push(...projectSessions) + } + for (let index = 0; index < sessions.length; index += 16) { + const persistentPtys = await Promise.all(sessions.slice(index, index + 16) + .map((session) => client.experimental.persistentPty.list({ sessionID: session.id }))) + for (const persistentPty of persistentPtys) { + const persistent = persistentPty.find((entry) => entry.status === "running" && servicePathContains(targetRoot, entry.cwd)) + if (persistent) throw new ProjectSessionError(`Running persistent PTY blocks deletion: ${persistent.id}`, 409) } } } diff --git a/packages/server/src/workspaces/manager.test.ts b/packages/server/src/workspaces/manager.test.ts index 38b4e8db4..f48e4e1cc 100644 --- a/packages/server/src/workspaces/manager.test.ts +++ b/packages/server/src/workspaces/manager.test.ts @@ -160,6 +160,18 @@ describe("workspace manager shared service lifecycle", () => { ) }) + it("keeps WSL worktree reservation paths case-sensitive", { skip: process.platform !== "win32" }, async () => { + const { manager } = createHarness(new ControlledSharedService(), { platform: "win32" }) + const releaseUpper = await manager.reserveWorktreeDeletion("\\\\wsl.localhost\\Ubuntu\\repo\\Foo") + const releaseLower = await manager.reserveWorktreeDeletion("\\\\wsl.localhost\\Ubuntu\\repo\\foo") + await assert.rejects( + () => manager.reserveWorktreeDeletion("\\\\wsl.localhost\\Ubuntu\\repo\\Foo\\nested"), + /already in progress/, + ) + releaseLower() + releaseUpper() + }) + it("pins a bounded host CLI lifecycle with binary, platform, and startup environment identity", async () => { const service = new ControlledSharedService() let factoryCall: unknown[] | undefined diff --git a/packages/server/src/workspaces/manager.ts b/packages/server/src/workspaces/manager.ts index 01667c648..2a2967652 100644 --- a/packages/server/src/workspaces/manager.ts +++ b/packages/server/src/workspaces/manager.ts @@ -1064,6 +1064,13 @@ export class WorkspaceManager { } function pathContains(parent: string, child: string): boolean { + const left = parseWorktreeIdentity(parent) + const right = parseWorktreeIdentity(child) + if (left || right) { + if (!left || !right || left.distro !== right.distro) return false + const relative = path.posix.relative(left.linuxPath, right.linuxPath) + return relative === "" || (relative !== ".." && !relative.startsWith("../") && !path.posix.isAbsolute(relative)) + } const relative = path.relative(parent, child) return relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) } @@ -1071,3 +1078,8 @@ function pathContains(parent: string, child: string): boolean { function pathsOverlap(left: string, right: string): boolean { return pathContains(left, right) || pathContains(right, left) } + +function parseWorktreeIdentity(identity: string): { distro: string; linuxPath: string } | undefined { + const match = /^wsl:([^:]+):(\/.*)$/.exec(identity) + return match ? { distro: match[1]!, linuxPath: match[2]! } : undefined +} diff --git a/packages/server/src/workspaces/project-session-families.test.ts b/packages/server/src/workspaces/project-session-families.test.ts index 031d1173e..d69ede3ea 100644 --- a/packages/server/src/workspaces/project-session-families.test.ts +++ b/packages/server/src/workspaces/project-session-families.test.ts @@ -43,7 +43,12 @@ function clientHarness(initial: SessionInfo[], options: { }), }, session: { - list: async () => ({ data: Array.from(sessions.values()).map((value) => structuredClone(value)), cursor: {} }), + list: async (input?: { workspace?: string }) => ({ + data: Array.from(sessions.values()) + .filter((value) => !input?.workspace || value.location.workspaceID === input.workspace) + .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) @@ -69,20 +74,26 @@ function clientHarness(initial: SessionInfo[], options: { describe("project session families", () => { it("loads the complete paginated project inventory", async () => { const calls: Array<{ project?: string; workspace?: string; cursor?: string; limit?: number; order?: string }> = [] + const pages = [ + session("root", undefined, ROOT, "workspace"), + session("child-1", "root", ROOT, "workspace"), + session("child-2", "root", ROOT, "workspace"), + session("child-3", "root", ROOT, "workspace"), + session("child-4", "root", ROOT, "workspace"), + ] const client = { session: { list: async (input: { project?: string; cursor?: string }) => { calls.push(input) - return input.cursor - ? { data: [session("child", "root", ROOT, "workspace")], cursor: { next: null } } - : { data: [session("root", undefined, ROOT, "workspace")], cursor: { next: "next" } } + const index = input.cursor ? Number(input.cursor.slice("page-".length)) - 1 : 0 + return { data: [pages[index]!], cursor: { next: index < pages.length - 1 ? `page-${index + 2}` : null } } }, }, } as unknown as OpenCodeClient - assert.deepEqual((await listCompleteProjectSessions(client, "project", "workspace")).map(({ id }) => id), ["root", "child"]) + assert.deepEqual((await listCompleteProjectSessions(client, "project", "workspace")).map(({ id }) => id), pages.map(({ id }) => id)) assert.deepEqual(calls[0], { project: "project", workspace: "workspace", limit: 500, order: "asc" }) - assert.deepEqual(calls[1], { cursor: "next" }) + assert.deepEqual(calls.slice(1), ["page-2", "page-3", "page-4", "page-5"].map((cursor) => ({ cursor }))) }) it("rejects malformed native cursors", async () => { @@ -129,6 +140,21 @@ describe("project session families", () => { assert.ok([...harness.sessions.values()].every(({ location }) => location.directory === WORKTREE)) }) + it("rejects a family split across native workspaces", async () => { + const harness = clientHarness([ + session("root", undefined, ROOT, "owned-workspace"), + session("child", "root", ROOT, "foreign-workspace"), + ], { workspaceID: "owned-workspace" }) + + await assert.rejects(() => moveProjectSessionFamily({ + client: harness.client, + projectLocation: { directory: ROOT, workspaceID: "owned-workspace" }, + sessionId: "root", + targetDirectory: WORKTREE, + }), /another workspace/) + assert.deepEqual(harness.moveCalls, []) + }) + it("waits for delayed move visibility", async () => { const harness = clientHarness([session("root")], { visibilityDelayGets: 2 }) await moveProjectSessionFamily({ client: harness.client, projectLocation: { directory: ROOT }, sessionId: "root", targetDirectory: WORKTREE }) @@ -166,7 +192,7 @@ describe("project session families", () => { const harness = clientHarness([]) let lists = 0 ;(harness.client.session.list as any) = async () => ({ - data: ++lists < 4 ? [] : [session("intruder", undefined, WORKTREE)], + data: ++lists < 3 ? [] : [session("intruder", undefined, WORKTREE)], cursor: {}, }) @@ -180,6 +206,24 @@ describe("project session families", () => { }), /Sessions remain attached/) }) + it("rechecks deletion blockers after evacuation", async () => { + const harness = clientHarness([session("root", undefined, WORKTREE)]) + let checks = 0 + await assert.rejects(() => removeProjectWorktree({ + client: harness.client, + projectLocation: { directory: ROOT }, + targetDirectory: WORKTREE, + rootDirectory: ROOT, + remove: async () => assert.fail("Git removal must not run"), + isTargetRegistered: async () => true, + validateBeforeRemove: async () => { + if (++checks === 2) throw new ProjectSessionError("running resource", 409) + }, + }), /running resource/) + assert.equal(checks, 2) + assert.equal(harness.sessions.get("root")?.location.directory, WORKTREE) + }) + 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)], { @@ -276,6 +320,27 @@ describe("project session families", () => { }), /another workspace/) }) + it("ignores unrelated sessions from another workspace when fencing deletion", async () => { + const harness = clientHarness([ + session("owned", undefined, WORKTREE, "owned-workspace"), + session("foreign", undefined, "/other/project", "foreign-workspace"), + ], { workspaceID: "owned-workspace" }) + let guarded: string[] = [] + await removeProjectWorktree({ + client: harness.client, + projectLocation: { directory: ROOT, workspaceID: "owned-workspace" }, + targetDirectory: WORKTREE, + rootDirectory: ROOT, + remove: async () => undefined, + isTargetRegistered: async () => true, + runMutation: async (directories, operation) => { + guarded = directories + return operation() + }, + }) + assert.equal(guarded.includes("/other/project"), false) + }) + it("rolls back only while the original worktree identity remains", async () => { const original = clientHarness([session("original", undefined, WORKTREE)]) await assert.rejects(() => removeProjectWorktree({ @@ -297,7 +362,7 @@ describe("project session families", () => { rootDirectory: ROOT, remove: async () => { throw new ProjectSessionError("worktree changed", 409) }, isTargetRegistered: async () => ++identityChecks === 1, - }), /worktree changed/) + }), /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 index a38250f0a..a32a0e88e 100644 --- a/packages/server/src/workspaces/project-session-families.ts +++ b/packages/server/src/workspaces/project-session-families.ts @@ -118,10 +118,11 @@ export async function moveProjectSessionFamily(params: { 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, context.workspaceID) + 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) + assertWorkspaceFamily(family[1], context.workspaceID) const target = await resolveProjectLocation(context, params.targetDirectory) const move = async () => { await assertInactive(context.client, family[1]) @@ -151,31 +152,23 @@ export async function removeProjectWorktree(params: { await withProject(params.client, params.projectLocation, async (context) => { const matchesTarget = params.matchesTarget ?? (async (directory: string) => directoryContains(params.targetDirectory, directory)) - const initial = await listCompleteProjectSessions(context.client, context.project.id) + const initial = await matchingFamilies( + await listCompleteProjectSessions(context.client, context.project.id), + matchesTarget, + ) + for (const family of initial) assertWorkspaceFamily(family, context.workspaceID) const destination = await resolveProjectLocation(context, params.rootDirectory) const remove = async () => { if (!await params.isTargetRegistered()) { throw new ProjectSessionError("Worktree changed before deletion", 409) } - const allSessions = await listCompleteProjectSessions(context.client, context.project.id) - const foreign = context.workspaceID - ? (await Promise.all(allSessions.map(async (session) => ( - session.location.workspaceID !== context.workspaceID && await matchesTarget(session.location.directory) - ? session.id - : undefined - )))).filter((id): id is string => Boolean(id)) - : [] - if (foreign.length) { - throw new ProjectSessionError(`Sessions from another workspace block deletion: ${foreign.join(", ")}`, 409) - } - const inventory = await listCompleteProjectSessions(context.client, context.project.id, context.workspaceID) - const families: SessionInfo[][] = [] - for (const family of resolveSessionFamilies(inventory).values()) { - if ((await Promise.all(family.map(({ location }) => matchesTarget(location.directory)))).some(Boolean)) { - families.push(family) - } - } + const families = await matchingFamilies( + await listCompleteProjectSessions(context.client, context.project.id), + matchesTarget, + ) + for (const family of families) assertWorkspaceFamily(family, context.workspaceID) await assertInactive(context.client, families.flat()) + await params.validateBeforeRemove?.(context.project.id) const original = new Map(families.flat().map((session) => [session.id, session.location])) const moved: string[] = [] let root: LocationRef | undefined @@ -192,6 +185,9 @@ export async function removeProjectWorktree(params: { } await assertInactive(context.client, families.flat()) await params.validateBeforeRemove?.(context.project.id) + if (!await params.isTargetRegistered()) { + throw new ProjectSessionError("Worktree changed before deletion", 409) + } await params.remove() } catch (error) { const changed = root ? await refreshChangedSessionIds(context, moved, root) : [] @@ -212,11 +208,32 @@ export async function removeProjectWorktree(params: { } } return params.runMutation - ? params.runMutation([...initial.map(({ location }) => location.directory), params.targetDirectory, destination.directory], remove) + ? params.runMutation([...initial.flatMap((family) => family.map(({ location }) => location.directory)), params.targetDirectory, destination.directory], remove) : remove() }) } +async function matchingFamilies( + sessions: SessionInfo[], + matchesTarget: (directory: string) => Promise, +): Promise { + const matches: SessionInfo[][] = [] + for (const family of resolveSessionFamilies(sessions).values()) { + if ((await Promise.all(family.map(({ location }) => matchesTarget(location.directory)))).some(Boolean)) { + matches.push(family) + } + } + return matches +} + +function assertWorkspaceFamily(family: SessionInfo[], workspaceID: string | undefined): void { + if (!workspaceID) return + const foreign = family.filter((session) => session.location.workspaceID !== workspaceID).map(({ id }) => id) + if (foreign.length) { + throw new ProjectSessionError(`Sessions from another workspace block this operation: ${foreign.join(", ")}`, 409) + } +} + async function withProject( client: OpenCodeClient, requested: LocationRef, diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index e5706f593..58929322e 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -16,6 +16,7 @@ import { SideCarView } from "./components/sidecar-view" import { InstanceMetadataProvider } from "./lib/contexts/instance-metadata-context" import { showAlertDialog } from "./stores/alerts" import { initGithubStars } from "./stores/github-stars" +import { reloadWorktrees } from "./stores/worktrees" import { useCommands } from "./lib/hooks/use-commands" import { useAppLifecycle } from "./lib/hooks/use-app-lifecycle" @@ -323,6 +324,7 @@ const App: Component = () => { sessionError = error } await Promise.all([ + reloadWorktrees(id), syncPendingRequests(id, (invalidate) => { invalidatePendingRequests = invalidate }), refreshVolatileInstanceState(id), syncLoadedSessionInboxes(id), diff --git a/packages/ui/src/components/session-list.tsx b/packages/ui/src/components/session-list.tsx index 4f2a0f7aa..ce64b8190 100644 --- a/packages/ui/src/components/session-list.tsx +++ b/packages/ui/src/components/session-list.tsx @@ -23,6 +23,7 @@ import { setActiveSessionFromList, toggleSessionExpanded, loadMoreSessions, + loadAllSessions, searchSessions, getSessionHasMore, getSessionListError, @@ -135,13 +136,14 @@ const SessionList: Component = (props) => { } if (normalizedQuery() || failedSortExhaustion === key || !getSessionHasMore(props.instanceId) || isFetchingSessions()) return - void loadMoreSessions(props.instanceId).catch((error) => { + void loadAllSessions(props.instanceId).catch((error) => { failedSortExhaustion = key log.error("Failed to load all sessions for sorting:", error) }) }) const handleRetrySessions = () => { + failedSortExhaustion = undefined void fetchSessions(props.instanceId, { reset: true }).catch((error) => { log.error("Failed to retry session list:", error) }) @@ -155,6 +157,7 @@ const SessionList: Component = (props) => { (entries) => { const entry = entries[0] if (entry?.isIntersecting && hasMore() && !isFetchingSessions()) { + failedSortExhaustion = undefined void loadMoreSessions(props.instanceId).catch((error) => { log.error("Failed to load more sessions:", error) }) diff --git a/packages/ui/src/components/worktree-selector.tsx b/packages/ui/src/components/worktree-selector.tsx index 447e0ab5f..9f8b651ec 100644 --- a/packages/ui/src/components/worktree-selector.tsx +++ b/packages/ui/src/components/worktree-selector.tsx @@ -1,6 +1,6 @@ import { Select } from "@kobalte/core/select" import { Dialog } from "@kobalte/core/dialog" -import { For, Show, createMemo, createSignal } from "solid-js" +import { For, Show, createMemo, createSignal, createUniqueId } from "solid-js" import { ChevronDown, Copy, FolderOpen, Trash2 } from "lucide-solid" import type { WorktreeDescriptor } from "../../../server/src/api-types" import { getLogger } from "../lib/logger" @@ -122,10 +122,12 @@ interface WorktreeSelectorProps { export default function WorktreeSelector(props: WorktreeSelectorProps) { const { t } = useI18n() + const createInputId = createUniqueId() const [isOpen, setIsOpen] = createSignal(false) const [createOpen, setCreateOpen] = createSignal(false) const [createSlug, setCreateSlug] = createSignal("") const [isCreating, setIsCreating] = createSignal(false) + const [actionWorktreeSlug, setActionWorktreeSlug] = createSignal("") const [deleteOpen, setDeleteOpen] = createSignal(false) const [deleteTarget, setDeleteTarget] = createSignal(null) @@ -166,6 +168,10 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) { const option = selectedOption() return option?.kind === "worktree" ? option : undefined }) + const actionWorktree = createMemo(() => { + const worktrees = worktreeOptions().filter((option): option is WorktreeOption & { kind: "worktree" } => option.kind === "worktree") + return worktrees.find((option) => option.slug === actionWorktreeSlug()) ?? selectedWorktree() ?? worktrees[0] + }) const openDeleteDialog = (opt: WorktreeOption & { kind: "worktree" }) => { if (opt.slug === "root") return @@ -193,10 +199,13 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) { const handleCopyPath = async (directory: string) => { try { const ok = await copyToClipboard(directory) - showToastNotification({ message: ok ? "Copied worktree path" : "Failed to copy path", variant: ok ? "success" : "error" }) + showToastNotification({ + message: t(ok ? "instanceShell.filesShell.toast.copyPathSuccess" : "instanceShell.filesShell.toast.copyPathError"), + variant: ok ? "success" : "error", + }) } catch (error) { log.error("Failed to copy worktree path", error) - showToastNotification({ message: "Failed to copy path", variant: "error" }) + showToastNotification({ message: t("instanceShell.filesShell.toast.copyPathError"), variant: "error" }) } } @@ -316,7 +325,7 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) { options={worktreeOptions()} optionValue="key" optionTextValue={(opt) => (opt.kind === "action" ? opt.label : opt.slug)} - placeholder="Worktree" + placeholder={t("sessionList.sort.worktree")} disabled={dropdownDisabled()} itemComponent={(itemProps) => { const opt = itemProps.item.rawValue @@ -325,7 +334,9 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) {
{opt.label} - New from current branch + + {t("instanceShell.worktree.create.fromCurrentBranch")} +
) @@ -336,7 +347,7 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) {
- {opt.slug === "root" ? "Workspace" : opt.slug} + {opt.slug === "root" ? t("sessionList.worktree.workspace") : opt.slug}
@@ -359,16 +370,22 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) { if (worktreesUnavailable()) { return (
- Worktree: Unavailable + + {t("sessionList.worktree.tooltip", { worktree: t("instanceShell.worktree.unavailable") })} +
) } const value = state.selectedOption() - const label = value && value.kind === "worktree" ? (value.slug === "root" ? "Workspace" : value.slug) : "Workspace" + const label = value && value.kind === "worktree" + ? (value.slug === "root" ? t("sessionList.worktree.workspace") : value.slug) + : t("sessionList.worktree.workspace") return (
- Worktree: {label} + + {t("sessionList.worktree.tooltip", { worktree: label })} +
) }} @@ -386,14 +403,28 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) { - + {(worktree) => ( -
+
+
@@ -502,15 +538,17 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) {
- Delete worktree - Deletes this branch worktree and its local folder. + {t("instanceShell.worktree.delete.action")} + + {t("instanceShell.worktree.delete.description")} +
{(target) => ( -
+

- Worktree "{target().slug}" + {t("instanceShell.worktree.delete.target", { slug: target().slug })}

{target().directory}

@@ -524,7 +562,7 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) { onChange={(e) => setForceDelete(e.currentTarget.checked)} disabled={isDeleting()} /> - Force delete (discard local changes) + {t("instanceShell.worktree.delete.force")}
@@ -534,7 +572,7 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) { onClick={closeDeleteDialog} disabled={isDeleting()} > - Cancel + {t("sessionPicker.actions.cancel")}
{(message) => ( -
+

{t("instanceShell.worktree.delete.error.title")} @@ -592,7 +633,7 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) {

-
{message()}
+
{message()}