feat(plugin): add namespaced hook API (#33416)
This commit is contained in:
parent
dc468bdcfd
commit
909a1a6d78
150 changed files with 3276 additions and 3906 deletions
|
|
@ -1,12 +1,17 @@
|
|||
export * as PluginV2 from "./plugin"
|
||||
|
||||
import { createDraft, finishDraft, type Draft } from "immer"
|
||||
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
||||
import { Context, Effect, Exit, Layer, Schema, Scope } from "effect"
|
||||
import type { ModelV2 } from "./model"
|
||||
import type { Catalog } from "./catalog"
|
||||
import type { Plugin, PluginDraft } from "@opencode-ai/plugin/v2/effect"
|
||||
import { AgentV2 } from "./agent"
|
||||
import { AISDK } from "./aisdk"
|
||||
import { Catalog } from "./catalog"
|
||||
import { CommandV2 } from "./command"
|
||||
import { EventV2 } from "./event"
|
||||
import { Integration } from "./integration"
|
||||
import { KeyedMutex } from "./effect/keyed-mutex"
|
||||
import { PluginHost } from "./plugin/host"
|
||||
import { Reference } from "./reference"
|
||||
import { SkillV2 } from "./skill"
|
||||
import { State } from "./state"
|
||||
|
||||
export const ID = Schema.String.pipe(Schema.brand("Plugin.ID"))
|
||||
|
|
@ -21,69 +26,9 @@ export const Event = {
|
|||
}),
|
||||
}
|
||||
|
||||
type HookSpec = {
|
||||
"catalog.transform": {
|
||||
input: Catalog.Draft
|
||||
output: {}
|
||||
}
|
||||
"aisdk.language": {
|
||||
input: {
|
||||
model: ModelV2.Info
|
||||
sdk: any
|
||||
options: Record<string, any>
|
||||
}
|
||||
output: {
|
||||
language?: LanguageModelV3
|
||||
}
|
||||
}
|
||||
"aisdk.sdk": {
|
||||
input: {
|
||||
model: ModelV2.Info
|
||||
package: string
|
||||
options: Record<string, any>
|
||||
}
|
||||
output: {
|
||||
sdk?: any
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type Hooks = {
|
||||
[Name in keyof HookSpec]: Readonly<HookSpec[Name]["input"]> & {
|
||||
-readonly [Field in keyof HookSpec[Name]["output"]]: HookSpec[Name]["output"][Field] extends object
|
||||
? Draft<HookSpec[Name]["output"][Field]>
|
||||
: HookSpec[Name]["output"][Field]
|
||||
}
|
||||
}
|
||||
|
||||
export type HookFunctions = {
|
||||
[key in keyof Hooks]?: (input: Hooks[key]) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export type HookInput<Name extends keyof Hooks> = HookSpec[Name]["input"]
|
||||
export type HookOutput<Name extends keyof Hooks> = HookSpec[Name]["output"]
|
||||
|
||||
export interface Interface {
|
||||
readonly add: (input: {
|
||||
id: string
|
||||
effect: Effect.Effect<void | HookFunctions, never, Scope.Scope>
|
||||
}) => Effect.Effect<void, never, never>
|
||||
readonly remove: (id: ID) => Effect.Effect<void>
|
||||
readonly hook: <Name extends keyof Hooks>(
|
||||
name: Name,
|
||||
callback: (input: Hooks[Name]) => Effect.Effect<void> | void,
|
||||
) => Effect.Effect<State.Registration, never, Scope.Scope>
|
||||
readonly triggerFor: <Name extends keyof Hooks>(
|
||||
id: ID,
|
||||
name: Name,
|
||||
input: HookInput<Name>,
|
||||
output: HookOutput<Name>,
|
||||
) => Effect.Effect<HookInput<Name> & HookOutput<Name>>
|
||||
readonly trigger: <Name extends keyof Hooks>(
|
||||
name: Name,
|
||||
input: HookInput<Name>,
|
||||
output: HookOutput<Name>,
|
||||
) => Effect.Effect<HookInput<Name> & HookOutput<Name>>
|
||||
readonly transform: State.Transform<PluginDraft>
|
||||
readonly reload: State.Reload
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Plugin") {}
|
||||
|
|
@ -91,127 +36,85 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
|
|||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
let hooks: {
|
||||
id: ID
|
||||
hooks: HookFunctions
|
||||
scope: Scope.Closeable
|
||||
}[] = []
|
||||
let registrations: {
|
||||
[Name in keyof Hooks]: {
|
||||
name: Name
|
||||
callback: (input: Hooks[Name]) => Effect.Effect<void> | void
|
||||
}
|
||||
}[keyof Hooks][] = []
|
||||
const events = yield* EventV2.Service
|
||||
const locks = KeyedMutex.makeUnsafe<ID>()
|
||||
const scope = yield* Scope.make()
|
||||
const active = new Map<ID, Scope.Closeable>()
|
||||
let host: Parameters<Plugin["effect"]>[0]
|
||||
|
||||
const attach = Effect.fn("Plugin.attach")(function* (plugin: Plugin, host: Parameters<Plugin["effect"]>[0]) {
|
||||
const id = ID.make(plugin.id)
|
||||
yield* locks.withLock(id)(
|
||||
Effect.gen(function* () {
|
||||
const existing = active.get(id)
|
||||
if (existing) yield* Scope.close(existing, Exit.void).pipe(Effect.ignore)
|
||||
|
||||
const child = yield* Scope.fork(scope)
|
||||
yield* plugin.effect(host).pipe(
|
||||
Scope.provide(child),
|
||||
Effect.withSpan("Plugin.load", { attributes: { "plugin.id": id } }),
|
||||
Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(child, exit) : Effect.void)),
|
||||
)
|
||||
active.set(id, child)
|
||||
yield* events.publish(Event.Added, { id })
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const detach = Effect.fn("Plugin.detach")(function* (id: ID) {
|
||||
yield* locks.withLock(id)(
|
||||
Effect.gen(function* () {
|
||||
const current = active.get(id)
|
||||
active.delete(id)
|
||||
if (current) yield* Scope.close(current, Exit.void).pipe(Effect.ignore)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const state = State.create<Map<ID, Plugin>, PluginDraft>({
|
||||
initial: () => new Map(),
|
||||
draft: (draft) => ({
|
||||
list: () => Array.from(draft.values()),
|
||||
add: (plugin) => draft.set(ID.make(plugin.id), plugin),
|
||||
remove: (id) => draft.delete(ID.make(id)),
|
||||
}),
|
||||
finalize: (draft) =>
|
||||
State.batch(
|
||||
Effect.gen(function* () {
|
||||
const desired = new Set<ID>()
|
||||
for (const plugin of draft.list()) desired.add(ID.make(plugin.id))
|
||||
|
||||
for (const id of active.keys()) {
|
||||
if (!desired.has(id)) yield* detach(id)
|
||||
}
|
||||
|
||||
for (const plugin of draft.list()) yield* attach(plugin, host)
|
||||
}).pipe(Effect.withSpan("Plugin.reconcile")),
|
||||
),
|
||||
})
|
||||
|
||||
// One registry-owned scope lets shutdown remove every plugin transform in one batch.
|
||||
yield* Effect.addFinalizer((exit) =>
|
||||
Effect.gen(function* () {
|
||||
hooks = []
|
||||
active.clear()
|
||||
yield* State.batch(Scope.close(scope, exit))
|
||||
}),
|
||||
)
|
||||
|
||||
const svc = Service.of({
|
||||
add: Effect.fn("Plugin.add")(function* (input) {
|
||||
const id = ID.make(input.id)
|
||||
yield* locks.withLock(id)(
|
||||
Effect.gen(function* () {
|
||||
const existing = hooks.find((item) => item.id === id)
|
||||
if (existing) yield* State.batch(Scope.close(existing.scope, Exit.void)).pipe(Effect.ignore)
|
||||
const childScope = yield* Scope.fork(scope)
|
||||
const result = yield* input.effect.pipe(
|
||||
Scope.provide(childScope),
|
||||
Effect.withSpan("Plugin.load", {
|
||||
attributes: {
|
||||
"plugin.id": id,
|
||||
},
|
||||
}),
|
||||
Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(childScope, exit) : Effect.void)),
|
||||
)
|
||||
const next = {
|
||||
id,
|
||||
hooks: result ?? {},
|
||||
scope: childScope,
|
||||
}
|
||||
hooks = existing ? hooks.with(hooks.indexOf(existing), next) : [...hooks, next]
|
||||
yield* events.publish(Event.Added, { id })
|
||||
}),
|
||||
)
|
||||
}),
|
||||
trigger: Effect.fn("Plugin.trigger")(function* (name, input, output) {
|
||||
return yield* svc.triggerFor(ID.make("*"), name, input, output)
|
||||
}),
|
||||
triggerFor: Effect.fn("Plugin.triggerFor")(function* (id, name, input, output) {
|
||||
const draftEntries = new Map<string, ReturnType<typeof createDraft>>()
|
||||
const event = {
|
||||
...input,
|
||||
...output,
|
||||
} as Record<string, unknown>
|
||||
|
||||
for (const [field, value] of Object.entries(output)) {
|
||||
if (value && typeof value === "object") {
|
||||
draftEntries.set(field, createDraft(value))
|
||||
event[field] = draftEntries.get(field)
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of hooks) {
|
||||
if (id !== ID.make("*") && item.id !== id) continue
|
||||
const match = item.hooks[name]
|
||||
if (!match) continue
|
||||
yield* match(event as any).pipe(
|
||||
Effect.withSpan(`Plugin.hook.${name}`, {
|
||||
attributes: {
|
||||
plugin: item.id,
|
||||
hook: name,
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
for (const item of registrations) {
|
||||
if (item.name !== name) continue
|
||||
const result = item.callback(event as never)
|
||||
if (Effect.isEffect(result)) yield* result
|
||||
}
|
||||
|
||||
for (const [field, draft] of draftEntries) {
|
||||
event[field] = finishDraft(draft)
|
||||
}
|
||||
|
||||
return event as any
|
||||
}),
|
||||
remove: Effect.fn("Plugin.remove")(function* (id) {
|
||||
yield* locks.withLock(id)(
|
||||
Effect.gen(function* () {
|
||||
const existing = hooks.find((item) => item.id === id)
|
||||
hooks = hooks.filter((item) => item.id !== id)
|
||||
if (existing) yield* State.batch(Scope.close(existing.scope, Exit.void)).pipe(Effect.ignore)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
hook: Effect.fn("Plugin.hook")(function* (name, callback) {
|
||||
const scope = yield* Scope.Scope
|
||||
const registration = { name, callback } as (typeof registrations)[number]
|
||||
let active = true
|
||||
registrations = [...registrations, registration]
|
||||
const dispose = Effect.sync(() => {
|
||||
if (!active) return
|
||||
active = false
|
||||
registrations = registrations.filter((item) => item !== registration)
|
||||
})
|
||||
yield* Scope.addFinalizer(scope, dispose)
|
||||
return { dispose }
|
||||
}),
|
||||
const service = Service.of({
|
||||
transform: state.transform,
|
||||
reload: state.reload,
|
||||
})
|
||||
return svc
|
||||
host = yield* PluginHost.make(service)
|
||||
return service
|
||||
}),
|
||||
)
|
||||
|
||||
export const locationLayer = layer
|
||||
|
||||
// opencode
|
||||
// sdcok
|
||||
export const locationLayer = layer.pipe(
|
||||
Layer.provideMerge(AgentV2.locationLayer),
|
||||
Layer.provideMerge(AISDK.locationLayer),
|
||||
Layer.provideMerge(Catalog.locationLayer),
|
||||
Layer.provideMerge(CommandV2.locationLayer),
|
||||
Layer.provideMerge(Integration.locationLayer),
|
||||
Layer.provideMerge(Reference.locationLayer),
|
||||
Layer.provideMerge(SkillV2.locationLayer),
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue