mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-10 01:08:32 +00:00
fix(core): reload all config plugins
This commit is contained in:
parent
c9b24ef027
commit
9751615651
6 changed files with 264 additions and 70 deletions
|
|
@ -2,7 +2,7 @@ export * as ConfigAgentPlugin from "./agent"
|
|||
|
||||
import { define } from "../../plugin/internal"
|
||||
import path from "path"
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
import { Effect, Option, Schema, Stream } from "effect"
|
||||
import { AgentV2 } from "../../agent"
|
||||
import { Config } from "../../config"
|
||||
import { ConfigAgent } from "../agent"
|
||||
|
|
@ -38,31 +38,34 @@ export const Plugin = define({
|
|||
effect: Effect.fn(function* (ctx) {
|
||||
const config = yield* Config.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const documents = yield* Effect.forEach(yield* config.entries(), (entry) => {
|
||||
if (entry.type === "document") return Effect.succeed([entry])
|
||||
return Effect.gen(function* () {
|
||||
const files = yield* discover(fs, entry.path)
|
||||
return yield* Effect.forEach(files, (file) =>
|
||||
fs.readFileStringSafe(file.filepath).pipe(
|
||||
Effect.map((content) => content && decode(file, content)),
|
||||
Effect.catch(() => Effect.succeed(undefined)),
|
||||
),
|
||||
).pipe(
|
||||
Effect.map((documents) =>
|
||||
documents.filter((document): document is Config.Document => document !== undefined),
|
||||
),
|
||||
)
|
||||
})
|
||||
}).pipe(Effect.map((documents) => documents.flat()))
|
||||
const global = documents.flatMap((document) => document.info.permissions ?? [])
|
||||
const configuredDefault = Config.latest(documents, "default_agent")
|
||||
const load = Effect.fn("ConfigAgentPlugin.load")(function* () {
|
||||
return yield* Effect.forEach(yield* config.entries(), (entry) => {
|
||||
if (entry.type === "document") return Effect.succeed([entry])
|
||||
return Effect.gen(function* () {
|
||||
const files = yield* discover(fs, entry.path)
|
||||
return yield* Effect.forEach(files, (file) =>
|
||||
fs.readFileStringSafe(file.filepath).pipe(
|
||||
Effect.map((content) => content && decode(file, content)),
|
||||
Effect.catch(() => Effect.succeed(undefined)),
|
||||
),
|
||||
).pipe(
|
||||
Effect.map((documents) =>
|
||||
documents.filter((document): document is Config.Document => document !== undefined),
|
||||
),
|
||||
)
|
||||
})
|
||||
}).pipe(Effect.map((documents) => documents.flat()))
|
||||
})
|
||||
const loaded = { documents: yield* load() }
|
||||
yield* ctx.agent.transform((draft) => {
|
||||
const global = loaded.documents.flatMap((document) => document.info.permissions ?? [])
|
||||
const configuredDefault = Config.latest(loaded.documents, "default_agent")
|
||||
if (configuredDefault !== undefined) draft.default(AgentV2.ID.make(configuredDefault))
|
||||
for (const current of draft.list()) {
|
||||
draft.update(current.id, (agent) => agent.permissions.push(...global))
|
||||
}
|
||||
|
||||
for (const document of documents) {
|
||||
for (const document of loaded.documents) {
|
||||
for (const [id, item] of Object.entries(document.info.agents ?? {})) {
|
||||
const agentID = AgentV2.ID.make(id)
|
||||
if (item.disabled) {
|
||||
|
|
@ -95,6 +98,16 @@ export const Plugin = define({
|
|||
}
|
||||
}
|
||||
})
|
||||
yield* ctx.event.subscribe().pipe(
|
||||
Stream.filter((event) => event.type === "config.updated"),
|
||||
Stream.runForEach(() =>
|
||||
load().pipe(
|
||||
Effect.tap((documents) => Effect.sync(() => (loaded.documents = documents))),
|
||||
Effect.andThen(ctx.agent.reload()),
|
||||
),
|
||||
),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ export * as ConfigExternalPlugin from "./external"
|
|||
|
||||
import type { Plugin as EffectPlugin } from "@opencode-ai/plugin/v2/effect"
|
||||
import type { Plugin as PromisePlugin } from "@opencode-ai/plugin/v2/promise"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import path from "path"
|
||||
import { fileURLToPath, pathToFileURL } from "url"
|
||||
import { Config } from "../../config"
|
||||
|
|
@ -42,8 +42,9 @@ export const Plugin = define({
|
|||
const fs = yield* FSUtil.Service
|
||||
const location = yield* Location.Service
|
||||
const npm = yield* Npm.Service
|
||||
yield* Effect.gen(function* () {
|
||||
const configured: { package: string; options?: Record<string, any> }[] = []
|
||||
const active = new Set<string>()
|
||||
const load = Effect.fn("ConfigExternalPlugin.load")(function* () {
|
||||
const configured: { package: string; options?: Record<string, unknown> }[] = []
|
||||
|
||||
for (const entry of yield* config.entries()) {
|
||||
if (entry.type === "document") {
|
||||
|
|
@ -98,8 +99,8 @@ export const Plugin = define({
|
|||
}
|
||||
}
|
||||
|
||||
for (const ref of configured) {
|
||||
yield* Effect.gen(function* () {
|
||||
return yield* Effect.forEach(configured, (ref) =>
|
||||
Effect.gen(function* () {
|
||||
const entrypoint = path.isAbsolute(ref.package)
|
||||
? pathToFileURL(ref.package).href
|
||||
: (yield* npm.add(ref.package)).entrypoint
|
||||
|
|
@ -108,13 +109,31 @@ 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)
|
||||
yield* ctx.plugin.add({
|
||||
return {
|
||||
id: plugin.id,
|
||||
effect: (host) => plugin.effect({ ...host, options: ref.options ?? {} }),
|
||||
})
|
||||
}).pipe(Effect.ignoreCause)
|
||||
}
|
||||
effect: (host: Parameters<typeof plugin.effect>[0]) =>
|
||||
plugin.effect({ ...host, options: ref.options ?? {} }),
|
||||
}
|
||||
}).pipe(Effect.catchCause(() => Effect.succeed(undefined))),
|
||||
).pipe(Effect.map((plugins) => plugins.filter((plugin) => plugin !== undefined)))
|
||||
})
|
||||
const reconcile = Effect.fn("ConfigExternalPlugin.reconcile")(function* () {
|
||||
const plugins = yield* load()
|
||||
const next = new Set(plugins.map((plugin) => plugin.id))
|
||||
for (const id of active) {
|
||||
if (!next.has(id)) yield* ctx.plugin.remove(id)
|
||||
}
|
||||
for (const plugin of plugins) yield* ctx.plugin.add(plugin)
|
||||
active.clear()
|
||||
for (const id of next) active.add(id)
|
||||
})
|
||||
|
||||
yield* reconcile()
|
||||
yield* ctx.event.subscribe().pipe(
|
||||
Stream.filter((event) => event.type === "config.updated"),
|
||||
Stream.runForEach(() => reconcile()),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
export * as ConfigProviderPlugin from "./provider"
|
||||
|
||||
import { define } from "../../plugin/internal"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Config } from "../../config"
|
||||
import { ModelV2 } from "../../model"
|
||||
|
||||
|
|
@ -9,14 +9,16 @@ export const Plugin = define({
|
|||
id: "config-provider",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const config = yield* Config.Service
|
||||
const entries = yield* config.entries()
|
||||
const files = entries.filter((entry): entry is Config.Document => entry.type === "document")
|
||||
const configuredIntegrations = new Set(
|
||||
files.flatMap((file) =>
|
||||
Object.entries(file.info.providers ?? {}).flatMap(([id, provider]) => (provider.env === undefined ? [] : [id])),
|
||||
),
|
||||
)
|
||||
const loaded = { entries: yield* config.entries() }
|
||||
yield* ctx.integration.transform((integrations) => {
|
||||
const files = loaded.entries.filter((entry): entry is Config.Document => entry.type === "document")
|
||||
const configuredIntegrations = new Set(
|
||||
files.flatMap((file) =>
|
||||
Object.entries(file.info.providers ?? {}).flatMap(([id, provider]) =>
|
||||
provider.env === undefined ? [] : [id],
|
||||
),
|
||||
),
|
||||
)
|
||||
for (const file of files) {
|
||||
for (const [id, item] of Object.entries(file.info.providers ?? {})) {
|
||||
const integrationID = id
|
||||
|
|
@ -34,8 +36,9 @@ export const Plugin = define({
|
|||
}
|
||||
})
|
||||
|
||||
const configuredDefault = Config.latest(entries, "model")
|
||||
yield* ctx.catalog.transform((catalog) => {
|
||||
const files = loaded.entries.filter((entry): entry is Config.Document => entry.type === "document")
|
||||
const configuredDefault = Config.latest(loaded.entries, "model")
|
||||
if (configuredDefault !== undefined) {
|
||||
const model = ModelV2.parse(configuredDefault)
|
||||
catalog.model.default.set(model.providerID, model.modelID)
|
||||
|
|
@ -105,5 +108,16 @@ 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 }),
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ export * as ConfigReferencePlugin from "./reference"
|
|||
|
||||
import { define } from "../../plugin/internal"
|
||||
import path from "path"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Config } from "../../config"
|
||||
import { ConfigReference } from "../reference"
|
||||
import { Reference } from "../../reference"
|
||||
|
|
@ -16,35 +16,48 @@ export const Plugin = define({
|
|||
const config = yield* Config.Service
|
||||
const location = yield* Location.Service
|
||||
const global = yield* Global.Service
|
||||
const entries = new Map<string, Reference.Source>()
|
||||
for (const doc of (yield* config.entries()).filter((entry): entry is Config.Document => entry.type === "document")) {
|
||||
const directory = doc.path ? path.dirname(doc.path) : location.directory
|
||||
for (const [name, entry] of Object.entries(doc.info.references ?? {})) {
|
||||
if (!validAlias(name)) continue
|
||||
const description = typeof entry === "string" ? undefined : entry.description
|
||||
const hidden = typeof entry === "string" ? undefined : entry.hidden
|
||||
entries.set(
|
||||
name,
|
||||
local(entry)
|
||||
? Reference.LocalSource.make({
|
||||
type: "local",
|
||||
path: AbsolutePath.make(localPath(directory, global.home, typeof entry === "string" ? entry : entry.path)),
|
||||
...(description === undefined ? {} : { description }),
|
||||
...(hidden === undefined ? {} : { hidden }),
|
||||
})
|
||||
: Reference.GitSource.make({
|
||||
type: "git",
|
||||
repository: typeof entry === "string" ? entry : entry.repository,
|
||||
...(entry.branch === undefined ? {} : { branch: entry.branch }),
|
||||
...(description === undefined ? {} : { description }),
|
||||
...(hidden === undefined ? {} : { hidden }),
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
const loaded = { entries: yield* config.entries() }
|
||||
yield* ctx.reference.transform((draft) => {
|
||||
const entries = new Map<string, Reference.Source>()
|
||||
for (const doc of loaded.entries.filter((entry): entry is Config.Document => entry.type === "document")) {
|
||||
const directory = doc.path ? path.dirname(doc.path) : location.directory
|
||||
for (const [name, entry] of Object.entries(doc.info.references ?? {})) {
|
||||
if (!validAlias(name)) continue
|
||||
const description = typeof entry === "string" ? undefined : entry.description
|
||||
const hidden = typeof entry === "string" ? undefined : entry.hidden
|
||||
entries.set(
|
||||
name,
|
||||
local(entry)
|
||||
? Reference.LocalSource.make({
|
||||
type: "local",
|
||||
path: AbsolutePath.make(
|
||||
localPath(directory, global.home, typeof entry === "string" ? entry : entry.path),
|
||||
),
|
||||
...(description === undefined ? {} : { description }),
|
||||
...(hidden === undefined ? {} : { hidden }),
|
||||
})
|
||||
: Reference.GitSource.make({
|
||||
type: "git",
|
||||
repository: typeof entry === "string" ? entry : entry.repository,
|
||||
...(entry.branch === undefined ? {} : { branch: entry.branch }),
|
||||
...(description === undefined ? {} : { description }),
|
||||
...(hidden === undefined ? {} : { hidden }),
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
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 }),
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ export * as ConfigSkillPlugin from "./skill"
|
|||
|
||||
import { define } from "../../plugin/internal"
|
||||
import path from "path"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Config } from "../../config"
|
||||
import { AbsolutePath } from "../../schema"
|
||||
import { SkillV2 } from "../../skill"
|
||||
|
|
@ -15,10 +15,10 @@ export const Plugin = define({
|
|||
const config = yield* Config.Service
|
||||
const global = yield* Global.Service
|
||||
const location = yield* Location.Service
|
||||
const entries = yield* config.entries()
|
||||
const directories = entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : []))
|
||||
const items = entries.flatMap((entry) => (entry.type === "document" ? (entry.info.skills ?? []) : []))
|
||||
const loaded = { entries: yield* config.entries() }
|
||||
yield* ctx.skill.transform((draft) => {
|
||||
const directories = loaded.entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : []))
|
||||
const items = loaded.entries.flatMap((entry) => (entry.type === "document" ? (entry.info.skills ?? []) : []))
|
||||
for (const directory of directories) {
|
||||
draft.source(
|
||||
SkillV2.DirectorySource.make({ type: "directory", path: AbsolutePath.make(path.join(directory, "skill")) }),
|
||||
|
|
@ -44,5 +44,15 @@ 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.skill.reload()),
|
||||
),
|
||||
),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
|
|
|||
125
packages/core/test/config/reload.test.ts
Normal file
125
packages/core/test/config/reload.test.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { CommandV2 } from "@opencode-ai/core/command"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { ConfigAgentPlugin } from "@opencode-ai/core/config/plugin/agent"
|
||||
import { ConfigCommandPlugin } from "@opencode-ai/core/config/plugin/command"
|
||||
import { ConfigExternalPlugin } from "@opencode-ai/core/config/plugin/external"
|
||||
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 { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { Reference } from "@opencode-ai/core/reference"
|
||||
import { SkillV2 } from "@opencode-ai/core/skill"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "../plugin/fixture"
|
||||
|
||||
const it = testEffect(PluginTestLayer)
|
||||
const decode = Schema.decodeUnknownSync(Config.Info)
|
||||
const document = path.join(import.meta.dir, "opencode.json")
|
||||
|
||||
describe("config plugin reloads", () => {
|
||||
it.live("reloads every config-backed domain", () =>
|
||||
Effect.gen(function* () {
|
||||
const agents = yield* AgentV2.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
const commands = yield* CommandV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
const plugins = yield* PluginV2.Service
|
||||
const references = yield* Reference.Service
|
||||
const skills = yield* SkillV2.Service
|
||||
const host = yield* PluginHost.make(plugins)
|
||||
let entries: Config.Entry[] = [config("first", "First plugin")]
|
||||
const service = Config.Service.of({ entries: () => Effect.sync(() => entries) })
|
||||
const setup = <R>(effect: Effect.Effect<void, never, R>) =>
|
||||
effect.pipe(Effect.provideService(Config.Service, service))
|
||||
|
||||
yield* setup(ConfigAgentPlugin.Plugin.effect(host))
|
||||
yield* setup(ConfigCommandPlugin.Plugin.effect(host))
|
||||
yield* setup(ConfigSkillPlugin.Plugin.effect(host))
|
||||
yield* setup(ConfigReferencePlugin.Plugin.effect(host))
|
||||
yield* setup(ConfigProviderPlugin.Plugin.effect(host))
|
||||
yield* setup(ConfigExternalPlugin.Plugin.effect(host))
|
||||
|
||||
expect((yield* agents.get(AgentV2.ID.make("first")))?.description).toBe("First agent")
|
||||
expect((yield* commands.get("first"))?.description).toBe("First command")
|
||||
expect(
|
||||
(yield* skills.sources()).some((source) => source.type === "directory" && source.path === "/skills/first"),
|
||||
).toBe(true)
|
||||
expect((yield* references.list()).map((reference) => reference.name)).toEqual(["first"])
|
||||
expect(yield* catalog.provider.get(ProviderV2.ID.make("first"))).toBeDefined()
|
||||
expect((yield* agents.get(AgentV2.ID.make("configured")))?.description).toBe("First plugin")
|
||||
|
||||
entries = [config("second", "Second plugin")]
|
||||
yield* events.publish(ConfigSchema.Event.Updated, {})
|
||||
yield* waitUntil(
|
||||
Effect.gen(function* () {
|
||||
return (
|
||||
(yield* agents.get(AgentV2.ID.make("first"))) === undefined &&
|
||||
(yield* agents.get(AgentV2.ID.make("second")))?.description === "Second agent" &&
|
||||
(yield* commands.get("first")) === undefined &&
|
||||
(yield* commands.get("second"))?.description === "Second command" &&
|
||||
(yield* references.list()).some((reference) => reference.name === "second") &&
|
||||
(yield* catalog.provider.get(ProviderV2.ID.make("first"))) === undefined &&
|
||||
(yield* catalog.provider.get(ProviderV2.ID.make("second"))) !== undefined &&
|
||||
(yield* agents.get(AgentV2.ID.make("configured")))?.description === "Second plugin"
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
expect(
|
||||
(yield* skills.sources()).some((source) => source.type === "directory" && source.path === "/skills/first"),
|
||||
).toBe(false)
|
||||
expect(
|
||||
(yield* skills.sources()).some((source) => source.type === "directory" && source.path === "/skills/second"),
|
||||
).toBe(true)
|
||||
|
||||
entries = [config("second")]
|
||||
yield* events.publish(ConfigSchema.Event.Updated, {})
|
||||
yield* waitUntil(agents.get(AgentV2.ID.make("configured")).pipe(Effect.map((agent) => agent === undefined)))
|
||||
}).pipe(Effect.provideService(Global.Service, Global.Service.of(Global.make()))),
|
||||
)
|
||||
})
|
||||
|
||||
function config(name: string, pluginDescription?: string) {
|
||||
return new Config.Document({
|
||||
type: "document",
|
||||
path: document,
|
||||
info: decode({
|
||||
agents: { [name]: { description: `${title(name)} agent`, mode: "subagent" } },
|
||||
commands: { [name]: { template: `${title(name)} command`, description: `${title(name)} command` } },
|
||||
skills: [`/skills/${name}`],
|
||||
references: { [name]: `/references/${name}` },
|
||||
providers: { [name]: { models: { chat: { name: `${title(name)} model` } } } },
|
||||
plugins:
|
||||
pluginDescription === undefined
|
||||
? []
|
||||
: [
|
||||
{
|
||||
package: "../plugin/fixtures/config-promise-plugin.ts",
|
||||
options: { description: pluginDescription },
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
function title(value: string) {
|
||||
return value.charAt(0).toUpperCase() + value.slice(1)
|
||||
}
|
||||
|
||||
const waitUntil = Effect.fnUntraced(function* (condition: Effect.Effect<boolean>) {
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
if (yield* condition) return
|
||||
yield* Effect.sleep("10 millis")
|
||||
}
|
||||
return yield* Effect.die("Timed out waiting for config plugin reloads")
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue