diff --git a/packages/core/src/database/path.ts b/packages/core/src/database/path.ts index 379d5f8aa76d..0b2d61e174db 100644 --- a/packages/core/src/database/path.ts +++ b/packages/core/src/database/path.ts @@ -11,12 +11,14 @@ function isWindowsStoragePath(input: string) { return /^[A-Za-z]:\//.test(input) || input.startsWith("//") } -function absolute(input: string) { +function isAbsoluteStoragePath(input: string) { const result = storagePath(input) - if (!nodePath.posix.isAbsolute(result) && !(process.platform === "win32" && isWindowsStoragePath(result))) { - throw new Error(`Path is not absolute: ${input}`) - } - return result + return nodePath.posix.isAbsolute(result) || (process.platform === "win32" && isWindowsStoragePath(result)) +} + +function absolute(input: string) { + if (!isAbsoluteStoragePath(input)) throw new Error(`Path is not absolute: ${input}`) + return storagePath(input) } function toPlatform(input: string) { @@ -40,8 +42,17 @@ export const absoluteColumn = customType<{ }, }) -// Legacy sessions may persist an empty directory. Keep that existing value -// readable while normalizing and validating every real directory. +// Legacy sessions may persist an empty directory or a value that is not a filesystem path at all — +// older web clients stored the session's own URL. Decoding runs per row, so one such value would +// abort the whole statement (a session list of any size would fail on that row). Keep those values +// readable as they are, while every real directory is still normalized and validated. Writes stay +// strict, so a value like this can no longer be stored. +function legacyDirectory(input: string) { + if (!input) return input + if (!isAbsoluteStoragePath(input)) return storagePath(input) + return toPlatform(absolute(input)) +} + export const directoryColumn = customType<{ data: string driverData: string @@ -54,7 +65,7 @@ export const directoryColumn = customType<{ return input ? absolute(input) : input }, fromDriver(input) { - return input ? toPlatform(absolute(input)) : input + return legacyDirectory(input) }, }) diff --git a/packages/core/test/session-list.test.ts b/packages/core/test/session-list.test.ts new file mode 100644 index 000000000000..2c634c108842 --- /dev/null +++ b/packages/core/test/session-list.test.ts @@ -0,0 +1,66 @@ +import { describe, expect } from "bun:test" +import { sql } from "drizzle-orm" +import { Effect, Layer } from "effect" +import { Database } from "@opencode-ai/core/database/database" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { EventV2 } from "@opencode-ai/core/event" +import { Location } from "@opencode-ai/core/location" +import { ProjectV2 } from "@opencode-ai/core/project" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { SessionV2 } from "@opencode-ai/core/session" +import { SessionExecution } from "@opencode-ai/core/session/execution" +import { SessionProjector } from "@opencode-ai/core/session/projector" +import { SessionStore } from "@opencode-ai/core/session/store" +import { testEffect } from "./lib/effect" + +const projects = Layer.succeed( + ProjectV2.Service, + ProjectV2.Service.of({ + resolve: (directory) => Effect.succeed({ id: ProjectV2.ID.global, directory }), + directories: () => Effect.succeed([]), + commit: () => Effect.void, + }), +) +const it = testEffect( + AppNodeBuilder.build( + LayerNode.group([Database.node, EventV2.node, SessionProjector.node, SessionStore.node, SessionV2.node]), + [ + [ProjectV2.node, projects], + [SessionExecution.node, SessionExecution.noopLayer], + ], + ), +) +const location = Location.Ref.make({ directory: AbsolutePath.make("/project") }) +const legacyDirectory = + "http://localhost:3333/L2hvbWUvamFuL3Byb2plY3RzL2hpcHBvY2FjdHVz/session/ses_13416c6c1ffeuwjo0B1mxirCjT" + +describe("SessionV2.list", () => { + it.effect("lists a legacy session whose stored directory is not a filesystem path", () => + Effect.gen(function* () { + const session = yield* SessionV2.Service + const created = yield* session.create({ location }) + + // Older web builds stored the session's own URL in `directory`, which the column's write + // path rejects now, so the value is planted directly. + const { db } = yield* Database.Service + yield* db.run(sql`UPDATE session SET directory = ${legacyDirectory} WHERE id = ${created.id}`) + + const listed = yield* session.list() + expect(listed).toHaveLength(1) + expect(listed[0].id).toBe(created.id) + expect(listed[0].location.directory).toBe(AbsolutePath.make(legacyDirectory)) + }), + ) + + it.effect("rejects a directory that is not a filesystem path", () => + Effect.gen(function* () { + const session = yield* SessionV2.Service + const exit = yield* session + .create({ location: Location.Ref.make({ directory: AbsolutePath.make(legacyDirectory) }) }) + .pipe(Effect.exit) + + expect(String(exit)).toContain(`Path is not absolute: ${legacyDirectory}`) + }), + ) +})