feat(core): configure package publishing

This commit is contained in:
Dax Raad
2026-08-12 09:30:15 -04:00
parent 5fd28cffc5
commit 9769e7012c
36 changed files with 1185 additions and 1082 deletions
+2 -5
View File
@@ -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",
+3
View File
@@ -0,0 +1,3 @@
# @opencode-ai/core
Core runtime services for OpenCode.
+21 -13
View File
@@ -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:"
}
}
+42
View File
@@ -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")
+51
View File
@@ -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<string, string | { import: string; types: string }>
imports: Record<string, Record<string, string>>
}
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 })
}
+1 -9
View File
@@ -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),
)
}
+1 -9
View File
@@ -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),
)
}
-3
View File
@@ -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<typeof drizzle>
export class Native extends Context.Service<Native, unknown>()("@opencode-ai/core/database/SqliteNative") {}
export class Drizzle extends Context.Service<Drizzle, DrizzleClient>()("@opencode-ai/core/database/SqliteDrizzle") {}
export interface ClientConfig {
readonly spanAttributes?: Record<string, unknown>
+1 -10
View File
@@ -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),
)
}
@@ -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<SourceMessage>
readonly parts: ReadonlyArray<SourcePart>
}
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<string, unknown>
}>
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<Warning>
}
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<typeof item> => 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<typeof item> => 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<string>()
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<Record<string, unknown>> => {
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<Status, never, Database.Service> {
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<RunResult, never, Database.Service | Global.Service> {
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<SourceMessage>(
sql`SELECT id, session_id, time_created, time_updated, data FROM message WHERE session_id = ${next.id}`,
)
const sourceParts = yield* tx.all<SourcePart>(
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<void, unknown> {
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<NextProject, []>("SELECT * FROM project")
.all()
.map((project) => [project.id, project]),
)
const sessions = source.query<NextSession, []>("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<string>) {
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<string, Array<{ row: SourcePart; value: typeof SessionV1.Part.Type }>>,
) {
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<MigrationState | undefined> {
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,
)
}
@@ -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)
}
+2 -997
View File
@@ -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<SourceMessage>
readonly parts: ReadonlyArray<SourcePart>
}
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<string, unknown>
}>
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<Warning>
}
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<typeof item> => 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<typeof item> => 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<string>()
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<Record<string, unknown>> => {
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<Status, never, Database.Service> {
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<RunResult, never, Database.Service | Global.Service> {
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<SourceMessage>(
sql`SELECT id, session_id, time_created, time_updated, data FROM message WHERE session_id = ${next.id}`,
)
const sourceParts = yield* tx.all<SourcePart>(
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<void, unknown> {
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<NextProject, []>("SELECT * FROM project")
.all()
.map((project) => [project.id, project]),
)
const sessions = source.query<NextSession, []>("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<string>) {
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<string, Array<{ row: SourcePart; value: typeof SessionV1.Part.Type }>>,
) {
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<MigrationState | undefined> {
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"
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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"
@@ -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"
+1 -1
View File
@@ -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"
@@ -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)
@@ -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 = <E>(
skip = false,
) => {
const check = <A, E2>(title: string, body: (harness: EnvironmentHarness) => Effect.Effect<A, E2>) =>
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)
+1 -1
View File
@@ -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<Record<string, Schema.Json>>
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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"
@@ -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"
+2 -2
View File
@@ -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"
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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"
@@ -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"
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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"
+11
View File
@@ -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"]
}
+1 -1
View File
@@ -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 = `
@@ -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 =
+1 -1
View File
@@ -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"
+3
View File
@@ -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`
}