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 }