fix(core): resolve relative directories during V1 migration (#57618)

Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
This commit is contained in:
MohammadAlMasalmeh
2026-10-21 00:26:53 -05:00
committed by GitHub
Unverified
parent 94bc5662c4
commit c3b613cac8
3 changed files with 67 additions and 12 deletions
+7 -4
View File
@@ -12,12 +12,15 @@ function isWindowsStoragePath(input: string) {
return /^[A-Za-z]:\//.test(input) || input.startsWith("//")
}
function absolute(input: string) {
export function storedAbsolutePath(input: string) {
const result = storagePath(input)
// Persisted projects and sessions can move between operating systems during migration.
if (!nodePath.posix.isAbsolute(result) && !isWindowsStoragePath(result)) {
throw new Error(`Path is not absolute: ${input}`)
}
if (nodePath.posix.isAbsolute(result) || isWindowsStoragePath(result)) return result
}
function absolute(input: string) {
const result = storedAbsolutePath(input)
if (result === undefined) throw new Error(`Path is not absolute: ${input}`)
return result
}
+22 -6
View File
@@ -2,6 +2,7 @@ export * as V1Migration from "./v1-migration.js"
import { Cause, Effect, Layer, Option, Schema, Semaphore } from "effect"
import { Database } from "./database.js"
import { storedAbsolutePath } from "./path.js"
import { SessionMessageTable, SessionTable } from "../session/sql.js"
import { SessionV1 } from "@opencode/schema/session-v1"
import { SessionMessage } from "../session/message.js"
@@ -580,16 +581,19 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
updateProgress({ label: "Migrating sessions", numerator: migrated + completed, denominator })
})
updateProgress({ label: "Migrating sessions", numerator: migrated + sourceTotal, denominator })
const projects = new Set(
(yield* db.all<{ id: string }>(sql`SELECT id FROM project`)).map((project) => project.id),
const projects = new Map(
(yield* db.all<{ id: string; worktree: string }>(sql`SELECT id, worktree FROM project`)).map((project) => [
project.id,
project.worktree,
]),
)
while (true) {
const state = yield* readState(db)
const cursorValue = state?.phase === "sessions" ? state.cursor : undefined
const nextID = yield* db.get<{ id: string; project_id: string }>(
const nextID = yield* db.get<{ id: string; project_id: string; directory: string }>(
cursorValue === undefined
? sql`SELECT id, project_id FROM session ORDER BY id DESC LIMIT 1`
: sql`SELECT id, project_id FROM session WHERE id < ${cursorValue} ORDER BY id DESC LIMIT 1`,
? sql`SELECT id, project_id, directory FROM session ORDER BY id DESC LIMIT 1`
: sql`SELECT id, project_id, directory FROM session WHERE id < ${cursorValue} ORDER BY id DESC LIMIT 1`,
)
if (!nextID) break
yield* db
@@ -609,6 +613,18 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
sessionID: nextID.id,
projectID: nextID.project_id,
})
// Legacy ACP sessions can store a cwd relative to an unknown process directory.
// Use the project worktree instead, and leave the original V1 row intact.
const directory =
!nextID.directory || storedAbsolutePath(nextID.directory)
? nextID.directory
: (storedAbsolutePath(projects.get(projectID) ?? path.parse(global.data).root) ?? "")
if (directory !== nextID.directory)
yield* Effect.logWarning("Replaced relative V1 session directory", {
sessionID: nextID.id,
directory: nextID.directory,
resolved: directory,
})
yield* tx.run(sql`
INSERT OR IGNORE INTO session_v2 (
id, project_id, workspace_id, parent_id, slug, directory, path, title, version, share_url,
@@ -617,7 +633,7 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
revert, permission, agent, model, time_created, time_updated, time_compacting, time_archived
)
SELECT
id, ${projectID}, workspace_id, parent_id, slug, directory, path, title, version, share_url,
id, ${projectID}, workspace_id, parent_id, slug, ${directory}, path, title, version, share_url,
summary_additions, summary_deletions, summary_files, summary_diffs, metadata, cost,
tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write,
revert, NULL, agent, model, time_created, time_updated, time_compacting, time_archived
+38 -2
View File
@@ -1120,13 +1120,46 @@ describe("V1Migration database workflow", () => {
)
})
test("reassigns V1 sessions whose projects are missing to the global project", async () => {
test.each([
{ worktree: "/repo", directory: ".", expected: "/repo" },
{ worktree: "/repo", directory: "src", expected: "/repo" },
{ worktree: "C:/repo", directory: ".", expected: "C:/repo" },
{ worktree: "//server/share/repo", directory: ".", expected: "//server/share/repo" },
{ worktree: "/repo", directory: "C:/other", expected: "C:/other" },
{ worktree: "/repo", directory: "//server/share/other", expected: "//server/share/other" },
{ worktree: "/repo", directory: "", expected: "" },
{ worktree: "relative", directory: ".", expected: "" },
])("migrates $directory in $worktree", async (input) => {
await database(
Effect.gen(function* () {
const { db } = yield* Database.Service
yield* db.run(sql`
INSERT INTO project (id, worktree, time_created, time_updated, sandboxes)
VALUES ('saved-project', ${input.worktree}, 1, 2, '[]')
`)
yield* db.run(sql`
INSERT INTO session (id, project_id, slug, directory, title, version, time_created, time_updated)
VALUES ('ses_test', 'saved-project', 'test', ${input.directory}, 'Test', '1', 1, 2)
`)
expect(yield* V1Migration.run()).toEqual({ status: "completed" })
expect(yield* db.get(sql`SELECT directory FROM session_v2 WHERE id = 'ses_test'`)).toEqual({
directory: input.expected,
})
expect(yield* db.get(sql`SELECT directory FROM session WHERE id = 'ses_test'`)).toEqual({
directory: input.directory,
})
}),
)
})
test.each(["/tmp/orphan", "."])("reassigns V1 sessions with missing projects and directory %s", async (directory) => {
await database(
Effect.gen(function* () {
const { db } = yield* Database.Service
const global = yield* Global.Service
yield* db.run(
sql`INSERT INTO session (id, project_id, slug, directory, title, version, time_created, time_updated) VALUES ('ses_orphan', 'missing-project', 'orphan', '/tmp/orphan', 'Orphan', '1', 1, 2)`,
sql`INSERT INTO session (id, project_id, slug, directory, title, version, time_created, time_updated) VALUES ('ses_orphan', 'missing-project', 'orphan', ${directory}, 'Orphan', '1', 1, 2)`,
)
expect(yield* V1Migration.run()).toEqual({ status: "completed" })
@@ -1136,6 +1169,9 @@ describe("V1Migration database workflow", () => {
expect(yield* db.get(sql`SELECT worktree FROM project WHERE id = 'global'`)).toEqual({
worktree: path.parse(global.data).root,
})
expect(yield* db.get(sql`SELECT directory FROM session_v2 WHERE id = 'ses_orphan'`)).toEqual({
directory: directory === "." ? path.parse(global.data).root.replaceAll("\\", "/") : directory,
})
expect(yield* db.get(sql`SELECT value FROM kv WHERE key = 'migration.v1-v2'`)).toEqual({
value: '{"phase":"completed"}',
})