mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-14 21:06:23 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
723150a5ea | ||
|
|
a1dc866273 |
@@ -8,6 +8,7 @@ import { Effect, Option, Schema, Stream } from "effect"
|
||||
import { Agent } from "../../agent.js"
|
||||
import { Config } from "../../config.js"
|
||||
import { ConfigMarkdown } from "../markdown.js"
|
||||
import { ConfigSourceWatch } from "../source-watch.js"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { ConfigAgentV1 } from "../../v1/config/agent.js"
|
||||
import { ConfigMigrateV1 } from "../../v1/config/migrate.js"
|
||||
@@ -52,6 +53,7 @@ export const Plugin = define({
|
||||
const config = yield* Config.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const global = yield* Global.Service
|
||||
const sources = yield* ConfigSourceWatch.make(sourceDirectories)
|
||||
const loadEntry = Effect.fnUntraced(function* (entry: Entry) {
|
||||
if (entry.type === "document") return [entry]
|
||||
if (entry.type !== "directory") return []
|
||||
@@ -64,7 +66,9 @@ export const Plugin = define({
|
||||
).pipe(Effect.map((documents) => documents.filter((document): document is Document => document !== undefined)))
|
||||
})
|
||||
const load = Effect.fn("ConfigAgentPlugin.load")(function* () {
|
||||
return yield* Effect.forEach(yield* config.entries(), loadEntry).pipe(Effect.map((documents) => documents.flat()))
|
||||
const entries = yield* config.entries()
|
||||
yield* sources.reconcile(entries)
|
||||
return yield* Effect.forEach(entries, loadEntry).pipe(Effect.map((documents) => documents.flat()))
|
||||
})
|
||||
const loaded = { documents: [] as Document[] }
|
||||
const reload = load().pipe(
|
||||
@@ -74,13 +78,8 @@ export const Plugin = define({
|
||||
// One merged trigger stream serializes reloads and shares one debounce
|
||||
// window; subscribing before the initial scan means updates racing the
|
||||
// scan still trigger a rebuild.
|
||||
const sourceChanges = config
|
||||
.changes()
|
||||
.pipe(
|
||||
Stream.filterEffect((update) => Effect.map(config.entries(), (entries) => isAgentSource(entries, update.path))),
|
||||
)
|
||||
const configUpdates = ctx.event.subscribe().pipe(Stream.filter((event) => event.type === "config.updated"))
|
||||
yield* Stream.merge(sourceChanges, configUpdates).pipe(
|
||||
yield* Stream.merge(sources.changes, configUpdates).pipe(
|
||||
Stream.debounce("100 millis"),
|
||||
Stream.runForEach(() => reload),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
@@ -134,16 +133,6 @@ export const Plugin = define({
|
||||
}),
|
||||
})
|
||||
|
||||
// Matches anything at or under <root>/{agent,agents,mode,modes}. No file-suffix
|
||||
// check: directory-level events such as renames carry no per-file paths.
|
||||
function isAgentSource(entries: Entry[], file: string) {
|
||||
return entries.some(
|
||||
(entry) =>
|
||||
entry.type === "directory" &&
|
||||
sourceDirectories.some((name) => FSUtil.contains(path.join(entry.path, name), file)),
|
||||
)
|
||||
}
|
||||
|
||||
function expandPermissions(rules: Permission.Ruleset, home: string): Permission.Ruleset {
|
||||
// Expand only resources tools resolve as filesystem paths. Bash resources are raw shell text:
|
||||
// rewriting `$HOME/private/**` would miss `$HOME/private/key`, and safe expansion needs shell-aware parsing.
|
||||
|
||||
@@ -15,6 +15,7 @@ import { Location } from "../../location.js"
|
||||
import { ShellSelect } from "../../shell/select.js"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { ConfigMarkdown } from "../markdown.js"
|
||||
import { ConfigSourceWatch } from "../source-watch.js"
|
||||
|
||||
const decodeCommand = Schema.decodeUnknownOption(ConfigCommand.Info)
|
||||
|
||||
@@ -23,6 +24,7 @@ export const Plugin = define({
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const config = yield* Config.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const sources = yield* ConfigSourceWatch.make(sourceDirectories)
|
||||
const loadEntry = Effect.fnUntraced(function* (entry: Entry) {
|
||||
if (entry.type === "document") return [{ commands: entry.info.commands }]
|
||||
if (entry.type !== "directory") return []
|
||||
@@ -33,7 +35,9 @@ export const Plugin = define({
|
||||
const processes = yield* AppProcess.Service
|
||||
const shell = yield* ShellSelect.Service
|
||||
const load = Effect.fn("ConfigCommandPlugin.load")(function* () {
|
||||
return yield* Effect.forEach(yield* config.entries(), loadEntry).pipe(Effect.map((documents) => documents.flat()))
|
||||
const entries = yield* config.entries()
|
||||
yield* sources.reconcile(entries)
|
||||
return yield* Effect.forEach(entries, loadEntry).pipe(Effect.map((documents) => documents.flat()))
|
||||
})
|
||||
const loaded = { documents: [] as { commands: Info["commands"] }[] }
|
||||
const reload = load().pipe(
|
||||
@@ -43,15 +47,8 @@ export const Plugin = define({
|
||||
// One merged trigger stream serializes reloads and shares one debounce
|
||||
// window; subscribing before the initial scan means updates racing the
|
||||
// scan still trigger a rebuild.
|
||||
const sourceChanges = config
|
||||
.changes()
|
||||
.pipe(
|
||||
Stream.filterEffect((update) =>
|
||||
Effect.map(config.entries(), (entries) => isCommandSource(entries, update.path)),
|
||||
),
|
||||
)
|
||||
const configUpdates = ctx.event.subscribe().pipe(Stream.filter((event) => event.type === "config.updated"))
|
||||
yield* Stream.merge(sourceChanges, configUpdates).pipe(
|
||||
yield* Stream.merge(sources.changes, configUpdates).pipe(
|
||||
Stream.debounce("100 millis"),
|
||||
Stream.runForEach(() => reload),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
@@ -105,16 +102,6 @@ export const Plugin = define({
|
||||
// Keep in sync with the loadDirectory scan pattern and the name-strip regex in decode.
|
||||
const sourceDirectories = ["command", "commands"] as const
|
||||
|
||||
// Matches anything at or under <root>/{command,commands}. No file-suffix check:
|
||||
// directory-level events such as renames carry no per-file paths.
|
||||
function isCommandSource(entries: Entry[], file: string) {
|
||||
return entries.some(
|
||||
(entry) =>
|
||||
entry.type === "directory" &&
|
||||
sourceDirectories.some((name) => FSUtil.contains(path.join(entry.path, name), file)),
|
||||
)
|
||||
}
|
||||
|
||||
function loadDirectory(fs: FSUtil.Interface, directory: string) {
|
||||
return Effect.gen(function* () {
|
||||
const files = yield* fs
|
||||
@@ -191,8 +178,8 @@ function evaluateTemplate(
|
||||
)
|
||||
.pipe(
|
||||
Effect.map((result) => (result.output ?? Buffer.concat([result.stdout, result.stderr])).toString("utf8")),
|
||||
Effect.mapError((error) =>
|
||||
new Error(`Shell interpolation failed for ${JSON.stringify(source)}: ${error.message}`),
|
||||
Effect.mapError(
|
||||
(error) => new Error(`Shell interpolation failed for ${JSON.stringify(source)}: ${error.message}`),
|
||||
),
|
||||
)
|
||||
},
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
export * as ConfigSourceWatch from "./source-watch.js"
|
||||
|
||||
import path from "path"
|
||||
import type { Entry } from "@opencode-ai/schema/config"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Effect, FiberMap, PubSub, Stream } from "effect"
|
||||
import { Watcher } from "../filesystem/watcher.js"
|
||||
|
||||
/** Scoped root subscriptions for the source directories a config plugin reads. */
|
||||
export const make = Effect.fn("ConfigSourceWatch.make")(function* (directories: readonly string[]) {
|
||||
const watcher = yield* Watcher.Service
|
||||
const watches = yield* FiberMap.make<string>()
|
||||
const changes = yield* PubSub.sliding<void>(1)
|
||||
// Match Config's ignores so equivalent subscriptions share an OS watch.
|
||||
const ignore = ["node_modules", ".git", "**/{node_modules,.git}/**"]
|
||||
|
||||
return {
|
||||
changes: Stream.fromPubSub(changes),
|
||||
reconcile: Effect.fn("ConfigSourceWatch.reconcile")(function* (entries: readonly Entry[]) {
|
||||
const roots = new Set(entries.flatMap((entry) => (entry.type === "directory" ? [path.resolve(entry.path)] : [])))
|
||||
yield* Effect.forEach(
|
||||
Array.from(watches).filter(([root]) => !roots.has(root)),
|
||||
([root]) => FiberMap.remove(watches, root),
|
||||
{ discard: true },
|
||||
)
|
||||
yield* Effect.forEach(
|
||||
roots,
|
||||
Effect.fnUntraced(function* (root) {
|
||||
if (yield* FiberMap.has(watches, root)) return
|
||||
// Watch the root even before a source subdirectory exists. Directory
|
||||
// rename events have no suffix and must trigger the same rebuild.
|
||||
const updates = yield* watcher.subscribe({ path: root, type: "directory", ignore })
|
||||
yield* FiberMap.run(
|
||||
watches,
|
||||
root,
|
||||
updates.pipe(
|
||||
Stream.filter((update) =>
|
||||
directories.some((name) => FSUtil.contains(path.join(root, name), update.path)),
|
||||
),
|
||||
Stream.runForEach(() => PubSub.publish(changes, undefined)),
|
||||
),
|
||||
{ onlyIfMissing: true, startImmediately: true },
|
||||
)
|
||||
}),
|
||||
{ discard: true },
|
||||
)
|
||||
}),
|
||||
}
|
||||
})
|
||||
@@ -1,13 +1,14 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Effect, Fiber, Schema, Stream } from "effect"
|
||||
import { Effect, Fiber, Layer, Schema, Stream } from "effect"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { Directory, Document, Info } from "@opencode-ai/schema/config"
|
||||
import { Directory, Document, Event, Info } from "@opencode-ai/schema/config"
|
||||
import { ConfigAgentPlugin } from "@opencode-ai/core/config/plugin/agent"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
@@ -16,11 +17,18 @@ import { AgentPlugin } from "@opencode-ai/core/plugin/agent"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { ConfigMigrateV1 } from "@opencode-ai/core/v1/config/migrate"
|
||||
import { advance, drain } from "../lib/clock"
|
||||
import { tmpdir } from "../fixture/tmpdir"
|
||||
import { tmpdir, tmpdirScoped } from "../fixture/tmpdir"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { agentHost, host } from "../plugin/host"
|
||||
|
||||
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Agent.node, Bus.node, FSUtil.node, Global.node])))
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(
|
||||
AppNodeBuilder.build(LayerNode.group([Agent.node, Bus.node, FSUtil.node, Global.node, Watcher.node]), [
|
||||
[Watcher.node, Watcher.testLayer],
|
||||
]),
|
||||
Watcher.testLayer,
|
||||
),
|
||||
)
|
||||
const decode = Schema.decodeUnknownSync(Info)
|
||||
const defaultPermissions = (global: Global.Interface): Permission.Ruleset => [
|
||||
...Agent.Info.default(Agent.ID.make("test")).permissions,
|
||||
@@ -419,7 +427,7 @@ Use native v2 fields.`,
|
||||
|
||||
const agents = yield* Agent.Service
|
||||
const bus = yield* Bus.Service
|
||||
const configTest = yield* Config.Test
|
||||
const watcher = yield* Watcher.Test
|
||||
yield* ConfigAgentPlugin.Plugin.effect(host({ agent: agentHost(agents) }))
|
||||
|
||||
// Verify inside the subscription so the update event is a read barrier:
|
||||
@@ -435,7 +443,7 @@ Use native v2 fields.`,
|
||||
yield* Effect.yieldNow
|
||||
|
||||
const updates = yield* testCase.mutate(directory)
|
||||
yield* Effect.forEach(updates, (update) => configTest.emitChange(update), { discard: true })
|
||||
yield* Effect.forEach(updates, (update) => watcher.emit(update), { discard: true })
|
||||
yield* advance(() => received === 1)
|
||||
yield* Fiber.join(changed)
|
||||
}).pipe(Effect.provide(Config.testLayer([directoryEntry(tmp.path)]))),
|
||||
@@ -455,7 +463,7 @@ Use native v2 fields.`,
|
||||
yield* Effect.promise(() => fs.mkdir(directory, { recursive: true }))
|
||||
|
||||
const agents = yield* Agent.Service
|
||||
const configTest = yield* Config.Test
|
||||
const watcher = yield* Watcher.Test
|
||||
let reloads = 0
|
||||
yield* ConfigAgentPlugin.Plugin.effect(
|
||||
host({
|
||||
@@ -468,14 +476,14 @@ Use native v2 fields.`,
|
||||
yield* Effect.yieldNow
|
||||
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(directory, "reviewer.md"), "Review once"))
|
||||
yield* configTest.emitChange({ type: "create", path: path.join(directory, "reviewer.md") })
|
||||
yield* configTest.emitChange({ type: "update", path: path.join(directory, "reviewer.md") })
|
||||
yield* configTest.emitChange({ type: "update", path: path.join(directory, "reviewer.md") })
|
||||
yield* watcher.emit({ type: "create", path: path.join(directory, "reviewer.md") })
|
||||
yield* watcher.emit({ type: "update", path: path.join(directory, "reviewer.md") })
|
||||
yield* watcher.emit({ type: "update", path: path.join(directory, "reviewer.md") })
|
||||
yield* advance(() => reloads >= 1)
|
||||
expect(reloads).toBe(1)
|
||||
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(directory, "reviewer.md"), "Review twice"))
|
||||
yield* configTest.emitChange({ type: "update", path: path.join(directory, "reviewer.md") })
|
||||
yield* watcher.emit({ type: "update", path: path.join(directory, "reviewer.md") })
|
||||
yield* advance(() => reloads >= 2)
|
||||
expect(reloads).toBe(2)
|
||||
expect(yield* agents.get(Agent.ID.make("reviewer"))).toMatchObject({ system: "Review twice" })
|
||||
@@ -495,7 +503,7 @@ Use native v2 fields.`,
|
||||
yield* Effect.promise(() => fs.mkdir(directory, { recursive: true }))
|
||||
|
||||
const agents = yield* Agent.Service
|
||||
const configTest = yield* Config.Test
|
||||
const watcher = yield* Watcher.Test
|
||||
let reloads = 0
|
||||
yield* ConfigAgentPlugin.Plugin.effect(
|
||||
host({
|
||||
@@ -506,20 +514,119 @@ Use native v2 fields.`,
|
||||
}),
|
||||
)
|
||||
|
||||
yield* configTest.emitChange({ type: "create", path: path.join(tmp.path, "commands", "review.md") })
|
||||
yield* configTest.emitChange({ type: "update", path: path.join(tmp.path, "opencode.json") })
|
||||
yield* watcher.emit({ type: "create", path: path.join(tmp.path, "commands", "review.md") })
|
||||
yield* watcher.emit({ type: "update", path: path.join(tmp.path, "opencode.json") })
|
||||
yield* drain
|
||||
expect(reloads).toBe(0)
|
||||
|
||||
// The feed stays live after unrelated updates.
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(directory, "reviewer.md"), "Review related"))
|
||||
yield* configTest.emitChange({ type: "create", path: path.join(directory, "reviewer.md") })
|
||||
yield* watcher.emit({ type: "create", path: path.join(directory, "reviewer.md") })
|
||||
yield* advance(() => reloads >= 1)
|
||||
expect(yield* agents.get(Agent.ID.make("reviewer"))).toMatchObject({ system: "Review related" })
|
||||
}).pipe(Effect.provide(Config.testLayer([directoryEntry(tmp.path)]))),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("loads a source directory created after startup without consuming Config.changes", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped()
|
||||
const agents = yield* Agent.Service
|
||||
const watcher = yield* Watcher.Test
|
||||
let changesCalls = 0
|
||||
let reloads = 0
|
||||
yield* ConfigAgentPlugin.Plugin.effect(
|
||||
host({
|
||||
agent: {
|
||||
...agentHost(agents),
|
||||
reload: () => agents.reload().pipe(Effect.tap(() => Effect.sync(() => reloads++))),
|
||||
},
|
||||
}),
|
||||
).pipe(
|
||||
Effect.provideService(Config.Service, {
|
||||
entries: () => Effect.succeed([directoryEntry(tmp.path)]),
|
||||
changes: () => {
|
||||
changesCalls++
|
||||
return Stream.die("unused Config.changes")
|
||||
},
|
||||
}),
|
||||
)
|
||||
expect((yield* watcher.subscriptions()).map((input) => input.path)).toEqual([tmp.path])
|
||||
expect(yield* agents.get(Agent.ID.make("team/helper"))).toBeUndefined()
|
||||
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(path.join(tmp.path, "agents", "team"), { recursive: true })
|
||||
await fs.writeFile(path.join(tmp.path, "agents", "team", "helper.md"), "Help after startup")
|
||||
})
|
||||
yield* watcher.emit({ type: "create", path: path.join(tmp.path, "agents") })
|
||||
yield* advance(() => reloads >= 1)
|
||||
expect(yield* agents.get(Agent.ID.make("team/helper"))).toMatchObject({ system: "Help after startup" })
|
||||
expect(changesCalls).toBe(0)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reconciles supplied roots and inline agents on config.updated without reacquiring unchanged roots", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped()
|
||||
const previous = path.join(tmp.path, "previous")
|
||||
const next = path.join(tmp.path, "next")
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(path.join(previous, "agents"), { recursive: true })
|
||||
await fs.mkdir(path.join(next, "agents"), { recursive: true })
|
||||
await fs.writeFile(path.join(previous, "agents", "reviewer.md"), "Review previous root")
|
||||
await fs.writeFile(path.join(next, "agents", "release.md"), "Review next root")
|
||||
})
|
||||
yield* Effect.gen(function* () {
|
||||
const agents = yield* Agent.Service
|
||||
const bus = yield* Bus.Service
|
||||
const config = yield* Config.Test
|
||||
const watcher = yield* Watcher.Test
|
||||
let reloads = 0
|
||||
yield* ConfigAgentPlugin.Plugin.effect(
|
||||
host({
|
||||
agent: {
|
||||
...agentHost(agents),
|
||||
reload: () => agents.reload().pipe(Effect.tap(() => Effect.sync(() => reloads++))),
|
||||
},
|
||||
event: { subscribe: () => bus.subscribe(Event.Updated) },
|
||||
}),
|
||||
)
|
||||
expect(yield* agents.get(Agent.ID.make("reviewer"))).toMatchObject({ system: "Review previous root" })
|
||||
const initialSubscriptions = yield* watcher.subscriptions()
|
||||
expect(initialSubscriptions.map((input) => input.path)).toEqual([previous])
|
||||
|
||||
yield* config.setEntries([
|
||||
directoryEntry(previous),
|
||||
new Document({ type: "document", info: decode({ agents: { inline: { system: "Inline refreshed" } } }) }),
|
||||
])
|
||||
yield* bus.publish(Event.Updated, {})
|
||||
yield* advance(() => reloads >= 1)
|
||||
expect(yield* agents.get(Agent.ID.make("inline"))).toMatchObject({ system: "Inline refreshed" })
|
||||
expect(yield* watcher.subscriptions()).toEqual(initialSubscriptions)
|
||||
|
||||
yield* config.setEntries([
|
||||
directoryEntry(next),
|
||||
new Document({ type: "document", info: decode({ agents: { inline: { system: "Inline destination" } } }) }),
|
||||
])
|
||||
yield* bus.publish(Event.Updated, {})
|
||||
yield* advance(() => reloads >= 2)
|
||||
expect(yield* agents.get(Agent.ID.make("reviewer"))).toBeUndefined()
|
||||
expect(yield* agents.get(Agent.ID.make("release"))).toMatchObject({ system: "Review next root" })
|
||||
expect(yield* agents.get(Agent.ID.make("inline"))).toMatchObject({ system: "Inline destination" })
|
||||
expect((yield* watcher.subscriptions()).map((input) => input.path)).toEqual([previous, next])
|
||||
|
||||
yield* watcher.emit({ type: "update", path: path.join(previous, "agents", "reviewer.md") })
|
||||
yield* drain
|
||||
expect(reloads).toBe(2)
|
||||
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(next, "agents", "release.md"), "Review destination update"))
|
||||
yield* watcher.emit({ type: "update", path: path.join(next, "agents", "release.md") })
|
||||
yield* advance(() => reloads >= 3)
|
||||
expect(yield* agents.get(Agent.ID.make("release"))).toMatchObject({ system: "Review destination update" })
|
||||
}).pipe(Effect.provide(Config.testLayer([directoryEntry(previous)])))
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
function directoryEntry(directory: string) {
|
||||
|
||||
@@ -14,18 +14,13 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { WellKnown } from "@opencode-ai/core/wellknown"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Mcp } from "@opencode-ai/core/mcp/index"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { ShellSelect } from "@opencode-ai/core/shell/select"
|
||||
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
|
||||
import { emptyCredentialNode, emptyWellknownNode } from "../fixture/config-nodes"
|
||||
import { emptyConfigLayer, emptyMcpLayer, testLocationLayer } from "../fixture/mcp"
|
||||
import { location } from "../fixture/location"
|
||||
import { tmpdir } from "../fixture/tmpdir"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { host } from "../plugin/host"
|
||||
@@ -41,14 +36,23 @@ const shellLayer = Layer.succeed(
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Command.node, Bus.node, FSUtil.node, AppProcess.node, Location.node, ShellSelect.node]),
|
||||
LayerNode.group([
|
||||
Command.node,
|
||||
Bus.node,
|
||||
FSUtil.node,
|
||||
AppProcess.node,
|
||||
Location.node,
|
||||
ShellSelect.node,
|
||||
Watcher.node,
|
||||
]),
|
||||
[
|
||||
[Mcp.node, emptyMcpLayer],
|
||||
[Config.node, emptyConfigLayer],
|
||||
[Location.node, testLocationLayer],
|
||||
[ShellSelect.node, shellLayer],
|
||||
[Watcher.node, Watcher.testLayer],
|
||||
],
|
||||
),
|
||||
).pipe(Layer.merge(Watcher.testLayer)),
|
||||
)
|
||||
const decode = Schema.decodeUnknownSync(Info)
|
||||
|
||||
@@ -178,7 +182,7 @@ Review files`,
|
||||
|
||||
const command = yield* Command.Service
|
||||
const bus = yield* Bus.Service
|
||||
const configTest = yield* Config.Test
|
||||
const watcher = yield* Watcher.Test
|
||||
yield* ConfigCommandPlugin.Plugin.effect(
|
||||
host({
|
||||
command: {
|
||||
@@ -202,7 +206,7 @@ Review files`,
|
||||
yield* Effect.yieldNow
|
||||
|
||||
const updates = yield* testCase.mutate(directory)
|
||||
yield* Effect.forEach(updates, (update) => configTest.emitChange(update), { discard: true })
|
||||
yield* Effect.forEach(updates, (update) => watcher.emit(update), { discard: true })
|
||||
yield* advance(() => received === 1)
|
||||
yield* Fiber.join(changed)
|
||||
}).pipe(Effect.provide(Config.testLayer([directoryEntry(tmp.path)]))),
|
||||
@@ -222,7 +226,7 @@ Review files`,
|
||||
yield* Effect.promise(() => fs.mkdir(directory, { recursive: true }))
|
||||
|
||||
const command = yield* Command.Service
|
||||
const configTest = yield* Config.Test
|
||||
const watcher = yield* Watcher.Test
|
||||
let reloads = 0
|
||||
yield* ConfigCommandPlugin.Plugin.effect(
|
||||
host({
|
||||
@@ -235,16 +239,16 @@ Review files`,
|
||||
)
|
||||
yield* Effect.yieldNow
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(directory, "review.md"), "Review once"))
|
||||
yield* configTest.emitChange({ type: "create", path: path.join(directory, "review.md") })
|
||||
yield* configTest.emitChange({ type: "update", path: path.join(directory, "review.md") })
|
||||
yield* configTest.emitChange({ type: "update", path: path.join(directory, "review.md") })
|
||||
yield* watcher.emit({ type: "create", path: path.join(directory, "review.md") })
|
||||
yield* watcher.emit({ type: "update", path: path.join(directory, "review.md") })
|
||||
yield* watcher.emit({ type: "update", path: path.join(directory, "review.md") })
|
||||
yield* advance(() => reloads >= 1)
|
||||
expect(reloads).toBe(1)
|
||||
|
||||
yield* Effect.promise(() =>
|
||||
fs.writeFile(path.join(directory, "review.md"), markdown("Review twice", "Review twice")),
|
||||
)
|
||||
yield* configTest.emitChange({ type: "update", path: path.join(directory, "review.md") })
|
||||
yield* watcher.emit({ type: "update", path: path.join(directory, "review.md") })
|
||||
yield* advance(() => reloads >= 2)
|
||||
expect(reloads).toBe(2)
|
||||
expect((yield* command.get("review"))?.description).toBe("Review twice")
|
||||
@@ -264,7 +268,7 @@ Review files`,
|
||||
yield* Effect.promise(() => fs.mkdir(directory, { recursive: true }))
|
||||
|
||||
const command = yield* Command.Service
|
||||
const configTest = yield* Config.Test
|
||||
const watcher = yield* Watcher.Test
|
||||
let reloads = 0
|
||||
yield* ConfigCommandPlugin.Plugin.effect(
|
||||
host({
|
||||
@@ -276,8 +280,8 @@ Review files`,
|
||||
}),
|
||||
)
|
||||
|
||||
yield* configTest.emitChange({ type: "create", path: path.join(tmp.path, "notes", "todo.md") })
|
||||
yield* configTest.emitChange({ type: "update", path: path.join(tmp.path, "opencode.json") })
|
||||
yield* watcher.emit({ type: "create", path: path.join(tmp.path, "notes", "todo.md") })
|
||||
yield* watcher.emit({ type: "update", path: path.join(tmp.path, "opencode.json") })
|
||||
yield* drain
|
||||
expect(reloads).toBe(0)
|
||||
|
||||
@@ -285,32 +289,99 @@ Review files`,
|
||||
yield* Effect.promise(() =>
|
||||
fs.writeFile(path.join(directory, "review.md"), markdown("Review related", "Review related")),
|
||||
)
|
||||
yield* configTest.emitChange({ type: "create", path: path.join(directory, "review.md") })
|
||||
yield* watcher.emit({ type: "create", path: path.join(directory, "review.md") })
|
||||
yield* advance(() => reloads >= 1)
|
||||
expect((yield* command.get("review"))?.description).toBe("Review related")
|
||||
}).pipe(Effect.provide(Config.testLayer([directoryEntry(tmp.path)]))),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("reconciles supplied roots and inline commands on config updates", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const previous = path.join(tmp.path, "previous")
|
||||
const next = path.join(tmp.path, "next")
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(path.join(previous, "commands"), { recursive: true })
|
||||
await fs.writeFile(path.join(previous, "commands", "old.md"), "Old command")
|
||||
await fs.mkdir(next)
|
||||
})
|
||||
|
||||
const command = yield* Command.Service
|
||||
const bus = yield* Bus.Service
|
||||
const config = yield* Config.Test
|
||||
const watcher = yield* Watcher.Test
|
||||
const inline = new Document({
|
||||
type: "document",
|
||||
info: decode({ commands: { inline: { template: "Inline command", description: "Inline command" } } }),
|
||||
})
|
||||
yield* config.setEntries([directoryEntry(previous)])
|
||||
let reloads = 0
|
||||
yield* ConfigCommandPlugin.Plugin.effect(
|
||||
host({
|
||||
command: {
|
||||
list: () => Effect.die("unused command.list"),
|
||||
transform: command.transform,
|
||||
reload: () => command.reload().pipe(Effect.tap(() => Effect.sync(() => reloads++))),
|
||||
},
|
||||
event: { subscribe: () => bus.subscribe(Event.Updated) },
|
||||
}),
|
||||
)
|
||||
expect(yield* command.get("old")).toBeDefined()
|
||||
const subscriptions = yield* watcher.subscriptions()
|
||||
expect(subscriptions.map((input) => input.path)).toEqual([previous])
|
||||
|
||||
yield* config.setEntries([directoryEntry(previous), inline])
|
||||
yield* bus.publish(Event.Updated, {})
|
||||
yield* advance(() => reloads === 1)
|
||||
expect((yield* command.get("inline"))?.description).toBe("Inline command")
|
||||
expect(yield* watcher.subscriptions()).toEqual(subscriptions)
|
||||
|
||||
yield* config.setEntries([directoryEntry(next), inline])
|
||||
yield* bus.publish(Event.Updated, {})
|
||||
yield* advance(() => reloads === 2)
|
||||
expect(yield* command.get("old")).toBeUndefined()
|
||||
expect((yield* command.get("inline"))?.description).toBe("Inline command")
|
||||
expect((yield* watcher.subscriptions()).map((input) => input.path)).toEqual([previous, next])
|
||||
|
||||
// Only the root existed when subscribed; a directory event must reload it.
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(path.join(next, "commands"))
|
||||
await fs.writeFile(path.join(next, "commands", "new.md"), markdown("New command", "New command"))
|
||||
})
|
||||
yield* watcher.emit({ type: "create", path: path.join(next, "commands") })
|
||||
yield* advance(() => reloads === 3)
|
||||
expect((yield* command.get("new"))?.description).toBe("New command")
|
||||
|
||||
yield* watcher.emit({ type: "update", path: path.join(previous, "commands", "old.md") })
|
||||
yield* drain
|
||||
expect(reloads).toBe(3)
|
||||
expect(yield* command.get("old")).toBeUndefined()
|
||||
expect((yield* watcher.subscriptions()).map((input) => input.path)).toEqual([previous, next])
|
||||
}).pipe(Effect.provide(Config.testLayer())),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const describeNative = Watcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip
|
||||
|
||||
// End-to-end proof for #37429: a real file edit reaches the command registry
|
||||
// through the native watcher, Config's watch topology, the source filter, and
|
||||
// the debounced reload — no mocked change feed.
|
||||
// through the plugin's own root watch, source filter, and debounced reload.
|
||||
describeNative("ConfigCommandPlugin native watcher", () => {
|
||||
it.live("reloads commands from real file edits", () =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
// Watcher events report real paths, so resolve the tempdir symlink up front.
|
||||
const tmp = yield* fs.makeTempDirectoryScoped({ prefix: "opencode-core-test-" }).pipe(Effect.flatMap(fs.realPath))
|
||||
const global = path.join(tmp, "global")
|
||||
yield* fs.makeDirectory(path.join(global, "commands"), { recursive: true })
|
||||
yield* fs.makeDirectory(path.join(tmp, "project"))
|
||||
yield* Effect.gen(function* () {
|
||||
const command = yield* Command.Service
|
||||
const config = yield* Config.Service
|
||||
const watcher = yield* Watcher.Service
|
||||
const bus = yield* Bus.Service
|
||||
yield* ConfigCommandPlugin.Plugin.effect(
|
||||
host({
|
||||
@@ -321,51 +392,32 @@ describeNative("ConfigCommandPlugin native watcher", () => {
|
||||
},
|
||||
}),
|
||||
)
|
||||
yield* watchReady(config, global)
|
||||
yield* watchReady(watcher, tmp)
|
||||
|
||||
const created = yield* nextCommandUpdate(bus)
|
||||
yield* fs.writeFileString(
|
||||
path.join(global, "commands", "review.md"),
|
||||
markdown("Review native", "Review native"),
|
||||
)
|
||||
yield* fs.makeDirectory(path.join(tmp, "commands"))
|
||||
yield* fs.writeFileString(path.join(tmp, "commands", "review.md"), markdown("Review native", "Review native"))
|
||||
yield* Fiber.join(created).pipe(Effect.timeout("10 seconds"))
|
||||
expect((yield* command.get("review"))?.description).toBe("Review native")
|
||||
|
||||
const updated = yield* nextCommandUpdate(bus)
|
||||
yield* fs.writeFileString(
|
||||
path.join(global, "commands", "review.md"),
|
||||
path.join(tmp, "commands", "review.md"),
|
||||
markdown("Review native again", "Review native again"),
|
||||
)
|
||||
yield* Fiber.join(updated).pipe(Effect.timeout("10 seconds"))
|
||||
expect((yield* command.get("review"))?.description).toBe("Review native again")
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([
|
||||
Command.node,
|
||||
Config.node,
|
||||
Bus.node,
|
||||
FSUtil.node,
|
||||
AppProcess.node,
|
||||
Global.node,
|
||||
Location.node,
|
||||
ShellSelect.node,
|
||||
]),
|
||||
[
|
||||
[
|
||||
Location.node,
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make(path.join(tmp, "project")) })),
|
||||
),
|
||||
],
|
||||
[Global.node, Global.layerWith({ config: global, home: path.join(global, "home") })],
|
||||
[ShellSelect.node, shellLayer],
|
||||
[Credential.node, emptyCredentialNode],
|
||||
[WellKnown.node, emptyWellknownNode],
|
||||
],
|
||||
Effect.provide([
|
||||
AppNodeBuilder.build(Watcher.node),
|
||||
Layer.succeed(
|
||||
Config.Service,
|
||||
Config.Service.of({
|
||||
entries: () => Effect.succeed([directoryEntry(tmp)]),
|
||||
changes: () => Stream.die("unused config.changes"),
|
||||
}),
|
||||
),
|
||||
),
|
||||
]),
|
||||
)
|
||||
}),
|
||||
)
|
||||
@@ -378,17 +430,23 @@ function nextCommandUpdate(bus: Bus.Interface) {
|
||||
}
|
||||
|
||||
// Native directory watches start asynchronously; probe with unrelated files
|
||||
// until the change feed delivers so command edits afterwards cannot be missed.
|
||||
function watchReady(config: Config.Interface, directory: string) {
|
||||
// until the shared root watch delivers so command edits afterwards cannot be missed.
|
||||
function watchReady(watcher: Watcher.Interface, directory: string) {
|
||||
return Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const seen = yield* Deferred.make<void>()
|
||||
const listener = yield* config.changes().pipe(
|
||||
const probe = path.join(directory, ".watch-probe")
|
||||
const updates = yield* watcher.subscribe({
|
||||
path: directory,
|
||||
type: "directory",
|
||||
ignore: ["node_modules", ".git", "**/{node_modules,.git}/**"],
|
||||
})
|
||||
const listener = yield* updates.pipe(
|
||||
Stream.filter((update) => update.path === probe),
|
||||
Stream.runForEach(() => Deferred.succeed(seen, undefined).pipe(Effect.asVoid)),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
yield* Effect.yieldNow
|
||||
const probe = path.join(directory, ".watch-probe")
|
||||
while (true) {
|
||||
yield* fs.writeFileString(probe, `ready-${Math.random()}`)
|
||||
const result = yield* Deferred.await(seen).pipe(Effect.timeoutOption("250 millis"))
|
||||
@@ -399,7 +457,7 @@ function watchReady(config: Config.Interface, directory: string) {
|
||||
}).pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: "10 seconds",
|
||||
orElse: () => Effect.fail(new Error("timed out waiting for the config watch to become ready")),
|
||||
orElse: () => Effect.fail(new Error("timed out waiting for the command source watch to become ready")),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { ConfigSourceWatch } from "@opencode-ai/core/config/source-watch"
|
||||
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Directory, Document, Info } from "@opencode-ai/schema/config"
|
||||
import { Deferred, Effect, Fiber, Layer, Stream } from "effect"
|
||||
import { it } from "../lib/effect"
|
||||
|
||||
describe("ConfigSourceWatch", () => {
|
||||
it.effect("shares root watches, retains unchanged roots, and releases removed roots and the plugin scope", () => {
|
||||
const counts = { starts: 0, stops: 0 }
|
||||
const native = Watcher.Native.of({
|
||||
subscribe: (input) =>
|
||||
Effect.sync(() => {
|
||||
expect(input.type).toBe("directory")
|
||||
expect(input.ignore).toEqual(["**/{node_modules,.git}/**", ".git", "node_modules"])
|
||||
counts.starts++
|
||||
return {
|
||||
unsubscribe: () => {
|
||||
counts.stops++
|
||||
return Promise.resolve()
|
||||
},
|
||||
}
|
||||
}),
|
||||
})
|
||||
return Effect.gen(function* () {
|
||||
yield* Effect.gen(function* () {
|
||||
const root = directory("source")
|
||||
const agents = yield* ConfigSourceWatch.make(["agents"])
|
||||
const commands = yield* ConfigSourceWatch.make(["commands"])
|
||||
yield* agents.reconcile([root])
|
||||
yield* commands.reconcile([root, root, new Document({ type: "document", info: new Info({}) })])
|
||||
yield* Effect.yieldNow
|
||||
expect(counts).toEqual({ starts: 1, stops: 0 })
|
||||
|
||||
yield* agents.reconcile([root])
|
||||
yield* Effect.yieldNow
|
||||
expect(counts).toEqual({ starts: 1, stops: 0 })
|
||||
yield* agents.reconcile([])
|
||||
expect(counts.stops).toBe(0)
|
||||
yield* commands.reconcile([])
|
||||
expect(counts.stops).toBe(1)
|
||||
|
||||
yield* agents.reconcile([root])
|
||||
yield* Effect.yieldNow
|
||||
expect(counts).toEqual({ starts: 2, stops: 1 })
|
||||
}).pipe(Effect.scoped)
|
||||
expect(counts).toEqual({ starts: 2, stops: 2 })
|
||||
}).pipe(withNative(native))
|
||||
})
|
||||
|
||||
it.effect("scope shutdown interrupts pending native watch acquisition", () =>
|
||||
Effect.gen(function* () {
|
||||
const started = yield* Deferred.make<void>()
|
||||
const stopped = yield* Deferred.make<void>()
|
||||
const native = Watcher.Native.of({
|
||||
subscribe: () =>
|
||||
Deferred.succeed(started, undefined).pipe(
|
||||
Effect.andThen(Effect.never),
|
||||
Effect.onInterrupt(() => Deferred.succeed(stopped, undefined)),
|
||||
),
|
||||
})
|
||||
yield* Effect.gen(function* () {
|
||||
const plugin = yield* Effect.gen(function* () {
|
||||
const sources = yield* ConfigSourceWatch.make(["agents"])
|
||||
yield* sources.reconcile([directory("pending")])
|
||||
yield* Effect.never
|
||||
}).pipe(Effect.scoped, Effect.forkScoped({ startImmediately: true }))
|
||||
yield* Deferred.await(started)
|
||||
yield* Fiber.interrupt(plugin)
|
||||
expect(yield* Deferred.isDone(stopped)).toBe(true)
|
||||
}).pipe(withNative(native))
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("disabled watching does not prevent source reconciliation", () =>
|
||||
Effect.gen(function* () {
|
||||
const sources = yield* ConfigSourceWatch.make(["agents"])
|
||||
yield* sources.reconcile([directory("disabled")])
|
||||
yield* sources.reconcile([])
|
||||
expect(yield* sources.changes.pipe(Stream.runHead, Effect.timeoutOption("1 millis"))).toMatchObject({
|
||||
_tag: "None",
|
||||
})
|
||||
}).pipe(Effect.provide(Watcher.layer({ enabled: false }).pipe(Layer.provide(Watcher.nativeLayer)))),
|
||||
)
|
||||
})
|
||||
|
||||
function withNative(native: Watcher.NativeInterface) {
|
||||
return Effect.provide(Watcher.layer().pipe(Layer.provide(Layer.succeed(Watcher.Native, native))))
|
||||
}
|
||||
|
||||
function directory(name: string) {
|
||||
return new Directory({ type: "directory", path: AbsolutePath.make(path.resolve(name)) })
|
||||
}
|
||||
Reference in New Issue
Block a user