@rackbops/ac-plugin-contract 2.0.0-alpha.41 → 2.0.0-alpha.43

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 CHANGED
@@ -113,14 +113,21 @@ Rules the host enforces, at validation or load time:
113
113
  installed plugin errors, naming the bundled owner — and the bundled one keeps running unaffected. This
114
114
  is what stops a third-party package from disabling a bundled plugin (e.g. the auth provider, or `admin`
115
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.
116
+ error, the same for two installed plugins with no bundled plugin in the collision. **An installed plugin
117
+ may neither provide nor consume the `auth` slot** (#452b, #604): the install gate (`ac plugins install`
118
+ and the admin Plugins card's install, which share it) refuses a manifest whose `contributes.provides` or
119
+ `contributes.consumes` names `auth` (any version), and
120
+ at runtime `slots.provide`/`slots.resolve` refuse that slot to an installed plugin whatever its manifest
121
+ declares. Every other slot, `notify@1` included, is open to an installed plugin.
117
122
  - **`hostApiVersion`** must equal the host's `HOST_API_VERSION` (currently `1`); a mismatch is rejected.
118
123
  - **`kind: "in-process"`** must declare a `server` entry — the *loader* records an error and skips a plugin that
119
124
  doesn't (the schema itself requires only `id`, `hostApiVersion` and `kind`). `contributes` and every sub-field
120
125
  is optional in the schema — contribute only the points you use; each contribution's own required fields are
121
126
  schema-enforced.
122
- - **Jobs** (#137): a job's **`timeoutSec`** must be at least `1` (the schema's `minimum`; a zero or negative
123
- timeout would fire at once and time out any run that awaits real I/O), and a job **`id`** must be unique
127
+ - **Jobs** (#137, #580): a job's **`timeoutSec`** must be at least `1` and at most `2147483` (the schema's
128
+ `minimum` and `maximum`; a zero or negative timeout would fire at once and time out any run that awaits real
129
+ I/O, and about 24.8 days is the longest delay Node honours, so a longer or non-finite one would be fired at
130
+ once too), and a job **`id`** must be unique
124
131
  *within its plugin* -- two jobs with
125
132
  one id are rejected as `duplicate job id "<id>" within the plugin`, like a duplicate panel, card or action id.
126
133
  Job ids are plugin-scoped (a job is addressed as `<pluginId>/<jobId>`), so two different plugins may declare the
@@ -185,10 +192,16 @@ export const createPlugin: CreatePlugin = (host) => ({
185
192
  click, whether or not the manifest sets `confirm` -- there is no second "Confirm" click. `danger: true` gives
186
193
  that primary button the danger style, gives its Ctrl/Cmd+K palette row a danger badge, and stops Enter pressed in
187
194
  one of the form's fields from submitting it (a click on the button, or Enter or Space on the focused button,
188
- still fires; Enter in a text area is a newline). A non-danger action still submits on Enter in any field,
189
- including a checkbox or a radio (browsers treat that Enter as a submit): mark an action `danger` if it must
190
- not fire from a stray Enter. The primary disables only while a request is in flight, so an action that
191
- answers faster than an operator's second click can be run twice: make its `run` tolerate an overlapping call.
195
+ still fires; Enter in a text area is a newline). A non-danger action still submits on Enter in a text field;
196
+ Enter on a checkbox, a radio or a select never submits any action (#568), so ticking a label and pressing
197
+ Enter does not fire it: mark an action `danger` if it must not fire from a stray Enter in a text field either.
198
+ After a successful result the primary stays disabled until the modal closes, so an action that succeeded is
199
+ never run again from that modal, however fast it answers (a double-click whose first run resolves inside the
200
+ second click's interval, or a repeated Enter, cannot run it twice). The modal closes itself 1.5 s after the
201
+ success unless the user clicks in it first, and then it stays open, locked, until Cancel or Escape; opening the
202
+ same action again while that locked modal is up reuses it and does not re-run it. After a failed result the
203
+ primary is enabled again and a retry runs, so a double-click whose first run fails fast can run the action
204
+ twice (the second a retry): an action whose failure may have partly applied should tolerate that.
192
205
  `confirm` stays a valid manifest flag, so an existing manifest keeps loading, but neither the modal nor the
193
206
  palette acts on it.
194
207
  - **Job** — implement `runJob(ctx)` and dispatch on `ctx.jobId`. The host runs only jobs you declared in
@@ -197,7 +210,7 @@ export const createPlugin: CreatePlugin = (host) => ({
197
210
  newest 200 runs of that job (per machine, for a `runOn: "agent"` job) and drops runs older than 30 days,
198
211
  though never a job's (or machine's) newest run, so `host.jobs.lastRun` still answers for a job that runs
199
212
  less often than monthly. A job that runs every minute therefore no longer grows the run history without
200
- bound. `ctx.signal` aborts at `timeoutSec` seconds (at least 1, #137). `runOn: "host"` runs your `runJob`
213
+ bound. `ctx.signal` aborts at `timeoutSec` seconds (1 to 2147483, #137, #580). `runOn: "host"` runs your `runJob`
201
214
  once, in the host process.
202
215
  `runOn: "agent"` (#51) also runs your `runJob` in the host process — the agent itself runs no plugin
203
216
  code — but once per currently paired machine, with `ctx.machine` set to `{ id, name }`; do the
@@ -432,6 +445,31 @@ strip of the panel uncovered between the header and the overlay; and the panel i
432
445
  the bar collapses. The layout arithmetic and the whole-pixel rounding were measured in desktop Chromium at
433
446
  375 px; the phone behaviours in this list are derived from the CSS, not observed on a phone.
434
447
 
448
+ ### A frontend that bundles a dependency (#98)
449
+
450
+ "Only the import-map externals" is what keeps one React on the page; it does not forbid a plugin from
451
+ bundling a library the import map does not carry. `plugins/ac-artifacts-weft` inlines
452
+ `@lepid-labs/weft-embed` (a framework-free reader, 1.15 MB) and its stylesheet into `dist/ui.js`, and its
453
+ `rollup.ui.config.mjs` is the reference. (The rollup configs of `issues-classic` and `ac-auth-cf-access`
454
+ inline a dependency into their SERVER bundle, and every other plugin's UI config inlines nothing; none of
455
+ them is a model for this.) What a bundled dependency changes:
456
+
457
+ - **One file.** The host serves exactly the manifest's `frontend` and never a sibling, so a stylesheet is a
458
+ string in the bundle that the panel injects as a `<style>`, once (the config's `inlineCss` plugin turns
459
+ `import css from "pkg/style.css"` into that string), never a `<link>` to a `.css` file.
460
+ - **Externals stay bare, everything else is inlined** (`@rollup/plugin-node-resolve`). The dependency goes in
461
+ `dependencies`, pinned exactly if its rendering is part of what you ship, and is absent at runtime: the
462
+ image carries `dist/` and no `node_modules`.
463
+ - **A library without types** gets a `src/<lib>.d.ts` that declares the module, listed in the UI tsconfigs.
464
+ - **Guard what shipped with a bundle test that runs the real build** (`src/bundle.test.ts` is the example):
465
+ anchor the import regex to the start of a line (inlined code carries doc comments such as ` * import
466
+ {unified} from 'unified'`), assert that no bare specifier outside the externals survives and that the
467
+ library is not one, that nothing needs `require(`, `import.meta` or `eval`, what the bundle exports, and a
468
+ size budget.
469
+ - **A stylesheet you did not write must not restyle the shell.** Check its selectors for what is scoped (a
470
+ parser over the CSS is enough: weft's 0.2.0 rules are all scoped to its own root), and have the smoke spec
471
+ compare the shell's computed styles with and without it in the page.
472
+
435
473
  ### The `@ac/host` browser module
436
474
 
437
475
  `@ac/host` (typed as `BrowserHostApi` in `@ac/contract`) is the one shared module instance the shell and every
@@ -554,10 +592,13 @@ same-origin iframe (the Artifacts report preview does) forwards the hotkey: bind
554
592
  iframe's `contentDocument` (skip a frame whose `contentDocument` is null or throws: cross-origin), and on Ctrl
555
593
  or Cmd plus `k` with no Alt and no Shift, `preventDefault()` and dispatch `new CustomEvent("ac:quickcast-key")`
556
594
  on the parent `window`. The shell opens the palette under the keystroke's own guard: only when no other dialog
557
- is open, and an event while the palette is already open does nothing (closing stays with Ctrl/Cmd+K and
558
- Escape). Remove the listener when the frame goes away. Closing the palette with Escape leaves focus on the
559
- shell page rather than back inside the frame (observed in Chromium), so the user clicks the frame to type in
560
- it again.
595
+ is open, and an event while the palette is already open leaves it open (closing stays with Ctrl/Cmd+K and
596
+ Escape). Remove the listener when the frame goes away. Closing the palette with Escape or Ctrl/Cmd+K again
597
+ returns focus to the frame element (#588): the native dialog's own restore does not put it back there for a
598
+ palette opened from inside a frame (observed in Chromium), so the shell remembers the element that had focus
599
+ when the event arrived and focuses it again when focus is nowhere useful after the close (on `body`, or
600
+ still on a node of the palette that just closed). Which element inside the frame's own document has focus
601
+ afterwards is the browser's call.
561
602
 
562
603
  `fetch` makes a same-origin, credentialed, CSRF-aware request to a host/plugin route — a relative `path`
563
604
  resolves against the console origin; a cross-origin absolute URL throws rather than being followed. A panel
@@ -995,7 +1036,25 @@ integer `rank` and the shell only sorts by it. The rule, in priority order:
995
1036
  Ties break the same way, so the order is total. `order` is a hint the operator can always override with
996
1037
  `tab_order`, so leave it off unless your panel belongs at the front (the Board declares `10`, Issues `20`,
997
1038
  which puts them right after Home). `Home` is always first and is not orderable. A panel that lost a
998
- contested nav slot still has no tab, and still appears in quickcast at its ranked position.
1039
+ contested nav slot still has no tab, and still appears in quickcast at its ranked position (once the
1040
+ operator's choice has flipped the slot, the position the other panel's tab would hold: the two swap ranks, #629).
1041
+
1042
+ **Contesting a nav slot (#65, #100).** A panel's `nav` names the slot it competes for the tab of. A slot with
1043
+ one panel is owned by it. When two or more panels from plugins that loaded without error name the same slot,
1044
+ the host picks one owner per request: the operator's choice, stored in the `nav.<slot>.panel` host setting
1045
+ (written through `POST /api/nav-slot` by the header's switcher, which renders only for a contested slot; an
1046
+ `AC_NAV_<SLOT>_PANEL` environment variable, when set, wins over it and pins the slot: the switcher for it is
1047
+ disabled, naming the variable, and `POST /api/nav-slot` answers `409`), else the smallest panel id. A panel
1048
+ that does not own its slot has no tab but keeps its route and its quickcast
1049
+ entry. The slot's tab stays where the default owner's would be, whichever panel owns it (#629), so a
1050
+ flip changes what the tab shows, not where it sits (a `tab_order` that names the chosen owner places it
1051
+ where the operator said). So declaring a `nav` another panel already has changes two things for every operator: the header gains
1052
+ the switcher, and the default owner is whichever panel id sorts first (an id that sorts after the existing
1053
+ owner leaves the default alone; one that sorts before it takes the tab from every operator who has not made a
1054
+ choice, and, since #629, also moves the slot's tab for an operator who has: its position is where the default
1055
+ owner's would be, and the default owner is the smallest id). The bundled example is the `artifacts` slot: the native viewer's `viewer` (`plugins/artifacts`, route
1056
+ `/artifacts`, the default) and the weft reader's `weft-reader` (`plugins/ac-artifacts-weft`, route
1057
+ `/artifacts-weft`).
999
1058
 
1000
1059
  **`isolation` (#124).** A panel may declare `isolation: "iframe"`: the value is reserved (design §4) and the
1001
1060
  manifest accepts it, but nothing implements it in 2.0 and the host ignores it -- panels run in-process today,
@@ -1015,7 +1074,14 @@ refuses to activate a consumer whose slot has no provider, and says why (`resolv
1015
1074
  `packages/host/src/plugins/slots.ts`, re-exported from `plugins/index.ts`). `ac-auth-cf-access`
1016
1075
  (`auth@1`) and `ac-notify-discord` (`notify@1`) are the reference *providers*; see their own
1017
1076
  `src/server.ts` for `host.slots.provide(slot, version, impl)`, and `design.md` §4 for the slot
1018
- contract itself. `hello` contributes none of the six points above via a slot (nor `attention` -- its own manifest
1077
+ contract itself. The consumer half is `host.slots.resolve<T>(slot, version)` (#97): the provider
1078
+ bound for a slot, or `null` when none is. It throws unless the plugin's own manifest lists the exact
1079
+ `{slot, version}` in `contributes.consumes`, and an installed plugin may not resolve `auth` at all. Call
1080
+ it at request time (inside a route handler or job), never in `activate()`: providers bind during
1081
+ activation in load order, so a consumer that activates first would read `null` there for good.
1082
+ `ac-artifacts-weft` (`artifacts@1`) is the reference consumer: the second plugin to declare `consumes`
1083
+ for it, after `artifacts`, which reads `provider-artifacts`' HTTP routes from the browser, and the first to
1084
+ call `resolve`. `hello` contributes none of the six points above via a slot (nor `attention` -- its own manifest
1019
1085
  declares panels/cards/actions/jobs/routes/settings only, matching the intro above) -- it's the
1020
1086
  reference for those six, not for slot provision or the attention rail; `plugins/scans` and
1021
1087
  `plugins/overview` are the worked examples for `contributes.attention` (§8 above).
@@ -175,6 +175,16 @@ export interface SlotRegistry {
175
175
  /** Register this plugin's provider instance for `slot@version`. Throws if that slot already has a
176
176
  * provider (from this plugin or another) -- auth/notify are singletons, never a last-write-wins. */
177
177
  provide(slot: string, version: number, impl: unknown): void;
178
+ /** The provider bound for `slot@version`, cast to `T` (a slot's interface in `slots.ts`, e.g.
179
+ * `ArtifactsProvider`), or `null` when nothing is bound. A plugin may resolve only a slot its
180
+ * manifest lists in `contributes.consumes`; any other throws, naming the plugin and the slot. An
181
+ * installed plugin may not resolve the `auth` slot at all, declared or not (as it may not provide it);
182
+ * the install gate also refuses a manifest that declares `provides` or `consumes` for `auth` (#604),
183
+ * so this call-time refusal is what remains for a plugin staged before that check.
184
+ * Call it at request time (inside a route handler or job), never in `activate()`: providers bind
185
+ * during activation in load order, so a consumer that activates before its provider would read `null`
186
+ * there for good (#97). */
187
+ resolve<T = unknown>(slot: string, version: number): T | null;
178
188
  }
179
189
  /** A logger prefixed with the plugin id. */
180
190
  export interface LogService {
@@ -112,8 +112,11 @@ export interface JobContribution {
112
112
  schedule: string;
113
113
  /**
114
114
  * Per-run timeout in seconds. Must be at least 1: a zero or negative value would fire the timeout at
115
- * once, timing out any run that awaits real I/O (#137).
115
+ * once, timing out any run that awaits real I/O (#137). And at most 2147483, about 24.8 days: Node
116
+ * clamps a longer or non-finite `setTimeout` delay to 1 ms, which fires it at once, the same failure
117
+ * (#580).
116
118
  * @minimum 1
119
+ * @maximum 2147483
117
120
  */
118
121
  timeoutSec: number;
119
122
  runOn: JobRunOn;
package/dist/slots.d.ts CHANGED
@@ -99,19 +99,60 @@ export interface IssuesProvider {
99
99
  /** A repo's own labels, independent of the standard (E5 #58). */
100
100
  repoLabels(repo: RepoRef): Promise<Label[]>;
101
101
  }
102
- /** A node in the artifacts report tree (v1 seam; E6 #68 enriches). */
102
+ /** A node in the artifacts report tree (v1 seam; E6 #68 enriches).
103
+ *
104
+ * A `report` node's `id` is its markdown source's path relative to the store's `artifacts/` directory
105
+ * (`Code-Reviews/2026-09-01/summary.md`), the id `source`/`read` take; a report with no markdown source
106
+ * (a rendered `.html` nothing in the store produced) falls back to its `href`. A `category` node's id is
107
+ * `category:<name>` and a `run` node's `run:<category>/<run>`; neither names a readable source. */
103
108
  export interface ArtifactNode {
104
109
  id: string;
105
110
  title: string;
106
111
  kind: "category" | "run" | "report";
107
112
  children?: ArtifactNode[];
113
+ /** A report's markdown source path (the same relative path its `id` carries), absent when the report has
114
+ * no markdown source. A node without it is a rendered `.html` only: `source`/`read` then answer that
115
+ * rendered HTML, not markdown, so a consumer that expects markdown must skip such a node. Report nodes
116
+ * only (#97). */
117
+ md?: string;
118
+ /** A report's viewer path: the rendered `.html` once rendered (`provider-artifacts`' `GET /render/<href>`
119
+ * serves it from its docroot), else the `.md` source path, which that route does not serve (it reads the
120
+ * docroot only; read the text through `source`). Report nodes only (#97). */
121
+ href?: string;
122
+ }
123
+ /** One `search` result: a section of one report. */
124
+ export interface ArtifactSearchHit {
125
+ /** The id of the report the hit is in, built the same way as `ArtifactNode.id` for a report that has a
126
+ * markdown source (search only reads markdown), so a hit opens through `source`/`read`. A report the
127
+ * tree leaves out, such as a rendered `index` page, can still surface here. */
128
+ id: string;
129
+ /** The report's title as the search reads it (its first level-1 heading, else its file name with the
130
+ * extension dropped and `-`/`_` shown as spaces). For a rendered report that can differ from the tree
131
+ * node's title, which comes from the rendered page's `<title>`. */
132
+ title: string;
133
+ /** The matching section's heading id inside the rendered report, without a leading `#`. Absent when the
134
+ * report is not rendered, when its rendered heading count disagrees with the source's, or when the
135
+ * section has no id (a hit on the report's preamble, or an empty `id` attribute). */
136
+ anchor?: string;
137
+ /** A short excerpt around the match. */
138
+ snippet: string;
108
139
  }
109
- /** `artifacts@1`: tree/read/search/render-status. */
140
+ /** `artifacts@1`: the report tree, one report's source by id, and ranked search over the markdown
141
+ * sources. A consumer reaches it in-process through `HostApi.slots.resolve("artifacts", 1)` (#97). */
110
142
  export interface ArtifactsProvider {
111
143
  /** The report tree. */
112
144
  tree(): Promise<ArtifactNode[]>;
113
- /** Raw source of one report by id. */
145
+ /** Raw source of one report by id; throws exactly when `source` would answer `null`. */
114
146
  read(id: string): Promise<string>;
147
+ /** Ranked cross-report search over the store's markdown sources, at most 50 hits (the cap `GET /search`
148
+ * has); `CONTEXT.md`, `CONTENT.md`, `Archive/` and `.sync-inbox/` are not searched. An empty array for
149
+ * an empty query. */
150
+ search(q: string): Promise<ArtifactSearchHit[]>;
151
+ /** Raw source of one report by id: `null` when no `.md` or `.html` resolves at that path inside the
152
+ * store (or the path escapes it). The tree, not `source`, says what counts as a report: a path the tree
153
+ * skips (an `index` page whose html exists, `CONTEXT.md`, anything under `Archive/`) still answers its
154
+ * text here. */
155
+ source(id: string): Promise<string | null>;
115
156
  }
116
157
  /** An authenticated identity. */
117
158
  export interface Identity {
package/dist/views.d.ts CHANGED
@@ -14,6 +14,15 @@ export interface PluginPanelView {
14
14
  nav?: string;
15
15
  export: string;
16
16
  navOwner: boolean;
17
+ /**
18
+ * Present (and only ever `"env"` today) on every contestant of a contested nav slot whose choice of owner
19
+ * an `AC_NAV_<SLOT>_PANEL` environment variable fixes (#628), so the shell can render that slot's switcher
20
+ * control disabled with the reason, and a script can expect `409` from `POST /api/nav-slot` for it. The
21
+ * variable pins the slot whatever its value is (one naming no contestant leaves the default owner, and the
22
+ * slot is still pinned). Absent otherwise (never `undefined`-valued). Optional, so a shell built before it
23
+ * ignores it.
24
+ */
25
+ navPinnedBy?: "env";
17
26
  /**
18
27
  * This panel's position among every panel in the response (#472): a non-negative integer, lower
19
28
  * first, resolved by the host per request from the operator's `tab_order` setting, then each
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rackbops/ac-plugin-contract",
3
- "version": "2.0.0-alpha.41",
3
+ "version": "2.0.0-alpha.43",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "The artifact-console plugin ABI for out-of-repo plugin authors: HostApi, the acPlugin manifest contribution points, the generated manifest JSON Schema, and the @ac/host ambient browser declaration.",
@@ -35,9 +35,9 @@
35
35
  },
36
36
  "devDependencies": {
37
37
  "@ac/contract": "0.0.0",
38
- "@types/node": "^26.0.0",
38
+ "@types/node": "^26.6.3",
39
39
  "typescript": "^7.0.0",
40
- "vitest": "^5.0.0"
40
+ "vitest": "^5.0.2"
41
41
  },
42
42
  "scripts": {
43
43
  "build": "node build.mjs",
package/schema.json CHANGED
@@ -348,8 +348,9 @@
348
348
  },
349
349
  "timeoutSec": {
350
350
  "type": "number",
351
- "description": "Per-run timeout in seconds. Must be at least 1: a zero or negative value would fire the timeout at once, timing out any run that awaits real I/O (#137).",
352
- "minimum": 1
351
+ "description": "Per-run timeout in seconds. Must be at least 1: a zero or negative value would fire the timeout at once, timing out any run that awaits real I/O (#137). And at most 2147483, about 24.8 days: Node clamps a longer or non-finite `setTimeout` delay to 1 ms, which fires it at once, the same failure (#580).",
352
+ "minimum": 1,
353
+ "maximum": 2147483
353
354
  },
354
355
  "runOn": {
355
356
  "$ref": "#/definitions/JobRunOn"