From 9769e7012c9b72b6171d95fce338c2e94d467987 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 12 Aug 2026 09:30:15 -0400 Subject: [PATCH] feat(core): configure package publishing --- bun.lock | 7 +- packages/core/README.md | 3 + packages/core/package.json | 34 +- packages/core/script/build.ts | 42 + packages/core/script/publish.ts | 51 + packages/core/src/database/sqlite.bun.ts | 10 +- packages/core/src/database/sqlite.node.ts | 10 +- packages/core/src/database/sqlite.ts | 3 - packages/core/src/database/sqlite.workerd.ts | 11 +- .../core/src/database/v1-migration.bun.ts | 997 +++++++++++++++++ .../core/src/database/v1-migration.noop.ts | 11 + packages/core/src/database/v1-migration.ts | 999 +----------------- packages/core/test/file-mutation.test.ts | 2 +- packages/core/test/fixture/environment.ts | 2 +- .../core/test/instruction-discovery.test.ts | 2 +- packages/core/test/instruction-state.test.ts | 2 +- packages/core/test/instructions/index.test.ts | 2 +- .../core/test/lib/environment-conformance.ts | 27 +- packages/core/test/lib/instructions.ts | 2 +- packages/core/test/session-execution.test.ts | 2 +- packages/core/test/session-generate.test.ts | 2 +- .../core/test/session-runner-recorded.test.ts | 4 +- packages/core/test/session-runner.test.ts | 4 +- packages/core/test/tool-edit.test.ts | 2 +- packages/core/test/tool-patch.test.ts | 2 +- .../core/test/tool-read-filesystem.test.ts | 2 +- packages/core/test/tool-read.test.ts | 2 +- packages/core/test/tool-search.test.ts | 2 +- packages/core/test/tool-shell.test.ts | 2 +- packages/core/test/tool-write.test.ts | 2 +- packages/core/test/workspace.test.ts | 2 +- packages/core/tsconfig.build.json | 11 + packages/server/src/workspace/modal.ts | 2 +- .../server/test/environment-modal.test.ts | 4 +- packages/server/test/workspace-modal.test.ts | 2 +- script/publish.ts | 3 + 36 files changed, 1185 insertions(+), 1082 deletions(-) create mode 100644 packages/core/README.md create mode 100644 packages/core/script/build.ts create mode 100644 packages/core/script/publish.ts create mode 100644 packages/core/src/database/v1-migration.bun.ts create mode 100644 packages/core/src/database/v1-migration.noop.ts create mode 100644 packages/core/tsconfig.build.json diff --git a/bun.lock b/bun.lock index f8814c3d071..2432c40f6fc 100644 --- a/bun.lock +++ b/bun.lock @@ -345,9 +345,6 @@ "packages/core": { "name": "@opencode-ai/core", "version": "1.18.4", - "bin": { - "opencode": "./bin/opencode", - }, "dependencies": { "@ai-sdk/alibaba": "1.0.17", "@ai-sdk/amazon-bedrock": "4.0.112", @@ -367,8 +364,6 @@ "@ai-sdk/togetherai": "2.0.41", "@ai-sdk/vercel": "2.0.39", "@aws-sdk/credential-providers": "3.1057.0", - "@effect/platform-node": "catalog:", - "@effect/sql-sqlite-bun": "catalog:", "@ff-labs/fff-bun": "0.10.1", "@ff-labs/fff-node": "0.10.1", "@lydell/node-pty": "catalog:", @@ -404,6 +399,8 @@ "zod": "catalog:", }, "devDependencies": { + "@effect/platform-node": "catalog:", + "@effect/sql-sqlite-bun": "catalog:", "@opencode-ai/http-recorder": "workspace:*", "@parcel/watcher-darwin-arm64": "2.5.1", "@parcel/watcher-darwin-x64": "2.5.1", diff --git a/packages/core/README.md b/packages/core/README.md new file mode 100644 index 00000000000..6c80690fc07 --- /dev/null +++ b/packages/core/README.md @@ -0,0 +1,3 @@ +# @opencode-ai/core + +Core runtime services for OpenCode. diff --git a/packages/core/package.json b/packages/core/package.json index 9844abe63d1..1d98d93c6ba 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -4,24 +4,28 @@ "name": "@opencode-ai/core", "type": "module", "license": "MIT", - "private": true, + "repository": { + "type": "git", + "url": "git+https://github.com/anomalyco/opencode.git", + "directory": "packages/core" + }, + "publishConfig": { + "access": "public" + }, + "files": [ + "dist" + ], "scripts": { "db": "bun drizzle-kit", "migration": "bun run script/migration.ts", "fix-node-pty": "bun run script/fix-node-pty.ts", "benchmark:location": "bun run script/benchmark-location.ts", + "build": "bun run script/build.ts", "update-models-snapshot": "bun run script/update-models-snapshot.ts", "test": "bun test --only-failures", "typecheck": "tsgo -b tsconfig.json tsconfig.tests.json" }, - "bin": { - "opencode": "./bin/opencode" - }, "exports": { - "./environment": "./src/environment/index.ts", - "./testing/environment-conformance": "./test/lib/environment-conformance.ts", - "./session/runner": "./src/session/runner/index.ts", - "./instructions": "./src/instructions/index.ts", "./*": "./src/*.ts" }, "imports": { @@ -55,9 +59,18 @@ "bun": "./src/util/process-lock-ffi.bun.ts", "node": "./src/util/process-lock-ffi.node.ts", "default": "./src/util/process-lock-ffi.bun.ts" + }, + "#v1-migration": { + "types": "./src/database/v1-migration.bun.ts", + "bun": "./src/database/v1-migration.bun.ts", + "node": "./src/database/v1-migration.noop.ts", + "workerd": "./src/database/v1-migration.noop.ts", + "default": "./src/database/v1-migration.noop.ts" } }, "devDependencies": { + "@effect/platform-node": "catalog:", + "@effect/sql-sqlite-bun": "catalog:", "@tsconfig/bun": "catalog:", "@types/bun": "catalog:", "@types/node": "catalog:", @@ -93,8 +106,6 @@ "@ai-sdk/togetherai": "2.0.41", "@ai-sdk/vercel": "2.0.39", "@aws-sdk/credential-providers": "3.1057.0", - "@effect/platform-node": "catalog:", - "@effect/sql-sqlite-bun": "catalog:", "@lydell/node-pty": "catalog:", "@modelcontextprotocol/sdk": "1.29.0", "@ff-labs/fff-bun": "0.10.1", @@ -128,8 +139,5 @@ "web-tree-sitter": "0.25.10", "which": "6.0.1", "zod": "catalog:" - }, - "overrides": { - "drizzle-orm": "catalog:" } } diff --git a/packages/core/script/build.ts b/packages/core/script/build.ts new file mode 100644 index 00000000000..181e37c0309 --- /dev/null +++ b/packages/core/script/build.ts @@ -0,0 +1,42 @@ +#!/usr/bin/env bun + +import { $ } from "bun" +import { rm } from "node:fs/promises" +import path from "node:path" +import { fileURLToPath } from "node:url" + +process.chdir(fileURLToPath(new URL("..", import.meta.url))) + +await rm("dist", { recursive: true, force: true }) +await $`bun tsc -p tsconfig.build.json` + +const root = path.resolve("src") +const files = await Array.fromAsync(new Bun.Glob("**/*.ts").scan({ cwd: root, absolute: true })) +const result = await Bun.build({ + entrypoints: files.filter((file) => !file.endsWith(".d.ts")), + root, + outdir: "dist", + target: "node", + format: "esm", + packages: "external", + external: [ + "#sqlite", + "#pty", + "#fff", + "#photon-wasm", + "#shell-parser-wasm", + "#process-lock-ffi", + "#v1-migration", + ], + splitting: true, + loader: { + ".txt": "text", + ".md": "text", + }, + naming: { + entry: "[dir]/[name].[ext]", + chunk: "chunks/[name]-[hash].[ext]", + asset: "assets/[name]-[hash].[ext]", + }, +}) +if (!result.success) throw new AggregateError(result.logs, "Failed to build Core") diff --git a/packages/core/script/publish.ts b/packages/core/script/publish.ts new file mode 100644 index 00000000000..45f6f85795c --- /dev/null +++ b/packages/core/script/publish.ts @@ -0,0 +1,51 @@ +#!/usr/bin/env bun + +import { Script } from "@opencode-ai/script" +import { $ } from "bun" +import { rm } from "node:fs/promises" +import { fileURLToPath } from "node:url" + +process.chdir(fileURLToPath(new URL("..", import.meta.url))) + +const originalText = await Bun.file("package.json").text() +const pkg = JSON.parse(originalText) as { + name: string + version: string + exports: Record + imports: Record> +} +const tarball = `${pkg.name.replace("@", "").replace("/", "-")}-${pkg.version}.tgz` +const output = (value: string, types = false) => + value.replace("./src/", types ? "./dist/types/" : "./dist/").replace(/\.ts$/, types ? ".d.ts" : ".js") + +if ((await $`npm view ${pkg.name}@${pkg.version} version`.nothrow()).exitCode === 0) { + console.log(`already published ${pkg.name}@${pkg.version}`) + process.exit(0) +} + +try { + await $`bun run typecheck` + await $`bun run build` + pkg.exports = Object.fromEntries( + Object.entries(pkg.exports) + .map(([key, value]) => { + if (typeof value !== "string") return [key, value] + return [key, { import: output(value), types: output(value, true) }] + }), + ) + pkg.imports = Object.fromEntries( + Object.entries(pkg.imports).map(([key, conditions]) => [ + key, + Object.fromEntries( + Object.entries(conditions).map(([condition, value]) => [condition, output(value, condition === "types")]), + ), + ]), + ) + await Bun.write("package.json", JSON.stringify(pkg, null, 2) + "\n") + await rm(tarball, { force: true }) + await $`bun pm pack` + await $`npm publish ${tarball} --tag ${Script.channel} --access public` +} finally { + await Bun.write("package.json", originalText) + await rm(tarball, { force: true }) +} diff --git a/packages/core/src/database/sqlite.bun.ts b/packages/core/src/database/sqlite.bun.ts index ac8f023878f..c914b94a8ed 100644 --- a/packages/core/src/database/sqlite.bun.ts +++ b/packages/core/src/database/sqlite.bun.ts @@ -1,5 +1,4 @@ import { Database, type SQLQueryBindings } from "bun:sqlite" -import { drizzle } from "drizzle-orm/bun-sqlite" import { Context, Effect, Layer } from "effect" import { Reactivity } from "effect/unstable/reactivity" import { SqlClient } from "effect/unstable/sql" @@ -98,16 +97,9 @@ const nativeLayer = (config: Config) => const clientLayer = (config: Config) => Layer.effect(SqlClient.SqlClient, make(config)) -const drizzleLayer = Layer.effect( - Sqlite.Drizzle, - Effect.gen(function* () { - return drizzle({ client: (yield* Sqlite.Native) as Database }) - }), -) - export const sqliteLayer = (config: Config) => { const native = nativeLayer(config) - return Layer.merge(native, Layer.merge(clientLayer(config), drizzleLayer).pipe(Layer.provide(native))).pipe( + return Layer.merge(native, clientLayer(config).pipe(Layer.provide(native))).pipe( Layer.provide(Reactivity.layer), ) } diff --git a/packages/core/src/database/sqlite.node.ts b/packages/core/src/database/sqlite.node.ts index 49e12de3b43..81f74a09d91 100644 --- a/packages/core/src/database/sqlite.node.ts +++ b/packages/core/src/database/sqlite.node.ts @@ -1,5 +1,4 @@ import { DatabaseSync, type SQLInputValue } from "node:sqlite" -import { drizzle } from "drizzle-orm/node-sqlite" import { Context, Effect, Layer } from "effect" import { Reactivity } from "effect/unstable/reactivity" import { SqlClient } from "effect/unstable/sql" @@ -95,16 +94,9 @@ const nativeLayer = (config: Config) => const clientLayer = (config: Config) => Layer.effect(SqlClient.SqlClient, make(config)) -const drizzleLayer = Layer.effect( - Sqlite.Drizzle, - Effect.gen(function* () { - return drizzle({ client: (yield* Sqlite.Native) as DatabaseSync }) as unknown as Sqlite.DrizzleClient - }), -) - export const sqliteLayer = (config: Config) => { const native = nativeLayer(config) - return Layer.merge(native, Layer.merge(clientLayer(config), drizzleLayer).pipe(Layer.provide(native))).pipe( + return Layer.merge(native, clientLayer(config).pipe(Layer.provide(native))).pipe( Layer.provide(Reactivity.layer), ) } diff --git a/packages/core/src/database/sqlite.ts b/packages/core/src/database/sqlite.ts index 1add137b10f..6ab22de8caf 100644 --- a/packages/core/src/database/sqlite.ts +++ b/packages/core/src/database/sqlite.ts @@ -5,11 +5,8 @@ import { identity } from "effect/Function" import { SqlClient, Statement } from "effect/unstable/sql" import type { Connection } from "effect/unstable/sql/SqlConnection" import type { SqlError } from "effect/unstable/sql/SqlError" -import type { drizzle } from "drizzle-orm/bun-sqlite" -export type DrizzleClient = ReturnType export class Native extends Context.Service()("@opencode-ai/core/database/SqliteNative") {} -export class Drizzle extends Context.Service()("@opencode-ai/core/database/SqliteDrizzle") {} export interface ClientConfig { readonly spanAttributes?: Record diff --git a/packages/core/src/database/sqlite.workerd.ts b/packages/core/src/database/sqlite.workerd.ts index da7a860e06e..fbe7d11bd54 100644 --- a/packages/core/src/database/sqlite.workerd.ts +++ b/packages/core/src/database/sqlite.workerd.ts @@ -1,4 +1,3 @@ -import { drizzle } from "drizzle-orm/durable-sqlite" import { Context, Effect, Exit, Fiber, Layer, Scope, Semaphore, Stream } from "effect" import { identity } from "effect/Function" import { Reactivity } from "effect/unstable/reactivity" @@ -238,17 +237,9 @@ const nativeLayer = (config: Config) => const clientLayer = (config: Config) => Layer.effect(SqlClient.SqlClient, make(config)) -const drizzleLayer = Layer.effect( - Sqlite.Drizzle, - Effect.gen(function* () { - const native = (yield* Sqlite.Native) as DurableObjectStorage - return drizzle(native) as unknown as Sqlite.DrizzleClient - }), -) - export const sqliteLayer = (config: Config) => { const native = nativeLayer(config) - return Layer.merge(native, Layer.merge(clientLayer(config), drizzleLayer).pipe(Layer.provide(native))).pipe( + return Layer.merge(native, clientLayer(config).pipe(Layer.provide(native))).pipe( Layer.provide(Reactivity.layer), ) } diff --git a/packages/core/src/database/v1-migration.bun.ts b/packages/core/src/database/v1-migration.bun.ts new file mode 100644 index 00000000000..5c3150c77c1 --- /dev/null +++ b/packages/core/src/database/v1-migration.bun.ts @@ -0,0 +1,997 @@ +export * as V1Migration from "./v1-migration.js" + +import { Cause, Effect, Layer, Option, Schema, Semaphore } from "effect" +import { Database } from "./database.js" +import { SessionMessageTable, SessionTable } from "../session/sql.js" +import { SessionV1 } from "@opencode-ai/schema/session-v1" +import { SessionMessage } from "../session/message.js" +import { SessionSchema } from "../session/schema.js" +import { KVTable } from "../kv/sql.js" +import { EventSequenceTable } from "../event/sql.js" +import { eq, sql } from "drizzle-orm" +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 + readonly session_id: string + readonly time_created: number + readonly time_updated: number + readonly data: string +} + +export type SourcePart = { + readonly id: string + readonly message_id: string + readonly session_id: string + readonly time_created: number + readonly time_updated: number + readonly data: string +} + +export type TransformInput = { + readonly session: typeof SessionTable.$inferSelect + readonly messages: ReadonlyArray + readonly parts: ReadonlyArray +} + +export type Warning = { + readonly reason: string + readonly sessionID: string + readonly messageID?: string + readonly partID?: string + readonly observedType?: string +} + +export type TransformResult = { + readonly messages: ReadonlyArray<{ + readonly id: string + readonly session_id: string + readonly type: SessionMessage.Type + readonly seq: number + readonly time_created: number + readonly time_updated: number + readonly data: Record + }> + readonly session: Pick< + typeof SessionTable.$inferInsert, + | "agent" + | "model" + | "cost" + | "tokens_input" + | "tokens_output" + | "tokens_reasoning" + | "tokens_cache_read" + | "tokens_cache_write" + | "revert" + | "time_compacting" + > + readonly watermark: number + readonly warnings: ReadonlyArray +} + +type Progress = { + readonly label: string + readonly numerator?: number + readonly denominator?: number +} + +export type Status = + | { readonly status: "required" | "completed" } + | { readonly status: "running"; readonly progress: Progress } + | { readonly status: "error"; readonly error: string } + +type RunResult = { + readonly status: "completed" +} + +type Options = { + readonly nextDatabasePath?: string +} + +type MigrationState = { readonly phase: "sessions"; readonly cursor?: string } | { readonly phase: "completed" } + +type RuntimeState = + | { readonly status: "idle" } + | { readonly status: "running"; readonly progress: Progress } + | { readonly status: "error"; readonly error: string } + +type NextProject = { + readonly id: string + readonly worktree: string + readonly vcs: string | null + readonly name: string | null + readonly icon_url: string | null + readonly icon_url_override: string | null + readonly icon_color: string | null + readonly time_created: number + readonly time_updated: number + readonly time_initialized: number | null + readonly sandboxes: string + readonly commands: string | null +} + +type NextSession = { + readonly id: string + readonly project_id: string + readonly workspace_id: string | null + readonly parent_id: string | null + readonly fork_session_id: string | null + readonly fork_boundary: string | null + readonly slug: string + readonly directory: string + readonly path: string | null + readonly title: string | null + readonly version: string + readonly share_url: string | null + readonly summary_additions: number | null + readonly summary_deletions: number | null + readonly summary_files: number | null + readonly summary_diffs: string | null + readonly metadata: string | null + readonly cost: number + readonly tokens_input: number + readonly tokens_output: number + readonly tokens_reasoning: number + readonly tokens_cache_read: number + readonly tokens_cache_write: number + readonly revert: string | null + readonly permission: string | null + readonly agent: string | null + readonly model: string | null + readonly time_created: number + readonly time_updated: number + readonly time_compacting: number | null + readonly time_archived: number | null + readonly time_suspended: number | null +} + +type NextMessage = { + readonly id: string + readonly session_id: string + readonly type: string + readonly seq: number + readonly time_created: number + readonly time_updated: number + readonly data: string +} + +const lock = Semaphore.makeUnsafe(1) +const MIGRATION_STATE_KEY = "migration.v1-v2" +const EVENT_DELETE_BATCH_SIZE = 1_000 +const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString) +const decodeMessage = Schema.decodeUnknownOption(SessionV1.Info) +const decodePart = Schema.decodeUnknownOption(SessionV1.Part) +let runtimeState: RuntimeState = { status: "idle" } + +export function transformSession(input: TransformInput): TransformResult { + const warnings: Warning[] = [] + const messages = input.messages + .map((row) => { + const value = Option.getOrUndefined(decodeJson(row.data)) + const decoded = + value && typeof value === "object" + ? Option.getOrUndefined(decodeMessage({ ...value, id: row.id, sessionID: row.session_id })) + : undefined + if (decoded) return { row, value: decoded } + warnings.push({ reason: "invalid-message", sessionID: input.session.id, messageID: row.id }) + return undefined + }) + .filter((item): item is NonNullable => item !== undefined) + .sort((a, b) => a.row.time_created - b.row.time_created || a.row.id.localeCompare(b.row.id)) + const messageIDs = new Set(input.messages.map((row) => row.id)) + const parts = input.parts + .map((row) => { + const value = Option.getOrUndefined(decodeJson(row.data)) + const observedType = value && typeof value === "object" && "type" in value ? String(value.type) : undefined + if (!messageIDs.has(row.message_id)) { + warnings.push({ + reason: "orphan-part", + sessionID: input.session.id, + messageID: row.message_id, + partID: row.id, + observedType, + }) + return undefined + } + const decoded = + value && typeof value === "object" + ? Option.getOrUndefined( + decodePart({ ...value, id: row.id, messageID: row.message_id, sessionID: row.session_id }), + ) + : undefined + if (decoded) return { row, value: decoded } + warnings.push({ + reason: "invalid-part", + sessionID: input.session.id, + messageID: row.message_id, + partID: row.id, + observedType, + }) + return undefined + }) + .filter((item): item is NonNullable => item !== undefined) + .sort((a, b) => a.row.id.localeCompare(b.row.id)) + const byMessage = Map.groupBy(parts, (item) => item.row.message_id) + const paired = new Set() + const used = new Set(messages.map((item) => item.row.id)) + const projected = messages + .flatMap((item) => { + if (paired.has(item.row.id)) return [] + const owned = byMessage.get(item.row.id)?.map((part) => part.value) ?? [] + if (item.value.role === "user") { + const compaction = owned.find((part) => part.type === "compaction") + if (compaction?.type === "compaction") { + const pairedSummary = messages.find( + (candidate) => + candidate.value.role === "assistant" && + candidate.value.parentID === item.row.id && + candidate.value.summary, + ) + if (!pairedSummary || pairedSummary.value.role !== "assistant") return [] + paired.add(pairedSummary.row.id) + if (pairedSummary.value.error || pairedSummary.value.time.completed === undefined) return [] + const summary = pairedSummary + const summaryText = (byMessage.get(summary.row.id) ?? []) + .map((part) => part.value) + .filter((part) => part.type === "text" && part.text.length > 0) + .map((part) => (part.type === "text" ? part.text : "")) + .join("\n\n") + const tailIndex = compaction.tail_start_id + ? messages.findIndex((candidate) => candidate.row.id === compaction.tail_start_id) + : -1 + const compactionIndex = messages.findIndex((candidate) => candidate.row.id === item.row.id) + const tail = tailIndex < 0 ? [] : messages.slice(tailIndex, compactionIndex) + return [ + row( + { ...item.row, time_updated: Math.max(item.row.time_updated, summary.row.time_updated) }, + { + id: item.row.id, + type: "compaction", + status: "completed", + reason: compaction.auto ? "auto" : "manual", + summary: summaryText, + recent: serializeRecent(tail, byMessage), + time: { created: item.row.time_created }, + }, + ), + ] + } + const subtasks = owned.filter((part) => part.type === "subtask") + const visible = owned.filter((part) => part.type === "text" && !part.ignored) + const files = owned.filter((part) => part.type === "file") + const agents = owned.filter((part) => part.type === "agent") + if (subtasks.length > 0 && visible.length === 0 && files.length === 0 && agents.length === 0) return [] + const ordinary = visible.filter((part) => part.type === "text" && !part.synthetic) + const synthetic = visible.filter((part) => part.type === "text" && part.synthetic) + const attachments = files.flatMap((part) => (part.type === "file" ? migrateFile(part) : [])) + const unavailable = files.flatMap((part) => + part.type === "file" && !part.url.startsWith("data:") ? [unavailableFile(part)] : [], + ) + const text = owned + .flatMap((part) => { + if (part.type === "text" && !part.ignored && !part.synthetic) return [part.text] + if (part.type === "file" && !part.url.startsWith("data:")) return [unavailableFile(part)] + return [] + }) + .join("\n\n") + const agentAttachments = agents.map((part) => + part.type === "agent" + ? { + name: part.name, + ...(part.source + ? { mention: { text: part.source.value, start: part.source.start, end: part.source.end } } + : {}), + } + : { name: "" }, + ) + if ( + ordinary.length === 0 && + unavailable.length === 0 && + synthetic.length > 0 && + attachments.length === 0 && + agentAttachments.length === 0 + ) + return [ + row(item.row, { + id: item.row.id, + type: "synthetic", + text: synthetic.map((part) => (part.type === "text" ? part.text : "")).join("\n\n"), + time: { created: item.row.time_created }, + }), + ] + const user = row(item.row, { + id: item.row.id, + type: "user", + text, + ...(attachments.length ? { files: attachments } : {}), + ...(agentAttachments.length ? { agents: agentAttachments } : {}), + time: { created: item.row.time_created }, + }) + if (synthetic.length === 0) return [user] + return [ + user, + row(item.row, { + id: syntheticID(item.row.id, used), + type: "synthetic", + text: synthetic.map((part) => (part.type === "text" ? part.text : "")).join("\n\n"), + time: { created: item.row.time_created }, + }), + ] + } + if (item.value.role !== "assistant") return [] + const assistant = item.value + const parent = messages.find((candidate) => candidate.row.id === assistant.parentID) + const parentParts = parent ? (byMessage.get(parent.row.id)?.map((part) => part.value) ?? []) : [] + if ( + parentParts.some((part) => part.type === "subtask") && + owned.some((part) => part.type === "tool" && part.tool === "task") + ) + return [] + const content = owned.flatMap((part): Array> => { + if (part.type === "text") + return [{ type: "text", text: part.text, ...(part.metadata ? { state: part.metadata } : {}) }] + if (part.type === "reasoning") + return [ + { + type: "reasoning", + text: part.text, + ...(part.metadata ? { state: part.metadata } : {}), + time: { created: part.time.start, ...(part.time.end === undefined ? {} : { completed: part.time.end }) }, + }, + ] + if (part.type !== "tool") return [] + return [migrateTool(part, item.row.time_created)] + }) + const start = + owned.flatMap((part) => (part.type === "step-start" && part.snapshot ? [part.snapshot] : []))[0] ?? + owned.flatMap((part) => (part.type === "snapshot" ? [part.snapshot] : []))[0] ?? + owned.flatMap((part) => (part.type === "patch" ? [part.hash] : []))[0] + const end = owned.flatMap((part) => (part.type === "step-finish" && part.snapshot ? [part.snapshot] : [])).at(-1) + const snapshotFiles = Array.from(new Set(owned.flatMap((part) => (part.type === "patch" ? part.files : [])))) + const finish = normalizeFinish(assistant.finish) + return [ + row(item.row, { + id: item.row.id, + type: "assistant", + agent: assistant.agent, + model: { + providerID: assistant.providerID, + id: assistant.modelID, + variant: assistant.variant ?? "default", + }, + content, + ...(start || end || snapshotFiles.length + ? { + snapshot: { + ...(start ? { start } : {}), + ...(end ? { end } : {}), + ...(snapshotFiles.length ? { files: snapshotFiles } : {}), + }, + } + : {}), + ...(finish ? { finish } : {}), + cost: assistant.cost, + tokens: { + input: assistant.tokens.input, + output: assistant.tokens.output, + reasoning: assistant.tokens.reasoning, + cache: assistant.tokens.cache, + }, + ...(assistant.error ? { error: migrateError(assistant.error) } : {}), + time: { + created: item.row.time_created, + ...(assistant.time.completed === undefined ? {} : { completed: item.row.time_updated }), + }, + }), + ] + }) + .map((item, seq) => ({ ...item, seq })) + const assistants = messages + .filter((item) => item.value.role === "assistant") + .map((item) => item.value) + .filter((item): item is SessionV1.Assistant => item.role === "assistant") + const latestUser = messages.findLast((item) => { + if (item.value.role !== "user") return false + const owned = byMessage.get(item.row.id) ?? [] + if (owned.some((part) => part.value.type === "compaction")) return false + return !owned.some((part) => part.value.type === "subtask") || !owned.every((part) => part.value.type === "subtask") + }) + return { + messages: projected, + session: { + agent: input.session.agent ?? (latestUser?.value.role === "user" ? latestUser.value.agent : null), + model: + input.session.model ?? + (latestUser?.value.role === "user" + ? { + id: latestUser.value.model.modelID, + providerID: latestUser.value.model.providerID, + variant: latestUser.value.model.variant ?? "default", + } + : null), + cost: assistants.reduce((total, item) => total + item.cost, 0), + tokens_input: assistants.reduce((total, item) => total + item.tokens.input, 0), + tokens_output: assistants.reduce((total, item) => total + item.tokens.output, 0), + tokens_reasoning: assistants.reduce((total, item) => total + item.tokens.reasoning, 0), + tokens_cache_read: assistants.reduce((total, item) => total + item.tokens.cache.read, 0), + tokens_cache_write: assistants.reduce((total, item) => total + item.tokens.cache.write, 0), + revert: null, + time_compacting: null, + }, + watermark: projected.length - 1, + warnings, + } +} + +export function status(): Effect.Effect { + return Effect.gen(function* () { + const { db } = yield* Database.Service + if (!(yield* hasLegacySessions(db))) return { status: "completed" as const } + const state = yield* readState(db) + if (runtimeState.status === "running") return runtimeState + if (runtimeState.status === "error") return runtimeState + if (state?.phase === "completed") return { status: "completed" as const } + return { status: "required" as const } + }).pipe(Effect.orDie) +} + +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) + const cause = input.cause + return cause === undefined ? input.message : `${input.message}\nCaused by: ${errorText(cause)}` +} + +function updateProgress(progress: Progress) { + if (runtimeState.status === "running") runtimeState = { status: "running", progress } +} + +export function run(options: Options = {}): Effect.Effect { + return lock.withPermit( + Effect.gen(function* () { + const { db } = yield* Database.Service + const global = yield* Global.Service + const state = yield* readState(db) + if (state?.phase === "completed") return { status: "completed" as const } + if (!(yield* hasLegacySessions(db))) return { status: "completed" as const } + 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.data).root}, ${now}, ${now}, '[]') + `) + if (state === undefined) + yield* db + .transaction((tx) => + Effect.gen(function* () { + while (true) { + yield* tx.run(sql` + DELETE FROM event + WHERE rowid IN (SELECT rowid FROM event LIMIT ${EVENT_DELETE_BATCH_SIZE}) + `) + const deleted = (yield* tx.get<{ value: number }>(sql`SELECT changes() AS value`))?.value ?? 0 + if (deleted < EVENT_DELETE_BATCH_SIZE) break + yield* Effect.yieldNow + } + yield* tx + .insert(KVTable) + .values({ key: MIGRATION_STATE_KEY, value: { phase: "sessions" } }) + .run() + }), + ) + .pipe(Effect.orDie) + const sourceTotal = yield* countNextSessions(nextPath(options, global.data)) + const legacyTotal = (yield* db.get<{ value: number }>(sql`SELECT COUNT(*) AS value FROM session`))?.value ?? 0 + const cursor = state?.phase === "sessions" ? state.cursor : undefined + const migrated = + cursor !== undefined + ? ((yield* db.get<{ value: number }>(sql`SELECT COUNT(*) AS value FROM session WHERE id >= ${cursor}`)) + ?.value ?? 0) + : 0 + const denominator = sourceTotal + legacyTotal + updateProgress({ label: "Migrating sessions", numerator: migrated, denominator }) + yield* importNextDatabase(db, nextPath(options, global.data), (completed) => { + 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), + ) + 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 }>( + 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`, + ) + if (!nextID) break + yield* db + .transaction((tx) => + Effect.gen(function* () { + yield* tx + .insert(KVTable) + .values({ key: MIGRATION_STATE_KEY, value: { phase: "sessions", cursor: nextID.id } }) + .onConflictDoUpdate({ + target: KVTable.key, + set: { value: { phase: "sessions", cursor: nextID.id }, time_updated: Date.now() }, + }) + .run() + 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: nextID.project_id, + }) + yield* tx.run(sql` + INSERT OR IGNORE INTO session_v2 ( + id, project_id, 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 + ) + SELECT + 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 + FROM session + WHERE id = ${nextID.id} + `) + const next = yield* tx + .select() + .from(SessionTable) + .where(eq(SessionTable.id, SessionSchema.ID.make(nextID.id))) + .get() + if (!next) return yield* Effect.die(new Error(`Failed to copy V1 session ${nextID.id}`)) + const sourceMessages = yield* tx.all( + sql`SELECT id, session_id, time_created, time_updated, data FROM message WHERE session_id = ${next.id}`, + ) + const sourceParts = yield* tx.all( + sql`SELECT id, message_id, session_id, time_created, time_updated, data FROM part WHERE session_id = ${next.id}`, + ) + const transformed = transformSession({ session: next, messages: sourceMessages, parts: sourceParts }) + yield* Effect.forEach(transformed.warnings, (warning) => + Effect.logWarning("Skipped V1 migration row", warning), + ) + yield* tx.delete(SessionMessageTable).where(eq(SessionMessageTable.session_id, next.id)).run() + yield* Effect.forEach(transformed.messages, (message) => + tx + .insert(SessionMessageTable) + .values({ + id: SessionMessage.ID.make(message.id), + session_id: SessionSchema.ID.make(message.session_id), + type: message.type, + seq: message.seq, + time_created: message.time_created, + time_updated: message.time_updated, + data: sql`${JSON.stringify(message.data)}`, + }) + .run(), + ) + yield* tx + .update(SessionTable) + .set({ ...transformed.session, time_updated: next.time_updated }) + .where(eq(SessionTable.id, next.id)) + .run() + yield* tx + .insert(EventSequenceTable) + .values({ aggregate_id: next.id, seq: transformed.watermark }) + .onConflictDoUpdate({ + target: EventSequenceTable.aggregate_id, + set: { seq: transformed.watermark, owner_id: null }, + }) + .run() + }), + ) + .pipe(Effect.orDie) + if (runtimeState.status === "running") + runtimeState = { + status: "running", + progress: { + label: "Migrating sessions", + numerator: (runtimeState.progress.numerator ?? 0) + 1, + denominator, + }, + } + yield* Effect.yieldNow + } + yield* db + .transaction((tx) => + Effect.gen(function* () { + yield* tx + .insert(KVTable) + .values({ key: MIGRATION_STATE_KEY, value: { phase: "completed" } }) + .onConflictDoUpdate({ + target: KVTable.key, + set: { value: { phase: "completed" }, time_updated: Date.now() }, + }) + .run() + }), + ) + .pipe(Effect.orDie) + return { status: "completed" as const } + }) + return yield* migrate + }).pipe(Effect.orDie), + ) +} + +function nextPath(options: Options, data: string) { + if (options.nextDatabasePath) return options.nextDatabasePath + if (process.env.OPENCODE_DB === ":memory:") return undefined + return path.join(data, "opencode-next.db") +} + +function openNextDatabase(sourcePath: string) { + return Effect.acquireRelease( + Effect.gen(function* () { + const sqlite = yield* Effect.promise(() => import("bun:sqlite")) + return new sqlite.Database(sourcePath, { readonly: true, strict: true }) + }), + (source) => Effect.sync(() => source.close()), + ) +} + +function countNextSessions(sourcePath: string | undefined) { + if (!sourcePath || !existsSync(sourcePath)) return Effect.succeed(0) + return Effect.scoped( + Effect.gen(function* () { + const source = yield* openNextDatabase(sourcePath) + if (!isNextDatabase(source)) return 0 + return source.query<{ value: number }, []>("SELECT COUNT(*) AS value FROM session").get()?.value ?? 0 + }), + ).pipe(Effect.orElseSucceed(() => 0)) +} + +function importNextDatabase( + db: Database.Interface["db"], + sourcePath: string | undefined, + onProgress: (completed: number) => void, +): Effect.Effect { + if (!sourcePath || !existsSync(sourcePath)) return Effect.void + return Effect.scoped( + Effect.gen(function* () { + const source = yield* openNextDatabase(sourcePath) + if (!isNextDatabase(source)) { + yield* Effect.logWarning("Skipped incompatible opencode-next.db", { path: sourcePath }) + return + } + source.run("BEGIN") + yield* Effect.addFinalizer(() => + Effect.sync(() => { + if (source.inTransaction) source.run("ROLLBACK") + }), + ) + const projects = new Map( + source + .query("SELECT * FROM project") + .all() + .map((project) => [project.id, project]), + ) + const sessions = source.query("SELECT * FROM session ORDER BY id DESC").all() + for (const [index, session] of sessions.entries()) { + const project = projects.get(session.project_id) + const projectID = project ? session.project_id : Project.ID.global + if (!project) { + yield* Effect.logWarning("Reassigned previous V2 session with missing project", { + sessionID: session.id, + projectID: session.project_id, + }) + } + const messages = source + .query< + NextMessage, + [string] + >("SELECT id, session_id, type, seq, time_created, time_updated, data FROM session_message WHERE session_id = ? ORDER BY seq") + .all(session.id) + yield* db + .transaction((tx) => + Effect.gen(function* () { + 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) + .where(eq(SessionTable.id, SessionSchema.ID.make(session.id))) + .get() + if (existing) return + yield* tx.run(sql` + INSERT INTO session_v2 ( + id, project_id, workspace_id, parent_id, fork_session_id, fork_boundary, 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, time_suspended + ) VALUES ( + ${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}, + ${session.summary_diffs}, ${session.metadata}, ${session.cost}, ${session.tokens_input}, + ${session.tokens_output}, ${session.tokens_reasoning}, ${session.tokens_cache_read}, + ${session.tokens_cache_write}, ${session.revert}, ${session.permission}, ${session.agent}, + ${session.model}, ${session.time_created}, ${session.time_updated}, ${session.time_compacting}, + ${session.time_archived}, ${session.time_suspended} + ) + `) + yield* Effect.forEach(messages, (message) => + tx + .insert(SessionMessageTable) + .values({ + id: SessionMessage.ID.make(message.id), + session_id: SessionSchema.ID.make(message.session_id), + type: message.type as SessionMessage.Type, + seq: message.seq, + time_created: message.time_created, + time_updated: message.time_updated, + data: sql`${message.data}`, + }) + .run(), + ) + yield* tx + .insert(EventSequenceTable) + .values({ aggregate_id: session.id, seq: messages.at(-1)?.seq ?? -1 }) + .onConflictDoUpdate({ + target: EventSequenceTable.aggregate_id, + set: { seq: messages.at(-1)?.seq ?? -1, owner_id: null }, + }) + .run() + }), + ) + .pipe(Effect.orDie) + onProgress(index + 1) + yield* Effect.yieldNow + } + source.run("COMMIT") + }), + ) +} + +function isNextDatabase(source: SQLiteDatabase) { + const tables = new Set( + source + .query<{ name: string }, []>("SELECT name FROM sqlite_master WHERE type = 'table'") + .all() + .map((table) => table.name), + ) + return tables.has("project") && tables.has("session") && tables.has("session_message") +} + +function row( + source: SourceMessage, + message: { + readonly id: string + readonly type: SessionMessage.Type + readonly time: { readonly created: number } + readonly [key: string]: unknown + }, +): TransformResult["messages"][number] { + const { id, type, ...data } = message + return { + id, + session_id: source.session_id, + type, + seq: 0, + time_created: source.time_created, + time_updated: source.time_updated, + data, + } +} + +function migrateTool(part: typeof SessionV1.ToolPart.Type, fallback: number) { + const base = { + type: "tool" as const, + id: part.callID, + name: part.tool, + ...(part.metadata ? { providerState: part.metadata } : {}), + } + if (part.state.status === "completed") + return { + ...base, + state: { + status: "completed", + input: part.state.input, + content: + part.state.time.compacted === undefined + ? [ + { type: "text", text: part.state.output }, + ...(part.state.attachments ?? []).map((file) => ({ + type: "file" as const, + uri: file.url, + mime: file.mime, + ...(file.filename ? { name: file.filename } : {}), + })), + ] + : [{ type: "text", text: "[Old tool result content cleared]" }], + metadata: part.state.metadata, + }, + time: { created: part.state.time.start, completed: part.state.time.end }, + } + if (part.state.status === "error") + return { + ...base, + state: { + status: "error", + input: part.state.input, + error: { type: "tool.execution", message: part.state.error }, + ...(typeof part.state.metadata?.output === "string" + ? { content: [{ type: "text", text: part.state.metadata.output }] } + : {}), + ...(part.state.metadata ? { metadata: part.state.metadata } : {}), + }, + time: { created: part.state.time.start, completed: part.state.time.end }, + } + return { + ...base, + state: { + status: "error", + input: part.state.input, + error: { type: "tool.interrupted", message: "Tool execution was interrupted before V2 migration" }, + ...(part.state.status === "running" && part.state.metadata ? { metadata: part.state.metadata } : {}), + }, + time: { created: part.state.status === "running" ? part.state.time.start : fallback }, + } +} + +function migrateError(error: NonNullable<(typeof SessionV1.Assistant.Type)["error"]>) { + const message = + "message" in error.data + ? error.data.message + : error.name === "MessageOutputLengthError" + ? "The model exceeded its output limit" + : error.name + const type = + error.name === "ProviderAuthError" + ? "provider.auth" + : error.name === "ContentFilterError" + ? "provider.content-filter" + : error.name === "ContextOverflowError" + ? "provider.invalid-request" + : error.name === "StructuredOutputError" || error.name === "MessageOutputLengthError" + ? "provider.invalid-output" + : error.name === "MessageAbortedError" + ? "aborted" + : error.name === "APIError" + ? "provider.error" + : "unknown" + return { type, message } +} + +function normalizeFinish(finish: string | undefined) { + if (!finish) return undefined + return ( + (["stop", "length", "tool-calls", "content-filter", "error", "unknown"] as const).find( + (value) => value === finish, + ) ?? "unknown" + ) +} + +function migrateFile(part: SessionV1.FilePart) { + if (!part.url.startsWith("data:")) return [] + const comma = part.url.indexOf(",") + if (comma < 0) return [] + const header = part.url.slice(0, comma) + const payload = part.url.slice(comma + 1) + const data = header.endsWith(";base64") + ? Buffer.from(payload, "base64").toString("base64") + : Buffer.from(decodeURIComponent(payload)).toString("base64") + return [ + { + data, + mime: part.mime, + source: + part.source?.type === "resource" ? { type: "uri" as const, uri: part.source.uri } : { type: "inline" as const }, + ...(part.filename ? { name: part.filename } : {}), + ...(part.source + ? { mention: { text: part.source.text.value, start: part.source.text.start, end: part.source.text.end } } + : {}), + }, + ] +} + +function unavailableFile(part: SessionV1.FilePart) { + const label = part.filename ?? (part.source?.type === "resource" ? part.source.uri : part.url) + return `[Attachment unavailable after migration: ${label} (${part.mime})]` +} + +function syntheticID(source: string, used: Set) { + const prefix = source.slice(0, 16) + const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + for (let salt = 0; ; salt++) { + const hex = new Bun.CryptoHasher("sha256").update(`v1-synthetic:${source}${salt ? `:${salt}` : ""}`).digest("hex") + let value = BigInt(`0x${hex}`) + let suffix = "" + while (suffix.length < 14) { + suffix = alphabet[Number(value % 62n)] + suffix + value /= 62n + } + const id = prefix + suffix + if (used.has(id)) continue + used.add(id) + return id + } +} + +function serializeRecent( + messages: ReadonlyArray<{ row: SourceMessage; value: typeof SessionV1.Info.Type }>, + parts: Map>, +) { + return messages + .flatMap((message) => { + const owned = parts.get(message.row.id)?.map((part) => part.value) ?? [] + if (message.value.role === "user") + return [ + `[User]: ${owned + .filter((part) => part.type === "text" && !part.ignored) + .map((part) => (part.type === "text" ? part.text : "")) + .join("\n\n")}`, + ] + return owned.flatMap((part) => + part.type === "text" + ? [`[Assistant]: ${part.text}`] + : part.type === "reasoning" && part.text + ? [`[Assistant reasoning]: ${part.text}`] + : [], + ) + }) + .join("\n\n") +} + +function readState(db: Database.Interface["db"]): Effect.Effect { + return db + .select({ value: KVTable.value }) + .from(KVTable) + .where(eq(KVTable.key, MIGRATION_STATE_KEY)) + .get() + .pipe( + Effect.map((row) => parseState(row?.value)), + Effect.orDie, + ) +} + +function parseState(input: unknown): MigrationState | undefined { + if (!input || typeof input !== "object" || !("phase" in input)) return + if (input.phase === "completed") return { phase: "completed" } + if (input.phase !== "sessions") return + if (!("cursor" in input) || input.cursor === undefined) return { phase: "sessions" } + if (typeof input.cursor === "string") return { phase: "sessions", cursor: input.cursor } +} + +function hasLegacySessions(db: Database.Interface["db"]) { + return db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session'`).pipe( + Effect.map((row) => row !== undefined), + Effect.orDie, + ) +} diff --git a/packages/core/src/database/v1-migration.noop.ts b/packages/core/src/database/v1-migration.noop.ts new file mode 100644 index 00000000000..268eaf44e7c --- /dev/null +++ b/packages/core/src/database/v1-migration.noop.ts @@ -0,0 +1,11 @@ +import { Effect, Layer } from "effect" + +export type Status = { readonly status: "completed" } + +export const layer = Layer.empty +export function status() { + return Effect.succeed({ status: "completed" } as const) +} +export function run() { + return Effect.succeed({ status: "completed" } as const) +} diff --git a/packages/core/src/database/v1-migration.ts b/packages/core/src/database/v1-migration.ts index 5c3150c77c1..55cb22797ea 100644 --- a/packages/core/src/database/v1-migration.ts +++ b/packages/core/src/database/v1-migration.ts @@ -1,997 +1,2 @@ -export * as V1Migration from "./v1-migration.js" - -import { Cause, Effect, Layer, Option, Schema, Semaphore } from "effect" -import { Database } from "./database.js" -import { SessionMessageTable, SessionTable } from "../session/sql.js" -import { SessionV1 } from "@opencode-ai/schema/session-v1" -import { SessionMessage } from "../session/message.js" -import { SessionSchema } from "../session/schema.js" -import { KVTable } from "../kv/sql.js" -import { EventSequenceTable } from "../event/sql.js" -import { eq, sql } from "drizzle-orm" -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 - readonly session_id: string - readonly time_created: number - readonly time_updated: number - readonly data: string -} - -export type SourcePart = { - readonly id: string - readonly message_id: string - readonly session_id: string - readonly time_created: number - readonly time_updated: number - readonly data: string -} - -export type TransformInput = { - readonly session: typeof SessionTable.$inferSelect - readonly messages: ReadonlyArray - readonly parts: ReadonlyArray -} - -export type Warning = { - readonly reason: string - readonly sessionID: string - readonly messageID?: string - readonly partID?: string - readonly observedType?: string -} - -export type TransformResult = { - readonly messages: ReadonlyArray<{ - readonly id: string - readonly session_id: string - readonly type: SessionMessage.Type - readonly seq: number - readonly time_created: number - readonly time_updated: number - readonly data: Record - }> - readonly session: Pick< - typeof SessionTable.$inferInsert, - | "agent" - | "model" - | "cost" - | "tokens_input" - | "tokens_output" - | "tokens_reasoning" - | "tokens_cache_read" - | "tokens_cache_write" - | "revert" - | "time_compacting" - > - readonly watermark: number - readonly warnings: ReadonlyArray -} - -type Progress = { - readonly label: string - readonly numerator?: number - readonly denominator?: number -} - -export type Status = - | { readonly status: "required" | "completed" } - | { readonly status: "running"; readonly progress: Progress } - | { readonly status: "error"; readonly error: string } - -type RunResult = { - readonly status: "completed" -} - -type Options = { - readonly nextDatabasePath?: string -} - -type MigrationState = { readonly phase: "sessions"; readonly cursor?: string } | { readonly phase: "completed" } - -type RuntimeState = - | { readonly status: "idle" } - | { readonly status: "running"; readonly progress: Progress } - | { readonly status: "error"; readonly error: string } - -type NextProject = { - readonly id: string - readonly worktree: string - readonly vcs: string | null - readonly name: string | null - readonly icon_url: string | null - readonly icon_url_override: string | null - readonly icon_color: string | null - readonly time_created: number - readonly time_updated: number - readonly time_initialized: number | null - readonly sandboxes: string - readonly commands: string | null -} - -type NextSession = { - readonly id: string - readonly project_id: string - readonly workspace_id: string | null - readonly parent_id: string | null - readonly fork_session_id: string | null - readonly fork_boundary: string | null - readonly slug: string - readonly directory: string - readonly path: string | null - readonly title: string | null - readonly version: string - readonly share_url: string | null - readonly summary_additions: number | null - readonly summary_deletions: number | null - readonly summary_files: number | null - readonly summary_diffs: string | null - readonly metadata: string | null - readonly cost: number - readonly tokens_input: number - readonly tokens_output: number - readonly tokens_reasoning: number - readonly tokens_cache_read: number - readonly tokens_cache_write: number - readonly revert: string | null - readonly permission: string | null - readonly agent: string | null - readonly model: string | null - readonly time_created: number - readonly time_updated: number - readonly time_compacting: number | null - readonly time_archived: number | null - readonly time_suspended: number | null -} - -type NextMessage = { - readonly id: string - readonly session_id: string - readonly type: string - readonly seq: number - readonly time_created: number - readonly time_updated: number - readonly data: string -} - -const lock = Semaphore.makeUnsafe(1) -const MIGRATION_STATE_KEY = "migration.v1-v2" -const EVENT_DELETE_BATCH_SIZE = 1_000 -const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString) -const decodeMessage = Schema.decodeUnknownOption(SessionV1.Info) -const decodePart = Schema.decodeUnknownOption(SessionV1.Part) -let runtimeState: RuntimeState = { status: "idle" } - -export function transformSession(input: TransformInput): TransformResult { - const warnings: Warning[] = [] - const messages = input.messages - .map((row) => { - const value = Option.getOrUndefined(decodeJson(row.data)) - const decoded = - value && typeof value === "object" - ? Option.getOrUndefined(decodeMessage({ ...value, id: row.id, sessionID: row.session_id })) - : undefined - if (decoded) return { row, value: decoded } - warnings.push({ reason: "invalid-message", sessionID: input.session.id, messageID: row.id }) - return undefined - }) - .filter((item): item is NonNullable => item !== undefined) - .sort((a, b) => a.row.time_created - b.row.time_created || a.row.id.localeCompare(b.row.id)) - const messageIDs = new Set(input.messages.map((row) => row.id)) - const parts = input.parts - .map((row) => { - const value = Option.getOrUndefined(decodeJson(row.data)) - const observedType = value && typeof value === "object" && "type" in value ? String(value.type) : undefined - if (!messageIDs.has(row.message_id)) { - warnings.push({ - reason: "orphan-part", - sessionID: input.session.id, - messageID: row.message_id, - partID: row.id, - observedType, - }) - return undefined - } - const decoded = - value && typeof value === "object" - ? Option.getOrUndefined( - decodePart({ ...value, id: row.id, messageID: row.message_id, sessionID: row.session_id }), - ) - : undefined - if (decoded) return { row, value: decoded } - warnings.push({ - reason: "invalid-part", - sessionID: input.session.id, - messageID: row.message_id, - partID: row.id, - observedType, - }) - return undefined - }) - .filter((item): item is NonNullable => item !== undefined) - .sort((a, b) => a.row.id.localeCompare(b.row.id)) - const byMessage = Map.groupBy(parts, (item) => item.row.message_id) - const paired = new Set() - const used = new Set(messages.map((item) => item.row.id)) - const projected = messages - .flatMap((item) => { - if (paired.has(item.row.id)) return [] - const owned = byMessage.get(item.row.id)?.map((part) => part.value) ?? [] - if (item.value.role === "user") { - const compaction = owned.find((part) => part.type === "compaction") - if (compaction?.type === "compaction") { - const pairedSummary = messages.find( - (candidate) => - candidate.value.role === "assistant" && - candidate.value.parentID === item.row.id && - candidate.value.summary, - ) - if (!pairedSummary || pairedSummary.value.role !== "assistant") return [] - paired.add(pairedSummary.row.id) - if (pairedSummary.value.error || pairedSummary.value.time.completed === undefined) return [] - const summary = pairedSummary - const summaryText = (byMessage.get(summary.row.id) ?? []) - .map((part) => part.value) - .filter((part) => part.type === "text" && part.text.length > 0) - .map((part) => (part.type === "text" ? part.text : "")) - .join("\n\n") - const tailIndex = compaction.tail_start_id - ? messages.findIndex((candidate) => candidate.row.id === compaction.tail_start_id) - : -1 - const compactionIndex = messages.findIndex((candidate) => candidate.row.id === item.row.id) - const tail = tailIndex < 0 ? [] : messages.slice(tailIndex, compactionIndex) - return [ - row( - { ...item.row, time_updated: Math.max(item.row.time_updated, summary.row.time_updated) }, - { - id: item.row.id, - type: "compaction", - status: "completed", - reason: compaction.auto ? "auto" : "manual", - summary: summaryText, - recent: serializeRecent(tail, byMessage), - time: { created: item.row.time_created }, - }, - ), - ] - } - const subtasks = owned.filter((part) => part.type === "subtask") - const visible = owned.filter((part) => part.type === "text" && !part.ignored) - const files = owned.filter((part) => part.type === "file") - const agents = owned.filter((part) => part.type === "agent") - if (subtasks.length > 0 && visible.length === 0 && files.length === 0 && agents.length === 0) return [] - const ordinary = visible.filter((part) => part.type === "text" && !part.synthetic) - const synthetic = visible.filter((part) => part.type === "text" && part.synthetic) - const attachments = files.flatMap((part) => (part.type === "file" ? migrateFile(part) : [])) - const unavailable = files.flatMap((part) => - part.type === "file" && !part.url.startsWith("data:") ? [unavailableFile(part)] : [], - ) - const text = owned - .flatMap((part) => { - if (part.type === "text" && !part.ignored && !part.synthetic) return [part.text] - if (part.type === "file" && !part.url.startsWith("data:")) return [unavailableFile(part)] - return [] - }) - .join("\n\n") - const agentAttachments = agents.map((part) => - part.type === "agent" - ? { - name: part.name, - ...(part.source - ? { mention: { text: part.source.value, start: part.source.start, end: part.source.end } } - : {}), - } - : { name: "" }, - ) - if ( - ordinary.length === 0 && - unavailable.length === 0 && - synthetic.length > 0 && - attachments.length === 0 && - agentAttachments.length === 0 - ) - return [ - row(item.row, { - id: item.row.id, - type: "synthetic", - text: synthetic.map((part) => (part.type === "text" ? part.text : "")).join("\n\n"), - time: { created: item.row.time_created }, - }), - ] - const user = row(item.row, { - id: item.row.id, - type: "user", - text, - ...(attachments.length ? { files: attachments } : {}), - ...(agentAttachments.length ? { agents: agentAttachments } : {}), - time: { created: item.row.time_created }, - }) - if (synthetic.length === 0) return [user] - return [ - user, - row(item.row, { - id: syntheticID(item.row.id, used), - type: "synthetic", - text: synthetic.map((part) => (part.type === "text" ? part.text : "")).join("\n\n"), - time: { created: item.row.time_created }, - }), - ] - } - if (item.value.role !== "assistant") return [] - const assistant = item.value - const parent = messages.find((candidate) => candidate.row.id === assistant.parentID) - const parentParts = parent ? (byMessage.get(parent.row.id)?.map((part) => part.value) ?? []) : [] - if ( - parentParts.some((part) => part.type === "subtask") && - owned.some((part) => part.type === "tool" && part.tool === "task") - ) - return [] - const content = owned.flatMap((part): Array> => { - if (part.type === "text") - return [{ type: "text", text: part.text, ...(part.metadata ? { state: part.metadata } : {}) }] - if (part.type === "reasoning") - return [ - { - type: "reasoning", - text: part.text, - ...(part.metadata ? { state: part.metadata } : {}), - time: { created: part.time.start, ...(part.time.end === undefined ? {} : { completed: part.time.end }) }, - }, - ] - if (part.type !== "tool") return [] - return [migrateTool(part, item.row.time_created)] - }) - const start = - owned.flatMap((part) => (part.type === "step-start" && part.snapshot ? [part.snapshot] : []))[0] ?? - owned.flatMap((part) => (part.type === "snapshot" ? [part.snapshot] : []))[0] ?? - owned.flatMap((part) => (part.type === "patch" ? [part.hash] : []))[0] - const end = owned.flatMap((part) => (part.type === "step-finish" && part.snapshot ? [part.snapshot] : [])).at(-1) - const snapshotFiles = Array.from(new Set(owned.flatMap((part) => (part.type === "patch" ? part.files : [])))) - const finish = normalizeFinish(assistant.finish) - return [ - row(item.row, { - id: item.row.id, - type: "assistant", - agent: assistant.agent, - model: { - providerID: assistant.providerID, - id: assistant.modelID, - variant: assistant.variant ?? "default", - }, - content, - ...(start || end || snapshotFiles.length - ? { - snapshot: { - ...(start ? { start } : {}), - ...(end ? { end } : {}), - ...(snapshotFiles.length ? { files: snapshotFiles } : {}), - }, - } - : {}), - ...(finish ? { finish } : {}), - cost: assistant.cost, - tokens: { - input: assistant.tokens.input, - output: assistant.tokens.output, - reasoning: assistant.tokens.reasoning, - cache: assistant.tokens.cache, - }, - ...(assistant.error ? { error: migrateError(assistant.error) } : {}), - time: { - created: item.row.time_created, - ...(assistant.time.completed === undefined ? {} : { completed: item.row.time_updated }), - }, - }), - ] - }) - .map((item, seq) => ({ ...item, seq })) - const assistants = messages - .filter((item) => item.value.role === "assistant") - .map((item) => item.value) - .filter((item): item is SessionV1.Assistant => item.role === "assistant") - const latestUser = messages.findLast((item) => { - if (item.value.role !== "user") return false - const owned = byMessage.get(item.row.id) ?? [] - if (owned.some((part) => part.value.type === "compaction")) return false - return !owned.some((part) => part.value.type === "subtask") || !owned.every((part) => part.value.type === "subtask") - }) - return { - messages: projected, - session: { - agent: input.session.agent ?? (latestUser?.value.role === "user" ? latestUser.value.agent : null), - model: - input.session.model ?? - (latestUser?.value.role === "user" - ? { - id: latestUser.value.model.modelID, - providerID: latestUser.value.model.providerID, - variant: latestUser.value.model.variant ?? "default", - } - : null), - cost: assistants.reduce((total, item) => total + item.cost, 0), - tokens_input: assistants.reduce((total, item) => total + item.tokens.input, 0), - tokens_output: assistants.reduce((total, item) => total + item.tokens.output, 0), - tokens_reasoning: assistants.reduce((total, item) => total + item.tokens.reasoning, 0), - tokens_cache_read: assistants.reduce((total, item) => total + item.tokens.cache.read, 0), - tokens_cache_write: assistants.reduce((total, item) => total + item.tokens.cache.write, 0), - revert: null, - time_compacting: null, - }, - watermark: projected.length - 1, - warnings, - } -} - -export function status(): Effect.Effect { - return Effect.gen(function* () { - const { db } = yield* Database.Service - if (!(yield* hasLegacySessions(db))) return { status: "completed" as const } - const state = yield* readState(db) - if (runtimeState.status === "running") return runtimeState - if (runtimeState.status === "error") return runtimeState - if (state?.phase === "completed") return { status: "completed" as const } - return { status: "required" as const } - }).pipe(Effect.orDie) -} - -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) - const cause = input.cause - return cause === undefined ? input.message : `${input.message}\nCaused by: ${errorText(cause)}` -} - -function updateProgress(progress: Progress) { - if (runtimeState.status === "running") runtimeState = { status: "running", progress } -} - -export function run(options: Options = {}): Effect.Effect { - return lock.withPermit( - Effect.gen(function* () { - const { db } = yield* Database.Service - const global = yield* Global.Service - const state = yield* readState(db) - if (state?.phase === "completed") return { status: "completed" as const } - if (!(yield* hasLegacySessions(db))) return { status: "completed" as const } - 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.data).root}, ${now}, ${now}, '[]') - `) - if (state === undefined) - yield* db - .transaction((tx) => - Effect.gen(function* () { - while (true) { - yield* tx.run(sql` - DELETE FROM event - WHERE rowid IN (SELECT rowid FROM event LIMIT ${EVENT_DELETE_BATCH_SIZE}) - `) - const deleted = (yield* tx.get<{ value: number }>(sql`SELECT changes() AS value`))?.value ?? 0 - if (deleted < EVENT_DELETE_BATCH_SIZE) break - yield* Effect.yieldNow - } - yield* tx - .insert(KVTable) - .values({ key: MIGRATION_STATE_KEY, value: { phase: "sessions" } }) - .run() - }), - ) - .pipe(Effect.orDie) - const sourceTotal = yield* countNextSessions(nextPath(options, global.data)) - const legacyTotal = (yield* db.get<{ value: number }>(sql`SELECT COUNT(*) AS value FROM session`))?.value ?? 0 - const cursor = state?.phase === "sessions" ? state.cursor : undefined - const migrated = - cursor !== undefined - ? ((yield* db.get<{ value: number }>(sql`SELECT COUNT(*) AS value FROM session WHERE id >= ${cursor}`)) - ?.value ?? 0) - : 0 - const denominator = sourceTotal + legacyTotal - updateProgress({ label: "Migrating sessions", numerator: migrated, denominator }) - yield* importNextDatabase(db, nextPath(options, global.data), (completed) => { - 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), - ) - 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 }>( - 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`, - ) - if (!nextID) break - yield* db - .transaction((tx) => - Effect.gen(function* () { - yield* tx - .insert(KVTable) - .values({ key: MIGRATION_STATE_KEY, value: { phase: "sessions", cursor: nextID.id } }) - .onConflictDoUpdate({ - target: KVTable.key, - set: { value: { phase: "sessions", cursor: nextID.id }, time_updated: Date.now() }, - }) - .run() - 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: nextID.project_id, - }) - yield* tx.run(sql` - INSERT OR IGNORE INTO session_v2 ( - id, project_id, 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 - ) - SELECT - 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 - FROM session - WHERE id = ${nextID.id} - `) - const next = yield* tx - .select() - .from(SessionTable) - .where(eq(SessionTable.id, SessionSchema.ID.make(nextID.id))) - .get() - if (!next) return yield* Effect.die(new Error(`Failed to copy V1 session ${nextID.id}`)) - const sourceMessages = yield* tx.all( - sql`SELECT id, session_id, time_created, time_updated, data FROM message WHERE session_id = ${next.id}`, - ) - const sourceParts = yield* tx.all( - sql`SELECT id, message_id, session_id, time_created, time_updated, data FROM part WHERE session_id = ${next.id}`, - ) - const transformed = transformSession({ session: next, messages: sourceMessages, parts: sourceParts }) - yield* Effect.forEach(transformed.warnings, (warning) => - Effect.logWarning("Skipped V1 migration row", warning), - ) - yield* tx.delete(SessionMessageTable).where(eq(SessionMessageTable.session_id, next.id)).run() - yield* Effect.forEach(transformed.messages, (message) => - tx - .insert(SessionMessageTable) - .values({ - id: SessionMessage.ID.make(message.id), - session_id: SessionSchema.ID.make(message.session_id), - type: message.type, - seq: message.seq, - time_created: message.time_created, - time_updated: message.time_updated, - data: sql`${JSON.stringify(message.data)}`, - }) - .run(), - ) - yield* tx - .update(SessionTable) - .set({ ...transformed.session, time_updated: next.time_updated }) - .where(eq(SessionTable.id, next.id)) - .run() - yield* tx - .insert(EventSequenceTable) - .values({ aggregate_id: next.id, seq: transformed.watermark }) - .onConflictDoUpdate({ - target: EventSequenceTable.aggregate_id, - set: { seq: transformed.watermark, owner_id: null }, - }) - .run() - }), - ) - .pipe(Effect.orDie) - if (runtimeState.status === "running") - runtimeState = { - status: "running", - progress: { - label: "Migrating sessions", - numerator: (runtimeState.progress.numerator ?? 0) + 1, - denominator, - }, - } - yield* Effect.yieldNow - } - yield* db - .transaction((tx) => - Effect.gen(function* () { - yield* tx - .insert(KVTable) - .values({ key: MIGRATION_STATE_KEY, value: { phase: "completed" } }) - .onConflictDoUpdate({ - target: KVTable.key, - set: { value: { phase: "completed" }, time_updated: Date.now() }, - }) - .run() - }), - ) - .pipe(Effect.orDie) - return { status: "completed" as const } - }) - return yield* migrate - }).pipe(Effect.orDie), - ) -} - -function nextPath(options: Options, data: string) { - if (options.nextDatabasePath) return options.nextDatabasePath - if (process.env.OPENCODE_DB === ":memory:") return undefined - return path.join(data, "opencode-next.db") -} - -function openNextDatabase(sourcePath: string) { - return Effect.acquireRelease( - Effect.gen(function* () { - const sqlite = yield* Effect.promise(() => import("bun:sqlite")) - return new sqlite.Database(sourcePath, { readonly: true, strict: true }) - }), - (source) => Effect.sync(() => source.close()), - ) -} - -function countNextSessions(sourcePath: string | undefined) { - if (!sourcePath || !existsSync(sourcePath)) return Effect.succeed(0) - return Effect.scoped( - Effect.gen(function* () { - const source = yield* openNextDatabase(sourcePath) - if (!isNextDatabase(source)) return 0 - return source.query<{ value: number }, []>("SELECT COUNT(*) AS value FROM session").get()?.value ?? 0 - }), - ).pipe(Effect.orElseSucceed(() => 0)) -} - -function importNextDatabase( - db: Database.Interface["db"], - sourcePath: string | undefined, - onProgress: (completed: number) => void, -): Effect.Effect { - if (!sourcePath || !existsSync(sourcePath)) return Effect.void - return Effect.scoped( - Effect.gen(function* () { - const source = yield* openNextDatabase(sourcePath) - if (!isNextDatabase(source)) { - yield* Effect.logWarning("Skipped incompatible opencode-next.db", { path: sourcePath }) - return - } - source.run("BEGIN") - yield* Effect.addFinalizer(() => - Effect.sync(() => { - if (source.inTransaction) source.run("ROLLBACK") - }), - ) - const projects = new Map( - source - .query("SELECT * FROM project") - .all() - .map((project) => [project.id, project]), - ) - const sessions = source.query("SELECT * FROM session ORDER BY id DESC").all() - for (const [index, session] of sessions.entries()) { - const project = projects.get(session.project_id) - const projectID = project ? session.project_id : Project.ID.global - if (!project) { - yield* Effect.logWarning("Reassigned previous V2 session with missing project", { - sessionID: session.id, - projectID: session.project_id, - }) - } - const messages = source - .query< - NextMessage, - [string] - >("SELECT id, session_id, type, seq, time_created, time_updated, data FROM session_message WHERE session_id = ? ORDER BY seq") - .all(session.id) - yield* db - .transaction((tx) => - Effect.gen(function* () { - 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) - .where(eq(SessionTable.id, SessionSchema.ID.make(session.id))) - .get() - if (existing) return - yield* tx.run(sql` - INSERT INTO session_v2 ( - id, project_id, workspace_id, parent_id, fork_session_id, fork_boundary, 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, time_suspended - ) VALUES ( - ${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}, - ${session.summary_diffs}, ${session.metadata}, ${session.cost}, ${session.tokens_input}, - ${session.tokens_output}, ${session.tokens_reasoning}, ${session.tokens_cache_read}, - ${session.tokens_cache_write}, ${session.revert}, ${session.permission}, ${session.agent}, - ${session.model}, ${session.time_created}, ${session.time_updated}, ${session.time_compacting}, - ${session.time_archived}, ${session.time_suspended} - ) - `) - yield* Effect.forEach(messages, (message) => - tx - .insert(SessionMessageTable) - .values({ - id: SessionMessage.ID.make(message.id), - session_id: SessionSchema.ID.make(message.session_id), - type: message.type as SessionMessage.Type, - seq: message.seq, - time_created: message.time_created, - time_updated: message.time_updated, - data: sql`${message.data}`, - }) - .run(), - ) - yield* tx - .insert(EventSequenceTable) - .values({ aggregate_id: session.id, seq: messages.at(-1)?.seq ?? -1 }) - .onConflictDoUpdate({ - target: EventSequenceTable.aggregate_id, - set: { seq: messages.at(-1)?.seq ?? -1, owner_id: null }, - }) - .run() - }), - ) - .pipe(Effect.orDie) - onProgress(index + 1) - yield* Effect.yieldNow - } - source.run("COMMIT") - }), - ) -} - -function isNextDatabase(source: SQLiteDatabase) { - const tables = new Set( - source - .query<{ name: string }, []>("SELECT name FROM sqlite_master WHERE type = 'table'") - .all() - .map((table) => table.name), - ) - return tables.has("project") && tables.has("session") && tables.has("session_message") -} - -function row( - source: SourceMessage, - message: { - readonly id: string - readonly type: SessionMessage.Type - readonly time: { readonly created: number } - readonly [key: string]: unknown - }, -): TransformResult["messages"][number] { - const { id, type, ...data } = message - return { - id, - session_id: source.session_id, - type, - seq: 0, - time_created: source.time_created, - time_updated: source.time_updated, - data, - } -} - -function migrateTool(part: typeof SessionV1.ToolPart.Type, fallback: number) { - const base = { - type: "tool" as const, - id: part.callID, - name: part.tool, - ...(part.metadata ? { providerState: part.metadata } : {}), - } - if (part.state.status === "completed") - return { - ...base, - state: { - status: "completed", - input: part.state.input, - content: - part.state.time.compacted === undefined - ? [ - { type: "text", text: part.state.output }, - ...(part.state.attachments ?? []).map((file) => ({ - type: "file" as const, - uri: file.url, - mime: file.mime, - ...(file.filename ? { name: file.filename } : {}), - })), - ] - : [{ type: "text", text: "[Old tool result content cleared]" }], - metadata: part.state.metadata, - }, - time: { created: part.state.time.start, completed: part.state.time.end }, - } - if (part.state.status === "error") - return { - ...base, - state: { - status: "error", - input: part.state.input, - error: { type: "tool.execution", message: part.state.error }, - ...(typeof part.state.metadata?.output === "string" - ? { content: [{ type: "text", text: part.state.metadata.output }] } - : {}), - ...(part.state.metadata ? { metadata: part.state.metadata } : {}), - }, - time: { created: part.state.time.start, completed: part.state.time.end }, - } - return { - ...base, - state: { - status: "error", - input: part.state.input, - error: { type: "tool.interrupted", message: "Tool execution was interrupted before V2 migration" }, - ...(part.state.status === "running" && part.state.metadata ? { metadata: part.state.metadata } : {}), - }, - time: { created: part.state.status === "running" ? part.state.time.start : fallback }, - } -} - -function migrateError(error: NonNullable<(typeof SessionV1.Assistant.Type)["error"]>) { - const message = - "message" in error.data - ? error.data.message - : error.name === "MessageOutputLengthError" - ? "The model exceeded its output limit" - : error.name - const type = - error.name === "ProviderAuthError" - ? "provider.auth" - : error.name === "ContentFilterError" - ? "provider.content-filter" - : error.name === "ContextOverflowError" - ? "provider.invalid-request" - : error.name === "StructuredOutputError" || error.name === "MessageOutputLengthError" - ? "provider.invalid-output" - : error.name === "MessageAbortedError" - ? "aborted" - : error.name === "APIError" - ? "provider.error" - : "unknown" - return { type, message } -} - -function normalizeFinish(finish: string | undefined) { - if (!finish) return undefined - return ( - (["stop", "length", "tool-calls", "content-filter", "error", "unknown"] as const).find( - (value) => value === finish, - ) ?? "unknown" - ) -} - -function migrateFile(part: SessionV1.FilePart) { - if (!part.url.startsWith("data:")) return [] - const comma = part.url.indexOf(",") - if (comma < 0) return [] - const header = part.url.slice(0, comma) - const payload = part.url.slice(comma + 1) - const data = header.endsWith(";base64") - ? Buffer.from(payload, "base64").toString("base64") - : Buffer.from(decodeURIComponent(payload)).toString("base64") - return [ - { - data, - mime: part.mime, - source: - part.source?.type === "resource" ? { type: "uri" as const, uri: part.source.uri } : { type: "inline" as const }, - ...(part.filename ? { name: part.filename } : {}), - ...(part.source - ? { mention: { text: part.source.text.value, start: part.source.text.start, end: part.source.text.end } } - : {}), - }, - ] -} - -function unavailableFile(part: SessionV1.FilePart) { - const label = part.filename ?? (part.source?.type === "resource" ? part.source.uri : part.url) - return `[Attachment unavailable after migration: ${label} (${part.mime})]` -} - -function syntheticID(source: string, used: Set) { - const prefix = source.slice(0, 16) - const alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" - for (let salt = 0; ; salt++) { - const hex = new Bun.CryptoHasher("sha256").update(`v1-synthetic:${source}${salt ? `:${salt}` : ""}`).digest("hex") - let value = BigInt(`0x${hex}`) - let suffix = "" - while (suffix.length < 14) { - suffix = alphabet[Number(value % 62n)] + suffix - value /= 62n - } - const id = prefix + suffix - if (used.has(id)) continue - used.add(id) - return id - } -} - -function serializeRecent( - messages: ReadonlyArray<{ row: SourceMessage; value: typeof SessionV1.Info.Type }>, - parts: Map>, -) { - return messages - .flatMap((message) => { - const owned = parts.get(message.row.id)?.map((part) => part.value) ?? [] - if (message.value.role === "user") - return [ - `[User]: ${owned - .filter((part) => part.type === "text" && !part.ignored) - .map((part) => (part.type === "text" ? part.text : "")) - .join("\n\n")}`, - ] - return owned.flatMap((part) => - part.type === "text" - ? [`[Assistant]: ${part.text}`] - : part.type === "reasoning" && part.text - ? [`[Assistant reasoning]: ${part.text}`] - : [], - ) - }) - .join("\n\n") -} - -function readState(db: Database.Interface["db"]): Effect.Effect { - return db - .select({ value: KVTable.value }) - .from(KVTable) - .where(eq(KVTable.key, MIGRATION_STATE_KEY)) - .get() - .pipe( - Effect.map((row) => parseState(row?.value)), - Effect.orDie, - ) -} - -function parseState(input: unknown): MigrationState | undefined { - if (!input || typeof input !== "object" || !("phase" in input)) return - if (input.phase === "completed") return { phase: "completed" } - if (input.phase !== "sessions") return - if (!("cursor" in input) || input.cursor === undefined) return { phase: "sessions" } - if (typeof input.cursor === "string") return { phase: "sessions", cursor: input.cursor } -} - -function hasLegacySessions(db: Database.Interface["db"]) { - return db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session'`).pipe( - Effect.map((row) => row !== undefined), - Effect.orDie, - ) -} +export * as V1Migration from "#v1-migration" +export * from "#v1-migration" diff --git a/packages/core/test/file-mutation.test.ts b/packages/core/test/file-mutation.test.ts index 11e70c65294..7a7508d2f61 100644 --- a/packages/core/test/file-mutation.test.ts +++ b/packages/core/test/file-mutation.test.ts @@ -5,7 +5,7 @@ import { Deferred, Effect, Fiber, Layer } from "effect" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { FileMutation } from "@opencode-ai/core/file-mutation" -import { Environment } from "@opencode-ai/core/environment" +import { Environment } from "@opencode-ai/core/environment/index" import { Location } from "@opencode-ai/core/location" import { LocationMutation } from "@opencode-ai/core/location-mutation" import { AbsolutePath } from "@opencode-ai/core/schema" diff --git a/packages/core/test/fixture/environment.ts b/packages/core/test/fixture/environment.ts index 1b77e7b0af4..67c4f858acc 100644 --- a/packages/core/test/fixture/environment.ts +++ b/packages/core/test/fixture/environment.ts @@ -1,5 +1,5 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" -import { Environment } from "@opencode-ai/core/environment" +import { Environment } from "@opencode-ai/core/environment/index" import { Location } from "@opencode-ai/core/location" import { Effect, Layer } from "effect" diff --git a/packages/core/test/instruction-discovery.test.ts b/packages/core/test/instruction-discovery.test.ts index 5c2706f2d1f..de5005a38c1 100644 --- a/packages/core/test/instruction-discovery.test.ts +++ b/packages/core/test/instruction-discovery.test.ts @@ -7,7 +7,7 @@ import { Bus } from "@opencode-ai/core/bus" import { ConfigInstructionPlugin } from "@opencode-ai/core/config/plugin/instruction" import { Watcher } from "@opencode-ai/core/filesystem/watcher" import { InstructionDiscovery } from "@opencode-ai/core/instruction-discovery" -import { Instructions } from "@opencode-ai/core/instructions" +import { Instructions } from "@opencode-ai/core/instructions/index" import { Location } from "@opencode-ai/core/location" import { AbsolutePath } from "@opencode-ai/core/schema" import { FSUtil } from "@opencode-ai/util/fs-util" diff --git a/packages/core/test/instruction-state.test.ts b/packages/core/test/instruction-state.test.ts index 36f9c61ef18..837d683b5cf 100644 --- a/packages/core/test/instruction-state.test.ts +++ b/packages/core/test/instruction-state.test.ts @@ -7,7 +7,7 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { Bus } from "@opencode-ai/core/bus" import { Event } from "@opencode-ai/schema/event" import { EventTable } from "@opencode-ai/core/event/sql" -import { Instructions } from "@opencode-ai/core/instructions" +import { Instructions } from "@opencode-ai/core/instructions/index" import { Project } from "@opencode-ai/core/project" import { ProjectTable } from "@opencode-ai/core/project/sql" import { AbsolutePath } from "@opencode-ai/core/schema" diff --git a/packages/core/test/instructions/index.test.ts b/packages/core/test/instructions/index.test.ts index 1b74bd130cb..842c5bb536f 100644 --- a/packages/core/test/instructions/index.test.ts +++ b/packages/core/test/instructions/index.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "bun:test" import { Cause, Effect, Exit, Option, Schema } from "effect" -import { Instructions } from "@opencode-ai/core/instructions" +import { Instructions } from "@opencode-ai/core/instructions/index" import { it } from "../lib/effect" const key = (value: string) => Instructions.Key.make(value) diff --git a/packages/core/test/lib/environment-conformance.ts b/packages/core/test/lib/environment-conformance.ts index 0264aec011b..6d656690619 100644 --- a/packages/core/test/lib/environment-conformance.ts +++ b/packages/core/test/lib/environment-conformance.ts @@ -1,7 +1,6 @@ -import { describe, expect } from "bun:test" +import { describe, expect, test } from "bun:test" import { Effect } from "effect" -import { Failed, NotFound, WrongKind, type Files } from "../../src/environment/index" -import { it } from "./effect" +import { Failed, NotFound, WrongKind, type Files } from "@opencode-ai/core/environment/index" export interface EnvironmentHarness { readonly files: Files @@ -16,17 +15,21 @@ export const environmentConformance = ( skip = false, ) => { const check = (title: string, body: (harness: EnvironmentHarness) => Effect.Effect) => - it.live(title, () => - Effect.gen(function* () { - const harness = yield* Effect.acquireRelease(makeHarness(), (harness) => + test(title, () => + Effect.runPromise( + Effect.scoped( Effect.gen(function* () { - yield* Effect.ignore(harness.files.remove(harness.root)) - if (harness.dispose) yield* harness.dispose + const harness = yield* Effect.acquireRelease(makeHarness(), (harness) => + Effect.gen(function* () { + yield* Effect.ignore(harness.files.remove(harness.root)) + if (harness.dispose) yield* harness.dispose + }), + ) + yield* harness.files.mkdir(harness.root) + return yield* body(harness) }), - ) - yield* harness.files.mkdir(harness.root) - return yield* body(harness) - }), + ), + ), ) const bytes = (value: string) => new TextEncoder().encode(value) diff --git a/packages/core/test/lib/instructions.ts b/packages/core/test/lib/instructions.ts index e7a0f6b7236..7a129d41ebb 100644 --- a/packages/core/test/lib/instructions.ts +++ b/packages/core/test/lib/instructions.ts @@ -1,5 +1,5 @@ import { Effect, Option, Schema } from "effect" -import { Instructions } from "@opencode-ai/core/instructions" +import { Instructions } from "@opencode-ai/core/instructions/index" export interface State { readonly values: Readonly> diff --git a/packages/core/test/session-execution.test.ts b/packages/core/test/session-execution.test.ts index e6a6d498d59..090bbb54322 100644 --- a/packages/core/test/session-execution.test.ts +++ b/packages/core/test/session-execution.test.ts @@ -14,7 +14,7 @@ import { SessionExecution } from "@opencode-ai/core/session/execution" import { SessionRestart } from "@opencode-ai/core/session/execution/restart" import { UserInterruptedError } from "@opencode-ai/core/session/error" import { SessionEvent } from "@opencode-ai/core/session/event" -import { SessionRunner } from "@opencode-ai/core/session/runner" +import { SessionRunner } from "@opencode-ai/core/session/runner/index" import { SessionTable } from "@opencode-ai/core/session/sql" import { SessionStore } from "@opencode-ai/core/session/store" import { Context, Deferred, Effect, Exit, Fiber, Layer, LayerMap, Scope } from "effect" diff --git a/packages/core/test/session-generate.test.ts b/packages/core/test/session-generate.test.ts index 35f2998cc64..88d1f212bfc 100644 --- a/packages/core/test/session-generate.test.ts +++ b/packages/core/test/session-generate.test.ts @@ -17,7 +17,7 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { Bus } from "@opencode-ai/core/bus" import { EventTable } from "@opencode-ai/core/event/sql" import { InstructionDiscovery } from "@opencode-ai/core/instruction-discovery" -import { Instructions } from "@opencode-ai/core/instructions" +import { Instructions } from "@opencode-ai/core/instructions/index" import { InstructionBuiltIns } from "@opencode-ai/core/instructions/builtins" import { Location } from "@opencode-ai/core/location" import { McpInstructions } from "@opencode-ai/core/mcp/instructions" diff --git a/packages/core/test/session-runner-recorded.test.ts b/packages/core/test/session-runner-recorded.test.ts index 02bec18ed26..891d9b61cbf 100644 --- a/packages/core/test/session-runner-recorded.test.ts +++ b/packages/core/test/session-runner-recorded.test.ts @@ -22,7 +22,7 @@ import { SessionTitle } from "@opencode-ai/core/session/title" import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionExecution } from "@opencode-ai/core/session/execution" import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator" -import { SessionRunner } from "@opencode-ai/core/session/runner" +import { SessionRunner } from "@opencode-ai/core/session/runner/index" import * as SessionRunnerLLM from "@opencode-ai/core/session/runner/llm" import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" import { Tool } from "@opencode-ai/core/tool" @@ -31,7 +31,7 @@ import { SessionStore } from "@opencode-ai/core/session/store" import { Location } from "@opencode-ai/core/location" import { InstructionBuiltIns } from "@opencode-ai/core/instructions/builtins" import { InstructionDiscovery } from "@opencode-ai/core/instruction-discovery" -import { Instructions } from "@opencode-ai/core/instructions" +import { Instructions } from "@opencode-ai/core/instructions/index" import { SkillInstructions } from "@opencode-ai/core/skill/instructions" import { ReferenceInstructions } from "@opencode-ai/core/reference/instructions" import { McpInstructions } from "@opencode-ai/core/mcp/instructions" diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 40efdbb4bb0..22c4daf98e2 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -38,7 +38,7 @@ import { Money } from "@opencode-ai/schema/money" import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionExecution } from "@opencode-ai/core/session/execution" import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator" -import { SessionRunner } from "@opencode-ai/core/session/runner" +import { SessionRunner } from "@opencode-ai/core/session/runner/index" import * as SessionRunnerLLM from "@opencode-ai/core/session/runner/llm" import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" import { PromptCacheDiagnostics } from "@opencode-ai/core/session/prompt-cache-diagnostics" @@ -61,7 +61,7 @@ import { } from "@opencode-ai/core/session/sql" import { InstructionEntry } from "@opencode-ai/core/session/instruction-entry" import { SessionStore } from "@opencode-ai/core/session/store" -import { Instructions } from "@opencode-ai/core/instructions" +import { Instructions } from "@opencode-ai/core/instructions/index" import { InstructionBuiltIns } from "@opencode-ai/core/instructions/builtins" import { InstructionDiscovery } from "@opencode-ai/core/instruction-discovery" import { SkillInstructions } from "@opencode-ai/core/skill/instructions" diff --git a/packages/core/test/tool-edit.test.ts b/packages/core/test/tool-edit.test.ts index e730ebf53dd..10bb94f8179 100644 --- a/packages/core/test/tool-edit.test.ts +++ b/packages/core/test/tool-edit.test.ts @@ -4,7 +4,7 @@ import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/util/effect/layer-node" -import { Environment } from "@opencode-ai/core/environment" +import { Environment } from "@opencode-ai/core/environment/index" import { FileMutation } from "@opencode-ai/core/file-mutation" import { Formatter } from "@opencode-ai/core/formatter" import { Location } from "@opencode-ai/core/location" diff --git a/packages/core/test/tool-patch.test.ts b/packages/core/test/tool-patch.test.ts index 5a08fc8dfc6..9c1cc590905 100644 --- a/packages/core/test/tool-patch.test.ts +++ b/packages/core/test/tool-patch.test.ts @@ -4,7 +4,7 @@ import { describe, expect } from "bun:test" import { Effect, Exit, Layer, Schema } from "effect" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/util/effect/layer-node" -import { Environment } from "@opencode-ai/core/environment" +import { Environment } from "@opencode-ai/core/environment/index" import { FSUtil } from "@opencode-ai/util/fs-util" import { Formatter } from "@opencode-ai/core/formatter" import { FileMutation } from "@opencode-ai/core/file-mutation" diff --git a/packages/core/test/tool-read-filesystem.test.ts b/packages/core/test/tool-read-filesystem.test.ts index 624258e0ce5..5c313f9229e 100644 --- a/packages/core/test/tool-read-filesystem.test.ts +++ b/packages/core/test/tool-read-filesystem.test.ts @@ -1,7 +1,7 @@ import { describe, expect } from "bun:test" import fs from "fs/promises" import path from "path" -import { Environment } from "@opencode-ai/core/environment" +import { Environment } from "@opencode-ai/core/environment/index" import { AbsolutePath } from "@opencode-ai/core/schema" import { ReadToolFileSystem } from "@opencode-ai/core/tool/read-filesystem" import { CrossSpawnSpawner } from "@opencode-ai/util/cross-spawn-spawner" diff --git a/packages/core/test/tool-read.test.ts b/packages/core/test/tool-read.test.ts index b2d2f091b32..de490f57eb8 100644 --- a/packages/core/test/tool-read.test.ts +++ b/packages/core/test/tool-read.test.ts @@ -21,7 +21,7 @@ import { ReadTool } from "@opencode-ai/core/tool/plugin/read" import { ReadToolFileSystem } from "@opencode-ai/core/tool/read-filesystem" import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { SessionInstructions } from "@opencode-ai/core/session/instructions" -import { Environment } from "@opencode-ai/core/environment" +import { Environment } from "@opencode-ai/core/environment/index" import { testEffect } from "./lib/effect" import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool" diff --git a/packages/core/test/tool-search.test.ts b/packages/core/test/tool-search.test.ts index 03d195a0e4c..a87c45cd883 100644 --- a/packages/core/test/tool-search.test.ts +++ b/packages/core/test/tool-search.test.ts @@ -5,7 +5,7 @@ import { Effect, Layer } from "effect" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { LayerNode } from "@opencode-ai/util/effect/layer-node" -import { Environment } from "@opencode-ai/core/environment" +import { Environment } from "@opencode-ai/core/environment/index" import { FileSystem } from "@opencode-ai/core/filesystem" import { Location } from "@opencode-ai/core/location" import { LocationMutation } from "@opencode-ai/core/location-mutation" diff --git a/packages/core/test/tool-shell.test.ts b/packages/core/test/tool-shell.test.ts index ca982164814..e3170a406fa 100644 --- a/packages/core/test/tool-shell.test.ts +++ b/packages/core/test/tool-shell.test.ts @@ -12,7 +12,7 @@ import { filesystem } from "@opencode-ai/util/effect/app-node-platform" import { Database } from "@opencode-ai/core/database/database" import { Bus } from "@opencode-ai/core/bus" import { Config } from "@opencode-ai/core/config" -import { Environment } from "@opencode-ai/core/environment" +import { Environment } from "@opencode-ai/core/environment/index" import { FSUtil } from "@opencode-ai/util/fs-util" import { Global } from "@opencode-ai/util/global" import { Location } from "@opencode-ai/core/location" diff --git a/packages/core/test/tool-write.test.ts b/packages/core/test/tool-write.test.ts index d3ab2f05408..46d2ea6fab4 100644 --- a/packages/core/test/tool-write.test.ts +++ b/packages/core/test/tool-write.test.ts @@ -6,7 +6,7 @@ import { FileMutation } from "@opencode-ai/core/file-mutation" import { Formatter } from "@opencode-ai/core/formatter" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/util/effect/layer-node" -import { Environment } from "@opencode-ai/core/environment" +import { Environment } from "@opencode-ai/core/environment/index" import { Location } from "@opencode-ai/core/location" import { LocationMutation } from "@opencode-ai/core/location-mutation" import { Permission } from "@opencode-ai/core/permission" diff --git a/packages/core/test/workspace.test.ts b/packages/core/test/workspace.test.ts index f5de82fbda6..262212e9c7c 100644 --- a/packages/core/test/workspace.test.ts +++ b/packages/core/test/workspace.test.ts @@ -1,7 +1,7 @@ import { beforeEach, expect } from "bun:test" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { Database } from "@opencode-ai/core/database/database" -import { makeMemoryDriver } from "@opencode-ai/core/environment" +import { makeMemoryDriver } from "@opencode-ai/core/environment/index" import { Workspace } from "@opencode-ai/core/workspace" import { WorkspaceDriver } from "@opencode-ai/core/workspace/driver" import { WorkspaceTable } from "@opencode-ai/core/workspace/sql" diff --git a/packages/core/tsconfig.build.json b/packages/core/tsconfig.build.json new file mode 100644 index 00000000000..94801488a54 --- /dev/null +++ b/packages/core/tsconfig.build.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "./tsconfig.json", + "compilerOptions": { + "composite": false, + "incremental": false, + "outDir": "dist/types", + "tsBuildInfoFile": null + }, + "include": ["src"] +} diff --git a/packages/server/src/workspace/modal.ts b/packages/server/src/workspace/modal.ts index 3699866d350..7a8c4b87bf0 100644 --- a/packages/server/src/workspace/modal.ts +++ b/packages/server/src/workspace/modal.ts @@ -2,7 +2,7 @@ import { Effect, Sink, Stream } from "effect" import { systemError } from "effect/PlatformError" import type { Command, KillOptions } from "effect/unstable/process/ChildProcess" import { ExitCode, make, makeHandle, ProcessId } from "effect/unstable/process/ChildProcessSpawner" -import type { Driver } from "@opencode-ai/core/environment" +import type { Driver } from "@opencode-ai/core/environment/index" import type { App, Image, ModalClient, ModalClientParams, Sandbox, SandboxCreateParams } from "modal" const INNER_WRAPPER = ` diff --git a/packages/server/test/environment-modal.test.ts b/packages/server/test/environment-modal.test.ts index 621d3f5b6ad..3dae4fe53b7 100644 --- a/packages/server/test/environment-modal.test.ts +++ b/packages/server/test/environment-modal.test.ts @@ -4,8 +4,8 @@ import path from "node:path" import { afterAll, expect, test } from "bun:test" import { Effect } from "effect" import { ChildProcess } from "effect/unstable/process" -import { Failed, makeFiles } from "@opencode-ai/core/environment" -import { environmentConformance } from "@opencode-ai/core/testing/environment-conformance" +import { Failed, makeFiles } from "@opencode-ai/core/environment/index" +import { environmentConformance } from "../../core/test/lib/environment-conformance.js" import { createModalSandbox } from "../src/workspace/modal" const enabled = diff --git a/packages/server/test/workspace-modal.test.ts b/packages/server/test/workspace-modal.test.ts index 126e8a04008..cc6e54593ed 100644 --- a/packages/server/test/workspace-modal.test.ts +++ b/packages/server/test/workspace-modal.test.ts @@ -3,7 +3,7 @@ import os from "node:os" import path from "node:path" import { expect, test } from "bun:test" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" -import { makeFiles } from "@opencode-ai/core/environment" +import { makeFiles } from "@opencode-ai/core/environment/index" import { Workspace } from "@opencode-ai/core/workspace" import { WorkspaceDriver } from "@opencode-ai/core/workspace/driver" import { Effect, Layer } from "effect" diff --git a/script/publish.ts b/script/publish.ts index 3cd3599039a..48fc95c2578 100755 --- a/script/publish.ts +++ b/script/publish.ts @@ -63,6 +63,9 @@ if (Script.channel !== "beta") { console.log("\n=== plugin ===\n") await $`bun ./packages/plugin/script/publish.ts` + console.log("\n=== core ===\n") + await $`bun ./packages/core/script/publish.ts` + console.log("\n=== ui ===\n") await $`bun ./packages/ui/script/publish.ts` }