refactor(core): derive config watches from entries
This commit is contained in:
+29
-19
@@ -119,6 +119,11 @@ export class Directory extends Schema.Class<Directory>("Config.Directory")({
|
|||||||
path: AbsolutePath,
|
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")({
|
export class AgentsDirectory extends Schema.Class<AgentsDirectory>("Config.AgentsDirectory")({
|
||||||
type: Schema.Literal("agents"),
|
type: Schema.Literal("agents"),
|
||||||
path: AbsolutePath,
|
path: AbsolutePath,
|
||||||
@@ -129,7 +134,7 @@ export class ClaudeDirectory extends Schema.Class<ClaudeDirectory>("Config.Claud
|
|||||||
path: AbsolutePath,
|
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 {
|
export function latest<K extends keyof Info>(entries: readonly Entry[], key: K): Info[K] | undefined {
|
||||||
return entries
|
return entries
|
||||||
@@ -138,7 +143,7 @@ export function latest<K extends keyof Info>(entries: readonly Entry[], key: K):
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface Interface {
|
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[]>
|
readonly entries: () => Effect.Effect<Entry[]>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,31 +232,36 @@ const layer = Layer.effect(
|
|||||||
const directPaths = discovered
|
const directPaths = discovered
|
||||||
.filter((item) => ![".agents", ".claude", ".opencode"].includes(path.basename(item)))
|
.filter((item) => ![".agents", ".claude", ".opencode"].includes(path.basename(item)))
|
||||||
.toReversed()
|
.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.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)
|
const supplementary = yield* Effect.forEach(directories, loadDirectory).pipe(Effect.orDie)
|
||||||
return {
|
return [...claude, ...agents, ...(supplementary[0] ?? []), ...direct, ...supplementary.slice(1).flat()]
|
||||||
entries: [...claude, ...agents, ...(supplementary[0] ?? []), ...direct, ...supplementary.slice(1).flat()],
|
|
||||||
directories: [...directories, ...claude.map((entry) => entry.path), ...agents.map((entry) => entry.path)],
|
|
||||||
files: directPaths,
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const initial = yield* discover()
|
const initial = yield* discover()
|
||||||
let configs = initial.entries
|
let configs = initial
|
||||||
const updates = yield* PubSub.unbounded<Watcher.Update>()
|
const updates = yield* PubSub.unbounded<Watcher.Update>()
|
||||||
const subscriptions = new Map<string, Effect.Effect<unknown>>()
|
const subscriptions = new Map<string, Effect.Effect<unknown>>()
|
||||||
const targets = (snapshot: typeof initial) => [
|
const reconcile = Effect.fn("Config.reconcileWatches")(function* (entries: readonly Entry[]) {
|
||||||
...snapshot.directories.map((path) => ({ path, type: "directory" as const })),
|
const directories = entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : []))
|
||||||
...snapshot.files
|
const files = entries.flatMap((entry) => (entry.type === "file" ? [entry.path] : []))
|
||||||
.filter((file) => !snapshot.directories.some((directory) => FSUtil.contains(directory, file)))
|
const targets = [
|
||||||
.map((path) => ({ path, type: "file" as const })),
|
...directories.map((path) => ({ path, type: "directory" as const })),
|
||||||
]
|
...files
|
||||||
const reconcile = Effect.fn("Config.reconcileWatches")(function* (snapshot: typeof initial) {
|
.filter((file) => !directories.some((directory) => FSUtil.contains(directory, file)))
|
||||||
const next = new Map(targets(snapshot).map((target) => [JSON.stringify(target), target]))
|
.map((path) => ({ path, type: "file" as const })),
|
||||||
|
]
|
||||||
|
const next = new Map(targets.map((target) => [JSON.stringify(target), target]))
|
||||||
for (const [key, stop] of subscriptions) {
|
for (const [key, stop] of subscriptions) {
|
||||||
if (next.has(key)) continue
|
if (next.has(key)) continue
|
||||||
yield* stop
|
yield* stop
|
||||||
@@ -272,7 +282,7 @@ const layer = Layer.effect(
|
|||||||
Stream.runForEach((update) =>
|
Stream.runForEach((update) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const next = yield* discover()
|
const next = yield* discover()
|
||||||
configs = next.entries
|
configs = next
|
||||||
yield* reconcile(next)
|
yield* reconcile(next)
|
||||||
yield* events.publish(ConfigSchema.Event.Updated, {})
|
yield* events.publish(ConfigSchema.Event.Updated, {})
|
||||||
}).pipe(Effect.catchCause((cause) => Effect.logError("failed to reload config", { path: update.path, cause }))),
|
}).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", () =>
|
it.live("loads opencode JSON and JSONC files from lowest to highest priority", () =>
|
||||||
Effect.acquireRelease(
|
Effect.acquireRelease(
|
||||||
Effect.promise(() => tmpdir()),
|
Effect.promise(() => tmpdir()),
|
||||||
@@ -915,8 +952,11 @@ describe("Config", () => {
|
|||||||
"global",
|
"global",
|
||||||
AbsolutePath.make(global),
|
AbsolutePath.make(global),
|
||||||
"root",
|
"root",
|
||||||
|
AbsolutePath.make(path.join(root, "opencode.json")),
|
||||||
"parent",
|
"parent",
|
||||||
|
AbsolutePath.make(path.join(parent, "opencode.jsonc")),
|
||||||
"directory",
|
"directory",
|
||||||
|
AbsolutePath.make(path.join(directory, "opencode.json")),
|
||||||
"root-dot",
|
"root-dot",
|
||||||
AbsolutePath.make(path.join(root, ".opencode")),
|
AbsolutePath.make(path.join(root, ".opencode")),
|
||||||
"directory-dot",
|
"directory-dot",
|
||||||
|
|||||||
Reference in New Issue
Block a user