refactor(search): persist provider in global config

This commit is contained in:
Shoubhit Dash 2026-07-07 19:27:50 +05:30
commit ccad83549d
38 changed files with 736 additions and 589 deletions

View file

@ -1,4 +1,5 @@
export * as Config from "./config"
export * as ConfigGlobal from "./config/global"
import { makeLocationNode } from "./effect/app-node"
import path from "path"

View file

@ -0,0 +1,50 @@
export * as ConfigGlobal from "./global"
import { randomUUID } from "node:crypto"
import path from "node:path"
import { Context, Effect, Layer } from "effect"
import { applyEdits, modify, type JSONPath } from "jsonc-parser"
import { makeGlobalNode } from "../effect/app-node"
import { FSUtil } from "../fs-util"
import { Global } from "../global"
import { EffectFlock } from "../util/effect-flock"
export interface Interface {
readonly update: (path: JSONPath, value: unknown) => Effect.Effect<void, FSUtil.Error | EffectFlock.LockError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/ConfigGlobal") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const global = yield* Global.Service
const flock = yield* EffectFlock.Service
return Service.of({
update: Effect.fn("ConfigGlobal.update")(function* (jsonPath, value) {
yield* flock.withLock(
Effect.gen(function* () {
const existing = yield* Effect.filter(
["opencode.jsonc", "opencode.json"].map((name) => path.join(global.config, name)),
fs.existsSafe,
)
const filepath = existing[0] ?? path.join(global.config, "opencode.json")
const text = (yield* fs.readFileStringSafe(filepath)) ?? "{}"
const next = applyEdits(
text,
modify(text, jsonPath, value, { formattingOptions: { tabSize: 2, insertSpaces: true } }),
)
const temp = `${filepath}.${randomUUID()}.tmp`
yield* fs.writeWithDirs(temp, next)
yield* fs.rename(temp, filepath).pipe(Effect.ensuring(fs.remove(temp).pipe(Effect.ignore)))
}),
"global-config",
)
}),
})
}),
)
export const node = makeGlobalNode({ service: Service, layer, deps: [EffectFlock.node, FSUtil.node, Global.node] })

View file

@ -46,7 +46,6 @@ export const migrations = (
import("./migration/20260703190000_reset_v2_shell_event_payloads"),
import("./migration/20260703200000_reset_v2_session_events"),
import("./migration/20260705180000_rename_instructions"),
import("./migration/20260706133920_integration-search"),
import("./migration/20260706223930_add-session-fork"),
import("./migration/20260707010146_durable_session_inbox"),
])

View file

@ -1,18 +0,0 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260706133920_integration-search",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`
CREATE TABLE \`integration_capability\` (
\`capability\` text PRIMARY KEY,
\`integration_id\` text NOT NULL,
\`time_created\` integer NOT NULL,
\`time_updated\` integer NOT NULL
);
`)
})
},
} satisfies DatabaseMigration.Migration

View file

