fix(core): bust external plugin import cache
This commit is contained in:
parent
e2faeb84e5
commit
afe3ebbc35
2 changed files with 90 additions and 2 deletions
|
|
@ -3,6 +3,7 @@ export * as ConfigExternalPlugin from "./external"
|
||||||
import type { Plugin as EffectPlugin } from "@opencode-ai/plugin/v2/effect"
|
import type { Plugin as EffectPlugin } from "@opencode-ai/plugin/v2/effect"
|
||||||
import type { Plugin as PromisePlugin } from "@opencode-ai/plugin/v2/promise"
|
import type { Plugin as PromisePlugin } from "@opencode-ai/plugin/v2/promise"
|
||||||
import { Effect, Schema, Stream } from "effect"
|
import { Effect, Schema, Stream } from "effect"
|
||||||
|
import { createRequire } from "node:module"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { fileURLToPath, pathToFileURL } from "url"
|
import { fileURLToPath, pathToFileURL } from "url"
|
||||||
import { Config } from "../../config"
|
import { Config } from "../../config"
|
||||||
|
|
@ -35,6 +36,9 @@ const PluginPackage = Schema.Struct({
|
||||||
module: Schema.optional(Schema.String),
|
module: Schema.optional(Schema.String),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
let importGeneration = 0
|
||||||
|
const moduleCache = createRequire(import.meta.url).cache
|
||||||
|
|
||||||
export const Plugin = define({
|
export const Plugin = define({
|
||||||
id: "config-plugin",
|
id: "config-plugin",
|
||||||
effect: Effect.fn(function* (ctx) {
|
effect: Effect.fn(function* (ctx) {
|
||||||
|
|
@ -106,7 +110,7 @@ export const Plugin = define({
|
||||||
: (yield* npm.add(ref.package)).entrypoint
|
: (yield* npm.add(ref.package)).entrypoint
|
||||||
if (!entrypoint) return
|
if (!entrypoint) return
|
||||||
yield* Effect.log({ msg: "loading plugin", id: ref.package, entrypoint })
|
yield* Effect.log({ msg: "loading plugin", id: ref.package, entrypoint })
|
||||||
const mod = yield* Effect.promise(() => import(entrypoint))
|
const mod = yield* Effect.promise(() => import(cacheBust(entrypoint)))
|
||||||
const value = (yield* Schema.decodeUnknownEffect(PluginModule)(mod)).default
|
const value = (yield* Schema.decodeUnknownEffect(PluginModule)(mod)).default
|
||||||
const plugin = "effect" in value ? value : PluginPromise.fromPromise(value)
|
const plugin = "effect" in value ? value : PluginPromise.fromPromise(value)
|
||||||
return {
|
return {
|
||||||
|
|
@ -114,7 +118,11 @@ export const Plugin = define({
|
||||||
effect: (host: Parameters<typeof plugin.effect>[0]) =>
|
effect: (host: Parameters<typeof plugin.effect>[0]) =>
|
||||||
plugin.effect({ ...host, options: ref.options ?? {} }),
|
plugin.effect({ ...host, options: ref.options ?? {} }),
|
||||||
}
|
}
|
||||||
}).pipe(Effect.catchCause(() => Effect.succeed(undefined))),
|
}).pipe(
|
||||||
|
Effect.catchCause((cause) =>
|
||||||
|
Effect.logError("failed to load plugin", { package: ref.package, cause }).pipe(Effect.as(undefined)),
|
||||||
|
),
|
||||||
|
),
|
||||||
).pipe(Effect.map((plugins) => plugins.filter((plugin) => plugin !== undefined)))
|
).pipe(Effect.map((plugins) => plugins.filter((plugin) => plugin !== undefined)))
|
||||||
})
|
})
|
||||||
const reconcile = Effect.fn("ConfigExternalPlugin.reconcile")(function* () {
|
const reconcile = Effect.fn("ConfigExternalPlugin.reconcile")(function* () {
|
||||||
|
|
@ -137,6 +145,13 @@ export const Plugin = define({
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function cacheBust(entrypoint: string) {
|
||||||
|
const url = path.isAbsolute(entrypoint) ? pathToFileURL(entrypoint) : new URL(entrypoint)
|
||||||
|
if (url.protocol === "file:") delete moduleCache[fileURLToPath(url)]
|
||||||
|
url.searchParams.set("opencode-reload", String(++importGeneration))
|
||||||
|
return url.href
|
||||||
|
}
|
||||||
|
|
||||||
const resolvePackageEntrypoint = Effect.fnUntraced(function* (fs: FSUtil.Interface, directory: string) {
|
const resolvePackageEntrypoint = Effect.fnUntraced(function* (fs: FSUtil.Interface, directory: string) {
|
||||||
const pkg = yield* fs.readJson(path.join(directory, "package.json")).pipe(
|
const pkg = yield* fs.readJson(path.join(directory, "package.json")).pipe(
|
||||||
Effect.flatMap(Schema.decodeUnknownEffect(PluginPackage)),
|
Effect.flatMap(Schema.decodeUnknownEffect(PluginPackage)),
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,19 @@
|
||||||
|
import fs from "fs/promises"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
|
import { Config as ConfigSchema } from "@opencode-ai/schema/config"
|
||||||
import { Effect, Schema } from "effect"
|
import { Effect, Schema } from "effect"
|
||||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||||
import { Config } from "@opencode-ai/core/config"
|
import { Config } from "@opencode-ai/core/config"
|
||||||
import { ConfigExternalPlugin } from "@opencode-ai/core/config/plugin/external"
|
import { ConfigExternalPlugin } from "@opencode-ai/core/config/plugin/external"
|
||||||
|
import { EventV2 } from "@opencode-ai/core/event"
|
||||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||||
import { Location } from "@opencode-ai/core/location"
|
import { Location } from "@opencode-ai/core/location"
|
||||||
import { Npm } from "@opencode-ai/core/npm"
|
import { Npm } from "@opencode-ai/core/npm"
|
||||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||||
|
import { tmpdir } from "../fixture/tmpdir"
|
||||||
import { testEffect } from "../lib/effect"
|
import { testEffect } from "../lib/effect"
|
||||||
import { PluginTestLayer } from "../plugin/fixture"
|
import { PluginTestLayer } from "../plugin/fixture"
|
||||||
|
|
||||||
|
|
@ -240,6 +244,49 @@ describe("ConfigExternalPlugin", () => {
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.live("reloads changed plugin source from the same entrypoint", () =>
|
||||||
|
Effect.acquireRelease(
|
||||||
|
Effect.promise(() => tmpdir()),
|
||||||
|
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||||
|
).pipe(
|
||||||
|
Effect.flatMap((tmp) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const plugins = yield* PluginV2.Service
|
||||||
|
const agents = yield* AgentV2.Service
|
||||||
|
const events = yield* EventV2.Service
|
||||||
|
const fsUtil = yield* FSUtil.Service
|
||||||
|
const location = yield* Location.Service
|
||||||
|
const npm = yield* Npm.Service
|
||||||
|
const host = yield* PluginHost.make(plugins)
|
||||||
|
const plugin = path.join(tmp.path, "plugin.ts")
|
||||||
|
const config = Config.Service.of({
|
||||||
|
entries: () =>
|
||||||
|
Effect.succeed([
|
||||||
|
new Config.Document({
|
||||||
|
type: "document",
|
||||||
|
info: decode({ plugins: [plugin] }),
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
})
|
||||||
|
|
||||||
|
yield* Effect.promise(() => fs.writeFile(plugin, pluginSource("First source")))
|
||||||
|
yield* ConfigExternalPlugin.Plugin.effect(host).pipe(
|
||||||
|
Effect.provideService(PluginV2.Service, plugins),
|
||||||
|
Effect.provideService(FSUtil.Service, fsUtil),
|
||||||
|
Effect.provideService(Location.Service, location),
|
||||||
|
Effect.provideService(Npm.Service, npm),
|
||||||
|
Effect.provideService(Config.Service, config),
|
||||||
|
)
|
||||||
|
expect((yield* waitForAgent(agents, "hot-reload"))?.description).toBe("First source")
|
||||||
|
|
||||||
|
yield* Effect.promise(() => fs.writeFile(plugin, pluginSource("Second source")))
|
||||||
|
yield* events.publish(ConfigSchema.Event.Updated, {})
|
||||||
|
expect(yield* waitForAgentDescription(agents, "hot-reload", "Second source")).toBe(true)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const waitForAgent = Effect.fnUntraced(function* (agents: AgentV2.Interface, id: string) {
|
const waitForAgent = Effect.fnUntraced(function* (agents: AgentV2.Interface, id: string) {
|
||||||
|
|
@ -250,3 +297,29 @@ const waitForAgent = Effect.fnUntraced(function* (agents: AgentV2.Interface, id:
|
||||||
}
|
}
|
||||||
return yield* Effect.die(`Timed out waiting for agent ${id}`)
|
return yield* Effect.die(`Timed out waiting for agent ${id}`)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const waitForAgentDescription = Effect.fnUntraced(function* (
|
||||||
|
agents: AgentV2.Interface,
|
||||||
|
id: string,
|
||||||
|
description: string,
|
||||||
|
) {
|
||||||
|
for (let attempt = 0; attempt < 100; attempt++) {
|
||||||
|
if ((yield* agents.get(AgentV2.ID.make(id)))?.description === description) return true
|
||||||
|
yield* Effect.sleep("10 millis")
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
function pluginSource(description: string) {
|
||||||
|
return `export default {
|
||||||
|
id: "source-hot-reload",
|
||||||
|
setup: async (ctx) => {
|
||||||
|
await ctx.agent.transform((agents) => {
|
||||||
|
agents.update("hot-reload", (agent) => {
|
||||||
|
agent.description = ${JSON.stringify(description)}
|
||||||
|
agent.mode = "subagent"
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue