mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-14 03:56:55 +03:00
feat(tui): announce server plugin failures (#47103)
Show grouped server plugin failure notices with an Open plugins action, retain the home failure count, and reveal failed built-ins in the plugin dialog. Keep unchanged failures quiet across inventory refreshes and reconnects.
This commit is contained in:
committed by
GitHub
Unverified
parent
a222401f19
commit
2dcfc89fab
@@ -52,7 +52,11 @@ function Plugins(props: { context: Plugin.Context }) {
|
||||
const dimensions = useTerminalDimensions()
|
||||
const visibility = createMemo(() => homeFooterVisibility(dimensions().width))
|
||||
const plugins = usePlugin()
|
||||
const failed = createMemo(() => plugins.list().filter((item) => item.status === "failed").length)
|
||||
const failed = createMemo(
|
||||
() =>
|
||||
plugins.list().filter((item) => item.status === "failed").length +
|
||||
plugins.server().filter((item) => item.state.status === "failed").length,
|
||||
)
|
||||
|
||||
return (
|
||||
<Show when={failed()}>
|
||||
|
||||
@@ -79,7 +79,9 @@ export function PluginsDialog(props: {
|
||||
...serverEntries.sort((a, b) => label(a, props.context).localeCompare(label(b, props.context))),
|
||||
]
|
||||
})
|
||||
const visibleEntries = createMemo(() => entries().filter((entry) => showInternal() || !entry.internal))
|
||||
const visibleEntries = createMemo(() =>
|
||||
entries().filter((entry) => showInternal() || !entry.internal || status(entry) === "failed"),
|
||||
)
|
||||
createEffect(() => {
|
||||
if (visibleEntries().some((entry) => entry.key === focused())) return
|
||||
const first = visibleEntries().find((entry) => entry.runtime === "tui") ?? visibleEntries()[0]
|
||||
@@ -286,9 +288,7 @@ function source(plugin: PluginInfo, context: Plugin.Context) {
|
||||
function isLocal(entry: Entry) {
|
||||
if (entry.runtime === "server") return entry.plugin.source.type === "local"
|
||||
const target = entry.target
|
||||
return (
|
||||
target.startsWith("file://") || target.startsWith("./") || target.startsWith("../") || path.isAbsolute(target)
|
||||
)
|
||||
return target.startsWith("file://") || target.startsWith("./") || target.startsWith("../") || path.isAbsolute(target)
|
||||
}
|
||||
|
||||
function status(entry: Entry) {
|
||||
|
||||
@@ -53,6 +53,7 @@ type RegisteredPlugin = {
|
||||
type Value = {
|
||||
readonly ready: () => boolean
|
||||
readonly list: () => ReadonlyArray<State>
|
||||
readonly server: () => readonly PluginInfo[]
|
||||
readonly registered: () => ReadonlyArray<RegisteredPlugin>
|
||||
readonly route: (id: string, name: string) => Page["render"] | undefined
|
||||
readonly slots: {
|
||||
@@ -90,13 +91,15 @@ export function PluginProvider(props: ParentProps<{ packages: PackageSource; dir
|
||||
const lifecycle = useTuiLifecycle()
|
||||
const client = useClient()
|
||||
const data = useData()
|
||||
const [serverPlugins, setServerPlugins] = createSignal<
|
||||
ReadonlyArray<
|
||||
PluginInfo & { readonly state: { readonly status: "active" } } & {
|
||||
readonly source: { readonly type: "package" } | { readonly type: "local" }
|
||||
}
|
||||
>
|
||||
>([])
|
||||
const [serverPlugins, setServerPlugins] = createSignal<readonly PluginInfo[]>([])
|
||||
const serverTuiPlugins = createMemo(() =>
|
||||
serverPlugins().filter(
|
||||
(plugin): plugin is PluginInfo & { readonly source: { readonly type: "package" } | { readonly type: "local" } } =>
|
||||
plugin.state.status === "active" &&
|
||||
plugin.features.tui === true &&
|
||||
(plugin.source.type === "package" || plugin.source.type === "local"),
|
||||
),
|
||||
)
|
||||
const directory = config.path ? path.dirname(config.path) : process.cwd()
|
||||
const [store, setStore] = createStore({
|
||||
ready: false,
|
||||
@@ -257,7 +260,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageSource; dir
|
||||
install: true,
|
||||
optional: true,
|
||||
})),
|
||||
...serverPlugins().map((plugin) => ({
|
||||
...serverTuiPlugins().map((plugin) => ({
|
||||
entry: plugin.source.type === "package" ? plugin.source.target : path.dirname(plugin.source.path),
|
||||
install: false,
|
||||
optional: true,
|
||||
@@ -473,7 +476,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageSource; dir
|
||||
const resolved = createMemo(() => resolveSlots({ paths: new Set(Object.keys(mounted)), claims: claims() }))
|
||||
createEffect(
|
||||
on(
|
||||
() => JSON.stringify([serverPlugins(), config.data.plugins ?? []]),
|
||||
() => JSON.stringify([serverTuiPlugins(), config.data.plugins ?? []]),
|
||||
() => {
|
||||
npmFailures.clear()
|
||||
void enqueue(reconcile).then(
|
||||
@@ -486,20 +489,26 @@ export function PluginProvider(props: ParentProps<{ packages: PackageSource; dir
|
||||
const syncServerPlugins = () =>
|
||||
client.api.plugin
|
||||
.list({ location: data.location.default() })
|
||||
.then((response) =>
|
||||
setServerPlugins(
|
||||
response.data.filter(
|
||||
(
|
||||
plugin,
|
||||
): plugin is PluginInfo & { readonly state: { readonly status: "active" } } & {
|
||||
readonly source: { readonly type: "package" } | { readonly type: "local" }
|
||||
} =>
|
||||
plugin.state.status === "active" &&
|
||||
plugin.features.tui === true &&
|
||||
(plugin.source.type === "package" || plugin.source.type === "local"),
|
||||
),
|
||||
),
|
||||
)
|
||||
.then((response) => {
|
||||
const failed = response.data.filter(
|
||||
(plugin) =>
|
||||
plugin.state.status === "failed" &&
|
||||
!serverPlugins().some(
|
||||
(previous) =>
|
||||
serverPluginName(previous) === serverPluginName(plugin) && isDeepEqual(previous.state, plugin.state),
|
||||
),
|
||||
)
|
||||
setServerPlugins(response.data)
|
||||
const first = failed[0]
|
||||
if (!first) return
|
||||
host.toast.show({
|
||||
variant: "error",
|
||||
title: failed.length === 1 ? `Plugin failed: ${serverPluginName(first)}` : `${failed.length} plugins failed`,
|
||||
message:
|
||||
(failed.length > 1 ? `${failed.map(serverPluginName).join(", ")}\n` : "") + "Run /plugins to view details.",
|
||||
action: { label: "Open plugins", run: () => host.keymap.dispatch("plugins.list") },
|
||||
})
|
||||
})
|
||||
.catch(() => undefined)
|
||||
createEffect(
|
||||
on(
|
||||
@@ -538,6 +547,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageSource; dir
|
||||
value={{
|
||||
ready: () => store.ready,
|
||||
list: () => store.states,
|
||||
server: serverPlugins,
|
||||
registered: () =>
|
||||
Object.entries(store.registrations).map(([id, plugin]) => ({
|
||||
id,
|
||||
@@ -558,6 +568,17 @@ export function PluginProvider(props: ParentProps<{ packages: PackageSource; dir
|
||||
)
|
||||
}
|
||||
|
||||
function serverPluginName(plugin: PluginInfo) {
|
||||
return (
|
||||
plugin.id ??
|
||||
(plugin.source.type === "package"
|
||||
? plugin.source.target
|
||||
: plugin.source.type === "local"
|
||||
? plugin.source.path
|
||||
: plugin.source.type)
|
||||
)
|
||||
}
|
||||
|
||||
async function disposeAll(cleanups: Dispose[]) {
|
||||
const failures: unknown[] = []
|
||||
for (const cleanup of cleanups.splice(0).reverse()) await cleanup().catch((error) => failures.push(error))
|
||||
|
||||
@@ -9,6 +9,7 @@ import { createEventStream, createFetch, directory, json, type FetchHandler } fr
|
||||
import { tmpdir } from "./fixture/fixture"
|
||||
import type { TuiInput } from "../src/app"
|
||||
import type { Config } from "../src/config"
|
||||
import type { PluginInfo } from "@opencode-ai/client"
|
||||
|
||||
test.each([100, 44])("Ctrl-O is immediate, dismissible, and prunes cached deletions at width %s", async (width) => {
|
||||
await using state = await tmpdir()
|
||||
@@ -1420,6 +1421,116 @@ test.each([100, 44])(
|
||||
},
|
||||
)
|
||||
|
||||
test.each([
|
||||
[100, true],
|
||||
[44, true],
|
||||
[100, false],
|
||||
[44, false],
|
||||
] as const)("server plugin failures are visible at width %s (already failed: %s)", async (width, initial) => {
|
||||
await using state = await tmpdir()
|
||||
const failure: PluginInfo["state"] = {
|
||||
status: "failed",
|
||||
error: "Plugin disabled after command.transform failed. Check server logs for details.",
|
||||
ref: "err_fixture",
|
||||
}
|
||||
let inventory: PluginInfo[] = [
|
||||
{
|
||||
id: "broken",
|
||||
source: { type: "builtin" },
|
||||
features: { server: true },
|
||||
state: initial ? failure : { status: "active" },
|
||||
},
|
||||
{ id: "healthy", source: { type: "builtin" }, features: { server: true }, state: { status: "active" } },
|
||||
]
|
||||
let requests = 0
|
||||
await using setup = await createAppFixture({
|
||||
width,
|
||||
state: state.path,
|
||||
fetch: (url) => {
|
||||
if (url.pathname !== "/api/plugin") return undefined
|
||||
requests++
|
||||
return json({
|
||||
location: { directory, project: { id: "proj_test", directory, canonical: directory } },
|
||||
data: inventory,
|
||||
})
|
||||
},
|
||||
})
|
||||
await setup.ready
|
||||
await setup.waitForFrame((frame) => frame.includes("commands"))
|
||||
if (!initial) {
|
||||
expect(setup.captureCharFrame()).not.toContain("Plugin failed")
|
||||
inventory = inventory.map((plugin) => (plugin.id === "broken" ? { ...plugin, state: failure } : plugin))
|
||||
setup.events.emit({ id: "evt_failure", created: 1, type: "plugin.updated", data: {} })
|
||||
}
|
||||
await setup.waitForFrame((frame) => frame.includes("Plugin failed:") && frame.includes("broken"))
|
||||
expect(setup.captureCharFrame()).toContain("/plugins")
|
||||
expect(setup.captureCharFrame()).toContain("1 plugin failed")
|
||||
|
||||
const lines = setup.captureCharFrame().split("\n")
|
||||
const row = lines.findIndex((line) => line.includes("Open plugins"))
|
||||
expect(row).toBeGreaterThanOrEqual(0)
|
||||
const line = lines[row]
|
||||
if (!line) throw new Error("Open plugins action is missing")
|
||||
await setup.mockMouse.click(line.indexOf("Open plugins"), row)
|
||||
await setup.waitForFrame((frame) => frame.includes("ctrl+a") && frame.includes("broken"))
|
||||
expect(setup.captureCharFrame()).toContain("broken")
|
||||
expect(setup.captureCharFrame()).not.toContain("healthy")
|
||||
setup.mockInput.pressEnter()
|
||||
await setup.waitForFrame((frame) => frame.includes("Server plugin error") && frame.includes("transform failed"))
|
||||
expect(setup.captureCharFrame()).toContain("Plugin disabled")
|
||||
expect(setup.captureCharFrame()).toContain("transform failed")
|
||||
expect(setup.captureCharFrame()).toContain("err_fixture")
|
||||
setup.mockInput.pressEscape()
|
||||
await setup.waitForFrame((frame) => frame.includes("ctrl+a"))
|
||||
setup.mockInput.pressEscape()
|
||||
await setup.waitForFrame((frame) => !frame.includes("ctrl+a"))
|
||||
expect(setup.captureCharFrame()).toContain("1 plugin failed")
|
||||
|
||||
const seen = requests
|
||||
setup.events.emit({ id: "evt_repeat", created: 2, type: "plugin.updated", data: {} })
|
||||
setup.events.emit({ id: "evt_reconnect", type: "server.connected", data: {} })
|
||||
await setup.waitFor(() => requests >= seen + 2)
|
||||
await setup.flush()
|
||||
expect(setup.captureCharFrame()).not.toContain("Plugin failed:")
|
||||
|
||||
inventory = inventory.map((plugin) => ({ ...plugin, state: { status: "active" } }))
|
||||
setup.events.emit({ id: "evt_recovered", created: 3, type: "plugin.updated", data: {} })
|
||||
await setup.waitForFrame((frame) => !frame.includes("1 plugin failed"))
|
||||
inventory = inventory.map((plugin) => (plugin.id === "broken" ? { ...plugin, state: failure } : plugin))
|
||||
setup.events.emit({ id: "evt_failed_again", created: 4, type: "plugin.updated", data: {} })
|
||||
await setup.waitForFrame((frame) => frame.includes("Plugin failed:") && frame.includes("broken"))
|
||||
})
|
||||
|
||||
test("server plugin failures share one notice and use source names before an ID is known", async () => {
|
||||
await using state = await tmpdir()
|
||||
await using setup = await createAppFixture({
|
||||
state: state.path,
|
||||
fetch: (url) =>
|
||||
url.pathname === "/api/plugin"
|
||||
? json({
|
||||
location: { directory, project: { id: "proj_test", directory, canonical: directory } },
|
||||
data: [
|
||||
{
|
||||
source: { type: "package", target: "missing-package" },
|
||||
features: {},
|
||||
state: { status: "failed", error: "Package missing" },
|
||||
},
|
||||
{
|
||||
source: { type: "local", path: "/fixture/broken.ts" },
|
||||
features: {},
|
||||
state: { status: "failed", error: "Invalid plugin" },
|
||||
},
|
||||
],
|
||||
})
|
||||
: undefined,
|
||||
})
|
||||
await setup.ready
|
||||
await setup.waitForFrame((frame) => frame.includes("2 plugins failed"))
|
||||
expect(setup.captureCharFrame()).toContain("missing-package")
|
||||
expect(setup.captureCharFrame()).toContain("/fixture/broken.ts")
|
||||
expect(setup.captureCharFrame()).toContain("Open plugins")
|
||||
})
|
||||
|
||||
async function createAppFixture(
|
||||
input: {
|
||||
width?: number
|
||||
|
||||
Reference in New Issue
Block a user