diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index 061afb608..1ffd2dd3c 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -275,7 +275,9 @@ jobs: packages/ui/src/stores/shell-store.test.ts packages/ui/src/stores/session-generation-recovery.test.ts packages/ui/src/stores/session-pagination.test.ts + packages/ui/src/stores/workspace-session-scope.test.ts packages/ui/src/stores/session-pending-state.test.ts + packages/ui/src/stores/session-tree.test.ts packages/ui/src/stores/session-previews.test.ts packages/ui/src/stores/workspace-load-readiness.test.ts packages/ui/src/types/session.test.ts @@ -305,6 +307,8 @@ jobs: packages/ui/src/stores/session-request-authority.test.ts packages/ui/src/stores/session-send-lifecycle.test.ts packages/ui/src/stores/session-status.test.ts + packages/ui/src/stores/worktree-ready.test.ts + packages/ui/src/stores/worktrees-delete.test.ts - name: Test server run: node --import tsx --test "packages/server/src/**/*.test.ts" diff --git a/.opencode/skills/codenomad-architecture-guide/references/sdk-api-reference.md b/.opencode/skills/codenomad-architecture-guide/references/sdk-api-reference.md index d1d811295..411870fb5 100644 --- a/.opencode/skills/codenomad-architecture-guide/references/sdk-api-reference.md +++ b/.opencode/skills/codenomad-architecture-guide/references/sdk-api-reference.md @@ -45,7 +45,7 @@ Location-sensitive list/create calls include `directory` or `location`. Session- Do not look for these in the OpenCode client: -- Workspace create/delete and worktree management +- Workspace create/delete and worktree workflow routes (native OpenCode owns worktree discovery/create/remove; CodeNomad supplies directory/branch policy and verified session-family moves) - Git status/diff/stage/unstage/commit - Yolo toggle, persistence and auto-accept policy - Authentication, storage, speech, sidecars and previews diff --git a/AGENTS.md b/AGENTS.md index 631e8145d..5c32a4fc9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,6 +24,8 @@ ## Coding Principles +- Worktree discovery/create/remove use `workspaces/native-worktrees.ts` and the native OpenCode worktree API. CodeNomad supplies the `.codenomad/worktrees` default, named-branch policy and verified family transactions. Git common-directory identity scopes the native inventory to the opened local repository; opaque worktree identifiers are separate from mutable branch labels. Validate through `scripts/test-opencode-location-native.mjs` with an isolated CLI and `tests/browser/worktrees.test.ts` for selector gestures. + - Session pruning is a narrow V2 plugin/RPC exception under `packages/server/src/opencode/session-pruning/`; see `dev-docs/SESSION_PRUNING_RPC.md`. Bundle it with the shared server for both desktop hosts and provision through normal native plugin discovery. RPC registrations follow backend presence; clean shutdown removes that backend's lease and crashes expire. Loading never deletes content. Deletion occurs only on an explicit pruning request, without an extra enable-write switch or beta-number gate. Keep generic RPC proxy access closed. Writes validate actual storage, a fresh daemon-storage identity challenge and the native durable execution claim inside a synchronous SQLite transaction. Run isolated native concurrency/payload and client-cache regressions; tests must never target the shared daemon or a user's database. - Favor KISS by keeping modules narrowly scoped and limiting public APIs to what callers actually need. - Uphold DRY: share helpers via dedicated modules before copy/pasting logic across stores, components, or scripts. @@ -68,8 +70,8 @@ Behavior for agents: - Run them with `npm run test:browser --workspace @codenomad/ui` after `npx playwright install chromium`. `CODENOMAD_BROWSER_PATH` optionally selects an existing Chromium executable; it does not target the installed application or user sessions. ## V2 Runtime Launch -- Launch the release executable from PowerShell with the dedicated WebView2 profile, CDP port, Rust backtraces, and Node source maps described in `MIGRATION_V2.md`. -- Stop the running CodeNomad instance before rebuilding the same release path, then relaunch it from the independent OpenCode TUI. +- Enable **Developer Mode** from the session tab bar and fully restart CodeNomad once; do not configure a fixed CDP port or a manual WebView2 profile. +- Rebuild Electron before calling `codenomad.act({ action: "restart" })`. For Windows Tauri, stop and relaunch the release executable only when the linker cannot replace it; never stop the shared OpenCode daemon. ## Commit Message Guidelines - When creating commits, use detailed commit messages: a concise conventional-style subject followed by body paragraphs that explain the user-visible behavior change, the implementation approach, important edge cases or platform considerations, and the validation or test coverage added. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 75738d722..76daca68f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -112,7 +112,7 @@ Then open a pull request on GitHub targeting the `dev` branch. ### OpenCode V2 Boundaries - Server and UI pin `@opencode/client@2.0.4`; the pruning plugin pins `@opencode/plugin@2.0.4`. Upgrade them together with the lockfile and isolated native validation. The runtime CLI is managed independently, and startup must not reject an otherwise compatible service solely for a different version string. Review current OpenCode documentation, installed declarations, and proxy/API parity whenever the client contract changes. -- Upgrade references: [OpenCode releases](https://github.com/anomalyco/opencode/releases), [OpenCode documentation](https://opencode.ai/docs/), and `node_modules/@opencode/client/dist/promise/`. +- Upgrade references: [OpenCode releases](https://github.com/anomalyco/opencode/releases), [OpenCode V2 documentation](https://opencode.ai/v2/docs/), and `node_modules/@opencode/client/dist/promise/`. - `packages/server/src/workspaces/opencode-service.ts` uses the selected host or WSL CLI's official `service status`, `service start`, and `service get password` lifecycle to connect to one externally owned global daemon. CodeNomad owns no private port, database, registration, or daemon PID and never stops the daemon on backend shutdown. - WSL requires Windows localhost forwarding and runs the Linux CLI lifecycle inside the distribution; never inspect or signal Linux PIDs from Windows. - OpenCode owns the global daemon's standard state and database. Configured allowed environment variables apply only when CodeNomad starts a missing daemon; an existing daemon is unchanged, and legacy `OPENCODE_DB`/`XDG_STATE_HOME` ownership settings are ignored. diff --git a/dev-docs/INDEX.md b/dev-docs/INDEX.md index 7fdfbd422..279719792 100644 --- a/dev-docs/INDEX.md +++ b/dev-docs/INDEX.md @@ -32,6 +32,10 @@ Measured DEV-v2 growth analysis, maintainer position on test volume, ranked redu Persistent Electron/Tauri Developer Mode, OpenCode feedback tools, reconnect behavior, and trust boundaries. +### [WORKTREE_SESSION_PLACEMENT.md](WORKTREE_SESSION_PLACEMENT.md) + +Native project/location versus CodeNomad conversation attachment, user-authorized movement, session instruction synchronization, and Windows directory-spelling diagnostics. + --- ## Specification Documents diff --git a/dev-docs/SUMMARY.md b/dev-docs/SUMMARY.md index 18d76cddf..8428ec9dd 100644 --- a/dev-docs/SUMMARY.md +++ b/dev-docs/SUMMARY.md @@ -158,7 +158,7 @@ dev-docs/ Development documentation ### External: -- OpenCode server API: https://opencode.ai/docs/server/ +- OpenCode V2 HTTP API: https://opencode.ai/v2/docs/api/ - Electron docs: https://electronjs.org/docs - SolidJS docs: https://solidjs.com - Kobalte UI: https://kobalte.dev diff --git a/dev-docs/WORKTREE_SESSION_PLACEMENT.md b/dev-docs/WORKTREE_SESSION_PLACEMENT.md new file mode 100644 index 000000000..ab1052d38 --- /dev/null +++ b/dev-docs/WORKTREE_SESSION_PLACEMENT.md @@ -0,0 +1,258 @@ +# Worktrees and conversation placement + +## Product/Git contract + +The user review on 2026-09-17 requires evaluating #649 as a development workflow, +not merely replacing Git calls with similarly named native methods. The rules +below define the implementation and its verification surface. + +- A Git worktree is a physical checkout with its own HEAD, index and working + files. Branch refs and the object database are shared with its local repository. + Sessions are not Git objects; multiple sessions can use one worktree. +- Selecting a session's worktree changes its native execution location. It does + not run `git switch`, move the checkout, copy uncommitted files, merge branches, + or create another conversation. Moving within the local repository keeps the + conversation visible in its CodeNomad project. +- Creation and session movement are separate operations. The existing selector's + create action performs both and is labelled "Create and use worktree". + Agent creation alone must not be confused with a request to continue the session + there. Explicit instructions to preserve the current session location prevail. +- Preserve the CodeNomad default parent `/.codenomad/worktrees` + for new worktrees. This is a storage convention, not the definition of workspace + membership: externally located registered worktrees remain valid. Resolve the + main checkout from Git/native local identity, including when the opened folder + is a linked worktree or a nested package. Do not recursively nest this default + beneath whichever worktree happens to be current. +- Distinguish the new branch name, the worktree folder name and the starting + revision. "From current branch" must refer to the session's selected checkout, + not silently use the project tab's checkout. Creation starts from a committed + revision; uncommitted changes stay in the original worktree. +- Honor Git's ordinary refusal to check out a branch already used in another + worktree. Support and identify detached worktrees, including multiple checkouts + at the same commit. A mutable branch name is a label, not physical identity. +- Worktree removal preserves branch refs and conversation history, observes Git's + dirty/locked/main-worktree constraints, and preserves CodeNomad's session-family + evacuation and resource fencing. Closing a conversation does not remove Git data. +- UI and agent equivalence must be demonstrated for directory, starting revision, + branch/detached state, native session location, local project visibility and Git + panel target. Native `session_move` alone does not provide CodeNomad's family + transaction. An agent's shell command also does not inherit UI policy by itself. + +Implementation: + +- `workspaces/native-worktrees.ts` owns the local catalogue and native create/remove + policy. It calls OpenCode `worktree.refresh/list/create/remove`. There is no second + production `git worktree list/add/remove` implementation. Git reads annotate HEAD + and branch and compare the physical common directory to exclude independent clones. +- Opaque directory-derived identifiers remain stable when branches change; labels + are separate. Main checkouts cannot be deleted. Nested workspace paths are mirrored + over the same checkout roots, retaining host/service namespace separation for WSL. +- Creation carries the selected source worktree, an explicit starting revision and + the local main checkout's `.codenomad/worktrees` parent. Native creation detaches HEAD; + Git `switch -c` (or `switch` for an existing branch) establishes the requested branch + without force/reset. Failed branch attachment removes only the newly created clean + worktree through the native API; a cleanup failure is reported with the original error. +- The catalogue supplies the same default parent to the agent instruction. Creation + alone and a request to continue the conversation there are distinguished. The UI + follows creation with the existing verified family transaction, using the returned + identifier rather than guessing it from the branch name. +- Native worktree events invalidate ownership and UI state. Opening the selector also + discovers external Git changes. Native session moves refresh the catalogue before + list reconciliation. Search traverses every local worktree's native cursor chain. +- `scripts/test-native-worktree-management.mjs` runs through the isolated native + contract fixture (and its CI callers), covering selected HEAD, default parent, + named branches, externally-created checkouts, independent clones, branch rename, + nested workspaces, dirty removal and checked-out branch refusal. It passes against + real OpenCode 2.0.1 and 2.0.4; no shared daemon or user database is used. + +References: [Git worktree](https://git-scm.com/docs/git-worktree) and +[OpenCode V2 worktree configuration](https://opencode.ai/v2/docs/config/#worktrees). + +## Shared model, explicit user choice + +OpenCode's native session location is both an execution context and the source of +truth for CodeNomad's session projection. The concepts are related, but distinct: + +| Concept | Meaning | +| --- | --- | +| Git worktree | A checkout with its own branch and working files. | +| Native project | Repository identity; more than one checkout, and even separate clones, can share it. It is not sufficient to authorize a directory. | +| Native session location | The session's execution context. It determines location-scoped services, defaults and native movement events. | +| CodeNomad workspace/instance | The folder opened by the user, its authorized locations, and their UI projection. Several tabs can open the same folder. | +| Tool working directory | A directory selected for one operation. It does not inherently move the conversation. | + +### Local workspace scope, not native project identity + +The CodeNomad tab is anchored to `Instance.folder` (`WorkspaceRecord.path` on the +server). Native `location.get().project.directory` describes the local checkout, +and `project.canonical` describes its canonical repository root. Neither should +be confused with `SessionInfo.location.directory`, the session's working directory. +The native project ID can span independent local clones; matching it alone does +not establish membership in the opened CodeNomad workspace. + +The UI uses the native project inventory to discover candidates, then projects +families using the opened folder and the server's registered worktree paths, +including their service-side paths for WSL. A separate clone sharing the native +project ID must not appear merely because of that ID. Family ancestry is retained +when a member belongs to the local scope. Moving within this workspace's worktrees +changes the execution directory and badge, while keeping the conversation in the +same CodeNomad project tab. + +During refresh, the first directory page is partial: it must not replace existing +worktree rows before inventory reconciliation completes. An unavailable inventory +must likewise preserve those rows. `workspace-session-scope.test.ts` and the +session request-authority regressions cover local-clone separation, family +retention, WSL paths and repeated refreshes. + +The integration does not require two competing session locations. CodeNomad keeps +native authority, while communicating when the user authorizes changing it: + +- Creating a worktree alone or running a one-off test preserves the session location. +- A request to continue the conversation's task in a worktree authorizes a native move, + unless the user explicitly requests preserving the current location. +- An explicit UI worktree selection uses CodeNomad's transactional family move. +- A native move is reflected faithfully, including one initiated outside CodeNomad. +- A confirmed explicit move becomes the new attachment to preserve. + +Keeping the session attached means its default tools, catalogs and plugins remain +location-scoped to that attachment. The agent must use absolute paths/`workdir` +for work elsewhere and read the instructions applicable to the target files. +Changing every location-scoped service requires an explicit native move; a shell +working-directory override does not do that. + +## Why an instruction belongs in PR #649 + +The native `opencode.tools` plugin adds this general guidance through its session +context hook: + +> When you create a worktree outside the current working directory and intend to use it as your primary working directory, consider using `execute` to call `tools.opencode.session_move` and make the worktree the session's working directory. + +This is core runtime behavior, not a TUI-only instruction. CodeNomad complements +it with its creation convention, explicit user intent and the distinction between +a one-off working-directory override and moving the conversation's execution context. + +`packages/ui/src/stores/session-instructions.ts` supplies the named native entry +`codenomad.session-placement`. It explicitly distinguishes working elsewhere from +moving the conversation and qualifies the generic upstream recommendation. + +The action admission path awaits synchronization before every prompt (including +queued/restored prompts), slash command and session shell command. Existing +sessions receive it on their next submission, as do newly created sessions on +their first submission. This is not a background migration of every stored session +or an intervention in an already-running turn initiated by another client. + +The entry uses the user's current choice rather than an embedded initial session path, +and appends the catalogue's default creation parent when available. It +survives independently of the voice-mode entry, and is reapplied without a +browser-only success cache. A failed write propagates through the existing action +failure path. Other instruction keys and session metadata are not replaced. + +This is model guidance, not a runtime permission. The inspected native tool calls +`ctx.session.move` directly, outside the CodeNomad HTTP proxy. Enforcing a distinct +agent-vs-user movement policy would require a supported native runtime control; +blocking the proxy would not provide that guarantee. A tool-initiated native move +also does not become CodeNomad's multi-session family transaction merely because +the instruction is present. + +## Windows spelling observation (2026-09-17) + +During this review, the shared runtime stored the conversation's location as +`D:\codenomad`, while the open workspace used `D:\CodeNomad`. Native location +resolution returned the same project ID for both spellings. However, native +`session.list` with `directory=D:\CodeNomad` and a matching title search returned +no session; the same request with `directory=D:\codenomad` returned it. Moving +the session to the exact open-folder spelling restored that native list result. + +This is independent of the placement instruction: preserving user intent cannot +fix an exact-string directory filter. Do not claim that this PR fixes native path +canonicalization, lowercase paths globally (including case-sensitive hosts), or +broaden project authority to hide this discrepancy. When diagnosing an absent +conversation, compare `session.get`, the actual directory-scoped list and the +authorized workspace spelling before changing UI state. + +## Evidence and regression coverage + +### Final native/UI integration (2026-09-17) + +Merged `dev@e47e01c6` (PR #697) into this branch. Discovery and canonical +`server.status()` adaptation are now inherited from that independently merged +change. The native worktree/family fixture passed again against isolated official +2.0.0 and 2.0.7 daemons, including legacy health and modern info discovery. + +Verified in the installed Windows Tauri application using dedicated disposable +sessions in the actual `D:\CodeNomad` repository: + +- The separately created #697 checkout appears in the selector without moving + the original conversation away from `D:\CodeNomad-worktrees\pr649-final`. +- Create/use creates a named branch under the main checkout's + `.codenomad/worktrees`, at the selected checkout's HEAD. Native session location, + composer selection and session-row badge all match the returned checkout. +- A dedicated untracked marker appears in the Git panel and the local Git/API + inventories. Selecting the original checkout moves the test conversation back + and removes that marker from the panel. Explicit refresh was exercised. +- A separate dedicated session moved with the agent's actual `session_move` + tool to #697 and back; both composer and row badges followed the native event. +- Test sessions, marker files, temporary worktrees and branches were removed. + The original conversation's attachment was preserved throughout. + +The rendered test exposed stale native VCS rows being unioned into a complete +local Git inventory. Local staged/unstaged details now exclude native-only rows; +the status loader also rechecks request/worktree identity after its native wait. +A regression covers stale rows, a clean local snapshot and native-only callers. +The rebuilt UI/server resources were installed and CodeNomad restarted through +Developer Mode. One immediate post-restart run exceeded the 30-second refresh +button wait; the subsequent completed run verified the workflow and rendered +exactly the dedicated marker. This does not establish a startup latency bound. + +Additional passing checks: server/UI typechecks, server build (including UI), +real-component browser selector gestures, ownership/proxy/route/cache suites, +session projection/request-authority suites, family evacuation/rollback suites, +and `git diff --check`. + +### Installed-build recovery (2026-09-17) + +The first installed native-catalogue build stalled while opening `D:\CodeNomad`. +This preceded the user's upgrade to OpenCode 2.0.7. The real repository contains +162 worktrees: serial Git annotations took about 21 seconds for one inventory. +Concurrent ownership misses also discarded pending cache loads and triggered +overlapping inventories; the installed UI showed permission/Form requests hitting +their 10-second timeout while the session list remained loading. + +Recovery combines bounded parallel Git annotations (about 3 seconds for the same +inventory), manager-level sharing of in-flight catalogue requests, snapshot-aware +ownership refreshes, cached negative resolutions, and a physical-path fast path +for the opened root. The ownership regression exercises 40 concurrent misses, +one shared refresh, negative caching and explicit invalidation. + +OpenCode 2.0.7 introduced a separate startup failure: `/api/status` and `/api/health` +returned 404, while authenticated `/api/info` worked. Lifecycle discovery now tries +that read after the two 404s, preserving credentials, the absolute deadline and +response validation. Contract negotiation recognizes the actual OpenAPI structure; +no 2.0.7 version exception was added. The isolated native worktree/family suite +also passed on 2.0.7 after exercising production lifecycle discovery. + +After installing the corrected server modules, Developer Mode inspection and a +rendered capture confirmed the project session list, this conversation's messages, +and its worktree badge. A subsequent full application restart restored the same +session automatically. A fresh comparison of all 132 generated client HTTP +method/path pairs against the installed 2.0.7 OpenAPI document found 131 unchanged +pairs and only the `/api/status` to `/api/info` mismatch handled above. Experimental +routes were already supported by the merged adapter; they are not an outstanding +route migration. Real reads through the production shared-service adapter passed +for canonical `client.server.status()` and session instruction entries. These are +route-presence and targeted runtime checks, not complete payload/behavior parity +or complete UI/agent/Git-panel workflow coverage for #649. + +- [Official V2 API](https://opencode.ai/v2/docs/api/): native session moves, instructions and locations. +- [V2 plugin context](https://opencode.ai/v2/docs/build/plugins/): location-scoped plugin context and session APIs. +- Native source inspected at `f91c6d8b25a040cc952db0c43fc5352bcab7f42d`: + `packages/core/src/tool/plugin/opencode.ts`, `session/move.ts`, + `session/instruction-entry.ts`, and `project.ts`. This is a source reference, + not an assertion that every supported runtime has identical internals. +- Client contract: the declarations pinned by this branch (`@opencode/client@2.0.4`). +- `session-actions.test.ts`: command/shell ordering, voice-mode changes during + synchronization, and delayed placement failures before prompt/command/shell. +- `session-send-lifecycle.test.ts`: prompt ordering, repair on existing sessions, + preservation of other named entries and a new native attachment, optimistic sends. +- Existing native-move, restored-session and worktree-family tests continue to + cover reconciliation and transactional movement; model obedience is not a test assertion. diff --git a/packages/server/src/api-types.ts b/packages/server/src/api-types.ts index f0e396219..a3d028749 100644 --- a/packages/server/src/api-types.ts +++ b/packages/server/src/api-types.ts @@ -91,25 +91,50 @@ export type WorktreeKind = "root" | "worktree" export interface WorktreeDescriptor { /** Stable identifier used by CodeNomad + clients ("root" for the selected workspace folder). */ slug: string + /** Presentation only; the worktree identifier does not change with its branch. */ + label?: string /** Absolute directory path on the server host. */ directory: string + /** Equivalent path in the OpenCode service namespace (notably WSL). */ + serviceDirectory?: string + /** Native checkout root (before mirroring a nested workspace folder). */ + serviceRoot?: string + /** Exact path registered in Git's worktree inventory. */ + registeredDirectory?: string kind: WorktreeKind + /** False for the opened folder and Git's main checkout. */ + removable?: boolean /** Optional VCS branch name when available. */ branch?: string + /** Commit recorded by the Git worktree inventory. */ + head?: string } export interface WorktreeListResponse { worktrees: WorktreeDescriptor[] + /** Default creation parent in the OpenCode service namespace. */ + defaultDirectory?: string /** True when the workspace folder resolves to a Git repository. */ isGitRepo?: boolean } export interface WorktreeCreateRequest { slug: string + fromSlug?: string /** Optional branch name (defaults to slug). */ 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/opencode/compatibility/location.test.ts b/packages/server/src/opencode/compatibility/location.test.ts index 93b07be59..6cf54022d 100644 --- a/packages/server/src/opencode/compatibility/location.test.ts +++ b/packages/server/src/opencode/compatibility/location.test.ts @@ -96,3 +96,44 @@ test("directory-only credential context is native-scoped on legacy and global on await client.credential.remove({ credentialID: "credential" }, locationRequestOptions({ directory: "/repo" }, { includeDirectory: true })) } }) +test("worktree refresh retains canonical project requests and adapts legacy directory context", () => { + const location = { directory: "/repo", workspaceID: "native" } + const legacyUrl = new URL("http://localhost/api/worktree/refresh") + const headers = new Headers(locationRequestOptions(location)?.headers) + assert.equal(applyLocationContext(legacyUrl, "POST", { projectID: "project" }, headers, "legacy"), undefined) + assert.equal(legacyUrl.searchParams.get("location[directory]"), "/repo") + assert.equal(legacyUrl.searchParams.get("location[workspace]"), "native") + const modernUrl = new URL("http://localhost/api/worktree/refresh") + const modernHeaders = new Headers(locationRequestOptions({ directory: "/repo" }, { includeDirectory: true })?.headers) + assert.deepEqual(applyLocationContext(modernUrl, "POST", { projectID: "project" }, modernHeaders, "modern"), { projectID: "project" }) + assert.equal(modernUrl.search, "") +}) + +test("generated worktree methods retain their payload while adapting the legacy location scope", async () => { + for (const version of ["2.0.3", "2.0.4"]) { + const endpoint = { url: "http://localhost:4321", auth: { type: "basic" as const, username: "fixture", password: "fixture" } } + rememberRuntime(endpoint, { version, pid: 1, discovery: version === "2.0.3" ? "health" : "status" }) + const requests: Array<{ method: string; url: URL; body: any }> = [] + const client = OpenCode.make({ baseUrl: endpoint.url, fetch: createRuntimeFetch(endpoint, async (input, init) => { + const request = new Request(input, init) + requests.push({ method: request.method, url: new URL(request.url), body: request.body ? await request.json() : undefined }) + if (request.method === "GET") return Response.json([{ directory: "/repo", strategy: "git" }]) + if (request.method === "POST") return Response.json({ directory: "/repo/.codenomad/worktrees/new" }) + return new Response(null, { status: 204 }) + }) }) + const options = locationRequestOptions({ directory: "/repo" }, { includeDirectory: true }) + await client.worktree.list({ projectID: "project" }, options) + await client.worktree.create({ projectID: "project", from: "/source", branch: "revision", directory: "/repo/.codenomad/worktrees", name: "new" }, options) + await client.worktree.remove({ projectID: "project", directory: "/repo/.codenomad/worktrees/new", force: false }, options) + const legacy = version === "2.0.3" + for (const request of requests) { + assert.equal(request.url.searchParams.get("location[directory]"), legacy ? "/repo" : null) + if (request.method === "GET") assert.equal(request.url.searchParams.get("projectID"), legacy ? null : "project") + else assert.equal(request.body.projectID, legacy ? undefined : "project") + } + assert.equal(requests[1].body.branch, "revision") + assert.equal(requests[1].body.from, "/source") + assert.equal(requests[2].body.directory, "/repo/.codenomad/worktrees/new") + assert.equal(requests[2].body.force, false) + } +}) diff --git a/packages/server/src/opencode/compatibility/location.ts b/packages/server/src/opencode/compatibility/location.ts index e7cdb03de..52d139841 100644 --- a/packages/server/src/opencode/compatibility/location.ts +++ b/packages/server/src/opencode/compatibility/location.ts @@ -38,6 +38,16 @@ export function applyLocationContext(url: URL, method: string, body: unknown, he headers.delete(LOCATION_CONTEXT_HEADER) if (!location) return body const input = body && typeof body === "object" && !Array.isArray(body) ? body as Record : undefined + if ((url.pathname === "/api/worktree" && ["GET", "POST", "DELETE"].includes(method)) + || (method === "POST" && url.pathname === "/api/worktree/refresh")) { + if (profile === "modern") return body + url.searchParams.delete("projectID") + url.searchParams.set("location[directory]", location.directory) + if (location.workspaceID !== undefined) url.searchParams.set("location[workspace]", location.workspaceID) + if (!input || url.pathname.endsWith("/refresh")) return undefined + const { projectID: _projectID, ...payload } = input + return payload + } const assertDirectory = (directory: unknown) => { if (directory !== location.directory) throw new Error("Location context does not match request") } diff --git a/packages/server/src/permissions/opencode-yolo-metadata.test.ts b/packages/server/src/permissions/opencode-yolo-metadata.test.ts index 1729dee42..8ae93d19c 100644 --- a/packages/server/src/permissions/opencode-yolo-metadata.test.ts +++ b/packages/server/src/permissions/opencode-yolo-metadata.test.ts @@ -92,8 +92,8 @@ describe("OpenCode Yolo persistence", () => { }, ]) assert.deepEqual(listInputs, [ - { directory: "/repo", limit: 10_000, cursor: undefined }, - { directory: "/repo", limit: 10_000, cursor: "page-2" }, + { directory: "/repo", limit: 10_000 }, + { cursor: "page-2" }, ]) }) diff --git a/packages/server/src/permissions/opencode-yolo-metadata.ts b/packages/server/src/permissions/opencode-yolo-metadata.ts index f67de2a78..80b7a4bfb 100644 --- a/packages/server/src/permissions/opencode-yolo-metadata.ts +++ b/packages/server/src/permissions/opencode-yolo-metadata.ts @@ -49,7 +49,9 @@ export function createOpencodeYoloPersistence( const sessions: SessionInfo[] = [] let cursor: string | undefined do { - const page = await client.session.list({ directory, limit: SESSION_LIST_LIMIT, cursor }) + const page = await client.session.list(cursor + ? { cursor } + : { directory, limit: SESSION_LIST_LIMIT }) sessions.push(...page.data) cursor = page.cursor.next ?? undefined } while (cursor) diff --git a/packages/server/src/server/routes/workspaces.ts b/packages/server/src/server/routes/workspaces.ts index d40b84c44..1d8e58a27 100644 --- a/packages/server/src/server/routes/workspaces.ts +++ b/packages/server/src/server/routes/workspaces.ts @@ -356,6 +356,7 @@ async function resolveGitWorktreeDirectory( workspaceId: workspace.id, workspacePath: workspace.path, worktreeSlug, + loadWorktrees: async () => (await workspaceManager.getWorktrees(workspace.id)).worktrees, logger, }) if (!directory) { diff --git a/packages/server/src/server/routes/worktrees.test.ts b/packages/server/src/server/routes/worktrees.test.ts index 642fb7790..6f309f3f6 100644 --- a/packages/server/src/server/routes/worktrees.test.ts +++ b/packages/server/src/server/routes/worktrees.test.ts @@ -1,70 +1,122 @@ import assert from "node:assert/strict" import { execFileSync } from "node:child_process" -import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs" +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import path from "node:path" import { describe, it } from "node:test" -import Fastify from "fastify" import type { OpenCodeClient, SessionInfo } from "@opencode/client" +import Fastify from "fastify" import type { WorkspaceDescriptor } from "../../api-types" import type { WorkspaceManager } from "../../workspaces/manager" import { registerWorktreeRoutes } from "./worktrees" import { WorktreeDeletionFence } from "../../workspaces/worktree-session-evacuation" +import { readLocationContext } from "../../opencode/compatibility/location" +import { fixtureCatalogue } from "../../workspaces/__tests__/native-worktree-fixture" describe("worktree routes", () => { - it("uses exact project paths despite a nested project sharing the root worktree identity", async () => { - const temp = mkdtempSync(path.join(tmpdir(), "codenomad-nested-project-")) - const target = path.join(temp, "doomed") - const nested = path.join(temp, "nested") - const app = Fastify({ logger: false }) - try { - execFileSync("git", ["init", "--initial-branch=main", temp]) - writeFileSync(path.join(temp, "README.md"), "test\n") - execFileSync("git", ["-C", temp, "add", "README.md"]) - execFileSync("git", ["-C", temp, "-c", "user.name=CodeNomad Test", "-c", "user.email=test@codenomad.local", "commit", "-m", "test"]) - execFileSync("git", ["-C", temp, "worktree", "add", "-b", "doomed", target]) - mkdirSync(nested) - const projects: string[] = [] - const exactPaths: string[] = [] - const client = { - location: { get: async () => ({ directory: temp }) }, - project: { list: async () => [ - { id: "nested", canonical: nested, sandboxes: [] }, - { id: "project", canonical: realpathSync(temp), sandboxes: [target] }, - ] }, - session: { - list: async ({ project }: { project: string }) => { - projects.push(project) - return { data: project === "project" ? [{ id: "active", location: { directory: target } }] : [], cursor: {} } - }, - active: async () => ({ active: { type: "running" } }), - move: async () => { assert.fail("Active sessions must not move") }, +it("reserves the physical worktree and rejects a same-HEAD replacement before deletion", async () => { + const temp = mkdtempSync(path.join(tmpdir(), "codenomad-worktree-route-")) + const repo = path.join(temp, "repo") + const linked = path.join(temp, "feature-worktree") + const workspacePath = path.join(repo, "apps", "web") + const linkedWorkspacePath = path.join(linked, "apps", "web") + const app = Fastify({ logger: false }) + + try { + mkdirSync(repo, { recursive: true }) + execFileSync("git", ["init", "-b", "main", repo], { stdio: "ignore" }) + mkdirSync(workspacePath, { recursive: true }) + writeFileSync(path.join(workspacePath, "README.md"), "nested workspace\n") + execFileSync("git", ["-C", repo, "add", "."], { stdio: "ignore" }) + execFileSync("git", ["-C", repo, "-c", "user.name=CodeNomad", "-c", "user.email=test@example.com", "commit", "--allow-empty", "-m", "init"], { stdio: "ignore" }) + execFileSync("git", ["-C", repo, "worktree", "add", "-b", "feature", linked], { stdio: "ignore" }) + + const originalLocation = { directory: path.join(linked, "..cache", "session"), workspaceID: "native-feature" } + const current: SessionInfo = { + id: "session", + projectID: "project", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1, updated: 1 }, + location: originalLocation, + } + 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, + workspaceID: path.resolve(location?.directory ?? workspacePath) === path.resolve(linkedWorkspacePath) ? "native-feature" : undefined, + project: { id: "project", directory: workspacePath, canonical: workspacePath }, + }), + }, + session: { + list: async () => { + if (++lists === 3) { + execFileSync("git", ["-C", repo, "worktree", "remove", "--force", linked], { stdio: "ignore" }) + execFileSync("git", ["-C", repo, "worktree", "add", linked, "feature"], { stdio: "ignore" }) + writeFileSync(path.join(linked, "replacement.txt"), "must survive\n") + } + return { data: [structuredClone(current)], cursor: {} } }, - } as unknown as OpenCodeClient - const manager = { - get: () => ({ id: "workspace", path: temp, status: "ready" }), - getSharedServiceClient: async () => client, - getServiceDirectory: () => temp, - getServiceDirectoryForPath: async (_id: string, directory: string) => { - exactPaths.push(directory) - return realpathSync(directory) + active: async () => ({}), + move: async ({ directory }: { directory: string }, options?: { headers: Record }) => { + current.location = readLocationContext(options?.headers["x-codenomad-location"], "legacy") ?? { directory } }, - getWorktreeIdentityForPath: async (_id: string, directory: string) => path.resolve(directory) === path.resolve(target) ? "workspace:doomed" : "workspace:root", - } as unknown as WorkspaceManager - registerWorktreeRoutes(app, { workspaceManager: manager, worktreeDeletionFence: new WorktreeDeletionFence() }) - const response = await app.inject({ method: "DELETE", url: "/api/workspaces/workspace/worktrees/doomed" }) - assert.equal(response.statusCode, 400) - assert.match(response.json().error, /Active sessions block worktree deletion: active/) - assert.deepEqual(projects, ["project"]) - assert.ok(exactPaths.includes(nested), "Project candidates must use the exact-path resolver") - assert.match(execFileSync("git", ["-C", temp, "worktree", "list", "--porcelain"], { encoding: "utf8" }), /doomed/) - } finally { - await app.close() - rmSync(temp, { recursive: true, force: true }) - } - }) + get: async () => structuredClone(current), + }, + } as unknown as OpenCodeClient + let reserved = "" + let released = false + const manager = { + getWorktrees: () => fixtureCatalogue(workspacePath), + get: () => ({ + id: "workspace", + path: workspacePath, + status: "ready", + proxyPath: "/workspaces/workspace/instance", + binaryId: "opencode", + binaryLabel: "opencode", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }), + reserveWorktreeDeletion: async (directory: string) => { + reserved = directory + return () => { released = true } + }, + getSharedServiceClient: async () => client, + getServiceLocation: () => ({ directory: workspacePath }), + getServiceDirectoryForPath: async (_id: string, directory: string) => { + assert.notEqual(path.resolve(directory), path.resolve(linked), "OpenCode must receive the mirrored workspace path") + return directory + }, + getWorktreeIdentityForPath: async (_id: string, directory: string) => ( + path.resolve(directory).startsWith(path.resolve(linked)) ? "workspace:feature" : "workspace:root" + ), + } as unknown as WorkspaceManager + registerWorktreeRoutes(app, { workspaceManager: manager, worktreeDeletionFence: new WorktreeDeletionFence() }) - it("fails a direct delete call closed when session evacuation fails", async () => { + const slug = (await fixtureCatalogue(workspacePath)).worktrees.find(entry => entry.branch === "feature")!.slug + const response = await app.inject({ method: "DELETE", url: `/api/workspaces/workspace/worktrees/${slug}` }) + + assert.equal(response.statusCode, 409) + assert.equal(path.resolve(reserved), path.resolve(linked)) + assert.equal(released, true) + assert.equal(path.resolve(current.location.directory), path.resolve(workspacePath)) + assert.equal(execFileSync("git", ["-C", linked, "status", "--short"], { encoding: "utf8" }).trim(), "?? replacement.txt") + const inventory = execFileSync("git", ["-C", repo, "worktree", "list", "--porcelain"], { encoding: "utf8" }) + assert.ok(inventory.replace(/\\/g, "/").includes(linked.replace(/\\/g, "/"))) + } finally { + await app.close() + rmSync(temp, { recursive: true, force: true }) + } +}) + +it("fails a direct delete call closed when session evacuation fails", async () => { const temp = mkdtempSync(path.join(tmpdir(), "codenomad-delete-worktree-")) const target = path.join(temp, "doomed") const app = Fastify({ logger: false }) @@ -77,10 +129,28 @@ describe("worktree routes", () => { const workspace = { id: "workspace", path: temp, status: "ready" } as WorkspaceDescriptor const nativeSession = { id: "unloaded", projectID: "project", location: { directory: target }, cost: 0, tokens: {}, time: { created: 1, updated: 1 } } as SessionInfo + let blocker: "shell" | "pty" | "persistent" | undefined = "shell" const client = { - location: { get: async ({ location }: { location: { directory: string } }) => location }, - project: { - list: async () => [{ id: "project", canonical: temp, sandboxes: [target], time: { created: 1, updated: 1 } }], + project: { list: async () => [{ id: "project" }] }, + debug: { location: { list: async () => [{ directory: path.join(temp, "unrelated"), workspaceID: "root-location" }] } }, + shell: { list: async (_input: unknown, options?: { headers: Record }) => ({ + data: blocker === "shell" && readLocationContext(options?.headers["x-codenomad-location"], "legacy")?.workspaceID === "root-location" + ? [{ id: "sh_blocker", status: "running", cwd: target }] + : [], + }) }, + pty: { list: async (_input: unknown, options?: { headers: Record }) => ({ + data: blocker === "pty" && readLocationContext(options?.headers["x-codenomad-location"], "legacy")?.workspaceID === "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, + project: { id: "project", canonical: temp, directory: temp }, + }), }, session: { list: async () => ({ data: [nativeSession], cursor: {} }), @@ -88,25 +158,37 @@ describe("worktree routes", () => { move: async (input: { directory: string }) => { if (input.directory === temp) throw new Error("native move failed") }, + get: async () => nativeSession, }, } as unknown as OpenCodeClient const manager = { + getWorktrees: () => fixtureCatalogue(temp), get: () => workspace, getSharedServiceClient: async () => client, - getServiceDirectory: () => temp, + getServiceLocation: () => ({ directory: temp }), getServiceDirectoryForPath: async (_id: string, directory: string) => directory, + reserveWorktreeDeletion: async () => () => undefined, getWorktreeIdentityForPath: async () => "workspace:doomed", } as unknown as WorkspaceManager registerWorktreeRoutes(app, { workspaceManager: manager, worktreeDeletionFence: new WorktreeDeletionFence() }) - const response = await app.inject({ method: "DELETE", url: "/api/workspaces/workspace/worktrees/doomed" }) + const slug = (await fixtureCatalogue(temp)).worktrees.find(entry => entry.branch === "doomed")!.slug + 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/${slug}` }) + 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/${slug}` }) - assert.equal(response.statusCode, 400) + assert.equal(response.statusCode, 502) assert.match(response.json().error, /native move failed/) assert.match(execFileSync("git", ["-C", temp, "worktree", "list", "--porcelain"], { encoding: "utf8" }), /doomed/) } finally { await app.close() rmSync(temp, { recursive: true, force: true }) } - }) +}) }) diff --git a/packages/server/src/server/routes/worktrees.ts b/packages/server/src/server/routes/worktrees.ts index 60089b4f2..34be72e23 100644 --- a/packages/server/src/server/routes/worktrees.ts +++ b/packages/server/src/server/routes/worktrees.ts @@ -1,18 +1,26 @@ import type { FastifyInstance, FastifyReply } from "fastify" +import { stat } from "node:fs/promises" +import path from "node:path" import { z } from "zod" import { WorkspaceManager } from "../../workspaces/manager" import { resolveRepoRoot, - listWorktrees, isValidWorktreeSlug, - createManagedWorktree, - removeWorktree, } from "../../workspaces/git-worktrees" +import type { + WorktreeListResponse, + WorktreeSessionMoveRequest, + WorktreeSessionMoveResponse, +} from "../../api-types" import { invalidateWorktreeCache } from "../../workspaces/worktree-directory" -import type { WorktreeListResponse } from "../../api-types" -import { ensureCodenomadGitExclude } from "../../workspaces/worktree-map" -import { createInstanceClient } from "../../workspaces/instance-client" -import { evacuateWorktreeSessions, type WorktreeDeletionFence } from "../../workspaces/worktree-session-evacuation" +import { + moveProjectSessionFamily, + listCompleteProjectSessions, + ProjectSessionError, + removeProjectWorktree, +} from "../../workspaces/project-session-families" +import type { WorktreeDeletionFence } from "../../workspaces/worktree-session-evacuation" +import { locationRequestOptions, readLocationRef } from "../../opencode/compatibility/location" interface RouteDeps { workspaceManager: WorkspaceManager @@ -22,6 +30,11 @@ interface RouteDeps { const WorktreeCreateSchema = z.object({ slug: z.string().trim().min(1), branch: z.string().trim().min(1).optional(), + fromSlug: z.string().trim().min(1).optional(), +}) + +const WorktreeSessionMoveSchema = z.object({ + worktreeSlug: z.string().trim().min(1), }) export function registerWorktreeRoutes(app: FastifyInstance, deps: RouteDeps) { @@ -32,10 +45,13 @@ export function registerWorktreeRoutes(app: FastifyInstance, deps: RouteDeps) { return { error: "Workspace not found" } } - const { repoRoot, isGitRepo } = await resolveRepoRoot(workspace.path, request.log) - const worktrees = await listWorktrees({ repoRoot, workspaceFolder: workspace.path, logger: request.log }) - const response: WorktreeListResponse = { worktrees, isGitRepo } - return response + try { + const response: WorktreeListResponse = await deps.workspaceManager.getWorktrees(workspace.id) + invalidateWorktreeCache(workspace.id) + return response + } catch (error) { + return handleError(error, reply) + } }) app.post<{ Params: { id: string } }>("/api/workspaces/:id/worktrees", async (request, reply) => { @@ -63,24 +79,82 @@ export function registerWorktreeRoutes(app: FastifyInstance, deps: RouteDeps) { } } - const { repoRoot, isGitRepo } = await resolveRepoRoot(workspace.path, request.log) + const { isGitRepo } = await resolveRepoRoot(workspace.path, request.log) if (!isGitRepo) { reply.code(400) return { error: "Workspace is not a Git repository" } } - await ensureCodenomadGitExclude(workspace.path, request.log).catch(() => undefined) + const catalogue = await deps.workspaceManager.getWorktrees(workspace.id) + const source = catalogue.worktrees.find(entry => entry.slug === (body.fromSlug ?? "root")) + if (!source) throw new ProjectSessionError("Source worktree not found", 404) + const identities = await Promise.all(catalogue.worktrees.map(entry => ( + deps.workspaceManager.getWorktreeIdentityForPath(workspace.id, entry.directory) + ))) + if (identities.some(identity => !identity)) throw new ProjectSessionError("Unable to identify worktrees before creation", 409) + const release = deps.worktreeDeletionFence.enter(identities as string[]) + if (!release) throw new ProjectSessionError("A worktree is being removed", 409) + try { + const created = await deps.workspaceManager.createWorktree(workspace.id, slug, source.slug) + invalidateWorktreeCache(workspace.id) + reply.code(201) + return created + } finally { release() } + } catch (error) { + return handleError(error, reply) + } + }) + + 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" } + } - const created = await createManagedWorktree({ - repoRoot, - workspaceFolder: workspace.path, - slug, - logger: request.log, + try { + const { worktreeSlug } = WorktreeSessionMoveSchema.parse(request.body ?? {}) + const { isGitRepo } = await resolveRepoRoot(workspace.path, request.log) + if (!isGitRepo) throw new ProjectSessionError("Workspace is not a Git repository", 409) + const worktrees = await strictWorktrees(deps.workspaceManager, workspace.id) + const target = worktrees.find((worktree) => worktree.slug === worktreeSlug) + if (!target) throw new ProjectSessionError("Worktree not found", 404) + const projectLocation = deps.workspaceManager.getServiceLocation(workspace.id) + const targetDirectory = await deps.workspaceManager.getServiceDirectoryForPath(workspace.id, target.directory) + if (!projectLocation || !targetDirectory) throw new ProjectSessionError("Unable to resolve OpenCode worktree paths", 409) + const moved = await moveProjectSessionFamily({ + client: await deps.workspaceManager.getSharedServiceClient(), + projectLocation, + sessionId: request.params.sessionId, + targetDirectory, + resolveExactDirectory: (directory) => deps.workspaceManager.getServiceDirectoryForPath(workspace.id, directory), + validateTarget: async () => { + const refreshed = await strictWorktrees(deps.workspaceManager, workspace.id) + return refreshed.some((worktree) => worktree.slug === worktreeSlug + && worktree.registeredDirectory === target.registeredDirectory + && worktree.head === target.head) + }, + runMutation: async (directories, operation) => { + const identities = await Promise.all(directories.map((directory) => ( + deps.workspaceManager.getWorktreeIdentityForPath(workspace.id, directory) + ))) + if (identities.some((identity) => !identity)) { + throw new ProjectSessionError("Unable to identify session worktrees", 409) + } + const release = deps.worktreeDeletionFence.enter(identities as string[]) + if (!release) throw new ProjectSessionError("A session worktree is being removed", 409) + try { + return await operation() + } finally { + release() + } + }, }) - invalidateWorktreeCache(workspace.id) - - reply.code(201) - return created + const response: WorktreeSessionMoveResponse = { ...moved, worktreeSlug } + return response } catch (error) { return handleError(error, reply) } @@ -101,7 +175,7 @@ export function registerWorktreeRoutes(app: FastifyInstance, deps: RouteDeps) { return { error: "Invalid worktree slug" } } - const { repoRoot, isGitRepo } = await resolveRepoRoot(workspace.path, request.log) + const { isGitRepo } = await resolveRepoRoot(workspace.path, request.log) if (!isGitRepo) { reply.code(400) return { error: "Workspace is not a Git repository" } @@ -110,35 +184,77 @@ 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(deps.workspaceManager, workspace.id) const match = worktrees.find((wt) => wt.slug === slug) if (!match || match.kind === "root") { reply.code(404) return { error: "Worktree not found" } } - - const [client, targetDirectory] = await Promise.all([ - createInstanceClient(deps.workspaceManager, workspace.id), - deps.workspaceManager.getServiceDirectoryForPath(workspace.id, match.directory), - ]) - const projectDirectory = deps.workspaceManager.getServiceDirectory(workspace.id) - if (!client || !projectDirectory || !targetDirectory) { - throw new Error("Unable to inventory sessions before deleting worktree") + if (match.removable === false) throw new ProjectSessionError("Git's main checkout cannot be removed", 409) + const targetHostDirectory = match.registeredDirectory ?? match.directory + const targetIdentity = await deps.workspaceManager.getWorktreeIdentityForPath(workspace.id, match.directory) + const pathIdentity = await readPathIdentity(targetHostDirectory) + if (!targetIdentity || !pathIdentity) throw new ProjectSessionError("Unable to identify worktree before deletion", 409) + let releaseDeletion: () => void + try { + releaseDeletion = await deps.workspaceManager.reserveWorktreeDeletion(targetHostDirectory) + } catch (error) { + throw new ProjectSessionError(error instanceof Error ? error.message : "Unable to reserve worktree deletion", 409) } - const worktreeIdentity = await deps.workspaceManager.getWorktreeIdentityForPath(workspace.id, match.directory) - if (!worktreeIdentity) throw new Error("Unable to identify worktree before deletion") - await deps.worktreeDeletionFence.run(worktreeIdentity, [worktreeIdentity], () => ( - evacuateWorktreeSessions({ + try { + const client = await deps.workspaceManager.getSharedServiceClient() + const projectLocation = deps.workspaceManager.getServiceLocation(workspace.id) + const rootHostDirectory = worktrees.find((worktree) => worktree.kind === "root")!.directory + const [targetDirectory, rootDirectory] = await Promise.all([ + deps.workspaceManager.getServiceDirectoryForPath(workspace.id, match.directory), + deps.workspaceManager.getServiceDirectoryForPath(workspace.id, rootHostDirectory), + ]) + if (!projectLocation || !targetDirectory || !rootDirectory) { + throw new ProjectSessionError("Unable to resolve OpenCode worktree paths", 409) + } + const targetServiceRoot = resolveServiceWorktreeRoot(targetHostDirectory, match.directory, targetDirectory) + const isTargetRegistered = async () => { + const refreshed = await strictWorktrees(deps.workspaceManager, workspace.id) + const current = refreshed.find((worktree) => worktree.slug === slug && worktree.kind === "worktree") + return Boolean(current + && current.registeredDirectory === match.registeredDirectory + && current.head === match.head + && current.branch === match.branch + && samePathIdentity(pathIdentity, await readPathIdentity(targetHostDirectory))) + } + await removeProjectWorktree({ client, - projectDirectory, + projectLocation, targetDirectory, - rootDirectory: projectDirectory, - resolveDirectoryIdentity: (directory) => deps.workspaceManager.getWorktreeIdentityForPath(workspace.id, directory), + rootDirectory, resolveExactDirectory: (directory) => deps.workspaceManager.getServiceDirectoryForPath(workspace.id, directory), - remove: () => removeWorktree({ workspaceFolder: workspace.path, directory: match.directory, force, logger: request.log }), + matchesTarget: async (directory) => servicePathContains(targetServiceRoot, directory), + validateBeforeRemove: async (projectID) => { + await assertNoWorktreeBlockers(client, projectID, targetServiceRoot, targetDirectory) + }, + runMutation: async (directories, operation) => { + const identities = await Promise.all(directories.map(async (directory) => ( + await deps.workspaceManager.getWorktreeIdentityForPath(workspace.id, directory) + ?? (servicePathContains(targetServiceRoot, directory) ? targetIdentity : undefined) + ))) + if (identities.some((identity) => !identity)) { + throw new ProjectSessionError("Unable to identify session worktrees", 409) + } + return deps.worktreeDeletionFence.run(targetIdentity, identities as string[], operation) + }, + remove: async () => { + try { + await deps.workspaceManager.removeWorktree(workspace.id, targetServiceRoot, force) + } catch (error) { + throw new ProjectSessionError(error instanceof Error ? error.message : "Unable to remove worktree", 409) + } + }, + isTargetRegistered, }) - )) - invalidateWorktreeCache(workspace.id) + invalidateWorktreeCache(workspace.id) + } finally { + releaseDeletion() + } reply.code(204) } catch (error) { @@ -148,7 +264,90 @@ export function registerWorktreeRoutes(app: FastifyInstance, deps: RouteDeps) { ) } +function strictWorktrees(manager: WorkspaceManager, workspaceId: string) { + return manager.getWorktrees(workspaceId).then(result => result.worktrees).catch((error) => { + throw new ProjectSessionError(error instanceof Error ? error.message : "Unable to read native worktree inventory", 502) + }) +} + +type PathIdentity = { dev: number; ino: number; birthtimeMs: number } + +async function readPathIdentity(directory: string): Promise { + try { + const value = await stat(directory) + return { dev: value.dev, ino: value.ino, birthtimeMs: value.birthtimeMs } + } catch { + return undefined + } +} + +function samePathIdentity(left: PathIdentity, right: PathIdentity | undefined): boolean { + return Boolean(right && left.dev === right.dev && left.ino === right.ino && left.birthtimeMs === right.birthtimeMs) +} + +function resolveServiceWorktreeRoot(hostRoot: string, hostWorkspace: string, serviceWorkspace: string): string { + const relative = path.relative(hostRoot, hostWorkspace) + if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { + throw new ProjectSessionError("Workspace path is outside the registered worktree", 409) + } + const servicePath = /^[A-Za-z]:[\\/]|^(?:\\\\|\/\/)/.test(serviceWorkspace) ? path.win32 : path.posix + return relative.split(/[\\/]/).filter(Boolean).reduce((directory) => servicePath.dirname(directory), serviceWorkspace) +} + +function servicePathContains(root: string, candidate: string): boolean { + const servicePath = /^[A-Za-z]:[\\/]|^(?:\\\\|\/\/)/.test(root) ? path.win32 : path.posix + const relative = servicePath.relative(root, candidate) + return relative === "" || (relative !== ".." && !relative.startsWith(`..${servicePath.sep}`) && !servicePath.isAbsolute(relative)) +} + +async function assertNoWorktreeBlockers( + client: Awaited>, + currentProjectID: string, + targetRoot: string, + targetDirectory: string, +): Promise { + const [nativeLocations, projects] = await Promise.all([ + client.debug.location.list(), + client.project.list(), + ]) + const locations = new Map() + const candidates = [{ directory: targetDirectory }, ...nativeLocations.map(readLocationRef)] + for (const location of candidates) { + locations.set(`${location.directory}\0${location.workspaceID ?? ""}`, location) + } + const resources = await Promise.all([...locations.values()].map(async (location) => Promise.all([ + client.shell.list({ location: { directory: location.directory } }, locationRequestOptions(location)), + client.pty.list({ location: { directory: location.directory } }, locationRequestOptions(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) + } + } +} + 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 55e174321..4e5382b0d 100644 --- a/packages/server/src/workspaces/__tests__/git-worktrees.test.ts +++ b/packages/server/src/workspaces/__tests__/git-worktrees.test.ts @@ -4,7 +4,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import path from "node:path" import { describe, it } from "node:test" -import { listWorktrees } from "../git-worktrees" +import { fixtureCatalogue } from "./native-worktree-fixture" describe("listWorktrees", () => { it("uses the selected workspace folder for the root worktree directory", async () => { @@ -19,7 +19,7 @@ describe("listWorktrees", () => { execFileSync("git", ["-C", repoRoot, "add", "README.md"]) execFileSync("git", ["-C", repoRoot, "-c", "user.name=CodeNomad Test", "-c", "user.email=test@codenomad.local", "commit", "-m", "test"]) - const worktrees = await listWorktrees({ repoRoot, workspaceFolder }) + const { worktrees } = await fixtureCatalogue(workspaceFolder) assert.equal(worktrees[0]?.slug, "root") assert.equal(worktrees[0]?.directory, workspaceFolder) diff --git a/packages/server/src/workspaces/__tests__/native-worktree-fixture.ts b/packages/server/src/workspaces/__tests__/native-worktree-fixture.ts new file mode 100644 index 000000000..61d31f0b2 --- /dev/null +++ b/packages/server/src/workspaces/__tests__/native-worktree-fixture.ts @@ -0,0 +1,23 @@ +import { execFileSync } from "node:child_process" +import path from "node:path" +import type { OpenCodeClient } from "@opencode/client" +import { listNativeWorktrees } from "../native-worktrees" + +// Only the native discovery transport is replaced. Filesystem identity, Git +// annotations, nested projection and the production catalogue remain real. +export function fixtureCatalogue(workspacePath: string) { + const git = (directory: string, ...args: string[]) => execFileSync("git", ["-C", directory, ...args], { encoding: "utf8" }).trim() + const client = { + location: { get: async ({ location }: { location: { directory: string } }) => { + const directory = git(location.directory, "rev-parse", "--show-toplevel") + const common = git(directory, "rev-parse", "--path-format=absolute", "--git-common-dir") + return { directory: location.directory, project: { id: "fixture", directory, canonical: path.dirname(common) } } + } }, + worktree: { + refresh: async () => {}, + list: async () => git(workspacePath, "worktree", "list", "--porcelain", "-z").split("\0") + .filter(field => field.startsWith("worktree ")).map(field => ({ directory: field.slice(9) })), + }, + } as unknown as OpenCodeClient + return listNativeWorktrees({ client, workspacePath, location: { directory: workspacePath }, toHost: async directory => directory }) +} diff --git a/packages/server/src/workspaces/git-worktrees.ts b/packages/server/src/workspaces/git-worktrees.ts index 087009015..d7debf81e 100644 --- a/packages/server/src/workspaces/git-worktrees.ts +++ b/packages/server/src/workspaces/git-worktrees.ts @@ -1,260 +1,60 @@ -import path from "path" -import { spawn } from "child_process" -import type { WorktreeDescriptor } from "../api-types" -import { promises as fsp } from "fs" +import path from "node:path" +import { execFile } from "node:child_process" +import { promisify } from "node:util" +import { realpath } from "node:fs/promises" export interface LogLike { debug?: (obj: any, msg?: string) => void warn?: (obj: any, msg?: string) => void } -type GitResult = { ok: true; stdout: string } | { ok: false; error: Error; stdout?: string; stderr?: string } - -function isGitUnavailableResult(result: GitResult): boolean { - return !result.ok && (result.error as NodeJS.ErrnoException | undefined)?.code === "ENOENT" -} - -function runGit(args: string[], cwd: string): Promise { - return new Promise((resolve) => { - const child = spawn("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"] }) - let stdout = "" - let stderr = "" - - child.stdout?.on("data", (chunk) => { - stdout += chunk.toString() - }) - child.stderr?.on("data", (chunk) => { - stderr += chunk.toString() - }) - child.once("error", (error) => { - resolve({ ok: false, error, stdout, stderr }) - }) - child.once("close", (code) => { - if (code === 0) { - resolve({ ok: true, stdout }) - } else { - const error = new Error(stderr.trim() || `git ${args.join(" ")} failed with code ${code}`) - resolve({ ok: false, error, stdout, stderr }) - } - }) - }) +const execute = promisify(execFile) +async function git(directory: string, args: string[]): Promise { + const { stdout } = await execute("git", ["-C", directory, ...args], { windowsHide: true, maxBuffer: 1024 * 1024 }) + return stdout.replace(/\r?\n$/, "") } export async function resolveRepoRoot(folder: string, logger?: LogLike): Promise<{ repoRoot: string; isGitRepo: boolean }> { - const result = await runGit(["rev-parse", "--show-toplevel"], folder) - if (isGitUnavailableResult(result)) { - throw new Error("Git is not installed or not available in PATH") - } - if (!result.ok) { - 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() - if (!repoRoot) { + try { + return { repoRoot: await git(folder, ["rev-parse", "--show-toplevel"]), isGitRepo: true } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") throw new Error("Git is not installed or not available in PATH") + logger?.debug?.({ folder, err: error }, "Folder is not a Git repository; using workspace folder as root") return { repoRoot: folder, isGitRepo: false } } - return { repoRoot, isGitRepo: true } } export async function isGitAvailable(folder: string): Promise { - const result = await runGit(["--version"], folder) - return result.ok || !isGitUnavailableResult(result) + return git(folder, ["--version"]).then(() => true, error => error.code !== "ENOENT") } -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 } = {} - - const flush = () => { - if (current.worktree) { - records.push({ worktree: current.worktree, branch: current.branch }) - } - current = {} - } - - for (const line of lines) { - const trimmed = line.trim() - if (!trimmed) { - flush() - continue - } - const [key, ...rest] = trimmed.split(" ") - const value = rest.join(" ").trim() - if (key === "worktree") { - current.worktree = value - } else if (key === "branch") { - // branch is like refs/heads/foo - current.branch = value.replace(/^refs\/heads\//, "") - } else if (key === "HEAD") { - current.head = value - } else if (key === "detached") { - current.detached = true - } - } - flush() - return records -} - -export async function listWorktrees(params: { - repoRoot: string - workspaceFolder: string - logger?: LogLike -}): Promise { - const { repoRoot, workspaceFolder, logger } = params - - const result = await runGit(["worktree", "list", "--porcelain"], workspaceFolder) - if (!result.ok) { - 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) - const rootRecord = records.find((record) => path.resolve(record.worktree) === path.resolve(repoRoot)) - const rootDescriptor: WorktreeDescriptor = { - slug: "root", - directory: workspaceFolder, - kind: "root", - branch: rootRecord?.branch, - } - - const worktrees: WorktreeDescriptor[] = [rootDescriptor] - const seen = new Set(["root"]) - - const normalizeSlug = (record: { branch?: string; head?: string; detached?: boolean; worktree: string }): string => { - const branch = (record.branch ?? "").trim() - if (branch) { - return branch - } - const head = (record.head ?? "").trim() - if (head && /^[0-9a-f]{7,40}$/i.test(head)) { - return `detached-${head.slice(0, 7)}` - } - // Fallback: stable-ish identifier derived from directory basename. - const base = path.basename(record.worktree || "") - return base ? `worktree-${base}` : "worktree" - } - - - for (const record of records) { - const abs = record.worktree - if (!abs || typeof abs !== "string") continue - - // Skip the root record (we always expose it as slug="root"). - if (path.resolve(abs) === path.resolve(repoRoot)) { - continue - } - - const slug = normalizeSlug(record) - if (!slug || slug === "root") { - continue - } - if (seen.has(slug)) { - continue - } - seen.add(slug) - worktrees.push({ slug, directory: abs, kind: "worktree", branch: record.branch }) - } - - return worktrees +// Git annotations and branch policy, not another worktree registry. Native +// OpenCode owns discovery, creation and removal of the physical checkouts. +export async function readCheckout(directory: string) { + const [paths, head, branch] = await Promise.all([ + git(directory, ["rev-parse", "--show-toplevel", "--path-format=absolute", "--git-common-dir", "--absolute-git-dir"]), + git(directory, ["rev-parse", "--verify", "HEAD"]).catch(() => undefined), + git(directory, ["symbolic-ref", "--quiet", "--short", "HEAD"]).catch(() => undefined), + ]) + const [root, common, gitDirectory] = paths.split(/\r?\n/) + return { root: await realpath(root), common: await realpath(common), gitDirectory: await realpath(gitDirectory), head, branch } } export function isValidWorktreeSlug(slug: string): boolean { - if (!slug) return false - const trimmed = slug.trim() - if (!trimmed) return false - if (trimmed.length > 200) return false - // Disallow control characters; allow branch-like slugs including '/'. - if (/[\x00-\x1F\x7F]/.test(trimmed)) return false - return true + return Boolean(slug.trim() && slug.length <= 200 && !/[\x00-\x1F\x7F]/.test(slug)) } -export async function createManagedWorktree(params: { - repoRoot: string - workspaceFolder: string - slug: string - logger?: LogLike -}): Promise<{ slug: string; directory: string; branch?: string }> { - const { repoRoot, workspaceFolder, logger } = params - const branch = params.slug.trim() - - if (!branch || branch === "root" || !isValidWorktreeSlug(branch)) { - throw new Error("Invalid worktree slug") - } - - const sanitizeDirName = (input: string): string => { - const normalized = input - .trim() - .replace(/[\\/]+/g, "-") - .replace(/\s+/g, "-") - .replace(/[^a-zA-Z0-9_.-]+/g, "-") - .replace(/-{2,}/g, "-") - .replace(/^-+|-+$/g, "") - return normalized || "worktree" - } - - const worktreesDir = path.join(repoRoot, ".codenomad", "worktrees") - const targetDir = path.join(worktreesDir, sanitizeDirName(branch)) - await fsp.mkdir(worktreesDir, { recursive: true }) - - try { - const stat = await fsp.stat(targetDir) - if (stat.isDirectory()) { - throw new Error("Worktree directory already exists") - } - } catch (error) { - const code = (error as NodeJS.ErrnoException).code - if (code !== "ENOENT") { - throw error - } - } - - logger?.debug?.({ slug: branch, branch, targetDir }, "Creating managed git worktree") - - // Prefer creating a new branch from HEAD. - const first = await runGit(["worktree", "add", "-b", branch, targetDir, "HEAD"], workspaceFolder) - if (first.ok) { - return { slug: branch, directory: targetDir, branch } - } - - const message = first.stderr?.toLowerCase() ?? first.error.message.toLowerCase() - if (message.includes("already exists")) { - // If the branch already exists, add worktree for that branch. - const second = await runGit(["worktree", "add", targetDir, branch], workspaceFolder) - if (second.ok) { - return { slug: branch, directory: targetDir, branch } - } - throw second.error - } - - throw first.error +export async function prepareWorktreeBranch(directory: string, branch: string): Promise<{ existing: boolean; revision: string }> { + await git(directory, ["check-ref-format", "--branch", branch]) + const revision = await git(directory, ["rev-parse", "--verify", `refs/heads/${branch}`]).catch(() => undefined) + return { existing: Boolean(revision), revision: revision ?? await git(directory, ["rev-parse", "--verify", "HEAD"]) } } -export async function removeWorktree(params: { - workspaceFolder: string - directory: string - force?: boolean - logger?: LogLike -}): Promise { - const { workspaceFolder, logger } = params - const directory = (params.directory ?? "").trim() - if (!directory) { - throw new Error("Invalid worktree directory") - } - logger?.debug?.({ directory, force: Boolean(params.force) }, "Removing git worktree") - - const args = ["worktree", "remove"] - if (params.force) { - args.push("--force") - } - args.push(directory) - - const result = await runGit(args, workspaceFolder) - if (!result.ok) { - throw result.error - } +export async function attachWorktreeBranch(directory: string, branch: string, existing: boolean): Promise { + // Never -B/--force: preserve branch refs and Git's already-checked-out guard. + await git(directory, ["switch", ...(existing ? [] : ["-c"]), branch]) +} - // Best-effort cleanup of stale metadata. - await runGit(["worktree", "prune"], workspaceFolder).catch(() => undefined) +export async function gitExcludePath(directory: string): Promise { + return path.resolve(directory, await git(directory, ["rev-parse", "--git-path", "info/exclude"])) } diff --git a/packages/server/src/workspaces/instance-events.test.ts b/packages/server/src/workspaces/instance-events.test.ts index 11dfd2f85..9efa6f73c 100644 --- a/packages/server/src/workspaces/instance-events.test.ts +++ b/packages/server/src/workspaces/instance-events.test.ts @@ -19,8 +19,13 @@ function deferred() { function waitFor(check: () => boolean): Promise { return new Promise((resolve, reject) => { - const timeout = setTimeout(() => reject(new Error("Timed out waiting for event")), 2000) + let expired = false + const timeout = setTimeout(() => { + expired = true + reject(new Error("Timed out waiting for event")) + }, 2000) const poll = () => { + if (expired) return if (check()) { clearTimeout(timeout) resolve() @@ -676,6 +681,35 @@ describe("InstanceEventBridge", () => { } }) + it("scopes typed plugin events to the owner of their required native location", async () => { + const location = { directory: "/repo-b", workspaceID: "workspace-b" } + const events = [{ + id: "rpc-event", + created: 1, + type: "rpc.example.updated", + location, + data: { itemID: "item" }, + }] as OpenCodeEvent[] + const workspaces = [{ id: "a", path: "/repo-a" }, { id: "b", path: "/repo-b", workspaceID: "workspace-b" }] + const { manager, sessionGets } = locationlessManager(events, {}, workspaces) + const bus = new EventBus() + const received: any[] = [] + bus.on("instance.event", (event) => { + if (event.event.type !== "server.connected") received.push(event) + }) + const bridge = new InstanceEventBridge({ workspaceManager: manager, eventBus: bus, logger }) + + try { + bus.publish({ type: "workspace.started", workspace: manager.list()[0] as any }) + await waitFor(() => received.length === 1) + assert.equal(sessionGets(), 0) + assert.equal(received[0].instanceId, "b") + assert.equal(received[0].event.type, "rpc.example.updated") + } finally { + bridge.shutdown() + } + }) + it("broadcasts safe global locationless service events", async () => { const events = [ { type: "agent.updated", data: {} }, diff --git a/packages/server/src/workspaces/instance-events.ts b/packages/server/src/workspaces/instance-events.ts index cb3c8aa05..8d23316d0 100644 --- a/packages/server/src/workspaces/instance-events.ts +++ b/packages/server/src/workspaces/instance-events.ts @@ -4,6 +4,7 @@ import { EventBus } from "../events/bus" import { Logger } from "../logger" import { WorkspaceManager } from "./manager" import { InstanceStreamStatus } from "../api-types" +import { invalidateWorktreeCache } from "./worktree-directory" const RECONNECT_DELAY_MS = 1000 const LOCATION_OWNER_CACHE_MS = 2000 @@ -28,6 +29,7 @@ const GLOBAL_EVENT_TYPES = new Set([ "server.connected", "skill.updated", "websearch.updated", + "worktree.updated", ]) interface InstanceEventBridgeOptions { @@ -107,6 +109,10 @@ export class InstanceEventBridge { } private async publishEvent(event: OpenCodeEvent) { + if (event.type === "worktree.updated") { + invalidateWorktreeCache() + this.locationOwners.clear() + } const sessionId = this.sessionId(event) const ptyId = this.ptyId(event) const shellId = this.shellId(event) diff --git a/packages/server/src/workspaces/manager.test.ts b/packages/server/src/workspaces/manager.test.ts index 5c21deb0b..7ee41f638 100644 --- a/packages/server/src/workspaces/manager.test.ts +++ b/packages/server/src/workspaces/manager.test.ts @@ -1,5 +1,7 @@ import assert from "node:assert/strict" +import os from "node:os" import path from "node:path" +import { mkdir, mkdtemp, rm } from "node:fs/promises" import { describe, it } from "node:test" import type { LocationRef, OpenCodeClient, OpenCodeEvent } from "@opencode/client" import pino from "pino" @@ -158,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("retains and validates native identity rather than authorizing an arbitrary owned-directory selector", async () => { const service = new ControlledSharedService() service.resolvedLocation = { directory: process.cwd(), workspaceID: "native-one" } @@ -532,6 +546,21 @@ describe("workspace manager shared service lifecycle", () => { assert.equal(harness.service.evictionCalls[0]?.signal, undefined) }) + it("refuses deletion while another workspace occupies the worktree", async () => { + const temp = await mkdtemp(path.join(os.tmpdir(), "codenomad-worktree-owner-")) + const worktree = path.join(temp, "worktree") + const nested = path.join(worktree, "apps", "web") + await mkdir(nested, { recursive: true }) + const harness = createHarness() + try { + const { workspace } = await harness.manager.create(nested) + await assert.rejects(() => harness.manager.reserveWorktreeDeletion(worktree), /open as another workspace/) + await harness.manager.delete(workspace.id) + } finally { + await rm(temp, { recursive: true, force: true }) + } + }) + it("evicts a ready location on explicit final deletion without stopping the daemon", async () => { const harness = createHarness() const created = await harness.manager.create(process.cwd()) diff --git a/packages/server/src/workspaces/manager.ts b/packages/server/src/workspaces/manager.ts index 6c525e2f1..1cd08fc6d 100644 --- a/packages/server/src/workspaces/manager.ts +++ b/packages/server/src/workspaces/manager.ts @@ -30,7 +30,9 @@ import { type OpenCodeSharedServiceOptions, } from "./opencode-service" import { WslOpenCodeService } from "./wsl-opencode-service" -import { isPathOwnedByWorktree, resolveOwnedWorktreePath } from "./worktree-directory" +import { invalidateWorktreeCache, isPathOwnedByWorktree, resolveOwnedWorktreePath } from "./worktree-directory" +import { listNativeWorktrees, createNativeWorktree, removeNativeWorktree } from "./native-worktrees" +import { resolveRepoRoot } from "./git-worktrees" import { locationRequestOptions, readLocationRef, sameLocation } from "../opencode/compatibility/location" const DEFAULT_LAUNCH_TIMEOUT_MS = 30_000 @@ -163,6 +165,7 @@ export interface WorkspaceCreateOptions { type CreationRequestState = "owner" | "cancelled" | "released" export class WorkspaceManager { private readonly workspaces = new Map() + private readonly deletingWorktreeRoots = new Set() private readonly cancelledCreationRequests = new Set() private readonly pendingCreationRequests = new Set() private readonly activeLocationCreations = new Set>() @@ -196,6 +199,12 @@ export class WorkspaceManager { return record?.[WORKSPACE_STATE].published ? record.location?.directory ?? record.path : undefined } + getServiceLocation(id: string): LocationRef | undefined { + const record = this.workspaces.get(id) + if (!record?.[WORKSPACE_STATE].published) return undefined + return record.location ?? { directory: record.path } + } + async getSharedServiceEndpoint(id: string): Promise { if (!this.workspaces.get(id)?.[WORKSPACE_STATE].published) return undefined try { @@ -212,6 +221,21 @@ export class WorkspaceManager { return this.sharedService.client() } + async reserveWorktreeDeletion(directory: string): Promise<() => void> { + const submitted = path.isAbsolute(directory) ? directory : path.resolve(this.options.rootDir, directory) + const target = canonicalWorktreeIdentity(await realpath(submitted).catch(() => path.normalize(submitted)), this.options.platform) + 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, canonicalWorktreeIdentity(workspace.path, this.options.platform)) + ))) { + throw new Error("Worktree is open as another workspace") + } + this.deletingWorktreeRoots.add(target) + return () => this.deletingWorktreeRoots.delete(target) + } + getSharedServiceFetch(): Promise { return this.sharedService.fetch ? this.sharedService.fetch() : Promise.resolve(globalThis.fetch) } @@ -280,11 +304,49 @@ export class WorkspaceManager { ?? (path.posix.isAbsolute(candidate) ? candidate : undefined) } + private async nativeWorktreeContext(id: string) { + const record = this.workspaces.get(id) + const location = this.getServiceLocation(id) + if (!record || !location) throw new Error("Workspace has no native location") + return { + client: await this.getSharedServiceClient(), location, workspacePath: record.path, + toHost: async (directory: string) => record.wslDistro + ? this.resolveWslHostDirectory(directory, record.wslDistro, DEFAULT_LAUNCH_TIMEOUT_MS) + : directory, + } + } + + private readonly worktreeInventoryRequests = new Map>() + + async getWorktrees(id: string) { + const pending = this.worktreeInventoryRequests.get(id) + if (pending) return pending + const task = this.nativeWorktreeContext(id).then(listNativeWorktrees) + this.worktreeInventoryRequests.set(id, task) + try { return await task } + finally { if (this.worktreeInventoryRequests.get(id) === task) this.worktreeInventoryRequests.delete(id) } + } + + async createWorktree(id: string, branch: string, fromSlug?: string) { + try { return await createNativeWorktree(await this.nativeWorktreeContext(id), branch, fromSlug) } + finally { invalidateWorktreeCache() } + } + + async removeWorktree(id: string, serviceDirectory: string, force: boolean) { + try { return await removeNativeWorktree(await this.nativeWorktreeContext(id), serviceDirectory, force) } + finally { invalidateWorktreeCache() } + } + private async ownsHostDirectory(record: WorkspaceRecord, directory: string): Promise { + const [target, root] = await Promise.all([ + realpath(directory).catch(() => undefined), realpath(record.path).catch(() => undefined), + ]) + if (target && target === root) return true return (await resolveOwnedWorktreePath({ workspaceId: record.id, workspacePath: record.path, directory, + loadWorktrees: async () => (await this.getWorktrees(record.id)).worktrees, logger: this.options.logger, })) !== null } @@ -296,10 +358,20 @@ export class WorkspaceManager { : await this.resolveWslHostDirectory(directory, record.wslDistro, DEFAULT_LAUNCH_TIMEOUT_MS) : directory if (!hostDirectory) return null + const [target, root] = await Promise.all([ + realpath(hostDirectory).catch(() => undefined), realpath(record.path).catch(() => undefined), + ]) + // The explicitly opened folder is already authority. Do not require native + // discovery (or a second connection) to authorize that exact local directory. + if (target && target === root) { + const { repoRoot } = await resolveRepoRoot(root) + return { slug: "root", directory: target, worktreeDirectory: await realpath(repoRoot) } + } return resolveOwnedWorktreePath({ workspaceId: record.id, workspacePath: record.path, directory: hostDirectory, + loadWorktrees: async () => (await this.getWorktrees(record.id)).worktrees, logger: this.options.logger, }) } @@ -318,6 +390,7 @@ export class WorkspaceManager { workspaceId: record.id, workspacePath: record.path, candidate, + loadWorktrees: async () => (await this.getWorktrees(record.id)).worktrees, logger: this.options.logger, }) } @@ -400,6 +473,10 @@ export class WorkspaceManager { launchDeadlineAt, launchTimeoutMs, ) + const workspaceIdentity = canonicalWorktreeIdentity(workspacePath, this.options.platform) + if (Array.from(this.deletingWorktreeRoots).some((root) => pathContains(root, workspaceIdentity))) { + 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`) } @@ -1030,3 +1107,24 @@ export class WorkspaceManager { return candidates[0] ?? "" } } + +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)) +} + +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/native-worktrees.ts b/packages/server/src/workspaces/native-worktrees.ts new file mode 100644 index 000000000..c2df02a55 --- /dev/null +++ b/packages/server/src/workspaces/native-worktrees.ts @@ -0,0 +1,137 @@ +import { createHash } from "node:crypto" +import { realpath, stat } from "node:fs/promises" +import path from "node:path" +import type { LocationRef, OpenCodeClient } from "@opencode/client" +import type { WorktreeDescriptor, WorktreeListResponse } from "../api-types" +import { locationRequestOptions } from "../opencode/compatibility/location" +import { readCheckout, resolveRepoRoot, prepareWorktreeBranch, attachWorktreeBranch } from "./git-worktrees" +import { ensureCodenomadGitExclude } from "./worktree-map" + +export interface NativeWorktreeContext { + client: OpenCodeClient + location: LocationRef + workspacePath: string + toHost: (directory: string) => Promise +} + +const servicePaths = (directory: string) => /^[A-Za-z]:[\\/]|^(?:\\\\|\/\/)/.test(directory) ? path.win32 : path.posix +const identifier = (directory: string) => `worktree-${createHash("sha256").update(directory).digest("hex").slice(0, 20)}` + +async function sameDirectory(left: string, right: string): Promise { + const [a, b] = await Promise.all([stat(left), stat(right)]) + return a.isDirectory() && b.isDirectory() && a.dev === b.dev && a.ino === b.ino && a.birthtimeMs === b.birthtimeMs +} + +export async function listNativeWorktrees(context: NativeWorktreeContext): Promise { + const { client, location, workspacePath, toHost } = context + const { isGitRepo } = await resolveRepoRoot(workspacePath) + if (!isGitRepo) return { isGitRepo, worktrees: [{ slug: "root", directory: workspacePath, serviceDirectory: location.directory, kind: "root" }] } + const local = await readCheckout(workspacePath) + const current = await client.location.get({ location: { directory: location.directory } }, locationRequestOptions(location)) + const checkoutHost = await toHost(current.project.directory) + if (!checkoutHost || !await sameDirectory(local.root, checkoutHost)) throw new Error("OpenCode resolved a different local checkout") + const mainHost = await toHost(current.project.canonical) + if (!mainHost || !await sameDirectory(local.common, (await readCheckout(mainHost)).common)) { + throw new Error("OpenCode resolved a different local repository") + } + const options = locationRequestOptions(location, { includeDirectory: true }) + await client.worktree.refresh({ projectID: current.project.id }, options) + const native = await client.worktree.list({ projectID: current.project.id }, options) + const paths = servicePaths(location.directory) + const relative = paths.relative(current.project.directory, current.directory) + if (paths.isAbsolute(relative) || relative === ".." || relative.startsWith(`..${paths.sep}`)) throw new Error("Workspace is outside its checkout") + const worktrees: WorktreeDescriptor[] = [] + const seen = new Set() + // Large local repositories can contain hundreds of checkouts. Bound process + // concurrency, but do not serialize three Git processes per inventory entry. + const pending = native.values() + const inspect = async (entry: (typeof native)[number]) => { + const host = await toHost(entry.directory) + if (!host) throw new Error("Unable to translate native worktree directory") + try { + await stat(host) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return + throw error + } + // Independent clones can share a native project ID. Git's physical common + // directory decides local membership; branch names and remote URLs do not. + const checkout = await readCheckout(host) + if (!await sameDirectory(local.common, checkout.common)) return + const registeredDirectory = await realpath(host) + if (!await sameDirectory(checkout.root, registeredDirectory)) throw new Error("Native worktree entry is not a checkout root") + if (seen.has(registeredDirectory)) return + seen.add(registeredDirectory) + const root = await sameDirectory(local.root, registeredDirectory) + worktrees.push({ + slug: root ? "root" : identifier(registeredDirectory), + label: checkout.branch ?? `${path.basename(registeredDirectory)} @ ${checkout.head?.slice(0, 7) ?? "HEAD"}`, + directory: root ? workspacePath : path.join(registeredDirectory, ...relative.split(/[\\/]/).filter(Boolean)), + serviceDirectory: root ? current.directory : paths.join(entry.directory, relative), + registeredDirectory, + serviceRoot: entry.directory, + kind: root ? "root" : "worktree", + removable: !root && !await sameDirectory(checkout.gitDirectory, checkout.common), + branch: checkout.branch, + head: checkout.head, + }) + } + await Promise.all(Array.from({ length: Math.min(8, native.length) }, async () => { + for (const entry of pending) await inspect(entry) + })) + if (!worktrees.some(entry => entry.kind === "root")) throw new Error("Native worktree inventory is missing the opened checkout") + return { + isGitRepo, + defaultDirectory: paths.join(current.project.canonical, ".codenomad", "worktrees"), + worktrees: worktrees.sort((a, b) => a.kind === "root" ? -1 : b.kind === "root" ? 1 : a.label!.localeCompare(b.label!)), + } +} + +export async function createNativeWorktree(context: NativeWorktreeContext, branch: string, fromSlug = "root") { + const catalogue = await listNativeWorktrees(context) + const source = catalogue.worktrees.find(entry => entry.slug === fromSlug) + if (!catalogue.isGitRepo || !source?.serviceRoot || !catalogue.defaultDirectory) throw new Error("Source worktree not found") + const policy = await prepareWorktreeBranch(source.registeredDirectory!, branch) + await ensureCodenomadGitExclude(context.workspacePath) + const { client, location } = context + const current = await client.location.get({ location: { directory: location.directory } }, locationRequestOptions(location)) + const options = locationRequestOptions(location, { includeDirectory: true }) + const created = await client.worktree.create({ + projectID: current.project.id, + from: source.serviceRoot, + branch: policy.revision, + directory: catalogue.defaultDirectory, + name: branch.replace(/[^a-zA-Z0-9_.-]+/g, "-").replace(/^\.+/, "") || "worktree", + }, options) + const host = await context.toHost(created.directory) + const paths = servicePaths(created.directory) + const relative = paths.relative(catalogue.defaultDirectory, created.directory) + if (!host || !relative || paths.isAbsolute(relative) || relative === ".." || relative.startsWith(`..${paths.sep}`)) { + throw new Error("Native worktree creation returned an unexpected directory") + } + const [sourceCheckout, createdCheckout] = await Promise.all([readCheckout(source.registeredDirectory!), readCheckout(host)]) + if (!await sameDirectory(sourceCheckout.common, createdCheckout.common)) throw new Error("Created worktree belongs to another local repository") + try { + // Native creation deliberately detaches HEAD. Branch selection remains Git + // policy: preserve a named branch without duplicating worktree management. + await attachWorktreeBranch(host, branch, policy.existing) + } catch (error) { + try { + await client.worktree.remove({ projectID: current.project.id, directory: created.directory, force: false }, options) + } catch (rollback) { + throw new Error(`Unable to attach branch: ${String(error)}; worktree cleanup failed: ${String(rollback)}`) + } + throw error + } + const updated = await listNativeWorktrees(context) + const registered = await realpath(host) + const result = updated.worktrees.find(entry => entry.registeredDirectory === registered) + if (!result) throw new Error("Created worktree is missing from native inventory") + return result +} + +export async function removeNativeWorktree(context: NativeWorktreeContext, directory: string, force: boolean) { + const { client, location } = context + const current = await client.location.get({ location: { directory: location.directory } }, locationRequestOptions(location)) + await client.worktree.remove({ projectID: current.project.id, directory, force }, locationRequestOptions(location, { includeDirectory: true })) +} 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..c346bfd8e --- /dev/null +++ b/packages/server/src/workspaces/project-session-families.test.ts @@ -0,0 +1,380 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" +import type { OpenCodeClient, SessionInfo } from "@opencode/client" +import { readLocationContext, readLocationRef } from "../opencode/compatibility/location" +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, workspaceID?: string): SessionInfo { + const location = { directory, workspaceID } + 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, + } +} + +function clientHarness(initial: SessionInfo[], options: { + active?: string[] | (() => string[]) + failMove?: (sessionId: string, call: number) => boolean + visibilityDelayGets?: number + workspaceID?: string +} = {}) { + const sessions = new Map(initial.map((value) => [value.id, structuredClone(value)])) + const moveCalls: string[] = [] + let moveCall = 0 + const pending = new Map() + const client = { + location: { + get: async ({ location }: { location?: { directory?: string } }) => ({ + directory: location?.directory ?? ROOT, + workspaceID: options.workspaceID, + project: { id: "project", directory: ROOT, canonical: ROOT }, + }), + }, + session: { + list: async (input?: { workspace?: string }) => ({ + data: Array.from(sessions.values()) + .filter((value) => !input?.workspace || readLocationRef(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) + if (update && update.remaining-- <= 0) { + sessions.get(sessionID)!.location = update.location + pending.delete(sessionID) + } + return structuredClone(sessions.get(sessionID)!) + }, + move: async ({ sessionID, directory }: { sessionID: string; directory: string }, requestOptions?: { headers: Record }) => { + moveCall += 1 + moveCalls.push(sessionID) + if (options.failMove?.(sessionID, moveCall)) throw new Error(`move failed: ${sessionID}`) + const location = readLocationContext(requestOptions?.headers["x-codenomad-location"], "legacy") ?? { directory } + if (options.visibilityDelayGets) pending.set(sessionID, { location, remaining: options.visibilityDelayGets }) + else sessions.get(sessionID)!.location = location + }, + }, + } as unknown as OpenCodeClient + return { client, sessions, moveCalls } +} + +describe("project session families", () => { + it("loads the complete paginated project inventory", async () => { + const calls: Array<{ project?: string; 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) + 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), pages.map(({ id }) => id)) + assert.deepEqual(calls[0], { project: "project", limit: 500, order: "asc" }) + assert.deepEqual(calls.slice(1), ["page-2", "page-3", "page-4", "page-5"].map((cursor) => ({ cursor }))) + }) + + it("rejects malformed native cursors", async () => { + const client = { session: { list: async () => ({ data: [session("root")], cursor: { next: 42 } }) } } as unknown as OpenCodeClient + await assert.rejects(() => listCompleteProjectSessions(client, "project"), /invalid session inventory cursor/) + }) + + it("runs a family move inside the supplied worktree mutation guard", async () => { + const harness = clientHarness([session("root"), session("child", "root")]) + const guarded: string[][] = [] + + await moveProjectSessionFamily({ + client: harness.client, + projectLocation: { directory: ROOT }, + sessionId: "root", + targetDirectory: WORKTREE, + runMutation: async (directories, operation) => { + guarded.push(directories) + return operation() + }, + }) + + assert.deepEqual(guarded, [[ROOT, ROOT, WORKTREE]]) + }) + + it("resolves complete families and rejects incomplete ancestry", () => { + assert.deepEqual( + Array.from(resolveSessionFamilies([session("child", "root"), session("root")]).values()) + .map((family) => family.map(({ id }) => id)), + [["root", "child"]], + ) + assert.throws(() => resolveSessionFamilies([session("child", "missing")]), /missing parent/) + }) + + it("moves a complete family to the target", async () => { + const harness = clientHarness([session("root"), session("child", "root")]) + const moved = await moveProjectSessionFamily({ + client: harness.client, + projectLocation: { directory: ROOT }, + sessionId: "child", + targetDirectory: WORKTREE, + }) + assert.deepEqual(moved.sessionIds, ["root", "child"]) + assert.ok([...harness.sessions.values()].every(({ location }) => location.directory === WORKTREE)) + }) + + it("rejects a same-project destination redirected to another directory before moving", async () => { + const { client, moveCalls } = clientHarness([session("root")]) + client.location.get = async () => ({ directory: ROOT, project: { id: "project", directory: ROOT, canonical: ROOT } }) as any + await assert.rejects(moveProjectSessionFamily({ + client, projectLocation: { directory: ROOT }, sessionId: "root", targetDirectory: WORKTREE, + }), /foreign evacuation destination/) + assert.deepEqual(moveCalls, []) + }) + + 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 }) + assert.equal(harness.sessions.get("root")?.location.directory, WORKTREE) + }) + + it("rolls back transaction-owned moves after partial failure", async () => { + const harness = clientHarness([session("root"), session("child", "root")], { + failMove: (id, call) => id === "child" && call === 2, + }) + await assert.rejects(() => moveProjectSessionFamily({ + client: harness.client, + projectLocation: { directory: ROOT }, + sessionId: "root", + targetDirectory: WORKTREE, + }), /move failed/) + assert.deepEqual(harness.moveCalls, ["root", "child", "root"]) + assert.equal(harness.sessions.get("root")?.location.directory, ROOT) + }) + + it("blocks deletion while an attached session is active", async () => { + const harness = clientHarness([session("blocked", undefined, WORKTREE)], { active: ["blocked"] }) + await assert.rejects(() => removeProjectWorktree({ + client: harness.client, + projectLocation: { directory: ROOT }, + targetDirectory: WORKTREE, + rootDirectory: ROOT, + remove: async () => assert.fail("Git removal must not run"), + isTargetRegistered: async () => true, + }), (error: unknown) => error instanceof ProjectSessionError && error.statusCode === 409) + assert.deepEqual(harness.moveCalls, []) + }) + + it("re-inventories an initially empty worktree before removing it", async () => { + const harness = clientHarness([]) + let lists = 0 + ;(harness.client.session.list as any) = async () => ({ + data: ++lists < 3 ? [] : [session("intruder", undefined, WORKTREE)], + cursor: {}, + }) + + 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, + }), /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)], { + active: () => harness.moveCalls.includes("root") ? ["child"] : [], + }) + 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, + }), /Active sessions block/) + assert.equal(harness.sessions.get("root")?.location.directory, WORKTREE) + }) + + it("rolls back from session state when inventory visibility is stale", async () => { + const harness = clientHarness([session("root"), session("child", "root")], { + failMove: (id, call) => id === "child" && call === 2, + }) + const stale = [session("root"), session("child", "root")] + ;(harness.client.session.list as any) = async () => ({ data: structuredClone(stale), cursor: {} }) + await assert.rejects(() => moveProjectSessionFamily({ + client: harness.client, + projectLocation: { directory: 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, + projectLocation: { directory: ROOT }, + targetDirectory: "/home/dev/foo", + rootDirectory: ROOT, + remove: async () => { removed = true }, + isTargetRegistered: async () => true, + }) + assert.equal(removed, true) + assert.deepEqual(harness.moveCalls, []) + }) + + it("evacuates a complete family before removing its worktree", async () => { + const harness = clientHarness([session("root", undefined, WORKTREE), session("child", "root", WORKTREE)]) + let removed = false + await removeProjectWorktree({ + client: harness.client, + projectLocation: { directory: ROOT }, + targetDirectory: WORKTREE, + rootDirectory: ROOT, + remove: async () => { removed = true }, + isTargetRegistered: async () => true, + }) + assert.equal(removed, true) + assert.ok([...harness.sessions.values()].every(({ location }) => location.directory === ROOT)) + }) + + it("guards every family source worktree during evacuation", async () => { + const sibling = "/repo/.codenomad/worktrees/sibling" + const harness = clientHarness([session("root", undefined, sibling), session("child", "root", WORKTREE)]) + let guarded: string[] = [] + await removeProjectWorktree({ + client: harness.client, + projectLocation: { directory: ROOT }, + targetDirectory: WORKTREE, + rootDirectory: ROOT, + remove: async () => undefined, + isTargetRegistered: async () => true, + runMutation: async (directories, operation) => { + guarded = directories + return operation() + }, + }) + assert.ok(guarded.includes(sibling)) + assert.ok(guarded.includes(WORKTREE)) + assert.ok(guarded.includes(ROOT)) + }) + + it("blocks physical deletion when another workspace still owns a target session", async () => { + const harness = clientHarness([ + session("foreign", undefined, WORKTREE, "foreign-workspace"), + ], { workspaceID: "owned-workspace" }) + await assert.rejects(() => removeProjectWorktree({ + client: harness.client, + projectLocation: { directory: ROOT, workspaceID: "owned-workspace" }, + targetDirectory: WORKTREE, + rootDirectory: ROOT, + remove: async () => assert.fail("Git removal must not run"), + isTargetRegistered: async () => true, + }), /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({ + client: original.client, + projectLocation: { directory: ROOT }, + targetDirectory: WORKTREE, + rootDirectory: ROOT, + remove: async () => { throw new ProjectSessionError("dirty worktree", 409) }, + isTargetRegistered: async () => true, + }), /dirty worktree/) + assert.equal(original.sessions.get("original")?.location.directory, WORKTREE) + + const replacement = clientHarness([session("replacement", undefined, WORKTREE)]) + let identityChecks = 0 + await assert.rejects(() => removeProjectWorktree({ + client: replacement.client, + projectLocation: { directory: ROOT }, + targetDirectory: WORKTREE, + rootDirectory: ROOT, + remove: async () => { throw new ProjectSessionError("worktree changed", 409) }, + isTargetRegistered: async () => ++identityChecks === 1, + }), /Worktree changed/) + assert.deepEqual(replacement.moveCalls, ["replacement"]) + assert.equal(replacement.sessions.get("replacement")?.location.directory, ROOT) + }) +}) diff --git a/packages/server/src/workspaces/project-session-families.ts b/packages/server/src/workspaces/project-session-families.ts new file mode 100644 index 000000000..680e5e18e --- /dev/null +++ b/packages/server/src/workspaces/project-session-families.ts @@ -0,0 +1,445 @@ +import path from "node:path" +import type { LocationGetOutput, LocationRef, OpenCodeClient, SessionInfo } from "@opencode/client" +import { locationRequestOptions, moveSessionToLocation, readLocationRef } from "../opencode/compatibility/location" + +const SESSION_PAGE_LIMIT = 500 +const MAX_SESSION_PAGES = 1000 +const MOVE_VERIFY_ATTEMPTS = 100 +const MOVE_VERIFY_DELAY_MS = 50 +const projectLocks = new Map>() + +export class ProjectSessionError extends Error { + constructor(message: string, readonly statusCode: number) { + super(message) + this.name = "ProjectSessionError" + } +} + +export interface SessionFamilyMoveResult { + rootSessionId: string + sessionIds: string[] +} + +interface ProjectContext { + client: OpenCodeClient + project: LocationGetOutput["project"] + workspaceID?: string +} + +export async function listCompleteProjectSessions( + client: OpenCodeClient, + projectID: string, + workspaceID?: 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(cursor + ? { cursor } + : { project: projectID, limit: SESSION_PAGE_LIMIT, order: "asc" }) + 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 sessions", 409) + // Modern project inventory has no workspace selector. Filter the retained + // internal legacy identity after the complete native page is received. + if (workspaceID && readLocationRef(session.location).workspaceID !== workspaceID) continue + sessionIds.add(session.id) + sessions.push(session) + } + + const rawNext = response.cursor.next + if (rawNext != null && (typeof rawNext !== "string" || !rawNext.trim())) { + throw new ProjectSessionError("OpenCode returned an invalid session inventory cursor", 502) + } + const next = rawNext ?? 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 + projectLocation: LocationRef + sessionId: string + targetDirectory: string + resolveExactDirectory?: (directory: string) => Promise + validateTarget?: () => Promise + runMutation?: (directories: string[], operation: () => Promise) => Promise +}): Promise { + return withProject(params.client, params.projectLocation, 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) + assertWorkspaceFamily(family[1], context.workspaceID) + const target = await resolveProjectLocation(context, params.targetDirectory, params.resolveExactDirectory) + const move = async () => { + await assertInactive(context.client, family[1]) + if (params.validateTarget && !await params.validateTarget()) { + throw new ProjectSessionError("Worktree changed before the session move", 409) + } + await moveWithRollback(context, family[1], target) + return { rootSessionId: family[0], sessionIds: family[1].map(({ id }) => id) } + } + return params.runMutation + ? params.runMutation([...family[1].map(({ location }) => location.directory), target.directory], move) + : move() + }) +} + +export async function removeProjectWorktree(params: { + client: OpenCodeClient + projectLocation: LocationRef + targetDirectory: string + rootDirectory: string + resolveExactDirectory?: (directory: string) => Promise + remove: () => Promise + isTargetRegistered: () => Promise + matchesTarget?: (directory: string) => Promise + validateBeforeRemove?: (projectID: string) => Promise + runMutation?: (directories: string[], operation: () => Promise) => Promise +}): Promise { + await withProject(params.client, params.projectLocation, async (context) => { + const matchesTarget = params.matchesTarget + ?? (async (directory: string) => directoryContains(params.targetDirectory, directory)) + 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, params.resolveExactDirectory) + const remove = async () => { + if (!await params.isTargetRegistered()) { + throw new ProjectSessionError("Worktree changed before deletion", 409) + } + 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 + + try { + if (families.length) { + root = destination + for (const family of families) await moveMembers(context, family, destination, moved) + await verifyInventory(context, moved, new Map(moved.map((id) => [id, destination]))) + } + const refreshed = await listCompleteProjectSessions(context.client, context.project.id) + if ((await Promise.all(refreshed.map(({ location }) => matchesTarget(location.directory)))).some(Boolean)) { + throw new ProjectSessionError("Sessions remain attached to the worktree after evacuation", 409) + } + 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) : [] + if (changed.length) { + let registered: boolean + try { + registered = await params.isTargetRegistered() + } catch (inventoryError) { + throw new ProjectSessionError( + `${errorMessage(error)}; unable to verify worktree registration, rollback skipped: ${errorMessage(inventoryError)}`, + 500, + ) + } + // A mismatched identity may now be a replacement checkout; never move sessions into it. + if (registered) await rollback(context, changed, original, error) + } + throw asProjectError(error, "Unable to remove worktree") + } + } + return params.runMutation + ? 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) => readLocationRef(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, + operation: (context: ProjectContext) => Promise, +): Promise { + let location: LocationGetOutput + try { + location = await client.location.get({ + location: { directory: requested.directory }, + }, locationRequestOptions(requested)) + } 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 workspaceID = readLocationRef(location).workspaceID ?? requested.workspaceID + const lockKey = location.project.id + const previous = projectLocks.get(lockKey) ?? Promise.resolve() + const run = previous.catch(() => undefined).then(async () => { + try { + return await operation({ client, project: location.project, workspaceID }) + } catch (error) { + throw asProjectError(error, "Project session operation failed") + } + }) + const tail = run.then(() => undefined, () => undefined) + projectLocks.set(lockKey, tail) + try { + return await run + } finally { + if (projectLocks.get(lockKey) === tail) projectLocks.delete(lockKey) + } +} + +async function resolveProjectLocation(context: ProjectContext, directory: string, resolveExactDirectory?: (directory: string) => Promise): 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) + } + const expected = resolveExactDirectory ? await resolveExactDirectory(directory) : directory + const actual = resolveExactDirectory ? await resolveExactDirectory(location.directory) : location.directory + if (!expected || !actual || !sameDirectory(expected, actual)) { + throw new ProjectSessionError("OpenCode resolved a foreign evacuation destination", 409) + } + return readLocationRef(location) +} + +async function assertInactive(client: OpenCodeClient, sessions: SessionInfo[]): Promise { + if (!sessions.length) return + const active = await client.session.active() + const blockers = sessions.filter(({ id }) => Object.prototype.hasOwnProperty.call(active, id)).map(({ id }) => id) + if (blockers.length) throw new ProjectSessionError(`Active sessions block this operation: ${blockers.join(", ")}`, 409) +} + +async function moveWithRollback(context: ProjectContext, family: SessionInfo[], target: LocationRef): Promise { + const original = new Map(family.map((session) => [session.id, session.location])) + const moved: string[] = [] + try { + await moveMembers(context, family, target, moved) + const refreshed = await verifyInventory(context, moved, new Map(moved.map((id) => [id, target]))) + const refreshedFamily = resolveSessionFamilies(refreshed).get(family[0]!.id) + if (!refreshedFamily + || !family.every(({ id }) => refreshedFamily.some((session) => session.id === id)) + || !refreshedFamily.every((session) => sameLocation(session.location, target))) { + throw new ProjectSessionError("Session family changed during the move", 409) + } + } catch (error) { + const changed = await refreshChangedSessionIds(context, family.map(({ id }) => id), target) + await rollback(context, changed, original, error) + throw asProjectError(error, "Unable to move session family") + } +} + +async function moveMembers( + context: ProjectContext, + members: SessionInfo[], + target: LocationRef, + moved: string[], +): Promise { + for (const session of members) { + await assertInactive(context.client, [session]) + moved.push(session.id) + await moveSessionToLocation(context.client, session.id, target) + await waitForSessionLocation(context, session.id, target, `Session move verification failed: ${session.id}`) + } +} + +async function refreshChangedSessionIds( + context: ProjectContext, + candidates: string[], + transactionLocation: LocationRef, +): Promise { + const changed: string[] = [] + for (const id of candidates) { + try { + const session = await context.client.session.get({ sessionID: id }) + if (session.id !== id || session.projectID !== context.project.id) { + throw new ProjectSessionError(`OpenCode returned the wrong session while determining rollback state: ${id}`, 502) + } + if (sameLocation(session.location, transactionLocation)) { + changed.push(id) + } + } catch (error) { + throw new ProjectSessionError(`Unable to determine rollback state for ${id}: ${errorMessage(error)}`, 500) + } + } + return changed +} + +async function rollback( + context: ProjectContext, + moved: string[], + original: Map, + cause: unknown, +): Promise { + try { + for (const sessionId of [...moved].reverse()) { + const location = original.get(sessionId)! + await moveSessionToLocation(context.client, sessionId, location) + await waitForSessionLocation(context, sessionId, location, `Session rollback verification failed: ${sessionId}`) + } + await verifyInventory(context, moved, original) + } catch (rollbackError) { + throw new ProjectSessionError( + `${errorMessage(cause)}; rollback failed: ${errorMessage(rollbackError)}`, + 500, + ) + } +} + +async function verifyInventory( + context: ProjectContext, + sessionIds: string[], + expected: Map, +): Promise { + for (let attempt = 0; attempt < MOVE_VERIFY_ATTEMPTS; attempt += 1) { + const sessions = await listCompleteProjectSessions(context.client, context.project.id) + const refreshed = new Map(sessions.map((session) => [session.id, session])) + if (sessionIds.every((sessionId) => { + const session = refreshed.get(sessionId) + return Boolean(session && sameLocation(session.location, expected.get(sessionId)!)) + })) return sessions + await new Promise((resolve) => setTimeout(resolve, MOVE_VERIFY_DELAY_MS)) + } + throw new ProjectSessionError("Timed out waiting for session inventory verification", 409) +} + +async function waitForSessionLocation( + context: ProjectContext, + sessionId: string, + expected: LocationRef, + message: string, +): Promise { + for (let attempt = 0; attempt < MOVE_VERIFY_ATTEMPTS; attempt += 1) { + const session = await context.client.session.get({ sessionID: sessionId }) + if (session.id !== sessionId || session.projectID !== context.project.id) { + throw new ProjectSessionError(`OpenCode returned the wrong session after move: ${sessionId}`, 502) + } + if (sameLocation(session.location, expected)) return session + await new Promise((resolve) => setTimeout(resolve, MOVE_VERIFY_DELAY_MS)) + } + throw new ProjectSessionError(message, 409) +} + +function sameLocation(left: LocationRef, right: LocationRef): boolean { + return sameDirectory(left.directory, right.directory) && left.workspaceID === right.workspaceID +} + +function ancestryDepth(session: SessionInfo, byId: Map): number { + let depth = 0 + let current = session + while (current.parentID) { + current = byId.get(current.parentID)! + depth += 1 + } + return depth +} + +function sameDirectory(left: string, right: string): boolean { + if (isWindowsPath(left) !== isWindowsPath(right)) return false + if (!isWindowsPath(left)) return path.posix.resolve(left) === path.posix.resolve(right) + return path.win32.resolve(left).toLowerCase() === path.win32.resolve(right).toLowerCase() +} + +function directoryContains(parent: string, child: string): boolean { + if (isWindowsPath(parent) !== isWindowsPath(child)) return false + const paths = isWindowsPath(parent) ? path.win32 : path.posix + const relative = paths.relative(parent, child) + return relative === "" || (relative !== ".." && !relative.startsWith(`..${paths.sep}`) && !paths.isAbsolute(relative)) +} + +function isWindowsPath(value: string): boolean { + return /^[a-z]:[\\/]/i.test(value) || value.startsWith("\\\\") +} + +function asProjectError(error: unknown, fallback: string): ProjectSessionError { + if (error instanceof ProjectSessionError) return error + return new ProjectSessionError(error instanceof Error ? error.message : fallback, 502) +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} diff --git a/packages/server/src/workspaces/worktree-directory.test.ts b/packages/server/src/workspaces/worktree-directory.test.ts index c66032ff6..e14736d3c 100644 --- a/packages/server/src/workspaces/worktree-directory.test.ts +++ b/packages/server/src/workspaces/worktree-directory.test.ts @@ -5,7 +5,33 @@ import { realpath } from "node:fs/promises" import { tmpdir } from "node:os" import path from "node:path" import { test } from "node:test" -import { isPathWithinWorktree, resolveOwnedWorktreePath } from "./worktree-directory" +import { invalidateWorktreeCache, isPathWithinWorktree, resolveOwnedWorktreePath } from "./worktree-directory" +import { fixtureCatalogue } from "./__tests__/native-worktree-fixture" + +test("concurrent ownership misses share a refresh and cache negative results until invalidation", async (t) => { + const temp = mkdtempSync(path.join(tmpdir(), "codenomad-inventory-misses-")) + t.after(() => { invalidateWorktreeCache(temp); rmSync(temp, { recursive: true, force: true }) }) + const root = path.join(temp, "repo") + const foreign = path.join(temp, "foreign") + mkdirSync(root) + mkdirSync(foreign) + let loads = 0 + const loadWorktrees = async () => { + loads++ + await new Promise(resolve => setTimeout(resolve, 30)) + return [{ slug: "root", directory: root, kind: "root" as const }] + } + const params = { workspaceId: temp, workspacePath: root, loadWorktrees } + await resolveOwnedWorktreePath({ ...params, directory: root }) + const results = await Promise.all(Array.from({ length: 40 }, () => resolveOwnedWorktreePath({ ...params, directory: foreign }))) + assert.ok(results.every(result => result === null)) + assert.equal(loads, 2) + await resolveOwnedWorktreePath({ ...params, directory: foreign }) + assert.equal(loads, 2) + invalidateWorktreeCache(temp) + await resolveOwnedWorktreePath({ ...params, directory: foreign }) + assert.ok(loads > 2) +}) test("resolves nested and junction paths to their canonical owning worktree", async (t) => { const temp = mkdtempSync(path.join(tmpdir(), "codenomad-worktree-path-")) @@ -19,7 +45,8 @@ test("resolves nested and junction paths to their canonical owning worktree", as execFileSync("git", ["-C", root, "add", "README.md"]) execFileSync("git", ["-C", root, "-c", "user.name=CodeNomad Test", "-c", "user.email=test@codenomad.local", "commit", "-m", "test"]) const workspaceId = `workspace-${Date.now()}` - assert.equal((await resolveOwnedWorktreePath({ workspaceId, workspacePath: root, directory: root }))?.slug, "root") + const loadWorktrees = async () => (await fixtureCatalogue(root)).worktrees + assert.equal((await resolveOwnedWorktreePath({ workspaceId, workspacePath: root, directory: root, loadWorktrees }))?.slug, "root") mkdirSync(path.dirname(worktree), { recursive: true }) execFileSync("git", ["-C", root, "worktree", "add", "-b", "doomed", worktree]) mkdirSync(path.join(worktree, "nested")) @@ -31,19 +58,23 @@ test("resolves nested and junction paths to their canonical owning worktree", as rmSync(external, { recursive: true }) const resolved = await resolveOwnedWorktreePath({ + loadWorktrees, workspaceId, workspacePath: root, directory: path.join(alias, "nested"), }) - assert.equal(resolved?.slug, "doomed") + const expectedSlug = (await loadWorktrees()).find(entry => entry.branch === "doomed")!.slug + assert.equal(resolved?.slug, expectedSlug) assert.equal(resolved?.directory, await realpath(path.join(worktree, "nested"))) + assert.equal(resolved?.worktreeDirectory, await realpath(worktree)) assert.equal((await resolveOwnedWorktreePath({ + loadWorktrees, workspaceId, workspacePath: root, directory: path.join(worktree, "missing", "nested"), - }))?.slug, "doomed") - assert.equal(await resolveOwnedWorktreePath({ workspaceId, workspacePath: root, directory: path.join(dangling, "nested") }), null) + }))?.slug, expectedSlug) + assert.equal(await resolveOwnedWorktreePath({ workspaceId, workspacePath: root, directory: path.join(dangling, "nested"), loadWorktrees }), null) assert.equal(isPathWithinWorktree("\\\\wsl.localhost\\Ubuntu\\repo\\Foo", "\\\\wsl.localhost\\Ubuntu\\repo\\foo\\nested"), false) assert.equal(isPathWithinWorktree("\\\\wsl.localhost\\Ubuntu\\repo\\Foo", "\\\\wsl.localhost\\Ubuntu\\repo\\Foo\\nested"), true) assert.equal(isPathWithinWorktree("\\\\WSL.LOCALHOST\\ubuntu\\repo\\Foo", "\\\\wsl.localhost\\Ubuntu\\repo\\Foo\\nested"), true) diff --git a/packages/server/src/workspaces/worktree-directory.ts b/packages/server/src/workspaces/worktree-directory.ts index 5348f1c3b..6f5ca3dd0 100644 --- a/packages/server/src/workspaces/worktree-directory.ts +++ b/packages/server/src/workspaces/worktree-directory.ts @@ -1,13 +1,14 @@ import { lstat, realpath } from "fs/promises" import path from "node:path" import type { LogLike } from "./git-worktrees" -import { listWorktrees, resolveRepoRoot } from "./git-worktrees" +import type { WorktreeDescriptor } from "../api-types" + +type WorktreeSource = { loadWorktrees: () => Promise } type WorktreeCacheEntry = { expiresAt: number - repoRoot: string - worktrees: Array<{ slug: string; directory: string; normalizedDirectory: string }> - resolvedDirectories: Map + worktrees: Array<{ slug: string; directory: string; normalizedDirectory: string; worktreeDirectory: string }> + resolvedDirectories: Map } const WORKTREE_CACHE_TTL_MS = 10_000 @@ -24,7 +25,7 @@ async function normalizeDirectoryPath(directory: string): Promise { } } -async function getCachedWorktrees(params: { workspaceId: string; workspacePath: string; logger?: LogLike }) { +async function getCachedWorktrees(params: WorktreeSource & { workspaceId: string; workspacePath: string; logger?: LogLike }) { const cached = worktreeCache.get(params.workspaceId) const now = Date.now() if (cached && cached.expiresAt > now) { @@ -34,22 +35,22 @@ async function getCachedWorktrees(params: { workspaceId: string; workspacePath: const pending = worktreeLoads.get(params.workspaceId) if (pending) return pending - const load = (async () => { - const { repoRoot } = await resolveRepoRoot(params.workspacePath, params.logger) - const worktrees = await listWorktrees({ repoRoot, workspaceFolder: params.workspacePath, logger: params.logger }) + let load!: Promise + load = (async () => { + const worktrees = await params.loadWorktrees() const entry: WorktreeCacheEntry = { expiresAt: Date.now() + WORKTREE_CACHE_TTL_MS, - repoRoot, worktrees: await Promise.all( worktrees.map(async (wt) => ({ slug: wt.slug, directory: wt.directory, normalizedDirectory: await normalizeDirectoryPath(wt.directory), + worktreeDirectory: await normalizeDirectoryPath(wt.registeredDirectory ?? wt.directory), })), ), resolvedDirectories: new Map(), } - worktreeCache.set(params.workspaceId, entry) + if (worktreeLoads.get(params.workspaceId) === load) worktreeCache.set(params.workspaceId, entry) return entry })() worktreeLoads.set(params.workspaceId, load) @@ -60,11 +61,17 @@ async function getCachedWorktrees(params: { workspaceId: string; workspacePath: } } -export function invalidateWorktreeCache(workspaceId: string): void { - worktreeCache.delete(workspaceId) +export function invalidateWorktreeCache(workspaceId?: string): void { + if (workspaceId === undefined) { + worktreeCache.clear() + worktreeLoads.clear() + } else { + worktreeCache.delete(workspaceId) + worktreeLoads.delete(workspaceId) + } } -export async function resolveWorktreeDirectory(params: { +export async function resolveWorktreeDirectory(params: WorktreeSource & { workspaceId: string workspacePath: string worktreeSlug: string @@ -74,6 +81,7 @@ export async function resolveWorktreeDirectory(params: { workspaceId: params.workspaceId, workspacePath: params.workspacePath, logger: params.logger, + loadWorktrees: params.loadWorktrees, }) const match = cached.worktrees.find((wt) => wt.slug === params.worktreeSlug) if (match) { @@ -85,11 +93,12 @@ export async function resolveWorktreeDirectory(params: { workspaceId: params.workspaceId, workspacePath: params.workspacePath, logger: params.logger, + loadWorktrees: params.loadWorktrees, }) return refreshed.worktrees.find((wt) => wt.slug === params.worktreeSlug)?.directory ?? null } -export async function resolveWorktreeSlugForDirectory(params: { +export async function resolveWorktreeSlugForDirectory(params: WorktreeSource & { workspaceId: string workspacePath: string directory: string @@ -102,6 +111,7 @@ export async function resolveWorktreeSlugForDirectory(params: { workspaceId: params.workspaceId, workspacePath: params.workspacePath, logger: params.logger, + loadWorktrees: params.loadWorktrees, }) const match = cached.worktrees.find((wt) => wt.normalizedDirectory === target) if (match) { @@ -113,6 +123,7 @@ export async function resolveWorktreeSlugForDirectory(params: { workspaceId: params.workspaceId, workspacePath: params.workspacePath, logger: params.logger, + loadWorktrees: params.loadWorktrees, }) return refreshed.worktrees.find((wt) => wt.normalizedDirectory === target)?.slug ?? null } @@ -157,7 +168,7 @@ export function isPathWithinWorktree(root: string, candidate: string): boolean { return relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) } -export async function resolveOwnedWorktreePath(params: { +export async function resolveOwnedWorktreePath(params: WorktreeSource & { workspaceId: string workspacePath: string directory: string @@ -169,20 +180,21 @@ export async function resolveOwnedWorktreePath(params: { .filter((worktree) => isPathWithinWorktree(worktree.normalizedDirectory, target)) .sort((left, right) => right.normalizedDirectory.length - left.normalizedDirectory.length)[0] let entry = await getCachedWorktrees(params) - const known = entry.resolvedDirectories.get(target) - if (known) return known + if (entry.resolvedDirectories.has(target)) return entry.resolvedDirectories.get(target)! let match = find(entry.worktrees) if (!match || (match.slug === "root" && match.normalizedDirectory !== target)) { - invalidateWorktreeCache(params.workspaceId) + // Several foreign-location events can miss the same snapshot concurrently. + // Refresh that snapshot once; never discard another caller's pending load. + if (worktreeCache.get(params.workspaceId) === entry) worktreeCache.delete(params.workspaceId) entry = await getCachedWorktrees(params) match = find(entry.worktrees) } - const resolved = match ? { slug: match.slug, directory: target, worktreeDirectory: match.normalizedDirectory } : null - if (resolved) entry.resolvedDirectories.set(target, resolved) + const resolved = match ? { slug: match.slug, directory: target, worktreeDirectory: match.worktreeDirectory } : null + entry.resolvedDirectories.set(target, resolved) return resolved } -export async function isPathOwnedByWorktree(params: { +export async function isPathOwnedByWorktree(params: WorktreeSource & { workspaceId: string workspacePath: string candidate: string diff --git a/packages/server/src/workspaces/worktree-map.ts b/packages/server/src/workspaces/worktree-map.ts index 0dd674af0..37d702817 100644 --- a/packages/server/src/workspaces/worktree-map.ts +++ b/packages/server/src/workspaces/worktree-map.ts @@ -1,14 +1,10 @@ import { promises as fsp } from "fs" import path from "path" -import { resolveRepoRoot } from "./git-worktrees" +import { resolveRepoRoot, gitExcludePath } from "./git-worktrees" import type { LogLike } from "./git-worktrees" -function getGitExcludePath(repoRoot: string): string { - return path.join(repoRoot, ".git", "info", "exclude") -} - async function ensureGitExclude(repoRoot: string, logger?: LogLike): Promise { - const excludePath = getGitExcludePath(repoRoot) + const excludePath = await gitExcludePath(repoRoot) try { await fsp.mkdir(path.dirname(excludePath), { recursive: true }) } catch { diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index ebf1032c7..67f3b3b6a 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -17,6 +17,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" @@ -324,6 +325,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/instance/shell/right-panel/git-changes-model.test.ts b/packages/ui/src/components/instance/shell/right-panel/git-changes-model.test.ts index 596d5dc51..dd4fdc9fa 100644 --- a/packages/ui/src/components/instance/shell/right-panel/git-changes-model.test.ts +++ b/packages/ui/src/components/instance/shell/right-panel/git-changes-model.test.ts @@ -1,9 +1,21 @@ import assert from "node:assert/strict" import { describe, it } from "node:test" -import { adaptSdkGitStatusEntries } from "./git-changes-model.ts" +import { adaptSdkGitStatusEntries, buildGitChangeListItems } from "./git-changes-model.ts" describe("adaptSdkGitStatusEntries", () => { + it("does not resurrect stale native files after a worktree checkout or a clean local snapshot", () => { + const native = [{ file: "old-checkout.ts", additions: 100, deletions: 100, status: "modified" as const }] + const detail = { + path: "proof.txt", originalPath: null, stagedStatus: null, stagedAdditions: 0, stagedDeletions: 0, + unstagedStatus: "untracked" as const, unstagedAdditions: 1, unstagedDeletions: 0, + } + const items = buildGitChangeListItems(adaptSdkGitStatusEntries(native, [detail])) + assert.deepEqual(items.map(item => ({ path: item.path, additions: item.additions })), [{ path: "proof.txt", additions: 1 }]) + assert.deepEqual(adaptSdkGitStatusEntries(native, []), []) + assert.equal(adaptSdkGitStatusEntries(native, null)[0].path, "old-checkout.ts", "retain native-only callers without a local snapshot") + }) + it("adapts native V2 status fields and preserves CodeNomad stage details", () => { assert.deepEqual( adaptSdkGitStatusEntries( diff --git a/packages/ui/src/components/instance/shell/right-panel/git-changes-model.ts b/packages/ui/src/components/instance/shell/right-panel/git-changes-model.ts index 628324397..209805166 100644 --- a/packages/ui/src/components/instance/shell/right-panel/git-changes-model.ts +++ b/packages/ui/src/components/instance/shell/right-panel/git-changes-model.ts @@ -42,6 +42,10 @@ export function adaptSdkGitStatusEntries( const adapted = adaptSdkGitStatusEntry(entry) if (!adapted.path) continue const detail = detailsByPath.get(adapted.path) + // Local Git supplies the complete staged/unstaged inventory used for edits. + // Native VCS can still report an older snapshot after checkout creation or + // movement; never resurrect absent files once local details are available. + if (details && !detail) continue adaptedByPath.set(adapted.path, { ...adapted, originalPath: detail?.originalPath ? normalizeGitChangePath(detail.originalPath) : adapted.originalPath ?? null, diff --git a/packages/ui/src/components/instance/shell/right-panel/useGitChanges.ts b/packages/ui/src/components/instance/shell/right-panel/useGitChanges.ts index 613b87d70..ec25035e2 100644 --- a/packages/ui/src/components/instance/shell/right-panel/useGitChanges.ts +++ b/packages/ui/src/components/instance/shell/right-panel/useGitChanges.ts @@ -189,6 +189,7 @@ export function useGitChanges(options: UseGitChangesOptions) { new Promise<{ kind: "timeout" }>((resolve) => setTimeout(() => resolve({ kind: "timeout" }), 1500)), ]).catch(() => null) + if (requestVersion !== gitStatusRequestVersion || slug !== options.worktreeSlug()) return const sdkList = sdkResult && sdkResult.kind === "fulfilled" ? sdkResult.value : null setGitStatusEntries(adaptSdkGitStatusEntries(sdkList, detailList)) } catch (error) { diff --git a/packages/ui/src/components/session-list.tsx b/packages/ui/src/components/session-list.tsx index f47caf820..c86414cc1 100644 --- a/packages/ui/src/components/session-list.tsx +++ b/packages/ui/src/components/session-list.tsx @@ -25,6 +25,7 @@ import { setActiveSessionFromList, toggleSessionExpanded, loadMoreSessions, + loadAllSessions, searchSessions, getSessionHasMore, getSessionListError, @@ -34,8 +35,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" @@ -68,7 +70,19 @@ 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() : "")) + let failedSortExhaustion: string | undefined + + createEffect(() => { + const selected = normalizeSessionDirectory(worktreeDirectory()) + if (!selected) return + const exists = getWorktrees(props.instanceId).some((worktree) => ( + normalizeSessionDirectory(worktree.serviceDirectory ?? worktree.directory) === selected + )) + if (!exists) setWorktreeDirectory("") + }) const [selectedSessionIds, setSelectedSessionIds] = createSignal>(new Set()) const [reloadingSessionIds, setReloadingSessionIds] = createSignal>(new Set()) @@ -116,7 +130,23 @@ const SessionList: Component = (props) => { }) const sessionListError = createMemo(() => getSessionListError(props.instanceId)) + createEffect(() => { + const sort = sortBy() + const key = `${props.instanceId}:${sort}` + if (sort === "activity") { + failedSortExhaustion = undefined + return + } + if (normalizedQuery() || failedSortExhaustion === key + || !getSessionHasMore(props.instanceId) || isFetchingSessions()) return + 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) }) @@ -130,6 +160,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) }) @@ -189,32 +220,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 result: SessionThread[] = [] - for (const thread of props.threads) { - const filtered = filterThreadTree(thread, query) - if (filtered !== null) result.push(filtered) + const searchThreads = query && getSessionSearchQuery(props.instanceId) === query && !isSessionSearchLoading(props.instanceId) + ? getSessionSearchThreads(props.instanceId) + : props.threads + const worktrees = getWorktrees(props.instanceId) + const getWorktreeLabel = (directory: string) => { + const normalized = normalizeSessionDirectory(directory) + const worktree = worktrees.find((candidate) => normalizeSessionDirectory(candidate.serviceDirectory ?? candidate.directory) === normalized) + return worktree?.kind === "root" ? t("sessionList.worktree.workspace") : worktree?.label ?? worktree?.slug ?? directory } - return result + return projectSessionFamilies(searchThreads, { + sort: sortBy(), + worktreeDirectory: worktreeDirectory(), + getWorktreeLabel, + ...(query && searchThreads === props.threads + ? { matchesSession: (session) => sessionMatchesQuery(session.id, query) } + : {}), + }) }) const visibleProjection = createMemo(() => { @@ -256,6 +280,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 @@ -424,8 +456,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,15 +559,15 @@ const SessionList: Component = (props) => { const isChild = () => rowProps.depth > 0 const worktreeSlug = createMemo(() => { - if (isChild()) return "root" - return getWorktreeSlugForParentSession(props.instanceId, sessionId()) + if (isChild()) return "" + const slug = getWorktreeSlugForParentSession(props.instanceId, sessionId()) + return slug === "root" ? "" : getWorktrees(props.instanceId).find(entry => entry.slug === slug)?.label ?? slug }) const showWorktreeBadge = createMemo(() => { if (isChild()) return false - if (getGitRepoStatus(props.instanceId) === false) return false - const slug = worktreeSlug() - return Boolean(slug) && slug !== "root" + if (getGitRepoStatus(props.instanceId) !== true) return false + return Boolean(worktreeSlug()) }) const isActive = () => props.activeSessionId === sessionId() @@ -712,7 +743,7 @@ const SessionList: Component = (props) => { - + @@ -814,6 +845,30 @@ const SessionList: Component = (props) => { +
+ + +
+ 0}>
@@ -364,9 +382,11 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) { + + +
@@ -394,18 +435,26 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) { return (
- Worktree: Unavailable + + {t("sessionList.worktree.tooltip", { worktree: "" }).trim()} + {" "} + {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.raw.label ?? value.slug) + : t("sessionList.worktree.workspace") return (
- Worktree: {label} + + {t("sessionList.worktree.tooltip", { worktree: "" }).trim()} + {" "} + {label}
) @@ -430,17 +479,22 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) {
- Create worktree - Creates a git worktree + {t("instanceShell.worktree.create.action")} + + {t("instanceShell.worktree.create.description")} +
- + setCreateSlug(e.currentTarget.value)} - placeholder="worktree-name" + placeholder={t("instanceShell.worktree.create.placeholder")} disabled={isCreating()} spellcheck={false} autocapitalize="off" @@ -455,7 +509,7 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) { onClick={() => setCreateOpen(false)} disabled={isCreating()} > - Cancel + {t("sessionPicker.actions.cancel")}
@@ -502,15 +556,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().raw.label ?? target().slug })}

