Просмотр исходного кода

refactor(plugin): use direct runtime registry

Dax Raad 2 месяцев назад
Родитель
Сommit
975b1132f1

+ 1 - 9
packages/core/src/config/plugin/external.ts

@@ -36,12 +36,6 @@ export const Plugin = define({
     const fs = yield* FSUtil.Service
     const location = yield* Location.Service
     const npm = yield* Npm.Service
-    const loaded: EffectPlugin[] = []
-
-    yield* ctx.plugin.transform((plugins) => {
-      for (const plugin of loaded) plugins.add(plugin)
-    })
-
     yield* Effect.gen(function* () {
       const configured: { package: string; options?: Record<string, any> }[] = []
 
@@ -86,14 +80,12 @@ export const Plugin = define({
           const mod = yield* Effect.promise(() => import(entrypoint))
           const value = (yield* Schema.decodeUnknownEffect(PluginModule)(mod)).default
           const plugin = "effect" in value ? value : PluginPromise.fromPromise(value)
-          loaded.push({
+          yield* ctx.plugin.add({
             id: plugin.id,
             effect: (host) => plugin.effect({ ...host, options: ref.options ?? {} }),
           })
         }).pipe(Effect.ignoreCause)
       }
-
-      yield* ctx.plugin.reload()
     }).pipe(Effect.forkScoped({ startImmediately: true }))
   }),
 })

+ 37 - 45
packages/core/src/plugin.ts

@@ -1,7 +1,7 @@
 export * as PluginV2 from "./plugin"
 
 import { Context, Effect, Exit, Layer, Schema, Scope } from "effect"
-import type { Plugin, PluginDraft } from "@opencode-ai/plugin/v2/effect"
+import type { Plugin } from "@opencode-ai/plugin/v2/effect"
 import { AgentV2 } from "./agent"
 import { AISDK } from "./aisdk"
 import { Catalog } from "./catalog"
@@ -27,8 +27,8 @@ export const Event = {
 }
 
 export interface Interface {
-  readonly transform: State.Transform<PluginDraft>
-  readonly reload: State.Reload
+  readonly add: (id: ID, effect: Plugin["effect"]) => Effect.Effect<void>
+  readonly remove: (id: ID) => Effect.Effect<void>
 }
 
 export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Plugin") {}
@@ -40,57 +40,49 @@ export const layer = Layer.effect(
     const locks = KeyedMutex.makeUnsafe<ID>()
     const scope = yield* Scope.make()
     const active = new Map<ID, Scope.Closeable>()
+    const loading = new Set<ID>()
     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)
