From 484782b5a90cb6ef261f3dfe5c854e8e254d8552 Mon Sep 17 00:00:00 2001 From: "chenyiy@133.cn" Date: Fri, 10 Apr 2026 13:51:57 +0800 Subject: [PATCH 1/3] fix: normalize path separators to POSIX for Windows compatibility (#21441) On Windows, directory paths used backslashes which caused cache key mismatches in Maps (booting, sdkCache, sessionMeta, queue, prefetch, seen). Extract Filesystem.posixPath() on the server and consistently use workspaceKey() on the client so all lookups use normalized paths. --- packages/app/src/context/global-sync.tsx | 46 ++++++------ .../app/src/context/global-sync/bootstrap.ts | 12 ++-- .../src/context/global-sync/child-store.ts | 71 +++++++++++-------- packages/app/src/context/global-sync/queue.ts | 8 ++- .../context/global-sync/session-prefetch.ts | 6 +- packages/app/src/context/sync.tsx | 48 +++++++------ packages/app/src/pages/layout/helpers.ts | 9 +-- .../app/src/pages/layout/sidebar-project.tsx | 4 +- .../src/pages/layout/sidebar-workspace.tsx | 4 +- packages/opencode/src/project/project.ts | 5 +- packages/opencode/src/server/instance.ts | 11 +-- packages/opencode/src/server/routes/file.ts | 5 +- packages/opencode/src/session/index.ts | 11 +-- packages/opencode/src/util/filesystem.ts | 15 +++- packages/opencode/src/worktree/index.ts | 3 +- 15 files changed, 154 insertions(+), 104 deletions(-) diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index 0cf3570a8b3d..cbf733951b4a 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -26,6 +26,7 @@ import type { ProjectMeta } from "./global-sync/types" import { SESSION_RECENT_LIMIT } from "./global-sync/types" import { sanitizeProject } from "./global-sync/utils" import { formatServerError } from "@/utils/server-errors" +import { workspaceKey } from "@/pages/layout/helpers" type GlobalStore = { ready: boolean @@ -151,39 +152,42 @@ function createGlobalSync() { const children = createChildStoreManager({ owner, - isBooting: (directory) => booting.has(directory), - isLoadingSessions: (directory) => sessionLoads.has(directory), + isBooting: (directory) => booting.has(workspaceKey(directory)), + isLoadingSessions: (directory) => sessionLoads.has(workspaceKey(directory)), onBootstrap: (directory) => { void bootstrapInstance(directory) }, onDispose: (directory) => { + const key = workspaceKey(directory) queue.clear(directory) - sessionMeta.delete(directory) - sdkCache.delete(directory) - clearProviderRev(directory) - clearSessionPrefetchDirectory(directory) + sessionMeta.delete(key) + sdkCache.delete(key) + clearProviderRev(key) + clearSessionPrefetchDirectory(key) }, translate: language.t, }) const sdkFor = (directory: string) => { - const cached = sdkCache.get(directory) + const key = workspaceKey(directory) + const cached = sdkCache.get(key) if (cached) return cached const sdk = globalSDK.createClient({ directory, throwOnError: true, }) - sdkCache.set(directory, sdk) + sdkCache.set(key, sdk) return sdk } async function loadSessions(directory: string) { - const pending = sessionLoads.get(directory) + const key = workspaceKey(directory) + const pending = sessionLoads.get(key) if (pending) return pending children.pin(directory) const [store, setStore] = children.child(directory, { bootstrap: false }) - const meta = sessionMeta.get(directory) + const meta = sessionMeta.get(key) if (meta && meta.limit >= store.limit) { const next = trimSessions(store.session, { limit: store.limit, @@ -224,7 +228,7 @@ function createGlobalSync() { ) setStore("session", reconcile(sessions, { key: "id" })) cleanupDroppedSessionCaches(store, setStore, sessions, setSessionTodo) - sessionMeta.set(directory, { limit }) + sessionMeta.set(key, { limit }) }) .catch((err) => { console.error("Failed to load sessions", err) @@ -236,9 +240,9 @@ function createGlobalSync() { }) }) - sessionLoads.set(directory, promise) + sessionLoads.set(key, promise) promise.finally(() => { - sessionLoads.delete(directory) + sessionLoads.delete(key) children.unpin(directory) }) return promise @@ -246,13 +250,14 @@ function createGlobalSync() { async function bootstrapInstance(directory: string) { if (!directory) return - const pending = booting.get(directory) + const key = workspaceKey(directory) + const pending = booting.get(key) if (pending) return pending children.pin(directory) const promise = (async () => { const child = children.ensureChild(directory) - const cache = children.vcsCache.get(directory) + const cache = children.vcsCache.get(key) if (!cache) return const sdk = sdkFor(directory) await bootstrapDirectory({ @@ -272,9 +277,9 @@ function createGlobalSync() { }) })() - booting.set(directory, promise) + booting.set(key, promise) promise.finally(() => { - booting.delete(directory) + booting.delete(key) children.unpin(directory) }) return promise @@ -297,14 +302,15 @@ function createGlobalSync() { }) if (event.type === "server.connected" || event.type === "global.disposed") { if (recent) return - for (const directory of Object.keys(children.children)) { + for (const directory of children.canonicalDir.values()) { queue.push(directory) } } return } - const existing = children.children[directory] + const key = workspaceKey(directory) + const existing = children.children[key] if (!existing) return children.mark(directory) const [store, setStore] = existing @@ -315,7 +321,7 @@ function createGlobalSync() { setStore, push: queue.push, setSessionTodo, - vcsCache: children.vcsCache.get(directory), + vcsCache: children.vcsCache.get(key), loadLsp: () => { sdkFor(directory) .lsp.status() diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index 7edd5a1ce106..f1673ad35f42 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -18,6 +18,7 @@ import { reconcile, type SetStoreFunction, type Store } from "solid-js/store" import type { State, VcsCache } from "./types" import { cmp, normalizeAgentList, normalizeProviderList } from "./utils" import { formatServerError } from "@/utils/server-errors" +import { workspaceKey } from "@/pages/layout/helpers" type GlobalStore = { ready: boolean @@ -58,7 +59,7 @@ function errors(list: PromiseSettledResult[]) { const providerRev = new Map() export function clearProviderRev(directory: string) { - providerRev.delete(directory) + providerRev.delete(workspaceKey(directory)) } function runAll(list: Array<() => Promise>) { @@ -339,16 +340,17 @@ export async function bootstrapDirectory(input: { if (loading && errs.length === 0 && slowErrs.length === 0) input.setStore("status", "complete") - const rev = (providerRev.get(input.directory) ?? 0) + 1 - providerRev.set(input.directory, rev) + const wk = workspaceKey(input.directory) + const rev = (providerRev.get(wk) ?? 0) + 1 + providerRev.set(wk, rev) void retry(() => input.sdk.provider.list()) .then((x) => { - if (providerRev.get(input.directory) !== rev) return + if (providerRev.get(wk) !== rev) return input.setStore("provider", normalizeProviderList(x.data!)) input.setStore("provider_ready", true) }) .catch((err) => { - if (providerRev.get(input.directory) !== rev) return + if (providerRev.get(wk) !== rev) return console.error("Failed to refresh provider list", err) const project = getFilename(input.directory) showToast({ diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts index 5678491f8973..a147c1658bb7 100644 --- a/packages/app/src/context/global-sync/child-store.ts +++ b/packages/app/src/context/global-sync/child-store.ts @@ -14,6 +14,7 @@ import { type VcsCache, } from "./types" import { canDisposeDirectory, pickDirectoriesToEvict } from "./eviction" +import { workspaceKey } from "@/pages/layout/helpers" export function createChildStoreManager(input: { owner: Owner @@ -31,31 +32,35 @@ export function createChildStoreManager(input: { const pins = new Map() const ownerPins = new WeakMap>() const disposers = new Map void>() + const canonicalDir = new Map() const mark = (directory: string) => { if (!directory) return - lifecycle.set(directory, { lastAccessAt: Date.now() }) - runEviction(directory) + const k = workspaceKey(directory) + lifecycle.set(k, { lastAccessAt: Date.now() }) + runEviction(k) } const pin = (directory: string) => { if (!directory) return - pins.set(directory, (pins.get(directory) ?? 0) + 1) + const k = workspaceKey(directory) + pins.set(k, (pins.get(k) ?? 0) + 1) mark(directory) } const unpin = (directory: string) => { if (!directory) return - const next = (pins.get(directory) ?? 0) - 1 + const k = workspaceKey(directory) + const next = (pins.get(k) ?? 0) - 1 if (next > 0) { - pins.set(directory, next) + pins.set(k, next) return } - pins.delete(directory) + pins.delete(k) runEviction() } - const pinned = (directory: string) => (pins.get(directory) ?? 0) > 0 + const pinned = (directory: string) => (pins.get(workspaceKey(directory)) ?? 0) > 0 const pinForOwner = (directory: string) => { const current = getOwner() @@ -78,29 +83,32 @@ export function createChildStoreManager(input: { } function disposeDirectory(directory: string) { + const key = workspaceKey(directory) + const orig = canonicalDir.get(key) ?? directory if ( !canDisposeDirectory({ - directory, - hasStore: !!children[directory], + directory: orig, + hasStore: !!children[key], pinned: pinned(directory), - booting: input.isBooting(directory), - loadingSessions: input.isLoadingSessions(directory), + booting: input.isBooting(orig), + loadingSessions: input.isLoadingSessions(orig), }) ) { return false } - vcsCache.delete(directory) - metaCache.delete(directory) - iconCache.delete(directory) - lifecycle.delete(directory) - const dispose = disposers.get(directory) + vcsCache.delete(key) + metaCache.delete(key) + iconCache.delete(key) + lifecycle.delete(key) + canonicalDir.delete(key) + const dispose = disposers.get(key) if (dispose) { dispose() - disposers.delete(directory) + disposers.delete(key) } - delete children[directory] - input.onDispose(directory) + delete children[key] + input.onDispose(orig) return true } @@ -123,7 +131,11 @@ export function createChildStoreManager(input: { function ensureChild(directory: string) { if (!directory) console.error("No directory provided") - if (!children[directory]) { + const key = workspaceKey(directory) + // Prefer backslash format on Windows for API calls that need server-side exact match. + // If backslash path is available, always use it as the canonical form. + if (!canonicalDir.has(key) || directory.includes("\\")) canonicalDir.set(key, directory) + if (!children[key]) { const vcs = runWithOwner(input.owner, () => persisted( Persist.workspace(directory, "vcs", ["vcs.v1"]), @@ -132,7 +144,7 @@ export function createChildStoreManager(input: { ) if (!vcs) throw new Error(input.translate("error.childStore.persistedCacheCreateFailed")) const vcsStore = vcs[0] - vcsCache.set(directory, { store: vcsStore, setStore: vcs[1], ready: vcs[3] }) + vcsCache.set(key, { store: vcsStore, setStore: vcs[1], ready: vcs[3] }) const meta = runWithOwner(input.owner, () => persisted( @@ -141,7 +153,7 @@ export function createChildStoreManager(input: { ), ) if (!meta) throw new Error(input.translate("error.childStore.persistedProjectMetadataCreateFailed")) - metaCache.set(directory, { store: meta[0], setStore: meta[1], ready: meta[3] }) + metaCache.set(key, { store: meta[0], setStore: meta[1], ready: meta[3] }) const icon = runWithOwner(input.owner, () => persisted( @@ -150,7 +162,7 @@ export function createChildStoreManager(input: { ), ) if (!icon) throw new Error(input.translate("error.childStore.persistedProjectIconCreateFailed")) - iconCache.set(directory, { store: icon[0], setStore: icon[1], ready: icon[3] }) + iconCache.set(key, { store: icon[0], setStore: icon[1], ready: icon[3] }) const init = () => createRoot((dispose) => { @@ -183,13 +195,13 @@ export function createChildStoreManager(input: { message: {}, part: {}, }) - children[directory] = child - disposers.set(directory, dispose) + children[key] = child + disposers.set(key, dispose) const onPersistedInit = (init: Promise | string | null, run: () => void) => { if (!(init instanceof Promise)) return void init.then(() => { - if (children[directory] !== child) return + if (children[key] !== child) return run() }) } @@ -214,7 +226,7 @@ export function createChildStoreManager(input: { runWithOwner(input.owner, init) } mark(directory) - const childStore = children[directory] + const childStore = children[key] if (!childStore) throw new Error(input.translate("error.childStore.storeCreateFailed")) return childStore } @@ -240,7 +252,7 @@ export function createChildStoreManager(input: { function projectMeta(directory: string, patch: ProjectMeta) { const [store, setStore] = ensureChild(directory) - const cached = metaCache.get(directory) + const cached = metaCache.get(workspaceKey(directory)) if (!cached) return const previous = store.projectMeta ?? {} const icon = patch.icon ? { ...(previous.icon ?? {}), ...patch.icon } : previous.icon @@ -257,7 +269,7 @@ export function createChildStoreManager(input: { function projectIcon(directory: string, value: string | undefined) { const [store, setStore] = ensureChild(directory) - const cached = iconCache.get(directory) + const cached = iconCache.get(workspaceKey(directory)) if (!cached) return if (store.icon === value) return cached.setStore("value", value) @@ -266,6 +278,7 @@ export function createChildStoreManager(input: { return { children, + canonicalDir, ensureChild, child, peek, diff --git a/packages/app/src/context/global-sync/queue.ts b/packages/app/src/context/global-sync/queue.ts index c3468583b930..5f749ee96bc7 100644 --- a/packages/app/src/context/global-sync/queue.ts +++ b/packages/app/src/context/global-sync/queue.ts @@ -4,6 +4,10 @@ type QueueInput = { bootstrapInstance: (directory: string) => Promise | void } +function normalizeKey(directory: string) { + return directory.replaceAll("\\", "/").replace(/\/+$/, "") +} + export function createRefreshQueue(input: QueueInput) { const queued = new Set() let root = false @@ -33,7 +37,7 @@ export function createRefreshQueue(input: QueueInput) { const push = (directory: string) => { if (!directory) return - queued.add(directory) + queued.add(normalizeKey(directory)) if (input.paused()) return schedule() } @@ -72,7 +76,7 @@ export function createRefreshQueue(input: QueueInput) { push, refresh, clear(directory: string) { - queued.delete(directory) + queued.delete(normalizeKey(directory)) }, dispose() { if (!timer) return diff --git a/packages/app/src/context/global-sync/session-prefetch.ts b/packages/app/src/context/global-sync/session-prefetch.ts index 608561f855c7..ec3a810e07ff 100644 --- a/packages/app/src/context/global-sync/session-prefetch.ts +++ b/packages/app/src/context/global-sync/session-prefetch.ts @@ -1,4 +1,6 @@ -const key = (directory: string, sessionID: string) => `${directory}\n${sessionID}` +import { workspaceKey } from "@/pages/layout/helpers" + +const key = (directory: string, sessionID: string) => `${workspaceKey(directory)}\n${sessionID}` export const SESSION_PREFETCH_TTL = 15_000 @@ -89,7 +91,7 @@ export function clearSessionPrefetch(directory: string, sessionIDs: Iterable>, key: string, task: () => P return promise } -const keyFor = (directory: string, id: string) => `${directory}\n${id}` +const keyFor = (directory: string, id: string) => `${workspaceKey(directory)}\n${id}` const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) @@ -202,6 +203,29 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ return undefined } + const seenFor = (directory: string) => { + const wk = workspaceKey(directory) + const existing = seen.get(wk) + if (existing) { + seen.delete(wk) + seen.set(wk, existing) + return existing + } + const created = new Set() + seen.set(wk, created) + while (seen.size > maxDirs) { + // `first` is a workspaceKey, not a raw directory. + // child()/evict() internally call workspaceKey() which is idempotent. + const first = seen.keys().next().value + if (!first) break + const stale = [...(seen.get(first) ?? [])] + seen.delete(first) + const [, setStore] = globalSync.child(first, { bootstrap: false }) + evict(first, setStore, stale) + } + return created + } + const setOptimistic = (directory: string, sessionID: string, item: OptimisticItem) => { const key = keyFor(directory, sessionID) const list = optimistic.get(key) @@ -229,26 +253,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ ...(optimistic.get(keyFor(directory, sessionID))?.values() ?? []), ] - const seenFor = (directory: string) => { - const existing = seen.get(directory) - if (existing) { - seen.delete(directory) - seen.set(directory, existing) - return existing - } - const created = new Set() - seen.set(directory, created) - while (seen.size > maxDirs) { - const first = seen.keys().next().value - if (!first) break - const stale = [...(seen.get(first) ?? [])] - seen.delete(first) - const [, setStore] = globalSync.child(first, { bootstrap: false }) - evict(first, setStore, stale) - } - return created - } - const clearMeta = (directory: string, sessionIDs: string[]) => { if (sessionIDs.length === 0) return for (const sessionID of sessionIDs) { @@ -311,7 +315,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ } } - const tracked = (directory: string, sessionID: string) => seen.get(directory)?.has(sessionID) ?? false + const tracked = (directory: string, sessionID: string) => seen.get(workspaceKey(directory))?.has(sessionID) ?? false const loadMessages = async (input: { directory: string diff --git a/packages/app/src/pages/layout/helpers.ts b/packages/app/src/pages/layout/helpers.ts index 48158debba1d..8548c9f868ed 100644 --- a/packages/app/src/pages/layout/helpers.ts +++ b/packages/app/src/pages/layout/helpers.ts @@ -31,13 +31,14 @@ function sortSessions(now: number) { const isRootVisibleSession = (session: Session, directory: string) => workspaceKey(session.directory) === workspaceKey(directory) && !session.parentID && !session.time?.archived -const roots = (store: SessionStore) => - (store.session ?? []).filter((session) => isRootVisibleSession(session, store.path.directory)) +const roots = (store: SessionStore, dir?: string) => + (store.session ?? []).filter((session) => isRootVisibleSession(session, dir ?? store.path.directory)) -export const sortedRootSessions = (store: SessionStore, now: number) => roots(store).sort(sortSessions(now)) +export const sortedRootSessions = (store: SessionStore, now: number, dir?: string) => + roots(store, dir).sort(sortSessions(now)) export const latestRootSession = (stores: SessionStore[], now: number) => - stores.flatMap(roots).sort(sortSessions(now))[0] + stores.flatMap((s) => roots(s)).sort(sortSessions(now))[0] export function hasProjectPermissions( request: Record | undefined, diff --git a/packages/app/src/pages/layout/sidebar-project.tsx b/packages/app/src/pages/layout/sidebar-project.tsx index 7c9ae1aafba6..f847788e7981 100644 --- a/packages/app/src/pages/layout/sidebar-project.tsx +++ b/packages/app/src/pages/layout/sidebar-project.tsx @@ -301,10 +301,10 @@ export const SortableProject = (props: { } const projectStore = createMemo(() => globalSync.child(props.project.worktree, { bootstrap: false })[0]) - const projectSessions = createMemo(() => sortedRootSessions(projectStore(), props.sortNow())) + const projectSessions = createMemo(() => sortedRootSessions(projectStore(), props.sortNow(), props.project.worktree)) const workspaceSessions = (directory: string) => { const [data] = globalSync.child(directory, { bootstrap: false }) - return sortedRootSessions(data, props.sortNow()) + return sortedRootSessions(data, props.sortNow(), directory) } const tile = () => ( base64Encode(props.directory)) - const sessions = createMemo(() => sortedRootSessions(workspaceStore, props.sortNow())) + const sessions = createMemo(() => sortedRootSessions(workspaceStore, props.sortNow(), props.directory)) const local = createMemo(() => props.directory === props.project.worktree) const active = createMemo(() => workspaceKey(props.ctx.currentDir()) === workspaceKey(props.directory)) const workspaceValue = createMemo(() => { @@ -451,7 +451,7 @@ export const LocalWorkspace = (props: { return { store, setStore } }) const slug = createMemo(() => base64Encode(props.project.worktree)) - const sessions = createMemo(() => sortedRootSessions(workspace().store, props.sortNow())) + const sessions = createMemo(() => sortedRootSessions(workspace().store, props.sortNow(), props.project.worktree)) const booted = createMemo((prev) => prev || workspace().store.status === "complete", false) const count = createMemo(() => sessions()?.length ?? 0) const loading = createMemo(() => !booted() && count() === 0) diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index f4b8b940d20f..c564fd6f7c35 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -2,6 +2,7 @@ import z from "zod" import { and, Database, eq } from "../storage/db" import { ProjectTable } from "./project.sql" import { SessionTable } from "../session/session.sql" +import { Filesystem } from "../util/filesystem" import { Log } from "../util/log" import { Flag } from "@/flag/flag" import { BusEvent } from "@/bus/bus-event" @@ -61,7 +62,7 @@ export namespace Project { : undefined return { id: row.id, - worktree: row.worktree, + worktree: Filesystem.posixPath(row.worktree), vcs: row.vcs ? Info.shape.vcs.parse(row.vcs) : undefined, name: row.name ?? undefined, icon, @@ -70,7 +71,7 @@ export namespace Project { updated: row.time_updated, initialized: row.time_initialized ?? undefined, }, - sandboxes: row.sandboxes, + sandboxes: row.sandboxes.map(Filesystem.posixPath), commands: row.commands ?? undefined, } } diff --git a/packages/opencode/src/server/instance.ts b/packages/opencode/src/server/instance.ts index 65ea2fac2ec1..9a99b750aeaf 100644 --- a/packages/opencode/src/server/instance.ts +++ b/packages/opencode/src/server/instance.ts @@ -4,6 +4,7 @@ import { proxy } from "hono/proxy" import type { UpgradeWebSocket } from "hono/ws" import z from "zod" import { createHash } from "node:crypto" +import { Filesystem } from "../util/filesystem" import { Log } from "../util/log" import { Format } from "../format" import { TuiRoutes } from "./routes/tui" @@ -110,11 +111,11 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket, app: Hono = new Hono() }), async (c) => { return c.json({ - home: Global.Path.home, - state: Global.Path.state, - config: Global.Path.config, - worktree: Instance.worktree, - directory: Instance.directory, + home: Filesystem.posixPath(Global.Path.home), + state: Filesystem.posixPath(Global.Path.state), + config: Filesystem.posixPath(Global.Path.config), + worktree: Filesystem.posixPath(Instance.worktree), + directory: Filesystem.posixPath(Instance.directory), }) }, ) diff --git a/packages/opencode/src/server/routes/file.ts b/packages/opencode/src/server/routes/file.ts index 60789ef4b722..ee55d714b757 100644 --- a/packages/opencode/src/server/routes/file.ts +++ b/packages/opencode/src/server/routes/file.ts @@ -6,6 +6,7 @@ import { Ripgrep } from "../../file/ripgrep" import { LSP } from "../../lsp" import { Instance } from "../../project/instance" import { lazy } from "../../util/lazy" +import { Filesystem } from "../../util/filesystem" export const FileRoutes = lazy(() => new Hono() @@ -138,7 +139,7 @@ export const FileRoutes = lazy(() => }), ), async (c) => { - const path = c.req.valid("query").path + const path = Filesystem.posixPath(c.req.valid("query").path) const content = await File.list(path) return c.json(content) }, @@ -167,7 +168,7 @@ export const FileRoutes = lazy(() => }), ), async (c) => { - const path = c.req.valid("query").path + const path = Filesystem.posixPath(c.req.valid("query").path) const content = await File.read(path) return c.json(content) }, diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index 65032de96252..7548052dafea 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -15,6 +15,7 @@ import type { SQL } from "../storage/db" import { SessionTable } from "./session.sql" import { ProjectTable } from "../project/project.sql" import { Storage } from "@/storage/storage" +import { Filesystem } from "../util/filesystem" import { Log } from "../util/log" import { updateSchema } from "../util/update-schema" import { MessageV2 } from "./message-v2" @@ -42,6 +43,8 @@ export namespace Session { const parentTitlePrefix = "New session - " const childTitlePrefix = "Child session - " + const normalizeDirectory = Filesystem.posixPath + function createDefaultTitle(isChild = false) { return (isChild ? childTitlePrefix : parentTitlePrefix) + new Date().toISOString() } @@ -71,7 +74,7 @@ export namespace Session { slug: row.slug, projectID: row.project_id, workspaceID: row.workspace_id ?? undefined, - directory: row.directory, + directory: normalizeDirectory(row.directory), parentID: row.parent_id ?? undefined, title: row.title, version: row.version, @@ -389,7 +392,7 @@ export namespace Session { slug: Slug.create(), version: Installation.VERSION, projectID: ctx.project.id, - directory: input.directory, + directory: normalizeDirectory(input.directory), workspaceID: input.workspaceID, parentID: input.parentID, title: input.title ?? createDefaultTitle(!!input.parentID), @@ -751,7 +754,7 @@ export namespace Session { conditions.push(eq(SessionTable.workspace_id, input.workspaceID)) } if (input?.directory) { - conditions.push(eq(SessionTable.directory, input.directory)) + conditions.push(eq(SessionTable.directory, normalizeDirectory(input.directory))) } if (input?.roots) { conditions.push(isNull(SessionTable.parent_id)) @@ -791,7 +794,7 @@ export namespace Session { const conditions: SQL[] = [] if (input?.directory) { - conditions.push(eq(SessionTable.directory, input.directory)) + conditions.push(eq(SessionTable.directory, normalizeDirectory(input.directory))) } if (input?.roots) { conditions.push(isNull(SessionTable.parent_id)) diff --git a/packages/opencode/src/util/filesystem.ts b/packages/opencode/src/util/filesystem.ts index 5f50231b0328..4065bd337a1e 100644 --- a/packages/opencode/src/util/filesystem.ts +++ b/packages/opencode/src/util/filesystem.ts @@ -2,7 +2,7 @@ import { chmod, mkdir, readFile, stat as statFile, writeFile } from "fs/promises import { createWriteStream, existsSync, statSync } from "fs" import { lookup } from "mime-types" import { realpathSync } from "fs" -import { dirname, join, relative, resolve as pathResolve, win32 } from "path" +import { dirname, isAbsolute, join, relative, resolve as pathResolve, win32 } from "path" import { Readable } from "stream" import { pipeline } from "stream/promises" import { Glob } from "./glob" @@ -156,14 +156,25 @@ export namespace Filesystem { .replace(/^\/mnt\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`) ) } + export function posixPath(p: string): string { + const value = p.replaceAll("\\", "/") + const drive = value.match(/^([A-Za-z]:)\/+$/) + if (drive) return `${drive[1]}/` + if (/^\/+$/i.test(value)) return "/" + return value.replace(/\/+$/, "") + } + export function overlaps(a: string, b: string) { const relA = relative(a, b) const relB = relative(b, a) + if (isAbsolute(relA) || isAbsolute(relB)) return false return !relA || !relA.startsWith("..") || !relB || !relB.startsWith("..") } export function contains(parent: string, child: string) { - return !relative(parent, child).startsWith("..") + const rel = relative(parent, child) + if (isAbsolute(rel)) return false + return !rel.startsWith("..") } export async function findUp( diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index b34364ccd871..61def98801c7 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -1,5 +1,6 @@ import z from "zod" import { NamedError } from "@opencode-ai/util/error" +import { Filesystem } from "../util/filesystem" import { Global } from "../global" import { Instance } from "../project/instance" import { InstanceBootstrap } from "../project/bootstrap" @@ -213,7 +214,7 @@ export namespace Worktree { const branchCheck = yield* git(["show-ref", "--verify", "--quiet", ref], { cwd: ctx.worktree }) if (branchCheck.code === 0) continue - return Info.parse({ name, branch, directory }) + return Info.parse({ name, branch, directory: Filesystem.posixPath(directory) }) } throw new NameGenerationFailedError({ message: "Failed to generate a unique worktree name" }) }) From fa308a7bbf573107eebb82d964a67ae6b2843f12 Mon Sep 17 00:00:00 2001 From: "chenyiy@133.cn" Date: Fri, 10 Apr 2026 15:44:37 +0800 Subject: [PATCH 2/3] test(opencode): normalize path comparisons with posixPath for Windows compatibility (#21441) - Update project.test.ts to use Filesystem.posixPath for sandbox comparisons - Update global-session-list.test.ts to use Filesystem.posixPath for worktree comparisons - Add unit tests for posixPath, contains, and overlaps --- .../opencode/test/project/project.test.ts | 11 +-- .../test/server/global-session-list.test.ts | 5 +- .../opencode/test/util/filesystem.test.ts | 79 +++++++++++++++++++ 3 files changed, 88 insertions(+), 7 deletions(-) diff --git a/packages/opencode/test/project/project.test.ts b/packages/opencode/test/project/project.test.ts index 988ae27426c6..7596b1de0a28 100644 --- a/packages/opencode/test/project/project.test.ts +++ b/packages/opencode/test/project/project.test.ts @@ -4,6 +4,7 @@ import { Log } from "../../src/util/log" import { $ } from "bun" import path from "path" import { tmpdir } from "../fixture/fixture" +import { Filesystem } from "../../src/util/filesystem" import { GlobalBus } from "../../src/bus/global" import { ProjectID } from "../../src/project/schema" import { Effect, Layer, Stream } from "effect" @@ -226,9 +227,9 @@ describe("Project.fromDirectory with worktrees", () => { const { project } = await Project.fromDirectory(worktree2) expect(project.worktree).toBe(tmp.path) - expect(project.sandboxes).toContain(worktree1) - expect(project.sandboxes).toContain(worktree2) - expect(project.sandboxes).not.toContain(tmp.path) + expect(project.sandboxes.map(Filesystem.posixPath)).toContain(Filesystem.posixPath(worktree1)) + expect(project.sandboxes.map(Filesystem.posixPath)).toContain(Filesystem.posixPath(worktree2)) + expect(project.sandboxes.map(Filesystem.posixPath)).not.toContain(Filesystem.posixPath(tmp.path)) } finally { await $`git worktree remove ${worktree1}` .cwd(tmp.path) @@ -434,12 +435,12 @@ describe("Project.addSandbox and Project.removeSandbox", () => { await Project.addSandbox(project.id, sandboxDir) let found = Project.get(project.id) - expect(found?.sandboxes).toContain(sandboxDir) + expect(found?.sandboxes.map(Filesystem.posixPath)).toContain(Filesystem.posixPath(sandboxDir)) await Project.removeSandbox(project.id, sandboxDir) found = Project.get(project.id) - expect(found?.sandboxes).not.toContain(sandboxDir) + expect(found?.sandboxes.map(Filesystem.posixPath)).not.toContain(Filesystem.posixPath(sandboxDir)) }) test("addSandbox emits GlobalBus event", async () => { diff --git a/packages/opencode/test/server/global-session-list.test.ts b/packages/opencode/test/server/global-session-list.test.ts index 05d6de04b1b1..0da88d336c1d 100644 --- a/packages/opencode/test/server/global-session-list.test.ts +++ b/packages/opencode/test/server/global-session-list.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test" import { Instance } from "../../src/project/instance" import { Project } from "../../src/project/project" import { Session } from "../../src/session" +import { Filesystem } from "../../src/util/filesystem" import { Log } from "../../src/util/log" import { tmpdir } from "../fixture/fixture" @@ -34,9 +35,9 @@ describe("Session.listGlobal", () => { const secondItem = sessions.find((session) => session.id === secondSession.id) expect(firstItem?.project?.id).toBe(firstProject?.id) - expect(firstItem?.project?.worktree).toBe(firstProject?.worktree) + expect(Filesystem.posixPath(firstItem!.project!.worktree)).toBe(Filesystem.posixPath(firstProject!.worktree)) expect(secondItem?.project?.id).toBe(secondProject?.id) - expect(secondItem?.project?.worktree).toBe(secondProject?.worktree) + expect(Filesystem.posixPath(secondItem!.project!.worktree)).toBe(Filesystem.posixPath(secondProject!.worktree)) }) test("excludes archived sessions by default", async () => { diff --git a/packages/opencode/test/util/filesystem.test.ts b/packages/opencode/test/util/filesystem.test.ts index 3abcf011bc06..22b332cf32de 100644 --- a/packages/opencode/test/util/filesystem.test.ts +++ b/packages/opencode/test/util/filesystem.test.ts @@ -645,6 +645,85 @@ describe("filesystem", () => { }) }) + describe("posixPath()", () => { + test("replaces backslashes with forward slashes", () => { + expect(Filesystem.posixPath("C:\\Users\\foo\\bar")).toBe("C:/Users/foo/bar") + }) + + test("normalizes drive root with trailing slashes", () => { + expect(Filesystem.posixPath("C:/")).toBe("C:/") + expect(Filesystem.posixPath("C:\\")).toBe("C:/") + expect(Filesystem.posixPath("C:\\\\")).toBe("C:/") + expect(Filesystem.posixPath("d:/")).toBe("d:/") + }) + + test("normalizes root path", () => { + expect(Filesystem.posixPath("/")).toBe("/") + expect(Filesystem.posixPath("///")).toBe("/") + }) + + test("strips trailing slashes", () => { + expect(Filesystem.posixPath("C:/Users/foo/")).toBe("C:/Users/foo") + expect(Filesystem.posixPath("/a/b/c/")).toBe("/a/b/c") + expect(Filesystem.posixPath("/a/b/c///")).toBe("/a/b/c") + }) + + test("preserves already-normalized posix paths", () => { + expect(Filesystem.posixPath("C:/Users/foo")).toBe("C:/Users/foo") + expect(Filesystem.posixPath("/home/user/project")).toBe("/home/user/project") + }) + + test("handles mixed separators", () => { + expect(Filesystem.posixPath("C:\\Users/foo\\bar")).toBe("C:/Users/foo/bar") + }) + }) + + describe("contains()", () => { + test("returns true for child within parent", () => { + expect(Filesystem.contains("/a/b", "/a/b/c")).toBe(true) + expect(Filesystem.contains("/a/b", "/a/b/c/d")).toBe(true) + }) + + test("returns false for sibling paths", () => { + expect(Filesystem.contains("/a/b", "/a/c")).toBe(false) + }) + + test("returns false for parent outside child", () => { + expect(Filesystem.contains("/a/b/c", "/a/b")).toBe(false) + }) + + test("returns true for same path", () => { + expect(Filesystem.contains("/a/b", "/a/b")).toBe(true) + }) + + test("returns false for cross-root paths on Windows", () => { + if (process.platform !== "win32") return + expect(Filesystem.contains("C:\\Users\\foo", "/etc/passwd")).toBe(false) + expect(Filesystem.contains("C:\\Users\\foo", "D:\\other")).toBe(false) + }) + }) + + describe("overlaps()", () => { + test("returns true when paths overlap", () => { + expect(Filesystem.overlaps("/a/b", "/a/b/c")).toBe(true) + expect(Filesystem.overlaps("/a/b/c", "/a/b")).toBe(true) + }) + + test("returns true for same path", () => { + expect(Filesystem.overlaps("/a/b", "/a/b")).toBe(true) + }) + + test("returns false for unrelated paths", () => { + expect(Filesystem.overlaps("/a", "/b")).toBe(false) + }) + + test("returns false for cross-root paths on Windows", () => { + if (process.platform !== "win32") return + expect(Filesystem.overlaps("C:\\Users\\foo", "D:\\other")).toBe(false) + expect(Filesystem.overlaps("C:\\Users\\foo", "/etc/passwd")).toBe(false) + }) + }) + describe("normalizePathPattern()", () => { test("preserves drive root globs on Windows", async () => { if (process.platform !== "win32") return From 8d8e6253fdf031d8785d468f1c05c0066a371419 Mon Sep 17 00:00:00 2001 From: "chenyiy@133.cn" Date: Sat, 11 Apr 2026 00:02:38 +0800 Subject: [PATCH 3/3] fix: resolve Windows path normalization issues causing E2E failures - Resolve 8.3 short paths in Global.Path via Filesystem.resolve() to ensure consistent canonical paths for data/cache/config/state directories - Normalize path.cwd and path.root with posixPath() in session prompt messages for cross-platform consistency - Fix session project_id update query to use posixPath() on worktree comparison --- packages/opencode/src/global/index.ts | 8 ++++---- packages/opencode/src/project/project.ts | 2 +- packages/opencode/src/session/prompt.ts | 7 ++++--- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/opencode/src/global/index.ts b/packages/opencode/src/global/index.ts index 869019e2ce74..ae5744a65ce8 100644 --- a/packages/opencode/src/global/index.ts +++ b/packages/opencode/src/global/index.ts @@ -6,10 +6,10 @@ import { Filesystem } from "../util/filesystem" const app = "opencode" -const data = path.join(xdgData!, app) -const cache = path.join(xdgCache!, app) -const config = path.join(xdgConfig!, app) -const state = path.join(xdgState!, app) +const data = Filesystem.resolve(path.join(xdgData!, app)) +const cache = Filesystem.resolve(path.join(xdgCache!, app)) +const config = Filesystem.resolve(path.join(xdgConfig!, app)) +const state = Filesystem.resolve(path.join(xdgState!, app)) export namespace Global { export const Path = { diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index 118a53e99f01..b0f341c33e13 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -316,7 +316,7 @@ export namespace Project { d .update(SessionTable) .set({ project_id: data.id }) - .where(and(eq(SessionTable.project_id, ProjectID.global), eq(SessionTable.directory, data.worktree))) + .where(and(eq(SessionTable.project_id, ProjectID.global), eq(SessionTable.directory, Filesystem.posixPath(data.worktree)))) .run(), ) } diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 33be6b9c58f9..4592a01c30b6 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -48,6 +48,7 @@ import { InstanceState } from "@/effect/instance-state" import { makeRuntime } from "@/effect/run-service" import { TaskTool } from "@/tool/task" import { SessionRunState } from "./run-state" +import { Filesystem } from "../util/filesystem" // @ts-ignore globalThis.AI_SDK_LOG_WARNINGS = false @@ -531,7 +532,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the mode: task.agent, agent: task.agent, variant: lastUser.model.variant, - path: { cwd: ctx.directory, root: ctx.worktree }, + path: { cwd: Filesystem.posixPath(ctx.directory), root: Filesystem.posixPath(ctx.worktree) }, cost: 0, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, modelID: taskModel.id, @@ -746,7 +747,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the mode: input.agent, agent: input.agent, cost: 0, - path: { cwd: ctx.directory, root: ctx.worktree }, + path: { cwd: Filesystem.posixPath(ctx.directory), root: Filesystem.posixPath(ctx.worktree) }, time: { created: Date.now() }, role: "assistant", tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, @@ -1405,7 +1406,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the mode: agent.name, agent: agent.name, variant: lastUser.model.variant, - path: { cwd: ctx.directory, root: ctx.worktree }, + path: { cwd: Filesystem.posixPath(ctx.directory), root: Filesystem.posixPath(ctx.worktree) }, cost: 0, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, modelID: model.id,