{target().directory}

@@ -524,7 +580,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 +590,7 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) { onClick={closeDeleteDialog} disabled={isDeleting()} > - Cancel + {t("sessionPicker.actions.cancel")}
diff --git a/packages/ui/src/lib/api-client.ts b/packages/ui/src/lib/api-client.ts index 3c10ebe30..8526c6cc9 100644 --- a/packages/ui/src/lib/api-client.ts +++ b/packages/ui/src/lib/api-client.ts @@ -42,6 +42,8 @@ import type { WorkspaceEventType, WorktreeListResponse, WorktreeCreateRequest, + WorktreeSessionMoveRequest, + WorktreeSessionMoveResponse, WorktreeGitDiffResponse, WorktreeGitStatusResponse, } from "../../../server/src/api-types" @@ -211,6 +213,13 @@ export const serverApi = { }) }, + moveSessionFamily(id: string, sessionId: string, payload: WorktreeSessionMoveRequest): Promise { + return request(`/api/workspaces/${encodeURIComponent(id)}/sessions/${encodeURIComponent(sessionId)}/worktree`, { + method: "POST", + body: JSON.stringify(payload), + }) + }, + createWorkspace(payload: WorkspaceCreateRequest, options?: { signal?: AbortSignal }): Promise { return request("/api/workspaces", { method: "POST", diff --git a/packages/ui/src/lib/i18n/messages/de/instance.ts b/packages/ui/src/lib/i18n/messages/de/instance.ts index b3dd90c5e..20bb36849 100644 --- a/packages/ui/src/lib/i18n/messages/de/instance.ts +++ b/packages/ui/src/lib/i18n/messages/de/instance.ts @@ -210,6 +210,21 @@ export const instanceMessages = { "instanceShell.diff.enableWordWrap": "Zeilenumbruch aktivieren", "instanceShell.diff.disableWordWrap": "Zeilenumbruch deaktivieren", "instanceShell.worktree.create": "+ Worktree erstellen", + "instanceShell.worktree.unavailable": "Nicht verfügbar", + "instanceShell.worktree.create.fromCurrentBranch": "Neu aus dem aktuellen Branch", + "instanceShell.worktree.create.action": "Worktree erstellen und verwenden", + "instanceShell.worktree.create.description": "Erstellt einen benannten Git-Branch vom Checkout dieser Sitzung und verschiebt die Sitzungsfamilie in den neuen Worktree. Nicht committete Änderungen bleiben im ursprünglichen Checkout.", + "instanceShell.worktree.create.name": "Name", + "instanceShell.worktree.create.placeholder": "worktree-name", + "instanceShell.worktree.create.success": "Worktree {slug} erstellt", + "instanceShell.worktree.create.error": "Worktree konnte nicht erstellt werden", + "instanceShell.worktree.openInFileManager.action": "Im Dateimanager öffnen", + "instanceShell.worktree.openInFileManager.error": "Worktree konnte nicht im Dateimanager geöffnet werden", + "instanceShell.worktree.delete.action": "Worktree löschen", + "instanceShell.worktree.delete.description": "Löscht diesen Branch-Worktree und seinen lokalen Ordner.", + "instanceShell.worktree.delete.target": "Worktree \"{slug}\"", + "instanceShell.worktree.delete.force": "Löschen erzwingen (lokale Änderungen verwerfen)", + "instanceShell.worktree.delete.success": "Worktree {slug} gelöscht", "instanceShell.worktree.delete.error.title": "Löschen fehlgeschlagen", "instanceShell.worktree.delete.error.fallback": "Worktree konnte nicht gelöscht werden", "instanceShell.worktree.delete.error.causeLabel": "Wahrscheinliche Ursache:", diff --git a/packages/ui/src/lib/i18n/messages/de/session.ts b/packages/ui/src/lib/i18n/messages/de/session.ts index ce3d95242..6cf9a0db9 100644 --- a/packages/ui/src/lib/i18n/messages/de/session.ts +++ b/packages/ui/src/lib/i18n/messages/de/session.ts @@ -54,6 +54,15 @@ export const sessionMessages = { "sessionList.filter.placeholder": "Sitzungen suchen...", "sessionList.filter.ariaLabel": "Sitzungen suchen", + "sessionList.sort.ariaLabel": "Sitzungen sortieren", + "sessionList.sort.activity": "Aktivität", + "sessionList.sort.name": "Name", + "sessionList.sort.worktree": "Worktree", + "sessionList.worktreeFilter.ariaLabel": "Sitzungen nach Worktree filtern", + "sessionList.worktreeFilter.all": "Alle Worktrees", + "sessionList.worktree.tooltip": "Worktree: {worktree}", + "sessionList.worktree.workspace": "Arbeitsbereich", + "sessionList.worktreeMove.error": "Sitzungsfamilie konnte nicht verschoben werden", "sessionList.loading.more": "Weitere Sitzungen werden geladen...", "sessionList.loading.initial": "Sitzungen werden geladen...", "sessionList.loadError.title": "Sitzungen konnten nicht geladen werden", diff --git a/packages/ui/src/lib/i18n/messages/en/instance.ts b/packages/ui/src/lib/i18n/messages/en/instance.ts index 4da9f9ae0..bebb9a62d 100644 --- a/packages/ui/src/lib/i18n/messages/en/instance.ts +++ b/packages/ui/src/lib/i18n/messages/en/instance.ts @@ -210,6 +210,21 @@ export const instanceMessages = { "instanceShell.diff.enableWordWrap": "Enable word wrap", "instanceShell.diff.disableWordWrap": "Disable word wrap", "instanceShell.worktree.create": "+ Create worktree", + "instanceShell.worktree.unavailable": "Unavailable", + "instanceShell.worktree.create.fromCurrentBranch": "New from current branch", + "instanceShell.worktree.create.action": "Create and use worktree", + "instanceShell.worktree.create.description": "Creates a named Git branch from this session's checkout and moves the session family to the new worktree. Uncommitted changes stay in the original checkout.", + "instanceShell.worktree.create.name": "Name", + "instanceShell.worktree.create.placeholder": "worktree-name", + "instanceShell.worktree.create.success": "Created worktree {slug}", + "instanceShell.worktree.create.error": "Failed to create worktree", + "instanceShell.worktree.openInFileManager.action": "Open in file manager", + "instanceShell.worktree.openInFileManager.error": "Failed to open worktree in file manager", + "instanceShell.worktree.delete.action": "Delete worktree", + "instanceShell.worktree.delete.description": "Deletes this branch worktree and its local folder.", + "instanceShell.worktree.delete.target": "Worktree \"{slug}\"", + "instanceShell.worktree.delete.force": "Force delete (discard local changes)", + "instanceShell.worktree.delete.success": "Deleted worktree {slug}", "instanceShell.worktree.delete.error.title": "Delete failed", "instanceShell.worktree.delete.error.fallback": "Failed to delete worktree", "instanceShell.worktree.delete.error.causeLabel": "Likely cause:", diff --git a/packages/ui/src/lib/i18n/messages/en/session.ts b/packages/ui/src/lib/i18n/messages/en/session.ts index fe0c5e923..217f806ae 100644 --- a/packages/ui/src/lib/i18n/messages/en/session.ts +++ b/packages/ui/src/lib/i18n/messages/en/session.ts @@ -54,6 +54,15 @@ export const sessionMessages = { "sessionList.filter.placeholder": "Search sessions…", "sessionList.filter.ariaLabel": "Search sessions", + "sessionList.sort.ariaLabel": "Sort sessions", + "sessionList.sort.activity": "Activity", + "sessionList.sort.name": "Name", + "sessionList.sort.worktree": "Worktree", + "sessionList.worktreeFilter.ariaLabel": "Filter sessions by worktree", + "sessionList.worktreeFilter.all": "All worktrees", + "sessionList.worktree.tooltip": "Worktree: {worktree}", + "sessionList.worktree.workspace": "Workspace", + "sessionList.worktreeMove.error": "Unable to move session family", "sessionList.loading.more": "Loading more sessions…", "sessionList.loading.initial": "Loading sessions…", "sessionList.loadError.title": "Unable to load sessions", diff --git a/packages/ui/src/lib/i18n/messages/es/instance.ts b/packages/ui/src/lib/i18n/messages/es/instance.ts index 9877bb477..dbf435c6e 100644 --- a/packages/ui/src/lib/i18n/messages/es/instance.ts +++ b/packages/ui/src/lib/i18n/messages/es/instance.ts @@ -210,6 +210,21 @@ export const instanceMessages = { "instanceShell.diff.enableWordWrap": "Activar ajuste de línea", "instanceShell.diff.disableWordWrap": "Desactivar ajuste de línea", "instanceShell.worktree.create": "+ Crear worktree", + "instanceShell.worktree.unavailable": "No disponible", + "instanceShell.worktree.create.fromCurrentBranch": "Nuevo desde la rama actual", + "instanceShell.worktree.create.action": "Crear y usar worktree", + "instanceShell.worktree.create.description": "Crea una rama Git con nombre desde el checkout de esta sesión y mueve su familia al nuevo worktree. Los cambios sin commit permanecen en el checkout original.", + "instanceShell.worktree.create.name": "Nombre", + "instanceShell.worktree.create.placeholder": "nombre-worktree", + "instanceShell.worktree.create.success": "Worktree {slug} creado", + "instanceShell.worktree.create.error": "No se pudo crear el worktree", + "instanceShell.worktree.openInFileManager.action": "Abrir en el administrador de archivos", + "instanceShell.worktree.openInFileManager.error": "No se pudo abrir el worktree en el administrador de archivos", + "instanceShell.worktree.delete.action": "Eliminar worktree", + "instanceShell.worktree.delete.description": "Elimina este worktree de rama y su carpeta local.", + "instanceShell.worktree.delete.target": "Worktree \"{slug}\"", + "instanceShell.worktree.delete.force": "Forzar eliminación (descartar cambios locales)", + "instanceShell.worktree.delete.success": "Worktree {slug} eliminado", "instanceShell.plan.noSessionSelected": "Selecciona una sesión para ver el plan.", "instanceShell.plan.empty": "Aún no hay nada planificado.", diff --git a/packages/ui/src/lib/i18n/messages/es/session.ts b/packages/ui/src/lib/i18n/messages/es/session.ts index f398fc1dd..be30a5b8f 100644 --- a/packages/ui/src/lib/i18n/messages/es/session.ts +++ b/packages/ui/src/lib/i18n/messages/es/session.ts @@ -54,6 +54,15 @@ export const sessionMessages = { "sessionList.filter.placeholder": "Buscar sesiones…", "sessionList.filter.ariaLabel": "Buscar sesiones", + "sessionList.sort.ariaLabel": "Ordenar sesiones", + "sessionList.sort.activity": "Actividad", + "sessionList.sort.name": "Nombre", + "sessionList.sort.worktree": "Worktree", + "sessionList.worktreeFilter.ariaLabel": "Filtrar sesiones por worktree", + "sessionList.worktreeFilter.all": "Todos los worktrees", + "sessionList.worktree.tooltip": "Worktree: {worktree}", + "sessionList.worktree.workspace": "Espacio de trabajo", + "sessionList.worktreeMove.error": "No se pudo mover la familia de sesiones", "sessionList.loading.more": "Cargando más sesiones…", "sessionList.loading.initial": "Cargando sesiones…", "sessionList.loadError.title": "No se pudieron cargar las sesiones", diff --git a/packages/ui/src/lib/i18n/messages/fr/instance.ts b/packages/ui/src/lib/i18n/messages/fr/instance.ts index ab0925f19..885f8e2eb 100644 --- a/packages/ui/src/lib/i18n/messages/fr/instance.ts +++ b/packages/ui/src/lib/i18n/messages/fr/instance.ts @@ -210,6 +210,21 @@ export const instanceMessages = { "instanceShell.diff.enableWordWrap": "Activer le retour à la ligne", "instanceShell.diff.disableWordWrap": "Désactiver le retour à la ligne", "instanceShell.worktree.create": "+ Créer un worktree", + "instanceShell.worktree.unavailable": "Indisponible", + "instanceShell.worktree.create.fromCurrentBranch": "Nouveau depuis la branche actuelle", + "instanceShell.worktree.create.action": "Créer et utiliser le worktree", + "instanceShell.worktree.create.description": "Crée une branche Git nommée depuis le checkout de cette session et déplace sa famille vers le nouveau worktree. Les modifications non committées restent dans le checkout d’origine.", + "instanceShell.worktree.create.name": "Nom", + "instanceShell.worktree.create.placeholder": "nom-du-worktree", + "instanceShell.worktree.create.success": "Worktree {slug} créé", + "instanceShell.worktree.create.error": "Impossible de créer le worktree", + "instanceShell.worktree.openInFileManager.action": "Ouvrir dans le gestionnaire de fichiers", + "instanceShell.worktree.openInFileManager.error": "Impossible d’ouvrir le worktree dans le gestionnaire de fichiers", + "instanceShell.worktree.delete.action": "Supprimer le worktree", + "instanceShell.worktree.delete.description": "Supprime ce worktree de branche et son dossier local.", + "instanceShell.worktree.delete.target": "Worktree \"{slug}\"", + "instanceShell.worktree.delete.force": "Forcer la suppression (jeter les modifications locales)", + "instanceShell.worktree.delete.success": "Worktree {slug} supprimé", "instanceShell.plan.noSessionSelected": "Sélectionnez une session pour voir le plan.", "instanceShell.plan.empty": "Aucun plan pour l'instant.", diff --git a/packages/ui/src/lib/i18n/messages/fr/session.ts b/packages/ui/src/lib/i18n/messages/fr/session.ts index 4c8e6d2f7..5be7b9bdc 100644 --- a/packages/ui/src/lib/i18n/messages/fr/session.ts +++ b/packages/ui/src/lib/i18n/messages/fr/session.ts @@ -54,6 +54,15 @@ export const sessionMessages = { "sessionList.filter.placeholder": "Rechercher des sessions…", "sessionList.filter.ariaLabel": "Rechercher des sessions", + "sessionList.sort.ariaLabel": "Trier les sessions", + "sessionList.sort.activity": "Activité", + "sessionList.sort.name": "Nom", + "sessionList.sort.worktree": "Worktree", + "sessionList.worktreeFilter.ariaLabel": "Filtrer les sessions par worktree", + "sessionList.worktreeFilter.all": "Tous les worktrees", + "sessionList.worktree.tooltip": "Worktree : {worktree}", + "sessionList.worktree.workspace": "Espace de travail", + "sessionList.worktreeMove.error": "Impossible de déplacer la famille de sessions", "sessionList.loading.more": "Chargement de plus de sessions…", "sessionList.loading.initial": "Chargement des sessions…", "sessionList.loadError.title": "Impossible de charger les sessions", diff --git a/packages/ui/src/lib/i18n/messages/he/instance.ts b/packages/ui/src/lib/i18n/messages/he/instance.ts index 49c6f26b7..47b820f75 100644 --- a/packages/ui/src/lib/i18n/messages/he/instance.ts +++ b/packages/ui/src/lib/i18n/messages/he/instance.ts @@ -210,6 +210,21 @@ export const instanceMessages = { "instanceShell.diff.enableWordWrap": "הפעל גלישת מילים", "instanceShell.diff.disableWordWrap": "כבה גלישת מילים", "instanceShell.worktree.create": "+ צור worktree", + "instanceShell.worktree.unavailable": "לא זמין", + "instanceShell.worktree.create.fromCurrentBranch": "חדש מהענף הנוכחי", + "instanceShell.worktree.create.action": "יצירה ושימוש ב-worktree", + "instanceShell.worktree.create.description": "יוצר ענף Git בעל שם מה-checkout של השיחה ומעביר את משפחת השיחות ל-worktree החדש. שינויים שלא בוצע להם commit נשארים ב-checkout המקורי.", + "instanceShell.worktree.create.name": "שם", + "instanceShell.worktree.create.placeholder": "worktree-name", + "instanceShell.worktree.create.success": "ה-worktree {slug} נוצר", + "instanceShell.worktree.create.error": "יצירת ה-worktree נכשלה", + "instanceShell.worktree.openInFileManager.action": "פתח במנהל הקבצים", + "instanceShell.worktree.openInFileManager.error": "לא ניתן לפתוח את ה-worktree במנהל הקבצים", + "instanceShell.worktree.delete.action": "מחק worktree", + "instanceShell.worktree.delete.description": "מוחק את ה-worktree של הענף ואת התיקייה המקומית שלו.", + "instanceShell.worktree.delete.target": "Worktree \"{slug}\"", + "instanceShell.worktree.delete.force": "מחיקה בכפייה (זריקת שינויים מקומיים)", + "instanceShell.worktree.delete.success": "ה-worktree {slug} נמחק", "instanceShell.worktree.delete.error.title": "המחיקה נכשלה", "instanceShell.worktree.delete.error.fallback": "מחיקת ה-worktree נכשלה", "instanceShell.worktree.delete.error.causeLabel": "סיבה סבירה:", diff --git a/packages/ui/src/lib/i18n/messages/he/session.ts b/packages/ui/src/lib/i18n/messages/he/session.ts index 563b991f4..242897bdc 100644 --- a/packages/ui/src/lib/i18n/messages/he/session.ts +++ b/packages/ui/src/lib/i18n/messages/he/session.ts @@ -54,6 +54,15 @@ export const sessionMessages = { "sessionList.filter.placeholder": "חפש סשנים…", "sessionList.filter.ariaLabel": "חפש סשנים", + "sessionList.sort.ariaLabel": "מיון סשנים", + "sessionList.sort.activity": "פעילות", + "sessionList.sort.name": "שם", + "sessionList.sort.worktree": "Worktree", + "sessionList.worktreeFilter.ariaLabel": "סינון סשנים לפי worktree", + "sessionList.worktreeFilter.all": "כל ה-worktrees", + "sessionList.worktree.tooltip": "Worktree: {worktree}", + "sessionList.worktree.workspace": "סביבת עבודה", + "sessionList.worktreeMove.error": "לא ניתן להעביר את משפחת הסשנים", "sessionList.loading.more": "טוען עוד סשנים…", "sessionList.loading.initial": "טוען סשנים…", "sessionList.loadError.title": "לא ניתן לטעון את הסשנים", diff --git a/packages/ui/src/lib/i18n/messages/ja/instance.ts b/packages/ui/src/lib/i18n/messages/ja/instance.ts index f4dbe606c..db2d34e37 100644 --- a/packages/ui/src/lib/i18n/messages/ja/instance.ts +++ b/packages/ui/src/lib/i18n/messages/ja/instance.ts @@ -210,6 +210,21 @@ export const instanceMessages = { "instanceShell.diff.enableWordWrap": "折り返しを有効化", "instanceShell.diff.disableWordWrap": "折り返しを無効化", "instanceShell.worktree.create": "+ worktree を作成", + "instanceShell.worktree.unavailable": "利用不可", + "instanceShell.worktree.create.fromCurrentBranch": "現在のブランチから新規作成", + "instanceShell.worktree.create.action": "worktree を作成して使用", + "instanceShell.worktree.create.description": "このセッションのチェックアウトから名前付き Git ブランチを作成し、セッションファミリーを新しい worktree に移動します。未コミットの変更は元のチェックアウトに残ります。", + "instanceShell.worktree.create.name": "名前", + "instanceShell.worktree.create.placeholder": "worktree-name", + "instanceShell.worktree.create.success": "worktree {slug} を作成しました", + "instanceShell.worktree.create.error": "worktree を作成できませんでした", + "instanceShell.worktree.openInFileManager.action": "ファイルマネージャーで開く", + "instanceShell.worktree.openInFileManager.error": "ファイルマネージャーで worktree を開けませんでした", + "instanceShell.worktree.delete.action": "worktree を削除", + "instanceShell.worktree.delete.description": "このブランチの worktree とローカルフォルダーを削除します。", + "instanceShell.worktree.delete.target": "Worktree \"{slug}\"", + "instanceShell.worktree.delete.force": "強制削除(ローカルの変更を破棄)", + "instanceShell.worktree.delete.success": "worktree {slug} を削除しました", "instanceShell.plan.noSessionSelected": "計画を表示するにはセッションを選択してください。", "instanceShell.plan.empty": "まだ計画はありません。", diff --git a/packages/ui/src/lib/i18n/messages/ja/session.ts b/packages/ui/src/lib/i18n/messages/ja/session.ts index 464d3c8ef..0b4eaa5d9 100644 --- a/packages/ui/src/lib/i18n/messages/ja/session.ts +++ b/packages/ui/src/lib/i18n/messages/ja/session.ts @@ -54,6 +54,15 @@ export const sessionMessages = { "sessionList.filter.placeholder": "セッションを検索…", "sessionList.filter.ariaLabel": "セッションを検索", + "sessionList.sort.ariaLabel": "セッションを並べ替え", + "sessionList.sort.activity": "アクティビティ", + "sessionList.sort.name": "名前", + "sessionList.sort.worktree": "ワークツリー", + "sessionList.worktreeFilter.ariaLabel": "ワークツリーでセッションを絞り込む", + "sessionList.worktreeFilter.all": "すべてのワークツリー", + "sessionList.worktree.tooltip": "ワークツリー: {worktree}", + "sessionList.worktree.workspace": "ワークスペース", + "sessionList.worktreeMove.error": "セッションファミリーを移動できませんでした", "sessionList.loading.more": "セッションをさらに読み込んでいます…", "sessionList.loading.initial": "セッションを読み込んでいます…", "sessionList.loadError.title": "セッションを読み込めません", diff --git a/packages/ui/src/lib/i18n/messages/ne/instance.ts b/packages/ui/src/lib/i18n/messages/ne/instance.ts index 4d6b5de92..cdaabc395 100644 --- a/packages/ui/src/lib/i18n/messages/ne/instance.ts +++ b/packages/ui/src/lib/i18n/messages/ne/instance.ts @@ -210,6 +210,21 @@ export const instanceMessages = { "instanceShell.diff.enableWordWrap": "Word wrap सक्षम गर्नुहोस्", "instanceShell.diff.disableWordWrap": "Word wrap अक्षम गर्नुहोस्", "instanceShell.worktree.create": "+ Worktree सिर्जना गर्नुहोस्", + "instanceShell.worktree.unavailable": "उपलब्ध छैन", + "instanceShell.worktree.create.fromCurrentBranch": "हालको branch बाट नयाँ", + "instanceShell.worktree.create.action": "Worktree सिर्जना गरी प्रयोग गर्नुहोस्", + "instanceShell.worktree.create.description": "यस सत्रको चेकआउटबाट नाम भएको Git शाखा सिर्जना गरी सत्र परिवारलाई नयाँ worktree मा सार्छ। कमिट नगरिएका परिवर्तनहरू मूल चेकआउटमै रहन्छन्।", + "instanceShell.worktree.create.name": "नाम", + "instanceShell.worktree.create.placeholder": "worktree-name", + "instanceShell.worktree.create.success": "Worktree {slug} सिर्जना गरियो", + "instanceShell.worktree.create.error": "Worktree सिर्जना गर्न असफल भयो", + "instanceShell.worktree.openInFileManager.action": "फाइल प्रबन्धकमा खोल्नुहोस्", + "instanceShell.worktree.openInFileManager.error": "फाइल प्रबन्धकमा worktree खोल्न सकिएन", + "instanceShell.worktree.delete.action": "Worktree मेटाउनुहोस्", + "instanceShell.worktree.delete.description": "यो branch worktree र यसको स्थानीय folder मेटाउँछ।", + "instanceShell.worktree.delete.target": "Worktree \"{slug}\"", + "instanceShell.worktree.delete.force": "बलपूर्वक मेटाउनुहोस् (स्थानीय परिवर्तनहरू खारेज गर्नुहोस्)", + "instanceShell.worktree.delete.success": "Worktree {slug} मेटाइयो", "instanceShell.worktree.delete.error.title": "मेटाउन असफल भयो", "instanceShell.worktree.delete.error.fallback": "Worktree मेटाउन असफल भयो", "instanceShell.worktree.delete.error.causeLabel": "सम्भावित कारण:", diff --git a/packages/ui/src/lib/i18n/messages/ne/session.ts b/packages/ui/src/lib/i18n/messages/ne/session.ts index cf59c0ed3..1fc2be072 100644 --- a/packages/ui/src/lib/i18n/messages/ne/session.ts +++ b/packages/ui/src/lib/i18n/messages/ne/session.ts @@ -54,6 +54,15 @@ export const sessionMessages = { "sessionList.filter.placeholder": "सत्रहरू खोज्नुहोस्...", "sessionList.filter.ariaLabel": "सत्रहरू खोज्नुहोस्", + "sessionList.sort.ariaLabel": "सत्रहरू क्रमबद्ध गर्नुहोस्", + "sessionList.sort.activity": "गतिविधि", + "sessionList.sort.name": "नाम", + "sessionList.sort.worktree": "वर्कट्री", + "sessionList.worktreeFilter.ariaLabel": "वर्कट्री अनुसार सत्रहरू फिल्टर गर्नुहोस्", + "sessionList.worktreeFilter.all": "सबै वर्कट्रीहरू", + "sessionList.worktree.tooltip": "वर्कट्री: {worktree}", + "sessionList.worktree.workspace": "कार्यक्षेत्र", + "sessionList.worktreeMove.error": "सत्र परिवार सार्न सकिएन", "sessionList.loading.more": "थप सत्रहरू लोड गर्दै...", "sessionList.loading.initial": "सत्रहरू लोड गर्दै...", "sessionList.loadError.title": "सत्रहरू लोड गर्न सकिएन", diff --git a/packages/ui/src/lib/i18n/messages/ru/instance.ts b/packages/ui/src/lib/i18n/messages/ru/instance.ts index fae585a33..e68819c6b 100644 --- a/packages/ui/src/lib/i18n/messages/ru/instance.ts +++ b/packages/ui/src/lib/i18n/messages/ru/instance.ts @@ -210,6 +210,21 @@ export const instanceMessages = { "instanceShell.diff.enableWordWrap": "Включить перенос строк", "instanceShell.diff.disableWordWrap": "Отключить перенос строк", "instanceShell.worktree.create": "+ Создать worktree", + "instanceShell.worktree.unavailable": "Недоступно", + "instanceShell.worktree.create.fromCurrentBranch": "Новый из текущей ветки", + "instanceShell.worktree.create.action": "Создать и использовать worktree", + "instanceShell.worktree.create.description": "Создаёт именованную ветку Git из рабочей копии этой сессии и переносит семейство сессий в новый worktree. Незакоммиченные изменения остаются в исходной рабочей копии.", + "instanceShell.worktree.create.name": "Имя", + "instanceShell.worktree.create.placeholder": "имя-worktree", + "instanceShell.worktree.create.success": "Worktree {slug} создан", + "instanceShell.worktree.create.error": "Не удалось создать worktree", + "instanceShell.worktree.openInFileManager.action": "Открыть в файловом менеджере", + "instanceShell.worktree.openInFileManager.error": "Не удалось открыть worktree в файловом менеджере", + "instanceShell.worktree.delete.action": "Удалить worktree", + "instanceShell.worktree.delete.description": "Удаляет этот worktree ветки и его локальную папку.", + "instanceShell.worktree.delete.target": "Worktree \"{slug}\"", + "instanceShell.worktree.delete.force": "Удалить принудительно (отбросить локальные изменения)", + "instanceShell.worktree.delete.success": "Worktree {slug} удалён", "instanceShell.plan.noSessionSelected": "Выберите сессию, чтобы просмотреть план.", "instanceShell.plan.empty": "Пока ничего не запланировано.", diff --git a/packages/ui/src/lib/i18n/messages/ru/session.ts b/packages/ui/src/lib/i18n/messages/ru/session.ts index ffd2c7cd9..da5ad5e90 100644 --- a/packages/ui/src/lib/i18n/messages/ru/session.ts +++ b/packages/ui/src/lib/i18n/messages/ru/session.ts @@ -54,6 +54,15 @@ export const sessionMessages = { "sessionList.filter.placeholder": "Поиск сессий…", "sessionList.filter.ariaLabel": "Поиск сессий", + "sessionList.sort.ariaLabel": "Сортировать сессии", + "sessionList.sort.activity": "Активность", + "sessionList.sort.name": "Имя", + "sessionList.sort.worktree": "Worktree", + "sessionList.worktreeFilter.ariaLabel": "Фильтровать сессии по worktree", + "sessionList.worktreeFilter.all": "Все worktree", + "sessionList.worktree.tooltip": "Worktree: {worktree}", + "sessionList.worktree.workspace": "Рабочая область", + "sessionList.worktreeMove.error": "Не удалось переместить семейство сессий", "sessionList.loading.more": "Загрузка дополнительных сессий…", "sessionList.loading.initial": "Загрузка сессий…", "sessionList.loadError.title": "Не удалось загрузить сессии", diff --git a/packages/ui/src/lib/i18n/messages/tr/instance.ts b/packages/ui/src/lib/i18n/messages/tr/instance.ts index 1a18f6e7c..0c0cd6047 100644 --- a/packages/ui/src/lib/i18n/messages/tr/instance.ts +++ b/packages/ui/src/lib/i18n/messages/tr/instance.ts @@ -183,6 +183,19 @@ export const instanceMessages = { "instanceShell.diff.enableWordWrap": "Sözcük kaydırmayı etkinleştir", "instanceShell.diff.disableWordWrap": "Sözcük kaydırmayı devre dışı bırak", "instanceShell.worktree.create": "+ Worktree oluştur", + "instanceShell.worktree.unavailable": "Kullanılamıyor", + "instanceShell.worktree.create.fromCurrentBranch": "Geçerli daldan yeni", + "instanceShell.worktree.create.action": "Worktree oluştur ve kullan", + "instanceShell.worktree.create.description": "Bu oturumun çalışma kopyasından adlandırılmış bir Git dalı oluşturur ve oturum ailesini yeni worktree'ye taşır. Commit edilmemiş değişiklikler eski çalışma kopyasında kalır.", + "instanceShell.worktree.create.name": "Ad", + "instanceShell.worktree.create.placeholder": "worktree-adı", + "instanceShell.worktree.create.success": "Worktree {slug} oluşturuldu", + "instanceShell.worktree.create.error": "Worktree oluşturulamadı", + "instanceShell.worktree.delete.action": "Worktree sil", + "instanceShell.worktree.delete.description": "Bu dal worktree'sini ve yerel klasörünü siler.", + "instanceShell.worktree.delete.target": "Worktree \"{slug}\"", + "instanceShell.worktree.delete.force": "Zorla sil (yerel değişiklikleri at)", + "instanceShell.worktree.delete.success": "Worktree {slug} silindi", "instanceShell.worktree.delete.error.title": "Silme başarısız", "instanceShell.worktree.delete.error.fallback": "Worktree silinemedi", "instanceShell.worktree.delete.error.causeLabel": "Olası neden:", @@ -207,6 +220,8 @@ export const instanceMessages = { "instanceShell.worktree.delete.error.copySuccess": "Silme hatası kopyalandı", "instanceShell.worktree.delete.error.copySanitizedSuccess": "Temizlenmiş silme hatası kopyalandı", "instanceShell.worktree.delete.error.copyFailure": "Silme hatası kopyalanamadı", + "instanceShell.worktree.openInFileManager.action": "Dosya yöneticisinde aç", + "instanceShell.worktree.openInFileManager.error": "Çalışma ağacı dosya yöneticisinde açılamadı", "instanceShell.plan.noSessionSelected": "Planı görüntülemek için bir session seçin.", "instanceShell.plan.empty": "Henüz planlanan bir şey yok.", "instanceShell.yoloMode.noSessionSelected": "Yolo modunu yapılandırmak için bir session seçin.", diff --git a/packages/ui/src/lib/i18n/messages/tr/session.ts b/packages/ui/src/lib/i18n/messages/tr/session.ts index c003d3ca0..05ec15b25 100644 --- a/packages/ui/src/lib/i18n/messages/tr/session.ts +++ b/packages/ui/src/lib/i18n/messages/tr/session.ts @@ -52,6 +52,15 @@ export const sessionMessages = { "sessionList.rename.error": "Session yeniden adlandırılamıyor", "sessionList.filter.placeholder": "Session ara…", "sessionList.filter.ariaLabel": "Session ara", + "sessionList.sort.ariaLabel": "Session'ları sırala", + "sessionList.sort.activity": "Etkinlik", + "sessionList.sort.name": "Ad", + "sessionList.sort.worktree": "Çalışma ağacı", + "sessionList.worktreeFilter.ariaLabel": "Session'ları çalışma ağacına göre filtrele", + "sessionList.worktreeFilter.all": "Tüm çalışma ağaçları", + "sessionList.worktree.tooltip": "Çalışma ağacı: {worktree}", + "sessionList.worktree.workspace": "Çalışma alanı", + "sessionList.worktreeMove.error": "Session ailesi taşınamıyor", "sessionList.loading.more": "Daha fazla session yükleniyor…", "sessionList.loading.initial": "Session'lar yükleniyor…", "sessionList.loadError.title": "Session'lar yüklenemiyor", diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/instance.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/instance.ts index a1a92c861..cb4e5a427 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/instance.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/instance.ts @@ -210,6 +210,21 @@ export const instanceMessages = { "instanceShell.diff.enableWordWrap": "启用自动换行", "instanceShell.diff.disableWordWrap": "禁用自动换行", "instanceShell.worktree.create": "+ 创建 worktree", + "instanceShell.worktree.unavailable": "不可用", + "instanceShell.worktree.create.fromCurrentBranch": "从当前分支新建", + "instanceShell.worktree.create.action": "创建并使用 worktree", + "instanceShell.worktree.create.description": "从此会话的检出创建命名 Git 分支,并将会话族移至新 worktree。未提交的更改保留在原检出中。", + "instanceShell.worktree.create.name": "名称", + "instanceShell.worktree.create.placeholder": "worktree-name", + "instanceShell.worktree.create.success": "已创建 worktree {slug}", + "instanceShell.worktree.create.error": "无法创建 worktree", + "instanceShell.worktree.openInFileManager.action": "在文件管理器中打开", + "instanceShell.worktree.openInFileManager.error": "无法在文件管理器中打开 worktree", + "instanceShell.worktree.delete.action": "删除 worktree", + "instanceShell.worktree.delete.description": "删除此分支 worktree 及其本地文件夹。", + "instanceShell.worktree.delete.target": "Worktree \"{slug}\"", + "instanceShell.worktree.delete.force": "强制删除(丢弃本地更改)", + "instanceShell.worktree.delete.success": "已删除 worktree {slug}", "instanceShell.plan.noSessionSelected": "选择会话以查看计划。", "instanceShell.plan.empty": "暂无计划。", diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/session.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/session.ts index 5799ea536..58a9c320a 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/session.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/session.ts @@ -54,6 +54,15 @@ export const sessionMessages = { "sessionList.filter.placeholder": "搜索会话…", "sessionList.filter.ariaLabel": "搜索会话", + "sessionList.sort.ariaLabel": "会话排序", + "sessionList.sort.activity": "活动时间", + "sessionList.sort.name": "名称", + "sessionList.sort.worktree": "工作树", + "sessionList.worktreeFilter.ariaLabel": "按工作树筛选会话", + "sessionList.worktreeFilter.all": "所有工作树", + "sessionList.worktree.tooltip": "工作树:{worktree}", + "sessionList.worktree.workspace": "工作区", + "sessionList.worktreeMove.error": "无法移动会话系列", "sessionList.loading.more": "正在加载更多会话…", "sessionList.loading.initial": "正在加载会话…", "sessionList.loadError.title": "无法加载会话", diff --git a/packages/ui/src/lib/sse-manager.ts b/packages/ui/src/lib/sse-manager.ts index 84df5fafe..c5a4d58c8 100644 --- a/packages/ui/src/lib/sse-manager.ts +++ b/packages/ui/src/lib/sse-manager.ts @@ -163,6 +163,9 @@ class SSEManager { log.warn("Failed to handle worktree ready event", { instanceId, error }) } break + case "worktree.updated": + void this.onWorktreeUpdated?.(instanceId)?.catch(error => log.warn("Failed to refresh native worktrees", error)) + break default: this.onNativeSessionEvent?.(instanceId, event as NativeSessionEvent) } @@ -179,6 +182,7 @@ class SSEManager { onSessionUpdate?: (instanceId: string, event: SessionCreated | SessionRevertStaged | SessionRevertCleared | SessionRevertCommitted) => void onSessionDeleted?: (instanceId: string, event: EventSessionDeleted) => void + onWorktreeUpdated?: (instanceId: string) => Promise onSessionCompacted?: (instanceId: string, event: SessionCompactionEnded) => void onSessionError?: (instanceId: string, event: SessionExecutionFailed) => void onTuiToast?: (instanceId: string, event: TuiToastShow) => void diff --git a/packages/ui/src/stores/instances.ts b/packages/ui/src/stores/instances.ts index 8f78e2bbd..06e7c5cfe 100644 --- a/packages/ui/src/stores/instances.ts +++ b/packages/ui/src/stores/instances.ts @@ -382,6 +382,7 @@ const connectionResyncs = new TrailingResyncCoordinator( sessionError = error } await Promise.all([ + reloadWorktrees(instanceId), syncPendingRequests(instanceId), refreshVolatileInstanceState(instanceId), syncLoadedSessionInboxes(instanceId), diff --git a/packages/ui/src/stores/session-actions.test.ts b/packages/ui/src/stores/session-actions.test.ts index f9a38d7d9..ff2feaa1a 100644 --- a/packages/ui/src/stores/session-actions.test.ts +++ b/packages/ui/src/stores/session-actions.test.ts @@ -162,7 +162,7 @@ describe("native undo settlement", () => { let release!: () => void const gate = new Promise(resolve => { release = resolve }) seed({ session: { - instructions: { entry: { remove: async () => {} } }, + instructions: { entry: { put: async () => {}, remove: async () => {} } }, switchAgent: async () => {}, switchModel: async () => {}, prompt: async (input: any) => { calls.push(input.text); if (input.text === "before") await gate; return { id: input.id } }, revert: { stage: async () => { calls.push("stage") } }, @@ -179,13 +179,13 @@ describe("native undo settlement", () => { }) }) -describe("voice instruction sync", () => { +describe("session instruction sync", () => { it("syncs the enabled instruction before a slash command", async () => { const calls: string[] = [] let commandInput: unknown seed({ session: { instructions: { entry: { - put: async () => { calls.push("put") }, + put: async (input: any) => { calls.push(`put:${input.key}`) }, remove: async () => { calls.push("remove") }, } }, command: async (input: unknown) => { calls.push("command"); commandInput = input }, @@ -194,7 +194,7 @@ describe("voice instruction sync", () => { await executeCustomCommand(instanceId, sessionId, "review", "") - assert.deepEqual(calls, ["put", "command"]) + assert.deepEqual(calls, ["put:codenomad.voice-mode", "put:codenomad.session-placement", "command"]) assert.deepEqual(commandInput, { sessionID: sessionId, name: "review", @@ -209,15 +209,15 @@ describe("voice instruction sync", () => { const calls: string[] = [] seed({ session: { instructions: { entry: { - put: async () => { calls.push("put") }, - remove: async () => { calls.push("remove") }, + put: async (input: any) => { calls.push(`put:${input.key}`) }, + remove: async (input: any) => { calls.push(`remove:${input.key}`) }, } }, shell: async () => { calls.push("shell") }, } }) await runShellCommand(instanceId, sessionId, "pwd") - assert.deepEqual(calls, ["remove", "shell"]) + assert.deepEqual(calls, ["remove:codenomad.voice-mode", "put:codenomad.session-placement", "shell"]) }) it("serializes concurrent syncs so the latest mode wins remotely", async () => { @@ -226,7 +226,10 @@ describe("voice instruction sync", () => { const putGate = new Promise((resolve) => { releasePut = resolve }) seed({ session: { instructions: { entry: { - put: async () => { calls.push("put:start"); await putGate; calls.push("put:end") }, + put: async (input: any) => { + if (input.key === "codenomad.session-placement") { calls.push("placement"); return } + calls.push("put:start"); await putGate; calls.push("put:end") + }, remove: async () => { calls.push("remove") }, } }, command: async () => { calls.push("command") }, @@ -239,9 +242,45 @@ describe("voice instruction sync", () => { releasePut() await first - assert.deepEqual(calls, ["put:start", "put:end", "remove", "command"]) + assert.deepEqual(calls, ["put:start", "put:end", "remove", "placement", "command"]) assert.equal(calls.filter((call) => call === "remove").length, 1) }) + + for (const action of ["prompt", "command", "shell"] as const) { + it(`waits for placement setup before ${action} and propagates its failure`, async () => { + const calls: string[] = [] + let rejectPut!: (error: Error) => void + const gate = new Promise((_resolve, reject) => { rejectPut = reject }) + seed({ session: { + instructions: { entry: { + put: async (input: any) => { + assert.equal(input.sessionID, sessionId) + assert.equal(input.key, "codenomad.session-placement") + calls.push("placement") + await gate + }, + remove: async () => {}, + } }, + switchAgent: async () => {}, switchModel: async () => {}, + [action]: async () => { calls.push(action) }, + } }) + const pending = action === "prompt" ? sendMessage(instanceId, sessionId, "hello") + : action === "command" ? executeCustomCommand(instanceId, sessionId, "review", "") + : runShellCommand(instanceId, sessionId, "pwd") + const rejected = assert.rejects(pending, /instruction unavailable/) + await new Promise((resolve) => setImmediate(resolve)) + assert.deepEqual(calls, ["placement"]) + rejectPut(new Error("instruction unavailable")) + await rejected + assert.deepEqual(calls, ["placement"]) + assert.equal(sessions().get(instanceId)?.get(sessionId)?.status, "idle") + if (action === "prompt") { + const store = messageStoreBus.getOrCreate(instanceId) + const ids = store.getSessionMessageIds(sessionId) + assert.equal(store.getMessage(ids[ids.length - 1])?.status, "error") + } + }) + } }) describe("session interruption", () => { diff --git a/packages/ui/src/stores/session-actions.ts b/packages/ui/src/stores/session-actions.ts index d1a9d0c2d..50d0d1e31 100644 --- a/packages/ui/src/stores/session-actions.ts +++ b/packages/ui/src/stores/session-actions.ts @@ -17,18 +17,10 @@ import { updateSessionInfo } from "./message-v2/session-info" import { messageStoreBus } from "./message-v2/bus" import { normalizeSessionMessage } from "./message-v2/normalizers" import { getLogger } from "../lib/logger" -import { clearConversationPlaybackForSession, isConversationModeEnabled } from "./conversation-speech" +import { clearConversationPlaybackForSession } from "./conversation-speech" +import { syncSessionInstructions } from "./session-instructions" const log = getLogger("actions") -const VOICE_MODE_INSTRUCTION_KEY = "codenomad.voice-mode" -const VOICE_MODE_INSTRUCTION = [ - "Voice conversation mode is enabled.", - "Prepend your reply with a fenced code block using language `spoken`.", - "The `spoken` block should be a concise, natural spoken gist of the full response in 2 to 4 sentences.", - "Do not include code, bullet lists, markdown formatting, or long technical detail in the spoken block.", - "After the `spoken` block, continue with your normal detailed response.", -].join("\n\n") -const voiceInstructionSyncs = new Map }>() const technicalPartUpdates = new Map>() const sessionAdmissions = new Map>() @@ -108,37 +100,6 @@ function serializeTechnicalPartUpdate( return settled } -async function syncVoiceModeInstruction(client: ReturnType, instanceId: string, sessionId: string): Promise { - const key = `${instanceId}:${sessionId}` - const existing = voiceInstructionSyncs.get(key) - if (existing) { - existing.desired = isConversationModeEnabled(instanceId) - return existing.running - } - - const state = { desired: isConversationModeEnabled(instanceId), running: Promise.resolve() } - state.running = (async () => { - try { - let applied: boolean | undefined - while (applied !== state.desired) { - const desired = state.desired - const instruction = client.session.instructions.entry - if (desired) { - await instruction.put({ sessionID: sessionId, key: VOICE_MODE_INSTRUCTION_KEY, value: VOICE_MODE_INSTRUCTION }) - } else { - await instruction.remove({ sessionID: sessionId, key: VOICE_MODE_INSTRUCTION_KEY }) - } - applied = desired - state.desired = isConversationModeEnabled(instanceId) - } - } finally { - if (voiceInstructionSyncs.get(key) === state) voiceInstructionSyncs.delete(key) - } - })() - voiceInstructionSyncs.set(key, state) - return state.running -} - function getVariantKeysForModel(instanceId: string, model: { providerId: string; modelId: string }): string[] { if (!model.providerId || !model.modelId) return [] const instanceProviders = providers().get(instanceId) || [] @@ -372,7 +333,7 @@ async function sendMessage( if (!currentInstance?.client) throw new Error("Instance not ready") if (!currentSession) throw new Error("Session not found") const client = getRootClient(instanceId) - await syncVoiceModeInstruction(client, instanceId, sessionId) + await syncSessionInstructions(client, instanceId, sessionId) if (options.delivery !== "queue") { if (currentSession.agent) await client.session.switchAgent({ sessionID: sessionId, agent: currentSession.agent }) if (currentSession.model.providerId && currentSession.model.modelId) { @@ -413,7 +374,7 @@ async function executeCustomCommand( if (!instances().get(instanceId)?.client) throw new Error("Instance not ready") if (!sessions().get(instanceId)?.has(sessionId)) throw new Error("Session not found") const client = getRootClient(instanceId) - await syncVoiceModeInstruction(client, instanceId, sessionId) + await syncSessionInstructions(client, instanceId, sessionId) await client.session.command({ sessionID: sessionId, name: commandName, text: args, delivery: "steer" }) }, { optimisticGeneration: false }) } @@ -433,7 +394,7 @@ async function runShellCommand(instanceId: string, sessionId: string, command: s if (!instances().get(instanceId)?.client) throw new Error("Instance not ready") if (!sessions().get(instanceId)?.has(sessionId)) throw new Error("Session not found") const client = getRootClient(instanceId) - await syncVoiceModeInstruction(client, instanceId, sessionId) + await syncSessionInstructions(client, instanceId, sessionId) await client.session.shell({ sessionID: sessionId, command }) }) } @@ -581,14 +542,6 @@ async function renameSession(instanceId: string, sessionId: string, nextTitle: s }) } -async function moveSession(instanceId: string, sessionId: string, directory: string): Promise { - if (!directory.trim()) throw new Error("Session directory is required") - await getRootClient(instanceId).session.move({ sessionID: sessionId, directory }) - withSession(instanceId, sessionId, (session) => { - session.location = { directory } - }) -} - async function compactSession(instanceId: string, sessionId: string): Promise { await getRootClient(instanceId).session.compact({ sessionID: sessionId }) } @@ -766,7 +719,6 @@ export { executeSessionTechnicalPartDeletion, planSessionTechnicalPartDeletion, renameSession, - moveSession, runShellCommand, sendMessage, updateSessionAgent, diff --git a/packages/ui/src/stores/session-api.ts b/packages/ui/src/stores/session-api.ts index 8848d35e4..e1a7a5fa0 100644 --- a/packages/ui/src/stores/session-api.ts +++ b/packages/ui/src/stores/session-api.ts @@ -5,6 +5,8 @@ import { } from "../types/session" import type { Message } from "../types/message" import type { Instance } from "../types/instance" +import { ensureWorktreesLoaded, getGitRepoStatus, getWorktrees } from "./worktrees" +import { selectWorkspaceSessionFamilies } from "./workspace-session-scope" import type { LocationRef, SessionInfo as SDKSession, SessionMessagesResponse } from "@opencode/client" import { instances, reconcilePendingSessionIndicators } from "./instances" @@ -50,6 +52,7 @@ import { setSessionSearchResults, setSessionListError, setSessionExpanded, + getSessionHasMore, getSessionNextCursor, getSessionListIds, } from "./session-state" @@ -78,6 +81,8 @@ import { PROJECT_SESSION_LIST_LIMIT, buildProjectSessionListOptions, } from "./session-list-options" + +const MAX_SESSION_LIST_PAGES = 1_000 import { getInstanceMetadata } from "./instance-metadata" import { mergeFetchedSessionRuntimeState, resolveAuthoritativeGenerationRecovery } from "./session-generation-recovery" import { fetchCommands } from "./commands" @@ -93,6 +98,8 @@ const providerRequestIds = new Map() const agentRefreshes = new Map; pending: boolean; cancelled: boolean }>() const providerRefreshes = new Map; pending: boolean; cancelled: boolean }>() const sessionPageRequests = new Map>() +const sessionExhaustionRequests = new Map>() +const sessionPageTraversals = new Map; pages: number }>() interface MessagePageRequest { controller: AbortController consumers: Set @@ -185,6 +192,8 @@ async function refreshSessionCatalog(instanceId: string, force = false): Promise function beginSessionListRequest(instanceId: string): number { const requestId = ++nextSessionListRequestId + sessionPageRequests.delete(instanceId) + sessionExhaustionRequests.delete(instanceId) sessionListRequestIds.set(instanceId, requestId) return requestId } @@ -196,6 +205,8 @@ function isLatestSessionListRequest(instanceId: string, requestId: number): bool function clearSessionListRequestState(instanceId: string): void { sessionListRequestIds.delete(instanceId) sessionPageRequests.delete(instanceId) + sessionExhaustionRequests.delete(instanceId) + sessionPageTraversals.delete(instanceId) setSessionListError(instanceId, null) setLoading((prev) => { if (!prev.fetchingSessions.has(instanceId)) return prev @@ -315,17 +326,23 @@ async function fetchCompleteSessionInventory( for (const scope of scopes) { const seenCursors = new Set() + let pageCount = 1 let response = await fetchV2Sessions(instanceId, scope, signal) while (true) { if (!isCurrent()) return [] for (const session of response.data) inventory.set(session.id, session) if (!response.nextCursor) break + if (++pageCount > MAX_SESSION_LIST_PAGES) throw new Error("Session inventory exceeded the page limit") if (seenCursors.has(response.nextCursor)) throw new Error(`Repeated session cursor: ${response.nextCursor}`) seenCursors.add(response.nextCursor) response = await fetchV2Sessions(instanceId, { cursor: response.nextCursor }, signal) } } - return Array.from(inventory.values()) + await ensureWorktreesLoaded(instanceId) + if (!isCurrent()) return [] + signal?.throwIfAborted() + if (getGitRepoStatus(instanceId) === null) throw new Error(tGlobal("sessionList.loadError.detail")) + return selectWorkspaceSessionFamilies(Array.from(inventory.values()), directory ?? "", getWorktrees(instanceId)) } function getDisconnectedCapturedSessionIds( @@ -509,11 +526,13 @@ async function fetchSessions(instanceId: string, options?: { next.set(instanceId, instanceSessions) return next }) + // This directory page is only a partial view of the workspace. Keep + // existing worktree rows until the project inventory can reconcile them. setSessionPage( instanceId, rootApiSessions.filter((session) => !session.parentID && !deletedSessionIds.has(session.id)).map((session) => session.id), Boolean(response.nextCursor), - options?.reset ?? true, + false, response.nextCursor, ) } @@ -521,12 +540,16 @@ async function fetchSessions(instanceId: string, options?: { let inventoryComplete = false try { inventory = await fetchCompleteSessionInventory(instanceId, options?.signal, isCurrent) - inventoryComplete = hasProjectInventory && response.complete + inventoryComplete = hasProjectInventory } catch (error) { if (options?.signal?.aborted) throw error + if (options?.strictStatus) throw error log.warn("Failed to enrich the session list with project descendants", { instanceId, error }) } - if (!isCurrent()) return + if (!isCurrent()) { + if (options?.strictStatus) throw new Error("Foreground session refresh was superseded") + return + } const rootIdsFromPage = new Set(rootApiSessions.map((session) => session.id)) const apiSessions = [...rootApiSessions, ...inventory.filter((session) => !rootIdsFromPage.has(session.id))] const sessionMap = new Map() @@ -555,7 +578,10 @@ async function fetchSessions(instanceId: string, options?: { return next }) await ensureV2ParentChainsLoaded(instanceId, apiSessions, options?.signal, isCurrent) - if (!isCurrent()) return + if (!isCurrent()) { + if (options?.strictStatus) throw new Error("Foreground session refresh was superseded") + return + } if (inventoryComplete || (!hasProjectInventory && response.complete)) { const authoritativeSessions = inventoryComplete ? apiSessions : rootApiSessions @@ -572,7 +598,7 @@ async function fetchSessions(instanceId: string, options?: { const rootIds: string[] = [] const seenRootIds = new Set() const missingRootSessionIds: string[] = [] - for (const apiSession of rootApiSessions) { + for (const apiSession of apiSessions) { const root = getSessionRoot(instanceId, apiSession.id) if (root) { if (!seenRootIds.has(root.id)) { @@ -583,7 +609,7 @@ async function fetchSessions(instanceId: string, options?: { missingRootSessionIds.push(apiSession.id) } } - if (!response.complete) { + if (!inventoryComplete && (!response.complete || hasProjectInventory)) { for (const sessionId of existingCatalogIds) { const session = sessions().get(instanceId)?.get(sessionId) if (session?.parentId === null && !seenRootIds.has(sessionId)) { @@ -610,6 +636,10 @@ async function fetchSessions(instanceId: string, options?: { } setSessionPage(instanceId, rootIds, Boolean(response.nextCursor), options?.reset ?? true, response.nextCursor) + sessionPageTraversals.set(instanceId, { + cursors: new Set(response.nextCursor ? [response.nextCursor] : []), + pages: 1, + }) for (const rootId of rootIds) updateThreadTotalsForParent(instanceId, rootId) reconcilePendingSessionIndicators(instanceId) @@ -656,6 +686,33 @@ async function loadMoreSessions(instanceId: string): Promise { return request } +async function loadAllSessions(instanceId: string): Promise { + const pending = sessionExhaustionRequests.get(instanceId) + if (pending) return pending + const listRequestId = sessionListRequestIds.get(instanceId) + const request = (async () => { + setSessionListError(instanceId, null) + try { + while (getSessionHasMore(instanceId)) { + const cursor = getSessionNextCursor(instanceId) + await loadMoreSessions(instanceId) + if (sessionListRequestIds.get(instanceId) !== listRequestId) return + if (getSessionHasMore(instanceId) && getSessionNextCursor(instanceId) === cursor) { + throw new Error("Session pagination was interrupted") + } + } + } catch (error) { + if (sessionListRequestIds.get(instanceId) !== listRequestId) return + setSessionListError(instanceId, getOpencodeErrorMessage(error, tGlobal("sessionList.loadError.detail"))) + throw error + } + })().finally(() => { + if (sessionExhaustionRequests.get(instanceId) === request) sessionExhaustionRequests.delete(instanceId) + }) + sessionExhaustionRequests.set(instanceId, request) + return request +} + async function loadNextSessionPage(instanceId: string): Promise { const cursor = getSessionNextCursor(instanceId) if (!cursor) return @@ -675,6 +732,14 @@ async function loadNextSessionPage(instanceId: string): Promise { }), ]) if (!isCurrent() || getSessionNextCursor(instanceId) !== cursor) return + const traversal = sessionPageTraversals.get(instanceId) ?? { cursors: new Set([cursor]), pages: 1 } + if (traversal.pages >= MAX_SESSION_LIST_PAGES) throw new Error("Session pagination exceeded the page limit") + if (response.nextCursor && traversal.cursors.has(response.nextCursor)) { + throw new Error(`Repeated session cursor: ${response.nextCursor}`) + } + traversal.pages += 1 + if (response.nextCursor) traversal.cursors.add(response.nextCursor) + sessionPageTraversals.set(instanceId, traversal) const pageSessions = response.data const deleted = getAuthoritativelyDeletedSessionIdsForInstance(instanceId) setSessions((previous) => { @@ -720,13 +785,27 @@ async function searchSessions(instanceId: string, query: string): Promise try { log.info("v2.session.search", { instanceId, query: trimmedQuery, directory: instance.folder }) - const response = await fetchV2Sessions(instanceId, { - search: trimmedQuery, - directory: instance.folder, - }) - if (!isCurrent()) return - - const searchResults = getV2SessionItems(response) + await ensureWorktreesLoaded(instanceId) + const results = new Map() + const worktreeDirectories = getInstanceMetadata(instanceId)?.project?.id === "global" + ? [] : getWorktrees(instanceId).map(entry => entry.serviceDirectory ?? entry.directory) + const directories = new Set([instance.folder, ...worktreeDirectories]) + for (const directory of directories) { + if (!isCurrent()) return + let response = await fetchV2Sessions(instanceId, { search: trimmedQuery, directory }) + const cursors = new Set() + let pageCount = 1 + while (true) { + if (!isCurrent()) return + for (const session of getV2SessionItems(response)) results.set(session.id, session) + if (!response.nextCursor) break + if (++pageCount > MAX_SESSION_LIST_PAGES) throw new Error("Session search exceeded the page limit") + if (cursors.has(response.nextCursor)) throw new Error(`Repeated session cursor: ${response.nextCursor}`) + cursors.add(response.nextCursor) + response = await fetchV2Sessions(instanceId, { cursor: response.nextCursor }) + } + } + const searchResults = Array.from(results.values()) if (searchResults.length === 0) { setSessionSearchResults(instanceId, trimmedQuery, [], requestId) @@ -1646,6 +1725,7 @@ export { fetchSessions, hydrateRestoredSessionChain, loadMoreSessions, + loadAllSessions, searchSessions, forkSession, loadMessages, diff --git a/packages/ui/src/stores/session-events.ts b/packages/ui/src/stores/session-events.ts index f1397e943..f5cadc656 100644 --- a/packages/ui/src/stores/session-events.ts +++ b/packages/ui/src/stores/session-events.ts @@ -5,6 +5,7 @@ import type { SessionCreated, SessionExecutionFailed, SessionIdle, + SessionMoved, SessionRevertCleared, SessionRevertCommitted, SessionRevertStaged, @@ -41,13 +42,14 @@ import { type SessionRetryState, type SessionStatus, } from "../types/session" -import { activeSessionId, ensureSessionAncestorsExpanded, getAuthoritativelyDeletedSessionIdsForInstance, invalidateSessionMessageLoad, prependSessionListId, removeSessionListId, sessions, setSessionStatus, setSessions, syncInstanceSessionIndicator, withSession } from "./session-state" +import { activeSessionId, ensureSessionAncestorsExpanded, getAuthoritativelyDeletedSessionIdsForInstance, invalidateSessionMessageLoad, prependSessionListId, sessions, setSessionStatus, setSessions, syncInstanceSessionIndicator, withSession } from "./session-state" import { mergeFetchedSessionRuntimeState } from "./session-generation-recovery" import { tGlobal } from "../lib/i18n" -import { fetchSessions, loadMessages, removeSessionRuntimeState } from "./session-api" +import { fetchSessions, loadMessages, refreshSessionCatalog, removeSessionRuntimeState } from "./session-api" import { getRootClient } from "./opencode-client" -import { getWorktrees } from "./worktrees" +import { getWorktrees, reloadWorktrees } from "./worktrees" +import { normalizeSessionDirectory } from "./session-list-options" import { setSessionRevertV2, } from "./message-v2/bridge" @@ -61,6 +63,7 @@ const pendingSessionFetches = new Map() const nativeLifecycleGenerations = new Map() let activeRetryToast: ToastHandle | null = null +const movedSessionRefreshTimers = new Map>() function advanceNativeLifecycle(instanceId: string, sessionId: string): number { const key = `${instanceId}\0${sessionId}` @@ -112,7 +115,7 @@ function handleNativeSessionEvent(instanceId: string, event: NativeSessionEvent) }) return case "session.moved": - handleSessionMoved(instanceId, event.data.sessionID, event.data.location.directory) + handleSessionMoved(instanceId, event.data) return case "session.forked": void fetchSessionInfo(instanceId, event.data.sessionID, event.location?.directory) @@ -200,59 +203,49 @@ function setTerminalNativeSessionStatus(instanceId: string, sessionId: string, f speakCompletedAssistantText(instanceId, sessionId) } -function handleSessionMoved(sourceInstanceId: string, sessionId: string, directory: string): void { - const normalized = directory.replace(/\\/g, "/").toLowerCase() - const matchesDirectory = (instance: { id: string; folder: string }) => { - const directories = [instance.folder, ...getWorktrees(instance.id).map((worktree) => worktree.directory)] - return directories.some((candidate) => candidate.replace(/\\/g, "/").toLowerCase() === normalized) - } - // A location can be open in several instances. An echo for a location still - // owned by this instance must not transfer its restored session to whichever - // duplicate happens to appear first, or clear its transcript/reading state. - const sourceInstance = instances().get(sourceInstanceId) - const targetInstanceId = sourceInstance && matchesDirectory(sourceInstance) - ? sourceInstanceId - : Array.from(instances().values()).find(matchesDirectory)?.id - - if (!targetInstanceId || targetInstanceId === sourceInstanceId) { - if (targetInstanceId) withSession(sourceInstanceId, sessionId, (session) => { session.location = { directory } }) - void fetchSessions(sourceInstanceId, { reset: true }) - return - } - - const moved = sessions().get(sourceInstanceId)?.get(sessionId) - setSessions((previous) => { - const next = new Map(previous) - const source = new Map(next.get(sourceInstanceId) ?? []) - source.delete(sessionId) - if (source.size) next.set(sourceInstanceId, source) - else next.delete(sourceInstanceId) - if (moved) { - const target = new Map(next.get(targetInstanceId) ?? []) - target.set(sessionId, { ...moved, instanceId: targetInstanceId, location: { directory } }) - next.set(targetInstanceId, target) - } - return next +function handleSessionMoved(sourceInstanceId: string, data: SessionMoved["data"]): void { + const { sessionID: sessionId, location } = data + const normalized = normalizeSessionDirectory(location.directory) + const targetInstanceIds = Array.from(instances().values()).flatMap((instance) => { + const directories = [ + instance.folder, + ...getWorktrees(instance.id).flatMap((worktree) => [worktree.directory, worktree.serviceDirectory]), + ] + return directories.some((candidate) => normalizeSessionDirectory(candidate) === normalized) ? [instance.id] : [] }) - removeSessionListId(sourceInstanceId, sessionId) - if (moved && !moved.parentId) prependSessionListId(targetInstanceId, sessionId) - messageStoreBus.getOrCreate(sourceInstanceId).clearSession(sessionId) - void Promise.allSettled([ - fetchSessions(sourceInstanceId, { reset: true }), - fetchSessions(targetInstanceId, { reset: true }), - ]).then(() => { - setSessions((previous) => { - const current = previous.get(sourceInstanceId) - if (!current?.has(sessionId)) return previous - const next = new Map(previous) - const source = new Map(current) - source.delete(sessionId) - if (source.size) next.set(sourceInstanceId, source) - else next.delete(sourceInstanceId) - return next + + // A location can be open in several instances. Keep the native event with + // its source whenever that source still owns the destination instead of + // assigning it to an arbitrary duplicate view. + const sourceOwnsLocation = targetInstanceIds.includes(sourceInstanceId) + if (sourceOwnsLocation) { + withSession(sourceInstanceId, sessionId, (session) => { + session.location = location + session.projectID = data.projectID + session.subpath = data.subpath }) - removeSessionListId(sourceInstanceId, sessionId) - }) + if (activeSessionId().get(sourceInstanceId) === sessionId) { + void refreshSessionCatalog(sourceInstanceId).catch((error) => { + log.warn("Failed to refresh moved session catalog", { instanceId: sourceInstanceId, sessionId, error }) + }) + } + } + + const affectedInstanceIds = sourceOwnsLocation ? [sourceInstanceId] : [sourceInstanceId, ...targetInstanceIds] + for (const instanceId of new Set(affectedInstanceIds)) { + const pending = movedSessionRefreshTimers.get(instanceId) + if (pending) clearTimeout(pending) + movedSessionRefreshTimers.set(instanceId, setTimeout(() => { + movedSessionRefreshTimers.delete(instanceId) + void reloadWorktrees(instanceId).catch(error => { + log.warn("Failed to refresh moved-session worktrees", { instanceId, error }) + }).then(() => fetchSessions(instanceId, { reset: true })).then(() => ( + activeSessionId().get(instanceId) ? refreshSessionCatalog(instanceId) : undefined + )).catch((error) => { + log.warn("Failed to reconcile moved sessions", { instanceId, error }) + }) + }, 100)) + } } function shouldSendOsNotification(kind: "needsInput" | "idle"): boolean { diff --git a/packages/ui/src/stores/session-instructions.ts b/packages/ui/src/stores/session-instructions.ts new file mode 100644 index 000000000..5e4df26c2 --- /dev/null +++ b/packages/ui/src/stores/session-instructions.ts @@ -0,0 +1,71 @@ +import type { OpenCodeClient } from "@opencode/client" +import { isConversationModeEnabled } from "./conversation-speech" +import { getDefaultWorktreeDirectory } from "./worktrees" + +const PLACEMENT_INSTRUCTION_KEY = "codenomad.session-placement" +// Agent context, not UI copy. Keep this relative to the user's current choice: +// embedding an initial directory would become stale after an explicit move. +const PLACEMENT_INSTRUCTION = [ + "This session is being used through CodeNomad. The opened local repository and its Git worktrees form the CodeNomad project. A session's native location is its execution directory; moving between that repository's worktrees keeps it in the same project.", + "Creating a worktree and moving a conversation are separate actions. Creating a branch or worktree alone does not move this session. A request to continue this conversation's task in a worktree authorizes changing its native location, unless the user explicitly asks to preserve the current location.", + "When working elsewhere, use absolute paths and the tools' working-directory parameter (for example, shell workdir). Read and follow the instructions applicable to the files you edit there.", + "Use session_move for an authorized change of the conversation's working context. A one-off command or test in another directory only needs workdir. Never move merely to repair UI classification. Do not independently relocate a child session or assume session_move moves the complete session family; CodeNomad's UI handles family moves.", + "An explicit change of attachment becomes the new choice to preserve. Use the destination's exact directory spelling; do not infer a new attachment from the last directory used by a tool.", + "Unless the user specifies another destination, create worktrees under the local main checkout's .codenomad/worktrees directory, not recursively beneath the current linked checkout. Use a named branch starting at the current session checkout's HEAD unless another revision or a detached checkout is requested. Uncommitted changes remain in the original worktree. Preserve Git's refusal to reuse a branch checked out elsewhere; never force or reset it to make creation succeed.", + "Prefer native worktree operations when available. Native creation can produce a detached checkout: select or create the requested branch with Git without resetting an existing branch. If using Git directly, create an ordinary registered linked worktree, keep .codenomad/worktrees excluded from tracking using Git's resolved info/exclude path, and report its exact directory and branch. CodeNomad discovers registered worktrees regardless of who created them.", +].join("\n\n") + +const VOICE_MODE_INSTRUCTION_KEY = "codenomad.voice-mode" +const VOICE_MODE_INSTRUCTION = [ + "Voice conversation mode is enabled.", + "Prepend your reply with a fenced code block using language `spoken`.", + "The `spoken` block should be a concise, natural spoken gist of the full response in 2 to 4 sentences.", + "Do not include code, bullet lists, markdown formatting, or long technical detail in the spoken block.", + "After the `spoken` block, continue with your normal detailed response.", +].join("\n\n") +const voiceInstructionSyncs = new Map }>() + +async function syncVoiceModeInstruction(client: OpenCodeClient, instanceId: string, sessionId: string): Promise { + const key = `${instanceId}:${sessionId}` + const existing = voiceInstructionSyncs.get(key) + if (existing) { + existing.desired = isConversationModeEnabled(instanceId) + return existing.running + } + + const state = { desired: isConversationModeEnabled(instanceId), running: Promise.resolve() } + state.running = (async () => { + try { + let applied: boolean | undefined + while (applied !== state.desired) { + const desired = state.desired + const instruction = client.session.instructions.entry + if (desired) { + await instruction.put({ sessionID: sessionId, key: VOICE_MODE_INSTRUCTION_KEY, value: VOICE_MODE_INSTRUCTION }) + } else { + await instruction.remove({ sessionID: sessionId, key: VOICE_MODE_INSTRUCTION_KEY }) + } + applied = desired + state.desired = isConversationModeEnabled(instanceId) + } + } finally { + if (voiceInstructionSyncs.get(key) === state) voiceInstructionSyncs.delete(key) + } + })() + voiceInstructionSyncs.set(key, state) + return state.running +} + +// Called inside session admission before prompt/command/shell. Reapply the named +// entry for existing sessions and after reconnects, without a browser-only cache. +// Await errors so an action cannot silently bypass its instruction setup. +export async function syncSessionInstructions(client: OpenCodeClient, instanceId: string, sessionId: string): Promise { + await syncVoiceModeInstruction(client, instanceId, sessionId) + await client.session.instructions.entry.put({ + sessionID: sessionId, + key: PLACEMENT_INSTRUCTION_KEY, + value: [PLACEMENT_INSTRUCTION, getDefaultWorktreeDirectory(instanceId) + ? `The default worktree parent for this CodeNomad project is: ${getDefaultWorktreeDirectory(instanceId)}` + : ""].filter(Boolean).join("\n\n"), + }) +} diff --git a/packages/ui/src/stores/session-list-options.ts b/packages/ui/src/stores/session-list-options.ts index f66155609..17614423b 100644 --- a/packages/ui/src/stores/session-list-options.ts +++ b/packages/ui/src/stores/session-list-options.ts @@ -11,14 +11,27 @@ type ProjectSessionListInput = { } export type ProjectSessionListOptions = ProjectSessionListInput & { - limit: typeof PROJECT_SESSION_LIST_LIMIT + limit?: typeof PROJECT_SESSION_LIST_LIMIT +} + +export function normalizeSessionDirectory(directory: string | null | undefined): string { + const trimmed = directory?.trim() + if (!trimmed) return "" + + const slashNormalized = trimmed.replace(/\\/g, "/").replace(/\/+$/, "") + const comparable = slashNormalized || "/" + const wsl = comparable.match(/^\/\/(wsl(?:\.localhost|\$)?)[/]([^/]+)(.*)$/i) + if (wsl) return `//${wsl[1]!.toLowerCase()}/${wsl[2]!.toLowerCase()}${wsl[3]}` + const isWindowsPath = /^[A-Za-z]:\//.test(comparable) || comparable.startsWith("//") || trimmed.includes("\\") + + return isWindowsPath ? comparable.toLowerCase() : comparable } export function buildProjectSessionListOptions(options: ProjectSessionListInput): ProjectSessionListOptions { + if (options.cursor) return { cursor: options.cursor } return { ...(options.directory ? { directory: options.directory } : {}), ...(options.search ? { search: options.search } : {}), - ...(options.cursor ? { cursor: options.cursor } : {}), ...(options.project ? { project: options.project } : {}), ...(options.subpath ? { subpath: options.subpath } : {}), ...(options.parentID !== undefined ? { parentID: options.parentID } : {}), diff --git a/packages/ui/src/stores/session-move-restore.test.ts b/packages/ui/src/stores/session-move-restore.test.ts index 225558844..2d7c9854f 100644 --- a/packages/ui/src/stores/session-move-restore.test.ts +++ b/packages/ui/src/stores/session-move-restore.test.ts @@ -10,6 +10,8 @@ import { handleNativeSessionEvent } from "./session-events.ts" import { getSessionListIds, prependSessionListId, sessions, setSessions } from "./session-state.ts" import { ensureWorktreesLoaded, setWorktreeSlugForParentSession } from "./worktrees.ts" +const delay = (duration: number) => new Promise((resolve) => setTimeout(resolve, duration)) + function setup(id: string, folder: string) { const session = { id: "restored", instanceId: id, parentId: null, title: "restored", agent: "build", @@ -46,28 +48,43 @@ it("keeps a restored session in each duplicate-folder instance after a same-dire } as any) assert.ok(sessions().get(second.session.instanceId)?.has("restored"), "The event is not an exclusive transfer to the first matching instance") assert.equal(store.getMessageWindow("restored"), window, "Do not clear the historical message window") - await new Promise((resolve) => setImmediate(resolve)) + await delay(150) assert.ok(sessions().get(second.session.instanceId)?.has("restored"), "Catalog settlement must not remove the restored selection") assert.deepEqual(getSessionListIds(second.session.instanceId), ["restored"]) } finally { first.cleanup(); second.cleanup() } }) -it("does not send session.move when worktree option hydration reselects the current directory", async () => { +it("does not move a session family when worktree option hydration reselects the current directory", async () => { const fixture = setup("worktree-selection-noop", "C:\\Repo\\") const original = serverApi.fetchWorktrees const moves: string[] = [] - fixture.client.session.move = async (input: { directory: string }) => { moves.push(input.directory) } + let destination = fixture.session.location.directory serverApi.fetchWorktrees = async () => ({ isGitRepo: true, worktrees: [ { slug: "root", directory: "c:/repo" }, { slug: "feature", directory: "c:/repo/.worktrees/feature" }, ] }) as any + const moveOptions = { + moveFamily: async (_instanceId: string, rootSessionId: string, slug: string) => { + moves.push(`${rootSessionId}:${slug}`) + destination = slug === "root" ? "c:/repo" : "c:/repo/.worktrees/feature" + }, + refreshSessions: async () => { + setSessions((previous) => { + const next = new Map(previous) + const current = new Map(next.get(fixture.session.instanceId)) + current.set(fixture.session.id, { ...current.get(fixture.session.id)!, location: { directory: destination } }) + next.set(fixture.session.instanceId, current) + return next + }) + }, + } try { await ensureWorktreesLoaded(fixture.session.instanceId) - await setWorktreeSlugForParentSession(fixture.session.instanceId, "restored", "root") + await setWorktreeSlugForParentSession(fixture.session.instanceId, "restored", "root", moveOptions) assert.deepEqual(moves, [], "Controlled option initialization is not a move request") - await setWorktreeSlugForParentSession(fixture.session.instanceId, "restored", "feature") - assert.deepEqual(moves, ["c:/repo/.worktrees/feature"], "A real directory change still moves the session") - await setWorktreeSlugForParentSession(fixture.session.instanceId, "restored", "feature") + await setWorktreeSlugForParentSession(fixture.session.instanceId, "restored", "feature", moveOptions) + assert.deepEqual(moves, ["restored:feature"], "A real directory change still moves the session family") + await setWorktreeSlugForParentSession(fixture.session.instanceId, "restored", "feature", moveOptions) assert.equal(moves.length, 1) } finally { serverApi.fetchWorktrees = original; fixture.cleanup() } }) @@ -88,7 +105,7 @@ it("keeps an in-project move in the source instance when another tab opens that data: { sessionID: "restored", location: { directory: "/repo/.worktrees/feature" } }, } as any) assert.equal(sessions().get(root.session.instanceId)?.get("restored")?.location.directory, "/repo/.worktrees/feature") - await new Promise((resolve) => setImmediate(resolve)) + await delay(150) assert.deepEqual(getSessionListIds(root.session.instanceId), ["restored"]) } finally { serverApi.fetchWorktrees = original; worktree.cleanup(); root.cleanup() } }) @@ -102,9 +119,9 @@ it("still removes the old instance projection when the session really leaves its id: "different-location", type: "session.moved", created: 1, data: { sessionID: "restored", location: { directory: "/other" } }, } as any) + await delay(150) assert.equal(sessions().get(source.session.instanceId)?.has("restored") ?? false, false) assert.equal(sessions().get(target.session.instanceId)?.get("restored")?.location.directory, "/other") - await new Promise((resolve) => setImmediate(resolve)) assert.deepEqual(getSessionListIds(source.session.instanceId), []) assert.deepEqual(getSessionListIds(target.session.instanceId), ["restored"]) } finally { source.cleanup(); target.cleanup() } diff --git a/packages/ui/src/stores/session-native-events.test.ts b/packages/ui/src/stores/session-native-events.test.ts index 584bd54ef..463570c3e 100644 --- a/packages/ui/src/stores/session-native-events.test.ts +++ b/packages/ui/src/stores/session-native-events.test.ts @@ -16,33 +16,18 @@ describe("native session event reducer", () => { const client = { session: { active: async () => ({}) } } as any ;(sdkManager as any).clients.set(`${instanceId}:/workspaces/${instanceId}/instance`, client) addInstance({ id: instanceId, folder: "/work", port: 0, pid: 0, proxyPath: "", status: "ready", client }) - try { handleSessionUpdate(instanceId, { - id: "created", - type: "session.created", - created: 1, + id: "created", type: "session.created", created: 1, durable: { aggregateID: sessionId, seq: 1, version: 1 }, location: { directory: "/work" }, - data: { - sessionID: sessionId, - title: "Session", - version: "1", - metadata: { client: "codenomad", restore: { window: "primary" } }, - }, + data: { sessionID: sessionId, title: "Session", version: "1", metadata: { client: "codenomad", restore: { window: "primary" } } }, } as any) - assert.deepEqual(sessions().get(instanceId)?.get(sessionId)?.metadata, { - client: "codenomad", - restore: { window: "primary" }, - }) - + assert.deepEqual(sessions().get(instanceId)?.get(sessionId)?.metadata, { client: "codenomad", restore: { window: "primary" } }) handleNativeSessionEvent(instanceId, { - id: "streamed", - type: "session.step.streamed", - created: 2, + id: "streamed", type: "session.step.streamed", created: 2, durable: { aggregateID: sessionId, seq: 2, version: 1 }, - location: { directory: "/work" }, - data: { sessionID: sessionId, assistantMessageID: "assistant" }, + location: { directory: "/work" }, data: { sessionID: sessionId, assistantMessageID: "assistant" }, } as any) assert.equal(sessions().get(instanceId)?.get(sessionId)?.status, "working") } finally { @@ -53,6 +38,81 @@ describe("native session event reducer", () => { } }) + it("applies the complete moved location before reconciliation", () => { + const instanceId = "native-session-moved" + const sessionId = "session" + const client = { session: { list: async () => ({ data: [], cursor: {} }), active: async () => ({}) } } as any + ;(sdkManager as any).clients.set(`${instanceId}:/workspaces/${instanceId}/instance`, client) + addInstance({ id: instanceId, folder: "/worktree", port: 0, pid: 0, proxyPath: "", status: "ready", client }) + setSessions((previous) => new Map(previous).set(instanceId, new Map([[sessionId, { + id: sessionId, instanceId, parentId: null, title: sessionId, agent: "build", + model: { providerId: "provider", modelId: "model" }, status: "idle", retry: null, + idleSince: null, generationRecovery: null, runtimeStatusKnown: true, + projectID: "old-project", subpath: "old", location: { directory: "/old" }, time: { created: 1, updated: 1 }, + } as any]]))) + + try { + handleNativeSessionEvent(instanceId, { + id: "moved", created: 2, type: "session.moved", + durable: { aggregateID: sessionId, seq: 1, version: 1 }, + data: { + sessionID: sessionId, + projectID: "new-project", + subpath: "apps/web", + location: { directory: "/worktree", workspaceID: "workspace-2" }, + }, + }) + + const moved = sessions().get(instanceId)?.get(sessionId) + assert.deepEqual(moved?.location, { directory: "/worktree", workspaceID: "workspace-2" }) + assert.equal(moved?.projectID, "new-project") + assert.equal(moved?.subpath, "apps/web") + } finally { + setSessions((previous) => { const next = new Map(previous); next.delete(instanceId); return next }) + removeInstance(instanceId, { authoritative: false }) + sdkManager.destroyClientsForInstance(instanceId) + } + }) + + it("does not infer a cross-instance owner from a shared directory", async () => { + const sourceId = "native-session-source" + const duplicateId = "native-session-duplicate" + const sessionId = "session" + const client = { session: { list: async () => ({ data: [], cursor: {} }), active: async () => ({}) } } as any + for (const instanceId of [sourceId, duplicateId]) { + ;(sdkManager as any).clients.set(`${instanceId}:/workspaces/${instanceId}/instance`, client) + addInstance({ id: instanceId, folder: "/shared", port: 0, pid: 0, proxyPath: "", status: "ready", client }) + } + setSessions((previous) => new Map(previous).set(sourceId, new Map([[sessionId, { + id: sessionId, instanceId: sourceId, parentId: null, title: sessionId, agent: "build", + model: { providerId: "provider", modelId: "model" }, status: "idle", retry: null, + idleSince: null, generationRecovery: null, runtimeStatusKnown: true, + projectID: "project", location: { directory: "/old" }, time: { created: 1, updated: 1 }, + } as any]]))) + + try { + handleNativeSessionEvent(sourceId, { + id: "moved", created: 2, type: "session.moved", + durable: { aggregateID: sessionId, seq: 1, version: 1 }, + data: { sessionID: sessionId, projectID: "project", location: { directory: "/shared", workspaceID: "unknown" } }, + }) + assert.equal(Boolean(sessions().get(duplicateId)?.has(sessionId)), false) + await delay(20) + assert.equal(Boolean(sessions().get(duplicateId)?.has(sessionId)), false) + } finally { + setSessions((previous) => { + const next = new Map(previous) + next.delete(sourceId) + next.delete(duplicateId) + return next + }) + for (const instanceId of [sourceId, duplicateId]) { + removeInstance(instanceId, { authoritative: false }) + sdkManager.destroyClientsForInstance(instanceId) + } + } + }) + it("projects text immediately, coalesces compaction deltas, and keeps terminal state authoritative", async () => { const instanceId = "native-compaction-delta" const sessionId = "session" diff --git a/packages/ui/src/stores/session-pagination.test.ts b/packages/ui/src/stores/session-pagination.test.ts index 01df78a66..94890401c 100644 --- a/packages/ui/src/stores/session-pagination.test.ts +++ b/packages/ui/src/stores/session-pagination.test.ts @@ -24,9 +24,7 @@ describe("project session list loading", () => { it("passes native cursors through unchanged", () => { const cursor = `next-page-${"x".repeat(4096)}` assert.deepEqual(buildProjectSessionListOptions({ directory: "/tmp/project", cursor }), { - directory: "/tmp/project", cursor, - limit: PROJECT_SESSION_LIST_LIMIT, }) }) diff --git a/packages/ui/src/stores/session-request-authority.test.ts b/packages/ui/src/stores/session-request-authority.test.ts index 9a0ec1ecc..1e00b3efe 100644 --- a/packages/ui/src/stores/session-request-authority.test.ts +++ b/packages/ui/src/stores/session-request-authority.test.ts @@ -2,11 +2,12 @@ import assert from "node:assert/strict" import { describe, it } from "node:test" import { sdkManager } from "../lib/sdk-manager.ts" +import { serverApi } from "../lib/api-client.ts" import type { Session } from "../types/session.ts" import { addInstance, instances, refreshVolatileInstanceState, removeInstance, updateInstance } from "./instances.ts" import { messageStoreBus } from "./message-v2/bus.ts" import { fetchCommands, getCommands } from "./commands.ts" -import { beginMessageHistoryTraversal, fetchAgents, fetchProviders, fetchSessions, forkSession, hasMoreMessages, hydrateRestoredSessionChain, invalidateMessageHistoryTraversal, isLatestMessageWindow, loadLatestMessageWindow, loadMessages, loadMoreMessages, loadMoreSessions, loadNewerMessageWindow, loadOldestMessageWindow, removeSessionRuntimeState, searchSessions } from "./session-api.ts" +import { beginMessageHistoryTraversal, fetchAgents, fetchProviders, fetchSessions, forkSession, hasMoreMessages, hydrateRestoredSessionChain, invalidateMessageHistoryTraversal, isLatestMessageWindow, loadAllSessions, loadLatestMessageWindow, loadMessages, loadMoreMessages, loadMoreSessions, loadNewerMessageWindow, loadOldestMessageWindow, removeSessionRuntimeState, searchSessions } from "./session-api.ts" import { handleNativeSessionEvent, handleSessionUpdate } from "./session-events.ts" import { getInstanceMetadata, setInstanceMetadata } from "./instance-metadata.ts" import { loadInstanceMetadata } from "../lib/hooks/use-instance-metadata.ts" @@ -57,12 +58,19 @@ function apiMessage(id: string) { } function setup(instanceId: string) { + const originalFetchWorktrees = serverApi.fetchWorktrees + serverApi.fetchWorktrees = async () => ({ isGitRepo: true, worktrees: [ + { slug: "root", directory: "/work", kind: "root" }, + { slug: "feature", directory: "/work/.worktrees/feature", kind: "worktree" }, + { slug: "external-feature", directory: "/work-feature", kind: "worktree" }, + ] }) const client = { session: { active: async () => ({}) } } as any ;(sdkManager as any).clients.set(`${instanceId}:/workspaces/${instanceId}/instance`, client) addInstance({ id: instanceId, folder: "/work", port: 0, pid: 0, proxyPath: "", status: "ready", client }) return { client, cleanup() { + serverApi.fetchWorktrees = originalFetchWorktrees messageStoreBus.unregisterInstance(instanceId) setSessions((previous) => { const next = new Map(previous); next.delete(instanceId); return next }) clearInstanceDeletedSessionAuthority(instanceId) @@ -311,7 +319,7 @@ describe("session request authority", () => { assert.equal(sessions().get(instanceId)?.has("child") ?? false, false) assert.equal(sessions().get(instanceId)?.has("parent") ?? false, false) assert.deepEqual(getSessionSearchResultIds(instanceId), []) - assert.equal(calls, 1) + assert.equal(calls, 3) } finally { cleanup() } @@ -1589,7 +1597,12 @@ describe("session request authority", () => { cursor: { next: "root-page-2" }, } if (input.project === "project") return { - data: [apiSession("root"), apiSession("child", "root")], + data: [ + apiSession("root"), + apiSession("child", "root"), + { ...apiSession("worktree-root"), location: { directory: "/work/.worktrees/feature" } }, + { ...apiSession("other-clone"), location: { directory: "/other-clone" } }, + ], cursor: { next: "project-inventory-page-2" }, } return { @@ -1603,21 +1616,24 @@ describe("session request authority", () => { assert.equal(sessions().get(instanceId)?.has("root"), true) assert.equal(sessions().get(instanceId)?.has("child"), true) assert.equal(sessions().get(instanceId)?.has("grandchild"), true) + assert.equal(sessions().get(instanceId)?.has("worktree-root"), true) + assert.equal(getSessionListIds(instanceId).includes("worktree-root"), true) + assert.equal(getSessionListIds(instanceId).includes("other-clone"), false) + assert.equal(sessions().get(instanceId)?.has("other-clone"), false) assert.equal(sessions().get(instanceId)?.get("legacy-child")?.projectID, "global") assert.equal(requests[0].directory, "/work") assert.equal("project" in requests[0], false) assert.equal(requests[0].parentID, null) assert.equal(requests[1].project, "project") assert.equal("directory" in requests[1], false) - assert.deepEqual(requests[2], { cursor: "project-inventory-page-2", limit: 200 }) + assert.deepEqual(requests[2], { cursor: "project-inventory-page-2" }) assert.equal(requests[3].directory, "/work") assert.equal("project" in requests[3], false) - assert.deepEqual(requests[4], { cursor: "directory-inventory-page-2", limit: 200 }) + assert.deepEqual(requests[4], { cursor: "directory-inventory-page-2" }) assert.equal(requests.some((request) => typeof request.parentID === "string"), false) - await loadMoreSessions(instanceId) - assert.deepEqual(requests[5], { cursor: "root-page-2", limit: 200 }) - assert.equal(requests.filter((request) => request.cursor).every((request) => Object.keys(request).sort().join(",") === "cursor,limit"), true) + assert.deepEqual(requests[5], { cursor: "root-page-2" }) + assert.equal(requests.filter((request) => request.cursor).every((request) => Object.keys(request).join(",") === "cursor"), true) assert.equal(requests.length, 6) assert.equal(sessions().get(instanceId)?.get("later")?.status, "working") assert.equal(sessions().get(instanceId)?.get("later")?.runtimeStatusKnown, true) @@ -1626,6 +1642,138 @@ describe("session request authority", () => { } }) + it("keeps worktree rows visible throughout repeated refreshes until the complete inventory replaces them", async () => { + const instanceId = "stable-worktree-rows" + const { client, cleanup } = setup(instanceId) + const root = apiSession("root") + const worktree = { ...apiSession("worktree"), location: { directory: "/work-feature" } } + let inventory = deferred() + let inventoryStarted = deferred() + let rootPageHasMore = false + setInstanceMetadata(instanceId, { project: { id: "project", directory: "/work", canonical: "/work" } as any }) + ;(client.session as any).list = async (input: any) => { + if (input.project === "project") { + inventoryStarted.resolve() + return inventory.promise + } + return { data: [root], cursor: input.parentID === null && rootPageHasMore ? { next: "older-roots" } : {} } + } + + try { + inventory.resolve({ data: [root, worktree], cursor: {} }) + await fetchSessions(instanceId) + assert.deepEqual(getSessionListIds(instanceId), ["root", "worktree"]) + + for (let refresh = 0; refresh < 3; refresh += 1) { + inventory = deferred() + inventoryStarted = deferred() + const request = fetchSessions(instanceId, { reset: true }) + await inventoryStarted.promise + // The directory page has arrived, but the worktree inventory has not. + const pendingIds = [...getSessionListIds(instanceId)] + inventory.resolve({ data: [root, worktree], cursor: {} }) + await request + assert.deepEqual(pendingIds, ["root", "worktree"]) + assert.deepEqual(getSessionListIds(instanceId), ["root", "worktree"]) + } + + inventory = deferred() + inventoryStarted = deferred() + const failedRequest = fetchSessions(instanceId, { reset: true }) + await inventoryStarted.promise + inventory.resolve(Promise.reject(new Error("inventory unavailable"))) + await failedRequest + assert.deepEqual(getSessionListIds(instanceId), ["root", "worktree"]) + + // A complete inventory is authoritative even when the first root page + // has an older continuation. A session that left the scope must disappear. + rootPageHasMore = true + inventory = deferred() + inventoryStarted = deferred() + const request = fetchSessions(instanceId, { reset: true }) + await inventoryStarted.promise + const pendingIds = [...getSessionListIds(instanceId)] + inventory.resolve({ data: [root, { ...worktree, location: { directory: "/other-clone" } }], cursor: {} }) + await request + assert.deepEqual(pendingIds, ["root", "worktree"]) + assert.deepEqual(getSessionListIds(instanceId), ["root"]) + } finally { + cleanup() + } + }) + + it("rejects a repeated incremental session cursor", async () => { + const instanceId = "repeated-root-cursor" + const { client, cleanup } = setup(instanceId) + setInstanceMetadata(instanceId, { project: { id: "project", directory: "/work", canonical: "/work" } as any }) + ;(client.session as any).list = async (input: any) => { + if (input.cursor === "repeat") return { data: [], cursor: { next: "repeat" } } + if (input.parentID === null) return { data: [apiSession("root")], cursor: { next: "repeat" } } + return { data: [apiSession("root")], cursor: {} } + } + + try { + await fetchSessions(instanceId) + await assert.rejects(loadMoreSessions(instanceId), /Repeated session cursor/) + } finally { + cleanup() + } + }) + + it("loads every root page for global sorting", async () => { + const instanceId = "global-sort-pages" + const { client, cleanup } = setup(instanceId) + const cursors: string[] = [] + ;(client.session as any).list = async (input: any) => { + if (!input.cursor) return { data: [apiSession("root-1")], cursor: { next: "page-2" } } + cursors.push(input.cursor) + const page = Number(input.cursor.slice("page-".length)) + return { data: [apiSession(`root-${page}`)], cursor: page < 5 ? { next: `page-${page + 1}` } : {} } + } + + try { + await fetchSessions(instanceId) + await Promise.all([loadAllSessions(instanceId), loadAllSessions(instanceId)]) + assert.deepEqual(cursors, ["page-2", "page-3", "page-4", "page-5"]) + assert.deepEqual(getSessionListIds(instanceId), ["root-1", "root-2", "root-3", "root-4", "root-5"]) + } finally { + cleanup() + } + }) + + it("starts a fresh sort exhaustion when the session list is superseded", async () => { + const instanceId = "superseded-sort-pages" + const { client, cleanup } = setup(instanceId) + const stalePage = deferred() + const stalePageStarted = deferred() + let refreshed = false + ;(client.session as any).list = async (input: any) => { + if (input.cursor === "stale-page") { + stalePageStarted.resolve() + return stalePage.promise + } + if (input.cursor === "fresh-page") return { data: [apiSession("fresh-2")], cursor: {} } + return refreshed + ? { data: [apiSession("fresh-1")], cursor: { next: "fresh-page" } } + : { data: [apiSession("stale-1")], cursor: { next: "stale-page" } } + } + + try { + await fetchSessions(instanceId) + const staleExhaustion = loadAllSessions(instanceId) + await stalePageStarted.promise + refreshed = true + await fetchSessions(instanceId) + await loadAllSessions(instanceId) + stalePage.resolve({ data: [apiSession("stale-2")], cursor: {} }) + await staleExhaustion + assert.equal(getSessionListIds(instanceId).includes("fresh-2"), true) + assert.equal(getSessionListIds(instanceId).includes("stale-2"), false) + } finally { + cleanup() + } + }) + it("keeps migrated global roots from the current project directory", async () => { const instanceId = "migrated-global-roots" const { client, cleanup } = setup(instanceId) @@ -1652,6 +1800,25 @@ describe("session request authority", () => { } }) + it("searches the local worktree catalogue and follows each opaque cursor without widening scope", async () => { + const instanceId = "search-local-worktrees" + const { client, cleanup } = setup(instanceId) + const requests: any[] = [] + ;(client.session as any).list = async (input: any) => { + requests.push(input) + if (input.cursor === "feature-next") return { data: [{ ...apiSession("feature-match"), location: { directory: "/work-feature" } }], cursor: {} } + if (input.directory === "/work-feature") return { data: [], cursor: { next: "feature-next" } } + return { data: [], cursor: {} } + } + try { + await searchSessions(instanceId, "match") + assert.deepEqual(getSessionSearchResultIds(instanceId), ["feature-match"]) + assert.deepEqual(requests.filter(input => input.directory).map(input => input.directory), ["/work", "/work/.worktrees/feature", "/work-feature"]) + assert.deepEqual(requests.at(-1), { cursor: "feature-next" }) + assert.ok(requests.every(input => !input.project)) + } finally { cleanup() } + }) + it("keeps the global project scoped to the workspace directory", async () => { const instanceId = "global-project-directory" const { client, cleanup } = setup(instanceId) @@ -1659,8 +1826,9 @@ describe("session request authority", () => { setInstanceMetadata(instanceId, { project: { id: "global", directory: "/", canonical: "/" } as any }) ;(client.session as any).list = async (input: any) => { requests.push(input) + if (input.cursor === "search-next") return { data: [apiSession("search-result")], cursor: {} } if (input.cursor) return { data: [], cursor: {} } - if (input.search) return { data: [], cursor: {} } + if (input.search) return { data: [], cursor: { next: "search-next" } } return { data: [], cursor: { next: input.parentID === null ? "root-next" : "inventory-next" } } } @@ -1669,11 +1837,12 @@ describe("session request authority", () => { await loadMoreSessions(instanceId) await searchSessions(instanceId, "needle") - assert.equal(requests.length, 5) + assert.equal(requests.length, 6) + assert.deepEqual(requests.at(-1), { cursor: "search-next" }) assert.equal(requests.filter((request) => !request.cursor).every((request) => request.directory === "/work"), true) assert.equal(requests.every((request) => !("project" in request)), true) assert.equal(requests.filter((request) => request.cursor) - .every((request) => Object.keys(request).sort().join(",") === "cursor,limit"), true) + .every((request) => Object.keys(request).join(",") === "cursor"), true) } finally { cleanup() } diff --git a/packages/ui/src/stores/session-send-lifecycle.test.ts b/packages/ui/src/stores/session-send-lifecycle.test.ts index e3d311bda..98239125a 100644 --- a/packages/ui/src/stores/session-send-lifecycle.test.ts +++ b/packages/ui/src/stores/session-send-lifecycle.test.ts @@ -8,7 +8,7 @@ import { messageStoreBus } from "./message-v2/bus.ts" import { setConversationModeEnabled } from "./conversation-speech.ts" import { sendMessage } from "./session-actions.ts" import { handleNativeSessionEvent, handleSessionError } from "./session-events.ts" -import { clearInstanceDeletedSessionAuthority, setSessions } from "./session-state.ts" +import { clearInstanceDeletedSessionAuthority, sessions, setSessions } from "./session-state.ts" import { partHasRenderableText } from "../types/message.ts" function session(instanceId: string, id: string): Session { @@ -115,7 +115,7 @@ describe("optimistic send lifecycle", () => { try { setConversationModeEnabled(instanceId, true) await sendMessage(instanceId, sessionId, "hello") - assert.deepEqual(calls, ["put:codenomad.voice-mode", "prompt"]) + assert.deepEqual(calls, ["put:codenomad.voice-mode", "put:codenomad.session-placement", "prompt"]) } finally { cleanup() } @@ -143,6 +143,47 @@ describe("optimistic send lifecycle", () => { } }) + it("repairs placement context on an existing session without pinning its initial directory or replacing other entries", async () => { + const instanceId = "send-placement-repair" + const sessionId = "session" + const entries = new Map([["user.context", "keep me"], ["codenomad.session-placement", "outdated"]]) + let writes = 0 + let instruction: unknown + const cleanup = setup(instanceId, sessionId, async () => { + instruction = entries.get("codenomad.session-placement") + assert.equal(typeof instruction, "string") + assert.notEqual(instruction, "outdated") + assert.equal(entries.get("user.context"), "keep me") + }, { + put: async (input) => { + assert.equal(input.sessionID, sessionId) + entries.set(input.key, input.value) + if (input.key === "codenomad.session-placement") writes++ + }, + remove: async (input) => { entries.delete(input.key) }, + }) + + try { + await sendMessage(instanceId, sessionId, "work in another directory") + const originalInstruction = instruction + assert.deepEqual(sessions().get(instanceId)?.get(sessionId)?.location, { directory: "/work" }) + // A real move is reflected by native session reconciliation; the next send + // must preserve that choice, rather than reattaching to the original folder. + setSessions((prev) => new Map(prev).set(instanceId, new Map([ + [sessionId, { ...session(instanceId, sessionId), location: { directory: "/worktree" } }], + ]))) + entries.delete("codenomad.session-placement") + setConversationModeEnabled(instanceId, true) + await sendMessage(instanceId, sessionId, "continue here") + assert.equal(writes, 2) + assert.equal(instruction, originalInstruction) + assert.deepEqual(sessions().get(instanceId)?.get(sessionId)?.location, { directory: "/worktree" }) + assert.equal(entries.has("codenomad.voice-mode"), true) + } finally { + cleanup() + } + }) + it("marks a rejected prompt failed so authoritative hydration can remove it", async () => { const instanceId = "send-rejected" const sessionId = "session" diff --git a/packages/ui/src/stores/session-state.ts b/packages/ui/src/stores/session-state.ts index 38b996388..a80a00b98 100644 --- a/packages/ui/src/stores/session-state.ts +++ b/packages/ui/src/stores/session-state.ts @@ -915,21 +915,18 @@ function getSessionSearchThreads(instanceId: string): SessionThread[] { if (!instanceSessions) return [] const rootIds: string[] = [] - const childIds = new Set() - for (const sessionId of resultIds) { const session = instanceSessions.get(sessionId) if (!session) continue if (session.parentId === null) { if (!rootIds.includes(session.id)) rootIds.push(session.id) } else { - childIds.add(session.id) const root = getSessionRootFromMap(instanceSessions, session.id) if (root && !rootIds.includes(root.id)) rootIds.push(root.id) } } - return buildSessionThreads(instanceId, rootIds, childIds) + return buildSessionThreads(instanceId, rootIds) } function isSessionExpanded(instanceId: string, sessionId: string): boolean { diff --git a/packages/ui/src/stores/session-tree.test.ts b/packages/ui/src/stores/session-tree.test.ts index e11d9a679..91cc927f2 100644 --- a/packages/ui/src/stores/session-tree.test.ts +++ b/packages/ui/src/stores/session-tree.test.ts @@ -11,8 +11,10 @@ import { getDescendantSessionsFromMap, getSessionAncestorIdsFromMap, getSessionRootFromMap, + projectSessionFamilies, sortSessionIdsDeepestFirst, } from "./session-tree" +import { normalizeSessionDirectory } from "./session-list-options" function session(id: string, parentId: string | null, updated: number): Session { return { id, parentId, time: { created: updated, updated } } as Session @@ -23,6 +25,14 @@ function sessionMap(definitions: Array<[string, string | null, number]>): Map { + it("preserves case in the Linux portion of WSL UNC paths", () => { + assert.equal( + normalizeSessionDirectory("\\\\WSL.localhost\\Ubuntu\\Repo\\Feature"), + "//wsl.localhost/ubuntu/Repo/Feature", + ) + assert.equal(normalizeSessionDirectory("\\\\wsl$\\Ubuntu\\Repo\\Feature"), "//wsl$/ubuntu/Repo/Feature") + }) + it("preserves nesting and sorts siblings by descendant activity", () => { const sessions = sessionMap([ ["root", null, 100], @@ -177,4 +187,18 @@ describe("session tree", () => { ["grandchild", "child", "root"], ) }) + + it("keeps a complete family when a descendant matches", () => { + const sessions = sessionMap([ + ["root", null, 100], + ["matching-child", "root", 500], + ["sibling", "root", 200], + ]) + const matched = projectSessionFamilies(buildSessionThreadsFromMap(sessions, ["root"]), { + sort: "activity", + matchesSession: (item) => item.id === "matching-child", + getWorktreeLabel: () => "root", + }) + assert.deepEqual(collectSessionThreadIds(matched), ["root", "matching-child", "sibling"]) + }) }) diff --git a/packages/ui/src/stores/session-tree.ts b/packages/ui/src/stores/session-tree.ts index 1d1a893ab..ea84fcc54 100644 --- a/packages/ui/src/stores/session-tree.ts +++ b/packages/ui/src/stores/session-tree.ts @@ -1,4 +1,5 @@ import type { Session } from "../types/session" +import { normalizeSessionDirectory } from "./session-list-options" export type SessionThread = { session: Session @@ -17,6 +18,46 @@ export type VisibleSessionRow = { expanded: boolean } +export type SessionFamilySort = "activity" | "name" | "worktree" + +type SessionFamilyProjection = { + matchesSession?: (session: Session) => boolean + worktreeDirectory?: string + sort: SessionFamilySort + getWorktreeLabel: (directory: string) => string +} + +function someSession(thread: SessionThread, predicate: (session: Session) => boolean): boolean { + return predicate(thread.session) || thread.children.some((child) => someSession(child, predicate)) +} + +export function projectSessionFamilies( + threads: SessionThread[], + options: SessionFamilyProjection, +): SessionThread[] { + const worktreeDirectory = normalizeSessionDirectory(options.worktreeDirectory) + const projected = threads.filter((thread) => { + if (options.matchesSession && !someSession(thread, options.matchesSession)) return false + if (!worktreeDirectory) return true + return someSession( + thread, + (session) => normalizeSessionDirectory(session.location?.directory) === worktreeDirectory, + ) + }) + + return [...projected].sort((left, right) => { + if (options.sort === "activity") { + return right.latestUpdated - left.latestUpdated || right.session.id.localeCompare(left.session.id) + } + if (options.sort === "name") { + return (left.session.title ?? "").localeCompare(right.session.title ?? "") || left.session.id.localeCompare(right.session.id) + } + const leftLabel = options.getWorktreeLabel(left.session.location?.directory ?? "") + const rightLabel = options.getWorktreeLabel(right.session.location?.directory ?? "") + return leftLabel.localeCompare(rightLabel) || (left.session.title ?? "").localeCompare(right.session.title ?? "") + }) +} + export function getSessionRootFromMap(instanceSessions: Map, sessionId: string): Session | null { let current = instanceSessions.get(sessionId) if (!current) return null diff --git a/packages/ui/src/stores/sessions.ts b/packages/ui/src/stores/sessions.ts index f3a373ab2..e31618b33 100644 --- a/packages/ui/src/stores/sessions.ts +++ b/packages/ui/src/stores/sessions.ts @@ -70,7 +70,7 @@ import { import { isSessionBusy } from "./session-status" import { getDefaultModel } from "./session-models" -import { handleWorktreeReady } from "./worktrees" +import { handleWorktreeReady, reloadWorktrees } from "./worktrees" import { createSession, deleteSession, @@ -84,6 +84,7 @@ import { getMessageNextCursor, loadMoreMessages, loadMoreSessions, + loadAllSessions, searchSessions, forkSession, loadMessages, @@ -129,6 +130,10 @@ sseManager.onTuiToast = handleTuiToast sseManager.onPermissionUpdated = handlePermissionUpdated sseManager.onPermissionReplied = handlePermissionReplied sseManager.onWorktreeReady = handleWorktreeReady +sseManager.onWorktreeUpdated = async (instanceId) => { + await reloadWorktrees(instanceId) + await fetchSessions(instanceId, { reset: true }) +} export { abortSession, @@ -165,6 +170,7 @@ export { loadOldestMessageWindow, isLatestMessageWindow, loadMoreSessions, + loadAllSessions, searchSessions, forkSession, getActiveParentSession, diff --git a/packages/ui/src/stores/workspace-session-scope.test.ts b/packages/ui/src/stores/workspace-session-scope.test.ts new file mode 100644 index 000000000..30989b270 --- /dev/null +++ b/packages/ui/src/stores/workspace-session-scope.test.ts @@ -0,0 +1,46 @@ +import assert from "node:assert/strict" +import { it } from "node:test" +import type { SessionInfo } from "@opencode/client" +import { selectWorkspaceSessionFamilies } from "./workspace-session-scope.ts" + +function session(id: string, directory: string, parentID?: string): SessionInfo { + return { id, projectID: "shared-native-project", parentID, location: { directory } } as SessionInfo +} + +it("scopes a native project to the opened local repository and its registered worktrees", () => { + const inventory = [ + session("root", "/repo"), + session("worktree", "/separate-worktrees/feature"), + session("child", "/separate-worktrees/feature", "worktree"), + session("clone", "/other-clone"), + session("clone-child", "/other-clone", "clone"), + session("prefix", "/repo-copy"), + session("unregistered", "/repo/.worktrees/unregistered"), + { ...session("migrated", "/repo"), projectID: "global" }, + ] + const selected = selectWorkspaceSessionFamilies(inventory, "/repo", [ + { slug: "root", directory: "/repo", kind: "root" }, + { slug: "feature", directory: "/separate-worktrees/feature", kind: "worktree" }, + ]) + assert.deepEqual(selected.map(({ id }) => id), ["root", "worktree", "child", "migrated"]) +}) + +it("retains the ancestry and siblings needed by a local family member", () => { + const inventory = [ + session("parent", "/former-directory"), + session("local-child", "/repo", "parent"), + session("sibling", "/former-directory", "parent"), + session("unrelated", "/former-directory"), + ] + assert.deepEqual(selectWorkspaceSessionFamilies(inventory, "/repo", []).map(({ id }) => id), [ + "parent", "local-child", "sibling", + ]) +}) + +it("matches the service-side paths of WSL worktrees without treating a shared project ID as ownership", () => { + const inventory = [session("owned", "/home/me/repo-feature"), session("clone", "/home/me/clone")] + const selected = selectWorkspaceSessionFamilies(inventory, "D:\\repo", [ + { slug: "feature", directory: "D:\\repo-feature", serviceDirectory: "/home/me/repo-feature", kind: "worktree" }, + ]) + assert.deepEqual(selected.map(({ id }) => id), ["owned"]) +}) diff --git a/packages/ui/src/stores/workspace-session-scope.ts b/packages/ui/src/stores/workspace-session-scope.ts new file mode 100644 index 000000000..7d4a4abbe --- /dev/null +++ b/packages/ui/src/stores/workspace-session-scope.ts @@ -0,0 +1,30 @@ +import type { SessionInfo } from "@opencode/client" +import type { WorktreeDescriptor } from "../../../server/src/api-types" +import { normalizeSessionDirectory } from "./session-list-options" + +// Native project IDs may be shared by independent clones. The opened local +// directory and its registered worktrees define the CodeNomad workspace. +export function selectWorkspaceSessionFamilies( + inventory: SessionInfo[], + folder: string, + worktrees: WorktreeDescriptor[], +): SessionInfo[] { + const directories = new Set([folder, ...worktrees.flatMap((worktree) => [ + worktree.directory, + worktree.serviceDirectory ?? worktree.directory, + ])].map(normalizeSessionDirectory).filter(Boolean)) + const byId = new Map(inventory.map((session) => [session.id, session])) + const rootOf = (session: SessionInfo): string => { + const seen = new Set() + while (session.parentID && byId.has(session.parentID) && !seen.has(session.id)) { + seen.add(session.id) + session = byId.get(session.parentID)! + } + return session.id + } + // Keep complete families, including a parent needed by a local descendant. + const roots = new Set(inventory + .filter((session) => directories.has(normalizeSessionDirectory(session.location.directory))) + .map(rootOf)) + return inventory.filter((session) => roots.has(rootOf(session))) +} diff --git a/packages/ui/src/stores/worktree-ready.test.ts b/packages/ui/src/stores/worktree-ready.test.ts index d29286db6..67886ca53 100644 --- a/packages/ui/src/stores/worktree-ready.test.ts +++ b/packages/ui/src/stores/worktree-ready.test.ts @@ -2,7 +2,9 @@ import assert from "node:assert/strict" import { describe, it } from "node:test" import { serverApi } from "../lib/api-client.ts" -import { ensureWorktreesLoaded, getWorktrees, handleWorktreeReady, reloadWorktrees } from "./worktrees.ts" +import { ensureWorktreesLoaded, getWorktrees, handleWorktreeReady, reloadWorktrees, setWorktreeSlugForParentSession } from "./worktrees.ts" +import type { Session } from "../types/session.ts" +import { sessions, setSessions } from "./session-state.ts" describe("handleWorktreeReady", () => { it("refreshes worktrees", async () => { @@ -133,3 +135,53 @@ describe("handleWorktreeReady", () => { } }) }) + +describe("session family worktree moves", () => { + it("moves a descendant's family root and refreshes authoritatively", async () => { + const instanceId = "family-move" + const originalFetchWorktrees = serverApi.fetchWorktrees + serverApi.fetchWorktrees = async () => ({ + isGitRepo: true, + worktrees: [ + { slug: "root", directory: "/repo", kind: "root" }, + { slug: "feature", directory: "/repo-feature", kind: "worktree" }, + ], + }) + const root = { id: "root", parentId: null, location: { directory: "/repo" } } as Session + const child = { id: "child", parentId: "root", location: { directory: "/repo" } } as Session + setSessions((prev) => new Map(prev).set(instanceId, new Map([[root.id, root], [child.id, child]]))) + await reloadWorktrees(instanceId) + + try { + const calls: string[] = [] + await setWorktreeSlugForParentSession(instanceId, child.id, "feature", { + moveFamily: async (_id, rootSessionId, slug) => { + calls.push(`move:${rootSessionId}:${slug}`) + assert.equal(root.location.directory, "/repo") + assert.equal(child.location.directory, "/repo") + }, + refreshSessions: async () => { + calls.push("refresh") + setSessions((prev) => new Map(prev).set(instanceId, new Map([ + [root.id, { ...root, location: { directory: "/repo-feature" } }], + [child.id, { ...child, location: { directory: "/repo-feature" } }], + ]))) + }, + }) + assert.deepEqual(calls, ["move:root:feature", "refresh"]) + assert.equal(root.location.directory, "/repo") + assert.equal(child.location.directory, "/repo") + assert.equal(sessions().get(instanceId)?.get(root.id)?.location.directory, "/repo-feature") + assert.equal(sessions().get(instanceId)?.get(child.id)?.location.directory, "/repo-feature") + + await setWorktreeSlugForParentSession(instanceId, child.id, "root", { + moveFamily: async () => { calls.push("move:root") }, + refreshSessions: async () => { throw new Error("refresh failed") }, + }) + assert.equal(calls.at(-1), "move:root") + } finally { + serverApi.fetchWorktrees = originalFetchWorktrees + setSessions((prev) => { const next = new Map(prev); next.delete(instanceId); return next }) + } + }) +}) diff --git a/packages/ui/src/stores/worktrees-delete.test.ts b/packages/ui/src/stores/worktrees-delete.test.ts index 352b4be52..11d149be5 100644 --- a/packages/ui/src/stores/worktrees-delete.test.ts +++ b/packages/ui/src/stores/worktrees-delete.test.ts @@ -2,54 +2,69 @@ import assert from "node:assert/strict" import { describe, it } from "node:test" import { serverApi } from "../lib/api-client.ts" -import { sdkManager } from "../lib/sdk-manager.ts" import type { Session } from "../types/session.ts" -import { addInstance, removeInstance } from "./instances.ts" import { sessions, setSessions } from "./session-state.ts" -import { deleteWorktree, ensureWorktreesLoaded } from "./worktrees.ts" +import { deleteWorktree, getWorktrees } from "./worktrees.ts" describe("worktree deletion authority", () => { - it("keeps locations on failure and reconciles them locally only after DELETE succeeds", async () => { + it("keeps locations on failure and accepts authoritative refresh after DELETE succeeds", async () => { const instanceId = "server-owned-worktree-delete" const originalFetch = serverApi.fetchWorktrees const originalDelete = serverApi.deleteWorktree - let moves = 0 let deletes = 0 - const client = { session: { move: async () => { moves += 1 } } } as any - ;(sdkManager as any).clients.set(`${instanceId}:/workspaces/${instanceId}/instance`, client) - addInstance({ id: instanceId, folder: "/repo", port: 0, pid: 0, proxyPath: "", status: "ready", client }) + let worktreeRefreshes = 0 + let refreshes = 0 setSessions((previous) => new Map(previous).set(instanceId, new Map([["session", { id: "session", instanceId, parentId: null, title: "session", agent: "build", model: { providerId: "provider", modelId: "model" }, status: "idle", retry: null, idleSince: null, generationRecovery: null, runtimeStatusKnown: true, location: { directory: "/repo/.worktrees/feature" }, time: { created: 1, updated: 1 }, } as Session]]))) - serverApi.fetchWorktrees = async () => ({ isGitRepo: true, worktrees: [ - { slug: "root", directory: "/repo" }, - { slug: "feature", directory: "/repo/.worktrees/feature" }, - ] }) as any + serverApi.fetchWorktrees = async () => { + worktreeRefreshes += 1 + if (deletes === 2 && worktreeRefreshes === 2) throw new Error("transient worktree refresh") + return { + isGitRepo: true, + worktrees: deletes < 2 + ? [{ slug: "root", directory: "/repo" }, { slug: "feature", directory: "/repo/.worktrees/feature" }] + : [{ slug: "root", directory: "/repo" }], + } as any + } serverApi.deleteWorktree = async () => { deletes += 1 if (deletes === 1) throw new Error("server rollback") } + const refreshSessions = async () => { + refreshes += 1 + if (deletes < 2) return + if (refreshes === 2) throw new Error("transient session refresh") + setSessions((previous) => { + const next = new Map(previous) + const instanceSessions = new Map(next.get(instanceId)) + const session = instanceSessions.get("session")! + instanceSessions.set("session", { ...session, location: { directory: "/repo" } }) + next.set(instanceId, instanceSessions) + return next + }) + } try { - await ensureWorktreesLoaded(instanceId) - await assert.rejects(deleteWorktree(instanceId, "feature"), /server rollback/) + await assert.rejects(deleteWorktree(instanceId, "feature", undefined, refreshSessions), /server rollback/) assert.equal(deletes, 1) - assert.equal(moves, 0) + assert.equal(refreshes, 1) assert.equal(sessions().get(instanceId)?.get("session")?.location.directory, "/repo/.worktrees/feature") + assert.deepEqual(getWorktrees(instanceId).map((worktree) => worktree.slug), ["root", "feature"]) - await deleteWorktree(instanceId, "feature") + await deleteWorktree(instanceId, "feature", undefined, refreshSessions) assert.equal(deletes, 2) - assert.equal(moves, 0) + assert.equal(refreshes, 3) + assert.equal(worktreeRefreshes, 3) assert.equal(sessions().get(instanceId)?.get("session")?.location.directory, "/repo") + assert.deepEqual(getWorktrees(instanceId).map((worktree) => worktree.slug), ["root"]) } finally { serverApi.fetchWorktrees = originalFetch serverApi.deleteWorktree = originalDelete setSessions((previous) => { const next = new Map(previous); next.delete(instanceId); return next }) - removeInstance(instanceId, { authoritative: false }) - sdkManager.destroyClientsForInstance(instanceId) } }) }) diff --git a/packages/ui/src/stores/worktrees.ts b/packages/ui/src/stores/worktrees.ts index 35f6a8292..5c2602dd6 100644 --- a/packages/ui/src/stores/worktrees.ts +++ b/packages/ui/src/stores/worktrees.ts @@ -1,10 +1,12 @@ import { createSignal } from "solid-js" import type { WorktreeDescriptor } from "../../../server/src/api-types" import { serverApi } from "../lib/api-client" -import { getSessionRoot, sessions, setSessions, withSession } from "./session-state" +import { getSessionRoot, sessions } from "./session-state" import { getLogger } from "../lib/logger" import type { WorktreeReadyEvent } from "../lib/sse-manager" -import { getRootClient } from "./opencode-client" +import { showToastNotification } from "../lib/notifications" +import { tGlobal } from "../lib/i18n" +import { normalizeSessionDirectory } from "./session-list-options" const log = getLogger("api") @@ -13,6 +15,8 @@ const [gitRepoStatusByInstance, setGitRepoStatusByInstance] = createSignal>() const worktreeReadyRefreshes = new Map>() +const familyMoveRequests = new Map>() +const defaultDirectories = new Map() type WorktreeReadyRefresh = (instanceId: string) => Promise @@ -21,6 +25,8 @@ async function queueWorktreeRequest(instanceId: string, initial: boolean): Promi const task = (previous?.catch(() => undefined) ?? Promise.resolve()).then(async () => { try { const response = await serverApi.fetchWorktrees(instanceId) + if (response.defaultDirectory) defaultDirectories.set(instanceId, response.defaultDirectory) + else defaultDirectories.delete(instanceId) setWorktreesByInstance((prev) => { const next = new Map(prev) next.set(instanceId, response.worktrees ?? []) @@ -34,7 +40,7 @@ async function queueWorktreeRequest(instanceId: string, initial: boolean): Promi }) } catch (error) { log.warn(initial ? "Failed to load worktrees" : "Failed to reload worktrees", { instanceId, error }) - if (!initial) return + if (!initial) throw error setWorktreesByInstance((prev) => { const next = new Map(prev) @@ -108,7 +114,7 @@ function getGitRepoStatus(instanceId: string): boolean | null { return gitRepoStatusByInstance().get(instanceId) ?? null } -async function createWorktree(instanceId: string, slug: string): Promise<{ slug: string; directory: string; branch?: string }> { +async function createWorktree(instanceId: string, slug: string, fromSlug = "root"): Promise<{ slug: string; directory: string; branch?: string }> { if (!instanceId) { throw new Error("Missing instanceId") } @@ -116,10 +122,16 @@ async function createWorktree(instanceId: string, slug: string): Promise<{ slug: if (!trimmed) { throw new Error("Worktree name is required") } - return serverApi.createWorktree(instanceId, { slug: trimmed }) + return serverApi.createWorktree(instanceId, { slug: trimmed, fromSlug }) } -async function deleteWorktree(instanceId: string, slug: string, options?: { force?: boolean }): Promise { +async function deleteWorktree( + instanceId: string, + slug: string, + options?: { force?: boolean }, + refreshSessions: (instanceId: string) => Promise = (id) => + import("./session-api").then(({ fetchSessions }) => fetchSessions(id, { reset: true, strictStatus: true })), +): Promise { if (!instanceId) { throw new Error("Missing instanceId") } @@ -127,44 +139,34 @@ async function deleteWorktree(instanceId: string, slug: string, options?: { forc if (!trimmed || trimmed === "root") { throw new Error("Invalid worktree") } - const worktrees = getWorktrees(instanceId) - const rootDirectory = worktrees.find((worktree) => worktree.slug === "root")?.directory - const targetDirectory = worktrees.find((worktree) => worktree.slug === trimmed)?.directory - await serverApi.deleteWorktree(instanceId, trimmed, options) - if (!rootDirectory || !targetDirectory) return - setSessions((previous) => { - const instanceSessions = previous.get(instanceId) - if (!instanceSessions) return previous - const target = normalizeDirectory(targetDirectory) - const updated = new Map(instanceSessions) - let changed = false - for (const [sessionId, session] of instanceSessions) { - if (normalizeDirectory(session.location.directory) !== target) continue - updated.set(sessionId, { ...session, location: { directory: rootDirectory } }) - changed = true + let deleteError: unknown + try { + await serverApi.deleteWorktree(instanceId, trimmed, options) + } catch (error) { + deleteError = error + } + const refreshers = [() => reloadWorktrees(instanceId), () => refreshSessions(instanceId)] + const refreshes = await Promise.allSettled(refreshers.map((refresh) => refresh())) + const failed = refreshes.flatMap((refresh, index) => refresh.status === "rejected" ? [index] : []) + if (failed.length) { + const retries = await Promise.allSettled(failed.map((index) => refreshers[index]!())) + for (const retry of retries) { + if (retry.status === "rejected") { + log.warn("Failed to refresh after worktree deletion", { instanceId, slug: trimmed, error: retry.reason }) + } } - return changed ? new Map(previous).set(instanceId, updated) : previous - }) + } + if (deleteError) { + throw deleteError + } } function getWorktrees(instanceId: string): WorktreeDescriptor[] { return worktreesByInstance().get(instanceId) ?? [] } -function isWorktreeSlugAvailable(instanceId: string, slug: string): boolean { - const normalized = (slug ?? "").trim() || "root" - if (normalized === "root") return true - - const list = getWorktrees(instanceId) - // If worktrees aren't loaded yet, don't force root incorrectly. - if (list.length === 0) return true - return list.some((wt) => wt.slug === normalized) -} - function normalizeWorktreeSlug(instanceId: string, slug: string): string { - const normalized = (slug ?? "").trim() || "root" - if (normalized === "root") return "root" - return isWorktreeSlugAvailable(instanceId, normalized) ? normalized : "root" + return (slug ?? "").trim() || "root" } function getDefaultWorktreeSlug(instanceId: string): string { @@ -176,46 +178,81 @@ function getParentSessionId(instanceId: string, sessionId: string): string { } function normalizeDirectory(directory: string): string { - const normalized = directory.replace(/\\/g, "/").replace(/\/+$/, "") - return /^[A-Za-z]:\//.test(normalized) || normalized.startsWith("//") ? normalized.toLowerCase() : normalized + return normalizeSessionDirectory(directory) } 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" } function getWorktreeSlugForSession(instanceId: string, sessionId: string): string { - const parentId = getParentSessionId(instanceId, sessionId) - return getWorktreeSlugForParentSession(instanceId, parentId) + return getWorktreeSlugForParentSession(instanceId, sessionId) +} + +export function getDefaultWorktreeDirectory(instanceId: string): string | undefined { + return defaultDirectories.get(instanceId) } async function setWorktreeSlugForParentSession( instanceId: string, parentSessionId: string, slug: string, - _options: { currentSlug?: string } = {}, + options: { + currentSlug?: string + moveFamily?: (instanceId: string, rootSessionId: string, worktreeSlug: string) => Promise + refreshSessions?: (instanceId: string) => Promise + } = {}, ): Promise { await ensureWorktreesLoaded(instanceId) + const rootSessionId = getParentSessionId(instanceId, parentSessionId) const normalizedSlug = normalizeWorktreeSlug(instanceId, slug) const worktree = getWorktrees(instanceId).find((candidate) => candidate.slug === normalizedSlug) if (!worktree) throw new Error(`Worktree not found: ${normalizedSlug}`) // Controlled selectors can report their current option while metadata loads. - // Do not turn that reconciliation into a native move and a session.moved echo. - const currentDirectory = sessions().get(instanceId)?.get(parentSessionId)?.location.directory - if (currentDirectory && normalizeDirectory(currentDirectory) === normalizeDirectory(worktree.directory)) return - - await getRootClient(instanceId).session.move({ - sessionID: parentSessionId, - directory: worktree.directory, + // Do not turn that reconciliation into a family move and native move echoes. + const currentDirectory = sessions().get(instanceId)?.get(rootSessionId)?.location.directory + const targetDirectory = worktree.serviceDirectory ?? worktree.directory + if (currentDirectory && normalizeDirectory(currentDirectory) === normalizeDirectory(targetDirectory)) return + + const key = `${instanceId}:${rootSessionId}` + const previous = familyMoveRequests.get(key) + const moveFamily = options.moveFamily ?? ((id: string, sessionId: string, worktreeSlug: string) => + serverApi.moveSessionFamily(id, sessionId, { worktreeSlug })) + const refreshSessions = options.refreshSessions ?? ((id: string) => + import("./session-api").then(({ fetchSessions }) => fetchSessions(id, { reset: true, strictStatus: true }))) + const task = (previous?.catch(() => undefined) ?? Promise.resolve()).then(async () => { + let moveError: unknown + try { + await moveFamily(instanceId, rootSessionId, normalizedSlug) + } catch (error) { + moveError = error + } + try { + await refreshSessions(instanceId) + } catch { + await refreshSessions(instanceId).catch((error) => { + log.warn("Failed to refresh sessions after family move", { instanceId, rootSessionId, error }) + }) + } + if (moveError) { + log.warn("Failed to move session family", { instanceId, rootSessionId, error: moveError }) + showToastNotification({ + message: tGlobal("sessionList.worktreeMove.error"), + variant: "error", + }) + throw moveError + } }) - withSession(instanceId, parentSessionId, (session) => { - session.location = { directory: worktree.directory } + + familyMoveRequests.set(key, task) + await task.finally(() => { + if (familyMoveRequests.get(key) === task) familyMoveRequests.delete(key) }) } diff --git a/packages/ui/src/types/session.test.ts b/packages/ui/src/types/session.test.ts index d78c93f5e..7c9beecfe 100644 --- a/packages/ui/src/types/session.test.ts +++ b/packages/ui/src/types/session.test.ts @@ -9,18 +9,21 @@ const hiddenPrimary: Agent = { id: "build", name: "Build", description: "", mode const hiddenSubagent: Agent = { id: "debug", name: "Debug", description: "", mode: "subagent", hidden: true } describe("agent selectability", () => { - it("keeps native session location authoritative", () => { + it("keeps native session location and metadata authoritative", () => { + const location = { directory: "D:/repo/worktree", workspaceID: "workspace" } const session = createClientSession({ id: "session", projectID: "project", title: "Session", - location: { directory: "D:/repo/worktree" }, + location, + metadata: { persisted: true }, cost: 0, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, time: { created: 1, updated: 1 }, }, "instance") - assert.deepEqual(session.location, { directory: "D:/repo/worktree" }) + assert.deepEqual(session.location, { directory: "D:/repo/worktree", workspaceID: "workspace" }) + assert.deepEqual(session.metadata, { persisted: true }) }) it("matches primary-session selector rules", () => { diff --git a/packages/ui/tests/browser/fixtures/worktrees.tsx b/packages/ui/tests/browser/fixtures/worktrees.tsx new file mode 100644 index 000000000..d9e27fd33 --- /dev/null +++ b/packages/ui/tests/browser/fixtures/worktrees.tsx @@ -0,0 +1,46 @@ +import { render } from "solid-js/web" +import WorktreeSelector from "../../../src/components/worktree-selector" +import { ConfigProvider, updatePreferences } from "../../../src/stores/preferences" +import { I18nProvider } from "../../../src/lib/i18n" +import { serverApi } from "../../../src/lib/api-client" +import { sdkManager } from "../../../src/lib/sdk-manager" +import { addInstance } from "../../../src/stores/instances" +import { setSessions } from "../../../src/stores/session-state" +import { ensureWorktreesLoaded } from "../../../src/stores/worktrees" +import "../../../src/index.css" + +const id = "worktree-fixture" +const calls: unknown[] = [] +const entries = [ + { slug: "root", label: "main", directory: "/repo", kind: "root" as const }, + { slug: "stable-feature-id", label: "feature", directory: "/repo/.codenomad/worktrees/feature", kind: "worktree" as const }, +] +const session: any = { id: "session", instanceId: id, parentId: null, title: "Fixture", location: { directory: "/repo" }, + projectID: "fixture", cost: 0, tokens: {}, time: { created: 1, updated: 1 }, agent: "build", status: "idle", model: { providerId: "fixture", modelId: "fixture" } } +const client: any = { session: { list: async () => ({ data: [session], cursor: {} }), active: async () => ({}) } } +;(sdkManager as any).clients.set(`${id}:/workspaces/${id}/instance`, client) +const uiConfig = { settings: { locale: "en" } } +serverApi.fetchConfigOwner = async () => uiConfig as any +serverApi.patchConfigOwner = async (_owner, patch) => Object.assign(uiConfig, patch) as any +serverApi.fetchStateOwner = async () => ({} as any) +serverApi.fetchWorktrees = async () => ({ isGitRepo: true, defaultDirectory: "/repo/.codenomad/worktrees", worktrees: entries }) +serverApi.createWorktree = async (_id, input) => { + calls.push({ create: input }) + const entry = { slug: "created-stable-id", label: input.slug, directory: `/repo/.codenomad/worktrees/${input.slug}`, kind: "worktree" as const } + entries.push(entry) + return entry +} +serverApi.moveSessionFamily = async (_id, _session, input) => { + calls.push({ move: input.worktreeSlug }) + session.location = { directory: entries.find(entry => entry.slug === input.worktreeSlug)!.directory } + return { rootSessionId: session.id, sessionIds: [session.id], worktreeSlug: input.worktreeSlug } +} +serverApi.deleteWorktree = async (_id, slug) => { calls.push({ delete: slug }); entries.splice(entries.findIndex(entry => entry.slug === slug), 1) } +addInstance({ id, folder: "/repo", port: 0, pid: 0, proxyPath: `/workspaces/${id}/instance`, status: "ready", client }) +setSessions(previous => new Map(previous).set(id, new Map([[session.id, session]]))) +await ensureWorktreesLoaded(id) +render(() =>
+ +
, document.getElementById("root")!) +await updatePreferences({ locale: "en" }) +;(window as any).fixture = { calls, location: () => session.location.directory } diff --git a/packages/ui/tests/browser/worktrees.test.ts b/packages/ui/tests/browser/worktrees.test.ts new file mode 100644 index 000000000..186807c9a --- /dev/null +++ b/packages/ui/tests/browser/worktrees.test.ts @@ -0,0 +1,81 @@ +import assert from "node:assert/strict" +import { after, before, test } from "node:test" +import { fileURLToPath } from "node:url" +import { chromium, type Browser, type Page } from "playwright" +import { createServer, type ViteDevServer } from "vite" +import solid from "vite-plugin-solid" + +let server: ViteDevServer, browser: Browser, url: string +before(async () => { + server = await createServer({ configFile: false, root: fileURLToPath(new URL("../..", import.meta.url)), logLevel: "error", + plugins: [solid(), { name: "worktree-fixture", configureServer(s) { + s.middlewares.use("/worktree-fixture", async (_req, res) => { + res.setHeader("Content-Type", "text/html") + res.end(await s.transformIndexHtml("/worktree-fixture", '
')) + }) + } }], resolve: { dedupe: ["solid-js"] }, optimizeDeps: { exclude: ["lucide-solid"] }, + server: { host: "127.0.0.1", port: 0, hmr: false, watch: null }, + }) + await server.listen() + url = `http://127.0.0.1:${(server.httpServer!.address() as { port: number }).port}/worktree-fixture` + browser = await chromium.launch({ executablePath: process.env.CODENOMAD_BROWSER_PATH || undefined }) +}) +after(async () => { await browser?.close(); await server?.close() }) + +async function prepare(page: Page) { + page.on("pageerror", error => console.error(error)) + await page.addInitScript(` + window.nativeCalls = []; + window.__TAURI__ = { core: { invoke: async (...args) => { window.nativeCalls.push(args) } } }; + Object.defineProperty(navigator, "clipboard", { value: { writeText: async text => { window.copied = text } } }); + `) + await page.route("**/api/**", route => route.fulfill({ contentType: "application/json", body: "{}" })) + await page.goto(url) + await page.waitForFunction(() => Boolean((window as any).fixture)) + page.setDefaultTimeout(5000) +} + +test("worktree actions use click/keyboard/touch without selecting or moving the session", async () => { + const context = await browser.newContext({ hasTouch: true }) + const page = await context.newPage() + try { + await prepare(page) + const trigger = page.locator(".selector-trigger") + await trigger.click() + const feature = page.getByRole("option", { name: /feature/ }) + await feature.getByRole("button", { name: "Copy path" }).click() + assert.equal(await page.evaluate(() => (window as any).copied), "/repo/.codenomad/worktrees/feature") + assert.deepEqual(await page.evaluate(() => (window as any).fixture.calls), []) + await trigger.click() + const open = page.getByRole("option", { name: /feature/ }).getByRole("button", { name: "Open in file manager" }) + await open.focus() + await page.keyboard.press("Enter") + await page.waitForFunction(() => (window as any).nativeCalls.length > 0) + assert.deepEqual(await page.evaluate(() => (window as any).fixture.calls), []) + await trigger.tap() + await page.getByRole("option", { name: /feature/ }).getByRole("button", { name: "Delete worktree", exact: true }).tap() + await page.getByRole("dialog").waitFor() + assert.deepEqual(await page.evaluate(() => (window as any).fixture.calls), []) + await page.getByRole("button", { name: "Cancel", exact: true }).click() + assert.equal(await page.evaluate(() => (window as any).fixture.location()), "/repo") + } finally { await context.close() } +}) + +test("creation uses the selected source and the returned stable worktree ID", async () => { + const page = await browser.newPage() + try { + await prepare(page) + const trigger = page.locator(".selector-trigger") + await trigger.click() + await page.getByRole("option", { name: /feature/ }).locator(".selector-option-label").click() + await page.waitForFunction(() => (window as any).fixture.calls.length === 1) + await trigger.click() + await page.getByRole("option", { name: /Create worktree/ }).click() + await page.getByRole("textbox").fill("new-feature") + await page.getByRole("button", { name: "Create and use worktree", exact: true }).click() + await page.waitForFunction(() => (window as any).fixture.calls.length === 3) + assert.deepEqual(await page.evaluate(() => (window as any).fixture.calls), [ + { move: "stable-feature-id" }, { create: { slug: "new-feature", fromSlug: "stable-feature-id" } }, { move: "created-stable-id" }, + ]) + } finally { await page.close() } +}) diff --git a/scripts/test-native-worktree-management.mjs b/scripts/test-native-worktree-management.mjs new file mode 100644 index 000000000..7379cbe81 --- /dev/null +++ b/scripts/test-native-worktree-management.mjs @@ -0,0 +1,68 @@ +import assert from "node:assert/strict" +import { execFileSync } from "node:child_process" +import { mkdir, writeFile, rm, realpath } from "node:fs/promises" +import path from "node:path" +import { tsImport } from "tsx/esm/api" + +// Invoked by the isolated daemon fixture only. No service discovery/user state. +export async function testNativeWorktreeManagement({ client, root }) { + const { listNativeWorktrees, createNativeWorktree, removeNativeWorktree } = await tsImport("../packages/server/src/workspaces/native-worktrees.ts", import.meta.url) + const repo = path.join(root, "worktree-policy") + const external = path.join(root, "agent-created") + const clone = path.join(root, "independent-clone") + await mkdir(repo) + const git = (directory, ...args) => execFileSync("git", ["-C", directory, ...args], { encoding: "utf8" }).trim() + git(repo, "init", "-b", "main") + git(repo, "config", "user.name", "CodeNomad fixture") + git(repo, "config", "user.email", "fixture@example.invalid") + await mkdir(path.join(repo, "packages", "app"), { recursive: true }) + await writeFile(path.join(repo, "packages", "app", "base.txt"), "base") + git(repo, "add", ".") + git(repo, "commit", "-m", "base") + git(repo, "worktree", "add", "-b", "agent", external) + await writeFile(path.join(external, "source.txt"), "selected checkout") + git(external, "add", ".") + git(external, "commit", "-m", "selected checkout") + const sourceHead = git(external, "rev-parse", "HEAD") + await writeFile(path.join(external, "uncommitted.txt"), "stay here") + git(root, "clone", "--no-hardlinks", repo, clone) + const location = await client.location.get({ location: { directory: repo } }) + await client.location.get({ location: { directory: external } }) + await client.location.get({ location: { directory: clone } }) + const context = { client, location, workspacePath: repo, toHost: async directory => directory } + let catalogue = await listNativeWorktrees(context) + assert.equal(catalogue.worktrees.length, 2, "same-project independent clone must be excluded") + const source = catalogue.worktrees.find(entry => entry.branch === "agent") + assert.ok(source) + const created = await createNativeWorktree(context, "feature/ui", source.slug) + const expectedParent = path.join(await realpath(repo), ".codenomad", "worktrees") + assert.equal(path.dirname(created.registeredDirectory), expectedParent) + assert.equal(git(created.registeredDirectory, "symbolic-ref", "--short", "HEAD"), "feature/ui") + assert.equal(git(created.registeredDirectory, "rev-parse", "HEAD"), sourceHead) + assert.equal(git(created.registeredDirectory, "status", "--porcelain"), "") + assert.match(git(external, "status", "--porcelain"), /uncommitted/) + assert.ok(git(repo, "check-ignore", created.registeredDirectory)) + git(created.registeredDirectory, "branch", "-m", "feature/renamed") + catalogue = await listNativeWorktrees(context) + assert.equal(catalogue.worktrees.find(entry => entry.branch === "feature/renamed").slug, created.slug) + await assert.rejects(createNativeWorktree(context, "agent"), "Git must refuse a branch already checked out") + assert.equal((await listNativeWorktrees(context)).worktrees.length, 3, "failed branch attachment must clean up its new checkout") + const nested = path.join(repo, "packages", "app") + const nestedCatalogue = await listNativeWorktrees({ ...context, workspacePath: nested, location: { directory: nested } }) + assert.equal(nestedCatalogue.worktrees.find(entry => entry.slug === created.slug).directory, path.join(created.registeredDirectory, "packages", "app")) + await writeFile(path.join(created.registeredDirectory, "dirty.txt"), "keep") + await assert.rejects(removeNativeWorktree(context, created.serviceRoot, false)) + await rm(path.join(created.registeredDirectory, "dirty.txt")) + await removeNativeWorktree(context, created.serviceRoot, false) + assert.equal(git(repo, "rev-parse", "refs/heads/feature/renamed"), sourceHead, "removal must preserve the branch") + assert.equal((await listNativeWorktrees(context)).worktrees.length, 2) + const detached = [path.join(root, "detached-one"), path.join(root, "detached-two")] + for (const directory of detached) git(repo, "worktree", "add", "--detach", directory, "HEAD") + const detachedEntries = (await listNativeWorktrees(context)).worktrees.filter(entry => !entry.branch) + assert.equal(detachedEntries.length, 2) + assert.notEqual(detachedEntries[0].slug, detachedEntries[1].slug) + const fromLinked = await listNativeWorktrees({ ...context, workspacePath: external, location: { directory: external } }) + assert.equal(fromLinked.defaultDirectory, catalogue.defaultDirectory, "opening a linked checkout must not nest the default parent") + assert.equal(fromLinked.worktrees.find(entry => entry.branch === "main").removable, false) + console.log("PASS: native worktree discovery/create/remove, clone scope, selected HEAD, default parent, named branches, stable identity, nested paths and dirty/checked-out guards") +} diff --git a/scripts/test-opencode-location-native.mjs b/scripts/test-opencode-location-native.mjs index b62ecb048..0ad3a520c 100644 --- a/scripts/test-opencode-location-native.mjs +++ b/scripts/test-opencode-location-native.mjs @@ -14,6 +14,8 @@ import { tsImport } from "tsx/esm/api" import Fastify from "fastify" import replyFrom from "@fastify/reply-from" import pino from "pino" +import { testNativeWorktreeFamily } from "./test-worktree-families-native.mjs" +import { testNativeWorktreeManagement } from "./test-native-worktree-management.mjs" export async function testNativeLocationIdentity({ client, connection, root }) { const { contractProfile, runtimeIdentity } = await tsImport("../packages/server/src/opencode/compatibility/runtime.ts", import.meta.url) @@ -33,6 +35,8 @@ export async function testNativeLocationIdentity({ client, connection, root }) { const rootLocation = await client.location.get({ location: { directory } }) const worktreeLocation = await client.location.get({ location: { directory: worktree } }) assert.equal(rootLocation.project.id, worktreeLocation.project.id) + await testNativeWorktreeFamily({ client, profile, rootLocation, worktreeLocation }) + await testNativeWorktreeManagement({ client, root }) if (profile !== "legacy") { await assert.rejects(client.location.get({ location: { directory } }, locationRequestOptions({ directory, workspaceID: "wrk_fixture_one" }))) console.log("PASS: native modern worktree location and obsolete-selector rejection") diff --git a/scripts/test-worktree-families-native.mjs b/scripts/test-worktree-families-native.mjs new file mode 100644 index 000000000..4b603cea5 --- /dev/null +++ b/scripts/test-worktree-families-native.mjs @@ -0,0 +1,45 @@ +import assert from "node:assert/strict" +import { realpath } from "node:fs/promises" +import { tsImport } from "tsx/esm/api" + +// Called only by the isolated location fixture, using its synthetic repository, +// native client and private daemon. Never discovers the user's service. +export async function testNativeWorktreeFamily({ client, profile, rootLocation, worktreeLocation }) { + const { moveProjectSessionFamily, removeProjectWorktree } = await tsImport("../packages/server/src/workspaces/project-session-families.ts", import.meta.url) + const { locationRequestOptions, readLocationRef } = await tsImport("../packages/server/src/opencode/compatibility/location.ts", import.meta.url) + const source = { directory: rootLocation.directory, ...(profile === "legacy" ? { workspaceID: "wrk_family_fixture" } : {}) } + const root = await client.session.create({ location: { directory: source.directory }, title: "Synthetic family root" }, locationRequestOptions(source)) + const seed = await client.session.create({ location: { directory: source.directory }, title: "Synthetic family child" }, locationRequestOptions(source)) + const exported = await client.session.export({ sessionID: seed.id }) + await client.session.remove({ sessionID: seed.id }) + const child = await client.session.import({ ...exported, info: { ...exported.info, parentID: root.id }, location: { directory: source.directory } }, locationRequestOptions(source)) + assert.equal(child.parentID, root.id) + const resolveExactDirectory = directory => realpath(directory).catch(() => undefined) + try { + await client.worktree.refresh({ projectID: rootLocation.project.id }, locationRequestOptions(readLocationRef(rootLocation), { includeDirectory: true })) + const moved = await moveProjectSessionFamily({ + client, projectLocation: source, sessionId: child.id, + targetDirectory: worktreeLocation.directory, resolveExactDirectory, + }) + assert.deepEqual(new Set(moved.sessionIds), new Set([root.id, child.id])) + for (const session of [root, child]) { + assert.deepEqual(readLocationRef((await client.session.get({ sessionID: session.id })).location), readLocationRef(worktreeLocation)) + } + await assert.rejects(removeProjectWorktree({ + client, projectLocation: readLocationRef(worktreeLocation), targetDirectory: worktreeLocation.directory, + rootDirectory: rootLocation.directory, resolveExactDirectory, isTargetRegistered: async () => true, + remove: async () => { + for (const session of [root, child]) { + assert.deepEqual(readLocationRef((await client.session.get({ sessionID: session.id })).location), readLocationRef(rootLocation)) + } + throw new Error("Synthetic family Git removal refusal") + }, + }), /Synthetic family Git removal refusal/) + for (const session of [root, child]) { + assert.deepEqual(readLocationRef((await client.session.get({ sessionID: session.id })).location), readLocationRef(worktreeLocation)) + } + console.log(`PASS: native ${profile} complete family move, worktree refresh and verified evacuation rollback`) + } finally { + await Promise.allSettled([child, root].map(session => client.session.remove({ sessionID: session.id }))) + } +}