Skip to content

Commit e528ed5

Browse files
authored
effectify Plugin service internals (anomalyco#19365)
1 parent bb8d2cd commit e528ed5

2 files changed

Lines changed: 75 additions & 68 deletions

File tree

packages/opencode/src/plugin/index.ts

Lines changed: 73 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -176,76 +176,86 @@ export namespace Plugin {
176176
Service,
177177
Effect.gen(function* () {
178178
const bus = yield* Bus.Service
179+
const config = yield* Config.Service
179180

180181
const cache = yield* InstanceState.make<State>(
181182
Effect.fn("Plugin.state")(function* (ctx) {
182183
const hooks: Hooks[] = []
183184

184-
yield* Effect.promise(async () => {
185-
const { Server } = await import("../server/server")
186-
187-
const client = createOpencodeClient({
188-
baseUrl: "http://localhost:4096",
189-
directory: ctx.directory,
190-
headers: Flag.OPENCODE_SERVER_PASSWORD
191-
? {
192-
Authorization: `Basic ${Buffer.from(`${Flag.OPENCODE_SERVER_USERNAME ?? "opencode"}:${Flag.OPENCODE_SERVER_PASSWORD}`).toString("base64")}`,
193-
}
194-
: undefined,
195-
fetch: async (...args) => Server.Default().fetch(...args),
196-
})
197-
const cfg = await Config.get()
198-
const input: PluginInput = {
199-
client,
200-
project: ctx.project,
201-
worktree: ctx.worktree,
202-
directory: ctx.directory,
203-
get serverUrl(): URL {
204-
return Server.url ?? new URL("http://localhost:4096")
205-
},
206-
$: Bun.$,
207-
}
185+
const { Server } = yield* Effect.promise(() => import("../server/server"))
186+
187+
const client = createOpencodeClient({
188+
baseUrl: "http://localhost:4096",
189+
directory: ctx.directory,
190+
headers: Flag.OPENCODE_SERVER_PASSWORD
191+
? {
192+
Authorization: `Basic ${Buffer.from(`${Flag.OPENCODE_SERVER_USERNAME ?? "opencode"}:${Flag.OPENCODE_SERVER_PASSWORD}`).toString("base64")}`,
193+
}
194+
: undefined,
195+
fetch: async (...args) => Server.Default().fetch(...args),
196+
})
197+
const cfg = yield* config.get()
198+
const input: PluginInput = {
199+
client,
200+
project: ctx.project,
201+
worktree: ctx.worktree,
202+
directory: ctx.directory,
203+
get serverUrl(): URL {
204+
return Server.url ?? new URL("http://localhost:4096")
205+
},
206+
$: Bun.$,
207+
}
208208

209-
for (const plugin of INTERNAL_PLUGINS) {
210-
log.info("loading internal plugin", { name: plugin.name })
211-
const init = await plugin(input).catch((err) => {
209+
for (const plugin of INTERNAL_PLUGINS) {
210+
log.info("loading internal plugin", { name: plugin.name })
211+
const init = yield* Effect.tryPromise({
212+
try: () => plugin(input),
213+
catch: (err) => {
212214
log.error("failed to load internal plugin", { name: plugin.name, error: err })
213-
})
214-
if (init) hooks.push(init)
215-
}
216-
217-
const plugins = Flag.OPENCODE_PURE ? [] : (cfg.plugin ?? [])
218-
if (Flag.OPENCODE_PURE && cfg.plugin?.length) {
219-
log.info("skipping external plugins in pure mode", { count: cfg.plugin.length })
220-
}
221-
if (plugins.length) await Config.waitForDependencies()
222-
223-
const loaded = await Promise.all(plugins.map((item) => prepPlugin(item)))
224-
for (const load of loaded) {
225-
if (!load) continue
226-
227-
// Keep plugin execution sequential so hook registration and execution
228-
// order remains deterministic across plugin runs.
229-
await applyPlugin(load, input, hooks).catch((err) => {
215+
},
216+
}).pipe(Effect.option)
217+
if (init._tag === "Some") hooks.push(init.value)
218+
}
219+
220+
const plugins = Flag.OPENCODE_PURE ? [] : (cfg.plugin ?? [])
221+
if (Flag.OPENCODE_PURE && cfg.plugin?.length) {
222+
log.info("skipping external plugins in pure mode", { count: cfg.plugin.length })
223+
}
224+
if (plugins.length) yield* config.waitForDependencies()
225+
226+
const loaded = yield* Effect.promise(() => Promise.all(plugins.map((item) => prepPlugin(item))))
227+
for (const load of loaded) {
228+
if (!load) continue
229+
230+
// Keep plugin execution sequential so hook registration and execution
231+
// order remains deterministic across plugin runs.
232+
yield* Effect.tryPromise({
233+
try: () => applyPlugin(load, input, hooks),
234+
catch: (err) => {
230235
const message = errorMessage(err)
231236
log.error("failed to load plugin", { path: load.spec, error: message })
232-
Bus.publish(Session.Event.Error, {
237+
return message
238+
},
239+
}).pipe(
240+
Effect.catch((message) =>
241+
bus.publish(Session.Event.Error, {
233242
error: new NamedError.Unknown({
234243
message: `Failed to load plugin ${load.spec}: ${message}`,
235244
}).toObject(),
236-
})
237-
})
238-
}
239-
240-
// Notify plugins of current config
241-
for (const hook of hooks) {
242-
try {
243-
await (hook as any).config?.(cfg)
244-
} catch (err) {
245+
}),
246+
),
247+
)
248+
}
249+
250+
// Notify plugins of current config
251+
for (const hook of hooks) {
252+
yield* Effect.tryPromise({
253+
try: () => Promise.resolve((hook as any).config?.(cfg)),
254+
catch: (err) => {
245255
log.error("plugin config hook failed", { error: err })
246-
}
247-
}
248-
})
256+
},
257+
}).pipe(Effect.ignore)
258+
}
249259

250260
// Subscribe to bus events, fiber interrupted when scope closes
251261
yield* bus.subscribeAll().pipe(
@@ -270,13 +280,11 @@ export namespace Plugin {
270280
>(name: Name, input: Input, output: Output) {
271281
if (!name) return output
272282
const state = yield* InstanceState.get(cache)
273-
yield* Effect.promise(async () => {
274-
for (const hook of state.hooks) {
275-
const fn = hook[name] as any
276-
if (!fn) continue
277-
await fn(input, output)
278-
}
279-
})
283+
for (const hook of state.hooks) {
284+
const fn = hook[name] as any
285+
if (!fn) continue
286+
yield* Effect.promise(() => fn(input, output))
287+
}
280288
return output
281289
})
282290

@@ -293,7 +301,7 @@ export namespace Plugin {
293301
}),
294302
)
295303

296-
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
304+
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Config.defaultLayer))
297305
const { runPromise } = makeRuntime(Service, defaultLayer)
298306

299307
export async function trigger<

packages/opencode/test/plugin/auth-override.test.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,12 +64,11 @@ describe("plugin.config-hook-error-isolation", () => {
6464
test("config hooks are individually error-isolated in the layer factory", async () => {
6565
const src = await Bun.file(file).text()
6666

67-
// The config hook try/catch lives in the InstanceState factory (layer definition),
68-
// not in init() which now just delegates to the Effect service.
67+
// Each hook's config call is wrapped in Effect.tryPromise with error logging + Effect.ignore
6968
expect(src).toContain("plugin config hook failed")
7069

7170
const pattern =
72-
/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]*?\}/
71+
/for\s*\(const hook of hooks\)\s*\{[\s\S]*?Effect\.tryPromise[\s\S]*?\.config\?\.\([\s\S]*?plugin config hook failed[\s\S]*?Effect\.ignore/
7372
expect(pattern.test(src)).toBe(true)
7473
})
7574
})

0 commit comments

Comments
 (0)