+    const add = Effect.fn("Plugin.add")(function* (id: ID, effect: Plugin["effect"]) {
+      if (loading.has(id)) return yield* Effect.die(`Plugin load cycle detected for ${id}`)
+
       yield* locks.withLock(id)(
-        Effect.gen(function* () {
-          const existing = active.get(id)
-          if (existing) yield* Scope.close(existing, Exit.void).pipe(Effect.ignore)
+        Effect.sync(() => loading.add(id)).pipe(
+          Effect.andThen(
+            State.batch(
+              Effect.gen(function* () {
+                const existing = active.get(id)
+                active.delete(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 child = yield* Scope.fork(scope)
+                yield* 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 })
+              }),
+            ),
+          ),
+          Effect.ensuring(Effect.sync(() => loading.delete(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 remove = Effect.fn("Plugin.remove")(function* (id: ID) {
+      if (loading.has(id)) return yield* Effect.die(`Cannot remove plugin ${id} while it is loading`)
 
-    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) =>
+      yield* locks.withLock(id)(
         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")),
+            const current = active.get(id)
+            active.delete(id)
+            if (current) yield* Scope.close(current, Exit.void).pipe(Effect.ignore)
+          }),
         ),
+      )
     })
 
     yield* Effect.addFinalizer((exit) =>
@@ -101,8 +93,8 @@ export const layer = Layer.effect(
     )
 
     const service = Service.of({
-      transform: state.transform,
-      reload: state.reload,
+      add,
+      remove,
     })
     host = yield* PluginHost.make(service)
     return service

+ 3 - 3
packages/core/src/plugin/host.ts

@@ -8,7 +8,7 @@ import { Catalog } from "../catalog"
 import { CommandV2 } from "../command"
 import { Integration } from "../integration"
 import { ModelV2 } from "../model"
-import type { PluginV2 } from "../plugin"
+import { PluginV2 } from "../plugin"
 import { ProviderV2 } from "../provider"
 import { Reference } from "../reference"
 import { SkillV2 } from "../skill"
@@ -126,8 +126,8 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int
         ),
     },
     plugin: {
-      reload: plugin.reload,
-      transform: plugin.transform,
+      add: (input) => plugin.add(PluginV2.ID.make(input.id), input.effect),
+      remove: (id) => plugin.remove(PluginV2.ID.make(id)),
     },
     reference: {
       reload: reference.reload,

+ 38 - 44
packages/core/src/plugin/internal.ts

@@ -23,10 +23,8 @@ import { Npm } from "../npm"
 import { PluginV2 } from "../plugin"
 import { Reference } from "../reference"
 import { SkillV2 } from "../skill"
-import { State } from "../state"
 import { AgentPlugin } from "./agent"
 import { CommandPlugin } from "./command"
-import { PluginHost } from "./host"
 import { ModelsDevPlugin } from "./models-dev"
 import { ProviderPlugins } from "./provider"
 import { SkillPlugin } from "./skill"
@@ -73,49 +71,45 @@ export const locationLayer = Layer.effectDiscard(
     const global = yield* Global.Service
     const skill = yield* SkillV2.Service
     const reference = yield* Reference.Service
-    const host = yield* PluginHost.make(plugin)
+    const add = <R>(input: Plugin<R>) => {
+      const loaded = {
+        id: input.id,
+        effect: (context: PluginContext) =>
+          input
+            .effect(context)
+            .pipe(
+              Effect.provideService(Catalog.Service, catalog),
+              Effect.provideService(CommandV2.Service, commands),
+              Effect.provideService(Integration.Service, integration),
+              Effect.provideService(AgentV2.Service, agents),
+              Effect.provideService(Config.Service, config),
+              Effect.provideService(Location.Service, location),
+              Effect.provideService(ModelsDev.Service, modelsDev),
+              Effect.provideService(Npm.Service, npm),
+              Effect.provideService(EventV2.Service, events),
+              Effect.provideService(FSUtil.Service, fs),
+              Effect.provideService(FileSystem.Service, filesystem),
+              Effect.provideService(Global.Service, global),
+              Effect.provideService(SkillV2.Service, skill),
+              Effect.provideService(Reference.Service, reference),
+            ),
+      }
+      return plugin.add(PluginV2.ID.make(loaded.id), loaded.effect)
+    }
 
-    const wrap = <R>(input: Plugin<R>) => ({
-      id: input.id,
-      effect: (context: PluginContext) =>
-        input
-          .effect(context)
-          .pipe(
-            Effect.provideService(Catalog.Service, catalog),
-            Effect.provideService(CommandV2.Service, commands),
-            Effect.provideService(Integration.Service, integration),
-            Effect.provideService(AgentV2.Service, agents),
-            Effect.provideService(Config.Service, config),
-            Effect.provideService(Location.Service, location),
-            Effect.provideService(ModelsDev.Service, modelsDev),
-            Effect.provideService(Npm.Service, npm),
-            Effect.provideService(EventV2.Service, events),
-            Effect.provideService(FSUtil.Service, fs),
-            Effect.provideService(FileSystem.Service, filesystem),
-            Effect.provideService(Global.Service, global),
-            Effect.provideService(SkillV2.Service, skill),
-            Effect.provideService(Reference.Service, reference),
-          ),
-    })
-
-    yield* State.batch(
-      Effect.gen(function* () {
-        yield* plugin.transform((plugins) => {
-          plugins.add(wrap(AgentPlugin.Plugin))
-          plugins.add(wrap(CommandPlugin.Plugin))
-          plugins.add(wrap(SkillPlugin.Plugin))
-          plugins.add(wrap(ModelsDevPlugin))
-          plugins.add(wrap(ConfigProviderPlugin.Plugin))
-          plugins.add(wrap(ConfigAgentPlugin.Plugin))
-          plugins.add(wrap(ConfigCommandPlugin.Plugin))
-          plugins.add(wrap(ConfigSkillPlugin.Plugin))
-          plugins.add(wrap(ConfigReferencePlugin.Plugin))
-          for (const item of ProviderPlugins) plugins.add(wrap(item))
-        })
-
-        yield* wrap(ConfigExternalPlugin.Plugin).effect(host)
-      }),
-    ).pipe(Effect.withSpan("PluginInternal.boot"), Effect.forkScoped({ startImmediately: true }))
+    yield* Effect.gen(function* () {
+      yield* add(AgentPlugin.Plugin)
+      yield* add(CommandPlugin.Plugin)
+      yield* add(SkillPlugin.Plugin)
+      yield* add(ModelsDevPlugin)
+      yield* add(ConfigProviderPlugin.Plugin)
+      yield* add(ConfigAgentPlugin.Plugin)
+      yield* add(ConfigCommandPlugin.Plugin)
+      yield* add(ConfigSkillPlugin.Plugin)
+      yield* add(ConfigReferencePlugin.Plugin)
+      for (const item of ProviderPlugins) yield* add(item)
+      yield* add(ConfigExternalPlugin.Plugin)
+    }).pipe(Effect.withSpan("PluginInternal.boot"), Effect.forkScoped({ startImmediately: true }))
   }),
 ).pipe(
   Layer.provideMerge(PluginV2.locationLayer),

+ 5 - 2
packages/core/src/plugin/promise.ts

@@ -67,8 +67,11 @@ export function fromPromise(plugin: Plugin) {
             reload: () => run(host.integration.reload()),
           },
           plugin: {
-            transform: transform(host.plugin),
-            reload: () => run(host.plugin.reload()),
+            add: (input) => {
+              const child = fromPromise(input)
+              return run(host.plugin.add(child))
+            },
+            remove: (id) => run(host.plugin.remove(id)),
           },
           reference: {
             transform: transform(host.reference),

+ 13 - 16
packages/core/test/location-layer.test.ts

@@ -197,22 +197,19 @@ describe("LocationServiceMap", () => {
       Effect.flatMap((dir) =>
         Effect.gen(function* () {
           const plugins = yield* PluginV2.Service
-          yield* plugins.transform((draft) =>
-            draft.add(
-              define({
-                id: "reviewer",
-                effect: (ctx) =>
-                  ctx.agent
-                    .transform((agent) => {
-                      agent.update("reviewer", (item) => {
-                        item.description = "Reviews code"
-                        item.mode = "subagent"
-                      })
-                    })
-                    .pipe(Effect.asVoid),
-              }),
-            ),
-          )
+          const reviewer = define({
+            id: "reviewer",
+            effect: (ctx) =>
+              ctx.agent
+                .transform((agent) => {
+                  agent.update("reviewer", (item) => {
+                    item.description = "Reviews code"
+                    item.mode = "subagent"
+                  })
+                })
+                .pipe(Effect.asVoid),
+          })
+          yield* plugins.add(PluginV2.ID.make(reviewer.id), reviewer.effect)
 
           expect(yield* (yield* AgentV2.Service).get(AgentV2.ID.make("reviewer"))).toMatchObject({
             description: "Reviews code",

+ 17 - 18
packages/core/test/plugin.test.ts

@@ -9,35 +9,34 @@ import { PluginTestLayer } from "./plugin/fixture"
 const it = testEffect(PluginTestLayer)
 
 describe("PluginV2", () => {
-  it.effect("reconciles transformed plugins", () =>
+  it.effect("adds, replaces, and removes plugins", () =>
     Effect.gen(function* () {
       const plugins = yield* PluginV2.Service
       const agents = yield* AgentV2.Service
       let description = "first"
 
-      const registration = yield* plugins.transform((draft) => {
-        draft.add(
-          define({
-            id: "managed",
-            effect: (ctx) =>
-              ctx.agent
-                .transform((agents) =>
-                  agents.update("configured", (agent) => {
-                    agent.description = description
-                  }),
-                )
-                .pipe(Effect.asVoid),
-          }),
-        )
-      })
+      const managed = () =>
+        define({
+          id: "managed",
+          effect: (ctx) =>
+            ctx.agent
+              .transform((agents) =>
+                agents.update("configured", (agent) => {
+                  agent.description = description
+                }),
+              )
+              .pipe(Effect.asVoid),
+        })
+
+      yield* plugins.add(PluginV2.ID.make("managed"), managed().effect)
 
       expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("first")
 
       description = "second"
-      yield* plugins.reload()
+      yield* plugins.add(PluginV2.ID.make("managed"), managed().effect)
       expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("second")
 
-      yield* registration.dispose
+      yield* plugins.remove(PluginV2.ID.make("managed"))
       expect(yield* agents.get(AgentV2.ID.make("configured"))).toBeUndefined()
     }),
   )

+ 2 - 2
packages/core/test/plugin/host.ts

@@ -33,8 +33,8 @@ export function host(overrides: Overrides = {}): PluginContext {
       reload: () => Effect.die("unused integration.reload"),
     },
     plugin: overrides.plugin ?? {
-      transform: () => Effect.die("unused plugin.transform"),
-      reload: () => Effect.die("unused plugin.reload"),
+      add: () => Effect.die("unused plugin.add"),
+      remove: () => Effect.die("unused plugin.remove"),
     },
     reference: overrides.reference ?? {
       transform: () => Effect.die("unused reference.transform"),

+ 2 - 2
packages/plugin/src/v2/effect/context.ts

@@ -4,7 +4,7 @@ import type { AISDKHooks } from "./aisdk.js"
 import type { CatalogHooks } from "./catalog.js"
 import type { CommandHooks } from "./command.js"
 import type { IntegrationHooks } from "./integration.js"
-import type { PluginHooks } from "./plugin.js"
+import type { PluginDomain } from "./plugin.js"
 import type { ReferenceHooks } from "./reference.js"
 import type { SkillHooks } from "./skill.js"
 import type { Reload } from "./registration.js"
@@ -16,7 +16,7 @@ export interface PluginContext {
   readonly catalog: CatalogHooks & Reload
   readonly command: CommandHooks & Reload
   readonly integration: IntegrationHooks & Reload
-  readonly plugin: PluginHooks & Reload
+  readonly plugin: PluginDomain
   readonly reference: ReferenceHooks & Reload
   readonly skill: SkillHooks & Reload
 }

+ 1 - 1
packages/plugin/src/v2/effect/index.ts

@@ -1,3 +1,3 @@
 export type { PluginContext } from "./context.js"
 export { define } from "./plugin.js"
-export type { Plugin, PluginDraft } from "./plugin.js"
+export type { Plugin } from "./plugin.js"

+ 3 - 15
packages/plugin/src/v2/effect/plugin.ts

@@ -1,7 +1,5 @@
 import type { Effect, Scope } from "effect"
 import type { PluginContext } from "./context.js"
-import type { PluginOptions } from "../options.js"
-import type { Hooks } from "./registration.js"
 
 export interface Plugin {
   readonly id: string
@@ -12,17 +10,7 @@ export function define(plugin: Plugin) {
   return plugin
 }
 
-export interface PluginRef {
-  readonly package: string
-  readonly options?: PluginOptions
+export interface PluginDomain {
+  readonly add: (plugin: Plugin) => Effect.Effect<void>
+  readonly remove: (id: string) => Effect.Effect<void>
 }
-
-export interface PluginDraft {
-  list(): readonly Plugin[]
-  add(plugin: Plugin): void
-  remove(id: string): void
-}
-
-export type PluginHooks = Hooks<{
-  transform: PluginDraft
-}>

+ 2 - 2
packages/plugin/src/v2/promise/context.ts

@@ -4,7 +4,7 @@ import type { AISDKHooks } from "./aisdk.js"
 import type { CatalogHooks } from "./catalog.js"
 import type { CommandHooks } from "./command.js"
 import type { IntegrationHooks } from "./integration.js"
-import type { PluginHooks } from "./plugin.js"
+import type { PluginDomain } from "./plugin.js"
 import type { ReferenceHooks } from "./reference.js"
 import type { SkillHooks } from "./skill.js"
 import type { Reload } from "./registration.js"
@@ -16,7 +16,7 @@ export interface PluginContext {
   readonly catalog: CatalogHooks & Reload
   readonly command: CommandHooks & Reload
   readonly integration: IntegrationHooks & Reload
-  readonly plugin: PluginHooks & Reload
+  readonly plugin: PluginDomain
   readonly reference: ReferenceHooks & Reload
   readonly skill: SkillHooks & Reload
 }

+ 1 - 1
packages/plugin/src/v2/promise/index.ts

@@ -1,7 +1,7 @@
 export type { PluginContext } from "./context.js"
 export type { PluginOptions } from "../options.js"
 export { define } from "./plugin.js"
-export type { Plugin, PluginDraft, PluginHooks, PluginRef } from "./plugin.js"
+export type { Plugin, PluginDomain } from "./plugin.js"
 export type { Registration, Reload } from "./registration.js"
 export type { AgentDraft, AgentHooks } from "./agent.js"
 export type { AISDKHooks } from "./aisdk.js"

+ 4 - 7
packages/plugin/src/v2/promise/plugin.ts

@@ -1,6 +1,4 @@
 import type { PluginContext } from "./context.js"
-import type { PluginDraft, PluginRef } from "../effect/plugin.js"
-import type { Hooks } from "./registration.js"
 
 export interface Plugin {
   readonly id: string
@@ -11,8 +9,7 @@ export function define(plugin: Plugin) {
   return plugin
 }
 
-export type { PluginDraft, PluginRef }
-
-export type PluginHooks = Hooks<{
-  transform: PluginDraft
-}>
+export interface PluginDomain {
+  readonly add: (plugin: Plugin) => Promise<void>
+  readonly remove: (id: string) => Promise<void>
+}