effectify Plugin service internals (#19365)

This commit is contained in:
Kit Langton
2026-03-27 15:20:11 +00:00
committed by GitHub
parent bb8d2cdd10
commit e528ed5d86
2 changed files with 70 additions and 63 deletions
+68 -60
View File
@@ -176,76 +176,86 @@ export namespace Plugin {
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const bus = yield* Bus.Service const bus = yield* Bus.Service
const config = yield* Config.Service
const cache = yield* InstanceState.make<State>( const cache = yield* InstanceState.make<State>(
Effect.fn("Plugin.state")(function* (ctx) { Effect.fn("Plugin.state")(function* (ctx) {
const hooks: Hooks[] = [] const hooks: Hooks[] = []
yield* Effect.promise(async () => { const { Server } = yield* Effect.promise(() => import("../server/server"))
const { Server } = await import("../server/server")
const client = createOpencodeClient({ const client = createOpencodeClient({
baseUrl: "http://localhost:4096", baseUrl: "http://localhost:4096",
directory: ctx.directory, directory: ctx.directory,
headers: Flag.OPENCODE_SERVER_PASSWORD headers: Flag.OPENCODE_SERVER_PASSWORD
? { ? {
Authorization: `Basic ${Buffer.from(`${Flag.OPENCODE_SERVER_USERNAME ?? "opencode"}:${Flag.OPENCODE_SERVER_PASSWORD}`).toString("base64")}`, Authorization: `Basic ${Buffer.from(`${Flag.OPENCODE_SERVER_USERNAME ?? "opencode"}:${Flag.OPENCODE_SERVER_PASSWORD}`).toString("base64")}`,
} }
: undefined, : undefined,
fetch: async (...args) => Server.Default().fetch(...args), fetch: async (...args) => Server.Default().fetch(...args),
}) })
const cfg = await Config.get() const cfg = yield* config.get()
const input: PluginInput = { const input: PluginInput = {
client, client,
project: ctx.project, project: ctx.project,
worktree: ctx.worktree, worktree: ctx.worktree,
directory: ctx.directory, directory: ctx.directory,
get serverUrl(): URL { get serverUrl(): URL {
return Server.url ?? new URL("http://localhost:4096") return Server.url ?? new URL("http://localhost:4096")
}, },
$: Bun.$, $: Bun.$,
} }
for (const plugin of INTERNAL_PLUGINS) { for (const plugin of INTERNAL_PLUGINS) {
log.info("loading internal plugin", { name: plugin.name }) log.info("loading internal plugin", { name: plugin.name })
const init = await plugin(input).catch((err) => { const init = yield* Effect.tryPromise({
try: () => plugin(input),
catch: (err) => {
log.error("failed to load internal plugin", { name: plugin.name, error: err }) log.error("failed to load internal plugin", { name: plugin.name, error: err })
}) },
if (init) hooks.push(init) }).pipe(Effect.option)
} if (init._tag === "Some") hooks.push(init.value)
}
const plugins = Flag.OPENCODE_PURE ? [] : (cfg.plugin ?? []) const plugins = Flag.OPENCODE_PURE ? [] : (cfg.plugin ?? [])
if (Flag.OPENCODE_PURE && cfg.plugin?.length) { if (Flag.OPENCODE_PURE && cfg.plugin?.length) {
log.info("skipping external plugins in pure mode", { count: cfg.plugin.length }) log.info("skipping external plugins in pure mode", { count: cfg.plugin.length })
} }
if (plugins.length) await Config.waitForDependencies() if (plugins.length) yield* config.waitForDependencies()
const loaded = await Promise.all(plugins.map((item) => prepPlugin(item))) const loaded = yield* Effect.promise(() => Promise.all(plugins.map((item) => prepPlugin(item))))
for (const load of loaded) { for (const load of loaded) {
if (!load) continue if (!load) continue
// Keep plugin execution sequential so hook registration and execution // Keep plugin execution sequential so hook registration and execution
// order remains deterministic across plugin runs. // order remains deterministic across plugin runs.
await applyPlugin(load, input, hooks).catch((err) => { yield* Effect.tryPromise({
try: () => applyPlugin(load, input, hooks),
catch: (err) => {
const message = errorMessage(err) const message = errorMessage(err)
log.error("failed to load plugin", { path: load.spec, error: message }) log.error("failed to load plugin", { path: load.spec, error: message })
Bus.publish(Session.Event.Error, { return message
},
}).pipe(
Effect.catch((message) =>
bus.publish(Session.Event.Error, {
error: new NamedError.Unknown({ error: new NamedError.Unknown({
message: `Failed to load plugin ${load.spec}: ${message}`, message: `Failed to load plugin ${load.spec}: ${message}`,
}).toObject(), }).toObject(),
}) }),
}) ),
} )
}
// Notify plugins of current config // Notify plugins of current config
for (const hook of hooks) { for (const hook of hooks) {
try { yield* Effect.tryPromise({
await (hook as any).config?.(cfg) try: () => Promise.resolve((hook as any).config?.(cfg)),
} catch (err) { catch: (err) => {
log.error("plugin config hook failed", { error: err }) log.error("plugin config hook failed", { error: err })
} },
} }).pipe(Effect.ignore)
}) }
// Subscribe to bus events, fiber interrupted when scope closes // Subscribe to bus events, fiber interrupted when scope closes
yield* bus.subscribeAll().pipe( yield* bus.subscribeAll().pipe(
@@ -270,13 +280,11 @@ export namespace Plugin {
>(name: Name, input: Input, output: Output) { >(name: Name, input: Input, output: Output) {
if (!name) return output if (!name) return output
const state = yield* InstanceState.get(cache) const state = yield* InstanceState.get(cache)
yield* Effect.promise(async () => { for (const hook of state.hooks) {
for (const hook of state.hooks) { const fn = hook[name] as any
const fn = hook[name] as any if (!fn) continue
if (!fn) continue yield* Effect.promise(() => fn(input, output))
await fn(input, output) }
}
})
return output return output
}) })
@@ -293,7 +301,7 @@ export namespace Plugin {
}), }),
) )
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer)) export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Config.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer) const { runPromise } = makeRuntime(Service, defaultLayer)
export async function trigger< export async function trigger<
@@ -64,12 +64,11 @@ describe("plugin.config-hook-error-isolation", () => {
test("config hooks are individually error-isolated in the layer factory", async () => { test("config hooks are individually error-isolated in the layer factory", async () => {
const src = await Bun.file(file).text() const src = await Bun.file(file).text()
// The config hook try/catch lives in the InstanceState factory (layer definition), // Each hook's config call is wrapped in Effect.tryPromise with error logging + Effect.ignore
// not in init() which now just delegates to the Effect service.
expect(src).toContain("plugin config hook failed") expect(src).toContain("plugin config hook failed")
const pattern = const pattern =
/for\s*\(const hook of hooks\)\s*\{[\s\S]*?try\s*\{[\s\S]*?\.config\?\.\([\s\S]*?\}\s*catch\s*\(err\)\s*\{[\s\S]*?plugin config hook failed[\s\S]*?\}/ /for\s*\(const hook of hooks\)\s*\{[\s\S]*?Effect\.tryPromise[\s\S]*?\.config\?\.\([\s\S]*?plugin config hook failed[\s\S]*?Effect\.ignore/
expect(pattern.test(src)).toBe(true) expect(pattern.test(src)).toBe(true)
}) })
}) })