refactor(core): derive config watches from entries
This commit is contained in:
parent
2db7ccb453
commit
4a006b1210
2 changed files with 69 additions and 19 deletions
|
|
@ -119,6 +119,11 @@ export class Directory extends Schema.Class<Directory>("Config.Directory")({
|
|||
path: AbsolutePath,
|
||||
}) {}
|
||||
|
||||
export class File extends Schema.Class<File>("Config.File")({
|
||||
type: Schema.Literal("file"),
|
||||
path: AbsolutePath,
|
||||
}) {}
|
||||
|
||||
export class AgentsDirectory extends Schema.Class<AgentsDirectory>("Config.AgentsDirectory")({
|
||||
type: Schema.Literal("agents"),
|
||||
path: AbsolutePath,
|
||||
|
|
@ -129,7 +134,7 @@ export class ClaudeDirectory extends Schema.Class<ClaudeDirectory>("Config.Claud
|
|||
path: AbsolutePath,
|
||||
}) {}
|
||||
|
||||
export type Entry = Document | Directory | AgentsDirectory | ClaudeDirectory
|
||||
export type Entry = Document | Directory | File | AgentsDirectory | ClaudeDirectory
|
||||
|
||||
export function latest<K extends keyof Info>(entries: readonly Entry[], key: K): Info[K] | undefined {
|
||||
return entries
|
||||
|
|
@ -138,7 +143,7 @@ export function latest<K extends keyof Info>(entries: readonly Entry[], key: K):
|
|||
}
|
||||
|
||||
export interface Interface {
|
||||
/** Returns location config documents and supplemental directories from lowest to highest priority. */
|
||||
/** Returns location config documents and discovery sources from lowest to highest priority. */
|
||||
readonly entries: () => Effect.Effect<Entry[]>
|
||||
}
|
||||
|
||||
|
|
@ -227,31 +232,36 @@ const layer = Layer.effect(
|
|||
const directPaths = discovered
|
||||
.filter((item) => ![".agents", ".claude", ".opencode"].includes(path.basename(item)))
|
||||
.toReversed()
|
||||
const direct = yield* Effect.forEach(directPaths, loadFile).pipe(
|
||||
const direct = yield* Effect.forEach(directPaths, (filepath) =>
|
||||
loadFile(filepath).pipe(
|
||||
Effect.map((config) => [
|
||||
...(config ? [config] : []),
|
||||
new File({ type: "file", path: AbsolutePath.make(filepath) }),
|
||||
]),
|
||||
),
|
||||
).pipe(
|
||||
Effect.orDie,
|
||||
Effect.map((configs) => configs.filter((config): config is Document => config !== undefined)),
|
||||
Effect.map((entries) => entries.flat()),
|
||||
)
|
||||
|
||||
const supplementary = yield* Effect.forEach(directories, loadDirectory).pipe(Effect.orDie)
|
||||
return {
|
||||
entries: [...claude, ...agents, ...(supplementary[0] ?? []), ...direct, ...supplementary.slice(1).flat()],
|
||||
directories: [...directories, ...claude.map((entry) => entry.path), ...agents.map((entry) => entry.path)],
|
||||
files: directPaths,
|
||||
}
|
||||
return [...claude, ...agents, ...(supplementary[0] ?? []), ...direct, ...supplementary.slice(1).flat()]
|
||||
})
|
||||
|
||||
const initial = yield* discover()
|
||||
let configs = initial.entries
|
||||
let configs = initial
|
||||
const updates = yield* PubSub.unbounded<Watcher.Update>()
|
||||
const subscriptions = new Map<string, Effect.Effect<unknown>>()
|
||||
const targets = (snapshot: typeof initial) => [
|
||||
...snapshot.directories.map((path) => ({ path, type: "directory" as const })),
|
||||
...snapshot.files
|
||||
.filter((file) => !snapshot.directories.some((directory) => FSUtil.contains(directory, file)))
|
||||
.map((path) => ({ path, type: "file" as const })),
|
||||
]
|
||||
const reconcile = Effect.fn("Config.reconcileWatches")(function* (snapshot: typeof initial) {
|
||||
const next = new Map(targets(snapshot).map((target) => [JSON.stringify(target), target]))
|
||||
const reconcile = Effect.fn("Config.reconcileWatches")(function* (entries: readonly Entry[]) {
|
||||
const directories = entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : []))
|
||||
const files = entries.flatMap((entry) => (entry.type === "file" ? [entry.path] : []))
|
||||
const targets = [
|
||||
...directories.map((path) => ({ path, type: "directory" as const })),
|
||||
...files
|
||||
.filter((file) => !directories.some((directory) => FSUtil.contains(directory, file)))
|
||||
.map((path) => ({ path, type: "file" as const })),
|
||||
]
|
||||
const next = new Map(targets.map((target) => [JSON.stringify(target), target]))
|
||||
for (const [key, stop] of subscriptions) {
|
||||
if (next.has(key)) continue
|
||||
yield* stop
|
||||
|
|
@ -272,7 +282,7 @@ const layer = Layer.effect(
|
|||
Stream.runForEach((update) =>
|
||||
Effect.gen(function* () {
|
||||
const next = yield* discover()
|
||||
configs = next.entries
|
||||
configs = next
|
||||
yield* reconcile(next)
|
||||
yield* events.publish(ConfigSchema.Event.Updated, {})
|
||||
}).pipe(Effect.catchCause((cause) => Effect.logError("failed to reload config", { path: update.path, cause }))),
|
||||
|
|
|
|||
|
|
@ -254,6 +254,43 @@ describe("Config", () => {
|
|||
),
|
||||
)
|
||||
|
||||
it.live("does not watch ecosystem config roots", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
Promise.all([
|
||||
fs.mkdir(path.join(tmp.path, ".claude", "skills"), { recursive: true }),
|
||||
fs.mkdir(path.join(tmp.path, ".agents"), { recursive: true }),
|
||||
]),
|
||||
)
|
||||
const targets: Watcher.WatchInput[] = []
|
||||
const watcher = Layer.succeed(
|
||||
Watcher.Service,
|
||||
Watcher.Service.of({
|
||||
subscribe: (input) => {
|
||||
targets.push(input)
|
||||
return Stream.never
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
return yield* Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
yield* config.entries()
|
||||
|
||||
expect(targets).toEqual([
|
||||
{ type: "directory", path: AbsolutePath.make(path.join(tmp.path, "global")) },
|
||||
])
|
||||
}).pipe(Effect.provide(testLayer(tmp.path, undefined, undefined, undefined, watcher)))
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("loads opencode JSON and JSONC files from lowest to highest priority", () =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
|
|
@ -915,8 +952,11 @@ describe("Config", () => {
|
|||
"global",
|
||||
AbsolutePath.make(global),
|
||||
"root",
|
||||
AbsolutePath.make(path.join(root, "opencode.json")),
|
||||
"parent",
|
||||
AbsolutePath.make(path.join(parent, "opencode.jsonc")),
|
||||
"directory",
|
||||
AbsolutePath.make(path.join(directory, "opencode.json")),
|
||||
"root-dot",
|
||||
AbsolutePath.make(path.join(root, ".opencode")),
|
||||
"directory-dot",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue