refactor(core): move database schema ownership (#29068)

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
This commit is contained in:
Dax
2026-05-30 21:08:38 -04:00
committed by GitHub
parent 6bcb9cb9bb
commit 7f571d36ea
390 changed files with 11127 additions and 9164 deletions
@@ -1,39 +0,0 @@
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"
export const AccountTable = sqliteTable("account", {
id: text().$type<AccountID>().primaryKey(),
email: text().notNull(),
url: text().notNull(),
access_token: text().$type<AccessToken>().notNull(),
refresh_token: text().$type<RefreshToken>().notNull(),
token_expiry: integer(),
...Timestamps,
})
export const AccountStateTable = sqliteTable("account_state", {
id: integer().primaryKey(),
active_account_id: text()
.$type<AccountID>()
.references(() => AccountTable.id, { onDelete: "set null" }),
active_org_id: text().$type<OrgID>(),
})
// LEGACY
export const ControlAccountTable = sqliteTable(
"control_account",
{
email: text().notNull(),
url: text().notNull(),
access_token: text().$type<AccessToken>().notNull(),
refresh_token: text().$type<RefreshToken>().notNull(),
token_expiry: integer(),
active: integer({ mode: "boolean" })
.notNull()
.$default(() => false),
...Timestamps,
},
(table) => [primaryKey({ columns: [table.email, table.url] })],
)
+1 -1
View File
@@ -454,6 +454,6 @@ export const layer: Layer.Layer<Service, never, AccountRepo.Service | HttpClient
}),
)
export const defaultLayer = layer.pipe(Layer.provide(AccountRepo.layer), Layer.provide(FetchHttpClient.layer))
export const defaultLayer = layer.pipe(Layer.provide(AccountRepo.defaultLayer), Layer.provide(FetchHttpClient.layer))
export * as Account from "./account"
+68 -59
View File
@@ -2,16 +2,13 @@ import { eq } from "drizzle-orm"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { Effect, Layer, Option, Schema, Context } from "effect"
import { Database } from "@/storage/db"
import { AccountStateTable, AccountTable } from "./account.sql"
import { Database } from "@opencode-ai/core/database/database"
import { AccountStateTable, AccountTable } from "@opencode-ai/core/account/sql"
import { AccessToken, AccountID, AccountRepoError, Info, OrgID, RefreshToken } from "./schema"
import { normalizeServerUrl } from "./url"
export type AccountRow = (typeof AccountTable)["$inferSelect"]
type DbClient = Parameters<typeof Database.use>[0] extends (db: infer T) => unknown ? T : never
type DbTransactionCallback<A> = Parameters<typeof Database.transaction<A>>[0]
const ACCOUNT_STATE_ID = 1
export interface Interface {
@@ -41,32 +38,32 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Ac
export const use = serviceUse(Service)
export const layer: Layer.Layer<Service> = Layer.effect(
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const { db } = yield* Database.Service
const decode = Schema.decodeUnknownSync(Info)
const query = <A>(f: DbTransactionCallback<A>) =>
Effect.try({
try: () => Database.use(f),
catch: (cause) => new AccountRepoError({ message: "Database operation failed", cause }),
})
const query = <A, E>(effect: Effect.Effect<A, E>) =>
effect.pipe(Effect.mapError((cause) => new AccountRepoError({ message: "Database operation failed", cause })))
const tx = <A>(f: DbTransactionCallback<A>) =>
Effect.try({
try: () => Database.transaction(f),
catch: (cause) => new AccountRepoError({ message: "Database operation failed", cause }),
})
const current = (db: DbClient) => {
const state = db.select().from(AccountStateTable).where(eq(AccountStateTable.id, ACCOUNT_STATE_ID)).get()
const current = Effect.fnUntraced(function* () {
const state = yield* db
.select()
.from(AccountStateTable)
.where(eq(AccountStateTable.id, ACCOUNT_STATE_ID))
.get()
if (!state?.active_account_id) return
const account = db.select().from(AccountTable).where(eq(AccountTable.id, state.active_account_id)).get()
const account = yield* db
.select()
.from(AccountTable)
.where(eq(AccountTable.id, state.active_account_id))
.get()
if (!account) return
return { ...account, active_org_id: state.active_org_id ?? null }
}
})
const state = (db: DbClient, accountID: AccountID, orgID: Option.Option<OrgID>) => {
const state = (accountID: AccountID, orgID: Option.Option<OrgID>) => {
const id = Option.getOrNull(orgID)
return db
.insert(AccountStateTable)
@@ -79,41 +76,46 @@ export const layer: Layer.Layer<Service> = Layer.effect(
}
const active = Effect.fn("AccountRepo.active")(() =>
query((db) => current(db)).pipe(Effect.map((row) => (row ? Option.some(decode(row)) : Option.none()))),
query(current()).pipe(Effect.map((row) => (row ? Option.some(decode(row)) : Option.none()))),
)
const list = Effect.fn("AccountRepo.list")(() =>
query((db) =>
query(
db
.select()
.from(AccountTable)
.all()
.map((row: AccountRow) => decode({ ...row, active_org_id: null })),
.pipe(Effect.map((rows) => rows.map((row: AccountRow) => decode({ ...row, active_org_id: null })))),
),
)
const remove = Effect.fn("AccountRepo.remove")((accountID: AccountID) =>
tx((db) => {
db.update(AccountStateTable)
.set({ active_account_id: null, active_org_id: null })
.where(eq(AccountStateTable.active_account_id, accountID))
.run()
db.delete(AccountTable).where(eq(AccountTable.id, accountID)).run()
}).pipe(Effect.asVoid),
query(
db.transaction((tx) =>
Effect.gen(function* () {
yield* tx
.update(AccountStateTable)
.set({ active_account_id: null, active_org_id: null })
.where(eq(AccountStateTable.active_account_id, accountID))
.run()
yield* tx.delete(AccountTable).where(eq(AccountTable.id, accountID)).run()
}),
),
).pipe(Effect.asVoid),
)
const use = Effect.fn("AccountRepo.use")((accountID: AccountID, orgID: Option.Option<OrgID>) =>
query((db) => state(db, accountID, orgID)).pipe(Effect.asVoid),
query(state(accountID, orgID)).pipe(Effect.asVoid),
)
const getRow = Effect.fn("AccountRepo.getRow")((accountID: AccountID) =>
query((db) => db.select().from(AccountTable).where(eq(AccountTable.id, accountID)).get()).pipe(
query(db.select().from(AccountTable).where(eq(AccountTable.id, accountID)).get()).pipe(
Effect.map(Option.fromNullishOr),
),
)
const persistToken = Effect.fn("AccountRepo.persistToken")((input) =>
query((db) =>
query(
db
.update(AccountTable)
.set({
@@ -127,31 +129,36 @@ export const layer: Layer.Layer<Service> = Layer.effect(
)
const persistAccount = Effect.fn("AccountRepo.persistAccount")((input) =>
tx((db) => {
const url = normalizeServerUrl(input.url)
query(
db.transaction((tx) =>
Effect.gen(function* () {
const url = normalizeServerUrl(input.url)
db.insert(AccountTable)
.values({
id: input.id,
email: input.email,
url,
access_token: input.accessToken,
refresh_token: input.refreshToken,
token_expiry: input.expiry,
})
.onConflictDoUpdate({
target: AccountTable.id,
set: {
email: input.email,
url,
access_token: input.accessToken,
refresh_token: input.refreshToken,
token_expiry: input.expiry,
},
})
.run()
void state(db, input.id, input.orgID)
}).pipe(Effect.asVoid),
yield* tx
.insert(AccountTable)
.values({
id: input.id,
email: input.email,
url,
access_token: input.accessToken,
refresh_token: input.refreshToken,
token_expiry: input.expiry,
})
.onConflictDoUpdate({
target: AccountTable.id,
set: {
email: input.email,
url,
access_token: input.accessToken,
refresh_token: input.refreshToken,
token_expiry: input.expiry,
},
})
.run()
yield* state(input.id, input.orgID)
}),
),
).pipe(Effect.asVoid),
)
return Service.of({
@@ -166,4 +173,6 @@ export const layer: Layer.Layer<Service> = Layer.effect(
}),
)
export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer))
export * as AccountRepo from "./repo"
+3 -3
View File
@@ -1,9 +1,9 @@
import type { ContentBlock, ContentChunk, ResourceLink, Role } from "@agentclientprotocol/sdk"
import path from "node:path"
import { pathToFileURL } from "node:url"
import type { MessageV2 } from "@/session/message-v2"
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
export type PromptPart = MessageV2.TextPartInput | MessageV2.FilePartInput
export type PromptPart = SessionLegacy.TextPartInput | SessionLegacy.FilePartInput
export type ReplayPart =
| {
@@ -141,7 +141,7 @@ function uriToFilePart(
uri: string,
mime: string,
filename?: string,
): MessageV2.FilePartInput | MessageV2.TextPartInput {
): SessionLegacy.FilePartInput | SessionLegacy.TextPartInput {
try {
if (uri.startsWith("file://")) {
return {
+7 -7
View File
@@ -2,15 +2,15 @@ import { Agent } from "@/agent/agent"
import { Command } from "@/command"
import { InstanceRef } from "@/effect/instance-ref"
import { InstanceStore } from "@/project/instance-store"
import { ModelID, ProviderID } from "@/provider/schema"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Provider } from "@/provider/provider"
import { Context, Effect, Layer, SynchronizedRef } from "effect"
import type * as ACPError from "./error"
export type ModelOption = {
readonly providerID: ProviderID
readonly providerID: ProviderV2.ID
readonly providerName: string
readonly modelID: ModelID
readonly modelID: ProviderV2.ModelID
readonly modelName: string
}
@@ -23,13 +23,13 @@ export type ModeOption = {
export type ModelVariants = NonNullable<Provider.Model["variants"]>
export type DefaultModel = {
readonly providerID: ProviderID
readonly modelID: ModelID
readonly providerID: ProviderV2.ID
readonly modelID: ProviderV2.ModelID
}
export type Snapshot = {
readonly directory: string
readonly providers: Record<ProviderID, Provider.Info>
readonly providers: Record<ProviderV2.ID, Provider.Info>
readonly modelOptions: readonly ModelOption[]
readonly variantsByModel: Readonly<Record<string, ModelVariants>>
readonly availableModes: readonly ModeOption[]
@@ -58,7 +58,7 @@ export const variants = (snapshot: Snapshot, model: DefaultModel) => snapshot.va
export const build = (input: {
readonly directory: string
readonly providers: Record<ProviderID, Provider.Info>
readonly providers: Record<ProviderV2.ID, Provider.Info>
readonly modes: readonly ModeOption[]
readonly defaultModeID: string
readonly commands: readonly Command.Info[]
+12 -12
View File
@@ -41,7 +41,7 @@ import { ACPEvent } from "./event"
import { ACPSession } from "./session"
import { UsageService } from "./usage"
import { ACPProfile } from "./profile"
import { ModelID, ProviderID } from "@/provider/schema"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Provider } from "@/provider/provider"
import type { Command } from "@/command"
@@ -603,7 +603,7 @@ function makeUsageService(sdk: OpencodeClient) {
.then((response) => {
const providers = Object.fromEntries(
(response.data?.providers ?? []).map((provider) => [provider.id, provider]),
) as Record<ProviderID, Provider.Info>
) as Record<ProviderV2.ID, Provider.Info>
return UsageService.findContextLimit(providers, params.providerID, params.modelID)
})
.catch((error: unknown) => {
@@ -642,8 +642,8 @@ function makeUsageService(sdk: OpencodeClient) {
const size = yield* contextLimit({
directory: params.directory,
providerID: ProviderID.make(message.providerID),
modelID: ModelID.make(message.modelID),
providerID: ProviderV2.ID.make(message.providerID),
modelID: ProviderV2.ModelID.make(message.modelID),
})
if (!size) return
@@ -745,7 +745,7 @@ async function loadDirectorySnapshot(sdk: OpencodeClient, directory: string) {
const commandsData = commandsResponse.data!
const skills = skillsResponse.data!
const providers = Object.fromEntries(providersData.providers.map((provider) => [provider.id, provider])) as Record<
ProviderID,
ProviderV2.ID,
Provider.Info
>
const defaultModelStarted = performance.now()
@@ -784,7 +784,7 @@ async function loadDirectorySnapshot(sdk: OpencodeClient, directory: string) {
function defaultModelFromConfig(
configuredModel: string | undefined,
providers: Record<ProviderID, Provider.Info>,
providers: Record<ProviderV2.ID, Provider.Info>,
): Directory.DefaultModel | undefined {
const configured = configuredModel ? Provider.parseModel(configuredModel) : undefined
if (configured && providers[configured.providerID]?.models[configured.modelID]) return configured
@@ -792,7 +792,7 @@ function defaultModelFromConfig(
// First-session ACP startup must not scan historical sessions just to infer
// a default. Configured model, opencode provider, then sorted best model keep
// the protocol response deterministic without extra session/message reads.
const opencodeProvider = providers[ProviderID.make("opencode")]
const opencodeProvider = providers[ProviderV2.ID.make("opencode")]
const opencodeModel = opencodeProvider ? Provider.sort(Object.values(opencodeProvider.models))[0] : undefined
if (opencodeProvider && opencodeModel) return { providerID: opencodeProvider.id, modelID: opencodeModel.id }
@@ -805,7 +805,7 @@ function selectDefaultModel(snapshot: Directory.Snapshot) {
if (snapshot.defaultModel) return snapshot.defaultModel
const model = snapshot.modelOptions[0]
if (model) return { providerID: model.providerID, modelID: model.modelID }
return { providerID: "unknown" as ProviderID, modelID: "unknown" as ModelID }
return { providerID: "unknown" as ProviderV2.ID, modelID: "unknown" as ProviderV2.ModelID }
}
function detectSlashCommand(parts: ReturnType<typeof promptContentToParts>) {
@@ -864,8 +864,8 @@ function configOptions(snapshot: Directory.Snapshot, session: ConfigState) {
function parseSelectedModel(snapshot: Directory.Snapshot, modelId: string) {
const selected = parseModelSelection(modelId, Object.values(snapshot.providers))
const provider = snapshot.providers[ProviderID.make(selected.model.providerID)]
const model = provider?.models[ModelID.make(selected.model.modelID)]
const provider = snapshot.providers[ProviderV2.ID.make(selected.model.providerID)]
const model = provider?.models[ProviderV2.ModelID.make(selected.model.modelID)]
if (!model) {
return Effect.fail(
new ACPError.InvalidModelError({
@@ -993,7 +993,7 @@ function restoreFromMessages(messages: readonly MessageInfo[]) {
)
if (user?.model?.providerID && user.model.modelID) {
return {
model: { providerID: user.model.providerID as ProviderID, modelID: user.model.modelID as ModelID },
model: { providerID: user.model.providerID as ProviderV2.ID, modelID: user.model.modelID as ProviderV2.ModelID },
variant: user.model.variant,
modeId: user.agent,
}
@@ -1002,7 +1002,7 @@ function restoreFromMessages(messages: readonly MessageInfo[]) {
const assistant = messages.findLast((message) => message.providerID && message.modelID)
if (assistant?.providerID && assistant.modelID) {
return {
model: { providerID: assistant.providerID as ProviderID, modelID: assistant.modelID as ModelID },
model: { providerID: assistant.providerID as ProviderV2.ID, modelID: assistant.modelID as ProviderV2.ModelID },
variant: assistant.variant,
modeId: assistant.mode ?? assistant.agent,
}
+3 -3
View File
@@ -1,12 +1,12 @@
import type { McpServer } from "@agentclientprotocol/sdk"
import type { Message, Part } from "@opencode-ai/sdk/v2"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Context, Effect, Layer, Ref } from "effect"
import type { ModelID, ProviderID } from "../provider/schema"
import * as ACPError from "./error"
export type SelectedModel = {
providerID: ProviderID
modelID: ModelID
providerID: ProviderV2.ID
modelID: ProviderV2.ModelID
}
export type KnownMessagePartMetadata = {
+13 -13
View File
@@ -3,7 +3,7 @@ import * as Log from "@opencode-ai/core/util/log"
import type { AssistantMessage as OpenCodeAssistantMessage, Message } from "@opencode-ai/sdk/v2"
import { InstanceRef } from "@/effect/instance-ref"
import { InstanceStore } from "@/project/instance-store"
import { ModelID, ProviderID } from "@/provider/schema"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Provider } from "@/provider/provider"
import { Context, Effect, Layer, SynchronizedRef } from "effect"
@@ -38,7 +38,7 @@ export interface MessageLoaderInterface {
}
export interface ContextLimitLoaderInterface {
readonly providers: (directory: string) => Effect.Effect<Record<ProviderID, Provider.Info>, unknown>
readonly providers: (directory: string) => Effect.Effect<Record<ProviderV2.ID, Provider.Info>, unknown>
}
export type UsageConnection = Pick<AgentSideConnection, "sessionUpdate">
@@ -49,8 +49,8 @@ export interface Interface {
readonly totalSessionCost: (messages: readonly SessionMessage[]) => number
readonly contextLimit: (input: {
readonly directory: string
readonly providerID: ProviderID
readonly modelID: ModelID
readonly providerID: ProviderV2.ID
readonly modelID: ProviderV2.ModelID
}) => Effect.Effect<number | undefined>
readonly sendUpdate: (input: {
readonly connection: UsageConnection
@@ -110,9 +110,9 @@ export function totalSessionCost(messages: readonly SessionMessage[]): number {
}
export function findContextLimit(
providers: Record<ProviderID, Provider.Info>,
providerID: ProviderID,
modelID: ModelID,
providers: Record<ProviderV2.ID, Provider.Info>,
providerID: ProviderV2.ID,
modelID: ProviderV2.ModelID,
): number | undefined {
return providers[providerID]?.models[modelID]?.limit.context
}
@@ -143,8 +143,8 @@ export const layer = Layer.effect(
const cachedLimit = Effect.fnUntraced(function* (input: {
readonly directory: string
readonly providerID: ProviderID
readonly modelID: ModelID
readonly providerID: ProviderV2.ID
readonly modelID: ProviderV2.ModelID
}) {
return yield* SynchronizedRef.modifyEffect(
limits,
@@ -170,8 +170,8 @@ export const layer = Layer.effect(
const contextLimit = Effect.fn("ACPUsage.contextLimit")(function* (input: {
readonly directory: string
readonly providerID: ProviderID
readonly modelID: ModelID
readonly providerID: ProviderV2.ID
readonly modelID: ProviderV2.ModelID
}) {
return yield* yield* cachedLimit(input)
})
@@ -197,8 +197,8 @@ export const layer = Layer.effect(
const size = yield* contextLimit({
directory: input.directory,
providerID: ProviderID.make(message.providerID),
modelID: ModelID.make(message.modelID),
providerID: ProviderV2.ID.make(message.providerID),
modelID: ProviderV2.ModelID.make(message.modelID),
})
if (!size) return
+6 -5
View File
@@ -1,7 +1,7 @@
import { Config } from "@/config/config"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { Provider } from "@/provider/provider"
import { ModelID, ProviderID } from "../provider/schema"
import { generateObject, streamObject, type ModelMessage } from "ai"
import { Truncate } from "@/tool/truncate"
import { Auth } from "../auth"
@@ -25,6 +25,7 @@ import { RuntimeFlags } from "@/effect/runtime-flags"
import * as Option from "effect/Option"
import * as OtelTracer from "@effect/opentelemetry/Tracer"
import { type DeepMutable } from "@opencode-ai/core/schema"
import { ProviderV2 } from "@opencode-ai/core/provider"
export const Info = Schema.Struct({
name: Schema.String,
@@ -38,8 +39,8 @@ export const Info = Schema.Struct({
permission: Permission.Ruleset,
model: Schema.optional(
Schema.Struct({
modelID: ModelID,
providerID: ProviderID,
modelID: ProviderV2.ModelID,
providerID: ProviderV2.ID,
}),
),
variant: Schema.optional(Schema.String),
@@ -62,7 +63,7 @@ export interface Interface {
readonly defaultAgent: () => Effect.Effect<string>
readonly generate: (input: {
description: string
model?: { providerID: ProviderID; modelID: ModelID }
model?: { providerID: ProviderV2.ID; modelID: ProviderV2.ModelID }
}) => Effect.Effect<
{
identifier: string
@@ -383,7 +384,7 @@ export const layer = Layer.effect(
}),
generate: Effect.fn("Agent.generate")(function* (input: {
description: string
model?: { providerID: ProviderID; modelID: ModelID }
model?: { providerID: ProviderV2.ID; modelID: ProviderV2.ModelID }
}) {
const cfg = yield* config.get()
const model = input.model ?? (yield* provider.defaultModel())
-45
View File
@@ -1,45 +0,0 @@
import { Schema } from "effect"
import { EventV2 } from "@opencode-ai/core/event"
export type Definition<Type extends string = string, Properties extends Schema.Top = Schema.Top> = {
type: Type
properties: Properties
}
const registry = new Map<string, Definition>()
export function define<Type extends string, Properties extends Schema.Top>(
type: Type,
properties: Properties,
): Definition<Type, Properties> {
const result = { type, properties }
registry.set(type, result)
return result
}
export function effectPayloads() {
return [
...registry
.entries()
.map(([type, def]) =>
Schema.Struct({
id: Schema.String,
type: Schema.Literal(type),
properties: def.properties,
}).annotate({ identifier: `Event.${type}` }),
)
.toArray(),
...EventV2.registry
.values()
.map((definition) =>
Schema.Struct({
id: Schema.String,
type: Schema.Literal(definition.type),
properties: definition.data,
}).annotate({ identifier: `Event.${definition.type}` }),
)
.toArray(),
]
}
export * as BusEvent from "./bus-event"
-217
View File
@@ -1,217 +0,0 @@
import { Effect, Exit, Layer, PubSub, Scope, Context, Stream, Schema } from "effect"
import { EffectBridge } from "@/effect/bridge"
import * as Log from "@opencode-ai/core/util/log"
import { BusEvent } from "./bus-event"
import { GlobalBus } from "./global"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { Identifier } from "@/id/id"
import type { InstanceContext } from "@/project/instance-context"
import { InstanceRef } from "@/effect/instance-ref"
const log = Log.create({ service: "bus" })
type BusProperties<D extends BusEvent.Definition<string, Schema.Top>> = Schema.Schema.Type<D["properties"]>
export const InstanceDisposed = BusEvent.define(
"server.instance.disposed",
Schema.Struct({
directory: Schema.String,
}),
)
type Payload<D extends BusEvent.Definition = BusEvent.Definition> = {
id: string
type: D["type"]
properties: BusProperties<D>
}
type State = {
wildcard: PubSub.PubSub<Payload>
typed: Map<string, PubSub.PubSub<Payload>>
}
export interface Interface {
readonly publish: <D extends BusEvent.Definition>(
def: D,
properties: BusProperties<D>,
options?: { id?: string },
) => Effect.Effect<void>
// subscribe / subscribeAll are eager: the underlying PubSub subscription is
// acquired in the caller's Scope at `yield*` time. Any publish after the
// yield is delivered, even if stream consumption starts later. The previous
// Stream-returning shape acquired the subscription lazily on first pull,
// opening a race window during which publishes were lost — see
// test/bus/bus-effect.test.ts RACE tests.
readonly subscribe: <D extends BusEvent.Definition>(
def: D,
) => Effect.Effect<Stream.Stream<Payload<D>>, never, Scope.Scope>
readonly subscribeAll: () => Effect.Effect<Stream.Stream<Payload>, never, Scope.Scope>
readonly subscribeCallback: <D extends BusEvent.Definition>(
def: D,
callback: (event: Payload<D>) => unknown,
) => Effect.Effect<() => void>
readonly subscribeAllCallback: (callback: (event: any) => unknown) => Effect.Effect<() => void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Bus") {}
export const use = serviceUse(Service)
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const state = yield* InstanceState.make<State>(
Effect.fn("Bus.state")(function* (ctx) {
const wildcard = yield* PubSub.unbounded<Payload>()
const typed = new Map<string, PubSub.PubSub<Payload>>()
yield* Effect.addFinalizer(() =>
Effect.gen(function* () {
// Publish InstanceDisposed before shutting down so subscribers see it
yield* PubSub.publish(wildcard, {
type: InstanceDisposed.type,
id: createID(),
properties: { directory: ctx.directory },
})
yield* PubSub.shutdown(wildcard)
for (const ps of typed.values()) {
yield* PubSub.shutdown(ps)
}
}),
)
return { wildcard, typed }
}),
)
function getOrCreate<D extends BusEvent.Definition>(state: State, def: D) {
return Effect.gen(function* () {
let ps = state.typed.get(def.type)
if (!ps) {
ps = yield* PubSub.unbounded<Payload>()
state.typed.set(def.type, ps)
}
return ps as unknown as PubSub.PubSub<Payload<D>>
})
}
function publish<D extends BusEvent.Definition>(def: D, properties: BusProperties<D>, options?: { id?: string }) {
return Effect.gen(function* () {
const s = yield* InstanceState.get(state)
const payload: Payload = { id: options?.id ?? createID(), type: def.type, properties }
log.info("publishing", { type: def.type })
const ps = s.typed.get(def.type)
if (ps) yield* PubSub.publish(ps, payload)
yield* PubSub.publish(s.wildcard, payload)
const dir = yield* InstanceState.directory
const context = yield* InstanceState.context
const workspace = yield* InstanceState.workspaceID
GlobalBus.emit("event", {
directory: dir,
project: context.project.id,
workspace,
payload,
})
})
}
const subscribe = <D extends BusEvent.Definition>(
def: D,
): Effect.Effect<Stream.Stream<Payload<D>>, never, Scope.Scope> =>
Effect.gen(function* () {
log.info("subscribing", { type: def.type })
const s = yield* InstanceState.get(state)
const ps = yield* getOrCreate(s, def)
const subscription = yield* PubSub.subscribe(ps)
yield* Effect.addFinalizer(() => Effect.sync(() => log.info("unsubscribing", { type: def.type })))
return Stream.fromSubscription(subscription)
})
const subscribeAll = (): Effect.Effect<Stream.Stream<Payload>, never, Scope.Scope> =>
Effect.gen(function* () {
log.info("subscribing", { type: "*" })
const s = yield* InstanceState.get(state)
const subscription = yield* PubSub.subscribe(s.wildcard)
yield* Effect.addFinalizer(() => Effect.sync(() => log.info("unsubscribing", { type: "*" })))
return Stream.fromSubscription(subscription)
})
function on<T>(pubsub: PubSub.PubSub<T>, type: string, callback: (event: T) => unknown) {
return Effect.gen(function* () {
log.info("subscribing", { type })
const bridge = yield* EffectBridge.make()
const scope = yield* Scope.make()
const subscription = yield* Scope.provide(scope)(PubSub.subscribe(pubsub))
yield* Scope.provide(scope)(
Stream.fromSubscription(subscription).pipe(
Stream.runForEach((msg) =>
Effect.tryPromise({
try: () => Promise.resolve().then(() => callback(msg)),
catch: (cause) => {
log.error("subscriber failed", { type, cause })
},
}).pipe(Effect.ignore),
),
Effect.forkScoped,
),
)
return () => {
log.info("unsubscribing", { type })
bridge.fork(Scope.close(scope, Exit.void))
}
})
}
const subscribeCallback = Effect.fn("Bus.subscribeCallback")(function* <D extends BusEvent.Definition>(
def: D,
callback: (event: Payload<D>) => unknown,
) {
const s = yield* InstanceState.get(state)
const ps = yield* getOrCreate(s, def)
return yield* on(ps, def.type, callback)
})
const subscribeAllCallback = Effect.fn("Bus.subscribeAllCallback")(function* (callback: (event: any) => unknown) {
const s = yield* InstanceState.get(state)
return yield* on(s.wildcard, "*", callback)
})
return Service.of({ publish, subscribe, subscribeAll, subscribeCallback, subscribeAllCallback })
}),
)
export const defaultLayer = layer
const { runPromise, runSync } = makeRuntime(Service, layer)
// runSync is safe here because the subscribe chain (InstanceState.get, PubSub.subscribe,
// Scope.make, Effect.forkScoped) is entirely synchronous. If any step becomes async, this will throw.
export function createID() {
return Identifier.create("evt", "ascending")
}
export async function publish<D extends BusEvent.Definition>(
ctx: InstanceContext,
def: D,
properties: BusProperties<D>,
options?: { id?: string },
) {
return runPromise((svc) => svc.publish(def, properties, options).pipe(Effect.provideService(InstanceRef, ctx)))
}
export function subscribe<D extends BusEvent.Definition>(def: D, callback: (event: Payload<D>) => unknown) {
return runSync((svc) => svc.subscribeCallback(def, callback))
}
export function subscribeAll(callback: (event: any) => unknown) {
return runSync((svc) => svc.subscribeAllCallback(callback))
}
export * as Bus from "."
+26 -84
View File
@@ -1,17 +1,14 @@
import type { Argv } from "yargs"
import { spawn } from "child_process"
import { Database } from "@/storage/db"
import { drizzle } from "drizzle-orm/bun-sqlite"
import { Database as BunDatabase } from "bun:sqlite"
import { UI } from "../ui"
import { cmd } from "./cmd"
import { JsonMigration } from "@/storage/json-migration"
import { EOL } from "os"
import { errorMessage } from "../../util/error"
import { Database } from "@opencode-ai/core/database/database"
import { Effect } from "effect"
import { sql } from "drizzle-orm"
import { effectCmd } from "../effect-cmd"
const QueryCommand = cmd({
const QueryCommand = effectCmd({
command: "$0 [query]",
describe: "open an interactive sqlite3 shell or run a query",
instance: false,
builder: (yargs: Argv) => {
return yargs
.positional("query", {
@@ -25,96 +22,41 @@ const QueryCommand = cmd({
describe: "Output format",
})
},
handler: async (args: { query?: string; format: string }) => {
handler: Effect.fn("Cli.db.query")(function* (args: { query?: string; format: string }) {
const query = args.query as string | undefined
if (query) {
const db = new BunDatabase(Database.getPath(), { readonly: true })
try {
const result = db.query(query).all() as Record<string, unknown>[]
if (args.format === "json") {
console.log(JSON.stringify(result, null, 2))
} else if (result.length > 0) {
const keys = Object.keys(result[0])
console.log(keys.join("\t"))
for (const row of result) {
console.log(keys.map((k) => row[k]).join("\t"))
}
}
} catch (err) {
UI.error(errorMessage(err))
process.exit(1)
const { db } = yield* Database.Service
const result = yield* db.all<Record<string, unknown>>(sql.raw(query)).pipe(Effect.orDie)
if (args.format === "json") console.log(JSON.stringify(result, null, 2))
else if (result.length > 0) {
const keys = Object.keys(result[0])
console.log(keys.join("\t"))
for (const row of result) console.log(keys.map((key) => row[key]).join("\t"))
}
db.close()
return
}
const child = spawn("sqlite3", [Database.getPath()], {
const child = spawn("sqlite3", [Database.path()], {
stdio: "inherit",
})
await new Promise((resolve) => child.on("close", resolve))
},
yield* Effect.promise(() => new Promise((resolve) => child.on("close", resolve)))
}),
})
const PathCommand = cmd({
const PathCommand = effectCmd({
command: "path",
describe: "print the database path",
handler: () => {
console.log(Database.getPath())
},
instance: false,
handler: Effect.fn("Cli.db.path")(function* () {
console.log(Database.path())
}),
})
const MigrateCommand = cmd({
command: "migrate",
describe: "migrate JSON data to SQLite (merges with existing data)",
handler: async () => {
const sqlite = new BunDatabase(Database.getPath())
const tty = process.stderr.isTTY
const width = 36
const orange = "\x1b[38;5;214m"
const muted = "\x1b[0;2m"
const reset = "\x1b[0m"
let last = -1
if (tty) process.stderr.write("\x1b[?25l")
try {
const stats = await JsonMigration.run(drizzle({ client: sqlite }), {
progress: (event) => {
const percent = Math.floor((event.current / event.total) * 100)
if (percent === last) return
last = percent
if (tty) {
const fill = Math.round((percent / 100) * width)
const bar = `${"■".repeat(fill)}${"・".repeat(width - fill)}`
process.stderr.write(
`\r${orange}${bar} ${percent.toString().padStart(3)}%${reset} ${muted}${event.current}/${event.total}${reset} `,
)
} else {
process.stderr.write(`sqlite-migration:${percent}${EOL}`)
}
},
})
if (tty) process.stderr.write("\n")
if (tty) process.stderr.write("\x1b[?25h")
else process.stderr.write(`sqlite-migration:done${EOL}`)
UI.println(
`Migration complete: ${stats.projects} projects, ${stats.sessions} sessions, ${stats.messages} messages`,
)
if (stats.errors.length > 0) {
UI.println(`${stats.errors.length} errors occurred during migration`)
}
} catch (err) {
if (tty) process.stderr.write("\x1b[?25h")
UI.error(`Migration failed: ${errorMessage(err)}`)
process.exit(1)
} finally {
sqlite.close()
}
},
})
export const DbCommand = cmd({
export const DbCommand = effectCmd({
command: "db",
describe: "database tools",
instance: false,
builder: (yargs: Argv) => {
return yargs.command(QueryCommand).command(PathCommand).command(MigrateCommand).demandCommand()
return yargs.command(QueryCommand).command(PathCommand).demandCommand()
},
handler: () => {},
handler: Effect.fn("Cli.db")(function* () {}),
})
+2 -1
View File
@@ -1,4 +1,5 @@
import { EOL } from "os"
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
import { basename } from "path"
import { Cause, Effect } from "effect"
import { Agent } from "../../../agent/agent"
@@ -163,7 +164,7 @@ const createToolContext = Effect.fn("Cli.debug.agent.createToolContext")(functio
)
})
const now = Date.now()
const message: MessageV2.Assistant = {
const message: SessionLegacy.Assistant = {
id: messageID,
sessionID: session.id,
role: "assistant",
+4 -1
View File
@@ -1,15 +1,18 @@
import { EOL } from "os"
import { Project } from "@/project/project"
import * as Log from "@opencode-ai/core/util/log"
import { makeRuntime } from "@opencode-ai/core/effect/runtime"
import { cmd } from "../cmd"
const runtime = makeRuntime(Project.Service, Project.defaultLayer)
export const ScrapCommand = cmd({
command: "scrap",
describe: "list all known projects",
builder: (yargs) => yargs,
async handler() {
const timer = Log.Default.time("scrap")
const list = await Project.list()
const list = await runtime.runPromise((project) => project.list())
process.stdout.write(JSON.stringify(list, null, 2) + EOL)
timer.stop()
},
+5 -4
View File
@@ -1,4 +1,5 @@
import { Session } from "@/session/session"
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
import { MessageV2 } from "../../session/message-v2"
import { SessionID } from "../../session/schema"
import { effectCmd, fail } from "../effect-cmd"
@@ -31,7 +32,7 @@ function diff(kind: string, diffs: { file?: string; patch?: string }[] | undefin
}))
}
function source(part: MessageV2.FilePart) {
function source(part: SessionLegacy.FilePart) {
if (!part.source) return part.source
if (part.source.type === "symbol") {
return {
@@ -56,7 +57,7 @@ function source(part: MessageV2.FilePart) {
}
}
function filepart(part: MessageV2.FilePart): MessageV2.FilePart {
function filepart(part: SessionLegacy.FilePart): SessionLegacy.FilePart {
return {
...part,
url: redact("file-url", part.id, part.url),
@@ -65,7 +66,7 @@ function filepart(part: MessageV2.FilePart): MessageV2.FilePart {
}
}
function part(part: MessageV2.Part): MessageV2.Part {
function part(part: SessionLegacy.Part): SessionLegacy.Part {
switch (part.type) {
case "text":
return {
@@ -159,7 +160,7 @@ function part(part: MessageV2.Part): MessageV2.Part {
const partFn = part
function sanitize(data: { info: Session.Info; messages: MessageV2.WithParts[] }) {
function sanitize(data: { info: Session.Info; messages: SessionLegacy.WithParts[] }) {
return {
info: {
...data.info,
+12 -7
View File
@@ -1,4 +1,5 @@
import path from "path"
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
import { exec } from "child_process"
import { Filesystem } from "@/util/filesystem"
import * as prompts from "@clack/prompts"
@@ -26,8 +27,9 @@ import { Session } from "@/session/session"
import type { SessionID } from "../../session/schema"
import { MessageID, PartID } from "../../session/schema"
import { Provider } from "@/provider/provider"
import { Bus } from "../../bus"
import { MessageV2 } from "../../session/message-v2"
import { EventV2Bridge } from "@/event-v2-bridge"
import { EventV2 } from "@opencode-ai/core/event"
import { SessionPrompt } from "@/session/prompt"
import { Git } from "@/git"
import { setTimeout as sleep } from "node:timers/promises"
@@ -159,7 +161,7 @@ export { parseGitHubRemote }
* Returns null for non-text responses (signals summary needed).
* Throws only for truly empty responses.
*/
export function extractResponseText(parts: MessageV2.Part[]): string | null {
export function extractResponseText(parts: SessionLegacy.Part[]): string | null {
const textPart = parts.findLast((p) => p.type === "text")
if (textPart) return textPart.text
@@ -435,7 +437,7 @@ export const GithubRunCommand = effectCmd({
const sessionSvc = yield* Session.Service
const sessionShare = yield* SessionShare.Service
const sessionPrompt = yield* SessionPrompt.Service
const busSvc = yield* Bus.Service
const events = yield* EventV2Bridge.Service
const runLocalEffect = <A, E>(effect: Effect.Effect<A, E>) =>
Effect.runPromise(effect.pipe(Effect.provideService(InstanceRef, ctx)))
yield* Effect.promise(async () => {
@@ -897,10 +899,12 @@ export const GithubRunCommand = effectCmd({
let text = ""
await runLocalEffect(
busSvc.subscribeCallback(MessageV2.Event.PartUpdated, (evt) => {
if (evt.properties.part.sessionID !== session.id) return
events.listen((evt) => {
if (evt.type !== MessageV2.Event.PartUpdated.type) return Effect.void
const data = evt.data as EventV2.Data<typeof MessageV2.Event.PartUpdated>
if (data.part.sessionID !== session.id) return Effect.void
//if (evt.properties.part.messageID === messageID) return
const part = evt.properties.part
const part = data.part
if (part.type === "tool" && part.state.status === "completed") {
const [tool, color] = TOOL[part.tool] ?? [part.tool, UI.Style.TEXT_INFO_BOLD]
@@ -920,9 +924,10 @@ export const GithubRunCommand = effectCmd({
UI.println(UI.markdown(text))
UI.empty()
text = ""
return
return Effect.void
}
}
return Effect.void
}),
)
}
+39 -40
View File
@@ -1,9 +1,10 @@
import type { Session as SDKSession, Message, Part } from "@opencode-ai/sdk/v2"
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
import { Session } from "@/session/session"
import { MessageV2 } from "../../session/message-v2"
import { CliError, effectCmd } from "../effect-cmd"
import { Database } from "@/storage/db"
import { SessionTable, MessageTable, PartTable } from "../../session/session.sql"
import { Database } from "@opencode-ai/core/database/database"
import { SessionTable, MessageTable, PartTable } from "@opencode-ai/core/session/sql"
import { InstanceRef } from "@/effect/instance-ref"
import { ShareNext } from "@/share/share-next"
import { EOL } from "os"
@@ -12,8 +13,8 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Effect, Schema } from "effect"
import type { InstanceContext } from "@/project/instance-context"
const decodeMessageInfo = Schema.decodeUnknownSync(MessageV2.Info)
const decodePart = Schema.decodeUnknownSync(MessageV2.Part)
const decodeMessageInfo = Schema.decodeUnknownSync(SessionLegacy.Info)
const decodePart = Schema.decodeUnknownSync(SessionLegacy.Part)
/** Discriminated union returned by the ShareNext API (GET /api/shares/:id/data) */
export type ShareData =
@@ -98,6 +99,7 @@ export const ImportCommand = effectCmd({
const runImport = Effect.fn("Cli.import.body")(function* (file: string, ctx: InstanceContext) {
const share = yield* ShareNext.Service
const fs = yield* AppFileSystem.Service
const { db } = yield* Database.Service
let exportData: ExportData | undefined
@@ -175,48 +177,45 @@ const runImport = Effect.fn("Cli.import.body")(function* (file: string, ctx: Ins
path: path.relative(path.resolve(ctx.worktree), ctx.directory).replaceAll("\\", "/"),
}) as Session.Info
const row = Session.toRow(info)
Database.use((db) =>
db
.insert(SessionTable)
.values(row)
.onConflictDoUpdate({
target: SessionTable.id,
set: { project_id: row.project_id, directory: row.directory, path: row.path },
})
.run(),
)
yield* db
.insert(SessionTable)
.values(row)
.onConflictDoUpdate({
target: SessionTable.id,
set: { project_id: row.project_id, directory: row.directory, path: row.path },
})
.run()
.pipe(Effect.orDie)
for (const msg of exportData.messages) {
const msgInfo = decodeMessageInfo(msg.info) as MessageV2.Info
const msgInfo = decodeMessageInfo(msg.info) as SessionLegacy.Info
const { id, sessionID: _, ...msgData } = msgInfo
Database.use((db) =>
db
.insert(MessageTable)
.values({
id,
session_id: row.id,
time_created: msgInfo.time?.created ?? Date.now(),
data: msgData,
})
.onConflictDoNothing()
.run(),
)
yield* db
.insert(MessageTable)
.values({
id,
session_id: row.id,
time_created: msgInfo.time?.created ?? Date.now(),
data: msgData as never,
})
.onConflictDoNothing()
.run()
.pipe(Effect.orDie)
for (const part of msg.parts) {
const partInfo = decodePart(part) as MessageV2.Part
const partInfo = decodePart(part) as SessionLegacy.Part
const { id: partId, sessionID: _s, messageID, ...partData } = partInfo
Database.use((db) =>
db
.insert(PartTable)
.values({
id: partId,
message_id: messageID,
session_id: row.id,
data: partData,
})
.onConflictDoNothing()
.run(),
)
yield* db
.insert(PartTable)
.values({
id: partId,
message_id: messageID,
session_id: row.id,
data: partData,
})
.onConflictDoNothing()
.run()
.pipe(Effect.orDie)
}
}
+10 -5
View File
@@ -17,7 +17,8 @@ import path from "path"
import { Global } from "@opencode-ai/core/global"
import { modify, applyEdits } from "jsonc-parser"
import { Filesystem } from "@/util/filesystem"
import { Bus } from "../../bus"
import { EventV2Bridge } from "@/event-v2-bridge"
import { EventV2 } from "@opencode-ai/core/event"
import { Effect } from "effect"
function getAuthStatusIcon(status: MCP.AuthStatus): string {
@@ -256,13 +257,17 @@ export const McpAuthCommand = effectCmd({
spinner.start("Starting OAuth flow...")
// Subscribe to browser open failure events to show URL for manual opening
const unsubscribe = Bus.subscribe(MCP.BrowserOpenFailed, (evt) => {
if (evt.properties.mcpName === serverName) {
const events = yield* EventV2Bridge.Service
const unsubscribe = yield* events.listen((event) => {
if (event.type !== MCP.BrowserOpenFailed.type) return Effect.void
const data = event.data as EventV2.Data<typeof MCP.BrowserOpenFailed>
if (data.mcpName === serverName) {
spinner.stop("Could not open browser automatically")
prompts.log.warn("Please open this URL in your browser to authenticate:")
prompts.log.info(evt.properties.url)
prompts.log.info(data.url)
spinner.start("Waiting for authorization...")
}
return Effect.void
})
yield* MCP.Service.use((mcp) => mcp.authenticate(serverName)).pipe(
@@ -300,7 +305,7 @@ export const McpAuthCommand = effectCmd({
prompts.log.error(error instanceof Error ? error.message : String(error))
}),
),
Effect.ensuring(Effect.sync(() => unsubscribe())),
Effect.ensuring(unsubscribe),
)
prompts.outro("Done")
+5 -4
View File
@@ -1,10 +1,11 @@
import { EOL } from "os"
import { Effect } from "effect"
import { Provider } from "@/provider/provider"
import { ProviderID } from "../../provider/schema"
import { ModelsDev } from "@opencode-ai/core/models-dev"
import { effectCmd, fail } from "../effect-cmd"
import { UI } from "../ui"
import { ProviderV2 } from "@opencode-ai/core/provider"
export const ModelsCommand = effectCmd({
command: "models [provider]",
@@ -33,7 +34,7 @@ export const ModelsCommand = effectCmd({
const provider = yield* Provider.Service
const providers = yield* provider.list()
const print = (providerID: ProviderID, verbose?: boolean) => {
const print = (providerID: ProviderV2.ID, verbose?: boolean) => {
const p = providers[providerID]
const sorted = Object.entries(p.models).sort(([a], [b]) => a.localeCompare(b))
for (const [modelID, model] of sorted) {
@@ -47,7 +48,7 @@ export const ModelsCommand = effectCmd({
}
if (args.provider) {
const providerID = ProviderID.make(args.provider)
const providerID = ProviderV2.ID.make(args.provider)
if (!providers[providerID]) return yield* fail(`Provider not found: ${args.provider}`)
print(providerID, args.verbose)
return
@@ -61,6 +62,6 @@ export const ModelsCommand = effectCmd({
return a.localeCompare(b)
})
for (const providerID of ids) print(ProviderID.make(providerID), args.verbose)
for (const providerID of ids) print(ProviderV2.ID.make(providerID), args.verbose)
}),
})
+7 -6
View File
@@ -2,8 +2,8 @@ import { Effect } from "effect"
import { effectCmd } from "../effect-cmd"
import { Session } from "@/session/session"
import { NotFoundError } from "@/storage/storage"
import { Database } from "@/storage/db"
import { SessionTable } from "../../session/session.sql"
import { Database } from "@opencode-ai/core/database/database"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { Project } from "@/project/project"
import { InstanceRef } from "@/effect/instance-ref"
@@ -80,9 +80,10 @@ export const StatsCommand = effectCmd({
}),
})
const getAllSessions = Effect.sync(() =>
Database.use((db) => db.select().from(SessionTable).all()).map((row) => Session.fromRow(row)),
)
const getAllSessions = Effect.fnUntraced(function* () {
const { db } = yield* Database.Service
return (yield* db.select().from(SessionTable).all().pipe(Effect.orDie)).map((row) => Session.fromRow(row))
})
const aggregateSessionStats = Effect.fn("Cli.stats.aggregate")(function* (
days?: number,
@@ -90,7 +91,7 @@ const aggregateSessionStats = Effect.fn("Cli.stats.aggregate")(function* (
currentProject?: Project.Info,
) {
const svc = yield* Session.Service
const sessions = yield* getAllSessions
const sessions = yield* getAllSessions()
const MS_IN_DAY = 24 * 60 * 60 * 1000
const cutoffTime = (() => {
+17 -17
View File
@@ -1,15 +1,15 @@
import { BusEvent } from "@/bus/bus-event"
import { SessionID } from "@/session/schema"
import { PositiveInt } from "@opencode-ai/core/schema"
import { EventV2 } from "@opencode-ai/core/event"
import { Effect, Schema } from "effect"
const DEFAULT_TOAST_DURATION = 5000
export const TuiEvent = {
PromptAppend: BusEvent.define("tui.prompt.append", Schema.Struct({ text: Schema.String })),
CommandExecute: BusEvent.define(
"tui.command.execute",
Schema.Struct({
PromptAppend: EventV2.define({ type: "tui.prompt.append", schema: { text: Schema.String } }),
CommandExecute: EventV2.define({
type: "tui.command.execute",
schema: {
command: Schema.Union([
Schema.Literals([
"session.list",
@@ -31,23 +31,23 @@ export const TuiEvent = {
]),
Schema.String,
]),
}),
),
ToastShow: BusEvent.define(
"tui.toast.show",
Schema.Struct({
},
}),
ToastShow: EventV2.define({
type: "tui.toast.show",
schema: {
title: Schema.optional(Schema.String),
message: Schema.String,
variant: Schema.Literals(["info", "success", "warning", "error"]),
duration: PositiveInt.pipe(Schema.withDecodingDefault(Effect.succeed(DEFAULT_TOAST_DURATION))).annotate({
description: "Duration in milliseconds",
}),
}),
),
SessionSelect: BusEvent.define(
"tui.session.select",
Schema.Struct({
},
}),
SessionSelect: EventV2.define({
type: "tui.session.select",
schema: {
sessionID: SessionID.annotate({ description: "Session ID to navigate to" }),
}),
),
},
}),
}
@@ -7,10 +7,10 @@ import { TextAttributes } from "@opentui/core"
import { Schema } from "effect"
import { TuiEvent } from "../event"
type ToastInput = Schema.Codec.Encoded<typeof TuiEvent.ToastShow.properties>
export type ToastOptions = Schema.Schema.Type<typeof TuiEvent.ToastShow.properties>
type ToastInput = Schema.Codec.Encoded<typeof TuiEvent.ToastShow.data>
export type ToastOptions = Schema.Schema.Type<typeof TuiEvent.ToastShow.data>
const decodeToastOptions = Schema.decodeUnknownSync(TuiEvent.ToastShow.properties)
const decodeToastOptions = Schema.decodeUnknownSync(TuiEvent.ToastShow.data)
export function Toast() {
const toast = useToast()
+6 -6
View File
@@ -1,4 +1,3 @@
import { BusEvent } from "@/bus/bus-event"
import { InstanceState } from "@/effect/instance-state"
import { EffectBridge } from "@/effect/bridge"
import type { InstanceContext } from "@/project/instance-context"
@@ -7,6 +6,7 @@ import { Effect, Layer, Context, Schema } from "effect"
import { Config } from "@/config/config"
import { MCP } from "../mcp"
import { Skill } from "../skill"
import { EventV2 } from "@opencode-ai/core/event"
import PROMPT_INITIALIZE from "./template/initialize.txt"
import PROMPT_REVIEW from "./template/review.txt"
@@ -15,15 +15,15 @@ type State = {
}
export const Event = {
Executed: BusEvent.define(
"command.executed",
Schema.Struct({
Executed: EventV2.define({
type: "command.executed",
schema: {
name: Schema.String,
sessionID: SessionID,
arguments: Schema.String,
messageID: MessageID,
}),
),
},
}),
}
export const Info = Schema.Struct({
@@ -1,4 +1,4 @@
import type { ProjectID } from "@/project/schema"
import type { ProjectV2 } from "@opencode-ai/core/project"
import type { WorkspaceAdapter, WorkspaceAdapterEntry } from "../types"
import { WorktreeAdapter } from "./worktree"
@@ -6,9 +6,9 @@ const BUILTIN: Record<string, WorkspaceAdapter> = {
worktree: WorktreeAdapter,
}
const state = new Map<ProjectID, Map<string, WorkspaceAdapter>>()
const state = new Map<ProjectV2.ID, Map<string, WorkspaceAdapter>>()
export function getAdapter(projectID: ProjectID, type: string): WorkspaceAdapter {
export function getAdapter(projectID: ProjectV2.ID, type: string): WorkspaceAdapter {
const custom = state.get(projectID)?.get(type)
if (custom) return custom
@@ -18,7 +18,7 @@ export function getAdapter(projectID: ProjectID, type: string): WorkspaceAdapter
throw new Error(`Unknown workspace adapter: ${type}`)
}
export function listAdapters(projectID: ProjectID): WorkspaceAdapterEntry[] {
export function listAdapters(projectID: ProjectV2.ID): WorkspaceAdapterEntry[] {
return registeredAdapters(projectID).map(([type, adapter]) => ({
type,
name: adapter.name,
@@ -26,15 +26,15 @@ export function listAdapters(projectID: ProjectID): WorkspaceAdapterEntry[] {
}))
}
export function registeredAdapters(projectID: ProjectID): [string, WorkspaceAdapter][] {
export function registeredAdapters(projectID: ProjectV2.ID): [string, WorkspaceAdapter][] {
const adapters = new Map(Object.entries(BUILTIN))
for (const [type, adapter] of state.get(projectID)?.entries() ?? []) adapters.set(type, adapter)
return [...adapters.entries()]
}
// Plugins can be loaded per-project so we need to scope them. If you
// want to install a global one pass `ProjectID.global`
export function registerAdapter(projectID: ProjectID, type: string, adapter: WorkspaceAdapter) {
// want to install a global one pass `ProjectV2.ID.global`
export function registerAdapter(projectID: ProjectV2.ID, type: string, adapter: WorkspaceAdapter) {
const adapters = state.get(projectID) ?? new Map<string, WorkspaceAdapter>()
adapters.set(type, adapter)
state.set(projectID, adapters)
@@ -1,14 +0,0 @@
import { Schema } from "effect"
import { Identifier } from "@/id/id"
import { withStatics } from "@opencode-ai/core/schema"
const workspaceIdSchema = Schema.String.check(Schema.isStartsWith("wrk")).pipe(Schema.brand("WorkspaceID"))
export type WorkspaceID = typeof workspaceIdSchema.Type
export const WorkspaceID = workspaceIdSchema.pipe(
withStatics((schema: typeof workspaceIdSchema) => ({
ascending: (id?: string) => schema.make(Identifier.ascending("workspace", id)),
})),
)
+5 -5
View File
@@ -1,17 +1,17 @@
import { Schema, Struct } from "effect"
import { ProjectID } from "@/project/schema"
import { ProjectV2 } from "@opencode-ai/core/project"
import type { InstanceContext } from "@/project/instance-context"
import { WorkspaceID } from "./schema"
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
import type { DeepMutable } from "@opencode-ai/core/schema"
export const WorkspaceInfo = Schema.Struct({
id: WorkspaceID,
id: WorkspaceV2.ID,
type: Schema.String,
name: Schema.String,
branch: Schema.optional(Schema.NullOr(Schema.String)),
directory: Schema.optional(Schema.NullOr(Schema.String)),
extra: Schema.optional(Schema.NullOr(Schema.Unknown)),
projectID: ProjectID,
projectID: ProjectV2.ID,
}).annotate({ identifier: "Workspace" })
export type WorkspaceInfo = DeepMutable<Schema.Schema.Type<typeof WorkspaceInfo>>
@@ -40,7 +40,7 @@ export type Target =
export type WorkspaceAdapterContext = {
readonly instance?: InstanceContext
readonly workspaceID?: WorkspaceID
readonly workspaceID?: WorkspaceV2.ID
}
export type WorkspaceAdapter = {
@@ -1,18 +1,18 @@
import { LocalContext } from "@/util/local-context"
import type { WorkspaceID } from "../control-plane/schema"
import type { WorkspaceV2 } from "@opencode-ai/core/workspace"
export interface WorkspaceContext {
workspaceID: WorkspaceID | undefined
workspaceID: WorkspaceV2.ID | undefined
}
const context = LocalContext.create<WorkspaceContext>("instance")
export const WorkspaceContext = {
async provide<R>(input: { workspaceID?: WorkspaceID; fn: () => R }): Promise<R> {
async provide<R>(input: { workspaceID?: WorkspaceV2.ID; fn: () => R }): Promise<R> {
return context.provide({ workspaceID: input.workspaceID }, () => input.fn())
},
restore<R>(workspaceID: WorkspaceID, fn: () => R): R {
restore<R>(workspaceID: WorkspaceV2.ID, fn: () => R): R {
return context.provide({ workspaceID }, fn)
},
@@ -1,20 +0,0 @@
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"
export const WorkspaceTable = sqliteTable("workspace", {
id: text().$type<WorkspaceID>().primaryKey(),
type: text().notNull(),
name: text().notNull().default(""),
branch: text(),
directory: text(),
extra: text({ mode: "json" }),
project_id: text()
.$type<ProjectID>()
.notNull()
.references(() => ProjectTable.id, { onDelete: "cascade" }),
time_used: integer()
.notNull()
.$default(() => Date.now()),
})
+180 -182
View File
@@ -1,28 +1,28 @@
import { Context, Effect, FiberMap, Iterable, Layer, Schema, Stream } from "effect"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { FetchHttpClient, HttpBody, HttpClient, HttpClientError, HttpClientRequest } from "effect/unstable/http"
import { Database } from "@/storage/db"
import { Database } from "@opencode-ai/core/database/database"
import { asc } from "drizzle-orm"
import { eq } from "drizzle-orm"
import { inArray } from "drizzle-orm"
import { Project } from "@/project/project"
import { BusEvent } from "@/bus/bus-event"
import { GlobalBus } from "@/bus/global"
import { Auth } from "@/auth"
import { SyncEvent } from "@/sync"
import { EventSequenceTable, EventTable } from "@/sync/event.sql"
import { EventV2 } from "@opencode-ai/core/event"
import { EventV2Bridge } from "@/event-v2-bridge"
import { EventSequenceTable, EventTable } from "@opencode-ai/core/event/sql"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import * as Log from "@opencode-ai/core/util/log"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { ProjectID } from "@/project/schema"
import { ProjectV2 } from "@opencode-ai/core/project"
import { Slug } from "@opencode-ai/core/util/slug"
import { WorkspaceTable } from "./workspace.sql"
import { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql"
import { getAdapter, registeredAdapters } from "./adapters"
import { type Target, type WorkspaceInfo, WorkspaceInfo as WorkspaceInfoSchema } from "./types"
import { WorkspaceID } from "./schema"
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
import { Session } from "@/session/session"
import { SessionPrompt } from "@/session/prompt"
import { SessionTable } from "@/session/session.sql"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { SessionID } from "@/session/schema"
import { NotFoundError } from "@/storage/storage"
import { errorData } from "@/util/error"
@@ -40,25 +40,25 @@ export const Info = Schema.Struct({
export type Info = WorkspaceInfo & { timeUsed: number }
export const ConnectionStatus = Schema.Struct({
workspaceID: WorkspaceID,
workspaceID: WorkspaceV2.ID,
status: Schema.Literals(["connected", "connecting", "disconnected", "error"]),
})
export type ConnectionStatus = Schema.Schema.Type<typeof ConnectionStatus>
export const Event = {
Ready: BusEvent.define(
"workspace.ready",
Schema.Struct({
Ready: EventV2.define({
type: "workspace.ready",
schema: {
name: Schema.String,
}),
),
Failed: BusEvent.define(
"workspace.failed",
Schema.Struct({
},
}),
Failed: EventV2.define({
type: "workspace.failed",
schema: {
message: Schema.String,
}),
),
Status: BusEvent.define("workspace.status", ConnectionStatus),
},
}),
Status: EventV2.define({ type: "workspace.status", schema: ConnectionStatus.fields }),
}
function fromRow(row: typeof WorkspaceTable.$inferSelect): Info {
@@ -74,22 +74,19 @@ function fromRow(row: typeof WorkspaceTable.$inferSelect): Info {
}
}
const db = <T>(fn: (d: Parameters<typeof Database.use>[0] extends (trx: infer D) => any ? D : never) => T) =>
Effect.sync(() => Database.use(fn))
const log = Log.create({ service: "workspace-sync" })
export const CreateInput = Schema.Struct({
id: Schema.optional(WorkspaceID),
id: Schema.optional(WorkspaceV2.ID),
type: Info.fields.type,
branch: Info.fields.branch,
projectID: ProjectID,
projectID: ProjectV2.ID,
extra: Schema.optional(Info.fields.extra),
})
export type CreateInput = Schema.Schema.Type<typeof CreateInput>
export const SessionWarpInput = Schema.Struct({
workspaceID: Schema.NullOr(WorkspaceID),
workspaceID: Schema.NullOr(WorkspaceV2.ID),
sessionID: SessionID,
copyChanges: Schema.optional(Schema.Boolean),
})
@@ -105,7 +102,7 @@ export class WorkspaceNotFoundError extends Schema.TaggedErrorClass<WorkspaceNot
"WorkspaceNotFoundError",
{
message: Schema.String,
workspaceID: WorkspaceID,
workspaceID: WorkspaceV2.ID,
},
) {}
@@ -121,7 +118,7 @@ export class SessionWarpHttpError extends Schema.TaggedErrorClass<SessionWarpHtt
"WorkspaceSessionWarpHttpError",
{
message: Schema.String,
workspaceID: WorkspaceID,
workspaceID: WorkspaceV2.ID,
sessionID: SessionID,
status: Schema.Number,
body: Schema.String,
@@ -153,17 +150,17 @@ export interface Interface {
readonly sessionWarp: (input: SessionWarpInput) => Effect.Effect<void, SessionWarpError>
readonly list: (project: Project.Info) => Effect.Effect<Info[]>
readonly syncList: (project: Project.Info) => Effect.Effect<void>
readonly get: (id: WorkspaceID) => Effect.Effect<Info | undefined>
readonly remove: (id: WorkspaceID) => Effect.Effect<Info | undefined>
readonly get: (id: WorkspaceV2.ID) => Effect.Effect<Info | undefined>
readonly remove: (id: WorkspaceV2.ID) => Effect.Effect<Info | undefined>
readonly status: () => Effect.Effect<ConnectionStatus[]>
readonly isSyncing: (workspaceID: WorkspaceID) => Effect.Effect<boolean>
readonly isSyncing: (workspaceID: WorkspaceV2.ID) => Effect.Effect<boolean>
readonly waitForSync: (
workspaceID: WorkspaceID,
workspaceID: WorkspaceV2.ID,
state: Record<string, number>,
signal?: AbortSignal,
timeout?: number,
) => Effect.Effect<void, WaitForSyncError>
readonly startWorkspaceSyncing: (projectID: ProjectID) => Effect.Effect<void>
readonly startWorkspaceSyncing: (projectID: ProjectV2.ID) => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Workspace") {}
@@ -177,14 +174,15 @@ export const layer = Layer.effect(
const session = yield* Session.Service
const prompt = yield* SessionPrompt.Service
const http = yield* HttpClient.HttpClient
const sync = yield* SyncEvent.Service
const events = yield* EventV2Bridge.Service
const vcs = yield* Vcs.Service
const flags = yield* RuntimeFlags.Service
const fs = yield* AppFileSystem.Service
const connections = new Map<WorkspaceID, ConnectionStatus>()
const syncFibers = yield* FiberMap.make<WorkspaceID, void, SyncLoopError>()
const { db } = yield* Database.Service
const connections = new Map<WorkspaceV2.ID, ConnectionStatus>()
const syncFibers = yield* FiberMap.make<WorkspaceV2.ID, void, SyncLoopError>()
const setStatus = (id: WorkspaceID, status: ConnectionStatus["status"]) => {
const setStatus = (id: WorkspaceV2.ID, status: ConnectionStatus["status"]) => {
const prev = connections.get(id)
if (prev?.status === status) return
const next = { workspaceID: id, status }
@@ -270,7 +268,7 @@ export const layer = Layer.effect(
})
const runInWorkspace = <A, E, R>(input: {
workspaceID?: WorkspaceID
workspaceID?: WorkspaceV2.ID
local: () => Effect.Effect<A, E, R>
remote: (input: {
workspace: Info
@@ -333,19 +331,20 @@ export const layer = Layer.effect(
url: URL | string,
headers: HeadersInit | undefined,
) {
const sessionIDs = yield* db((db) =>
db
.select({ id: SessionTable.id })
.from(SessionTable)
.where(eq(SessionTable.workspace_id, space.id))
.all()
.map((row) => row.id),
)
const sessionIDs = (yield* db
.select({ id: SessionTable.id })
.from(SessionTable)
.where(eq(SessionTable.workspace_id, space.id))
.all()
.pipe(Effect.orDie)).map((row) => row.id)
const state = sessionIDs.length
? Object.fromEntries(
(yield* db((db) =>
db.select().from(EventSequenceTable).where(inArray(EventSequenceTable.aggregate_id, sessionIDs)).all(),
)).map((row) => [row.aggregate_id, row.seq]),
(yield* db
.select()
.from(EventSequenceTable)
.where(inArray(EventSequenceTable.aggregate_id, sessionIDs))
.all()
.pipe(Effect.orDie)).map((row) => [row.aggregate_id, row.seq]),
)
: {}
@@ -371,20 +370,20 @@ export const layer = Layer.effect(
})
}
const events = (yield* response.json) as HistoryEvent[]
const history = (yield* response.json) as HistoryEvent[]
log.info("workspace history synced", {
workspaceID: space.id,
events: events.length,
events: history.length,
})
yield* Effect.forEach(
events,
history,
(event) =>
sync
events
.replay(
{
id: event.id,
id: EventV2.ID.make(event.id),
aggregateID: event.aggregate_id,
seq: event.seq,
type: event.type,
@@ -431,11 +430,11 @@ export const layer = Layer.effect(
yield* parseSSE(stream, (evt) =>
Effect.gen(function* () {
if (!evt || typeof evt !== "object" || !("payload" in evt)) return
const payload = evt.payload as { type?: string; syncEvent?: SyncEvent.SerializedEvent }
const payload = evt.payload as { type?: string; syncEvent?: EventV2.SerializedEvent }
if (payload.type === "server.heartbeat") return
if (payload.type === "sync" && payload.syncEvent) {
const failed = yield* sync.replay(payload.syncEvent).pipe(
const failed = yield* events.replay(payload.syncEvent, { publish: true }).pipe(
Effect.as(false),
Effect.catchCause((error) =>
Effect.sync(() => {
@@ -524,13 +523,13 @@ export const layer = Layer.effect(
)
})
const stopSync = Effect.fn("Workspace.stopSync")(function* (id: WorkspaceID) {
const stopSync = Effect.fn("Workspace.stopSync")(function* (id: WorkspaceV2.ID) {
yield* FiberMap.remove(syncFibers, id)
connections.delete(id)
})
const create = Effect.fn("Workspace.create")(function* (input: CreateInput) {
const id = WorkspaceID.ascending(input.id)
const id = WorkspaceV2.ID.ascending(input.id)
const adapter = getAdapter(input.projectID, input.type)
const config = yield* WorkspaceAdapterRuntime.configure(adapter, {
...input,
@@ -551,20 +550,20 @@ export const layer = Layer.effect(
timeUsed: Date.now(),
}
yield* db((db) => {
db.insert(WorkspaceTable)
.values({
id: info.id,
type: info.type,
branch: info.branch,
name: info.name,
directory: info.directory,
extra: info.extra,
project_id: info.projectID,
time_used: info.timeUsed,
})
.run()
})
yield* db
.insert(WorkspaceTable)
.values({
id: info.id,
type: info.type,
branch: info.branch,
name: info.name,
directory: info.directory,
extra: info.extra,
project_id: info.projectID,
time_used: info.timeUsed,
})
.run()
.pipe(Effect.orDie)
const env = {
OPENCODE_AUTH_CONTENT: JSON.stringify(yield* auth.all()),
@@ -603,13 +602,12 @@ export const layer = Layer.effect(
sessionID: input.sessionID,
})
const current = yield* db((db) =>
db
.select({ workspaceID: SessionTable.workspace_id })
.from(SessionTable)
.where(eq(SessionTable.id, input.sessionID))
.get(),
)
const current = yield* db
.select({ workspaceID: SessionTable.workspace_id })
.from(SessionTable)
.where(eq(SessionTable.id, input.sessionID))
.get()
.pipe(Effect.orDie)
if (current?.workspaceID) {
const previous = yield* get(current.workspaceID)
@@ -634,7 +632,7 @@ export const layer = Layer.effect(
// "claim" this session so any future events coming from
// the old workspace are ignored
yield* sync.claim(input.sessionID, input.workspaceID ?? previous.projectID)
yield* events.claim(input.sessionID, input.workspaceID ?? previous.projectID)
}
}
@@ -669,12 +667,7 @@ export const layer = Layer.effect(
}
if (input.workspaceID === null) {
yield* sync.run(Session.Event.Updated, {
sessionID: input.sessionID,
info: {
workspaceID: null,
},
})
yield* session.setWorkspace({ sessionID: input.sessionID, workspaceID: undefined })
log.info("session warp complete", {
workspaceID: input.workspaceID,
@@ -695,12 +688,7 @@ export const layer = Layer.effect(
const target = yield* WorkspaceAdapterRuntime.target(space)
if (target.type === "local") {
yield* sync.run(Session.Event.Updated, {
sessionID: input.sessionID,
info: {
workspaceID: input.workspaceID,
},
})
yield* session.setWorkspace({ sessionID: input.sessionID, workspaceID: input.workspaceID })
log.info("session warp complete", {
workspaceID: input.workspaceID,
@@ -710,20 +698,19 @@ export const layer = Layer.effect(
return
}
const rows = yield* db((db) =>
db
.select({
id: EventTable.id,
aggregateID: EventTable.aggregate_id,
seq: EventTable.seq,
type: EventTable.type,
data: EventTable.data,
})
.from(EventTable)
.where(eq(EventTable.aggregate_id, input.sessionID))
.orderBy(asc(EventTable.seq))
.all(),
)
const rows = yield* db
.select({
id: EventTable.id,
aggregateID: EventTable.aggregate_id,
seq: EventTable.seq,
type: EventTable.type,
data: EventTable.data,
})
.from(EventTable)
.where(eq(EventTable.aggregate_id, input.sessionID))
.orderBy(asc(EventTable.seq))
.all()
.pipe(Effect.orDie)
if (rows.length === 0)
return yield* new SessionEventsNotFoundError({
message: `No events found for session: ${input.sessionID}`,
@@ -810,6 +797,8 @@ export const layer = Layer.effect(
})
}
yield* session.setWorkspace({ sessionID: input.sessionID, workspaceID: input.workspaceID })
log.info("session warp complete", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
@@ -829,15 +818,14 @@ export const layer = Layer.effect(
})
const list = Effect.fn("Workspace.list")(function* (project: Project.Info) {
return yield* db((db) =>
db
.select()
.from(WorkspaceTable)
.where(eq(WorkspaceTable.project_id, project.id))
.all()
.map(fromRow)
.sort((a, b) => a.id.localeCompare(b.id)),
)
return (yield* db
.select()
.from(WorkspaceTable)
.where(eq(WorkspaceTable.project_id, project.id))
.all()
.pipe(Effect.orDie))
.map(fromRow)
.sort((a, b) => a.id.localeCompare(b.id))
})
const syncList = Effect.fn("Workspace.syncList")(function* (project: Project.Info) {
@@ -864,7 +852,7 @@ export const layer = Layer.effect(
names.add(item.name)
const info: Info = {
id: WorkspaceID.ascending(),
id: WorkspaceV2.ID.ascending(),
type: item.type,
branch: item.branch,
name: item.name,
@@ -874,20 +862,20 @@ export const layer = Layer.effect(
timeUsed: Date.now(),
}
yield* db((db) => {
db.insert(WorkspaceTable)
.values({
id: info.id,
type: info.type,
branch: info.branch,
name: info.name,
directory: info.directory,
extra: info.extra,
project_id: info.projectID,
time_used: info.timeUsed,
})
.run()
})
yield* db
.insert(WorkspaceTable)
.values({
id: info.id,
type: info.type,
branch: info.branch,
name: info.name,
directory: info.directory,
extra: info.extra,
project_id: info.projectID,
time_used: info.timeUsed,
})
.run()
.pipe(Effect.orDie)
yield* startSync(info)
}),
@@ -895,20 +883,19 @@ export const layer = Layer.effect(
)
})
const get = Effect.fn("Workspace.get")(function* (id: WorkspaceID) {
const row = yield* db((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get())
const get = Effect.fn("Workspace.get")(function* (id: WorkspaceV2.ID) {
const row = yield* db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get().pipe(Effect.orDie)
if (!row) return
return fromRow(row)
})
const remove = Effect.fn("Workspace.remove")(function* (id: WorkspaceID) {
const sessions = yield* db((db) =>
db
.select({ id: SessionTable.id, parentID: SessionTable.parent_id })
.from(SessionTable)
.where(eq(SessionTable.workspace_id, id))
.all(),
)
const remove = Effect.fn("Workspace.remove")(function* (id: WorkspaceV2.ID) {
const sessions = yield* db
.select({ id: SessionTable.id, parentID: SessionTable.parent_id })
.from(SessionTable)
.where(eq(SessionTable.workspace_id, id))
.all()
.pipe(Effect.orDie)
const sessionIDs = new Set(sessions.map((sessionInfo) => sessionInfo.id))
yield* Effect.forEach(
sessions.filter((sessionInfo) => !sessionInfo.parentID || !sessionIDs.has(sessionInfo.parentID)),
@@ -917,7 +904,7 @@ export const layer = Layer.effect(
{ discard: true },
)
const row = yield* db((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get())
const row = yield* db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get().pipe(Effect.orDie)
if (!row) return
yield* stopSync(id)
@@ -933,7 +920,7 @@ export const layer = Layer.effect(
}),
)
yield* db((db) => db.delete(WorkspaceTable).where(eq(WorkspaceTable.id, id)).run())
yield* db.delete(WorkspaceTable).where(eq(WorkspaceTable.id, id)).run().pipe(Effect.orDie)
return info
})
@@ -941,30 +928,21 @@ export const layer = Layer.effect(
return [...connections.values()]
})
const isSyncing = Effect.fn("Workspace.isSyncing")(function* (workspaceID: WorkspaceID) {
const isSyncing = Effect.fn("Workspace.isSyncing")(function* (workspaceID: WorkspaceV2.ID) {
const exists = yield* FiberMap.has(syncFibers, workspaceID)
return exists && connections.get(workspaceID)?.status !== "error"
})
const waitForSync = Effect.fn("Workspace.waitForSync")(function* (
workspaceID: WorkspaceID,
workspaceID: WorkspaceV2.ID,
state: Record<string, number>,
signal?: AbortSignal,
timeout = TIMEOUT,
) {
if (synced(state)) return
if (yield* synced(db, state)) return
yield* Effect.catch(
waitEvent({
timeout,
signal,
fn(event) {
if (event.workspace !== workspaceID && event.payload.type !== "sync") {
return false
}
return synced(state)
},
}),
waitUntilSynced({ db, workspaceID, state, signal, timeout }),
(): Effect.Effect<never, WaitForSyncError> =>
signal?.aborted
? Effect.fail(
@@ -982,14 +960,13 @@ export const layer = Layer.effect(
)
})
const startWorkspaceSyncing = Effect.fn("Workspace.startWorkspaceSyncing")(function* (projectID: ProjectID) {
const rows = yield* db((db) =>
db
.selectDistinct({ workspace: WorkspaceTable })
.from(WorkspaceTable)
.where(eq(WorkspaceTable.project_id, projectID))
.all(),
)
const startWorkspaceSyncing = Effect.fn("Workspace.startWorkspaceSyncing")(function* (projectID: ProjectV2.ID) {
const rows = yield* db
.selectDistinct({ workspace: WorkspaceTable })
.from(WorkspaceTable)
.where(eq(WorkspaceTable.project_id, projectID))
.all()
.pipe(Effect.orDie)
for (const { workspace } of rows) {
yield* startSync(fromRow(workspace)).pipe(
@@ -1025,11 +1002,12 @@ export const layer = Layer.effect(
export const defaultLayer = layer.pipe(
Layer.provide(Auth.defaultLayer),
Layer.provide(Session.defaultLayer),
Layer.provide(SyncEvent.defaultLayer),
Layer.provide(SessionPrompt.defaultLayer),
Layer.provide(Project.defaultLayer),
Layer.provide(Vcs.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Database.defaultLayer),
Layer.provide(EventV2Bridge.defaultLayer),
Layer.provide(FetchHttpClient.layer),
Layer.provide(RuntimeFlags.defaultLayer),
)
@@ -1044,26 +1022,46 @@ type HistoryEvent = {
data: Record<string, unknown>
}
function synced(state: Record<string, number>) {
function waitUntilSynced(input: {
db: Database.Interface["db"]
workspaceID: WorkspaceV2.ID
state: Record<string, number>
signal?: AbortSignal
timeout: number
}): Effect.Effect<void, unknown> {
return Effect.suspend(() =>
waitEvent({
timeout: input.timeout,
signal: input.signal,
fn(event) {
return event.workspace === input.workspaceID || event.payload.type === "sync"
},
}).pipe(
Effect.andThen(synced(input.db, input.state)),
Effect.flatMap((done): Effect.Effect<void, unknown> => (done ? Effect.void : waitUntilSynced(input))),
),
)
}
function synced(db: Database.Interface["db"], state: Record<string, number>): Effect.Effect<boolean> {
const ids = Object.keys(state)
if (ids.length === 0) return true
if (ids.length === 0) return Effect.succeed(true)
const done = Object.fromEntries(
Database.use((db) =>
db
.select({
id: EventSequenceTable.aggregate_id,
seq: EventSequenceTable.seq,
})
.from(EventSequenceTable)
.where(inArray(EventSequenceTable.aggregate_id, ids))
.all(),
).map((row) => [row.id, row.seq]),
) as Record<string, number>
return ids.every((id) => {
return (done[id] ?? -1) >= state[id]
})
return db
.select({
id: EventSequenceTable.aggregate_id,
seq: EventSequenceTable.seq,
})
.from(EventSequenceTable)
.where(inArray(EventSequenceTable.aggregate_id, ids))
.all()
.pipe(
Effect.orDie,
Effect.map((rows) => {
const done = Object.fromEntries(rows.map((row) => [row.id, row.seq])) as Record<string, number>
return ids.every((id) => (done[id] ?? -1) >= state[id])
}),
)
}
function route(url: string | URL, path: string) {
@@ -1,6 +0,0 @@
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"
export const DataMigrationTable = sqliteTable("data_migration", {
name: text().primaryKey(),
time_completed: integer().notNull(),
})
-161
View File
@@ -1,161 +0,0 @@
import { Context, Effect, Layer } from "effect"
import { Database } from "./storage/db"
import { DataMigrationTable } from "./data-migration.sql"
import * as Log from "@opencode-ai/core/util/log"
import { and, asc, eq, gt, inArray, sql } from "drizzle-orm"
import { MessageTable, SessionTable } from "./session/session.sql"
import type { SessionID } from "./session/schema"
export type Migration<R = never> = {
name: string
run: Effect.Effect<void, unknown, R>
}
const log = Log.create({ service: "data-migration" })
export interface Interface {}
export class Service extends Context.Service<Service, Interface>()("@opencode/DataMigration") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const migrations: Migration[] = [
{
name: "session_usage_from_messages",
run: Effect.gen(function* () {
type Usage = {
cost: number
tokens: { input: number; output: number; reasoning: number; cache: { read: number; write: number } }
}
for (let cursor: SessionID | undefined, page = 1; ; page++) {
const next = yield* Effect.gen(function* () {
const sessions = yield* Effect.sync(() =>
Database.use((db) =>
db
.select({ id: SessionTable.id })
.from(SessionTable)
.where(cursor ? gt(SessionTable.id, cursor) : undefined)
.orderBy(asc(SessionTable.id))
.limit(100)
.all(),
),
)
if (sessions.length === 0) return
yield* Effect.sync(() =>
Database.transaction((db) => {
const usageBySession = new Map<SessionID, Usage>(
sessions.map((session) => [
session.id,
{ cost: 0, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } } },
]),
)
for (const row of db
.select({
session_id: MessageTable.session_id,
cost: sql<number>`coalesce(sum(coalesce(json_extract(${MessageTable.data}, '$.cost'), 0)), 0)`,
tokens_input: sql<number>`coalesce(sum(coalesce(json_extract(${MessageTable.data}, '$.tokens.input'), 0)), 0)`,
tokens_output: sql<number>`coalesce(sum(coalesce(json_extract(${MessageTable.data}, '$.tokens.output'), 0)), 0)`,
tokens_reasoning: sql<number>`coalesce(sum(coalesce(json_extract(${MessageTable.data}, '$.tokens.reasoning'), 0)), 0)`,
tokens_cache_read: sql<number>`coalesce(sum(coalesce(json_extract(${MessageTable.data}, '$.tokens.cache.read'), 0)), 0)`,
tokens_cache_write: sql<number>`coalesce(sum(coalesce(json_extract(${MessageTable.data}, '$.tokens.cache.write'), 0)), 0)`,
})
.from(MessageTable)
.where(
and(
inArray(
MessageTable.session_id,
sessions.map((session) => session.id),
),
sql`json_extract(${MessageTable.data}, '$.role') = 'assistant'`,
),
)
.groupBy(MessageTable.session_id)
.all()) {
const current = usageBySession.get(row.session_id)
if (!current) continue
current.cost = row.cost
current.tokens.input = row.tokens_input
current.tokens.output = row.tokens_output
current.tokens.reasoning = row.tokens_reasoning
current.tokens.cache.read = row.tokens_cache_read
current.tokens.cache.write = row.tokens_cache_write
}
for (const [sessionID, value] of usageBySession) {
db.update(SessionTable)
.set({
cost: value.cost,
tokens_input: value.tokens.input,
tokens_output: value.tokens.output,
tokens_reasoning: value.tokens.reasoning,
tokens_cache_read: value.tokens.cache.read,
tokens_cache_write: value.tokens.cache.write,
time_updated: sql`${SessionTable.time_updated}`,
})
.where(eq(SessionTable.id, sessionID))
.run()
}
}),
)
return sessions.at(-1)?.id
}).pipe(
Effect.withSpan("DataMigration.sessionUsage.page", {
attributes: {
"data_migration.name": "session_usage_from_messages",
"data_migration.page": page,
"data_migration.cursor": cursor ?? "",
},
}),
)
if (!next) return
cursor = next
yield* Effect.sleep("10 millis")
}
}),
},
]
yield* Effect.gen(function* () {
if (migrations.length === 0) return
// Migrations run in a background fiber, so they must be resumable until
// their completion row is written.
for (const migration of migrations) {
const completed = Database.use((db) =>
db
.select({ name: DataMigrationTable.name })
.from(DataMigrationTable)
.where(eq(DataMigrationTable.name, migration.name))
.get(),
)
if (completed) continue
log.info("running data migration", { name: migration.name })
yield* migration.run.pipe(Effect.withSpan("DataMigration", { attributes: { name: migration.name } }))
Database.use((db) =>
db
.insert(DataMigrationTable)
.values({ name: migration.name, time_completed: Date.now() })
.onConflictDoNothing()
.run(),
)
}
}).pipe(
Effect.tapCause((cause) =>
Effect.logError("failed to run data migrations").pipe(Effect.annotateLogs("cause", cause)),
),
Effect.ignore,
Effect.forkScoped,
)
return Service.of({})
}),
)
export const defaultLayer = layer
export * as DataMigration from "./data-migration"
+4 -8
View File
@@ -3,7 +3,7 @@ import { attach } from "./run-service"
import * as Observability from "@opencode-ai/core/effect/observability"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Bus } from "@/bus"
import { Database } from "@opencode-ai/core/database/database"
import { Auth } from "@/auth"
import { Account } from "@/account/account"
import { Config } from "@/config/config"
@@ -51,18 +51,16 @@ import { PtyTicket } from "@/pty/ticket"
import { Installation } from "@/installation"
import { ShareNext } from "@/share/share-next"
import { SessionShare } from "@/share/session"
import { SyncEvent } from "@/sync"
import { Npm } from "@opencode-ai/core/npm"
import { memoMap } from "@opencode-ai/core/effect/memo-map"
import { DataMigration } from "@/data-migration"
import { BackgroundJob } from "@/background/job"
import { EventV2Bridge } from "@/event-v2-bridge"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { EventV2Bridge } from "@/event-v2-bridge"
export const AppLayer = Layer.mergeAll(
Npm.defaultLayer,
AppFileSystem.defaultLayer,
Bus.defaultLayer,
Database.defaultLayer,
Auth.defaultLayer,
Account.defaultLayer,
Config.defaultLayer,
@@ -86,6 +84,7 @@ export const AppLayer = Layer.mergeAll(
SessionStatus.defaultLayer,
BackgroundJob.defaultLayer,
RuntimeFlags.defaultLayer,
EventV2Bridge.defaultLayer,
SessionRunState.defaultLayer,
SessionProcessor.defaultLayer,
SessionCompaction.defaultLayer,
@@ -111,9 +110,6 @@ export const AppLayer = Layer.mergeAll(
Installation.defaultLayer,
ShareNext.defaultLayer,
SessionShare.defaultLayer,
SyncEvent.defaultLayer,
EventV2Bridge.defaultLayer,
DataMigration.defaultLayer,
).pipe(Layer.provideMerge(InstanceLayer.layer), Layer.provideMerge(Observability.layer))
const rt = ManagedRuntime.make(AppLayer, { memoMap })
@@ -8,7 +8,6 @@ import { ShareNext } from "@/share/share-next"
import { File } from "@/file"
import { Vcs } from "@/project/vcs"
import { Snapshot } from "@/snapshot"
import { Bus } from "@/bus"
import { Config } from "@/config/config"
import * as Observability from "@opencode-ai/core/effect/observability"
import { memoMap } from "@opencode-ai/core/effect/memo-map"
@@ -23,7 +22,6 @@ export const BootstrapLayer = Layer.mergeAll(
FileWatcher.defaultLayer,
Vcs.defaultLayer,
Snapshot.defaultLayer,
Bus.defaultLayer,
).pipe(Layer.provide(Observability.layer))
export const BootstrapRuntime = ManagedRuntime.make(BootstrapLayer, { memoMap })
+2 -2
View File
@@ -1,6 +1,6 @@
import { Context, Effect, Exit, Fiber } from "effect"
import { WorkspaceContext } from "@/control-plane/workspace-context"
import type { WorkspaceID } from "@/control-plane/schema"
import type { WorkspaceV2 } from "@opencode-ai/core/workspace"
import { InstanceRef, WorkspaceRef } from "./instance-ref"
import { attachWith } from "./run-service"
@@ -11,7 +11,7 @@ export interface Shape {
readonly bind: <Args extends readonly unknown[], Result>(fn: (...args: Args) => Result) => (...args: Args) => Result
}
function restoreWorkspace<R>(workspace: WorkspaceID | undefined, fn: () => R): R {
function restoreWorkspace<R>(workspace: WorkspaceV2.ID | undefined, fn: () => R): R {
if (workspace !== undefined) return WorkspaceContext.restore(workspace, fn)
return fn()
}
+2 -2
View File
@@ -1,11 +1,11 @@
import { Context } from "effect"
import type { InstanceContext } from "@/project/instance-context"
import type { WorkspaceID } from "@/control-plane/schema"
import type { WorkspaceV2 } from "@opencode-ai/core/workspace"
export const InstanceRef = Context.Reference<InstanceContext | undefined>("~opencode/InstanceRef", {
defaultValue: () => undefined,
})
export const WorkspaceRef = Context.Reference<WorkspaceID | undefined>("~opencode/WorkspaceRef", {
export const WorkspaceRef = Context.Reference<WorkspaceV2.ID | undefined>("~opencode/WorkspaceRef", {
defaultValue: () => undefined,
})
@@ -17,11 +17,9 @@ export class Service extends ConfigService.Service<Service>()("@opencode/Runtime
autoShare: bool("OPENCODE_AUTO_SHARE"),
pure: bool("OPENCODE_PURE"),
disableDefaultPlugins: bool("OPENCODE_DISABLE_DEFAULT_PLUGINS"),
disableChannelDb: bool("OPENCODE_DISABLE_CHANNEL_DB"),
disableEmbeddedWebUi: bool("OPENCODE_DISABLE_EMBEDDED_WEB_UI"),
disableExternalSkills: bool("OPENCODE_DISABLE_EXTERNAL_SKILLS"),
disableLspDownload: bool("OPENCODE_DISABLE_LSP_DOWNLOAD"),
skipMigrations: bool("OPENCODE_SKIP_MIGRATIONS"),
disableClaudeCodePrompt: Config.all({
broad: bool("OPENCODE_DISABLE_CLAUDE_CODE"),
direct: bool("OPENCODE_DISABLE_CLAUDE_CODE_PROMPT"),
+31 -67
View File
@@ -1,27 +1,13 @@
// Temporary V2 bridge: core events are the publish path, but the rest of
// opencode and the HTTP event stream still expect legacy bus/sync payloads.
// This layer goes away once consumers subscribe to core EventV2 directly.
import { Bus as ProjectBus } from "@/bus"
import { GlobalBus } from "@/bus/global"
// Opencode publish boundary for core events. Attach routed instance location
// so direct EventV2 consumers can isolate directory/workspace streams.
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
import { InstanceStore } from "@/project/instance-store"
import { SyncEvent } from "@/sync"
import { GlobalBus } from "@/bus/global"
import { EventV2 } from "@opencode-ai/core/event"
import { AbsolutePath } from "@opencode-ai/core/schema"
import "@opencode-ai/core/account"
import "@opencode-ai/core/catalog"
import "@opencode-ai/core/session-event"
import { Context, Effect, Layer, Option } from "effect"
export function toSyncDefinition<D extends EventV2.Definition>(definition: D) {
const result = {
type: definition.type,
version: definition.version,
aggregate: definition.aggregate,
schema: definition.data,
properties: definition.data,
}
return result as SyncEvent.Definition<D["type"], D["data"], D["data"]>
}
import "@opencode-ai/core/session/event"
import { Context, Effect, Layer } from "effect"
export class Service extends Context.Service<Service, EventV2.Interface>()("@opencode/EventV2Bridge") {}
@@ -29,62 +15,40 @@ export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const events = yield* EventV2.Service
const bus = yield* ProjectBus.Service
const sync = yield* SyncEvent.Service
const publishGlobal = (event: EventV2.Payload) =>
Effect.sync(() => {
GlobalBus.emit("event", {
workspace: event.location?.workspaceID,
payload: {
id: event.id,
type: event.type,
properties: event.data,
const publish: EventV2.Interface["publish"] = (definition, data, options) =>
Effect.gen(function* () {
if (options?.location) return yield* events.publish(definition, data, options)
const ctx = yield* InstanceRef
if (!ctx) return yield* events.publish(definition, data, options)
const workspaceID = yield* WorkspaceRef
return yield* events.publish(definition, data, {
...options,
location: {
directory: AbsolutePath.make(ctx.directory),
...(workspaceID ? { workspaceID } : {}),
},
})
})
const provideEventLocation = <E, R>(event: EventV2.Payload, effect: Effect.Effect<void, E, R>) => {
return Effect.gen(function* () {
const unsubscribe = yield* events.listen((event) =>
Effect.gen(function* () {
const ctx = yield* InstanceRef
if (ctx) return yield* effect
const store = Option.getOrUndefined(yield* Effect.serviceOption(InstanceStore.Service))
if (!event.location?.directory || !store) return yield* publishGlobal(event)
return yield* store.load({ directory: event.location.directory }).pipe(
Effect.flatMap((ctx) => {
const withInstance = effect.pipe(Effect.provideService(InstanceRef, ctx))
if (!event.location?.workspaceID) return withInstance
return withInstance.pipe(Effect.provideService(WorkspaceRef, event.location.workspaceID))
}),
)
})
}
const unsubscribe = yield* events.sync((event) => {
const definition = EventV2.registry.get(event.type)
if (!definition) return Effect.void
const aggregateID = definition.aggregate
? (event.data as Record<string, unknown>)[definition.aggregate]
: undefined
if (definition.version !== undefined && typeof aggregateID === "string") {
return provideEventLocation(event, sync.run(toSyncDefinition(definition), event.data))
}
return provideEventLocation(
event,
bus.publish({ type: definition.type, properties: definition.data }, event.data, { id: event.id }),
)
})
const workspaceID = (yield* WorkspaceRef) ?? event.location?.workspaceID
GlobalBus.emit("event", {
directory: event.location?.directory ?? ctx?.directory,
project: ctx?.project.id,
workspace: workspaceID,
payload: { id: event.id, type: event.type, properties: event.data },
})
}),
)
yield* Effect.addFinalizer(() => unsubscribe)
return Service.of(events)
return Service.of({ ...events, publish })
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(EventV2.defaultLayer),
Layer.provide(SyncEvent.defaultLayer),
Layer.provide(ProjectBus.defaultLayer),
)
export const defaultLayer = layer.pipe(Layer.provide(EventV2.defaultLayer))
export * as EventV2Bridge from "./event-v2-bridge"
+6 -6
View File
@@ -1,4 +1,4 @@
import { BusEvent } from "@/bus/bus-event"
import { EventV2 } from "@opencode-ai/core/event"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { InstanceState } from "@/effect/instance-state"
@@ -62,12 +62,12 @@ export const Content = Schema.Struct({
export type Content = DeepMutable<Schema.Schema.Type<typeof Content>>
export const Event = {
Edited: BusEvent.define(
"file.edited",
Schema.Struct({
Edited: EventV2.define({
type: "file.edited",
schema: {
file: Schema.String,
}),
),
},
}),
}
const log = Log.create({ service: "file" })
+16 -11
View File
@@ -4,8 +4,8 @@ import { createWrapper } from "@parcel/watcher/wrapper"
import type ParcelWatcher from "@parcel/watcher"
import { readdir, realpath } from "fs/promises"
import path from "path"
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import { EventV2 } from "@opencode-ai/core/event"
import { EventV2Bridge } from "@/event-v2-bridge"
import { EffectBridge } from "@/effect/bridge"
import { InstanceState } from "@/effect/instance-state"
import { Flag } from "@opencode-ai/core/flag/flag"
@@ -22,13 +22,13 @@ const log = Log.create({ service: "file.watcher" })
const SUBSCRIBE_TIMEOUT_MS = 10_000
export const Event = {
Updated: BusEvent.define(
"file.watcher.updated",
Schema.Struct({
Updated: EventV2.define({
type: "file.watcher.updated",
schema: {
file: Schema.String,
event: Schema.Literals(["add", "change", "unlink"]),
}),
),
},
}),
}
const watcher = lazy((): typeof import("@parcel/watcher") | undefined => {
@@ -69,6 +69,7 @@ export const layer = Layer.effect(
Effect.gen(function* () {
const config = yield* Config.Service
const git = yield* Git.Service
const events = yield* EventV2Bridge.Service
const state = yield* InstanceState.make(
Effect.fn("FileWatcher.state")(
@@ -98,9 +99,9 @@ export const layer = Layer.effect(
const cb: ParcelWatcher.SubscribeCallback = bridge.bind((err, evts) => {
// if (err) return
for (const evt of evts) {
if (evt.type === "create") void Bus.publish(ctx, Event.Updated, { file: evt.path, event: "add" })
if (evt.type === "update") void Bus.publish(ctx, Event.Updated, { file: evt.path, event: "change" })
if (evt.type === "delete") void Bus.publish(ctx, Event.Updated, { file: evt.path, event: "unlink" })
if (evt.type === "create") bridge.fork(events.publish(Event.Updated, { file: evt.path, event: "add" }))
if (evt.type === "update") bridge.fork(events.publish(Event.Updated, { file: evt.path, event: "change" }))
if (evt.type === "delete") bridge.fork(events.publish(Event.Updated, { file: evt.path, event: "unlink" }))
}
})
@@ -162,6 +163,10 @@ export const layer = Layer.effect(
}),
)
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer), Layer.provide(Git.defaultLayer))
export const defaultLayer = layer.pipe(
Layer.provide(Config.defaultLayer),
Layer.provide(Git.defaultLayer),
Layer.provide(EventV2Bridge.defaultLayer),
)
export * as FileWatcher from "./watcher"
+6 -6
View File
@@ -1,4 +1,4 @@
import { BusEvent } from "@/bus/bus-event"
import { EventV2 } from "@opencode-ai/core/event"
import { Schema } from "effect"
import { NamedError } from "@opencode-ai/core/util/error"
import * as Log from "@opencode-ai/core/util/log"
@@ -15,12 +15,12 @@ const SUPPORTED_IDES = [
const log = Log.create({ service: "ide" })
export const Event = {
Installed: BusEvent.define(
"ide.installed",
Schema.Struct({
Installed: EventV2.define({
type: "ide.installed",
schema: {
ide: Schema.String,
}),
),
},
}),
}
export const AlreadyInstalledError = NamedError.create("AlreadyInstalledError", {})
+3 -2
View File
@@ -1,4 +1,5 @@
import { Config } from "@/config/config"
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
import type { MessageV2 } from "@/session/message-v2"
import * as Log from "@opencode-ai/core/util/log"
import photonWasm from "@silvia-odwyer/photon-node/photon_rs_bg.wasm" with { type: "file" }
@@ -52,7 +53,7 @@ export class SizeError extends Schema.TaggedErrorClass<SizeError>()("ImageSizeEr
export type Error = ResizerUnavailableError | InvalidDataUrlError | DecodeError | SizeError
export interface Interface {
readonly normalize: (input: MessageV2.FilePart) => Effect.Effect<MessageV2.FilePart, Error>
readonly normalize: (input: SessionLegacy.FilePart) => Effect.Effect<SessionLegacy.FilePart, Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Image") {}
@@ -73,7 +74,7 @@ export const layer = Layer.effect(
),
)
const normalize = Effect.fn("Image.normalize")(function* (input: MessageV2.FilePart) {
const normalize = Effect.fn("Image.normalize")(function* (input: SessionLegacy.FilePart) {
const image = (yield* config.get()).attachment?.image
const info = {
autoResize: image?.auto_resize ?? AUTO_RESIZE,
+5 -4
View File
@@ -30,10 +30,9 @@ import { WebCommand } from "./cli/cmd/web"
import { PrCommand } from "./cli/cmd/pr"
import { SessionCommand } from "./cli/cmd/session"
import { DbCommand } from "./cli/cmd/db"
import path from "path"
import { Global } from "@opencode-ai/core/global"
import { JsonMigration } from "@/storage/json-migration"
import { Database } from "@/storage/db"
import { Database } from "@opencode-ai/core/database/database"
import { errorMessage } from "./util/error"
import { PluginCommand } from "./cli/cmd/plug"
import { Heap } from "./cli/heap"
@@ -116,7 +115,7 @@ const cli = yargs(args)
run_id: processMetadata.runID,
})
const marker = path.join(Global.Path.data, "opencode.db")
const marker = Database.path()
if (!(await Filesystem.exists(marker))) {
const tty = process.stderr.isTTY
process.stderr.write("Performing one time database migration, may take a few minutes..." + EOL)
@@ -126,8 +125,9 @@ const cli = yargs(args)
const reset = "\x1b[0m"
let last = -1
if (tty) process.stderr.write("\x1b[?25l")
const sqlite = new (await import("bun:sqlite")).Database(marker)
try {
await JsonMigration.run(drizzle({ client: Database.Client().$client }), {
await JsonMigration.run(drizzle({ client: sqlite }), {
progress: (event) => {
const percent = Math.floor((event.current / event.total) * 100)
if (percent === last && event.current !== event.total) return
@@ -145,6 +145,7 @@ const cli = yargs(args)
},
})
} finally {
sqlite.close()
if (tty) process.stderr.write("\x1b[?25h")
else {
process.stderr.write(`sqlite-migration:done${EOL}`)
+11 -11
View File
@@ -6,7 +6,7 @@ import { errorMessage } from "@/util/error"
import { ChildProcess } from "effect/unstable/process"
import { AppProcess } from "@opencode-ai/core/process"
import path from "path"
import { BusEvent } from "@/bus/bus-event"
import { EventV2 } from "@opencode-ai/core/event"
import * as Log from "@opencode-ai/core/util/log"
import { makeRuntime } from "@opencode-ai/core/effect/runtime"
import semver from "semver"
@@ -20,18 +20,18 @@ export type Method = "curl" | "npm" | "yarn" | "pnpm" | "bun" | "brew" | "scoop"
export type ReleaseType = "patch" | "minor" | "major"
export const Event = {
Updated: BusEvent.define(
"installation.updated",
Schema.Struct({
Updated: EventV2.define({
type: "installation.updated",
schema: {
version: Schema.String,
}),
),
UpdateAvailable: BusEvent.define(
"installation.update-available",
Schema.Struct({
},
}),
UpdateAvailable: EventV2.define({
type: "installation.update-available",
schema: {
version: Schema.String,
}),
),
},
}),
}
export function getReleaseType(current: string, latest: string): ReleaseType {
+8 -29
View File
@@ -1,5 +1,3 @@
import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import path from "path"
import { pathToFileURL, fileURLToPath } from "url"
import { createMessageConnection, StreamMessageReader, StreamMessageWriter } from "vscode-jsonrpc/node"
@@ -11,8 +9,6 @@ import { Effect, Schema } from "effect"
import type * as LSPServer from "./server"
import { withTimeout } from "../util/timeout"
import { Filesystem } from "@/util/filesystem"
import { InstanceRef } from "@/effect/instance-ref"
import { makeRuntime } from "@/effect/run-service"
import type { InstanceContext } from "@/project/instance-context"
const DIAGNOSTICS_DEBOUNCE_MS = 150
@@ -28,8 +24,6 @@ const FILE_CHANGE_CHANGED = 2
const TEXT_DOCUMENT_SYNC_INCREMENTAL = 2
const log = Log.create({ service: "lsp.client" })
const busRuntime = makeRuntime(Bus.Service, Bus.layer)
export type Info = NonNullable<Awaited<ReturnType<typeof create>>>
export type Diagnostic = VSCodeDiagnostic
@@ -39,16 +33,6 @@ export class InitializeError extends Schema.TaggedErrorClass<InitializeError>()(
cause: Schema.optional(Schema.Defect),
}) {}
export const Event = {
Diagnostics: BusEvent.define(
"lsp.client.diagnostics",
Schema.Struct({
serverID: Schema.String,
path: Schema.String,
}),
),
}
type DocumentDiagnosticReport = {
items?: Diagnostic[]
relatedDocuments?: Record<string, DocumentDiagnosticReport>
@@ -169,15 +153,12 @@ export async function create(input: {
const published = new Map<string, { at: number; version?: number }>()
const diagnosticRegistrations = new Map<string, CapabilityRegistration>()
const registrationListeners = new Set<() => void>()
const diagnosticListeners = new Set<(input: { path: string; serverID: string }) => void>()
const mergedDiagnostics = (filePath: string) =>
dedupeDiagnostics([...(pushDiagnostics.get(filePath) ?? []), ...(pullDiagnostics.get(filePath) ?? [])])
const updatePushDiagnostics = (filePath: string, next: Diagnostic[]) => {
pushDiagnostics.set(filePath, next)
void busRuntime.runPromise((svc) =>
svc
.publish(Event.Diagnostics, { path: filePath, serverID: input.serverID })
.pipe(Effect.provideService(InstanceRef, instance)),
)
for (const listener of diagnosticListeners) listener({ path: filePath, serverID: input.serverID })
}
const updatePullDiagnostics = (filePath: string, next: Diagnostic[]) => {
pullDiagnostics.set(filePath, next)
@@ -525,14 +506,12 @@ export async function create(input: {
}
timeoutTimer = setTimeout(() => finish(false), request.timeout)
unsub = busRuntime.runSync((svc) =>
svc
.subscribeCallback(Event.Diagnostics, (event) => {
if (event.properties.path !== request.path || event.properties.serverID !== input.serverID) return
schedule()
})
.pipe(Effect.provideService(InstanceRef, instance)),
)
const listener = (event: { path: string; serverID: string }) => {
if (event.path !== request.path || event.serverID !== input.serverID) return
schedule()
}
diagnosticListeners.add(listener)
unsub = () => diagnosticListeners.delete(listener)
schedule()
})
}
+17 -7
View File
@@ -1,5 +1,5 @@
import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import { EventV2Bridge } from "@/event-v2-bridge"
import { EventV2 } from "@opencode-ai/core/event"
import * as Log from "@opencode-ai/core/util/log"
import * as LSPClient from "./client"
import path from "path"
@@ -17,7 +17,7 @@ import { RuntimeFlags } from "@/effect/runtime-flags"
const log = Log.create({ service: "lsp" })
export const Event = {
Updated: BusEvent.define("lsp.updated", Schema.Struct({})),
Updated: EventV2.define({ type: "lsp.updated", schema: {} }),
}
const Position = Schema.Struct({
@@ -144,6 +144,7 @@ export const layer = Layer.effect(
Effect.gen(function* () {
const config = yield* Config.Service
const flags = yield* RuntimeFlags.Service
const events = yield* EventV2Bridge.Service
const state = yield* InstanceState.make<State>(
Effect.fn("LSP.state")(function* (ctx) {
@@ -212,9 +213,10 @@ export const layer = Layer.effect(
const ctx = yield* InstanceState.context
if (!containsPath(file, ctx)) return [] as LSPClient.Info[]
const s = yield* InstanceState.get(state)
return yield* Effect.promise(async () => {
const clients = yield* Effect.promise(async () => {
const extension = path.parse(file).ext || file
const result: LSPClient.Info[] = []
let updated = 0
async function schedule(server: LSPServer.Info, root: string, key: string) {
const handle = await server
@@ -291,11 +293,15 @@ export const layer = Layer.effect(
if (!client) continue
result.push(client)
await Bus.publish(ctx, Event.Updated, {})
updated++
}
return result
return { result, updated }
})
yield* Effect.forEach(Array.from({ length: clients.updated }), () => events.publish(Event.Updated, {}), {
discard: true,
})
return clients.result
})
const run = Effect.fnUntraced(function* <T>(file: string, fn: (client: LSPClient.Info) => Promise<T>) {
@@ -500,7 +506,11 @@ export const layer = Layer.effect(
}),
)
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer), Layer.provide(RuntimeFlags.defaultLayer))
export const defaultLayer = layer.pipe(
Layer.provide(Config.defaultLayer),
Layer.provide(RuntimeFlags.defaultLayer),
Layer.provide(EventV2Bridge.defaultLayer),
)
export * as Diagnostic from "./diagnostic"
+18 -18
View File
@@ -22,8 +22,8 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { McpOAuthProvider, OAUTH_CALLBACK_PATH } from "./oauth-provider"
import { McpOAuthCallback } from "./oauth-callback"
import { McpAuth } from "./auth"
import { BusEvent } from "../bus/bus-event"
import { Bus } from "@/bus"
import { EventV2Bridge } from "@/event-v2-bridge"
import { EventV2 } from "@opencode-ai/core/event"
import { TuiEvent } from "@/cli/cmd/tui/event"
import open from "open"
import { Effect, Exit, Layer, Option, Context, Schema, Stream } from "effect"
@@ -48,20 +48,20 @@ export const Resource = Schema.Struct({
}).annotate({ identifier: "McpResource" })
export type Resource = Schema.Schema.Type<typeof Resource>
export const ToolsChanged = BusEvent.define(
"mcp.tools.changed",
Schema.Struct({
export const ToolsChanged = EventV2.define({
type: "mcp.tools.changed",
schema: {
server: Schema.String,
}),
)
},
})
export const BrowserOpenFailed = BusEvent.define(
"mcp.browser.open.failed",
Schema.Struct({
export const BrowserOpenFailed = EventV2.define({
type: "mcp.browser.open.failed",
schema: {
mcpName: Schema.String,
url: Schema.String,
}),
)
},
})
export const Failed = NamedError.create("MCPFailed", {
name: Schema.String,
@@ -278,7 +278,7 @@ export const layer = Layer.effect(
Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const auth = yield* McpAuth.Service
const bus = yield* Bus.Service
const events = yield* EventV2Bridge.Service
type Transport = StdioClientTransport | StreamableHTTPClientTransport | SSEClientTransport
@@ -373,7 +373,7 @@ export const layer = Layer.effect(
status: "needs_client_registration" as const,
error: "Server does not support dynamic client registration. Please provide clientId in config.",
}
return bus
return events
.publish(TuiEvent.ToastShow, {
title: "MCP Authentication Required",
message: `Server "${key}" requires a pre-registered client ID. Add clientId to your config.`,
@@ -384,7 +384,7 @@ export const layer = Layer.effect(
} else {
pendingOAuthTransports.set(key, transport)
lastStatus = { status: "needs_auth" as const }
return bus
return events
.publish(TuiEvent.ToastShow, {
title: "MCP Authentication Required",
message: `Server "${key}" requires authentication. Run: opencode mcp auth ${key}`,
@@ -516,7 +516,7 @@ export const layer = Layer.effect(
if (s.clients[name] !== client || s.status[name]?.status !== "connected") return
s.defs[name] = listed
await bridge.promise(bus.publish(ToolsChanged, { server: name }).pipe(Effect.ignore))
await bridge.promise(events.publish(ToolsChanged, { server: name }).pipe(Effect.ignore))
})
}
@@ -880,7 +880,7 @@ export const layer = Layer.effect(
),
Effect.catch(() => {
log.warn("failed to open browser, user must open URL manually", { mcpName })
return bus.publish(BrowserOpenFailed, { mcpName, url: result.authorizationUrl }).pipe(Effect.ignore)
return events.publish(BrowserOpenFailed, { mcpName, url: result.authorizationUrl }).pipe(Effect.ignore)
}),
)
@@ -972,7 +972,7 @@ export type AuthStatus = "authenticated" | "expired" | "not_authenticated"
export const defaultLayer = layer.pipe(
Layer.provide(McpAuth.defaultLayer),
Layer.provide(Bus.layer),
Layer.provide(EventV2Bridge.defaultLayer),
Layer.provide(Config.defaultLayer),
Layer.provide(CrossSpawnSpawner.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
+1 -1
View File
@@ -2,5 +2,5 @@ export { Config } from "@/config/config"
export { Server } from "./server/server"
export { bootstrap } from "./cli/bootstrap"
export * as Log from "@opencode-ai/core/util/log"
export { Database } from "@/storage/db"
export { Database } from "@opencode-ai/core/database/database"
export { JsonMigration } from "@/storage/json-migration"
+20 -21
View File
@@ -1,11 +1,9 @@
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import { ConfigPermission } from "@/config/permission"
import { InstanceState } from "@/effect/instance-state"
import { ProjectID } from "@/project/schema"
import { ProjectV2 } from "@opencode-ai/core/project"
import { MessageID, SessionID } from "@/session/schema"
import { PermissionTable } from "@/session/session.sql"
import { Database } from "@/storage/db"
import { PermissionTable } from "@opencode-ai/core/session/sql"
import { Database } from "@opencode-ai/core/database/database"
import { eq } from "drizzle-orm"
import * as Log from "@opencode-ai/core/util/log"
import { Wildcard } from "@opencode-ai/core/util/wildcard"
@@ -13,6 +11,8 @@ import { Deferred, Effect, Layer, Schema, Context } from "effect"
import os from "os"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { PermissionID } from "./schema"
import { EventV2Bridge } from "@/event-v2-bridge"
import { EventV2 } from "@opencode-ai/core/event"
const log = Log.create({ service: "permission" })
@@ -61,21 +61,21 @@ export const ReplyBody = Schema.Struct(reply).annotate({ identifier: "Permission
export type ReplyBody = Schema.Schema.Type<typeof ReplyBody>
export const Approval = Schema.Struct({
projectID: ProjectID,
projectID: ProjectV2.ID,
patterns: Schema.Array(Schema.String),
}).annotate({ identifier: "PermissionApproval" })
export type Approval = Schema.Schema.Type<typeof Approval>
export const Event = {
Asked: BusEvent.define("permission.asked", Request),
Replied: BusEvent.define(
"permission.replied",
Schema.Struct({
Asked: EventV2.define({ type: "permission.asked", schema: Request.fields }),
Replied: EventV2.define({
type: "permission.replied",
schema: {
sessionID: SessionID,
requestID: PermissionID,
reply: Reply,
}),
),
},
}),
}
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("PermissionRejectedError", {}) {
@@ -144,12 +144,11 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Pe
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const events = yield* EventV2Bridge.Service
const { db } = yield* Database.Service
const state = yield* InstanceState.make<State>(
Effect.fn("Permission.state")(function* (ctx) {
const row = Database.use((db) =>
db.select().from(PermissionTable).where(eq(PermissionTable.project_id, ctx.project.id)).get(),
)
const row = yield* db.select().from(PermissionTable).where(eq(PermissionTable.project_id, ctx.project.id)).get().pipe(Effect.orDie)
const state = {
pending: new Map<PermissionID, PendingEntry>(),
approved: [...(row?.data ?? [])],
@@ -201,7 +200,7 @@ export const layer = Layer.effect(
const deferred = yield* Deferred.make<void, RejectedError | CorrectedError>()
pending.set(id, { info, deferred })
yield* bus.publish(Event.Asked, info)
yield* events.publish(Event.Asked, info)
return yield* Effect.ensuring(
Deferred.await(deferred),
Effect.sync(() => {
@@ -216,7 +215,7 @@ export const layer = Layer.effect(
if (!existing) return yield* new NotFoundError({ requestID: input.requestID })
pending.delete(input.requestID)
yield* bus.publish(Event.Replied, {
yield* events.publish(Event.Replied, {
sessionID: existing.info.sessionID,
requestID: existing.info.id,
reply: input.reply,
@@ -231,7 +230,7 @@ export const layer = Layer.effect(
for (const [id, item] of pending.entries()) {
if (item.info.sessionID !== existing.info.sessionID) continue
pending.delete(id)
yield* bus.publish(Event.Replied, {
yield* events.publish(Event.Replied, {
sessionID: item.info.sessionID,
requestID: item.info.id,
reply: "reject",
@@ -259,7 +258,7 @@ export const layer = Layer.effect(
)
if (!ok) continue
pending.delete(id)
yield* bus.publish(Event.Replied, {
yield* events.publish(Event.Replied, {
sessionID: item.info.sessionID,
requestID: item.info.id,
reply: "always",
@@ -307,6 +306,6 @@ export function disabled(tools: string[], ruleset: Ruleset): Set<string> {
return PermissionV2.disabled(tools, ruleset)
}
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer), Layer.provide(EventV2Bridge.defaultLayer))
export * as Permission from "."
+16 -18
View File
@@ -6,7 +6,6 @@ import type {
WorkspaceAdapter as PluginWorkspaceAdapter,
} from "@opencode-ai/plugin"
import { Config } from "@/config/config"
import { Bus } from "../bus"
import * as Log from "@opencode-ai/core/util/log"
import { createOpencodeClient } from "@opencode-ai/sdk"
import { ServerAuth } from "@/server/auth"
@@ -20,7 +19,7 @@ import { CloudflareAIGatewayAuthPlugin, CloudflareWorkersAuthPlugin } from "./cl
import { AzureAuthPlugin } from "./azure"
import { DigitalOceanAuthPlugin } from "./digitalocean"
import { XaiAuthPlugin } from "./xai"
import { Effect, Layer, Context, Stream } from "effect"
import { Effect, Layer, Context } from "effect"
import { EffectBridge } from "@/effect/bridge"
import { InstanceState } from "@/effect/instance-state"
import { errorMessage } from "@/util/error"
@@ -29,6 +28,7 @@ import { parsePluginSpecifier, readPluginId, readV1Plugin, resolvePluginId } fro
import { registerAdapter } from "@/control-plane/adapters"
import type { WorkspaceAdapter } from "@/control-plane/types"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { EventV2Bridge } from "@/event-v2-bridge"
import { InstallationChannel } from "@opencode-ai/core/installation/version"
const log = Log.create({ service: "plugin" })
@@ -123,7 +123,7 @@ async function applyPlugin(load: PluginLoader.Loaded, input: PluginInput, hooks:
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const events = yield* EventV2Bridge.Service
const config = yield* Config.Service
const flags = yield* RuntimeFlags.Service
@@ -133,7 +133,7 @@ export const layer = Layer.effect(
const bridge = yield* EffectBridge.make()
function publishPluginError(message: string) {
bridge.fork(bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }))
bridge.fork(events.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }))
}
const { Server } = yield* Effect.promise(() => import("../server/server"))
@@ -235,7 +235,7 @@ export const layer = Layer.effect(
}).pipe(
Effect.catch(() => {
// TODO: make proper events for this
// bus.publish(Session.Event.Error, {
// events.publish(Session.Event.Error, {
// error: new NamedError.Unknown({
// message: `Failed to load plugin ${load.spec}: ${message}`,
// }).toObject(),
@@ -255,6 +255,16 @@ export const layer = Layer.effect(
}).pipe(Effect.ignore)
}
const unsubscribe = yield* events.listen((event) => {
if (event.location?.directory !== ctx.directory) return Effect.void
return Effect.sync(() => {
for (const hook of hooks) {
void hook["event"]?.({ event: { id: event.id, type: event.type, properties: event.data } as any })
}
})
})
yield* Effect.addFinalizer(() => unsubscribe)
yield* Effect.addFinalizer(() =>
Effect.forEach(
hooks,
@@ -269,18 +279,6 @@ export const layer = Layer.effect(
),
)
// Subscribe to bus events, fiber interrupted when scope closes
yield* (yield* bus.subscribeAll()).pipe(
Stream.runForEach((input) =>
Effect.sync(() => {
for (const hook of hooks) {
void hook["event"]?.({ event: input as any })
}
}),
),
Effect.forkScoped,
)
return { hooks }
}),
)
@@ -314,7 +312,7 @@ export const layer = Layer.effect(
)
export const defaultLayer = layer.pipe(
Layer.provide(Bus.layer),
Layer.provide(EventV2Bridge.defaultLayer),
Layer.provide(Config.defaultLayer),
Layer.provide(RuntimeFlags.defaultLayer),
)
@@ -5,7 +5,6 @@ import { File } from "../file"
import { Snapshot } from "../snapshot"
import * as Project from "./project"
import * as Vcs from "./vcs"
import { Bus } from "../bus"
import { InstanceState } from "@/effect/instance-state"
import { FileWatcher } from "@/file/watcher"
import { ShareNext } from "@/share/share-next"
@@ -57,7 +56,6 @@ export const layer = Layer.effect(
export const defaultLayer: Layer.Layer<Service> = layer.pipe(
Layer.provide([
Bus.layer,
Config.defaultLayer,
File.defaultLayer,
FileWatcher.defaultLayer,
@@ -19,6 +19,7 @@ export interface Interface {
readonly load: (input: LoadInput) => Effect.Effect<InstanceContext>
readonly reload: (input: LoadInput) => Effect.Effect<InstanceContext>
readonly dispose: (ctx: InstanceContext) => Effect.Effect<void>
readonly disposeDirectory: (directory: string) => Effect.Effect<void>
readonly disposeAll: () => Effect.Effect<void>
readonly provide: <A, E, R>(input: LoadInput, effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>
}
@@ -151,6 +152,15 @@ export const layer: Layer.Layer<Service, never, Project.Service | InstanceBootst
yield* disposeEntry(ctx.directory, entry, ctx).pipe(Effect.asVoid)
})
const disposeDirectory = Effect.fn("InstanceStore.disposeDirectory")(function* (input: string) {
const directory = AppFileSystem.resolve(input)
const entry = cache.get(directory)
if (!entry) return
const exit = yield* Deferred.await(entry.deferred).pipe(Effect.exit)
if (Exit.isFailure(exit)) return yield* removeEntry(directory, entry).pipe(Effect.asVoid)
yield* disposeEntry(directory, entry, exit.value).pipe(Effect.asVoid)
})
const disposeAllOnce = Effect.fnUntraced(function* () {
yield* Effect.logInfo("disposing all instances")
yield* Effect.forEach(
@@ -185,6 +195,7 @@ export const layer: Layer.Layer<Service, never, Project.Service | InstanceBootst
load,
reload,
dispose,
disposeDirectory,
disposeAll,
provide,
})
@@ -1,17 +0,0 @@
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"
import { Timestamps } from "../storage/schema.sql"
import type { ProjectID } from "./schema"
export const ProjectTable = sqliteTable("project", {
id: text().$type<ProjectID>().primaryKey(),
worktree: text().notNull(),
vcs: text(),
name: text(),
icon_url: text(),
icon_url_override: text(),
icon_color: text(),
...Timestamps,
time_initialized: integer(),
sandboxes: text({ mode: "json" }).notNull().$type<string[]>(),
commands: text({ mode: "json" }).$type<{ start?: string }>(),
})
+84 -111
View File
@@ -1,26 +1,25 @@
import { and, eq, sql } from "drizzle-orm"
import { Database } from "@/storage/db"
import { ProjectTable } from "./project.sql"
import { PermissionTable, SessionTable } from "../session/session.sql"
import { WorkspaceTable } from "../control-plane/workspace.sql"
import { Database } from "@opencode-ai/core/database/database"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { PermissionTable, SessionTable } from "@opencode-ai/core/session/sql"
import { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql"
import * as Log from "@opencode-ai/core/util/log"
import { Flag } from "@opencode-ai/core/flag/flag"
import { BusEvent } from "@/bus/bus-event"
import { GlobalBus } from "@/bus/global"
import { which } from "../util/which"
import { ProjectID } from "./schema"
import { Bus } from "@/bus"
import { Command } from "@/command"
import { InstanceState } from "@/effect/instance-state"
import { Effect, Layer, Scope, Context, Stream, Types, Schema } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { AppProcess } from "@opencode-ai/core/process"
import { Project as ProjectV2 } from "@opencode-ai/core/project"
import { ProjectV2 } from "@opencode-ai/core/project"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { AbsolutePath, NonNegativeInt, optionalOmitUndefined } from "@opencode-ai/core/schema"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { EventV2Bridge } from "@/event-v2-bridge"
import { EventV2 } from "@opencode-ai/core/event"
const log = Log.create({ service: "project" })
@@ -45,7 +44,7 @@ const ProjectTime = Schema.Struct({
})
export const Info = Schema.Struct({
id: ProjectID,
id: ProjectV2.ID,
worktree: Schema.String,
vcs: optionalOmitUndefined(ProjectVcs),
name: optionalOmitUndefined(Schema.String),
@@ -57,7 +56,7 @@ export const Info = Schema.Struct({
export type Info = Types.DeepMutable<Schema.Schema.Type<typeof Info>>
export const Event = {
Updated: BusEvent.define("project.updated", Info),
Updated: EventV2.define({ type: "project.updated", schema: Info.fields }),
}
type Row = typeof ProjectTable.$inferSelect
@@ -92,7 +91,7 @@ function mergePermissionRules<T extends readonly unknown[]>(oldRules: T, newRule
}
export const UpdateInput = Schema.Struct({
projectID: ProjectID,
projectID: ProjectV2.ID,
name: Schema.optional(Schema.String),
icon: Schema.optional(ProjectIcon),
commands: Schema.optional(ProjectCommands),
@@ -107,7 +106,7 @@ export const UpdatePayload = Schema.Struct({
export type UpdatePayload = Types.DeepMutable<Schema.Schema.Type<typeof UpdatePayload>>
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Project.NotFoundError", {
projectID: ProjectID,
projectID: ProjectV2.ID,
}) {}
// ---------------------------------------------------------------------------
@@ -124,13 +123,13 @@ export interface Interface {
readonly fromDirectory: (directory: string) => Effect.Effect<{ project: Info; sandbox: string }>
readonly discover: (input: Info) => Effect.Effect<void>
readonly list: () => Effect.Effect<Info[]>
readonly get: (id: ProjectID) => Effect.Effect<Info | undefined>
readonly get: (id: ProjectV2.ID) => Effect.Effect<Info | undefined>
readonly update: (input: UpdateInput) => Effect.Effect<Info, NotFoundError>
readonly initGit: (input: { directory: string; project: Info }) => Effect.Effect<Info>
readonly setInitialized: (id: ProjectID) => Effect.Effect<void>
readonly sandboxes: (id: ProjectID) => Effect.Effect<string[]>
readonly addSandbox: (id: ProjectID, directory: string) => Effect.Effect<void>
readonly removeSandbox: (id: ProjectID, directory: string) => Effect.Effect<void>
readonly setInitialized: (id: ProjectV2.ID) => Effect.Effect<void>
readonly sandboxes: (id: ProjectV2.ID) => Effect.Effect<string[]>
readonly addSandbox: (id: ProjectV2.ID, directory: string) => Effect.Effect<void>
readonly removeSandbox: (id: ProjectV2.ID, directory: string) => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Project") {}
@@ -144,8 +143,9 @@ export const layer = Layer.effect(
const proc = yield* AppProcess.Service
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const projectV2 = yield* ProjectV2.Service
const bus = yield* Bus.Service
const events = yield* EventV2Bridge.Service
const flags = yield* RuntimeFlags.Service
const { db } = yield* Database.Service
const git = Effect.fnUntraced(
function* (args: string[], opts?: { cwd?: string }) {
@@ -163,9 +163,6 @@ export const layer = Layer.effect(
Effect.catch(() => Effect.succeed({ code: 1, text: "", stderr: "" } satisfies GitResult)),
)
const db = <T>(fn: (d: Parameters<typeof Database.use>[0] extends (trx: infer D) => any ? D : never) => T) =>
Effect.sync(() => Database.use(fn))
const emitUpdated = (data: Info) =>
Effect.sync(() =>
GlobalBus.emit("event", {
@@ -180,20 +177,22 @@ export const layer = Layer.effect(
const scope = yield* Scope.Scope
const migrateProjectId = Effect.fn("Project.migrateProjectId")(function* (
oldID: ProjectID | undefined,
newID: ProjectID,
oldID: ProjectV2.ID | undefined,
newID: ProjectV2.ID,
) {
if (!oldID) return
if (oldID === ProjectID.global) return
if (oldID === ProjectV2.ID.global) return
if (oldID === newID) return
yield* Effect.sync(() =>
Database.transaction(
(d) => {
const oldProject = d.select().from(ProjectTable).where(eq(ProjectTable.id, oldID)).get()
const newProject = d.select().from(ProjectTable).where(eq(ProjectTable.id, newID)).get()
yield* db
.transaction(
(d) =>
Effect.gen(function* () {
const oldProject = yield* d.select().from(ProjectTable).where(eq(ProjectTable.id, oldID)).get()
const newProject = yield* d.select().from(ProjectTable).where(eq(ProjectTable.id, newID)).get()
if (oldProject && !newProject) {
d.insert(ProjectTable)
yield* d
.insert(ProjectTable)
.values({
...oldProject,
id: newID,
@@ -202,10 +201,11 @@ export const layer = Layer.effect(
.run()
}
const oldPermission = d.select().from(PermissionTable).where(eq(PermissionTable.project_id, oldID)).get()
const newPermission = d.select().from(PermissionTable).where(eq(PermissionTable.project_id, newID)).get()
const oldPermission = yield* d.select().from(PermissionTable).where(eq(PermissionTable.project_id, oldID)).get()
const newPermission = yield* d.select().from(PermissionTable).where(eq(PermissionTable.project_id, newID)).get()
if (oldPermission && newPermission) {
d.update(PermissionTable)
yield* d
.update(PermissionTable)
.set({
data: mergePermissionRules(oldPermission.data, newPermission.data),
time_created: Math.min(oldPermission.time_created, newPermission.time_created),
@@ -213,23 +213,24 @@ export const layer = Layer.effect(
})
.where(eq(PermissionTable.project_id, newID))
.run()
d.delete(PermissionTable).where(eq(PermissionTable.project_id, oldID)).run()
yield* d.delete(PermissionTable).where(eq(PermissionTable.project_id, oldID)).run()
}
if (oldPermission && !newPermission) {
d.update(PermissionTable).set({ project_id: newID }).where(eq(PermissionTable.project_id, oldID)).run()
yield* d.update(PermissionTable).set({ project_id: newID }).where(eq(PermissionTable.project_id, oldID)).run()
}
d.update(SessionTable)
yield* d
.update(SessionTable)
.set({ project_id: newID, time_updated: sql`${SessionTable.time_updated}` })
.where(eq(SessionTable.project_id, oldID))
.run()
d.update(WorkspaceTable).set({ project_id: newID }).where(eq(WorkspaceTable.project_id, oldID)).run()
yield* d.update(WorkspaceTable).set({ project_id: newID }).where(eq(WorkspaceTable.project_id, oldID)).run()
if (oldProject) d.delete(ProjectTable).where(eq(ProjectTable.id, oldID)).run()
},
if (oldProject) yield* d.delete(ProjectTable).where(eq(ProjectTable.id, oldID)).run()
}),
{ behavior: "immediate" },
),
)
)
.pipe(Effect.orDie)
})
const fromDirectory = Effect.fn("Project.fromDirectory")(function* (directory: string) {
@@ -239,9 +240,9 @@ export const layer = Layer.effect(
const worktree = data.id === ProjectV2.ID.make("global") && !data.vcs ? "/" : data.directory
// Phase 2: upsert
const projectID = ProjectID.make(data.id)
yield* migrateProjectId(data.previous ? ProjectID.make(data.previous) : undefined, projectID)
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, projectID)).get())
const projectID = ProjectV2.ID.make(data.id)
yield* migrateProjectId(data.previous ? ProjectV2.ID.make(data.previous) : undefined, projectID)
const row = yield* db.select().from(ProjectTable).where(eq(ProjectTable.id, projectID)).get().pipe(Effect.orDie)
const existing = row
? fromRow(row)
: {
@@ -256,12 +257,12 @@ export const layer = Layer.effect(
const result: Info = {
...existing,
worktree: projectID === ProjectID.global ? worktree : existing.worktree,
worktree: projectID === ProjectV2.ID.global ? worktree : existing.worktree,
vcs: data.vcs?.type ?? fakeVcs,
time: { ...existing.time, updated: Date.now() },
}
if (
projectID !== ProjectID.global &&
projectID !== ProjectV2.ID.global &&
data.directory !== result.worktree &&
!result.sandboxes.includes(data.directory)
)
@@ -276,8 +277,7 @@ export const layer = Layer.effect(
{ concurrency: "unbounded" },
).pipe(Effect.map((arr) => arr.filter((x): x is string => x !== undefined)))
yield* db((d) =>
d
yield* db
.insert(ProjectTable)
.values({
id: result.id,
@@ -308,21 +308,20 @@ export const layer = Layer.effect(
commands: result.commands,
},
})
.run(),
)
.run()
.pipe(Effect.orDie)
if (projectID !== ProjectID.global) {
yield* db((d) =>
d
if (projectID !== ProjectV2.ID.global) {
yield* db
.update(SessionTable)
.set({ project_id: projectID })
.where(and(eq(SessionTable.project_id, ProjectID.global), eq(SessionTable.directory, data.directory)))
.run(),
)
.where(and(eq(SessionTable.project_id, ProjectV2.ID.global), eq(SessionTable.directory, data.directory)))
.run()
.pipe(Effect.orDie)
}
yield* emitUpdated(result)
if (projectID !== ProjectID.global && data.vcs?.type === "git") {
if (projectID !== ProjectV2.ID.global && data.vcs?.type === "git") {
yield* projectV2.commit({ store: data.vcs.store, id: data.id })
}
return { project: result, sandbox: data.vcs ? data.directory : worktree }
@@ -353,17 +352,16 @@ export const layer = Layer.effect(
})
const list = Effect.fn("Project.list")(function* () {
return yield* db((d) => d.select().from(ProjectTable).all().map(fromRow))
return (yield* db.select().from(ProjectTable).all().pipe(Effect.orDie)).map(fromRow)
})
const get = Effect.fn("Project.get")(function* (id: ProjectID) {
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
const get = Effect.fn("Project.get")(function* (id: ProjectV2.ID) {
const row = yield* db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get().pipe(Effect.orDie)
return row ? fromRow(row) : undefined
})
const update = Effect.fn("Project.update")(function* (input: UpdateInput) {
const result = yield* db((d) =>
d
const result = yield* db
.update(ProjectTable)
.set({
name: input.name,
@@ -375,8 +373,8 @@ export const layer = Layer.effect(
})
.where(eq(ProjectTable.id, input.projectID))
.returning()
.get(),
)
.get()
.pipe(Effect.orDie)
if (!result) return yield* new NotFoundError({ projectID: input.projectID })
const data = fromRow(result)
yield* emitUpdated(data)
@@ -394,20 +392,18 @@ export const layer = Layer.effect(
return project
})
const setInitialized = Effect.fn("Project.setInitialized")(function* (id: ProjectID) {
yield* db((d) =>
d.update(ProjectTable).set({ time_initialized: Date.now() }).where(eq(ProjectTable.id, id)).run(),
)
const setInitialized = Effect.fn("Project.setInitialized")(function* (id: ProjectV2.ID) {
yield* db.update(ProjectTable).set({ time_initialized: Date.now() }).where(eq(ProjectTable.id, id)).run().pipe(Effect.orDie)
})
const initState = yield* InstanceState.make(
Effect.fn("Project.initState")(function* (ctx) {
yield* (yield* bus.subscribe(Command.Event.Executed)).pipe(
Stream.runForEach((payload) =>
payload.properties.name === Command.Default.INIT ? setInitialized(ctx.project.id) : Effect.void,
),
Effect.forkScoped,
)
const unsubscribe = yield* events.listen((event) => {
if (event.type !== Command.Event.Executed.type || event.location?.directory !== ctx.directory) return Effect.void
const data = event.data as EventV2.Data<typeof Command.Event.Executed>
return data.name === Command.Default.INIT ? setInitialized(ctx.project.id) : Effect.void
})
yield* Effect.addFinalizer(() => unsubscribe)
}),
)
@@ -415,8 +411,8 @@ export const layer = Layer.effect(
yield* InstanceState.get(initState)
})
const sandboxes = Effect.fn("Project.sandboxes")(function* (id: ProjectID) {
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
const sandboxes = Effect.fn("Project.sandboxes")(function* (id: ProjectV2.ID) {
const row = yield* db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get().pipe(Effect.orDie)
if (!row) return []
const data = fromRow(row)
return yield* Effect.forEach(
@@ -430,35 +426,33 @@ export const layer = Layer.effect(
).pipe(Effect.map((arr) => arr.filter((x): x is string => x !== undefined)))
})
const addSandbox = Effect.fn("Project.addSandbox")(function* (id: ProjectID, directory: string) {
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
const addSandbox = Effect.fn("Project.addSandbox")(function* (id: ProjectV2.ID, directory: string) {
const row = yield* db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get().pipe(Effect.orDie)
if (!row) throw new Error(`Project not found: ${id}`)
const sboxes = [...row.sandboxes]
if (!sboxes.includes(directory)) sboxes.push(directory)
const result = yield* db((d) =>
d
const result = yield* db
.update(ProjectTable)
.set({ sandboxes: sboxes, time_updated: Date.now() })
.where(eq(ProjectTable.id, id))
.returning()
.get(),
)
.get()
.pipe(Effect.orDie)
if (!result) throw new Error(`Project not found: ${id}`)
yield* emitUpdated(fromRow(result))
})
const removeSandbox = Effect.fn("Project.removeSandbox")(function* (id: ProjectID, directory: string) {
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
const removeSandbox = Effect.fn("Project.removeSandbox")(function* (id: ProjectV2.ID, directory: string) {
const row = yield* db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get().pipe(Effect.orDie)
if (!row) throw new Error(`Project not found: ${id}`)
const sboxes = row.sandboxes.filter((s) => s !== directory)
const result = yield* db((d) =>
d
const result = yield* db
.update(ProjectTable)
.set({ sandboxes: sboxes, time_updated: Date.now() })
.where(eq(ProjectTable.id, id))
.returning()
.get(),
)
.get()
.pipe(Effect.orDie)
if (!result) throw new Error(`Project not found: ${id}`)
yield* emitUpdated(fromRow(result))
})
@@ -480,36 +474,15 @@ export const layer = Layer.effect(
)
export const defaultLayer = layer.pipe(
Layer.provide(Bus.defaultLayer),
Layer.provide(EventV2Bridge.defaultLayer),
Layer.provide(ProjectV2.defaultLayer),
Layer.provide(AppProcess.defaultLayer),
Layer.provide(CrossSpawnSpawner.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Database.defaultLayer),
Layer.provide(RuntimeFlags.defaultLayer),
)
export const use = serviceUse(Service)
export function list() {
return Database.use((db) =>
db
.select()
.from(ProjectTable)
.all()
.map((row) => fromRow(row)),
)
}
export function get(id: ProjectID): Info | undefined {
const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
if (!row) return undefined
return fromRow(row)
}
export function setInitialized(id: ProjectID) {
Database.use((db) =>
db.update(ProjectTable).set({ time_initialized: Date.now() }).where(eq(ProjectTable.id, id)).run(),
)
}
export * as Project from "./project"
-13
View File
@@ -1,13 +0,0 @@
import { Schema } from "effect"
import { withStatics } from "@opencode-ai/core/schema"
const projectIdSchema = Schema.String.pipe(Schema.brand("ProjectID"))
export type ProjectID = typeof projectIdSchema.Type
export const ProjectID = projectIdSchema.pipe(
withStatics((schema: typeof projectIdSchema) => ({
global: schema.make("global"),
})),
)
+27 -24
View File
@@ -1,11 +1,11 @@
import { Effect, Layer, Context, Schema, Stream, Scope } from "effect"
import { formatPatch, structuredPatch } from "diff"
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import { InstanceState } from "@/effect/instance-state"
import { FileWatcher } from "@/file/watcher"
import { Git } from "@/git"
import * as Log from "@opencode-ai/core/util/log"
import { EventV2Bridge } from "@/event-v2-bridge"
import { EventV2 } from "@opencode-ai/core/event"
const log = Log.create({ service: "vcs" })
const PATCH_CONTEXT_LINES = 2_147_483_647
@@ -239,12 +239,12 @@ export const Mode = Schema.Literals(["git", "branch"])
export type Mode = Schema.Schema.Type<typeof Mode>
export const Event = {
BranchUpdated: BusEvent.define(
"vcs.branch.updated",
Schema.Struct({
BranchUpdated: EventV2.define({
type: "vcs.branch.updated",
schema: {
branch: Schema.optional(Schema.String),
}),
),
},
}),
}
export const Info = Schema.Struct({
@@ -305,11 +305,11 @@ interface State {
export class Service extends Context.Service<Service, Interface>()("@opencode/Vcs") {}
export const layer: Layer.Layer<Service, never, Git.Service | Bus.Service> = Layer.effect(
export const layer: Layer.Layer<Service, never, Git.Service | EventV2Bridge.Service> = Layer.effect(
Service,
Effect.gen(function* () {
const git = yield* Git.Service
const bus = yield* Bus.Service
const events = yield* EventV2Bridge.Service
const scope = yield* Scope.Scope
const state = yield* InstanceState.make<State>(
@@ -327,20 +327,20 @@ export const layer: Layer.Layer<Service, never, Git.Service | Bus.Service> = Lay
const value = { current, root }
log.info("initialized", { branch: value.current, default_branch: value.root?.name })
yield* (yield* bus.subscribe(FileWatcher.Event.Updated)).pipe(
Stream.filter((evt) => evt.properties.file.endsWith("HEAD")),
Stream.runForEach((_evt) =>
Effect.gen(function* () {
const next = yield* get()
if (next !== value.current) {
log.info("branch changed", { from: value.current, to: next })
value.current = next
yield* bus.publish(Event.BranchUpdated, { branch: next })
}
}),
),
Effect.forkScoped,
)
const unsubscribe = yield* events.listen((event) => {
if (event.type !== FileWatcher.Event.Updated.type || event.location?.directory !== ctx.directory) return Effect.void
const data = event.data as EventV2.Data<typeof FileWatcher.Event.Updated>
if (!data.file.endsWith("HEAD")) return Effect.void
return Effect.gen(function* () {
const next = yield* get()
if (next !== value.current) {
log.info("branch changed", { from: value.current, to: next })
value.current = next
yield* events.publish(Event.BranchUpdated, { branch: next })
}
})
})
yield* Effect.addFinalizer(() => unsubscribe)
return value
}),
@@ -429,6 +429,9 @@ export const layer: Layer.Layer<Service, never, Git.Service | Bus.Service> = Lay
}),
)
export const defaultLayer = layer.pipe(Layer.provide(Git.defaultLayer), Layer.provide(Bus.layer))
export const defaultLayer = layer.pipe(
Layer.provide(Git.defaultLayer),
Layer.provide(EventV2Bridge.defaultLayer),
)
export * as Vcs from "./vcs"
+11 -11
View File
@@ -4,7 +4,7 @@ import { Auth } from "@/auth"
import { InstanceState } from "@/effect/instance-state"
import { optionalOmitUndefined } from "@opencode-ai/core/schema"
import { Plugin } from "../plugin"
import { ProviderID } from "./schema"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Array as Arr, Effect, Layer, Record, Result, Context, Schema } from "effect"
const When = Schema.Struct({
@@ -65,11 +65,11 @@ export const CallbackInput = Schema.Struct({
export type CallbackInput = Schema.Schema.Type<typeof CallbackInput>
export class OauthMissing extends Schema.TaggedErrorClass<OauthMissing>()("ProviderAuthOauthMissing", {
providerID: ProviderID,
providerID: ProviderV2.ID,
}) {}
export class OauthCodeMissing extends Schema.TaggedErrorClass<OauthCodeMissing>()("ProviderAuthOauthCodeMissing", {
providerID: ProviderID,
providerID: ProviderV2.ID,
}) {}
export class OauthCallbackFailed extends Schema.TaggedErrorClass<OauthCallbackFailed>()(
@@ -90,15 +90,15 @@ export interface Interface {
readonly methods: () => Effect.Effect<Methods>
readonly authorize: (
input: {
providerID: ProviderID
providerID: ProviderV2.ID
} & AuthorizeInput,
) => Effect.Effect<Authorization | undefined, Error>
readonly callback: (input: { providerID: ProviderID } & CallbackInput) => Effect.Effect<void, Error>
readonly callback: (input: { providerID: ProviderV2.ID } & CallbackInput) => Effect.Effect<void, Error>
}
interface State {
hooks: Record<ProviderID, Hook>
pending: Map<ProviderID, AuthOAuthResult>
hooks: Record<ProviderV2.ID, Hook>
pending: Map<ProviderV2.ID, AuthOAuthResult>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/ProviderAuth") {}
@@ -117,11 +117,11 @@ export const layer: Layer.Layer<Service, never, Auth.Service | Plugin.Service> =
hooks: Record.fromEntries(
Arr.filterMap(plugins, (x) =>
x.auth?.provider !== undefined
? Result.succeed([ProviderID.make(x.auth.provider), x.auth] as const)
? Result.succeed([ProviderV2.ID.make(x.auth.provider), x.auth] as const)
: Result.failVoid,
),
),
pending: new Map<ProviderID, AuthOAuthResult>(),
pending: new Map<ProviderV2.ID, AuthOAuthResult>(),
}
}),
)
@@ -160,7 +160,7 @@ export const layer: Layer.Layer<Service, never, Auth.Service | Plugin.Service> =
})
const authorize = Effect.fn("ProviderAuth.authorize")(function* (
input: { providerID: ProviderID } & AuthorizeInput,
input: { providerID: ProviderV2.ID } & AuthorizeInput,
) {
const { hooks, pending } = yield* InstanceState.get(state)
const method = hooks[input.providerID].methods[input.method]
@@ -184,7 +184,7 @@ export const layer: Layer.Layer<Service, never, Auth.Service | Plugin.Service> =
}
})
const callback = Effect.fn("ProviderAuth.callback")(function* (input: { providerID: ProviderID } & CallbackInput) {
const callback = Effect.fn("ProviderAuth.callback")(function* (input: { providerID: ProviderV2.ID } & CallbackInput) {
const pending = (yield* InstanceState.get(state)).pending
const match = pending.get(input.providerID)
if (!match) return yield* new OauthMissing({ providerID: input.providerID })
+3 -3
View File
@@ -1,7 +1,7 @@
import { APICallError } from "ai"
import { STATUS_CODES } from "http"
import { iife } from "@/util/iife"
import type { ProviderID } from "./schema"
import type { ProviderV2 } from "@opencode-ai/core/provider"
export class HeaderTimeoutError extends Error {
public override readonly name = "ProviderHeaderTimeoutError"
@@ -61,7 +61,7 @@ function isOverflow(message: string) {
return /^4(00|13)\s*(status code)?\s*\(no body\)/i.test(message)
}
function message(providerID: ProviderID, e: APICallError) {
function message(providerID: ProviderV2.ID, e: APICallError) {
return iife(() => {
const msg = e.message
if (msg === "") {
@@ -194,7 +194,7 @@ export type ParsedAPICallError =
metadata?: Record<string, string>
}
export function parseAPICallError(input: { providerID: ProviderID; error: APICallError }): ParsedAPICallError {
export function parseAPICallError(input: { providerID: ProviderV2.ID; error: APICallError }): ParsedAPICallError {
const m = message(input.providerID, input.error)
const body = json(input.error.responseBody)
if (isOverflow(m) || input.error.statusCode === 413 || body?.error?.code === "context_length_exceeded") {
+53 -53
View File
@@ -25,7 +25,7 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { isRecord } from "@/util/record"
import { optionalOmitUndefined } from "@opencode-ai/core/schema"
import * as ProviderTransform from "./transform"
import { ModelID, ProviderID } from "./schema"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelStatus } from "./model-status"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { ProviderError } from "./error"
@@ -663,8 +663,8 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
for (const m of result.models) {
if (!input.models[m.id]) {
models[m.id] = {
id: ModelID.make(m.id),
providerID: ProviderID.make("gitlab"),
id: ProviderV2.ModelID.make(m.id),
providerID: ProviderV2.ID.make("gitlab"),
name: `Agent Platform (${m.name})`,
family: "",
api: {
@@ -928,8 +928,8 @@ const ProviderLimit = Schema.Struct({
})
export const Model = Schema.Struct({
id: ModelID,
providerID: ProviderID,
id: ProviderV2.ModelID,
providerID: ProviderV2.ID,
api: ProviderApiInfo,
name: Schema.String,
family: optionalOmitUndefined(Schema.String),
@@ -945,7 +945,7 @@ export const Model = Schema.Struct({
export type Model = Types.DeepMutable<Schema.Schema.Type<typeof Model>>
export const Info = Schema.Struct({
id: ProviderID,
id: ProviderV2.ID,
name: Schema.String,
source: Schema.Literals(["env", "config", "custom", "api"]),
env: Schema.Array(Schema.String),
@@ -985,8 +985,8 @@ export function defaultModelIDs<T extends { models: Record<string, { id: string
}
export class ModelNotFoundError extends Schema.TaggedErrorClass<ModelNotFoundError>()("ProviderModelNotFoundError", {
providerID: ProviderID,
modelID: ModelID,
providerID: ProviderV2.ID,
modelID: ProviderV2.ModelID,
suggestions: Schema.optional(Schema.Array(Schema.String)),
cause: Schema.optional(Schema.Defect),
}) {
@@ -996,7 +996,7 @@ export class ModelNotFoundError extends Schema.TaggedErrorClass<ModelNotFoundErr
}
export class InitError extends Schema.TaggedErrorClass<InitError>()("ProviderInitError", {
providerID: ProviderID,
providerID: ProviderV2.ID,
cause: Schema.optional(Schema.Defect),
}) {
static isInstance(input: unknown): input is InitError {
@@ -1011,7 +1011,7 @@ export class NoProvidersError extends Schema.TaggedErrorClass<NoProvidersError>(
}
export class NoModelsError extends Schema.TaggedErrorClass<NoModelsError>()("ProviderNoModelsError", {
providerID: ProviderID,
providerID: ProviderV2.ID,
}) {
static isInstance(input: unknown): input is NoModelsError {
return input instanceof NoModelsError
@@ -1022,22 +1022,22 @@ export type DefaultModelError = ModelNotFoundError | NoProvidersError | NoModels
export type Error = ModelNotFoundError | InitError | NoProvidersError | NoModelsError
export interface Interface {
readonly list: () => Effect.Effect<Record<ProviderID, Info>>
readonly getProvider: (providerID: ProviderID) => Effect.Effect<Info>
readonly getModel: (providerID: ProviderID, modelID: ModelID) => Effect.Effect<Model, ModelNotFoundError>
readonly list: () => Effect.Effect<Record<ProviderV2.ID, Info>>
readonly getProvider: (providerID: ProviderV2.ID) => Effect.Effect<Info>
readonly getModel: (providerID: ProviderV2.ID, modelID: ProviderV2.ModelID) => Effect.Effect<Model, ModelNotFoundError>
readonly getLanguage: (model: Model) => Effect.Effect<LanguageModelV3, ModelNotFoundError>
readonly closest: (
providerID: ProviderID,
providerID: ProviderV2.ID,
query: string[],
) => Effect.Effect<{ providerID: ProviderID; modelID: string } | undefined>
readonly getSmallModel: (providerID: ProviderID) => Effect.Effect<Model | undefined>
readonly defaultModel: () => Effect.Effect<{ providerID: ProviderID; modelID: ModelID }, DefaultModelError>
) => Effect.Effect<{ providerID: ProviderV2.ID; modelID: string } | undefined>
readonly getSmallModel: (providerID: ProviderV2.ID) => Effect.Effect<Model | undefined>
readonly defaultModel: () => Effect.Effect<{ providerID: ProviderV2.ID; modelID: ProviderV2.ModelID }, DefaultModelError>
}
interface State {
models: Map<string, LanguageModelV3>
providers: Record<ProviderID, Info>
catalog: Record<ProviderID, Info>
providers: Record<ProviderV2.ID, Info>
catalog: Record<ProviderV2.ID, Info>
sdk: Map<string, BundledSDK>
modelLoaders: Record<string, CustomModelLoader>
varsLoaders: Record<string, CustomVarsLoader>
@@ -1082,8 +1082,8 @@ function cost(c: ModelsDev.Model["cost"]): Model["cost"] {
function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model): Model {
const base: Model = {
id: ModelID.make(model.id),
providerID: ProviderID.make(provider.id),
id: ProviderV2.ModelID.make(model.id),
providerID: ProviderV2.ID.make(provider.id),
name: model.name,
family: model.family,
api: {
@@ -1140,7 +1140,7 @@ export function fromModelsDevProvider(provider: ModelsDev.Provider): Info {
const base = fromModelsDevModel(provider, model)
models[id] = {
...base,
id: ModelID.make(id),
id: ProviderV2.ModelID.make(id),
name: `${model.name} ${mode[0].toUpperCase()}${mode.slice(1)}`,
cost: opts.cost ? mergeDeep(base.cost, cost(opts.cost)) : base.cost,
options: opts.provider?.body
@@ -1156,7 +1156,7 @@ export function fromModelsDevProvider(provider: ModelsDev.Provider): Info {
}
}
return {
id: ProviderID.make(provider.id),
id: ProviderV2.ID.make(provider.id),
source: "custom",
name: provider.name,
env: [...(provider.env ?? [])],
@@ -1175,7 +1175,7 @@ function suggestionModelIDs(provider: Info | undefined, enableExperimentalModels
})
}
function modelSuggestions(provider: Info | undefined, modelID: ModelID, enableExperimentalModels: boolean) {
function modelSuggestions(provider: Info | undefined, modelID: ProviderV2.ModelID, enableExperimentalModels: boolean) {
const available = suggestionModelIDs(provider, enableExperimentalModels)
const fuzzy = fuzzysort.go(modelID, available, { limit: 3, threshold: -10000 }).map((m) => m.target)
if (fuzzy.length) return fuzzy
@@ -1217,7 +1217,7 @@ export const layer = Layer.effect(
const catalog = mapValues(modelsDev, fromModelsDevProvider)
const database = mapValues(catalog, toPublicInfo)
const providers: Record<ProviderID, Info> = {} as Record<ProviderID, Info>
const providers: Record<ProviderV2.ID, Info> = {} as Record<ProviderV2.ID, Info>
const languages = new Map<string, LanguageModelV3>()
const modelLoaders: {
[providerID: string]: CustomModelLoader
@@ -1238,7 +1238,7 @@ export const layer = Layer.effect(
log.info("init")
function mergeProvider(providerID: ProviderID, provider: Partial<Info>) {
function mergeProvider(providerID: ProviderV2.ID, provider: Partial<Info>) {
const existing = providers[providerID]
if (existing) {
// @ts-expect-error
@@ -1259,7 +1259,7 @@ export const layer = Layer.effect(
const disabled = new Set(cfg.disabled_providers ?? [])
const enabled = cfg.enabled_providers ? new Set(cfg.enabled_providers) : null
function isProviderAllowed(providerID: ProviderID): boolean {
function isProviderAllowed(providerID: ProviderV2.ID): boolean {
if (enabled && !enabled.has(providerID)) return false
if (disabled.has(providerID)) return false
return true
@@ -1270,7 +1270,7 @@ export const layer = Layer.effect(
const models = p?.models
if (!p || !models) continue
const providerID = ProviderID.make(p.id)
const providerID = ProviderV2.ID.make(p.id)
if (disabled.has(providerID)) continue
const provider = database[providerID]
@@ -1284,7 +1284,7 @@ export const layer = Layer.effect(
id,
{
...model,
id: ModelID.make(id),
id: ProviderV2.ModelID.make(id),
providerID,
},
]),
@@ -1296,7 +1296,7 @@ export const layer = Layer.effect(
for (const [providerID, provider] of configProviders) {
const existing = database[providerID]
const parsed: Info = {
id: ProviderID.make(providerID),
id: ProviderV2.ID.make(providerID),
name: provider.name ?? existing?.name ?? providerID,
env: provider.env ?? existing?.env ?? [],
options: mergeDeep(existing?.options ?? {}, provider.options ?? {}),
@@ -1319,7 +1319,7 @@ export const layer = Layer.effect(
return existingModel?.name ?? modelID
})
const parsedModel: Model = {
id: ModelID.make(modelID),
id: ProviderV2.ModelID.make(modelID),
api: {
id: apiID,
npm: apiNpm,
@@ -1327,7 +1327,7 @@ export const layer = Layer.effect(
},
status: model.status ?? existingModel?.status ?? "active",
name,
providerID: ProviderID.make(providerID),
providerID: ProviderV2.ID.make(providerID),
capabilities: {
temperature: model.temperature ?? existingModel?.capabilities.temperature ?? false,
reasoning: model.reasoning ?? existingModel?.capabilities.reasoning ?? false,
@@ -1389,7 +1389,7 @@ export const layer = Layer.effect(
// load env
const envs = yield* env.all()
for (const [id, provider] of Object.entries(database)) {
const providerID = ProviderID.make(id)
const providerID = ProviderV2.ID.make(id)
if (disabled.has(providerID)) continue
const apiKey = provider.env.map((item) => envs[item]).find(Boolean)
if (!apiKey) continue
@@ -1402,7 +1402,7 @@ export const layer = Layer.effect(
// load apikeys
const auths = yield* auth.all().pipe(Effect.orDie)
for (const [id, provider] of Object.entries(auths)) {
const providerID = ProviderID.make(id)
const providerID = ProviderV2.ID.make(id)
if (disabled.has(providerID)) continue
if (provider.type === "api") {
mergeProvider(providerID, {
@@ -1415,7 +1415,7 @@ export const layer = Layer.effect(
// plugin auth loader - database now has entries for config providers
for (const plugin of plugins) {
if (!plugin.auth) continue
const providerID = ProviderID.make(plugin.auth.provider)
const providerID = ProviderV2.ID.make(plugin.auth.provider)
if (disabled.has(providerID)) continue
const stored = yield* auth.get(providerID).pipe(Effect.orDie)
@@ -1434,7 +1434,7 @@ export const layer = Layer.effect(
}
for (const [id, fn] of Object.entries(custom(dep))) {
const providerID = ProviderID.make(id)
const providerID = ProviderV2.ID.make(id)
if (disabled.has(providerID)) continue
const data = database[providerID]
if (!data) {
@@ -1454,7 +1454,7 @@ export const layer = Layer.effect(
// load config - re-apply with updated data
for (const [id, provider] of configProviders) {
const providerID = ProviderID.make(id)
const providerID = ProviderV2.ID.make(id)
const partial: Partial<Info> = { source: "config" }
if (provider.env) partial.env = provider.env
if (provider.name) partial.name = provider.name
@@ -1462,7 +1462,7 @@ export const layer = Layer.effect(
mergeProvider(providerID, partial)
}
const gitlab = ProviderID.make("gitlab")
const gitlab = ProviderV2.ID.make("gitlab")
if (discoveryLoaders[gitlab] && providers[gitlab] && isProviderAllowed(gitlab)) {
yield* Effect.promise(async () => {
try {
@@ -1479,7 +1479,7 @@ export const layer = Layer.effect(
}
for (const [id, provider] of Object.entries(providers)) {
const providerID = ProviderID.make(id)
const providerID = ProviderV2.ID.make(id)
if (!isProviderAllowed(providerID)) {
delete providers[providerID]
continue
@@ -1493,10 +1493,10 @@ export const layer = Layer.effect(
// These chat aliases are invalid for the special handling in the
// built-in providers below, but custom providers may support them.
(modelID === "gpt-5-chat-latest" &&
(providerID === ProviderID.openai ||
providerID === ProviderID.githubCopilot ||
providerID === ProviderID.openrouter)) ||
(providerID === ProviderID.openrouter && modelID === "openai/gpt-5-chat")
(providerID === ProviderV2.ID.openai ||
providerID === ProviderV2.ID.githubCopilot ||
providerID === ProviderV2.ID.openrouter)) ||
(providerID === ProviderV2.ID.openrouter && modelID === "openai/gpt-5-chat")
)
delete provider.models[modelID]
if (model.status === "alpha" && !runtimeFlags.enableExperimentalModels) delete provider.models[modelID]
@@ -1702,11 +1702,11 @@ export const layer = Layer.effect(
}
}
const getProvider = Effect.fn("Provider.getProvider")((providerID: ProviderID) =>
const getProvider = Effect.fn("Provider.getProvider")((providerID: ProviderV2.ID) =>
InstanceState.use(state, (s) => s.providers[providerID]),
)
const getModel = Effect.fn("Provider.getModel")(function* (providerID: ProviderID, modelID: ModelID) {
const getModel = Effect.fn("Provider.getModel")(function* (providerID: ProviderV2.ID, modelID: ProviderV2.ModelID) {
const s = yield* InstanceState.get(state)
const provider = s.providers[providerID]
if (!provider) {
@@ -1756,7 +1756,7 @@ export const layer = Layer.effect(
)
})
const closest = Effect.fn("Provider.closest")(function* (providerID: ProviderID, query: string[]) {
const closest = Effect.fn("Provider.closest")(function* (providerID: ProviderV2.ID, query: string[]) {
const s = yield* InstanceState.get(state)
const provider = s.providers[providerID]
if (!provider) return undefined
@@ -1768,7 +1768,7 @@ export const layer = Layer.effect(
return undefined
})
const getSmallModel = Effect.fn("Provider.getSmallModel")(function* (providerID: ProviderID) {
const getSmallModel = Effect.fn("Provider.getSmallModel")(function* (providerID: ProviderV2.ID) {
const cfg = yield* config.get()
if (cfg.small_model) {
@@ -1798,7 +1798,7 @@ export const layer = Layer.effect(
priority = ["gpt-5-mini", "claude-haiku-4.5", ...priority]
}
for (const item of priority) {
if (providerID === ProviderID.amazonBedrock) {
if (providerID === ProviderV2.ID.amazonBedrock) {
const crossRegionPrefixes = ["global.", "us.", "eu."]
const candidates = Object.keys(provider.models).filter((m) => m.includes(item))
@@ -1832,16 +1832,16 @@ export const layer = Layer.effect(
const s = yield* InstanceState.get(state)
const recent = yield* fs.readJson(path.join(Global.Path.state, "model.json")).pipe(
Effect.map((x): { providerID: ProviderID; modelID: ModelID }[] => {
Effect.map((x): { providerID: ProviderV2.ID; modelID: ProviderV2.ModelID }[] => {
if (!isRecord(x) || !Array.isArray(x.recent)) return []
return x.recent.flatMap((item) => {
if (!isRecord(item)) return []
if (typeof item.providerID !== "string") return []
if (typeof item.modelID !== "string") return []
return [{ providerID: ProviderID.make(item.providerID), modelID: ModelID.make(item.modelID) }]
return [{ providerID: ProviderV2.ID.make(item.providerID), modelID: ProviderV2.ModelID.make(item.modelID) }]
})
}),
Effect.catch(() => Effect.succeed([] as { providerID: ProviderID; modelID: ModelID }[])),
Effect.catch(() => Effect.succeed([] as { providerID: ProviderV2.ID; modelID: ProviderV2.ModelID }[])),
)
for (const entry of recent) {
const provider = s.providers[entry.providerID]
@@ -1889,8 +1889,8 @@ export function sort<T extends { id: string }>(models: T[]) {
export function parseModel(model: string) {
const [providerID, ...rest] = model.split("/")
return {
providerID: ProviderID.make(providerID),
modelID: ModelID.make(rest.join("/")),
providerID: ProviderV2.ID.make(providerID),
modelID: ProviderV2.ModelID.make(rest.join("/")),
}
}
-30
View File
@@ -1,30 +0,0 @@
import { Schema } from "effect"
import { withStatics } from "@opencode-ai/core/schema"
const providerIdSchema = Schema.String.pipe(Schema.brand("ProviderID"))
export type ProviderID = typeof providerIdSchema.Type
export const ProviderID = providerIdSchema.pipe(
withStatics((schema: typeof providerIdSchema) => ({
// Well-known providers
opencode: schema.make("opencode"),
anthropic: schema.make("anthropic"),
openai: schema.make("openai"),
google: schema.make("google"),
googleVertex: schema.make("google-vertex"),
githubCopilot: schema.make("github-copilot"),
amazonBedrock: schema.make("amazon-bedrock"),
azure: schema.make("azure"),
openrouter: schema.make("openrouter"),
mistral: schema.make("mistral"),
gitlab: schema.make("gitlab"),
})),
)
const modelIdSchema = Schema.String.pipe(Schema.brand("ModelID"))
export type ModelID = typeof modelIdSchema.Type
export const ModelID = modelIdSchema
+12 -12
View File
@@ -1,5 +1,5 @@
import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import { EventV2Bridge } from "@/event-v2-bridge"
import { EventV2 } from "@opencode-ai/core/event"
import { Config } from "@/config/config"
import { InstanceState } from "@/effect/instance-state"
import { EffectBridge } from "@/effect/bridge"
@@ -96,10 +96,10 @@ export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Pty
}) {}
export const Event = {
Created: BusEvent.define("pty.created", Schema.Struct({ info: Info })),
Updated: BusEvent.define("pty.updated", Schema.Struct({ info: Info })),
Exited: BusEvent.define("pty.exited", Schema.Struct({ id: PtyID, exitCode: NonNegativeInt })),
Deleted: BusEvent.define("pty.deleted", Schema.Struct({ id: PtyID })),
Created: EventV2.define({ type: "pty.created", schema: { info: Info } }),
Updated: EventV2.define({ type: "pty.updated", schema: { info: Info } }),
Exited: EventV2.define({ type: "pty.exited", schema: { id: PtyID, exitCode: NonNegativeInt } }),
Deleted: EventV2.define({ type: "pty.deleted", schema: { id: PtyID } }),
}
export interface Interface {
@@ -126,7 +126,7 @@ export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const config = yield* Config.Service
const bus = yield* Bus.Service
const events = yield* EventV2Bridge.Service
const plugin = yield* Plugin.Service
function teardown(session: Active) {
@@ -173,7 +173,7 @@ export const layer = Layer.effect(
s.sessions.delete(id)
log.info("removing session", { id })
teardown(session)
yield* bus.publish(Event.Deleted, { id: session.info.id })
yield* events.publish(Event.Deleted, { id: session.info.id })
})
const list = Effect.fn("Pty.list")(function* () {
@@ -269,10 +269,10 @@ export const layer = Layer.effect(
if (session.info.status === "exited") return
log.info("session exited", { id, exitCode })
session.info.status = "exited"
bridge.fork(bus.publish(Event.Exited, { id, exitCode }))
bridge.fork(events.publish(Event.Exited, { id, exitCode }))
bridge.fork(remove(id))
})
yield* bus.publish(Event.Created, { info })
yield* events.publish(Event.Created, { info })
return info
})
@@ -284,7 +284,7 @@ export const layer = Layer.effect(
if (input.size) {
session.process.resize(input.size.cols, input.size.rows)
}
yield* bus.publish(Event.Updated, { info: session.info })
yield* events.publish(Event.Updated, { info: session.info })
return session.info
})
@@ -369,7 +369,7 @@ export const layer = Layer.effect(
)
export const defaultLayer = layer.pipe(
Layer.provide(Bus.layer),
Layer.provide(EventV2Bridge.defaultLayer),
Layer.provide(Plugin.defaultLayer),
Layer.provide(Config.defaultLayer),
)
+2 -2
View File
@@ -1,6 +1,6 @@
export * as PtyTicket from "./ticket"
import { WorkspaceID } from "@/control-plane/schema"
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
import { PtyID } from "@/pty/schema"
import { PositiveInt } from "@opencode-ai/core/schema"
@@ -17,7 +17,7 @@ export const ConnectToken = Schema.Struct({
export type Scope = {
readonly ptyID: PtyID
readonly directory?: string
readonly workspaceID?: WorkspaceID
readonly workspaceID?: WorkspaceV2.ID
}
export interface Interface {
+10 -10
View File
@@ -1,10 +1,10 @@
import { Deferred, Effect, Layer, Schema, Context } from "effect"
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import { InstanceState } from "@/effect/instance-state"
import { SessionID, MessageID } from "@/session/schema"
import * as Log from "@opencode-ai/core/util/log"
import { QuestionID } from "./schema"
import { EventV2Bridge } from "@/event-v2-bridge"
import { EventV2 } from "@opencode-ai/core/event"
const log = Log.create({ service: "question" })
@@ -87,9 +87,9 @@ const Rejected = Schema.Struct({
}).annotate({ identifier: "QuestionRejected" })
export const Event = {
Asked: BusEvent.define("question.asked", Request),
Replied: BusEvent.define("question.replied", Replied),
Rejected: BusEvent.define("question.rejected", Rejected),
Asked: EventV2.define({ type: "question.asked", schema: Request.fields }),
Replied: EventV2.define({ type: "question.replied", schema: Replied.fields }),
Rejected: EventV2.define({ type: "question.rejected", schema: Rejected.fields }),
}
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("QuestionRejectedError", {}) {
@@ -132,7 +132,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Qu
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const events = yield* EventV2Bridge.Service
const state = yield* InstanceState.make<State>(
Effect.fn("Question.state")(function* () {
const state = {
@@ -169,7 +169,7 @@ export const layer = Layer.effect(
tool: input.tool,
}
pending.set(id, { info, deferred })
yield* bus.publish(Event.Asked, info)
yield* events.publish(Event.Asked, info)
return yield* Effect.ensuring(
Deferred.await(deferred),
@@ -191,7 +191,7 @@ export const layer = Layer.effect(
}
pending.delete(input.requestID)
log.info("replied", { requestID: input.requestID, answers: input.answers })
yield* bus.publish(Event.Replied, {
yield* events.publish(Event.Replied, {
sessionID: existing.info.sessionID,
requestID: existing.info.id,
answers: input.answers.map((a) => [...a]),
@@ -208,7 +208,7 @@ export const layer = Layer.effect(
}
pending.delete(requestID)
log.info("rejected", { requestID })
yield* bus.publish(Event.Rejected, {
yield* events.publish(Event.Rejected, {
sessionID: existing.info.sessionID,
requestID: existing.info.id,
})
@@ -224,6 +224,6 @@ export const layer = Layer.effect(
}),
)
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
export const defaultLayer = layer.pipe(Layer.provide(EventV2Bridge.defaultLayer))
export * as Question from "."
+3 -4
View File
@@ -1,7 +1,6 @@
import { BusEvent } from "@/bus/bus-event"
import { Schema } from "effect"
import { EventV2 } from "@opencode-ai/core/event"
export const Event = {
Connected: BusEvent.define("server.connected", Schema.Struct({})),
Disposed: BusEvent.define("global.disposed", Schema.Struct({})),
Connected: EventV2.define({ type: "server.connected", schema: {} }),
Disposed: EventV2.define({ type: "global.disposed", schema: {} }),
}
@@ -1,26 +1,2 @@
import sessionProjectors from "../session/projectors"
import { SyncEvent } from "@/sync"
import { Session } from "@/session/session"
import { SessionTable } from "@/session/session.sql"
import { Database } from "@/storage/db"
import { eq } from "drizzle-orm"
export function initProjectors() {
SyncEvent.init({
projectors: sessionProjectors,
convertEvent: (type, data) => {
if (type === "session.updated") {
const id = (data as SyncEvent.Event<typeof Session.Event.Updated>["data"]).sessionID
const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
if (!row) return data
return {
sessionID: id,
info: Session.fromRow(row),
}
}
return data
},
})
}
@@ -1,7 +1,6 @@
import { Schema } from "effect"
import { HttpApi } from "effect/unstable/httpapi"
import { BusEvent } from "@/bus/bus-event"
import { SyncEvent } from "@/sync"
import { EventV2 } from "@opencode-ai/core/event"
import { ConfigApi } from "./groups/config"
import { ControlApi } from "./groups/control"
import { EventApi } from "./groups/event"
@@ -23,9 +22,18 @@ import { V2Api } from "./groups/v2"
import { Authorization } from "./middleware/authorization"
import { SchemaErrorMiddleware } from "./middleware/schema-error"
// SSE event schemas built from the BusEvent/SyncEvent registries.
const EventSchema = Schema.Union(BusEvent.effectPayloads()).annotate({ identifier: "Event" })
const SyncEventSchemas = SyncEvent.effectPayloads()
const EventSchema = Schema.Union(
EventV2.registry
.values()
.map((definition) =>
Schema.Struct({
id: Schema.String,
type: Schema.Literal(definition.type),
properties: definition.data,
}).annotate({ identifier: `Event.${definition.type}` }),
)
.toArray(),
).annotate({ identifier: "Event" })
export const RootHttpApi = HttpApi.make("opencode-root")
.addHttpApi(ControlApi)
@@ -56,7 +64,7 @@ export const OpenCodeHttpApi = HttpApi.make("opencode")
.addHttpApi(EventApi)
.addHttpApi(InstanceHttpApi)
.addHttpApi(PtyConnectApi)
.annotate(HttpApi.AdditionalSchemas, [EventSchema, ...SyncEventSchemas])
.annotate(HttpApi.AdditionalSchemas, [EventSchema])
export type RootHttpApiType = typeof RootHttpApi
export type InstanceHttpApiType = typeof InstanceHttpApi
@@ -1,11 +1,12 @@
import { Auth } from "@/auth"
import { ProviderID } from "@/provider/schema"
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { described } from "./metadata"
import { ProviderV2 } from "@opencode-ai/core/provider"
const AuthParams = Schema.Struct({
providerID: ProviderID,
providerID: ProviderV2.ID,
})
const LogQuery = Schema.Struct({
@@ -1,6 +1,6 @@
import { AccountID, OrgID } from "@/account/schema"
import { MCP } from "@/mcp"
import { ProviderID, ModelID } from "@/provider/schema"
import { Session } from "@/session/session"
import { Worktree } from "@/worktree"
import { NonNegativeInt } from "@opencode-ai/core/schema"
@@ -15,6 +15,7 @@ import {
} from "../middleware/workspace-routing"
import { described } from "./metadata"
import { QueryBoolean } from "./query"
import { ProviderV2 } from "@opencode-ai/core/provider"
const ConsoleStateResponse = Schema.Struct({
consoleManagedProviders: Schema.mutable(Schema.Array(Schema.String)),
@@ -49,8 +50,8 @@ const ToolListItem = Schema.Struct({
const ToolList = Schema.Array(ToolListItem).annotate({ identifier: "ToolList" })
export const ToolListQuery = Schema.Struct({
...WorkspaceRoutingQueryFields,
provider: ProviderID,
model: ModelID,
provider: ProviderV2.ID,
model: ProviderV2.ModelID,
})
const WorktreeList = Schema.Array(Schema.String)
@@ -1,6 +1,5 @@
import { Config } from "@/config/config"
import { BusEvent } from "@/bus/bus-event"
import { SyncEvent } from "@/sync"
import { EventV2 } from "@opencode-ai/core/event"
import "@/server/event"
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
@@ -15,7 +14,14 @@ const GlobalEventSchema = Schema.Struct({
directory: Schema.String,
project: Schema.optional(Schema.String),
workspace: Schema.optional(Schema.String),
payload: Schema.Union([...BusEvent.effectPayloads(), ...SyncEvent.effectPayloads()]),
payload: Schema.Union(
EventV2.registry
.values()
.map((definition) =>
Schema.Struct({ id: Schema.String, type: Schema.Literal(definition.type), properties: definition.data }),
)
.toArray(),
),
}).annotate({ identifier: "GlobalEvent" })
export const GlobalUpgradeInput = Schema.Struct({
@@ -1,5 +1,5 @@
import { Project } from "@/project/project"
import { ProjectID } from "@/project/schema"
import { ProjectV2 } from "@opencode-ai/core/project"
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { ProjectNotFoundError } from "../errors"
@@ -50,7 +50,7 @@ export const ProjectApi = HttpApi.make("project")
}),
),
HttpApiEndpoint.patch("update", `${root}/:projectID`, {
params: { projectID: ProjectID },
params: { projectID: ProjectV2.ID },
query: WorkspaceRoutingQuery,
payload: UpdatePayload,
success: described(Project.Info, "Updated project information"),
@@ -1,12 +1,13 @@
import { ProviderAuth } from "@/provider/auth"
import { Provider } from "@/provider/provider"
import { ProviderID } from "@/provider/schema"
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context"
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
import { described } from "./metadata"
import { ProviderV2 } from "@opencode-ai/core/provider"
const root = "/provider"
@@ -21,7 +22,7 @@ export class ProviderAuthApiError extends Schema.ErrorClass<ProviderAuthApiError
{
name: ProviderAuthErrorName,
data: Schema.Struct({
providerID: Schema.optional(ProviderID),
providerID: Schema.optional(ProviderV2.ID),
field: Schema.optional(Schema.String),
message: Schema.optional(Schema.String),
kind: Schema.optional(Schema.String),
@@ -55,7 +56,7 @@ export const ProviderApi = HttpApi.make("provider")
}),
),
HttpApiEndpoint.post("authorize", `${root}/:providerID/oauth/authorize`, {
params: { providerID: ProviderID },
params: { providerID: ProviderV2.ID },
query: WorkspaceRoutingQuery,
payload: ProviderAuth.AuthorizeInput,
success: described(Schema.UndefinedOr(ProviderAuth.Authorization), "Authorization URL and method"),
@@ -68,7 +69,7 @@ export const ProviderApi = HttpApi.make("provider")
}),
),
HttpApiEndpoint.post("callback", `${root}/:providerID/oauth/callback`, {
params: { providerID: ProviderID },
params: { providerID: ProviderV2.ID },
query: WorkspaceRoutingQuery,
payload: ProviderAuth.CallbackInput,
success: described(Schema.Boolean, "OAuth callback processed successfully"),
@@ -1,6 +1,7 @@
import { Permission } from "@/permission"
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
import { PermissionID } from "@/permission/schema"
import { ModelID, ProviderID } from "@/provider/schema"
import { Session } from "@/session/session"
import { MessageV2 } from "@/session/message-v2"
import { SessionPrompt } from "@/session/prompt"
@@ -22,6 +23,7 @@ import {
import { ApiNotFoundError, PermissionNotFoundError, SessionBusyError } from "../errors"
import { described } from "./metadata"
import { QueryBoolean } from "./query"
import { ProviderV2 } from "@opencode-ai/core/provider"
const root = "/session"
export const ListQuery = Schema.Struct({
@@ -55,13 +57,13 @@ export const UpdatePayload = Schema.Struct({
})
export const ForkPayload = Schema.Struct(Struct.omit(Session.ForkInput.fields, ["sessionID"]))
export const InitPayload = Schema.Struct({
modelID: ModelID,
providerID: ProviderID,
modelID: ProviderV2.ModelID,
providerID: ProviderV2.ID,
messageID: MessageID,
})
export const SummarizePayload = Schema.Struct({
providerID: ProviderID,
modelID: ModelID,
providerID: ProviderV2.ID,
modelID: ProviderV2.ModelID,
auto: Schema.optional(Schema.Boolean),
})
export const PromptPayload = Schema.Struct(Struct.omit(SessionPrompt.PromptInput.fields, ["sessionID"]))
@@ -176,7 +178,7 @@ export const SessionApi = HttpApi.make("session")
HttpApiEndpoint.get("messages", SessionPaths.messages, {
params: { sessionID: SessionID },
query: MessagesQuery,
success: described(Schema.Array(MessageV2.WithParts), "List of messages"),
success: described(Schema.Array(SessionLegacy.WithParts), "List of messages"),
error: [HttpApiError.BadRequest, ApiNotFoundError],
}).annotateMerge(
OpenApi.annotations({
@@ -188,7 +190,7 @@ export const SessionApi = HttpApi.make("session")
HttpApiEndpoint.get("message", SessionPaths.message, {
params: { sessionID: SessionID, messageID: MessageID },
query: WorkspaceRoutingQuery,
success: described(MessageV2.WithParts, "Message"),
success: described(SessionLegacy.WithParts, "Message"),
error: [HttpApiError.BadRequest, ApiNotFoundError],
}).annotateMerge(
OpenApi.annotations({
@@ -314,7 +316,7 @@ export const SessionApi = HttpApi.make("session")
params: { sessionID: SessionID },
query: WorkspaceRoutingQuery,
payload: PromptPayload,
success: described(MessageV2.WithParts, "Created message"),
success: described(SessionLegacy.WithParts, "Created message"),
error: [HttpApiError.BadRequest, ApiNotFoundError],
}).annotateMerge(
OpenApi.annotations({
@@ -341,7 +343,7 @@ export const SessionApi = HttpApi.make("session")
params: { sessionID: SessionID },
query: WorkspaceRoutingQuery,
payload: CommandPayload,
success: described(MessageV2.WithParts, "Created message"),
success: described(SessionLegacy.WithParts, "Created message"),
error: [HttpApiError.BadRequest, ApiNotFoundError],
}).annotateMerge(
OpenApi.annotations({
@@ -354,7 +356,7 @@ export const SessionApi = HttpApi.make("session")
params: { sessionID: SessionID },
query: WorkspaceRoutingQuery,
payload: ShellPayload,
success: described(MessageV2.WithParts, "Created message"),
success: described(SessionLegacy.WithParts, "Created message"),
error: [HttpApiError.BadRequest, ApiNotFoundError, SessionBusyError],
}).annotateMerge(
OpenApi.annotations({
@@ -430,8 +432,8 @@ export const SessionApi = HttpApi.make("session")
HttpApiEndpoint.patch("updatePart", SessionPaths.updatePart, {
params: { sessionID: SessionID, messageID: MessageID, partID: PartID },
query: WorkspaceRoutingQuery,
payload: MessageV2.Part,
success: described(MessageV2.Part, "Successfully updated part"),
payload: SessionLegacy.Part,
success: described(SessionLegacy.Part, "Successfully updated part"),
error: [HttpApiError.BadRequest, ApiNotFoundError],
}).annotateMerge(
OpenApi.annotations({
@@ -12,19 +12,19 @@ const root = "/tui"
export const CommandPayload = Schema.Struct({ command: Schema.String })
const EventTuiPromptAppend = Schema.Struct({
type: Schema.Literal(TuiEvent.PromptAppend.type),
properties: TuiEvent.PromptAppend.properties,
properties: TuiEvent.PromptAppend.data,
}).annotate({ identifier: "EventTuiPromptAppend" })
const EventTuiCommandExecute = Schema.Struct({
type: Schema.Literal(TuiEvent.CommandExecute.type),
properties: TuiEvent.CommandExecute.properties,
properties: TuiEvent.CommandExecute.data,
}).annotate({ identifier: "EventTuiCommandExecute" })
const EventTuiToastShow = Schema.Struct({
type: Schema.Literal(TuiEvent.ToastShow.type),
properties: TuiEvent.ToastShow.properties,
properties: TuiEvent.ToastShow.data,
}).annotate({ identifier: "EventTuiToastShow" })
const EventTuiSessionSelect = Schema.Struct({
type: Schema.Literal(TuiEvent.SessionSelect.type),
properties: TuiEvent.SessionSelect.properties,
properties: TuiEvent.SessionSelect.data,
}).annotate({ identifier: "EventTuiSessionSelect" })
export const TuiPublishPayload = Schema.Union([
EventTuiPromptAppend,
@@ -55,7 +55,7 @@ export const TuiApi = HttpApi.make("tui")
.add(
HttpApiEndpoint.post("appendPrompt", TuiPaths.appendPrompt, {
query: WorkspaceRoutingQuery,
payload: TuiEvent.PromptAppend.properties,
payload: TuiEvent.PromptAppend.data,
success: described(Schema.Boolean, "Prompt processed successfully"),
error: HttpApiError.BadRequest,
}).annotateMerge(
@@ -139,7 +139,7 @@ export const TuiApi = HttpApi.make("tui")
),
HttpApiEndpoint.post("showToast", TuiPaths.showToast, {
query: WorkspaceRoutingQuery,
payload: TuiEvent.ToastShow.properties,
payload: TuiEvent.ToastShow.data,
success: described(Schema.Boolean, "Toast notification shown successfully"),
}).annotateMerge(
OpenApi.annotations({
@@ -162,7 +162,7 @@ export const TuiApi = HttpApi.make("tui")
),
HttpApiEndpoint.post("selectSession", TuiPaths.selectSession, {
query: WorkspaceRoutingQuery,
payload: TuiEvent.SessionSelect.properties,
payload: TuiEvent.SessionSelect.data,
success: described(Schema.Boolean, "Session selected successfully"),
error: [HttpApiError.BadRequest, ApiNotFoundError],
}).annotateMerge(
@@ -1,5 +1,5 @@
import { SessionID } from "@/session/schema"
import { SessionMessage } from "@opencode-ai/core/session-message"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { InvalidCursorError, SessionNotFoundError, UnknownError } from "../../errors"
@@ -1,7 +1,7 @@
import { SessionID } from "@/session/schema"
import { SessionMessage } from "@opencode-ai/core/session-message"
import { Prompt } from "@opencode-ai/core/session-prompt"
import { SessionV2 } from "@/v2/session"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { Prompt } from "@opencode-ai/core/session/prompt"
import { SessionV2 } from "@opencode-ai/core/session"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import {
@@ -1,24 +1,25 @@
import { Auth } from "@/auth"
import { ProviderID } from "@/provider/schema"
import * as Log from "@opencode-ai/core/util/log"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { RootHttpApi } from "../api"
import { LogInput } from "../groups/control"
import { ProviderV2 } from "@opencode-ai/core/provider"
export const controlHandlers = HttpApiBuilder.group(RootHttpApi, "control", (handlers) =>
Effect.gen(function* () {
const auth = yield* Auth.Service
const authSet = Effect.fn("ControlHttpApi.authSet")(function* (ctx: {
params: { providerID: ProviderID }
params: { providerID: ProviderV2.ID }
payload: Auth.Info
}) {
yield* auth.set(ctx.params.providerID, ctx.payload).pipe(Effect.orDie)
return true
})
const authRemove = Effect.fn("ControlHttpApi.authRemove")(function* (ctx: { params: { providerID: ProviderID } }) {
const authRemove = Effect.fn("ControlHttpApi.authRemove")(function* (ctx: { params: { providerID: ProviderV2.ID } }) {
yield* auth.remove(ctx.params.providerID).pipe(Effect.orDie)
return true
})
@@ -1,6 +1,9 @@
import { Bus } from "@/bus"
import { EventV2Bridge } from "@/event-v2-bridge"
import { InstanceState } from "@/effect/instance-state"
import { GlobalBus } from "@/bus/global"
import { EventV2 } from "@opencode-ai/core/event"
import * as Log from "@opencode-ai/core/util/log"
import { Effect } from "effect"
import { Effect, Queue } from "effect"
import * as Stream from "effect/Stream"
import { HttpServerResponse } from "effect/unstable/http"
import { HttpApiBuilder } from "effect/unstable/httpapi"
@@ -18,24 +21,51 @@ function eventData(data: unknown): Sse.Event {
}
}
function eventResponse(bus: Bus.Interface) {
function eventID() {
return EventV2.ID.create()
}
function eventResponse(events: EventV2.Interface) {
return Effect.gen(function* () {
// Subscribe eagerly: the bus subscription is acquired in the request scope
// at this yield, so any publish from now on is queued for the body-pump
// fiber to drain — closing the race where Stream.concat(server.connected,
// lazy-subscribe) used to drop publishes in the prefix-consume window.
const events = (yield* bus.subscribeAll()).pipe(
Stream.takeUntil((event) => event.type === Bus.InstanceDisposed.type),
const instance = yield* InstanceState.context
const workspaceID = yield* InstanceState.workspaceID
// Listener registration is eager, so events published after this point cannot
// be lost while the HTTP body fiber is starting or emitting server.connected.
const queue = yield* Queue.unbounded<EventV2.Payload>()
const unsubscribe = yield* events.listen((event) => Effect.sync(() => Queue.offerUnsafe(queue, event)))
yield* Effect.addFinalizer(() => unsubscribe)
const stream = Stream.fromQueue(queue).pipe(
Stream.filter(
(event) =>
event.location?.directory === instance.directory &&
(event.location.workspaceID === undefined || event.location.workspaceID === workspaceID),
),
Stream.map((event) => ({ id: event.id, type: event.type, properties: event.data })),
)
const disposed = Stream.callback<{ id: string; type: string; properties: unknown }>((queue) => {
const listener = (event: { directory?: string; payload: { id?: string; type?: string; properties?: unknown } }) => {
if (event.directory !== instance.directory || event.payload.type !== "server.instance.disposed") return
Queue.offerUnsafe(queue, {
id: event.payload.id ?? eventID(),
type: "server.instance.disposed",
properties: event.payload.properties ?? {},
})
}
return Effect.acquireRelease(
Effect.sync(() => GlobalBus.on("event", listener)),
() => Effect.sync(() => GlobalBus.off("event", listener)),
)
})
const output = stream.pipe(Stream.merge(disposed, { haltStrategy: "left" }), Stream.takeUntil((event) => event.type === "server.instance.disposed"))
const heartbeat = Stream.tick("10 seconds").pipe(
Stream.drop(1),
Stream.map(() => ({ id: Bus.createID(), type: "server.heartbeat", properties: {} })),
Stream.map(() => ({ id: eventID(), type: "server.heartbeat", properties: {} })),
)
log.info("event connected")
return HttpServerResponse.stream(
Stream.make({ id: Bus.createID(), type: "server.connected", properties: {} }).pipe(
Stream.concat(events.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }))),
Stream.make({ id: eventID(), type: "server.connected", properties: {} }).pipe(
Stream.concat(output.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }))),
Stream.map(eventData),
Stream.pipeThroughChannel(Sse.encode()),
Stream.encodeText,
@@ -55,11 +85,11 @@ function eventResponse(bus: Bus.Interface) {
export const eventHandlers = HttpApiBuilder.group(EventApi, "event", (handlers) =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const events = yield* EventV2Bridge.Service
return handlers.handleRaw(
"subscribe",
Effect.fn("EventHttpApi.subscribe")(function* () {
return yield* eventResponse(bus)
return yield* eventResponse(events)
}),
)
}),
@@ -29,6 +29,7 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper
const project = yield* Project.Service
const registry = yield* ToolRegistry.Service
const worktreeSvc = yield* Worktree.Service
const sessions = yield* Session.Service
const getConsole = Effect.fn("ExperimentalHttpApi.console")(function* () {
const [state, groups] = yield* Effect.all(
@@ -127,21 +128,19 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper
const session = Effect.fn("ExperimentalHttpApi.session")(function* (ctx: { query: typeof SessionListQuery.Type }) {
const limit = ctx.query.limit ?? 100
const sessions = Array.from(
Session.listGlobal({
directory: ctx.query.directory,
roots: ctx.query.roots,
start: ctx.query.start,
cursor: ctx.query.cursor,
search: ctx.query.search,
limit: limit + 1,
archived: ctx.query.archived,
}),
)
const list = sessions.length > limit ? sessions.slice(0, limit) : sessions
const all = yield* sessions.listGlobal({
directory: ctx.query.directory,
roots: ctx.query.roots,
start: ctx.query.start,
cursor: ctx.query.cursor,
search: ctx.query.search,
limit: limit + 1,
archived: ctx.query.archived,
})
const list = all.length > limit ? all.slice(0, limit) : all
return HttpServerResponse.jsonUnsafe(list, {
headers:
sessions.length > limit && list.length > 0
all.length > limit && list.length > 0
? { "x-next-cursor": String(list[list.length - 1].time.updated) }
: undefined,
})
@@ -1,7 +1,7 @@
import { Config } from "@/config/config"
import { GlobalBus, type GlobalEvent as GlobalBusEvent } from "@/bus/global"
import { EffectBridge } from "@/effect/bridge"
import { Bus } from "@/bus"
import { EventV2 } from "@opencode-ai/core/event"
import { Installation } from "@/installation"
import { disposeAllInstancesAndEmitGlobalDisposed } from "@/server/global-lifecycle"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
@@ -44,11 +44,11 @@ function eventResponse() {
})
const heartbeat = Stream.tick("10 seconds").pipe(
Stream.drop(1),
Stream.map(() => ({ payload: { id: Bus.createID(), type: "server.heartbeat", properties: {} } })),
Stream.map(() => ({ payload: { id: EventV2.ID.create(), type: "server.heartbeat", properties: {} } })),
)
return HttpServerResponse.stream(
Stream.make({ payload: { id: Bus.createID(), type: "server.connected", properties: {} } }).pipe(
Stream.make({ payload: { id: EventV2.ID.create(), type: "server.connected", properties: {} } }).pipe(
Stream.concat(events.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }))),
Stream.map(eventData),
Stream.pipeThroughChannel(Sse.encode()),
@@ -1,6 +1,6 @@
import * as InstanceState from "@/effect/instance-state"
import { Project } from "@/project/project"
import { ProjectID } from "@/project/schema"
import { ProjectV2 } from "@opencode-ai/core/project"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../api"
@@ -33,7 +33,7 @@ export const projectHandlers = HttpApiBuilder.group(InstanceHttpApi, "project",
})
const update = Effect.fn("ProjectHttpApi.update")(function* (ctx: {
params: { projectID: ProjectID }
params: { projectID: ProjectV2.ID }
payload: Project.UpdatePayload
}) {
return yield* svc.update({ ...ctx.payload, projectID: ctx.params.projectID }).pipe(
@@ -2,13 +2,14 @@ import { ProviderAuth } from "@/provider/auth"
import { Config } from "@/config/config"
import { ModelsDev } from "@opencode-ai/core/models-dev"
import { Provider } from "@/provider/provider"
import { ProviderID } from "@/provider/schema"
import { mapValues } from "remeda"
import { Effect, Schema } from "effect"
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../api"
import { ProviderAuthApiError } from "../groups/provider"
import { ProviderV2 } from "@opencode-ai/core/provider"
function mapProviderAuthError<A, R>(self: Effect.Effect<A, ProviderAuth.Error, R>) {
return self.pipe(
@@ -62,7 +63,7 @@ export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "provider"
})
const authorize = Effect.fn("ProviderHttpApi.authorize")(function* (ctx: {
params: { providerID: ProviderID }
params: { providerID: ProviderV2.ID }
payload: ProviderAuth.AuthorizeInput
}) {
return yield* mapProviderAuthError(
@@ -75,7 +76,7 @@ export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "provider"
})
const authorizeRaw = Effect.fn("ProviderHttpApi.authorizeRaw")(function* (ctx: {
params: { providerID: ProviderID }
params: { providerID: ProviderV2.ID }
request: HttpServerRequest.HttpServerRequest
}) {
const body = yield* Effect.orDie(ctx.request.text)
@@ -90,7 +91,7 @@ export const providerHandlers = HttpApiBuilder.group(InstanceHttpApi, "provider"
})
const callback = Effect.fn("ProviderHttpApi.callback")(function* (ctx: {
params: { providerID: ProviderID }
params: { providerID: ProviderV2.ID }
payload: ProviderAuth.CallbackInput
}) {
yield* mapProviderAuthError(
@@ -1,5 +1,6 @@
import { Agent } from "@/agent/agent"
import { Bus } from "@/bus"
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
import { EventV2Bridge } from "@/event-v2-bridge"
import { Command } from "@/command"
import { Permission } from "@/permission"
import { PermissionID } from "@/permission/schema"
@@ -56,7 +57,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
const statusSvc = yield* SessionStatus.Service
const todoSvc = yield* Todo.Service
const summary = yield* SessionSummary.Service
const bus = yield* Bus.Service
const events = yield* EventV2Bridge.Service
const scope = yield* Scope.Scope
const list = Effect.fn("SessionHttpApi.list")(function* (ctx: { query: typeof ListQuery.Type }) {
@@ -316,7 +317,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
yield* Effect.logError("prompt_async failed").pipe(
Effect.annotateLogs({ sessionID: ctx.params.sessionID, cause }),
)
yield* bus.publish(Session.Event.Error, {
yield* events.publish(Session.Event.Error, {
sessionID: ctx.params.sessionID,
error: new NamedError.Unknown({ message: Cause.pretty(cause) }).toObject(),
})
@@ -395,10 +396,10 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
const updatePart = Effect.fn("SessionHttpApi.updatePart")(function* (ctx: {
params: { sessionID: SessionID; messageID: MessageID; partID: PartID }
payload: typeof MessageV2.Part.Type
payload: typeof SessionLegacy.Part.Type
}) {
yield* requireSession(ctx.params.sessionID)
const payload = ctx.payload as MessageV2.Part
const payload = ctx.payload as SessionLegacy.Part
if (
payload.id !== ctx.params.partID ||
payload.messageID !== ctx.params.messageID ||
@@ -1,9 +1,10 @@
import { Workspace } from "@/control-plane/workspace"
import * as InstanceState from "@/effect/instance-state"
import { Session } from "@/session/session"
import { Database } from "@/storage/db"
import { SyncEvent } from "@/sync"
import { EventTable } from "@/sync/event.sql"
import { Database } from "@opencode-ai/core/database/database"
import { EventV2 } from "@opencode-ai/core/event"
import { EventV2Bridge } from "@/event-v2-bridge"
import { EventTable } from "@opencode-ai/core/event/sql"
import { asc } from "drizzle-orm"
import { and } from "drizzle-orm"
import { eq } from "drizzle-orm"
@@ -21,8 +22,10 @@ const log = Log.create({ service: "server.sync" })
export const syncHandlers = HttpApiBuilder.group(InstanceHttpApi, "sync", (handlers) =>
Effect.gen(function* () {
const workspace = yield* Workspace.Service
const session = yield* Session.Service
const scope = yield* Scope.Scope
const sync = yield* SyncEvent.Service
const events = yield* EventV2Bridge.Service
const { db } = yield* Database.Service
const start = Effect.fn("SyncHttpApi.start")(function* () {
yield* workspace
@@ -32,27 +35,27 @@ export const syncHandlers = HttpApiBuilder.group(InstanceHttpApi, "sync", (handl
})
const replay = Effect.fn("SyncHttpApi.replay")(function* (ctx: { payload: typeof ReplayPayload.Type }) {
const events: SyncEvent.SerializedEvent[] = ctx.payload.events.map((event) => ({
id: event.id,
const payload: EventV2.SerializedEvent[] = ctx.payload.events.map((event) => ({
id: EventV2.ID.make(event.id),
aggregateID: event.aggregateID,
seq: event.seq,
type: event.type,
data: { ...event.data },
}))
const source = events[0].aggregateID
const source = payload[0].aggregateID
log.info("sync replay requested", {
sessionID: source,
events: events.length,
first: events[0]?.seq,
last: events.at(-1)?.seq,
events: payload.length,
first: payload[0]?.seq,
last: payload.at(-1)?.seq,
directory: ctx.payload.directory,
})
yield* sync.replayAll(events)
yield* events.replayAll(payload)
log.info("sync replay complete", {
sessionID: source,
events: events.length,
first: events[0]?.seq,
last: events.at(-1)?.seq,
events: payload.length,
first: payload[0]?.seq,
last: payload.at(-1)?.seq,
})
return { sessionID: source }
})
@@ -61,12 +64,7 @@ export const syncHandlers = HttpApiBuilder.group(InstanceHttpApi, "sync", (handl
const workspaceID = yield* InstanceState.workspaceID
if (!workspaceID) return yield* new HttpApiError.BadRequest({})
yield* sync.run(Session.Event.Updated, {
sessionID: ctx.payload.sessionID,
info: {
workspaceID,
},
})
yield* session.setWorkspace({ sessionID: ctx.payload.sessionID, workspaceID })
log.info("sync session stolen", {
sessionID: ctx.payload.sessionID,
@@ -78,18 +76,17 @@ export const syncHandlers = HttpApiBuilder.group(InstanceHttpApi, "sync", (handl
const history = Effect.fn("SyncHttpApi.history")(function* (ctx: { payload: typeof HistoryPayload.Type }) {
const exclude = Object.entries(ctx.payload)
return Database.use((db) =>
db
.select()
.from(EventTable)
.where(
exclude.length > 0
? not(or(...exclude.map(([id, seq]) => and(eq(EventTable.aggregate_id, id), lte(EventTable.seq, seq))))!)
: undefined,
)
.orderBy(asc(EventTable.seq))
.all(),
)
return yield* db
.select()
.from(EventTable)
.where(
exclude.length > 0
? not(or(...exclude.map(([id, seq]) => and(eq(EventTable.aggregate_id, id), lte(EventTable.seq, seq))))!)
: undefined,
)
.orderBy(asc(EventTable.seq))
.all()
.pipe(Effect.orDie)
})
return handlers.handle("start", start).handle("replay", replay).handle("steal", steal).handle("history", history)
@@ -1,4 +1,4 @@
import { Bus } from "@/bus"
import { EventV2Bridge } from "@/event-v2-bridge"
import { TuiEvent } from "@/cli/cmd/tui/event"
import { Session } from "@/session/session"
import { Effect } from "effect"
@@ -26,15 +26,15 @@ const commandAliases = {
export const tuiHandlers = HttpApiBuilder.group(InstanceHttpApi, "tui", (handlers) =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const events = yield* EventV2Bridge.Service
const session = yield* Session.Service
const publishCommand = (command: typeof TuiEvent.CommandExecute.properties.Type.command | undefined) =>
bus.publish(TuiEvent.CommandExecute, { command } as typeof TuiEvent.CommandExecute.properties.Type)
const publishCommand = (command: typeof TuiEvent.CommandExecute.data.Type.command | undefined) =>
events.publish(TuiEvent.CommandExecute, { command } as typeof TuiEvent.CommandExecute.data.Type)
const appendPrompt = Effect.fn("TuiHttpApi.appendPrompt")(function* (ctx: {
payload: typeof TuiEvent.PromptAppend.properties.Type
payload: typeof TuiEvent.PromptAppend.data.Type
}) {
yield* bus.publish(TuiEvent.PromptAppend, ctx.payload)
yield* events.publish(TuiEvent.PromptAppend, ctx.payload)
return true
})
@@ -77,29 +77,29 @@ export const tuiHandlers = HttpApiBuilder.group(InstanceHttpApi, "tui", (handler
})
const showToast = Effect.fn("TuiHttpApi.showToast")(function* (ctx: {
payload: typeof TuiEvent.ToastShow.properties.Type
payload: typeof TuiEvent.ToastShow.data.Type
}) {
yield* bus.publish(TuiEvent.ToastShow, ctx.payload)
yield* events.publish(TuiEvent.ToastShow, ctx.payload)
return true
})
const publish = Effect.fn("TuiHttpApi.publish")(function* (ctx: { payload: typeof TuiPublishPayload.Type }) {
if (ctx.payload.type === TuiEvent.PromptAppend.type)
yield* bus.publish(TuiEvent.PromptAppend, ctx.payload.properties)
yield* events.publish(TuiEvent.PromptAppend, ctx.payload.properties)
if (ctx.payload.type === TuiEvent.CommandExecute.type)
yield* bus.publish(TuiEvent.CommandExecute, ctx.payload.properties)
if (ctx.payload.type === TuiEvent.ToastShow.type) yield* bus.publish(TuiEvent.ToastShow, ctx.payload.properties)
yield* events.publish(TuiEvent.CommandExecute, ctx.payload.properties)
if (ctx.payload.type === TuiEvent.ToastShow.type) yield* events.publish(TuiEvent.ToastShow, ctx.payload.properties)
if (ctx.payload.type === TuiEvent.SessionSelect.type)
yield* bus.publish(TuiEvent.SessionSelect, ctx.payload.properties)
yield* events.publish(TuiEvent.SessionSelect, ctx.payload.properties)
return true
})
const selectSession = Effect.fn("TuiHttpApi.selectSession")(function* (ctx: {
payload: typeof TuiEvent.SessionSelect.properties.Type
payload: typeof TuiEvent.SessionSelect.data.Type
}) {
if (!ctx.payload.sessionID.startsWith("ses")) return yield* new HttpApiError.BadRequest({})
yield* SessionError.mapStorageNotFound(session.get(ctx.payload.sessionID))
yield* bus.publish(TuiEvent.SessionSelect, ctx.payload)
yield* events.publish(TuiEvent.SessionSelect, ctx.payload)
return true
})
@@ -1,4 +1,4 @@
import { SessionV2 } from "@/v2/session"
import { SessionV2 } from "@opencode-ai/core/session"
import { Layer } from "effect"
import { layer as v2LocationLayer } from "../groups/v2/location"
import { messageHandlers } from "./v2/message"
@@ -1,5 +1,5 @@
import { SessionMessage } from "@opencode-ai/core/session-message"
import { SessionV2 } from "@/v2/session"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionV2 } from "@opencode-ai/core/session"
import { Effect, Schema } from "effect"
import * as DateTime from "effect/DateTime"
import { HttpApiBuilder } from "effect/unstable/httpapi"
@@ -1,5 +1,6 @@
import { WorkspaceID } from "@/control-plane/schema"
import { SessionV2 } from "@/v2/session"
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
import { SessionV2 } from "@opencode-ai/core/session"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { DateTime, Effect, Option, Schema } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../../api"
@@ -20,7 +21,7 @@ const SessionCursor = Schema.Struct({
direction: Schema.Union([Schema.Literal("previous"), Schema.Literal("next")]),
directory: Schema.String.pipe(Schema.optional),
path: Schema.String.pipe(Schema.optional),
workspaceID: WorkspaceID.pipe(Schema.optional),
workspaceID: WorkspaceV2.ID.pipe(Schema.optional),
roots: Schema.Boolean.pipe(Schema.optional),
start: Schema.Finite.pipe(Schema.optional),
search: Schema.String.pipe(Schema.optional),
@@ -78,7 +79,7 @@ const sessionCursor = {
function decodeWorkspaceID(input: string | undefined) {
if (input === undefined) return Effect.succeed(undefined)
const workspaceID = Schema.decodeUnknownOption(WorkspaceID)(input)
const workspaceID = Schema.decodeUnknownOption(WorkspaceV2.ID)(input)
if (Option.isSome(workspaceID)) return Effect.succeed(workspaceID.value)
return Effect.fail(
new InvalidRequestError({
@@ -114,17 +115,21 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.session
start: ctx.query.start,
search: ctx.query.search,
}
const sessions = yield* session.list({
const input = {
limit: ctx.query.limit ?? DefaultSessionsLimit,
order,
directory: filters.directory,
path: filters.path,
workspaceID: filters.workspaceID,
roots: filters.roots,
start: filters.start,
search: filters.search,
cursor: decoded ? { id: decoded.id, time: decoded.time, direction: decoded.direction } : undefined,
})
}
const sessions = yield* session.list(
filters.directory
? {
...input,
directory: AbsolutePath.make(filters.directory),
}
: input,
)
const first = sessions[0]
const last = sessions.at(-1)
return {
@@ -168,7 +173,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.session
.handle(
"compact",
Effect.fn(function* (ctx) {
yield* session.compact(ctx.params.sessionID).pipe(
yield* session.compact({ sessionID: ctx.params.sessionID }).pipe(
Effect.catchTag("Session.NotFoundError", (error) =>
Effect.fail(
new SessionNotFoundError({
@@ -1,20 +1,25 @@
import { Flag } from "@opencode-ai/core/flag/flag"
import { Database } from "@opencode-ai/core/database/database"
import { Effect } from "effect"
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import * as Fence from "@/server/shared/fence"
const ignoredMethods = new Set(["GET", "HEAD", "OPTIONS"])
export const fenceLayer = HttpRouter.middleware<{ handles: unknown }>()((effect) =>
export const fenceLayer = HttpRouter.middleware<{ requires: Database.Service; handles: unknown }>()(
Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest
if (!Flag.OPENCODE_WORKSPACE_ID || ignoredMethods.has(request.method)) return yield* effect
const { db } = yield* Database.Service
return (effect) =>
Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest
if (!Flag.OPENCODE_WORKSPACE_ID || ignoredMethods.has(request.method)) return yield* effect
const previous = Fence.load()
const response = yield* effect
const current = Fence.diff(previous, Fence.load())
if (Object.keys(current).length === 0) return response
const previous = yield* Fence.load(db)
const response = yield* effect
const current = Fence.diff(previous, yield* Fence.load(db))
if (Object.keys(current).length === 0) return response
return HttpServerResponse.setHeader(response, Fence.HEADER, JSON.stringify(current))
return HttpServerResponse.setHeader(response, Fence.HEADER, JSON.stringify(current))
})
}),
).layer
@@ -1,4 +1,4 @@
import { WorkspaceID } from "@/control-plane/schema"
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
import type { Target } from "@/control-plane/types"
import { Workspace } from "@/control-plane/workspace"
import { WorkspaceAdapterRuntime } from "@/control-plane/workspace-adapter-runtime"
@@ -30,8 +30,8 @@ type RemoteTarget = Extract<Target, { type: "remote" }>
type RequestPlan = Data.TaggedEnum<{
InvalidWorkspace: {}
MissingWorkspace: { readonly workspaceID: WorkspaceID }
Local: { readonly directory: string; readonly workspaceID?: WorkspaceID }
MissingWorkspace: { readonly workspaceID: WorkspaceV2.ID }
Local: { readonly directory: string; readonly workspaceID?: WorkspaceV2.ID }
Remote: {
readonly request: HttpServerRequest.HttpServerRequest
readonly workspace: Workspace.Info
@@ -46,7 +46,7 @@ export class WorkspaceRouteContext extends Context.Service<
WorkspaceRouteContext,
{
readonly directory: string
readonly workspaceID?: WorkspaceID
readonly workspaceID?: WorkspaceV2.ID
}
>()("@opencode/ExperimentalHttpApiWorkspaceRouteContext") {}
@@ -62,23 +62,23 @@ function requestURL(request: HttpServerRequest.HttpServerRequest): URL {
return new URL(request.url, "http://localhost")
}
function configuredWorkspaceID(): WorkspaceID | undefined {
return Flag.OPENCODE_WORKSPACE_ID ? WorkspaceID.make(Flag.OPENCODE_WORKSPACE_ID) : undefined
function configuredWorkspaceID(): WorkspaceV2.ID | undefined {
return Flag.OPENCODE_WORKSPACE_ID ? WorkspaceV2.ID.make(Flag.OPENCODE_WORKSPACE_ID) : undefined
}
function selectedWorkspaceID(url: URL, sessionWorkspaceID?: WorkspaceID): WorkspaceID | undefined {
function selectedWorkspaceID(url: URL, sessionWorkspaceID?: WorkspaceV2.ID): WorkspaceV2.ID | undefined {
const workspaceParam = url.searchParams.get("workspace")
return sessionWorkspaceID ?? (workspaceParam ? WorkspaceID.make(workspaceParam) : undefined)
return sessionWorkspaceID ?? (workspaceParam ? WorkspaceV2.ID.make(workspaceParam) : undefined)
}
function selectedV2WorkspaceID(
url: URL,
sessionWorkspaceID?: WorkspaceID,
): WorkspaceID | typeof InvalidWorkspaceID | undefined {
sessionWorkspaceID?: WorkspaceV2.ID,
): WorkspaceV2.ID | typeof InvalidWorkspaceID | undefined {
if (sessionWorkspaceID) return sessionWorkspaceID
const workspaceParam = url.searchParams.get("workspace")
if (!workspaceParam) return undefined
const workspaceID = Schema.decodeUnknownOption(WorkspaceID)(workspaceParam)
const workspaceID = Schema.decodeUnknownOption(WorkspaceV2.ID)(workspaceParam)
if (Option.isNone(workspaceID)) return InvalidWorkspaceID
return workspaceID.value
}
@@ -92,14 +92,14 @@ function shouldStayOnControlPlane(request: HttpServerRequest.HttpServerRequest,
}
function resolveWorkspace(
id: WorkspaceID | undefined,
envWorkspaceID: WorkspaceID | undefined,
id: WorkspaceV2.ID | undefined,
envWorkspaceID: WorkspaceV2.ID | undefined,
): Effect.Effect<Workspace.Info | void, never, Workspace.Service> {
if (!id || envWorkspaceID) return Effect.void
return Workspace.Service.use((workspace) => workspace.get(id))
}
function missingWorkspaceResponse(id: WorkspaceID): HttpServerResponse.HttpServerResponse {
function missingWorkspaceResponse(id: WorkspaceV2.ID): HttpServerResponse.HttpServerResponse {
return HttpServerResponse.text(`Workspace not found: ${id}`, {
status: 500,
contentType: "text/plain; charset=utf-8",
@@ -13,7 +13,6 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Account } from "@/account/account"
import { Agent } from "@/agent/agent"
import { Auth } from "@/auth"
import { Bus } from "@/bus"
import { Config } from "@/config/config"
import { Command } from "@/command"
import * as Observability from "@opencode-ai/core/effect/observability"
@@ -46,9 +45,9 @@ import { Todo } from "@/session/todo"
import { SessionShare } from "@/share/session"
import { ShareNext } from "@/share/share-next"
import { EventV2Bridge } from "@/event-v2-bridge"
import { Database } from "@opencode-ai/core/database/database"
import { Skill } from "@/skill"
import { Snapshot } from "@/snapshot"
import { SyncEvent } from "@/sync"
import { ToolRegistry } from "@/tool/registry"
import { lazy } from "@/util/lazy"
import { Vcs } from "@/project/vcs"
@@ -191,8 +190,9 @@ export function createRoutes(
errorLayer,
compressionLayer,
corsVaryFix,
fenceLayer,
fenceLayer.pipe(Layer.provide(Database.defaultLayer)),
cors(corsOptions),
Database.defaultLayer,
Account.defaultLayer,
Agent.defaultLayer,
Auth.defaultLayer,
@@ -225,7 +225,6 @@ export function createRoutes(
SessionSummary.defaultLayer,
ShareNext.defaultLayer,
Snapshot.defaultLayer,
SyncEvent.defaultLayer,
EventV2Bridge.defaultLayer,
Skill.defaultLayer,
Todo.defaultLayer,
@@ -233,7 +232,6 @@ export function createRoutes(
Vcs.defaultLayer,
Workspace.defaultLayer,
Worktree.appLayer,
Bus.layer,
AppFileSystem.defaultLayer,
FetchHttpClient.layer,
HttpServer.layerServices,
+12 -12
View File
@@ -1,8 +1,8 @@
import { Database } from "@/storage/db"
import { Database } from "@opencode-ai/core/database/database"
import { inArray } from "drizzle-orm"
import { EventSequenceTable } from "@/sync/event.sql"
import { EventSequenceTable } from "@opencode-ai/core/event/sql"
import { Workspace } from "@/control-plane/workspace"
import type { WorkspaceID } from "@/control-plane/schema"
import type { WorkspaceV2 } from "@opencode-ai/core/workspace"
import * as Log from "@opencode-ai/core/util/log"
import { Effect } from "effect"
@@ -10,16 +10,16 @@ export const HEADER = "x-opencode-sync"
export type State = Record<string, number>
const log = Log.create({ service: "fence" })
export function load(ids?: string[]) {
const rows = Database.use((db) => {
if (!ids?.length) {
return db.select().from(EventSequenceTable).all()
}
export function load(db: Database.Interface["db"], ids?: string[]) {
return Effect.gen(function* () {
const rows = yield* (
ids?.length
? db.select().from(EventSequenceTable).where(inArray(EventSequenceTable.aggregate_id, ids)).all()
: db.select().from(EventSequenceTable).all()
).pipe(Effect.orDie)
return db.select().from(EventSequenceTable).where(inArray(EventSequenceTable.aggregate_id, ids)).all()
return Object.fromEntries(rows.map((row) => [row.aggregate_id, row.seq]))
})
return Object.fromEntries(rows.map((row) => [row.aggregate_id, row.seq]))
}
export function diff(prev: State, next: State) {
@@ -53,7 +53,7 @@ export function parse(headers: Headers): State | undefined {
)
}
export function wait(workspaceID: WorkspaceID, state: State, signal?: AbortSignal) {
export function wait(workspaceID: WorkspaceV2.ID, state: State, signal?: AbortSignal) {
return Effect.gen(function* () {
log.info("waiting for state", {
workspaceID,
+31 -32
View File
@@ -1,5 +1,4 @@
import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
import * as Session from "./session"
import { SessionID, MessageID, PartID } from "./schema"
import { Provider } from "@/provider/provider"
@@ -11,7 +10,7 @@ import { Agent } from "@/agent/agent"
import { Plugin } from "@/plugin"
import { Config } from "@/config/config"
import { NotFoundError } from "@/storage/storage"
import { ModelID, ProviderID } from "@/provider/schema"
import { Effect, Layer, Context, Schema } from "effect"
import * as DateTime from "effect/DateTime"
import { InstanceState } from "@/effect/instance-state"
@@ -19,17 +18,19 @@ import { isOverflow as overflow, usable } from "./overflow"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { EventV2Bridge } from "@/event-v2-bridge"
import { SessionEvent } from "@opencode-ai/core/session-event"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { EventV2 } from "@opencode-ai/core/event"
const log = Log.create({ service: "session.compaction" })
export const Event = {
Compacted: BusEvent.define(
"session.compacted",
Schema.Struct({
Compacted: EventV2.define({
type: "session.compacted",
schema: {
sessionID: SessionID,
}),
),
},
}),
}
export const PRUNE_MINIMUM = 20_000
@@ -92,9 +93,9 @@ type CompletedCompaction = {
summary: string | undefined
}
function summaryText(message: MessageV2.WithParts) {
function summaryText(message: SessionLegacy.WithParts) {
const text = message.parts
.filter((part): part is MessageV2.TextPart => part.type === "text")
.filter((part): part is SessionLegacy.TextPart => part.type === "text")
.map((part) => part.text.trim())
.filter(Boolean)
.join("\n\n")
@@ -102,7 +103,7 @@ function summaryText(message: MessageV2.WithParts) {
return text || undefined
}
function completedCompactions(messages: MessageV2.WithParts[]) {
function completedCompactions(messages: SessionLegacy.WithParts[]) {
const users = new Map<MessageID, number>()
for (let i = 0; i < messages.length; i++) {
const msg = messages[i]
@@ -140,7 +141,7 @@ function preserveRecentBudget(input: { cfg: Config.Info; model: Provider.Model }
)
}
function turns(messages: MessageV2.WithParts[]) {
function turns(messages: SessionLegacy.WithParts[]) {
const result: Turn[] = []
for (let i = 0; i < messages.length; i++) {
const msg = messages[i]
@@ -159,11 +160,11 @@ function turns(messages: MessageV2.WithParts[]) {
}
function splitTurn(input: {
messages: MessageV2.WithParts[]
messages: SessionLegacy.WithParts[]
turn: Turn
model: Provider.Model
budget: number
estimate: (input: { messages: MessageV2.WithParts[]; model: Provider.Model }) => Effect.Effect<number>
estimate: (input: { messages: SessionLegacy.WithParts[]; model: Provider.Model }) => Effect.Effect<number>
}) {
return Effect.gen(function* () {
if (input.budget <= 0) return undefined
@@ -185,13 +186,13 @@ function splitTurn(input: {
export interface Interface {
readonly isOverflow: (input: {
tokens: MessageV2.Assistant["tokens"]
tokens: SessionLegacy.Assistant["tokens"]
model: Provider.Model
}) => Effect.Effect<boolean>
readonly prune: (input: { sessionID: SessionID }) => Effect.Effect<void>
readonly process: (input: {
parentID: MessageID
messages: MessageV2.WithParts[]
messages: SessionLegacy.WithParts[]
sessionID: SessionID
auto: boolean
overflow?: boolean
@@ -199,7 +200,7 @@ export interface Interface {
readonly create: (input: {
sessionID: SessionID
agent: string
model: { providerID: ProviderID; modelID: ModelID }
model: { providerID: ProviderV2.ID; modelID: ProviderV2.ModelID }
auto: boolean
overflow?: boolean
}) => Effect.Effect<void>
@@ -212,7 +213,6 @@ export const use = serviceUse(Service)
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const config = yield* Config.Service
const session = yield* Session.Service
const agents = yield* Agent.Service
@@ -223,7 +223,7 @@ export const layer = Layer.effect(
const flags = yield* RuntimeFlags.Service
const isOverflow = Effect.fn("SessionCompaction.isOverflow")(function* (input: {
tokens: MessageV2.Assistant["tokens"]
tokens: SessionLegacy.Assistant["tokens"]
model: Provider.Model
}) {
return overflow({
@@ -235,7 +235,7 @@ export const layer = Layer.effect(
})
const estimate = Effect.fn("SessionCompaction.estimate")(function* (input: {
messages: MessageV2.WithParts[]
messages: SessionLegacy.WithParts[]
model: Provider.Model
}) {
const msgs = yield* MessageV2.toModelMessagesEffect(input.messages, input.model)
@@ -243,7 +243,7 @@ export const layer = Layer.effect(
})
const select = Effect.fn("SessionCompaction.select")(function* (input: {
messages: MessageV2.WithParts[]
messages: SessionLegacy.WithParts[]
cfg: Config.Info
model: Provider.Model
}) {
@@ -307,7 +307,7 @@ export const layer = Layer.effect(
let total = 0
let pruned = 0
const toPrune: MessageV2.ToolPart[] = []
const toPrune: SessionLegacy.ToolPart[] = []
let turns = 0
loop: for (let msgIndex = msgs.length - 1; msgIndex >= 0; msgIndex--) {
@@ -343,7 +343,7 @@ export const layer = Layer.effect(
const processCompaction = Effect.fn("SessionCompaction.process")(function* (input: {
parentID: MessageID
messages: MessageV2.WithParts[]
messages: SessionLegacy.WithParts[]
sessionID: SessionID
auto: boolean
overflow?: boolean
@@ -353,13 +353,13 @@ export const layer = Layer.effect(
throw new Error(`Compaction parent must be a user message: ${input.parentID}`)
}
const userMessage = parent.info
const compactionPart = parent.parts.find((part): part is MessageV2.CompactionPart => part.type === "compaction")
const compactionPart = parent.parts.find((part): part is SessionLegacy.CompactionPart => part.type === "compaction")
let messages = input.messages
let replay:
| {
info: MessageV2.User
parts: MessageV2.Part[]
info: SessionLegacy.User
parts: SessionLegacy.Part[]
}
| undefined
if (input.overflow) {
@@ -408,7 +408,7 @@ export const layer = Layer.effect(
toolOutputMaxChars: TOOL_OUTPUT_MAX_CHARS,
})
const ctx = yield* InstanceState.context
const msg: MessageV2.Assistant = {
const msg: SessionLegacy.Assistant = {
id: MessageID.ascending(),
role: "assistant",
parentID: input.parentID,
@@ -457,7 +457,7 @@ export const layer = Layer.effect(
})
if (result === "compact") {
processor.message.error = new MessageV2.ContextOverflowError({
processor.message.error = new SessionLegacy.ContextOverflowError({
message: replay
? "Conversation history too large to compact - exceeds model context limit"
: "Session too large to compact - context exceeds model limit even after stripping media",
@@ -576,7 +576,7 @@ export const layer = Layer.effect(
include: selected.tail_start_id,
})
}
yield* bus.publish(Event.Compacted, { sessionID: input.sessionID })
yield* events.publish(Event.Compacted, { sessionID: input.sessionID })
}
return result
})
@@ -584,7 +584,7 @@ export const layer = Layer.effect(
const create = Effect.fn("SessionCompaction.create")(function* (input: {
sessionID: SessionID
agent: string
model: { providerID: ProviderID; modelID: ModelID }
model: { providerID: ProviderV2.ID; modelID: ProviderV2.ModelID }
auto: boolean
overflow?: boolean
}) {
@@ -629,7 +629,6 @@ export const defaultLayer = Layer.suspend(() =>
Layer.provide(SessionProcessor.defaultLayer),
Layer.provide(Agent.defaultLayer),
Layer.provide(Plugin.defaultLayer),
Layer.provide(Bus.layer),
Layer.provide(Config.defaultLayer),
Layer.provide(RuntimeFlags.defaultLayer),
Layer.provide(EventV2Bridge.defaultLayer),
+5 -4
View File
@@ -1,4 +1,5 @@
import path from "path"
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
import { Effect, Layer, Context } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
import { Config } from "@/config/config"
@@ -17,7 +18,7 @@ const files = (disableClaudeCodePrompt: boolean) => [
"CONTEXT.md", // deprecated
]
function extract(messages: MessageV2.WithParts[]) {
function extract(messages: SessionLegacy.WithParts[]) {
const paths = new Set<string>()
for (const msg of messages) {
for (const part of msg.parts) {
@@ -40,7 +41,7 @@ export interface Interface {
readonly system: () => Effect.Effect<string[], AppFileSystem.Error>
readonly find: (dir: string) => Effect.Effect<string | undefined, AppFileSystem.Error>
readonly resolve: (
messages: MessageV2.WithParts[],
messages: SessionLegacy.WithParts[],
filepath: string,
messageID: MessageID,
) => Effect.Effect<{ filepath: string; content: string }[], AppFileSystem.Error>
@@ -176,7 +177,7 @@ export const layer: Layer.Layer<
})
const resolve = Effect.fn("Instruction.resolve")(function* (
messages: MessageV2.WithParts[],
messages: SessionLegacy.WithParts[],
filepath: string,
messageID: MessageID,
) {
@@ -231,7 +232,7 @@ export const defaultLayer = layer.pipe(
Layer.provide(RuntimeFlags.defaultLayer),
)
export function loaded(messages: MessageV2.WithParts[]) {
export function loaded(messages: SessionLegacy.WithParts[]) {
return extract(messages)
}
+16 -8
View File
@@ -1,4 +1,5 @@
import { Provider } from "@/provider/provider"
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import * as Log from "@opencode-ai/core/util/log"
import { Context, Effect, Layer } from "effect"
@@ -15,7 +16,8 @@ import type { MessageV2 } from "./message-v2"
import { Plugin } from "@/plugin"
import { Permission } from "@/permission"
import { PermissionID } from "@/permission/schema"
import { Bus } from "@/bus"
import { EventV2Bridge } from "@/event-v2-bridge"
import { EventV2 } from "@opencode-ai/core/event"
import { Wildcard } from "@/util/wildcard"
import { SessionID } from "@/session/schema"
import { Auth } from "@/auth"
@@ -31,7 +33,7 @@ const log = Log.create({ service: "llm" })
export const OUTPUT_TOKEN_MAX = ProviderTransform.OUTPUT_TOKEN_MAX
export type StreamInput = {
user: MessageV2.User
user: SessionLegacy.User
sessionID: string
parentSessionID?: string
model: Provider.Model
@@ -65,6 +67,7 @@ const live: Layer.Layer<
| Provider.Service
| Plugin.Service
| Permission.Service
| EventV2Bridge.Service
| LLMClientService
| RuntimeFlags.Service
> = Layer.effect(
@@ -75,6 +78,7 @@ const live: Layer.Layer<
const provider = yield* Provider.Service
const plugin = yield* Plugin.Service
const perm = yield* Permission.Service
const events = yield* EventV2Bridge.Service
const llmClient = yield* LLMClient.Service
const flags = yield* RuntimeFlags.Service
@@ -162,11 +166,15 @@ const live: Layer.Layer<
}
const id = PermissionID.ascending()
let unsub: (() => void) | undefined
let unsub: EventV2.Unsubscribe | undefined
try {
unsub = Bus.subscribe(Permission.Event.Replied, (evt) => {
if (evt.properties.requestID === id) void evt.properties.reply
})
unsub = await bridge.promise(events.listen((event) => {
if (event.type !== Permission.Event.Replied.type) return Effect.void
const data = event.data as EventV2.Data<typeof Permission.Event.Replied>
if (data.requestID !== id) return Effect.void
void data.reply
return Effect.void
}))
const toolPatterns = approvalTools.map((t: { name: string; args: string }) => {
try {
const parsed = JSON.parse(t.args) as Record<string, unknown>
@@ -194,7 +202,7 @@ const live: Layer.Layer<
} catch {
return { approved: false }
} finally {
unsub?.()
if (unsub) await bridge.promise(unsub)
}
})
}
@@ -370,7 +378,7 @@ const live: Layer.Layer<
}),
)
export const layer = live.pipe(Layer.provide(Permission.defaultLayer))
export const layer = live.pipe(Layer.provide(Permission.defaultLayer), Layer.provide(EventV2Bridge.defaultLayer))
export const defaultLayer = Layer.suspend(() =>
layer.pipe(
+2 -1
View File
@@ -1,4 +1,5 @@
import type { Auth } from "@/auth"
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
import type { RuntimeFlags } from "@/effect/runtime-flags"
import { InstanceState } from "@/effect/instance-state"
import { Permission } from "@/permission"
@@ -16,7 +17,7 @@ import { mergeDeep } from "remeda"
const USER_AGENT = `opencode/${InstallationVersion}`
type PrepareInput = {
readonly user: MessageV2.User
readonly user: SessionLegacy.User
readonly sessionID: string
readonly parentSessionID?: string
readonly model: Provider.Model
+102 -584
View File
@@ -1,11 +1,27 @@
import { BusEvent } from "@/bus/bus-event"
import { EventV2 } from "@opencode-ai/core/event"
import { SessionID, MessageID, PartID } from "./schema"
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
import { ProviderV2 } from "@opencode-ai/core/provider"
import {
APIError,
AbortedError,
Assistant,
AuthError,
CompactionPart,
ContextOverflowError,
Info,
OutputLengthError,
Part,
StructuredOutputError,
SubtaskPart,
User,
WithParts,
type ToolPart,
} from "@opencode-ai/core/session/legacy"
import { NamedError } from "@opencode-ai/core/util/error"
import { APICallError, convertToModelMessages, LoadAPIKeyError, type ModelMessage, type UIMessage } from "ai"
import { LSP } from "@/lsp/lsp"
import { Snapshot } from "@/snapshot"
import { SyncEvent } from "../sync"
import { Database } from "@/storage/db"
import { Database } from "@opencode-ai/core/database/database"
import { NotFoundError } from "@/storage/storage"
import { and } from "drizzle-orm"
import { desc } from "drizzle-orm"
@@ -13,20 +29,15 @@ import { eq } from "drizzle-orm"
import { inArray } from "drizzle-orm"
import { lt } from "drizzle-orm"
import { or } from "drizzle-orm"
import { MessageTable, PartTable, SessionTable } from "./session.sql"
import { MessageTable, PartTable, SessionTable } from "@opencode-ai/core/session/sql"
import * as ProviderError from "@/provider/error"
import { iife } from "@/util/iife"
import { errorMessage } from "@/util/error"
import { isMedia } from "@/util/media"
import type { SystemError } from "bun"
import type { Provider } from "@/provider/provider"
import { ModelID, ProviderID } from "@/provider/schema"
import { Effect, Schema, Types } from "effect"
import { NonNegativeInt } from "@opencode-ai/core/schema"
import { Effect, Schema } from "effect"
import * as EffectLogger from "@opencode-ai/core/effect/logger"
import { MessageError } from "./message-error"
import { AuthError, OutputLengthError } from "./message-error"
export { AuthError, OutputLengthError } from "./message-error"
/** Error shape thrown by Bun's fetch() when gzip/br decompression fails mid-stream */
interface FetchDecompressionError extends Error {
@@ -38,526 +49,27 @@ interface FetchDecompressionError extends Error {
export const SYNTHETIC_ATTACHMENT_PROMPT = "Attached media from tool result:"
export { isMedia }
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: SessionID,
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 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: LSP.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: ProviderID,
modelID: 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>>
function truncateToolOutput(text: string, maxChars?: number) {
if (!maxChars || text.length <= maxChars) return text
const omitted = text.length - maxChars
return `${text.slice(0, maxChars)}\n[Tool output truncated for compaction: omitted ${omitted} chars]`
}
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: SessionID,
}
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(Snapshot.FileDiff),
}),
),
agent: Schema.String,
model: Schema.Struct({
providerID: ProviderID,
modelID: 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([
...MessageError.Shared,
AbortedError.EffectSchema,
StructuredOutputError.EffectSchema,
ContextOverflowError.EffectSchema,
APIError.EffectSchema,
]).annotate({ discriminator: "name" })
type AssistantError = Schema.Schema.Type<typeof AssistantErrorSchema>
// ── Prompt input schemas ─────────────────────────────────────────────────────
//
// Consumers of `SessionPrompt.PromptInput.parts` send part drafts without the
// ambient IDs (`messageID`, `sessionID`) that live on stored parts, and may
// omit `id` to let the server allocate one. These Schema-Struct variants
// carry that shape so prompt decoding can accept drafts without stored IDs.
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: ProviderID,
modelID: 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: ModelID,
providerID: ProviderID,
/**
* @deprecated
*/
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
const UpdatedEventSchema = Schema.Struct({
sessionID: SessionID,
info: Info,
})
const RemovedEventSchema = Schema.Struct({
sessionID: SessionID,
messageID: MessageID,
})
const PartUpdatedEventSchema = Schema.Struct({
sessionID: SessionID,
part: Part,
time: NonNegativeInt,
})
const PartRemovedEventSchema = Schema.Struct({
sessionID: SessionID,
messageID: MessageID,
partID: PartID,
})
export const Event = {
Updated: SyncEvent.define({
type: "message.updated",
version: 1,
aggregate: "sessionID",
schema: UpdatedEventSchema,
}),
Removed: SyncEvent.define({
type: "message.removed",
version: 1,
aggregate: "sessionID",
schema: RemovedEventSchema,
}),
PartUpdated: SyncEvent.define({
type: "message.part.updated",
version: 1,
aggregate: "sessionID",
schema: PartUpdatedEventSchema,
}),
PartDelta: BusEvent.define(
"message.part.delta",
Schema.Struct({
Updated: SessionLegacy.Event.MessageUpdated,
Removed: SessionLegacy.Event.MessageRemoved,
PartUpdated: SessionLegacy.Event.PartUpdated,
PartDelta: EventV2.define({
type: "message.part.delta",
schema: {
sessionID: SessionID,
messageID: MessageID,
partID: PartID,
field: Schema.String,
delta: Schema.String,
}),
),
PartRemoved: SyncEvent.define({
type: "message.part.removed",
version: 1,
aggregate: "sessionID",
schema: PartRemovedEventSchema,
},
}),
}
export const WithParts = Schema.Struct({
info: Info,
parts: Schema.Array(Part),
})
export type WithParts = {
info: Info
parts: Part[]
PartRemoved: SessionLegacy.Event.PartRemoved,
}
const Cursor = Schema.Struct({
@@ -595,30 +107,31 @@ const part = (row: typeof PartTable.$inferSelect) =>
const older = (row: Cursor) =>
or(lt(MessageTable.time_created, row.time), and(eq(MessageTable.time_created, row.time), lt(MessageTable.id, row.id)))
function hydrate(rows: (typeof MessageTable.$inferSelect)[]) {
function hydrate(db: Database.Interface["db"], rows: (typeof MessageTable.$inferSelect)[]) {
const ids = rows.map((row) => row.id)
const partByMessage = new Map<string, Part[]>()
if (ids.length > 0) {
const partRows = Database.use((db) =>
db
return Effect.gen(function* () {
if (ids.length > 0) {
const partRows = yield* db
.select()
.from(PartTable)
.where(inArray(PartTable.message_id, ids))
.orderBy(PartTable.message_id, PartTable.id)
.all(),
)
for (const row of partRows) {
const next = part(row)
const list = partByMessage.get(row.message_id)
if (list) list.push(next)
else partByMessage.set(row.message_id, [next])
.all()
.pipe(Effect.orDie)
for (const row of partRows) {
const next = part(row)
const list = partByMessage.get(row.message_id)
if (list) list.push(next)
else partByMessage.set(row.message_id, [next])
}
}
}
return rows.map((row) => ({
info: info(row),
parts: partByMessage.get(row.id) ?? [],
}))
return rows.map((row) => ({
info: info(row),
parts: partByMessage.get(row.id) ?? [],
}))
})
}
function providerMeta(metadata: Record<string, any> | undefined) {
@@ -925,23 +438,26 @@ export const page = Effect.fn("MessageV2.page")(function* (input: {
limit: number
before?: string
}) {
const { db } = yield* Database.Service
const before = input.before ? cursor.decode(input.before) : undefined
const where = before
? and(eq(MessageTable.session_id, input.sessionID), older(before))
: eq(MessageTable.session_id, input.sessionID)
const rows = Database.use((db) =>
db
.select()
.from(MessageTable)
.where(where)
.orderBy(desc(MessageTable.time_created), desc(MessageTable.id))
.limit(input.limit + 1)
.all(),
)
const rows = yield* db
.select()
.from(MessageTable)
.where(where)
.orderBy(desc(MessageTable.time_created), desc(MessageTable.id))
.limit(input.limit + 1)
.all()
.pipe(Effect.orDie)
if (rows.length === 0) {
const row = Database.use((db) =>
db.select({ id: SessionTable.id }).from(SessionTable).where(eq(SessionTable.id, input.sessionID)).get(),
)
const row = yield* db
.select({ id: SessionTable.id })
.from(SessionTable)
.where(eq(SessionTable.id, input.sessionID))
.get()
.pipe(Effect.orDie)
if (!row) return yield* new NotFoundError({ message: `Session not found: ${input.sessionID}` })
return {
items: [] as WithParts[],
@@ -951,7 +467,7 @@ export const page = Effect.fn("MessageV2.page")(function* (input: {
const more = rows.length > input.limit
const slice = more ? rows.slice(0, input.limit) : rows
const items = hydrate(slice)
const items = yield* hydrate(db, slice)
items.reverse()
const tail = slice.at(-1)
return {
@@ -961,53 +477,55 @@ export const page = Effect.fn("MessageV2.page")(function* (input: {
}
})
export function* stream(sessionID: SessionID) {
export function stream(sessionID: SessionID) {
const size = 50
let before: string | undefined
while (true) {
const next = Effect.runSync(
page({ sessionID, limit: size, before }).pipe(
return Effect.gen(function* () {
const result = [] as WithParts[]
let before: string | undefined
while (true) {
const next = yield* page({ sessionID, limit: size, before }).pipe(
Effect.catchIf(NotFoundError.isInstance, () =>
Effect.succeed({ items: [] as WithParts[], more: false, cursor: undefined }),
),
),
)
if (next.items.length === 0) break
for (let i = next.items.length - 1; i >= 0; i--) {
yield next.items[i]
)
if (next.items.length === 0) break
for (let i = next.items.length - 1; i >= 0; i--) {
const item = next.items[i]
if (item) result.push(item)
}
if (!next.more || !next.cursor) break
before = next.cursor
}
if (!next.more || !next.cursor) break
before = next.cursor
}
return result
})
}
export function parts(message_id: MessageID) {
const rows = Database.use((db) =>
db.select().from(PartTable).where(eq(PartTable.message_id, message_id)).orderBy(PartTable.id).all(),
)
return rows.map(
(row) =>
({
...row.data,
id: row.id,
sessionID: row.session_id,
messageID: row.message_id,
}) as Part,
)
export function parts(messageID: MessageID) {
return Effect.gen(function* () {
const { db } = yield* Database.Service
const rows = yield* db
.select()
.from(PartTable)
.where(eq(PartTable.message_id, messageID))
.orderBy(PartTable.id)
.all()
.pipe(Effect.orDie)
return rows.map(part)
})
}
export const get = Effect.fn("MessageV2.get")(function* (input: { sessionID: SessionID; messageID: MessageID }) {
const row = Database.use((db) =>
db
.select()
.from(MessageTable)
.where(and(eq(MessageTable.id, input.messageID), eq(MessageTable.session_id, input.sessionID)))
.get(),
)
const { db } = yield* Database.Service
const row = yield* db
.select()
.from(MessageTable)
.where(and(eq(MessageTable.id, input.messageID), eq(MessageTable.session_id, input.sessionID)))
.get()
.pipe(Effect.orDie)
if (!row) return yield* new NotFoundError({ message: `Message not found: ${input.messageID}` })
return {
info: info(row),
parts: parts(input.messageID),
parts: yield* parts(input.messageID),
}
})
@@ -1065,7 +583,7 @@ export function filterCompacted(msgs: Iterable<WithParts>) {
}
export const filterCompactedEffect = Effect.fnUntraced(function* (sessionID: SessionID) {
return filterCompacted(stream(sessionID))
return filterCompacted(yield* stream(sessionID))
})
// filterCompacted reorders messages for model consumption
@@ -1095,7 +613,7 @@ export function latest(msgs: WithParts[]) {
export function fromError(
e: unknown,
ctx: { providerID: ProviderID; aborted?: boolean },
ctx: { providerID: ProviderV2.ID; aborted?: boolean },
): NonNullable<Assistant["error"]> {
switch (true) {
case e instanceof DOMException && e.name === "AbortError":
+4 -3
View File
@@ -1,9 +1,10 @@
import { Schema } from "effect"
import { SessionID } from "./schema"
import { ModelID, ProviderID } from "../provider/schema"
import { NonNegativeInt } from "@opencode-ai/core/schema"
import { MessageError } from "./message-error"
import { AuthError, OutputLengthError } from "./message-error"
import { ProviderV2 } from "@opencode-ai/core/provider"
export { AuthError, OutputLengthError } from "./message-error"
export const ToolCall = Schema.Struct({
@@ -119,8 +120,8 @@ export const Info = Schema.Struct({
assistant: Schema.optional(
Schema.Struct({
system: Schema.Array(Schema.String),
modelID: ModelID,
providerID: ProviderID,
modelID: ProviderV2.ModelID,
providerID: ProviderV2.ID,
path: Schema.Struct({
cwd: Schema.String,
root: Schema.String,
+2 -1
View File
@@ -1,4 +1,5 @@
import type { Config } from "@/config/config"
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
import type { Provider } from "@/provider/provider"
import { ProviderTransform } from "@/provider/transform"
import type { MessageV2 } from "./message-v2"
@@ -19,7 +20,7 @@ export function usable(input: { cfg: Config.Info; model: Provider.Model; outputT
export function isOverflow(input: {
cfg: Config.Info
tokens: MessageV2.Assistant["tokens"]
tokens: SessionLegacy.Assistant["tokens"]
model: Provider.Model
outputTokenMax?: number
}) {
+28 -25
View File
@@ -1,8 +1,8 @@
import { Image } from "@/image/image"
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
import { Cause, Deferred, Effect, Exit, Layer, Context, Scope, Schema } from "effect"
import * as Stream from "effect/Stream"
import { Agent } from "@/agent/agent"
import { Bus } from "@/bus"
import { Config } from "@/config/config"
import { Permission } from "@/permission"
import { Plugin } from "@/plugin"
@@ -22,7 +22,8 @@ import { errorMessage } from "@/util/error"
import * as Log from "@opencode-ai/core/util/log"
import { isRecord } from "@/util/record"
import { EventV2Bridge } from "@/event-v2-bridge"
import { SessionEvent } from "@opencode-ai/core/session-event"
import { Database } from "@opencode-ai/core/database/database"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import * as DateTime from "effect/DateTime"
@@ -35,25 +36,25 @@ const log = Log.create({ service: "session.processor" })
export type Result = "compact" | "stop" | "continue"
export interface Handle {
readonly message: MessageV2.Assistant
readonly message: SessionLegacy.Assistant
readonly updateToolCall: (
toolCallID: string,
update: (part: MessageV2.ToolPart) => MessageV2.ToolPart,
) => Effect.Effect<MessageV2.ToolPart | undefined>
update: (part: SessionLegacy.ToolPart) => SessionLegacy.ToolPart,
) => Effect.Effect<SessionLegacy.ToolPart | undefined>
readonly completeToolCall: (
toolCallID: string,
output: {
title: string
metadata: Record<string, any>
output: string
attachments?: MessageV2.FilePart[]
attachments?: SessionLegacy.FilePart[]
},
) => Effect.Effect<void>
readonly process: (streamInput: LLM.StreamInput) => Effect.Effect<Result>
}
type Input = {
assistantMessage: MessageV2.Assistant
assistantMessage: SessionLegacy.Assistant
sessionID: SessionID
model: Provider.Model
}
@@ -63,9 +64,9 @@ export interface Interface {
}
type ToolCall = {
partID: MessageV2.ToolPart["id"]
messageID: MessageV2.ToolPart["messageID"]
sessionID: MessageV2.ToolPart["sessionID"]
partID: SessionLegacy.ToolPart["id"]
messageID: SessionLegacy.ToolPart["messageID"]
sessionID: SessionLegacy.ToolPart["sessionID"]
done: Deferred.Deferred<void>
inputEnded: boolean
}
@@ -76,8 +77,8 @@ interface ProcessorContext extends Input {
snapshot: string | undefined
blocked: boolean
needsCompaction: boolean
currentText: MessageV2.TextPart | undefined
reasoningMap: Record<string, MessageV2.ReasoningPart>
currentText: SessionLegacy.TextPart | undefined
reasoningMap: Record<string, SessionLegacy.ReasoningPart>
}
type StreamEvent = LLMEvent
@@ -89,7 +90,6 @@ export const layer = Layer.effect(
Effect.gen(function* () {
const session = yield* Session.Service
const config = yield* Config.Service
const bus = yield* Bus.Service
const snapshot = yield* Snapshot.Service
const agents = yield* Agent.Service
const llm = yield* LLM.Service
@@ -101,6 +101,7 @@ export const layer = Layer.effect(
const image = yield* Image.Service
const events = yield* EventV2Bridge.Service
const flags = yield* RuntimeFlags.Service
const database = yield* Database.Service
const create = Effect.fn("SessionProcessor.create")(function* (input: Input) {
// Pre-capture snapshot before the LLM stream starts. The AI SDK
@@ -151,7 +152,7 @@ export const layer = Layer.effect(
const updateToolCall = Effect.fn("SessionProcessor.updateToolCall")(function* (
toolCallID: string,
update: (part: MessageV2.ToolPart) => MessageV2.ToolPart,
update: (part: SessionLegacy.ToolPart) => SessionLegacy.ToolPart,
) {
const match = yield* readToolCall(toolCallID)
if (!match) return undefined
@@ -171,7 +172,7 @@ export const layer = Layer.effect(
title: string
metadata: Record<string, any>
output: string
attachments?: MessageV2.FilePart[]
attachments?: SessionLegacy.FilePart[]
},
) {
const match = yield* readToolCall(toolCallID)
@@ -266,7 +267,7 @@ export const layer = Layer.effect(
callID: input.id,
state: { status: "pending", input: {}, raw: "" },
metadata: input.providerExecuted ? { providerExecuted: true } : undefined,
} satisfies MessageV2.ToolPart)
} satisfies SessionLegacy.ToolPart)
ctx.toolcalls[input.id] = {
done: yield* Deferred.make<void>(),
partID: part.id,
@@ -277,11 +278,11 @@ export const layer = Layer.effect(
return { call: ctx.toolcalls[input.id], part }
})
const isFilePart = (value: unknown): value is MessageV2.FilePart => Schema.is(MessageV2.FilePart)(value)
const isFilePart = (value: unknown): value is SessionLegacy.FilePart => Schema.is(SessionLegacy.FilePart)(value)
const toolResultOutput = (
value: Extract<StreamEvent, { type: "tool-result" }>,
): { title: string; metadata: Record<string, any>; output: string; attachments?: MessageV2.FilePart[] } => {
): { title: string; metadata: Record<string, any>; output: string; attachments?: SessionLegacy.FilePart[] } => {
if (isRecord(value.result.value) && typeof value.result.value.output === "string") {
return {
title: typeof value.result.value.title === "string" ? value.result.value.title : value.name,
@@ -421,7 +422,9 @@ export const layer = Layer.effect(
: value.providerMetadata,
}))
const parts = MessageV2.parts(ctx.assistantMessage.id)
const parts = yield* MessageV2.parts(ctx.assistantMessage.id).pipe(
Effect.provideService(Database.Service, database),
)
const recentParts = parts.slice(-DOOM_LOOP_THRESHOLD)
if (
@@ -461,7 +464,7 @@ export const layer = Layer.effect(
),
Effect.exit,
)
: Effect.succeed(Exit.succeed<MessageV2.FilePart>(attachment)),
: Effect.succeed(Exit.succeed<SessionLegacy.FilePart>(attachment)),
)
const omitted = normalized.filter(Exit.isFailure).length
const attachments = normalized.filter(Exit.isSuccess).map((item) => item.value)
@@ -484,7 +487,7 @@ export const layer = Layer.effect(
type: "text",
text: output.output,
},
...(output.attachments?.map((item: MessageV2.FilePart) => ({
...(output.attachments?.map((item: SessionLegacy.FilePart) => ({
type: "file" as const,
uri: item.url,
mime: item.mime,
@@ -751,9 +754,9 @@ export const layer = Layer.effect(
const halt = Effect.fn("SessionProcessor.halt")(function* (e: unknown) {
slog.error("process", { error: errorMessage(e), stack: e instanceof Error ? e.stack : undefined })
const error = parse(e)
if (MessageV2.ContextOverflowError.isInstance(error)) {
if (SessionLegacy.ContextOverflowError.isInstance(error)) {
ctx.needsCompaction = true
yield* bus.publish(Session.Event.Error, { sessionID: ctx.sessionID, error })
yield* events.publish(Session.Event.Error, { sessionID: ctx.sessionID, error })
return
}
if (!ctx.assistantMessage.summary) {
@@ -770,7 +773,7 @@ export const layer = Layer.effect(
}
}
ctx.assistantMessage.error = error
yield* bus.publish(Session.Event.Error, {
yield* events.publish(Session.Event.Error, {
sessionID: ctx.assistantMessage.sessionID,
error: ctx.assistantMessage.error,
})
@@ -873,9 +876,9 @@ export const defaultLayer = Layer.suspend(() =>
Layer.provide(SessionSummary.defaultLayer),
Layer.provide(SessionStatus.defaultLayer),
Layer.provide(Image.defaultLayer),
Layer.provide(Bus.layer),
Layer.provide(Config.defaultLayer),
Layer.provide(RuntimeFlags.defaultLayer),
Layer.provide(Database.defaultLayer),
Layer.provide(EventV2Bridge.defaultLayer),
),
)
@@ -1,204 +0,0 @@
import { and, desc, eq } from "@/storage/db"
import type { Database } from "@/storage/db"
import { SessionMessage } from "@opencode-ai/core/session-message"
import { SessionMessageUpdater } from "@opencode-ai/core/session-message-updater"
import { SessionEvent } from "@opencode-ai/core/session-event"
import * as DateTime from "effect/DateTime"
import { SyncEvent } from "@/sync"
import { EventV2Bridge } from "@/event-v2-bridge"
import { SessionMessageTable, SessionTable } from "./session.sql"
import type { SessionID } from "./schema"
import { Schema } from "effect"
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Message)
type SessionMessageData = NonNullable<(typeof SessionMessageTable.$inferInsert)["data"]>
function encodeDateTimes(value: unknown): unknown {
if (DateTime.isDateTime(value)) return DateTime.toEpochMillis(value)
if (Array.isArray(value)) return value.map(encodeDateTimes)
if (typeof value === "object" && value !== null) {
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, encodeDateTimes(item)]))
}
return value
}
function encodeMessageData(value: unknown): SessionMessageData {
return encodeDateTimes(value) as SessionMessageData
}
function sqlite(db: Database.TxOrDb, sessionID: SessionID): SessionMessageUpdater.Adapter<void> {
return {
getCurrentAssistant() {
return db
.select()
.from(SessionMessageTable)
.where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "assistant")))
.orderBy(desc(SessionMessageTable.id))
.all()
.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 db
.select()
.from(SessionMessageTable)
.where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "compaction")))
.orderBy(desc(SessionMessageTable.id))
.all()
.map((row) => decodeMessage({ ...row.data, id: row.id, type: row.type }))
.find((message): message is SessionMessage.Compaction => message.type === "compaction")
},
getCurrentShell(callID) {
return db
.select()
.from(SessionMessageTable)
.where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "shell")))
.orderBy(desc(SessionMessageTable.id))
.all()
.map((row) => decodeMessage({ ...row.data, id: row.id, type: row.type }))
.find((message): message is SessionMessage.Shell => message.type === "shell" && message.callID === callID)
},
updateAssistant(assistant) {
const { id, type, ...data } = assistant
db.update(SessionMessageTable)
.set({ data: encodeMessageData(data) })
.where(
and(
eq(SessionMessageTable.id, id),
eq(SessionMessageTable.session_id, sessionID),
eq(SessionMessageTable.type, type),
),
)
.run()
},
updateCompaction(compaction) {
const { id, type, ...data } = compaction
db.update(SessionMessageTable)
.set({ data: encodeMessageData(data) })
.where(
and(
eq(SessionMessageTable.id, id),
eq(SessionMessageTable.session_id, sessionID),
eq(SessionMessageTable.type, type),
),
)
.run()
},
updateShell(shell) {
const { id, type, ...data } = shell
db.update(SessionMessageTable)
.set({ data: encodeMessageData(data) })
.where(
and(
eq(SessionMessageTable.id, id),
eq(SessionMessageTable.session_id, sessionID),
eq(SessionMessageTable.type, type),
),
)
.run()
},
appendMessage(message) {
const { id, type, ...data } = message
db.insert(SessionMessageTable)
.values([
{
id,
session_id: sessionID,
type,
time_created: DateTime.toEpochMillis(message.time.created),
data: encodeMessageData(data),
},
])
.run()
},
finish() {},
}
}
function update(db: Database.TxOrDb, event: SessionEvent.Event) {
SessionMessageUpdater.update(sqlite(db, event.data.sessionID), event)
}
export default [
SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.AgentSwitched), (db, data, event) => {
db.update(SessionTable)
.set({
agent: data.agent,
time_updated: DateTime.toEpochMillis(data.timestamp),
})
.where(eq(SessionTable.id, data.sessionID))
.run()
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.agent.switched", data })
}),
SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.ModelSwitched), (db, data, event) => {
db.update(SessionTable)
.set({
model: data.model,
time_updated: DateTime.toEpochMillis(data.timestamp),
})
.where(eq(SessionTable.id, data.sessionID))
.run()
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.model.switched", data })
}),
SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Prompted), (db, data, event) => {
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.prompted", data })
}),
SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Synthetic), (db, data, event) => {
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.synthetic", data })
}),
SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Shell.Started), (db, data, event) => {
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.shell.started", data })
}),
SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Shell.Ended), (db, data, event) => {
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.shell.ended", data })
}),
SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Step.Started), (db, data, event) => {
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.step.started", data })
}),
SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Step.Ended), (db, data, event) => {
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.step.ended", data })
}),
SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Step.Failed), (db, data, event) => {
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.step.failed", data })
}),
SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Text.Started), (db, data, event) => {
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.text.started", data })
}),
SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Text.Delta), () => {}),
SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Text.Ended), (db, data, event) => {
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.text.ended", data })
}),
SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Tool.Input.Started), (db, data, event) => {
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.tool.input.started", data })
}),
SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Tool.Input.Delta), () => {}),
SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Tool.Input.Ended), (db, data, event) => {
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.tool.input.ended", data })
}),
SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Tool.Called), (db, data, event) => {
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.tool.called", data })
}),
SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Tool.Success), (db, data, event) => {
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.tool.success", data })
}),
SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Tool.Failed), (db, data, event) => {
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.tool.failed", data })
}),
SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Reasoning.Started), (db, data, event) => {
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.reasoning.started", data })
}),
SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Reasoning.Delta), () => {}),
SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Reasoning.Ended), (db, data, event) => {
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.reasoning.ended", data })
}),
SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Retried), (db, data, event) => {
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.retried", data })
}),
SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Compaction.Started), (db, data, event) => {
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.compaction.started", data })
}),
SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Compaction.Delta), () => {}),
SyncEvent.project(EventV2Bridge.toSyncDefinition(SessionEvent.Compaction.Ended), (db, data, event) => {
update(db, { id: SessionMessage.ID.make(event.id), type: "session.next.compaction.ended", data })
}),
]
-200
View File
@@ -1,200 +0,0 @@
import { NotFoundError } from "@/storage/storage"
import { eq } from "drizzle-orm"
import { and } from "drizzle-orm"
import { sql } from "drizzle-orm"
import type { TxOrDb } from "@/storage/db"
import { SyncEvent } from "@/sync"
import * as Session from "./session"
import { MessageV2 } from "./message-v2"
import { SessionTable, MessageTable, PartTable } from "./session.sql"
import { WorkspaceTable } from "@/control-plane/workspace.sql"
import { Log } from "@opencode-ai/core/util/log"
import nextProjectors from "./projectors-next"
const log = Log.create({ service: "session.projector" })
function foreign(err: unknown) {
if (typeof err !== "object" || err === null) return false
if ("code" in err && err.code === "SQLITE_CONSTRAINT_FOREIGNKEY") return true
return "message" in err && typeof err.message === "string" && err.message.includes("FOREIGN KEY constraint failed")
}
export type DeepPartial<T> = T extends object ? { [K in keyof T]?: DeepPartial<T[K]> | null } : T
type Usage = Pick<MessageV2.StepFinishPart, "cost" | "tokens">
function usage(part: MessageV2.Part | (typeof PartTable.$inferSelect)["data"]): Usage | undefined {
if (part.type !== "step-finish") return undefined
if (!("cost" in part) || !("tokens" in part)) return undefined
return { cost: part.cost, tokens: part.tokens }
}
function applyUsage(db: TxOrDb, sessionID: Session.Info["id"], value: Usage, sign = 1) {
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()
}
function grab<T extends object, K1 extends keyof T, X>(
obj: T,
field1: K1,
cb?: (val: NonNullable<T[K1]>) => X,
): X | undefined {
if (obj == undefined || !(field1 in obj)) return undefined
const val = obj[field1]
if (val && typeof val === "object" && cb) {
return cb(val)
}
if (val === undefined) {
throw new Error(
"Session update failure: pass `null` to clear a field instead of `undefined`: " + JSON.stringify(obj),
)
}
return val as X | undefined
}
export function toPartialRow(info: DeepPartial<Session.Info>) {
const obj = {
id: grab(info, "id"),
project_id: grab(info, "projectID"),
workspace_id: grab(info, "workspaceID"),
parent_id: grab(info, "parentID"),
slug: grab(info, "slug"),
directory: grab(info, "directory"),
path: grab(info, "path"),
title: grab(info, "title"),
version: grab(info, "version"),
share_url: grab(info, "share", (v) => grab(v, "url")),
summary_additions: grab(info, "summary", (v) => grab(v, "additions")),
summary_deletions: grab(info, "summary", (v) => grab(v, "deletions")),
summary_files: grab(info, "summary", (v) => grab(v, "files")),
summary_diffs: grab(info, "summary", (v) => grab(v, "diffs")),
metadata: grab(info, "metadata"),
cost: grab(info, "cost"),
tokens_input: grab(info, "tokens", (v) => grab(v, "input")),
tokens_output: grab(info, "tokens", (v) => grab(v, "output")),
tokens_reasoning: grab(info, "tokens", (v) => grab(v, "reasoning")),
tokens_cache_read: grab(info, "tokens", (v) => grab(v, "cache", (cache) => grab(cache, "read"))),
tokens_cache_write: grab(info, "tokens", (v) => grab(v, "cache", (cache) => grab(cache, "write"))),
revert: grab(info, "revert"),
permission: grab(info, "permission"),
time_created: grab(info, "time", (v) => grab(v, "created")),
time_updated: grab(info, "time", (v) => grab(v, "updated")),
time_compacting: grab(info, "time", (v) => grab(v, "compacting")),
time_archived: grab(info, "time", (v) => grab(v, "archived")),
}
return Object.fromEntries(Object.entries(obj).filter(([_, val]) => val !== undefined))
}
export default [
SyncEvent.project(Session.Event.Created, (db, data) => {
db.insert(SessionTable)
.values(Session.toRow(data.info as Session.Info))
.run()
if (data.info.workspaceID) {
db.update(WorkspaceTable).set({ time_used: Date.now() }).where(eq(WorkspaceTable.id, data.info.workspaceID)).run()
}
}),
SyncEvent.project(Session.Event.Updated, (db, data) => {
const info = data.info
const row = db
.update(SessionTable)
.set({ time_updated: sql`${SessionTable.time_updated}`, ...toPartialRow(info as Session.Patch) })
.where(eq(SessionTable.id, data.sessionID))
.returning()
.get()
if (!row) throw new NotFoundError({ message: `Session not found: ${data.sessionID}` })
}),
SyncEvent.project(Session.Event.Deleted, (db, data) => {
db.delete(SessionTable).where(eq(SessionTable.id, data.sessionID)).run()
}),
SyncEvent.project(MessageV2.Event.Updated, (db, data) => {
const time_created = data.info.time.created
const { id, sessionID, ...rest } = data.info
try {
db.insert(MessageTable)
.values({
id,
session_id: sessionID,
time_created,
data: rest,
})
.onConflictDoUpdate({ target: MessageTable.id, set: { data: rest } })
.run()
} catch (err) {
if (!foreign(err)) throw err
log.warn("ignored late message update", { messageID: id, sessionID })
}
}),
SyncEvent.project(MessageV2.Event.Removed, (db, data) => {
for (const row of db
.select()
.from(PartTable)
.where(and(eq(PartTable.message_id, data.messageID), eq(PartTable.session_id, data.sessionID)))
.all()) {
const previous = usage(row.data)
if (previous) applyUsage(db, data.sessionID, previous, -1)
}
db.delete(MessageTable)
.where(and(eq(MessageTable.id, data.messageID), eq(MessageTable.session_id, data.sessionID)))
.run()
}),
SyncEvent.project(MessageV2.Event.PartRemoved, (db, data) => {
const row = db
.select()
.from(PartTable)
.where(and(eq(PartTable.id, data.partID), eq(PartTable.session_id, data.sessionID)))
.get()
const previous = row && usage(row.data)
if (previous) applyUsage(db, data.sessionID, previous, -1)
db.delete(PartTable)
.where(and(eq(PartTable.id, data.partID), eq(PartTable.session_id, data.sessionID)))
.run()
}),
SyncEvent.project(MessageV2.Event.PartUpdated, (db, data) => {
const { id, messageID, sessionID, ...rest } = data.part
const row = db.select().from(PartTable).where(eq(PartTable.id, id)).get()
try {
db.insert(PartTable)
.values({
id,
message_id: messageID,
session_id: sessionID,
time_created: data.time,
data: rest,
})
.onConflictDoUpdate({ target: PartTable.id, set: { data: rest } })
.run()
const previous = row && usage(row.data)
const next = usage(data.part)
if (previous) applyUsage(db, row.session_id, previous, -1)
if (next) applyUsage(db, sessionID, next)
} catch (err) {
if (!foreign(err)) throw err
log.warn("ignored late part update", { partID: id, messageID, sessionID })
}
}),
...nextProjectors,
]

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