mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-09 10:59:49 -04:00
fix(core): reassign orphaned sessions
This commit is contained in:
@@ -269,8 +269,7 @@ Before transforming V1 rows, look for `opencode-next.db` in the data directory.
|
||||
builds. Open it read-only with Bun SQLite and copy its `project`, `session`, and `session_message` rows directly into the
|
||||
current `project`, `session_v2`, and `session_message` tables. Existing current projects and Sessions win ID collisions.
|
||||
Do not copy its durable events or runtime caches; initialize each imported Session's `event_sequence` watermark from its
|
||||
maximum message sequence. Commit each imported Session independently and leave the source database untouched. Skip and
|
||||
warn for beta Sessions whose referenced project row is missing rather than blocking the remaining migration.
|
||||
maximum message sequence. Commit each imported Session independently and leave the source database untouched.
|
||||
|
||||
The previous V2 import is part of this migration and uses the same completion marker. It needs no source-specific cursor:
|
||||
the destination Session row is the per-Session idempotency boundary, so a retry skips transactions that already committed.
|
||||
@@ -289,9 +288,10 @@ session-level backfills, `event_sequence` watermark, and cursor update. If inter
|
||||
rolls back and the next endpoint call retries the same session. If it committed, the next call continues after the stored
|
||||
cursor. Mark the migration complete after the final session and return immediately on later calls.
|
||||
|
||||
Process every `session` row, including archived, root, child, and empty sessions, as well as sessions whose messages are
|
||||
all skipped or internal. Skip and warn for V1 Sessions whose referenced project row is missing. Each successfully
|
||||
committed or skipped session advances the cursor.
|
||||
Ensure the global project exists using the current platform's filesystem root as its worktree. Process every `session`
|
||||
row, including archived, root, child, and empty sessions, as well as sessions whose messages are all skipped or internal.
|
||||
Reassign beta and V1 Sessions whose referenced project row is missing to the global project and log a warning. Each
|
||||
successfully committed session advances the cursor.
|
||||
|
||||
## Testing
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Global } from "@opencode-ai/util/global"
|
||||
import { existsSync } from "node:fs"
|
||||
import path from "node:path"
|
||||
import type { Database as SQLiteDatabase } from "bun:sqlite"
|
||||
import { Project } from "@opencode-ai/schema/project"
|
||||
|
||||
export type SourceMessage = {
|
||||
readonly id: string
|
||||
@@ -458,7 +459,13 @@ export function run(options: Options = {}): Effect.Effect<Result, never, Databas
|
||||
if (!(yield* hasLegacySessions(db))) return { status: "completed" as const }
|
||||
running = true
|
||||
const migrate = Effect.gen(function* () {
|
||||
const now = Date.now()
|
||||
yield* db.run(sql`
|
||||
INSERT OR IGNORE INTO project (id, worktree, time_created, time_updated, sandboxes)
|
||||
VALUES (${Project.ID.global}, ${path.parse(Global.Path.data).root}, ${now}, ${now}, '[]')
|
||||
`)
|
||||
yield* importNextDatabase(db, nextPath(options))
|
||||
const projects = new Set((yield* db.all<{ id: string }>(sql`SELECT id FROM project`)).map((project) => project.id))
|
||||
while (true) {
|
||||
const cursor = yield* db
|
||||
.select({ value: KVTable.value })
|
||||
@@ -467,10 +474,10 @@ export function run(options: Options = {}): Effect.Effect<Result, never, Databas
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
const cursorValue = typeof cursor?.value === "string" ? cursor.value : undefined
|
||||
const nextID = yield* db.get<{ id: string }>(
|
||||
const nextID = yield* db.get<{ id: string; project_id: string }>(
|
||||
cursorValue === undefined
|
||||
? sql`SELECT id FROM session ORDER BY id DESC LIMIT 1`
|
||||
: sql`SELECT id FROM session WHERE id < ${cursorValue} ORDER BY id DESC LIMIT 1`,
|
||||
? 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`,
|
||||
)
|
||||
if (!nextID) break
|
||||
yield* db
|
||||
@@ -481,18 +488,12 @@ export function run(options: Options = {}): Effect.Effect<Result, never, Databas
|
||||
.values({ key: cursorKey, value: nextID.id })
|
||||
.onConflictDoUpdate({ target: KVTable.key, set: { value: nextID.id, time_updated: Date.now() } })
|
||||
.run()
|
||||
const source = yield* tx.get<{ project_id: string }>(
|
||||
sql`SELECT project_id FROM session WHERE id = ${nextID.id}`,
|
||||
)
|
||||
if (!source) return yield* Effect.die(new Error(`Missing V1 session ${nextID.id}`))
|
||||
const project = yield* tx.get<{ id: string }>(sql`SELECT id FROM project WHERE id = ${source.project_id}`)
|
||||
if (!project) {
|
||||
yield* Effect.logWarning("Skipped V1 session with missing project", {
|
||||
const projectID = projects.has(nextID.project_id) ? nextID.project_id : Project.ID.global
|
||||
if (projectID !== nextID.project_id)
|
||||
yield* Effect.logWarning("Reassigned V1 session with missing project", {
|
||||
sessionID: nextID.id,
|
||||
projectID: source.project_id,
|
||||
projectID: nextID.project_id,
|
||||
})
|
||||
return
|
||||
}
|
||||
yield* tx.run(sql`
|
||||
INSERT OR IGNORE INTO session_v2 (
|
||||
id, project_id, workspace_id, parent_id, slug, directory, path, title, version, share_url,
|
||||
@@ -501,7 +502,7 @@ export function run(options: Options = {}): Effect.Effect<Result, never, Databas
|
||||
revert, permission, agent, model, time_created, time_updated, time_compacting, time_archived
|
||||
)
|
||||
SELECT
|
||||
id, project_id, 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, permission, agent, model, time_created, time_updated, time_compacting, time_archived
|
||||
@@ -630,14 +631,12 @@ function importNextDatabase(
|
||||
nextCompleted = 0
|
||||
for (const session of sessions) {
|
||||
const project = projects.get(session.project_id)
|
||||
const projectID = project ? session.project_id : Project.ID.global
|
||||
if (!project) {
|
||||
yield* Effect.logWarning("Skipped previous V2 session with missing project", {
|
||||
yield* Effect.logWarning("Reassigned previous V2 session with missing project", {
|
||||
sessionID: session.id,
|
||||
projectID: session.project_id,
|
||||
})
|
||||
nextCompleted++
|
||||
yield* Effect.yieldNow
|
||||
continue
|
||||
}
|
||||
const messages = source
|
||||
.query<NextMessage, [string]>(
|
||||
@@ -647,16 +646,17 @@ function importNextDatabase(
|
||||
yield* db
|
||||
.transaction((tx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* tx.run(sql`
|
||||
INSERT OR IGNORE INTO project (
|
||||
id, worktree, vcs, name, icon_url, icon_url_override, icon_color,
|
||||
time_created, time_updated, time_initialized, sandboxes, commands
|
||||
) VALUES (
|
||||
${project.id}, ${project.worktree}, ${project.vcs}, ${project.name}, ${project.icon_url},
|
||||
${project.icon_url_override}, ${project.icon_color}, ${project.time_created}, ${project.time_updated},
|
||||
${project.time_initialized}, ${project.sandboxes}, ${project.commands}
|
||||
)
|
||||
`)
|
||||
if (project)
|
||||
yield* tx.run(sql`
|
||||
INSERT OR IGNORE INTO project (
|
||||
id, worktree, vcs, name, icon_url, icon_url_override, icon_color,
|
||||
time_created, time_updated, time_initialized, sandboxes, commands
|
||||
) VALUES (
|
||||
${project.id}, ${project.worktree}, ${project.vcs}, ${project.name}, ${project.icon_url},
|
||||
${project.icon_url_override}, ${project.icon_color}, ${project.time_created}, ${project.time_updated},
|
||||
${project.time_initialized}, ${project.sandboxes}, ${project.commands}
|
||||
)
|
||||
`)
|
||||
const existing = yield* tx
|
||||
.select({ id: SessionTable.id })
|
||||
.from(SessionTable)
|
||||
@@ -671,7 +671,7 @@ function importNextDatabase(
|
||||
tokens_cache_write, revert, permission, agent, model, time_created, time_updated, time_compacting,
|
||||
time_archived, time_suspended
|
||||
) VALUES (
|
||||
${session.id}, ${session.project_id}, ${session.workspace_id}, ${session.parent_id},
|
||||
${session.id}, ${projectID}, ${session.workspace_id}, ${session.parent_id},
|
||||
${session.fork_session_id}, ${session.fork_boundary}, ${session.slug}, ${session.directory},
|
||||
${session.path}, ${session.title}, ${session.version}, ${session.share_url},
|
||||
${session.summary_additions}, ${session.summary_deletions}, ${session.summary_files},
|
||||
|
||||
@@ -10,6 +10,7 @@ import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Effect, Logger, Schema } from "effect"
|
||||
import { eq, sql } from "drizzle-orm"
|
||||
import type { SqlClient } from "effect/unstable/sql/SqlClient"
|
||||
@@ -890,7 +891,9 @@ describe("V1Migration database workflow", () => {
|
||||
expect(yield* db.all(sql`SELECT id FROM session_message WHERE session_id = 'ses_existing'`)).toEqual([
|
||||
{ id: "msg_current_existing" },
|
||||
])
|
||||
expect(yield* db.get(sql`SELECT id FROM session_v2 WHERE id = 'ses_orphan'`)).toBeUndefined()
|
||||
expect(yield* db.get(sql`SELECT project_id FROM session_v2 WHERE id = 'ses_orphan'`)).toEqual({
|
||||
project_id: "global",
|
||||
})
|
||||
expect(yield* db.get(sql`SELECT name, worktree FROM project WHERE id = 'next-project'`)).toEqual({
|
||||
name: "Current project",
|
||||
worktree: "/tmp/current",
|
||||
@@ -932,7 +935,7 @@ describe("V1Migration database workflow", () => {
|
||||
)
|
||||
})
|
||||
|
||||
test("skips V1 sessions whose projects are missing", async () => {
|
||||
test("reassigns V1 sessions whose projects are missing to the global project", async () => {
|
||||
await database(
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
@@ -941,7 +944,12 @@ describe("V1Migration database workflow", () => {
|
||||
)
|
||||
|
||||
expect(yield* V1Migration.run()).toEqual({ status: "completed" })
|
||||
expect(yield* db.get(sql`SELECT id FROM session_v2 WHERE id = 'ses_orphan'`)).toBeUndefined()
|
||||
expect(yield* db.get(sql`SELECT project_id FROM session_v2 WHERE id = 'ses_orphan'`)).toEqual({
|
||||
project_id: "global",
|
||||
})
|
||||
expect(yield* db.get(sql`SELECT worktree FROM project WHERE id = 'global'`)).toEqual({
|
||||
worktree: path.parse(Global.Path.data).root,
|
||||
})
|
||||
expect(yield* db.get(sql`SELECT value FROM kv WHERE key = 'migration.v1-v2.completed'`)).toEqual({
|
||||
value: "true",
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user