Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions packages/core/src/config/plugin/source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ export const layer = Layer.effect(
const watched = new Set<string>()

// Configured local plugin files can live outside config roots, where the
// config change feed cannot see them; watch those entrypoints directly.
// config change feed cannot see them. A generated dist directory needs a
// stable parent watch because replacing dist destroys a watch inside it.
// Watches start on first sighting and are never torn down individually:
// a stale watch after a config edit costs one deduped fs handle and a
// no-op activation, and every watch dies with this layer's scope.
Expand All @@ -58,8 +59,15 @@ export const layer = Layer.effect(
// inside), so don't watch what can't trigger anything.
if (yield* fs.isDir(operation.target)) continue
watched.add(operation.target)
const updates = yield* watcher.subscribe({ path: operation.target, type: "file" })
const directory = path.dirname(operation.target)
const generated = path.basename(directory) === "dist"
const updates = yield* watcher.subscribe(
generated
? { path: path.dirname(directory), type: "directory" }
: { path: operation.target, type: "file" },
)
yield* updates.pipe(
Stream.filter((update) => !generated || path.resolve(update.path) === operation.target),
Stream.runForEach(() => PubSub.publish(configuredChanges, undefined)),
Effect.catchCause((cause) =>
Effect.logError("configured plugin watch failed", { target: operation.target, cause }),
Expand Down
11 changes: 10 additions & 1 deletion packages/core/src/plugin/supervisor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
pre: readonly Plugin.Versioned[],
post: readonly Plugin.Versioned[],
operations: readonly ConfigPluginSource.Operation[],
previousPackages: ReadonlyMap<string, Plugin.Versioned>,
) {
const matches = (selector: string, target: string) =>
selector === "*" || (selector.endsWith(".*") ? target.startsWith(selector.slice(0, -1)) : selector === target)
Expand Down Expand Up @@ -83,6 +84,11 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
error: plugin.error,
tui: false,
})
const previous = previousPackages.get(operation.target)
if (previous) {
packages.set(operation.target, previous)
enabled.add(previous.id)
}
continue
}
failures.delete(operation.target)
Expand All @@ -99,6 +105,7 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
...post.filter((plugin) => enabled.has(plugin.id)),
],
failures: [...failures.values()],
packages,
}
})

Expand Down Expand Up @@ -138,6 +145,7 @@ export const layer = Layer.effect(
const sources = yield* ConfigPluginSource.Service
const bus = yield* Bus.Service
const ready = yield* Latch.make()
let packages = new Map<string, Plugin.Versioned>()
let observed = 0

const activate = Effect.fn("PluginSupervisor.activate")(function* () {
Expand All @@ -155,9 +163,10 @@ export const layer = Layer.effect(
}))
const operations = yield* sources.operations()
// Apply config operations and load enabled package plugins into one ordered generation.
const resolved = yield* resolve(pre, post, operations)
const resolved = yield* resolve(pre, post, operations, packages)
// Replace the active generation in one scoped, batched activation.
yield* registry.activate(resolved.plugins, resolved.failures)
packages = resolved.packages
})
const updates = Stream.merge(sources.changes(), bus.subscribe([Event.Updated, SdkPlugins.Updated])).pipe(
// Make accepted work visible to flush before coalescing the burst.
Expand Down
27 changes: 22 additions & 5 deletions packages/core/test/config/plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -321,13 +321,13 @@ describe("PluginSupervisor config", () => {

it.live("reloads a configured plugin when its source file changes", () =>
withLocation(
{ plugins: ["-*", "./external/mutable.ts"] },
{ plugins: ["-*", "./external/dist/index.ts"] },
Effect.gen(function* () {
yield* ready()
const agents = yield* Agent.Service
const bus = yield* Bus.Service
const location = yield* Location.Service
const file = path.join(location.directory, "external", "mutable.ts")
const file = path.join(location.directory, "external", "dist", "index.ts")

expect((yield* agents.get(Agent.ID.make("mutable")))?.description).toBe("first")

Expand All @@ -342,14 +342,31 @@ describe("PluginSupervisor config", () => {
yield* Fiber.join(changed).pipe(Effect.timeout("5 seconds"))

expect((yield* agents.get(Agent.ID.make("mutable")))?.description).toBe("second")

const removed = yield* bus
.subscribe(Plugin.Event.Updated)
.pipe(Stream.take(1), Stream.runDrain, Effect.forkScoped({ startImmediately: true }))
yield* Effect.promise(() => fs.rm(path.dirname(file), { recursive: true }))
yield* Fiber.join(removed).pipe(Effect.timeout("5 seconds"))
expect((yield* agents.get(Agent.ID.make("mutable")))?.description).toBe("second")

const recreated = yield* bus
.subscribe(Plugin.Event.Updated)
.pipe(Stream.take(1), Stream.runDrain, Effect.forkScoped({ startImmediately: true }))
yield* Effect.promise(async () => {
await fs.mkdir(path.dirname(file), { recursive: true })
await fs.writeFile(file, mutablePlugin("third"))
})
yield* Fiber.join(recreated).pipe(Effect.timeout("5 seconds"))
expect((yield* agents.get(Agent.ID.make("mutable")))?.description).toBe("third")
}),
false,
async (directory) => {
// Outside any {plugin,plugins} config-source directory, so only the
// configured-entrypoint watch can observe the edit.
const external = path.join(directory, "external")
await fs.mkdir(external, { recursive: true })
await fs.writeFile(path.join(external, "mutable.ts"), mutablePlugin("first"))
const dist = path.join(directory, "external", "dist")
await fs.mkdir(dist, { recursive: true })
await fs.writeFile(path.join(dist, "index.ts"), mutablePlugin("first"))
},
),
)
Expand Down
Loading