Skip to content

Gateway is a separate executable and first class - #17

Open
vinniefalco wants to merge 45 commits into
cppalliance:masterfrom
vinniefalco:master
Open

Gateway is a separate executable and first class#17
vinniefalco wants to merge 45 commits into
cppalliance:masterfrom
vinniefalco:master

Conversation

@vinniefalco

Copy link
Copy Markdown
Member

doing some logging and better startup too

Give each shipped binary its product name and put the cross-product crates under the shared prefix. The `gateway` binary becomes `promptforge-gateway` and the `workshop` binary becomes `promptforge-workshop`; the `gateway-protocol` and `gateway-loopback` crates move to `crates/shared-protocol` and `crates/shared-loopback` as `shared-protocol` and `shared-loopback`. Every dependent manifest, import, CI workflow, packaging unit, guide, and design note follows the new names.

- The crate directories move as pure renames; only the package names, crate headings, and referring paths change, and the source files under them stay identical.
- The `gateway` crate keeps `pub(crate) use shared_protocol::{upstream, wire};` so every existing `crate::wire::*` and `crate::upstream::*` path resolves unchanged.
- The package names `gateway` and `workshop` stay, so `cargo install gateway` and `cargo run -p workshop` keep working; `tauri.conf.json` keeps `productName` as PromptForge and adds `mainBinaryName` set to `promptforge-workshop`.
- The release workflows now smoke-test the new names: `release-workshop.yml` accepts only `promptforge-workshop.exe` after install, and the gateway workflows install and run `promptforge-gateway`.
- No test assertions change; the touched tests update import paths and the fake program name only, and the rename adds no new tests.
Introduce the `shared-sidecar` crate: the `gateway.json` connection-file seam that lets a reader attach to an already-running gateway instead of launching a second one, Jupyter-style. The crate holds the `ConnectionFile` type with validation, the atomic owner-only write and shutdown removal, stale detection with cleanup, the `gateway.json.lock` launch-race lock, and the health wait moved from the workshop shell's `health.rs`. The gateway writes the file after every successful bind in `runner.rs`, and the shell's boot now waits through `shared_sidecar::wait_for_health`.

- The crate is synchronous and runtime-agnostic: `serde`, `serde_json`, and `thiserror` are the only general dependencies, so the gateway's lean `--no-default-features` build takes it unconditionally.
- Unsafe is confined to the `src/sys/` process-image shims (`/proc/<pid>/exe` on Linux, `proc_pidpath` on macOS, `OpenProcess` and `QueryFullProcessImageNameW` on Windows); each shim module carries `#[expect(unsafe_code)]` while the crate lints deny `unsafe_code` everywhere else.
- The health leg of `liveness_failure` polls through `wait_for_health` with the 2-second `LIVENESS_BUDGET` instead of a single probe: the writer lands the file before its serve loop starts accepting, so one starved probe must never read as stale, since a false stale deletes a live gateway's file and a reader relaunches a duplicate. The test `a_transiently_silent_health_endpoint_is_not_stale` pins this by dropping the first probe connection and requiring `Resolution::Attach`.
- The key probe reuses the key-gated `GET /v1/models` route as `KEY_PROBE_PATH` rather than adding an endpoint, and the moved probe sends the bound loopback address as the `Host` header, never `localhost`.
- A file attaches only when the pid is alive, its image matches `GATEWAY_IMAGE_NAME`, `GET /health` answers 200, and the bearer key is accepted; anything else is deleted with its `StaleReason`. Stale-file deletion is the launch-lock holder's privilege: a `launch_or_attach` loser only ever attaches through `is_live`, never deletes.
- The gateway lands the file in `serve_thread` before the readiness signal, and `ConnectionFileGuard` removes it on drop via `remove_if_mine`, which spares a replacement's file; a write failure is logged and tolerated, degrading discovery to a relaunch instead of an attach.
- A platform without a `process_image_path` shim fails closed, every file reading as stale so readers relaunch rather than attach to an unverified process; that fallback path has no test.
A bare gateway run must find or create its configuration, and the generated default takes the sidecar shape. Discovery and first-run generation move from the shell into the gateway's new `boot.rs`: `ServeOptions.config_path` becomes `Option<PathBuf>`, and `None` searches beside the executable, then the working directory, then the profile's `.promptforge` directory before generating into the profile location. The shell's `discover.rs` is deleted, and the shell passes `None` through.

- The generated default binds `127.0.0.1:0`, carries no `[workshop]` section, and gates the `[[stt_model]]` pair on `InstallerStt`; generation also writes the sibling state file selecting `default`, so the first boot needs no `--profile` flag.
- `write_new_config` opens the file create-new: `AlreadyExists` means a racing first run won, an existing config is never overwritten, and a symlink planted at the path is never followed.
- `crates/gateway/Cargo.toml` mirrors the workspace lint set with `unsafe_code` lowered to `deny`, because the `registry` shim is raw Win32 FFI and a workspace `forbid` cannot be overridden by a module allow; `expect_used` stays `deny`, with the two invariant checks restructured as let-else plus `unreachable!`.
- The `STT_REQUIRES_WORKSHOP` refusal leaves `Gateway::from_config` and `prepare_switch_target`; STT no longer needs a `[workshop]` section, and the runner test now pins refusal on the missing `workshop` feature instead.
- An explicit path (the CLI positional or `PROMPTFORGE_GATEWAY_CONFIG`) wins without touching the disk: `resolve_in` calls the location `gather` only when `explicit` is `None`.
- `InstallerStt::from_dword` compiles on every host; a Windows-only gate would leave `Omitted` with no construction site elsewhere and fire `dead_code` under the Linux clippy run.
- Only the read side of the installer's choice lands here: nothing in this change writes the `InstallSTT` DWORD the NSIS components page records, so the value stays absent and STT ships until that writer exists.
- The `registry::install_stt_dword` FFI read has no test; the tests pin `InstallerStt::from_dword`, the candidate order, and the generation paths.
Transcription must ship in every default build, independent of hosting the workshop UI. Every `gateway-stt` gate moves from `workshop` to a new default-on `stt` feature: the engine lifecycle, `POST /v1/audio/transcriptions`, and the `/stt` socket a hosted workshop merges. The `workshop` feature keeps only workshop-server hosting.

- `AppState::stt_state()` is gated `all(feature = "stt", feature = "workshop")` because only the hosted workshop reads it; the gateway's own route reads the field directly.
- Without `stt`, `spawn_with_opener` passes a routes closure that returns an empty `axum::Router::new()`, so a hosting-only build merges no STT routes.
- A `--no-default-features` build refuses `[[stt_model]]` at startup and on profile switch through `STT_RUNTIME_UNAVAILABLE`, which now names the `stt` feature.
- No new tests; `stt_tests` and `transcription_auth_tests` are re-gated to follow the rename, and the refusal assertion now requires the detail to name the `stt` feature.
The gateway runs as a tray-resident sidecar, so its operator surface gains a remote stop, a DNS-rebinding defense, and a browser entry that keeps the bearer key out of the SPA. `POST /shutdown` fires a shared `ShutdownSignal` the serve loop selects on, and `require_loopback_host` from `shared-loopback` wraps the whole surface when the bind is loopback. `GET /auth?key=` validates the key, sets a session proof as an HttpOnly `SameSite=Lax` cookie named `promptforge-gateway-session`, and redirects to `/config/`; `check_auth` accepts the cookie in place of the bearer header.

