Compare commits

...

38 Commits

Author SHA1 Message Date
copilot-swe-agent[bot] edfba50d6e fix: resolve merge conflicts from dev branch 2026-05-28 01:41:40 +00:00
copilot-swe-agent[bot] b8b16391df chore: resolve merge conflicts with dev branch 2026-05-28 01:39:49 +00:00
Dax Raad 6a994d6c67 test(core): run migration generation check on linux 2026-05-26 19:24:09 -04:00
Dax Raad 50f6df3f3a test(core): check migration generation is clean 2026-05-26 14:40:09 -04:00
Dax Raad c1e744f099 fix(core): include domain schemas in migrations 2026-05-26 14:31:58 -04:00
Dax Raad 714cbe6dbd fix(opencode): preserve worktree path when disposing instances 2026-05-26 13:48:52 -04:00
Dax Raad 3077ff3f0c fix(opencode): dispose booted worktrees before removal 2026-05-26 13:16:26 -04:00
Dax Raad 9b5fbbe398 fix(opencode): share httpapi exercise service graph 2026-05-26 12:39:36 -04:00
Dax Raad b5912f50c1 chore: merge dev into event v2 branch 2026-05-26 12:13:39 -04:00
Dax Raad 0364de0af6 refactor(opencode): replace instance bus with event v2 2026-05-26 12:03:42 -04:00
Dax Raad d0dae93c73 sync 2026-05-25 20:39:22 -04:00
Dax Raad 7c14627fc6 chore: merge dev into database wrapper branch 2026-05-25 19:35:44 -04:00
Dax Raad 876ce31fef refactor(opencode): remove legacy database wrapper 2026-05-25 19:26:05 -04:00
Dax Raad c3743b26e8 fix(opencode): preserve session event routing types 2026-05-25 18:23:03 -04:00
Dax Raad b97d254dcc refactor(opencode): migrate session events to core 2026-05-25 18:20:07 -04:00
Dax Raad 087d356d41 chore(core): format event service 2026-05-25 14:25:23 -04:00
Dax Raad 08c17e831f refactor(opencode): move session reads to core database 2026-05-25 14:24:27 -04:00
Dax Raad dad0f579fb refactor(opencode): remove storage db from domain services 2026-05-25 12:21:14 -04:00
Dax Raad b819034c50 fix(opencode): align v2 session endpoint errors 2026-05-25 12:21:06 -04:00
Dax Raad dcbe09bc9f refactor(core): mark session methods unimplemented 2026-05-25 11:44:21 -04:00
Dax Raad 7180c3a7fb fix(core): add session list cursor schema 2026-05-25 01:14:22 -04:00
Dax Raad 9440f7f5b9 test(core): cover sync event replay controls 2026-05-25 00:44:04 -04:00
Dax Raad 13e9e317b1 chore: merge dev into core database branch 2026-05-25 00:34:57 -04:00
Dax Raad 06bd34c953 feat(core): add sync event replay controls 2026-05-25 00:28:30 -04:00
Dax Raad 05dca9f698 feat(core): persist sync event projections 2026-05-25 00:13:17 -04:00
Dax Raad dedcb9ba91 refactor(core): project session events in core 2026-05-24 21:45:01 -04:00
Dax Raad b18ff8fd68 progress 2026-05-24 19:47:55 -04:00
Dax Raad 9ded717fc1 refactor(core): unify sqlite database clients 2026-05-24 19:14:24 -04:00
Dax Raad 9b812d9609 fix(core): migrate sync database clients 2026-05-24 15:26:22 -04:00
Dax Raad aa8ad494b8 refactor(core): centralize legacy session schemas 2026-05-24 15:14:29 -04:00
Dax Raad 46953462d9 refactor(core): centralize project and workspace schemas 2026-05-24 14:55:56 -04:00
Dax Raad 077c91b95d core: avoid session schema import cycle 2026-05-24 12:36:42 -04:00
Dax Raad 31d89590e1 core: isolate native node sqlite provider 2026-05-24 12:22:39 -04:00
Dax Raad 46422a4575 core: support native node sqlite database runtime 2026-05-24 02:11:57 -04:00
Dax Raad 977f48a97b Merge remote-tracking branch 'origin/dev' into refactor/core-database-schema-ownership
# Conflicts:
#	packages/core/src/project.ts
#	packages/opencode/src/project/project.ts
2026-05-24 01:57:05 -04:00
Dax Raad 0cab11f26b refactor(core): move database schema ownership 2026-05-24 01:27:22 -04:00
Dax Raad eaf5697b06 progress 2026-05-22 10:07:53 -04:00
Dax Raad 36160ef28f feat(core): add sqlite schema sync 2026-05-22 10:07:53 -04:00
373 changed files with 9331 additions and 9038 deletions
+3
View File
@@ -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,
+6
View File
@@ -47,6 +47,12 @@ obj.b
const { a, b } = obj
```
### Imports
- Never alias imports. Do not use `import { foo as bar } from "..."` or renamed imports like `resolve as pathResolve`.
- Never use star imports. Do not use `import * as Foo from "..."` or `import type * as Foo from "..."`.
- If a namespace-style value is needed, import the module's own exported namespace by name, for example `import { Project } from "@opencode-ai/core/project"`, then reference `Project.ID`.
### Variables
Prefer `const` over `let`. Use ternaries or early returns instead of reassignment.
+19 -1
View File
@@ -242,8 +242,11 @@
"@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:*",
"@opencode-ai/effect-sqlite-node": "workspace:*",
"@openrouter/ai-sdk-provider": "2.8.1",
"@opentelemetry/api": "1.9.0",
"@opentelemetry/context-async-hooks": "2.6.1",
@@ -251,6 +254,7 @@
"@opentelemetry/sdk-trace-base": "2.6.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",
@@ -271,6 +275,7 @@
"@types/npm-package-arg": "6.1.4",
"@types/npmcli__arborist": "6.3.3",
"@types/semver": "catalog:",
"drizzle-kit": "catalog:",
},
},
"packages/desktop": {
@@ -342,6 +347,18 @@
"@typescript/native-preview": "catalog:",
},
},
"packages/effect-sqlite-node": {
"name": "@opencode-ai/effect-sqlite-node",
"version": "1.15.10",
"dependencies": {
"effect": "catalog:",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
},
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.15.11",
@@ -551,7 +568,6 @@
"@types/which": "3.0.4",
"@types/yargs": "17.0.33",
"@typescript/native-preview": "catalog:",
"drizzle-kit": "catalog:",
"drizzle-orm": "catalog:",
"prettier": "3.6.2",
"typescript": "catalog:",
@@ -1662,6 +1678,8 @@
"@opencode-ai/effect-drizzle-sqlite": ["@opencode-ai/effect-drizzle-sqlite@workspace:packages/effect-drizzle-sqlite"],
"@opencode-ai/effect-sqlite-node": ["@opencode-ai/effect-sqlite-node@workspace:packages/effect-sqlite-node"],
"@opencode-ai/enterprise": ["@opencode-ai/enterprise@workspace:packages/enterprise"],
"@opencode-ai/function": ["@opencode-ai/function@workspace:packages/function"],
@@ -2,7 +2,7 @@ import { defineConfig } from "drizzle-kit"
export default defineConfig({
dialect: "sqlite",
schema: "./src/**/*.sql.ts",
schema: ["./src/**/*.sql.ts", "./src/**/sql.ts"],
out: "./migration",
dbCredentials: {
url: "/home/thdxr/.local/share/opencode/opencode.db",
+15 -2
View File
@@ -6,6 +6,8 @@
"license": "MIT",
"private": true,
"scripts": {
"db": "bun drizzle-kit",
"migration": "bun run script/migration.ts",
"test": "bun test",
"test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
"typecheck": "tsgo --noEmit"
@@ -16,14 +18,21 @@
"exports": {
"./*": "./src/*.ts"
},
"imports": {},
"imports": {
"#sqlite": {
"bun": "./src/database/sqlite.bun.ts",
"node": "./src/database/sqlite.node.ts",
"default": "./src/database/sqlite.bun.ts"
}
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@types/cross-spawn": "catalog:",
"@types/npm-package-arg": "6.1.4",
"@types/npmcli__arborist": "6.3.3",
"@types/semver": "catalog:"
"@types/semver": "catalog:",
"drizzle-kit": "catalog:"
},
"dependencies": {
"@ai-sdk/alibaba": "1.0.17",
@@ -49,8 +58,11 @@
"@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:*",
"@opencode-ai/effect-sqlite-node": "workspace:*",
"@opentelemetry/api": "1.9.0",
"@opentelemetry/context-async-hooks": "2.6.1",
"@opentelemetry/exporter-trace-otlp-http": "0.214.0",
@@ -58,6 +70,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",
+113
View File
@@ -0,0 +1,113 @@
#!/usr/bin/env bun
import { $ } from "bun"
import fs from "fs/promises"
import os from "os"
import path from "path"
import { pathToFileURL } from "url"
const root = path.resolve(import.meta.dirname, "../../..")
const sqlDir = path.join(root, "packages/core/migration")
const tsDir = path.join(root, "packages/core/src/database/migration")
const registry = path.join(root, "packages/core/src/database/migration.gen.ts")
if (Bun.argv.includes("--check")) {
await check()
process.exit(0)
}
await $`bun drizzle-kit generate`.cwd(path.join(root, "packages/core"))
const sqlMigrations = (await Array.fromAsync(new Bun.Glob("*/migration.sql").scan({ cwd: sqlDir })))
.map((file) => file.split("/")[0])
.filter((name) => name !== undefined)
.sort()
for (const name of sqlMigrations) {
if (await Bun.file(path.join(tsDir, `${name}.ts`)).exists()) continue
await Bun.write(path.join(tsDir, `${name}.ts`), renderMigration(name, await Bun.file(path.join(sqlDir, name, "migration.sql")).text()))
}
await Bun.write(registry, renderRegistry(sqlMigrations))
async function check() {
const temporary = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-core-migration-check-"))
const output = path.join(temporary, "migration")
try {
await fs.cp(sqlDir, output, { recursive: true })
const config = path.join(temporary, "drizzle.config.ts")
await Bun.write(
config,
`import config from ${JSON.stringify(pathToFileURL(path.join(root, "packages/core/drizzle.config.ts")).href)}
export default { ...config, out: ${JSON.stringify(output)} }
`,
)
const before = await snapshot(output)
await $`bun drizzle-kit generate --config ${config}`.cwd(path.join(root, "packages/core"))
const after = await snapshot(output)
if (JSON.stringify(after) !== JSON.stringify(before)) {
throw new Error("Core schema has ungenerated database migrations. Run `bun script/migration.ts` from packages/core.")
}
const migrations = before
.map((entry) => entry.path.split("/")[0])
.filter((name, index, all) => name !== undefined && all.indexOf(name) === index)
.sort()
for (const name of migrations) {
if (await Bun.file(path.join(tsDir, `${name}.ts`)).exists()) continue
throw new Error(`Database migration TypeScript wrapper is missing for ${name}. Run \`bun script/migration.ts\` from packages/core.`)
}
if ((await Bun.file(registry).text()) !== renderRegistry(migrations)) {
throw new Error("Database migration registry is stale. Run `bun script/migration.ts` from packages/core.")
}
} finally {
await fs.rm(temporary, { recursive: true, force: true })
}
}
async function snapshot(directory: string) {
const files = await Array.fromAsync(new Bun.Glob("**/*").scan({ cwd: directory, onlyFiles: true }))
return Promise.all(
files.sort().map(async (file) => ({ path: file, contents: await Bun.file(path.join(directory, file)).text() })),
)
}
function renderMigration(name: string, sql: string) {
return `import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: ${JSON.stringify(name)},
up(tx) {
return Effect.gen(function* () {
${sql
.split("--> statement-breakpoint")
.map((statement) => statement.trim())
.filter((statement) => statement.length > 0)
.map(renderRun)
.join("\n")}
})
},
} satisfies DatabaseMigration.Migration
`
}
function renderRun(statement: string) {
const lines = statement.replaceAll("\t", " ").split("\n")
if (lines.length === 1) return ` yield* tx.run(\`${escapeTemplate(lines[0])}\`)`
return ` yield* tx.run(\`\n${lines.map((line) => ` ${escapeTemplate(line)}`).join("\n")}\n \`)`
}
function escapeTemplate(line: string) {
return line.replaceAll("\\", "\\\\").replaceAll("`", "\\`").replaceAll("${", "\\${")
}
function renderRegistry(names: string[]) {
return `import type { DatabaseMigration } from "./migration"
export const migrations = (await Promise.all([
${names.map((name) => ` import("./migration/${name}"),`).join("\n")}
])).map((module) => module.default) satisfies DatabaseMigration.Migration[]
`
}
+89 -307
View File
@@ -1,319 +1,101 @@
import path from "path"
import { Effect, Layer, Option, Schema, Context, SynchronizedRef } from "effect"
import { Identifier } from "./util/identifier"
import { NonNegativeInt, withStatics } from "./schema"
import { Global } from "./global"
import { AppFileSystem } from "./filesystem"
import { EventV2 } from "./event"
export * as AccountV2 from "./account"
export const ID = Schema.String.pipe(
Schema.brand("AccountV2.ID"),
withStatics((schema) => ({ create: () => schema.make("acc_" + Identifier.ascending()) })),
)
export type ID = typeof ID.Type
import { Schema } from "effect"
import type * as HttpClientError from "effect/unstable/http/HttpClientError"
export const ServiceID = Schema.String.pipe(Schema.brand("ServiceID"))
export type ServiceID = typeof ServiceID.Type
export const ID = Schema.String.pipe(Schema.brand("AccountID"))
export type ID = Schema.Schema.Type<typeof ID>
export class OAuthCredential extends Schema.Class<OAuthCredential>("AccountV2.OAuthCredential")({
type: Schema.Literal("oauth"),
refresh: Schema.String,
access: Schema.String,
expires: NonNegativeInt,
}) {}
export const OrgID = Schema.String.pipe(Schema.brand("OrgID"))
export type OrgID = Schema.Schema.Type<typeof OrgID>
export class ApiKeyCredential extends Schema.Class<ApiKeyCredential>("AccountV2.ApiKeyCredential")({
type: Schema.Literal("api"),
key: Schema.String,
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
}) {}
export const AccessToken = Schema.String.pipe(Schema.brand("AccessToken"))
export type AccessToken = Schema.Schema.Type<typeof AccessToken>
export const Credential = Schema.Union([OAuthCredential, ApiKeyCredential])
.pipe(Schema.toTaggedUnion("type"))
.annotate({
identifier: "AccountV2.Credential",
})
export type Credential = Schema.Schema.Type<typeof Credential>
export const RefreshToken = Schema.String.pipe(Schema.brand("RefreshToken"))
export type RefreshToken = Schema.Schema.Type<typeof RefreshToken>
export class Info extends Schema.Class<Info>("AccountV2.Info")({
export const DeviceCode = Schema.String.pipe(Schema.brand("DeviceCode"))
export type DeviceCode = Schema.Schema.Type<typeof DeviceCode>
export const UserCode = Schema.String.pipe(Schema.brand("UserCode"))
export type UserCode = Schema.Schema.Type<typeof UserCode>
export class Info extends Schema.Class<Info>("Account")({
id: ID,
serviceID: ServiceID,
description: Schema.String,
credential: Credential,
email: Schema.String,
url: Schema.String,
active_org_id: Schema.NullOr(OrgID),
}) {}
export class FileWriteError extends Schema.TaggedErrorClass<FileWriteError>()("AccountV2.FileWriteError", {
operation: Schema.Union([Schema.Literal("migrate"), Schema.Literal("write")]),
export class Org extends Schema.Class<Org>("Org")({
id: OrgID,
name: Schema.String,
}) {}
export class AccountRepoError extends Schema.TaggedErrorClass<AccountRepoError>()("AccountRepoError", {
message: Schema.String,
cause: Schema.optional(Schema.Defect),
}) {}
export class AccountServiceError extends Schema.TaggedErrorClass<AccountServiceError>()("AccountServiceError", {
message: Schema.String,
cause: Schema.optional(Schema.Defect),
}) {}
export class AccountTransportError extends Schema.TaggedErrorClass<AccountTransportError>()("AccountTransportError", {
method: Schema.String,
url: Schema.String,
description: Schema.optional(Schema.String),
cause: Schema.optional(Schema.Defect),
}) {
static fromHttpClientError(error: HttpClientError.TransportError): AccountTransportError {
return new AccountTransportError({
method: error.request.method,
url: error.request.url,
description: error.description,
cause: error.cause,
})
}
override get message(): string {
return [
`Could not reach ${this.method} ${this.url}.`,
`This failed before the server returned an HTTP response.`,
this.description,
`Check your network, proxy, or VPN configuration and try again.`,
]
.filter(Boolean)
.join("\n")
}
}
export type AccountError = AccountRepoError | AccountServiceError | AccountTransportError
export class Login extends Schema.Class<Login>("Login")({
code: DeviceCode,
user: UserCode,
url: Schema.String,
server: Schema.String,
expiry: Schema.Duration,
interval: Schema.Duration,
}) {}
export class PollSuccess extends Schema.TaggedClass<PollSuccess>()("PollSuccess", {
email: Schema.String,
}) {}
export class PollPending extends Schema.TaggedClass<PollPending>()("PollPending", {}) {}
export class PollSlow extends Schema.TaggedClass<PollSlow>()("PollSlow", {}) {}
export class PollExpired extends Schema.TaggedClass<PollExpired>()("PollExpired", {}) {}
export class PollDenied extends Schema.TaggedClass<PollDenied>()("PollDenied", {}) {}
export class PollError extends Schema.TaggedClass<PollError>()("PollError", {
cause: Schema.Defect,
}) {}
export type Error = FileWriteError
export const Event = {
Added: EventV2.define({
type: "account.added",
schema: {
account: Info,
},
}),
Removed: EventV2.define({
type: "account.removed",
schema: {
account: Info,
},
}),
Switched: EventV2.define({
type: "account.switched",
schema: {
serviceID: ServiceID,
from: Schema.optional(ID),
to: Schema.optional(ID),
},
}),
}
interface Writable {
version: 2
accounts: Record<string, Info>
active: Record<string, ID>
}
const decodeV1 = Schema.decodeUnknownOption(Schema.Record(Schema.String, Credential))
function migrate(old: Record<string, unknown>): Writable {
const accounts: Record<string, Info> = {}
const active: Record<string, ID> = {}
for (const [serviceID, value] of Object.entries(old)) {
const decoded = Option.getOrElse(decodeV1({ [serviceID]: value }), () => ({}))
const parsed = (decoded as Record<string, Credential>)[serviceID]
if (!parsed) continue
const id = Identifier.ascending()
const account = ID.make(id)
const brandedServiceID = ServiceID.make(serviceID)
accounts[id] = new Info({
id: account,
serviceID: brandedServiceID,
description: "default",
credential: parsed,
})
active[brandedServiceID] = account
}
return { version: 2, accounts, active }
}
export interface Interface {
readonly get: (id: ID) => Effect.Effect<Info | undefined, Error>
readonly all: () => Effect.Effect<Info[], Error>
readonly create: (input: {
serviceID: ServiceID
credential: Credential
description?: string
}) => Effect.Effect<Info | undefined, Error>
readonly update: (id: ID, updates: Partial<Pick<Info, "description" | "credential">>) => Effect.Effect<void, Error>
readonly remove: (id: ID) => Effect.Effect<void, Error>
readonly activate: (id: ID) => Effect.Effect<void, Error>
readonly active: (serviceID: ServiceID) => Effect.Effect<Info | undefined, Error>
readonly forService: (serviceID: ServiceID) => Effect.Effect<Info[], Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Account") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fsys = yield* AppFileSystem.Service
const global = yield* Global.Service
const events = yield* EventV2.Service
const file = path.join(global.data, "account.json")
const legacyFile = path.join(global.data, "auth.json")
const writeMigrated = Effect.fnUntraced(function* (raw: Record<string, unknown>) {
const migrated = migrate(raw)
yield* fsys
.writeJson(file, migrated, 0o600)
.pipe(Effect.mapError((cause) => new FileWriteError({ operation: "migrate", cause })))
return migrated
})
const parseAuthContent = () => {
try {
return JSON.parse(process.env.OPENCODE_AUTH_CONTENT ?? "")
} catch {}
}
const load: () => Effect.Effect<Writable, Error> = Effect.fnUntraced(function* () {
if (process.env.OPENCODE_AUTH_CONTENT) {
const raw = parseAuthContent()
if (raw && typeof raw === "object") {
if ("version" in raw && raw.version === 2) return raw as Writable
return yield* writeMigrated(raw as Record<string, unknown>)
}
return { version: 2, accounts: {}, active: {} }
}
const legacy = yield* fsys.readJson(legacyFile).pipe(Effect.orElseSucceed(() => null))
if (legacy && typeof legacy === "object") return yield* writeMigrated(legacy as Record<string, unknown>)
const raw = yield* fsys.readJson(file).pipe(Effect.orElseSucceed(() => null))
if (raw && typeof raw === "object") {
if ("version" in raw && raw.version === 2) return raw as Writable
return yield* writeMigrated(raw as Record<string, unknown>)
}
return { version: 2, accounts: {}, active: {} }
})
const write = (data: Writable) =>
fsys
.writeJson(file, data, 0o600)
.pipe(Effect.mapError((cause) => new FileWriteError({ operation: "write", cause })))
const state = SynchronizedRef.makeUnsafe(
yield* load().pipe(Effect.orElseSucceed((): Writable => ({ version: 2, accounts: {}, active: {} }))),
)
const activate = Effect.fn("AccountV2.activate")(function* (id: ID) {
const data = yield* SynchronizedRef.get(state)
const account = data.accounts[id]
if (!account) return
const activated = yield* SynchronizedRef.modifyEffect(
state,
Effect.fnUntraced(function* (data) {
const nextAccount = data.accounts[id]
if (!nextAccount) return [undefined, data] as const
const next = { ...data, active: { ...data.active, [nextAccount.serviceID]: id } }
yield* write(next)
return [{ serviceID: nextAccount.serviceID, from: data.active[nextAccount.serviceID], to: id }, next] as const
}),
)
if (activated) yield* events.publish(Event.Switched, activated)
})
const result: Interface = {
get: Effect.fn("AccountV2.get")(function* (id) {
return (yield* SynchronizedRef.get(state)).accounts[id]
}),
all: Effect.fn("AccountV2.all")(function* () {
return Object.values((yield* SynchronizedRef.get(state)).accounts)
}),
active: Effect.fn("AccountV2.active")(function* (serviceID) {
const data = yield* SynchronizedRef.get(state)
return (
data.accounts[data.active[serviceID]] ?? Object.values(data.accounts).find((a) => a.serviceID === serviceID)
)
}),
forService: Effect.fn("AccountV2.list")(function* (serviceID) {
return Object.values((yield* SynchronizedRef.get(state)).accounts).filter((a) => a.serviceID === serviceID)
}),
create: Effect.fn("AccountV2.add")(function* (input) {
const id = ID.make(Identifier.ascending())
const account = new Info({
id,
serviceID: input.serviceID,
description: input.description ?? "default",
credential: input.credential,
})
const added = yield* SynchronizedRef.modifyEffect(
state,
Effect.fnUntraced(function* (data) {
const next = {
...data,
accounts: { ...data.accounts, [account.id]: account },
active: { ...data.active, [account.serviceID]: account.id },
}
yield* write(next)
return [
{
account,
switched: { serviceID: account.serviceID, from: data.active[account.serviceID], to: account.id },
},
next,
] as const
}),
)
yield* events.publish(Event.Added, { account: added.account })
yield* events.publish(Event.Switched, added.switched)
return added.account
}),
update: Effect.fn("AccountV2.update")(function* (id, updates) {
const existing = (yield* SynchronizedRef.get(state)).accounts[id]
if (!existing) return
yield* SynchronizedRef.modifyEffect(
state,
Effect.fnUntraced(function* (data) {
if (!data.accounts[id]) return [undefined, data] as const
const next = {
...data,
accounts: {
...data.accounts,
[id]: new Info({
id,
serviceID: existing.serviceID,
description: updates.description ?? existing.description,
credential: updates.credential ?? existing.credential,
}),
},
}
yield* write(next)
return [undefined, next] as const
}),
)
}),
remove: Effect.fn("AccountV2.remove")(function* (id) {
const removed = yield* SynchronizedRef.modifyEffect(
state,
Effect.fnUntraced(function* (data) {
const accounts = { ...data.accounts }
const active = { ...data.active }
const removed = accounts[id]
if (!removed) return [undefined, data] as const
const wasActive = active[removed.serviceID] === id
delete accounts[id]
const replacement = Object.values(accounts).find((account) => account.serviceID === removed.serviceID)
if (wasActive) {
if (replacement) active[removed.serviceID] = replacement.id
else delete active[removed.serviceID]
}
const next = { ...data, accounts, active }
yield* write(next)
return [
{
account: removed,
switched: wasActive ? { serviceID: removed.serviceID, from: id, to: replacement?.id } : undefined,
},
next,
] as const
}),
)
if (removed) {
yield* events.publish(Event.Removed, { account: removed.account })
if (removed.switched) yield* events.publish(Event.Switched, removed.switched)
}
}),
activate,
}
return Service.of(result)
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Global.defaultLayer),
Layer.provide(EventV2.defaultLayer),
)
export * as AccountV2 from "./account"
export const PollResult = Schema.Union([PollSuccess, PollPending, PollSlow, PollExpired, PollDenied, PollError])
export type PollResult = Schema.Schema.Type<typeof PollResult>
@@ -1,14 +1,14 @@
import { sqliteTable, text, integer, primaryKey } from "drizzle-orm/sqlite-core"
import { type AccessToken, type AccountID, type OrgID, type RefreshToken } from "./schema"
import { Timestamps } from "../storage/schema.sql"
import { AccountV2 } from "../account"
import { Timestamps } from "../database/schema.sql"
export const AccountTable = sqliteTable("account", {
id: text().$type<AccountID>().primaryKey(),
id: text().$type<AccountV2.ID>().primaryKey(),
email: text().notNull(),
url: text().notNull(),
access_token: text().$type<AccessToken>().notNull(),
refresh_token: text().$type<RefreshToken>().notNull(),
access_token: text().$type<AccountV2.AccessToken>().notNull(),
refresh_token: text().$type<AccountV2.RefreshToken>().notNull(),
token_expiry: integer(),
...Timestamps,
})
@@ -16,9 +16,9 @@ export const AccountTable = sqliteTable("account", {
export const AccountStateTable = sqliteTable("account_state", {
id: integer().primaryKey(),
active_account_id: text()
.$type<AccountID>()
.$type<AccountV2.ID>()
.references(() => AccountTable.id, { onDelete: "set null" }),
active_org_id: text().$type<OrgID>(),
active_org_id: text().$type<AccountV2.OrgID>(),
})
// LEGACY
@@ -27,8 +27,8 @@ export const ControlAccountTable = sqliteTable(
{
email: text().notNull(),
url: text().notNull(),
access_token: text().$type<AccessToken>().notNull(),
refresh_token: text().$type<RefreshToken>().notNull(),
access_token: text().$type<AccountV2.AccessToken>().notNull(),
refresh_token: text().$type<AccountV2.RefreshToken>().notNull(),
token_expiry: integer(),
active: integer({ mode: "boolean" })
.notNull()
+326
View File
@@ -0,0 +1,326 @@
export * as Auth from "./auth"
import path from "path"
import { Effect, Layer, Option, Schema, Context, SynchronizedRef } from "effect"
import { Identifier } from "./util/identifier"
import { NonNegativeInt, withStatics } from "./schema"
import { Global } from "./global"
import { AppFileSystem } from "./filesystem"
import { EventV2 } from "./event"
export const ID = Schema.String.pipe(
Schema.brand("Auth.ID"),
withStatics((schema) => ({ create: () => schema.make("acc_" + Identifier.ascending()) })),
)
export type ID = typeof ID.Type
export const ServiceID = Schema.String.pipe(Schema.brand("ServiceID"))
export type ServiceID = typeof ServiceID.Type
export const OrgID = Schema.String.pipe(Schema.brand("OrgID"))
export type OrgID = typeof OrgID.Type
export const AccessToken = Schema.String.pipe(Schema.brand("AccessToken"))
export type AccessToken = typeof AccessToken.Type
export const RefreshToken = Schema.String.pipe(Schema.brand("RefreshToken"))
export type RefreshToken = typeof RefreshToken.Type
export class OAuthCredential extends Schema.Class<OAuthCredential>("Auth.OAuthCredential")({
type: Schema.Literal("oauth"),
refresh: Schema.String,
access: Schema.String,
expires: NonNegativeInt,
}) {}
export class ApiKeyCredential extends Schema.Class<ApiKeyCredential>("Auth.ApiKeyCredential")({
type: Schema.Literal("api"),
key: Schema.String,
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
}) {}
export const Credential = Schema.Union([OAuthCredential, ApiKeyCredential])
.pipe(Schema.toTaggedUnion("type"))
.annotate({
identifier: "Auth.Credential",
})
export type Credential = Schema.Schema.Type<typeof Credential>
export class Info extends Schema.Class<Info>("Auth.Info")({
id: ID,
serviceID: ServiceID,
description: Schema.String,
credential: Credential,
}) {}
export class FileWriteError extends Schema.TaggedErrorClass<FileWriteError>()("Auth.FileWriteError", {
operation: Schema.Union([Schema.Literal("migrate"), Schema.Literal("write")]),
cause: Schema.Defect,
}) {}
export type Error = FileWriteError
export const Event = {
Added: EventV2.define({
type: "account.added",
schema: {
account: Info,
},
}),
Removed: EventV2.define({
type: "account.removed",
schema: {
account: Info,
},
}),
Switched: EventV2.define({
type: "account.switched",
schema: {
serviceID: ServiceID,
from: Schema.optional(ID),
to: Schema.optional(ID),
},
}),
}
interface Writable {
version: 2
accounts: Record<string, Info>
active: Record<string, ID>
}
const decodeV1 = Schema.decodeUnknownOption(Schema.Record(Schema.String, Credential))
function migrate(old: Record<string, unknown>): Writable {
const accounts: Record<string, Info> = {}
const active: Record<string, ID> = {}
for (const [serviceID, value] of Object.entries(old)) {
const decoded = Option.getOrElse(decodeV1({ [serviceID]: value }), () => ({}))
const parsed = (decoded as Record<string, Credential>)[serviceID]
if (!parsed) continue
const id = Identifier.ascending()
const account = ID.make(id)
const brandedServiceID = ServiceID.make(serviceID)
accounts[id] = new Info({
id: account,
serviceID: brandedServiceID,
description: "default",
credential: parsed,
})
active[brandedServiceID] = account
}
return { version: 2, accounts, active }
}
export interface Interface {
readonly get: (id: ID) => Effect.Effect<Info | undefined, Error>
readonly all: () => Effect.Effect<Info[], Error>
readonly create: (input: {
serviceID: ServiceID
credential: Credential
description?: string
}) => Effect.Effect<Info | undefined, Error>
readonly update: (id: ID, updates: Partial<Pick<Info, "description" | "credential">>) => Effect.Effect<void, Error>
readonly remove: (id: ID) => Effect.Effect<void, Error>
readonly activate: (id: ID) => Effect.Effect<void, Error>
readonly active: (serviceID: ServiceID) => Effect.Effect<Info | undefined, Error>
readonly forService: (serviceID: ServiceID) => Effect.Effect<Info[], Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Account") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fsys = yield* AppFileSystem.Service
const global = yield* Global.Service
const events = yield* EventV2.Service
const file = path.join(global.data, "account.json")
const legacyFile = path.join(global.data, "auth.json")
const writeMigrated = Effect.fnUntraced(function* (raw: Record<string, unknown>) {
const migrated = migrate(raw)
yield* fsys
.writeJson(file, migrated, 0o600)
.pipe(Effect.mapError((cause) => new FileWriteError({ operation: "migrate", cause })))
return migrated
})
const parseAuthContent = () => {
try {
return JSON.parse(process.env.OPENCODE_AUTH_CONTENT ?? "")
} catch {}
}
const load: () => Effect.Effect<Writable, Error> = Effect.fnUntraced(function* () {
if (process.env.OPENCODE_AUTH_CONTENT) {
const raw = parseAuthContent()
if (raw && typeof raw === "object") {
if ("version" in raw && raw.version === 2) return raw as Writable
return yield* writeMigrated(raw as Record<string, unknown>)
}
return { version: 2, accounts: {}, active: {} }
}
const legacy = yield* fsys.readJson(legacyFile).pipe(Effect.orElseSucceed(() => null))
if (legacy && typeof legacy === "object") return yield* writeMigrated(legacy as Record<string, unknown>)
const raw = yield* fsys.readJson(file).pipe(Effect.orElseSucceed(() => null))
if (raw && typeof raw === "object") {
if ("version" in raw && raw.version === 2) return raw as Writable
return yield* writeMigrated(raw as Record<string, unknown>)
}
return { version: 2, accounts: {}, active: {} }
})
const write = (data: Writable) =>
fsys
.writeJson(file, data, 0o600)
.pipe(Effect.mapError((cause) => new FileWriteError({ operation: "write", cause })))
const state = SynchronizedRef.makeUnsafe(
yield* load().pipe(Effect.orElseSucceed((): Writable => ({ version: 2, accounts: {}, active: {} }))),
)
const activate = Effect.fn("Auth.activate")(function* (id: ID) {
const data = yield* SynchronizedRef.get(state)
const account = data.accounts[id]
if (!account) return
const activated = yield* SynchronizedRef.modifyEffect(
state,
Effect.fnUntraced(function* (data) {
const nextAccount = data.accounts[id]
if (!nextAccount) return [undefined, data] as const
const next = { ...data, active: { ...data.active, [nextAccount.serviceID]: id } }
yield* write(next)
return [{ serviceID: nextAccount.serviceID, from: data.active[nextAccount.serviceID], to: id }, next] as const
}),
)
if (activated) yield* events.publish(Event.Switched, activated)
})
const result: Interface = {
get: Effect.fn("Auth.get")(function* (id) {
return (yield* SynchronizedRef.get(state)).accounts[id]
}),
all: Effect.fn("Auth.all")(function* () {
return Object.values((yield* SynchronizedRef.get(state)).accounts)
}),
active: Effect.fn("Auth.active")(function* (serviceID) {
const data = yield* SynchronizedRef.get(state)
return (
data.accounts[data.active[serviceID]] ?? Object.values(data.accounts).find((a) => a.serviceID === serviceID)
)
}),
forService: Effect.fn("Auth.list")(function* (serviceID) {
return Object.values((yield* SynchronizedRef.get(state)).accounts).filter((a) => a.serviceID === serviceID)
}),
create: Effect.fn("Auth.add")(function* (input) {
const id = ID.make(Identifier.ascending())
const account = new Info({
id,
serviceID: input.serviceID,
description: input.description ?? "default",
credential: input.credential,
})
const added = yield* SynchronizedRef.modifyEffect(
state,
Effect.fnUntraced(function* (data) {
const next = {
...data,
accounts: { ...data.accounts, [account.id]: account },
active: { ...data.active, [account.serviceID]: account.id },
}
yield* write(next)
return [
{
account,
switched: { serviceID: account.serviceID, from: data.active[account.serviceID], to: account.id },
},
next,
] as const
}),
)
yield* events.publish(Event.Added, { account: added.account })
yield* events.publish(Event.Switched, added.switched)
return added.account
}),
update: Effect.fn("Auth.update")(function* (id, updates) {
const existing = (yield* SynchronizedRef.get(state)).accounts[id]
if (!existing) return
yield* SynchronizedRef.modifyEffect(
state,
Effect.fnUntraced(function* (data) {
if (!data.accounts[id]) return [undefined, data] as const
const next = {
...data,
accounts: {
...data.accounts,
[id]: new Info({
id,
serviceID: existing.serviceID,
description: updates.description ?? existing.description,
credential: updates.credential ?? existing.credential,
}),
},
}
yield* write(next)
return [undefined, next] as const
}),
)
}),
remove: Effect.fn("Auth.remove")(function* (id) {
const removed = yield* SynchronizedRef.modifyEffect(
state,
Effect.fnUntraced(function* (data) {
const accounts = { ...data.accounts }
const active = { ...data.active }
const removed = accounts[id]
if (!removed) return [undefined, data] as const
const wasActive = active[removed.serviceID] === id
delete accounts[id]
const replacement = Object.values(accounts).find((account) => account.serviceID === removed.serviceID)
if (wasActive) {
if (replacement) active[removed.serviceID] = replacement.id
else delete active[removed.serviceID]
}
const next = { ...data, accounts, active }
yield* write(next)
return [
{
account: removed,
switched: wasActive ? { serviceID: removed.serviceID, from: id, to: replacement?.id } : undefined,
},
next,
] as const
}),
)
if (removed) {
yield* events.publish(Event.Removed, { account: removed.account })
if (removed.switched) yield* events.publish(Event.Switched, removed.switched)
}
}),
activate,
}
return Service.of(result)
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Global.defaultLayer),
Layer.provide(EventV2.defaultLayer),
)
@@ -1,17 +1,17 @@
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"
import { ProjectTable } from "../project/project.sql"
import type { ProjectID } from "../project/schema"
import type { WorkspaceID } from "./schema"
import { ProjectTable } from "../project/sql"
import { ProjectV2 } from "../project"
import { WorkspaceV2 } from "../workspace"
export const WorkspaceTable = sqliteTable("workspace", {
id: text().$type<WorkspaceID>().primaryKey(),
id: text().$type<WorkspaceV2.ID>().primaryKey(),
type: text().notNull(),
name: text().notNull().default(""),
branch: text(),
directory: text(),
extra: text({ mode: "json" }),
project_id: text()
.$type<ProjectID>()
.$type<ProjectV2.ID>()
.notNull()
.references(() => ProjectTable.id, { onDelete: "cascade" }),
time_used: integer()
+60
View File
@@ -0,0 +1,60 @@
export * as Database from "./database"
import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
import { layer as sqliteLayer } from "#sqlite"
import { Context, Effect, Layer } from "effect"
import { Global } from "../global"
import { Flag } from "../flag/flag"
import { isAbsolute, join } from "path"
import { DatabaseMigration } from "./migration"
import { InstallationChannel } from "../installation/version"
const makeDatabase = EffectDrizzleSqlite.makeWithDefaults()
type DatabaseShape = Effect.Success<typeof makeDatabase>
export interface Interface {
db: DatabaseShape
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/storage/Database") {}
export 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)")
yield* DatabaseMigration.apply(db)
return { db }
}).pipe(Effect.orDie),
)
export function layerFromPath(filename: string) {
return layer.pipe(Layer.provide(sqliteLayer({ filename })))
}
export function path() {
if (Flag.OPENCODE_DB) {
if (Flag.OPENCODE_DB === ":memory:" || isAbsolute(Flag.OPENCODE_DB)) return Flag.OPENCODE_DB
return join(Global.Path.data, Flag.OPENCODE_DB)
}
if (
["latest", "beta", "prod"].includes(InstallationChannel) ||
process.env.OPENCODE_DISABLE_CHANNEL_DB === "1" ||
process.env.OPENCODE_DISABLE_CHANNEL_DB === "true"
)
return join(Global.Path.data, "opencode.db")
return join(Global.Path.data, `opencode-${InstallationChannel.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`)
}
export const defaultLayer = Layer.unwrap(
Effect.gen(function* () {
return layerFromPath(path())
}),
).pipe(Layer.provide(Global.defaultLayer))
@@ -0,0 +1,24 @@
import type { DatabaseMigration } from "./migration"
export const migrations = (await Promise.all([
import("./migration/20260127222353_familiar_lady_ursula"),
import("./migration/20260211171708_add_project_commands"),
import("./migration/20260213144116_wakeful_the_professor"),
import("./migration/20260225215848_workspace"),
import("./migration/20260227213759_add_session_workspace_id"),
import("./migration/20260228203230_blue_harpoon"),
import("./migration/20260303231226_add_workspace_fields"),
import("./migration/20260309230000_move_org_to_state"),
import("./migration/20260312043431_session_message_cursor"),
import("./migration/20260323234822_events"),
import("./migration/20260410174513_workspace-name"),
import("./migration/20260413175956_chief_energizer"),
import("./migration/20260423070820_add_icon_url_override"),
import("./migration/20260427172553_slow_nightmare"),
import("./migration/20260428004200_add_session_path"),
import("./migration/20260501142318_next_venus"),
import("./migration/20260504145000_add_sync_owner"),
import("./migration/20260507164347_add_workspace_time"),
import("./migration/20260510033149_session_usage"),
import("./migration/20260511000411_data_migration_state"),
])).map((module) => module.default) satisfies DatabaseMigration.Migration[]
+58
View File
@@ -0,0 +1,58 @@
export * as DatabaseMigration from "./migration"
import { sql } from "drizzle-orm"
import { Effect } from "effect"
import type { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
import { migrations } from "./migration.gen"
type Database = EffectDrizzleSqlite.EffectSQLiteDatabase
type Transaction = Parameters<Parameters<Database["transaction"]>[0]>[0]
export type Migration = {
id: string
up: (tx: Transaction) => Effect.Effect<void, unknown>
}
export function apply(db: Database) {
return applyOnly(db, migrations)
}
export function applyOnly(db: Database, input: Migration[]) {
return Effect.gen(function* () {
yield* db.run(
sql`CREATE TABLE IF NOT EXISTS ${sql.identifier("migration")} (id TEXT PRIMARY KEY, time_completed INTEGER NOT NULL)`,
)
let completed = new Set(
(yield* db.all<{ id: string }>(sql`SELECT id FROM ${sql.identifier("migration")}`)).map((row) => row.id),
)
if (completed.size === 0) {
// Existing installs used Drizzle's migration journal. Seed the new
// journal once so TypeScript migrations don't replay old SQL.
if (
yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ${"__drizzle_migrations"}`)
) {
yield* db.run(sql`
INSERT OR IGNORE INTO ${sql.identifier("migration")} (id, time_completed)
SELECT name, ${Date.now()}
FROM ${sql.identifier("__drizzle_migrations")}
WHERE name IS NOT NULL
`)
completed = new Set(
(yield* db.all<{ id: string }>(sql`SELECT id FROM ${sql.identifier("migration")}`)).map((row) => row.id),
)
}
}
for (const migration of input) {
if (completed.has(migration.id)) continue
yield* db.transaction((tx) =>
Effect.gen(function* () {
yield* migration.up(tx)
yield* tx.run(
sql`INSERT INTO ${sql.identifier("migration")} (id, time_completed) VALUES (${migration.id}, ${Date.now()})`,
)
}),
)
}
})
}
@@ -0,0 +1,107 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260127222353_familiar_lady_ursula",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`
CREATE TABLE \`project\` (
\`id\` text PRIMARY KEY,
\`worktree\` text NOT NULL,
\`vcs\` text,
\`name\` text,
\`icon_url\` text,
\`icon_color\` text,
\`time_created\` integer NOT NULL,
\`time_updated\` integer NOT NULL,
\`time_initialized\` integer,
\`sandboxes\` text NOT NULL
);
`)
yield* tx.run(`
CREATE TABLE \`message\` (
\`id\` text PRIMARY KEY,
\`session_id\` text NOT NULL,
\`time_created\` integer NOT NULL,
\`time_updated\` integer NOT NULL,
\`data\` text NOT NULL,
CONSTRAINT \`fk_message_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(`
CREATE TABLE \`part\` (
\`id\` text PRIMARY KEY,
\`message_id\` text NOT NULL,
\`session_id\` text NOT NULL,
\`time_created\` integer NOT NULL,
\`time_updated\` integer NOT NULL,
\`data\` text NOT NULL,
CONSTRAINT \`fk_part_message_id_message_id_fk\` FOREIGN KEY (\`message_id\`) REFERENCES \`message\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(`
CREATE TABLE \`permission\` (
\`project_id\` text PRIMARY KEY,
\`time_created\` integer NOT NULL,
\`time_updated\` integer NOT NULL,
\`data\` text NOT NULL,
CONSTRAINT \`fk_permission_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(`
CREATE TABLE \`session\` (
\`id\` text PRIMARY KEY,
\`project_id\` text NOT NULL,
\`parent_id\` text,
\`slug\` text NOT NULL,
\`directory\` text NOT NULL,
\`title\` text NOT NULL,
\`version\` text NOT NULL,
\`share_url\` text,
\`summary_additions\` integer,
\`summary_deletions\` integer,
\`summary_files\` integer,
\`summary_diffs\` text,
\`revert\` text,
\`permission\` text,
\`time_created\` integer NOT NULL,
\`time_updated\` integer NOT NULL,
\`time_compacting\` integer,
\`time_archived\` integer,
CONSTRAINT \`fk_session_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(`
CREATE TABLE \`todo\` (
\`session_id\` text NOT NULL,
\`content\` text NOT NULL,
\`status\` text NOT NULL,
\`priority\` text NOT NULL,
\`position\` integer NOT NULL,
\`time_created\` integer NOT NULL,
\`time_updated\` integer NOT NULL,
CONSTRAINT \`todo_pk\` PRIMARY KEY(\`session_id\`, \`position\`),
CONSTRAINT \`fk_todo_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(`
CREATE TABLE \`session_share\` (
\`session_id\` text PRIMARY KEY,
\`id\` text NOT NULL,
\`secret\` text NOT NULL,
\`url\` text NOT NULL,
\`time_created\` integer NOT NULL,
\`time_updated\` integer NOT NULL,
CONSTRAINT \`fk_session_share_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(`CREATE INDEX \`message_session_idx\` ON \`message\` (\`session_id\`);`)
yield* tx.run(`CREATE INDEX \`part_message_idx\` ON \`part\` (\`message_id\`);`)
yield* tx.run(`CREATE INDEX \`part_session_idx\` ON \`part\` (\`session_id\`);`)
yield* tx.run(`CREATE INDEX \`session_project_idx\` ON \`session\` (\`project_id\`);`)
yield* tx.run(`CREATE INDEX \`session_parent_idx\` ON \`session\` (\`parent_id\`);`)
yield* tx.run(`CREATE INDEX \`todo_session_idx\` ON \`todo\` (\`session_id\`);`)
})
},
} satisfies DatabaseMigration.Migration
@@ -0,0 +1,11 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260211171708_add_project_commands",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`ALTER TABLE \`project\` ADD \`commands\` text;`)
})
},
} satisfies DatabaseMigration.Migration
@@ -0,0 +1,23 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260213144116_wakeful_the_professor",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`
CREATE TABLE \`control_account\` (
\`email\` text NOT NULL,
\`url\` text NOT NULL,
\`access_token\` text NOT NULL,
\`refresh_token\` text NOT NULL,
\`token_expiry\` integer,
\`active\` integer NOT NULL,
\`time_created\` integer NOT NULL,
\`time_updated\` integer NOT NULL,
CONSTRAINT \`control_account_pk\` PRIMARY KEY(\`email\`, \`url\`)
);
`)
})
},
} satisfies DatabaseMigration.Migration
@@ -0,0 +1,19 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260225215848_workspace",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`
CREATE TABLE \`workspace\` (
\`id\` text PRIMARY KEY,
\`branch\` text,
\`project_id\` text NOT NULL,
\`config\` text NOT NULL,
CONSTRAINT \`fk_workspace_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE
);
`)
})
},
} satisfies DatabaseMigration.Migration
@@ -0,0 +1,12 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260227213759_add_session_workspace_id",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`ALTER TABLE \`session\` ADD \`workspace_id\` text;`)
yield* tx.run(`CREATE INDEX \`session_workspace_idx\` ON \`session\` (\`workspace_id\`);`)
})
},
} satisfies DatabaseMigration.Migration
@@ -0,0 +1,30 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260228203230_blue_harpoon",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`
CREATE TABLE \`account\` (
\`id\` text PRIMARY KEY,
\`email\` text NOT NULL,
\`url\` text NOT NULL,
\`access_token\` text NOT NULL,
\`refresh_token\` text NOT NULL,
\`token_expiry\` integer,
\`selected_org_id\` text,
\`time_created\` integer NOT NULL,
\`time_updated\` integer NOT NULL
);
`)
yield* tx.run(`
CREATE TABLE \`account_state\` (
\`id\` integer PRIMARY KEY NOT NULL,
\`active_account_id\` text,
FOREIGN KEY (\`active_account_id\`) REFERENCES \`account\`(\`id\`) ON UPDATE no action ON DELETE set null
);
`)
})
},
} satisfies DatabaseMigration.Migration
@@ -0,0 +1,15 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260303231226_add_workspace_fields",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`ALTER TABLE \`workspace\` ADD \`type\` text NOT NULL;`)
yield* tx.run(`ALTER TABLE \`workspace\` ADD \`name\` text;`)
yield* tx.run(`ALTER TABLE \`workspace\` ADD \`directory\` text;`)
yield* tx.run(`ALTER TABLE \`workspace\` ADD \`extra\` text;`)
yield* tx.run(`ALTER TABLE \`workspace\` DROP COLUMN \`config\`;`)
})
},
} satisfies DatabaseMigration.Migration
@@ -0,0 +1,13 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260309230000_move_org_to_state",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`ALTER TABLE \`account_state\` ADD \`active_org_id\` text;`)
yield* tx.run(`UPDATE \`account_state\` SET \`active_org_id\` = (SELECT \`selected_org_id\` FROM \`account\` WHERE \`account\`.\`id\` = \`account_state\`.\`active_account_id\`);`)
yield* tx.run(`ALTER TABLE \`account\` DROP COLUMN \`selected_org_id\`;`)
})
},
} satisfies DatabaseMigration.Migration
@@ -0,0 +1,14 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260312043431_session_message_cursor",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`DROP INDEX IF EXISTS \`message_session_idx\`;`)
yield* tx.run(`DROP INDEX IF EXISTS \`part_message_idx\`;`)
yield* tx.run(`CREATE INDEX \`message_session_time_created_id_idx\` ON \`message\` (\`session_id\`,\`time_created\`,\`id\`);`)
yield* tx.run(`CREATE INDEX \`part_message_id_id_idx\` ON \`part\` (\`message_id\`,\`id\`);`)
})
},
} satisfies DatabaseMigration.Migration
@@ -0,0 +1,26 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260323234822_events",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`
CREATE TABLE \`event_sequence\` (
\`aggregate_id\` text PRIMARY KEY,
\`seq\` integer NOT NULL
);
`)
yield* tx.run(`
CREATE TABLE \`event\` (
\`id\` text PRIMARY KEY,
\`aggregate_id\` text NOT NULL,
\`seq\` integer NOT NULL,
\`type\` text NOT NULL,
\`data\` text NOT NULL,
CONSTRAINT \`fk_event_aggregate_id_event_sequence_aggregate_id_fk\` FOREIGN KEY (\`aggregate_id\`) REFERENCES \`event_sequence\`(\`aggregate_id\`) ON DELETE CASCADE
);
`)
})
},
} satisfies DatabaseMigration.Migration
@@ -0,0 +1,27 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260410174513_workspace-name",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`PRAGMA foreign_keys=OFF;`)
yield* tx.run(`
CREATE TABLE \`__new_workspace\` (
\`id\` text PRIMARY KEY,
\`type\` text NOT NULL,
\`name\` text DEFAULT '' NOT NULL,
\`branch\` text,
\`directory\` text,
\`extra\` text,
\`project_id\` text NOT NULL,
CONSTRAINT \`fk_workspace_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(`INSERT INTO \`__new_workspace\`(\`id\`, \`type\`, \`branch\`, \`name\`, \`directory\`, \`extra\`, \`project_id\`) SELECT \`id\`, \`type\`, \`branch\`, \`name\`, \`directory\`, \`extra\`, \`project_id\` FROM \`workspace\`;`)
yield* tx.run(`DROP TABLE \`workspace\`;`)
yield* tx.run(`ALTER TABLE \`__new_workspace\` RENAME TO \`workspace\`;`)
yield* tx.run(`PRAGMA foreign_keys=ON;`)
})
},
} satisfies DatabaseMigration.Migration
@@ -0,0 +1,24 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260413175956_chief_energizer",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`
CREATE TABLE \`session_entry\` (
\`id\` text PRIMARY KEY,
\`session_id\` text NOT NULL,
\`type\` text NOT NULL,
\`time_created\` integer NOT NULL,
\`time_updated\` integer NOT NULL,
\`data\` text NOT NULL,
CONSTRAINT \`fk_session_entry_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(`CREATE INDEX \`session_entry_session_idx\` ON \`session_entry\` (\`session_id\`);`)
yield* tx.run(`CREATE INDEX \`session_entry_session_type_idx\` ON \`session_entry\` (\`session_id\`,\`type\`);`)
yield* tx.run(`CREATE INDEX \`session_entry_time_created_idx\` ON \`session_entry\` (\`time_created\`);`)
})
},
} satisfies DatabaseMigration.Migration
@@ -0,0 +1,14 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260423070820_add_icon_url_override",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`
ALTER TABLE \`project\` ADD \`icon_url_override\` text;
UPDATE \`project\` SET \`icon_url_override\` = \`icon_url\` WHERE \`icon_url\` IS NOT NULL;
`)
})
},
} satisfies DatabaseMigration.Migration
@@ -0,0 +1,28 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260427172553_slow_nightmare",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`
CREATE TABLE \`session_message\` (
\`id\` text PRIMARY KEY,
\`session_id\` text NOT NULL,
\`type\` text NOT NULL,
\`time_created\` integer NOT NULL,
\`time_updated\` integer NOT NULL,
\`data\` text NOT NULL,
CONSTRAINT \`fk_session_message_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(`DROP INDEX IF EXISTS \`session_entry_session_idx\`;`)
yield* tx.run(`DROP INDEX IF EXISTS \`session_entry_session_type_idx\`;`)
yield* tx.run(`DROP INDEX IF EXISTS \`session_entry_time_created_idx\`;`)
yield* tx.run(`CREATE INDEX \`session_message_session_idx\` ON \`session_message\` (\`session_id\`);`)
yield* tx.run(`CREATE INDEX \`session_message_session_type_idx\` ON \`session_message\` (\`session_id\`,\`type\`);`)
yield* tx.run(`CREATE INDEX \`session_message_time_created_idx\` ON \`session_message\` (\`time_created\`);`)
yield* tx.run(`DROP TABLE \`session_entry\`;`)
})
},
} satisfies DatabaseMigration.Migration
@@ -0,0 +1,11 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260428004200_add_session_path",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`ALTER TABLE \`session\` ADD \`path\` text;`)
})
},
} satisfies DatabaseMigration.Migration
@@ -0,0 +1,12 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260501142318_next_venus",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`ALTER TABLE \`session\` ADD \`agent\` text;`)
yield* tx.run(`ALTER TABLE \`session\` ADD \`model\` text;`)
})
},
} satisfies DatabaseMigration.Migration
@@ -0,0 +1,11 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260504145000_add_sync_owner",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`ALTER TABLE \`event_sequence\` ADD \`owner_id\` text;`)
})
},
} satisfies DatabaseMigration.Migration
@@ -0,0 +1,11 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260507164347_add_workspace_time",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`ALTER TABLE \`workspace\` ADD \`time_used\` integer NOT NULL DEFAULT 0;`)
})
},
} satisfies DatabaseMigration.Migration
@@ -0,0 +1,56 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260510033149_session_usage",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`ALTER TABLE \`session\` ADD \`cost\` real DEFAULT 0 NOT NULL;`)
yield* tx.run(`ALTER TABLE \`session\` ADD \`tokens_input\` integer DEFAULT 0 NOT NULL;`)
yield* tx.run(`ALTER TABLE \`session\` ADD \`tokens_output\` integer DEFAULT 0 NOT NULL;`)
yield* tx.run(`ALTER TABLE \`session\` ADD \`tokens_reasoning\` integer DEFAULT 0 NOT NULL;`)
yield* tx.run(`ALTER TABLE \`session\` ADD \`tokens_cache_read\` integer DEFAULT 0 NOT NULL;`)
yield* tx.run(`ALTER TABLE \`session\` ADD \`tokens_cache_write\` integer DEFAULT 0 NOT NULL;`)
yield* tx.run(`
UPDATE session
SET
cost = coalesce((
SELECT sum(coalesce(json_extract(message.data, '$.cost'), 0))
FROM message
WHERE message.session_id = session.id
AND json_extract(message.data, '$.role') = 'assistant'
), 0),
tokens_input = coalesce((
SELECT sum(coalesce(json_extract(message.data, '$.tokens.input'), 0))
FROM message
WHERE message.session_id = session.id
AND json_extract(message.data, '$.role') = 'assistant'
), 0),
tokens_output = coalesce((
SELECT sum(coalesce(json_extract(message.data, '$.tokens.output'), 0))
FROM message
WHERE message.session_id = session.id
AND json_extract(message.data, '$.role') = 'assistant'
), 0),
tokens_reasoning = coalesce((
SELECT sum(coalesce(json_extract(message.data, '$.tokens.reasoning'), 0))
FROM message
WHERE message.session_id = session.id
AND json_extract(message.data, '$.role') = 'assistant'
), 0),
tokens_cache_read = coalesce((
SELECT sum(coalesce(json_extract(message.data, '$.tokens.cache.read'), 0))
FROM message
WHERE message.session_id = session.id
AND json_extract(message.data, '$.role') = 'assistant'
), 0),
tokens_cache_write = coalesce((
SELECT sum(coalesce(json_extract(message.data, '$.tokens.cache.write'), 0))
FROM message
WHERE message.session_id = session.id
AND json_extract(message.data, '$.role') = 'assistant'
), 0)
`)
})
},
} satisfies DatabaseMigration.Migration
@@ -0,0 +1,16 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260511000411_data_migration_state",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`
CREATE TABLE \`data_migration\` (
\`name\` text PRIMARY KEY,
\`time_completed\` integer NOT NULL
);
`)
})
},
} satisfies DatabaseMigration.Migration
+177
View File
@@ -0,0 +1,177 @@
import { Database } from "bun:sqlite"
import { drizzle } from "drizzle-orm/bun-sqlite"
import * as Context from "effect/Context"
import * as Effect from "effect/Effect"
import * as Fiber from "effect/Fiber"
import { identity } from "effect/Function"
import * as Layer from "effect/Layer"
import * as Scope from "effect/Scope"
import * as Semaphore from "effect/Semaphore"
import * as Stream from "effect/Stream"
import * as Reactivity from "effect/unstable/reactivity/Reactivity"
import * as Client from "effect/unstable/sql/SqlClient"
import type { Connection } from "effect/unstable/sql/SqlConnection"
import { classifySqliteError, SqlError } from "effect/unstable/sql/SqlError"
import * as Statement from "effect/unstable/sql/Statement"
import { Sqlite } from "./sqlite"
const ATTR_DB_SYSTEM_NAME = "db.system.name"
const TypeId = "~@opencode-ai/core/database/SqliteBun" as const
type TypeId = typeof TypeId
interface SqliteClient extends Client.SqlClient {
readonly [TypeId]: TypeId
readonly config: Config
readonly export: Effect.Effect<Uint8Array, SqlError>
readonly loadExtension: (path: string) => Effect.Effect<void, SqlError>
readonly updateValues: never
}
interface Config {
readonly filename: string
readonly readonly?: boolean
readonly create?: boolean
readonly readwrite?: boolean
readonly disableWAL?: boolean
readonly spanAttributes?: Record<string, unknown>
readonly transformResultNames?: (str: string) => string
readonly transformQueryNames?: (str: string) => string
}
interface SqliteConnection extends Connection {
readonly export: Effect.Effect<Uint8Array, SqlError>
readonly loadExtension: (path: string) => Effect.Effect<void, SqlError>
}
const make = (options: Config) =>
Effect.gen(function* () {
const native = (yield* Sqlite.Native) as Database
const compiler = Statement.makeCompilerSqlite(options.transformQueryNames)
const transformRows = options.transformResultNames ? Statement.defaultTransforms(options.transformResultNames).array : undefined
const run = (query: string, params: ReadonlyArray<unknown> = []) =>
Effect.withFiber<Array<Record<string, unknown>>, SqlError>((fiber) => {
const statement = native.query(query)
// @ts-ignore bun-types missing safeIntegers method, fixed in https://github.com/oven-sh/bun/pull/26627
statement.safeIntegers(Context.get(fiber.context, Client.SafeIntegers))
try {
return Effect.succeed((statement.all(...(params as any)) ?? []) as Array<Record<string, unknown>>)
} catch (cause) {
return Effect.fail(
new SqlError({
reason: classifySqliteError(cause, { message: "Failed to execute statement", operation: "execute" }),
}),
)
}
})
const runValues = (query: string, params: ReadonlyArray<unknown> = []) =>
Effect.withFiber<Array<unknown[]>, SqlError>((fiber) => {
const statement = native.query(query)
// @ts-ignore bun-types missing safeIntegers method, fixed in https://github.com/oven-sh/bun/pull/26627
statement.safeIntegers(Context.get(fiber.context, Client.SafeIntegers))
try {
return Effect.succeed((statement.values(...(params as any)) ?? []) as Array<unknown[]>)
} catch (cause) {
return Effect.fail(
new SqlError({
reason: classifySqliteError(cause, { message: "Failed to execute statement", operation: "execute" }),
}),
)
}
})
const connection = identity<SqliteConnection>({
execute(query, params, transformRows) {
return transformRows ? Effect.map(run(query, params), transformRows) : run(query, params)
},
executeRaw(query, params) {
return run(query, params)
},
executeValues(query, params) {
return runValues(query, params)
},
executeUnprepared(query, params, transformRows) {
return this.execute(query, params, transformRows)
},
executeStream() {
return Stream.die("executeStream not implemented")
},
export: Effect.try({
try: () => native.serialize(),
catch: (cause) =>
new SqlError({ reason: classifySqliteError(cause, { message: "Failed to export database", operation: "export" }) }),
}),
loadExtension: (path) =>
Effect.try({
try: () => native.loadExtension(path),
catch: (cause) =>
new SqlError({
reason: classifySqliteError(cause, { message: "Failed to load extension", operation: "loadExtension" }),
}),
}),
})
const semaphore = yield* Semaphore.make(1)
const acquirer = semaphore.withPermits(1)(Effect.succeed(connection))
const transactionAcquirer = Effect.uninterruptibleMask((restore) => {
const fiber = Fiber.getCurrent()!
const scope = Context.getUnsafe(fiber.context, Scope.Scope)
return Effect.as(Effect.tap(restore(semaphore.take(1)), () => Scope.addFinalizer(scope, semaphore.release(1))), connection)
})
const client = Object.assign(
(yield* Client.make({
acquirer,
compiler,
transactionAcquirer,
spanAttributes: [
...(options.spanAttributes ? Object.entries(options.spanAttributes) : []),
[ATTR_DB_SYSTEM_NAME, "sqlite"],
],
transformRows,
})) as SqliteClient,
{
[TypeId]: TypeId,
config: options,
export: Effect.flatMap(acquirer, (_) => _.export),
loadExtension: (path: string) => Effect.flatMap(acquirer, (_) => _.loadExtension(path)),
},
)
return client
})
const nativeLayer = (config: Config) =>
Layer.effect(
Sqlite.Native,
Effect.gen(function* () {
const native = new Database(config.filename, {
readonly: config.readonly,
readwrite: config.readwrite ?? true,
create: config.create ?? true,
})
yield* Effect.addFinalizer(() => Effect.sync(() => native.close()))
if (config.disableWAL !== true) native.run("PRAGMA journal_mode = WAL;")
return native
}),
)
const sqliteLayer = (config: Config) => Layer.effect(Client.SqlClient, make(config))
const drizzleLayer = Layer.effect(
Sqlite.Drizzle,
Effect.gen(function* () {
return drizzle({ client: (yield* Sqlite.Native) as Database })
}),
)
export const layer = (config: Config) =>
Layer.merge(
nativeLayer(config),
Layer.merge(sqliteLayer(config), drizzleLayer).pipe(Layer.provide(nativeLayer(config))),
).pipe(
Layer.provide(Reactivity.layer),
)
+172
View File
@@ -0,0 +1,172 @@
import { DatabaseSync, type SQLInputValue } from "node:sqlite"
import { drizzle } from "drizzle-orm/node-sqlite"
import * as Context from "effect/Context"
import * as Effect from "effect/Effect"
import * as Fiber from "effect/Fiber"
import { identity } from "effect/Function"
import * as Layer from "effect/Layer"
import * as Scope from "effect/Scope"
import * as Semaphore from "effect/Semaphore"
import * as Stream from "effect/Stream"
import * as Reactivity from "effect/unstable/reactivity/Reactivity"
import * as Client from "effect/unstable/sql/SqlClient"
import type { Connection } from "effect/unstable/sql/SqlConnection"
import { classifySqliteError, SqlError } from "effect/unstable/sql/SqlError"
import * as Statement from "effect/unstable/sql/Statement"
import { Sqlite } from "./sqlite"
const ATTR_DB_SYSTEM_NAME = "db.system.name"
const TypeId = "~@opencode-ai/core/database/SqliteNode" as const
type TypeId = typeof TypeId
interface SqliteClient extends Client.SqlClient {
readonly [TypeId]: TypeId
readonly config: Config
readonly loadExtension: (path: string) => Effect.Effect<void, SqlError>
readonly updateValues: never
}
interface Config {
readonly filename: string
readonly readonly?: boolean
readonly create?: boolean
readonly readwrite?: boolean
readonly disableWAL?: boolean
readonly timeout?: number
readonly allowExtension?: boolean
readonly spanAttributes?: Record<string, unknown>
readonly transformResultNames?: (str: string) => string
readonly transformQueryNames?: (str: string) => string
}
interface SqliteConnection extends Connection {
readonly loadExtension: (path: string) => Effect.Effect<void, SqlError>
}
const make = (options: Config) =>
Effect.gen(function* () {
const native = (yield* Sqlite.Native) as DatabaseSync
const compiler = Statement.makeCompilerSqlite(options.transformQueryNames)
const transformRows = options.transformResultNames ? Statement.defaultTransforms(options.transformResultNames).array : undefined
const run = (query: string, params: ReadonlyArray<unknown> = []) =>
Effect.withFiber<Array<Record<string, unknown>>, SqlError>((fiber) => {
const statement = native.prepare(query)
statement.setReadBigInts(Context.get(fiber.context, Client.SafeIntegers))
try {
return Effect.succeed(statement.all(...(params as SQLInputValue[])) as Array<Record<string, unknown>>)
} catch (cause) {
return Effect.fail(
new SqlError({
reason: classifySqliteError(cause, { message: "Failed to execute statement", operation: "execute" }),
}),
)
}
})
const runValues = (query: string, params: ReadonlyArray<unknown> = []) =>
Effect.withFiber<ReadonlyArray<ReadonlyArray<unknown>>, SqlError>((fiber) => {
const statement = native.prepare(query)
statement.setReadBigInts(Context.get(fiber.context, Client.SafeIntegers))
statement.setReturnArrays(true)
try {
return Effect.succeed(statement.all(...(params as SQLInputValue[])) as unknown as ReadonlyArray<ReadonlyArray<unknown>>)
} catch (cause) {
return Effect.fail(
new SqlError({
reason: classifySqliteError(cause, { message: "Failed to execute statement", operation: "execute" }),
}),
)
}
})
const connection = identity<SqliteConnection>({
execute(query, params, transformRows) {
return transformRows ? Effect.map(run(query, params), transformRows) : run(query, params)
},
executeRaw(query, params) {
return run(query, params)
},
executeValues(query, params) {
return runValues(query, params)
},
executeUnprepared(query, params, transformRows) {
return this.execute(query, params, transformRows)
},
executeStream() {
return Stream.die("executeStream not implemented")
},
loadExtension: (path) =>
Effect.try({
try: () => native.loadExtension(path),
catch: (cause) =>
new SqlError({
reason: classifySqliteError(cause, { message: "Failed to load extension", operation: "loadExtension" }),
}),
}),
})
const semaphore = yield* Semaphore.make(1)
const acquirer = semaphore.withPermits(1)(Effect.succeed(connection))
const transactionAcquirer = Effect.uninterruptibleMask((restore) => {
const fiber = Fiber.getCurrent()!
const scope = Context.getUnsafe(fiber.context, Scope.Scope)
return Effect.as(Effect.tap(restore(semaphore.take(1)), () => Scope.addFinalizer(scope, semaphore.release(1))), connection)
})
const client = Object.assign(
(yield* Client.make({
acquirer,
compiler,
transactionAcquirer,
spanAttributes: [
...(options.spanAttributes ? Object.entries(options.spanAttributes) : []),
[ATTR_DB_SYSTEM_NAME, "sqlite"],
],
transformRows,
})) as SqliteClient,
{
[TypeId]: TypeId,
config: options,
loadExtension: (path: string) => Effect.flatMap(acquirer, (_) => _.loadExtension(path)),
},
)
return client
})
const nativeLayer = (config: Config) =>
Layer.effect(
Sqlite.Native,
Effect.gen(function* () {
const native = new DatabaseSync(config.filename, {
readOnly: config.readonly,
timeout: config.timeout,
allowExtension: config.allowExtension,
enableForeignKeyConstraints: true,
open: true,
})
yield* Effect.addFinalizer(() => Effect.sync(() => native.close()))
if (config.disableWAL !== true && config.readonly !== true) native.exec("PRAGMA journal_mode = WAL;")
return native
}),
)
const sqliteLayer = (config: Config) => Layer.effect(Client.SqlClient, make(config))
const drizzleLayer = Layer.effect(
Sqlite.Drizzle,
Effect.gen(function* () {
return drizzle({ client: (yield* Sqlite.Native) as DatabaseSync }) as unknown as Sqlite.DrizzleClient
}),
)
export const layer = (config: Config) =>
Layer.merge(
nativeLayer(config),
Layer.merge(sqliteLayer(config), drizzleLayer).pipe(Layer.provide(nativeLayer(config))),
).pipe(
Layer.provide(Reactivity.layer),
)
+8
View File
@@ -0,0 +1,8 @@
export * as Sqlite from "./sqlite"
import { Context } from "effect"
import type { drizzle } from "drizzle-orm/bun-sqlite"
export type DrizzleClient = ReturnType<typeof drizzle>
export class Native extends Context.Service<Native, unknown>()("@opencode-ai/core/database/SqliteNative") {}
export class Drizzle extends Context.Service<Drizzle, DrizzleClient>()("@opencode-ai/core/database/SqliteDrizzle") {}
+245 -33
View File
@@ -1,6 +1,9 @@
export * as EventV2 from "./event"
import { Context, Effect, Layer, Option, PubSub, Schema, Stream } from "effect"
import { eq } from "drizzle-orm"
import { Database } from "./database/database"
import { EventSequenceTable, EventTable } from "./event/sql"
import { Location } from "./location"
import { withStatics } from "./schema"
import { Identifier } from "./util/identifier"
@@ -13,8 +16,10 @@ export type ID = typeof ID.Type
export type Definition<Type extends string = string, DataSchema extends Schema.Top = Schema.Top> = {
readonly type: Type
readonly version?: number
readonly aggregate?: string
readonly sync?: {
readonly version: number
readonly aggregate: string
}
readonly data: DataSchema
}
@@ -29,14 +34,40 @@ export type Payload<D extends Definition = Definition> = {
readonly metadata?: Record<string, unknown>
}
export type Sync = (event: Payload) => Effect.Effect<void>
export type Projector<D extends Definition = Definition> = (event: Payload<D>) => Effect.Effect<void>
type AnyProjector = (event: Payload) => Effect.Effect<void>
export type Listener = (event: Payload) => Effect.Effect<void>
export type Unsubscribe = Effect.Effect<void>
export type SerializedEvent = {
readonly id: ID
readonly type: string
readonly seq: number
readonly aggregateID: string
readonly data: Record<string, unknown>
}
export class InvalidSyncEventError extends Schema.TaggedErrorClass<InvalidSyncEventError>()(
"EventV2.InvalidSyncEvent",
{
type: Schema.String,
message: Schema.String,
},
) {}
export function versionedType(type: string, version: number) {
return `${type}.${version}`
}
export const registry = new Map<string, Definition>()
const syncRegistry = new Map<string, Definition & { readonly sync: NonNullable<Definition["sync"]> }>()
export function define<const Type extends string, Fields extends Schema.Struct.Fields>(input: {
readonly type: Type
readonly version?: number
readonly aggregate?: string
readonly sync?: {
readonly version: number
readonly aggregate: string
}
readonly schema: Fields
}): Schema.Schema<Payload<Definition<Type, Schema.Struct<Fields>>>> & Definition<Type, Schema.Struct<Fields>> {
const Data = Schema.Struct(input.schema)
@@ -51,11 +82,18 @@ export function define<const Type extends string, Fields extends Schema.Struct.F
const definition = Object.assign(Payload, {
type: input.type,
...(input.version === undefined ? {} : { version: input.version }),
...(input.aggregate === undefined ? {} : { aggregate: input.aggregate }),
...(input.sync === undefined ? {} : { sync: input.sync }),
data: Data,
})
registry.set(input.type, definition)
const existing = registry.get(input.type)
if (input.sync === undefined || existing?.sync === undefined || input.sync.version >= existing.sync.version) {
registry.set(input.type, definition)
}
if (input.sync)
syncRegistry.set(
versionedType(input.type, input.sync.version),
definition as Definition & { readonly sync: NonNullable<Definition["sync"]> },
)
return definition as Schema.Schema<Payload<Definition<Type, Schema.Struct<Fields>>>> &
Definition<Type, Schema.Struct<Fields>>
}
@@ -67,20 +105,29 @@ export function definitions() {
export interface PublishOptions {
readonly id?: ID
readonly metadata?: Record<string, unknown>
readonly location?: Location.Ref
}
export type Unsubscribe = Effect.Effect<void>
export interface Interface {
readonly publish: <D extends Definition>(
definition: D,
data: Data<D>,
options?: PublishOptions,
) => Effect.Effect<Payload<D>>
readonly publishEvent: <D extends Definition>(event: Payload<D>) => Effect.Effect<Payload<D>>
readonly subscribe: <D extends Definition>(definition: D) => Stream.Stream<Payload<D>>
readonly all: () => Stream.Stream<Payload>
readonly sync: (handler: Sync) => Effect.Effect<Unsubscribe>
readonly listen: (listener: Listener) => Effect.Effect<Unsubscribe>
readonly project: <D extends Definition>(definition: D, projector: Projector<D>) => Effect.Effect<void>
readonly replay: (
event: SerializedEvent,
options?: { readonly publish?: boolean; readonly ownerID?: string },
) => Effect.Effect<void>
readonly replayAll: (
events: SerializedEvent[],
options?: { readonly publish?: boolean; readonly ownerID?: string },
) => Effect.Effect<string | undefined>
readonly remove: (aggregateID: string) => Effect.Effect<void>
readonly claim: (aggregateID: string, ownerID: string) => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Event") {}
@@ -90,7 +137,9 @@ export const layer = Layer.effect(
Effect.gen(function* () {
const all = yield* PubSub.unbounded<Payload>()
const typed = new Map<string, PubSub.PubSub<Payload>>()
const syncHandlers = new Array<Sync>()
const projectors = new Map<string, AnyProjector[]>()
const listeners = new Array<Listener>()
const { db } = yield* Database.Service
const getOrCreate = (definition: Definition) =>
Effect.gen(function* () {
@@ -108,10 +157,102 @@ export const layer = Layer.effect(
}),
)
function publishEvent<D extends Definition>(event: Payload<D>) {
function commitSyncEvent(
event: Payload,
input?: { readonly seq: number; readonly aggregateID: string; readonly ownerID?: string },
) {
return Effect.gen(function* () {
for (const sync of syncHandlers) {
yield* sync(event as Payload)
const definition = registry.get(event.type)
const sync = definition?.sync
if (sync) {
if (event.version !== sync.version) {
yield* Effect.die(
new InvalidSyncEventError({
type: event.type,
message: `Expected event version ${sync.version}, got ${event.version}`,
}),
)
}
const aggregateID = (event.data as Record<string, unknown>)[sync.aggregate]
if (typeof aggregateID !== "string") {
yield* Effect.die(
new InvalidSyncEventError({
type: event.type,
message: `Expected string aggregate field ${sync.aggregate}`,
}),
)
} else {
const list = projectors.get(event.type) ?? []
yield* db
.transaction(
() =>
Effect.gen(function* () {
const row = yield* db
.select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id })
.from(EventSequenceTable)
.where(eq(EventSequenceTable.aggregate_id, aggregateID))
.get()
.pipe(Effect.orDie)
const latest = row?.seq ?? -1
if (input && input.seq <= latest) return
if (input && row?.ownerID && row.ownerID !== input.ownerID) return
const seq = input?.seq ?? latest + 1
if (input && seq !== latest + 1) {
yield* Effect.die(
new InvalidSyncEventError({
type: event.type,
message: `Sequence mismatch for aggregate ${aggregateID}: expected ${latest + 1}, got ${seq}`,
}),
)
}
for (const projector of list) {
yield* projector(event as Payload)
}
yield* db
.insert(EventSequenceTable)
.values([{ aggregate_id: aggregateID, seq, owner_id: input?.ownerID }])
.onConflictDoUpdate({
target: EventSequenceTable.aggregate_id,
set: { seq },
})
.run()
.pipe(Effect.orDie)
yield* db
.insert(EventTable)
.values([
{
id: event.id,
aggregate_id: aggregateID,
seq,
type: versionedType(definition.type, sync.version),
data: event.data as Record<string, unknown>,
},
])
.run()
.pipe(Effect.orDie)
}),
{ behavior: "immediate" },
)
.pipe(Effect.orDie)
}
}
})
}
function publish<D extends Definition>(definition: D, data: Data<D>, options?: PublishOptions) {
return Effect.gen(function* () {
const location = options?.location ?? Option.getOrUndefined(yield* Effect.serviceOption(Location.Service))
const event = {
id: options?.id ?? ID.create(),
...(options?.metadata ? { metadata: options.metadata } : {}),
type: definition.type,
...(definition.sync === undefined ? {} : { version: definition.sync.version }),
...(location ? { location } : {}),
data,
} as Payload<D>
yield* commitSyncEvent(event as Payload)
for (const listener of listeners) {
yield* listener(event as Payload)
}
const pubsub = typed.get(event.type)
if (pubsub) yield* PubSub.publish(pubsub, event as Payload)
@@ -120,38 +261,109 @@ export const layer = Layer.effect(
})
}
function publish<D extends Definition>(definition: D, data: Data<D>, options?: PublishOptions) {
function replay(event: SerializedEvent, options?: { readonly publish?: boolean; readonly ownerID?: string }) {
return Effect.gen(function* () {
const location = Option.getOrUndefined(yield* Effect.serviceOption(Location.Service))
const event = {
id: options?.id ?? ID.create(),
...(options?.metadata ? { metadata: options.metadata } : {}),
type: definition.type,
...(definition.version === undefined ? {} : { version: definition.version }),
...(location ? { location } : {}),
data,
} as Payload<D>
return yield* publishEvent(event)
const definition = syncRegistry.get(event.type)
if (!definition) {
yield* Effect.die(
new InvalidSyncEventError({ type: event.type, message: `Unknown sync event type ${event.type}` }),
)
} else {
const payload = {
id: event.id,
type: definition.type,
version: definition.sync.version,
data: event.data,
} as Payload
yield* commitSyncEvent(payload, { seq: event.seq, aggregateID: event.aggregateID, ownerID: options?.ownerID })
if (options?.publish) {
for (const listener of listeners) {
yield* listener(payload)
}
const pubsub = typed.get(payload.type)
if (pubsub) yield* PubSub.publish(pubsub, payload)
yield* PubSub.publish(all, payload)
}
}
})
}
function replayAll(events: SerializedEvent[], options?: { readonly publish?: boolean; readonly ownerID?: string }) {
return Effect.gen(function* () {
const source = events[0]?.aggregateID
if (!source) return undefined
if (events.some((event) => event.aggregateID !== source)) {
yield* Effect.die(
new InvalidSyncEventError({
type: events[0]?.type ?? "unknown",
message: "Replay events must belong to the same aggregate",
}),
)
}
const start = events[0]?.seq ?? 0
for (const [index, event] of events.entries()) {
const seq = start + index
if (event.seq !== seq) {
yield* Effect.die(
new InvalidSyncEventError({
type: event.type,
message: `Replay sequence mismatch at index ${index}: expected ${seq}, got ${event.seq}`,
}),
)
}
}
for (const event of events) {
yield* replay(event, options)
}
return source
})
}
function remove(aggregateID: string) {
return db
.transaction(() =>
Effect.gen(function* () {
yield* db.delete(EventSequenceTable).where(eq(EventSequenceTable.aggregate_id, aggregateID)).run()
yield* db.delete(EventTable).where(eq(EventTable.aggregate_id, aggregateID)).run()
}),
)
.pipe(Effect.orDie)
}
function claim(aggregateID: string, ownerID: string) {
return db
.update(EventSequenceTable)
.set({ owner_id: ownerID })
.where(eq(EventSequenceTable.aggregate_id, aggregateID))
.run()
.pipe(Effect.orDie)
}
const subscribe = <D extends Definition>(definition: D): Stream.Stream<Payload<D>> =>
Stream.unwrap(getOrCreate(definition).pipe(Effect.map((pubsub) => Stream.fromPubSub(pubsub)))).pipe(
Stream.map((event) => event as Payload<D>),
)
const streamAll = (): Stream.Stream<Payload> => Stream.fromPubSub(all)
const sync = (handler: Sync): Effect.Effect<Unsubscribe> =>
const listen = (listener: Listener): Effect.Effect<Unsubscribe> =>
Effect.sync(() => {
syncHandlers.push(handler)
listeners.push(listener)
return Effect.sync(() => {
const index = syncHandlers.indexOf(handler)
if (index >= 0) syncHandlers.splice(index, 1)
const index = listeners.indexOf(listener)
if (index >= 0) listeners.splice(index, 1)
})
})
return Service.of({ publish, publishEvent, subscribe, all: streamAll, sync })
const project = <D extends Definition>(definition: D, projector: Projector<D>): Effect.Effect<void> =>
Effect.sync(() => {
const list = projectors.get(definition.type) ?? []
list.push((event) => projector(event as Payload<D>))
projectors.set(definition.type, list)
})
return Service.of({ publish, subscribe, all: streamAll, listen, project, replay, replayAll, remove, claim })
}),
)
export const defaultLayer = layer
export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer))
@@ -1,4 +1,5 @@
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"
import type { EventV2 } from "../event"
export const EventSequenceTable = sqliteTable("event_sequence", {
aggregate_id: text().notNull().primaryKey(),
@@ -7,7 +8,7 @@ export const EventSequenceTable = sqliteTable("event_sequence", {
})
export const EventTable = sqliteTable("event", {
id: text().primaryKey(),
id: text().$type<EventV2.ID>().primaryKey(),
aggregate_id: text()
.notNull()
.references(() => EventSequenceTable.aggregate_id, { onDelete: "cascade" }),
+80
View File
@@ -0,0 +1,80 @@
import { randomBytes } from "crypto"
const prefixes = {
job: "job",
event: "evt",
session: "ses",
message: "msg",
permission: "per",
question: "que",
part: "prt",
pty: "pty",
tool: "tool",
workspace: "wrk",
} as const
const LENGTH = 26
// State for monotonic ID generation
let lastTimestamp = 0
let counter = 0
export function ascending(prefix: keyof typeof prefixes, given?: string) {
return generateID(prefix, "ascending", given)
}
export function descending(prefix: keyof typeof prefixes, given?: string) {
return generateID(prefix, "descending", given)
}
function generateID(prefix: keyof typeof prefixes, direction: "descending" | "ascending", given?: string): string {
if (!given) {
return create(prefixes[prefix], direction)
}
if (!given.startsWith(prefixes[prefix])) {
throw new Error(`ID ${given} does not start with ${prefixes[prefix]}`)
}
return given
}
function randomBase62(length: number): string {
const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
let result = ""
const bytes = randomBytes(length)
for (let i = 0; i < length; i++) {
result += chars[bytes[i] % 62]
}
return result
}
export function create(prefix: string, direction: "descending" | "ascending", timestamp?: number): string {
const currentTimestamp = timestamp ?? Date.now()
if (currentTimestamp !== lastTimestamp) {
lastTimestamp = currentTimestamp
counter = 0
}
counter++
let now = BigInt(currentTimestamp) * BigInt(0x1000) + BigInt(counter)
now = direction === "descending" ? ~now : now
const timeBytes = Buffer.alloc(6)
for (let i = 0; i < 6; i++) {
timeBytes[i] = Number((now >> BigInt(40 - 8 * i)) & BigInt(0xff))
}
return prefix + "_" + timeBytes.toString("hex") + randomBase62(LENGTH - 12)
}
/** Extract timestamp from an ascending ID. Does not work with descending IDs. */
export function timestamp(id: string): number {
const prefix = id.split("_")[0]
const hex = id.slice(prefix.length + 1, prefix.length + 13)
const encoded = BigInt("0x" + hex)
return Number(encoded / BigInt(0x1000))
}
export * as Identifier from "./id"
+2 -1
View File
@@ -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
+11
View File
@@ -2,6 +2,17 @@ export * as PermissionV2 from "./permission"
import { Schema } from "effect"
import { Wildcard } from "./util/wildcard"
import { Identifier } from "./id/id"
import { Newtype } from "./schema"
export class PermissionID extends Newtype<PermissionID>()(
"PermissionID",
Schema.String.check(Schema.isStartsWith("per")),
) {
static ascending(id?: string): PermissionID {
return this.make(Identifier.ascending("permission", id))
}
}
export const Action = Schema.Literals(["allow", "deny", "ask"]).annotate({ identifier: "PermissionV2.Action" })
export type Action = typeof Action.Type
+3 -3
View File
@@ -17,9 +17,9 @@ type HookSpec = {
}
"account.switched": {
input: {
serviceID: import("./account").AccountV2.ServiceID
from?: import("./account").AccountV2.ID
to?: import("./account").AccountV2.ID
serviceID: import("./auth").Auth.ServiceID
from?: import("./auth").Auth.ID
to?: import("./auth").Auth.ID
}
output: {}
}
+6 -4
View File
@@ -1,16 +1,18 @@
import { Effect, Scope, Stream } from "effect"
import { AccountV2 } from "../account"
import { Auth } from "../auth"
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* () {
const accounts = yield* AccountV2.Service
const accounts = yield* Auth.Service
const events = yield* EventV2.Service
const scope = yield* Scope.Scope
yield* events.subscribe(AccountV2.Event.Switched).pipe(
yield* events.subscribe(Auth.Event.Switched).pipe(
Stream.runForEach((event) =>
PluginV2.Service.use((plugin) => plugin.trigger("account.switched", event.data, {})).pipe(Effect.asVoid),
),
@@ -20,7 +22,7 @@ export const AccountPlugin = PluginV2.define({
return {
"catalog.transform": Effect.fn(function* (evt) {
for (const item of evt.data) {
const account = yield* accounts.active(AccountV2.ServiceID.make(item.provider.id)).pipe(Effect.orDie)
const account = yield* accounts.active(Auth.ServiceID.make(item.provider.id)).pipe(Effect.orDie)
if (!account) continue
evt.provider.update(item.provider.id, (provider) => {
provider.enabled = {
+5 -5
View File
@@ -1,7 +1,7 @@
export * as PluginBoot from "./boot"
import { Context, Deferred, Effect, Layer } from "effect"
import { AccountV2 } from "../account"
import { Auth } from "../auth"
import { AgentV2 } from "../agent"
import { Catalog } from "../catalog"
import { EventV2 } from "../event"
@@ -15,7 +15,7 @@ import { ProviderPlugins } from "./provider"
type Plugin = {
id: PluginV2.ID
effect: PluginV2.Effect<
Catalog.Service | AgentV2.Service | AccountV2.Service | Npm.Service | EventV2.Service | PluginV2.Service
Catalog.Service | AgentV2.Service | Auth.Service | Npm.Service | EventV2.Service | PluginV2.Service
>
}
@@ -31,7 +31,7 @@ export const layer = Layer.effect(
const agent = yield* AgentV2.Service
const catalog = yield* Catalog.Service
const plugin = yield* PluginV2.Service
const accounts = yield* AccountV2.Service
const accounts = yield* Auth.Service
const npm = yield* Npm.Service
const events = yield* EventV2.Service
const done = yield* Deferred.make<void>()
@@ -42,7 +42,7 @@ export const layer = Layer.effect(
effect: input.effect.pipe(
Effect.provideService(Catalog.Service, catalog),
Effect.provideService(AgentV2.Service, agent),
Effect.provideService(AccountV2.Service, accounts),
Effect.provideService(Auth.Service, accounts),
Effect.provideService(Npm.Service, npm),
Effect.provideService(EventV2.Service, events),
Effect.provideService(PluginV2.Service, plugin),
@@ -76,6 +76,6 @@ export const defaultLayer = layer.pipe(
Layer.provide(Catalog.defaultLayer),
Layer.provide(EventV2.defaultLayer),
Layer.provide(PluginV2.defaultLayer),
Layer.provide(AccountV2.defaultLayer),
Layer.provide(Auth.defaultLayer),
Layer.provide(Npm.defaultLayer),
)
+1 -2
View File
@@ -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)),
})
+67 -1
View File
@@ -1 +1,67 @@
export { ProviderPlugins } from "./provider/index"
import { AlibabaPlugin } from "./provider/alibaba"
import { AmazonBedrockPlugin } from "./provider/amazon-bedrock"
import { AnthropicPlugin } from "./provider/anthropic"
import { AzureCognitiveServicesPlugin, AzurePlugin } from "./provider/azure"
import { CerebrasPlugin } from "./provider/cerebras"
import { CloudflareAIGatewayPlugin } from "./provider/cloudflare-ai-gateway"
import { CloudflareWorkersAIPlugin } from "./provider/cloudflare-workers-ai"
import { CoherePlugin } from "./provider/cohere"
import { DeepInfraPlugin } from "./provider/deepinfra"
import { DynamicProviderPlugin } from "./provider/dynamic"
import { GatewayPlugin } from "./provider/gateway"
import { GithubCopilotPlugin } from "./provider/github-copilot"
import { GitLabPlugin } from "./provider/gitlab"
import { GooglePlugin } from "./provider/google"
import { GoogleVertexAnthropicPlugin, GoogleVertexPlugin } from "./provider/google-vertex"
import { GroqPlugin } from "./provider/groq"
import { KiloPlugin } from "./provider/kilo"
import { LLMGatewayPlugin } from "./provider/llmgateway"
import { MistralPlugin } from "./provider/mistral"
import { NvidiaPlugin } from "./provider/nvidia"
import { OpenAIPlugin } from "./provider/openai"
import { OpenAICompatiblePlugin } from "./provider/openai-compatible"
import { OpencodePlugin } from "./provider/opencode"
import { OpenRouterPlugin } from "./provider/openrouter"
import { PerplexityPlugin } from "./provider/perplexity"
import { SapAICorePlugin } from "./provider/sap-ai-core"
import { TogetherAIPlugin } from "./provider/togetherai"
import { VercelPlugin } from "./provider/vercel"
import { VenicePlugin } from "./provider/venice"
import { XAIPlugin } from "./provider/xai"
import { ZenmuxPlugin } from "./provider/zenmux"
export const ProviderPlugins = [
AlibabaPlugin,
AmazonBedrockPlugin,
AnthropicPlugin,
AzureCognitiveServicesPlugin,
AzurePlugin,
CerebrasPlugin,
CloudflareAIGatewayPlugin,
CloudflareWorkersAIPlugin,
CoherePlugin,
DeepInfraPlugin,
GatewayPlugin,
GithubCopilotPlugin,
GitLabPlugin,
GooglePlugin,
GoogleVertexAnthropicPlugin,
GoogleVertexPlugin,
GroqPlugin,
KiloPlugin,
LLMGatewayPlugin,
MistralPlugin,
NvidiaPlugin,
OpencodePlugin,
OpenAICompatiblePlugin,
OpenAIPlugin,
OpenRouterPlugin,
PerplexityPlugin,
SapAICorePlugin,
TogetherAIPlugin,
VercelPlugin,
VenicePlugin,
XAIPlugin,
ZenmuxPlugin,
DynamicProviderPlugin,
]
@@ -1,67 +0,0 @@
import { AlibabaPlugin } from "./alibaba"
import { AmazonBedrockPlugin } from "./amazon-bedrock"
import { AnthropicPlugin } from "./anthropic"
import { AzureCognitiveServicesPlugin, AzurePlugin } from "./azure"
import { CerebrasPlugin } from "./cerebras"
import { CloudflareAIGatewayPlugin } from "./cloudflare-ai-gateway"
import { CloudflareWorkersAIPlugin } from "./cloudflare-workers-ai"
import { CoherePlugin } from "./cohere"
import { DeepInfraPlugin } from "./deepinfra"
import { DynamicProviderPlugin } from "./dynamic"
import { GatewayPlugin } from "./gateway"
import { GithubCopilotPlugin } from "./github-copilot"
import { GitLabPlugin } from "./gitlab"
import { GooglePlugin } from "./google"
import { GoogleVertexAnthropicPlugin, GoogleVertexPlugin } from "./google-vertex"
import { GroqPlugin } from "./groq"
import { KiloPlugin } from "./kilo"
import { LLMGatewayPlugin } from "./llmgateway"
import { MistralPlugin } from "./mistral"
import { NvidiaPlugin } from "./nvidia"
import { OpenAIPlugin } from "./openai"
import { OpenAICompatiblePlugin } from "./openai-compatible"
import { OpencodePlugin } from "./opencode"
import { OpenRouterPlugin } from "./openrouter"
import { PerplexityPlugin } from "./perplexity"
import { SapAICorePlugin } from "./sap-ai-core"
import { TogetherAIPlugin } from "./togetherai"
import { VercelPlugin } from "./vercel"
import { VenicePlugin } from "./venice"
import { XAIPlugin } from "./xai"
import { ZenmuxPlugin } from "./zenmux"
export const ProviderPlugins = [
AlibabaPlugin,
AmazonBedrockPlugin,
AnthropicPlugin,
AzureCognitiveServicesPlugin,
AzurePlugin,
CerebrasPlugin,
CloudflareAIGatewayPlugin,
CloudflareWorkersAIPlugin,
CoherePlugin,
DeepInfraPlugin,
GatewayPlugin,
GithubCopilotPlugin,
GitLabPlugin,
GooglePlugin,
GoogleVertexAnthropicPlugin,
GoogleVertexPlugin,
GroqPlugin,
KiloPlugin,
LLMGatewayPlugin,
MistralPlugin,
NvidiaPlugin,
OpencodePlugin,
OpenAICompatiblePlugin,
OpenAIPlugin,
OpenRouterPlugin,
PerplexityPlugin,
SapAICorePlugin,
TogetherAIPlugin,
VercelPlugin,
VenicePlugin,
XAIPlugin,
ZenmuxPlugin,
DynamicProviderPlugin,
]
+1 -1
View File
@@ -1,4 +1,4 @@
export * as Project from "./project"
export * as ProjectV2 from "./project"
import { Context, Effect, Layer, Schema } from "effect"
import path from "path"
@@ -1,9 +1,9 @@
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"
import { Timestamps } from "../storage/schema.sql"
import type { ProjectID } from "./schema"
import { Timestamps } from "../database/schema.sql"
import { ProjectV2 } from "../project"
export const ProjectTable = sqliteTable("project", {
id: text().$type<ProjectID>().primaryKey(),
id: text().$type<ProjectV2.ID>().primaryKey(),
worktree: text().notNull(),
vcs: text(),
name: text(),
+3
View File
@@ -22,6 +22,9 @@ export const ID = Schema.String.pipe(
)
export type ID = typeof ID.Type
export const ModelID = Schema.String.pipe(Schema.brand("ModelID"))
export type ModelID = typeof ModelID.Type
const OpenAIResponses = Schema.Struct({
type: Schema.Literal("openai/responses"),
url: Schema.String,
+12 -6
View File
@@ -1,11 +1,5 @@
import { Option, Schema, SchemaGetter } from "effect"
export const AbsolutePath = Schema.String.pipe(Schema.brand("AbsolutePath"))
export type AbsolutePath = typeof AbsolutePath.Type
export const RelativePath = Schema.String.pipe(Schema.brand("RelativePath"))
export type RelativePath = typeof RelativePath.Type
/**
* Integer greater than zero.
*/
@@ -16,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<typeof RelativePath>
/**
* 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<typeof AbsolutePath>
/**
* Optional public JSON field that can hold explicit `undefined` on the type
* side but encodes it as an omitted key, matching legacy `JSON.stringify`.
+334 -10
View File
@@ -1,13 +1,337 @@
export * as Session from "./session"
export * as SessionV2 from "./session"
export * from "./session/schema"
import { Schema } from "effect"
import { withStatics } from "./schema"
import { Identifier } from "./util/identifier"
import { DateTime, Effect, Layer, Schema, Context } from "effect"
import { and, asc, desc, eq, gt, gte, like, lt, or, type SQL } from "drizzle-orm"
import { ProjectV2 } from "./project"
import { WorkspaceV2 } from "./workspace"
import { ModelV2 } from "./model"
import { Location } from "./location"
import { SessionMessage } from "./session/message"
import type { Prompt } from "./session/prompt"
import { EventV2 } from "./event"
import { ProviderV2 } from "./provider"
import { Database } from "./database/database"
import { SessionProjector } from "./session/projector"
import { SessionMessageTable, SessionTable } from "./session/sql"
import { SessionSchema } from "./session/schema"
import { AbsolutePath, RelativePath } from "./schema"
export const ID = Schema.String.check(Schema.isStartsWith("ses")).pipe(
Schema.brand("SessionID"),
withStatics((schema) => ({
descending: (id?: string) => schema.make(id ?? "ses_" + Identifier.descending()),
})),
// get project -> project.locations
//
// get all sessions
//
// - by project
// - by subpath
// - by workspace (home is special)
export const ListCursor = Schema.Struct({
id: SessionSchema.ID,
time: Schema.Finite,
direction: Schema.Literals(["previous", "next"]),
})
export type ListCursor = typeof ListCursor.Type
const ListInputBase = {
workspaceID: WorkspaceV2.ID.pipe(Schema.optional),
search: Schema.String.pipe(Schema.optional),
limit: Schema.Int.pipe(Schema.optional),
order: Schema.Literals(["asc", "desc"]).pipe(Schema.optional),
cursor: ListCursor.pipe(Schema.optional),
}
export const ListInput = Schema.Union([
Schema.Struct({
...ListInputBase,
}),
Schema.Struct({
...ListInputBase,
directory: AbsolutePath,
}),
Schema.Struct({
...ListInputBase,
project: ProjectV2.ID,
subpath: RelativePath.pipe(Schema.optional),
}),
])
export type ListInput = typeof ListInput.Type
type CreateInput = {
id?: SessionSchema.ID
agent?: string
model?: ModelV2.Ref
location: Location.Ref
}
type MoveInput = {
sessionID: SessionSchema.ID
location: Location.Ref
}
type CompactInput = {
sessionID: SessionSchema.ID
prompt?: Prompt
}
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Session.NotFoundError", {
sessionID: SessionSchema.ID,
}) {}
export class OperationUnavailableError extends Schema.TaggedErrorClass<OperationUnavailableError>()(
"Session.OperationUnavailableError",
{
operation: Schema.Literals(["prompt", "compact", "wait"]),
},
) {}
export class MessageDecodeError extends Schema.TaggedErrorClass<MessageDecodeError>()("Session.MessageDecodeError", {
sessionID: SessionSchema.ID,
messageID: SessionMessage.ID,
}) {}
export type Error = NotFoundError | MessageDecodeError | OperationUnavailableError
export interface Interface {
readonly list: (input?: ListInput) => Effect.Effect<SessionSchema.Info[]>
readonly create: (input?: CreateInput) => Effect.Effect<SessionSchema.Info>
readonly move: (input: MoveInput) => Effect.Effect<void, NotFoundError>
readonly get: (sessionID: SessionSchema.ID) => Effect.Effect<SessionSchema.Info, NotFoundError>
readonly messages: (input: {
sessionID: SessionSchema.ID
limit?: number
order?: "asc" | "desc"
cursor?: {
id: SessionMessage.ID
time: number
direction: "previous" | "next"
}
}) => Effect.Effect<SessionMessage.Message[], NotFoundError | MessageDecodeError>
readonly context: (
sessionID: SessionSchema.ID,
) => Effect.Effect<SessionMessage.Message[], NotFoundError | MessageDecodeError>
readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: string }) => Effect.Effect<void, never>
readonly switchModel: (input: { sessionID: SessionSchema.ID; model: ModelV2.Ref }) => Effect.Effect<void, never>
readonly prompt: (input: {
id?: EventV2.ID
sessionID: SessionSchema.ID
prompt: Prompt
delivery?: SessionSchema.Delivery
resume?: boolean
}) => Effect.Effect<SessionMessage.User, NotFoundError | OperationUnavailableError>
readonly shell: (input: {
id?: EventV2.ID
sessionID: SessionSchema.ID
command: string
delivery?: SessionSchema.Delivery
resume?: boolean
}) => Effect.Effect<void, never>
readonly skill: (input: {
id?: EventV2.ID
sessionID: SessionSchema.ID
skill: string
delivery?: SessionSchema.Delivery
resume?: boolean
}) => Effect.Effect<void, never>
readonly compact: (input: CompactInput) => Effect.Effect<void, NotFoundError | OperationUnavailableError>
readonly wait: (id: SessionSchema.ID) => Effect.Effect<void, NotFoundError | OperationUnavailableError>
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Session") {}
function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.Info {
return new SessionSchema.Info({
id: SessionSchema.ID.make(row.id),
projectID: ProjectV2.ID.make(row.project_id),
workspaceID: row.workspace_id ? WorkspaceV2.ID.make(row.workspace_id) : undefined,
title: row.title,
parentID: row.parent_id ? SessionSchema.ID.make(row.parent_id) : undefined,
path: row.path ?? "",
agent: row.agent ?? undefined,
model: row.model
? {
id: ModelV2.ID.make(row.model.id),
providerID: ProviderV2.ID.make(row.model.providerID),
variant: ModelV2.VariantID.make(row.model.variant ?? "default"),
}
: undefined,
cost: row.cost,
tokens: {
input: row.tokens_input,
output: row.tokens_output,
reasoning: row.tokens_reasoning,
cache: {
read: row.tokens_cache_read,
write: row.tokens_cache_write,
},
},
time: {
created: DateTime.makeUnsafe(row.time_created),
updated: DateTime.makeUnsafe(row.time_updated),
archived: row.time_archived ? DateTime.makeUnsafe(row.time_archived) : undefined,
},
})
}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const db = (yield* Database.Service).db
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Message)
const decode = (row: typeof SessionMessageTable.$inferSelect) =>
decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe(
Effect.mapError(
() =>
new MessageDecodeError({
sessionID: SessionSchema.ID.make(row.session_id),
messageID: SessionMessage.ID.make(row.id),
}),
),
)
const result = Service.of({
create: Effect.fn("V2Session.create")(function* () {
return {} as SessionSchema.Info
}),
get: Effect.fn("V2Session.get")(function* (sessionID) {
const row = yield* db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get().pipe(Effect.orDie)
if (!row) return yield* new NotFoundError({ sessionID })
return fromRow(row)
}),
list: Effect.fn("V2Session.list")(function* (input = {}) {
const direction = input.cursor?.direction ?? "next"
const requestedOrder = input.order ?? "desc"
const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder
const sortColumn = SessionTable.time_updated
const conditions: SQL[] = []
if ("directory" in input) conditions.push(eq(SessionTable.directory, input.directory))
if (input.workspaceID) conditions.push(eq(SessionTable.workspace_id, input.workspaceID))
if ("project" in input) conditions.push(eq(SessionTable.project_id, input.project))
if (input.search) conditions.push(like(SessionTable.title, `%${input.search}%`))
if (input.cursor) {
conditions.push(
order === "asc"
? or(
gt(sortColumn, input.cursor.time),
and(eq(sortColumn, input.cursor.time), gt(SessionTable.id, input.cursor.id)),
)!
: or(
lt(sortColumn, input.cursor.time),
and(eq(sortColumn, input.cursor.time), lt(SessionTable.id, input.cursor.id)),
)!,
)
}
const query = db
.select()
.from(SessionTable)
.where(conditions.length > 0 ? and(...conditions) : undefined)
.orderBy(
order === "asc" ? asc(sortColumn) : desc(sortColumn),
order === "asc" ? asc(SessionTable.id) : desc(SessionTable.id),
)
const rows = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe(
Effect.orDie,
)
return (direction === "previous" ? rows.toReversed() : rows).map((row) => fromRow(row))
}),
messages: Effect.fn("V2Session.messages")(function* (input) {
yield* result.get(input.sessionID)
const direction = input.cursor?.direction ?? "next"
const requestedOrder = input.order ?? "desc"
const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder
const boundary = input.cursor
? order === "asc"
? or(
gt(SessionMessageTable.time_created, input.cursor.time),
and(
eq(SessionMessageTable.time_created, input.cursor.time),
gt(SessionMessageTable.id, input.cursor.id),
),
)
: or(
lt(SessionMessageTable.time_created, input.cursor.time),
and(
eq(SessionMessageTable.time_created, input.cursor.time),
lt(SessionMessageTable.id, input.cursor.id),
),
)
: undefined
const where = boundary
? and(eq(SessionMessageTable.session_id, input.sessionID), boundary)
: eq(SessionMessageTable.session_id, input.sessionID)
const query = db
.select()
.from(SessionMessageTable)
.where(where)
.orderBy(
order === "asc" ? asc(SessionMessageTable.time_created) : desc(SessionMessageTable.time_created),
order === "asc" ? asc(SessionMessageTable.id) : desc(SessionMessageTable.id),
)
const rows = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe(
Effect.orDie,
)
return yield* Effect.forEach(direction === "previous" ? rows.toReversed() : rows, decode)
}),
context: Effect.fn("V2Session.context")(function* (sessionID) {
yield* result.get(sessionID)
const compaction = yield* db
.select()
.from(SessionMessageTable)
.where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "compaction")))
.orderBy(desc(SessionMessageTable.time_created), desc(SessionMessageTable.id))
.limit(1)
.get()
.pipe(Effect.orDie)
const rows = yield* db
.select()
.from(SessionMessageTable)
.where(
and(
eq(SessionMessageTable.session_id, sessionID),
compaction
? or(
gt(SessionMessageTable.time_created, compaction.time_created),
and(
eq(SessionMessageTable.time_created, compaction.time_created),
gte(SessionMessageTable.id, compaction.id),
),
)
: undefined,
),
)
.orderBy(asc(SessionMessageTable.time_created), asc(SessionMessageTable.id))
.all()
.pipe(Effect.orDie)
return yield* Effect.forEach(rows, decode)
}),
prompt: Effect.fn("V2Session.prompt")(function* (input) {
yield* result.get(input.sessionID)
return yield* Effect.fail(new OperationUnavailableError({ operation: "prompt" }))
}),
shell: Effect.fn("V2Session.shell")(function* () {}),
skill: Effect.fn("V2Session.skill")(function* () {}),
switchAgent: Effect.fn("V2Session.switchAgent")(function* () {}),
switchModel: Effect.fn("V2Session.switchModel")(function* () {}),
compact: Effect.fn("V2Session.compact")(function* (input) {
yield* result.get(input.sessionID)
return yield* new OperationUnavailableError({ operation: "compact" })
}),
wait: Effect.fn("V2Session.wait")(function* (sessionID) {
yield* result.get(sessionID)
return yield* new OperationUnavailableError({ operation: "wait" })
}),
resume: Effect.fn("V2Session.resume")(function* () {}),
move: Effect.fn("V2Session.move")(function* () {}),
})
return result
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(SessionProjector.defaultLayer),
Layer.provide(Database.defaultLayer),
Layer.orDie,
)
export type ID = typeof ID.Type
@@ -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 { FileAttachment, Prompt } from "./prompt"
import { SessionSchema } from "./schema"
export { FileAttachment }
@@ -20,12 +20,14 @@ export type Source = typeof Source.Type
const Base = {
timestamp: V2Schema.DateTimeUtcFromMillis,
sessionID: Session.ID,
sessionID: SessionSchema.ID,
}
const options = {
aggregate: "sessionID",
version: 1,
sync: {
aggregate: "sessionID",
version: 1,
},
} as const
export const UnknownError = Schema.Struct({
@@ -395,8 +397,7 @@ export const All = Schema.Union(
mode: "oneOf",
},
).pipe(Schema.toTaggedUnion("type"))
export type Event = typeof All.Type
export type Type = Event["type"]
export * as SessionEvent from "./session-event"
export * as SessionEvent from "./event"
+624
View File
@@ -0,0 +1,624 @@
export * as SessionLegacy from "./legacy"
import { Effect, Schema, Types } from "effect"
import { EventV2 } from "../event"
import { PermissionV2 } from "../permission"
import { ProjectV2 } from "../project"
import { ProviderV2 } from "../provider"
import { optionalOmitUndefined, withStatics } from "../schema"
import { Identifier } from "../util/identifier"
import { NonNegativeInt } from "../schema"
import { NamedError } from "../util/error"
import { SessionSchema } from "./schema"
import { WorkspaceV2 } from "../workspace"
export const MessageID = Schema.String.check(Schema.isStartsWith("msg")).pipe(
Schema.brand("MessageID"),
withStatics((schema) => ({ ascending: (id?: string) => schema.make(id ?? "msg_" + Identifier.ascending()) })),
)
export type MessageID = typeof MessageID.Type
export const PartID = Schema.String.check(Schema.isStartsWith("prt")).pipe(
Schema.brand("PartID"),
withStatics((schema) => ({ ascending: (id?: string) => schema.make(id ?? "prt_" + Identifier.ascending()) })),
)
export type PartID = typeof PartID.Type
export const OutputLengthError = NamedError.create("MessageOutputLengthError", {})
export const AuthError = NamedError.create("ProviderAuthError", {
providerID: Schema.String,
message: Schema.String,
})
export const AbortedError = NamedError.create("MessageAbortedError", { message: Schema.String })
export const StructuredOutputError = NamedError.create("StructuredOutputError", {
message: Schema.String,
retries: NonNegativeInt,
})
export const APIError = NamedError.create("APIError", {
message: Schema.String,
statusCode: Schema.optional(NonNegativeInt),
isRetryable: Schema.Boolean,
responseHeaders: Schema.optional(Schema.Record(Schema.String, Schema.String)),
responseBody: Schema.optional(Schema.String),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
})
export type APIError = Schema.Schema.Type<typeof APIError.Schema>
export const ContextOverflowError = NamedError.create("ContextOverflowError", {
message: Schema.String,
responseBody: Schema.optional(Schema.String),
})
export class OutputFormatText extends Schema.Class<OutputFormatText>("OutputFormatText")({
type: Schema.Literal("text"),
}) {}
export class OutputFormatJsonSchema extends Schema.Class<OutputFormatJsonSchema>("OutputFormatJsonSchema")({
type: Schema.Literal("json_schema"),
schema: Schema.Record(Schema.String, Schema.Any).annotate({ identifier: "JSONSchema" }),
retryCount: NonNegativeInt.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed(2))),
}) {}
export const Format = Schema.Union([OutputFormatText, OutputFormatJsonSchema]).annotate({
discriminator: "type",
identifier: "OutputFormat",
})
export type OutputFormat = Schema.Schema.Type<typeof Format>
const partBase = {
id: PartID,
sessionID: SessionSchema.ID,
messageID: MessageID,
}
export const SnapshotPart = Schema.Struct({
...partBase,
type: Schema.Literal("snapshot"),
snapshot: Schema.String,
}).annotate({ identifier: "SnapshotPart" })
export type SnapshotPart = Types.DeepMutable<Schema.Schema.Type<typeof SnapshotPart>>
export const PatchPart = Schema.Struct({
...partBase,
type: Schema.Literal("patch"),
hash: Schema.String,
files: Schema.Array(Schema.String),
}).annotate({ identifier: "PatchPart" })
export type PatchPart = Types.DeepMutable<Schema.Schema.Type<typeof PatchPart>>
export const TextPart = Schema.Struct({
...partBase,
type: Schema.Literal("text"),
text: Schema.String,
synthetic: Schema.optional(Schema.Boolean),
ignored: Schema.optional(Schema.Boolean),
time: Schema.optional(
Schema.Struct({
start: NonNegativeInt,
end: Schema.optional(NonNegativeInt),
}),
),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
}).annotate({ identifier: "TextPart" })
export type TextPart = Types.DeepMutable<Schema.Schema.Type<typeof TextPart>>
export const ReasoningPart = Schema.Struct({
...partBase,
type: Schema.Literal("reasoning"),
text: Schema.String,
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
time: Schema.Struct({
start: NonNegativeInt,
end: Schema.optional(NonNegativeInt),
}),
}).annotate({ identifier: "ReasoningPart" })
export type ReasoningPart = Types.DeepMutable<Schema.Schema.Type<typeof ReasoningPart>>
const filePartSourceBase = {
text: Schema.Struct({
value: Schema.String,
start: Schema.Finite,
end: Schema.Finite,
}).annotate({ identifier: "FilePartSourceText" }),
}
export const Range = Schema.Struct({
start: Schema.Struct({ line: NonNegativeInt, character: NonNegativeInt }),
end: Schema.Struct({ line: NonNegativeInt, character: NonNegativeInt }),
}).annotate({ identifier: "Range" })
export type Range = typeof Range.Type
export const FileSource = Schema.Struct({
...filePartSourceBase,
type: Schema.Literal("file"),
path: Schema.String,
}).annotate({ identifier: "FileSource" })
export const SymbolSource = Schema.Struct({
...filePartSourceBase,
type: Schema.Literal("symbol"),
path: Schema.String,
range: Range,
name: Schema.String,
kind: NonNegativeInt,
}).annotate({ identifier: "SymbolSource" })
export const ResourceSource = Schema.Struct({
...filePartSourceBase,
type: Schema.Literal("resource"),
clientName: Schema.String,
uri: Schema.String,
}).annotate({ identifier: "ResourceSource" })
export const FilePartSource = Schema.Union([FileSource, SymbolSource, ResourceSource]).annotate({
discriminator: "type",
identifier: "FilePartSource",
})
export const FilePart = Schema.Struct({
...partBase,
type: Schema.Literal("file"),
mime: Schema.String,
filename: Schema.optional(Schema.String),
url: Schema.String,
source: Schema.optional(FilePartSource),
}).annotate({ identifier: "FilePart" })
export type FilePart = Types.DeepMutable<Schema.Schema.Type<typeof FilePart>>
export const AgentPart = Schema.Struct({
...partBase,
type: Schema.Literal("agent"),
name: Schema.String,
source: Schema.optional(
Schema.Struct({
value: Schema.String,
start: NonNegativeInt,
end: NonNegativeInt,
}),
),
}).annotate({ identifier: "AgentPart" })
export type AgentPart = Types.DeepMutable<Schema.Schema.Type<typeof AgentPart>>
export const CompactionPart = Schema.Struct({
...partBase,
type: Schema.Literal("compaction"),
auto: Schema.Boolean,
overflow: Schema.optional(Schema.Boolean),
tail_start_id: Schema.optional(MessageID),
}).annotate({ identifier: "CompactionPart" })
export type CompactionPart = Types.DeepMutable<Schema.Schema.Type<typeof CompactionPart>>
export const SubtaskPart = Schema.Struct({
...partBase,
type: Schema.Literal("subtask"),
prompt: Schema.String,
description: Schema.String,
agent: Schema.String,
model: Schema.optional(
Schema.Struct({
providerID: ProviderV2.ID,
modelID: ProviderV2.ModelID,
}),
),
command: Schema.optional(Schema.String),
}).annotate({ identifier: "SubtaskPart" })
export type SubtaskPart = Types.DeepMutable<Schema.Schema.Type<typeof SubtaskPart>>
export const RetryPart = Schema.Struct({
...partBase,
type: Schema.Literal("retry"),
attempt: NonNegativeInt,
error: APIError.EffectSchema,
time: Schema.Struct({
created: NonNegativeInt,
}),
}).annotate({ identifier: "RetryPart" })
export type RetryPart = Omit<Types.DeepMutable<Schema.Schema.Type<typeof RetryPart>>, "error"> & {
error: APIError
}
export const StepStartPart = Schema.Struct({
...partBase,
type: Schema.Literal("step-start"),
snapshot: Schema.optional(Schema.String),
}).annotate({ identifier: "StepStartPart" })
export type StepStartPart = Types.DeepMutable<Schema.Schema.Type<typeof StepStartPart>>
export const StepFinishPart = Schema.Struct({
...partBase,
type: Schema.Literal("step-finish"),
reason: Schema.String,
snapshot: Schema.optional(Schema.String),
cost: Schema.Finite,
tokens: Schema.Struct({
total: Schema.optional(Schema.Finite),
input: Schema.Finite,
output: Schema.Finite,
reasoning: Schema.Finite,
cache: Schema.Struct({
read: Schema.Finite,
write: Schema.Finite,
}),
}),
}).annotate({ identifier: "StepFinishPart" })
export type StepFinishPart = Types.DeepMutable<Schema.Schema.Type<typeof StepFinishPart>>
export const ToolStatePending = Schema.Struct({
status: Schema.Literal("pending"),
input: Schema.Record(Schema.String, Schema.Any),
raw: Schema.String,
}).annotate({ identifier: "ToolStatePending" })
export type ToolStatePending = Types.DeepMutable<Schema.Schema.Type<typeof ToolStatePending>>
export const ToolStateRunning = Schema.Struct({
status: Schema.Literal("running"),
input: Schema.Record(Schema.String, Schema.Any),
title: Schema.optional(Schema.String),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
time: Schema.Struct({
start: NonNegativeInt,
}),
}).annotate({ identifier: "ToolStateRunning" })
export type ToolStateRunning = Types.DeepMutable<Schema.Schema.Type<typeof ToolStateRunning>>
export const ToolStateCompleted = Schema.Struct({
status: Schema.Literal("completed"),
input: Schema.Record(Schema.String, Schema.Any),
output: Schema.String,
title: Schema.String,
metadata: Schema.Record(Schema.String, Schema.Any),
time: Schema.Struct({
start: NonNegativeInt,
end: NonNegativeInt,
compacted: Schema.optional(NonNegativeInt),
}),
attachments: Schema.optional(Schema.Array(FilePart)),
}).annotate({ identifier: "ToolStateCompleted" })
export type ToolStateCompleted = Types.DeepMutable<Schema.Schema.Type<typeof ToolStateCompleted>>
export const ToolStateError = Schema.Struct({
status: Schema.Literal("error"),
input: Schema.Record(Schema.String, Schema.Any),
error: Schema.String,
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
time: Schema.Struct({
start: NonNegativeInt,
end: NonNegativeInt,
}),
}).annotate({ identifier: "ToolStateError" })
export type ToolStateError = Types.DeepMutable<Schema.Schema.Type<typeof ToolStateError>>
export const ToolState = Schema.Union([
ToolStatePending,
ToolStateRunning,
ToolStateCompleted,
ToolStateError,
]).annotate({
discriminator: "status",
identifier: "ToolState",
})
export type ToolState = ToolStatePending | ToolStateRunning | ToolStateCompleted | ToolStateError
export const ToolPart = Schema.Struct({
...partBase,
type: Schema.Literal("tool"),
callID: Schema.String,
tool: Schema.String,
state: ToolState,
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
}).annotate({ identifier: "ToolPart" })
export type ToolPart = Omit<Types.DeepMutable<Schema.Schema.Type<typeof ToolPart>>, "state"> & {
state: ToolState
}
const messageBase = {
id: MessageID,
sessionID: partBase.sessionID,
}
const FileDiff = Schema.Struct({
file: Schema.optional(Schema.String),
patch: Schema.optional(Schema.String),
additions: Schema.Finite,
deletions: Schema.Finite,
status: Schema.optional(Schema.Literals(["added", "deleted", "modified"])),
}).annotate({ identifier: "SnapshotFileDiff" })
export const User = Schema.Struct({
...messageBase,
role: Schema.Literal("user"),
time: Schema.Struct({
created: NonNegativeInt,
}),
format: Schema.optional(Format),
summary: Schema.optional(
Schema.Struct({
title: Schema.optional(Schema.String),
body: Schema.optional(Schema.String),
diffs: Schema.Array(FileDiff),
}),
),
agent: Schema.String,
model: Schema.Struct({
providerID: ProviderV2.ID,
modelID: ProviderV2.ModelID,
variant: Schema.optional(Schema.String),
}),
system: Schema.optional(Schema.String),
tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)),
}).annotate({ identifier: "UserMessage" })
export type User = Types.DeepMutable<Schema.Schema.Type<typeof User>>
export const Part = Schema.Union([
TextPart,
SubtaskPart,
ReasoningPart,
FilePart,
ToolPart,
StepStartPart,
StepFinishPart,
SnapshotPart,
PatchPart,
AgentPart,
RetryPart,
CompactionPart,
]).annotate({ discriminator: "type", identifier: "Part" })
export type Part =
| TextPart
| SubtaskPart
| ReasoningPart
| FilePart
| ToolPart
| StepStartPart
| StepFinishPart
| SnapshotPart
| PatchPart
| AgentPart
| RetryPart
| CompactionPart
const AssistantErrorSchema = Schema.Union([
AuthError.EffectSchema,
NamedError.Unknown.EffectSchema,
OutputLengthError.EffectSchema,
AbortedError.EffectSchema,
StructuredOutputError.EffectSchema,
ContextOverflowError.EffectSchema,
APIError.EffectSchema,
]).annotate({ discriminator: "name" })
type AssistantError = Schema.Schema.Type<typeof AssistantErrorSchema>
export const TextPartInput = Schema.Struct({
id: Schema.optional(PartID),
type: Schema.Literal("text"),
text: Schema.String,
synthetic: Schema.optional(Schema.Boolean),
ignored: Schema.optional(Schema.Boolean),
time: Schema.optional(
Schema.Struct({
start: NonNegativeInt,
end: Schema.optional(NonNegativeInt),
}),
),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
}).annotate({ identifier: "TextPartInput" })
export type TextPartInput = Types.DeepMutable<Schema.Schema.Type<typeof TextPartInput>>
export const FilePartInput = Schema.Struct({
id: Schema.optional(PartID),
type: Schema.Literal("file"),
mime: Schema.String,
filename: Schema.optional(Schema.String),
url: Schema.String,
source: Schema.optional(FilePartSource),
}).annotate({ identifier: "FilePartInput" })
export type FilePartInput = Types.DeepMutable<Schema.Schema.Type<typeof FilePartInput>>
export const AgentPartInput = Schema.Struct({
id: Schema.optional(PartID),
type: Schema.Literal("agent"),
name: Schema.String,
source: Schema.optional(
Schema.Struct({
value: Schema.String,
start: NonNegativeInt,
end: NonNegativeInt,
}),
),
}).annotate({ identifier: "AgentPartInput" })
export type AgentPartInput = Types.DeepMutable<Schema.Schema.Type<typeof AgentPartInput>>
export const SubtaskPartInput = Schema.Struct({
id: Schema.optional(PartID),
type: Schema.Literal("subtask"),
prompt: Schema.String,
description: Schema.String,
agent: Schema.String,
model: Schema.optional(
Schema.Struct({
providerID: ProviderV2.ID,
modelID: ProviderV2.ModelID,
}),
),
command: Schema.optional(Schema.String),
}).annotate({ identifier: "SubtaskPartInput" })
export type SubtaskPartInput = Types.DeepMutable<Schema.Schema.Type<typeof SubtaskPartInput>>
export const Assistant = Schema.Struct({
...messageBase,
role: Schema.Literal("assistant"),
time: Schema.Struct({
created: NonNegativeInt,
completed: Schema.optional(NonNegativeInt),
}),
error: Schema.optional(AssistantErrorSchema),
parentID: MessageID,
modelID: ProviderV2.ModelID,
providerID: ProviderV2.ID,
mode: Schema.String,
agent: Schema.String,
path: Schema.Struct({
cwd: Schema.String,
root: Schema.String,
}),
summary: Schema.optional(Schema.Boolean),
cost: Schema.Finite,
tokens: Schema.Struct({
total: Schema.optional(Schema.Finite),
input: Schema.Finite,
output: Schema.Finite,
reasoning: Schema.Finite,
cache: Schema.Struct({
read: Schema.Finite,
write: Schema.Finite,
}),
}),
structured: Schema.optional(Schema.Any),
variant: Schema.optional(Schema.String),
finish: Schema.optional(Schema.String),
}).annotate({ identifier: "AssistantMessage" })
export type Assistant = Omit<Types.DeepMutable<Schema.Schema.Type<typeof Assistant>>, "error"> & {
error?: AssistantError
}
export const Info = Schema.Union([User, Assistant]).annotate({ discriminator: "role", identifier: "Message" })
export type Info = User | Assistant
export const WithParts = Schema.Struct({
info: Info,
parts: Schema.Array(Part),
})
export type WithParts = {
info: Info
parts: Part[]
}
const options = {
sync: {
aggregate: "sessionID",
version: 1,
},
} as const
const SessionSummary = Schema.Struct({
additions: Schema.Finite,
deletions: Schema.Finite,
files: Schema.Finite,
diffs: optionalOmitUndefined(Schema.Array(FileDiff)),
})
const SessionTokens = Schema.Struct({
input: Schema.Finite,
output: Schema.Finite,
reasoning: Schema.Finite,
cache: Schema.Struct({
read: Schema.Finite,
write: Schema.Finite,
}),
})
const SessionShare = Schema.Struct({
url: Schema.String,
})
const SessionRevert = Schema.Struct({
messageID: MessageID,
partID: optionalOmitUndefined(PartID),
snapshot: optionalOmitUndefined(Schema.String),
diff: optionalOmitUndefined(Schema.String),
})
const SessionModel = Schema.Struct({
id: ProviderV2.ModelID,
providerID: ProviderV2.ID,
variant: optionalOmitUndefined(Schema.String),
})
export const SessionInfo = Schema.Struct({
id: SessionSchema.ID,
slug: Schema.String,
projectID: ProjectV2.ID,
workspaceID: optionalOmitUndefined(WorkspaceV2.ID),
directory: Schema.String,
path: optionalOmitUndefined(Schema.String),
parentID: optionalOmitUndefined(SessionSchema.ID),
summary: optionalOmitUndefined(SessionSummary),
cost: optionalOmitUndefined(Schema.Finite),
tokens: optionalOmitUndefined(SessionTokens),
share: optionalOmitUndefined(SessionShare),
title: Schema.String,
agent: optionalOmitUndefined(Schema.String),
model: optionalOmitUndefined(SessionModel),
version: Schema.String,
time: Schema.Struct({
created: NonNegativeInt,
updated: NonNegativeInt,
compacting: optionalOmitUndefined(NonNegativeInt),
archived: optionalOmitUndefined(Schema.Finite),
}),
permission: optionalOmitUndefined(PermissionV2.Ruleset),
revert: optionalOmitUndefined(SessionRevert),
}).annotate({ identifier: "Session" })
export type SessionInfo = typeof SessionInfo.Type
export const Event = {
Created: EventV2.define({
type: "session.created",
...options,
schema: {
sessionID: SessionSchema.ID,
info: SessionInfo,
},
}),
Updated: EventV2.define({
type: "session.updated",
...options,
schema: {
sessionID: SessionSchema.ID,
info: SessionInfo,
},
}),
Deleted: EventV2.define({
type: "session.deleted",
...options,
schema: {
sessionID: SessionSchema.ID,
info: SessionInfo,
},
}),
MessageUpdated: EventV2.define({
type: "message.updated",
...options,
schema: {
sessionID: SessionSchema.ID,
info: Info,
},
}),
MessageRemoved: EventV2.define({
type: "message.removed",
...options,
schema: {
sessionID: SessionSchema.ID,
messageID: MessageID,
},
}),
PartUpdated: EventV2.define({
type: "message.part.updated",
...options,
schema: {
sessionID: SessionSchema.ID,
part: Part,
time: Schema.Finite,
},
}),
PartRemoved: EventV2.define({
type: "message.part.removed",
...options,
schema: {
sessionID: SessionSchema.ID,
messageID: MessageID,
partID: PartID,
},
}),
}
@@ -1,23 +1,23 @@
import { produce, type WritableDraft } from "immer"
import { SessionEvent } from "./session-event"
import { SessionMessage } from "./session-message"
import { Effect } from "effect"
import { SessionEvent } from "./event"
import { SessionMessage } from "./message"
export type MemoryState = {
messages: SessionMessage.Message[]
}
export interface Adapter<Result> {
readonly getCurrentAssistant: () => SessionMessage.Assistant | undefined
readonly getCurrentCompaction: () => SessionMessage.Compaction | undefined
readonly getCurrentShell: (callID: string) => SessionMessage.Shell | undefined
readonly updateAssistant: (assistant: SessionMessage.Assistant) => void
readonly updateCompaction: (compaction: SessionMessage.Compaction) => void
readonly updateShell: (shell: SessionMessage.Shell) => void
readonly appendMessage: (message: SessionMessage.Message) => void
readonly finish: () => Result
export interface Adapter {
readonly getCurrentAssistant: () => Effect.Effect<SessionMessage.Assistant | undefined>
readonly getCurrentCompaction: () => Effect.Effect<SessionMessage.Compaction | undefined>
readonly getCurrentShell: (callID: string) => Effect.Effect<SessionMessage.Shell | undefined>
readonly updateAssistant: (assistant: SessionMessage.Assistant) => Effect.Effect<void>
readonly updateCompaction: (compaction: SessionMessage.Compaction) => Effect.Effect<void>
readonly updateShell: (shell: SessionMessage.Shell) => Effect.Effect<void>
readonly appendMessage: (message: SessionMessage.Message) => Effect.Effect<void>
}
export function memory(state: MemoryState): Adapter<MemoryState> {
export function memory(state: MemoryState): Adapter {
const activeAssistantIndex = () =>
state.messages.findLastIndex((message) => message.type === "assistant" && !message.time.completed)
const activeCompactionIndex = () => state.messages.findLastIndex((message) => message.type === "compaction")
@@ -26,55 +26,65 @@ export function memory(state: MemoryState): Adapter<MemoryState> {
return {
getCurrentAssistant() {
const index = activeAssistantIndex()
if (index < 0) return
const assistant = state.messages[index]
return assistant?.type === "assistant" ? assistant : undefined
return Effect.sync(() => {
const index = activeAssistantIndex()
if (index < 0) return
const assistant = state.messages[index]
return assistant?.type === "assistant" ? assistant : undefined
})
},
getCurrentCompaction() {
const index = activeCompactionIndex()
if (index < 0) return
const compaction = state.messages[index]
return compaction?.type === "compaction" ? compaction : undefined
return Effect.sync(() => {
const index = activeCompactionIndex()
if (index < 0) return
const compaction = state.messages[index]
return compaction?.type === "compaction" ? compaction : undefined
})
},
getCurrentShell(callID) {
const index = activeShellIndex(callID)
if (index < 0) return
const shell = state.messages[index]
return shell?.type === "shell" ? shell : undefined
return Effect.sync(() => {
const index = activeShellIndex(callID)
if (index < 0) return
const shell = state.messages[index]
return shell?.type === "shell" ? shell : undefined
})
},
updateAssistant(assistant) {
const index = activeAssistantIndex()
if (index < 0) return
const current = state.messages[index]
if (current?.type !== "assistant") return
state.messages[index] = assistant
return Effect.sync(() => {
const index = activeAssistantIndex()
if (index < 0) return
const current = state.messages[index]
if (current?.type !== "assistant") return
state.messages[index] = assistant
})
},
updateCompaction(compaction) {
const index = activeCompactionIndex()
if (index < 0) return
const current = state.messages[index]
if (current?.type !== "compaction") return
state.messages[index] = compaction
return Effect.sync(() => {
const index = activeCompactionIndex()
if (index < 0) return
const current = state.messages[index]
if (current?.type !== "compaction") return
state.messages[index] = compaction
})
},
updateShell(shell) {
const index = activeShellIndex(shell.callID)
if (index < 0) return
const current = state.messages[index]
if (current?.type !== "shell") return
state.messages[index] = shell
return Effect.sync(() => {
const index = activeShellIndex(shell.callID)
if (index < 0) return
const current = state.messages[index]
if (current?.type !== "shell") return
state.messages[index] = shell
})
},
appendMessage(message) {
state.messages.push(message)
},
finish() {
return state
return Effect.sync(() => {
state.messages.push(message)
})
},
}
}
export function update<Result>(adapter: Adapter<Result>, event: SessionEvent.Event): Result {
const currentAssistant = adapter.getCurrentAssistant()
export function update(adapter: Adapter, event: SessionEvent.Event) {
type DraftAssistant = WritableDraft<SessionMessage.Assistant>
type DraftTool = WritableDraft<SessionMessage.AssistantTool>
type DraftText = WritableDraft<SessionMessage.AssistantText>
@@ -91,9 +101,10 @@ export function update<Result>(adapter: Adapter<Result>, event: SessionEvent.Eve
const latestReasoning = (assistant: DraftAssistant | undefined, reasoningID: string) =>
assistant?.content.findLast((item): item is DraftReasoning => item.type === "reasoning" && item.id === reasoningID)
SessionEvent.All.match(event, {
return Effect.gen(function* () {
yield* SessionEvent.All.match(event, {
"session.next.agent.switched": (event) => {
adapter.appendMessage(
return adapter.appendMessage(
new SessionMessage.AgentSwitched({
id: event.id,
type: "agent-switched",
@@ -104,7 +115,7 @@ export function update<Result>(adapter: Adapter<Result>, event: SessionEvent.Eve
)
},
"session.next.model.switched": (event) => {
adapter.appendMessage(
return adapter.appendMessage(
new SessionMessage.ModelSwitched({
id: event.id,
type: "model-switched",
@@ -115,7 +126,7 @@ export function update<Result>(adapter: Adapter<Result>, event: SessionEvent.Eve
)
},
"session.next.prompted": (event) => {
adapter.appendMessage(
return adapter.appendMessage(
new SessionMessage.User({
id: event.id,
type: "user",
@@ -129,7 +140,7 @@ export function update<Result>(adapter: Adapter<Result>, event: SessionEvent.Eve
)
},
"session.next.synthetic": (event) => {
adapter.appendMessage(
return adapter.appendMessage(
new SessionMessage.Synthetic({
sessionID: event.data.sessionID,
text: event.data.text,
@@ -140,7 +151,7 @@ export function update<Result>(adapter: Adapter<Result>, event: SessionEvent.Eve
)
},
"session.next.shell.started": (event) => {
adapter.appendMessage(
return adapter.appendMessage(
new SessionMessage.Shell({
id: event.id,
type: "shell",
@@ -153,39 +164,46 @@ export function update<Result>(adapter: Adapter<Result>, event: SessionEvent.Eve
)
},
"session.next.shell.ended": (event) => {
const currentShell = adapter.getCurrentShell(event.data.callID)
return Effect.gen(function* () {
const currentShell = yield* adapter.getCurrentShell(event.data.callID)
if (currentShell) {
adapter.updateShell(
yield* adapter.updateShell(
produce(currentShell, (draft) => {
draft.output = event.data.output
draft.time.completed = event.data.timestamp
}),
)
}
})
},
"session.next.step.started": (event) => {
if (currentAssistant) {
adapter.updateAssistant(
produce(currentAssistant, (draft) => {
draft.time.completed = event.data.timestamp
return Effect.gen(function* () {
const currentAssistant = yield* adapter.getCurrentAssistant()
if (currentAssistant) {
yield* adapter.updateAssistant(
produce(currentAssistant, (draft) => {
draft.time.completed = event.data.timestamp
}),
)
}
yield* adapter.appendMessage(
new SessionMessage.Assistant({
id: event.id,
type: "assistant",
agent: event.data.agent,
model: event.data.model,
time: { created: event.data.timestamp },
content: [],
snapshot: event.data.snapshot ? { start: event.data.snapshot } : undefined,
}),
)
}
adapter.appendMessage(
new SessionMessage.Assistant({
id: event.id,
type: "assistant",
agent: event.data.agent,
model: event.data.model,
time: { created: event.data.timestamp },
content: [],
snapshot: event.data.snapshot ? { start: event.data.snapshot } : undefined,
}),
)
})
},
"session.next.step.ended": (event) => {
return Effect.gen(function* () {
const currentAssistant = yield* adapter.getCurrentAssistant()
if (currentAssistant) {
adapter.updateAssistant(
yield* adapter.updateAssistant(
produce(currentAssistant, (draft) => {
draft.time.completed = event.data.timestamp
draft.finish = event.data.finish
@@ -195,10 +213,13 @@ export function update<Result>(adapter: Adapter<Result>, event: SessionEvent.Eve
}),
)
}
})
},
"session.next.step.failed": (event) => {
return Effect.gen(function* () {
const currentAssistant = yield* adapter.getCurrentAssistant()
if (currentAssistant) {
adapter.updateAssistant(
yield* adapter.updateAssistant(
produce(currentAssistant, (draft) => {
draft.time.completed = event.data.timestamp
draft.finish = "error"
@@ -206,62 +227,71 @@ export function update<Result>(adapter: Adapter<Result>, event: SessionEvent.Eve
}),
)
}
})
},
"session.next.text.started": () => {
return Effect.gen(function* () {
const currentAssistant = yield* adapter.getCurrentAssistant()
if (currentAssistant) {
adapter.updateAssistant(
yield* adapter.updateAssistant(
produce(currentAssistant, (draft) => {
draft.content.push({
type: "text",
text: "",
})
draft.content.push(new SessionMessage.AssistantText({ type: "text", text: "" }) as DraftText)
}),
)
}
})
},
"session.next.text.delta": (event) => {
return Effect.gen(function* () {
const currentAssistant = yield* adapter.getCurrentAssistant()
if (currentAssistant) {
adapter.updateAssistant(
yield* adapter.updateAssistant(
produce(currentAssistant, (draft) => {
const match = latestText(draft)
if (match) match.text += event.data.delta
}),
)
}
})
},
"session.next.text.ended": (event) => {
return Effect.gen(function* () {
const currentAssistant = yield* adapter.getCurrentAssistant()
if (currentAssistant) {
adapter.updateAssistant(
yield* adapter.updateAssistant(
produce(currentAssistant, (draft) => {
const match = latestText(draft)
if (match) match.text = event.data.text
}),
)
}
})
},
"session.next.tool.input.started": (event) => {
return Effect.gen(function* () {
const currentAssistant = yield* adapter.getCurrentAssistant()
if (currentAssistant) {
adapter.updateAssistant(
yield* adapter.updateAssistant(
produce(currentAssistant, (draft) => {
draft.content.push({
type: "tool",
id: event.data.callID,
name: event.data.name,
time: {
created: event.data.timestamp,
},
state: {
status: "pending",
input: "",
},
})
draft.content.push(
new SessionMessage.AssistantTool({
type: "tool",
id: event.data.callID,
name: event.data.name,
time: { created: event.data.timestamp },
state: new SessionMessage.ToolStatePending({ status: "pending", input: "" }),
}) as DraftTool,
)
}),
)
}
})
},
"session.next.tool.input.delta": (event) => {
return Effect.gen(function* () {
const currentAssistant = yield* adapter.getCurrentAssistant()
if (currentAssistant) {
adapter.updateAssistant(
yield* adapter.updateAssistant(
produce(currentAssistant, (draft) => {
const match = latestTool(draft, event.data.callID)
// oxlint-disable-next-line no-base-to-string -- event.delta is a Schema.String (runtime string)
@@ -269,11 +299,14 @@ export function update<Result>(adapter: Adapter<Result>, event: SessionEvent.Eve
}),
)
}
})
},
"session.next.tool.input.ended": () => {},
"session.next.tool.input.ended": () => Effect.void,
"session.next.tool.called": (event) => {
return Effect.gen(function* () {
const currentAssistant = yield* adapter.getCurrentAssistant()
if (currentAssistant) {
adapter.updateAssistant(
yield* adapter.updateAssistant(
produce(currentAssistant, (draft) => {
const match = latestTool(draft, event.data.callID)
if (match) {
@@ -289,10 +322,13 @@ export function update<Result>(adapter: Adapter<Result>, event: SessionEvent.Eve
}),
)
}
})
},
"session.next.tool.progress": (event) => {
return Effect.gen(function* () {
const currentAssistant = yield* adapter.getCurrentAssistant()
if (currentAssistant) {
adapter.updateAssistant(
yield* adapter.updateAssistant(
produce(currentAssistant, (draft) => {
const match = latestTool(draft, event.data.callID)
if (match && match.state.status === "running") {
@@ -302,10 +338,13 @@ export function update<Result>(adapter: Adapter<Result>, event: SessionEvent.Eve
}),
)
}
})
},
"session.next.tool.success": (event) => {
return Effect.gen(function* () {
const currentAssistant = yield* adapter.getCurrentAssistant()
if (currentAssistant) {
adapter.updateAssistant(
yield* adapter.updateAssistant(
produce(currentAssistant, (draft) => {
const match = latestTool(draft, event.data.callID)
if (match && match.state.status === "running") {
@@ -321,10 +360,13 @@ export function update<Result>(adapter: Adapter<Result>, event: SessionEvent.Eve
}),
)
}
})
},
"session.next.tool.failed": (event) => {
return Effect.gen(function* () {
const currentAssistant = yield* adapter.getCurrentAssistant()
if (currentAssistant) {
adapter.updateAssistant(
yield* adapter.updateAssistant(
produce(currentAssistant, (draft) => {
const match = latestTool(draft, event.data.callID)
if (match && match.state.status === "running") {
@@ -341,43 +383,55 @@ export function update<Result>(adapter: Adapter<Result>, event: SessionEvent.Eve
}),
)
}
})
},
"session.next.reasoning.started": (event) => {
return Effect.gen(function* () {
const currentAssistant = yield* adapter.getCurrentAssistant()
if (currentAssistant) {
adapter.updateAssistant(
yield* adapter.updateAssistant(
produce(currentAssistant, (draft) => {
draft.content.push({
type: "reasoning",
id: event.data.reasoningID,
text: "",
})
draft.content.push(
new SessionMessage.AssistantReasoning({
type: "reasoning",
id: event.data.reasoningID,
text: "",
}) as DraftReasoning,
)
}),
)
}
})
},
"session.next.reasoning.delta": (event) => {
return Effect.gen(function* () {
const currentAssistant = yield* adapter.getCurrentAssistant()
if (currentAssistant) {
adapter.updateAssistant(
yield* adapter.updateAssistant(
produce(currentAssistant, (draft) => {
const match = latestReasoning(draft, event.data.reasoningID)
if (match) match.text += event.data.delta
}),
)
}
})
},
"session.next.reasoning.ended": (event) => {
return Effect.gen(function* () {
const currentAssistant = yield* adapter.getCurrentAssistant()
if (currentAssistant) {
adapter.updateAssistant(
yield* adapter.updateAssistant(
produce(currentAssistant, (draft) => {
const match = latestReasoning(draft, event.data.reasoningID)
if (match) match.text = event.data.text
}),
)
}
})
},
"session.next.retried": () => {},
"session.next.retried": () => Effect.void,
"session.next.compaction.started": (event) => {
adapter.appendMessage(
return adapter.appendMessage(
new SessionMessage.Compaction({
id: event.id,
type: "compaction",
@@ -389,29 +443,32 @@ export function update<Result>(adapter: Adapter<Result>, event: SessionEvent.Eve
)
},
"session.next.compaction.delta": (event) => {
const currentCompaction = adapter.getCurrentCompaction()
return Effect.gen(function* () {
const currentCompaction = yield* adapter.getCurrentCompaction()
if (currentCompaction) {
adapter.updateCompaction(
yield* adapter.updateCompaction(
produce(currentCompaction, (draft) => {
draft.summary += event.data.text
}),
)
}
})
},
"session.next.compaction.ended": (event) => {
const currentCompaction = adapter.getCurrentCompaction()
return Effect.gen(function* () {
const currentCompaction = yield* adapter.getCurrentCompaction()
if (currentCompaction) {
adapter.updateCompaction(
yield* adapter.updateCompaction(
produce(currentCompaction, (draft) => {
draft.summary = event.data.text
draft.include = event.data.include
}),
)
}
})
},
})
return adapter.finish()
})
}
export * as SessionMessageUpdater from "./session-message-updater"
export * as SessionMessageUpdater from "./message-updater"
@@ -1,10 +1,12 @@
export * as SessionMessage from "./message"
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<typeof ID>
@@ -169,5 +171,3 @@ export const Message = Schema.Union([AgentSwitched, ModelSwitched, User, Synthet
export type Message = Schema.Schema.Type<typeof Message>
export type Type = Message["type"]
export * as SessionMessage from "./session-message"
+455
View File
@@ -0,0 +1,455 @@
export * as SessionProjector from "./projector"
import { and, eq, sql } from "drizzle-orm"
import { DateTime, Effect, Layer, Schema } from "effect"
import { Database } from "../database/database"
import { EventV2 } from "../event"
import { SessionEvent } from "./event"
import { SessionLegacy } from "./legacy"
import { WorkspaceTable } from "../control-plane/workspace.sql"
import { SessionMessage } from "./message"
import { SessionMessageUpdater } from "./message-updater"
import { MessageTable, PartTable, SessionMessageTable, SessionTable } from "./sql"
import type { DeepMutable } from "../schema"
type DatabaseService = Database.Interface["db"]
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Message)
const encodeMessage = Schema.encodeSync(SessionMessage.Message)
type Usage = {
cost: number
tokens: {
input: number
output: number
reasoning: number
cache: { read: number; write: number }
}
}
function usage(part: (typeof SessionLegacy.Event.PartUpdated.Type)["data"]["part"] | unknown): Usage | undefined {
if (typeof part !== "object" || part === null) return undefined
const value = part as Record<string, unknown>
if (value.type !== "step-finish") return undefined
if (!("cost" in value) || !("tokens" in value)) return undefined
return { cost: value.cost as Usage["cost"], tokens: value.tokens as Usage["tokens"] }
}
function sessionRow(info: SessionLegacy.SessionInfo): typeof SessionTable.$inferInsert {
return {
id: info.id,
project_id: info.projectID,
workspace_id: info.workspaceID ?? null,
parent_id: info.parentID,
slug: info.slug,
directory: info.directory,
path: info.path,
title: info.title,
agent: info.agent,
model: info.model,
version: info.version,
share_url: info.share?.url,
summary_additions: info.summary?.additions,
summary_deletions: info.summary?.deletions,
summary_files: info.summary?.files,
summary_diffs: info.summary?.diffs ? [...info.summary.diffs] : undefined,
cost: info.cost ?? 0,
tokens_input: (info.tokens ?? { input: 0 }).input,
tokens_output: (info.tokens ?? { output: 0 }).output,
tokens_reasoning: (info.tokens ?? { reasoning: 0 }).reasoning,
tokens_cache_read: (info.tokens ?? { cache: { read: 0 } }).cache.read,
tokens_cache_write: (info.tokens ?? { cache: { write: 0 } }).cache.write,
revert: info.revert ?? null,
permission: info.permission ? [...info.permission] : undefined,
time_created: info.time.created,
time_updated: info.time.updated,
time_compacting: info.time.compacting,
time_archived: info.time.archived,
}
}
function messageData(
info: (typeof SessionLegacy.Event.MessageUpdated.Type)["data"]["info"],
): typeof MessageTable.$inferInsert.data {
const { id: _, sessionID: __, ...rest } = info
return rest as DeepMutable<typeof rest>
}
function partData(
part: (typeof SessionLegacy.Event.PartUpdated.Type)["data"]["part"],
): typeof PartTable.$inferInsert.data {
const { id: _, messageID: __, sessionID: ___, ...rest } = part
return rest as DeepMutable<typeof rest>
}
function applyUsage(
db: DatabaseService,
sessionID: (typeof SessionLegacy.Event.MessageUpdated.Type)["data"]["sessionID"],
value: Usage,
sign = 1,
) {
return db
.update(SessionTable)
.set({
cost: sql`${SessionTable.cost} + ${value.cost * sign}`,
tokens_input: sql`${SessionTable.tokens_input} + ${value.tokens.input * sign}`,
tokens_output: sql`${SessionTable.tokens_output} + ${value.tokens.output * sign}`,
tokens_reasoning: sql`${SessionTable.tokens_reasoning} + ${value.tokens.reasoning * sign}`,
tokens_cache_read: sql`${SessionTable.tokens_cache_read} + ${value.tokens.cache.read * sign}`,
tokens_cache_write: sql`${SessionTable.tokens_cache_write} + ${value.tokens.cache.write * sign}`,
time_updated: sql`${SessionTable.time_updated}`,
})
.where(eq(SessionTable.id, sessionID))
.run()
.pipe(Effect.orDie)
}
function run(db: DatabaseService, event: SessionEvent.Event) {
return Effect.gen(function* () {
const adapter: SessionMessageUpdater.Adapter = {
getCurrentAssistant() {
return Effect.gen(function* () {
const rows = yield* db
.select()
.from(SessionMessageTable)
.where(
and(eq(SessionMessageTable.session_id, event.data.sessionID), eq(SessionMessageTable.type, "assistant")),
)
.all()
.pipe(Effect.orDie)
return rows
.map((row) => decodeMessage({ ...row.data, id: row.id, type: row.type }))
.find(
(message): message is SessionMessage.Assistant => message.type === "assistant" && !message.time.completed,
)
})
},
getCurrentCompaction() {
return Effect.gen(function* () {
const rows = yield* db
.select()
.from(SessionMessageTable)
.where(
and(eq(SessionMessageTable.session_id, event.data.sessionID), eq(SessionMessageTable.type, "compaction")),
)
.all()
.pipe(Effect.orDie)
return rows
.map((row) => decodeMessage({ ...row.data, id: row.id, type: row.type }))
.find((message): message is SessionMessage.Compaction => message.type === "compaction")
})
},
getCurrentShell(callID) {
return Effect.gen(function* () {
const rows = yield* db
.select()
.from(SessionMessageTable)
.where(and(eq(SessionMessageTable.session_id, event.data.sessionID), eq(SessionMessageTable.type, "shell")))
.all()
.pipe(Effect.orDie)
return rows
.map((row) => decodeMessage({ ...row.data, id: row.id, type: row.type }))
.find((message): message is SessionMessage.Shell => message.type === "shell" && message.callID === callID)
})
},
updateAssistant(message) {
return Effect.gen(function* () {
const encoded = encodeMessage(message)
const { id, type, ...data } = encoded
yield* db
.insert(SessionMessageTable)
.values([
{
id: SessionMessage.ID.make(id),
session_id: event.data.sessionID,
type,
time_created: DateTime.toEpochMillis(message.time.created),
data,
},
])
.onConflictDoUpdate({
target: SessionMessageTable.id,
set: {
type,
time_created: DateTime.toEpochMillis(message.time.created),
data,
},
})
.run()
.pipe(Effect.orDie)
})
},
updateCompaction(message) {
return Effect.gen(function* () {
const encoded = encodeMessage(message)
const { id, type, ...data } = encoded
yield* db
.insert(SessionMessageTable)
.values([
{
id: SessionMessage.ID.make(id),
session_id: event.data.sessionID,
type,
time_created: DateTime.toEpochMillis(message.time.created),
data,
},
])
.onConflictDoUpdate({
target: SessionMessageTable.id,
set: {
type,
time_created: DateTime.toEpochMillis(message.time.created),
data,
},
})
.run()
.pipe(Effect.orDie)
})
},
updateShell(message) {
return Effect.gen(function* () {
const encoded = encodeMessage(message)
const { id, type, ...data } = encoded
yield* db
.insert(SessionMessageTable)
.values([
{
id: SessionMessage.ID.make(id),
session_id: event.data.sessionID,
type,
time_created: DateTime.toEpochMillis(message.time.created),
data,
},
])
.onConflictDoUpdate({
target: SessionMessageTable.id,
set: {
type,
time_created: DateTime.toEpochMillis(message.time.created),
data,
},
})
.run()
.pipe(Effect.orDie)
})
},
appendMessage(message) {
return Effect.gen(function* () {
const encoded = encodeMessage(message)
const { id, type, ...data } = encoded
yield* db
.insert(SessionMessageTable)
.values([
{
id: SessionMessage.ID.make(id),
session_id: event.data.sessionID,
type,
time_created: DateTime.toEpochMillis(message.time.created),
data,
},
])
.onConflictDoUpdate({
target: SessionMessageTable.id,
set: {
type,
time_created: DateTime.toEpochMillis(message.time.created),
data,
},
})
.run()
.pipe(Effect.orDie)
})
},
}
yield* SessionMessageUpdater.update(adapter, event)
})
}
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
const events = yield* EventV2.Service
const { db } = yield* Database.Service
yield* events.project(SessionLegacy.Event.Created, (event) =>
Effect.gen(function* () {
yield* db.insert(SessionTable).values(sessionRow(event.data.info)).run().pipe(Effect.orDie)
if (event.data.info.workspaceID) {
yield* db
.update(WorkspaceTable)
.set({ time_used: Date.now() })
.where(eq(WorkspaceTable.id, event.data.info.workspaceID))
.run()
.pipe(Effect.orDie)
}
}),
)
yield* events.project(SessionLegacy.Event.Updated, (event) =>
db
.update(SessionTable)
.set(sessionRow(event.data.info))
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie),
)
yield* events.project(SessionLegacy.Event.Deleted, (event) =>
db.delete(SessionTable).where(eq(SessionTable.id, event.data.sessionID)).run().pipe(Effect.orDie),
)
yield* events.project(SessionLegacy.Event.MessageUpdated, (event) =>
Effect.gen(function* () {
const time_created = event.data.info.time.created
const id = event.data.info.id
const sessionID = event.data.info.sessionID
const data = messageData(event.data.info)
yield* db
.insert(MessageTable)
.values({ id, session_id: sessionID, time_created, data })
.onConflictDoUpdate({ target: MessageTable.id, set: { data } })
.run()
.pipe(Effect.orDie)
}),
)
yield* events.project(SessionLegacy.Event.MessageRemoved, (event) =>
Effect.gen(function* () {
const rows = yield* db
.select()
.from(PartTable)
.where(and(eq(PartTable.message_id, event.data.messageID), eq(PartTable.session_id, event.data.sessionID)))
.all()
.pipe(Effect.orDie)
for (const row of rows) {
const previous = usage(row.data)
if (previous) yield* applyUsage(db, event.data.sessionID, previous, -1)
}
yield* db
.delete(MessageTable)
.where(and(eq(MessageTable.id, event.data.messageID), eq(MessageTable.session_id, event.data.sessionID)))
.run()
.pipe(Effect.orDie)
}),
)
yield* events.project(SessionLegacy.Event.PartRemoved, (event) =>
Effect.gen(function* () {
const row = yield* db
.select()
.from(PartTable)
.where(and(eq(PartTable.id, event.data.partID), eq(PartTable.session_id, event.data.sessionID)))
.get()
.pipe(Effect.orDie)
const previous = row && usage(row.data)
if (previous) yield* applyUsage(db, event.data.sessionID, previous, -1)
yield* db
.delete(PartTable)
.where(and(eq(PartTable.id, event.data.partID), eq(PartTable.session_id, event.data.sessionID)))
.run()
.pipe(Effect.orDie)
}),
)
yield* events.project(SessionLegacy.Event.PartUpdated, (event) =>
Effect.gen(function* () {
const id = event.data.part.id
const messageID = event.data.part.messageID
const sessionID = event.data.part.sessionID
const data = partData(event.data.part)
const row = yield* db.select().from(PartTable).where(eq(PartTable.id, id)).get().pipe(Effect.orDie)
yield* db
.insert(PartTable)
.values({ id, message_id: messageID, session_id: sessionID, time_created: event.data.time, data })
.onConflictDoUpdate({ target: PartTable.id, set: { data } })
.run()
.pipe(Effect.orDie)
const previous = row && usage(row.data)
const next = usage(event.data.part)
if (previous) yield* applyUsage(db, row.session_id, previous, -1)
if (next) yield* applyUsage(db, sessionID, next)
}),
)
// session.next.* projectors are disabled while the v2 message projection is stabilized.
// The events still publish through EventV2 and fan out through the opencode bridge.
// yield* events.project(SessionEvent.AgentSwitched, (event) =>
// Effect.gen(function* () {
// const message = Schema.encodeSync(SessionMessage.AgentSwitched)(
// new SessionMessage.AgentSwitched({
// id: event.id,
// type: "agent-switched",
// metadata: event.metadata,
// agent: event.data.agent,
// time: { created: event.data.timestamp },
// }),
// )
// const data = { metadata: message.metadata, agent: message.agent, time: message.time }
// yield* db
// .update(SessionTable)
// .set({ agent: event.data.agent, time_updated: DateTime.toEpochMillis(event.data.timestamp) })
// .where(eq(SessionTable.id, event.data.sessionID))
// .run()
// .pipe(Effect.orDie)
// yield* db
// .insert(SessionMessageTable)
// .values([
// {
// id: SessionMessage.ID.make(event.id),
// session_id: event.data.sessionID,
// type: "agent-switched",
// time_created: DateTime.toEpochMillis(event.data.timestamp),
// data,
// },
// ])
// .run()
// .pipe(Effect.orDie)
// }),
// )
// yield* events.project(SessionEvent.ModelSwitched, (event) =>
// Effect.gen(function* () {
// const message = Schema.encodeSync(SessionMessage.ModelSwitched)(
// new SessionMessage.ModelSwitched({
// id: event.id,
// type: "model-switched",
// metadata: event.metadata,
// model: event.data.model,
// time: { created: event.data.timestamp },
// }),
// )
// const data = { metadata: message.metadata, model: message.model, time: message.time }
// yield* db
// .update(SessionTable)
// .set({ model: event.data.model, time_updated: DateTime.toEpochMillis(event.data.timestamp) })
// .where(eq(SessionTable.id, event.data.sessionID))
// .run()
// .pipe(Effect.orDie)
// yield* db
// .insert(SessionMessageTable)
// .values([
// {
// id: SessionMessage.ID.make(event.id),
// session_id: event.data.sessionID,
// type: "model-switched",
// time_created: DateTime.toEpochMillis(event.data.timestamp),
// data,
// },
// ])
// .run()
// .pipe(Effect.orDie)
// }),
// )
// yield* events.project(SessionEvent.Prompted, (event) => run(db, event))
// yield* events.project(SessionEvent.Synthetic, (event) => run(db, event))
// yield* events.project(SessionEvent.Shell.Started, (event) => run(db, event))
// yield* events.project(SessionEvent.Shell.Ended, (event) => run(db, event))
// yield* events.project(SessionEvent.Step.Started, (event) => run(db, event))
// yield* events.project(SessionEvent.Step.Ended, (event) => run(db, event))
// yield* events.project(SessionEvent.Step.Failed, (event) => run(db, event))
// yield* events.project(SessionEvent.Text.Started, (event) => run(db, event))
// yield* events.project(SessionEvent.Text.Ended, (event) => run(db, event))
// yield* events.project(SessionEvent.Tool.Input.Started, (event) => run(db, event))
// yield* events.project(SessionEvent.Tool.Input.Ended, (event) => run(db, event))
// yield* events.project(SessionEvent.Tool.Called, (event) => run(db, event))
// yield* events.project(SessionEvent.Tool.Success, (event) => run(db, event))
// yield* events.project(SessionEvent.Tool.Failed, (event) => run(db, event))
// yield* events.project(SessionEvent.Reasoning.Started, (event) => run(db, event))
// yield* events.project(SessionEvent.Reasoning.Ended, (event) => run(db, event))
// yield* events.project(SessionEvent.Retried, (event) => run(db, event))
// yield* events.project(SessionEvent.Compaction.Started, (event) => run(db, event))
// yield* events.project(SessionEvent.Compaction.Ended, (event) => run(db, event))
}),
)
export const defaultLayer = layer.pipe(Layer.provide(EventV2.defaultLayer), Layer.provide(Database.defaultLayer))

Some files were not shown because too many files have changed in this diff Show More