feat(core): expose config changes stream (#39131)
This commit is contained in:
parent
33e3d1ebca
commit
02c37c401a
19 changed files with 123 additions and 25 deletions
|
|
@ -159,6 +159,12 @@ export function latest<K extends keyof Info>(entries: readonly Entry[], key: K):
|
|||
export interface Interface {
|
||||
/** Returns location config documents and discovery sources from lowest to highest priority. */
|
||||
readonly entries: () => Effect.Effect<Entry[]>
|
||||
/**
|
||||
* Streams raw filesystem updates under config roots. Config owns root
|
||||
* topology and watch reconciliation; domain owners filter this feed for the
|
||||
* source files they parse and rebuild their own state.
|
||||
*/
|
||||
readonly changes: () => Stream.Stream<Watcher.Update>
|
||||
}
|
||||
|
||||
export const Options = Schema.Struct({
|
||||
|
|
@ -430,6 +436,7 @@ export const layer = (options?: Options) => Layer.effect(
|
|||
entries: Effect.fn("Config.entries")(function* () {
|
||||
return configs
|
||||
}),
|
||||
changes: () => Stream.fromPubSub(updates),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { ConfigAgentPlugin } from "@opencode-ai/core/config/plugin/agent"
|
||||
|
|
@ -67,6 +67,7 @@ describe("ConfigAgentPlugin.Plugin", () => {
|
|||
)
|
||||
|
||||
const config = Config.Service.of({
|
||||
changes: () => Stream.empty,
|
||||
entries: () =>
|
||||
Effect.succeed([
|
||||
new Config.Document({
|
||||
|
|
@ -152,6 +153,7 @@ describe("ConfigAgentPlugin.Plugin", () => {
|
|||
Effect.gen(function* () {
|
||||
const agents = yield* Agent.Service
|
||||
const config = Config.Service.of({
|
||||
changes: () => Stream.empty,
|
||||
entries: () =>
|
||||
Effect.succeed([
|
||||
new Config.Document({
|
||||
|
|
@ -220,6 +222,7 @@ describe("ConfigAgentPlugin.Plugin", () => {
|
|||
yield* agents.transform((editor) => editor.update(build, () => {}))
|
||||
|
||||
const config = Config.Service.of({
|
||||
changes: () => Stream.empty,
|
||||
entries: () =>
|
||||
Effect.succeed([
|
||||
new Config.Document({
|
||||
|
|
@ -279,6 +282,7 @@ Use native v2 fields.`,
|
|||
})
|
||||
const agents = yield* Agent.Service
|
||||
const config = Config.Service.of({
|
||||
changes: () => Stream.empty,
|
||||
entries: () =>
|
||||
Effect.succeed([
|
||||
new Config.Document({
|
||||
|
|
@ -320,6 +324,7 @@ function loadHomePermissions(home: string) {
|
|||
const build = Agent.ID.make("build")
|
||||
yield* agents.transform((editor) => editor.update(build, () => {}))
|
||||
const config = Config.Service.of({
|
||||
changes: () => Stream.empty,
|
||||
entries: () =>
|
||||
Effect.succeed([
|
||||
new Config.Document({
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ Review files`,
|
|||
Effect.provideService(
|
||||
Config.Service,
|
||||
Config.Service.of({
|
||||
changes: () => Stream.empty,
|
||||
entries: () =>
|
||||
Effect.succeed([
|
||||
new Config.Document({
|
||||
|
|
|
|||
|
|
@ -215,6 +215,45 @@ describe("Config", () => {
|
|||
),
|
||||
)
|
||||
|
||||
it.live("exposes filesystem updates under config roots through changes", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const global = path.join(tmp.path, "global")
|
||||
const project = path.join(tmp.path, "project")
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(path.join(global, "commands"), { recursive: true })
|
||||
await fs.mkdir(project, { recursive: true })
|
||||
})
|
||||
const updates = yield* PubSub.unbounded<Watcher.Update>()
|
||||
const watcher = Layer.succeed(
|
||||
Watcher.Service,
|
||||
Watcher.Service.of({
|
||||
subscribe: () => Stream.fromPubSub(updates),
|
||||
}),
|
||||
)
|
||||
|
||||
return yield* Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const received = yield* config
|
||||
.changes()
|
||||
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped({ startImmediately: true }))
|
||||
yield* Effect.sleep("10 millis")
|
||||
|
||||
const file = path.join(global, "commands", "review.md")
|
||||
yield* PubSub.publish(updates, { type: "update", path: file } satisfies Watcher.Update)
|
||||
|
||||
const collected = yield* Fiber.join(received).pipe(Effect.timeout("1 second"))
|
||||
expect(Array.from(collected)).toEqual([{ type: "update", path: file }])
|
||||
}).pipe(Effect.provide(testLayer(project, global, project, undefined, watcher)))
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("returns the latest defined scalar from priority-ordered documents", () =>
|
||||
Effect.sync(() => {
|
||||
const entries = [
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { Bus } from "@opencode-ai/core/bus"
|
|||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "../plugin/fixture"
|
||||
|
||||
|
|
@ -28,7 +28,10 @@ const addPlugin = Effect.fn(function* (entries: () => Config.Entry[]) {
|
|||
const plugin = yield* Plugin.Service
|
||||
const host = yield* PluginHost.make(plugin)
|
||||
yield* ConfigPolicyPlugin.Plugin.effect(host).pipe(
|
||||
Effect.provideService(Config.Service, Config.Service.of({ entries: () => Effect.sync(entries) })),
|
||||
Effect.provideService(
|
||||
Config.Service,
|
||||
Config.Service.of({ changes: () => Stream.empty, entries: () => Effect.sync(entries) }),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { ConfigProviderPlugin } from "@opencode-ai/core/config/plugin/provider"
|
||||
|
|
@ -55,6 +55,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
|||
const providerID = Provider.ID.make("custom")
|
||||
const modelID = Model.ID.make("chat")
|
||||
const config = Config.Service.of({
|
||||
changes: () => Stream.empty,
|
||||
entries: () =>
|
||||
Effect.succeed([
|
||||
new Config.Document({
|
||||
|
|
@ -93,6 +94,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
|||
})
|
||||
})
|
||||
const config = Config.Service.of({
|
||||
changes: () => Stream.empty,
|
||||
entries: () =>
|
||||
Effect.succeed([
|
||||
new Config.Document({
|
||||
|
|
@ -135,6 +137,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
|||
const providerID = Provider.ID.opencode
|
||||
const modelID = Model.ID.make("alpha-gpt-next")
|
||||
const config = Config.Service.of({
|
||||
changes: () => Stream.empty,
|
||||
entries: () =>
|
||||
Effect.succeed([
|
||||
new Config.Document({
|
||||
|
|
@ -187,6 +190,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
|||
const providerID = Provider.ID.opencode
|
||||
const modelID = Model.ID.make("alpha-gpt-next")
|
||||
const config = Config.Service.of({
|
||||
changes: () => Stream.empty,
|
||||
entries: () =>
|
||||
Effect.succeed([
|
||||
new Config.Document({
|
||||
|
|
@ -235,6 +239,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
|||
const providerID = Provider.ID.make("custom")
|
||||
const modelID = Model.ID.make("chat")
|
||||
const config = Config.Service.of({
|
||||
changes: () => Stream.empty,
|
||||
entries: () =>
|
||||
Effect.succeed([
|
||||
new Config.Document({
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ 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 { Effect, Schema } from "effect"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "../plugin/fixture"
|
||||
|
||||
|
|
@ -37,7 +37,7 @@ describe("config plugin reloads", () => {
|
|||
const skills = yield* Skill.Service
|
||||
const host = yield* PluginHost.make(plugins)
|
||||
let entries: Config.Entry[] = [config("first")]
|
||||
const service = Config.Service.of({ entries: () => Effect.sync(() => entries) })
|
||||
const service = Config.Service.of({ changes: () => Stream.empty, entries: () => Effect.sync(() => entries) })
|
||||
const setup = <R>(effect: Effect.Effect<void, never, R>) =>
|
||||
effect.pipe(Effect.provideService(Config.Service, service))
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { Effect, Layer, Schema, Stream } from "effect"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { ConfigSkillPlugin } from "@opencode-ai/core/config/plugin/skill"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
|
|
@ -44,6 +44,7 @@ describe("ConfigSkillPlugin.Plugin", () => {
|
|||
Effect.provideService(
|
||||
Config.Service,
|
||||
Config.Service.of({
|
||||
changes: () => Stream.empty,
|
||||
entries: () =>
|
||||
Effect.succeed([
|
||||
new Config.ClaudeDirectory({ type: "claude", path: AbsolutePath.make("/repo/.claude") }),
|
||||
|
|
|
|||
|
|
@ -25,9 +25,7 @@ const it = testEffect(AppNodeBuilder.build(LayerNode.group([FSUtil.node, Bus.nod
|
|||
|
||||
const configLayer = Layer.succeed(
|
||||
Config.Service,
|
||||
Config.Service.of({
|
||||
entries: () => Effect.succeed([]),
|
||||
}),
|
||||
Config.Service.of({ changes: () => Stream.empty, entries: () => Effect.succeed([]) }),
|
||||
)
|
||||
|
||||
function provide(directory: string, vcs?: Location.Interface["vcs"]) {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Effect, Layer } from "effect"
|
||||
import { Effect, Layer, Stream } from "effect"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { MCP } from "@opencode-ai/core/mcp/index"
|
||||
|
|
@ -23,7 +23,10 @@ export const emptyMcpLayer = Layer.succeed(
|
|||
}),
|
||||
)
|
||||
|
||||
export const emptyConfigLayer = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) }))
|
||||
export const emptyConfigLayer = Layer.succeed(
|
||||
Config.Service,
|
||||
Config.Service.of({ changes: () => Stream.empty, entries: () => Effect.succeed([]) }),
|
||||
)
|
||||
|
||||
export const testLocationLayer = Layer.succeed(
|
||||
Location.Service,
|
||||
|
|
|
|||
|
|
@ -163,6 +163,7 @@ function resourceMcpLayer(
|
|||
Layer.succeed(
|
||||
Config.Service,
|
||||
Config.Service.of({
|
||||
changes: () => Stream.empty,
|
||||
entries: () =>
|
||||
Effect.succeed([
|
||||
new Config.Document({
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ import { Reference } from "@opencode-ai/core/reference"
|
|||
import { Skill } from "@opencode-ai/core/skill"
|
||||
import { Tool } from "@opencode-ai/core/tool"
|
||||
import { WebSearch } from "@opencode-ai/core/websearch"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Effect, Layer, Stream } from "effect"
|
||||
import { tempLocationLayer } from "../fixture/location"
|
||||
|
||||
const npmLayer = Layer.succeed(
|
||||
|
|
@ -60,6 +60,12 @@ export const PluginTestLayer = AppNodeBuilder.build(
|
|||
[
|
||||
[Location.node, tempLocationLayer],
|
||||
[Npm.node, npmLayer],
|
||||
[Config.node, Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) }))],
|
||||
[
|
||||
Config.node,
|
||||
Layer.succeed(
|
||||
Config.Service,
|
||||
Config.Service.of({ changes: () => Stream.empty, entries: () => Effect.succeed([]) }),
|
||||
),
|
||||
],
|
||||
],
|
||||
) as unknown as Layer.Layer<unknown, never>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { Config } from "@opencode-ai/core/config"
|
|||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { SkillPlugin } from "@opencode-ai/core/plugin/skill"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Skill } from "@opencode-ai/core/skill"
|
||||
|
|
@ -28,7 +28,10 @@ describe("SkillPlugin.Plugin", () => {
|
|||
},
|
||||
}),
|
||||
).pipe(
|
||||
Effect.provideService(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) })),
|
||||
Effect.provideService(
|
||||
Config.Service,
|
||||
Config.Service.of({ changes: () => Stream.empty, entries: () => Effect.succeed([]) }),
|
||||
),
|
||||
Effect.provideService(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make(import.meta.dir) })),
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Effect, Layer } from "effect"
|
||||
import { Effect, Layer, Stream } from "effect"
|
||||
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
|
|
@ -43,7 +43,15 @@ export const webSearchIntegrationTest = testEffect(
|
|||
Layer.merge(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Integration.node, Credential.node, Bus.node, Form.node, WebSearch.node]),
|
||||
[[Config.node, Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) }))]],
|
||||
[
|
||||
[
|
||||
Config.node,
|
||||
Layer.succeed(
|
||||
Config.Service,
|
||||
Config.Service.of({ changes: () => Stream.empty, entries: () => Effect.succeed([]) }),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
http,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { DateTime, Effect, Layer } from "effect"
|
||||
import { DateTime, Effect, Layer, Stream } from "effect"
|
||||
import { Message } from "@opencode-ai/ai"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
|
|
@ -70,7 +70,10 @@ const permission = Layer.succeed(
|
|||
list: () => Effect.die("unused"),
|
||||
}),
|
||||
)
|
||||
const config = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) }))
|
||||
const config = Layer.succeed(
|
||||
Config.Service,
|
||||
Config.Service.of({ changes: () => Stream.empty, entries: () => Effect.succeed([]) }),
|
||||
)
|
||||
const imageLayer = AppNodeBuilder.build(Image.node, [[Config.node, config]])
|
||||
|
||||
const testLayer = AppNodeBuilder.build(
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
|
|||
import { SystemPromptPlugin } from "@opencode-ai/core/plugin/system-prompt"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Effect, Layer, Stream } from "effect"
|
||||
import path from "node:path"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { agentHost, catalogHost, host } from "./plugin/host"
|
||||
|
|
@ -88,7 +88,10 @@ const referenceInstructions = Layer.mock(ReferenceInstructions.Service, {
|
|||
load: () => Effect.succeed(Instructions.empty),
|
||||
})
|
||||
const mcpInstructions = Layer.mock(McpInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
|
||||
const config = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) }))
|
||||
const config = Layer.succeed(
|
||||
Config.Service,
|
||||
Config.Service.of({ changes: () => Stream.empty, entries: () => Effect.succeed([]) }),
|
||||
)
|
||||
const pluginSupervisor = Layer.succeed(PluginSupervisor.Service, PluginSupervisor.Service.of({ flush: Effect.void }))
|
||||
const promptCatalog = Layer.mock(Catalog.Service, {
|
||||
provider: {
|
||||
|
|
|
|||
|
|
@ -358,6 +358,7 @@ const mcpInstructions = Layer.mock(McpInstructions.Service, { load: () => Effect
|
|||
const config = Layer.succeed(
|
||||
Config.Service,
|
||||
Config.Service.of({
|
||||
changes: () => Stream.empty,
|
||||
entries: () =>
|
||||
Effect.succeed([
|
||||
new Config.Document({
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { beforeEach, describe, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import { Effect, Exit, Layer, PlatformError } from "effect"
|
||||
import { Effect, Exit, Layer, PlatformError, Stream } from "effect"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { ConfigAttachments } from "@opencode-ai/core/config/attachments"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
|
|
@ -100,7 +100,10 @@ const permission = Layer.succeed(
|
|||
list: () => Effect.die("unused"),
|
||||
}),
|
||||
)
|
||||
const config = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed(configEntries) }))
|
||||
const config = Layer.succeed(
|
||||
Config.Service,
|
||||
Config.Service.of({ changes: () => Stream.empty, entries: () => Effect.succeed(configEntries) }),
|
||||
)
|
||||
const imageLayer = AppNodeBuilder.build(Image.node, [[Config.node, config]])
|
||||
const testFileSystem = Layer.effect(
|
||||
FSUtil.Service,
|
||||
|
|
|
|||
|
|
@ -701,7 +701,15 @@ const toolLifecycleLayer = (endpoint: string) => {
|
|||
})
|
||||
return AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node, provider]),
|
||||
[[Config.node, Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) }))]],
|
||||
[
|
||||
[
|
||||
Config.node,
|
||||
Layer.succeed(
|
||||
Config.Service,
|
||||
Config.Service.of({ changes: () => Stream.empty, entries: () => Effect.succeed([]) }),
|
||||
),
|
||||
],
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue