From 6bd47e1bce0b65e401cba5850ae2299ea254cc2a Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 21 May 2026 01:26:31 -0400 Subject: [PATCH 1/2] feat(core): add sqlite schema sync --- packages/core/package.json | 3 + packages/core/src/database/database.ts | 45 +++ packages/core/src/database/migration.ts | 248 +++++++++++++ .../{session-event.ts => session/event.ts} | 16 +- .../core/src/{session.ts => session/index.ts} | 6 +- .../message-updater.ts} | 6 +- .../message.ts} | 14 +- .../{session-prompt.ts => session/prompt.ts} | 0 packages/core/src/session/sql.ts | 49 +++ packages/core/test/database-migration.test.ts | 328 ++++++++++++++++++ packages/opencode/src/event-v2-bridge.ts | 2 +- .../instance/httpapi/groups/v2/message.ts | 2 +- .../instance/httpapi/groups/v2/session.ts | 4 +- .../instance/httpapi/handlers/v2/message.ts | 2 +- packages/opencode/src/session/compaction.ts | 2 +- packages/opencode/src/session/processor.ts | 2 +- .../opencode/src/session/projectors-next.ts | 6 +- packages/opencode/src/session/prompt.ts | 4 +- packages/opencode/src/session/schema.ts | 2 +- packages/opencode/src/session/session.sql.ts | 2 +- packages/opencode/src/v2/session.ts | 5 +- .../test/server/httpapi-session.test.ts | 2 +- .../test/v2/session-message-updater.test.ts | 4 +- 23 files changed, 712 insertions(+), 42 deletions(-) create mode 100644 packages/core/src/database/database.ts create mode 100644 packages/core/src/database/migration.ts rename packages/core/src/{session-event.ts => session/event.ts} (96%) rename packages/core/src/{session.ts => session/index.ts} (70%) rename packages/core/src/{session-message-updater.ts => session/message-updater.ts} (98%) rename packages/core/src/{session-message.ts => session/message.ts} (95%) rename packages/core/src/{session-prompt.ts => session/prompt.ts} (100%) create mode 100644 packages/core/src/session/sql.ts create mode 100644 packages/core/test/database-migration.test.ts 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..a7c56eb220 --- /dev/null +++ b/packages/core/src/database/database.ts @@ -0,0 +1,45 @@ +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" + +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)") + + 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/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.ts b/packages/core/src/session/index.ts similarity index 70% rename from packages/core/src/session.ts rename to packages/core/src/session/index.ts index 756531e328..fa6cf07995 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session/index.ts @@ -1,8 +1,8 @@ -export * as Session from "./session" +export * as Session from "." import { Schema } from "effect" -import { withStatics } from "./schema" -import { Identifier } from "./util/identifier" +import { withStatics } from "../schema" +import { Identifier } from "../util/identifier" export const ID = Schema.String.check(Schema.isStartsWith("ses")).pipe( Schema.brand("SessionID"), 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/test/database-migration.test.ts b/packages/core/test/database-migration.test.ts new file mode 100644 index 0000000000..7128c1b95c --- /dev/null +++ b/packages/core/test/database-migration.test.ts @@ -0,0 +1,328 @@ +import { describe, expect, test } from "bun:test" +import { SqliteClient } from "@effect/sql-sqlite-bun" +import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite" +import { DatabaseMigration } from "@opencode-ai/core/database/migration" +import { Effect } from "effect" +import { index, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core" +import { sql, type ColumnBuilderBase } from "drizzle-orm" +import path from "path" +import { tmpdir } from "./fixture/tmpdir" + +const rand = (seed: number) => () => { + seed = (seed * 1664525 + 1013904223) >>> 0 + return seed / 0x100000000 +} + +describe("DatabaseMigration", () => { + test("diff creates missing tables before indexes and apply is idempotent", () => + withDb((db) => + Effect.gen(function* () { + const table = sqliteTable( + "session", + { + id: text().primaryKey(), + slug: text().notNull(), + title: text().notNull().default("untitled"), + }, + (table) => [index("session_slug_idx").on(table.slug), uniqueIndex("session_title_uidx").on(table.title)], + ) + + const operations = yield* DatabaseMigration.diff(db, [table]) + expect(operations.map((operation) => operation.type)).toEqual(["create_table", "create_index", "create_index"]) + + yield* DatabaseMigration.apply(db, operations) + expect(yield* DatabaseMigration.diff(db, [table])).toEqual([]) + expect((yield* columnNames(db, "session")).sort()).toEqual(["id", "slug", "title"]) + expect((yield* indexNames(db, "session")).sort()).toEqual(["session_slug_idx", "session_title_uidx"]) + }), + ), + ) + + test("diff adds missing columns and indexes without recreating existing tables", () => + withDb((db) => + Effect.gen(function* () { + yield* db.run(`CREATE TABLE "session" ("id" text PRIMARY KEY NOT NULL)`) + const table = sqliteTable( + "session", + { + id: text().primaryKey(), + title: text().notNull().default("untitled"), + path: text(), + }, + (table) => [index("session_title_idx").on(table.title)], + ) + + const operations = yield* DatabaseMigration.diff(db, [table]) + expect(operations.map((operation) => operation.type)).toEqual(["add_column", "add_column", "create_index"]) + + yield* DatabaseMigration.apply(db, operations) + expect(yield* DatabaseMigration.diff(db, [table])).toEqual([]) + expect((yield* columnNames(db, "session")).sort()).toEqual(["id", "path", "title"]) + expect(yield* indexNames(db, "session")).toEqual(["session_title_idx"]) + }), + ), + ) + + test("diff is additive only and ignores extra actual schema and definition drift", () => + withDb((db) => + Effect.gen(function* () { + yield* db.run(`CREATE TABLE "session" ("id" text PRIMARY KEY NOT NULL, "title" integer NOT NULL, "extra" text)`) + yield* db.run(`CREATE INDEX "session_extra_idx" ON "session" ("extra")`) + yield* db.run(`CREATE TABLE "extra_table" ("id" text PRIMARY KEY)`) + const table = sqliteTable("session", { + id: text().primaryKey(), + title: text().notNull().default("untitled"), + }) + + expect(yield* DatabaseMigration.diff(db, [table])).toEqual([]) + }), + ), + ) + + test("diff ignores changed indexes to stay downgrade safe", () => + withDb((db) => + Effect.gen(function* () { + yield* db.run(`CREATE TABLE "session" ("id" text PRIMARY KEY NOT NULL, "title" text, "slug" text)`) + yield* db.run(`CREATE INDEX "session_lookup_idx" ON "session" ("slug")`) + yield* db.run(`CREATE INDEX "session_expression_idx" ON "session" (lower("slug"))`) + const table = sqliteTable( + "session", + { + id: text().primaryKey(), + title: text(), + slug: text(), + }, + (table) => [ + uniqueIndex("session_lookup_idx").on(table.title, table.slug), + index("session_expression_idx").on(sql`lower(${table.title})`), + ], + ) + + expect(yield* DatabaseMigration.diff(db, [table])).toEqual([]) + expect((yield* indexColumns(db, "session_lookup_idx")).map((column) => column.name)).toEqual(["slug"]) + }), + ), + ) + + test("diff ignores changed not-null constraints to stay downgrade safe", () => + withDb((db) => + Effect.gen(function* () { + yield* db.run(`CREATE TABLE "session" ("id" text PRIMARY KEY NOT NULL, "required" text, "optional" text NOT NULL)`) + const table = sqliteTable("session", { + id: text().primaryKey(), + required: text().notNull(), + optional: text(), + }) + + expect(yield* DatabaseMigration.diff(db, [table])).toEqual([]) + expect(yield* columnFlags(db, "session")).toMatchObject({ required: { notnull: 0 }, optional: { notnull: 1 } }) + }), + ), + ) + + test("apply handles quoted identifiers, composite indexes, unique indexes, and expression indexes", () => + withDb((db) => + Effect.gen(function* () { + const table = sqliteTable( + "table \" with spaces", + { + id: text('id " col').primaryKey(), + value: text('value " one').notNull().default("a'b"), + other: text("other space"), + }, + (table) => [ + uniqueIndex('idx " composite').on(table.value, table.other), + index('idx " expression').on(sql`lower(${table.value})`.inlineParams()), + ], + ) + + yield* DatabaseMigration.apply(db, yield* DatabaseMigration.diff(db, [table])) + expect(yield* DatabaseMigration.diff(db, [table])).toEqual([]) + expect((yield* columnNames(db, 'table " with spaces')).sort()).toEqual(["id \" col", "other space", "value \" one"]) + expect((yield* indexNames(db, 'table " with spaces')).sort()).toEqual(['idx " composite', 'idx " expression']) + }), + ), + ) + + test("random schema reconciliation reaches a fixed point with additive operations", () => + withDb((db) => + Effect.gen(function* () { + for (let seed = 1; seed <= 75; seed++) { + const random = rand(seed) + const specs = Array.from({ length: 1 + Math.floor(random() * 4) }, (_, index) => randomSpec(random, seed, index)) + for (const spec of specs) yield* seedActualSchema(db, spec, random) + + const tables = specs.map(tableFromSpec) + const operations = yield* DatabaseMigration.diff(db, tables) + expect( + operations.every((operation) => ["create_table", "add_column", "create_index"].includes(operation.type)), + ).toBe(true) + + yield* DatabaseMigration.apply(db, operations) + expect(yield* DatabaseMigration.diff(db, tables)).toEqual([]) + } + }), + ), + 20000, + ) +}) + +type TableSpec = { + name: string + columns: ColumnSpec[] + indexes: IndexSpec[] +} + +type ColumnSpec = { + key: string + name: string + primaryKey: boolean + notNull: boolean + default?: string +} + +type IndexSpec = { + name: string + columns: string[] + unique: boolean +} + +function tableFromSpec(spec: TableSpec) { + const columns = Object.fromEntries(spec.columns.map((column) => [column.key, columnBuilder(column)])) as Record + return sqliteTable(spec.name, columns, (table) => + spec.indexes.map((item) => { + const columns = item.columns.map((key) => table[key]).filter((column) => column !== undefined) + const first = columns[0] + if (!first) throw new Error(`index ${item.name} has no columns`) + return (item.unique ? uniqueIndex(item.name) : index(item.name)).on(first, ...columns.slice(1)) + }), + ) +} + +function columnBuilder(spec: ColumnSpec): ColumnBuilderBase { + if (spec.primaryKey) return text(spec.name).primaryKey() + if (spec.default !== undefined) return text(spec.name).notNull().default(spec.default) + if (spec.notNull) return text(spec.name).notNull() + return text(spec.name) +} + +function randomSpec(random: () => number, seed: number, index: number): TableSpec { + const name = identifier(random, `table_${seed}_${index}`) + const columns = Array.from({ length: 1 + Math.floor(random() * 8) }, (_, i): ColumnSpec => { + const primaryKey = i === 0 + const notNull = primaryKey || random() > 0.5 + return { + key: `column_${i}`, + name: identifier(random, `column_${i}`), + primaryKey, + notNull, + ...(notNull && !primaryKey ? { default: `default_${Math.floor(random() * 1000)}` } : {}), + } + }) + const indexes = columns + .filter((column) => !column.primaryKey && random() > 0.45) + .map((column, i): IndexSpec => ({ + name: identifier(random, `${name}_${column.name}_${i}_idx`), + columns: random() > 0.65 ? columns.filter((item) => !item.primaryKey).slice(0, 2).map((item) => item.key) : [column.key], + unique: random() > 0.8, + })) + .filter((item) => item.columns.length > 0) + return { name, columns, indexes } +} + +function seedActualSchema(db: EffectDrizzleSqlite.EffectSQLiteDatabase, spec: TableSpec, random: () => number) { + return Effect.gen(function* () { + if (random() < 0.25) return + const columns = spec.columns.filter((column) => column.primaryKey || random() > 0.35) + yield* db.run(`CREATE TABLE ${quoteIdentifier(spec.name)} (${columns.map(columnSql).join(", ")})`) + for (const column of columns.filter((column) => !column.primaryKey && !column.notNull && random() > 0.6)) { + yield* db.run(`ALTER TABLE ${quoteIdentifier(spec.name)} ALTER COLUMN ${quoteIdentifier(column.name)} SET NOT NULL`) + } + for (const item of spec.indexes.filter(() => random() > 0.5)) { + if (!item.columns.every((key) => columns.some((column) => column.key === key))) continue + const changed = random() > 0.5 + yield* db.run( + indexSql(spec.name, { + ...item, + unique: changed ? !item.unique : item.unique, + columns: changed ? [...item.columns].reverse() : item.columns, + }), + ) + } + }) +} + +function columnSql(spec: ColumnSpec) { + return [ + quoteIdentifier(spec.name), + "text", + spec.primaryKey ? "PRIMARY KEY" : undefined, + spec.notNull ? "NOT NULL" : undefined, + spec.default === undefined ? undefined : `DEFAULT ${literal(spec.default)}`, + ] + .filter((item) => item !== undefined) + .join(" ") +} + +function indexSql(table: string, spec: IndexSpec) { + return [ + "CREATE", + spec.unique ? "UNIQUE" : undefined, + "INDEX", + quoteIdentifier(spec.name), + "ON", + quoteIdentifier(table), + `(${spec.columns.map((column) => quoteIdentifier(columnName(column))).join(", ")})`, + ] + .filter((item) => item !== undefined) + .join(" ") +} + +function indexColumns(db: EffectDrizzleSqlite.EffectSQLiteDatabase, index: string) { + return db.all<{ name: string | null }>(`PRAGMA index_info(${quoteIdentifier(index)})`) +} + +function columnName(key: string) { + return key.replace(/^column_/, "column_") +} + +function identifier(random: () => number, fallback: string) { + const suffixes = ["", " space", ' " quote', " select", "-dash", "_underscore"] + return `${fallback}${suffixes[Math.floor(random() * suffixes.length)]}` +} + +function columnNames(db: EffectDrizzleSqlite.EffectSQLiteDatabase, table: string) { + return db.all<{ name: string }>(`PRAGMA table_info(${quoteIdentifier(table)})`).pipe(Effect.map((rows) => rows.map((row) => row.name))) +} + +function indexNames(db: EffectDrizzleSqlite.EffectSQLiteDatabase, table: string) { + return db + .all<{ name: string }>(`PRAGMA index_list(${quoteIdentifier(table)})`) + .pipe(Effect.map((rows) => rows.map((row) => row.name).filter((name) => !name.startsWith("sqlite_autoindex_")))) +} + +function columnFlags(db: EffectDrizzleSqlite.EffectSQLiteDatabase, table: string) { + return db + .all<{ name: string; notnull: number }>(`PRAGMA table_info(${quoteIdentifier(table)})`) + .pipe(Effect.map((rows) => Object.fromEntries(rows.map((row) => [row.name, { notnull: row.notnull }])))) +} + +async function withDb(fn: (db: EffectDrizzleSqlite.EffectSQLiteDatabase) => Effect.Effect) { + const dir = await tmpdir() + try { + return await Effect.gen(function* () { + const db = yield* EffectDrizzleSqlite.makeWithDefaults() + return yield* fn(db) + }).pipe(Effect.provide(SqliteClient.layer({ filename: path.join(dir.path, "test.db") })), Effect.runPromise) + } finally { + await dir[Symbol.asyncDispose]() + } +} + +function quoteIdentifier(value: string) { + return `"${value.replaceAll('"', '""')}"` +} + +function literal(value: string) { + return `'${value.replaceAll("'", "''")}'` +} 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..122880e21e 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" 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: [] } From 34bade292d8ecc41d9ff2d6de6b27e9f6011db66 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Fri, 22 May 2026 09:41:02 -0400 Subject: [PATCH 2/2] progress --- .opencode/opencode.jsonc | 3 + packages/core/src/database/database.ts | 4 + packages/core/src/location.ts | 3 +- packages/core/src/plugin/account.ts | 2 + packages/core/src/plugin/models-dev.ts | 3 +- packages/core/src/project.ts | 130 ++++++ packages/core/src/schema.ts | 12 + packages/core/src/session/index.ts | 118 ++++- packages/core/src/workspace.ts | 11 + packages/core/test/database-migration.test.ts | 440 ++++++------------ packages/opencode/src/v2/session.ts | 14 +- 11 files changed, 420 insertions(+), 320 deletions(-) create mode 100644 packages/core/src/project.ts create mode 100644 packages/core/src/workspace.ts 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/src/database/database.ts b/packages/core/src/database/database.ts index a7c56eb220..36b49b65cc 100644 --- a/packages/core/src/database/database.ts +++ b/packages/core/src/database/database.ts @@ -6,6 +6,7 @@ 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 @@ -24,6 +25,9 @@ const layer = Layer.effect( yield* db.run("PRAGMA foreign_keys = ON") yield* db.run("PRAGMA wal_checkpoint(PASSIVE)") + console.log(DatabaseMigration.ensure + + return db }), ) 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/index.ts b/packages/core/src/session/index.ts index fa6cf07995..3627423552 100644 --- a/packages/core/src/session/index.ts +++ b/packages/core/src/session/index.ts @@ -1,8 +1,20 @@ export * as Session from "." -import { Schema } from "effect" -import { withStatics } from "../schema" +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"), @@ -11,3 +23,105 @@ export const ID = Schema.String.check(Schema.isStartsWith("ses")).pipe( })), ) 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/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 index 7128c1b95c..d5a3de6620 100644 --- a/packages/core/test/database-migration.test.ts +++ b/packages/core/test/database-migration.test.ts @@ -1,12 +1,5 @@ import { describe, expect, test } from "bun:test" -import { SqliteClient } from "@effect/sql-sqlite-bun" -import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite" import { DatabaseMigration } from "@opencode-ai/core/database/migration" -import { Effect } from "effect" -import { index, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core" -import { sql, type ColumnBuilderBase } from "drizzle-orm" -import path from "path" -import { tmpdir } from "./fixture/tmpdir" const rand = (seed: number) => () => { seed = (seed * 1664525 + 1013904223) >>> 0 @@ -14,315 +7,152 @@ const rand = (seed: number) => () => { } describe("DatabaseMigration", () => { - test("diff creates missing tables before indexes and apply is idempotent", () => - withDb((db) => - Effect.gen(function* () { - const table = sqliteTable( - "session", - { - id: text().primaryKey(), - slug: text().notNull(), - title: text().notNull().default("untitled"), - }, - (table) => [index("session_slug_idx").on(table.slug), uniqueIndex("session_title_uidx").on(table.title)], + 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 = yield* DatabaseMigration.diff(db, [table]) - expect(operations.map((operation) => operation.type)).toEqual(["create_table", "create_index", "create_index"]) - - yield* DatabaseMigration.apply(db, operations) - expect(yield* DatabaseMigration.diff(db, [table])).toEqual([]) - expect((yield* columnNames(db, "session")).sort()).toEqual(["id", "slug", "title"]) - expect((yield* indexNames(db, "session")).sort()).toEqual(["session_slug_idx", "session_title_uidx"]) - }), - ), - ) - - test("diff adds missing columns and indexes without recreating existing tables", () => - withDb((db) => - Effect.gen(function* () { - yield* db.run(`CREATE TABLE "session" ("id" text PRIMARY KEY NOT NULL)`) - const table = sqliteTable( - "session", - { - id: text().primaryKey(), - title: text().notNull().default("untitled"), - path: text(), - }, - (table) => [index("session_title_idx").on(table.title)], - ) - - const operations = yield* DatabaseMigration.diff(db, [table]) - expect(operations.map((operation) => operation.type)).toEqual(["add_column", "add_column", "create_index"]) - - yield* DatabaseMigration.apply(db, operations) - expect(yield* DatabaseMigration.diff(db, [table])).toEqual([]) - expect((yield* columnNames(db, "session")).sort()).toEqual(["id", "path", "title"]) - expect(yield* indexNames(db, "session")).toEqual(["session_title_idx"]) - }), - ), - ) - - test("diff is additive only and ignores extra actual schema and definition drift", () => - withDb((db) => - Effect.gen(function* () { - yield* db.run(`CREATE TABLE "session" ("id" text PRIMARY KEY NOT NULL, "title" integer NOT NULL, "extra" text)`) - yield* db.run(`CREATE INDEX "session_extra_idx" ON "session" ("extra")`) - yield* db.run(`CREATE TABLE "extra_table" ("id" text PRIMARY KEY)`) - const table = sqliteTable("session", { - id: text().primaryKey(), - title: text().notNull().default("untitled"), - }) - - expect(yield* DatabaseMigration.diff(db, [table])).toEqual([]) - }), - ), - ) - - test("diff ignores changed indexes to stay downgrade safe", () => - withDb((db) => - Effect.gen(function* () { - yield* db.run(`CREATE TABLE "session" ("id" text PRIMARY KEY NOT NULL, "title" text, "slug" text)`) - yield* db.run(`CREATE INDEX "session_lookup_idx" ON "session" ("slug")`) - yield* db.run(`CREATE INDEX "session_expression_idx" ON "session" (lower("slug"))`) - const table = sqliteTable( - "session", - { - id: text().primaryKey(), - title: text(), - slug: text(), - }, - (table) => [ - uniqueIndex("session_lookup_idx").on(table.title, table.slug), - index("session_expression_idx").on(sql`lower(${table.title})`), - ], - ) - - expect(yield* DatabaseMigration.diff(db, [table])).toEqual([]) - expect((yield* indexColumns(db, "session_lookup_idx")).map((column) => column.name)).toEqual(["slug"]) - }), - ), - ) - - test("diff ignores changed not-null constraints to stay downgrade safe", () => - withDb((db) => - Effect.gen(function* () { - yield* db.run(`CREATE TABLE "session" ("id" text PRIMARY KEY NOT NULL, "required" text, "optional" text NOT NULL)`) - const table = sqliteTable("session", { - id: text().primaryKey(), - required: text().notNull(), - optional: text(), - }) - - expect(yield* DatabaseMigration.diff(db, [table])).toEqual([]) - expect(yield* columnFlags(db, "session")).toMatchObject({ required: { notnull: 0 }, optional: { notnull: 1 } }) - }), - ), - ) - - test("apply handles quoted identifiers, composite indexes, unique indexes, and expression indexes", () => - withDb((db) => - Effect.gen(function* () { - const table = sqliteTable( - "table \" with spaces", - { - id: text('id " col').primaryKey(), - value: text('value " one').notNull().default("a'b"), - other: text("other space"), - }, - (table) => [ - uniqueIndex('idx " composite').on(table.value, table.other), - index('idx " expression').on(sql`lower(${table.value})`.inlineParams()), - ], - ) - - yield* DatabaseMigration.apply(db, yield* DatabaseMigration.diff(db, [table])) - expect(yield* DatabaseMigration.diff(db, [table])).toEqual([]) - expect((yield* columnNames(db, 'table " with spaces')).sort()).toEqual(["id \" col", "other space", "value \" one"]) - expect((yield* indexNames(db, 'table " with spaces')).sort()).toEqual(['idx " composite', 'idx " expression']) - }), - ), - ) - - test("random schema reconciliation reaches a fixed point with additive operations", () => - withDb((db) => - Effect.gen(function* () { - for (let seed = 1; seed <= 75; seed++) { - const random = rand(seed) - const specs = Array.from({ length: 1 + Math.floor(random() * 4) }, (_, index) => randomSpec(random, seed, index)) - for (const spec of specs) yield* seedActualSchema(db, spec, random) - - const tables = specs.map(tableFromSpec) - const operations = yield* DatabaseMigration.diff(db, tables) - expect( - operations.every((operation) => ["create_table", "add_column", "create_index"].includes(operation.type)), - ).toBe(true) - - yield* DatabaseMigration.apply(db, operations) - expect(yield* DatabaseMigration.diff(db, tables)).toEqual([]) + 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)] } - }), - ), - 20000, - ) + 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('"') + } + } + }) }) -type TableSpec = { - name: string - columns: ColumnSpec[] - indexes: IndexSpec[] +function emptySchema(): DatabaseMigration.SchemaAst { + return { tables: {} } } -type ColumnSpec = { - key: string - name: string - primaryKey: boolean - notNull: boolean - default?: string +function schema(...tables: DatabaseMigration.TableAst[]): DatabaseMigration.SchemaAst { + return { tables: Object.fromEntries(tables.map((table) => [table.name, table])) } } -type IndexSpec = { - name: string - columns: string[] - unique: boolean -} - -function tableFromSpec(spec: TableSpec) { - const columns = Object.fromEntries(spec.columns.map((column) => [column.key, columnBuilder(column)])) as Record - return sqliteTable(spec.name, columns, (table) => - spec.indexes.map((item) => { - const columns = item.columns.map((key) => table[key]).filter((column) => column !== undefined) - const first = columns[0] - if (!first) throw new Error(`index ${item.name} has no columns`) - return (item.unique ? uniqueIndex(item.name) : index(item.name)).on(first, ...columns.slice(1)) - }), - ) -} - -function columnBuilder(spec: ColumnSpec): ColumnBuilderBase { - if (spec.primaryKey) return text(spec.name).primaryKey() - if (spec.default !== undefined) return text(spec.name).notNull().default(spec.default) - if (spec.notNull) return text(spec.name).notNull() - return text(spec.name) -} - -function randomSpec(random: () => number, seed: number, index: number): TableSpec { - const name = identifier(random, `table_${seed}_${index}`) - const columns = Array.from({ length: 1 + Math.floor(random() * 8) }, (_, i): ColumnSpec => { - const primaryKey = i === 0 - const notNull = primaryKey || random() > 0.5 - return { - key: `column_${i}`, - name: identifier(random, `column_${i}`), - primaryKey, - notNull, - ...(notNull && !primaryKey ? { default: `default_${Math.floor(random() * 1000)}` } : {}), - } - }) - const indexes = columns - .filter((column) => !column.primaryKey && random() > 0.45) - .map((column, i): IndexSpec => ({ - name: identifier(random, `${name}_${column.name}_${i}_idx`), - columns: random() > 0.65 ? columns.filter((item) => !item.primaryKey).slice(0, 2).map((item) => item.key) : [column.key], - unique: random() > 0.8, - })) - .filter((item) => item.columns.length > 0) - return { name, columns, indexes } -} - -function seedActualSchema(db: EffectDrizzleSqlite.EffectSQLiteDatabase, spec: TableSpec, random: () => number) { - return Effect.gen(function* () { - if (random() < 0.25) return - const columns = spec.columns.filter((column) => column.primaryKey || random() > 0.35) - yield* db.run(`CREATE TABLE ${quoteIdentifier(spec.name)} (${columns.map(columnSql).join(", ")})`) - for (const column of columns.filter((column) => !column.primaryKey && !column.notNull && random() > 0.6)) { - yield* db.run(`ALTER TABLE ${quoteIdentifier(spec.name)} ALTER COLUMN ${quoteIdentifier(column.name)} SET NOT NULL`) - } - for (const item of spec.indexes.filter(() => random() > 0.5)) { - if (!item.columns.every((key) => columns.some((column) => column.key === key))) continue - const changed = random() > 0.5 - yield* db.run( - indexSql(spec.name, { - ...item, - unique: changed ? !item.unique : item.unique, - columns: changed ? [...item.columns].reverse() : item.columns, - }), - ) - } - }) -} - -function columnSql(spec: ColumnSpec) { - return [ - quoteIdentifier(spec.name), - "text", - spec.primaryKey ? "PRIMARY KEY" : undefined, - spec.notNull ? "NOT NULL" : undefined, - spec.default === undefined ? undefined : `DEFAULT ${literal(spec.default)}`, - ] - .filter((item) => item !== undefined) - .join(" ") -} - -function indexSql(table: string, spec: IndexSpec) { - return [ - "CREATE", - spec.unique ? "UNIQUE" : undefined, - "INDEX", - quoteIdentifier(spec.name), - "ON", - quoteIdentifier(table), - `(${spec.columns.map((column) => quoteIdentifier(columnName(column))).join(", ")})`, - ] - .filter((item) => item !== undefined) - .join(" ") -} - -function indexColumns(db: EffectDrizzleSqlite.EffectSQLiteDatabase, index: string) { - return db.all<{ name: string | null }>(`PRAGMA index_info(${quoteIdentifier(index)})`) -} - -function columnName(key: string) { - return key.replace(/^column_/, "column_") -} - -function identifier(random: () => number, fallback: string) { - const suffixes = ["", " space", ' " quote', " select", "-dash", "_underscore"] - return `${fallback}${suffixes[Math.floor(random() * suffixes.length)]}` -} - -function columnNames(db: EffectDrizzleSqlite.EffectSQLiteDatabase, table: string) { - return db.all<{ name: string }>(`PRAGMA table_info(${quoteIdentifier(table)})`).pipe(Effect.map((rows) => rows.map((row) => row.name))) -} - -function indexNames(db: EffectDrizzleSqlite.EffectSQLiteDatabase, table: string) { - return db - .all<{ name: string }>(`PRAGMA index_list(${quoteIdentifier(table)})`) - .pipe(Effect.map((rows) => rows.map((row) => row.name).filter((name) => !name.startsWith("sqlite_autoindex_")))) -} - -function columnFlags(db: EffectDrizzleSqlite.EffectSQLiteDatabase, table: string) { - return db - .all<{ name: string; notnull: number }>(`PRAGMA table_info(${quoteIdentifier(table)})`) - .pipe(Effect.map((rows) => Object.fromEntries(rows.map((row) => [row.name, { notnull: row.notnull }])))) -} - -async function withDb(fn: (db: EffectDrizzleSqlite.EffectSQLiteDatabase) => Effect.Effect) { - const dir = await tmpdir() - try { - return await Effect.gen(function* () { - const db = yield* EffectDrizzleSqlite.makeWithDefaults() - return yield* fn(db) - }).pipe(Effect.provide(SqliteClient.layer({ filename: path.join(dir.path, "test.db") })), Effect.runPromise) - } finally { - await dir[Symbol.asyncDispose]() +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 quoteIdentifier(value: string) { - return `"${value.replaceAll('"', '""')}"` +function createTableOperation(table: DatabaseMigration.TableAst): DatabaseMigration.Operation { + return { type: "create_table", table } } -function literal(value: string) { - return `'${value.replaceAll("'", "''")}'` +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/v2/session.ts b/packages/opencode/src/v2/session.ts index 122880e21e..8d8e2d0a30 100644 --- a/packages/opencode/src/v2/session.ts +++ b/packages/opencode/src/v2/session.ts @@ -85,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 @@ -110,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 }