- The cookie carries `session_token`, SHA-256 over the process-lifetime `handoff_salt` and the live key, compared with `secret_eq`: a harvested cookie never resolves to the key, and a restart or key rotation revokes it.
- Because the cookie is ambient, the cookie path also requires `fetch_metadata_allows_cookie`: `Sec-Fetch-Site: same-origin` or `none`, since `SameSite=Lax` does not cover same-site requests (ports are not part of a site).
- `build_router` takes the bound socket and installs the host wall as the outermost layer only for a loopback bind; the `Gateway::router` seam passes `None` and carries no wall, and its `POST /shutdown` answers 202 without stopping anything.
- A port-80 bind also admits the port-elided bare authorities in `authority_allowed`, since http clients omit the default port; no route is exempt from the wall, `/health` included.
- The SPA boot probes the key-gated `GET /admin/status` through `hasAmbientAuth` and mounts the shell on the ambient cookie; no key is stored.
The workshop attaches to a gateway it can discover: a live `gateway.json` connection file in the run directory wins, so loopback, WSL, or LAN become one topology. A new `resolve.rs` resolves the endpoint - the live file first via `shared_sidecar`'s stale-detecting probe, explicit `[gateway]` config second, the plain `ResolveError` otherwise. `AppState::new` resolves before construction, and `spawn` resolves before the server thread starts, so a resolution failure is the plain no-gateway error, never a bind-then-fail.

- `spawn_with_routes` now takes an already-resolved `ResolvedGateway`: the merged gateway hosting the workshop in-process passes `ResolvedGateway::from_config`, because its own just-written connection file is not serving yet when the workshop spawns and a foreign gateway's live file must never win.
- The state-construction body moved from `AppState::new` into `state_with_gateway`, the direct entry for a host holding its own endpoint, and `StateError` gains the `Resolution` variant.
- `DEFAULT_GATEWAY_BASE_URL` and its export are deleted: an empty `gateway.base_url` is kept as-is as the not-explicit signal resolution reads, so an explicitly written default URL resolves as explicit config.
- `shared-sidecar` gains a `test-fixtures` feature exposing `resolve_for_test`, so a consumer's test binary, never named `promptforge-gateway`, runs the real liveness gauntlet against its own process image.
- `module-ceilings.toml` is re-recorded with reasons: `resolve.rs` enters at 553 (over half its in-file tests), and `app.rs`, `config.rs`, and `serve.rs` grow.
- A stale file is removed and its reason (dead pid, foreign image, failed health, rejected key) is reported on the status bus and in the log before the config fallback; a probe I/O failure degrades to the config fallback and never fails startup on its own.
- With no live file and no explicit `gateway.base_url`, startup fails plainly: `no gateway configured or running`.
- Tests never consult the real run directory: the `serve.rs` shutdown and bind tests route through the discovery-bypassing `spawn_with_grace`, and the integration fixtures build state through `ResolvedGateway::from_config` plus `state_with_gateway` or `spawn_with_routes`.
- The production `resolve` entry point is covered only through the injected `resolve_with` run-directory and probe seams; no test touches the real default run directory.
The gateway is always a separate process now, so the desktop shell hosts the workshop server itself and the merged-gateway shape is deleted. The `workshop` crate drops its `gateway` dependency for `workshop-server`, spawning the server in-process on an OS-assigned loopback port from a new `config.rs` that discovers `workshop.toml` and forces the shell-owned listener settings. The `gateway` crate loses the `workshop` feature, `src/workshop.rs`, `WorkshopHandle`, and the `open` dependency, while `workshop-server` gains `csp.rs`, a middleware stamping a Content-Security-Policy on every response.

- The window capability is built programmatically in setup by `window_capability` from `WINDOW_PERMISSIONS` with the exact bound origin, replacing the wildcard-port `capabilities/default.json`; a wildcard port would hand the Tauri API surface to any loopback server.
- The policy keeps `style-src 'self' 'unsafe-inline'` because CodeMirror 6 mounts `<style>` elements through style-mod and Shiki emits inline `style` attributes; `connect-src` admits the Tauri IPC endpoints `ipc:` and `http://ipc.localhost` plus the loopback WebSocket spellings.
- A boot config carrying a `[workshop]` section still parses; `workshop_section_deprecation` logs a startup warning naming the inert `bind` and `open_browser` fields and the still-live `[workshop.stt]` capture tuning.
- Closing the window stops only the in-process server through `ServerSlot`; the gateway keeps running. The `release-workshop.yml` smoke tests now write a dummy `[gateway]` endpoint into `workshop.toml` and discover the ephemeral port from the OS.
- The config-ui Workshop card no longer edits the inert `bind` and `open_browser` fields; its Add button seeds only the STT capture tuning.
- No product host merges routes through `spawn_with_routes` anymore, so the `/stt` socket and `GET /stt/capability` go unanswered and dictation stays blocked until voice migrates into `workshop-server`; the guides record this.
The shell must connect a gateway before the in-process server starts, and closing the window must never stop it. `gateway::ensure_gateway` resolves the connection file first: attach to a live gateway, launch the sibling `promptforge-gateway` detached through the `shared_sidecar` launch lock when none runs, or fall back to explicit `workshop.toml` config on a Workshop-only install, with `no_gateway_error` naming both remedies when nothing connects. The new window menu (`menu.rs`) quits the shell and, when the gateway is a local sidecar, posts `POST /shutdown` through the new `shared_sidecar::request_shutdown` first.

- `spawn_detached` uses `std::process::Command` with `CREATE_BREAKAWAY_FROM_JOB | CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS` on Windows and `process_group(0)` on unix, so the gateway survives the shell's exit and any job object; tauri-plugin-shell's sidecar API is rejected because it kills its children on exit.
- The shutdown request lives in `shared-sidecar` (`shutdown.rs`): `get_head` is generalized to `request_head` with a method parameter, and `ShutdownError` converts `ProbeError`, keeping the connection-file contract in the one crate both sides call.
- `GatewayAttachment` records how boot connected: `GatewaySlot` holds the sidecar `ConnectionFile` for the quit item's post, and an explicit-config (LAN) gateway holds no file and never receives a shutdown post.
- The launch lock stays held across `wait_for_launched_file`, so a racing shell attaches to the winner's connection file instead of launching a second gateway.
- A `std::thread::Builder::new().spawn` failure for the reaper thread logs and boots on, leaving the child unreaped; `menu::handle_event` exits the shell even when the shutdown post fails, so quit always works.
- No single-instance guard is wired: nothing in this diff stops a second shell launch from racing the first.
- `menu.rs` (`install`, `handle_event`) carries no tests; the `plan_gateway` decision matrix and the `request_shutdown` status classification are pinned by tests.
The gateway has no window, so on an installed system the tray icon is its only on-screen presence. `run_with_tray` becomes the binary's default main loop: on Windows a hidden message-only window runs the Win32 message loop on the main thread while serving stays on the gateway thread, and `--no-tray` keeps the headless Ctrl-C loop for servers and CI. The menu layout, status label, icon phase machine, launch-at-login rules, and icon tints live in `tray/logic.rs` as pure tested logic, so the idiom cannot drift between the platforms whose backends land later.

