Skip to content

Cockpit plugin runtime — view-plugins + app-plugins unified into the shell - #49

Merged
adbarc92 merged 28 commits into
mainfrom
feat/plugin-runtime
Aug 16, 2026
Merged

Cockpit plugin runtime — view-plugins + app-plugins unified into the shell#49
adbarc92 merged 28 commits into
mainfrom
feat/plugin-runtime

Conversation

@adbarc92

@adbarc92 adbarc92 commented Aug 9, 2026

Copy link
Copy Markdown
Owner

Lands the cockpit plugin runtime: view-plugins (sandboxed iframe + MessagePort) and app-plugins
(native child webview), unified into one shell switcher.

Built by the plugin-runtime swarm on 2026-07-16/17 per
docs/SWARM-HANDOFF-plugin-runtime.md, then
left unopened. Reopened and re-verified against current main.

What's in it

  • Lane V — view-plugin core (dc37806): store.svelte.ts command-sink extraction, bridge.ts
    (PluginBridge/PluginSession over MessagePort + command policy + flood/rate kill), loader.ts,
    cockpit/plugin-sdk/, plugins/reference/.
  • Lane A — app-plugin core (e3a688f): src-tauri/src/plugins/* extensions, Tauri unstable
    pinned =2.11.2, capabilities/default.json app::* capability, Audience proving manifest +
    dev-list discovery.
  • Lane S — integration (cda3cc1): sole writer of the three shared files. Host CSP in
    tauri.conf.json; new view_plugins.rs (production ccplugin:// asset handler); new
    embedding.rs (plugin_show/plugin_hide/plugin_set_rect, async, park-off-screen, warm-pool
    LRU cap 3); lib.rs registration; one topbar switcher in App.svelte.

Both spike findings are baked in rather than merged: P3's async-command fix (sync deadlocks webview
creation) and P4's opaque-origin CORS fix (script-src … http://ccplugin.localhost, ACAO *, CSP
as a response header). The spike harnesses themselves stay unmerged as throwaway, per the handoff.

Automated gates — re-verified against current main

Merges conflict-free (27 files). Re-run today, not copied from the original swarm record:

Gate Result
cargo test (cockpit/ui/src-tauri) 28 passed, 0 failed
npm test (cockpit/ui) 133 passed, 18 files
npm run check 0 errors / 0 warnings, 352 files

Identical to the numbers Lane S recorded on 2026-07-17, so three weeks of main movement has not
disturbed it. Coverage includes the bridge handshake at 100× zero-drop, command policy/flood-kill,
single-sink store, and the App.overlay/Switcher/ApprovalOverlay ops-grid regression canaries.

⛔ Merge blocker — interactive smoke not yet run

Draft on purpose. The automated gates cannot assert the compositing and packaging behaviour; that
needs a watched window on the target machine. Full checklist in
spikes/SPIKE-RESULTS.md under
"REMAINING HUMAN GATE". In short — dev run: switcher shows FLEET/PROJECTS/REFERENCE/AUDIENCE; the
reference view-plugin completes plugin-helloready with a policed launch round-trip and no
network; the Audience app-plugin's native webview stays glued on resize, parks off-screen when a
host overlay opens, restores on close, and leaks no orphaned webview when switching away and back;
Vite HMR still works under the new host CSP. Then the same again in a packaged build
(npm run bundle), which is the only way to prove ccplugin:// resolves without a dev server.

Record PASS/FAIL in spikes/SPIKE-RESULTS.md and mark this ready for review once it passes.

adbarc92 added 8 commits July 16, 2026 17:54
…caps, Audience manifest

- Cargo.toml: enable Tauri `unstable` feature (child-webview multiwebview APIs) and
  PIN with `=2.11.2` (unstable is not semver-stable; note for upgrade docs).
- capabilities/default.json: add isolated `app-plugins` capability scoped to the
  `app::*` webview-label glob (spec §6 step 0) granting host visibility/position/focus
  webview commands only — no core:default/shell (spec §5 no host<->app bridge).
- Audience proving manifest (app-plugins/audience/app-plugin.json): credential-free dev
  path — fake providers as BUILD args (baked), devAuth via runtime env + DEV_WORKSPACE_ID/
  DEV_USER_ID; ready probe accepts 3xx for the /dashboard redirect.
- manager.rs: CC_APP_PLUGINS_DEV dev-list discovery root (user dir still wins on collision)
  + unit tests for the shipped manifest posture and dev-root discovery.

cargo test: 28 passed / 0 failed. Manifest/discovery/lifecycle scaffolds already present
on main extended in place, not rewritten.
…bridge+policy, SDK, loader, reference plugin

Lane V (Spec-A steps 2–5). Sandboxed-iframe view-plugin runtime:

- store.svelte.ts: single command-sink support without breaking the live
  reconnect/socket path. Adds a plain dirty-set accumulator (drainDirty) the
  bridge drains on its tick, a seq-tagged per-unit log tail (logsSince) for
  log-append, and guarded socket-opening (ensureStream) + optimistic-insert
  guard so a bridge launch racing reconnect() yields exactly one unit + one
  socket. Preserves the started/seq-dedup/if-exists double-connect guards.

- bridge.ts: MessagePort bridge. plugin-hello→init→ready handshake (identity
  bound to the transferred port, not the null origin); dirty-delta `state`
  pushes with per-unit lastEmitted suppression + full-snapshot baseline reset;
  log-append/log-reset; command policy (shape/authority/unknown-id/over-bound),
  token-bucket rate limit + inbound-flood→port.close() kill; command-ack with
  reqId correlation; real-launch staged to the host overlay (demo-only for
  plugins). Coarse `degraded` only — no raw health leaked.

- loader.ts: manifest validation + apiVersion gate + capability negotiation +
  dev|packaged iframe-src resolution behind a single injected PluginSource.

- cockpit/plugin-sdk: connect()/attach() plugin client (browser ESM + .d.ts).

- plugins/reference: reference plugin exercising the FULL surface (dirty state,
  log-append, policed demo launch, command-ack rejection, awaiting-approval
  presence indicator). Pure model.js is unit-tested.

Tests: 53 green across store(sink/fold/overlay), bridge (handshake 100×,
policy, flood-kill, ack), loader, SDK↔session, reference model.

The overlay is unchanged (already built); the bridge reads host-owned
awaitingApproval state, it does not drive its own modal.
… policy, plugin-sdk, loader, reference plugin
…lities, Audience manifest, dev-list discovery
- tauri.conf.json: host CSP (frame-src http://ccplugin.localhost + fleetd connect-src)
- lib.rs: register ccplugin:// scheme (ACAO + plugin-doc CSP header) + plugin_show/hide/set_rect + WebviewPool
- view_plugins.rs (new): production ccplugin:// asset handler (dev roots + packaged resources), P4 findings
- embedding.rs (new): async app-plugin child-webview show/hide(park)/set_rect + warm-pool LRU, P3 findings
- App.svelte: one topbar switcher across Fleet + Projects + view-plugin iframe + app-plugin webview;
  PluginBridge handshake wiring, hide-on-overlay compositing, ResizeObserver rect tracking

Verified: cargo test 28/0, npm test 133/0, npm run check 0 errors, npm run build clean.
Ops-grid + overlay regression tests unchanged.
This file was referenced by docs/SWARM-HANDOFF-plugin-runtime.md but existed
only as an untracked file inside the spike/app-plugins-webview-v2 worktree --
in no commit, on no branch, one 'git clean' from being lost.

It is the provenance for this branch's design decisions: the park-off-screen
keep-warm choice (hide()/show() forces a repaint/reload, losing scroll and
session state), the async-command fix for the add_child deadlock, and the
verbatim Tauri 2.11.2 unstable webview API that embedding.rs copies.

Records the P3 decision as LEANING GO with packaged gates 2/4 and Gate 5
(lifecycle, no orphans) still open -- both of which remain outstanding and
belong to this PR's interactive smoke gate.
The Phase-6 interactive smoke (checklist 1.5) froze the whole UI on the first
AUDIENCE tab activation. `plugin_launch` was a synchronous #[tauri::command], so
it ran on the main event-loop thread -- the same finding that already forced the
embedding commands to be async -- and blocked there on `docker compose build`
plus the health/ready probe budgets. The code carried a standing note predicting
this ("may block up to the probe timeout (~180 s) ... can move to a background
task"); the smoke is what came due.

Dispatch the start sequence to a dedicated OS thread and return immediately. A
plain thread, not an async-runtime worker: every seam in the sequence is
blocking (Command::status, ureq, thread::sleep), so handing it to the runtime
would starve a worker and merely relocate the stall.

That changes the command's contract -- Ok now means "dispatched", not "healthy"
-- so the shell must stop treating it as readiness. App.svelte no longer
fabricates pluginState[id]='healthy' and no longer calls plugin_show directly;
the existing compositing $effect composites on the plugin://state `healthy`
event instead. This half is load-bearing rather than cosmetic: with launch
returning early, the old code would have pointed the child webview at a URL that
is not serving yet. The tab now shows live building -> starting -> healthy
progress instead of a frozen window.

Pinned by src/App.appPlugin.test.ts (verified red -> green): no compositing
before `healthy`, and none at all on `error`.

Gates: cargo test 28 passed; npm test 135 passed (19 files); npm run check 353
files 0 errors/0 warnings; cargo clippy exit 0.

Smoke results recorded in spikes/SPIKE-RESULTS.md. Gate 5 container teardown
PASSES (docker ps empty after quit, 0-container baseline), but the app process
survived the window close -- recorded as an open anomaly, not diagnosed.
…at is left

The interactive smoke for PR #49 ran for the first time and failed at item 1.5:
plugin_launch blocked the main event-loop thread for the whole docker build.
Fixed in db74a47. The fix is verified by cargo test / npm test / npm run check /
clippy and a new regression test, but NOT in a watched window -- re-running 1.5
and finishing the other nine dev items plus the packaged pass is the next action.

Rewrites the STATUS.md state summary in place, appends the session-log entry, and
adds a bounded pickup block to CLAUDE.md pointing at both. Records two traps worth
not rediscovering: a running dev app blocks rebuilds of the tauri crate (sidecar
file lock), and Gate 5 requires a graceful quit rather than a kill.
adbarc92 added a commit that referenced this pull request Aug 14, 2026
Four of the allows I added claimed the code was waiting on the Phase-6 embedding
layer. Verified by grep across origin/main and origin/feat/plugin-runtime — that
is false for these:

  state::check_crash        only its own two unit tests call it
  Manifest::webview         read only by test assertions
  WebviewCfg (all fields)   read only by test assertions
  Manifest::window_title    one test assertion, no production caller
  Spawner::has_exited       reachable only via check_crash, so dead with it

These are unimplemented features with no caller anywhere, not code whose caller
is en route. A plugin author setting 'popups: block' today gets no blocking. The
allows stay — deleting the code would amputate work in flight on #49 — but the
comments now say plainly that nothing calls this on any branch, so the allow
cannot quietly become the permanent resting place of two unbuilt features.

PluginManager::url_for keeps its original comment: that one is genuinely waiting
on a caller, verified at embedding.rs:99 on feat/plugin-runtime. Comments-only
change, no code touched.
adbarc92 added a commit that referenced this pull request Aug 15, 2026
* ci: gate on rustfmt, clippy, svelte-check and the cockpit crate's tests

Four gates the workflow was missing:

  - cargo fmt --all -- --check
  - cargo clippy --workspace --all-targets -- -D warnings
  - npm run check (svelte-check + tsc)
  - cargo test for cockpit/ui/src-tauri

The last one closes a real hole. cockpit/ui/src-tauri declares its own empty
[workspace] table, so it is a standalone workspace that the root manifest does
not list as a member — 'cargo test --workspace' run from the repo root never
reached its tests, and that suite (the app-plugin state machine, manifest
validation, discovery) had only ever been run by hand. The fmt and clippy gates
run once per manifest for the same reason.

Compiling the tauri crate needs the WebKitGTK system deps the build job already
installs, plus the fleetd sidecar binary, because tauri-build resolves the
externalBin resource at compile time and hard-errors without it. Both the lint
and cockpit-test jobs therefore repeat that setup.

* style: apply cargo fmt to both cargo workspaces

Pure rustfmt output, no behavior change — the new 'cargo fmt --all -- --check'
gate's baseline. Neither workspace had ever been run through rustfmt, so this is
the whole backlog in one pass; there is no rustfmt.toml, so these are the stock
defaults. Mostly struct literals and multi-argument calls being split across
lines.

* fix(cockpit): clear clippy debt in the src-tauri crate

The root workspace was already clean under '-D warnings'; the cockpit crate had
never been linted and had twelve findings.

Three were real and are fixed: a nonminimal_bool in the discovery filter (the
pruned test is now a named binding rather than a negated conjunction) and two
unnecessary_to_owned in the check_crash tests.

The other nine are dead_code on plugin-runtime scaffolding that is deliberately
ahead of its caller — the Phase-6 embedding layer that will read the webview
manifest surface, the crash watcher that will poll Spawner::has_exited via
check_crash, and the STOPPED rest state. Deleting any of it would amputate an
in-flight feature, so each site gets a narrowly scoped #[allow(dead_code)] with
a comment naming the caller it is waiting on. No blanket allows, and nothing at
module or crate level.

* test(cockpit): give each local_projects test its own temp dir

The tmp() helper keyed its directory on the process id alone, so all four
local_projects tests shared one path while running concurrently in the same test
binary. One test's remove_dir_all raced another's create_dir_all and the run
failed with AlreadyExists or PermissionDenied — 5 times in 20 on Windows.

Pre-existing, and invisible until now because these tests were never run by CI.
The new 'cargo test (cockpit)' gate would have inherited it as a ~25% flake
rate, so: an atomic counter makes each directory unique. 0 failures in 30 runs
after. Test-only change, no production code touched.

* test(cockpit): make the absolute-cwd test pass off Windows

keeps_absolute_cwd_as_is leaned on the AUDIENCE_JSON fixture's 'D:/…' cwd. That
is only an absolute path on Windows; everywhere else Path::is_absolute says no,
resolved_cwd joins it to the manifest dir, and the assertion fails.

Production code is fine — is_absolute is doing exactly its job. Only the test
baked in a platform. It now picks an absolute path the host agrees with.

Surfaced by the new 'cargo test (cockpit)' gate, which is the first time this
suite has ever run on Linux.

* ci: gate on the frontend vitest suite

Fifth gate: 'npm test' in cockpit/ui, 91 tests across 13 files. Same shape as
the svelte-check job — node only, no Rust, no system deps.

Closes a half-gated pair rather than just a coverage gap. The regression pin for
the plugin_launch main-thread freeze is split across two suites: a Rust test,
now covered by 'cargo test (cockpit)', and cockpit/ui/src/App.appPlugin.test.ts,
covered here. That file is not on main yet — it lands with the plugin-runtime
work — so this job gates 91 tests today and picks up the JS half of the pin the
moment it merges. Wiring it now means the pin is never half-enforced.

* docs(cockpit): correct four dead_code allow comments that overstated

Four of the allows I added claimed the code was waiting on the Phase-6 embedding
layer. Verified by grep across origin/main and origin/feat/plugin-runtime — that
is false for these:

  state::check_crash        only its own two unit tests call it
  Manifest::webview         read only by test assertions
  WebviewCfg (all fields)   read only by test assertions
  Manifest::window_title    one test assertion, no production caller
  Spawner::has_exited       reachable only via check_crash, so dead with it

These are unimplemented features with no caller anywhere, not code whose caller
is en route. A plugin author setting 'popups: block' today gets no blocking. The
allows stay — deleting the code would amputate work in flight on #49 — but the
comments now say plainly that nothing calls this on any branch, so the allow
cannot quietly become the permanent resting place of two unbuilt features.

PluginManager::url_for keeps its original comment: that one is genuinely waiting
on a caller, verified at embedding.rs:99 on feat/plugin-runtime. Comments-only
change, no code touched.
…hread rule

Gate 5 ("quit the app, confirm `docker ps` is clean") was the one merge blocker
on the plugin runtime with no automated coverage at all. It splits in two: WHICH
stacks a teardown pass picks, and WHETHER `docker compose down` brings them down.
Only the first half is testable without a Docker daemon, so extract it.

`teardown_targets` pulls the selection half out of `stop_all_owned` and pins it
with six tests: owned-only (adopted stacks survive a quit), a vanished discovery
record is skipped rather than panicked on inside the shutdown handler, every
owned stack is selected rather than the first, the manifest's own cwd is carried
through, the running map is emptied, and a second pass is a no-op. That last one
bears on the open Gate-5 process-exit anomaly: it rules out a teardown pass
re-spending the 30 s ExitRequested budget.

`tests/tauri_command_threading.rs` makes the main-thread-blocking defect
mechanical. This project has hit it twice -- the embedding commands in P3, then
`plugin_launch` in smoke item 1.5 -- and both times a human watching a window
caught what every automated gate had passed. The test scans for
`#[tauri::command]` and requires each to be `async` or dispatching; the four
pre-existing sync commands are listed as debt with a note on each, three of them
marked UNBOUNDED because they genuinely are. A companion test fails if a debt
entry goes stale, so the ratchet only tightens. It also pins the db74a47 fix from
the Rust side: delete the thread::spawn and this goes red.

cargo test (cockpit): 34 passed, 2 passed. Execution half stays a human gate.
Bootstrap run of the testing-plan skill against a3edc78. Scores 131 gaps on
likelihood x impact across seven test tiers, records a trust verdict per runner,
and ranks what to close next.

The load-bearing discovery is that five of the seven automated tiers did not run
in CI: 135 vitest tests, 28 Tauri-host Rust tests, 52 session-state tests and 53
pytest tests were advisory, not gating. `cargo test --workspace` never reached
the cockpit crate at all, because `cockpit/ui/src-tauri/Cargo.toml` opens with a
bare `[workspace]` and the root manifest lists only crates/fleet-core and
crates/fleetd. GAP-057, GAP-111 and GAP-113 are the entries for that; PR #60
closes all three, so those rows and the tier map's `in_ci` flags are already
stale and will re-derive on the next run.

The app-plugin runtime is a deliberate scope carve-out (section 2): targeted
tests for the plugin_launch freeze and the Gate-5 teardown lifecycle were being
written concurrently, so GAP-005/009/010 are recorded as context rather than as
gaps to act on. The preceding commit is that work; R5 releases them back into the
ranking.

Six items await human ratification (section 3), including the `spine_weight`
table -- which is the Impact axis of every score in the file and is currently a
first-run proposal, not a ratified input.
#60 added `cargo fmt -- --check` to CI and applied the formatter to both
workspaces on main. This branch predates that gate, so its own additions -- the
Gate-5 tests from 0d05f55, the audience-manifest assertions, and the LRU touch in
embedding.rs -- were never formatted and would fail the new check job.

Whitespace only, stock defaults, no rustfmt.toml. Verified after: fmt --check
clean on both manifests, clippy --all-targets -D warnings clean, cargo test 34+2
(cockpit) and 120 (workspace), npm run check 353 files 0/0, vitest 135 in 19
files.
…n pass

Rewrites the State summary in place and appends a session-log entry. The audit
that opened this session found STATUS.md two sessions behind: it was silent on
the 8/13 testing plan, the Gate-5 tests, and PR #60 entirely.

Also records two wrong predictions so they are not re-derived -- the #60/#49
merge-tree conflict forecast that did not materialise, and the `gh pr merge
--delete-branch` failure that was only a local-branch delete failing after a
successful merge.
Self-contained brief for the interactive smoke. Carries the dev-seam env vars
(npm run desktop does not set them), the 11-item dev checklist plus the packaged
pass, the Gate-5 baseline, and the three traps that have already cost time twice.

Records the 1.9b process-exit anomaly as an open question rather than a known
issue: it has been carried forward three times undiagnosed, and 0d05f55 has now
eliminated one of its hypotheses.
…eme origin

`pluginSrc` emitted the literal `ccplugin://localhost/<id>/<entry>`. On Windows/WebView2 —
the primary target — a custom scheme is reachable only as `http://<scheme>.localhost/...`;
the literal form is an EXTERNAL protocol, and the plugin iframe's `sandbox="allow-scripts"`
forbids navigating to one. The frame never navigated and rendered blank:

  Navigation to external protocol blocked by sandbox, because it doesn't contain any of:
  'allow-top-navigation-to-custom-protocols', ...

So view-plugins could not load on Windows at all. The codebase already assumed the fixed
form in two places the code never produced: the host CSP (`frame-src
http://ccplugin.localhost`) and `pluginSrc`'s own docstring.

Every automated gate missed this because `loader.test.ts` asserted the broken string as
correct, and `tauri build` only compiles — nothing in CI navigates the iframe. Found by the
PR #49 interactive smoke (Smoke run 2, item 1.3).

`pluginSrc` now takes an injected `isWindows` (defaulting to a UA check, matching Tauri's own
convertFileSrc logic) so both branches are unit-testable. Verified live: REFERENCE renders and
completes its `plugin-hello`->`ready` handshake, on hot reload and on a cold start.
…lly exits

The `ExitRequested` handler called `api.prevent_exit()` unconditionally, ran teardown, then
`app_handle.exit(0)` — which re-emits `ExitRequested`. The handler prevented that too, and
called itself forever. The process never exited: after a graceful window close it sat with no
window, 20 threads, `Responding=True`, spinning at 93.9% of one core, having burned 309s of
CPU before it was killed.

This is Gate 5 item 1.9b, carried forward undiagnosed since Smoke run 1. It is a real shutdown
defect, not a `tauri dev` supervision artifact: `cargo` was blocked *on* the app rather than
holding it open, and a hot spin loop is not what supervision looks like.

`stop_all_owned_is_idempotent` (0d05f55) made this worse while appearing to rule it out —
removing the 30s cost per iteration turned a slow loop into a hot one.

It also explains the standing "do not rebuild the tauri crate while the app is running" trap:
`tauri-build` cannot overwrite `fleetd-serve.exe` precisely because the app never exits.

A `ShutdownGuard` now prevents the first exit request only; every later one passes through.
Pinned by `shutdown_guard_tests::only_the_first_exit_request_is_prevented` (verified red->green).

Verified in a watched window: the process now exits within ~1s of teardown, total CPU 0.19s
(was 309s and climbing), with cargo returning, the sidecar reaped and 8787 released.

Known remaining, deliberately not bundled here: `stop_all_owned(30_000)` still runs
synchronously inside the RunEvent callback, i.e. on the main event-loop thread, blocking exit
for as long as teardown takes (~2.5 min with 10 containers up).
Nine of eleven checklist items had never been executed. Records the full result table plus
six findings (D-1..D-6), including the two fixed this session.

Headline: db74a47 is CONFIRMED. Responsiveness was measured rather than judged — 1,127
samples at 1Hz with zero unresponsive, spanning a compose build, a failed up, and a clean
0->3->10 container ramp.

Also corrects two premises the handoff was written on: the prebuilt images do NOT skip the
compose build, and Gate 5's "docker ps empty" criterion uses the wrong instrument — it cannot
see the Created/Exited residue that run 1 left behind, which is what broke run 2's first launch.
State summary rewritten in place, session-log entry appended, and the CLAUDE.md pickup block
refreshed (it still pointed at Smoke run 1 and claimed 2/11 items done).

The picture changed: db74a47 is CONFIRMED by measurement, but #49 is no longer blocked merely
on "finish the checklist" — it is blocked on D-7 (view-plugins receive no state), D-2
(capability negotiation is inert), and the packaged Part 2 which has never been run.

Also corrects two premises the previous handoff was written on, and records that 1.2/1.4a/1.7
are BLOCKED behind a pre-existing fleetd CORS gap rather than failing.
@adbarc92

Copy link
Copy Markdown
Owner Author

Smoke run 2 — Part 1 complete. db74a47 confirmed; 4 hidden defects found, 2 fixed here

The interactive smoke finally ran properly (9 of 11 items had never been executed). Full record: spikes/SPIKE-RESULTS.md"Smoke run 2".

The good news, and it is measured rather than asserted

db74a47 holds. Responsiveness was sampled at 1 Hz rather than eyeballed: 1,127 samples, zero unresponsive, spanning a live compose build, a failed up, and a clean 0→3→10 container ramp. The workload that froze the window in run 1 no longer does.

Two fixes in this push

Commit Defect
55b0a5b View-plugins could not load on Windows at all. pluginSrc emitted ccplugin://localhost/…; on WebView2 that is an external protocol, and sandbox="allow-scripts" forbids navigating to one, so the frame stayed blank. The host CSP (frame-src http://ccplugin.localhost) and pluginSrc's own docstring already assumed the correct form — the code contradicted both. Verified cold-start.
2ab1b49 The app never exited. ExitRequested called prevent_exit() then exit(0), which re-emits ExitRequested → prevented again → infinite loop. Measured at 93.9% of a core, 309 s of CPU burned, no window. Fixed with a ShutdownGuard; verified in a watched window (exits ~1 s, 0.19 s CPU).

Both are pinned by tests written red→green.

Why CI never caught either

loader.test.ts:52 asserted the broken URL string as correct, and tauri build only compiles — nothing in the pipeline ever navigates the iframe or closes the window.

More broadly, three of the four defects share one shape: a unit test passes on a function whose output is never wired to anything. negotiateCapabilities is tested while its result is discarded; bridge.test.ts drives fakes, so the real Svelte 5 $state proxies are never exercised. The coverage gap is at integration boundaries, not units.

Staying draft — still blocked on

  • D-7 — view-plugins receive no state at all: sendFullState throws DataCloneError posting $state proxies through postMessage, and it throws inside onReady before the tick timer starts. Agreed as the next fix.
  • D-2 — capability negotiation is inert: every plugin is granted every host capability regardless of its manifest. Security-relevant.
  • Part 2, the packaged smoke — still never executed.

Not this PR's problem

Items 1.2 / 1.4a / 1.7 are BLOCKED, not failed, behind a pre-existing main defect: fleetd serves no CORS headers (OPTIONS /missions → 405, no ACAO on any response), so no browser fetch from the cockpit reaches the daemon. git diff origin/main...HEAD is empty for api.ts and crates/fleetd/. This is also why the FLEET ops grid renders nothing today.

Also corrected two premises earlier docs were written on: prebuilt images do not skip compose build, and Gate 5's docker ps criterion cannot see the Created residue that broke this run's first launch — it needs docker ps -a.

Follow-up filed as #61 (loading model: blank tabs with no loading or failure affordance).

…when packaged

Two defects found by the packaged Part 2 smoke, both fixed test-first.

D-7 - view-plugins received no state at all. `makeFleetHost` hands the
session live Svelte 5 `$state` proxies (`store.units[id]`, `store.order`),
which are not structured-cloneable, so `port.postMessage` threw
DataCloneError. The throw landed inside `onReady` before the tick timer was
installed, so the plugin got no state AND no ticks - the channel was dead,
not stale. Fixed at the single choke point in `PluginSession.post` so
`state`, `log-append`, `order` and `degraded` are all covered.

`bridge.test.ts` could never have caught this: it drives a hand-rolled host
whose units are plain objects. The new `bridge.proxy.svelte.test.ts` uses the
real `FleetStore`, real proxies and a real `MessagePort`, and reproduces the
DataCloneError exactly. Verified red -> green.

D-8 - the view-plugin runtime could not work in ANY packaged build.
`plugin_roots` falls back to `resource_dir()/plugins` and `sdk_bytes` to
`resource_dir()/plugin-sdk/index.js`, but `tauri.conf.json` had no
`bundle.resources` key at all - the bundle shipped only `icon.ico`. Every
packaged build would 404 the plugin document and never load the SDK, so every
handshake timed out. Found by preflight inspection before the operator
touched the keyboard.

Pinned by `tests/packaged_plugin_root.rs`, a ratchet rather than a note:
delete either resource mapping and it goes red.

Both are the same shape as D-1 and D-2 - exercised on the dev path, never
wired or asserted on the path that ships. Nothing in CI bundles the app or
navigates the iframe, which is why all four passed every gate.

Verified in the packaged build: REFERENCE renders (a first), handshake
completes, the state badge leaves its static default, 632 responsiveness
samples with zero unresponsive across a 0->10 container ramp, and Gate 5
tears down 10 containers with a 5.27s exit.

Gates: cargo workspace 120, tauri host 40 (+3), npm 139 (+2),
check 354 files 0 errors 0 warnings.
… (D-2)

`discoverFrom` computed `grantedCapabilities` per plugin (loader.ts:140) and
nothing ever passed it to the bridge, so `init` shipped the full host set to
every view-plugin regardless of what its manifest requested. Observed live in
both the dev and packaged smokes: a plugin whose manifest asks only for
`log-append` was also granted `real-launch-confirm`. The manifest's capability
declaration was decorative.

App.svelte now passes the discovered grant, and the bridge fallback fails
CLOSED - an absent grant is `[]`, not `HOST_CAPABILITIES` - so forgetting to
pass it can never again silently widen a plugin's authority.

`bridge.test.ts:360` asserted the full host set as expected behaviour, which
encoded the defect exactly as `loader.test.ts:52` did for D-1. That is the
fifth instance of this branch's one pattern, and the second time a test
actively defended the bug. Assertion corrected; three tests now pin the
grant, the fail-closed default, and an explicitly empty grant.

Also fixes a flake introduced earlier this session: the D-7 regression test
waited on `setTimeout(0)` for an async MessagePort delivery and was
intermittently red (1 in 3 isolation runs). It now waits on the condition
with a 5s ceiling - 8/8 clean. A ratchet that flakes is a ratchet that gets
deleted.

Gates: npm 142 passed (+3), check 354 files 0 errors 0 warnings,
cargo workspace 120, tauri host 40.
@adbarc92
adbarc92 marked this pull request as ready for review August 16, 2026 17:38
@adbarc92

Copy link
Copy Markdown
Owner Author

Packaged smoke (Part 2) — run for the first time, and it moved the branch

Part 1 ran on 2026-08-15. Part 2, the packaged pass, had never once been executed. It has now been run end to end on Windows against a freshly built artifact, operator-driven, with Process.Responding sampled at 1 Hz throughout (935 samples).

Result: 9 PASS · 2 BLOCKED · 2 NOT RUN · 0 FAIL. Full record in spikes/SPIKE-RESULTS.md → "Smoke run 3".

What this run established

  • The view-plugin runtime works packaged — a first. REFERENCE renders, the handshake completes (connected · caps: log-append), and the plugin receives state.
  • D-7 fixed and confirmed on the shipping path. $state proxies are not structured-cloneable, so postMessage threw DataCloneError inside onReady before the tick timer was installed — the state channel was dead, not stale. Confirmed live: the plugin's badge left its static DAEMON: ? default, which is only reachable via onStaterender().
  • D-8 found and fixed — this one was decisive. tauri.conf.json had no bundle.resources key at all, so every packaged build shipped icon.ico and nothing else. ccplugin:// would 404 and the SDK would never load, meaning this branch's headline feature could not function in any build that shipped. Found by preflight inspection before the operator touched the keyboard.
  • D-2 fixed. Every view-plugin was being granted every host capability regardless of its manifest — reproduced on the packaged path, so it was fixed rather than deferred. The bridge now fails closed.
  • Gate 5 passes properly. 10 containers → 0 (asserted with docker ps -a, which sees the Created/Exited residue docker ps cannot), process exit 5.27 s, sidecar reaped, port released. Pre-D-4-fix this process never exited at all.
  • db74a47 confirmed packaged. 632 samples across compose-up and a 0→10 container ramp, zero unresponsive.

Still open, deliberately

  • D-3 — fleetd serves no CORS headers. Blocks items 2.2 / 2.4a / 2.7. Pre-existing on maingit diff origin/main...HEAD is empty for crates/fleetd/ — so it belongs in its own issue, not this branch.
  • D-4 second halfstop_all_owned still runs synchronously in the RunEvent callback. Measured at 5.27 s here versus ~2.5 min in dev, so lower urgency than assumed; needs a fix-or-accept decision.
  • 2.3c unverifiable in a release build (no devtools; F12 confirmed dead). 2.4a was simply not exercised this run.

The finding worth acting on

D-1, D-2, D-7 and D-8 are one defect shape: exercised on the dev path, never wired or asserted on the path that ships. All four passed every automated gate, because nothing in CI bundles the app or navigates the iframe. Twice a test actively defended the bug — loader.test.ts:52 asserted D-1's broken string, and bridge.test.ts:360 asserted D-2's full-host-set grant as correct.

Both fixes here ship as ratchets rather than notes: tests/packaged_plugin_root.rs goes red if either resource mapping is deleted, and bridge.proxy.svelte.test.ts drives real $state proxies through a real MessagePort — the fake-based suite could never have failed on D-7.

Gates: cargo test --workspace 120 · tauri host 40 · npm test 142 · npm run check 354 files, 0 errors / 0 warnings.

Two fixes, one mechanical and one factual.

rustfmt: `packaged_plugin_root.rs` was committed unformatted and failed the
`fmt + clippy` gate. Clippy was clean on both workspaces; formatting was the
only failure.

The factual one matters more. Commit c16356a's message and the results record
both claimed "nothing in CI bundles the app". That is false, and CI proved it
by running the job: .github/workflows/ci.yml:311 runs `npm run tauri build` on
windows, macos and ubuntu and uploads the bundles as artifacts, on every run.

The real gap is narrower and cheaper to close: nothing asserts what is INSIDE
the bundle, and nothing runs the artifact. D-8 did not slip past a missing
build - it slipped past a SUCCESSFUL one. `tauri build` packaged an app with
no plugin root and exited 0, because a green build means "it compiled and
packaged", never "it packaged the right files".

Since the bundles already exist as CI artifacts, a job that unzips one and
asserts the plugin root and SDK are present would have caught D-8 on the
commit that introduced it. This guard closes the config half; the artifact
half is the highest-value remaining addition to docs/testing/PLAN.md.

The claim in c16356a's message cannot be edited now that it is pushed; this
commit and the results record carry the correction.
@adbarc92

Copy link
Copy Markdown
Owner Author

Correction to my summary above

I wrote that "nothing in CI bundles the app or navigates the iframe". The first half is false, and CI demonstrated it minutes later by running the job.

.github/workflows/ci.yml:311 runs npm run tauri build on windows-latest, macos-latest and ubuntu-latest, and uploads the bundles as artifacts — on every run. CI bundles the app on all three platforms.

The real gap is narrower, and cheaper to close. Nothing asserts what is inside the bundle, and nothing runs the artifact. D-8 did not slip past a missing build — it slipped past a successful one. tauri build packaged an app with no plugin root and exited 0, because a green build means "it compiled and packaged", never "it packaged the right files".

Since the bundles already exist as CI artifacts, a job that unzips one and asserts plugins/reference/index.html and plugin-sdk/index.js are present would have caught D-8 on the commit that introduced it. tests/packaged_plugin_root.rs closes the config half; the artifact half is the highest-value remaining addition to docs/testing/PLAN.md.

Corrected in spikes/SPIKE-RESULTS.md and the guard's doc comment in 05c95ca. The claim in c16356a's commit message can't be edited now it's pushed, so the correction lives there instead.

Also in 05c95ca: rustfmt on the new guard, which is what actually failed fmt + clippy. Clippy itself was clean on both workspaces.

…rds reconciled

The interactive smoke is finished. Part 1 (run 2, dev) and Part 2 (run 3,
packaged) are both complete and #49 is READY FOR REVIEW with 18/18 CI checks
green. It needs a human merge decision, not more work.

Five defects closed this session: D-7 (view-plugins got no state), D-8 (the
packaged bundle shipped no plugin root, so no shipped build could load a
view-plugin), D-2 (every plugin granted every capability), D-4 (re-verified
packaged), and D-5 (investigated, did not reproduce).

Reconciled three records that contradicted each other:

- docs/testing/PLAN.md — added a §1 Reconciliation block (it claimed CI has
  "exactly three jobs"; CI now runs nine checks), struck through GAP-006 /
  GAP-008 / GAP-010 / GAP-012 which runs 2 and 3 verified, and added GAP-132
  as rank 1. Entry `status` fields were deliberately left `open`: status is
  human-owned and retirement belongs to a real scan, not a hand edit. A full
  testing-plan re-run is now next step 3.
- docs/STATUS.md — State summary rewritten in place, session-log entry
  appended, Next steps re-ordered around merging #49 and filing D-3.
- CLAUDE.md — pickup block rewritten; every trap in it was stale.

GAP-132 is the important addition. It records the pattern behind five defects
and corrects a claim I made and propagated: "nothing in CI bundles the app" is
FALSE. ci.yml:311 runs `tauri build` on three OSes. Nothing looks inside the
bundle, which is how D-8 passed a *successful* build - so the fix is cheap
(unzip a CI artifact, assert two files) rather than architectural.

Confirmed NOT problems, contrary to earlier session notes:
.embargo-guard.local.json is gitignored (.gitignore:35), and PR #50 is merged.
@adbarc92
adbarc92 merged commit e2fc3ce into main Aug 16, 2026
18 checks passed
@adbarc92
adbarc92 deleted the feat/plugin-runtime branch August 16, 2026 18:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant