Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 19 additions & 8 deletions packages/core/src/database/path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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
Expand All @@ -54,7 +65,7 @@ export const directoryColumn = customType<{
return input ? absolute(input) : input
},
fromDriver(input) {
return input ? toPlatform(absolute(input)) : input
return legacyDirectory(input)
},
})

Expand Down
66 changes: 66 additions & 0 deletions packages/core/test/session-list.test.ts
Original file line number Diff line number Diff line change
@@ -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}`)
}),
)
})
Loading