From 35ac83fd29b95ae8f385d68aff341942c696e2ba Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 5 Aug 2026 19:41:22 -0400 Subject: [PATCH] refactor(core): auto-start v1 migration --- packages/client/src/effect/api/api.ts | 5 +- .../client/src/effect/generated/client.ts | 8 +- .../client/src/promise/generated/client.ts | 12 --- .../client/src/promise/generated/types.ts | 2 - packages/core/src/database/v1-migration.ts | 44 +++++---- packages/core/test/v1-migration.test.ts | 16 ++-- packages/protocol/src/groups/migration.ts | 13 --- packages/server/src/handlers/migration.ts | 19 ++-- packages/server/src/routes.ts | 4 +- packages/tui/src/app.tsx | 30 +------ .../tui/src/component/migration-overlay.tsx | 89 +++++++++++++------ packages/tui/src/migration.test.ts | 64 ------------- packages/tui/src/migration.ts | 21 ----- 13 files changed, 102 insertions(+), 225 deletions(-) delete mode 100644 packages/tui/src/migration.test.ts delete mode 100644 packages/tui/src/migration.ts diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index 456411c963..d45c50e817 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -1572,11 +1572,8 @@ export type Endpoint27_0Output = | { readonly status: "error"; readonly error: string } export type MigrationV1StatusOperation = () => Effect.Effect -export type Endpoint27_1Output = { readonly status: "running" } -export type MigrationV1RunOperation = () => Effect.Effect - export interface MigrationApi { - readonly v1: { readonly status: MigrationV1StatusOperation; readonly run: MigrationV1RunOperation } + readonly v1: { readonly status: MigrationV1StatusOperation } } export type Endpoint28_0Input = { diff --git a/packages/client/src/effect/generated/client.ts b/packages/client/src/effect/generated/client.ts index 585f6bf71e..dc2071a54d 100644 --- a/packages/client/src/effect/generated/client.ts +++ b/packages/client/src/effect/generated/client.ts @@ -216,7 +216,6 @@ import type { Endpoint26_1Input, Endpoint26_1Output, Endpoint27_0Output, - Endpoint27_1Output, Endpoint28_0Input, Endpoint28_0Output, Endpoint28_1Input, @@ -1222,12 +1221,7 @@ const adaptGroup26 = (raw: RawClient["server.debug"]) => ({ const Endpoint27_0 = (raw: RawClient["server.migration"]) => () => preserveEffect()(raw["migration.v1.status"]({}).pipe(Effect.mapError(mapClientError))) -const Endpoint27_1 = (raw: RawClient["server.migration"]) => () => - preserveEffect()(raw["migration.v1.run"]({}).pipe(Effect.mapError(mapClientError))) - -const adaptGroup27 = (raw: RawClient["server.migration"]) => ({ - v1: { status: Endpoint27_0(raw), run: Endpoint27_1(raw) }, -}) +const adaptGroup27 = (raw: RawClient["server.migration"]) => ({ v1: { status: Endpoint27_0(raw) } }) const Endpoint28_0 = (raw: RawClient["server.websearch"]) => (input?: Endpoint28_0Input) => preserveEffect()( diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index cfabe8d13c..42d0440fc0 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -212,7 +212,6 @@ import type { DebugLocationEvictInput, DebugLocationEvictOutput, MigrationV1StatusOutput, - MigrationV1RunOutput, WebsearchProvidersInput, WebsearchProvidersOutput, WebsearchQueryInput, @@ -1783,17 +1782,6 @@ export function make(options: ClientOptions) { }, requestOptions, ), - run: (requestOptions?: RequestOptions) => - request( - { - method: "POST", - path: `/api/experimental/migration/v1`, - successStatus: 200, - declaredStatuses: [401, 400], - empty: false, - }, - requestOptions, - ), }, }, websearch: { diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index ececd98170..010fb2dc21 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -4570,8 +4570,6 @@ export type MigrationV1StatusOutput = | { status: "running"; progress: { label: string; numerator?: number | undefined; denominator?: number | undefined } } | { status: "error"; error: string } -export type MigrationV1RunOutput = { status: "running" } - export type WebsearchProvidersInput = { readonly location?: { readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined diff --git a/packages/core/src/database/v1-migration.ts b/packages/core/src/database/v1-migration.ts index a2e5014eb8..57e295f18a 100644 --- a/packages/core/src/database/v1-migration.ts +++ b/packages/core/src/database/v1-migration.ts @@ -1,6 +1,6 @@ export * as V1Migration from "./v1-migration" -import { Cause, Effect, Option, Schema, Semaphore } from "effect" +import { Cause, Effect, Layer, Option, Schema, Semaphore } from "effect" import { Database } from "./database" import { SessionMessageTable, SessionTable } from "../session/sql" import { SessionV1 } from "@opencode-ai/schema/session-v1" @@ -84,10 +84,6 @@ export type Status = | { readonly status: "running"; readonly progress: Progress } | { readonly status: "error"; readonly error: string } -export type Result = { - readonly status: "running" -} - type RunResult = { readonly status: "completed" } @@ -432,7 +428,7 @@ export function transformSession(input: TransformInput): TransformResult { } } -export function status(options: Options = {}): Effect.Effect { +export function status(): Effect.Effect { return Effect.gen(function* () { const { db } = yield* Database.Service if (!(yield* hasLegacySessions(db))) return { status: "completed" as const } @@ -444,24 +440,24 @@ export function status(options: Options = {}): Effect.Effect - Effect.sync(() => { - runtimeState = { status: "error", error: errorText(Cause.squash(cause)) } - }).pipe(Effect.andThen(Effect.logError("V1 migration failed", { cause }))), - onSuccess: () => - Effect.sync(() => { - runtimeState = { status: "idle" } - }), - }), - Effect.forkDetach({ startImmediately: true }), - ) - return { status: "running" as const } -}) +export const layer = Layer.effectDiscard( + Effect.gen(function* () { + runtimeState = { status: "running", progress: { label: "Clearing old events" } } + yield* run().pipe( + Effect.matchCauseEffect({ + onFailure: (cause) => + Effect.sync(() => { + runtimeState = { status: "error", error: errorText(Cause.squash(cause)) } + }).pipe(Effect.andThen(Effect.logError("V1 migration failed", { cause }))), + onSuccess: () => + Effect.sync(() => { + runtimeState = { status: "idle" } + }), + }), + Effect.forkScoped({ startImmediately: true }), + ) + }), +) function errorText(input: unknown): string { if (!(input instanceof Error)) return String(input) diff --git a/packages/core/test/v1-migration.test.ts b/packages/core/test/v1-migration.test.ts index c7eb7e42ad..457a87ddd3 100644 --- a/packages/core/test/v1-migration.test.ts +++ b/packages/core/test/v1-migration.test.ts @@ -11,16 +11,16 @@ 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, Schedule, Schema } from "effect" +import { Effect, Layer, Logger, Schedule, Schema, Scope } from "effect" import { eq, sql } from "drizzle-orm" import type { SqlClient } from "effect/unstable/sql/SqlClient" import { tmpdir } from "./fixture/tmpdir" import path from "path" const makeDb = EffectDrizzleSqlite.makeWithDefaults() -const run = (effect: Effect.Effect) => +const run = (effect: Effect.Effect) => Effect.runPromise( - effect.pipe(Effect.provide(SqliteClient.layer({ filename: ":memory:", disableWAL: true })), Effect.scoped), + Effect.scoped(effect.pipe(Effect.provide(SqliteClient.layer({ filename: ":memory:", disableWAL: true })))), ) const session = ( @@ -772,7 +772,7 @@ describe("V1Migration database workflow", () => { `) }) - const database = (effect: Effect.Effect) => + const database = (effect: Effect.Effect) => run( Effect.gen(function* () { const db = yield* makeDb @@ -851,11 +851,11 @@ describe("V1Migration database workflow", () => { VALUES ('msg_current_existing', 'ses_existing', 'user', 0, 1, 2, '{"text":"current","time":{"created":1}}') `) - expect(yield* V1Migration.status({ nextDatabasePath: filename })).toEqual({ + expect(yield* V1Migration.status()).toEqual({ status: "required", }) expect(yield* V1Migration.run({ nextDatabasePath: filename })).toEqual({ status: "completed" }) - expect(yield* V1Migration.status({ nextDatabasePath: filename })).toEqual({ + expect(yield* V1Migration.status()).toEqual({ status: "completed", }) expect(yield* db.get(sql`SELECT title, agent, model FROM session_v2 WHERE id = 'ses_next'`)).toEqual({ @@ -1055,7 +1055,7 @@ describe("V1Migration database workflow", () => { yield* db.run( sql`INSERT INTO event (id, aggregate_id, seq, created, type, data) VALUES ('event_stale_b', 'ses_b', 7, 1, 'session.renamed.1', '{}')`, ) - expect(yield* V1Migration.start()).toEqual({ status: "running" }) + yield* Layer.launch(V1Migration.layer).pipe(Effect.forkScoped) const failed = yield* V1Migration.status().pipe( Effect.filterOrFail((status) => status.status === "error"), Effect.retry(Schedule.spaced("10 millis")), @@ -1093,7 +1093,7 @@ describe("V1Migration database workflow", () => { sql`INSERT INTO event (id, aggregate_id, seq, created, type, data) VALUES ('event_after_clear', 'ses_c', 0, 2, 'session.renamed.1', '{}')`, ) yield* db.run(sql`DROP TRIGGER fail_b`) - expect(yield* V1Migration.start()).toEqual({ status: "running" }) + yield* Layer.launch(V1Migration.layer).pipe(Effect.forkScoped) yield* V1Migration.status().pipe( Effect.filterOrFail((status) => status.status === "completed"), Effect.retry(Schedule.spaced("10 millis")), diff --git a/packages/protocol/src/groups/migration.ts b/packages/protocol/src/groups/migration.ts index 065d9e17e3..6ac4eccb3a 100644 --- a/packages/protocol/src/groups/migration.ts +++ b/packages/protocol/src/groups/migration.ts @@ -13,8 +13,6 @@ export const V1MigrationStatus = Schema.Union([ Schema.Struct({ status: Schema.Literal("error"), error: Schema.String }), ]) -export const V1MigrationResult = Schema.Struct({ status: Schema.Literal("running") }) - export const MigrationGroup = HttpApiGroup.make("server.migration") .add( HttpApiEndpoint.get("migration.v1.status", "/api/experimental/migration/v1", { @@ -27,15 +25,4 @@ export const MigrationGroup = HttpApiGroup.make("server.migration") }), ), ) - .add( - HttpApiEndpoint.post("migration.v1.run", "/api/experimental/migration/v1", { - success: V1MigrationResult, - }).annotateMerge( - OpenApi.annotations({ - identifier: "v2.experimental.migration.v1.run", - summary: "Run V1 migration", - description: "Start or resume the V1 to V2 session history migration in the background.", - }), - ), - ) .annotateMerge(OpenApi.annotations({ title: "migration" })) diff --git a/packages/server/src/handlers/migration.ts b/packages/server/src/handlers/migration.ts index 3619c7d507..230f69d73e 100644 --- a/packages/server/src/handlers/migration.ts +++ b/packages/server/src/handlers/migration.ts @@ -4,17 +4,10 @@ import { Effect } from "effect" import { Api } from "../api" export const MigrationHandler = HttpApiBuilder.group(Api, "server.migration", (handlers) => - handlers - .handle( - "migration.v1.status", - Effect.fn(function* () { - return yield* V1Migration.status() - }), - ) - .handle( - "migration.v1.run", - Effect.fn(function* () { - return yield* V1Migration.start() - }), - ), + handlers.handle( + "migration.v1.status", + Effect.fn(function* () { + return yield* V1Migration.status() + }), + ), ) diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts index 2a38214231..6f1efa1cb9 100644 --- a/packages/server/src/routes.ts +++ b/packages/server/src/routes.ts @@ -1,4 +1,5 @@ import { Database } from "@opencode-ai/core/database/database" +import { V1Migration } from "@opencode-ai/core/database/v1-migration" import { App } from "@opencode-ai/core/app" import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { httpClient } from "@opencode-ai/util/effect/app-node-platform" @@ -138,7 +139,7 @@ function makeRoutes( ), ServerInfo.layer(serviceURLs, options.app), ) - return HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" }).pipe( + const api = HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" }).pipe( Layer.provide(handlers.pipe(Layer.provide(services))), Layer.provide(formLocationLayer), Layer.provide(sessionLocationLayer), @@ -150,6 +151,7 @@ function makeRoutes( Layer.provideMerge(services), Layer.provideMerge(HttpRouter.layer), ) + return Layer.merge(api, V1Migration.layer.pipe(Layer.provide(services))) }), Layer.provide(observability), ) diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 8145358ffb..376963cddb 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -97,7 +97,6 @@ import { destroyRenderer } from "./util/renderer" import { cliErrorMessage, errorFormat } from "./util/error" import { AttentionProvider } from "./context/attention" import { StorageProvider } from "./context/storage" -import { Migration } from "./migration" registerOpencodeSpinner() @@ -458,31 +457,6 @@ function App(props: { pair?: DialogPairCredentials }) { const promptRef = usePromptRef() const plugins = usePlugin() const clipboard = useClipboard() - const [migration, setMigration] = createStore({ active: false, progress: { label: "Preparing migration" } }) - const migrationAbort = new AbortController() - - onMount(async () => { - await Bun.sleep(1_000) - void Migration.run( - client.api, - (status) => { - setMigration("active", status.status === "running") - if (status.status === "running") setMigration("progress", status.progress) - }, - migrationAbort.signal, - ).catch((error) => { - if (migrationAbort.signal.aborted) return - setMigration("active", false) - toast.show({ - variant: "error", - title: "Data migration failed", - message: error instanceof Error ? error.message : String(error), - duration: 10_000, - }) - }) - }) - onCleanup(() => migrationAbort.abort()) - // Toast once when an MCP server enters a failed or needs-auth state so the user knows to act, // without having to open the status panel. Tracking the last alerted status avoids re-toasting // the same problem on every refresh while still re-alerting if the state changes. @@ -1272,9 +1246,7 @@ function App(props: { pair?: DialogPairCredentials }) { - - - + ) diff --git a/packages/tui/src/component/migration-overlay.tsx b/packages/tui/src/component/migration-overlay.tsx index 9ed12ec656..21943529af 100644 --- a/packages/tui/src/component/migration-overlay.tsx +++ b/packages/tui/src/component/migration-overlay.tsx @@ -1,37 +1,72 @@ +import { createSignal, onCleanup, onMount, Show } from "solid-js" +import { useClient } from "../context/client" import { useTheme } from "../context/theme" import { SplitBorder } from "../ui/border" +import { useToast } from "../ui/toast" import { Spinner } from "./spinner" -export function MigrationOverlay(props: { - progress: { label: string; numerator?: number; denominator?: number } -}) { +type Progress = { label: string; numerator?: number; denominator?: number } + +export function MigrationOverlay() { + const client = useClient() + const toast = useToast() const theme = useTheme("overlay") - const count = () => { - if (props.progress.numerator === undefined) return "" - if (props.progress.denominator === undefined) return ` ${props.progress.numerator}` - return ` ${props.progress.numerator}/${props.progress.denominator}` + const [progress, setProgress] = createSignal() + const abort = new AbortController() + + onMount(async () => { + await Bun.sleep(1_000) + void (async () => { + while (true) { + const status = await client.api.migration.v1.status({ signal: abort.signal }) + setProgress(status.status === "running" ? status.progress : undefined) + if (status.status === "completed") return + if (status.status === "error") throw new Error(status.error) + await Bun.sleep(1_000) + } + })().catch((error) => { + if (abort.signal.aborted) return + setProgress(undefined) + toast.show({ + variant: "error", + title: "Data migration failed", + message: error instanceof Error ? error.message : String(error), + duration: 10_000, + }) + }) + }) + onCleanup(() => abort.abort()) + + const count = (value: Progress) => { + if (value.numerator === undefined) return "" + if (value.denominator === undefined) return ` ${value.numerator}` + return ` ${value.numerator}/${value.denominator}` } return ( - - - {props.progress.label} - {count()} - - + + {(value) => ( + + + {value().label} + {count(value())} + + + )} + ) } diff --git a/packages/tui/src/migration.test.ts b/packages/tui/src/migration.test.ts deleted file mode 100644 index d09acea018..0000000000 --- a/packages/tui/src/migration.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { expect, test } from "bun:test" -import { Migration } from "./migration" - -test("skips a completed migration", async () => { - const updates: Migration.Status[] = [] - const client = { - migration: { - v1: { - status: async () => ({ status: "completed" as const }), - run: async () => ({ status: "running" as const }), - }, - }, - } - - expect(await Migration.run(client, (status) => updates.push(status))).toBe(false) - expect(updates).toEqual([]) -}) - -test("polls committed session progress after starting migration", async () => { - const updates: Migration.Status[] = [] - let completed = 0 - const client = { - migration: { - v1: { - status: async () => - completed === 2 - ? { status: "completed" as const } - : { - status: "running" as const, - progress: { label: "Migrating sessions", numerator: completed, denominator: 2 }, - }, - run: async () => ({ status: "running" as const }), - }, - }, - } - - const running = Migration.run(client, (status) => updates.push(status)) - await Bun.sleep(50) - completed = 2 - expect(await running).toBe(true) - expect(updates).toContainEqual({ - status: "running", - progress: { label: "Migrating sessions", numerator: 0, denominator: 2 }, - }) - expect(updates).toContainEqual({ - status: "completed", - }) -}) - -test("surfaces a failed background migration", async () => { - const client = { - migration: { - v1: { - status: async () => ({ - status: "error" as const, - error: "broken row", - }), - run: async () => ({ status: "running" as const }), - }, - }, - } - - await expect(Migration.run(client, () => {})).rejects.toThrow("broken row") -}) diff --git a/packages/tui/src/migration.ts b/packages/tui/src/migration.ts deleted file mode 100644 index a68da15b3e..0000000000 --- a/packages/tui/src/migration.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { OpenCodeClient } from "@opencode-ai/client" - -type Client = Pick -export type Status = Awaited> - -export async function run(client: Client, update: (status: Status) => void, signal?: AbortSignal) { - const initial = await client.migration.v1.status({ signal }) - if (initial.status === "completed") return false - update(initial) - - await client.migration.v1.run({ signal }) - while (true) { - const status = await client.migration.v1.status({ signal }) - update(status) - if (status.status === "completed") return true - if (status.status === "error") throw new Error(status.error) - await Bun.sleep(1_000) - } -} - -export * as Migration from "./migration"