diff --git a/.opencode/opencode.jsonc b/.opencode/opencode.jsonc index 0ae2fbe26b..7f07577f8c 100644 --- a/.opencode/opencode.jsonc +++ b/.opencode/opencode.jsonc @@ -2,6 +2,9 @@ "$schema": "https://opencode.ai/config.json", "provider": {}, "permission": {}, + "reference": { + "effect": "github.com/Effect-TS/effect-smol", + }, "mcp": {}, "tools": { "github-triage": false, diff --git a/packages/core/package.json b/packages/core/package.json index 60bb3bfd07..a2c7eadb3c 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -49,8 +49,10 @@ "@aws-sdk/credential-providers": "3.993.0", "@effect/opentelemetry": "catalog:", "@effect/platform-node": "catalog:", + "@effect/sql-sqlite-bun": "catalog:", "@npmcli/arborist": "9.4.0", "@npmcli/config": "10.8.1", + "@opencode-ai/effect-drizzle-sqlite": "workspace:*", "@opentelemetry/api": "1.9.0", "@opentelemetry/context-async-hooks": "2.6.1", "@opentelemetry/exporter-trace-otlp-http": "0.214.0", @@ -58,6 +60,7 @@ "@openrouter/ai-sdk-provider": "2.8.1", "ai-gateway-provider": "3.1.2", "cross-spawn": "catalog:", + "drizzle-orm": "catalog:", "effect": "catalog:", "gitlab-ai-provider": "6.7.0", "glob": "13.0.5", diff --git a/packages/core/src/database/database.ts b/packages/core/src/database/database.ts new file mode 100644 index 0000000000..36b49b65cc --- /dev/null +++ b/packages/core/src/database/database.ts @@ -0,0 +1,49 @@ +export * as Database from "./database" + +import { SqliteClient } from "@effect/sql-sqlite-bun" +import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite" +import { Context, Effect, Layer } from "effect" +import { Global } from "../global" +import { Flag } from "../flag/flag" +import path from "path" +import { DatabaseMigration } from "./migration" + +const makeDatabase = EffectDrizzleSqlite.makeWithDefaults() +type DatabaseShape = Effect.Success + +export class Service extends Context.Service()("@opencode/v2/storage/Database") {} + +const layer = Layer.effect( + Service, + Effect.gen(function* () { + const db = yield* makeDatabase + + yield* db.run("PRAGMA journal_mode = WAL") + yield* db.run("PRAGMA synchronous = NORMAL") + yield* db.run("PRAGMA busy_timeout = 5000") + yield* db.run("PRAGMA cache_size = -64000") + yield* db.run("PRAGMA foreign_keys = ON") + yield* db.run("PRAGMA wal_checkpoint(PASSIVE)") + + console.log(DatabaseMigration.ensure + + + return db + }), +) + +export function layerFromPath(filename: string) { + return layer.pipe(Layer.provide(SqliteClient.layer({ filename }))) +} + +export const defaultLayer = Layer.unwrap( + Effect.gen(function* () { + return layerFromPath( + !Flag.OPENCODE_DB + ? path.join(Global.Path.data, "opencode.db") + : Flag.OPENCODE_DB === ":memory:" || path.isAbsolute(Flag.OPENCODE_DB) + ? Flag.OPENCODE_DB + : path.join(Global.Path.data, Flag.OPENCODE_DB), + ) + }), +).pipe(Layer.provide(Global.defaultLayer)) diff --git a/packages/core/src/database/migration.ts b/packages/core/src/database/migration.ts new file mode 100644 index 0000000000..e9575c865a --- /dev/null +++ b/packages/core/src/database/migration.ts @@ -0,0 +1,248 @@ +export * as DatabaseMigration from "./migration" + +import type { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite" +import { Effect } from "effect" +import { getTableName, sql, type SQL, type Table } from "drizzle-orm" +import { getTableConfig, type AnySQLiteTable, type Index, type SQLiteColumn } from "drizzle-orm/sqlite-core" + +export type SchemaAst = { + tables: Record +} + +export type TableAst = { + name: string + columns: Record + indexes: Record +} + +export type ColumnAst = { + name: string + type: string + notNull: boolean + primaryKey: boolean + default?: string +} + +export type IndexAst = { + name: string + table: string + columns: IndexColumnAst[] + unique: boolean + where?: string +} + +export type IndexColumnAst = { type: "column"; name: string } | { type: "expression"; sql: string } + +export type Operation = + | { type: "create_table"; table: TableAst } + | { type: "add_column"; table: string; column: ColumnAst } + | { type: "create_index"; index: IndexAst } + +export function diff(db: EffectDrizzleSqlite.EffectSQLiteDatabase, tables: Table[]) { + return read(db).pipe(Effect.map((actual) => diffSchema(actual, fromTables(tables)))) +} + +export function apply(db: EffectDrizzleSqlite.EffectSQLiteDatabase, operations: Operation[]) { + return Effect.forEach(operations, (operation) => db.run(toSql(operation))).pipe(Effect.asVoid) +} + +function fromTables(tables: Table[]): SchemaAst { + return { + tables: Object.fromEntries(tables.map((table) => { + const config = getTableConfig(table as AnySQLiteTable) + const name = getTableName(table) + return [name, tableFromConfig(name, config.columns, config.indexes)] + })), + } +} + +function diffSchema(actual: SchemaAst, desired: SchemaAst): Operation[] { + return Object.values(desired.tables).flatMap((table) => { + const current = actual.tables[table.name] + if (!current) { + return [createTableOperation(table), ...Object.values(table.indexes).map(createIndexOperation)] + } + return [ + ...Object.values(table.columns) + .filter((column) => current.columns[column.name] === undefined) + .map((column) => addColumnOperation(table.name, column)), + ...Object.values(table.indexes) + .filter((index) => current.indexes[index.name] === undefined) + .map(createIndexOperation), + ] + }) +} + +function createTableOperation(table: TableAst): Operation { + return { type: "create_table", table } +} + +function addColumnOperation(table: string, column: ColumnAst): Operation { + return { type: "add_column", table, column } +} + +function createIndexOperation(index: IndexAst): Operation { + return { type: "create_index", index } +} + +function toSql(operation: Operation) { + if (operation.type === "create_table") { + return `CREATE TABLE ${quoteIdentifier(operation.table.name)} (${Object.values(operation.table.columns) + .map((column) => columnSql(column, true)) + .join(", ")})` + } + if (operation.type === "add_column") { + return `ALTER TABLE ${quoteIdentifier(operation.table)} ADD COLUMN ${columnSql(operation.column, false)}` + } + return [ + "CREATE", + operation.index.unique ? "UNIQUE" : undefined, + "INDEX", + quoteIdentifier(operation.index.name), + "ON", + quoteIdentifier(operation.index.table), + `(${operation.index.columns.map(indexColumnSql).join(", ")})`, + operation.index.where === undefined ? undefined : `WHERE ${operation.index.where}`, + ] + .filter((part) => part !== undefined) + .join(" ") +} + +function read(db: EffectDrizzleSqlite.EffectSQLiteDatabase) { + return Effect.gen(function* () { + const rows = yield* db.all<{ name: string }>(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'`) + const tables = yield* Effect.forEach(rows, (row) => readTable(db, row.name)) + return { tables: Object.fromEntries(tables.map((table) => [table.name, table])) } + }) +} + +function readTable(db: EffectDrizzleSqlite.EffectSQLiteDatabase, name: string) { + return Effect.gen(function* () { + const columns = yield* db.all<{ + name: string + type: string + notnull: number + pk: number + dflt_value: string | null + }>(`PRAGMA table_info(${quoteIdentifier(name)})`) + const indexes = yield* db.all<{ name: string; unique: number }>(`PRAGMA index_list(${quoteIdentifier(name)})`) + const indexEntries = yield* Effect.forEach(indexes, (index) => + Effect.gen(function* () { + const statement = yield* db.get<{ sql: string | null }>(sql`SELECT sql FROM sqlite_master WHERE type = 'index' AND name = ${index.name}`) + if (statement?.sql === null || statement?.sql === undefined) return undefined + const columns = yield* db.all<{ seqno: number; name: string | null }>(`PRAGMA index_info(${quoteIdentifier(index.name)})`) + return [ + index.name, + { + name: index.name, + table: name, + columns: columns.map((column) => + column.name === null + ? ({ type: "expression", sql: "" } as const) + : ({ type: "column", name: column.name } as const), + ), + unique: index.unique === 1, + }, + ] as const + }), + ) + return { + name, + columns: Object.fromEntries(columns.map((column) => [ + column.name, + { + name: column.name, + type: column.type, + notNull: column.notnull === 1, + primaryKey: column.pk > 0, + ...(column.dflt_value === null ? {} : { default: column.dflt_value }), + }, + ])), + indexes: Object.fromEntries(indexEntries.filter((entry) => entry !== undefined)), + } + }) +} + +function tableFromConfig(name: string, columns: SQLiteColumn[], indexes: Index[]): TableAst { + return { + name, + columns: Object.fromEntries(columns.map((column) => [column.name, columnFromConfig(column)])), + indexes: Object.fromEntries(indexes.map((index) => [index.config.name, indexFromConfig(index)])), + } +} + +function columnFromConfig(column: SQLiteColumn): ColumnAst { + return { + name: column.name, + type: column.getSQLType(), + notNull: column.notNull, + primaryKey: column.primary, + ...defaultFromColumn(column), + } +} + +function defaultFromColumn(column: SQLiteColumn) { + if (column.default !== undefined) return { default: literal(column.default) } + if (column.defaultFn !== undefined) return { default: literal(column.defaultFn()) } + return {} +} + +function indexFromConfig(index: Index): IndexAst { + return { + name: index.config.name, + table: getTableName(index.config.table), + columns: index.config.columns.map(indexColumnName), + unique: index.config.unique, + ...(index.config.where === undefined ? {} : { where: compileSql(index.config.where) }), + } +} + +function indexColumnName(column: SQLiteColumn | SQL) { + if ("name" in column) return { type: "column", name: column.name } as const + return { type: "expression", sql: compileSql(column) } as const +} + +function compileSql(value: SQL) { + return value.getSQL().toQuery(new SQLiteCompiler()).sql.replace(/"(?:""|[^"])*"\./g, "") +} + +function indexColumnSql(column: IndexColumnAst) { + if (column.type === "column") return quoteIdentifier(column.name) + return column.sql +} + +function columnSql(column: ColumnAst, includePrimaryKey: boolean) { + return [ + quoteIdentifier(column.name), + column.type, + includePrimaryKey && column.primaryKey ? "PRIMARY KEY" : undefined, + column.notNull ? "NOT NULL" : undefined, + column.default === undefined ? undefined : `DEFAULT ${column.default}`, + ] + .filter((part) => part !== undefined) + .join(" ") +} + +class SQLiteCompiler { + inlineParams = true + escapeName = (name: string) => { + return quoteIdentifier(name) + } + escapeParam = () => { + return "?" + } + escapeString = (value: string) => { + return `'${value.replaceAll("'", "''")}'` + } +} + +function literal(value: unknown) { + if (typeof value === "number") return String(value) + if (typeof value === "boolean") return value ? "1" : "0" + if (value === null) return "NULL" + return `'${String(value).replaceAll("'", "''")}'` +} + +function quoteIdentifier(value: string) { + return `"${value.replaceAll('"', '""')}"` +} diff --git a/packages/core/src/location.ts b/packages/core/src/location.ts index 00ff9cd3ea..78409a783e 100644 --- a/packages/core/src/location.ts +++ b/packages/core/src/location.ts @@ -1,9 +1,10 @@ import { Context, Schema } from "effect" +import { AbsolutePath } from "./schema" export * as Location from "./location" export const Ref = Schema.Struct({ - directory: Schema.String, + directory: AbsolutePath, workspaceID: Schema.optional(Schema.String), }).annotate({ identifier: "Location.Ref" }) export type Ref = typeof Ref.Type diff --git a/packages/core/src/plugin/account.ts b/packages/core/src/plugin/account.ts index d4d00c3ab6..10ccf047ee 100644 --- a/packages/core/src/plugin/account.ts +++ b/packages/core/src/plugin/account.ts @@ -3,6 +3,8 @@ import { AccountV2 } from "../account" import { EventV2 } from "../event" import { PluginV2 } from "../plugin" +// Depending on what account is active, enable matching providers for that +// service export const AccountPlugin = PluginV2.define({ id: PluginV2.ID.make("account"), effect: Effect.gen(function* () { diff --git a/packages/core/src/plugin/models-dev.ts b/packages/core/src/plugin/models-dev.ts index bde162d729..f54d6a44df 100644 --- a/packages/core/src/plugin/models-dev.ts +++ b/packages/core/src/plugin/models-dev.ts @@ -56,7 +56,6 @@ export const ModelsDevPlugin = PluginV2.define({ const catalog = yield* Catalog.Service const modelsDev = yield* ModelsDev.Service const events = yield* EventV2.Service - const scope = yield* Scope.Scope const load = yield* catalog.loader() const refresh = Effect.fn("ModelsDevPlugin.refresh")(function* () { const data = yield* modelsDev.get() @@ -114,7 +113,7 @@ export const ModelsDevPlugin = PluginV2.define({ yield* refresh() yield* events.subscribe(ModelsDev.Event.Refreshed).pipe( Stream.runForEach(() => refresh()), - Effect.forkIn(scope, { startImmediately: true }), + Effect.forkScoped({ startImmediately: true }), ) }).pipe(Effect.provide(ModelsDev.defaultLayer)), }) diff --git a/packages/core/src/project.ts b/packages/core/src/project.ts new file mode 100644 index 0000000000..a9d54f9ea7 --- /dev/null +++ b/packages/core/src/project.ts @@ -0,0 +1,130 @@ +export * as Project from "./project" + +import path from "path" +import { Context, Effect, Layer, Schema } from "effect" +import { ChildProcess } from "effect/unstable/process" +import { AppFileSystem } from "./filesystem" +import { AppProcess } from "./process" +import { AbsolutePath, withStatics } from "./schema" +import type { Location } from "./location" + +export const ID = Schema.String.pipe( + Schema.brand("AccountV2.ID"), + withStatics((schema) => ({ + global: schema.make("global"), + })), +) +export type ID = typeof ID.Type + +export interface Interface { + readonly create: (input: AbsolutePath) => Promise + readonly locations: (projectID: ID) => Promise + // opencode -> ["~/dev/projects/anomalyco/opencode", "~/.gitworktrees/anomalyci/opencode"] + // global -> ["~/.config/nvim", "/etc/nixos"] + + readonly resolve: (input: AbsolutePath) => Promise + // ~/dev/projects/anomalyco/opencode -> opencode + // ~/dev/projects/anomalyco/opencode/packages/core -> opencode + // ~/.gitworktrees/anomalyci/opencode -> opencode + // ~/.config/nvim -> global +} + +export class Service extends Context.Service()("@opencode/Project") {} + +interface GitResult { + readonly exitCode: number + readonly text: () => string +} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const proc = yield* AppProcess.Service + + const runGit = Effect.fn("Project.git")( + function* (args: string[], cwd: string) { + const result = yield* proc.run( + ChildProcess.make("git", args, { + cwd, + extendEnv: true, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }), + ) + return { + exitCode: result.exitCode, + text: () => result.stdout.toString("utf8"), + } satisfies GitResult + }, + Effect.catch(() => + Effect.succeed({ + exitCode: 1, + text: () => "", + } satisfies GitResult), + ), + ) + + const resolveGitPath = (cwd: string, value: string) => { + const trimmed = value.replace(/[\r\n]+$/, "") + if (!trimmed) return cwd + const normalized = AppFileSystem.windowsPath(trimmed) + if (path.isAbsolute(normalized)) return path.normalize(normalized) + return path.resolve(cwd, normalized) + } + + const readCachedProjectId = Effect.fnUntraced(function* (dir: string) { + return yield* fs.readFileString(path.join(dir, "opencode")).pipe( + Effect.map((x) => x.trim()), + Effect.map((x) => ID.make(x)), + Effect.catch(() => Effect.void), + ) + }) + + const resolve = async (input: AbsolutePath) => + Effect.runPromise( + Effect.gen(function* () { + const repoPath = yield* fs.up({ targets: [".git"], start: input }).pipe( + Effect.map((matches) => matches[0]), + Effect.catch(() => Effect.void), + ) + if (!repoPath) return ID.global + + const cwd = path.dirname(repoPath) + const parsed = yield* runGit(["rev-parse", "--git-dir", "--git-common-dir"], cwd) + if (parsed.exitCode !== 0) return (yield* readCachedProjectId(repoPath)) ?? ID.global + + const gitPaths = parsed + .text() + .split(/\r?\n/) + .map((item) => item.trim()) + .filter(Boolean) + const commonDir = gitPaths[1] ? resolveGitPath(cwd, gitPaths[1]) : undefined + if (!commonDir) return (yield* readCachedProjectId(repoPath)) ?? ID.global + + const cached = (yield* readCachedProjectId(repoPath)) ?? (yield* readCachedProjectId(commonDir)) + if (cached) return cached + + const id = (yield* runGit(["rev-list", "--max-parents=0", "HEAD"], cwd)) + .text() + .split("\n") + .map((item) => item.trim()) + .filter(Boolean) + .toSorted()[0] + + if (!id) return ID.global + yield* fs.writeFileString(path.join(commonDir, "opencode"), id).pipe(Effect.ignore) + return ID.make(id) + }), + ) + + return Service.of({ + create: async () => { + throw new Error("Project.create is not implemented") + }, + locations: async () => [], + resolve, + }) + }), +) diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts index 5b4042c736..b5cee90a57 100644 --- a/packages/core/src/schema.ts +++ b/packages/core/src/schema.ts @@ -10,6 +10,18 @@ export const PositiveInt = Schema.Int.check(Schema.isGreaterThan(0)) */ export const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)) +/** + * Relative file path (e.g., `src/components/Button.tsx`). + */ +export const RelativePath = Schema.String.pipe(Schema.brand("RelativePath")) +export type RelativePath = Schema.Schema.Type + +/** + * Absolute file path (e.g., `/home/user/projects/myapp/src/main.ts`). + */ +export const AbsolutePath = Schema.String.pipe(Schema.brand("AbsolutePath")) +export type AbsolutePath = Schema.Schema.Type + /** * Optional public JSON field that can hold explicit `undefined` on the type * side but encodes it as an omitted key, matching legacy `JSON.stringify`. diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts deleted file mode 100644 index 756531e328..0000000000 --- a/packages/core/src/session.ts +++ /dev/null @@ -1,13 +0,0 @@ -export * as Session from "./session" - -import { Schema } from "effect" -import { withStatics } from "./schema" -import { Identifier } from "./util/identifier" - -export const ID = Schema.String.check(Schema.isStartsWith("ses")).pipe( - Schema.brand("SessionID"), - withStatics((schema) => ({ - descending: (id?: string) => schema.make(id ?? "ses_" + Identifier.descending()), - })), -) -export type ID = typeof ID.Type diff --git a/packages/core/src/session-event.ts b/packages/core/src/session/event.ts similarity index 96% rename from packages/core/src/session-event.ts rename to packages/core/src/session/event.ts index a98d9cc051..2d5f431053 100644 --- a/packages/core/src/session-event.ts +++ b/packages/core/src/session/event.ts @@ -1,11 +1,11 @@ import { Schema } from "effect" -import { EventV2 } from "./event" -import { ModelV2 } from "./model" -import { NonNegativeInt } from "./schema" -import { Session } from "./session" -import { FileAttachment, Prompt } from "./session-prompt" -import { ToolOutput } from "./tool-output" -import { V2Schema } from "./v2-schema" +import { EventV2 } from "../event" +import { ModelV2 } from "../model" +import { NonNegativeInt } from "../schema" +import { ToolOutput } from "../tool-output" +import { V2Schema } from "../v2-schema" +import { Session } from "./index" +import { FileAttachment, Prompt } from "./prompt" export { FileAttachment } @@ -399,4 +399,4 @@ export const All = Schema.Union( export type Event = typeof All.Type export type Type = Event["type"] -export * as SessionEvent from "./session-event" +export * as SessionEvent from "./event" diff --git a/packages/core/src/session/index.ts b/packages/core/src/session/index.ts new file mode 100644 index 0000000000..3627423552 --- /dev/null +++ b/packages/core/src/session/index.ts @@ -0,0 +1,127 @@ +export * as Session from "." + +import { Effect, Schema } from "effect" +import { AbsolutePath, RelativePath, withStatics } from "../schema" +import { Identifier } from "../util/identifier" +import { Project } from "../project" +import { Workspace } from "../workspace" +import type { ModelV2 } from "../model" +import { Location } from "../location" +import type { SessionMessage } from "./message" +import type { Prompt } from "./prompt" +import type { EventV2 } from "../event" + +export const Delivery = Schema.Literals(["immediate", "deferred"]).annotate({ + identifier: "Session.Delivery", +}) +export type Delivery = Schema.Schema.Type + +export const ID = Schema.String.check(Schema.isStartsWith("ses")).pipe( + Schema.brand("SessionID"), + withStatics((schema) => ({ + descending: (id?: string) => schema.make(id ?? "ses_" + Identifier.descending()), + })), +) +export type ID = typeof ID.Type + +export const Info = Schema.Struct({ + id: ID, + location: Location.Ref, + subpath: RelativePath, // derived from location + project: Project.ID, // derived from location +}) +export type Info = typeof Info.Type + +// get project -> project.locations +// +// get all sessions +// + +// - by project +// - by subpath +// - by workspace (home is special) + +type Cursor = {} + +type ListInput = { + workspaceID?: Workspace.ID + search?: string + cursor?: Cursor + limit?: number + order?: "asc" | "desc" +} & ( + | { + project: Project.ID + subpath?: RelativePath + } + | { + directory?: AbsolutePath + } +) + +type CreateInput = { + id?: ID + agent?: string + model?: ModelV2.Ref + location: Location.Ref +} + +type MoveInput = { + sessionID: ID + location: Location.Ref +} + +type CompactInput = { + sessionID: ID + prompt?: Prompt +} + +export class NotFoundError extends Schema.TaggedErrorClass()("Session.NotFoundError", { + sessionID: ID, +}) {} + +export type Error = NotFoundError + +export interface Interface { + readonly list: (input?: ListInput) => Effect.Effect + readonly create: (input?: CreateInput) => Effect.Effect + readonly move: (input: MoveInput) => Effect.Effect + readonly get: (sessionID: ID) => Effect.Effect + readonly messages: (input: { + sessionID: ID + limit?: number + order?: "asc" | "desc" + cursor?: { + id: SessionMessage.ID + time: number + direction: "previous" | "next" + } + }) => Effect.Effect + readonly context: (sessionID: ID) => Effect.Effect + readonly switchAgent: (input: { sessionID: ID; agent: string }) => Effect.Effect + readonly switchModel: (input: { sessionID: ID; model: ModelV2.Ref }) => Effect.Effect + readonly prompt: (input: { + id?: EventV2.ID + sessionID: ID + prompt: Prompt + delivery?: Delivery + resume?: boolean + }) => Effect.Effect + readonly shell: (input: { + id?: EventV2.ID + sessionID: ID + command: string + delivery?: Delivery + resume?: boolean + }) => Effect.Effect + readonly skill: (input: { + id?: EventV2.ID + sessionID: ID + skill: string + delivery?: Delivery + resume?: boolean + }) => Effect.Effect + readonly compact: (input: CompactInput) => Effect.Effect + readonly wait: (id: ID) => Effect.Effect + readonly resume: (sessionID: ID) => Effect.Effect +} diff --git a/packages/core/src/session-message-updater.ts b/packages/core/src/session/message-updater.ts similarity index 98% rename from packages/core/src/session-message-updater.ts rename to packages/core/src/session/message-updater.ts index bbdf59c555..fa5fcc3a4a 100644 --- a/packages/core/src/session-message-updater.ts +++ b/packages/core/src/session/message-updater.ts @@ -1,6 +1,6 @@ import { produce, type WritableDraft } from "immer" -import { SessionEvent } from "./session-event" -import { SessionMessage } from "./session-message" +import { SessionEvent } from "./event" +import { SessionMessage } from "./message" export type MemoryState = { messages: SessionMessage.Message[] @@ -414,4 +414,4 @@ export function update(adapter: Adapter, event: SessionEvent.Eve return adapter.finish() } -export * as SessionMessageUpdater from "./session-message-updater" +export * as SessionMessageUpdater from "./message-updater" diff --git a/packages/core/src/session-message.ts b/packages/core/src/session/message.ts similarity index 95% rename from packages/core/src/session-message.ts rename to packages/core/src/session/message.ts index 73b6dd7da2..305202b0a4 100644 --- a/packages/core/src/session-message.ts +++ b/packages/core/src/session/message.ts @@ -1,10 +1,10 @@ import { Schema } from "effect" -import { Prompt } from "./session-prompt" -import { SessionEvent } from "./session-event" -import { EventV2 } from "./event" -import { ToolOutput } from "./tool-output" -import { V2Schema } from "./v2-schema" -import { ModelV2 } from "./model" +import { EventV2 } from "../event" +import { ModelV2 } from "../model" +import { ToolOutput } from "../tool-output" +import { V2Schema } from "../v2-schema" +import { SessionEvent } from "./event" +import { Prompt } from "./prompt" export const ID = EventV2.ID export type ID = Schema.Schema.Type @@ -170,4 +170,4 @@ export type Message = Schema.Schema.Type export type Type = Message["type"] -export * as SessionMessage from "./session-message" +export * as SessionMessage from "./message" diff --git a/packages/core/src/session-prompt.ts b/packages/core/src/session/prompt.ts similarity index 100% rename from packages/core/src/session-prompt.ts rename to packages/core/src/session/prompt.ts diff --git a/packages/core/src/session/sql.ts b/packages/core/src/session/sql.ts new file mode 100644 index 0000000000..310ffde0f5 --- /dev/null +++ b/packages/core/src/session/sql.ts @@ -0,0 +1,49 @@ +import { index, integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core" +import { Session } from "." + +export const SessionTable = sqliteTable( + "session", + { + id: text().$type().primaryKey(), + project_id: text().notNull(), + workspace_id: text(), + parent_id: text().$type(), + slug: text().notNull(), + directory: text().notNull(), + path: text(), + title: text().notNull(), + version: text().notNull(), + share_url: text(), + summary_additions: integer(), + summary_deletions: integer(), + summary_files: integer(), + summary_diffs: text({ mode: "json" }), + cost: real().notNull().default(0), + tokens_input: integer().notNull().default(0), + tokens_output: integer().notNull().default(0), + tokens_reasoning: integer().notNull().default(0), + tokens_cache_read: integer().notNull().default(0), + tokens_cache_write: integer().notNull().default(0), + revert: text({ mode: "json" }), + permission: text({ mode: "json" }), + agent: text(), + model: text({ mode: "json" }).$type<{ + id: string + providerID: string + variant?: string + }>(), + time_created: integer() + .notNull() + .$default(() => Date.now()), + time_updated: integer() + .notNull() + .$onUpdate(() => Date.now()), + time_compacting: integer(), + time_archived: integer(), + }, + (table) => [ + index("session_project_idx").on(table.project_id), + index("session_workspace_idx").on(table.workspace_id), + index("session_parent_idx").on(table.parent_id), + ], +) diff --git a/packages/core/src/workspace.ts b/packages/core/src/workspace.ts new file mode 100644 index 0000000000..a890780a16 --- /dev/null +++ b/packages/core/src/workspace.ts @@ -0,0 +1,11 @@ +export * as Workspace from "./workspace" + +import { Schema } from "effect" +import { withStatics } from "./schema" +import { Identifier } from "./util/identifier" + +export const ID = Schema.String.pipe( + Schema.brand("AccountV2.ID"), + withStatics((schema) => ({ create: () => schema.make("wrk_" + Identifier.ascending()) })), +) +export type ID = typeof ID.Type diff --git a/packages/core/test/database-migration.test.ts b/packages/core/test/database-migration.test.ts new file mode 100644 index 0000000000..d5a3de6620 --- /dev/null +++ b/packages/core/test/database-migration.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, test } from "bun:test" +import { DatabaseMigration } from "@opencode-ai/core/database/migration" + +const rand = (seed: number) => () => { + seed = (seed * 1664525 + 1013904223) >>> 0 + return seed / 0x100000000 +} + +describe("DatabaseMigration", () => { + test("diff creates missing tables before indexes", () => { + const table = makeTable( + "session", + [makeColumn("id", { primaryKey: true })], + [makeIndex("session_id_idx", "session", ["id"])], + ) + + expect(DatabaseMigration.diff(emptySchema(), schema(table))).toEqual([ + { type: "create_table", table }, + { type: "create_index", index: table.indexes.session_id_idx }, + ]) + }) + + test("diff adds missing columns and indexes without recreating existing tables", () => { + const actual = makeTable("session", [makeColumn("id", { primaryKey: true })], []) + const desired = makeTable( + "session", + [makeColumn("id", { primaryKey: true }), makeColumn("title", { notNull: true })], + [makeIndex("session_title_idx", "session", ["title"])], + ) + + expect(DatabaseMigration.diff(schema(actual), schema(desired))).toEqual([ + { type: "add_column", table: "session", column: desired.columns.title }, + { type: "create_index", index: desired.indexes.session_title_idx }, + ]) + }) + + test("diff is empty when actual already satisfies desired", () => { + const table = makeTable( + "session", + [makeColumn("id", { primaryKey: true }), makeColumn("title")], + [makeIndex("session_title_idx", "session", ["title"])], + ) + + expect(DatabaseMigration.diff(schema(table), schema(table))).toEqual([]) + }) + + test("random desired schemas generate exactly missing additive operations", () => { + for (let seed = 1; seed <= 200; seed++) { + const random = rand(seed) + const desiredTables = Array.from({ length: 1 + Math.floor(random() * 5) }, (_, i) => + makeRandomTable(random, `table_${i}`), + ) + const actualTables = desiredTables + .filter(() => random() > 0.25) + .map((table) => + makeTable( + table.name, + Object.values(table.columns).filter((column) => column.primaryKey || random() > 0.35), + Object.values(table.indexes).filter(() => random() > 0.5), + ), + ) + const operations = DatabaseMigration.diff(schema(...actualTables), schema(...desiredTables)) + const expected = desiredTables.flatMap((table) => { + const actual = actualTables.find((item) => item.name === table.name) + if (!actual) { + return [createTableOperation(table), ...Object.values(table.indexes).map(createIndexOperation)] + } + return [ + ...Object.values(table.columns) + .filter((column) => actual.columns[column.name] === undefined) + .map((column) => addColumnOperation(table.name, column)), + ...Object.values(table.indexes) + .filter((index) => actual.indexes[index.name] === undefined) + .map(createIndexOperation), + ] + }) + + expect(operations).toEqual(expected) + } + }) + + test("random operations render quoted SQL", () => { + for (let seed = 1; seed <= 200; seed++) { + const random = rand(seed) + const table = makeRandomTable(random, `table_"${seed}`) + const operations = DatabaseMigration.diff(emptySchema(), schema(table)) + + for (const operation of operations) { + const rendered = DatabaseMigration.toSql(operation) + expect(rendered).not.toContain("undefined") + expect(rendered).toContain('"') + } + } + }) +}) + +function emptySchema(): DatabaseMigration.SchemaAst { + return { tables: {} } +} + +function schema(...tables: DatabaseMigration.TableAst[]): DatabaseMigration.SchemaAst { + return { tables: Object.fromEntries(tables.map((table) => [table.name, table])) } +} + +function makeTable( + name: string, + columns: DatabaseMigration.ColumnAst[], + indexes: DatabaseMigration.IndexAst[], +): DatabaseMigration.TableAst { + return { + name, + columns: Object.fromEntries(columns.map((column) => [column.name, column])), + indexes: Object.fromEntries(indexes.map((index) => [index.name, index])), + } +} + +function createTableOperation(table: DatabaseMigration.TableAst): DatabaseMigration.Operation { + return { type: "create_table", table } +} + +function addColumnOperation(table: string, column: DatabaseMigration.ColumnAst): DatabaseMigration.Operation { + return { type: "add_column", table, column } +} + +function createIndexOperation(index: DatabaseMigration.IndexAst): DatabaseMigration.Operation { + return { type: "create_index", index } +} + +function makeColumn( + name: string, + options: Partial> = {}, +): DatabaseMigration.ColumnAst { + return { + name, + type: "text", + notNull: options.notNull ?? false, + primaryKey: options.primaryKey ?? false, + ...(options.default === undefined ? {} : { default: options.default }), + } +} + +function makeIndex(name: string, table: string, columns: string[], unique = false): DatabaseMigration.IndexAst { + return { name, table, columns, unique } +} + +function makeRandomTable(random: () => number, name: string) { + const columns = Array.from({ length: 1 + Math.floor(random() * 8) }, (_, i) => + makeColumn(`column_${i}`, { + primaryKey: i === 0, + notNull: i === 0 || random() > 0.5, + default: random() > 0.7 ? String(Math.floor(random() * 100)) : undefined, + }), + ) + const indexes = columns + .filter((column) => !column.primaryKey && random() > 0.5) + .map((column) => makeIndex(`${name}_${column.name}_idx`, name, [column.name], random() > 0.75)) + return makeTable(name, columns, indexes) +} diff --git a/packages/opencode/src/event-v2-bridge.ts b/packages/opencode/src/event-v2-bridge.ts index 4c6c79a707..ff3ede4750 100644 --- a/packages/opencode/src/event-v2-bridge.ts +++ b/packages/opencode/src/event-v2-bridge.ts @@ -9,7 +9,7 @@ import { SyncEvent } from "@/sync" import { EventV2 } from "@opencode-ai/core/event" import "@opencode-ai/core/account" import "@opencode-ai/core/catalog" -import "@opencode-ai/core/session-event" +import "@opencode-ai/core/session/event" import { Context, Effect, Layer, Option } from "effect" export function toSyncDefinition(definition: D) { diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/message.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/message.ts index 794a749632..be2fdb5ba4 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/message.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/message.ts @@ -1,5 +1,5 @@ import { SessionID } from "@/session/schema" -import { SessionMessage } from "@opencode-ai/core/session-message" +import { SessionMessage } from "@opencode-ai/core/session/message" import { Schema } from "effect" import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { InvalidCursorError, SessionNotFoundError, UnknownError } from "../../errors" diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts index c1a07957db..0389fdf258 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts @@ -1,6 +1,6 @@ import { SessionID } from "@/session/schema" -import { SessionMessage } from "@opencode-ai/core/session-message" -import { Prompt } from "@opencode-ai/core/session-prompt" +import { SessionMessage } from "@opencode-ai/core/session/message" +import { Prompt } from "@opencode-ai/core/session/prompt" import { SessionV2 } from "@/v2/session" import { Schema } from "effect" import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/message.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/message.ts index 0d9273d8cd..9150ac43e1 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/message.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/message.ts @@ -1,4 +1,4 @@ -import { SessionMessage } from "@opencode-ai/core/session-message" +import { SessionMessage } from "@opencode-ai/core/session/message" import { SessionV2 } from "@/v2/session" import { Effect, Schema } from "effect" import * as DateTime from "effect/DateTime" diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index ef007fe74d..b0a762a5a8 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -19,7 +19,7 @@ import { isOverflow as overflow, usable } from "./overflow" import { serviceUse } from "@/effect/service-use" import { RuntimeFlags } from "@/effect/runtime-flags" import { EventV2Bridge } from "@/event-v2-bridge" -import { SessionEvent } from "@opencode-ai/core/session-event" +import { SessionEvent } from "@opencode-ai/core/session/event" const log = Log.create({ service: "session.compaction" }) diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index a287c3b006..b4e6ad4232 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -22,7 +22,7 @@ import { errorMessage } from "@/util/error" import * as Log from "@opencode-ai/core/util/log" import { isRecord } from "@/util/record" import { EventV2Bridge } from "@/event-v2-bridge" -import { SessionEvent } from "@opencode-ai/core/session-event" +import { SessionEvent } from "@opencode-ai/core/session/event" import { ModelV2 } from "@opencode-ai/core/model" import { ProviderV2 } from "@opencode-ai/core/provider" import * as DateTime from "effect/DateTime" diff --git a/packages/opencode/src/session/projectors-next.ts b/packages/opencode/src/session/projectors-next.ts index ae5b9c5d2f..d7b2d14824 100644 --- a/packages/opencode/src/session/projectors-next.ts +++ b/packages/opencode/src/session/projectors-next.ts @@ -1,8 +1,8 @@ import { and, desc, eq } from "@/storage/db" import type { Database } from "@/storage/db" -import { SessionMessage } from "@opencode-ai/core/session-message" -import { SessionMessageUpdater } from "@opencode-ai/core/session-message-updater" -import { SessionEvent } from "@opencode-ai/core/session-event" +import { SessionMessage } from "@opencode-ai/core/session/message" +import { SessionMessageUpdater } from "@opencode-ai/core/session/message-updater" +import { SessionEvent } from "@opencode-ai/core/session/event" import * as DateTime from "effect/DateTime" import { SyncEvent } from "@/sync" import { EventV2Bridge } from "@/event-v2-bridge" diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index fc9fa0b96a..a2d19e146d 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -48,10 +48,10 @@ import { TaskTool, type TaskPromptOps } from "@/tool/task" import { SessionRunState } from "./run-state" import { RuntimeFlags } from "@/effect/runtime-flags" import { EventV2Bridge } from "@/event-v2-bridge" -import { SessionEvent } from "@opencode-ai/core/session-event" +import { SessionEvent } from "@opencode-ai/core/session/event" import { ModelV2 } from "@opencode-ai/core/model" import { ProviderV2 } from "@opencode-ai/core/provider" -import { AgentAttachment, FileAttachment, ReferenceAttachment, Source } from "@opencode-ai/core/session-prompt" +import { AgentAttachment, FileAttachment, ReferenceAttachment, Source } from "@opencode-ai/core/session/prompt" import { Reference } from "@/reference/reference" import * as DateTime from "effect/DateTime" import { eq } from "@/storage/db" diff --git a/packages/opencode/src/session/schema.ts b/packages/opencode/src/session/schema.ts index f1622b6958..aa3a1e28d9 100644 --- a/packages/opencode/src/session/schema.ts +++ b/packages/opencode/src/session/schema.ts @@ -1,7 +1,7 @@ import { Schema } from "effect" import { Identifier } from "@/id/id" -import { Session as CoreSession } from "@opencode-ai/core/session" +import { Session as CoreSession } from "@opencode-ai/core/session/index" import { withStatics } from "@opencode-ai/core/schema" export const SessionID = CoreSession.ID diff --git a/packages/opencode/src/session/session.sql.ts b/packages/opencode/src/session/session.sql.ts index 610ca72c46..b1f40dcf1d 100644 --- a/packages/opencode/src/session/session.sql.ts +++ b/packages/opencode/src/session/session.sql.ts @@ -1,7 +1,7 @@ import { sqliteTable, text, integer, index, primaryKey, real } from "drizzle-orm/sqlite-core" import { ProjectTable } from "../project/project.sql" import type { MessageV2 } from "./message-v2" -import type { SessionMessage } from "@opencode-ai/core/session-message" +import type { SessionMessage } from "@opencode-ai/core/session/message" import type { Snapshot } from "../snapshot" import type { Permission } from "../permission" import type { ProjectID } from "../project/schema" diff --git a/packages/opencode/src/v2/session.ts b/packages/opencode/src/v2/session.ts index 5e477cc8a3..8d8e2d0a30 100644 --- a/packages/opencode/src/v2/session.ts +++ b/packages/opencode/src/v2/session.ts @@ -3,11 +3,8 @@ import { SessionID } from "@/session/schema" import { WorkspaceID } from "@/control-plane/schema" import { and, asc, desc, eq, gt, gte, isNull, like, lt, or, type SQL } from "@/storage/db" import * as Database from "@/storage/db" -import { Context, DateTime, Effect, Layer, Schema } from "effect" -import { SessionMessage } from "@opencode-ai/core/session-message" -import type { Prompt } from "@opencode-ai/core/session-prompt" import { ProjectID } from "@/project/schema" -import { SessionEvent } from "@opencode-ai/core/session-event" +import { SessionEvent } from "@opencode-ai/core/session/event" import { V2Schema } from "@opencode-ai/core/v2-schema" import { optionalOmitUndefined } from "@opencode-ai/core/schema" import { EventV2 } from "@opencode-ai/core/event" @@ -88,9 +85,9 @@ export interface Interface { readonly list: (input: { limit?: number order?: "asc" | "desc" - directory?: string - path?: string + projectID?: ProjectID workspaceID?: WorkspaceID + path?: string roots?: boolean start?: number search?: string @@ -113,24 +110,18 @@ export interface Interface { readonly context: ( sessionID: SessionID, ) => Effect.Effect - readonly prompt: (input: { - id?: EventV2.ID - sessionID: SessionID - prompt: Prompt - delivery?: Delivery - }) => Effect.Effect - readonly shell: (input: { id?: EventV2.ID; sessionID: SessionID; command: string }) => Effect.Effect - readonly skill: (input: { id?: EventV2.ID; sessionID: SessionID; skill: string }) => Effect.Effect readonly subagent: (input: { id?: EventV2.ID parentID: SessionID prompt: Prompt agent: string model?: ModelV2.Ref + resume?: boolean }) => Effect.Effect readonly switchAgent: (input: { sessionID: SessionID; agent: string }) => Effect.Effect readonly switchModel: (input: { sessionID: SessionID; model: ModelV2.Ref }) => Effect.Effect readonly compact: (sessionID: SessionID) => Effect.Effect + readonly resume: (sessionID: SessionID) => Effect.Effect readonly wait: (sessionID: SessionID) => Effect.Effect } diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index 1e87ddc6b1..9c9cbd1e6e 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -20,7 +20,7 @@ import { MessageID, PartID, SessionID, type SessionID as SessionIDType } from ". import { MessageV2 } from "../../src/session/message-v2" import { Database } from "@/storage/db" import { SessionMessageTable, SessionTable } from "@/session/session.sql" -import { SessionMessage } from "@opencode-ai/core/session-message" +import { SessionMessage } from "@opencode-ai/core/session/message" import { ModelV2 } from "@opencode-ai/core/model" import { ProviderV2 } from "@opencode-ai/core/provider" import * as DateTime from "effect/DateTime" diff --git a/packages/opencode/test/v2/session-message-updater.test.ts b/packages/opencode/test/v2/session-message-updater.test.ts index 588521281c..394a4a8708 100644 --- a/packages/opencode/test/v2/session-message-updater.test.ts +++ b/packages/opencode/test/v2/session-message-updater.test.ts @@ -4,8 +4,8 @@ import { SessionID } from "../../src/session/schema" import { EventV2 } from "@opencode-ai/core/event" import { ModelV2 } from "@opencode-ai/core/model" import { ProviderV2 } from "@opencode-ai/core/provider" -import { SessionEvent } from "@opencode-ai/core/session-event" -import { SessionMessageUpdater } from "@opencode-ai/core/session-message-updater" +import { SessionEvent } from "@opencode-ai/core/session/event" +import { SessionMessageUpdater } from "@opencode-ai/core/session/message-updater" test("step snapshots carry over to assistant messages", () => { const state: SessionMessageUpdater.MemoryState = { messages: [] }