From 7c621b4a4103866bc159217104e4ebac2243be13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sun, 9 Aug 2026 13:53:11 +0200 Subject: [PATCH 01/20] refactor(ui): preserve native session locations Keep OpenCode project, workspace, and directory identity in CodeNomad session state across list hydration, creation, forks, and session update events. Resolve worktree labels from the native session directory when OpenCode reports a workspace, while retaining the legacy metadata mapping as a migration fallback. Normalize Windows and POSIX path matching and cover native directory resolution with focused tests. Validated with the UI typecheck, focused OpenCode workspace matching tests, and git diff checks. --- .../src/stores/opencode-workspace-matching.ts | 18 +++++++++++++++++- .../ui/src/stores/opencode-workspaces.test.ts | 17 ++++++++++++++++- packages/ui/src/stores/session-api.ts | 12 ++++++++++++ packages/ui/src/stores/session-events.ts | 7 +++++++ packages/ui/src/stores/worktrees.ts | 16 +++++++++++----- packages/ui/src/types/session.ts | 6 ++++++ 6 files changed, 69 insertions(+), 7 deletions(-) diff --git a/packages/ui/src/stores/opencode-workspace-matching.ts b/packages/ui/src/stores/opencode-workspace-matching.ts index cbb79269c..0cbfc375f 100644 --- a/packages/ui/src/stores/opencode-workspace-matching.ts +++ b/packages/ui/src/stores/opencode-workspace-matching.ts @@ -23,6 +23,22 @@ function normalizeWindowsWorkspaceDirectory(directory: string): string { return normalizeWorkspaceDirectory(directory).toLowerCase() } +function findWorktreeSlugForDirectory( + worktrees: Pick[], + target: string | null | undefined, +): string | null { + const directory = normalizeWorkspaceDirectory(target) + if (!directory) return null + const windowsDirectory = isWindowsWorkspaceDirectory(directory) ? normalizeWindowsWorkspaceDirectory(directory) : null + return worktrees.find((worktree) => { + const candidate = normalizeWorkspaceDirectory(worktree.directory) + if (candidate === directory) return true + return windowsDirectory !== null + && isWindowsWorkspaceDirectory(candidate) + && normalizeWindowsWorkspaceDirectory(candidate) === windowsDirectory + })?.slug ?? null +} + function mapOpenCodeWorkspacesToWorktreeSlugs( worktrees: Pick[], workspaces: OpenCodeWorkspaceLike[], @@ -48,4 +64,4 @@ function mapOpenCodeWorkspacesToWorktreeSlugs( return next } -export { mapOpenCodeWorkspacesToWorktreeSlugs } +export { findWorktreeSlugForDirectory, mapOpenCodeWorkspacesToWorktreeSlugs } diff --git a/packages/ui/src/stores/opencode-workspaces.test.ts b/packages/ui/src/stores/opencode-workspaces.test.ts index 789b18702..42d5b2c62 100644 --- a/packages/ui/src/stores/opencode-workspaces.test.ts +++ b/packages/ui/src/stores/opencode-workspaces.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict" import { describe, it } from "node:test" -import { mapOpenCodeWorkspacesToWorktreeSlugs } from "./opencode-workspace-matching.ts" +import { findWorktreeSlugForDirectory, mapOpenCodeWorkspacesToWorktreeSlugs } from "./opencode-workspace-matching.ts" describe("mapOpenCodeWorkspacesToWorktreeSlugs", () => { it("matches POSIX worktree directories case-sensitively", () => { @@ -58,3 +58,18 @@ describe("mapOpenCodeWorkspacesToWorktreeSlugs", () => { assert.equal(result.size, 0) }) }) + +describe("findWorktreeSlugForDirectory", () => { + const worktrees = [ + { slug: "root", directory: String.raw`C:\Users\Dev\Repo` }, + { slug: "feature", directory: String.raw`C:\Users\Dev\Repo\.codenomad\worktrees\feature` }, + ] + + it("matches a native session directory to its worktree", () => { + assert.equal(findWorktreeSlugForDirectory(worktrees, "c:/users/dev/repo/.codenomad/worktrees/feature/"), "feature") + }) + + it("returns null for an unknown native directory", () => { + assert.equal(findWorktreeSlugForDirectory(worktrees, "C:/other"), null) + }) +}) diff --git a/packages/ui/src/stores/session-api.ts b/packages/ui/src/stores/session-api.ts index 69b87a12f..a80ffa8da 100644 --- a/packages/ui/src/stores/session-api.ts +++ b/packages/ui/src/stores/session-api.ts @@ -202,6 +202,9 @@ async function recordSessionWorkspaceHints( interface SessionForkResponse { id: string + projectID?: string + workspaceID?: string + directory?: string title?: string parentID?: string | null agent?: string @@ -707,6 +710,9 @@ function toClientSessionV2(instanceId: string, apiSession: SDKSession, existingS return { id: apiSession.id, instanceId, + projectId: apiSession.projectID, + workspaceId: apiSession.workspaceID, + directory: apiSession.directory, title: apiSession.title || existingSession?.title || "Untitled", parentId: apiSession.parentID || null, agent: apiSession.agent ?? existingSession?.agent ?? "", @@ -772,6 +778,9 @@ async function createSession(instanceId: string, agent?: string): Promise() @@ -419,6 +420,9 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo const newSession = { id: info.id, instanceId, + projectId: info.projectID, + workspaceId, + directory: info.directory, title: info.title || tGlobal("sessionList.session.untitled"), parentId: info.parentID || null, agent: "", @@ -472,6 +476,9 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo } const updatedSession = { ...existingSession, + projectId: info.projectID ?? existingSession.projectId, + workspaceId, + directory: info.directory ?? existingSession.directory, title: info.title || existingSession.title, parentId: info.parentID ?? existingSession.parentId, status: existingSession.status ?? "idle", diff --git a/packages/ui/src/stores/worktrees.ts b/packages/ui/src/stores/worktrees.ts index be7c3860c..f67ebf18e 100644 --- a/packages/ui/src/stores/worktrees.ts +++ b/packages/ui/src/stores/worktrees.ts @@ -5,6 +5,7 @@ import { getSessionRoot, sessions } from "./session-state" import { getLogger } from "../lib/logger" import { getCodeNomadSessionMetadata, setSessionWorktreeSlug } from "./session-metadata" import type { WorktreeReadyEvent } from "../lib/sse-manager" +import { findWorktreeSlugForDirectory } from "./opencode-workspace-matching" const log = getLogger("api") @@ -311,13 +312,21 @@ function getParentSessionId(instanceId: string, sessionId: string): string { } function getWorktreeSlugForParentSession(instanceId: string, parentSessionId: string): string { + const session = sessions().get(instanceId)?.get(parentSessionId) + const nativeSlug = session?.workspaceId + ? findWorktreeSlugForDirectory(getWorktrees(instanceId), session.directory) + : null + if (nativeSlug) return nativeSlug + const metadataSlug = getCodeNomadSessionMetadata(instanceId, parentSessionId).worktreeSlug if (metadataSlug) { return normalizeWorktreeSlug(instanceId, metadataSlug) } const map = getWorktreeMap(instanceId) - const candidate = map.parentSessionWorktreeSlug[parentSessionId] ?? "root" + const candidate = map.parentSessionWorktreeSlug[parentSessionId] + ?? findWorktreeSlugForDirectory(getWorktrees(instanceId), session?.directory) + ?? "root" return normalizeWorktreeSlug(instanceId, candidate) } @@ -410,10 +419,7 @@ async function pruneStaleLegacyWorktreeMapEntries(instanceId: string): Promise wt.directory === directory) - return match?.slug ?? null + return findWorktreeSlugForDirectory(getWorktrees(instanceId), directory) } export { diff --git a/packages/ui/src/types/session.ts b/packages/ui/src/types/session.ts index 8009900d3..f72017435 100644 --- a/packages/ui/src/types/session.ts +++ b/packages/ui/src/types/session.ts @@ -65,6 +65,9 @@ export function mapSdkSessionRetry(status: SDKSessionStatus | null | undefined): export interface Session extends Omit { instanceId: string // Client-specific field + projectId?: string + workspaceId?: string + directory?: string parentId: string | null // Client-specific field (override parentID) agent: string // Client-specific field model: { @@ -94,6 +97,9 @@ export function createClientSession( return { ...sdkSession, instanceId, + projectId: sdkSession.projectID, + workspaceId: sdkSession.workspaceID, + directory: sdkSession.directory, parentId: sdkSession.parentID || null, agent, model, From 4a50dea4312cde74cf3646c839acb250b4971316 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sun, 9 Aug 2026 14:09:20 +0200 Subject: [PATCH 02/20] feat(worktrees): move sessions through native workspaces Create new sessions in the active OpenCode workspace and warp existing session families when users select another Git worktree. Route prompts, commands, compaction, reverts, files, Git status, interruption replies, and destructive session operations through the resolved native workspace so unresolved locations fail closed instead of falling back to the project root. Keep the historical CodeNomad worktree slug only as a lazy migration hint. Successful warps clear that metadata, update runtime location state, and remove legacy map entries; partial family moves roll back before any local state changes. Worktree deletion now refuses to proceed unless affected sessions move safely to root. Allow metadata markers to be removed in the server persistence layer while preserving unrelated metadata and resolving the session's current workspace before writes. Add focused tests for metadata removal, family migration, and rollback, and validate UI/server typechecks plus related store tests. --- .../opencode-yolo-metadata.test.ts | 15 ++- .../src/permissions/opencode-yolo-metadata.ts | 21 ++- .../server/src/server/routes/worktrees.ts | 2 +- .../shell/right-panel/tabs/files-runtime.tsx | 5 +- .../shell/right-panel/useGitChanges.ts | 6 +- .../src/components/session/session-view.tsx | 2 + .../ui/src/components/worktree-selector.tsx | 10 +- packages/ui/src/lib/api-client.ts | 2 +- packages/ui/src/lib/hooks/use-commands.ts | 3 + .../ui/src/lib/i18n/messages/de/instance.ts | 4 + .../ui/src/lib/i18n/messages/en/instance.ts | 4 + .../ui/src/lib/i18n/messages/es/instance.ts | 4 + .../ui/src/lib/i18n/messages/fr/instance.ts | 4 + .../ui/src/lib/i18n/messages/he/instance.ts | 4 + .../ui/src/lib/i18n/messages/ja/instance.ts | 4 + .../ui/src/lib/i18n/messages/ne/instance.ts | 4 + .../ui/src/lib/i18n/messages/ru/instance.ts | 4 + .../src/lib/i18n/messages/zh-Hans/instance.ts | 4 + packages/ui/src/stores/instances.ts | 25 +++- packages/ui/src/stores/session-actions.ts | 21 ++- packages/ui/src/stores/session-api.ts | 30 +++-- packages/ui/src/stores/session-metadata.ts | 2 +- packages/ui/src/stores/session-state.ts | 6 +- .../stores/session-worktree-binding.test.ts | 127 ++++++++++++++++++ .../ui/src/stores/session-worktree-binding.ts | 106 +++++++++++++++ packages/ui/src/stores/worktrees.ts | 8 +- 26 files changed, 365 insertions(+), 62 deletions(-) create mode 100644 packages/ui/src/stores/session-worktree-binding.test.ts create mode 100644 packages/ui/src/stores/session-worktree-binding.ts diff --git a/packages/server/src/permissions/opencode-yolo-metadata.test.ts b/packages/server/src/permissions/opencode-yolo-metadata.test.ts index d31d56523..c844a5861 100644 --- a/packages/server/src/permissions/opencode-yolo-metadata.test.ts +++ b/packages/server/src/permissions/opencode-yolo-metadata.test.ts @@ -1,6 +1,11 @@ import assert from "node:assert/strict" import { describe, it } from "node:test" -import { createOpencodeYoloPersistence, hasPersistedYolo, mergePersistedYolo } from "./opencode-yolo-metadata" +import { + createOpencodeYoloPersistence, + hasPersistedYolo, + mergePersistedWorktreeSlug, + mergePersistedYolo, +} from "./opencode-yolo-metadata" describe("OpenCode Yolo metadata", () => { it("preserves unrelated metadata while replacing Yolo state", () => { @@ -20,6 +25,13 @@ describe("OpenCode Yolo metadata", () => { assert.equal(hasPersistedYolo("root", mergePersistedYolo({}, "root", false)), false) }) + it("clears only the legacy worktree marker", () => { + assert.deepEqual( + mergePersistedWorktreeSlug({ thirdParty: true, codenomad: { version: 1, worktreeSlug: "feature", keep: true } }, null), + { thirdParty: true, codenomad: { version: 1, keep: true } }, + ) + }) + it("uses the session workspace for metadata updates", async () => { const calls: Array> = [] const client = { @@ -41,6 +53,7 @@ describe("OpenCode Yolo metadata", () => { let metadata: Record = { thirdParty: true } const client = { session: { + async list() { return { data: [{ id: "root" }] } }, async get() { return { data: { metadata } } }, async update(parameters: Record) { metadata = parameters.metadata as Record diff --git a/packages/server/src/permissions/opencode-yolo-metadata.ts b/packages/server/src/permissions/opencode-yolo-metadata.ts index bca929015..b6f8e5b28 100644 --- a/packages/server/src/permissions/opencode-yolo-metadata.ts +++ b/packages/server/src/permissions/opencode-yolo-metadata.ts @@ -10,7 +10,7 @@ type Metadata = Record export interface OpencodeYoloPersistence extends AutoAcceptPersistence { hasProjectSession(instanceId: string, sessionId: string): Promise - setWorktreeSlug(instanceId: string, sessionId: string, worktreeSlug: string): Promise + setWorktreeSlug(instanceId: string, sessionId: string, worktreeSlug: string | null): Promise } function record(value: unknown): Metadata { @@ -38,12 +38,17 @@ export function mergePersistedYolo(metadata: unknown, rootSessionId: string, ena } } -export function mergePersistedWorktreeSlug(metadata: unknown, worktreeSlug: string): Metadata { +export function mergePersistedWorktreeSlug(metadata: unknown, worktreeSlug: string | null): Metadata { const current = record(metadata) const codenomad = record(current.codenomad) + if (worktreeSlug === null) delete codenomad.worktreeSlug return { ...current, - codenomad: { ...codenomad, version: CODENOMAD_METADATA_VERSION, worktreeSlug }, + codenomad: { + ...codenomad, + version: CODENOMAD_METADATA_VERSION, + ...(worktreeSlug === null ? {} : { worktreeSlug }), + }, } } @@ -104,8 +109,14 @@ export function createOpencodeYoloPersistence( return (data ?? []).some((session) => session.id === sessionId) }, setWorktreeSlug(instanceId, sessionId, worktreeSlug): Promise { - return updateMetadata(instanceId, sessionId, undefined, - (metadata) => mergePersistedWorktreeSlug(metadata, worktreeSlug)) + return clientFor(instanceId).session.list( + { scope: "project", limit: SESSION_LIST_LIMIT }, + { throwOnError: true }, + ).then(({ data }) => { + const workspaceId = (data ?? []).find((session) => session.id === sessionId)?.workspaceID + return updateMetadata(instanceId, sessionId, workspaceId, + (metadata) => mergePersistedWorktreeSlug(metadata, worktreeSlug)) + }) }, } } diff --git a/packages/server/src/server/routes/worktrees.ts b/packages/server/src/server/routes/worktrees.ts index d48d9ddb1..b5f3edd77 100644 --- a/packages/server/src/server/routes/worktrees.ts +++ b/packages/server/src/server/routes/worktrees.ts @@ -28,7 +28,7 @@ const WorktreeCreateSchema = z.object({ branch: z.string().trim().min(1).optional(), }) -const WorktreeSessionSchema = z.object({ worktreeSlug: z.string().trim().refine(isValidWorktreeSlug) }) +const WorktreeSessionSchema = z.object({ worktreeSlug: z.string().trim().refine(isValidWorktreeSlug).nullable() }) export function registerWorktreeRoutes(app: FastifyInstance, deps: RouteDeps) { app.put<{ Params: { id: string; sessionId: string }; Body: unknown }>( diff --git a/packages/ui/src/components/instance/shell/right-panel/tabs/files-runtime.tsx b/packages/ui/src/components/instance/shell/right-panel/tabs/files-runtime.tsx index 462b20a1e..0bf08e4e7 100644 --- a/packages/ui/src/components/instance/shell/right-panel/tabs/files-runtime.tsx +++ b/packages/ui/src/components/instance/shell/right-panel/tabs/files-runtime.tsx @@ -4,7 +4,7 @@ import type { FileContent, FileNode } from "@opencode-ai/sdk/v2/client" import type { DiffWordWrapMode, RightPanelTab } from "../types" import { getRootClient } from "../../../../../stores/opencode-client" -import { getOpenCodeWorkspaceIdForWorktree } from "../../../../../stores/opencode-workspaces" +import { requireWorktreeWorkspacePayload } from "../../../../../stores/session-worktree-binding" import { requestData } from "../../../../../lib/opencode-api" import { serverApi } from "../../../../../lib/api-client" import { showConfirmDialog } from "../../../../../stores/alerts" @@ -62,8 +62,7 @@ export function createFilesTabRuntime(options: FilesTabRuntimeOptions): () => JS ) const fileWorkspacePayload = async () => { - const workspace = await getOpenCodeWorkspaceIdForWorktree(options.instanceId, options.worktreeSlug()) - return workspace ? { workspace } : {} + return requireWorktreeWorkspacePayload(options.instanceId, options.worktreeSlug()) } createEffect(() => { 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 de1899893..d2c174fd2 100644 --- a/packages/ui/src/components/instance/shell/right-panel/useGitChanges.ts +++ b/packages/ui/src/components/instance/shell/right-panel/useGitChanges.ts @@ -4,7 +4,7 @@ import type { PromptInputApi } from "../../../prompt-input/types" import type { GitChangeEntry, GitChangeListItem, GitSelectionDescriptor, RightPanelTab } from "./types" import { getRootClient } from "../../../../stores/opencode-client" -import { getOpenCodeWorkspaceIdForWorktree } from "../../../../stores/opencode-workspaces" +import { requireWorktreeWorkspacePayload } from "../../../../stores/session-worktree-binding" import { requestData } from "../../../../lib/opencode-api" import { serverApi } from "../../../../lib/api-client" import { serverEvents } from "../../../../lib/server-events" @@ -168,12 +168,12 @@ export function useGitChanges(options: UseGitChangesOptions) { if (!force && gitStatusEntries() !== null) return const slug = options.worktreeSlug() const client = getRootClient(options.instanceId) - const workspace = await getOpenCodeWorkspaceIdForWorktree(options.instanceId, slug) + const workspace = await requireWorktreeWorkspacePayload(options.instanceId, slug) const requestVersion = ++gitStatusRequestVersion setGitStatusLoading(true) setGitStatusError(null) try { - const sdkStatusPromise = requestData(client.file.status({ ...(workspace ? { workspace } : {}) }), "file.status") + const sdkStatusPromise = requestData(client.file.status(workspace), "file.status") const detailList = await serverApi.fetchWorktreeGitStatus(options.instanceId, slug) if (requestVersion !== gitStatusRequestVersion) return if (slug !== options.worktreeSlug()) return diff --git a/packages/ui/src/components/session/session-view.tsx b/packages/ui/src/components/session/session-view.tsx index af11017a2..22e7a7dba 100644 --- a/packages/ui/src/components/session/session-view.tsx +++ b/packages/ui/src/components/session/session-view.tsx @@ -11,6 +11,7 @@ import { instances, waitForInstanceWorkspaceMetadataHydration } from "../../stor import { loadMessages, sendMessage, forkSession, renameSession, isSessionMessagesLoading, getSessionMessagesLoadError, markSessionIdleSeen, ensureSessionAncestorsExpanded, setActiveSessionFromList, runShellCommand, abortSession } from "../../stores/sessions" import { clearSessionIdleFade, IDLE_STATUS_VISIBILITY_MS, getSessionStatus, isSessionBusy as getSessionBusyStatus, markSessionIdleFadeStarted } from "../../stores/session-status" import { deleteMessage } from "../../stores/session-actions" +import { requireSessionWorkspacePayload } from "../../stores/session-worktree-binding" import { showAlertDialog } from "../../stores/alerts" import { getLogger } from "../../lib/logger" import { requestData } from "../../lib/opencode-api" @@ -427,6 +428,7 @@ export const SessionView: Component = (props) => { await requestData( instance.client.session.revert({ sessionID: props.sessionId, + ...(await requireSessionWorkspacePayload(props.instanceId, props.sessionId)), messageID: messageId, }), "session.revert", diff --git a/packages/ui/src/components/worktree-selector.tsx b/packages/ui/src/components/worktree-selector.tsx index a3d3f209f..96e112839 100644 --- a/packages/ui/src/components/worktree-selector.tsx +++ b/packages/ui/src/components/worktree-selector.tsx @@ -15,8 +15,8 @@ import { getWorktrees, reloadWorktreeMap, reloadWorktrees, - setWorktreeSlugForParentSession, } from "../stores/worktrees" +import { moveSessionToWorktree } from "../stores/session-worktree-binding" import { sessions } from "../stores/sessions" import { useI18n } from "../lib/i18n" @@ -297,7 +297,7 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) { setCreateOpen(true) return } - await setWorktreeSlugForParentSession(props.instanceId, parentId(), value.slug) + await moveSessionToWorktree(props.instanceId, parentId(), value.slug) } return ( @@ -468,7 +468,7 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) { setIsCreating(true) await createWorktree(props.instanceId, slug) await reloadWorktrees(props.instanceId) - await setWorktreeSlugForParentSession(props.instanceId, parentId(), slug) + await moveSessionToWorktree(props.instanceId, parentId(), slug) setCreateOpen(false) showToastNotification({ message: `Created worktree ${slug}`, variant: "success" }) })() @@ -550,10 +550,6 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) { await reloadWorktrees(props.instanceId) await reloadWorktreeMap(props.instanceId) - if (currentSlug() === target.slug) { - await setWorktreeSlugForParentSession(props.instanceId, parentId(), "root") - } - closeDeleteDialog() showToastNotification({ message: `Deleted worktree ${target.slug}`, variant: "success" }) })() diff --git a/packages/ui/src/lib/api-client.ts b/packages/ui/src/lib/api-client.ts index e94fd39b1..a1077f650 100644 --- a/packages/ui/src/lib/api-client.ts +++ b/packages/ui/src/lib/api-client.ts @@ -563,7 +563,7 @@ export const serverApi = { { method: "POST" }, ) }, - setSessionWorktreeSlug(instanceId: string, sessionId: string, worktreeSlug: string): Promise { + setSessionWorktreeSlug(instanceId: string, sessionId: string, worktreeSlug: string | null): Promise { return request( `/api/workspaces/${encodeURIComponent(instanceId)}/worktrees/sessions/${encodeURIComponent(sessionId)}`, { method: "PUT", body: JSON.stringify({ worktreeSlug }) }, diff --git a/packages/ui/src/lib/hooks/use-commands.ts b/packages/ui/src/lib/hooks/use-commands.ts index e11c97650..d4838103e 100644 --- a/packages/ui/src/lib/hooks/use-commands.ts +++ b/packages/ui/src/lib/hooks/use-commands.ts @@ -21,6 +21,7 @@ import { requestData } from "../opencode-api" import { emitSessionSidebarRequest } from "../session-sidebar-events" import { tGlobal } from "../i18n" import { registerBehaviorCommands } from "../settings/behavior-registry" +import { requireSessionWorkspacePayload } from "../../stores/session-worktree-binding" const log = getLogger("actions") @@ -249,6 +250,7 @@ export function useCommands(options: UseCommandsOptions) { await requestData( instance.client.session.summarize({ sessionID: sessionId, + ...(await requireSessionWorkspacePayload(instance.id, sessionId)), providerID: session.model.providerId, modelID: session.model.modelId, }), @@ -340,6 +342,7 @@ export function useCommands(options: UseCommandsOptions) { await requestData( instance.client.session.revert({ sessionID: sessionId, + ...(await requireSessionWorkspacePayload(instance.id, sessionId)), messageID, }), "session.revert", diff --git a/packages/ui/src/lib/i18n/messages/de/instance.ts b/packages/ui/src/lib/i18n/messages/de/instance.ts index d3eaa2635..5bb463fe8 100644 --- a/packages/ui/src/lib/i18n/messages/de/instance.ts +++ b/packages/ui/src/lib/i18n/messages/de/instance.ts @@ -180,6 +180,10 @@ export const instanceMessages = { "instanceShell.diff.enableWordWrap": "Zeilenumbruch aktivieren", "instanceShell.diff.disableWordWrap": "Zeilenumbruch deaktivieren", "instanceShell.worktree.create": "+ Worktree erstellen", + "instanceShell.worktree.locationUnavailable": "Worktree {slug} ist in OpenCode nicht verfügbar.", + "instanceShell.worktree.moveBusy": "Warte, bis die Sitzung beendet ist, bevor du sie in einen anderen Worktree verschiebst.", + "instanceShell.worktree.moveFailed": "Die Sitzung konnte nicht in den ausgewählten Worktree verschoben werden.", + "instanceShell.worktree.sessionNotFound": "Sitzung nicht gefunden.", "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/en/instance.ts b/packages/ui/src/lib/i18n/messages/en/instance.ts index bd4fb6990..88cbf2d92 100644 --- a/packages/ui/src/lib/i18n/messages/en/instance.ts +++ b/packages/ui/src/lib/i18n/messages/en/instance.ts @@ -180,6 +180,10 @@ export const instanceMessages = { "instanceShell.diff.enableWordWrap": "Enable word wrap", "instanceShell.diff.disableWordWrap": "Disable word wrap", "instanceShell.worktree.create": "+ Create worktree", + "instanceShell.worktree.locationUnavailable": "Worktree {slug} is not available in OpenCode.", + "instanceShell.worktree.moveBusy": "Wait for the session to finish before moving it to another worktree.", + "instanceShell.worktree.moveFailed": "Failed to move the session to the selected worktree.", + "instanceShell.worktree.sessionNotFound": "Session not found.", "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/es/instance.ts b/packages/ui/src/lib/i18n/messages/es/instance.ts index e24672a82..8767cb83b 100644 --- a/packages/ui/src/lib/i18n/messages/es/instance.ts +++ b/packages/ui/src/lib/i18n/messages/es/instance.ts @@ -180,6 +180,10 @@ 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.locationUnavailable": "El worktree {slug} no está disponible en OpenCode.", + "instanceShell.worktree.moveBusy": "Espera a que termine la sesión antes de moverla a otro worktree.", + "instanceShell.worktree.moveFailed": "No se pudo mover la sesión al worktree seleccionado.", + "instanceShell.worktree.sessionNotFound": "No se encontró la sesión.", "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/fr/instance.ts b/packages/ui/src/lib/i18n/messages/fr/instance.ts index 51b865dc0..ca017a3c5 100644 --- a/packages/ui/src/lib/i18n/messages/fr/instance.ts +++ b/packages/ui/src/lib/i18n/messages/fr/instance.ts @@ -180,6 +180,10 @@ 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.locationUnavailable": "Le worktree {slug} n'est pas disponible dans OpenCode.", + "instanceShell.worktree.moveBusy": "Attendez la fin de la session avant de la déplacer vers un autre worktree.", + "instanceShell.worktree.moveFailed": "Impossible de déplacer la session vers le worktree sélectionné.", + "instanceShell.worktree.sessionNotFound": "Session introuvable.", "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/he/instance.ts b/packages/ui/src/lib/i18n/messages/he/instance.ts index 3e42ff082..c88cee854 100644 --- a/packages/ui/src/lib/i18n/messages/he/instance.ts +++ b/packages/ui/src/lib/i18n/messages/he/instance.ts @@ -180,6 +180,10 @@ export const instanceMessages = { "instanceShell.diff.enableWordWrap": "הפעל גלישת מילים", "instanceShell.diff.disableWordWrap": "כבה גלישת מילים", "instanceShell.worktree.create": "+ צור worktree", + "instanceShell.worktree.locationUnavailable": "ה-worktree {slug} אינו זמין ב-OpenCode.", + "instanceShell.worktree.moveBusy": "יש להמתין לסיום ההפעלה לפני העברתה ל-worktree אחר.", + "instanceShell.worktree.moveFailed": "העברת ההפעלה ל-worktree שנבחר נכשלה.", + "instanceShell.worktree.sessionNotFound": "ההפעלה לא נמצאה.", "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/ja/instance.ts b/packages/ui/src/lib/i18n/messages/ja/instance.ts index 51952de54..cfd589a76 100644 --- a/packages/ui/src/lib/i18n/messages/ja/instance.ts +++ b/packages/ui/src/lib/i18n/messages/ja/instance.ts @@ -180,6 +180,10 @@ export const instanceMessages = { "instanceShell.diff.enableWordWrap": "折り返しを有効化", "instanceShell.diff.disableWordWrap": "折り返しを無効化", "instanceShell.worktree.create": "+ worktree を作成", + "instanceShell.worktree.locationUnavailable": "worktree {slug} は OpenCode で利用できません。", + "instanceShell.worktree.moveBusy": "別の worktree に移動する前に、セッションが完了するまで待ってください。", + "instanceShell.worktree.moveFailed": "選択した worktree にセッションを移動できませんでした。", + "instanceShell.worktree.sessionNotFound": "セッションが見つかりません。", "instanceShell.plan.noSessionSelected": "計画を表示するにはセッションを選択してください。", "instanceShell.plan.empty": "まだ計画はありません。", diff --git a/packages/ui/src/lib/i18n/messages/ne/instance.ts b/packages/ui/src/lib/i18n/messages/ne/instance.ts index 5bb3f3e0a..dd445bc7c 100644 --- a/packages/ui/src/lib/i18n/messages/ne/instance.ts +++ b/packages/ui/src/lib/i18n/messages/ne/instance.ts @@ -180,6 +180,10 @@ export const instanceMessages = { "instanceShell.diff.enableWordWrap": "Word wrap सक्षम गर्नुहोस्", "instanceShell.diff.disableWordWrap": "Word wrap अक्षम गर्नुहोस्", "instanceShell.worktree.create": "+ Worktree सिर्जना गर्नुहोस्", + "instanceShell.worktree.locationUnavailable": "Worktree {slug} OpenCode मा उपलब्ध छैन।", + "instanceShell.worktree.moveBusy": "अर्को worktree मा सार्नुअघि सत्र समाप्त हुन दिनुहोस्।", + "instanceShell.worktree.moveFailed": "सत्रलाई चयन गरिएको worktree मा सार्न सकिएन।", + "instanceShell.worktree.sessionNotFound": "सत्र फेला परेन।", "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/ru/instance.ts b/packages/ui/src/lib/i18n/messages/ru/instance.ts index 80437e052..8091b1913 100644 --- a/packages/ui/src/lib/i18n/messages/ru/instance.ts +++ b/packages/ui/src/lib/i18n/messages/ru/instance.ts @@ -180,6 +180,10 @@ export const instanceMessages = { "instanceShell.diff.enableWordWrap": "Включить перенос строк", "instanceShell.diff.disableWordWrap": "Отключить перенос строк", "instanceShell.worktree.create": "+ Создать worktree", + "instanceShell.worktree.locationUnavailable": "Worktree {slug} недоступен в OpenCode.", + "instanceShell.worktree.moveBusy": "Дождитесь завершения сессии, прежде чем перемещать её в другой worktree.", + "instanceShell.worktree.moveFailed": "Не удалось переместить сессию в выбранный worktree.", + "instanceShell.worktree.sessionNotFound": "Сессия не найдена.", "instanceShell.plan.noSessionSelected": "Выберите сессию, чтобы просмотреть план.", "instanceShell.plan.empty": "Пока ничего не запланировано.", 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 6a29f2029..019947277 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/instance.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/instance.ts @@ -180,6 +180,10 @@ export const instanceMessages = { "instanceShell.diff.enableWordWrap": "启用自动换行", "instanceShell.diff.disableWordWrap": "禁用自动换行", "instanceShell.worktree.create": "+ 创建 worktree", + "instanceShell.worktree.locationUnavailable": "OpenCode 中没有可用的 worktree {slug}。", + "instanceShell.worktree.moveBusy": "请等待会话完成后再将其移动到其他 worktree。", + "instanceShell.worktree.moveFailed": "无法将会话移动到所选 worktree。", + "instanceShell.worktree.sessionNotFound": "找不到会话。", "instanceShell.plan.noSessionSelected": "选择会话以查看计划。", "instanceShell.plan.empty": "暂无计划。", diff --git a/packages/ui/src/stores/instances.ts b/packages/ui/src/stores/instances.ts index 3f8ffeb31..015e7ffa8 100644 --- a/packages/ui/src/stores/instances.ts +++ b/packages/ui/src/stores/instances.ts @@ -31,7 +31,7 @@ import { reloadWorktrees, } from "./worktrees" import { getRootClient } from "./opencode-client" -import { clearOpenCodeWorkspaceCache, getOpenCodeWorkspaceIdForSession, getOpenCodeWorkspaceIdForWorktree, syncOpenCodeWorkspaces } from "./opencode-workspaces" +import { clearOpenCodeWorkspaceCache, getOpenCodeWorkspaceIdForWorktree, syncOpenCodeWorkspaces } from "./opencode-workspaces" import { fetchCommands, clearCommands } from "./commands" import { serverSettings } from "./preferences" import { @@ -209,6 +209,11 @@ async function getV2RequestLocations(instanceId: string): Promise return buildV2RequestLocations(instance?.folder, worktrees, workspaceBySlug) } +async function requireInterruptionWorkspace(instanceId: string, sessionId: string) { + const { requireSessionWorkspacePayload } = await import("./session-worktree-binding") + return requireSessionWorkspacePayload(instanceId, sessionId) +} + const [activeInterruption, setActiveInterruption] = createSignal>(new Map()) function syncHasInstancesFlag() { @@ -1764,11 +1769,13 @@ async function sendQuestionReply( const source = questionRegistry.getSource(instanceId, requestId) if (source === "legacy") { - const workspace = sessionId ? await getOpenCodeWorkspaceIdForSession(instanceId, sessionId) : null + const workspace = sessionId + ? await requireInterruptionWorkspace(instanceId, sessionId) + : {} await requestData( client.question.reply({ requestID: requestId, - ...(workspace ? { workspace } : {}), + ...workspace, answers, }), "question.reply", @@ -1802,11 +1809,13 @@ async function sendQuestionReject(instanceId: string, sessionId: string, request const source = questionRegistry.getSource(instanceId, requestId) if (source === "legacy") { - const workspace = sessionId ? await getOpenCodeWorkspaceIdForSession(instanceId, sessionId) : null + const workspace = sessionId + ? await requireInterruptionWorkspace(instanceId, sessionId) + : {} await requestData( client.question.reject({ requestID: requestId, - ...(workspace ? { workspace } : {}), + ...workspace, }), "question.reject", ) @@ -1844,11 +1853,13 @@ async function sendPermissionResponse( const source = permissionRegistry.getSource(instanceId, requestId) if (source === "legacy") { - const workspace = sessionId ? await getOpenCodeWorkspaceIdForSession(instanceId, sessionId) : null + const workspace = sessionId + ? await requireInterruptionWorkspace(instanceId, sessionId) + : {} await requestData( client.permission.reply({ requestID: requestId, - ...(workspace ? { workspace } : {}), + ...workspace, reply, ...(message ? { message } : {}), }), diff --git a/packages/ui/src/stores/session-actions.ts b/packages/ui/src/stores/session-actions.ts index 17e1bb93e..19a551ec9 100644 --- a/packages/ui/src/stores/session-actions.ts +++ b/packages/ui/src/stores/session-actions.ts @@ -1,7 +1,7 @@ import { preparePromptDisplayText } from "../lib/prompt-display-metadata" import { instances } from "./instances" import { getRootClient } from "./opencode-client" -import { getOpenCodeWorkspaceIdForSession } from "./opencode-workspaces" +import { requireSessionWorkspacePayload } from "./session-worktree-binding" import { addRecentModelPreference, getModelThinkingSelection, setAgentModelPreference } from "./preferences" import { beginSessionGenerationAdmission, providers, sessions, withSession } from "./session-state" @@ -15,11 +15,6 @@ import { clearConversationPlaybackForSession } from "./conversation-speech" const log = getLogger("actions") -async function getSessionWorkspacePayload(instanceId: string, sessionId: string): Promise<{ workspace?: string }> { - const workspace = await getOpenCodeWorkspaceIdForSession(instanceId, sessionId) - return workspace ? { workspace } : {} -} - function getVariantKeysForModel(instanceId: string, model: { providerId: string; modelId: string }): string[] { if (!model.providerId || !model.modelId) return [] const instanceProviders = providers().get(instanceId) || [] @@ -224,7 +219,7 @@ async function sendMessage( try { log.info("session.promptAsync", { instanceId, sessionId, requestBody }) - const workspacePayload = await getSessionWorkspacePayload(instanceId, sessionId) + const workspacePayload = await requireSessionWorkspacePayload(instanceId, sessionId) const admission = beginSessionGenerationAdmission(instanceId, sessionId) try { await requestData( @@ -289,7 +284,7 @@ async function executeCustomCommand( if (variant) body.variant = variant } - const workspacePayload = await getSessionWorkspacePayload(instanceId, sessionId) + const workspacePayload = await requireSessionWorkspacePayload(instanceId, sessionId) const admission = beginSessionGenerationAdmission(instanceId, sessionId) try { await requestData( @@ -322,7 +317,7 @@ async function runShellCommand(instanceId: string, sessionId: string, command: s const agent = session.agent || "build" - const workspacePayload = await getSessionWorkspacePayload(instanceId, sessionId) + const workspacePayload = await requireSessionWorkspacePayload(instanceId, sessionId) const admission = beginSessionGenerationAdmission(instanceId, sessionId) try { await requestData( @@ -356,7 +351,7 @@ async function abortSession(instanceId: string, sessionId: string): Promise() @@ -750,6 +750,14 @@ async function createSession(instanceId: string, agent?: string): Promise candidate.slug === worktreeSlug) + if (!worktree) throw new Error(tGlobal("instanceShell.worktree.locationUnavailable", { slug: worktreeSlug })) + const workspaceId = worktreeSlug === "root" + ? null + : await getOpenCodeWorkspaceIdForWorktree(instanceId, worktreeSlug) + if (worktreeSlug !== "root" && !workspaceId) { + throw new Error(tGlobal("instanceShell.worktree.locationUnavailable", { slug: worktreeSlug })) + } const instanceAgents = agents().get(instanceId) || [] const primaryAgents = instanceAgents.filter(isSelectablePrimaryAgent) @@ -769,7 +777,9 @@ async function createSession(instanceId: string, agent?: string): Promise { - log.warn("Failed to persist session worktree mapping", { instanceId, sessionId: session.id, worktreeSlug, error }) - }) - return session } catch (error) { log.error("Failed to create session:", error) @@ -879,7 +885,7 @@ async function forkSession( const request: { sessionID: string; messageID?: string } = { sessionID: sourceSessionId, - ...(await getSessionWorkspacePayload(instanceId, sourceSessionId)), + ...(await requireSessionWorkspacePayload(instanceId, sourceSessionId)), messageID: options?.messageId, } @@ -977,7 +983,7 @@ async function deleteSession(instanceId: string, sessionId: string): Promise( - client.session.messages({ sessionID: sessionId, ...(await getSessionWorkspacePayload(instanceId, sessionId)) }), + client.session.messages({ sessionID: sessionId, ...(await requireSessionWorkspacePayload(instanceId, sessionId)) }), "session.messages", ) diff --git a/packages/ui/src/stores/session-metadata.ts b/packages/ui/src/stores/session-metadata.ts index bd4f4ab85..55ec92b6b 100644 --- a/packages/ui/src/stores/session-metadata.ts +++ b/packages/ui/src/stores/session-metadata.ts @@ -60,7 +60,7 @@ export async function hydrateSessionMetadataWithClient( export async function setSessionWorktreeSlug( instanceId: string, sessionId: string, - worktreeSlug: string, + worktreeSlug: string | null, ): Promise { const { metadata } = await serverApi.setSessionWorktreeSlug(instanceId, sessionId, worktreeSlug) withSession(instanceId, sessionId, (session) => { diff --git a/packages/ui/src/stores/session-state.ts b/packages/ui/src/stores/session-state.ts index e9cf30bd0..e3c6f0e5a 100644 --- a/packages/ui/src/stores/session-state.ts +++ b/packages/ui/src/stores/session-state.ts @@ -9,7 +9,6 @@ import { showConfirmDialog } from "./alerts" import { getLogger } from "../lib/logger" import { requestData } from "../lib/opencode-api" import { getRootClient } from "./opencode-client" -import { getOpenCodeWorkspaceIdForSession } from "./opencode-workspaces" import { tGlobal } from "../lib/i18n" import { computeThreadTotals, type ThreadTotals } from "../lib/thread-totals" import { applySessionPage, getDefaultSessionPaginationState, type SessionPaginationState } from "./session-pagination-model" @@ -1118,9 +1117,10 @@ async function isBlankSession(session: Session, instanceId: string, fetchIfNeede let messages: any[] = [] try { const client = getRootClient(instanceId) - const workspace = await getOpenCodeWorkspaceIdForSession(instanceId, session.id) + const { requireSessionWorkspacePayload } = await import("./session-worktree-binding") + const workspace = await requireSessionWorkspacePayload(instanceId, session.id) messages = await requestData( - client.session.messages({ sessionID: session.id, ...(workspace ? { workspace } : {}) }), + client.session.messages({ sessionID: session.id, ...workspace }), "session.messages", ) } catch (error) { diff --git a/packages/ui/src/stores/session-worktree-binding.test.ts b/packages/ui/src/stores/session-worktree-binding.test.ts new file mode 100644 index 000000000..28477325e --- /dev/null +++ b/packages/ui/src/stores/session-worktree-binding.test.ts @@ -0,0 +1,127 @@ +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 { clearOpenCodeWorkspaceCache } from "./opencode-workspaces.ts" +import { requireSessionWorkspacePayload } from "./session-worktree-binding.ts" +import { sessions, setSessions } from "./session-state.ts" +import { reloadWorktrees } from "./worktrees.ts" + +function session(instanceId: string, id: string, parentId: string | null, metadata?: Record): Session { + return { + id, + instanceId, + parentId, + title: id, + agent: "build", + model: { providerId: "provider", modelId: "model" }, + status: "idle", + retry: null, + idleSince: null, + generationRecovery: null, + runtimeStatusKnown: true, + version: "1", + time: { created: 1, updated: 1 }, + directory: "/repo", + metadata, + } +} + +async function setup(instanceId: string, warp: (parameters: Record) => Promise) { + const client = { + experimental: { + workspace: { + async syncList() { return { data: [] } }, + async list() { return { data: [{ id: "workspace-feature", directory: "/repo-feature" }] } }, + async warp(parameters: Record) { return warp(parameters) }, + }, + }, + } 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 }) + + const originalFetchWorktrees = serverApi.fetchWorktrees + serverApi.fetchWorktrees = async () => ({ + isGitRepo: true, + worktrees: [ + { slug: "root", directory: "/repo", kind: "root" }, + { slug: "feature", directory: "/repo-feature", kind: "worktree" }, + ], + }) + await reloadWorktrees(instanceId) + serverApi.fetchWorktrees = originalFetchWorktrees + + return () => { + setSessions((previous) => { + const next = new Map(previous) + next.delete(instanceId) + return next + }) + clearOpenCodeWorkspaceCache(instanceId) + removeInstance(instanceId, { authoritative: false }) + sdkManager.destroyClientsForInstance(instanceId) + } +} + +describe("session worktree binding", () => { + it("warps a legacy session family before returning its workspace", async () => { + const instanceId = "legacy-worktree-warp" + const calls: Array> = [] + const cleanup = await setup(instanceId, async (parameters) => { + calls.push(parameters) + return { data: true } + }) + const originalSetWorktreeSlug = serverApi.setSessionWorktreeSlug + const metadataWrites: Array = [] + serverApi.setSessionWorktreeSlug = async (_instanceId, _sessionId, slug) => { + metadataWrites.push(slug) + return { metadata: { codenomad: { version: 1 } } } + } + const root = session(instanceId, "root-session", null, { codenomad: { version: 1, worktreeSlug: "feature" } }) + const child = session(instanceId, "child-session", root.id) + setSessions((previous) => new Map(previous).set(instanceId, new Map([[root.id, root], [child.id, child]]))) + + try { + assert.deepEqual(await requireSessionWorkspacePayload(instanceId, child.id), { workspace: "workspace-feature" }) + assert.deepEqual(calls.map(({ sessionID, id }) => ({ sessionID, id })), [ + { sessionID: root.id, id: "workspace-feature" }, + { sessionID: child.id, id: "workspace-feature" }, + ]) + assert.equal(sessions().get(instanceId)?.get(root.id)?.directory, "/repo-feature") + assert.equal(sessions().get(instanceId)?.get(child.id)?.workspaceId, "workspace-feature") + assert.deepEqual(metadataWrites, [null]) + } finally { + serverApi.setSessionWorktreeSlug = originalSetWorktreeSlug + cleanup() + } + }) + + it("rolls back an incomplete family warp", async () => { + const instanceId = "worktree-warp-rollback" + const calls: Array> = [] + const cleanup = await setup(instanceId, async (parameters) => { + calls.push(parameters) + if (parameters.sessionID === "child-session") throw new Error("warp failed") + return { data: true } + }) + const root = session(instanceId, "root-session", null, { codenomad: { version: 1, worktreeSlug: "feature" } }) + const child = session(instanceId, "child-session", root.id) + setSessions((previous) => new Map(previous).set(instanceId, new Map([[root.id, root], [child.id, child]]))) + + try { + await assert.rejects(() => requireSessionWorkspacePayload(instanceId, root.id), /warp failed/) + assert.deepEqual(calls.map(({ sessionID, id }) => ({ sessionID, id })), [ + { sessionID: root.id, id: "workspace-feature" }, + { sessionID: child.id, id: "workspace-feature" }, + { sessionID: root.id, id: null }, + ]) + assert.equal(sessions().get(instanceId)?.get(root.id)?.workspaceId, undefined) + assert.equal(sessions().get(instanceId)?.get(root.id)?.directory, "/repo") + } finally { + cleanup() + } + }) +}) diff --git a/packages/ui/src/stores/session-worktree-binding.ts b/packages/ui/src/stores/session-worktree-binding.ts new file mode 100644 index 000000000..4daaeb729 --- /dev/null +++ b/packages/ui/src/stores/session-worktree-binding.ts @@ -0,0 +1,106 @@ +import { tGlobal } from "../lib/i18n" +import { getLogger } from "../lib/logger" +import { requestData } from "../lib/opencode-api" +import { getRootClient } from "./opencode-client" +import { + forgetOpenCodeWorkspaceIdForSession, + getOpenCodeWorkspaceIdForWorktree, + rememberOpenCodeWorkspaceIdForSession, +} from "./opencode-workspaces" +import { getDescendantSessions, getSessionRoot, sessions, withSession } from "./session-state" +import { setSessionWorktreeSlug } from "./session-metadata" +import { + getWorktreeSlugForSession, + getWorktrees, + removeLegacyParentSessionMapping, +} from "./worktrees" + +const log = getLogger("session") + +function locationError(slug: string): Error { + return new Error(tGlobal("instanceShell.worktree.locationUnavailable", { slug })) +} + +async function targetLocation(instanceId: string, slug: string) { + const worktree = getWorktrees(instanceId).find((candidate) => candidate.slug === slug) + if (!worktree) throw locationError(slug) + if (slug === "root") return { worktree, workspaceId: null } + const workspaceId = await getOpenCodeWorkspaceIdForWorktree(instanceId, slug) + if (!workspaceId) throw locationError(slug) + return { worktree, workspaceId } +} + +async function warpSession(instanceId: string, sessionId: string, workspaceId: string | null): Promise { + const { instances } = await import("./instances") + const instance = instances().get(instanceId) + if (!instance?.folder) throw new Error(tGlobal("instanceShell.worktree.moveFailed")) + await requestData( + getRootClient(instanceId).experimental.workspace.warp({ + directory: instance.folder, + id: workspaceId, + sessionID: sessionId, + }), + "experimental.workspace.warp", + ) +} + +async function moveSessionToWorktree(instanceId: string, sessionId: string, slug: string): Promise { + const root = getSessionRoot(instanceId, sessionId) + if (!root) throw new Error(tGlobal("instanceShell.worktree.sessionNotFound")) + const target = await targetLocation(instanceId, slug) + if (root.workspaceId === (target.workspaceId ?? undefined) && root.directory === target.worktree.directory) return + + const members = [root, ...getDescendantSessions(instanceId, root.id)] + if (members.some((session) => session.status === "working" || session.status === "compacting")) { + throw new Error(tGlobal("instanceShell.worktree.moveBusy")) + } + + const moved: typeof members = [] + try { + for (const member of members) { + await warpSession(instanceId, member.id, target.workspaceId) + moved.push(member) + } + } catch (error) { + for (const member of moved.reverse()) { + await warpSession(instanceId, member.id, member.workspaceId ?? null).catch((rollbackError) => { + log.error("Failed to roll back session worktree move", { instanceId, sessionId: member.id, rollbackError }) + }) + } + throw error + } + + for (const member of members) { + withSession(instanceId, member.id, (current) => { + current.workspaceId = target.workspaceId ?? undefined + current.directory = target.worktree.directory + }) + forgetOpenCodeWorkspaceIdForSession(instanceId, member.id) + if (target.workspaceId) rememberOpenCodeWorkspaceIdForSession(instanceId, member.id, target.workspaceId) + } + + await setSessionWorktreeSlug(instanceId, root.id, null).catch((error) => { + log.warn("Failed to clear migrated worktree metadata", { instanceId, sessionId: root.id, error }) + }) + await removeLegacyParentSessionMapping(instanceId, root.id) +} + +async function requireSessionWorkspacePayload(instanceId: string, sessionId: string): Promise<{ workspace?: string }> { + const root = getSessionRoot(instanceId, sessionId) + if (!root) throw new Error(tGlobal("instanceShell.worktree.sessionNotFound")) + if (root.workspaceId) return { workspace: root.workspaceId } + + const slug = getWorktreeSlugForSession(instanceId, root.id) + if (slug === "root") return {} + await moveSessionToWorktree(instanceId, root.id, slug) + const workspace = sessions().get(instanceId)?.get(root.id)?.workspaceId + if (!workspace) throw locationError(slug) + return { workspace } +} + +async function requireWorktreeWorkspacePayload(instanceId: string, slug: string): Promise<{ workspace?: string }> { + const target = await targetLocation(instanceId, slug) + return target.workspaceId ? { workspace: target.workspaceId } : {} +} + +export { moveSessionToWorktree, requireSessionWorkspacePayload, requireWorktreeWorkspacePayload } diff --git a/packages/ui/src/stores/worktrees.ts b/packages/ui/src/stores/worktrees.ts index f67ebf18e..02e9dcd3f 100644 --- a/packages/ui/src/stores/worktrees.ts +++ b/packages/ui/src/stores/worktrees.ts @@ -156,9 +156,7 @@ async function deleteWorktree(instanceId: string, slug: string, options?: { forc if (!trimmed || trimmed === "root") { throw new Error("Invalid worktree") } - await moveSessionsFromDeletedWorktree(instanceId, trimmed).catch((error) => { - log.warn("Failed to move sessions from deleted worktree", { instanceId, slug: trimmed, error }) - }) + await moveSessionsFromDeletedWorktree(instanceId, trimmed) await import("./opencode-workspaces").then(({ removeOpenCodeWorkspaceForWorktree }) => removeOpenCodeWorkspaceForWorktree(instanceId, trimmed)).catch((error) => { log.warn("Failed to remove OpenCode workspace for deleted worktree", { instanceId, slug: trimmed, error }) }) @@ -178,8 +176,8 @@ async function moveSessionsFromDeletedWorktree(instanceId: string, slug: string) .map((session) => session.id) for (const parentSessionId of parentSessionIds) { - await setSessionWorktreeSlug(instanceId, parentSessionId, "root") - await removeLegacyParentSessionMapping(instanceId, parentSessionId) + const { moveSessionToWorktree } = await import("./session-worktree-binding") + await moveSessionToWorktree(instanceId, parentSessionId, "root") } } From 14de946b13035237db0568cfa5a14314bc45c683 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sun, 9 Aug 2026 15:34:47 +0200 Subject: [PATCH 03/20] feat(ui): sort sessions and open worktree folders Add an accessible session-list menu for recent-activity, name, and native-worktree sorting plus per-worktree filtering. Keep families intact, apply projection after search, and align rendering, bulk selection, and deletion fallback with the visible result. Expose an Open in file manager action only in local Electron and Tauri desktop windows. Both native bridges validate the sender, reject network paths, canonicalize the requested directory, and verify it against Git's authoritative worktree inventory before invoking the system file manager. Harden worktree moves and deletion around the new UI: serialize family warps, refresh authoritative OpenCode locations, reject incomplete families, normalize Windows paths, block new worktree operations during deletion, and roll back physical locations and legacy metadata when preparation or Git deletion fails. Serialize legacy map updates and keep post-delete map cleanup best-effort. Add translated labels for every locale, focused migration/sorting/rollback tests to PR CI, and validate production UI build, all TypeScript typechecks, 118 Electron native tests, 40 focused UI tests, 5 server metadata tests, Tauri cargo check, and Rust formatting. --- .github/workflows/pr-build.yml | 3 + packages/electron-app/electron/main/ipc.ts | 49 +++- .../electron-app/electron/preload/index.cjs | 1 + .../server/src/server/routes/worktrees.ts | 44 ++- packages/tauri-app/src-tauri/src/main.rs | 52 ++++ .../instance/shell/SessionSidebar.tsx | 97 ++++++- packages/ui/src/components/session-list.tsx | 36 ++- .../ui/src/components/worktree-selector.tsx | 26 +- .../ui/src/lib/i18n/messages/de/instance.ts | 2 + .../ui/src/lib/i18n/messages/de/session.ts | 8 + .../ui/src/lib/i18n/messages/en/instance.ts | 2 + .../ui/src/lib/i18n/messages/en/session.ts | 8 + .../ui/src/lib/i18n/messages/es/instance.ts | 2 + .../ui/src/lib/i18n/messages/es/session.ts | 8 + .../ui/src/lib/i18n/messages/fr/instance.ts | 2 + .../ui/src/lib/i18n/messages/fr/session.ts | 8 + .../ui/src/lib/i18n/messages/he/instance.ts | 2 + .../ui/src/lib/i18n/messages/he/session.ts | 8 + .../ui/src/lib/i18n/messages/ja/instance.ts | 2 + .../ui/src/lib/i18n/messages/ja/session.ts | 8 + .../ui/src/lib/i18n/messages/ne/instance.ts | 2 + .../ui/src/lib/i18n/messages/ne/session.ts | 8 + .../ui/src/lib/i18n/messages/ru/instance.ts | 2 + .../ui/src/lib/i18n/messages/ru/session.ts | 8 + .../src/lib/i18n/messages/zh-Hans/instance.ts | 2 + .../src/lib/i18n/messages/zh-Hans/session.ts | 8 + .../ui/src/lib/native/native-functions.ts | 30 ++- packages/ui/src/lib/runtime-env.ts | 1 + .../src/stores/opencode-workspace-matching.ts | 11 +- .../ui/src/stores/opencode-workspaces.test.ts | 13 +- packages/ui/src/stores/session-api.ts | 4 + packages/ui/src/stores/session-tree.test.ts | 26 ++ packages/ui/src/stores/session-tree.ts | 24 ++ .../stores/session-worktree-binding.test.ts | 250 +++++++++++++++++- .../ui/src/stores/session-worktree-binding.ts | 181 +++++++++++-- packages/ui/src/stores/worktrees.ts | 123 ++++++--- packages/ui/src/types/global.d.ts | 1 + 37 files changed, 972 insertions(+), 90 deletions(-) diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index 05318b3a1..79def0e29 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -127,6 +127,7 @@ jobs: packages/ui/src/stores/session-generation-recovery.test.ts packages/ui/src/stores/session-metadata.test.ts packages/ui/src/stores/session-pagination.test.ts + packages/ui/src/stores/session-tree.test.ts packages/ui/src/stores/workspace-list-reconciliation-fence.test.ts - name: Test restore ownership integration @@ -136,6 +137,8 @@ jobs: packages/ui/src/stores/permission-lifecycle.test.ts packages/ui/src/stores/session-request-authority.test.ts packages/ui/src/stores/session-send-lifecycle.test.ts + packages/ui/src/stores/session-worktree-binding.test.ts + packages/ui/src/stores/opencode-workspaces.test.ts - name: Test server run: node --import tsx --test "packages/server/src/**/*.test.ts" diff --git a/packages/electron-app/electron/main/ipc.ts b/packages/electron-app/electron/main/ipc.ts index 6e664d907..aa329c1c3 100644 --- a/packages/electron-app/electron/main/ipc.ts +++ b/packages/electron-app/electron/main/ipc.ts @@ -1,5 +1,7 @@ -import { BrowserWindow, Notification, dialog, ipcMain, powerSaveBlocker, type OpenDialogOptions } from "electron" +import { BrowserWindow, Notification, dialog, ipcMain, powerSaveBlocker, shell, type OpenDialogOptions } from "electron" +import { execFile } from "child_process" import fs from "fs" +import pathUtils from "path" import { requestMicrophoneAccess } from "./permissions" import type { CliProcessManager, CliStatus } from "./process-manager" @@ -18,6 +20,34 @@ interface DialogOpenResult { paths: string[] } +function gitWorktreePaths(repoRoot: string): Promise { + return new Promise((resolve, reject) => { + execFile("git", ["-C", repoRoot, "worktree", "list", "--porcelain", "-z"], (error, stdout) => { + if (error) return reject(error) + resolve(stdout.split("\0").flatMap((entry) => entry.startsWith("worktree ") ? [entry.slice(9)] : [])) + }) + }) +} + +async function isRegisteredGitWorktree(repoRoot: string, path: string): Promise { + const requested = fs.realpathSync.native(path) + const root = fs.realpathSync.native(repoRoot) + const registered = await gitWorktreePaths(root) + return registered.some((candidate) => { + try { + const resolved = fs.realpathSync.native(candidate) + const normalize = (value: string) => process.platform === "win32" ? value.toLowerCase() : value + const exact = normalize(resolved) === normalize(requested) + const relative = pathUtils.relative(resolved, requested) + const rootSubdirectory = normalize(requested) === normalize(root) + && relative !== ".." && !relative.startsWith(`..${pathUtils.sep}`) && !pathUtils.isAbsolute(relative) + return exact || rootSubdirectory + } catch { + return false + } + }) +} + export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessManager) { cliManager.on("status", (status: CliStatus) => { if (!mainWindow.isDestroyed()) { @@ -88,6 +118,23 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan return directories }) + ipcMain.handle("filesystem:openDirectory", async (event, path: unknown, repoRoot: unknown): Promise<{ ok: boolean }> => { + if (event.sender !== mainWindow.webContents) throw new Error("Directory opening is unavailable from this window") + if ( + typeof path !== "string" || path.trim().length === 0 + || typeof repoRoot !== "string" || repoRoot.trim().length === 0 + || path.startsWith("\\\\") || path.startsWith("//") + || repoRoot.startsWith("\\\\") || repoRoot.startsWith("//") + || !fs.statSync(path).isDirectory() + || !await isRegisteredGitWorktree(repoRoot, path) + ) { + throw new Error("Directory not found") + } + const error = await shell.openPath(path) + if (error) throw new Error(error) + return { ok: true } + }) + ipcMain.handle("power:setWakeLock", async (_event, enabled: boolean): Promise<{ enabled: boolean }> => { const next = Boolean(enabled) if (next) { diff --git a/packages/electron-app/electron/preload/index.cjs b/packages/electron-app/electron/preload/index.cjs index 0a35f6690..27dfc3525 100644 --- a/packages/electron-app/electron/preload/index.cjs +++ b/packages/electron-app/electron/preload/index.cjs @@ -26,6 +26,7 @@ const localElectronAPI = { restartCli: () => ipcRenderer.invoke("cli:restart"), openDialog: (options) => ipcRenderer.invoke("dialog:open", options), getDirectoryPaths: (paths) => ipcRenderer.invoke("filesystem:getDirectoryPaths", paths), + openDirectory: (path, repoRoot) => ipcRenderer.invoke("filesystem:openDirectory", path, repoRoot), getPathForFile: (file) => { try { return webUtils.getPathForFile(file) diff --git a/packages/server/src/server/routes/worktrees.ts b/packages/server/src/server/routes/worktrees.ts index b5f3edd77..89deeaf97 100644 --- a/packages/server/src/server/routes/worktrees.ts +++ b/packages/server/src/server/routes/worktrees.ts @@ -149,30 +149,28 @@ export function registerWorktreeRoutes(app: FastifyInstance, deps: RouteDeps) { await removeWorktree({ workspaceFolder: workspace.path, directory: match.directory, force, logger: request.log }) - // Best-effort: prune any mappings that point at the deleted worktree. - const current = await readWorktreeMap(workspace.path, request.log) - let changed = false - const nextMapping: Record = { ...(current.parentSessionWorktreeSlug ?? {}) } - for (const [sessionId, mapped] of Object.entries(nextMapping)) { - if (mapped === slug) { - delete nextMapping[sessionId] - changed = true + // Best-effort: Git deletion has already succeeded, so cleanup must not report a false failure. + try { + const current = await readWorktreeMap(workspace.path, request.log) + let changed = false + const nextMapping: Record = { ...(current.parentSessionWorktreeSlug ?? {}) } + for (const [sessionId, mapped] of Object.entries(nextMapping)) { + if (mapped === slug) { + delete nextMapping[sessionId] + changed = true + } } - } - const nextDefault = current.defaultWorktreeSlug === slug ? "root" : current.defaultWorktreeSlug - if (nextDefault !== current.defaultWorktreeSlug) { - changed = true - } - if (changed) { - await writeWorktreeMap( - workspace.path, - { - version: 1, - defaultWorktreeSlug: nextDefault, - parentSessionWorktreeSlug: nextMapping, - }, - request.log, - ) + const nextDefault = current.defaultWorktreeSlug === slug ? "root" : current.defaultWorktreeSlug + if (nextDefault !== current.defaultWorktreeSlug) changed = true + if (changed) { + await writeWorktreeMap( + workspace.path, + { version: 1, defaultWorktreeSlug: nextDefault, parentSessionWorktreeSlug: nextMapping }, + request.log, + ) + } + } catch (error) { + request.log.warn({ error, slug }, "Failed to prune deleted worktree mappings") } reply.code(204) diff --git a/packages/tauri-app/src-tauri/src/main.rs b/packages/tauri-app/src-tauri/src/main.rs index 4dc142612..ad1ceced9 100644 --- a/packages/tauri-app/src-tauri/src/main.rs +++ b/packages/tauri-app/src-tauri/src/main.rs @@ -195,6 +195,57 @@ fn wake_lock_stop(state: tauri::State) -> Result<(), String> { Ok(()) } +#[tauri::command] +fn open_local_directory( + app: AppHandle, + window: tauri::WebviewWindow, + path: String, + repo_root: String, +) -> Result<(), String> { + if window.label() != "main" { + return Err("Directory opening is unavailable from this window".to_string()); + } + let path = path.trim(); + let repo_root = repo_root.trim(); + if path.is_empty() + || repo_root.is_empty() + || path.starts_with(r"\\") + || path.starts_with("//") + || repo_root.starts_with(r"\\") + || repo_root.starts_with("//") + { + return Err("Directory not found".to_string()); + } + let directory = std::fs::canonicalize(path).map_err(|_| "Directory not found")?; + let repo_root = std::fs::canonicalize(repo_root).map_err(|_| "Directory not found")?; + if !directory.is_dir() || !repo_root.is_dir() { + return Err("Directory not found".to_string()); + } + let worktrees = std::process::Command::new("git") + .arg("-C") + .arg(&repo_root) + .args(["worktree", "list", "--porcelain", "-z"]) + .output() + .map_err(|err| err.to_string())?; + if !worktrees.status.success() { + return Err("Directory not found".to_string()); + } + let registered = worktrees.stdout.split(|byte| *byte == 0).any(|entry| { + let Some(path) = entry.strip_prefix(b"worktree ") else { + return false; + }; + std::fs::canonicalize(String::from_utf8_lossy(path).as_ref()).is_ok_and(|candidate| { + candidate == directory || (directory == repo_root && directory.starts_with(candidate)) + }) + }); + if !registered { + return Err("Directory not found".to_string()); + } + app.opener() + .open_path(directory.to_string_lossy(), None::<&str>) + .map_err(|err| err.to_string()) +} + fn is_dev_mode() -> bool { cfg!(debug_assertions) || std::env::var("TAURI_DEV").is_ok() } @@ -664,6 +715,7 @@ fn main() { desktop_events_stop, wake_lock_start, wake_lock_stop, + open_local_directory, needs_local_certificate_install, open_remote_window, client_state::client_state_claim_access, diff --git a/packages/ui/src/components/instance/shell/SessionSidebar.tsx b/packages/ui/src/components/instance/shell/SessionSidebar.tsx index fa80c1db7..223e47820 100644 --- a/packages/ui/src/components/instance/shell/SessionSidebar.tsx +++ b/packages/ui/src/components/instance/shell/SessionSidebar.tsx @@ -1,10 +1,11 @@ -import { Show, type Accessor, type Component } from "solid-js" +import { For, Show, createMemo, createSignal, type Accessor, type Component } from "solid-js" +import { DropdownMenu } from "@kobalte/core/dropdown-menu" import type { SessionThread } from "../../../stores/session-state" import type { Session } from "../../../types/session" import { keyboardRegistry, type KeyboardShortcut } from "../../../lib/keyboard-registry" import type { DrawerViewState } from "./types" -import { PlusSquare, Search } from "lucide-solid" +import { Check, ListFilter, PlusSquare, Search } from "lucide-solid" import IconButton from "@suid/material/IconButton" import MenuOpenIcon from "@suid/icons-material/MenuOpen" import PushPinIcon from "@suid/icons-material/PushPin" @@ -19,6 +20,8 @@ import ModelSelector from "../../model-selector" import ThinkingSelector from "../../thinking-selector" import { getLogger } from "../../../lib/logger" import { shouldMountSessionList } from "../../session-list-visibility" +import { getWorktrees } from "../../../stores/worktrees" +import type { SessionThreadSortMode } from "../../../stores/session-tree" const log = getLogger("session") @@ -52,7 +55,22 @@ interface SessionSidebarProps { setContentEl: (el: HTMLElement | null) => void } -const SessionSidebar: Component = (props) => ( +const SessionSidebar: Component = (props) => { + const [sortMode, setSortMode] = createSignal("activity") + const [selectedWorktree, setSelectedWorktree] = createSignal(null) + const worktrees = createMemo(() => getWorktrees(props.instanceId)) + const worktreeFilter = createMemo(() => { + const selected = selectedWorktree() + return selected && worktrees().some((worktree) => worktree.slug === selected) ? selected : null + }) + const optionsActive = createMemo(() => sortMode() !== "activity" || worktreeFilter() !== null) + const sortOptions: Array<{ value: SessionThreadSortMode; labelKey: string }> = [ + { value: "activity", labelKey: "sessionList.options.sort.activity" }, + { value: "name", labelKey: "sessionList.options.sort.name" }, + { value: "worktree", labelKey: "sessionList.options.sort.worktree" }, + ] + + return (
@@ -91,6 +109,76 @@ const SessionSidebar: Component = (props) => ( > + + + + + + +
+ {props.t("sessionList.options.sort.label")} +
+ + {(option) => ( + setSortMode(option.value)} + > + + {props.t(option.labelKey)} + + )} + +
) +} export default SessionSidebar diff --git a/packages/ui/src/components/session-list.tsx b/packages/ui/src/components/session-list.tsx index db1549432..1a2a0815e 100644 --- a/packages/ui/src/components/session-list.tsx +++ b/packages/ui/src/components/session-list.tsx @@ -14,7 +14,6 @@ import { showConfirmDialog } from "../stores/alerts" import { deleteSession, ensureSessionAncestorsExpanded, - getVisibleSessionIds, isSessionExpanded, loadMessages, loading, @@ -33,7 +32,14 @@ import { isSessionSearchLoading, } from "../stores/sessions" import { getGitRepoStatus, getWorktreeSlugForParentSession } from "../stores/worktrees" -import { collectSessionThreadIds, findSessionThread, flattenVisibleSessionThreads, sortSessionIdsDeepestFirst } from "../stores/session-tree" +import { + collectSessionThreadIds, + findSessionThread, + flattenVisibleSessionThreads, + projectSessionThreads, + sortSessionIdsDeepestFirst, + type SessionThreadSortMode, +} from "../stores/session-tree" import { getLogger } from "../lib/logger" import { copyToClipboard } from "../lib/clipboard" import { useConfig } from "../stores/preferences" @@ -53,6 +59,8 @@ interface SessionListProps { headerContent?: JSX.Element footerContent?: JSX.Element enableFilterBar?: boolean + sortMode: SessionThreadSortMode + worktreeFilter: string | null } function formatSessionStatus(status: SessionStatus): string { @@ -214,10 +222,17 @@ const SessionList: Component = (props) => { return result }) + const projectedThreads = createMemo(() => projectSessionThreads(filteredThreads(), { + sort: props.sortMode, + worktree: props.worktreeFilter, + getLabel: (thread) => normalizeSessionLabel(thread.session.id), + getWorktree: (thread) => getWorktreeSlugForParentSession(props.instanceId, thread.session.id), + })) + const visibleProjection = createMemo(() => { const expandAll = Boolean(normalizedQuery()) const rows = flattenVisibleSessionThreads( - filteredThreads(), + projectedThreads(), (sessionId) => expandAll || isSessionExpanded(props.instanceId, sessionId), ) const ids: string[] = [] @@ -245,10 +260,18 @@ const SessionList: Component = (props) => { collectIds(thread.children) } } - collectIds(filteredThreads()) + collectIds(projectedThreads()) return ids }) + createEffect(() => { + const matching = new Set(allMatchingSessionIds()) + setSelectedSessionIds((selected) => { + if (Array.from(selected).every((sessionId) => matching.has(sessionId))) return selected + return new Set(Array.from(selected).filter((sessionId) => matching.has(sessionId))) + }) + }) + const selectedCount = createMemo(() => selectedSessionIds().size) const isAllSelected = createMemo(() => { @@ -317,7 +340,7 @@ const SessionList: Component = (props) => { let fallbackSessionId: string | undefined if (shouldSelectFallback) { - const visible = getVisibleSessionIds(props.instanceId) + const visible = visibleProjection().ids const currentIndex = visible.indexOf(sessionId) const remaining = visible.filter((id) => id !== sessionId) @@ -423,8 +446,7 @@ const SessionList: Component = (props) => { } const getSelectableThreadIds = (sessionId: string): string[] => { - const source = normalizedQuery() ? filteredThreads() : props.threads - const thread = findSessionThread(source, sessionId) + const thread = findSessionThread(projectedThreads(), sessionId) return thread ? collectSessionThreadIds([thread]) : [sessionId] } diff --git a/packages/ui/src/components/worktree-selector.tsx b/packages/ui/src/components/worktree-selector.tsx index 96e112839..05e832a74 100644 --- a/packages/ui/src/components/worktree-selector.tsx +++ b/packages/ui/src/components/worktree-selector.tsx @@ -1,7 +1,7 @@ import { Select } from "@kobalte/core/select" import { Dialog } from "@kobalte/core/dialog" import { For, Show, createMemo, createSignal } from "solid-js" -import { ChevronDown, Copy, Trash2 } from "lucide-solid" +import { ChevronDown, Copy, FolderOpen, Trash2 } from "lucide-solid" import type { WorktreeDescriptor } from "../../../server/src/api-types" import { getLogger } from "../lib/logger" import { copyToClipboard } from "../lib/clipboard" @@ -19,6 +19,7 @@ import { import { moveSessionToWorktree } from "../stores/session-worktree-binding" import { sessions } from "../stores/sessions" import { useI18n } from "../lib/i18n" +import { openLocalDirectory, supportsLocalDirectoryOpen } from "../lib/native/native-functions" const log = getLogger("session") @@ -205,6 +206,11 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) { } } + const handleOpenDirectory = async (directory: string) => { + if (await openLocalDirectory(directory, repoRoot())) return + showToastNotification({ message: t("instanceShell.worktree.openDirectory.error"), variant: "error" }) + } + const sanitizeDeleteError = (input: string) => { let sanitized = (input ?? "").trim() if (!sanitized) { @@ -361,6 +367,24 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) { > {displayPathFor(opt.directory)} + + +
- - - -
+
= (props) => { height: viewport().height ? `${viewport().height}px` : "100%", margin: viewport().width ? "0 auto" : "0", }} - referrerPolicy="same-origin" + referrerPolicy={framePolicy.referrerPolicy} + sandbox={framePolicy.sandbox} onLoad={syncPathInputFromFrame} />
- - {(rect) => ( -
- )} -
) diff --git a/packages/ui/src/components/preview-isolation.test.ts b/packages/ui/src/components/preview-isolation.test.ts index ac439ebe1..7324308f5 100644 --- a/packages/ui/src/components/preview-isolation.test.ts +++ b/packages/ui/src/components/preview-isolation.test.ts @@ -1,24 +1,28 @@ import assert from "node:assert/strict" import test from "node:test" -import { buildPreviewNavigationUrl, isolateProjectPreviewUrl } from "./preview-isolation" +import { buildPreviewNavigationUrl, previewFramePolicy, resolvePreviewUrl } from "./preview-isolation" -test("Electron project previews use a native origin distinct from the app", () => { - const isolated = isolateProjectPreviewUrl("/previews/token/page?q=1", "electron", "local", "http://127.0.0.1:43123/app") - assert.equal(isolated, "codenomad-preview://127.0.0.1:43123/previews/token/page?q=1") - assert.notEqual(new URL(isolated).origin, "http://127.0.0.1:43123") - assert.equal( - isolateProjectPreviewUrl("/previews/token", "tauri", "local", "http://127.0.0.1:43123/app"), - "http://127.0.0.1:43123/previews/token", - ) - assert.equal( - isolateProjectPreviewUrl("/previews/token", "electron", "remote", "https://remote.example/app"), - "https://remote.example/previews/token", - ) +test("preview URLs retain HTTP networking in local and remote windows", () => { + assert.equal(resolvePreviewUrl("/previews/token/page?q=1", "http://127.0.0.1:43123/app"), "http://127.0.0.1:43123/previews/token/page?q=1") + assert.equal(resolvePreviewUrl("/previews/token", "https://remote.example/app"), "https://remote.example/previews/token") }) -test("preview address navigation preserves the initial custom scheme and authority", () => { +test("preview sandbox isolates DOM and native bridges without blocking scripts or forms", () => { + const policy = previewFramePolicy() + const tokens = new Set(policy.sandbox.split(/\s+/)) + assert.equal(tokens.has("allow-scripts"), true) + assert.equal(tokens.has("allow-forms"), true) + assert.equal(tokens.has("allow-same-origin"), false) + assert.equal([...tokens].some((token) => token.startsWith("allow-top-navigation")), false) +}) + +test("preview address navigation preserves local and remote proxy bases", () => { + assert.equal( + buildPreviewNavigationUrl("nested/../next?q=1", "/previews/token", "/previews/token", "http://127.0.0.1:43123/app"), + "http://127.0.0.1:43123/previews/token/next?q=1", + ) assert.equal( - buildPreviewNavigationUrl("nested/../next?q=1", "/previews/token", "codenomad-preview://127.0.0.1:43123/previews/token"), - "codenomad-preview://127.0.0.1:43123/previews/token/next?q=1", + buildPreviewNavigationUrl("/docs", "/sidecars/dev", "/sidecars/dev/", "https://remote.example/app"), + "https://remote.example/sidecars/dev/docs", ) }) diff --git a/packages/ui/src/components/preview-isolation.ts b/packages/ui/src/components/preview-isolation.ts index cceeb118b..81ca7da0e 100644 --- a/packages/ui/src/components/preview-isolation.ts +++ b/packages/ui/src/components/preview-isolation.ts @@ -1,22 +1,26 @@ -export function isolateProjectPreviewUrl( - url: string, - runtimeHost: string | undefined, - windowContext: string | undefined, - baseUrl: string, -): string { - const source = new URL(url, baseUrl) - if (runtimeHost !== "electron" || windowContext !== "local") return source.href - return `codenomad-preview://${source.host}${source.pathname}${source.search}${source.hash}` +export const PREVIEW_FRAME_SANDBOX = "allow-forms allow-scripts" + +export function resolvePreviewUrl(url: string, baseUrl: string): string { + return new URL(url, baseUrl).href +} + +export function previewFramePolicy() { + // ponytail: credentialless strips the HttpOnly session required by the preview and websocket proxy. + return { + sandbox: PREVIEW_FRAME_SANDBOX, + referrerPolicy: "same-origin" as const, + } } -export function buildPreviewNavigationUrl(rawInput: string, proxyBasePath: string, initialUrl: string): string { +export function buildPreviewNavigationUrl(rawInput: string, proxyBasePath: string, initialUrl: string, baseUrl: string): string { const trimmed = rawInput.trim() - const parsed = new URL(trimmed.startsWith("/") ? trimmed : `/${trimmed}`, initialUrl) + const frameUrl = new URL(initialUrl, baseUrl) + const parsed = new URL(trimmed.startsWith("/") ? trimmed : `/${trimmed}`, frameUrl) const safeSegments: string[] = [] for (const segment of parsed.pathname.split("/")) { if (!segment || segment === ".") continue if (segment === "..") safeSegments.pop() else safeSegments.push(segment) } - return new URL(`${proxyBasePath}/${safeSegments.join("/")}${parsed.search}${parsed.hash}`, initialUrl).href + return new URL(`${proxyBasePath}/${safeSegments.join("/")}${parsed.search}${parsed.hash}`, frameUrl).href } diff --git a/packages/ui/src/components/session-preview-view.tsx b/packages/ui/src/components/session-preview-view.tsx index 643cdbc65..4e145c56c 100644 --- a/packages/ui/src/components/session-preview-view.tsx +++ b/packages/ui/src/components/session-preview-view.tsx @@ -1,11 +1,9 @@ -import { createSignal, type Component } from "solid-js" +import type { Component } from "solid-js" import { X } from "lucide-solid" import { useI18n } from "../lib/i18n" -import { showPromptDialog } from "../stores/alerts" import type { SessionPreviewRecord } from "../stores/session-previews" -import { BrowserFrame, type BrowserFrameElementTarget } from "./browser-frame" -import { isolateProjectPreviewUrl } from "./preview-isolation" -import { runtimeEnv } from "../lib/runtime-env" +import { BrowserFrame } from "./browser-frame" +import { resolvePreviewUrl } from "./preview-isolation" interface SessionPreviewViewProps { preview: SessionPreviewRecord @@ -14,41 +12,10 @@ interface SessionPreviewViewProps { onInsertComment: (markdown: string) => void } -function describeElement(target: BrowserFrameElementTarget): string { - const label = target.ariaLabel || target.text - const role = target.role ? ` role="${target.role}"` : "" - return label ? `${target.tagName}${role} "${label}"` : `${target.tagName}${role}` -} - -function buildCommentMarkdown(target: BrowserFrameElementTarget, comment: string): string { - const lines = [ - "> Web preview comment", - `> Page: \`${target.pagePath}\``, - `> Element: \`${describeElement(target)}\``, - ] - if (target.selector) { - lines.push(`> Selector: \`${target.selector}\``) - } - return `${lines.join("\n")}\n\n${comment}\n\n` -} - export const SessionPreviewView: Component = (props) => { const { t } = useI18n() - const [commentMode, setCommentMode] = createSignal(false) const target = () => new URL(props.preview.targetUrl) - async function handleCommentTarget(elementTarget: BrowserFrameElementTarget) { - const comment = await showPromptDialog(t("sessionPreview.comment.prompt"), { - title: t("sessionPreview.comment.title"), - inputLabel: t("sessionPreview.comment.label"), - confirmLabel: t("sessionPreview.comment.add"), - cancelLabel: t("sessionPreview.comment.cancel"), - }) - const normalized = comment?.trim() - if (!normalized) return - props.onInsertComment(buildCommentMarkdown(elementTarget, normalized)) - } - return (
@@ -67,12 +34,7 @@ export const SessionPreviewView: Component = (props) =>
= (props) => viewportTabletLandscape: t("browserFrame.viewport.tabletLandscape"), viewportMobile: t("browserFrame.viewport.mobile"), viewportMobileLandscape: t("browserFrame.viewport.mobileLandscape"), - commentMode: t("sessionPreview.comment.mode"), }} - commentMode={commentMode()} - onToggleCommentMode={() => setCommentMode((value) => !value)} - onCommentTarget={(target) => void handleCommentTarget(target)} />
) diff --git a/packages/ui/src/stores/instance-lifecycle-authority.ts b/packages/ui/src/stores/instance-lifecycle-authority.ts index c939ec082..ec055b328 100644 --- a/packages/ui/src/stores/instance-lifecycle-authority.ts +++ b/packages/ui/src/stores/instance-lifecycle-authority.ts @@ -1,3 +1,5 @@ +import { messageStoreBus } from "./message-v2/bus" + export interface InstanceLifecycleAuthorityEvent { type: "removed" | "opened" | "unavailable" instanceId: string @@ -6,6 +8,19 @@ export interface InstanceLifecycleAuthorityEvent { } const listeners = new Set<(event: InstanceLifecycleAuthorityEvent) => void>() +const generations = new Map() + +messageStoreBus.onInstanceDestroyed((instanceId) => { + generations.set(instanceId, getInstanceLifecycleGeneration(instanceId) + 1) +}) + +export function getInstanceLifecycleGeneration(instanceId: string): number { + return generations.get(instanceId) ?? 0 +} + +export function isInstanceLifecycleCurrent(instanceId: string, generation: number): boolean { + return getInstanceLifecycleGeneration(instanceId) === generation +} export function onInstanceLifecycleAuthority( listener: (event: InstanceLifecycleAuthorityEvent) => void, diff --git a/packages/ui/src/stores/instances.ts b/packages/ui/src/stores/instances.ts index 6fcebecfa..239632088 100644 --- a/packages/ui/src/stores/instances.ts +++ b/packages/ui/src/stores/instances.ts @@ -37,6 +37,7 @@ import { fetchCommands, clearCommands } from "./commands" import { serverSettings } from "./preferences" import { reconcileSessionPendingState, + getSessionRoot, sessions, setSessionPendingPermission, setSessionPendingQuestion, @@ -217,7 +218,7 @@ async function withInterruptionWorkspace( sessionId: string, operation: (workspace: { workspace?: string }) => Promise, ): Promise { - if (sessions().get(instanceId)?.has(sessionId)) { + if (getSessionRoot(instanceId, sessionId)) { const { withSessionWorkspace } = await import("./session-worktree-binding") return withSessionWorkspace(instanceId, sessionId, operation, { migrateLegacy: false }) } @@ -228,7 +229,7 @@ async function withInterruptionWorkspace( getRootClient(instanceId).session.list({ scope: "project", limit: INTERRUPTION_SESSION_LIST_LIMIT }), "session.list", ) - if (sessions().get(instanceId)?.has(sessionId)) { + if (getSessionRoot(instanceId, sessionId)) { const { withSessionWorkspace } = await import("./session-worktree-binding") return withSessionWorkspace(instanceId, sessionId, operation, { migrateLegacy: false }) } diff --git a/packages/ui/src/stores/opencode-workspaces.ts b/packages/ui/src/stores/opencode-workspaces.ts index 1723f166a..015e6e633 100644 --- a/packages/ui/src/stores/opencode-workspaces.ts +++ b/packages/ui/src/stores/opencode-workspaces.ts @@ -108,9 +108,11 @@ async function reloadOpenCodeWorkspaces(instanceId: string): Promise { await syncOpenCodeWorkspaces(instanceId) } -async function reloadOpenCodeWorkspacesStrict(instanceId: string): Promise { +async function reloadOpenCodeWorkspacesStrict(instanceId: string, isCurrent: () => boolean = () => true): Promise { await workspaceSyncs.get(instanceId) - workspaceIdByWorktreeSlug.set(instanceId, await loadOpenCodeWorkspaces(instanceId)) + if (!isCurrent()) return + const workspaces = await loadOpenCodeWorkspaces(instanceId) + if (isCurrent()) workspaceIdByWorktreeSlug.set(instanceId, workspaces) } async function getOpenCodeWorkspaceIdForWorktree(instanceId: string, slug: string): Promise { diff --git a/packages/ui/src/stores/permission-lifecycle.test.ts b/packages/ui/src/stores/permission-lifecycle.test.ts index 8f038ea78..8bbc576da 100644 --- a/packages/ui/src/stores/permission-lifecycle.test.ts +++ b/packages/ui/src/stores/permission-lifecycle.test.ts @@ -3,6 +3,7 @@ import { afterEach, test } from "node:test" import type { OpencodeClient } from "@opencode-ai/sdk/v2/client" import { sdkManager } from "../lib/sdk-manager" import type { Instance } from "../types/instance" +import type { Session } from "../types/session" import { addInstance, addPermissionToQueue, @@ -12,11 +13,13 @@ import { getPermissionQueue, getQuestionQueue, sendPermissionResponse, + sendQuestionReply, syncPendingRequests, updateInstance, } from "./instances" import { messageStoreBus } from "./message-v2/bus" import { handlePermissionUpdated } from "./session-events" +import { setSessions } from "./session-state" const instanceIds: string[] = [] const originalCreateClient = sdkManager.createClient @@ -38,6 +41,7 @@ afterEach(() => { for (const instanceId of instanceIds.splice(0)) { clearPermissionQueue(instanceId) clearQuestionQueue(instanceId) + setSessions((prev) => { const next = new Map(prev); next.delete(instanceId); return next }) messageStoreBus.unregisterInstance(instanceId) sdkManager.destroyClientsForInstance(instanceId) } @@ -132,6 +136,46 @@ test("early legacy permission resolves its authoritative non-root workspace", as assert.deepEqual(replies, [{ requestID: "permission", workspace: "workspace-feature", reply: "once" }]) }) +test("missing-parent interruptions use authoritative list routing", async () => { + const permissionReplies: unknown[] = [] + const questionReplies: unknown[] = [] + let lists = 0 + const client = { + session: { + list: async () => { + lists += 1 + return { data: [{ id: "child", parentID: "missing-parent", directory: "/workspace-feature", workspaceID: "workspace-feature" }] } + }, + }, + permission: { reply: async (parameters: unknown) => { permissionReplies.push(parameters); return { data: true } } }, + question: { reply: async (parameters: unknown) => { questionReplies.push(parameters); return { data: true } } }, + v2: { + session: { + permission: { reply: async () => ({ data: true }) }, + question: { reply: async () => ({ data: true }) }, + }, + }, + } as unknown as OpencodeClient + sdkManager.createClient = (() => client) as typeof sdkManager.createClient + addTestInstance("missing-parent-interruptions", client) + const child = { + id: "child", instanceId: "missing-parent-interruptions", parentId: "missing-parent", title: "child", + agent: "build", model: { providerId: "provider", modelId: "model" }, status: "idle", retry: null, + idleSince: null, generationRecovery: null, runtimeStatusKnown: true, version: "1", + time: { created: 1, updated: 1 }, directory: "/workspace-feature", workspaceId: "workspace-feature", + } satisfies Session + setSessions((prev) => new Map(prev).set("missing-parent-interruptions", new Map([[child.id, child]]))) + addPermissionToQueue("missing-parent-interruptions", { id: "permission", sessionID: child.id } as never, "legacy") + addQuestionToQueue("missing-parent-interruptions", { id: "question", sessionID: child.id, questions: [] } as never, "legacy") + + await sendPermissionResponse("missing-parent-interruptions", child.id, "permission", "once") + await sendQuestionReply("missing-parent-interruptions", child.id, "question", [["answer"]]) + + assert.equal(lists, 2) + assert.deepEqual(permissionReplies, [{ requestID: "permission", workspace: "workspace-feature", reply: "once" }]) + assert.deepEqual(questionReplies, [{ requestID: "question", workspace: "workspace-feature", answers: [["answer"]] }]) +}) + test("pending request sync cannot erase newer SSE mutations", async () => { const newPermission = { id: "new-permission", sessionID: "session", permission: "edit", patterns: ["*"], metadata: {}, diff --git a/packages/ui/src/stores/session-api.ts b/packages/ui/src/stores/session-api.ts index 3420d06fd..e89067fd6 100644 --- a/packages/ui/src/stores/session-api.ts +++ b/packages/ui/src/stores/session-api.ts @@ -93,6 +93,7 @@ import { mergeFetchedSessionRuntimeState, resolveAuthoritativeGenerationRecovery import { normalizeWorkspacePath } from "./app-session-reconciliation" import { withSessionWorkspace } from "./session-worktree-binding" import { getSessionLocationEpoch, markAuthoritativeSessionLocation } from "./session-location-authority" +import { getInstanceLifecycleGeneration, isInstanceLifecycleCurrent } from "./instance-lifecycle-authority" const log = getLogger("api") const sessionListRequestIds = new Map() @@ -429,7 +430,12 @@ async function hydrateRestoredSessionChain( } } -async function ensureV2ParentChainsLoaded(instanceId: string, apiSessions: SDKSession[], directory?: string): Promise { +async function ensureV2ParentChainsLoaded( + instanceId: string, + apiSessions: SDKSession[], + directory?: string, + isCurrent: () => boolean = () => true, +): Promise { const currentSessions = sessions().get(instanceId) ?? new Map() const loaded = new Map(currentSessions) for (const session of apiSessions) loaded.set(session.id, session) @@ -438,6 +444,7 @@ async function ensureV2ParentChainsLoaded(instanceId: string, apiSessions: SDKSe const locationEpochs = new Map([...currentSessions.keys()].map((id) => [id, getSessionLocationEpoch(instanceId, id)])) const page = await fetchV2Sessions(instanceId, { directory }) + if (!isCurrent()) return const items = getV2SessionItems(page) if (items.length === 0) return @@ -702,7 +709,10 @@ async function searchSessions(instanceId: string, query: string): Promise throw new Error("Instance not ready") } + const generation = getInstanceLifecycleGeneration(instanceId) const requestId = beginSessionSearch(instanceId, trimmedQuery) + const isCurrent = () => isInstanceLifecycleCurrent(instanceId, generation) + && isLatestSessionSearch(instanceId, trimmedQuery, requestId) const locationEpochs = new Map([...(sessions().get(instanceId)?.keys() ?? [])].map((id) => [ id, getSessionLocationEpoch(instanceId, id), @@ -714,7 +724,7 @@ async function searchSessions(instanceId: string, query: string): Promise search: trimmedQuery, directory: instance.folder, }) - if (!isLatestSessionSearch(instanceId, trimmedQuery, requestId)) return + if (!isCurrent()) return const searchResults = getV2SessionItems(response) @@ -746,9 +756,9 @@ async function searchSessions(instanceId: string, query: string): Promise }) void hydrateMissingSessionMetadata(instanceId, searchResults.map((session) => session.id)) - await ensureV2ParentChainsLoaded(instanceId, searchResults, instance.folder) + await ensureV2ParentChainsLoaded(instanceId, searchResults, instance.folder, isCurrent) - if (!isLatestSessionSearch(instanceId, trimmedQuery, requestId)) return + if (!isCurrent()) return const hydratedSessions = sessions().get(instanceId) const deletedSessionIds = getAuthoritativelyDeletedSessionIdsForInstance(instanceId) @@ -769,7 +779,7 @@ async function searchSessions(instanceId: string, query: string): Promise setSessionSearchResults(instanceId, trimmedQuery, currentSearchResults.map((session) => session.id), requestId) } catch (error) { log.error("Failed to search sessions:", error) - if (isLatestSessionSearch(instanceId, trimmedQuery, requestId)) { + if (isCurrent()) { clearSessionSearch(instanceId) } throw error diff --git a/packages/ui/src/stores/session-events.ts b/packages/ui/src/stores/session-events.ts index f540afdc9..521690ccc 100644 --- a/packages/ui/src/stores/session-events.ts +++ b/packages/ui/src/stores/session-events.ts @@ -422,9 +422,11 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo const staleLocation = hasLocation && Boolean(existingSession) && isStaleSessionLocation(instanceId, info.id, { directory: info.directory ?? existingSession?.directory, workspaceId, - updated: info.time?.updated, }) - if (hasLocation && !staleLocation) { + const confirmsCurrentLocation = hasLocation && Boolean(existingSession) + && (info.directory ?? existingSession?.directory) === existingSession?.directory + && workspaceId === existingSession?.workspaceId + if (hasLocation && !staleLocation && !confirmsCurrentLocation) { markAuthoritativeSessionLocation(instanceId, info.id) forgetOpenCodeWorkspaceIdForSession(instanceId, info.id) if (workspaceId) rememberOpenCodeWorkspaceIdForSession(instanceId, info.id, workspaceId) diff --git a/packages/ui/src/stores/session-location-authority.ts b/packages/ui/src/stores/session-location-authority.ts index 2bb9c5c23..d1ddf3600 100644 --- a/packages/ui/src/stores/session-location-authority.ts +++ b/packages/ui/src/stores/session-location-authority.ts @@ -3,7 +3,6 @@ import { messageStoreBus } from "./message-v2/bus" const epochs = new Map() const stalePredecessors = new Map() @@ -30,7 +29,6 @@ function commitAuthoritativeSessionLocation( markAuthoritativeSessionLocation(instanceId, sessionId) stalePredecessors.set(key(instanceId, sessionId), { epoch: getSessionLocationEpoch(instanceId, sessionId), - committedAt: Date.now(), directory: previous.directory, workspaceId: previous.workspaceId, }) @@ -39,13 +37,12 @@ function commitAuthoritativeSessionLocation( function isStaleSessionLocation( instanceId: string, sessionId: string, - location: { directory?: string; workspaceId?: string; updated?: number }, + location: { directory?: string; workspaceId?: string }, ): boolean { const sessionKey = key(instanceId, sessionId) const stale = stalePredecessors.get(sessionKey) - if (!stale || stale.epoch !== getSessionLocationEpoch(instanceId, sessionId) - || stale.directory !== location.directory || stale.workspaceId !== location.workspaceId) return false - return location.updated === undefined || location.updated <= stale.committedAt + return Boolean(stale && stale.epoch === getSessionLocationEpoch(instanceId, sessionId) + && stale.directory === location.directory && stale.workspaceId === location.workspaceId) } messageStoreBus.onInstanceDestroyed((instanceId) => { diff --git a/packages/ui/src/stores/session-request-authority.test.ts b/packages/ui/src/stores/session-request-authority.test.ts index f3fe52691..17ac68f08 100644 --- a/packages/ui/src/stores/session-request-authority.test.ts +++ b/packages/ui/src/stores/session-request-authority.test.ts @@ -336,6 +336,27 @@ describe("session request authority", () => { } }) + it("does not reuse search authority after an instance reopens", async () => { + const instanceId = "reopened-session-search" + const { client, cleanup } = setup(instanceId) + const oldResponse = deferred() + ;(client.session as any).list = () => oldResponse.promise + + try { + const oldSearch = searchSessions(instanceId, "old") + removeInstance(instanceId, { authoritative: false }) + addInstance({ id: instanceId, folder: "/work", port: 0, pid: 0, proxyPath: "", status: "ready", client }) + setSessions((prev) => new Map(prev).set(instanceId, new Map())) + oldResponse.resolve({ data: [apiSession("old-result")] }) + await oldSearch + + assert.equal(sessions().get(instanceId)?.has("old-result") ?? false, false) + assert.deepEqual(getSessionSearchResultIds(instanceId), []) + } finally { + cleanup() + } + }) + it("keeps a newer load authoritative when an older request finishes last", async () => { const instanceId = "newer-message-load", sessionId = "session" const { client, cleanup } = setup(instanceId) diff --git a/packages/ui/src/stores/session-state.ts b/packages/ui/src/stores/session-state.ts index 3ca8efa97..e11d3e7e3 100644 --- a/packages/ui/src/stores/session-state.ts +++ b/packages/ui/src/stores/session-state.ts @@ -365,6 +365,7 @@ function invalidateSessionMessageLoad(instanceId: string, sessionId: string): vo } messageStoreBus.onSessionCleared(invalidateSessionMessageLoad) +messageStoreBus.onInstanceDestroyed(clearSessionSearch) function getDraftKey(instanceId: string, sessionId: string): string { return `${instanceId}:${sessionId}` diff --git a/packages/ui/src/stores/session-worktree-binding.test.ts b/packages/ui/src/stores/session-worktree-binding.test.ts index aa96f680c..26351e29c 100644 --- a/packages/ui/src/stores/session-worktree-binding.test.ts +++ b/packages/ui/src/stores/session-worktree-binding.test.ts @@ -252,6 +252,16 @@ describe("session worktree binding", () => { try { await moveSessionToWorktree(instanceId, root.id, "feature") + handleSessionUpdate(instanceId, { + properties: { info: { + id: root.id, + directory: "/repo-feature", + workspaceID: "workspace-feature", + title: root.title, + version: root.version, + time: { created: 1, updated: 100 }, + } }, + } as any) handleSessionUpdate(instanceId, { properties: { info: { id: root.id, @@ -269,13 +279,13 @@ describe("session worktree binding", () => { handleSessionUpdate(instanceId, { properties: { info: { id: root.id, - directory: "/repo", + directory: "/repo-newer", title: root.title, version: root.version, - time: { created: 1, updated: Date.now() + 1_000 }, + time: { created: 1, updated: 3 }, } }, } as any) - assert.equal(sessions().get(instanceId)?.get(root.id)?.directory, "/repo") + assert.equal(sessions().get(instanceId)?.get(root.id)?.directory, "/repo-newer") } finally { cleanup() } @@ -454,6 +464,34 @@ describe("session worktree binding", () => { } }) + it("abandons a running move when the same instance ID reconnects", async () => { + const instanceId = "reconnected-running-move" + const response = deferred() + const started = deferred() + const cleanup = await setup(instanceId, { + move: async () => { started.resolve(); return response.promise }, + }) + const root = session(instanceId, "root-session", null) + const child = session(instanceId, "child-session", root.id) + setFamily(instanceId, root, child) + + try { + const move = moveSessionToWorktree(instanceId, root.id, "feature") + const rejected = assert.rejects(move) + await started.promise + messageStoreBus.unregisterInstance(instanceId) + setFamily(instanceId, { ...root, directory: "/reopened" }, { ...child, directory: "/reopened" }) + response.resolve(moveResponse("feature")) + await rejected + + assert.equal(sessions().get(instanceId)?.get(root.id)?.directory, "/reopened") + assert.equal(sessions().get(instanceId)?.get(root.id)?.workspaceId, undefined) + } finally { + response.resolve(moveResponse("feature")) + cleanup() + } + }) + it("creates from the active family while holding its read lease", async () => { const instanceId = "create-family-read-lease" const createPending = deferred() diff --git a/packages/ui/src/stores/session-worktree-binding.ts b/packages/ui/src/stores/session-worktree-binding.ts index c537539f1..84fd9d463 100644 --- a/packages/ui/src/stores/session-worktree-binding.ts +++ b/packages/ui/src/stores/session-worktree-binding.ts @@ -10,6 +10,7 @@ import { getDescendantSessions, getSessionRoot, sessions, withSession } from "./ import { commitAuthoritativeSessionLocation, getSessionLocationEpoch } from "./session-location-authority" import { clearLocalSessionWorktreeSlug } from "./session-metadata" import { messageStoreBus } from "./message-v2/bus" +import { getInstanceLifecycleGeneration, isInstanceLifecycleCurrent } from "./instance-lifecycle-authority" import { getWorktreeSlugForSession, getWorktrees, @@ -18,7 +19,6 @@ import { } from "./worktrees" const familyLeases = new Map() -const instanceLeaseGenerations = new Map() type WorkspacePayload = { workspace?: string } type FamilyLeaseState = { @@ -29,16 +29,11 @@ type FamilyLeaseState = { type FamilyReadLease = { release: () => void; upgrade: () => Promise } type FamilyWriteLease = { release: () => void } -function instanceLeaseGeneration(instanceId: string): number { - return instanceLeaseGenerations.get(instanceId) ?? 0 -} - function familyLeaseKey(instanceId: string, rootId: string): string { - return `${instanceId}:${instanceLeaseGeneration(instanceId)}:${rootId}` + return `${instanceId}:${getInstanceLifecycleGeneration(instanceId)}:${rootId}` } messageStoreBus.onInstanceDestroyed((instanceId) => { - instanceLeaseGenerations.set(instanceId, instanceLeaseGeneration(instanceId) + 1) const prefix = `${instanceId}:` for (const [key, state] of familyLeases) { if (!key.startsWith(prefix)) continue @@ -69,6 +64,12 @@ async function targetLocation(instanceId: string, slug: string): Promise<{ direc } async function moveSessionFamily(instanceId: string, sessionId: string, slug: string): Promise { + const lifecycleGeneration = getInstanceLifecycleGeneration(instanceId) + const assertCurrentLifecycle = () => { + if (!isInstanceLifecycleCurrent(instanceId, lifecycleGeneration)) { + throw new Error(tGlobal("instanceShell.worktree.moveFailed")) + } + } const root = getSessionRoot(instanceId, sessionId) if (!root) throw new Error(tGlobal("instanceShell.worktree.sessionNotFound")) if (!slug.trim()) throw new Error(tGlobal("instanceShell.worktree.moveFailed")) @@ -88,6 +89,7 @@ async function moveSessionFamily(instanceId: string, sessionId: string, slug: st getSessionLocationEpoch(instanceId, member.id), ])) const moved = await serverApi.moveWorktreeSessionFamily(instanceId, root.id, { worktreeSlug: slug }) + assertCurrentLifecycle() if (moved.rootSessionId !== root.id || moved.worktreeSlug !== slug || !Array.isArray(moved.sessions) || !moved.sessions.some((location) => location.sessionId === root.id)) { throw new Error(tGlobal("instanceShell.worktree.moveFailed")) @@ -104,8 +106,11 @@ async function moveSessionFamily(instanceId: string, sessionId: string, slug: st )) if (stale) { const { fetchSessions } = await import("./session-api") + assertCurrentLifecycle() await fetchSessions(instanceId, { reset: false, strictStatus: true }) + assertCurrentLifecycle() await reloadWorktreeMap(instanceId) + assertCurrentLifecycle() return false } for (const location of moved.sessions) { @@ -118,6 +123,7 @@ async function moveSessionFamily(instanceId: string, sessionId: string, slug: st commitAuthoritativeSessionLocation(instanceId, location.sessionId, before.get(location.sessionId) ?? {}) } await reloadWorktreeMap(instanceId) + assertCurrentLifecycle() return moved.sessions.some((location) => { const previous = before.get(location.sessionId) return !previous diff --git a/packages/ui/src/stores/worktree-deletion.test.ts b/packages/ui/src/stores/worktree-deletion.test.ts index a147670e3..4e493b5ce 100644 --- a/packages/ui/src/stores/worktree-deletion.test.ts +++ b/packages/ui/src/stores/worktree-deletion.test.ts @@ -198,7 +198,7 @@ describe("renderer worktree deletion reconciliation", () => { experimental: { workspace: { syncList: async () => ({ data: [] }), list: async () => { - if (workspaceLists++ < 2) throw new Error("workspace refresh failed") + if (workspaceLists++ < 3) throw new Error("workspace refresh failed") return { data: [] } }, } }, @@ -216,11 +216,11 @@ describe("renderer worktree deletion reconciliation", () => { assert.equal(isWorktreeDeletionInProgress(instanceId, "feature"), true) await assert.rejects(() => withSessionWorkspace(instanceId, existing.id, async (workspace) => workspace)) - for (let attempt = 0; attempt < 20 && isWorktreeDeletionInProgress(instanceId, "feature"); attempt++) { - await new Promise((resolve) => setTimeout(resolve, 5)) + for (let attempt = 0; attempt < 40 && isWorktreeDeletionInProgress(instanceId, "feature"); attempt++) { + await new Promise((resolve) => setTimeout(resolve, 10)) } assert.equal(isWorktreeDeletionInProgress(instanceId, "feature"), false) - assert.equal(workspaceLists, 3) + assert.equal(workspaceLists, 4) assert.deepEqual(await withSessionWorkspace(instanceId, existing.id, async (workspace) => workspace), {}) } finally { harness.restore() @@ -249,4 +249,42 @@ describe("renderer worktree deletion reconciliation", () => { harness.restore() } }) + + it("does not let an old deletion finalizer clear a reused instance guard", async () => { + const instanceId = "reused-deletion-guard" + const harness = setup(instanceId, client()) + let deleteCalls = 0 + let releaseOld!: () => void + let releaseNew!: () => void + let startOld!: () => void + let startNew!: () => void + const oldPending = new Promise((resolve) => { releaseOld = resolve }) + const newPending = new Promise((resolve) => { releaseNew = resolve }) + const oldStarted = new Promise((resolve) => { startOld = resolve }) + const newStarted = new Promise((resolve) => { startNew = resolve }) + serverApi.fetchWorktrees = async () => ({ worktrees: deleteCalls === 0 ? [root, feature] : [root], isGitRepo: true }) + serverApi.deleteWorktree = async () => { + deleteCalls += 1 + if (deleteCalls === 1) { startOld(); await oldPending } + else { startNew(); await newPending } + } + try { + await ensureWorktreesLoaded(instanceId) + const oldDeletion = deleteWorktree(instanceId, "feature") + await oldStarted + messageStoreBus.unregisterInstance(instanceId) + const newDeletion = deleteWorktree(instanceId, "feature") + await newStarted + releaseOld() + await oldDeletion + assert.equal(isWorktreeDeletionInProgress(instanceId, "feature"), true) + releaseNew() + await newDeletion + assert.equal(isWorktreeDeletionInProgress(instanceId, "feature"), false) + } finally { + releaseOld() + releaseNew() + harness.restore() + } + }) }) diff --git a/packages/ui/src/stores/worktrees.ts b/packages/ui/src/stores/worktrees.ts index 1f5412658..200acbb33 100644 --- a/packages/ui/src/stores/worktrees.ts +++ b/packages/ui/src/stores/worktrees.ts @@ -8,6 +8,7 @@ import type { WorktreeReadyEvent } from "../lib/sse-manager" import { findWorktreeSlugForDirectory } from "./opencode-workspace-matching" import { tGlobal } from "../lib/i18n" import { messageStoreBus } from "./message-v2/bus" +import { getInstanceLifecycleGeneration, isInstanceLifecycleCurrent } from "./instance-lifecycle-authority" const log = getLogger("api") @@ -24,18 +25,36 @@ const worktreeDeletionOperations = new Map>() const worktreeReconciliationQueues = new Map>() const worktreeReconciliationRetries = new Map>() +const worktreeReconciliationAttempts = new Map() let worktreeDeletionOperationId = 0 +const RECONCILIATION_RETRY_BASE_MS = 10 +const RECONCILIATION_RETRY_MAX_MS = 1_000 + +function lifecycleKey(instanceId: string, generation = getInstanceLifecycleGeneration(instanceId)): string { + return `${instanceId}:${generation}` +} + +function deletionKey(instanceId: string, generation: number, slug: string): string { + return `${lifecycleKey(instanceId, generation)}:${slug}` +} + messageStoreBus.onInstanceDestroyed((instanceId) => { - const retry = worktreeReconciliationRetries.get(instanceId) - if (retry !== undefined) clearTimeout(retry) - worktreeReconciliationRetries.delete(instanceId) - worktreeDeletionOperations.delete(instanceId) - worktreeDeletionQueues.delete(instanceId) - worktreeReconciliationQueues.delete(instanceId) - for (const key of worktreeDeletions) { - if (key.startsWith(`${instanceId}:`)) worktreeDeletions.delete(key) + const prefix = `${instanceId}:` + for (const [key, retry] of worktreeReconciliationRetries) { + if (!key.startsWith(prefix)) continue + clearTimeout(retry) + worktreeReconciliationRetries.delete(key) } + for (const collection of [ + worktreeDeletionOperations, + worktreeDeletionQueues, + worktreeReconciliationQueues, + worktreeReconciliationAttempts, + ]) { + for (const key of collection.keys()) if (key.startsWith(prefix)) collection.delete(key) + } + for (const key of worktreeDeletions) if (key.startsWith(prefix)) worktreeDeletions.delete(key) }) type WorktreeReadyRefresh = (instanceId: string) => Promise @@ -175,72 +194,89 @@ async function deleteWorktree(instanceId: string, slug: string, options?: { forc if (!trimmed || trimmed === "root") { throw new Error("Invalid worktree") } - const deletionKey = `${instanceId}:${trimmed}` - if (worktreeDeletions.has(deletionKey)) throw new Error(tGlobal("instanceShell.worktree.moveFailed")) + const generation = getInstanceLifecycleGeneration(instanceId) + const operationKey = lifecycleKey(instanceId, generation) + const guardKey = deletionKey(instanceId, generation, trimmed) + if (worktreeDeletions.has(guardKey)) throw new Error(tGlobal("instanceShell.worktree.moveFailed")) const operationId = ++worktreeDeletionOperationId - worktreeDeletions.add(deletionKey) - const operations = worktreeDeletionOperations.get(instanceId) ?? new Map() + worktreeDeletions.add(guardKey) + const operations = worktreeDeletionOperations.get(operationKey) ?? new Map() operations.set(operationId, { slug: trimmed, reconciling: false }) - worktreeDeletionOperations.set(instanceId, operations) + worktreeDeletionOperations.set(operationKey, operations) - const previous = worktreeDeletionQueues.get(instanceId) + const previous = worktreeDeletionQueues.get(operationKey) const task = (previous?.catch(() => undefined) ?? Promise.resolve()).then(async () => { + if (!isInstanceLifecycleCurrent(instanceId, generation)) return let deleteError: unknown try { await serverApi.deleteWorktree(instanceId, trimmed, options) } catch (error) { deleteError = error } + if (!isInstanceLifecycleCurrent(instanceId, generation)) return let inventory: WorktreeListResponse try { inventory = await serverApi.fetchWorktrees(instanceId) } catch (error) { - markWorktreeDeletionForReconciliation(instanceId, operationId) - scheduleWorktreeDeletionRetry(instanceId) + if (!isInstanceLifecycleCurrent(instanceId, generation)) return + markWorktreeDeletionForReconciliation(operationKey, operationId) + scheduleWorktreeDeletionRetry(instanceId, generation) throw deleteError ?? error } + if (!isInstanceLifecycleCurrent(instanceId, generation)) return if (inventory.worktrees.some((worktree) => worktree.slug === trimmed)) { - finishWorktreeDeletionOperations(instanceId, [operationId]) + finishWorktreeDeletionOperations(operationKey, [operationId]) throw deleteError ?? new Error(tGlobal("instanceShell.worktree.moveFailed")) } - markWorktreeDeletionForReconciliation(instanceId, operationId) - await reconcileOwnedWorktreeDeletions(instanceId, [operationId], inventory) + markWorktreeDeletionForReconciliation(operationKey, operationId) + await reconcileOwnedWorktreeDeletions(instanceId, generation, [operationId], inventory) }) - worktreeDeletionQueues.set(instanceId, task) + worktreeDeletionQueues.set(operationKey, task) try { await task } finally { - worktreeDeletions.delete(deletionKey) - if (worktreeDeletionQueues.get(instanceId) === task) worktreeDeletionQueues.delete(instanceId) + worktreeDeletions.delete(guardKey) + if (worktreeDeletionQueues.get(operationKey) === task) worktreeDeletionQueues.delete(operationKey) } } -function markWorktreeDeletionForReconciliation(instanceId: string, operationId: number): void { - const operation = worktreeDeletionOperations.get(instanceId)?.get(operationId) +function markWorktreeDeletionForReconciliation(operationKey: string, operationId: number): void { + const operation = worktreeDeletionOperations.get(operationKey)?.get(operationId) if (operation) operation.reconciling = true } -function finishWorktreeDeletionOperations(instanceId: string, operationIds: readonly number[]): void { - const operations = worktreeDeletionOperations.get(instanceId) +function finishWorktreeDeletionOperations(operationKey: string, operationIds: readonly number[]): void { + const operations = worktreeDeletionOperations.get(operationKey) if (!operations) return operationIds.forEach((id) => operations.delete(id)) - if (operations.size === 0) worktreeDeletionOperations.delete(instanceId) + if (operations.size === 0) { + worktreeDeletionOperations.delete(operationKey) + worktreeReconciliationAttempts.delete(operationKey) + const retry = worktreeReconciliationRetries.get(operationKey) + if (retry !== undefined) clearTimeout(retry) + worktreeReconciliationRetries.delete(operationKey) + } } -function scheduleWorktreeDeletionRetry(instanceId: string): void { - if (!worktreeDeletionOperations.get(instanceId)?.size) return - if (worktreeReconciliationRetries.has(instanceId)) return +function scheduleWorktreeDeletionRetry(instanceId: string, generation: number): void { + const operationKey = lifecycleKey(instanceId, generation) + if (!isInstanceLifecycleCurrent(instanceId, generation) || !worktreeDeletionOperations.get(operationKey)?.size) return + if (worktreeReconciliationRetries.has(operationKey)) return + const attempt = worktreeReconciliationAttempts.get(operationKey) ?? 0 + const delayMs = Math.min(RECONCILIATION_RETRY_BASE_MS * (2 ** attempt), RECONCILIATION_RETRY_MAX_MS) + worktreeReconciliationAttempts.set(operationKey, attempt + 1) const timer = setTimeout(() => { - if (worktreeReconciliationRetries.get(instanceId) !== timer) return - worktreeReconciliationRetries.delete(instanceId) + if (worktreeReconciliationRetries.get(operationKey) !== timer) return + worktreeReconciliationRetries.delete(operationKey) + if (!isInstanceLifecycleCurrent(instanceId, generation)) return void reconcileWorktreeDeletion(instanceId).catch((error) => { log.warn("Failed to retry worktree deletion reconciliation", { instanceId, error }) }) - }, 0) - worktreeReconciliationRetries.set(instanceId, timer) + }, delayMs) + worktreeReconciliationRetries.set(operationKey, timer) } function cacheWorktreeMap(instanceId: string, input: WorktreeMap): void { @@ -253,49 +289,64 @@ function cacheWorktreeMap(instanceId: string, input: WorktreeMap): void { } function isWorktreeDeletionInProgress(instanceId: string, slug: string): boolean { - return worktreeDeletions.has(`${instanceId}:${slug}`) || Boolean(worktreeDeletionOperations.get(instanceId)?.size) + const generation = getInstanceLifecycleGeneration(instanceId) + const operationKey = lifecycleKey(instanceId, generation) + return worktreeDeletions.has(deletionKey(instanceId, generation, slug)) || Boolean(worktreeDeletionOperations.get(operationKey)?.size) } async function reconcileWorktreeDeletion(instanceId: string, knownInventory?: WorktreeListResponse): Promise { - const operationIds = [...(worktreeDeletionOperations.get(instanceId) ?? [])] + const generation = getInstanceLifecycleGeneration(instanceId) + const operationKey = lifecycleKey(instanceId, generation) + const operationIds = [...(worktreeDeletionOperations.get(operationKey) ?? [])] .filter(([, operation]) => operation.reconciling) .map(([id]) => id) if (operationIds.length === 0) return - await reconcileOwnedWorktreeDeletions(instanceId, operationIds, knownInventory) + await reconcileOwnedWorktreeDeletions(instanceId, generation, operationIds, knownInventory) } async function reconcileOwnedWorktreeDeletions( instanceId: string, + generation: number, operationIds: readonly number[], knownInventory?: WorktreeListResponse, ): Promise { - const previous = worktreeReconciliationQueues.get(instanceId) + const operationKey = lifecycleKey(instanceId, generation) + const previous = worktreeReconciliationQueues.get(operationKey) const task = (previous?.catch(() => undefined) ?? Promise.resolve()).then(async () => { - const owned = operationIds.filter((id) => worktreeDeletionOperations.get(instanceId)?.get(id)?.reconciling) + if (!isInstanceLifecycleCurrent(instanceId, generation)) return + const owned = operationIds.filter((id) => worktreeDeletionOperations.get(operationKey)?.get(id)?.reconciling) if (owned.length === 0) return const inventory = previous ? await serverApi.fetchWorktrees(instanceId) : knownInventory ?? await serverApi.fetchWorktrees(instanceId) + if (!isInstanceLifecycleCurrent(instanceId, generation)) return applyWorktreeInventory(instanceId, inventory) const failures: unknown[] = [] const { clearOpenCodeWorkspaceCache, reloadOpenCodeWorkspacesStrict } = await import("./opencode-workspaces") - await reloadOpenCodeWorkspacesStrict(instanceId).catch((error) => { + if (!isInstanceLifecycleCurrent(instanceId, generation)) return + const isCurrent = () => isInstanceLifecycleCurrent(instanceId, generation) + await reloadOpenCodeWorkspacesStrict(instanceId, isCurrent).catch((error) => { + if (!isCurrent()) return clearOpenCodeWorkspaceCache(instanceId) failures.push(error) }) - await reloadWorktreeMap(instanceId, { strict: true }).catch((error) => failures.push(error)) + if (!isCurrent()) return + await reloadWorktreeMap(instanceId, { strict: true, isCurrent }).catch((error) => failures.push(error)) + if (!isInstanceLifecycleCurrent(instanceId, generation)) return const { fetchSessions } = await import("./session-api") + if (!isInstanceLifecycleCurrent(instanceId, generation)) return await fetchSessions(instanceId, { reset: false, strictStatus: true }).catch((error) => failures.push(error)) + if (!isInstanceLifecycleCurrent(instanceId, generation)) return if (failures.length === 1) throw failures[0] if (failures.length > 1) throw new Error("Worktree deletion reconciliation failed") - finishWorktreeDeletionOperations(instanceId, owned) + finishWorktreeDeletionOperations(operationKey, owned) }) - worktreeReconciliationQueues.set(instanceId, task) + worktreeReconciliationQueues.set(operationKey, task) try { await task } catch (error) { - scheduleWorktreeDeletionRetry(instanceId) + scheduleWorktreeDeletionRetry(instanceId, generation) throw error } finally { - if (worktreeReconciliationQueues.get(instanceId) === task) worktreeReconciliationQueues.delete(instanceId) + if (worktreeReconciliationQueues.get(operationKey) === task) worktreeReconciliationQueues.delete(operationKey) } } @@ -332,14 +383,19 @@ async function ensureWorktreeMapLoaded(instanceId: string): Promise { return task } -async function reloadWorktreeMap(instanceId: string, options: { strict?: boolean } = {}): Promise { +async function reloadWorktreeMap( + instanceId: string, + options: { strict?: boolean; isCurrent?: () => boolean } = {}, +): Promise { if (!instanceId) return await serverApi .readWorktreeMap(instanceId) .then((map) => { + if (options.isCurrent && !options.isCurrent()) return cacheWorktreeMap(instanceId, map) }) .catch((error) => { + if (options.isCurrent && !options.isCurrent()) return log.warn("Failed to reload worktree map", { instanceId, error }) if (options.strict) throw error }) From b22c61d6331da406af7dd38a1b3a75d3aeb63a87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Mon, 10 Aug 2026 23:01:45 +0200 Subject: [PATCH 15/20] fix(worktrees): close cross-host authority gaps Keep workspace mutations, leases, and native directory opens authoritative across concurrent processes and Windows/WSL hosts. Control replies can now complete synchronous prompts without weakening admission for unrelated mutations, while confirmed deletions reconcile asynchronously. Use deterministic private state roots, host-aware heartbeat fences, canonical missing-path locks, strict shared-map migration, monotone session location updates, and document-bound native capabilities. Bound Electron and Tauri directory lookups and close incomplete proxy uploads safely. Cover lock cleanup and foreign-host expiry, clone admission, map conflicts, rapid session moves, background deletion reconciliation, native capability checks, and request deadlines. Validated server, UI, Electron, and Tauri suites plus typechecks and the UI build. --- .../electron/main/client-state.test.ts | 4 +- packages/electron-app/electron/main/ipc.ts | 7 +- packages/electron-app/electron/main/main.ts | 2 +- .../electron/main/worktree-directory.test.ts | 12 + .../electron/main/worktree-directory.ts | 40 ++- .../electron-app/electron/preload/index.cjs | 2 +- .../src/permissions/opencode-replier.test.ts | 82 +++++- .../src/permissions/opencode-replier.ts | 52 ++-- packages/server/src/server/http-server.ts | 15 +- .../src/server/instance-mutation-body.test.ts | 37 ++- .../server/instance-mutation-proxy.test.ts | 250 ++++++++++++++++++ .../src/server/instance-mutation-proxy.ts | 64 +++-- .../src/server/routes/workspaces.test.ts | 36 ++- .../server/src/server/routes/workspaces.ts | 2 +- .../src/server/routes/worktrees.test.ts | 29 ++ .../server/src/server/routes/worktrees.ts | 20 +- .../__tests__/git-worktrees.test.ts | 15 +- .../src/workspaces/__tests__/spawn.test.ts | 7 + .../__tests__/workspace-identity.test.ts | 38 +++ .../workspaces/__tests__/worktree-map.test.ts | 35 +++ packages/server/src/workspaces/git-output.ts | 23 ++ .../server/src/workspaces/git-worktrees.ts | 25 +- .../server/src/workspaces/manager.test.ts | 67 +++++ packages/server/src/workspaces/manager.ts | 36 ++- .../src/workspaces/process-identity.test.ts | 14 +- .../server/src/workspaces/process-identity.ts | 58 +++- .../workspaces/repository-lock-ownership.ts | 128 ++++++++- .../repository-mutation-lock.test.ts | 151 ++++++++++- .../workspaces/repository-mutation-lock.ts | 92 +++++-- packages/server/src/workspaces/spawn.ts | 5 +- .../server/src/workspaces/state-root.test.ts | 36 +++ packages/server/src/workspaces/state-root.ts | 30 +++ .../src/workspaces/workspace-identity.ts | 72 +++-- .../workspaces/workspace-lifetime-lease.ts | 58 +++- .../src/workspaces/worktree-deletion.test.ts | 23 +- .../src/workspaces/worktree-deletion.ts | 10 +- .../server/src/workspaces/worktree-map.ts | 62 ++++- .../src-tauri/src/worktree_directory.rs | 90 +++++-- packages/ui/src/components/browser-frame.tsx | 6 +- .../src/components/preview-isolation.test.ts | 11 +- .../ui/src/components/preview-isolation.ts | 10 - .../ui/src/components/worktree-selector.tsx | 27 +- .../hooks/foreground-refresh-controller.ts | 2 +- .../lib/hooks/use-foreground-refresh.test.ts | 31 +++ packages/ui/src/lib/native/client-state.ts | 11 +- .../ui/src/lib/native/native-functions.ts | 17 +- packages/ui/src/stores/client-state.test.ts | 11 +- packages/ui/src/stores/session-api.ts | 2 +- packages/ui/src/stores/session-events.ts | 39 ++- .../src/stores/session-location-authority.ts | 87 ++++-- .../stores/session-worktree-binding.test.ts | 59 ++++- .../ui/src/stores/session-worktree-binding.ts | 8 +- .../ui/src/stores/worktree-deletion.test.ts | 22 +- packages/ui/src/stores/worktrees.ts | 8 +- packages/ui/src/types/global.d.ts | 2 +- 55 files changed, 1758 insertions(+), 324 deletions(-) create mode 100644 packages/server/src/workspaces/git-output.ts create mode 100644 packages/server/src/workspaces/state-root.test.ts create mode 100644 packages/server/src/workspaces/state-root.ts diff --git a/packages/electron-app/electron/main/client-state.test.ts b/packages/electron-app/electron/main/client-state.test.ts index 18ee8d3ab..f8c621d69 100644 --- a/packages/electron-app/electron/main/client-state.test.ts +++ b/packages/electron-app/electron/main/client-state.test.ts @@ -29,13 +29,15 @@ function harness(t: test.TestContext, initial?: object) { return { create, directory, statePath, fail: (value: boolean) => { failing = value }, writes: () => writes } } -test("renderer access is exclusive per document and resettable", async (t) => { +test("renderer capability rejects missing, wrong, and stale document tokens", async (t) => { const manager = harness(t, { version: 1, restoreEnabled: true }).create() assert.throws(() => manager.claimClientStateAccess(""), /nonempty string/) + assert.throws(() => manager.assertRendererAccessToken(undefined), /nonempty string/) assert.throws(() => manager.assertRendererAccessToken("unclaimed"), /has not been claimed/) assert.equal(manager.claimClientStateAccess("document-1"), true) assert.equal(manager.claimClientStateAccess("document-1"), true) assert.throws(() => manager.claimClientStateAccess("document-2"), /does not match/) + assert.throws(() => manager.assertRendererAccessToken("document-2"), /has not been claimed/) manager.assertRendererAccessToken("document-1") assert.equal(await manager.saveClientState({ saved: true }), true) manager.resetRendererAccessToken() diff --git a/packages/electron-app/electron/main/ipc.ts b/packages/electron-app/electron/main/ipc.ts index f5db5352a..d18d9f6e8 100644 --- a/packages/electron-app/electron/main/ipc.ts +++ b/packages/electron-app/electron/main/ipc.ts @@ -1,5 +1,6 @@ import { BrowserWindow, Notification, dialog, ipcMain, powerSaveBlocker, shell, type OpenDialogOptions } from "electron" import fs from "fs" +import type { ClientStateManager } from "./client-state" import { requestMicrophoneAccess } from "./permissions" import type { CliProcessManager, CliStatus } from "./process-manager" import { isManagedMainFrame, openManagedWorktreeDirectory } from "./worktree-directory" @@ -19,7 +20,7 @@ interface DialogOpenResult { paths: string[] } -export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessManager) { +export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessManager, clientStateManager: ClientStateManager) { cliManager.on("status", (status: CliStatus) => { if (!mainWindow.isDestroyed()) { mainWindow.webContents.send("cli:status", status) @@ -89,13 +90,14 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan return directories }) - ipcMain.handle("filesystem:openDirectory", async (event, instanceId: unknown, worktreeSlug: unknown): Promise<{ ok: boolean }> => { + ipcMain.handle("filesystem:openDirectory", async (event, accessToken: unknown, instanceId: unknown, worktreeSlug: unknown): Promise<{ ok: boolean }> => { const renderer = { sender: event.sender, senderFrame: event.senderFrame } const authority = await cliManager.captureReadyAuthority() const baseUrl = authority?.url if (!authority || !baseUrl || !isManagedMainFrame(renderer, mainWindow, baseUrl)) { throw new Error("Directory opening is unavailable from this renderer") } + clientStateManager.assertRendererAccessToken(accessToken) if (typeof instanceId !== "string" || typeof worktreeSlug !== "string") throw new Error("Invalid worktree") await openManagedWorktreeDirectory({ baseUrl, @@ -106,6 +108,7 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan if (!isManagedMainFrame(renderer, mainWindow, baseUrl)) { throw new Error("Directory opening is unavailable from this renderer") } + clientStateManager.assertRendererAccessToken(accessToken) return shell.openPath(directory) }), }) diff --git a/packages/electron-app/electron/main/main.ts b/packages/electron-app/electron/main/main.ts index f90fb3611..b1ba7534a 100644 --- a/packages/electron-app/electron/main/main.ts +++ b/packages/electron-app/electron/main/main.ts @@ -446,7 +446,7 @@ function createWindow() { void navigationController.navigate((target) => target.webContents.reloadIgnoringCache()) }, }) - setupCliIPC(window, cliManager) + setupCliIPC(window, cliManager, clientStateManager) bindClientStateWindow(window) clientStateLifecycle.attachMainWindow(window, windowStateTracker) diff --git a/packages/electron-app/electron/main/worktree-directory.test.ts b/packages/electron-app/electron/main/worktree-directory.test.ts index b984792ca..a7fcf7c16 100644 --- a/packages/electron-app/electron/main/worktree-directory.test.ts +++ b/packages/electron-app/electron/main/worktree-directory.test.ts @@ -114,6 +114,18 @@ test("aborts directory lookup bodies at idle and total deadlines", async () => { } }) +test("starts the total directory lookup deadline before response headers", async () => { + const setup = options({ worktrees: [] }) + let signal: AbortSignal | undefined + setup.value.responseTotalTimeoutMs = 5 + setup.value.fetch = async (_url, init) => { + signal = init.signal + return await new Promise(() => {}) + } + await assert.rejects(openManagedWorktreeDirectory(setup.value), /Worktree lookup timed out/) + assert.equal(signal?.aborted, true) +}) + test("directory identity compares exact bigint device and inode values", () => { const roundedAsNumber = BigInt(Number(2n ** 60n + 1n)) assert.notEqual(roundedAsNumber, 2n ** 60n + 1n) diff --git a/packages/electron-app/electron/main/worktree-directory.ts b/packages/electron-app/electron/main/worktree-directory.ts index 61647baf3..cecfef52d 100644 --- a/packages/electron-app/electron/main/worktree-directory.ts +++ b/packages/electron-app/electron/main/worktree-directory.ts @@ -115,12 +115,33 @@ async function resolveManagedWorktreeDirectory(options: OpenManagedWorktreeOptio const endpoint = managedEndpoint(options.baseUrl, options.instanceId) const responseAbort = new AbortController() - const response = await options.fetch(endpoint.href, { - credentials: "include", - redirect: "manual", - headers: { Accept: "application/json" }, - signal: responseAbort.signal, - }) + const totalTimeoutMs = options.responseTotalTimeoutMs ?? WORKTREE_RESPONSE_TOTAL_TIMEOUT_MS + const deadlineAt = Date.now() + totalTimeoutMs + let responseTimer: ReturnType | undefined + let responseTimedOut = false + let response: FetchResponse + try { + response = await Promise.race([ + options.fetch(endpoint.href, { + credentials: "include", + redirect: "manual", + headers: { Accept: "application/json" }, + signal: responseAbort.signal, + }), + new Promise((_resolve, reject) => { + responseTimer = setTimeout(() => { + responseTimedOut = true + responseAbort.abort() + reject(new Error("Worktree lookup timed out")) + }, Math.max(1, totalTimeoutMs)) + }), + ]) + } catch (error) { + if (responseTimedOut) throw new Error("Worktree lookup timed out") + throw error + } finally { + if (responseTimer) clearTimeout(responseTimer) + } // Electron documents Response.url as unreliable; manual redirects plus an exact 200 status are authoritative. if (response.status !== 200 || response.redirected) { throw new Error("Worktree lookup failed") @@ -130,7 +151,7 @@ async function resolveManagedWorktreeDirectory(options: OpenManagedWorktreeOptio response, responseAbort, options.responseIdleTimeoutMs ?? WORKTREE_RESPONSE_IDLE_TIMEOUT_MS, - options.responseTotalTimeoutMs ?? WORKTREE_RESPONSE_TOTAL_TIMEOUT_MS, + deadlineAt, ) if (!body || typeof body !== "object" || !Array.isArray((body as { worktrees?: unknown }).worktrees)) { throw new Error("Malformed worktree response") @@ -163,7 +184,7 @@ async function readBoundedJson( response: FetchResponse, abort: AbortController, idleTimeoutMs: number, - totalTimeoutMs: number, + deadlineAt: number, ): Promise { const contentLength = response.headers.get("content-length") if (contentLength !== null) { @@ -177,9 +198,8 @@ async function readBoundedJson( const reader = response.body.getReader() const chunks: Uint8Array[] = [] let size = 0 - const startedAt = Date.now() while (true) { - const remainingMs = totalTimeoutMs - (Date.now() - startedAt) + const remainingMs = deadlineAt - Date.now() if (remainingMs <= 0) { abort.abort() void reader.cancel().catch(() => undefined) diff --git a/packages/electron-app/electron/preload/index.cjs b/packages/electron-app/electron/preload/index.cjs index 199a58793..342cd4201 100644 --- a/packages/electron-app/electron/preload/index.cjs +++ b/packages/electron-app/electron/preload/index.cjs @@ -26,7 +26,7 @@ const localElectronAPI = { restartCli: () => ipcRenderer.invoke("cli:restart"), openDialog: (options) => ipcRenderer.invoke("dialog:open", options), getDirectoryPaths: (paths) => ipcRenderer.invoke("filesystem:getDirectoryPaths", paths), - openDirectory: (instanceId, worktreeSlug) => ipcRenderer.invoke("filesystem:openDirectory", instanceId, worktreeSlug), + openDirectory: (accessToken, instanceId, worktreeSlug) => ipcRenderer.invoke("filesystem:openDirectory", accessToken, instanceId, worktreeSlug), getPathForFile: (file) => { try { return webUtils.getPathForFile(file) diff --git a/packages/server/src/permissions/opencode-replier.test.ts b/packages/server/src/permissions/opencode-replier.test.ts index 5dc89762c..535817c55 100644 --- a/packages/server/src/permissions/opencode-replier.test.ts +++ b/packages/server/src/permissions/opencode-replier.test.ts @@ -7,6 +7,7 @@ import type { AutoAcceptReply } from "./auto-accept-manager" import { createOpencodePermissionReplier } from "./opencode-replier" import { InstanceMutationGate } from "../server/instance-mutation-gate" import type { InstanceClientOptions } from "../workspaces/instance-client" +import { resolveRepositoryMutationKey } from "../workspaces/workspace-identity" const logger = {} as Logger const legacyReply: AutoAcceptReply = { @@ -143,7 +144,86 @@ describe("OpenCode permission replier", () => { assert.deepEqual(harness.clientOptions, [{ directory: nativeRoot }]) }) - it("shares listener mutation admission with legacy and V2 Yolo replies", async () => { + it("bypasses repository-exclusive admission for V2 Yolo replies", async () => { + const gate = new InstanceMutationGate() + let finishExclusive!: () => void + let markExclusiveStarted!: () => void + const held = new Promise((resolve) => { finishExclusive = resolve }) + const started = new Promise((resolve) => { markExclusiveStarted = resolve }) + const exclusive = gate.exclusive(await resolveRepositoryMutationKey(os.tmpdir()), async () => { + markExclusiveStarted() + await held + }) + await started + const harness = createHarness([], gate) + const reply = harness.replier({ ...legacyReply, source: "v2" }) + try { + await new Promise((resolve) => setImmediate(resolve)) + assert.equal(harness.v2Calls.length, 1) + } finally { + finishExclusive() + await exclusive + await reply + } + }) + + it("admits legacy discovery normally, then releases repository before replying", async () => { + const gate = new InstanceMutationGate() + const repositoryKey = await resolveRepositoryMutationKey(os.tmpdir()) + let finishExclusive!: () => void + let markExclusiveStarted!: () => void + const held = new Promise((resolve) => { finishExclusive = resolve }) + const started = new Promise((resolve) => { markExclusiveStarted = resolve }) + const exclusive = gate.exclusive(repositoryKey, async () => { markExclusiveStarted(); await held }) + await started + let finishReply!: () => void + const replyWait = new Promise((resolve) => { finishReply = resolve }) + const harness = createHarness([{ id: "session", directory: "/repo" }], gate, replyWait) + const reply = harness.replier(legacyReply) + await new Promise((resolve) => setImmediate(resolve)) + assert.equal(harness.listCalls.length, 0) + + finishExclusive() + await exclusive + while (harness.legacyCalls.length === 0) await new Promise((resolve) => setImmediate(resolve)) + let repositoryReadmitted = false + const readmitted = gate.exclusive(repositoryKey, async () => { repositoryReadmitted = true }) + await readmitted + assert.equal(repositoryReadmitted, true) + + let instanceExclusiveStarted = false + const instanceExclusive = gate.exclusive("instance", async () => { instanceExclusiveStarted = true }) + await Promise.resolve() + assert.equal(instanceExclusiveStarted, false) + finishReply() + await reply + await instanceExclusive + assert.equal(instanceExclusiveStarted, true) + }) + + it("retries a transient legacy repository release before replying", async () => { + const gate = new InstanceMutationGate() + let releaseAttempts = 0 + const mutationGate = { + enter: gate.enter.bind(gate), + acquireExclusive: async (key: string) => { + const release = await gate.acquireExclusive(key) + return () => { + releaseAttempts += 1 + if (releaseAttempts === 1) throw new Error("transient repository release failure") + release() + } + }, + } + const harness = createHarness([{ id: "session", directory: "/repo" }], mutationGate) + + await harness.replier(legacyReply) + + assert.ok(releaseAttempts >= 2) + assert.equal(harness.legacyCalls.length, 1) + }) + + it("holds instance-shared admission for legacy and V2 Yolo replies", async () => { for (const source of ["legacy", "v2"] as const) { const gate = new InstanceMutationGate() const releaseListener = await gate.enter("instance") diff --git a/packages/server/src/permissions/opencode-replier.ts b/packages/server/src/permissions/opencode-replier.ts index 9cfa4752f..3693a7073 100644 --- a/packages/server/src/permissions/opencode-replier.ts +++ b/packages/server/src/permissions/opencode-replier.ts @@ -3,8 +3,9 @@ import type { WorkspaceManager } from "../workspaces/manager" import type { Logger } from "../logger" import { createInstanceClient, type InstanceClientOptions } from "../workspaces/instance-client" import type { AutoAcceptReply, PermissionReplier } from "./auto-accept-manager" -import { enterWorkspaceMutationAdmission, type InstanceMutationGate } from "../server/instance-mutation-gate" +import type { InstanceMutationGate } from "../server/instance-mutation-gate" import { resolveNativeSessionLocation } from "../workspaces/native-session-location" +import { acquireRepositoryMutation } from "../workspaces/repository-mutation-lock" interface OpencodeReplierDeps { workspaceManager: WorkspaceManager @@ -29,11 +30,7 @@ export function createOpencodePermissionReplier( ) => OpencodeClient | null = createInstanceClient, ): PermissionReplier { return async (reply: AutoAcceptReply) => { - const releaseMutation = await enterWorkspaceMutationAdmission( - deps.mutationGate, - reply.instanceId, - async () => deps.workspaceManager.get(reply.instanceId)?.path, - ) + const releaseMutation = await deps.mutationGate.enter(reply.instanceId) try { const workspace = deps.workspaceManager.get(reply.instanceId) if (!workspace) throw new Error(`Yolo: instance ${reply.instanceId} is not ready`) @@ -55,18 +52,37 @@ export function createOpencodePermissionReplier( opts, ) } else { - const { data: sessions } = await client.session.list( - { scope: "project", limit: 10_000, directory: nativeRoot }, - { throwOnError: true }, - ) - const matches = (sessions ?? []).filter((session) => session.id === reply.sessionId) - if (matches.length !== 1) { - throw new Error(`Yolo: legacy permission session ${reply.sessionId} is ${matches.length === 0 ? "missing" : "ambiguous"}`) + const repository = await acquireRepositoryMutation({ + workspaceFolder: workspace.path, + gate: deps.mutationGate, + }) + let location: ReturnType + try { + const { data: sessions } = await client.session.list( + { scope: "project", limit: 10_000, directory: nativeRoot }, + { throwOnError: true }, + ) + const matches = (sessions ?? []).filter((session) => session.id === reply.sessionId) + if (matches.length !== 1) { + throw new Error(`Yolo: legacy permission session ${reply.sessionId} is ${matches.length === 0 ? "missing" : "ambiguous"}`) + } + const scope = { directory: nativeRoot } + await client.experimental.workspace.syncList(scope, { throwOnError: true }) + const { data: workspaces = [] } = await client.experimental.workspace.list(scope, { throwOnError: true }) + location = resolveNativeSessionLocation(nativeRoot, workspaces, matches[0]) + } finally { + let failure: unknown + for (let attempt = 0; attempt < 3; attempt += 1) { + try { + await repository.release() + failure = undefined + break + } catch (error) { + failure = error + } + } + if (failure) throw failure } - const scope = { directory: nativeRoot } - await client.experimental.workspace.syncList(scope, { throwOnError: true }) - const { data: workspaces = [] } = await client.experimental.workspace.list(scope, { throwOnError: true }) - const location = resolveNativeSessionLocation(nativeRoot, workspaces, matches[0]) await client.permission.reply( { requestID: reply.permissionId, @@ -77,7 +93,7 @@ export function createOpencodePermissionReplier( ) } } finally { - await releaseMutation() + releaseMutation() } } } diff --git a/packages/server/src/server/http-server.ts b/packages/server/src/server/http-server.ts index 25ce1cfbe..7c6ef3914 100644 --- a/packages/server/src/server/http-server.ts +++ b/packages/server/src/server/http-server.ts @@ -261,6 +261,9 @@ export function createHttpServer(deps: HttpServerDeps) { } } + if (request.method !== "GET" && request.method !== "HEAD" && !request.raw.complete) { + closeMutationUploadAfterResponse(request.raw, reply.raw) + } sendUnauthorized(request, reply) return } @@ -623,7 +626,7 @@ export function authoritativeMutationHeaders( authorization?: string, ): Record { const result = { ...headers } - const blocked = new Set(["host", "connection", "transfer-encoding", MUTATION_SESSION_HEADER, + const blocked = new Set(["host", "connection", "transfer-encoding", "expect", MUTATION_SESSION_HEADER, "x-opencode-directory", "x-opencode-workspace"]) for (const name of Object.keys(result)) { if (blocked.has(name.toLowerCase())) delete result[name] @@ -760,12 +763,18 @@ async function proxyWorkspaceRequest(args: { } if (!workspace) { + if (request.method !== "GET" && request.method !== "HEAD" && !request.raw.complete) { + closeMutationUploadAfterResponse(request.raw, reply.raw) + } reply.code(404).send({ error: "Workspace not found" }) return } const port = workspaceManager.getInstancePort(workspaceId) if (!port) { + if (request.method !== "GET" && request.method !== "HEAD" && !request.raw.complete) { + closeMutationUploadAfterResponse(request.raw, reply.raw) + } reply.code(502).send({ error: "Workspace instance is not ready" }) return } @@ -803,6 +812,7 @@ async function proxyWorkspaceRequest(args: { admitted = await admitWorkspaceMutation({ gate: args.mutationGate, workspaceId, + method: request.method, pathSuffix: args.pathSuffix, rawUrl: request.raw.url, sessionContext: Array.isArray(request.headers[MUTATION_SESSION_HEADER]) @@ -888,12 +898,11 @@ async function proxyWorkspaceRequest(args: { request.raw.removeListener("aborted", abortDownstream) reply.raw.removeListener("close", abortClosedReply) args.shutdownSignal.removeEventListener("abort", abortShutdown) + if (!request.raw.complete && !reply.sent) closeMutationUploadAfterResponse(request.raw, reply.raw) if (error instanceof MutationBodyLimitError && !reply.sent) { - closeMutationUploadAfterResponse(request.raw, reply.raw) return reply.code(413).send({ error: "Mutation request body is too large" }) } if (error instanceof MutationBodyTimeoutError && !reply.sent) { - closeMutationUploadAfterResponse(request.raw, reply.raw) return reply.code(408).send({ error: "Mutation request body timed out" }) } if (error instanceof WorkspaceMutationConflictError && !reply.sent) { diff --git a/packages/server/src/server/instance-mutation-body.test.ts b/packages/server/src/server/instance-mutation-body.test.ts index 69e5eea88..c01c696e7 100644 --- a/packages/server/src/server/instance-mutation-body.test.ts +++ b/packages/server/src/server/instance-mutation-body.test.ts @@ -10,10 +10,27 @@ import { readMutationBody, } from "./http-server" -async function uploadResponse(options: { chunks: Buffer[]; bytes: number; idleMs?: number }) { +async function uploadResponse(options: { + chunks: Buffer[] + bytes: number + idleMs?: number + shutdownAfterMs?: number + immediateStatus?: number +}) { const server = createServer(async (request, response) => { + if (options.immediateStatus !== undefined) { + closeMutationUploadAfterResponse(request, response) + response.statusCode = options.immediateStatus + response.setHeader("content-type", "application/json") + response.end(JSON.stringify({ error: response.statusCode })) + return + } + const controller = new AbortController() + const shutdown = options.shutdownAfterMs === undefined + ? undefined + : setTimeout(() => controller.abort(new Error("shutdown")), options.shutdownAfterMs) try { - await readMutationBody(request, request.headers["content-length"], new AbortController().signal, { + await readMutationBody(request, request.headers["content-length"], controller.signal, { bytes: options.bytes, idleMs: options.idleMs ?? 100, deadlineMs: 500, @@ -21,9 +38,11 @@ async function uploadResponse(options: { chunks: Buffer[]; bytes: number; idleMs response.end("ok") } catch (error) { closeMutationUploadAfterResponse(request, response) - response.statusCode = error instanceof MutationBodyLimitError ? 413 : 408 + response.statusCode = error instanceof MutationBodyLimitError ? 413 : error instanceof MutationBodyTimeoutError ? 408 : 502 response.setHeader("content-type", "application/json") response.end(JSON.stringify({ error: response.statusCode })) + } finally { + if (shutdown) clearTimeout(shutdown) } }) await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)) @@ -54,10 +73,12 @@ describe("mutation request body admission", () => { "x-opencode-directory": "untrusted", "x-opencode-workspace": "untrusted", "x-codenomad-mutation-session": "private", + expect: "100-continue", }, "/native/repo", "admitted", "Basic trusted") assert.equal(headers["x-opencode-directory"], encodeURIComponent("/native/repo")) assert.equal(headers["x-opencode-workspace"], "admitted") assert.equal(headers["x-codenomad-mutation-session"], undefined) + assert.equal(headers.expect, undefined) assert.equal(headers.authorization, "Basic trusted") }) @@ -95,4 +116,14 @@ describe("mutation request body admission", () => { const timedOut = await uploadResponse({ chunks: [Buffer.alloc(1)], bytes: 10, idleMs: 5 }) assert.deepEqual(timedOut, { statusCode: 408, body: '{"error":408}', connection: "close" }) }) + + it("flushes a shutdown error before closing an incomplete upload socket", async () => { + const shutdown = await uploadResponse({ chunks: [Buffer.alloc(1)], bytes: 10, shutdownAfterMs: 5 }) + assert.deepEqual(shutdown, { statusCode: 502, body: '{"error":502}', connection: "close" }) + }) + + it("flushes 401 before closing an unauthorized incomplete upload socket", async () => { + const unauthorized = await uploadResponse({ chunks: [Buffer.alloc(1)], bytes: 10, immediateStatus: 401 }) + assert.deepEqual(unauthorized, { statusCode: 401, body: '{"error":401}', connection: "close" }) + }) }) diff --git a/packages/server/src/server/instance-mutation-proxy.test.ts b/packages/server/src/server/instance-mutation-proxy.test.ts index 886d508e8..e4eb660d9 100644 --- a/packages/server/src/server/instance-mutation-proxy.test.ts +++ b/packages/server/src/server/instance-mutation-proxy.test.ts @@ -1,9 +1,15 @@ import assert from "node:assert/strict" import { EventEmitter } from "node:events" +import { mkdtemp, rm } from "node:fs/promises" +import { createServer } from "node:http" +import os from "node:os" +import path from "node:path" import { describe, it } from "node:test" +import { request as requestUpstream } from "undici" import { InstanceMutationGate } from "./instance-mutation-gate" import { admitWorkspaceMutation, + isInstanceControlMutation, openUpstreamMutation, ProxyMutationTracker, WorkspaceMutationConflictError, @@ -115,12 +121,76 @@ describe("openUpstreamMutation", () => { await drain assert.equal(drained, true) }) + + it("retries failed admission cleanup without retaining tracker or instance admission", async () => { + const gate = new InstanceMutationGate() + const releaseInstance = await gate.enter("instance") + const tracker = new ProxyMutationTracker() + const body = new EventEmitter() + let attempts = 0 + await openUpstreamMutation({ + start: async () => ({ body }), + release: async () => { + attempts += 1 + try { + if (attempts === 1) throw new Error("transient repository release failure") + } finally { + releaseInstance() + } + }, + tracker, + }) + + body.emit("end") + await tracker.abortAndDrain(new Error("shutdown")) + await gate.exclusive("instance", async () => undefined) + assert.equal(attempts, 2) + }) + + it("untracks settlement after persistent cleanup failure without an unhandled rejection", async () => { + const tracker = new ProxyMutationTracker() + const body = new EventEmitter() + let unhandled: unknown + const onUnhandled = (error: unknown) => { unhandled = error } + process.once("unhandledRejection", onUnhandled) + try { + await openUpstreamMutation({ + start: async () => ({ body }), + release: async () => { throw new Error("persistent release failure") }, + tracker, + }) + body.emit("end") + await tracker.abortAndDrain(new Error("shutdown")) + await new Promise((resolve) => setImmediate(resolve)) + assert.equal(unhandled, undefined) + } finally { + process.removeListener("unhandledRejection", onUnhandled) + } + }) }) describe("admitWorkspaceMutation", () => { const state = { port: 4321, hostDirectory: process.cwd(), nativeRootDirectory: "/repo" } const workspaces = [{ id: "old", directory: "/old" }, { id: "new", directory: "/new" }] + it("allowlists only permission and question reply controls", () => { + for (const suffix of [ + "permission/request/reply", + "question/request/reply", + "question/request/reject", + "session/session/permissions/request", + "api/session/session/permission/request/reply", + "api/session/session/question/request/reply", + "api/session/session/question/request/reject", + ]) assert.equal(isInstanceControlMutation("POST", suffix), true, suffix) + for (const [method, suffix] of [ + ["GET", "permission/request/reply"], + ["POST", "session/session/prompt"], + ["POST", "permission/request/reply/extra"], + ["POST", "api/session/session/permission/request/reject"], + ]) assert.equal(isInstanceControlMutation(method, suffix), false, `${method} ${suffix}`) + }) + it("re-resolves a moved session after waiting for instance admission", async () => { const gate = new InstanceMutationGate() let releaseExclusive!: () => void @@ -166,6 +236,7 @@ describe("admitWorkspaceMutation", () => { const admitted = await admitWorkspaceMutation({ gate: new InstanceMutationGate(), workspaceId: "instance", + method: "POST", pathSuffix: "permission/request/reply", rawUrl: "/permission/request/reply?workspace=old", sessionContext: "session-id", @@ -205,6 +276,7 @@ describe("admitWorkspaceMutation", () => { const admitted = await admitWorkspaceMutation({ gate: new InstanceMutationGate(), workspaceId: "instance", + method: "POST", pathSuffix: "question/request/reply", rawUrl: "/question/request/reply", sessionContext: "session-id", @@ -235,4 +307,182 @@ describe("admitWorkspaceMutation", () => { await sibling assert.equal(exclusiveStarted, true) }) + + it("releases instance admission when repository cleanup fails and permits cleanup retry", async () => { + let repositoryReleaseAttempts = 0 + const gate = { + enter: async (key: string) => key === "instance" + ? () => { instanceReleased = true } + : () => {}, + acquireExclusive: async () => async () => { + repositoryReleaseAttempts += 1 + if (repositoryReleaseAttempts === 1) throw new Error("transient repository release failure") + }, + } + let instanceReleased = false + const admitted = await admitWorkspaceMutation({ + gate, + workspaceId: "instance", + pathSuffix: "session/session-id/abort", + resolveWorkspace: async () => state, + loadSessions: async () => [{ id: "session-id", directory: "/repo" }], + loadWorkspaces: async () => [], + }) + + await assert.rejects(admitted.release(), /transient repository release failure/) + assert.equal(instanceReleased, true) + await admitted.release() + assert.ok(repositoryReleaseAttempts >= 2) + }) + + it("retries repository cleanup when admission discovery fails", async () => { + let instanceReleased = false + let repositoryReleaseAttempts = 0 + const gate = { + enter: async () => () => { instanceReleased = true }, + acquireExclusive: async () => async () => { + repositoryReleaseAttempts += 1 + if (repositoryReleaseAttempts === 1) throw new Error("transient repository release failure") + }, + } + + await assert.rejects(admitWorkspaceMutation({ + gate, + workspaceId: "instance", + pathSuffix: "session/session-id/abort", + resolveWorkspace: async () => state, + loadSessions: async () => { throw new Error("discovery failed") }, + loadWorkspaces: async () => [], + }), /discovery failed/) + assert.equal(instanceReleased, true) + assert.ok(repositoryReleaseAttempts >= 2) + }) + + it("lets a reply complete an actual pending synchronous prompt without weakening other mutations", async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), "codenomad-control-lane-")) + const gate = new InstanceMutationGate() + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(new Error("control lane regression timed out")), 5_000) + let promptResponse: import("node:http").ServerResponse | undefined + let markPromptStarted!: () => void + const promptStarted = new Promise((resolve) => { markPromptStarted = resolve }) + const server = createServer((request, response) => { + if (request.url === "/session/session-id/prompt") { + promptResponse = response + markPromptStarted() + return + } + if (request.url === "/permission/request/reply") { + response.end("replied") + promptResponse?.end("prompt complete") + return + } + response.writeHead(404).end() + }) + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)) + const address = server.address() + if (!address || typeof address === "string") throw new Error("Prompt test server did not bind") + const workspace = { port: address.port, hostDirectory: directory, nativeRootDirectory: "/repo" } + const admission = (pathSuffix: string, sessionContext?: string) => admitWorkspaceMutation({ + gate, + workspaceId: "instance", + method: "POST", + pathSuffix, + sessionContext, + signal: controller.signal, + resolveWorkspace: async () => workspace, + loadSessions: async () => [{ id: "session-id", directory: "/repo" }], + loadWorkspaces: async () => [], + }) + let promptAdmission: Awaited> | undefined + let replyAdmission: Awaited> | undefined + let promptRequest: Promise | undefined + let markPromptReleased!: () => void + const promptReleased = new Promise((resolve) => { markPromptReleased = resolve }) + try { + promptAdmission = await admission("session/session-id/prompt") + promptRequest = openUpstreamMutation({ + start: (signal) => requestUpstream(`http://127.0.0.1:${address.port}/session/session-id/prompt`, { + method: "POST", + body: "{}", + signal, + }), + release: async () => { + try { + await promptAdmission!.release() + } finally { + markPromptReleased() + } + }, + downstreamSignal: controller.signal, + }) + await promptStarted + + const unrelatedController = new AbortController() + let unrelatedAdmitted = false + const unrelated = admitWorkspaceMutation({ + gate, + workspaceId: "instance", + method: "POST", + pathSuffix: "session/session-id/abort", + signal: unrelatedController.signal, + resolveWorkspace: async () => workspace, + loadSessions: async () => [{ id: "session-id", directory: "/repo" }], + loadWorkspaces: async () => [], + }).then((value) => { unrelatedAdmitted = true; return value }) + await new Promise((resolve) => setImmediate(resolve)) + assert.equal(unrelatedAdmitted, false) + unrelatedController.abort(new Error("expected repository admission block")) + await assert.rejects(unrelated, /expected repository admission block/) + + replyAdmission = await admission("permission/request/reply", "session-id") + const reply = await requestUpstream(`http://127.0.0.1:${address.port}/permission/request/reply`, { + method: "POST", + body: "{}", + signal: controller.signal, + }) + assert.equal(await reply.body.text(), "replied") + await replyAdmission.release() + replyAdmission = undefined + const promptResult = await promptRequest + assert.equal(await promptResult.body.text(), "prompt complete") + } finally { + clearTimeout(timeout) + controller.abort() + if (promptResponse && !promptResponse.writableEnded) promptResponse.end() + await promptRequest?.catch(() => undefined) + await replyAdmission?.release() + if (promptRequest) await promptReleased + else await promptAdmission?.release() + server.closeAllConnections() + await new Promise((resolve) => server.close(() => resolve())) + await rm(directory, { recursive: true, force: true }) + } + }) + + it("keeps control replies behind instance-exclusive admission", async () => { + const gate = new InstanceMutationGate() + let finishExclusive!: () => void + let markExclusiveStarted!: () => void + const held = new Promise((resolve) => { finishExclusive = resolve }) + const started = new Promise((resolve) => { markExclusiveStarted = resolve }) + const exclusive = gate.exclusive("instance", async () => { markExclusiveStarted(); await held }) + await started + let admitted = false + const pending = admitWorkspaceMutation({ + gate, + workspaceId: "instance", + method: "POST", + pathSuffix: "api/session/session-id/question/request/reject", + resolveWorkspace: async () => state, + loadSessions: async () => [{ id: "session-id", directory: "/repo" }], + loadWorkspaces: async () => [], + }).then((value) => { admitted = true; return value }) + await Promise.resolve() + assert.equal(admitted, false) + finishExclusive() + await exclusive + const control = await pending + await control.release() + }) }) diff --git a/packages/server/src/server/instance-mutation-proxy.ts b/packages/server/src/server/instance-mutation-proxy.ts index f80b1fbbf..94dfd8005 100644 --- a/packages/server/src/server/instance-mutation-proxy.ts +++ b/packages/server/src/server/instance-mutation-proxy.ts @@ -9,6 +9,14 @@ import { acquireRepositoryMutation } from "../workspaces/repository-mutation-loc const SESSION_LIST_LIMIT = 10_000 export const MUTATION_SESSION_HEADER = "x-codenomad-mutation-session" +export function isInstanceControlMutation(method: string | undefined, pathSuffix: string | undefined): boolean { + if (method?.toUpperCase() !== "POST") return false + const path = `/${(pathSuffix ?? "").replace(/^\/+|\/+$/g, "")}` + return /^\/(?:permission\/[^/]+\/reply|question\/[^/]+\/(?:reply|reject))$/.test(path) + || /^\/session\/[^/]+\/permissions\/[^/]+$/.test(path) + || /^\/api\/session\/[^/]+\/(?:permission\/[^/]+\/reply|question\/[^/]+\/(?:reply|reject))$/.test(path) +} + export class WorkspaceMutationConflictError extends Error { constructor(message: string) { super(message) @@ -29,9 +37,24 @@ interface MutationSession { directory?: string } +async function retryRelease(release: () => void | Promise, onFailure?: () => void): Promise { + let failure: unknown + for (let attempt = 0; attempt < 3; attempt += 1) { + try { + await release() + return + } catch (error) { + failure = error + onFailure?.() + } + } + throw failure +} + export async function admitWorkspaceMutation(params: { gate: Pick workspaceId: string + method?: string pathSuffix?: string rawUrl?: string resolveWorkspace: () => Promise @@ -45,12 +68,14 @@ export async function admitWorkspaceMutation(params: { try { const queued = await params.resolveWorkspace() if (!queued) throw new WorkspaceMutationConflictError("Workspace changed while the mutation was queued") - const repository = await acquireRepositoryMutation({ - workspaceFolder: queued.hostDirectory, - gate: params.gate as InstanceMutationGate, - signal: params.signal, - }) - releaseRepository = repository.release + if (!isInstanceControlMutation(params.method, params.pathSuffix)) { + const repository = await acquireRepositoryMutation({ + workspaceFolder: queued.hostDirectory, + gate: params.gate as InstanceMutationGate, + signal: params.signal, + }) + releaseRepository = repository.release + } const current = await params.resolveWorkspace() if (!current || current.hostDirectory !== queued.hostDirectory || current.nativeRootDirectory !== queued.nativeRootDirectory) { @@ -69,13 +94,19 @@ export async function admitWorkspaceMutation(params: { ...current, ...location, release: async () => { - await releaseRepository?.() - releaseInstance() + try { + await releaseRepository?.() + } finally { + releaseInstance() + } }, } } catch (error) { - if (releaseRepository) await releaseRepository() - releaseInstance() + try { + if (releaseRepository) await retryRelease(releaseRepository) + } finally { + releaseInstance() + } throw error } } @@ -90,7 +121,7 @@ async function authoritativeMutationLocation(params: { }): Promise<{ search: string; directory: string; workspaceId: string | null }> { const queryIndex = (params.rawUrl ?? "").indexOf("?") const search = new URLSearchParams(queryIndex >= 0 ? (params.rawUrl ?? "").slice(queryIndex + 1) : "") - const match = /^\/?session\/([^/]+)(?:\/|$)/.exec(params.pathSuffix ?? "") + const match = /^\/?(?:api\/)?session\/([^/]+)(?:\/|$)/.exec(params.pathSuffix ?? "") const pathSessionId = match ? decodeURIComponent(match[1]) : undefined if (pathSessionId && params.sessionContext && pathSessionId !== params.sessionContext) { throw new WorkspaceMutationConflictError("Mutation session context does not match the request path") @@ -194,8 +225,11 @@ export async function openUpstreamMutation { if (timeout) clearTimeout(timeout) params.downstreamSignal?.removeEventListener("abort", abortForDisconnect) - await params.release() - untrack() + try { + await retryRelease(params.release, untrack) + } finally { + untrack() + } }) return settlement } @@ -206,10 +240,10 @@ export async function openUpstreamMutation { void settle() }) + holdMutationLeaseUntilUpstreamSettles(upstream.body, () => { void settle().catch(() => undefined) }) return upstream } catch (error) { - await settle() + await settle().catch(() => undefined) throw error } } diff --git a/packages/server/src/server/routes/workspaces.test.ts b/packages/server/src/server/routes/workspaces.test.ts index 64729cd2b..904889f6e 100644 --- a/packages/server/src/server/routes/workspaces.test.ts +++ b/packages/server/src/server/routes/workspaces.test.ts @@ -1,4 +1,7 @@ import assert from "node:assert/strict" +import { mkdtempSync, rmSync } from "node:fs" +import os from "node:os" +import path from "node:path" import { describe, it } from "node:test" import Fastify from "fastify" @@ -128,24 +131,31 @@ describe("workspace routes", () => { await app.close() }) - it("rejects cleanup clone when another workspace holds lifetime authority", async () => { + it("rejects every clone into an occupied destination, including an existing empty folder", async () => { const app = Fastify({ logger: false }) + const destinationPath = mkdtempSync(path.join(os.tmpdir(), "codenomad-clone-occupied-")) const workspaceManager = { hasWorkspaceBlocker: async () => true, } as unknown as WorkspaceManager registerWorkspaceRoutes(app, { workspaceManager, mutationGate: new InstanceMutationGate() }) - const response = await app.inject({ - method: "POST", - url: "/api/workspaces/clone", - payload: { - repositoryUrl: "https://example.invalid/repository.git", - destinationPath: process.platform === "win32" ? "C:\\codenomad-clone-occupied" : "/tmp/codenomad-clone-occupied", - cleanup: true, - }, - }) - assert.equal(response.statusCode, 409) - assert.match(response.json().error, /live workspace/i) - await app.close() + try { + for (const cleanup of [undefined, true]) { + const response = await app.inject({ + method: "POST", + url: "/api/workspaces/clone", + payload: { + repositoryUrl: "https://example.invalid/repository.git", + destinationPath, + ...(cleanup ? { cleanup } : {}), + }, + }) + assert.equal(response.statusCode, 409) + assert.match(response.json().error, /live workspace/i) + } + } finally { + await app.close() + rmSync(destinationPath, { recursive: true, force: true }) + } }) }) diff --git a/packages/server/src/server/routes/workspaces.ts b/packages/server/src/server/routes/workspaces.ts index ec136a39b..10d3bd271 100644 --- a/packages/server/src/server/routes/workspaces.ts +++ b/packages/server/src/server/routes/workspaces.ts @@ -127,7 +127,7 @@ export function registerWorkspaceRoutes(app: FastifyInstance, deps: RouteDeps) { gate: deps.mutationGate, signal: cancellation.signal, operation: async () => { - if (body.cleanup && await deps.workspaceManager.hasWorkspaceBlocker(body.destinationPath)) { + if (await deps.workspaceManager.hasWorkspaceBlocker(body.destinationPath)) { throw new WorkspaceMutationConflictError("Clone destination is used by a live workspace") } return cloneGitRepository(body) diff --git a/packages/server/src/server/routes/worktrees.test.ts b/packages/server/src/server/routes/worktrees.test.ts index e3eaad6af..927e36455 100644 --- a/packages/server/src/server/routes/worktrees.test.ts +++ b/packages/server/src/server/routes/worktrees.test.ts @@ -287,6 +287,9 @@ describe("worktree mutation routes", () => { const mapPath = path.join(test.folder, ".codenomad", "worktreeMap.json") mkdirSync(path.dirname(mapPath), { recursive: true }) writeFileSync(mapPath, "{ malformed", "utf-8") + const read = await test.app.inject({ method: "GET", url: "/api/workspaces/workspace/worktrees/map" }) + assert.equal(read.statusCode, 500) + assert.match(read.json().error, /malformed worktree map/i) const response = await test.app.inject({ method: "PUT", url: "/api/workspaces/workspace/worktrees/map", @@ -299,6 +302,32 @@ describe("worktree mutation routes", () => { } }) + it("returns a typed conflict when linked map sources disagree", async () => { + const test = setup() + try { + const created = await test.app.inject({ + method: "POST", url: "/api/workspaces/workspace/worktrees", payload: { slug: "feature" }, + }) + assert.equal(created.statusCode, 201) + const canonicalPath = path.join(test.folder, ".codenomad", "worktreeMap.json") + const legacyPath = path.join(created.json().directory, ".codenomad", "worktreeMap.json") + mkdirSync(path.dirname(canonicalPath), { recursive: true }) + mkdirSync(path.dirname(legacyPath), { recursive: true }) + writeFileSync(canonicalPath, JSON.stringify({ + version: 1, revision: 1, defaultWorktreeSlug: "root", parentSessionWorktreeSlug: { session: "root" }, + })) + writeFileSync(legacyPath, JSON.stringify({ + version: 1, revision: 2, defaultWorktreeSlug: "root", parentSessionWorktreeSlug: { session: "feature" }, + })) + + const response = await test.app.inject({ method: "GET", url: "/api/workspaces/workspace/worktrees/map" }) + assert.equal(response.statusCode, 409) + assert.match(response.json().error, /conflicting worktree binding/i) + } finally { + await test.close() + } + }) + it("returns 500 for typed rollback-incomplete failures", async () => { const test = setup({ metadataError: new WorktreeRollbackIncompleteError([new Error("restore failed")], "Rollback was incomplete"), diff --git a/packages/server/src/server/routes/worktrees.ts b/packages/server/src/server/routes/worktrees.ts index ef18fe530..836c7b13e 100644 --- a/packages/server/src/server/routes/worktrees.ts +++ b/packages/server/src/server/routes/worktrees.ts @@ -13,8 +13,9 @@ import type { WorktreeListResponse, WorktreeMap, WorktreeSessionMoveResponse, Wo import type { OpencodeYoloPersistence } from "../../permissions/opencode-yolo-metadata" import { ensureCodenomadGitExclude, - readWorktreeMap, readWorktreeMapStrict, + WorktreeMapConflictError, + WorktreeMapReadError, WorktreeMapRevisionConflictError, writeWorktreeMap, } from "../../workspaces/worktree-map" @@ -50,7 +51,7 @@ const WorktreeCreateSchema = z.object({ const WorktreeSessionSchema = z.object({ worktreeSlug: z.string().trim().refine(isValidWorktreeSlug).nullable() }) const WorktreeSessionMoveSchema = z.object({ worktreeSlug: z.string().trim().refine(isValidWorktreeSlug) }) -class WorktreeMapConflictError extends Error {} +class WorktreeMapUpdateConflictError extends Error {} class WorktreeRepositoryInUseError extends Error {} class WorktreeWorkspaceChangedError extends Error {} class WorktreeNonGitError extends Error {} @@ -333,7 +334,11 @@ export function registerWorktreeRoutes(app: FastifyInstance, deps: RouteDeps) { reply.code(404) return { error: "Workspace not found" } } - return await readWorktreeMap(workspace.path, request.log) + try { + return (await readWorktreeMapStrict(workspace.path, request.log)).map + } catch (error) { + return handleError(error, reply) + } }) app.put<{ Params: { id: string } }>("/api/workspaces/:id/worktrees/map", async (request, reply) => { @@ -360,7 +365,7 @@ export function registerWorktreeRoutes(app: FastifyInstance, deps: RouteDeps) { const { repoRoot } = await resolveRepoRoot(currentWorkspace.path, request.log) const current = (await readWorktreeMapStrict(currentWorkspace.path, request.log)).map if ((parsed.revision ?? 0) !== (current.revision ?? 0)) { - throw new WorktreeMapConflictError("Worktree map revision is stale") + throw new WorktreeMapUpdateConflictError("Worktree map revision is stale") } const available = new Set((await listWorktrees({ repoRoot, @@ -369,7 +374,7 @@ export function registerWorktreeRoutes(app: FastifyInstance, deps: RouteDeps) { })).map((worktree) => worktree.slug)) if (!available.has(parsed.defaultWorktreeSlug) || Object.values(parsed.parentSessionWorktreeSlug).some((slug) => !available.has(slug))) { - throw new WorktreeMapConflictError("Worktree map references a missing worktree") + throw new WorktreeMapUpdateConflictError("Worktree map references a missing worktree") } const next: WorktreeMap = { ...parsed, revision: (current.revision ?? 0) + 1 } await writeWorktreeMap(currentWorkspace.path, next, request.log, parsed.revision ?? 0) @@ -390,6 +395,7 @@ function handleError(error: unknown, reply: FastifyReply) { } if (error instanceof WorktreeSessionBusyError || error instanceof WorktreeMapConflictError + || error instanceof WorktreeMapUpdateConflictError || error instanceof WorktreeMapRevisionConflictError || error instanceof WorktreeRepositoryInUseError || error instanceof WorktreeWorkspaceChangedError) { @@ -404,6 +410,10 @@ function handleError(error: unknown, reply: FastifyReply) { reply.code(400) return { error: error.message } } + if (error instanceof WorktreeMapReadError) { + reply.code(500) + return { error: error.message } + } if (error instanceof Error && (error.message === "Worktree not found" || error.message === "Session not found")) { reply.code(404) return { error: error.message } diff --git a/packages/server/src/workspaces/__tests__/git-worktrees.test.ts b/packages/server/src/workspaces/__tests__/git-worktrees.test.ts index a89c5905e..c95f5c5c9 100644 --- a/packages/server/src/workspaces/__tests__/git-worktrees.test.ts +++ b/packages/server/src/workspaces/__tests__/git-worktrees.test.ts @@ -4,9 +4,22 @@ import { execFileSync } from "node:child_process" import { tmpdir } from "node:os" import path from "node:path" import { describe, it } from "node:test" -import { listWorktrees } from "../git-worktrees" +import { listWorktrees, parseWorktreePorcelain } from "../git-worktrees" +import { stripGitLineTerminator } from "../git-output" describe("listWorktrees", () => { + it("preserves newlines in Git path output and NUL-delimited worktree inventory", () => { + const directory = "/repo/line\nbreak" + assert.equal(stripGitLineTerminator(`${directory}\n`), directory) + assert.deepEqual(parseWorktreePorcelain([ + `worktree ${directory}`, + "HEAD abcdef1234567890", + "detached", + "", + "", + ].join("\0")), [{ worktree: directory, head: "abcdef1234567890", detached: true }]) + }) + it("uses the selected workspace folder for the root worktree directory", async () => { const temp = mkdtempSync(path.join(tmpdir(), "codenomad-git-worktrees-")) const repoRoot = path.join(temp, "repo") diff --git a/packages/server/src/workspaces/__tests__/spawn.test.ts b/packages/server/src/workspaces/__tests__/spawn.test.ts index beae8315e..9077d23c4 100644 --- a/packages/server/src/workspaces/__tests__/spawn.test.ts +++ b/packages/server/src/workspaces/__tests__/spawn.test.ts @@ -56,6 +56,13 @@ describe("resolveWslNativePath", () => { "/home/dev/workspace", ) }) + + it("normalizes extended UNC paths before WSL detection", () => { + assert.deepEqual(parseWslUncPath(String.raw`\\?\UNC\wsl.localhost\Ubuntu\home\dev`), { + distro: "Ubuntu", + linuxPath: "/home/dev", + }) + }) }) describe("buildWindowsSpawnSpec", () => { diff --git a/packages/server/src/workspaces/__tests__/workspace-identity.test.ts b/packages/server/src/workspaces/__tests__/workspace-identity.test.ts index c736a6e0b..334a808b3 100644 --- a/packages/server/src/workspaces/__tests__/workspace-identity.test.ts +++ b/packages/server/src/workspaces/__tests__/workspace-identity.test.ts @@ -8,6 +8,7 @@ import pino from "pino" import { EventBus } from "../../events/bus" import { WorkspaceManager } from "../manager" +import { queryGitRepositoryPaths, stripGitLineTerminator } from "../git-output" import { canonicalFilesystemIdentity, nativeWorkspacePathsEqual, @@ -85,6 +86,31 @@ async function createSharedLaunch() { } describe("workspace identity", () => { + it("runs Git path probes sequentially and strips only Git's LF terminator", async () => { + const calls: string[] = [] + let topLevelComplete = false + const result = await queryGitRepositoryPaths("/workspace", async (_cwd, argument) => { + calls.push(argument) + if (argument === "--show-toplevel") { + await new Promise((resolve) => setImmediate(resolve)) + topLevelComplete = true + return "/repository" + } + assert.equal(topLevelComplete, true) + return ".git" + }) + assert.deepEqual(result, { topLevel: "/repository", commonDir: ".git" }) + assert.deepEqual(calls, ["--show-toplevel", "--git-common-dir"]) + assert.equal(stripGitLineTerminator("/repository\r\n"), "/repository\r") + + calls.length = 0 + await assert.rejects(queryGitRepositoryPaths("/workspace", async (_cwd, argument) => { + calls.push(argument) + throw new Error("top-level failed") + }), /top-level failed/) + assert.deepEqual(calls, ["--show-toplevel"]) + }) + it("normalizes Windows paths without affecting POSIX case", () => { assert.equal(normalizeWorkspaceIdentityPath("C:\\Projects\\CodeNomad\\", "win32"), "c:\\projects\\codenomad\\") assert.equal(normalizeWorkspaceIdentityPath(String.raw`\\Server\Share\Repo`, "win32"), String.raw`\\server\share\repo`) @@ -114,6 +140,10 @@ describe("workspace identity", () => { ), false) assert.equal(nativeWorkspacePathsEqual("/home/dev/Repo/feature/..", "/home/dev/Repo"), true) assert.equal(nativeWorkspacePathsEqual("/home/dev/Repo", "/home/dev/repo"), false) + assert.equal(canonicalFilesystemIdentity( + String.raw`\\?\UNC\wsl.localhost\Ubuntu\home\dev\Repo`, + "win32", + ), "wsl:ubuntu:/home/dev/Repo") }) it("canonicalizes aliases and falls back to an absolute identity for missing paths", async () => { @@ -131,6 +161,14 @@ describe("workspace identity", () => { assert.equal(canonicalFilesystemIdentity(link), canonicalFilesystemIdentity(target)) assert.equal(missing.workspacePath, expectedMissing) assert.equal(missing.identityKey, normalizeWorkspaceIdentityPath(expectedMissing)) + + const missingThroughLink = path.join(link, "not-created", "workspace") + const missingThroughTarget = path.join(target, "not-created", "workspace") + assert.equal(canonicalFilesystemIdentity(missingThroughLink), canonicalFilesystemIdentity(missingThroughTarget)) + assert.equal( + (await resolveWorkspaceIdentity(missingThroughLink, root)).workspacePath, + missingThroughTarget, + ) }) it("uses the canonical Git common directory for real linked worktrees", async () => { diff --git a/packages/server/src/workspaces/__tests__/worktree-map.test.ts b/packages/server/src/workspaces/__tests__/worktree-map.test.ts index 1e67f5d4b..675c56f6c 100644 --- a/packages/server/src/workspaces/__tests__/worktree-map.test.ts +++ b/packages/server/src/workspaces/__tests__/worktree-map.test.ts @@ -1,5 +1,6 @@ import assert from "node:assert/strict" import { execFileSync } from "node:child_process" +import { promises as fsp, type PathLike, type RmOptions } from "node:fs" import { access, mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises" import os from "node:os" import path from "node:path" @@ -126,6 +127,40 @@ describe("worktree map identity", () => { await assert.rejects(access(secondLegacyPath), { code: "ENOENT" }) }) + it("keeps an equivalent migration retry at the published revision", async () => { + const { repository, linked } = await createLinkedRepository() + await writeWorktreeMap(repository, map(8, "canonical")) + const legacyPath = legacyMapPath(linked) + await mkdir(path.dirname(legacyPath), { recursive: true }) + await writeFile(legacyPath, JSON.stringify(map(5, "legacy")), "utf-8") + + const published = (await readWorktreeMapStrict(repository)).map + assert.equal(published.revision, 9) + await mkdir(path.dirname(legacyPath), { recursive: true }) + await writeFile(legacyPath, JSON.stringify(map(5, "legacy")), "utf-8") + + assert.deepEqual((await readWorktreeMapStrict(repository)).map, published) + }) + + it("blocks writes and deletes while a published legacy source cannot be retired", async (test) => { + const { repository, linked } = await createLinkedRepository() + const canonicalPath = legacyMapPath(repository) + const legacyPath = legacyMapPath(linked) + await mkdir(path.dirname(legacyPath), { recursive: true }) + await writeFile(legacyPath, JSON.stringify(map(4, "legacy")), "utf-8") + const originalRm = fsp.rm.bind(fsp) + test.mock.method(fsp, "rm", async (filePath: PathLike, options?: RmOptions) => { + if (path.resolve(filePath.toString()) === path.resolve(legacyPath)) throw new Error("injected rm failure") + return originalRm(filePath, options) + }) + + assert.deepEqual((await readWorktreeMapStrict(repository)).map, map(4, "legacy")) + await assert.rejects(writeWorktreeMap(repository, map(5, "replacement"), undefined, 4), /retire legacy/) + await assert.rejects(deleteWorktreeMap(repository), /retire legacy/) + assert.deepEqual(JSON.parse(await readFile(canonicalPath, "utf-8")), map(4, "legacy")) + assert.deepEqual(JSON.parse(await readFile(legacyPath, "utf-8")), map(4, "legacy")) + }) + it("parses every linked map before publishing or removing any source", async () => { const { repository, linked } = await createLinkedRepository() const secondLinked = addLinkedWorktree(repository, "linked-two") diff --git a/packages/server/src/workspaces/git-output.ts b/packages/server/src/workspaces/git-output.ts new file mode 100644 index 000000000..26097e759 --- /dev/null +++ b/packages/server/src/workspaces/git-output.ts @@ -0,0 +1,23 @@ +import { execFile } from "node:child_process" +import { promisify } from "node:util" + +const execFileAsync = promisify(execFile) + +export function stripGitLineTerminator(output: string): string { + if (output.endsWith("\n")) return output.slice(0, -1) + return output +} + +async function queryGitPath(cwd: string, argument: "--show-toplevel" | "--git-common-dir"): Promise { + const { stdout } = await execFileAsync("git", ["rev-parse", argument], { cwd, windowsHide: true }) + return stripGitLineTerminator(stdout) +} + +export async function queryGitRepositoryPaths( + cwd: string, + query: typeof queryGitPath = queryGitPath, +): Promise<{ topLevel: string; commonDir: string }> { + const topLevel = await query(cwd, "--show-toplevel") + const commonDir = await query(cwd, "--git-common-dir") + return { topLevel, commonDir } +} diff --git a/packages/server/src/workspaces/git-worktrees.ts b/packages/server/src/workspaces/git-worktrees.ts index 1c606ccbe..1001c29f0 100644 --- a/packages/server/src/workspaces/git-worktrees.ts +++ b/packages/server/src/workspaces/git-worktrees.ts @@ -2,6 +2,7 @@ import path from "path" import { spawn } from "child_process" import type { WorktreeDescriptor } from "../api-types" import { promises as fsp } from "fs" +import { stripGitLineTerminator } from "./git-output" export interface LogLike { debug?: (obj: any, msg?: string) => void @@ -67,7 +68,7 @@ export async function resolveRepoRoot(folder: string, logger?: LogLike): Promise } throw result.error } - const repoRoot = result.stdout.trim() + const repoRoot = stripGitLineTerminator(result.stdout) if (!repoRoot) { return { repoRoot: folder, isGitRepo: false } } @@ -79,26 +80,30 @@ export async function isGitAvailable(folder: string): Promise { return result.ok || !isGitUnavailableResult(result) } -function parseWorktreePorcelain(output: string): Array<{ worktree: string; branch?: string; head?: string; detached?: boolean }> { +export 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 }) + records.push({ ...current, worktree: current.worktree }) } current = {} } - for (const line of lines) { - const trimmed = line.trim() - if (!trimmed) { + for (const field of output.split("\0")) { + if (!field) { flush() continue } - const [key, ...rest] = trimmed.split(" ") - const value = rest.join(" ").trim() + const separator = field.indexOf(" ") + const key = separator === -1 ? field : field.slice(0, separator) + const value = separator === -1 ? "" : field.slice(separator + 1) if (key === "worktree") { current.worktree = value } else if (key === "branch") { @@ -121,7 +126,7 @@ export async function listWorktrees(params: { }): Promise { const { repoRoot, workspaceFolder, logger } = params - const result = await runGit(["worktree", "list", "--porcelain"], workspaceFolder) + const result = await runGit(["worktree", "list", "--porcelain", "-z"], workspaceFolder) if (!result.ok) { const message = result.stderr ?? result.error.message if (/not a git repository/i.test(message)) { diff --git a/packages/server/src/workspaces/manager.test.ts b/packages/server/src/workspaces/manager.test.ts index b0c27164b..df74df8b2 100644 --- a/packages/server/src/workspaces/manager.test.ts +++ b/packages/server/src/workspaces/manager.test.ts @@ -21,6 +21,7 @@ import { WorkspaceShutdownError, } from "./manager" import { resolveRepositoryMutationKey } from "./workspace-identity" +import type { WorkspaceLifetimeLease } from "./workspace-lifetime-lease" function deferred() { let resolve!: (value: T) => void @@ -70,6 +71,8 @@ function createHarness(options: { launchTimeoutMs?: number setTimeout?: (callback: () => void, delayMs: number) => ReturnType clearTimeout?: (timer: ReturnType) => void + acquireLifetimeLease?: (workspaceFolder: string, workspaceId: string) => Promise + withRepositoryMutation?: WorkspaceManagerConstructorOptions["withRepositoryMutation"] } = {}) { const { stubReadiness = true, ...managerOptions } = options const eventBus = new EventBus() @@ -104,6 +107,8 @@ function createHarness(options: { return { manager, runtime, readiness, started, stopped, mutationGate } } +type WorkspaceManagerConstructorOptions = ConstructorParameters[0] + async function createReady(harness: ReturnType, folder = process.cwd()) { const creation = harness.manager.create(folder) const workspaceId = await harness.runtime.launchCalled.promise @@ -197,6 +202,32 @@ describe("workspace manager lifecycle", () => { assert.equal(exclusiveStarted, true) }) + it("retains a ready runtime and lifetime lease when repository admission cleanup fails", async () => { + let leaseReleases = 0 + const releaseFailure = new Error("repository admission release failed") + const harness = createHarness({ + acquireLifetimeLease: async () => ({ + token: "release-failure-lease", + directoryKey: "release-failure-directory", + repositoryKey: "release-failure-repository", + release: async () => { leaseReleases += 1 }, + }), + withRepositoryMutation: async ({ operation }) => { + await operation("release-failure-repository") + throw releaseFailure + }, + }) + const creation = harness.manager.create(process.cwd()) + const workspaceId = await harness.runtime.launchCalled.promise + harness.runtime.resolveLaunch() + harness.readiness.resolve(undefined) + + await assert.rejects(creation, (error) => error === releaseFailure) + assert.equal(harness.manager.get(workspaceId)?.status, "ready") + assert.equal(harness.runtime.active.has(workspaceId), true) + assert.equal(leaseReleases, 0) + }) + it("keeps unpublished stop failures as repository blockers", async () => { const harness = createHarness() const creation = harness.manager.create(process.cwd()) @@ -278,6 +309,42 @@ describe("workspace manager lifecycle", () => { }) } + for (const operation of ["cancel", "shutdown"] as const) { + it(`keeps ${operation} behind a paused lifetime lease acquisition`, async () => { + const leaseStarted = deferred() + const leaseReady = deferred() + let leaseReleases = 0 + const harness = createHarness({ + acquireLifetimeLease: async (_workspaceFolder, workspaceId) => { + leaseStarted.resolve(workspaceId) + return leaseReady.promise + }, + }) + const requestId = operation === "cancel" ? "paused-lease-cancel" : undefined + const creation = harness.manager.create(process.cwd(), undefined, requestId ? { requestId } : {}) + const workspaceId = await leaseStarted.promise + + const cleanup = operation === "cancel" + ? harness.manager.cancelCreationRequest(requestId!) + : harness.manager.shutdown() + await Promise.resolve() + assert.equal(harness.runtime.stopCalls, 0) + assert.equal(harness.runtime.active.size, 0) + + leaseReady.resolve({ + token: "paused-lease", + directoryKey: "paused-directory", + repositoryKey: "paused-repository", + release: async () => { leaseReleases += 1 }, + }) + await assert.rejects(creation, WorkspaceLaunchCancelledError) + await cleanup + assert.equal(leaseReleases, 1) + assert.equal(harness.runtime.active.size, 0) + assert.equal(harness.manager.get(workspaceId), undefined) + }) + } + it("shares failed cleanup and allows a later delete retry", async () => { const harness = createHarness() const workspaceId = await createReady(harness) diff --git a/packages/server/src/workspaces/manager.ts b/packages/server/src/workspaces/manager.ts index d1a5257e0..4f5806512 100644 --- a/packages/server/src/workspaces/manager.ts +++ b/packages/server/src/workspaces/manager.ts @@ -76,6 +76,8 @@ interface WorkspaceManagerOptions { setTimeout?: (callback: () => void, delayMs: number) => ManagerTimeout clearTimeout?: (timer: ManagerTimeout) => void mutationGate?: InstanceMutationGate + acquireLifetimeLease?: typeof acquireWorkspaceLifetimeLease + withRepositoryMutation?: typeof withRepositoryMutation } interface WorkspaceRecord extends WorkspaceDescriptor { @@ -349,7 +351,6 @@ export class WorkspaceManager { } } try { - return await this.withLaunchRepository(workspacePath, launchDeadlineAt, launchTimeoutMs, options.signal, async () => { if (options.requestId && this.cancelledCreationRequests.has(options.requestId)) { throw new Error(`Workspace creation request ${options.requestId} was cancelled`) } @@ -359,7 +360,6 @@ export class WorkspaceManager { if (options.forceNew) { const ownership = this.createOwnership(options.requestId) const record = this.reserveWorkspace(workspacePath, identityKey, name, options, ownership, launchDeadlineAt) - await this.acquireLifetimeLease(record) const result = await this.startCreation(record, options, launchDeadlineAt, launchTimeoutMs) return this.finishCreation(result, options.requestId, ownership) } @@ -385,7 +385,6 @@ export class WorkspaceManager { } const ownership = this.createOwnership(options.requestId) const record = this.reserveWorkspace(workspacePath, identityKey, name, options, ownership, launchDeadlineAt) - await this.acquireLifetimeLease(record) const creation = this.startCreation(record, options, launchDeadlineAt, launchTimeoutMs) this.pendingWorkspaceCreations.set(identityKey, record) identityAdmission?.resolve() @@ -399,7 +398,6 @@ export class WorkspaceManager { this.pendingWorkspaceCreations.delete(identityKey) } } - }) } finally { identityAdmission?.resolve() if (this.pendingWorkspaceAdmissions.get(identityKey) === identityAdmission) { @@ -457,8 +455,27 @@ export class WorkspaceManager { } private startCreation(record: WorkspaceRecord, options: WorkspaceCreateOptions, launchDeadlineAt: number, launchTimeoutMs: number): Promise { - const creation = this.mutationGate.exclusive(record.id, - () => this.createWithDeadline(record, options, launchDeadlineAt, launchTimeoutMs)) + const creation = this.mutationGate.exclusive(record.id, async () => { + let startupSucceeded = false + try { + return await this.withLaunchRepository(record.path, launchDeadlineAt, launchTimeoutMs, options.signal, async () => { + this.throwIfCancelled(record) + if (this.shuttingDown) throw new Error("Workspace manager is shutting down") + await this.acquireLifetimeLease(record) + const result = await this.createWithDeadline(record, options, launchDeadlineAt, launchTimeoutMs) + startupSucceeded = true + return result + }) + } catch (error) { + const state = record[WORKSPACE_STATE] + if (!startupSucceeded && !state.cleanupUnconfirmed && this.workspaces.get(record.id) === record) { + await state.lifetimeLease?.release() + state.lifetimeLease = undefined + this.removeRecord(record.id, record, false) + } + throw error + } + }) record[WORKSPACE_STATE].creation = creation record[WORKSPACE_STATE].settlement = creation.then(() => undefined, () => undefined) return creation @@ -466,7 +483,10 @@ export class WorkspaceManager { private async acquireLifetimeLease(record: WorkspaceRecord): Promise { try { - record[WORKSPACE_STATE].lifetimeLease = await acquireWorkspaceLifetimeLease(record.path, record.id) + record[WORKSPACE_STATE].lifetimeLease = await (this.options.acquireLifetimeLease ?? acquireWorkspaceLifetimeLease)( + record.path, + record.id, + ) } catch (error) { this.removeRecord(record.id, record, false) throw error @@ -517,7 +537,7 @@ export class WorkspaceManager { }, timeoutMs) try { if (signal?.aborted) abort() - return await withRepositoryMutation({ + return await (this.options.withRepositoryMutation ?? withRepositoryMutation)({ workspaceFolder: workspacePath, gate: this.mutationGate, signal: controller.signal, diff --git a/packages/server/src/workspaces/process-identity.test.ts b/packages/server/src/workspaces/process-identity.test.ts index 73029fcb0..0356eba75 100644 --- a/packages/server/src/workspaces/process-identity.test.ts +++ b/packages/server/src/workspaces/process-identity.test.ts @@ -5,6 +5,7 @@ import { readFileSync } from "node:fs" import { describe, it } from "node:test" import { + managerHostIdentity, managerProcessIdentity, probePosixProcesses, probeWindowsProcesses, probeWslProcesses, sameProcess, signalOwnedPosixProcessGroup, signalPosixProcesses, signalWindowsProcesses, @@ -12,16 +13,16 @@ import { } from "./process-identity" type Spawn = typeof import("node:child_process").spawnSync -type Call = { command: string; args: readonly string[]; script: string } +type Call = { command: string; args: readonly string[]; script: string; env?: NodeJS.ProcessEnv } const output = (stdout = "", status = 0, stderr = ""): SpawnSyncReturns => ({ pid: 1, output: [null, stdout, stderr], stdout, stderr, status, signal: null }) -const spawn = (stdout: string, call?: Call, status = 0, stderr = "") => ((command: string, args: readonly string[]) => { - if (call) Object.assign(call, { command, args, script: command === "powershell.exe" ? args.at(-1) ?? "" : args[args.indexOf("-c") + 1] ?? "" }) +const spawn = (stdout: string, call?: Call, status = 0, stderr = "") => ((command: string, args: readonly string[], options?: { env?: NodeJS.ProcessEnv }) => { + if (call) Object.assign(call, { command, args, script: command === "powershell.exe" ? args.at(-1) ?? "" : args[args.indexOf("-c") + 1] ?? "", env: options?.env }) return output(stdout, status, stderr) }) as unknown as Spawn const b64 = (value: string) => Buffer.from(value).toString("base64") const identity = (startTime = "123456"): ProcessIdentity => - ({ pid: 42, parentPid: 1, groupId: 42, startTime, bootId: "boot-a", startOrder: startTime }) + ({ hostId: managerProcessIdentity.hostId, pid: 42, parentPid: 1, groupId: 42, startTime, bootId: "boot-a", startOrder: startTime }) describe("process identity probes", () => { it("parses immutable Linux identities", () => { @@ -64,6 +65,7 @@ describe("process identity probes", () => { const start = "Fri Jul 10 12:34:56 2026" const probe = probePosixProcesses(spawn(`42 1 42 ${start} ${command}\n`, call), 25, "darwin") assert.deepEqual([call.command, call.args], ["ps", ["-axo", "pid=,ppid=,pgid=,lstart=,comm="]]) + assert.equal(call.env?.TZ, "UTC") assert.equal(probe.ok && probe.processes.get(42)?.startTime, `${start}\t${command}`) }) @@ -122,7 +124,8 @@ describe("process identity probes", () => { it("rejects PID reuse and invalid start ordering", () => { const original = identity("9") - for (const [candidate, expected] of [[{ ...original }, true], [{ ...original, startTime: "10" }, false], [{ ...original, pid: 43 }, false]] as const) + for (const [candidate, expected] of [[{ ...original }, true], [{ ...original, startTime: "10" }, false], + [{ ...original, pid: 43 }, false], [{ ...original, hostId: "another-host" }, false]] as const) assert.equal(sameProcess(original, candidate), expected) assert.equal(startedNoLaterThan(original, "10"), true) assert.equal(startedNoLaterThan({ ...original, startOrder: "11" }, "10"), false) @@ -130,6 +133,7 @@ describe("process identity probes", () => { }) it("persists one immutable manager start identity", () => { + assert.match(managerHostIdentity, /^[a-f0-9]{64}$/) assert.equal(managerProcessIdentity.pid, process.pid) assert.equal(Object.isFrozen(managerProcessIdentity), true) assert.equal(sameProcess(managerProcessIdentity, { ...managerProcessIdentity, startTime: `${managerProcessIdentity.startTime}-reused` }), false) diff --git a/packages/server/src/workspaces/process-identity.ts b/packages/server/src/workspaces/process-identity.ts index 826b56e05..dee758169 100644 --- a/packages/server/src/workspaces/process-identity.ts +++ b/packages/server/src/workspaces/process-identity.ts @@ -1,6 +1,53 @@ import { spawnSync, type SpawnSyncReturns } from "node:child_process" +import { createHash } from "node:crypto" +import { readFileSync } from "node:fs" +import os from "node:os" + +function commandMachineIdentity(command: string, args: string[], pattern: RegExp): string | undefined { + try { + const result = spawnSync(command, args, { encoding: "utf8", timeout: 2_000, windowsHide: true }) + if (result.status !== 0) return undefined + return String(result.stdout ?? "").match(pattern)?.[1]?.trim() + } catch { + return undefined + } +} + +function stableMachineIdentity(): string | undefined { + if (process.platform === "linux") { + for (const file of ["/etc/machine-id", "/var/lib/dbus/machine-id"]) { + try { + const value = readFileSync(file, "utf8").trim() + if (value) return value + } catch { + // Try the next OS identity source. + } + } + } + if (process.platform === "darwin") { + return commandMachineIdentity("ioreg", ["-rd1", "-c", "IOPlatformExpertDevice"], /"IOPlatformUUID"\s*=\s*"([^"]+)"/) + } + if (process.platform === "win32") { + return commandMachineIdentity("reg.exe", ["query", String.raw`HKLM\SOFTWARE\Microsoft\Cryptography`, "/v", "MachineGuid"], + /MachineGuid\s+REG_\w+\s+([^\r\n]+)/i) + } + return undefined +} + +function safeHostname(): string { + try { + return os.hostname().trim().toLowerCase() || "unknown-host" + } catch { + return "unknown-host" + } +} + +export const managerHostIdentity = createHash("sha256") + .update(`${process.platform}\0${stableMachineIdentity() ?? `hostname:${safeHostname()}`}`) + .digest("hex") export interface ProcessIdentity { + hostId?: string pid: number parentPid: number groupId: number @@ -97,7 +144,7 @@ printf 'CODENOMAD_RESULT|%s|%s|%s\n' "$matched" "$cutoff" "$signal_sent" ` const POSIX_IDENTITY_FUNCTIONS = String.raw` -LC_ALL=C; export LC_ALL; set -f +LC_ALL=C; LANG=C; TZ=UTC; export LC_ALL LANG TZ; set -f encode() { printf '%s' "$1" | base64 | tr -d '\r\n'; } read_identity() { current_meta=$(ps -p "$1" -o ppid= -o pgid= -o lstart= -o comm= 2>/dev/null) || return 1 @@ -207,7 +254,7 @@ function parseDelimitedSnapshot(output: string, requireBootId = false): Map 0 ? groupId : pid, startTime, + processes.set(pid, { hostId: managerHostIdentity, pid, parentPid, groupId: Number.isInteger(groupId) && groupId > 0 ? groupId : pid, startTime, ...(bootId ? { bootId } : {}), ...(startOrder ? { startOrder } : {}) }) } return processes @@ -241,7 +288,7 @@ function parseBase64Snapshot(output: string, prefix = "CODENOMAD_B64|"): Map spawnCommand("ps", ["-axo", "pid=,ppid=,pgid=,lstart=,comm="], { - encoding: "utf8", timeout: timeoutMs, env: { ...process.env, LC_ALL: "C", LANG: "C" }, + encoding: "utf8", timeout: timeoutMs, env: { ...process.env, LC_ALL: "C", LANG: "C", TZ: "UTC" }, }), (output) => parsePortablePosixSnapshot(output, filter), { allowEmpty: Boolean(filter) }, diff --git a/packages/server/src/workspaces/repository-lock-ownership.ts b/packages/server/src/workspaces/repository-lock-ownership.ts index 64c68c2b7..f1ee2c782 100644 --- a/packages/server/src/workspaces/repository-lock-ownership.ts +++ b/packages/server/src/workspaces/repository-lock-ownership.ts @@ -2,15 +2,22 @@ import { randomUUID } from "node:crypto" import { spawnSync } from "node:child_process" import { lstat, mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises" import path from "node:path" +import { performance } from "node:perf_hooks" import { setTimeout as delay } from "node:timers/promises" import { managerProcessIdentity, + managerHostIdentity, probeHostProcess, sameProcess, type ProcessIdentity, + type ProcessSnapshot, } from "./process-identity" const POLL_MS = 40 +const HEARTBEAT_MS = 1_000 +// ponytail: bounded file heartbeats are the cross-host liveness primitive; add a shared lease service only if needed. +export const OWNERSHIP_EXPIRY_MS = 10_000 +const remoteHeartbeatObservations = new Map() interface OwnerRecord { token: string @@ -18,10 +25,18 @@ interface OwnerRecord { identity: ProcessIdentity } +export interface OwnershipHeartbeat { + token: string + hostId: string + updatedAt: number +} + interface Claim { path: string token: string owner?: OwnerRecord + heartbeat?: OwnershipHeartbeat + heartbeatFence?: string ticket?: number } @@ -56,7 +71,51 @@ function validOwner(value: unknown, token: string): value is OwnerRecord { const identity = owner?.identity return owner?.token === token && Number.isFinite(owner.createdAt) && Boolean(identity) && Number.isSafeInteger(identity?.pid) && (identity?.pid ?? 0) > 0 && typeof identity?.startTime === "string" && - identity.startTime.length > 0 && (!managerProcessIdentity.bootId || typeof identity.bootId === "string") + identity.startTime.length > 0 && typeof identity.hostId === "string" && identity.hostId.length > 0 && + (identity.bootId === undefined || typeof identity.bootId === "string") +} + +function validHeartbeat(value: unknown, token: string, hostId: string): value is OwnershipHeartbeat { + const heartbeat = value as Partial | null + return heartbeat?.token === token && heartbeat.hostId === hostId && Number.isFinite(heartbeat.updatedAt) +} + +async function readHeartbeat(directory: string, token: string, hostId: string): Promise<{ + heartbeat?: OwnershipHeartbeat + fence?: string +}> { + try { + const fence = await readFile(path.join(directory, "heartbeat.json"), "utf8") + const parsed = JSON.parse(fence) as unknown + return validHeartbeat(parsed, token, hostId) ? { heartbeat: parsed, fence } : {} + } catch (error) { + if (errorCode(error) === "ENOENT" || error instanceof SyntaxError) return {} + throw error + } +} + +async function writeHeartbeat(directory: string, token: string): Promise { + const temporaryPath = path.join(directory, `.heartbeat-${randomUUID()}`) + const heartbeat: OwnershipHeartbeat = { token, hostId: managerHostIdentity, updatedAt: Date.now() } + try { + await writeFile(temporaryPath, JSON.stringify(heartbeat), { flag: "wx", mode: 0o600 }) + await rename(temporaryPath, path.join(directory, "heartbeat.json")) + } finally { + await rm(temporaryPath, { force: true }) + } +} + +export function maintainOwnershipHeartbeat(directory: string, token: string): () => Promise { + let update: Promise | undefined + const timer = setInterval(() => { + if (update) return + update = writeHeartbeat(directory, token).catch(() => undefined).finally(() => { update = undefined }) + }, HEARTBEAT_MS) + timer.unref() + return async () => { + clearInterval(timer) + await update + } } async function readClaim(claimsPath: string, token: string): Promise { @@ -72,7 +131,8 @@ async function readClaim(claimsPath: string, token: string): Promise { .filter((claim): claim is Claim => Boolean(claim)) } -export function processIdentityIsAlive(identity: ProcessIdentity): boolean { +export function processIdentityIsAlive( + identity: ProcessIdentity, + claimPath: string, + heartbeatFence: string, + now = performance.now(), + probe: (pid: number) => ProcessSnapshot = (pid) => probeHostProcess(spawnSync, pid, 2_000), +): boolean { + if (identity.hostId !== managerHostIdentity) { + const previous = remoteHeartbeatObservations.get(claimPath) + if (!previous || previous.fence !== heartbeatFence || now < previous.since) { + remoteHeartbeatObservations.set(claimPath, { fence: heartbeatFence, since: now }) + return true + } + return now - previous.since <= OWNERSHIP_EXPIRY_MS + } if (sameProcess(identity, managerProcessIdentity)) return true - const snapshot = probeHostProcess(spawnSync, identity.pid, 2_000) + const snapshot = probe(identity.pid) if (!snapshot.ok) return true return sameProcess(identity, snapshot.processes.get(identity.pid)) } -export async function retireOwnershipClaim(claimPath: string): Promise { +export async function retireOwnershipClaim(claimPath: string, expectedHeartbeat: string): Promise { const retiredPath = path.join(path.dirname(claimPath), `.retired-${path.basename(claimPath)}-${randomUUID()}`) + try { + if (await readFile(path.join(claimPath, "heartbeat.json"), "utf8") !== expectedHeartbeat) return false + } catch (error) { + if (errorCode(error) === "ENOENT") return false + throw error + } try { await rename(claimPath, retiredPath) } catch (error) { - if (errorCode(error) === "ENOENT") return + if (errorCode(error) === "ENOENT") return false throw error } + if (await readFile(path.join(retiredPath, "heartbeat.json"), "utf8") !== expectedHeartbeat) { + await rename(retiredPath, claimPath) + return false + } await rm(retiredPath, { recursive: true, force: true }) + remoteHeartbeatObservations.delete(claimPath) + return true +} + +export async function retireCurrentOwnershipClaim(claimPath: string): Promise { + try { + return retireOwnershipClaim(claimPath, await readFile(path.join(claimPath, "heartbeat.json"), "utf8")) + } catch (error) { + if (errorCode(error) === "ENOENT") return true + throw error + } } async function wait(signal?: AbortSignal): Promise { @@ -123,11 +218,14 @@ export async function acquireOwnershipQueue(lockPath: string, signal?: AbortSign const token = randomUUID() const preparationPath = path.join(claimsPath, `.prepare-${token}`) const claimPath = path.join(claimsPath, token) + let stopHeartbeat: (() => Promise) | undefined await mkdir(preparationPath) try { const owner: OwnerRecord = { token, createdAt: Date.now(), identity: managerProcessIdentity } await writeFile(path.join(preparationPath, "owner.json"), JSON.stringify(owner), { flag: "wx", mode: 0o600 }) + await writeHeartbeat(preparationPath, token) await rename(preparationPath, claimPath) + stopHeartbeat = maintainOwnershipHeartbeat(claimPath, token) const existing = await claims(claimsPath) const ticket = Math.max(0, ...existing.map((claim) => claim.ticket ?? 0)) + 1 @@ -146,8 +244,9 @@ export async function acquireOwnershipQueue(lockPath: string, signal?: AbortSign const precedes = claim.ticket === undefined || claim.ticket < ticket || (claim.ticket === ticket && claim.token < token) if (!precedes) continue - if (!processIdentityIsAlive(claim.owner.identity)) { - await retireOwnershipClaim(claim.path) + if (!claim.heartbeat) throw new Error(`Malformed repository ownership heartbeat: ${claim.path}`) + if (!processIdentityIsAlive(claim.owner.identity, claim.path, claim.heartbeatFence!)) { + if (!await retireOwnershipClaim(claim.path, claim.heartbeatFence!)) continue continue } blocked = true @@ -160,11 +259,18 @@ export async function acquireOwnershipQueue(lockPath: string, signal?: AbortSign let released = false return async () => { if (released) return - await retireOwnershipClaim(claimPath) - released = true + await stopHeartbeat?.() + try { + await retireCurrentOwnershipClaim(claimPath) + released = true + } catch (error) { + stopHeartbeat = maintainOwnershipHeartbeat(claimPath, token) + throw error + } } } catch (error) { - await retireOwnershipClaim(claimPath) + await stopHeartbeat?.() + await retireCurrentOwnershipClaim(claimPath) await rm(preparationPath, { recursive: true, force: true }) throw error } diff --git a/packages/server/src/workspaces/repository-mutation-lock.test.ts b/packages/server/src/workspaces/repository-mutation-lock.test.ts index fa243530f..5b8210bea 100644 --- a/packages/server/src/workspaces/repository-mutation-lock.test.ts +++ b/packages/server/src/workspaces/repository-mutation-lock.test.ts @@ -1,13 +1,18 @@ import assert from "node:assert/strict" import { spawn } from "node:child_process" import { execFileSync } from "node:child_process" -import { access, mkdir, mkdtemp, readFile, readdir, rename, rm, stat, symlink, writeFile } from "node:fs/promises" +import { access, chmod, mkdir, mkdtemp, readFile, readdir, rename, rm, stat, symlink, writeFile } from "node:fs/promises" import os from "node:os" import path from "node:path" import { afterEach, describe, it } from "node:test" import { fileURLToPath } from "node:url" import { managerProcessIdentity } from "./process-identity" -import { ensurePrivateLockRoot, retireOwnershipClaim } from "./repository-lock-ownership" +import { + ensurePrivateLockRoot, + OWNERSHIP_EXPIRY_MS, + processIdentityIsAlive, + retireOwnershipClaim, +} from "./repository-lock-ownership" import { acquireRepositoryMutation } from "./repository-mutation-lock" import { readWorktreeMapStrict } from "./worktree-map" @@ -95,6 +100,140 @@ describe("repository mutation lock", () => { await first.release() }) + it("places a missing non-Git destination lock under its canonical writable parent", async () => { + const directory = await plainDirectory() + const destination = path.join(directory, "missing", "workspace") + const admission = await acquireRepositoryMutation({ workspaceFolder: destination }) + try { + assert.equal([...admission.lockPaths].some((lockPath) => + lockPath.startsWith(path.join(directory, ".codenomad", "repository-locks"))), true) + } finally { + await admission.release() + } + }) + + it("keeps an existing empty clone destination empty during admission", async () => { + const directory = await plainDirectory() + const destination = path.join(directory, "workspace") + await mkdir(destination) + const admission = await acquireRepositoryMutation({ workspaceFolder: destination }) + try { + assert.deepEqual(await readdir(destination), []) + assert.equal([...admission.lockPaths].some((lockPath) => + lockPath.startsWith(path.join(directory, ".codenomad", "repository-locks"))), true) + } finally { + await admission.release() + } + }) + + it("accepts a non-private general .codenomad directory but protects the dedicated lock root", + { skip: process.platform === "win32" }, async () => { + const directory = await plainDirectory() + const destination = path.join(directory, "workspace") + const generalDirectory = path.join(directory, ".codenomad") + await mkdir(destination) + await mkdir(generalDirectory) + await chmod(generalDirectory, 0o755) + const admission = await acquireRepositoryMutation({ workspaceFolder: destination }) + try { + assert.equal((await stat(generalDirectory)).mode & 0o777, 0o755) + assert.equal((await stat(path.join(generalDirectory, "repository-locks"))).mode & 0o777, 0o700) + } finally { + await admission.release() + } + }) + + it("uses local fence observation for foreign heartbeats regardless of remote clock skew", () => { + const foreignIdentity = { ...managerProcessIdentity, hostId: "foreign-host", pid: process.pid } + const claimPath = path.join(os.tmpdir(), "foreign-clock-skew-claim") + const oldClockFence = JSON.stringify({ token: "foreign", hostId: "foreign-host", updatedAt: -1e15 }) + assert.equal(processIdentityIsAlive(foreignIdentity, claimPath, oldClockFence, 100, () => { + throw new Error("foreign PID must not be probed") + }), true) + assert.equal(processIdentityIsAlive(foreignIdentity, claimPath, oldClockFence, + 101 + OWNERSHIP_EXPIRY_MS), false) + + const futureClockFence = JSON.stringify({ token: "foreign", hostId: "foreign-host", updatedAt: 1e15 }) + assert.equal(processIdentityIsAlive(foreignIdentity, claimPath, futureClockFence, + 101 + OWNERSHIP_EXPIRY_MS), true) + }) + + it("fails closed when a local process probe fails", () => { + const identity = { ...managerProcessIdentity, pid: managerProcessIdentity.pid + 1, startTime: "other-start" } + assert.equal(processIdentityIsAlive(identity, "local-claim", "local-fence", 0, + () => ({ ok: false, error: "probe unavailable" })), true) + }) + + it("fences foreign-host expiry by heartbeat without probing its PID locally", async () => { + const directory = await repository() + const claimPath = path.join(directory, ".git", "codenomad", "mutation.lock", "claims", "foreign") + await mkdir(claimPath, { recursive: true }) + const foreignIdentity = { ...managerProcessIdentity, hostId: "foreign-host", startTime: "foreign-start" } + await writeFile(path.join(claimPath, "owner.json"), JSON.stringify({ + token: "foreign", + createdAt: Date.now(), + identity: foreignIdentity, + })) + const heartbeatPath = path.join(claimPath, "heartbeat.json") + const freshAt = Date.now() + const freshFence = JSON.stringify({ token: "foreign", hostId: "foreign-host", updatedAt: freshAt }) + await writeFile(heartbeatPath, freshFence) + await writeFile(path.join(claimPath, "ticket"), "1") + + const controller = new AbortController() + const reason = new Error("fresh foreign owner") + setTimeout(() => controller.abort(reason), 100) + await assert.rejects(acquireRepositoryMutation({ workspaceFolder: directory, signal: controller.signal }), + (error) => error === reason) + + const replacementFence = JSON.stringify({ token: "foreign", hostId: "foreign-host", updatedAt: freshAt + 1 }) + await writeFile(heartbeatPath, replacementFence) + assert.equal(await retireOwnershipClaim(claimPath, freshFence), false) + const staleFence = JSON.stringify({ + token: "foreign", + hostId: "foreign-host", + updatedAt: Date.now() - OWNERSHIP_EXPIRY_MS - 1, + }) + await writeFile(heartbeatPath, staleFence) + processIdentityIsAlive(foreignIdentity, claimPath, staleFence, -OWNERSHIP_EXPIRY_MS - 1) + const admission = await acquireRepositoryMutation({ workspaceFolder: directory }) + await admission.release() + await assert.rejects(access(claimPath), { code: "ENOENT" }) + }) + + it("retries repository admission cleanup after a release failure", async () => { + const directory = await plainDirectory() + let releaseAttempts = 0 + const gate = { + acquireExclusive: async () => async () => { + releaseAttempts += 1 + if (releaseAttempts === 1) throw new Error("transient release failure") + }, + } + const admission = await acquireRepositoryMutation({ workspaceFolder: directory, gate }) + await assert.rejects(admission.release(), /transient release failure/) + await admission.release() + assert.equal(releaseAttempts, 2) + }) + + it("retries a transient release while cleaning up failed admission", async () => { + const directory = await repository() + let acquisitions = 0 + let releaseAttempts = 0 + const gate = { + acquireExclusive: async () => { + acquisitions += 1 + if (acquisitions === 2) throw new Error("identity admission failed") + return async () => { + releaseAttempts += 1 + if (releaseAttempts === 1) throw new Error("transient cleanup failure") + } + }, + } + await assert.rejects(acquireRepositoryMutation({ workspaceFolder: directory, gate }), /identity admission failed/) + assert.equal(releaseAttempts, 2) + }) + it("recovers an owner whose PID has been reused", async () => { const directory = await repository() const claimPath = path.join(directory, ".git", "codenomad", "mutation.lock", "claims", "reused") @@ -104,6 +243,11 @@ describe("repository mutation lock", () => { createdAt: Date.now(), identity: { ...managerProcessIdentity, startTime: `${managerProcessIdentity.startTime}-reused` }, })) + await writeFile(path.join(claimPath, "heartbeat.json"), JSON.stringify({ + token: "reused", + hostId: managerProcessIdentity.hostId, + updatedAt: Date.now(), + })) await writeFile(path.join(claimPath, "ticket"), "1") await child(["hold", directory, "0", path.join(directory, "marker.txt")]) await assert.rejects(access(claimPath), { code: "ENOENT" }) @@ -151,11 +295,12 @@ describe("repository mutation lock", () => { const claimsPath = path.join(lockPath!, "claims") const [firstClaim] = (await readdir(claimsPath)).filter((name) => !name.startsWith(".")) assert.ok(firstClaim) + const firstFence = await readFile(path.join(claimsPath, firstClaim, "heartbeat.json"), "utf8") await rename(path.join(claimsPath, firstClaim), path.join(claimsPath, `.detached-${firstClaim}`)) const replacement = child(["controlled", directory, "replacement", control]) await waitForFile(path.join(control, "replacement.ready")) - await retireOwnershipClaim(path.join(claimsPath, firstClaim)) + await retireOwnershipClaim(path.join(claimsPath, firstClaim), firstFence) await writeFile(path.join(control, "first.release"), "") await Promise.all([first, waitForFile(path.join(control, "first.done"))]) diff --git a/packages/server/src/workspaces/repository-mutation-lock.ts b/packages/server/src/workspaces/repository-mutation-lock.ts index 6c648b0cb..dd7093a36 100644 --- a/packages/server/src/workspaces/repository-mutation-lock.ts +++ b/packages/server/src/workspaces/repository-mutation-lock.ts @@ -1,45 +1,88 @@ import { createHash } from "node:crypto" -import { mkdir } from "node:fs/promises" -import os from "node:os" +import { access, constants, lstat, mkdir, realpath } from "node:fs/promises" import path from "node:path" import { AsyncLocalStorage } from "node:async_hooks" import type { InstanceMutationGate } from "../server/instance-mutation-gate" import { acquireOwnershipQueue, ensurePrivateLockRoot } from "./repository-lock-ownership" import { canonicalFilesystemIdentity, repositoryMutationKey, resolveRepositoryIdentity } from "./workspace-identity" +import { ensurePrivateStateDirectory, serverStateRoot } from "./state-root" const heldLocks = new AsyncLocalStorage>() -const fallbackRoot = path.join(os.tmpdir(), `codenomad-repository-locks-${createHash("sha256") - .update(`${os.userInfo().username}\0${os.homedir()}`) - .digest("hex") - .slice(0, 16)}`) +const fallbackRoot = path.join(serverStateRoot, "repository-locks") function fallbackLockPath(key: string): string { const digest = createHash("sha256").update(key).digest("hex") return path.join(fallbackRoot, `${digest}.lock`) } -function lockPathForIdentity(identity: Awaited>): string { +async function adjacentFallbackLockPath(workspaceFolder: string, key: string): Promise { + let candidate = path.dirname(path.resolve(workspaceFolder)) + while (true) { + try { + const canonicalParent = await realpath(candidate) + const metadata = await lstat(canonicalParent) + if (!metadata.isDirectory() || metadata.isSymbolicLink()) return undefined + if (process.platform !== "win32" && process.getuid && metadata.uid !== process.getuid()) return undefined + await access(canonicalParent, constants.W_OK) + const digest = createHash("sha256").update(key).digest("hex") + return path.join(canonicalParent, ".codenomad", "repository-locks", `${digest}.lock`) + } catch (error) { + const code = (error as NodeJS.ErrnoException).code + if (!["ENOENT", "ENOTDIR"].includes(code ?? "")) return undefined + const parent = path.dirname(candidate) + if (parent === candidate) return undefined + candidate = parent + } + } +} + +async function fallbackLockPathForWorkspace(workspaceFolder: string, key: string): Promise { + return (await adjacentFallbackLockPath(workspaceFolder, key)) ?? fallbackLockPath(key) +} + +async function lockPathForIdentity( + identity: Awaited>, + workspaceFolder: string, +): Promise { return identity.commonDir ? path.join(identity.commonDir, "codenomad", "mutation.lock") - : fallbackLockPath(identity.mutationKey) + : fallbackLockPathForWorkspace(workspaceFolder, identity.mutationKey) } async function acquireFileLock(lockPath: string, signal?: AbortSignal): Promise<() => Promise> { - if (path.dirname(lockPath) === fallbackRoot) await ensurePrivateLockRoot(fallbackRoot) - else await mkdir(path.dirname(lockPath), { recursive: true }) + if (path.dirname(lockPath) === fallbackRoot) { + await ensurePrivateStateDirectory() + await ensurePrivateLockRoot(fallbackRoot) + } else if (path.basename(path.dirname(lockPath)) === "repository-locks") { + await mkdir(path.dirname(path.dirname(lockPath)), { recursive: true }) + await ensurePrivateLockRoot(path.dirname(lockPath)) + } else { + await ensurePrivateLockRoot(path.dirname(lockPath)) + } return acquireOwnershipQueue(lockPath, signal) } async function releaseAll(releases: Array<() => void | Promise>): Promise { - const failures: unknown[] = [] - for (const release of releases.reverse()) { - try { - await release() - } catch (error) { - failures.push(error) + while (releases.length > 0) { + await releases[releases.length - 1]!() + releases.pop() + } +} + +async function cleanupAll(releases: Array<() => void | Promise>): Promise { + let failures: unknown[] = [] + for (let attempt = 0; attempt < 2 && releases.length > 0; attempt += 1) { + failures = [] + for (let index = releases.length - 1; index >= 0; index -= 1) { + try { + await releases[index]!() + releases.splice(index, 1) + } catch (error) { + failures.push(error) + } } } - if (failures.length) throw new AggregateError(failures, "Failed to release repository mutation admission") + if (failures.length > 0) throw new AggregateError(failures, "Failed to clean up repository mutation admission") } export async function acquireRepositoryMutation(params: { @@ -67,16 +110,21 @@ export async function acquireRepositoryMutation(params: { try { const lexicalKey = repositoryMutationKey(canonicalFilesystemIdentity(params.workspaceFolder)) - await acquire(lexicalKey, fallbackLockPath(lexicalKey)) + const initialIdentity = await resolveRepositoryIdentity(params.workspaceFolder) + await acquire(lexicalKey, initialIdentity.isGitRepository + ? fallbackLockPath(lexicalKey) + : await fallbackLockPathForWorkspace(params.workspaceFolder, lexicalKey)) while (true) { const identity = await resolveRepositoryIdentity(params.workspaceFolder) - const identityLockPath = lockPathForIdentity(identity) + const identityLockPath = await lockPathForIdentity(identity, params.workspaceFolder) try { await acquire(identity.mutationKey, identityLockPath) } catch (error) { const code = (error as NodeJS.ErrnoException).code if (!identity.commonDir || !["EACCES", "EPERM", "EROFS"].includes(code ?? "")) throw error - await acquire(identity.mutationKey, fallbackLockPath(identity.mutationKey)) + const sharedFallback = await adjacentFallbackLockPath(identity.commonDir, identity.mutationKey) + if (!sharedFallback) throw error + await acquire(identity.mutationKey, sharedFallback) } const confirmed = await resolveRepositoryIdentity(params.workspaceFolder) if (confirmed.mutationKey === identity.mutationKey) { @@ -86,14 +134,14 @@ export async function acquireRepositoryMutation(params: { lockPaths: acquiredPaths, release: async () => { if (released) return - released = true await releaseAll(releases) + released = true }, } } } } catch (error) { - await releaseAll(releases) + await cleanupAll(releases) throw error } } diff --git a/packages/server/src/workspaces/spawn.ts b/packages/server/src/workspaces/spawn.ts index 1b29bf939..8eb371638 100644 --- a/packages/server/src/workspaces/spawn.ts +++ b/packages/server/src/workspaces/spawn.ts @@ -67,7 +67,10 @@ export type WslWorkingDirectory = | { kind: "windows"; path: string } export function parseWslUncPath(input: string): WslPath | null { - const normalized = input.trim().replace(/\//g, "\\") + const normalized = input.trim() + .replace(/^\\\\\?\\UNC[\\/]/i, "\\\\") + .replace(/^\\\\\?\\/, "") + .replace(/\//g, "\\") const match = normalized.match(WSL_UNC_PATH_REGEX) if (!match) { return null diff --git a/packages/server/src/workspaces/state-root.test.ts b/packages/server/src/workspaces/state-root.test.ts new file mode 100644 index 000000000..c0aa31159 --- /dev/null +++ b/packages/server/src/workspaces/state-root.test.ts @@ -0,0 +1,36 @@ +import assert from "node:assert/strict" +import { mkdtemp, rm, stat } from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { describe, it } from "node:test" +import { ensurePrivateStateDirectory, resolveServerStateRoot } from "./state-root" + +describe("server state root", () => { + it("uses the OS account home independently of home environment variables", () => { + const expected = resolveServerStateRoot(os.userInfo().homedir, process.platform) + const previous = { HOME: process.env.HOME, USERPROFILE: process.env.USERPROFILE } + try { + process.env.HOME = path.join(os.tmpdir(), "different-home") + process.env.USERPROFILE = path.join(os.tmpdir(), "different-profile") + assert.equal(resolveServerStateRoot(), expected) + } finally { + if (previous.HOME === undefined) delete process.env.HOME + else process.env.HOME = previous.HOME + if (previous.USERPROFILE === undefined) delete process.env.USERPROFILE + else process.env.USERPROFILE = previous.USERPROFILE + } + }) + + it("creates and verifies a private per-user directory", async () => { + const parent = await mkdtemp(path.join(os.tmpdir(), "codenomad-state-root-test-")) + const directory = path.join(parent, "state") + try { + await ensurePrivateStateDirectory(directory) + const metadata = await stat(directory) + assert.equal(metadata.isDirectory(), true) + if (process.platform !== "win32") assert.equal(metadata.mode & 0o077, 0) + } finally { + await rm(parent, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/server/src/workspaces/state-root.ts b/packages/server/src/workspaces/state-root.ts new file mode 100644 index 000000000..b4bd67e69 --- /dev/null +++ b/packages/server/src/workspaces/state-root.ts @@ -0,0 +1,30 @@ +import { lstat, mkdir } from "node:fs/promises" +import os from "node:os" +import path from "node:path" + +export function resolveServerStateRoot( + homeDirectory = os.userInfo().homedir, + platform: NodeJS.Platform = process.platform, +): string { + if (platform === "win32") return path.win32.join(homeDirectory, "AppData", "Local", "CodeNomad", "state") + if (platform === "darwin") return path.posix.join(homeDirectory, "Library", "Application Support", "CodeNomad", "state") + return path.posix.join(homeDirectory, ".local", "state", "codenomad") +} + +export const serverStateRoot = resolveServerStateRoot() + +export async function ensurePrivateStateDirectory(directory = serverStateRoot): Promise { + await mkdir(directory, { recursive: true, mode: 0o700 }) + const metadata = await lstat(directory) + if (!metadata.isDirectory() || metadata.isSymbolicLink()) { + throw new Error(`Server state path is not a real directory: ${directory}`) + } + if (process.platform !== "win32") { + if (process.getuid && metadata.uid !== process.getuid()) { + throw new Error(`Server state directory is owned by another user: ${directory}`) + } + if ((metadata.mode & 0o077) !== 0) { + throw new Error(`Server state directory must not be accessible by other users: ${directory}`) + } + } +} diff --git a/packages/server/src/workspaces/workspace-identity.ts b/packages/server/src/workspaces/workspace-identity.ts index f4082b165..9c8a6b0e4 100644 --- a/packages/server/src/workspaces/workspace-identity.ts +++ b/packages/server/src/workspaces/workspace-identity.ts @@ -1,14 +1,16 @@ import { realpath, stat } from "node:fs/promises" import { realpathSync } from "node:fs" -import { execFile } from "node:child_process" import path from "node:path" -import { promisify } from "node:util" +import { queryGitRepositoryPaths } from "./git-output" -const execFileAsync = promisify(execFile) const WSL_UNC_PATH_REGEX = /^\\\\wsl(?:\.localhost|\$)\\([^\\/]+)(?:[\\/](.*))?$/i +function withoutWindowsExtendedPrefix(value: string): string { + return value.replace(/^\\\\\?\\UNC[\\/]/i, "\\\\").replace(/^\\\\\?\\/, "") +} + function wslUncIdentity(value: string): string | null { - const match = value.trim().replace(/\//g, "\\").match(WSL_UNC_PATH_REGEX) + const match = withoutWindowsExtendedPrefix(value.trim()).replace(/\//g, "\\").match(WSL_UNC_PATH_REGEX) if (!match) return null const linuxPath = `/${(match[2] ?? "").split(/\\+/).filter(Boolean).join("/")}` return `wsl:${match[1]!.toLowerCase()}:${path.posix.normalize(linuxPath)}` @@ -16,9 +18,7 @@ function wslUncIdentity(value: string): string | null { export function normalizeWorkspaceIdentityPath(value: string, platform: NodeJS.Platform = process.platform): string { const pathApi = platform === "win32" ? path.win32 : path.posix - const withoutExtendedPrefix = platform === "win32" - ? value.replace(/^\\\\\?\\UNC\\/i, "\\\\").replace(/^\\\\\?\\/, "") - : value + const withoutExtendedPrefix = platform === "win32" ? withoutWindowsExtendedPrefix(value) : value const normalized = pathApi.normalize(withoutExtendedPrefix) return platform === "win32" ? normalized.toLowerCase() : normalized } @@ -27,11 +27,12 @@ export function canonicalFilesystemIdentity( value: string, platform: NodeJS.Platform = process.platform, ): string { - const wslIdentity = platform === "win32" ? wslUncIdentity(value) : null + const input = platform === "win32" ? withoutWindowsExtendedPrefix(value) : value + const wslIdentity = platform === "win32" ? wslUncIdentity(input) : null if (wslIdentity) { if (platform === process.platform) { try { - return wslUncIdentity(realpathSync.native(path.win32.resolve(value))) ?? wslIdentity + return wslUncIdentity(realpathSync.native(path.win32.resolve(input))) ?? wslIdentity } catch { // Keep a stable lexical identity for missing WSL paths. } @@ -39,13 +40,9 @@ export function canonicalFilesystemIdentity( return wslIdentity } const pathApi = platform === "win32" ? path.win32 : path.posix - const absolutePath = pathApi.resolve(value) + const absolutePath = pathApi.resolve(input) if (platform === process.platform) { - try { - return normalizeWorkspaceIdentityPath(realpathSync.native(absolutePath), platform) - } catch { - // Missing paths still need a stable, alias-aware lexical identity. - } + return normalizeWorkspaceIdentityPath(canonicalFilesystemPathSync(absolutePath), platform) } return normalizeWorkspaceIdentityPath(absolutePath, platform) } @@ -85,19 +82,44 @@ export class RepositoryIdentityError extends Error { } } -async function canonicalFilesystemPath(value: string): Promise { +function canonicalFilesystemPathSync(value: string): string { + const absolutePath = path.resolve(value) + let ancestor = absolutePath + const suffix: string[] = [] + while (true) { + try { + return path.resolve(realpathSync.native(ancestor), ...suffix.reverse()) + } catch (error) { + if (!["ENOENT", "ENOTDIR"].includes((error as NodeJS.ErrnoException).code ?? "")) return absolutePath + const parent = path.dirname(ancestor) + if (parent === ancestor) return absolutePath + suffix.push(path.basename(ancestor)) + ancestor = parent + } + } +} + +export async function canonicalFilesystemPath(value: string): Promise { const absolutePath = path.resolve(value) - return path.normalize(await realpath(absolutePath).catch(() => absolutePath)) + let ancestor = absolutePath + const suffix: string[] = [] + while (true) { + try { + return path.resolve(await realpath(ancestor), ...suffix.reverse()) + } catch (error) { + if (!["ENOENT", "ENOTDIR"].includes((error as NodeJS.ErrnoException).code ?? "")) return absolutePath + const parent = path.dirname(ancestor) + if (parent === ancestor) return absolutePath + suffix.push(path.basename(ancestor)) + ancestor = parent + } + } } export async function resolveRepositoryIdentity(folder: string): Promise { const workspacePath = await canonicalFilesystemPath(folder) try { - const { stdout } = await execFileAsync("git", ["rev-parse", "--show-toplevel", "--git-common-dir"], { - cwd: workspacePath, - windowsHide: true, - }) - const [repositoryRootValue, commonDirValue] = stdout.trim().split(/\r?\n/) + const { topLevel: repositoryRootValue, commonDir: commonDirValue } = await queryGitRepositoryPaths(workspacePath) if (repositoryRootValue && commonDirValue) { const repositoryRoot = await canonicalFilesystemPath(repositoryRootValue) const commonDir = await canonicalFilesystemPath(path.isAbsolute(commonDirValue) @@ -150,10 +172,10 @@ export async function resolveWorkspaceIdentity( : normalizeWorkspaceIdentityPath(workspacePath), } } catch { - // Preserve the existing launch behavior when the path cannot be resolved yet. + const workspacePath = await canonicalFilesystemPath(submittedPath) return { - workspacePath: submittedPath, - identityKey: normalizeWorkspaceIdentityPath(submittedPath), + workspacePath, + identityKey: normalizeWorkspaceIdentityPath(workspacePath), } } } diff --git a/packages/server/src/workspaces/workspace-lifetime-lease.ts b/packages/server/src/workspaces/workspace-lifetime-lease.ts index cb75b8180..01405a79f 100644 --- a/packages/server/src/workspaces/workspace-lifetime-lease.ts +++ b/packages/server/src/workspaces/workspace-lifetime-lease.ts @@ -1,10 +1,17 @@ -import { createHash, randomUUID } from "node:crypto" +import { randomUUID } from "node:crypto" import { lstat, mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises" -import os from "node:os" import path from "node:path" import { managerProcessIdentity, type ProcessIdentity } from "./process-identity" -import { ensurePrivateLockRoot, processIdentityIsAlive, retireOwnershipClaim } from "./repository-lock-ownership" +import { + ensurePrivateLockRoot, + maintainOwnershipHeartbeat, + processIdentityIsAlive, + retireCurrentOwnershipClaim, + retireOwnershipClaim, + type OwnershipHeartbeat, +} from "./repository-lock-ownership" import { canonicalFilesystemIdentity, resolveRepositoryIdentity } from "./workspace-identity" +import { ensurePrivateStateDirectory, serverStateRoot } from "./state-root" interface LeaseRecord { token: string @@ -23,10 +30,7 @@ export interface WorkspaceLifetimeLease { release: () => Promise } -const leaseRoot = path.join(os.tmpdir(), `codenomad-workspace-leases-${createHash("sha256") - .update(`${os.userInfo().username}\0${os.homedir()}`) - .digest("hex") - .slice(0, 16)}`) +const leaseRoot = path.join(serverStateRoot, "workspace-leases") function isLeaseRecord(value: unknown, token: string): value is LeaseRecord { const record = value as Partial | null @@ -36,15 +40,27 @@ function isLeaseRecord(value: unknown, token: string): value is LeaseRecord { && Number.isFinite(record.createdAt) && Boolean(record.owner) && Number.isSafeInteger(record.owner?.pid) && (record.owner?.pid ?? 0) > 0 && typeof record.owner?.startTime === "string" && record.owner.startTime.length > 0 + && typeof record.owner?.hostId === "string" && record.owner.hostId.length > 0 } -async function readLease(entryName: string): Promise<{ path: string; record?: LeaseRecord } | undefined> { +async function readLease(entryName: string): Promise<{ + path: string + record?: LeaseRecord + heartbeat?: OwnershipHeartbeat + heartbeatFence?: string +} | undefined> { const leasePath = path.join(leaseRoot, entryName) try { const metadata = await lstat(leasePath) if (!metadata.isDirectory() || metadata.isSymbolicLink()) return { path: leasePath } const parsed = JSON.parse(await readFile(path.join(leasePath, "lease.json"), "utf8")) as unknown - return { path: leasePath, ...(isLeaseRecord(parsed, entryName) ? { record: parsed } : {}) } + if (!isLeaseRecord(parsed, entryName)) return { path: leasePath } + const heartbeatFence = await readFile(path.join(leasePath, "heartbeat.json"), "utf8") + const heartbeat = JSON.parse(heartbeatFence) as OwnershipHeartbeat + if (heartbeat.token !== entryName || heartbeat.hostId !== parsed.owner.hostId || !Number.isFinite(heartbeat.updatedAt)) { + return { path: leasePath, record: parsed } + } + return { path: leasePath, record: parsed, heartbeat, heartbeatFence } } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined if (error instanceof SyntaxError) return { path: leasePath } @@ -53,6 +69,7 @@ async function readLease(entryName: string): Promise<{ path: string; record?: Le } async function activeLeases(): Promise { + await ensurePrivateStateDirectory() await ensurePrivateLockRoot(leaseRoot) const entries = await readdir(leaseRoot, { withFileTypes: true }) const leases = await Promise.all(entries @@ -61,9 +78,9 @@ async function activeLeases(): Promise { const active: LeaseRecord[] = [] for (const lease of leases) { if (!lease) continue - if (!lease.record) throw new Error(`Malformed workspace lifetime lease: ${lease.path}`) - if (!processIdentityIsAlive(lease.record.owner)) { - await retireOwnershipClaim(lease.path) + if (!lease.record || !lease.heartbeat) throw new Error(`Malformed workspace lifetime lease: ${lease.path}`) + if (!processIdentityIsAlive(lease.record.owner, lease.path, lease.heartbeatFence!)) { + if (!await retireOwnershipClaim(lease.path, lease.heartbeatFence!)) continue continue } active.push(lease.record) @@ -75,6 +92,7 @@ export async function acquireWorkspaceLifetimeLease( workspaceFolder: string, workspaceId: string, ): Promise { + await ensurePrivateStateDirectory() await ensurePrivateLockRoot(leaseRoot) const identity = await resolveRepositoryIdentity(workspaceFolder) const token = randomUUID() @@ -92,11 +110,17 @@ export async function acquireWorkspaceLifetimeLease( await mkdir(preparationPath, { mode: 0o700 }) try { await writeFile(path.join(preparationPath, "lease.json"), JSON.stringify(record), { flag: "wx", mode: 0o600 }) + await writeFile(path.join(preparationPath, "heartbeat.json"), JSON.stringify({ + token, + hostId: managerProcessIdentity.hostId, + updatedAt: Date.now(), + }), { flag: "wx", mode: 0o600 }) await rename(preparationPath, leasePath) } catch (error) { await rm(preparationPath, { recursive: true, force: true }) throw error } + let stopHeartbeat = maintainOwnershipHeartbeat(leasePath, token) let released = false return { token, @@ -104,8 +128,14 @@ export async function acquireWorkspaceLifetimeLease( repositoryKey: record.repositoryKey, release: async () => { if (released) return - await retireOwnershipClaim(leasePath) - released = true + await stopHeartbeat() + try { + await retireCurrentOwnershipClaim(leasePath) + released = true + } catch (error) { + stopHeartbeat = maintainOwnershipHeartbeat(leasePath, token) + throw error + } }, } } diff --git a/packages/server/src/workspaces/worktree-deletion.test.ts b/packages/server/src/workspaces/worktree-deletion.test.ts index 8d778070d..d69014773 100644 --- a/packages/server/src/workspaces/worktree-deletion.test.ts +++ b/packages/server/src/workspaces/worktree-deletion.test.ts @@ -21,8 +21,10 @@ function harness(options: { legacySlug?: string mapSlug?: string mapReadError?: Error + emptyNativeInventory?: boolean + missingTargetInventory?: boolean } = {}) { - const sessions: TestSession[] = [ + const sessions: TestSession[] = options.emptyNativeInventory ? [] : [ { id: "root-session", directory: "/repo-other", @@ -55,8 +57,8 @@ function harness(options: { }, workspace: { syncList: async () => ({ data: true }), - list: async () => ({ data: [ - { id: "feature-workspace", directory: "/repo-feature" }, + list: async () => ({ data: options.emptyNativeInventory ? [] : [ + ...(!options.missingTargetInventory ? [{ id: "feature-workspace", directory: "/repo-feature" }] : []), { id: "other-workspace", directory: "/repo-other" }, ] }), remove: async () => { calls.push("remove-native"); return { data: true } }, @@ -168,4 +170,19 @@ describe("deleteWorktreeTransaction", () => { await test.run() assert.equal(test.calls.some((call) => call.startsWith("map:")), false) }) + + it("deletes an empty worktree absent from native workspace inventory", async () => { + const test = harness({ emptyNativeInventory: true }) + await test.run() + + assert.equal(test.calls.includes("git"), true) + assert.equal(test.calls.some((call) => call.startsWith("warp:")), false) + assert.equal(test.calls.includes("remove-native"), false) + }) + + it("fails closed when a session workspace is absent from native inventory", async () => { + const test = harness({ missingTargetInventory: true }) + await assert.rejects(test.run, /Unable to account for session child-session workspace feature-workspace/) + assert.deepEqual(test.calls, []) + }) }) diff --git a/packages/server/src/workspaces/worktree-deletion.ts b/packages/server/src/workspaces/worktree-deletion.ts index 40ab2fa78..e6a262bdd 100644 --- a/packages/server/src/workspaces/worktree-deletion.ts +++ b/packages/server/src/workspaces/worktree-deletion.ts @@ -9,7 +9,7 @@ import { familiesAtWorktree, loadNativeSessionState, moveNativeSessions, - resolveTargetLocation, + sameWorktreeDirectory, WorktreeRollbackIncompleteError, } from "./worktree-session-move" @@ -42,10 +42,16 @@ export async function deleteWorktreeTransaction(params: DeleteWorktreeTransactio const nativeWorkspaceFolder = params.nativeWorkspaceFolder ?? params.workspaceFolder const nativeTarget = { ...params.target, directory: params.nativeTargetDirectory ?? params.target.directory } const state = await loadNativeSessionState(params.client, nativeWorkspaceFolder) + const workspaceIds = new Set(state.workspaces.map((workspace) => workspace.id)) + const unaccountedSession = state.sessions.find((session) => session.workspaceID && !workspaceIds.has(session.workspaceID)) + if (unaccountedSession) { + throw new Error(`Unable to account for session ${unaccountedSession.id} workspace ${unaccountedSession.workspaceID}`) + } const selected = familiesAtWorktree(state, nativeTarget) const movedRootIds = new Set(selected.filter((session) => !session.parentID).map((session) => session.id)) assertSessionsMovable(state, selected) - const targetWorkspaceId = resolveTargetLocation(state, nativeTarget).workspaceId + const targetWorkspaceId = state.workspaces.find((workspace) => + sameWorktreeDirectory(workspace.directory, nativeTarget.directory))?.id const prepared = await moveNativeSessions(state, selected, { workspaceId: null, directory: nativeWorkspaceFolder, diff --git a/packages/server/src/workspaces/worktree-map.ts b/packages/server/src/workspaces/worktree-map.ts index 54f3d328d..e3f400d3d 100644 --- a/packages/server/src/workspaces/worktree-map.ts +++ b/packages/server/src/workspaces/worktree-map.ts @@ -5,6 +5,7 @@ import path from "path" import { promisify } from "node:util" import type { WorktreeMap } from "../api-types" import type { LogLike } from "./git-worktrees" +import { queryGitRepositoryPaths } from "./git-output" import { canonicalFilesystemIdentity, resolveRepositoryIdentity } from "./workspace-identity" import { withRepositoryMutation } from "./repository-mutation-lock" @@ -38,6 +39,13 @@ export class WorktreeMapRevisionConflictError extends Error { } } +export class WorktreeMapConflictError extends WorktreeMapReadError { + constructor(message: string) { + super(message) + this.name = "WorktreeMapConflictError" + } +} + function defaultMap(): WorktreeMap { return { ...DEFAULT_MAP, parentSessionWorktreeSlug: {} } } @@ -53,9 +61,12 @@ async function resolveWorktreeMapPaths(workspaceFolder: string): Promise { +function sameMapContents(left: WorktreeMap, right: WorktreeMap): boolean { + const leftEntries = Object.entries(left.parentSessionWorktreeSlug).sort(([a], [b]) => a.localeCompare(b)) + const rightEntries = Object.entries(right.parentSessionWorktreeSlug).sort(([a], [b]) => a.localeCompare(b)) + return left.defaultWorktreeSlug === right.defaultWorktreeSlug + && JSON.stringify(leftEntries) === JSON.stringify(rightEntries) +} + +async function readExistingMap( + paths: WorktreeMapPaths, + logger?: LogLike, + requireLegacyRetirement = false, +): Promise { const [canonical, ...legacyMaps] = await Promise.all([ readMapFile(paths.filePath), ...paths.legacyPaths.map(readMapFile), @@ -193,12 +215,26 @@ async function readExistingMap(paths: WorktreeMapPaths): Promise= Math.max(...sources.map(({ map }) => map.revision ?? 0))) { + merged = canonical + } await writeMapFile(paths.filePath, JSON.stringify(merged, null, 2)) - await Promise.all(legacySources.map(({ filePath }) => fsp.rm(filePath))) + const retirementFailures: unknown[] = [] + await Promise.all(legacySources.map(async ({ filePath }) => { + await fsp.rm(filePath, { force: true }).catch((error) => { + logger?.warn?.({ err: error, filePath }, "Failed to remove published legacy worktree map") + retirementFailures.push(error) + }) + })) + if (requireLegacyRetirement && retirementFailures.length) { + throw new AggregateError(retirementFailures, "Failed to retire legacy worktree maps") + } return merged } @@ -207,7 +243,7 @@ export async function readWorktreeMapStrict(workspaceFolder: string, logger?: Lo workspaceFolder, operation: async () => { const paths = await resolveWorktreeMapPaths(workspaceFolder) - const map = await readExistingMap(paths) + const map = await readExistingMap(paths, logger) if (!map) { if (paths.gitExcludePath) await ensureGitExclude(paths.gitExcludePath, logger).catch(() => undefined) return { exists: false, map: defaultMap() } @@ -234,7 +270,7 @@ export async function writeWorktreeMap( ): Promise { await withRepositoryMutation({ workspaceFolder, operation: async () => { const paths = await resolveWorktreeMapPaths(workspaceFolder) - const current = await readExistingMap(paths) + const current = await readExistingMap(paths, logger, true) if (expectedRevision !== undefined && (current?.revision ?? 0) !== expectedRevision) { throw new WorktreeMapRevisionConflictError() } @@ -259,7 +295,7 @@ export async function deleteWorktreeMap(workspaceFolder: string, logger?: LogLik await withRepositoryMutation({ workspaceFolder, operation: async () => { const paths = await resolveWorktreeMapPaths(workspaceFolder) try { - await readExistingMap(paths) + await readExistingMap(paths, logger, true) await fsp.rm(paths.filePath, { force: true }) } catch (error) { logger?.warn?.({ err: error, filePath: paths.filePath }, "Failed to delete worktree map") diff --git a/packages/tauri-app/src-tauri/src/worktree_directory.rs b/packages/tauri-app/src-tauri/src/worktree_directory.rs index 76a59368d..665a1ae50 100644 --- a/packages/tauri-app/src-tauri/src/worktree_directory.rs +++ b/packages/tauri-app/src-tauri/src/worktree_directory.rs @@ -11,6 +11,7 @@ use std::os::unix::fs::MetadataExt; #[cfg(windows)] use std::os::windows::ffi::{OsStrExt, OsStringExt}; use std::path::{Path, PathBuf}; +use std::time::Duration; use tauri::{AppHandle, State, WebviewWindow}; use tauri_plugin_opener::OpenerExt; use url::Url; @@ -30,6 +31,9 @@ use windows_sys::Win32::System::WindowsProgramming::DRIVE_REMOTE; const DRIVE_REMOTE: u32 = 4; const MAX_WORKTREE_RESPONSE_BYTES: usize = 1024 * 1024; +const WORKTREE_CONNECT_TIMEOUT: Duration = Duration::from_secs(5); +const WORKTREE_READ_TIMEOUT: Duration = Duration::from_secs(5); +const WORKTREE_TOTAL_TIMEOUT: Duration = Duration::from_secs(15); #[cfg(windows)] #[derive(Clone, Copy, PartialEq, Eq)] @@ -96,6 +100,12 @@ fn validate_windows_final_path( Ok(()) } +#[cfg(windows)] +fn same_windows_path(left: &Path, right: &Path) -> bool { + left.to_string_lossy() + .eq_ignore_ascii_case(&right.to_string_lossy()) +} + #[cfg(windows)] fn open_directory_handle(path: &Path) -> Result<(HANDLE, DirectoryIdentity, PathBuf), String> { let wide = path @@ -152,6 +162,10 @@ impl VerifiedDirectory { #[cfg(windows)] { let (handle, identity, final_path) = open_directory_handle(&path)?; + if !same_windows_path(&path, &final_path) { + unsafe { CloseHandle(handle) }; + return Err("Worktree changed before it could be opened".to_string()); + } Ok(Self { path, handle, @@ -184,12 +198,8 @@ impl VerifiedDirectory { let (handle, identity, final_path) = open_directory_handle(&self.path)?; unsafe { CloseHandle(handle) }; if identity != self.identity - || !held_final_path - .to_string_lossy() - .eq_ignore_ascii_case(&self.final_path.to_string_lossy()) - || !final_path - .to_string_lossy() - .eq_ignore_ascii_case(&self.final_path.to_string_lossy()) + || !same_windows_path(&held_final_path, &self.final_path) + || !same_windows_path(&final_path, &self.final_path) { return Err("Worktree changed before it could be opened".to_string()); } @@ -371,16 +381,32 @@ async fn read_bounded_body(mut response: reqwest::Response) -> Result, S .unwrap_or(0) .min(MAX_WORKTREE_RESPONSE_BYTES as u64) as usize, ); - while let Some(chunk) = response - .chunk() - .await - .map_err(|_| "Worktree lookup failed")? - { + while let Some(chunk) = response.chunk().await.map_err(worktree_lookup_error)? { append_bounded(&mut body, &chunk)?; } Ok(body) } +fn worktree_lookup_error(error: reqwest::Error) -> String { + if error.is_timeout() { + "Worktree lookup timed out".to_string() + } else { + "Worktree lookup failed".to_string() + } +} + +fn apply_worktree_timeouts( + builder: reqwest::ClientBuilder, + connect: Duration, + read: Duration, + total: Duration, +) -> reqwest::ClientBuilder { + builder + .connect_timeout(connect) + .read_timeout(read) + .timeout(total) +} + fn dispatch_after_validation( validate: impl FnOnce() -> Result<(), String>, dispatch: impl FnOnce() -> Result<(), String>, @@ -448,9 +474,14 @@ pub(crate) async fn open_local_directory( validate_authority(&window, &client_state, &access_token, &config.base_url)?; let endpoint = worktree_endpoint(&config.base_url, &instance_id)?; - let mut builder = reqwest::Client::builder() - .no_proxy() - .redirect(Policy::none()); + let mut builder = apply_worktree_timeouts( + reqwest::Client::builder() + .no_proxy() + .redirect(Policy::none()), + WORKTREE_CONNECT_TIMEOUT, + WORKTREE_READ_TIMEOUT, + WORKTREE_TOTAL_TIMEOUT, + ); if endpoint.scheme() == "https" { let cert = cert_manager::ensure_local_cert()?; let ca = @@ -466,7 +497,7 @@ pub(crate) async fn open_local_directory( if let Some(value) = session_cookie.filter(|value| !value.is_empty()) { request = request.header(COOKIE, format!("{}={}", config.cookie_name, value)); } - let response = request.send().await.map_err(|err| err.to_string())?; + let response = request.send().await.map_err(worktree_lookup_error)?; if response.status() != reqwest::StatusCode::OK || response.url() != &endpoint { return Err("Worktree lookup failed".to_string()); } @@ -615,6 +646,20 @@ mod tests { assert_eq!(body.len(), 600_000); } + #[test] + fn lookup_connect_total_and_read_deadlines_are_bounded() { + assert!(WORKTREE_CONNECT_TIMEOUT <= WORKTREE_TOTAL_TIMEOUT); + assert!(WORKTREE_READ_TIMEOUT <= WORKTREE_TOTAL_TIMEOUT); + apply_worktree_timeouts( + reqwest::Client::builder().no_proxy(), + WORKTREE_CONNECT_TIMEOUT, + WORKTREE_READ_TIMEOUT, + WORKTREE_TOTAL_TIMEOUT, + ) + .build() + .unwrap(); + } + #[test] fn navigation_during_canonicalization_revokes_final_dispatch() { let generation = Cell::new(1); @@ -691,7 +736,7 @@ mod tests { #[cfg(windows)] #[test] - fn held_handle_resolves_an_intermediate_directory_link() { + fn initial_handle_path_must_match_the_canonical_input() { use std::os::windows::fs::symlink_dir; let root = tempfile::tempdir().unwrap(); @@ -701,13 +746,12 @@ mod tests { if symlink_dir(&target, &linked).is_err() { return; } - let verified = VerifiedDirectory::open(linked.join("feature")).unwrap(); - assert!(verified - .final_path - .to_string_lossy() - .to_ascii_lowercase() - .ends_with(r"target\feature")); - verified.revalidate().unwrap(); + assert_eq!( + VerifiedDirectory::open(linked.join("feature")) + .err() + .unwrap(), + "Worktree changed before it could be opened" + ); } #[test] diff --git a/packages/ui/src/components/browser-frame.tsx b/packages/ui/src/components/browser-frame.tsx index 2bb338722..195069df8 100644 --- a/packages/ui/src/components/browser-frame.tsx +++ b/packages/ui/src/components/browser-frame.tsx @@ -1,6 +1,6 @@ import { ArrowLeft, ArrowRight, ChevronDown, Expand, Monitor, RefreshCw, RotateCw, Smartphone, Tablet } from "lucide-solid" import { Show, createEffect, createMemo, createSignal, type Component } from "solid-js" -import { buildPreviewNavigationUrl, previewFramePolicy, resolvePreviewUrl } from "./preview-isolation" +import { buildPreviewNavigationUrl, resolvePreviewUrl } from "./preview-isolation" interface BrowserFrameLabels { back: string @@ -51,7 +51,6 @@ export const BrowserFrame: Component = (props) => { const [viewportMenuOpen, setViewportMenuOpen] = createSignal(false) let iframeRef: HTMLIFrameElement | undefined - const framePolicy = previewFramePolicy() const viewport = createMemo(() => VIEWPORT_PRESETS[viewportPreset()]) const isResponsiveViewport = createMemo(() => viewportPreset() === "responsive") const selectedViewportOption = createMemo(() => VIEWPORT_OPTIONS.find((option) => option.id === viewportPreset()) ?? VIEWPORT_OPTIONS[0]) @@ -214,8 +213,7 @@ export const BrowserFrame: Component = (props) => { height: viewport().height ? `${viewport().height}px` : "100%", margin: viewport().width ? "0 auto" : "0", }} - referrerPolicy={framePolicy.referrerPolicy} - sandbox={framePolicy.sandbox} + referrerPolicy="same-origin" onLoad={syncPathInputFromFrame} />
diff --git a/packages/ui/src/components/preview-isolation.test.ts b/packages/ui/src/components/preview-isolation.test.ts index 7324308f5..40dcc5fd1 100644 --- a/packages/ui/src/components/preview-isolation.test.ts +++ b/packages/ui/src/components/preview-isolation.test.ts @@ -1,21 +1,12 @@ import assert from "node:assert/strict" import test from "node:test" -import { buildPreviewNavigationUrl, previewFramePolicy, resolvePreviewUrl } from "./preview-isolation" +import { buildPreviewNavigationUrl, resolvePreviewUrl } from "./preview-isolation" test("preview URLs retain HTTP networking in local and remote windows", () => { assert.equal(resolvePreviewUrl("/previews/token/page?q=1", "http://127.0.0.1:43123/app"), "http://127.0.0.1:43123/previews/token/page?q=1") assert.equal(resolvePreviewUrl("/previews/token", "https://remote.example/app"), "https://remote.example/previews/token") }) -test("preview sandbox isolates DOM and native bridges without blocking scripts or forms", () => { - const policy = previewFramePolicy() - const tokens = new Set(policy.sandbox.split(/\s+/)) - assert.equal(tokens.has("allow-scripts"), true) - assert.equal(tokens.has("allow-forms"), true) - assert.equal(tokens.has("allow-same-origin"), false) - assert.equal([...tokens].some((token) => token.startsWith("allow-top-navigation")), false) -}) - test("preview address navigation preserves local and remote proxy bases", () => { assert.equal( buildPreviewNavigationUrl("nested/../next?q=1", "/previews/token", "/previews/token", "http://127.0.0.1:43123/app"), diff --git a/packages/ui/src/components/preview-isolation.ts b/packages/ui/src/components/preview-isolation.ts index 81ca7da0e..2af70357b 100644 --- a/packages/ui/src/components/preview-isolation.ts +++ b/packages/ui/src/components/preview-isolation.ts @@ -1,17 +1,7 @@ -export const PREVIEW_FRAME_SANDBOX = "allow-forms allow-scripts" - export function resolvePreviewUrl(url: string, baseUrl: string): string { return new URL(url, baseUrl).href } -export function previewFramePolicy() { - // ponytail: credentialless strips the HttpOnly session required by the preview and websocket proxy. - return { - sandbox: PREVIEW_FRAME_SANDBOX, - referrerPolicy: "same-origin" as const, - } -} - export function buildPreviewNavigationUrl(rawInput: string, proxyBasePath: string, initialUrl: string, baseUrl: string): string { const trimmed = rawInput.trim() const frameUrl = new URL(initialUrl, baseUrl) diff --git a/packages/ui/src/components/worktree-selector.tsx b/packages/ui/src/components/worktree-selector.tsx index 7d9f96e12..f2085b8ca 100644 --- a/packages/ui/src/components/worktree-selector.tsx +++ b/packages/ui/src/components/worktree-selector.tsx @@ -5,7 +5,7 @@ import { ChevronDown, Copy, FolderOpen, Trash2 } from "lucide-solid" import type { WorktreeDescriptor } from "../../../server/src/api-types" import { getLogger } from "../lib/logger" import { copyToClipboard } from "../lib/clipboard" -import { showToastNotification } from "../lib/notifications" +import { getToastHistory, showToastNotification } from "../lib/notifications" import { createWorktree, deleteWorktree, @@ -207,8 +207,8 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) { } } - const handleOpenDirectory = async (worktreeSlug: string) => { - if (await openLocalDirectory(props.instanceId, worktreeSlug)) return + const handleOpenDirectory = async (event: Pick, worktreeSlug: string) => { + if (await openLocalDirectory(event, props.instanceId, worktreeSlug)) return showToastNotification({ message: t("instanceShell.worktree.openDirectory.error"), variant: "error" }) } @@ -314,7 +314,15 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) { onOpenChange={setIsOpen} value={selectedOption() ?? null} onChange={(value) => { - void handleChange(value).catch((error) => log.warn("Failed to change worktree", error)) + const existingErrorToasts = new Set(getToastHistory({ variant: "error" }).map((item) => item.id)) + void handleChange(value).catch((error) => { + log.warn("Failed to change worktree", error) + const message = t("instanceShell.worktree.moveFailed") + const alreadyShown = getToastHistory({ variant: "error" }).some((item) => ( + !existingErrorToasts.has(item.id) && item.message === message + )) + if (!alreadyShown) showToastNotification({ message, variant: "error" }) + }) }} options={worktreeOptions()} optionValue="key" @@ -374,14 +382,15 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) { class="session-item-close opacity-80 hover:opacity-100 hover:bg-surface-hover" aria-label={t("instanceShell.worktree.openDirectory.action")} title={t("instanceShell.worktree.openDirectory.action")} - onPointerDown={(event) => { - preventSelectPress(event) - void handleOpenDirectory(opt.slug).finally(() => setIsOpen(false)) - }} + onPointerDown={preventSelectPress} onPointerUp={preventSelectPress} onMouseDown={preventSelectPress} onMouseUp={preventSelectPress} - onClick={preventSelectPress} + onClick={(event) => { + preventSelectPress(event) + if (!event.isTrusted) return + void handleOpenDirectory(event, opt.slug).finally(() => setIsOpen(false)) + }} > diff --git a/packages/ui/src/lib/hooks/foreground-refresh-controller.ts b/packages/ui/src/lib/hooks/foreground-refresh-controller.ts index 556fb37f8..beb4db1ef 100644 --- a/packages/ui/src/lib/hooks/foreground-refresh-controller.ts +++ b/packages/ui/src/lib/hooks/foreground-refresh-controller.ts @@ -50,7 +50,7 @@ export function createForegroundRefreshController( return } - const delayMs = retryDelaysMs[retryAttempt] + const delayMs = retryDelaysMs[Math.min(retryAttempt, retryDelaysMs.length - 1)] if (delayMs === undefined || retryTimer !== undefined) return retryAttempt += 1 retryTimer = setTimer(() => { diff --git a/packages/ui/src/lib/hooks/use-foreground-refresh.test.ts b/packages/ui/src/lib/hooks/use-foreground-refresh.test.ts index f89bb7ef1..223ee5f2b 100644 --- a/packages/ui/src/lib/hooks/use-foreground-refresh.test.ts +++ b/packages/ui/src/lib/hooks/use-foreground-refresh.test.ts @@ -84,4 +84,35 @@ describe("foreground refresh controller", () => { controller.dispose() }) + it("keeps retrying at the capped delay until recovery succeeds", async () => { + const timers: Array<{ callback: () => void; delayMs: number }> = [] + let calls = 0 + const controller = createForegroundRefreshController( + () => { + calls += 1 + if (calls < 4) throw new Error("temporary") + }, + { + retryDelaysMs: [10, 30], + setTimer: (callback, delayMs) => { + timers.push({ callback, delayMs }) + return timers.length as unknown as ReturnType + }, + clearTimer: () => {}, + }, + ) + + controller.handle("disconnected") + controller.handle("connected") + await tick() + for (let index = 0; index < 3; index += 1) { + timers[index].callback() + await tick() + } + + assert.equal(calls, 4) + assert.deepEqual(timers.map((timer) => timer.delayMs), [10, 30, 30]) + controller.dispose() + }) + }) diff --git a/packages/ui/src/lib/native/client-state.ts b/packages/ui/src/lib/native/client-state.ts index 1f20f9940..f2902458b 100644 --- a/packages/ui/src/lib/native/client-state.ts +++ b/packages/ui/src/lib/native/client-state.ts @@ -48,9 +48,14 @@ export const setNativeRestoreEnabled = (enabled: boolean): Promise => mutateNativeClientState((api) => api.setClientStateRestoreEnabled?.(accessToken, enabled), "client_state_set_restore_enabled", { enabled }) export const clearNativeClientState = (): Promise => mutateNativeClientState((api) => api.clearClientState?.(accessToken), "client_state_clear") -export function invokeClaimedNativeCommand(command: string, args: Record): Promise | undefined { - if (!isTauriHost() || !nativeAccessClaimed) return undefined - return invoke(command, { accessToken, ...args }) +export async function openClaimedLocalDirectory(event: Pick, instanceId: string, worktreeSlug: string): Promise { + if (!event.isTrusted || !nativeAccessClaimed) return false + if (isElectronHost()) return (await electronApi()?.openDirectory?.(accessToken, instanceId, worktreeSlug))?.ok === true + if (isTauriHost()) { + await invoke("open_local_directory", { accessToken, instanceId, worktreeSlug }) + return true + } + return false } function acknowledge(command: string, args: Record = {}): Promise { if (!isTauriHost() || !nativeAccessClaimed) return Promise.resolve() diff --git a/packages/ui/src/lib/native/native-functions.ts b/packages/ui/src/lib/native/native-functions.ts index dc34db57e..8aaeebe3b 100644 --- a/packages/ui/src/lib/native/native-functions.ts +++ b/packages/ui/src/lib/native/native-functions.ts @@ -3,7 +3,7 @@ import { getLogger } from "../logger" import type { NativeDialogOptions } from "./types" import { openElectronNativeDialog } from "./electron/functions" import { openTauriNativeDialog } from "./tauri/functions" -import { invokeClaimedNativeCommand } from "./client-state" +import { openClaimedLocalDirectory } from "./client-state" const log = getLogger("actions") @@ -57,22 +57,13 @@ export function supportsLocalDirectoryOpen(): boolean { return canOpenLocalDirectory() } -export async function openLocalDirectory(instanceId: string, worktreeSlug: string): Promise { +export async function openLocalDirectory(event: Pick, instanceId: string, worktreeSlug: string): Promise { const workspace = instanceId.trim() const slug = worktreeSlug.trim() - if (!workspace || !slug || !canOpenLocalDirectory()) return false + if (!event.isTrusted || !workspace || !slug || !canOpenLocalDirectory()) return false try { - if (isElectronHost()) { - const result = await window.electronAPI?.openDirectory?.(workspace, slug) - return result?.ok === true - } - if (isTauriHost()) { - const invocation = invokeClaimedNativeCommand("open_local_directory", { instanceId: workspace, worktreeSlug: slug }) - if (!invocation) return false - await invocation - return true - } + return await openClaimedLocalDirectory(event, workspace, slug) } catch (error) { log.error("[native] failed to open local directory", error) } diff --git a/packages/ui/src/stores/client-state.test.ts b/packages/ui/src/stores/client-state.test.ts index 882a92348..f4db58982 100644 --- a/packages/ui/src/stores/client-state.test.ts +++ b/packages/ui/src/stores/client-state.test.ts @@ -1,5 +1,6 @@ import assert from "node:assert/strict" import { describe, it } from "node:test" +import { openLocalDirectory } from "../lib/native/native-functions" type ClientState = typeof import("./client-state.ts") type NativeApi = Record any>; type TransactionKind = "clear" | "disable" const layoutKey = "opencode-session-sidebar-width-v8"; let moduleId = 0 @@ -43,7 +44,7 @@ describe("client state ownership and persistence", () => { assert.equal(state.clientStateIsPrimary(), false) assert.equal(loads, 0) }) - it("claims before loading and reuses one renderer token for native operations", async () => { + it("reuses one renderer token and rejects untrusted directory activation", async () => { const storage = new MemoryStorage() storage.setItem(layoutKey, "360") const calls: Array<{ operation: string; token: string }> = [], saved: any[] = [] @@ -52,6 +53,10 @@ describe("client state ownership and persistence", () => { loadClientState: async (token) => { calls.push({ operation: "load", token }); return loadResult() }, saveClientState: async (token, value) => { calls.push({ operation: "save", token }); saved.push(value); return true }, setClientStateRestoreEnabled: async (token, enabled) => { calls.push({ operation: `set:${enabled}`, token }); return true }, + openDirectory: async (token, instanceId, worktreeSlug) => { + calls.push({ operation: `open:${instanceId}:${worktreeSlug}`, token }) + return { ok: true } + }, }, storage) assert.equal(state.readClientLayoutValue(layoutKey), "360") state.updateRestorableSession(session("project")); await state.flushClientState() @@ -61,10 +66,12 @@ describe("client state ownership and persistence", () => { assert.equal(saved[0].layout[layoutKey], "360") assert.equal(saved[0].session.tabs[0].sidecarId, "project") await state.setRestorePreviousStateEnabled(false) + assert.equal(await openLocalDirectory(new Event("click"), "workspace-1", "feature"), false) + assert.equal(await openLocalDirectory({ isTrusted: true }, "workspace-1", "feature"), true) assert.equal(state.restorePreviousStateEnabled(), false) assert.equal(state.loadedRestorableSession(), null) assert.equal(storage.getItem(layoutKey), null) - assert.deepEqual(calls.map(({ operation }) => operation), ["claim", "load", "save", "save", "set:false"]) + assert.deepEqual(calls.map(({ operation }) => operation), ["claim", "load", "save", "save", "set:false", "open:workspace-1:feature"]) assert.match(calls[0]!.token, /^[0-9a-f]{64}$/) assert.ok(calls.every(({ token }) => token === calls[0]!.token)) assert.equal(storage.getItem(calls[0]!.token), null) diff --git a/packages/ui/src/stores/session-api.ts b/packages/ui/src/stores/session-api.ts index e89067fd6..54db7bab5 100644 --- a/packages/ui/src/stores/session-api.ts +++ b/packages/ui/src/stores/session-api.ts @@ -265,7 +265,7 @@ function reconcileListedSessionLocation( } const reconciled = { ...merged, directory: fetched.directory, workspaceId: fetched.workspaceId } if (!latest || latest.directory !== reconciled.directory || latest.workspaceId !== reconciled.workspaceId) { - markAuthoritativeSessionLocation(instanceId, fetched.id) + markAuthoritativeSessionLocation(instanceId, fetched.id, fetched.time?.updated) } return reconciled } diff --git a/packages/ui/src/stores/session-events.ts b/packages/ui/src/stores/session-events.ts index 521690ccc..db35dd4f6 100644 --- a/packages/ui/src/stores/session-events.ts +++ b/packages/ui/src/stores/session-events.ts @@ -71,7 +71,12 @@ import { updateSessionInfo } from "./message-v2/session-info" import { tGlobal } from "../lib/i18n" import { loadMessages, removeSessionRuntimeState } from "./session-api" -import { getSessionLocationEpoch, isStaleSessionLocation, markAuthoritativeSessionLocation } from "./session-location-authority" +import { + getSessionLocationEpoch, + isStaleSessionLocation, + markAuthoritativeSessionLocation, + observeSessionUpdateAuthority, +} from "./session-location-authority" import { forgetOpenCodeWorkspaceIdForSession, rememberOpenCodeWorkspaceIdForSession } from "./opencode-workspaces" import { getRootClient } from "./opencode-client" import { getWorktreeSlugForDirectory, getWorktreeSlugForSession } from "./worktrees" @@ -415,22 +420,31 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo const info = event.properties?.info if (!info) return - const workspaceId = (info as typeof info & { workspaceID?: string }).workspaceID + const locationInfo = info as typeof info & { workspaceID?: string } + const hasDirectory = Object.prototype.hasOwnProperty.call(info, "directory") && typeof info.directory === "string" + const hasWorkspaceId = Object.prototype.hasOwnProperty.call(info, "workspaceID") + const workspaceId = typeof locationInfo.workspaceID === "string" ? locationInfo.workspaceID : undefined + const serverUpdated = typeof info.time?.updated === "number" ? info.time.updated : undefined if (getAuthoritativelyDeletedSessionIdsForInstance(instanceId).has(info.id)) return - const hasLocation = typeof info.directory === "string" || Object.prototype.hasOwnProperty.call(info, "workspaceID") + const hasLocation = hasDirectory || hasWorkspaceId const existingSession = sessions().get(instanceId)?.get(info.id) const staleLocation = hasLocation && Boolean(existingSession) && isStaleSessionLocation(instanceId, info.id, { - directory: info.directory ?? existingSession?.directory, + hasDirectory, + hasWorkspaceId, + directory: info.directory, workspaceId, - }) + }, { + directory: existingSession?.directory, + workspaceId: existingSession?.workspaceId, + }, serverUpdated) const confirmsCurrentLocation = hasLocation && Boolean(existingSession) - && (info.directory ?? existingSession?.directory) === existingSession?.directory - && workspaceId === existingSession?.workspaceId + && (!hasDirectory || info.directory === existingSession?.directory) + && (!hasWorkspaceId || workspaceId === existingSession?.workspaceId) if (hasLocation && !staleLocation && !confirmsCurrentLocation) { - markAuthoritativeSessionLocation(instanceId, info.id) + markAuthoritativeSessionLocation(instanceId, info.id, serverUpdated) forgetOpenCodeWorkspaceIdForSession(instanceId, info.id) - if (workspaceId) rememberOpenCodeWorkspaceIdForSession(instanceId, info.id, workspaceId) - } + if (hasWorkspaceId && workspaceId) rememberOpenCodeWorkspaceIdForSession(instanceId, info.id, workspaceId) + } else if (!staleLocation) observeSessionUpdateAuthority(instanceId, info.id, serverUpdated) const instanceSessions = sessions().get(instanceId) ?? new Map() const currentSession = instanceSessions.get(info.id) @@ -492,12 +506,13 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo const mergedTime = { ...currentSession.time, ...(info.time ?? {}), + updated: Math.max(currentSession.time?.updated ?? 0, info.time?.updated ?? 0), } const updatedSession = { ...currentSession, projectId: info.projectID ?? currentSession.projectId, - workspaceId: staleLocation ? currentSession.workspaceId : workspaceId, - directory: staleLocation ? currentSession.directory : info.directory ?? currentSession.directory, + workspaceId: staleLocation || !hasWorkspaceId ? currentSession.workspaceId : workspaceId, + directory: staleLocation || !hasDirectory ? currentSession.directory : info.directory, title: info.title || currentSession.title, parentId: info.parentID ?? currentSession.parentId, status: currentSession.status ?? "idle", diff --git a/packages/ui/src/stores/session-location-authority.ts b/packages/ui/src/stores/session-location-authority.ts index d1ddf3600..33b86951d 100644 --- a/packages/ui/src/stores/session-location-authority.ts +++ b/packages/ui/src/stores/session-location-authority.ts @@ -1,63 +1,96 @@ import { messageStoreBus } from "./message-v2/bus" -const epochs = new Map() -const stalePredecessors = new Map() +type SessionLocation = { directory?: string; workspaceId?: string } +type SessionLocationUpdate = SessionLocation & { hasDirectory: boolean; hasWorkspaceId: boolean } +type SessionLocationAuthority = { + generation: number + serverUpdated?: number + superseded: SessionLocation[] +} + +const authorities = new Map() function key(instanceId: string, sessionId: string): string { return `${instanceId}:${sessionId}` } +function authority(instanceId: string, sessionId: string): SessionLocationAuthority { + const sessionKey = key(instanceId, sessionId) + const current = authorities.get(sessionKey) + if (current) return current + const created = { generation: 0, superseded: [] } + authorities.set(sessionKey, created) + return created +} + function getSessionLocationEpoch(instanceId: string, sessionId: string): number { - return epochs.get(key(instanceId, sessionId)) ?? 0 + return authorities.get(key(instanceId, sessionId))?.generation ?? 0 } -function markAuthoritativeSessionLocation(instanceId: string, sessionId: string): void { - const sessionKey = key(instanceId, sessionId) - epochs.set(sessionKey, (epochs.get(sessionKey) ?? 0) + 1) - stalePredecessors.delete(sessionKey) +function observeSessionUpdateAuthority(instanceId: string, sessionId: string, serverUpdated?: number): void { + if (typeof serverUpdated !== "number" || !Number.isFinite(serverUpdated)) return + const current = authority(instanceId, sessionId) + current.serverUpdated = Math.max(current.serverUpdated ?? serverUpdated, serverUpdated) +} + +function markAuthoritativeSessionLocation(instanceId: string, sessionId: string, serverUpdated?: number): void { + const current = authority(instanceId, sessionId) + current.generation += 1 + observeSessionUpdateAuthority(instanceId, sessionId, serverUpdated) +} + +function beginSessionLocationRequest(instanceId: string, sessionId: string): number { + markAuthoritativeSessionLocation(instanceId, sessionId) + return getSessionLocationEpoch(instanceId, sessionId) } function commitAuthoritativeSessionLocation( instanceId: string, sessionId: string, - previous: { directory?: string; workspaceId?: string }, + previous: SessionLocation, ): void { markAuthoritativeSessionLocation(instanceId, sessionId) - stalePredecessors.set(key(instanceId, sessionId), { - epoch: getSessionLocationEpoch(instanceId, sessionId), - directory: previous.directory, - workspaceId: previous.workspaceId, - }) + const current = authority(instanceId, sessionId) + if (!current.superseded.some((location) => ( + location.directory === previous.directory && location.workspaceId === previous.workspaceId + ))) current.superseded.push(previous) } function isStaleSessionLocation( instanceId: string, sessionId: string, - location: { directory?: string; workspaceId?: string }, + update: SessionLocationUpdate, + currentLocation: SessionLocation, + serverUpdated?: number, ): boolean { - const sessionKey = key(instanceId, sessionId) - const stale = stalePredecessors.get(sessionKey) - return Boolean(stale && stale.epoch === getSessionLocationEpoch(instanceId, sessionId) - && stale.directory === location.directory && stale.workspaceId === location.workspaceId) + const current = authorities.get(key(instanceId, sessionId)) + const conflictsWithCurrent = (update.hasDirectory && update.directory !== currentLocation.directory) + || (update.hasWorkspaceId && update.workspaceId !== currentLocation.workspaceId) + if (!conflictsWithCurrent) return false + + if (typeof serverUpdated === "number" && Number.isFinite(serverUpdated) && current?.serverUpdated !== undefined) { + if (serverUpdated <= current.serverUpdated) return true + return false + } + + return Boolean(current?.superseded.some((location) => ( + (!update.hasDirectory || location.directory === update.directory) + && (!update.hasWorkspaceId || location.workspaceId === update.workspaceId) + ))) } messageStoreBus.onInstanceDestroyed((instanceId) => { const prefix = `${instanceId}:` - for (const sessionKey of epochs.keys()) { - if (sessionKey.startsWith(prefix)) epochs.delete(sessionKey) - } - for (const sessionKey of stalePredecessors.keys()) { - if (sessionKey.startsWith(prefix)) stalePredecessors.delete(sessionKey) + for (const sessionKey of authorities.keys()) { + if (sessionKey.startsWith(prefix)) authorities.delete(sessionKey) } }) export { + beginSessionLocationRequest, commitAuthoritativeSessionLocation, getSessionLocationEpoch, isStaleSessionLocation, markAuthoritativeSessionLocation, + observeSessionUpdateAuthority, } diff --git a/packages/ui/src/stores/session-worktree-binding.test.ts b/packages/ui/src/stores/session-worktree-binding.test.ts index 26351e29c..0a1d08dc2 100644 --- a/packages/ui/src/stores/session-worktree-binding.test.ts +++ b/packages/ui/src/stores/session-worktree-binding.test.ts @@ -259,7 +259,7 @@ describe("session worktree binding", () => { workspaceID: "workspace-feature", title: root.title, version: root.version, - time: { created: 1, updated: 100 }, + time: { created: 1, updated: 2 }, } }, } as any) handleSessionUpdate(instanceId, { @@ -268,7 +268,7 @@ describe("session worktree binding", () => { directory: "/repo", title: root.title, version: root.version, - time: { created: 1, updated: 2 }, + time: { created: 1, updated: 1 }, } }, } as any) @@ -286,6 +286,61 @@ describe("session worktree binding", () => { } }, } as any) assert.equal(sessions().get(instanceId)?.get(root.id)?.directory, "/repo-newer") + assert.equal(sessions().get(instanceId)?.get(root.id)?.workspaceId, "workspace-feature") + } finally { + cleanup() + } + }) + + it("fences delayed partial locations across rapid root to A to B moves", async () => { + const instanceId = "rapid-location-moves" + const worktrees = [ + { slug: "root", directory: "/repo", kind: "root" as const }, + { slug: "a", directory: "/repo-a", kind: "worktree" as const }, + { slug: "b", directory: "/repo-b", kind: "worktree" as const }, + ] + const cleanup = await setup(instanceId, { + worktrees, + move: async (rootId, slug) => ({ + rootSessionId: rootId, + worktreeSlug: slug, + sessions: [rootId, "child-session"].map((sessionId) => ({ + sessionId, + directory: slug === "root" ? "/repo" : `/repo-${slug}`, + workspaceId: slug === "root" ? null : `workspace-${slug}`, + })), + }), + }) + const root = session(instanceId, "root-session", null) + const child = session(instanceId, "child-session", root.id) + setFamily(instanceId, root, child) + + const update = (directory: string, updated: number, workspaceID?: string) => handleSessionUpdate(instanceId, { + properties: { info: { + id: root.id, + directory, + ...(workspaceID ? { workspaceID } : {}), + title: root.title, + version: root.version, + time: { created: 1, updated }, + } }, + } as any) + + try { + await moveSessionToWorktree(instanceId, root.id, "a") + update("/repo-a", 10, "workspace-a") + await moveSessionToWorktree(instanceId, root.id, "b") + update("/repo-b", 20, "workspace-b") + + update("/repo", 5) + update("/repo-a", 15) + assert.equal(sessions().get(instanceId)?.get(root.id)?.directory, "/repo-b") + assert.equal(sessions().get(instanceId)?.get(root.id)?.workspaceId, "workspace-b") + assert.equal(sessions().get(instanceId)?.get(root.id)?.time.updated, 20) + + update("/repo-a", 30, "workspace-a") + assert.equal(sessions().get(instanceId)?.get(root.id)?.directory, "/repo-a") + assert.equal(sessions().get(instanceId)?.get(root.id)?.workspaceId, "workspace-a") } finally { cleanup() } diff --git a/packages/ui/src/stores/session-worktree-binding.ts b/packages/ui/src/stores/session-worktree-binding.ts index 84fd9d463..993fc0bc6 100644 --- a/packages/ui/src/stores/session-worktree-binding.ts +++ b/packages/ui/src/stores/session-worktree-binding.ts @@ -7,7 +7,11 @@ import { rememberOpenCodeWorkspaceIdForSession, } from "./opencode-workspaces" import { getDescendantSessions, getSessionRoot, sessions, withSession } from "./session-state" -import { commitAuthoritativeSessionLocation, getSessionLocationEpoch } from "./session-location-authority" +import { + beginSessionLocationRequest, + commitAuthoritativeSessionLocation, + getSessionLocationEpoch, +} from "./session-location-authority" import { clearLocalSessionWorktreeSlug } from "./session-metadata" import { messageStoreBus } from "./message-v2/bus" import { getInstanceLifecycleGeneration, isInstanceLifecycleCurrent } from "./instance-lifecycle-authority" @@ -86,7 +90,7 @@ async function moveSessionFamily(instanceId: string, sessionId: string, slug: st }])) const locationEpochs = new Map(members.map((member) => [ member.id, - getSessionLocationEpoch(instanceId, member.id), + beginSessionLocationRequest(instanceId, member.id), ])) const moved = await serverApi.moveWorktreeSessionFamily(instanceId, root.id, { worktreeSlug: slug }) assertCurrentLifecycle() diff --git a/packages/ui/src/stores/worktree-deletion.test.ts b/packages/ui/src/stores/worktree-deletion.test.ts index 4e493b5ce..d2d52a91c 100644 --- a/packages/ui/src/stores/worktree-deletion.test.ts +++ b/packages/ui/src/stores/worktree-deletion.test.ts @@ -98,6 +98,9 @@ describe("renderer worktree deletion reconciliation", () => { await first assert.equal(isWorktreeDeletionInProgress(instanceId, "other"), true) await second + for (let attempt = 0; attempt < 40 && isWorktreeDeletionInProgress(instanceId, "other"); attempt++) { + await new Promise((resolve) => setTimeout(resolve, 10)) + } assert.equal(sessionRefreshes, 2) assert.equal(isWorktreeDeletionInProgress(instanceId, "other"), false) } finally { @@ -134,7 +137,7 @@ describe("renderer worktree deletion reconciliation", () => { } }) - it("holds the guard through workspace, map, and delayed project-session reconciliation", async () => { + it("resolves after inventory confirmation while reconciliation remains fenced", async () => { const instanceId = "delete-reconciliation-guard" const events: string[] = [] let releaseSessions!: () => void @@ -168,6 +171,7 @@ describe("renderer worktree deletion reconciliation", () => { setSessions((previous) => new Map(previous).set(instanceId, new Map([[existing.id, existing]]))) events.length = 0 const deletion = deleteWorktree(instanceId, "feature") + await deletion await sessionsStarted assert.equal(isWorktreeDeletionInProgress(instanceId, "feature"), true) await assert.rejects(() => withSessionWorkspace(instanceId, existing.id, async (workspace) => workspace)) @@ -179,7 +183,9 @@ describe("renderer worktree deletion reconciliation", () => { )) assert.deepEqual(events, ["delete", "git", "workspace-sync", "workspace-list", "map", "sessions"]) releaseSessions() - await deletion + for (let attempt = 0; attempt < 40 && isWorktreeDeletionInProgress(instanceId, "feature"); attempt++) { + await new Promise((resolve) => setTimeout(resolve, 10)) + } assert.equal(isWorktreeDeletionInProgress(instanceId, "feature"), false) assert.deepEqual(getWorktrees(instanceId), [root]) } finally { @@ -211,7 +217,7 @@ describe("renderer worktree deletion reconciliation", () => { await ensureWorktreesLoaded(instanceId) const existing = nativeSession(instanceId) setSessions((previous) => new Map(previous).set(instanceId, new Map([[existing.id, existing]]))) - await assert.rejects(() => deleteWorktree(instanceId, "feature"), /workspace refresh failed/) + await deleteWorktree(instanceId, "feature") assert.deepEqual(getWorktrees(instanceId), [root]) assert.equal(isWorktreeDeletionInProgress(instanceId, "feature"), true) await assert.rejects(() => withSessionWorkspace(instanceId, existing.id, async (workspace) => workspace)) @@ -230,9 +236,11 @@ describe("renderer worktree deletion reconciliation", () => { it("cancels deletion reconciliation retries when an instance is destroyed", async () => { const instanceId = "destroyed-deletion-retry" let workspaceLists = 0 + let markWorkspaceListStarted!: () => void + const workspaceListStarted = new Promise((resolve) => { markWorkspaceListStarted = resolve }) const testClient = client({ experimental: { workspace: { syncList: async () => ({ data: [] }), - list: async () => { workspaceLists += 1; throw new Error("workspace refresh failed") }, + list: async () => { workspaceLists += 1; markWorkspaceListStarted(); throw new Error("workspace refresh failed") }, } } }) const harness = setup(instanceId, testClient) let fetches = 0 @@ -240,7 +248,8 @@ describe("renderer worktree deletion reconciliation", () => { serverApi.deleteWorktree = async () => undefined try { await ensureWorktreesLoaded(instanceId) - await assert.rejects(() => deleteWorktree(instanceId, "feature"), /workspace refresh failed/) + await deleteWorktree(instanceId, "feature") + await workspaceListStarted messageStoreBus.unregisterInstance(instanceId) await new Promise((resolve) => setTimeout(resolve, 10)) assert.equal(workspaceLists, 1) @@ -280,6 +289,9 @@ describe("renderer worktree deletion reconciliation", () => { assert.equal(isWorktreeDeletionInProgress(instanceId, "feature"), true) releaseNew() await newDeletion + for (let attempt = 0; attempt < 40 && isWorktreeDeletionInProgress(instanceId, "feature"); attempt++) { + await new Promise((resolve) => setTimeout(resolve, 10)) + } assert.equal(isWorktreeDeletionInProgress(instanceId, "feature"), false) } finally { releaseOld() diff --git a/packages/ui/src/stores/worktrees.ts b/packages/ui/src/stores/worktrees.ts index 200acbb33..bef8b5ee1 100644 --- a/packages/ui/src/stores/worktrees.ts +++ b/packages/ui/src/stores/worktrees.ts @@ -222,7 +222,8 @@ async function deleteWorktree(instanceId: string, slug: string, options?: { forc if (!isInstanceLifecycleCurrent(instanceId, generation)) return markWorktreeDeletionForReconciliation(operationKey, operationId) scheduleWorktreeDeletionRetry(instanceId, generation) - throw deleteError ?? error + if (deleteError) throw deleteError + return } if (!isInstanceLifecycleCurrent(instanceId, generation)) return if (inventory.worktrees.some((worktree) => worktree.slug === trimmed)) { @@ -230,8 +231,11 @@ async function deleteWorktree(instanceId: string, slug: string, options?: { forc throw deleteError ?? new Error(tGlobal("instanceShell.worktree.moveFailed")) } + applyWorktreeInventory(instanceId, inventory) markWorktreeDeletionForReconciliation(operationKey, operationId) - await reconcileOwnedWorktreeDeletions(instanceId, generation, [operationId], inventory) + void reconcileOwnedWorktreeDeletions(instanceId, generation, [operationId], inventory).catch((error) => { + log.warn("Failed to reconcile confirmed worktree deletion", { instanceId, error }) + }) }) worktreeDeletionQueues.set(operationKey, task) diff --git a/packages/ui/src/types/global.d.ts b/packages/ui/src/types/global.d.ts index 32f3da2b1..c9f00ec88 100644 --- a/packages/ui/src/types/global.d.ts +++ b/packages/ui/src/types/global.d.ts @@ -35,7 +35,7 @@ declare global { restartCli?: () => Promise openDialog?: (options: ElectronDialogOptions) => Promise getDirectoryPaths?: (paths: string[]) => Promise - openDirectory?: (instanceId: string, worktreeSlug: string) => Promise<{ ok: boolean }> + openDirectory?: (accessToken: string, instanceId: string, worktreeSlug: string) => Promise<{ ok: boolean }> getPathForFile?: (file: File) => string | null requestMicrophoneAccess?: () => Promise<{ granted: boolean }> setWakeLock?: (enabled: boolean) => Promise<{ enabled: boolean }> From 7b3bd8e4c70c8b226d95d68c5e6cec6b32fc1e92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Mon, 10 Aug 2026 23:44:41 +0200 Subject: [PATCH 16/20] fix(worktrees): preserve authority through host transitions Keep one adjacent repository lock across clone topology changes and publish workspace leases in repository-visible locations shared by Windows and WSL. Foreign-host claims now fail closed without an unsafe timeout, while release remains retryable after transient filesystem failures. Fence locally committed session moves until server confirmation, advance location authority on unchanged list results, and restore same-origin preview element comments with the original cross-origin availability guard. Add regression coverage for pre-clone locking, cross-host path convergence, foreign claims, release retries, shared lifetime leases, delayed session updates, unchanged list timestamps, and preview comment context. Validated 399 server tests, 255 UI tests, 133 Electron tests, 97 Tauri tests, typechecks, and the UI production build. --- .../__tests__/workspace-identity.test.ts | 9 + .../workspaces/repository-authority-path.ts | 31 ++++ .../workspaces/repository-lock-ownership.ts | 46 +++-- .../repository-mutation-lock.test.ts | 85 +++++++-- .../workspaces/repository-mutation-lock.ts | 52 ++---- .../src/workspaces/workspace-identity.ts | 13 ++ .../workspace-lifetime-lease.test.ts | 31 +++- .../workspaces/workspace-lifetime-lease.ts | 137 +++++++++------ packages/ui/src/components/browser-frame.tsx | 162 +++++++++++++++++- .../src/components/session-preview-comment.ts | 14 ++ .../components/session-preview-view.test.ts | 27 +++ .../src/components/session-preview-view.tsx | 23 ++- packages/ui/src/stores/session-api.ts | 8 +- packages/ui/src/stores/session-events.ts | 4 +- .../src/stores/session-location-authority.ts | 34 ++-- .../stores/session-request-authority.test.ts | 43 +++++ .../stores/session-worktree-binding.test.ts | 34 ++++ 17 files changed, 607 insertions(+), 146 deletions(-) create mode 100644 packages/server/src/workspaces/repository-authority-path.ts create mode 100644 packages/ui/src/components/session-preview-comment.ts create mode 100644 packages/ui/src/components/session-preview-view.test.ts diff --git a/packages/server/src/workspaces/__tests__/workspace-identity.test.ts b/packages/server/src/workspaces/__tests__/workspace-identity.test.ts index 334a808b3..acd9c8a2a 100644 --- a/packages/server/src/workspaces/__tests__/workspace-identity.test.ts +++ b/packages/server/src/workspaces/__tests__/workspace-identity.test.ts @@ -16,6 +16,7 @@ import { resolveRepositoryIdentity, resolveRepositoryMutationKey, resolveWorkspaceIdentity, + sharedRelativePathIdentity, workspaceIdentityPathsEqual, } from "../workspace-identity" @@ -144,6 +145,14 @@ describe("workspace identity", () => { String.raw`\\?\UNC\wsl.localhost\Ubuntu\home\dev\Repo`, "win32", ), "wsl:ubuntu:/home/dev/Repo") + assert.equal( + sharedRelativePathIdentity( + String.raw`\\wsl.localhost\Ubuntu\home\dev\Projects\MissingRepo`, + String.raw`\\wsl.localhost\Ubuntu\home\dev`, + "win32", + ), + sharedRelativePathIdentity("/home/dev/Projects/MissingRepo", "/home/dev", "linux"), + ) }) it("canonicalizes aliases and falls back to an absolute identity for missing paths", async () => { diff --git a/packages/server/src/workspaces/repository-authority-path.ts b/packages/server/src/workspaces/repository-authority-path.ts new file mode 100644 index 000000000..75b8cd34f --- /dev/null +++ b/packages/server/src/workspaces/repository-authority-path.ts @@ -0,0 +1,31 @@ +import { createHash } from "node:crypto" +import { access, constants, lstat, realpath } from "node:fs/promises" +import path from "node:path" +import { sharedRelativePathIdentity } from "./workspace-identity" + +export async function adjacentRepositoryAuthorityPath( + workspaceFolder: string, + category: string, + suffix = "", +): Promise { + const workspacePath = path.resolve(workspaceFolder) + let candidate = path.dirname(workspacePath) + while (true) { + try { + const canonicalParent = await realpath(candidate) + const metadata = await lstat(canonicalParent) + if (!metadata.isDirectory() || metadata.isSymbolicLink()) return undefined + if (process.platform !== "win32" && process.getuid && metadata.uid !== process.getuid()) return undefined + await access(canonicalParent, constants.W_OK) + const relativeIdentity = sharedRelativePathIdentity(workspacePath, candidate) + const digest = createHash("sha256").update(relativeIdentity).digest("hex") + return path.join(canonicalParent, ".codenomad", category, `${digest}${suffix}`) + } catch (error) { + const code = (error as NodeJS.ErrnoException).code + if (!["ENOENT", "ENOTDIR"].includes(code ?? "")) return undefined + const parent = path.dirname(candidate) + if (parent === candidate) return undefined + candidate = parent + } + } +} diff --git a/packages/server/src/workspaces/repository-lock-ownership.ts b/packages/server/src/workspaces/repository-lock-ownership.ts index f1ee2c782..bfd752c5d 100644 --- a/packages/server/src/workspaces/repository-lock-ownership.ts +++ b/packages/server/src/workspaces/repository-lock-ownership.ts @@ -2,7 +2,6 @@ import { randomUUID } from "node:crypto" import { spawnSync } from "node:child_process" import { lstat, mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises" import path from "node:path" -import { performance } from "node:perf_hooks" import { setTimeout as delay } from "node:timers/promises" import { managerProcessIdentity, @@ -15,9 +14,7 @@ import { const POLL_MS = 40 const HEARTBEAT_MS = 1_000 -// ponytail: bounded file heartbeats are the cross-host liveness primitive; add a shared lease service only if needed. -export const OWNERSHIP_EXPIRY_MS = 10_000 -const remoteHeartbeatObservations = new Map() +const RELEASE_ATTEMPTS = 3 interface OwnerRecord { token: string @@ -149,19 +146,13 @@ async function claims(claimsPath: string): Promise { export function processIdentityIsAlive( identity: ProcessIdentity, - claimPath: string, - heartbeatFence: string, - now = performance.now(), + _claimPath: string, + _heartbeatFence: string, + _now = 0, probe: (pid: number) => ProcessSnapshot = (pid) => probeHostProcess(spawnSync, pid, 2_000), ): boolean { - if (identity.hostId !== managerHostIdentity) { - const previous = remoteHeartbeatObservations.get(claimPath) - if (!previous || previous.fence !== heartbeatFence || now < previous.since) { - remoteHeartbeatObservations.set(claimPath, { fence: heartbeatFence, since: now }) - return true - } - return now - previous.since <= OWNERSHIP_EXPIRY_MS - } + // A heartbeat cannot fence a paused foreign process from resuming, so foreign claims fail closed. + if (identity.hostId !== managerHostIdentity) return true if (sameProcess(identity, managerProcessIdentity)) return true const snapshot = probe(identity.pid) if (!snapshot.ok) return true @@ -187,7 +178,6 @@ export async function retireOwnershipClaim(claimPath: string, expectedHeartbeat: return false } await rm(retiredPath, { recursive: true, force: true }) - remoteHeartbeatObservations.delete(claimPath) return true } @@ -200,6 +190,23 @@ export async function retireCurrentOwnershipClaim(claimPath: string): Promise Promise = retireCurrentOwnershipClaim, +): Promise { + let failure: unknown + for (let attempt = 0; attempt < RELEASE_ATTEMPTS; attempt += 1) { + try { + if (await retire(claimPath)) return + failure = new Error(`Ownership claim changed while releasing: ${claimPath}`) + } catch (error) { + failure = error + } + if (attempt + 1 < RELEASE_ATTEMPTS) await delay(POLL_MS) + } + throw failure +} + async function wait(signal?: AbortSignal): Promise { try { await delay(POLL_MS, undefined, { signal }) @@ -244,7 +251,10 @@ export async function acquireOwnershipQueue(lockPath: string, signal?: AbortSign const precedes = claim.ticket === undefined || claim.ticket < ticket || (claim.ticket === ticket && claim.token < token) if (!precedes) continue - if (!claim.heartbeat) throw new Error(`Malformed repository ownership heartbeat: ${claim.path}`) + if (!claim.heartbeat) { + blocked = true + break + } if (!processIdentityIsAlive(claim.owner.identity, claim.path, claim.heartbeatFence!)) { if (!await retireOwnershipClaim(claim.path, claim.heartbeatFence!)) continue continue @@ -261,7 +271,7 @@ export async function acquireOwnershipQueue(lockPath: string, signal?: AbortSign if (released) return await stopHeartbeat?.() try { - await retireCurrentOwnershipClaim(claimPath) + await retireCurrentOwnershipClaimWithRetry(claimPath) released = true } catch (error) { stopHeartbeat = maintainOwnershipHeartbeat(claimPath, token) diff --git a/packages/server/src/workspaces/repository-mutation-lock.test.ts b/packages/server/src/workspaces/repository-mutation-lock.test.ts index 5b8210bea..581213e7d 100644 --- a/packages/server/src/workspaces/repository-mutation-lock.test.ts +++ b/packages/server/src/workspaces/repository-mutation-lock.test.ts @@ -8,9 +8,10 @@ import { afterEach, describe, it } from "node:test" import { fileURLToPath } from "node:url" import { managerProcessIdentity } from "./process-identity" import { + acquireOwnershipQueue, ensurePrivateLockRoot, - OWNERSHIP_EXPIRY_MS, processIdentityIsAlive, + retireCurrentOwnershipClaimWithRetry, retireOwnershipClaim, } from "./repository-lock-ownership" import { acquireRepositoryMutation } from "./repository-mutation-lock" @@ -112,6 +113,23 @@ describe("repository mutation lock", () => { } }) + it("retains the adjacent destination lock after Git appears", async () => { + const directory = await plainDirectory() + const destination = path.join(directory, "workspace") + const marker = path.join(directory, "marker.txt") + await writeFile(marker, "") + const admission = await acquireRepositoryMutation({ workspaceFolder: destination }) + await mkdir(destination) + execFileSync("git", ["init"], { cwd: destination }) + + const waiting = child(["hold", destination, "0", marker]) + await new Promise((resolve) => setTimeout(resolve, 150)) + assert.equal(await readFile(marker, "utf8"), "") + await admission.release() + await waiting + assert.deepEqual((await readFile(marker, "utf8")).trim().split(/\r?\n/), ["enter:0", "exit:0"]) + }) + it("keeps an existing empty clone destination empty during admission", async () => { const directory = await plainDirectory() const destination = path.join(directory, "workspace") @@ -143,19 +161,14 @@ describe("repository mutation lock", () => { } }) - it("uses local fence observation for foreign heartbeats regardless of remote clock skew", () => { + it("fails closed for foreign owners regardless of heartbeat age", () => { const foreignIdentity = { ...managerProcessIdentity, hostId: "foreign-host", pid: process.pid } const claimPath = path.join(os.tmpdir(), "foreign-clock-skew-claim") const oldClockFence = JSON.stringify({ token: "foreign", hostId: "foreign-host", updatedAt: -1e15 }) assert.equal(processIdentityIsAlive(foreignIdentity, claimPath, oldClockFence, 100, () => { throw new Error("foreign PID must not be probed") }), true) - assert.equal(processIdentityIsAlive(foreignIdentity, claimPath, oldClockFence, - 101 + OWNERSHIP_EXPIRY_MS), false) - - const futureClockFence = JSON.stringify({ token: "foreign", hostId: "foreign-host", updatedAt: 1e15 }) - assert.equal(processIdentityIsAlive(foreignIdentity, claimPath, futureClockFence, - 101 + OWNERSHIP_EXPIRY_MS), true) + assert.equal(processIdentityIsAlive(foreignIdentity, claimPath, oldClockFence, Number.MAX_SAFE_INTEGER), true) }) it("fails closed when a local process probe fails", () => { @@ -164,7 +177,7 @@ describe("repository mutation lock", () => { () => ({ ok: false, error: "probe unavailable" })), true) }) - it("fences foreign-host expiry by heartbeat without probing its PID locally", async () => { + it("does not admit behind an expired foreign-host heartbeat", async () => { const directory = await repository() const claimPath = path.join(directory, ".git", "codenomad", "mutation.lock", "claims", "foreign") await mkdir(claimPath, { recursive: true }) @@ -186,19 +199,57 @@ describe("repository mutation lock", () => { await assert.rejects(acquireRepositoryMutation({ workspaceFolder: directory, signal: controller.signal }), (error) => error === reason) - const replacementFence = JSON.stringify({ token: "foreign", hostId: "foreign-host", updatedAt: freshAt + 1 }) - await writeFile(heartbeatPath, replacementFence) - assert.equal(await retireOwnershipClaim(claimPath, freshFence), false) const staleFence = JSON.stringify({ token: "foreign", hostId: "foreign-host", - updatedAt: Date.now() - OWNERSHIP_EXPIRY_MS - 1, + updatedAt: -1e15, }) await writeFile(heartbeatPath, staleFence) - processIdentityIsAlive(foreignIdentity, claimPath, staleFence, -OWNERSHIP_EXPIRY_MS - 1) - const admission = await acquireRepositoryMutation({ workspaceFolder: directory }) - await admission.release() - await assert.rejects(access(claimPath), { code: "ENOENT" }) + const staleController = new AbortController() + const staleReason = new Error("foreign owner remains authoritative") + setTimeout(() => staleController.abort(staleReason), 100) + await assert.rejects(acquireRepositoryMutation({ workspaceFolder: directory, signal: staleController.signal }), + (error) => error === staleReason) + await access(claimPath) + }) + + it("retries a changed ownership fence before reporting release success", async () => { + let attempts = 0 + await retireCurrentOwnershipClaimWithRetry("claim", async () => { + attempts += 1 + return attempts === 2 + }) + assert.equal(attempts, 2) + }) + + it("keeps a failed ownership release retryable and restarts its heartbeat", async () => { + const directory = await plainDirectory() + const lockPath = path.join(directory, "release.lock") + const release = await acquireOwnershipQueue(lockPath) + const claimsPath = path.join(lockPath, "claims") + const [claim] = (await readdir(claimsPath)).filter((entry) => !entry.startsWith(".")) + assert.ok(claim) + const heartbeatPath = path.join(claimsPath, claim, "heartbeat.json") + const original = JSON.parse(await readFile(heartbeatPath, "utf8")) as { updatedAt: number } + await rm(heartbeatPath) + await mkdir(heartbeatPath) + + await assert.rejects(release) + await rm(heartbeatPath, { recursive: true }) + await writeFile(heartbeatPath, JSON.stringify({ + token: claim, + hostId: managerProcessIdentity.hostId, + updatedAt: original.updatedAt, + })) + const deadline = Date.now() + 3_000 + let updatedAt = original.updatedAt + while (updatedAt === original.updatedAt && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 50)) + updatedAt = (JSON.parse(await readFile(heartbeatPath, "utf8")) as { updatedAt: number }).updatedAt + } + assert.ok(updatedAt > original.updatedAt) + await release() + assert.deepEqual((await readdir(claimsPath)).filter((entry) => !entry.startsWith(".")), []) }) it("retries repository admission cleanup after a release failure", async () => { diff --git a/packages/server/src/workspaces/repository-mutation-lock.ts b/packages/server/src/workspaces/repository-mutation-lock.ts index dd7093a36..377939e6a 100644 --- a/packages/server/src/workspaces/repository-mutation-lock.ts +++ b/packages/server/src/workspaces/repository-mutation-lock.ts @@ -1,43 +1,17 @@ -import { createHash } from "node:crypto" -import { access, constants, lstat, mkdir, realpath } from "node:fs/promises" +import { mkdir } from "node:fs/promises" import path from "node:path" import { AsyncLocalStorage } from "node:async_hooks" import type { InstanceMutationGate } from "../server/instance-mutation-gate" import { acquireOwnershipQueue, ensurePrivateLockRoot } from "./repository-lock-ownership" +import { adjacentRepositoryAuthorityPath } from "./repository-authority-path" import { canonicalFilesystemIdentity, repositoryMutationKey, resolveRepositoryIdentity } from "./workspace-identity" -import { ensurePrivateStateDirectory, serverStateRoot } from "./state-root" const heldLocks = new AsyncLocalStorage>() -const fallbackRoot = path.join(serverStateRoot, "repository-locks") -function fallbackLockPath(key: string): string { - const digest = createHash("sha256").update(key).digest("hex") - return path.join(fallbackRoot, `${digest}.lock`) -} - -async function adjacentFallbackLockPath(workspaceFolder: string, key: string): Promise { - let candidate = path.dirname(path.resolve(workspaceFolder)) - while (true) { - try { - const canonicalParent = await realpath(candidate) - const metadata = await lstat(canonicalParent) - if (!metadata.isDirectory() || metadata.isSymbolicLink()) return undefined - if (process.platform !== "win32" && process.getuid && metadata.uid !== process.getuid()) return undefined - await access(canonicalParent, constants.W_OK) - const digest = createHash("sha256").update(key).digest("hex") - return path.join(canonicalParent, ".codenomad", "repository-locks", `${digest}.lock`) - } catch (error) { - const code = (error as NodeJS.ErrnoException).code - if (!["ENOENT", "ENOTDIR"].includes(code ?? "")) return undefined - const parent = path.dirname(candidate) - if (parent === candidate) return undefined - candidate = parent - } - } -} - -async function fallbackLockPathForWorkspace(workspaceFolder: string, key: string): Promise { - return (await adjacentFallbackLockPath(workspaceFolder, key)) ?? fallbackLockPath(key) +async function adjacentLockPath(workspaceFolder: string): Promise { + const lockPath = await adjacentRepositoryAuthorityPath(workspaceFolder, "repository-locks", ".lock") + if (!lockPath) throw new Error(`No shared repository lock location is available for ${workspaceFolder}`) + return lockPath } async function lockPathForIdentity( @@ -46,14 +20,11 @@ async function lockPathForIdentity( ): Promise { return identity.commonDir ? path.join(identity.commonDir, "codenomad", "mutation.lock") - : fallbackLockPathForWorkspace(workspaceFolder, identity.mutationKey) + : adjacentLockPath(workspaceFolder) } async function acquireFileLock(lockPath: string, signal?: AbortSignal): Promise<() => Promise> { - if (path.dirname(lockPath) === fallbackRoot) { - await ensurePrivateStateDirectory() - await ensurePrivateLockRoot(fallbackRoot) - } else if (path.basename(path.dirname(lockPath)) === "repository-locks") { + if (path.basename(path.dirname(lockPath)) === "repository-locks") { await mkdir(path.dirname(path.dirname(lockPath)), { recursive: true }) await ensurePrivateLockRoot(path.dirname(lockPath)) } else { @@ -110,10 +81,7 @@ export async function acquireRepositoryMutation(params: { try { const lexicalKey = repositoryMutationKey(canonicalFilesystemIdentity(params.workspaceFolder)) - const initialIdentity = await resolveRepositoryIdentity(params.workspaceFolder) - await acquire(lexicalKey, initialIdentity.isGitRepository - ? fallbackLockPath(lexicalKey) - : await fallbackLockPathForWorkspace(params.workspaceFolder, lexicalKey)) + await acquire(lexicalKey, await adjacentLockPath(params.workspaceFolder)) while (true) { const identity = await resolveRepositoryIdentity(params.workspaceFolder) const identityLockPath = await lockPathForIdentity(identity, params.workspaceFolder) @@ -122,7 +90,7 @@ export async function acquireRepositoryMutation(params: { } catch (error) { const code = (error as NodeJS.ErrnoException).code if (!identity.commonDir || !["EACCES", "EPERM", "EROFS"].includes(code ?? "")) throw error - const sharedFallback = await adjacentFallbackLockPath(identity.commonDir, identity.mutationKey) + const sharedFallback = await adjacentRepositoryAuthorityPath(identity.commonDir, "repository-locks", ".lock") if (!sharedFallback) throw error await acquire(identity.mutationKey, sharedFallback) } diff --git a/packages/server/src/workspaces/workspace-identity.ts b/packages/server/src/workspaces/workspace-identity.ts index 9c8a6b0e4..30d27125b 100644 --- a/packages/server/src/workspaces/workspace-identity.ts +++ b/packages/server/src/workspaces/workspace-identity.ts @@ -47,6 +47,19 @@ export function canonicalFilesystemIdentity( return normalizeWorkspaceIdentityPath(absolutePath, platform) } +export function sharedRelativePathIdentity( + value: string, + ancestor: string, + platform: NodeJS.Platform = process.platform, +): string { + const pathApi = platform === "win32" ? path.win32 : path.posix + const relative = pathApi.relative(pathApi.resolve(ancestor), pathApi.resolve(value)) + const segments = relative.split(/[\\/]+/).filter(Boolean) + if (segments.some((segment) => segment === "..")) throw new Error(`${value} is outside ${ancestor}`) + const identity = segments.join("/") + return platform === "win32" && !wslUncIdentity(value) ? identity.toLowerCase() : identity +} + export function workspaceIdentityPathsEqual( left: string, right: string, diff --git a/packages/server/src/workspaces/workspace-lifetime-lease.test.ts b/packages/server/src/workspaces/workspace-lifetime-lease.test.ts index 9c9a7a4a3..de66960df 100644 --- a/packages/server/src/workspaces/workspace-lifetime-lease.test.ts +++ b/packages/server/src/workspaces/workspace-lifetime-lease.test.ts @@ -1,11 +1,16 @@ import assert from "node:assert/strict" import { spawn, type ChildProcess } from "node:child_process" -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises" +import { execFileSync } from "node:child_process" +import { access, mkdtemp, readFile, rm, writeFile } from "node:fs/promises" import os from "node:os" import path from "node:path" import { afterEach, describe, it } from "node:test" import { fileURLToPath } from "node:url" -import { acquireWorkspaceLifetimeLease, hasWorkspaceLifetimeBlocker } from "./workspace-lifetime-lease" +import { + acquireWorkspaceLifetimeLease, + hasWorkspaceLifetimeBlocker, + workspaceLifetimeAuthorityRoots, +} from "./workspace-lifetime-lease" const childScript = fileURLToPath(new URL("./workspace-lifetime-lease.child.ts", import.meta.url)) const temporaryDirectories: string[] = [] @@ -87,4 +92,26 @@ describe("workspace lifetime lease", () => { await childExit assert.equal(await hasWorkspaceLifetimeBlocker({ workspaceFolder: directory }), false) }) + + it("publishes repository lifetime authority where Windows and WSL can both observe it", async () => { + const directory = await temporaryDirectory() + execFileSync("git", ["init"], { cwd: directory }) + const lease = await acquireWorkspaceLifetimeLease(directory, "workspace") + const authority = await workspaceLifetimeAuthorityRoots(directory) + const repositoryRoot = path.join(directory, ".git", "codenomad", "workspace-leases") + assert.ok(authority.roots.includes(repositoryRoot)) + await access(path.join(repositoryRoot, lease.token)) + + const foreignHostPath = process.platform === "win32" + ? "/home/dev/repository" + : String.raw`\\wsl.localhost\Ubuntu\home\dev\repository` + for (const root of authority.roots) { + const recordPath = path.join(root, lease.token, "lease.json") + const record = JSON.parse(await readFile(recordPath, "utf8")) as { workspaceFolder: string } + record.workspaceFolder = foreignHostPath + await writeFile(recordPath, JSON.stringify(record)) + } + assert.equal(await hasWorkspaceLifetimeBlocker({ workspaceFolder: directory }), true) + await lease.release() + }) }) diff --git a/packages/server/src/workspaces/workspace-lifetime-lease.ts b/packages/server/src/workspaces/workspace-lifetime-lease.ts index 01405a79f..688e8aa4c 100644 --- a/packages/server/src/workspaces/workspace-lifetime-lease.ts +++ b/packages/server/src/workspaces/workspace-lifetime-lease.ts @@ -2,16 +2,16 @@ import { randomUUID } from "node:crypto" import { lstat, mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises" import path from "node:path" import { managerProcessIdentity, type ProcessIdentity } from "./process-identity" +import { adjacentRepositoryAuthorityPath } from "./repository-authority-path" import { ensurePrivateLockRoot, maintainOwnershipHeartbeat, processIdentityIsAlive, - retireCurrentOwnershipClaim, + retireCurrentOwnershipClaimWithRetry, retireOwnershipClaim, type OwnershipHeartbeat, } from "./repository-lock-ownership" import { canonicalFilesystemIdentity, resolveRepositoryIdentity } from "./workspace-identity" -import { ensurePrivateStateDirectory, serverStateRoot } from "./state-root" interface LeaseRecord { token: string @@ -30,12 +30,10 @@ export interface WorkspaceLifetimeLease { release: () => Promise } -const leaseRoot = path.join(serverStateRoot, "workspace-leases") - function isLeaseRecord(value: unknown, token: string): value is LeaseRecord { const record = value as Partial | null return record?.token === token && typeof record.workspaceId === "string" && record.workspaceId.length > 0 - && typeof record.workspaceFolder === "string" && path.isAbsolute(record.workspaceFolder) + && typeof record.workspaceFolder === "string" && record.workspaceFolder.length > 0 && typeof record.directoryKey === "string" && typeof record.repositoryKey === "string" && Number.isFinite(record.createdAt) && Boolean(record.owner) && Number.isSafeInteger(record.owner?.pid) && (record.owner?.pid ?? 0) > 0 @@ -43,24 +41,49 @@ function isLeaseRecord(value: unknown, token: string): value is LeaseRecord { && typeof record.owner?.hostId === "string" && record.owner.hostId.length > 0 } -async function readLease(entryName: string): Promise<{ +export async function workspaceLifetimeAuthorityRoots(workspaceFolder: string): Promise<{ + repositoryKey: string + roots: string[] +}> { + const identity = await resolveRepositoryIdentity(workspaceFolder) + const adjacent = await adjacentRepositoryAuthorityPath(workspaceFolder, "workspace-leases") + const roots = [adjacent, identity.commonDir && path.join(identity.commonDir, "codenomad", "workspace-leases")] + .filter((value): value is string => Boolean(value)) + if (roots.length === 0) throw new Error(`No shared workspace lease location is available for ${workspaceFolder}`) + return { repositoryKey: identity.mutationKey, roots: [...new Set(roots)] } +} + +async function ensureLeaseRoot(root: string): Promise { + await mkdir(path.dirname(root), { recursive: true, mode: 0o700 }) + await ensurePrivateLockRoot(path.dirname(root)) + await ensurePrivateLockRoot(root) +} + +async function readLease(root: string, entryName: string): Promise<{ path: string record?: LeaseRecord heartbeat?: OwnershipHeartbeat heartbeatFence?: string } | undefined> { - const leasePath = path.join(leaseRoot, entryName) + const leasePath = path.join(root, entryName) try { const metadata = await lstat(leasePath) if (!metadata.isDirectory() || metadata.isSymbolicLink()) return { path: leasePath } const parsed = JSON.parse(await readFile(path.join(leasePath, "lease.json"), "utf8")) as unknown if (!isLeaseRecord(parsed, entryName)) return { path: leasePath } - const heartbeatFence = await readFile(path.join(leasePath, "heartbeat.json"), "utf8") - const heartbeat = JSON.parse(heartbeatFence) as OwnershipHeartbeat - if (heartbeat.token !== entryName || heartbeat.hostId !== parsed.owner.hostId || !Number.isFinite(heartbeat.updatedAt)) { - return { path: leasePath, record: parsed } + try { + const heartbeatFence = await readFile(path.join(leasePath, "heartbeat.json"), "utf8") + const heartbeat = JSON.parse(heartbeatFence) as OwnershipHeartbeat + if (heartbeat.token !== entryName || heartbeat.hostId !== parsed.owner.hostId || !Number.isFinite(heartbeat.updatedAt)) { + return { path: leasePath, record: parsed } + } + return { path: leasePath, record: parsed, heartbeat, heartbeatFence } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT" || error instanceof SyntaxError) { + return { path: leasePath, record: parsed } + } + throw error } - return { path: leasePath, record: parsed, heartbeat, heartbeatFence } } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined if (error instanceof SyntaxError) return { path: leasePath } @@ -68,17 +91,20 @@ async function readLease(entryName: string): Promise<{ } } -async function activeLeases(): Promise { - await ensurePrivateStateDirectory() - await ensurePrivateLockRoot(leaseRoot) - const entries = await readdir(leaseRoot, { withFileTypes: true }) +async function activeLeases(root: string): Promise { + await ensureLeaseRoot(root) + const entries = await readdir(root, { withFileTypes: true }) const leases = await Promise.all(entries .filter((entry) => !entry.name.startsWith(".")) - .map((entry) => readLease(entry.name))) + .map((entry) => readLease(root, entry.name))) const active: LeaseRecord[] = [] for (const lease of leases) { if (!lease) continue - if (!lease.record || !lease.heartbeat) throw new Error(`Malformed workspace lifetime lease: ${lease.path}`) + if (!lease.record) throw new Error(`Malformed workspace lifetime lease: ${lease.path}`) + if (!lease.heartbeat) { + active.push(lease.record) + continue + } if (!processIdentityIsAlive(lease.record.owner, lease.path, lease.heartbeatFence!)) { if (!await retireOwnershipClaim(lease.path, lease.heartbeatFence!)) continue continue @@ -92,50 +118,64 @@ export async function acquireWorkspaceLifetimeLease( workspaceFolder: string, workspaceId: string, ): Promise { - await ensurePrivateStateDirectory() - await ensurePrivateLockRoot(leaseRoot) - const identity = await resolveRepositoryIdentity(workspaceFolder) + const authority = await workspaceLifetimeAuthorityRoots(workspaceFolder) const token = randomUUID() - const preparationPath = path.join(leaseRoot, `.prepare-${token}`) - const leasePath = path.join(leaseRoot, token) const record: LeaseRecord = { token, workspaceId, workspaceFolder: path.resolve(workspaceFolder), directoryKey: canonicalFilesystemIdentity(workspaceFolder), - repositoryKey: identity.mutationKey, + repositoryKey: authority.repositoryKey, createdAt: Date.now(), owner: managerProcessIdentity, } - await mkdir(preparationPath, { mode: 0o700 }) + const claims: Array<{ path: string; stopHeartbeat: () => Promise }> = [] try { - await writeFile(path.join(preparationPath, "lease.json"), JSON.stringify(record), { flag: "wx", mode: 0o600 }) - await writeFile(path.join(preparationPath, "heartbeat.json"), JSON.stringify({ - token, - hostId: managerProcessIdentity.hostId, - updatedAt: Date.now(), - }), { flag: "wx", mode: 0o600 }) - await rename(preparationPath, leasePath) + for (const root of authority.roots) { + await ensureLeaseRoot(root) + const preparationPath = path.join(root, `.prepare-${token}`) + const leasePath = path.join(root, token) + await mkdir(preparationPath, { mode: 0o700 }) + try { + await writeFile(path.join(preparationPath, "lease.json"), JSON.stringify(record), { flag: "wx", mode: 0o600 }) + await writeFile(path.join(preparationPath, "heartbeat.json"), JSON.stringify({ + token, + hostId: managerProcessIdentity.hostId, + updatedAt: Date.now(), + }), { flag: "wx", mode: 0o600 }) + await rename(preparationPath, leasePath) + } catch (error) { + await rm(preparationPath, { recursive: true, force: true }) + throw error + } + claims.push({ path: leasePath, stopHeartbeat: maintainOwnershipHeartbeat(leasePath, token) }) + } } catch (error) { - await rm(preparationPath, { recursive: true, force: true }) + await Promise.allSettled(claims.map(async (claim) => { + await claim.stopHeartbeat() + await retireCurrentOwnershipClaimWithRetry(claim.path) + })) throw error } - let stopHeartbeat = maintainOwnershipHeartbeat(leasePath, token) - let released = false + return { token, directoryKey: record.directoryKey, repositoryKey: record.repositoryKey, release: async () => { - if (released) return - await stopHeartbeat() - try { - await retireCurrentOwnershipClaim(leasePath) - released = true - } catch (error) { - stopHeartbeat = maintainOwnershipHeartbeat(leasePath, token) - throw error + const failures: unknown[] = [] + for (let index = claims.length - 1; index >= 0; index -= 1) { + const claim = claims[index]! + await claim.stopHeartbeat() + try { + await retireCurrentOwnershipClaimWithRetry(claim.path) + claims.splice(index, 1) + } catch (error) { + claim.stopHeartbeat = maintainOwnershipHeartbeat(claim.path, token) + failures.push(error) + } } + if (failures.length > 0) throw new AggregateError(failures, "Failed to release workspace lifetime lease") }, } } @@ -145,12 +185,11 @@ export async function hasWorkspaceLifetimeBlocker(params: { repositoryKey?: string excludingToken?: string }): Promise { - const directoryKey = canonicalFilesystemIdentity(params.workspaceFolder) - const repositoryKey = params.repositoryKey ?? (await resolveRepositoryIdentity(params.workspaceFolder)).mutationKey - for (const lease of await activeLeases()) { - if (lease.token === params.excludingToken) continue - if (lease.directoryKey === directoryKey || lease.repositoryKey === repositoryKey) return true - if ((await resolveRepositoryIdentity(lease.workspaceFolder)).mutationKey === repositoryKey) return true + const authority = await workspaceLifetimeAuthorityRoots(params.workspaceFolder) + for (const root of authority.roots) { + for (const lease of await activeLeases(root)) { + if (lease.token !== params.excludingToken) return true + } } return false } diff --git a/packages/ui/src/components/browser-frame.tsx b/packages/ui/src/components/browser-frame.tsx index 195069df8..8a59b799b 100644 --- a/packages/ui/src/components/browser-frame.tsx +++ b/packages/ui/src/components/browser-frame.tsx @@ -1,12 +1,23 @@ -import { ArrowLeft, ArrowRight, ChevronDown, Expand, Monitor, RefreshCw, RotateCw, Smartphone, Tablet } from "lucide-solid" -import { Show, createEffect, createMemo, createSignal, type Component } from "solid-js" +import { ArrowLeft, ArrowRight, ChevronDown, Expand, MessageSquarePlus, Monitor, RefreshCw, RotateCw, Smartphone, Tablet } from "lucide-solid" +import { Show, createEffect, createMemo, createSignal, onCleanup, type Component } from "solid-js" import { buildPreviewNavigationUrl, resolvePreviewUrl } from "./preview-isolation" +export interface BrowserFrameElementTarget { + pagePath: string + tagName: string + text?: string + role?: string + ariaLabel?: string + selector?: string + rect: { x: number; y: number; width: number; height: number } +} + interface BrowserFrameLabels { back: string refresh: string path: string go: string + commentMode?: string viewport?: string viewportResponsive?: string viewportDesktop?: string @@ -42,6 +53,38 @@ interface BrowserFrameProps { proxyBasePath: string lockedBaseLabel: string labels: BrowserFrameLabels + commentMode?: boolean + onToggleCommentMode?: () => void + onCommentTarget?: (target: BrowserFrameElementTarget) => void +} + +function getElementText(element: Element): string | undefined { + const text = (element.textContent ?? "").replace(/\s+/g, " ").trim() + return text ? text.slice(0, 120) : undefined +} + +function getElementSelector(element: Element): string { + const parts: string[] = [] + let current: Element | null = element + while (current && current.nodeType === Node.ELEMENT_NODE && parts.length < 5) { + const tag = current.tagName.toLowerCase() + const id = current.getAttribute("id") + if (id) { + parts.unshift(`${tag}#${CSS.escape(id)}`) + break + } + + const className = Array.from(current.classList).slice(0, 2).map((item) => `.${CSS.escape(item)}`).join("") + let part = `${tag}${className}` + const parentElement: Element | null = current.parentElement + if (parentElement) { + const siblings = Array.from(parentElement.children as HTMLCollectionOf).filter((child) => child.tagName === current?.tagName) + if (siblings.length > 1) part = `${part}:nth-of-type(${siblings.indexOf(current) + 1})` + } + parts.unshift(part) + current = parentElement + } + return parts.join(" > ") } export const BrowserFrame: Component = (props) => { @@ -49,8 +92,13 @@ export const BrowserFrame: Component = (props) => { const [pathInput, setPathInput] = createSignal("/") const [viewportPreset, setViewportPreset] = createSignal("responsive") const [viewportMenuOpen, setViewportMenuOpen] = createSignal(false) + const [highlight, setHighlight] = createSignal<{ x: number; y: number; width: number; height: number } | null>(null) + const [commentAvailable, setCommentAvailable] = createSignal(false) let iframeRef: HTMLIFrameElement | undefined + let frameWrapRef: HTMLDivElement | undefined + let cleanupFrameListeners: (() => void) | null = null + const canComment = createMemo(() => commentAvailable() && Boolean(props.onToggleCommentMode && props.onCommentTarget)) const viewport = createMemo(() => VIEWPORT_PRESETS[viewportPreset()]) const isResponsiveViewport = createMemo(() => viewportPreset() === "responsive") const selectedViewportOption = createMemo(() => VIEWPORT_OPTIONS.find((option) => option.id === viewportPreset()) ?? VIEWPORT_OPTIONS[0]) @@ -79,6 +127,75 @@ export const BrowserFrame: Component = (props) => { return buildPreviewNavigationUrl(rawInput, props.proxyBasePath, props.initialUrl, window.location.href) } + const getAccessibleFrameDocument = (): Document | null => { + try { + const doc = iframeRef?.contentDocument + if (!doc || !iframeRef?.contentWindow) return null + void iframeRef.contentWindow.location.href + return doc + } catch { + return null + } + } + + const buildElementTarget = (element: Element): BrowserFrameElementTarget => { + const rect = element.getBoundingClientRect() + const pagePath = getEditablePathFromUrl(iframeRef?.contentWindow?.location.href ?? frameSrc()) + return { + pagePath, + tagName: element.tagName.toLowerCase(), + text: getElementText(element), + role: element.getAttribute("role") ?? undefined, + ariaLabel: element.getAttribute("aria-label") ?? undefined, + selector: getElementSelector(element), + rect: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) }, + } + } + + const attachCommentListeners = () => { + cleanupFrameListeners?.() + cleanupFrameListeners = null + setHighlight(null) + + const doc = getAccessibleFrameDocument() + if (!props.commentMode || !doc || !iframeRef?.contentWindow || !frameWrapRef) return + const frameWindow = iframeRef.contentWindow + + const handleMove = (event: MouseEvent) => { + const target = event.target + if (!target || !(target instanceof (frameWindow as any).Element)) return + const rect = (target as Element).getBoundingClientRect() + const frameRect = iframeRef?.getBoundingClientRect() + const wrapRect = frameWrapRef?.getBoundingClientRect() + if (!frameRect || !wrapRect) return + setHighlight({ + x: frameRect.left - wrapRect.left + rect.x, + y: frameRect.top - wrapRect.top + rect.y, + width: rect.width, + height: rect.height, + }) + } + + const handleLeave = () => setHighlight(null) + + const handleClick = (event: MouseEvent) => { + const target = event.target + if (!target || !(target instanceof (frameWindow as any).Element)) return + event.preventDefault() + event.stopPropagation() + props.onCommentTarget?.(buildElementTarget(target as Element)) + } + + doc.addEventListener("mousemove", handleMove, true) + doc.addEventListener("mouseleave", handleLeave, true) + doc.addEventListener("click", handleClick, true) + cleanupFrameListeners = () => { + doc.removeEventListener("mousemove", handleMove, true) + doc.removeEventListener("mouseleave", handleLeave, true) + doc.removeEventListener("click", handleClick, true) + } + } + const syncPathInputFromFrame = () => { try { const currentHref = iframeRef?.contentWindow?.location.href @@ -86,6 +203,10 @@ export const BrowserFrame: Component = (props) => { } catch { setPathInput(getEditablePathFromUrl(frameSrc())) } + const available = Boolean(getAccessibleFrameDocument()) + setCommentAvailable(available) + if (!available && props.commentMode) props.onToggleCommentMode?.() + attachCommentListeners() } createEffect(() => { @@ -93,6 +214,13 @@ export const BrowserFrame: Component = (props) => { setPathInput(getEditablePathFromUrl(props.initialUrl)) }) + createEffect(() => { + props.commentMode + attachCommentListeners() + }) + + onCleanup(() => cleanupFrameListeners?.()) + const handleBack = (event: MouseEvent) => { event.preventDefault() event.stopPropagation() @@ -196,8 +324,21 @@ export const BrowserFrame: Component = (props) => { + + + -
+
= (props) => { onLoad={syncPathInputFromFrame} />
+ + {(rect) => ( +
+ )} +
) diff --git a/packages/ui/src/components/session-preview-comment.ts b/packages/ui/src/components/session-preview-comment.ts new file mode 100644 index 000000000..b0d666713 --- /dev/null +++ b/packages/ui/src/components/session-preview-comment.ts @@ -0,0 +1,14 @@ +import type { BrowserFrameElementTarget } from "./browser-frame" + +export function buildPreviewCommentMarkdown(target: BrowserFrameElementTarget, comment: string): string { + const label = target.ariaLabel || target.text + const role = target.role ? ` role="${target.role}"` : "" + const element = label ? `${target.tagName}${role} "${label}"` : `${target.tagName}${role}` + const lines = [ + "> Web preview comment", + `> Page: \`${target.pagePath}\``, + `> Element: \`${element}\``, + ] + if (target.selector) lines.push(`> Selector: \`${target.selector}\``) + return `${lines.join("\n")}\n\n${comment}\n\n` +} diff --git a/packages/ui/src/components/session-preview-view.test.ts b/packages/ui/src/components/session-preview-view.test.ts new file mode 100644 index 000000000..b106a091d --- /dev/null +++ b/packages/ui/src/components/session-preview-view.test.ts @@ -0,0 +1,27 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { buildPreviewCommentMarkdown } from "./session-preview-comment" + +test("web preview comments retain the selected element context", () => { + assert.equal( + buildPreviewCommentMarkdown({ + pagePath: "/settings?tab=profile", + tagName: "button", + text: "Fallback text", + role: "switch", + ariaLabel: "Enable alerts", + selector: "main > button#alerts", + rect: { x: 10, y: 20, width: 30, height: 40 }, + }, "Use the compact style"), + [ + "> Web preview comment", + "> Page: `/settings?tab=profile`", + '> Element: `button role="switch" "Enable alerts"`', + "> Selector: `main > button#alerts`", + "", + "Use the compact style", + "", + "", + ].join("\n"), + ) +}) diff --git a/packages/ui/src/components/session-preview-view.tsx b/packages/ui/src/components/session-preview-view.tsx index 4e145c56c..2a261cac3 100644 --- a/packages/ui/src/components/session-preview-view.tsx +++ b/packages/ui/src/components/session-preview-view.tsx @@ -1,8 +1,10 @@ -import type { Component } from "solid-js" +import { createSignal, type Component } from "solid-js" import { X } from "lucide-solid" import { useI18n } from "../lib/i18n" +import { showPromptDialog } from "../stores/alerts" import type { SessionPreviewRecord } from "../stores/session-previews" -import { BrowserFrame } from "./browser-frame" +import { BrowserFrame, type BrowserFrameElementTarget } from "./browser-frame" +import { buildPreviewCommentMarkdown } from "./session-preview-comment" import { resolvePreviewUrl } from "./preview-isolation" interface SessionPreviewViewProps { @@ -14,8 +16,21 @@ interface SessionPreviewViewProps { export const SessionPreviewView: Component = (props) => { const { t } = useI18n() + const [commentMode, setCommentMode] = createSignal(false) const target = () => new URL(props.preview.targetUrl) + async function handleCommentTarget(elementTarget: BrowserFrameElementTarget) { + const comment = await showPromptDialog(t("sessionPreview.comment.prompt"), { + title: t("sessionPreview.comment.title"), + inputLabel: t("sessionPreview.comment.label"), + confirmLabel: t("sessionPreview.comment.add"), + cancelLabel: t("sessionPreview.comment.cancel"), + }) + const normalized = comment?.trim() + if (!normalized) return + props.onInsertComment(buildPreviewCommentMarkdown(elementTarget, normalized)) + } + return (
@@ -49,7 +64,11 @@ export const SessionPreviewView: Component = (props) => viewportTabletLandscape: t("browserFrame.viewport.tabletLandscape"), viewportMobile: t("browserFrame.viewport.mobile"), viewportMobileLandscape: t("browserFrame.viewport.mobileLandscape"), + commentMode: t("sessionPreview.comment.mode"), }} + commentMode={commentMode()} + onToggleCommentMode={() => setCommentMode((value) => !value)} + onCommentTarget={(target) => void handleCommentTarget(target)} />
) diff --git a/packages/ui/src/stores/session-api.ts b/packages/ui/src/stores/session-api.ts index 54db7bab5..39779a422 100644 --- a/packages/ui/src/stores/session-api.ts +++ b/packages/ui/src/stores/session-api.ts @@ -92,7 +92,11 @@ import { import { mergeFetchedSessionRuntimeState, resolveAuthoritativeGenerationRecovery } from "./session-generation-recovery" import { normalizeWorkspacePath } from "./app-session-reconciliation" import { withSessionWorkspace } from "./session-worktree-binding" -import { getSessionLocationEpoch, markAuthoritativeSessionLocation } from "./session-location-authority" +import { + getSessionLocationEpoch, + markAuthoritativeSessionLocation, + observeSessionUpdateAuthority, +} from "./session-location-authority" import { getInstanceLifecycleGeneration, isInstanceLifecycleCurrent } from "./instance-lifecycle-authority" const log = getLogger("api") @@ -266,6 +270,8 @@ function reconcileListedSessionLocation( const reconciled = { ...merged, directory: fetched.directory, workspaceId: fetched.workspaceId } if (!latest || latest.directory !== reconciled.directory || latest.workspaceId !== reconciled.workspaceId) { markAuthoritativeSessionLocation(instanceId, fetched.id, fetched.time?.updated) + } else { + observeSessionUpdateAuthority(instanceId, fetched.id, fetched.time?.updated, true) } return reconciled } diff --git a/packages/ui/src/stores/session-events.ts b/packages/ui/src/stores/session-events.ts index db35dd4f6..886ae6420 100644 --- a/packages/ui/src/stores/session-events.ts +++ b/packages/ui/src/stores/session-events.ts @@ -444,7 +444,9 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo markAuthoritativeSessionLocation(instanceId, info.id, serverUpdated) forgetOpenCodeWorkspaceIdForSession(instanceId, info.id) if (hasWorkspaceId && workspaceId) rememberOpenCodeWorkspaceIdForSession(instanceId, info.id, workspaceId) - } else if (!staleLocation) observeSessionUpdateAuthority(instanceId, info.id, serverUpdated) + } else if (!staleLocation) { + observeSessionUpdateAuthority(instanceId, info.id, serverUpdated, confirmsCurrentLocation) + } const instanceSessions = sessions().get(instanceId) ?? new Map() const currentSession = instanceSessions.get(info.id) diff --git a/packages/ui/src/stores/session-location-authority.ts b/packages/ui/src/stores/session-location-authority.ts index 33b86951d..a37781abe 100644 --- a/packages/ui/src/stores/session-location-authority.ts +++ b/packages/ui/src/stores/session-location-authority.ts @@ -5,6 +5,7 @@ type SessionLocationUpdate = SessionLocation & { hasDirectory: boolean; hasWorks type SessionLocationAuthority = { generation: number serverUpdated?: number + pendingCommit: boolean superseded: SessionLocation[] } @@ -18,7 +19,7 @@ function authority(instanceId: string, sessionId: string): SessionLocationAuthor const sessionKey = key(instanceId, sessionId) const current = authorities.get(sessionKey) if (current) return current - const created = { generation: 0, superseded: [] } + const created = { generation: 0, pendingCommit: false, superseded: [] } authorities.set(sessionKey, created) return created } @@ -27,20 +28,27 @@ function getSessionLocationEpoch(instanceId: string, sessionId: string): number return authorities.get(key(instanceId, sessionId))?.generation ?? 0 } -function observeSessionUpdateAuthority(instanceId: string, sessionId: string, serverUpdated?: number): void { - if (typeof serverUpdated !== "number" || !Number.isFinite(serverUpdated)) return +function observeSessionUpdateAuthority( + instanceId: string, + sessionId: string, + serverUpdated?: number, + confirmsLocation = false, +): void { const current = authority(instanceId, sessionId) - current.serverUpdated = Math.max(current.serverUpdated ?? serverUpdated, serverUpdated) + if (confirmsLocation) current.pendingCommit = false + if (typeof serverUpdated === "number" && Number.isFinite(serverUpdated)) { + current.serverUpdated = Math.max(current.serverUpdated ?? serverUpdated, serverUpdated) + } } function markAuthoritativeSessionLocation(instanceId: string, sessionId: string, serverUpdated?: number): void { const current = authority(instanceId, sessionId) current.generation += 1 - observeSessionUpdateAuthority(instanceId, sessionId, serverUpdated) + observeSessionUpdateAuthority(instanceId, sessionId, serverUpdated, true) } function beginSessionLocationRequest(instanceId: string, sessionId: string): number { - markAuthoritativeSessionLocation(instanceId, sessionId) + authority(instanceId, sessionId).generation += 1 return getSessionLocationEpoch(instanceId, sessionId) } @@ -49,8 +57,9 @@ function commitAuthoritativeSessionLocation( sessionId: string, previous: SessionLocation, ): void { - markAuthoritativeSessionLocation(instanceId, sessionId) const current = authority(instanceId, sessionId) + current.generation += 1 + current.pendingCommit = true if (!current.superseded.some((location) => ( location.directory === previous.directory && location.workspaceId === previous.workspaceId ))) current.superseded.push(previous) @@ -68,15 +77,18 @@ function isStaleSessionLocation( || (update.hasWorkspaceId && update.workspaceId !== currentLocation.workspaceId) if (!conflictsWithCurrent) return false + const matchesSuperseded = Boolean(current?.superseded.some((location) => ( + (!update.hasDirectory || location.directory === update.directory) + && (!update.hasWorkspaceId || location.workspaceId === update.workspaceId) + ))) + if (current?.pendingCommit && matchesSuperseded) return true + if (typeof serverUpdated === "number" && Number.isFinite(serverUpdated) && current?.serverUpdated !== undefined) { if (serverUpdated <= current.serverUpdated) return true return false } - return Boolean(current?.superseded.some((location) => ( - (!update.hasDirectory || location.directory === update.directory) - && (!update.hasWorkspaceId || location.workspaceId === update.workspaceId) - ))) + return matchesSuperseded } messageStoreBus.onInstanceDestroyed((instanceId) => { diff --git a/packages/ui/src/stores/session-request-authority.test.ts b/packages/ui/src/stores/session-request-authority.test.ts index 17ac68f08..bae0af5d2 100644 --- a/packages/ui/src/stores/session-request-authority.test.ts +++ b/packages/ui/src/stores/session-request-authority.test.ts @@ -244,6 +244,49 @@ describe("session request authority", () => { } }) + it("records unchanged list location time against delayed partial updates", async () => { + const instanceId = "unchanged-list-location-time", sessionId = "session" + const { client, cleanup } = setup(instanceId) + const cached = { + ...session(instanceId, sessionId), + directory: "/work", + workspaceId: "workspace-a", + time: { created: 1, updated: 10 }, + } + setSessions((prev) => new Map(prev).set(instanceId, new Map([[sessionId, cached]]))) + ;(client.session as any).list = async () => ({ data: [{ + ...apiSession(sessionId), + directory: "/work", + workspaceID: "workspace-a", + time: { created: 1, updated: 30 }, + }] }) + ;(client.session as any).status = async () => ({ data: {} }) + + try { + await fetchSessions(instanceId) + handleSessionUpdate(instanceId, { + properties: { info: { + ...apiSession(sessionId), + workspaceID: "workspace-b", + time: { created: 1, updated: 20 }, + } }, + } as any) + assert.equal(sessions().get(instanceId)?.get(sessionId)?.workspaceId, "workspace-a") + + handleSessionUpdate(instanceId, { + properties: { info: { + ...apiSession(sessionId), + workspaceID: "workspace-b", + time: { created: 1, updated: 40 }, + } }, + } as any) + assert.equal(sessions().get(instanceId)?.get(sessionId)?.directory, "/work") + assert.equal(sessions().get(instanceId)?.get(sessionId)?.workspaceId, "workspace-b") + } finally { + cleanup() + } + }) + it("does not restore deleted search results or their parent chain", async () => { const instanceId = "late-search-delete" const { client, cleanup } = setup(instanceId) diff --git a/packages/ui/src/stores/session-worktree-binding.test.ts b/packages/ui/src/stores/session-worktree-binding.test.ts index 0a1d08dc2..6df89feac 100644 --- a/packages/ui/src/stores/session-worktree-binding.test.ts +++ b/packages/ui/src/stores/session-worktree-binding.test.ts @@ -292,6 +292,40 @@ describe("session worktree binding", () => { } }) + it("fences a numerically newer partial pre-move location until server confirmation", async () => { + const instanceId = "move-fences-newer-delayed-location" + const cleanup = await setup(instanceId) + const root = session(instanceId, "root-session", null) + const child = session(instanceId, "child-session", root.id) + setFamily(instanceId, root, child) + const update = (directory: string, updated: number, workspaceID?: string) => handleSessionUpdate(instanceId, { + properties: { info: { + id: root.id, + directory, + ...(workspaceID !== undefined ? { workspaceID } : {}), + title: root.title, + version: root.version, + time: { created: 1, updated }, + } }, + } as any) + + try { + update("/repo", 10) + await moveSessionToWorktree(instanceId, root.id, "feature") + + update("/repo", 15) + assert.equal(sessions().get(instanceId)?.get(root.id)?.directory, "/repo-feature") + assert.equal(sessions().get(instanceId)?.get(root.id)?.workspaceId, "workspace-feature") + + update("/repo-feature", 20) + update("/repo", 30) + assert.equal(sessions().get(instanceId)?.get(root.id)?.directory, "/repo") + assert.equal(sessions().get(instanceId)?.get(root.id)?.workspaceId, "workspace-feature") + } finally { + cleanup() + } + }) + it("fences delayed partial locations across rapid root to A to B moves", async () => { const instanceId = "rapid-location-moves" const worktrees = [ From f46b74561fc51b50e5e950917a9edd8593af9b83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Tue, 11 Aug 2026 08:20:46 +0200 Subject: [PATCH 17/20] fix(worktrees): stabilize shared authority paths Anchor pre-Git locks beneath the destination parent even when intermediate directories appear, converge Windows and WSL drive identities, and accept verified DrvFS permission projection without relaxing normal POSIX lock roots. Discover pre-Git leases through linked worktrees, fail closed for nonstandard common directories, retain upstream mutation cleanup until release succeeds, and retry claim retirement without treating a missing heartbeat as a completed release. Normalize superseded Windows session locations so equivalent slash, case, and trailing-separator spellings cannot bypass pending move fences. Covered each edge case with focused tests and validated 406 server tests, 61 browser UI tests, and root typechecks. --- .../server/instance-mutation-proxy.test.ts | 38 +++++----- .../src/server/instance-mutation-proxy.ts | 27 +++++-- .../__tests__/workspace-identity.test.ts | 29 ++++++++ .../workspaces/repository-authority-path.ts | 14 ++-- .../workspaces/repository-lock-ownership.ts | 41 +++++++++-- .../repository-mutation-lock.test.ts | 73 ++++++++++++++++++- .../workspaces/repository-mutation-lock.ts | 4 +- .../src/workspaces/workspace-identity.ts | 28 ++++++- .../workspace-lifetime-lease.test.ts | 29 +++++++- .../workspaces/workspace-lifetime-lease.ts | 14 +++- .../src/stores/session-location-authority.ts | 5 +- .../stores/session-worktree-binding.test.ts | 38 ++++++++++ 12 files changed, 292 insertions(+), 48 deletions(-) diff --git a/packages/server/src/server/instance-mutation-proxy.test.ts b/packages/server/src/server/instance-mutation-proxy.test.ts index e4eb660d9..cd3dcb12e 100644 --- a/packages/server/src/server/instance-mutation-proxy.test.ts +++ b/packages/server/src/server/instance-mutation-proxy.test.ts @@ -147,25 +147,29 @@ describe("openUpstreamMutation", () => { assert.equal(attempts, 2) }) - it("untracks settlement after persistent cleanup failure without an unhandled rejection", async () => { + it("retains settlement and retries after cleanup remains unavailable past the immediate attempts", async () => { const tracker = new ProxyMutationTracker() const body = new EventEmitter() - let unhandled: unknown - const onUnhandled = (error: unknown) => { unhandled = error } - process.once("unhandledRejection", onUnhandled) - try { - await openUpstreamMutation({ - start: async () => ({ body }), - release: async () => { throw new Error("persistent release failure") }, - tracker, - }) - body.emit("end") - await tracker.abortAndDrain(new Error("shutdown")) - await new Promise((resolve) => setImmediate(resolve)) - assert.equal(unhandled, undefined) - } finally { - process.removeListener("unhandledRejection", onUnhandled) - } + let attempts = 0 + let releaseAvailable = false + await openUpstreamMutation({ + start: async () => ({ body }), + release: async () => { + attempts += 1 + if (!releaseAvailable) throw new Error("temporary release failure") + }, + tracker, + }) + body.emit("end") + while (attempts < 4) await new Promise((resolve) => setTimeout(resolve, 10)) + + let drained = false + const drain = tracker.abortAndDrain(new Error("shutdown"), 1_000).then(() => { drained = true }) + await new Promise((resolve) => setImmediate(resolve)) + assert.equal(drained, false) + releaseAvailable = true + await drain + assert.ok(attempts >= 5) }) }) diff --git a/packages/server/src/server/instance-mutation-proxy.ts b/packages/server/src/server/instance-mutation-proxy.ts index 94dfd8005..113a794db 100644 --- a/packages/server/src/server/instance-mutation-proxy.ts +++ b/packages/server/src/server/instance-mutation-proxy.ts @@ -7,6 +7,7 @@ import { resolveNativeSessionLocation, type NativeWorkspaceLocation } from "../w import { acquireRepositoryMutation } from "../workspaces/repository-mutation-lock" const SESSION_LIST_LIMIT = 10_000 +const RELEASE_RETRY_MS = 40 export const MUTATION_SESSION_HEADER = "x-codenomad-mutation-session" export function isInstanceControlMutation(method: string | undefined, pathSuffix: string | undefined): boolean { @@ -37,7 +38,7 @@ interface MutationSession { directory?: string } -async function retryRelease(release: () => void | Promise, onFailure?: () => void): Promise { +async function retryRelease(release: () => void | Promise): Promise { let failure: unknown for (let attempt = 0; attempt < 3; attempt += 1) { try { @@ -45,12 +46,26 @@ async function retryRelease(release: () => void | Promise, onFailure?: () return } catch (error) { failure = error - onFailure?.() } } throw failure } +function waitForReleaseRetry(): Promise { + return new Promise((resolve) => setTimeout(resolve, RELEASE_RETRY_MS)) +} + +async function releaseUntilSuccessful(release: () => void | Promise): Promise { + while (true) { + try { + await retryRelease(release) + return + } catch { + await waitForReleaseRetry() + } + } +} + export async function admitWorkspaceMutation(params: { gate: Pick workspaceId: string @@ -215,18 +230,16 @@ export async function openUpstreamMutation controller.abort(params.downstreamSignal?.reason) params.downstreamSignal?.addEventListener("abort", abortForDisconnect, { once: true }) - let settled = false let timeout: ReturnType | undefined let untrack = () => {} let settlement: Promise | undefined const settle = (): Promise => { if (settlement) return settlement - settled = true settlement = Promise.resolve().then(async () => { if (timeout) clearTimeout(timeout) params.downstreamSignal?.removeEventListener("abort", abortForDisconnect) try { - await retryRelease(params.release, untrack) + await releaseUntilSuccessful(params.release) } finally { untrack() } @@ -240,10 +253,10 @@ export async function openUpstreamMutation { void settle().catch(() => undefined) }) + holdMutationLeaseUntilUpstreamSettles(upstream.body, () => { void settle() }) return upstream } catch (error) { - await settle().catch(() => undefined) + await settle() throw error } } diff --git a/packages/server/src/workspaces/__tests__/workspace-identity.test.ts b/packages/server/src/workspaces/__tests__/workspace-identity.test.ts index acd9c8a2a..df93b24aa 100644 --- a/packages/server/src/workspaces/__tests__/workspace-identity.test.ts +++ b/packages/server/src/workspaces/__tests__/workspace-identity.test.ts @@ -155,6 +155,35 @@ describe("workspace identity", () => { ) }) + it("shares Windows-drive identities with WSL drive mounts regardless of case", () => { + const wslRelease = "5.15.153.1-microsoft-standard-WSL2" + const windowsIdentity = canonicalFilesystemIdentity(String.raw`C:\Projects\CodeNomad`, "win32") + assert.equal( + windowsIdentity, + canonicalFilesystemIdentity("/mnt/c/Projects/CodeNomad", "linux", wslRelease), + ) + assert.equal( + windowsIdentity, + canonicalFilesystemIdentity(String.raw`\\wsl.localhost\Ubuntu\mnt\c\Projects\CodeNomad`, "win32"), + ) + assert.equal( + sharedRelativePathIdentity(String.raw`C:\Projects\CodeNomad`, String.raw`C:\Projects`, "win32"), + sharedRelativePathIdentity("/mnt/c/Projects/CodeNomad", "/mnt/c/Projects", "linux", wslRelease), + ) + assert.equal( + sharedRelativePathIdentity(String.raw`C:\Projects\CodeNomad`, String.raw`C:\Projects`, "win32"), + sharedRelativePathIdentity( + String.raw`\\wsl.localhost\Ubuntu\mnt\c\Projects\CodeNomad`, + String.raw`\\wsl.localhost\Ubuntu\mnt\c\Projects`, + "win32", + ), + ) + assert.notEqual( + canonicalFilesystemIdentity("/mnt/c/Projects/CodeNomad", "linux", "6.8.0-linux"), + canonicalFilesystemIdentity("/mnt/c/projects/codenomad", "linux", "6.8.0-linux"), + ) + }) + it("canonicalizes aliases and falls back to an absolute identity for missing paths", async () => { const { root, target, link } = await createLinkedWorkspace() const [targetResult, linkResult, missing] = await Promise.all([ diff --git a/packages/server/src/workspaces/repository-authority-path.ts b/packages/server/src/workspaces/repository-authority-path.ts index 75b8cd34f..34eef1403 100644 --- a/packages/server/src/workspaces/repository-authority-path.ts +++ b/packages/server/src/workspaces/repository-authority-path.ts @@ -9,15 +9,18 @@ export async function adjacentRepositoryAuthorityPath( suffix = "", ): Promise { const workspacePath = path.resolve(workspaceFolder) - let candidate = path.dirname(workspacePath) + const workspaceParent = path.dirname(workspacePath) + let candidate = workspaceParent + const missingSegments: string[] = [] while (true) { try { - const canonicalParent = await realpath(candidate) - const metadata = await lstat(canonicalParent) + const canonicalAncestor = await realpath(candidate) + const metadata = await lstat(canonicalAncestor) if (!metadata.isDirectory() || metadata.isSymbolicLink()) return undefined if (process.platform !== "win32" && process.getuid && metadata.uid !== process.getuid()) return undefined - await access(canonicalParent, constants.W_OK) - const relativeIdentity = sharedRelativePathIdentity(workspacePath, candidate) + await access(canonicalAncestor, constants.W_OK) + const canonicalParent = path.resolve(canonicalAncestor, ...missingSegments) + const relativeIdentity = sharedRelativePathIdentity(workspacePath, workspaceParent) const digest = createHash("sha256").update(relativeIdentity).digest("hex") return path.join(canonicalParent, ".codenomad", category, `${digest}${suffix}`) } catch (error) { @@ -25,6 +28,7 @@ export async function adjacentRepositoryAuthorityPath( if (!["ENOENT", "ENOTDIR"].includes(code ?? "")) return undefined const parent = path.dirname(candidate) if (parent === candidate) return undefined + missingSegments.unshift(path.basename(candidate)) candidate = parent } } diff --git a/packages/server/src/workspaces/repository-lock-ownership.ts b/packages/server/src/workspaces/repository-lock-ownership.ts index bfd752c5d..828525594 100644 --- a/packages/server/src/workspaces/repository-lock-ownership.ts +++ b/packages/server/src/workspaces/repository-lock-ownership.ts @@ -1,6 +1,7 @@ import { randomUUID } from "node:crypto" import { spawnSync } from "node:child_process" -import { lstat, mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises" +import { lstat, mkdir, readFile, readdir, realpath, rename, rm, statfs, writeFile } from "node:fs/promises" +import os from "node:os" import path from "node:path" import { setTimeout as delay } from "node:timers/promises" import { @@ -15,6 +16,7 @@ import { const POLL_MS = 40 const HEARTBEAT_MS = 1_000 const RELEASE_ATTEMPTS = 3 +const WSL_WINDOWS_FILESYSTEM_TYPES = new Set([0x01021997, 0x53464846]) interface OwnerRecord { token: string @@ -59,10 +61,27 @@ export async function ensurePrivateLockRoot(directory: string): Promise { if (!metadata.isDirectory() || metadata.isSymbolicLink()) throw new Error(`Private lock root is not a real directory: ${directory}`) if (process.platform !== "win32") { if (process.getuid && metadata.uid !== process.getuid()) throw new Error(`Private lock root is owned by another user: ${directory}`) - if ((metadata.mode & 0o777) !== 0o700) throw new Error(`Private lock root must have mode 0700: ${directory}`) + const [canonicalDirectory, filesystem] = await Promise.all([realpath(directory), statfs(directory)]) + if (!privateLockModeIsAcceptable(canonicalDirectory, metadata.mode, process.platform, os.release(), filesystem.type)) { + throw new Error(`Private lock root must have mode 0700: ${directory}`) + } } } +export function privateLockModeIsAcceptable( + directory: string, + mode: number, + platform: NodeJS.Platform = process.platform, + release = os.release(), + filesystemType?: number | bigint, +): boolean { + if (platform === "win32" || (mode & 0o777) === 0o700) return true + const wslWindowsDrive = platform === "linux" && /microsoft/i.test(release) + && /^\/mnt\/[a-z](?:\/|$)/i.test(path.posix.resolve(directory)) + && filesystemType !== undefined && WSL_WINDOWS_FILESYSTEM_TYPES.has(Number(filesystemType)) + return wslWindowsDrive && (mode & 0o700) === 0o700 +} + function validOwner(value: unknown, token: string): value is OwnerRecord { const owner = value as Partial | null const identity = owner?.identity @@ -185,7 +204,15 @@ export async function retireCurrentOwnershipClaim(claimPath: string): Promise { } } -export async function acquireOwnershipQueue(lockPath: string, signal?: AbortSignal): Promise<() => Promise> { +export async function acquireOwnershipQueue( + lockPath: string, + signal?: AbortSignal, + retireCurrent: (path: string) => Promise = retireCurrentOwnershipClaim, +): Promise<() => Promise> { signal?.throwIfAborted() await ensureDirectory(lockPath) const claimsPath = path.join(lockPath, "claims") @@ -280,7 +311,7 @@ export async function acquireOwnershipQueue(lockPath: string, signal?: AbortSign } } catch (error) { await stopHeartbeat?.() - await retireCurrentOwnershipClaim(claimPath) + await retireCurrentOwnershipClaimWithRetry(claimPath, retireCurrent) await rm(preparationPath, { recursive: true, force: true }) throw error } diff --git a/packages/server/src/workspaces/repository-mutation-lock.test.ts b/packages/server/src/workspaces/repository-mutation-lock.test.ts index 581213e7d..18adcc35a 100644 --- a/packages/server/src/workspaces/repository-mutation-lock.test.ts +++ b/packages/server/src/workspaces/repository-mutation-lock.test.ts @@ -10,7 +10,9 @@ import { managerProcessIdentity } from "./process-identity" import { acquireOwnershipQueue, ensurePrivateLockRoot, + privateLockModeIsAcceptable, processIdentityIsAlive, + retireCurrentOwnershipClaim, retireCurrentOwnershipClaimWithRetry, retireOwnershipClaim, } from "./repository-lock-ownership" @@ -101,18 +103,33 @@ describe("repository mutation lock", () => { await first.release() }) - it("places a missing non-Git destination lock under its canonical writable parent", async () => { + it("places a missing non-Git destination lock under its stable immediate parent", async () => { const directory = await plainDirectory() const destination = path.join(directory, "missing", "workspace") const admission = await acquireRepositoryMutation({ workspaceFolder: destination }) try { assert.equal([...admission.lockPaths].some((lockPath) => - lockPath.startsWith(path.join(directory, ".codenomad", "repository-locks"))), true) + lockPath.startsWith(path.join(directory, "missing", ".codenomad", "repository-locks"))), true) } finally { await admission.release() } }) + it("keeps another process blocked after missing intermediate directories appear", async () => { + const directory = await plainDirectory() + const destination = path.join(directory, "missing", "workspace") + const marker = path.join(directory, "marker.txt") + await writeFile(marker, "") + const admission = await acquireRepositoryMutation({ workspaceFolder: destination }) + + const waiting = child(["hold", destination, "0", marker]) + await new Promise((resolve) => setTimeout(resolve, 150)) + assert.equal(await readFile(marker, "utf8"), "") + await admission.release() + await waiting + assert.deepEqual((await readFile(marker, "utf8")).trim().split(/\r?\n/), ["enter:0", "exit:0"]) + }) + it("retains the adjacent destination lock after Git appears", async () => { const directory = await plainDirectory() const destination = path.join(directory, "workspace") @@ -222,6 +239,38 @@ describe("repository mutation lock", () => { assert.equal(attempts, 2) }) + it("does not report a missing heartbeat as released while its claim survives", async () => { + const directory = await plainDirectory() + const claimPath = path.join(directory, "claim") + await mkdir(claimPath) + assert.equal(await retireCurrentOwnershipClaim(claimPath), false) + await access(claimPath) + }) + + it("retries claim retirement when a waiting acquisition is cancelled", async () => { + const directory = await plainDirectory() + const lockPath = path.join(directory, "cancel.lock") + const release = await acquireOwnershipQueue(lockPath) + const controller = new AbortController() + const reason = new Error("cancel waiting owner") + let retireAttempts = 0 + const waiting = acquireOwnershipQueue(lockPath, controller.signal, async (claimPath) => { + retireAttempts += 1 + if (retireAttempts === 1) throw new Error("transient retirement failure") + return retireCurrentOwnershipClaim(claimPath) + }) + const claimsPath = path.join(lockPath, "claims") + while ((await readdir(claimsPath)).filter((entry) => !entry.startsWith(".")).length < 2) { + await new Promise((resolve) => setTimeout(resolve, 10)) + } + controller.abort(reason) + + await assert.rejects(waiting, (error) => error === reason) + assert.equal(retireAttempts, 2) + await release() + assert.deepEqual((await readdir(claimsPath)).filter((entry) => !entry.startsWith(".")), []) + }) + it("keeps a failed ownership release retryable and restarts its heartbeat", async () => { const directory = await plainDirectory() const lockPath = path.join(directory, "release.lock") @@ -318,6 +367,26 @@ describe("repository mutation lock", () => { await assert.rejects(ensurePrivateLockRoot(linkedRoot), /not a real directory/) }) + it("accepts WSL Windows-drive mode projection without relaxing normal POSIX roots", () => { + const wslRelease = "5.15.153.1-microsoft-standard-WSL2" + assert.equal(privateLockModeIsAcceptable( + "/mnt/c/Repo/.codenomad", 0o40777, "linux", wslRelease, 0x01021997, + ), true) + assert.equal(privateLockModeIsAcceptable( + "/mnt/c/Repo/.codenomad", 0o40755, "linux", wslRelease, 0x53464846, + ), true) + assert.equal(privateLockModeIsAcceptable( + "/mnt/c/Repo/.codenomad", 0o40755, "linux", wslRelease, 0xef53, + ), false) + assert.equal(privateLockModeIsAcceptable( + "/home/dev/.codenomad", 0o40755, "linux", wslRelease, 0x01021997, + ), false) + assert.equal(privateLockModeIsAcceptable( + "/mnt/c/Repo/.codenomad", 0o40755, "linux", "6.8.0-linux", 0x01021997, + ), false) + assert.equal(privateLockModeIsAcceptable("/home/dev/.codenomad", 0o40700, "linux", "6.8.0-linux"), true) + }) + it("serializes a simultaneous multi-process acquisition race", async () => { const directory = await repository() const marker = path.join(directory, "race.txt") diff --git a/packages/server/src/workspaces/repository-mutation-lock.ts b/packages/server/src/workspaces/repository-mutation-lock.ts index 377939e6a..c952b3a4c 100644 --- a/packages/server/src/workspaces/repository-mutation-lock.ts +++ b/packages/server/src/workspaces/repository-mutation-lock.ts @@ -4,7 +4,7 @@ import { AsyncLocalStorage } from "node:async_hooks" import type { InstanceMutationGate } from "../server/instance-mutation-gate" import { acquireOwnershipQueue, ensurePrivateLockRoot } from "./repository-lock-ownership" import { adjacentRepositoryAuthorityPath } from "./repository-authority-path" -import { canonicalFilesystemIdentity, repositoryMutationKey, resolveRepositoryIdentity } from "./workspace-identity" +import { repositoryMutationKey, resolveRepositoryIdentity } from "./workspace-identity" const heldLocks = new AsyncLocalStorage>() @@ -80,7 +80,7 @@ export async function acquireRepositoryMutation(params: { } try { - const lexicalKey = repositoryMutationKey(canonicalFilesystemIdentity(params.workspaceFolder)) + const lexicalKey = repositoryMutationKey(params.workspaceFolder) await acquire(lexicalKey, await adjacentLockPath(params.workspaceFolder)) while (true) { const identity = await resolveRepositoryIdentity(params.workspaceFolder) diff --git a/packages/server/src/workspaces/workspace-identity.ts b/packages/server/src/workspaces/workspace-identity.ts index 30d27125b..2194fea48 100644 --- a/packages/server/src/workspaces/workspace-identity.ts +++ b/packages/server/src/workspaces/workspace-identity.ts @@ -1,5 +1,6 @@ import { realpath, stat } from "node:fs/promises" import { realpathSync } from "node:fs" +import os from "node:os" import path from "node:path" import { queryGitRepositoryPaths } from "./git-output" @@ -13,7 +14,18 @@ function wslUncIdentity(value: string): string | null { const match = withoutWindowsExtendedPrefix(value.trim()).replace(/\//g, "\\").match(WSL_UNC_PATH_REGEX) if (!match) return null const linuxPath = `/${(match[2] ?? "").split(/\\+/).filter(Boolean).join("/")}` - return `wsl:${match[1]!.toLowerCase()}:${path.posix.normalize(linuxPath)}` + return windowsDriveIdentity(linuxPath, "linux", "microsoft") + ?? `wsl:${match[1]!.toLowerCase()}:${path.posix.normalize(linuxPath)}` +} + +function windowsDriveIdentity(value: string, platform: NodeJS.Platform, release = os.release()): string | null { + if (platform !== "win32" && (platform !== "linux" || !/microsoft/i.test(release))) return null + const match = platform === "win32" + ? withoutWindowsExtendedPrefix(value).replace(/\\/g, "/").match(/^([a-z]):(?:\/(.*))?$/i) + : path.posix.normalize(value).match(/^\/mnt\/([a-z])(?:\/(.*))?$/i) + if (!match) return null + const suffix = (match[2] ?? "").split("/").filter(Boolean).join("/").toLowerCase() + return `windows-drive:${match[1]!.toLowerCase()}:/${suffix}` } export function normalizeWorkspaceIdentityPath(value: string, platform: NodeJS.Platform = process.platform): string { @@ -26,6 +38,7 @@ export function normalizeWorkspaceIdentityPath(value: string, platform: NodeJS.P export function canonicalFilesystemIdentity( value: string, platform: NodeJS.Platform = process.platform, + release = os.release(), ): string { const input = platform === "win32" ? withoutWindowsExtendedPrefix(value) : value const wslIdentity = platform === "win32" ? wslUncIdentity(input) : null @@ -41,8 +54,11 @@ export function canonicalFilesystemIdentity( } const pathApi = platform === "win32" ? path.win32 : path.posix const absolutePath = pathApi.resolve(input) + const canonicalPath = platform === process.platform ? canonicalFilesystemPathSync(absolutePath) : absolutePath + const driveIdentity = windowsDriveIdentity(canonicalPath, platform, release) + if (driveIdentity) return driveIdentity if (platform === process.platform) { - return normalizeWorkspaceIdentityPath(canonicalFilesystemPathSync(absolutePath), platform) + return normalizeWorkspaceIdentityPath(canonicalPath, platform) } return normalizeWorkspaceIdentityPath(absolutePath, platform) } @@ -51,13 +67,19 @@ export function sharedRelativePathIdentity( value: string, ancestor: string, platform: NodeJS.Platform = process.platform, + release = os.release(), ): string { const pathApi = platform === "win32" ? path.win32 : path.posix const relative = pathApi.relative(pathApi.resolve(ancestor), pathApi.resolve(value)) const segments = relative.split(/[\\/]+/).filter(Boolean) if (segments.some((segment) => segment === "..")) throw new Error(`${value} is outside ${ancestor}`) const identity = segments.join("/") - return platform === "win32" && !wslUncIdentity(value) ? identity.toLowerCase() : identity + const valueDrive = windowsDriveIdentity(pathApi.resolve(value), platform, release) + ?? (wslUncIdentity(value)?.startsWith("windows-drive:") ? wslUncIdentity(value) : null) + const ancestorDrive = windowsDriveIdentity(pathApi.resolve(ancestor), platform, release) + ?? (wslUncIdentity(ancestor)?.startsWith("windows-drive:") ? wslUncIdentity(ancestor) : null) + const windowsDrive = valueDrive && ancestorDrive + return (platform === "win32" && !wslUncIdentity(value)) || windowsDrive ? identity.toLowerCase() : identity } export function workspaceIdentityPathsEqual( diff --git a/packages/server/src/workspaces/workspace-lifetime-lease.test.ts b/packages/server/src/workspaces/workspace-lifetime-lease.test.ts index de66960df..6c6113960 100644 --- a/packages/server/src/workspaces/workspace-lifetime-lease.test.ts +++ b/packages/server/src/workspaces/workspace-lifetime-lease.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict" import { spawn, type ChildProcess } from "node:child_process" import { execFileSync } from "node:child_process" -import { access, mkdtemp, readFile, rm, writeFile } from "node:fs/promises" +import { access, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises" import os from "node:os" import path from "node:path" import { afterEach, describe, it } from "node:test" @@ -114,4 +114,31 @@ describe("workspace lifetime lease", () => { assert.equal(await hasWorkspaceLifetimeBlocker({ workspaceFolder: directory }), true) await lease.release() }) + + it("finds a pre-Git lease later through a linked worktree", async () => { + const container = await temporaryDirectory() + const repository = path.join(container, "repository") + const linked = path.join(container, "linked") + await mkdir(repository) + const lease = await acquireWorkspaceLifetimeLease(repository, "workspace") + + execFileSync("git", ["init", "-b", "main"], { cwd: repository }) + execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repository }) + execFileSync("git", ["config", "user.name", "Test"], { cwd: repository }) + execFileSync("git", ["commit", "--allow-empty", "-m", "initial"], { cwd: repository }) + execFileSync("git", ["worktree", "add", "-b", "linked", linked], { cwd: repository }) + + assert.equal(await hasWorkspaceLifetimeBlocker({ workspaceFolder: linked }), true) + await lease.release() + assert.equal(await hasWorkspaceLifetimeBlocker({ workspaceFolder: linked }), false) + }) + + it("fails closed when a nonstandard common directory hides pre-Git adjacent authority", async () => { + const container = await temporaryDirectory() + const repository = path.join(container, "repository") + const commonDirectory = path.join(container, "git-data") + await mkdir(repository) + execFileSync("git", ["init", "--separate-git-dir", commonDirectory], { cwd: repository }) + assert.equal(await hasWorkspaceLifetimeBlocker({ workspaceFolder: repository }), true) + }) }) diff --git a/packages/server/src/workspaces/workspace-lifetime-lease.ts b/packages/server/src/workspaces/workspace-lifetime-lease.ts index 688e8aa4c..67ef0a28a 100644 --- a/packages/server/src/workspaces/workspace-lifetime-lease.ts +++ b/packages/server/src/workspaces/workspace-lifetime-lease.ts @@ -44,13 +44,19 @@ function isLeaseRecord(value: unknown, token: string): value is LeaseRecord { export async function workspaceLifetimeAuthorityRoots(workspaceFolder: string): Promise<{ repositoryKey: string roots: string[] + complete: boolean }> { const identity = await resolveRepositoryIdentity(workspaceFolder) - const adjacent = await adjacentRepositoryAuthorityPath(workspaceFolder, "workspace-leases") - const roots = [adjacent, identity.commonDir && path.join(identity.commonDir, "codenomad", "workspace-leases")] + const authorityFolders = [workspaceFolder] + const standardCommonDirectory = !identity.commonDir || path.basename(identity.commonDir) === ".git" + if (identity.commonDir && standardCommonDirectory) authorityFolders.push(path.dirname(identity.commonDir)) + const adjacent = await Promise.all([...new Set(authorityFolders)].map( + (folder) => adjacentRepositoryAuthorityPath(folder, "workspace-leases"), + )) + const roots = [...adjacent, identity.commonDir && path.join(identity.commonDir, "codenomad", "workspace-leases")] .filter((value): value is string => Boolean(value)) if (roots.length === 0) throw new Error(`No shared workspace lease location is available for ${workspaceFolder}`) - return { repositoryKey: identity.mutationKey, roots: [...new Set(roots)] } + return { repositoryKey: identity.mutationKey, roots: [...new Set(roots)], complete: standardCommonDirectory } } async function ensureLeaseRoot(root: string): Promise { @@ -191,5 +197,5 @@ export async function hasWorkspaceLifetimeBlocker(params: { if (lease.token !== params.excludingToken) return true } } - return false + return !authority.complete } diff --git a/packages/ui/src/stores/session-location-authority.ts b/packages/ui/src/stores/session-location-authority.ts index a37781abe..0f46b0082 100644 --- a/packages/ui/src/stores/session-location-authority.ts +++ b/packages/ui/src/stores/session-location-authority.ts @@ -1,4 +1,5 @@ import { messageStoreBus } from "./message-v2/bus" +import { workspaceDirectoriesEqual } from "./opencode-workspace-matching" type SessionLocation = { directory?: string; workspaceId?: string } type SessionLocationUpdate = SessionLocation & { hasDirectory: boolean; hasWorkspaceId: boolean } @@ -61,7 +62,7 @@ function commitAuthoritativeSessionLocation( current.generation += 1 current.pendingCommit = true if (!current.superseded.some((location) => ( - location.directory === previous.directory && location.workspaceId === previous.workspaceId + workspaceDirectoriesEqual(location.directory, previous.directory) && location.workspaceId === previous.workspaceId ))) current.superseded.push(previous) } @@ -78,7 +79,7 @@ function isStaleSessionLocation( if (!conflictsWithCurrent) return false const matchesSuperseded = Boolean(current?.superseded.some((location) => ( - (!update.hasDirectory || location.directory === update.directory) + (!update.hasDirectory || workspaceDirectoriesEqual(location.directory, update.directory)) && (!update.hasWorkspaceId || location.workspaceId === update.workspaceId) ))) if (current?.pendingCommit && matchesSuperseded) return true diff --git a/packages/ui/src/stores/session-worktree-binding.test.ts b/packages/ui/src/stores/session-worktree-binding.test.ts index 6df89feac..7fbf585bd 100644 --- a/packages/ui/src/stores/session-worktree-binding.test.ts +++ b/packages/ui/src/stores/session-worktree-binding.test.ts @@ -292,6 +292,44 @@ describe("session worktree binding", () => { } }) + it("fences a Windows-equivalent partial location while a local move is pending confirmation", async () => { + const instanceId = "windows-equivalent-delayed-event" + const oldDirectory = String.raw`C:\Users\Dev\Repo` + const movedDirectory = String.raw`C:\Users\Dev\Repo-feature` + const cleanup = await setup(instanceId, { + move: async (_sessionId, slug) => ({ + rootSessionId: "root-session", + worktreeSlug: slug, + sessions: ["root-session", "child-session"].map((sessionId) => ({ + sessionId, + directory: movedDirectory, + workspaceId: "workspace-feature", + })), + }), + }) + const root = { ...session(instanceId, "root-session", null), directory: oldDirectory, workspaceId: "workspace-old" } + const child = { ...session(instanceId, "child-session", root.id), directory: oldDirectory, workspaceId: "workspace-old" } + setFamily(instanceId, root, child) + + try { + await moveSessionToWorktree(instanceId, root.id, "feature") + handleSessionUpdate(instanceId, { + properties: { info: { + id: root.id, + directory: "c:/users/dev/repo/", + title: root.title, + version: root.version, + time: { created: 1, updated: 2 }, + } }, + } as any) + + assert.equal(sessions().get(instanceId)?.get(root.id)?.directory, movedDirectory) + assert.equal(sessions().get(instanceId)?.get(root.id)?.workspaceId, "workspace-feature") + } finally { + cleanup() + } + }) + it("fences a numerically newer partial pre-move location until server confirmation", async () => { const instanceId = "move-fences-newer-delayed-location" const cleanup = await setup(instanceId) From 6be9788b071258d45516d293caf3bf486728fd65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Tue, 11 Aug 2026 09:18:36 +0200 Subject: [PATCH 18/20] fix(worktrees): bind authority to trusted documents Keep repository and lease cleanup pending until every ownership claim retires, canonicalize existing directory aliases, resolve custom WSL drive mounts, and distinguish WSL PID namespaces so live foreign owners cannot be retired locally. Restrict Electron and Tauri renderer authority to shell documents and block top-level same-origin preview navigation before it can replace the shell. Preserve iframe preview networking while preventing history-based native capability bypasses. Clear stale workspace IDs on directory-only moves, compare current Windows locations canonically, and normalize bounded preview metadata before building comment Markdown. Added focused coverage and validated 410 server tests, 69 UI tests, 133 Electron tests, 98 Tauri tests, and typechecks. --- .../electron/main/client-state-ipc.test.ts | 8 +- .../electron/main/client-state-ipc.ts | 8 +- packages/electron-app/electron/main/main.ts | 10 +- .../electron/main/renderer-origin.test.ts | 12 ++- .../electron/main/renderer-origin.ts | 18 ++++ .../electron/main/worktree-directory.test.ts | 4 +- .../electron/main/worktree-directory.ts | 6 +- .../src/permissions/opencode-replier.ts | 12 +-- .../src/server/instance-mutation-gate.ts | 3 +- .../server/instance-mutation-proxy.test.ts | 11 +- .../src/server/instance-mutation-proxy.ts | 14 +-- .../__tests__/workspace-identity.test.ts | 45 +++++++- .../src/workspaces/process-identity.test.ts | 14 +++ .../server/src/workspaces/process-identity.ts | 36 ++++++- .../workspaces/repository-authority-path.ts | 4 +- .../workspaces/repository-lock-ownership.ts | 38 ++++--- .../repository-mutation-lock.test.ts | 53 +++++---- .../workspaces/repository-mutation-lock.ts | 25 ++--- .../src/workspaces/workspace-identity.ts | 55 ++++++---- .../workspace-lifetime-lease.test.ts | 27 ++++- .../workspaces/workspace-lifetime-lease.ts | 14 ++- .../src/workspaces/wsl-windows-drive.ts | 74 +++++++++++++ .../src-tauri/src/client_state/access.rs | 71 ++++++------ .../src-tauri/src/client_state/commands.rs | 22 ++-- .../src-tauri/src/client_state/navigation.rs | 2 +- .../src-tauri/src/client_state/tests.rs | 35 +++--- packages/tauri-app/src-tauri/src/main.rs | 59 ++++++++++ .../src-tauri/src/worktree_directory.rs | 29 +++-- .../src/components/session-preview-comment.ts | 19 +++- .../components/session-preview-view.test.ts | 45 ++++++++ packages/ui/src/stores/session-events.ts | 9 +- .../src/stores/session-location-authority.ts | 2 +- .../stores/session-worktree-binding.test.ts | 101 +++++++++++++++++- 33 files changed, 677 insertions(+), 208 deletions(-) create mode 100644 packages/server/src/workspaces/wsl-windows-drive.ts diff --git a/packages/electron-app/electron/main/client-state-ipc.test.ts b/packages/electron-app/electron/main/client-state-ipc.test.ts index 15f1698e8..596545c1f 100644 --- a/packages/electron-app/electron/main/client-state-ipc.test.ts +++ b/packages/electron-app/electron/main/client-state-ipc.test.ts @@ -6,10 +6,10 @@ import { setupClientStateIPC } from "./client-state-ipc" function harness() { const handlers = new Map unknown>() const listeners = new Map void>() - const frame = { url: "http://127.0.0.1:3000/app" } + const frame = { url: "http://127.0.0.1:3000/?launch=desktop" } const webContents = { mainFrame: frame, - getURL: () => "http://127.0.0.1:3000/app", + getURL: () => frame.url, on: (event: string, listener: (...args: unknown[]) => void) => listeners.set(event, listener), } const window = { isDestroyed: () => false, webContents } @@ -50,6 +50,10 @@ test("IPC channels enforce the current main sender, frame, origin, and token", a { sender: h.webContents, senderFrame: { url: h.frame.url } }, { sender: h.webContents, senderFrame: { ...h.frame, url: "https://example.com" } }, ]) await assert.rejects(h.handlers.get("client-state:load")!(invalid as never, "token") as Promise) + + h.frame.url = "http://127.0.0.1:3000/previews/token" + await assert.rejects(h.handlers.get("client-state:claimAccess")!(event as never, "preview-token") as Promise, /renderer document/) + await assert.rejects(h.handlers.get("client-state:load")!(event as never, "token") as Promise, /renderer document/) }) test("only the registered current window can reset renderer authority", () => { diff --git a/packages/electron-app/electron/main/client-state-ipc.ts b/packages/electron-app/electron/main/client-state-ipc.ts index 51b0b0c5f..31cd7e7c8 100644 --- a/packages/electron-app/electron/main/client-state-ipc.ts +++ b/packages/electron-app/electron/main/client-state-ipc.ts @@ -1,7 +1,7 @@ import type { BrowserWindow, IpcMainInvokeEvent } from "electron" import type { ClientStateManager } from "./client-state" import { shouldResetRendererAccessTokenForNavigation } from "./client-state-navigation" -import { isAllowedRendererOrigin } from "./renderer-origin" +import { isAllowedRendererDocument, isAllowedRendererOrigin } from "./renderer-origin" interface IPCRegistrar { handle(channel: string, listener: (event: IpcMainInvokeEvent, ...args: unknown[]) => unknown): void @@ -19,11 +19,11 @@ function validateSender(event: IpcMainInvokeEvent, mainWindow: BrowserWindow | n const currentUrl = mainWindow.webContents.getURL() if ( - !isAllowedRendererOrigin(currentUrl, allowedOrigins) || - !isAllowedRendererOrigin(event.senderFrame.url, allowedOrigins) || + !isAllowedRendererDocument(currentUrl, allowedOrigins) || + !isAllowedRendererDocument(event.senderFrame.url, allowedOrigins) || new URL(currentUrl).origin !== new URL(event.senderFrame.url).origin ) { - throw new Error("Client state IPC is not available to the current renderer origin") + throw new Error("Client state IPC is not available to the current renderer document") } } diff --git a/packages/electron-app/electron/main/main.ts b/packages/electron-app/electron/main/main.ts index b1ba7534a..3737aa0e0 100644 --- a/packages/electron-app/electron/main/main.ts +++ b/packages/electron-app/electron/main/main.ts @@ -11,7 +11,7 @@ import { ClientStateLifecycle } from "./client-state-lifecycle" import { ClientStateNavigationController } from "./client-state-navigation" import { setupCliIPC } from "./ipc" import { configureMediaPermissionHandlers, isAllowedRendererOrigin } from "./permissions" -import { resolveConfiguredRendererOrigins } from "./renderer-origin" +import { isAllowedMainWindowDocument, resolveConfiguredRendererOrigins } from "./renderer-origin" import { CliProcessManager } from "./process-manager" import { clampWindowBounds, @@ -235,6 +235,8 @@ function shouldOpenExternally(url: string, window?: BrowserWindow | null): boole } function setupNavigationGuards(window: BrowserWindow, navigationController?: ClientStateNavigationController) { + const isTrustedLocalDocument = (url: string) => !navigationController + || isAllowedMainWindowDocument(url, getAllowedRendererOrigins(window)) const handleExternal = (url: string) => { shell.openExternal(url).catch((error) => console.error("[cli] failed to open external URL", url, error)) } @@ -244,13 +246,15 @@ function setupNavigationGuards(window: BrowserWindow, navigationController?: Cli handleExternal(url) return { action: "deny" } } - return { action: "allow" } + return { action: isTrustedLocalDocument(url) ? "allow" : "deny" } }) window.webContents.on("will-navigate", (event, url) => { if (shouldOpenExternally(url, window)) { event.preventDefault() handleExternal(url) + } else if (!isTrustedLocalDocument(url)) { + event.preventDefault() } else if (navigationController) { event.preventDefault() void navigationController.navigate((target) => target.loadURL(url)).catch((error) => { @@ -265,6 +269,8 @@ function setupNavigationGuards(window: BrowserWindow, navigationController?: Cli if (shouldOpenExternally(url, window)) { event.preventDefault() handleExternal(url) + } else if (!isTrustedLocalDocument(url)) { + event.preventDefault() } }) } diff --git a/packages/electron-app/electron/main/renderer-origin.test.ts b/packages/electron-app/electron/main/renderer-origin.test.ts index 9080e7cce..de2aeda46 100644 --- a/packages/electron-app/electron/main/renderer-origin.test.ts +++ b/packages/electron-app/electron/main/renderer-origin.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict" import test from "node:test" -import { resolveConfiguredRendererOrigins } from "./renderer-origin" +import { isAllowedMainWindowDocument, isAllowedRendererDocument, resolveConfiguredRendererOrigins } from "./renderer-origin" test("packaged renderer origins exclude development server environment URLs", () => { assert.deepEqual( @@ -23,3 +23,13 @@ test("development renderer origins include configured development servers", () = ["http://127.0.0.1:43123", "http://localhost:3000"], ) }) + +test("main-window navigation allows shell reloads and denies same-origin preview documents", () => { + const origins = ["http://127.0.0.1:43123"] + assert.equal(isAllowedMainWindowDocument("http://127.0.0.1:43123/?launch=desktop#state", origins), true) + assert.equal(isAllowedMainWindowDocument("http://127.0.0.1:43123/login", origins), true) + assert.equal(isAllowedMainWindowDocument("http://127.0.0.1:43123/previews/token", origins), false) + assert.equal(isAllowedMainWindowDocument("http://127.0.0.1:43123/workspaces/owned", origins), false) + assert.equal(isAllowedMainWindowDocument("http://127.0.0.1:43124/", origins), false) + assert.equal(isAllowedRendererDocument("http://127.0.0.1:43123/login", origins), false) +}) diff --git a/packages/electron-app/electron/main/renderer-origin.ts b/packages/electron-app/electron/main/renderer-origin.ts index 40f501b12..1e30626d8 100644 --- a/packages/electron-app/electron/main/renderer-origin.ts +++ b/packages/electron-app/electron/main/renderer-origin.ts @@ -7,6 +7,24 @@ export function isAllowedRendererOrigin(origin: string | undefined | null, allow } } +export function isAllowedRendererDocument(url: string | undefined | null, allowedOrigins: string[]): boolean { + if (!isAllowedRendererOrigin(url, allowedOrigins)) return false + try { + return new URL(url!).pathname === "/" + } catch { + return false + } +} + +export function isAllowedMainWindowDocument(url: string | undefined | null, allowedOrigins: string[]): boolean { + if (!isAllowedRendererOrigin(url, allowedOrigins)) return false + try { + return ["/", "/login", "/auth/token"].includes(new URL(url!).pathname) + } catch { + return false + } +} + export function resolveConfiguredRendererOrigins( currentCliUrl: string | null, isPackaged: boolean, diff --git a/packages/electron-app/electron/main/worktree-directory.test.ts b/packages/electron-app/electron/main/worktree-directory.test.ts index a7fcf7c16..8d71927e6 100644 --- a/packages/electron-app/electron/main/worktree-directory.test.ts +++ b/packages/electron-app/electron/main/worktree-directory.test.ts @@ -185,12 +185,14 @@ test("rechecks the original main frame and backend URL after async lookup", asyn }) test("requires the managed main frame", () => { - const frame = { url: "http://127.0.0.1:43123/app" } + const frame = { url: "http://127.0.0.1:43123/?launch=desktop" } const webContents = { mainFrame: frame, getURL: () => frame.url } const window = { isDestroyed: () => false, webContents } assert.equal(isManagedMainFrame({ sender: webContents, senderFrame: frame }, window, "http://127.0.0.1:43123"), true) assert.equal(isManagedMainFrame({ sender: webContents, senderFrame: { url: frame.url } }, window, "http://127.0.0.1:43123"), false) assert.equal(isManagedMainFrame({ sender: webContents, senderFrame: frame }, window, "http://127.0.0.1:9999"), false) + frame.url = "http://127.0.0.1:43123/previews/token" + assert.equal(isManagedMainFrame({ sender: webContents, senderFrame: frame }, window, "http://127.0.0.1:43123"), false) }) test("a held Windows directory identity rejects path replacement", { skip: process.platform !== "win32" }, async () => { diff --git a/packages/electron-app/electron/main/worktree-directory.ts b/packages/electron-app/electron/main/worktree-directory.ts index cecfef52d..a0a2b7cb8 100644 --- a/packages/electron-app/electron/main/worktree-directory.ts +++ b/packages/electron-app/electron/main/worktree-directory.ts @@ -102,8 +102,10 @@ export function isManagedMainFrame(event: RendererEvent, window: RendererWindow, if (!event.senderFrame || window.isDestroyed() || event.sender !== window.webContents || event.senderFrame !== window.webContents.mainFrame) return false try { const managedOrigin = managedEndpoint(baseUrl, "authority-check").origin - return new URL(window.webContents.getURL()).origin === managedOrigin - && new URL(event.senderFrame.url).origin === managedOrigin + const current = new URL(window.webContents.getURL()) + const sender = new URL(event.senderFrame.url) + return current.origin === managedOrigin && current.pathname === "/" + && sender.origin === managedOrigin && sender.pathname === "/" } catch { return false } diff --git a/packages/server/src/permissions/opencode-replier.ts b/packages/server/src/permissions/opencode-replier.ts index 3693a7073..f83572bab 100644 --- a/packages/server/src/permissions/opencode-replier.ts +++ b/packages/server/src/permissions/opencode-replier.ts @@ -71,17 +71,7 @@ export function createOpencodePermissionReplier( const { data: workspaces = [] } = await client.experimental.workspace.list(scope, { throwOnError: true }) location = resolveNativeSessionLocation(nativeRoot, workspaces, matches[0]) } finally { - let failure: unknown - for (let attempt = 0; attempt < 3; attempt += 1) { - try { - await repository.release() - failure = undefined - break - } catch (error) { - failure = error - } - } - if (failure) throw failure + await repository.release() } await client.permission.reply( { diff --git a/packages/server/src/server/instance-mutation-gate.ts b/packages/server/src/server/instance-mutation-gate.ts index 1e473f688..b2f542834 100644 --- a/packages/server/src/server/instance-mutation-gate.ts +++ b/packages/server/src/server/instance-mutation-gate.ts @@ -42,12 +42,13 @@ export async function enterWorkspaceMutationAdmission( if (!workspaceFolder) throw new Error(`Workspace ${instanceId} has no repository admission path`) const repository = await acquireRepositoryMutation({ workspaceFolder, gate, signal }) if (await resolve() !== workspaceFolder) { + releaseInstance() await repository.release() throw new Error(`Workspace ${instanceId} changed while mutation admission was queued`) } return async () => { - await repository.release() releaseInstance() + await repository.release() } } catch (error) { releaseInstance() diff --git a/packages/server/src/server/instance-mutation-proxy.test.ts b/packages/server/src/server/instance-mutation-proxy.test.ts index cd3dcb12e..4a525e465 100644 --- a/packages/server/src/server/instance-mutation-proxy.test.ts +++ b/packages/server/src/server/instance-mutation-proxy.test.ts @@ -312,7 +312,7 @@ describe("admitWorkspaceMutation", () => { assert.equal(exclusiveStarted, true) }) - it("releases instance admission when repository cleanup fails and permits cleanup retry", async () => { + it("releases instance admission while repository cleanup keeps retrying", async () => { let repositoryReleaseAttempts = 0 const gate = { enter: async (key: string) => key === "instance" @@ -320,7 +320,7 @@ describe("admitWorkspaceMutation", () => { : () => {}, acquireExclusive: async () => async () => { repositoryReleaseAttempts += 1 - if (repositoryReleaseAttempts === 1) throw new Error("transient repository release failure") + if (repositoryReleaseAttempts <= 4) throw new Error("transient repository release failure") }, } let instanceReleased = false @@ -333,10 +333,11 @@ describe("admitWorkspaceMutation", () => { loadWorkspaces: async () => [], }) - await assert.rejects(admitted.release(), /transient repository release failure/) + const releasing = admitted.release() + await Promise.resolve() assert.equal(instanceReleased, true) - await admitted.release() - assert.ok(repositoryReleaseAttempts >= 2) + await releasing + assert.ok(repositoryReleaseAttempts >= 5) }) it("retries repository cleanup when admission discovery fails", async () => { diff --git a/packages/server/src/server/instance-mutation-proxy.ts b/packages/server/src/server/instance-mutation-proxy.ts index 113a794db..5cc87d01e 100644 --- a/packages/server/src/server/instance-mutation-proxy.ts +++ b/packages/server/src/server/instance-mutation-proxy.ts @@ -109,19 +109,13 @@ export async function admitWorkspaceMutation(params: { ...current, ...location, release: async () => { - try { - await releaseRepository?.() - } finally { - releaseInstance() - } + releaseInstance() + await releaseRepository?.() }, } } catch (error) { - try { - if (releaseRepository) await retryRelease(releaseRepository) - } finally { - releaseInstance() - } + releaseInstance() + if (releaseRepository) await releaseRepository() throw error } } diff --git a/packages/server/src/workspaces/__tests__/workspace-identity.test.ts b/packages/server/src/workspaces/__tests__/workspace-identity.test.ts index df93b24aa..73251a685 100644 --- a/packages/server/src/workspaces/__tests__/workspace-identity.test.ts +++ b/packages/server/src/workspaces/__tests__/workspace-identity.test.ts @@ -19,6 +19,7 @@ import { sharedRelativePathIdentity, workspaceIdentityPathsEqual, } from "../workspace-identity" +import { parseWslWindowsDriveMounts } from "../wsl-windows-drive" const temporaryDirectories: string[] = [] const runtimeResult = (pid = 123) => ({ @@ -129,27 +130,34 @@ describe("workspace identity", () => { }) it("normalizes WSL distro identity without folding Linux path case", () => { + const sources = { resolveWslPath: () => undefined } assert.equal(workspaceIdentityPathsEqual( String.raw`\\wsl.localhost\Ubuntu\home\dev\Repo`, String.raw`\\wsl$\ubuntu\home\dev\Repo\.`, "win32", + sources, ), true) assert.equal(workspaceIdentityPathsEqual( String.raw`\\wsl.localhost\Ubuntu\home\dev\Repo`, String.raw`\\wsl$\ubuntu\home\dev\repo`, "win32", + sources, ), false) assert.equal(nativeWorkspacePathsEqual("/home/dev/Repo/feature/..", "/home/dev/Repo"), true) assert.equal(nativeWorkspacePathsEqual("/home/dev/Repo", "/home/dev/repo"), false) assert.equal(canonicalFilesystemIdentity( String.raw`\\?\UNC\wsl.localhost\Ubuntu\home\dev\Repo`, "win32", + undefined, + sources, ), "wsl:ubuntu:/home/dev/Repo") assert.equal( sharedRelativePathIdentity( String.raw`\\wsl.localhost\Ubuntu\home\dev\Projects\MissingRepo`, String.raw`\\wsl.localhost\Ubuntu\home\dev`, "win32", + undefined, + sources, ), sharedRelativePathIdentity("/home/dev/Projects/MissingRepo", "/home/dev", "linux"), ) @@ -157,18 +165,24 @@ describe("workspace identity", () => { it("shares Windows-drive identities with WSL drive mounts regardless of case", () => { const wslRelease = "5.15.153.1-microsoft-standard-WSL2" + const mounts = parseWslWindowsDriveMounts(String.raw`36 25 0:32 / /mnt/c rw - 9p C:\134 rw`) + const sources = { + mounts, + resolveWslPath: (_distribution: string, linuxPath: string) => + `C:\\${linuxPath.replace(/^\/mnt\/c\/?/i, "").replace(/\//g, "\\")}`, + } const windowsIdentity = canonicalFilesystemIdentity(String.raw`C:\Projects\CodeNomad`, "win32") assert.equal( windowsIdentity, - canonicalFilesystemIdentity("/mnt/c/Projects/CodeNomad", "linux", wslRelease), + canonicalFilesystemIdentity("/mnt/c/Projects/CodeNomad", "linux", wslRelease, sources), ) assert.equal( windowsIdentity, - canonicalFilesystemIdentity(String.raw`\\wsl.localhost\Ubuntu\mnt\c\Projects\CodeNomad`, "win32"), + canonicalFilesystemIdentity(String.raw`\\wsl.localhost\Ubuntu\mnt\c\Projects\CodeNomad`, "win32", undefined, sources), ) assert.equal( sharedRelativePathIdentity(String.raw`C:\Projects\CodeNomad`, String.raw`C:\Projects`, "win32"), - sharedRelativePathIdentity("/mnt/c/Projects/CodeNomad", "/mnt/c/Projects", "linux", wslRelease), + sharedRelativePathIdentity("/mnt/c/Projects/CodeNomad", "/mnt/c/Projects", "linux", wslRelease, sources), ) assert.equal( sharedRelativePathIdentity(String.raw`C:\Projects\CodeNomad`, String.raw`C:\Projects`, "win32"), @@ -176,6 +190,8 @@ describe("workspace identity", () => { String.raw`\\wsl.localhost\Ubuntu\mnt\c\Projects\CodeNomad`, String.raw`\\wsl.localhost\Ubuntu\mnt\c\Projects`, "win32", + undefined, + sources, ), ) assert.notEqual( @@ -184,6 +200,29 @@ describe("workspace identity", () => { ) }) + it("uses WSL mount metadata for custom automount roots", () => { + const release = "5.15.153.1-microsoft-standard-WSL2" + const mounts = parseWslWindowsDriveMounts(String.raw`36 25 0:32 / /windows/c rw - 9p C:\134 rw`) + const sources = { + mounts, + resolveWslPath: (_distribution: string, linuxPath: string) => + `C:\\${linuxPath.replace(/^\/windows\/c\/?/i, "").replace(/\//g, "\\")}`, + } + assert.deepEqual(mounts, [{ mountPoint: "/windows/c", drive: "c" }]) + assert.equal( + canonicalFilesystemIdentity("/windows/c/Projects/CodeNomad", "linux", release, sources), + canonicalFilesystemIdentity(String.raw`C:\Projects\CodeNomad`, "win32"), + ) + assert.equal( + canonicalFilesystemIdentity(String.raw`\\wsl.localhost\Custom\windows\c\Projects\CodeNomad`, "win32", undefined, sources), + canonicalFilesystemIdentity(String.raw`C:\Projects\CodeNomad`, "win32"), + ) + assert.notEqual( + canonicalFilesystemIdentity("/mnt/c/Projects/CodeNomad", "linux", release, sources), + canonicalFilesystemIdentity(String.raw`C:\Projects\CodeNomad`, "win32"), + ) + }) + it("canonicalizes aliases and falls back to an absolute identity for missing paths", async () => { const { root, target, link } = await createLinkedWorkspace() const [targetResult, linkResult, missing] = await Promise.all([ diff --git a/packages/server/src/workspaces/process-identity.test.ts b/packages/server/src/workspaces/process-identity.test.ts index 0356eba75..757ebfa81 100644 --- a/packages/server/src/workspaces/process-identity.test.ts +++ b/packages/server/src/workspaces/process-identity.test.ts @@ -5,6 +5,7 @@ import { readFileSync } from "node:fs" import { describe, it } from "node:test" import { + createManagerHostIdentity, managerHostIdentity, managerProcessIdentity, probePosixProcesses, probeWindowsProcesses, probeWslProcesses, sameProcess, @@ -139,6 +140,19 @@ describe("process identity probes", () => { assert.equal(sameProcess(managerProcessIdentity, { ...managerProcessIdentity, startTime: `${managerProcessIdentity.startTime}-reused` }), false) }) + it("scopes manager hosts by WSL distribution and PID namespace", () => { + const identityFor = (wslDistribution: string, pidNamespace: string) => createManagerHostIdentity({ + platform: "linux", + machineIdentity: "shared-machine", + hostname: "host", + wslDistribution, + pidNamespace, + }) + assert.equal(identityFor("Ubuntu", "pid:[1]"), identityFor("ubuntu", "pid:[1]")) + assert.notEqual(identityFor("Ubuntu", "pid:[1]"), identityFor("Debian", "pid:[1]")) + assert.notEqual(identityFor("Ubuntu", "pid:[1]"), identityFor("Ubuntu", "pid:[2]")) + }) + it("returns a POSIX mismatch without a second signal command", () => { const call = {} as Call const guarded = signalPosixProcesses(spawn("CODENOMAD_RESULT|0||0\n", call), { leader: identity(), groupId: 42, members: [identity()], signal: "SIGTERM" }, 25, "linux") diff --git a/packages/server/src/workspaces/process-identity.ts b/packages/server/src/workspaces/process-identity.ts index dee758169..68c45f4ce 100644 --- a/packages/server/src/workspaces/process-identity.ts +++ b/packages/server/src/workspaces/process-identity.ts @@ -1,6 +1,6 @@ import { spawnSync, type SpawnSyncReturns } from "node:child_process" import { createHash } from "node:crypto" -import { readFileSync } from "node:fs" +import { readFileSync, readlinkSync } from "node:fs" import os from "node:os" function commandMachineIdentity(command: string, args: string[], pattern: RegExp): string | undefined { @@ -42,9 +42,37 @@ function safeHostname(): string { } } -export const managerHostIdentity = createHash("sha256") - .update(`${process.platform}\0${stableMachineIdentity() ?? `hostname:${safeHostname()}`}`) - .digest("hex") +export function createManagerHostIdentity(sources: { + platform: NodeJS.Platform + machineIdentity?: string + hostname: string + wslDistribution?: string + pidNamespace?: string +}): string { + return createHash("sha256").update([ + sources.platform, + sources.machineIdentity ?? `hostname:${sources.hostname.trim().toLowerCase() || "unknown-host"}`, + sources.wslDistribution?.trim().toLowerCase() ?? "", + sources.pidNamespace?.trim() ?? "", + ].join("\0")).digest("hex") +} + +function linuxPidNamespace(): string | undefined { + if (process.platform !== "linux") return undefined + try { + return readlinkSync("/proc/self/ns/pid") + } catch { + return undefined + } +} + +export const managerHostIdentity = createManagerHostIdentity({ + platform: process.platform, + machineIdentity: stableMachineIdentity(), + hostname: safeHostname(), + wslDistribution: process.platform === "linux" ? process.env.WSL_DISTRO_NAME : undefined, + pidNamespace: linuxPidNamespace(), +}) export interface ProcessIdentity { hostId?: string diff --git a/packages/server/src/workspaces/repository-authority-path.ts b/packages/server/src/workspaces/repository-authority-path.ts index 34eef1403..3ae5d4af2 100644 --- a/packages/server/src/workspaces/repository-authority-path.ts +++ b/packages/server/src/workspaces/repository-authority-path.ts @@ -1,14 +1,14 @@ import { createHash } from "node:crypto" import { access, constants, lstat, realpath } from "node:fs/promises" import path from "node:path" -import { sharedRelativePathIdentity } from "./workspace-identity" +import { canonicalFilesystemPath, sharedRelativePathIdentity } from "./workspace-identity" export async function adjacentRepositoryAuthorityPath( workspaceFolder: string, category: string, suffix = "", ): Promise { - const workspacePath = path.resolve(workspaceFolder) + const workspacePath = await canonicalFilesystemPath(workspaceFolder) const workspaceParent = path.dirname(workspacePath) let candidate = workspaceParent const missingSegments: string[] = [] diff --git a/packages/server/src/workspaces/repository-lock-ownership.ts b/packages/server/src/workspaces/repository-lock-ownership.ts index 828525594..a07853aac 100644 --- a/packages/server/src/workspaces/repository-lock-ownership.ts +++ b/packages/server/src/workspaces/repository-lock-ownership.ts @@ -12,6 +12,7 @@ import { type ProcessIdentity, type ProcessSnapshot, } from "./process-identity" +import { currentWslWindowsDriveMounts, windowsDrivePathIdentity, type WslWindowsDriveMount } from "./wsl-windows-drive" const POLL_MS = 40 const HEARTBEAT_MS = 1_000 @@ -74,10 +75,11 @@ export function privateLockModeIsAcceptable( platform: NodeJS.Platform = process.platform, release = os.release(), filesystemType?: number | bigint, + mounts: readonly WslWindowsDriveMount[] = currentWslWindowsDriveMounts(platform, release), ): boolean { if (platform === "win32" || (mode & 0o777) === 0o700) return true const wslWindowsDrive = platform === "linux" && /microsoft/i.test(release) - && /^\/mnt\/[a-z](?:\/|$)/i.test(path.posix.resolve(directory)) + && Boolean(windowsDrivePathIdentity(path.posix.resolve(directory), platform, release, mounts)) && filesystemType !== undefined && WSL_WINDOWS_FILESYSTEM_TYPES.has(Number(filesystemType)) return wslWindowsDrive && (mode & 0o700) === 0o700 } @@ -234,6 +236,24 @@ export async function retireCurrentOwnershipClaimWithRetry( throw failure } +export async function retireOwnedClaimUntilSuccessful( + claimPath: string, + token: string, + stopHeartbeat: () => Promise, + retire: (path: string) => Promise = retireCurrentOwnershipClaim, +): Promise { + while (true) { + await stopHeartbeat() + try { + await retireCurrentOwnershipClaimWithRetry(claimPath, retire) + return + } catch { + stopHeartbeat = maintainOwnershipHeartbeat(claimPath, token) + await delay(POLL_MS) + } + } +} + async function wait(signal?: AbortSignal): Promise { try { await delay(POLL_MS, undefined, { signal }) @@ -297,21 +317,13 @@ export async function acquireOwnershipQueue( await wait(signal) } - let released = false + let releasePending: Promise | undefined return async () => { - if (released) return - await stopHeartbeat?.() - try { - await retireCurrentOwnershipClaimWithRetry(claimPath) - released = true - } catch (error) { - stopHeartbeat = maintainOwnershipHeartbeat(claimPath, token) - throw error - } + releasePending ??= retireOwnedClaimUntilSuccessful(claimPath, token, stopHeartbeat!) + await releasePending } } catch (error) { - await stopHeartbeat?.() - await retireCurrentOwnershipClaimWithRetry(claimPath, retireCurrent) + if (stopHeartbeat) await retireOwnedClaimUntilSuccessful(claimPath, token, stopHeartbeat, retireCurrent) await rm(preparationPath, { recursive: true, force: true }) throw error } diff --git a/packages/server/src/workspaces/repository-mutation-lock.test.ts b/packages/server/src/workspaces/repository-mutation-lock.test.ts index 18adcc35a..40c2b37b4 100644 --- a/packages/server/src/workspaces/repository-mutation-lock.test.ts +++ b/packages/server/src/workspaces/repository-mutation-lock.test.ts @@ -130,6 +130,23 @@ describe("repository mutation lock", () => { assert.deepEqual((await readFile(marker, "utf8")).trim().split(/\r?\n/), ["enter:0", "exit:0"]) }) + it("shares non-Git admission through a direct directory alias", async () => { + const directory = await plainDirectory() + const target = path.join(directory, "target") + const alias = path.join(directory, "alias") + const marker = path.join(directory, "marker.txt") + await mkdir(target) + await symlink(target, alias, process.platform === "win32" ? "junction" : "dir") + await writeFile(marker, "") + const admission = await acquireRepositoryMutation({ workspaceFolder: alias }) + const waiting = child(["hold", target, "0", marker]) + await new Promise((resolve) => setTimeout(resolve, 150)) + assert.equal(await readFile(marker, "utf8"), "") + await admission.release() + await waiting + assert.deepEqual((await readFile(marker, "utf8")).trim().split(/\r?\n/), ["enter:0", "exit:0"]) + }) + it("retains the adjacent destination lock after Git appears", async () => { const directory = await plainDirectory() const destination = path.join(directory, "workspace") @@ -256,7 +273,7 @@ describe("repository mutation lock", () => { let retireAttempts = 0 const waiting = acquireOwnershipQueue(lockPath, controller.signal, async (claimPath) => { retireAttempts += 1 - if (retireAttempts === 1) throw new Error("transient retirement failure") + if (retireAttempts <= 4) throw new Error("transient retirement failure") return retireCurrentOwnershipClaim(claimPath) }) const claimsPath = path.join(lockPath, "claims") @@ -266,12 +283,12 @@ describe("repository mutation lock", () => { controller.abort(reason) await assert.rejects(waiting, (error) => error === reason) - assert.equal(retireAttempts, 2) + assert.equal(retireAttempts, 5) await release() assert.deepEqual((await readdir(claimsPath)).filter((entry) => !entry.startsWith(".")), []) }) - it("keeps a failed ownership release retryable and restarts its heartbeat", async () => { + it("keeps retrying ownership release until the claim is retired", async () => { const directory = await plainDirectory() const lockPath = path.join(directory, "release.lock") const release = await acquireOwnershipQueue(lockPath) @@ -283,37 +300,32 @@ describe("repository mutation lock", () => { await rm(heartbeatPath) await mkdir(heartbeatPath) - await assert.rejects(release) + let settled = false + const releasing = release().then(() => { settled = true }) + await new Promise((resolve) => setTimeout(resolve, 150)) + assert.equal(settled, false) await rm(heartbeatPath, { recursive: true }) await writeFile(heartbeatPath, JSON.stringify({ token: claim, hostId: managerProcessIdentity.hostId, updatedAt: original.updatedAt, })) - const deadline = Date.now() + 3_000 - let updatedAt = original.updatedAt - while (updatedAt === original.updatedAt && Date.now() < deadline) { - await new Promise((resolve) => setTimeout(resolve, 50)) - updatedAt = (JSON.parse(await readFile(heartbeatPath, "utf8")) as { updatedAt: number }).updatedAt - } - assert.ok(updatedAt > original.updatedAt) - await release() + await releasing assert.deepEqual((await readdir(claimsPath)).filter((entry) => !entry.startsWith(".")), []) }) - it("retries repository admission cleanup after a release failure", async () => { + it("does not settle repository admission release before cleanup succeeds", async () => { const directory = await plainDirectory() let releaseAttempts = 0 const gate = { acquireExclusive: async () => async () => { releaseAttempts += 1 - if (releaseAttempts === 1) throw new Error("transient release failure") + if (releaseAttempts <= 4) throw new Error("transient release failure") }, } const admission = await acquireRepositoryMutation({ workspaceFolder: directory, gate }) - await assert.rejects(admission.release(), /transient release failure/) await admission.release() - assert.equal(releaseAttempts, 2) + assert.equal(releaseAttempts, 5) }) it("retries a transient release while cleaning up failed admission", async () => { @@ -369,20 +381,21 @@ describe("repository mutation lock", () => { it("accepts WSL Windows-drive mode projection without relaxing normal POSIX roots", () => { const wslRelease = "5.15.153.1-microsoft-standard-WSL2" + const mounts = [{ mountPoint: "/windows/c", drive: "c" }] assert.equal(privateLockModeIsAcceptable( - "/mnt/c/Repo/.codenomad", 0o40777, "linux", wslRelease, 0x01021997, + "/windows/c/Repo/.codenomad", 0o40777, "linux", wslRelease, 0x01021997, mounts, ), true) assert.equal(privateLockModeIsAcceptable( - "/mnt/c/Repo/.codenomad", 0o40755, "linux", wslRelease, 0x53464846, + "/windows/c/Repo/.codenomad", 0o40755, "linux", wslRelease, 0x53464846, mounts, ), true) assert.equal(privateLockModeIsAcceptable( - "/mnt/c/Repo/.codenomad", 0o40755, "linux", wslRelease, 0xef53, + "/windows/c/Repo/.codenomad", 0o40755, "linux", wslRelease, 0xef53, mounts, ), false) assert.equal(privateLockModeIsAcceptable( "/home/dev/.codenomad", 0o40755, "linux", wslRelease, 0x01021997, ), false) assert.equal(privateLockModeIsAcceptable( - "/mnt/c/Repo/.codenomad", 0o40755, "linux", "6.8.0-linux", 0x01021997, + "/windows/c/Repo/.codenomad", 0o40755, "linux", "6.8.0-linux", 0x01021997, mounts, ), false) assert.equal(privateLockModeIsAcceptable("/home/dev/.codenomad", 0o40700, "linux", "6.8.0-linux"), true) }) diff --git a/packages/server/src/workspaces/repository-mutation-lock.ts b/packages/server/src/workspaces/repository-mutation-lock.ts index c952b3a4c..d01c36ed1 100644 --- a/packages/server/src/workspaces/repository-mutation-lock.ts +++ b/packages/server/src/workspaces/repository-mutation-lock.ts @@ -1,6 +1,7 @@ import { mkdir } from "node:fs/promises" import path from "node:path" import { AsyncLocalStorage } from "node:async_hooks" +import { setTimeout as delay } from "node:timers/promises" import type { InstanceMutationGate } from "../server/instance-mutation-gate" import { acquireOwnershipQueue, ensurePrivateLockRoot } from "./repository-lock-ownership" import { adjacentRepositoryAuthorityPath } from "./repository-authority-path" @@ -35,25 +36,14 @@ async function acquireFileLock(lockPath: string, signal?: AbortSignal): Promise< async function releaseAll(releases: Array<() => void | Promise>): Promise { while (releases.length > 0) { - await releases[releases.length - 1]!() - releases.pop() - } -} - -async function cleanupAll(releases: Array<() => void | Promise>): Promise { - let failures: unknown[] = [] - for (let attempt = 0; attempt < 2 && releases.length > 0; attempt += 1) { - failures = [] for (let index = releases.length - 1; index >= 0; index -= 1) { try { await releases[index]!() releases.splice(index, 1) - } catch (error) { - failures.push(error) - } + } catch {} } + if (releases.length > 0) await delay(40) } - if (failures.length > 0) throw new AggregateError(failures, "Failed to clean up repository mutation admission") } export async function acquireRepositoryMutation(params: { @@ -96,20 +86,19 @@ export async function acquireRepositoryMutation(params: { } const confirmed = await resolveRepositoryIdentity(params.workspaceFolder) if (confirmed.mutationKey === identity.mutationKey) { - let released = false + let releasePending: Promise | undefined return { repositoryKey: confirmed.mutationKey, lockPaths: acquiredPaths, release: async () => { - if (released) return - await releaseAll(releases) - released = true + releasePending ??= releaseAll(releases) + await releasePending }, } } } } catch (error) { - await cleanupAll(releases) + await releaseAll(releases) throw error } } diff --git a/packages/server/src/workspaces/workspace-identity.ts b/packages/server/src/workspaces/workspace-identity.ts index 2194fea48..7de9849ab 100644 --- a/packages/server/src/workspaces/workspace-identity.ts +++ b/packages/server/src/workspaces/workspace-identity.ts @@ -3,6 +3,11 @@ import { realpathSync } from "node:fs" import os from "node:os" import path from "node:path" import { queryGitRepositoryPaths } from "./git-output" +import { + resolveWslWindowsPath, + windowsDrivePathIdentity, + type WslWindowsDriveMount, +} from "./wsl-windows-drive" const WSL_UNC_PATH_REGEX = /^\\\\wsl(?:\.localhost|\$)\\([^\\/]+)(?:[\\/](.*))?$/i @@ -10,22 +15,28 @@ function withoutWindowsExtendedPrefix(value: string): string { return value.replace(/^\\\\\?\\UNC[\\/]/i, "\\\\").replace(/^\\\\\?\\/, "") } -function wslUncIdentity(value: string): string | null { +interface WorkspaceIdentitySources { + mounts?: readonly WslWindowsDriveMount[] + resolveWslPath?: (distribution: string, linuxPath: string) => string | undefined +} + +function wslUncIdentity(value: string, sources: WorkspaceIdentitySources = {}): string | null { const match = withoutWindowsExtendedPrefix(value.trim()).replace(/\//g, "\\").match(WSL_UNC_PATH_REGEX) if (!match) return null const linuxPath = `/${(match[2] ?? "").split(/\\+/).filter(Boolean).join("/")}` - return windowsDriveIdentity(linuxPath, "linux", "microsoft") + const windowsPath = (sources.resolveWslPath ?? resolveWslWindowsPath)(match[1]!, linuxPath) + return (windowsPath && windowsDriveIdentity(windowsPath, "win32", undefined, sources.mounts)) ?? `wsl:${match[1]!.toLowerCase()}:${path.posix.normalize(linuxPath)}` } -function windowsDriveIdentity(value: string, platform: NodeJS.Platform, release = os.release()): string | null { - if (platform !== "win32" && (platform !== "linux" || !/microsoft/i.test(release))) return null - const match = platform === "win32" - ? withoutWindowsExtendedPrefix(value).replace(/\\/g, "/").match(/^([a-z]):(?:\/(.*))?$/i) - : path.posix.normalize(value).match(/^\/mnt\/([a-z])(?:\/(.*))?$/i) - if (!match) return null - const suffix = (match[2] ?? "").split("/").filter(Boolean).join("/").toLowerCase() - return `windows-drive:${match[1]!.toLowerCase()}:/${suffix}` +function windowsDriveIdentity( + value: string, + platform: NodeJS.Platform, + release = os.release(), + mounts?: readonly WslWindowsDriveMount[], +): string | null { + return windowsDrivePathIdentity(platform === "win32" ? withoutWindowsExtendedPrefix(value) : value, + platform, release, mounts) } export function normalizeWorkspaceIdentityPath(value: string, platform: NodeJS.Platform = process.platform): string { @@ -39,13 +50,14 @@ export function canonicalFilesystemIdentity( value: string, platform: NodeJS.Platform = process.platform, release = os.release(), + sources: WorkspaceIdentitySources = {}, ): string { const input = platform === "win32" ? withoutWindowsExtendedPrefix(value) : value - const wslIdentity = platform === "win32" ? wslUncIdentity(input) : null + const wslIdentity = platform === "win32" ? wslUncIdentity(input, sources) : null if (wslIdentity) { if (platform === process.platform) { try { - return wslUncIdentity(realpathSync.native(path.win32.resolve(input))) ?? wslIdentity + return wslUncIdentity(realpathSync.native(path.win32.resolve(input)), sources) ?? wslIdentity } catch { // Keep a stable lexical identity for missing WSL paths. } @@ -55,7 +67,7 @@ export function canonicalFilesystemIdentity( const pathApi = platform === "win32" ? path.win32 : path.posix const absolutePath = pathApi.resolve(input) const canonicalPath = platform === process.platform ? canonicalFilesystemPathSync(absolutePath) : absolutePath - const driveIdentity = windowsDriveIdentity(canonicalPath, platform, release) + const driveIdentity = windowsDriveIdentity(canonicalPath, platform, release, sources.mounts) if (driveIdentity) return driveIdentity if (platform === process.platform) { return normalizeWorkspaceIdentityPath(canonicalPath, platform) @@ -68,26 +80,31 @@ export function sharedRelativePathIdentity( ancestor: string, platform: NodeJS.Platform = process.platform, release = os.release(), + sources: WorkspaceIdentitySources = {}, ): string { const pathApi = platform === "win32" ? path.win32 : path.posix const relative = pathApi.relative(pathApi.resolve(ancestor), pathApi.resolve(value)) const segments = relative.split(/[\\/]+/).filter(Boolean) if (segments.some((segment) => segment === "..")) throw new Error(`${value} is outside ${ancestor}`) const identity = segments.join("/") - const valueDrive = windowsDriveIdentity(pathApi.resolve(value), platform, release) - ?? (wslUncIdentity(value)?.startsWith("windows-drive:") ? wslUncIdentity(value) : null) - const ancestorDrive = windowsDriveIdentity(pathApi.resolve(ancestor), platform, release) - ?? (wslUncIdentity(ancestor)?.startsWith("windows-drive:") ? wslUncIdentity(ancestor) : null) + const valueWsl = wslUncIdentity(value, sources) + const ancestorWsl = wslUncIdentity(ancestor, sources) + const valueDrive = windowsDriveIdentity(pathApi.resolve(value), platform, release, sources.mounts) + ?? (valueWsl?.startsWith("windows-drive:") ? valueWsl : null) + const ancestorDrive = windowsDriveIdentity(pathApi.resolve(ancestor), platform, release, sources.mounts) + ?? (ancestorWsl?.startsWith("windows-drive:") ? ancestorWsl : null) const windowsDrive = valueDrive && ancestorDrive - return (platform === "win32" && !wslUncIdentity(value)) || windowsDrive ? identity.toLowerCase() : identity + return (platform === "win32" && !valueWsl) || windowsDrive ? identity.toLowerCase() : identity } export function workspaceIdentityPathsEqual( left: string, right: string, platform: NodeJS.Platform = process.platform, + sources: WorkspaceIdentitySources = {}, ): boolean { - return canonicalFilesystemIdentity(left, platform) === canonicalFilesystemIdentity(right, platform) + return canonicalFilesystemIdentity(left, platform, undefined, sources) + === canonicalFilesystemIdentity(right, platform, undefined, sources) } export function nativeWorkspacePathsEqual(left: string, right: string): boolean { diff --git a/packages/server/src/workspaces/workspace-lifetime-lease.test.ts b/packages/server/src/workspaces/workspace-lifetime-lease.test.ts index 6c6113960..224b363a5 100644 --- a/packages/server/src/workspaces/workspace-lifetime-lease.test.ts +++ b/packages/server/src/workspaces/workspace-lifetime-lease.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict" import { spawn, type ChildProcess } from "node:child_process" import { execFileSync } from "node:child_process" -import { access, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises" +import { access, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises" import os from "node:os" import path from "node:path" import { afterEach, describe, it } from "node:test" @@ -11,6 +11,7 @@ import { hasWorkspaceLifetimeBlocker, workspaceLifetimeAuthorityRoots, } from "./workspace-lifetime-lease" +import { retireCurrentOwnershipClaim } from "./repository-lock-ownership" const childScript = fileURLToPath(new URL("./workspace-lifetime-lease.child.ts", import.meta.url)) const temporaryDirectories: string[] = [] @@ -141,4 +142,28 @@ describe("workspace lifetime lease", () => { execFileSync("git", ["init", "--separate-git-dir", commonDirectory], { cwd: repository }) assert.equal(await hasWorkspaceLifetimeBlocker({ workspaceFolder: repository }), true) }) + + it("does not discard a partial publication after bounded retirement failures", async () => { + const directory = await temporaryDirectory() + execFileSync("git", ["init"], { cwd: directory }) + const authority = await workspaceLifetimeAuthorityRoots(directory) + const repositoryRoot = path.join(directory, ".git", "codenomad", "workspace-leases") + await mkdir(path.dirname(repositoryRoot), { recursive: true, mode: 0o700 }) + await writeFile(repositoryRoot, "block publication") + let attempts = 0 + let allowRetirement = false + let settled = false + const acquisition = acquireWorkspaceLifetimeLease(directory, "workspace", async (claimPath) => { + attempts += 1 + return allowRetirement ? retireCurrentOwnershipClaim(claimPath) : false + }) + acquisition.then(() => { settled = true }, () => { settled = true }) + while (attempts < 4) await new Promise((resolve) => setTimeout(resolve, 10)) + assert.equal(settled, false) + allowRetirement = true + await assert.rejects(acquisition) + assert.ok(attempts > 3) + const adjacentRoot = authority.roots.find((root) => root !== repositoryRoot)! + assert.deepEqual((await readdir(adjacentRoot)).filter((entry) => !entry.startsWith(".")), []) + }) }) diff --git a/packages/server/src/workspaces/workspace-lifetime-lease.ts b/packages/server/src/workspaces/workspace-lifetime-lease.ts index 67ef0a28a..0d3daff33 100644 --- a/packages/server/src/workspaces/workspace-lifetime-lease.ts +++ b/packages/server/src/workspaces/workspace-lifetime-lease.ts @@ -7,7 +7,8 @@ import { ensurePrivateLockRoot, maintainOwnershipHeartbeat, processIdentityIsAlive, - retireCurrentOwnershipClaimWithRetry, + retireCurrentOwnershipClaim, + retireOwnedClaimUntilSuccessful, retireOwnershipClaim, type OwnershipHeartbeat, } from "./repository-lock-ownership" @@ -123,6 +124,7 @@ async function activeLeases(root: string): Promise { export async function acquireWorkspaceLifetimeLease( workspaceFolder: string, workspaceId: string, + retireCurrent: (path: string) => Promise = retireCurrentOwnershipClaim, ): Promise { const authority = await workspaceLifetimeAuthorityRoots(workspaceFolder) const token = randomUUID() @@ -157,10 +159,8 @@ export async function acquireWorkspaceLifetimeLease( claims.push({ path: leasePath, stopHeartbeat: maintainOwnershipHeartbeat(leasePath, token) }) } } catch (error) { - await Promise.allSettled(claims.map(async (claim) => { - await claim.stopHeartbeat() - await retireCurrentOwnershipClaimWithRetry(claim.path) - })) + await Promise.all(claims.map((claim) => + retireOwnedClaimUntilSuccessful(claim.path, token, claim.stopHeartbeat, retireCurrent))) throw error } @@ -172,12 +172,10 @@ export async function acquireWorkspaceLifetimeLease( const failures: unknown[] = [] for (let index = claims.length - 1; index >= 0; index -= 1) { const claim = claims[index]! - await claim.stopHeartbeat() try { - await retireCurrentOwnershipClaimWithRetry(claim.path) + await retireOwnedClaimUntilSuccessful(claim.path, token, claim.stopHeartbeat, retireCurrent) claims.splice(index, 1) } catch (error) { - claim.stopHeartbeat = maintainOwnershipHeartbeat(claim.path, token) failures.push(error) } } diff --git a/packages/server/src/workspaces/wsl-windows-drive.ts b/packages/server/src/workspaces/wsl-windows-drive.ts new file mode 100644 index 000000000..c0f8be701 --- /dev/null +++ b/packages/server/src/workspaces/wsl-windows-drive.ts @@ -0,0 +1,74 @@ +import { spawnSync } from "node:child_process" +import { readFileSync } from "node:fs" +import os from "node:os" +import path from "node:path" + +export interface WslWindowsDriveMount { + mountPoint: string + drive: string +} + +const decodeMountField = (value: string): string => value.replace(/\\([0-7]{3})/g, + (_match, octal: string) => String.fromCharCode(Number.parseInt(octal, 8))) + +export function parseWslWindowsDriveMounts(mountInfo: string): WslWindowsDriveMount[] { + const mounts: WslWindowsDriveMount[] = [] + for (const line of mountInfo.split(/\r?\n/)) { + const fields = line.split(" ") + const separator = fields.indexOf("-") + if (separator < 0 || !fields[4] || !fields[separator + 2]) continue + const source = decodeMountField(fields[separator + 2]!) + const match = source.match(/^([a-z]):(?:[\\/]|$)/i) + if (!match) continue + mounts.push({ mountPoint: path.posix.normalize(decodeMountField(fields[4])), drive: match[1]!.toLowerCase() }) + } + return mounts.sort((left, right) => right.mountPoint.length - left.mountPoint.length) +} + +export function currentWslWindowsDriveMounts( + platform: NodeJS.Platform = process.platform, + release = os.release(), +): WslWindowsDriveMount[] { + if (platform !== "linux" || !/microsoft/i.test(release)) return [] + try { + return parseWslWindowsDriveMounts(readFileSync("/proc/self/mountinfo", "utf8")) + } catch { + return [] + } +} + +export function windowsDrivePathIdentity( + value: string, + platform: NodeJS.Platform, + release = os.release(), + mounts: readonly WslWindowsDriveMount[] = currentWslWindowsDriveMounts(platform, release), +): string | null { + if (platform === "win32") { + const match = value.replace(/\\/g, "/").match(/^([a-z]):(?:\/(.*))?$/i) + if (!match) return null + const suffix = (match[2] ?? "").split("/").filter(Boolean).join("/").toLowerCase() + return `windows-drive:${match[1]!.toLowerCase()}:/${suffix}` + } + if (platform !== "linux" || !/microsoft/i.test(release)) return null + const normalized = path.posix.normalize(value) + for (const mount of mounts) { + const relative = path.posix.relative(path.posix.resolve(mount.mountPoint), normalized) + if (relative === ".." || relative.startsWith("../") || path.posix.isAbsolute(relative)) continue + return `windows-drive:${mount.drive.toLowerCase()}:/${relative.split("/").filter(Boolean).join("/").toLowerCase()}` + } + return null +} + +export function resolveWslWindowsPath(distribution: string, linuxPath: string): string | undefined { + try { + const result = spawnSync("wsl.exe", ["--distribution", distribution, "--exec", "wslpath", "-w", linuxPath], { + encoding: "utf8", + timeout: 2_000, + windowsHide: true, + }) + if (result.status !== 0) return undefined + return String(result.stdout ?? "").trim() || undefined + } catch { + return undefined + } +} diff --git a/packages/tauri-app/src-tauri/src/client_state/access.rs b/packages/tauri-app/src-tauri/src/client_state/access.rs index 9c83096f7..e1372d796 100644 --- a/packages/tauri-app/src-tauri/src/client_state/access.rs +++ b/packages/tauri-app/src-tauri/src/client_state/access.rs @@ -9,22 +9,23 @@ pub(super) struct RendererAccess { #[derive(Default)] struct RendererAccessState { token: Option, - committed_origin: Option, - pending_origin: Option, + committed_document: Option, + pending_document: Option, generation: u64, } pub(super) struct PendingNavigation { - previous_origin: Option, - staged_origin: Option, + previous_document: Option, + staged_document: Option, } -fn origin_key(url: &Url) -> Result { - match (url.scheme(), url.host_str()) { - ("http" | "https", Some(_)) => Ok(url.origin().ascii_serialization()), - ("tauri" | "asset", Some(host)) => Ok(format!("{}://{}", url.scheme(), host)), - _ => Err("Client state renderer URL does not have a supported origin".to_string()), - } +fn document_key(url: &Url) -> Result { + let origin = match (url.scheme(), url.host_str()) { + ("http" | "https", Some(_)) => url.origin().ascii_serialization(), + ("tauri" | "asset", Some(host)) => format!("{}://{}", url.scheme(), host), + _ => return Err("Client state renderer URL does not have a supported origin".to_string()), + }; + Ok(format!("{origin}{}", url.path())) } impl RendererAccess { @@ -33,24 +34,24 @@ impl RendererAccess { return Err("Client state access token must not be empty".to_string()); } - let renderer_origin = origin_key(renderer_url)?; + let renderer_document = document_key(renderer_url)?; let mut state = self.state.lock().map_err(|err| err.to_string())?; if state.token.is_none() - || state.pending_origin.as_deref() == Some(renderer_origin.as_str()) + || state.pending_document.as_deref() == Some(renderer_document.as_str()) { state.token = Some(access_token.to_string()); - state.committed_origin = Some(renderer_origin); - state.pending_origin = None; + state.committed_document = Some(renderer_document); + state.pending_document = None; state.generation = state.generation.wrapping_add(1); return Ok(()); } if state.token.as_deref() != Some(access_token) { return Err("Client state access token does not match this renderer".to_string()); } - if state.committed_origin.as_deref() == Some(renderer_origin.as_str()) { + if state.committed_document.as_deref() == Some(renderer_document.as_str()) { Ok(()) } else { - Err("Client state renderer origin changed without access rotation".to_string()) + Err("Client state renderer document changed without access rotation".to_string()) } } @@ -59,16 +60,16 @@ impl RendererAccess { return Err("Client state access token must not be empty".to_string()); } - let renderer_origin = origin_key(renderer_url)?; + let renderer_document = document_key(renderer_url)?; let state = self.state.lock().map_err(|err| err.to_string())?; if state.token.as_deref() == Some(access_token) - && state.committed_origin.as_deref() == Some(renderer_origin.as_str()) + && state.committed_document.as_deref() == Some(renderer_document.as_str()) { return Ok(state.generation); } match state.token.as_deref() { Some(current) if current == access_token => { - Err("Client state renderer origin does not match this renderer".to_string()) + Err("Client state renderer document does not match this renderer".to_string()) } Some(_) => Err("Client state access token does not match this renderer".to_string()), None => Err("Client state access has not been claimed by this renderer".to_string()), @@ -82,11 +83,11 @@ impl RendererAccess { generation: u64, operation: impl FnOnce() -> Result, ) -> Result { - let renderer_origin = origin_key(renderer_url)?; + let renderer_document = document_key(renderer_url)?; let state = self.state.lock().map_err(|err| err.to_string())?; if state.generation != generation || state.token.as_deref() != Some(access_token) - || state.committed_origin.as_deref() != Some(renderer_origin.as_str()) + || state.committed_document.as_deref() != Some(renderer_document.as_str()) { return Err("Client state renderer authority changed before dispatch".to_string()); } @@ -100,15 +101,15 @@ impl RendererAccess { .unwrap_or(false) } - pub(super) fn allows_claim_origin(&self, renderer_url: &Url) -> bool { - let Ok(renderer_origin) = origin_key(renderer_url) else { + pub(super) fn allows_claim_document(&self, renderer_url: &Url) -> bool { + let Ok(renderer_document) = document_key(renderer_url) else { return false; }; self.state .lock() .map(|state| { - state.committed_origin.as_deref() == Some(renderer_origin.as_str()) - || state.pending_origin.as_deref() == Some(renderer_origin.as_str()) + state.committed_document.as_deref() == Some(renderer_document.as_str()) + || state.pending_document.as_deref() == Some(renderer_document.as_str()) }) .unwrap_or(false) } @@ -118,24 +119,24 @@ impl RendererAccess { target_url: Option<&Url>, ) -> Result { let mut state = self.state.lock().map_err(|err| err.to_string())?; - let previous_origin = state.pending_origin.clone(); - let staged_origin = match target_url { - Some(url) => Some(origin_key(url)?), - None => previous_origin + let previous_document = state.pending_document.clone(); + let staged_document = match target_url { + Some(url) => Some(document_key(url)?), + None => previous_document .clone() - .or_else(|| state.committed_origin.clone()), + .or_else(|| state.committed_document.clone()), }; - state.pending_origin = staged_origin.clone(); + state.pending_document = staged_document.clone(); Ok(PendingNavigation { - previous_origin, - staged_origin, + previous_document, + staged_document, }) } pub(super) fn cancel_navigation(&self, navigation: PendingNavigation) { let mut state = self.state.lock().unwrap_or_else(|err| err.into_inner()); - if state.pending_origin == navigation.staged_origin { - state.pending_origin = navigation.previous_origin; + if state.pending_document == navigation.staged_document { + state.pending_document = navigation.previous_document; } } diff --git a/packages/tauri-app/src-tauri/src/client_state/commands.rs b/packages/tauri-app/src-tauri/src/client_state/commands.rs index f22b64232..5f32cdb88 100644 --- a/packages/tauri-app/src-tauri/src/client_state/commands.rs +++ b/packages/tauri-app/src-tauri/src/client_state/commands.rs @@ -4,9 +4,9 @@ use serde_json::Value; use tauri::{AppHandle, State, WebviewWindow}; use url::Url; -fn same_origin(url: &Url, expected: &str) -> bool { +fn same_document(url: &Url, expected: &str) -> bool { Url::parse(expected) - .map(|expected| url.origin() == expected.origin()) + .map(|expected| url.origin() == expected.origin() && url.path() == expected.path()) .unwrap_or(false) } @@ -25,12 +25,11 @@ fn is_dev_renderer_origin(url: &Url) -> bool { && url.port() == Some(1420) } -pub(super) fn is_allowed_client_state_origin(url: &Url, managed_cli_url: Option<&str>) -> bool { +pub(super) fn is_allowed_client_state_document(url: &Url, managed_cli_url: Option<&str>) -> bool { managed_cli_url - .map(|expected| same_origin(url, expected)) + .map(|expected| same_document(url, expected)) .unwrap_or(false) - || is_app_renderer_origin(url) - || is_dev_renderer_origin(url) + || url.path() == "/" && (is_app_renderer_origin(url) || is_dev_renderer_origin(url)) } fn main_window_url(window: &WebviewWindow) -> Result { @@ -45,18 +44,19 @@ fn main_window_url(window: &WebviewWindow) -> Result { .map_err(|err| format!("failed to inspect current renderer URL: {err}")) } -fn validate_claim_origin( +fn validate_claim_document( current_url: &Url, app_state: &AppState, state: &ClientState, ) -> Result<(), String> { let status = app_state.manager.status(); - if state.renderer_access.allows_claim_origin(current_url) - || is_allowed_client_state_origin(current_url, status.url.as_deref()) + if current_url.path() == "/" + && (state.renderer_access.allows_claim_document(current_url) + || is_allowed_client_state_document(current_url, status.url.as_deref())) { Ok(()) } else { - Err("Client state commands are not available to the current renderer origin".to_string()) + Err("Client state commands are not available to the current renderer document".to_string()) } } @@ -77,7 +77,7 @@ pub fn client_state_claim_access( access_token: String, ) -> Result<(), String> { let current_url = main_window_url(&window)?; - validate_claim_origin(¤t_url, &app_state, &state)?; + validate_claim_document(¤t_url, &app_state, &state)?; state.claim_renderer_access(&access_token, ¤t_url) } diff --git a/packages/tauri-app/src-tauri/src/client_state/navigation.rs b/packages/tauri-app/src-tauri/src/client_state/navigation.rs index ef49dbc01..055e81180 100644 --- a/packages/tauri-app/src-tauri/src/client_state/navigation.rs +++ b/packages/tauri-app/src-tauri/src/client_state/navigation.rs @@ -304,7 +304,7 @@ mod tests { .renderer_access .validate("outgoing-renderer", &outgoing_url) .unwrap(); - assert!(state.renderer_access.allows_claim_origin(&outgoing_url)); + assert!(state.renderer_access.allows_claim_document(&outgoing_url)); state .renderer_access .claim("incoming-renderer", &incoming_url) diff --git a/packages/tauri-app/src-tauri/src/client_state/tests.rs b/packages/tauri-app/src-tauri/src/client_state/tests.rs index c821f0828..b80eb6b60 100644 --- a/packages/tauri-app/src-tauri/src/client_state/tests.rs +++ b/packages/tauri-app/src-tauri/src/client_state/tests.rs @@ -1,4 +1,4 @@ -use super::commands::is_allowed_client_state_origin; +use super::commands::is_allowed_client_state_document; use super::process::{PRIMARY_LOCK_FILENAME, RUNNING_MARKER_PREFIX, RUNNING_MARKER_SUFFIX}; use super::window::{ clamp_window_bounds, normalize_native_zoom_level, DisplayArea, NativeWindowState, WindowBounds, @@ -512,11 +512,14 @@ fn future_envelope_is_preserved_until_successful_clear() { fn renderer_tokens_and_origins_are_isolated_across_navigation() { let directory = tempfile::tempdir().unwrap(); let state = ClientState::initialize_at(directory.path()).unwrap(); - let outgoing = Url::parse("http://127.0.0.1:43123/workspace").unwrap(); - let incoming = Url::parse("http://127.0.0.1:43124/workspace").unwrap(); + let outgoing = Url::parse("http://127.0.0.1:43123/").unwrap(); + let incoming = Url::parse("http://127.0.0.1:43124/").unwrap(); assert!(state.renderer_access.claim("", &outgoing).is_err()); assert_access_rejected(&state, "missing", &outgoing); state.renderer_access.claim("outgoing", &outgoing).unwrap(); + let preview = Url::parse("http://127.0.0.1:43123/previews/token").unwrap(); + assert_access_rejected(&state, "outgoing", &preview); + assert!(state.renderer_access.claim("outgoing", &preview).is_err()); assert!(state.renderer_access.claim("other", &outgoing).is_err()); assert_access_rejected(&state, "outgoing", &incoming); state @@ -536,19 +539,23 @@ fn renderer_tokens_and_origins_are_isolated_across_navigation() { for (url, managed, allowed) in [ (&outgoing, Some("http://127.0.0.1:43123"), true), (&incoming, Some("http://127.0.0.1:43123"), false), - ( - &Url::parse("http://localhost:9000/workspace").unwrap(), - None, - false, - ), - ( - &Url::parse("https://tauri.localhost/loading.html").unwrap(), - None, - true, - ), + (&Url::parse("http://localhost:9000/").unwrap(), None, false), + (&Url::parse("https://tauri.localhost/").unwrap(), None, true), ] { - assert_eq!(is_allowed_client_state_origin(url, managed), allowed); + assert_eq!(is_allowed_client_state_document(url, managed), allowed); } + assert!(!is_allowed_client_state_document( + &preview, + Some("http://127.0.0.1:43123") + )); + assert!(!is_allowed_client_state_document( + &Url::parse("https://tauri.localhost/loading.html").unwrap(), + None + )); + assert!(is_allowed_client_state_document( + &Url::parse("http://127.0.0.1:43123/?launch=desktop").unwrap(), + Some("http://127.0.0.1:43123") + )); for url in ["file:///tmp/loading.html", "about:blank"] { assert!(state .renderer_access diff --git a/packages/tauri-app/src-tauri/src/main.rs b/packages/tauri-app/src-tauri/src/main.rs index bde629d60..ca67fa9e1 100644 --- a/packages/tauri-app/src-tauri/src/main.rs +++ b/packages/tauri-app/src-tauri/src/main.rs @@ -234,8 +234,44 @@ fn should_allow_window_origin( false } +fn is_allowed_main_window_document(url: &Url, managed_cli_url: Option<&str>) -> bool { + if let Some(managed) = managed_cli_url.and_then(|value| Url::parse(value).ok()) { + if url.origin() == managed.origin() && matches!(url.path(), "/" | "/login" | "/auth/token") + { + return true; + } + } + + match url.scheme() { + "tauri" | "asset" | "file" => url.path().ends_with("/loading.html"), + "about" => url.as_str() == "about:blank", + "http" | "https" if url.host_str() == Some("tauri.localhost") => { + url.path() == "/loading.html" + } + "http" if is_dev_mode() => { + matches!(url.host_str(), Some("127.0.0.1" | "localhost")) + && url.port() == Some(1420) + && url.path() == "/" + } + _ => false, + } +} + fn intercept_navigation(webview: &Webview, url: &Url) -> bool { let window_label = webview.label().to_string(); + if window_label == "main" { + if is_allowed_main_window_document(url, None) { + return true; + } + let app_handle = webview.app_handle(); + let status = app_handle.state::().manager.status(); + if is_allowed_main_window_document(url, status.url.as_deref()) { + return true; + } + if should_allow_window_origin(&app_handle, &window_label, url) { + return false; + } + } if should_allow_window_origin(&webview.app_handle(), &window_label, url) { return true; } @@ -1059,6 +1095,29 @@ fn build_about_metadata(version: &str, include_update_link: bool) -> AboutMetada } } +#[cfg(test)] +mod navigation_guard_tests { + use super::is_allowed_main_window_document; + use url::Url; + + #[test] + fn main_window_allows_shell_reload_and_denies_same_origin_preview_navigation() { + let managed = Some("http://127.0.0.1:43123"); + assert!(is_allowed_main_window_document( + &Url::parse("http://127.0.0.1:43123/?launch=desktop").unwrap(), + managed + )); + assert!(!is_allowed_main_window_document( + &Url::parse("http://127.0.0.1:43123/previews/token").unwrap(), + managed + )); + assert!(!is_allowed_main_window_document( + &Url::parse("http://127.0.0.1:43123/workspaces/owned").unwrap(), + managed + )); + } +} + #[cfg(test)] mod menu_tests { use super::{build_about_metadata, run_update_with_fallback, RELEASES_URL}; diff --git a/packages/tauri-app/src-tauri/src/worktree_directory.rs b/packages/tauri-app/src-tauri/src/worktree_directory.rs index 665a1ae50..8dfc5aa13 100644 --- a/packages/tauri-app/src-tauri/src/worktree_directory.rs +++ b/packages/tauri-app/src-tauri/src/worktree_directory.rs @@ -265,9 +265,13 @@ fn worktree_endpoint(base_url: &str, instance_id: &str) -> Result { Ok(url) } -fn is_managed_renderer_origin(renderer_url: &Url, base_url: &str) -> bool { +fn is_managed_renderer_document(renderer_url: &Url, base_url: &str) -> bool { Url::parse(base_url) - .map(|managed| is_loopback(&managed) && renderer_url.origin() == managed.origin()) + .map(|managed| { + is_loopback(&managed) + && renderer_url.origin() == managed.origin() + && renderer_url.path() == managed.path() + }) .unwrap_or(false) } @@ -445,7 +449,7 @@ fn validate_authority( .url() .map_err(|err| format!("failed to inspect current renderer URL: {err}"))?; let generation = state.validate_renderer_access(access_token, &renderer_url)?; - if !is_managed_renderer_origin(&renderer_url, base_url) { + if !is_managed_renderer_document(&renderer_url, base_url) { return Err("Directory opening is unavailable from this renderer".to_string()); } Ok(generation) @@ -527,7 +531,7 @@ pub(crate) async fn open_local_directory( .url() .map_err(|err| format!("failed to inspect current renderer URL: {err}"))?; if window.label() != "main" - || !is_managed_renderer_origin(&renderer_url, &config.base_url) + || !is_managed_renderer_document(&renderer_url, &config.base_url) { return Err("Directory opening is unavailable from this renderer".to_string()); } @@ -541,7 +545,8 @@ pub(crate) async fn open_local_directory( .url() .map_err(|err| format!("failed to inspect current renderer URL: {err}"))?; if final_renderer_url.origin() != renderer_url.origin() - || !is_managed_renderer_origin(&final_renderer_url, &config.base_url) + || final_renderer_url.path() != renderer_url.path() + || !is_managed_renderer_document(&final_renderer_url, &config.base_url) { return Err( "Directory opening is unavailable from this renderer".to_string() @@ -755,19 +760,23 @@ mod tests { } #[test] - fn accepts_only_dynamic_loopback_managed_origins() { - let renderer = Url::parse("http://127.0.0.1:43123/workspace").unwrap(); - assert!(is_managed_renderer_origin( + fn accepts_only_the_dynamic_loopback_shell_document() { + let renderer = Url::parse("http://127.0.0.1:43123/?launch=desktop").unwrap(); + assert!(is_managed_renderer_document( &renderer, "http://127.0.0.1:43123" )); - assert!(!is_managed_renderer_origin( + assert!(!is_managed_renderer_document( &renderer, "http://127.0.0.1:43124" )); - assert!(!is_managed_renderer_origin( + assert!(!is_managed_renderer_document( &renderer, "https://example.com" )); + assert!(!is_managed_renderer_document( + &Url::parse("http://127.0.0.1:43123/previews/token").unwrap(), + "http://127.0.0.1:43123" + )); } } diff --git a/packages/ui/src/components/session-preview-comment.ts b/packages/ui/src/components/session-preview-comment.ts index b0d666713..11846dca2 100644 --- a/packages/ui/src/components/session-preview-comment.ts +++ b/packages/ui/src/components/session-preview-comment.ts @@ -1,14 +1,23 @@ import type { BrowserFrameElementTarget } from "./browser-frame" +function normalizeMetadata(value: string | undefined, maxLength: number): string { + if (typeof value !== "string") return "" + return value.replace(/[\u0000-\u001f\u007f-\u009f`]+/g, " ").replace(/\s+/g, " ").trim().slice(0, maxLength) +} + export function buildPreviewCommentMarkdown(target: BrowserFrameElementTarget, comment: string): string { - const label = target.ariaLabel || target.text - const role = target.role ? ` role="${target.role}"` : "" - const element = label ? `${target.tagName}${role} "${label}"` : `${target.tagName}${role}` + const pagePath = normalizeMetadata(target.pagePath, 300) + const tagName = normalizeMetadata(target.tagName, 40) || "element" + const label = normalizeMetadata(target.ariaLabel, 160) || normalizeMetadata(target.text, 160) + const normalizedRole = normalizeMetadata(target.role, 80) + const selector = normalizeMetadata(target.selector, 300) + const role = normalizedRole ? ` role="${normalizedRole}"` : "" + const element = label ? `${tagName}${role} "${label}"` : `${tagName}${role}` const lines = [ "> Web preview comment", - `> Page: \`${target.pagePath}\``, + `> Page: \`${pagePath}\``, `> Element: \`${element}\``, ] - if (target.selector) lines.push(`> Selector: \`${target.selector}\``) + if (selector) lines.push(`> Selector: \`${selector}\``) return `${lines.join("\n")}\n\n${comment}\n\n` } diff --git a/packages/ui/src/components/session-preview-view.test.ts b/packages/ui/src/components/session-preview-view.test.ts index b106a091d..7335f343f 100644 --- a/packages/ui/src/components/session-preview-view.test.ts +++ b/packages/ui/src/components/session-preview-view.test.ts @@ -25,3 +25,48 @@ test("web preview comments retain the selected element context", () => { ].join("\n"), ) }) + +test("web preview comments contain hostile metadata inside bounded single-line code spans", () => { + const markdown = buildPreviewCommentMarkdown({ + pagePath: "/settings", + tagName: `${"t".repeat(50)}\`\n> injected tag`, + text: "fallback", + role: `${"r".repeat(90)}\`\r\n> injected role`, + ariaLabel: `${"l".repeat(170)}\`\n> injected label\u0000`, + selector: `${"s".repeat(310)}\`\n> injected selector`, + rect: { x: 0, y: 0, width: 0, height: 0 }, + }, "Keep this comment") + const lines = markdown.split("\n") + + assert.equal( + lines[2], + `> Element: \`${"t".repeat(40)} role="${"r".repeat(80)}" "${"l".repeat(160)}"\``, + ) + assert.equal(lines[3], `> Selector: \`${"s".repeat(300)}\``) + assert.equal(markdown.includes("injected"), false) + assert.equal(markdown.endsWith("\n\nKeep this comment\n\n"), true) +}) + +test("web preview comments normalize Markdown-breaking metadata", () => { + assert.equal( + buildPreviewCommentMarkdown({ + pagePath: "/settings`\n> injected page", + tagName: "but`ton\n> injected tag", + text: "fallback", + role: "sw`itch\r\n> injected role", + ariaLabel: "Save`\n> injected label\u0000", + selector: "main`\n> injected selector", + rect: { x: 0, y: 0, width: 0, height: 0 }, + }, "Keep this comment"), + [ + "> Web preview comment", + "> Page: `/settings > injected page`", + '> Element: `but ton > injected tag role="sw itch > injected role" "Save > injected label"`', + "> Selector: `main > injected selector`", + "", + "Keep this comment", + "", + "", + ].join("\n"), + ) +}) diff --git a/packages/ui/src/stores/session-events.ts b/packages/ui/src/stores/session-events.ts index 886ae6420..dc0cb9872 100644 --- a/packages/ui/src/stores/session-events.ts +++ b/packages/ui/src/stores/session-events.ts @@ -78,6 +78,7 @@ import { observeSessionUpdateAuthority, } from "./session-location-authority" import { forgetOpenCodeWorkspaceIdForSession, rememberOpenCodeWorkspaceIdForSession } from "./opencode-workspaces" +import { workspaceDirectoriesEqual } from "./opencode-workspace-matching" import { getRootClient } from "./opencode-client" import { getWorktreeSlugForDirectory, getWorktreeSlugForSession } from "./worktrees" import { getOpenCodeWorkspaceIdForWorktree } from "./opencode-workspaces" @@ -428,6 +429,8 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo if (getAuthoritativelyDeletedSessionIdsForInstance(instanceId).has(info.id)) return const hasLocation = hasDirectory || hasWorkspaceId const existingSession = sessions().get(instanceId)?.get(info.id) + const changesDirectory = hasDirectory && Boolean(existingSession) + && !workspaceDirectoriesEqual(info.directory, existingSession?.directory) const staleLocation = hasLocation && Boolean(existingSession) && isStaleSessionLocation(instanceId, info.id, { hasDirectory, hasWorkspaceId, @@ -438,7 +441,7 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo workspaceId: existingSession?.workspaceId, }, serverUpdated) const confirmsCurrentLocation = hasLocation && Boolean(existingSession) - && (!hasDirectory || info.directory === existingSession?.directory) + && (!hasDirectory || !changesDirectory) && (!hasWorkspaceId || workspaceId === existingSession?.workspaceId) if (hasLocation && !staleLocation && !confirmsCurrentLocation) { markAuthoritativeSessionLocation(instanceId, info.id, serverUpdated) @@ -513,7 +516,9 @@ function handleSessionUpdate(instanceId: string, event: EventSessionUpdated): vo const updatedSession = { ...currentSession, projectId: info.projectID ?? currentSession.projectId, - workspaceId: staleLocation || !hasWorkspaceId ? currentSession.workspaceId : workspaceId, + workspaceId: staleLocation + ? currentSession.workspaceId + : hasWorkspaceId ? workspaceId : changesDirectory ? undefined : currentSession.workspaceId, directory: staleLocation || !hasDirectory ? currentSession.directory : info.directory, title: info.title || currentSession.title, parentId: info.parentID ?? currentSession.parentId, diff --git a/packages/ui/src/stores/session-location-authority.ts b/packages/ui/src/stores/session-location-authority.ts index 0f46b0082..e460c5c71 100644 --- a/packages/ui/src/stores/session-location-authority.ts +++ b/packages/ui/src/stores/session-location-authority.ts @@ -74,7 +74,7 @@ function isStaleSessionLocation( serverUpdated?: number, ): boolean { const current = authorities.get(key(instanceId, sessionId)) - const conflictsWithCurrent = (update.hasDirectory && update.directory !== currentLocation.directory) + const conflictsWithCurrent = (update.hasDirectory && !workspaceDirectoriesEqual(update.directory, currentLocation.directory)) || (update.hasWorkspaceId && update.workspaceId !== currentLocation.workspaceId) if (!conflictsWithCurrent) return false diff --git a/packages/ui/src/stores/session-worktree-binding.test.ts b/packages/ui/src/stores/session-worktree-binding.test.ts index 7fbf585bd..c93d384fe 100644 --- a/packages/ui/src/stores/session-worktree-binding.test.ts +++ b/packages/ui/src/stores/session-worktree-binding.test.ts @@ -286,7 +286,59 @@ describe("session worktree binding", () => { } }, } as any) assert.equal(sessions().get(instanceId)?.get(root.id)?.directory, "/repo-newer") - assert.equal(sessions().get(instanceId)?.get(root.id)?.workspaceId, "workspace-feature") + assert.equal(sessions().get(instanceId)?.get(root.id)?.workspaceId, undefined) + } finally { + cleanup() + } + }) + + it("clears a stale worktree workspace ID on a directory-only root move", async () => { + const instanceId = "directory-only-root-move" + const cleanup = await setup(instanceId) + const root = { ...session(instanceId, "root-session", null), directory: "/repo-feature", workspaceId: "workspace-feature" } + const child = session(instanceId, "child-session", root.id) + setFamily(instanceId, root, child) + + try { + handleSessionUpdate(instanceId, { + properties: { info: { + id: root.id, + directory: "/repo", + title: root.title, + version: root.version, + time: { created: 1, updated: 2 }, + } }, + } as any) + + assert.equal(sessions().get(instanceId)?.get(root.id)?.directory, "/repo") + assert.equal(sessions().get(instanceId)?.get(root.id)?.workspaceId, undefined) + } finally { + cleanup() + } + }) + + it("keeps POSIX directory checks case-sensitive while merging partial locations", async () => { + const instanceId = "posix-partial-location" + const cleanup = await setup(instanceId) + const root = { ...session(instanceId, "root-session", null), directory: "/Repo", workspaceId: "workspace-old" } + const child = session(instanceId, "child-session", root.id) + setFamily(instanceId, root, child) + + try { + handleSessionUpdate(instanceId, { + properties: { info: { + id: root.id, directory: "/repo", title: root.title, version: root.version, time: { created: 1, updated: 2 }, + } }, + } as any) + assert.equal(sessions().get(instanceId)?.get(root.id)?.workspaceId, undefined) + + handleSessionUpdate(instanceId, { + properties: { info: { + id: root.id, workspaceID: "workspace-new", title: root.title, version: root.version, time: { created: 1, updated: 3 }, + } }, + } as any) + assert.equal(sessions().get(instanceId)?.get(root.id)?.directory, "/repo") + assert.equal(sessions().get(instanceId)?.get(root.id)?.workspaceId, "workspace-new") } finally { cleanup() } @@ -330,6 +382,51 @@ describe("session worktree binding", () => { } }) + it("confirms a pending move with a Windows-equivalent partial location", async () => { + const instanceId = "windows-equivalent-current-confirmation" + const oldDirectory = String.raw`C:\Users\Dev\Repo` + const movedDirectory = String.raw`C:\Users\Dev\Repo-feature` + const cleanup = await setup(instanceId, { + move: async (_sessionId, slug) => ({ + rootSessionId: "root-session", + worktreeSlug: slug, + sessions: ["root-session", "child-session"].map((sessionId) => ({ + sessionId, + directory: movedDirectory, + workspaceId: "workspace-feature", + })), + }), + }) + const root = { ...session(instanceId, "root-session", null), directory: oldDirectory, workspaceId: "workspace-old" } + const child = { ...session(instanceId, "child-session", root.id), directory: oldDirectory, workspaceId: "workspace-old" } + setFamily(instanceId, root, child) + + const update = (directory: string, updated: number) => handleSessionUpdate(instanceId, { + properties: { info: { + id: root.id, + directory, + title: root.title, + version: root.version, + time: { created: 1, updated }, + } }, + } as any) + + try { + update(oldDirectory, 10) + await moveSessionToWorktree(instanceId, root.id, "feature") + update("c:/users/dev/repo-FEATURE/", 10) + + assert.equal(sessions().get(instanceId)?.get(root.id)?.directory, "c:/users/dev/repo-FEATURE/") + assert.equal(sessions().get(instanceId)?.get(root.id)?.workspaceId, "workspace-feature") + + update(oldDirectory, 20) + assert.equal(sessions().get(instanceId)?.get(root.id)?.directory, oldDirectory) + assert.equal(sessions().get(instanceId)?.get(root.id)?.workspaceId, undefined) + } finally { + cleanup() + } + }) + it("fences a numerically newer partial pre-move location until server confirmation", async () => { const instanceId = "move-fences-newer-delayed-location" const cleanup = await setup(instanceId) @@ -358,7 +455,7 @@ describe("session worktree binding", () => { update("/repo-feature", 20) update("/repo", 30) assert.equal(sessions().get(instanceId)?.get(root.id)?.directory, "/repo") - assert.equal(sessions().get(instanceId)?.get(root.id)?.workspaceId, "workspace-feature") + assert.equal(sessions().get(instanceId)?.get(root.id)?.workspaceId, undefined) } finally { cleanup() } From 617807ee28212cfd09e1dc30efa060f12dd44e25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Tue, 11 Aug 2026 09:59:29 +0200 Subject: [PATCH 19/20] fix(worktrees): resolve native routing after admission Let legacy permission replies use the instance control lane without reacquiring a repository lock held by their prompting mutation, and resolve YOLO persistence location only after queued move admission completes. Preserve shared launches when one creator disconnects, memoize concurrent lifetime-lease release, retain Windows mount source prefixes, and safely converge default WSL drive identity when Windows-side path conversion is unavailable. Route WSL-native root and feature sessions through authoritative workspace IDs while keeping host and native path equality namespace-aware. Validated 415 server tests, 68 browser UI tests, and root typechecks. --- .../src/permissions/opencode-replier.test.ts | 82 +++++++------- .../src/permissions/opencode-replier.ts | 33 +++--- .../opencode-yolo-metadata.test.ts | 42 ++++++++ .../src/permissions/opencode-yolo-metadata.ts | 15 ++- .../__tests__/workspace-identity.test.ts | 48 ++++++++- .../server/src/workspaces/manager.test.ts | 90 +++++++++++++++- packages/server/src/workspaces/manager.ts | 2 +- .../src/workspaces/workspace-identity.ts | 6 +- .../workspace-lifetime-lease.test.ts | 26 +++++ .../workspaces/workspace-lifetime-lease.ts | 28 ++--- .../src/workspaces/wsl-windows-drive.ts | 22 +++- packages/ui/src/stores/instances.ts | 7 +- .../src/stores/opencode-workspace-matching.ts | 38 ++++--- .../ui/src/stores/opencode-workspaces.test.ts | 10 ++ packages/ui/src/stores/opencode-workspaces.ts | 22 +++- .../src/stores/permission-lifecycle.test.ts | 30 +++++- packages/ui/src/stores/session-api.ts | 8 +- .../stores/session-worktree-binding.test.ts | 100 +++++++++++++++++- .../ui/src/stores/session-worktree-binding.ts | 31 ++++-- packages/ui/src/stores/worktrees.ts | 12 ++- 20 files changed, 522 insertions(+), 130 deletions(-) diff --git a/packages/server/src/permissions/opencode-replier.test.ts b/packages/server/src/permissions/opencode-replier.test.ts index 535817c55..c3ef6705e 100644 --- a/packages/server/src/permissions/opencode-replier.test.ts +++ b/packages/server/src/permissions/opencode-replier.test.ts @@ -6,6 +6,7 @@ import type { Logger } from "../logger" import type { AutoAcceptReply } from "./auto-accept-manager" import { createOpencodePermissionReplier } from "./opencode-replier" import { InstanceMutationGate } from "../server/instance-mutation-gate" +import { admitWorkspaceMutation } from "../server/instance-mutation-proxy" import type { InstanceClientOptions } from "../workspaces/instance-client" import { resolveRepositoryMutationKey } from "../workspaces/workspace-identity" @@ -167,60 +168,49 @@ describe("OpenCode permission replier", () => { } }) - it("admits legacy discovery normally, then releases repository before replying", async () => { + it("answers a synchronous mutation while preserving instance-exclusive serialization", async () => { const gate = new InstanceMutationGate() - const repositoryKey = await resolveRepositoryMutationKey(os.tmpdir()) - let finishExclusive!: () => void - let markExclusiveStarted!: () => void - const held = new Promise((resolve) => { finishExclusive = resolve }) - const started = new Promise((resolve) => { markExclusiveStarted = resolve }) - const exclusive = gate.exclusive(repositoryKey, async () => { markExclusiveStarted(); await held }) - await started + const promptAdmission = await admitWorkspaceMutation({ + gate, + workspaceId: "instance", + method: "POST", + pathSuffix: "session/session/prompt", + resolveWorkspace: async () => ({ + port: 4321, + hostDirectory: os.tmpdir(), + nativeRootDirectory: "/repo", + }), + loadSessions: async () => [{ id: "session", directory: "/repo" }], + loadWorkspaces: async () => [], + }) let finishReply!: () => void const replyWait = new Promise((resolve) => { finishReply = resolve }) const harness = createHarness([{ id: "session", directory: "/repo" }], gate, replyWait) const reply = harness.replier(legacyReply) - await new Promise((resolve) => setImmediate(resolve)) - assert.equal(harness.listCalls.length, 0) + let exclusive: Promise | undefined - finishExclusive() - await exclusive - while (harness.legacyCalls.length === 0) await new Promise((resolve) => setImmediate(resolve)) - let repositoryReadmitted = false - const readmitted = gate.exclusive(repositoryKey, async () => { repositoryReadmitted = true }) - await readmitted - assert.equal(repositoryReadmitted, true) - - let instanceExclusiveStarted = false - const instanceExclusive = gate.exclusive("instance", async () => { instanceExclusiveStarted = true }) - await Promise.resolve() - assert.equal(instanceExclusiveStarted, false) - finishReply() - await reply - await instanceExclusive - assert.equal(instanceExclusiveStarted, true) - }) - - it("retries a transient legacy repository release before replying", async () => { - const gate = new InstanceMutationGate() - let releaseAttempts = 0 - const mutationGate = { - enter: gate.enter.bind(gate), - acquireExclusive: async (key: string) => { - const release = await gate.acquireExclusive(key) - return () => { - releaseAttempts += 1 - if (releaseAttempts === 1) throw new Error("transient repository release failure") - release() - } - }, - } - const harness = createHarness([{ id: "session", directory: "/repo" }], mutationGate) + try { + for (let attempt = 0; attempt < 20 && harness.legacyCalls.length === 0; attempt += 1) { + await new Promise((resolve) => setImmediate(resolve)) + } + assert.equal(harness.legacyCalls.length, 1, "legacy reply deadlocked behind its prompt's repository admission") - await harness.replier(legacyReply) + let exclusiveStarted = false + exclusive = gate.exclusive("instance", async () => { exclusiveStarted = true }) + await promptAdmission.release() + await new Promise((resolve) => setImmediate(resolve)) + assert.equal(exclusiveStarted, false) - assert.ok(releaseAttempts >= 2) - assert.equal(harness.legacyCalls.length, 1) + finishReply() + await reply + await exclusive + assert.equal(exclusiveStarted, true) + } finally { + finishReply() + await promptAdmission.release() + await reply.catch(() => undefined) + await exclusive?.catch(() => undefined) + } }) it("holds instance-shared admission for legacy and V2 Yolo replies", async () => { diff --git a/packages/server/src/permissions/opencode-replier.ts b/packages/server/src/permissions/opencode-replier.ts index f83572bab..638e6ee12 100644 --- a/packages/server/src/permissions/opencode-replier.ts +++ b/packages/server/src/permissions/opencode-replier.ts @@ -5,7 +5,6 @@ import { createInstanceClient, type InstanceClientOptions } from "../workspaces/ import type { AutoAcceptReply, PermissionReplier } from "./auto-accept-manager" import type { InstanceMutationGate } from "../server/instance-mutation-gate" import { resolveNativeSessionLocation } from "../workspaces/native-session-location" -import { acquireRepositoryMutation } from "../workspaces/repository-mutation-lock" interface OpencodeReplierDeps { workspaceManager: WorkspaceManager @@ -52,27 +51,19 @@ export function createOpencodePermissionReplier( opts, ) } else { - const repository = await acquireRepositoryMutation({ - workspaceFolder: workspace.path, - gate: deps.mutationGate, - }) - let location: ReturnType - try { - const { data: sessions } = await client.session.list( - { scope: "project", limit: 10_000, directory: nativeRoot }, - { throwOnError: true }, - ) - const matches = (sessions ?? []).filter((session) => session.id === reply.sessionId) - if (matches.length !== 1) { - throw new Error(`Yolo: legacy permission session ${reply.sessionId} is ${matches.length === 0 ? "missing" : "ambiguous"}`) - } - const scope = { directory: nativeRoot } - await client.experimental.workspace.syncList(scope, { throwOnError: true }) - const { data: workspaces = [] } = await client.experimental.workspace.list(scope, { throwOnError: true }) - location = resolveNativeSessionLocation(nativeRoot, workspaces, matches[0]) - } finally { - await repository.release() + // The prompting mutation may hold repository admission; instance admission still serializes moves and deletion. + const { data: sessions } = await client.session.list( + { scope: "project", limit: 10_000, directory: nativeRoot }, + { throwOnError: true }, + ) + const matches = (sessions ?? []).filter((session) => session.id === reply.sessionId) + if (matches.length !== 1) { + throw new Error(`Yolo: legacy permission session ${reply.sessionId} is ${matches.length === 0 ? "missing" : "ambiguous"}`) } + const scope = { directory: nativeRoot } + await client.experimental.workspace.syncList(scope, { throwOnError: true }) + const { data: workspaces = [] } = await client.experimental.workspace.list(scope, { throwOnError: true }) + const location = resolveNativeSessionLocation(nativeRoot, workspaces, matches[0]) await client.permission.reply( { requestID: reply.permissionId, diff --git a/packages/server/src/permissions/opencode-yolo-metadata.test.ts b/packages/server/src/permissions/opencode-yolo-metadata.test.ts index e205cc4ce..151cdb9eb 100644 --- a/packages/server/src/permissions/opencode-yolo-metadata.test.ts +++ b/packages/server/src/permissions/opencode-yolo-metadata.test.ts @@ -107,6 +107,48 @@ describe("OpenCode Yolo metadata", () => { assert.equal(lists, 1) }) + it("resolves a moved session workspace after queued mutation admission", async () => { + const gate = new InstanceMutationGate() + let workspaceID = "workspace-a" + let lists = 0 + const scopes: Array> = [] + const client = { + session: { + async list() { + lists += 1 + return { data: [{ id: "root", workspaceID, metadata: {} }] } + }, + async get(parameters: Record) { + scopes.push(parameters) + return { data: { metadata: {} } } + }, + async update(parameters: Record) { + scopes.push(parameters) + return { data: { metadata: parameters.metadata } } + }, + }, + } + const manager = { get: () => ({ path: os.tmpdir() }), resolveInstanceDirectory: async () => "/repo" } + const persistence = createOpencodeYoloPersistence(manager as never, () => client as never, gate) + let releaseMove!: () => void + let markMoveStarted!: () => void + const moveHeld = new Promise((resolve) => { releaseMove = resolve }) + const moveStarted = new Promise((resolve) => { markMoveStarted = resolve }) + const move = gate.exclusive("instance", async () => { markMoveStarted(); await moveHeld }) + await moveStarted + + const toggle = persistence.persist("instance", "root", true, "workspace-a") + await Promise.resolve() + assert.equal(lists, 0) + workspaceID = "workspace-b" + releaseMove() + await move + await toggle + + assert.equal(lists, 1) + assert.deepEqual(scopes.map((scope) => scope.workspace), ["workspace-b", "workspace-b"]) + }) + it("allows transaction-owned metadata cleanup without re-entering its exclusive gate", async () => { const gate = new InstanceMutationGate() const client = { diff --git a/packages/server/src/permissions/opencode-yolo-metadata.ts b/packages/server/src/permissions/opencode-yolo-metadata.ts index c6685496d..91d897bd7 100644 --- a/packages/server/src/permissions/opencode-yolo-metadata.ts +++ b/packages/server/src/permissions/opencode-yolo-metadata.ts @@ -121,9 +121,18 @@ export function createOpencodeYoloPersistence( })) }) }, - persist(instanceId, rootSessionId, enabled, workspaceId, admission): Promise { - return admitted(instanceId, admission, () => updateMetadata(instanceId, rootSessionId, workspaceId, - (metadata) => mergePersistedYolo(metadata, rootSessionId, enabled)).then(() => undefined)) + persist(instanceId, rootSessionId, enabled, _workspaceId, admission): Promise { + return admitted(instanceId, admission, async () => { + const { client, directory } = await clientFor(instanceId) + const { data } = await client.session.list( + { scope: "project", limit: SESSION_LIST_LIMIT, directory }, + { throwOnError: true }, + ) + const matches = (data ?? []).filter((session) => session.id === rootSessionId) + if (matches.length !== 1) throw new Error(`Session ${rootSessionId} location is missing or ambiguous`) + await updateMetadata(instanceId, rootSessionId, matches[0].workspaceID, + (metadata) => mergePersistedYolo(metadata, rootSessionId, enabled)) + }) }, hasProjectSession(instanceId, sessionId, admission): Promise { return admitted(instanceId, admission, async () => { diff --git a/packages/server/src/workspaces/__tests__/workspace-identity.test.ts b/packages/server/src/workspaces/__tests__/workspace-identity.test.ts index 73251a685..a3d998895 100644 --- a/packages/server/src/workspaces/__tests__/workspace-identity.test.ts +++ b/packages/server/src/workspaces/__tests__/workspace-identity.test.ts @@ -200,6 +200,38 @@ describe("workspace identity", () => { ) }) + it("uses the default WSL drive identity only when Windows-side resolution fails", () => { + const failedResolution = { resolveWslPath: () => undefined } + const windowsIdentity = canonicalFilesystemIdentity(String.raw`C:\Projects\CodeNomad`, "win32") + assert.equal( + canonicalFilesystemIdentity( + String.raw`\\wsl.localhost\Ubuntu\mnt\c\Projects\CodeNomad`, + "win32", + undefined, + failedResolution, + ), + windowsIdentity, + ) + assert.equal( + canonicalFilesystemIdentity( + String.raw`\\wsl.localhost\Ubuntu\windows\c\Projects\CodeNomad`, + "win32", + undefined, + failedResolution, + ), + "wsl:ubuntu:/windows/c/Projects/CodeNomad", + ) + assert.equal( + canonicalFilesystemIdentity( + String.raw`\\wsl.localhost\Ubuntu\mnt\c\Projects\CodeNomad`, + "win32", + undefined, + { resolveWslPath: () => String.raw`\\server\share\CodeNomad` }, + ), + "wsl:ubuntu:/mnt/c/Projects/CodeNomad", + ) + }) + it("uses WSL mount metadata for custom automount roots", () => { const release = "5.15.153.1-microsoft-standard-WSL2" const mounts = parseWslWindowsDriveMounts(String.raw`36 25 0:32 / /windows/c rw - 9p C:\134 rw`) @@ -208,7 +240,7 @@ describe("workspace identity", () => { resolveWslPath: (_distribution: string, linuxPath: string) => `C:\\${linuxPath.replace(/^\/windows\/c\/?/i, "").replace(/\//g, "\\")}`, } - assert.deepEqual(mounts, [{ mountPoint: "/windows/c", drive: "c" }]) + assert.deepEqual(mounts, [{ mountPoint: "/windows/c", drive: "c", sourcePrefix: "" }]) assert.equal( canonicalFilesystemIdentity("/windows/c/Projects/CodeNomad", "linux", release, sources), canonicalFilesystemIdentity(String.raw`C:\Projects\CodeNomad`, "win32"), @@ -223,6 +255,20 @@ describe("workspace identity", () => { ) }) + it("includes a custom mount's Windows source prefix in its identity", () => { + const release = "5.15.153.1-microsoft-standard-WSL2" + const mounts = parseWslWindowsDriveMounts(String.raw`36 25 0:32 / /work rw - 9p C:\134Projects rw`) + assert.deepEqual(mounts, [{ mountPoint: "/work", drive: "c", sourcePrefix: "Projects" }]) + assert.equal( + canonicalFilesystemIdentity("/work/repo", "linux", release, { mounts }), + canonicalFilesystemIdentity(String.raw`C:\Projects\repo`, "win32"), + ) + assert.notEqual( + canonicalFilesystemIdentity("/work/repo", "linux", release, { mounts }), + canonicalFilesystemIdentity(String.raw`C:\repo`, "win32"), + ) + }) + it("canonicalizes aliases and falls back to an absolute identity for missing paths", async () => { const { root, target, link } = await createLinkedWorkspace() const [targetResult, linkResult, missing] = await Promise.all([ diff --git a/packages/server/src/workspaces/manager.test.ts b/packages/server/src/workspaces/manager.test.ts index df74df8b2..f6a1de7ce 100644 --- a/packages/server/src/workspaces/manager.test.ts +++ b/packages/server/src/workspaces/manager.test.ts @@ -109,6 +109,39 @@ function createHarness(options: { type WorkspaceManagerConstructorOptions = ConstructorParameters[0] +function blockRepositoryAdmission() { + const entered = deferred() + const release = deferred() + const aborted = deferred() + const withRepositoryMutation: NonNullable = async ({ signal, operation }) => { + entered.resolve() + await new Promise((resolve, reject) => { + let settled = false + const finish = (result: () => void) => { + if (settled) return + settled = true + signal?.removeEventListener("abort", abort) + result() + } + const abort = () => finish(() => { + aborted.resolve(signal?.reason) + reject(signal?.reason) + }) + signal?.addEventListener("abort", abort, { once: true }) + release.promise.then(() => finish(resolve)) + if (signal?.aborted) abort() + }) + return operation("blocked-repository") + } + return { entered, release, aborted, withRepositoryMutation } +} + +async function waitForCreationOwners(manager: WorkspaceManager, count: number) { + while ([...(manager as any).pendingWorkspaceCreations.values()][0]?.ownership.size !== count) { + await new Promise((resolve) => setImmediate(resolve)) + } +} + async function createReady(harness: ReturnType, folder = process.cwd()) { const creation = harness.manager.create(folder) const workspaceId = await harness.runtime.launchCalled.promise @@ -119,6 +152,59 @@ async function createReady(harness: ReturnType, folder = p } describe("workspace manager lifecycle", () => { + it("keeps blocked shared admission alive when the creator disconnects", async () => { + const admission = blockRepositoryAdmission() + const harness = createHarness({ withRepositoryMutation: admission.withRepositoryMutation }) + const creatorController = new AbortController() + const creator = harness.manager.create(process.cwd(), undefined, { + requestId: "queued-creator", + signal: creatorController.signal, + }) + await admission.entered.promise + const owner = harness.manager.create(process.cwd(), undefined, { requestId: "queued-owner" }) + const outcomes = Promise.allSettled([creator, owner]) + await waitForCreationOwners(harness.manager, 2) + + creatorController.abort(new Error("creator disconnected")) + await harness.manager.cancelCreationRequest("queued-creator") + admission.release.resolve() + const workspaceId = await harness.runtime.launchCalled.promise + harness.runtime.resolveLaunch() + harness.readiness.resolve(undefined) + + const [creatorOutcome, ownerOutcome] = await outcomes + assert.equal(creatorOutcome.status, "rejected") + assert.match(String(creatorOutcome.reason), /queued-creator was cancelled/) + assert.equal(ownerOutcome.status, "fulfilled") + assert.equal(ownerOutcome.value.created, false) + assert.equal(ownerOutcome.value.workspace.id, workspaceId) + assert.equal(ownerOutcome.value.workspace.requestId, "queued-owner") + assert.equal(harness.runtime.active.has(workspaceId), true) + }) + + it("aborts blocked shared admission when every owner cancels", async () => { + const admission = blockRepositoryAdmission() + const harness = createHarness({ withRepositoryMutation: admission.withRepositoryMutation }) + const first = harness.manager.create(process.cwd(), undefined, { requestId: "cancelled-one" }) + await admission.entered.promise + const second = harness.manager.create(process.cwd(), undefined, { requestId: "cancelled-two" }) + const outcomes = Promise.allSettled([first, second]) + await waitForCreationOwners(harness.manager, 2) + + await harness.manager.cancelCreationRequest("cancelled-one") + const finalCancellation = harness.manager.cancelCreationRequest("cancelled-two") + const reason = await admission.aborted.promise + const results = await outcomes + await finalCancellation + + assert.ok(reason instanceof WorkspaceLaunchCancelledError) + assert.deepEqual(results.map((result) => result.status), ["rejected", "rejected"]) + assert.ok(results.every((result) => result.status === "rejected" && result.reason === reason)) + assert.equal(harness.runtime.active.size, 0) + assert.deepEqual(harness.manager.list(), []) + assert.equal((harness.manager as any).workspaces.size, 0) + }) + it("waits for repository admission before reserving and starting", async () => { const harness = createHarness() const repositoryKey = await resolveRepositoryMutationKey(process.cwd()) @@ -532,9 +618,7 @@ describe("workspace manager lifecycle", () => { const first = harness.manager.create(process.cwd(), undefined, { requestId: "deadline-one" }) const workspaceId = await harness.runtime.launchCalled.promise const shared = harness.manager.create(process.cwd(), undefined, { requestId: "deadline-two" }) - while ([...(harness.manager as any).pendingWorkspaceCreations.values()][0]?.ownership.size !== 2) { - await new Promise((resolve) => setImmediate(resolve)) - } + await waitForCreationOwners(harness.manager, 2) if (boundary === "health readiness") { harness.runtime.resolveLaunch() await new Promise((resolve) => setImmediate(resolve)) diff --git a/packages/server/src/workspaces/manager.ts b/packages/server/src/workspaces/manager.ts index 4f5806512..a73a9b9f0 100644 --- a/packages/server/src/workspaces/manager.ts +++ b/packages/server/src/workspaces/manager.ts @@ -458,7 +458,7 @@ export class WorkspaceManager { const creation = this.mutationGate.exclusive(record.id, async () => { let startupSucceeded = false try { - return await this.withLaunchRepository(record.path, launchDeadlineAt, launchTimeoutMs, options.signal, async () => { + return await this.withLaunchRepository(record.path, launchDeadlineAt, launchTimeoutMs, record[WORKSPACE_STATE].abortController.signal, async () => { this.throwIfCancelled(record) if (this.shuttingDown) throw new Error("Workspace manager is shutting down") await this.acquireLifetimeLease(record) diff --git a/packages/server/src/workspaces/workspace-identity.ts b/packages/server/src/workspaces/workspace-identity.ts index 7de9849ab..9aeaea97c 100644 --- a/packages/server/src/workspaces/workspace-identity.ts +++ b/packages/server/src/workspaces/workspace-identity.ts @@ -4,6 +4,7 @@ import os from "node:os" import path from "node:path" import { queryGitRepositoryPaths } from "./git-output" import { + defaultWslWindowsDrivePathIdentity, resolveWslWindowsPath, windowsDrivePathIdentity, type WslWindowsDriveMount, @@ -25,8 +26,9 @@ function wslUncIdentity(value: string, sources: WorkspaceIdentitySources = {}): if (!match) return null const linuxPath = `/${(match[2] ?? "").split(/\\+/).filter(Boolean).join("/")}` const windowsPath = (sources.resolveWslPath ?? resolveWslWindowsPath)(match[1]!, linuxPath) - return (windowsPath && windowsDriveIdentity(windowsPath, "win32", undefined, sources.mounts)) - ?? `wsl:${match[1]!.toLowerCase()}:${path.posix.normalize(linuxPath)}` + const distroIdentity = `wsl:${match[1]!.toLowerCase()}:${path.posix.normalize(linuxPath)}` + if (windowsPath) return windowsDriveIdentity(windowsPath, "win32", undefined, sources.mounts) ?? distroIdentity + return defaultWslWindowsDrivePathIdentity(linuxPath) ?? distroIdentity } function windowsDriveIdentity( diff --git a/packages/server/src/workspaces/workspace-lifetime-lease.test.ts b/packages/server/src/workspaces/workspace-lifetime-lease.test.ts index 224b363a5..4bef6fe9e 100644 --- a/packages/server/src/workspaces/workspace-lifetime-lease.test.ts +++ b/packages/server/src/workspaces/workspace-lifetime-lease.test.ts @@ -68,6 +68,32 @@ describe("workspace lifetime lease", () => { assert.equal(await hasWorkspaceLifetimeBlocker({ workspaceFolder: directory }), false) }) + it("shares one release operation between concurrent callers", async () => { + const directory = await temporaryDirectory() + execFileSync("git", ["init"], { cwd: directory }) + let allowRetirement!: () => void + const retirementGate = new Promise((resolve) => { allowRetirement = resolve }) + const attempts = new Map() + const lease = await acquireWorkspaceLifetimeLease(directory, "workspace", async (claimPath) => { + attempts.set(claimPath, (attempts.get(claimPath) ?? 0) + 1) + await retirementGate + return retireCurrentOwnershipClaim(claimPath) + }) + + const firstRelease = lease.release() + const secondRelease = lease.release() + assert.equal(firstRelease, secondRelease) + while (attempts.size === 0) await new Promise((resolve) => setImmediate(resolve)) + await new Promise((resolve) => setImmediate(resolve)) + assert.equal([...attempts.values()].reduce((total, count) => total + count, 0), 1) + + allowRetirement() + await firstRelease + const authority = await workspaceLifetimeAuthorityRoots(directory) + assert.equal(attempts.size, authority.roots.length) + assert.ok([...attempts.values()].every((count) => count === 1)) + }) + it("observes another process and recovers its stale lease", async () => { const directory = await temporaryDirectory() const readyPath = path.join(directory, "ready") diff --git a/packages/server/src/workspaces/workspace-lifetime-lease.ts b/packages/server/src/workspaces/workspace-lifetime-lease.ts index 0d3daff33..b60e09d60 100644 --- a/packages/server/src/workspaces/workspace-lifetime-lease.ts +++ b/packages/server/src/workspaces/workspace-lifetime-lease.ts @@ -164,23 +164,25 @@ export async function acquireWorkspaceLifetimeLease( throw error } + let releasePending: Promise | undefined + const release = async () => { + const failures: unknown[] = [] + for (let index = claims.length - 1; index >= 0; index -= 1) { + const claim = claims[index]! + try { + await retireOwnedClaimUntilSuccessful(claim.path, token, claim.stopHeartbeat, retireCurrent) + claims.splice(index, 1) + } catch (error) { + failures.push(error) + } + } + if (failures.length > 0) throw new AggregateError(failures, "Failed to release workspace lifetime lease") + } return { token, directoryKey: record.directoryKey, repositoryKey: record.repositoryKey, - release: async () => { - const failures: unknown[] = [] - for (let index = claims.length - 1; index >= 0; index -= 1) { - const claim = claims[index]! - try { - await retireOwnedClaimUntilSuccessful(claim.path, token, claim.stopHeartbeat, retireCurrent) - claims.splice(index, 1) - } catch (error) { - failures.push(error) - } - } - if (failures.length > 0) throw new AggregateError(failures, "Failed to release workspace lifetime lease") - }, + release: () => releasePending ??= release(), } } diff --git a/packages/server/src/workspaces/wsl-windows-drive.ts b/packages/server/src/workspaces/wsl-windows-drive.ts index c0f8be701..637ff5d4d 100644 --- a/packages/server/src/workspaces/wsl-windows-drive.ts +++ b/packages/server/src/workspaces/wsl-windows-drive.ts @@ -6,6 +6,7 @@ import path from "node:path" export interface WslWindowsDriveMount { mountPoint: string drive: string + sourcePrefix?: string } const decodeMountField = (value: string): string => value.replace(/\\([0-7]{3})/g, @@ -17,10 +18,15 @@ export function parseWslWindowsDriveMounts(mountInfo: string): WslWindowsDriveMo const fields = line.split(" ") const separator = fields.indexOf("-") if (separator < 0 || !fields[4] || !fields[separator + 2]) continue - const source = decodeMountField(fields[separator + 2]!) - const match = source.match(/^([a-z]):(?:[\\/]|$)/i) + const source = decodeMountField(fields[separator + 2]!).replace(/\\/g, "/") + const match = source.match(/^([a-z]):(?:\/(.*))?$/i) if (!match) continue - mounts.push({ mountPoint: path.posix.normalize(decodeMountField(fields[4])), drive: match[1]!.toLowerCase() }) + const sourcePrefix = path.posix.normalize(`/${match[2] ?? ""}`).split("/").filter(Boolean).join("/") + mounts.push({ + mountPoint: path.posix.normalize(decodeMountField(fields[4])), + drive: match[1]!.toLowerCase(), + sourcePrefix, + }) } return mounts.sort((left, right) => right.mountPoint.length - left.mountPoint.length) } @@ -54,11 +60,19 @@ export function windowsDrivePathIdentity( for (const mount of mounts) { const relative = path.posix.relative(path.posix.resolve(mount.mountPoint), normalized) if (relative === ".." || relative.startsWith("../") || path.posix.isAbsolute(relative)) continue - return `windows-drive:${mount.drive.toLowerCase()}:/${relative.split("/").filter(Boolean).join("/").toLowerCase()}` + const suffix = [mount.sourcePrefix ?? "", relative].join("/").split("/").filter(Boolean).join("/").toLowerCase() + return `windows-drive:${mount.drive.toLowerCase()}:/${suffix}` } return null } +export function defaultWslWindowsDrivePathIdentity(value: string): string | null { + const match = path.posix.normalize(value).match(/^\/mnt\/([a-z])(?:\/(.*))?$/i) + if (!match) return null + const suffix = (match[2] ?? "").split("/").filter(Boolean).join("/").toLowerCase() + return `windows-drive:${match[1]!.toLowerCase()}:/${suffix}` +} + export function resolveWslWindowsPath(distribution: string, linuxPath: string): string | undefined { try { const result = spawnSync("wsl.exe", ["--distribution", distribution, "--exec", "wslpath", "-w", linuxPath], { diff --git a/packages/ui/src/stores/instances.ts b/packages/ui/src/stores/instances.ts index 239632088..c03b29f9b 100644 --- a/packages/ui/src/stores/instances.ts +++ b/packages/ui/src/stores/instances.ts @@ -80,7 +80,10 @@ import { RestoreWorkspaceCommitGates, type RestoreWorkspaceCommitGate, type RestoreWorkspaceTerminal, } from "./restore-workspace-commit-gates" import { WorkspaceListReconciliationFence } from "./workspace-list-reconciliation-fence" -import { findWorktreeSlugForDirectory, workspaceDirectoriesEqual } from "./opencode-workspace-matching" +import { + findWorktreeSlugForDirectory, + inventoryAndNativeDirectoriesEqual, +} from "./opencode-workspace-matching" const log = getLogger("api") const INTERRUPTION_SESSION_LIST_LIMIT = 10_000 @@ -241,7 +244,7 @@ async function withInterruptionWorkspace( if (typeof explicitWorkspace === "string" && explicitWorkspace) { return operation({ workspace: explicitWorkspace }) } - if (workspaceDirectoriesEqual(match.directory, instance.folder)) return operation({}) + if (inventoryAndNativeDirectoriesEqual(instance.folder, match.directory)) return operation({}) const slug = findWorktreeSlugForDirectory(getWorktrees(instanceId), match.directory) if (!slug || slug === "root") throw new Error("Unable to resolve interruption session workspace") const workspace = await getOpenCodeWorkspaceIdForWorktree(instanceId, slug) diff --git a/packages/ui/src/stores/opencode-workspace-matching.ts b/packages/ui/src/stores/opencode-workspace-matching.ts index 558cfc6f6..cbae5bb20 100644 --- a/packages/ui/src/stores/opencode-workspace-matching.ts +++ b/packages/ui/src/stores/opencode-workspace-matching.ts @@ -37,6 +37,20 @@ function workspaceDirectoriesEqual(left: string | null | undefined, right: strin && normalizeWindowsWorkspaceDirectory(normalizedLeft) === normalizeWindowsWorkspaceDirectory(normalizedRight) } +function inventoryAndNativeDirectoriesEqual( + inventoryDirectory: string | null | undefined, + nativeDirectory: string | null | undefined, +): boolean { + if (workspaceDirectoriesEqual(inventoryDirectory, nativeDirectory)) return true + const inventory = normalizeWorkspaceDirectory(inventoryDirectory) + const native = normalizeWorkspaceDirectory(nativeDirectory) + if (!isWindowsWorkspaceDirectory(inventory)) return false + const wslDrive = native.match(/^\/mnt\/([A-Za-z])(?:\/(.*))?$/) + if (!wslDrive) return false + return normalizeWindowsWorkspaceDirectory(inventory) + === normalizeWindowsWorkspaceDirectory(`${wslDrive[1]}:/${wslDrive[2] ?? ""}`) +} + function findWorktreeSlugForDirectory( worktrees: Pick[], target: string | null | undefined, @@ -57,25 +71,21 @@ function mapOpenCodeWorkspacesToWorktreeSlugs( worktrees: Pick[], workspaces: OpenCodeWorkspaceLike[], ): Map { - const byDirectory = new Map() - const byWindowsDirectory = new Map() - for (const workspace of workspaces) { - const directory = normalizeWorkspaceDirectory(workspace.directory) - if (!directory) continue - byDirectory.set(directory, workspace) - if (isWindowsWorkspaceDirectory(directory)) { - byWindowsDirectory.set(normalizeWindowsWorkspaceDirectory(directory), workspace) - } - } - const next = new Map() for (const worktree of worktrees) { if (worktree.slug === "root") continue - const directory = normalizeWorkspaceDirectory(worktree.directory) - const workspace = byDirectory.get(directory) ?? (isWindowsWorkspaceDirectory(directory) ? byWindowsDirectory.get(normalizeWindowsWorkspaceDirectory(directory)) : undefined) + const workspace = workspaces.find((candidate) => ( + inventoryAndNativeDirectoriesEqual(worktree.directory, candidate.directory) + )) if (workspace?.id) next.set(worktree.slug, workspace.id) } return next } -export { findWorktreeSlugForDirectory, mapOpenCodeWorkspacesToWorktreeSlugs, normalizeWorkspaceDirectory, workspaceDirectoriesEqual } +export { + findWorktreeSlugForDirectory, + inventoryAndNativeDirectoriesEqual, + mapOpenCodeWorkspacesToWorktreeSlugs, + normalizeWorkspaceDirectory, + workspaceDirectoriesEqual, +} diff --git a/packages/ui/src/stores/opencode-workspaces.test.ts b/packages/ui/src/stores/opencode-workspaces.test.ts index 88a31bac8..67a714942 100644 --- a/packages/ui/src/stores/opencode-workspaces.test.ts +++ b/packages/ui/src/stores/opencode-workspaces.test.ts @@ -61,6 +61,16 @@ describe("mapOpenCodeWorkspacesToWorktreeSlugs", () => { assert.equal(result.size, 0) }) + + it("bridges Windows inventory to WSL-native workspaces without merging path namespaces", () => { + const result = mapOpenCodeWorkspacesToWorktreeSlugs( + [{ slug: "feature", directory: String.raw`C:\repo-feature` }], + [{ id: "wrk_feature", directory: "/mnt/c/repo-feature" }], + ) + + assert.equal(result.get("feature"), "wrk_feature") + assert.equal(workspaceDirectoriesEqual(String.raw`C:\repo`, "/mnt/c/repo"), false) + }) }) describe("findWorktreeSlugForDirectory", () => { diff --git a/packages/ui/src/stores/opencode-workspaces.ts b/packages/ui/src/stores/opencode-workspaces.ts index 015e6e633..6401321b4 100644 --- a/packages/ui/src/stores/opencode-workspaces.ts +++ b/packages/ui/src/stores/opencode-workspaces.ts @@ -1,5 +1,4 @@ import { getRootClient } from "./opencode-client" -import { getWorktreeSlugForSession, getWorktrees } from "./worktrees" import { getLogger } from "../lib/logger" import { mapOpenCodeWorkspacesToWorktreeSlugs } from "./opencode-workspace-matching" @@ -54,6 +53,7 @@ async function loadOpenCodeWorkspaces(instanceId: string): Promise(workspaceApi.list({ directory: instance.folder })) const workspaces = Array.isArray(result?.data) ? (result.data as OpenCodeWorkspace[]) : [] + const { getWorktrees } = await import("./worktrees") return mapOpenCodeWorkspacesToWorktreeSlugs(getWorktrees(instanceId), workspaces) } @@ -63,8 +63,14 @@ function getCachedOpenCodeWorkspaceIdForWorktree(instanceId: string, slug: strin } function getCachedOpenCodeWorkspaceIdForSession(instanceId: string, sessionId: string): string | null { - return workspaceIdBySession.get(instanceId)?.get(sessionId) - ?? getCachedOpenCodeWorkspaceIdForWorktree(instanceId, getWorktreeSlugForSession(instanceId, sessionId)) + return workspaceIdBySession.get(instanceId)?.get(sessionId) ?? null +} + +function getCachedWorktreeSlugForOpenCodeWorkspaceId(instanceId: string, workspaceId: string): string | null { + for (const [slug, candidate] of workspaceIdByWorktreeSlug.get(instanceId) ?? []) { + if (candidate === workspaceId) return slug + } + return null } function rememberOpenCodeWorkspaceIdForSession(instanceId: string, sessionId: string, workspaceId: string): void { @@ -126,10 +132,18 @@ async function getOpenCodeWorkspaceIdForWorktree(instanceId: string, slug: strin async function getOpenCodeWorkspaceIdForSession(instanceId: string, sessionId: string): Promise { const cached = getCachedOpenCodeWorkspaceIdForSession(instanceId, sessionId) if (cached) return cached + const { getWorktreeSlugForSession } = await import("./worktrees") const slug = getWorktreeSlugForSession(instanceId, sessionId) return getOpenCodeWorkspaceIdForWorktree(instanceId, slug) } +async function getWorktreeSlugForOpenCodeWorkspaceId(instanceId: string, workspaceId: string): Promise { + const cached = getCachedWorktreeSlugForOpenCodeWorkspaceId(instanceId, workspaceId) + if (cached) return cached + await syncOpenCodeWorkspaces(instanceId) + return getCachedWorktreeSlugForOpenCodeWorkspaceId(instanceId, workspaceId) +} + function clearOpenCodeWorkspaceCache(instanceId: string): void { workspaceSyncs.delete(instanceId) workspaceIdByWorktreeSlug.delete(instanceId) @@ -154,8 +168,10 @@ export { clearOpenCodeWorkspaceCache, getCachedOpenCodeWorkspaceIdForSession, getCachedOpenCodeWorkspaceIdForWorktree, + getCachedWorktreeSlugForOpenCodeWorkspaceId, getOpenCodeWorkspaceIdForSession, getOpenCodeWorkspaceIdForWorktree, + getWorktreeSlugForOpenCodeWorkspaceId, forgetOpenCodeWorkspaceIdForSession, rememberOpenCodeWorkspaceIdForSession, reloadOpenCodeWorkspaces, diff --git a/packages/ui/src/stores/permission-lifecycle.test.ts b/packages/ui/src/stores/permission-lifecycle.test.ts index 8bbc576da..fa7c4ec07 100644 --- a/packages/ui/src/stores/permission-lifecycle.test.ts +++ b/packages/ui/src/stores/permission-lifecycle.test.ts @@ -24,11 +24,11 @@ import { setSessions } from "./session-state" const instanceIds: string[] = [] const originalCreateClient = sdkManager.createClient -function addTestInstance(id: string, client: OpencodeClient): void { +function addTestInstance(id: string, client: OpencodeClient, folder = "/workspace"): void { instanceIds.push(id) addInstance({ id, - folder: "/workspace", + folder, port: 1, pid: 1, proxyPath: `/workspaces/${id}/instance`, @@ -176,6 +176,32 @@ test("missing-parent interruptions use authoritative list routing", async () => assert.deepEqual(questionReplies, [{ requestID: "question", workspace: "workspace-feature", answers: [["answer"]] }]) }) +test("missing-parent interruptions recognize a WSL-native root location", async () => { + const permissionReplies: unknown[] = [] + const questionReplies: unknown[] = [] + const client = { + session: { list: async () => ({ data: [{ id: "child", parentID: "missing", directory: "/mnt/c/repo" }] }) }, + permission: { reply: async (parameters: unknown) => { permissionReplies.push(parameters); return { data: true } } }, + question: { reply: async (parameters: unknown) => { questionReplies.push(parameters); return { data: true } } }, + v2: { + session: { + permission: { reply: async () => ({ data: true }) }, + question: { reply: async () => ({ data: true }) }, + }, + }, + } as unknown as OpencodeClient + sdkManager.createClient = (() => client) as typeof sdkManager.createClient + addTestInstance("wsl-root-interruptions", client, String.raw`C:\repo`) + addPermissionToQueue("wsl-root-interruptions", { id: "permission", sessionID: "child" } as never, "legacy") + addQuestionToQueue("wsl-root-interruptions", { id: "question", sessionID: "child", questions: [] } as never, "legacy") + + await sendPermissionResponse("wsl-root-interruptions", "child", "permission", "once") + await sendQuestionReply("wsl-root-interruptions", "child", "question", [["answer"]]) + + assert.deepEqual(permissionReplies, [{ requestID: "permission", reply: "once" }]) + assert.deepEqual(questionReplies, [{ requestID: "question", answers: [["answer"]] }]) +}) + test("pending request sync cannot erase newer SSE mutations", async () => { const newPermission = { id: "new-permission", sessionID: "session", permission: "edit", patterns: ["*"], metadata: {}, diff --git a/packages/ui/src/stores/session-api.ts b/packages/ui/src/stores/session-api.ts index 39779a422..74dee986a 100644 --- a/packages/ui/src/stores/session-api.ts +++ b/packages/ui/src/stores/session-api.ts @@ -91,7 +91,7 @@ import { } from "./session-list-options" import { mergeFetchedSessionRuntimeState, resolveAuthoritativeGenerationRecovery } from "./session-generation-recovery" import { normalizeWorkspacePath } from "./app-session-reconciliation" -import { withSessionWorkspace } from "./session-worktree-binding" +import { getSessionWorktreeSlug, withSessionWorkspace } from "./session-worktree-binding" import { getSessionLocationEpoch, markAuthoritativeSessionLocation, @@ -874,8 +874,10 @@ async function createSession(instanceId: string, agent?: string): Promise ( - createAtLocation(workspace, getWorktreeSlugForSession(instanceId, activeId), activeId) + ? await withSessionWorkspace(instanceId, activeId, async (workspace) => createAtLocation( + workspace, + await getSessionWorktreeSlug(instanceId, activeId, workspace.workspace), + activeId, )) : await createAtLocation({}, "root") diff --git a/packages/ui/src/stores/session-worktree-binding.test.ts b/packages/ui/src/stores/session-worktree-binding.test.ts index c93d384fe..051b77628 100644 --- a/packages/ui/src/stores/session-worktree-binding.test.ts +++ b/packages/ui/src/stores/session-worktree-binding.test.ts @@ -75,6 +75,8 @@ async function setup(instanceId: string, options: { status?: () => Promise get?: (parameters: Record) => Promise worktrees?: WorktreeDescriptor[] | null + folder?: string + openCodeWorkspaces?: Array<{ id: string; directory: string }> } = {}) { const client = { session: { @@ -91,12 +93,12 @@ async function setup(instanceId: string, options: { experimental: { workspace: { async syncList() { return { data: [] } }, - async list() { return { data: [{ id: "workspace-feature", directory: "/repo-feature" }] } }, + async list() { return { data: options.openCodeWorkspaces ?? [{ id: "workspace-feature", directory: "/repo-feature" }] } }, }, }, } 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 }) + addInstance({ id: instanceId, folder: options.folder ?? "/repo", port: 0, pid: 0, proxyPath: "", status: "ready", client }) const originalMove = serverApi.moveWorktreeSessionFamily const originalReadMap = serverApi.readWorktreeMap @@ -559,6 +561,100 @@ describe("session worktree binding", () => { } }) + it("routes and creates from an authoritative WSL feature workspace by workspace ID", async () => { + const instanceId = "wsl-native-feature" + const abortCalls: Array> = [] + const createCalls: Array> = [] + const cleanup = await setup(instanceId, { + folder: String.raw`C:\repo`, + worktrees: [ + { slug: "root", directory: String.raw`C:\repo`, kind: "root" }, + { slug: "feature", directory: String.raw`C:\repo-feature`, kind: "worktree" }, + ], + openCodeWorkspaces: [{ id: "workspace-feature", directory: "/mnt/c/repo-feature" }], + abort: async (parameters) => { abortCalls.push(parameters); return { data: true } }, + create: async (parameters) => { + createCalls.push(parameters) + return { data: { + id: "created", directory: "/mnt/c/repo-feature", workspaceID: "workspace-feature", + title: "Created", version: "1", time: { created: 1, updated: 1 }, + } } + }, + }) + const root = { + ...session(instanceId, "root-session", null), + directory: "/mnt/c/repo-feature", + workspaceId: "workspace-feature", + } + const child = { + ...session(instanceId, "child-session", root.id), + directory: root.directory, + workspaceId: root.workspaceId, + time: { created: 1, updated: 2 }, + } + setFamily(instanceId, root, child) + setActiveParentSession(instanceId, root.id) + + try { + await abortSession(instanceId, child.id) + const created = await createSession(instanceId) + + assert.deepEqual(abortCalls, [{ sessionID: child.id, workspace: "workspace-feature" }]) + assert.deepEqual(createCalls, [{ workspace: "workspace-feature" }]) + assert.equal(created.workspaceId, "workspace-feature") + assert.equal(getWorktreeSlugForSession(instanceId, root.id), "feature") + } finally { + cleanup() + } + }) + + it("routes WSL roots and legacy directory-only WSL features without weakening POSIX case", async () => { + const rootInstanceId = "wsl-native-root" + const rootAbortCalls: Array> = [] + const rootCleanup = await setup(rootInstanceId, { + folder: String.raw`C:\repo`, + worktrees: [ + { slug: "root", directory: String.raw`C:\repo`, kind: "root" }, + { slug: "feature", directory: String.raw`C:\repo-feature`, kind: "worktree" }, + ], + abort: async (parameters) => { rootAbortCalls.push(parameters); return { data: true } }, + }) + const movedRoot = { ...session(rootInstanceId, "root-session", null), directory: "/mnt/c/repo" } + const movedChild = { ...session(rootInstanceId, "child-session", movedRoot.id), directory: movedRoot.directory } + setFamily(rootInstanceId, movedRoot, movedChild) + + const featureInstanceId = "wsl-legacy-feature" + const featureAbortCalls: Array> = [] + const featureCleanup = await setup(featureInstanceId, { + folder: String.raw`C:\repo`, + worktrees: [ + { slug: "root", directory: String.raw`C:\repo`, kind: "root" }, + { slug: "feature", directory: String.raw`C:\repo-feature`, kind: "worktree" }, + ], + openCodeWorkspaces: [{ id: "workspace-feature", directory: "/mnt/c/repo-feature" }], + abort: async (parameters) => { featureAbortCalls.push(parameters); return { data: true } }, + }) + const legacyRoot = { + ...session(featureInstanceId, "root-session", null, { codenomad: { version: 1, worktreeSlug: "feature" } }), + directory: "/mnt/c/repo-feature", + } + const legacyChild = { ...session(featureInstanceId, "child-session", legacyRoot.id), directory: legacyRoot.directory } + setFamily(featureInstanceId, legacyRoot, legacyChild) + + try { + await abortSession(rootInstanceId, movedChild.id) + await abortSession(featureInstanceId, legacyChild.id) + + assert.deepEqual(rootAbortCalls, [{ sessionID: movedChild.id }]) + assert.equal(getWorktreeSlugForSession(rootInstanceId, movedRoot.id), "root") + assert.deepEqual(featureAbortCalls, [{ sessionID: legacyChild.id, workspace: "workspace-feature" }]) + assert.equal(getWorktreeSlugForSession(featureInstanceId, legacyRoot.id), "feature") + } finally { + rootCleanup() + featureCleanup() + } + }) + it("routes busy controls through native state without requesting a move", async () => { const instanceId = "busy-control" const abortCalls: Array> = [] diff --git a/packages/ui/src/stores/session-worktree-binding.ts b/packages/ui/src/stores/session-worktree-binding.ts index 993fc0bc6..626839917 100644 --- a/packages/ui/src/stores/session-worktree-binding.ts +++ b/packages/ui/src/stores/session-worktree-binding.ts @@ -1,9 +1,14 @@ import { tGlobal } from "../lib/i18n" import { serverApi } from "../lib/api-client" -import { findWorktreeSlugForDirectory, workspaceDirectoriesEqual } from "./opencode-workspace-matching" +import { + findWorktreeSlugForDirectory, + inventoryAndNativeDirectoriesEqual, + workspaceDirectoriesEqual, +} from "./opencode-workspace-matching" import { forgetOpenCodeWorkspaceIdForSession, getOpenCodeWorkspaceIdForWorktree, + getWorktreeSlugForOpenCodeWorkspaceId, rememberOpenCodeWorkspaceIdForSession, } from "./opencode-workspaces" import { getDescendantSessions, getSessionRoot, sessions, withSession } from "./session-state" @@ -260,15 +265,19 @@ async function currentOrLegacySessionWorkspacePayload( if (members.some((member) => Boolean(member.workspaceId))) { throw new Error(tGlobal("instanceShell.worktree.moveFailed")) } - const slug = getWorktreeSlugForSession(instanceId, root.id) - if (!slug) throw new Error(tGlobal("instanceShell.worktree.moveFailed")) - const target = getWorktrees(instanceId).find((worktree) => worktree.slug === slug) - const targetDirectory = slug === "root" ? target?.directory ?? await rootDirectory(instanceId) : target?.directory const familyLocationMatches = members.every((member) => ( workspaceDirectoriesEqual(member.directory, root.directory) )) + const inventoryRootDirectory = await rootDirectory(instanceId) + if (familyLocationMatches && root.directory + && !workspaceDirectoriesEqual(inventoryRootDirectory, root.directory) + && inventoryAndNativeDirectoriesEqual(inventoryRootDirectory, root.directory)) return {} + const slug = getWorktreeSlugForSession(instanceId, root.id) + if (!slug) throw new Error(tGlobal("instanceShell.worktree.moveFailed")) + const target = getWorktrees(instanceId).find((worktree) => worktree.slug === slug) + const targetDirectory = slug === "root" ? target?.directory ?? inventoryRootDirectory : target?.directory if (familyLocationMatches && slug === "root" - && (!root.directory || workspaceDirectoriesEqual(root.directory, targetDirectory))) return {} + && (!root.directory || inventoryAndNativeDirectoriesEqual(targetDirectory, root.directory))) return {} return null } @@ -293,14 +302,21 @@ async function currentSessionWorkspacePayload(instanceId: string, sessionId: str throw new Error(tGlobal("instanceShell.worktree.moveFailed")) } if (session.workspaceId) return { workspace: session.workspaceId } - if (!session.directory || workspaceDirectoriesEqual(session.directory, await rootDirectory(instanceId))) return {} + if (!session.directory || inventoryAndNativeDirectoriesEqual(await rootDirectory(instanceId), session.directory)) return {} const slug = findWorktreeSlugForDirectory(getWorktrees(instanceId), session.directory) + ?? getWorktreeSlugForSession(instanceId, sessionId) if (!slug || slug === "root") throw new Error(tGlobal("instanceShell.worktree.moveFailed")) const workspace = await getOpenCodeWorkspaceIdForWorktree(instanceId, slug) if (!workspace) throw locationError(slug) return { workspace } } +async function getSessionWorktreeSlug(instanceId: string, sessionId: string, workspace?: string): Promise { + if (!workspace) return "root" + return await getWorktreeSlugForOpenCodeWorkspaceId(instanceId, workspace) + ?? getWorktreeSlugForSession(instanceId, sessionId) +} + function withSessionWorkspace( instanceId: string, sessionId: string, @@ -342,6 +358,7 @@ async function requireWorktreeWorkspacePayload(instanceId: string, slug: string) } export { + getSessionWorktreeSlug, moveSessionToWorktree, requireWorktreeWorkspacePayload, withSessionWorkspace, diff --git a/packages/ui/src/stores/worktrees.ts b/packages/ui/src/stores/worktrees.ts index bef8b5ee1..9b39d0b3d 100644 --- a/packages/ui/src/stores/worktrees.ts +++ b/packages/ui/src/stores/worktrees.ts @@ -5,7 +5,8 @@ import { getSessionRoot, sessions } from "./session-state" import { getLogger } from "../lib/logger" import { getCodeNomadSessionMetadata } from "./session-metadata" import type { WorktreeReadyEvent } from "../lib/sse-manager" -import { findWorktreeSlugForDirectory } from "./opencode-workspace-matching" +import { findWorktreeSlugForDirectory, inventoryAndNativeDirectoriesEqual } from "./opencode-workspace-matching" +import { getCachedWorktreeSlugForOpenCodeWorkspaceId } from "./opencode-workspaces" import { tGlobal } from "../lib/i18n" import { messageStoreBus } from "./message-v2/bus" import { getInstanceLifecycleGeneration, isInstanceLifecycleCurrent } from "./instance-lifecycle-authority" @@ -478,17 +479,22 @@ function getParentSessionId(instanceId: string, sessionId: string): string { function getWorktreeSlugForParentSession(instanceId: string, parentSessionId: string): string { const session = sessions().get(instanceId)?.get(parentSessionId) + if (session?.workspaceId) { + return getCachedWorktreeSlugForOpenCodeWorkspaceId(instanceId, session.workspaceId) ?? "" + } const nativeSlug = findWorktreeSlugForDirectory(getWorktrees(instanceId), session?.directory) - if (session?.workspaceId) return nativeSlug ?? "" const metadataSlug = getCodeNomadSessionMetadata(instanceId, parentSessionId).worktreeSlug if (metadataSlug) { return normalizeWorktreeSlug(instanceId, metadataSlug) } + if (nativeSlug) return normalizeWorktreeSlug(instanceId, nativeSlug) + const root = getWorktrees(instanceId).find((worktree) => worktree.slug === "root") + if (root && inventoryAndNativeDirectoriesEqual(root.directory, session?.directory)) return "root" + const map = getWorktreeMap(instanceId) const candidate = map.parentSessionWorktreeSlug[parentSessionId] - ?? nativeSlug ?? "root" return normalizeWorktreeSlug(instanceId, candidate) } From 0a033b4cd7d70f16b9b9f0a031fe2e856e49682f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Tue, 11 Aug 2026 10:37:51 +0200 Subject: [PATCH 20/20] fix(worktrees): preserve control and native workspace authority Allow permission, question, and abort controls to join an active prompt lane even when an exclusive repository mutation is queued. Disconnecting workspace creators now release only their own ownership while shared launches remain available to other callers. Expose server-resolved OpenCode-native worktree directories and use them for workspace discovery and reactive session routing across default DrvFS, custom automounts, and WSL UNC repositories. Cover control admission ordering, authoritative abort routing, request cancellation, native workspace-list scoping, and reactive UI mapping. Validated with 423 passing server tests, 72 focused browser tests, and root typecheck. --- packages/server/src/api-types.ts | 2 + .../src/permissions/opencode-replier.test.ts | 14 +- .../src/permissions/opencode-replier.ts | 4 +- packages/server/src/server/http-server.ts | 15 +- .../src/server/instance-mutation-gate.test.ts | 34 ++++ .../src/server/instance-mutation-gate.ts | 10 +- .../server/instance-mutation-proxy.test.ts | 172 +++++++----------- .../src/server/instance-mutation-proxy.ts | 13 +- .../instance-workspace-list-proxy.test.ts | 82 +++++++++ .../src/server/routes/worktrees.test.ts | 40 +++- .../server/src/server/routes/worktrees.ts | 6 +- .../server/src/workspaces/manager.test.ts | 46 +++-- packages/server/src/workspaces/manager.ts | 47 +++-- packages/ui/src/stores/instances.ts | 8 +- .../src/stores/opencode-workspace-matching.ts | 32 +--- .../ui/src/stores/opencode-workspaces.test.ts | 40 +++- packages/ui/src/stores/opencode-workspaces.ts | 27 ++- .../src/stores/permission-lifecycle.test.ts | 11 ++ .../stores/session-worktree-binding.test.ts | 56 +++++- .../ui/src/stores/session-worktree-binding.ts | 15 +- packages/ui/src/stores/worktrees.ts | 4 +- 21 files changed, 465 insertions(+), 213 deletions(-) create mode 100644 packages/server/src/server/instance-workspace-list-proxy.test.ts diff --git a/packages/server/src/api-types.ts b/packages/server/src/api-types.ts index 347f5118e..87662f723 100644 --- a/packages/server/src/api-types.ts +++ b/packages/server/src/api-types.ts @@ -94,6 +94,8 @@ export interface WorktreeDescriptor { slug: string /** Absolute directory path on the server host. */ directory: string + /** Absolute directory as seen by the OpenCode process. */ + nativeDirectory?: string kind: WorktreeKind /** Optional VCS branch name when available. */ branch?: string diff --git a/packages/server/src/permissions/opencode-replier.test.ts b/packages/server/src/permissions/opencode-replier.test.ts index c3ef6705e..2586f4d89 100644 --- a/packages/server/src/permissions/opencode-replier.test.ts +++ b/packages/server/src/permissions/opencode-replier.test.ts @@ -21,9 +21,8 @@ const legacyReply: AutoAcceptReply = { function createHarness( sessions: Array<{ id: string; directory?: string; workspaceID?: string }>, - mutationGate: Pick = { - enter: async () => () => {}, - acquireExclusive: async () => () => {}, + mutationGate: Pick = { + enterControl: async () => () => {}, }, replyWait: Promise = Promise.resolve(), workspaces: Array<{ id: string; directory: string }> = [{ id: "workspace", directory: "/repo-workspace" }], @@ -168,7 +167,7 @@ describe("OpenCode permission replier", () => { } }) - it("answers a synchronous mutation while preserving instance-exclusive serialization", async () => { + it("uses the control lane for a legacy reply after an exclusive operation queues behind its prompt", async () => { const gate = new InstanceMutationGate() const promptAdmission = await admitWorkspaceMutation({ gate, @@ -186,8 +185,9 @@ describe("OpenCode permission replier", () => { let finishReply!: () => void const replyWait = new Promise((resolve) => { finishReply = resolve }) const harness = createHarness([{ id: "session", directory: "/repo" }], gate, replyWait) + let exclusiveStarted = false + const exclusive = gate.exclusive("instance", async () => { exclusiveStarted = true }) const reply = harness.replier(legacyReply) - let exclusive: Promise | undefined try { for (let attempt = 0; attempt < 20 && harness.legacyCalls.length === 0; attempt += 1) { @@ -195,8 +195,6 @@ describe("OpenCode permission replier", () => { } assert.equal(harness.legacyCalls.length, 1, "legacy reply deadlocked behind its prompt's repository admission") - let exclusiveStarted = false - exclusive = gate.exclusive("instance", async () => { exclusiveStarted = true }) await promptAdmission.release() await new Promise((resolve) => setImmediate(resolve)) assert.equal(exclusiveStarted, false) @@ -209,7 +207,7 @@ describe("OpenCode permission replier", () => { finishReply() await promptAdmission.release() await reply.catch(() => undefined) - await exclusive?.catch(() => undefined) + await exclusive.catch(() => undefined) } }) diff --git a/packages/server/src/permissions/opencode-replier.ts b/packages/server/src/permissions/opencode-replier.ts index 638e6ee12..08332896a 100644 --- a/packages/server/src/permissions/opencode-replier.ts +++ b/packages/server/src/permissions/opencode-replier.ts @@ -9,7 +9,7 @@ import { resolveNativeSessionLocation } from "../workspaces/native-session-locat interface OpencodeReplierDeps { workspaceManager: WorkspaceManager logger: Logger - mutationGate: Pick + mutationGate: Pick } /** @@ -29,7 +29,7 @@ export function createOpencodePermissionReplier( ) => OpencodeClient | null = createInstanceClient, ): PermissionReplier { return async (reply: AutoAcceptReply) => { - const releaseMutation = await deps.mutationGate.enter(reply.instanceId) + const releaseMutation = await deps.mutationGate.enterControl(reply.instanceId) try { const workspace = deps.workspaceManager.get(reply.instanceId) if (!workspace) throw new Error(`Yolo: instance ${reply.instanceId} is not ready`) diff --git a/packages/server/src/server/http-server.ts b/packages/server/src/server/http-server.ts index 7c6ef3914..0f3953dd8 100644 --- a/packages/server/src/server/http-server.ts +++ b/packages/server/src/server/http-server.ts @@ -558,7 +558,7 @@ function setupPreviewWebSocketProxy(app: FastifyInstance, deps: PreviewWebSocket }) } -function registerInstanceProxyRoutes(app: FastifyInstance, deps: InstanceProxyDeps) { +export function registerInstanceProxyRoutes(app: FastifyInstance, deps: InstanceProxyDeps) { app.register(async (instance) => { instance.removeAllContentTypeParsers() instance.addContentTypeParser("*", (req, body, done) => done(null, body)) @@ -781,7 +781,18 @@ async function proxyWorkspaceRequest(args: { const normalizedSuffix = normalizeInstanceSuffix(args.pathSuffix) const queryIndex = (request.raw.url ?? "").indexOf("?") - const search = queryIndex >= 0 ? (request.raw.url ?? "").slice(queryIndex) : "" + let search = queryIndex >= 0 ? (request.raw.url ?? "").slice(queryIndex) : "" + if (request.method === "GET" && normalizedSuffix === "/experimental/workspace") { + try { + const scope = new URLSearchParams(search) + scope.set("directory", await workspaceManager.resolveInstanceDirectory(workspaceId)) + search = `?${scope.toString()}` + } catch (error) { + logger.error({ err: error, workspaceId }, "Failed to resolve native workspace-list scope") + reply.code(502).send({ error: "Workspace instance proxy failed" }) + return + } + } let targetUrl = `http://${INSTANCE_PROXY_HOST}:${port}${normalizedSuffix}${search}` const instanceAuthHeader = workspaceManager.getInstanceAuthorizationHeader(workspaceId) diff --git a/packages/server/src/server/instance-mutation-gate.test.ts b/packages/server/src/server/instance-mutation-gate.test.ts index 3e895b305..5529ee4ac 100644 --- a/packages/server/src/server/instance-mutation-gate.test.ts +++ b/packages/server/src/server/instance-mutation-gate.test.ts @@ -73,6 +73,40 @@ describe("InstanceMutationGate", () => { assert.equal(admitted, true) }) + it("admits control work into an active shared lane ahead of a queued exclusive operation", async () => { + const gate = new InstanceMutationGate() + const releasePrompt = await gate.enter("workspace") + let exclusiveStarted = false + const exclusive = gate.exclusive("workspace", async () => { exclusiveStarted = true }) + + const releaseControl = await gate.enterControl("workspace") + releasePrompt() + await Promise.resolve() + assert.equal(exclusiveStarted, false) + releaseControl() + await exclusive + assert.equal(exclusiveStarted, true) + }) + + it("blocks control work while an exclusive operation is active", async () => { + const gate = new InstanceMutationGate() + let finishExclusive!: () => void + let markStarted!: () => void + const held = new Promise((resolve) => { finishExclusive = resolve }) + const started = new Promise((resolve) => { markStarted = resolve }) + const exclusive = gate.exclusive("workspace", async () => { markStarted(); await held }) + await started + + let admitted = false + const control = gate.enterControl("workspace").then((release) => { admitted = true; release() }) + await Promise.resolve() + assert.equal(admitted, false) + finishExclusive() + await exclusive + await control + assert.equal(admitted, true) + }) + it("removes an aborted admission waiter", async () => { const gate = new InstanceMutationGate() let releaseExclusive!: () => void diff --git a/packages/server/src/server/instance-mutation-gate.ts b/packages/server/src/server/instance-mutation-gate.ts index b2f542834..47eb59e3d 100644 --- a/packages/server/src/server/instance-mutation-gate.ts +++ b/packages/server/src/server/instance-mutation-gate.ts @@ -61,9 +61,17 @@ export class InstanceMutationGate { private readonly states = new Map() async enter(instanceId: string, signal?: AbortSignal): Promise<() => void> { + return this.enterShared(instanceId, false, signal) + } + + async enterControl(instanceId: string, signal?: AbortSignal): Promise<() => void> { + return this.enterShared(instanceId, true, signal) + } + + private async enterShared(instanceId: string, joinActive: boolean, signal?: AbortSignal): Promise<() => void> { const state = this.state(instanceId) signal?.throwIfAborted() - while (state.exclusive) await this.changed(state, signal) + while (state.exclusive && (!joinActive || state.active === 0)) await this.changed(state, signal) signal?.throwIfAborted() state.active += 1 let released = false diff --git a/packages/server/src/server/instance-mutation-proxy.test.ts b/packages/server/src/server/instance-mutation-proxy.test.ts index 4a525e465..bda38f884 100644 --- a/packages/server/src/server/instance-mutation-proxy.test.ts +++ b/packages/server/src/server/instance-mutation-proxy.test.ts @@ -1,11 +1,6 @@ import assert from "node:assert/strict" import { EventEmitter } from "node:events" -import { mkdtemp, rm } from "node:fs/promises" -import { createServer } from "node:http" -import os from "node:os" -import path from "node:path" import { describe, it } from "node:test" -import { request as requestUpstream } from "undici" import { InstanceMutationGate } from "./instance-mutation-gate" import { admitWorkspaceMutation, @@ -14,7 +9,6 @@ import { ProxyMutationTracker, WorkspaceMutationConflictError, } from "./instance-mutation-proxy" -import { resolveRepositoryMutationKey } from "../workspaces/workspace-identity" describe("openUpstreamMutation", () => { it("has no unconditional mutation deadline", async () => { @@ -177,7 +171,7 @@ describe("admitWorkspaceMutation", () => { const state = { port: 4321, hostDirectory: process.cwd(), nativeRootDirectory: "/repo" } const workspaces = [{ id: "old", directory: "/old" }, { id: "new", directory: "/new" }] - it("allowlists only permission and question reply controls", () => { + it("allowlists only permission and question replies, rejects, and authoritative session abort", () => { for (const suffix of [ "permission/request/reply", "question/request/reply", @@ -186,12 +180,16 @@ describe("admitWorkspaceMutation", () => { "api/session/session/permission/request/reply", "api/session/session/question/request/reply", "api/session/session/question/request/reject", + "session/session/abort", + "api/session/session/abort", ]) assert.equal(isInstanceControlMutation("POST", suffix), true, suffix) for (const [method, suffix] of [ ["GET", "permission/request/reply"], ["POST", "session/session/prompt"], ["POST", "permission/request/reply/extra"], ["POST", "api/session/session/permission/request/reject"], + ["POST", "session/abort"], + ["POST", "abort"], ]) assert.equal(isInstanceControlMutation(method, suffix), false, `${method} ${suffix}`) }) @@ -226,6 +224,7 @@ describe("admitWorkspaceMutation", () => { const admitted = await admitWorkspaceMutation({ gate: new InstanceMutationGate(), workspaceId: "instance", + method: "POST", pathSuffix: "session/session-id/abort", rawUrl: "/session/session-id/abort?workspace=old", resolveWorkspace: async () => state, @@ -292,24 +291,44 @@ describe("admitWorkspaceMutation", () => { admitted.release() }) - it("takes repository read admission shared by sibling instances", async () => { + it("routes abort authoritatively without reacquiring the repository lock held by its prompt", async () => { const gate = new InstanceMutationGate() - const admitted = await admitWorkspaceMutation({ + const prompt = await admitWorkspaceMutation({ gate, - workspaceId: "instance-a", - pathSuffix: "session/session-id/abort", - rawUrl: "/session/session-id/abort", + workspaceId: "instance", + method: "POST", + pathSuffix: "session/session-id/prompt", resolveWorkspace: async () => state, loadSessions: async () => [{ id: "session-id", directory: state.nativeRootDirectory }], loadWorkspaces: async () => workspaces, }) - let exclusiveStarted = false - const sibling = gate.exclusive(await resolveRepositoryMutationKey(process.cwd()), async () => { exclusiveStarted = true }) - await Promise.resolve() - assert.equal(exclusiveStarted, false) - admitted.release() - await sibling - assert.equal(exclusiveStarted, true) + const controller = new AbortController() + let abort: Awaited> | undefined + let abortError: unknown + const pendingAbort = admitWorkspaceMutation({ + gate, + workspaceId: "instance", + method: "POST", + pathSuffix: "session/session-id/abort", + rawUrl: "/session/session-id/abort?workspace=old", + signal: controller.signal, + resolveWorkspace: async () => state, + loadSessions: async () => [{ id: "session-id", workspaceID: "new" }], + loadWorkspaces: async () => workspaces, + }).then((value) => { abort = value }, (error) => { abortError = error }) + try { + await new Promise((resolve) => setImmediate(resolve)) + assert.ifError(abortError) + assert.ok(abort, "abort reacquired the repository lock held by its prompt") + assert.equal(abort.search, "?workspace=new&directory=%2Fnew") + await abort.release() + abort = undefined + } finally { + controller.abort(new Error("test cleanup")) + await pendingAbort + await abort?.release() + await prompt.release() + } }) it("releases instance admission while repository cleanup keeps retrying", async () => { @@ -318,6 +337,7 @@ describe("admitWorkspaceMutation", () => { enter: async (key: string) => key === "instance" ? () => { instanceReleased = true } : () => {}, + enterControl: async () => () => {}, acquireExclusive: async () => async () => { repositoryReleaseAttempts += 1 if (repositoryReleaseAttempts <= 4) throw new Error("transient repository release failure") @@ -345,6 +365,7 @@ describe("admitWorkspaceMutation", () => { let repositoryReleaseAttempts = 0 const gate = { enter: async () => () => { instanceReleased = true }, + enterControl: async () => () => {}, acquireExclusive: async () => async () => { repositoryReleaseAttempts += 1 if (repositoryReleaseAttempts === 1) throw new Error("transient repository release failure") @@ -363,105 +384,44 @@ describe("admitWorkspaceMutation", () => { assert.ok(repositoryReleaseAttempts >= 2) }) - it("lets a reply complete an actual pending synchronous prompt without weakening other mutations", async () => { - const directory = await mkdtemp(path.join(os.tmpdir(), "codenomad-control-lane-")) + it("admits a permission reply after move or deletion queues behind its prompt", async () => { const gate = new InstanceMutationGate() - const controller = new AbortController() - const timeout = setTimeout(() => controller.abort(new Error("control lane regression timed out")), 5_000) - let promptResponse: import("node:http").ServerResponse | undefined - let markPromptStarted!: () => void - const promptStarted = new Promise((resolve) => { markPromptStarted = resolve }) - const server = createServer((request, response) => { - if (request.url === "/session/session-id/prompt") { - promptResponse = response - markPromptStarted() - return - } - if (request.url === "/permission/request/reply") { - response.end("replied") - promptResponse?.end("prompt complete") - return - } - response.writeHead(404).end() - }) - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)) - const address = server.address() - if (!address || typeof address === "string") throw new Error("Prompt test server did not bind") - const workspace = { port: address.port, hostDirectory: directory, nativeRootDirectory: "/repo" } - const admission = (pathSuffix: string, sessionContext?: string) => admitWorkspaceMutation({ + const admission = (pathSuffix: string, sessionContext?: string, signal?: AbortSignal) => admitWorkspaceMutation({ gate, workspaceId: "instance", method: "POST", pathSuffix, sessionContext, - signal: controller.signal, - resolveWorkspace: async () => workspace, + signal, + resolveWorkspace: async () => state, loadSessions: async () => [{ id: "session-id", directory: "/repo" }], loadWorkspaces: async () => [], }) - let promptAdmission: Awaited> | undefined - let replyAdmission: Awaited> | undefined - let promptRequest: Promise | undefined - let markPromptReleased!: () => void - const promptReleased = new Promise((resolve) => { markPromptReleased = resolve }) + const prompt = await admission("session/session-id/prompt") + let exclusiveStarted = false + const exclusive = gate.exclusive("instance", async () => { exclusiveStarted = true }) + const controller = new AbortController() + let reply: Awaited> | undefined + let replyError: unknown + const pendingReply = admission("permission/request/reply", "session-id", controller.signal) + .then((value) => { reply = value }, (error) => { replyError = error }) try { - promptAdmission = await admission("session/session-id/prompt") - promptRequest = openUpstreamMutation({ - start: (signal) => requestUpstream(`http://127.0.0.1:${address.port}/session/session-id/prompt`, { - method: "POST", - body: "{}", - signal, - }), - release: async () => { - try { - await promptAdmission!.release() - } finally { - markPromptReleased() - } - }, - downstreamSignal: controller.signal, - }) - await promptStarted - - const unrelatedController = new AbortController() - let unrelatedAdmitted = false - const unrelated = admitWorkspaceMutation({ - gate, - workspaceId: "instance", - method: "POST", - pathSuffix: "session/session-id/abort", - signal: unrelatedController.signal, - resolveWorkspace: async () => workspace, - loadSessions: async () => [{ id: "session-id", directory: "/repo" }], - loadWorkspaces: async () => [], - }).then((value) => { unrelatedAdmitted = true; return value }) await new Promise((resolve) => setImmediate(resolve)) - assert.equal(unrelatedAdmitted, false) - unrelatedController.abort(new Error("expected repository admission block")) - await assert.rejects(unrelated, /expected repository admission block/) - - replyAdmission = await admission("permission/request/reply", "session-id") - const reply = await requestUpstream(`http://127.0.0.1:${address.port}/permission/request/reply`, { - method: "POST", - body: "{}", - signal: controller.signal, - }) - assert.equal(await reply.body.text(), "replied") - await replyAdmission.release() - replyAdmission = undefined - const promptResult = await promptRequest - assert.equal(await promptResult.body.text(), "prompt complete") + assert.ifError(replyError) + assert.ok(reply, "permission reply did not join the prompt lane") + await prompt.release() + await Promise.resolve() + assert.equal(exclusiveStarted, false) + await reply.release() + reply = undefined + await exclusive + assert.equal(exclusiveStarted, true) } finally { - clearTimeout(timeout) - controller.abort() - if (promptResponse && !promptResponse.writableEnded) promptResponse.end() - await promptRequest?.catch(() => undefined) - await replyAdmission?.release() - if (promptRequest) await promptReleased - else await promptAdmission?.release() - server.closeAllConnections() - await new Promise((resolve) => server.close(() => resolve())) - await rm(directory, { recursive: true, force: true }) + controller.abort(new Error("test cleanup")) + await pendingReply + await reply?.release() + await prompt.release() + await exclusive } }) diff --git a/packages/server/src/server/instance-mutation-proxy.ts b/packages/server/src/server/instance-mutation-proxy.ts index 5cc87d01e..840bb3637 100644 --- a/packages/server/src/server/instance-mutation-proxy.ts +++ b/packages/server/src/server/instance-mutation-proxy.ts @@ -15,7 +15,7 @@ export function isInstanceControlMutation(method: string | undefined, pathSuffix const path = `/${(pathSuffix ?? "").replace(/^\/+|\/+$/g, "")}` return /^\/(?:permission\/[^/]+\/reply|question\/[^/]+\/(?:reply|reject))$/.test(path) || /^\/session\/[^/]+\/permissions\/[^/]+$/.test(path) - || /^\/api\/session\/[^/]+\/(?:permission\/[^/]+\/reply|question\/[^/]+\/(?:reply|reject))$/.test(path) + || /^\/(?:api\/)?session\/[^/]+\/(?:abort|permission\/[^/]+\/reply|question\/[^/]+\/(?:reply|reject))$/.test(path) } export class WorkspaceMutationConflictError extends Error { @@ -67,7 +67,7 @@ async function releaseUntilSuccessful(release: () => void | Promise): Prom } export async function admitWorkspaceMutation(params: { - gate: Pick + gate: Pick workspaceId: string method?: string pathSuffix?: string @@ -78,15 +78,18 @@ export async function admitWorkspaceMutation(params: { sessionContext?: string signal?: AbortSignal }): Promise Promise }> { - const releaseInstance = await params.gate.enter(params.workspaceId, params.signal) + const control = isInstanceControlMutation(params.method, params.pathSuffix) + const releaseInstance = control + ? await params.gate.enterControl(params.workspaceId, params.signal) + : await params.gate.enter(params.workspaceId, params.signal) let releaseRepository: (() => Promise) | undefined try { const queued = await params.resolveWorkspace() if (!queued) throw new WorkspaceMutationConflictError("Workspace changed while the mutation was queued") - if (!isInstanceControlMutation(params.method, params.pathSuffix)) { + if (!control) { const repository = await acquireRepositoryMutation({ workspaceFolder: queued.hostDirectory, - gate: params.gate as InstanceMutationGate, + gate: params.gate, signal: params.signal, }) releaseRepository = repository.release diff --git a/packages/server/src/server/instance-workspace-list-proxy.test.ts b/packages/server/src/server/instance-workspace-list-proxy.test.ts new file mode 100644 index 000000000..f3673ec70 --- /dev/null +++ b/packages/server/src/server/instance-workspace-list-proxy.test.ts @@ -0,0 +1,82 @@ +import assert from "node:assert/strict" +import http from "node:http" +import { describe, it } from "node:test" +import replyFrom from "@fastify/reply-from" +import Fastify from "fastify" +import type { Logger } from "../logger" +import type { WorkspaceManager } from "../workspaces/manager" +import { InstanceMutationGate } from "./instance-mutation-gate" +import { ProxyMutationTracker } from "./instance-mutation-proxy" +import { registerInstanceProxyRoutes } from "./http-server" + +describe("instance workspace-list proxy", () => { + for (const [name, hostDirectory, nativeDirectory] of [ + ["default DrvFS", String.raw`C:\Repo`, "/mnt/c/Repo"], + ["custom DrvFS", String.raw`C:\Repo`, "/windows/c/Repo"], + ["WSL UNC /home", String.raw`\\wsl.localhost\Ubuntu\home\Dev\Repo`, "/home/Dev/Repo"], + ] as const) { + it(`scopes workspace-list GET in native coordinates for ${name}`, async () => { + await withUpstream(async (port, requests) => { + const app = Fastify({ logger: false }) + await app.register(replyFrom) + registerInstanceProxyRoutes(app, { + workspaceManager: { + get: () => ({ id: "workspace", path: hostDirectory, status: "ready" }), + getInstancePort: () => port, + getInstanceAuthorizationHeader: () => undefined, + resolveInstanceDirectory: async () => nativeDirectory, + } as unknown as WorkspaceManager, + logger: stubLogger(), + mutationGate: new InstanceMutationGate(), + proxyMutations: new ProxyMutationTracker(), + shutdownSignal: new AbortController().signal, + }) + + try { + const response = await app.inject({ + method: "GET", + url: `/workspaces/workspace/instance/experimental/workspace?directory=${encodeURIComponent(hostDirectory)}&limit=7`, + }) + assert.equal(response.statusCode, 200) + const workspaceList = new URL(requests[0], "http://127.0.0.1") + assert.equal(workspaceList.pathname, "/experimental/workspace") + assert.equal(workspaceList.searchParams.get("directory"), nativeDirectory) + assert.equal(workspaceList.searchParams.get("limit"), "7") + + await app.inject({ + method: "GET", + url: `/workspaces/workspace/instance/session?directory=${encodeURIComponent(hostDirectory)}`, + }) + const ordinaryGet = new URL(requests[1], "http://127.0.0.1") + assert.equal(ordinaryGet.searchParams.get("directory"), hostDirectory) + } finally { + await app.close() + } + }) + }) + } +}) + +async function withUpstream(operation: (port: number, requests: string[]) => Promise): Promise { + const requests: string[] = [] + const server = http.createServer((request, response) => { + requests.push(request.url ?? "") + response.writeHead(200, { "content-type": "application/json" }).end("[]") + }) + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)) + try { + const address = server.address() + if (!address || typeof address === "string") throw new Error("Missing upstream port") + await operation(address.port, requests) + } finally { + await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())) + } +} + +function stubLogger(): Logger { + const logger = { + debug() {}, error() {}, trace() {}, isLevelEnabled: () => false, + child: () => logger, + } + return logger as unknown as Logger +} diff --git a/packages/server/src/server/routes/worktrees.test.ts b/packages/server/src/server/routes/worktrees.test.ts index 927e36455..ce0633d94 100644 --- a/packages/server/src/server/routes/worktrees.test.ts +++ b/packages/server/src/server/routes/worktrees.test.ts @@ -12,7 +12,10 @@ import { registerWorktreeRoutes } from "./worktrees" import { repositoryMutationKey } from "../../workspaces/workspace-identity" import { WorktreeRollbackIncompleteError } from "../../workspaces/worktree-session-move" -function setup(options: { metadataError?: Error } = {}) { +function setup(options: { + metadataError?: Error + resolveNativeDirectory?: (directory: string, root: string) => string +} = {}) { const folder = mkdtempSync(path.join(tmpdir(), "codenomad-worktree-routes-")) execFileSync("git", ["init", "-b", "main"], { cwd: folder }) execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: folder }) @@ -37,6 +40,9 @@ function setup(options: { metadataError?: Error } = {}) { : undefined, hasRepositoryBlocker: (key: string, excludingId?: string) => key === repositoryKey && workspaces.some((workspace) => workspace.id !== excludingId), + resolveInstanceDirectory: async (_id: string, directory = folder) => ( + options.resolveNativeDirectory?.(directory, folder) ?? directory + ), } as unknown as WorkspaceManager const sessionMetadataPersistence = { hasProjectSession: async () => { @@ -68,6 +74,38 @@ function setup(options: { metadataError?: Error } = {}) { } describe("worktree mutation routes", () => { + for (const [name, nativeRoot] of [ + ["default DrvFS", "/mnt/c/Repo"], + ["custom DrvFS", "/windows/c/Repo"], + ["WSL UNC /home", "/home/Dev/Repo"], + ] as const) { + it(`lists host and native root/feature directories for ${name}`, async () => { + const test = setup({ + resolveNativeDirectory: (directory, root) => { + const relative = path.relative(root, directory).replace(/\\/g, "/") + return relative ? `${nativeRoot}/${relative}` : nativeRoot + }, + }) + try { + const created = await test.app.inject({ + method: "POST", url: "/api/workspaces/workspace/worktrees", payload: { slug: "feature" }, + }) + assert.equal(created.statusCode, 201) + const response = await test.app.inject({ method: "GET", url: "/api/workspaces/workspace/worktrees" }) + assert.equal(response.statusCode, 200) + const worktrees = response.json().worktrees as Array<{ slug: string; directory: string; nativeDirectory: string }> + const root = worktrees.find((worktree) => worktree.slug === "root") + const feature = worktrees.find((worktree) => worktree.slug === "feature") + assert.equal(root?.directory, test.folder) + assert.equal(root?.nativeDirectory, nativeRoot) + assert.equal(path.resolve(feature?.directory ?? ""), path.resolve(created.json().directory)) + assert.equal(feature?.nativeDirectory, `${nativeRoot}/.codenomad/worktrees/feature`) + } finally { + await test.close() + } + }) + } + it("gates binding writes and rejects missing non-null slugs while admitted", async () => { const test = setup() try { diff --git a/packages/server/src/server/routes/worktrees.ts b/packages/server/src/server/routes/worktrees.ts index 836c7b13e..6c4fe8624 100644 --- a/packages/server/src/server/routes/worktrees.ts +++ b/packages/server/src/server/routes/worktrees.ts @@ -174,7 +174,11 @@ export function registerWorktreeRoutes(app: FastifyInstance, deps: RouteDeps) { } const { repoRoot, isGitRepo } = await resolveRepoRoot(workspace.path, request.log) - const worktrees = await listWorktrees({ repoRoot, workspaceFolder: workspace.path, logger: request.log }) + const hostWorktrees = await listWorktrees({ repoRoot, workspaceFolder: workspace.path, logger: request.log }) + const worktrees = await Promise.all(hostWorktrees.map(async (worktree) => ({ + ...worktree, + nativeDirectory: await deps.workspaceManager.resolveInstanceDirectory(workspace.id, worktree.directory), + }))) const response: WorktreeListResponse = { worktrees, isGitRepo } return response }) diff --git a/packages/server/src/workspaces/manager.test.ts b/packages/server/src/workspaces/manager.test.ts index f6a1de7ce..911637448 100644 --- a/packages/server/src/workspaces/manager.test.ts +++ b/packages/server/src/workspaces/manager.test.ts @@ -152,47 +152,45 @@ async function createReady(harness: ReturnType, folder = p } describe("workspace manager lifecycle", () => { - it("keeps blocked shared admission alive when the creator disconnects", async () => { + it("aborts and cleans a blocked launch when its sole owner disconnects", async () => { const admission = blockRepositoryAdmission() const harness = createHarness({ withRepositoryMutation: admission.withRepositoryMutation }) const creatorController = new AbortController() - const creator = harness.manager.create(process.cwd(), undefined, { - requestId: "queued-creator", + const creation = harness.manager.create(process.cwd(), undefined, { + requestId: "disconnected-owner", signal: creatorController.signal, }) await admission.entered.promise - const owner = harness.manager.create(process.cwd(), undefined, { requestId: "queued-owner" }) - const outcomes = Promise.allSettled([creator, owner]) - await waitForCreationOwners(harness.manager, 2) creatorController.abort(new Error("creator disconnected")) - await harness.manager.cancelCreationRequest("queued-creator") - admission.release.resolve() - const workspaceId = await harness.runtime.launchCalled.promise - harness.runtime.resolveLaunch() - harness.readiness.resolve(undefined) + const reason = await admission.aborted.promise - const [creatorOutcome, ownerOutcome] = await outcomes - assert.equal(creatorOutcome.status, "rejected") - assert.match(String(creatorOutcome.reason), /queued-creator was cancelled/) - assert.equal(ownerOutcome.status, "fulfilled") - assert.equal(ownerOutcome.value.created, false) - assert.equal(ownerOutcome.value.workspace.id, workspaceId) - assert.equal(ownerOutcome.value.workspace.requestId, "queued-owner") - assert.equal(harness.runtime.active.has(workspaceId), true) + assert.ok(reason instanceof WorkspaceLaunchCancelledError) + await assert.rejects(creation, (error) => error === reason) + assert.equal(harness.runtime.active.size, 0) + assert.deepEqual(harness.manager.list(), []) + assert.equal((harness.manager as any).workspaces.size, 0) }) - it("aborts blocked shared admission when every owner cancels", async () => { + it("keeps shared admission alive after one disconnect and aborts on final cancellation", async () => { const admission = blockRepositoryAdmission() const harness = createHarness({ withRepositoryMutation: admission.withRepositoryMutation }) - const first = harness.manager.create(process.cwd(), undefined, { requestId: "cancelled-one" }) + const firstController = new AbortController() + const first = harness.manager.create(process.cwd(), undefined, { + requestId: "disconnected-one", + signal: firstController.signal, + }) await admission.entered.promise - const second = harness.manager.create(process.cwd(), undefined, { requestId: "cancelled-two" }) + const second = harness.manager.create(process.cwd(), undefined, { requestId: "remaining-two" }) const outcomes = Promise.allSettled([first, second]) await waitForCreationOwners(harness.manager, 2) - await harness.manager.cancelCreationRequest("cancelled-one") - const finalCancellation = harness.manager.cancelCreationRequest("cancelled-two") + firstController.abort(new Error("first creator disconnected")) + const pending = [...(harness.manager as any).pendingWorkspaceCreations.values()][0] + assert.equal(pending.ownership.get("disconnected-one"), "cancelled") + assert.equal(pending.ownership.get("remaining-two"), "active") + + const finalCancellation = harness.manager.cancelCreationRequest("remaining-two") const reason = await admission.aborted.promise const results = await outcomes await finalCancellation diff --git a/packages/server/src/workspaces/manager.ts b/packages/server/src/workspaces/manager.ts index a73a9b9f0..1139ad7aa 100644 --- a/packages/server/src/workspaces/manager.ts +++ b/packages/server/src/workspaces/manager.ts @@ -325,8 +325,7 @@ export class WorkspaceManager { const owner = options.requestId ?? ORDINARY_CREATION_OWNER if (!pending.ownership.has(owner)) pending.ownership.set(owner, "active") this.syncOwnership(pending) - const result = await pending[WORKSPACE_STATE].creation! - return this.finishCreation({ workspace: result.workspace, created: false }, options.requestId, pending.ownership) + return this.awaitCreation(pending, options, false) } } let identityAdmission: { promise: Promise; resolve: () => void } | undefined @@ -340,8 +339,7 @@ export class WorkspaceManager { const owner = options.requestId ?? ORDINARY_CREATION_OWNER if (!pending.ownership.has(owner)) pending.ownership.set(owner, "active") this.syncOwnership(pending) - const result = await pending[WORKSPACE_STATE].creation! - return this.finishCreation({ workspace: result.workspace, created: false }, options.requestId, pending.ownership) + return this.awaitCreation(pending, options, false) } } else { let resolve!: () => void @@ -360,8 +358,8 @@ export class WorkspaceManager { if (options.forceNew) { const ownership = this.createOwnership(options.requestId) const record = this.reserveWorkspace(workspacePath, identityKey, name, options, ownership, launchDeadlineAt) - const result = await this.startCreation(record, options, launchDeadlineAt, launchTimeoutMs) - return this.finishCreation(result, options.requestId, ownership) + this.startCreation(record, options, launchDeadlineAt, launchTimeoutMs) + return this.awaitCreation(record, options, true) } const existing = this.findReadyWorkspaceByIdentity(identityKey, Boolean(options.requestId)) if (existing) { @@ -376,23 +374,21 @@ export class WorkspaceManager { } const pending = this.pendingWorkspaceCreations.get(identityKey) if (pending) { - const state = pending[WORKSPACE_STATE] const owner = options.requestId ?? ORDINARY_CREATION_OWNER if (!pending.ownership.has(owner)) pending.ownership.set(owner, "active") this.syncOwnership(pending) - const result = await state.creation! - return this.finishCreation({ workspace: result.workspace, created: false }, options.requestId, pending.ownership) + return this.awaitCreation(pending, options, false) } const ownership = this.createOwnership(options.requestId) const record = this.reserveWorkspace(workspacePath, identityKey, name, options, ownership, launchDeadlineAt) - const creation = this.startCreation(record, options, launchDeadlineAt, launchTimeoutMs) + this.startCreation(record, options, launchDeadlineAt, launchTimeoutMs) this.pendingWorkspaceCreations.set(identityKey, record) identityAdmission?.resolve() if (this.pendingWorkspaceAdmissions.get(identityKey) === identityAdmission) { this.pendingWorkspaceAdmissions.delete(identityKey) } try { - return this.finishCreation(await creation, options.requestId, ownership) + return await this.awaitCreation(record, options, true) } finally { if (this.pendingWorkspaceCreations.get(identityKey) === record) { this.pendingWorkspaceCreations.delete(identityKey) @@ -730,6 +726,35 @@ export class WorkspaceManager { return new Map([[requestId ?? ORDINARY_CREATION_OWNER, "active"]]) } + private async awaitCreation( + record: WorkspaceRecord, + options: WorkspaceCreateOptions, + created: boolean, + ): Promise { + const { requestId, signal } = options + const finish = async () => { + const result = await record[WORKSPACE_STATE].creation! + return this.finishCreation({ workspace: result.workspace, created }, requestId, record.ownership) + } + if (!requestId || !signal) return finish() + let cancellation: Promise | undefined + let cancellationError: unknown + const cancel = () => { + cancellation ??= this.cancelCreationRequest(requestId).catch((error) => { + cancellationError = error + }) + } + signal.addEventListener("abort", cancel, { once: true }) + if (signal.aborted) cancel() + try { + return await finish() + } finally { + signal.removeEventListener("abort", cancel) + await cancellation + if (cancellationError) throw cancellationError + } + } + private finishCreation( result: WorkspaceCreateResult, requestId: string | undefined, diff --git a/packages/ui/src/stores/instances.ts b/packages/ui/src/stores/instances.ts index c03b29f9b..cdabdf4f2 100644 --- a/packages/ui/src/stores/instances.ts +++ b/packages/ui/src/stores/instances.ts @@ -82,7 +82,8 @@ import { import { WorkspaceListReconciliationFence } from "./workspace-list-reconciliation-fence" import { findWorktreeSlugForDirectory, - inventoryAndNativeDirectoriesEqual, + worktreeDirectoryMatches, + workspaceDirectoriesEqual, } from "./opencode-workspace-matching" const log = getLogger("api") @@ -244,7 +245,10 @@ async function withInterruptionWorkspace( if (typeof explicitWorkspace === "string" && explicitWorkspace) { return operation({ workspace: explicitWorkspace }) } - if (inventoryAndNativeDirectoriesEqual(instance.folder, match.directory)) return operation({}) + const rootWorktree = getWorktrees(instanceId).find((worktree) => worktree.slug === "root") + if (rootWorktree + ? worktreeDirectoryMatches(rootWorktree, match.directory) + : workspaceDirectoriesEqual(instance.folder, match.directory)) return operation({}) const slug = findWorktreeSlugForDirectory(getWorktrees(instanceId), match.directory) if (!slug || slug === "root") throw new Error("Unable to resolve interruption session workspace") const workspace = await getOpenCodeWorkspaceIdForWorktree(instanceId, slug) diff --git a/packages/ui/src/stores/opencode-workspace-matching.ts b/packages/ui/src/stores/opencode-workspace-matching.ts index cbae5bb20..06f7d1698 100644 --- a/packages/ui/src/stores/opencode-workspace-matching.ts +++ b/packages/ui/src/stores/opencode-workspace-matching.ts @@ -37,45 +37,29 @@ function workspaceDirectoriesEqual(left: string | null | undefined, right: strin && normalizeWindowsWorkspaceDirectory(normalizedLeft) === normalizeWindowsWorkspaceDirectory(normalizedRight) } -function inventoryAndNativeDirectoriesEqual( - inventoryDirectory: string | null | undefined, +function worktreeDirectoryMatches( + worktree: Pick, nativeDirectory: string | null | undefined, ): boolean { - if (workspaceDirectoriesEqual(inventoryDirectory, nativeDirectory)) return true - const inventory = normalizeWorkspaceDirectory(inventoryDirectory) - const native = normalizeWorkspaceDirectory(nativeDirectory) - if (!isWindowsWorkspaceDirectory(inventory)) return false - const wslDrive = native.match(/^\/mnt\/([A-Za-z])(?:\/(.*))?$/) - if (!wslDrive) return false - return normalizeWindowsWorkspaceDirectory(inventory) - === normalizeWindowsWorkspaceDirectory(`${wslDrive[1]}:/${wslDrive[2] ?? ""}`) + return workspaceDirectoriesEqual(worktree.nativeDirectory ?? worktree.directory, nativeDirectory) } function findWorktreeSlugForDirectory( - worktrees: Pick[], + worktrees: Pick[], target: string | null | undefined, ): string | null { - const directory = normalizeWorkspaceDirectory(target) - if (!directory) return null - const windowsDirectory = isWindowsWorkspaceDirectory(directory) ? normalizeWindowsWorkspaceDirectory(directory) : null - return worktrees.find((worktree) => { - const candidate = normalizeWorkspaceDirectory(worktree.directory) - if (candidate === directory) return true - return windowsDirectory !== null - && isWindowsWorkspaceDirectory(candidate) - && normalizeWindowsWorkspaceDirectory(candidate) === windowsDirectory - })?.slug ?? null + return worktrees.find((worktree) => worktreeDirectoryMatches(worktree, target))?.slug ?? null } function mapOpenCodeWorkspacesToWorktreeSlugs( - worktrees: Pick[], + worktrees: Pick[], workspaces: OpenCodeWorkspaceLike[], ): Map { const next = new Map() for (const worktree of worktrees) { if (worktree.slug === "root") continue const workspace = workspaces.find((candidate) => ( - inventoryAndNativeDirectoriesEqual(worktree.directory, candidate.directory) + worktreeDirectoryMatches(worktree, candidate.directory) )) if (workspace?.id) next.set(worktree.slug, workspace.id) } @@ -84,8 +68,8 @@ function mapOpenCodeWorkspacesToWorktreeSlugs( export { findWorktreeSlugForDirectory, - inventoryAndNativeDirectoriesEqual, mapOpenCodeWorkspacesToWorktreeSlugs, normalizeWorkspaceDirectory, + worktreeDirectoryMatches, workspaceDirectoriesEqual, } diff --git a/packages/ui/src/stores/opencode-workspaces.test.ts b/packages/ui/src/stores/opencode-workspaces.test.ts index 67a714942..cbefb261f 100644 --- a/packages/ui/src/stores/opencode-workspaces.test.ts +++ b/packages/ui/src/stores/opencode-workspaces.test.ts @@ -62,14 +62,40 @@ describe("mapOpenCodeWorkspacesToWorktreeSlugs", () => { assert.equal(result.size, 0) }) - it("bridges Windows inventory to WSL-native workspaces without merging path namespaces", () => { - const result = mapOpenCodeWorkspacesToWorktreeSlugs( - [{ slug: "feature", directory: String.raw`C:\repo-feature` }], - [{ id: "wrk_feature", directory: "/mnt/c/repo-feature" }], - ) + for (const [name, hostRoot, nativeRoot] of [ + ["default DrvFS", String.raw`C:\Repo`, "/mnt/c/Repo"], + ["custom DrvFS", String.raw`C:\Repo`, "/windows/c/Repo"], + ["WSL UNC /home", String.raw`\\wsl.localhost\Ubuntu\home\Dev\Repo`, "/home/Dev/Repo"], + ] as const) { + it(`maps authoritative root and feature coordinates for ${name}`, () => { + const worktrees = [ + { slug: "root", directory: hostRoot, nativeDirectory: nativeRoot }, + { + slug: "feature", + directory: `${hostRoot}\\.codenomad\\worktrees\\feature`, + nativeDirectory: `${nativeRoot}/.codenomad/worktrees/feature`, + }, + ] + const result = mapOpenCodeWorkspacesToWorktreeSlugs(worktrees, [{ + id: "wrk_feature", + directory: `${nativeRoot}/.codenomad/worktrees/feature`, + }]) + + assert.equal(findWorktreeSlugForDirectory(worktrees, nativeRoot), "root") + assert.equal(findWorktreeSlugForDirectory(worktrees, `${nativeRoot}/.codenomad/worktrees/feature`), "feature") + assert.equal(result.get("feature"), "wrk_feature") + assert.equal(result.has("root"), false) + assert.equal(workspaceDirectoriesEqual(hostRoot, nativeRoot), false) + }) + } - assert.equal(result.get("feature"), "wrk_feature") - assert.equal(workspaceDirectoriesEqual(String.raw`C:\repo`, "/mnt/c/repo"), false) + it("keeps authoritative WSL-native paths case-sensitive", () => { + const worktrees = [{ + slug: "feature", + directory: String.raw`C:\Repo\Feature`, + nativeDirectory: "/mnt/c/Repo/Feature", + }] + assert.equal(findWorktreeSlugForDirectory(worktrees, "/mnt/c/Repo/feature"), null) }) }) diff --git a/packages/ui/src/stores/opencode-workspaces.ts b/packages/ui/src/stores/opencode-workspaces.ts index 6401321b4..69772878f 100644 --- a/packages/ui/src/stores/opencode-workspaces.ts +++ b/packages/ui/src/stores/opencode-workspaces.ts @@ -1,6 +1,8 @@ +import { createSignal } from "solid-js" import { getRootClient } from "./opencode-client" import { getLogger } from "../lib/logger" import { mapOpenCodeWorkspacesToWorktreeSlugs } from "./opencode-workspace-matching" +import { messageStoreBus } from "./message-v2/bus" const WORKSPACE_SYNC_TIMEOUT_MS = 5_000 @@ -34,6 +36,14 @@ type OpenCodeWorkspace = { const workspaceIdByWorktreeSlug = new Map>() const workspaceIdBySession = new Map>() const workspaceSyncs = new Map>() +const workspaceMappingVersions = new Map>>() + +messageStoreBus.onInstanceDestroyed((instanceId) => workspaceMappingVersions.delete(instanceId)) + +function publishOpenCodeWorkspaces(instanceId: string, workspaces: Map): void { + workspaceIdByWorktreeSlug.set(instanceId, workspaces) + workspaceMappingVersions.get(instanceId)?.[1]((version) => version + 1) +} async function getInstance(instanceId: string) { const { instances } = await import("./instances") @@ -67,6 +77,12 @@ function getCachedOpenCodeWorkspaceIdForSession(instanceId: string, sessionId: s } function getCachedWorktreeSlugForOpenCodeWorkspaceId(instanceId: string, workspaceId: string): string | null { + let version = workspaceMappingVersions.get(instanceId) + if (!version) { + version = createSignal(0) + workspaceMappingVersions.set(instanceId, version) + } + version[0]() for (const [slug, candidate] of workspaceIdByWorktreeSlug.get(instanceId) ?? []) { if (candidate === workspaceId) return slug } @@ -91,12 +107,12 @@ async function syncOpenCodeWorkspaces(instanceId: string): Promise { if (existing) return existing const task = (async () => { - workspaceIdByWorktreeSlug.set(instanceId, await loadOpenCodeWorkspaces(instanceId)) + publishOpenCodeWorkspaces(instanceId, await loadOpenCodeWorkspaces(instanceId)) })() .catch((error) => { log.warn("Failed to sync OpenCode workspaces", { instanceId, error }) if (!workspaceIdByWorktreeSlug.has(instanceId)) { - workspaceIdByWorktreeSlug.set(instanceId, new Map()) + publishOpenCodeWorkspaces(instanceId, new Map()) } }) .finally(() => { @@ -118,7 +134,7 @@ async function reloadOpenCodeWorkspacesStrict(instanceId: string, isCurrent: () await workspaceSyncs.get(instanceId) if (!isCurrent()) return const workspaces = await loadOpenCodeWorkspaces(instanceId) - if (isCurrent()) workspaceIdByWorktreeSlug.set(instanceId, workspaces) + if (isCurrent()) publishOpenCodeWorkspaces(instanceId, workspaces) } async function getOpenCodeWorkspaceIdForWorktree(instanceId: string, slug: string): Promise { @@ -148,6 +164,7 @@ function clearOpenCodeWorkspaceCache(instanceId: string): void { workspaceSyncs.delete(instanceId) workspaceIdByWorktreeSlug.delete(instanceId) workspaceIdBySession.delete(instanceId) + workspaceMappingVersions.get(instanceId)?.[1]((version) => version + 1) } async function removeOpenCodeWorkspaceForWorktree(instanceId: string, slug: string): Promise { @@ -161,7 +178,9 @@ async function removeOpenCodeWorkspaceForWorktree(instanceId: string, slug: stri if (!workspaceApi?.remove) return await workspaceApi.remove({ directory: instance.folder, id: workspaceId }) - workspaceIdByWorktreeSlug.get(instanceId)?.delete(slug) + if (workspaceIdByWorktreeSlug.get(instanceId)?.delete(slug)) { + workspaceMappingVersions.get(instanceId)?.[1]((version) => version + 1) + } } export { diff --git a/packages/ui/src/stores/permission-lifecycle.test.ts b/packages/ui/src/stores/permission-lifecycle.test.ts index fa7c4ec07..be899bf17 100644 --- a/packages/ui/src/stores/permission-lifecycle.test.ts +++ b/packages/ui/src/stores/permission-lifecycle.test.ts @@ -20,6 +20,8 @@ import { import { messageStoreBus } from "./message-v2/bus" import { handlePermissionUpdated } from "./session-events" import { setSessions } from "./session-state" +import { serverApi } from "../lib/api-client" +import { ensureWorktreesLoaded } from "./worktrees" const instanceIds: string[] = [] const originalCreateClient = sdkManager.createClient @@ -192,6 +194,15 @@ test("missing-parent interruptions recognize a WSL-native root location", async } as unknown as OpencodeClient sdkManager.createClient = (() => client) as typeof sdkManager.createClient addTestInstance("wsl-root-interruptions", client, String.raw`C:\repo`) + const originalFetchWorktrees = serverApi.fetchWorktrees + serverApi.fetchWorktrees = async () => ({ + isGitRepo: true, + worktrees: [{ + slug: "root", directory: String.raw`C:\repo`, nativeDirectory: "/mnt/c/repo", kind: "root", + }], + }) + await ensureWorktreesLoaded("wsl-root-interruptions") + serverApi.fetchWorktrees = originalFetchWorktrees addPermissionToQueue("wsl-root-interruptions", { id: "permission", sessionID: "child" } as never, "legacy") addQuestionToQueue("wsl-root-interruptions", { id: "question", sessionID: "child", questions: [] } as never, "legacy") diff --git a/packages/ui/src/stores/session-worktree-binding.test.ts b/packages/ui/src/stores/session-worktree-binding.test.ts index 051b77628..3d3538843 100644 --- a/packages/ui/src/stores/session-worktree-binding.test.ts +++ b/packages/ui/src/stores/session-worktree-binding.test.ts @@ -1,12 +1,13 @@ import assert from "node:assert/strict" import { describe, it } from "node:test" +import { createMemo, createRoot } from "solid-js" import type { WorktreeDescriptor, WorktreeMap, WorktreeSessionMoveResponse } from "../../../server/src/api-types.ts" 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 { clearOpenCodeWorkspaceCache } from "./opencode-workspaces.ts" +import { clearOpenCodeWorkspaceCache, syncOpenCodeWorkspaces } from "./opencode-workspaces.ts" import { abortSession } from "./session-actions.ts" import { createSession } from "./session-api.ts" import { handleSessionUpdate } from "./session-events.ts" @@ -77,6 +78,7 @@ async function setup(instanceId: string, options: { worktrees?: WorktreeDescriptor[] | null folder?: string openCodeWorkspaces?: Array<{ id: string; directory: string }> + openCodeWorkspaceList?: () => Promise } = {}) { const client = { session: { @@ -93,7 +95,10 @@ async function setup(instanceId: string, options: { experimental: { workspace: { async syncList() { return { data: [] } }, - async list() { return { data: options.openCodeWorkspaces ?? [{ id: "workspace-feature", directory: "/repo-feature" }] } }, + async list() { + return options.openCodeWorkspaceList?.() + ?? { data: options.openCodeWorkspaces ?? [{ id: "workspace-feature", directory: "/repo-feature" }] } + }, }, }, } as any @@ -568,8 +573,8 @@ describe("session worktree binding", () => { const cleanup = await setup(instanceId, { folder: String.raw`C:\repo`, worktrees: [ - { slug: "root", directory: String.raw`C:\repo`, kind: "root" }, - { slug: "feature", directory: String.raw`C:\repo-feature`, kind: "worktree" }, + { slug: "root", directory: String.raw`C:\repo`, nativeDirectory: "/mnt/c/repo", kind: "root" }, + { slug: "feature", directory: String.raw`C:\repo-feature`, nativeDirectory: "/mnt/c/repo-feature", kind: "worktree" }, ], openCodeWorkspaces: [{ id: "workspace-feature", directory: "/mnt/c/repo-feature" }], abort: async (parameters) => { abortCalls.push(parameters); return { data: true } }, @@ -608,14 +613,49 @@ describe("session worktree binding", () => { } }) + it("reactively resolves a rendered session when workspace mappings arrive", async () => { + const instanceId = "reactive-workspace-mapping" + const listing = deferred() + const listStarted = deferred() + const cleanup = await setup(instanceId, { + openCodeWorkspaceList: async () => { + listStarted.resolve() + return listing.promise + }, + }) + const root = { ...session(instanceId, "root-session", null), workspaceId: "workspace-feature" } + const child = session(instanceId, "child-session", root.id) + setFamily(instanceId, root, child) + + let dispose = () => {} + let renderedSlug = () => "" + createRoot((rootDispose) => { + dispose = rootDispose + renderedSlug = createMemo(() => getWorktreeSlugForSession(instanceId, root.id)) + }) + + try { + assert.equal(renderedSlug(), "") + const syncing = syncOpenCodeWorkspaces(instanceId) + await listStarted.promise + assert.equal(renderedSlug(), "") + listing.resolve({ data: [{ id: "workspace-feature", directory: "/repo-feature" }] }) + await syncing + assert.equal(renderedSlug(), "feature") + } finally { + dispose() + cleanup() + } + }) + it("routes WSL roots and legacy directory-only WSL features without weakening POSIX case", async () => { const rootInstanceId = "wsl-native-root" const rootAbortCalls: Array> = [] const rootCleanup = await setup(rootInstanceId, { folder: String.raw`C:\repo`, worktrees: [ - { slug: "root", directory: String.raw`C:\repo`, kind: "root" }, - { slug: "feature", directory: String.raw`C:\repo-feature`, kind: "worktree" }, + { slug: "root", directory: String.raw`C:\repo`, nativeDirectory: "/mnt/c/repo", kind: "root" }, + { slug: "feature", directory: String.raw`C:\repo-feature`, nativeDirectory: "/mnt/c/repo-feature", kind: "worktree" }, ], abort: async (parameters) => { rootAbortCalls.push(parameters); return { data: true } }, }) @@ -628,8 +668,8 @@ describe("session worktree binding", () => { const featureCleanup = await setup(featureInstanceId, { folder: String.raw`C:\repo`, worktrees: [ - { slug: "root", directory: String.raw`C:\repo`, kind: "root" }, - { slug: "feature", directory: String.raw`C:\repo-feature`, kind: "worktree" }, + { slug: "root", directory: String.raw`C:\repo`, nativeDirectory: "/mnt/c/repo", kind: "root" }, + { slug: "feature", directory: String.raw`C:\repo-feature`, nativeDirectory: "/mnt/c/repo-feature", kind: "worktree" }, ], openCodeWorkspaces: [{ id: "workspace-feature", directory: "/mnt/c/repo-feature" }], abort: async (parameters) => { featureAbortCalls.push(parameters); return { data: true } }, diff --git a/packages/ui/src/stores/session-worktree-binding.ts b/packages/ui/src/stores/session-worktree-binding.ts index 626839917..e6309cc61 100644 --- a/packages/ui/src/stores/session-worktree-binding.ts +++ b/packages/ui/src/stores/session-worktree-binding.ts @@ -2,7 +2,7 @@ import { tGlobal } from "../lib/i18n" import { serverApi } from "../lib/api-client" import { findWorktreeSlugForDirectory, - inventoryAndNativeDirectoriesEqual, + worktreeDirectoryMatches, workspaceDirectoriesEqual, } from "./opencode-workspace-matching" import { @@ -269,15 +269,17 @@ async function currentOrLegacySessionWorkspacePayload( workspaceDirectoriesEqual(member.directory, root.directory) )) const inventoryRootDirectory = await rootDirectory(instanceId) + const inventoryRoot = getWorktrees(instanceId).find((worktree) => worktree.slug === "root") if (familyLocationMatches && root.directory && !workspaceDirectoriesEqual(inventoryRootDirectory, root.directory) - && inventoryAndNativeDirectoriesEqual(inventoryRootDirectory, root.directory)) return {} + && inventoryRoot && worktreeDirectoryMatches(inventoryRoot, root.directory)) return {} const slug = getWorktreeSlugForSession(instanceId, root.id) if (!slug) throw new Error(tGlobal("instanceShell.worktree.moveFailed")) + if (familyLocationMatches && slug === "root" && root.directory + && workspaceDirectoriesEqual(inventoryRootDirectory, root.directory)) return {} const target = getWorktrees(instanceId).find((worktree) => worktree.slug === slug) - const targetDirectory = slug === "root" ? target?.directory ?? inventoryRootDirectory : target?.directory if (familyLocationMatches && slug === "root" - && (!root.directory || inventoryAndNativeDirectoriesEqual(targetDirectory, root.directory))) return {} + && (!root.directory || (target && worktreeDirectoryMatches(target, root.directory)))) return {} return null } @@ -302,7 +304,10 @@ async function currentSessionWorkspacePayload(instanceId: string, sessionId: str throw new Error(tGlobal("instanceShell.worktree.moveFailed")) } if (session.workspaceId) return { workspace: session.workspaceId } - if (!session.directory || inventoryAndNativeDirectoriesEqual(await rootDirectory(instanceId), session.directory)) return {} + const rootWorktree = getWorktrees(instanceId).find((worktree) => worktree.slug === "root") + if (!session.directory + || workspaceDirectoriesEqual(await rootDirectory(instanceId), session.directory) + || (rootWorktree && worktreeDirectoryMatches(rootWorktree, session.directory))) return {} const slug = findWorktreeSlugForDirectory(getWorktrees(instanceId), session.directory) ?? getWorktreeSlugForSession(instanceId, sessionId) if (!slug || slug === "root") throw new Error(tGlobal("instanceShell.worktree.moveFailed")) diff --git a/packages/ui/src/stores/worktrees.ts b/packages/ui/src/stores/worktrees.ts index 9b39d0b3d..1e48767e9 100644 --- a/packages/ui/src/stores/worktrees.ts +++ b/packages/ui/src/stores/worktrees.ts @@ -5,7 +5,7 @@ import { getSessionRoot, sessions } from "./session-state" import { getLogger } from "../lib/logger" import { getCodeNomadSessionMetadata } from "./session-metadata" import type { WorktreeReadyEvent } from "../lib/sse-manager" -import { findWorktreeSlugForDirectory, inventoryAndNativeDirectoriesEqual } from "./opencode-workspace-matching" +import { findWorktreeSlugForDirectory, worktreeDirectoryMatches } from "./opencode-workspace-matching" import { getCachedWorktreeSlugForOpenCodeWorkspaceId } from "./opencode-workspaces" import { tGlobal } from "../lib/i18n" import { messageStoreBus } from "./message-v2/bus" @@ -491,7 +491,7 @@ function getWorktreeSlugForParentSession(instanceId: string, parentSessionId: st if (nativeSlug) return normalizeWorktreeSlug(instanceId, nativeSlug) const root = getWorktrees(instanceId).find((worktree) => worktree.slug === "root") - if (root && inventoryAndNativeDirectoriesEqual(root.directory, session?.directory)) return "root" + if (root && worktreeDirectoryMatches(root, session?.directory)) return "root" const map = getWorktreeMap(instanceId) const candidate = map.parentSessionWorktreeSlug[parentSessionId]