- `GatewayHandle` and the readiness `Ready` now carry the bearer key as a `Secret`, so the derived `Debug` redacts it, plus a clone of the assembled `AppState`; the tray's timer reads status in-process through `tray_model_status` and `is_serving` instead of polling over HTTP.
- The menu is built once from `menu_spec` into retained `MenuItem` and `CheckMenuItem` handles and mutated in place; a displayed menu is never rebuilt (muda#129, muda#328).
- The tray-icon and menu callbacks only forward a `TrayEvent` into the message loop with a `PostMessageW` wake on `WM_TRAY_EVENT`; they never launch processes, open browsers, or touch the registry.
- `tray-icon` is pinned at `0.24.1` minimum, which re-registers the icon on `TaskbarCreated` and keeps a hidden icon hidden across Explorer restarts.
- Every status tick recomputes the phase from `GatewayHandle`'s `is_serving` and re-reads the Workshop sibling probe and the login check, so no stale state can latch; a profile switch holding the live-state lock just skips one tick.
- A stopped gateway thread with `ShutdownSignal`'s `is_fired` set is a requested `POST /shutdown`: the tray follows it through the normal teardown instead of pumping a dead gateway in a false Error phase.
- The thread-local `MENU_OPEN` guard keeps the modal `TrackPopupMenu` loop's reentrant dispatch from creating a second `&mut Tray`; queued events drain when the outer dispatch resumes.
- Launch at Login state comes from the HKCU Run key alone (`read_run_value`, `write_run_value`, `delete_run_value` in `boot::registry`), never local config; a failed write restores the check item from the OS at once.
- Settings and double-click open `auth_url`, which percent-encodes the key with `url::form_urlencoded::byte_serialize` so a configured key with query-special characters survives the handoff.
- The Workshop item spawns `promptforge-workshop.exe` detached with `CREATE_BREAKAWAY_FROM_JOB | CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS`, the same detach flags the shell uses for its own gateway spawn, and stays disabled when no sibling exe exists.
- `--login` marks an OS autostart launch and is a marker only today: no launch path opens a browser.
- Quit tears down in order: icon first, then the window, then the gateway's graceful shutdown, never `process::exit`, so the connection-file guard still runs.
- `tray/windows.rs` has no automated tests; CI is headless, so the tests pin `tray/logic.rs`, the new `--no-tray` and `--login` parsing, `tray_model_status`, and the `is_fired` peek.
- macOS and Linux have no backend yet; `run_inner` warns and falls back to the headless loop there.
Give the gateway its tray UI on macOS, behind the same `run_with_tray` entry point as the Windows backend. `macos.rs` puts the `NSApplication` run loop on the main thread while serving stays on the gateway thread, with Launch at Login backed by `SMAppService.mainApp` and the muda menu materialization shared through the new `menu` module. The platform rules stay in `logic`, extended with a `logic::macos` submodule of pure functions that CI exercises off-platform.

- The tray is built by a zero-delay one-shot `NSTimer` on the run loop's first pass and lives in a thread-local `TRAY` slot; a construction failure hands the `GatewayHandle` back to the headless Ctrl-C loop.
- `MenuItemSpec::LaunchAtLogin` gains an `enabled` bit: `SMAppService.mainApp` registers the bundle's principal executable, so inside the workshop's bundle, where the principal is the workshop, `LoginService::new` returns `None` and the item is disabled; the store also requires macOS 13.
- The muda menu build moves from `windows.rs` into `menu` (`BuiltMenu`, `MenuBuildError`); a displayed menu is never rebuilt, because muda 0.19.3 lacks the `set_menu` use-after-free fix, so state changes mutate the retained item handles.
- The template glyph cannot carry the phase tint, so the phase travels in the status label and tooltip; `template_glyph` forces the embedded 36x36 RGBA asset to black with alpha preserved.
- `launch_workshop` opens the containing bundle through `/usr/bin/open`; the unbundled dev fallback spawns the sibling with `process_group(0)` so a terminal Ctrl-C does not SIGINT the workshop, and an `open-reaper` thread reaps the child off the run loop.
- The mouse-down `Click` event is the pre-display hook that re-probes the Workshop and login states; a requested shutdown (`is_fired`) quits through the normal teardown, so the connection-file guard still runs.
- No Info.plist carries `LSUIElement`: the gateway ships as a bare executable inside the workshop's .app bundle and has no bundle plist of its own, so the early `setActivationPolicy(.accessory)` call in `run` is the only mechanism that keeps the daemon out of the Dock.
- The objc2 boundary in `macos.rs` is untested; the suite pins the `logic::macos` rules (bundle walk, principal match, login-status mapping, macOS 13 gate, template glyph) and the new `menu_spec` enabled-bit cases. `menu.rs` has no tests.
The gateway's tray gains its Linux backend: a pure StatusNotifierItem over the session D-Bus via `ksni` (async-io build, no GTK), with the no-watcher fallback and the XDG autostart toggle. The change also adds two tray-less affordances: `--print-url` prints the Settings handoff URL once bound through `run_printing_url`, and a second launch hands off to the running gateway's Settings page through `relaunch.rs` instead of booting a duplicate. The shared handoff-URL builder `auth_url` moves from `tray/logic.rs` to `handoff.rs` so the tray, the relaunch path, and `--print-url` build the one-time `/auth` URL in one place.

- `set_launch_at_login` now takes the caller's platform-shaped command string instead of an exe path: `run_key_command` keeps the Windows Run-key quoting and is gated to its Windows and macOS callers plus tests, while `logic::linux::exec_command` backslash-escapes the desktop-entry spec's reserved characters for the XDG Exec line.
- With no StatusNotifierWatcher on the session bus (stock GNOME), the tray spawns with `assume_sni_available(true)`, keeps serving, posts one first-run notification naming the Settings URL behind a sentinel file, and re-registers automatically when a watcher appears.
- The relaunch check in `running_gateway_settings_url` runs before any bind attempt: a live connection file yields the `/auth` URL (opened in the browser, printed under `--print-url`, or a quiet exit under `--login`), and every stale resolution boots normally.
- `ShutdownSignal::is_fired`, `tray_model_status`, and the `GatewayHandle` tray accessors are now gated on Linux as well, so the new backend compiles there.
- The D-Bus and ksni service paths in `tray/linux.rs` are untested (headless CI has no session bus); tests pin `logic::linux`, `relaunch::decide`, `auth_url`, and the new flag parsing.
The workshop shell and the gateway now ship as two processes in one bundle, so the installer and the release pipeline must handle the gateway as its own component. `tauri.conf.json` gains `externalBin` for `binaries/promptforge-gateway`, `nightly.yml` and `release-workshop.yml` build and stage the per-triple sidecar before `tauri-action` runs, and `installer.nsi` splits the monolithic install section into independently checkable Gateway, Workshop, and STT components.

- The update path stops a running `promptforge-gateway.exe` by process-name lookup alone (`nsis_tauri_utils::FindProcess` / `FindProcessCurrentUser`) instead of reading the pid from `gateway.json` as originally specified: NSIS has no JSON parser, and the per-user install makes the image name unique enough. `-Finalize` relaunches it with `serve --login` when `$GatewayWasRunning` is set.
- The STT section carries no files; it writes or deletes the `InstallSTT` registry value as a first-run config gate, and update mode never touches it.
- The component selection persists under `HKCU\${MANUPRODUCTKEY}\Components`, and `RestoreComponentSelections` forces it onto the sections in passive and update mode; `DeleteComponentPayloadIfDeclined` removes the payloads of declined components on update.
- `FinishPageShow` hides the finish-page Run checkbox on Gateway-only installs, and `DisplayIcon` falls back to the gateway exe when the Workshop section is declined.
- Each platform smoke test pre-writes a minimal `gateway.toml` with no `[[stt_model]]` so the launched sibling gateway boots without model downloads, and the macOS and Linux tests select `promptforge-workshop` by exact name because the bundle now holds two executables.
- No test suite changes beyond the release workflow smoke tests; the NSIS script has no automated coverage outside them.
The tray's Launch at Login entries were unbootable: `run_key_command` (Windows Run key) and `exec_command` (XDG desktop entry) both emitted `"<exe>" --login`, but `parse_args` requires the `serve` subcommand as the first argument, so every login-triggered start would have failed with "unknown command --login". Both builders now emit `"<exe>" serve --login`, and their tests expect the full command line. The bug slipped through because the builder tests and the parse test (`the_autostart_command_line_parses`) never shared a fixture: the parse side always used `serve --login` while the builder side never emitted it.
The documentation now describes the shipped architecture: the gateway is always a separate tray-resident process, and the desktop app hosts `workshop-server` in-process and attaches to the gateway over HTTP. `AGENTS.md` rewrites the product-boundary rules, `README.md` rewrites the product description and build instructions, and the gateway and workshop guides gain the new runtime behavior.

- `AGENTS.md` records two conventions: every crate shared across products carries the `shared-` prefix (`shared-progress`, `shared-loopback`, `shared-protocol`, `shared-sidecar`), and the gateway's `workshop` feature is removed with the gateway never hosting the workshop.
- `README.md` documents the three independent installer components (Gateway, Workshop, STT) and the `cargo tauri build` requirement to stage `promptforge-gateway-<target-triple>` under `crates/workshop/binaries/` before bundling.
- The gateway guide documents first-run default config generation, the system tray, `--no-tray`, `--print-url`, the `gateway.json` connection file, and the authenticated `/shutdown` route.
- The workshop guide documents the Quit PromptForge and Gateway command, which posts shutdown to a local gateway but never to a gateway on another machine.
- No module-ceiling updates appear in this diff; the change touches documentation only.
The Linux tray moved to `ksni` (pure StatusNotifierItem over D-Bus), so `libxdo-dev` and `libayatana-appindicator3-dev` are no longer part of any crate's build graph: `cargo tree` for the workshop's Linux target shows `muda` with only the `gtk` and `common-controls-v6` features and no appindicator crate at all. The remaining packages (`libwebkit2gtk-4.1-dev`, `libssl-dev`, `librsvg2-dev`) are what the Tauri webview and icon pipeline actually link.
A stale `PROMPTFORGE_GATEWAY_CONFIG` hard-failed the boot: the variable was resolved as an explicit path, and explicit paths must exist. Ambient state rots in ways a typed CLI path does not, so the env leg now warns and falls through to boot discovery when it names no file. A CLI positional is deliberate and stays strict. The `env_path_is_the_fallback` test gains a real temp file, and `a_stale_env_path_falls_back_to_discovery` pins the new behavior.
A Gateway-only install has no shell to run, so the installer's finish page needs a first-run action. The new `--open-settings` flag on `promptforge-gateway serve` opens the Settings handoff URL in the default browser once the listener is bound. The flag rides `ServeOptions` through the new `open_settings` field and `with_open_settings` builder, and `spawn` calls the new `open_settings_page` after the readiness handshake.

- `--login` wins over `--open-settings`: `parse_args` passes `open_settings && !login`, so a login launch never opens a browser; `login_wins_over_open_settings` pins this.
- `open_settings_page` goes through the one-time `/auth` redirect from `auth_url`, so the key never sits in browser history; a browser that cannot launch warns and the gateway serves on.
- In `installer.nsi` the finish-page Run checkbox is retitled with `WM_SETTEXT` on a Gateway-only install, and `RunMainBinary` launches `promptforge-gateway.exe serve --open-settings`.
- Tests cover parsing only (`open_settings_parses_and_rides_the_serve_options`, `open_settings_defaults_off`, `login_wins_over_open_settings`); the browser launch in `spawn` is untested.
The `llama-cuda-blackwell-b10082` release asset was rebuilt and re-uploaded by the `llama-cuda-blackwell.yml` workflow run of 2026-09-03, so the pinned sha256 no longer matched the download and provisioning failed verification. The new pin matches the release's `SHA256SUMS` and GitHub's recorded asset digest (`10dcd278f0051060bd9adeee75e1d0024e7d19fe359c2df2e20b1ffc7937168c`).
The push trigger on crates/build-llama-cuda rebuilt and re-published the release zip whenever a push range touched the builder crate, and the overwrite changed the asset's sha256, breaking the gateway's pin for every install until assets.rs caught up. The build now runs on workflow_dispatch only, so a rebuild - and its pin update - is always a deliberate act.
The manual show_menu call ran without a foreground window, so TrackPopupMenu positioned the menu detached from the tray icon. SetForegroundWindow on the tray's message window before the show is the standard KB Q135788 recipe: the menu now opens beside the icon.
The gateway bound its listener only after provisioning finished, so a multi-GB download stood between startup and a reachable server. `serve_thread` now parses config, binds, and assembles a `Gateway` over an empty `Routing` table; provisioning, profile switches, and unloads run as `Command` values on a bounded, debounced `CommandQueue` drained by one worker, each carrying a `CancellationToken` honored at download chunk boundaries and phase boundaries.

- `Command::LoadProfile.persist` is a shared `Arc<AtomicBool>` read at commit time, so a debounced duplicate that asks to persist upgrades the pending or active command it attaches to.
- `enqueue` performs `try_send` before applying debounce side effects, so a full queue rejects the new command with `GatewayError::QueueFull` without cancelling the active command or settling a replaced pending one.
- A cancelled switch stops between `replace_runtimes` and the persist and routing-table swap, and before the STT start phase; the dropped replacement tears down any children it started.
- A pre-cancelled `download_with_progress` returns `LocalError::Cancelled` before the resume-negotiation request and stages no file; mid-stream it stops at the next chunk boundary and keeps the staged partial for resume.
- `resolve_routed_model` answers a configured-but-unloaded model with `GatewayError::ModelProvisioning` (503) naming the active command; an unconfigured name keeps its 404.
- The headless `provision_model` returns `LOCAL_MODELS_UNSUPPORTED` through `switch_failed` rather than misclassifying the unsupported build as `CommandCancelled`.
- `ProvisionModel` and `UnloadModel` have no producers yet and sit behind `#[allow(dead_code)]`; `active_command`, `pending_commands`, and `cancel_pending` are likewise unread.
- The blob cache route passes no token, so its transfers run uncancellable to their own end.
The tray menu's popup position comes from Shell_NotifyIconGetRect in physical pixels, but a DPI-unaware process has Windows virtualize its coordinate space, so TrackPopupMenu rendered the menu at the icon's position scaled by the display factor - far from the icon on a high-DPI screen. SetProcessDpiAwarenessContext runs once at process start, before any window exists. The earlier SetForegroundWindow call in show_menu is removed: tray-icon already calls it on its own window inside show_tray_menu, and a message-only window cannot take foreground, so the call was a no-op.
Terminal progress bars duplicate the log stream, and no interactive user watches the terminal; visual progress lives in the config UI status bar and the tray label. This change removes the `indicatif` dependency and the TTY rendering path, so the gateway emits progress as `tracing` log lines on every stream.

- `render_loop` and `tty_loop` are deleted with `bar_style` and `bar_position`; `log_loop` is now the only body of the `Renderer` thread, which `serve_thread` in `runner.rs` still starts before serving.
- The `LOG_STEP_PERCENT` cadence, one line per 5% step per node, now applies on every stream, including a TTY.
- The new test `the_render_loop_emits_tracing_lines_for_a_hub_operation` runs `log_loop` on the test thread under a thread-local subscriber writing to a `LogBuffer`; a scoped thread stops the loop when the `model.bin: done` line lands, and the 10-second deadline only bounds a broken loop.
- Removing `indicatif` also drops `console`, `encode_unicode`, and `unit-prefix` from `Cargo.lock`, and the RUSTSEC-2025-0119 `number_prefix` note leaves `deny.toml`.
- No test covers `Renderer::start` itself; the new test exercises `log_loop` directly, and the `bar_position` clamp-and-round test goes with the deleted bar code.
The `whisper_cpp=warn` filter was dead because ggml wrote to `stderr` and nothing emitted the `whisper_cpp` target. `gateway-whisper-ffi` now resolves `whisper_log_set` when the library loads, and `WhisperLibrary::set_log_callback` installs a process-wide `extern "C"` bridge that maps `enum ggml_log_level` values onto `tracing` levels at that target. `SttEngine` installs the bridge right after the load, before any context is created.

- Continuation fragments, `GGML_LOG_LEVEL_NONE`, and out-of-range levels degrade to `tracing::Level::DEBUG`, so a runtime emitting an unknown level cannot spam the terminal.
- `whisper_log_set` resolves eagerly through `load_symbol`, so a library without the export fails `WhisperLibrary::load`; the `PROMPTFORGE_WHISPER_LIBRARY` test proves the pinned b4938 library exports it and that the resolved symbol is callable.
- The new tests call `tracing_bridge` directly but assert no emitted events, and the `SttEngine` call site has no test.
Make the command queue visible and cancellable while the gateway serves. `GET /admin/status` gains `queue` (the active command's name, `fraction`, and `started_at`, plus the pending entries), `endpoints` (one `EndpointStatus` per capability endpoint), and `vram_gb`; `POST /admin/queue/cancel` and `POST /admin/queue/cancel-pending` fire the active command's token and drop a waiting entry by index. The tray's `status_label` reads the active command, and the config UI mounts a fixed bottom status bar from `createStatusBar` in `mountLiveShell`.

- `createStatusBar` is self-contained on purpose: it owns its poll loop and the `has-status-bar` body class, so the planned move to shared-ui is a file move.
- `endpoint_status` marks an endpoint `provisioning` only when it is configured, not ready, and a command is active; a configured endpoint with an idle queue reads as not ready.
- The active pane renders one cancel button per pending entry, firing `cancelPendingCommand(index)`, beside the active command's cancel firing `cancelActiveCommand`; each button disables during the call and repolls at once.
- The tray label reports the command's name and rounded percent only in `TrayPhase::Running`; Starting and Error ignore the queue, and `instant_epoch_seconds` clamps a backward clock jump to now.
- The `#[allow(dead_code)]` guards on `pending_commands`, `cancel_pending`, and the `CommandStatus` and `CommandSummary` fields come off; every one now has a caller.
The flag opens the Settings page in the default browser after the bind, so it names the action, not the destination. The rename covers the CLI flag, the ServeOptions field and builder (now browser and with_browser), the USAGE text, the installer's finish-page launch, the README, and the guide. The tray's open_settings functions and the relaunch handoff's OpenSettings variant keep their names: they are the Settings action, not the flag.
Both UIs carried their own copies of the Cursor Dark tokens and the modal, dropdown, toast, status bar, and progress primitives. The new `crates/shared-ui` package holds one copy of each, and both esbuild-built UIs consume it as a `file:../../shared-ui` dependency resolved through its `exports` map. The gateway's molten-lava tokens give way to the shared sheet, which keeps the PromptForge gold accent and maps the gateway's generic token names onto the Cursor Dark values.

- `crates/shared-ui` is not a Rust crate: `Cargo.toml` excludes it from the `crates/*` glob, and `build-ui` emits `cargo::rerun-if-changed` for it so editing shared sources rebuilds both bundles. Its component CSS sits in the `components` layer so each UI's own rules win over the shared defaults.
- `openModal` no-ops a second dialog of the same kind and returns an already-closed handle; `confirmDialog` checks `handle.closed` and settles false, so a duplicate call resolves as a cancellation instead of hanging.
- The workshop's panel dialogs stay content-sized and shadowless: `editor-panel.css` and `zones.css` override the fixed width, drop shadow, and actions margin that `modal.css` skins onto the shared base classes.
- `UpdateView` takes a `ToastStack` and toasts the `available` and `error` phase transitions once each under the `notifiedPhase` guard; the workshop's `main.ts` mounts the shared stack and clears the status bar through `--toast-inset-block-end`.
- New tests `shared-modal.mjs`, `shared-status-bar.mjs`, `shared-toast.mjs`, and `update-view.mjs` pin the shared modules and the toast-on-transition behavior; `createProgressBar` has no dedicated unit test and is exercised only through `update-view.mjs` and the gateway's metric tiles.
Apply ran its switch inline under the apply lock while `load_profile` held that same lock for its whole download, so an apply during a boot load hung. `admin_config_apply` now captures the shadow contents into an `ApplySnapshot` under the lock, releases it, and enqueues `Command::ApplyConfig`; the switch promotes the captures through `promote_captures` at its commit, so a failed or cancelled apply promotes nothing. `load_profile` no longer takes the apply lock, and `gateway-config` exposes `write_atomic` as the replace-through-rename primitive the commit uses.

- Debounce is asymmetric: `DebounceKey::Apply` replaces a pending `LoadProfile` and cancels an active one through `supersedes_load_profile`, a `LoadProfile` queues FIFO behind an apply without cancelling it, and a second apply attaches to the first.
- `promote_captures` writes each capture with `write_atomic` and deletes the shadow only when its current bytes equal the capture; a save that landed mid-apply stays pending.
- `commit_profile_state` takes the `token` and re-checks it under the apply lock, so `admin_config_revert` calling `cancel_apply` before it deletes shadows wins over a commit still waiting for the lock.
- A census with no config or state shadow takes `ApplyPlan::Inline` and promotes under the route lock with no command; `StatePersistence::Promote` now carries `Vec<config_apply::ShadowCapture>` instead of a `PathBuf`.
- A cancelled apply replies `GatewayError::ApplyCancelled` (503, `apply_cancelled`); `ApplyReloadFailed` no longer says the config was promoted. Under a fired token a `PartialStart` (feature `local`) still reports as itself because it lands after the commit.
- `a_save_completes_while_a_switch_command_is_parked` in `tests/it/boot.rs` pins the dropped lock end to end; `active_waiters` exists only under `#[cfg(test)]`.
The progress stream carries raw hub `ProgressEvent` frames with no `stage` key, so the overlay never lit a stage, and the modal hid the status bar's cancel control. `apply-overlay.ts` gains `observe`, which begins a known stage on a `Begun` frame whose `label` matches, and an optional `onCancel` button that `main.ts` wires to `api.cancelActiveCommand()`. `GatewayHttpError` now carries the envelope's `error.code`, and `main.ts` words an `apply_cancelled` refusal as a cancellation toast instead of a failure.

- `main.ts` forwards every stream event to `overlay.observe` under the existing `applying` guard alone. The guard is enough because an Apply cancels any active profile load and later loads queue behind it, so the Apply is the only switch-stage emitter while it runs.
- `refusalDetail` takes over the body of `refusalMessage` and returns the message with the `code`. Only `applyConfig` passes the `code` into `GatewayHttpError`; every other refusal still constructs it with `code` null.
- `observe` ignores frames whose `label` is not one of the three known stages. `beginStage` still appends a row for an unknown stage when called directly.
- The Cancel button disables on its first click and again in `fail`. The overlay stays open until the apply route settles; the cancel request itself is not a terminal event.
- New tests pin: a `Begun` stage frame lights its row, `Updated`, `Finished`, non-stage `Begun`, and `{ stage }` frames change nothing, Cancel posts once and stays disabled, the `apply_cancelled` toast wording, and an unchanged toast for `apply_reload_failed`.
- Untested: the `onCancel` rejection path that toasts "The cancel failed", and `GatewayHttpError.code` on any route other than `applyConfig`.
A local client should reach the gateway without a bearer key, but the key was the only thing that stopped a web page from driving a loopback request. `check_auth` gains a third rule: with `[server] trust_loopback` on (the default), a request from a loopback peer that presents no `Authorization` header and whose `Sec-Fetch-Site` permits ambient access is admitted. Every authenticated handler now extracts a `Caller` in place of a `HeaderMap`, so the rule can see the peer address.

- `Caller` implements `FromRequestParts` with `Rejection = Infallible` and derefs to `HeaderMap`. The peer comes from the `ConnectInfo<SocketAddr>` extension and is `None` when absent; `None` earns no trust.
- `shared_loopback::is_loopback_peer` is the one peer predicate. `require_loopback` now calls it and `check_auth` reuses it.
- `LiveState` carries `trust_loopback`, read once from `config.server().trust_loopback()` at assembly. A change to the field takes effect on restart.
- A presented-but-wrong bearer is refused even on loopback, because rule 3 requires no `Authorization` header at all. Existing missing-key tests now send `Bearer wrong` or run a fixture with `trust_loopback = false`.
- `fetch_metadata_allows_ambient` admits an absent header, `same-origin`, and `none`. It refuses `cross-site`, `same-site`, and any unknown value. `fetch_metadata_allows_cookie` is unchanged.
- The first-run template in `boot.rs` writes `trust_loopback = true` with a comment that names the shared-machine caveat and the opt-out.
- No shared-sidecar test is in this diff. The wrong-bearer edge that a stale-key probe depends on is pinned by gateway tests only.
A loopback gateway admits keyless same-machine callers by default, so the SDK no longer needs `PROMPTFORGE_GATEWAY_API_KEY` to reach one, and operators who must opt out need the caveat stated where they configure it. `GatewayClient` now holds `Option<SecretString>`, gains a `keyless` constructor, and `from_env_with` builds a keyless client when `GatewayEndpoint::is_loopback` holds and the key is unset or empty. The Settings view adds a `trust_loopback` toggle, and the gateway README, `gateway.local.example.toml`, the workshop-server README, and the guide chapters state the shared-machine caveat beside the `trust_loopback = false` opt-out.

- `GatewayEndpoint` decides `loopback` once at construction from the parsed `url::Host`: the `localhost` domain, or an `Ipv4`/`Ipv6` address whose `is_loopback()` holds. `is_loopback()` is a new public accessor.
- `SecretString::disabled_placeholder` is removed. The `Disabled` transport now carries `key: None` like a keyless client.
- A keyless client omits the `Authorization` header entirely. It does not send an empty bearer, because the gateway rejects a presented-but-wrong bearer even from loopback. `keyless_client_sends_no_authorization_header` pins this against a live `axum` listener.
- `from_env_with` folds an empty key into `None` through `SecretString::new`'s refusal. A non-loopback URL with no key still returns `MissingEnv`.
- `GatewayClient::keyless` does not check the endpoint host. Only `from_env` applies the loopback rule.
- `Debug` output stays `<redacted>` for a keyless client, so key presence does not leak. `keyless_client_debug_is_indistinguishable_from_a_keyed_one` pins this.
- The toggle in `settings-view.ts` uses `fallback: true`, so a config file without the field renders the gateway's default as on.
- The editing chapter and the regenerated `promptforge-gateway-guide.md` also gain text on the apply command queue, the `apply-config` command, the `apply_cancelled` error code, and deferred promotion.
- `has_key` is `#[cfg(test)]` only. No code under `crates/workshop-server` changes; only its README does.
An attached progress subscriber held its SSE connection open through the graceful shutdown and pinned the process. The progress stream now selects on the shutdown signal and ends when it fires, and `serve` bounds the in-flight drain with `GRACEFUL_DRAIN_TIMEOUT`. `ShutdownSignal` becomes a `CancellationToken` so one `fire` wakes every waiter and stays fired.

- `ShutdownSignal` replaces `Arc<tokio::sync::Notify>` plus `Arc<AtomicBool>` with one `CancellationToken`; `fire`, `is_fired`, and `fired` map to `cancel`, `is_cancelled`, and `cancelled`. A stream that subscribes after the signal fires ends at once.
- `progress_sse_response` takes a `ShutdownSignal` parameter, `admin_progress` passes `state.shutdown.clone()`, and the unfold state tuple carries the signal into the `tokio::select!`, which returns `None` when it fires.
- The serve shutdown closure fires `route_shutdown` itself and calls `commands.shutdown()` in place of `commands.cancel_active()`, so no pending command starts during the drain; the later `commands_after.shutdown()` call remains.
- `serve` races the server future against `drain_shutdown.fired()` followed by `GRACEFUL_DRAIN_TIMEOUT` (5 s). When the timeout wins it logs a `tracing::warn!` and returns `Ok(())`, and the server result is discarded.
- `serve_thread` calls `runtime.shutdown_timeout(RUNTIME_SHUTDOWN_TIMEOUT)` (5 s) after `block_on`, so blocking-pool work that ignores its cancellation token no longer pins the exit.
- `the_stream_ends_when_the_shutdown_signal_fires` pins that the stream yields `None` on `fire` before the next heartbeat; `shutdown_exits_while_a_progress_subscriber_is_attached` pins that `server.shutdown()` completes within `PHASE_TIMEOUT` with a subscriber attached.
- `serve_abandons_a_stalled_request_after_the_drain_bound` pins the `GRACEFUL_DRAIN_TIMEOUT` expiry arm against a chat proxy whose backend never answers. No test exercises `RUNTIME_SHUTDOWN_TIMEOUT`.
Revert All promises the running configuration, so edits and drafts layered on the deleted shadows must go with them. `revertAll` in `config-store.ts` now clears `edits` and `drafts` and increments a new `revertGeneration` counter after `revertConfig` resolves. `settings-view.ts` compares that counter on each store notification and clears its own `edits`, `sectionDrafts`, `arrayDrafts`, and `revealed` when it moved.

- The Settings view holds its edit state outside the store, so the store signals the revert with a public counter rather than clearing the view's state itself. The view's subscription runs whether or not the view is mounted, so its state clears even while another view owns `main`.
- The clears run only after `revertConfig` resolves; a failed revert request leaves edits and drafts in place. The counter increments before `refreshAll`, so the view sees the new value on the notification that carries the refreshed configuration.
- `revealed.clear()` also collapses the Settings view's revealed sections on revert, beyond the edit and draft state.
- Three new tests in `apply-revert.test.mjs` pin a model `description` edit, a draft model, and a Settings `bind` edit each discarded by Revert All, and a fourth pins that a failed revert request leaves the edit in place. No test covers a revert that lands while the Settings view is unmounted.
The restart banner stayed on screen because `.banner` sets `display: flex`, and an explicit author display beats the UA `[hidden]` rule, so the `hidden` attribute did not remove it. `layout.css` gains `.banner[hidden]`, `.section-body[hidden]`, and `.chat-template-custom[hidden]` rules that set `display: none`. The test harness gains `bundledDisplay`, which resolves the winning `display` from the built `dist/app.css`, and three tests in `settings-sections.test.mjs` and `model-detail.test.mjs` assert the hidden and shown states with it.

- `bundledDisplay` in `harness.mjs` evaluates the cascade itself instead of calling jsdom's `getComputedStyle`: it flattens `@layer` blocks in declared order, places unlayered rules last, and ranks matching selector parts with a hand-rolled `specificity`. jsdom skips `@layer` and lets the UA `[hidden]` rule outrank author classes, so its answer is wrong for exactly the case under test.
- The parsed rule list is cached in the module-level `displayRulesPromise`, so the stylesheet is read and parsed once per process.
- When no author rule matches, `bundledDisplay` returns `none` for a `hidden` element and `block` otherwise.
- Rules under a media condition are skipped, so the helper reports the mobile-first base display only, not the wide-viewport layout.
- `specificity` counts the argument of `:not()` and `:is()` directly, which is exact for a single argument only; a multi-argument list would be misranked.
- `selectorParts`, `specificity`, and `matchesPart` have no direct tests; they are exercised only through the three view tests.
The config UI and the workshop title bar showed the old stone medallion, and `promptforge-gateway.exe` carried no Windows icon. This change replaces `promptforge-icon-1.png` with `promptforge-icon.png` and `promptforge-icon@2x.png`, copies of the workshop icon set's 128 px renders, in both UIs, their asset routes, static file lists, tests, and the guide. A new `crates/gateway/build.rs` compiles `../workshop/icons/icon.ico` into the exe through `embed-resource` on Windows hosts.

- `program-icon.ts` exports `programIcon`, which builds the `<img>` with `src` and a two-entry `srcset` for high-DPI displays; `tab-bar.ts`, `key-prompt.ts`, and `settings-view.ts` call it instead of building their own `<img>`. The workshop `index.html` carries the same `srcset` inline.
- The 2x render gets its own route, `ui_program_icon_2x`, in `routes.rs` and `assets.rs`; both icon routes in the config UI sit inside the `require_loopback` layer, and the loopback test lists both paths.
- `embed-resource` is a `[target.'cfg(windows)'.build-dependencies]` entry; `Cargo.lock` gains only the dependency edge, not a new package entry. `crates/workshop/icons/AGENTS.md` names the four derived PNG copies and the exe embedding so a regenerated set refreshes them together.
- `build.rs` writes a `1 ICON` script to `OUT_DIR` and calls `embed_resource::compile`. `NotAttempted` prints a `cargo::warning` and the build succeeds without the icon; `Failed` fails the build. The icon path lies outside the crate and would break `cargo package`; this is accepted because the gateway is `publish = false`.
- The `icon` test module compiles only under `#[cfg(windows)]` and confirms the embedding by a byte search for every `.ico` image in the built exe. No test covers the `NotAttempted` warning path, and no test runs on non-Windows hosts.
A profile switch held the `switch` mutex for its whole run, so a model download or spawn blocked every inference request, remote ones included. `run_switch_with_config` now runs in five phases - prepare, download, cut over, spawn, commit - and takes the lock only inside `cut_over` and `commit_switch`. The download runs through the new `LocalRuntime::provision_artifacts_with_cancellation`, which stages every artifact without spawning a child, so the spawn that follows finds the cache warm.

- The cut-over publishes an interim `LiveState` in one `live.write`: the target's remote `Routing` (now `Clone`) as the routing table, the surviving runtimes, and the local models about to spawn as `LiveState.loading`; the commit merges the started models into that table and clears the set.
- When nothing old is running, `cut_over` runs before the download so the remote models serve through a cold-boot download; otherwise the download runs first so the old runtimes keep serving, and the old runtimes stop only right before the new ones spawn.
- A request for a model in `loading` gets `GatewayError::ModelLoading`, a 503 with code `model_loading` and `Retry-After: 5`; `GET /admin/status` reports the set as `loading_models`; a failure or cancellation past the cut-over clears it, so the model falls through to a 404 rather than a permanent 503.
- The switch registers a `downloading-models` stage only when the profile names local models and `stopping-models` only when there is something to stop; the config UI overlay in `apply-overlay.ts` maps all four stages.
- No test parks a switch that has old local runtimes inside its download phase, so the order where the old routing keeps serving through the download is untested; the parked-phase tests use the test-only `switch_park::PhasePark` rendezvous and all run the nothing-to-stop order.
Quit and Cancel could wait without bound: `Gateway::serve` joined the command worker forever, and a stalled download body parked the cancellation check until the client's whole-request ceiling. The worker join now has a bound, body reads have an idle bound, and a serve launch also logs to `<state dir>/logs/gateway.log` so a headless run leaves a record.

- `IdleReader` in `download.rs` drains the blocking response on a named reader thread through `mpsc::sync_channel(1)`, because the pinned reqwest 0.12 blocking client exposes no per-read timeout; a read parked past the idle bound reaps only when the whole-request ceiling drops the body.
- A silent peer fails the transfer with `std::io::ErrorKind::TimedOut` at the chunk boundary where the cancellation token is already checked; the staged partial and its provenance marker stay on disk for a later resume.
- A command body that ignores its token is abandoned after `WORKER_JOIN_TIMEOUT` with a `tracing::warn!` naming the active command; `commands.rs` gains a `#[cfg(test)]`-only `override_executor` hook so the new `serve` test can park the worker.
- `init_logging` installs a stdout layer plus a file layer under `DEFAULT_LOG_FILTER` (gateway crates at `info`, the HTTP dependencies at `warn`); it runs after argument parsing so `--help` and `--version` never rotate the running gateway's log, and `open_log_file` keeps one previous run as `gateway.log.1`.
- The stdout-only fallback paths in `init_logging` (unopenable log file, missing profile directory) carry no test.
An apply that downloads model weights sat at a spinning stage row for the whole download and read as hung. `observe` in `apply-overlay.ts` now tracks `<stage>/<model>/download` leaves from the hub stream and drives a detail row under the active stage: "Downloading <model>" with a bar and integer percent, "Verifying <model>" when the download leaf finishes, "Starting <model>" when the `ready` leaf begins, cleared when the stage changes or the overlay closes.

- The row reuses `createProgressBar` from `shared-ui/progress` and creates its nodes once, updating them in place so a flood of coalesced `Updated` frames never re-creates DOM.
- The most recent download leaf wins the row; `Updated` and `Finished` frames for any other path change nothing, and `splitLeafPath` keeps a model name containing a slash intact by taking the stage as the first segment and the leaf as the last.
- Fractions clamp to 0..1 before they reach `setFraction`, so an out-of-range `Updated` cannot overflow the bar.
Every refused route now throws a `GatewayHttpError` that carries the envelope's `error.code`, so callers can branch on the code. A new `refusalError` helper builds the error from `refusalDetail` and replaces `refusalMessage` at each refusal site. The four private `isRecord` copies move into one shared `json.ts` module.

- `main.ts` now re-exports `GatewayApi` and `GatewayHttpError`, so the jsdom harness can construct the client and assert the error type.
- A new `gateway-api.test.mjs` pins that a `putConfig` refusal keeps the status, the message, and the code. A new case in `apply-revert.test.mjs` pins that a Revert All landing while the Settings view is unmounted clears its edits on the next mount.
- No test pins the rejection path of the overlay cancel callback in `main.ts`.
Workspace builds unify `muda`'s `common-controls-v6` feature on, so the tray's predefined About dialog calls `TaskDialogIndirect`; without an application manifest that import binds to the v5 `comctl32.dll` and the exe fails to start with STATUS_ENTRYPOINT_NOT_FOUND. `crates/gateway/build.rs` now writes a manifest declaring the common-controls v6 dependency into `OUT_DIR` and adds it to the resource script as resource id 1 of type 24 (`RT_MANIFEST`), and `embed_icon` is renamed `embed_resources` to cover both resources.

- The manifest lives in the `MANIFEST` const as an inline XML string and is written to `OUT_DIR` at build time, so no new tracked file enters the crate.
- When no resource compiler is present (`CompilationResult::NotAttempted`) the build still succeeds with a `cargo::warning`, even though the manifest is not cosmetic once feature unification turns on `common-controls-v6`.
- The build script has no tests; the `#[cfg(not(windows))]` path is unchanged apart from the rename.
The UI bundles are unversioned (`app.js` keeps its name across builds), so a heuristic cache with no validator could serve a stale script against a newer gateway. `ui_asset` in `gateway-config-ui` and `workshop-server` now answers every asset with `Cache-Control: no-cache`, forcing revalidation on every load.

- `proxy_config_asset` in `gateway_config.rs` stamps the same header on relayed config assets at the relay, so the panel's WebView2 revalidates regardless of what the upstream response carries.
- New tests `every_asset_forces_revalidation` in `routes.rs` and `routes/assets.rs` assert the header on every served asset, and `the_proxied_config_assets_force_revalidation` pins the relay.
- No validator header is emitted, so each load pays a full 200 response; the `ui_asset` doc comment notes a 304 fast path only once validators exist.
Grant IPC permission for the `desktop_update_supported` command so the desktop webview can invoke it. `crates/workshop/build.rs` now calls `tauri_build::try_build` with `tauri_build::Attributes` and `tauri_build::AppManifest` to generate the command permission files. `crates/workshop/src/main.rs` adds `allow-desktop-update-supported` to `WINDOW_PERMISSIONS`, and `crates/workshop/permissions/autogenerated/desktop_update_supported.toml` stores the generated permission definitions.

- `desktop_update_supported` is declared in `tauri_build::AppManifest` so Tauri generates both `allow-desktop-update-supported` and `deny-desktop-update-supported` permission identifiers.
- `WINDOW_PERMISSIONS` includes `allow-desktop-update-supported` so the runtime window capability permits IPC calls from the frontend.
- The change adds no tests for the build script or the generated permission manifest.
The Gateway Config panel iframes `/gateway/config/` from the workshop window, and `frame-ancestors 'none'` made Chromium refuse the frame. The fix adds `POLICY_FRAMEABLE`, a copy of `POLICY` with `frame-ancestors 'self'`, and `header` selects it when the request path starts with `/gateway/config/`. Every other route keeps `POLICY`, which forbids framing.

- `header` stamps the policy after `next.run`, so the self-frameable variant also lands on error envelopes; the new test `the_proxied_config_spa_allows_self_framing` pins this on the 502 path where the gateway is unreachable.
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