@ -87,14 +87,6 @@ export default {
CONSTRAINT \`fk_event_aggregate_id_event_sequence_aggregate_id_fk\` FOREIGN KEY (\`aggregate_id\`) REFERENCES \`event_sequence\`(\`aggregate_id\`) ON DELETE CASCADE
);
`)
yield* tx.run(`
CREATE TABLE \`integration_capability\` (
\`capability\` text PRIMARY KEY,
\`integration_id\` text NOT NULL,
\`time_created\` integer NOT NULL,
\`time_updated\` integer NOT NULL
);
`)
yield* tx.run(`
CREATE TABLE \`permission\` (
\`id\` text PRIMARY KEY,

View file

@ -1,7 +1,6 @@
export * as Integration from "./integration"
import { makeLocationNode } from "./effect/app-node"
import { eq } from "drizzle-orm"
import {
Cause,
Clock,
@ -19,11 +18,9 @@ import {
import { Integration } from "@opencode-ai/schema/integration"
import { Search } from "@opencode-ai/schema/search"
import { Credential } from "./credential"
import { Database } from "./database/database"
import { State } from "./state"
import { EventV2 } from "./event"
import { IntegrationConnection } from "./integration/connection"
import { IntegrationCapabilityTable } from "./integration/sql"
export const ID = Integration.ID
export type ID = Integration.ID
@ -64,12 +61,6 @@ export type Info = Integration.Info
export const Inputs = Integration.Inputs
export type Inputs = Integration.Inputs
export const SearchCapability = Integration.SearchCapability
export type SearchCapability = Omit<Integration.SearchCapability, "selected">
export const Capability = Integration.Capability
export type Capability = Integration.Capability
export type OAuthAuthorization = {
readonly url: string
readonly instructions: string
@ -106,7 +97,7 @@ export type Implementation = OAuthImplementation | KeyImplementation | EnvImplem
export interface SearchImplementation {
readonly integrationID: ID
readonly capability: SearchCapability
readonly connection: Integration.Search["connection"]
readonly execute: (
input: Search.Input,
context: { readonly credential?: Credential.Value; readonly sessionID?: string },
@ -155,12 +146,10 @@ export type Draft = {
update: (implementation: Implementation) => void
remove: (integrationID: ID, method: Method) => void
}
capability: {
search: {
list: () => readonly SearchImplementation[]
update: (implementation: SearchImplementation) => void
remove: (integrationID: ID) => void
}
search: {
list: () => readonly SearchImplementation[]
update: (implementation: SearchImplementation) => void
remove: (integrationID: ID) => void
}
}
@ -218,13 +207,9 @@ export interface Interface extends State.Transformable<Draft> {
/** Cancels an attempt and releases its resources. */
readonly cancel: (attemptID: AttemptID) => Effect.Effect<void>
}
readonly capability: {
readonly search: {
readonly list: () => Effect.Effect<readonly SearchImplementation[]>
readonly get: (integrationID: ID) => Effect.Effect<SearchImplementation | undefined>
readonly selected: () => Effect.Effect<ID | undefined>
readonly select: (integrationID: ID) => Effect.Effect<void>
}
readonly search: {
readonly list: () => Effect.Effect<readonly SearchImplementation[]>
readonly get: (integrationID: ID) => Effect.Effect<SearchImplementation | undefined>
}
}
@ -257,7 +242,6 @@ const layer = Layer.effect(
Service,
Effect.gen(function* () {
const credentials = yield* Credential.Service
const { db } = yield* Database.Service
const events = yield* EventV2.Service
const scope = yield* Scope.Scope
const attempts = SynchronizedRef.makeUnsafe(new Map<AttemptID, AttemptEntry>())
@ -317,30 +301,28 @@ const layer = Layer.effect(
if (method.type === "oauth") current.implementations.delete(method.id)
},
},
capability: {
search: {
list: () =>
Array.from(draft.integrations.values()).flatMap((entry) =>
entry.search ? [entry.search as SearchImplementation] : [],
),
update: (implementation) => {
const current = draft.integrations.get(implementation.integrationID) ?? {
ref: {
id: implementation.integrationID,
name: implementation.integrationID,
},
methods: [],
implementations: new Map<MethodID, Types.DeepMutable<OAuthImplementation>>(),
}
if (!draft.integrations.has(implementation.integrationID)) {
draft.integrations.set(implementation.integrationID, current)
}
current.search = implementation as Types.DeepMutable<SearchImplementation>
},
remove: (integrationID) => {
const current = draft.integrations.get(integrationID)
if (current) delete current.search
},
search: {
list: () =>
Array.from(draft.integrations.values()).flatMap((entry) =>
entry.search ? [entry.search as SearchImplementation] : [],
),
update: (implementation) => {
const current = draft.integrations.get(implementation.integrationID) ?? {
ref: {
id: implementation.integrationID,
name: implementation.integrationID,
},
methods: [],
implementations: new Map<MethodID, Types.DeepMutable<OAuthImplementation>>(),
}
if (!draft.integrations.has(implementation.integrationID)) {
draft.integrations.set(implementation.integrationID, current)
}
current.search = implementation as Types.DeepMutable<SearchImplementation>
},
remove: (integrationID) => {
const current = draft.integrations.get(integrationID)
if (current) delete current.search
},
},
}),
@ -362,24 +344,15 @@ const layer = Layer.effect(
return [...credentials, ...env]
}
const project = (entry: Entry, connections: IntegrationConnection.Info[], selectedSearch: ID | undefined) =>
const project = (entry: Entry, connections: IntegrationConnection.Info[]) =>
new Info({
id: entry.ref.id,
name: entry.ref.name,
methods: entry.methods,
capabilities: entry.search ? [{ ...entry.search.capability, selected: entry.ref.id === selectedSearch }] : [],
search: entry.search ? { connection: entry.search.connection } : undefined,
connections,
})
const selectedSearch = Effect.fn("Integration.capability.search.selected")(function* () {
return (yield* db
.select({ integrationID: IntegrationCapabilityTable.integration_id })
.from(IntegrationCapabilityTable)
.where(eq(IntegrationCapabilityTable.capability, "search"))
.get()
.pipe(Effect.orDie))?.integrationID
})
const authorize = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
effect.pipe(Effect.mapError((cause) => new AuthorizationError({ cause })))
@ -441,13 +414,12 @@ const layer = Layer.effect(
get: Effect.fn("Integration.get")(function* (id) {
const entry = state.get().integrations.get(id)
if (!entry) return undefined
return project(entry, resolveConnections(entry, yield* credentials.list(id)), yield* selectedSearch())
return project(entry, resolveConnections(entry, yield* credentials.list(id)))
}),
list: Effect.fn("Integration.list")(function* () {
const saved = Map.groupBy(yield* credentials.all(), (credential) => credential.integrationID)
const selected = yield* selectedSearch()
return Array.from(state.get().integrations.values(), (entry) =>
project(entry, resolveConnections(entry, saved.get(entry.ref.id) ?? []), selected),
project(entry, resolveConnections(entry, saved.get(entry.ref.id) ?? [])),
).toSorted((a, b) => a.name.localeCompare(b.name))
}),
connection: {
@ -587,36 +559,18 @@ const layer = Layer.effect(
if (attempt) yield* Scope.close(attempt.scope, Exit.void)
}),
},
capability: {
search: {
list: Effect.fn("Integration.capability.search.list")(function* () {
return Array.from(state.get().integrations.values()).flatMap((entry) =>
entry.search ? [entry.search as SearchImplementation] : [],
)
}),
get: Effect.fn("Integration.capability.search.get")(function* (integrationID) {
return state.get().integrations.get(integrationID)?.search as SearchImplementation | undefined
}),
selected: selectedSearch,
select: Effect.fn("Integration.capability.search.select")(function* (integrationID) {
if (!state.get().integrations.get(integrationID)?.search) {
return yield* Effect.die(new Error(`Search capability not found: ${integrationID}`))
}
yield* db
.insert(IntegrationCapabilityTable)
.values({ capability: "search", integration_id: integrationID })
.onConflictDoUpdate({
target: IntegrationCapabilityTable.capability,
set: { integration_id: integrationID },
})
.run()
.pipe(Effect.orDie)
yield* events.publish(Event.Updated, {})
}),
},
search: {
list: Effect.fn("Integration.search.list")(function* () {
return Array.from(state.get().integrations.values()).flatMap((entry) =>
entry.search ? [entry.search as SearchImplementation] : [],
)
}),
get: Effect.fn("Integration.search.get")(function* (integrationID) {
return state.get().integrations.get(integrationID)?.search as SearchImplementation | undefined
}),
},
})
}),
)
export const node = makeLocationNode({ service: Service, layer, deps: [Credential.node, Database.node, EventV2.node] })
export const node = makeLocationNode({ service: Service, layer, deps: [Credential.node, EventV2.node] })

View file

@ -1,9 +0,0 @@
import { sqliteTable, text } from "drizzle-orm/sqlite-core"
import { Timestamps } from "../database/schema.sql"
import type { Integration } from "../integration"
export const IntegrationCapabilityTable = sqliteTable("integration_capability", {
capability: text().$type<Integration.Capability["type"]>().primaryKey(),
integration_id: text().$type<Integration.ID>().notNull(),
...Timestamps,
})

View file

@ -165,7 +165,6 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
integration: {
list: () => response(integration.list()),
get: (input) => response(integration.get(Integration.ID.make(input.integrationID))),
selectCapability: (input) => integration.capability.search.select(Integration.ID.make(input.integrationID)),
connectKey: (input) =>
integration.connection.key({
integrationID: Integration.ID.make(input.integrationID),
@ -343,9 +342,9 @@ function registerIntegration(draft: Integration.Draft, definition: IntegrationDe
)
}
if (!definition.search) return
draft.capability.search.update({
draft.search.update({
integrationID,
capability: { type: "search", connection: definition.search.connection },
connection: definition.search.connection,
execute: definition.search.execute,
})
}

View file

@ -79,7 +79,6 @@ export function fromPromise(plugin: Plugin) {
integration: {
list: (input) => run(host.integration.list(input)),
get: (input) => run(host.integration.get(input)),
selectCapability: (input) => run(host.integration.selectCapability(input)),
connectKey: (input) => run(host.integration.connectKey(input)),
connectOauth: (input) => run(host.integration.connectOauth(input)),
attemptStatus: (input) => run(host.integration.attemptStatus(input)),

View file

@ -1,10 +1,16 @@
export * as Search from "./search"
import { Search } from "@opencode-ai/schema/search"
import { Context, Effect, Layer, Schema, Semaphore } from "effect"
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
import { Context, Effect, Layer, Schema, Semaphore, Stream } from "effect"
import path from "node:path"
import { Config } from "./config"
import { ConfigGlobal } from "./config/global"
import { ConfigSearch } from "./config/search"
import { makeLocationNode } from "./effect/app-node"
import { EventV2 } from "./event"
import { Form } from "./form"
import { Global } from "./global"
import { Integration } from "./integration"
import { truthy } from "./flag/flag"
@ -50,6 +56,8 @@ export interface QueryInput extends Input {
}
export interface Interface {
readonly selected: () => Effect.Effect<Integration.ID | undefined>
readonly select: (providerID: Integration.ID) => Effect.Effect<void, ProviderNotFoundError>
readonly query: (input: QueryInput) => Effect.Effect<Result, Error>
}
@ -59,10 +67,14 @@ const layer = Layer.effect(
Service,
Effect.gen(function* () {
const config = yield* Config.Service
const configGlobal = yield* ConfigGlobal.Service
const events = yield* EventV2.Service
const forms = yield* Form.Service
const global = yield* Global.Service
const integrations = yield* Integration.Service
const onboarding = Semaphore.makeUnsafe(1)
const decodeOutput = Schema.decodeUnknownEffect(ProviderOutput)
let pending: Integration.ID | undefined
const requireProvider = (
providers: Map<Integration.ID, Integration.SearchImplementation>,
@ -86,6 +98,39 @@ const layer = Layer.effect(
}
})
const globalProvider = Effect.fn("Search.globalProvider")(function* () {
const entries = (yield* config.entries()).filter(
(entry) => entry.type === "document" && entry.path && path.dirname(entry.path) === path.resolve(global.config),
)
return Config.latest(entries, "search")?.provider
})
const selected = Effect.fn("Search.selected")(function* () {
return pending ?? (yield* globalProvider())
})
const save = Effect.fn("Search.save")(function* (providerID: Integration.ID) {
pending = providerID
yield* configGlobal.update(["search"], new ConfigSearch.Info({ provider: providerID })).pipe(
Effect.tapError(() => Effect.sync(() => (pending = undefined))),
Effect.orDie,
)
})
yield* events.subscribe(ConfigSchema.Event.Updated).pipe(
Stream.runForEach(() =>
globalProvider().pipe(
Effect.tap((providerID) =>
Effect.sync(() => {
if (providerID === pending) pending = undefined
}),
),
Effect.ignore,
),
),
Effect.forkScoped,
)
const ask = Effect.fn("Search.ask")(function* (
providers: Map<Integration.ID, Integration.SearchImplementation>,
sessionID: string,
@ -110,8 +155,7 @@ const layer = Layer.effect(
.flatMap((provider) => {
const info = infos.get(provider.integrationID)
if (!info) return []
const disconnected =
provider.capability.connection === "optional" ? "Keyless available" : "Connection required"
const disconnected = provider.connection === "optional" ? "Keyless available" : "Connection required"
return [{ info, description: info.connections.length ? "Connected" : disconnected }]
})
.toSorted((a, b) => a.info.name.localeCompare(b.info.name))
@ -135,7 +179,7 @@ const layer = Layer.effect(
sessionID?: string,
) {
const active = yield* integrations.connection.active(provider.integrationID)
if (active || provider.capability.connection === "optional") return active
if (active || provider.connection === "optional") return active
if (!sessionID) return yield* new ConnectionRequiredError({ providerID: provider.integrationID })
const state = yield* forms
.ask({
@ -152,34 +196,40 @@ const layer = Layer.effect(
return connected
})
const select = Effect.fn("Search.select")(function* (input: QueryInput) {
const resolve = Effect.fn("Search.resolve")(function* (input: QueryInput) {
const providers = new Map(
(yield* integrations.capability.search.list()).map((provider) => [provider.integrationID, provider]),
(yield* integrations.search.list()).map((provider) => [provider.integrationID, provider]),
)
if (input.providerID) return yield* requireProvider(providers, input.providerID)
const override = yield* configuredProvider()
if (override) return yield* requireProvider(providers, override)
const selected = yield* integrations.capability.search.selected()
const provider = selected ? providers.get(selected) : undefined
const providerID = yield* selected()
const provider = providerID ? providers.get(providerID) : undefined
if (provider) return provider
const sessionID = input.sessionID
if (!sessionID) return yield* new ProviderRequiredError()
return yield* onboarding.withPermit(
Effect.gen(function* () {
const current = yield* integrations.capability.search.selected()
const selected = current ? providers.get(current) : undefined
if (selected) return selected
const current = yield* selected()
const selectedProvider = current ? providers.get(current) : undefined
if (selectedProvider) return selectedProvider
const provider = yield* ask(providers, sessionID)
yield* connect(provider, sessionID)
yield* integrations.capability.search.select(provider.integrationID)
yield* save(provider.integrationID)
return provider
}),
)
})
return Service.of({
selected,
select: Effect.fn("Search.select")(function* (providerID) {
const provider = yield* integrations.search.get(providerID)
if (!provider) return yield* new ProviderNotFoundError({ providerID })
yield* save(providerID)
}),
query: Effect.fn("Search.query")(function* (input) {
const provider = yield* select(input)
const provider = yield* resolve(input)
const connection = yield* connect(provider, input.sessionID)
const credential = connection
? yield* integrations.connection
@ -196,4 +246,8 @@ const layer = Layer.effect(
}),
)
export const node = makeLocationNode({ service: Service, layer, deps: [Config.node, Form.node, Integration.node] })
export const node = makeLocationNode({
service: Service,
layer,
deps: [Config.node, ConfigGlobal.node, EventV2.node, Form.node, Global.node, Integration.node],
})