@rackbops/ac-plugin-contract 2.0.0-alpha.33
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AUTHORING.md +934 -0
- package/LICENSE +201 -0
- package/README.md +29 -0
- package/dist/action-options.d.ts +49 -0
- package/dist/action-options.js +9 -0
- package/dist/attention.d.ts +43 -0
- package/dist/attention.js +12 -0
- package/dist/host-api.d.ts +365 -0
- package/dist/host-api.js +10 -0
- package/dist/index-envelope.d.ts +36 -0
- package/dist/index-envelope.js +2 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +9 -0
- package/dist/issue-filter.d.ts +122 -0
- package/dist/issue-filter.js +152 -0
- package/dist/manifest.d.ts +217 -0
- package/dist/manifest.js +9 -0
- package/dist/ov-widgets.d.ts +21 -0
- package/dist/ov-widgets.js +33 -0
- package/dist/slots.d.ts +139 -0
- package/dist/slots.js +17 -0
- package/dist/version.d.ts +31 -0
- package/dist/version.js +19 -0
- package/dist/views.d.ts +262 -0
- package/dist/views.js +2 -0
- package/host.d.ts +60 -0
- package/package.json +47 -0
- package/schema.json +495 -0
package/AUTHORING.md
ADDED
|
@@ -0,0 +1,934 @@
|
|
|
1
|
+
# Writing an artifact-console plugin
|
|
2
|
+
|
|
3
|
+
A plugin adds to the console without changing it: a nav **panel**, an Overview **card**, a confirm-gated
|
|
4
|
+
**action**, a scheduled **job**, an HTTP **route**, and a **setting** are all contributed from data + a small
|
|
5
|
+
server module. The host validates the plugin's manifest before it runs any of the plugin's code, loads it at
|
|
6
|
+
startup, and surfaces it at `GET /api/plugins`.
|
|
7
|
+
|
|
8
|
+
This guide is written against the reference plugin, [`plugins/hello`](../../plugins/hello), which contributes
|
|
9
|
+
**one of each** of the six points (two jobs: the host job `hello.tick` and the agent job `hello.machines`, #51;
|
|
10
|
+
two actions, `hello.ping` and the option-picker example `hello.order`, #463, whose choices come from a second
|
|
11
|
+
route, `GET /toppings`).
|
|
12
|
+
Every **code** snippet below is excerpted from that plugin, trimmed to what its section is about — the §1
|
|
13
|
+
manifest and §2 server snippets show its first action (`hello.ping`) and route (`/ping`), and the option-picker
|
|
14
|
+
part of §2 shows `hello.order` and `GET /toppings` — copy it and change
|
|
15
|
+
the names. (The §1 `package.json` is shown in two blocks — the `acPlugin` manifest, then the surrounding fields
|
|
16
|
+
— with the manifest's arrays on single lines for readability.) The plugin contract itself is specified in
|
|
17
|
+
[`design.md` §4](../plans/v2/design.md).
|
|
18
|
+
|
|
19
|
+
## 0. Where a plugin lives
|
|
20
|
+
|
|
21
|
+
A plugin is a directory `plugins/<id>/` — a pnpm workspace member (the `plugins/*` glob in
|
|
22
|
+
`pnpm-workspace.yaml` already covers it, so a new dir needs no workspace edit). The host **auto-discovers** every
|
|
23
|
+
subdirectory of `plugins/` whose `package.json` carries an `acPlugin` block; there is no registration list to
|
|
24
|
+
add yourself to. A directory with no `acPlugin` block is simply not a plugin and is skipped. So the whole job is:
|
|
25
|
+
create `plugins/<id>/`, give its `package.json` an `acPlugin` block and the files it points at, and the host
|
|
26
|
+
finds it.
|
|
27
|
+
|
|
28
|
+
## 1. The manifest — `package.json` `acPlugin`
|
|
29
|
+
|
|
30
|
+
The host reads the `acPlugin` block from the plugin's `package.json` and validates it against the generated
|
|
31
|
+
JSON Schema (`packages/contract/schema.json`) **before loading any code**. An invalid manifest is recorded as
|
|
32
|
+
an error and the plugin is skipped — it never runs. The schema is `additionalProperties: false`, so a mistyped
|
|
33
|
+
key is itself a hard validation error, not a silently-ignored field.
|
|
34
|
+
|
|
35
|
+
```json
|
|
36
|
+
"acPlugin": {
|
|
37
|
+
"id": "hello",
|
|
38
|
+
"hostApiVersion": 1,
|
|
39
|
+
"kind": "in-process",
|
|
40
|
+
"server": "dist/server.js",
|
|
41
|
+
"frontend": "dist/ui.js",
|
|
42
|
+
"contributes": {
|
|
43
|
+
"panels": [{ "id": "hello-panel", "title": "Hello", "route": "/hello", "export": "HelloPanel" }],
|
|
44
|
+
"cards": [{ "id": "hello-card", "title": "Hello", "sizes": ["2x1"], "export": "HelloCard" }],
|
|
45
|
+
"actions": [
|
|
46
|
+
{
|
|
47
|
+
"id": "hello.ping",
|
|
48
|
+
"title": "Ping",
|
|
49
|
+
"scope": "global",
|
|
50
|
+
"confirm": true,
|
|
51
|
+
"fields": [{ "name": "message", "label": "Message", "type": "string" }]
|
|
52
|
+
}
|
|
53
|
+
],
|
|
54
|
+
"jobs": [
|
|
55
|
+
{ "id": "hello.tick", "schedule": "*/15 * * * *", "timeoutSec": 30, "runOn": "host" },
|
|
56
|
+
{ "id": "hello.machines", "schedule": "17 3 * * *", "timeoutSec": 30, "runOn": "agent" }
|
|
57
|
+
],
|
|
58
|
+
"routes": [{ "method": "GET", "path": "/ping" }],
|
|
59
|
+
"settings": [{ "key": "greeting", "type": "string", "default": "hello" }],
|
|
60
|
+
"attention": true
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
That `acPlugin` block sits inside an otherwise ordinary `package.json`. The **rest** of the file matters too —
|
|
66
|
+
the workspace ratchet requires a `name`, a `build` script, and an `exports` (or `main`) entry, so a plugin
|
|
67
|
+
missing any of them fails `pnpm run check`. The complete shape (the `acPlugin` block above elided):
|
|
68
|
+
|
|
69
|
+
```jsonc
|
|
70
|
+
{
|
|
71
|
+
"name": "@ac/plugin-hello",
|
|
72
|
+
"version": "0.1.0",
|
|
73
|
+
"private": true,
|
|
74
|
+
"type": "module",
|
|
75
|
+
"exports": { ".": { "default": "./dist/server.js" } },
|
|
76
|
+
"acPlugin": { /* the block shown above */ },
|
|
77
|
+
"scripts": {
|
|
78
|
+
"typecheck": "tsc --noEmit && tsc -p tsconfig.ui.json --noEmit",
|
|
79
|
+
"build": "tsc && tsc -p tsconfig.ui.json",
|
|
80
|
+
"test": "vitest run"
|
|
81
|
+
},
|
|
82
|
+
"dependencies": {
|
|
83
|
+
"@ac/contract": "workspace:*",
|
|
84
|
+
"@rackbops/ui-react": "^0.2.36",
|
|
85
|
+
"react": "^19.2.0"
|
|
86
|
+
},
|
|
87
|
+
"devDependencies": {
|
|
88
|
+
"@types/node": "catalog:",
|
|
89
|
+
"@types/react": "catalog:",
|
|
90
|
+
"typescript": "catalog:",
|
|
91
|
+
"vitest": "catalog:"
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
`"type": "module"` (the emitted `dist/*.js` are ESM), `"exports"` pointing at the built server, and the shared
|
|
97
|
+
dev deps pinned through the workspace `catalog:` (one version of `typescript`/`vitest`/`@types/node`/`@types/react`
|
|
98
|
+
across the repo — run `pnpm install` after adding them). `@rackbops/ui-react` and `react` are only needed if you
|
|
99
|
+
ship a frontend (§3). If that frontend needs `@types/react-dom` too (a real render test, not just
|
|
100
|
+
`@testing-library/react`), put it on the catalog rather than a bare semver range — pnpm can otherwise link a new
|
|
101
|
+
consumer's `@types/react-dom` to a peer-unqualified store path that doesn't exist, failing with `TS2307` (#205).
|
|
102
|
+
The `scripts` and tsconfig split are explained in §3.
|
|
103
|
+
|
|
104
|
+
Rules the host enforces, at validation or load time:
|
|
105
|
+
|
|
106
|
+
- **`id`** must match `/^[a-z][a-z0-9-]*$/` and is **permanent** — it is the plugin's route namespace
|
|
107
|
+
(`/api/x/<id>/…`), its state key and its log prefix. Renaming it orphans a deployed plugin's stored data, so
|
|
108
|
+
never rename it. Plugin `id`, panel `id`/`route`, card `id` and action `id` are **globally unique** across all
|
|
109
|
+
installed plugins. (These, plus `hostApiVersion` equality, are checked by the *validator* before any plugin
|
|
110
|
+
code loads.) **Collisions are origin-aware** (#451): an installed plugin (one found under the operator's
|
|
111
|
+
second, agent-writable plugins directory, `AC_INSTALLED_PLUGINS_DIR`) whose `id` or any contributed
|
|
112
|
+
id/route matches a *bundled* plugin's (one shipped in the image's own `plugins/`) is refused — only the
|
|
113
|
+
installed plugin errors, naming the bundled owner — and the bundled one keeps running unaffected. This
|
|
114
|
+
is what stops a third-party package from disabling a bundled plugin (e.g. the auth provider, or `admin`
|
|
115
|
+
itself) just by declaring its id. Two bundled plugins sharing an id is still a build/CI mistake and both
|
|
116
|
+
error, the same for two installed plugins with no bundled plugin in the collision.
|
|
117
|
+
- **`hostApiVersion`** must equal the host's `HOST_API_VERSION` (currently `1`); a mismatch is rejected.
|
|
118
|
+
- **`kind: "in-process"`** must declare a `server` entry — the *loader* records an error and skips a plugin that
|
|
119
|
+
doesn't (the schema itself requires only `id`, `hostApiVersion` and `kind`). `contributes` and every sub-field
|
|
120
|
+
is optional in the schema — contribute only the points you use; each contribution's own required fields are
|
|
121
|
+
schema-enforced.
|
|
122
|
+
- The manifest is the **declaration**; your server code supplies the runtime halves. An HTTP route and a job must
|
|
123
|
+
be *declared here* or the host will refuse to serve/run them (see §2).
|
|
124
|
+
|
|
125
|
+
## 2. The server — `src/server.ts`
|
|
126
|
+
|
|
127
|
+
The server entry (`dist/server.js`) exports **`createPlugin`**. `createPlugin(host)` must be **pure** — it does
|
|
128
|
+
no host I/O, so the host can construct every plugin before any side effect. All I/O goes inside `activate()`
|
|
129
|
+
(called once at startup) and `runJob(ctx)` (called per scheduled/triggered job run).
|
|
130
|
+
|
|
131
|
+
```ts
|
|
132
|
+
import type { CreatePlugin } from "@ac/contract"
|
|
133
|
+
|
|
134
|
+
export const createPlugin: CreatePlugin = (host) => ({
|
|
135
|
+
activate() {
|
|
136
|
+
// GET /api/x/hello/ping → the greeting setting (its default comes from the manifest `settings[]`).
|
|
137
|
+
host.routes.register("GET", "/ping", async () => {
|
|
138
|
+
const greeting = (await host.settings.get("greeting")) ?? "hello"
|
|
139
|
+
return Response.json({ greeting })
|
|
140
|
+
})
|
|
141
|
+
// A confirm-gated action: the button, confirm and fields come from the manifest; the handler is here.
|
|
142
|
+
host.actions.register({
|
|
143
|
+
id: "hello.ping",
|
|
144
|
+
run: async (input) => ({ echoed: input }),
|
|
145
|
+
})
|
|
146
|
+
},
|
|
147
|
+
runJob(ctx) {
|
|
148
|
+
// One `runJob` dispatches all the plugin's contributed jobs, selected by `ctx.jobId`.
|
|
149
|
+
if (ctx.jobId === "hello.tick") ctx.log("hello tick")
|
|
150
|
+
// hello.machines (#51) calls no verb -- the per-machine fan-out itself is what it demonstrates;
|
|
151
|
+
// #52's real scans are the verb-calling agent jobs. Records nothing on a console with no paired
|
|
152
|
+
// machine, same as any other agent job dispatched to zero machines.
|
|
153
|
+
if (ctx.jobId === "hello.machines") {
|
|
154
|
+
ctx.log(`hello from the host for ${ctx.machine?.name ?? "(no machine)"}`)
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
})
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
- **Route** — `host.routes.register(method, path, handler)`. The handler is a web-standard
|
|
161
|
+
`(request: Request) => Response | Promise<Response>`, mounted at `/api/x/<id><path>`. You may only register a
|
|
162
|
+
`(method, path)` you declared in `contributes.routes` — an undeclared route throws. **Never answer `502` or
|
|
163
|
+
`504` yourself** (#474): the production console is reached through Cloudflare, and Cloudflare's own
|
|
164
|
+
documentation says it "returns a Cloudflare-branded HTTP 502 or 504 error when your origin web server responds
|
|
165
|
+
with a standard HTTP 502 bad gateway or 504 gateway timeout error" — so on the production console (default
|
|
166
|
+
Cloudflare error pages) your JSON `{ ok: false, error }` does not reach the browser and a client sees an HTML
|
|
167
|
+
page. For "a dependency I relay to (a paired agent, the sidecar)
|
|
168
|
+
is unavailable or refused" answer `503`: Cloudflare's Custom Errors page says its Error Pages "do not apply to
|
|
169
|
+
responses with an HTTP status code of `500`, `501`, `503`, or `505`", and its "Error 503" page tells an origin's
|
|
170
|
+
own 503 from Cloudflare's by whether the page's HTML contains `cloudflare`. Neither says in so many words that
|
|
171
|
+
an origin's 503 *body* passes through — that is inferred, and a Custom Error Rule an operator sets on the zone
|
|
172
|
+
can act on any status from 400 up — so the check on the deployed console is what proves it. Otherwise a 4xx or
|
|
173
|
+
`500`. `packages/host/src/no-gateway-status.test.ts` scans every bundled plugin's `src/` and the host's, and
|
|
174
|
+
fails naming the file and line (an installed third-party plugin is not covered).
|
|
175
|
+
- **Action** — `host.actions.register({ id, run })`. The button, `confirm`/`danger` and the modal `fields` all
|
|
176
|
+
come from the manifest; here you supply only `run(input)`. (The action's manifest metadata drives the shell
|
|
177
|
+
UI; registering a `run` for an id the manifest omits simply leaves it without a button.)
|
|
178
|
+
- **Job** — implement `runJob(ctx)` and dispatch on `ctx.jobId`. The host runs only jobs you declared in
|
|
179
|
+
`contributes.jobs`, on their 5-field cron `schedule`. `ctx.log` appends to that run's recorded log;
|
|
180
|
+
`ctx.signal` aborts at `timeoutSec`. `runOn: "host"` runs your `runJob` once, in the host process.
|
|
181
|
+
`runOn: "agent"` (#51) also runs your `runJob` in the host process — the agent itself runs no plugin
|
|
182
|
+
code — but once per currently paired machine, with `ctx.machine` set to `{ id, name }`; do the
|
|
183
|
+
machine's own work through `host.agent.call(ctx.machine.id, …)`. A machine that doesn't answer when
|
|
184
|
+
the job is due is never a failed run: it's queued (a red `queued` row in the tasks drawer) and the
|
|
185
|
+
host's own scheduler tick drains the queue, running the job for that machine as soon as it becomes
|
|
186
|
+
reachable again — nothing in your `runJob` needs to know this happened. `host.jobs.machineRuns(jobId)`
|
|
187
|
+
reads the current per-machine state: one entry per paired machine for an agent job (its last run,
|
|
188
|
+
or why it's still queued), or a single entry for a host job. `plugins/scans` (#258) is the reference
|
|
189
|
+
for a verb-calling agent job: it calls a scan verb per machine and stores the answer only on
|
|
190
|
+
success, keeping a separate `.../error` key for a failure and throwing the agent's reason verbatim
|
|
191
|
+
so the run is honestly recorded failed -- store, log, throw, in that order, never masking the
|
|
192
|
+
agent's text with a storage error of your own.
|
|
193
|
+
- **Setting** — read/write with `host.settings.get(key)` / `host.settings.set(key, value)`. Both are async;
|
|
194
|
+
`get` returns the manifest `default` when unset.
|
|
195
|
+
|
|
196
|
+
`createPlugin` must import from `@ac/contract` **type-only** (`import type`), so the compiled `dist/server.js`
|
|
197
|
+
has no runtime imports — see §6.
|
|
198
|
+
|
|
199
|
+
### Rejecting a bad action input — `ActionInputError` (#440)
|
|
200
|
+
|
|
201
|
+
An action's `run(input)` receives whatever the caller POSTed under `{ "input": … }`. When that input is
|
|
202
|
+
**malformed** (a missing or wrong-typed field, a bad `owner/name`), throw an `Error` whose **`name` is
|
|
203
|
+
`"ActionInputError"`** and the host answers `400` with its message. Any other thrown error is a `500`.
|
|
204
|
+
Carry the class in your own plugin — five lines, no runtime import from the host:
|
|
205
|
+
|
|
206
|
+
```ts
|
|
207
|
+
import type { ActionInputErrorName } from "@ac/contract"
|
|
208
|
+
|
|
209
|
+
export class ActionInputError extends Error {
|
|
210
|
+
override name: ActionInputErrorName = "ActionInputError"
|
|
211
|
+
}
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
Throw it for the **shape of what the caller sent** — that is the caller's to fix. Keep a plain `Error` (a 500)
|
|
215
|
+
for anything that went wrong in the world while acting on a well-formed input: a GitHub failure, a store that
|
|
216
|
+
isn't cloned, a machine that isn't paired. When unsure, leave it a plain `Error`.
|
|
217
|
+
|
|
218
|
+
**Why a name and not a class you import.** A plugin server imports `@ac/contract` as `import type` only (above), so
|
|
219
|
+
a class exported from the contract would give every plugin a *runtime* dependency on it, which its compiled
|
|
220
|
+
`dist/server.js` is meant not to have. And `instanceof` cannot see across a bundle: a rollup-bundled server
|
|
221
|
+
inlines its own copy of any class it declares, which is a different class from the host's. A `name` compares
|
|
222
|
+
equal wherever the error was made (the `AbortError` idiom). Only the *type* of the name,
|
|
223
|
+
`ActionInputErrorName`, comes from the contract, so renaming it there breaks your build rather than silently
|
|
224
|
+
turning your 400s into 500s.
|
|
225
|
+
|
|
226
|
+
**The route also guards a missing `input` for you.** If your manifest declares `fields` for the action and the
|
|
227
|
+
body has no `input` object (`null`, an array, a string, or nothing at all — a caller who sent the fields at the
|
|
228
|
+
top level of the body instead of under `input`), the host answers `400` *before your `run` is called*, naming
|
|
229
|
+
the fields. An action that declares no `fields` is not guarded — it is legitimately called with no input — so
|
|
230
|
+
its `run` must still cope with `input` being `undefined`. A field-declaring action's `run` still validates each
|
|
231
|
+
field itself: the guard only ensures there is an object to read them from.
|
|
232
|
+
|
|
233
|
+
### Action form fields — `multiselect`, `hidden`, `optionsFrom` (#463)
|
|
234
|
+
|
|
235
|
+
An action's `fields[]` are what the universal modal renders. Each field has a `name` (the key in the `input` your
|
|
236
|
+
`run` receives), a `label`, and a `type`:
|
|
237
|
+
|
|
238
|
+
| `type` | Renders | Value your `run` receives |
|
|
239
|
+
|---|---|---|
|
|
240
|
+
| `string` | a text box | a string |
|
|
241
|
+
| `text` | a multi-line text box | a string |
|
|
242
|
+
| `number` | a number box | a number |
|
|
243
|
+
| `boolean` | a checkbox | a boolean |
|
|
244
|
+
| `select` | a `<select>` of its fixed `options` (or of the choices `optionsFrom` answers) | a string |
|
|
245
|
+
| `multiselect` | a picker (below) of its `optionsFrom` choices or its fixed `options`, or a comma-separated text box when it has neither | a **`string[]`**, trimmed and de-duplicated |
|
|
246
|
+
| `hidden` | nothing | the value the caller seeded (`actions.open({ …, input })`), unchanged; when the seed does not name it (or there is no `input`), the one in the stored draft; `""` otherwise |
|
|
247
|
+
|
|
248
|
+
A `multiselect`'s value is always an array in the modal's state, in the draft it stores, and in the `input` it
|
|
249
|
+
POSTs. A seed or an older stored draft that holds a comma-separated string (`"bug, docs"`) is read as that list,
|
|
250
|
+
so a caller may seed either form. A `hidden` field is for a value the caller knows and the user should not edit
|
|
251
|
+
(an id, a scope) but your `run` still needs; it is not validated or shown by the shell, so treat it as caller
|
|
252
|
+
input like any other.
|
|
253
|
+
|
|
254
|
+
**`optionsFrom`** lets a field's choices come from one of your own routes instead of being fixed in the manifest.
|
|
255
|
+
It is valid only on a `select` or `multiselect` field, not beside a non-empty fixed `options` list (one source of
|
|
256
|
+
choices, not two), and the validator refuses anything provably wrong at load time (the plugin is skipped, naming
|
|
257
|
+
the field):
|
|
258
|
+
|
|
259
|
+
```json
|
|
260
|
+
{
|
|
261
|
+
"name": "toppings",
|
|
262
|
+
"label": "Toppings",
|
|
263
|
+
"type": "multiselect",
|
|
264
|
+
"optionsFrom": { "route": "/toppings", "params": ["shop"] }
|
|
265
|
+
}
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
- **`route`** must be one of the plugin's **own declared `GET` routes** (`contributes.routes[]`): a path starting
|
|
269
|
+
with `/`, with no query string or fragment and no `..`, `//`, `%` or `\` (a percent-escaped dot segment would
|
|
270
|
+
climb out of your namespace once the browser resolves the URL). A route declared as `toppings` is served as
|
|
271
|
+
`/toppings`, so `optionsFrom` names it that way. The shell fetches `/api/x/<id><route>`.
|
|
272
|
+
- **`params`** (optional) names **other fields of the same action**. Their current values are sent as query
|
|
273
|
+
parameters (`?shop=…`), a `multiselect`'s as its comma-joined picks. While any named field is empty nothing is
|
|
274
|
+
fetched and the picker says `Fill in “<label>” to load the choices.` (a `hidden` field named here must be
|
|
275
|
+
seeded by the caller, since it has no control to fill in); the first fetch runs at once, each later one waits
|
|
276
|
+
for a 250 ms pause after the last change, and an answer that is no longer the latest is dropped. While the
|
|
277
|
+
choices are loading or blocked, the field lists the picks it still holds (`Chosen: …`), since they are still
|
|
278
|
+
submitted.
|
|
279
|
+
|
|
280
|
+
**What the route answers** is an **`ActionOptionsResponse`** (`packages/contract/src/action-options.ts`, its
|
|
281
|
+
comments condensed here):
|
|
282
|
+
|
|
283
|
+
```ts
|
|
284
|
+
type ActionOptionsResponse =
|
|
285
|
+
| { ok: true; groups: ActionOptionGroup[]; allowCustom?: boolean; preselect?: string[] }
|
|
286
|
+
| { ok: false; error: string }
|
|
287
|
+
|
|
288
|
+
interface ActionOptionGroup {
|
|
289
|
+
key: string
|
|
290
|
+
caption?: string // the group's heading
|
|
291
|
+
exclusive?: boolean // at most one pick from this group: a segmented control with a "None"
|
|
292
|
+
options: ActionOption[]
|
|
293
|
+
}
|
|
294
|
+
interface ActionOption {
|
|
295
|
+
value: string // what is submitted
|
|
296
|
+
label?: string // what is shown; defaults to value
|
|
297
|
+
color?: string // six hex digits, no "#" -- GitHub's form (a label's colour)
|
|
298
|
+
description?: string // the chip's tooltip
|
|
299
|
+
badge?: string // a small tag beside the label
|
|
300
|
+
}
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
The shell makes every part of the answer safe before it renders it: a `color` that is not exactly six hex digits
|
|
304
|
+
is ignored, an option `value` is trimmed, a duplicate `value` within a group, an option with no string (or a blank)
|
|
305
|
+
`value` and a group left empty are dropped, and every string is rendered as text, never as markup. A body that is
|
|
306
|
+
not that shape, a network error, no answer within 15 seconds, or `{ ok: false, error }` (**whatever the status** —
|
|
307
|
+
answer `503`, never `502`/`504`, per §2) is a **failed load**:
|
|
308
|
+
the field falls back to a plain text box, so the action stays usable: a `multiselect`'s is comma-separated
|
|
309
|
+
(`Couldn't load the choices: <reason>. Type them below, separated by commas.`), a `select`'s takes one value
|
|
310
|
+
(`Couldn't load the choices: <reason>. Type a value below.`).
|
|
311
|
+
|
|
312
|
+
The picker itself is the shell's, and behaves the way 1.x's label picker did: an `exclusive` group is a segmented
|
|
313
|
+
control and clicking the current pick (or "None") clears it; the other groups are coloured chips (text colour
|
|
314
|
+
chosen for contrast); a search box appears when **more than 8** options sit outside the exclusive groups (an
|
|
315
|
+
exclusive group is a small control and is never filtered) and never hides an option that is already chosen (an
|
|
316
|
+
option of a loaded group that you just unticked stays on screen until the filter next changes); a chosen value no group names (a seed, a
|
|
317
|
+
draft) lands in a trailing **Other** group instead of being dropped, and **`allowCustom: true`** adds a free-form
|
|
318
|
+
"add" box there (Enter adds, it does not submit the form); **`preselect`** picks those values once (a `select`
|
|
319
|
+
takes the first), only while nothing is chosen and the user has not touched the picker — a seed or a draft that
|
|
320
|
+
already holds picks wins over it (an empty draft does not). It is keyboard-operable (the chips and segments are
|
|
321
|
+
native checkboxes and radios), and a group is named by its `caption`, or by the field's label when it has none.
|
|
322
|
+
A `multiselect`'s fixed `options` list gets the same treatment as a route's answer (values and labels trimmed,
|
|
323
|
+
blanks and repeats dropped); a plain `select`'s fixed `options` are rendered as written.
|
|
324
|
+
|
|
325
|
+
`plugins/hello` is the worked example: `hello.order` (a `hidden` `ticket`, a `string` `shop` and the `multiselect`
|
|
326
|
+
`toppings` above) reads `GET /toppings?shop=…`, which answers an exclusive **Size** group and a coloured
|
|
327
|
+
**Toppings** group of twelve options with `allowCustom`; `?shop=closed` answers `503 { ok: false, error: "the shop
|
|
328
|
+
is closed" }` so you can see the fallback. Its `run` echoes its input, so the submitted array is visible.
|
|
329
|
+
|
|
330
|
+
## 3. The frontend — `src/ui.tsx`
|
|
331
|
+
|
|
332
|
+
A plugin's `frontend` (`dist/ui.js`) is a single ES module the host serves at `/plugins-assets/<id>` and the
|
|
333
|
+
shell `import()`s. It exports the components the manifest's `export` names point at. It imports **only the
|
|
334
|
+
import-map externals** — `react` (via `react/jsx-runtime`), `react-dom`, `react-router`, `@rackbops/ui-react` and
|
|
335
|
+
`@ac/host` — so **React is never bundled**: the shell and every plugin share one React copy through the host
|
|
336
|
+
page's import map.
|
|
337
|
+
|
|
338
|
+
The built `index.html` template keeps those paths unversioned, but the host adds its running version as a
|
|
339
|
+
`?v=<encoded-version>` query when it serves the shell. There are seven mapped specifiers and six distinct
|
|
340
|
+
vendor URLs because `react-dom` and `react-dom/client` deliberately share the byte-identical
|
|
341
|
+
`/vendor/react-dom.js?v=…` URL. `/index.js` receives the same query. Plugins must keep importing the bare
|
|
342
|
+
specifiers above—never `/vendor/*.js` or a relative `src/vendor/*` module—so the browser cannot load a second
|
|
343
|
+
React or `@ac/host` instance. Both versioned and legacy asset URLs remain valid and revalidating; the query is a
|
|
344
|
+
deployment cache key, not a filesystem path or a change to the plugin ABI.
|
|
345
|
+
|
|
346
|
+
```tsx
|
|
347
|
+
import { Card } from "@rackbops/ui-react"
|
|
348
|
+
|
|
349
|
+
export function HelloPanel() {
|
|
350
|
+
return (
|
|
351
|
+
<Card>
|
|
352
|
+
<h1>Hello from a plugin</h1>
|
|
353
|
+
<p>This panel is contributed by plugins/hello — the reference plugin.</p>
|
|
354
|
+
</Card>
|
|
355
|
+
)
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export function HelloCard() {
|
|
359
|
+
return <Card>Hello, from a contributed card.</Card>
|
|
360
|
+
}
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
A card's manifest `sizes` (`["2x1"]` above, `<width>x<height>`) names the grid slots it renders at; the
|
|
364
|
+
Overview dashboard (#77) reads only the **height** half as the card's own default row-span tier (1/3, 2/3, or
|
|
365
|
+
3/3) — `sizes[0]`'s second number, falling back to 2 when that's missing, unparseable, or outside 1-3. A viewer's ⚙ menu can
|
|
366
|
+
override a card's height per layout; picking that same default height back clears the override rather than
|
|
367
|
+
pinning it, so an un-touched card keeps tracking whatever this manifest declares even after a redeploy changes
|
|
368
|
+
it.
|
|
369
|
+
|
|
370
|
+
### The `@ac/host` browser module
|
|
371
|
+
|
|
372
|
+
`@ac/host` (typed as `BrowserHostApi` in `@ac/contract`) is the one shared module instance the shell and every
|
|
373
|
+
plugin import through the host page's import map — upgrading it upgrades every plugin, with no plugin rebuild.
|
|
374
|
+
Methods are added additively. **Inside the console**, every plugin-facing surface below is safe to call from a
|
|
375
|
+
mounted panel/card's effect **or** at bundle import time: a call made before the shell has mounted its chrome is
|
|
376
|
+
held and delivered, in order, once it does — a plugin never has to wait for the shell itself. **Outside the
|
|
377
|
+
console** (a bundle that never runs inside the shell at all) that hold is permanent for `header.registerButton`,
|
|
378
|
+
`actions.open` and `quickcast.register` — there is no shell to eventually deliver to, so the call simply has no
|
|
379
|
+
effect — except `toast`, which renders through a minimal, self-contained fallback after a tick once nothing has
|
|
380
|
+
claimed it, so a toast is never silently lost.
|
|
381
|
+
|
|
382
|
+
`theme.token` resolves a `--rb-*` custom property to its current value:
|
|
383
|
+
|
|
384
|
+
```ts
|
|
385
|
+
import { theme } from "@ac/host"
|
|
386
|
+
|
|
387
|
+
const accent = theme.token("--rb-accent")
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
`toast` shows a message through the shell's toast portal. Called before the shell mounts, it is buffered (capped
|
|
391
|
+
so an import-time loop can't grow it without bound) and flushed once it does; only if no shell ever mounts at
|
|
392
|
+
all does it fall back to a minimal, self-contained DOM toast — so a plugin calling `toast` never silently loses
|
|
393
|
+
a message:
|
|
394
|
+
|
|
395
|
+
```ts
|
|
396
|
+
import { toast } from "@ac/host"
|
|
397
|
+
|
|
398
|
+
toast("hi from hello")
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
`header.registerButton` puts a button in the shell header and returns the unregister — the unregister works
|
|
402
|
+
whether or not the button has been delivered yet (cancels a pending registration, or removes a delivered one):
|
|
403
|
+
|
|
404
|
+
```ts
|
|
405
|
+
import { header } from "@ac/host"
|
|
406
|
+
|
|
407
|
+
const unregister = header.registerButton({ id: "hello-scan", label: "Scan", onClick: () => {} })
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
`actions.open` opens the universal confirm modal for a registered action — the plugin's own, or another
|
|
411
|
+
plugin's (`pluginId` names it; the Board and the Artifacts action bar open `provider-github`'s and others').
|
|
412
|
+
`target` scopes the draft to a specific object, e.g. an issue number, when the action isn't `scope: "global"`.
|
|
413
|
+
It is fire-and-forget, held until the shell mounts, and dropped if it never does:
|
|
414
|
+
|
|
415
|
+
```ts
|
|
416
|
+
import { actions } from "@ac/host"
|
|
417
|
+
|
|
418
|
+
actions.open({ pluginId: "hello", actionId: "hello.ping" })
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
`input` seeds the form (`actions.open({ pluginId: "provider-github", actionId: "issue.comment", target:
|
|
422
|
+
"acme/kb#2", input: { repo: "acme/kb", number: 2 } })`). A seed wins over the stored draft for the same
|
|
423
|
+
`target` **for the fields it names** (#478): every other field keeps what the draft holds, so a comment typed
|
|
424
|
+
into a modal opened from a row survives a cancel and a reopen of the same row, while a seeded `repo` or `number`
|
|
425
|
+
is never stale. A field the seed names as empty (`""`, `[]`, `0`, `false`) still wins; only a key that is absent
|
|
426
|
+
(or `undefined`) leaves the field to the draft, and a field in neither gets its default. So seed only what you
|
|
427
|
+
know: a key seeded as `""` because the value is unknown wipes what the operator typed for that field on a reopen
|
|
428
|
+
-- omit the key instead. A key that is not a
|
|
429
|
+
field of the action is dropped, from the seed and from the draft. The merged values are written back as the
|
|
430
|
+
draft at once, so a reload keeps exactly what the form shows; a draft is still kept on Cancel and on a failed
|
|
431
|
+
submit and cleared on a successful one, under the same `ac-draft:<pluginId>/<actionId>/<scopeKey>` key.
|
|
432
|
+
|
|
433
|
+
**A seed must name every field whose meaning depends on another field it names.** `issue.labels` diffs
|
|
434
|
+
`originalLabels` (what the form showed) against `labels` (what is ticked), so the Board seeds both; a caller that
|
|
435
|
+
seeded only `originalLabels` would get a fresh snapshot diffed against a stale drafted `labels`, and remove
|
|
436
|
+
labels nobody unticked.
|
|
437
|
+
|
|
438
|
+
`actions.open` returns nothing, and cannot tell you whether the action ran. `actions.subscribe` is that signal:
|
|
439
|
+
the listener is called once for every action the modal dispatched and got a result for — success or failure,
|
|
440
|
+
and whoever opened it (your own `actions.open`, or the Ctrl/Cmd+K palette) — with the action's `pluginId`,
|
|
441
|
+
`actionId`, `ok`, the `error` message on a failure, and the opened ref's `target` (absent when the ref had none,
|
|
442
|
+
as from the palette) — what the action was opened *for*, not what the operator finally typed into the form. It
|
|
443
|
+
carries no input. Cancelling, or closing before the confirm, calls nothing. Use it to
|
|
444
|
+
refresh what an action changed — the Board refetches the card an `issue.*` action named, so a closed issue's row
|
|
445
|
+
goes at once instead of at the next poll. It returns the unsubscribe; a listener that throws is logged and
|
|
446
|
+
skipped, and never reaches the modal or another listener. It needs no shell to be mounted: it simply hears
|
|
447
|
+
nothing until one dispatches something.
|
|
448
|
+
|
|
449
|
+
```ts
|
|
450
|
+
import { actions } from "@ac/host"
|
|
451
|
+
|
|
452
|
+
const off = actions.subscribe((e) => {
|
|
453
|
+
if (e.ok && e.pluginId === "provider-github" && e.actionId.startsWith("issue.")) {
|
|
454
|
+
// refetch what the action changed; e.target says what it was opened for (absent from the palette)
|
|
455
|
+
}
|
|
456
|
+
})
|
|
457
|
+
// ... and in the effect's cleanup: off()
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
Every tabbed panel, and every action of a plugin that loaded without error, is automatically listed in the
|
|
461
|
+
Ctrl/Cmd+K quickcast palette — nothing to register for that. (A panel that lost a contested nav slot —
|
|
462
|
+
`buildTabs` skips a non-owning `navOwner: false` panel — has no tab but IS still listed: `quickcastPanels`
|
|
463
|
+
offers every loadable panel on purpose (#65), so the palette stays a keyboard way back to it. Actions are pure
|
|
464
|
+
data, so they surface even for a plugin with no frontend at all.) `quickcast.register` is for anything beyond a plain
|
|
465
|
+
panel/action jump: register a function returning the plugin's current items, and the palette calls it on open
|
|
466
|
+
and again on every filter keystroke (matching each item's `label` and `hint` — see below). Items appear after
|
|
467
|
+
the built-in panel/action items, in registration order; the plugin's `run` decides what happens (navigate, open
|
|
468
|
+
its own UI, call `actions.open`) and the palette closes before calling it. Held until the shell mounts, the
|
|
469
|
+
same as `header.registerButton`. This is the snippet `HelloPanel` itself registers in an effect:
|
|
470
|
+
|
|
471
|
+
```tsx
|
|
472
|
+
import { quickcast, toast } from "@ac/host"
|
|
473
|
+
import { useEffect } from "react"
|
|
474
|
+
|
|
475
|
+
useEffect(
|
|
476
|
+
() => quickcast.register(() => [{ label: "Hello: say hi", run: () => toast("hi from hello") }]),
|
|
477
|
+
[],
|
|
478
|
+
)
|
|
479
|
+
```
|
|
480
|
+
|
|
481
|
+
A `hint` (optional, e.g. a category or the plugin id) is searched the same as `label`, ranked after it — so
|
|
482
|
+
`>hel` finds an item whose label doesn't contain "hel" but whose hint does — and renders as muted secondary
|
|
483
|
+
text in the row.
|
|
484
|
+
|
|
485
|
+
`fetch` makes a same-origin, credentialed, CSRF-aware request to a host/plugin route — a relative `path`
|
|
486
|
+
resolves against the console origin; a cross-origin absolute URL throws rather than being followed. A panel
|
|
487
|
+
reads a provider's routes through this rather than a bare `window.fetch`; a card should reach for `cached`
|
|
488
|
+
below instead:
|
|
489
|
+
|
|
490
|
+
```ts
|
|
491
|
+
import { fetch } from "@ac/host"
|
|
492
|
+
|
|
493
|
+
const res = await fetch("/api/x/provider-github/issues?repo=o/n")
|
|
494
|
+
const data = await res.json()
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
`cached` and `invalidate` wrap `fetch` in a per-path 45 s (default) TTL cache (E7 #77): a call within `ttlMs`
|
|
498
|
+
of the path's last fetch reuses that same underlying request instead of issuing a new one — each caller
|
|
499
|
+
still gets its own `Response` (`.clone()`), so two independent reads of a cache hit never collide on an
|
|
500
|
+
already-consumed body. Only `GET` may be cached (`cached` throws on any other method); a rejected fetch is
|
|
501
|
+
never cached — the very next call retries. **A card reads through `cached`, never bare `fetch`, so Refresh
|
|
502
|
+
and the Overview heartbeat (both call `invalidate`) actually reach it** — a card that bypasses `cached` keeps
|
|
503
|
+
serving its own stale response until its own next poll, deaf to both:
|
|
504
|
+
|
|
505
|
+
```ts
|
|
506
|
+
import { cached, invalidate } from "@ac/host"
|
|
507
|
+
|
|
508
|
+
const res = await cached("/api/x/scans/context-pressure") // a hit inside 45 s reuses the one in-flight/last fetch
|
|
509
|
+
const data = await res.json()
|
|
510
|
+
|
|
511
|
+
// ... later, e.g. from a Refresh button:
|
|
512
|
+
invalidate("/api/x/scans/context-pressure") // this one path
|
|
513
|
+
invalidate() // every cached path
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
**Type resolution and testing.** A plugin importing `@ac/host` needs three things beyond the import itself —
|
|
517
|
+
the guide is written against hello, so this is exactly what it does:
|
|
518
|
+
|
|
519
|
+
1. **The ambient declaration.** TypeScript's `declare module "@ac/host"` (in
|
|
520
|
+
`packages/ui-shell/src/ac-host.d.ts`, what lets the shell's own program see it) doesn't cross a package
|
|
521
|
+
boundary just because your plugin also depends on `@ac/contract` — your plugin's own `tsconfig.ui.json`
|
|
522
|
+
only sees files it includes. Copy [`plugins/hello/src/ac-host.d.ts`](../../plugins/hello/src/ac-host.d.ts)
|
|
523
|
+
into your plugin (it deliberately re-declares only the plugin-facing surface — no `ShellChrome`/`__shell`,
|
|
524
|
+
which are shell-internal) and add it to that tsconfig's `include`, the way hello's
|
|
525
|
+
[`tsconfig.ui.json`](../../plugins/hello/tsconfig.ui.json) does. A vendored copy can silently drift from
|
|
526
|
+
the shell's own, the same risk as the `@ac/contract` schema copy in §5 — guard it the way
|
|
527
|
+
[`plugins/hello/src/ac-host-drift.test.ts`](../../plugins/hello/src/ac-host-drift.test.ts) does, pinning
|
|
528
|
+
the copy's exported surface against the shell's line-for-line.
|
|
529
|
+
2. **The Vitest alias.** `@ac/host` only resolves for real through the host page's import map, which a plain
|
|
530
|
+
Node test runner has no equivalent of. If you test the frontend through Vitest, alias the specifier to
|
|
531
|
+
`packages/ui-shell/src/vendor/ac-host-entry.mjs` (the shell's own implementation — reuse it rather than
|
|
532
|
+
mocking), the way [`plugins/hello/vitest.config.ts`](../../plugins/hello/vitest.config.ts) does.
|
|
533
|
+
3. **Leaving it external in the build.** Nothing extra is needed here as long as you build with plain `tsc`
|
|
534
|
+
(§3, no bundler) — `tsc` only transpiles syntax, so `import { toast } from "@ac/host"` is emitted into
|
|
535
|
+
`dist/ui.js` verbatim, unresolved, for the browser's import map to satisfy at runtime, exactly like the
|
|
536
|
+
`react`/`@rackbops/ui-react` imports. (This only becomes something to configure explicitly if a plugin
|
|
537
|
+
ever builds its frontend with a bundler instead — then `@ac/host` needs the same external treatment
|
|
538
|
+
`packages/ui-shell/vendor.config.mjs` gives `react`/`react-dom`.)
|
|
539
|
+
|
|
540
|
+
Build it with `tsc` and `"jsx": "react-jsx"` — `tsc` under `nodenext` preserves the bare `@rackbops/ui-react`
|
|
541
|
+
import and injects `import { jsx } from "react/jsx-runtime"`, emitting exactly the servable ESM the import map
|
|
542
|
+
resolves. Do **not** bundle (no rollup/esbuild bundling of React) — that would ship a second React copy.
|
|
543
|
+
|
|
544
|
+
hello uses two tsconfigs so the server (no JSX) and the frontend (JSX) build separately:
|
|
545
|
+
|
|
546
|
+
`tsconfig.json` (server):
|
|
547
|
+
|
|
548
|
+
```json
|
|
549
|
+
{
|
|
550
|
+
"extends": "../../tsconfig.base.json",
|
|
551
|
+
"compilerOptions": { "rootDir": "src", "outDir": "dist" },
|
|
552
|
+
"include": ["src/server.ts", "src/server.test.ts"]
|
|
553
|
+
}
|
|
554
|
+
```
|
|
555
|
+
|
|
556
|
+
`tsconfig.ui.json` (frontend):
|
|
557
|
+
|
|
558
|
+
```json
|
|
559
|
+
{
|
|
560
|
+
"extends": "../../tsconfig.base.json",
|
|
561
|
+
"compilerOptions": { "rootDir": "src", "outDir": "dist", "jsx": "react-jsx" },
|
|
562
|
+
"include": ["src/ui.tsx", "src/ui.test.tsx"]
|
|
563
|
+
}
|
|
564
|
+
```
|
|
565
|
+
|
|
566
|
+
and `package.json` builds + typechecks both:
|
|
567
|
+
|
|
568
|
+
```json
|
|
569
|
+
"scripts": {
|
|
570
|
+
"typecheck": "tsc --noEmit && tsc -p tsconfig.ui.json --noEmit",
|
|
571
|
+
"build": "tsc && tsc -p tsconfig.ui.json",
|
|
572
|
+
"test": "vitest run"
|
|
573
|
+
}
|
|
574
|
+
```
|
|
575
|
+
|
|
576
|
+
`react` and `@rackbops/ui-react` are `dependencies` (needed to typecheck the frontend); at runtime they resolve
|
|
577
|
+
through the import map, not from the plugin's `node_modules`. `@rackbops/ui-react` is **optional** — a frontend
|
|
578
|
+
can use plain JSX and drop it, keeping only `react`. And if you split the server or frontend across multiple
|
|
579
|
+
source files, each relative import carries an explicit `.js` extension (`import { x } from "./util.js"`, even
|
|
580
|
+
from a `.ts` source) — the workspace is `moduleResolution: nodenext`.
|
|
581
|
+
|
|
582
|
+
If your **server** takes on a runtime npm dependency (not just a type-only `import type`), it must be bundled
|
|
583
|
+
into `dist/server.js` — the image ships a plugin's `package.json` + `dist/*.js` only, never a plugin
|
|
584
|
+
`node_modules`, and `scripts/assert-image.mjs` asserts every `plugins/*` id actually loads in the booted image
|
|
585
|
+
(so a plugin shipping an unresolvable bare import goes red there, not silently in production). `plugins/issues-classic`
|
|
586
|
+
is the worked example (#312): `tsconfig.build.json` emits the server alone to `dist-tsc/`, and
|
|
587
|
+
`rollup.config.mjs` bundles that into `dist/server.js` with everything except Node's own `node:*` built-ins
|
|
588
|
+
inlined — `yaml` (its `views.yaml` parser) included. `createPlugin` stays pure regardless of how the server is
|
|
589
|
+
built. The **frontend** is never bundled this way (§3 above) — `@ac/host`/`react`/`@rackbops/ui-react` resolve
|
|
590
|
+
through the shell's import map, not a bundle.
|
|
591
|
+
|
|
592
|
+
## 4. `CHANGELOG.md` — required, and version-matched
|
|
593
|
+
|
|
594
|
+
Every plugin needs a `CHANGELOG.md` with a section matching its `package.json` `version`, in Keep-a-Changelog
|
|
595
|
+
form `## <version> - <YYYY-MM-DD>`:
|
|
596
|
+
|
|
597
|
+
```markdown
|
|
598
|
+
# Changelog
|
|
599
|
+
|
|
600
|
+
## 0.1.0 - 2026-09-21
|
|
601
|
+
|
|
602
|
+
- A second action, `hello.order`, and a second route, `GET /toppings` (#463): the worked example of the
|
|
603
|
+
action-form option pickers. The action has a `hidden` field (`ticket`), a `string` field (`shop`) and a
|
|
604
|
+
`multiselect` (`toppings`) whose choices come from `GET /toppings?shop=<shop>` (`optionsFrom`): two
|
|
605
|
+
groups -- an exclusive "Size" and a plain, coloured "Toppings" -- and a free-form add. `?shop=closed`
|
|
606
|
+
answers 503 with `{ ok: false, error: "the shop is closed" }`, so the picker's text-box fallback is
|
|
607
|
+
reachable in a browser. The handler echoes the input it received, so the submitted `toppings` array is
|
|
608
|
+
visible. `hello.ping` is unchanged.
|
|
609
|
+
```
|
|
610
|
+
|
|
611
|
+
This is a **hard requirement**: `scripts/generate-index.mjs` scans every plugin's `acPlugin` block + CHANGELOG
|
|
612
|
+
into the committed `plugins.json` (the shared plugin-index envelope), and **throws** — failing `pnpm run check`
|
|
613
|
+
— if the current version has no matching CHANGELOG section. Bump the version and add its section together.
|
|
614
|
+
|
|
615
|
+
## 5. Vendor the contract — `vendor/@ac/contract/schema.json` (when you need it)
|
|
616
|
+
|
|
617
|
+
An in-monorepo plugin depends on `@ac/contract: workspace:*`, so it builds against the **live** contract and a
|
|
618
|
+
host-side change reaches it immediately — nothing to drift from. A plugin that instead pins or publishes against
|
|
619
|
+
a fixed `@ac/contract` builds against a **frozen copy**, which can silently fall behind the host. The guard
|
|
620
|
+
against that is a vendored snapshot of the schema:
|
|
621
|
+
|
|
622
|
+
```sh
|
|
623
|
+
cp packages/contract/schema.json plugins/<id>/vendor/@ac/contract/schema.json
|
|
624
|
+
```
|
|
625
|
+
|
|
626
|
+
`check-contract` (`packages/host/src/check-contract.test.ts`) asserts that **every vendored copy that exists** is
|
|
627
|
+
identical to the live `packages/contract/schema.json`, and that at least one plugin vendors one so the guard is
|
|
628
|
+
never a no-op (the reference plugin `hello` provides that copy). So vendoring is **optional for a `workspace:*`
|
|
629
|
+
monorepo plugin** and the pattern to follow for a pinned or published one; when the contract changes you
|
|
630
|
+
**re-vendor** (copy again) and commit — that acknowledgement is the point of the check.
|
|
631
|
+
|
|
632
|
+
## 6. Build, package, and verify it loads
|
|
633
|
+
|
|
634
|
+
```sh
|
|
635
|
+
pnpm install # once, after adding the plugin's deps
|
|
636
|
+
pnpm run check # build + regenerate plugins.json + lint + typecheck + test
|
|
637
|
+
```
|
|
638
|
+
|
|
639
|
+
`pnpm run check` runs `vitest run` in every package, and Vitest **fails a package that ships no test file** (with
|
|
640
|
+
*no test files found*) — so write at least one test; copy the shape of `hello`'s `src/server.test.ts` (a
|
|
641
|
+
`createPlugin` smoke test with a mocked `host`) and `src/ui.test.tsx` (a component-export test), both listed in
|
|
642
|
+
the tsconfig `include`s above. `check` also regenerates the committed `plugins.json` and then
|
|
643
|
+
**fails if it differs from `HEAD`** — adding a plugin changes it, so `pnpm run check` stays red until you
|
|
644
|
+
**commit** the regenerated `plugins.json` (staging is not enough; the check diffs against the last commit).
|
|
645
|
+
|
|
646
|
+
Then confirm the plugin **loads and activates**. Build, then boot the host pointed at your `plugins/` dir — both
|
|
647
|
+
`AC_PLUGINS_DIR` and `AC_STATE_DIR` must be **absolute** paths (the host rejects a relative one at startup), and
|
|
648
|
+
`AC_STATE_DIR` must be **writable**: the host opens its SQLite DB at `<AC_STATE_DIR>/ac.db`, and the image
|
|
649
|
+
default `/state` does not exist on a dev box.
|
|
650
|
+
|
|
651
|
+
```sh
|
|
652
|
+
pnpm -r build
|
|
653
|
+
mkdir -p .dev-state
|
|
654
|
+
AC_PLUGINS_DIR="$PWD/plugins" AC_STATE_DIR="$PWD/.dev-state" node packages/host/dist/main.js
|
|
655
|
+
```
|
|
656
|
+
|
|
657
|
+
The host listens on `http://127.0.0.1:8787` by default (override with `AC_HOST` / `AC_PORT`). Read `GET
|
|
658
|
+
/api/plugins` there (the envelope is `{ "plugins": [ … ] }`): your plugin appears with **no `error`** and its
|
|
659
|
+
`frontend`/`panels`/`cards`/`actions` — a contribution you didn't declare is simply absent. `GET /api/x/<id>/…`
|
|
660
|
+
hits your routes; `/plugins-assets/<id>` serves your frontend. Its entry also carries `origin` — the shell marks
|
|
661
|
+
a tab dashed when the plugin was found under an *installed* plugins dir rather than the image's own `plugins/`;
|
|
662
|
+
this is derived from where the host found it, so there is nothing a plugin can declare to change it.
|
|
663
|
+
|
|
664
|
+
**Packaging into the image.** `main.js` discovers plugins at `../plugins` (i.e. `/app/plugins` in the image), so
|
|
665
|
+
the `Dockerfile` copies each plugin's runtime files there: `package.json` plus the whole `dist/`
|
|
666
|
+
directory (`COPY --from=build /repo/plugins/<name>/dist ./plugins/<name>/dist`) — **not** a
|
|
667
|
+
hand-listed set of individual files (#401). Your server may freely split across sibling emitted
|
|
668
|
+
modules (a plain `tsc` build, e.g. so one can be unit-tested directly — `plugins/provider-artifacts`,
|
|
669
|
+
#67, is the reference) with no extra Dockerfile work: the whole-`dist/` copy ships whatever you
|
|
670
|
+
emit, automatically. This is safe from shipping a compiled `*.test.js` alongside your real modules
|
|
671
|
+
only because the container's `pnpm -r build` runs your **build-only** tsconfig
|
|
672
|
+
(`tsconfig.build.json`/`tsconfig.ui.build.json`, excluding every `*.test.ts`), never the
|
|
673
|
+
test-covering one `typecheck` uses — guarded by `scripts/check-no-test-emit.mjs` (#350, run twice in
|
|
674
|
+
`pnpm run check`), which fails the moment any package's build leaves a compiled test file under
|
|
675
|
+
`dist*/`. A brand-new **plugin directory** still needs its own two `COPY` lines (`scripts/
|
|
676
|
+
check-dockerfile-plugin-copy.mjs`, wired into `pnpm run check`, fails naming any `acPlugin` plugin
|
|
677
|
+
copied file-by-file or not copied at all; `scripts/assert-image.mjs` is the runtime-load proof that
|
|
678
|
+
catches a plugin dir never `COPY`ed at all, in the real booted image).
|
|
679
|
+
|
|
680
|
+
## 7. Host-agent capabilities
|
|
681
|
+
|
|
682
|
+
A plugin that needs to reach a paired machine's host agent (a git status, a `claude`/`gh` run, ...)
|
|
683
|
+
declares which capabilities it may use in its manifest, as a sibling of `contributes` rather than
|
|
684
|
+
inside it -- a capability isn't a contribution point, it's a permission:
|
|
685
|
+
|
|
686
|
+
```json
|
|
687
|
+
"acPlugin": {
|
|
688
|
+
"id": "hello",
|
|
689
|
+
...
|
|
690
|
+
"capabilities": ["agent.git.read"]
|
|
691
|
+
}
|
|
692
|
+
```
|
|
693
|
+
|
|
694
|
+
Each declared capability appears as its own row in the Agents panel's Grants grid, one column per
|
|
695
|
+
paired machine; an operator grants or revokes it there, per machine, and nothing here runs until it
|
|
696
|
+
is granted. `hello`'s own manifest declares `agent.git.read` as the reference declaration (`hello`
|
|
697
|
+
never actually calls it -- the declaration alone is what the grid, a live pairing pass, and this
|
|
698
|
+
guide need).
|
|
699
|
+
|
|
700
|
+
At runtime, call a granted capability on a specific paired machine through `host.agent`:
|
|
701
|
+
|
|
702
|
+
```ts
|
|
703
|
+
const machines = await host.agent.list() // paired machines, each with its current grants
|
|
704
|
+
const outcome = await host.agent.call(machines[0].id, "GET", "/v1/repos")
|
|
705
|
+
if (outcome.ok) {
|
|
706
|
+
// outcome.status, outcome.body
|
|
707
|
+
} else {
|
|
708
|
+
// outcome.error.kind: "unknown-machine" | "refused" | "grant" | "pin" | "network" | "http"
|
|
709
|
+
}
|
|
710
|
+
```
|
|
711
|
+
|
|
712
|
+
`outcome.error.kind === "grant"` is the one worth showing to the person almost verbatim -- it
|
|
713
|
+
carries the agent's own refusal text (`"<capability> is not granted to <plugin> on <machine>"`). The
|
|
714
|
+
others are the console's problem to describe differently: `unknown-machine` (a stale machine id),
|
|
715
|
+
`refused` (the path isn't a `/v1/*` verb, or is `/v1/pair`/`/v1/grants` -- the control plane, never
|
|
716
|
+
reachable this way, whatever the path spelling), `pin` (the agent's certificate changed since
|
|
717
|
+
pairing), `network`, `http` (an unexpected status the console doesn't special-case).
|
|
718
|
+
|
|
719
|
+
A verb that can genuinely run long (a scan, a build) needs a fifth, optional `call` argument:
|
|
720
|
+
`host.agent.call(machineId, method, path, body, { timeoutMs })` sets how long the console waits for
|
|
721
|
+
the agent's response *headers* (undici's `headersTimeout`; the default is 300s). Pass the verb's own
|
|
722
|
+
limit plus a margin -- `scans` passes `agent limit + 30s` -- so the agent's own `504` reaches the
|
|
723
|
+
console before the console gives up and reports a bare `network` timeout instead.
|
|
724
|
+
|
|
725
|
+
### Streaming a job into the tasks drawer
|
|
726
|
+
|
|
727
|
+
A capability whose verb starts a long-running agent job (`exec/claude`'s `{ jobId }`, #54) doesn't
|
|
728
|
+
need to poll or parse SSE itself -- `host.agent.streamJob(machineId, jobId, { label })` opens the
|
|
729
|
+
agent's `GET /v1/jobs/:jobId/events` through the same pinned client, registers a new host task for
|
|
730
|
+
`label`, and relays every `line`/`done` frame into it as the job runs; it resolves `{ taskId }` once
|
|
731
|
+
the agent has confirmed the stream is open (a `200`), or rejects (starting no task) on anything
|
|
732
|
+
else -- an invalid `jobId`, an unknown machine, or a non-`200` from the agent (a `404`, a `403`
|
|
733
|
+
refusal, ...), each surfaced the same way `call` surfaces them. The relay itself runs detached from
|
|
734
|
+
that promise, so nothing in a plugin's own code touches the stream at all:
|
|
735
|
+
|
|
736
|
+
```ts
|
|
737
|
+
const { jobId } = await execResult // however the plugin's own verb call started the job
|
|
738
|
+
const { taskId } = await host.agent.streamJob(machineId, jobId, { label: `code: ${prompt}` })
|
|
739
|
+
// taskId names the task now streaming in the tasks drawer -- the action can simply return it.
|
|
740
|
+
```
|
|
741
|
+
|
|
742
|
+
`plugins/claude-runner` (#54) is the reference: `task.code` and `routine.rerun` call `exec/claude`
|
|
743
|
+
then `streamJob` and return the task id, never touching SSE framing themselves.
|
|
744
|
+
|
|
745
|
+
## 8. The needs-attention rail (#78)
|
|
746
|
+
|
|
747
|
+
`contributes.attention: true` marks a plugin that serves `GET /api/x/<id>/attention` ->
|
|
748
|
+
`{ ok: true, items: AttentionItem[] }` (`@ac/contract`'s `attention.ts`). The `overview` plugin's
|
|
749
|
+
rollup card discovers every contributor from `GET /api/plugins`'s `PluginView.attention` field (no
|
|
750
|
+
second manifest read) and polls each `/attention` route, merging, sorting by severity, and deduping
|
|
751
|
+
by `${source}:${id}`.
|
|
752
|
+
|
|
753
|
+
```ts
|
|
754
|
+
interface AttentionItem {
|
|
755
|
+
id: string // unique within YOUR plugin; the rollup's dedupe key is `${source}:${id}`
|
|
756
|
+
severity: "danger" | "warn" | "info"
|
|
757
|
+
source: string // your own plugin id -- the rollup shows it as the row's source
|
|
758
|
+
text: string // always a counted sentence: "3 open actions in the ledger", never "some actions"
|
|
759
|
+
badge?: string
|
|
760
|
+
href?: string // an external link; wins over `target` when both are set
|
|
761
|
+
target?: string // an in-app destination: "widget:<cardId>" or "panel:<pluginId>"
|
|
762
|
+
hint?: string // the exact command to run, or the reason -- shown as a tooltip/expander
|
|
763
|
+
action?: { kind: "issue"; repo?: string; title: string; body: string } // opens provider-github's issue.create, seeded
|
|
764
|
+
}
|
|
765
|
+
```
|
|
766
|
+
|
|
767
|
+
**Own the fact, own the row.** The rollup itself derives nothing -- it only merges what contributors
|
|
768
|
+
already computed. If your plugin is the one that KNOWS a fact is wrong (a failed job, a stale scan, a
|
|
769
|
+
stuck push), your plugin is the one that turns it into a row, at the severity and with the hint that
|
|
770
|
+
makes sense given what only you know about it. Don't reach into another plugin's storage or routes to
|
|
771
|
+
compute a row about ITS facts -- ask that plugin's own maintainer to add the row instead.
|
|
772
|
+
|
|
773
|
+
`scans` (`scan.stale.<kind>`, warn, one row per kind with a stale machine) and `provider-artifacts`
|
|
774
|
+
(`store.push-rejected` danger, `ledger.open`/`reviews.open`/`reviews.untracked` warn,
|
|
775
|
+
`publish.error.<machineId>` warn) are the reference contributors; `overview`'s own server (`GET
|
|
776
|
+
/attention`'s handler) is the reference for a plugin computing rows about facts it has PRIVILEGED,
|
|
777
|
+
cross-plugin, host-internal access to (job health across every other plugin, paired-agent
|
|
778
|
+
reachability) rather than its own storage -- `host.jobs.machineRunsOf(pluginId, jobId)` and
|
|
779
|
+
`host.plugins.list()` are additive `HostApi` capabilities added for exactly this, deliberately
|
|
780
|
+
scoped no wider than the data `GET /api/jobs/:pluginId/:jobId/runs` and `GET /api/plugins` already
|
|
781
|
+
serve publicly over HTTP.
|
|
782
|
+
|
|
783
|
+
## 9. Building a plugin outside this repo (#450)
|
|
784
|
+
|
|
785
|
+
Everything above is written from inside this monorepo, where a plugin depends on `@ac/contract:
|
|
786
|
+
workspace:*` and builds against the **live** contract with nothing to drift from. A plugin that
|
|
787
|
+
instead lives in its **own repository** — the normal shape for a third-party plugin — depends on the
|
|
788
|
+
**published** [`@rackbops/ac-plugin-contract`](https://www.npmjs.com/package/@rackbops/ac-plugin-contract)
|
|
789
|
+
package instead: the same `HostApi`, manifest types, and generated JSON Schema this guide has been
|
|
790
|
+
using all along, assembled by `packages/plugin-contract/build.mjs` from this repo's own `@ac/contract`
|
|
791
|
+
build, the shell's `ac-host.d.ts`, and this very file (shipped inside the package as `AUTHORING.md`).
|
|
792
|
+
Its version always equals the `artifact-console` host's own version — pin your `devDependency` the
|
|
793
|
+
way you'd pin against a specific host release.
|
|
794
|
+
|
|
795
|
+
**The devDependency:**
|
|
796
|
+
|
|
797
|
+
```sh
|
|
798
|
+
pnpm add -D @rackbops/ac-plugin-contract @types/node @types/react
|
|
799
|
+
```
|
|
800
|
+
|
|
801
|
+
`@types/react` is for a frontend written as `.tsx`: the tsconfig below compiles JSX with
|
|
802
|
+
`"jsx": "react-jsx"`, and without React's types every JSX tag fails (`TS2875`, `TS7026`). A frontend
|
|
803
|
+
with no JSX can leave it out. React itself is never a dependency of your package: the console's import
|
|
804
|
+
map provides it at runtime (the externals below).
|
|
805
|
+
|
|
806
|
+
`HostApi`'s types (and `@octokit/rest`'s, which it references) use fetch-API globals such as
|
|
807
|
+
`Request`, `Response`, `AbortSignal`, `Blob` and `File`. With TypeScript's default `lib` the DOM
|
|
808
|
+
library declares them; a server-side tsconfig that narrows `lib` to exclude DOM (this repo's own uses
|
|
809
|
+
`["ES2023"]`) needs `@types/node` for them instead. A plugin server runs on Node, so the tsconfig
|
|
810
|
+
below names `"node"` — which is why `@types/node` must be installed: naming it without the package
|
|
811
|
+
fails with `TS2688: Cannot find type definition file for 'node'`.
|
|
812
|
+
|
|
813
|
+
**A tsconfig** — the same `nodenext` shape every in-repo plugin already builds with (§3 above), plus
|
|
814
|
+
the ambient `@ac/host` declaration this package publishes as a types-only subpath. Naming anything in
|
|
815
|
+
`types` REPLACES TypeScript's default auto-inclusion of every installed `@types/*` package (the same
|
|
816
|
+
reason this repo's own `tsconfig.base.json` explicitly lists `"node"`), so `"node"` is named alongside
|
|
817
|
+
the host declaration or Node's own globals (`process`, `Buffer`, …) disappear from your server code:
|
|
818
|
+
|
|
819
|
+
```json
|
|
820
|
+
{
|
|
821
|
+
"compilerOptions": {
|
|
822
|
+
"module": "nodenext",
|
|
823
|
+
"moduleResolution": "nodenext",
|
|
824
|
+
"strict": true,
|
|
825
|
+
"jsx": "react-jsx",
|
|
826
|
+
"types": ["node", "@rackbops/ac-plugin-contract/host"]
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
```
|
|
830
|
+
|
|
831
|
+
`@rackbops/ac-plugin-contract/host` is what replaces copying `plugins/hello/src/ac-host.d.ts` by hand
|
|
832
|
+
(§3, step 1, the in-repo path): the published package's own `./host` export IS that ambient
|
|
833
|
+
declaration, pre-stripped of the shell-internal `ShellChrome`/`__shell` surface a plugin was never
|
|
834
|
+
meant to see, kept in sync with the live shell automatically on every release rather than by a
|
|
835
|
+
vendored copy a plugin author has to remember to update.
|
|
836
|
+
|
|
837
|
+
**Your server code:**
|
|
838
|
+
|
|
839
|
+
```ts
|
|
840
|
+
import type { HostApi, Plugin } from "@rackbops/ac-plugin-contract"
|
|
841
|
+
|
|
842
|
+
export function createPlugin(host: HostApi): Plugin {
|
|
843
|
+
return { activate: async () => { host.log.info("hi") } }
|
|
844
|
+
}
|
|
845
|
+
```
|
|
846
|
+
|
|
847
|
+
**Your frontend code** imports `@ac/host` exactly as an in-repo plugin does (§3) — the specifier is
|
|
848
|
+
never a real dependency of your package (it resolves through the console's own import map at
|
|
849
|
+
runtime, never through your `node_modules`), so don't add it to `package.json` at all; the
|
|
850
|
+
`types` array above is what gives it a type without one.
|
|
851
|
+
|
|
852
|
+
**The rollup externals**, if your frontend builds with a bundler instead of plain `tsc` (§3's own
|
|
853
|
+
"leaving it external" note): the same seven import-map specifiers every in-repo plugin's frontend
|
|
854
|
+
resolves against, so your bundle inlines none of them — `react`, `react/jsx-runtime`, `react-dom`,
|
|
855
|
+
`react-dom/client`, `react-router`, `@ac/host`, `@rackbops/ui-react`.
|
|
856
|
+
|
|
857
|
+
**Vendoring `schema.json` with a drift check.** §5 above vendors a schema snapshot for a plugin
|
|
858
|
+
pinned against a *frozen* contract, which an out-of-repo plugin always is (there is no live contract
|
|
859
|
+
to build against outside this repo at all). Vendor the published copy, and check it on every install
|
|
860
|
+
rather than trusting the pin alone — the console's own manifest schema can change between the
|
|
861
|
+
version you built against and the version you're actually running against:
|
|
862
|
+
|
|
863
|
+
```sh
|
|
864
|
+
mkdir -p vendor && cp node_modules/@rackbops/ac-plugin-contract/schema.json vendor/schema.json
|
|
865
|
+
```
|
|
866
|
+
|
|
867
|
+
```js
|
|
868
|
+
// scripts/check-schema-drift.mjs
|
|
869
|
+
import { readFileSync } from "node:fs"
|
|
870
|
+
|
|
871
|
+
const vendored = readFileSync("vendor/schema.json", "utf8")
|
|
872
|
+
const live = readFileSync("node_modules/@rackbops/ac-plugin-contract/schema.json", "utf8")
|
|
873
|
+
if (vendored !== live) {
|
|
874
|
+
console.error("vendor/schema.json has drifted from @rackbops/ac-plugin-contract -- re-vendor it:")
|
|
875
|
+
console.error(" cp node_modules/@rackbops/ac-plugin-contract/schema.json vendor/schema.json")
|
|
876
|
+
process.exit(1)
|
|
877
|
+
}
|
|
878
|
+
```
|
|
879
|
+
|
|
880
|
+
**Naming your published package.** Follow `@rackbops/ac-plugin-<id>` (#105) — the same `@rackbops/`
|
|
881
|
+
scope this whole family of packages uses (`ac-agent`, `ac-plugin-contract` itself), so
|
|
882
|
+
`<id>` matches your manifest's own `acPlugin.id` and an operator installing it can tell at a glance
|
|
883
|
+
what it's for.
|
|
884
|
+
|
|
885
|
+
## The seven contribution points, in one place
|
|
886
|
+
|
|
887
|
+
| Point | Declared in `contributes` | Supplied by |
|
|
888
|
+
|---|---|---|
|
|
889
|
+
| Panel | `panels[]` (`id`, `title`, `route`, `export`, `nav?`, `order?`) | a component export in `ui.js` |
|
|
890
|
+
| Card | `cards[]` (`id`, `title`, `sizes`, `export`) | a component export in `ui.js` |
|
|
891
|
+
| Action | `actions[]` (`id`, `scope`, `title?`, `confirm?`, `danger?`, `fields?`) | `host.actions.register({ id, run })` |
|
|
892
|
+
| Job | `jobs[]` (`id`, `schedule`, `timeoutSec`, `runOn`) | `runJob(ctx)` dispatching on `ctx.jobId` |
|
|
893
|
+
| Route | `routes[]` (`method`, `path`) | `host.routes.register(method, path, handler)` |
|
|
894
|
+
| Setting | `settings[]` (`key`, `type`, `default?`) | read via `host.settings.get/set` |
|
|
895
|
+
| Attention | `attention: true` | `host.routes.register("GET", "/attention", handler)` -> `{ ok: true, items }` |
|
|
896
|
+
|
|
897
|
+
**Panel order (#472).** The tab strip, the narrow-screen nav drawer and the quickcast panel list all show
|
|
898
|
+
panels in one order, which the host resolves per request: `GET /api/plugins` stamps every panel with an
|
|
899
|
+
integer `rank` and the shell only sorts by it. The rule, in priority order:
|
|
900
|
+
|
|
901
|
+
1. panels named in the operator's **`tab_order`** host setting -- a comma-separated list of panel ids, edited
|
|
902
|
+
in Admin > Settings (or set with `AC_TAB_ORDER`), applied on the next page load -- in the order listed; an
|
|
903
|
+
id that names no loaded panel is ignored, never an error. (A panel `id` has no fixed grammar, but the
|
|
904
|
+
setting cannot name one that contains a space or a comma, so pick panel ids without them);
|
|
905
|
+
2. then panels whose manifest declares **`order`** -- an optional finite number on the panel, lower first --
|
|
906
|
+
with a `bundled` plugin's panel always ahead of an `installed` plugin's, so a third-party package
|
|
907
|
+
declaring `order: -1` cannot jump ahead of the bundled ordered panels (it does still precede every panel
|
|
908
|
+
that declares no `order`);
|
|
909
|
+
3. then every other panel, by plugin directory name and then declaration order (where every panel sat
|
|
910
|
+
before `order` existed).
|
|
911
|
+
|
|
912
|
+
Ties break the same way, so the order is total. `order` is a hint the operator can always override with
|
|
913
|
+
`tab_order`, so leave it off unless your panel belongs at the front (the Board declares `10`, Issues `20`,
|
|
914
|
+
which puts them right after Home). `Home` is always first and is not orderable. A panel that lost a
|
|
915
|
+
contested nav slot still has no tab, and still appears in quickcast at its ranked position.
|
|
916
|
+
|
|
917
|
+
A card never renders its own heading -- the wrapper supplies it from the manifest `title`
|
|
918
|
+
(`HomeCards.tsx`'s `PluginCard`, `<Card><h3>{title}</h3>...</Card>`); a card rendering one of its
|
|
919
|
+
own duplicates it on screen (#399).
|
|
920
|
+
|
|
921
|
+
Two more fields exist on `Contributes` (`packages/contract/src/manifest.ts`) but aren't live
|
|
922
|
+
contribution points yet: **`themes`** (`ThemeContribution: {id, title?}`) is schema-declared with no
|
|
923
|
+
host-side reader at all -- validates, but nothing consumes it. **`provides`/`consumes`**
|
|
924
|
+
(`SlotRef: {slot, version}`) ARE live and load-bearing, but for a different kind of plugin than this
|
|
925
|
+
guide's worked example: a slot (`issues@1`, `artifacts@1`, `auth@1`, `notify@1`) is a
|
|
926
|
+
named/versioned TypeScript interface one plugin **provides** and others **consume** -- the host
|
|
927
|
+
refuses to activate a consumer whose slot has no provider, and says why (`resolveSlots`,
|
|
928
|
+
`packages/host/src/plugins/slots.ts`, re-exported from `plugins/index.ts`). `ac-auth-cf-access`
|
|
929
|
+
(`auth@1`) and `ac-notify-discord` (`notify@1`) are the reference *providers*; see their own
|
|
930
|
+
`src/server.ts` for `host.slots.provide(slot, version, impl)`, and `design.md` §4 for the slot
|
|
931
|
+
contract itself. `hello` contributes none of the six points above via a slot (nor `attention` -- its own manifest
|
|
932
|
+
declares panels/cards/actions/jobs/routes/settings only, matching the intro above) -- it's the
|
|
933
|
+
reference for those six, not for slot provision or the attention rail; `plugins/scans` and
|
|
934
|
+
`plugins/overview` are the worked examples for `contributes.attention` (§8 above).
|