refactor(config): share entry observer (#43973)

This commit is contained in:
Kit Langton
2026-08-21 15:29:08 -04:00
committed by GitHub
parent ed08f0e691
commit 2eecf076c4
16 changed files with 172 additions and 145 deletions
+2 -3
View File
@@ -30,9 +30,8 @@ import { ConfigNormalize } from "./config/normalize.js"
import { WellKnown } from "./wellknown.js"
export function latest<K extends keyof Info>(entries: readonly Entry[], key: K): Info[K] | undefined {
return entries
.filter((entry): entry is Document => entry.type === "document")
.findLast((entry) => entry.info[key] !== undefined)?.info[key]
return entries.findLast((entry): entry is Document => entry.type === "document" && entry.info[key] !== undefined)
?.info[key]
}
export interface Interface {
+4 -15
View File
@@ -1,35 +1,24 @@
export * as ConfigCompactionPlugin from "./compaction.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Effect, Stream } from "effect"
import { Effect } from "effect"
import { Config } from "../../config.js"
import { SessionCompaction } from "../../session/compaction.js"
import { ConfigEntryObserver } from "./entry-observer.js"
export const Plugin = define({
id: "opencode.config.compaction",
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const compaction = yield* SessionCompaction.Service
const loaded = { entries: yield* config.entries() }
const reload = config.entries().pipe(
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
Effect.andThen(compaction.reload()),
)
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(() => reload),
Effect.forkScoped({ startImmediately: true }),
)
loaded.entries = yield* config.entries()
const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, compaction.reload())
yield* compaction.transform((draft) => {
for (const entry of loaded.entries) {
if (entry.type !== "document" || !entry.info.compaction) continue
draft.configure({
...(entry.info.compaction.auto === undefined ? {} : { auto: entry.info.compaction.auto }),
...(entry.info.compaction.buffer === undefined ? {} : { buffer: entry.info.compaction.buffer }),
...(entry.info.compaction.keep?.tokens === undefined
? {}
: { tokens: entry.info.compaction.keep.tokens }),
...(entry.info.compaction.keep?.tokens === undefined ? {} : { tokens: entry.info.compaction.keep.tokens }),
})
}
})
@@ -0,0 +1,25 @@
export * as ConfigEntryObserver from "./entry-observer.js"
import type { EventDomain } from "@opencode-ai/plugin/effect/event"
import { Effect, Stream } from "effect"
import { Config } from "../../config.js"
export const observe = Effect.fnUntraced(function* (
config: Config.Interface,
event: EventDomain,
reload: Effect.Effect<void>,
) {
const loaded = { entries: yield* config.entries() }
const refresh = config.entries().pipe(
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
Effect.andThen(reload),
)
yield* event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(() => refresh),
Effect.forkScoped({ startImmediately: true }),
)
// Close the race between the first read and establishing the subscription.
loaded.entries = yield* config.entries()
return loaded
})
+3 -15
View File
@@ -5,11 +5,12 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { Npm } from "@opencode-ai/util/npm"
import { AppProcess } from "@opencode-ai/util/process"
import { Effect, Stream } from "effect"
import { Effect } from "effect"
import { Config } from "../../config.js"
import { Formatter } from "../../formatter.js"
import { make, type Info } from "../../formatter/builtins.js"
import { Location } from "../../location.js"
import { ConfigEntryObserver } from "./entry-observer.js"
export const Plugin = define({
id: "opencode.config.formatter",
@@ -21,20 +22,7 @@ export const Plugin = define({
const location = yield* Location.Service
const npm = yield* Npm.Service
const processes = yield* AppProcess.Service
const loaded = { entries: yield* config.entries() }
const reload = config.entries().pipe(
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
Effect.andThen(formatter.reload()),
)
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(() => reload),
Effect.forkScoped({ startImmediately: true }),
)
// Refetch after subscribing so a config update between the first read and
// the live subscription cannot leave the transform on a stale snapshot.
loaded.entries = yield* config.entries()
const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, formatter.reload())
yield* formatter.transform((draft) => {
const configured = Config.latest(loaded.entries, "formatter")
+3 -14
View File
@@ -1,28 +1,17 @@
export * as ConfigImagePlugin from "./image.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Effect, Stream } from "effect"
import { Effect } from "effect"
import { Config } from "../../config.js"
import { Image } from "../../image.js"
import { ConfigEntryObserver } from "./entry-observer.js"
export const Plugin = define({
id: "opencode.config.image",
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const image = yield* Image.Service
const loaded = { entries: yield* config.entries() }
const reload = config.entries().pipe(
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
Effect.andThen(image.reload()),
)
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(() => reload),
Effect.forkScoped({ startImmediately: true }),
)
// Refetch after subscribing so a config update between the first read and
// the live subscription cannot leave the transform on a stale snapshot.
loaded.entries = yield* config.entries()
const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, image.reload())
yield* image.transform((draft) => {
for (const entry of loaded.entries) {
if (entry.type !== "document") continue
@@ -1,26 +1,17 @@
export * as ConfigLocationWatcherPlugin from "./location-watcher.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Effect, Stream } from "effect"
import { Effect } from "effect"
import { Config } from "../../config.js"
import { LocationWatcherPolicy } from "../../filesystem/location-watcher-policy.js"
import { ConfigEntryObserver } from "./entry-observer.js"
export const Plugin = define({
id: "opencode.config.location-watcher",
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const policy = yield* LocationWatcherPolicy.Service
const loaded = { entries: yield* config.entries() }
const reload = config.entries().pipe(
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
Effect.andThen(policy.reload()),
)
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(() => reload),
Effect.forkScoped({ startImmediately: true }),
)
loaded.entries = yield* config.entries()
const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, policy.reload())
yield* policy.transform((draft) => {
for (const entry of loaded.entries) {
if (entry.type !== "document" || !entry.info.watcher?.ignore) continue
+3 -12
View File
@@ -2,15 +2,16 @@ export * as ConfigPolicyPlugin from "./policy.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Document } from "@opencode-ai/schema/config"
import { Effect, Stream } from "effect"
import { Effect } from "effect"
import { Config } from "../../config.js"
import { Wildcard } from "../../util/wildcard.js"
import { ConfigEntryObserver } from "./entry-observer.js"
export const Plugin = define({
id: "opencode.config.policy",
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const loaded = { entries: yield* config.entries() }
const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, ctx.catalog.reload())
yield* ctx.catalog.transform((catalog) => {
// User-global policy takes priority over policy authored by a repository.
const policies = loaded.entries
@@ -22,15 +23,5 @@ export const Plugin = define({
if (policy?.effect === "deny") catalog.provider.remove(record.provider.id)
}
})
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(() =>
config.entries().pipe(
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
Effect.andThen(ctx.catalog.reload()),
),
),
Effect.forkScoped({ startImmediately: true }),
)
}),
})
+7 -13
View File
@@ -3,15 +3,20 @@ export * as ConfigProviderPlugin from "./provider.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Document, type Entry } from "@opencode-ai/schema/config"
import { Money } from "@opencode-ai/schema/money"
import { Effect, Stream } from "effect"
import { Effect } from "effect"
import { Config } from "../../config.js"
import { Provider } from "../../provider.js"
import { ConfigEntryObserver } from "./entry-observer.js"
export const Plugin = define({
id: "opencode.config.provider",
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const loaded = { entries: yield* config.entries() }
const loaded = yield* ConfigEntryObserver.observe(
config,
ctx.event,
ctx.integration.reload().pipe(Effect.andThen(ctx.catalog.reload())),
)
yield* ctx.integration.transform((integrations) => {
for (const [id, provider] of configuredProviders(loaded.entries)) {
const integrationID = id
@@ -97,17 +102,6 @@ export const Plugin = define({
}
}
})
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(() =>
config.entries().pipe(
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
Effect.andThen(ctx.integration.reload()),
Effect.andThen(ctx.catalog.reload()),
),
),
Effect.forkScoped({ startImmediately: true }),
)
}),
})
+3 -12
View File
@@ -4,12 +4,13 @@ import { define } from "@opencode-ai/plugin/effect/plugin"
import { Document } from "@opencode-ai/schema/config"
import { ConfigReference } from "@opencode-ai/schema/config/reference"
import path from "path"
import { Effect, Stream } from "effect"
import { Effect } from "effect"
import { Config } from "../../config.js"
import { Reference } from "../../reference.js"
import { AbsolutePath } from "../../schema.js"
import { Global } from "@opencode-ai/util/global"
import { Location } from "../../location.js"
import { ConfigEntryObserver } from "./entry-observer.js"
export const Plugin = define({
id: "opencode.config.reference",
@@ -17,7 +18,7 @@ export const Plugin = define({
const config = yield* Config.Service
const location = yield* Location.Service
const global = yield* Global.Service
const loaded = { entries: yield* config.entries() }
const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, ctx.reference.reload())
yield* ctx.reference.transform((draft) => {
const entries = new Map<string, Reference.Source>()
for (const doc of loaded.entries.filter((entry): entry is Document => entry.type === "document")) {
@@ -49,16 +50,6 @@ export const Plugin = define({
}
for (const [name, source] of entries) draft.add(name, source)
})
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(() =>
config.entries().pipe(
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
Effect.andThen(ctx.reference.reload()),
),
),
Effect.forkScoped({ startImmediately: true }),
)
}),
})
+3 -12
View File
@@ -1,26 +1,17 @@
export * as ConfigShellPlugin from "./shell.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Effect, Stream } from "effect"
import { Effect } from "effect"
import { Config } from "../../config.js"
import { ShellSelect } from "../../shell/select.js"
import { ConfigEntryObserver } from "./entry-observer.js"
export const Plugin = define({
id: "opencode.config.shell",
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const shell = yield* ShellSelect.Service
const loaded = { entries: yield* config.entries() }
const reload = config.entries().pipe(
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
Effect.andThen(shell.reload()),
)
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(() => reload),
Effect.forkScoped({ startImmediately: true }),
)
loaded.entries = yield* config.entries()
const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, shell.reload())
yield* shell.transform((draft) => {
const configured = Config.latest(loaded.entries, "shell")
if (configured) draft.configure(configured)
+3 -12
View File
@@ -1,26 +1,17 @@
export * as ConfigSnapshotPlugin from "./snapshot.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Effect, Stream } from "effect"
import { Effect } from "effect"
import { Config } from "../../config.js"
import { Snapshot } from "../../snapshot.js"
import { ConfigEntryObserver } from "./entry-observer.js"
export const Plugin = define({
id: "opencode.config.snapshot",
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const snapshot = yield* Snapshot.Service
const loaded = { entries: yield* config.entries() }
const reload = config.entries().pipe(
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
Effect.andThen(snapshot.reload()),
)
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(() => reload),
Effect.forkScoped({ startImmediately: true }),
)
loaded.entries = yield* config.entries()
const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, snapshot.reload())
yield* snapshot.transform((draft) => {
const configured = Config.latest(loaded.entries, "snapshots")
if (configured === undefined) return
+3 -12
View File
@@ -1,26 +1,17 @@
export * as ConfigToolOutputPlugin from "./tool-output.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Effect, Stream } from "effect"
import { Effect } from "effect"
import { Config } from "../../config.js"
import { ToolOutput } from "../../tool-output.js"
import { ConfigEntryObserver } from "./entry-observer.js"
export const Plugin = define({
id: "opencode.config.tool-output",
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const output = yield* ToolOutput.Service
const loaded = { entries: yield* config.entries() }
const reload = config.entries().pipe(
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
Effect.andThen(output.reload()),
)
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(() => reload),
Effect.forkScoped({ startImmediately: true }),
)
loaded.entries = yield* config.entries()
const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, output.reload())
yield* output.transform((draft) => {
const configured = Config.latest(loaded.entries, "tool_output")
if (!configured) return
+3 -12
View File
@@ -1,28 +1,19 @@
export * as ConfigWebSearchPlugin from "./websearch.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Effect, Stream } from "effect"
import { Effect } from "effect"
import { Config } from "../../config.js"
import { ConfigEntryObserver } from "./entry-observer.js"
export const Plugin = define({
id: "opencode.config.websearch",
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const loaded = { entries: yield* config.entries() }
const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, ctx.websearch.reload())
yield* ctx.websearch.transform((websearch) => {
const selection = Config.latest(loaded.entries, "websearch")
if (selection === false) websearch.default.set(false)
if (selection) websearch.default.set(selection.provider)
})
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(() =>
config.entries().pipe(
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
Effect.andThen(ctx.websearch.reload()),
),
),
Effect.forkScoped({ startImmediately: true }),
)
}),
})
@@ -0,0 +1,49 @@
import { describe, expect } from "bun:test"
import { Config } from "@opencode-ai/core/config"
import { Document, Info, type Entry } from "@opencode-ai/schema/config"
import { Event } from "@opencode-ai/schema/event"
import { Deferred, Effect, PubSub, Ref, Stream } from "effect"
import { ConfigEntryObserver } from "../../src/config/plugin/entry-observer"
import { it } from "../lib/effect"
describe("ConfigEntryObserver", () => {
it.effect("closes the startup race and reloads later updates", () =>
Effect.gen(function* () {
const current = yield* Ref.make([document("first")])
const updates = yield* PubSub.unbounded<ReturnType<typeof updated>>()
const reloaded = yield* Deferred.make<void>()
const config = Config.Service.of({
entries: () => Ref.get(current),
update: () => Effect.die("unused config.update"),
changes: () => Stream.empty,
})
const event = {
subscribe: () =>
Stream.unwrap(Ref.set(current, [document("raced")]).pipe(Effect.as(Stream.fromPubSub(updates)))),
}
const loaded = yield* ConfigEntryObserver.observe(
config,
event,
Deferred.succeed(reloaded, undefined).pipe(Effect.asVoid),
)
expect(Config.latest(loaded.entries, "shell")).toBe("raced")
expect(yield* Deferred.isDone(reloaded)).toBe(false)
yield* Ref.set(current, [document("later")])
yield* PubSub.publish(updates, updated())
yield* Deferred.await(reloaded)
expect(Config.latest(loaded.entries, "shell")).toBe("later")
}),
)
})
function document(shell: string): Entry {
return new Document({ type: "document", info: new Info({ shell }) })
}
function updated() {
return { id: Event.ID.create(), created: Date.now(), type: "config.updated" as const, data: {} }
}
+6 -1
View File
@@ -11,12 +11,13 @@ import { ConfigProviderPlugin } from "@opencode-ai/core/config/plugin/provider"
import { ConfigReferencePlugin } from "@opencode-ai/core/config/plugin/reference"
import { ConfigSkillPlugin } from "@opencode-ai/core/config/plugin/skill"
import { Bus } from "@opencode-ai/core/bus"
import { Global } from "@opencode-ai/util/global"
import { Integration } from "@opencode-ai/core/integration"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { Provider } from "@opencode-ai/core/provider"
import { Reference } from "@opencode-ai/core/reference"
import { Skill } from "@opencode-ai/core/skill"
import { Global } from "@opencode-ai/util/global"
import { Effect, Schema } from "effect"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { testEffect } from "../lib/effect"
@@ -32,6 +33,7 @@ describe("config plugin reloads", () => {
const agents = yield* Agent.Service
const catalog = yield* Catalog.Service
const commands = yield* Command.Service
const integrations = yield* Integration.Service
const bus = yield* Bus.Service
const plugins = yield* Plugin.Service
const references = yield* Reference.Service
@@ -47,6 +49,7 @@ describe("config plugin reloads", () => {
expect((yield* agents.get(Agent.ID.make("first")))?.description).toBe("First agent")
expect((yield* commands.get("first"))?.description).toBe("First command")
expect(yield* integrations.get(Integration.ID.make("first"))).toBeDefined()
expect((yield* skills.list()).some((skill) => skill.id === "first")).toBe(true)
expect((yield* references.list()).map((reference) => reference.name)).toEqual(["first"])
expect(yield* catalog.provider.get(Provider.ID.make("first"))).toBeDefined()
@@ -61,6 +64,8 @@ describe("config plugin reloads", () => {
(yield* agents.get(Agent.ID.make("second")))?.description === "Second agent" &&
(yield* commands.get("first")) === undefined &&
(yield* commands.get("second"))?.description === "Second command" &&
(yield* integrations.get(Integration.ID.make("first"))) === undefined &&
(yield* integrations.get(Integration.ID.make("second"))) !== undefined &&
(yield* references.list()).some((reference) => reference.name === "second") &&
(yield* catalog.provider.get(Provider.ID.make("first"))) === undefined &&
(yield* catalog.provider.get(Provider.ID.make("second"))) !== undefined
@@ -0,0 +1,52 @@
import { describe, expect } from "bun:test"
import { Bus } from "@opencode-ai/core/bus"
import { Config } from "@opencode-ai/core/config"
import { ConfigWebSearchPlugin } from "@opencode-ai/core/config/plugin/websearch"
import { Plugin } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { WebSearch } from "@opencode-ai/core/websearch"
import { Document, Event, Info } from "@opencode-ai/schema/config"
import { ConfigWebSearch } from "@opencode-ai/schema/config/websearch"
import { Effect } from "effect"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "../plugin/fixture"
const it = testEffect(PluginTestLayer)
describe("ConfigWebSearchPlugin.Plugin", () => {
it.live("reloads changed default selection", () =>
Effect.gen(function* () {
const websearch = yield* WebSearch.Service
const bus = yield* Bus.Service
const config = yield* Config.Test
const plugins = yield* Plugin.Service
yield* websearch.transform((draft) =>
draft.add({ id: WebSearch.ID.make("test"), name: "Test", execute: () => Effect.succeed([]) }),
)
yield* ConfigWebSearchPlugin.Plugin.effect(yield* PluginHost.make(plugins))
expect((yield* websearch.default().pipe(Effect.flip))._tag).toBe("WebSearch.Disabled")
yield* config.setEntries([configured(new ConfigWebSearch.Info({ provider: "random" }))])
yield* bus.publish(Event.Updated, {})
yield* waitUntil(
websearch.default().pipe(
Effect.map((provider) => provider?.id === WebSearch.ID.make("test")),
Effect.catch(() => Effect.succeed(false)),
),
)
}).pipe(Effect.provide(Config.testLayer([configured(false)]))),
)
})
function configured(websearch: ConfigWebSearch.Selection): Document {
return new Document({ type: "document", info: new Info({ websearch }) })
}
const waitUntil = Effect.fnUntraced(function* (condition: Effect.Effect<boolean>) {
for (let attempt = 0; attempt < 200; attempt++) {
if (yield* condition) return
yield* Effect.sleep("10 millis")
}
yield* Effect.die(new Error("Timed out waiting for websearch config reload"))
})