From 31568387074b302ab4851b19c91a6d0141e4e994 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Thu, 17 Sep 2026 12:06:10 +0200 Subject: [PATCH] fix(core): match session directory case by filesystem identity Directory-scoped session lists currently compare stored paths as exact SQL strings. A session moved to D:\codenomad disappears from a D:\CodeNomad listing even though both names refer to the same Windows directory. Resolve existing case variants at read time so historical sessions remain discoverable without rewriting their location or durable events. Use case folding only to identify candidate spellings among distinct directories matching the other selectors, then require filesystem resolution to establish identity. Reuse native Windows normalization because Bun's callback realPath can preserve caller spelling. Keep exact missing-directory histories readable, preserve workspace/search/parent and anchor filters, and apply the accepted directory set before LIMIT in both paging directions. This adds one distinct-directory query to directory-scoped lists and performs bounded filesystem resolution only for candidate aliases; project-wide lists keep their existing path. Add real-filesystem regressions for ASCII, accented and Greek names, pagination, selector isolation and missing directories. Determine the expected filesystem behavior by directory enumeration rather than reusing the resolution logic under test. Restoring the old exact predicate makes three tests fail on default Windows; the fix passes seven store tests on default and explicitly case-sensitive Windows directories. Core move/store tests pass (23 passed, one existing skip), four server instance/import tests pass, core typecheck and LF-normalized Prettier validation pass. --- packages/core/src/session/store.ts | 61 +++++++++++- packages/core/test/session-store.test.ts | 113 +++++++++++++++++++++-- 2 files changed, 165 insertions(+), 9 deletions(-) diff --git a/packages/core/src/session/store.ts b/packages/core/src/session/store.ts index 53bf9e585d0c..6ad2f0bd0e23 100644 --- a/packages/core/src/session/store.ts +++ b/packages/core/src/session/store.ts @@ -1,12 +1,28 @@ export * as SessionStore from "./store.js" -import { and, asc, desc, eq, gt, isNotNull, isNull, like, lt, notInArray, or, sql, type SQL } from "drizzle-orm" +import { + and, + asc, + desc, + eq, + gt, + inArray, + isNotNull, + isNull, + like, + lt, + notInArray, + or, + sql, + type SQL, +} from "drizzle-orm" import { Context, Effect, Layer, Schema } from "effect" import { Project } from "@opencode/schema/project" import { Workspace } from "@opencode/schema/workspace" import { AbsolutePath, PositiveInt, RelativePath } from "@opencode/schema/schema" import { Database } from "../database/database.js" import { makeGlobalNode } from "@opencode/util/effect/app-node" +import { FSUtil } from "@opencode/util/fs-util" import { SessionHistory } from "./history.js" import { MessageDecodeError } from "./error.js" import { SessionMessage } from "./message.js" @@ -90,6 +106,7 @@ const layer = Layer.effect( Service, Effect.gen(function* () { const { db } = yield* Database.Service + const fs = yield* FSUtil.Service return Service.of({ get: Effect.fnUntraced(function* (sessionID) { @@ -102,7 +119,6 @@ const layer = Layer.effect( const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder const sortColumn = SessionTable.time_updated const conditions: SQL[] = [] - if ("directory" in input) conditions.push(eq(SessionTable.directory, input.directory)) if (input.workspaceID) conditions.push(eq(SessionTable.workspace_id, input.workspaceID)) if ("project" in input) conditions.push(eq(SessionTable.project_id, input.project)) if ("project" in input && input.subpath !== undefined) conditions.push(eq(SessionTable.path, input.subpath)) @@ -124,6 +140,45 @@ const layer = Layer.effect( )!, ) } + if ("directory" in input) { + // Older sessions and native moves can retain a different spelling of + // the same directory. Case folding only finds candidates: filesystem + // resolution must prove identity, including case-sensitive volumes. + // normalizePath uses native realpath on Windows; Bun's callback-based + // realPath can otherwise preserve the caller's case spelling. + // Resolve before LIMIT so excluded directories cannot shorten a page. + const candidates = yield* db + .selectDistinct({ directory: SessionTable.directory }) + .from(SessionTable) + .where(conditions.length > 0 ? and(...conditions) : undefined) + .all() + .pipe(Effect.orDie) + const aliases = candidates.filter( + (row) => row.directory !== input.directory && row.directory.toUpperCase() === input.directory.toUpperCase(), + ) + const canonical = + aliases.length > 0 + ? yield* fs.realPath(input.directory).pipe( + Effect.map(FSUtil.normalizePath), + Effect.orElseSucceed(() => undefined), + ) + : undefined + const directories = + canonical === undefined + ? [] + : yield* Effect.forEach( + aliases, + (row) => + fs.realPath(row.directory).pipe( + Effect.map(FSUtil.normalizePath), + Effect.map((resolved) => (resolved === canonical ? [row.directory] : [])), + Effect.orElseSucceed(() => []), + ), + { concurrency: 8 }, + ) + // Exact spelling remains readable even for missing/offline histories. + conditions.push(inArray(SessionTable.directory, [input.directory, ...directories.flat()])) + } const query = db .select() .from(SessionTable) @@ -253,4 +308,4 @@ const layer = Layer.effect( }), ) -export const node = makeGlobalNode({ service: Service, layer, deps: [Database.node] }) +export const node = makeGlobalNode({ service: Service, layer, deps: [Database.node, FSUtil.node] }) diff --git a/packages/core/test/session-store.test.ts b/packages/core/test/session-store.test.ts index 19292795d146..658556700706 100644 --- a/packages/core/test/session-store.test.ts +++ b/packages/core/test/session-store.test.ts @@ -1,5 +1,6 @@ import { describe, expect } from "bun:test" -import { Effect } from "effect" +import { DateTime, Effect } from "effect" +import path from "node:path" import { Bus } from "@opencode/core/bus" import { Database } from "@opencode/core/database/database" import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder" @@ -12,16 +13,21 @@ import { AbsolutePath } from "@opencode/schema/schema" import { Session } from "@opencode/schema/session" import { SessionEvent } from "@opencode/schema/session-event" import { SessionMessage } from "@opencode/schema/session-message" +import { Workspace } from "@opencode/schema/workspace" +import { FSUtil } from "@opencode/util/fs-util" import { LayerNode } from "@opencode/util/effect/layer-node" import { testEffect } from "./lib/effect" const it = testEffect( - AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node]), [ - Bus.node.replace(Bus.configured({ persist: true })), - ]), + AppNodeBuilder.build( + LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, FSUtil.node]), + [Bus.node.replace(Bus.configured({ persist: true }))], + ), ) -const seedSessions = (rows: { id: string; updated: number }[]) => +const seedSessions = ( + rows: { id: string; updated: number; directory?: string; parentID?: Session.ID; workspaceID?: Workspace.ID }[], +) => Effect.gen(function* () { const database = yield* Database.Service const bus = yield* Bus.Service @@ -33,7 +39,11 @@ const seedSessions = (rows: { id: string; updated: number }[]) => yield* bus.publish(SessionEvent.Created, { sessionID, projectID: Project.ID.global, - location: { directory }, + location: { + directory: row.directory ? AbsolutePath.make(row.directory) : directory, + workspaceID: row.workspaceID, + }, + parentID: row.parentID, slug: "store-test", version: "test", }) @@ -51,6 +61,97 @@ const seedSessions = (rows: { id: string; updated: number }[]) => }) describe("SessionStore", () => { + for (const [upperName, lowerName] of [ + ["Checkout", "checkout"], + ["ÉQUIPE", "équipe"], + ["Σ", "ς"], + ]) { + it.effect( + `matches ${upperName}/${lowerName} historical directory spellings only when the filesystem confirms identity`, + () => + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const store = yield* SessionStore.Service + const tmp = yield* fs.makeTempDirectoryScoped({ prefix: "opencode-directory-case-" }) + const upper = AbsolutePath.make(path.join(tmp, upperName!)) + const lower = AbsolutePath.make(path.join(tmp, lowerName!)) + yield* fs.makeDirectory(upper, { recursive: true }) + yield* fs.makeDirectory(lower, { recursive: true }) + // These are one directory on a normal Windows/macOS filesystem and two + // on a case-sensitive filesystem. Exercise the actual host's behavior. + const same = (yield* fs.readDirectory(tmp)).length === 1 + yield* seedSessions([ + { id: "ses_upper", updated: 3, directory: upper }, + { id: "ses_lower", updated: 2, directory: lower }, + { id: "ses_other", updated: 4, directory: path.join(tmp, "Checkout-other") }, + ]) + expect((yield* store.list({ directory: upper })).map((session) => String(session.id))).toEqual( + same ? ["ses_upper", "ses_lower"] : ["ses_upper"], + ) + expect((yield* store.list({ directory: lower })).map((session) => String(session.id))).toEqual( + same ? ["ses_upper", "ses_lower"] : ["ses_lower"], + ) + expect((yield* store.get(Session.ID.make("ses_lower")))?.location.directory).toBe(lower) + }), + ) + } + + it.effect("applies directory identity before page limits and retains search, parent and workspace selectors", () => + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const store = yield* SessionStore.Service + const tmp = yield* fs.makeTempDirectoryScoped({ prefix: "opencode-directory-pages-" }) + const upper = AbsolutePath.make(path.join(tmp, "Checkout")) + const lower = AbsolutePath.make(path.join(tmp, "checkout")) + yield* fs.makeDirectory(upper, { recursive: true }) + yield* fs.makeDirectory(lower, { recursive: true }) + const same = (yield* fs.readDirectory(tmp)).length === 1 + const workspaceID = Workspace.ID.make("wrk_test") + yield* seedSessions([ + { id: "ses_match_a", updated: 5, directory: upper, workspaceID }, + { id: "ses_match_b", updated: 4, directory: lower, workspaceID }, + { id: "ses_match_c", updated: 3, directory: upper, workspaceID }, + { id: "ses_match_child", updated: 8, directory: lower, workspaceID, parentID: Session.ID.make("ses_match_a") }, + { id: "ses_match_foreign", updated: 7, directory: lower, workspaceID: Workspace.ID.make("wrk_other") }, + { id: "ses_unrelated", updated: 6, directory: lower, workspaceID }, + ]) + const input = { directory: upper, workspaceID, parentID: null, search: "match", limit: 2 } as const + const first = yield* store.list(input) + expect(first.map((session) => String(session.id))).toEqual( + same ? ["ses_match_a", "ses_match_b"] : ["ses_match_a", "ses_match_c"], + ) + const last = first[first.length - 1]! + const next = yield* store.list({ + ...input, + anchor: { id: last.id, time: DateTime.toEpochMillis(last.time.updated), direction: "next" }, + }) + expect(next.map((session) => String(session.id))).toEqual(same ? ["ses_match_c"] : []) + const previous = yield* store.list({ + ...input, + anchor: { id: Session.ID.make("ses_match_c"), time: 3, direction: "previous" }, + }) + expect(previous.map((session) => String(session.id))).toEqual( + same ? ["ses_match_a", "ses_match_b"] : ["ses_match_a"], + ) + }), + ) + + it.effect("preserves exact missing-directory history without conflating unresolvable case variants", () => + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const store = yield* SessionStore.Service + const tmp = yield* fs.makeTempDirectoryScoped({ prefix: "opencode-directory-missing-" }) + const upper = AbsolutePath.make(path.join(tmp, "Missing")) + const lower = AbsolutePath.make(path.join(tmp, "missing")) + yield* seedSessions([ + { id: "ses_upper", updated: 2, directory: upper }, + { id: "ses_lower", updated: 1, directory: lower }, + ]) + expect((yield* store.list({ directory: upper })).map((session) => String(session.id))).toEqual(["ses_upper"]) + expect((yield* store.list({ directory: lower })).map((session) => String(session.id))).toEqual(["ses_lower"]) + }), + ) + it.effect("lists by updated time and ID with exclusive two-item pages in either direction", () => Effect.gen(function* () { yield* seedSessions([