fix(cli): await plugin activation before caching ACP catalog (#46682)

This commit is contained in:
Kit Langton 2026-09-02 10:44:10 -04:00 committed by GitHub
parent fe4ea1d693
commit 74fbe199af
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 628 additions and 69 deletions

View file

@ -400,7 +400,8 @@ function turnStart(messageID: string, slash: PreparedPrompt["slash"], skill: Ski
async function loadCatalog(client: OpenCodeClient, cwd: string): Promise<Catalog> {
const location = { directory: cwd }
// Location plugins initialize asynchronously, so the first ACP request may observe an empty catalog.
await client.plugin.awaitActivation({ location })
// Some providers discover models in the background after activation has settled.
const deadline = Date.now() + 5_000
let missing = "No models are available"
while (Date.now() < deadline) {

View file

@ -1,9 +1,50 @@
import { describe, expect, test } from "bun:test"
import type { McpServer, SessionConfigOption } from "@agentclientprotocol/sdk"
import { makeACPFixture, makeSession, secondModel } from "./service-fixture"
import { makeACPFixture, makeSession, secondModel, testModel } from "./service-fixture"
import { flattenSelectOptions, requireSelectOption } from "./subprocess"
describe("acp service directory behavior", () => {
test("does not cache an available model before plugin activation settles", async () => {
const requested = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
let ready = false
await using fixture = makeACPFixture({
fetch(request) {
requested.resolve()
if (request.path === "/api/plugin/await-activation") {
return release.promise.then(() => {
ready = true
return new Response(null, { status: 204 })
})
}
if (!ready && request.path === "/api/model") {
return Response.json({ data: [{ ...testModel, providerID: "ambient" }] })
}
if (!ready && request.path === "/api/model/default") {
return Response.json({ data: { ...testModel, providerID: "ambient" } })
}
if (request.path === "/api/session" && request.method === "POST") {
return Response.json({ data: { ...makeSession("ses_ready"), model: undefined } })
}
return undefined
},
})
const pending = fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
try {
await requested.promise
expect(fixture.requests.map((request) => request.path)).toEqual(["/api/plugin/await-activation"])
expect(fixture.requests[0]?.query["location[directory]"]).toBe("/workspace")
release.resolve()
expect(currentValue(await pending, "model")).toBe("test/test-model")
expect(
fixture.requests.find((request) => request.path === "/api/session" && request.method === "POST")?.body,
).toMatchObject({ model: { providerID: "test", id: "test-model" } })
} finally {
release.resolve()
await pending.catch(() => {})
}
})
test("creates sessions from a catalog shared by concurrent callers in the same cwd", async () => {
let created = 0
await using fixture = makeACPFixture({
@ -27,7 +68,14 @@ describe("acp service directory behavior", () => {
expect(currentValue(first[0], "model")).toBe("test/test-model")
expect(currentValue(first[0], "mode")).toBe("build")
expect(
["/api/model", "/api/model/default", "/api/agent", "/api/command", "/api/skill"].map((path) =>
[
"/api/plugin/await-activation",
"/api/model",
"/api/model/default",
"/api/agent",
"/api/command",
"/api/skill",
].map((path) =>
fixture.requests
.filter((request) => request.path === path)
.map((request) => request.query["location[directory]"]),
@ -38,6 +86,7 @@ describe("acp service directory behavior", () => {
["/workspace", "/other"],
["/workspace", "/other"],
["/workspace", "/other"],
["/workspace", "/other"],
])
expect(
fixture.requests

View file

@ -152,6 +152,7 @@ export function makeACPFixture(options: FixtureOptions = {}) {
const directory = request.query["location[directory]"] ?? "/workspace"
const location = { directory, project: { id: "global", directory } }
if (request.path === "/api/plugin/await-activation") return new Response(null, { status: 204 })
if (request.path === "/api/event") {
let controller: ReadableStreamDefaultController<Uint8Array> | undefined
return new Response(

View file

@ -18,6 +18,7 @@ describe("acp service", () => {
body: request.method === "GET" ? undefined : await request.json().catch(() => undefined),
})
const location = { directory: "/workspace", project: { id: "global", directory: "/workspace" } }
if (url.pathname === "/api/plugin/await-activation") return new Response(null, { status: 204 })
if (url.pathname === "/api/model") return Response.json({ location, data: [model] })
if (url.pathname === "/api/model/default") return Response.json({ location, data: model })
if (url.pathname === "/api/agent") return Response.json({ location, data: [agent] })

View file

@ -88,6 +88,14 @@ export type PluginListInput = {
export type PluginListOutput = { readonly location: Location.Info; readonly data: ReadonlyArray<Plugin.Info> }
export type PluginListOperation<E = never> = (input?: PluginListInput) => Effect.Effect<PluginListOutput, E>
export type PluginAwaitActivationInput = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type PluginAwaitActivationOutput = void
export type PluginAwaitActivationOperation<E = never> = (
input?: PluginAwaitActivationInput,
) => Effect.Effect<PluginAwaitActivationOutput, E>
export type PluginCheckInput = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly target?: string | undefined
@ -104,6 +112,7 @@ export type PluginUpdateOperation<E = never> = (input: PluginUpdateInput) => Eff
export interface PluginApi<E = never> {
readonly list: PluginListOperation<E>
readonly awaitActivation: PluginAwaitActivationOperation<E>
readonly check: PluginCheckOperation<E>
readonly update: PluginUpdateOperation<E>
}

View file

@ -15,6 +15,8 @@ import type {
AgentGetOutput,
PluginListInput,
PluginListOutput,
PluginAwaitActivationInput,
PluginAwaitActivationOutput,
PluginCheckInput,
PluginCheckOutput,
PluginUpdateInput,
@ -326,6 +328,11 @@ const EndpointPluginList = (raw: RawClient["server.plugin"]) => (input?: PluginL
raw["plugin.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const EndpointPluginAwaitActivation = (raw: RawClient["server.plugin"]) => (input?: PluginAwaitActivationInput) =>
preserveEffect<PluginAwaitActivationOutput>()(
raw["plugin.awaitActivation"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const EndpointPluginCheck = (raw: RawClient["server.plugin"]) => (input?: PluginCheckInput) =>
preserveEffect<PluginCheckOutput>()(
raw["plugin.check"]({ query: { location: input?.["location"] }, payload: { target: input?.["target"] } }).pipe(
@ -342,6 +349,7 @@ const EndpointPluginUpdate = (raw: RawClient["server.plugin"]) => (input: Plugin
const adaptGroupPlugin = (raw: RawClient["server.plugin"]) => ({
list: EndpointPluginList(raw),
awaitActivation: EndpointPluginAwaitActivation(raw),
check: EndpointPluginCheck(raw),
update: EndpointPluginUpdate(raw),
})

View file

@ -9,6 +9,8 @@ import type {
AgentGetOutput,
PluginListInput,
PluginListOutput,
PluginAwaitActivationInput,
PluginAwaitActivationOutput,
PluginCheckInput,
PluginCheckOutput,
PluginUpdateInput,
@ -470,6 +472,18 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
awaitActivation: (input?: PluginAwaitActivationInput, requestOptions?: RequestOptions) =>
request<PluginAwaitActivationOutput>(
{
method: "POST",
path: `/api/plugin/await-activation`,
query: { location: input?.["location"] },
successStatus: 204,
declaredStatuses: [400, 401],
empty: true,
},
requestOptions,
),
check: (input?: PluginCheckInput, requestOptions?: RequestOptions) =>
request<PluginCheckOutput>(
{

View file

@ -2585,6 +2585,14 @@ export type PluginListOutput = {
data: Array<PluginInfo>
}
export type PluginAwaitActivationInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type PluginAwaitActivationOutput = void
export type PluginCheckInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined

View file

@ -486,6 +486,82 @@
"summary": "List plugins"
}
},
"/api/plugin/await-activation": {
"post": {
"tags": ["plugin"],
"operationId": "v2.plugin.awaitActivation",
"parameters": [
{
"name": "location",
"in": "query",
"schema": {
"anyOf": [
{
"type": "object",
"properties": {
"directory": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"workspace": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
{
"type": "null"
}
]
},
"required": false,
"style": "deepObject",
"explode": true
}
],
"security": [],
"responses": {
"204": {
"description": "<No Content>"
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
}
},
"description": "Wait for configured plugin activation at a Location to settle, including missing-package installs. Completion does not imply every plugin succeeded or background resource discovery finished. Cancelling this wait does not cancel activation.",
"summary": "Wait for plugin activation"
}
},
"/api/plugin/check": {
"post": {
"tags": ["plugin"],

View file

@ -20,6 +20,21 @@ export const PluginGroup = HttpApiGroup.make("server.plugin")
}),
),
)
.add(
HttpApiEndpoint.post("plugin.awaitActivation", "/api/plugin/await-activation", {
query: LocationQuery,
success: HttpApiSchema.NoContent,
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.plugin.awaitActivation",
summary: "Wait for plugin activation",
description:
"Wait for configured plugin activation at a Location to settle, including missing-package installs. Completion does not imply every plugin succeeded or background resource discovery finished. Cancelling this wait does not cancel activation.",
}),
),
)
.add(
HttpApiEndpoint.post("plugin.check", "/api/plugin/check", {
query: LocationQuery,

View file

@ -13,6 +13,7 @@ export const PluginHandler = HttpApiBuilder.group(Api, "server.plugin", (handler
return yield* response(Plugin.Service.use((plugin) => plugin.list()))
}),
)
.handle("plugin.awaitActivation", () => Plugin.Service.use((plugin) => plugin.awaitActivation))
.handle("plugin.check", (ctx) =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service

View file

@ -0,0 +1,192 @@
import { expect } from "bun:test"
import { mkdir } from "node:fs/promises"
import path from "node:path"
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
import { Plugin } from "@opencode-ai/plugin/effect"
import { Context, Deferred, Effect, Fiber, Layer } from "effect"
import { HttpEffect, HttpRouter, HttpServer } from "effect/unstable/http"
import { tmpdirScoped } from "../../core/test/fixture/tmpdir"
import { it } from "../../core/test/lib/effect"
import { createRoutes } from "../src/routes"
const fixture = Effect.fn(function* (plugin: Plugin.Plugin) {
const tmp = yield* tmpdirScoped("opencode-plugin-activation-")
const first = path.join(tmp.path, "first")
const second = path.join(tmp.path, "second")
const config = path.join(tmp.path, "config")
yield* Effect.promise(() => Promise.all([first, second, config].map((directory) => mkdir(directory))))
const context = yield* Layer.build(
createRoutes({
password: "secret",
database: { path: ":memory:" },
models: { fetch: false },
fs: { filewatcher: false },
config: {
directory: config,
project: false,
content: JSON.stringify({
providers: {
acme: {
models: {
reasoner: { name: "Configured Reasoner", limit: { context: 96_000, output: 8_000 } },
},
},
},
}),
},
}).pipe(Layer.provide(HttpServer.layerServices)),
)
const sdk = Context.get(context, SdkPlugins.Service)
yield* sdk.register(plugin)
const handler = Context.get(context, HttpRouter.HttpRouter).asHttpEffect().pipe(HttpEffect.toWebHandlerWith(context))
return {
first,
second,
request: (method: "GET" | "POST", route: string, directory = first, signal?: AbortSignal) =>
Effect.promise((interruption) => {
const url = new URL(route, "http://opencode.local")
url.searchParams.set("location[directory]", directory)
return handler(
new Request(url, {
method,
headers: { authorization: `Basic ${btoa("opencode:secret")}` },
signal: signal ?? interruption,
}),
)
}),
}
})
it.live(
"awaits activation only for the requested location without blocking model or plugin snapshots",
() =>
Effect.gen(function* () {
const started = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const server = yield* fixture(
Plugin.define({
id: "slow-plugin",
effect: (ctx) =>
Effect.gen(function* () {
if (path.basename(ctx.location.directory) !== "first") return
yield* Deferred.succeed(started, undefined)
yield* Deferred.await(release)
}),
}),
)
const pending = yield* server.request("POST", "/api/plugin/await-activation").pipe(Effect.forkScoped)
yield* Deferred.await(started)
expect(pending.pollUnsafe()).toBeUndefined()
const models = yield* server.request("GET", "/api/model")
expect(models.status).toBe(200)
expect(yield* Effect.promise(() => models.json())).toMatchObject({
location: { directory: server.first },
data: expect.not.arrayContaining([expect.objectContaining({ providerID: "acme", id: "reasoner" })]),
})
const plugins = yield* server.request("GET", "/api/plugin")
expect(plugins.status).toBe(200)
expect(yield* Effect.promise(() => plugins.json())).toMatchObject({ location: { directory: server.first } })
const second = yield* server.request("POST", "/api/plugin/await-activation", server.second)
expect(second.status).toBe(204)
expect(pending.pollUnsafe()).toBeUndefined()
yield* Deferred.succeed(release, undefined)
const response = yield* Fiber.join(pending)
expect(response.status).toBe(204)
expect(yield* Effect.promise(() => response.text())).toBe("")
const configured = yield* server.request("GET", "/api/model")
expect(configured.status).toBe(200)
expect(yield* Effect.promise(() => configured.json())).toMatchObject({
location: { directory: server.first },
data: expect.arrayContaining([
expect.objectContaining({
providerID: "acme",
id: "reasoner",
name: "Configured Reasoner",
limit: { context: 96_000, output: 8_000 },
}),
]),
})
const active = yield* server.request("GET", "/api/plugin")
expect(active.status).toBe(200)
expect(yield* Effect.promise(() => active.json())).toMatchObject({
data: expect.arrayContaining([
expect.objectContaining({ id: "slow-plugin", source: { type: "sdk" }, state: { status: "active" } }),
]),
})
}).pipe(Effect.timeout("10 seconds")),
15_000,
)
it.live(
"aborting an activation wait does not cancel plugin setup",
() =>
Effect.gen(function* () {
const started = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const completed = yield* Deferred.make<void>()
const interrupted = yield* Deferred.make<void>()
const server = yield* fixture(
Plugin.define({
id: "slow-plugin",
effect: () =>
Effect.gen(function* () {
yield* Deferred.succeed(started, undefined)
yield* Deferred.await(release)
yield* Deferred.succeed(completed, undefined)
}).pipe(Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined))),
}),
)
const controller = new AbortController()
yield* Effect.addFinalizer(() => Effect.sync(() => controller.abort()))
const pending = yield* server
.request("POST", "/api/plugin/await-activation", server.first, controller.signal)
.pipe(Effect.forkScoped)
yield* Deferred.await(started)
controller.abort()
// HttpEffect resolves a cancelled Web request with 499 rather than rejecting its Promise.
expect((yield* Fiber.join(pending)).status).toBe(499)
expect(yield* Deferred.isDone(interrupted)).toBe(false)
expect(yield* Deferred.isDone(completed)).toBe(false)
yield* Deferred.succeed(release, undefined)
expect((yield* server.request("POST", "/api/plugin/await-activation")).status).toBe(204)
expect(yield* Deferred.isDone(completed)).toBe(true)
expect(yield* Deferred.isDone(interrupted)).toBe(false)
const plugins = yield* server.request("GET", "/api/plugin")
expect(plugins.status).toBe(200)
expect(yield* Effect.promise(() => plugins.json())).toMatchObject({
data: expect.arrayContaining([expect.objectContaining({ id: "slow-plugin", state: { status: "active" } })]),
})
}).pipe(Effect.timeout("10 seconds")),
15_000,
)
it.live(
"settles activation when plugin setup fails and exposes the failure in the inventory",
() =>
Effect.gen(function* () {
const server = yield* fixture(
Plugin.define({
id: "failing-plugin",
effect: () => Effect.die(new Error("fixture setup failed")),
}),
)
expect((yield* server.request("POST", "/api/plugin/await-activation")).status).toBe(204)
const plugins = yield* server.request("GET", "/api/plugin")
expect(plugins.status).toBe(200)
expect(yield* Effect.promise(() => plugins.json())).toMatchObject({
location: { directory: server.first },
data: expect.arrayContaining([
expect.objectContaining({
id: "failing-plugin",
source: { type: "sdk" },
state: { status: "failed", error: expect.stringContaining("fixture setup failed") },
}),
]),
})
}).pipe(Effect.timeout("10 seconds")),
15_000,
)

View file

@ -1,74 +1,106 @@
import fs from "node:fs/promises"
import path from "node:path"
import { expect } from "bun:test"
import { Effect, Schedule } from "effect"
import { tmpdir } from "../../core/test/fixture/tmpdir"
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
import { Plugin } from "@opencode-ai/plugin/effect"
import { Context, Deferred, Effect, Fiber, Layer } from "effect"
import { HttpEffect, HttpRouter, HttpServer } from "effect/unstable/http"
import { tmpdirScoped } from "../../core/test/fixture/tmpdir"
import { it } from "../../core/test/lib/effect"
import { startServer } from "./fixture/server"
import { createRoutes } from "../src/routes"
it.live(
"lists providers without blocking on plugin initialization",
"lists and gets providers without blocking on plugin initialization",
() =>
Effect.gen(function* () {
const fixture = yield* configuredProvider("opencode-provider-list-endpoint-")
const url = new URL("/api/provider", fixture.server.base)
url.searchParams.set("location[directory]", fixture.path)
yield* Effect.promise(async () => {
const response = await fetch(url, { headers: fixture.server.headers })
if (response.status !== 200) return false
const body: unknown = await response.json()
return isRecord(body) && Array.isArray(body["data"])
? body["data"].some((provider) => isRecord(provider) && provider["id"] === "custom")
: false
}).pipe(
Effect.filterOrFail((found) => found),
Effect.retry(Schedule.spaced("10 millis")),
Effect.timeout("2 seconds"),
)
}),
15_000,
)
it.live(
"gets providers without blocking on plugin initialization",
() =>
Effect.gen(function* () {
const fixture = yield* configuredProvider("opencode-provider-get-endpoint-")
const url = new URL("/api/provider/custom", fixture.server.base)
url.searchParams.set("location[directory]", fixture.path)
const body: unknown = yield* Effect.tryPromise({
try: async () => {
const response = await fetch(url, { headers: fixture.server.headers })
if (response.status !== 200) throw new Error(`Provider not ready: ${response.status}`)
return response.json()
},
catch: (cause) => cause,
}).pipe(Effect.retry(Schedule.spaced("10 millis")), Effect.timeout("2 seconds"))
if (!isRecord(body) || !isRecord(body["data"])) throw new Error("Expected a provider response")
expect(body["data"]["id"]).toBe("custom")
}),
15_000,
)
const configuredProvider = Effect.fnUntraced(function* (prefix: string) {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir(prefix)))
yield* Effect.promise(() =>
fs.writeFile(
path.join(tmp.path, "opencode.json"),
JSON.stringify({
providers: {
custom: {
package: "@opencode-ai/ai/providers/openai-compatible",
settings: { apiKey: "secret" },
models: { chat: {} },
const tmp = yield* tmpdirScoped("opencode-provider-endpoints-")
const started = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const context = yield* Layer.build(
createRoutes({
password: "secret",
database: { path: ":memory:" },
models: { fetch: false },
fs: { filewatcher: false },
config: {
directory: tmp.path,
project: false,
content: JSON.stringify({
providers: {
custom: {
name: "Configured Custom Provider",
package: "@opencode-ai/ai/providers/openai-compatible",
settings: { apiKey: "secret" },
models: { chat: {} },
},
},
}),
},
},
}),
),
)
return { server: yield* startServer(tmp.path), path: tmp.path }
})
}).pipe(Layer.provide(HttpServer.layerServices)),
)
const sdk = Context.get(context, SdkPlugins.Service)
yield* sdk.register(
Plugin.define({
id: "slow-plugin",
effect: () =>
Effect.gen(function* () {
yield* Deferred.succeed(started, undefined)
yield* Deferred.await(release)
}),
}),
)
const handler = Context.get(context, HttpRouter.HttpRouter)
.asHttpEffect()
.pipe(HttpEffect.toWebHandlerWith(context))
const request = (method: "GET" | "POST", route: string) =>
Effect.promise((signal) => {
const url = new URL(route, "http://opencode.local")
url.searchParams.set("location[directory]", tmp.path)
return handler(
new Request(url, {
method,
headers: { authorization: `Basic ${btoa("opencode:secret")}` },
signal,
}),
)
})
const pending = yield* request("POST", "/api/plugin/await-activation").pipe(Effect.forkScoped)
yield* Deferred.await(started)
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
// Config providers activate after SDK plugins; reads must return the current snapshot without waiting.
const list = yield* request("GET", "/api/provider").pipe(Effect.timeout("2 seconds"))
expect(list.status).toBe(200)
expect(yield* Effect.promise(() => list.json())).toMatchObject({
location: { directory: tmp.path },
data: expect.not.arrayContaining([expect.objectContaining({ id: "custom" })]),
})
const get = yield* request("GET", "/api/provider/custom").pipe(Effect.timeout("2 seconds"))
expect(get.status).toBe(404)
expect(yield* Effect.promise(() => get.json())).toMatchObject({
_tag: "ProviderNotFoundError",
providerID: "custom",
})
expect(pending.pollUnsafe()).toBeUndefined()
yield* Deferred.succeed(release, undefined)
expect((yield* Fiber.join(pending)).status).toBe(204)
const provider = {
id: "custom",
name: "Configured Custom Provider",
activation: "enabled",
package: "@opencode-ai/ai/providers/openai-compatible",
settings: { apiKey: "secret" },
}
const configuredList = yield* request("GET", "/api/provider").pipe(Effect.timeout("2 seconds"))
expect(configuredList.status).toBe(200)
expect(yield* Effect.promise(() => configuredList.json())).toMatchObject({
location: { directory: tmp.path },
data: expect.arrayContaining([expect.objectContaining(provider)]),
})
const configuredGet = yield* request("GET", "/api/provider/custom").pipe(Effect.timeout("2 seconds"))
expect(configuredGet.status).toBe(200)
expect(yield* Effect.promise(() => configuredGet.json())).toMatchObject({
location: { directory: tmp.path },
data: provider,
})
}),
15_000,
)

View file

@ -486,6 +486,82 @@
"summary": "List plugins"
}
},
"/api/plugin/await-activation": {
"post": {
"tags": ["plugin"],
"operationId": "v2.plugin.awaitActivation",
"parameters": [
{
"name": "location",
"in": "query",
"schema": {
"anyOf": [
{
"type": "object",
"properties": {
"directory": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"workspace": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
{
"type": "null"
}
]
},
"required": false,
"style": "deepObject",
"explode": true
}
],
"security": [],
"responses": {
"204": {
"description": "<No Content>"
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
}
},
"description": "Wait for configured plugin activation at a Location to settle, including missing-package installs. Completion does not imply every plugin succeeded or background resource discovery finished. Cancelling this wait does not cancel activation.",
"summary": "Wait for plugin activation"
}
},
"/api/plugin/check": {
"post": {
"tags": ["plugin"],

View file

@ -486,6 +486,82 @@
"summary": "List plugins"
}
},
"/api/plugin/await-activation": {
"post": {
"tags": ["plugin"],
"operationId": "v2.plugin.awaitActivation",
"parameters": [
{
"name": "location",
"in": "query",
"schema": {
"anyOf": [
{
"type": "object",
"properties": {
"directory": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"workspace": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
{
"type": "null"
}
]
},
"required": false,
"style": "deepObject",
"explode": true
}
],
"security": [],
"responses": {
"204": {
"description": "<No Content>"
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
}
}
}
}
},
"description": "Wait for configured plugin activation at a Location to settle, including missing-package installs. Completion does not imply every plugin succeeded or background resource discovery finished. Cancelling this wait does not cancel activation.",
"summary": "Wait for plugin activation"
}
},
"/api/plugin/check": {
"post": {
"tags": ["plugin"],