From 539463157349718bdc26f296c3d355f2fa5c3368 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Sat, 12 Sep 2026 12:49:36 -0700 Subject: [PATCH 01/19] Make the cross-arm append conflict test deterministic A scheduler test covering two live arms appending to one store path raced: it passed only when the second arm's blocking-pool operation claimed the path before the first arm finished and released its claims, so a slow blocking pool let both appends land and the run return success. The test now parks the first backend append with its write claim held until the losing arm's conflict observation releases it, so the conflict fires no matter how late the second operation's thread starts. The parked wait is bounded at ten seconds, so a claims model that stops conflicting fails the test instead of hanging the run-end drain. - `AppendGate` is a one-shot gate: the first caller parks on a condvar until `open` releases it, later callers pass through, and the wait times out after ten seconds as a backstop. - `GatedStore` and `GatedAccess` wrap a memory backend so the first append served parks on the gate with its write claim held; every other operation forwards unchanged. - `from_vfs` wraps a caller-built `VfsRef` in the test store's seeding and assertion helpers, letting the test mount the gated backend. - `GateObserver` opens the gate on `Observation::StoreAppendFailed`, which fires before the conflict answer posts, so the winner's parked operation completes ahead of the run-end drain that awaits it. - `Scheduler` and the claims model are untouched: the diff changes only test code and the plan's step status. Plan: vibe/2026-09-12-3-workshop-server-decomposition.md --- .../promptforge-core/src/execute/tests/mod.rs | 6 + .../src/execute/tests/scheduler.rs | 174 ++++- ...6-09-12-3-workshop-server-decomposition.md | 608 ++++++++++++++++++ 3 files changed, 783 insertions(+), 5 deletions(-) create mode 100644 vibe/2026-09-12-3-workshop-server-decomposition.md diff --git a/crates/promptforge-core/src/execute/tests/mod.rs b/crates/promptforge-core/src/execute/tests/mod.rs index d7bca0447..33c3cb9dd 100644 --- a/crates/promptforge-core/src/execute/tests/mod.rs +++ b/crates/promptforge-core/src/execute/tests/mod.rs @@ -225,6 +225,12 @@ impl TestStore { TestStore(promptforge_vfs::empty()) } + /// Wraps a caller-built handle - a gated backend, say - in the test + /// store's seeding and post-run assertion helpers. + fn from_vfs(vfs: VfsRef) -> TestStore { + TestStore(vfs) + } + /// The handle the run and the context builders take. fn vfs(&self) -> &VfsRef { &self.0 diff --git a/crates/promptforge-core/src/execute/tests/scheduler.rs b/crates/promptforge-core/src/execute/tests/scheduler.rs index 7dc4e5664..d2aab4900 100644 --- a/crates/promptforge-core/src/execute/tests/scheduler.rs +++ b/crates/promptforge-core/src/execute/tests/scheduler.rs @@ -14,12 +14,16 @@ //! while suspended in an arm). use std::num::NonZeroUsize; +use std::sync::Condvar; +use std::sync::atomic::AtomicBool; +use std::time::Duration; use super::*; use crate::execute::protocol::Answer; use crate::execute::scheduler::Scheduler; use crate::model::{ModelBinding, ModelId}; use promptforge_model_client::model::ModelInvocation; +use shared_vfs::{Entry, ExecId, MemoryBackend, Stat, Vfs, VfsAccess, VfsError, VfsPath}; /// The model set the live H1 pass would leave behind: one `writer` binding /// as the prompt-wide default. The scheduler's tests bypass H1, so they @@ -2515,14 +2519,168 @@ async fn two_live_arms_appending_one_path_terminate_with_a_determinism_violation } } +/// A one-shot gate for the winning arm's backend append: the first +/// `append` the backend serves parks with its write claim held until the +/// losing arm's conflict observation opens the gate, so the cross-arm +/// conflict fires no matter how late the second op's blocking-pool thread +/// starts. The parked wait is bounded: a claims model that stopped +/// conflicting would otherwise strand the run-end drain on the parked op, +/// and the test must fail, never hang. +#[derive(Default)] +struct AppendGate { + released: Mutex, + release: Condvar, + taken: AtomicBool, +} + +impl AppendGate { + /// Parks the first caller until the gate opens; later callers pass. + fn block_first(&self) { + if self.taken.swap(true, Ordering::SeqCst) { + return; + } + let mut released = self + .released + .lock() + .expect("the gate mutex is not poisoned"); + while !*released { + let (guard, elapsed) = self + .release + .wait_timeout(released, Duration::from_secs(10)) + .expect("the gate mutex is not poisoned"); + released = guard; + if elapsed.timed_out() { + // Backstop only: a working claims model opens the gate + // from the conflict observation long before this. + break; + } + } + } + + /// Releases the parked append. + fn open(&self) { + let mut released = self + .released + .lock() + .expect("the gate mutex is not poisoned"); + *released = true; + self.release.notify_all(); + } +} + +/// Opens the gate when the losing arm's append fails: the conflict's +/// failed observation fires before the answer posts, so the winner's +/// parked op completes ahead of the run-end drain that awaits it. +struct GateObserver { + gate: Arc, +} + +impl Observer for GateObserver { + fn observe(&self, _execution: &str, _section: &str, event: Observation) { + if event == Observation::StoreAppendFailed { + self.gate.open(); + } + } +} + +/// A memory backend whose first `append` parks on the gate, so the first +/// arm to reach the backend holds its write claim until the sibling's +/// claim check has met it. +struct GatedStore { + inner: MemoryBackend, + gate: Arc, +} + +impl Vfs for GatedStore { + fn acquire(&mut self, id: ExecId) -> std::result::Result, VfsError> { + Ok(Box::new(GatedAccess { + inner: self.inner.acquire(id)?, + gate: Arc::clone(&self.gate), + })) + } + + fn release(&mut self, id: ExecId) -> std::result::Result<(), VfsError> { + self.inner.release(id) + } +} + +struct GatedAccess { + inner: Box, + gate: Arc, +} + +impl VfsAccess for GatedAccess { + fn read(&self, path: &VfsPath) -> std::result::Result, VfsError> { + self.inner.read(path) + } + + fn write(&mut self, path: &VfsPath, contents: &[u8]) -> std::result::Result<(), VfsError> { + self.inner.write(path, contents) + } + + fn append(&mut self, path: &VfsPath, contents: &[u8]) -> std::result::Result<(), VfsError> { + self.gate.block_first(); + self.inner.append(path, contents) + } + + fn remove(&mut self, path: &VfsPath, recursive: bool) -> std::result::Result<(), VfsError> { + self.inner.remove(path, recursive) + } + + fn exists(&self, path: &VfsPath) -> std::result::Result { + self.inner.exists(path) + } + + fn glob(&self, pattern: &str) -> std::result::Result, VfsError> { + self.inner.glob(pattern) + } + + fn list(&self, path: &VfsPath) -> std::result::Result, VfsError> { + self.inner.list(path) + } + + fn stat(&self, path: &VfsPath) -> std::result::Result { + self.inner.stat(path) + } + + fn mkdir(&mut self, path: &VfsPath, recursive: bool) -> std::result::Result<(), VfsError> { + self.inner.mkdir(path, recursive) + } + + fn rename(&mut self, from: &VfsPath, to: &VfsPath) -> std::result::Result<(), VfsError> { + self.inner.rename(from, to) + } + + fn copy(&mut self, from: &VfsPath, to: &VfsPath) -> std::result::Result<(), VfsError> { + self.inner.copy(from, to) + } +} + #[tokio::test(flavor = "current_thread")] async fn two_arms_appending_one_path_boom_without_any_other_suspension() { // The store operation alone is the interleaving point now: every store // op is a leaf yield, so the arms park live on their appends and the - // second op to execute in the blocking pool meets the first arm's - // standing claim. The old premise - arms that never suspend at I/O run - // one at a time - is gone, and the cross-arm append booms. - let store = TestStore::new(); + // cross-arm append booms. Which arm's op executes first is the + // blocking pool's choice, and an op that runs to completion lets its + // arm finish and release its claims - so the test cannot rely on the + // second op starting while the first is still in flight. The gate + // parks the first op to reach the backend with its write claim held, + // and the second op's claim check meets that standing claim no matter + // how late its thread starts; the conflict's failed observation then + // opens the gate, so the winner's op completes ahead of the run-end + // drain that awaits it. + let gate = Arc::new(AppendGate::default()); + let store = TestStore::from_vfs( + VfsRef::builder() + .mount( + promptforge_vfs::STORE_MOUNT, + GatedStore { + inner: MemoryBackend::new(), + gate: Arc::clone(&gate), + }, + ) + .build(), + ); let md = "---\nname: t\ndescription: d\npromptforge: 0\n---\n\n\ # Fanout\n\n\ ## Parent\n\n\ @@ -2536,7 +2694,13 @@ async fn two_arms_appending_one_path_boom_without_any_other_suspension() { return item\n\ ```\n"; let prompt = parse(md); - let ctx = scheduler_context_on(&prompt, &store, Arc::new(NullObserver::default())); + let ctx = scheduler_context_on( + &prompt, + &store, + Arc::new(GateObserver { + gate: Arc::clone(&gate), + }), + ); let error = Scheduler::new(&ctx, None) .drive() .await diff --git a/vibe/2026-09-12-3-workshop-server-decomposition.md b/vibe/2026-09-12-3-workshop-server-decomposition.md new file mode 100644 index 000000000..2c3fe8ce5 --- /dev/null +++ b/vibe/2026-09-12-3-workshop-server-decomposition.md @@ -0,0 +1,608 @@ +--- +name: Workshop Server Decomposition +overview: Full-stack decomposition of Workshop - server-side crate split (18.2k lines into one-way layered crates with self-registration) and client-side SPA restructuring (lazy-loaded panels, colocated CSS, design tokens, god-object breakup). +todos: + - id: fix-ci + content: "Fix failing CI: scheduler determinism-violation test broken by drain commit" + status: pending + - id: enforcement + content: Add xtask with tidy-style architecture tests and a new-crate generator + status: pending + - id: spa-directory-split + content: "SPA: split flat workshop/ into editor/, agent/, tree/, layout/, menu/ subdirectories" + status: pending + - id: spa-css-colocation + content: "SPA: colocate CSS beside TypeScript, add .ws-* prefix, extract --ws-* design tokens" + status: pending + - id: registry + content: Create workshop-registry crate; migrate status bus as proof of self-registration + status: pending + - id: tier0 + content: Extract workshop-protocol and workshop-support (with generic retained-bus collapse) + status: pending + - id: services + content: Extract workshop-gateway, workshop-status, workshop-menu + status: pending + - id: features + content: Extract workshop-workspace and workshop-sessions; decompose AppState into registry handles + status: pending + - id: spa-lazy-panels + content: "SPA: dynamic import registry for lazy panel loading; enable esbuild splitting" + status: pending + - id: spa-god-objects + content: "SPA: break up window-menu.ts god object; add Part base class for panels; centralize zone/tree state" + status: pending + - id: view-decouple + content: Decouple the SPA bundle behind a narrow asset interface; add headless test feature; content-hashed filenames + status: pending + - id: shell-cleanup + content: Delete workshop shell's duplicated gateway supervisor in favor of shared-sidecar + status: pending + - id: rulebook + content: Write the architecture invariants into workspace rules + status: pending +isProject: false +--- + +# Workshop Full-Stack Decomposition + + + +## Product Requirements + +Workshop is being positioned as a full IDE competing with Cursor. The server delivers everything to a thin Tauri shell via a WebSocket-driven SPA. The codebase must scale to IDE-level feature surface while remaining navigable by agents (narrow-context crates) and friendly to UI/UX designers (discoverable CSS, design tokens, feature-based directories). + +- Problem and users: The server is one 18.2k-line crate with hand-wired subsystems and a god-struct composition root. The SPA is a monolithic bundle (69 files, ~399 KB, everything loads at boot) with no CSS scoping, a flat directory mixing 15 concerns, and a 618-line menu god-object. Agents struggle with the large interaction surface. Designers cannot find or safely edit styles. +- Goals: (1) Decompose the server into one-way layered crates with self-registering subsystems, enforced by Cargo. (2) Decompose the SPA into lazy-loaded feature directories with colocated CSS, design tokens, and pluggable registries. (3) Make every component independently comprehensible within a single context window. (4) Make the CSS layer designer-friendly: discoverable, scopable, theme-swappable. +- Non-goals: No React/Vue/Angular adoption. No Shadow DOM migration (the field is split; `.ws-*` prefix provides 80% of the benefit at 10% of the cost). No LSP integration in this plan. No real extension API yet. +- Success criteria: Every crate under 2k lines. Every file under 500 lines. No hand-wiring in composition roots - subsystems self-register. Initial SPA bundle contains only the shell, services, and chrome; heavy panels load on first activation. All design tokens in `--ws-*` custom properties. Zero raw `#hex` or `px` values in component CSS. +- Constraints: The wire format in `protocol.rs` + `protocol.ts` + their shared JSON fixtures must not change. Public APIs of `promptforge-*` / `shared-*` crates are untouched. The DisposableStore lifecycle tree is correct and stays. The `workshop` Tauri shell stays one thin crate (verified healthy: largest real file 688 lines, slated for deletion). +- Open questions: None + +## Functional Specification + +The decomposition is purely structural - no user-visible behavior changes. Every panel, menu, shortcut, socket connection, and status update works identically after the restructure. The wire protocol is the invariant. + +- Actors and workflows: Agents work on narrow crates instead of one 18k-line crate. UI/UX designers find styles in feature directories beside the TypeScript, edit tokens to theme, and see changes via CSS hot-reload. The build system enforces the one-way graph and file-size ceiling. +- Inputs and outputs: Server inputs/outputs unchanged (HTTP + WebSocket). SPA inputs/outputs unchanged (same wire frames, same DOM). Build output changes from one monolithic `app.js` to a shell chunk plus lazy-loaded feature chunks. +- States and validation: `AppState` decomposes from 9 named fields into registry handles. Module-scope Maps/Sets in `zones.ts` and `workshop-panel.ts` move into observable services. No new states introduced. +- Errors and recovery: Per-crate error types replace the central `error.rs`; the shell maps them to HTTP responses. SPA gains `Result` + `ErrorCatalog` replacing stringly-typed errors. +- Security and privacy behavior: No changes. CSP, cross-site guard, and jailed workspace all stay. +- Acceptance criteria: Full Rust test suite green (`cargo test --locked --workspace`). Full SPA tests green (`npm run build` + `node --test`). Visual verification that all five menus, all panels, and all keyboard shortcuts work. Initial bundle size measurably smaller than pre-decomposition. + + + + +## Technical Design + +The architecture splits into two parallel decompositions - server-side crates and client-side SPA features - sharing one pattern: subsystems self-register into a central registry owned by a bottom layer, the dependency graph flows strictly one way, and mechanical enforcement (Cargo, xtask tests, lint rules) replaces reviewer vigilance. Evidence base: two what-to-steal reports analyzing 12 reference projects (Zed, VS Code, Theia, rust-analyzer, Helix, ox, JupyterLab, Home Assistant, vanilla-typescript-spa, ft_transcendence) with citation-checked findings and per-idiom provenance tags. + +### Server crate graph + +Dependencies flow strictly downward. Cargo enforces no upward edges. Crates in the same tier never depend on each other - they meet through `workshop-protocol` (wire types) and `workshop-registry` (proxy slots). + +```mermaid +flowchart TD + subgraph shell ["workshop-server - composition root and HTTP shell"] + ROOT["app, serve, routes, assets, csp, cross_site, main"] + end + + subgraph features ["Feature crates"] + MENU["workshop-menu\nmenu + catalog ~1.1k lines"] + SESS["workshop-sessions\nsession + session_agents + input + relay\n~4.4k lines"] + WSP["workshop-workspace\nworkspace ~1.2k lines"] + end + + subgraph services ["Domain service crates"] + GW["workshop-gateway\ngateway + binding + progress\n+ resolve + heartbeat + observer ~4.5k lines"] + STAT["workshop-status\nstatus bus + progress renderer ~1.7k lines"] + end + + subgraph vocab ["Vocabulary crates - no internal deps"] + REG["workshop-registry\nproxy slots, subsystems self-register"] + PROTO["workshop-protocol\n/ws JSON frames + wire error, fixture-pinned\n~1.1k lines"] + SUP["workshop-support\natomic, backoff, deadline, config, retained bus"] + end + + shell --> features + features --> services + services --> vocab +``` + +Every crate carries: a written invariant list in its `lib.rs` docs (rust-analyzer's architecture.md pattern, strong human signal, introduced 2018 by Aleksey Kladov), workspace-inherited lints, and files under the 500-line ceiling. + +### Workspace manifest discipline (rulebook section 8) + +The workspace root stays a virtual manifest (`[workspace]` with no `[package]`). All new crates go in the flat `crates/` directory, globbed as `members = ["crates/*"]`. Each crate is named `workshop-*` in kebab-case with no `-rs` suffix. Every external dependency is declared once in `[workspace.dependencies]`; members write `dep.workspace = true`. Internal path dependencies carry both `path` and `version` in `[workspace.dependencies]`. New crates set `version = "0.0.0"` and `publish = false`. `edition`, `rust-version`, `license`, and `repository` are inherited from `[workspace.package]`. `[profile.*]` and `[patch.*]` stay in the root manifest only. + +### Per-crate conventions (rulebook sections 5, 6, 7, 10, 12) + +- `lib.rs` is a facade only: crate docs (`//!`), crate-level attributes, `mod` declarations, and `pub use` re-exports. No logic. +- Default every item to `pub(crate)`. Set `unreachable_pub = "warn"` so bare `pub` reliably marks the public API. +- Each crate gets its own concrete error type derived with `thiserror`, `#[non_exhaustive]` on every public error enum and on every variant carrying data. `Display` messages are lowercase noun phrases, no trailing period, no `failed to` prefix. The shell crate maps per-crate errors to HTTP responses - no crate below the shell exposes `anyhow`, `Box`, or another crate's error type through a public signature. +- Document every `pub` item. `# Errors` on every `Result`-returning function. `# Panics` where callers can trigger one. `# Safety` on every `unsafe` item. +- Lint levels in `[lints]` tables only, never `#![deny(...)]` at the crate root. Each member inherits from `[workspace.lints]` with `[lints] workspace = true`. The workspace already sets `unsafe_code = "forbid"`, `missing_docs = "warn"`, `clippy::unwrap_used = "deny"`, `clippy::expect_used = "deny"`. +- One integration-test binary at `tests/it/main.rs` per crate, not one file per test. Shared test helpers go in `tests/common/mod.rs` (not `tests/common.rs`, which Cargo builds as its own binary). Test-only dependencies in `[dev-dependencies]`. Gate cross-crate test helpers behind a `test-fixtures` feature. + +### Server crate map + +Tier 0 - vocabulary, no internal dependencies: +- `workshop-protocol` - from `protocol.rs` + `error.rs`. The client-server wire contract: every JSON frame exchanged over the `/ws` socket, plus the opaque wire error. Zero I/O, fixture-pinned. rust-analyzer confines `lsp-types` to exactly 1 of 33 manifests; this applies the same transport-quarantine discipline. +- `workshop-support` - from `atomic.rs`, `backoff.rs`, `deadline.rs`, `config.rs`, plus a new generic retained-bus abstraction collapsing the three hand-rolled copies in status/catalog/menu (~2k lines of duplicated broadcast + retained snapshot + resend-on-reconnect). +- `workshop-registry` - NEW, the keystone. One proxy slot per subsystem (`RwLock>>`); subsystems self-register routes, state handles, and shutdown; unregistered slots are graceful no-ops. The registry's traits are sealed (rulebook section 6: seal a trait you do not want implemented downstream with a private empty supertrait) so only workshop crates can implement them. `#[must_use]` on the registration handle. All six crate-report references converge: Zed's `ExtensionHostProxy`, VS Code's `registerSingleton`, Theia's `ContainerModule`, rust-analyzer's `handlers::all()`, Helix's `register_hook!`, ox's convention tables. + +Tier 1 - domain services: +- `workshop-gateway` - ~4.5k lines. HTTP client, endpoint binding, discovery, heartbeat, progress subscriber, relay, test seam. Domain crate never exposes an axum type (Helix quarantines LSP the same way). +- `workshop-status` - ~1.7k lines. Status-bar broadcast bus, progress renderer, event log. +- `workshop-menu` - ~1.1k lines. Model menu snapshot, catalog channel. + +Tier 2 - features: +- `workshop-sessions` - ~4.4k lines. `/ws` WebSocket, agent supervision, input waits. +- `workshop-workspace` - ~1.2k lines. Jailed filesystem: trees, reads, writes. + +Tier 3 - shell (the slimmed `workshop-server`): +- `app.rs` (composition root over the registry), `routes.rs`, `serve.rs`, `assets.rs`, `csp.rs`, `cross_site.rs`, `push.rs`, `fixtures.rs`, `main.rs`, `lib.rs`. + +### SPA structure + +Feature-based directories with colocated assets - the universal industry standard for designer-friendly codebases (confirmed by GitHub Primer, Shoelace, Adobe Spectrum, IBM Carbon, and all 6 SPA references). A designer finds "the styles for the agent chat" at `ui/agent/agent-session.css`, not by grepping a flat folder. + +```mermaid +flowchart TD + subgraph boot ["Boot shell (loads immediately)"] + MAIN["main.ts\npanel registry, service registry, lazy thunks"] + SERVICES["services/\nDOM-free state, wire protocol, emitters"] + BASE["base/\nDisposableStore, Emitter, lifecycle primitives"] + end + + subgraph lazy ["Lazy-loaded features (dynamic import on first activation)"] + EDITOR["ui/editor/\neditor-surface.ts + editor-panel.ts + editor-panel.css\n+ editor-dialog.ts + index.ts"] + AGENT["ui/agent/\nagent-session-view.ts + prompt-input.ts + markdown-render.ts\n+ 6 more files, each with colocated .css\n+ index.ts"] + TREE["ui/tree/\nworkshop-panel.ts + zones.ts + zones.css\n+ index.ts"] + MENU["ui/menu/\ncommand-registry.ts + menu-registry.ts\n+ menu-renderer.ts + index.ts"] + STT["ui/stt/\nrealtime-stt.ts + stt.ts + stt.css\n+ index.ts"] + end + + subgraph shared ["Shared (boot + features both use)"] + TOKENS["tokens/\nbase.css + semantic.css + component.css"] + PROTO["services/protocol.ts\nwire types, fixture-pinned with protocol.rs"] + CHROME["ui/chrome/\nwindow chrome, model picker, about, zoom"] + end + + MAIN -->|"() => import(...)"| lazy + boot --> shared + lazy --> shared +``` + +### SPA naming and file conventions + +- Directories: kebab-case (`ui/agent/`, `tokens/`) +- Files: kebab-case (`agent-session-view.ts`, `agent-session.css`) +- CSS classes: `.ws-` project prefix (`.ws-agent-toolbar`, `.ws-prompt-input`) - namespace isolation matching VS Code and Theia's `.theia-` convention, without Shadow DOM or CSS modules +- Design tokens: `--ws-` prefix (`--ws-color-bg-surface`, `--ws-spacing-panel-gap`) - three-tier architecture following Primer/Spectrum/Carbon: `tokens/base.css` (primitives) -> `tokens/semantic.css` (intent aliases, theming) -> `tokens/component.css` (per-component overrides). All 5 surveyed design systems split tokens by domain; all 5 use separate theme files. +- Barrel exports: each directory has `index.ts`; lazy directories export `register()` installing commands, menu items, panel factories, and socket subscriptions +- CSS colocation: `.css` beside `.ts`, imported as side-effect; esbuild CSS hot-reload gives designers save-and-see workflow + +### SPA target layout + +| Directory | Files | Lines | Loads | Designer touches | +|---|---|---|---|---| +| `(root)` | 2 | ~220 | boot | - | +| `base/` | 2 | 119 | boot | - | +| `services/` | 12 | 2,786 | boot | - | +| `tokens/` | 3 | ~120 | boot | `base.css` (primitives), `semantic.css` (theme), `component.css` (overrides) | +| `ui/agent/` | ~12 | ~1,720 | lazy | `.css` per component | +| `ui/editor/` | ~4 | ~740 | lazy | `editor-panel.css` | +| `ui/layout/` | ~6 | ~1,250 | lazy | `zones.css` | +| `ui/menu/` | ~6 | ~730 | lazy | per-menu CSS | +| `ui/take/` | ~4 | ~1,100 | lazy | - | +| `ui/stt/` | ~3 | ~400 | lazy | `stt.css` | +| `ui/chrome/` | ~7 | ~780 | boot | 5 `.css` files | +| `ui/status/` | ~1 | ~150 | boot | - | +| `ui/workspace/` | ~2 | ~260 | lazy | - | +| `ui/gateway/` | ~2 | ~140 | lazy | `gateway-config-panel.css` | +| `ui/shared/` | ~1 | ~16 | boot | Icons | + +### Registration point inventory (12 total) + +Every one follows the same pattern: a central file names things today; after the decomposition, things name themselves. + +Server side (5 points, all flow through `workshop-registry`): + +| # | What | Central wiring today | Registrant | +|---|---|---|---| +| 1 | Routes | `app.rs:242-255` - 7 `.merge()` calls | Each subsystem crate | +| 2 | State handles | `app.rs:164-204` - `AppState` constructs 9 fields by name | Each subsystem crate | +| 3 | Background tasks | `heartbeat.rs`, `gateway_progress.rs`, `progress.rs` - 3 `tokio::spawn` | Each subsystem crate | +| 4 | Push channels | `push.rs` - cross-bus facade naming 3 buses | Each subsystem crate | +| 5 | Shutdown handles | `serve.rs` - stop senders held by root | Each subsystem crate | + +SPA side (7 points): + +| # | What | Central wiring today | Registrant | +|---|---|---|---| +| 6 | Panel types | `panel-types.ts:49-81` - 4 hardcoded entries | Each directory's `index.ts` | +| 7 | Menu items | `window-menu.ts:168-195` - 5 hardcoded arrays | Each directory via `appendMenuItem()` | +| 8 | Services | `main.ts:46-81` - 8 services by name | Each service via `registerService(token, factory)` | +| 9 | Socket handlers | `main.ts:84-228` - 4 hand-wired subscriptions | Each directory at activation | +| 10 | Keyboard shortcuts | `shortcuts.ts` - hardcoded table | Each directory, alongside its commands | +| 11 | Zone affinities | `panel-types.ts` - hardcoded `defaultZone` | Part of panel registry (#6) | +| 12 | Lifecycle disposal | `main.ts:28` - 20+ `.add()` calls | Each subsystem's `register()` returns disposable | + +### God-object decomposition + +**Server `AppState`:** Nine fields already have single owners. Each subsystem crate owns its state behind a narrow handle trait; handlers receive only the handles their route needs (Zed Entity-handles, Helix boxed callbacks, rust-analyzer host/snapshot split). `AppState` shrinks to a registry of handles. `SessionHost` carrying five buses is the specific tangle - the generic retained-bus plus per-route handle injection removes it. The `error.rs <-> app.rs` cycle breaks by moving error-to-HTTP mapping up into the shell, with per-crate error types below (rulebook section 5: prefer one error type per unit of fallibility so a caller never sees variants a function cannot produce; never expose a dependency's error type through a public API; wrap it or hide the representation behind `#[error(transparent)]`). + +**SPA `window-menu.ts` (618 lines, 5 HTML menus):** Becomes a pluggable registry: `command-registry.ts` (Map of command descriptors), `menu-registry.ts` (Map of menu placements per MenuId), `menu-renderer.ts` (reads registries, builds DOM, knows nothing about what's registered). VS Code's `MenuRegistry` + `registerAction2` pattern. Each concern directory registers its commands and menu items at activation - lazy panels register when their chunk loads. + +**SPA module-scope state:** `zones.ts` and `workshop-panel.ts` hold Maps/Sets at module scope, invisible to services. Move into `ZoneStateService` and `TreeStateService` on the existing Emitter pattern. + +- Modules and interfaces: Server splits into 8 crates across 4 tiers. SPA splits into ~14 feature directories. Three SPA registries (panel, menu/command, service) mirror the server's `workshop-registry`. Each lazy directory exports `register()`. +- File and public API changes: `app.rs` shrinks from composition root to registry host. `main.ts` shrinks from 230 lines of hand-wiring to registry setup + lazy thunks. 8 oversized Rust files split during crate extraction. `window-menu.ts` splits into 3 registry files + per-directory registrations. +- Data, persistence, failure, security, and privacy constraints: Wire protocol unchanged. Layout persistence JSON unchanged (dockview serialization). `workshop.toml` config unchanged. CSP, cross-site guard, and jailed workspace unchanged. No new persistence, no new network surface. + +### AGENTS.md guard rails + +Three structural rules plus two designer-facing rules at `promptforge/AGENTS.md`: + +``` +# Structural Rules + +- Dependencies flow one way: shell -> features -> services -> vocabulary. + Never add a dependency from a lower tier to a higher one. If Cargo rejects + a cycle, the design is wrong, not the graph. On the SPA side, lazy-loaded + panels never import the boot shell; shared code lives in services/ or base/. +- Every workshop-* crate's lib.rs opens with a //! doc listing what the crate + may depend on and what it may not. Read it before adding an import. Every + SPA concern directory (ui/editor/, ui/agent/, etc.) has the same in its + index.ts. +- No file exceeds 500 lines. If an edit would push a file past 500, split + first, then edit. + +# SPA and CSS Rules + +- CSS lives beside its TypeScript, never in a separate styles/ tree. A + designer finds the styles for the agent chat at ui/agent/agent-session.css, + not by grepping a flat directory. Every feature directory is self-contained: + .ts, .css, and index.ts together. +- No raw color, size, or spacing values in component CSS. Use --ws-* tokens + from tokens/. Primitives go in tokens/base.css, intent aliases in + tokens/semantic.css, per-component overrides in tokens/component.css. A + designer themes the app by editing semantic.css. +``` + + + + +## Testing Plan + +The test suites are the invariant. Every work item passes the full suite before commit. The wire protocol fixtures pin behavior across the restructure. + +- Unit: Existing Rust unit tests move with their modules into the new crates (rulebook section 11: unit tests in `#[cfg(test)] mod tests` in the same file). No new unit tests required for file moves; new tests required for the generic retained-bus abstraction, the registry crate, the SPA panel registry, and the command/menu registries. +- Integration and end-to-end: One integration-test binary at `tests/it/main.rs` per crate, with `mod` per area (rulebook section 11: each extra file directly under `tests/` relinks the whole library). Existing integration tests continue to test the composed server. SPA visual verification confirms all five menus, all panels, and all keyboard shortcuts work after each structural change. The headless feature flag enables new end-to-end tests without the webview. +- Regression, security, and performance: The full local loop before pushing (rulebook section 12): `cargo fmt --all --check`, `cargo clippy --all-targets --all-features -- -D warnings`, `cargo test --locked --workspace --all-features`, `cargo test --doc`, `RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --all-features`. `protocol.rs`/`protocol.ts` cross-language fixture tests catch wire-format drift. `npm run build` on every SPA step. Initial bundle size measured before and after lazy-loading to confirm reduction. +- Exit criteria: All crates under 2k lines. All files under 500 lines. No hand-wiring in composition roots. Initial SPA bundle measurably smaller. `xtask` architecture tests pass (allowed-dependency tables, file ceiling). CSS lint blocks raw values. Full CI green. `cargo hack check --feature-powerset --no-dev-deps --depth 2` confirms every feature combination compiles. + + + + +## Decision Record + +- Decisions: + - Self-registration via proxy slots, not DI framework: Zed's `ExtensionHostProxy` pattern (one `RwLock>>` per subsystem) is the most portable Rust form. "I want a central server that has an API that lets the sub-crates install themselves, so we don't have a cyclic dependency." + - Feature-based SPA directories, not layer-based: "I want to make it very easy for [UI/UX designers] to work on this shit." Industry consensus (Primer, Spectrum, Carbon, all 6 SPA references) confirms feature-based. A designer browses `ui/agent/` to find agent styles. + - Three-tier design tokens (base/semantic/component): Primer, Spectrum, and Carbon all use this. Semantic tier is where theming happens. Lint rule blocks raw values in component CSS. + - `.ws-*` CSS prefix over Shadow DOM: "The field is split (Home Assistant and vanilla-typescript-spa use Shadow DOM; VS Code and Theia do not). The `.ws-*` prefix provides 80% of the benefit at 10% of the cost." + - Pluggable menu registry over hardcoded arrays: VS Code's `MenuRegistry` + `registerAction2` pattern. "A component can install menus dynamically." Lazy panels register their items when their chunk loads. + - Crate decomposition for agentic workflows, not just build parallelism: "A larger crate has a bigger interaction surface with itself. A smaller crate is more focused, and the crate is the natural unit of context." + - Server crate graph as a strict tree: "The dependency graph has to go only one way. It has to be a tree, and it has to be clean, and I want it enforced by cargo." + - Mechanical enforcement (xtask tests) over review: "3 of 6 crate-report references enforce mechanically; the one that doesn't - Zed - has the biggest god-files." +- Rejected alternatives: + - Shadow DOM for CSS isolation: Duplicates CSS across shadow roots. Home Assistant uses it successfully but VS Code and Theia do not. Revisit if `.ws-*` prefix proves insufficient. + - Full DI framework (InversifyJS) on the SPA side: Too heavy for the subject's vanilla-TS discipline. Lightweight registries (Map + factory) achieve the same decoupling. + - Publishing crates to crates.io: "I don't give a shit." All crates are `publish = false`. + - Phased contribution lifecycle now: Premature before the registry exists and subsystem count justifies it. + - HTML template files now: Low priority because DOM construction is clean enough and the primary designer touchpoint is CSS. +- Assumptions, risks, and notes: + - The `workshop` Tauri shell is verified healthy at ~3.5k lines with the largest real file at 688 lines (slated for deletion). It stays one crate. + - `AppState`'s nine fields already have single owners, so the god-struct decomposition is latent - the crate split formalizes ownership that already exists. + - esbuild supports `import()` splitting natively with `splitting: true` and `format: 'esm'`. + - The `workshop` crate overrides `unsafe_code` from `forbid` to `deny` because `bridge.rs` uses raw WebView2 COM and cannot be written without unsafe; this is documented in the crate's `Cargo.toml`. + - Risk: if the per-concern state split turns out artificial, a `workshop-common` crate could recreate the big interaction surface one level down. The registry's proxy-slot design mitigates this - subsystems meet through protocol types, not shared state. + - CI is currently red: `promptforge-core` scheduler determinism-violation test broken by the drain commit (`f566cc1`). Must be fixed before any decomposition work. + + + + +## Project Survey + +- Status: complete +- Build command: `cargo build --locked -p gateway` (default member; workshop: `cargo build --locked -p workshop`) +- Focused test command pattern: `cargo nextest run --locked -p ` +- Component test command pattern: `cargo nextest run --locked -p ` +- Full-suite test command: `cargo nextest run --locked --workspace --exclude workshop --exclude workshop-server --all-features` (workshop: `cargo nextest run --locked -p workshop -p workshop-server`) +- Linter command: `cargo clippy --workspace --exclude workshop --exclude workshop-server --all-targets --all-features -- -D warnings` (workshop: `cargo clippy -p workshop -p workshop-server --all-targets -- -D warnings`) +- Formatter check command: `cargo fmt --all --check` +- Docs command: `RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps --all-features --exclude workshop --exclude workshop-server` +- Test placement and naming conventions: one integration-test binary at `tests/it/main.rs` per crate with `mod` per area and subdirectories for sub-modules; shared test helpers in `tests/common/mod.rs`; unit tests in `#[cfg(test)] mod tests` in-file; SPA tests via `node --test "test/**/*.mjs" "src/**/*.test.mjs"` from `crates/workshop-server/ui` +- Directory map: + - `crates/` - flat directory of all Rust crates (37 crates) and one non-Rust package (`shared-ui`); workspace globbed as `members = ["crates/*"]` with `shared-ui` excluded + - `crates/workshop-server/` - the 18.2k-line server crate being decomposed; `src/` has 40+ Rust source files; `ui/` holds the SPA (TypeScript + CSS, esbuild-bundled) + - `crates/workshop-server/ui/src/` - SPA entry: `main.ts`, `base/` (lifecycle primitives), `services/` (DOM-free state), `ui/` (flat 23 TS + 13 CSS), `ui/workshop/` (flat 13 TS + 4 CSS) + - `crates/workshop/` - Tauri desktop shell (thin, verified healthy) + - `crates/gateway*/` - inference gateway product crates (gateway, gateway-config, gateway-config-ui, gateway-local, gateway-logging, gateway-protocol, gateway-routing, gateway-stt, gateway-stt-backend-whisper, gateway-stt-engine, gateway-web-search, gateway-whisper-ffi) + - `crates/promptforge*/` - runtime engine crates (promptforge, promptforge-core, promptforge-core-support, promptforge-agent, promptforge-lua, promptforge-model-client, promptforge-parser, promptforge-store, promptforge-tool-picker, promptforge-tools, promptforge-vfs, promptforge-webfetch, promptforge-web-search) + - `crates/shared-*/` - cross-product crates (shared-loopback, shared-progress, shared-sidecar, shared-vfs, shared-ui) + - `crates/build-*/` - build output crates (build-llama-cuda, build-ui, build-user-guide, build-workshop) + - `crates/product-integration-tests/` - cross-product integration tests + - `.github/workflows/` - CI: fmt, clippy, test (nextest), docs, check-workshop (Windows), check-workshop-linux, ui (typecheck + build + test), supply-chain (cargo-deny + cargo-audit), ci-green gate + - `guide/` - user guide sources + - `prompts/` - prompt pipeline sources + - `tools/` - build and staging scripts (e.g. `stage-gateway-sidecar.mjs`) + - `vibe/` - project context (`archdoc.md`) +- Component boundaries: + - Workshop crates (`workshop-*`) depend on shared crates but not on gateway or promptforge crates + - Gateway crates (`gateway-*`) depend on shared crates but not on promptforge or workshop crates + - PromptForge crates (`promptforge-*`) depend on shared crates but not on gateway or workshop crates + - Shared crates (`shared-*`) depend on no product crates + - Default `cargo build` builds only gateway; workshop requires explicit `-p workshop` + - Workshop-server and workshop are excluded from the Linux CI clippy/test/docs jobs and tested separately on Windows (and a dedicated Linux job) + - SPA is TypeScript (esbuild-bundled, Node >= 22), no framework (vanilla TS); depends on `shared-ui` as a local npm file dependency +- Conventions summary: + - Rust 2024 edition, stable toolchain, `rustfmt.toml` with `style_edition = "2024"` + - Workspace-inherited lints: `unsafe_code = "forbid"`, `missing_docs = "warn"`, `clippy::unwrap_used = "deny"`, `clippy::expect_used = "deny"`, `clippy::all = "deny"`, `clippy::pedantic = "warn"`; `clippy.toml` allows unwrap/expect in tests + - All external dependencies declared once in `[workspace.dependencies]`; members use `dep.workspace = true` + - Internal path dependencies carry both `path` and `version` in `[workspace.dependencies]` + - `cargo-deny` and `cargo-audit` for supply-chain checks + - `cargo-nextest` for concurrent test execution in CI + - SPA: `npm run build` (esbuild), `npm run typecheck` (tsc --noEmit), `npm test` (node --test) + - CI gate job (`ci-green`) aggregates all jobs for branch protection + + + + +## Execution Instructions + + + +### Step 1: Fix CI [completed] + +- Component: Foundation + +Fix the scheduler determinism-violation test in `promptforge-core` (`scheduler.rs:2543`). The drain commit (`f566cc1`) lets both concurrent appends complete before the conflict detector fires. [Failing run](https://github.com/cppalliance/promptforge/actions/runs/34707753972). The fix must make the conflict detector fire deterministically regardless of append ordering during drain. CI must be fully green before any decomposition work begins. + +Verification: `cargo nextest run --locked -p promptforge-core scheduler` passes locally. Push and confirm CI green on all jobs. + + + + + +### Step 2: AGENTS.md, xtask, and new-crate generator + +- Component: Foundation + +Add `promptforge/AGENTS.md` with three structural rules (one-way deps: shell -> features -> services -> vocabulary; per-crate invariant docs in `lib.rs`; 500-line file ceiling) and two designer-facing rules (CSS colocation beside TypeScript; token-only `--ws-*` values in component CSS). + +Add `crates/xtask/` with tidy-style `#[test]` architecture checks: allowed-dependency table per tier (vocabulary crates depend on no internal crates; service crates depend only on vocabulary; feature crates depend on vocabulary and services; shell depends on all), forbidden reverse edges, file-line ceiling scan (500 lines), `unreachable_pub` enforcement. Add a `new-crate` subcommand that scaffolds `crates/workshop-/` with `Cargo.toml` (workspace-inherited edition/lints/version, `publish = false`), facade `lib.rs` with `//!` invariant docs, and `tests/it/main.rs`. rust-analyzer's tidy checks (Kladov 2019) and Zed's `script/new-crate` are the reference patterns. + +Verification: `cargo test -p xtask` passes. `cargo xtask new-crate workshop-scratch` scaffolds correctly (then delete it). AGENTS.md renders correctly. + + + + + +### Step 3: Extract tier-0 vocabulary crates + +- Component: Server Decomposition + +Extract all three vocabulary crates in leaf-first order. These crates have no internal dependencies. + +**workshop-protocol:** Extract from `workshop-server/src/protocol.rs` and `workshop-server/src/error.rs` (wire error variants only). The client-server wire contract: every JSON frame exchanged over `/ws`, plus the opaque wire error type. Zero I/O, fixture-pinned against `protocol.ts` and shared JSON fixtures. Declare in `[workspace.dependencies]` with `path` and `version`. + +**workshop-support:** Extract from `workshop-server/src/atomic.rs`, `workshop-server/src/backoff.rs`, `workshop-server/src/deadline.rs`, `workshop-server/src/config.rs`. Add a new generic retained-bus abstraction (`RetainedBus`) collapsing the three hand-rolled copies in status/catalog/menu (broadcast + retained snapshot + resend-on-reconnect, ~2k duplicated lines). Each copy becomes a type alias or thin wrapper over the generic bus. + +**workshop-registry:** NEW crate - the keystone. One proxy slot per subsystem (`RwLock>>`); sealed traits (private empty supertrait) so only workshop crates can implement them. Traits for: route registration, state handle provision, background task spawning, push channel subscription, shutdown handle. `#[must_use]` on the registration guard handle. Unregistered slots are graceful no-ops. Migrate status bus as the proof-of-concept registrant. + +Update `workshop-server/Cargo.toml` to depend on all three. Update `xtask` allowed-dependency tables. + +Verification: `cargo clippy -p workshop-protocol -p workshop-support -p workshop-registry --all-targets -- -D warnings` clean. `cargo test --locked --workspace` green. Wire protocol fixture tests still pass. `cargo test -p xtask` confirms dependency graph. + + + + + +### Step 4: Extract service crates + +- Component: Server Decomposition + +Extract all three domain service crates. These depend only on tier-0 vocabulary crates. + +**workshop-gateway** (~4.5k lines): Extract from `workshop-server/src/gateway.rs`, `workshop-server/src/gateway_binding.rs`, `workshop-server/src/gateway_progress.rs`, `workshop-server/src/resolve.rs`, `workshop-server/src/heartbeat.rs`, `workshop-server/src/observer.rs`. HTTP client, endpoint binding, discovery, heartbeat, progress subscriber, relay, test seam. Domain crate never exposes an axum type in its public API. Self-registers routes, state handle, and background tasks (heartbeat, progress) into `workshop-registry`. + +**workshop-status** (~1.7k lines): Extract from `workshop-server/src/status.rs`, `workshop-server/src/progress.rs`. Status-bar broadcast bus (now backed by generic `RetainedBus`), progress renderer, event log. Self-registers push channel and state handle. + +**workshop-menu** (~1.1k lines): Extract from `workshop-server/src/menu.rs`, `workshop-server/src/catalog.rs`. Model menu snapshot, catalog channel (backed by `RetainedBus`). Self-registers push channel and state handle. + +Each crate gets its own concrete error type derived with `thiserror`, `#[non_exhaustive]`. Update `workshop-server` to depend on all three service crates. Update `xtask` allowed-dependency tables. + +Verification: `cargo clippy --all-targets --all-features -- -D warnings` clean for all new crates. `cargo test --locked --workspace` green. `cargo test -p xtask` confirms no upward dependency edges. + + + + + +### Step 5: Extract feature crates and decompose AppState + +- Component: Server Decomposition + +Extract both feature crates. These depend on vocabulary and service crates. + +**workshop-workspace** (~1.2k lines): Extract from `workshop-server/src/workspace.rs`. Jailed filesystem: tree operations, reads, writes. Self-registers routes and state handle. + +**workshop-sessions** (~4.4k lines): Extract from `workshop-server/src/session.rs`, `workshop-server/src/session_agents.rs`, `workshop-server/src/input.rs`, `workshop-server/src/relay.rs`. WebSocket `/ws` handler, agent supervision, input waits. Self-registers routes, state handle, push channels, and background tasks. The `SessionHost` tangle (five buses) resolves through generic `RetainedBus` plus per-route handle injection via registry traits. + +**AppState decomposition:** `AppState` in `app.rs` shrinks from 9 named fields to registry handles. Each extracted subsystem owns its state behind a narrow handle trait registered in `workshop-registry`. The shell's `app.rs` becomes a composition root that starts the registry, calls each crate's `register()`, and builds the axum router from registered routes. `error.rs` error-to-HTTP mapping stays in the shell; per-crate error types live below. + +Update `xtask` allowed-dependency tables for the complete tier graph. + +Verification: `cargo clippy --all-targets --all-features -- -D warnings` clean workspace-wide. `cargo test --locked --workspace` green. All crates under 2k lines. `cargo test -p xtask` confirms the full one-way dependency graph. No file exceeds 500 lines. + + + + + +### Step 6: SPA directory split, CSS colocation, and design tokens + +- Component: SPA Restructuring + +**Directory split:** Move 53 files from flat `ui/` and `ui/workshop/` into feature directories: `ui/agent/` (12 files), `ui/editor/` (4 files), `ui/layout/` (6 files from `workshop/`), `ui/menu/` (2 files), `ui/stt/` (3 files), `ui/chrome/` (7 files), `ui/take/` (4 files), `ui/status/` (1 file), `ui/workspace/` (1 file), `ui/gateway/` (2 files + 2 CSS), `ui/shared/` (1 file). Add barrel `index.ts` per directory re-exporting the directory's public API. Update all import paths in `main.ts`, `services/`, and cross-directory references. File mapping follows the SPA file inventory table in the plan. + +**CSS colocation:** Each `.css` file moves beside its `.ts` counterpart (already the case in the flat directories; the directory split preserves this). Rename all CSS classes to `.ws-*` prefix (`.ws-agent-toolbar`, `.ws-prompt-input`, `.ws-editor-panel`, etc.). Update all `querySelector`/`classList` references in TypeScript. CSS imports remain side-effect imports. + +**Design token extraction:** Extract raw color, spacing, font, and size values from all 17 CSS files into three token files: `tokens/base.css` (primitives: raw hex colors, px sizes, font stacks), `tokens/semantic.css` (intent aliases: `--ws-color-bg-surface`, `--ws-spacing-panel-gap`, mapped to base tokens, theming layer), `tokens/component.css` (per-component overrides). Replace all raw values in component CSS with `var(--ws-*)` references. Zero raw `#hex` or `px` values remain in component CSS after this step. + +Verification: `npm run build` succeeds. `npm run typecheck` clean. `npm test` (`node --test`) green. Visual verification that all five menus, all panels, and all keyboard shortcuts work. No raw color/size/spacing values in component CSS (grep confirms). + + + + + +### Step 7: SPA registries, lazy loading, and god-object breakup + +- Component: SPA Restructuring + +**Panel registry and lazy loading:** Add `services/panel-registry.ts` mapping panel IDs to `() => import(...)` thunks. Enable esbuild `splitting: true` and `format: 'esm'` in the build config. Each feature directory's `index.ts` exports a `register()` function that installs its panel factory, commands, menu items, socket subscriptions, and keyboard shortcuts. `main.ts` shrinks from 230 lines of hand-wiring to registry setup + lazy thunks. Initial bundle contains only shell, services, and chrome; heavy panels (`ui/agent/`, `ui/editor/`, `ui/layout/`, `ui/take/`) load on first activation. Home Assistant's `partial-panel-resolver` is the reference pattern. + +**Pluggable menu system:** Split `window-menu.ts` (618 lines) into three files in `ui/menu/`: `command-registry.ts` (Map of command descriptors keyed by command ID), `menu-registry.ts` (Map of menu placements per MenuId, `appendMenuItem()` API), `menu-renderer.ts` (reads both registries, builds DOM, knows nothing about what's registered). Each feature directory registers its commands and menu items in its `register()` function. VS Code `MenuRegistry` + `registerAction2` pattern. + +**Part base class:** Add `base/workshop-part.ts` with `WorkshopPart` abstract class: `create(parent: HTMLElement)`, `layout(dimension: IDimension)`, `dispose()`. All panels extend `WorkshopPart`. VS Code Part/Composite hierarchy reference. + +**Service and event registries:** Add `services/service-registry.ts` with `registerService(token, factory)` for lazy instantiation. Socket handler registration per directory at activation. Shortcut registration per directory alongside its commands. Each directory's `register()` function is the single entry point for all registration. + +**Centralize module-scope state:** Move `zones.ts` module-scope Maps/Sets into `services/zone-state-service.ts` (`ZoneStateService` on the Emitter pattern). Move `workshop-panel.ts` module-scope state into `services/tree-state-service.ts` (`TreeStateService`). Both registered via the service registry. + +**Lifecycle disposal:** Each directory's `register()` returns a `Disposable`. `main.ts` collects these into the root `DisposableStore`. Each `register()` internally disposes its own socket subscriptions, event listeners, and service state. + +Verification: `npm run build` succeeds with chunk splitting (verify multiple output chunks). `npm run typecheck` clean. `npm test` green. Visual verification of all five menus, all panels, all shortcuts. Measure initial bundle size - must be smaller than pre-split. `window-menu.ts` no longer exists as a single file. + + + + + +### Step 8: SPA error handling, view decoupling, and asset hashing + +- Component: SPA Restructuring + +**Typed SPA errors:** Add `services/error-catalog.ts` with `Result` type and `ErrorCatalog` enum. Replace stringly-typed error handling across services and UI code with typed error variants. ft_transcendence pattern reference. + +**View decoupling:** Define a narrow asset-serving interface in the server (`AssetServer` trait) that the shell implements. Add a `headless` Cargo feature on `workshop-server` that replaces the webview asset layer with a no-op implementation, enabling server-only integration tests without the Tauri webview. Helix TestBackend pattern reference. + +**Content-hashed filenames:** Configure esbuild `entryNames: '[name]-[hash]'` and `chunkNames: '[name]-[hash]'`. Generate a manifest JSON mapping logical names to hashed filenames. Update the server's asset-serving to read the manifest and serve correct paths. Add long-lived cache headers (`Cache-Control: immutable`) for hashed assets. + +Verification: `npm run build` produces hashed filenames and a manifest. `cargo test --locked --workspace` green (including headless feature tests). `npm test` green. Visual verification all assets load correctly. Server correctly resolves hashed filenames from manifest. + + + + + +### Step 9: Shell cleanup and rulebook + +- Component: Cleanup + +**Shell gateway supervisor deletion:** Delete `workshop/src/gateway/supervisor.rs` (~1.4k lines) in the Tauri shell crate. Replace with the existing `shared-sidecar` crate's supervisor, which provides the same gateway lifecycle management. Update `workshop/Cargo.toml` to depend on `shared-sidecar`. Update call sites in the shell to use the shared implementation. + +**Rulebook and workspace rules:** Update `.cursor/rules/` with architecture invariants: one-way dependency graph (shell -> features -> services -> vocabulary), registry pattern (self-registration via `workshop-registry`), file ceiling (500 lines), SPA conventions (feature directories, barrel exports, lazy loading), CSS colocation (`.css` beside `.ts`), token-only values (`--ws-*` in component CSS). These rules encode the decomposition's structural decisions so future agents maintain them. + +Verification: `cargo clippy -p workshop --all-targets -- -D warnings` clean. `cargo test --locked -p workshop` green. `cargo test -p xtask` confirms full architecture. All crates under 2k lines. All files under 500 lines. Full CI green. `npm run build` + `npm test` green. Initial SPA bundle measurably smaller than pre-decomposition baseline. + + + +### Dependencies and Verification + +- Step 1 (Fix CI) must complete before all other steps. +- Step 2 (AGENTS.md + xtask) must complete before Steps 3-5 (server crate extraction). +- Step 3 (tier-0 crates) before Step 4 (services); Step 4 before Step 5 (features). Leaf-first. +- Step 6 (SPA directory split) can begin after Step 2, in parallel with server work. +- Step 7 (SPA registries) requires Step 6 (directories must exist to register into). +- Step 8 (SPA hardening) requires Step 7. +- Step 9 (cleanup) is final. +- Every Rust step verifies with: `cargo fmt --all --check`, `cargo clippy --all-targets --all-features -- -D warnings`, `cargo test --locked --workspace`. +- Every SPA step verifies with: `npm run build`, `npm run typecheck`, `npm test`, visual check. +- Stop condition: two consecutive failures on one step stops the run for a re-plan. + +### Deferred and Out of Scope + +- Phased contribution lifecycle (VS Code `WorkbenchPhase`, JupyterLab activation phases) - only once subsystem count justifies it. +- Versioned extension API (Zed's immutable WIT snapshots) - only when a real extension API ships. +- Shadow DOM / Web Components - `.ws-*` prefix sufficient; Shadow DOM duplicates CSS across roots. +- HTML template files (Vite `?raw` imports) - CSS is the primary designer touchpoint, not HTML. +- LSP integration - out of scope for this plan. + +### SPA file inventory + +One HTML file (`index.html`, 50 lines) - a bare shell. All DOM is TypeScript. + +Current `ui/` flat directory (23 TS + 13 CSS = 5,411 lines): + +| File | Lines | Target | +|---|---|---| +| `ui/window-menu.ts` | 618 | `ui/menu/` - split into 3 registries | +| `ui/window-menu.css` | 111 | `ui/menu/` | +| `ui/take-registry.ts` | 327 | `ui/take/` | +| `ui/take-registry-types.ts` | 163 | `ui/take/` | +| `ui/take-registry-state.ts` | 202 | `ui/take/` | +| `ui/take-registry-events.ts` | 414 | `ui/take/` | +| `ui/agent-session-view.ts` | 334 | `ui/agent/` | +| `ui/agent-session.css` | 258 | `ui/agent/` | +| `ui/agent-menu.ts` | 94 | `ui/agent/` | +| `ui/agent-toolbar.ts` | 32 | `ui/agent/` | +| `ui/agent-toolbar.css` | 11 | `ui/agent/` | +| `ui/prompt-input.ts` | 285 | `ui/agent/` | +| `ui/prompt-input.css` | 91 | `ui/agent/` | +| `ui/markdown-render.ts` | 196 | `ui/agent/` | +| `ui/markdown-render.css` | 122 | `ui/agent/` | +| `ui/tool-call-card.ts` | 133 | `ui/agent/` | +| `ui/tool-call-card.css` | 145 | `ui/agent/` | +| `ui/mode-chip.ts` | 122 | `ui/agent/` | +| `ui/mode-chip.css` | 37 | `ui/agent/` | +| `ui/realtime-stt.ts` | 284 | `ui/stt/` | +| `ui/stt.ts` | 90 | `ui/stt/` | +| `ui/stt.css` | 29 | `ui/stt/` | +| `ui/status-bar.ts` | 151 | `ui/status/` | +| `ui/window-chrome.ts` | 132 | `ui/chrome/` | +| `ui/window-chrome.css` | 86 | `ui/chrome/` | +| `ui/model-picker-trigger.ts` | 83 | `ui/chrome/` | +| `ui/model-picker-trigger.css` | 44 | `ui/chrome/` | +| `ui/token-ring.ts` | 79 | `ui/chrome/` | +| `ui/token-ring.css` | 23 | `ui/chrome/` | +| `ui/about-dialog.ts` | 136 | `ui/chrome/` | +| `ui/about-dialog.css` | 40 | `ui/chrome/` | +| `ui/update-view.ts` | 130 | `ui/chrome/` | +| `ui/update-view.css` | 76 | `ui/chrome/` | +| `ui/zoom.ts` | 69 | `ui/chrome/` | +| `ui/workspace-drops.ts` | 161 | `ui/workspace/` | +| `ui/gateway-config-bridge.ts` | 103 | `ui/gateway/` | + +Current `ui/workshop/` flat directory (13 TS + 4 CSS = 2,426 lines): + +| File | Lines | Target | +|---|---|---| +| `workshop/workshop-panel.ts` | 332 | `ui/layout/` | +| `workshop/panel-types.ts` | 184 | `ui/layout/` | +| `workshop/layout-persistence.ts` | 136 | `ui/layout/` | +| `workshop/zones.ts` | 219 | `ui/layout/` | +| `workshop/zones.css` | 240 | `ui/layout/` | +| `workshop/shortcuts.ts` | 140 | `ui/layout/` | +| `workshop/editor-surface.ts` | 327 | `ui/editor/` | +| `workshop/editor-panel.ts` | 255 | `ui/editor/` | +| `workshop/editor-panel.css` | 107 | `ui/editor/` | +| `workshop/editor-dialog.ts` | 52 | `ui/editor/` | +| `workshop/agent-panel.ts` | 48 | `ui/agent/` | +| `workshop/typeahead-popup.ts` | 174 | `ui/agent/` | +| `workshop/typeahead-popup.css` | 45 | `ui/agent/` | +| `workshop/mention-chip.ts` | 99 | `ui/agent/` | +| `workshop/icons.ts` | 16 | `ui/shared/` | +| `workshop/gateway-config-panel.ts` | 37 | `ui/gateway/` | +| `workshop/gateway-config-panel.css` | 15 | `ui/gateway/` | + + From c7fa8969c86672e7e8cab7f22dfdc959d5482153 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Sat, 12 Sep 2026 13:05:52 -0700 Subject: [PATCH 02/19] Add architecture rules, xtask checks, and crate scaffolder This change writes the decomposition's structural rules into the repository's agent guidance and adds mechanical enforcement so they bind without reviewer vigilance. A new tooling crate checks that tiered crates depend only on lower tiers, that no source file exceeds the line ceiling, and that every participating crate inherits the workspace lints, with the checks running as ordinary tests. It also adds a generator that scaffolds new crates with the required manifest, invariant docs, and integration-test binary so each future crate starts conformant. - `AGENTS.md` records the one-way tier dependency rule, the per-crate invariant doc requirement, and the 500-line file ceiling, plus the CSS colocation and token rules for the SPA side. - `crates/xtask/src/tidy.rs` keeps the tier policy in a pure table over four tier lists; crates not yet extracted have no manifest and are skipped, so the check passes today and binds each crate as it lands. - `crates/xtask/src/new_crate.rs` emits a generated library doc carrying the invariant marker, which is what opts the new crate into the ceiling and lint checks. - `crates/xtask/src/main.rs` prints violations and exits nonzero on the tidy subcommand, scaffolds on the new-crate subcommand, and prints usage with exit code 2 otherwise. - `validate_name` confines scaffolding to the kebab-case workshop namespace, with tests covering overwrite refusal and rejected names. - `crates/xtask/Cargo.toml` declares only anyhow and toml, so the tooling tier depends on none of the crates it checks. Design: new pure-function @ crates/xtask/src/new_crate.rs::validate_name deps: &str Design: new pure-function @ crates/xtask/src/tidy.rs::allowed_dependencies deps: &str Plan: vibe/2026-09-12-3-workshop-server-decomposition.md --- .cargo/config.toml | 1 + AGENTS.md | 11 + Cargo.lock | 9 + crates/xtask/Cargo.toml | 17 + crates/xtask/src/main.rs | 55 ++++ crates/xtask/src/new_crate.rs | 150 +++++++++ crates/xtask/src/tidy.rs | 300 ++++++++++++++++++ ...6-09-12-3-workshop-server-decomposition.md | 2 +- 8 files changed, 544 insertions(+), 1 deletion(-) create mode 100644 crates/xtask/Cargo.toml create mode 100644 crates/xtask/src/main.rs create mode 100644 crates/xtask/src/new_crate.rs create mode 100644 crates/xtask/src/tidy.rs diff --git a/.cargo/config.toml b/.cargo/config.toml index db269852a..c9c364637 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -5,3 +5,4 @@ rustflags = ["-C", "target-feature=+crt-static"] [alias] workshop = "run -p build-workshop --" +xtask = "run -p xtask --" diff --git a/AGENTS.md b/AGENTS.md index 54b235da6..9773f4ae6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,3 +37,14 @@ Multi-crate Rust workspace for the PromptForge pipeline runtime, inference gatew - Long-running work reports through `shared-progress`. Producers report operation state, hosts forward it, and renderers format it. - Unsafe code stays in its explicitly owned boundary. Every unsafe block documents its safety invariants immediately before the block. - Comments explain a non-obvious constraint, ordering requirement, or workaround. Every platform or external-bug workaround cites its upstream issue URL in the explanatory comment. + +## Structural Rules + +- Dependencies flow one way: shell -> features -> services -> vocabulary. Never add a dependency from a lower tier to a higher one. If Cargo rejects a cycle, the design is wrong, not the graph. On the SPA side, lazy-loaded panels never import the boot shell; shared code lives in services/ or base/. +- Every workshop-* crate's lib.rs opens with a //! doc listing what the crate may depend on and what it may not. Read it before adding an import. Every SPA concern directory (ui/editor/, ui/agent/, etc.) has the same in its index.ts. +- No file exceeds 500 lines. If an edit would push a file past 500, split first, then edit. + +## SPA and CSS Rules + +- CSS lives beside its TypeScript, never in a separate styles/ tree. A designer finds the styles for the agent chat at ui/agent/agent-session.css, not by grepping a flat directory. Every feature directory is self-contained: .ts, .css, and index.ts together. +- No raw color, size, or spacing values in component CSS. Use --ws-* tokens from tokens/. Primitives go in tokens/base.css, intent aliases in tokens/semantic.css, per-component overrides in tokens/component.css. A designer themes the app by editing semantic.css. diff --git a/Cargo.lock b/Cargo.lock index 49fa1c8dd..162d0a632 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8839,6 +8839,15 @@ dependencies = [ "markup5ever 0.38.0", ] +[[package]] +name = "xtask" +version = "0.0.0" +dependencies = [ + "anyhow", + "tempfile", + "toml 0.8.2", +] + [[package]] name = "yoke" version = "0.8.3" diff --git a/crates/xtask/Cargo.toml b/crates/xtask/Cargo.toml new file mode 100644 index 000000000..cfd56353e --- /dev/null +++ b/crates/xtask/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "xtask" +version = "0.0.0" +publish = false +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +anyhow.workspace = true +toml.workspace = true + +[dev-dependencies] +tempfile.workspace = true + +[lints] +workspace = true diff --git a/crates/xtask/src/main.rs b/crates/xtask/src/main.rs new file mode 100644 index 000000000..3405126e9 --- /dev/null +++ b/crates/xtask/src/main.rs @@ -0,0 +1,55 @@ +//! `xtask` - workspace automation for the PromptForge repository. +//! +//! ## Invariants +//! +//! - Tier: tooling; depends on no workspace crates. The tidy-style +//! architecture checks run as tests (`cargo test -p xtask`); +//! `cargo xtask tidy` prints the same report on demand. +//! - Every file in this crate stays under 500 lines; split first, then edit. + +mod new_crate; +mod tidy; + +use std::path::Path; +use std::process::ExitCode; + +fn main() -> ExitCode { + let args: Vec = std::env::args().collect(); + let Some(root) = Path::new(env!("CARGO_MANIFEST_DIR")).ancestors().nth(2) else { + eprintln!("error: cannot locate workspace root from CARGO_MANIFEST_DIR"); + return ExitCode::FAILURE; + }; + match args.get(1).map(String::as_str) { + Some("new-crate") => match args.get(2) { + Some(name) => match new_crate::scaffold(root, name) { + Ok(dir) => { + println!("scaffolded {}", dir.display()); + ExitCode::SUCCESS + } + Err(error) => { + eprintln!("error: {error:#}"); + ExitCode::FAILURE + } + }, + None => usage(), + }, + Some("tidy") => { + let violations = tidy::all_violations(root); + if violations.is_empty() { + println!("tidy: no violations"); + ExitCode::SUCCESS + } else { + for violation in &violations { + eprintln!("tidy: {violation}"); + } + ExitCode::FAILURE + } + } + _ => usage(), + } +} + +fn usage() -> ExitCode { + eprintln!("usage: cargo xtask new-crate | cargo xtask tidy"); + ExitCode::from(2) +} diff --git a/crates/xtask/src/new_crate.rs b/crates/xtask/src/new_crate.rs new file mode 100644 index 000000000..4349bcd6b --- /dev/null +++ b/crates/xtask/src/new_crate.rs @@ -0,0 +1,150 @@ +//! `cargo xtask new-crate` scaffolder for `workshop-*` crates. + +use std::fs; +use std::path::{Path, PathBuf}; + +/// Scaffold `crates//` with a manifest, a facade `lib.rs` carrying the +/// invariant docs, and the crate's integration-test binary. +/// +/// # Errors +/// +/// Returns an error when `name` is not a kebab-case `workshop-*` name, when +/// the crate directory already exists, or when any file cannot be written. +pub(crate) fn scaffold(root: &Path, name: &str) -> anyhow::Result { + validate_name(name)?; + let dir = root.join("crates").join(name); + anyhow::ensure!( + !dir.exists(), + "crate directory already exists: {}", + dir.display() + ); + fs::create_dir_all(dir.join("src"))?; + fs::create_dir_all(dir.join("tests").join("it"))?; + fs::write(dir.join("Cargo.toml"), manifest(name))?; + fs::write(dir.join("src").join("lib.rs"), lib_rs(name))?; + fs::write( + dir.join("tests").join("it").join("main.rs"), + test_main_rs(name), + )?; + Ok(dir) +} + +/// Kebab-case `workshop-`: the server decomposition's crate namespace. +fn validate_name(name: &str) -> anyhow::Result<()> { + let suffix = name.strip_prefix("workshop-").unwrap_or(""); + anyhow::ensure!( + !suffix.is_empty() + && !suffix.starts_with('-') + && !suffix.ends_with('-') + && !suffix.contains("--") + && suffix + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-'), + "invalid crate name `{name}`: expected kebab-case `workshop-`" + ); + Ok(()) +} + +fn manifest(name: &str) -> String { + format!( + "[package]\n\ + name = \"{name}\"\n\ + version = \"0.0.0\"\n\ + publish = false\n\ + edition.workspace = true\n\ + license.workspace = true\n\ + repository.workspace = true\n\ + \n\ + [dependencies]\n\ + \n\ + [lints]\n\ + workspace = true\n" + ) +} + +fn lib_rs(name: &str) -> String { + format!( + "//! {name} - TODO: one-line purpose.\n\ + //!\n\ + //! ## Invariants\n\ + //!\n\ + //! - Tier: TODO (vocabulary | services | features | shell); may depend\n\ + //! on: TODO. Read `AGENTS.md` before adding an import.\n\ + //! - Every file in this crate stays under 500 lines; split first, then\n\ + //! edit.\n" + ) +} + +fn test_main_rs(name: &str) -> String { + format!("//! Integration tests for `{name}`.\n") +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn scaffold_scratch() -> (tempfile::TempDir, PathBuf) { + let temp = tempfile::tempdir().expect("tempdir"); + let root = temp.path().join("repo"); + fs::create_dir_all(root.join("crates")).expect("crates dir"); + let dir = scaffold(&root, "workshop-scratch").expect("scaffold"); + (temp, dir) + } + + #[test] + fn scaffold_creates_manifest_lib_and_test_binary() { + let (_temp, dir) = scaffold_scratch(); + let manifest = fs::read_to_string(dir.join("Cargo.toml")).expect("manifest"); + assert!(manifest.contains("name = \"workshop-scratch\"")); + assert!(manifest.contains("version = \"0.0.0\"")); + assert!(manifest.contains("publish = false")); + assert!(manifest.contains("[lints]\nworkspace = true")); + let lib = fs::read_to_string(dir.join("src").join("lib.rs")).expect("lib.rs"); + assert!(lib.contains("//! ## Invariants")); + assert!(dir.join("tests").join("it").join("main.rs").is_file()); + } + + #[test] + fn scaffolded_manifest_parses_and_inherits_workspace_metadata() { + let (_temp, dir) = scaffold_scratch(); + let text = fs::read_to_string(dir.join("Cargo.toml")).expect("manifest"); + let manifest: toml::Value = toml::from_str(&text).expect("valid toml"); + let workspace_bool = |key: &str| { + manifest + .get("package") + .and_then(|p| p.get(key)) + .and_then(|v| v.get("workspace")) + .and_then(toml::Value::as_bool) + }; + assert_eq!(workspace_bool("edition"), Some(true)); + assert_eq!(workspace_bool("license"), Some(true)); + assert_eq!(workspace_bool("repository"), Some(true)); + let lints = manifest + .get("lints") + .and_then(|l| l.get("workspace")) + .and_then(toml::Value::as_bool); + assert_eq!(lints, Some(true)); + } + + #[test] + fn scaffold_refuses_to_overwrite_an_existing_crate() { + let (_temp, dir) = scaffold_scratch(); + let root = dir.parent().and_then(Path::parent).expect("workspace root"); + assert!(scaffold(root, "workshop-scratch").is_err()); + } + + #[test] + fn scaffold_rejects_names_outside_the_workshop_namespace() { + let temp = tempfile::tempdir().expect("tempdir"); + for name in [ + "scratch", + "Workshop-Scratch", + "workshop-", + "workshop-Bad", + "workshop--double", + ] { + assert!(scaffold(temp.path(), name).is_err(), "accepted {name}"); + } + } +} diff --git a/crates/xtask/src/tidy.rs b/crates/xtask/src/tidy.rs new file mode 100644 index 000000000..f9535d951 --- /dev/null +++ b/crates/xtask/src/tidy.rs @@ -0,0 +1,300 @@ +//! Tidy-style architecture checks for the workshop server decomposition. +//! +//! Each check returns a list of human-readable violations. The `#[test]` +//! wrappers assert the lists are empty, so `cargo test -p xtask` enforces +//! the architecture; `cargo xtask tidy` prints the same report on demand. + +use std::fs; +use std::path::{Path, PathBuf}; + +/// Tier 0: vocabulary crates. No internal `workshop-*` dependencies. +const VOCABULARY: &[&str] = &["workshop-protocol", "workshop-registry", "workshop-support"]; +/// Tier 1: domain services. Depend on vocabulary crates only. +const SERVICES: &[&str] = &["workshop-gateway", "workshop-menu", "workshop-status"]; +/// Tier 2: features. Depend on vocabulary and service crates. +const FEATURES: &[&str] = &["workshop-sessions", "workshop-workspace"]; +/// Tier 3: the shell. May depend on every lower tier. +const SHELL: &[&str] = &["workshop-server"]; + +/// File-line ceiling from the `AGENTS.md` structural rules. +const MAX_FILE_LINES: usize = 500; + +/// Marker in a crate's `lib.rs` (or `main.rs`) crate docs opting the crate +/// into the decomposed-architecture checks. The `new-crate` scaffolder emits +/// it; crates outside the decomposition are left alone. +const INVARIANT_MARKER: &str = "//! ## Invariants"; + +/// Run every check and return all violations. +#[must_use] +pub(crate) fn all_violations(root: &Path) -> Vec { + let mut violations = tier_dependency_violations(root); + violations.extend(file_ceiling_violations(root)); + violations.extend(lint_inheritance_violations(root)); + violations +} + +/// The internal `workshop-*` crates a tiered crate may depend on, or `None` +/// when `name` is not part of the decomposition's crate map. +fn allowed_dependencies(name: &str) -> Option> { + let allowed = if VOCABULARY.contains(&name) { + Vec::new() + } else if SERVICES.contains(&name) { + VOCABULARY.to_vec() + } else if FEATURES.contains(&name) { + [VOCABULARY, SERVICES].concat() + } else if SHELL.contains(&name) { + [VOCABULARY, SERVICES, FEATURES].concat() + } else { + return None; + }; + Some(allowed) +} + +/// Check that tiered `workshop-*` crates depend only on lower tiers. +/// +/// Crates not yet extracted from `workshop-server` have no manifest and are +/// skipped, so the check passes today and binds each crate as it lands. +#[must_use] +pub(crate) fn tier_dependency_violations(root: &Path) -> Vec { + let mut violations = Vec::new(); + for name in [VOCABULARY, SERVICES, FEATURES, SHELL].concat() { + let Some(allowed) = allowed_dependencies(name) else { + continue; + }; + let manifest_path = root.join("crates").join(name).join("Cargo.toml"); + let Ok(text) = fs::read_to_string(&manifest_path) else { + continue; + }; + let manifest: toml::Value = match toml::from_str(&text) { + Ok(manifest) => manifest, + Err(error) => { + violations.push(format!( + "{}: unparseable manifest: {error}", + manifest_path.display() + )); + continue; + } + }; + for dep in workshop_dependencies(&manifest) { + if dep == name { + continue; // self dev-dependency for test fixtures + } + if !allowed.contains(&dep.as_str()) { + violations.push(format!( + "{name} depends on {dep}, which its tier forbids (allowed: {})", + allowed.join(", ") + )); + } + } + } + violations +} + +/// Collect the `workshop-*` dependency names of every kind (normal, dev, +/// build, and target-specific) declared in a manifest. +fn workshop_dependencies(manifest: &toml::Value) -> Vec { + let mut names = Vec::new(); + for kind in ["dependencies", "dev-dependencies", "build-dependencies"] { + if let Some(table) = manifest.get(kind).and_then(toml::Value::as_table) { + collect_workshop_deps(table, &mut names); + } + } + if let Some(targets) = manifest.get("target").and_then(toml::Value::as_table) { + for target in targets.values() { + for kind in ["dependencies", "dev-dependencies", "build-dependencies"] { + if let Some(table) = target.get(kind).and_then(toml::Value::as_table) { + collect_workshop_deps(table, &mut names); + } + } + } + } + names +} + +fn collect_workshop_deps(table: &toml::map::Map, names: &mut Vec) { + for (key, value) in table { + let package = value + .get("package") + .and_then(toml::Value::as_str) + .unwrap_or(key); + if package.starts_with("workshop-") && !names.contains(&package.to_owned()) { + names.push(package.to_owned()); + } + } +} + +/// Check the 500-line file ceiling on every crate participating in the +/// decomposed architecture (its `lib.rs` or `main.rs` carries the invariant +/// marker). +#[must_use] +pub(crate) fn file_ceiling_violations(root: &Path) -> Vec { + let mut violations = Vec::new(); + for dir in participating_crates(root) { + for file in rust_files(&dir) { + let Ok(text) = fs::read_to_string(&file) else { + continue; + }; + let lines = text.lines().count(); + if lines > MAX_FILE_LINES { + violations.push(format!( + "{} has {lines} lines, over the {MAX_FILE_LINES}-line ceiling", + file.display() + )); + } + } + } + violations +} + +/// Check that every participating crate inherits `[lints] workspace = true` +/// (which carries `unreachable_pub`) and that the workspace root sets it. +#[must_use] +pub(crate) fn lint_inheritance_violations(root: &Path) -> Vec { + let mut violations = Vec::new(); + let root_manifest = root.join("Cargo.toml"); + match fs::read_to_string(&root_manifest) + .ok() + .and_then(|text| toml::from_str::(&text).ok()) + { + Some(manifest) => { + let set = manifest + .get("workspace") + .and_then(|w| w.get("lints")) + .and_then(|l| l.get("rust")) + .and_then(|r| r.get("unreachable_pub")); + if set.is_none() { + violations.push( + "workspace root does not set `unreachable_pub` in [workspace.lints.rust]" + .to_owned(), + ); + } + } + None => violations.push(format!("{}: unparseable manifest", root_manifest.display())), + } + for dir in participating_crates(root) { + let manifest_path = dir.join("Cargo.toml"); + let inherits = fs::read_to_string(&manifest_path) + .ok() + .and_then(|text| toml::from_str::(&text).ok()) + .and_then(|manifest| { + manifest + .get("lints") + .and_then(|l| l.get("workspace")) + .and_then(toml::Value::as_bool) + }) + .unwrap_or(false); + if !inherits { + violations.push(format!( + "{} does not inherit `[lints] workspace = true`", + manifest_path.display() + )); + } + } + violations +} + +/// Crates under `crates/` whose crate docs carry the invariant marker. +fn participating_crates(root: &Path) -> Vec { + let mut crates = Vec::new(); + let Ok(entries) = fs::read_dir(root.join("crates")) else { + return crates; + }; + for entry in entries.flatten() { + let dir = entry.path(); + if !dir.is_dir() { + continue; + } + let marked = ["src/lib.rs", "src/main.rs"].iter().any(|candidate| { + fs::read_to_string(dir.join(candidate)) + .is_ok_and(|text| text.contains(INVARIANT_MARKER)) + }); + if marked { + crates.push(dir); + } + } + crates +} + +/// Every `.rs` file under `dir`, recursively. +fn rust_files(dir: &Path) -> Vec { + let mut files = Vec::new(); + collect_rust_files(dir, &mut files); + files +} + +fn collect_rust_files(dir: &Path, files: &mut Vec) { + let Ok(entries) = fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + if entry.file_name() != "target" { + collect_rust_files(&path, files); + } + } else if path.extension().is_some_and(|ext| ext == "rs") { + files.push(path); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn workspace_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .ancestors() + .nth(2) + .expect("xtask lives at /crates/xtask") + .to_path_buf() + } + + #[test] + fn workshop_tier_dependencies_flow_one_way() { + let violations = tier_dependency_violations(&workspace_root()); + assert!( + violations.is_empty(), + "tier violations:\n{}", + violations.join("\n") + ); + } + + #[test] + fn participating_crates_respect_the_file_line_ceiling() { + let violations = file_ceiling_violations(&workspace_root()); + assert!( + violations.is_empty(), + "ceiling violations:\n{}", + violations.join("\n") + ); + } + + #[test] + fn participating_crates_inherit_workspace_lints() { + let violations = lint_inheritance_violations(&workspace_root()); + assert!( + violations.is_empty(), + "lint violations:\n{}", + violations.join("\n") + ); + } + + #[test] + fn tier_table_grants_each_tier_only_lower_tiers() { + assert_eq!(allowed_dependencies("workshop-protocol"), Some(Vec::new())); + assert_eq!( + allowed_dependencies("workshop-gateway"), + Some(VOCABULARY.to_vec()) + ); + assert_eq!( + allowed_dependencies("workshop-sessions"), + Some([VOCABULARY, SERVICES].concat()) + ); + assert_eq!( + allowed_dependencies("workshop-server"), + Some([VOCABULARY, SERVICES, FEATURES].concat()) + ); + assert_eq!(allowed_dependencies("gateway"), None); + } +} diff --git a/vibe/2026-09-12-3-workshop-server-decomposition.md b/vibe/2026-09-12-3-workshop-server-decomposition.md index 2c3fe8ce5..724e2f019 100644 --- a/vibe/2026-09-12-3-workshop-server-decomposition.md +++ b/vibe/2026-09-12-3-workshop-server-decomposition.md @@ -377,7 +377,7 @@ Verification: `cargo nextest run --locked -p promptforge-core scheduler` passes -### Step 2: AGENTS.md, xtask, and new-crate generator +### Step 2: AGENTS.md, xtask, and new-crate generator [completed] - Component: Foundation From 66f40533b92b46fa50a6485c2b20e7345fe74744 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Sat, 12 Sep 2026 13:29:44 -0700 Subject: [PATCH 03/19] Extract workshop protocol, support, and registry crates Splits the workshop server's bottom layer into three vocabulary crates: the socket wire protocol, the support utilities, and a new subsystem registry. Every frame type moves with its wire shape unchanged, still pinned against the shared JSON fixture and the TypeScript contract, so nothing on the wire changes. A generic retained broadcast bus replaces the three hand-rolled copies behind the status, catalog, and menu buses. The registry introduces sealed traits and proxy slots so subsystems self-register, and the status bus migrates as the proof of concept. - `crates/workshop-protocol/src/lib.rs` now owns the whole socket wire contract with zero I/O; the frame types, their per-shape pin tests, and the shared agent-frame fixture move together, and the TypeScript half cross-cites the new home. - `crates/workshop-registry/src/traits.rs` defines five sealed subsystem traits; only the status channel ships its closure-backed adapter, and the crate docs state the remaining adapters arrive with their subsystem migrations. - `crates/workshop-support/src/bus.rs` introduces the generic retained bus; the status, catalog, and menu buses become thin wrappers that keep their own ring capacities and intent-named helpers. - `crates/workshop-server/src/app.rs::AppState` gains the registry plus the guard that keeps the status bus's self-registration alive for the state's lifetime. - `crates/workshop-server/src/session.rs::run_session` reads the status channel through the registry slot; an empty slot pends the receive branch forever, so a session runs without status frames rather than failing. - `crates/workshop-server/src/error.rs::AppError` serializes its HTTP error body from the pinned envelope type instead of an ad-hoc JSON literal, with a status-text fallback that cannot trigger for two strings. - `crates/workshop-server/ui/test/agent-wire-fixtures.mjs` reads the shared fixture from its new location, keeping the two-sided drift pin intact. - `crates/workshop-server/src/protocol.rs` is deleted; every type and test it held reappears under the protocol crate, so nothing it contained is dropped. - `crates/workshop-registry/src/registry.rs::Registry` exposes route, state, task, and shutdown slots that no subsystem registers into yet. Design: replaces value-object @ crates/workshop-protocol/src/agent.rs::AgentsFrame boundary: wire was: crates/workshop-server/src/protocol.rs::AgentsFrame Design: replaces value-object @ crates/workshop-protocol/src/agent.rs::AgentSessionFrame boundary: wire was: crates/workshop-server/src/protocol.rs::AgentSessionFrame Design: replaces value-object @ crates/workshop-protocol/src/agent.rs::AgentEventFrame boundary: wire was: crates/workshop-server/src/protocol.rs::AgentEventFrame Design: replaces value-object @ crates/workshop-protocol/src/agent.rs::AgentDeltaKind boundary: wire was: crates/workshop-server/src/protocol.rs::AgentDeltaKind Design: replaces value-object @ crates/workshop-protocol/src/agent.rs::AgentDeltaFrame boundary: wire was: crates/workshop-server/src/protocol.rs::AgentDeltaFrame Design: replaces value-object @ crates/workshop-protocol/src/input.rs::InputFrame boundary: wire was: crates/workshop-server/src/protocol.rs::InputFrame Design: replaces value-object @ crates/workshop-protocol/src/status.rs::Severity boundary: wire was: crates/workshop-server/src/protocol.rs::Severity Design: replaces value-object @ crates/workshop-protocol/src/status.rs::Activity boundary: wire was: crates/workshop-server/src/protocol.rs::Activity Design: new value-object @ crates/workshop-protocol/src/error.rs::ErrorEnvelope boundary: wire Design: new speculative-abstraction @ crates/workshop-registry/src/traits.rs::RouteRegistrar Design: new speculative-abstraction @ crates/workshop-registry/src/traits.rs::StateProvider Design: new speculative-abstraction @ crates/workshop-registry/src/traits.rs::BackgroundTasks Design: new speculative-abstraction @ crates/workshop-registry/src/traits.rs::StatusChannel Design: new speculative-abstraction @ crates/workshop-registry/src/traits.rs::ShutdownHook Design: new shared-mutable-state @ crates/workshop-registry/src/slot.rs::ProxySlot Design: extends ambient-context @ crates/workshop-server/src/app.rs::AppState Design: new service-locator @ crates/workshop-server/src/session.rs::run_session deps: AppState,WebSocket Design: removes clone-block @ crates/workshop-server/src/status.rs::StatusBus Design: removes clone-block @ crates/workshop-server/src/catalog.rs::CatalogBus Design: removes clone-block @ crates/workshop-server/src/menu.rs::MenuBus Design: extends facade @ crates/workshop-server/src/lib.rs Plan: vibe/2026-09-12-3-workshop-server-decomposition.md --- Cargo.lock | 35 + Cargo.toml | 3 + crates/workshop-protocol/Cargo.toml | 17 + crates/workshop-protocol/src/agent.rs | 144 +++ crates/workshop-protocol/src/catalog.rs | 33 + crates/workshop-protocol/src/error.rs | 70 ++ crates/workshop-protocol/src/input.rs | 48 + crates/workshop-protocol/src/lib.rs | 142 +++ crates/workshop-protocol/src/status.rs | 81 ++ crates/workshop-protocol/src/workbench.rs | 56 ++ .../tests/fixtures/agent-frames.json | 0 crates/workshop-protocol/tests/it/fixture.rs | 199 ++++ crates/workshop-protocol/tests/it/frames.rs | 268 +++++ crates/workshop-protocol/tests/it/main.rs | 4 + crates/workshop-registry/Cargo.toml | 20 + crates/workshop-registry/src/lib.rs | 32 + crates/workshop-registry/src/registry.rs | 108 ++ crates/workshop-registry/src/slot.rs | 96 ++ crates/workshop-registry/src/traits.rs | 115 +++ crates/workshop-registry/tests/it/main.rs | 135 +++ crates/workshop-server/Cargo.toml | 5 +- crates/workshop-server/src/app.rs | 55 +- crates/workshop-server/src/catalog.rs | 40 +- crates/workshop-server/src/error.rs | 25 +- crates/workshop-server/src/fixtures.rs | 4 +- crates/workshop-server/src/gateway.rs | 3 +- crates/workshop-server/src/heartbeat.rs | 7 +- crates/workshop-server/src/input.rs | 2 +- crates/workshop-server/src/lib.rs | 13 +- crates/workshop-server/src/menu.rs | 29 +- crates/workshop-server/src/progress.rs | 4 +- crates/workshop-server/src/protocol.rs | 945 ------------------ crates/workshop-server/src/push.rs | 13 +- crates/workshop-server/src/relay.rs | 2 +- crates/workshop-server/src/resolve.rs | 5 +- crates/workshop-server/src/routes/chat.rs | 2 +- .../src/routes/gateway_config.rs | 2 +- crates/workshop-server/src/serve.rs | 5 +- crates/workshop-server/src/session.rs | 32 +- crates/workshop-server/src/session/menu.rs | 2 +- crates/workshop-server/src/session_agents.rs | 7 +- .../src/session_agents/socket.rs | 4 +- .../src/session_agents/supervisor/effects.rs | 2 +- crates/workshop-server/src/status.rs | 32 +- crates/workshop-server/src/workspace.rs | 2 +- .../ui/src/services/protocol.ts | 6 +- .../ui/test/agent-wire-fixtures.mjs | 10 +- crates/workshop-support/Cargo.toml | 31 + .../src/atomic.rs | 4 +- .../src/backoff.rs | 13 +- crates/workshop-support/src/bus.rs | 153 +++ .../src/config.rs | 254 +---- .../src/deadline.rs | 22 +- crates/workshop-support/src/lib.rs | 29 + crates/workshop-support/tests/it/config.rs | 245 +++++ crates/workshop-support/tests/it/main.rs | 3 + crates/xtask/src/tidy.rs | 10 +- ...6-09-12-3-workshop-server-decomposition.md | 2 +- 58 files changed, 2270 insertions(+), 1360 deletions(-) create mode 100644 crates/workshop-protocol/Cargo.toml create mode 100644 crates/workshop-protocol/src/agent.rs create mode 100644 crates/workshop-protocol/src/catalog.rs create mode 100644 crates/workshop-protocol/src/error.rs create mode 100644 crates/workshop-protocol/src/input.rs create mode 100644 crates/workshop-protocol/src/lib.rs create mode 100644 crates/workshop-protocol/src/status.rs create mode 100644 crates/workshop-protocol/src/workbench.rs rename crates/{workshop-server => workshop-protocol}/tests/fixtures/agent-frames.json (100%) create mode 100644 crates/workshop-protocol/tests/it/fixture.rs create mode 100644 crates/workshop-protocol/tests/it/frames.rs create mode 100644 crates/workshop-protocol/tests/it/main.rs create mode 100644 crates/workshop-registry/Cargo.toml create mode 100644 crates/workshop-registry/src/lib.rs create mode 100644 crates/workshop-registry/src/registry.rs create mode 100644 crates/workshop-registry/src/slot.rs create mode 100644 crates/workshop-registry/src/traits.rs create mode 100644 crates/workshop-registry/tests/it/main.rs delete mode 100644 crates/workshop-server/src/protocol.rs create mode 100644 crates/workshop-support/Cargo.toml rename crates/{workshop-server => workshop-support}/src/atomic.rs (98%) rename crates/{workshop-server => workshop-support}/src/backoff.rs (97%) create mode 100644 crates/workshop-support/src/bus.rs rename crates/{workshop-server => workshop-support}/src/config.rs (56%) rename crates/{workshop-server => workshop-support}/src/deadline.rs (85%) create mode 100644 crates/workshop-support/src/lib.rs create mode 100644 crates/workshop-support/tests/it/config.rs create mode 100644 crates/workshop-support/tests/it/main.rs diff --git a/Cargo.lock b/Cargo.lock index 162d0a632..30bf7804b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8553,6 +8553,24 @@ dependencies = [ "workshop-server", ] +[[package]] +name = "workshop-protocol" +version = "0.0.0" +dependencies = [ + "promptforge-core-support", + "serde", + "serde_json", +] + +[[package]] +name = "workshop-registry" +version = "0.0.0" +dependencies = [ + "axum", + "tokio", + "workshop-protocol", +] + [[package]] name = "workshop-server" version = "0.3.0" @@ -8592,7 +8610,24 @@ dependencies = [ "tracing", "tracing-subscriber", "url", + "workshop-protocol", + "workshop-registry", "workshop-server", + "workshop-support", +] + +[[package]] +name = "workshop-support" +version = "0.0.0" +dependencies = [ + "axum", + "serde", + "tempfile", + "thiserror 2.0.19", + "tokio", + "toml 0.8.2", + "tower", + "tracing", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 593c656c5..345eeee4a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,6 +47,9 @@ gateway-stt-backend-whisper = { path = "crates/gateway-stt-backend-whisper", ver promptforge-web-search = { path = "crates/promptforge-web-search", version = "0.3.0" } gateway-web-search = { path = "crates/gateway-web-search", version = "0.3.0" } workshop-server = { path = "crates/workshop-server", version = "0.3.0" } +workshop-protocol = { path = "crates/workshop-protocol", version = "0.0.0" } +workshop-support = { path = "crates/workshop-support", version = "0.0.0" } +workshop-registry = { path = "crates/workshop-registry", version = "0.0.0" } gateway-whisper-ffi = { path = "crates/gateway-whisper-ffi", version = "0.3.0" } promptforge-agent = { path = "crates/promptforge-agent", version = "0.3.0" } pulldown-cmark = "0.12" diff --git a/crates/workshop-protocol/Cargo.toml b/crates/workshop-protocol/Cargo.toml new file mode 100644 index 000000000..16e17c551 --- /dev/null +++ b/crates/workshop-protocol/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "workshop-protocol" +version = "0.0.0" +publish = false +edition.workspace = true +license.workspace = true +repository.workspace = true + +description = "Workshop wire protocol: every JSON frame exchanged over the workshop sockets, typed in one place, with zero I/O" + +[dependencies] +promptforge-core-support.workspace = true +serde.workspace = true +serde_json.workspace = true + +[lints] +workspace = true diff --git a/crates/workshop-protocol/src/agent.rs b/crates/workshop-protocol/src/agent.rs new file mode 100644 index 000000000..15b1f9b15 --- /dev/null +++ b/crates/workshop-protocol/src/agent.rs @@ -0,0 +1,144 @@ +//! Agent-session frames: the `/agents/ws` socket's frame family. + +use serde::Serialize; + +/// The agent list pushed when an `/agents/ws` socket connects: +/// `{"type":"agents","agents":["chat","research"]}`. +/// +/// Delivery: ephemeral - every push is the complete discovered list, +/// resent on every connect; there is no incremental form to lose. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct AgentsFrame { + #[serde(rename = "type")] + kind: &'static str, + /// The launchable agent names, in discovery order. + agents: Vec, +} + +impl AgentsFrame { + /// Builds the list frame over the discovered agent names. + #[must_use] + pub fn new(agents: Vec) -> Self { + Self { + kind: "agents", + agents, + } + } +} + +/// The direct reply to a `launch` or `attach` frame: +/// `{"type":"agent_session","session":"...","agent":"..."}`. The client +/// keeps the session id to reattach after a disconnect - sessions +/// outlive sockets. +/// +/// Delivery: durable - a direct per-request reply sent by the loop that +/// owns the socket, the contract's no-cursor case. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct AgentSessionFrame { + #[serde(rename = "type")] + kind: &'static str, + /// The session's unguessable id. + session: String, + /// The launched agent's name. + agent: String, +} + +impl AgentSessionFrame { + /// Builds the acknowledgment for `session` running `agent`. + #[must_use] + pub fn new(session: String, agent: String) -> Self { + Self { + kind: "agent_session", + session, + agent, + } + } +} + +/// One durable entry of an agent session's event log: +/// `{"type":"agent_event","index":N,"event":{...}}` plus, on the +/// model-round content kinds (`agent_thought`, `agent_message`, +/// `tool_call`), the `reply` id that coalesces the round's ephemeral +/// deltas away (see [`AgentDeltaFrame`]). +/// +/// Delivery: durable - `index` is the entry's position in the session's +/// event log, the per-client cursor recovers everything past it on +/// reconnect, and a future `replayFrom` cursor rides the same field. +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct AgentEventFrame { + #[serde(rename = "type")] + kind: &'static str, + /// The entry's log index. + index: u64, + /// The reply id this event settles, present on the model-round + /// content kinds and omitted elsewhere. + #[serde(skip_serializing_if = "Option::is_none")] + reply: Option, + /// The logged entry, in its persisted vocabulary shape. + event: promptforge_core_support::events::RuntimeEvent, +} + +impl AgentEventFrame { + /// Builds the frame for the entry at `index`. + #[must_use] + pub fn new( + index: u64, + reply: Option, + event: promptforge_core_support::events::RuntimeEvent, + ) -> Self { + Self { + kind: "agent_event", + index, + reply, + event, + } + } +} + +/// Which streaming side channel one agent delta belongs to. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum AgentDeltaKind { + /// Answer content, superseded by the round's `agent_message` event. + Text, + /// Reasoning content, superseded by the round's `agent_thought` + /// event. + Reasoning, +} + +/// One live streaming chunk of an agent's model round: +/// `{"type":"agent_delta","kind":"text","content":"...","reply":N}`. +/// +/// Every delta is stamped with the `reply` id of the durable event that +/// will supersede it, so the SPA coalesces chunks by that id and replaces +/// them when the event arrives (the ACP messageId chunk-vs-upsert rule). +/// +/// Delivery: ephemeral - deltas ride a bounded broadcast and may drop +/// under lag; the completed-reply event is the repair path, which is why +/// agent deltas never enter the event log. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct AgentDeltaFrame { + #[serde(rename = "type")] + kind: &'static str, + /// Which side channel the chunk belongs to. + #[serde(rename = "kind")] + channel: AgentDeltaKind, + /// The chunk's text. + content: String, + /// The id of the durable event that will supersede this delta. + reply: u64, +} + +impl AgentDeltaFrame { + /// Builds a delta frame carrying `content` on `channel`, stamped with + /// the superseding `reply` id. + #[must_use] + pub fn new(channel: AgentDeltaKind, content: String, reply: u64) -> Self { + Self { + kind: "agent_delta", + channel, + content, + reply, + } + } +} diff --git a/crates/workshop-protocol/src/catalog.rs b/crates/workshop-protocol/src/catalog.rs new file mode 100644 index 000000000..03991e458 --- /dev/null +++ b/crates/workshop-protocol/src/catalog.rs @@ -0,0 +1,33 @@ +//! Catalog frames: the `{"type":"models",...}` pushes. + +use serde::Serialize; + +/// One pushed model catalog. +#[derive(Debug, Clone, PartialEq)] +pub struct CatalogPush { + /// The chat-capable subset of the gateway's model array. + pub models: Vec, +} + +impl CatalogPush { + /// The push as a wire frame: `"type": "models"` beside the array. + #[must_use] + pub fn frame(&self) -> CatalogFrame<'_> { + CatalogFrame { + kind: "models", + models: &self.models, + } + } +} + +/// The serialized shape of a catalog push on the socket, matching the +/// workshop protocol's frame taxonomy. +/// +/// Delivery: ephemeral - the newest push carries the whole catalog and +/// supersedes every older one; the catalog is resent on reconnect. +#[derive(Debug, Serialize)] +pub struct CatalogFrame<'a> { + #[serde(rename = "type")] + kind: &'static str, + models: &'a [serde_json::Value], +} diff --git a/crates/workshop-protocol/src/error.rs b/crates/workshop-protocol/src/error.rs new file mode 100644 index 000000000..3f4681697 --- /dev/null +++ b/crates/workshop-protocol/src/error.rs @@ -0,0 +1,70 @@ +//! Error shapes on the wire: the socket `error` frame and the HTTP error +//! envelope. + +use serde::Serialize; + +/// A failure report answered to one inbound frame - a malformed frame, a +/// refused menu event, or an agent-session failure: +/// `{"type":"error","message":"..."}` plus the echoed request `id`. +/// +/// Delivery: durable on the workshop socket - a direct per-request reply +/// sent by the loop that owns the socket. The agent-session socket +/// additionally pushes id-less error frames for session-level failures; +/// that delivery is ephemeral and documented in the agent-session +/// section of the crate docs. +#[derive(Debug, Serialize)] +pub struct ErrorFrame { + #[serde(rename = "type")] + kind: &'static str, + message: String, + /// The request's `id`, echoed verbatim when it carried one and omitted + /// from the wire when it did not. + #[serde(skip_serializing_if = "Option::is_none")] + id: Option, +} + +impl ErrorFrame { + /// Builds an error frame carrying `message`, echoing `id` when present. + #[must_use] + pub fn new(message: String, id: Option<&serde_json::Value>) -> Self { + Self { + kind: "error", + message, + id: id.cloned(), + } + } +} + +/// The opaque wire error envelope every HTTP failure answers with: +/// `{"error":{"message":"...","code":"..."}}`. +/// +/// The shell maps its per-crate error types onto status codes and renders +/// this envelope; the shape is pinned here so the wire contract lives in +/// one place. Failures rendered as plain text (the asset 404) never take +/// this shape. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ErrorEnvelope { + error: EnvelopeBody, +} + +/// The envelope payload: the user-visible message and the +/// machine-readable code. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct EnvelopeBody { + message: String, + code: String, +} + +impl ErrorEnvelope { + /// Builds the envelope carrying `message` under the machine-readable + /// `code`. + #[must_use] + pub fn new(message: impl Into, code: impl Into) -> Self { + Self { + error: EnvelopeBody { + message: message.into(), + code: code.into(), + }, + } + } +} diff --git a/crates/workshop-protocol/src/input.rs b/crates/workshop-protocol/src/input.rs new file mode 100644 index 000000000..9b004247a --- /dev/null +++ b/crates/workshop-protocol/src/input.rs @@ -0,0 +1,48 @@ +//! Agent-session input frames: the operator-input conversation. + +use serde::{Deserialize, Serialize}; + +/// A pushed user-input lifecycle frame on an agent session's socket. +/// +/// `{"type":"input_required","token":"..."}` announces an open wait: the +/// SPA pins its input box to the token and answers with an +/// `input_response` frame. `{"type":"input_cancelled","token":"..."}` +/// announces a wait that died unresolved, so the SPA never holds a +/// prompt against a dead token - cancellation is an outcome on the wire, +/// never silence. +/// +/// Delivery: durable - the server's wait registry retains every +/// unresolved wait and the session resends it on reconnect, so a push +/// lost to a dead socket is repaired by the resent set: a live wait +/// reappears, and a cancelled one vanishes by its absence. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(tag = "type")] +pub enum InputFrame { + /// A wait opened: the session wants operator input for `token`. + #[serde(rename = "input_required")] + Required { + /// The single-use wait token an `input_response` must echo. + token: String, + }, + /// A wait died unresolved: the prompt for `token` is stale. + #[serde(rename = "input_cancelled")] + Cancelled { + /// The token whose wait is gone. + token: String, + }, +} + +/// The inbound answer to an [`InputFrame::Required`] prompt: +/// `{"type":"input_response","token":"...","text":"..."}`. +/// +/// The session routes on the envelope's `type` and deserializes the body +/// with serde, which ignores the envelope tag itself. `text` is the +/// operator's input, byte-exact as typed. Like every inbound frame it +/// takes no delivery classification, because the server pushes none. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct InputResponse { + /// The wait token this response answers. + pub token: String, + /// The operator's text, byte-exact as typed. + pub text: String, +} diff --git a/crates/workshop-protocol/src/lib.rs b/crates/workshop-protocol/src/lib.rs new file mode 100644 index 000000000..223b98969 --- /dev/null +++ b/crates/workshop-protocol/src/lib.rs @@ -0,0 +1,142 @@ +//! workshop-protocol - the wire protocol of the workshop sockets: every +//! JSON frame the server exchanges with the UI, typed in one place, with +//! zero I/O. +//! +//! Frames are grouped by direction - inbound (client to server) first, +//! outbound (server to client) second. Nothing here touches a socket, a +//! task, or a clock, so every wire shape is pinned by the plain tests in +//! `tests/it`. The TypeScript half of this contract is +//! `workshop-server/ui/src/services/protocol.ts`; the two files +//! cross-cite each other so a shape change touches both or neither. The +//! agent-session frame family is additionally pinned by the shared +//! fixture `tests/fixtures/agent-frames.json`, asserted as the same JSON +//! by the fixture test here and by the SPA suite's +//! `workshop-server/ui/test/agent-wire-fixtures.mjs`, so drift on either +//! side fails that side's tests. The wire shapes are additionally frozen +//! end to end by the characterization tests in `workshop-server`'s +//! `tests/it`. +//! +//! ## Invariants +//! +//! - Tier: vocabulary; may depend on: no internal `workshop-*` crates. +//! Read `AGENTS.md` before adding an import. +//! - Every file in this crate stays under 500 lines; split first, then +//! edit. +//! - Zero I/O: no sockets, tasks, or clocks, so every wire shape is +//! pinned by a plain test. +//! +//! # Inbound workshop-socket frames +//! +//! `{"type":"select_model","model":"..."}` selects the chat model: the +//! menu validates the id against the retained catalog and publishes a +//! fresh [`WorkbenchFrame`] on success; an unknown model is refused +//! with an `error` frame. `{"type":"switch_profile","name":"..."}` +//! starts a gateway profile switch: the pending snapshot publishes +//! immediately, stage progress arrives as [`StatusFrame`]s, and the +//! settled menu publishes a final [`WorkbenchFrame`] and a +//! [`CatalogFrame`]; a switch requested while one runs is refused with +//! an `error` frame. Both events may carry an optional `id`, echoed on +//! the `error` frame that refuses them. +//! +//! No inbound frame is pushed by the server, so none takes a delivery +//! classification; the reply frames they trigger are classified below. +//! +//! # Agent-session frames +//! +//! The `/agents/ws` socket speaks its own frame family. On connect the +//! server pushes [`AgentsFrame`], the discovered agent list. The client +//! opens a session with `{"type":"launch","agent":"..."}` or reattaches +//! with `{"type":"attach","session":"..."}`; either is answered with +//! [`AgentSessionFrame`] naming the session id. A running session streams +//! [`AgentEventFrame`]s - the durable event log, each frame carrying its +//! log index, replayed from the top on attach - and [`AgentDeltaFrame`]s, +//! the ephemeral live chunks, each stamped with the `reply` id of the +//! durable event that will supersede it. `{"type":"cancel"}` fires the +//! session's turn-cancel: cancellation is a stop reason, never an error - +//! no error frame follows, pending waits die as `input_cancelled`, and +//! the relaunched agent returns to waiting. Frames already in flight +//! from the cancelled run may still arrive between the cancel and the +//! relaunch: a defined grace window, absorbed by the reply-id +//! coalescing (the cancelled round never settles, so its deltas fall to +//! the round that eventually does), never a protocol violation. +//! +//! A session-level failure is pushed as an id-less [`ErrorFrame`]: a +//! model round that failed while the program survived it (the built-in +//! chat `pcall`s `models.chat` and returns to waiting), or a run that +//! ended in error. Delivery on this socket: ephemeral - the reports ride +//! a bounded broadcast beside the deltas and may drop under lag; the +//! durable transcript already shows the failed turn as one without a +//! reply, and terminal failures also land on the status bus. +//! +//! # Agent-session input frames +//! +//! An agent session asks its operator for input through the Workshop's +//! `user_input` tool. Three frames carry that conversation: the server +//! pushes [`InputFrame::Required`] when a wait opens and +//! [`InputFrame::Cancelled`] when one dies unresolved, and the client +//! answers with an `input_response` frame parsed as [`InputResponse`]. +//! Both pushed frames are durable: the wait registry retains every +//! unresolved wait and the session resends it on reconnect, so a push +//! lost to a dead socket is repaired by the resent set - a live wait +//! reappears, and a stale prompt is dropped because its token is absent. +//! Cancellation is an explicit outcome, never silence: every path out of +//! an unresolved wait pushes `input_cancelled` for its token. The session +//! loops that route these frames arrive with agent sessions; the shapes +//! and classification are pinned here first. +//! +//! # Delivery contract +//! +//! Every frame the server pushes carries exactly one of two delivery +//! semantics. The session loops are built on this classification, so no +//! pushed frame type ships unclassified. +//! +//! **Durable** frames are delivered exactly, and coalesce. Where the +//! data is shared fan-out state, the producer records it and wakes each +//! connection loop through a `Notify`; the loop compares the shared +//! revision against its own per-client cursor and sends everything past +//! the cursor, so a missed wakeup is harmless because the next one +//! delivers everything past the cursor. A durable frame that answers +//! the connection's own request (a `launch` acknowledgment) is sent +//! directly by the loop that owns the socket, which delivers exactly +//! without any cursor - no shared state exists for a cursor to index. +//! +//! **Ephemeral** frames may drop under lag. They ride bounded channels +//! (a broadcast where the state fans out); a client too slow to drain +//! its channel lags out and its connection may drop. The drop is +//! harmless because every ephemeral frame has a repair path that owes +//! nothing to its predecessors: status and catalog are complete +//! snapshots resent on reconnect. +//! +//! ## Classification +//! +//! Workshop socket (`/ws`): +//! +//! - [`ErrorFrame`] - durable on this socket. The direct reply refusing +//! a malformed frame or a menu event, sent by the loop that owns the +//! socket - the contract's no-cursor case. +//! - [`StatusFrame`] - ephemeral. Every update is a complete snapshot of +//! the bar, so a lagging client loses nothing by skipping +//! intermediates, and the current status is resent on reconnect. +//! - [`CatalogFrame`] - ephemeral. Each push carries the whole catalog +//! verbatim; the newest push supersedes every older one and the +//! catalog is resent on reconnect. +//! - [`WorkbenchFrame`] - ephemeral. Every push is a complete snapshot +//! of the server-owned Model-menu state, retained and resent on +//! reconnect, exactly like the catalog frame. The connect-time send - +//! the retained snapshot follows the status and catalog snapshots on +//! every new session, so the UI boots with zero HTTP state fetches - +//! is that resend promise, not a third delivery class. + +mod agent; +mod catalog; +mod error; +mod input; +mod status; +mod workbench; + +pub use agent::{AgentDeltaFrame, AgentDeltaKind, AgentEventFrame, AgentSessionFrame, AgentsFrame}; +pub use catalog::{CatalogFrame, CatalogPush}; +pub use error::{ErrorEnvelope, ErrorFrame}; +pub use input::{InputFrame, InputResponse}; +pub use status::{Activity, Progress, Severity, StatusBarUpdate, StatusFrame}; +pub use workbench::{WorkbenchFrame, WorkbenchSnapshot}; diff --git a/crates/workshop-protocol/src/status.rs b/crates/workshop-protocol/src/status.rs new file mode 100644 index 000000000..32828668f --- /dev/null +++ b/crates/workshop-protocol/src/status.rs @@ -0,0 +1,81 @@ +//! Status-bar frames: the `{"type":"status",...}` updates. + +use serde::Serialize; + +/// One status bar update: what the bar should show right now. +/// +/// Every update is a complete snapshot, so a lagging receiver loses nothing +/// by skipping intermediates. `label` is the short text rendered in the +/// status bar; `description` is the longer tooltip shown on hover. +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct StatusBarUpdate { + /// Short text rendered in the status bar. + pub label: String, + /// Longer text shown as the bar's tooltip. + pub description: String, + /// Determinate progress, when the activity can report it. + pub progress: Option, + /// How loudly the update speaks; the UI ignores `Debug` updates. + pub severity: Severity, + /// Which subsystem is active, driving the bar's activity indicator. + pub activity: Activity, +} + +impl StatusBarUpdate { + /// The update as a wire frame: its own fields plus `"type": "status"`. + #[must_use] + pub fn frame(&self) -> StatusFrame<'_> { + StatusFrame { + kind: "status", + update: self, + } + } +} + +/// A determinate progress report for the status bar's progress slot. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub struct Progress { + /// Units completed so far. + pub current: u64, + /// Units expected in total. + pub total: u64, +} + +/// How loudly a status update speaks. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum Severity { + /// User-visible status text. + Info, + /// Internal instrumentation; the UI ignores it for display. + Debug, + /// A failure the user should see. + Error, +} + +/// The subsystem an update belongs to, driving the activity indicator. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum Activity { + /// No specific subsystem; the activity LED stays dark. + General, + /// A model turn in flight: amber on the activity LED. + Thinking, + /// Output tokens arriving: green on the activity LED. + Generating, +} + +/// The serialized shape of one update on the socket: the update's fields +/// flattened beside `"type": "status"`, matching the workshop protocol's frame +/// taxonomy. +/// +/// Delivery: ephemeral - every update is a complete snapshot, so a +/// lagging client skips intermediates and the current status is resent +/// on reconnect. +#[derive(Debug, Serialize)] +pub struct StatusFrame<'a> { + #[serde(rename = "type")] + kind: &'static str, + #[serde(flatten)] + update: &'a StatusBarUpdate, +} diff --git a/crates/workshop-protocol/src/workbench.rs b/crates/workshop-protocol/src/workbench.rs new file mode 100644 index 000000000..c785d19d0 --- /dev/null +++ b/crates/workshop-protocol/src/workbench.rs @@ -0,0 +1,56 @@ +//! Workbench frames: the `{"type":"workbench",...}` menu snapshots. + +use serde::Serialize; + +/// One pushed workbench snapshot: the server-owned Model-menu state. +/// +/// The server computes `chat_ready` - a chat-capable model available, one +/// selected, no switch in flight, gateway reachable - and the UI never +/// derives it. +#[derive(Debug, Clone, PartialEq)] +pub struct WorkbenchSnapshot { + /// Every gateway profile name, in gateway order. + pub profiles: Vec, + /// The profile the gateway is serving, once known. + pub active: Option, + /// The profile a switch is loading, while one is in flight. + pub switching: Option, + /// The model chat requests go to, once one is selected. + pub selected_model: Option, + /// Whether a chat can be submitted right now. + pub chat_ready: bool, +} + +impl WorkbenchSnapshot { + /// The snapshot as a wire frame: `"type": "workbench"` beside the + /// fields, with `selected_model` shortened to `selected` on the wire. + #[must_use] + pub fn frame(&self) -> WorkbenchFrame<'_> { + WorkbenchFrame { + kind: "workbench", + profiles: &self.profiles, + active: self.active.as_deref(), + switching: self.switching.as_deref(), + selected: self.selected_model.as_deref(), + chat_ready: self.chat_ready, + } + } +} + +/// The serialized shape of a workbench push on the socket, matching the +/// workshop protocol's frame taxonomy. Absent options serialize as `null`, +/// never as omitted keys: every push is the complete menu state. +/// +/// Delivery: ephemeral - every push is a complete snapshot of the menu +/// state, retained and resent on reconnect, exactly like the catalog +/// frame. +#[derive(Debug, Serialize)] +pub struct WorkbenchFrame<'a> { + #[serde(rename = "type")] + kind: &'static str, + profiles: &'a [String], + active: Option<&'a str>, + switching: Option<&'a str>, + selected: Option<&'a str>, + chat_ready: bool, +} diff --git a/crates/workshop-server/tests/fixtures/agent-frames.json b/crates/workshop-protocol/tests/fixtures/agent-frames.json similarity index 100% rename from crates/workshop-server/tests/fixtures/agent-frames.json rename to crates/workshop-protocol/tests/fixtures/agent-frames.json diff --git a/crates/workshop-protocol/tests/it/fixture.rs b/crates/workshop-protocol/tests/it/fixture.rs new file mode 100644 index 000000000..f6e95ed25 --- /dev/null +++ b/crates/workshop-protocol/tests/it/fixture.rs @@ -0,0 +1,199 @@ +//! The shared agent-frame fixture pins: the same JSON the SPA suite +//! (`workshop-server/ui/test/agent-wire-fixtures.mjs`) asserts, so a wire +//! drift on either side fails that side's fixture test. + +use workshop_protocol::{ + AgentDeltaFrame, AgentDeltaKind, AgentEventFrame, AgentSessionFrame, AgentsFrame, InputFrame, + InputResponse, +}; + +/// The shared agent-frame fixture, asserted as the same JSON by the SPA +/// suite: a wire drift on either side fails that side's fixture test. +const AGENT_FRAME_FIXTURE: &str = include_str!("../fixtures/agent-frames.json"); + +/// Parses the shared fixture into one object keyed by case name. +fn agent_fixture() -> serde_json::Value { + match serde_json::from_str(AGENT_FRAME_FIXTURE) { + Ok(fixture) => fixture, + Err(error) => panic!("the fixture is valid JSON: {error}"), + } +} + +/// The fixture's `agent_event_minimal` entry as the vocabulary type. +fn minimal_fixture_event() -> promptforge_core_support::events::RuntimeEvent { + use promptforge_core_support::events::{RuntimeEvent, RuntimeEventKind}; + RuntimeEvent { + kind: RuntimeEventKind::UserInput, + section: "chat".to_owned(), + chain_id: 0, + depth: 0, + turn: 0, + content: "hi".to_owned(), + model: None, + tool_call_id: None, + finish_reason: None, + metrics: None, + } +} + +/// The fixture's `agent_event_stamped` entry as the vocabulary type, +/// every metrics section populated. +fn stamped_fixture_event() -> promptforge_core_support::events::RuntimeEvent { + use promptforge_core_support::events::{ + CallMetrics, ClientTiming, LlamaTimings, RuntimeEvent, RuntimeEventKind, Usage, VllmMetrics, + }; + RuntimeEvent { + kind: RuntimeEventKind::AssistantReply, + section: "chat".to_owned(), + chain_id: 1, + depth: 0, + turn: 2, + content: "hello".to_owned(), + model: Some("llama-3".to_owned()), + tool_call_id: None, + finish_reason: Some("stop".to_owned()), + metrics: Some(CallMetrics { + usage: Some(Usage { + prompt_tokens: 7, + completion_tokens: 3, + total_tokens: 10, + cached_tokens: Some(2), + reasoning_tokens: Some(1), + }), + llama: Some(LlamaTimings { + prompt_n: 7, + prompt_ms: 12.5, + prompt_per_second: 560.0, + predicted_n: 3, + predicted_ms: 30.5, + predicted_per_second: 98.5, + draft_n: 4, + draft_n_accepted: 2, + }), + vllm: Some(VllmMetrics { + time_to_first_token_ms: Some(8.5), + generation_time_ms: Some(22.5), + queue_time_ms: Some(1.5), + mean_itl_ms: Some(7.5), + tokens_per_second: Some(133.5), + }), + client: Some(ClientTiming { + ttft_ms: Some(9.5), + mean_itl_ms: Some(8.25), + e2e_ms: 41.5, + }), + }), + } +} + +#[test] +fn the_shared_fixture_pins_exactly_the_agreed_case_list() { + let fixture = agent_fixture(); + let mut cases: Vec<&str> = fixture + .as_object() + .expect("the fixture is one object keyed by case name") + .keys() + .map(String::as_str) + .collect(); + cases.sort_unstable(); + assert_eq!( + cases, + [ + "agent_delta_reasoning", + "agent_delta_text", + "agent_event_minimal", + "agent_event_stamped", + "agent_session", + "agents", + "attach", + "cancel", + "input_cancelled", + "input_required", + "input_response", + "launch", + ], + "both suites pin exactly the same case list, so a case added on \ + one side fails the other" + ); +} + +#[test] +fn server_to_client_agent_frames_match_the_shared_fixture() { + // Each typed frame serializes to its fixture entry, compared as + // values so key order in the file is free. + let fixture = agent_fixture(); + assert_eq!( + serde_json::to_value(AgentsFrame::new(vec![ + "chat".to_owned(), + "research".to_owned() + ])) + .expect("the frame serializes"), + fixture["agents"] + ); + assert_eq!( + serde_json::to_value(AgentSessionFrame::new("a1b2".to_owned(), "chat".to_owned())) + .expect("the frame serializes"), + fixture["agent_session"] + ); + assert_eq!( + serde_json::to_value(AgentEventFrame::new(3, None, minimal_fixture_event())) + .expect("the frame serializes"), + fixture["agent_event_minimal"] + ); + assert_eq!( + serde_json::to_value(AgentEventFrame::new(4, Some(1), stamped_fixture_event())) + .expect("the frame serializes"), + fixture["agent_event_stamped"], + "the event rides in its persisted vocabulary shape, metrics and all" + ); + assert_eq!( + serde_json::to_value(AgentDeltaFrame::new( + AgentDeltaKind::Text, + "po".to_owned(), + 2 + )) + .expect("the frame serializes"), + fixture["agent_delta_text"] + ); + assert_eq!( + serde_json::to_value(AgentDeltaFrame::new( + AgentDeltaKind::Reasoning, + "hmm".to_owned(), + 2 + )) + .expect("the frame serializes"), + fixture["agent_delta_reasoning"] + ); + assert_eq!( + serde_json::to_value(InputFrame::Required { + token: "a1b2c3".to_owned(), + }) + .expect("the frame serializes"), + fixture["input_required"] + ); + assert_eq!( + serde_json::to_value(InputFrame::Cancelled { + token: "a1b2c3".to_owned(), + }) + .expect("the frame serializes"), + fixture["input_cancelled"] + ); +} + +#[test] +fn client_to_server_agent_frames_match_the_shared_fixture() { + // `input_response` parses through its typed body; `launch`, + // `attach`, and `cancel` are routed from raw JSON in + // `session_agents::socket`, so the fixture pins exactly the fields + // that routing reads. + let fixture = agent_fixture(); + let response: InputResponse = serde_json::from_value(fixture["input_response"].clone()) + .expect("the fixture input_response parses"); + assert_eq!(response.token, "a1b2c3"); + assert_eq!(response.text, "two words"); + assert_eq!(fixture["launch"]["type"], "launch"); + assert_eq!(fixture["launch"]["agent"], "chat"); + assert_eq!(fixture["attach"]["type"], "attach"); + assert_eq!(fixture["attach"]["session"], "a1b2"); + assert_eq!(fixture["cancel"]["type"], "cancel"); +} diff --git a/crates/workshop-protocol/tests/it/frames.rs b/crates/workshop-protocol/tests/it/frames.rs new file mode 100644 index 000000000..02c95a586 --- /dev/null +++ b/crates/workshop-protocol/tests/it/frames.rs @@ -0,0 +1,268 @@ +//! Per-frame wire-shape pins: each test asserts one frame against the +//! exact JSON literal the pre-refactor code built with +//! `serde_json::json!`, so a field rename, retype, or optionality change +//! fails here before it reaches a socket. + +use workshop_protocol::{ + Activity, AgentDeltaFrame, AgentDeltaKind, AgentEventFrame, AgentSessionFrame, AgentsFrame, + CatalogPush, ErrorEnvelope, ErrorFrame, InputFrame, InputResponse, Progress, Severity, + StatusBarUpdate, WorkbenchSnapshot, +}; + +/// Builds a minimal update with the given label. +fn stub(label: impl Into) -> StatusBarUpdate { + StatusBarUpdate { + label: label.into(), + description: String::new(), + progress: None, + severity: Severity::Info, + activity: Activity::General, + } +} + +#[test] +fn a_status_update_serializes_as_a_status_frame() { + let frame = serde_json::to_value(stub("Ready").frame()).expect("the frame serializes"); + assert_eq!( + frame, + serde_json::json!({ + "type": "status", + "label": "Ready", + "description": "", + "progress": null, + "severity": "info", + "activity": "general", + }), + "the wire shape matches the workshop protocol's frame taxonomy" + ); +} + +#[test] +fn progress_and_the_remaining_variants_serialize() { + let update = StatusBarUpdate { + progress: Some(Progress { + current: 1, + total: 2, + }), + severity: Severity::Error, + activity: Activity::Thinking, + ..stub("Working") + }; + let frame = serde_json::to_value(update.frame()).expect("the frame serializes"); + assert_eq!( + frame["progress"], + serde_json::json!({"current": 1, "total": 2}) + ); + assert_eq!(frame["severity"], "error"); + assert_eq!(frame["activity"], "thinking"); + // Debug serializes too; the UI, not the bus, ignores it. + let debug = serde_json::to_value( + StatusBarUpdate { + severity: Severity::Debug, + activity: Activity::Generating, + ..stub("x") + } + .frame(), + ) + .expect("the frame serializes"); + assert_eq!(debug["severity"], "debug"); + assert_eq!(debug["activity"], "generating"); +} + +#[test] +fn a_catalog_push_serializes_as_a_models_frame() { + let push = CatalogPush { + models: vec![serde_json::json!({"id": "test-model", "object": "model"})], + }; + let frame = serde_json::to_value(push.frame()).expect("the frame serializes"); + assert_eq!( + frame, + serde_json::json!({ + "type": "models", + "models": [{"id": "test-model", "object": "model"}], + }), + "the wire shape matches the workshop protocol's frame taxonomy" + ); +} + +#[test] +fn a_workbench_snapshot_serializes_as_a_workbench_frame() { + let snapshot = WorkbenchSnapshot { + profiles: vec!["main".to_string(), "coding".to_string()], + active: Some("main".to_string()), + switching: None, + selected_model: Some("claude-sonnet-4-6".to_string()), + chat_ready: true, + }; + let frame = serde_json::to_value(snapshot.frame()).expect("the frame serializes"); + assert_eq!( + frame, + serde_json::json!({ + "type": "workbench", + "profiles": ["main", "coding"], + "active": "main", + "switching": null, + "selected": "claude-sonnet-4-6", + "chat_ready": true, + }), + "the wire shape matches the workshop protocol's frame taxonomy" + ); +} + +#[test] +fn an_agents_frame_serializes_the_discovered_names() { + let frame = serde_json::to_value(AgentsFrame::new(vec![ + "chat".to_owned(), + "research".to_owned(), + ])) + .expect("the frame serializes"); + assert_eq!( + frame, + serde_json::json!({"type": "agents", "agents": ["chat", "research"]}), + "the wire shape matches the workshop protocol's frame taxonomy" + ); +} + +#[test] +fn an_agent_session_frame_serializes_its_id_and_agent() { + let frame = serde_json::to_value(AgentSessionFrame::new("a1b2".to_owned(), "chat".to_owned())) + .expect("the frame serializes"); + assert_eq!( + frame, + serde_json::json!({"type": "agent_session", "session": "a1b2", "agent": "chat"}), + ); +} + +#[test] +fn an_agent_event_frame_carries_its_log_index_and_optional_reply_id() { + use promptforge_core_support::events::{RuntimeEvent, RuntimeEventKind}; + let event = RuntimeEvent { + kind: RuntimeEventKind::UserInput, + section: "chat".to_owned(), + chain_id: 0, + depth: 0, + turn: 0, + content: "hi".to_owned(), + model: None, + tool_call_id: None, + finish_reason: None, + metrics: None, + }; + let plain = serde_json::to_value(AgentEventFrame::new(3, None, event.clone())) + .expect("the frame serializes"); + assert_eq!(plain["type"], "agent_event"); + assert_eq!(plain["index"], 3, "the frame carries the entry's log index"); + assert!( + plain.get("reply").is_none(), + "an absent reply id is omitted from the wire, not serialized as null" + ); + assert_eq!( + plain["event"], + serde_json::to_value(&event).expect("events serialize"), + "the entry rides in its persisted vocabulary shape" + ); + let stamped = serde_json::to_value(AgentEventFrame::new(4, Some(1), event)) + .expect("the frame serializes"); + assert_eq!( + stamped["reply"], 1, + "a superseding event is stamped with the reply id its deltas carried" + ); +} + +#[test] +fn an_agent_delta_frame_is_stamped_with_its_superseding_reply_id() { + let text = serde_json::to_value(AgentDeltaFrame::new( + AgentDeltaKind::Text, + "po".to_owned(), + 2, + )) + .expect("the frame serializes"); + assert_eq!( + text, + serde_json::json!({"type": "agent_delta", "kind": "text", "content": "po", "reply": 2}), + ); + let reasoning = serde_json::to_value(AgentDeltaFrame::new( + AgentDeltaKind::Reasoning, + "hmm".to_owned(), + 2, + )) + .expect("the frame serializes"); + assert_eq!( + reasoning, + serde_json::json!({ + "type": "agent_delta", "kind": "reasoning", "content": "hmm", "reply": 2, + }), + ); +} + +#[test] +fn an_input_required_frame_serializes_with_its_token() { + let frame = serde_json::to_value(InputFrame::Required { + token: "a1b2c3".to_owned(), + }) + .expect("the frame serializes"); + assert_eq!( + frame, + serde_json::json!({"type": "input_required", "token": "a1b2c3"}), + "the wire shape matches the workshop protocol's frame taxonomy" + ); +} + +#[test] +fn an_input_cancelled_frame_serializes_with_its_token() { + let frame = serde_json::to_value(InputFrame::Cancelled { + token: "a1b2c3".to_owned(), + }) + .expect("the frame serializes"); + assert_eq!( + frame, + serde_json::json!({"type": "input_cancelled", "token": "a1b2c3"}), + "the wire shape matches the workshop protocol's frame taxonomy" + ); +} + +#[test] +fn an_input_response_parses_its_body_byte_exact_ignoring_the_envelope() { + let gnarly = "line1\r\nline2 \"quoted\" {\"text\":\"decoy\"} \\slash 🦀"; + let response: InputResponse = serde_json::from_value(serde_json::json!({ + "type": "input_response", + "token": "a1b2c3", + "text": gnarly, + })) + .expect("the frame parses with its envelope tag present"); + assert_eq!(response.token, "a1b2c3"); + assert_eq!( + response.text, gnarly, + "the operator's text survives the wire byte-exact" + ); +} + +#[test] +fn an_error_frame_serializes_with_and_without_the_echoed_id() { + let untagged = serde_json::to_value(ErrorFrame::new("Gateway unreachable".to_string(), None)) + .expect("the frame serializes"); + assert_eq!( + untagged, + serde_json::json!({"type": "error", "message": "Gateway unreachable"}) + ); + let id = serde_json::json!(7); + let tagged = serde_json::to_value(ErrorFrame::new( + "Gateway unreachable".to_string(), + Some(&id), + )) + .expect("the frame serializes"); + assert_eq!( + tagged, + serde_json::json!({"type": "error", "message": "Gateway unreachable", "id": 7}) + ); +} + +#[test] +fn an_error_envelope_serializes_as_message_and_code_under_error() { + let envelope = ErrorEnvelope::new("file cannot be read", "read_file"); + assert_eq!( + serde_json::to_value(&envelope).expect("the envelope serializes"), + serde_json::json!({"error": {"message": "file cannot be read", "code": "read_file"}}), + "the wire shape matches the envelope the shell has always answered with" + ); +} diff --git a/crates/workshop-protocol/tests/it/main.rs b/crates/workshop-protocol/tests/it/main.rs new file mode 100644 index 000000000..6caa20d65 --- /dev/null +++ b/crates/workshop-protocol/tests/it/main.rs @@ -0,0 +1,4 @@ +//! Integration tests for `workshop-protocol`: the wire-shape pins. + +mod fixture; +mod frames; diff --git a/crates/workshop-registry/Cargo.toml b/crates/workshop-registry/Cargo.toml new file mode 100644 index 000000000..10ae9ba69 --- /dev/null +++ b/crates/workshop-registry/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "workshop-registry" +version = "0.0.0" +publish = false +edition.workspace = true +license.workspace = true +repository.workspace = true + +description = "Workshop subsystem registry: sealed proxy slots subsystems self-register into, so the composition root never names them" + +[dependencies] +axum.workspace = true +tokio.workspace = true +workshop-protocol.workspace = true + +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } + +[lints] +workspace = true diff --git a/crates/workshop-registry/src/lib.rs b/crates/workshop-registry/src/lib.rs new file mode 100644 index 000000000..be8b2070a --- /dev/null +++ b/crates/workshop-registry/src/lib.rs @@ -0,0 +1,32 @@ +//! workshop-registry - the keystone of the workshop server +//! decomposition: one proxy slot per subsystem, into which subsystems +//! self-register their routes, state handles, background tasks, push +//! channels, and shutdown hooks. Consumers reach subsystems through the +//! registry instead of by name, so the composition root never hand-wires +//! what a subsystem can announce itself. +//! +//! ## Invariants +//! +//! - Tier: vocabulary; may depend on: `workshop-protocol` (the wire +//! types the push-channel slots carry). Read `AGENTS.md` before adding +//! an import. +//! - Every file in this crate stays under 500 lines; split first, then +//! edit. +//! - Every subsystem trait is sealed (a private empty supertrait), so +//! only this crate implements them: registrants plug in through the +//! adapters provided here, never by implementing a trait downstream. +//! - An unregistered slot is a graceful no-op, never an error: +//! consumers branch on `None` and continue degraded. +//! - A registration is alive exactly as long as its guard: dropping the +//! guard deregisters the subsystem. + +mod registry; +mod slot; +mod traits; + +pub use registry::Registry; +pub use slot::{ProxySlot, Registration}; +pub use traits::{ + BackgroundTasks, RouteRegistrar, ShutdownHook, StateProvider, StatusChannel, + StatusChannelAdapter, +}; diff --git a/crates/workshop-registry/src/registry.rs b/crates/workshop-registry/src/registry.rs new file mode 100644 index 000000000..1f30c6e47 --- /dev/null +++ b/crates/workshop-registry/src/registry.rs @@ -0,0 +1,108 @@ +//! The central registry: one proxy slot per subsystem. + +use std::fmt; +use std::sync::Arc; + +use crate::slot::ProxySlot; +use crate::traits::{BackgroundTasks, RouteRegistrar, ShutdownHook, StateProvider, StatusChannel}; + +/// The central registry subsystems self-register into. +/// +/// Clones are cheap (every slot is an `Arc`) and share the same slots, +/// so every handle the composition root hands out sees the same +/// registrations. +pub struct Registry { + routes: ProxySlot, + state_handles: ProxySlot, + tasks: ProxySlot, + status: ProxySlot, + shutdown: ProxySlot, +} + +impl Registry { + /// An empty registry: every slot a graceful no-op until its + /// subsystem registers. + #[must_use] + pub fn new() -> Self { + Self { + routes: ProxySlot::new(), + state_handles: ProxySlot::new(), + tasks: ProxySlot::new(), + status: ProxySlot::new(), + shutdown: ProxySlot::new(), + } + } + + /// The route-registration slot: the subsystem merges its routes into + /// the shell's API router. + #[must_use] + pub fn routes(&self) -> &ProxySlot { + &self.routes + } + + /// The state-handle slot: the subsystem publishes the shared handles + /// its routes and tasks need. + #[must_use] + pub fn state_handles(&self) -> &ProxySlot { + &self.state_handles + } + + /// The background-task slot: the subsystem spawns its long-lived + /// tasks. + #[must_use] + pub fn tasks(&self) -> &ProxySlot { + &self.tasks + } + + /// The status push-channel slot: the status subsystem's retained + /// snapshot plus live subscription. + #[must_use] + pub fn status(&self) -> &ProxySlot { + &self.status + } + + /// The shutdown slot: the subsystem's graceful-stop signal. + #[must_use] + pub fn shutdown(&self) -> &ProxySlot { + &self.shutdown + } + + /// The registered status channel, or `None` while the status + /// subsystem has not registered - a graceful no-op for the `/ws` + /// session loop. + #[must_use] + pub fn status_channel(&self) -> Option> { + self.status.get() + } +} + +impl Default for Registry { + fn default() -> Self { + Self::new() + } +} + +impl Clone for Registry { + fn clone(&self) -> Self { + Self { + routes: self.routes.clone(), + state_handles: self.state_handles.clone(), + tasks: self.tasks.clone(), + status: self.status.clone(), + shutdown: self.shutdown.clone(), + } + } +} + +impl fmt::Debug for Registry { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("Registry") + .field("routes", &self.routes) + .field("state_handles", &self.state_handles) + .field("tasks", &self.tasks) + .field("status", &self.status) + .field("shutdown", &self.shutdown) + .finish() + } +} diff --git a/crates/workshop-registry/src/slot.rs b/crates/workshop-registry/src/slot.rs new file mode 100644 index 000000000..a565ea534 --- /dev/null +++ b/crates/workshop-registry/src/slot.rs @@ -0,0 +1,96 @@ +//! The typed proxy slot: one per subsystem, `RwLock>>` under the hood. + +use std::fmt; +use std::sync::{Arc, PoisonError, RwLock, Weak}; + +/// One subsystem's proxy slot: empty until the subsystem self-registers. +/// +/// Consumers read through [`ProxySlot::get`] and treat `None` as a +/// graceful no-op - an unregistered slot degrades the feature, never +/// fails the caller. +pub struct ProxySlot { + occupant: Arc>>>, +} + +impl ProxySlot { + /// An empty slot. + pub(crate) fn new() -> Self { + Self { + occupant: Arc::new(RwLock::new(None)), + } + } + + /// The registered subsystem, or `None` while the slot is empty. + #[must_use] + pub fn get(&self) -> Option> { + self.occupant + .read() + .unwrap_or_else(PoisonError::into_inner) + .clone() + } + + /// Registers `subsystem`, replacing any previous occupant, and + /// returns the guard keeping the registration alive: dropping the + /// guard deregisters the subsystem. A replaced occupant's stale + /// guard deregisters nothing. + pub fn register(&self, subsystem: Arc) -> Registration { + *self + .occupant + .write() + .unwrap_or_else(PoisonError::into_inner) = Some(Arc::clone(&subsystem)); + Registration { + slot: Arc::downgrade(&self.occupant), + subsystem, + } + } +} + +impl Clone for ProxySlot { + fn clone(&self) -> Self { + Self { + occupant: Arc::clone(&self.occupant), + } + } +} + +impl fmt::Debug for ProxySlot { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ProxySlot") + .field("registered", &self.get().is_some()) + .finish() + } +} + +/// The registration guard: keeps the subsystem registered while held. +/// +/// Dropping the guard deregisters the subsystem, unless the slot has +/// since been re-registered - a stale guard never evicts a newer +/// occupant. +#[must_use = "dropping the guard deregisters the subsystem"] +pub struct Registration { + slot: Weak>>>, + subsystem: Arc, +} + +impl Drop for Registration { + fn drop(&mut self) { + let Some(slot) = self.slot.upgrade() else { + return; + }; + let mut occupant = slot.write().unwrap_or_else(PoisonError::into_inner); + if occupant + .as_ref() + .is_some_and(|current| Arc::ptr_eq(current, &self.subsystem)) + { + occupant.take(); + } + } +} + +impl fmt::Debug for Registration { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.debug_struct("Registration").finish() + } +} diff --git a/crates/workshop-registry/src/traits.rs b/crates/workshop-registry/src/traits.rs new file mode 100644 index 000000000..81c5dca79 --- /dev/null +++ b/crates/workshop-registry/src/traits.rs @@ -0,0 +1,115 @@ +//! The sealed subsystem traits: one per registration point in the +//! decomposition's inventory (routes, state handles, background tasks, +//! push channels, shutdown). +//! +//! All five are sealed behind a private supertrait, so only this crate +//! can implement them. A registrant plugs its subsystem in through an +//! adapter this crate provides - [`StatusChannelAdapter`] is the first, +//! added with the status bus's proof-of-concept migration; each later +//! migration adds its adapter beside it. + +use std::any::Any; +use std::fmt; +use std::sync::Arc; + +use axum::Router; +use tokio::sync::broadcast; +use tokio::task::JoinHandle; + +use workshop_protocol::StatusBarUpdate; + +/// The sealing boundary: a private empty supertrait no downstream crate +/// can name, so the subsystem traits below cannot be implemented outside +/// this crate. +mod sealed { + /// The private empty supertrait sealing every subsystem trait. + pub trait Sealed {} +} +use sealed::Sealed; + +/// Route registration: a subsystem contributes its HTTP routes, merged +/// into the shell's API router at composition time. +pub trait RouteRegistrar: Sealed + Send + Sync { + /// The subsystem's routes, with their state already applied. + fn routes(&self) -> Router; +} + +/// State handle provision: a subsystem publishes the shared handles its +/// routes and tasks need, type-erased so the shell's state object +/// shrinks to a registry of handles. +pub trait StateProvider: Sealed + Send + Sync { + /// The subsystem's handle set, downcast by its consumers. + fn handles(&self) -> Arc; +} + +/// Background task spawning: a subsystem starts its long-lived tasks, +/// so the composition root holds no `tokio::spawn` calls of its own. +pub trait BackgroundTasks: Sealed + Send + Sync { + /// Spawns the subsystem's tasks on `runtime`; the returned join + /// handles are the shell's shutdown lever. + fn spawn(&self, runtime: &tokio::runtime::Handle) -> Vec>; +} + +/// The status-bar push channel: the retained snapshot plus live +/// subscription every `/ws` session forwards. +pub trait StatusChannel: Sealed + Send + Sync { + /// Subscribes to every update sent from this call onward. + fn subscribe(&self) -> broadcast::Receiver; + /// The most recently emitted update, retained so a session + /// connecting later can send the current status as its snapshot. + fn latest(&self) -> Option; +} + +/// Shutdown handle: a subsystem's graceful-stop signal, held by the +/// shell and fired at teardown. +pub trait ShutdownHook: Sealed + Send + Sync { + /// Signals the subsystem to stop; returns immediately. + fn shutdown(&self); +} + +/// A [`StatusChannel`] backed by two closures over the status bus: the +/// registration adapter for the status subsystem. The registry's traits +/// are sealed, so the registrant plugs its bus in through this adapter +/// rather than implementing the trait itself. +pub struct StatusChannelAdapter { + subscribe: S, + latest: L, +} + +impl StatusChannelAdapter +where + S: Fn() -> broadcast::Receiver + Send + Sync, + L: Fn() -> Option + Send + Sync, +{ + /// Builds the adapter from the bus's subscribe and latest closures. + pub fn new(subscribe: S, latest: L) -> Self { + Self { subscribe, latest } + } +} + +impl Sealed for StatusChannelAdapter +where + S: Fn() -> broadcast::Receiver + Send + Sync, + L: Fn() -> Option + Send + Sync, +{ +} + +impl StatusChannel for StatusChannelAdapter +where + S: Fn() -> broadcast::Receiver + Send + Sync, + L: Fn() -> Option + Send + Sync, +{ + fn subscribe(&self) -> broadcast::Receiver { + (self.subscribe)() + } + + fn latest(&self) -> Option { + (self.latest)() + } +} + +impl fmt::Debug for StatusChannelAdapter { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.debug_struct("StatusChannelAdapter").finish() + } +} diff --git a/crates/workshop-registry/tests/it/main.rs b/crates/workshop-registry/tests/it/main.rs new file mode 100644 index 000000000..0b4123544 --- /dev/null +++ b/crates/workshop-registry/tests/it/main.rs @@ -0,0 +1,135 @@ +//! Integration tests for `workshop-registry`: the proxy-slot contract. + +use std::sync::{Arc, Mutex, PoisonError}; + +use tokio::sync::broadcast; + +use workshop_protocol::{Activity, Severity, StatusBarUpdate}; +use workshop_registry::{Registry, StatusChannelAdapter}; + +/// The adapter's boxed subscribe closure type. +type Subscribe = Box broadcast::Receiver + Send + Sync>; +/// The adapter's boxed latest-snapshot closure type. +type Latest = Box Option + Send + Sync>; + +/// A hand-rolled status bus plus its registration adapter: a broadcast +/// sender and a retained snapshot, mirroring the registrant's real bus. +struct TestBus { + adapter: StatusChannelAdapter, + sender: broadcast::Sender, + latest: Arc>>, +} + +/// Builds a test bus with a fresh channel and no snapshot. +fn status_adapter() -> TestBus { + let (sender, _) = broadcast::channel(4); + let latest = Arc::new(Mutex::new(None)); + let subscribe: Subscribe = Box::new({ + let sender = sender.clone(); + move || sender.subscribe() + }); + let latest_snapshot: Latest = Box::new({ + let latest = Arc::clone(&latest); + move || { + latest + .lock() + .unwrap_or_else(PoisonError::into_inner) + .clone() + } + }); + TestBus { + adapter: StatusChannelAdapter::new(subscribe, latest_snapshot), + sender, + latest, + } +} + +/// A status update carrying only a label. +fn update(label: &str) -> StatusBarUpdate { + StatusBarUpdate { + label: label.to_string(), + description: String::new(), + progress: None, + severity: Severity::Info, + activity: Activity::General, + } +} + +#[test] +fn an_unregistered_slot_is_a_graceful_no_op() { + let registry = Registry::new(); + assert!(registry.status_channel().is_none()); + assert!(registry.routes().get().is_none()); + assert!(registry.state_handles().get().is_none()); + assert!(registry.tasks().get().is_none()); + assert!(registry.shutdown().get().is_none()); +} + +#[test] +fn a_registered_status_channel_serves_subscribe_and_latest() { + let registry = Registry::new(); + let bus = status_adapter(); + let _registration = registry.status().register(Arc::new(bus.adapter)); + let channel = registry + .status_channel() + .expect("the registered channel is served"); + *bus.latest.lock().unwrap_or_else(PoisonError::into_inner) = Some(update("Ready")); + assert_eq!( + channel.latest().map(|update| update.label), + Some("Ready".to_string()), + "the slot serves the registrant's retained snapshot" + ); + let mut receiver = channel.subscribe(); + bus.sender + .send(update("Working")) + .expect("a receiver is subscribed"); + assert_eq!( + receiver + .try_recv() + .expect("the send reaches the subscriber") + .label, + "Working" + ); +} + +#[test] +fn dropping_the_guard_deregisters_the_subsystem() { + let registry = Registry::new(); + let bus = status_adapter(); + let registration = registry.status().register(Arc::new(bus.adapter)); + assert!(registry.status_channel().is_some()); + drop(registration); + assert!( + registry.status_channel().is_none(), + "the slot empties when the guard drops" + ); +} + +#[test] +fn a_stale_guard_never_evicts_a_newer_occupant() { + let registry = Registry::new(); + let stale = registry + .status() + .register(Arc::new(status_adapter().adapter)); + let _current = registry + .status() + .register(Arc::new(status_adapter().adapter)); + drop(stale); + assert!( + registry.status_channel().is_some(), + "the replacement survives the stale guard's drop" + ); +} + +#[test] +fn registry_clones_share_the_same_slots() { + let registry = Registry::new(); + let clone = registry.clone(); + let _registration = registry + .status() + .register(Arc::new(status_adapter().adapter)); + assert!( + clone.status_channel().is_some(), + "a registration through one handle is visible through every clone" + ); +} diff --git a/crates/workshop-server/Cargo.toml b/crates/workshop-server/Cargo.toml index f8402481a..9899d489e 100644 --- a/crates/workshop-server/Cargo.toml +++ b/crates/workshop-server/Cargo.toml @@ -44,12 +44,15 @@ toml.workspace = true tracing.workspace = true tracing-subscriber.workspace = true url.workspace = true +workshop-protocol.workspace = true +workshop-registry.workspace = true +workshop-support.workspace = true promptforge-agent.workspace = true tempfile = { workspace = true, optional = true } [features] default = [] -test-fixtures = ["dep:tempfile"] +test-fixtures = ["dep:tempfile", "workshop-support/test-fixtures"] [dev-dependencies] workshop-server = { path = ".", features = ["test-fixtures"] } diff --git a/crates/workshop-server/src/app.rs b/crates/workshop-server/src/app.rs index 04a016b7b..8279775a9 100644 --- a/crates/workshop-server/src/app.rs +++ b/crates/workshop-server/src/app.rs @@ -7,10 +7,10 @@ use axum::Router; use shared_progress::ProgressHub; -use crate::backoff::ReconnectBackoff; +use workshop_registry::{Registration, Registry, StatusChannel, StatusChannelAdapter}; +use workshop_support::{Config, DEFAULT_DEADLINE, ReconnectBackoff, with_deadline}; + use crate::catalog::CatalogBus; -use crate::config::Config; -use crate::deadline::{DEFAULT_DEADLINE, with_deadline}; use crate::gateway::GatewayError; use crate::gateway_binding::{GatewayBinding, GatewaySnapshot, GatewayUpdater}; use crate::heartbeat::GatewayHealth; @@ -23,7 +23,7 @@ use crate::status::StatusBus; use crate::workspace::Workspace; /// Address the server binds to when no override is given. -pub const DEFAULT_ADDR: &str = "127.0.0.1:7910"; +pub use workshop_support::DEFAULT_ADDR; /// Shared handler state: the authenticated gateway client, the status, /// catalog, and menu buses, the process progress hub, the hosted @@ -39,6 +39,10 @@ pub struct AppState { pub(crate) menu: MenuBus, pub(crate) workspace: Workspace, pub(crate) agents: AgentSessions, + registry: Registry, + // Keeps the status bus's self-registration alive; dropping the last + // state clone deregisters it. + _status_registration: Arc>, } impl AppState { @@ -141,6 +145,15 @@ impl AppState { &self.workspace } + /// The subsystem registry: proxy slots the subsystems self-register + /// into, so consumers reach them by slot instead of by name. The + /// status bus is the proof-of-concept registrant; the `/ws` session + /// loop reads its channel here. + #[must_use] + pub fn registry(&self) -> &Registry { + &self.registry + } + /// The agent-session registry: discovery, launch, and the running /// sessions behind the `/agents/ws` socket. Sessions outlive /// sockets, so an embedding host ends one through @@ -169,9 +182,25 @@ pub fn state_with_gateway( // A crash between an atomic write's temp file and its rename // orphans the temp; boot is the one moment the directory is // known and quiet, so it is swept here. - crate::atomic::sweep_orphaned_temps(state_dir); + workshop_support::sweep_orphaned_temps(state_dir); let menu = MenuBus::new(catalog.clone(), Some(state_dir)); let push = Push::new(status.clone(), catalog.clone(), menu.clone()); + // The status bus is the proof-of-concept self-registrant: the `/ws` + // session loop discovers its channel through the registry's slot + // instead of naming the bus. + let registry = Registry::new(); + let status_registration = Arc::new(registry.status().register(Arc::new( + StatusChannelAdapter::new( + { + let bus = status.clone(); + move || bus.subscribe() + }, + { + let bus = status.clone(); + move || bus.latest() + }, + ), + ))); // Startup phases are reported as they run; with no client connected // yet these land on an empty bus, ready for the first session. crate::resolve::report(gateway, &push); @@ -207,6 +236,8 @@ pub fn state_with_gateway( menu, workspace, agents, + registry, + _status_registration: status_registration, }) } @@ -232,7 +263,8 @@ pub enum StateError { /// group narrowed to the one service its handlers use. The API routes sit /// behind the `crate::cross_site` guard; `/health` and the UI assets /// stay outside it so the shell probe, heartbeat, and initial navigation -/// keep working. Every HTTP route carries a `crate::deadline` tier - +/// keep working. Every HTTP route carries a `workshop_support` deadline +/// tier - /// the default here, the relay tier inside `routes::chat` - and the /// WebSocket upgrades carry none. Every response carries the /// `crate::csp` policy: the shell's webview loads the UI as an External @@ -288,7 +320,7 @@ pub(crate) mod fixtures { #[cfg(test)] use crate::app::{AppState, state_with_gateway}; #[cfg(test)] - use crate::config::{AgentsConfig, Config, GatewayConfig, ServerConfig}; + use workshop_support::{AgentsConfig, Config, GatewayConfig, ServerConfig}; /// Builds a configuration pointing at `base_url`, anchoring the state /// directory at `state_dir`. @@ -386,6 +418,15 @@ mod tests { assert_eq!(DEFAULT_ADDR, "127.0.0.1:7910"); } + #[test] + fn the_relay_deadline_outlasts_the_gateway_request_timeout() { + assert!( + workshop_support::RELAY_DEADLINE > crate::gateway::REQUEST_TIMEOUT, + "the route deadline must let the gateway client time out first, \ + so the caller sees the relay's 502 rather than a blunt 408" + ); + } + #[test] fn startup_sweeps_orphaned_temp_files_from_the_state_directory() { let dir = tempfile::TempDir::new().expect("tempdir"); diff --git a/crates/workshop-server/src/catalog.rs b/crates/workshop-server/src/catalog.rs index 767c8b639..146bb3b7e 100644 --- a/crates/workshop-server/src/catalog.rs +++ b/crates/workshop-server/src/catalog.rs @@ -3,19 +3,18 @@ //! //! The heartbeat republishes the catalog when the gateway comes back //! (unreachable to connected), so a UI that booted while the gateway was -//! down refreshes its model picker without a reload. Like the status bus, -//! the channel is a tokio broadcast: publishing never blocks, a publish -//! with no sessions is a no-op, and a lagging session skips ahead - every -//! push is a complete snapshot, so an overwritten one loses nothing. The -//! bus also retains the newest push, so a session that connects later -//! sends the current catalog immediately - the delivery contract's +//! down refreshes its model picker without a reload. The channel is a +//! [`RetainedBus`]: publishing never blocks, a publish with no sessions +//! is a no-op, and a lagging session skips ahead - every push is a +//! complete snapshot, so an overwritten one loses nothing. The bus also +//! retains the newest push, so a session that connects later sends the +//! current catalog immediately - the delivery contract's //! resend-on-reconnect for ephemeral frames. -use std::sync::{Arc, Mutex, PoisonError}; - use tokio::sync::{broadcast, watch}; -use crate::protocol::CatalogPush; +use workshop_protocol::CatalogPush; +use workshop_support::RetainedBus; mod chat; use chat::ChatCatalogBus; @@ -30,8 +29,7 @@ const CATALOG_CHANNEL_CAPACITY: usize = 4; /// mirroring [`crate::status::StatusBus`]. #[derive(Debug, Clone)] pub struct CatalogBus { - sender: broadcast::Sender, - latest: Arc>>, + bus: RetainedBus, chat: ChatCatalogBus, } @@ -39,26 +37,20 @@ impl CatalogBus { /// Creates a bus with no subscribers, an empty ring, and no snapshot. pub(crate) fn new() -> Self { Self { - sender: broadcast::channel(CATALOG_CHANNEL_CAPACITY).0, - latest: Arc::new(Mutex::new(None)), + bus: RetainedBus::new(CATALOG_CHANNEL_CAPACITY), chat: ChatCatalogBus::new(), } } /// Subscribes to every push sent from this call onward. pub(crate) fn subscribe(&self) -> broadcast::Receiver { - self.sender.subscribe() + self.bus.subscribe() } /// The most recently published catalog, retained so a session /// connecting later can send the current catalog as its snapshot. pub(crate) fn latest(&self) -> Option { - // A lock poisoned by a panicking peer recovers the value rather - // than wedging the process (the crate's zone-two error policy). - self.latest - .lock() - .unwrap_or_else(PoisonError::into_inner) - .clone() + self.bus.latest() } /// The current non-empty chat-capable catalog generation. @@ -77,13 +69,7 @@ impl CatalogBus { let models = models.into_iter().filter(is_chat_capable).collect(); let push = CatalogPush { models }; self.chat.publish(&push.models); - // The retained copy (a second owner, hence the clone) is written - // before the send, so a session that subscribes after the send - // still finds this push as its snapshot. - *self.latest.lock().unwrap_or_else(PoisonError::into_inner) = Some(push.clone()); - // A send only fails when there are no receivers, which is the bus's - // resting state before the first client connects. - let _ = self.sender.send(push); + self.bus.send(push); } } diff --git a/crates/workshop-server/src/error.rs b/crates/workshop-server/src/error.rs index b350d92ea..7738256e4 100644 --- a/crates/workshop-server/src/error.rs +++ b/crates/workshop-server/src/error.rs @@ -10,8 +10,8 @@ //! Internal failure detail (the source chain) reaches the response body in //! debug builds only; production bodies stay at each variant's own message, //! close to the status text. Rich construction-time errors live elsewhere -//! ([`crate::config::ConfigError`], [`crate::serve::SpawnError`]) and never -//! cross the wire. +//! ([`workshop_support::ConfigError`], [`crate::serve::SpawnError`]) and +//! never cross the wire. use std::fmt::Write as _; use std::io; @@ -19,6 +19,8 @@ use std::io; use axum::http::{StatusCode, header}; use axum::response::{IntoResponse, Response}; +use workshop_protocol::ErrorEnvelope; + use crate::gateway::GatewayError; use crate::workspace::WorkspaceError; @@ -241,18 +243,13 @@ impl IntoResponse for AppError { let status = self.status(); match self.code() { Some(code) => { - let body = serde_json::json!({ - "error": { - "message": render_message(&self, LEAK_DETAIL), - "code": code, - } - }); - ( - status, - [(header::CONTENT_TYPE, "application/json")], - body.to_string(), - ) - .into_response() + let envelope = ErrorEnvelope::new(render_message(&self, LEAK_DETAIL), code); + // Serializing the envelope cannot fail: two strings only. + // A body that somehow cannot serialize degrades to the + // status line's own text. + let body = serde_json::to_string(&envelope) + .unwrap_or_else(|_| status.canonical_reason().unwrap_or("error").to_string()); + (status, [(header::CONTENT_TYPE, "application/json")], body).into_response() } None => ( status, diff --git a/crates/workshop-server/src/fixtures.rs b/crates/workshop-server/src/fixtures.rs index 22f72d43b..e0cad9405 100644 --- a/crates/workshop-server/src/fixtures.rs +++ b/crates/workshop-server/src/fixtures.rs @@ -1,13 +1,13 @@ //! Integration-test seams that exercise Workshop behavior in-process. pub use crate::app::state_with_gateway; -pub use crate::backoff::ReconnectBackoff; pub use crate::catalog::CatalogBus; pub use crate::heartbeat::{GatewayHealth, Heartbeat}; pub use crate::menu::{MenuBus, MenuRefusal}; -pub use crate::protocol::{Activity, Progress, Severity, StatusBarUpdate}; pub use crate::push::Push; pub use crate::status::StatusBus; +pub use workshop_protocol::{Activity, Progress, Severity, StatusBarUpdate}; +pub use workshop_support::ReconnectBackoff; #[cfg(feature = "test-fixtures")] pub use crate::app::fixtures::spawn_gateway; diff --git a/crates/workshop-server/src/gateway.rs b/crates/workshop-server/src/gateway.rs index b294eac75..1945a63f1 100644 --- a/crates/workshop-server/src/gateway.rs +++ b/crates/workshop-server/src/gateway.rs @@ -669,8 +669,7 @@ mod tests { use axum::response::IntoResponse; - use crate::backoff::xorshift; - + use workshop_support::xorshift; #[test] fn trailing_slash_is_trimmed_from_base_url() { let client = GatewayClient::new("http://127.0.0.1:8081/", "k").expect("client builds"); diff --git a/crates/workshop-server/src/heartbeat.rs b/crates/workshop-server/src/heartbeat.rs index 57b6b16ba..174102dbc 100644 --- a/crates/workshop-server/src/heartbeat.rs +++ b/crates/workshop-server/src/heartbeat.rs @@ -32,9 +32,10 @@ use std::time::Duration; use tokio::sync::{oneshot, watch}; -use crate::backoff::ReconnectBackoff; +use workshop_protocol::{Activity, Severity, StatusBarUpdate}; +use workshop_support::ReconnectBackoff; + use crate::gateway_binding::{GatewayBinding, GatewaySnapshot}; -use crate::protocol::{Activity, Severity, StatusBarUpdate}; use crate::push::Push; mod refresh; @@ -355,7 +356,7 @@ mod tests { use crate::catalog::CatalogBus; use crate::menu::MenuBus; - use crate::protocol::{CatalogPush, Progress, Severity, StatusBarUpdate, WorkbenchSnapshot}; + use workshop_protocol::{CatalogPush, Progress, Severity, StatusBarUpdate, WorkbenchSnapshot}; fn retained(label: &str) -> StatusBarUpdate { StatusBarUpdate { diff --git a/crates/workshop-server/src/input.rs b/crates/workshop-server/src/input.rs index a5e871973..687fda38d 100644 --- a/crates/workshop-server/src/input.rs +++ b/crates/workshop-server/src/input.rs @@ -22,7 +22,7 @@ use promptforge_core_support::observe::Observer; use promptforge_tools::{Tool, ToolError, ToolErrorKind, ToolId, ToolOutput}; use tokio::sync::{broadcast, oneshot}; -use crate::protocol::{InputFrame, InputResponse}; +use workshop_protocol::{InputFrame, InputResponse}; /// One unresolved wait: its single-use token, and the sender that resumes /// the suspended `user_input` call with the operator's text. diff --git a/crates/workshop-server/src/lib.rs b/crates/workshop-server/src/lib.rs index 484cc0da4..652c72328 100644 --- a/crates/workshop-server/src/lib.rs +++ b/crates/workshop-server/src/lib.rs @@ -10,13 +10,9 @@ mod app; mod assets; -mod atomic; -mod backoff; mod catalog; -mod config; mod cross_site; mod csp; -mod deadline; mod error; mod gateway; mod gateway_binding; @@ -26,7 +22,6 @@ mod input; mod menu; mod observer; mod progress; -mod protocol; mod push; mod relay; mod resolve; @@ -51,9 +46,6 @@ mod workspace; pub mod fixtures; pub use app::{AppState, DEFAULT_ADDR, StateError, router}; -pub use config::{ - AgentsConfig, Config, ConfigError, DEFAULT_CONFIG_PATH, GatewayConfig, ServerConfig, -}; pub use cross_site::{guard as cross_site_guard, origin_allowed}; pub use gateway::{ CacheEvent, CacheResponse, GatewayClient, GatewayError, GatewayResponse, SsePayloadStream, @@ -64,8 +56,11 @@ pub use input::{ SessionInputBroker, UserInputTool, WaitError, WaitRegistry, deliver_input_response, }; pub use observer::WorkshopObserver; -pub use protocol::{Activity, InputFrame, InputResponse}; pub use push::Push; pub use resolve::{GatewaySource, ResolveError, ResolvedGateway}; pub use serve::{ServerHandle, SpawnError, Termination, spawn}; pub use session_agents::AgentSessions; +pub use workshop_protocol::{Activity, InputFrame, InputResponse}; +pub use workshop_support::{ + AgentsConfig, Config, ConfigError, DEFAULT_CONFIG_PATH, GatewayConfig, ServerConfig, +}; diff --git a/crates/workshop-server/src/menu.rs b/crates/workshop-server/src/menu.rs index 498f07a21..78bcd50a8 100644 --- a/crates/workshop-server/src/menu.rs +++ b/crates/workshop-server/src/menu.rs @@ -25,8 +25,10 @@ use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; use tokio::sync::broadcast; +use workshop_protocol::WorkbenchSnapshot; +use workshop_support::RetainedBus; + use crate::catalog::{CatalogBus, is_chat_capable}; -use crate::protocol::WorkbenchSnapshot; /// Ring capacity of the menu bus. Pushes follow user interactions and /// heartbeat transitions, so a handful of slots is generous. @@ -44,8 +46,7 @@ const WORKSHOP_STATE_FILE: &str = "workshop-state.json"; /// snapshot, and one channel. #[derive(Debug, Clone)] pub struct MenuBus { - sender: broadcast::Sender, - latest: Arc>>, + bus: RetainedBus, state: Arc>, // Selections are validated against the retained catalog and // `chat_ready` reads its emptiness, so the menu holds its own handle. @@ -160,8 +161,7 @@ impl MenuBus { let memory_path = state_dir.map(|dir| dir.join(WORKSHOP_STATE_FILE)); let last_selected = memory_path.as_deref().map(load_memory).unwrap_or_default(); Self { - sender: broadcast::channel(MENU_CHANNEL_CAPACITY).0, - latest: Arc::new(Mutex::new(None)), + bus: RetainedBus::new(MENU_CHANNEL_CAPACITY), state: Arc::new(Mutex::new(MenuState { profiles: Vec::new(), active: None, @@ -177,18 +177,13 @@ impl MenuBus { /// Subscribes to every snapshot published from this call onward. pub(crate) fn subscribe(&self) -> broadcast::Receiver { - self.sender.subscribe() + self.bus.subscribe() } /// The most recently published snapshot, retained so a session /// connecting later can send the current menu as its snapshot. pub(crate) fn latest(&self) -> Option { - // A lock poisoned by a panicking peer recovers the value rather - // than wedging the process (the crate's zone-two error policy). - self.latest - .lock() - .unwrap_or_else(PoisonError::into_inner) - .clone() + self.bus.latest() } /// Selects `id` as the chat model and publishes a fresh snapshot, @@ -365,13 +360,7 @@ impl MenuBus { /// Broadcasts one snapshot. With no subscribers this is a no-op; a /// slow subscriber skips ahead rather than applying backpressure. fn send(&self, snapshot: WorkbenchSnapshot) { - // The retained copy (a second owner, hence the clone) is written - // before the send, so a session that subscribes after the send - // still finds this snapshot. - *self.latest.lock().unwrap_or_else(PoisonError::into_inner) = Some(snapshot.clone()); - // A send only fails when there are no receivers, which is the - // bus's resting state before the first client connects. - let _ = self.sender.send(snapshot); + self.bus.send(snapshot); } /// Whether `id` names a model in the current catalog snapshot. @@ -467,7 +456,7 @@ fn store_pending(pending: Option) { /// [`WORKSHOP_STATE_FILE`]. A failed write costs the memory, not the /// process (zone two): logged and tolerated. fn store_memory(pending: &PendingWrite) { - if let Err(error) = crate::atomic::write_atomic(&pending.path, &pending.bytes) { + if let Err(error) = workshop_support::write_atomic(&pending.path, &pending.bytes) { tracing::warn!( %error, path = %pending.path.display(), diff --git a/crates/workshop-server/src/progress.rs b/crates/workshop-server/src/progress.rs index 039642541..9c21adb4d 100644 --- a/crates/workshop-server/src/progress.rs +++ b/crates/workshop-server/src/progress.rs @@ -20,8 +20,8 @@ use tokio::time::Instant; use shared_progress::{ProgressHub, ProgressMeter}; -use crate::protocol::Activity; use crate::push::Push; +use workshop_protocol::Activity; /// How long an operation must be live before the indicator appears; work /// shorter than this never disturbs the status bar. @@ -218,8 +218,8 @@ mod tests { use crate::catalog::CatalogBus; use crate::menu::MenuBus; - use crate::protocol::{Progress, Severity, StatusBarUpdate}; use crate::status::StatusBus; + use workshop_protocol::{Progress, Severity, StatusBarUpdate}; /// A hub, a push handle over fresh buses, and the status receiver the /// renderer's frames land on (the push.rs wired() pattern). diff --git a/crates/workshop-server/src/protocol.rs b/crates/workshop-server/src/protocol.rs deleted file mode 100644 index ff7b6edad..000000000 --- a/crates/workshop-server/src/protocol.rs +++ /dev/null @@ -1,945 +0,0 @@ -//! The wire protocol of the workshop sockets: every JSON frame the server -//! exchanges with the UI, typed in one place, with zero I/O. -//! -//! Frames are grouped by direction - inbound (client to server) first, -//! outbound (server to client) second. Nothing here touches a socket, a -//! task, or a clock, so every wire shape is pinned by a plain unit test -//! below. The TypeScript half of this contract is -//! `ui/src/services/protocol.ts`; the two files cross-cite each other so a -//! shape change touches both or neither. The agent-session frame family is -//! additionally pinned by the shared fixture -//! `tests/fixtures/agent-frames.json`, asserted as the same JSON by the -//! fixture test below and by the SPA suite's -//! `ui/test/agent-wire-fixtures.mjs`, so drift on either side fails that -//! side's tests. The wire shapes are additionally frozen end to end by the -//! characterization tests in `tests/it`. -//! -//! # Inbound workshop-socket frames -//! -//! `{"type":"select_model","model":"..."}` selects the chat model: the -//! menu validates the id against the retained catalog and publishes a -//! fresh [`WorkbenchFrame`] on success; an unknown model is refused -//! with an `error` frame. `{"type":"switch_profile","name":"..."}` -//! starts a gateway profile switch: the pending snapshot publishes -//! immediately, stage progress arrives as [`StatusFrame`]s, and the -//! settled menu publishes a final [`WorkbenchFrame`] and a -//! [`CatalogFrame`]; a switch requested while one runs is refused with -//! an `error` frame. Both events may carry an optional `id`, echoed on -//! the `error` frame that refuses them. -//! -//! No inbound frame is pushed by the server, so none takes a delivery -//! classification; the reply frames they trigger are classified below. -//! -//! # Agent-session frames -//! -//! The `/agents/ws` socket speaks its own frame family. On connect the -//! server pushes [`AgentsFrame`], the discovered agent list. The client -//! opens a session with `{"type":"launch","agent":"..."}` or reattaches -//! with `{"type":"attach","session":"..."}`; either is answered with -//! [`AgentSessionFrame`] naming the session id. A running session streams -//! [`AgentEventFrame`]s - the durable event log, each frame carrying its -//! log index, replayed from the top on attach - and [`AgentDeltaFrame`]s, -//! the ephemeral live chunks, each stamped with the `reply` id of the -//! durable event that will supersede it. `{"type":"cancel"}` fires the -//! session's turn-cancel: cancellation is a stop reason, never an error - -//! no error frame follows, pending waits die as `input_cancelled`, and -//! the relaunched agent returns to waiting. Frames already in flight -//! from the cancelled run may still arrive between the cancel and the -//! relaunch: a defined grace window, absorbed by the reply-id -//! coalescing (the cancelled round never settles, so its deltas fall to -//! the round that eventually does), never a protocol violation. -//! -//! A session-level failure is pushed as an id-less [`ErrorFrame`]: a -//! model round that failed while the program survived it (the built-in -//! chat `pcall`s `models.chat` and returns to waiting), or a run that -//! ended in error. Delivery on this socket: ephemeral - the reports ride -//! a bounded broadcast beside the deltas and may drop under lag; the -//! durable transcript already shows the failed turn as one without a -//! reply, and terminal failures also land on the status bus. -//! -//! # Agent-session input frames -//! -//! An agent session asks its operator for input through the Workshop's -//! `user_input` tool. Three frames carry that conversation: the server -//! pushes [`InputFrame::Required`] when a wait opens and -//! [`InputFrame::Cancelled`] when one dies unresolved, and the client -//! answers with an `input_response` frame parsed as [`InputResponse`]. -//! Both pushed frames are durable: the wait registry retains every -//! unresolved wait and the session resends it on reconnect, so a push -//! lost to a dead socket is repaired by the resent set - a live wait -//! reappears, and a stale prompt is dropped because its token is absent. -//! Cancellation is an explicit outcome, never silence: every path out of -//! an unresolved wait pushes `input_cancelled` for its token. The session -//! loops that route these frames arrive with agent sessions; the shapes -//! and classification are pinned here first. -//! -//! # Delivery contract -//! -//! Every frame the server pushes carries exactly one of two delivery -//! semantics. The session loops are built on this classification, so no -//! pushed frame type ships unclassified. -//! -//! **Durable** frames are delivered exactly, and coalesce. Where the -//! data is shared fan-out state, the producer records it and wakes each -//! connection loop through a `Notify`; the loop compares the shared -//! revision against its own per-client cursor and sends everything past -//! the cursor, so a missed wakeup is harmless because the next one -//! delivers everything past the cursor. A durable frame that answers -//! the connection's own request (a `launch` acknowledgment) is sent -//! directly by the loop that owns the socket, which delivers exactly -//! without any cursor - no shared state exists for a cursor to index. -//! -//! **Ephemeral** frames may drop under lag. They ride bounded channels -//! (a broadcast where the state fans out); a client too slow to drain -//! its channel lags out and its connection may drop. The drop is -//! harmless because every ephemeral frame has a repair path that owes -//! nothing to its predecessors: status and catalog are complete -//! snapshots resent on reconnect. -//! -//! ## Classification -//! -//! Workshop socket (`/ws`): -//! -//! - [`ErrorFrame`] - durable on this socket. The direct reply refusing -//! a malformed frame or a menu event, sent by the loop that owns the -//! socket - the contract's no-cursor case. -//! - [`StatusFrame`] - ephemeral. Every update is a complete snapshot of -//! the bar, so a lagging client loses nothing by skipping -//! intermediates, and the current status is resent on reconnect. -//! - [`CatalogFrame`] - ephemeral. Each push carries the whole catalog -//! verbatim; the newest push supersedes every older one and the -//! catalog is resent on reconnect. -//! - [`WorkbenchFrame`] - ephemeral. Every push is a complete snapshot -//! of the server-owned Model-menu state, retained and resent on -//! reconnect, exactly like the catalog frame. The connect-time send - -//! the retained snapshot follows the status and catalog snapshots on -//! every new session, so the UI boots with zero HTTP state fetches - -//! is that resend promise, not a third delivery class. -//! -use serde::{Deserialize, Serialize}; - -// --- Agent-session frames -------------------------------------------------- - -/// The agent list pushed when an `/agents/ws` socket connects: -/// `{"type":"agents","agents":["chat","research"]}`. -/// -/// Delivery: ephemeral - every push is the complete discovered list, -/// resent on every connect; there is no incremental form to lose. -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub(crate) struct AgentsFrame { - #[serde(rename = "type")] - kind: &'static str, - /// The launchable agent names, in discovery order. - agents: Vec, -} - -impl AgentsFrame { - /// Builds the list frame over the discovered agent names. - pub(crate) fn new(agents: Vec) -> Self { - Self { - kind: "agents", - agents, - } - } -} - -/// The direct reply to a `launch` or `attach` frame: -/// `{"type":"agent_session","session":"...","agent":"..."}`. The client -/// keeps the session id to reattach after a disconnect - sessions -/// outlive sockets. -/// -/// Delivery: durable - a direct per-request reply sent by the loop that -/// owns the socket, the contract's no-cursor case. -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub(crate) struct AgentSessionFrame { - #[serde(rename = "type")] - kind: &'static str, - /// The session's unguessable id. - session: String, - /// The launched agent's name. - agent: String, -} - -impl AgentSessionFrame { - /// Builds the acknowledgment for `session` running `agent`. - pub(crate) fn new(session: String, agent: String) -> Self { - Self { - kind: "agent_session", - session, - agent, - } - } -} - -/// One durable entry of an agent session's event log: -/// `{"type":"agent_event","index":N,"event":{...}}` plus, on the -/// model-round content kinds (`agent_thought`, `agent_message`, -/// `tool_call`), the `reply` id that coalesces the round's ephemeral -/// deltas away (see [`AgentDeltaFrame`]). -/// -/// Delivery: durable - `index` is the entry's position in the session's -/// event log, the per-client cursor recovers everything past it on -/// reconnect, and a future `replayFrom` cursor rides the same field. -#[derive(Debug, Clone, PartialEq, Serialize)] -pub(crate) struct AgentEventFrame { - #[serde(rename = "type")] - kind: &'static str, - /// The entry's log index. - index: u64, - /// The reply id this event settles, present on the model-round - /// content kinds and omitted elsewhere. - #[serde(skip_serializing_if = "Option::is_none")] - reply: Option, - /// The logged entry, in its persisted vocabulary shape. - event: promptforge_core_support::events::RuntimeEvent, -} - -impl AgentEventFrame { - /// Builds the frame for the entry at `index`. - pub(crate) fn new( - index: u64, - reply: Option, - event: promptforge_core_support::events::RuntimeEvent, - ) -> Self { - Self { - kind: "agent_event", - index, - reply, - event, - } - } -} - -/// Which streaming side channel one agent delta belongs to. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] -#[serde(rename_all = "lowercase")] -pub(crate) enum AgentDeltaKind { - /// Answer content, superseded by the round's `agent_message` event. - Text, - /// Reasoning content, superseded by the round's `agent_thought` - /// event. - Reasoning, -} - -/// One live streaming chunk of an agent's model round: -/// `{"type":"agent_delta","kind":"text","content":"...","reply":N}`. -/// -/// Every delta is stamped with the `reply` id of the durable event that -/// will supersede it, so the SPA coalesces chunks by that id and replaces -/// them when the event arrives (the ACP messageId chunk-vs-upsert rule). -/// -/// Delivery: ephemeral - deltas ride a bounded broadcast and may drop -/// under lag; the completed-reply event is the repair path, which is why -/// agent deltas never enter the event log. -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub(crate) struct AgentDeltaFrame { - #[serde(rename = "type")] - kind: &'static str, - /// Which side channel the chunk belongs to. - #[serde(rename = "kind")] - channel: AgentDeltaKind, - /// The chunk's text. - content: String, - /// The id of the durable event that will supersede this delta. - reply: u64, -} - -impl AgentDeltaFrame { - /// Builds a delta frame carrying `content` on `channel`, stamped with - /// the superseding `reply` id. - pub(crate) fn new(channel: AgentDeltaKind, content: String, reply: u64) -> Self { - Self { - kind: "agent_delta", - channel, - content, - reply, - } - } -} - -// --- Agent-session input frames ------------------------------------------- - -/// A pushed user-input lifecycle frame on an agent session's socket. -/// -/// `{"type":"input_required","token":"..."}` announces an open wait: the -/// SPA pins its input box to the token and answers with an -/// `input_response` frame. `{"type":"input_cancelled","token":"..."}` -/// announces a wait that died unresolved, so the SPA never holds a -/// prompt against a dead token - cancellation is an outcome on the wire, -/// never silence. -/// -/// Delivery: durable - the [`WaitRegistry`](crate::WaitRegistry) retains -/// every unresolved wait and the session resends it on reconnect, so a -/// push lost to a dead socket is repaired by the resent set: a live wait -/// reappears, and a cancelled one vanishes by its absence. -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -#[serde(tag = "type")] -pub enum InputFrame { - /// A wait opened: the session wants operator input for `token`. - #[serde(rename = "input_required")] - Required { - /// The single-use wait token an `input_response` must echo. - token: String, - }, - /// A wait died unresolved: the prompt for `token` is stale. - #[serde(rename = "input_cancelled")] - Cancelled { - /// The token whose wait is gone. - token: String, - }, -} - -/// The inbound answer to an [`InputFrame::Required`] prompt: -/// `{"type":"input_response","token":"...","text":"..."}`. -/// -/// The session routes on the envelope's `type` and deserializes the body -/// with serde, which ignores the envelope tag itself. `text` is the -/// operator's input, byte-exact as typed. Like every inbound frame it -/// takes no delivery classification, because the server pushes none. -#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] -pub struct InputResponse { - /// The wait token this response answers. - pub token: String, - /// The operator's text, byte-exact as typed. - pub text: String, -} - -// --- Outbound: server to client ------------------------------------------ - -/// One status bar update: what the bar should show right now. -/// -/// Every update is a complete snapshot, so a lagging receiver loses nothing -/// by skipping intermediates. `label` is the short text rendered in the -/// status bar; `description` is the longer tooltip shown on hover. -#[derive(Debug, Clone, PartialEq, Serialize)] -pub struct StatusBarUpdate { - /// Short text rendered in the status bar. - pub label: String, - /// Longer text shown as the bar's tooltip. - pub description: String, - /// Determinate progress, when the activity can report it. - pub progress: Option, - /// How loudly the update speaks; the UI ignores `Debug` updates. - pub severity: Severity, - /// Which subsystem is active, driving the bar's activity indicator. - pub activity: Activity, -} - -impl StatusBarUpdate { - /// The update as a wire frame: its own fields plus `"type": "status"`. - pub(crate) fn frame(&self) -> StatusFrame<'_> { - StatusFrame { - kind: "status", - update: self, - } - } -} - -/// A determinate progress report for the status bar's progress slot. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] -pub struct Progress { - /// Units completed so far. - pub current: u64, - /// Units expected in total. - pub total: u64, -} - -/// How loudly a status update speaks. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] -#[serde(rename_all = "lowercase")] -pub enum Severity { - /// User-visible status text. - Info, - /// Internal instrumentation; the UI ignores it for display. - Debug, - /// A failure the user should see. - Error, -} - -/// The subsystem an update belongs to, driving the activity indicator. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] -#[serde(rename_all = "lowercase")] -pub enum Activity { - /// No specific subsystem; the activity LED stays dark. - General, - /// A model turn in flight: amber on the activity LED. - Thinking, - /// Output tokens arriving: green on the activity LED. - Generating, -} - -/// The serialized shape of one update on the socket: the update's fields -/// flattened beside `"type": "status"`, matching the workshop protocol's frame -/// taxonomy. -/// -/// Delivery: ephemeral - every update is a complete snapshot, so a -/// lagging client skips intermediates and the current status is resent -/// on reconnect. -#[derive(Debug, Serialize)] -pub(crate) struct StatusFrame<'a> { - #[serde(rename = "type")] - kind: &'static str, - #[serde(flatten)] - update: &'a StatusBarUpdate, -} - -/// One pushed model catalog. -#[derive(Debug, Clone, PartialEq)] -pub(crate) struct CatalogPush { - /// The chat-capable subset of the gateway's model array. - pub(crate) models: Vec, -} - -impl CatalogPush { - /// The push as a wire frame: `"type": "models"` beside the array. - pub(crate) fn frame(&self) -> CatalogFrame<'_> { - CatalogFrame { - kind: "models", - models: &self.models, - } - } -} - -/// The serialized shape of a catalog push on the socket, matching the -/// workshop protocol's frame taxonomy. -/// -/// Delivery: ephemeral - the newest push carries the whole catalog and -/// supersedes every older one; the catalog is resent on reconnect. -#[derive(Debug, Serialize)] -pub(crate) struct CatalogFrame<'a> { - #[serde(rename = "type")] - kind: &'static str, - models: &'a [serde_json::Value], -} - -/// One pushed workbench snapshot: the server-owned Model-menu state. -/// -/// The server computes `chat_ready` - a chat-capable model available, one -/// selected, no switch in flight, gateway reachable - and the UI never -/// derives it. -#[derive(Debug, Clone, PartialEq)] -pub(crate) struct WorkbenchSnapshot { - /// Every gateway profile name, in gateway order. - pub(crate) profiles: Vec, - /// The profile the gateway is serving, once known. - pub(crate) active: Option, - /// The profile a switch is loading, while one is in flight. - pub(crate) switching: Option, - /// The model chat requests go to, once one is selected. - pub(crate) selected_model: Option, - /// Whether a chat can be submitted right now. - pub(crate) chat_ready: bool, -} - -impl WorkbenchSnapshot { - /// The snapshot as a wire frame: `"type": "workbench"` beside the - /// fields, with `selected_model` shortened to `selected` on the wire. - pub(crate) fn frame(&self) -> WorkbenchFrame<'_> { - WorkbenchFrame { - kind: "workbench", - profiles: &self.profiles, - active: self.active.as_deref(), - switching: self.switching.as_deref(), - selected: self.selected_model.as_deref(), - chat_ready: self.chat_ready, - } - } -} - -/// The serialized shape of a workbench push on the socket, matching the -/// workshop protocol's frame taxonomy. Absent options serialize as `null`, -/// never as omitted keys: every push is the complete menu state. -/// -/// Delivery: ephemeral - every push is a complete snapshot of the menu -/// state, retained and resent on reconnect, exactly like the catalog -/// frame. -#[derive(Debug, Serialize)] -pub(crate) struct WorkbenchFrame<'a> { - #[serde(rename = "type")] - kind: &'static str, - profiles: &'a [String], - active: Option<&'a str>, - switching: Option<&'a str>, - selected: Option<&'a str>, - chat_ready: bool, -} - -/// A failure report answered to one inbound frame - a malformed frame, a -/// refused menu event, or an agent-session failure: -/// `{"type":"error","message":"..."}` plus the echoed request `id`. -/// -/// Delivery: durable on the workshop socket - a direct per-request reply -/// sent by the loop that owns the socket. The agent-session socket -/// additionally pushes id-less error frames for session-level failures; -/// that delivery is ephemeral and documented in the agent-session -/// section above. -#[derive(Debug, Serialize)] -pub(crate) struct ErrorFrame { - #[serde(rename = "type")] - kind: &'static str, - message: String, - /// The request's `id`, echoed verbatim when it carried one and omitted - /// from the wire when it did not. - #[serde(skip_serializing_if = "Option::is_none")] - id: Option, -} - -impl ErrorFrame { - /// Builds an error frame carrying `message`, echoing `id` when present. - pub(crate) fn new(message: String, id: Option<&serde_json::Value>) -> Self { - Self { - kind: "error", - message, - id: id.cloned(), - } - } -} - -// Every test below pins one frame's wire shape against the exact JSON -// literal the pre-refactor code built with `serde_json::json!`, so a field -// rename, retype, or optionality change fails here before it reaches a -// socket. -#[cfg(test)] -mod tests { - use super::*; - - /// Builds a minimal update with the given label. - fn stub(label: impl Into) -> StatusBarUpdate { - StatusBarUpdate { - label: label.into(), - description: String::new(), - progress: None, - severity: Severity::Info, - activity: Activity::General, - } - } - - #[test] - fn a_status_update_serializes_as_a_status_frame() { - let frame = serde_json::to_value(stub("Ready").frame()).expect("the frame serializes"); - assert_eq!( - frame, - serde_json::json!({ - "type": "status", - "label": "Ready", - "description": "", - "progress": null, - "severity": "info", - "activity": "general", - }), - "the wire shape matches the workshop protocol's frame taxonomy" - ); - } - - #[test] - fn progress_and_the_remaining_variants_serialize() { - let update = StatusBarUpdate { - progress: Some(Progress { - current: 1, - total: 2, - }), - severity: Severity::Error, - activity: Activity::Thinking, - ..stub("Working") - }; - let frame = serde_json::to_value(update.frame()).expect("the frame serializes"); - assert_eq!( - frame["progress"], - serde_json::json!({"current": 1, "total": 2}) - ); - assert_eq!(frame["severity"], "error"); - assert_eq!(frame["activity"], "thinking"); - // Debug serializes too; the UI, not the bus, ignores it. - let debug = serde_json::to_value( - StatusBarUpdate { - severity: Severity::Debug, - activity: Activity::Generating, - ..stub("x") - } - .frame(), - ) - .expect("the frame serializes"); - assert_eq!(debug["severity"], "debug"); - assert_eq!(debug["activity"], "generating"); - } - - #[test] - fn a_catalog_push_serializes_as_a_models_frame() { - let push = CatalogPush { - models: vec![serde_json::json!({"id": "test-model", "object": "model"})], - }; - let frame = serde_json::to_value(push.frame()).expect("the frame serializes"); - assert_eq!( - frame, - serde_json::json!({ - "type": "models", - "models": [{"id": "test-model", "object": "model"}], - }), - "the wire shape matches the workshop protocol's frame taxonomy" - ); - } - - #[test] - fn a_workbench_snapshot_serializes_as_a_workbench_frame() { - let snapshot = WorkbenchSnapshot { - profiles: vec!["main".to_string(), "coding".to_string()], - active: Some("main".to_string()), - switching: None, - selected_model: Some("claude-sonnet-4-6".to_string()), - chat_ready: true, - }; - let frame = serde_json::to_value(snapshot.frame()).expect("the frame serializes"); - assert_eq!( - frame, - serde_json::json!({ - "type": "workbench", - "profiles": ["main", "coding"], - "active": "main", - "switching": null, - "selected": "claude-sonnet-4-6", - "chat_ready": true, - }), - "the wire shape matches the workshop protocol's frame taxonomy" - ); - } - - #[test] - fn an_agents_frame_serializes_the_discovered_names() { - let frame = serde_json::to_value(AgentsFrame::new(vec![ - "chat".to_owned(), - "research".to_owned(), - ])) - .expect("the frame serializes"); - assert_eq!( - frame, - serde_json::json!({"type": "agents", "agents": ["chat", "research"]}), - "the wire shape matches the workshop protocol's frame taxonomy" - ); - } - - #[test] - fn an_agent_session_frame_serializes_its_id_and_agent() { - let frame = - serde_json::to_value(AgentSessionFrame::new("a1b2".to_owned(), "chat".to_owned())) - .expect("the frame serializes"); - assert_eq!( - frame, - serde_json::json!({"type": "agent_session", "session": "a1b2", "agent": "chat"}), - ); - } - - #[test] - fn an_agent_event_frame_carries_its_log_index_and_optional_reply_id() { - use promptforge_core_support::events::{RuntimeEvent, RuntimeEventKind}; - let event = RuntimeEvent { - kind: RuntimeEventKind::UserInput, - section: "chat".to_owned(), - chain_id: 0, - depth: 0, - turn: 0, - content: "hi".to_owned(), - model: None, - tool_call_id: None, - finish_reason: None, - metrics: None, - }; - let plain = serde_json::to_value(AgentEventFrame::new(3, None, event.clone())) - .expect("the frame serializes"); - assert_eq!(plain["type"], "agent_event"); - assert_eq!(plain["index"], 3, "the frame carries the entry's log index"); - assert!( - plain.get("reply").is_none(), - "an absent reply id is omitted from the wire, not serialized as null" - ); - assert_eq!( - plain["event"], - serde_json::to_value(&event).expect("events serialize"), - "the entry rides in its persisted vocabulary shape" - ); - let stamped = serde_json::to_value(AgentEventFrame::new(4, Some(1), event)) - .expect("the frame serializes"); - assert_eq!( - stamped["reply"], 1, - "a superseding event is stamped with the reply id its deltas carried" - ); - } - - #[test] - fn an_agent_delta_frame_is_stamped_with_its_superseding_reply_id() { - let text = serde_json::to_value(AgentDeltaFrame::new( - AgentDeltaKind::Text, - "po".to_owned(), - 2, - )) - .expect("the frame serializes"); - assert_eq!( - text, - serde_json::json!({"type": "agent_delta", "kind": "text", "content": "po", "reply": 2}), - ); - let reasoning = serde_json::to_value(AgentDeltaFrame::new( - AgentDeltaKind::Reasoning, - "hmm".to_owned(), - 2, - )) - .expect("the frame serializes"); - assert_eq!( - reasoning, - serde_json::json!({ - "type": "agent_delta", "kind": "reasoning", "content": "hmm", "reply": 2, - }), - ); - } - - #[test] - fn an_input_required_frame_serializes_with_its_token() { - let frame = serde_json::to_value(InputFrame::Required { - token: "a1b2c3".to_owned(), - }) - .expect("the frame serializes"); - assert_eq!( - frame, - serde_json::json!({"type": "input_required", "token": "a1b2c3"}), - "the wire shape matches the workshop protocol's frame taxonomy" - ); - } - - #[test] - fn an_input_cancelled_frame_serializes_with_its_token() { - let frame = serde_json::to_value(InputFrame::Cancelled { - token: "a1b2c3".to_owned(), - }) - .expect("the frame serializes"); - assert_eq!( - frame, - serde_json::json!({"type": "input_cancelled", "token": "a1b2c3"}), - "the wire shape matches the workshop protocol's frame taxonomy" - ); - } - - #[test] - fn an_input_response_parses_its_body_byte_exact_ignoring_the_envelope() { - let gnarly = "line1\r\nline2 \"quoted\" {\"text\":\"decoy\"} \\slash 🦀"; - let response: InputResponse = serde_json::from_value(serde_json::json!({ - "type": "input_response", - "token": "a1b2c3", - "text": gnarly, - })) - .expect("the frame parses with its envelope tag present"); - assert_eq!(response.token, "a1b2c3"); - assert_eq!( - response.text, gnarly, - "the operator's text survives the wire byte-exact" - ); - } - - /// The shared agent-frame fixture, asserted as the same JSON by the SPA - /// suite (`ui/test/agent-wire-fixtures.mjs`): a wire drift on either - /// side fails that side's fixture test. - const AGENT_FRAME_FIXTURE: &str = include_str!("../tests/fixtures/agent-frames.json"); - - /// Parses the shared fixture into one object keyed by case name. - fn agent_fixture() -> serde_json::Value { - serde_json::from_str(AGENT_FRAME_FIXTURE).expect("the fixture is valid JSON") - } - - /// The fixture's `agent_event_minimal` entry as the vocabulary type. - fn minimal_fixture_event() -> promptforge_core_support::events::RuntimeEvent { - use promptforge_core_support::events::{RuntimeEvent, RuntimeEventKind}; - RuntimeEvent { - kind: RuntimeEventKind::UserInput, - section: "chat".to_owned(), - chain_id: 0, - depth: 0, - turn: 0, - content: "hi".to_owned(), - model: None, - tool_call_id: None, - finish_reason: None, - metrics: None, - } - } - - /// The fixture's `agent_event_stamped` entry as the vocabulary type, - /// every metrics section populated. - fn stamped_fixture_event() -> promptforge_core_support::events::RuntimeEvent { - use promptforge_core_support::events::{ - CallMetrics, ClientTiming, LlamaTimings, RuntimeEvent, RuntimeEventKind, Usage, - VllmMetrics, - }; - RuntimeEvent { - kind: RuntimeEventKind::AssistantReply, - section: "chat".to_owned(), - chain_id: 1, - depth: 0, - turn: 2, - content: "hello".to_owned(), - model: Some("llama-3".to_owned()), - tool_call_id: None, - finish_reason: Some("stop".to_owned()), - metrics: Some(CallMetrics { - usage: Some(Usage { - prompt_tokens: 7, - completion_tokens: 3, - total_tokens: 10, - cached_tokens: Some(2), - reasoning_tokens: Some(1), - }), - llama: Some(LlamaTimings { - prompt_n: 7, - prompt_ms: 12.5, - prompt_per_second: 560.0, - predicted_n: 3, - predicted_ms: 30.5, - predicted_per_second: 98.5, - draft_n: 4, - draft_n_accepted: 2, - }), - vllm: Some(VllmMetrics { - time_to_first_token_ms: Some(8.5), - generation_time_ms: Some(22.5), - queue_time_ms: Some(1.5), - mean_itl_ms: Some(7.5), - tokens_per_second: Some(133.5), - }), - client: Some(ClientTiming { - ttft_ms: Some(9.5), - mean_itl_ms: Some(8.25), - e2e_ms: 41.5, - }), - }), - } - } - - #[test] - fn the_shared_fixture_pins_exactly_the_agreed_case_list() { - let fixture = agent_fixture(); - let mut cases: Vec<&str> = fixture - .as_object() - .expect("the fixture is one object keyed by case name") - .keys() - .map(String::as_str) - .collect(); - cases.sort_unstable(); - assert_eq!( - cases, - [ - "agent_delta_reasoning", - "agent_delta_text", - "agent_event_minimal", - "agent_event_stamped", - "agent_session", - "agents", - "attach", - "cancel", - "input_cancelled", - "input_required", - "input_response", - "launch", - ], - "both suites pin exactly the same case list, so a case added on \ - one side fails the other" - ); - } - - #[test] - fn server_to_client_agent_frames_match_the_shared_fixture() { - // Each typed frame serializes to its fixture entry, compared as - // values so key order in the file is free. - let fixture = agent_fixture(); - assert_eq!( - serde_json::to_value(AgentsFrame::new(vec![ - "chat".to_owned(), - "research".to_owned() - ])) - .expect("the frame serializes"), - fixture["agents"] - ); - assert_eq!( - serde_json::to_value(AgentSessionFrame::new("a1b2".to_owned(), "chat".to_owned())) - .expect("the frame serializes"), - fixture["agent_session"] - ); - assert_eq!( - serde_json::to_value(AgentEventFrame::new(3, None, minimal_fixture_event())) - .expect("the frame serializes"), - fixture["agent_event_minimal"] - ); - assert_eq!( - serde_json::to_value(AgentEventFrame::new(4, Some(1), stamped_fixture_event())) - .expect("the frame serializes"), - fixture["agent_event_stamped"], - "the event rides in its persisted vocabulary shape, metrics and all" - ); - assert_eq!( - serde_json::to_value(AgentDeltaFrame::new( - AgentDeltaKind::Text, - "po".to_owned(), - 2 - )) - .expect("the frame serializes"), - fixture["agent_delta_text"] - ); - assert_eq!( - serde_json::to_value(AgentDeltaFrame::new( - AgentDeltaKind::Reasoning, - "hmm".to_owned(), - 2 - )) - .expect("the frame serializes"), - fixture["agent_delta_reasoning"] - ); - assert_eq!( - serde_json::to_value(InputFrame::Required { - token: "a1b2c3".to_owned(), - }) - .expect("the frame serializes"), - fixture["input_required"] - ); - assert_eq!( - serde_json::to_value(InputFrame::Cancelled { - token: "a1b2c3".to_owned(), - }) - .expect("the frame serializes"), - fixture["input_cancelled"] - ); - } - - #[test] - fn client_to_server_agent_frames_match_the_shared_fixture() { - // `input_response` parses through its typed body; `launch`, - // `attach`, and `cancel` are routed from raw JSON in - // `session_agents::socket`, so the fixture pins exactly the fields - // that routing reads. - let fixture = agent_fixture(); - let response: InputResponse = serde_json::from_value(fixture["input_response"].clone()) - .expect("the fixture input_response parses"); - assert_eq!(response.token, "a1b2c3"); - assert_eq!(response.text, "two words"); - assert_eq!(fixture["launch"]["type"], "launch"); - assert_eq!(fixture["launch"]["agent"], "chat"); - assert_eq!(fixture["attach"]["type"], "attach"); - assert_eq!(fixture["attach"]["session"], "a1b2"); - assert_eq!(fixture["cancel"]["type"], "cancel"); - } - - #[test] - fn an_error_frame_serializes_with_and_without_the_echoed_id() { - let untagged = - serde_json::to_value(ErrorFrame::new("Gateway unreachable".to_string(), None)) - .expect("the frame serializes"); - assert_eq!( - untagged, - serde_json::json!({"type": "error", "message": "Gateway unreachable"}) - ); - let id = serde_json::json!(7); - let tagged = serde_json::to_value(ErrorFrame::new( - "Gateway unreachable".to_string(), - Some(&id), - )) - .expect("the frame serializes"); - assert_eq!( - tagged, - serde_json::json!({"type": "error", "message": "Gateway unreachable", "id": 7}) - ); - } -} diff --git a/crates/workshop-server/src/push.rs b/crates/workshop-server/src/push.rs index 586bd6bef..88e843241 100644 --- a/crates/workshop-server/src/push.rs +++ b/crates/workshop-server/src/push.rs @@ -8,15 +8,15 @@ //! catalog; workbench producers drive the Model-menu mutators through //! [`Push::menu`], and every mutation publishes its own snapshot. What //! each intent becomes on the wire -//! is decided here and in [`crate::protocol`], nowhere else. The buses +//! is decided here and in `workshop-protocol`, nowhere else. The buses //! stay the transport: every `/ws` session subscribes on //! [`crate::status::StatusBus`], [`crate::catalog::CatalogBus`], and //! [`crate::menu::MenuBus`] and serializes what it receives. use crate::catalog::CatalogBus; use crate::menu::MenuBus; -use crate::protocol::{Activity, Progress}; use crate::status::StatusBus; +use workshop_protocol::{Activity, Progress}; /// The intent-named push handle over the status, catalog, and menu buses. /// @@ -75,8 +75,8 @@ impl Push { } /// Pushes determinate progress - `current` of `total` units done: a - /// `{"type":"status",...}` [`crate::protocol::StatusFrame`] at - /// [`Severity::Info`](crate::protocol::Severity::Info) carrying a + /// `{"type":"status",...}` `StatusFrame` at + /// [`Severity::Info`](workshop_protocol::Severity::Info) carrying a /// [`Progress`], which the status bar renders as its progress bar. pub(crate) fn push_progress( &self, @@ -97,7 +97,7 @@ impl Push { } /// Pushes one complete model catalog snapshot: a `{"type":"models",...}` - /// [`crate::protocol::CatalogFrame`] carrying only chat-capable + /// `CatalogFrame` carrying only chat-capable /// entries. The single choke point for catalog publishes: the menu /// revalidates its selection against the new catalog and republishes /// the workbench snapshot when it changed. @@ -120,8 +120,7 @@ mod tests { use tokio::sync::broadcast; - use crate::protocol::{CatalogPush, Severity, StatusBarUpdate}; - + use workshop_protocol::{CatalogPush, Severity, StatusBarUpdate}; /// A push handle plus one receiver on the status and catalog buses. fn wired() -> ( Push, diff --git a/crates/workshop-server/src/relay.rs b/crates/workshop-server/src/relay.rs index 32ccd50ff..a643bcbab 100644 --- a/crates/workshop-server/src/relay.rs +++ b/crates/workshop-server/src/relay.rs @@ -8,8 +8,8 @@ use axum::response::{IntoResponse, Response}; use crate::app::AppState; use crate::error::AppError; use crate::gateway::{GatewayError, GatewayResponse}; -use crate::protocol::Activity; use crate::push::Push; +use workshop_protocol::Activity; /// Relays the gateway's model catalog to the caller verbatim. /// diff --git a/crates/workshop-server/src/resolve.rs b/crates/workshop-server/src/resolve.rs index 753b4f9b0..537b6aa2f 100644 --- a/crates/workshop-server/src/resolve.rs +++ b/crates/workshop-server/src/resolve.rs @@ -12,8 +12,9 @@ use std::path::Path; use shared_sidecar::{Resolution, SidecarError, StaleReason, ValidatedConnection}; -use crate::config::GatewayConfig; -use crate::protocol::Activity; +use workshop_protocol::Activity; +use workshop_support::GatewayConfig; + use crate::push::Push; /// The gateway endpoint state construction connects to, and how it was diff --git a/crates/workshop-server/src/routes/chat.rs b/crates/workshop-server/src/routes/chat.rs index 017ca5b3c..9796c360b 100644 --- a/crates/workshop-server/src/routes/chat.rs +++ b/crates/workshop-server/src/routes/chat.rs @@ -7,8 +7,8 @@ use axum::Router; use axum::routing::get; use crate::app::AppState; -use crate::deadline::{RELAY_DEADLINE, with_deadline}; use crate::{relay, session}; +use workshop_support::{RELAY_DEADLINE, with_deadline}; /// The relay routes. They take the whole [`AppState`]: the handlers reach /// the gateway client, the health flag, and the status and catalog buses. diff --git a/crates/workshop-server/src/routes/gateway_config.rs b/crates/workshop-server/src/routes/gateway_config.rs index f74f96c0c..a38dc1a0a 100644 --- a/crates/workshop-server/src/routes/gateway_config.rs +++ b/crates/workshop-server/src/routes/gateway_config.rs @@ -18,8 +18,8 @@ use axum::response::{IntoResponse, Response}; use axum::routing::{any, get}; use crate::app::AppState; -use crate::deadline::{DEFAULT_DEADLINE, with_deadline}; use crate::error::AppError; +use workshop_support::{DEFAULT_DEADLINE, with_deadline}; /// The gateway-config panel routes. The origin probe is local and /// instant, so it carries the default deadline; the forward route is diff --git a/crates/workshop-server/src/serve.rs b/crates/workshop-server/src/serve.rs index 40264bed3..a34cff9cb 100644 --- a/crates/workshop-server/src/serve.rs +++ b/crates/workshop-server/src/serve.rs @@ -15,12 +15,12 @@ use std::thread::JoinHandle; use std::time::Duration; use crate::app::{StateError, router, state_with_gateway}; -use crate::config::Config; use crate::gateway_binding::GatewayUpdater; use crate::gateway_progress; use crate::heartbeat; use crate::progress; use crate::resolve::ResolvedGateway; +use workshop_support::Config; /// How long a signaled shutdown waits for in-flight connections to drain /// before the watchdog abandons the graceful path. axum's drain waits on @@ -352,8 +352,7 @@ mod tests { use std::path::Path; - use crate::config::{AgentsConfig, GatewayConfig, ServerConfig}; - + use workshop_support::{AgentsConfig, GatewayConfig, ServerConfig}; fn test_config(bind: &str, state_dir: &Path) -> Config { Config { gateway: GatewayConfig { diff --git a/crates/workshop-server/src/session.rs b/crates/workshop-server/src/session.rs index fde3ebccd..8230b8eb4 100644 --- a/crates/workshop-server/src/session.rs +++ b/crates/workshop-server/src/session.rs @@ -27,10 +27,15 @@ //! [`crate::catalog`], and workbench snapshots from [`crate::menu`] flow //! as they publish. On connect the session first sends the retained //! status, catalog, and workbench snapshots, honoring the delivery -//! contract's resend promise (see [`crate::protocol`]) - the UI boots +//! contract's resend promise (see `workshop-protocol`) - the UI boots //! from this socket alone, with zero HTTP state fetches; after that the //! buses forward as they publish, and a session too slow to drain them //! skips ahead to the newest snapshot rather than slowing the producers. +//! +//! The status channel is reached through the subsystem registry +//! ([`AppState::registry`]), not named directly: the status bus is the +//! proof-of-concept self-registrant, and an unregistered slot degrades +//! the session to no status frames rather than failing it. mod log; mod menu; @@ -43,10 +48,11 @@ use axum::http::HeaderMap; use axum::response::{IntoResponse, Response}; use tokio::sync::broadcast; +use workshop_protocol::ErrorFrame; + use crate::app::AppState; use crate::cross_site; use crate::error::AppError; -use crate::protocol::ErrorFrame; use self::log::SessionLog; use self::menu::{select_model, start_switch}; @@ -78,8 +84,11 @@ async fn run_session(mut socket: WebSocket, state: AppState) { // Subscribe before snapshotting, so an update emitted between the two // arrives at least once; the possible duplicate is harmless because - // status and catalog frames are complete snapshots. - let mut status_rx = state.status().subscribe(); + // status and catalog frames are complete snapshots. The status + // channel comes from the registry's slot: unregistered is a graceful + // no-op, so the branch below pends forever instead of failing. + let status = state.registry().status_channel(); + let mut status_rx = status.as_ref().map(|channel| channel.subscribe()); let mut catalog_rx = state.catalog().subscribe(); let mut menu_rx = state.menu().subscribe(); // The delivery contract resends the current status, catalog, and @@ -88,8 +97,10 @@ async fn run_session(mut socket: WebSocket, state: AppState) { // The status line is the one exception: a retained heartbeat transition // ("Connected to gateway") describes a past moment, so the join line is // recomputed from the current probe instead of replayed stale. - if let Some(update) = crate::heartbeat::join_status(state.status().latest(), state.health()) - && !send_frame(&mut socket, &update.frame()).await + if let Some(update) = crate::heartbeat::join_status( + status.as_ref().and_then(|channel| channel.latest()), + state.health(), + ) && !send_frame(&mut socket, &update.frame()).await { return; } @@ -120,7 +131,14 @@ async fn run_session(mut socket: WebSocket, state: AppState) { // skips ahead to the retained window, which is a resync // because every status and catalog frame is a complete // snapshot. - received = status_rx.recv(), if status_open => match received { + received = async { + match status_rx.as_mut() { + Some(rx) => rx.recv().await, + // The unregistered slot: a graceful no-op that never + // fires, so the branch simply never runs. + None => std::future::pending().await, + } + }, if status_open => match received { Ok(update) => { if !send_frame(&mut socket, &update.frame()).await { break; diff --git a/crates/workshop-server/src/session/menu.rs b/crates/workshop-server/src/session/menu.rs index eb522bccb..af9e6e5d7 100644 --- a/crates/workshop-server/src/session/menu.rs +++ b/crates/workshop-server/src/session/menu.rs @@ -13,9 +13,9 @@ use crate::gateway::{ }; use crate::heartbeat::{refresh_catalog, refresh_profiles}; use crate::menu::SwitchOutcome; -use crate::protocol::Activity; use crate::push::Push; use crate::relay::value_from_bytes; +use workshop_protocol::Activity; use super::send_error; diff --git a/crates/workshop-server/src/session_agents.rs b/crates/workshop-server/src/session_agents.rs index 10cb8b485..03da04c0b 100644 --- a/crates/workshop-server/src/session_agents.rs +++ b/crates/workshop-server/src/session_agents.rs @@ -44,13 +44,14 @@ use promptforge_model_client::client::StreamDelta; use promptforge_model_client::model::{ModelCatalog, ModelDescriptor, ModelId, ThinkingMode}; use tokio::sync::{broadcast, mpsc}; -use crate::backoff::ReconnectBackoff; +use workshop_protocol::{Activity, AgentDeltaKind, InputFrame, InputResponse}; +use workshop_support::ReconnectBackoff; + use crate::catalog::{CatalogBus, is_chat_capable}; use crate::gateway_binding::GatewayBinding; use crate::input::{WaitError, WaitRegistry, deliver_input_response_before_completion}; use crate::menu::MenuBus; use crate::observer::WorkshopObserver; -use crate::protocol::{Activity, AgentDeltaKind, InputFrame, InputResponse}; use crate::push::Push; use crate::workspace::Workspace; @@ -1030,7 +1031,7 @@ mod tests { .recv() .await .expect("the failed round pushes a terminal status"); - assert_eq!(update.severity, crate::protocol::Severity::Error); + assert_eq!(update.severity, workshop_protocol::Severity::Error); assert_eq!( update.activity, Activity::General, diff --git a/crates/workshop-server/src/session_agents/socket.rs b/crates/workshop-server/src/session_agents/socket.rs index f7df71916..5f9bd4bca 100644 --- a/crates/workshop-server/src/session_agents/socket.rs +++ b/crates/workshop-server/src/session_agents/socket.rs @@ -38,11 +38,11 @@ use crate::app::AppState; use crate::cross_site; use crate::error::AppError; use crate::input::WaitError; -use crate::protocol::{ +use crate::session::{send_error, send_frame}; +use workshop_protocol::{ Activity, AgentDeltaFrame, AgentEventFrame, AgentSessionFrame, AgentsFrame, ErrorFrame, InputFrame, InputResponse, }; -use crate::session::{send_error, send_frame}; use super::{AgentDelta, AgentSession, reply_stamp}; diff --git a/crates/workshop-server/src/session_agents/supervisor/effects.rs b/crates/workshop-server/src/session_agents/supervisor/effects.rs index a1be766b6..5aa7f8ad0 100644 --- a/crates/workshop-server/src/session_agents/supervisor/effects.rs +++ b/crates/workshop-server/src/session_agents/supervisor/effects.rs @@ -15,7 +15,7 @@ use shared_vfs::VfsRef; use crate::catalog::ChatCatalog; use crate::gateway_binding::GatewaySnapshot; use crate::input::SessionInputBroker; -use crate::protocol::Activity; +use workshop_protocol::Activity; use super::events::{CollectedEvent, EventCollector, RunFuture}; use super::transition::{ diff --git a/crates/workshop-server/src/status.rs b/crates/workshop-server/src/status.rs index dacc1ba57..8f403b98e 100644 --- a/crates/workshop-server/src/status.rs +++ b/crates/workshop-server/src/status.rs @@ -3,8 +3,8 @@ //! //! Anything with user-visible latency - startup phases, gateway round //! trips, dictation and transcription, model downloads - reports what -//! it is doing as a [`StatusBarUpdate`]. The bus is a tokio broadcast -//! channel: updates fan out to all current subscribers, a send with no +//! it is doing as a [`StatusBarUpdate`]. The bus is a [`RetainedBus`]: +//! updates fan out to all current subscribers, a send with no //! subscribers is a no-op, and a subscriber that falls more than //! [`STATUS_CHANNEL_CAPACITY`] updates behind is told it lagged and resumes //! at the oldest retained update. Sending never blocks, so instrumenting a @@ -16,11 +16,10 @@ //! sends the current status immediately - the delivery contract's //! resend-on-reconnect for ephemeral frames. -use std::sync::{Arc, Mutex, PoisonError}; - use tokio::sync::broadcast; -use crate::protocol::{Activity, Progress, Severity, StatusBarUpdate}; +use workshop_protocol::{Activity, Progress, Severity, StatusBarUpdate}; +use workshop_support::RetainedBus; /// Ring capacity of the status bus. Covers a startup burst plus an agent /// turn's phase transitions with headroom; a receiver lagging past it @@ -33,45 +32,32 @@ const STATUS_CHANNEL_CAPACITY: usize = 64; /// channel, so subsystems take their own copy rather than a reference. #[derive(Debug, Clone)] pub struct StatusBus { - sender: broadcast::Sender, - latest: Arc>>, + bus: RetainedBus, } impl StatusBus { /// Creates a bus with no subscribers, an empty ring, and no snapshot. pub(crate) fn new() -> Self { Self { - sender: broadcast::channel(STATUS_CHANNEL_CAPACITY).0, - latest: Arc::new(Mutex::new(None)), + bus: RetainedBus::new(STATUS_CHANNEL_CAPACITY), } } /// Subscribes to every update sent from this call onward. pub(crate) fn subscribe(&self) -> broadcast::Receiver { - self.sender.subscribe() + self.bus.subscribe() } /// The most recently emitted update, retained so a session connecting /// later can send the current status as its snapshot. pub(crate) fn latest(&self) -> Option { - // A lock poisoned by a panicking peer recovers the value rather - // than wedging the process (the crate's zone-two error policy). - self.latest - .lock() - .unwrap_or_else(PoisonError::into_inner) - .clone() + self.bus.latest() } /// Broadcasts one update. With no subscribers this is a no-op; a slow /// subscriber skips ahead rather than applying backpressure. pub fn emit(&self, update: StatusBarUpdate) { - // The retained copy (a second owner, hence the clone) is written - // before the send, so a session that subscribes after the send - // still finds this update as its snapshot. - *self.latest.lock().unwrap_or_else(PoisonError::into_inner) = Some(update.clone()); - // A send only fails when there are no receivers, which is the bus's - // resting state before the first client connects. - let _ = self.sender.send(update); + self.bus.send(update); } /// Broadcasts one progress-free update at the given severity. diff --git a/crates/workshop-server/src/workspace.rs b/crates/workshop-server/src/workspace.rs index 8e174f3f5..2fa79b165 100644 --- a/crates/workshop-server/src/workspace.rs +++ b/crates/workshop-server/src/workspace.rs @@ -353,7 +353,7 @@ impl Workspace { Err(source) if source.kind() == io::ErrorKind::NotFound => {} Err(source) => return Err(WorkspaceError::InspectPath { source }), } - crate::atomic::write_atomic(&canonical, text.as_bytes()) + workshop_support::write_atomic(&canonical, text.as_bytes()) .map_err(|source| WorkspaceError::WriteFile { source })?; let metadata = fs::metadata(&canonical).map_err(|source| WorkspaceError::InspectPath { source })?; diff --git a/crates/workshop-server/ui/src/services/protocol.ts b/crates/workshop-server/ui/src/services/protocol.ts index 5c4f8837a..c1f15acd6 100644 --- a/crates/workshop-server/ui/src/services/protocol.ts +++ b/crates/workshop-server/ui/src/services/protocol.ts @@ -3,12 +3,12 @@ // Types only - the socket logic that sends and routes these frames stays // in workshop-socket.ts and agent-socket.ts. The // Rust half of this contract is -// crates/workshop-server/src/protocol.rs; the two files +// crates/workshop-protocol/src; the two files // cross-cite each other so a shape change touches both or neither. The // agent-session frame family is additionally pinned by the shared fixture -// crates/workshop-server/tests/fixtures/agent-frames.json, +// crates/workshop-protocol/tests/fixtures/agent-frames.json, // asserted as the same JSON by both suites (test/agent-wire-fixtures.mjs -// here, the protocol.rs fixture test there), so drift on either side fails +// here, the workshop-protocol fixture test there), so drift on either side fails // that side's tests. /** One observer status update, as sent by the server. */ diff --git a/crates/workshop-server/ui/test/agent-wire-fixtures.mjs b/crates/workshop-server/ui/test/agent-wire-fixtures.mjs index 7330ec9ec..f63584200 100644 --- a/crates/workshop-server/ui/test/agent-wire-fixtures.mjs +++ b/crates/workshop-server/ui/test/agent-wire-fixtures.mjs @@ -1,9 +1,10 @@ // The TS half of the agent-frame wire contract: every frame in the shared -// fixture crates/workshop-server/tests/fixtures/agent-frames.json +// fixture crates/workshop-protocol/tests/fixtures/agent-frames.json // routes through AgentSocket unchanged (server-to-client), and every frame // the socket sends matches its fixture entry byte-for-byte as parsed JSON // (client-to-server). The Rust half is the fixture test in -// src/protocol.rs; both suites pin the same case list, so a wire drift or +// crates/workshop-protocol/tests/it/fixture.rs; both suites pin the same +// case list, so a wire drift or // a case added on one side fails the other. // Run: node test/agent-wire-fixtures.mjs import { readFile, writeFile } from "node:fs/promises"; @@ -38,7 +39,10 @@ await writeFile(bundlePath, bundle.outputFiles[0].text); const { lifecycle, AgentSocket } = await import(pathToFileURL(bundlePath).href); const fixture = JSON.parse( - await readFile(path.join(testDir, "..", "..", "tests", "fixtures", "agent-frames.json"), "utf8"), + await readFile( + path.join(testDir, "..", "..", "..", "workshop-protocol", "tests", "fixtures", "agent-frames.json"), + "utf8", + ), ); const failures = []; diff --git a/crates/workshop-support/Cargo.toml b/crates/workshop-support/Cargo.toml new file mode 100644 index 000000000..e9c49a122 --- /dev/null +++ b/crates/workshop-support/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "workshop-support" +version = "0.0.0" +publish = false +edition.workspace = true +license.workspace = true +repository.workspace = true + +description = "Workshop support vocabulary: atomic writes, reconnect backoff, route deadlines, workshop.toml configuration, and the retained broadcast bus" + +[features] +default = [] +# Exposes test seams (`ReconnectBackoff::is_escalated_for_test`) to the +# dependent crates' integration tests. +test-fixtures = [] + +[dependencies] +axum.workspace = true +serde.workspace = true +thiserror.workspace = true +tokio.workspace = true +toml.workspace = true +tracing.workspace = true + +[dev-dependencies] +tempfile.workspace = true +tokio = { workspace = true, features = ["test-util"] } +tower.workspace = true + +[lints] +workspace = true diff --git a/crates/workshop-server/src/atomic.rs b/crates/workshop-support/src/atomic.rs similarity index 98% rename from crates/workshop-server/src/atomic.rs rename to crates/workshop-support/src/atomic.rs index 46e40756a..63ae9928f 100644 --- a/crates/workshop-server/src/atomic.rs +++ b/crates/workshop-support/src/atomic.rs @@ -45,7 +45,7 @@ fn temp_path(path: &Path) -> Option { /// Returns [`io::ErrorKind::InvalidInput`] when `path` has no file name, /// and otherwise the underlying I/O error when the create, write, sync, /// or rename fails. -pub(crate) fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> { +pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> { let Some(temp) = temp_path(path) else { return Err(io::Error::new( io::ErrorKind::InvalidInput, @@ -72,7 +72,7 @@ pub(crate) fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> { /// simply never written there. Every other failure - an unreadable /// directory or entry, an unremovable file - is logged and tolerated: /// the sweep is cleanup and must never cost startup. -pub(crate) fn sweep_orphaned_temps(dir: &Path) { +pub fn sweep_orphaned_temps(dir: &Path) { let entries = match fs::read_dir(dir) { Ok(entries) => entries, Err(error) => { diff --git a/crates/workshop-server/src/backoff.rs b/crates/workshop-support/src/backoff.rs similarity index 97% rename from crates/workshop-server/src/backoff.rs rename to crates/workshop-support/src/backoff.rs index c8c902d5d..62beb7dd7 100644 --- a/crates/workshop-server/src/backoff.rs +++ b/crates/workshop-support/src/backoff.rs @@ -67,7 +67,8 @@ struct State { impl ReconnectBackoff { /// A backoff on the production schedule. - pub(crate) fn new() -> Self { + #[must_use] + pub fn new() -> Self { Self::with_schedule(BASE_DELAY, MAX_DELAY, TOTAL_DELAY_BUDGET) } @@ -123,7 +124,7 @@ impl ReconnectBackoff { /// only when the gateway does useful work - a delivered streaming /// token or a successful buffered completion - never on a probe that /// merely connects. - pub(crate) fn record_useful_work(&self) { + pub fn record_useful_work(&self) { let mut state = self.lock_state(); state.current = self.base; state.spent = Duration::ZERO; @@ -145,11 +146,17 @@ impl ReconnectBackoff { } } +impl Default for ReconnectBackoff { + fn default() -> Self { + Self::new() + } +} + /// xorshift64: a tiny deterministic generator; jitter needs spread, not /// cryptography, and this keeps the dependency tree unchanged. Shared /// with the gateway tests, which seed it explicitly so each randomized /// failure names its seed. -pub(crate) fn xorshift(state: &mut u64) -> u64 { +pub fn xorshift(state: &mut u64) -> u64 { *state ^= *state << 13; *state ^= *state >> 7; *state ^= *state << 17; diff --git a/crates/workshop-support/src/bus.rs b/crates/workshop-support/src/bus.rs new file mode 100644 index 000000000..31767cdda --- /dev/null +++ b/crates/workshop-support/src/bus.rs @@ -0,0 +1,153 @@ +//! The retained broadcast bus: the generic transport pattern behind the +//! workshop server's status, catalog, and menu buses. +//! +//! A [`RetainedBus`] is a tokio broadcast channel plus a retained copy of +//! the newest value: sends fan out to all current subscribers, a send +//! with no subscribers is a no-op, and a subscriber that falls more than +//! the ring capacity behind is told it lagged and resumes at the oldest +//! retained value. Sending never blocks, so instrumenting a hot path +//! cannot stall the subsystem it observes. The retained newest value is +//! the resend-on-reconnect snapshot: a session that connects later reads +//! it directly instead of waiting for the next publish. +//! +//! The three hand-rolled copies of this pattern (status, catalog, menu) +//! are thin wrappers over this one type; each wrapper owns its ring +//! capacity and its intent-named helper methods. + +use std::sync::{Arc, Mutex, PoisonError}; + +use tokio::sync::broadcast; + +/// A broadcast bus retaining the newest value sent: a cloneable handle +/// onto the channel and the snapshot. +/// +/// Clones are cheap (two `Arc` bumps) and all of them send into the same +/// channel, so subsystems take their own copy rather than a reference. +#[derive(Debug, Clone)] +pub struct RetainedBus { + sender: broadcast::Sender, + latest: Arc>>, +} + +impl RetainedBus { + /// Creates a bus with the given ring capacity, no subscribers, and no + /// snapshot. A receiver lagging past `capacity` skips ahead rather + /// than slowing the senders. + #[must_use] + pub fn new(capacity: usize) -> Self { + Self { + sender: broadcast::channel(capacity).0, + latest: Arc::new(Mutex::new(None)), + } + } + + /// Subscribes to every value sent from this call onward. + #[must_use] + pub fn subscribe(&self) -> broadcast::Receiver { + self.sender.subscribe() + } + + /// The most recently sent value, retained so a consumer connecting + /// later can take the current state as its snapshot. + #[must_use] + pub fn latest(&self) -> Option { + // A lock poisoned by a panicking peer recovers the value rather + // than wedging the process (the zone-two error policy). + self.latest + .lock() + .unwrap_or_else(PoisonError::into_inner) + .clone() + } + + /// Retains `value` as the newest snapshot, then broadcasts it. With + /// no subscribers the broadcast is a no-op and only the snapshot + /// moves; a slow subscriber skips ahead rather than applying + /// backpressure. + pub fn send(&self, value: T) { + // The retained copy (a second owner, hence the clone) is written + // before the send, so a consumer that subscribes after the send + // still finds this value as its snapshot. + *self.latest.lock().unwrap_or_else(PoisonError::into_inner) = Some(value.clone()); + // A send only fails when there are no receivers, which is the + // bus's resting state before the first client connects. + let _ = self.sender.send(value); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn sending_with_no_subscribers_is_a_no_op() { + let bus = RetainedBus::new(4); + bus.send("one".to_string()); + } + + #[test] + fn the_newest_value_is_retained_for_the_connect_snapshot() { + let bus = RetainedBus::new(4); + assert!(bus.latest().is_none(), "an untouched bus has no snapshot"); + bus.send(1); + bus.send(2); + assert_eq!( + bus.latest(), + Some(2), + "a consumer connecting now snapshots the newest value" + ); + } + + #[test] + fn the_snapshot_moves_before_the_send_so_a_late_subscriber_sees_it() { + let bus = RetainedBus::new(4); + bus.send(7); + let _late = bus.subscribe(); + assert_eq!( + bus.latest(), + Some(7), + "the retained copy is written even when the send has no receivers" + ); + } + + #[tokio::test] + async fn a_lagged_receiver_skips_ahead_instead_of_blocking() { + let capacity = 4; + let bus = RetainedBus::new(capacity); + let mut receiver = bus.subscribe(); + for index in 0..capacity + 10 { + // Sends never block, however far behind the receiver is. + bus.send(index); + } + let lag = match receiver.recv().await { + Err(broadcast::error::RecvError::Lagged(skipped)) => skipped, + Ok(got) => panic!("expected a lag report, got {got:?}"), + Err(broadcast::error::RecvError::Closed) => panic!("the bus is still open"), + }; + assert_eq!(lag, 10, "the ring retained only its capacity"); + let resumed = receiver.recv().await.expect("the ring still holds values"); + assert_eq!( + resumed, 10, + "receiving resumes at the oldest retained value" + ); + } + + #[tokio::test] + async fn clones_send_into_one_channel_and_share_one_snapshot() { + let bus = RetainedBus::new(4); + let clone = bus.clone(); + let mut receiver = bus.subscribe(); + clone.send("through the clone".to_string()); + assert_eq!( + receiver + .recv() + .await + .expect("the clone's send reaches the bus"), + "through the clone" + ); + assert_eq!( + bus.latest().as_deref(), + Some("through the clone"), + "the clone's send moves the shared snapshot" + ); + } +} diff --git a/crates/workshop-server/src/config.rs b/crates/workshop-support/src/config.rs similarity index 56% rename from crates/workshop-server/src/config.rs rename to crates/workshop-support/src/config.rs index 92be00074..a647f5e8c 100644 --- a/crates/workshop-server/src/config.rs +++ b/crates/workshop-support/src/config.rs @@ -13,6 +13,9 @@ use std::path::{Path, PathBuf}; +/// Address the workshop server binds to when no override is given. +pub const DEFAULT_ADDR: &str = "127.0.0.1:7910"; + /// Path [`Config::load`] reads when no override is given. pub const DEFAULT_CONFIG_PATH: &str = "workshop.toml"; @@ -66,11 +69,11 @@ impl Config { /// /// # Examples /// ``` - /// let config = workshop_server::Config::from_toml_str( + /// let config = workshop_support::Config::from_toml_str( /// "[gateway]\nbase_url = \"http://127.0.0.1:8081\"\napi_key = \"k\"\n", /// )?; /// assert_eq!(config.server.bind, "127.0.0.1:7910"); - /// # Ok::<(), workshop_server::ConfigError>(()) + /// # Ok::<(), workshop_support::ConfigError>(()) /// ``` pub fn from_toml_str(raw: &str) -> Result { Self::parse(raw, None) @@ -149,7 +152,7 @@ pub struct ServerConfig { impl Default for ServerConfig { fn default() -> Self { Self { - bind: crate::DEFAULT_ADDR.to_string(), + bind: DEFAULT_ADDR.to_string(), open_browser: false, state_dir: PathBuf::new(), } @@ -295,248 +298,3 @@ fn interpolate_value(value: &mut toml::Value) -> Result<(), ConfigError> { } Ok(()) } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parses_fixture_and_interpolates_from_environment() { - let path_value = std::env::var("PATH").expect("PATH is set on every supported platform"); - let raw = r#" -[gateway] -base_url = "http://127.0.0.1:8081" -api_key = "${PATH}" -"#; - let config = Config::from_toml_str(raw).expect("fixture parses"); - assert_eq!(config.gateway.base_url, "http://127.0.0.1:8081"); - assert_eq!(config.gateway.api_key, path_value); - } - - #[test] - fn defaults_fill_server() { - let raw = r#" -[gateway] -base_url = "http://127.0.0.1:8081" -api_key = "k" -"#; - let config = Config::from_toml_str(raw).expect("fixture parses"); - assert_eq!(config.server.bind, "127.0.0.1:7910"); - } - - #[test] - fn removed_voice_section_is_rejected() { - let raw = r#" -[gateway] -base_url = "http://127.0.0.1:8081" -api_key = "k" - -[voice] -interim_model = "old.bin" -"#; - assert!( - matches!(Config::from_toml_str(raw), Err(ConfigError::Parse { .. })), - "workshop voice ownership was removed instead of silently ignored" - ); - } - - #[test] - fn explicit_sections_override_defaults() { - let raw = r#" -[gateway] -base_url = "http://127.0.0.1:8081" -api_key = "k" - -[server] -bind = "127.0.0.1:9000" -"#; - let config = Config::from_toml_str(raw).expect("fixture parses"); - assert_eq!(config.server.bind, "127.0.0.1:9000"); - } - - #[test] - fn path_defaults_anchor_beside_the_config_file() { - let dir = tempfile::TempDir::new().expect("tempdir"); - let path = dir.path().join("workshop.toml"); - std::fs::write( - &path, - "[gateway]\nbase_url = \"http://127.0.0.1:8081\"\napi_key = \"k\"\n", - ) - .expect("write fixture"); - let config = Config::load(&path).expect("fixture loads"); - assert_eq!( - config.server.state_dir, - dir.path(), - "an absent state_dir is the config file's directory" - ); - assert_eq!( - config.agents.path, - dir.path().join("agents"), - "an absent agents path is agents/ beside the config file" - ); - - // Without a file, the anchor degrades to the working directory. - let raw = "[gateway]\nbase_url = \"http://x\"\napi_key = \"k\"\n"; - let config = Config::from_toml_str(raw).expect("fixture parses"); - assert_eq!(config.server.state_dir, PathBuf::from(".")); - assert_eq!(config.agents.path, Path::new(".").join("agents")); - } - - #[test] - fn explicit_state_dir_and_agents_path_are_kept_verbatim() { - let dir = tempfile::TempDir::new().expect("tempdir"); - let path = dir.path().join("workshop.toml"); - std::fs::write( - &path, - "[gateway]\nbase_url = \"http://x\"\napi_key = \"k\"\n\n\ - [server]\nstate_dir = \"state\"\n\n[agents]\npath = \"my-agents\"\n", - ) - .expect("write fixture"); - let config = Config::load(&path).expect("fixture loads"); - assert_eq!( - config.server.state_dir, - PathBuf::from("state"), - "an explicit state_dir is not re-anchored" - ); - assert_eq!( - config.agents.path, - PathBuf::from("my-agents"), - "an explicit agents path is not re-anchored" - ); - } - - #[test] - fn open_browser_defaults_to_false_and_parses_when_set() { - let raw = r#" -[gateway] -base_url = "http://127.0.0.1:8081" -api_key = "k" -"#; - let config = Config::from_toml_str(raw).expect("fixture parses"); - assert!(!config.server.open_browser, "default is off"); - - let raw = r#" -[gateway] -base_url = "http://127.0.0.1:8081" -api_key = "k" - -[server] -open_browser = true -"#; - let config = Config::from_toml_str(raw).expect("fixture parses"); - assert!(config.server.open_browser); - assert_eq!(config.server.bind, "127.0.0.1:7910", "bind still defaults"); - } - - #[test] - fn double_dollar_is_literal() { - let raw = "[gateway]\nbase_url = \"http://x\"\napi_key = \"cost $$5\"\n"; - let config = Config::from_toml_str(raw).expect("fixture parses"); - assert_eq!(config.gateway.api_key, "cost $5"); - } - - #[test] - fn unset_variable_interpolates_to_empty() { - let raw = - "[gateway]\nbase_url = \"http://x\"\napi_key = \"${PFG_WB_DEFINITELY_UNSET_XYZ}\"\n"; - let config = Config::from_toml_str(raw).expect("unset variable resolves to empty"); - assert_eq!(config.gateway.api_key, ""); - } - - #[test] - fn an_empty_base_url_is_kept_as_the_not_explicit_signal() { - let raw = "[gateway]\nbase_url = \"${PFG_WB_DEFINITELY_UNSET_XYZ}\"\napi_key = \"k\"\n"; - let config = Config::from_toml_str(raw).expect("fixture parses"); - assert_eq!( - config.gateway.base_url, "", - "no default is filled: endpoint resolution reads empty as not explicit" - ); - } - - #[test] - fn explicit_base_url_is_kept() { - let raw = "[gateway]\nbase_url = \"http://gw:9999\"\napi_key = \"k\"\n"; - let config = Config::from_toml_str(raw).expect("fixture parses"); - assert_eq!(config.gateway.base_url, "http://gw:9999"); - } - - #[test] - fn unclosed_interpolation_is_an_error() { - let raw = "[gateway]\nbase_url = \"http://x\"\napi_key = \"${UNCLOSED\"\n"; - let err = Config::from_toml_str(raw).expect_err("unclosed interpolation must fail"); - assert!( - matches!(err, ConfigError::Interpolation(_)), - "expected Interpolation, got {err:?}" - ); - } - - #[test] - fn missing_gateway_section_is_an_error() { - let err = Config::from_toml_str("[server]\nbind = \"127.0.0.1:9000\"\n") - .expect_err("gateway section is required"); - assert!( - matches!(err, ConfigError::Parse { .. }), - "expected Parse, got {err:?}" - ); - } - - #[test] - fn missing_file_names_the_expected_path() { - let err = Config::load(Path::new("definitely-missing-workshop.toml")) - .expect_err("missing file must fail"); - assert!( - matches!(err, ConfigError::NotFound { .. }), - "expected NotFound, got {err:?}" - ); - assert!( - err.to_string().contains("definitely-missing-workshop.toml"), - "error names the path: {err}" - ); - } - - #[test] - fn unreadable_existing_file_is_a_read_error() { - let dir = tempfile::TempDir::new().expect("tempdir"); - let path = dir.path().join("workshop.toml"); - std::fs::write(&path, "[gateway]\n").expect("write fixture"); - // A directory-shaped read failure: replace the file with a - // directory of the same name so the read fails for a reason other - // than NotFound. - std::fs::remove_file(&path).expect("remove fixture"); - std::fs::create_dir(&path).expect("directory in the file's place"); - let err = Config::load(&path).expect_err("unreadable path must fail"); - assert!( - matches!(err, ConfigError::Read { .. }), - "expected Read, got {err:?}" - ); - } - - #[test] - fn parse_error_names_the_file() { - let dir = tempfile::TempDir::new().expect("tempdir"); - let path = dir.path().join("broken-workshop.toml"); - std::fs::write(&path, "[gateway\n").expect("write fixture"); - let err = Config::load(&path).expect_err("malformed TOML must fail"); - assert!( - matches!(err, ConfigError::Parse { .. }), - "expected Parse, got {err:?}" - ); - assert!( - err.to_string().contains("broken-workshop.toml"), - "error names the path: {err}" - ); - } - - #[test] - fn load_reads_and_parses_a_file() { - let dir = tempfile::TempDir::new().expect("tempdir"); - let path = dir.path().join("workshop.toml"); - std::fs::write( - &path, - "[gateway]\nbase_url = \"http://127.0.0.1:8081\"\napi_key = \"k\"\n", - ) - .expect("write fixture"); - let config = Config::load(&path).expect("fixture loads"); - assert_eq!(config.gateway.api_key, "k"); - } -} diff --git a/crates/workshop-server/src/deadline.rs b/crates/workshop-support/src/deadline.rs similarity index 85% rename from crates/workshop-server/src/deadline.rs rename to crates/workshop-support/src/deadline.rs index 9d42de75a..14773db09 100644 --- a/crates/workshop-server/src/deadline.rs +++ b/crates/workshop-support/src/deadline.rs @@ -13,13 +13,13 @@ use axum::response::IntoResponse; /// Deadline for ordinary HTTP routes: local, fast work that should never /// run long. A response not produced in time answers 408. -pub(crate) const DEFAULT_DEADLINE: Duration = Duration::from_secs(10); +pub const DEFAULT_DEADLINE: Duration = Duration::from_secs(10); /// Deadline for routes that relay a buffered gateway call: longer than the /// gateway client's own request timeout, so a stalled gateway surfaces as /// the relay's 502 with its failure shape rather than a blunt 408 from the /// route deadline. -pub(crate) const RELAY_DEADLINE: Duration = Duration::from_secs(35); +pub const RELAY_DEADLINE: Duration = Duration::from_secs(35); /// Bounds every route already in `router` on `limit`: a response not /// produced by the deadline is abandoned and answered with 408 instead. @@ -27,7 +27,7 @@ pub(crate) const RELAY_DEADLINE: Duration = Duration::from_secs(35); /// The WebSocket upgrade routes are deliberately left outside this layer /// by their feature modules: an upgrade answers immediately and the /// session then lives as long as the client stays connected. -pub(crate) fn with_deadline(router: Router, limit: Duration) -> Router +pub fn with_deadline(router: Router, limit: Duration) -> Router where S: Clone + Send + Sync + 'static, { @@ -53,7 +53,12 @@ mod tests { use axum::routing::get; use tower::ServiceExt; - use crate::app::fixtures::body_bytes; + /// Collects a response body already buffered in memory. + async fn body_bytes(response: axum::response::Response) -> axum::body::Bytes { + axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("the body is in memory already") + } #[tokio::test(start_paused = true)] async fn a_stalled_route_answers_408_at_its_deadline() { @@ -100,13 +105,4 @@ mod tests { assert_eq!(response.status(), StatusCode::OK); assert_eq!(&body_bytes(response).await[..], b"ok"); } - - #[test] - fn the_relay_deadline_outlasts_the_gateway_request_timeout() { - assert!( - RELAY_DEADLINE > crate::gateway::REQUEST_TIMEOUT, - "the route deadline must let the gateway client time out first, \ - so the caller sees the relay's 502 rather than a blunt 408" - ); - } } diff --git a/crates/workshop-support/src/lib.rs b/crates/workshop-support/src/lib.rs new file mode 100644 index 000000000..41bfc317c --- /dev/null +++ b/crates/workshop-support/src/lib.rs @@ -0,0 +1,29 @@ +//! workshop-support - the workshop server's support vocabulary: +//! crash-safe atomic writes, the gateway reconnect backoff, route +//! deadline tiers, `workshop.toml` configuration, and the generic +//! retained broadcast bus the status, catalog, and menu buses are thin +//! wrappers over. +//! +//! ## Invariants +//! +//! - Tier: vocabulary; may depend on: no internal `workshop-*` crates. +//! Read `AGENTS.md` before adding an import. +//! - Every file in this crate stays under 500 lines; split first, then +//! edit. +//! - A lock poisoned by a panicking peer recovers the value rather than +//! wedging the process (the zone-two error policy). + +mod atomic; +mod backoff; +mod bus; +mod config; +mod deadline; + +pub use atomic::{sweep_orphaned_temps, write_atomic}; +pub use backoff::{ReconnectBackoff, xorshift}; +pub use bus::RetainedBus; +pub use config::{ + AgentsConfig, Config, ConfigError, DEFAULT_ADDR, DEFAULT_CONFIG_PATH, GatewayConfig, + ServerConfig, +}; +pub use deadline::{DEFAULT_DEADLINE, RELAY_DEADLINE, with_deadline}; diff --git a/crates/workshop-support/tests/it/config.rs b/crates/workshop-support/tests/it/config.rs new file mode 100644 index 000000000..40dc63430 --- /dev/null +++ b/crates/workshop-support/tests/it/config.rs @@ -0,0 +1,245 @@ +//! `workshop.toml` loading tests: parsing, interpolation, and defaults, +//! driven through the public API. + +use std::path::{Path, PathBuf}; + +use workshop_support::{Config, ConfigError}; + +#[test] +fn parses_fixture_and_interpolates_from_environment() { + let path_value = std::env::var("PATH").expect("PATH is set on every supported platform"); + let raw = r#" +[gateway] +base_url = "http://127.0.0.1:8081" +api_key = "${PATH}" +"#; + let config = Config::from_toml_str(raw).expect("fixture parses"); + assert_eq!(config.gateway.base_url, "http://127.0.0.1:8081"); + assert_eq!(config.gateway.api_key, path_value); +} + +#[test] +fn defaults_fill_server() { + let raw = r#" +[gateway] +base_url = "http://127.0.0.1:8081" +api_key = "k" +"#; + let config = Config::from_toml_str(raw).expect("fixture parses"); + assert_eq!(config.server.bind, "127.0.0.1:7910"); +} + +#[test] +fn removed_voice_section_is_rejected() { + let raw = r#" +[gateway] +base_url = "http://127.0.0.1:8081" +api_key = "k" + +[voice] +interim_model = "old.bin" +"#; + assert!( + matches!(Config::from_toml_str(raw), Err(ConfigError::Parse { .. })), + "workshop voice ownership was removed instead of silently ignored" + ); +} + +#[test] +fn explicit_sections_override_defaults() { + let raw = r#" +[gateway] +base_url = "http://127.0.0.1:8081" +api_key = "k" + +[server] +bind = "127.0.0.1:9000" +"#; + let config = Config::from_toml_str(raw).expect("fixture parses"); + assert_eq!(config.server.bind, "127.0.0.1:9000"); +} + +#[test] +fn path_defaults_anchor_beside_the_config_file() { + let dir = tempfile::TempDir::new().expect("tempdir"); + let path = dir.path().join("workshop.toml"); + std::fs::write( + &path, + "[gateway]\nbase_url = \"http://127.0.0.1:8081\"\napi_key = \"k\"\n", + ) + .expect("write fixture"); + let config = Config::load(&path).expect("fixture loads"); + assert_eq!( + config.server.state_dir, + dir.path(), + "an absent state_dir is the config file's directory" + ); + assert_eq!( + config.agents.path, + dir.path().join("agents"), + "an absent agents path is agents/ beside the config file" + ); + + // Without a file, the anchor degrades to the working directory. + let raw = "[gateway]\nbase_url = \"http://x\"\napi_key = \"k\"\n"; + let config = Config::from_toml_str(raw).expect("fixture parses"); + assert_eq!(config.server.state_dir, PathBuf::from(".")); + assert_eq!(config.agents.path, Path::new(".").join("agents")); +} + +#[test] +fn explicit_state_dir_and_agents_path_are_kept_verbatim() { + let dir = tempfile::TempDir::new().expect("tempdir"); + let path = dir.path().join("workshop.toml"); + std::fs::write( + &path, + "[gateway]\nbase_url = \"http://x\"\napi_key = \"k\"\n\n\ + [server]\nstate_dir = \"state\"\n\n[agents]\npath = \"my-agents\"\n", + ) + .expect("write fixture"); + let config = Config::load(&path).expect("fixture loads"); + assert_eq!( + config.server.state_dir, + PathBuf::from("state"), + "an explicit state_dir is not re-anchored" + ); + assert_eq!( + config.agents.path, + PathBuf::from("my-agents"), + "an explicit agents path is not re-anchored" + ); +} + +#[test] +fn open_browser_defaults_to_false_and_parses_when_set() { + let raw = r#" +[gateway] +base_url = "http://127.0.0.1:8081" +api_key = "k" +"#; + let config = Config::from_toml_str(raw).expect("fixture parses"); + assert!(!config.server.open_browser, "default is off"); + + let raw = r#" +[gateway] +base_url = "http://127.0.0.1:8081" +api_key = "k" + +[server] +open_browser = true +"#; + let config = Config::from_toml_str(raw).expect("fixture parses"); + assert!(config.server.open_browser); + assert_eq!(config.server.bind, "127.0.0.1:7910", "bind still defaults"); +} + +#[test] +fn double_dollar_is_literal() { + let raw = "[gateway]\nbase_url = \"http://x\"\napi_key = \"cost $$5\"\n"; + let config = Config::from_toml_str(raw).expect("fixture parses"); + assert_eq!(config.gateway.api_key, "cost $5"); +} + +#[test] +fn unset_variable_interpolates_to_empty() { + let raw = "[gateway]\nbase_url = \"http://x\"\napi_key = \"${PFG_WB_DEFINITELY_UNSET_XYZ}\"\n"; + let config = Config::from_toml_str(raw).expect("unset variable resolves to empty"); + assert_eq!(config.gateway.api_key, ""); +} + +#[test] +fn an_empty_base_url_is_kept_as_the_not_explicit_signal() { + let raw = "[gateway]\nbase_url = \"${PFG_WB_DEFINITELY_UNSET_XYZ}\"\napi_key = \"k\"\n"; + let config = Config::from_toml_str(raw).expect("fixture parses"); + assert_eq!( + config.gateway.base_url, "", + "no default is filled: endpoint resolution reads empty as not explicit" + ); +} + +#[test] +fn explicit_base_url_is_kept() { + let raw = "[gateway]\nbase_url = \"http://gw:9999\"\napi_key = \"k\"\n"; + let config = Config::from_toml_str(raw).expect("fixture parses"); + assert_eq!(config.gateway.base_url, "http://gw:9999"); +} + +#[test] +fn unclosed_interpolation_is_an_error() { + let raw = "[gateway]\nbase_url = \"http://x\"\napi_key = \"${UNCLOSED\"\n"; + let err = Config::from_toml_str(raw).expect_err("unclosed interpolation must fail"); + assert!( + err.to_string().starts_with("interpolation: "), + "expected Interpolation, got {err:?}" + ); +} + +#[test] +fn missing_gateway_section_is_an_error() { + let err = Config::from_toml_str("[server]\nbind = \"127.0.0.1:9000\"\n") + .expect_err("gateway section is required"); + assert!( + matches!(err, ConfigError::Parse { .. }), + "expected Parse, got {err:?}" + ); +} + +#[test] +fn missing_file_names_the_expected_path() { + let err = Config::load(Path::new("definitely-missing-workshop.toml")) + .expect_err("missing file must fail"); + assert!( + matches!(err, ConfigError::NotFound { .. }), + "expected NotFound, got {err:?}" + ); + assert!( + err.to_string().contains("definitely-missing-workshop.toml"), + "error names the path: {err}" + ); +} + +#[test] +fn unreadable_existing_file_is_a_read_error() { + let dir = tempfile::TempDir::new().expect("tempdir"); + let path = dir.path().join("workshop.toml"); + std::fs::write(&path, "[gateway]\n").expect("write fixture"); + // A directory-shaped read failure: replace the file with a + // directory of the same name so the read fails for a reason other + // than NotFound. + std::fs::remove_file(&path).expect("remove fixture"); + std::fs::create_dir(&path).expect("directory in the file's place"); + let err = Config::load(&path).expect_err("unreadable path must fail"); + assert!( + matches!(err, ConfigError::Read { .. }), + "expected Read, got {err:?}" + ); +} + +#[test] +fn parse_error_names_the_file() { + let dir = tempfile::TempDir::new().expect("tempdir"); + let path = dir.path().join("broken-workshop.toml"); + std::fs::write(&path, "[gateway\n").expect("write fixture"); + let err = Config::load(&path).expect_err("malformed TOML must fail"); + assert!( + matches!(err, ConfigError::Parse { .. }), + "expected Parse, got {err:?}" + ); + assert!( + err.to_string().contains("broken-workshop.toml"), + "error names the path: {err}" + ); +} + +#[test] +fn load_reads_and_parses_a_file() { + let dir = tempfile::TempDir::new().expect("tempdir"); + let path = dir.path().join("workshop.toml"); + std::fs::write( + &path, + "[gateway]\nbase_url = \"http://127.0.0.1:8081\"\napi_key = \"k\"\n", + ) + .expect("write fixture"); + let config = Config::load(&path).expect("fixture loads"); + assert_eq!(config.gateway.api_key, "k"); +} diff --git a/crates/workshop-support/tests/it/main.rs b/crates/workshop-support/tests/it/main.rs new file mode 100644 index 000000000..ddc1577df --- /dev/null +++ b/crates/workshop-support/tests/it/main.rs @@ -0,0 +1,3 @@ +//! Integration tests for `workshop-support`. + +mod config; diff --git a/crates/xtask/src/tidy.rs b/crates/xtask/src/tidy.rs index f9535d951..c11e8f2e7 100644 --- a/crates/xtask/src/tidy.rs +++ b/crates/xtask/src/tidy.rs @@ -36,7 +36,11 @@ pub(crate) fn all_violations(root: &Path) -> Vec { /// The internal `workshop-*` crates a tiered crate may depend on, or `None` /// when `name` is not part of the decomposition's crate map. fn allowed_dependencies(name: &str) -> Option> { - let allowed = if VOCABULARY.contains(&name) { + let allowed = if name == "workshop-registry" { + // The proxy slots speak the wire types: the status push-channel + // slot carries `workshop-protocol`'s `StatusBarUpdate`. + vec!["workshop-protocol"] + } else if VOCABULARY.contains(&name) { Vec::new() } else if SERVICES.contains(&name) { VOCABULARY.to_vec() @@ -283,6 +287,10 @@ mod tests { #[test] fn tier_table_grants_each_tier_only_lower_tiers() { assert_eq!(allowed_dependencies("workshop-protocol"), Some(Vec::new())); + assert_eq!( + allowed_dependencies("workshop-registry"), + Some(vec!["workshop-protocol"]) + ); assert_eq!( allowed_dependencies("workshop-gateway"), Some(VOCABULARY.to_vec()) diff --git a/vibe/2026-09-12-3-workshop-server-decomposition.md b/vibe/2026-09-12-3-workshop-server-decomposition.md index 724e2f019..4408340ee 100644 --- a/vibe/2026-09-12-3-workshop-server-decomposition.md +++ b/vibe/2026-09-12-3-workshop-server-decomposition.md @@ -391,7 +391,7 @@ Verification: `cargo test -p xtask` passes. `cargo xtask new-crate workshop-scra -### Step 3: Extract tier-0 vocabulary crates +### Step 3: Extract tier-0 vocabulary crates [completed] - Component: Server Decomposition From f19e3df702c7ffc7d8bf093beaebd0b5150c9b3a Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Sat, 12 Sep 2026 14:11:23 -0700 Subject: [PATCH 04/19] Extract workshop gateway, status, and menu crates Splits the workshop server's domain services into three crates: the gateway client with its binding, heartbeat, progress subscriber, resolution, and run event log; the status bus with its progress renderer; and the model menu with its catalog channel. The push facade moves into the registry and reads new producer sink slots, so a subsystem reports what happened without naming another subsystem's bus. The shell re-exports the extracted crates at their old module paths, so its internals and tests read as they did before the split. The chat-capability predicate moves into the wire protocol crate, where the menu's picker filter and the gateway's catalog refresh both share it. - `crates/workshop-registry/src/push.rs::Push` wraps the registry and resolves the status, catalog, and menu sink slots on every call; an intent whose slot is empty degrades to a no-op, so a producer spawned before its subsystem registers never fails. - `crates/workshop-registry/src/traits.rs` adds the sealed `StatusSink`, `CatalogSink`, and `MenuSink` traits with closure-backed adapters; registrants plug in through the adapters and never implement the traits downstream. - `crates/workshop-menu/src/lib.rs::register` and `crates/workshop-status/src/lib.rs::register` self-register the buses' producer sinks and the status push channel, returning the guards the composition root holds for the process lifetime. - `crates/workshop-server/src/lib.rs` re-exports the three crates at their pre-split module paths and narrows its own push module to a re-export of the registry's facade. - `crates/workshop-protocol/src/catalog.rs::is_chat_capable` interprets the gateway's catalog shape as wire semantics, documented as living in the protocol crate rather than in any one subsystem. - `crates/workshop-server/src/session/menu.rs::run_switch` takes the menu bus as a parameter and calls `finish_switch` on it directly; the registry's menu facade keeps only the heartbeat's reachability, profile, and restore mutators. - `crates/workshop-server/src/session/menu.rs::drive_switch` answers an unrecognized switch response variant with a stream-ended failure and skips an unrecognized event variant, so a gateway that grows new response shapes never degrades the server. - `crates/workshop-server/tests/it/heartbeat_loop.rs` composes the extracted heartbeat with the real status, catalog, and menu buses through the registry: single-fire transition announcements, refresh-on-reconnect, startup convergence retries, the anti-flap backoff rule, and give-up reporting all run against mock gateways. - `crates/workshop-gateway/src/lib.rs` registers nothing into the registry; the crate exposes its modules and re-exports only. Design: new facade @ crates/workshop-gateway/src/lib.rs Design: new facade @ crates/workshop-menu/src/lib.rs Design: new facade @ crates/workshop-status/src/lib.rs Design: extends facade @ crates/workshop-server/src/lib.rs Design: new facade @ crates/workshop-registry/src/push.rs::Push boundary: wire Design: new service-locator @ crates/workshop-registry/src/push.rs::Push Design: new speculative-abstraction @ crates/workshop-registry/src/traits.rs::StatusSink Design: new speculative-abstraction @ crates/workshop-registry/src/traits.rs::CatalogSink Design: new speculative-abstraction @ crates/workshop-registry/src/traits.rs::MenuSink Design: new pure-function @ crates/workshop-protocol/src/catalog.rs::is_chat_capable deps: serde_json::Value boundary: wire Deferred: workshop-gateway registers no routes, state handle, or background tasks into the registry Deferred: workshop-status registers no state handle into the registry Deferred: workshop-menu registers no state handle into the registry Plan: vibe/2026-09-12-3-workshop-server-decomposition.md --- Cargo.lock | 55 + Cargo.toml | 3 + crates/workshop-gateway/Cargo.toml | 44 + crates/workshop-gateway/src/gateway.rs | 355 +++++ crates/workshop-gateway/src/gateway/events.rs | 209 +++ .../src/gateway/socket.rs | 8 +- crates/workshop-gateway/src/gateway/sse.rs | 126 ++ crates/workshop-gateway/src/gateway/tests.rs | 37 + .../src/gateway/tests/cache.rs | 201 +++ .../src/gateway/tests/decoder.rs | 138 ++ .../src/gateway/tests/switch.rs | 155 +++ .../src/gateway/tests/timeouts.rs | 65 + .../src/gateway_binding.rs | 70 +- .../src/gateway_binding/publication.rs | 0 .../src/gateway_binding/shutdown.rs | 0 .../src/gateway_binding/tests.rs | 0 .../src/gateway_binding/tests/atomic.rs | 0 .../src/gateway_binding/tests/publication.rs | 0 .../src/gateway_binding/tests/shutdown.rs | 0 .../workshop-gateway/src/gateway_progress.rs | 197 +++ .../src/gateway_progress/tests.rs | 307 ++++ .../src/gateway_progress/tests/lifecycle.rs | 0 .../src/gateway_progress/tests/recovery.rs | 0 crates/workshop-gateway/src/heartbeat.rs | 352 +++++ .../src/heartbeat/refresh.rs | 9 +- .../workshop-gateway/src/heartbeat/tests.rs | 123 ++ crates/workshop-gateway/src/lib.rs | 38 + .../src/observer.rs | 391 +----- crates/workshop-gateway/src/observer/tests.rs | 385 +++++ crates/workshop-gateway/src/resolve.rs | 274 ++++ crates/workshop-gateway/src/resolve/tests.rs | 324 +++++ .../src/test_gateway.rs | 0 .../src/test_gateway/process.rs | 0 crates/workshop-menu/Cargo.toml | 29 + .../src/catalog.rs | 20 +- .../src/catalog/chat.rs | 22 +- .../src/catalog/tests.rs | 0 crates/workshop-menu/src/lib.rs | 69 + .../src/menu.rs | 480 +------ crates/workshop-menu/src/menu/tests.rs | 451 ++++++ crates/workshop-protocol/src/catalog.rs | 20 + crates/workshop-protocol/src/lib.rs | 2 +- crates/workshop-registry/Cargo.toml | 1 + crates/workshop-registry/src/lib.rs | 10 +- crates/workshop-registry/src/push.rs | 184 +++ crates/workshop-registry/src/push/tests.rs | 297 ++++ crates/workshop-registry/src/registry.rs | 47 +- crates/workshop-registry/src/traits.rs | 166 +++ crates/workshop-server/Cargo.toml | 10 +- crates/workshop-server/src/app.rs | 54 +- crates/workshop-server/src/error.rs | 5 +- crates/workshop-server/src/fixtures.rs | 10 + crates/workshop-server/src/gateway.rs | 1234 ----------------- .../workshop-server/src/gateway_progress.rs | 496 ------- crates/workshop-server/src/heartbeat.rs | 898 ------------ crates/workshop-server/src/lib.rs | 32 +- crates/workshop-server/src/progress.rs | 500 ------- crates/workshop-server/src/push.rs | 269 ---- crates/workshop-server/src/resolve.rs | 595 -------- crates/workshop-server/src/serve.rs | 5 +- crates/workshop-server/src/session/menu.rs | 17 +- crates/workshop-server/src/session_agents.rs | 23 +- .../tests/it/heartbeat_loop.rs | 488 +++++++ .../it/heartbeat_loop}/recovery.rs | 8 +- .../it/heartbeat_loop}/startup_convergence.rs | 8 +- crates/workshop-server/tests/it/main.rs | 1 + crates/workshop-status/Cargo.toml | 22 + crates/workshop-status/src/lib.rs | 64 + crates/workshop-status/src/progress.rs | 214 +++ crates/workshop-status/src/progress/tests.rs | 289 ++++ .../src/status.rs | 23 +- ...6-09-12-3-workshop-server-decomposition.md | 8 +- 72 files changed, 5960 insertions(+), 4977 deletions(-) create mode 100644 crates/workshop-gateway/Cargo.toml create mode 100644 crates/workshop-gateway/src/gateway.rs create mode 100644 crates/workshop-gateway/src/gateway/events.rs rename crates/{workshop-server => workshop-gateway}/src/gateway/socket.rs (90%) create mode 100644 crates/workshop-gateway/src/gateway/sse.rs create mode 100644 crates/workshop-gateway/src/gateway/tests.rs create mode 100644 crates/workshop-gateway/src/gateway/tests/cache.rs create mode 100644 crates/workshop-gateway/src/gateway/tests/decoder.rs create mode 100644 crates/workshop-gateway/src/gateway/tests/switch.rs create mode 100644 crates/workshop-gateway/src/gateway/tests/timeouts.rs rename crates/{workshop-server => workshop-gateway}/src/gateway_binding.rs (85%) rename crates/{workshop-server => workshop-gateway}/src/gateway_binding/publication.rs (100%) rename crates/{workshop-server => workshop-gateway}/src/gateway_binding/shutdown.rs (100%) rename crates/{workshop-server => workshop-gateway}/src/gateway_binding/tests.rs (100%) rename crates/{workshop-server => workshop-gateway}/src/gateway_binding/tests/atomic.rs (100%) rename crates/{workshop-server => workshop-gateway}/src/gateway_binding/tests/publication.rs (100%) rename crates/{workshop-server => workshop-gateway}/src/gateway_binding/tests/shutdown.rs (100%) create mode 100644 crates/workshop-gateway/src/gateway_progress.rs create mode 100644 crates/workshop-gateway/src/gateway_progress/tests.rs rename crates/{workshop-server => workshop-gateway}/src/gateway_progress/tests/lifecycle.rs (100%) rename crates/{workshop-server => workshop-gateway}/src/gateway_progress/tests/recovery.rs (100%) create mode 100644 crates/workshop-gateway/src/heartbeat.rs rename crates/{workshop-server => workshop-gateway}/src/heartbeat/refresh.rs (94%) create mode 100644 crates/workshop-gateway/src/heartbeat/tests.rs create mode 100644 crates/workshop-gateway/src/lib.rs rename crates/{workshop-server => workshop-gateway}/src/observer.rs (54%) create mode 100644 crates/workshop-gateway/src/observer/tests.rs create mode 100644 crates/workshop-gateway/src/resolve.rs create mode 100644 crates/workshop-gateway/src/resolve/tests.rs rename crates/{workshop-server => workshop-gateway}/src/test_gateway.rs (100%) rename crates/{workshop-server => workshop-gateway}/src/test_gateway/process.rs (100%) create mode 100644 crates/workshop-menu/Cargo.toml rename crates/{workshop-server => workshop-menu}/src/catalog.rs (83%) rename crates/{workshop-server => workshop-menu}/src/catalog/chat.rs (79%) rename crates/{workshop-server => workshop-menu}/src/catalog/tests.rs (100%) create mode 100644 crates/workshop-menu/src/lib.rs rename crates/{workshop-server => workshop-menu}/src/menu.rs (50%) create mode 100644 crates/workshop-menu/src/menu/tests.rs create mode 100644 crates/workshop-registry/src/push.rs create mode 100644 crates/workshop-registry/src/push/tests.rs delete mode 100644 crates/workshop-server/src/gateway.rs delete mode 100644 crates/workshop-server/src/gateway_progress.rs delete mode 100644 crates/workshop-server/src/heartbeat.rs delete mode 100644 crates/workshop-server/src/progress.rs delete mode 100644 crates/workshop-server/src/push.rs delete mode 100644 crates/workshop-server/src/resolve.rs create mode 100644 crates/workshop-server/tests/it/heartbeat_loop.rs rename crates/workshop-server/{src/heartbeat/tests => tests/it/heartbeat_loop}/recovery.rs (87%) rename crates/workshop-server/{src/heartbeat/tests => tests/it/heartbeat_loop}/startup_convergence.rs (94%) create mode 100644 crates/workshop-status/Cargo.toml create mode 100644 crates/workshop-status/src/lib.rs create mode 100644 crates/workshop-status/src/progress.rs create mode 100644 crates/workshop-status/src/progress/tests.rs rename crates/{workshop-server => workshop-status}/src/status.rs (93%) diff --git a/Cargo.lock b/Cargo.lock index 30bf7804b..7edc764c5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8553,6 +8553,46 @@ dependencies = [ "workshop-server", ] +[[package]] +name = "workshop-gateway" +version = "0.0.0" +dependencies = [ + "arc-swap", + "axum", + "futures-util", + "promptforge-core-support", + "promptforge-model-client", + "reqwest 0.12.28", + "serde", + "serde_json", + "shared-progress", + "shared-sidecar", + "tempfile", + "thiserror 2.0.19", + "tokio", + "tokio-tungstenite", + "tracing", + "url", + "workshop-protocol", + "workshop-registry", + "workshop-support", +] + +[[package]] +name = "workshop-menu" +version = "0.0.0" +dependencies = [ + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.19", + "tokio", + "tracing", + "workshop-protocol", + "workshop-registry", + "workshop-support", +] + [[package]] name = "workshop-protocol" version = "0.0.0" @@ -8567,6 +8607,7 @@ name = "workshop-registry" version = "0.0.0" dependencies = [ "axum", + "serde_json", "tokio", "workshop-protocol", ] @@ -8610,9 +8651,23 @@ dependencies = [ "tracing", "tracing-subscriber", "url", + "workshop-gateway", + "workshop-menu", "workshop-protocol", "workshop-registry", "workshop-server", + "workshop-status", + "workshop-support", +] + +[[package]] +name = "workshop-status" +version = "0.0.0" +dependencies = [ + "shared-progress", + "tokio", + "workshop-protocol", + "workshop-registry", "workshop-support", ] diff --git a/Cargo.toml b/Cargo.toml index 345eeee4a..a696f553d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,7 +47,10 @@ gateway-stt-backend-whisper = { path = "crates/gateway-stt-backend-whisper", ver promptforge-web-search = { path = "crates/promptforge-web-search", version = "0.3.0" } gateway-web-search = { path = "crates/gateway-web-search", version = "0.3.0" } workshop-server = { path = "crates/workshop-server", version = "0.3.0" } +workshop-gateway = { path = "crates/workshop-gateway", version = "0.0.0" } +workshop-menu = { path = "crates/workshop-menu", version = "0.0.0" } workshop-protocol = { path = "crates/workshop-protocol", version = "0.0.0" } +workshop-status = { path = "crates/workshop-status", version = "0.0.0" } workshop-support = { path = "crates/workshop-support", version = "0.0.0" } workshop-registry = { path = "crates/workshop-registry", version = "0.0.0" } gateway-whisper-ffi = { path = "crates/gateway-whisper-ffi", version = "0.3.0" } diff --git a/crates/workshop-gateway/Cargo.toml b/crates/workshop-gateway/Cargo.toml new file mode 100644 index 000000000..8a770f107 --- /dev/null +++ b/crates/workshop-gateway/Cargo.toml @@ -0,0 +1,44 @@ +[package] +name = "workshop-gateway" +version = "0.0.0" +publish = false +edition.workspace = true +license.workspace = true +repository.workspace = true + +description = "Workshop gateway subsystem: the bearer-authenticated gateway HTTP client, endpoint binding and discovery, heartbeat, progress subscriber, and the run event log" + +[features] +test-fixtures = ["dep:tempfile"] + +[dependencies] +arc-swap.workspace = true +futures-util.workspace = true +promptforge-core-support.workspace = true +promptforge-model-client.workspace = true +reqwest.workspace = true +serde.workspace = true +serde_json.workspace = true +shared-progress.workspace = true +shared-sidecar.workspace = true +thiserror.workspace = true +tokio.workspace = true +tokio-tungstenite.workspace = true +tracing.workspace = true +url.workspace = true +workshop-protocol.workspace = true +workshop-registry.workspace = true +workshop-support.workspace = true +tempfile = { workspace = true, optional = true } + +[dev-dependencies] +axum.workspace = true +# The test-fixtures feature exposes `resolve_for_test`, so the resolution +# tests can run the real liveness gauntlet against the test binary's own +# process image. +shared-sidecar = { workspace = true, features = ["test-fixtures"] } +tempfile.workspace = true +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } + +[lints] +workspace = true diff --git a/crates/workshop-gateway/src/gateway.rs b/crates/workshop-gateway/src/gateway.rs new file mode 100644 index 000000000..830d5747c --- /dev/null +++ b/crates/workshop-gateway/src/gateway.rs @@ -0,0 +1,355 @@ +//! HTTP client for the PromptForge gateway's OpenAI-compatible API. +//! +//! [`GatewayClient`] wraps `reqwest` with bearer authentication and returns +//! responses as raw bytes so the workshop routes can relay them to the +//! caller byte-for-byte. A non-success status from the gateway is *not* an +//! error here: it is part of the relayed response. Streaming responses +//! (profile switches, cache downloads) are decoded from SSE into a +//! [`SsePayloadStream`] of `data:` payloads. + +use std::time::Duration; + +mod events; +mod sse; + +pub mod socket; + +pub use events::{ + CacheEvent, CacheResponse, ForwardedResponse, GatewayResponse, SsePayloadStream, SwitchEvent, + SwitchEventStream, SwitchResponse, switch_events, +}; +pub use socket::GatewayRealtimeSocket; +use sse::{is_event_stream, payload_stream, read}; + +/// Default bound on a single `GET /health` probe: a gateway that accepts +/// the connection but never answers must still read as unreachable, and two +/// seconds keeps the probe well under the heartbeat interval it serves. +pub(crate) const HEALTH_PROBE_TIMEOUT: Duration = Duration::from_secs(2); + +/// TCP connect timeout applied to every request. A gateway that is down or +/// unreachable should fail fast rather than hanging for the OS default (~21 s +/// on Linux, ~75 s on Windows). +const CONNECT_TIMEOUT: Duration = Duration::from_secs(5); + +/// Default whole-request timeout for non-streaming operations: the model +/// catalog fetch and the initial cache API handshake. Streaming responses +/// (cache downloads and profile switches) can legitimately run for +/// minutes, so the same bound covers only their header phase (see +/// `send_bounded`) and the body stream stays open-ended. +pub const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); + +/// A gateway request failure. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum GatewayError { + /// The HTTP client could not be built. + #[non_exhaustive] + #[error("build gateway http client")] + Build(#[source] Box), + + /// The request could not be sent or no response arrived (connect + /// refused, DNS, TLS, timeout). + #[non_exhaustive] + #[error("gateway transport error")] + Transport(#[source] Box), + + /// The response body could not be read to completion. + #[non_exhaustive] + #[error("read gateway response body")] + ReadBody(#[source] Box), +} + +impl GatewayError { + /// A transport failure manufactured by a test, for the shell's + /// error-mapping fixtures. + #[cfg(feature = "test-fixtures")] + #[must_use] + pub fn transport_for_test(source: Box) -> Self { + Self::Transport(source) + } +} + +/// Bearer-authenticated client for the gateway's OpenAI-compatible +/// endpoints. An empty API key sends no `Authorization` header at all, for +/// gateways running with authentication disabled. +#[derive(Clone)] +pub struct GatewayClient { + http: reqwest::Client, + pub(crate) base_url: String, + pub(crate) api_key: String, + /// Whole-request bound for buffered calls; header-phase bound for + /// streaming calls. + request_timeout: Duration, + /// Whole-request bound for the health probe. + health_timeout: Duration, +} + +// Manual so the bearer key is never written to logs. +impl std::fmt::Debug for GatewayClient { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("GatewayClient") + .field("base_url", &self.base_url) + .field("api_key", &"") + .finish_non_exhaustive() + } +} + +impl GatewayClient { + /// Builds a client for `base_url` authenticating with `api_key`. + /// + /// A trailing slash on `base_url` is trimmed so route joins stay clean. + /// An empty `api_key` disables authentication: requests then carry no + /// `Authorization` header. + /// + /// # Errors + /// Returns [`GatewayError::Build`] if the TLS backend cannot initialize. + pub fn new(base_url: &str, api_key: &str) -> Result { + let http = reqwest::Client::builder() + .connect_timeout(CONNECT_TIMEOUT) + .build() + .map_err(|source| GatewayError::Build(Box::new(source)))?; + Ok(Self { + http, + base_url: base_url.trim_end_matches('/').to_string(), + api_key: api_key.to_string(), + request_timeout: REQUEST_TIMEOUT, + health_timeout: HEALTH_PROBE_TIMEOUT, + }) + } + + /// Overrides the request and probe bounds, so tests can trip them + /// without waiting out the production values. + #[cfg(test)] + pub(crate) fn with_timeouts_for_test(mut self, request: Duration, health: Duration) -> Self { + self.request_timeout = request; + self.health_timeout = health; + self + } + + /// Sends `request`, bounding the wait for the response headers on the + /// client's request timeout. + /// + /// The streaming calls use this instead of a whole-request timeout: a + /// gateway that accepts the connection and then stalls must fail the + /// call rather than hang its caller, but an accepted stream may + /// legitimately run for minutes, so only the header phase is bounded + /// and the body stream stays open-ended. + async fn send_bounded( + &self, + request: reqwest::RequestBuilder, + ) -> Result { + match tokio::time::timeout(self.request_timeout, request.send()).await { + Ok(Ok(response)) => Ok(response), + Ok(Err(source)) => Err(GatewayError::Transport(Box::new(source))), + Err(elapsed) => Err(GatewayError::Transport(Box::new(elapsed))), + } + } + + /// Applies bearer authentication to `request`, unless the client was + /// built with an empty API key, in which case the request goes out with + /// no `Authorization` header. + fn authorize(&self, request: reqwest::RequestBuilder) -> reqwest::RequestBuilder { + if self.api_key.is_empty() { + request + } else { + request.bearer_auth(&self.api_key) + } + } + + /// The gateway's base URL as configured, trailing slash trimmed - + /// also the origin the config-panel iframe loads from. + #[must_use] + pub fn base_url(&self) -> &str { + &self.base_url + } + + /// Forwards one request to the gateway: `method` on + /// `path_and_query`, with an optional JSON `body`, authenticated + /// with the client's bearer key. Only the wait for the response + /// headers is bounded - a forwarded cache download or profile + /// switch legitimately streams for minutes - and the whole body is + /// buffered for relay. A non-success status is relayed in the + /// returned [`ForwardedResponse`], not reported as an error. + /// + /// # Errors + /// Returns [`GatewayError::Transport`] if the request cannot be + /// completed (the header bound elapsing included) and + /// [`GatewayError::ReadBody`] if the response body cannot be read. + pub async fn forward( + &self, + method: reqwest::Method, + path_and_query: &str, + body: Option>, + ) -> Result { + let mut request = self.authorize( + self.http + .request(method, format!("{}{}", self.base_url, path_and_query)), + ); + if let Some(bytes) = body { + request = request + .header(reqwest::header::CONTENT_TYPE, "application/json") + .body(bytes); + } + let response = self.send_bounded(request).await?; + let status = response.status(); + let content_type = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .map(str::to_string); + let body = response + .bytes() + .await + .map_err(|source| GatewayError::ReadBody(Box::new(source)))? + .to_vec(); + Ok(ForwardedResponse { + status, + content_type, + body, + }) + } + + /// Probes the gateway's liveness endpoint, `GET /health`. + /// + /// Returns `true` only when the gateway answers with a success status: + /// a transport failure, a probe timeout, or a non-success answer all + /// read as unreachable. The request never carries the client's API key + /// (the endpoint is unauthenticated by design) and is capped at the + /// probe bound (`HEALTH_PROBE_TIMEOUT` by default). + pub async fn health(&self) -> bool { + let probe = self + .http + .get(format!("{}/health", self.base_url)) + .timeout(self.health_timeout); + match probe.send().await { + Ok(response) => response.status().is_success(), + Err(_) => false, + } + } + + /// Fetches the gateway's model catalog from `GET /v1/models`. + /// + /// A non-success status is relayed in the returned + /// [`GatewayResponse`], not reported as an error. + /// + /// # Errors + /// Returns [`GatewayError::Transport`] if the request cannot be + /// completed and [`GatewayError::ReadBody`] if the response body cannot + /// be read. + pub async fn list_models(&self) -> Result { + let response = self + .authorize(self.http.get(format!("{}/v1/models", self.base_url))) + .timeout(self.request_timeout) + .send() + .await + .map_err(|source| GatewayError::Transport(Box::new(source)))?; + read(response).await + } + + /// Fetches the gateway's profile list from `GET /admin/profiles`. + /// + /// A non-success status is relayed in the returned + /// [`GatewayResponse`], not reported as an error. + /// + /// # Errors + /// Returns [`GatewayError::Transport`] if the request cannot be + /// completed and [`GatewayError::ReadBody`] if the response body cannot + /// be read. + pub async fn list_profiles(&self) -> Result { + let response = self + .authorize(self.http.get(format!("{}/admin/profiles", self.base_url))) + .timeout(self.request_timeout) + .send() + .await + .map_err(|source| GatewayError::Transport(Box::new(source)))?; + read(response).await + } + + /// Fetches the gateway's live status from `GET /admin/status`, which + /// carries the active profile's name. + /// + /// A non-success status is relayed in the returned + /// [`GatewayResponse`], not reported as an error. + /// + /// # Errors + /// Returns [`GatewayError::Transport`] if the request cannot be + /// completed and [`GatewayError::ReadBody`] if the response body cannot + /// be read. + pub async fn profile_status(&self) -> Result { + let response = self + .authorize(self.http.get(format!("{}/admin/status", self.base_url))) + .timeout(self.request_timeout) + .send() + .await + .map_err(|source| GatewayError::Transport(Box::new(source)))?; + read(response).await + } + + /// Posts a profile switch to `POST /admin/switch-profile`. + /// + /// An accepted switch answers `text/event-stream` and returns + /// [`SwitchResponse::Switching`], whose payload stream carries stage + /// markers and then a terminal `ready` or `error` event (decode it with + /// [`switch_events`]). Only the wait for the response headers is + /// bounded: loading model weights into VRAM legitimately runs for + /// minutes and the stream reports progress the whole way, so the + /// stream itself carries no deadline. A non-success or non-streaming + /// answer is buffered and returned, not reported as an error. + /// + /// # Errors + /// Returns [`GatewayError::Transport`] if the request cannot be + /// completed (the header bound elapsing included) and + /// [`GatewayError::ReadBody`] if a buffered answer's body cannot be + /// read. + pub async fn switch_profile(&self, name: &str) -> Result { + let request = self + .authorize( + self.http + .post(format!("{}/admin/switch-profile", self.base_url)), + ) + .json(&serde_json::json!({ "name": name })); + let response = self.send_bounded(request).await?; + let status = response.status(); + if status.is_success() && is_event_stream(&response) { + return Ok(SwitchResponse::Switching { + status, + payloads: payload_stream(response), + }); + } + read(response).await.map(SwitchResponse::Buffered) + } + + /// Posts a cache-ensure request to `POST /v1/cache`, asking the gateway + /// to make the blob at `source` available locally. + /// + /// A cache hit answers a buffered JSON `ready` event + /// ([`CacheResponse::Buffered`] on a success status); a miss answers + /// `text/event-stream` and returns [`CacheResponse::Download`], whose + /// payload stream ends in a terminal `ready` or `error` event. Only + /// the wait for the response headers is bounded; a download stream + /// itself carries no deadline. A non-success status is buffered and + /// returned, not reported as an error. + /// + /// # Errors + /// Returns [`GatewayError::Transport`] if the request cannot be + /// completed (the header bound elapsing included) and + /// [`GatewayError::ReadBody`] if a buffered answer's body cannot be + /// read. + pub async fn cache_ensure(&self, source: &str) -> Result { + let request = self + .authorize(self.http.post(format!("{}/v1/cache", self.base_url))) + .json(&serde_json::json!({ "source": source })); + let response = self.send_bounded(request).await?; + let status = response.status(); + if status.is_success() && is_event_stream(&response) { + return Ok(CacheResponse::Download { + status, + payloads: payload_stream(response), + }); + } + read(response).await.map(CacheResponse::Buffered) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/workshop-gateway/src/gateway/events.rs b/crates/workshop-gateway/src/gateway/events.rs new file mode 100644 index 000000000..ee0b3e585 --- /dev/null +++ b/crates/workshop-gateway/src/gateway/events.rs @@ -0,0 +1,209 @@ +//! The gateway client's wire types: the buffered relay response, the +//! forwarded config-panel response, the SSE payload stream, and the +//! typed cache and profile-switch events decoded from it. + +use std::path::PathBuf; +use std::pin::Pin; + +use futures_util::stream::{Stream, StreamExt}; +use serde::Deserialize; + +use super::GatewayError; + +/// A gateway HTTP response captured for verbatim relay. +#[derive(Debug)] +pub struct GatewayResponse { + /// The gateway's status code, relayed unchanged. + pub status: reqwest::StatusCode, + /// The gateway's response body, relayed byte-for-byte. + pub body: Vec, +} + +/// A gateway response captured for the config-panel proxy: the relay +/// keeps the content type alongside the status and body, because the +/// config UI distinguishes a buffered JSON answer from an SSE stream by +/// it. +#[derive(Debug)] +pub struct ForwardedResponse { + /// The gateway's status code, relayed unchanged. + pub status: reqwest::StatusCode, + /// The gateway's `Content-Type`, when it sent one. + pub content_type: Option, + /// The gateway's response body, relayed byte-for-byte. + pub body: Vec, +} + +/// A stream of SSE `data:` payloads from the gateway, in arrival order. +/// +/// Each item is one event's data, verbatim. A transport failure mid-stream +/// yields one error item and then ends the stream. +pub type SsePayloadStream = Pin> + Send>>; + +/// The gateway's answer to a cache-ensure request, `POST /v1/cache`. +/// +/// The gateway answers a cache hit with a buffered JSON `ready` event and a +/// miss with an SSE stream of `downloading` progress events terminated by a +/// `ready` or `error` event; both event shapes decode as [`CacheEvent`]. A +/// non-success status (a declined or failed request) is buffered rather +/// than reported as an error, matching the relay contract of the other +/// client methods. +#[non_exhaustive] +pub enum CacheResponse { + /// The gateway is downloading the blob; `payloads` carries the SSE + /// stream of [`CacheEvent`] JSON documents. + Download { + /// The gateway's success status. + status: reqwest::StatusCode, + /// The SSE payload stream, ending in a terminal `ready` or `error` + /// event. + payloads: SsePayloadStream, + }, + + /// Any other answer, buffered: a cache hit's `ready` JSON on a success + /// status, or the gateway's error envelope on a failure status. + Buffered(GatewayResponse), +} + +// Manual because the boxed payload stream has no `Debug` impl. +impl std::fmt::Debug for CacheResponse { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Download { status, .. } => f + .debug_struct("CacheResponse::Download") + .field("status", status) + .finish_non_exhaustive(), + Self::Buffered(response) => f + .debug_tuple("CacheResponse::Buffered") + .field(response) + .finish(), + } + } +} + +/// One event of the gateway cache API: a download progress sample, or the +/// terminal state of a cache-ensure call. +/// +/// The `path` a `Ready` event carries names a file on the gateway host, so +/// the cache API is only meaningful to a workshop sharing the gateway's +/// filesystem - the standard local deployment, where both run on loopback. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(tag = "status", rename_all = "lowercase")] +#[non_exhaustive] +pub enum CacheEvent { + /// A progress sample from a running download. + Downloading { + /// Cumulative bytes downloaded so far. + bytes: u64, + /// Total bytes expected; null when the upstream server sent no + /// Content-Length. + total: Option, + }, + + /// The blob is cached and ready at `path`. + Ready { + /// Local path of the cached blob on the gateway host. + path: PathBuf, + }, + + /// The download failed. + Error { + /// The gateway's description of the failure. + message: String, + }, +} + +/// The gateway's answer to a profile switch, `POST /admin/switch-profile`. +/// +/// An accepted switch answers `text/event-stream`: stage markers as the +/// switch proceeds, then exactly one terminal `ready` or `error` event, all +/// decoding as [`SwitchEvent`]. A refusal before the switch starts (bad +/// auth, a malformed name, no profiles directory) is buffered rather than +/// reported as an error, matching the relay contract of the other client +/// methods. +#[non_exhaustive] +pub enum SwitchResponse { + /// The gateway accepted the switch and is streaming its progress. + Switching { + /// The gateway's success status. + status: reqwest::StatusCode, + /// The SSE payload stream of [`SwitchEvent`] JSON documents, ending + /// in a terminal `ready` or `error` event. + payloads: SsePayloadStream, + }, + + /// A refusal, buffered: the gateway's error envelope. + Buffered(GatewayResponse), +} + +// Manual because the boxed payload stream has no `Debug` impl. +impl std::fmt::Debug for SwitchResponse { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Switching { status, .. } => f + .debug_struct("SwitchResponse::Switching") + .field("status", status) + .finish_non_exhaustive(), + Self::Buffered(response) => f + .debug_tuple("SwitchResponse::Buffered") + .field(response) + .finish(), + } + } +} + +/// One event of the gateway's switch-profile stream: a stage marker as the +/// switch proceeds, then exactly one terminal event. +/// +/// Stage markers arrive in execution order - `loading-profile`, +/// `stopping-models`, `starting-models` (the long pole: weights loading +/// into VRAM). The stage stays a string so a gateway that grows a new +/// stage never breaks the decode. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(untagged)] +#[non_exhaustive] +pub enum SwitchEvent { + /// A phase of the switch is beginning. + Stage { + /// The gateway's name for the phase, e.g. `starting-models`. + stage: String, + }, + + /// Terminal: the switch committed and `profile` is live. + Ready { + /// The now-active profile name. + profile: String, + }, + + /// Terminal: the switch failed. The previous profile stays + /// authenticated and remote-routable, but its local children may + /// already be gone (the gateway's documented degraded state). + Error { + /// The gateway's description of the failure. + message: String, + }, +} + +/// A stream of decoded [`SwitchEvent`]s, as produced by [`switch_events`]. +pub type SwitchEventStream = Pin> + Send>>; + +/// Decodes a switch-profile payload stream into typed [`SwitchEvent`]s. +/// +/// A payload that does not parse as a switch event is logged and skipped - +/// a malformed line from the gateway degrades one progress update, never +/// the switch - and the stream continues to its terminal event. A transport +/// failure passes through and ends the stream. +#[must_use] +pub fn switch_events(payloads: SsePayloadStream) -> SwitchEventStream { + Box::pin(payloads.filter_map(|item| async move { + match item { + Ok(payload) => match serde_json::from_str::(&payload) { + Ok(event) => Some(Ok(event)), + Err(error) => { + tracing::warn!(%error, payload, "skipping a malformed switch-profile event"); + None + } + }, + Err(error) => Some(Err(error)), + } + })) +} diff --git a/crates/workshop-server/src/gateway/socket.rs b/crates/workshop-gateway/src/gateway/socket.rs similarity index 90% rename from crates/workshop-server/src/gateway/socket.rs rename to crates/workshop-gateway/src/gateway/socket.rs index 07c891947..b3ba99342 100644 --- a/crates/workshop-server/src/gateway/socket.rs +++ b/crates/workshop-gateway/src/gateway/socket.rs @@ -8,14 +8,18 @@ type GatewaySocket = tokio_tungstenite::WebSocketStream>; /// An authenticated WebSocket connection to Gateway Realtime transcription. -pub(crate) type GatewayRealtimeSocket = GatewaySocket; +pub type GatewayRealtimeSocket = GatewaySocket; impl GatewayClient { /// Opens the gateway's authenticated Realtime transcription socket. /// /// The target is fixed to `/v1/realtime?intent=transcription`; browser /// query parameters and handshake policy headers never cross the relay. - pub(crate) async fn connect_realtime(&self) -> Result { + /// + /// # Errors + /// Returns [`GatewayError::Transport`] if the socket cannot be + /// connected (the header bound elapsing included). + pub async fn connect_realtime(&self) -> Result { self.connect_socket().await } diff --git a/crates/workshop-gateway/src/gateway/sse.rs b/crates/workshop-gateway/src/gateway/sse.rs new file mode 100644 index 000000000..65cccb8ca --- /dev/null +++ b/crates/workshop-gateway/src/gateway/sse.rs @@ -0,0 +1,126 @@ +//! The SSE decode half of the gateway client: response-body capture and +//! the incremental decoder turning arbitrary byte chunks into `data:` +//! payloads. + +use std::collections::VecDeque; + +use futures_util::stream::{self, StreamExt}; + +use super::{GatewayError, GatewayResponse, SsePayloadStream}; + +/// Whether the gateway answered with an SSE body. +pub(super) fn is_event_stream(response: &reqwest::Response) -> bool { + response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value.starts_with("text/event-stream")) +} + +/// Captures the status and raw body of a gateway response. +pub(super) async fn read(response: reqwest::Response) -> Result { + let status = response.status(); + let body = response + .bytes() + .await + .map_err(|source| GatewayError::ReadBody(Box::new(source)))? + .to_vec(); + Ok(GatewayResponse { status, body }) +} + +/// Decodes a gateway SSE response body into its `data:` payload stream. +/// +/// Byte chunks arrive on arbitrary TCP boundaries, so the decoder buffers +/// partial lines; a mid-stream transport failure surfaces as one error item +/// that ends the stream. +pub(super) fn payload_stream(response: reqwest::Response) -> SsePayloadStream { + let state = (response.bytes_stream(), SseDecoder::default(), false); + let payloads = stream::try_unfold(state, |(mut bytes, mut decoder, mut eof)| async move { + loop { + if let Some(payload) = decoder.pop() { + return Ok(Some((payload, (bytes, decoder, eof)))); + } + if eof { + return Ok(None); + } + match bytes.next().await { + Some(Ok(chunk)) => decoder.feed(&chunk), + Some(Err(source)) => return Err(GatewayError::ReadBody(Box::new(source))), + None => { + decoder.finish(); + eof = true; + } + } + } + }); + Box::pin(payloads) +} + +/// Incremental SSE decoder: turns arbitrary byte chunks into `data:` +/// payloads, one per event, in arrival order. +/// +/// Only `data:` fields are collected; `event:`, `id:`, `retry:`, and +/// comments are dropped, matching what an OpenAI-compatible stream carries. +/// Multiple `data:` lines in one event are joined with `\n` per the SSE +/// specification. +#[derive(Debug, Default)] +pub(crate) struct SseDecoder { + /// Bytes received but not yet terminated by `\n`. + partial: Vec, + /// Joined `data:` lines of the event currently being accumulated. + data: String, + /// Whether the current event carries at least one `data:` line. + has_data: bool, + /// Completed payloads awaiting pickup. + out: VecDeque, +} + +impl SseDecoder { + /// Feeds one byte chunk, completing every event it terminates. + pub(crate) fn feed(&mut self, chunk: &[u8]) { + self.partial.extend_from_slice(chunk); + while let Some(end) = self.partial.iter().position(|&b| b == b'\n') { + let line: Vec = self.partial.drain(..=end).collect(); + self.line(&line[..line.len() - 1]); + } + } + + /// Flushes a trailing unterminated line and any pending event at EOF. + pub(crate) fn finish(&mut self) { + if !self.partial.is_empty() { + let line = std::mem::take(&mut self.partial); + self.line(&line); + } + self.dispatch(); + } + + /// Takes the oldest completed payload, if any. + pub(crate) fn pop(&mut self) -> Option { + self.out.pop_front() + } + + /// Handles one line without its `\n`; a blank line ends the event. + fn line(&mut self, raw: &[u8]) { + let line = raw.strip_suffix(b"\r").unwrap_or(raw); + if line.is_empty() { + self.dispatch(); + return; + } + if let Some(value) = line.strip_prefix(b"data:") { + let value = value.strip_prefix(b" ").unwrap_or(value); + if self.has_data { + self.data.push('\n'); + } + self.data.push_str(&String::from_utf8_lossy(value)); + self.has_data = true; + } + } + + /// Queues the accumulated event, dropping events with no `data:` line. + fn dispatch(&mut self) { + if self.has_data { + self.out.push_back(std::mem::take(&mut self.data)); + self.has_data = false; + } + } +} diff --git a/crates/workshop-gateway/src/gateway/tests.rs b/crates/workshop-gateway/src/gateway/tests.rs new file mode 100644 index 000000000..8b2e0c905 --- /dev/null +++ b/crates/workshop-gateway/src/gateway/tests.rs @@ -0,0 +1,37 @@ +//! Tests for the gateway client: the client basics live here beside the +//! shared mock-server helper; the decoder, timeout, cache, and switch +//! areas each have their own submodule. + +use super::*; + +mod cache; +mod decoder; +mod switch; +mod timeouts; + +/// Binds `app` on a free loopback port and returns its base URL. +pub(super) async fn serve(app: axum::Router) -> String { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind mock gateway"); + let addr = listener.local_addr().expect("mock gateway address"); + tokio::spawn(async move { + axum::serve(listener, app) + .await + .expect("mock gateway serves"); + }); + format!("http://{addr}") +} + +#[test] +fn trailing_slash_is_trimmed_from_base_url() { + let client = GatewayClient::new("http://127.0.0.1:8081/", "k").expect("client builds"); + assert_eq!(client.base_url, "http://127.0.0.1:8081"); +} + +#[test] +fn debug_redacts_the_api_key() { + let client = GatewayClient::new("http://127.0.0.1:8081", "secret-key").expect("client"); + let rendered = format!("{client:?}"); + assert!(!rendered.contains("secret-key"), "key leaked: {rendered}"); +} diff --git a/crates/workshop-gateway/src/gateway/tests/cache.rs b/crates/workshop-gateway/src/gateway/tests/cache.rs new file mode 100644 index 000000000..45fb9ce9e --- /dev/null +++ b/crates/workshop-gateway/src/gateway/tests/cache.rs @@ -0,0 +1,201 @@ +//! Cache API tests: the wire shapes decode, a hit buffers, a miss +//! streams, and a declined request is buffered rather than an error. + +use super::*; + +use std::path::PathBuf; + +use axum::response::IntoResponse as _; +use futures_util::StreamExt as _; + +#[test] +fn cache_event_decodes_each_wire_shape() { + let downloading: CacheEvent = + serde_json::from_str(r#"{"status":"downloading","bytes":5,"total":10}"#) + .expect("downloading decodes"); + assert_eq!( + downloading, + CacheEvent::Downloading { + bytes: 5, + total: Some(10) + } + ); + let unknown_total: CacheEvent = + serde_json::from_str(r#"{"status":"downloading","bytes":5,"total":null}"#) + .expect("a null total decodes"); + assert_eq!( + unknown_total, + CacheEvent::Downloading { + bytes: 5, + total: None + } + ); + let ready: CacheEvent = serde_json::from_str(r#"{"status":"ready","path":"/cache/ggml.bin"}"#) + .expect("ready decodes"); + assert_eq!( + ready, + CacheEvent::Ready { + path: PathBuf::from("/cache/ggml.bin") + } + ); + let error: CacheEvent = + serde_json::from_str(r#"{"status":"error","message":"boom"}"#).expect("error decodes"); + assert_eq!( + error, + CacheEvent::Error { + message: "boom".to_string() + } + ); +} + +/// Mock cache route state: the last request's auth header and body, +/// captured so tests can assert what the client sent. +#[derive(Clone, Default)] +struct CacheProbe { + authorized: std::sync::Arc, + sources: std::sync::Arc>>, +} + +impl CacheProbe { + fn sources(&self) -> Vec { + self.sources + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + } +} + +#[tokio::test] +async fn a_cache_hit_answers_a_buffered_ready_event() { + let probe = CacheProbe::default(); + let seen = probe.clone(); + let app = axum::Router::new().route( + "/v1/cache", + axum::routing::post( + move |headers: axum::http::HeaderMap, body: axum::Json| { + let seen = seen.clone(); + async move { + seen.authorized.store( + headers + .get(axum::http::header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + == Some("Bearer test-key"), + std::sync::atomic::Ordering::Relaxed, + ); + seen.sources + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push( + body["source"] + .as_str() + .expect("source is a string") + .to_string(), + ); + axum::Json(serde_json::json!({ + "path": "/cache/ggml-large-v3-turbo.bin", + "status": "ready", + })) + .into_response() + } + }, + ), + ); + let base_url = serve(app).await; + let client = GatewayClient::new(&base_url, "test-key").expect("client builds in tests"); + let response = client + .cache_ensure("https://example.com/models/ggml-large-v3-turbo.bin") + .await + .expect("the request completes"); + let CacheResponse::Buffered(answer) = response else { + panic!("a cache hit is buffered, got {response:?}"); + }; + assert!(answer.status.is_success()); + let event: CacheEvent = + serde_json::from_slice(&answer.body).expect("the hit body is a ready event"); + assert_eq!( + event, + CacheEvent::Ready { + path: PathBuf::from("/cache/ggml-large-v3-turbo.bin") + } + ); + assert!(probe.authorized.load(std::sync::atomic::Ordering::Relaxed)); + assert_eq!( + probe.sources(), + ["https://example.com/models/ggml-large-v3-turbo.bin"] + ); +} + +#[tokio::test] +async fn a_cache_miss_answers_a_download_stream() { + let app = axum::Router::new().route( + "/v1/cache", + axum::routing::post(|| async { + ( + [(axum::http::header::CONTENT_TYPE, "text/event-stream")], + concat!( + "data: {\"status\":\"downloading\",\"bytes\":5,\"total\":null}\n\n", + "data: {\"status\":\"downloading\",\"bytes\":10,\"total\":12}\n\n", + "data: {\"status\":\"ready\",\"path\":\"/cache/ggml.bin\"}\n\n", + ), + ) + }), + ); + let base_url = serve(app).await; + let client = GatewayClient::new(&base_url, "").expect("client builds in tests"); + let response = client + .cache_ensure("https://example.com/models/ggml.bin") + .await + .expect("the request completes"); + let CacheResponse::Download { mut payloads, .. } = response else { + panic!("a cache miss streams, got {response:?}"); + }; + let mut events = Vec::new(); + while let Some(item) = payloads.next().await { + let payload = item.expect("the stream is clean"); + events.push( + serde_json::from_str::(&payload).expect("each payload is a cache event"), + ); + } + assert_eq!( + events, + [ + CacheEvent::Downloading { + bytes: 5, + total: None + }, + CacheEvent::Downloading { + bytes: 10, + total: Some(12) + }, + CacheEvent::Ready { + path: PathBuf::from("/cache/ggml.bin") + }, + ], + "the stream carries progress samples then the terminal ready" + ); +} + +#[tokio::test] +async fn a_declined_cache_request_is_buffered_not_an_error() { + let app = axum::Router::new().route( + "/v1/cache", + axum::routing::post(|| async { + ( + axum::http::StatusCode::BAD_REQUEST, + axum::Json(serde_json::json!({ + "error": {"message": "bad source", "code": "malformed_request"} + })), + ) + }), + ); + let base_url = serve(app).await; + let client = GatewayClient::new(&base_url, "").expect("client builds in tests"); + let response = client + .cache_ensure("not-a-url") + .await + .expect("a declined request still completes"); + let CacheResponse::Buffered(answer) = response else { + panic!("a declined request is buffered, got {response:?}"); + }; + assert_eq!(answer.status, reqwest::StatusCode::BAD_REQUEST); +} diff --git a/crates/workshop-gateway/src/gateway/tests/decoder.rs b/crates/workshop-gateway/src/gateway/tests/decoder.rs new file mode 100644 index 000000000..c89e94717 --- /dev/null +++ b/crates/workshop-gateway/src/gateway/tests/decoder.rs @@ -0,0 +1,138 @@ +//! SSE decoder tests: chunking invariance, multi-line events, CRLF, EOF +//! flush, and data-less event drops. + +use crate::gateway::sse::SseDecoder; +use workshop_support::xorshift; + +fn drain(decoder: &mut SseDecoder) -> Vec { + let mut payloads = Vec::new(); + while let Some(payload) = decoder.pop() { + payloads.push(payload); + } + payloads +} + +#[test] +fn decoder_emits_the_same_events_regardless_of_chunk_boundaries() { + let wire = "data: {\"a\":1}\n\ndata: [DONE]\n\n"; + let mut whole = SseDecoder::default(); + whole.feed(wire.as_bytes()); + whole.finish(); + + let mut drip = SseDecoder::default(); + for byte in wire.as_bytes() { + drip.feed(std::slice::from_ref(byte)); + } + drip.finish(); + + let whole_out = drain(&mut whole); + assert_eq!(drain(&mut drip), whole_out, "chunking must not matter"); + assert_eq!( + whole_out, + ["{\"a\":1}".to_string(), "[DONE]".to_string()], + "payloads arrive verbatim, including the terminal sentinel" + ); +} + +#[test] +fn decoder_joins_multi_line_data_and_ignores_other_fields() { + let wire = ": comment\nevent: message\nid: 7\ndata: first\ndata: second\n\n"; + let mut decoder = SseDecoder::default(); + decoder.feed(wire.as_bytes()); + decoder.finish(); + assert_eq!(decoder.pop().as_deref(), Some("first\nsecond")); + assert!(decoder.pop().is_none(), "one event, one payload"); +} + +#[test] +fn decoder_accepts_crlf_line_endings() { + let mut decoder = SseDecoder::default(); + decoder.feed(b"data: one\r\n\r\n"); + decoder.finish(); + assert_eq!(decoder.pop().as_deref(), Some("one")); +} + +#[test] +fn decoder_flushes_an_unterminated_final_line_at_eof() { + let mut decoder = SseDecoder::default(); + decoder.feed(b"data: tail"); + decoder.finish(); + assert_eq!(decoder.pop().as_deref(), Some("tail")); +} + +#[test] +fn decoder_drops_events_without_data() { + let mut decoder = SseDecoder::default(); + decoder.feed(b"event: ping\n\ndata: kept\n\n"); + decoder.finish(); + assert_eq!(decoder.pop().as_deref(), Some("kept")); + assert!(decoder.pop().is_none()); +} + +/// A realistic delta stream whose payloads carry multi-byte UTF-8 +/// (2-, 3-, and 4-byte codepoints), so a byte split can land inside a +/// codepoint; mixed CRLF/LF endings and a multi-line event ride along. +const MULTIBYTE_WIRE: &str = concat!( + "data: {\"choices\":[{\"delta\":{\"reasoning_content\":\"h\u{e9}llo \u{1f914} w\u{f6}rld\"}}]}\r\n\r\n", + "data: {\"choices\":[{\"delta\":{\"content\":\"\u{65e5}\u{672c}\u{8a9e}\"}}]}\n\n", + "data: first\ndata: \u{1f40d} second\n\n", + "data: [DONE]\n\n", +); + +/// The payloads [`MULTIBYTE_WIRE`] must always decode to, regardless +/// of how the bytes are chunked. +fn multibyte_payloads() -> Vec { + vec![ + "{\"choices\":[{\"delta\":{\"reasoning_content\":\"h\u{e9}llo \u{1f914} w\u{f6}rld\"}}]}" + .to_string(), + "{\"choices\":[{\"delta\":{\"content\":\"\u{65e5}\u{672c}\u{8a9e}\"}}]}".to_string(), + "first\n\u{1f40d} second".to_string(), + "[DONE]".to_string(), + ] +} + +/// Feeds `wire` split at the ascending byte offsets in `cuts`, then +/// returns everything the decoder produced. +fn decode_in_fragments(wire: &[u8], cuts: &[usize]) -> Vec { + let mut decoder = SseDecoder::default(); + let mut start = 0; + for &cut in cuts { + decoder.feed(&wire[start..cut]); + start = cut; + } + decoder.feed(&wire[start..]); + decoder.finish(); + drain(&mut decoder) +} + +#[test] +fn decoder_survives_every_split_point_including_mid_utf8() { + let wire = MULTIBYTE_WIRE.as_bytes(); + let expected = multibyte_payloads(); + assert_eq!(decode_in_fragments(wire, &[]), expected, "unsplit feed"); + // Every split point, so every mid-codepoint boundary is exercised. + for cut in 1..wire.len() { + assert_eq!( + decode_in_fragments(wire, &[cut]), + expected, + "split at byte {cut} changed the decode" + ); + } +} + +#[test] +fn decoder_is_chunking_invariant_under_random_splits() { + let wire = MULTIBYTE_WIRE.as_bytes(); + let expected = multibyte_payloads(); + for seed in [0x9E37_79B9_7F4A_7C15_u64, 42, 7_777_777] { + let mut state = seed; + let cuts: Vec = (1..wire.len()) + .filter(|_| xorshift(&mut state).is_multiple_of(4)) + .collect(); + assert_eq!( + decode_in_fragments(wire, &cuts), + expected, + "seed {seed}: fragmenting at {cuts:?} changed the decode" + ); + } +} diff --git a/crates/workshop-gateway/src/gateway/tests/switch.rs b/crates/workshop-gateway/src/gateway/tests/switch.rs new file mode 100644 index 000000000..f5689c84c --- /dev/null +++ b/crates/workshop-gateway/src/gateway/tests/switch.rs @@ -0,0 +1,155 @@ +//! Switch-profile tests: the wire shapes decode, an accepted switch +//! streams stages then its terminal event, malformed payloads are +//! skipped, and a declined switch is buffered rather than an error. + +use super::*; + +use futures_util::StreamExt as _; + +#[test] +fn switch_event_decodes_each_wire_shape() { + let stage: SwitchEvent = + serde_json::from_str(r#"{"stage":"stopping-models"}"#).expect("a stage marker decodes"); + assert_eq!( + stage, + SwitchEvent::Stage { + stage: "stopping-models".to_string() + } + ); + let ready: SwitchEvent = + serde_json::from_str(r#"{"status":"ready","profile":"beta"}"#).expect("ready decodes"); + assert_eq!( + ready, + SwitchEvent::Ready { + profile: "beta".to_string() + } + ); + let error: SwitchEvent = + serde_json::from_str(r#"{"status":"error","message":"boom"}"#).expect("error decodes"); + assert_eq!( + error, + SwitchEvent::Error { + message: "boom".to_string() + } + ); +} + +/// Collects the typed events of a switch stream, panicking on a +/// transport error item. +async fn collect_switch_events(payloads: SsePayloadStream) -> Vec { + let mut events = Vec::new(); + let mut typed = switch_events(payloads); + while let Some(item) = typed.next().await { + events.push(item.expect("the stream is clean")); + } + events +} + +#[tokio::test] +async fn an_accepted_switch_streams_stages_then_the_terminal_event() { + let app = axum::Router::new().route( + "/admin/switch-profile", + axum::routing::post(|| async { + ( + [(axum::http::header::CONTENT_TYPE, "text/event-stream")], + concat!( + "data: {\"stage\":\"loading-profile\"}\n\n", + "data: {\"stage\":\"stopping-models\"}\n\n", + "data: {\"stage\":\"starting-models\"}\n\n", + "data: {\"status\":\"ready\",\"profile\":\"beta\"}\n\n", + ), + ) + }), + ); + let base_url = serve(app).await; + let client = GatewayClient::new(&base_url, "").expect("client builds in tests"); + let response = client + .switch_profile("beta") + .await + .expect("the request completes"); + let SwitchResponse::Switching { payloads, .. } = response else { + panic!("an accepted switch streams, got {response:?}"); + }; + assert_eq!( + collect_switch_events(payloads).await, + [ + SwitchEvent::Stage { + stage: "loading-profile".to_string() + }, + SwitchEvent::Stage { + stage: "stopping-models".to_string() + }, + SwitchEvent::Stage { + stage: "starting-models".to_string() + }, + SwitchEvent::Ready { + profile: "beta".to_string() + }, + ], + "the stream carries stage markers in order then the terminal ready" + ); +} + +#[tokio::test] +async fn a_malformed_switch_event_is_skipped_and_the_stream_continues() { + let app = axum::Router::new().route( + "/admin/switch-profile", + axum::routing::post(|| async { + ( + [(axum::http::header::CONTENT_TYPE, "text/event-stream")], + concat!( + "data: {\"stage\":\"loading-profile\"}\n\n", + "data: this is not json\n\n", + "data: {\"unrelated\":true}\n\n", + "data: {\"status\":\"error\",\"message\":\"start-local failed\"}\n\n", + ), + ) + }), + ); + let base_url = serve(app).await; + let client = GatewayClient::new(&base_url, "").expect("client builds in tests"); + let response = client + .switch_profile("beta") + .await + .expect("the request completes"); + let SwitchResponse::Switching { payloads, .. } = response else { + panic!("an accepted switch streams, got {response:?}"); + }; + assert_eq!( + collect_switch_events(payloads).await, + [ + SwitchEvent::Stage { + stage: "loading-profile".to_string() + }, + SwitchEvent::Error { + message: "start-local failed".to_string() + }, + ], + "malformed payloads are skipped; the terminal event still arrives" + ); +} + +#[tokio::test] +async fn a_declined_switch_is_buffered_not_an_error() { + let app = axum::Router::new().route( + "/admin/switch-profile", + axum::routing::post(|| async { + ( + axum::http::StatusCode::BAD_REQUEST, + axum::Json(serde_json::json!({ + "error": {"message": "bad name", "code": "switch_failed"} + })), + ) + }), + ); + let base_url = serve(app).await; + let client = GatewayClient::new(&base_url, "").expect("client builds in tests"); + let response = client + .switch_profile("../escape") + .await + .expect("a declined request still completes"); + let SwitchResponse::Buffered(answer) = response else { + panic!("a declined switch is buffered, got {response:?}"); + }; + assert_eq!(answer.status, reqwest::StatusCode::BAD_REQUEST); +} diff --git a/crates/workshop-gateway/src/gateway/tests/timeouts.rs b/crates/workshop-gateway/src/gateway/tests/timeouts.rs new file mode 100644 index 000000000..bd5366cea --- /dev/null +++ b/crates/workshop-gateway/src/gateway/tests/timeouts.rs @@ -0,0 +1,65 @@ +//! Timeout tests: a gateway that accepts and never answers must trip the +//! client's bounds rather than hang its caller. + +use super::*; + +/// Binds a stub that completes TCP handshakes and then never answers, +/// modeling a gateway that is up but wedged. +async fn spawn_stalled_gateway() -> String { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind stalled stub"); + let addr = listener.local_addr().expect("stalled stub address"); + tokio::spawn(async move { + // Sockets are held open and never answered until the test's + // runtime tears the task down. + let mut held = Vec::new(); + while let Ok((socket, _)) = listener.accept().await { + held.push(socket); + } + }); + format!("http://{addr}") +} + +/// A client against `base_url` whose bounds are tight enough to trip +/// inside a test. +fn impatient_client(base_url: &str) -> GatewayClient { + GatewayClient::new(base_url, "") + .expect("client builds in tests") + .with_timeouts_for_test(Duration::from_millis(100), Duration::from_millis(100)) +} + +#[tokio::test] +async fn a_stalled_gateway_trips_the_request_timeout() { + let base_url = spawn_stalled_gateway().await; + let error = impatient_client(&base_url) + .list_models() + .await + .expect_err("a gateway that never answers must trip the request timeout"); + assert!( + matches!(error, GatewayError::Transport(_)), + "expected Transport, got {error:?}" + ); +} + +#[tokio::test] +async fn a_stalled_gateway_trips_the_stream_header_bound() { + let base_url = spawn_stalled_gateway().await; + let error = impatient_client(&base_url) + .switch_profile("beta") + .await + .expect_err("a gateway that never sends headers must trip the header bound"); + assert!( + matches!(error, GatewayError::Transport(_)), + "expected Transport, got {error:?}" + ); +} + +#[tokio::test] +async fn a_stalled_gateway_probe_reads_unreachable() { + let base_url = spawn_stalled_gateway().await; + assert!( + !impatient_client(&base_url).health().await, + "a gateway that accepts but never answers must read unreachable" + ); +} diff --git a/crates/workshop-server/src/gateway_binding.rs b/crates/workshop-gateway/src/gateway_binding.rs similarity index 85% rename from crates/workshop-server/src/gateway_binding.rs rename to crates/workshop-gateway/src/gateway_binding.rs index 33d00d93f..d82d02325 100644 --- a/crates/workshop-server/src/gateway_binding.rs +++ b/crates/workshop-gateway/src/gateway_binding.rs @@ -21,7 +21,7 @@ use tokio::sync::watch; use crate::gateway::{GatewayClient, GatewayError}; /// One immutable generation of every Gateway client credential. -pub(crate) struct GatewaySnapshot { +pub struct GatewaySnapshot { /// HTTP and Realtime client used by Workshop routes and the heartbeat. client: GatewayClient, /// Normalized Gateway base URL paired with both clients. @@ -52,34 +52,39 @@ impl fmt::Debug for GatewaySnapshot { impl GatewaySnapshot { /// The HTTP and Realtime client in this generation. - pub(crate) fn client(&self) -> &GatewayClient { + #[must_use] + pub fn client(&self) -> &GatewayClient { &self.client } /// The agent model client from the same endpoint and credential pair. - pub(crate) fn model_client(&self) -> Option { + #[must_use] + pub fn model_client(&self) -> Option { self.model_client.clone() } /// The Gateway base URL in this generation. - pub(crate) fn base_url(&self) -> &str { + #[must_use] + pub fn base_url(&self) -> &str { &self.base_url } /// The Gateway bearer in this generation. - pub(crate) fn api_key(&self) -> &str { + #[must_use] + pub fn api_key(&self) -> &str { &self.api_key } /// This snapshot's monotonic generation. - pub(crate) fn generation(&self) -> u64 { + #[must_use] + pub fn generation(&self) -> u64 { self.generation } } /// Shared atomic Gateway snapshot and replacement notification. #[derive(Clone)] -pub(crate) struct GatewayBinding { +pub struct GatewayBinding { current: Arc>, changed: watch::Sender, publication: Arc>, @@ -115,13 +120,19 @@ impl fmt::Debug for GatewayBinding { impl GatewayBinding { /// Builds generation zero from one endpoint and credential pair. - #[cfg(test)] - pub(crate) fn new(base_url: &str, api_key: &str) -> Result { + /// + /// # Errors + /// Returns [`GatewayError::Build`] if the HTTP client cannot be built. + #[cfg(any(test, feature = "test-fixtures"))] + pub fn new(base_url: &str, api_key: &str) -> Result { Self::new_with_identity(base_url, api_key, None) } /// Builds generation zero with an optional validated local identity. - pub(crate) fn new_with_identity( + /// + /// # Errors + /// Returns [`GatewayError::Build`] if the HTTP client cannot be built. + pub fn new_with_identity( base_url: &str, api_key: &str, identity: Option, @@ -138,7 +149,8 @@ impl GatewayBinding { } /// Builds a binding around a client carrying test-specific timeouts. - pub(crate) fn from_client(client: GatewayClient) -> Self { + #[must_use] + pub fn from_client(client: GatewayClient) -> Self { let model_client = model_client(&client.base_url, &client.api_key); let base_url = client.base_url.clone(); let api_key = client.api_key.clone(); @@ -161,27 +173,31 @@ impl GatewayBinding { } /// Loads one endpoint and credential generation atomically. - pub(crate) fn snapshot(&self) -> Arc { + #[must_use] + pub fn snapshot(&self) -> Arc { self.current.load_full() } /// Subscribes to replacements after loading the current generation. - pub(crate) fn subscribe(&self) -> watch::Receiver { + #[must_use] + pub fn subscribe(&self) -> watch::Receiver { self.changed.subscribe() } /// The currently published generation. - pub(crate) fn generation(&self) -> u64 { + #[must_use] + pub fn generation(&self) -> u64 { self.snapshot().generation() } /// Builds and atomically publishes a replacement, then wakes consumers. + /// + /// # Errors + /// Returns [`GatewayPublicationError::Build`] if the replacement HTTP + /// client cannot initialize, or + /// [`GatewayPublicationError::PublicationClosed`] after teardown. #[cfg(any(test, feature = "test-fixtures"))] - pub(crate) fn replace( - &self, - base_url: &str, - api_key: &str, - ) -> Result<(), GatewayPublicationError> { + pub fn replace(&self, base_url: &str, api_key: &str) -> Result<(), GatewayPublicationError> { self.replace_with_identity(base_url, api_key, None) } @@ -230,7 +246,8 @@ impl GatewayBinding { } /// Creates the restricted handle the desktop host uses for sidecar updates. - pub(crate) fn updater(&self) -> GatewayUpdater { + #[must_use] + pub fn updater(&self) -> GatewayUpdater { GatewayUpdater { binding: self.clone(), } @@ -247,9 +264,9 @@ impl GatewayBinding { /// use shared_sidecar::GatewayDiscoveryFile; /// /// # fn publish( -/// # updater: &workshop_server::GatewayUpdater, +/// # updater: &workshop_gateway::GatewayUpdater, /// # raw: &GatewayDiscoveryFile, -/// # ) -> Result<(), workshop_server::GatewayPublicationError> { +/// # ) -> Result<(), workshop_gateway::GatewayPublicationError> { /// updater.replace_sidecar(raw) /// # } /// ``` @@ -288,8 +305,13 @@ impl GatewayUpdater { /// Replaces the configured Gateway in the crate's integration fixtures /// without manufacturing a production sidecar capability. + /// + /// # Errors + /// Returns [`GatewayPublicationError::Build`] if the replacement HTTP + /// client cannot initialize, or + /// [`GatewayPublicationError::PublicationClosed`] after teardown. #[cfg(feature = "test-fixtures")] - pub(crate) fn replace_fixture( + pub fn replace_fixture( &self, base_url: &str, api_key: &str, @@ -331,7 +353,7 @@ fn build_snapshot( } /// Builds the agent model client carried in a Gateway snapshot. -pub(crate) fn model_client(base_url: &str, api_key: &str) -> Option { +pub fn model_client(base_url: &str, api_key: &str) -> Option { let key = match SecretString::new(api_key) { Ok(key) => key, Err(error) => { diff --git a/crates/workshop-server/src/gateway_binding/publication.rs b/crates/workshop-gateway/src/gateway_binding/publication.rs similarity index 100% rename from crates/workshop-server/src/gateway_binding/publication.rs rename to crates/workshop-gateway/src/gateway_binding/publication.rs diff --git a/crates/workshop-server/src/gateway_binding/shutdown.rs b/crates/workshop-gateway/src/gateway_binding/shutdown.rs similarity index 100% rename from crates/workshop-server/src/gateway_binding/shutdown.rs rename to crates/workshop-gateway/src/gateway_binding/shutdown.rs diff --git a/crates/workshop-server/src/gateway_binding/tests.rs b/crates/workshop-gateway/src/gateway_binding/tests.rs similarity index 100% rename from crates/workshop-server/src/gateway_binding/tests.rs rename to crates/workshop-gateway/src/gateway_binding/tests.rs diff --git a/crates/workshop-server/src/gateway_binding/tests/atomic.rs b/crates/workshop-gateway/src/gateway_binding/tests/atomic.rs similarity index 100% rename from crates/workshop-server/src/gateway_binding/tests/atomic.rs rename to crates/workshop-gateway/src/gateway_binding/tests/atomic.rs diff --git a/crates/workshop-server/src/gateway_binding/tests/publication.rs b/crates/workshop-gateway/src/gateway_binding/tests/publication.rs similarity index 100% rename from crates/workshop-server/src/gateway_binding/tests/publication.rs rename to crates/workshop-gateway/src/gateway_binding/tests/publication.rs diff --git a/crates/workshop-server/src/gateway_binding/tests/shutdown.rs b/crates/workshop-gateway/src/gateway_binding/tests/shutdown.rs similarity index 100% rename from crates/workshop-server/src/gateway_binding/tests/shutdown.rs rename to crates/workshop-gateway/src/gateway_binding/tests/shutdown.rs diff --git a/crates/workshop-gateway/src/gateway_progress.rs b/crates/workshop-gateway/src/gateway_progress.rs new file mode 100644 index 000000000..60f506b17 --- /dev/null +++ b/crates/workshop-gateway/src/gateway_progress.rs @@ -0,0 +1,197 @@ +//! The gateway progress subscriber: a background task that imports the +//! gateway's `GET /admin/progress` event stream into the workshop +//! [`ProgressHub`] as a [`RemoteOperation`], so gateway-side work (model +//! downloads, profile switches) renders on the status bar through the same +//! renderer task as local operations. +//! +//! The task follows the heartbeat's lifecycle posture: spawned with the +//! server, stopped through its [`Subscriber`] handle inside the same +//! graceful-shutdown signal, and driven by the shared [`GatewayHealth`] +//! verdict rather than by probes of its own. It subscribes while the +//! gateway reads reachable and idles while it does not; a reconnect +//! resubscribes, and each subscription tracks one import per upstream +//! operation id, so interleaved work stays separate and a finished operation +//! detaches without closing the long-lived event stream. +//! When the subscription drops - a lost connection or an unreachable +//! verdict - the import detaches with it, because progress from a gateway +//! the workshop can no longer hear is stale, not informative. + +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; + +use futures_util::StreamExt; +use tokio::sync::oneshot; + +use promptforge_model_client::model::subscribe_progress; +use shared_progress::{EventState, OperationId, ProgressHub, RemoteOperation}; + +use crate::gateway_binding::GatewayBinding; +use crate::heartbeat::GatewayHealth; + +/// How long a resubscribe waits when the stream ended while the gateway +/// still reads reachable, so an endpoint that accepts and immediately +/// closes cannot spin the loop. A reachability flip restarts at once; +/// matched to the heartbeat's probe cadence. +const RESUBSCRIBE_DELAY: Duration = Duration::from_secs(5); + +/// A running subscriber task. +/// +/// [`Subscriber::shutdown`] signals the task to stop and awaits it. +/// Dropping the handle without shutting down still stops the task at its +/// next select point, because the closed channel resolves the stop branch. +#[derive(Debug)] +pub struct Subscriber { + stop: Option>, + task: Option>, +} + +impl Subscriber { + /// Signals the subscriber to stop and waits for its task to finish. + pub async fn shutdown(mut self) { + if let Some(stop) = self.stop.take() { + let _ = stop.send(()); + } + if let Some(task) = self.task.take() { + let _ = task.await; + } + } +} + +/// Spawns the subscriber task against the gateway at `base_url`, +/// importing its progress events into `hub` while `health` reads +/// reachable. +#[must_use] +pub fn spawn(gateway: GatewayBinding, hub: Arc, health: GatewayHealth) -> Subscriber { + spawn_with_delay(gateway, hub, health, RESUBSCRIBE_DELAY) +} + +/// [`spawn`] with the resubscribe delay injected, so tests can shorten it. +fn spawn_with_delay( + gateway: GatewayBinding, + hub: Arc, + health: GatewayHealth, + resubscribe_delay: Duration, +) -> Subscriber { + let (stop, mut stopped) = oneshot::channel(); + let task = tokio::spawn(async move { + run(&gateway, &hub, &health, resubscribe_delay, &mut stopped).await; + }); + Subscriber { + stop: Some(stop), + task: Some(task), + } +} + +/// The subscription loop: idle while the gateway is unreachable, and while +/// reachable hold one subscription whose events drive operation-id-keyed +/// [`RemoteOperation`] imports. An operation-level terminal event +/// detaches that import while the subscription remains open. The stop +/// signal wins every select, so shutdown never waits out a stream read, a +/// connect, or a resubscribe delay. +async fn run( + gateway: &GatewayBinding, + hub: &Arc, + health: &GatewayHealth, + resubscribe_delay: Duration, + stop: &mut oneshot::Receiver<()>, +) { + let mut reachable = health.subscribe(); + let mut gateway_changed = gateway.subscribe(); + 'reconnect: loop { + while !*reachable.borrow_and_update() { + tokio::select! { + _ = &mut *stop => return, + changed = gateway_changed.changed() => { + if changed.is_err() { + return; + } + } + changed = reachable.changed() => { + // The sender lives in AppState for the process + // lifetime, so a closed watch means shutdown. + if changed.is_err() { + return; + } + } + } + } + let snapshot = gateway.snapshot(); + let stream = tokio::select! { + _ = &mut *stop => return, + _ = reachable.changed() => continue, + changed = gateway_changed.changed() => { + if changed.is_err() { + return; + } + continue; + } + result = subscribe_progress(snapshot.base_url(), snapshot.api_key()) => match result { + Ok(stream) => stream, + Err(error) => { + tracing::warn!(%error, "gateway progress subscription failed"); + tokio::select! { + _ = &mut *stop => return, + _ = reachable.changed() => {} + changed = gateway_changed.changed() => { + if changed.is_err() { + return; + } + } + () = tokio::time::sleep(resubscribe_delay) => {} + } + continue; + } + }, + }; + let mut remotes: HashMap = HashMap::new(); + tokio::pin!(stream); + loop { + tokio::select! { + _ = &mut *stop => return, + _ = reachable.changed() => break, + changed = gateway_changed.changed() => { + if changed.is_err() { + return; + } + continue 'reconnect; + } + item = stream.next() => match item { + Some(Ok(event)) => { + let operation = event.operation; + if matches!(event.state, EventState::OperationFinished) { + remotes.remove(&operation); + continue; + } + remotes + .entry(operation) + .or_insert_with(|| RemoteOperation::attach(hub)) + .apply(&event); + } + // One malformed event or a terminal read failure; the + // stream itself decides which by continuing or ending. + Some(Err(error)) => { + tracing::warn!(%error, "gateway progress event skipped"); + } + None => break, + } + } + } + drop(remotes); + if *reachable.borrow_and_update() { + tokio::select! { + _ = &mut *stop => return, + _ = reachable.changed() => {} + changed = gateway_changed.changed() => { + if changed.is_err() { + return; + } + } + () = tokio::time::sleep(resubscribe_delay) => {} + } + } + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/workshop-gateway/src/gateway_progress/tests.rs b/crates/workshop-gateway/src/gateway_progress/tests.rs new file mode 100644 index 000000000..a7c19f608 --- /dev/null +++ b/crates/workshop-gateway/src/gateway_progress/tests.rs @@ -0,0 +1,307 @@ +// Fractions are fixed-point millionths, so equality comparisons are exact +// (the shared-progress remote.rs test precedent). +#![expect(clippy::float_cmp, reason = "fixed-point fractions compare exactly")] + +use super::*; + +use std::sync::atomic::{AtomicUsize, Ordering}; + +use axum::extract::State; +use axum::response::{IntoResponse, Response}; +use tokio::sync::broadcast; + +use shared_progress::OperationSnapshot; + +/// Binds `app` as a mock gateway on a free loopback port and returns its +/// base URL. +async fn spawn_gateway(app: axum::Router) -> String { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind mock gateway"); + let addr = listener.local_addr().expect("mock gateway address"); + tokio::spawn(async move { + axum::serve(listener, app) + .await + .expect("mock gateway serves"); + }); + format!("http://{addr}") +} + +/// A replaceable binding for one mock Gateway. +fn binding(base_url: &str) -> GatewayBinding { + GatewayBinding::new(base_url, "").expect("the test binding builds") +} + +/// A mock `GET /admin/progress`: every payload published to the feed +/// streams to every connected subscriber as an SSE `data:` frame, and +/// `connections` counts how often the endpoint was hit. The receiver +/// is created before the count increments, so a test that observes a +/// connection can publish without losing the frame. [`close`](Self::close) +/// ends every live stream, so a test can drive the resubscribe path. +struct MockProgress { + connections: AtomicUsize, + feeds: std::sync::Mutex>, +} + +impl MockProgress { + fn new() -> Self { + Self { + connections: AtomicUsize::new(0), + feeds: std::sync::Mutex::new(broadcast::channel(16).0), + } + } + + fn router(self: Arc) -> axum::Router { + axum::Router::new() + .route("/admin/progress", axum::routing::get(serve_feed)) + .with_state(self) + } + + /// Publishes one payload to every connected subscriber. + fn send(&self, payload: String) { + self.feeds + .lock() + .expect("the feed lock is not poisoned") + .send(payload) + .expect("the mock has a subscriber"); + } + + /// Ends every live stream; later connections subscribe to the + /// fresh feed. + fn close(&self) { + *self.feeds.lock().expect("the feed lock is not poisoned") = broadcast::channel(16).0; + } +} + +async fn serve_feed(State(mock): State>) -> Response { + let rx = mock + .feeds + .lock() + .expect("the feed lock is not poisoned") + .subscribe(); + mock.connections.fetch_add(1, Ordering::Relaxed); + let stream = futures_util::stream::unfold(rx, |mut rx| async move { + loop { + match rx.recv().await { + Ok(payload) => { + return Some(( + Ok::<_, std::convert::Infallible>(format!("data: {payload}\n\n")), + rx, + )); + } + Err(broadcast::error::RecvError::Lagged(_)) => {} + Err(broadcast::error::RecvError::Closed) => return None, + } + } + }); + ( + [(axum::http::header::CONTENT_TYPE, "text/event-stream")], + axum::body::Body::from_stream(stream), + ) + .into_response() +} + +/// Serializes a wire-format progress event by hand, so the tests pin +/// the JSON shape the gateway emits rather than the progress crate's +/// constructors (the gateway-client test pattern). +fn event_json(path: &str, state: &serde_json::Value) -> String { + serde_json::json!({ + "operation": 7, + "path": path, + "label": path, + "state": state, + }) + .to_string() +} + +/// Polls the hub's snapshot until `accept` holds, within a generous +/// deadline (the heartbeat tests' snapshot_where pattern). +async fn snapshot_where( + hub: &ProgressHub, + accept: impl Fn(&[OperationSnapshot]) -> bool, +) -> Vec { + tokio::time::timeout(Duration::from_secs(5), async { + loop { + let snapshot = hub.snapshot(); + if accept(&snapshot) { + return snapshot; + } + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await + .expect("a matching snapshot arrives within the deadline") +} + +/// Polls the mock's connection count until it reaches `n`. +async fn wait_for_connections(mock: &MockProgress, n: usize) { + tokio::time::timeout(Duration::from_secs(5), async { + while mock.connections.load(Ordering::Relaxed) < n { + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await + .expect("the subscriber connects within the deadline"); +} + +#[tokio::test] +async fn events_from_the_gateway_feed_a_remote_operation_on_the_hub() { + let mock = Arc::new(MockProgress::new()); + let base_url = spawn_gateway(Arc::clone(&mock).router()).await; + let hub = Arc::new(ProgressHub::new()); + // The flag starts optimistic, so the subscriber connects at once. + let subscriber = spawn(binding(&base_url), Arc::clone(&hub), GatewayHealth::new()); + + wait_for_connections(&mock, 1).await; + mock.send(event_json( + "download", + &serde_json::json!({"Begun": {"weight": 1.0}}), + )); + mock.send(event_json( + "download", + &serde_json::json!({"Updated": {"fraction": 0.5}}), + )); + + let snapshot = snapshot_where(&hub, |s| { + s.len() == 1 && s[0].nodes.iter().any(|n| n.fraction == 0.5) + }) + .await; + assert_eq!(snapshot[0].nodes[0].path, "download"); + assert_eq!(snapshot[0].nodes[0].label, "download"); + subscriber.shutdown().await; +} + +#[tokio::test] +async fn a_malformed_event_is_skipped_and_the_stream_continues() { + let mock = Arc::new(MockProgress::new()); + let base_url = spawn_gateway(Arc::clone(&mock).router()).await; + let hub = Arc::new(ProgressHub::new()); + let subscriber = spawn(binding(&base_url), Arc::clone(&hub), GatewayHealth::new()); + + wait_for_connections(&mock, 1).await; + mock.send(event_json( + "download", + &serde_json::json!({"Begun": {"weight": 1.0}}), + )); + // One undecodable `data:` block between two valid events: the + // subscriber warns and continues rather than dropping the stream. + mock.send("{not valid json".to_owned()); + mock.send(event_json( + "download", + &serde_json::json!({"Updated": {"fraction": 0.5}}), + )); + + let snapshot = snapshot_where(&hub, |s| { + s.len() == 1 && s[0].nodes.iter().any(|n| n.fraction == 0.5) + }) + .await; + assert_eq!( + snapshot[0].nodes[0].path, "download", + "the event after the malformed one still lands on the hub" + ); + subscriber.shutdown().await; +} + +#[tokio::test] +async fn a_stream_that_ends_while_reachable_resubscribes_after_the_delay() { + let mock = Arc::new(MockProgress::new()); + let base_url = spawn_gateway(Arc::clone(&mock).router()).await; + let hub = Arc::new(ProgressHub::new()); + let delay = Duration::from_millis(50); + let subscriber = spawn_with_delay( + binding(&base_url), + Arc::clone(&hub), + GatewayHealth::new(), + delay, + ); + + wait_for_connections(&mock, 1).await; + mock.send(event_json( + "download", + &serde_json::json!({"Begun": {"weight": 1.0}}), + )); + snapshot_where(&hub, |s| s.len() == 1).await; + + // The stream ends while the gateway still reads reachable: the + // import detaches, and a fresh subscription follows the delay. + let closed = std::time::Instant::now(); + mock.close(); + snapshot_where(&hub, <[OperationSnapshot]>::is_empty).await; + wait_for_connections(&mock, 2).await; + assert!( + closed.elapsed() >= delay, + "the resubscribe waits out the delay rather than spinning" + ); + subscriber.shutdown().await; +} + +#[tokio::test] +async fn an_unreachable_gateway_holds_no_subscription_and_no_remote_state() { + let mock = Arc::new(MockProgress::new()); + let base_url = spawn_gateway(Arc::clone(&mock).router()).await; + let hub = Arc::new(ProgressHub::new()); + let health = GatewayHealth::new(); + health.publish(false); + let subscriber = spawn(binding(&base_url), Arc::clone(&hub), health.clone()); + + let quiet = tokio::time::timeout(Duration::from_millis(200), async { + wait_for_connections(&mock, 1).await; + }) + .await; + assert!( + quiet.is_err(), + "an unreachable gateway must not be subscribed" + ); + assert!(hub.snapshot().is_empty()); + + health.publish(true); + wait_for_connections(&mock, 1).await; + subscriber.shutdown().await; +} + +#[tokio::test] +async fn a_reconnect_resubscribes_without_duplicating_state() { + let mock = Arc::new(MockProgress::new()); + let base_url = spawn_gateway(Arc::clone(&mock).router()).await; + let hub = Arc::new(ProgressHub::new()); + let health = GatewayHealth::new(); + let subscriber = spawn(binding(&base_url), Arc::clone(&hub), health.clone()); + + wait_for_connections(&mock, 1).await; + mock.send(event_json( + "download", + &serde_json::json!({"Begun": {"weight": 1.0}}), + )); + let first = snapshot_where(&hub, |s| s.len() == 1).await; + + health.publish(false); + snapshot_where(&hub, <[OperationSnapshot]>::is_empty).await; + + health.publish(true); + wait_for_connections(&mock, 2).await; + mock.send(event_json( + "download", + &serde_json::json!({"Begun": {"weight": 1.0}}), + )); + mock.send(event_json( + "download", + &serde_json::json!({"Updated": {"fraction": 0.5}}), + )); + let reconnected = snapshot_where(&hub, |s| { + s.len() == 1 && s[0].nodes.iter().any(|n| n.fraction == 0.5) + }) + .await; + assert_eq!( + reconnected.len(), + 1, + "the reconnect replaces the import, never stacks a second one" + ); + assert_ne!( + first[0].operation, reconnected[0].operation, + "the resubscription attaches a fresh import under a new local id" + ); + subscriber.shutdown().await; +} + +mod lifecycle; +mod recovery; diff --git a/crates/workshop-server/src/gateway_progress/tests/lifecycle.rs b/crates/workshop-gateway/src/gateway_progress/tests/lifecycle.rs similarity index 100% rename from crates/workshop-server/src/gateway_progress/tests/lifecycle.rs rename to crates/workshop-gateway/src/gateway_progress/tests/lifecycle.rs diff --git a/crates/workshop-server/src/gateway_progress/tests/recovery.rs b/crates/workshop-gateway/src/gateway_progress/tests/recovery.rs similarity index 100% rename from crates/workshop-server/src/gateway_progress/tests/recovery.rs rename to crates/workshop-gateway/src/gateway_progress/tests/recovery.rs diff --git a/crates/workshop-gateway/src/heartbeat.rs b/crates/workshop-gateway/src/heartbeat.rs new file mode 100644 index 000000000..c7cc51ec7 --- /dev/null +++ b/crates/workshop-gateway/src/heartbeat.rs @@ -0,0 +1,352 @@ +//! The gateway heartbeat: a background task polling the gateway's +//! `GET /health` endpoint and publishing reachability to the rest of the +//! server. +//! +//! One task is spawned with the server ([`spawn`]): while the gateway +//! answers, it probes through [`GatewayClient::health`](crate::gateway::GatewayClient::health) on the fixed +//! [`HEARTBEAT_INTERVAL`] and publishes the outcome to the shared +//! [`GatewayHealth`] flag the gateway-dependent routes read; while the +//! gateway is unreachable, the next probe instead waits out a delay +//! drawn from the shared [`ReconnectBackoff`] - jittered, escalating, +//! and reset only by useful work elsewhere (a delivered token or a +//! successful completion), never by a probe that merely connects, so a +//! gateway that flaps without delivering keeps escalating. When the +//! backoff's total-delay budget exhausts, the loop reports the give-up +//! on the status bus and stops probing for the life of the process. +//! The observer hears about transitions only - the first probe reports +//! the initial state ("Connected to gateway" or "Gateway unreachable"), +//! and after that a status update fires when the answer changes, so a +//! steady state never spams the status bar. Every transition also feeds +//! the Model menu's reachability (so `chat_ready` flips with the +//! gateway), and a transition to reachable (boot's first probe included) +//! refreshes the gateway's profile state and model catalog into their +//! buses. If simultaneous startup leaves either source empty, later healthy +//! ticks retry each source independently until the profile and a selectable +//! model are both ready, then restore the selection exactly once. +//! +//! The task stops through its [`Heartbeat`] handle: the signal wins the +//! loop's selects, so shutdown never waits out a tick or an in-flight +//! probe. The server runs the shutdown inside its graceful-shutdown future. + +use std::time::Duration; + +use tokio::sync::{oneshot, watch}; + +use workshop_protocol::{Activity, Severity, StatusBarUpdate}; +use workshop_registry::Push; +use workshop_support::ReconnectBackoff; + +use crate::gateway_binding::{GatewayBinding, GatewaySnapshot}; + +mod refresh; +pub use refresh::{refresh_catalog, refresh_profiles}; + +/// The status line announcing that the gateway answers its health probe. +pub const CONNECTED_LABEL: &str = "Connected to gateway"; +/// The status line announcing that the gateway does not answer. +pub const UNREACHABLE_LABEL: &str = "Gateway unreachable"; +/// The description riding the unreachable announcement. +pub const UNREACHABLE_DESCRIPTION: &str = "the gateway does not answer its health probe"; + +/// The status frame a joining session hears first: the bus's retained +/// frame, unless that frame is one of the heartbeat's transition +/// announcements. A transition describes a past moment, not the current +/// state - the boot-time "Connected to gateway" outlives itself within +/// seconds - so the line is recomputed from the current probe. A retained +/// frame carrying real work (a download's progress, a chat's activity) +/// replays as-is. +#[must_use] +pub fn join_status( + retained: Option, + health: &GatewayHealth, +) -> Option { + let update = retained?; + if update.label != CONNECTED_LABEL && update.label != UNREACHABLE_LABEL { + return Some(update); + } + let reachable = health.is_reachable(); + Some(StatusBarUpdate { + label: if reachable { + "Ready" + } else { + UNREACHABLE_LABEL + } + .to_owned(), + description: if reachable { + "idle".to_owned() + } else { + UNREACHABLE_DESCRIPTION.to_owned() + }, + progress: None, + severity: Severity::Info, + activity: Activity::General, + }) +} + +/// How often the heartbeat probes a reachable gateway. Hardcoded for +/// now; a configuration knob may follow once someone needs one. Probes +/// of an unreachable gateway follow the [`ReconnectBackoff`] instead. +pub const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(5); + +/// Shared gateway reachability, written by the heartbeat and read by the +/// gateway-dependent routes. +/// +/// The flag starts optimistic (`true`): until the first probe lands, a +/// request flows to the gateway and fails or succeeds on its own merits, +/// which keeps a server running without a heartbeat (every router-only +/// test) behaving exactly as it did before the heartbeat existed. +#[derive(Debug, Clone)] +pub struct GatewayHealth { + reachable: watch::Sender, +} + +impl GatewayHealth { + /// Starts the flag optimistic; see the type docs for why. + #[must_use] + pub fn new() -> Self { + Self { + reachable: watch::channel(true).0, + } + } + + /// Whether the gateway is currently believed reachable. + #[must_use] + pub fn is_reachable(&self) -> bool { + *self.reachable.borrow() + } + + /// Subscribes to reachability changes. The current value is visible + /// immediately through the receiver; each later publish that flips the + /// flag notifies. The provisioning task waits on this to run its cache + /// calls only while the gateway answers. + #[must_use] + pub fn subscribe(&self) -> watch::Receiver { + self.reachable.subscribe() + } + + /// Publishes one probe outcome. The heartbeat is the only production + /// writer; tests publish directly to pin the degraded paths. + pub fn publish(&self, reachable: bool) { + self.reachable.send_if_modified(|current| { + let changed = *current != reachable; + *current = reachable; + changed + }); + } +} + +impl Default for GatewayHealth { + fn default() -> Self { + Self::new() + } +} + +/// A running heartbeat task. +/// +/// [`Heartbeat::shutdown`] signals the loop to stop and awaits the task. +/// Dropping the handle without shutting down still stops the task at its +/// next select point, because the closed channel resolves the stop branch. +#[derive(Debug)] +pub struct Heartbeat { + stop: Option>, + task: Option>, +} + +impl Heartbeat { + /// Signals the heartbeat to stop and waits for its task to finish. + pub async fn shutdown(mut self) { + if let Some(stop) = self.stop.take() { + let _ = stop.send(()); + } + if let Some(task) = self.task.take() { + let _ = task.await; + } + } +} + +/// Spawns the heartbeat loop against `client`, reporting transitions +/// through `push` and publishing reachability to `health` and to the +/// menu behind `push`, which recomputes `chat_ready` from it. A +/// transition to reachable - boot's first probe included - refreshes the +/// gateway's profile state and model catalog through the same handle, +/// then restores a model selection when none is applied. Healthy ticks +/// repeat each incomplete refresh independently, covering a gateway whose +/// health endpoint becomes ready before its catalog or profile state. The first +/// probe runs immediately; later probes follow `interval` while the +/// gateway answers and draw from `backoff` while it does not, ending the +/// loop when the backoff's budget exhausts. +#[must_use] +pub fn spawn( + gateway: GatewayBinding, + push: Push, + health: GatewayHealth, + interval: Duration, + backoff: ReconnectBackoff, +) -> Heartbeat { + let (stop, mut stopped) = oneshot::channel(); + let task = tokio::spawn(async move { + run(&gateway, &push, &health, interval, &backoff, &mut stopped).await; + }); + Heartbeat { + stop: Some(stop), + task: Some(task), + } +} + +/// The probe loop: a status update per transition, with the stop signal +/// winning over the wait, an in-flight probe, an in-flight profile +/// refresh, and an in-flight catalog refresh. The wait before each probe +/// is `interval` while the gateway answered last time (measured from the +/// previous probe's completion, so a slow probe never bunches into a +/// catch-up burst) and the backoff's next delay while it did not; a +/// successful probe deliberately never resets the backoff - only useful +/// work does, elsewhere - and an exhausted budget ends the loop with a +/// give-up report. +#[derive(Default)] +struct RefreshState { + profiles_ready: bool, + catalog_ready: bool, + selection_restored: bool, +} + +async fn run( + gateway: &GatewayBinding, + push: &Push, + health: &GatewayHealth, + interval: Duration, + backoff: &ReconnectBackoff, + stop: &mut oneshot::Receiver<()>, +) { + let mut last: Option = None; + let mut refresh = RefreshState::default(); + let mut gateway_changed = gateway.subscribe(); + loop { + // The first probe runs immediately; every later one waits here. + if let Some(reachable) = last { + let wait = if reachable { + interval + } else if let Some(delay) = backoff.next_delay() { + delay + } else { + push.push_failure( + "Gateway reconnect stopped", + "the reconnect budget is exhausted; restart the workshop to retry", + Activity::General, + ); + break; + }; + tokio::select! { + _ = &mut *stop => break, + changed = gateway_changed.changed() => { + if changed.is_err() { + break; + } + last = None; + refresh = RefreshState::default(); + continue; + } + () = tokio::time::sleep(wait) => {} + } + } + let snapshot = gateway.snapshot(); + let generation = snapshot.generation(); + let reachable = tokio::select! { + _ = &mut *stop => break, + changed = gateway_changed.changed() => { + if changed.is_err() { + break; + } + last = None; + refresh = RefreshState::default(); + continue; + } + reachable = snapshot.client().health() => reachable, + }; + if gateway.generation() != generation { + last = None; + refresh = RefreshState::default(); + continue; + } + health.publish(reachable); + let transitioned = last != Some(reachable); + last = Some(reachable); + if transitioned { + // The menu recomputes chat_ready from reachability, so the + // verdict feeds it before any slower refresh work below. + push.menu().set_gateway_reachable(reachable); + if reachable { + push.push_status_update( + CONNECTED_LABEL, + "the gateway answers its health probe", + Activity::General, + ); + } else { + push.push_status_update( + UNREACHABLE_LABEL, + UNREACHABLE_DESCRIPTION, + Activity::General, + ); + } + } + if !reachable { + refresh = RefreshState::default(); + continue; + } + if !refresh.profiles_ready || !refresh.catalog_ready { + // All menu state is server-owned and reaches the UI via + // socket pushes - the UI fetches nothing on boot - so every + // transition into reachable, boot's first probe included, + // (re)populates the profile state and the model catalog. + // Healthy ticks independently repeat either refresh until both + // sources are populated, because health and one ready source do + // not imply the other source is ready. The interval above bounds + // retries and keeps this from becoming a busy loop. + tokio::select! { + _ = &mut *stop => break, + changed = gateway_changed.changed() => { + if changed.is_err() { + break; + } + last = None; + refresh = RefreshState::default(); + continue; + } + () = refresh_incomplete_sources( + &snapshot, + push, + &mut refresh, + ) => {} + } + } + if refresh.profiles_ready && refresh.catalog_ready && !refresh.selection_restored { + // A fresh boot has no selection, so restore the remembered + // model for the now-known active profile (else the first + // catalog model); a reconnect whose selection survived the + // outage is a no-op. This branch runs exactly once per reachable + // convergence because both readiness facts remain true. + push.menu().restore_selection(); + refresh.selection_restored = true; + } + } +} + +/// Refreshes only the Gateway-owned menu sources that have not converged. +async fn refresh_incomplete_sources( + snapshot: &GatewaySnapshot, + push: &Push, + refresh: &mut RefreshState, +) { + match (refresh.profiles_ready, refresh.catalog_ready) { + (false, false) => { + (refresh.profiles_ready, refresh.catalog_ready) = tokio::join!( + refresh_profiles(snapshot.client(), push), + refresh_catalog(snapshot.client(), push) + ); + } + (false, true) => refresh.profiles_ready = refresh_profiles(snapshot.client(), push).await, + (true, false) => refresh.catalog_ready = refresh_catalog(snapshot.client(), push).await, + (true, true) => {} + } +} +#[cfg(test)] +mod tests; diff --git a/crates/workshop-server/src/heartbeat/refresh.rs b/crates/workshop-gateway/src/heartbeat/refresh.rs similarity index 94% rename from crates/workshop-server/src/heartbeat/refresh.rs rename to crates/workshop-gateway/src/heartbeat/refresh.rs index 0bb3c82b8..db078c872 100644 --- a/crates/workshop-server/src/heartbeat/refresh.rs +++ b/crates/workshop-gateway/src/heartbeat/refresh.rs @@ -1,15 +1,16 @@ //! Gateway profile and model-catalog refresh after reachability. -use crate::catalog::is_chat_capable; +use workshop_protocol::is_chat_capable; +use workshop_registry::Push; + use crate::gateway::GatewayClient; -use crate::push::Push; /// Re-fetches the gateway's model catalog and pushes it to every session. /// /// A failed, declined, or malformed catalog is logged and skipped rather /// than pushed: pushing a bad snapshot would clear pickers that still hold /// a usable list. -pub(crate) async fn refresh_catalog(client: &GatewayClient, push: &Push) -> bool { +pub async fn refresh_catalog(client: &GatewayClient, push: &Push) -> bool { let response = match client.list_models().await { Ok(response) => response, Err(error) => { @@ -59,7 +60,7 @@ struct ProfileStatus { /// A gateway without profile support is a state, not an error: a failed, /// declined, or malformed answer degrades that half to empty, so the menu /// shows no profiles rather than stale names. -pub(crate) async fn refresh_profiles(client: &GatewayClient, push: &Push) -> bool { +pub async fn refresh_profiles(client: &GatewayClient, push: &Push) -> bool { let (profiles, active) = tokio::join!(fetch_profile_list(client), fetch_active_profile(client)); let ready = profiles.as_ref().is_some_and(|profiles| { !profiles.is_empty() diff --git a/crates/workshop-gateway/src/heartbeat/tests.rs b/crates/workshop-gateway/src/heartbeat/tests.rs new file mode 100644 index 000000000..2d95d42bc --- /dev/null +++ b/crates/workshop-gateway/src/heartbeat/tests.rs @@ -0,0 +1,123 @@ +//! Heartbeat unit tests: the join-line recompute and the probe bound. +//! The bus-coupled loop behavior (transitions, refreshes, convergence) +//! is pinned by the workshop-server integration tests, which compose the +//! heartbeat with the real status, catalog, and menu buses. + +use super::*; +use crate::gateway::GatewayClient; + +fn retained(label: &str) -> StatusBarUpdate { + StatusBarUpdate { + label: label.to_owned(), + description: String::new(), + progress: None, + severity: Severity::Info, + activity: Activity::General, + } +} + +#[test] +fn a_join_recomputes_a_stale_connect_announcement_to_the_resting_line() { + let health = GatewayHealth::new(); + let update = join_status(Some(retained(CONNECTED_LABEL)), &health) + .expect("a retained transition still yields a join line"); + assert_eq!(update.label, "Ready"); + assert_eq!(update.severity, Severity::Info); +} + +#[test] +fn a_join_recomputes_a_stale_connect_announcement_during_an_outage() { + let health = GatewayHealth::new(); + health.publish(false); + let update = join_status(Some(retained(CONNECTED_LABEL)), &health) + .expect("a retained transition still yields a join line"); + assert_eq!(update.label, UNREACHABLE_LABEL); + assert_eq!(update.description, UNREACHABLE_DESCRIPTION); +} + +#[test] +fn a_join_keeps_a_retained_outage_while_the_gateway_is_down() { + let health = GatewayHealth::new(); + health.publish(false); + let update = join_status(Some(retained(UNREACHABLE_LABEL)), &health) + .expect("the outage line survives the recompute"); + assert_eq!(update.label, UNREACHABLE_LABEL); +} + +#[test] +fn a_join_replays_a_retained_frame_carrying_real_work() { + let health = GatewayHealth::new(); + let working = Some(StatusBarUpdate { + label: "Downloading model".to_owned(), + description: "ggml-large-v3.bin".to_owned(), + progress: Some(workshop_protocol::Progress { + current: 1, + total: 2, + }), + severity: Severity::Info, + activity: Activity::General, + }); + let update = join_status(working, &health).expect("the work frame replays as-is"); + assert_eq!(update.label, "Downloading model"); + assert!(update.progress.is_some()); +} + +#[test] +fn a_join_with_no_retained_frame_sends_nothing() { + let health = GatewayHealth::new(); + assert!(join_status(None, &health).is_none()); +} + +#[test] +fn the_probe_bound_is_shorter_than_the_heartbeat_interval() { + assert!( + crate::gateway::HEALTH_PROBE_TIMEOUT < HEARTBEAT_INTERVAL, + "a probe outlasting the interval would back the heartbeat up \ + behind a stalled gateway" + ); +} + +#[tokio::test] +async fn a_stalled_gateway_reads_unreachable_within_the_probe_bound() { + // A stub that completes TCP handshakes and never answers: without + // a bounded probe, the first probe would hang forever and the + // heartbeat would never report at all. + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind stalled stub"); + let addr = listener.local_addr().expect("stalled stub address"); + tokio::spawn(async move { + let mut held = Vec::new(); + while let Ok((socket, _)) = listener.accept().await { + held.push(socket); + } + }); + let client = GatewayClient::new(&format!("http://{addr}"), "") + .expect("client builds in tests") + .with_timeouts_for_test(Duration::from_millis(100), Duration::from_millis(100)); + // A recording status sink stands in for the status bus. + let registry = workshop_registry::Registry::new(); + let (status_tx, mut rx) = tokio::sync::broadcast::channel(16); + let _sink = registry.status_sink().register(std::sync::Arc::new( + workshop_registry::StatusSinkAdapter::new(move |update| { + let _ = status_tx.send(update); + }), + )); + let heartbeat = spawn( + GatewayBinding::from_client(client), + registry.push(), + GatewayHealth::new(), + Duration::from_millis(25), + ReconnectBackoff::with_schedule( + Duration::from_millis(10), + Duration::from_millis(40), + Duration::from_secs(60), + ), + ); + let update = tokio::time::timeout(Duration::from_secs(5), rx.recv()) + .await + .expect("a status update arrives within the deadline") + .expect("the recording sink is open"); + assert_eq!(update.label, "Gateway unreachable"); + heartbeat.shutdown().await; +} diff --git a/crates/workshop-gateway/src/lib.rs b/crates/workshop-gateway/src/lib.rs new file mode 100644 index 000000000..b3e84fb0e --- /dev/null +++ b/crates/workshop-gateway/src/lib.rs @@ -0,0 +1,38 @@ +//! workshop-gateway - the gateway subsystem: the bearer-authenticated +//! HTTP client for the PromptForge gateway's OpenAI-compatible API, the +//! replaceable endpoint binding and discovery-file resolution, the +//! reachability heartbeat, the gateway progress subscriber, and the +//! workshop's run event log. +//! +//! ## Invariants +//! +//! - Tier: service; may depend on: `workshop-protocol`, `workshop-registry`, +//! `workshop-support`. Read `AGENTS.md` before adding an import. +//! - Every file in this crate stays under 500 lines; split first, then +//! edit. +//! - No axum type appears in this crate's public API: the domain code +//! speaks `reqwest` statuses and raw bodies, and the shell maps them +//! to HTTP responses. +//! - A bearer key is never written to logs or `Debug` output. +//! - User-visible reporting flows through the registry's push facade, so +//! this crate never names another subsystem's bus. + +pub mod gateway; +pub mod gateway_binding; +pub mod gateway_progress; +pub mod heartbeat; +pub mod observer; +pub mod resolve; +#[cfg(any(test, feature = "test-fixtures"))] +pub mod test_gateway; + +pub use gateway::{ + CacheEvent, CacheResponse, GatewayClient, GatewayError, GatewayResponse, SsePayloadStream, + SwitchEvent, SwitchEventStream, SwitchResponse, switch_events, +}; +pub use gateway_binding::{ + GatewayBinding, GatewayPublicationError, GatewaySnapshot, GatewayUpdater, +}; +pub use heartbeat::{GatewayHealth, Heartbeat}; +pub use observer::WorkshopObserver; +pub use resolve::{GatewaySource, ResolveError, ResolvedGateway}; diff --git a/crates/workshop-server/src/observer.rs b/crates/workshop-gateway/src/observer.rs similarity index 54% rename from crates/workshop-server/src/observer.rs rename to crates/workshop-gateway/src/observer.rs index 01a95a98a..181b6961c 100644 --- a/crates/workshop-server/src/observer.rs +++ b/crates/workshop-gateway/src/observer.rs @@ -486,393 +486,4 @@ fn invalid_data(message: String) -> io::Error { } #[cfg(test)] -mod tests { - use std::sync::Arc; - - use promptforge_core_support::events::{ClientTiming, LlamaTimings, Usage, VllmMetrics}; - use serde_json::json; - - use super::*; - - fn full_metrics() -> CallMetrics { - CallMetrics { - usage: Some(Usage { - prompt_tokens: 7, - completion_tokens: 3, - total_tokens: 10, - cached_tokens: Some(2), - reasoning_tokens: Some(1), - }), - llama: Some(LlamaTimings { - prompt_n: 7, - prompt_ms: 12.5, - prompt_per_second: 560.0, - predicted_n: 3, - predicted_ms: 30.5, - predicted_per_second: 98.5, - draft_n: 4, - draft_n_accepted: 2, - }), - vllm: Some(VllmMetrics { - time_to_first_token_ms: Some(8.5), - generation_time_ms: Some(22.5), - queue_time_ms: Some(1.5), - mean_itl_ms: Some(7.5), - tokens_per_second: Some(133.5), - }), - client: Some(ClientTiming { - ttft_ms: Some(9.5), - mean_itl_ms: Some(8.25), - e2e_ms: 41.5, - }), - } - } - - /// Emits one event of every kind through the Observer hooks. - fn emit_one_of_each(log: &WorkshopObserver) { - log.on_user_input("run", "chat", "hi"); - log.on_thinking("run", "chat", 0, 0, 1, "llama-3", "pondering"); - log.on_assistant_tool_calls( - "run", - "chat", - 0, - 0, - 1, - "llama-3", - &[ToolCallEvent { - id: "call_1".to_owned(), - name: "read_file".to_owned(), - arguments: json!({ "path": "notes.txt" }), - }], - ); - log.on_tool_result( - "run", - "chat", - 0, - 0, - 1, - "call_1", - "read_file", - "file contents", - false, - ); - log.on_assistant_reply( - "run", - "chat", - 1, - 0, - 2, - "hello", - Some("stop"), - "llama-3", - Some(&full_metrics()), - ); - } - - fn collect(log: &WorkshopObserver) -> Vec { - (0..log.len()) - .map(|index| log.get(index).expect("every index below len() reads")) - .collect() - } - - #[test] - fn concurrent_appends_lose_nothing_and_preserve_per_producer_order() { - let dir = tempfile::TempDir::new().expect("tempdir"); - let path = dir.path().join("events.jsonl"); - let log = Arc::new(WorkshopObserver::new(Some(&path)).expect("open a fresh log")); - - let mut producers = Vec::new(); - for producer in 0..4 { - let log = Arc::clone(&log); - producers.push(std::thread::spawn(move || { - let section = format!("producer-{producer}"); - for sequence in 0..25 { - log.on_user_input("run", §ion, &sequence.to_string()); - } - })); - } - for producer in producers { - producer.join().expect("producer threads finish"); - } - - assert_eq!(log.len(), 100, "no append may be lost"); - let events = collect(&log); - let expected: Vec = (0..25).map(|sequence| sequence.to_string()).collect(); - for producer in 0..4 { - let section = format!("producer-{producer}"); - let sequence: Vec<&str> = events - .iter() - .filter(|event| event.section == section) - .map(|event| event.content.as_str()) - .collect(); - assert_eq!( - sequence, expected, - "{section} must keep its own append order through the interleaving" - ); - } - - // The file's order is the in-memory order: the two advance under - // one guard, and the replay proves it. - let replayed = WorkshopObserver::load_from(&path).expect("replay the concurrent log"); - assert_eq!(collect(&replayed), events); - } - - #[test] - fn event_log_reads_see_a_consistent_prefix() { - let log = Arc::new(WorkshopObserver::new(None).expect("open a memory log")); - let writer = Arc::clone(&log); - let producer = std::thread::spawn(move || { - for sequence in 0..200 { - writer.on_user_input("run", "chat", &sequence.to_string()); - } - }); - - // Every observed length is a fully readable prefix, and an entry - // once appended never changes. - loop { - let len = log.len(); - for index in 0..len { - let event = log - .get(index) - .expect("every index below an observed len() must read"); - assert_eq!( - event.content, - index.to_string(), - "entry {index} must be the entry that was appended there" - ); - } - if len == 200 { - break; - } - std::thread::yield_now(); - } - producer.join().expect("the producer thread finishes"); - } - - #[test] - fn subscribe_receives_every_entry_in_log_order() { - let log = WorkshopObserver::new(None).expect("open a memory log"); - let mut entries = log.subscribe(); - emit_one_of_each(&log); - - let expected = [ - (RuntimeEventKind::UserInput, "hi".to_owned()), - (RuntimeEventKind::Thinking, "pondering".to_owned()), - ( - RuntimeEventKind::AssistantToolCalls, - r#"[{"id":"call_1","name":"read_file","arguments":{"path":"notes.txt"}}]"# - .to_owned(), - ), - (RuntimeEventKind::ToolResult, "file contents".to_owned()), - (RuntimeEventKind::AssistantReply, "hello".to_owned()), - ]; - for (kind, content) in expected { - let received = entries.try_recv().expect("every appended entry broadcasts"); - assert_eq!((received.kind, received.content), (kind, content)); - } - assert!( - matches!( - entries.try_recv(), - Err(broadcast::error::TryRecvError::Empty) - ), - "no entry may broadcast that was not appended" - ); - } - - #[test] - fn append_and_load_round_trip_byte_for_byte() { - let dir = tempfile::TempDir::new().expect("tempdir"); - let path = dir.path().join("events.jsonl"); - let log = WorkshopObserver::new(Some(&path)).expect("open a fresh log"); - emit_one_of_each(&log); - let in_memory = collect(&log); - drop(log); - - let original = std::fs::read_to_string(&path).expect("read the persisted log"); - let restored = WorkshopObserver::load_from(&path).expect("replay the log"); - assert_eq!(collect(&restored), in_memory, "replay restores every entry"); - - // Re-serializing the replayed log reproduces the file byte for - // byte: nothing was lost, reordered, or reshaped in either - // direction. - let mut rebuilt = header_line().expect("the header line renders"); - for event in collect(&restored) { - rebuilt.push_str(&serde_json::to_string(&event).expect("events serialize")); - rebuilt.push('\n'); - } - assert_eq!(rebuilt, original); - - // A loaded log keeps appending to the same file, behind the same - // header. - restored.on_user_input("run", "chat", "again"); - drop(restored); - let reloaded = WorkshopObserver::load_from(&path).expect("replay the appended log"); - assert_eq!(reloaded.len(), 6); - assert_eq!( - reloaded.get(5).map(|event| event.content), - Some("again".to_owned()) - ); - } - - #[test] - fn load_from_tolerates_crlf_line_endings() { - // An autocrlf checkout of the committed canary, or a log touched - // by a CRLF editor, materializes \r\n endings; replay must keep - // reading such a file. - let dir = tempfile::TempDir::new().expect("tempdir"); - let path = dir.path().join("events.jsonl"); - let log = WorkshopObserver::new(Some(&path)).expect("open a fresh log"); - emit_one_of_each(&log); - let events = collect(&log); - drop(log); - - let text = std::fs::read_to_string(&path).expect("read the persisted log"); - std::fs::write(&path, text.replace('\n', "\r\n")).expect("rewrite with CRLF endings"); - - let replayed = WorkshopObserver::load_from(&path).expect("a CRLF log must still load"); - assert_eq!(collect(&replayed), events); - } - - #[test] - fn new_truncates_to_a_fresh_headed_log() { - let dir = tempfile::TempDir::new().expect("tempdir"); - let path = dir.path().join("events.jsonl"); - std::fs::write(&path, "stale junk from an earlier life\n").expect("seed stale bytes"); - - let log = WorkshopObserver::new(Some(&path)).expect("open over the stale file"); - drop(log); - assert_eq!( - std::fs::read_to_string(&path).expect("read the fresh log"), - header_line().expect("the header line renders"), - "new() must truncate to a bare versioned header" - ); - let empty = WorkshopObserver::load_from(&path).expect("replay the fresh log"); - assert_eq!(empty.len(), 0); - } - - #[test] - fn load_from_rejects_missing_and_alien_headers_and_torn_lines() { - let dir = tempfile::TempDir::new().expect("tempdir"); - let header = header_line().expect("the header line renders"); - - let empty = dir.path().join("empty.jsonl"); - std::fs::write(&empty, "").expect("write the empty file"); - let error = WorkshopObserver::load_from(&empty).expect_err("an empty file must not load"); - assert_eq!(error.kind(), io::ErrorKind::InvalidData); - assert!(error.to_string().contains("missing event log header")); - - let alien = dir.path().join("alien.jsonl"); - std::fs::write( - &alien, - "{\"format\":\"workshop-event-log\",\"version\":999}\n", - ) - .expect("write the alien file"); - let error = - WorkshopObserver::load_from(&alien).expect_err("an alien version must not load"); - assert_eq!(error.kind(), io::ErrorKind::InvalidData); - assert!(error.to_string().contains("unsupported event log")); - - let torn = dir.path().join("torn.jsonl"); - std::fs::write(&torn, format!("{header}{{\"kind\":\"user_message\"")) - .expect("write the torn file"); - let error = WorkshopObserver::load_from(&torn).expect_err("a torn line must not load"); - assert_eq!(error.kind(), io::ErrorKind::InvalidData); - assert!( - error.to_string().contains("line 2"), - "the error must name the offending line: {error}" - ); - - // The version discipline leans on this: a kind outside the - // version-1 vocabulary (the reserved `plan`, for one) must refuse - // to load rather than replay as something else. - let unknown = dir.path().join("unknown.jsonl"); - std::fs::write( - &unknown, - format!( - "{header}{{\"kind\":\"plan\",\"section\":\"chat\",\"chain_id\":0,\"depth\":0,\"turn\":0,\"content\":\"\"}}\n" - ), - ) - .expect("write the unknown-kind file"); - let error = WorkshopObserver::load_from(&unknown) - .expect_err("a kind this version does not speak must not load"); - assert_eq!(error.kind(), io::ErrorKind::InvalidData); - assert!( - error.to_string().contains("malformed event on line 2"), - "the error must name the offending line: {error}" - ); - - let missing = dir.path().join("missing.jsonl"); - let error = - WorkshopObserver::load_from(&missing).expect_err("a missing file must not load"); - assert_eq!(error.kind(), io::ErrorKind::NotFound); - } - - #[test] - fn a_poisoned_lock_recovers_for_appends_and_reads() { - let dir = tempfile::TempDir::new().expect("tempdir"); - let path = dir.path().join("events.jsonl"); - let log = Arc::new(WorkshopObserver::new(Some(&path)).expect("open a fresh log")); - - let poisoner = Arc::clone(&log); - let panicked = std::thread::spawn(move || { - let _guard = poisoner - .inner - .write() - .expect("the lock is not yet poisoned"); - panic!("poisoning the event log lock on purpose"); - }) - .join(); - assert!(panicked.is_err(), "the poisoning thread must panic"); - assert!(log.inner.is_poisoned(), "the lock must be poisoned"); - - // Zone two: the poison is recovered, not propagated - appends, - // reads, broadcast, and persistence all keep working. - let mut entries = log.subscribe(); - log.on_user_input("run", "chat", "after the poison"); - assert_eq!(log.len(), 1); - assert_eq!( - log.get(0).map(|event| event.content), - Some("after the poison".to_owned()) - ); - assert_eq!( - entries - .try_recv() - .expect("the broadcast survives the poison") - .content, - "after the poison" - ); - drop(entries); - drop(log); - let replayed = WorkshopObserver::load_from(&path).expect("replay the poisoned-era log"); - assert_eq!(replayed.len(), 1, "persistence survives the poison"); - } - - #[test] - fn a_failing_writer_degrades_to_the_in_memory_log() { - struct FailingWriter; - impl Write for FailingWriter { - fn write(&mut self, _buf: &[u8]) -> io::Result { - Err(io::Error::other("injected append failure")) - } - fn flush(&mut self) -> io::Result<()> { - Ok(()) - } - } - - let log = WorkshopObserver::with_writer_for_test(FailingWriter); - let mut entries = log.subscribe(); - emit_one_of_each(&log); - assert_eq!( - log.len(), - 5, - "a failed file append never loses the in-memory entry" - ); - assert_eq!( - entries - .try_recv() - .expect("the broadcast survives the failing writer") - .content, - "hi" - ); - } -} +mod tests; diff --git a/crates/workshop-gateway/src/observer/tests.rs b/crates/workshop-gateway/src/observer/tests.rs new file mode 100644 index 000000000..fa76688ae --- /dev/null +++ b/crates/workshop-gateway/src/observer/tests.rs @@ -0,0 +1,385 @@ +use std::sync::Arc; + +use promptforge_core_support::events::{ClientTiming, LlamaTimings, Usage, VllmMetrics}; +use serde_json::json; + +use super::*; + +fn full_metrics() -> CallMetrics { + CallMetrics { + usage: Some(Usage { + prompt_tokens: 7, + completion_tokens: 3, + total_tokens: 10, + cached_tokens: Some(2), + reasoning_tokens: Some(1), + }), + llama: Some(LlamaTimings { + prompt_n: 7, + prompt_ms: 12.5, + prompt_per_second: 560.0, + predicted_n: 3, + predicted_ms: 30.5, + predicted_per_second: 98.5, + draft_n: 4, + draft_n_accepted: 2, + }), + vllm: Some(VllmMetrics { + time_to_first_token_ms: Some(8.5), + generation_time_ms: Some(22.5), + queue_time_ms: Some(1.5), + mean_itl_ms: Some(7.5), + tokens_per_second: Some(133.5), + }), + client: Some(ClientTiming { + ttft_ms: Some(9.5), + mean_itl_ms: Some(8.25), + e2e_ms: 41.5, + }), + } +} + +/// Emits one event of every kind through the Observer hooks. +fn emit_one_of_each(log: &WorkshopObserver) { + log.on_user_input("run", "chat", "hi"); + log.on_thinking("run", "chat", 0, 0, 1, "llama-3", "pondering"); + log.on_assistant_tool_calls( + "run", + "chat", + 0, + 0, + 1, + "llama-3", + &[ToolCallEvent { + id: "call_1".to_owned(), + name: "read_file".to_owned(), + arguments: json!({ "path": "notes.txt" }), + }], + ); + log.on_tool_result( + "run", + "chat", + 0, + 0, + 1, + "call_1", + "read_file", + "file contents", + false, + ); + log.on_assistant_reply( + "run", + "chat", + 1, + 0, + 2, + "hello", + Some("stop"), + "llama-3", + Some(&full_metrics()), + ); +} + +fn collect(log: &WorkshopObserver) -> Vec { + (0..log.len()) + .map(|index| log.get(index).expect("every index below len() reads")) + .collect() +} + +#[test] +fn concurrent_appends_lose_nothing_and_preserve_per_producer_order() { + let dir = tempfile::TempDir::new().expect("tempdir"); + let path = dir.path().join("events.jsonl"); + let log = Arc::new(WorkshopObserver::new(Some(&path)).expect("open a fresh log")); + + let mut producers = Vec::new(); + for producer in 0..4 { + let log = Arc::clone(&log); + producers.push(std::thread::spawn(move || { + let section = format!("producer-{producer}"); + for sequence in 0..25 { + log.on_user_input("run", §ion, &sequence.to_string()); + } + })); + } + for producer in producers { + producer.join().expect("producer threads finish"); + } + + assert_eq!(log.len(), 100, "no append may be lost"); + let events = collect(&log); + let expected: Vec = (0..25).map(|sequence| sequence.to_string()).collect(); + for producer in 0..4 { + let section = format!("producer-{producer}"); + let sequence: Vec<&str> = events + .iter() + .filter(|event| event.section == section) + .map(|event| event.content.as_str()) + .collect(); + assert_eq!( + sequence, expected, + "{section} must keep its own append order through the interleaving" + ); + } + + // The file's order is the in-memory order: the two advance under + // one guard, and the replay proves it. + let replayed = WorkshopObserver::load_from(&path).expect("replay the concurrent log"); + assert_eq!(collect(&replayed), events); +} + +#[test] +fn event_log_reads_see_a_consistent_prefix() { + let log = Arc::new(WorkshopObserver::new(None).expect("open a memory log")); + let writer = Arc::clone(&log); + let producer = std::thread::spawn(move || { + for sequence in 0..200 { + writer.on_user_input("run", "chat", &sequence.to_string()); + } + }); + + // Every observed length is a fully readable prefix, and an entry + // once appended never changes. + loop { + let len = log.len(); + for index in 0..len { + let event = log + .get(index) + .expect("every index below an observed len() must read"); + assert_eq!( + event.content, + index.to_string(), + "entry {index} must be the entry that was appended there" + ); + } + if len == 200 { + break; + } + std::thread::yield_now(); + } + producer.join().expect("the producer thread finishes"); +} + +#[test] +fn subscribe_receives_every_entry_in_log_order() { + let log = WorkshopObserver::new(None).expect("open a memory log"); + let mut entries = log.subscribe(); + emit_one_of_each(&log); + + let expected = [ + (RuntimeEventKind::UserInput, "hi".to_owned()), + (RuntimeEventKind::Thinking, "pondering".to_owned()), + ( + RuntimeEventKind::AssistantToolCalls, + r#"[{"id":"call_1","name":"read_file","arguments":{"path":"notes.txt"}}]"#.to_owned(), + ), + (RuntimeEventKind::ToolResult, "file contents".to_owned()), + (RuntimeEventKind::AssistantReply, "hello".to_owned()), + ]; + for (kind, content) in expected { + let received = entries.try_recv().expect("every appended entry broadcasts"); + assert_eq!((received.kind, received.content), (kind, content)); + } + assert!( + matches!( + entries.try_recv(), + Err(broadcast::error::TryRecvError::Empty) + ), + "no entry may broadcast that was not appended" + ); +} + +#[test] +fn append_and_load_round_trip_byte_for_byte() { + let dir = tempfile::TempDir::new().expect("tempdir"); + let path = dir.path().join("events.jsonl"); + let log = WorkshopObserver::new(Some(&path)).expect("open a fresh log"); + emit_one_of_each(&log); + let in_memory = collect(&log); + drop(log); + + let original = std::fs::read_to_string(&path).expect("read the persisted log"); + let restored = WorkshopObserver::load_from(&path).expect("replay the log"); + assert_eq!(collect(&restored), in_memory, "replay restores every entry"); + + // Re-serializing the replayed log reproduces the file byte for + // byte: nothing was lost, reordered, or reshaped in either + // direction. + let mut rebuilt = header_line().expect("the header line renders"); + for event in collect(&restored) { + rebuilt.push_str(&serde_json::to_string(&event).expect("events serialize")); + rebuilt.push('\n'); + } + assert_eq!(rebuilt, original); + + // A loaded log keeps appending to the same file, behind the same + // header. + restored.on_user_input("run", "chat", "again"); + drop(restored); + let reloaded = WorkshopObserver::load_from(&path).expect("replay the appended log"); + assert_eq!(reloaded.len(), 6); + assert_eq!( + reloaded.get(5).map(|event| event.content), + Some("again".to_owned()) + ); +} + +#[test] +fn load_from_tolerates_crlf_line_endings() { + // An autocrlf checkout of the committed canary, or a log touched + // by a CRLF editor, materializes \r\n endings; replay must keep + // reading such a file. + let dir = tempfile::TempDir::new().expect("tempdir"); + let path = dir.path().join("events.jsonl"); + let log = WorkshopObserver::new(Some(&path)).expect("open a fresh log"); + emit_one_of_each(&log); + let events = collect(&log); + drop(log); + + let text = std::fs::read_to_string(&path).expect("read the persisted log"); + std::fs::write(&path, text.replace('\n', "\r\n")).expect("rewrite with CRLF endings"); + + let replayed = WorkshopObserver::load_from(&path).expect("a CRLF log must still load"); + assert_eq!(collect(&replayed), events); +} + +#[test] +fn new_truncates_to_a_fresh_headed_log() { + let dir = tempfile::TempDir::new().expect("tempdir"); + let path = dir.path().join("events.jsonl"); + std::fs::write(&path, "stale junk from an earlier life\n").expect("seed stale bytes"); + + let log = WorkshopObserver::new(Some(&path)).expect("open over the stale file"); + drop(log); + assert_eq!( + std::fs::read_to_string(&path).expect("read the fresh log"), + header_line().expect("the header line renders"), + "new() must truncate to a bare versioned header" + ); + let empty = WorkshopObserver::load_from(&path).expect("replay the fresh log"); + assert_eq!(empty.len(), 0); +} + +#[test] +fn load_from_rejects_missing_and_alien_headers_and_torn_lines() { + let dir = tempfile::TempDir::new().expect("tempdir"); + let header = header_line().expect("the header line renders"); + + let empty = dir.path().join("empty.jsonl"); + std::fs::write(&empty, "").expect("write the empty file"); + let error = WorkshopObserver::load_from(&empty).expect_err("an empty file must not load"); + assert_eq!(error.kind(), io::ErrorKind::InvalidData); + assert!(error.to_string().contains("missing event log header")); + + let alien = dir.path().join("alien.jsonl"); + std::fs::write( + &alien, + "{\"format\":\"workshop-event-log\",\"version\":999}\n", + ) + .expect("write the alien file"); + let error = WorkshopObserver::load_from(&alien).expect_err("an alien version must not load"); + assert_eq!(error.kind(), io::ErrorKind::InvalidData); + assert!(error.to_string().contains("unsupported event log")); + + let torn = dir.path().join("torn.jsonl"); + std::fs::write(&torn, format!("{header}{{\"kind\":\"user_message\"")) + .expect("write the torn file"); + let error = WorkshopObserver::load_from(&torn).expect_err("a torn line must not load"); + assert_eq!(error.kind(), io::ErrorKind::InvalidData); + assert!( + error.to_string().contains("line 2"), + "the error must name the offending line: {error}" + ); + + // The version discipline leans on this: a kind outside the + // version-1 vocabulary (the reserved `plan`, for one) must refuse + // to load rather than replay as something else. + let unknown = dir.path().join("unknown.jsonl"); + std::fs::write( + &unknown, + format!( + "{header}{{\"kind\":\"plan\",\"section\":\"chat\",\"chain_id\":0,\"depth\":0,\"turn\":0,\"content\":\"\"}}\n" + ), + ) + .expect("write the unknown-kind file"); + let error = WorkshopObserver::load_from(&unknown) + .expect_err("a kind this version does not speak must not load"); + assert_eq!(error.kind(), io::ErrorKind::InvalidData); + assert!( + error.to_string().contains("malformed event on line 2"), + "the error must name the offending line: {error}" + ); + + let missing = dir.path().join("missing.jsonl"); + let error = WorkshopObserver::load_from(&missing).expect_err("a missing file must not load"); + assert_eq!(error.kind(), io::ErrorKind::NotFound); +} + +#[test] +fn a_poisoned_lock_recovers_for_appends_and_reads() { + let dir = tempfile::TempDir::new().expect("tempdir"); + let path = dir.path().join("events.jsonl"); + let log = Arc::new(WorkshopObserver::new(Some(&path)).expect("open a fresh log")); + + let poisoner = Arc::clone(&log); + let panicked = std::thread::spawn(move || { + let _guard = poisoner + .inner + .write() + .expect("the lock is not yet poisoned"); + panic!("poisoning the event log lock on purpose"); + }) + .join(); + assert!(panicked.is_err(), "the poisoning thread must panic"); + assert!(log.inner.is_poisoned(), "the lock must be poisoned"); + + // Zone two: the poison is recovered, not propagated - appends, + // reads, broadcast, and persistence all keep working. + let mut entries = log.subscribe(); + log.on_user_input("run", "chat", "after the poison"); + assert_eq!(log.len(), 1); + assert_eq!( + log.get(0).map(|event| event.content), + Some("after the poison".to_owned()) + ); + assert_eq!( + entries + .try_recv() + .expect("the broadcast survives the poison") + .content, + "after the poison" + ); + drop(entries); + drop(log); + let replayed = WorkshopObserver::load_from(&path).expect("replay the poisoned-era log"); + assert_eq!(replayed.len(), 1, "persistence survives the poison"); +} + +#[test] +fn a_failing_writer_degrades_to_the_in_memory_log() { + struct FailingWriter; + impl Write for FailingWriter { + fn write(&mut self, _buf: &[u8]) -> io::Result { + Err(io::Error::other("injected append failure")) + } + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } + } + + let log = WorkshopObserver::with_writer_for_test(FailingWriter); + let mut entries = log.subscribe(); + emit_one_of_each(&log); + assert_eq!( + log.len(), + 5, + "a failed file append never loses the in-memory entry" + ); + assert_eq!( + entries + .try_recv() + .expect("the broadcast survives the failing writer") + .content, + "hi" + ); +} diff --git a/crates/workshop-gateway/src/resolve.rs b/crates/workshop-gateway/src/resolve.rs new file mode 100644 index 000000000..ad60eadbd --- /dev/null +++ b/crates/workshop-gateway/src/resolve.rs @@ -0,0 +1,274 @@ +//! Gateway endpoint resolution: a live gateway discovery file in the run +//! directory first, explicit `[gateway]` config second. +//! +//! The sidecar gateway writes `gateway.json` after a successful bind (see +//! `shared-sidecar`), so a workshop that finds a live file attaches to +//! that gateway - loopback, WSL, or LAN become one topology. A stale file +//! is condemned with its reason (the probe removes it) and explicit config +//! takes over; with no live file and no explicit config there is nothing +//! to connect to, which is the plain [`ResolveError`]. + +use std::path::Path; + +use shared_sidecar::{Resolution, SidecarError, StaleReason, ValidatedConnection}; + +use workshop_protocol::Activity; +use workshop_registry::Push; +use workshop_support::GatewayConfig; + +/// The gateway endpoint state construction connects to, and how it was +/// found. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedGateway { + base_url: String, + api_key: String, + identity: Option, + source: GatewaySource, + stale: Option, +} + +/// Which source won gateway endpoint resolution. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum GatewaySource { + /// A live `gateway.json` gateway discovery file in the run directory. + GatewayDiscoveryFile, + /// Explicit `[gateway]` settings from `workshop.toml`. + Config, +} + +impl ResolvedGateway { + /// The endpoint explicit config names, with no discovery: the bypass + /// for a host that already holds its gateway endpoint, or a test + /// fixture. + #[must_use] + pub fn from_config(config: &GatewayConfig) -> Self { + Self { + base_url: config.base_url.clone(), + api_key: config.api_key.clone(), + identity: None, + source: GatewaySource::Config, + stale: None, + } + } + + /// The endpoint a validated local Gateway boot published, for a host + /// holding its own validated identity (a test fixture). + #[cfg(feature = "test-fixtures")] + #[must_use] + pub fn from_validated(identity: ValidatedConnection) -> Self { + Self { + base_url: format!("http://127.0.0.1:{}", identity.port()), + api_key: identity.api_key().to_owned(), + identity: Some(identity), + source: GatewaySource::GatewayDiscoveryFile, + stale: None, + } + } + + /// The resolved base URL, for example `http://127.0.0.1:8081`. + #[must_use] + pub fn base_url(&self) -> &str { + &self.base_url + } + + /// The resolved bearer key. + #[must_use] + pub fn api_key(&self) -> &str { + &self.api_key + } + + /// The validated local Gateway boot, when discovery won. + #[must_use] + pub fn identity(&self) -> Option<&ValidatedConnection> { + self.identity.as_ref() + } + + /// Which source won the resolution. + #[must_use] + pub fn source(&self) -> GatewaySource { + self.source + } + + /// Why a gateway discovery file was condemned on the way to the config + /// fallback, when one was. + #[must_use] + pub fn stale(&self) -> Option { + self.stale + } + + /// The winning source rendered for the status bar and the log. + #[must_use] + pub(crate) fn source_label(&self) -> &'static str { + match self.source { + GatewaySource::GatewayDiscoveryFile => "gateway discovery file", + GatewaySource::Config => "workshop.toml", + } + } +} + +/// Gateway endpoint resolution found nothing to connect to. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +#[non_exhaustive] +#[error("no gateway configured or running{detail}")] +pub struct ResolveError { + /// The rendered suffix: the stale-file note and the remedy. + detail: String, + /// Why the gateway discovery file was condemned, when one was. + stale: Option, +} + +impl ResolveError { + /// The failure with the stale-file note rendered in, when a file was + /// condemned on the way. + fn new(stale: Option) -> Self { + let note = stale + .map(|reason| { + format!( + " (removed a stale gateway discovery file: {})", + stale_clause(reason) + ) + }) + .unwrap_or_default(); + Self { + detail: format!( + "{note}; start promptforge-gateway or set [gateway] base_url and api_key in workshop.toml" + ), + stale, + } + } + + /// Why the gateway discovery file was condemned, when one was: a wrong key, + /// a dead pid, and a foreign image are different problems for the + /// operator. + #[must_use] + pub fn stale(&self) -> Option { + self.stale + } +} + +/// Resolves the gateway endpoint for a loaded config: the live gateway +/// discovery file in the default run directory first, explicit `[gateway]` +/// config second. +/// +/// # Errors +/// Returns [`ResolveError`] when no live gateway discovery file exists and the +/// config carries no explicit gateway. +pub fn resolve(config: &GatewayConfig) -> Result { + resolve_with(shared_sidecar::default_run_dir().as_deref(), config, probe) +} + +/// The production probe: `shared_sidecar`'s stale-detecting resolve. +fn probe(run_dir: &Path) -> Result { + shared_sidecar::resolve(run_dir) +} + +/// `resolve` against an explicit run directory and probe, so tests point +/// at a tempdir and at a probe that accepts the test binary's own image. +fn resolve_with( + run_dir: Option<&Path>, + config: &GatewayConfig, + probe: fn(&Path) -> Result, +) -> Result { + let mut stale = None; + if let Some(run_dir) = run_dir { + match probe(run_dir) { + Ok(Resolution::Attach(file)) => match validate_resolved(file) { + Ok(identity) => { + return Ok(ResolvedGateway { + base_url: format!("http://127.0.0.1:{}", identity.port()), + api_key: identity.api_key().to_owned(), + identity: Some(identity), + source: GatewaySource::GatewayDiscoveryFile, + stale: None, + }); + } + Err(reason) => stale = Some(reason), + }, + Ok(Resolution::Stale(reason)) => { + tracing::warn!( + reason = stale_clause(reason), + "removed a stale gateway discovery file" + ); + stale = Some(reason); + } + // A read or cleanup I/O failure degrades discovery to the + // config fallback; it never fails startup on its own. + Err(error) => { + tracing::warn!("could not resolve the gateway discovery file: {error}"); + } + // Absent, and any future resolution: nothing to attach to. + _ => {} + } + } + if is_explicit(config) { + return Ok(ResolvedGateway { + base_url: config.base_url.clone(), + api_key: config.api_key.clone(), + identity: None, + source: GatewaySource::Config, + stale, + }); + } + Err(ResolveError::new(stale)) +} + +/// Reifies the shared resolver's live result as the capability stored in +/// Workshop's immutable Gateway snapshot. +fn validate_resolved( + file: shared_sidecar::GatewayDiscoveryFile, +) -> Result { + ValidatedConnection::validate(file) +} + +/// Reports the resolution outcome where the house surfaces startup state: +/// a condemned file's reason and the winning source on the status bus, +/// the same facts in the log. +pub fn report(gateway: &ResolvedGateway, push: &Push) { + if let Some(reason) = gateway.stale() { + push.push_status_update( + "Stale gateway discovery file", + format!("{}; attaching from workshop.toml", stale_clause(reason)), + Activity::General, + ); + } + push.push_status_update( + "Connecting to gateway", + format!( + "base URL {} ({})", + gateway.base_url(), + gateway.source_label() + ), + Activity::General, + ); + tracing::info!( + base_url = %gateway.base_url(), + source = gateway.source_label(), + "gateway endpoint resolved" + ); +} + +/// Whether the config names a gateway itself: a non-empty `base_url` is +/// explicit, an empty one (unset, or an unset `${PROMPTFORGE_GATEWAY_URL}` +/// interpolation) is not. The explicit fallback exists for the gateways +/// discovery cannot see - a LAN gateway; a local gateway writes a +/// gateway discovery file, which discovery finds first. +fn is_explicit(config: &GatewayConfig) -> bool { + !config.base_url.is_empty() +} + +/// Renders a stale reason as a user-facing clause: a wrong key, a dead +/// pid, and a foreign image are different problems for the operator. +fn stale_clause(reason: StaleReason) -> &'static str { + match reason { + StaleReason::Invalid => "the file was not valid", + StaleReason::ProcessDead => "the recorded gateway process is dead", + StaleReason::ImageMismatch => "the recorded pid belongs to another program", + StaleReason::HealthFailed => "the recorded gateway does not answer", + StaleReason::KeyRejected => "the file's key was rejected", + _ => "the file is stale", + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/workshop-gateway/src/resolve/tests.rs b/crates/workshop-gateway/src/resolve/tests.rs new file mode 100644 index 000000000..1d0ecb887 --- /dev/null +++ b/crates/workshop-gateway/src/resolve/tests.rs @@ -0,0 +1,324 @@ +use super::*; + +use std::io::{Read, Write as _}; +use std::net::TcpListener; + +use shared_sidecar::GatewayDiscoveryFile; + +/// The test process's own image name, so the probe's pid and image +/// checks pass and the test reaches the liveness probes. +fn own_image_name() -> String { + std::env::current_exe() + .expect("current exe") + .file_name() + .expect("the exe has a file name") + .to_string_lossy() + .into_owned() +} + +/// A probe running the real liveness gauntlet against the test +/// binary's own image. +fn probe_own_image(run_dir: &Path) -> Result { + shared_sidecar::resolve_for_test(run_dir, &own_image_name()) +} + +/// A gateway discovery file pointing at the test process itself. +fn live_file(port: u16, api_key: &str) -> GatewayDiscoveryFile { + GatewayDiscoveryFile { + port, + api_key: api_key.to_owned(), + pid: std::process::id(), + epoch: 1_757_000_000, + version: "0.2.0".to_owned(), + started_at: "2026-09-03T12:00:00Z".to_owned(), + } +} + +/// A pid guaranteed dead: a short-lived child, reaped and dropped so +/// no handle keeps the process object alive. +fn dead_pid() -> u32 { + let mut child = std::process::Command::new(std::env::current_exe().expect("current exe")) + .arg("--list") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .expect("spawn a short-lived child"); + let pid = child.id(); + child.wait().expect("the child exits"); + drop(child); + pid +} + +/// A fixture gateway: answers `GET /health` with 200 and the key +/// probe with 200 only when the bearer matches `expected_key`. +fn fixture_gateway(expected_key: &'static str) -> u16 { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind fixture"); + let port = listener.local_addr().expect("fixture address").port(); + std::thread::spawn(move || { + while let Ok((mut stream, _)) = listener.accept() { + for _ in 0..2 { + let mut buffer = [0u8; 1024]; + let Ok(read) = stream.read(&mut buffer) else { + break; + }; + let request = String::from_utf8_lossy(&buffer[..read]); + let accepted = request.starts_with("GET /health ") + || request.contains(&format!("Authorization: Bearer {expected_key}\r\n")); + let response = if accepted { + &b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n{}"[..] + } else { + &b"HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\n\r\n"[..] + }; + if stream.write_all(response).is_err() { + break; + } + } + } + }); + port +} + +/// An explicit gateway config: a LAN URL, never the built-in default. +fn explicit_config() -> GatewayConfig { + GatewayConfig { + base_url: "http://gateway.lan:9999".to_owned(), + api_key: "config-key".to_owned(), + } +} + +#[test] +fn a_live_gateway_discovery_file_wins_over_explicit_config() { + let dir = tempfile::TempDir::new().expect("tempdir"); + let gateway = crate::test_gateway::ValidatedGateway::spawn("file-key"); + let port = gateway.port(); + gateway + .gateway_discovery_file("file-key", 1_757_000_000, "2026-09-03T12:00:00Z") + .write_to(dir.path()) + .expect("write"); + + let resolved = + resolve_with(Some(dir.path()), &explicit_config(), probe).expect("a live file resolves"); + assert_eq!(resolved.source(), GatewaySource::GatewayDiscoveryFile); + assert_eq!(resolved.base_url(), format!("http://127.0.0.1:{port}")); + assert_eq!(resolved.api_key(), "file-key"); + assert_eq!( + resolved.identity().map(ValidatedConnection::port), + Some(port), + "the winning sidecar retains its validated identity for the initial snapshot" + ); + assert_eq!(resolved.stale(), None); +} + +#[test] +fn a_stale_file_is_cleaned_and_explicit_config_wins() { + let dir = tempfile::TempDir::new().expect("tempdir"); + let file = GatewayDiscoveryFile { + pid: dead_pid(), + ..live_file(1, "k") + }; + file.write_to(dir.path()).expect("write"); + + let resolved = resolve_with(Some(dir.path()), &explicit_config(), probe_own_image) + .expect("explicit config is the fallback"); + assert_eq!(resolved.source(), GatewaySource::Config); + assert_eq!(resolved.base_url(), "http://gateway.lan:9999"); + assert_eq!(resolved.api_key(), "config-key"); + assert_eq!(resolved.stale(), Some(StaleReason::ProcessDead)); + assert!( + !shared_sidecar::gateway_discovery_file_path(dir.path()).exists(), + "the stale file was removed" + ); +} + +#[test] +fn a_wrong_key_is_reported_distinctly_from_a_dead_pid() { + // Wrong key: the pid, image, and health checks pass; the key + // probe rejects. + let dir = tempfile::TempDir::new().expect("tempdir"); + let port = fixture_gateway("right"); + live_file(port, "wrong") + .write_to(dir.path()) + .expect("write"); + let resolved = resolve_with(Some(dir.path()), &explicit_config(), probe_own_image) + .expect("explicit config is the fallback"); + assert_eq!(resolved.stale(), Some(StaleReason::KeyRejected)); + + // Dead pid: the process check fails before any probe runs. + let dir = tempfile::TempDir::new().expect("tempdir"); + let file = GatewayDiscoveryFile { + pid: dead_pid(), + ..live_file(1, "k") + }; + file.write_to(dir.path()).expect("write"); + let resolved = resolve_with(Some(dir.path()), &explicit_config(), probe_own_image) + .expect("explicit config is the fallback"); + assert_eq!(resolved.stale(), Some(StaleReason::ProcessDead)); +} + +#[test] +fn no_file_and_no_explicit_config_is_the_plain_error() { + let dir = tempfile::TempDir::new().expect("tempdir"); + let config = GatewayConfig { + base_url: String::new(), + api_key: String::new(), + }; + let error = resolve_with(Some(dir.path()), &config, probe_own_image) + .expect_err("an empty base_url is not explicit config"); + assert!( + error + .to_string() + .contains("no gateway configured or running"), + "the error says it plainly: {error}" + ); + assert_eq!(error.stale(), None, "no file existed to condemn"); +} + +#[test] +fn an_explicitly_configured_default_url_is_honored() { + // The well-known default URL written by hand is explicit config: + // it names a gateway discovery cannot see (an SSH-tunneled remote, + // a gateway whose discovery-file write failed), so it must + // resolve, not read as an unset value. + let dir = tempfile::TempDir::new().expect("tempdir"); + let config = GatewayConfig { + base_url: "http://127.0.0.1:8081".to_owned(), + api_key: "config-key".to_owned(), + }; + let resolved = resolve_with(Some(dir.path()), &config, probe_own_image) + .expect("an explicitly configured URL resolves"); + assert_eq!(resolved.source(), GatewaySource::Config); + assert_eq!(resolved.base_url(), "http://127.0.0.1:8081"); + assert_eq!(resolved.api_key(), "config-key"); +} + +#[test] +fn a_stale_file_with_no_explicit_config_carries_the_reason_into_the_error() { + let dir = tempfile::TempDir::new().expect("tempdir"); + let port = fixture_gateway("right"); + live_file(port, "wrong") + .write_to(dir.path()) + .expect("write"); + let config = GatewayConfig { + base_url: String::new(), + api_key: String::new(), + }; + let error = resolve_with(Some(dir.path()), &config, probe_own_image) + .expect_err("no explicit config remains"); + assert_eq!(error.stale(), Some(StaleReason::KeyRejected)); + let message = error.to_string(); + assert!( + message.contains("no gateway configured or running"), + "the error says it plainly: {message}" + ); + assert!( + message.contains("key was rejected"), + "the condemned file's reason is named: {message}" + ); +} + +#[test] +fn no_file_and_explicit_config_uses_the_config() { + let dir = tempfile::TempDir::new().expect("tempdir"); + let resolved = resolve_with(Some(dir.path()), &explicit_config(), probe_own_image) + .expect("explicit config resolves"); + assert_eq!(resolved.source(), GatewaySource::Config); + assert_eq!(resolved.stale(), None); +} + +/// A probe whose discovery-file read fails: a directory sits where +/// `gateway.json` belongs, so the read errors instead of answering. +fn probe_read_failure(run_dir: &Path) -> Result { + std::fs::create_dir(run_dir.join("gateway.json")).expect("the unreadable file plants"); + shared_sidecar::resolve_for_test(run_dir, &own_image_name()) +} + +#[test] +fn a_probe_io_failure_degrades_to_the_config_fallback() { + let dir = tempfile::TempDir::new().expect("tempdir"); + let resolved = resolve_with(Some(dir.path()), &explicit_config(), probe_read_failure) + .expect("a probe failure never fails startup on its own"); + assert_eq!(resolved.source(), GatewaySource::Config); + assert_eq!(resolved.base_url(), "http://gateway.lan:9999"); + assert_eq!(resolved.stale(), None, "nothing was condemned"); +} + +#[test] +fn a_probe_io_failure_with_no_explicit_config_is_the_plain_error() { + let dir = tempfile::TempDir::new().expect("tempdir"); + let config = GatewayConfig { + base_url: String::new(), + api_key: String::new(), + }; + let error = resolve_with(Some(dir.path()), &config, probe_read_failure) + .expect_err("no explicit config remains after a probe failure"); + assert!( + error + .to_string() + .contains("no gateway configured or running"), + "the error says it plainly: {error}" + ); + assert_eq!(error.stale(), None, "a probe failure is not a condemnation"); +} + +#[test] +fn no_run_directory_skips_discovery() { + // The production probe stands in: with no run directory it is + // never called, so resolution is the config fallback or the plain + // error, and the real run directory is never consulted. + let resolved = resolve_with(None, &explicit_config(), probe) + .expect("explicit config resolves without a run directory"); + assert_eq!(resolved.source(), GatewaySource::Config); + assert_eq!(resolved.stale(), None); + + let config = GatewayConfig { + base_url: String::new(), + api_key: String::new(), + }; + let error = resolve_with(None, &config, probe) + .expect_err("no run directory and no explicit config is the plain error"); + assert!( + error + .to_string() + .contains("no gateway configured or running"), + "the error says it plainly: {error}" + ); +} + +#[test] +fn the_report_names_the_winning_source_and_a_condemned_file() { + // A recording status sink stands in for the status bus: the report's + // frames land on it through the registry's push facade. + let registry = workshop_registry::Registry::new(); + let (status_tx, mut receiver) = tokio::sync::broadcast::channel(16); + let _sink = registry.status_sink().register(std::sync::Arc::new( + workshop_registry::StatusSinkAdapter::new(move |update| { + let _ = status_tx.send(update); + }), + )); + let push = registry.push(); + + let resolved = ResolvedGateway { + base_url: "http://127.0.0.1:4000".to_owned(), + api_key: "k".to_owned(), + identity: None, + source: GatewaySource::Config, + stale: Some(StaleReason::KeyRejected), + }; + report(&resolved, &push); + + let stale_frame = receiver.try_recv().expect("the stale note is reported"); + assert_eq!(stale_frame.label, "Stale gateway discovery file"); + assert!( + stale_frame.description.contains("key was rejected"), + "the stale reason is named: {}", + stale_frame.description + ); + let connecting = receiver.try_recv().expect("the winning source is reported"); + assert_eq!(connecting.label, "Connecting to gateway"); + assert!( + connecting.description.contains("http://127.0.0.1:4000") + && connecting.description.contains("workshop.toml"), + "the endpoint and its source are named: {}", + connecting.description + ); +} diff --git a/crates/workshop-server/src/test_gateway.rs b/crates/workshop-gateway/src/test_gateway.rs similarity index 100% rename from crates/workshop-server/src/test_gateway.rs rename to crates/workshop-gateway/src/test_gateway.rs diff --git a/crates/workshop-server/src/test_gateway/process.rs b/crates/workshop-gateway/src/test_gateway/process.rs similarity index 100% rename from crates/workshop-server/src/test_gateway/process.rs rename to crates/workshop-gateway/src/test_gateway/process.rs diff --git a/crates/workshop-menu/Cargo.toml b/crates/workshop-menu/Cargo.toml new file mode 100644 index 000000000..6141bbc8c --- /dev/null +++ b/crates/workshop-menu/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "workshop-menu" +version = "0.0.0" +publish = false +edition.workspace = true +license.workspace = true +repository.workspace = true + +description = "Workshop menu subsystem: the server-owned Model menu workbench snapshot, its broadcast bus, the chat model catalog channel, and the per-profile model memory" + +[features] +test-fixtures = [] + +[dependencies] +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +tokio.workspace = true +tracing.workspace = true +workshop-protocol.workspace = true +workshop-registry.workspace = true +workshop-support.workspace = true + +[dev-dependencies] +tempfile.workspace = true +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } + +[lints] +workspace = true diff --git a/crates/workshop-server/src/catalog.rs b/crates/workshop-menu/src/catalog.rs similarity index 83% rename from crates/workshop-server/src/catalog.rs rename to crates/workshop-menu/src/catalog.rs index 146bb3b7e..cd3c795f6 100644 --- a/crates/workshop-server/src/catalog.rs +++ b/crates/workshop-menu/src/catalog.rs @@ -17,8 +17,9 @@ use workshop_protocol::CatalogPush; use workshop_support::RetainedBus; mod chat; +pub use chat::ChatCatalog; use chat::ChatCatalogBus; -pub(crate) use chat::{ChatCatalog, is_chat_capable}; +pub use workshop_protocol::is_chat_capable; /// Ring capacity of the catalog bus. Pushes are rare (one per gateway /// reconnect) and each is a full snapshot, so a handful of slots is @@ -26,7 +27,7 @@ pub(crate) use chat::{ChatCatalog, is_chat_capable}; const CATALOG_CHANNEL_CAPACITY: usize = 4; /// The shared catalog bus: a cloneable handle onto the broadcast channel, -/// mirroring [`crate::status::StatusBus`]. +/// mirroring the status subsystem's [`RetainedBus`]-backed status bus. #[derive(Debug, Clone)] pub struct CatalogBus { bus: RetainedBus, @@ -35,7 +36,8 @@ pub struct CatalogBus { impl CatalogBus { /// Creates a bus with no subscribers, an empty ring, and no snapshot. - pub(crate) fn new() -> Self { + #[must_use] + pub fn new() -> Self { Self { bus: RetainedBus::new(CATALOG_CHANNEL_CAPACITY), chat: ChatCatalogBus::new(), @@ -43,23 +45,27 @@ impl CatalogBus { } /// Subscribes to every push sent from this call onward. - pub(crate) fn subscribe(&self) -> broadcast::Receiver { + #[must_use] + pub fn subscribe(&self) -> broadcast::Receiver { self.bus.subscribe() } /// The most recently published catalog, retained so a session /// connecting later can send the current catalog as its snapshot. - pub(crate) fn latest(&self) -> Option { + #[must_use] + pub fn latest(&self) -> Option { self.bus.latest() } /// The current non-empty chat-capable catalog generation. - pub(crate) fn latest_chat(&self) -> Option { + #[must_use] + pub fn latest_chat(&self) -> Option { self.chat.latest() } /// Subscribes to chat-capable catalog generation changes. - pub(crate) fn subscribe_chat_generation(&self) -> watch::Receiver { + #[must_use] + pub fn subscribe_chat_generation(&self) -> watch::Receiver { self.chat.subscribe() } diff --git a/crates/workshop-server/src/catalog/chat.rs b/crates/workshop-menu/src/catalog/chat.rs similarity index 79% rename from crates/workshop-server/src/catalog/chat.rs rename to crates/workshop-menu/src/catalog/chat.rs index f2b9e54c2..0fa257819 100644 --- a/crates/workshop-server/src/catalog/chat.rs +++ b/crates/workshop-menu/src/catalog/chat.rs @@ -4,13 +4,15 @@ use std::sync::{Arc, Mutex, PoisonError}; use tokio::sync::watch; +use workshop_protocol::is_chat_capable; + /// One immutable chat-capable catalog generation. #[derive(Debug, Clone, Default)] -pub(crate) struct ChatCatalog { +pub struct ChatCatalog { /// Monotonically increasing whenever the chat-capable subset changes. - pub(crate) generation: u64, + pub generation: u64, /// The chat-capable entries for this generation. - pub(crate) models: Vec, + pub models: Vec, } /// Shared retained chat catalog and its generation notification. @@ -67,17 +69,3 @@ impl ChatCatalogBus { } } } - -/// Whether one gateway catalog row can back a chat model binding. -pub(crate) fn is_chat_capable(model: &serde_json::Value) -> bool { - let has_id = model - .get("id") - .and_then(serde_json::Value::as_str) - .is_some_and(|id| !id.is_empty()); - let chat_kind = match model.get("kind") { - None => true, - Some(serde_json::Value::String(kind)) => kind == "chat", - Some(_) => false, - }; - has_id && chat_kind -} diff --git a/crates/workshop-server/src/catalog/tests.rs b/crates/workshop-menu/src/catalog/tests.rs similarity index 100% rename from crates/workshop-server/src/catalog/tests.rs rename to crates/workshop-menu/src/catalog/tests.rs diff --git a/crates/workshop-menu/src/lib.rs b/crates/workshop-menu/src/lib.rs new file mode 100644 index 000000000..4926e009c --- /dev/null +++ b/crates/workshop-menu/src/lib.rs @@ -0,0 +1,69 @@ +//! workshop-menu - the server-owned Model menu subsystem: the workbench +//! snapshot pushed to every `/ws` session as a `{"type":"workbench",...}` +//! frame, the chat-capable model catalog channel rebroadcast as +//! `{"type":"models",...}` frames, and the per-profile model memory +//! persisted in the state directory. +//! +//! ## Invariants +//! +//! - Tier: service; may depend on: `workshop-protocol`, `workshop-registry`, +//! `workshop-support`. Read `AGENTS.md` before adding an import. +//! - Every file in this crate stays under 500 lines; split first, then +//! edit. +//! - The server owns all Model-menu state and the UI only renders it; +//! `chat_ready` is computed here and never derived client-side. +//! - Publishing never blocks: a publish with no sessions is a no-op, and +//! a lagging session skips ahead - every push is a complete snapshot, so +//! an overwritten one loses nothing. Both buses retain the newest push, +//! so a session connecting later receives the current state immediately. +//! - Mutation is zone two throughout: a refused mutation is a value +//! returned to the caller, and a missing, unreadable, or corrupt memory +//! file means "no memory yet" - logged and tolerated, never fatal. + +pub mod catalog; +pub mod menu; + +use std::sync::Arc; + +pub use catalog::{CatalogBus, ChatCatalog, is_chat_capable}; +pub use menu::{MenuBus, MenuRefusal, SwitchOutcome}; +use workshop_registry::{ + CatalogSink, CatalogSinkAdapter, MenuSink, MenuSinkAdapter, Registration, Registry, +}; + +/// Registers the menu subsystem's producer sinks into the registry: the +/// catalog channel's receiving end and the workbench mutators, which +/// same-tier subsystems (the gateway heartbeat's refreshes) drive through +/// the registry's push facade. The returned guards keep the registrations +/// alive; the composition root holds them for the process lifetime. +pub fn register( + registry: &Registry, + catalog: &CatalogBus, + menu: &MenuBus, +) -> (Registration, Registration) { + let catalog_guard = registry + .catalog_sink() + .register(Arc::new(CatalogSinkAdapter::new({ + let catalog = catalog.clone(); + move |models| catalog.publish(models) + }))); + let menu_guard = registry.menu_sink().register(Arc::new(MenuSinkAdapter::new( + { + let menu = menu.clone(); + move |reachable| menu.set_gateway_reachable(reachable) + }, + { + let menu = menu.clone(); + move |profiles, active| menu.set_profiles(profiles, active) + }, + { + let menu = menu.clone(); + move || menu.restore_selection() + }, + { + let menu = menu.clone(); + move || menu.reconcile_catalog() + }, + ))); + (catalog_guard, menu_guard) +} diff --git a/crates/workshop-server/src/menu.rs b/crates/workshop-menu/src/menu.rs similarity index 50% rename from crates/workshop-server/src/menu.rs rename to crates/workshop-menu/src/menu.rs index 78bcd50a8..239d215f5 100644 --- a/crates/workshop-server/src/menu.rs +++ b/crates/workshop-menu/src/menu.rs @@ -127,9 +127,11 @@ struct PendingWrite { /// to escalate (zone two): the caller relays it and the applied state is /// untouched. #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +#[non_exhaustive] pub enum MenuRefusal { /// The requested model id is not in the current catalog. #[error("unknown model {id:?}: not in the current catalog")] + #[non_exhaustive] UnknownModel { /// The id that was requested. id: String, @@ -137,6 +139,7 @@ pub enum MenuRefusal { /// A profile switch is already in flight; switches are single-flight. #[error("a switch to {name:?} is already in progress")] + #[non_exhaustive] SwitchInProgress { /// The target of the switch already running. name: String, @@ -145,7 +148,7 @@ pub enum MenuRefusal { /// How a profile switch ended, reported by whoever ran it. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum SwitchOutcome { +pub enum SwitchOutcome { /// The gateway finished loading the target profile. Completed, /// The switch failed; the previously active profile still serves. @@ -157,7 +160,7 @@ impl MenuBus { /// loading the per-profile model memory from `state_dir` when one is /// given. A missing, unreadable, or corrupt memory file means "no /// memory yet": logged and tolerated (zone two), never fatal. - pub(crate) fn new(catalog: CatalogBus, state_dir: Option<&Path>) -> Self { + pub fn new(catalog: CatalogBus, state_dir: Option<&Path>) -> Self { let memory_path = state_dir.map(|dir| dir.join(WORKSHOP_STATE_FILE)); let last_selected = memory_path.as_deref().map(load_memory).unwrap_or_default(); Self { @@ -176,13 +179,15 @@ impl MenuBus { } /// Subscribes to every snapshot published from this call onward. - pub(crate) fn subscribe(&self) -> broadcast::Receiver { + #[must_use] + pub fn subscribe(&self) -> broadcast::Receiver { self.bus.subscribe() } /// The most recently published snapshot, retained so a session /// connecting later can send the current menu as its snapshot. - pub(crate) fn latest(&self) -> Option { + #[must_use] + pub fn latest(&self) -> Option { self.bus.latest() } @@ -233,7 +238,7 @@ impl MenuBus { /// catalog still holds that model, else to the first catalog model. /// On [`SwitchOutcome::Failed`] the previous profile stays active. A /// finish with no switch in flight is logged and ignored (zone two). - pub(crate) fn finish_switch(&self, outcome: SwitchOutcome) { + pub fn finish_switch(&self, outcome: SwitchOutcome) { let mut state = self.lock_state(); let Some(target) = state.switching.take() else { tracing::warn!("finish_switch with no switch in flight; ignored"); @@ -259,7 +264,7 @@ impl MenuBus { /// nothing. The heartbeat calls this after its boot and reconnect /// refreshes settle, so a reconnect whose selection survived the /// outage changes nothing. - pub(crate) fn restore_selection(&self) { + pub fn restore_selection(&self) { let mut state = self.lock_state(); if state.selected_model.is_some() { return; @@ -304,10 +309,11 @@ impl MenuBus { /// Revalidates the selection against the current catalog - a selected /// model the catalog no longer holds is cleared - and republishes the - /// snapshot when it changed. [`crate::push::Push::push_models_catalog`] - /// calls this after every catalog publish, making that method the - /// single choke point where catalog and menu reconcile. - pub(crate) fn reconcile_catalog(&self) { + /// snapshot when it changed. + /// [`workshop_registry::Push::push_models_catalog`] calls this after + /// every catalog publish, making that method the single choke point + /// where catalog and menu reconcile. + pub fn reconcile_catalog(&self) { let mut state = self.lock_state(); if let Some(selected) = &state.selected_model && !self.catalog_has(selected) @@ -466,456 +472,4 @@ fn store_memory(pending: &PendingWrite) { } #[cfg(test)] -mod tests { - use super::*; - - use tokio::sync::broadcast::error::{RecvError, TryRecvError}; - - /// A catalog bus already holding one push of the given model ids. - fn catalog_of(ids: &[&str]) -> CatalogBus { - let catalog = CatalogBus::new(); - catalog.publish(ids.iter().map(|id| serde_json::json!({"id": id})).collect()); - catalog - } - - /// A menu with no persistence, on a catalog of the given model ids. - fn menu_of(ids: &[&str]) -> MenuBus { - MenuBus::new(catalog_of(ids), None) - } - - /// Drives `menu` to full readiness on `profile`: gateway reachable - /// and a completed switch, which selects a model. - fn onto_profile(menu: &MenuBus, profile: &str) { - menu.set_gateway_reachable(true); - menu.begin_switch(profile).expect("no switch is running"); - menu.finish_switch(SwitchOutcome::Completed); - } - - /// The retained snapshot, which every mutation publishes. - fn snapshot(menu: &MenuBus) -> WorkbenchSnapshot { - menu.latest().expect("a mutation published a snapshot") - } - - #[test] - fn a_known_model_selects_and_publishes_the_snapshot() { - let menu = menu_of(&["model-a", "model-b"]); - menu.set_selected("model-b") - .expect("the id is in the catalog"); - assert_eq!(snapshot(&menu).selected_model.as_deref(), Some("model-b")); - } - - #[test] - fn an_unknown_model_is_refused_and_not_applied() { - let menu = menu_of(&["model-a"]); - menu.set_selected("model-a") - .expect("the id is in the catalog"); - let refusal = menu - .set_selected("model-x") - .expect_err("an unknown id is refused"); - assert_eq!( - refusal, - MenuRefusal::UnknownModel { - id: "model-x".to_string() - } - ); - assert_eq!( - snapshot(&menu).selected_model.as_deref(), - Some("model-a"), - "a refused selection leaves the applied one in place" - ); - } - - #[test] - fn a_switch_publishes_its_begin_and_its_finish() { - let menu = menu_of(&["model-a"]); - menu.set_gateway_reachable(true); - menu.begin_switch("coding").expect("no switch is running"); - let during = snapshot(&menu); - assert_eq!(during.switching.as_deref(), Some("coding")); - assert!(!during.chat_ready, "a switch in flight blocks chat"); - menu.finish_switch(SwitchOutcome::Completed); - let after = snapshot(&menu); - assert_eq!(after.active.as_deref(), Some("coding")); - assert_eq!(after.switching, None); - assert_eq!( - after.selected_model.as_deref(), - Some("model-a"), - "with no memory for the profile the first catalog model is selected" - ); - assert!(after.chat_ready); - } - - #[test] - fn a_failed_switch_keeps_the_previous_profile() { - let menu = menu_of(&["model-a"]); - onto_profile(&menu, "main"); - menu.begin_switch("coding").expect("no switch is running"); - menu.finish_switch(SwitchOutcome::Failed); - let after = snapshot(&menu); - assert_eq!(after.active.as_deref(), Some("main")); - assert_eq!(after.switching, None); - assert!(after.chat_ready, "the previous profile still serves"); - } - - #[test] - fn a_second_switch_while_one_runs_is_refused() { - let menu = menu_of(&["model-a"]); - menu.begin_switch("coding").expect("no switch is running"); - let refusal = menu - .begin_switch("writing") - .expect_err("switches are single-flight"); - assert_eq!( - refusal, - MenuRefusal::SwitchInProgress { - name: "coding".to_string() - } - ); - assert_eq!( - snapshot(&menu).switching.as_deref(), - Some("coding"), - "the refused switch is not applied" - ); - } - - #[test] - fn finishing_with_no_switch_in_flight_is_tolerated() { - let menu = menu_of(&[]); - menu.finish_switch(SwitchOutcome::Completed); - assert!(menu.latest().is_none(), "a no-op finish publishes nothing"); - } - - #[test] - fn the_newest_snapshot_is_retained_for_the_connect_snapshot() { - let menu = menu_of(&["model-a", "model-b"]); - assert!(menu.latest().is_none(), "an untouched menu has no snapshot"); - menu.set_selected("model-a") - .expect("the id is in the catalog"); - menu.set_selected("model-b") - .expect("the id is in the catalog"); - assert_eq!( - snapshot(&menu).selected_model.as_deref(), - Some("model-b"), - "a session connecting now snapshots the newest state" - ); - } - - #[tokio::test] - async fn a_lagged_receiver_skips_ahead_instead_of_blocking() { - let menu = menu_of(&["model-a"]); - let mut receiver = menu.subscribe(); - for _ in 0..=MENU_CHANNEL_CAPACITY { - menu.set_gateway_reachable(true); - } - match receiver.recv().await { - Err(RecvError::Lagged(1)) => {} - other => panic!("expected a lag report of one, got {other:?}"), - } - receiver - .recv() - .await - .expect("the ring still holds snapshots"); - } - - #[test] - fn a_catalog_change_clears_a_selection_it_no_longer_holds() { - let catalog = catalog_of(&["model-a"]); - let menu = MenuBus::new(catalog.clone(), None); - menu.set_selected("model-a") - .expect("the id is in the catalog"); - catalog.publish(vec![serde_json::json!({"id": "model-b"})]); - menu.reconcile_catalog(); - assert_eq!( - snapshot(&menu).selected_model, - None, - "the vanished selection is revalidated away" - ); - } - - #[test] - fn a_catalog_change_that_keeps_the_selection_republishes_nothing() { - let catalog = catalog_of(&["model-a"]); - let menu = MenuBus::new(catalog.clone(), None); - menu.set_selected("model-a") - .expect("the id is in the catalog"); - let mut receiver = menu.subscribe(); - catalog.publish(vec![ - serde_json::json!({"id": "model-a"}), - serde_json::json!({"id": "model-b"}), - ]); - menu.reconcile_catalog(); - assert!( - matches!(receiver.try_recv(), Err(TryRecvError::Empty)), - "an unchanged snapshot is not republished" - ); - } - - #[test] - fn chat_ready_is_true_only_when_every_condition_holds() { - let catalog = catalog_of(&["model-a"]); - let menu = MenuBus::new(catalog.clone(), None); - onto_profile(&menu, "main"); - assert!( - snapshot(&menu).chat_ready, - "non-empty catalog, a selection, no switch, gateway up" - ); - - menu.set_gateway_reachable(false); - assert!(!snapshot(&menu).chat_ready, "gateway down forces false"); - menu.set_gateway_reachable(true); - - menu.begin_switch("coding").expect("no switch is running"); - assert!( - !snapshot(&menu).chat_ready, - "a switch in flight forces false" - ); - menu.finish_switch(SwitchOutcome::Completed); - assert!( - snapshot(&menu).chat_ready, - "the finished switch restores readiness" - ); - - catalog.publish(vec![serde_json::json!({"id": "model-b"})]); - menu.reconcile_catalog(); - let cleared = snapshot(&menu); - assert_eq!(cleared.selected_model, None); - assert!(!cleared.chat_ready, "no selection forces false"); - - menu.set_selected("model-b") - .expect("the id is in the catalog"); - catalog.publish(Vec::new()); - menu.reconcile_catalog(); - assert!(!snapshot(&menu).chat_ready, "an empty catalog forces false"); - } - - #[test] - fn chat_ready_is_false_before_the_heartbeat_reports_reachability() { - let menu = menu_of(&["model-a"]); - menu.set_selected("model-a") - .expect("the id is in the catalog"); - assert!( - !snapshot(&menu).chat_ready, - "a fresh menu boots unreachable: catalog and selection alone \ - must not open chat before the heartbeat's first verdict" - ); - } - - #[test] - fn set_profiles_publishes_the_list_and_the_active_profile() { - let menu = menu_of(&["model-a"]); - menu.set_profiles( - vec!["main".to_string(), "coding".to_string()], - Some("main".to_string()), - ); - let published = snapshot(&menu); - assert_eq!(published.profiles, ["main", "coding"]); - assert_eq!(published.active.as_deref(), Some("main")); - } - - #[test] - fn set_profiles_leaves_the_selection_and_readiness_alone() { - let menu = menu_of(&["model-a"]); - onto_profile(&menu, "main"); - menu.set_profiles(vec!["main".to_string()], Some("main".to_string())); - let after = snapshot(&menu); - assert_eq!( - after.selected_model.as_deref(), - Some("model-a"), - "the profile list does not own selection validity" - ); - assert!(after.chat_ready, "readiness survives a profile refresh"); - } - - #[test] - fn an_empty_profile_list_replaces_a_populated_one() { - let menu = menu_of(&[]); - menu.set_profiles(vec!["main".to_string()], Some("main".to_string())); - menu.set_profiles(Vec::new(), None); - let after = snapshot(&menu); - assert!( - after.profiles.is_empty(), - "a gateway without profile support publishes an empty list" - ); - assert_eq!(after.active, None); - } - - #[test] - fn a_selection_is_remembered_per_profile_across_switches() { - let menu = menu_of(&["model-a", "model-b", "model-c"]); - onto_profile(&menu, "main"); - menu.set_selected("model-c") - .expect("the id is in the catalog"); - menu.begin_switch("coding").expect("no switch is running"); - menu.finish_switch(SwitchOutcome::Completed); - assert_eq!( - snapshot(&menu).selected_model.as_deref(), - Some("model-a"), - "a profile with no memory selects the first model" - ); - menu.set_selected("model-b") - .expect("the id is in the catalog"); - menu.begin_switch("main").expect("no switch is running"); - menu.finish_switch(SwitchOutcome::Completed); - assert_eq!( - snapshot(&menu).selected_model.as_deref(), - Some("model-c"), - "the remembered model for the profile is restored" - ); - } - - #[test] - fn model_memory_round_trips_through_the_state_file() { - let dir = tempfile::TempDir::new().expect("tempdir"); - let catalog = catalog_of(&["model-a", "model-b"]); - { - let menu = MenuBus::new(catalog.clone(), Some(dir.path())); - onto_profile(&menu, "main"); - menu.set_selected("model-b") - .expect("the id is in the catalog"); - } - let reborn = MenuBus::new(catalog, Some(dir.path())); - onto_profile(&reborn, "main"); - assert_eq!( - snapshot(&reborn).selected_model.as_deref(), - Some("model-b"), - "the persisted memory survives a restart" - ); - } - - #[test] - fn a_missing_state_file_means_no_memory_yet() { - let dir = tempfile::TempDir::new().expect("tempdir"); - let menu = MenuBus::new(catalog_of(&["model-a"]), Some(dir.path())); - onto_profile(&menu, "main"); - assert_eq!( - snapshot(&menu).selected_model.as_deref(), - Some("model-a"), - "no memory yet: the first catalog model is selected" - ); - } - - #[test] - fn a_corrupt_state_file_means_no_memory_yet() { - let dir = tempfile::TempDir::new().expect("tempdir"); - std::fs::write(dir.path().join(WORKSHOP_STATE_FILE), "not json {").expect("write fixture"); - let menu = MenuBus::new(catalog_of(&["model-a"]), Some(dir.path())); - onto_profile(&menu, "main"); - assert_eq!( - snapshot(&menu).selected_model.as_deref(), - Some("model-a"), - "corrupt memory degrades to no memory, never to a failure" - ); - } - - #[test] - fn an_unreadable_state_file_means_no_memory_yet() { - let dir = tempfile::TempDir::new().expect("tempdir"); - // A directory in the file's place: reads and writes both fail for - // a reason other than NotFound, and both must degrade. - std::fs::create_dir(dir.path().join(WORKSHOP_STATE_FILE)) - .expect("directory in the file's place"); - let menu = MenuBus::new(catalog_of(&["model-a"]), Some(dir.path())); - onto_profile(&menu, "main"); - assert_eq!( - snapshot(&menu).selected_model.as_deref(), - Some("model-a"), - "unreadable memory degrades to no memory, never to a failure" - ); - } - - #[test] - fn a_remembered_model_gone_from_the_catalog_falls_back_to_the_first() { - let dir = tempfile::TempDir::new().expect("tempdir"); - std::fs::write( - dir.path().join(WORKSHOP_STATE_FILE), - r#"{"last_selected":{"main":"retired-model"}}"#, - ) - .expect("write fixture"); - let menu = MenuBus::new(catalog_of(&["model-a"]), Some(dir.path())); - onto_profile(&menu, "main"); - assert_eq!( - snapshot(&menu).selected_model.as_deref(), - Some("model-a"), - "a remembered model the catalog no longer holds falls back to the first" - ); - } - - #[test] - fn restore_selection_picks_the_remembered_model_for_the_active_profile() { - let dir = tempfile::TempDir::new().expect("tempdir"); - std::fs::write( - dir.path().join(WORKSHOP_STATE_FILE), - r#"{"last_selected":{"main":"model-b"}}"#, - ) - .expect("write fixture"); - let menu = MenuBus::new(catalog_of(&["model-a", "model-b"]), Some(dir.path())); - menu.set_profiles(vec!["main".to_string()], Some("main".to_string())); - menu.restore_selection(); - assert_eq!( - snapshot(&menu).selected_model.as_deref(), - Some("model-b"), - "boot restores the remembered model for the active profile" - ); - } - - #[test] - fn restore_selection_falls_back_to_the_first_model_when_memory_is_stale() { - let dir = tempfile::TempDir::new().expect("tempdir"); - std::fs::write( - dir.path().join(WORKSHOP_STATE_FILE), - r#"{"last_selected":{"main":"retired-model"}}"#, - ) - .expect("write fixture"); - let menu = MenuBus::new(catalog_of(&["model-a", "model-b"]), Some(dir.path())); - menu.set_profiles(vec!["main".to_string()], Some("main".to_string())); - menu.restore_selection(); - assert_eq!( - snapshot(&menu).selected_model.as_deref(), - Some("model-a"), - "a remembered model the catalog lacks falls back to the first" - ); - } - - #[test] - fn restore_selection_without_an_active_profile_picks_the_first_model() { - let menu = menu_of(&["model-a", "model-b"]); - menu.restore_selection(); - assert_eq!( - snapshot(&menu).selected_model.as_deref(), - Some("model-a"), - "with no active profile there is no memory; the first model serves" - ); - } - - #[test] - fn restore_selection_with_a_selection_applied_publishes_nothing() { - let menu = menu_of(&["model-a", "model-b"]); - menu.set_selected("model-b") - .expect("the id is in the catalog"); - let mut receiver = menu.subscribe(); - menu.restore_selection(); - assert!( - matches!(receiver.try_recv(), Err(TryRecvError::Empty)), - "an existing selection makes the restore a no-op" - ); - assert_eq!( - snapshot(&menu).selected_model.as_deref(), - Some("model-b"), - "the surviving selection is untouched" - ); - } - - #[test] - fn restore_selection_with_an_empty_catalog_publishes_nothing() { - let menu = menu_of(&[]); - let mut receiver = menu.subscribe(); - menu.restore_selection(); - assert!( - matches!(receiver.try_recv(), Err(TryRecvError::Empty)), - "an empty catalog leaves nothing to restore" - ); - assert!( - menu.latest().is_none(), - "a no-op restore retains no snapshot" - ); - } -} +mod tests; diff --git a/crates/workshop-menu/src/menu/tests.rs b/crates/workshop-menu/src/menu/tests.rs new file mode 100644 index 000000000..d87467847 --- /dev/null +++ b/crates/workshop-menu/src/menu/tests.rs @@ -0,0 +1,451 @@ +use super::*; + +use tokio::sync::broadcast::error::{RecvError, TryRecvError}; + +/// A catalog bus already holding one push of the given model ids. +fn catalog_of(ids: &[&str]) -> CatalogBus { + let catalog = CatalogBus::new(); + catalog.publish(ids.iter().map(|id| serde_json::json!({"id": id})).collect()); + catalog +} + +/// A menu with no persistence, on a catalog of the given model ids. +fn menu_of(ids: &[&str]) -> MenuBus { + MenuBus::new(catalog_of(ids), None) +} + +/// Drives `menu` to full readiness on `profile`: gateway reachable +/// and a completed switch, which selects a model. +fn onto_profile(menu: &MenuBus, profile: &str) { + menu.set_gateway_reachable(true); + menu.begin_switch(profile).expect("no switch is running"); + menu.finish_switch(SwitchOutcome::Completed); +} + +/// The retained snapshot, which every mutation publishes. +fn snapshot(menu: &MenuBus) -> WorkbenchSnapshot { + menu.latest().expect("a mutation published a snapshot") +} + +#[test] +fn a_known_model_selects_and_publishes_the_snapshot() { + let menu = menu_of(&["model-a", "model-b"]); + menu.set_selected("model-b") + .expect("the id is in the catalog"); + assert_eq!(snapshot(&menu).selected_model.as_deref(), Some("model-b")); +} + +#[test] +fn an_unknown_model_is_refused_and_not_applied() { + let menu = menu_of(&["model-a"]); + menu.set_selected("model-a") + .expect("the id is in the catalog"); + let refusal = menu + .set_selected("model-x") + .expect_err("an unknown id is refused"); + assert_eq!( + refusal, + MenuRefusal::UnknownModel { + id: "model-x".to_string() + } + ); + assert_eq!( + snapshot(&menu).selected_model.as_deref(), + Some("model-a"), + "a refused selection leaves the applied one in place" + ); +} + +#[test] +fn a_switch_publishes_its_begin_and_its_finish() { + let menu = menu_of(&["model-a"]); + menu.set_gateway_reachable(true); + menu.begin_switch("coding").expect("no switch is running"); + let during = snapshot(&menu); + assert_eq!(during.switching.as_deref(), Some("coding")); + assert!(!during.chat_ready, "a switch in flight blocks chat"); + menu.finish_switch(SwitchOutcome::Completed); + let after = snapshot(&menu); + assert_eq!(after.active.as_deref(), Some("coding")); + assert_eq!(after.switching, None); + assert_eq!( + after.selected_model.as_deref(), + Some("model-a"), + "with no memory for the profile the first catalog model is selected" + ); + assert!(after.chat_ready); +} + +#[test] +fn a_failed_switch_keeps_the_previous_profile() { + let menu = menu_of(&["model-a"]); + onto_profile(&menu, "main"); + menu.begin_switch("coding").expect("no switch is running"); + menu.finish_switch(SwitchOutcome::Failed); + let after = snapshot(&menu); + assert_eq!(after.active.as_deref(), Some("main")); + assert_eq!(after.switching, None); + assert!(after.chat_ready, "the previous profile still serves"); +} + +#[test] +fn a_second_switch_while_one_runs_is_refused() { + let menu = menu_of(&["model-a"]); + menu.begin_switch("coding").expect("no switch is running"); + let refusal = menu + .begin_switch("writing") + .expect_err("switches are single-flight"); + assert_eq!( + refusal, + MenuRefusal::SwitchInProgress { + name: "coding".to_string() + } + ); + assert_eq!( + snapshot(&menu).switching.as_deref(), + Some("coding"), + "the refused switch is not applied" + ); +} + +#[test] +fn finishing_with_no_switch_in_flight_is_tolerated() { + let menu = menu_of(&[]); + menu.finish_switch(SwitchOutcome::Completed); + assert!(menu.latest().is_none(), "a no-op finish publishes nothing"); +} + +#[test] +fn the_newest_snapshot_is_retained_for_the_connect_snapshot() { + let menu = menu_of(&["model-a", "model-b"]); + assert!(menu.latest().is_none(), "an untouched menu has no snapshot"); + menu.set_selected("model-a") + .expect("the id is in the catalog"); + menu.set_selected("model-b") + .expect("the id is in the catalog"); + assert_eq!( + snapshot(&menu).selected_model.as_deref(), + Some("model-b"), + "a session connecting now snapshots the newest state" + ); +} + +#[tokio::test] +async fn a_lagged_receiver_skips_ahead_instead_of_blocking() { + let menu = menu_of(&["model-a"]); + let mut receiver = menu.subscribe(); + for _ in 0..=MENU_CHANNEL_CAPACITY { + menu.set_gateway_reachable(true); + } + match receiver.recv().await { + Err(RecvError::Lagged(1)) => {} + other => panic!("expected a lag report of one, got {other:?}"), + } + receiver + .recv() + .await + .expect("the ring still holds snapshots"); +} + +#[test] +fn a_catalog_change_clears_a_selection_it_no_longer_holds() { + let catalog = catalog_of(&["model-a"]); + let menu = MenuBus::new(catalog.clone(), None); + menu.set_selected("model-a") + .expect("the id is in the catalog"); + catalog.publish(vec![serde_json::json!({"id": "model-b"})]); + menu.reconcile_catalog(); + assert_eq!( + snapshot(&menu).selected_model, + None, + "the vanished selection is revalidated away" + ); +} + +#[test] +fn a_catalog_change_that_keeps_the_selection_republishes_nothing() { + let catalog = catalog_of(&["model-a"]); + let menu = MenuBus::new(catalog.clone(), None); + menu.set_selected("model-a") + .expect("the id is in the catalog"); + let mut receiver = menu.subscribe(); + catalog.publish(vec![ + serde_json::json!({"id": "model-a"}), + serde_json::json!({"id": "model-b"}), + ]); + menu.reconcile_catalog(); + assert!( + matches!(receiver.try_recv(), Err(TryRecvError::Empty)), + "an unchanged snapshot is not republished" + ); +} + +#[test] +fn chat_ready_is_true_only_when_every_condition_holds() { + let catalog = catalog_of(&["model-a"]); + let menu = MenuBus::new(catalog.clone(), None); + onto_profile(&menu, "main"); + assert!( + snapshot(&menu).chat_ready, + "non-empty catalog, a selection, no switch, gateway up" + ); + + menu.set_gateway_reachable(false); + assert!(!snapshot(&menu).chat_ready, "gateway down forces false"); + menu.set_gateway_reachable(true); + + menu.begin_switch("coding").expect("no switch is running"); + assert!( + !snapshot(&menu).chat_ready, + "a switch in flight forces false" + ); + menu.finish_switch(SwitchOutcome::Completed); + assert!( + snapshot(&menu).chat_ready, + "the finished switch restores readiness" + ); + + catalog.publish(vec![serde_json::json!({"id": "model-b"})]); + menu.reconcile_catalog(); + let cleared = snapshot(&menu); + assert_eq!(cleared.selected_model, None); + assert!(!cleared.chat_ready, "no selection forces false"); + + menu.set_selected("model-b") + .expect("the id is in the catalog"); + catalog.publish(Vec::new()); + menu.reconcile_catalog(); + assert!(!snapshot(&menu).chat_ready, "an empty catalog forces false"); +} + +#[test] +fn chat_ready_is_false_before_the_heartbeat_reports_reachability() { + let menu = menu_of(&["model-a"]); + menu.set_selected("model-a") + .expect("the id is in the catalog"); + assert!( + !snapshot(&menu).chat_ready, + "a fresh menu boots unreachable: catalog and selection alone \ + must not open chat before the heartbeat's first verdict" + ); +} + +#[test] +fn set_profiles_publishes_the_list_and_the_active_profile() { + let menu = menu_of(&["model-a"]); + menu.set_profiles( + vec!["main".to_string(), "coding".to_string()], + Some("main".to_string()), + ); + let published = snapshot(&menu); + assert_eq!(published.profiles, ["main", "coding"]); + assert_eq!(published.active.as_deref(), Some("main")); +} + +#[test] +fn set_profiles_leaves_the_selection_and_readiness_alone() { + let menu = menu_of(&["model-a"]); + onto_profile(&menu, "main"); + menu.set_profiles(vec!["main".to_string()], Some("main".to_string())); + let after = snapshot(&menu); + assert_eq!( + after.selected_model.as_deref(), + Some("model-a"), + "the profile list does not own selection validity" + ); + assert!(after.chat_ready, "readiness survives a profile refresh"); +} + +#[test] +fn an_empty_profile_list_replaces_a_populated_one() { + let menu = menu_of(&[]); + menu.set_profiles(vec!["main".to_string()], Some("main".to_string())); + menu.set_profiles(Vec::new(), None); + let after = snapshot(&menu); + assert!( + after.profiles.is_empty(), + "a gateway without profile support publishes an empty list" + ); + assert_eq!(after.active, None); +} + +#[test] +fn a_selection_is_remembered_per_profile_across_switches() { + let menu = menu_of(&["model-a", "model-b", "model-c"]); + onto_profile(&menu, "main"); + menu.set_selected("model-c") + .expect("the id is in the catalog"); + menu.begin_switch("coding").expect("no switch is running"); + menu.finish_switch(SwitchOutcome::Completed); + assert_eq!( + snapshot(&menu).selected_model.as_deref(), + Some("model-a"), + "a profile with no memory selects the first model" + ); + menu.set_selected("model-b") + .expect("the id is in the catalog"); + menu.begin_switch("main").expect("no switch is running"); + menu.finish_switch(SwitchOutcome::Completed); + assert_eq!( + snapshot(&menu).selected_model.as_deref(), + Some("model-c"), + "the remembered model for the profile is restored" + ); +} + +#[test] +fn model_memory_round_trips_through_the_state_file() { + let dir = tempfile::TempDir::new().expect("tempdir"); + let catalog = catalog_of(&["model-a", "model-b"]); + { + let menu = MenuBus::new(catalog.clone(), Some(dir.path())); + onto_profile(&menu, "main"); + menu.set_selected("model-b") + .expect("the id is in the catalog"); + } + let reborn = MenuBus::new(catalog, Some(dir.path())); + onto_profile(&reborn, "main"); + assert_eq!( + snapshot(&reborn).selected_model.as_deref(), + Some("model-b"), + "the persisted memory survives a restart" + ); +} + +#[test] +fn a_missing_state_file_means_no_memory_yet() { + let dir = tempfile::TempDir::new().expect("tempdir"); + let menu = MenuBus::new(catalog_of(&["model-a"]), Some(dir.path())); + onto_profile(&menu, "main"); + assert_eq!( + snapshot(&menu).selected_model.as_deref(), + Some("model-a"), + "no memory yet: the first catalog model is selected" + ); +} + +#[test] +fn a_corrupt_state_file_means_no_memory_yet() { + let dir = tempfile::TempDir::new().expect("tempdir"); + std::fs::write(dir.path().join(WORKSHOP_STATE_FILE), "not json {").expect("write fixture"); + let menu = MenuBus::new(catalog_of(&["model-a"]), Some(dir.path())); + onto_profile(&menu, "main"); + assert_eq!( + snapshot(&menu).selected_model.as_deref(), + Some("model-a"), + "corrupt memory degrades to no memory, never to a failure" + ); +} + +#[test] +fn an_unreadable_state_file_means_no_memory_yet() { + let dir = tempfile::TempDir::new().expect("tempdir"); + // A directory in the file's place: reads and writes both fail for + // a reason other than NotFound, and both must degrade. + std::fs::create_dir(dir.path().join(WORKSHOP_STATE_FILE)) + .expect("directory in the file's place"); + let menu = MenuBus::new(catalog_of(&["model-a"]), Some(dir.path())); + onto_profile(&menu, "main"); + assert_eq!( + snapshot(&menu).selected_model.as_deref(), + Some("model-a"), + "unreadable memory degrades to no memory, never to a failure" + ); +} + +#[test] +fn a_remembered_model_gone_from_the_catalog_falls_back_to_the_first() { + let dir = tempfile::TempDir::new().expect("tempdir"); + std::fs::write( + dir.path().join(WORKSHOP_STATE_FILE), + r#"{"last_selected":{"main":"retired-model"}}"#, + ) + .expect("write fixture"); + let menu = MenuBus::new(catalog_of(&["model-a"]), Some(dir.path())); + onto_profile(&menu, "main"); + assert_eq!( + snapshot(&menu).selected_model.as_deref(), + Some("model-a"), + "a remembered model the catalog no longer holds falls back to the first" + ); +} + +#[test] +fn restore_selection_picks_the_remembered_model_for_the_active_profile() { + let dir = tempfile::TempDir::new().expect("tempdir"); + std::fs::write( + dir.path().join(WORKSHOP_STATE_FILE), + r#"{"last_selected":{"main":"model-b"}}"#, + ) + .expect("write fixture"); + let menu = MenuBus::new(catalog_of(&["model-a", "model-b"]), Some(dir.path())); + menu.set_profiles(vec!["main".to_string()], Some("main".to_string())); + menu.restore_selection(); + assert_eq!( + snapshot(&menu).selected_model.as_deref(), + Some("model-b"), + "boot restores the remembered model for the active profile" + ); +} + +#[test] +fn restore_selection_falls_back_to_the_first_model_when_memory_is_stale() { + let dir = tempfile::TempDir::new().expect("tempdir"); + std::fs::write( + dir.path().join(WORKSHOP_STATE_FILE), + r#"{"last_selected":{"main":"retired-model"}}"#, + ) + .expect("write fixture"); + let menu = MenuBus::new(catalog_of(&["model-a", "model-b"]), Some(dir.path())); + menu.set_profiles(vec!["main".to_string()], Some("main".to_string())); + menu.restore_selection(); + assert_eq!( + snapshot(&menu).selected_model.as_deref(), + Some("model-a"), + "a remembered model the catalog lacks falls back to the first" + ); +} + +#[test] +fn restore_selection_without_an_active_profile_picks_the_first_model() { + let menu = menu_of(&["model-a", "model-b"]); + menu.restore_selection(); + assert_eq!( + snapshot(&menu).selected_model.as_deref(), + Some("model-a"), + "with no active profile there is no memory; the first model serves" + ); +} + +#[test] +fn restore_selection_with_a_selection_applied_publishes_nothing() { + let menu = menu_of(&["model-a", "model-b"]); + menu.set_selected("model-b") + .expect("the id is in the catalog"); + let mut receiver = menu.subscribe(); + menu.restore_selection(); + assert!( + matches!(receiver.try_recv(), Err(TryRecvError::Empty)), + "an existing selection makes the restore a no-op" + ); + assert_eq!( + snapshot(&menu).selected_model.as_deref(), + Some("model-b"), + "the surviving selection is untouched" + ); +} + +#[test] +fn restore_selection_with_an_empty_catalog_publishes_nothing() { + let menu = menu_of(&[]); + let mut receiver = menu.subscribe(); + menu.restore_selection(); + assert!( + matches!(receiver.try_recv(), Err(TryRecvError::Empty)), + "an empty catalog leaves nothing to restore" + ); + assert!( + menu.latest().is_none(), + "a no-op restore retains no snapshot" + ); +} diff --git a/crates/workshop-protocol/src/catalog.rs b/crates/workshop-protocol/src/catalog.rs index 03991e458..36e1dfc25 100644 --- a/crates/workshop-protocol/src/catalog.rs +++ b/crates/workshop-protocol/src/catalog.rs @@ -31,3 +31,23 @@ pub struct CatalogFrame<'a> { kind: &'static str, models: &'a [serde_json::Value], } + +/// Whether one gateway catalog row can back a chat model binding. +/// +/// This is wire semantics - it interprets the gateway's catalog shape - +/// so it lives here rather than in any one subsystem: the menu filters +/// its picker by it, and the gateway subsystem's catalog refresh reads +/// readiness from it. +#[must_use] +pub fn is_chat_capable(model: &serde_json::Value) -> bool { + let has_id = model + .get("id") + .and_then(serde_json::Value::as_str) + .is_some_and(|id| !id.is_empty()); + let chat_kind = match model.get("kind") { + None => true, + Some(serde_json::Value::String(kind)) => kind == "chat", + Some(_) => false, + }; + has_id && chat_kind +} diff --git a/crates/workshop-protocol/src/lib.rs b/crates/workshop-protocol/src/lib.rs index 223b98969..0443bb326 100644 --- a/crates/workshop-protocol/src/lib.rs +++ b/crates/workshop-protocol/src/lib.rs @@ -135,7 +135,7 @@ mod status; mod workbench; pub use agent::{AgentDeltaFrame, AgentDeltaKind, AgentEventFrame, AgentSessionFrame, AgentsFrame}; -pub use catalog::{CatalogFrame, CatalogPush}; +pub use catalog::{CatalogFrame, CatalogPush, is_chat_capable}; pub use error::{ErrorEnvelope, ErrorFrame}; pub use input::{InputFrame, InputResponse}; pub use status::{Activity, Progress, Severity, StatusBarUpdate, StatusFrame}; diff --git a/crates/workshop-registry/Cargo.toml b/crates/workshop-registry/Cargo.toml index 10ae9ba69..8597bca7d 100644 --- a/crates/workshop-registry/Cargo.toml +++ b/crates/workshop-registry/Cargo.toml @@ -10,6 +10,7 @@ description = "Workshop subsystem registry: sealed proxy slots subsystems self-r [dependencies] axum.workspace = true +serde_json.workspace = true tokio.workspace = true workshop-protocol.workspace = true diff --git a/crates/workshop-registry/src/lib.rs b/crates/workshop-registry/src/lib.rs index be8b2070a..74e79fefe 100644 --- a/crates/workshop-registry/src/lib.rs +++ b/crates/workshop-registry/src/lib.rs @@ -16,17 +16,21 @@ //! only this crate implements them: registrants plug in through the //! adapters provided here, never by implementing a trait downstream. //! - An unregistered slot is a graceful no-op, never an error: -//! consumers branch on `None` and continue degraded. +//! consumers branch on `None` and continue degraded, and the [`Push`] +//! facade drops intents whose sink slot is empty. //! - A registration is alive exactly as long as its guard: dropping the //! guard deregisters the subsystem. +mod push; mod registry; mod slot; mod traits; +pub use push::{MenuPush, Push}; pub use registry::Registry; pub use slot::{ProxySlot, Registration}; pub use traits::{ - BackgroundTasks, RouteRegistrar, ShutdownHook, StateProvider, StatusChannel, - StatusChannelAdapter, + BackgroundTasks, CatalogSink, CatalogSinkAdapter, MenuSink, MenuSinkAdapter, RouteRegistrar, + ShutdownHook, StateProvider, StatusChannel, StatusChannelAdapter, StatusSink, + StatusSinkAdapter, }; diff --git a/crates/workshop-registry/src/push.rs b/crates/workshop-registry/src/push.rs new file mode 100644 index 000000000..e80438408 --- /dev/null +++ b/crates/workshop-registry/src/push.rs @@ -0,0 +1,184 @@ +//! The intent-named push facade over the producer sink slots: business +//! code reports what happened and never chooses a severity or builds a +//! bus payload (SiYuan's `PushReloadFiletree` pattern). +//! +//! Producers hold a [`Push`] and speak in intents - a status update, a +//! failure, an activity pulse, determinate progress, idle, a fresh model +//! catalog; workbench producers drive the Model-menu mutators through +//! [`Push::menu`], and every mutation publishes its own snapshot. What +//! each intent becomes on the wire is decided here and in +//! `workshop-protocol`, nowhere else. The sinks stay the transport: +//! every `/ws` session subscribes to the status, catalog, and menu +//! buses behind the sink slots and serializes what it receives. +//! +//! Every intent degrades to a no-op while its sink slot is empty, so a +//! producer spawned before its subsystem registers never fails. + +use workshop_protocol::{Activity, Progress, Severity, StatusBarUpdate}; + +use crate::Registry; + +/// The intent-named push handle over the status, catalog, and menu sink +/// slots. +/// +/// Clones are cheap (a few `Arc` bumps) and every clone reads the same +/// registry slots, so producers take their own copy, exactly as they did +/// with the buses themselves. +#[derive(Debug, Clone)] +pub struct Push { + registry: Registry, +} + +impl Push { + /// Wraps the registry whose sink slots every unsolicited push flows + /// through. + pub(crate) fn new(registry: Registry) -> Self { + Self { registry } + } + + /// Pushes a user-visible status update: a `{"type":"status",...}` + /// `StatusFrame` at info severity with no progress. + pub fn push_status_update( + &self, + label: impl Into, + description: impl Into, + activity: Activity, + ) { + self.emit(label, description, None, Severity::Info, activity); + } + + /// Pushes a failure the user should see: a `{"type":"status",...}` + /// `StatusFrame` at error severity. + pub fn push_failure( + &self, + label: impl Into, + description: impl Into, + activity: Activity, + ) { + self.emit(label, description, None, Severity::Error, activity); + } + + /// Pushes an activity pulse the UI does not display as text: a + /// `{"type":"status",...}` `StatusFrame` at debug severity, whose + /// `activity` field drives the status bar's LED. + pub fn push_activity( + &self, + label: impl Into, + description: impl Into, + activity: Activity, + ) { + self.emit(label, description, None, Severity::Debug, activity); + } + + /// Pushes determinate progress - `current` of `total` units done: a + /// `{"type":"status",...}` `StatusFrame` at [`Severity::Info`] + /// carrying a [`Progress`], which the status bar renders as its + /// progress bar. + pub fn push_progress( + &self, + label: impl Into, + description: impl Into, + current: u64, + total: u64, + activity: Activity, + ) { + self.emit( + label, + description, + Some(Progress { current, total }), + Severity::Info, + activity, + ); + } + + /// Pushes the status bar back to its resting state: the + /// `Ready`/`idle` `{"type":"status",...}` `StatusFrame`. + pub fn push_idle(&self) { + self.push_status_update("Ready", "idle", Activity::General); + } + + /// Pushes one complete model catalog snapshot: a + /// `{"type":"models",...}` `CatalogFrame` carrying only chat-capable + /// entries. The single choke point for catalog publishes: the menu + /// revalidates its selection against the new catalog and republishes + /// the workbench snapshot when it changed. + pub fn push_models_catalog(&self, models: Vec) { + if let Some(catalog) = self.registry.catalog_sink().get() { + catalog.publish(models); + } + if let Some(menu) = self.registry.menu_sink().get() { + menu.reconcile_catalog(); + } + } + + /// The menu sink behind the facade, for producers that drive the + /// Model-menu mutators directly: the heartbeat feeds reachability + /// and the gateway's profile state through this handle. + #[must_use] + pub fn menu(&self) -> MenuPush { + MenuPush { + registry: self.registry.clone(), + } + } + + /// Builds one status frame and emits it into the status sink; a + /// no-op while the status subsystem has not registered. + fn emit( + &self, + label: impl Into, + description: impl Into, + progress: Option, + severity: Severity, + activity: Activity, + ) { + let Some(status) = self.registry.status_sink().get() else { + return; + }; + status.emit(StatusBarUpdate { + label: label.into(), + description: description.into(), + progress, + severity, + activity, + }); + } +} + +/// The menu-mutator face of [`Push`]: the workbench mutators the +/// gateway subsystem drives, reading the registry's menu sink slot. +/// Every mutator is a no-op while the menu subsystem has not +/// registered. +#[derive(Debug, Clone)] +pub struct MenuPush { + registry: Registry, +} + +impl MenuPush { + /// Records the heartbeat's verdict on the gateway; `chat_ready` is + /// false while the gateway is down. + pub fn set_gateway_reachable(&self, reachable: bool) { + if let Some(menu) = self.registry.menu_sink().get() { + menu.set_gateway_reachable(reachable); + } + } + + /// Records the gateway's profile list and active profile. A gateway + /// without profile support feeds an empty list - a state, not an + /// error. + pub fn set_profiles(&self, profiles: Vec, active: Option) { + if let Some(menu) = self.registry.menu_sink().get() { + menu.set_profiles(profiles, active); + } + } + + /// Restores a boot-time selection when none is applied; with a + /// selection already applied this is a no-op. + pub fn restore_selection(&self) { + if let Some(menu) = self.registry.menu_sink().get() { + menu.restore_selection(); + } + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/workshop-registry/src/push/tests.rs b/crates/workshop-registry/src/push/tests.rs new file mode 100644 index 000000000..f71823c67 --- /dev/null +++ b/crates/workshop-registry/src/push/tests.rs @@ -0,0 +1,297 @@ +//! Tests for the [`Push`] facade: every intent lands on the right sink +//! with the right frame, and an empty slot degrades the intent to a +//! no-op. The sinks are recording adapters - closures capturing into +//! channels - so the assertions read exactly like the bus-receiver +//! assertions the producers' own crates carry. + +use std::sync::{Arc, Mutex, PoisonError}; + +use tokio::sync::broadcast; + +use super::*; +use crate::{ + CatalogSinkAdapter, MenuSinkAdapter, Registration, StatusSink, StatusSinkAdapter, + traits::{CatalogSink, MenuSink}, +}; +use workshop_protocol::StatusBarUpdate; + +/// A registry wired with recording sinks: status and catalog frames go +/// to broadcast receivers, menu mutator calls to a shared log. +struct Recording { + push: Push, + status_rx: broadcast::Receiver, + catalog_rx: broadcast::Receiver>, + menu_calls: Arc>>, + // The registrations keep the sinks alive for the test's duration. + _guards: ( + Registration, + Registration, + Registration, + ), +} + +/// Wires a registry with recording sink adapters and returns its push +/// facade plus the recording ends. +fn wired() -> Recording { + let registry = Registry::new(); + let (status_tx, status_rx) = broadcast::channel(16); + let (catalog_tx, catalog_rx) = broadcast::channel(16); + let menu_calls: Arc>> = Arc::new(Mutex::new(Vec::new())); + let status_guard = registry + .status_sink() + .register(Arc::new(StatusSinkAdapter::new(move |update| { + // A send only fails when the test dropped its receiver. + let _ = status_tx.send(update); + }))); + let catalog_guard = registry + .catalog_sink() + .register(Arc::new(CatalogSinkAdapter::new(move |models| { + let _ = catalog_tx.send(models); + }))); + let menu_guard = { + let calls = Arc::clone(&menu_calls); + registry.menu_sink().register(Arc::new(MenuSinkAdapter::new( + { + let calls = Arc::clone(&calls); + move |reachable| record(&calls, format!("reachable:{reachable}")) + }, + { + let calls = Arc::clone(&calls); + move |profiles, active| { + record(&calls, format!("profiles:{}:{active:?}", profiles.len())); + } + }, + { + let calls = Arc::clone(&calls); + move || record(&calls, "restore".to_string()) + }, + move || record(&calls, "reconcile".to_string()), + ))) + }; + Recording { + push: registry.push(), + status_rx, + catalog_rx, + menu_calls, + _guards: (status_guard, catalog_guard, menu_guard), + } +} + +/// Appends one menu mutator call to the recording. +fn record(calls: &Mutex>, call: String) { + calls + .lock() + .unwrap_or_else(PoisonError::into_inner) + .push(call); +} + +/// The menu mutator calls recorded so far. +fn menu_calls(recording: &Recording) -> Vec { + recording + .menu_calls + .lock() + .unwrap_or_else(PoisonError::into_inner) + .clone() +} + +#[tokio::test] +async fn a_status_update_reaches_the_sink_at_info_severity() { + let mut recording = wired(); + recording.push.push_status_update( + "Connected to gateway", + "the probe answered", + Activity::General, + ); + let update = recording + .status_rx + .recv() + .await + .expect("the update reaches the sink"); + assert_eq!( + update, + StatusBarUpdate { + label: "Connected to gateway".to_string(), + description: "the probe answered".to_string(), + progress: None, + severity: Severity::Info, + activity: Activity::General, + } + ); +} + +#[tokio::test] +async fn a_failure_reaches_the_sink_at_error_severity() { + let mut recording = wired(); + recording + .push + .push_failure("Connection lost", "the gateway hung up", Activity::General); + let update = recording + .status_rx + .recv() + .await + .expect("the update reaches the sink"); + assert_eq!( + update, + StatusBarUpdate { + label: "Connection lost".to_string(), + description: "the gateway hung up".to_string(), + progress: None, + severity: Severity::Error, + activity: Activity::General, + } + ); +} + +#[tokio::test] +async fn an_activity_pulse_reaches_the_sink_at_debug_severity() { + let mut recording = wired(); + recording.push.push_activity( + "Streaming response...", + "a gateway response chunk", + Activity::Generating, + ); + let update = recording + .status_rx + .recv() + .await + .expect("the update reaches the sink"); + assert_eq!( + update, + StatusBarUpdate { + label: "Streaming response...".to_string(), + description: "a gateway response chunk".to_string(), + progress: None, + severity: Severity::Debug, + activity: Activity::Generating, + } + ); +} + +#[tokio::test] +async fn progress_reaches_the_sink_with_its_current_and_total_counts() { + let mut recording = wired(); + recording.push.push_progress( + "Downloading model", + "ggml-large-v3.bin", + 5, + 12, + Activity::General, + ); + let update = recording + .status_rx + .recv() + .await + .expect("the update reaches the sink"); + assert_eq!( + update, + StatusBarUpdate { + label: "Downloading model".to_string(), + description: "ggml-large-v3.bin".to_string(), + progress: Some(Progress { + current: 5, + total: 12, + }), + severity: Severity::Info, + activity: Activity::General, + } + ); +} + +#[tokio::test] +async fn idle_reaches_the_sink_as_the_resting_update() { + let mut recording = wired(); + recording.push.push_idle(); + let update = recording + .status_rx + .recv() + .await + .expect("the update reaches the sink"); + assert_eq!( + update, + StatusBarUpdate { + label: "Ready".to_string(), + description: "idle".to_string(), + progress: None, + severity: Severity::Info, + activity: Activity::General, + } + ); +} + +#[tokio::test] +async fn a_models_catalog_reaches_the_sink_as_one_snapshot() { + let mut recording = wired(); + let models = vec![serde_json::json!({"id": "test-model", "object": "model"})]; + recording.push.push_models_catalog(models.clone()); + let received = recording + .catalog_rx + .recv() + .await + .expect("the push reaches the sink"); + assert_eq!(received, models, "the catalog sink takes the raw models"); +} + +#[tokio::test] +async fn a_catalog_push_reconciles_the_workbench_selection() { + let recording = wired(); + recording + .push + .push_models_catalog(vec![serde_json::json!({"id": "model-a"})]); + assert_eq!( + menu_calls(&recording), + ["reconcile"], + "every catalog publish revalidates the menu's selection" + ); +} + +#[test] +fn the_menu_handle_drives_the_menu_sink_mutators() { + let recording = wired(); + let menu = recording.push.menu(); + menu.set_gateway_reachable(true); + menu.set_profiles(vec!["main".to_string()], Some("main".to_string())); + menu.restore_selection(); + assert_eq!( + menu_calls(&recording), + ["reachable:true", "profiles:1:Some(\"main\")", "restore"], + ); +} + +#[test] +fn intents_on_empty_slots_are_no_ops() { + let push = Registry::new().push(); + // None of these panic or fail without registered sinks. + push.push_status_update("label", "description", Activity::General); + push.push_failure("label", "description", Activity::General); + push.push_activity("label", "description", Activity::General); + push.push_progress("label", "description", 1, 2, Activity::General); + push.push_idle(); + push.push_models_catalog(Vec::new()); + let menu = push.menu(); + menu.set_gateway_reachable(false); + menu.set_profiles(Vec::new(), None); + menu.restore_selection(); +} + +#[test] +fn dropping_a_registration_stops_its_intents() { + let registry = Registry::new(); + let (status_tx, mut status_rx) = broadcast::channel(16); + let guard = registry + .status_sink() + .register(Arc::new(StatusSinkAdapter::new(move |update| { + let _ = status_tx.send(update); + }))); + let push = registry.push(); + push.push_idle(); + assert!( + status_rx.try_recv().is_ok(), + "the registered sink receives the intent" + ); + drop(guard); + push.push_idle(); + assert!( + status_rx.try_recv().is_err(), + "the dropped registration deregisters the sink" + ); +} diff --git a/crates/workshop-registry/src/registry.rs b/crates/workshop-registry/src/registry.rs index 1f30c6e47..c48558077 100644 --- a/crates/workshop-registry/src/registry.rs +++ b/crates/workshop-registry/src/registry.rs @@ -3,8 +3,12 @@ use std::fmt; use std::sync::Arc; +use crate::push::Push; use crate::slot::ProxySlot; -use crate::traits::{BackgroundTasks, RouteRegistrar, ShutdownHook, StateProvider, StatusChannel}; +use crate::traits::{ + BackgroundTasks, CatalogSink, MenuSink, RouteRegistrar, ShutdownHook, StateProvider, + StatusChannel, StatusSink, +}; /// The central registry subsystems self-register into. /// @@ -17,6 +21,9 @@ pub struct Registry { tasks: ProxySlot, status: ProxySlot, shutdown: ProxySlot, + status_sink: ProxySlot, + catalog_sink: ProxySlot, + menu_sink: ProxySlot, } impl Registry { @@ -30,6 +37,9 @@ impl Registry { tasks: ProxySlot::new(), status: ProxySlot::new(), shutdown: ProxySlot::new(), + status_sink: ProxySlot::new(), + catalog_sink: ProxySlot::new(), + menu_sink: ProxySlot::new(), } } @@ -74,6 +84,35 @@ impl Registry { pub fn status_channel(&self) -> Option> { self.status.get() } + + /// The status producer slot: the status subsystem registers its + /// receiving end, and same-tier producers emit through it. + #[must_use] + pub fn status_sink(&self) -> &ProxySlot { + &self.status_sink + } + + /// The catalog producer slot: the menu subsystem registers its + /// catalog channel's receiving end. + #[must_use] + pub fn catalog_sink(&self) -> &ProxySlot { + &self.catalog_sink + } + + /// The menu producer slot: the menu subsystem registers its + /// workbench mutators' receiving end. + #[must_use] + pub fn menu_sink(&self) -> &ProxySlot { + &self.menu_sink + } + + /// The intent-named push facade over the producer sink slots, for + /// subsystems that report what happened without naming another + /// subsystem's bus. + #[must_use] + pub fn push(&self) -> Push { + Push::new(self.clone()) + } } impl Default for Registry { @@ -90,6 +129,9 @@ impl Clone for Registry { tasks: self.tasks.clone(), status: self.status.clone(), shutdown: self.shutdown.clone(), + status_sink: self.status_sink.clone(), + catalog_sink: self.catalog_sink.clone(), + menu_sink: self.menu_sink.clone(), } } } @@ -103,6 +145,9 @@ impl fmt::Debug for Registry { .field("tasks", &self.tasks) .field("status", &self.status) .field("shutdown", &self.shutdown) + .field("status_sink", &self.status_sink) + .field("catalog_sink", &self.catalog_sink) + .field("menu_sink", &self.menu_sink) .finish() } } diff --git a/crates/workshop-registry/src/traits.rs b/crates/workshop-registry/src/traits.rs index 81c5dca79..467fb0f1d 100644 --- a/crates/workshop-registry/src/traits.rs +++ b/crates/workshop-registry/src/traits.rs @@ -67,6 +67,37 @@ pub trait ShutdownHook: Sealed + Send + Sync { fn shutdown(&self); } +/// The status producer sink: the status subsystem's receiving end for +/// [`StatusBarUpdate`]s emitted by subsystems in other crates. The +/// [`Push`](crate::Push) facade builds the frames; the sink only +/// accepts them, so a producer in a same-tier crate never names the +/// status bus's type. +pub trait StatusSink: Sealed + Send + Sync { + /// Emits one update onto the status bus. + fn emit(&self, update: StatusBarUpdate); +} + +/// The catalog producer sink: the menu subsystem's receiving end for +/// refreshed model catalogs published by the gateway subsystem. +pub trait CatalogSink: Sealed + Send + Sync { + /// Publishes one complete model catalog snapshot. + fn publish(&self, models: Vec); +} + +/// The menu producer sink: the menu subsystem's receiving end for the +/// workbench mutators the gateway subsystem drives - reachability +/// verdicts, profile state, and selection restores. +pub trait MenuSink: Sealed + Send + Sync { + /// Records the heartbeat's verdict on gateway reachability. + fn set_gateway_reachable(&self, reachable: bool); + /// Records the gateway's profile list and active profile. + fn set_profiles(&self, profiles: Vec, active: Option); + /// Restores a boot-time selection when none is applied. + fn restore_selection(&self); + /// Revalidates the selection against the catalog just published. + fn reconcile_catalog(&self); +} + /// A [`StatusChannel`] backed by two closures over the status bus: the /// registration adapter for the status subsystem. The registry's traits /// are sealed, so the registrant plugs its bus in through this adapter @@ -113,3 +144,138 @@ impl fmt::Debug for StatusChannelAdapter { formatter.debug_struct("StatusChannelAdapter").finish() } } + +/// A [`StatusSink`] backed by one closure over the status bus: the +/// registration adapter for the status subsystem's producer side. The +/// registry's traits are sealed, so the registrant plugs its bus in +/// through this adapter rather than implementing the trait itself. +pub struct StatusSinkAdapter { + emit: E, +} + +impl StatusSinkAdapter +where + E: Fn(StatusBarUpdate) + Send + Sync, +{ + /// Builds the adapter from the bus's emit closure. + pub fn new(emit: E) -> Self { + Self { emit } + } +} + +impl Sealed for StatusSinkAdapter where E: Fn(StatusBarUpdate) + Send + Sync {} + +impl StatusSink for StatusSinkAdapter +where + E: Fn(StatusBarUpdate) + Send + Sync, +{ + fn emit(&self, update: StatusBarUpdate) { + (self.emit)(update); + } +} + +impl fmt::Debug for StatusSinkAdapter { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.debug_struct("StatusSinkAdapter").finish() + } +} + +/// A [`CatalogSink`] backed by one closure over the catalog bus: the +/// registration adapter for the menu subsystem's catalog channel. +pub struct CatalogSinkAdapter

{ + publish: P, +} + +impl

CatalogSinkAdapter

+where + P: Fn(Vec) + Send + Sync, +{ + /// Builds the adapter from the bus's publish closure. + pub fn new(publish: P) -> Self { + Self { publish } + } +} + +impl

Sealed for CatalogSinkAdapter

where P: Fn(Vec) + Send + Sync {} + +impl

CatalogSink for CatalogSinkAdapter

+where + P: Fn(Vec) + Send + Sync, +{ + fn publish(&self, models: Vec) { + (self.publish)(models); + } +} + +impl

fmt::Debug for CatalogSinkAdapter

{ + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.debug_struct("CatalogSinkAdapter").finish() + } +} + +/// A [`MenuSink`] backed by closures over the menu bus's mutators: the +/// registration adapter for the menu subsystem's workbench state. +pub struct MenuSinkAdapter { + reachable: R, + profiles: P, + restore: S, + reconcile: C, +} + +impl MenuSinkAdapter +where + R: Fn(bool) + Send + Sync, + P: Fn(Vec, Option) + Send + Sync, + S: Fn() + Send + Sync, + C: Fn() + Send + Sync, +{ + /// Builds the adapter from the menu bus's mutator closures, in the + /// [`MenuSink`] trait's method order. + pub fn new(reachable: R, profiles: P, restore: S, reconcile: C) -> Self { + Self { + reachable, + profiles, + restore, + reconcile, + } + } +} + +impl Sealed for MenuSinkAdapter +where + R: Fn(bool) + Send + Sync, + P: Fn(Vec, Option) + Send + Sync, + S: Fn() + Send + Sync, + C: Fn() + Send + Sync, +{ +} + +impl MenuSink for MenuSinkAdapter +where + R: Fn(bool) + Send + Sync, + P: Fn(Vec, Option) + Send + Sync, + S: Fn() + Send + Sync, + C: Fn() + Send + Sync, +{ + fn set_gateway_reachable(&self, reachable: bool) { + (self.reachable)(reachable); + } + + fn set_profiles(&self, profiles: Vec, active: Option) { + (self.profiles)(profiles, active); + } + + fn restore_selection(&self) { + (self.restore)(); + } + + fn reconcile_catalog(&self) { + (self.reconcile)(); + } +} + +impl fmt::Debug for MenuSinkAdapter { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.debug_struct("MenuSinkAdapter").finish() + } +} diff --git a/crates/workshop-server/Cargo.toml b/crates/workshop-server/Cargo.toml index 9899d489e..fa0127179 100644 --- a/crates/workshop-server/Cargo.toml +++ b/crates/workshop-server/Cargo.toml @@ -44,15 +44,23 @@ toml.workspace = true tracing.workspace = true tracing-subscriber.workspace = true url.workspace = true +workshop-gateway.workspace = true +workshop-menu.workspace = true workshop-protocol.workspace = true workshop-registry.workspace = true +workshop-status.workspace = true workshop-support.workspace = true promptforge-agent.workspace = true tempfile = { workspace = true, optional = true } [features] default = [] -test-fixtures = ["dep:tempfile", "workshop-support/test-fixtures"] +test-fixtures = [ + "dep:tempfile", + "workshop-gateway/test-fixtures", + "workshop-menu/test-fixtures", + "workshop-support/test-fixtures", +] [dev-dependencies] workshop-server = { path = ".", features = ["test-fixtures"] } diff --git a/crates/workshop-server/src/app.rs b/crates/workshop-server/src/app.rs index 8279775a9..a26c910d2 100644 --- a/crates/workshop-server/src/app.rs +++ b/crates/workshop-server/src/app.rs @@ -7,7 +7,7 @@ use axum::Router; use shared_progress::ProgressHub; -use workshop_registry::{Registration, Registry, StatusChannel, StatusChannelAdapter}; +use workshop_registry::{CatalogSink, MenuSink, Registration, Registry, StatusChannel, StatusSink}; use workshop_support::{Config, DEFAULT_DEADLINE, ReconnectBackoff, with_deadline}; use crate::catalog::CatalogBus; @@ -40,11 +40,20 @@ pub struct AppState { pub(crate) workspace: Workspace, pub(crate) agents: AgentSessions, registry: Registry, - // Keeps the status bus's self-registration alive; dropping the last - // state clone deregisters it. - _status_registration: Arc>, + // Keeps the subsystems' self-registrations alive; dropping the last + // state clone deregisters them. + _registrations: Registrations, } +/// The registration guards keeping the subsystems' self-registrations +/// alive: the status push channel and the three producer sinks. +type Registrations = ( + Arc>, + Arc>, + Arc>, + Arc>, +); + impl AppState { /// Builds shared state from the loaded configuration, resolving the /// gateway endpoint first: a live gateway discovery file in the run @@ -74,11 +83,11 @@ impl AppState { &self.progress } - /// The push facade over the status, catalog, and menu buses, held by - /// every subsystem that reports what happened. + /// The push facade over the status, catalog, and menu sink slots, + /// held by every subsystem that reports what happened. #[must_use] pub fn push(&self) -> Push { - Push::new(self.status.clone(), self.catalog.clone(), self.menu.clone()) + self.registry.push() } /// One atomic Gateway endpoint and credential generation. @@ -184,23 +193,20 @@ pub fn state_with_gateway( // known and quiet, so it is swept here. workshop_support::sweep_orphaned_temps(state_dir); let menu = MenuBus::new(catalog.clone(), Some(state_dir)); - let push = Push::new(status.clone(), catalog.clone(), menu.clone()); - // The status bus is the proof-of-concept self-registrant: the `/ws` - // session loop discovers its channel through the registry's slot - // instead of naming the bus. + // The subsystems self-register: the `/ws` session loop discovers the + // status channel through the registry's slot instead of naming the + // bus, and same-tier producers (the gateway heartbeat's refreshes) + // reach the buses through the sink slots behind the push facade. let registry = Registry::new(); - let status_registration = Arc::new(registry.status().register(Arc::new( - StatusChannelAdapter::new( - { - let bus = status.clone(); - move || bus.subscribe() - }, - { - let bus = status.clone(); - move || bus.latest() - }, - ), - ))); + let (status_channel, status_sink) = workshop_status::register(®istry, &status); + let (catalog_sink, menu_sink) = workshop_menu::register(®istry, &catalog, &menu); + let registrations = ( + Arc::new(status_channel), + Arc::new(status_sink), + Arc::new(catalog_sink), + Arc::new(menu_sink), + ); + let push = registry.push(); // Startup phases are reported as they run; with no client connected // yet these land on an empty bus, ready for the first session. crate::resolve::report(gateway, &push); @@ -237,7 +243,7 @@ pub fn state_with_gateway( workspace, agents, registry, - _status_registration: status_registration, + _registrations: registrations, }) } diff --git a/crates/workshop-server/src/error.rs b/crates/workshop-server/src/error.rs index 7738256e4..9cab9d498 100644 --- a/crates/workshop-server/src/error.rs +++ b/crates/workshop-server/src/error.rs @@ -294,7 +294,8 @@ mod tests { let unreachable = AppError::GatewayUnreachable; assert_eq!(unreachable.status(), StatusCode::BAD_GATEWAY); assert_eq!(unreachable.code(), Some("gateway_unreachable")); - let transport = AppError::Gateway(GatewayError::Transport(Box::new(injected_io()))); + let transport = + AppError::Gateway(GatewayError::transport_for_test(Box::new(injected_io()))); assert_eq!(transport.status(), StatusCode::BAD_GATEWAY); assert_eq!(transport.code(), Some("gateway_unreachable")); } @@ -452,7 +453,7 @@ mod tests { "file cannot be read", "production bodies carry no source detail" ); - let gateway = AppError::Gateway(GatewayError::Transport(Box::new(injected_io()))); + let gateway = AppError::Gateway(GatewayError::transport_for_test(Box::new(injected_io()))); assert_eq!( render_message(&gateway, false), "gateway transport error", diff --git a/crates/workshop-server/src/fixtures.rs b/crates/workshop-server/src/fixtures.rs index e0cad9405..58b941146 100644 --- a/crates/workshop-server/src/fixtures.rs +++ b/crates/workshop-server/src/fixtures.rs @@ -55,6 +55,16 @@ pub fn spawn_heartbeat( ) } +/// The named child-process half of [`ValidatedGateway`]: the fixture +/// spawns a copy of this test binary with this test's name, so the name +/// must stay in sync with the `spawn_in` call sites. +#[cfg(test)] +#[test] +#[ignore = "runs only as a named child process"] +fn validated_gateway_fixture_process() { + run_validated_gateway_fixture_process(); +} + /// Spawns a Workshop test server against the explicit configured Gateway. #[cfg(feature = "test-fixtures")] pub fn spawn(config: crate::Config) -> Result { diff --git a/crates/workshop-server/src/gateway.rs b/crates/workshop-server/src/gateway.rs deleted file mode 100644 index 1945a63f1..000000000 --- a/crates/workshop-server/src/gateway.rs +++ /dev/null @@ -1,1234 +0,0 @@ -//! HTTP client for the PromptForge gateway's OpenAI-compatible API. -//! -//! [`GatewayClient`] wraps `reqwest` with bearer authentication and returns -//! responses as raw bytes so the workshop routes can relay them to the -//! caller byte-for-byte. A non-success status from the gateway is *not* an -//! error here: it is part of the relayed response. Streaming responses -//! (profile switches, cache downloads) are decoded from SSE into a -//! [`SsePayloadStream`] of `data:` payloads. - -use std::collections::VecDeque; -use std::path::PathBuf; -use std::pin::Pin; -use std::time::Duration; - -use futures_util::stream::{self, Stream, StreamExt}; -use serde::Deserialize; - -mod socket; -pub(crate) use socket::GatewayRealtimeSocket; - -/// Default bound on a single `GET /health` probe: a gateway that accepts -/// the connection but never answers must still read as unreachable, and two -/// seconds keeps the probe well under the heartbeat interval it serves. -pub(crate) const HEALTH_PROBE_TIMEOUT: Duration = Duration::from_secs(2); - -/// TCP connect timeout applied to every request. A gateway that is down or -/// unreachable should fail fast rather than hanging for the OS default (~21 s -/// on Linux, ~75 s on Windows). -const CONNECT_TIMEOUT: Duration = Duration::from_secs(5); - -/// Default whole-request timeout for non-streaming operations: the model -/// catalog fetch and the initial cache API handshake. Streaming responses -/// (cache downloads and profile switches) can legitimately run for -/// minutes, so the same bound covers only their header phase (see -/// `send_bounded`) and the body stream stays open-ended. -pub(crate) const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); - -/// A gateway HTTP response captured for verbatim relay. -#[derive(Debug)] -pub struct GatewayResponse { - /// The gateway's status code, relayed unchanged. - pub status: reqwest::StatusCode, - /// The gateway's response body, relayed byte-for-byte. - pub body: Vec, -} - -/// A gateway response captured for the config-panel proxy: the relay -/// keeps the content type alongside the status and body, because the -/// config UI distinguishes a buffered JSON answer from an SSE stream by -/// it. -#[derive(Debug)] -pub(crate) struct ForwardedResponse { - /// The gateway's status code, relayed unchanged. - pub(crate) status: reqwest::StatusCode, - /// The gateway's `Content-Type`, when it sent one. - pub(crate) content_type: Option, - /// The gateway's response body, relayed byte-for-byte. - pub(crate) body: Vec, -} - -/// A stream of SSE `data:` payloads from the gateway, in arrival order. -/// -/// Each item is one event's data, verbatim. A transport failure mid-stream -/// yields one error item and then ends the stream. -pub type SsePayloadStream = Pin> + Send>>; - -/// The gateway's answer to a cache-ensure request, `POST /v1/cache`. -/// -/// The gateway answers a cache hit with a buffered JSON `ready` event and a -/// miss with an SSE stream of `downloading` progress events terminated by a -/// `ready` or `error` event; both event shapes decode as [`CacheEvent`]. A -/// non-success status (a declined or failed request) is buffered rather -/// than reported as an error, matching the relay contract of the other -/// client methods. -#[non_exhaustive] -pub enum CacheResponse { - /// The gateway is downloading the blob; `payloads` carries the SSE - /// stream of [`CacheEvent`] JSON documents. - #[non_exhaustive] - Download { - /// The gateway's success status. - status: reqwest::StatusCode, - /// The SSE payload stream, ending in a terminal `ready` or `error` - /// event. - payloads: SsePayloadStream, - }, - - /// Any other answer, buffered: a cache hit's `ready` JSON on a success - /// status, or the gateway's error envelope on a failure status. - #[non_exhaustive] - Buffered(GatewayResponse), -} - -// Manual because the boxed payload stream has no `Debug` impl. -impl std::fmt::Debug for CacheResponse { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Download { status, .. } => f - .debug_struct("CacheResponse::Download") - .field("status", status) - .finish_non_exhaustive(), - Self::Buffered(response) => f - .debug_tuple("CacheResponse::Buffered") - .field(response) - .finish(), - } - } -} - -/// One event of the gateway cache API: a download progress sample, or the -/// terminal state of a cache-ensure call. -/// -/// The `path` a `Ready` event carries names a file on the gateway host, so -/// the cache API is only meaningful to a workshop sharing the gateway's -/// filesystem - the standard local deployment, where both run on loopback. -#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] -#[serde(tag = "status", rename_all = "lowercase")] -#[non_exhaustive] -pub enum CacheEvent { - /// A progress sample from a running download. - #[non_exhaustive] - Downloading { - /// Cumulative bytes downloaded so far. - bytes: u64, - /// Total bytes expected; null when the upstream server sent no - /// Content-Length. - total: Option, - }, - - /// The blob is cached and ready at `path`. - #[non_exhaustive] - Ready { - /// Local path of the cached blob on the gateway host. - path: PathBuf, - }, - - /// The download failed. - #[non_exhaustive] - Error { - /// The gateway's description of the failure. - message: String, - }, -} - -/// The gateway's answer to a profile switch, `POST /admin/switch-profile`. -/// -/// An accepted switch answers `text/event-stream`: stage markers as the -/// switch proceeds, then exactly one terminal `ready` or `error` event, all -/// decoding as [`SwitchEvent`]. A refusal before the switch starts (bad -/// auth, a malformed name, no profiles directory) is buffered rather than -/// reported as an error, matching the relay contract of the other client -/// methods. -#[non_exhaustive] -pub enum SwitchResponse { - /// The gateway accepted the switch and is streaming its progress. - #[non_exhaustive] - Switching { - /// The gateway's success status. - status: reqwest::StatusCode, - /// The SSE payload stream of [`SwitchEvent`] JSON documents, ending - /// in a terminal `ready` or `error` event. - payloads: SsePayloadStream, - }, - - /// A refusal, buffered: the gateway's error envelope. - #[non_exhaustive] - Buffered(GatewayResponse), -} - -// Manual because the boxed payload stream has no `Debug` impl. -impl std::fmt::Debug for SwitchResponse { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Switching { status, .. } => f - .debug_struct("SwitchResponse::Switching") - .field("status", status) - .finish_non_exhaustive(), - Self::Buffered(response) => f - .debug_tuple("SwitchResponse::Buffered") - .field(response) - .finish(), - } - } -} - -/// One event of the gateway's switch-profile stream: a stage marker as the -/// switch proceeds, then exactly one terminal event. -/// -/// Stage markers arrive in execution order - `loading-profile`, -/// `stopping-models`, `starting-models` (the long pole: weights loading -/// into VRAM). The stage stays a string so a gateway that grows a new -/// stage never breaks the decode. -#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] -#[serde(untagged)] -#[non_exhaustive] -pub enum SwitchEvent { - /// A phase of the switch is beginning. - #[non_exhaustive] - Stage { - /// The gateway's name for the phase, e.g. `starting-models`. - stage: String, - }, - - /// Terminal: the switch committed and `profile` is live. - #[non_exhaustive] - Ready { - /// The now-active profile name. - profile: String, - }, - - /// Terminal: the switch failed. The previous profile stays - /// authenticated and remote-routable, but its local children may - /// already be gone (the gateway's documented degraded state). - #[non_exhaustive] - Error { - /// The gateway's description of the failure. - message: String, - }, -} - -/// A stream of decoded [`SwitchEvent`]s, as produced by [`switch_events`]. -pub type SwitchEventStream = Pin> + Send>>; - -/// Decodes a switch-profile payload stream into typed [`SwitchEvent`]s. -/// -/// A payload that does not parse as a switch event is logged and skipped - -/// a malformed line from the gateway degrades one progress update, never -/// the switch - and the stream continues to its terminal event. A transport -/// failure passes through and ends the stream. -#[must_use] -pub fn switch_events(payloads: SsePayloadStream) -> SwitchEventStream { - Box::pin(payloads.filter_map(|item| async move { - match item { - Ok(payload) => match serde_json::from_str::(&payload) { - Ok(event) => Some(Ok(event)), - Err(error) => { - tracing::warn!(%error, payload, "skipping a malformed switch-profile event"); - None - } - }, - Err(error) => Some(Err(error)), - } - })) -} - -/// A gateway request failure. -#[derive(Debug, thiserror::Error)] -#[non_exhaustive] -pub enum GatewayError { - /// The HTTP client could not be built. - #[non_exhaustive] - #[error("build gateway http client")] - Build(#[source] Box), - - /// The request could not be sent or no response arrived (connect - /// refused, DNS, TLS, timeout). - #[non_exhaustive] - #[error("gateway transport error")] - Transport(#[source] Box), - - /// The response body could not be read to completion. - #[non_exhaustive] - #[error("read gateway response body")] - ReadBody(#[source] Box), -} - -/// Bearer-authenticated client for the gateway's OpenAI-compatible -/// endpoints. An empty API key sends no `Authorization` header at all, for -/// gateways running with authentication disabled. -#[derive(Clone)] -pub struct GatewayClient { - http: reqwest::Client, - pub(crate) base_url: String, - pub(crate) api_key: String, - /// Whole-request bound for buffered calls; header-phase bound for - /// streaming calls. - request_timeout: Duration, - /// Whole-request bound for the health probe. - health_timeout: Duration, -} - -// Manual so the bearer key is never written to logs. -impl std::fmt::Debug for GatewayClient { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("GatewayClient") - .field("base_url", &self.base_url) - .field("api_key", &"") - .finish_non_exhaustive() - } -} - -impl GatewayClient { - /// Builds a client for `base_url` authenticating with `api_key`. - /// - /// A trailing slash on `base_url` is trimmed so route joins stay clean. - /// An empty `api_key` disables authentication: requests then carry no - /// `Authorization` header. - /// - /// # Errors - /// Returns [`GatewayError::Build`] if the TLS backend cannot initialize. - pub fn new(base_url: &str, api_key: &str) -> Result { - let http = reqwest::Client::builder() - .connect_timeout(CONNECT_TIMEOUT) - .build() - .map_err(|source| GatewayError::Build(Box::new(source)))?; - Ok(Self { - http, - base_url: base_url.trim_end_matches('/').to_string(), - api_key: api_key.to_string(), - request_timeout: REQUEST_TIMEOUT, - health_timeout: HEALTH_PROBE_TIMEOUT, - }) - } - - /// Overrides the request and probe bounds, so tests can trip them - /// without waiting out the production values. - #[cfg(test)] - pub(crate) fn with_timeouts_for_test(mut self, request: Duration, health: Duration) -> Self { - self.request_timeout = request; - self.health_timeout = health; - self - } - - /// Sends `request`, bounding the wait for the response headers on the - /// client's request timeout. - /// - /// The streaming calls use this instead of a whole-request timeout: a - /// gateway that accepts the connection and then stalls must fail the - /// call rather than hang its caller, but an accepted stream may - /// legitimately run for minutes, so only the header phase is bounded - /// and the body stream stays open-ended. - async fn send_bounded( - &self, - request: reqwest::RequestBuilder, - ) -> Result { - match tokio::time::timeout(self.request_timeout, request.send()).await { - Ok(Ok(response)) => Ok(response), - Ok(Err(source)) => Err(GatewayError::Transport(Box::new(source))), - Err(elapsed) => Err(GatewayError::Transport(Box::new(elapsed))), - } - } - - /// Applies bearer authentication to `request`, unless the client was - /// built with an empty API key, in which case the request goes out with - /// no `Authorization` header. - fn authorize(&self, request: reqwest::RequestBuilder) -> reqwest::RequestBuilder { - if self.api_key.is_empty() { - request - } else { - request.bearer_auth(&self.api_key) - } - } - - /// The gateway's base URL as configured, trailing slash trimmed - - /// also the origin the config-panel iframe loads from. - #[must_use] - pub fn base_url(&self) -> &str { - &self.base_url - } - - /// Forwards one request to the gateway: `method` on - /// `path_and_query`, with an optional JSON `body`, authenticated - /// with the client's bearer key. Only the wait for the response - /// headers is bounded - a forwarded cache download or profile - /// switch legitimately streams for minutes - and the whole body is - /// buffered for relay. A non-success status is relayed in the - /// returned [`ForwardedResponse`], not reported as an error. - /// - /// # Errors - /// Returns [`GatewayError::Transport`] if the request cannot be - /// completed (the header bound elapsing included) and - /// [`GatewayError::ReadBody`] if the response body cannot be read. - pub(crate) async fn forward( - &self, - method: reqwest::Method, - path_and_query: &str, - body: Option>, - ) -> Result { - let mut request = self.authorize( - self.http - .request(method, format!("{}{}", self.base_url, path_and_query)), - ); - if let Some(bytes) = body { - request = request - .header(reqwest::header::CONTENT_TYPE, "application/json") - .body(bytes); - } - let response = self.send_bounded(request).await?; - let status = response.status(); - let content_type = response - .headers() - .get(reqwest::header::CONTENT_TYPE) - .and_then(|value| value.to_str().ok()) - .map(str::to_string); - let body = response - .bytes() - .await - .map_err(|source| GatewayError::ReadBody(Box::new(source)))? - .to_vec(); - Ok(ForwardedResponse { - status, - content_type, - body, - }) - } - - /// Probes the gateway's liveness endpoint, `GET /health`. - /// - /// Returns `true` only when the gateway answers with a success status: - /// a transport failure, a probe timeout, or a non-success answer all - /// read as unreachable. The request never carries the client's API key - /// (the endpoint is unauthenticated by design) and is capped at the - /// probe bound (`HEALTH_PROBE_TIMEOUT` by default). - pub async fn health(&self) -> bool { - let probe = self - .http - .get(format!("{}/health", self.base_url)) - .timeout(self.health_timeout); - match probe.send().await { - Ok(response) => response.status().is_success(), - Err(_) => false, - } - } - - /// Fetches the gateway's model catalog from `GET /v1/models`. - /// - /// A non-success status is relayed in the returned - /// [`GatewayResponse`], not reported as an error. - /// - /// # Errors - /// Returns [`GatewayError::Transport`] if the request cannot be - /// completed and [`GatewayError::ReadBody`] if the response body cannot - /// be read. - pub async fn list_models(&self) -> Result { - let response = self - .authorize(self.http.get(format!("{}/v1/models", self.base_url))) - .timeout(self.request_timeout) - .send() - .await - .map_err(|source| GatewayError::Transport(Box::new(source)))?; - read(response).await - } - - /// Fetches the gateway's profile list from `GET /admin/profiles`. - /// - /// A non-success status is relayed in the returned - /// [`GatewayResponse`], not reported as an error. - /// - /// # Errors - /// Returns [`GatewayError::Transport`] if the request cannot be - /// completed and [`GatewayError::ReadBody`] if the response body cannot - /// be read. - pub async fn list_profiles(&self) -> Result { - let response = self - .authorize(self.http.get(format!("{}/admin/profiles", self.base_url))) - .timeout(self.request_timeout) - .send() - .await - .map_err(|source| GatewayError::Transport(Box::new(source)))?; - read(response).await - } - - /// Fetches the gateway's live status from `GET /admin/status`, which - /// carries the active profile's name. - /// - /// A non-success status is relayed in the returned - /// [`GatewayResponse`], not reported as an error. - /// - /// # Errors - /// Returns [`GatewayError::Transport`] if the request cannot be - /// completed and [`GatewayError::ReadBody`] if the response body cannot - /// be read. - pub async fn profile_status(&self) -> Result { - let response = self - .authorize(self.http.get(format!("{}/admin/status", self.base_url))) - .timeout(self.request_timeout) - .send() - .await - .map_err(|source| GatewayError::Transport(Box::new(source)))?; - read(response).await - } - - /// Posts a profile switch to `POST /admin/switch-profile`. - /// - /// An accepted switch answers `text/event-stream` and returns - /// [`SwitchResponse::Switching`], whose payload stream carries stage - /// markers and then a terminal `ready` or `error` event (decode it with - /// [`switch_events`]). Only the wait for the response headers is - /// bounded: loading model weights into VRAM legitimately runs for - /// minutes and the stream reports progress the whole way, so the - /// stream itself carries no deadline. A non-success or non-streaming - /// answer is buffered and returned, not reported as an error. - /// - /// # Errors - /// Returns [`GatewayError::Transport`] if the request cannot be - /// completed (the header bound elapsing included) and - /// [`GatewayError::ReadBody`] if a buffered answer's body cannot be - /// read. - pub async fn switch_profile(&self, name: &str) -> Result { - let request = self - .authorize( - self.http - .post(format!("{}/admin/switch-profile", self.base_url)), - ) - .json(&serde_json::json!({ "name": name })); - let response = self.send_bounded(request).await?; - let status = response.status(); - if status.is_success() && is_event_stream(&response) { - return Ok(SwitchResponse::Switching { - status, - payloads: payload_stream(response), - }); - } - read(response).await.map(SwitchResponse::Buffered) - } - - /// Posts a cache-ensure request to `POST /v1/cache`, asking the gateway - /// to make the blob at `source` available locally. - /// - /// A cache hit answers a buffered JSON `ready` event - /// ([`CacheResponse::Buffered`] on a success status); a miss answers - /// `text/event-stream` and returns [`CacheResponse::Download`], whose - /// payload stream ends in a terminal `ready` or `error` event. Only - /// the wait for the response headers is bounded; a download stream - /// itself carries no deadline. A non-success status is buffered and - /// returned, not reported as an error. - /// - /// # Errors - /// Returns [`GatewayError::Transport`] if the request cannot be - /// completed (the header bound elapsing included) and - /// [`GatewayError::ReadBody`] if a buffered answer's body cannot be - /// read. - pub async fn cache_ensure(&self, source: &str) -> Result { - let request = self - .authorize(self.http.post(format!("{}/v1/cache", self.base_url))) - .json(&serde_json::json!({ "source": source })); - let response = self.send_bounded(request).await?; - let status = response.status(); - if status.is_success() && is_event_stream(&response) { - return Ok(CacheResponse::Download { - status, - payloads: payload_stream(response), - }); - } - read(response).await.map(CacheResponse::Buffered) - } -} - -/// Whether the gateway answered with an SSE body. -fn is_event_stream(response: &reqwest::Response) -> bool { - response - .headers() - .get(reqwest::header::CONTENT_TYPE) - .and_then(|value| value.to_str().ok()) - .is_some_and(|value| value.starts_with("text/event-stream")) -} - -/// Captures the status and raw body of a gateway response. -async fn read(response: reqwest::Response) -> Result { - let status = response.status(); - let body = response - .bytes() - .await - .map_err(|source| GatewayError::ReadBody(Box::new(source)))? - .to_vec(); - Ok(GatewayResponse { status, body }) -} - -/// Decodes a gateway SSE response body into its `data:` payload stream. -/// -/// Byte chunks arrive on arbitrary TCP boundaries, so the decoder buffers -/// partial lines; a mid-stream transport failure surfaces as one error item -/// that ends the stream. -fn payload_stream(response: reqwest::Response) -> SsePayloadStream { - let state = (response.bytes_stream(), SseDecoder::default(), false); - let payloads = stream::try_unfold(state, |(mut bytes, mut decoder, mut eof)| async move { - loop { - if let Some(payload) = decoder.pop() { - return Ok(Some((payload, (bytes, decoder, eof)))); - } - if eof { - return Ok(None); - } - match bytes.next().await { - Some(Ok(chunk)) => decoder.feed(&chunk), - Some(Err(source)) => return Err(GatewayError::ReadBody(Box::new(source))), - None => { - decoder.finish(); - eof = true; - } - } - } - }); - Box::pin(payloads) -} - -/// Incremental SSE decoder: turns arbitrary byte chunks into `data:` -/// payloads, one per event, in arrival order. -/// -/// Only `data:` fields are collected; `event:`, `id:`, `retry:`, and -/// comments are dropped, matching what an OpenAI-compatible stream carries. -/// Multiple `data:` lines in one event are joined with `\n` per the SSE -/// specification. -#[derive(Debug, Default)] -struct SseDecoder { - /// Bytes received but not yet terminated by `\n`. - partial: Vec, - /// Joined `data:` lines of the event currently being accumulated. - data: String, - /// Whether the current event carries at least one `data:` line. - has_data: bool, - /// Completed payloads awaiting pickup. - out: VecDeque, -} - -impl SseDecoder { - /// Feeds one byte chunk, completing every event it terminates. - fn feed(&mut self, chunk: &[u8]) { - self.partial.extend_from_slice(chunk); - while let Some(end) = self.partial.iter().position(|&b| b == b'\n') { - let line: Vec = self.partial.drain(..=end).collect(); - self.line(&line[..line.len() - 1]); - } - } - - /// Flushes a trailing unterminated line and any pending event at EOF. - fn finish(&mut self) { - if !self.partial.is_empty() { - let line = std::mem::take(&mut self.partial); - self.line(&line); - } - self.dispatch(); - } - - /// Takes the oldest completed payload, if any. - fn pop(&mut self) -> Option { - self.out.pop_front() - } - - /// Handles one line without its `\n`; a blank line ends the event. - fn line(&mut self, raw: &[u8]) { - let line = raw.strip_suffix(b"\r").unwrap_or(raw); - if line.is_empty() { - self.dispatch(); - return; - } - if let Some(value) = line.strip_prefix(b"data:") { - let value = value.strip_prefix(b" ").unwrap_or(value); - if self.has_data { - self.data.push('\n'); - } - self.data.push_str(&String::from_utf8_lossy(value)); - self.has_data = true; - } - } - - /// Queues the accumulated event, dropping events with no `data:` line. - fn dispatch(&mut self) { - if self.has_data { - self.out.push_back(std::mem::take(&mut self.data)); - self.has_data = false; - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - use axum::response::IntoResponse; - - use workshop_support::xorshift; - #[test] - fn trailing_slash_is_trimmed_from_base_url() { - let client = GatewayClient::new("http://127.0.0.1:8081/", "k").expect("client builds"); - assert_eq!(client.base_url, "http://127.0.0.1:8081"); - } - - #[test] - fn debug_redacts_the_api_key() { - let client = GatewayClient::new("http://127.0.0.1:8081", "secret-key").expect("client"); - let rendered = format!("{client:?}"); - assert!(!rendered.contains("secret-key"), "key leaked: {rendered}"); - } - - fn drain(decoder: &mut SseDecoder) -> Vec { - let mut payloads = Vec::new(); - while let Some(payload) = decoder.pop() { - payloads.push(payload); - } - payloads - } - - #[test] - fn decoder_emits_the_same_events_regardless_of_chunk_boundaries() { - let wire = "data: {\"a\":1}\n\ndata: [DONE]\n\n"; - let mut whole = SseDecoder::default(); - whole.feed(wire.as_bytes()); - whole.finish(); - - let mut drip = SseDecoder::default(); - for byte in wire.as_bytes() { - drip.feed(std::slice::from_ref(byte)); - } - drip.finish(); - - let whole_out = drain(&mut whole); - assert_eq!(drain(&mut drip), whole_out, "chunking must not matter"); - assert_eq!( - whole_out, - ["{\"a\":1}".to_string(), "[DONE]".to_string()], - "payloads arrive verbatim, including the terminal sentinel" - ); - } - - #[test] - fn decoder_joins_multi_line_data_and_ignores_other_fields() { - let wire = ": comment\nevent: message\nid: 7\ndata: first\ndata: second\n\n"; - let mut decoder = SseDecoder::default(); - decoder.feed(wire.as_bytes()); - decoder.finish(); - assert_eq!(decoder.pop().as_deref(), Some("first\nsecond")); - assert!(decoder.pop().is_none(), "one event, one payload"); - } - - #[test] - fn decoder_accepts_crlf_line_endings() { - let mut decoder = SseDecoder::default(); - decoder.feed(b"data: one\r\n\r\n"); - decoder.finish(); - assert_eq!(decoder.pop().as_deref(), Some("one")); - } - - #[test] - fn decoder_flushes_an_unterminated_final_line_at_eof() { - let mut decoder = SseDecoder::default(); - decoder.feed(b"data: tail"); - decoder.finish(); - assert_eq!(decoder.pop().as_deref(), Some("tail")); - } - - #[test] - fn decoder_drops_events_without_data() { - let mut decoder = SseDecoder::default(); - decoder.feed(b"event: ping\n\ndata: kept\n\n"); - decoder.finish(); - assert_eq!(decoder.pop().as_deref(), Some("kept")); - assert!(decoder.pop().is_none()); - } - - /// A realistic delta stream whose payloads carry multi-byte UTF-8 - /// (2-, 3-, and 4-byte codepoints), so a byte split can land inside a - /// codepoint; mixed CRLF/LF endings and a multi-line event ride along. - const MULTIBYTE_WIRE: &str = concat!( - "data: {\"choices\":[{\"delta\":{\"reasoning_content\":\"h\u{e9}llo \u{1f914} w\u{f6}rld\"}}]}\r\n\r\n", - "data: {\"choices\":[{\"delta\":{\"content\":\"\u{65e5}\u{672c}\u{8a9e}\"}}]}\n\n", - "data: first\ndata: \u{1f40d} second\n\n", - "data: [DONE]\n\n", - ); - - /// The payloads [`MULTIBYTE_WIRE`] must always decode to, regardless - /// of how the bytes are chunked. - fn multibyte_payloads() -> Vec { - vec![ - "{\"choices\":[{\"delta\":{\"reasoning_content\":\"h\u{e9}llo \u{1f914} w\u{f6}rld\"}}]}".to_string(), - "{\"choices\":[{\"delta\":{\"content\":\"\u{65e5}\u{672c}\u{8a9e}\"}}]}".to_string(), - "first\n\u{1f40d} second".to_string(), - "[DONE]".to_string(), - ] - } - - /// Feeds `wire` split at the ascending byte offsets in `cuts`, then - /// returns everything the decoder produced. - fn decode_in_fragments(wire: &[u8], cuts: &[usize]) -> Vec { - let mut decoder = SseDecoder::default(); - let mut start = 0; - for &cut in cuts { - decoder.feed(&wire[start..cut]); - start = cut; - } - decoder.feed(&wire[start..]); - decoder.finish(); - drain(&mut decoder) - } - - #[test] - fn decoder_survives_every_split_point_including_mid_utf8() { - let wire = MULTIBYTE_WIRE.as_bytes(); - let expected = multibyte_payloads(); - assert_eq!(decode_in_fragments(wire, &[]), expected, "unsplit feed"); - // Every split point, so every mid-codepoint boundary is exercised. - for cut in 1..wire.len() { - assert_eq!( - decode_in_fragments(wire, &[cut]), - expected, - "split at byte {cut} changed the decode" - ); - } - } - - #[test] - fn decoder_is_chunking_invariant_under_random_splits() { - let wire = MULTIBYTE_WIRE.as_bytes(); - let expected = multibyte_payloads(); - for seed in [0x9E37_79B9_7F4A_7C15_u64, 42, 7_777_777] { - let mut state = seed; - let cuts: Vec = (1..wire.len()) - .filter(|_| xorshift(&mut state).is_multiple_of(4)) - .collect(); - assert_eq!( - decode_in_fragments(wire, &cuts), - expected, - "seed {seed}: fragmenting at {cuts:?} changed the decode" - ); - } - } - - #[test] - fn cache_event_decodes_each_wire_shape() { - let downloading: CacheEvent = - serde_json::from_str(r#"{"status":"downloading","bytes":5,"total":10}"#) - .expect("downloading decodes"); - assert_eq!( - downloading, - CacheEvent::Downloading { - bytes: 5, - total: Some(10) - } - ); - let unknown_total: CacheEvent = - serde_json::from_str(r#"{"status":"downloading","bytes":5,"total":null}"#) - .expect("a null total decodes"); - assert_eq!( - unknown_total, - CacheEvent::Downloading { - bytes: 5, - total: None - } - ); - let ready: CacheEvent = - serde_json::from_str(r#"{"status":"ready","path":"/cache/ggml.bin"}"#) - .expect("ready decodes"); - assert_eq!( - ready, - CacheEvent::Ready { - path: PathBuf::from("/cache/ggml.bin") - } - ); - let error: CacheEvent = - serde_json::from_str(r#"{"status":"error","message":"boom"}"#).expect("error decodes"); - assert_eq!( - error, - CacheEvent::Error { - message: "boom".to_string() - } - ); - } - - /// Mock cache route state: the last request's auth header and body, - /// captured so tests can assert what the client sent. - #[derive(Clone, Default)] - struct CacheProbe { - authorized: std::sync::Arc, - sources: std::sync::Arc>>, - } - - impl CacheProbe { - fn sources(&self) -> Vec { - self.sources - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .clone() - } - } - - /// Binds `app` on a free loopback port and returns its base URL. - async fn serve(app: axum::Router) -> String { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .expect("bind mock gateway"); - let addr = listener.local_addr().expect("mock gateway address"); - tokio::spawn(async move { - axum::serve(listener, app) - .await - .expect("mock gateway serves"); - }); - format!("http://{addr}") - } - - /// Binds a stub that completes TCP handshakes and then never answers, - /// modeling a gateway that is up but wedged. - async fn spawn_stalled_gateway() -> String { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .expect("bind stalled stub"); - let addr = listener.local_addr().expect("stalled stub address"); - tokio::spawn(async move { - // Sockets are held open and never answered until the test's - // runtime tears the task down. - let mut held = Vec::new(); - while let Ok((socket, _)) = listener.accept().await { - held.push(socket); - } - }); - format!("http://{addr}") - } - - /// A client against `base_url` whose bounds are tight enough to trip - /// inside a test. - fn impatient_client(base_url: &str) -> GatewayClient { - GatewayClient::new(base_url, "") - .expect("client builds in tests") - .with_timeouts_for_test(Duration::from_millis(100), Duration::from_millis(100)) - } - - #[tokio::test] - async fn a_stalled_gateway_trips_the_request_timeout() { - let base_url = spawn_stalled_gateway().await; - let error = impatient_client(&base_url) - .list_models() - .await - .expect_err("a gateway that never answers must trip the request timeout"); - assert!( - matches!(error, GatewayError::Transport(_)), - "expected Transport, got {error:?}" - ); - } - - #[tokio::test] - async fn a_stalled_gateway_trips_the_stream_header_bound() { - let base_url = spawn_stalled_gateway().await; - let error = impatient_client(&base_url) - .switch_profile("beta") - .await - .expect_err("a gateway that never sends headers must trip the header bound"); - assert!( - matches!(error, GatewayError::Transport(_)), - "expected Transport, got {error:?}" - ); - } - - #[tokio::test] - async fn a_stalled_gateway_probe_reads_unreachable() { - let base_url = spawn_stalled_gateway().await; - assert!( - !impatient_client(&base_url).health().await, - "a gateway that accepts but never answers must read unreachable" - ); - } - - #[tokio::test] - async fn a_cache_hit_answers_a_buffered_ready_event() { - let probe = CacheProbe::default(); - let seen = probe.clone(); - let app = axum::Router::new().route( - "/v1/cache", - axum::routing::post( - move |headers: axum::http::HeaderMap, body: axum::Json| { - let seen = seen.clone(); - async move { - seen.authorized.store( - headers - .get(axum::http::header::AUTHORIZATION) - .and_then(|value| value.to_str().ok()) - == Some("Bearer test-key"), - std::sync::atomic::Ordering::Relaxed, - ); - seen.sources - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .push( - body["source"] - .as_str() - .expect("source is a string") - .to_string(), - ); - axum::Json(serde_json::json!({ - "path": "/cache/ggml-large-v3-turbo.bin", - "status": "ready", - })) - .into_response() - } - }, - ), - ); - let base_url = serve(app).await; - let client = GatewayClient::new(&base_url, "test-key").expect("client builds in tests"); - let response = client - .cache_ensure("https://example.com/models/ggml-large-v3-turbo.bin") - .await - .expect("the request completes"); - let CacheResponse::Buffered(answer) = response else { - panic!("a cache hit is buffered, got {response:?}"); - }; - assert!(answer.status.is_success()); - let event: CacheEvent = - serde_json::from_slice(&answer.body).expect("the hit body is a ready event"); - assert_eq!( - event, - CacheEvent::Ready { - path: PathBuf::from("/cache/ggml-large-v3-turbo.bin") - } - ); - assert!(probe.authorized.load(std::sync::atomic::Ordering::Relaxed)); - assert_eq!( - probe.sources(), - ["https://example.com/models/ggml-large-v3-turbo.bin"] - ); - } - - #[tokio::test] - async fn a_cache_miss_answers_a_download_stream() { - let app = axum::Router::new().route( - "/v1/cache", - axum::routing::post(|| async { - ( - [(axum::http::header::CONTENT_TYPE, "text/event-stream")], - concat!( - "data: {\"status\":\"downloading\",\"bytes\":5,\"total\":null}\n\n", - "data: {\"status\":\"downloading\",\"bytes\":10,\"total\":12}\n\n", - "data: {\"status\":\"ready\",\"path\":\"/cache/ggml.bin\"}\n\n", - ), - ) - }), - ); - let base_url = serve(app).await; - let client = GatewayClient::new(&base_url, "").expect("client builds in tests"); - let response = client - .cache_ensure("https://example.com/models/ggml.bin") - .await - .expect("the request completes"); - let CacheResponse::Download { mut payloads, .. } = response else { - panic!("a cache miss streams, got {response:?}"); - }; - let mut events = Vec::new(); - while let Some(item) = payloads.next().await { - let payload = item.expect("the stream is clean"); - events.push( - serde_json::from_str::(&payload) - .expect("each payload is a cache event"), - ); - } - assert_eq!( - events, - [ - CacheEvent::Downloading { - bytes: 5, - total: None - }, - CacheEvent::Downloading { - bytes: 10, - total: Some(12) - }, - CacheEvent::Ready { - path: PathBuf::from("/cache/ggml.bin") - }, - ], - "the stream carries progress samples then the terminal ready" - ); - } - - #[test] - fn switch_event_decodes_each_wire_shape() { - let stage: SwitchEvent = - serde_json::from_str(r#"{"stage":"stopping-models"}"#).expect("a stage marker decodes"); - assert_eq!( - stage, - SwitchEvent::Stage { - stage: "stopping-models".to_string() - } - ); - let ready: SwitchEvent = - serde_json::from_str(r#"{"status":"ready","profile":"beta"}"#).expect("ready decodes"); - assert_eq!( - ready, - SwitchEvent::Ready { - profile: "beta".to_string() - } - ); - let error: SwitchEvent = - serde_json::from_str(r#"{"status":"error","message":"boom"}"#).expect("error decodes"); - assert_eq!( - error, - SwitchEvent::Error { - message: "boom".to_string() - } - ); - } - - /// Collects the typed events of a switch stream, panicking on a - /// transport error item. - async fn collect_switch_events(payloads: SsePayloadStream) -> Vec { - let mut events = Vec::new(); - let mut typed = switch_events(payloads); - while let Some(item) = typed.next().await { - events.push(item.expect("the stream is clean")); - } - events - } - - #[tokio::test] - async fn an_accepted_switch_streams_stages_then_the_terminal_event() { - let app = axum::Router::new().route( - "/admin/switch-profile", - axum::routing::post(|| async { - ( - [(axum::http::header::CONTENT_TYPE, "text/event-stream")], - concat!( - "data: {\"stage\":\"loading-profile\"}\n\n", - "data: {\"stage\":\"stopping-models\"}\n\n", - "data: {\"stage\":\"starting-models\"}\n\n", - "data: {\"status\":\"ready\",\"profile\":\"beta\"}\n\n", - ), - ) - }), - ); - let base_url = serve(app).await; - let client = GatewayClient::new(&base_url, "").expect("client builds in tests"); - let response = client - .switch_profile("beta") - .await - .expect("the request completes"); - let SwitchResponse::Switching { payloads, .. } = response else { - panic!("an accepted switch streams, got {response:?}"); - }; - assert_eq!( - collect_switch_events(payloads).await, - [ - SwitchEvent::Stage { - stage: "loading-profile".to_string() - }, - SwitchEvent::Stage { - stage: "stopping-models".to_string() - }, - SwitchEvent::Stage { - stage: "starting-models".to_string() - }, - SwitchEvent::Ready { - profile: "beta".to_string() - }, - ], - "the stream carries stage markers in order then the terminal ready" - ); - } - - #[tokio::test] - async fn a_malformed_switch_event_is_skipped_and_the_stream_continues() { - let app = axum::Router::new().route( - "/admin/switch-profile", - axum::routing::post(|| async { - ( - [(axum::http::header::CONTENT_TYPE, "text/event-stream")], - concat!( - "data: {\"stage\":\"loading-profile\"}\n\n", - "data: this is not json\n\n", - "data: {\"unrelated\":true}\n\n", - "data: {\"status\":\"error\",\"message\":\"start-local failed\"}\n\n", - ), - ) - }), - ); - let base_url = serve(app).await; - let client = GatewayClient::new(&base_url, "").expect("client builds in tests"); - let response = client - .switch_profile("beta") - .await - .expect("the request completes"); - let SwitchResponse::Switching { payloads, .. } = response else { - panic!("an accepted switch streams, got {response:?}"); - }; - assert_eq!( - collect_switch_events(payloads).await, - [ - SwitchEvent::Stage { - stage: "loading-profile".to_string() - }, - SwitchEvent::Error { - message: "start-local failed".to_string() - }, - ], - "malformed payloads are skipped; the terminal event still arrives" - ); - } - - #[tokio::test] - async fn a_declined_switch_is_buffered_not_an_error() { - let app = axum::Router::new().route( - "/admin/switch-profile", - axum::routing::post(|| async { - ( - axum::http::StatusCode::BAD_REQUEST, - axum::Json(serde_json::json!({ - "error": {"message": "bad name", "code": "switch_failed"} - })), - ) - }), - ); - let base_url = serve(app).await; - let client = GatewayClient::new(&base_url, "").expect("client builds in tests"); - let response = client - .switch_profile("../escape") - .await - .expect("a declined request still completes"); - let SwitchResponse::Buffered(answer) = response else { - panic!("a declined switch is buffered, got {response:?}"); - }; - assert_eq!(answer.status, reqwest::StatusCode::BAD_REQUEST); - } - - #[tokio::test] - async fn a_declined_cache_request_is_buffered_not_an_error() { - let app = axum::Router::new().route( - "/v1/cache", - axum::routing::post(|| async { - ( - axum::http::StatusCode::BAD_REQUEST, - axum::Json(serde_json::json!({ - "error": {"message": "bad source", "code": "malformed_request"} - })), - ) - }), - ); - let base_url = serve(app).await; - let client = GatewayClient::new(&base_url, "").expect("client builds in tests"); - let response = client - .cache_ensure("not-a-url") - .await - .expect("a declined request still completes"); - let CacheResponse::Buffered(answer) = response else { - panic!("a declined request is buffered, got {response:?}"); - }; - assert_eq!(answer.status, reqwest::StatusCode::BAD_REQUEST); - } -} diff --git a/crates/workshop-server/src/gateway_progress.rs b/crates/workshop-server/src/gateway_progress.rs deleted file mode 100644 index a303352d4..000000000 --- a/crates/workshop-server/src/gateway_progress.rs +++ /dev/null @@ -1,496 +0,0 @@ -//! The gateway progress subscriber: a background task that imports the -//! gateway's `GET /admin/progress` event stream into the workshop -//! [`ProgressHub`] as a [`RemoteOperation`], so gateway-side work (model -//! downloads, profile switches) renders on the status bar through the same -//! renderer task as local operations. -//! -//! The task follows the heartbeat's lifecycle posture: spawned with the -//! server, stopped through its [`Subscriber`] handle inside the same -//! graceful-shutdown signal, and driven by the shared [`GatewayHealth`] -//! verdict rather than by probes of its own. It subscribes while the -//! gateway reads reachable and idles while it does not; a reconnect -//! resubscribes, and each subscription tracks one import per upstream -//! operation id, so interleaved work stays separate and a finished operation -//! detaches without closing the long-lived event stream. -//! When the subscription drops - a lost connection or an unreachable -//! verdict - the import detaches with it, because progress from a gateway -//! the workshop can no longer hear is stale, not informative. - -use std::collections::HashMap; -use std::sync::Arc; -use std::time::Duration; - -use futures_util::StreamExt; -use tokio::sync::oneshot; - -use promptforge_model_client::model::subscribe_progress; -use shared_progress::{EventState, OperationId, ProgressHub, RemoteOperation}; - -use crate::gateway_binding::GatewayBinding; -use crate::heartbeat::GatewayHealth; - -/// How long a resubscribe waits when the stream ended while the gateway -/// still reads reachable, so an endpoint that accepts and immediately -/// closes cannot spin the loop. A reachability flip restarts at once; -/// matched to the heartbeat's probe cadence. -const RESUBSCRIBE_DELAY: Duration = Duration::from_secs(5); - -/// A running subscriber task. -/// -/// [`Subscriber::shutdown`] signals the task to stop and awaits it. -/// Dropping the handle without shutting down still stops the task at its -/// next select point, because the closed channel resolves the stop branch. -#[derive(Debug)] -pub(crate) struct Subscriber { - stop: Option>, - task: Option>, -} - -impl Subscriber { - /// Signals the subscriber to stop and waits for its task to finish. - pub(crate) async fn shutdown(mut self) { - if let Some(stop) = self.stop.take() { - let _ = stop.send(()); - } - if let Some(task) = self.task.take() { - let _ = task.await; - } - } -} - -/// Spawns the subscriber task against the gateway at `base_url`, -/// importing its progress events into `hub` while `health` reads -/// reachable. -#[must_use] -pub(crate) fn spawn( - gateway: GatewayBinding, - hub: Arc, - health: GatewayHealth, -) -> Subscriber { - spawn_with_delay(gateway, hub, health, RESUBSCRIBE_DELAY) -} - -/// [`spawn`] with the resubscribe delay injected, so tests can shorten it. -fn spawn_with_delay( - gateway: GatewayBinding, - hub: Arc, - health: GatewayHealth, - resubscribe_delay: Duration, -) -> Subscriber { - let (stop, mut stopped) = oneshot::channel(); - let task = tokio::spawn(async move { - run(&gateway, &hub, &health, resubscribe_delay, &mut stopped).await; - }); - Subscriber { - stop: Some(stop), - task: Some(task), - } -} - -/// The subscription loop: idle while the gateway is unreachable, and while -/// reachable hold one subscription whose events drive operation-id-keyed -/// [`RemoteOperation`] imports. An operation-level terminal event -/// detaches that import while the subscription remains open. The stop -/// signal wins every select, so shutdown never waits out a stream read, a -/// connect, or a resubscribe delay. -async fn run( - gateway: &GatewayBinding, - hub: &Arc, - health: &GatewayHealth, - resubscribe_delay: Duration, - stop: &mut oneshot::Receiver<()>, -) { - let mut reachable = health.subscribe(); - let mut gateway_changed = gateway.subscribe(); - 'reconnect: loop { - while !*reachable.borrow_and_update() { - tokio::select! { - _ = &mut *stop => return, - changed = gateway_changed.changed() => { - if changed.is_err() { - return; - } - } - changed = reachable.changed() => { - // The sender lives in AppState for the process - // lifetime, so a closed watch means shutdown. - if changed.is_err() { - return; - } - } - } - } - let snapshot = gateway.snapshot(); - let stream = tokio::select! { - _ = &mut *stop => return, - _ = reachable.changed() => continue, - changed = gateway_changed.changed() => { - if changed.is_err() { - return; - } - continue; - } - result = subscribe_progress(snapshot.base_url(), snapshot.api_key()) => match result { - Ok(stream) => stream, - Err(error) => { - tracing::warn!(%error, "gateway progress subscription failed"); - tokio::select! { - _ = &mut *stop => return, - _ = reachable.changed() => {} - changed = gateway_changed.changed() => { - if changed.is_err() { - return; - } - } - () = tokio::time::sleep(resubscribe_delay) => {} - } - continue; - } - }, - }; - let mut remotes: HashMap = HashMap::new(); - tokio::pin!(stream); - loop { - tokio::select! { - _ = &mut *stop => return, - _ = reachable.changed() => break, - changed = gateway_changed.changed() => { - if changed.is_err() { - return; - } - continue 'reconnect; - } - item = stream.next() => match item { - Some(Ok(event)) => { - let operation = event.operation; - if matches!(event.state, EventState::OperationFinished) { - remotes.remove(&operation); - continue; - } - remotes - .entry(operation) - .or_insert_with(|| RemoteOperation::attach(hub)) - .apply(&event); - } - // One malformed event or a terminal read failure; the - // stream itself decides which by continuing or ending. - Some(Err(error)) => { - tracing::warn!(%error, "gateway progress event skipped"); - } - None => break, - } - } - } - drop(remotes); - if *reachable.borrow_and_update() { - tokio::select! { - _ = &mut *stop => return, - _ = reachable.changed() => {} - changed = gateway_changed.changed() => { - if changed.is_err() { - return; - } - } - () = tokio::time::sleep(resubscribe_delay) => {} - } - } - } -} - -#[cfg(test)] -mod tests { - // Fractions are fixed-point millionths, so equality comparisons are exact - // (the shared-progress remote.rs test precedent). - #![expect(clippy::float_cmp, reason = "fixed-point fractions compare exactly")] - - use super::*; - - use std::sync::atomic::{AtomicUsize, Ordering}; - - use axum::extract::State; - use axum::response::{IntoResponse, Response}; - use tokio::sync::broadcast; - - use shared_progress::OperationSnapshot; - - use crate::app::fixtures::spawn_gateway; - - /// A replaceable binding for one mock Gateway. - fn binding(base_url: &str) -> GatewayBinding { - GatewayBinding::new(base_url, "").expect("the test binding builds") - } - - /// A mock `GET /admin/progress`: every payload published to the feed - /// streams to every connected subscriber as an SSE `data:` frame, and - /// `connections` counts how often the endpoint was hit. The receiver - /// is created before the count increments, so a test that observes a - /// connection can publish without losing the frame. [`close`](Self::close) - /// ends every live stream, so a test can drive the resubscribe path. - struct MockProgress { - connections: AtomicUsize, - feeds: std::sync::Mutex>, - } - - impl MockProgress { - fn new() -> Self { - Self { - connections: AtomicUsize::new(0), - feeds: std::sync::Mutex::new(broadcast::channel(16).0), - } - } - - fn router(self: Arc) -> axum::Router { - axum::Router::new() - .route("/admin/progress", axum::routing::get(serve_feed)) - .with_state(self) - } - - /// Publishes one payload to every connected subscriber. - fn send(&self, payload: String) { - self.feeds - .lock() - .expect("the feed lock is not poisoned") - .send(payload) - .expect("the mock has a subscriber"); - } - - /// Ends every live stream; later connections subscribe to the - /// fresh feed. - fn close(&self) { - *self.feeds.lock().expect("the feed lock is not poisoned") = broadcast::channel(16).0; - } - } - - async fn serve_feed(State(mock): State>) -> Response { - let rx = mock - .feeds - .lock() - .expect("the feed lock is not poisoned") - .subscribe(); - mock.connections.fetch_add(1, Ordering::Relaxed); - let stream = futures_util::stream::unfold(rx, |mut rx| async move { - loop { - match rx.recv().await { - Ok(payload) => { - return Some(( - Ok::<_, std::convert::Infallible>(format!("data: {payload}\n\n")), - rx, - )); - } - Err(broadcast::error::RecvError::Lagged(_)) => {} - Err(broadcast::error::RecvError::Closed) => return None, - } - } - }); - ( - [(axum::http::header::CONTENT_TYPE, "text/event-stream")], - axum::body::Body::from_stream(stream), - ) - .into_response() - } - - /// Serializes a wire-format progress event by hand, so the tests pin - /// the JSON shape the gateway emits rather than the progress crate's - /// constructors (the gateway-client test pattern). - fn event_json(path: &str, state: &serde_json::Value) -> String { - serde_json::json!({ - "operation": 7, - "path": path, - "label": path, - "state": state, - }) - .to_string() - } - - /// Polls the hub's snapshot until `accept` holds, within a generous - /// deadline (the heartbeat tests' snapshot_where pattern). - async fn snapshot_where( - hub: &ProgressHub, - accept: impl Fn(&[OperationSnapshot]) -> bool, - ) -> Vec { - tokio::time::timeout(Duration::from_secs(5), async { - loop { - let snapshot = hub.snapshot(); - if accept(&snapshot) { - return snapshot; - } - tokio::time::sleep(Duration::from_millis(5)).await; - } - }) - .await - .expect("a matching snapshot arrives within the deadline") - } - - /// Polls the mock's connection count until it reaches `n`. - async fn wait_for_connections(mock: &MockProgress, n: usize) { - tokio::time::timeout(Duration::from_secs(5), async { - while mock.connections.load(Ordering::Relaxed) < n { - tokio::time::sleep(Duration::from_millis(5)).await; - } - }) - .await - .expect("the subscriber connects within the deadline"); - } - - #[tokio::test] - async fn events_from_the_gateway_feed_a_remote_operation_on_the_hub() { - let mock = Arc::new(MockProgress::new()); - let base_url = spawn_gateway(Arc::clone(&mock).router()).await; - let hub = Arc::new(ProgressHub::new()); - // The flag starts optimistic, so the subscriber connects at once. - let subscriber = spawn(binding(&base_url), Arc::clone(&hub), GatewayHealth::new()); - - wait_for_connections(&mock, 1).await; - mock.send(event_json( - "download", - &serde_json::json!({"Begun": {"weight": 1.0}}), - )); - mock.send(event_json( - "download", - &serde_json::json!({"Updated": {"fraction": 0.5}}), - )); - - let snapshot = snapshot_where(&hub, |s| { - s.len() == 1 && s[0].nodes.iter().any(|n| n.fraction == 0.5) - }) - .await; - assert_eq!(snapshot[0].nodes[0].path, "download"); - assert_eq!(snapshot[0].nodes[0].label, "download"); - subscriber.shutdown().await; - } - - #[tokio::test] - async fn a_malformed_event_is_skipped_and_the_stream_continues() { - let mock = Arc::new(MockProgress::new()); - let base_url = spawn_gateway(Arc::clone(&mock).router()).await; - let hub = Arc::new(ProgressHub::new()); - let subscriber = spawn(binding(&base_url), Arc::clone(&hub), GatewayHealth::new()); - - wait_for_connections(&mock, 1).await; - mock.send(event_json( - "download", - &serde_json::json!({"Begun": {"weight": 1.0}}), - )); - // One undecodable `data:` block between two valid events: the - // subscriber warns and continues rather than dropping the stream. - mock.send("{not valid json".to_owned()); - mock.send(event_json( - "download", - &serde_json::json!({"Updated": {"fraction": 0.5}}), - )); - - let snapshot = snapshot_where(&hub, |s| { - s.len() == 1 && s[0].nodes.iter().any(|n| n.fraction == 0.5) - }) - .await; - assert_eq!( - snapshot[0].nodes[0].path, "download", - "the event after the malformed one still lands on the hub" - ); - subscriber.shutdown().await; - } - - #[tokio::test] - async fn a_stream_that_ends_while_reachable_resubscribes_after_the_delay() { - let mock = Arc::new(MockProgress::new()); - let base_url = spawn_gateway(Arc::clone(&mock).router()).await; - let hub = Arc::new(ProgressHub::new()); - let delay = Duration::from_millis(50); - let subscriber = spawn_with_delay( - binding(&base_url), - Arc::clone(&hub), - GatewayHealth::new(), - delay, - ); - - wait_for_connections(&mock, 1).await; - mock.send(event_json( - "download", - &serde_json::json!({"Begun": {"weight": 1.0}}), - )); - snapshot_where(&hub, |s| s.len() == 1).await; - - // The stream ends while the gateway still reads reachable: the - // import detaches, and a fresh subscription follows the delay. - let closed = std::time::Instant::now(); - mock.close(); - snapshot_where(&hub, <[OperationSnapshot]>::is_empty).await; - wait_for_connections(&mock, 2).await; - assert!( - closed.elapsed() >= delay, - "the resubscribe waits out the delay rather than spinning" - ); - subscriber.shutdown().await; - } - - #[tokio::test] - async fn an_unreachable_gateway_holds_no_subscription_and_no_remote_state() { - let mock = Arc::new(MockProgress::new()); - let base_url = spawn_gateway(Arc::clone(&mock).router()).await; - let hub = Arc::new(ProgressHub::new()); - let health = GatewayHealth::new(); - health.publish(false); - let subscriber = spawn(binding(&base_url), Arc::clone(&hub), health.clone()); - - let quiet = tokio::time::timeout(Duration::from_millis(200), async { - wait_for_connections(&mock, 1).await; - }) - .await; - assert!( - quiet.is_err(), - "an unreachable gateway must not be subscribed" - ); - assert!(hub.snapshot().is_empty()); - - health.publish(true); - wait_for_connections(&mock, 1).await; - subscriber.shutdown().await; - } - - #[tokio::test] - async fn a_reconnect_resubscribes_without_duplicating_state() { - let mock = Arc::new(MockProgress::new()); - let base_url = spawn_gateway(Arc::clone(&mock).router()).await; - let hub = Arc::new(ProgressHub::new()); - let health = GatewayHealth::new(); - let subscriber = spawn(binding(&base_url), Arc::clone(&hub), health.clone()); - - wait_for_connections(&mock, 1).await; - mock.send(event_json( - "download", - &serde_json::json!({"Begun": {"weight": 1.0}}), - )); - let first = snapshot_where(&hub, |s| s.len() == 1).await; - - health.publish(false); - snapshot_where(&hub, <[OperationSnapshot]>::is_empty).await; - - health.publish(true); - wait_for_connections(&mock, 2).await; - mock.send(event_json( - "download", - &serde_json::json!({"Begun": {"weight": 1.0}}), - )); - mock.send(event_json( - "download", - &serde_json::json!({"Updated": {"fraction": 0.5}}), - )); - let reconnected = snapshot_where(&hub, |s| { - s.len() == 1 && s[0].nodes.iter().any(|n| n.fraction == 0.5) - }) - .await; - assert_eq!( - reconnected.len(), - 1, - "the reconnect replaces the import, never stacks a second one" - ); - assert_ne!( - first[0].operation, reconnected[0].operation, - "the resubscription attaches a fresh import under a new local id" - ); - subscriber.shutdown().await; - } - - mod lifecycle; - mod recovery; -} diff --git a/crates/workshop-server/src/heartbeat.rs b/crates/workshop-server/src/heartbeat.rs deleted file mode 100644 index 174102dbc..000000000 --- a/crates/workshop-server/src/heartbeat.rs +++ /dev/null @@ -1,898 +0,0 @@ -//! The gateway heartbeat: a background task polling the gateway's -//! `GET /health` endpoint and publishing reachability to the rest of the -//! server. -//! -//! One task is spawned with the server ([`spawn`]): while the gateway -//! answers, it probes through [`GatewayClient::health`] on the fixed -//! [`HEARTBEAT_INTERVAL`] and publishes the outcome to the shared -//! [`GatewayHealth`] flag the gateway-dependent routes read; while the -//! gateway is unreachable, the next probe instead waits out a delay -//! drawn from the shared [`ReconnectBackoff`] - jittered, escalating, -//! and reset only by useful work elsewhere (a delivered token or a -//! successful completion), never by a probe that merely connects, so a -//! gateway that flaps without delivering keeps escalating. When the -//! backoff's total-delay budget exhausts, the loop reports the give-up -//! on the status bus and stops probing for the life of the process. -//! The observer hears about transitions only - the first probe reports -//! the initial state ("Connected to gateway" or "Gateway unreachable"), -//! and after that a status update fires when the answer changes, so a -//! steady state never spams the status bar. Every transition also feeds -//! the Model menu's reachability (so `chat_ready` flips with the -//! gateway), and a transition to reachable (boot's first probe included) -//! refreshes the gateway's profile state and model catalog into their -//! buses. If simultaneous startup leaves either source empty, later healthy -//! ticks retry each source independently until the profile and a selectable -//! model are both ready, then restore the selection exactly once. -//! -//! The task stops through its [`Heartbeat`] handle: the signal wins the -//! loop's selects, so shutdown never waits out a tick or an in-flight -//! probe. The server runs the shutdown inside its graceful-shutdown future. - -use std::time::Duration; - -use tokio::sync::{oneshot, watch}; - -use workshop_protocol::{Activity, Severity, StatusBarUpdate}; -use workshop_support::ReconnectBackoff; - -use crate::gateway_binding::{GatewayBinding, GatewaySnapshot}; -use crate::push::Push; - -mod refresh; -pub(crate) use refresh::{refresh_catalog, refresh_profiles}; - -/// The status line announcing that the gateway answers its health probe. -pub(crate) const CONNECTED_LABEL: &str = "Connected to gateway"; -/// The status line announcing that the gateway does not answer. -pub(crate) const UNREACHABLE_LABEL: &str = "Gateway unreachable"; -/// The description riding the unreachable announcement. -pub(crate) const UNREACHABLE_DESCRIPTION: &str = "the gateway does not answer its health probe"; - -/// The status frame a joining session hears first: the bus's retained -/// frame, unless that frame is one of the heartbeat's transition -/// announcements. A transition describes a past moment, not the current -/// state - the boot-time "Connected to gateway" outlives itself within -/// seconds - so the line is recomputed from the current probe. A retained -/// frame carrying real work (a download's progress, a chat's activity) -/// replays as-is. -pub(crate) fn join_status( - retained: Option, - health: &GatewayHealth, -) -> Option { - let update = retained?; - if update.label != CONNECTED_LABEL && update.label != UNREACHABLE_LABEL { - return Some(update); - } - let reachable = health.is_reachable(); - Some(StatusBarUpdate { - label: if reachable { - "Ready" - } else { - UNREACHABLE_LABEL - } - .to_owned(), - description: if reachable { - "idle".to_owned() - } else { - UNREACHABLE_DESCRIPTION.to_owned() - }, - progress: None, - severity: Severity::Info, - activity: Activity::General, - }) -} - -/// How often the heartbeat probes a reachable gateway. Hardcoded for -/// now; a configuration knob may follow once someone needs one. Probes -/// of an unreachable gateway follow the [`ReconnectBackoff`] instead. -pub(crate) const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(5); - -/// Shared gateway reachability, written by the heartbeat and read by the -/// gateway-dependent routes. -/// -/// The flag starts optimistic (`true`): until the first probe lands, a -/// request flows to the gateway and fails or succeeds on its own merits, -/// which keeps a server running without a heartbeat (every router-only -/// test) behaving exactly as it did before the heartbeat existed. -#[derive(Debug, Clone)] -pub struct GatewayHealth { - reachable: watch::Sender, -} - -impl GatewayHealth { - /// Starts the flag optimistic; see the type docs for why. - pub(crate) fn new() -> Self { - Self { - reachable: watch::channel(true).0, - } - } - - /// Whether the gateway is currently believed reachable. - pub(crate) fn is_reachable(&self) -> bool { - *self.reachable.borrow() - } - - /// Subscribes to reachability changes. The current value is visible - /// immediately through the receiver; each later publish that flips the - /// flag notifies. The provisioning task waits on this to run its cache - /// calls only while the gateway answers. - pub(crate) fn subscribe(&self) -> watch::Receiver { - self.reachable.subscribe() - } - - /// Publishes one probe outcome. The heartbeat is the only production - /// writer; tests publish directly to pin the degraded paths. - pub fn publish(&self, reachable: bool) { - self.reachable.send_if_modified(|current| { - let changed = *current != reachable; - *current = reachable; - changed - }); - } -} - -/// A running heartbeat task. -/// -/// [`Heartbeat::shutdown`] signals the loop to stop and awaits the task. -/// Dropping the handle without shutting down still stops the task at its -/// next select point, because the closed channel resolves the stop branch. -#[derive(Debug)] -pub struct Heartbeat { - stop: Option>, - task: Option>, -} - -impl Heartbeat { - /// Signals the heartbeat to stop and waits for its task to finish. - pub async fn shutdown(mut self) { - if let Some(stop) = self.stop.take() { - let _ = stop.send(()); - } - if let Some(task) = self.task.take() { - let _ = task.await; - } - } -} - -/// Spawns the heartbeat loop against `client`, reporting transitions -/// through `push` and publishing reachability to `health` and to the -/// menu behind `push`, which recomputes `chat_ready` from it. A -/// transition to reachable - boot's first probe included - refreshes the -/// gateway's profile state and model catalog through the same handle, -/// then restores a model selection when none is applied. Healthy ticks -/// repeat each incomplete refresh independently, covering a gateway whose -/// health endpoint becomes ready before its catalog or profile state. The first -/// probe runs immediately; later probes follow `interval` while the -/// gateway answers and draw from `backoff` while it does not, ending the -/// loop when the backoff's budget exhausts. -#[must_use] -pub(crate) fn spawn( - gateway: GatewayBinding, - push: Push, - health: GatewayHealth, - interval: Duration, - backoff: ReconnectBackoff, -) -> Heartbeat { - let (stop, mut stopped) = oneshot::channel(); - let task = tokio::spawn(async move { - run(&gateway, &push, &health, interval, &backoff, &mut stopped).await; - }); - Heartbeat { - stop: Some(stop), - task: Some(task), - } -} - -/// The probe loop: a status update per transition, with the stop signal -/// winning over the wait, an in-flight probe, an in-flight profile -/// refresh, and an in-flight catalog refresh. The wait before each probe -/// is `interval` while the gateway answered last time (measured from the -/// previous probe's completion, so a slow probe never bunches into a -/// catch-up burst) and the backoff's next delay while it did not; a -/// successful probe deliberately never resets the backoff - only useful -/// work does, elsewhere - and an exhausted budget ends the loop with a -/// give-up report. -#[derive(Default)] -struct RefreshState { - profiles_ready: bool, - catalog_ready: bool, - selection_restored: bool, -} - -async fn run( - gateway: &GatewayBinding, - push: &Push, - health: &GatewayHealth, - interval: Duration, - backoff: &ReconnectBackoff, - stop: &mut oneshot::Receiver<()>, -) { - let mut last: Option = None; - let mut refresh = RefreshState::default(); - let mut gateway_changed = gateway.subscribe(); - loop { - // The first probe runs immediately; every later one waits here. - if let Some(reachable) = last { - let wait = if reachable { - interval - } else if let Some(delay) = backoff.next_delay() { - delay - } else { - push.push_failure( - "Gateway reconnect stopped", - "the reconnect budget is exhausted; restart the workshop to retry", - Activity::General, - ); - break; - }; - tokio::select! { - _ = &mut *stop => break, - changed = gateway_changed.changed() => { - if changed.is_err() { - break; - } - last = None; - refresh = RefreshState::default(); - continue; - } - () = tokio::time::sleep(wait) => {} - } - } - let snapshot = gateway.snapshot(); - let generation = snapshot.generation(); - let reachable = tokio::select! { - _ = &mut *stop => break, - changed = gateway_changed.changed() => { - if changed.is_err() { - break; - } - last = None; - refresh = RefreshState::default(); - continue; - } - reachable = snapshot.client().health() => reachable, - }; - if gateway.generation() != generation { - last = None; - refresh = RefreshState::default(); - continue; - } - health.publish(reachable); - let transitioned = last != Some(reachable); - last = Some(reachable); - if transitioned { - // The menu recomputes chat_ready from reachability, so the - // verdict feeds it before any slower refresh work below. - push.menu().set_gateway_reachable(reachable); - if reachable { - push.push_status_update( - CONNECTED_LABEL, - "the gateway answers its health probe", - Activity::General, - ); - } else { - push.push_status_update( - UNREACHABLE_LABEL, - UNREACHABLE_DESCRIPTION, - Activity::General, - ); - } - } - if !reachable { - refresh = RefreshState::default(); - continue; - } - if !refresh.profiles_ready || !refresh.catalog_ready { - // All menu state is server-owned and reaches the UI via - // socket pushes - the UI fetches nothing on boot - so every - // transition into reachable, boot's first probe included, - // (re)populates the profile state and the model catalog. - // Healthy ticks independently repeat either refresh until both - // sources are populated, because health and one ready source do - // not imply the other source is ready. The interval above bounds - // retries and keeps this from becoming a busy loop. - tokio::select! { - _ = &mut *stop => break, - changed = gateway_changed.changed() => { - if changed.is_err() { - break; - } - last = None; - refresh = RefreshState::default(); - continue; - } - () = refresh_incomplete_sources( - &snapshot, - push, - &mut refresh, - ) => {} - } - } - if refresh.profiles_ready && refresh.catalog_ready && !refresh.selection_restored { - // A fresh boot has no selection, so restore the remembered - // model for the now-known active profile (else the first - // catalog model); a reconnect whose selection survived the - // outage is a no-op. This branch runs exactly once per reachable - // convergence because both readiness facts remain true. - push.menu().restore_selection(); - refresh.selection_restored = true; - } - } -} - -/// Refreshes only the Gateway-owned menu sources that have not converged. -async fn refresh_incomplete_sources( - snapshot: &GatewaySnapshot, - push: &Push, - refresh: &mut RefreshState, -) { - match (refresh.profiles_ready, refresh.catalog_ready) { - (false, false) => { - (refresh.profiles_ready, refresh.catalog_ready) = tokio::join!( - refresh_profiles(snapshot.client(), push), - refresh_catalog(snapshot.client(), push) - ); - } - (false, true) => refresh.profiles_ready = refresh_profiles(snapshot.client(), push).await, - (true, false) => refresh.catalog_ready = refresh_catalog(snapshot.client(), push).await, - (true, true) => {} - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::gateway::GatewayClient; - - use std::sync::Arc; - use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; - - use axum::Router; - use axum::extract::State; - use axum::http::StatusCode; - use axum::response::{IntoResponse, Response}; - use axum::routing::get; - use tokio::sync::broadcast; - - use crate::catalog::CatalogBus; - use crate::menu::MenuBus; - use workshop_protocol::{CatalogPush, Progress, Severity, StatusBarUpdate, WorkbenchSnapshot}; - - fn retained(label: &str) -> StatusBarUpdate { - StatusBarUpdate { - label: label.to_owned(), - description: String::new(), - progress: None, - severity: Severity::Info, - activity: Activity::General, - } - } - - #[test] - fn a_join_recomputes_a_stale_connect_announcement_to_the_resting_line() { - let health = GatewayHealth::new(); - let update = join_status(Some(retained(CONNECTED_LABEL)), &health) - .expect("a retained transition still yields a join line"); - assert_eq!(update.label, "Ready"); - assert_eq!(update.severity, Severity::Info); - } - - #[test] - fn a_join_recomputes_a_stale_connect_announcement_during_an_outage() { - let health = GatewayHealth::new(); - health.publish(false); - let update = join_status(Some(retained(CONNECTED_LABEL)), &health) - .expect("a retained transition still yields a join line"); - assert_eq!(update.label, UNREACHABLE_LABEL); - assert_eq!(update.description, UNREACHABLE_DESCRIPTION); - } - - #[test] - fn a_join_keeps_a_retained_outage_while_the_gateway_is_down() { - let health = GatewayHealth::new(); - health.publish(false); - let update = join_status(Some(retained(UNREACHABLE_LABEL)), &health) - .expect("the outage line survives the recompute"); - assert_eq!(update.label, UNREACHABLE_LABEL); - } - - #[test] - fn a_join_replays_a_retained_frame_carrying_real_work() { - let health = GatewayHealth::new(); - let working = Some(StatusBarUpdate { - label: "Downloading model".to_owned(), - description: "ggml-large-v3.bin".to_owned(), - progress: Some(Progress { - current: 1, - total: 2, - }), - severity: Severity::Info, - activity: Activity::General, - }); - let update = join_status(working, &health).expect("the work frame replays as-is"); - assert_eq!(update.label, "Downloading model"); - assert!(update.progress.is_some()); - } - - #[test] - fn a_join_with_no_retained_frame_sends_nothing() { - let health = GatewayHealth::new(); - assert!(join_status(None, &health).is_none()); - } - - use crate::status::StatusBus; - - /// Fast enough to observe transitions without real waiting, slow - /// enough that a 200 ms quiet window spans several ticks and so proves - /// the loop does not re-emit a steady state. - const TEST_INTERVAL: Duration = Duration::from_millis(25); - - const CATALOG: &str = r#"{"object":"list","data":[{"id":"test-model","object":"model","owned_by":"promptforge"}]}"#; - - /// A mock `/health` whose answer flips under test control. - async fn flippable_health(State(healthy): State>) -> Response { - if healthy.load(Ordering::Relaxed) { - StatusCode::OK.into_response() - } else { - StatusCode::SERVICE_UNAVAILABLE.into_response() - } - } - - /// A static mock catalog for the refresh-on-reconnect tests. - async fn mock_models() -> Response { - ( - [(axum::http::header::CONTENT_TYPE, "application/json")], - CATALOG, - ) - .into_response() - } - - /// A static mock profile list for the profile-populate tests. - async fn mock_profiles() -> Response { - ( - [(axum::http::header::CONTENT_TYPE, "application/json")], - r#"{"profiles":["coding","main"]}"#, - ) - .into_response() - } - - /// A static mock gateway status naming the active profile. - async fn mock_profile_status() -> Response { - ( - [(axum::http::header::CONTENT_TYPE, "application/json")], - r#"{"profile":"main","models":["test-model"]}"#, - ) - .into_response() - } - - /// Binds a mock gateway whose `/health` flips with `healthy`, with a - /// static `/v1/models` and the profile endpoints beside it. - async fn spawn_gateway(healthy: Arc) -> String { - let app = Router::new() - .route("/health", get(flippable_health)) - .route("/v1/models", get(mock_models)) - .route("/admin/profiles", get(mock_profiles)) - .route("/admin/status", get(mock_profile_status)) - .with_state(healthy); - serve(app).await - } - - /// Binds `app` on a free loopback port and returns its base URL. - async fn serve(app: Router) -> String { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .expect("bind mock gateway"); - let addr = listener.local_addr().expect("mock gateway address"); - tokio::spawn(async move { - axum::serve(listener, app) - .await - .expect("mock gateway serves"); - }); - format!("http://{addr}") - } - - /// A backoff fast enough that a down-phase probe retries within a few - /// ticks, with a budget no test exhausts by accident. - fn test_backoff() -> ReconnectBackoff { - ReconnectBackoff::with_schedule( - Duration::from_millis(10), - Duration::from_millis(40), - Duration::from_secs(60), - ) - } - - /// Starts a heartbeat against `base_url` on the fast interval, wired to - /// `status` and `catalog`; returns the handle, the shared health flag, - /// the menu bus the heartbeat feeds, and its reconnect backoff. - fn heartbeat_on( - base_url: &str, - status: &StatusBus, - catalog: &CatalogBus, - ) -> (Heartbeat, GatewayHealth, MenuBus, ReconnectBackoff) { - let gateway = GatewayBinding::new(base_url, "").expect("binding builds in tests"); - let health = GatewayHealth::new(); - let menu = MenuBus::new(catalog.clone(), None); - let backoff = test_backoff(); - let heartbeat = spawn( - gateway, - Push::new(status.clone(), catalog.clone(), menu.clone()), - health.clone(), - TEST_INTERVAL, - backoff.clone(), - ); - (heartbeat, health, menu, backoff) - } - - /// Receives the next status update within a generous deadline. - async fn next_update(rx: &mut broadcast::Receiver) -> StatusBarUpdate { - tokio::time::timeout(Duration::from_secs(5), rx.recv()) - .await - .expect("a status update arrives within the deadline") - .expect("the status bus is open") - } - - /// Asserts no update arrives within a window spanning several ticks. - async fn assert_quiet(rx: &mut broadcast::Receiver) { - let quiet = tokio::time::timeout(Duration::from_millis(200), rx.recv()).await; - assert!( - quiet.is_err(), - "a steady state must not re-emit, got {quiet:?}" - ); - } - - /// Polls the retained menu snapshot until `accept` holds, within a - /// generous deadline. Polling the retained copy rather than - /// subscribing sidesteps the race between the heartbeat's publishes - /// and the test's subscription. - async fn snapshot_where( - menu: &MenuBus, - accept: impl Fn(&WorkbenchSnapshot) -> bool, - ) -> WorkbenchSnapshot { - tokio::time::timeout(Duration::from_secs(5), async { - loop { - if let Some(snapshot) = menu.latest() - && accept(&snapshot) - { - return snapshot; - } - tokio::time::sleep(Duration::from_millis(5)).await; - } - }) - .await - .expect("a matching snapshot is retained within the deadline") - } - - #[test] - fn the_probe_bound_is_shorter_than_the_heartbeat_interval() { - assert!( - crate::gateway::HEALTH_PROBE_TIMEOUT < HEARTBEAT_INTERVAL, - "a probe outlasting the interval would back the heartbeat up \ - behind a stalled gateway" - ); - } - - #[tokio::test] - async fn a_stalled_gateway_reads_unreachable_within_the_probe_bound() { - // A stub that completes TCP handshakes and never answers: without - // a bounded probe, the first probe would hang forever and the - // heartbeat would never report at all. - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .expect("bind stalled stub"); - let addr = listener.local_addr().expect("stalled stub address"); - tokio::spawn(async move { - let mut held = Vec::new(); - while let Ok((socket, _)) = listener.accept().await { - held.push(socket); - } - }); - let client = GatewayClient::new(&format!("http://{addr}"), "") - .expect("client builds in tests") - .with_timeouts_for_test(Duration::from_millis(100), Duration::from_millis(100)); - let status = StatusBus::new(); - let catalog = CatalogBus::new(); - let menu = MenuBus::new(catalog.clone(), None); - let mut rx = status.subscribe(); - let heartbeat = spawn( - GatewayBinding::from_client(client), - Push::new(status.clone(), catalog, menu), - GatewayHealth::new(), - TEST_INTERVAL, - test_backoff(), - ); - let update = next_update(&mut rx).await; - assert_eq!(update.label, "Gateway unreachable"); - heartbeat.shutdown().await; - } - - #[tokio::test] - async fn a_healthy_gateway_fires_connected_once_and_stays_quiet() { - let healthy = Arc::new(AtomicBool::new(true)); - let base_url = spawn_gateway(Arc::clone(&healthy)).await; - let status = StatusBus::new(); - let catalog = CatalogBus::new(); - let mut rx = status.subscribe(); - let (heartbeat, health, _menu, _backoff) = heartbeat_on(&base_url, &status, &catalog); - - let update = next_update(&mut rx).await; - assert_eq!(update.label, "Connected to gateway"); - assert_eq!(update.severity, Severity::Info); - assert_eq!(update.activity, Activity::General); - assert!(health.is_reachable(), "the probe published reachable"); - assert_quiet(&mut rx).await; - heartbeat.shutdown().await; - } - - #[tokio::test] - async fn an_unreachable_gateway_fires_unreachable_once_and_stays_quiet() { - // Nothing listens on port 1, so the connect fails deterministically. - let status = StatusBus::new(); - let catalog = CatalogBus::new(); - let mut rx = status.subscribe(); - let (heartbeat, health, _menu, _backoff) = - heartbeat_on("http://127.0.0.1:1", &status, &catalog); - - let update = next_update(&mut rx).await; - assert_eq!(update.label, "Gateway unreachable"); - assert_eq!(update.severity, Severity::Info); - assert_eq!(update.activity, Activity::General); - assert!(!health.is_reachable(), "the probe published unreachable"); - assert_quiet(&mut rx).await; - heartbeat.shutdown().await; - } - - #[tokio::test] - async fn each_transition_fires_exactly_one_update() { - let healthy = Arc::new(AtomicBool::new(true)); - let base_url = spawn_gateway(Arc::clone(&healthy)).await; - let status = StatusBus::new(); - let catalog = CatalogBus::new(); - let mut rx = status.subscribe(); - let (heartbeat, health, _menu, _backoff) = heartbeat_on(&base_url, &status, &catalog); - - assert_eq!(next_update(&mut rx).await.label, "Connected to gateway"); - healthy.store(false, Ordering::Relaxed); - assert_eq!(next_update(&mut rx).await.label, "Gateway unreachable"); - assert!(!health.is_reachable()); - healthy.store(true, Ordering::Relaxed); - assert_eq!(next_update(&mut rx).await.label, "Connected to gateway"); - assert!(health.is_reachable()); - assert_quiet(&mut rx).await; - heartbeat.shutdown().await; - } - - #[tokio::test] - async fn a_reconnect_pushes_the_refreshed_catalog() { - let healthy = Arc::new(AtomicBool::new(false)); - let base_url = spawn_gateway(Arc::clone(&healthy)).await; - let status = StatusBus::new(); - let catalog = CatalogBus::new(); - let mut status_rx = status.subscribe(); - let mut catalog_rx = catalog.subscribe(); - let (heartbeat, _health, _menu, _backoff) = heartbeat_on(&base_url, &status, &catalog); - - assert_eq!( - next_update(&mut status_rx).await.label, - "Gateway unreachable" - ); - healthy.store(true, Ordering::Relaxed); - assert_eq!( - next_update(&mut status_rx).await.label, - "Connected to gateway" - ); - let push: CatalogPush = tokio::time::timeout(Duration::from_secs(5), catalog_rx.recv()) - .await - .expect("the refreshed catalog arrives within the deadline") - .expect("the catalog bus is open"); - assert_eq!( - push.models, - serde_json::json!([{"id": "test-model", "object": "model", "owned_by": "promptforge"}]) - .as_array() - .expect("the fixture is an array") - .clone(), - "the push carries every chat-capable gateway model" - ); - heartbeat.shutdown().await; - } - - #[tokio::test] - async fn a_reconnect_whose_refresh_is_declined_pushes_no_catalog() { - // No /v1/models route: the refresh is declined with a 404, and a - // declined refresh is skipped rather than pushed - pushing it - // would empty pickers that still hold a usable list. - let healthy = Arc::new(AtomicBool::new(false)); - let base_url = serve( - Router::new() - .route("/health", get(flippable_health)) - .with_state(Arc::clone(&healthy)), - ) - .await; - let status = StatusBus::new(); - let catalog = CatalogBus::new(); - let mut status_rx = status.subscribe(); - let mut catalog_rx = catalog.subscribe(); - let (heartbeat, _health, _menu, _backoff) = heartbeat_on(&base_url, &status, &catalog); - - assert_eq!( - next_update(&mut status_rx).await.label, - "Gateway unreachable" - ); - healthy.store(true, Ordering::Relaxed); - assert_eq!( - next_update(&mut status_rx).await.label, - "Connected to gateway" - ); - let quiet = tokio::time::timeout(Duration::from_millis(200), catalog_rx.recv()).await; - assert!(quiet.is_err(), "a declined refresh is skipped, not pushed"); - heartbeat.shutdown().await; - } - - #[tokio::test] - async fn a_down_to_up_transition_publishes_a_populated_snapshot() { - let healthy = Arc::new(AtomicBool::new(false)); - let base_url = spawn_gateway(Arc::clone(&healthy)).await; - let status = StatusBus::new(); - let catalog = CatalogBus::new(); - let mut status_rx = status.subscribe(); - let (heartbeat, _health, menu, _backoff) = heartbeat_on(&base_url, &status, &catalog); - - assert_eq!( - next_update(&mut status_rx).await.label, - "Gateway unreachable" - ); - healthy.store(true, Ordering::Relaxed); - let populated = snapshot_where(&menu, |snapshot| !snapshot.profiles.is_empty()).await; - assert_eq!(populated.profiles, ["coding", "main"]); - assert_eq!(populated.active.as_deref(), Some("main")); - heartbeat.shutdown().await; - } - - #[tokio::test] - async fn a_gateway_without_profile_support_publishes_an_empty_list() { - // Only /health exists: the profile endpoints answer 404, which - // is a state, not an error - the reconnect publishes an empty - // list rather than keeping the stale names. - let healthy = Arc::new(AtomicBool::new(false)); - let base_url = serve( - Router::new() - .route("/health", get(flippable_health)) - .with_state(Arc::clone(&healthy)), - ) - .await; - let status = StatusBus::new(); - let catalog = CatalogBus::new(); - let mut status_rx = status.subscribe(); - let (heartbeat, _health, menu, _backoff) = heartbeat_on(&base_url, &status, &catalog); - - assert_eq!( - next_update(&mut status_rx).await.label, - "Gateway unreachable" - ); - menu.set_profiles(vec!["stale".to_string()], Some("stale".to_string())); - healthy.store(true, Ordering::Relaxed); - let emptied = snapshot_where(&menu, |snapshot| snapshot.profiles.is_empty()).await; - assert_eq!(emptied.active, None, "the stale active profile clears"); - heartbeat.shutdown().await; - } - - #[tokio::test] - async fn reachability_transitions_flip_chat_ready() { - let healthy = Arc::new(AtomicBool::new(true)); - let base_url = spawn_gateway(Arc::clone(&healthy)).await; - let status = StatusBus::new(); - let catalog = CatalogBus::new(); - // Readiness needs a non-empty catalog and a selection; the mock - // catalog holds test-model, so a reconnect's refresh keeps it. - catalog.publish(vec![serde_json::json!({"id": "test-model"})]); - let mut status_rx = status.subscribe(); - let (heartbeat, _health, menu, _backoff) = heartbeat_on(&base_url, &status, &catalog); - - assert_eq!( - next_update(&mut status_rx).await.label, - "Connected to gateway" - ); - menu.set_selected("test-model") - .expect("the id is in the catalog"); - snapshot_where(&menu, |snapshot| snapshot.chat_ready).await; - - healthy.store(false, Ordering::Relaxed); - let down = snapshot_where(&menu, |snapshot| !snapshot.chat_ready).await; - assert_eq!( - down.selected_model.as_deref(), - Some("test-model"), - "only reachability flipped; the selection survives the outage" - ); - - healthy.store(true, Ordering::Relaxed); - snapshot_where(&menu, |snapshot| snapshot.chat_ready).await; - heartbeat.shutdown().await; - } - - #[tokio::test] - async fn a_mere_connect_keeps_the_backoff_escalated() { - // The anti-flap rule this step exists for: an outage escalates the - // backoff, and a gateway that answers its probe again (connects - // without delivering any useful work) must leave the escalation - // standing, so the next outage keeps the slow schedule. - let healthy = Arc::new(AtomicBool::new(false)); - let base_url = spawn_gateway(Arc::clone(&healthy)).await; - let status = StatusBus::new(); - let catalog = CatalogBus::new(); - let mut rx = status.subscribe(); - let (heartbeat, health, _menu, backoff) = heartbeat_on(&base_url, &status, &catalog); - - assert_eq!(next_update(&mut rx).await.label, "Gateway unreachable"); - healthy.store(true, Ordering::Relaxed); - assert_eq!(next_update(&mut rx).await.label, "Connected to gateway"); - assert!(health.is_reachable()); - assert!( - backoff.is_escalated_for_test(), - "reconnecting without useful work must not reset the backoff" - ); - heartbeat.shutdown().await; - } - - #[tokio::test] - async fn an_exhausted_budget_stops_reconnect_probes_with_a_give_up_report() { - let healthy = Arc::new(AtomicBool::new(false)); - let base_url = spawn_gateway(Arc::clone(&healthy)).await; - let status = StatusBus::new(); - let catalog = CatalogBus::new(); - let mut rx = status.subscribe(); - let gateway = GatewayBinding::new(&base_url, "").expect("binding builds in tests"); - let health = GatewayHealth::new(); - let menu = MenuBus::new(catalog.clone(), None); - // A budget of a few schedule steps: exhausted within a handful of - // failed probes, well inside the test deadline. - let backoff = ReconnectBackoff::with_schedule( - Duration::from_millis(10), - Duration::from_millis(20), - Duration::from_millis(50), - ); - let heartbeat = spawn( - gateway, - Push::new(status.clone(), catalog, menu), - health.clone(), - TEST_INTERVAL, - backoff, - ); - - assert_eq!(next_update(&mut rx).await.label, "Gateway unreachable"); - let report = next_update(&mut rx).await; - assert_eq!(report.label, "Gateway reconnect stopped"); - assert_eq!(report.severity, Severity::Error); - // The gateway coming back after the give-up changes nothing: the - // loop has ended, so no probe ever notices. - healthy.store(true, Ordering::Relaxed); - assert_quiet(&mut rx).await; - assert!( - !health.is_reachable(), - "an ended loop leaves the last verdict standing" - ); - heartbeat.shutdown().await; - } - - #[tokio::test] - async fn shutdown_stops_the_task_without_waiting_out_the_interval() { - // A long interval: if the stop signal did not win the select, the - // shutdown would block for the whole minute. - let status = StatusBus::new(); - let gateway = - GatewayBinding::new("http://127.0.0.1:1", "").expect("binding builds in tests"); - let catalog = CatalogBus::new(); - let menu = crate::menu::MenuBus::new(catalog.clone(), None); - let heartbeat = spawn( - gateway, - Push::new(status, catalog, menu), - GatewayHealth::new(), - Duration::from_secs(60), - ReconnectBackoff::new(), - ); - tokio::time::timeout(Duration::from_secs(5), heartbeat.shutdown()) - .await - .expect("shutdown does not wait out the interval"); - } - - mod recovery; - mod startup_convergence; -} diff --git a/crates/workshop-server/src/lib.rs b/crates/workshop-server/src/lib.rs index 652c72328..8984cd08a 100644 --- a/crates/workshop-server/src/lib.rs +++ b/crates/workshop-server/src/lib.rs @@ -10,30 +10,36 @@ mod app; mod assets; -mod catalog; mod cross_site; mod csp; mod error; -mod gateway; -mod gateway_binding; -mod gateway_progress; -mod heartbeat; mod input; -mod menu; -mod observer; -mod progress; -mod push; mod relay; -mod resolve; mod routes; mod serve; mod session; mod session_agents; -mod status; -#[cfg(any(test, feature = "test-fixtures"))] -mod test_gateway; mod workspace; +// The extracted subsystem crates, aliased at their pre-decomposition +// module paths so the shell's internals read as they did before the +// split. The tier graph is enforced by `cargo test -p xtask`. +pub use workshop_gateway::{ + gateway, gateway_binding, gateway_progress, heartbeat, observer, resolve, +}; +pub use workshop_menu::{catalog, menu}; +pub use workshop_status::{progress, status}; + +/// The intent-named push facade over the registry's producer sink slots: +/// business code reports what happened and never chooses a severity or +/// builds a bus payload. +pub mod push { + pub use workshop_registry::Push; +} + +#[cfg(any(test, feature = "test-fixtures"))] +pub use workshop_gateway::test_gateway; + /// Crate-internal test seams, re-exported to the integration-test binary. /// The socket behavior tests drive the status, catalog, and menu buses, /// the health flag, the backoff, and the heartbeat directly, so those diff --git a/crates/workshop-server/src/progress.rs b/crates/workshop-server/src/progress.rs deleted file mode 100644 index 9c21adb4d..000000000 --- a/crates/workshop-server/src/progress.rs +++ /dev/null @@ -1,500 +0,0 @@ -//! The hub-to-status-bar renderer: a task that samples the process -//! [`ProgressHub`] and drives the status bar's progress indicator through -//! [`Push`], so the anti-flicker policy lives here and the UI stays dumb. -//! -//! The indicator appears only once an operation has been live for -//! [`SHOW_DELAY`], stays up at least [`MIN_VISIBLE`] once shown, and -//! displays the monotonic aggregate from [`ProgressMeter`]: the bar never -//! flashes for sub-second work, never resets mid-operation, and never -//! steps backward. Status texts ("Listening...", failures) stay explicit -//! push calls in the subsystems that own them; the trees own only -//! fractional progress. When the hub's last tree detaches, the renderer -//! returns the bar to rest with [`Push::push_idle`]. - -use std::sync::Arc; -use std::time::Duration; - -use tokio::sync::broadcast::error::RecvError; -use tokio::sync::oneshot; -use tokio::time::Instant; - -use shared_progress::{ProgressHub, ProgressMeter}; - -use crate::push::Push; -use workshop_protocol::Activity; - -/// How long an operation must be live before the indicator appears; work -/// shorter than this never disturbs the status bar. -pub(crate) const SHOW_DELAY: Duration = Duration::from_secs(1); - -/// How long the indicator stays up once shown, so an operation that ends -/// just past [`SHOW_DELAY`] still reads as a completed bar, not a flash. -pub(crate) const MIN_VISIBLE: Duration = Duration::from_millis(500); - -/// How often the renderer re-samples while the indicator is up: a tree's -/// detach emits no event, so only a poll notices the last tree leaving. -const DETACH_POLL: Duration = Duration::from_millis(100); - -/// The `total` every pushed frame carries; fractions quantize to -/// millionths of it. -const PROGRESS_TOTAL: u64 = 1_000_000; - -/// A running renderer task. -/// -/// [`Renderer::shutdown`] signals the task to stop and awaits it. Dropping -/// the handle without shutting down still stops the task at its next -/// select point, because the closed channel resolves the stop branch. -#[derive(Debug)] -pub(crate) struct Renderer { - stop: Option>, - task: Option>, -} - -impl Renderer { - /// Signals the renderer to stop and waits for its task to finish. - pub(crate) async fn shutdown(mut self) { - if let Some(stop) = self.stop.take() { - let _ = stop.send(()); - } - if let Some(task) = self.task.take() { - let _ = task.await; - } - } -} - -/// Spawns the renderer task against `hub`, pushing through `push`. -#[must_use] -pub(crate) fn spawn(hub: Arc, push: Push) -> Renderer { - let (stop, mut stopped) = oneshot::channel(); - let task = tokio::spawn(async move { - run(&hub, &push, &mut stopped).await; - }); - Renderer { - stop: Some(stop), - task: Some(task), - } -} - -/// The task loop: re-sample on every hub event and on every anti-flicker -/// deadline. A lagged receiver simply re-samples - snapshots are the -/// ground truth and intermediate events are lossy by design. -async fn run(hub: &ProgressHub, push: &Push, stop: &mut oneshot::Receiver<()>) { - let mut events = hub.subscribe(); - let mut indicator = Indicator::default(); - // Catches operations that attached before the subscription. - indicator.update(hub, push); - loop { - tokio::select! { - _ = &mut *stop => break, - event = events.recv() => { - // The hub lives in AppState for the process lifetime, so - // Closed cannot occur in production; treat it as a stop. - if matches!(event, Err(RecvError::Closed)) { - break; - } - } - () = wake_at(indicator.next_wake(Instant::now())) => {} - } - indicator.update(hub, push); - } -} - -/// Waits for `at`, or forever when there is no pending deadline. -async fn wake_at(at: Option) { - match at { - Some(at) => tokio::time::sleep_until(at).await, - None => std::future::pending().await, - } -} - -/// The anti-flicker state machine over the hub's snapshots. -#[derive(Debug, Default)] -struct Indicator { - meter: ProgressMeter, - /// When the current run of live operations began; back-to-back - /// operations share one run, so the bar never flickers between them. - live_since: Option, - /// When the indicator was shown; held until the idle push lands. - shown_since: Option, - /// The last frame pushed, so the detach poll never re-pushes an - /// unchanged sample. - last_frame: Option<(String, u64)>, -} - -impl Indicator { - /// Samples the hub and pushes whatever transition the sample calls for. - fn update(&mut self, hub: &ProgressHub, push: &Push) { - let now = Instant::now(); - let Some(fraction) = self.meter.sample(hub) else { - self.live_since = None; - if let Some(shown) = self.shown_since - && now.duration_since(shown) >= MIN_VISIBLE - { - self.shown_since = None; - self.last_frame = None; - push.push_idle(); - } - return; - }; - let live_since = *self.live_since.get_or_insert(now); - if self.shown_since.is_none() && now.duration_since(live_since) < SHOW_DELAY { - return; - } - // Every leaf finished but the tree still lives: the detach (and - // the idle push) is imminent, so the last frame stands. - let Some(label) = hub.headline() else { - return; - }; - self.shown_since.get_or_insert(now); - let current = quantize(fraction); - // The meter resets its high-water mark on an idle sample, so a new - // operation attaching while the bar holds for MIN_VISIBLE after a - // drain would restart the visible bar at the new operation's zero. - // The last pushed frame is the floor until the new operation rises - // past it: within a run the meter is already monotonic, so the floor - // only ever bites across a drain. - let current = self - .last_frame - .as_ref() - .map_or(current, |(_, shown)| current.max(*shown)); - if self - .last_frame - .as_ref() - .is_some_and(|(l, c)| l == &label && *c == current) - { - return; - } - self.last_frame = Some((label.clone(), current)); - push.push_progress( - label.clone(), - label, - current, - PROGRESS_TOTAL, - Activity::General, - ); - } - - /// The next moment `update` can change state without a hub event: the - /// show deadline while an operation warms up, the detach poll while - /// the indicator is up, or the earliest idle moment once the hub has - /// drained under a still-visible bar. - fn next_wake(&self, now: Instant) -> Option { - match (self.live_since, self.shown_since) { - (Some(live), None) => { - let deadline = live + SHOW_DELAY; - // A lapsed deadline with the bar unshown means every leaf - // finished but the tree still lives; poll for the detach - // instead of re-arming a past instant, which would spin - // the select loop. - Some(if deadline > now { - deadline - } else { - now + DETACH_POLL - }) - } - (Some(_), Some(_)) => Some(now + DETACH_POLL), - (None, Some(shown)) => Some(shown + MIN_VISIBLE), - (None, None) => None, - } - } -} - -/// Quantizes a `0.0..=1.0` fraction to `current` of [`PROGRESS_TOTAL`]. -#[expect( - clippy::cast_possible_truncation, - clippy::cast_sign_loss, - clippy::cast_precision_loss, - reason = "the clamped fraction lands the cast in 0..=PROGRESS_TOTAL" -)] -fn quantize(fraction: f64) -> u64 { - (fraction.clamp(0.0, 1.0) * PROGRESS_TOTAL as f64).round() as u64 -} - -#[cfg(test)] -mod tests { - use super::*; - - use tokio::sync::broadcast; - - use crate::catalog::CatalogBus; - use crate::menu::MenuBus; - use crate::status::StatusBus; - use workshop_protocol::{Progress, Severity, StatusBarUpdate}; - - /// A hub, a push handle over fresh buses, and the status receiver the - /// renderer's frames land on (the push.rs wired() pattern). - fn wired() -> (Arc, Push, broadcast::Receiver) { - let hub = Arc::new(ProgressHub::new()); - let status = StatusBus::new(); - let rx = status.subscribe(); - let catalog = CatalogBus::new(); - let menu = MenuBus::new(catalog.clone(), None); - (hub, Push::new(status, catalog, menu), rx) - } - - /// Lets the renderer task run everything currently pending. - async fn settle() { - for _ in 0..3 { - tokio::task::yield_now().await; - } - } - - #[tokio::test(start_paused = true)] - async fn a_sub_second_operation_never_reaches_the_status_bar() { - let (hub, push, mut rx) = wired(); - let renderer = spawn(Arc::clone(&hub), push); - let tree = hub.operation(); - let leaf = tree.register("download", 1.0); - leaf.set_fraction(0.5); - settle().await; - leaf.complete(); - drop(tree); - settle().await; - tokio::time::advance(SHOW_DELAY * 2).await; - settle().await; - assert!( - rx.try_recv().is_err(), - "a sub-second operation must never show the indicator" - ); - renderer.shutdown().await; - } - - #[tokio::test(start_paused = true)] - async fn an_operation_outliving_the_show_delay_pushes_the_headline_and_aggregate() { - let (hub, push, mut rx) = wired(); - let renderer = spawn(Arc::clone(&hub), push); - let tree = hub.operation(); - let download = tree.register("download", 3.0); - let verify = tree.register("verify", 1.0); - download.set_fraction(1.0); - verify.set_fraction(0.5); - settle().await; - tokio::time::advance(SHOW_DELAY.saturating_sub(Duration::from_millis(1))).await; - settle().await; - assert!(rx.try_recv().is_err(), "the bar waits out the show delay"); - tokio::time::advance(Duration::from_millis(2)).await; - settle().await; - let update = rx - .try_recv() - .expect("the bar appears once the delay lapses"); - assert_eq!( - update.label, "verify", - "the headline is the unfinished leaf" - ); - assert_eq!( - update.progress, - Some(Progress { - current: 875_000, - total: PROGRESS_TOTAL, - }), - "the weighted aggregate: (3*1.0 + 1*0.5) / 4" - ); - assert_eq!(update.severity, Severity::Info); - assert_eq!(update.activity, Activity::General); - renderer.shutdown().await; - } - - #[tokio::test(start_paused = true)] - async fn the_indicator_holds_for_the_minimum_visible_time_after_the_hub_drains() { - let (hub, push, mut rx) = wired(); - let renderer = spawn(Arc::clone(&hub), push); - let tree = hub.operation(); - let leaf = tree.register("download", 1.0); - leaf.set_fraction(0.5); - settle().await; - tokio::time::advance(SHOW_DELAY).await; - settle().await; - let shown = rx - .try_recv() - .expect("the bar appears once the delay lapses"); - assert!(shown.progress.is_some()); - - drop(tree); - tokio::time::advance(DETACH_POLL).await; - settle().await; - assert!( - rx.try_recv().is_err(), - "the idle push waits out the minimum visible time" - ); - tokio::time::advance(MIN_VISIBLE).await; - settle().await; - let idle = rx - .try_recv() - .expect("the bar clears once the minimum has passed"); - assert_eq!(idle.label, "Ready"); - assert_eq!(idle.progress, None); - renderer.shutdown().await; - } - - #[tokio::test(start_paused = true)] - async fn a_lapsed_show_deadline_polls_for_the_detach_instead_of_rearming_the_past() { - let live = Instant::now(); - let indicator = Indicator { - live_since: Some(live), - ..Indicator::default() - }; - let now = live + SHOW_DELAY + Duration::from_secs(1); - assert_eq!( - indicator.next_wake(now), - Some(now + DETACH_POLL), - "a past deadline re-armed would resolve instantly and spin the loop" - ); - } - - #[tokio::test(start_paused = true)] - async fn a_tree_finished_before_the_delay_and_held_past_it_never_reaches_the_status_bar() { - let (hub, push, mut rx) = wired(); - let renderer = spawn(Arc::clone(&hub), push); - let tree = hub.operation(); - let leaf = tree.register("download", 1.0); - leaf.complete(); - settle().await; - tokio::time::advance(SHOW_DELAY * 2).await; - settle().await; - assert!( - rx.try_recv().is_err(), - "a finished tree has no headline, so the bar never shows" - ); - drop(tree); - tokio::time::advance(DETACH_POLL * 2).await; - settle().await; - assert!( - rx.try_recv().is_err(), - "a bar that never showed pushes no idle frame" - ); - renderer.shutdown().await; - } - - #[tokio::test(start_paused = true)] - async fn an_unchanged_sample_is_not_repushed_by_the_detach_poll() { - let (hub, push, mut rx) = wired(); - let renderer = spawn(Arc::clone(&hub), push); - let tree = hub.operation(); - let leaf = tree.register("download", 1.0); - leaf.set_fraction(0.5); - settle().await; - tokio::time::advance(SHOW_DELAY).await; - settle().await; - let first = rx - .try_recv() - .expect("the bar appears once the delay lapses"); - assert!(first.progress.is_some()); - tokio::time::advance(DETACH_POLL * 5).await; - settle().await; - assert!( - rx.try_recv().is_err(), - "the detach poll re-samples without re-pushing an unchanged frame" - ); - leaf.set_fraction(0.75); - settle().await; - let update = rx.try_recv().expect("a real change pushes"); - assert_eq!( - update.progress, - Some(Progress { - current: 750_000, - total: PROGRESS_TOTAL, - }) - ); - renderer.shutdown().await; - } - - #[tokio::test(start_paused = true)] - async fn an_operation_attached_before_the_spawn_is_caught_by_the_first_sample() { - let (hub, push, mut rx) = wired(); - // The tree attaches before the renderer subscribes: only the - // initial sample catches it, since its Begun predates the - // subscription. - let tree = hub.operation(); - let leaf = tree.register("download", 1.0); - leaf.set_fraction(0.5); - let renderer = spawn(Arc::clone(&hub), push); - settle().await; - tokio::time::advance(SHOW_DELAY).await; - settle().await; - let update = rx - .try_recv() - .expect("the pre-attached operation still reaches the bar"); - assert_eq!(update.label, "download"); - assert_eq!( - update.progress, - Some(Progress { - current: 500_000, - total: PROGRESS_TOTAL, - }) - ); - renderer.shutdown().await; - } - - #[tokio::test(start_paused = true)] - async fn a_lagged_event_receiver_resamples_from_the_snapshot() { - let (hub, push, mut rx) = wired(); - let renderer = spawn(Arc::clone(&hub), push); - // Overflow the hub's 1024-event ring so the renderer's receiver - // lags: the lag must fall through to a re-sample, not stall the - // renderer. - let tree = hub.operation(); - let _leaves: Vec<_> = (0..1100) - .map(|index| tree.register(&format!("leaf-{index}"), 1.0)) - .collect(); - settle().await; - tokio::time::advance(SHOW_DELAY).await; - settle().await; - let update = rx - .try_recv() - .expect("the bar still appears after the receiver lagged"); - assert!( - update.progress.is_some(), - "the re-sampled snapshot drives the bar" - ); - renderer.shutdown().await; - } - - #[tokio::test(start_paused = true)] - async fn an_operation_attaching_during_the_minimum_visible_hold_does_not_step_the_bar_backward() - { - let (hub, push, mut rx) = wired(); - let renderer = spawn(Arc::clone(&hub), push); - let tree = hub.operation(); - let leaf = tree.register("first", 1.0); - leaf.set_fraction(0.5); - settle().await; - tokio::time::advance(SHOW_DELAY).await; - settle().await; - let shown = rx - .try_recv() - .expect("the bar appears once the delay lapses"); - assert_eq!( - shown.progress, - Some(Progress { - current: 500_000, - total: PROGRESS_TOTAL, - }) - ); - - // The first operation drains while the bar is up; the minimum - // visible hold keeps the bar showing. - drop(tree); - tokio::time::advance(DETACH_POLL).await; - settle().await; - - // A new operation attaching during the hold continues from the - // drained level: the meter reset on the idle sample, so without - // the floor the visible bar would restart at zero. - let second = hub.operation(); - let _leaf = second.register("second", 1.0); - settle().await; - let continued = rx - .try_recv() - .expect("the new operation pushes under the held bar"); - assert_eq!(continued.label, "second"); - assert_eq!( - continued.progress, - Some(Progress { - current: 500_000, - total: PROGRESS_TOTAL, - }), - "the bar never steps backward" - ); - renderer.shutdown().await; - } -} diff --git a/crates/workshop-server/src/push.rs b/crates/workshop-server/src/push.rs deleted file mode 100644 index 88e843241..000000000 --- a/crates/workshop-server/src/push.rs +++ /dev/null @@ -1,269 +0,0 @@ -//! The push facade: intent-named send methods over the status, catalog, -//! and menu broadcast buses, so business code reports what happened and -//! never chooses a severity or builds a bus payload (SiYuan's -//! `PushReloadFiletree` pattern). -//! -//! Producers hold a [`Push`] and speak in intents - a status update, a -//! failure, an activity pulse, determinate progress, idle, a fresh model -//! catalog; workbench producers drive the Model-menu mutators through -//! [`Push::menu`], and every mutation publishes its own snapshot. What -//! each intent becomes on the wire -//! is decided here and in `workshop-protocol`, nowhere else. The buses -//! stay the transport: every `/ws` session subscribes on -//! [`crate::status::StatusBus`], [`crate::catalog::CatalogBus`], and -//! [`crate::menu::MenuBus`] and serializes what it receives. - -use crate::catalog::CatalogBus; -use crate::menu::MenuBus; -use crate::status::StatusBus; -use workshop_protocol::{Activity, Progress}; - -/// The intent-named push handle over the status, catalog, and menu buses. -/// -/// Clones are cheap (a few `Arc` bumps) and every clone feeds the same -/// buses, so producers take their own copy, exactly as they did with the -/// buses themselves. -#[derive(Debug, Clone)] -pub struct Push { - status: StatusBus, - catalog: CatalogBus, - menu: MenuBus, -} - -impl Push { - /// Wraps the buses every unsolicited push flows through. - pub(crate) fn new(status: StatusBus, catalog: CatalogBus, menu: MenuBus) -> Self { - Self { - status, - catalog, - menu, - } - } - - /// Pushes a user-visible status update: a `{"type":"status",...}` - /// `StatusFrame` at info severity with no progress. - pub fn push_status_update( - &self, - label: impl Into, - description: impl Into, - activity: Activity, - ) { - self.status.info(label, description, activity); - } - - /// Pushes a failure the user should see: a `{"type":"status",...}` - /// `StatusFrame` at error severity. - pub fn push_failure( - &self, - label: impl Into, - description: impl Into, - activity: Activity, - ) { - self.status.error(label, description, activity); - } - - /// Pushes an activity pulse the UI does not display as text: a - /// `{"type":"status",...}` `StatusFrame` at debug severity, whose - /// `activity` field drives the status bar's LED. - pub fn push_activity( - &self, - label: impl Into, - description: impl Into, - activity: Activity, - ) { - self.status.debug(label, description, activity); - } - - /// Pushes determinate progress - `current` of `total` units done: a - /// `{"type":"status",...}` `StatusFrame` at - /// [`Severity::Info`](workshop_protocol::Severity::Info) carrying a - /// [`Progress`], which the status bar renders as its progress bar. - pub(crate) fn push_progress( - &self, - label: impl Into, - description: impl Into, - current: u64, - total: u64, - activity: Activity, - ) { - self.status - .progress(label, description, Progress { current, total }, activity); - } - - /// Pushes the status bar back to its resting state: the `Ready`/`idle` - /// `{"type":"status",...}` `StatusFrame`. - pub fn push_idle(&self) { - self.status.idle(); - } - - /// Pushes one complete model catalog snapshot: a `{"type":"models",...}` - /// `CatalogFrame` carrying only chat-capable - /// entries. The single choke point for catalog publishes: the menu - /// revalidates its selection against the new catalog and republishes - /// the workbench snapshot when it changed. - pub(crate) fn push_models_catalog(&self, models: Vec) { - self.catalog.publish(models); - self.menu.reconcile_catalog(); - } - - /// The menu bus behind the facade, for producers that drive the - /// Model-menu mutators directly: the heartbeat feeds reachability - /// and the gateway's profile state through this handle. - pub(crate) fn menu(&self) -> &MenuBus { - &self.menu - } -} - -#[cfg(test)] -mod tests { - use super::*; - - use tokio::sync::broadcast; - - use workshop_protocol::{CatalogPush, Severity, StatusBarUpdate}; - /// A push handle plus one receiver on the status and catalog buses. - fn wired() -> ( - Push, - broadcast::Receiver, - broadcast::Receiver, - ) { - let status = StatusBus::new(); - let catalog = CatalogBus::new(); - let status_rx = status.subscribe(); - let catalog_rx = catalog.subscribe(); - let menu = MenuBus::new(catalog.clone(), None); - (Push::new(status, catalog, menu), status_rx, catalog_rx) - } - - /// A push handle plus the menu bus it feeds, for the workbench tests. - fn wired_with_menu() -> (Push, MenuBus) { - let catalog = CatalogBus::new(); - let menu = MenuBus::new(catalog.clone(), None); - (Push::new(StatusBus::new(), catalog, menu.clone()), menu) - } - - #[tokio::test] - async fn a_status_update_reaches_the_bus_at_info_severity() { - let (push, mut rx, _catalog_rx) = wired(); - push.push_status_update( - "Connected to gateway", - "the probe answered", - Activity::General, - ); - let update = rx.recv().await.expect("the update reaches the bus"); - assert_eq!( - update, - StatusBarUpdate { - label: "Connected to gateway".to_string(), - description: "the probe answered".to_string(), - progress: None, - severity: Severity::Info, - activity: Activity::General, - } - ); - } - - #[tokio::test] - async fn a_failure_reaches_the_bus_at_error_severity() { - let (push, mut rx, _catalog_rx) = wired(); - push.push_failure("Connection lost", "the gateway hung up", Activity::General); - let update = rx.recv().await.expect("the update reaches the bus"); - assert_eq!( - update, - StatusBarUpdate { - label: "Connection lost".to_string(), - description: "the gateway hung up".to_string(), - progress: None, - severity: Severity::Error, - activity: Activity::General, - } - ); - } - - #[tokio::test] - async fn an_activity_pulse_reaches_the_bus_at_debug_severity() { - let (push, mut rx, _catalog_rx) = wired(); - push.push_activity( - "Streaming response...", - "a gateway response chunk", - Activity::Generating, - ); - let update = rx.recv().await.expect("the update reaches the bus"); - assert_eq!( - update, - StatusBarUpdate { - label: "Streaming response...".to_string(), - description: "a gateway response chunk".to_string(), - progress: None, - severity: Severity::Debug, - activity: Activity::Generating, - } - ); - } - - #[tokio::test] - async fn progress_reaches_the_bus_with_its_current_and_total_counts() { - let (push, mut rx, _catalog_rx) = wired(); - push.push_progress( - "Downloading model", - "ggml-large-v3.bin", - 5, - 12, - Activity::General, - ); - let update = rx.recv().await.expect("the update reaches the bus"); - assert_eq!( - update, - StatusBarUpdate { - label: "Downloading model".to_string(), - description: "ggml-large-v3.bin".to_string(), - progress: Some(Progress { - current: 5, - total: 12, - }), - severity: Severity::Info, - activity: Activity::General, - } - ); - } - - #[tokio::test] - async fn idle_reaches_the_bus_as_the_resting_update() { - let (push, mut rx, _catalog_rx) = wired(); - push.push_idle(); - let update = rx.recv().await.expect("the update reaches the bus"); - assert_eq!( - update, - StatusBarUpdate { - label: "Ready".to_string(), - description: "idle".to_string(), - progress: None, - severity: Severity::Info, - activity: Activity::General, - } - ); - } - - #[tokio::test] - async fn a_models_catalog_reaches_the_bus_as_one_snapshot() { - let (push, _status_rx, mut rx) = wired(); - let models = vec![serde_json::json!({"id": "test-model", "object": "model"})]; - push.push_models_catalog(models.clone()); - let received = rx.recv().await.expect("the push reaches the bus"); - assert_eq!(received, CatalogPush { models }); - } - - #[test] - fn a_catalog_push_reconciles_the_workbench_selection() { - let (push, menu) = wired_with_menu(); - push.push_models_catalog(vec![serde_json::json!({"id": "model-a"})]); - menu.set_selected("model-a") - .expect("the id is in the catalog"); - push.push_models_catalog(vec![serde_json::json!({"id": "model-b"})]); - let snapshot = menu.latest().expect("the reconcile republished"); - assert_eq!( - snapshot.selected_model, None, - "the selection the new catalog no longer holds is revalidated away" - ); - } -} diff --git a/crates/workshop-server/src/resolve.rs b/crates/workshop-server/src/resolve.rs deleted file mode 100644 index 537b6aa2f..000000000 --- a/crates/workshop-server/src/resolve.rs +++ /dev/null @@ -1,595 +0,0 @@ -//! Gateway endpoint resolution: a live gateway discovery file in the run -//! directory first, explicit `[gateway]` config second. -//! -//! The sidecar gateway writes `gateway.json` after a successful bind (see -//! `shared-sidecar`), so a workshop that finds a live file attaches to -//! that gateway - loopback, WSL, or LAN become one topology. A stale file -//! is condemned with its reason (the probe removes it) and explicit config -//! takes over; with no live file and no explicit config there is nothing -//! to connect to, which is the plain [`ResolveError`]. - -use std::path::Path; - -use shared_sidecar::{Resolution, SidecarError, StaleReason, ValidatedConnection}; - -use workshop_protocol::Activity; -use workshop_support::GatewayConfig; - -use crate::push::Push; - -/// The gateway endpoint state construction connects to, and how it was -/// found. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ResolvedGateway { - base_url: String, - api_key: String, - identity: Option, - source: GatewaySource, - stale: Option, -} - -/// Which source won gateway endpoint resolution. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[non_exhaustive] -pub enum GatewaySource { - /// A live `gateway.json` gateway discovery file in the run directory. - GatewayDiscoveryFile, - /// Explicit `[gateway]` settings from `workshop.toml`. - Config, -} - -impl ResolvedGateway { - /// The endpoint explicit config names, with no discovery: the bypass - /// for a host that already holds its gateway endpoint, or a test - /// fixture. - #[must_use] - pub fn from_config(config: &GatewayConfig) -> Self { - Self { - base_url: config.base_url.clone(), - api_key: config.api_key.clone(), - identity: None, - source: GatewaySource::Config, - stale: None, - } - } - - #[cfg(test)] - pub(crate) fn from_validated(identity: ValidatedConnection) -> Self { - Self { - base_url: format!("http://127.0.0.1:{}", identity.port()), - api_key: identity.api_key().to_owned(), - identity: Some(identity), - source: GatewaySource::GatewayDiscoveryFile, - stale: None, - } - } - - /// The resolved base URL, for example `http://127.0.0.1:8081`. - #[must_use] - pub fn base_url(&self) -> &str { - &self.base_url - } - - /// The resolved bearer key. - #[must_use] - pub fn api_key(&self) -> &str { - &self.api_key - } - - /// The validated local Gateway boot, when discovery won. - pub(crate) fn identity(&self) -> Option<&ValidatedConnection> { - self.identity.as_ref() - } - - /// Which source won the resolution. - #[must_use] - pub fn source(&self) -> GatewaySource { - self.source - } - - /// Why a gateway discovery file was condemned on the way to the config - /// fallback, when one was. - #[must_use] - pub fn stale(&self) -> Option { - self.stale - } - - /// The winning source rendered for the status bar and the log. - #[must_use] - pub(crate) fn source_label(&self) -> &'static str { - match self.source { - GatewaySource::GatewayDiscoveryFile => "gateway discovery file", - GatewaySource::Config => "workshop.toml", - } - } -} - -/// Gateway endpoint resolution found nothing to connect to. -#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] -#[non_exhaustive] -#[error("no gateway configured or running{detail}")] -pub struct ResolveError { - /// The rendered suffix: the stale-file note and the remedy. - detail: String, - /// Why the gateway discovery file was condemned, when one was. - stale: Option, -} - -impl ResolveError { - /// The failure with the stale-file note rendered in, when a file was - /// condemned on the way. - fn new(stale: Option) -> Self { - let note = stale - .map(|reason| { - format!( - " (removed a stale gateway discovery file: {})", - stale_clause(reason) - ) - }) - .unwrap_or_default(); - Self { - detail: format!( - "{note}; start promptforge-gateway or set [gateway] base_url and api_key in workshop.toml" - ), - stale, - } - } - - /// Why the gateway discovery file was condemned, when one was: a wrong key, - /// a dead pid, and a foreign image are different problems for the - /// operator. - #[must_use] - pub fn stale(&self) -> Option { - self.stale - } -} - -/// Resolves the gateway endpoint for a loaded config: the live gateway -/// discovery file in the default run directory first, explicit `[gateway]` -/// config second. -/// -/// # Errors -/// Returns [`ResolveError`] when no live gateway discovery file exists and the -/// config carries no explicit gateway. -pub(crate) fn resolve(config: &GatewayConfig) -> Result { - resolve_with(shared_sidecar::default_run_dir().as_deref(), config, probe) -} - -/// The production probe: `shared_sidecar`'s stale-detecting resolve. -fn probe(run_dir: &Path) -> Result { - shared_sidecar::resolve(run_dir) -} - -/// `resolve` against an explicit run directory and probe, so tests point -/// at a tempdir and at a probe that accepts the test binary's own image. -fn resolve_with( - run_dir: Option<&Path>, - config: &GatewayConfig, - probe: fn(&Path) -> Result, -) -> Result { - let mut stale = None; - if let Some(run_dir) = run_dir { - match probe(run_dir) { - Ok(Resolution::Attach(file)) => match validate_resolved(file) { - Ok(identity) => { - return Ok(ResolvedGateway { - base_url: format!("http://127.0.0.1:{}", identity.port()), - api_key: identity.api_key().to_owned(), - identity: Some(identity), - source: GatewaySource::GatewayDiscoveryFile, - stale: None, - }); - } - Err(reason) => stale = Some(reason), - }, - Ok(Resolution::Stale(reason)) => { - tracing::warn!( - reason = stale_clause(reason), - "removed a stale gateway discovery file" - ); - stale = Some(reason); - } - // A read or cleanup I/O failure degrades discovery to the - // config fallback; it never fails startup on its own. - Err(error) => { - tracing::warn!("could not resolve the gateway discovery file: {error}"); - } - // Absent, and any future resolution: nothing to attach to. - _ => {} - } - } - if is_explicit(config) { - return Ok(ResolvedGateway { - base_url: config.base_url.clone(), - api_key: config.api_key.clone(), - identity: None, - source: GatewaySource::Config, - stale, - }); - } - Err(ResolveError::new(stale)) -} - -/// Reifies the shared resolver's live result as the capability stored in -/// Workshop's immutable Gateway snapshot. -fn validate_resolved( - file: shared_sidecar::GatewayDiscoveryFile, -) -> Result { - ValidatedConnection::validate(file) -} - -/// Reports the resolution outcome where the house surfaces startup state: -/// a condemned file's reason and the winning source on the status bus, -/// the same facts in the log. -pub(crate) fn report(gateway: &ResolvedGateway, push: &Push) { - if let Some(reason) = gateway.stale() { - push.push_status_update( - "Stale gateway discovery file", - format!("{}; attaching from workshop.toml", stale_clause(reason)), - Activity::General, - ); - } - push.push_status_update( - "Connecting to gateway", - format!( - "base URL {} ({})", - gateway.base_url(), - gateway.source_label() - ), - Activity::General, - ); - tracing::info!( - base_url = %gateway.base_url(), - source = gateway.source_label(), - "gateway endpoint resolved" - ); -} - -/// Whether the config names a gateway itself: a non-empty `base_url` is -/// explicit, an empty one (unset, or an unset `${PROMPTFORGE_GATEWAY_URL}` -/// interpolation) is not. The explicit fallback exists for the gateways -/// discovery cannot see - a LAN gateway; a local gateway writes a -/// gateway discovery file, which discovery finds first. -fn is_explicit(config: &GatewayConfig) -> bool { - !config.base_url.is_empty() -} - -/// Renders a stale reason as a user-facing clause: a wrong key, a dead -/// pid, and a foreign image are different problems for the operator. -fn stale_clause(reason: StaleReason) -> &'static str { - match reason { - StaleReason::Invalid => "the file was not valid", - StaleReason::ProcessDead => "the recorded gateway process is dead", - StaleReason::ImageMismatch => "the recorded pid belongs to another program", - StaleReason::HealthFailed => "the recorded gateway does not answer", - StaleReason::KeyRejected => "the file's key was rejected", - _ => "the file is stale", - } -} - -#[cfg(test)] -mod tests { - use super::*; - - use std::io::{Read, Write as _}; - use std::net::TcpListener; - - use shared_sidecar::GatewayDiscoveryFile; - - use crate::catalog::CatalogBus; - use crate::menu::MenuBus; - use crate::status::StatusBus; - - /// The test process's own image name, so the probe's pid and image - /// checks pass and the test reaches the liveness probes. - fn own_image_name() -> String { - std::env::current_exe() - .expect("current exe") - .file_name() - .expect("the exe has a file name") - .to_string_lossy() - .into_owned() - } - - /// A probe running the real liveness gauntlet against the test - /// binary's own image. - fn probe_own_image(run_dir: &Path) -> Result { - shared_sidecar::resolve_for_test(run_dir, &own_image_name()) - } - - /// A gateway discovery file pointing at the test process itself. - fn live_file(port: u16, api_key: &str) -> GatewayDiscoveryFile { - GatewayDiscoveryFile { - port, - api_key: api_key.to_owned(), - pid: std::process::id(), - epoch: 1_757_000_000, - version: "0.2.0".to_owned(), - started_at: "2026-09-03T12:00:00Z".to_owned(), - } - } - - /// A pid guaranteed dead: a short-lived child, reaped and dropped so - /// no handle keeps the process object alive. - fn dead_pid() -> u32 { - let mut child = std::process::Command::new(std::env::current_exe().expect("current exe")) - .arg("--list") - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .spawn() - .expect("spawn a short-lived child"); - let pid = child.id(); - child.wait().expect("the child exits"); - drop(child); - pid - } - - /// A fixture gateway: answers `GET /health` with 200 and the key - /// probe with 200 only when the bearer matches `expected_key`. - fn fixture_gateway(expected_key: &'static str) -> u16 { - let listener = TcpListener::bind("127.0.0.1:0").expect("bind fixture"); - let port = listener.local_addr().expect("fixture address").port(); - std::thread::spawn(move || { - while let Ok((mut stream, _)) = listener.accept() { - for _ in 0..2 { - let mut buffer = [0u8; 1024]; - let Ok(read) = stream.read(&mut buffer) else { - break; - }; - let request = String::from_utf8_lossy(&buffer[..read]); - let accepted = request.starts_with("GET /health ") - || request.contains(&format!("Authorization: Bearer {expected_key}\r\n")); - let response = if accepted { - &b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n{}"[..] - } else { - &b"HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\n\r\n"[..] - }; - if stream.write_all(response).is_err() { - break; - } - } - } - }); - port - } - - /// An explicit gateway config: a LAN URL, never the built-in default. - fn explicit_config() -> GatewayConfig { - GatewayConfig { - base_url: "http://gateway.lan:9999".to_owned(), - api_key: "config-key".to_owned(), - } - } - - #[test] - fn a_live_gateway_discovery_file_wins_over_explicit_config() { - let dir = tempfile::TempDir::new().expect("tempdir"); - let gateway = crate::test_gateway::ValidatedGateway::spawn("file-key"); - let port = gateway.port(); - gateway - .gateway_discovery_file("file-key", 1_757_000_000, "2026-09-03T12:00:00Z") - .write_to(dir.path()) - .expect("write"); - - let resolved = resolve_with(Some(dir.path()), &explicit_config(), probe) - .expect("a live file resolves"); - assert_eq!(resolved.source(), GatewaySource::GatewayDiscoveryFile); - assert_eq!(resolved.base_url(), format!("http://127.0.0.1:{port}")); - assert_eq!(resolved.api_key(), "file-key"); - assert_eq!( - resolved.identity().map(ValidatedConnection::port), - Some(port), - "the winning sidecar retains its validated identity for the initial snapshot" - ); - assert_eq!(resolved.stale(), None); - } - - #[test] - fn a_stale_file_is_cleaned_and_explicit_config_wins() { - let dir = tempfile::TempDir::new().expect("tempdir"); - let file = GatewayDiscoveryFile { - pid: dead_pid(), - ..live_file(1, "k") - }; - file.write_to(dir.path()).expect("write"); - - let resolved = resolve_with(Some(dir.path()), &explicit_config(), probe_own_image) - .expect("explicit config is the fallback"); - assert_eq!(resolved.source(), GatewaySource::Config); - assert_eq!(resolved.base_url(), "http://gateway.lan:9999"); - assert_eq!(resolved.api_key(), "config-key"); - assert_eq!(resolved.stale(), Some(StaleReason::ProcessDead)); - assert!( - !shared_sidecar::gateway_discovery_file_path(dir.path()).exists(), - "the stale file was removed" - ); - } - - #[test] - fn a_wrong_key_is_reported_distinctly_from_a_dead_pid() { - // Wrong key: the pid, image, and health checks pass; the key - // probe rejects. - let dir = tempfile::TempDir::new().expect("tempdir"); - let port = fixture_gateway("right"); - live_file(port, "wrong") - .write_to(dir.path()) - .expect("write"); - let resolved = resolve_with(Some(dir.path()), &explicit_config(), probe_own_image) - .expect("explicit config is the fallback"); - assert_eq!(resolved.stale(), Some(StaleReason::KeyRejected)); - - // Dead pid: the process check fails before any probe runs. - let dir = tempfile::TempDir::new().expect("tempdir"); - let file = GatewayDiscoveryFile { - pid: dead_pid(), - ..live_file(1, "k") - }; - file.write_to(dir.path()).expect("write"); - let resolved = resolve_with(Some(dir.path()), &explicit_config(), probe_own_image) - .expect("explicit config is the fallback"); - assert_eq!(resolved.stale(), Some(StaleReason::ProcessDead)); - } - - #[test] - fn no_file_and_no_explicit_config_is_the_plain_error() { - let dir = tempfile::TempDir::new().expect("tempdir"); - let config = GatewayConfig { - base_url: String::new(), - api_key: String::new(), - }; - let error = resolve_with(Some(dir.path()), &config, probe_own_image) - .expect_err("an empty base_url is not explicit config"); - assert!( - error - .to_string() - .contains("no gateway configured or running"), - "the error says it plainly: {error}" - ); - assert_eq!(error.stale(), None, "no file existed to condemn"); - } - - #[test] - fn an_explicitly_configured_default_url_is_honored() { - // The well-known default URL written by hand is explicit config: - // it names a gateway discovery cannot see (an SSH-tunneled remote, - // a gateway whose discovery-file write failed), so it must - // resolve, not read as an unset value. - let dir = tempfile::TempDir::new().expect("tempdir"); - let config = GatewayConfig { - base_url: "http://127.0.0.1:8081".to_owned(), - api_key: "config-key".to_owned(), - }; - let resolved = resolve_with(Some(dir.path()), &config, probe_own_image) - .expect("an explicitly configured URL resolves"); - assert_eq!(resolved.source(), GatewaySource::Config); - assert_eq!(resolved.base_url(), "http://127.0.0.1:8081"); - assert_eq!(resolved.api_key(), "config-key"); - } - - #[test] - fn a_stale_file_with_no_explicit_config_carries_the_reason_into_the_error() { - let dir = tempfile::TempDir::new().expect("tempdir"); - let port = fixture_gateway("right"); - live_file(port, "wrong") - .write_to(dir.path()) - .expect("write"); - let config = GatewayConfig { - base_url: String::new(), - api_key: String::new(), - }; - let error = resolve_with(Some(dir.path()), &config, probe_own_image) - .expect_err("no explicit config remains"); - assert_eq!(error.stale(), Some(StaleReason::KeyRejected)); - let message = error.to_string(); - assert!( - message.contains("no gateway configured or running"), - "the error says it plainly: {message}" - ); - assert!( - message.contains("key was rejected"), - "the condemned file's reason is named: {message}" - ); - } - - #[test] - fn no_file_and_explicit_config_uses_the_config() { - let dir = tempfile::TempDir::new().expect("tempdir"); - let resolved = resolve_with(Some(dir.path()), &explicit_config(), probe_own_image) - .expect("explicit config resolves"); - assert_eq!(resolved.source(), GatewaySource::Config); - assert_eq!(resolved.stale(), None); - } - - /// A probe whose discovery-file read fails: a directory sits where - /// `gateway.json` belongs, so the read errors instead of answering. - fn probe_read_failure(run_dir: &Path) -> Result { - std::fs::create_dir(run_dir.join("gateway.json")).expect("the unreadable file plants"); - shared_sidecar::resolve_for_test(run_dir, &own_image_name()) - } - - #[test] - fn a_probe_io_failure_degrades_to_the_config_fallback() { - let dir = tempfile::TempDir::new().expect("tempdir"); - let resolved = resolve_with(Some(dir.path()), &explicit_config(), probe_read_failure) - .expect("a probe failure never fails startup on its own"); - assert_eq!(resolved.source(), GatewaySource::Config); - assert_eq!(resolved.base_url(), "http://gateway.lan:9999"); - assert_eq!(resolved.stale(), None, "nothing was condemned"); - } - - #[test] - fn a_probe_io_failure_with_no_explicit_config_is_the_plain_error() { - let dir = tempfile::TempDir::new().expect("tempdir"); - let config = GatewayConfig { - base_url: String::new(), - api_key: String::new(), - }; - let error = resolve_with(Some(dir.path()), &config, probe_read_failure) - .expect_err("no explicit config remains after a probe failure"); - assert!( - error - .to_string() - .contains("no gateway configured or running"), - "the error says it plainly: {error}" - ); - assert_eq!(error.stale(), None, "a probe failure is not a condemnation"); - } - - #[test] - fn no_run_directory_skips_discovery() { - // The production probe stands in: with no run directory it is - // never called, so resolution is the config fallback or the plain - // error, and the real run directory is never consulted. - let resolved = resolve_with(None, &explicit_config(), probe) - .expect("explicit config resolves without a run directory"); - assert_eq!(resolved.source(), GatewaySource::Config); - assert_eq!(resolved.stale(), None); - - let config = GatewayConfig { - base_url: String::new(), - api_key: String::new(), - }; - let error = resolve_with(None, &config, probe) - .expect_err("no run directory and no explicit config is the plain error"); - assert!( - error - .to_string() - .contains("no gateway configured or running"), - "the error says it plainly: {error}" - ); - } - - #[test] - fn the_report_names_the_winning_source_and_a_condemned_file() { - let status = StatusBus::new(); - let catalog = CatalogBus::new(); - let menu = MenuBus::new(catalog.clone(), None); - let push = Push::new(status.clone(), catalog, menu); - let mut receiver = status.subscribe(); - - let resolved = ResolvedGateway { - base_url: "http://127.0.0.1:4000".to_owned(), - api_key: "k".to_owned(), - identity: None, - source: GatewaySource::Config, - stale: Some(StaleReason::KeyRejected), - }; - report(&resolved, &push); - - let stale_frame = receiver.try_recv().expect("the stale note is reported"); - assert_eq!(stale_frame.label, "Stale gateway discovery file"); - assert!( - stale_frame.description.contains("key was rejected"), - "the stale reason is named: {}", - stale_frame.description - ); - let connecting = receiver.try_recv().expect("the winning source is reported"); - assert_eq!(connecting.label, "Connecting to gateway"); - assert!( - connecting.description.contains("http://127.0.0.1:4000") - && connecting.description.contains("workshop.toml"), - "the endpoint and its source are named: {}", - connecting.description - ); - } -} diff --git a/crates/workshop-server/src/serve.rs b/crates/workshop-server/src/serve.rs index a34cff9cb..4dc705e59 100644 --- a/crates/workshop-server/src/serve.rs +++ b/crates/workshop-server/src/serve.rs @@ -544,7 +544,10 @@ mod tests { #[test] fn server_handle_reports_the_identity_initially_published_into_state() { let dir = tempfile::TempDir::new().expect("tempdir"); - let gateway = crate::test_gateway::ValidatedGateway::spawn("initial-key"); + let gateway = crate::test_gateway::ValidatedGateway::spawn_in( + "initial-key", + "fixtures::validated_gateway_fixture_process", + ); let identity = gateway.validate("initial-key", 1_778_000_001, "2026-09-08T18:00:01Z"); let resolved = ResolvedGateway::from_validated(identity.clone()); let server = spawn_inner( diff --git a/crates/workshop-server/src/session/menu.rs b/crates/workshop-server/src/session/menu.rs index af9e6e5d7..bfdc18b3b 100644 --- a/crates/workshop-server/src/session/menu.rs +++ b/crates/workshop-server/src/session/menu.rs @@ -12,7 +12,7 @@ use crate::gateway::{ GatewayClient, GatewayError, GatewayResponse, SwitchEvent, SwitchResponse, switch_events, }; use crate::heartbeat::{refresh_catalog, refresh_profiles}; -use crate::menu::SwitchOutcome; +use crate::menu::{MenuBus, SwitchOutcome}; use crate::push::Push; use crate::relay::value_from_bytes; use workshop_protocol::Activity; @@ -67,9 +67,10 @@ pub(super) async fn start_switch( // disconnects mid-switch. let client = state.gateway_snapshot().client().clone(); let push = state.push(); + let menu = state.menu().clone(); let name = name.to_string(); tokio::spawn(async move { - run_switch(&client, &push, &name).await; + run_switch(&client, &push, &menu, &name).await; }); } @@ -84,7 +85,7 @@ const SWITCH_STAGES: u64 = 3; /// selected and `chat_ready` recomputed on success, the truthful /// pre-switch state restored on failure - before pushing the idle or /// failure status. -async fn run_switch(client: &GatewayClient, push: &Push, name: &str) { +async fn run_switch(client: &GatewayClient, push: &Push, menu: &MenuBus, name: &str) { let outcome = drive_switch(client, push, name).await; // The gateway's serving state may have changed even on a failed // switch (its documented degraded state can lose local children), @@ -96,11 +97,11 @@ async fn run_switch(client: &GatewayClient, push: &Push, name: &str) { ); match outcome { Ok(()) => { - push.menu().finish_switch(SwitchOutcome::Completed); + menu.finish_switch(SwitchOutcome::Completed); push.push_idle(); } Err(failure) => { - push.menu().finish_switch(SwitchOutcome::Failed); + menu.finish_switch(SwitchOutcome::Failed); push.push_failure( "Profile switch failed", failure.to_string(), @@ -147,6 +148,9 @@ async fn drive_switch( Ok(SwitchResponse::Buffered(refusal)) => { return Err(SwitchFailure::Refused(switch_refusal(&refusal))); } + // A variant this build does not know: the gateway may grow + // response shapes, and a lost switch never degrades the server. + Ok(_) => return Err(SwitchFailure::StreamEnded), Err(error) => return Err(SwitchFailure::Transport(error)), }; let mut events = switch_events(payloads); @@ -155,6 +159,9 @@ async fn drive_switch( Ok(SwitchEvent::Stage { stage }) => push_stage(push, name, &stage), Ok(SwitchEvent::Ready { .. }) => return Ok(()), Ok(SwitchEvent::Error { message }) => return Err(SwitchFailure::Failed(message)), + // An event variant this build does not know is skipped, the + // same degradation a malformed payload gets. + Ok(_) => {} Err(error) => return Err(SwitchFailure::Transport(error)), } } diff --git a/crates/workshop-server/src/session_agents.rs b/crates/workshop-server/src/session_agents.rs index 03da04c0b..2a9b84b0d 100644 --- a/crates/workshop-server/src/session_agents.rs +++ b/crates/workshop-server/src/session_agents.rs @@ -808,6 +808,19 @@ mod tests { use super::*; + /// A push facade wired to the real buses through the registry, with + /// the registrations kept alive by the returned guards. + fn wired_push( + status: &crate::status::StatusBus, + catalog: &CatalogBus, + menu: &MenuBus, + ) -> (Push, impl std::fmt::Debug + Send + Sync + 'static) { + let registry = workshop_registry::Registry::new(); + let status_guards = workshop_status::register(®istry, status); + let menu_guards = workshop_menu::register(®istry, catalog, menu); + (registry.push(), (status_guards, menu_guards)) + } + #[test] fn discovery_lists_sorted_lua_stems_and_tolerates_a_missing_dir() { let dir = tempfile::TempDir::new().expect("tempdir"); @@ -975,17 +988,14 @@ mod tests { std::fs::write(dir.path().join("echo.lua"), "return 1").expect("seed echo"); let catalog = CatalogBus::default(); let menu = MenuBus::new(catalog.clone(), None); + let (push, _guards) = wired_push(&crate::status::StatusBus::new(), &catalog, &menu); let sessions = AgentSessions::new( dir.path().to_path_buf(), dir.path().join("sessions"), GatewayBinding::new("http://127.0.0.1:1", "") .expect("the unusable model binding still builds its HTTP client"), SessionHost { - push: Push::new( - crate::status::StatusBus::new(), - catalog.clone(), - menu.clone(), - ), + push, backoff: ReconnectBackoff::new(), menu, workspace: Workspace::new(), @@ -1013,13 +1023,14 @@ mod tests { let mut status_rx = status.subscribe(); let catalog = CatalogBus::new(); let menu = MenuBus::new(catalog.clone(), None); + let (push, _guards) = wired_push(&status, &catalog, &menu); let (errors, mut errors_rx) = broadcast::channel(ERROR_CAPACITY); let (supervisor_events, _events) = mpsc::unbounded_channel(); let (cancellations, _cancellation_events) = mpsc::channel(lifecycle::CANCELLATION_CAPACITY); let observer = SessionObserver { log: Arc::new(WorkshopObserver::new(None).expect("a memory log")), rounds: Arc::new(AtomicU64::new(0)), - push: Push::new(status, catalog, menu), + push, backoff: ReconnectBackoff::new(), errors, lifecycle: Arc::new(RunLifecycle::new(supervisor_events, cancellations)), diff --git a/crates/workshop-server/tests/it/heartbeat_loop.rs b/crates/workshop-server/tests/it/heartbeat_loop.rs new file mode 100644 index 000000000..7233d014f --- /dev/null +++ b/crates/workshop-server/tests/it/heartbeat_loop.rs @@ -0,0 +1,488 @@ +//! The heartbeat loop's behavior against the real status, catalog, and +//! menu buses: transition announcements, refresh-on-reconnect, startup +//! convergence, and the backoff's anti-flap rule. These tests compose +//! `workshop-gateway`'s heartbeat with `workshop-status` and +//! `workshop-menu`'s buses through the registry's push facade - the +//! composition only the shell can make, so they live in its integration +//! binary rather than in any one subsystem crate. + +// clippy.toml's allow-expect-in-tests covers #[test] functions and +// #[cfg(test)] modules only, not integration-test helpers; failing a test +// by panicking with the invariant named is exactly what these are for. +#![expect( + clippy::expect_used, + reason = "test helpers fail by panicking with the invariant named" +)] + +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::time::Duration; + +use axum::Router; +use axum::extract::State; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::routing::get; +use tokio::sync::broadcast; + +use workshop_gateway::{GatewayBinding, GatewayHealth, Heartbeat}; +use workshop_menu::{CatalogBus, MenuBus}; +use workshop_protocol::{CatalogPush, Severity, StatusBarUpdate, WorkbenchSnapshot}; +use workshop_registry::{ + CatalogSink, MenuSink, Push, Registration, Registry, StatusChannel, StatusSink, +}; +use workshop_status::StatusBus; +use workshop_support::ReconnectBackoff; + +/// The registration guards keeping the test's sink adapters alive. +type Guards = ( + Registration, + Registration, + Registration, + Registration, +); + +/// Wires the buses into a fresh registry and returns the push facade +/// plus the guards keeping the registrations alive. +fn wired_push(status: &StatusBus, catalog: &CatalogBus, menu: &MenuBus) -> (Push, Guards) { + let registry = Registry::new(); + let (status_channel, status_sink) = workshop_status::register(®istry, status); + let (catalog_sink, menu_sink) = workshop_menu::register(®istry, catalog, menu); + ( + registry.push(), + (status_channel, status_sink, catalog_sink, menu_sink), + ) +} + +/// Fast enough to observe transitions without real waiting, slow +/// enough that a 200 ms quiet window spans several ticks and so proves +/// the loop does not re-emit a steady state. +const TEST_INTERVAL: Duration = Duration::from_millis(25); + +const CATALOG: &str = + r#"{"object":"list","data":[{"id":"test-model","object":"model","owned_by":"promptforge"}]}"#; + +/// A mock `/health` whose answer flips under test control. +async fn flippable_health(State(healthy): State>) -> Response { + if healthy.load(Ordering::Relaxed) { + StatusCode::OK.into_response() + } else { + StatusCode::SERVICE_UNAVAILABLE.into_response() + } +} + +/// A static mock catalog for the refresh-on-reconnect tests. +async fn mock_models() -> Response { + ( + [(axum::http::header::CONTENT_TYPE, "application/json")], + CATALOG, + ) + .into_response() +} + +/// A static mock profile list for the profile-populate tests. +async fn mock_profiles() -> Response { + ( + [(axum::http::header::CONTENT_TYPE, "application/json")], + r#"{"profiles":["coding","main"]}"#, + ) + .into_response() +} + +/// A static mock gateway status naming the active profile. +async fn mock_profile_status() -> Response { + ( + [(axum::http::header::CONTENT_TYPE, "application/json")], + r#"{"profile":"main","models":["test-model"]}"#, + ) + .into_response() +} + +/// Binds a mock gateway whose `/health` flips with `healthy`, with a +/// static `/v1/models` and the profile endpoints beside it. +async fn spawn_gateway(healthy: Arc) -> String { + let app = Router::new() + .route("/health", get(flippable_health)) + .route("/v1/models", get(mock_models)) + .route("/admin/profiles", get(mock_profiles)) + .route("/admin/status", get(mock_profile_status)) + .with_state(healthy); + serve(app).await +} + +/// Binds `app` on a free loopback port and returns its base URL. +async fn serve(app: Router) -> String { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind mock gateway"); + let addr = listener.local_addr().expect("mock gateway address"); + tokio::spawn(async move { + axum::serve(listener, app) + .await + .expect("mock gateway serves"); + }); + format!("http://{addr}") +} + +/// A backoff fast enough that a down-phase probe retries within a few +/// ticks, with a budget no test exhausts by accident. +fn test_backoff() -> ReconnectBackoff { + ReconnectBackoff::with_schedule( + Duration::from_millis(10), + Duration::from_millis(40), + Duration::from_secs(60), + ) +} + +/// Starts a heartbeat against `base_url` on the fast interval, wired to +/// `status` and `catalog`; returns the handle, the shared health flag, +/// the menu bus the heartbeat feeds, its reconnect backoff, and the +/// guards keeping the sink registrations alive. +fn heartbeat_on( + base_url: &str, + status: &StatusBus, + catalog: &CatalogBus, +) -> (Heartbeat, GatewayHealth, MenuBus, ReconnectBackoff, Guards) { + let gateway = + GatewayBinding::new_with_identity(base_url, "", None).expect("binding builds in tests"); + let health = GatewayHealth::new(); + let menu = MenuBus::new(catalog.clone(), None); + let backoff = test_backoff(); + let (push, guards) = wired_push(status, catalog, &menu); + let heartbeat = workshop_gateway::heartbeat::spawn( + gateway, + push, + health.clone(), + TEST_INTERVAL, + backoff.clone(), + ); + (heartbeat, health, menu, backoff, guards) +} + +/// Receives the next status update within a generous deadline. +async fn next_update(rx: &mut broadcast::Receiver) -> StatusBarUpdate { + tokio::time::timeout(Duration::from_secs(5), rx.recv()) + .await + .expect("a status update arrives within the deadline") + .expect("the status bus is open") +} + +/// Asserts no update arrives within a window spanning several ticks. +async fn assert_quiet(rx: &mut broadcast::Receiver) { + let quiet = tokio::time::timeout(Duration::from_millis(200), rx.recv()).await; + assert!( + quiet.is_err(), + "a steady state must not re-emit, got {quiet:?}" + ); +} + +/// Polls the retained menu snapshot until `accept` holds, within a +/// generous deadline. Polling the retained copy rather than +/// subscribing sidesteps the race between the heartbeat's publishes +/// and the test's subscription. +async fn snapshot_where( + menu: &MenuBus, + accept: impl Fn(&WorkbenchSnapshot) -> bool, +) -> WorkbenchSnapshot { + tokio::time::timeout(Duration::from_secs(5), async { + loop { + if let Some(snapshot) = menu.latest() + && accept(&snapshot) + { + return snapshot; + } + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await + .expect("a matching snapshot is retained within the deadline") +} + +#[tokio::test] +async fn a_healthy_gateway_fires_connected_once_and_stays_quiet() { + let healthy = Arc::new(AtomicBool::new(true)); + let base_url = spawn_gateway(Arc::clone(&healthy)).await; + let status = StatusBus::new(); + let catalog = CatalogBus::new(); + let mut rx = status.subscribe(); + let (heartbeat, health, _menu, _backoff, _guards) = heartbeat_on(&base_url, &status, &catalog); + + let update = next_update(&mut rx).await; + assert_eq!(update.label, "Connected to gateway"); + assert_eq!(update.severity, Severity::Info); + assert_eq!(update.activity, workshop_protocol::Activity::General); + assert!(health.is_reachable(), "the probe published reachable"); + assert_quiet(&mut rx).await; + heartbeat.shutdown().await; +} + +#[tokio::test] +async fn an_unreachable_gateway_fires_unreachable_once_and_stays_quiet() { + // Nothing listens on port 1, so the connect fails deterministically. + let status = StatusBus::new(); + let catalog = CatalogBus::new(); + let mut rx = status.subscribe(); + let (heartbeat, health, _menu, _backoff, _guards) = + heartbeat_on("http://127.0.0.1:1", &status, &catalog); + + let update = next_update(&mut rx).await; + assert_eq!(update.label, "Gateway unreachable"); + assert_eq!(update.severity, Severity::Info); + assert_eq!(update.activity, workshop_protocol::Activity::General); + assert!(!health.is_reachable(), "the probe published unreachable"); + assert_quiet(&mut rx).await; + heartbeat.shutdown().await; +} + +#[tokio::test] +async fn each_transition_fires_exactly_one_update() { + let healthy = Arc::new(AtomicBool::new(true)); + let base_url = spawn_gateway(Arc::clone(&healthy)).await; + let status = StatusBus::new(); + let catalog = CatalogBus::new(); + let mut rx = status.subscribe(); + let (heartbeat, health, _menu, _backoff, _guards) = heartbeat_on(&base_url, &status, &catalog); + + assert_eq!(next_update(&mut rx).await.label, "Connected to gateway"); + healthy.store(false, Ordering::Relaxed); + assert_eq!(next_update(&mut rx).await.label, "Gateway unreachable"); + assert!(!health.is_reachable()); + healthy.store(true, Ordering::Relaxed); + assert_eq!(next_update(&mut rx).await.label, "Connected to gateway"); + assert!(health.is_reachable()); + assert_quiet(&mut rx).await; + heartbeat.shutdown().await; +} + +#[tokio::test] +async fn a_reconnect_pushes_the_refreshed_catalog() { + let healthy = Arc::new(AtomicBool::new(false)); + let base_url = spawn_gateway(Arc::clone(&healthy)).await; + let status = StatusBus::new(); + let catalog = CatalogBus::new(); + let mut status_rx = status.subscribe(); + let mut catalog_rx = catalog.subscribe(); + let (heartbeat, _health, _menu, _backoff, _guards) = heartbeat_on(&base_url, &status, &catalog); + + assert_eq!( + next_update(&mut status_rx).await.label, + "Gateway unreachable" + ); + healthy.store(true, Ordering::Relaxed); + assert_eq!( + next_update(&mut status_rx).await.label, + "Connected to gateway" + ); + let push: CatalogPush = tokio::time::timeout(Duration::from_secs(5), catalog_rx.recv()) + .await + .expect("the refreshed catalog arrives within the deadline") + .expect("the catalog bus is open"); + assert_eq!( + push.models, + serde_json::json!([{"id": "test-model", "object": "model", "owned_by": "promptforge"}]) + .as_array() + .expect("the fixture is an array") + .clone(), + "the push carries every chat-capable gateway model" + ); + heartbeat.shutdown().await; +} + +#[tokio::test] +async fn a_reconnect_whose_refresh_is_declined_pushes_no_catalog() { + // No /v1/models route: the refresh is declined with a 404, and a + // declined refresh is skipped rather than pushed - pushing it + // would empty pickers that still hold a usable list. + let healthy = Arc::new(AtomicBool::new(false)); + let base_url = serve( + Router::new() + .route("/health", get(flippable_health)) + .with_state(Arc::clone(&healthy)), + ) + .await; + let status = StatusBus::new(); + let catalog = CatalogBus::new(); + let mut status_rx = status.subscribe(); + let mut catalog_rx = catalog.subscribe(); + let (heartbeat, _health, _menu, _backoff, _guards) = heartbeat_on(&base_url, &status, &catalog); + + assert_eq!( + next_update(&mut status_rx).await.label, + "Gateway unreachable" + ); + healthy.store(true, Ordering::Relaxed); + assert_eq!( + next_update(&mut status_rx).await.label, + "Connected to gateway" + ); + let quiet = tokio::time::timeout(Duration::from_millis(200), catalog_rx.recv()).await; + assert!(quiet.is_err(), "a declined refresh is skipped, not pushed"); + heartbeat.shutdown().await; +} + +#[tokio::test] +async fn a_down_to_up_transition_publishes_a_populated_snapshot() { + let healthy = Arc::new(AtomicBool::new(false)); + let base_url = spawn_gateway(Arc::clone(&healthy)).await; + let status = StatusBus::new(); + let catalog = CatalogBus::new(); + let mut status_rx = status.subscribe(); + let (heartbeat, _health, menu, _backoff, _guards) = heartbeat_on(&base_url, &status, &catalog); + + assert_eq!( + next_update(&mut status_rx).await.label, + "Gateway unreachable" + ); + healthy.store(true, Ordering::Relaxed); + let populated = snapshot_where(&menu, |snapshot| !snapshot.profiles.is_empty()).await; + assert_eq!(populated.profiles, ["coding", "main"]); + assert_eq!(populated.active.as_deref(), Some("main")); + heartbeat.shutdown().await; +} + +#[tokio::test] +async fn a_gateway_without_profile_support_publishes_an_empty_list() { + // Only /health exists: the profile endpoints answer 404, which + // is a state, not an error - the reconnect publishes an empty + // list rather than keeping the stale names. + let healthy = Arc::new(AtomicBool::new(false)); + let base_url = serve( + Router::new() + .route("/health", get(flippable_health)) + .with_state(Arc::clone(&healthy)), + ) + .await; + let status = StatusBus::new(); + let catalog = CatalogBus::new(); + let mut status_rx = status.subscribe(); + let (heartbeat, _health, menu, _backoff, _guards) = heartbeat_on(&base_url, &status, &catalog); + + assert_eq!( + next_update(&mut status_rx).await.label, + "Gateway unreachable" + ); + menu.set_profiles(vec!["stale".to_string()], Some("stale".to_string())); + healthy.store(true, Ordering::Relaxed); + let emptied = snapshot_where(&menu, |snapshot| snapshot.profiles.is_empty()).await; + assert_eq!(emptied.active, None, "the stale active profile clears"); + heartbeat.shutdown().await; +} + +#[tokio::test] +async fn reachability_transitions_flip_chat_ready() { + let healthy = Arc::new(AtomicBool::new(true)); + let base_url = spawn_gateway(Arc::clone(&healthy)).await; + let status = StatusBus::new(); + let catalog = CatalogBus::new(); + // Readiness needs a non-empty catalog and a selection; the mock + // catalog holds test-model, so a reconnect's refresh keeps it. + catalog.publish(vec![serde_json::json!({"id": "test-model"})]); + let mut status_rx = status.subscribe(); + let (heartbeat, _health, menu, _backoff, _guards) = heartbeat_on(&base_url, &status, &catalog); + + assert_eq!( + next_update(&mut status_rx).await.label, + "Connected to gateway" + ); + menu.set_selected("test-model") + .expect("the id is in the catalog"); + snapshot_where(&menu, |snapshot| snapshot.chat_ready).await; + + healthy.store(false, Ordering::Relaxed); + let down = snapshot_where(&menu, |snapshot| !snapshot.chat_ready).await; + assert_eq!( + down.selected_model.as_deref(), + Some("test-model"), + "only reachability flipped; the selection survives the outage" + ); + + healthy.store(true, Ordering::Relaxed); + snapshot_where(&menu, |snapshot| snapshot.chat_ready).await; + heartbeat.shutdown().await; +} + +#[tokio::test] +async fn a_mere_connect_keeps_the_backoff_escalated() { + // The anti-flap rule this step exists for: an outage escalates the + // backoff, and a gateway that answers its probe again (connects + // without delivering any useful work) must leave the escalation + // standing, so the next outage keeps the slow schedule. + let healthy = Arc::new(AtomicBool::new(false)); + let base_url = spawn_gateway(Arc::clone(&healthy)).await; + let status = StatusBus::new(); + let catalog = CatalogBus::new(); + let mut rx = status.subscribe(); + let (heartbeat, health, _menu, backoff, _guards) = heartbeat_on(&base_url, &status, &catalog); + + assert_eq!(next_update(&mut rx).await.label, "Gateway unreachable"); + healthy.store(true, Ordering::Relaxed); + assert_eq!(next_update(&mut rx).await.label, "Connected to gateway"); + assert!(health.is_reachable()); + assert!( + backoff.is_escalated_for_test(), + "reconnecting without useful work must not reset the backoff" + ); + heartbeat.shutdown().await; +} + +#[tokio::test] +async fn an_exhausted_budget_stops_reconnect_probes_with_a_give_up_report() { + let healthy = Arc::new(AtomicBool::new(false)); + let base_url = spawn_gateway(Arc::clone(&healthy)).await; + let status = StatusBus::new(); + let catalog = CatalogBus::new(); + let mut rx = status.subscribe(); + let gateway = + GatewayBinding::new_with_identity(&base_url, "", None).expect("binding builds in tests"); + let health = GatewayHealth::new(); + let menu = MenuBus::new(catalog.clone(), None); + // A budget of a few schedule steps: exhausted within a handful of + // failed probes, well inside the test deadline. + let backoff = ReconnectBackoff::with_schedule( + Duration::from_millis(10), + Duration::from_millis(20), + Duration::from_millis(50), + ); + let (push, _guards) = wired_push(&status, &catalog, &menu); + let heartbeat = + workshop_gateway::heartbeat::spawn(gateway, push, health.clone(), TEST_INTERVAL, backoff); + + assert_eq!(next_update(&mut rx).await.label, "Gateway unreachable"); + let report = next_update(&mut rx).await; + assert_eq!(report.label, "Gateway reconnect stopped"); + assert_eq!(report.severity, Severity::Error); + // The gateway coming back after the give-up changes nothing: the + // loop has ended, so no probe ever notices. + healthy.store(true, Ordering::Relaxed); + assert_quiet(&mut rx).await; + assert!( + !health.is_reachable(), + "an ended loop leaves the last verdict standing" + ); + heartbeat.shutdown().await; +} + +#[tokio::test] +async fn shutdown_stops_the_task_without_waiting_out_the_interval() { + // A long interval: if the stop signal did not win the select, the + // shutdown would block for the whole minute. + let status = StatusBus::new(); + let gateway = GatewayBinding::new_with_identity("http://127.0.0.1:1", "", None) + .expect("binding builds in tests"); + let catalog = CatalogBus::new(); + let menu = MenuBus::new(catalog.clone(), None); + let (push, _guards) = wired_push(&status, &catalog, &menu); + let heartbeat = workshop_gateway::heartbeat::spawn( + gateway, + push, + GatewayHealth::new(), + Duration::from_secs(60), + ReconnectBackoff::new(), + ); + tokio::time::timeout(Duration::from_secs(5), heartbeat.shutdown()) + .await + .expect("shutdown does not wait out the interval"); +} + +mod recovery; +mod startup_convergence; diff --git a/crates/workshop-server/src/heartbeat/tests/recovery.rs b/crates/workshop-server/tests/it/heartbeat_loop/recovery.rs similarity index 87% rename from crates/workshop-server/src/heartbeat/tests/recovery.rs rename to crates/workshop-server/tests/it/heartbeat_loop/recovery.rs index 326924139..30315e03c 100644 --- a/crates/workshop-server/src/heartbeat/tests/recovery.rs +++ b/crates/workshop-server/tests/it/heartbeat_loop/recovery.rs @@ -22,16 +22,18 @@ async fn a_replaced_endpoint_wakes_the_heartbeat_and_refreshes_with_its_new_key( .route("/admin/status", get(mock_profile_status)), ) .await; - let gateway = GatewayBinding::new("http://127.0.0.1:1", "old-key").expect("binding builds"); + let gateway = GatewayBinding::new_with_identity("http://127.0.0.1:1", "old-key", None) + .expect("binding builds"); let status = StatusBus::new(); let catalog = CatalogBus::new(); let menu = MenuBus::new(catalog.clone(), None); let health = GatewayHealth::new(); let mut status_rx = status.subscribe(); let mut catalog_rx = catalog.subscribe(); - let heartbeat = spawn( + let (push, _guards) = wired_push(&status, &catalog, &menu); + let heartbeat = workshop_gateway::heartbeat::spawn( gateway.clone(), - Push::new(status, catalog, menu), + push, health.clone(), Duration::from_secs(60), test_backoff(), diff --git a/crates/workshop-server/src/heartbeat/tests/startup_convergence.rs b/crates/workshop-server/tests/it/heartbeat_loop/startup_convergence.rs similarity index 94% rename from crates/workshop-server/src/heartbeat/tests/startup_convergence.rs rename to crates/workshop-server/tests/it/heartbeat_loop/startup_convergence.rs index f321f9462..2b0fd18c8 100644 --- a/crates/workshop-server/src/heartbeat/tests/startup_convergence.rs +++ b/crates/workshop-server/tests/it/heartbeat_loop/startup_convergence.rs @@ -63,7 +63,7 @@ async fn the_initial_connect_populates_the_profile_state() { let base_url = spawn_gateway(Arc::clone(&healthy)).await; let status = StatusBus::new(); let catalog = CatalogBus::new(); - let (heartbeat, _health, menu, _backoff) = heartbeat_on(&base_url, &status, &catalog); + let (heartbeat, _health, menu, _backoff, _guards) = heartbeat_on(&base_url, &status, &catalog); let populated = snapshot_where(&menu, |snapshot| !snapshot.profiles.is_empty()).await; assert_eq!(populated.profiles, ["coding", "main"]); @@ -78,7 +78,7 @@ async fn the_initial_connect_pushes_the_catalog_and_readies_chat() { let status = StatusBus::new(); let catalog = CatalogBus::new(); let mut catalog_rx = catalog.subscribe(); - let (heartbeat, _health, menu, _backoff) = heartbeat_on(&base_url, &status, &catalog); + let (heartbeat, _health, menu, _backoff, _guards) = heartbeat_on(&base_url, &status, &catalog); let push: CatalogPush = tokio::time::timeout(Duration::from_secs(5), catalog_rx.recv()) .await @@ -118,7 +118,7 @@ async fn a_healthy_gateway_retries_refresh_until_its_catalog_is_ready() { .await; let status = StatusBus::new(); let catalog = CatalogBus::new(); - let (heartbeat, health, menu, _backoff) = heartbeat_on(&base_url, &status, &catalog); + let (heartbeat, health, menu, _backoff, _guards) = heartbeat_on(&base_url, &status, &catalog); tokio::time::timeout(Duration::from_secs(5), async { while state.requests.load(Ordering::Relaxed) < 1 { @@ -170,7 +170,7 @@ async fn a_healthy_gateway_waits_for_profiles_after_its_catalog_is_ready() { .await; let status = StatusBus::new(); let catalog = CatalogBus::new(); - let (heartbeat, health, menu, _backoff) = heartbeat_on(&base_url, &status, &catalog); + let (heartbeat, health, menu, _backoff, _guards) = heartbeat_on(&base_url, &status, &catalog); tokio::time::timeout(Duration::from_secs(5), async { loop { diff --git a/crates/workshop-server/tests/it/main.rs b/crates/workshop-server/tests/it/main.rs index 36d0a9495..d0dd7ab68 100644 --- a/crates/workshop-server/tests/it/main.rs +++ b/crates/workshop-server/tests/it/main.rs @@ -7,6 +7,7 @@ mod common; mod agents; mod chat_gate; mod heartbeat; +mod heartbeat_loop; mod observer; mod realtime_relay; mod session; diff --git a/crates/workshop-status/Cargo.toml b/crates/workshop-status/Cargo.toml new file mode 100644 index 000000000..5e5055959 --- /dev/null +++ b/crates/workshop-status/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "workshop-status" +version = "0.0.0" +publish = false +edition.workspace = true +license.workspace = true +repository.workspace = true + +description = "Workshop status subsystem: the status-bar broadcast bus and the progress renderer that drives the bar's indicator from the process progress hub" + +[dependencies] +shared-progress.workspace = true +tokio.workspace = true +workshop-protocol.workspace = true +workshop-registry.workspace = true +workshop-support.workspace = true + +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt-multi-thread", "test-util"] } + +[lints] +workspace = true diff --git a/crates/workshop-status/src/lib.rs b/crates/workshop-status/src/lib.rs new file mode 100644 index 000000000..188d84e08 --- /dev/null +++ b/crates/workshop-status/src/lib.rs @@ -0,0 +1,64 @@ +//! workshop-status - the status-bar subsystem: a broadcast bus carrying +//! status updates from every subsystem to every connected `/ws` session, +//! and the renderer task that turns the process progress hub's snapshots +//! into the status bar's progress indicator. +//! +//! ## Invariants +//! +//! - Tier: service; may depend on: `workshop-protocol`, `workshop-registry`, +//! `workshop-support`. Read `AGENTS.md` before adding an import. +//! - Every file in this crate stays under 500 lines; split first, then +//! edit. +//! - Sending on the bus never blocks: a send with no subscribers is a +//! no-op, and a lagging subscriber skips ahead, so instrumenting a hot +//! path cannot stall the subsystem it observes. +//! - The bus retains the newest update, so a session that connects later +//! sends the current status immediately - the delivery contract's +//! resend-on-reconnect for ephemeral frames. +//! - The public API is infallible (sends are no-ops on lag or empty +//! rings, never errors), so the crate carries no thiserror error type - +//! the same exemption `workshop-registry` takes. + +pub mod status; + +pub mod progress; + +use std::sync::Arc; + +pub use status::StatusBus; +use workshop_registry::{ + Registration, Registry, StatusChannel, StatusChannelAdapter, StatusSink, StatusSinkAdapter, +}; + +/// Registers the status subsystem's two channels into the registry: the +/// consumer-side push channel every `/ws` session subscribes through, and +/// the producer-side sink same-tier subsystems emit through. The returned +/// guards keep the registrations alive; the composition root holds them +/// for the process lifetime. +pub fn register( + registry: &Registry, + bus: &StatusBus, +) -> ( + Registration, + Registration, +) { + let channel = registry + .status() + .register(Arc::new(StatusChannelAdapter::new( + { + let bus = bus.clone(); + move || bus.subscribe() + }, + { + let bus = bus.clone(); + move || bus.latest() + }, + ))); + let sink = registry + .status_sink() + .register(Arc::new(StatusSinkAdapter::new({ + let bus = bus.clone(); + move |update| bus.emit(update) + }))); + (channel, sink) +} diff --git a/crates/workshop-status/src/progress.rs b/crates/workshop-status/src/progress.rs new file mode 100644 index 000000000..7da77da35 --- /dev/null +++ b/crates/workshop-status/src/progress.rs @@ -0,0 +1,214 @@ +//! The hub-to-status-bar renderer: a task that samples the process +//! [`ProgressHub`] and drives the status bar's progress indicator through +//! [`Push`], so the anti-flicker policy lives here and the UI stays dumb. +//! +//! The indicator appears only once an operation has been live for +//! `SHOW_DELAY`, stays up at least `MIN_VISIBLE` once shown, and +//! displays the monotonic aggregate from [`ProgressMeter`]: the bar never +//! flashes for sub-second work, never resets mid-operation, and never +//! steps backward. Status texts ("Listening...", failures) stay explicit +//! push calls in the subsystems that own them; the trees own only +//! fractional progress. When the hub's last tree detaches, the renderer +//! returns the bar to rest with [`Push::push_idle`]. + +use std::sync::Arc; +use std::time::Duration; + +use tokio::sync::broadcast::error::RecvError; +use tokio::sync::oneshot; +use tokio::time::Instant; + +use shared_progress::{ProgressHub, ProgressMeter}; + +use workshop_protocol::Activity; +use workshop_registry::Push; + +/// How long an operation must be live before the indicator appears; work +/// shorter than this never disturbs the status bar. +pub(crate) const SHOW_DELAY: Duration = Duration::from_secs(1); + +/// How long the indicator stays up once shown, so an operation that ends +/// just past [`SHOW_DELAY`] still reads as a completed bar, not a flash. +pub(crate) const MIN_VISIBLE: Duration = Duration::from_millis(500); + +/// How often the renderer re-samples while the indicator is up: a tree's +/// detach emits no event, so only a poll notices the last tree leaving. +const DETACH_POLL: Duration = Duration::from_millis(100); + +/// The `total` every pushed frame carries; fractions quantize to +/// millionths of it. +const PROGRESS_TOTAL: u64 = 1_000_000; + +/// A running renderer task. +/// +/// [`Renderer::shutdown`] signals the task to stop and awaits it. Dropping +/// the handle without shutting down still stops the task at its next +/// select point, because the closed channel resolves the stop branch. +#[derive(Debug)] +pub struct Renderer { + stop: Option>, + task: Option>, +} + +impl Renderer { + /// Signals the renderer to stop and waits for its task to finish. + pub async fn shutdown(mut self) { + if let Some(stop) = self.stop.take() { + let _ = stop.send(()); + } + if let Some(task) = self.task.take() { + let _ = task.await; + } + } +} + +/// Spawns the renderer task against `hub`, pushing through `push`. +#[must_use] +pub fn spawn(hub: Arc, push: Push) -> Renderer { + let (stop, mut stopped) = oneshot::channel(); + let task = tokio::spawn(async move { + run(&hub, &push, &mut stopped).await; + }); + Renderer { + stop: Some(stop), + task: Some(task), + } +} + +/// The task loop: re-sample on every hub event and on every anti-flicker +/// deadline. A lagged receiver simply re-samples - snapshots are the +/// ground truth and intermediate events are lossy by design. +async fn run(hub: &ProgressHub, push: &Push, stop: &mut oneshot::Receiver<()>) { + let mut events = hub.subscribe(); + let mut indicator = Indicator::default(); + // Catches operations that attached before the subscription. + indicator.update(hub, push); + loop { + tokio::select! { + _ = &mut *stop => break, + event = events.recv() => { + // The hub lives in AppState for the process lifetime, so + // Closed cannot occur in production; treat it as a stop. + if matches!(event, Err(RecvError::Closed)) { + break; + } + } + () = wake_at(indicator.next_wake(Instant::now())) => {} + } + indicator.update(hub, push); + } +} + +/// Waits for `at`, or forever when there is no pending deadline. +async fn wake_at(at: Option) { + match at { + Some(at) => tokio::time::sleep_until(at).await, + None => std::future::pending().await, + } +} + +/// The anti-flicker state machine over the hub's snapshots. +#[derive(Debug, Default)] +struct Indicator { + meter: ProgressMeter, + /// When the current run of live operations began; back-to-back + /// operations share one run, so the bar never flickers between them. + live_since: Option, + /// When the indicator was shown; held until the idle push lands. + shown_since: Option, + /// The last frame pushed, so the detach poll never re-pushes an + /// unchanged sample. + last_frame: Option<(String, u64)>, +} + +impl Indicator { + /// Samples the hub and pushes whatever transition the sample calls for. + fn update(&mut self, hub: &ProgressHub, push: &Push) { + let now = Instant::now(); + let Some(fraction) = self.meter.sample(hub) else { + self.live_since = None; + if let Some(shown) = self.shown_since + && now.duration_since(shown) >= MIN_VISIBLE + { + self.shown_since = None; + self.last_frame = None; + push.push_idle(); + } + return; + }; + let live_since = *self.live_since.get_or_insert(now); + if self.shown_since.is_none() && now.duration_since(live_since) < SHOW_DELAY { + return; + } + // Every leaf finished but the tree still lives: the detach (and + // the idle push) is imminent, so the last frame stands. + let Some(label) = hub.headline() else { + return; + }; + self.shown_since.get_or_insert(now); + let current = quantize(fraction); + // The meter resets its high-water mark on an idle sample, so a new + // operation attaching while the bar holds for MIN_VISIBLE after a + // drain would restart the visible bar at the new operation's zero. + // The last pushed frame is the floor until the new operation rises + // past it: within a run the meter is already monotonic, so the floor + // only ever bites across a drain. + let current = self + .last_frame + .as_ref() + .map_or(current, |(_, shown)| current.max(*shown)); + if self + .last_frame + .as_ref() + .is_some_and(|(l, c)| l == &label && *c == current) + { + return; + } + self.last_frame = Some((label.clone(), current)); + push.push_progress( + label.clone(), + label, + current, + PROGRESS_TOTAL, + Activity::General, + ); + } + + /// The next moment `update` can change state without a hub event: the + /// show deadline while an operation warms up, the detach poll while + /// the indicator is up, or the earliest idle moment once the hub has + /// drained under a still-visible bar. + fn next_wake(&self, now: Instant) -> Option { + match (self.live_since, self.shown_since) { + (Some(live), None) => { + let deadline = live + SHOW_DELAY; + // A lapsed deadline with the bar unshown means every leaf + // finished but the tree still lives; poll for the detach + // instead of re-arming a past instant, which would spin + // the select loop. + Some(if deadline > now { + deadline + } else { + now + DETACH_POLL + }) + } + (Some(_), Some(_)) => Some(now + DETACH_POLL), + (None, Some(shown)) => Some(shown + MIN_VISIBLE), + (None, None) => None, + } + } +} + +/// Quantizes a `0.0..=1.0` fraction to `current` of [`PROGRESS_TOTAL`]. +#[expect( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss, + reason = "the clamped fraction lands the cast in 0..=PROGRESS_TOTAL" +)] +fn quantize(fraction: f64) -> u64 { + (fraction.clamp(0.0, 1.0) * PROGRESS_TOTAL as f64).round() as u64 +} + +#[cfg(test)] +mod tests; diff --git a/crates/workshop-status/src/progress/tests.rs b/crates/workshop-status/src/progress/tests.rs new file mode 100644 index 000000000..39c8c5570 --- /dev/null +++ b/crates/workshop-status/src/progress/tests.rs @@ -0,0 +1,289 @@ +use super::*; + +use tokio::sync::broadcast; + +use crate::StatusBus; +use workshop_protocol::{Progress, Severity, StatusBarUpdate}; +use workshop_registry::{Registration, Registry, StatusSink}; + +/// A hub, a push handle whose status sink is a real bus, the status +/// receiver the renderer's frames land on, and the registration +/// keeping the sink alive. +fn wired() -> ( + Arc, + Push, + broadcast::Receiver, + Registration, +) { + let hub = Arc::new(ProgressHub::new()); + let status = StatusBus::new(); + let rx = status.subscribe(); + let registry = Registry::new(); + let (_channel, sink) = crate::register(®istry, &status); + (hub, registry.push(), rx, sink) +} + +/// Lets the renderer task run everything currently pending. +async fn settle() { + for _ in 0..3 { + tokio::task::yield_now().await; + } +} + +#[tokio::test(start_paused = true)] +async fn a_sub_second_operation_never_reaches_the_status_bar() { + let (hub, push, mut rx, _sink) = wired(); + let renderer = spawn(Arc::clone(&hub), push); + let tree = hub.operation(); + let leaf = tree.register("download", 1.0); + leaf.set_fraction(0.5); + settle().await; + leaf.complete(); + drop(tree); + settle().await; + tokio::time::advance(SHOW_DELAY * 2).await; + settle().await; + assert!( + rx.try_recv().is_err(), + "a sub-second operation must never show the indicator" + ); + renderer.shutdown().await; +} + +#[tokio::test(start_paused = true)] +async fn an_operation_outliving_the_show_delay_pushes_the_headline_and_aggregate() { + let (hub, push, mut rx, _sink) = wired(); + let renderer = spawn(Arc::clone(&hub), push); + let tree = hub.operation(); + let download = tree.register("download", 3.0); + let verify = tree.register("verify", 1.0); + download.set_fraction(1.0); + verify.set_fraction(0.5); + settle().await; + tokio::time::advance(SHOW_DELAY.saturating_sub(Duration::from_millis(1))).await; + settle().await; + assert!(rx.try_recv().is_err(), "the bar waits out the show delay"); + tokio::time::advance(Duration::from_millis(2)).await; + settle().await; + let update = rx + .try_recv() + .expect("the bar appears once the delay lapses"); + assert_eq!( + update.label, "verify", + "the headline is the unfinished leaf" + ); + assert_eq!( + update.progress, + Some(Progress { + current: 875_000, + total: PROGRESS_TOTAL, + }), + "the weighted aggregate: (3*1.0 + 1*0.5) / 4" + ); + assert_eq!(update.severity, Severity::Info); + assert_eq!(update.activity, Activity::General); + renderer.shutdown().await; +} + +#[tokio::test(start_paused = true)] +async fn the_indicator_holds_for_the_minimum_visible_time_after_the_hub_drains() { + let (hub, push, mut rx, _sink) = wired(); + let renderer = spawn(Arc::clone(&hub), push); + let tree = hub.operation(); + let leaf = tree.register("download", 1.0); + leaf.set_fraction(0.5); + settle().await; + tokio::time::advance(SHOW_DELAY).await; + settle().await; + let shown = rx + .try_recv() + .expect("the bar appears once the delay lapses"); + assert!(shown.progress.is_some()); + + drop(tree); + tokio::time::advance(DETACH_POLL).await; + settle().await; + assert!( + rx.try_recv().is_err(), + "the idle push waits out the minimum visible time" + ); + tokio::time::advance(MIN_VISIBLE).await; + settle().await; + let idle = rx + .try_recv() + .expect("the bar clears once the minimum has passed"); + assert_eq!(idle.label, "Ready"); + assert_eq!(idle.progress, None); + renderer.shutdown().await; +} + +#[tokio::test(start_paused = true)] +async fn a_lapsed_show_deadline_polls_for_the_detach_instead_of_rearming_the_past() { + let live = Instant::now(); + let indicator = Indicator { + live_since: Some(live), + ..Indicator::default() + }; + let now = live + SHOW_DELAY + Duration::from_secs(1); + assert_eq!( + indicator.next_wake(now), + Some(now + DETACH_POLL), + "a past deadline re-armed would resolve instantly and spin the loop" + ); +} + +#[tokio::test(start_paused = true)] +async fn a_tree_finished_before_the_delay_and_held_past_it_never_reaches_the_status_bar() { + let (hub, push, mut rx, _sink) = wired(); + let renderer = spawn(Arc::clone(&hub), push); + let tree = hub.operation(); + let leaf = tree.register("download", 1.0); + leaf.complete(); + settle().await; + tokio::time::advance(SHOW_DELAY * 2).await; + settle().await; + assert!( + rx.try_recv().is_err(), + "a finished tree has no headline, so the bar never shows" + ); + drop(tree); + tokio::time::advance(DETACH_POLL * 2).await; + settle().await; + assert!( + rx.try_recv().is_err(), + "a bar that never showed pushes no idle frame" + ); + renderer.shutdown().await; +} + +#[tokio::test(start_paused = true)] +async fn an_unchanged_sample_is_not_repushed_by_the_detach_poll() { + let (hub, push, mut rx, _sink) = wired(); + let renderer = spawn(Arc::clone(&hub), push); + let tree = hub.operation(); + let leaf = tree.register("download", 1.0); + leaf.set_fraction(0.5); + settle().await; + tokio::time::advance(SHOW_DELAY).await; + settle().await; + let first = rx + .try_recv() + .expect("the bar appears once the delay lapses"); + assert!(first.progress.is_some()); + tokio::time::advance(DETACH_POLL * 5).await; + settle().await; + assert!( + rx.try_recv().is_err(), + "the detach poll re-samples without re-pushing an unchanged frame" + ); + leaf.set_fraction(0.75); + settle().await; + let update = rx.try_recv().expect("a real change pushes"); + assert_eq!( + update.progress, + Some(Progress { + current: 750_000, + total: PROGRESS_TOTAL, + }) + ); + renderer.shutdown().await; +} + +#[tokio::test(start_paused = true)] +async fn an_operation_attached_before_the_spawn_is_caught_by_the_first_sample() { + let (hub, push, mut rx, _sink) = wired(); + // The tree attaches before the renderer subscribes: only the + // initial sample catches it, since its Begun predates the + // subscription. + let tree = hub.operation(); + let leaf = tree.register("download", 1.0); + leaf.set_fraction(0.5); + let renderer = spawn(Arc::clone(&hub), push); + settle().await; + tokio::time::advance(SHOW_DELAY).await; + settle().await; + let update = rx + .try_recv() + .expect("the pre-attached operation still reaches the bar"); + assert_eq!(update.label, "download"); + assert_eq!( + update.progress, + Some(Progress { + current: 500_000, + total: PROGRESS_TOTAL, + }) + ); + renderer.shutdown().await; +} + +#[tokio::test(start_paused = true)] +async fn a_lagged_event_receiver_resamples_from_the_snapshot() { + let (hub, push, mut rx, _sink) = wired(); + let renderer = spawn(Arc::clone(&hub), push); + // Overflow the hub's 1024-event ring so the renderer's receiver + // lags: the lag must fall through to a re-sample, not stall the + // renderer. + let tree = hub.operation(); + let _leaves: Vec<_> = (0..1100) + .map(|index| tree.register(&format!("leaf-{index}"), 1.0)) + .collect(); + settle().await; + tokio::time::advance(SHOW_DELAY).await; + settle().await; + let update = rx + .try_recv() + .expect("the bar still appears after the receiver lagged"); + assert!( + update.progress.is_some(), + "the re-sampled snapshot drives the bar" + ); + renderer.shutdown().await; +} + +#[tokio::test(start_paused = true)] +async fn an_operation_attaching_during_the_minimum_visible_hold_does_not_step_the_bar_backward() { + let (hub, push, mut rx, _sink) = wired(); + let renderer = spawn(Arc::clone(&hub), push); + let tree = hub.operation(); + let leaf = tree.register("first", 1.0); + leaf.set_fraction(0.5); + settle().await; + tokio::time::advance(SHOW_DELAY).await; + settle().await; + let shown = rx + .try_recv() + .expect("the bar appears once the delay lapses"); + assert_eq!( + shown.progress, + Some(Progress { + current: 500_000, + total: PROGRESS_TOTAL, + }) + ); + + // The first operation drains while the bar is up; the minimum + // visible hold keeps the bar showing. + drop(tree); + tokio::time::advance(DETACH_POLL).await; + settle().await; + + // A new operation attaching during the hold continues from the + // drained level: the meter reset on the idle sample, so without + // the floor the visible bar would restart at zero. + let second = hub.operation(); + let _leaf = second.register("second", 1.0); + settle().await; + let continued = rx + .try_recv() + .expect("the new operation pushes under the held bar"); + assert_eq!(continued.label, "second"); + assert_eq!( + continued.progress, + Some(Progress { + current: 500_000, + total: PROGRESS_TOTAL, + }), + "the bar never steps backward" + ); + renderer.shutdown().await; +} diff --git a/crates/workshop-server/src/status.rs b/crates/workshop-status/src/status.rs similarity index 93% rename from crates/workshop-server/src/status.rs rename to crates/workshop-status/src/status.rs index 8f403b98e..7cb240440 100644 --- a/crates/workshop-server/src/status.rs +++ b/crates/workshop-status/src/status.rs @@ -6,7 +6,7 @@ //! it is doing as a [`StatusBarUpdate`]. The bus is a [`RetainedBus`]: //! updates fan out to all current subscribers, a send with no //! subscribers is a no-op, and a subscriber that falls more than -//! [`STATUS_CHANNEL_CAPACITY`] updates behind is told it lagged and resumes +//! `STATUS_CHANNEL_CAPACITY` updates behind is told it lagged and resumes //! at the oldest retained update. Sending never blocks, so instrumenting a //! hot path cannot stall the subsystem it observes. //! @@ -37,20 +37,23 @@ pub struct StatusBus { impl StatusBus { /// Creates a bus with no subscribers, an empty ring, and no snapshot. - pub(crate) fn new() -> Self { + #[must_use] + pub fn new() -> Self { Self { bus: RetainedBus::new(STATUS_CHANNEL_CAPACITY), } } /// Subscribes to every update sent from this call onward. - pub(crate) fn subscribe(&self) -> broadcast::Receiver { + #[must_use] + pub fn subscribe(&self) -> broadcast::Receiver { self.bus.subscribe() } /// The most recently emitted update, retained so a session connecting /// later can send the current status as its snapshot. - pub(crate) fn latest(&self) -> Option { + #[must_use] + pub fn latest(&self) -> Option { self.bus.latest() } @@ -61,7 +64,7 @@ impl StatusBus { } /// Broadcasts one progress-free update at the given severity. - pub(crate) fn report( + pub fn report( &self, label: impl Into, description: impl Into, @@ -78,7 +81,7 @@ impl StatusBus { } /// Broadcasts a user-visible status text. - pub(crate) fn info( + pub fn info( &self, label: impl Into, description: impl Into, @@ -89,7 +92,7 @@ impl StatusBus { /// Broadcasts a user-visible update carrying determinate progress, /// which the status bar renders as its progress bar. - pub(crate) fn progress( + pub fn progress( &self, label: impl Into, description: impl Into, @@ -107,7 +110,7 @@ impl StatusBus { /// Broadcasts an internal instrumentation pulse the UI does not /// display. - pub(crate) fn debug( + pub fn debug( &self, label: impl Into, description: impl Into, @@ -117,7 +120,7 @@ impl StatusBus { } /// Broadcasts a failure the user should see. - pub(crate) fn error( + pub fn error( &self, label: impl Into, description: impl Into, @@ -127,7 +130,7 @@ impl StatusBus { } /// Returns the bar to its resting state. - pub(crate) fn idle(&self) { + pub fn idle(&self) { self.info("Ready", "idle", Activity::General); } } diff --git a/vibe/2026-09-12-3-workshop-server-decomposition.md b/vibe/2026-09-12-3-workshop-server-decomposition.md index 4408340ee..d983106d7 100644 --- a/vibe/2026-09-12-3-workshop-server-decomposition.md +++ b/vibe/2026-09-12-3-workshop-server-decomposition.md @@ -411,17 +411,17 @@ Verification: `cargo clippy -p workshop-protocol -p workshop-support -p workshop -### Step 4: Extract service crates +### Step 4: Extract service crates [completed] - Component: Server Decomposition Extract all three domain service crates. These depend only on tier-0 vocabulary crates. -**workshop-gateway** (~4.5k lines): Extract from `workshop-server/src/gateway.rs`, `workshop-server/src/gateway_binding.rs`, `workshop-server/src/gateway_progress.rs`, `workshop-server/src/resolve.rs`, `workshop-server/src/heartbeat.rs`, `workshop-server/src/observer.rs`. HTTP client, endpoint binding, discovery, heartbeat, progress subscriber, relay, test seam. Domain crate never exposes an axum type in its public API. Self-registers routes, state handle, and background tasks (heartbeat, progress) into `workshop-registry`. +**workshop-gateway** (~4.5k lines): Extract from `workshop-server/src/gateway.rs`, `workshop-server/src/gateway_binding.rs`, `workshop-server/src/gateway_progress.rs`, `workshop-server/src/resolve.rs`, `workshop-server/src/heartbeat.rs`, `workshop-server/src/observer.rs`. HTTP client, endpoint binding, discovery, heartbeat, progress subscriber, relay, test seam. Domain crate never exposes an axum type in its public API. Reports through the registry's push facade; its route, state-handle, and background-task (heartbeat, progress) registration into `workshop-registry` is deferred to step 5, which owns the composition-root `register()` calls. -**workshop-status** (~1.7k lines): Extract from `workshop-server/src/status.rs`, `workshop-server/src/progress.rs`. Status-bar broadcast bus (now backed by generic `RetainedBus`), progress renderer, event log. Self-registers push channel and state handle. +**workshop-status** (~1.7k lines): Extract from `workshop-server/src/status.rs`, `workshop-server/src/progress.rs`. Status-bar broadcast bus (now backed by generic `RetainedBus`), progress renderer, event log. Self-registers push channel and producer sink; state-handle registration is deferred to step 5's AppState decomposition. -**workshop-menu** (~1.1k lines): Extract from `workshop-server/src/menu.rs`, `workshop-server/src/catalog.rs`. Model menu snapshot, catalog channel (backed by `RetainedBus`). Self-registers push channel and state handle. +**workshop-menu** (~1.1k lines): Extract from `workshop-server/src/menu.rs`, `workshop-server/src/catalog.rs`. Model menu snapshot, catalog channel (backed by `RetainedBus`). Self-registers push channel and producer sinks; state-handle registration is deferred to step 5's AppState decomposition. Each crate gets its own concrete error type derived with `thiserror`, `#[non_exhaustive]`. Update `workshop-server` to depend on all three service crates. Update `xtask` allowed-dependency tables. From 08370dd716194a32c6c17a8ea90c7262deee4ee1 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Sat, 12 Sep 2026 14:51:29 -0700 Subject: [PATCH 05/19] Extract workshop sessions and workspace feature crates Split the two remaining feature subsystems out of the server shell into their own crates: the workbench and agent-session sockets, run supervision, input waits, and the catalog relay into one, and the jailed workspace filesystem behind the workspace routes into the other. The shell's shared state stops naming subsystem fields and becomes a composition root: every subsystem is built at startup, self-registers its routes and state handles into the registry, and is reached through the registry's slots thereafter. Each feature crate maps its own failures to the wire envelope at its route boundary, so the shell's error type shrinks to the shell's own routes. - `Registry` gains seven slots - two per-subsystem route registrars, four state-handle providers, and the workspace granted-roots handle - plus closure adapters for the sealed traits, so registrants plug their routes and handles in without implementing a registry trait. - `SessionsState` receives every collaborator as a constructor parameter, including the shell's WebSocket origin policy as a plain function, so the cross-site guard stays the shell's security boundary while the subsystem owns the upgrade handlers. - `SessionHost` drops its workspace field and reads the granted roots through the registry's roots slot; the `ui()` snapshot serves null while the slot is empty. - `AppState` shrinks to the reconnect backoff, the progress hub, the registry, and the registration guards; its accessors downcast the registered handle sets and panic on a missing registration, a composition-root bug rather than a runtime condition. - `WorkspaceError` renders its own wire envelope at the workspace route boundary, and the shell's `AppError` drops the sixteen workspace variants, the `From` seam, and the `GatewayUnreachable` variant the relay now answers as a 502 itself. - `/ws`, `/agents/ws`, `/v1/models`, and `/workspace/*` stay mounted, merged from the registered routers, and `POST /chat` still answers 404, pinned by the tests that moved with the extraction. - `serve.rs` splits its tests into `serve/tests.rs`, keeping every file under the 500-line ceiling. Design: new registry @ crates/workshop-registry/src/registry.rs::Registry Design: new speculative-abstraction @ crates/workshop-registry/src/traits.rs::WorkspaceRoots Design: ambient-context -> service-locator @ crates/workshop-server/src/app.rs::AppState Design: new constructor-injection @ crates/workshop-sessions/src/state.rs::SessionsState deps: AgentSessions,CatalogBus,GatewayBinding,GatewayHealth,MenuBus,Registry,fn(&HeaderMap) -> bool instead-of: layer-violation: the feature crate importing the shell's cross_site module Design: new constructor-injection @ crates/workshop-sessions/src/agents.rs::SessionHost deps: CatalogBus,MenuBus,ReconnectBackoff,Registry instead-of: layer-violation: naming the workspace crate for the ui() snapshot's granted roots Design: new facade @ crates/workshop-sessions/src/lib.rs Design: new facade @ crates/workshop-workspace/src/lib.rs Design: extends facade @ crates/workshop-server/src/lib.rs Deferred: workshop-sessions registers no push channels or background tasks into the registry Repairs: a crate's doctests compile against the crate itself @ crates/workshop-gateway/src/observer.rs - three WorkshopObserver examples imported workshop_server::WorkshopObserver, an import the gateway crate cannot resolve Plan: vibe/2026-09-12-3-workshop-server-decomposition.md --- Cargo.lock | 59 +- Cargo.toml | 2 + crates/workshop-gateway/src/handles.rs | 51 + crates/workshop-gateway/src/lib.rs | 2 + crates/workshop-gateway/src/observer.rs | 6 +- crates/workshop-menu/src/lib.rs | 56 +- crates/workshop-registry/src/lib.rs | 4 +- crates/workshop-registry/src/registry.rs | 81 +- crates/workshop-registry/src/traits.rs | 115 ++ crates/workshop-registry/tests/it/main.rs | 73 + crates/workshop-server/Cargo.toml | 28 +- crates/workshop-server/src/app.rs | 417 +++--- crates/workshop-server/src/app/fixtures.rs | 84 ++ crates/workshop-server/src/app/tests.rs | 103 ++ crates/workshop-server/src/error.rs | 310 +--- crates/workshop-server/src/input.rs | 1025 ------------- crates/workshop-server/src/lib.rs | 21 +- crates/workshop-server/src/relay.rs | 176 --- crates/workshop-server/src/routes.rs | 7 +- crates/workshop-server/src/routes/chat.rs | 72 - .../workshop-server/src/routes/workspace.rs | 20 - crates/workshop-server/src/serve.rs | 290 +--- crates/workshop-server/src/serve/tests.rs | 275 ++++ crates/workshop-server/src/session_agents.rs | 1140 --------------- crates/workshop-server/src/workspace.rs | 1272 ----------------- crates/workshop-server/tests/it/chat_gate.rs | 2 +- .../tests/it/heartbeat_loop.rs | 17 +- crates/workshop-sessions/Cargo.toml | 48 + .../agents/chat.md | 0 crates/workshop-sessions/src/agents.rs | 419 ++++++ .../src/agents}/lifecycle.rs | 0 .../workshop-sessions/src/agents/session.rs | 467 ++++++ .../src/agents}/socket.rs | 38 +- .../src/agents}/supervisor.rs | 5 +- .../src/agents}/supervisor/catalog.rs | 2 +- .../src/agents}/supervisor/effects.rs | 27 +- .../src/agents}/supervisor/events.rs | 4 +- .../src/agents}/supervisor/transition.rs | 42 +- .../agents}/supervisor/transition/tests.rs | 0 crates/workshop-sessions/src/agents/tests.rs | 337 +++++ crates/workshop-sessions/src/input.rs | 318 +++++ crates/workshop-sessions/src/input/tests.rs | 448 ++++++ crates/workshop-sessions/src/input/tool.rs | 279 ++++ crates/workshop-sessions/src/lib.rs | 39 + crates/workshop-sessions/src/relay.rs | 107 ++ crates/workshop-sessions/src/relay/tests.rs | 159 +++ .../src/session.rs | 61 +- .../src/session/log.rs | 0 .../src/session/menu.rs | 21 +- crates/workshop-sessions/src/state.rs | 144 ++ crates/workshop-sessions/tests/it/main.rs | 154 ++ crates/workshop-status/src/lib.rs | 23 +- crates/workshop-status/src/progress/tests.rs | 2 +- crates/workshop-workspace/Cargo.toml | 28 + crates/workshop-workspace/src/error.rs | 200 +++ crates/workshop-workspace/src/error/tests.rs | 171 +++ crates/workshop-workspace/src/handlers.rs | 164 +++ .../workshop-workspace/src/handlers/tests.rs | 120 ++ crates/workshop-workspace/src/lib.rs | 61 + crates/workshop-workspace/src/workspace.rs | 477 +++++++ .../workshop-workspace/src/workspace/tests.rs | 464 ++++++ crates/workshop-workspace/tests/it/main.rs | 73 + ...6-09-12-3-workshop-server-decomposition.md | 2 +- 63 files changed, 5925 insertions(+), 4687 deletions(-) create mode 100644 crates/workshop-gateway/src/handles.rs create mode 100644 crates/workshop-server/src/app/fixtures.rs create mode 100644 crates/workshop-server/src/app/tests.rs delete mode 100644 crates/workshop-server/src/input.rs delete mode 100644 crates/workshop-server/src/relay.rs delete mode 100644 crates/workshop-server/src/routes/chat.rs delete mode 100644 crates/workshop-server/src/routes/workspace.rs create mode 100644 crates/workshop-server/src/serve/tests.rs delete mode 100644 crates/workshop-server/src/session_agents.rs delete mode 100644 crates/workshop-server/src/workspace.rs create mode 100644 crates/workshop-sessions/Cargo.toml rename crates/{workshop-server => workshop-sessions}/agents/chat.md (100%) create mode 100644 crates/workshop-sessions/src/agents.rs rename crates/{workshop-server/src/session_agents => workshop-sessions/src/agents}/lifecycle.rs (100%) create mode 100644 crates/workshop-sessions/src/agents/session.rs rename crates/{workshop-server/src/session_agents => workshop-sessions/src/agents}/socket.rs (95%) rename crates/{workshop-server/src/session_agents => workshop-sessions/src/agents}/supervisor.rs (97%) rename crates/{workshop-server/src/session_agents => workshop-sessions/src/agents}/supervisor/catalog.rs (97%) rename crates/{workshop-server/src/session_agents => workshop-sessions/src/agents}/supervisor/effects.rs (97%) rename crates/{workshop-server/src/session_agents => workshop-sessions/src/agents}/supervisor/events.rs (98%) rename crates/{workshop-server/src/session_agents => workshop-sessions/src/agents}/supervisor/transition.rs (91%) rename crates/{workshop-server/src/session_agents => workshop-sessions/src/agents}/supervisor/transition/tests.rs (100%) create mode 100644 crates/workshop-sessions/src/agents/tests.rs create mode 100644 crates/workshop-sessions/src/input.rs create mode 100644 crates/workshop-sessions/src/input/tests.rs create mode 100644 crates/workshop-sessions/src/input/tool.rs create mode 100644 crates/workshop-sessions/src/lib.rs create mode 100644 crates/workshop-sessions/src/relay.rs create mode 100644 crates/workshop-sessions/src/relay/tests.rs rename crates/{workshop-server => workshop-sessions}/src/session.rs (83%) rename crates/{workshop-server => workshop-sessions}/src/session/log.rs (100%) rename crates/{workshop-server => workshop-sessions}/src/session/menu.rs (95%) create mode 100644 crates/workshop-sessions/src/state.rs create mode 100644 crates/workshop-sessions/tests/it/main.rs create mode 100644 crates/workshop-workspace/Cargo.toml create mode 100644 crates/workshop-workspace/src/error.rs create mode 100644 crates/workshop-workspace/src/error/tests.rs create mode 100644 crates/workshop-workspace/src/handlers.rs create mode 100644 crates/workshop-workspace/src/handlers/tests.rs create mode 100644 crates/workshop-workspace/src/lib.rs create mode 100644 crates/workshop-workspace/src/workspace.rs create mode 100644 crates/workshop-workspace/src/workspace/tests.rs create mode 100644 crates/workshop-workspace/tests/it/main.rs diff --git a/Cargo.lock b/Cargo.lock index 7edc764c5..4a2d81eca 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8617,14 +8617,10 @@ name = "workshop-server" version = "0.3.0" dependencies = [ "anyhow", - "arc-swap", - "async-trait", "axum", "build-ui", - "dunce", "futures-util", "open", - "percent-encoding", "promptforge-agent", "promptforge-core", "promptforge-core-support", @@ -8632,7 +8628,6 @@ dependencies = [ "promptforge-tool-picker", "promptforge-tools", "promptforge-vfs", - "rand 0.9.5", "reqwest 0.12.28", "rust-embed", "serde", @@ -8640,13 +8635,11 @@ dependencies = [ "shared-loopback", "shared-progress", "shared-sidecar", - "shared-vfs", "socket2", "tempfile", "thiserror 2.0.19", "tokio", "tokio-tungstenite", - "toml 0.8.2", "tower", "tracing", "tracing-subscriber", @@ -8656,6 +8649,40 @@ dependencies = [ "workshop-protocol", "workshop-registry", "workshop-server", + "workshop-sessions", + "workshop-status", + "workshop-support", + "workshop-workspace", +] + +[[package]] +name = "workshop-sessions" +version = "0.0.0" +dependencies = [ + "async-trait", + "axum", + "futures-util", + "promptforge-agent", + "promptforge-core", + "promptforge-core-support", + "promptforge-model-client", + "promptforge-tool-picker", + "promptforge-tools", + "promptforge-vfs", + "rand 0.9.5", + "serde", + "serde_json", + "shared-vfs", + "tempfile", + "thiserror 2.0.19", + "tokio", + "tokio-tungstenite", + "tower", + "tracing", + "workshop-gateway", + "workshop-menu", + "workshop-protocol", + "workshop-registry", "workshop-status", "workshop-support", ] @@ -8685,6 +8712,24 @@ dependencies = [ "tracing", ] +[[package]] +name = "workshop-workspace" +version = "0.0.0" +dependencies = [ + "axum", + "dunce", + "percent-encoding", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.19", + "tokio", + "tower", + "workshop-protocol", + "workshop-registry", + "workshop-support", +] + [[package]] name = "wrapcenum-derive" version = "0.4.1" diff --git a/Cargo.toml b/Cargo.toml index a696f553d..bdbc1cf4e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -53,6 +53,8 @@ workshop-protocol = { path = "crates/workshop-protocol", version = "0.0.0" } workshop-status = { path = "crates/workshop-status", version = "0.0.0" } workshop-support = { path = "crates/workshop-support", version = "0.0.0" } workshop-registry = { path = "crates/workshop-registry", version = "0.0.0" } +workshop-sessions = { path = "crates/workshop-sessions", version = "0.0.0" } +workshop-workspace = { path = "crates/workshop-workspace", version = "0.0.0" } gateway-whisper-ffi = { path = "crates/gateway-whisper-ffi", version = "0.3.0" } promptforge-agent = { path = "crates/promptforge-agent", version = "0.3.0" } pulldown-cmark = "0.12" diff --git a/crates/workshop-gateway/src/handles.rs b/crates/workshop-gateway/src/handles.rs new file mode 100644 index 000000000..b63736c55 --- /dev/null +++ b/crates/workshop-gateway/src/handles.rs @@ -0,0 +1,51 @@ +//! The gateway subsystem's state handles: the replaceable endpoint +//! binding and the reachability flag, bundled for registration into the +//! subsystem registry so the composition root fetches them by slot +//! instead of holding them by name. + +use std::sync::Arc; + +use workshop_registry::{Registration, Registry, StateProvider, StateProviderAdapter}; + +use crate::gateway_binding::GatewayBinding; +use crate::heartbeat::GatewayHealth; + +/// The gateway subsystem's shared handles: the atomically replaceable +/// endpoint binding every gateway call snapshots, and the heartbeat's +/// reachability flag the gateway-dependent routes short-circuit on. +#[derive(Debug, Clone)] +pub struct GatewayHandles { + binding: GatewayBinding, + health: GatewayHealth, +} + +impl GatewayHandles { + /// Bundles the binding and the health flag for registration. + #[must_use] + pub fn new(binding: GatewayBinding, health: GatewayHealth) -> Self { + Self { binding, health } + } + + /// The replaceable endpoint binding. + #[must_use] + pub fn binding(&self) -> &GatewayBinding { + &self.binding + } + + /// The heartbeat's shared reachability flag. + #[must_use] + pub fn health(&self) -> &GatewayHealth { + &self.health + } +} + +/// Registers the gateway subsystem's state handles into the registry. +/// The returned guard keeps the registration alive; the composition +/// root holds it for the process lifetime. +pub fn register(registry: &Registry, handles: GatewayHandles) -> Registration { + registry + .gateway_state() + .register(Arc::new(StateProviderAdapter::new(move || { + Arc::new(handles.clone()) as Arc + }))) +} diff --git a/crates/workshop-gateway/src/lib.rs b/crates/workshop-gateway/src/lib.rs index b3e84fb0e..86c0592da 100644 --- a/crates/workshop-gateway/src/lib.rs +++ b/crates/workshop-gateway/src/lib.rs @@ -20,6 +20,7 @@ pub mod gateway; pub mod gateway_binding; pub mod gateway_progress; +pub mod handles; pub mod heartbeat; pub mod observer; pub mod resolve; @@ -33,6 +34,7 @@ pub use gateway::{ pub use gateway_binding::{ GatewayBinding, GatewayPublicationError, GatewaySnapshot, GatewayUpdater, }; +pub use handles::{GatewayHandles, register}; pub use heartbeat::{GatewayHealth, Heartbeat}; pub use observer::WorkshopObserver; pub use resolve::{GatewaySource, ResolveError, ResolvedGateway}; diff --git a/crates/workshop-gateway/src/observer.rs b/crates/workshop-gateway/src/observer.rs index 181b6961c..63944dc23 100644 --- a/crates/workshop-gateway/src/observer.rs +++ b/crates/workshop-gateway/src/observer.rs @@ -121,7 +121,7 @@ impl WorkshopObserver { /// ``` /// use promptforge_core_support::events::EventLog; /// use promptforge_core_support::observe::Observer; - /// use workshop_server::WorkshopObserver; + /// use workshop_gateway::WorkshopObserver; /// /// let log = WorkshopObserver::new(None)?; /// log.on_user_input("run", "chat", "hello"); @@ -153,7 +153,7 @@ impl WorkshopObserver { /// ``` /// use promptforge_core_support::events::EventLog; /// use promptforge_core_support::observe::Observer; - /// use workshop_server::WorkshopObserver; + /// use workshop_gateway::WorkshopObserver; /// /// let dir = tempfile::TempDir::new()?; /// let path = dir.path().join("events.jsonl"); @@ -189,7 +189,7 @@ impl WorkshopObserver { /// # Examples /// ``` /// use promptforge_core_support::observe::Observer; - /// use workshop_server::WorkshopObserver; + /// use workshop_gateway::WorkshopObserver; /// /// let log = WorkshopObserver::new(None)?; /// let mut entries = log.subscribe(); diff --git a/crates/workshop-menu/src/lib.rs b/crates/workshop-menu/src/lib.rs index 4926e009c..67d5220df 100644 --- a/crates/workshop-menu/src/lib.rs +++ b/crates/workshop-menu/src/lib.rs @@ -29,18 +29,54 @@ pub use catalog::{CatalogBus, ChatCatalog, is_chat_capable}; pub use menu::{MenuBus, MenuRefusal, SwitchOutcome}; use workshop_registry::{ CatalogSink, CatalogSinkAdapter, MenuSink, MenuSinkAdapter, Registration, Registry, + StateProvider, StateProviderAdapter, }; -/// Registers the menu subsystem's producer sinks into the registry: the -/// catalog channel's receiving end and the workbench mutators, which -/// same-tier subsystems (the gateway heartbeat's refreshes) drive through -/// the registry's push facade. The returned guards keep the registrations -/// alive; the composition root holds them for the process lifetime. +/// The menu subsystem's state handles: the chat model catalog channel +/// and the workbench bus, bundled for registration into the subsystem +/// registry so the composition root fetches them by slot instead of +/// holding them by name. +#[derive(Debug, Clone)] +pub struct MenuHandles { + catalog: CatalogBus, + menu: MenuBus, +} + +impl MenuHandles { + /// Bundles the catalog channel and the menu bus for registration. + #[must_use] + pub fn new(catalog: CatalogBus, menu: MenuBus) -> Self { + Self { catalog, menu } + } + + /// The chat model catalog channel. + #[must_use] + pub fn catalog(&self) -> &CatalogBus { + &self.catalog + } + + /// The workbench menu bus. + #[must_use] + pub fn menu(&self) -> &MenuBus { + &self.menu + } +} + +/// Registers the menu subsystem into the registry: the catalog +/// channel's receiving end and the workbench mutators, which same-tier +/// subsystems (the gateway heartbeat's refreshes) drive through the +/// registry's push facade, plus the subsystem's state handles. The +/// returned guards keep the registrations alive; the composition root +/// holds them for the process lifetime. pub fn register( registry: &Registry, catalog: &CatalogBus, menu: &MenuBus, -) -> (Registration, Registration) { +) -> ( + Registration, + Registration, + Registration, +) { let catalog_guard = registry .catalog_sink() .register(Arc::new(CatalogSinkAdapter::new({ @@ -65,5 +101,11 @@ pub fn register( move || menu.reconcile_catalog() }, ))); - (catalog_guard, menu_guard) + let state = registry + .menu_state() + .register(Arc::new(StateProviderAdapter::new({ + let handles = MenuHandles::new(catalog.clone(), menu.clone()); + move || Arc::new(handles.clone()) as Arc + }))); + (catalog_guard, menu_guard, state) } diff --git a/crates/workshop-registry/src/lib.rs b/crates/workshop-registry/src/lib.rs index 74e79fefe..367c6f949 100644 --- a/crates/workshop-registry/src/lib.rs +++ b/crates/workshop-registry/src/lib.rs @@ -31,6 +31,6 @@ pub use registry::Registry; pub use slot::{ProxySlot, Registration}; pub use traits::{ BackgroundTasks, CatalogSink, CatalogSinkAdapter, MenuSink, MenuSinkAdapter, RouteRegistrar, - ShutdownHook, StateProvider, StatusChannel, StatusChannelAdapter, StatusSink, - StatusSinkAdapter, + RouteRegistrarAdapter, ShutdownHook, StateProvider, StateProviderAdapter, StatusChannel, + StatusChannelAdapter, StatusSink, StatusSinkAdapter, WorkspaceRoots, WorkspaceRootsAdapter, }; diff --git a/crates/workshop-registry/src/registry.rs b/crates/workshop-registry/src/registry.rs index c48558077..65c58a763 100644 --- a/crates/workshop-registry/src/registry.rs +++ b/crates/workshop-registry/src/registry.rs @@ -7,7 +7,7 @@ use crate::push::Push; use crate::slot::ProxySlot; use crate::traits::{ BackgroundTasks, CatalogSink, MenuSink, RouteRegistrar, ShutdownHook, StateProvider, - StatusChannel, StatusSink, + StatusChannel, StatusSink, WorkspaceRoots, }; /// The central registry subsystems self-register into. @@ -24,6 +24,13 @@ pub struct Registry { status_sink: ProxySlot, catalog_sink: ProxySlot, menu_sink: ProxySlot, + session_routes: ProxySlot, + workspace_routes: ProxySlot, + status_state: ProxySlot, + menu_state: ProxySlot, + gateway_state: ProxySlot, + sessions_state: ProxySlot, + workspace_roots: ProxySlot, } impl Registry { @@ -40,6 +47,13 @@ impl Registry { status_sink: ProxySlot::new(), catalog_sink: ProxySlot::new(), menu_sink: ProxySlot::new(), + session_routes: ProxySlot::new(), + workspace_routes: ProxySlot::new(), + status_state: ProxySlot::new(), + menu_state: ProxySlot::new(), + gateway_state: ProxySlot::new(), + sessions_state: ProxySlot::new(), + workspace_roots: ProxySlot::new(), } } @@ -106,6 +120,57 @@ impl Registry { &self.menu_sink } + /// The sessions subsystem's route slot: its `/ws`, `/agents/ws`, and + /// catalog-relay routes, merged into the shell's API router. + #[must_use] + pub fn session_routes(&self) -> &ProxySlot { + &self.session_routes + } + + /// The workspace subsystem's route slot: its `/workspace/*` routes, + /// merged into the shell's API router. + #[must_use] + pub fn workspace_routes(&self) -> &ProxySlot { + &self.workspace_routes + } + + /// The status subsystem's state-handle slot: its bus, fetched by the + /// composition root's consumers through a downcast. + #[must_use] + pub fn status_state(&self) -> &ProxySlot { + &self.status_state + } + + /// The menu subsystem's state-handle slot: its catalog and menu + /// buses, fetched by the composition root's consumers through a + /// downcast. + #[must_use] + pub fn menu_state(&self) -> &ProxySlot { + &self.menu_state + } + + /// The gateway subsystem's state-handle slot: its endpoint binding + /// and health flag, fetched through a downcast. + #[must_use] + pub fn gateway_state(&self) -> &ProxySlot { + &self.gateway_state + } + + /// The sessions subsystem's state-handle slot: the agent-session + /// registry, fetched through a downcast. + #[must_use] + pub fn sessions_state(&self) -> &ProxySlot { + &self.sessions_state + } + + /// The workspace subsystem's granted-roots slot: the narrow state + /// handle same-tier subsystems read instead of naming the workspace + /// crate. + #[must_use] + pub fn workspace_roots(&self) -> &ProxySlot { + &self.workspace_roots + } + /// The intent-named push facade over the producer sink slots, for /// subsystems that report what happened without naming another /// subsystem's bus. @@ -132,6 +197,13 @@ impl Clone for Registry { status_sink: self.status_sink.clone(), catalog_sink: self.catalog_sink.clone(), menu_sink: self.menu_sink.clone(), + session_routes: self.session_routes.clone(), + workspace_routes: self.workspace_routes.clone(), + status_state: self.status_state.clone(), + menu_state: self.menu_state.clone(), + gateway_state: self.gateway_state.clone(), + sessions_state: self.sessions_state.clone(), + workspace_roots: self.workspace_roots.clone(), } } } @@ -148,6 +220,13 @@ impl fmt::Debug for Registry { .field("status_sink", &self.status_sink) .field("catalog_sink", &self.catalog_sink) .field("menu_sink", &self.menu_sink) + .field("session_routes", &self.session_routes) + .field("workspace_routes", &self.workspace_routes) + .field("status_state", &self.status_state) + .field("menu_state", &self.menu_state) + .field("gateway_state", &self.gateway_state) + .field("sessions_state", &self.sessions_state) + .field("workspace_roots", &self.workspace_roots) .finish() } } diff --git a/crates/workshop-registry/src/traits.rs b/crates/workshop-registry/src/traits.rs index 467fb0f1d..b81e03fc5 100644 --- a/crates/workshop-registry/src/traits.rs +++ b/crates/workshop-registry/src/traits.rs @@ -10,6 +10,7 @@ use std::any::Any; use std::fmt; +use std::path::PathBuf; use std::sync::Arc; use axum::Router; @@ -213,6 +214,120 @@ impl

fmt::Debug for CatalogSinkAdapter

{ } } +/// The workspace subsystem's granted-root view: the narrow state handle +/// same-tier subsystems (the agent session's `ui()` snapshot) consume +/// through the registry instead of naming the workspace crate, which the +/// one-way tier graph forbids. +pub trait WorkspaceRoots: Sealed + Send + Sync { + /// The granted workspace roots in stable sorted order. + fn granted_roots(&self) -> Vec; +} + +/// A [`WorkspaceRoots`] backed by one closure over the workspace's grant +/// set: the registration adapter for the workspace subsystem. The +/// registry's traits are sealed, so the registrant plugs its state in +/// through this adapter rather than implementing the trait itself. +pub struct WorkspaceRootsAdapter { + roots: F, +} + +impl WorkspaceRootsAdapter +where + F: Fn() -> Vec + Send + Sync, +{ + /// Builds the adapter from the workspace's granted-roots closure. + pub fn new(roots: F) -> Self { + Self { roots } + } +} + +impl Sealed for WorkspaceRootsAdapter where F: Fn() -> Vec + Send + Sync {} + +impl WorkspaceRoots for WorkspaceRootsAdapter +where + F: Fn() -> Vec + Send + Sync, +{ + fn granted_roots(&self) -> Vec { + (self.roots)() + } +} + +impl fmt::Debug for WorkspaceRootsAdapter { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.debug_struct("WorkspaceRootsAdapter").finish() + } +} + +/// A [`RouteRegistrar`] backed by one closure building the subsystem's +/// router: the registration adapter for a subsystem's routes. The +/// registry's traits are sealed, so the registrant plugs its routes in +/// through this adapter rather than implementing the trait itself. +pub struct RouteRegistrarAdapter { + build: F, +} + +impl RouteRegistrarAdapter +where + F: Fn() -> Router + Send + Sync, +{ + /// Builds the adapter from the subsystem's router constructor. + pub fn new(build: F) -> Self { + Self { build } + } +} + +impl Sealed for RouteRegistrarAdapter where F: Fn() -> Router + Send + Sync {} + +impl RouteRegistrar for RouteRegistrarAdapter +where + F: Fn() -> Router + Send + Sync, +{ + fn routes(&self) -> Router { + (self.build)() + } +} + +impl fmt::Debug for RouteRegistrarAdapter { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.debug_struct("RouteRegistrarAdapter").finish() + } +} + +/// A [`StateProvider`] backed by one closure yielding the subsystem's +/// handle set: the registration adapter for a subsystem's shared state. +/// The registry's traits are sealed, so the registrant plugs its handles +/// in through this adapter rather than implementing the trait itself. +pub struct StateProviderAdapter { + handles: F, +} + +impl StateProviderAdapter +where + F: Fn() -> Arc + Send + Sync, +{ + /// Builds the adapter from the subsystem's handle-set closure. + pub fn new(handles: F) -> Self { + Self { handles } + } +} + +impl Sealed for StateProviderAdapter where F: Fn() -> Arc + Send + Sync {} + +impl StateProvider for StateProviderAdapter +where + F: Fn() -> Arc + Send + Sync, +{ + fn handles(&self) -> Arc { + (self.handles)() + } +} + +impl fmt::Debug for StateProviderAdapter { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.debug_struct("StateProviderAdapter").finish() + } +} + /// A [`MenuSink`] backed by closures over the menu bus's mutators: the /// registration adapter for the menu subsystem's workbench state. pub struct MenuSinkAdapter { diff --git a/crates/workshop-registry/tests/it/main.rs b/crates/workshop-registry/tests/it/main.rs index 0b4123544..d2608c463 100644 --- a/crates/workshop-registry/tests/it/main.rs +++ b/crates/workshop-registry/tests/it/main.rs @@ -133,3 +133,76 @@ fn registry_clones_share_the_same_slots() { "a registration through one handle is visible through every clone" ); } + +#[test] +fn the_workspace_roots_slot_serves_the_registrants_grants() { + use std::path::PathBuf; + + use workshop_registry::WorkspaceRootsAdapter; + + let registry = Registry::new(); + assert!( + registry.workspace_roots().get().is_none(), + "an unregistered roots slot is a graceful no-op" + ); + let registration = registry + .workspace_roots() + .register(Arc::new(WorkspaceRootsAdapter::new(|| { + vec![PathBuf::from("/granted")] + }))); + let roots = registry + .workspace_roots() + .get() + .expect("the registered roots handle is served"); + assert_eq!(roots.granted_roots(), vec![PathBuf::from("/granted")]); + drop(registration); + assert!( + registry.workspace_roots().get().is_none(), + "the slot empties when the guard drops" + ); +} + +#[test] +fn a_registered_route_registrar_builds_its_router() { + use workshop_registry::RouteRegistrarAdapter; + + let registry = Registry::new(); + assert!(registry.session_routes().get().is_none()); + assert!(registry.workspace_routes().get().is_none()); + let _registration = registry + .session_routes() + .register(Arc::new(RouteRegistrarAdapter::new(axum::Router::new))); + assert!( + registry.session_routes().get().is_some(), + "the sessions subsystem's routes are served" + ); + assert!( + registry.workspace_routes().get().is_none(), + "route slots are per subsystem" + ); +} + +#[test] +fn a_registered_state_provider_serves_its_handles_for_downcast() { + use workshop_registry::StateProviderAdapter; + + let registry = Registry::new(); + assert!(registry.sessions_state().get().is_none()); + let _registration = registry + .sessions_state() + .register(Arc::new(StateProviderAdapter::new(|| { + Arc::new("handles".to_string()) as Arc + }))); + let handles = registry + .sessions_state() + .get() + .expect("the registered provider is served") + .handles(); + assert_eq!( + handles + .downcast::() + .expect("the handle set downcasts to its concrete type") + .as_str(), + "handles" + ); +} diff --git a/crates/workshop-server/Cargo.toml b/crates/workshop-server/Cargo.toml index fa0127179..fa55a8b40 100644 --- a/crates/workshop-server/Cargo.toml +++ b/crates/workshop-server/Cargo.toml @@ -14,33 +14,20 @@ path = "src/main.rs" [dependencies] anyhow.workspace = true -arc-swap.workspace = true -async-trait.workspace = true axum.workspace = true -dunce.workspace = true futures-util.workspace = true open.workspace = true -percent-encoding.workspace = true -promptforge-core.workspace = true -promptforge-core-support.workspace = true -promptforge-model-client.workspace = true -promptforge-tool-picker.workspace = true -shared-progress.workspace = true -promptforge-vfs.workspace = true -promptforge-tools.workspace = true -rand.workspace = true reqwest.workspace = true rust-embed.workspace = true serde.workspace = true serde_json.workspace = true shared-loopback.workspace = true +shared-progress.workspace = true shared-sidecar.workspace = true -shared-vfs.workspace = true socket2.workspace = true thiserror.workspace = true tokio.workspace = true tokio-tungstenite.workspace = true -toml.workspace = true tracing.workspace = true tracing-subscriber.workspace = true url.workspace = true @@ -48,9 +35,10 @@ workshop-gateway.workspace = true workshop-menu.workspace = true workshop-protocol.workspace = true workshop-registry.workspace = true +workshop-sessions.workspace = true workshop-status.workspace = true workshop-support.workspace = true -promptforge-agent.workspace = true +workshop-workspace.workspace = true tempfile = { workspace = true, optional = true } [features] @@ -59,6 +47,7 @@ test-fixtures = [ "dep:tempfile", "workshop-gateway/test-fixtures", "workshop-menu/test-fixtures", + "workshop-sessions/test-fixtures", "workshop-support/test-fixtures", ] @@ -68,6 +57,15 @@ workshop-server = { path = ".", features = ["test-fixtures"] } # tests can run the real liveness gauntlet against the test binary's own # process image. shared-sidecar = { workspace = true, features = ["test-fixtures"] } +# The socket integration tests drive agent programs on the runtime engine +# directly, so the engine crates are test-only dependencies of the shell. +promptforge-agent.workspace = true +promptforge-core.workspace = true +promptforge-core-support.workspace = true +promptforge-model-client.workspace = true +promptforge-tool-picker.workspace = true +promptforge-tools.workspace = true +promptforge-vfs.workspace = true tempfile.workspace = true tokio = { workspace = true, features = ["test-util"] } tower.workspace = true diff --git a/crates/workshop-server/src/app.rs b/crates/workshop-server/src/app.rs index a26c910d2..877611546 100644 --- a/crates/workshop-server/src/app.rs +++ b/crates/workshop-server/src/app.rs @@ -1,13 +1,31 @@ //! Shared handler state and the composition root that assembles the //! per-feature routers into the workshop server. +//! +//! [`AppState`] holds no subsystem state by name: each extracted +//! subsystem owns its state behind a narrow handle registered into the +//! [`Registry`], and consumers fetch the handles through the registry's +//! slots. What remains here is the shell's own runtime infrastructure - +//! the shared reconnect backoff and the process progress hub - plus the +//! registration guards keeping every self-registration alive. +#[cfg(any(test, feature = "test-fixtures"))] +pub(crate) mod fixtures; +#[cfg(test)] +mod tests; + +use std::any::Any; +use std::fmt; use std::sync::Arc; use axum::Router; use shared_progress::ProgressHub; -use workshop_registry::{CatalogSink, MenuSink, Registration, Registry, StatusChannel, StatusSink}; +use workshop_gateway::GatewayHandles; +use workshop_menu::MenuHandles; +use workshop_registry::{ProxySlot, Push, Registration, Registry, StateProvider}; +use workshop_sessions::{AgentSessions, SessionHost, SessionsState}; +use workshop_status::StatusBus; use workshop_support::{Config, DEFAULT_DEADLINE, ReconnectBackoff, with_deadline}; use crate::catalog::CatalogBus; @@ -15,44 +33,70 @@ use crate::gateway::GatewayError; use crate::gateway_binding::{GatewayBinding, GatewaySnapshot, GatewayUpdater}; use crate::heartbeat::GatewayHealth; use crate::menu::MenuBus; -use crate::push::Push; use crate::resolve::ResolvedGateway; use crate::routes; -use crate::session_agents::{AgentSessions, SessionHost}; -use crate::status::StatusBus; -use crate::workspace::Workspace; /// Address the server binds to when no override is given. pub use workshop_support::DEFAULT_ADDR; -/// Shared handler state: the authenticated gateway client, the status, -/// catalog, and menu buses, the process progress hub, the hosted -/// workspace state, and the agent-session registry. +/// Shared handler state: the subsystem registry, the shell's runtime +/// infrastructure, and the registration guards. Subsystem handles - the +/// gateway binding and health flag, the status, catalog, and menu buses, +/// the agent-session registry - are fetched through the registry's +/// slots, where each subsystem self-registers them. #[derive(Debug, Clone)] pub struct AppState { - pub(crate) gateway: GatewayBinding, - pub(crate) status: StatusBus, - pub(crate) progress: Arc, - pub(crate) health: GatewayHealth, - pub(crate) backoff: ReconnectBackoff, - pub(crate) catalog: CatalogBus, - pub(crate) menu: MenuBus, - pub(crate) workspace: Workspace, - pub(crate) agents: AgentSessions, + backoff: ReconnectBackoff, + progress: Arc, registry: Registry, // Keeps the subsystems' self-registrations alive; dropping the last // state clone deregisters them. _registrations: Registrations, } -/// The registration guards keeping the subsystems' self-registrations -/// alive: the status push channel and the three producer sinks. -type Registrations = ( - Arc>, - Arc>, - Arc>, - Arc>, -); +/// The registration guards keeping every subsystem's self-registrations +/// alive: the push channels, the producer sinks, the state handles, the +/// route registrars, and the workspace roots handle. +#[derive(Clone)] +struct Registrations { + guards: Vec>, +} + +impl Registrations { + fn new() -> Self { + Self { guards: Vec::new() } + } + + /// Holds one registration guard for the state's lifetime. + fn hold(&mut self, guard: Registration) { + self.guards.push(Arc::new(guard)); + } +} + +impl fmt::Debug for Registrations { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("Registrations") + .field("held", &self.guards.len()) + .finish() + } +} + +/// Fetches one subsystem's registered handle set from `slot`, downcast +/// to its concrete type. A missing or mistyped registration is a +/// composition-root bug (zone one), never a runtime condition: the root +/// registers every subsystem before sharing state. +fn registered(slot: &ProxySlot, what: &str) -> T +where + T: Clone + Send + Sync + 'static, +{ + slot.get() + .and_then(|provider| provider.handles().downcast::().ok()) + .map_or_else( + || panic!("the composition root registers {what} before sharing state"), + |handles| (*handles).clone(), + ) +} impl AppState { /// Builds shared state from the loaded configuration, resolving the @@ -72,7 +116,7 @@ impl AppState { /// forward updates; producers report through [`AppState::push`]. #[must_use] pub fn status(&self) -> StatusBus { - self.status.clone() + registered(self.registry.status_state(), "the status bus") } /// The process progress hub: operations with bounded lifetimes attach @@ -90,9 +134,19 @@ impl AppState { self.registry.push() } + /// The gateway subsystem's registered handles. + fn gateway_handles(&self) -> GatewayHandles { + registered(self.registry.gateway_state(), "the gateway handles") + } + + /// The menu subsystem's registered handles. + fn menu_handles(&self) -> MenuHandles { + registered(self.registry.menu_state(), "the menu handles") + } + /// One atomic Gateway endpoint and credential generation. pub(crate) fn gateway_snapshot(&self) -> Arc { - self.gateway.snapshot() + self.gateway_handles().binding().snapshot() } /// A clone of the currently published Gateway HTTP client. @@ -102,34 +156,37 @@ impl AppState { } /// The replaceable Gateway binding shared with long-lived tasks. - pub(crate) fn gateway_binding(&self) -> &GatewayBinding { - &self.gateway + pub(crate) fn gateway_binding(&self) -> GatewayBinding { + self.gateway_handles().binding().clone() } /// Restricted local-Gateway authority for an embedding host. pub(crate) fn gateway_updater(&self) -> GatewayUpdater { - self.gateway.updater() + self.gateway_handles().binding().updater() } /// Permanently revokes host publication before application teardown. pub(crate) fn close_gateway_publication(&self) { - self.gateway.updater().close_publication(); + self.gateway_handles() + .binding() + .updater() + .close_publication(); } /// Shared gateway reachability, published by the heartbeat; the /// gateway-dependent routes read it to short-circuit while the gateway /// is down. #[must_use] - pub fn health(&self) -> &GatewayHealth { - &self.health + pub fn health(&self) -> GatewayHealth { + self.gateway_handles().health().clone() } /// The shared reconnect backoff: the heartbeat draws probe delays /// from it while the gateway is down, and the agent sessions reset it /// on useful work - a completed model reply. #[must_use] - pub fn backoff(&self) -> &ReconnectBackoff { - &self.backoff + pub fn backoff(&self) -> ReconnectBackoff { + self.backoff.clone() } /// The catalog bus, which the heartbeat publishes the refreshed model @@ -137,27 +194,18 @@ impl AppState { /// from. #[must_use] pub fn catalog(&self) -> CatalogBus { - self.catalog.clone() + self.menu_handles().catalog().clone() } /// The menu bus, whose workbench snapshots every `/ws` session /// forwards and whose mutators the session's menu events drive. #[must_use] - pub fn menu(&self) -> &MenuBus { - &self.menu - } - - /// The confined workspace, shared with the `/workspace/*` handlers; - /// grants registered through `POST /workspace/grant` are visible to - /// every clone immediately. - pub(crate) fn workspace(&self) -> &Workspace { - &self.workspace + pub fn menu(&self) -> MenuBus { + self.menu_handles().menu().clone() } /// The subsystem registry: proxy slots the subsystems self-register - /// into, so consumers reach them by slot instead of by name. The - /// status bus is the proof-of-concept registrant; the `/ws` session - /// loop reads its channel here. + /// into, so consumers reach them by slot instead of by name. #[must_use] pub fn registry(&self) -> &Registry { &self.registry @@ -168,8 +216,8 @@ impl AppState { /// sockets, so an embedding host ends one through /// [`AgentSessions::close`]. #[must_use] - pub fn agents(&self) -> &AgentSessions { - &self.agents + pub fn agents(&self) -> AgentSessions { + registered(self.registry.sessions_state(), "the agent-session registry") } } @@ -177,6 +225,11 @@ impl AppState { /// construction phase a host holding its own endpoint enters directly, /// skipping gateway discovery file resolution. /// +/// The composition root: every subsystem is constructed here, registers +/// itself into the registry, and is thereafter reached through the +/// registry's slots - routes included, which [`router`] merges from the +/// route slots. +/// /// # Errors /// Returns [`StateError::Gateway`] if the HTTP client cannot be built. pub fn state_with_gateway( @@ -193,19 +246,19 @@ pub fn state_with_gateway( // known and quiet, so it is swept here. workshop_support::sweep_orphaned_temps(state_dir); let menu = MenuBus::new(catalog.clone(), Some(state_dir)); - // The subsystems self-register: the `/ws` session loop discovers the - // status channel through the registry's slot instead of naming the - // bus, and same-tier producers (the gateway heartbeat's refreshes) - // reach the buses through the sink slots behind the push facade. + // The subsystems self-register: consumers reach their channels, + // sinks, state handles, and routes through the registry's slots + // instead of by name. let registry = Registry::new(); - let (status_channel, status_sink) = workshop_status::register(®istry, &status); - let (catalog_sink, menu_sink) = workshop_menu::register(®istry, &catalog, &menu); - let registrations = ( - Arc::new(status_channel), - Arc::new(status_sink), - Arc::new(catalog_sink), - Arc::new(menu_sink), - ); + let mut registrations = Registrations::new(); + let (status_channel, status_sink, status_state) = workshop_status::register(®istry, &status); + registrations.hold(status_channel); + registrations.hold(status_sink); + registrations.hold(status_state); + let (catalog_sink, menu_sink, menu_state) = workshop_menu::register(®istry, &catalog, &menu); + registrations.hold(catalog_sink); + registrations.hold(menu_sink); + registrations.hold(menu_state); let push = registry.push(); // Startup phases are reported as they run; with no client connected // yet these land on an empty bus, ready for the first session. @@ -218,30 +271,42 @@ pub fn state_with_gateway( .map_err(StateError::Gateway)?; let progress = Arc::new(ProgressHub::new()); let backoff = ReconnectBackoff::new(); - let workspace = Workspace::new(); + let health = GatewayHealth::new(); + registrations.hold(workshop_gateway::register( + ®istry, + GatewayHandles::new(gateway_binding.clone(), health.clone()), + )); + let workspace = workshop_workspace::Workspace::new(); + let (workspace_routes, workspace_roots) = workshop_workspace::register(®istry, &workspace); + registrations.hold(workspace_routes); + registrations.hold(workspace_roots); let agents = AgentSessions::new( config.agents.path.clone(), - config.server.state_dir.join("sessions"), + state_dir.join("sessions"), gateway_binding.clone(), - SessionHost { - push: push.clone(), - backoff: backoff.clone(), - menu: menu.clone(), - workspace: workspace.clone(), - catalog: catalog.clone(), - }, + SessionHost::new( + registry.clone(), + backoff.clone(), + menu.clone(), + catalog.clone(), + ), ); + let sessions = SessionsState::new( + agents, + gateway_binding, + health, + catalog, + menu, + registry.clone(), + crate::cross_site::origin_allowed, + ); + let (session_routes, sessions_state) = workshop_sessions::register(®istry, sessions); + registrations.hold(session_routes); + registrations.hold(sessions_state); push.push_idle(); Ok(AppState { - gateway: gateway_binding, - status, - progress, - health: GatewayHealth::new(), backoff, - catalog, - menu, - workspace, - agents, + progress, registry, _registrations: registrations, }) @@ -264,29 +329,29 @@ pub enum StateError { Resolution(#[source] crate::resolve::ResolveError), } -/// Returns the workshop server router with every route mounted: each -/// feature router from `crate::routes` built and merged, the workspace -/// group narrowed to the one service its handlers use. The API routes sit -/// behind the `crate::cross_site` guard; `/health` and the UI assets -/// stay outside it so the shell probe, heartbeat, and initial navigation -/// keep working. Every HTTP route carries a `workshop_support` deadline -/// tier - -/// the default here, the relay tier inside `routes::chat` - and the -/// WebSocket upgrades carry none. Every response carries the -/// `crate::csp` policy: the shell's webview loads the UI as an External -/// origin, so the server sets the page's Content-Security-Policy. +/// Returns the workshop server router with every route mounted: the +/// shell's own feature routers from [`crate::routes`], plus the extracted +/// subsystems' routers merged from the registry's route slots - an +/// unregistered slot is a graceful no-op. The API routes sit behind the +/// `crate::cross_site` guard; `/health` and the UI assets stay outside it +/// so the shell probe, heartbeat, and initial navigation keep working. +/// Every response carries the `crate::csp` policy: the shell's webview +/// loads the UI as an External origin, so the server sets the page's +/// Content-Security-Policy. Each subsystem applies its own deadline tier: +/// the default on the workspace routes, the relay tier on `/v1/models`, +/// none on the WebSocket upgrades. pub fn router(state: AppState) -> Router { - let workspace = state.workspace().clone(); - let api = Router::new() - .merge(routes::chat::routes(state.clone())) - .merge(crate::session_agents::socket::routes(state.clone())) + let registry = state.registry().clone(); + let mut api = Router::new() .merge(routes::realtime::routes(state.clone())) - .merge(routes::gateway_config::routes(state)) - .merge(with_deadline( - routes::workspace::routes(workspace), - DEFAULT_DEADLINE, - )) - .layer(axum::middleware::from_fn(crate::cross_site::guard)); + .merge(routes::gateway_config::routes(state)); + if let Some(registrar) = registry.session_routes().get() { + api = api.merge(registrar.routes()); + } + if let Some(registrar) = registry.workspace_routes().get() { + api = api.merge(registrar.routes()); + } + let api = api.layer(axum::middleware::from_fn(crate::cross_site::guard)); Router::new() .merge(with_deadline(routes::assets::routes(), DEFAULT_DEADLINE)) .merge(with_deadline(routes::health::routes(), DEFAULT_DEADLINE)) @@ -297,155 +362,3 @@ pub fn router(state: AppState) -> Router { // route answered. .layer(axum::middleware::from_fn(crate::csp::header)) } - -/// Shared fixtures for the router tests here and in [`crate::relay`] -/// and the [`crate::routes`] feature modules: state -/// construction against a stub gateway address and -/// the small helpers every route test leans on. [`fixtures::spawn_gateway`] -/// is additionally re-exported to the integration-test binary through the -/// `test-fixtures` feature the crate's own dev-dependency enables. -// An `allow` rather than an `expect`: whether the lint fires here depends -// on the build's cfg permutation (clippy suppresses expect_used inside -// test-cfg'd code on its own), so an expectation would be unfulfilled in -// some builds and fail the -D warnings gate. -#[cfg(any(test, feature = "test-fixtures"))] -#[allow( - clippy::expect_used, - reason = "test fixtures fail by panicking with the invariant named" -)] -pub(crate) mod fixtures { - #[cfg(test)] - use std::path::Path; - - use axum::Router; - #[cfg(test)] - use axum::body::to_bytes; - #[cfg(test)] - use axum::response::Response; - - #[cfg(test)] - use crate::app::{AppState, state_with_gateway}; - #[cfg(test)] - use workshop_support::{AgentsConfig, Config, GatewayConfig, ServerConfig}; - - /// Builds a configuration pointing at `base_url`, anchoring the state - /// directory at `state_dir`. - #[cfg(test)] - pub(crate) fn config_for(base_url: &str, state_dir: &Path) -> Config { - Config { - gateway: GatewayConfig { - base_url: base_url.to_string(), - api_key: "test-key".to_string(), - }, - server: ServerConfig { - state_dir: state_dir.to_path_buf(), - ..ServerConfig::default() - }, - agents: AgentsConfig::default(), - } - } - - /// Builds state whose state directory is a fresh tempdir, returned - /// alongside so the directory outlives the test. Discovery is - /// bypassed: a test never consults the real run directory. - #[cfg(test)] - pub(crate) fn state_for(base_url: &str) -> (AppState, tempfile::TempDir) { - let state_dir = tempfile::TempDir::new().expect("tempdir"); - let config = config_for(base_url, state_dir.path()); - let gateway = crate::resolve::ResolvedGateway::from_config(&config.gateway); - let state = state_with_gateway(&config, &gateway).expect("state builds in tests"); - (state, state_dir) - } - - /// Collects a response body already buffered in memory. - #[cfg(test)] - pub(crate) async fn body_bytes(response: Response) -> axum::body::Bytes { - to_bytes(response.into_body(), usize::MAX) - .await - .expect("the body is in memory already") - } - - /// Binds `app` as a mock gateway on a free loopback port and returns its - /// base URL. - /// - /// # Panics - /// Panics when the loopback bind fails or the bound address cannot be - /// read. - pub async fn spawn_gateway(app: Router) -> String { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .expect("bind mock gateway"); - let addr = listener.local_addr().expect("mock gateway address"); - tokio::spawn(async move { - axum::serve(listener, app) - .await - .expect("mock gateway serves"); - }); - format!("http://{addr}") - } -} - -#[cfg(test)] -mod tests { - use super::*; - - use axum::http::{HeaderMap, header}; - use axum::response::{IntoResponse, Response}; - use axum::routing::get; - - use super::fixtures::{config_for, spawn_gateway}; - use crate::gateway::GatewayClient; - - /// Reports whether the request carried an `Authorization` header, so - /// the client tests can observe what was sent. - async fn mock_auth_probe(headers: HeaderMap) -> Response { - let body = if headers.contains_key(header::AUTHORIZATION) { - "auth" - } else { - "no-auth" - }; - ([(header::CONTENT_TYPE, "text/plain")], body).into_response() - } - - #[tokio::test] - async fn empty_api_key_sends_no_authorization_header() { - let base_url = spawn_gateway(Router::new().route("/v1/models", get(mock_auth_probe))).await; - let anonymous = GatewayClient::new(&base_url, "").expect("client builds"); - let response = anonymous.list_models().await.expect("request completes"); - assert_eq!(response.body, b"no-auth", "empty key sends no header"); - - let keyed = GatewayClient::new(&base_url, "test-key").expect("client builds"); - let response = keyed.list_models().await.expect("request completes"); - assert_eq!(response.body, b"auth", "a set key still authenticates"); - } - - #[test] - fn default_bind_is_loopback_port_7910() { - assert_eq!(DEFAULT_ADDR, "127.0.0.1:7910"); - } - - #[test] - fn the_relay_deadline_outlasts_the_gateway_request_timeout() { - assert!( - workshop_support::RELAY_DEADLINE > crate::gateway::REQUEST_TIMEOUT, - "the route deadline must let the gateway client time out first, \ - so the caller sees the relay's 502 rather than a blunt 408" - ); - } - - #[test] - fn startup_sweeps_orphaned_temp_files_from_the_state_directory() { - let dir = tempfile::TempDir::new().expect("tempdir"); - // Residue of a write that crashed between its temp file and its - // rename in a previous run. - let orphan = dir.path().join("workshop-state.json.42-7.pf-tmp"); - std::fs::write(&orphan, "partial").expect("the simulated crash residue writes"); - let config = config_for("http://127.0.0.1:1", dir.path()); - let gateway = ResolvedGateway::from_config(&config.gateway); - let _state = state_with_gateway(&config, &gateway).expect("state builds"); - assert!( - !orphan.exists(), - "state construction sweeps orphaned temp files from the state directory" - ); - } -} diff --git a/crates/workshop-server/src/app/fixtures.rs b/crates/workshop-server/src/app/fixtures.rs new file mode 100644 index 000000000..bafd07d69 --- /dev/null +++ b/crates/workshop-server/src/app/fixtures.rs @@ -0,0 +1,84 @@ +//! Shared fixtures for the router tests here and in the +//! [`crate::routes`] feature modules: state construction against a stub +//! gateway address and the small helpers every route test leans on. +//! [`spawn_gateway`] is additionally re-exported to the +//! integration-test binary through the `test-fixtures` feature the +//! crate's own dev-dependency enables. +// An `allow` rather than an `expect`: whether the lint fires here depends +// on the build's cfg permutation (clippy suppresses expect_used inside +// test-cfg'd code on its own), so an expectation would be unfulfilled in +// some builds and fail the -D warnings gate. +#![allow( + clippy::expect_used, + reason = "test fixtures fail by panicking with the invariant named" +)] + +#[cfg(test)] +use std::path::Path; + +use axum::Router; +#[cfg(test)] +use axum::body::to_bytes; +#[cfg(test)] +use axum::response::Response; + +#[cfg(test)] +use crate::app::{AppState, state_with_gateway}; +#[cfg(test)] +use workshop_support::{AgentsConfig, Config, GatewayConfig, ServerConfig}; + +/// Builds a configuration pointing at `base_url`, anchoring the state +/// directory at `state_dir`. +#[cfg(test)] +pub(crate) fn config_for(base_url: &str, state_dir: &Path) -> Config { + Config { + gateway: GatewayConfig { + base_url: base_url.to_string(), + api_key: "test-key".to_string(), + }, + server: ServerConfig { + state_dir: state_dir.to_path_buf(), + ..ServerConfig::default() + }, + agents: AgentsConfig::default(), + } +} + +/// Builds state whose state directory is a fresh tempdir, returned +/// alongside so the directory outlives the test. Discovery is +/// bypassed: a test never consults the real run directory. +#[cfg(test)] +pub(crate) fn state_for(base_url: &str) -> (AppState, tempfile::TempDir) { + let state_dir = tempfile::TempDir::new().expect("tempdir"); + let config = config_for(base_url, state_dir.path()); + let gateway = crate::resolve::ResolvedGateway::from_config(&config.gateway); + let state = state_with_gateway(&config, &gateway).expect("state builds in tests"); + (state, state_dir) +} + +/// Collects a response body already buffered in memory. +#[cfg(test)] +pub(crate) async fn body_bytes(response: Response) -> axum::body::Bytes { + to_bytes(response.into_body(), usize::MAX) + .await + .expect("the body is in memory already") +} + +/// Binds `app` as a mock gateway on a free loopback port and returns its +/// base URL. +/// +/// # Panics +/// Panics when the loopback bind fails or the bound address cannot be +/// read. +pub async fn spawn_gateway(app: Router) -> String { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind mock gateway"); + let addr = listener.local_addr().expect("mock gateway address"); + tokio::spawn(async move { + axum::serve(listener, app) + .await + .expect("mock gateway serves"); + }); + format!("http://{addr}") +} diff --git a/crates/workshop-server/src/app/tests.rs b/crates/workshop-server/src/app/tests.rs new file mode 100644 index 000000000..6e4869d7f --- /dev/null +++ b/crates/workshop-server/src/app/tests.rs @@ -0,0 +1,103 @@ +use super::*; + +use axum::http::{HeaderMap, header}; +use axum::response::{IntoResponse, Response}; +use axum::routing::get; + +use super::fixtures::{config_for, spawn_gateway, state_for}; +use crate::gateway::GatewayClient; + +/// Reports whether the request carried an `Authorization` header, so +/// the client tests can observe what was sent. +async fn mock_auth_probe(headers: HeaderMap) -> Response { + let body = if headers.contains_key(header::AUTHORIZATION) { + "auth" + } else { + "no-auth" + }; + ([(header::CONTENT_TYPE, "text/plain")], body).into_response() +} + +#[tokio::test] +async fn empty_api_key_sends_no_authorization_header() { + let base_url = spawn_gateway(Router::new().route("/v1/models", get(mock_auth_probe))).await; + let anonymous = GatewayClient::new(&base_url, "").expect("client builds"); + let response = anonymous.list_models().await.expect("request completes"); + assert_eq!(response.body, b"no-auth", "empty key sends no header"); + + let keyed = GatewayClient::new(&base_url, "test-key").expect("client builds"); + let response = keyed.list_models().await.expect("request completes"); + assert_eq!(response.body, b"auth", "a set key still authenticates"); +} + +#[test] +fn default_bind_is_loopback_port_7910() { + assert_eq!(DEFAULT_ADDR, "127.0.0.1:7910"); +} + +#[test] +fn the_relay_deadline_outlasts_the_gateway_request_timeout() { + assert!( + workshop_support::RELAY_DEADLINE > crate::gateway::REQUEST_TIMEOUT, + "the route deadline must let the gateway client time out first, \ + so the caller sees the relay's 502 rather than a blunt 408" + ); +} + +#[test] +fn startup_sweeps_orphaned_temp_files_from_the_state_directory() { + let dir = tempfile::TempDir::new().expect("tempdir"); + // Residue of a write that crashed between its temp file and its + // rename in a previous run. + let orphan = dir.path().join("workshop-state.json.42-7.pf-tmp"); + std::fs::write(&orphan, "partial").expect("the simulated crash residue writes"); + let config = config_for("http://127.0.0.1:1", dir.path()); + let gateway = ResolvedGateway::from_config(&config.gateway); + let _state = state_with_gateway(&config, &gateway).expect("state builds"); + assert!( + !orphan.exists(), + "state construction sweeps orphaned temp files from the state directory" + ); +} + +/// A plain GET to `/ws` without upgrade headers is rejected with 400, +/// which proves the sessions subsystem's route is mounted through the +/// registry; the WebSocket flow is covered by the integration binary's +/// `session` modules over a live socket. +#[tokio::test] +async fn ws_route_rejects_a_non_upgrade_get() { + use tower::ServiceExt as _; + + let (state, _state_dir) = state_for("http://127.0.0.1:1"); + let request = axum::http::Request::builder() + .uri("/ws") + .body(axum::body::Body::empty()) + .expect("static request parts are valid"); + let response = router(state) + .oneshot(request) + .await + .expect("the router is infallible"); + assert_eq!(response.status(), axum::http::StatusCode::BAD_REQUEST); +} + +/// The excised buffered chat endpoint is gone from the router: a +/// `POST /chat` answers 404, not a relay response. +#[tokio::test] +async fn post_chat_is_absent_and_answers_not_found() { + use tower::ServiceExt as _; + + let (state, _state_dir) = state_for("http://127.0.0.1:1"); + let request = axum::http::Request::builder() + .method("POST") + .uri("/chat") + .header(header::CONTENT_TYPE, "application/json") + .body(axum::body::Body::from( + r#"{"model":"test-model","messages":[{"role":"user","content":"ping"}]}"#, + )) + .expect("static request parts are valid"); + let response = router(state) + .oneshot(request) + .await + .expect("the router is infallible"); + assert_eq!(response.status(), axum::http::StatusCode::NOT_FOUND); +} diff --git a/crates/workshop-server/src/error.rs b/crates/workshop-server/src/error.rs index 9cab9d498..56008dffb 100644 --- a/crates/workshop-server/src/error.rs +++ b/crates/workshop-server/src/error.rs @@ -4,9 +4,11 @@ //! response: one variant per wire failure that exists today, each mapped to //! exactly one status code by the central [`IntoResponse`] impl, so the //! same failure is built in one place no matter which handler hits it. -//! Conversions in are explicit - handler seams name a variant constructor, -//! and workspace failures go through the deliberate `From` -//! mapping below; no `#[from]` derive exists on this side of the boundary. +//! Conversions in are explicit - handler seams name a variant constructor; +//! no `#[from]` derive exists on this side of the boundary. The extracted +//! feature crates map their own error types at their own route boundaries +//! (`workshop_workspace::WorkspaceError`, the sessions relay's gateway +//! envelope); this shell type covers the shell's own routes. //! Internal failure detail (the source chain) reaches the response body in //! debug builds only; production bodies stay at each variant's own message, //! close to the status text. Rich construction-time errors live elsewhere @@ -14,7 +16,6 @@ //! never cross the wire. use std::fmt::Write as _; -use std::io; use axum::http::{StatusCode, header}; use axum::response::{IntoResponse, Response}; @@ -22,7 +23,6 @@ use axum::response::{IntoResponse, Response}; use workshop_protocol::ErrorEnvelope; use crate::gateway::GatewayError; -use crate::workspace::WorkspaceError; /// Whether wire bodies carry internal failure detail. Debug builds append /// the source chain to the envelope message; production bodies stay at the @@ -37,12 +37,6 @@ const LEAK_DETAIL: bool = cfg!(debug_assertions); #[derive(Debug, thiserror::Error)] #[non_exhaustive] pub(crate) enum AppError { - /// The heartbeat knows the gateway is down, so the call was never - /// attempted. The message is user-visible in the UI and pinned by the - /// characterization tests, hence the capitalized wire text. - #[error("Gateway unreachable")] - GatewayUnreachable, - /// An attempted gateway call failed in transport. Transparent so the /// wire message stays the [`GatewayError`]'s own summary line, as it /// was before the error split. @@ -65,97 +59,6 @@ pub(crate) enum AppError { #[error("path is not forwardable to the gateway")] ForwardDenied, - /// A granted workspace path could not be canonicalized. - #[error("grant path cannot be resolved")] - ResolveGrant { - /// The underlying I/O failure. - #[source] - source: io::Error, - }, - - /// A requested workspace path could not be canonicalized. - #[error("requested path cannot be resolved")] - ResolvePath { - /// The underlying I/O failure. - #[source] - source: io::Error, - }, - - /// Filesystem metadata for a workspace path could not be read. - #[error("path cannot be inspected")] - InspectPath { - /// The underlying I/O failure. - #[source] - source: io::Error, - }, - - /// A workspace directory could not be listed. - #[error("directory cannot be listed")] - ListDirectory { - /// The underlying I/O failure. - #[source] - source: io::Error, - }, - - /// A workspace file could not be read. - #[error("file cannot be read")] - ReadFile { - /// The underlying I/O failure. - #[source] - source: io::Error, - }, - - /// A workspace file could not be written. - #[error("file cannot be written")] - WriteFile { - /// The underlying I/O failure. - #[source] - source: io::Error, - }, - - /// The path is not inside any granted workspace root. - #[error("path is outside every granted root")] - OutsideGrants, - - /// The path carries a `..` or an alternate data stream name. - #[error("path contains a forbidden component")] - ForbiddenComponent, - - /// The workspace path does not exist. - #[error("path does not exist")] - NotFound, - - /// A revoke named a path that is not a granted workspace root. - #[error("path is not a granted root")] - NotGranted, - - /// A tree listing was requested for something that is not a directory. - #[error("path is not a directory")] - NotADirectory, - - /// A read or write targeted something that is not a regular file. - #[error("path is not a file")] - NotAFile, - - /// The file contains NUL bytes and is not editable text. - #[error("file is binary, not text")] - BinaryFile, - - /// The file is not valid UTF-8. - #[error("file is not utf-8 text")] - NotUtf8, - - /// The file or body exceeds the workspace size limit. - #[error("file exceeds the {limit}-byte size limit")] - FileTooLarge { - /// The size limit that was exceeded. - limit: u64, - }, - - /// The on-disk modified time does not match the writer's token. - #[error("file changed on disk since it was read")] - ModifiedConflict, - /// An embedded UI asset is missing from the bundle. #[error("ui asset not found: {0}")] AssetMissing(String), @@ -165,22 +68,10 @@ impl AppError { /// The one HTTP status this failure answers with. fn status(&self) -> StatusCode { match self { - Self::GatewayUnreachable | Self::Gateway(_) => StatusCode::BAD_GATEWAY, - Self::NotADirectory | Self::NotAFile => StatusCode::BAD_REQUEST, - Self::OutsideGrants - | Self::ForbiddenComponent - | Self::CrossSite - | Self::ForwardDenied => StatusCode::FORBIDDEN, - Self::NotFound | Self::NotGranted | Self::AssetMissing(_) => StatusCode::NOT_FOUND, - Self::BinaryFile | Self::NotUtf8 | Self::NotJson => StatusCode::UNSUPPORTED_MEDIA_TYPE, - Self::FileTooLarge { .. } => StatusCode::PAYLOAD_TOO_LARGE, - Self::ModifiedConflict => StatusCode::CONFLICT, - Self::ResolveGrant { .. } - | Self::ResolvePath { .. } - | Self::InspectPath { .. } - | Self::ListDirectory { .. } - | Self::ReadFile { .. } - | Self::WriteFile { .. } => StatusCode::INTERNAL_SERVER_ERROR, + Self::Gateway(_) => StatusCode::BAD_GATEWAY, + Self::CrossSite | Self::ForwardDenied => StatusCode::FORBIDDEN, + Self::NotJson => StatusCode::UNSUPPORTED_MEDIA_TYPE, + Self::AssetMissing(_) => StatusCode::NOT_FOUND, } } @@ -188,56 +79,15 @@ impl AppError { /// the failures rendered as plain text instead of the envelope. fn code(&self) -> Option<&'static str> { match self { - Self::GatewayUnreachable | Self::Gateway(_) => Some("gateway_unreachable"), + Self::Gateway(_) => Some("gateway_unreachable"), Self::CrossSite => Some("cross_site"), Self::NotJson => Some("not_json"), Self::ForwardDenied => Some("forward_denied"), - Self::ResolveGrant { .. } => Some("resolve_grant"), - Self::ResolvePath { .. } => Some("resolve_path"), - Self::InspectPath { .. } => Some("inspect_path"), - Self::ListDirectory { .. } => Some("list_directory"), - Self::ReadFile { .. } => Some("read_file"), - Self::WriteFile { .. } => Some("write_file"), - Self::OutsideGrants => Some("outside_grants"), - Self::ForbiddenComponent => Some("forbidden_component"), - Self::NotFound => Some("not_found"), - Self::NotGranted => Some("not_granted"), - Self::NotADirectory => Some("not_a_directory"), - Self::NotAFile => Some("not_a_file"), - Self::BinaryFile => Some("binary_file"), - Self::NotUtf8 => Some("not_utf8"), - Self::FileTooLarge { .. } => Some("file_too_large"), - Self::ModifiedConflict => Some("modified_conflict"), Self::AssetMissing(_) => None, } } } -/// The deliberate workspace seam: each domain failure keeps the status, -/// code, and message it answered with before the error split. -impl From for AppError { - fn from(error: WorkspaceError) -> Self { - match error { - WorkspaceError::ResolveGrant { source } => Self::ResolveGrant { source }, - WorkspaceError::ResolvePath { source } => Self::ResolvePath { source }, - WorkspaceError::InspectPath { source } => Self::InspectPath { source }, - WorkspaceError::ListDirectory { source } => Self::ListDirectory { source }, - WorkspaceError::ReadFile { source } => Self::ReadFile { source }, - WorkspaceError::WriteFile { source } => Self::WriteFile { source }, - WorkspaceError::OutsideGrants => Self::OutsideGrants, - WorkspaceError::ForbiddenComponent => Self::ForbiddenComponent, - WorkspaceError::NotFound => Self::NotFound, - WorkspaceError::NotGranted => Self::NotGranted, - WorkspaceError::NotADirectory => Self::NotADirectory, - WorkspaceError::NotAFile => Self::NotAFile, - WorkspaceError::BinaryFile => Self::BinaryFile, - WorkspaceError::NotUtf8 => Self::NotUtf8, - WorkspaceError::FileTooLarge { limit } => Self::FileTooLarge { limit }, - WorkspaceError::ModifiedConflict => Self::ModifiedConflict, - } - } -} - impl IntoResponse for AppError { fn into_response(self) -> Response { let status = self.status(); @@ -282,6 +132,8 @@ fn render_message(error: &AppError, leak_detail: bool) -> String { mod tests { use super::*; + use std::io; + use crate::app::fixtures::body_bytes; /// A distinctive injected cause for leak-boundary assertions. @@ -291,9 +143,6 @@ mod tests { #[test] fn gateway_failures_map_to_bad_gateway() { - let unreachable = AppError::GatewayUnreachable; - assert_eq!(unreachable.status(), StatusCode::BAD_GATEWAY); - assert_eq!(unreachable.code(), Some("gateway_unreachable")); let transport = AppError::Gateway(GatewayError::transport_for_test(Box::new(injected_io()))); assert_eq!(transport.status(), StatusCode::BAD_GATEWAY); @@ -307,113 +156,10 @@ mod tests { assert_eq!(miss.code(), None, "the asset 404 is plain text, not JSON"); } - /// Every workspace failure keeps the status, code, and message it - /// answered with before the error split, through the `From` seam. - #[test] - fn workspace_failures_keep_their_wire_mapping_through_the_seam() { - let cases: Vec<(WorkspaceError, StatusCode, &str)> = vec![ - ( - WorkspaceError::ResolveGrant { - source: injected_io(), - }, - StatusCode::INTERNAL_SERVER_ERROR, - "resolve_grant", - ), - ( - WorkspaceError::ResolvePath { - source: injected_io(), - }, - StatusCode::INTERNAL_SERVER_ERROR, - "resolve_path", - ), - ( - WorkspaceError::InspectPath { - source: injected_io(), - }, - StatusCode::INTERNAL_SERVER_ERROR, - "inspect_path", - ), - ( - WorkspaceError::ListDirectory { - source: injected_io(), - }, - StatusCode::INTERNAL_SERVER_ERROR, - "list_directory", - ), - ( - WorkspaceError::ReadFile { - source: injected_io(), - }, - StatusCode::INTERNAL_SERVER_ERROR, - "read_file", - ), - ( - WorkspaceError::WriteFile { - source: injected_io(), - }, - StatusCode::INTERNAL_SERVER_ERROR, - "write_file", - ), - ( - WorkspaceError::OutsideGrants, - StatusCode::FORBIDDEN, - "outside_grants", - ), - ( - WorkspaceError::ForbiddenComponent, - StatusCode::FORBIDDEN, - "forbidden_component", - ), - (WorkspaceError::NotFound, StatusCode::NOT_FOUND, "not_found"), - ( - WorkspaceError::NotGranted, - StatusCode::NOT_FOUND, - "not_granted", - ), - ( - WorkspaceError::NotADirectory, - StatusCode::BAD_REQUEST, - "not_a_directory", - ), - ( - WorkspaceError::NotAFile, - StatusCode::BAD_REQUEST, - "not_a_file", - ), - ( - WorkspaceError::BinaryFile, - StatusCode::UNSUPPORTED_MEDIA_TYPE, - "binary_file", - ), - ( - WorkspaceError::NotUtf8, - StatusCode::UNSUPPORTED_MEDIA_TYPE, - "not_utf8", - ), - ( - WorkspaceError::FileTooLarge { limit: 7 }, - StatusCode::PAYLOAD_TOO_LARGE, - "file_too_large", - ), - ( - WorkspaceError::ModifiedConflict, - StatusCode::CONFLICT, - "modified_conflict", - ), - ]; - for (error, status, code) in cases { - let message = error.to_string(); - let wire = AppError::from(error); - assert_eq!(wire.status(), status, "status for {code}"); - assert_eq!(wire.code(), Some(code), "code for {code}"); - assert_eq!(wire.to_string(), message, "message for {code}"); - } - } - #[tokio::test] async fn the_json_envelope_carries_message_code_and_content_type() { - let response = AppError::GatewayUnreachable.into_response(); - assert_eq!(response.status(), StatusCode::BAD_GATEWAY); + let response = AppError::CrossSite.into_response(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); let content_type = response .headers() .get(header::CONTENT_TYPE) @@ -422,10 +168,10 @@ mod tests { let body = body_bytes(response).await; let json: serde_json::Value = serde_json::from_slice(&body).expect("the envelope is JSON"); assert_eq!( - json["error"]["message"], "Gateway unreachable", + json["error"]["message"], "cross-site request refused", "the pinned user-visible message is identical in every build" ); - assert_eq!(json["error"]["code"], "gateway_unreachable"); + assert_eq!(json["error"]["code"], "cross_site"); } #[tokio::test] @@ -445,14 +191,6 @@ mod tests { #[test] fn production_messages_stay_at_the_variant_text() { - let read = AppError::ReadFile { - source: injected_io(), - }; - assert_eq!( - render_message(&read, false), - "file cannot be read", - "production bodies carry no source detail" - ); let gateway = AppError::Gateway(GatewayError::transport_for_test(Box::new(injected_io()))); assert_eq!( render_message(&gateway, false), @@ -463,12 +201,10 @@ mod tests { #[test] fn debug_messages_append_the_source_chain() { - let read = AppError::ReadFile { - source: injected_io(), - }; + let gateway = AppError::Gateway(GatewayError::transport_for_test(Box::new(injected_io()))); assert_eq!( - render_message(&read, true), - "file cannot be read: injected disk failure" + render_message(&gateway, true), + "gateway transport error: injected disk failure" ); } @@ -478,15 +214,13 @@ mod tests { #[cfg(debug_assertions)] #[tokio::test] async fn debug_builds_leak_detail_into_the_live_envelope() { - let response = AppError::ReadFile { - source: injected_io(), - } - .into_response(); + let response = AppError::Gateway(GatewayError::transport_for_test(Box::new(injected_io()))) + .into_response(); let body = body_bytes(response).await; let json: serde_json::Value = serde_json::from_slice(&body).expect("the envelope is JSON"); assert_eq!( json["error"]["message"], - "file cannot be read: injected disk failure" + "gateway transport error: injected disk failure" ); } } diff --git a/crates/workshop-server/src/input.rs b/crates/workshop-server/src/input.rs deleted file mode 100644 index 687fda38d..000000000 --- a/crates/workshop-server/src/input.rs +++ /dev/null @@ -1,1025 +0,0 @@ -//! The user-input wait: the [`WaitRegistry`] of single-use wait tokens, -//! the Workshop's `user_input` tool, and the `input_response` producer -//! that completes a wait. -//! -//! An agent program asks its operator for input by calling the -//! `user_input` tool - session-supplied code, never advertised to a -//! model. Its `call()` registers a wait, announces it with a durable -//! `input_required` frame, and suspends on the wait's receiver until the -//! session delivers the operator's answer ([`deliver_input_response`]) or -//! the wait dies. A dying wait is an outcome, never silence: every path -//! out of an unresolved wait - the future dropped by a turn-cancel, the -//! wait cancelled out of the registry - removes the entry and pushes a -//! durable `input_cancelled` frame, so the SPA never pins its input box -//! to a dead token. Unresolved waits are retained across socket loss and -//! re-announced on reconnect: sessions outlive sockets. - -use std::fmt; -use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; - -use promptforge_core::input::{InputBroker, InputError, InputOutcome}; -use promptforge_core_support::observe::Observer; -use promptforge_tools::{Tool, ToolError, ToolErrorKind, ToolId, ToolOutput}; -use tokio::sync::{broadcast, oneshot}; - -use workshop_protocol::{InputFrame, InputResponse}; - -/// One unresolved wait: its single-use token, and the sender that resumes -/// the suspended `user_input` call with the operator's text. -struct Wait { - /// The unguessable token an `input_response` must echo. - token: String, - /// Resumes the suspended call; dropping it without a value resolves - /// the call as cancelled. - sender: oneshot::Sender, -} - -/// The registry of unresolved user-input waits, keyed by single-use -/// cryptographic tokens. -/// -/// [`create`](Self::create) opens a wait and returns its token beside the -/// receiving half; [`complete`](Self::complete) resolves the wait with the -/// operator's text and consumes the token; [`cancel`](Self::cancel) kills -/// it. Unresolved waits are retained - sessions outlive sockets - and -/// [`resend_unresolved`](Self::resend_unresolved) re-announces them to a -/// reconnecting client in creation order. -#[derive(Default)] -pub struct WaitRegistry { - /// The unresolved waits in creation order. A `Vec` rather than a map: - /// a session holds at most a handful of waits (in the gate, one), and - /// creation order is exactly the resend order reconnect needs. - waits: Mutex>, -} - -/// Shows the unresolved count, never the tokens: a token in a log would -/// let whoever reads the log answer someone else's prompt. -impl fmt::Debug for WaitRegistry { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter - .debug_struct("WaitRegistry") - .field("unresolved", &self.lock().len()) - .finish() - } -} - -impl WaitRegistry { - /// Opens an empty registry. - /// - /// # Examples - /// ``` - /// use workshop_server::WaitRegistry; - /// - /// let registry = WaitRegistry::new(); - /// assert!(registry.unresolved().is_empty()); - /// ``` - #[must_use] - pub fn new() -> Self { - Self::default() - } - - /// The registry lock. Zone two: a peer that panicked mid-mutation - /// cannot wedge the process, and the recovered list is still - /// consistent because every mutation is one push, remove, or retain. - fn lock(&self) -> MutexGuard<'_, Vec> { - self.waits.lock().unwrap_or_else(PoisonError::into_inner) - } - - /// Opens a wait: returns its fresh single-use token and the receiver - /// that resolves with the operator's text. - /// - /// The token is 128 bits from the OS-seeded cryptographic RNG - /// (`rand::rng`, a ChaCha-based CSPRNG), hex-encoded, so it cannot be - /// guessed by anything that has not seen the `input_required` frame. - /// - /// # Examples - /// ``` - /// use workshop_server::WaitRegistry; - /// - /// let registry = WaitRegistry::new(); - /// let (token, mut receiver) = registry.create(); - /// registry.complete(&token, "hello".to_owned())?; - /// assert_eq!(receiver.try_recv(), Ok("hello".to_owned())); - /// # Ok::<(), workshop_server::WaitError>(()) - /// ``` - #[must_use] - pub fn create(&self) -> (String, oneshot::Receiver) { - use rand::Rng as _; - let mut rng = rand::rng(); - let token = format!("{:016x}{:016x}", rng.random::(), rng.random::()); - let (sender, receiver) = oneshot::channel(); - self.lock().push(Wait { - token: token.clone(), - sender, - }); - (token, receiver) - } - - /// Resolves the wait holding `token` with the operator's text, - /// consuming the token: a second `complete` of the same token fails. - /// - /// # Errors - /// Returns [`WaitError::UnknownToken`] when no unresolved wait holds - /// `token` - never created, already completed, cancelled, or its - /// suspended call dropped concurrently. The undelivered `value` is - /// discarded with the error: a dead wait has no consumer left. - /// - /// # Examples - /// ``` - /// use workshop_server::{WaitError, WaitRegistry}; - /// - /// let registry = WaitRegistry::new(); - /// let (token, mut receiver) = registry.create(); - /// registry.complete(&token, "typed".to_owned())?; - /// assert_eq!(receiver.try_recv(), Ok("typed".to_owned())); - /// assert_eq!( - /// registry.complete(&token, "again".to_owned()), - /// Err(WaitError::UnknownToken), - /// ); - /// # Ok::<(), workshop_server::WaitError>(()) - /// ``` - pub fn complete(&self, token: &str, value: String) -> Result<(), WaitError> { - let wait = { - let mut waits = self.lock(); - let index = waits - .iter() - .position(|wait| wait.token == token) - .ok_or(WaitError::UnknownToken)?; - waits.remove(index) - }; - wait.sender.send(value).map_err(|_| WaitError::UnknownToken) - } - - /// Kills the wait holding `token`: the entry is removed and the - /// suspended call resolves as cancelled. - /// - /// Cancelling a token with no wait is a no-op, because a cancel - /// racing the wait's own completion is normal, exactly as a chat - /// cancel racing its `done` is. - /// - /// # Examples - /// ``` - /// use workshop_server::WaitRegistry; - /// - /// let registry = WaitRegistry::new(); - /// let (token, mut receiver) = registry.create(); - /// registry.cancel(&token); - /// assert!(receiver.try_recv().is_err(), "the wait resolves as dead"); - /// assert!(registry.unresolved().is_empty()); - /// ``` - pub fn cancel(&self, token: &str) { - self.lock().retain(|wait| wait.token != token); - } - - /// Returns the unresolved wait tokens in creation order. - /// - /// This is the retained state behind reconnect resend and the - /// leaked-wait assertion in session teardown tests. - /// - /// # Examples - /// ``` - /// use workshop_server::WaitRegistry; - /// - /// let registry = WaitRegistry::new(); - /// let (token, _receiver) = registry.create(); - /// assert_eq!(registry.unresolved(), vec![token]); - /// ``` - #[must_use] - pub fn unresolved(&self) -> Vec { - self.lock().iter().map(|wait| wait.token.clone()).collect() - } - - /// Re-announces every unresolved wait to `frames` as an - /// `input_required` frame, in creation order. - /// - /// The reconnect half of the durable-delivery promise: a client that - /// missed pushes rebuilds its prompt state from this resend - a live - /// wait reappears, and a stale prompt vanishes by its absence. - /// - /// # Examples - /// ``` - /// use workshop_server::{InputFrame, WaitRegistry}; - /// - /// let registry = WaitRegistry::new(); - /// let (token, _receiver) = registry.create(); - /// let (frames, mut socket) = tokio::sync::broadcast::channel(8); - /// registry.resend_unresolved(&frames); - /// assert_eq!(socket.try_recv()?, InputFrame::Required { token }); - /// # Ok::<(), tokio::sync::broadcast::error::TryRecvError>(()) - /// ``` - pub fn resend_unresolved(&self, frames: &broadcast::Sender) { - for token in self.unresolved() { - // No receiver means the client vanished again between - // subscribing and this resend; the registry still holds the - // wait, so the next reconnect resends it once more. - let _ = frames.send(InputFrame::Required { token }); - } - } -} - -/// A [`WaitRegistry`] operation failed. -#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] -#[non_exhaustive] -pub enum WaitError { - /// No unresolved wait holds the token: never created, already - /// completed (tokens are single-use), cancelled, or its suspended - /// call dropped concurrently. - #[error("no unresolved wait holds this token")] - UnknownToken, -} - -/// Fires `on_user_input` for an arrived `input_response`, byte-exact, -/// then completes the wait its token names. -/// -/// This is the producer the session calls when the SPA answers a prompt. -/// The event fires exactly once per response, before completion and -/// regardless of whether the token still names a live wait: the -/// operator's text is history the relaunched agent rebuilds context -/// from, so a response racing a turn-cancel records its text even though -/// the wait it aimed at is gone. -/// -/// # Errors -/// Returns [`WaitError::UnknownToken`] when no unresolved wait holds the -/// response's token; the `on_user_input` event has fired regardless. -/// -/// # Examples -/// ``` -/// use promptforge_core_support::observe::NullObserver; -/// use workshop_server::{InputResponse, WaitRegistry, deliver_input_response}; -/// -/// let registry = WaitRegistry::new(); -/// let (token, mut receiver) = registry.create(); -/// deliver_input_response( -/// &NullObserver::default(), -/// ®istry, -/// "run", -/// "chat", -/// InputResponse { token, text: "hello".to_owned() }, -/// )?; -/// assert_eq!(receiver.try_recv(), Ok("hello".to_owned())); -/// # Ok::<(), workshop_server::WaitError>(()) -/// ``` -pub fn deliver_input_response( - observer: &dyn Observer, - registry: &WaitRegistry, - execution: &str, - section: &str, - response: InputResponse, -) -> Result<(), WaitError> { - deliver_input_response_before_completion( - observer, - registry, - execution, - section, - response, - || {}, - ) -} - -/// Completes the wait `response` names without recording anything. -/// -/// The unified-runtime half of delivery: a session whose agent runs on the -/// unified runtime records the operator's text consumer-side, when the -/// suspended `user_input` call resumes, so the producer-side observation -/// would double the event. The `before_completion` seam is the same one -/// [`deliver_input_response_before_completion`] offers. -/// -/// # Errors -/// Returns [`WaitError::UnknownToken`] when no unresolved wait holds the -/// response's token. -pub(crate) fn complete_input_response( - registry: &WaitRegistry, - response: InputResponse, - before_completion: impl FnOnce(), -) -> Result<(), WaitError> { - before_completion(); - registry.complete(&response.token, response.text) -} - -/// Delivers one response with a synchronous seam after the durable input -/// observation and before the suspended tool call resumes. -pub(crate) fn deliver_input_response_before_completion( - observer: &dyn Observer, - registry: &WaitRegistry, - execution: &str, - section: &str, - response: InputResponse, - before_completion: impl FnOnce(), -) -> Result<(), WaitError> { - observer.on_user_input(execution, section, &response.text); - before_completion(); - registry.complete(&response.token, response.text) -} - -/// The Workshop's `user_input` tool: suspends an agent program until its -/// operator types into the session's input box. -/// -/// A host-primitive [`Tool`] the session constructs per agent session - -/// it is never advertised to a model (the agent driver advertises only -/// the aliases a `models.chat` call names, and host primitives are -/// excluded from that set), and only the agent program itself calls it. -/// `call()` opens a wait in the session's [`WaitRegistry`], pushes the -/// `input_required` frame itself, and suspends until the wait resolves; -/// `run_agent` has no user-input awareness because this tool is the -/// caller's own code. -/// -/// The output is **trusted and structured**: a JSON object with `text` -/// (the operator's input, byte-exact - the operator is not an attacker of -/// their own session, so no nonce envelope ever wraps it) and `images` -/// (present and always empty until SPA attachments land). The session -/// binds this tool with the structured output kind, so the object resumes -/// into Lua as a table - `result.text`, `result.images` - through the -/// serde boundary; structured output stays restricted to trusted tools. -/// -/// # Examples -/// ``` -/// use std::sync::Arc; -/// -/// use promptforge_tools::Tool; -/// use workshop_server::{UserInputTool, WaitRegistry}; -/// -/// let (frames, _receiver) = tokio::sync::broadcast::channel(8); -/// let tool = UserInputTool::new(Arc::new(WaitRegistry::new()), frames); -/// assert_eq!(tool.wire_name(), "user_input"); -/// ``` -#[derive(Debug)] -pub struct UserInputTool { - /// The session's wait registry, shared with the session loop that - /// completes and cancels waits. - registry: Arc, - /// Where `input_required` and `input_cancelled` frames are pushed; - /// the session's socket loop forwards them to the SPA. - frames: broadcast::Sender, -} - -impl UserInputTool { - /// Builds the tool over the session's wait registry and frame sender. - /// - /// # Examples - /// ``` - /// use std::sync::Arc; - /// - /// use workshop_server::{UserInputTool, WaitRegistry}; - /// - /// let registry = Arc::new(WaitRegistry::new()); - /// let (frames, _receiver) = tokio::sync::broadcast::channel(8); - /// let _tool = UserInputTool::new(registry, frames); - /// ``` - #[must_use] - pub fn new(registry: Arc, frames: broadcast::Sender) -> Self { - Self { registry, frames } - } -} - -/// Guarantees a dying wait is an outcome, not silence: unless disarmed by -/// a delivered value, dropping the guard removes the wait from the -/// registry and pushes `input_cancelled` for its token. The tool future -/// is dropped by the shared dispatch's cancel race on turn-cancel, so -/// this guard is what keeps a cancelled turn from leaking its wait or -/// leaving the SPA prompting against a dead token. -struct WaitGuard { - /// The registry the wait entry is removed from. - registry: Arc, - /// Where the `input_cancelled` frame is pushed. - frames: broadcast::Sender, - /// The dying wait's token. - token: String, - /// Cleared when the wait resolved with a value; the guard then does - /// nothing, because `complete` already consumed the entry. - armed: bool, -} - -impl Drop for WaitGuard { - fn drop(&mut self) { - if !self.armed { - return; - } - // On the registry-cancel path the entry is already gone and this - // is a no-op; on the dropped-future path it is the removal. - self.registry.cancel(&self.token); - // No receiver means no socket is attached; the reconnect resend - // repairs the SPA anyway, because this wait is absent from the - // resent set. - let _ = self.frames.send(InputFrame::Cancelled { - token: std::mem::take(&mut self.token), - }); - } -} - -/// The session's wait registry behind the generic input-broker interface: -/// the adapter the unified runtime's `user_input()` and model-visible -/// input tool suspend on. -/// -/// One broker per run: `user_input` opens a wait in the session's -/// [`WaitRegistry`], announces it with the durable `input_required` frame, -/// and suspends on the receiver until the session delivers the operator's -/// answer or the wait dies. A dying wait is an outcome, never silence - -/// the same drop-guard rule as the legacy tool: a future dropped by a -/// turn-cancel removes the entry and pushes `input_cancelled`, so the SPA -/// never pins its input box to a dead token. -/// -/// # Examples -/// ``` -/// use std::sync::Arc; -/// -/// use workshop_server::{SessionInputBroker, WaitRegistry}; -/// -/// let (frames, _receiver) = tokio::sync::broadcast::channel(8); -/// let broker = SessionInputBroker::new(Arc::new(WaitRegistry::new()), frames); -/// # drop(broker); -/// ``` -#[derive(Debug)] -pub struct SessionInputBroker { - /// The session's wait registry, shared with the session loop that - /// completes and cancels waits. - registry: Arc, - /// Where `input_required` and `input_cancelled` frames are pushed; - /// the session's socket loop forwards them to the SPA. - frames: broadcast::Sender, -} - -impl SessionInputBroker { - /// Builds the broker over the session's wait registry and frame sender. - /// - /// # Examples - /// ``` - /// use std::sync::Arc; - /// - /// use workshop_server::{SessionInputBroker, WaitRegistry}; - /// - /// let registry = Arc::new(WaitRegistry::new()); - /// let (frames, _receiver) = tokio::sync::broadcast::channel(8); - /// let _broker = SessionInputBroker::new(registry, frames); - /// ``` - #[must_use] - pub fn new(registry: Arc, frames: broadcast::Sender) -> Self { - Self { registry, frames } - } -} - -#[async_trait::async_trait] -impl InputBroker for SessionInputBroker { - /// Opens a wait, announces it, and suspends until it resolves. - /// - /// On cancellation - the future dropped mid-await, or the wait - /// cancelled out of the registry - the drop guard removes the wait and - /// pushes `input_cancelled`, so no path leaks a wait or a stale - /// prompt. A wait cancelled out of the registry resolves here as the - /// broker's failure policy. - /// - /// # Errors - /// Returns an [`InputError`] when the wait dies before the operator - /// answers. - async fn user_input( - &self, - _execution: &str, - _section: &str, - ) -> Result { - let (token, receiver) = self.registry.create(); - let mut guard = WaitGuard { - registry: Arc::clone(&self.registry), - frames: self.frames.clone(), - token, - armed: true, - }; - // No receiver means no socket is attached right now. Not a - // failure: the registry retains the wait and the session resends - // it on reconnect, so the lost push is repaired. - let _ = self.frames.send(InputFrame::Required { - token: guard.token.clone(), - }); - match receiver.await { - Ok(text) => { - guard.armed = false; - Ok(InputOutcome::Text(text)) - } - // The sender died without a value: the wait was cancelled out - // of the registry. The still-armed guard pushes - // `input_cancelled` on scope exit, so this path clears the - // SPA prompt too. - Err(_) => Err(InputError::message("the user-input wait was cancelled")), - } - } -} - -#[async_trait::async_trait] -impl Tool for UserInputTool { - fn id(&self) -> ToolId { - ToolId::from_validated("workshop", "user_input") - } - - #[expect( - clippy::unnecessary_literal_bound, - reason = "the Tool trait fixes this return type to &str, so the &'static str suggestion cannot be applied" - )] - fn wire_name(&self) -> &str { - "user_input" - } - - #[expect( - clippy::unnecessary_literal_bound, - reason = "the Tool trait fixes this return type to &str, so the &'static str suggestion cannot be applied" - )] - fn description(&self) -> &str { - "Waits for the workshop operator to type into the session's input box." - } - - fn parameters_schema(&self) -> serde_json::Value { - serde_json::json!({ "type": "object", "properties": {} }) - } - - /// Structured: the JSON object resumes into Lua as a table - /// (`result.text`, `result.images`), which is safe here because the - /// output is trusted - the untrusted wrap that would break a JSON - /// parse never applies. - fn structured_output(&self) -> bool { - true - } - - /// Opens a wait, announces it, and suspends until it resolves. - /// - /// Arguments are ignored: the tool takes none. On cancellation - the - /// future dropped mid-await, or the wait cancelled out of the - /// registry - the drop guard removes the wait and pushes - /// `input_cancelled`, so no path leaks a wait or a stale prompt. - /// - /// # Errors - /// Returns a [`ToolErrorKind::Cancelled`] error when the wait dies - /// before the operator answers. - async fn call(&self, _args: serde_json::Value) -> Result { - let (token, receiver) = self.registry.create(); - let mut guard = WaitGuard { - registry: Arc::clone(&self.registry), - frames: self.frames.clone(), - token, - armed: true, - }; - // No receiver means no socket is attached right now. Not a - // failure: the registry retains the wait and the session resends - // it on reconnect, so the lost push is repaired. - let _ = self.frames.send(InputFrame::Required { - token: guard.token.clone(), - }); - match receiver.await { - Ok(text) => { - guard.armed = false; - let table = serde_json::json!({ "text": text, "images": [] }); - Ok(ToolOutput::trusted(table.to_string())) - } - // The sender died without a value: the wait was cancelled out - // of the registry. The still-armed guard pushes - // `input_cancelled` on scope exit, so this path clears the - // SPA prompt too. - Err(_) => Err(ToolError::message("the user-input wait was cancelled") - .with_kind(ToolErrorKind::Cancelled)), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - use promptforge_core_support::observe::Observation; - use promptforge_tools::OutputTrust; - - /// Hostile operator text covering the bytes most likely to be mangled - /// by an envelope or codec. - const GNARLY: &str = "line1\r\nline2 \"quoted\" {\"text\":\"decoy\"} \\slash \u{1F980}"; - - /// A fresh tool, registry, and channel with no subscribers. - fn tool_fixture() -> ( - UserInputTool, - Arc, - broadcast::Sender, - ) { - let registry = Arc::new(WaitRegistry::new()); - let (frames, _) = broadcast::channel(8); - let tool = UserInputTool::new(Arc::clone(®istry), frames.clone()); - (tool, registry, frames) - } - - async fn registered_token(registry: &WaitRegistry) -> String { - for _ in 0..1024 { - if let Some(token) = registry.unresolved().first().cloned() { - return token; - } - tokio::task::yield_now().await; - } - panic!("the tool call never registered its wait"); - } - - async fn required_token(socket: &mut broadcast::Receiver) -> String { - let frame = socket.recv().await.expect("a frame arrives"); - let InputFrame::Required { token } = frame else { - panic!("expected input_required first, got {frame:?}"); - }; - token - } - - #[test] - fn complete_delivers_the_value_and_consumes_the_token() { - let registry = WaitRegistry::new(); - let (token, mut receiver) = registry.create(); - registry - .complete(&token, "hello".to_owned()) - .expect("a live wait completes"); - assert_eq!( - receiver.try_recv().expect("the value arrived"), - "hello", - "completion delivers the value to the waiting receiver" - ); - assert_eq!( - registry.complete(&token, "again".to_owned()), - Err(WaitError::UnknownToken), - "tokens are single-use: a duplicate complete is refused" - ); - assert!(registry.unresolved().is_empty()); - } - - #[test] - fn an_unknown_token_reports_unknown_and_leaves_live_waits_alone() { - let registry = WaitRegistry::new(); - let (token, mut receiver) = registry.create(); - assert_eq!( - registry.complete("not-a-token", "x".to_owned()), - Err(WaitError::UnknownToken) - ); - assert_eq!( - registry.unresolved(), - vec![token.clone()], - "a refused complete must not disturb the live wait" - ); - registry - .complete(&token, "still here".to_owned()) - .expect("the live wait was untouched"); - assert_eq!( - receiver.try_recv().expect("the value arrived"), - "still here" - ); - } - - #[test] - fn cancel_kills_the_wait_and_its_token() { - let registry = WaitRegistry::new(); - let (token, mut receiver) = registry.create(); - registry.cancel(&token); - assert!( - receiver.try_recv().is_err(), - "a cancelled wait's receiver resolves dead rather than hanging" - ); - assert_eq!( - registry.complete(&token, "late".to_owned()), - Err(WaitError::UnknownToken), - "a cancelled token is dead to completion" - ); - // Cancelling again is the normal cancel-races-completion no-op. - registry.cancel(&token); - } - - #[test] - fn tokens_are_distinct_and_unguessably_wide() { - let registry = WaitRegistry::new(); - let mut receivers = Vec::new(); - let mut seen = std::collections::BTreeSet::new(); - for _ in 0..64 { - let (token, receiver) = registry.create(); - receivers.push(receiver); - assert_eq!(token.len(), 32, "128 bits hex-encode to 32 characters"); - assert!( - token - .bytes() - .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()), - "tokens are lowercase hex" - ); - assert!(seen.insert(token), "every token is unique"); - } - } - - #[test] - fn the_registry_debug_shows_the_count_and_never_a_token() { - let registry = WaitRegistry::new(); - let (token, _receiver) = registry.create(); - let rendered = format!("{registry:?}"); - assert_eq!( - rendered, "WaitRegistry { unresolved: 1 }", - "Debug reports the pending count" - ); - assert!( - !rendered.contains(&token), - "a token in a log would let the log's reader answer the prompt" - ); - } - - #[test] - fn the_tool_declares_structured_output() { - let (tool, _registry, _frames) = tool_fixture(); - assert!( - tool.structured_output(), - "user_input must bind structured so its JSON resumes as a Lua table" - ); - } - - #[tokio::test] - async fn the_tool_emits_input_required_carrying_its_wait_token() { - let (tool, registry, frames) = tool_fixture(); - let mut socket = frames.subscribe(); - let call = tokio::spawn(async move { tool.call(serde_json::json!({})).await }); - let token = required_token(&mut socket).await; - assert_eq!( - registry.unresolved(), - vec![token.clone()], - "the announced token names the retained wait" - ); - registry - .complete(&token, "done".to_owned()) - .expect("the wait completes"); - let output = call - .await - .expect("the task joins") - .expect("the call succeeds"); - assert_eq!(output.trust(), OutputTrust::Trusted); - } - - #[tokio::test] - async fn the_resumed_output_is_a_trusted_table_with_byte_exact_text_and_empty_images() { - let (tool, registry, frames) = tool_fixture(); - let mut socket = frames.subscribe(); - let call = tokio::spawn(async move { tool.call(serde_json::json!({})).await }); - let token = required_token(&mut socket).await; - registry - .complete(&token, GNARLY.to_owned()) - .expect("the wait completes"); - let output = call - .await - .expect("the task joins") - .expect("the call succeeds"); - assert_eq!( - output.trust(), - OutputTrust::Trusted, - "operator input is first-party: no nonce envelope may wrap it" - ); - let table: serde_json::Value = - serde_json::from_str(output.text()).expect("a structured tool returns JSON"); - assert_eq!( - table["text"].as_str().expect("text is a string"), - GNARLY, - "result.text is the SPA text byte-exact and envelope-free" - ); - assert_eq!( - table["images"], - serde_json::json!([]), - "result.images is present and empty in the gate" - ); - assert!( - matches!( - socket.try_recv(), - Err(broadcast::error::TryRecvError::Empty) - ), - "a completed wait dies silently: no input_cancelled follows" - ); - } - - #[tokio::test] - async fn dropping_the_tool_future_removes_the_wait_and_emits_input_cancelled() { - let (tool, registry, frames) = tool_fixture(); - let mut socket = frames.subscribe(); - let call = tokio::spawn(async move { tool.call(serde_json::json!({})).await }); - let token = required_token(&mut socket).await; - call.abort(); - let joined = call.await; - assert!( - joined.is_err_and(|error| error.is_cancelled()), - "abort drops the suspended call" - ); - assert!( - registry.unresolved().is_empty(), - "a dropped future may not leak its wait" - ); - let frame = socket.recv().await.expect("the cancellation frame arrives"); - assert_eq!( - frame, - InputFrame::Cancelled { token }, - "the SPA is told exactly which prompt died" - ); - } - - #[tokio::test] - async fn a_registry_cancel_fails_the_call_as_cancelled_and_emits_input_cancelled() { - let (tool, registry, frames) = tool_fixture(); - let mut socket = frames.subscribe(); - let call = tokio::spawn(async move { tool.call(serde_json::json!({})).await }); - let token = required_token(&mut socket).await; - registry.cancel(&token); - let error = call - .await - .expect("the task joins") - .expect_err("a cancelled wait fails the call"); - assert_eq!(error.kind(), ToolErrorKind::Cancelled); - let frame = socket.recv().await.expect("the cancellation frame arrives"); - assert_eq!( - frame, - InputFrame::Cancelled { token }, - "cancellation is an outcome on the wire, not silence" - ); - } - - #[tokio::test] - async fn a_disconnected_socket_does_not_cancel_the_wait() { - let (tool, registry, frames) = tool_fixture(); - // No subscriber exists at all: the session's socket is gone. - drop(frames); - let call = tokio::spawn(async move { tool.call(serde_json::json!({})).await }); - let token = registered_token(®istry).await; - assert_eq!( - registry.unresolved(), - vec![token.clone()], - "the wait outlives the absent socket" - ); - registry - .complete(&token, "typed after reconnect".to_owned()) - .expect("the retained wait still completes"); - let output = call - .await - .expect("the task joins") - .expect("the call succeeds"); - let table: serde_json::Value = - serde_json::from_str(output.text()).expect("a structured tool returns JSON"); - assert_eq!(table["text"], "typed after reconnect"); - } - - #[tokio::test] - async fn reconnect_resends_unresolved_waits_in_creation_order() { - let registry = WaitRegistry::new(); - let (first, _first_receiver) = registry.create(); - let (second, _second_receiver) = registry.create(); - // The reconnecting client subscribes, then the session resends. - let (frames, mut socket) = broadcast::channel(8); - registry.resend_unresolved(&frames); - assert_eq!( - socket.recv().await.expect("the first resend arrives"), - InputFrame::Required { token: first }, - "resend replays the retained waits" - ); - assert_eq!( - socket.recv().await.expect("the second resend arrives"), - InputFrame::Required { token: second }, - "resend preserves creation order" - ); - } - - #[derive(Default)] - struct RecordingObserver { - inputs: Mutex>, - } - - impl RecordingObserver { - fn inputs(&self) -> MutexGuard<'_, Vec<(String, String, String)>> { - self.inputs.lock().expect("the recorder mutex stays usable") - } - } - - impl Observer for RecordingObserver { - fn observe(&self, _execution: &str, _section: &str, _event: Observation) {} - - fn on_user_input(&self, execution: &str, section: &str, text: &str) { - self.inputs() - .push((execution.to_owned(), section.to_owned(), text.to_owned())); - } - } - - #[test] - fn on_user_input_fires_exactly_once_per_response_byte_exact_before_completion() { - let registry = WaitRegistry::new(); - let observer = RecordingObserver::default(); - let (token, mut receiver) = registry.create(); - deliver_input_response( - &observer, - ®istry, - "run-1", - "chat", - InputResponse { - token: token.clone(), - text: GNARLY.to_owned(), - }, - ) - .expect("a live wait completes"); - assert_eq!( - receiver.try_recv().expect("the wait resumed"), - GNARLY, - "the completed value is the response text byte-exact" - ); - assert_eq!( - observer.inputs().as_slice(), - &[("run-1".to_owned(), "chat".to_owned(), GNARLY.to_owned())], - "exactly one byte-exact event per response" - ); - // A duplicate response still records the operator's text - one - // event per response - while the dead wait reports as the error. - assert_eq!( - deliver_input_response( - &observer, - ®istry, - "run-1", - "chat", - InputResponse { - token, - text: "again".to_owned(), - }, - ), - Err(WaitError::UnknownToken) - ); - assert_eq!( - observer.inputs().len(), - 2, - "the event fires exactly once per response, even a stale one" - ); - } - - /// A fresh broker, registry, and channel with no subscribers. - fn broker_fixture() -> ( - SessionInputBroker, - Arc, - broadcast::Sender, - ) { - let registry = Arc::new(WaitRegistry::new()); - let (frames, _) = broadcast::channel(8); - let broker = SessionInputBroker::new(Arc::clone(®istry), frames.clone()); - (broker, registry, frames) - } - - #[tokio::test] - async fn the_broker_announces_the_wait_and_resolves_with_the_operator_text() { - let (broker, registry, frames) = broker_fixture(); - let mut socket = frames.subscribe(); - let call = tokio::spawn(async move { broker.user_input("run", "chat").await }); - let token = required_token(&mut socket).await; - assert_eq!( - registry.unresolved(), - vec![token.clone()], - "the announced token names the retained wait" - ); - registry - .complete(&token, GNARLY.to_owned()) - .expect("the wait completes"); - let outcome = call - .await - .expect("the task joins") - .expect("the broker answers"); - assert_eq!( - outcome, - InputOutcome::Text(GNARLY.to_owned()), - "the operator's text rides back byte-exact" - ); - assert!( - matches!( - socket.try_recv(), - Err(broadcast::error::TryRecvError::Empty) - ), - "a completed wait dies silently: no input_cancelled follows" - ); - } - - #[tokio::test] - async fn a_dropped_broker_future_removes_the_wait_and_emits_input_cancelled() { - let (broker, registry, frames) = broker_fixture(); - let mut socket = frames.subscribe(); - let call = tokio::spawn(async move { broker.user_input("run", "chat").await }); - let token = required_token(&mut socket).await; - call.abort(); - let joined = call.await; - assert!( - joined.is_err_and(|error| error.is_cancelled()), - "abort drops the suspended call" - ); - assert!( - registry.unresolved().is_empty(), - "a dropped future may not leak its wait" - ); - let frame = socket.recv().await.expect("the cancellation frame arrives"); - assert_eq!( - frame, - InputFrame::Cancelled { token }, - "the SPA is told exactly which prompt died" - ); - } - - #[tokio::test] - async fn a_registry_cancel_fails_the_broker_call_and_emits_input_cancelled() { - let (broker, registry, frames) = broker_fixture(); - let mut socket = frames.subscribe(); - let call = tokio::spawn(async move { broker.user_input("run", "chat").await }); - let token = required_token(&mut socket).await; - registry.cancel(&token); - let error = call - .await - .expect("the task joins") - .expect_err("a cancelled wait fails the broker call"); - assert_eq!(error.to_string(), "the user-input wait was cancelled"); - let frame = socket.recv().await.expect("the cancellation frame arrives"); - assert_eq!( - frame, - InputFrame::Cancelled { token }, - "cancellation is an outcome on the wire, not silence" - ); - } -} diff --git a/crates/workshop-server/src/lib.rs b/crates/workshop-server/src/lib.rs index 8984cd08a..0906a0b43 100644 --- a/crates/workshop-server/src/lib.rs +++ b/crates/workshop-server/src/lib.rs @@ -7,19 +7,22 @@ //! waits, [`AgentSessions`] for the agent-session registry behind //! `/agents/ws`, and [`router`] for the HTTP API; [`spawn`] runs the whole //! server in-process on its own thread for embedding binaries. +//! +//! The crate is the composition root of the workshop server +//! decomposition: the feature subsystems (`workshop-sessions`, +//! `workshop-workspace`), the domain services (`workshop-gateway`, +//! `workshop-status`, `workshop-menu`), and the vocabulary crates +//! (`workshop-protocol`, `workshop-registry`, `workshop-support`) are +//! assembled in `app.rs`, where every subsystem self-registers its +//! routes, state handles, and push channels into the registry. mod app; mod assets; mod cross_site; mod csp; mod error; -mod input; -mod relay; mod routes; mod serve; -mod session; -mod session_agents; -mod workspace; // The extracted subsystem crates, aliased at their pre-decomposition // module paths so the shell's internals read as they did before the @@ -58,15 +61,15 @@ pub use gateway::{ SwitchEvent, SwitchEventStream, SwitchResponse, switch_events, }; pub use gateway_binding::{GatewayPublicationError, GatewayUpdater}; -pub use input::{ - SessionInputBroker, UserInputTool, WaitError, WaitRegistry, deliver_input_response, -}; pub use observer::WorkshopObserver; pub use push::Push; pub use resolve::{GatewaySource, ResolveError, ResolvedGateway}; pub use serve::{ServerHandle, SpawnError, Termination, spawn}; -pub use session_agents::AgentSessions; pub use workshop_protocol::{Activity, InputFrame, InputResponse}; +pub use workshop_sessions::{ + AgentSessions, SessionInputBroker, UserInputTool, WaitError, WaitRegistry, + deliver_input_response, +}; pub use workshop_support::{ AgentsConfig, Config, ConfigError, DEFAULT_CONFIG_PATH, GatewayConfig, ServerConfig, }; diff --git a/crates/workshop-server/src/relay.rs b/crates/workshop-server/src/relay.rs deleted file mode 100644 index a643bcbab..000000000 --- a/crates/workshop-server/src/relay.rs +++ /dev/null @@ -1,176 +0,0 @@ -//! The buffered gateway relay: the `/v1/models` catalog passthrough and -//! the helpers that shape gateway responses for the wire. - -use axum::extract::State; -use axum::http::header; -use axum::response::{IntoResponse, Response}; - -use crate::app::AppState; -use crate::error::AppError; -use crate::gateway::{GatewayError, GatewayResponse}; -use crate::push::Push; -use workshop_protocol::Activity; - -/// Relays the gateway's model catalog to the caller verbatim. -/// -/// While the heartbeat reports the gateway down, the catalog is not -/// attempted: the route answers 502 with a user-visible message instead. -pub(crate) async fn models(State(state): State) -> Response { - if !state.health().is_reachable() { - return AppError::GatewayUnreachable.into_response(); - } - let push = state.push(); - push.push_status_update( - "Loading models...", - "fetching the gateway model catalog", - Activity::General, - ); - let gateway = state.gateway_snapshot(); - let result = gateway.client().list_models().await; - report_gateway_outcome(&push, &result, "GET /v1/models"); - relay(result) -} - -/// Reports a gateway call's outcome on the status bus: back to idle on -/// success, otherwise the error label matching the failure shape. -fn report_gateway_outcome( - push: &Push, - result: &Result, - route: &str, -) { - match result { - Ok(upstream) if upstream.status.is_success() => push.push_idle(), - Ok(upstream) => push.push_failure( - format!("Gateway error: {}", upstream.status), - format!("{route} answered a non-success status"), - Activity::General, - ), - Err(error) => push.push_failure("Connection lost", error.to_string(), Activity::General), - } -} - -/// Parses a gateway body as JSON, falling back to a plain string. -pub(crate) fn value_from_bytes(body: &[u8]) -> serde_json::Value { - serde_json::from_slice(body) - .unwrap_or_else(|_| serde_json::Value::String(String::from_utf8_lossy(body).into_owned())) -} - -/// Turns a gateway call outcome into the workshop's HTTP response. -/// -/// Success (any status) is relayed byte-for-byte; a transport failure -/// becomes `502 Bad Gateway` through the [`AppError`] wire envelope. -pub(crate) fn relay(result: Result) -> Response { - match result { - Ok(upstream) => ( - upstream.status, - [(header::CONTENT_TYPE, "application/json")], - upstream.body, - ) - .into_response(), - Err(error) => AppError::Gateway(error).into_response(), - } -} - -#[cfg(test)] -mod tests { - use axum::Router; - use axum::body::Body; - use axum::http::{HeaderMap, Request, StatusCode, header}; - use axum::response::{IntoResponse, Response}; - use axum::routing::get; - use tower::ServiceExt; - - use crate::app::fixtures::{body_bytes, spawn_gateway, state_for}; - use crate::app::router; - - const CATALOG: &str = r#"{"object":"list","data":[{"id":"test-model","object":"model","created":1,"owned_by":"promptforge"}]}"#; - const UPSTREAM_ERROR: &str = - r#"{"error":{"message":"model unloaded","code":"upstream_unavailable"}}"#; - - fn authorized(headers: &HeaderMap) -> bool { - headers - .get(header::AUTHORIZATION) - .and_then(|value| value.to_str().ok()) - == Some("Bearer test-key") - } - - async fn mock_models(headers: HeaderMap) -> Response { - if !authorized(&headers) { - return StatusCode::UNAUTHORIZED.into_response(); - } - ([(header::CONTENT_TYPE, "application/json")], CATALOG).into_response() - } - - async fn mock_broken_models() -> Response { - ( - StatusCode::SERVICE_UNAVAILABLE, - [(header::CONTENT_TYPE, "application/json")], - UPSTREAM_ERROR, - ) - .into_response() - } - - fn models_request() -> Request { - Request::builder() - .uri("/v1/models") - .body(Body::empty()) - .expect("static request parts are valid") - } - - #[tokio::test] - async fn models_are_relayed_byte_for_byte() { - let base_url = spawn_gateway(Router::new().route("/v1/models", get(mock_models))).await; - let (state, _state_dir) = state_for(&base_url); - let response = router(state) - .oneshot(models_request()) - .await - .expect("the router is infallible"); - assert_eq!(response.status(), StatusCode::OK); - assert_eq!(&body_bytes(response).await[..], CATALOG.as_bytes()); - } - - #[tokio::test] - async fn gateway_error_status_is_relayed_byte_for_byte() { - let base_url = - spawn_gateway(Router::new().route("/v1/models", get(mock_broken_models))).await; - let (state, _state_dir) = state_for(&base_url); - let response = router(state) - .oneshot(models_request()) - .await - .expect("the router is infallible"); - assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); - assert_eq!(&body_bytes(response).await[..], UPSTREAM_ERROR.as_bytes()); - } - - #[tokio::test] - async fn unreachable_gateway_becomes_bad_gateway() { - // Port 1 is never listening, so the connect fails deterministically. - let (state, _state_dir) = state_for("http://127.0.0.1:1"); - let response = router(state) - .oneshot(models_request()) - .await - .expect("the router is infallible"); - assert_eq!(response.status(), StatusCode::BAD_GATEWAY); - let body = body_bytes(response).await; - let json: serde_json::Value = serde_json::from_slice(&body).expect("error body is JSON"); - assert_eq!(json["error"]["code"], "gateway_unreachable"); - } - - #[tokio::test] - async fn a_gateway_known_down_short_circuits_the_catalog_with_bad_gateway() { - let (state, _state_dir) = state_for("http://127.0.0.1:1"); - state.health().publish(false); - let response = router(state) - .oneshot(models_request()) - .await - .expect("the router is infallible"); - assert_eq!(response.status(), StatusCode::BAD_GATEWAY); - let body = body_bytes(response).await; - let json: serde_json::Value = serde_json::from_slice(&body).expect("error body is JSON"); - assert_eq!(json["error"]["code"], "gateway_unreachable"); - assert_eq!( - json["error"]["message"], "Gateway unreachable", - "the short-circuit message is user-visible" - ); - } -} diff --git a/crates/workshop-server/src/routes.rs b/crates/workshop-server/src/routes.rs index 9ff6a613f..58cb861c4 100644 --- a/crates/workshop-server/src/routes.rs +++ b/crates/workshop-server/src/routes.rs @@ -1,9 +1,10 @@ //! Per-feature route constructors, one child module per domain, composed -//! into the full router by [`crate::app::router`]. +//! into the full router by [`crate::app::router`]. The extracted feature +//! subsystems (`/ws`, `/agents/ws`, `/v1/models`, `/workspace/*`) +//! self-register their routes through the registry instead of appearing +//! here. pub(crate) mod assets; -pub(crate) mod chat; pub(crate) mod gateway_config; pub(crate) mod health; pub(crate) mod realtime; -pub(crate) mod workspace; diff --git a/crates/workshop-server/src/routes/chat.rs b/crates/workshop-server/src/routes/chat.rs deleted file mode 100644 index 9796c360b..000000000 --- a/crates/workshop-server/src/routes/chat.rs +++ /dev/null @@ -1,72 +0,0 @@ -//! Routes for the gateway relay: the model catalog passthrough and the -//! `/ws` workshop socket. The buffered `POST /chat` completion is gone - -//! chat runs through agent sessions on `/agents/ws` - so `/chat` answers -//! 404 like any unknown API path. - -use axum::Router; -use axum::routing::get; - -use crate::app::AppState; -use crate::{relay, session}; -use workshop_support::{RELAY_DEADLINE, with_deadline}; - -/// The relay routes. They take the whole [`AppState`]: the handlers reach -/// the gateway client, the health flag, and the status and catalog buses. -/// The buffered relay route waits on a gateway call, so it carries the -/// relay deadline; `/ws` is added after the layer and carries none - the -/// upgrade answers immediately and the session then outlives any deadline. -pub(crate) fn routes(state: AppState) -> Router { - with_deadline( - Router::new().route("/v1/models", get(relay::models)), - RELAY_DEADLINE, - ) - .route("/ws", get(session::upgrade)) - .with_state(state) -} - -#[cfg(test)] -mod tests { - use axum::body::Body; - use axum::http::{Request, StatusCode, header}; - use tower::ServiceExt; - - use crate::app::fixtures::state_for; - use crate::app::router; - - /// A plain GET to `/ws` without upgrade headers is rejected with 400, - /// which proves the route is mounted; the WebSocket flow is covered - /// by the integration binary's `session` modules over a live socket. - #[tokio::test] - async fn ws_route_rejects_a_non_upgrade_get() { - let (state, _state_dir) = state_for("http://127.0.0.1:1"); - let request = Request::builder() - .uri("/ws") - .body(Body::empty()) - .expect("static request parts are valid"); - let response = router(state) - .oneshot(request) - .await - .expect("the router is infallible"); - assert_eq!(response.status(), StatusCode::BAD_REQUEST); - } - - /// The excised buffered chat endpoint is gone from the router: a - /// `POST /chat` answers 404, not a relay response. - #[tokio::test] - async fn post_chat_is_absent_and_answers_not_found() { - let (state, _state_dir) = state_for("http://127.0.0.1:1"); - let request = Request::builder() - .method("POST") - .uri("/chat") - .header(header::CONTENT_TYPE, "application/json") - .body(Body::from( - r#"{"model":"test-model","messages":[{"role":"user","content":"ping"}]}"#, - )) - .expect("static request parts are valid"); - let response = router(state) - .oneshot(request) - .await - .expect("the router is infallible"); - assert_eq!(response.status(), StatusCode::NOT_FOUND); - } -} diff --git a/crates/workshop-server/src/routes/workspace.rs b/crates/workshop-server/src/routes/workspace.rs deleted file mode 100644 index 7e7c728ac..000000000 --- a/crates/workshop-server/src/routes/workspace.rs +++ /dev/null @@ -1,20 +0,0 @@ -//! Routes for confined workspace filesystem access. - -use axum::Router; -use axum::routing::{get, post}; - -use crate::workspace::{self, Workspace}; - -/// The workspace routes, narrowed to the [`Workspace`] service - the only -/// state their handlers use. -pub(crate) fn routes(state: Workspace) -> Router { - Router::new() - .route("/workspace/tree", get(workspace::tree)) - .route( - "/workspace/file", - get(workspace::read_file).put(workspace::write_file), - ) - .route("/workspace/grant", post(workspace::grant)) - .route("/workspace/revoke", post(workspace::revoke)) - .with_state(state) -} diff --git a/crates/workshop-server/src/serve.rs b/crates/workshop-server/src/serve.rs index 4dc705e59..a2688c719 100644 --- a/crates/workshop-server/src/serve.rs +++ b/crates/workshop-server/src/serve.rs @@ -274,17 +274,17 @@ fn serve_thread( // start with serving and stop inside the same graceful-shutdown // signal, so they never outlive the server. let heartbeat = heartbeat::spawn( - state.gateway_binding().clone(), + state.gateway_binding(), state.push(), - state.health().clone(), + state.health(), heartbeat::HEARTBEAT_INTERVAL, - state.backoff().clone(), + state.backoff(), ); let renderer = progress::spawn(std::sync::Arc::clone(state.progress()), state.push()); let subscriber = gateway_progress::spawn( - state.gateway_binding().clone(), + state.gateway_binding(), std::sync::Arc::clone(state.progress()), - state.health().clone(), + state.health(), ); let (draining_tx, draining_rx) = tokio::sync::oneshot::channel(); let serve = async { @@ -347,282 +347,4 @@ fn reuse_bind(address: &str) -> std::io::Result { } #[cfg(test)] -mod tests { - use super::*; - - use std::path::Path; - - use workshop_support::{AgentsConfig, GatewayConfig, ServerConfig}; - fn test_config(bind: &str, state_dir: &Path) -> Config { - Config { - gateway: GatewayConfig { - base_url: "http://127.0.0.1:1".to_string(), - api_key: "test-key".to_string(), - }, - server: ServerConfig { - bind: bind.to_string(), - open_browser: false, - state_dir: state_dir.to_path_buf(), - }, - agents: AgentsConfig::default(), - } - } - - #[tokio::test] - async fn readiness_means_the_health_endpoint_answers() { - let dir = tempfile::TempDir::new().expect("tempdir"); - let server = spawn_with_grace(test_config("127.0.0.1:0", dir.path()), SHUTDOWN_GRACE) - .expect("server spawns"); - let url = server.url().to_string(); - assert!( - url.starts_with("http://127.0.0.1:"), - "the URL carries the bound loopback address: {url}" - ); - - let response = reqwest::get(format!("{url}/health")) - .await - .expect("the health endpoint answers once spawn returns"); - assert_eq!(response.status(), reqwest::StatusCode::OK); - let body = response.text().await.expect("the health body reads"); - assert_eq!(body, r#"{"status":"serving"}"#); - - server.shutdown().expect("graceful shutdown succeeds"); - } - - /// The test config points the gateway at port 1, which never listens: - /// the server must still boot and serve - the UI and its own health - /// endpoint do not depend on the gateway, and the heartbeat reports - /// the outage instead of failing startup. - #[tokio::test] - async fn the_server_boots_and_serves_the_ui_with_an_unreachable_gateway() { - let dir = tempfile::TempDir::new().expect("tempdir"); - let server = spawn_with_grace(test_config("127.0.0.1:0", dir.path()), SHUTDOWN_GRACE) - .expect("server spawns"); - let url = server.url().to_string(); - - let health = reqwest::get(format!("{url}/health")) - .await - .expect("the health endpoint answers"); - assert_eq!(health.status(), reqwest::StatusCode::OK); - let index = reqwest::get(format!("{url}/")) - .await - .expect("the UI answers"); - assert_eq!(index.status(), reqwest::StatusCode::OK); - - server.shutdown().expect("graceful shutdown succeeds"); - } - - /// A grace window short enough that the forced path proves itself in - /// milliseconds instead of stalling the suite. - const TEST_GRACE: Duration = Duration::from_millis(200); - - /// Connects a WebSocket to the workshop socket and returns it for the - /// caller to hold open. - async fn hold_ws_open( - url: &str, - ) -> tokio_tungstenite::WebSocketStream> - { - let address = url.strip_prefix("http://").expect("the URL is http"); - let (socket, _) = tokio_tungstenite::connect_async(format!("ws://{address}/ws")) - .await - .expect("the chat socket connects"); - socket - } - - /// Opens a raw connection and wedges it mid-request: the head promises - /// a body that never fully arrives, so the handler waits on the body, - /// no response begins, and the connection holds axum's graceful drain - /// open until torn down. The head must pass the cross-site guard - a - /// loopback `Host` and a JSON content type - or the guard answers 403 - /// without ever polling the body and nothing wedges. - async fn wedge_http_connection(url: &str) -> tokio::net::TcpStream { - use tokio::io::AsyncWriteExt as _; - - let address = url.strip_prefix("http://").expect("the URL is http"); - let mut wedged = tokio::net::TcpStream::connect(address) - .await - .expect("the raw connection opens"); - wedged - .write_all( - b"POST /workspace/grant HTTP/1.1\r\nhost: 127.0.0.1\r\n\ - content-type: application/json\r\ncontent-length: 64\r\n\r\n{", - ) - .await - .expect("the wedged request head sends"); - // Give the accept loop and the handler a beat to pick the request - // up, so the connection is in-flight before shutdown begins. - tokio::time::sleep(Duration::from_millis(50)).await; - wedged - } - - /// The regression this step exists to prevent: a client that never - /// closes its WebSocket must not park shutdown forever. The upgrade - /// detaches the session from axum's graceful drain, so today this stop - /// is even graceful; the assertion pins only the bound, which the - /// watchdog keeps true however axum's connection tracking evolves. - #[tokio::test] - async fn a_held_websocket_does_not_block_shutdown_past_the_grace_window() { - let dir = tempfile::TempDir::new().expect("tempdir"); - let server = spawn_with_grace(test_config("127.0.0.1:0", dir.path()), TEST_GRACE) - .expect("server spawns"); - let _held = hold_ws_open(server.url()).await; - - let begun = std::time::Instant::now(); - server - .shutdown() - .expect("shutdown returns despite the held socket"); - assert!( - begun.elapsed() < Duration::from_secs(3), - "shutdown must return shortly after the grace window, took {:?}", - begun.elapsed() - ); - } - - /// A connection wedged mid-request does hold the graceful drain open, - /// so the watchdog must abandon the wait at the window and report the - /// stop as forced. - #[tokio::test] - async fn a_wedged_http_connection_is_forced_out_at_the_grace_window() { - let dir = tempfile::TempDir::new().expect("tempdir"); - let server = spawn_with_grace(test_config("127.0.0.1:0", dir.path()), TEST_GRACE) - .expect("server spawns"); - let _wedged = wedge_http_connection(server.url()).await; - - let begun = std::time::Instant::now(); - let outcome = server - .shutdown() - .expect("shutdown returns despite the wedged connection"); - assert_eq!( - outcome, - Termination::Forced, - "an in-flight request cannot drain; the watchdog must force the stop" - ); - assert!( - begun.elapsed() < Duration::from_secs(3), - "shutdown must return shortly after the grace window, took {:?}", - begun.elapsed() - ); - } - - #[tokio::test] - async fn an_idle_shutdown_completes_gracefully_without_spending_the_window() { - let dir = tempfile::TempDir::new().expect("tempdir"); - let server = spawn_with_grace(test_config("127.0.0.1:0", dir.path()), SHUTDOWN_GRACE) - .expect("server spawns"); - - let begun = std::time::Instant::now(); - let outcome = server.shutdown().expect("graceful shutdown succeeds"); - assert_eq!( - outcome, - Termination::Graceful, - "nothing held the drain open" - ); - assert!( - begun.elapsed() < SHUTDOWN_GRACE, - "an idle server must stop before the watchdog matters, took {:?}", - begun.elapsed() - ); - } - - #[test] - fn server_shutdown_permanently_closes_every_host_updater_clone() { - let dir = tempfile::TempDir::new().expect("tempdir"); - let server = spawn_with_grace(test_config("127.0.0.1:0", dir.path()), SHUTDOWN_GRACE) - .expect("server spawns"); - let updater = server.gateway_updater(); - let clone = updater.clone(); - - server.shutdown().expect("graceful shutdown succeeds"); - - assert!(updater.publication_closed()); - assert!( - clone.publication_closed(), - "application teardown closes the shared publication state for every clone" - ); - } - - #[test] - fn server_handle_reports_the_identity_initially_published_into_state() { - let dir = tempfile::TempDir::new().expect("tempdir"); - let gateway = crate::test_gateway::ValidatedGateway::spawn_in( - "initial-key", - "fixtures::validated_gateway_fixture_process", - ); - let identity = gateway.validate("initial-key", 1_778_000_001, "2026-09-08T18:00:01Z"); - let resolved = ResolvedGateway::from_validated(identity.clone()); - let server = spawn_inner( - test_config("127.0.0.1:0", dir.path()), - Some(resolved), - SHUTDOWN_GRACE, - ) - .expect("server spawns"); - - assert!( - server - .initial_gateway_identity() - .is_some_and(|published| published.same_boot(&identity)), - "the host can authenticate which launch candidate entered server state" - ); - server.shutdown().expect("graceful shutdown succeeds"); - } - - /// The stopped barrier: when `shutdown` returns, the server is really - /// gone - nothing listens on its address - even when the stop was - /// forced. - #[tokio::test] - async fn the_stopped_barrier_reports_after_serving_has_ended() { - let dir = tempfile::TempDir::new().expect("tempdir"); - let server = spawn_with_grace(test_config("127.0.0.1:0", dir.path()), TEST_GRACE) - .expect("server spawns"); - let address = server - .url() - .strip_prefix("http://") - .expect("the URL is http") - .to_string(); - let _wedged = wedge_http_connection(server.url()).await; - - let outcome = server.shutdown().expect("shutdown returns"); - assert_eq!( - outcome, - Termination::Forced, - "the wedged connection forces the stop" - ); - let refused = tokio::net::TcpStream::connect(&address).await; - assert!( - refused.is_err(), - "the stopped barrier resolves only after serving has ended, yet {address} accepted" - ); - } - - #[tokio::test] - async fn shutdown_releases_the_bound_port() { - let dir = tempfile::TempDir::new().expect("tempdir"); - let server = spawn_with_grace(test_config("127.0.0.1:0", dir.path()), SHUTDOWN_GRACE) - .expect("server spawns"); - let address = server - .url() - .strip_prefix("http://") - .expect("the URL is http") - .to_string(); - server.shutdown().expect("graceful shutdown succeeds"); - - let listener = tokio::net::TcpListener::bind(&address) - .await - .expect("the port is free after shutdown"); - drop(listener); - } - - #[test] - fn a_bind_conflict_fails_spawn_with_io_error() { - let blocker = std::net::TcpListener::bind("127.0.0.1:0").expect("bind blocker"); - let address = blocker.local_addr().expect("blocker address"); - let dir = tempfile::TempDir::new().expect("tempdir"); - let config = test_config(&address.to_string(), dir.path()); - let error = - spawn_with_grace(config, SHUTDOWN_GRACE).expect_err("a taken port must fail spawn"); - assert!( - matches!(error, SpawnError::Io(_)), - "expected Io, got {error:?}" - ); - } -} +mod tests; diff --git a/crates/workshop-server/src/serve/tests.rs b/crates/workshop-server/src/serve/tests.rs new file mode 100644 index 000000000..f4ce14dd0 --- /dev/null +++ b/crates/workshop-server/src/serve/tests.rs @@ -0,0 +1,275 @@ +use super::*; + +use std::path::Path; + +use workshop_support::{AgentsConfig, GatewayConfig, ServerConfig}; +fn test_config(bind: &str, state_dir: &Path) -> Config { + Config { + gateway: GatewayConfig { + base_url: "http://127.0.0.1:1".to_string(), + api_key: "test-key".to_string(), + }, + server: ServerConfig { + bind: bind.to_string(), + open_browser: false, + state_dir: state_dir.to_path_buf(), + }, + agents: AgentsConfig::default(), + } +} + +#[tokio::test] +async fn readiness_means_the_health_endpoint_answers() { + let dir = tempfile::TempDir::new().expect("tempdir"); + let server = spawn_with_grace(test_config("127.0.0.1:0", dir.path()), SHUTDOWN_GRACE) + .expect("server spawns"); + let url = server.url().to_string(); + assert!( + url.starts_with("http://127.0.0.1:"), + "the URL carries the bound loopback address: {url}" + ); + + let response = reqwest::get(format!("{url}/health")) + .await + .expect("the health endpoint answers once spawn returns"); + assert_eq!(response.status(), reqwest::StatusCode::OK); + let body = response.text().await.expect("the health body reads"); + assert_eq!(body, r#"{"status":"serving"}"#); + + server.shutdown().expect("graceful shutdown succeeds"); +} + +/// The test config points the gateway at port 1, which never listens: +/// the server must still boot and serve - the UI and its own health +/// endpoint do not depend on the gateway, and the heartbeat reports +/// the outage instead of failing startup. +#[tokio::test] +async fn the_server_boots_and_serves_the_ui_with_an_unreachable_gateway() { + let dir = tempfile::TempDir::new().expect("tempdir"); + let server = spawn_with_grace(test_config("127.0.0.1:0", dir.path()), SHUTDOWN_GRACE) + .expect("server spawns"); + let url = server.url().to_string(); + + let health = reqwest::get(format!("{url}/health")) + .await + .expect("the health endpoint answers"); + assert_eq!(health.status(), reqwest::StatusCode::OK); + let index = reqwest::get(format!("{url}/")) + .await + .expect("the UI answers"); + assert_eq!(index.status(), reqwest::StatusCode::OK); + + server.shutdown().expect("graceful shutdown succeeds"); +} + +/// A grace window short enough that the forced path proves itself in +/// milliseconds instead of stalling the suite. +const TEST_GRACE: Duration = Duration::from_millis(200); + +/// Connects a WebSocket to the workshop socket and returns it for the +/// caller to hold open. +async fn hold_ws_open( + url: &str, +) -> tokio_tungstenite::WebSocketStream> { + let address = url.strip_prefix("http://").expect("the URL is http"); + let (socket, _) = tokio_tungstenite::connect_async(format!("ws://{address}/ws")) + .await + .expect("the chat socket connects"); + socket +} + +/// Opens a raw connection and wedges it mid-request: the head promises +/// a body that never fully arrives, so the handler waits on the body, +/// no response begins, and the connection holds axum's graceful drain +/// open until torn down. The head must pass the cross-site guard - a +/// loopback `Host` and a JSON content type - or the guard answers 403 +/// without ever polling the body and nothing wedges. +async fn wedge_http_connection(url: &str) -> tokio::net::TcpStream { + use tokio::io::AsyncWriteExt as _; + + let address = url.strip_prefix("http://").expect("the URL is http"); + let mut wedged = tokio::net::TcpStream::connect(address) + .await + .expect("the raw connection opens"); + wedged + .write_all( + b"POST /workspace/grant HTTP/1.1\r\nhost: 127.0.0.1\r\n\ + content-type: application/json\r\ncontent-length: 64\r\n\r\n{", + ) + .await + .expect("the wedged request head sends"); + // Give the accept loop and the handler a beat to pick the request + // up, so the connection is in-flight before shutdown begins. + tokio::time::sleep(Duration::from_millis(50)).await; + wedged +} + +/// The regression this step exists to prevent: a client that never +/// closes its WebSocket must not park shutdown forever. The upgrade +/// detaches the session from axum's graceful drain, so today this stop +/// is even graceful; the assertion pins only the bound, which the +/// watchdog keeps true however axum's connection tracking evolves. +#[tokio::test] +async fn a_held_websocket_does_not_block_shutdown_past_the_grace_window() { + let dir = tempfile::TempDir::new().expect("tempdir"); + let server = spawn_with_grace(test_config("127.0.0.1:0", dir.path()), TEST_GRACE) + .expect("server spawns"); + let _held = hold_ws_open(server.url()).await; + + let begun = std::time::Instant::now(); + server + .shutdown() + .expect("shutdown returns despite the held socket"); + assert!( + begun.elapsed() < Duration::from_secs(3), + "shutdown must return shortly after the grace window, took {:?}", + begun.elapsed() + ); +} + +/// A connection wedged mid-request does hold the graceful drain open, +/// so the watchdog must abandon the wait at the window and report the +/// stop as forced. +#[tokio::test] +async fn a_wedged_http_connection_is_forced_out_at_the_grace_window() { + let dir = tempfile::TempDir::new().expect("tempdir"); + let server = spawn_with_grace(test_config("127.0.0.1:0", dir.path()), TEST_GRACE) + .expect("server spawns"); + let _wedged = wedge_http_connection(server.url()).await; + + let begun = std::time::Instant::now(); + let outcome = server + .shutdown() + .expect("shutdown returns despite the wedged connection"); + assert_eq!( + outcome, + Termination::Forced, + "an in-flight request cannot drain; the watchdog must force the stop" + ); + assert!( + begun.elapsed() < Duration::from_secs(3), + "shutdown must return shortly after the grace window, took {:?}", + begun.elapsed() + ); +} + +#[tokio::test] +async fn an_idle_shutdown_completes_gracefully_without_spending_the_window() { + let dir = tempfile::TempDir::new().expect("tempdir"); + let server = spawn_with_grace(test_config("127.0.0.1:0", dir.path()), SHUTDOWN_GRACE) + .expect("server spawns"); + + let begun = std::time::Instant::now(); + let outcome = server.shutdown().expect("graceful shutdown succeeds"); + assert_eq!( + outcome, + Termination::Graceful, + "nothing held the drain open" + ); + assert!( + begun.elapsed() < SHUTDOWN_GRACE, + "an idle server must stop before the watchdog matters, took {:?}", + begun.elapsed() + ); +} + +#[test] +fn server_shutdown_permanently_closes_every_host_updater_clone() { + let dir = tempfile::TempDir::new().expect("tempdir"); + let server = spawn_with_grace(test_config("127.0.0.1:0", dir.path()), SHUTDOWN_GRACE) + .expect("server spawns"); + let updater = server.gateway_updater(); + let clone = updater.clone(); + + server.shutdown().expect("graceful shutdown succeeds"); + + assert!(updater.publication_closed()); + assert!( + clone.publication_closed(), + "application teardown closes the shared publication state for every clone" + ); +} + +#[test] +fn server_handle_reports_the_identity_initially_published_into_state() { + let dir = tempfile::TempDir::new().expect("tempdir"); + let gateway = crate::test_gateway::ValidatedGateway::spawn_in( + "initial-key", + "fixtures::validated_gateway_fixture_process", + ); + let identity = gateway.validate("initial-key", 1_778_000_001, "2026-09-08T18:00:01Z"); + let resolved = ResolvedGateway::from_validated(identity.clone()); + let server = spawn_inner( + test_config("127.0.0.1:0", dir.path()), + Some(resolved), + SHUTDOWN_GRACE, + ) + .expect("server spawns"); + + assert!( + server + .initial_gateway_identity() + .is_some_and(|published| published.same_boot(&identity)), + "the host can authenticate which launch candidate entered server state" + ); + server.shutdown().expect("graceful shutdown succeeds"); +} + +/// The stopped barrier: when `shutdown` returns, the server is really +/// gone - nothing listens on its address - even when the stop was +/// forced. +#[tokio::test] +async fn the_stopped_barrier_reports_after_serving_has_ended() { + let dir = tempfile::TempDir::new().expect("tempdir"); + let server = spawn_with_grace(test_config("127.0.0.1:0", dir.path()), TEST_GRACE) + .expect("server spawns"); + let address = server + .url() + .strip_prefix("http://") + .expect("the URL is http") + .to_string(); + let _wedged = wedge_http_connection(server.url()).await; + + let outcome = server.shutdown().expect("shutdown returns"); + assert_eq!( + outcome, + Termination::Forced, + "the wedged connection forces the stop" + ); + let refused = tokio::net::TcpStream::connect(&address).await; + assert!( + refused.is_err(), + "the stopped barrier resolves only after serving has ended, yet {address} accepted" + ); +} + +#[tokio::test] +async fn shutdown_releases_the_bound_port() { + let dir = tempfile::TempDir::new().expect("tempdir"); + let server = spawn_with_grace(test_config("127.0.0.1:0", dir.path()), SHUTDOWN_GRACE) + .expect("server spawns"); + let address = server + .url() + .strip_prefix("http://") + .expect("the URL is http") + .to_string(); + server.shutdown().expect("graceful shutdown succeeds"); + + let listener = tokio::net::TcpListener::bind(&address) + .await + .expect("the port is free after shutdown"); + drop(listener); +} + +#[test] +fn a_bind_conflict_fails_spawn_with_io_error() { + let blocker = std::net::TcpListener::bind("127.0.0.1:0").expect("bind blocker"); + let address = blocker.local_addr().expect("blocker address"); + let dir = tempfile::TempDir::new().expect("tempdir"); + let config = test_config(&address.to_string(), dir.path()); + let error = spawn_with_grace(config, SHUTDOWN_GRACE).expect_err("a taken port must fail spawn"); + assert!( + matches!(error, SpawnError::Io(_)), + "expected Io, got {error:?}" + ); +} diff --git a/crates/workshop-server/src/session_agents.rs b/crates/workshop-server/src/session_agents.rs deleted file mode 100644 index 2a9b84b0d..000000000 --- a/crates/workshop-server/src/session_agents.rs +++ /dev/null @@ -1,1140 +0,0 @@ -//! Agent sessions: discovery of `.lua` agent programs, the -//! [`AgentSessions`] registry, and each session's run lifecycle. -//! -//! A session owns one running agent: its persisting event log -//! ([`crate::observer::WorkshopObserver`], JSONL under -//! `state_dir/sessions/.jsonl`), its -//! [`crate::input::WaitRegistry`] and `user_input` tool, its dedicated -//! delta broadcast (deltas never enter the event log), and the retained -//! [`CancelHandle`] behind turn-cancel. The supervisor task relaunches -//! `run_agent` over the retained event log after a turn-cancel - -//! cancellation is a stop reason, never an error - and ends the session -//! when the program returns or fails. -//! -//! **Registry carve-out.** Sessions survive socket disconnect and sockets -//! attach and detach ([`socket`]), so this module keeps the session -//! registry the crate's socket rule otherwise forbids. The rule governed -//! per-request relay work, where every held resource belonged to one -//! socket; an agent session is longer-lived than any socket on purpose, -//! and the registry is the one place that owns it. -//! -//! Reply ids coalesce deltas: every live delta is stamped with the id of -//! the durable event that will supersede it. The id is the count of -//! settled model rounds - [`SessionObserver`] advances it as the reply or -//! tool-call event lands, before the program resumes, and the socket -//! derives the same count from the event sequence itself, so both sides -//! agree without sharing more than the log. - -mod lifecycle; -pub(crate) mod socket; -mod supervisor; - -use std::collections::HashMap; -use std::fmt; -use std::io; -use std::num::NonZeroU32; -use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; - -use promptforge_core_support::cancel::CancelHandle; -use promptforge_core_support::events::{CallMetrics, RuntimeEventKind, ToolCallEvent}; -use promptforge_core_support::observe::{Observation, Observer}; -use promptforge_model_client::client::StreamDelta; -use promptforge_model_client::model::{ModelCatalog, ModelDescriptor, ModelId, ThinkingMode}; -use tokio::sync::{broadcast, mpsc}; - -use workshop_protocol::{Activity, AgentDeltaKind, InputFrame, InputResponse}; -use workshop_support::ReconnectBackoff; - -use crate::catalog::{CatalogBus, is_chat_capable}; -use crate::gateway_binding::GatewayBinding; -use crate::input::{WaitError, WaitRegistry, deliver_input_response_before_completion}; -use crate::menu::MenuBus; -use crate::observer::WorkshopObserver; -use crate::push::Push; -use crate::workspace::Workspace; - -use self::lifecycle::RunLifecycle; -use self::supervisor::transition::RunId; - -/// Capacity of a session's delta broadcast. Deltas are ephemeral: a -/// receiver that lags loses chunks, and the completed-reply event is the -/// repair path. -const DELTA_CAPACITY: usize = 256; - -/// Capacity of a session's input-frame broadcast. A session holds at -/// most a handful of waits; the registry's retained state is the -/// durable-delivery repair path on lag. -const INPUT_CAPACITY: usize = 32; - -/// Context window recorded for a catalog entry that does not carry one. -/// The window is catalog metadata (nothing on the completion wire reads -/// it), so a generous default keeps the model usable rather than -/// refusing it. -const FALLBACK_CONTEXT: u32 = 8192; - -/// The built-in default agent's name: discovery always offers it, and a -/// directory file named `chat.lua` shadows the embedded source. -const BUILTIN_CHAT_NAME: &str = "chat"; - -/// The committed built-in chat agent, embedded at compile time - the same -/// shipped-asset pattern as the SPA `dist/` - so a fresh install has a -/// working chat with no agents directory at all. The built-in is a -/// Markdown prompt on the unified runtime; the standalone `chat.lua` -/// program is retired. -const BUILTIN_CHAT_SOURCE: &str = include_str!("../agents/chat.md"); - -/// One agent's program source and the runtime that executes it. -/// -/// Directory agents are standalone Lua programs on the agent runtime; the -/// embedded built-in chat is a Markdown prompt on the unified runtime. -/// External Markdown-agent discovery stays deferred, so no directory file -/// ever lands in the Markdown arm. -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) enum AgentSource { - /// A standalone Lua agent program (the agent runtime). - Lua(String), - /// A Markdown prompt document (the unified runtime). - Markdown(String), -} - -/// Capacity of a session's error broadcast. Session errors are rare -/// one-off reports: a failed model round or a run that ended in error -/// surfaces one frame each, and a receiver that lags misses only what -/// the durable transcript already shows as a turn without a reply. -const ERROR_CAPACITY: usize = 8; - -/// One live delta on a session's dedicated channel, stamped with the -/// reply id of the durable event that will supersede it. -#[derive(Debug, Clone)] -pub(crate) struct AgentDelta { - /// The superseding reply id ([`SessionObserver`]'s round count when - /// the chunk streamed). - pub(crate) reply: u64, - /// Which side channel the chunk belongs to. - pub(crate) channel: AgentDeltaKind, - /// The chunk's text. - pub(crate) content: String, -} - -/// The shared bus handles a session's lifecycle reports flow through, -/// captured once at [`AgentSessions`] construction. -#[derive(Debug, Clone)] -pub(crate) struct SessionHost { - /// The status/catalog/menu push facade (Thinking, Generating, idle, - /// failures). - pub(crate) push: Push, - /// Reset on completed replies: an agent reply is useful gateway work. - pub(crate) backoff: ReconnectBackoff, - /// Serves `selected_model` to the agent's `ui()` snapshot. - pub(crate) menu: MenuBus, - /// Serves `workspace_root` to the agent's `ui()` snapshot: the first - /// granted root, absent when nothing is granted. - pub(crate) workspace: Workspace, - /// The retained gateway catalog the session's model catalog is built - /// from at launch. - pub(crate) catalog: CatalogBus, -} - -/// The registry of running agent sessions. -/// -/// Typed and construction-phased: everything a launch needs is captured -/// when [`AppState`](crate::AppState) builds, and the only mutable state -/// is the session map itself. Sessions survive socket disconnect - -/// sockets attach and detach through the `socket` module - which is this -/// module's documented carve-out from the crate's no-session-registry -/// socket rule. -#[derive(Clone)] -pub struct AgentSessions { - inner: Arc, -} - -/// The shared registry state behind the cloneable handle. -struct Inner { - /// Directory whose `.lua` files are the launchable agents. - agents_dir: PathBuf, - /// Where session event JSONLs persist (`state_dir/sessions`). - sessions_dir: PathBuf, - /// The atomically replaceable Gateway clients every run snapshots. - gateway: GatewayBinding, - /// The shared bus handles session lifecycles report through. - host: SessionHost, - /// The running sessions by id. - sessions: Mutex>>, -} - -impl fmt::Debug for AgentSessions { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter - .debug_struct("AgentSessions") - .field("agents_dir", &self.inner.agents_dir) - .field("sessions", &self.lock().len()) - .finish_non_exhaustive() - } -} - -impl AgentSessions { - /// Builds the registry over the discovery directory, the sessions - /// state directory, the model client agents complete through, and - /// the shared bus handles. Nothing touches the filesystem here: - /// discovery reads the agents directory per request, and the - /// sessions directory is created at first launch. - pub(crate) fn new( - agents_dir: PathBuf, - sessions_dir: PathBuf, - gateway: GatewayBinding, - host: SessionHost, - ) -> Self { - Self { - inner: Arc::new(Inner { - agents_dir, - sessions_dir, - gateway, - host, - sessions: Mutex::new(HashMap::new()), - }), - } - } - - /// The launchable agent names: the `.lua` file stems under the - /// configured agents directory plus the built-in `chat`, sorted. The - /// built-in is always offered - a missing or unreadable directory - /// still lists it, so a fresh install always has a working chat - and - /// a directory file named `chat.lua` shadows the embedded source - /// rather than listing twice. - #[must_use] - pub fn discover(&self) -> Vec { - discover_agents(&self.inner.agents_dir) - } - - /// Launches a session running the discovered agent `name` and - /// returns it. The session runs until its program returns, fails, or - /// [`close`](Self::close) ends it; turn-cancel relaunches the program - /// over the retained event log without ending the session. - /// - /// # Errors - /// Returns [`LaunchRefusal::UnknownAgent`] when `name` is not a - /// discovered agent (which also refuses path-shaped names: discovery - /// yields bare file stems), [`LaunchRefusal::GatewayUnusable`] when - /// the workshop gateway settings could not make a model client, and - /// [`LaunchRefusal::SessionState`] when the sessions directory or the - /// session's event log cannot be created. - pub(crate) fn launch(&self, name: &str) -> Result, LaunchRefusal> { - // Resolving through the discovered list is the trust boundary: a - // client-sent name never reaches the filesystem unless it is the - // bare stem of a real `.lua` file in the configured directory. - if !self.discover().iter().any(|agent| agent == name) { - return Err(LaunchRefusal::UnknownAgent { - name: name.to_owned(), - }); - } - // The client is checked at launch, not at startup: a workshop - // whose gateway settings cannot make a model client still serves - // chat, but an agent run would fail its first model round - or - // silently resolve a different gateway from the environment - so - // the launch refuses instead. - if self.inner.gateway.snapshot().model_client().is_none() { - return Err(LaunchRefusal::GatewayUnusable); - } - let source = agent_source(&self.inner.agents_dir, name) - .map_err(|source| LaunchRefusal::SessionState { source })?; - std::fs::create_dir_all(&self.inner.sessions_dir) - .map_err(|source| LaunchRefusal::SessionState { source })?; - let id = fresh_session_id(); - let log_path = self.inner.sessions_dir.join(format!("{id}.jsonl")); - let observer = Arc::new( - WorkshopObserver::new(Some(&log_path)) - .map_err(|source| LaunchRefusal::SessionState { source })?, - ); - let (supervisor_events, events) = mpsc::unbounded_channel(); - let (cancellations, cancellation_events) = mpsc::channel(lifecycle::CANCELLATION_CAPACITY); - let lifecycle = Arc::new(RunLifecycle::new(supervisor_events, cancellations)); - let waits = Arc::new(WaitRegistry::new()); - let (input_frames, _) = broadcast::channel(INPUT_CAPACITY); - let (deltas, _) = broadcast::channel(DELTA_CAPACITY); - let (errors, _) = broadcast::channel(ERROR_CAPACITY); - let session = Arc::new(AgentSession { - id: id.clone(), - agent: name.to_owned(), - source, - log: Arc::clone(&observer), - lifecycle, - rounds: Arc::new(AtomicU64::new(0)), - waits, - input_frames, - deltas, - errors, - }); - self.lock().insert(id, Arc::clone(&session)); - supervisor::spawn( - Arc::clone(&session), - self.clone(), - self.inner.host.clone(), - self.inner.gateway.clone(), - events, - cancellation_events, - ); - Ok(session) - } - - /// The running session with this id, when one exists. - pub(crate) fn get(&self, id: &str) -> Option> { - self.lock().get(id).cloned() - } - - /// Ends the session with this id: its run is cancelled for good (no - /// relaunch), pending waits die as `input_cancelled`, and the session - /// leaves the registry. Returns whether a session was ended. The - /// persisted event JSONL stays on disk. - #[must_use] - pub fn close(&self, id: &str) -> bool { - let Some(session) = self.lock().remove(id) else { - return false; - }; - session.close(); - true - } - - /// The unresolved wait tokens of the session with this id - the - /// teardown leak probe: after a close or a finished run, the list - /// must be empty. `None` when no such session is registered. - #[must_use] - pub fn unresolved_waits(&self, id: &str) -> Option> { - Some(self.get(id)?.waits.unresolved()) - } - - /// Delivers a fixture response after running `after_acceptance` - /// between its durable observation and the waiting tool's resumption. - #[cfg(feature = "test-fixtures")] - pub fn deliver_input_after_acceptance_for_test( - &self, - id: &str, - response: InputResponse, - after_acceptance: impl FnOnce(), - ) -> Option> { - let session = self.get(id)?; - Some(session.accept_input(response, after_acceptance)) - } - - /// The session map guard; a lock poisoned by a panicking peer - /// recovers the value rather than wedging the process (zone two). - fn lock(&self) -> MutexGuard<'_, HashMap>> { - self.inner - .sessions - .lock() - .unwrap_or_else(PoisonError::into_inner) - } - - /// Removes a finished session from the map, unless a close already - /// did. - fn forget(&self, id: &str) { - self.lock().remove(id); - } -} - -/// A refused agent launch, relayed to the client as an error frame. -#[derive(Debug, thiserror::Error)] -#[non_exhaustive] -pub(crate) enum LaunchRefusal { - /// The requested name is not a discovered agent. - #[error("unknown agent {name:?}: not in the agents directory")] - UnknownAgent { - /// The name that was requested. - name: String, - }, - /// The workshop gateway settings could not make a model client, so - /// no agent could complete a model round. - #[error( - "agent sessions need a usable gateway client; check `gateway.base_url` and \ - `gateway.api_key` in workshop.toml" - )] - GatewayUnusable, - /// The session's on-disk state could not be prepared. - #[error("agent session state unavailable")] - SessionState { - /// The underlying filesystem failure. - #[source] - source: io::Error, - }, -} - -/// One running agent session: the state that outlives any socket. -pub(crate) struct AgentSession { - /// The session's unguessable id, also its event JSONL's file stem. - pub(crate) id: String, - /// The agent's name (its `.lua` file stem), every observer call's - /// `section` label. - pub(crate) agent: String, - /// The program source and its runtime, retained so turn-cancel can - /// relaunch it. - source: AgentSource, - /// The persisting event log: `Observer` write side, `EventLog` read - /// side, broadcast fan-out for socket wakeups. - pub(crate) log: Arc, - /// Cancellation provenance and the accepted-turn exclusion boundary. - lifecycle: Arc, - /// Settled model rounds - the reply id deltas are stamped with. - rounds: Arc, - /// The session's unresolved user-input waits. - pub(crate) waits: Arc, - /// Where the `user_input` tool announces waits; sockets subscribe. - pub(crate) input_frames: broadcast::Sender, - /// The dedicated live-delta channel; deltas never enter the event - /// log. - deltas: broadcast::Sender, - /// The session's error reports, forwarded to the SPA as `error` - /// frames: a failed model round the program survived, or a run that - /// ended in error. Ephemeral like the deltas - errors never enter - /// the event log. - errors: broadcast::Sender, -} - -impl fmt::Debug for AgentSession { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter - .debug_struct("AgentSession") - .field("id", &self.id) - .field("agent", &self.agent) - .finish_non_exhaustive() - } -} - -impl AgentSession { - /// Subscribes to the session's live deltas from this call on. - pub(crate) fn subscribe_deltas(&self) -> broadcast::Receiver { - self.deltas.subscribe() - } - - /// Subscribes to the session's error reports from this call on. - pub(crate) fn subscribe_errors(&self) -> broadcast::Receiver { - self.errors.subscribe() - } - - /// Durably accepts one input and resumes its wait after publishing - /// acceptance ahead of the observation-to-completion boundary. - /// - /// Recording is runtime-specific: a Lua agent's input is recorded - /// producer-side here (its relaunched program rebuilds history from - /// the event log), while a Markdown agent's unified runtime records - /// consumer-side when the suspended `user_input` resumes - recording - /// here too would double the event. - pub(crate) fn accept_input( - &self, - response: InputResponse, - after_acceptance: impl FnOnce(), - ) -> Result<(), WaitError> { - let accepted_run = self.lifecycle.accept_input(); - let result = match &self.source { - AgentSource::Lua(_) => deliver_input_response_before_completion( - self.log.as_ref(), - &self.waits, - &self.id, - &self.agent, - response, - after_acceptance, - ), - AgentSource::Markdown(_) => { - crate::input::complete_input_response(&self.waits, response, after_acceptance) - } - }; - if let (Err(_), Some(run)) = (&result, accepted_run) { - self.lifecycle.settle_turn(run); - } - result - } - - /// Fires the current run's retained cancel handle: the turn dies as - /// a stop reason (pending waits emit `input_cancelled`, no error - /// frame), and the supervisor relaunches the program over the - /// retained event log with a fresh handle. - pub(crate) fn cancel_turn(&self) { - self.lifecycle.operator_cancel(); - } - - /// Ends the session: the run is cancelled and the supervisor stops - /// relaunching. - fn close(&self) { - self.lifecycle.close(); - } - - /// Installs and retains the next run's fresh cancel handle. - fn arm_cancel(&self, run: RunId) -> CancelHandle { - self.lifecycle.arm(run) - } - - /// Cancels the run selected by a reducer effect. - fn cancel_current_run(&self) { - self.lifecycle.cancel_current(); - } - - /// Clears the lifecycle identity after a run ends. - fn finish_run(&self, run: RunId) { - self.lifecycle.finish(run); - } -} - -/// The per-session [`Observer`] wrapper `run_agent` reports through: it -/// forwards every report to the persisting log and owns the side effects -/// the session wires to content events - the reply-id round count -/// (advanced as a reply or tool-call batch lands, before the program -/// resumes, so no later delta can carry a settled id), the backoff reset, -/// and the idle status push on completed replies. -struct SessionObserver { - /// The persisting log every report forwards to. - log: Arc, - /// Settled model rounds, shared with the delta stamp. - rounds: Arc, - /// Where idle lands when a reply completes. - push: Push, - /// Reset on completed replies: the gateway proved it answers. - backoff: ReconnectBackoff, - /// Where a failed model round surfaces as a wire error frame. - errors: broadcast::Sender, - /// Marks an accepted turn settled before catalog retirement proceeds. - lifecycle: Arc, -} - -impl Observer for SessionObserver { - fn observe(&self, execution: &str, section: &str, event: Observation) { - // A failed model round is operator-visible: the program survives - // it (the built-in chat pcalls models.chat and returns to - // waiting), so the run never fails and only the session can tell - // the SPA. The observation carries no payload; the frame names - // the boundary that failed. - if matches!(event, Observation::ModelTurnFailed) { - self.lifecycle.settle_current_turn(); - let message = format!("{event} in agent `{section}`"); - let _ = self.errors.send(message.clone()); - // The failed round never reaches on_assistant_reply, so this - // terminal status is the only frame that releases the - // turn-dispatch Thinking push; without it the status bar's - // sustained amber LED never returns to idle. - self.push - .push_failure("Model turn failed", message, Activity::General); - } - self.log.observe(execution, section, event); - } - - fn on_assistant_reply( - &self, - execution: &str, - section: &str, - chain_id: u32, - depth: u32, - turn: u32, - text: &str, - finish_reason: Option<&str>, - model: &str, - metrics: Option<&CallMetrics>, - ) { - self.log.on_assistant_reply( - execution, - section, - chain_id, - depth, - turn, - text, - finish_reason, - model, - metrics, - ); - self.lifecycle.settle_current_turn(); - self.rounds.fetch_add(1, Ordering::SeqCst); - self.backoff.record_useful_work(); - self.push.push_idle(); - } - - fn on_assistant_tool_calls( - &self, - execution: &str, - section: &str, - chain_id: u32, - depth: u32, - turn: u32, - model: &str, - calls: &[ToolCallEvent], - ) { - self.log - .on_assistant_tool_calls(execution, section, chain_id, depth, turn, model, calls); - // A tool-call batch settles its round's deltas without ending the - // turn: the count advances, the status stays busy. - self.rounds.fetch_add(1, Ordering::SeqCst); - } - - fn on_tool_result( - &self, - execution: &str, - section: &str, - chain_id: u32, - depth: u32, - turn: u32, - tool_call_id: &str, - alias: &str, - content: &str, - trusted: bool, - ) { - self.log.on_tool_result( - execution, - section, - chain_id, - depth, - turn, - tool_call_id, - alias, - content, - trusted, - ); - } - - fn on_thinking( - &self, - execution: &str, - section: &str, - chain_id: u32, - depth: u32, - turn: u32, - model: &str, - text: &str, - ) { - self.log - .on_thinking(execution, section, chain_id, depth, turn, model, text); - } - - fn on_user_input(&self, execution: &str, section: &str, text: &str) { - self.log.on_user_input(execution, section, text); - } -} - -/// Builds the delta stamp: the `on_delta` closure feeding the session's -/// dedicated broadcast, each chunk stamped with the current round count - -/// the id of the durable event that will supersede it - plus the -/// activity pulse that lights the status LED (Generating for answer -/// content, Thinking for the reasoning side channel). -fn delta_stamp(session: &Arc, push: &Push) -> Arc { - let deltas = session.deltas.clone(); - let rounds = Arc::clone(&session.rounds); - let push = push.clone(); - Arc::new(move |delta| { - let (channel, content, activity) = match delta { - StreamDelta::Text(text) => (AgentDeltaKind::Text, text, Activity::Generating), - StreamDelta::Reasoning(text) => (AgentDeltaKind::Reasoning, text, Activity::Thinking), - // The enum is non-exhaustive across the crate seam; a future - // side channel has no frame kind yet and stays live-only. - _ => return, - }; - push.push_activity("Streaming response...", "an agent response chunk", activity); - // No receiver means no socket is attached; deltas are ephemeral - // and the completed-reply event is the repair, so the drop is - // the design. - let _ = deltas.send(AgentDelta { - reply: rounds.load(Ordering::SeqCst), - channel, - content, - }); - }) -} - -/// Builds the `ui()` snapshot provider: `selected_model` from the menu's -/// retained workbench state and `workspace_root` as the first granted -/// workspace root, each `null` when absent. -fn ui_provider( - menu: &MenuBus, - workspace: &Workspace, -) -> Arc serde_json::Value + Send + Sync> { - let menu = menu.clone(); - let workspace = workspace.clone(); - Arc::new(move || { - let selected = menu.latest().and_then(|snapshot| snapshot.selected_model); - let root = workspace - .granted_roots() - .first() - .map(|root| root.display().to_string()); - serde_json::json!({ "selected_model": selected, "workspace_root": root }) - }) -} - -/// The reply-id derivation the socket applies while draining the event -/// log: the model-round content kinds carry the current round count as -/// their stamp, and a reply or tool-call batch advances it - the same -/// rule [`SessionObserver`] applies live, so delta stamps and event -/// stamps agree. -pub(crate) fn reply_stamp(kind: RuntimeEventKind, rounds_seen: &mut u64) -> Option { - match kind { - RuntimeEventKind::Thinking => Some(*rounds_seen), - RuntimeEventKind::AssistantReply | RuntimeEventKind::AssistantToolCalls => { - let round = *rounds_seen; - *rounds_seen += 1; - Some(round) - } - _ => None, - } -} - -/// Lists the launchable agent names: the `.lua` file stems under `dir` -/// plus the built-in `chat`, sorted. A missing or unreadable directory -/// offers exactly the built-in, and a directory `chat.lua` lists once - -/// it shadows the embedded source instead of duplicating the name. -fn discover_agents(dir: &Path) -> Vec { - let mut names: Vec = std::fs::read_dir(dir) - .into_iter() - .flatten() - .filter_map(Result::ok) - .map(|entry| entry.path()) - .filter(|path| { - path.is_file() && path.extension().is_some_and(|extension| extension == "lua") - }) - .filter_map(|path| { - path.file_stem() - .and_then(|stem| stem.to_str()) - .map(str::to_owned) - }) - .collect(); - if !names.iter().any(|name| name == BUILTIN_CHAT_NAME) { - names.push(BUILTIN_CHAT_NAME.to_owned()); - } - names.sort(); - names -} - -/// Reads the agent's program source: the directory file when it exists - -/// a directory `chat.lua` shadows the built-in - else the embedded -/// built-in for the `chat` name alone. Launch resolved `name` through -/// discovery already, so a missing file for any other name is a real -/// filesystem race, surfaced as the error it is; so is an existing -/// `chat.lua` that cannot be read, because silently serving the built-in -/// would mask the operator's own file. -fn agent_source(dir: &Path, name: &str) -> io::Result { - match std::fs::read_to_string(dir.join(format!("{name}.lua"))) { - Ok(source) => Ok(AgentSource::Lua(source)), - Err(error) if name == BUILTIN_CHAT_NAME && error.kind() == io::ErrorKind::NotFound => { - Ok(AgentSource::Markdown(BUILTIN_CHAT_SOURCE.to_owned())) - } - Err(error) => Err(error), - } -} - -/// A fresh unguessable session id: 128 bits from the OS-seeded -/// cryptographic RNG, hex-encoded - wide enough that ids never collide -/// across server restarts, so an old session's JSONL is never truncated -/// by a new session's log. -fn fresh_session_id() -> String { - use rand::Rng as _; - let mut rng = rand::rng(); - format!("{:016x}{:016x}", rng.random::(), rng.random::()) -} - -/// Builds the model-client the agent completes through from the workshop -/// gateway settings: the workshop base URL plus the `/v1` API root. -/// `None` - logged here, and refused per launch as -/// [`LaunchRefusal::GatewayUnusable`] - when the key is empty (the model -/// client refuses blank credentials) or the URL does not parse. -#[cfg(test)] -fn model_client( - base_url: &str, - api_key: &str, -) -> Option { - crate::gateway_binding::model_client(base_url, api_key) -} - -/// Builds the session's model catalog from the retained gateway catalog: -/// one descriptor per chat entry (an absent `kind` is a plain OpenAI -/// catalog and counts as chat), carrying the entry's description, -/// context window, and thinking mode where present. Entries that cannot -/// make a descriptor are skipped with a warning - a launch must not fail -/// because one catalog row is malformed. -fn build_model_catalog(models: Option>) -> ModelCatalog { - let Some(models) = models else { - return ModelCatalog::empty(); - }; - let mut descriptors: Vec = Vec::new(); - for entry in &models { - if !is_chat_capable(entry) { - continue; - } - let Some(id) = entry.get("id").and_then(serde_json::Value::as_str) else { - tracing::warn!("catalog entry without an id skipped for the agent model catalog"); - continue; - }; - let model_id = match ModelId::gateway(id) { - Ok(model_id) => model_id, - Err(error) => { - tracing::warn!(%error, id, "catalog entry skipped for the agent model catalog"); - continue; - } - }; - if descriptors - .iter() - .any(|descriptor| descriptor.id() == &model_id) - { - tracing::warn!( - id, - "duplicate catalog id skipped for the agent model catalog" - ); - continue; - } - let description = entry - .get("description") - .and_then(serde_json::Value::as_str) - .unwrap_or_default(); - let context = entry - .get("context") - .and_then(serde_json::Value::as_u64) - .and_then(|context| u32::try_from(context).ok()) - .and_then(NonZeroU32::new) - .unwrap_or_else(|| NonZeroU32::new(FALLBACK_CONTEXT).unwrap_or(NonZeroU32::MIN)); - let thinking = entry - .get("thinking") - .and_then(|value| serde_json::from_value::(value.clone()).ok()) - .unwrap_or(ThinkingMode::Never); - descriptors.push(ModelDescriptor::new( - model_id, - description, - context, - thinking, - )); - } - // Duplicates were filtered above, so construction cannot refuse; an - // empty catalog is the honest degenerate outcome. - ModelCatalog::new(descriptors).unwrap_or_else(|error| { - tracing::warn!(%error, "agent model catalog degraded to empty"); - ModelCatalog::empty() - }) -} - -#[cfg(test)] -mod tests { - use promptforge_core_support::events::RuntimeEventKind; - - use super::*; - - /// A push facade wired to the real buses through the registry, with - /// the registrations kept alive by the returned guards. - fn wired_push( - status: &crate::status::StatusBus, - catalog: &CatalogBus, - menu: &MenuBus, - ) -> (Push, impl std::fmt::Debug + Send + Sync + 'static) { - let registry = workshop_registry::Registry::new(); - let status_guards = workshop_status::register(®istry, status); - let menu_guards = workshop_menu::register(®istry, catalog, menu); - (registry.push(), (status_guards, menu_guards)) - } - - #[test] - fn discovery_lists_sorted_lua_stems_and_tolerates_a_missing_dir() { - let dir = tempfile::TempDir::new().expect("tempdir"); - std::fs::write(dir.path().join("zeta.lua"), "return 1").expect("seed zeta"); - std::fs::write(dir.path().join("alpha.lua"), "return 1").expect("seed alpha"); - std::fs::write(dir.path().join("notes.txt"), "not an agent").expect("seed noise"); - std::fs::create_dir(dir.path().join("nested.lua")).expect("seed a decoy directory"); - assert_eq!( - discover_agents(dir.path()), - vec!["alpha".to_owned(), "chat".to_owned(), "zeta".to_owned()], - "discovery lists .lua file stems plus the built-in chat, sorted, \ - and skips everything else" - ); - assert_eq!( - discover_agents(&dir.path().join("missing")), - vec!["chat".to_owned()], - "a missing agents directory still offers the built-in chat rather than failing" - ); - } - - #[test] - fn the_built_in_chat_is_always_offered_and_a_dir_file_shadows_its_source() { - let dir = tempfile::TempDir::new().expect("tempdir"); - assert_eq!( - discover_agents(dir.path()), - vec!["chat".to_owned()], - "an empty agents directory still offers the built-in chat" - ); - assert_eq!( - agent_source(dir.path(), "chat").expect("the built-in serves"), - AgentSource::Markdown(BUILTIN_CHAT_SOURCE.to_owned()), - "with no directory file, the embedded source is what launches" - ); - - std::fs::write(dir.path().join("chat.lua"), "-- shadowed").expect("seed the shadow"); - assert_eq!( - discover_agents(dir.path()), - vec!["chat".to_owned()], - "a directory chat.lua lists once, never beside the built-in" - ); - assert_eq!( - agent_source(dir.path(), "chat").expect("the shadow reads"), - AgentSource::Lua("-- shadowed".to_owned()), - "a directory chat.lua shadows the embedded source" - ); - - assert_eq!( - agent_source(dir.path(), "ghost") - .expect_err("only the built-in name falls back to embedded source") - .kind(), - io::ErrorKind::NotFound, - "a non-built-in name surfaces its filesystem error" - ); - } - - #[test] - fn an_unreadable_chat_lua_surfaces_its_error_rather_than_the_built_in() { - let dir = tempfile::TempDir::new().expect("tempdir"); - // A directory named chat.lua cannot be read as a file on any - // platform, and its failure is never NotFound - the one kind - // that falls back to the embedded source. - std::fs::create_dir(dir.path().join("chat.lua")).expect("seed the unreadable shadow"); - agent_source(dir.path(), "chat").expect_err( - "an existing chat.lua that cannot be read surfaces its error; \ - silently serving the built-in would mask the operator's own file", - ); - } - - #[test] - fn the_model_catalog_keeps_chat_entries_and_skips_the_rest() { - let catalog = build_model_catalog(Some(vec![ - serde_json::json!({ - "id": "chat-model", "kind": "chat", "description": "a chat model", - "context": 4096, "thinking": "switchable", - }), - serde_json::json!({ "id": "plain-openai-model" }), - serde_json::json!({ "id": "embed-model", "kind": "embedding" }), - serde_json::json!({ "object": "model" }), - serde_json::json!({ "id": "chat-model" }), - ])); - let names: Vec<&str> = catalog - .models() - .iter() - .map(|descriptor| descriptor.id().name()) - .collect(); - assert_eq!( - names, - vec!["chat-model", "plain-openai-model"], - "chat and kind-less entries stay; embeddings, id-less rows, and duplicates drop" - ); - let chat = &catalog.models()[0]; - assert_eq!(chat.context().get(), 4096); - assert_eq!(chat.thinking(), ThinkingMode::Switchable); - let bare = &catalog.models()[1]; - assert_eq!( - bare.context().get(), - FALLBACK_CONTEXT, - "an entry without a context window records the fallback" - ); - assert!( - build_model_catalog(None).is_empty(), - "no retained catalog means an empty agent catalog" - ); - } - - #[test] - fn reply_stamps_follow_the_settle_rule() { - let mut rounds = 0; - assert_eq!( - reply_stamp(RuntimeEventKind::UserInput, &mut rounds), - None, - "input events settle nothing" - ); - assert_eq!( - reply_stamp(RuntimeEventKind::Thinking, &mut rounds), - Some(0), - "thinking carries the open round without settling it" - ); - assert_eq!( - reply_stamp(RuntimeEventKind::AssistantReply, &mut rounds), - Some(0) - ); - assert_eq!( - reply_stamp(RuntimeEventKind::AssistantToolCalls, &mut rounds), - Some(1), - "a tool-call batch settles its round exactly as a reply does" - ); - assert_eq!(reply_stamp(RuntimeEventKind::ToolResult, &mut rounds), None); - assert_eq!( - reply_stamp(RuntimeEventKind::Thinking, &mut rounds), - Some(2), - "the next round opens where the last one settled" - ); - } - - #[test] - fn the_ui_snapshot_serves_the_selection_and_first_granted_root() { - let catalog = CatalogBus::default(); - let menu = MenuBus::new(catalog.clone(), None); - let workspace = Workspace::new(); - let ui = ui_provider(&menu, &workspace); - assert_eq!( - ui(), - serde_json::json!({ "selected_model": null, "workspace_root": null }), - "absent producers serve null, never a missing key" - ); - - catalog.publish(vec![serde_json::json!({ "id": "test-model" })]); - menu.set_selected("test-model") - .expect("the id is in the catalog"); - let dir = tempfile::TempDir::new().expect("tempdir"); - let granted = workspace.grant(dir.path()).expect("the tempdir grants"); - let snapshot = ui(); - assert_eq!(snapshot["selected_model"], "test-model"); - assert_eq!( - snapshot["workspace_root"], - serde_json::json!(granted.display().to_string()), - "workspace_root is the first granted root" - ); - } - - #[test] - fn a_launch_without_a_usable_client_is_refused() { - let dir = tempfile::TempDir::new().expect("tempdir"); - std::fs::write(dir.path().join("echo.lua"), "return 1").expect("seed echo"); - let catalog = CatalogBus::default(); - let menu = MenuBus::new(catalog.clone(), None); - let (push, _guards) = wired_push(&crate::status::StatusBus::new(), &catalog, &menu); - let sessions = AgentSessions::new( - dir.path().to_path_buf(), - dir.path().join("sessions"), - GatewayBinding::new("http://127.0.0.1:1", "") - .expect("the unusable model binding still builds its HTTP client"), - SessionHost { - push, - backoff: ReconnectBackoff::new(), - menu, - workspace: Workspace::new(), - catalog, - }, - ); - // A plain #[test] doubles as ordering proof: the refusal returns - // before anything is spawned, or this panics outside a runtime. - let refusal = sessions - .launch("echo") - .expect_err("a discovered agent must still refuse without a model client"); - assert!( - matches!(refusal, LaunchRefusal::GatewayUnusable), - "the refusal names the gateway configuration, not the agent: {refusal}" - ); - assert!( - sessions.lock().is_empty(), - "a refused launch registers no session" - ); - } - - #[tokio::test] - async fn a_failed_model_turn_pushes_a_terminal_failure_status() { - let status = crate::status::StatusBus::new(); - let mut status_rx = status.subscribe(); - let catalog = CatalogBus::new(); - let menu = MenuBus::new(catalog.clone(), None); - let (push, _guards) = wired_push(&status, &catalog, &menu); - let (errors, mut errors_rx) = broadcast::channel(ERROR_CAPACITY); - let (supervisor_events, _events) = mpsc::unbounded_channel(); - let (cancellations, _cancellation_events) = mpsc::channel(lifecycle::CANCELLATION_CAPACITY); - let observer = SessionObserver { - log: Arc::new(WorkshopObserver::new(None).expect("a memory log")), - rounds: Arc::new(AtomicU64::new(0)), - push, - backoff: ReconnectBackoff::new(), - errors, - lifecycle: Arc::new(RunLifecycle::new(supervisor_events, cancellations)), - }; - - observer.observe("run", "chat", Observation::ModelTurnFailed); - - let update = status_rx - .recv() - .await - .expect("the failed round pushes a terminal status"); - assert_eq!(update.severity, workshop_protocol::Severity::Error); - assert_eq!( - update.activity, - Activity::General, - "a non-thinking activity releases the status bar's sustained amber LED" - ); - assert_eq!( - errors_rx.recv().await.expect("the error frame is sent"), - "Model turn failed in agent `chat`" - ); - } - - #[test] - fn the_model_client_requires_a_usable_key_and_url() { - assert!( - model_client("http://127.0.0.1:8081", "k").is_some(), - "a keyed gateway builds the agent model client" - ); - assert!( - model_client("http://127.0.0.1:8081", "").is_none(), - "an empty key cannot authenticate: agents report it at launch" - ); - assert!(model_client("not a url", "k").is_none()); - } - - /// Runs the embedded chat prompt on the unified runtime with the given - /// broker configuration, against a client no model call can survive. - async fn run_builtin_chat( - broker: Option>, - ) -> Result { - use promptforge_core::{Prompt, ResolutionContext, RunConfig}; - let observer: Arc = - Arc::new(WorkshopObserver::new(None).expect("memory log")); - let prompt = Prompt::parse(BUILTIN_CHAT_SOURCE, "chat-unit", observer.as_ref()) - .expect("the embedded chat prompt parses"); - let picker = promptforge_tool_picker::ToolPicker::build( - promptforge_tool_picker::Catalog::new(Vec::new()), - promptforge_tool_picker::Config::default(), - ) - .expect("the empty picker builds"); - let models = ModelCatalog::empty(); - let tools = promptforge_tools::ToolCatalog::new(&[]).expect("an empty catalog is valid"); - let store = promptforge_vfs::empty(); - let mut config = RunConfig::new("chat-unit").observer(observer); - if let Some(broker) = broker { - config = config.input_broker(broker); - } - promptforge_core::run( - &prompt, - "", - ResolutionContext::new(&picker, &models, &tools), - &store, - config, - ) - .await - } - - #[tokio::test] - async fn the_builtin_chat_returns_without_a_broker_beneath_it() { - // No broker is the unavailable-fallback policy: user_input() - // resumes unavailable, the prompt returns, and no model call is - // ever attempted (the run carries no client at all). - let result = run_builtin_chat(None).await; - assert!( - result.is_ok(), - "the unavailable fallback ends the run cleanly: {result:?}" - ); - } - - #[tokio::test] - async fn a_failing_broker_fails_the_builtin_chat_as_typed_input() { - struct FailingBroker; - - #[async_trait::async_trait] - impl promptforge_core::input::InputBroker for FailingBroker { - async fn user_input( - &self, - _execution: &str, - _section: &str, - ) -> Result - { - Err(promptforge_core::input::InputError::message( - "the input device is gone", - )) - } - } - - let error = run_builtin_chat(Some(Arc::new(FailingBroker))) - .await - .expect_err("the broker failure fails the run"); - assert!( - matches!(error.kind(), promptforge_core::execute::RunErrorKind::Input), - "a broker failure is the typed input failure: {error}" - ); - } -} diff --git a/crates/workshop-server/src/workspace.rs b/crates/workshop-server/src/workspace.rs deleted file mode 100644 index 2fa79b165..000000000 --- a/crates/workshop-server/src/workspace.rs +++ /dev/null @@ -1,1272 +0,0 @@ -//! Confined workspace filesystem access: directory trees, file reads, and -//! file writes jailed to roots explicitly granted through drag and drop. -//! -//! A dropped folder becomes a granted root; a dropped file grants its parent -//! directory. Grants live in memory for the running process only - profile -//! persistence is a separate future consent decision. Every request path is -//! checked lexically (no `..`, and on Windows no NTFS alternate data -//! stream names) and then -//! canonicalized and prefix-matched against the canonical grants before any -//! filesystem operation, so traversal, symlink escapes, and UNC aliases -//! cannot reach outside a grant. This is the same jail shape as the -//! gateway's artifact-cache `confine.rs`, with canonicalization performing -//! the resolution that module's component walk performs by hand. - -use std::collections::BTreeSet; -use std::fs; -use std::hash::{DefaultHasher, Hash, Hasher}; -use std::io; -use std::path::{Component, Path, PathBuf}; -use std::sync::{Arc, PoisonError, RwLock}; -use std::time::UNIX_EPOCH; - -use axum::Json; -use axum::extract::{Query, State}; -use axum::http::StatusCode; -use axum::response::{IntoResponse, Response}; -use serde::{Deserialize, Serialize}; - -use crate::error::AppError; - -/// The largest file the workspace reads or accepts for a write: the editor -/// targets source text, not media, so one MiB is generous. -const MAX_FILE_BYTES: u64 = 1024 * 1024; - -/// A workspace operation failure. -#[derive(Debug, thiserror::Error)] -#[non_exhaustive] -pub(crate) enum WorkspaceError { - /// A granted path could not be canonicalized. - #[error("grant path cannot be resolved")] - ResolveGrant { - /// The underlying I/O failure. - #[source] - source: io::Error, - }, - - /// A requested path could not be canonicalized. - #[error("requested path cannot be resolved")] - ResolvePath { - /// The underlying I/O failure. - #[source] - source: io::Error, - }, - - /// Filesystem metadata for a path could not be read. - #[error("path cannot be inspected")] - InspectPath { - /// The underlying I/O failure. - #[source] - source: io::Error, - }, - - /// A directory could not be listed. - #[error("directory cannot be listed")] - ListDirectory { - /// The underlying I/O failure. - #[source] - source: io::Error, - }, - - /// A file could not be read. - #[error("file cannot be read")] - ReadFile { - /// The underlying I/O failure. - #[source] - source: io::Error, - }, - - /// A file could not be written. - #[error("file cannot be written")] - WriteFile { - /// The underlying I/O failure. - #[source] - source: io::Error, - }, - - /// The path is not inside any granted root. - #[error("path is outside every granted root")] - OutsideGrants, - - /// The path carries a `..` or an alternate data stream name. - #[error("path contains a forbidden component")] - ForbiddenComponent, - - /// The path does not exist. - #[error("path does not exist")] - NotFound, - - /// A revoke named a path that is not a granted root. - #[error("path is not a granted root")] - NotGranted, - - /// A tree listing was requested for something that is not a directory. - #[error("path is not a directory")] - NotADirectory, - - /// A read or write targeted something that is not a regular file. - #[error("path is not a file")] - NotAFile, - - /// The file contains NUL bytes and is not editable text. - #[error("file is binary, not text")] - BinaryFile, - - /// The file is not valid UTF-8. - #[error("file is not utf-8 text")] - NotUtf8, - - /// The file or body exceeds [`MAX_FILE_BYTES`]. - #[error("file exceeds the {limit}-byte size limit")] - FileTooLarge { - /// The size limit that was exceeded. - limit: u64, - }, - - /// The on-disk conflict token does not match the writer's token. - #[error("file changed on disk since it was read")] - ModifiedConflict, -} - -/// Whether a tree entry is a directory or a regular file. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] -#[serde(rename_all = "lowercase")] -pub(crate) enum EntryKind { - /// A directory. - Directory, - /// A regular file. - File, -} - -/// One entry in a directory listing. -#[derive(Debug, Serialize)] -pub(crate) struct TreeEntry { - /// The entry's file name (lossy for non-Unicode names). - name: String, - /// The entry's full path, ready to pass back to the API. - path: PathBuf, - /// Directory or file. - kind: EntryKind, - /// Byte length (0 for directories). - size: u64, - /// Modification time in milliseconds since the Unix epoch. - modified_ms: u64, - /// Whether the entry is currently on disk. Directory listings only - /// enumerate what exists, so their entries are always `true`; a - /// granted root deleted from disk lists as `false` so the panel can - /// flag it for cleanup. - exists: bool, -} - -/// One level of a workspace directory tree. -#[derive(Debug, Serialize)] -pub(crate) struct TreeListing { - /// The listed directory; `None` when the listing is the granted roots. - path: Option, - /// Directories before files, each group ordered by name. - entries: Vec, -} - -/// A file's text plus the metadata a writer needs to detect conflicts. -#[derive(Debug, Serialize)] -pub(crate) struct FileContents { - /// The canonical file path. - path: PathBuf, - /// Byte length. - size: u64, - /// The opaque conflict token a writer must echo back as - /// `expected_token`; see [`file_token`] for its derivation. - token: String, - /// The file's UTF-8 text. - text: String, -} - -/// The in-memory set of granted workspace roots. -/// -/// Cloning shares the same grant set, so the router state and every handler -/// see grants registered through `POST /workspace/grant` immediately. -#[derive(Debug, Clone, Default)] -pub(crate) struct Workspace { - grants: Arc>>, -} - -impl Workspace { - /// Creates a workspace with no grants. - pub(crate) fn new() -> Self { - Self::default() - } - - /// Registers `path` as a granted root: a directory grants itself, a - /// file grants its parent directory. - /// - /// # Errors - /// Returns [`WorkspaceError::ForbiddenComponent`] when the path carries - /// a `..` or stream name, [`WorkspaceError::ResolveGrant`] when it - /// cannot be canonicalized, and [`WorkspaceError::NotFound`] when a - /// file path has no parent directory. - pub(crate) fn grant(&self, path: &Path) -> Result { - reject_forbidden(path)?; - let canonical = canonicalize_simplified(path) - .map_err(|source| WorkspaceError::ResolveGrant { source })?; - let root = if canonical.is_dir() { - canonical - } else { - canonical - .parent() - .map(Path::to_owned) - .ok_or(WorkspaceError::NotFound)? - }; - self.grants - .write() - .unwrap_or_else(PoisonError::into_inner) - .insert(root.clone()); - Ok(root) - } - - /// Removes `path` from the granted roots by exact canonical match. - /// A root deleted from disk stays revocable by the literal stored - /// key. Nested grants are independent: revoking a parent leaves a - /// separately granted child intact, and files under the child stay - /// reachable while everything else under the parent loses access on - /// its next operation. - /// - /// # Errors - /// Returns [`WorkspaceError::ForbiddenComponent`] when the path carries - /// a `..` or stream name, [`WorkspaceError::ResolveGrant`] when - /// canonicalization fails for a reason other than absence, and - /// [`WorkspaceError::NotGranted`] when the resolved path is not a - /// granted root. - pub(crate) fn revoke(&self, path: &Path) -> Result { - reject_forbidden(path)?; - // A root deleted from disk no longer canonicalizes, but its grant - // must stay removable: fall back to the literal path, which matches - // the stored canonical key the roots listing handed the client. - let canonical = match canonicalize_simplified(path) { - Ok(canonical) => canonical, - Err(source) if source.kind() == io::ErrorKind::NotFound => path.to_path_buf(), - Err(source) => return Err(WorkspaceError::ResolveGrant { source }), - }; - let removed = self - .grants - .write() - .unwrap_or_else(PoisonError::into_inner) - .remove(&canonical); - if removed { - Ok(canonical) - } else { - Err(WorkspaceError::NotGranted) - } - } - - /// The granted roots in stable sorted order. - pub(crate) fn granted_roots(&self) -> Vec { - self.grants - .read() - .unwrap_or_else(PoisonError::into_inner) - .iter() - .cloned() - .collect() - } - - /// Lists one level of `path`, or the granted roots when `path` is - /// `None` or empty. Directories sort before files, each group ordered - /// by name. - /// - /// # Errors - /// Returns [`WorkspaceError`] when the path is forbidden, outside every - /// grant, missing, not a directory, or cannot be listed. - pub(crate) fn tree(&self, path: Option<&Path>) -> Result { - match path { - None => Ok(self.grants_listing()), - Some(path) if path.as_os_str().is_empty() => Ok(self.grants_listing()), - Some(path) => self.directory_listing(path), - } - } - - /// Reads a confined UTF-8 text file with its size and conflict - /// token. Binary and oversized files are rejected. - /// - /// # Errors - /// Returns [`WorkspaceError`] when the path is forbidden, outside every - /// grant, missing, not a regular file, binary, not UTF-8, oversized, or - /// cannot be read. - pub(crate) fn read_file(&self, path: &Path) -> Result { - let canonical = self.confine_existing(path)?; - let metadata = - fs::metadata(&canonical).map_err(|source| WorkspaceError::InspectPath { source })?; - if !metadata.is_file() { - return Err(WorkspaceError::NotAFile); - } - if metadata.len() > MAX_FILE_BYTES { - return Err(WorkspaceError::FileTooLarge { - limit: MAX_FILE_BYTES, - }); - } - let bytes = fs::read(&canonical).map_err(|source| WorkspaceError::ReadFile { source })?; - if bytes.contains(&0) { - return Err(WorkspaceError::BinaryFile); - } - let token = file_token(&metadata, &bytes); - let text = String::from_utf8(bytes).map_err(|_| WorkspaceError::NotUtf8)?; - Ok(FileContents { - path: canonical, - size: metadata.len(), - token, - text, - }) - } - - /// Writes `text` to a confined path, creating the file when it does not - /// exist. When the file exists, `expected_token` must match its current - /// conflict token or the write is refused as a conflict. - /// - /// # Errors - /// Returns [`WorkspaceError::FileTooLarge`] when the text exceeds the - /// size limit, [`WorkspaceError::ModifiedConflict`] when the token is - /// stale, absent, or underivable for the existing file, and otherwise - /// [`WorkspaceError`] when the path is forbidden, outside every grant, - /// not a regular file, or cannot be written. - pub(crate) fn write_file( - &self, - path: &Path, - text: &str, - expected_token: Option<&str>, - ) -> Result { - if text.len() as u64 > MAX_FILE_BYTES { - return Err(WorkspaceError::FileTooLarge { - limit: MAX_FILE_BYTES, - }); - } - let canonical = self.confine_for_write(path)?; - match fs::metadata(&canonical) { - Ok(metadata) => { - if !metadata.is_file() { - return Err(WorkspaceError::NotAFile); - } - // Fail closed: only a derivable on-disk token that equals - // the writer's token proves the file is unchanged. - match (current_token(&canonical, &metadata), expected_token) { - (Some(current), Some(expected)) if current == expected => {} - _ => return Err(WorkspaceError::ModifiedConflict), - } - } - Err(source) if source.kind() == io::ErrorKind::NotFound => {} - Err(source) => return Err(WorkspaceError::InspectPath { source }), - } - workshop_support::write_atomic(&canonical, text.as_bytes()) - .map_err(|source| WorkspaceError::WriteFile { source })?; - let metadata = - fs::metadata(&canonical).map_err(|source| WorkspaceError::InspectPath { source })?; - Ok(FileContents { - path: canonical, - size: metadata.len(), - token: file_token(&metadata, text.as_bytes()), - text: text.to_owned(), - }) - } - - /// The granted roots rendered as a synthetic directory listing. - fn grants_listing(&self) -> TreeListing { - let entries = self - .granted_roots() - .into_iter() - .map(|root| { - let metadata = fs::metadata(&root).ok(); - // The folder's own name reads better than the full path in - // the tree; the path stays available as the row tooltip. A - // drive root (C:\) has no file name and shows the path. - let name = root.file_name().map_or_else( - || root.to_string_lossy().into_owned(), - |name| name.to_string_lossy().into_owned(), - ); - TreeEntry { - name, - path: root, - kind: EntryKind::Directory, - size: 0, - modified_ms: metadata.as_ref().map_or(0, modified_ms), - exists: metadata.is_some(), - } - }) - .collect(); - TreeListing { - path: None, - entries, - } - } - - /// Lists one level of an existing confined directory. - fn directory_listing(&self, path: &Path) -> Result { - let canonical = self.confine_existing(path)?; - let metadata = - fs::metadata(&canonical).map_err(|source| WorkspaceError::InspectPath { source })?; - if !metadata.is_dir() { - return Err(WorkspaceError::NotADirectory); - } - let mut entries = Vec::new(); - for entry in - fs::read_dir(&canonical).map_err(|source| WorkspaceError::ListDirectory { source })? - { - let entry = entry.map_err(|source| WorkspaceError::ListDirectory { source })?; - let metadata = entry - .metadata() - .map_err(|source| WorkspaceError::InspectPath { source })?; - let kind = if metadata.is_dir() { - EntryKind::Directory - } else { - EntryKind::File - }; - entries.push(TreeEntry { - name: entry.file_name().to_string_lossy().into_owned(), - path: entry.path(), - kind, - size: if metadata.is_file() { - metadata.len() - } else { - 0 - }, - modified_ms: modified_ms(&metadata), - exists: true, - }); - } - entries.sort_by(|a, b| { - (a.kind != EntryKind::Directory) - .cmp(&(b.kind != EntryKind::Directory)) - .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())) - .then_with(|| a.name.cmp(&b.name)) - }); - Ok(TreeListing { - path: Some(canonical), - entries, - }) - } - - /// Canonicalizes an existing path and confines it to the grants. - fn confine_existing(&self, path: &Path) -> Result { - reject_forbidden(path)?; - let canonical = canonicalize_simplified(path).map_err(|source| { - if source.kind() == io::ErrorKind::NotFound { - WorkspaceError::NotFound - } else { - WorkspaceError::ResolvePath { source } - } - })?; - self.check_confined(canonical) - } - - /// Confines a write target: an existing path canonicalizes directly; a - /// new file confines its canonicalized parent and reattaches its name. - fn confine_for_write(&self, path: &Path) -> Result { - reject_forbidden(path)?; - match canonicalize_simplified(path) { - Ok(canonical) => self.check_confined(canonical), - Err(source) if source.kind() == io::ErrorKind::NotFound => { - // A dangling symlink canonicalizes as NotFound, but fs::write - // would follow it and create the target outside the grant. - match fs::symlink_metadata(path) { - Ok(_) => return Err(WorkspaceError::OutsideGrants), - Err(source) if source.kind() == io::ErrorKind::NotFound => {} - Err(source) => return Err(WorkspaceError::InspectPath { source }), - } - let parent = path.parent().ok_or(WorkspaceError::NotFound)?; - let canonical_parent = canonicalize_simplified(parent).map_err(|source| { - if source.kind() == io::ErrorKind::NotFound { - WorkspaceError::NotFound - } else { - WorkspaceError::ResolvePath { source } - } - })?; - let name = path.file_name().ok_or(WorkspaceError::ForbiddenComponent)?; - self.check_confined(canonical_parent.join(name)) - } - Err(source) => Err(WorkspaceError::ResolvePath { source }), - } - } - - /// Admits a canonical path that starts with a granted root. - fn check_confined(&self, canonical: PathBuf) -> Result { - let grants = self.grants.read().unwrap_or_else(PoisonError::into_inner); - if grants.iter().any(|root| canonical.starts_with(root)) { - Ok(canonical) - } else { - Err(WorkspaceError::OutsideGrants) - } - } -} - -/// Canonicalizes and strips Windows' `\\?\` verbatim prefix (a no-op on -/// other platforms). Every path the workspace stores, compares, or returns -/// goes through here, so grants and confinement checks stay in one form -/// and the UI never sees the prefix. -fn canonicalize_simplified(path: &Path) -> io::Result { - Ok(dunce::simplified(&path.canonicalize()?).to_path_buf()) -} - -/// Rejects the lexical tricks canonicalization would otherwise hide: `..` -/// traversal everywhere, and `:` alternate data stream names on Windows, -/// where a colon in a name addresses an NTFS stream. Elsewhere a colon is -/// an ordinary filename character and passes. -fn reject_forbidden(path: &Path) -> Result<(), WorkspaceError> { - for component in path.components() { - match component { - Component::ParentDir => return Err(WorkspaceError::ForbiddenComponent), - #[cfg(windows)] - Component::Normal(name) if name.to_string_lossy().contains(':') => { - return Err(WorkspaceError::ForbiddenComponent); - } - _ => {} - } - } - Ok(()) -} - -/// A file's modification time as milliseconds since the Unix epoch. -fn modified_ms(metadata: &fs::Metadata) -> u64 { - metadata - .modified() - .ok() - .and_then(|time| time.duration_since(UNIX_EPOCH).ok()) - .map_or(0, |duration| { - u64::try_from(duration.as_millis()).unwrap_or(u64::MAX) - }) -} - -/// The mtime half of the conflict token: full-precision modified time in -/// nanoseconds since the Unix epoch plus the byte length. `None` when the -/// filesystem reports no usable modified time, which callers cover with -/// [`hash_token`] - collapsing the error to a constant would make every -/// token on such a filesystem equal and no write would ever conflict. -fn mtime_token(metadata: &fs::Metadata) -> Option { - let duration = metadata.modified().ok()?.duration_since(UNIX_EPOCH).ok()?; - Some(format!("{}-{}", duration.as_nanos(), metadata.len())) -} - -/// The content-hash fallback token for filesystems without modified times. -/// `DefaultHasher` is stable within one process run, which is all a token -/// needs: a restart invalidates outstanding tokens toward conflict, never -/// toward a silent overwrite. -fn hash_token(contents: &[u8]) -> String { - let mut hasher = DefaultHasher::new(); - contents.hash(&mut hasher); - format!("h-{:016x}", hasher.finish()) -} - -/// A file's opaque conflict token from its metadata and already-read -/// contents: the mtime form when available, otherwise the hash form. -fn file_token(metadata: &fs::Metadata, contents: &[u8]) -> String { - mtime_token(metadata).unwrap_or_else(|| hash_token(contents)) -} - -/// The current on-disk token of an existing write target, reading the file -/// only when the hash fallback demands it. `None` means no token could be -/// derived - an unreadable or oversized file - and the caller must refuse -/// the write rather than overwrite unverified contents. -fn current_token(path: &Path, metadata: &fs::Metadata) -> Option { - if let Some(token) = mtime_token(metadata) { - return Some(token); - } - if metadata.len() > MAX_FILE_BYTES { - return None; - } - fs::read(path).ok().map(|bytes| hash_token(&bytes)) -} - -/// The query string of `GET /workspace/tree`. -#[derive(Debug, Deserialize)] -pub(crate) struct TreeQuery { - /// The directory to list; absent or empty lists the granted roots. - path: Option, -} - -/// The query string of `GET /workspace/file`. -#[derive(Debug, Deserialize)] -pub(crate) struct FileQuery { - /// The file to read. - path: String, -} - -/// The JSON body of `PUT /workspace/file`. -#[derive(Debug, Deserialize)] -pub(crate) struct WriteRequest { - /// The file to write. - path: String, - /// The new UTF-8 contents. - text: String, - /// The conflict token the writer last read; required to match when - /// the file already exists. - expected_token: Option, -} - -/// The JSON body of `POST /workspace/grant`. -#[derive(Debug, Deserialize)] -pub(crate) struct GrantRequest { - /// The dropped path: a folder grants itself, a file grants its parent. - path: String, -} - -/// The JSON body of a successful grant. -#[derive(Debug, Serialize)] -pub(crate) struct GrantResponse { - /// The root that was registered. - granted: PathBuf, -} - -/// The JSON body of `POST /workspace/revoke`. -#[derive(Debug, Deserialize)] -pub(crate) struct RevokeRequest { - /// The granted root to remove, as listed by the roots tree. - path: String, -} - -/// The JSON body of a successful revoke. -#[derive(Debug, Serialize)] -pub(crate) struct RevokeResponse { - /// The root that was removed. - revoked: PathBuf, -} - -/// Percent-decodes a workspace path parameter before validation. The query -/// layer already decoded once, so any surviving `%XX` sequence is a second -/// encoding layer - decoding it here means an encoded traversal (`%2e%2e`) -/// reaches the lexical `..` check as a literal `..` however the client -/// encoded it. Invalid sequences pass through unchanged. -fn decode_path_param(raw: &str) -> String { - percent_encoding::percent_decode_str(raw) - .decode_utf8_lossy() - .into_owned() -} - -/// Lists one level of a workspace directory, or the granted roots when the -/// query carries no path. -pub(crate) async fn tree( - State(workspace): State, - Query(query): Query, -) -> Response { - let path = query.path.as_deref().map(decode_path_param); - respond(workspace.tree(path.as_deref().map(Path::new))) -} - -/// Reads a confined UTF-8 text file with its metadata. -pub(crate) async fn read_file( - State(workspace): State, - Query(query): Query, -) -> Response { - let path = decode_path_param(&query.path); - respond(workspace.read_file(Path::new(&path))) -} - -/// Writes a confined file after path, size, and conflict-token validation. -pub(crate) async fn write_file( - State(workspace): State, - Json(body): Json, -) -> Response { - respond(workspace.write_file( - Path::new(&body.path), - &body.text, - body.expected_token.as_deref(), - )) -} - -/// Registers a dropped path as a granted root for this process. -pub(crate) async fn grant( - State(workspace): State, - Json(body): Json, -) -> Response { - respond( - workspace - .grant(Path::new(&body.path)) - .map(|granted| GrantResponse { granted }), - ) -} - -/// Removes a granted root; paths under it fail their next operation. -pub(crate) async fn revoke( - State(workspace): State, - Json(body): Json, -) -> Response { - respond( - workspace - .revoke(Path::new(&body.path)) - .map(|revoked| RevokeResponse { revoked }), - ) -} - -/// Renders a workspace result as JSON, routing failures through the -/// [`AppError`] wire envelope. -fn respond(result: Result) -> Response { - match result { - Ok(value) => (StatusCode::OK, Json(value)).into_response(), - Err(error) => AppError::from(error).into_response(), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - /// A workspace with one granted tempdir, returned alongside so the - /// directory outlives the test. - fn granted_dir() -> (Workspace, tempfile::TempDir) { - let dir = tempfile::TempDir::new().expect("tempdir"); - let workspace = Workspace::new(); - workspace.grant(dir.path()).expect("grant the tempdir"); - (workspace, dir) - } - - /// The canonical, verbatim-prefix-free form grants are stored in. - fn simplified(path: &Path) -> PathBuf { - canonicalize_simplified(path).expect("canonical") - } - - #[test] - fn a_folder_grant_grants_the_folder_itself() { - let workspace = Workspace::new(); - let dir = tempfile::TempDir::new().expect("tempdir"); - let granted = workspace.grant(dir.path()).expect("grant succeeds"); - assert_eq!(granted, simplified(dir.path())); - assert_eq!(workspace.granted_roots(), vec![granted]); - } - - #[test] - fn a_file_grant_grants_the_parent_directory() { - let workspace = Workspace::new(); - let dir = tempfile::TempDir::new().expect("tempdir"); - let file = dir.path().join("dropped.txt"); - fs::write(&file, "x").expect("seed the dropped file"); - let granted = workspace.grant(&file).expect("grant succeeds"); - assert_eq!(granted, simplified(dir.path())); - assert_eq!(workspace.granted_roots(), vec![granted]); - } - - #[test] - fn files_read_and_write_inside_a_grant() { - let (workspace, dir) = granted_dir(); - let file = dir.path().join("notes.txt"); - let written = workspace - .write_file(&file, "hello", None) - .expect("write inside the grant"); - assert_eq!(written.text, "hello"); - assert_eq!(written.size, 5); - let read = workspace.read_file(&file).expect("read inside the grant"); - assert_eq!(read.text, "hello"); - assert_eq!(read.size, 5); - assert_eq!(read.token, written.token); - } - - #[test] - fn writes_leave_no_temp_file_behind() { - let (workspace, dir) = granted_dir(); - let file = dir.path().join("notes.txt"); - let written = workspace - .write_file(&file, "one", None) - .expect("the create write succeeds"); - workspace - .write_file(&file, "two", Some(&written.token)) - .expect("the overwrite succeeds"); - let names: Vec = fs::read_dir(dir.path()) - .expect("the granted directory is listable") - .map(|entry| { - entry - .expect("the entry is readable") - .file_name() - .to_string_lossy() - .into_owned() - }) - .collect(); - assert_eq!( - names, - ["notes.txt"], - "the atomic write's temp file must not survive the write" - ); - } - - #[test] - fn paths_outside_every_grant_are_rejected() { - let workspace = Workspace::new(); - let dir = tempfile::TempDir::new().expect("tempdir"); - fs::write(dir.path().join("a.txt"), "a").expect("seed outside the grants"); - let error = workspace - .read_file(&dir.path().join("a.txt")) - .expect_err("an ungranted path must be rejected"); - assert!( - matches!(error, WorkspaceError::OutsideGrants), - "expected OutsideGrants, got {error:?}" - ); - } - - #[test] - fn parent_components_are_rejected() { - let (workspace, dir) = granted_dir(); - let escape = dir.path().join("..").join("anything.txt"); - let error = workspace - .read_file(&escape) - .expect_err("a .. component must be rejected"); - assert!( - matches!(error, WorkspaceError::ForbiddenComponent), - "expected ForbiddenComponent, got {error:?}" - ); - } - - #[cfg(windows)] - #[test] - fn alternate_data_stream_names_are_rejected() { - let (workspace, dir) = granted_dir(); - let stream = dir.path().join("notes.txt:secret"); - let error = workspace - .write_file(&stream, "hidden", None) - .expect_err("an alternate data stream name must be rejected"); - assert!( - matches!(error, WorkspaceError::ForbiddenComponent), - "expected ForbiddenComponent, got {error:?}" - ); - } - - #[test] - fn a_symlink_escape_is_rejected() { - let (workspace, dir) = granted_dir(); - let outside = tempfile::TempDir::new().expect("outside tempdir"); - fs::write(outside.path().join("secret.txt"), "secret").expect("seed the secret"); - let link = dir.path().join("link"); - #[cfg(unix)] - let linked = std::os::unix::fs::symlink(outside.path(), &link); - #[cfg(windows)] - let linked = std::os::windows::fs::symlink_dir(outside.path(), &link); - let Ok(()) = linked else { - // Symlink creation needs a privilege some Windows hosts lack. - eprintln!("skipping: symlink creation failed"); - return; - }; - let error = workspace - .read_file(&link.join("secret.txt")) - .expect_err("a symlink escape must be rejected"); - assert!( - matches!(error, WorkspaceError::OutsideGrants), - "expected OutsideGrants, got {error:?}" - ); - } - - #[test] - fn a_dangling_symlink_write_is_rejected() { - let (workspace, dir) = granted_dir(); - let outside = tempfile::TempDir::new().expect("outside tempdir"); - let target = outside.path().join("new.txt"); - let link = dir.path().join("link.txt"); - #[cfg(unix)] - let linked = std::os::unix::fs::symlink(&target, &link); - #[cfg(windows)] - let linked = std::os::windows::fs::symlink_file(&target, &link); - let Ok(()) = linked else { - // Symlink creation needs a privilege some Windows hosts lack. - eprintln!("skipping: symlink creation failed"); - return; - }; - let error = workspace - .write_file(&link, "payload", None) - .expect_err("a write through a dangling symlink must be rejected"); - assert!( - matches!(error, WorkspaceError::OutsideGrants), - "expected OutsideGrants, got {error:?}" - ); - assert!(!target.exists(), "nothing may be written outside the grant"); - } - - #[test] - fn binary_files_are_rejected() { - let (workspace, dir) = granted_dir(); - let file = dir.path().join("bin.dat"); - fs::write(&file, [0x66, 0x00, 0x66]).expect("seed a binary file"); - let error = workspace - .read_file(&file) - .expect_err("a binary file must be rejected"); - assert!( - matches!(error, WorkspaceError::BinaryFile), - "expected BinaryFile, got {error:?}" - ); - } - - #[test] - fn oversized_files_are_rejected() { - let (workspace, dir) = granted_dir(); - let file = dir.path().join("big.txt"); - let big = vec![b'x'; usize::try_from(MAX_FILE_BYTES).expect("the limit fits") + 1]; - fs::write(&file, big).expect("seed an oversized file"); - let error = workspace - .read_file(&file) - .expect_err("an oversized file must be rejected"); - assert!( - matches!(error, WorkspaceError::FileTooLarge { .. }), - "expected FileTooLarge, got {error:?}" - ); - } - - #[test] - fn oversized_writes_are_rejected() { - let (workspace, dir) = granted_dir(); - let text = "x".repeat(usize::try_from(MAX_FILE_BYTES).expect("the limit fits") + 1); - let error = workspace - .write_file(&dir.path().join("big.txt"), &text, None) - .expect_err("an oversized write must be rejected"); - assert!( - matches!(error, WorkspaceError::FileTooLarge { .. }), - "expected FileTooLarge, got {error:?}" - ); - } - - #[test] - fn a_stale_modified_token_conflicts() { - let (workspace, dir) = granted_dir(); - let file = dir.path().join("a.txt"); - let written = workspace - .write_file(&file, "one", None) - .expect("initial write"); - let stale = format!("{}-stale", written.token); - let error = workspace - .write_file(&file, "two", Some(&stale)) - .expect_err("a stale token must conflict"); - assert!( - matches!(error, WorkspaceError::ModifiedConflict), - "expected ModifiedConflict, got {error:?}" - ); - let rewritten = workspace - .write_file(&file, "two", Some(&written.token)) - .expect("the fresh token writes"); - assert_eq!(rewritten.text, "two"); - } - - #[test] - fn a_tokenless_write_to_an_existing_file_conflicts() { - let (workspace, dir) = granted_dir(); - let file = dir.path().join("a.txt"); - workspace - .write_file(&file, "one", None) - .expect("initial write"); - let error = workspace - .write_file(&file, "two", None) - .expect_err("a write with no token over an existing file must conflict"); - assert!( - matches!(error, WorkspaceError::ModifiedConflict), - "expected ModifiedConflict, got {error:?}" - ); - } - - #[test] - fn the_token_tracks_mtime_and_length_not_write_count() { - let (workspace, dir) = granted_dir(); - let file = dir.path().join("t.txt"); - let written = workspace.write_file(&file, "one", None).expect("write"); - let metadata = fs::metadata(&file).expect("metadata"); - // The token is a pure function of full-precision mtime plus length: - // a same-content rewrite changes it exactly when the filesystem - // reports a new mtime or length, and never otherwise. - assert_eq!(Some(written.token.clone()), mtime_token(&metadata)); - let rewritten = workspace - .write_file(&file, "one", Some(&written.token)) - .expect("same-content rewrite"); - let metadata = fs::metadata(&file).expect("metadata after rewrite"); - assert_eq!(Some(rewritten.token), mtime_token(&metadata)); - // Reading without a write in between re-derives the same token. - let reread = workspace.read_file(&file).expect("read"); - let again = workspace.read_file(&file).expect("second read"); - assert_eq!(reread.token, again.token); - } - - #[test] - fn the_hash_fallback_token_round_trips() { - let token = hash_token(b"same contents"); - assert_eq!( - token, - hash_token(b"same contents"), - "the fallback token must be stable for identical contents" - ); - assert!(token.starts_with("h-"), "got {token}"); - assert_ne!(token, hash_token(b"different contents")); - } - - #[cfg(unix)] - #[test] - fn colon_named_files_read_and_write_on_unix() { - let (workspace, dir) = granted_dir(); - let file = dir.path().join("backup-12:30.log"); - let written = workspace - .write_file(&file, "ok", None) - .expect("a colon-named file writes on unix"); - let read = workspace - .read_file(&file) - .expect("a colon-named file reads on unix"); - assert_eq!(read.text, "ok"); - assert_eq!(read.token, written.token); - } - - #[test] - fn tree_lists_directories_before_files_with_stable_ordering() { - let (workspace, dir) = granted_dir(); - fs::create_dir(dir.path().join("zeta")).expect("dir"); - fs::create_dir(dir.path().join("alpha")).expect("dir"); - fs::write(dir.path().join("b.txt"), "b").expect("file"); - fs::write(dir.path().join("a.txt"), "a").expect("file"); - let listing = workspace.tree(Some(dir.path())).expect("tree"); - let names: Vec<&str> = listing - .entries - .iter() - .map(|entry| entry.name.as_str()) - .collect(); - assert_eq!(names, ["alpha", "zeta", "a.txt", "b.txt"]); - assert_eq!(listing.entries[0].kind, EntryKind::Directory); - assert_eq!(listing.entries[3].kind, EntryKind::File); - assert!( - listing.entries.iter().all(|entry| entry.exists), - "an enumerated directory entry is on disk by construction" - ); - } - - #[test] - fn a_tree_without_a_path_lists_the_granted_roots() { - let (workspace, dir) = granted_dir(); - let listing = workspace.tree(None).expect("roots listing"); - assert_eq!(listing.path, None); - assert_eq!(listing.entries.len(), 1); - let root = simplified(dir.path()); - assert_eq!(listing.entries[0].path, root); - // A root row shows the folder's own name, not the whole path. - assert_eq!( - listing.entries[0].name, - root.file_name().expect("leaf").to_string_lossy() - ); - assert_eq!(listing.entries[0].kind, EntryKind::Directory); - assert!(listing.entries[0].exists, "a live root lists as existing"); - } - - #[test] - fn the_roots_listing_flags_a_deleted_root_as_missing() { - let workspace = Workspace::new(); - let kept = tempfile::TempDir::new().expect("tempdir"); - let doomed = tempfile::TempDir::new().expect("tempdir"); - let kept_root = workspace.grant(kept.path()).expect("grant the kept root"); - let doomed_root = workspace - .grant(doomed.path()) - .expect("grant the doomed root"); - doomed.close().expect("delete the doomed directory"); - let listing = workspace.tree(None).expect("roots listing"); - assert_eq!(listing.entries.len(), 2); - for entry in &listing.entries { - if entry.path == doomed_root { - assert!(!entry.exists, "the deleted root must list as missing"); - } else { - assert_eq!(entry.path, kept_root); - assert!(entry.exists, "the live root must list as existing"); - } - } - } - - #[test] - fn percent_sequences_decode_before_validation() { - assert_eq!(decode_path_param("%2e%2e/x"), "../x"); - assert_eq!(decode_path_param("plain.txt"), "plain.txt"); - // An invalid sequence is not an encoding; the literal survives. - assert_eq!(decode_path_param("100%.txt"), "100%.txt"); - } - - /// A double-encoded traversal (`%252e%252e` in the raw query) survives - /// the query layer's single decode as `%2e%2e`; the handler's explicit - /// decode must still reveal it to the lexical `..` check. - #[tokio::test] - async fn an_encoded_traversal_in_the_query_is_rejected() { - use axum::body::Body; - use axum::http::{Request, StatusCode}; - use tower::ServiceExt as _; - - let (workspace, _dir) = granted_dir(); - let router = crate::routes::workspace::routes(workspace); - for uri in [ - "/workspace/file?path=%252e%252e%2Fsecret.txt", - "/workspace/tree?path=%252e%252e", - ] { - let request = Request::builder() - .uri(uri) - .body(Body::empty()) - .expect("static request parts are valid"); - let response = router - .clone() - .oneshot(request) - .await - .expect("the router is infallible"); - assert_eq!(response.status(), StatusCode::FORBIDDEN, "for {uri}"); - let body = crate::app::fixtures::body_bytes(response).await; - let json: serde_json::Value = - serde_json::from_slice(&body).expect("the envelope is JSON"); - assert_eq!( - json["error"]["code"], "forbidden_component", - "the traversal must be caught lexically, not by a lookup miss: {uri}" - ); - } - } - - #[test] - fn a_revoke_removes_the_granted_root() { - let (workspace, dir) = granted_dir(); - let revoked = workspace.revoke(dir.path()).expect("revoke the grant"); - assert_eq!(revoked, simplified(dir.path())); - assert_eq!(workspace.granted_roots(), Vec::::new()); - } - - #[test] - fn revoking_an_unknown_root_errors() { - let workspace = Workspace::new(); - let dir = tempfile::TempDir::new().expect("tempdir"); - let error = workspace - .revoke(dir.path()) - .expect_err("an ungranted root must not revoke"); - assert!( - matches!(error, WorkspaceError::NotGranted), - "expected NotGranted, got {error:?}" - ); - } - - #[test] - fn a_deleted_root_can_still_be_revoked() { - let (workspace, dir) = granted_dir(); - let root = simplified(dir.path()); - dir.close().expect("delete the granted directory"); - let revoked = workspace.revoke(&root).expect("revoke the deleted root"); - assert_eq!(revoked, root); - assert_eq!(workspace.granted_roots(), Vec::::new()); - } - - #[test] - fn a_spelling_variant_revokes_the_same_root() { - let (workspace, dir) = granted_dir(); - let variant = PathBuf::from(format!( - "{}{}", - dir.path().display(), - std::path::MAIN_SEPARATOR - )); - let revoked = workspace - .revoke(&variant) - .expect("a trailing separator names the same root"); - assert_eq!(revoked, simplified(dir.path())); - assert_eq!(workspace.granted_roots(), Vec::::new()); - } - - #[test] - fn reads_and_writes_under_a_revoked_root_are_rejected() { - let (workspace, dir) = granted_dir(); - let file = dir.path().join("notes.txt"); - let written = workspace - .write_file(&file, "hello", None) - .expect("write before the revoke"); - workspace.revoke(dir.path()).expect("revoke the grant"); - let error = workspace - .read_file(&file) - .expect_err("a read under a revoked root must be rejected"); - assert!( - matches!(error, WorkspaceError::OutsideGrants), - "expected OutsideGrants, got {error:?}" - ); - let error = workspace - .write_file(&file, "later", Some(&written.token)) - .expect_err("a write under a revoked root must be rejected"); - assert!( - matches!(error, WorkspaceError::OutsideGrants), - "expected OutsideGrants, got {error:?}" - ); - } - - #[test] - fn a_nested_grant_survives_its_parents_revoke() { - let workspace = Workspace::new(); - let parent = tempfile::TempDir::new().expect("tempdir"); - let child = parent.path().join("child"); - fs::create_dir(&child).expect("create the nested directory"); - fs::write(parent.path().join("outer.txt"), "outer").expect("seed the parent"); - fs::write(child.join("inner.txt"), "inner").expect("seed the child"); - workspace.grant(parent.path()).expect("grant the parent"); - workspace.grant(&child).expect("grant the child"); - workspace.revoke(parent.path()).expect("revoke the parent"); - assert_eq!(workspace.granted_roots(), vec![simplified(&child)]); - let read = workspace - .read_file(&child.join("inner.txt")) - .expect("the nested grant stays usable"); - assert_eq!(read.text, "inner"); - let error = workspace - .read_file(&parent.path().join("outer.txt")) - .expect_err("the parent's own files lose access"); - assert!( - matches!(error, WorkspaceError::OutsideGrants), - "expected OutsideGrants, got {error:?}" - ); - } - - /// Builds a `POST /workspace/revoke` request with a raw JSON body. - fn revoke_request(body: String) -> axum::http::Request { - axum::http::Request::builder() - .method("POST") - .uri("/workspace/revoke") - .header(axum::http::header::CONTENT_TYPE, "application/json") - .body(axum::body::Body::from(body)) - .expect("static request parts are valid") - } - - #[tokio::test] - async fn a_revoke_over_http_removes_the_root() { - use tower::ServiceExt as _; - - let (workspace, dir) = granted_dir(); - let router = crate::routes::workspace::routes(workspace.clone()); - let root = simplified(dir.path()); - let body = serde_json::json!({ "path": root }).to_string(); - let response = router - .oneshot(revoke_request(body)) - .await - .expect("the router is infallible"); - assert_eq!(response.status(), StatusCode::OK); - let bytes = crate::app::fixtures::body_bytes(response).await; - let json: serde_json::Value = serde_json::from_slice(&bytes).expect("the body is JSON"); - assert_eq!(json["revoked"], serde_json::json!(root)); - assert_eq!(workspace.granted_roots(), Vec::::new()); - } - - #[tokio::test] - async fn an_unknown_root_revoke_answers_not_found() { - use tower::ServiceExt as _; - - let (workspace, _dir) = granted_dir(); - let outside = tempfile::TempDir::new().expect("outside tempdir"); - let router = crate::routes::workspace::routes(workspace); - let body = serde_json::json!({ "path": outside.path() }).to_string(); - let response = router - .oneshot(revoke_request(body)) - .await - .expect("the router is infallible"); - assert_eq!(response.status(), StatusCode::NOT_FOUND); - let bytes = crate::app::fixtures::body_bytes(response).await; - let json: serde_json::Value = serde_json::from_slice(&bytes).expect("the body is JSON"); - assert_eq!(json["error"]["code"], "not_granted"); - } - - #[tokio::test] - async fn a_malformed_revoke_body_answers_bad_request() { - use tower::ServiceExt as _; - - let (workspace, _dir) = granted_dir(); - let router = crate::routes::workspace::routes(workspace); - let response = router - .oneshot(revoke_request("{".to_owned())) - .await - .expect("the router is infallible"); - assert_eq!(response.status(), StatusCode::BAD_REQUEST); - } - - #[test] - fn a_tree_of_a_file_is_rejected() { - let (workspace, dir) = granted_dir(); - let file = dir.path().join("a.txt"); - fs::write(&file, "a").expect("seed"); - let error = workspace - .tree(Some(&file)) - .expect_err("a file cannot be listed"); - assert!( - matches!(error, WorkspaceError::NotADirectory), - "expected NotADirectory, got {error:?}" - ); - } -} diff --git a/crates/workshop-server/tests/it/chat_gate.rs b/crates/workshop-server/tests/it/chat_gate.rs index de4f7fe6d..7ea1478e3 100644 --- a/crates/workshop-server/tests/it/chat_gate.rs +++ b/crates/workshop-server/tests/it/chat_gate.rs @@ -49,7 +49,7 @@ use crate::agents::{answer, collect_turn, delta_text, next_wait_token, wait_afte use crate::common::{JsonSocket, spawn_gateway}; /// The embedded built-in chat prompt, exactly what a `chat` launch runs. -const CHAT_MD: &str = include_str!("../../agents/chat.md"); +const CHAT_MD: &str = include_str!("../../../workshop-sessions/agents/chat.md"); /// Every completion request body the gate mock received, in arrival /// order: the gate's proof of exactly what the model was shown. diff --git a/crates/workshop-server/tests/it/heartbeat_loop.rs b/crates/workshop-server/tests/it/heartbeat_loop.rs index 7233d014f..8ff8a0567 100644 --- a/crates/workshop-server/tests/it/heartbeat_loop.rs +++ b/crates/workshop-server/tests/it/heartbeat_loop.rs @@ -29,7 +29,7 @@ use workshop_gateway::{GatewayBinding, GatewayHealth, Heartbeat}; use workshop_menu::{CatalogBus, MenuBus}; use workshop_protocol::{CatalogPush, Severity, StatusBarUpdate, WorkbenchSnapshot}; use workshop_registry::{ - CatalogSink, MenuSink, Push, Registration, Registry, StatusChannel, StatusSink, + CatalogSink, MenuSink, Push, Registration, Registry, StateProvider, StatusChannel, StatusSink, }; use workshop_status::StatusBus; use workshop_support::ReconnectBackoff; @@ -38,19 +38,28 @@ use workshop_support::ReconnectBackoff; type Guards = ( Registration, Registration, + Registration, Registration, Registration, + Registration, ); /// Wires the buses into a fresh registry and returns the push facade /// plus the guards keeping the registrations alive. fn wired_push(status: &StatusBus, catalog: &CatalogBus, menu: &MenuBus) -> (Push, Guards) { let registry = Registry::new(); - let (status_channel, status_sink) = workshop_status::register(®istry, status); - let (catalog_sink, menu_sink) = workshop_menu::register(®istry, catalog, menu); + let (status_channel, status_sink, status_state) = workshop_status::register(®istry, status); + let (catalog_sink, menu_sink, menu_state) = workshop_menu::register(®istry, catalog, menu); ( registry.push(), - (status_channel, status_sink, catalog_sink, menu_sink), + ( + status_channel, + status_sink, + status_state, + catalog_sink, + menu_sink, + menu_state, + ), ) } diff --git a/crates/workshop-sessions/Cargo.toml b/crates/workshop-sessions/Cargo.toml new file mode 100644 index 000000000..6f183a76e --- /dev/null +++ b/crates/workshop-sessions/Cargo.toml @@ -0,0 +1,48 @@ +[package] +name = "workshop-sessions" +version = "0.0.0" +publish = false +edition.workspace = true +license.workspace = true +repository.workspace = true + +description = "Workshop sessions subsystem: the /ws workbench socket, the /agents/ws agent-session socket with supervision and input waits, and the /v1/models catalog relay" + +[features] +default = [] +test-fixtures = [] + +[dependencies] +async-trait.workspace = true +axum.workspace = true +futures-util.workspace = true +promptforge-agent.workspace = true +promptforge-core.workspace = true +promptforge-core-support.workspace = true +promptforge-model-client.workspace = true +promptforge-tool-picker.workspace = true +promptforge-tools.workspace = true +promptforge-vfs.workspace = true +rand.workspace = true +serde.workspace = true +serde_json.workspace = true +shared-vfs.workspace = true +thiserror.workspace = true +tokio.workspace = true +tracing.workspace = true +workshop-gateway.workspace = true +workshop-menu.workspace = true +workshop-protocol.workspace = true +workshop-registry.workspace = true +workshop-support.workspace = true + +[dev-dependencies] +tempfile.workspace = true +tokio = { workspace = true, features = ["macros", "rt-multi-thread", "test-util"] } +tokio-tungstenite.workspace = true +tower.workspace = true +workshop-gateway = { workspace = true, features = ["test-fixtures"] } +workshop-status.workspace = true + +[lints] +workspace = true diff --git a/crates/workshop-server/agents/chat.md b/crates/workshop-sessions/agents/chat.md similarity index 100% rename from crates/workshop-server/agents/chat.md rename to crates/workshop-sessions/agents/chat.md diff --git a/crates/workshop-sessions/src/agents.rs b/crates/workshop-sessions/src/agents.rs new file mode 100644 index 000000000..cb7eb1854 --- /dev/null +++ b/crates/workshop-sessions/src/agents.rs @@ -0,0 +1,419 @@ +//! Agent sessions: discovery of `.lua` agent programs, the +//! [`AgentSessions`] registry, and each session's run lifecycle. +//! +//! A session owns one running agent: its persisting event log +//! ([`workshop_gateway::WorkshopObserver`], JSONL under +//! `state_dir/sessions/.jsonl`), its +//! [`crate::input::WaitRegistry`] and `user_input` tool, its dedicated +//! delta broadcast (deltas never enter the event log), and the retained +//! cancel handle behind turn-cancel. The supervisor task relaunches +//! `run_agent` over the retained event log after a turn-cancel - +//! cancellation is a stop reason, never an error - and ends the session +//! when the program returns or fails. +//! +//! **Registry carve-out.** Sessions survive socket disconnect and sockets +//! attach and detach (`socket`), so this module keeps the session +//! registry the crate's socket rule otherwise forbids. The rule governed +//! per-request relay work, where every held resource belonged to one +//! socket; an agent session is longer-lived than any socket on purpose, +//! and the registry is the one place that owns it. +//! +//! Reply ids coalesce deltas: every live delta is stamped with the id of +//! the durable event that will supersede it. The id is the count of +//! settled model rounds - the session observer advances it as the reply +//! or tool-call event lands, before the program +//! resumes, and the socket derives the same count from the event sequence +//! itself, so both sides agree without sharing more than the log. + +mod lifecycle; +mod session; +pub(crate) mod socket; +mod supervisor; + +use std::collections::HashMap; +use std::fmt; +use std::io; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; + +use tokio::sync::{broadcast, mpsc}; + +use workshop_gateway::{GatewayBinding, WorkshopObserver}; +use workshop_menu::{CatalogBus, MenuBus}; +use workshop_registry::{Push, Registry}; +use workshop_support::ReconnectBackoff; + +use crate::input::WaitRegistry; + +use self::lifecycle::RunLifecycle; + +pub(crate) use session::{AgentDelta, AgentSession, AgentSource, SessionObserver}; +pub(crate) use session::{build_model_catalog, delta_stamp, reply_stamp, ui_provider}; + +/// Capacity of a session's delta broadcast. Deltas are ephemeral: a +/// receiver that lags loses chunks, and the completed-reply event is the +/// repair path. +pub(crate) const DELTA_CAPACITY: usize = 256; + +/// Capacity of a session's input-frame broadcast. A session holds at +/// most a handful of waits; the registry's retained state is the +/// durable-delivery repair path on lag. +const INPUT_CAPACITY: usize = 32; + +/// Capacity of a session's error broadcast. Session errors are rare +/// one-off reports: a failed model round or a run that ended in error +/// surfaces one frame each, and a receiver that lags misses only what +/// the durable transcript already shows as a turn without a reply. +pub(crate) const ERROR_CAPACITY: usize = 8; + +/// The committed built-in chat agent, embedded at compile time - the same +/// shipped-asset pattern as the SPA `dist/` - so a fresh install has a +/// working chat with no agents directory at all. The built-in is a +/// Markdown prompt on the unified runtime; the standalone `chat.lua` +/// program is retired. +pub(crate) const BUILTIN_CHAT_SOURCE: &str = include_str!("../agents/chat.md"); + +/// The built-in default agent's name: discovery always offers it, and a +/// directory file named `chat.lua` shadows the embedded source. +const BUILTIN_CHAT_NAME: &str = "chat"; + +/// The shared handles a session's lifecycle reports flow through, +/// captured once at [`AgentSessions`] construction. The buses come from +/// the menu subsystem; the push facade and the workspace-roots handle +/// are read through the subsystem registry's slots, so this host never +/// names the workspace crate the tier graph forbids. +#[derive(Debug, Clone)] +pub struct SessionHost { + /// The subsystem registry: the push facade and the workspace-roots + /// slot are read through it. + registry: Registry, + /// Reset on completed replies: an agent reply is useful gateway work. + backoff: ReconnectBackoff, + /// Serves `selected_model` to the agent's `ui()` snapshot. + menu: MenuBus, + /// The retained gateway catalog the session's model catalog is built + /// from at launch. + catalog: CatalogBus, +} + +impl SessionHost { + /// Bundles the registry and the bus handles for one sessions host. + #[must_use] + pub fn new( + registry: Registry, + backoff: ReconnectBackoff, + menu: MenuBus, + catalog: CatalogBus, + ) -> Self { + Self { + registry, + backoff, + menu, + catalog, + } + } + + /// The push facade over the registry's producer sink slots. + pub(crate) fn push(&self) -> Push { + self.registry.push() + } + + /// The subsystem registry the `ui()` snapshot reads the workspace + /// roots slot through. + pub(crate) fn registry(&self) -> &Registry { + &self.registry + } + + /// The shared reconnect backoff, reset on completed replies. + pub(crate) fn backoff(&self) -> &ReconnectBackoff { + &self.backoff + } + + /// The menu bus serving `selected_model` to the `ui()` snapshot. + pub(crate) fn menu(&self) -> &MenuBus { + &self.menu + } + + /// The retained catalog the session's model catalog is built from. + pub(crate) fn catalog(&self) -> &CatalogBus { + &self.catalog + } +} + +/// The registry of running agent sessions. +/// +/// Typed and construction-phased: everything a launch needs is captured +/// when the composition root builds it, and the only mutable state +/// is the session map itself. Sessions survive socket disconnect - +/// sockets attach and detach through the `socket` module - which is this +/// module's documented carve-out from the crate's no-session-registry +/// socket rule. +#[derive(Clone)] +pub struct AgentSessions { + inner: Arc, +} + +/// The shared registry state behind the cloneable handle. +struct Inner { + /// Directory whose `.lua` files are the launchable agents. + agents_dir: PathBuf, + /// Where session event JSONLs persist (`state_dir/sessions`). + sessions_dir: PathBuf, + /// The atomically replaceable Gateway clients every run snapshots. + gateway: GatewayBinding, + /// The shared handles session lifecycles report through. + host: SessionHost, + /// The running sessions by id. + sessions: Mutex>>, +} + +impl fmt::Debug for AgentSessions { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("AgentSessions") + .field("agents_dir", &self.inner.agents_dir) + .field("sessions", &self.lock().len()) + .finish_non_exhaustive() + } +} + +impl AgentSessions { + /// Builds the registry over the discovery directory, the sessions + /// state directory, the model client agents complete through, and + /// the shared handles. Nothing touches the filesystem here: + /// discovery reads the agents directory per request, and the + /// sessions directory is created at first launch. + #[must_use] + pub fn new( + agents_dir: PathBuf, + sessions_dir: PathBuf, + gateway: GatewayBinding, + host: SessionHost, + ) -> Self { + Self { + inner: Arc::new(Inner { + agents_dir, + sessions_dir, + gateway, + host, + sessions: Mutex::new(HashMap::new()), + }), + } + } + + /// The launchable agent names: the `.lua` file stems under the + /// configured agents directory plus the built-in `chat`, sorted. The + /// built-in is always offered - a missing or unreadable directory + /// still lists it, so a fresh install always has a working chat - and + /// a directory file named `chat.lua` shadows the embedded source + /// rather than listing twice. + #[must_use] + pub fn discover(&self) -> Vec { + discover_agents(&self.inner.agents_dir) + } + + /// Launches a session running the discovered agent `name` and + /// returns it. The session runs until its program returns, fails, or + /// [`close`](Self::close) ends it; turn-cancel relaunches the program + /// over the retained event log without ending the session. + /// + /// # Errors + /// Returns [`LaunchRefusal::UnknownAgent`] when `name` is not a + /// discovered agent (which also refuses path-shaped names: discovery + /// yields bare file stems), [`LaunchRefusal::GatewayUnusable`] when + /// the workshop gateway settings could not make a model client, and + /// [`LaunchRefusal::SessionState`] when the sessions directory or the + /// session's event log cannot be created. + pub(crate) fn launch(&self, name: &str) -> Result, LaunchRefusal> { + // Resolving through the discovered list is the trust boundary: a + // client-sent name never reaches the filesystem unless it is the + // bare stem of a real `.lua` file in the configured directory. + if !self.discover().iter().any(|agent| agent == name) { + return Err(LaunchRefusal::UnknownAgent { + name: name.to_owned(), + }); + } + // The client is checked at launch, not at startup: a workshop + // whose gateway settings cannot make a model client still serves + // chat, but an agent run would fail its first model round - or + // silently resolve a different gateway from the environment - so + // the launch refuses instead. + if self.inner.gateway.snapshot().model_client().is_none() { + return Err(LaunchRefusal::GatewayUnusable); + } + let source = agent_source(&self.inner.agents_dir, name) + .map_err(|source| LaunchRefusal::SessionState { source })?; + std::fs::create_dir_all(&self.inner.sessions_dir) + .map_err(|source| LaunchRefusal::SessionState { source })?; + let id = fresh_session_id(); + let log_path = self.inner.sessions_dir.join(format!("{id}.jsonl")); + let observer = Arc::new( + WorkshopObserver::new(Some(&log_path)) + .map_err(|source| LaunchRefusal::SessionState { source })?, + ); + let (supervisor_events, events) = mpsc::unbounded_channel(); + let (cancellations, cancellation_events) = mpsc::channel(lifecycle::CANCELLATION_CAPACITY); + let lifecycle = Arc::new(RunLifecycle::new(supervisor_events, cancellations)); + let waits = Arc::new(WaitRegistry::new()); + let (input_frames, _) = broadcast::channel(INPUT_CAPACITY); + let (deltas, _) = broadcast::channel(DELTA_CAPACITY); + let (errors, _) = broadcast::channel(ERROR_CAPACITY); + let session = Arc::new(AgentSession::new( + id.clone(), + name, + source, + observer, + lifecycle, + waits, + input_frames, + deltas, + errors, + )); + self.lock().insert(id, Arc::clone(&session)); + supervisor::spawn( + Arc::clone(&session), + self.clone(), + self.inner.host.clone(), + self.inner.gateway.clone(), + events, + cancellation_events, + ); + Ok(session) + } + + /// The running session with this id, when one exists. + pub(crate) fn get(&self, id: &str) -> Option> { + self.lock().get(id).cloned() + } + + /// Ends the session with this id: its run is cancelled for good (no + /// relaunch), pending waits die as `input_cancelled`, and the session + /// leaves the registry. Returns whether a session was ended. The + /// persisted event JSONL stays on disk. + #[must_use] + pub fn close(&self, id: &str) -> bool { + let Some(session) = self.lock().remove(id) else { + return false; + }; + session.close(); + true + } + + /// The unresolved wait tokens of the session with this id - the + /// teardown leak probe: after a close or a finished run, the list + /// must be empty. `None` when no such session is registered. + #[must_use] + pub fn unresolved_waits(&self, id: &str) -> Option> { + Some(self.get(id)?.waits.unresolved()) + } + + /// Delivers a fixture response after running `after_acceptance` + /// between its durable observation and the waiting tool's resumption. + #[cfg(feature = "test-fixtures")] + pub fn deliver_input_after_acceptance_for_test( + &self, + id: &str, + response: workshop_protocol::InputResponse, + after_acceptance: impl FnOnce(), + ) -> Option> { + let session = self.get(id)?; + Some(session.accept_input(response, after_acceptance)) + } + + /// The session map guard; a lock poisoned by a panicking peer + /// recovers the value rather than wedging the process (zone two). + fn lock(&self) -> MutexGuard<'_, HashMap>> { + self.inner + .sessions + .lock() + .unwrap_or_else(PoisonError::into_inner) + } + + /// Removes a finished session from the map, unless a close already + /// did. + fn forget(&self, id: &str) { + self.lock().remove(id); + } +} + +/// A refused agent launch, relayed to the client as an error frame. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub(crate) enum LaunchRefusal { + /// The requested name is not a discovered agent. + #[error("unknown agent {name:?}: not in the agents directory")] + UnknownAgent { + /// The name that was requested. + name: String, + }, + /// The workshop gateway settings could not make a model client, so + /// no agent could complete a model round. + #[error( + "agent sessions need a usable gateway client; check `gateway.base_url` and \ + `gateway.api_key` in workshop.toml" + )] + GatewayUnusable, + /// The session's on-disk state could not be prepared. + #[error("agent session state unavailable")] + SessionState { + /// The underlying filesystem failure. + #[source] + source: io::Error, + }, +} + +/// Lists the launchable agent names: the `.lua` file stems under `dir` +/// plus the built-in `chat`, sorted. A missing or unreadable directory +/// offers exactly the built-in, and a directory `chat.lua` lists once - +/// it shadows the embedded source instead of duplicating the name. +fn discover_agents(dir: &Path) -> Vec { + let mut names: Vec = std::fs::read_dir(dir) + .into_iter() + .flatten() + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| { + path.is_file() && path.extension().is_some_and(|extension| extension == "lua") + }) + .filter_map(|path| { + path.file_stem() + .and_then(|stem| stem.to_str()) + .map(str::to_owned) + }) + .collect(); + if !names.iter().any(|name| name == BUILTIN_CHAT_NAME) { + names.push(BUILTIN_CHAT_NAME.to_owned()); + } + names.sort(); + names +} + +/// Reads the agent's program source: the directory file when it exists - +/// a directory `chat.lua` shadows the built-in - else the embedded +/// built-in for the `chat` name alone. Launch resolved `name` through +/// discovery already, so a missing file for any other name is a real +/// filesystem race, surfaced as the error it is; so is an existing +/// `chat.lua` that cannot be read, because silently serving the built-in +/// would mask the operator's own file. +fn agent_source(dir: &Path, name: &str) -> io::Result { + match std::fs::read_to_string(dir.join(format!("{name}.lua"))) { + Ok(source) => Ok(AgentSource::Lua(source)), + Err(error) if name == BUILTIN_CHAT_NAME && error.kind() == io::ErrorKind::NotFound => { + Ok(AgentSource::Markdown(BUILTIN_CHAT_SOURCE.to_owned())) + } + Err(error) => Err(error), + } +} + +/// A fresh unguessable session id: 128 bits from the OS-seeded +/// cryptographic RNG, hex-encoded - wide enough that ids never collide +/// across server restarts, so an old session's JSONL is never truncated +/// by a new session's log. +fn fresh_session_id() -> String { + use rand::Rng as _; + let mut rng = rand::rng(); + format!("{:016x}{:016x}", rng.random::(), rng.random::()) +} + +#[cfg(test)] +mod tests; diff --git a/crates/workshop-server/src/session_agents/lifecycle.rs b/crates/workshop-sessions/src/agents/lifecycle.rs similarity index 100% rename from crates/workshop-server/src/session_agents/lifecycle.rs rename to crates/workshop-sessions/src/agents/lifecycle.rs diff --git a/crates/workshop-sessions/src/agents/session.rs b/crates/workshop-sessions/src/agents/session.rs new file mode 100644 index 000000000..304180f62 --- /dev/null +++ b/crates/workshop-sessions/src/agents/session.rs @@ -0,0 +1,467 @@ +//! One running agent session: the state that outlives any socket, the +//! per-session observer `run_agent` reports through, and the launch-time +//! providers for deltas, the `ui()` snapshot, and the model catalog. + +use std::fmt; +use std::num::NonZeroU32; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; + +use promptforge_core_support::cancel::CancelHandle; +use promptforge_core_support::events::{CallMetrics, RuntimeEventKind, ToolCallEvent}; +use promptforge_core_support::observe::{Observation, Observer}; +use promptforge_model_client::client::StreamDelta; +use promptforge_model_client::model::{ModelCatalog, ModelDescriptor, ModelId, ThinkingMode}; +use tokio::sync::broadcast; + +use workshop_gateway::WorkshopObserver; +use workshop_menu::{MenuBus, is_chat_capable}; +use workshop_protocol::{Activity, AgentDeltaKind, InputFrame, InputResponse}; +use workshop_registry::{Push, Registry}; + +use super::lifecycle::RunLifecycle; +use super::supervisor::transition::RunId; +use crate::input::{WaitError, WaitRegistry}; + +/// One agent's program source and the runtime that executes it. +/// +/// Directory agents are standalone Lua programs on the agent runtime; the +/// embedded built-in chat is a Markdown prompt on the unified runtime. +/// External Markdown-agent discovery stays deferred, so no directory file +/// ever lands in the Markdown arm. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum AgentSource { + /// A standalone Lua agent program (the agent runtime). + Lua(String), + /// A Markdown prompt document (the unified runtime). + Markdown(String), +} + +/// One live delta on a session's dedicated channel, stamped with the +/// reply id of the durable event that will supersede it. +#[derive(Debug, Clone)] +pub(crate) struct AgentDelta { + /// The superseding reply id ([`SessionObserver`]'s round count when + /// the chunk streamed). + pub(crate) reply: u64, + /// Which side channel the chunk belongs to. + pub(crate) channel: AgentDeltaKind, + /// The chunk's text. + pub(crate) content: String, +} + +/// One running agent session: the state that outlives any socket. +pub(crate) struct AgentSession { + /// The session's unguessable id, also its event JSONL's file stem. + pub(crate) id: String, + /// The agent's name (its `.lua` file stem), every observer call's + /// `section` label. + pub(crate) agent: String, + /// The program source and its runtime, retained so turn-cancel can + /// relaunch it. + pub(super) source: AgentSource, + /// The persisting event log: `Observer` write side, `EventLog` read + /// side, broadcast fan-out for socket wakeups. + pub(crate) log: Arc, + /// Cancellation provenance and the accepted-turn exclusion boundary. + pub(super) lifecycle: Arc, + /// Settled model rounds - the reply id deltas are stamped with. + pub(super) rounds: Arc, + /// The session's unresolved user-input waits. + pub(crate) waits: Arc, + /// Where the `user_input` tool announces waits; sockets subscribe. + pub(crate) input_frames: broadcast::Sender, + /// The dedicated live-delta channel; deltas never enter the event + /// log. + pub(super) deltas: broadcast::Sender, + /// The session's error reports, forwarded to the SPA as `error` + /// frames: a failed model round the program survived, or a run that + /// ended in error. Ephemeral like the deltas - errors never enter + /// the event log. + pub(super) errors: broadcast::Sender, +} + +impl fmt::Debug for AgentSession { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("AgentSession") + .field("id", &self.id) + .field("agent", &self.agent) + .finish_non_exhaustive() + } +} + +impl AgentSession { + /// Bundles one session's state at launch. + #[allow(clippy::too_many_arguments)] + pub(super) fn new( + id: String, + agent: &str, + source: AgentSource, + log: Arc, + lifecycle: Arc, + waits: Arc, + input_frames: broadcast::Sender, + deltas: broadcast::Sender, + errors: broadcast::Sender, + ) -> Self { + Self { + id, + agent: agent.to_owned(), + source, + log, + lifecycle, + rounds: Arc::new(AtomicU64::new(0)), + waits, + input_frames, + deltas, + errors, + } + } + + /// Subscribes to the session's live deltas from this call on. + pub(crate) fn subscribe_deltas(&self) -> broadcast::Receiver { + self.deltas.subscribe() + } + + /// Subscribes to the session's error reports from this call on. + pub(crate) fn subscribe_errors(&self) -> broadcast::Receiver { + self.errors.subscribe() + } + + /// Durably accepts one input and resumes its wait after publishing + /// acceptance ahead of the observation-to-completion boundary. + /// + /// Recording is runtime-specific: a Lua agent's input is recorded + /// producer-side here (its relaunched program rebuilds history from + /// the event log), while a Markdown agent's unified runtime records + /// consumer-side when the suspended `user_input` resumes - recording + /// here too would double the event. + pub(crate) fn accept_input( + &self, + response: InputResponse, + after_acceptance: impl FnOnce(), + ) -> Result<(), WaitError> { + let accepted_run = self.lifecycle.accept_input(); + let result = match &self.source { + AgentSource::Lua(_) => crate::input::deliver_input_response_before_completion( + self.log.as_ref(), + &self.waits, + &self.id, + &self.agent, + response, + after_acceptance, + ), + AgentSource::Markdown(_) => { + crate::input::complete_input_response(&self.waits, response, after_acceptance) + } + }; + if let (Err(_), Some(run)) = (&result, accepted_run) { + self.lifecycle.settle_turn(run); + } + result + } + + /// Fires the current run's retained cancel handle: the turn dies as + /// a stop reason (pending waits emit `input_cancelled`, no error + /// frame), and the supervisor relaunches the program over the + /// retained event log with a fresh handle. + pub(crate) fn cancel_turn(&self) { + self.lifecycle.operator_cancel(); + } + + /// Ends the session: the run is cancelled and the supervisor stops + /// relaunching. + pub(super) fn close(&self) { + self.lifecycle.close(); + } + + /// Installs and retains the next run's fresh cancel handle. + pub(super) fn arm_cancel(&self, run: RunId) -> CancelHandle { + self.lifecycle.arm(run) + } + + /// Cancels the run selected by a reducer effect. + pub(super) fn cancel_current_run(&self) { + self.lifecycle.cancel_current(); + } + + /// Clears the lifecycle identity after a run ends. + pub(super) fn finish_run(&self, run: RunId) { + self.lifecycle.finish(run); + } +} + +/// The per-session [`Observer`] wrapper `run_agent` reports through: it +/// forwards every report to the persisting log and owns the side effects +/// the session wires to content events - the reply-id round count +/// (advanced as a reply or tool-call batch lands, before the program +/// resumes, so no later delta can carry a settled id), the backoff reset, +/// and the idle status push on completed replies. +pub(crate) struct SessionObserver { + /// The persisting log every report forwards to. + pub(super) log: Arc, + /// Settled model rounds, shared with the delta stamp. + pub(super) rounds: Arc, + /// Where idle lands when a reply completes. + pub(super) push: Push, + /// Reset on completed replies: the gateway proved it answers. + pub(super) backoff: workshop_support::ReconnectBackoff, + /// Where a failed model round surfaces as a wire error frame. + pub(super) errors: broadcast::Sender, + /// Marks an accepted turn settled before catalog retirement proceeds. + pub(super) lifecycle: Arc, +} + +impl Observer for SessionObserver { + fn observe(&self, execution: &str, section: &str, event: Observation) { + // A failed model round is operator-visible: the program survives + // it (the built-in chat pcalls models.chat and returns to + // waiting), so the run never fails and only the session can tell + // the SPA. The observation carries no payload; the frame names + // the boundary that failed. + if matches!(event, Observation::ModelTurnFailed) { + self.lifecycle.settle_current_turn(); + let message = format!("{event} in agent `{section}`"); + let _ = self.errors.send(message.clone()); + // The failed round never reaches on_assistant_reply, so this + // terminal status is the only frame that releases the + // turn-dispatch Thinking push; without it the status bar's + // sustained amber LED never returns to idle. + self.push + .push_failure("Model turn failed", message, Activity::General); + } + self.log.observe(execution, section, event); + } + + fn on_assistant_reply( + &self, + execution: &str, + section: &str, + chain_id: u32, + depth: u32, + turn: u32, + text: &str, + finish_reason: Option<&str>, + model: &str, + metrics: Option<&CallMetrics>, + ) { + self.log.on_assistant_reply( + execution, + section, + chain_id, + depth, + turn, + text, + finish_reason, + model, + metrics, + ); + self.lifecycle.settle_current_turn(); + self.rounds.fetch_add(1, Ordering::SeqCst); + self.backoff.record_useful_work(); + self.push.push_idle(); + } + + fn on_assistant_tool_calls( + &self, + execution: &str, + section: &str, + chain_id: u32, + depth: u32, + turn: u32, + model: &str, + calls: &[ToolCallEvent], + ) { + self.log + .on_assistant_tool_calls(execution, section, chain_id, depth, turn, model, calls); + // A tool-call batch settles its round's deltas without ending the + // turn: the count advances, the status stays busy. + self.rounds.fetch_add(1, Ordering::SeqCst); + } + + fn on_tool_result( + &self, + execution: &str, + section: &str, + chain_id: u32, + depth: u32, + turn: u32, + tool_call_id: &str, + alias: &str, + content: &str, + trusted: bool, + ) { + self.log.on_tool_result( + execution, + section, + chain_id, + depth, + turn, + tool_call_id, + alias, + content, + trusted, + ); + } + + fn on_thinking( + &self, + execution: &str, + section: &str, + chain_id: u32, + depth: u32, + turn: u32, + model: &str, + text: &str, + ) { + self.log + .on_thinking(execution, section, chain_id, depth, turn, model, text); + } + + fn on_user_input(&self, execution: &str, section: &str, text: &str) { + self.log.on_user_input(execution, section, text); + } +} + +/// Builds the delta stamp: the `on_delta` closure feeding the session's +/// dedicated broadcast, each chunk stamped with the current round count - +/// the id of the durable event that will supersede it - plus the +/// activity pulse that lights the status LED (Generating for answer +/// content, Thinking for the reasoning side channel). +pub(crate) fn delta_stamp( + session: &Arc, + push: &Push, +) -> Arc { + let deltas = session.deltas.clone(); + let rounds = Arc::clone(&session.rounds); + let push = push.clone(); + Arc::new(move |delta| { + let (channel, content, activity) = match delta { + StreamDelta::Text(text) => (AgentDeltaKind::Text, text, Activity::Generating), + StreamDelta::Reasoning(text) => (AgentDeltaKind::Reasoning, text, Activity::Thinking), + // The enum is non-exhaustive across the crate seam; a future + // side channel has no frame kind yet and stays live-only. + _ => return, + }; + push.push_activity("Streaming response...", "an agent response chunk", activity); + // No receiver means no socket is attached; deltas are ephemeral + // and the completed-reply event is the repair, so the drop is + // the design. + let _ = deltas.send(AgentDelta { + reply: rounds.load(Ordering::SeqCst), + channel, + content, + }); + }) +} + +/// Builds the `ui()` snapshot provider: `selected_model` from the menu's +/// retained workbench state and `workspace_root` as the first granted +/// workspace root, each `null` when absent. The roots come through the +/// registry's workspace slot, so this crate never names the workspace +/// crate the tier graph forbids; an unregistered slot serves `null`. +pub(crate) fn ui_provider( + menu: &MenuBus, + registry: &Registry, +) -> Arc serde_json::Value + Send + Sync> { + let menu = menu.clone(); + let registry = registry.clone(); + Arc::new(move || { + let selected = menu.latest().and_then(|snapshot| snapshot.selected_model); + let root = registry + .workspace_roots() + .get() + .and_then(|roots| roots.granted_roots().first().cloned()) + .map(|root| root.display().to_string()); + serde_json::json!({ "selected_model": selected, "workspace_root": root }) + }) +} + +/// The reply-id derivation the socket applies while draining the event +/// log: the model-round content kinds carry the current round count as +/// their stamp, and a reply or tool-call batch advances it - the same +/// rule [`SessionObserver`] applies live, so delta stamps and event +/// stamps agree. +pub(crate) fn reply_stamp(kind: RuntimeEventKind, rounds_seen: &mut u64) -> Option { + match kind { + RuntimeEventKind::Thinking => Some(*rounds_seen), + RuntimeEventKind::AssistantReply | RuntimeEventKind::AssistantToolCalls => { + let round = *rounds_seen; + *rounds_seen += 1; + Some(round) + } + _ => None, + } +} + +/// Context window recorded for a catalog entry that does not carry one. +/// The window is catalog metadata (nothing on the completion wire reads +/// it), so a generous default keeps the model usable rather than +/// refusing it. +pub(super) const FALLBACK_CONTEXT: u32 = 8192; + +/// Builds the session's model catalog from the retained gateway catalog: +/// one descriptor per chat entry (an absent `kind` is a plain OpenAI +/// catalog and counts as chat), carrying the entry's description, +/// context window, and thinking mode where present. Entries that cannot +/// make a descriptor are skipped with a warning - a launch must not fail +/// because one catalog row is malformed. +pub(crate) fn build_model_catalog(models: Option>) -> ModelCatalog { + let Some(models) = models else { + return ModelCatalog::empty(); + }; + let mut descriptors: Vec = Vec::new(); + for entry in &models { + if !is_chat_capable(entry) { + continue; + } + let Some(id) = entry.get("id").and_then(serde_json::Value::as_str) else { + tracing::warn!("catalog entry without an id skipped for the agent model catalog"); + continue; + }; + let model_id = match ModelId::gateway(id) { + Ok(model_id) => model_id, + Err(error) => { + tracing::warn!(%error, id, "catalog entry skipped for the agent model catalog"); + continue; + } + }; + if descriptors + .iter() + .any(|descriptor| descriptor.id() == &model_id) + { + tracing::warn!( + id, + "duplicate catalog id skipped for the agent model catalog" + ); + continue; + } + let description = entry + .get("description") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + let context = entry + .get("context") + .and_then(serde_json::Value::as_u64) + .and_then(|context| u32::try_from(context).ok()) + .and_then(NonZeroU32::new) + .unwrap_or_else(|| NonZeroU32::new(FALLBACK_CONTEXT).unwrap_or(NonZeroU32::MIN)); + let thinking = entry + .get("thinking") + .and_then(|value| serde_json::from_value::(value.clone()).ok()) + .unwrap_or(ThinkingMode::Never); + descriptors.push(ModelDescriptor::new( + model_id, + description, + context, + thinking, + )); + } + // Duplicates were filtered above, so construction cannot refuse; an + // empty catalog is the honest degenerate outcome. + ModelCatalog::new(descriptors).unwrap_or_else(|error| { + tracing::warn!(%error, "agent model catalog degraded to empty"); + ModelCatalog::empty() + }) +} diff --git a/crates/workshop-server/src/session_agents/socket.rs b/crates/workshop-sessions/src/agents/socket.rs similarity index 95% rename from crates/workshop-server/src/session_agents/socket.rs rename to crates/workshop-sessions/src/agents/socket.rs index 5f9bd4bca..d706086f4 100644 --- a/crates/workshop-server/src/session_agents/socket.rs +++ b/crates/workshop-sessions/src/agents/socket.rs @@ -25,44 +25,34 @@ use std::sync::Arc; -use axum::Router; use axum::extract::State; use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; use axum::http::HeaderMap; -use axum::response::{IntoResponse, Response}; -use axum::routing::get; +use axum::response::Response; use promptforge_core_support::events::{EventLog as _, RuntimeEvent}; use tokio::sync::broadcast; -use crate::app::AppState; -use crate::cross_site; -use crate::error::AppError; -use crate::input::WaitError; -use crate::session::{send_error, send_frame}; use workshop_protocol::{ Activity, AgentDeltaFrame, AgentEventFrame, AgentSessionFrame, AgentsFrame, ErrorFrame, InputFrame, InputResponse, }; -use super::{AgentDelta, AgentSession, reply_stamp}; +use crate::input::WaitError; +use crate::session::{cross_site_refusal, send_error, send_frame}; +use crate::state::SessionsState; -/// The agent-session socket route. -pub(crate) fn routes(state: AppState) -> Router { - Router::new() - .route("/agents/ws", get(upgrade)) - .with_state(state) -} +use super::{AgentDelta, AgentSession, reply_stamp}; /// Upgrades a `GET /agents/ws` request to an agent-session socket. A -/// foreign `Origin` is refused with 403, exactly as the chat socket's -/// upgrade is. -async fn upgrade( - State(state): State, +/// foreign `Origin` is refused with 403, exactly as the workbench +/// socket's upgrade is. +pub(crate) async fn upgrade( + State(state): State, headers: HeaderMap, ws: WebSocketUpgrade, ) -> Response { - if !cross_site::origin_allowed(&headers) { - return AppError::CrossSite.into_response(); + if !state.origin_allowed(&headers) { + return cross_site_refusal(); } ws.on_upgrade(move |socket| run_socket(socket, state)) } @@ -92,7 +82,7 @@ async fn recv_or_pending( } /// Runs one agent-session socket until it closes or fails. -async fn run_socket(mut socket: WebSocket, state: AppState) { +async fn run_socket(mut socket: WebSocket, state: SessionsState) { // The list is discovered per connect: the frame is a complete // snapshot, so a directory edited between connects is picked up by // the next window with no push machinery. @@ -226,7 +216,7 @@ type Subscriptions<'a> = ( /// Handles one inbound text frame. A `false` return means the client is /// gone and the socket loop should end. async fn handle_frame( - state: &AppState, + state: &SessionsState, text: &str, attached: &mut Option, subscriptions: Subscriptions<'_>, @@ -305,7 +295,7 @@ async fn handle_frame( /// windows are modal - so a second open on an attached socket is /// refused. A `false` return means the client is gone. async fn handle_open( - state: &AppState, + state: &SessionsState, kind: &str, frame: &serde_json::Value, attached: &mut Option, diff --git a/crates/workshop-server/src/session_agents/supervisor.rs b/crates/workshop-sessions/src/agents/supervisor.rs similarity index 97% rename from crates/workshop-server/src/session_agents/supervisor.rs rename to crates/workshop-sessions/src/agents/supervisor.rs index 1ed0a6b73..2a2484cd1 100644 --- a/crates/workshop-server/src/session_agents/supervisor.rs +++ b/crates/workshop-sessions/src/agents/supervisor.rs @@ -5,7 +5,8 @@ use std::sync::Arc; use promptforge_tools::{Tool, ToolCatalog}; use tokio::sync::mpsc; -use crate::gateway_binding::GatewayBinding; +use workshop_gateway::GatewayBinding; + use crate::input::UserInputTool; use super::{AgentSession, AgentSessions, SessionHost}; @@ -42,7 +43,7 @@ pub(super) fn spawn( } }; let (mut collector, initial_catalog, initial_gateway) = - EventCollector::new(lifecycle, cancellations, host.catalog.clone(), gateway); + EventCollector::new(lifecycle, cancellations, host.catalog().clone(), gateway); let mut executor = EffectExecutor::new( Arc::clone(&session), host, diff --git a/crates/workshop-server/src/session_agents/supervisor/catalog.rs b/crates/workshop-sessions/src/agents/supervisor/catalog.rs similarity index 97% rename from crates/workshop-server/src/session_agents/supervisor/catalog.rs rename to crates/workshop-sessions/src/agents/supervisor/catalog.rs index 0c5c1ba96..3ef0ec3ab 100644 --- a/crates/workshop-server/src/session_agents/supervisor/catalog.rs +++ b/crates/workshop-sessions/src/agents/supervisor/catalog.rs @@ -1,6 +1,6 @@ //! Typed catalog-event collection for one agent supervisor. -use crate::catalog::{CatalogBus, ChatCatalog}; +use workshop_menu::{CatalogBus, ChatCatalog}; use super::transition::{CatalogDisposition, SupervisorEvent}; diff --git a/crates/workshop-server/src/session_agents/supervisor/effects.rs b/crates/workshop-sessions/src/agents/supervisor/effects.rs similarity index 97% rename from crates/workshop-server/src/session_agents/supervisor/effects.rs rename to crates/workshop-sessions/src/agents/supervisor/effects.rs index 5aa7f8ad0..04736b2a5 100644 --- a/crates/workshop-server/src/session_agents/supervisor/effects.rs +++ b/crates/workshop-sessions/src/agents/supervisor/effects.rs @@ -12,20 +12,21 @@ use promptforge_tool_picker::{Config, ToolPicker}; use promptforge_tools::ToolCatalog; use shared_vfs::VfsRef; -use crate::catalog::ChatCatalog; -use crate::gateway_binding::GatewaySnapshot; -use crate::input::SessionInputBroker; +use workshop_gateway::GatewaySnapshot; +use workshop_menu::ChatCatalog; use workshop_protocol::Activity; +use crate::agents::{ + AgentSession, AgentSource, SessionHost, SessionObserver, build_model_catalog, delta_stamp, + ui_provider, +}; +use crate::input::SessionInputBroker; + use super::events::{CollectedEvent, EventCollector, RunFuture}; use super::transition::{ CancelOrigin, CatalogDisposition, CloseReason, HistoryEffect, RelaunchEffect, RunCompletion, RunId, SupervisorEffect, SupervisorEvent, }; -use crate::session_agents::{ - AgentSession, AgentSource, SessionHost, SessionObserver, build_model_catalog, delta_stamp, - ui_provider, -}; /// The result of executing one reducer-selected effect. pub(super) enum EffectOutcome { @@ -54,8 +55,8 @@ impl RunFactory { let observer: Arc = Arc::new(SessionObserver { log: Arc::clone(&session.log), rounds: Arc::clone(&session.rounds), - push: host.push.clone(), - backoff: host.backoff.clone(), + push: host.push(), + backoff: host.backoff().clone(), errors: session.errors.clone(), lifecycle: Arc::clone(&session.lifecycle), }); @@ -64,8 +65,8 @@ impl RunFactory { AgentSource::Lua(_) => None, }; Self { - on_delta: delta_stamp(&session, &host.push), - ui: ui_provider(&host.menu, &host.workspace), + on_delta: delta_stamp(&session, &host.push()), + ui: ui_provider(host.menu(), host.registry()), session, tools, vfs: promptforge_vfs::empty(), @@ -344,7 +345,7 @@ fn run_completion_event( "agent run failed" ); let _ = session.errors.send(error.to_string()); - host.push + host.push() .push_failure("Agent failed", error.to_string(), Activity::General); RunCompletion::Failed } @@ -370,7 +371,7 @@ fn report_cancel_origin(session: &AgentSession, origin: CancelOrigin) { /// Reports a failure shared by relaunch validation paths. fn report_failure(session: &AgentSession, host: &SessionHost, message: &str) { let _ = session.errors.send(message.to_owned()); - host.push + host.push() .push_failure("Agent failed", message, Activity::General); } diff --git a/crates/workshop-server/src/session_agents/supervisor/events.rs b/crates/workshop-sessions/src/agents/supervisor/events.rs similarity index 98% rename from crates/workshop-server/src/session_agents/supervisor/events.rs rename to crates/workshop-sessions/src/agents/supervisor/events.rs index 8aa6df026..105656239 100644 --- a/crates/workshop-server/src/session_agents/supervisor/events.rs +++ b/crates/workshop-sessions/src/agents/supervisor/events.rs @@ -7,8 +7,8 @@ use std::sync::Arc; use promptforge_agent::AgentError; use tokio::sync::{mpsc, watch}; -use crate::catalog::CatalogBus; -use crate::gateway_binding::{GatewayBinding, GatewaySnapshot}; +use workshop_gateway::{GatewayBinding, GatewaySnapshot}; +use workshop_menu::CatalogBus; use super::catalog::{CatalogEvent, current_catalog_event, next_catalog_event}; use super::transition::{RunId, SupervisorEvent}; diff --git a/crates/workshop-server/src/session_agents/supervisor/transition.rs b/crates/workshop-sessions/src/agents/supervisor/transition.rs similarity index 91% rename from crates/workshop-server/src/session_agents/supervisor/transition.rs rename to crates/workshop-sessions/src/agents/supervisor/transition.rs index 026978124..922fb36bc 100644 --- a/crates/workshop-server/src/session_agents/supervisor/transition.rs +++ b/crates/workshop-sessions/src/agents/supervisor/transition.rs @@ -2,7 +2,7 @@ /// Why the current run's cancellation handle fires. #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(in crate::session_agents) enum CancelOrigin { +pub(in crate::agents) enum CancelOrigin { /// The operator explicitly cancelled the current turn. Operator, /// A usable catalog generation replaced the run's frozen bindings. @@ -13,7 +13,7 @@ pub(in crate::session_agents) enum CancelOrigin { /// One run's terminal result. #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(in crate::session_agents) enum RunCompletion { +pub(in crate::agents) enum RunCompletion { /// Cancellation stopped the run without ending the session. Interrupted, /// The program returned normally. @@ -24,7 +24,7 @@ pub(in crate::session_agents) enum RunCompletion { /// How a published catalog generation relates to the frozen run catalog. #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(in crate::session_agents) enum CatalogDisposition { +pub(in crate::agents) enum CatalogDisposition { /// No chat-capable catalog is currently available. Unavailable, /// The generation is usable without changing frozen model bindings. @@ -35,11 +35,11 @@ pub(in crate::session_agents) enum CatalogDisposition { /// Identity assigned to one launched run. #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(in crate::session_agents) struct RunId(u64); +pub(in crate::agents) struct RunId(u64); /// An input to the pure supervisor transition model. #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(in crate::session_agents) enum SupervisorEvent { +pub(in crate::agents) enum SupervisorEvent { /// A run produced its terminal result. RunCompleted { /// The run that completed. @@ -68,7 +68,7 @@ pub(in crate::session_agents) enum SupervisorEvent { /// The condition the supervisor must await. #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(in crate::session_agents) enum WaitFor { +pub(in crate::agents) enum WaitFor { /// A usable chat catalog. Catalog, /// The accepted turn's durable terminal event. @@ -77,7 +77,7 @@ pub(in crate::session_agents) enum WaitFor { /// Why the current ownership remains unchanged. #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(in crate::session_agents) enum PreserveReason { +pub(in crate::agents) enum PreserveReason { /// The current run remains authoritative. CurrentRun, /// Cancellation already owns run retirement. @@ -90,27 +90,27 @@ pub(in crate::session_agents) enum PreserveReason { /// Event-log handling for a launched replacement run. #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(in crate::session_agents) enum HistoryEffect { +pub(in crate::agents) enum HistoryEffect { /// Reuse the session's retained event log. Preserve, } /// The complete immutable inputs for one replacement run. #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(in crate::session_agents) struct RelaunchEffect { +pub(in crate::agents) struct RelaunchEffect { /// Identity assigned to the replacement run. - pub(in crate::session_agents) run: RunId, + pub(in crate::agents) run: RunId, /// Catalog generation frozen by the replacement. - pub(in crate::session_agents) catalog_generation: u64, + pub(in crate::agents) catalog_generation: u64, /// Gateway generation frozen by the replacement. - pub(in crate::session_agents) gateway_generation: u64, + pub(in crate::agents) gateway_generation: u64, /// Event-log treatment across replacement. - pub(in crate::session_agents) history: HistoryEffect, + pub(in crate::agents) history: HistoryEffect, } /// Why supervision ends. #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(in crate::session_agents) enum CloseReason { +pub(in crate::agents) enum CloseReason { /// The owning session requested close. Requested, /// The agent program returned normally. @@ -121,7 +121,7 @@ pub(in crate::session_agents) enum CloseReason { /// One typed action selected by the transition model. #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(in crate::session_agents) enum SupervisorEffect { +pub(in crate::agents) enum SupervisorEffect { /// Await a named condition. Wait(WaitFor), /// Cancel the current run with provenance. @@ -145,7 +145,7 @@ enum Phase { /// Pure state owned by one agent-session supervisor. #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(in crate::session_agents) struct SupervisorState { +pub(in crate::agents) struct SupervisorState { phase: Phase, active_run: Option, next_run: u64, @@ -158,7 +158,7 @@ pub(in crate::session_agents) struct SupervisorState { impl SupervisorState { /// Starts supervision before a usable chat catalog exists. - pub(in crate::session_agents) fn new(gateway_generation: u64) -> Self { + pub(in crate::agents) fn new(gateway_generation: u64) -> Self { Self { phase: Phase::WaitingForCatalog, active_run: None, @@ -174,13 +174,13 @@ impl SupervisorState { /// The next immutable state and its one typed effect. #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(in crate::session_agents) struct SupervisorTransition { - pub(in crate::session_agents) state: SupervisorState, - pub(in crate::session_agents) effect: SupervisorEffect, +pub(in crate::agents) struct SupervisorTransition { + pub(in crate::agents) state: SupervisorState, + pub(in crate::agents) effect: SupervisorEffect, } /// Reduces one explicit event without performing asynchronous work. -pub(in crate::session_agents) fn transition( +pub(in crate::agents) fn transition( state: SupervisorState, event: SupervisorEvent, ) -> SupervisorTransition { diff --git a/crates/workshop-server/src/session_agents/supervisor/transition/tests.rs b/crates/workshop-sessions/src/agents/supervisor/transition/tests.rs similarity index 100% rename from crates/workshop-server/src/session_agents/supervisor/transition/tests.rs rename to crates/workshop-sessions/src/agents/supervisor/transition/tests.rs diff --git a/crates/workshop-sessions/src/agents/tests.rs b/crates/workshop-sessions/src/agents/tests.rs new file mode 100644 index 000000000..a18600f16 --- /dev/null +++ b/crates/workshop-sessions/src/agents/tests.rs @@ -0,0 +1,337 @@ +use std::sync::atomic::AtomicU64; + +use promptforge_core_support::events::RuntimeEventKind; +use promptforge_core_support::observe::{Observation, Observer}; +use promptforge_model_client::model::{ModelCatalog, ThinkingMode}; +use workshop_protocol::Activity; + +use super::*; + +/// A push facade wired to the real buses through the registry, with +/// the registrations kept alive by the returned guards. +fn wired_push( + status: &workshop_status::StatusBus, + catalog: &CatalogBus, + menu: &MenuBus, +) -> (Push, impl std::fmt::Debug + Send + Sync + 'static) { + let registry = Registry::new(); + let status_guards = workshop_status::register(®istry, status); + let menu_guards = workshop_menu::register(®istry, catalog, menu); + (registry.push(), (status_guards, menu_guards)) +} + +#[test] +fn discovery_lists_sorted_lua_stems_and_tolerates_a_missing_dir() { + let dir = tempfile::TempDir::new().expect("tempdir"); + std::fs::write(dir.path().join("zeta.lua"), "return 1").expect("seed zeta"); + std::fs::write(dir.path().join("alpha.lua"), "return 1").expect("seed alpha"); + std::fs::write(dir.path().join("notes.txt"), "not an agent").expect("seed noise"); + std::fs::create_dir(dir.path().join("nested.lua")).expect("seed a decoy directory"); + assert_eq!( + discover_agents(dir.path()), + vec!["alpha".to_owned(), "chat".to_owned(), "zeta".to_owned()], + "discovery lists .lua file stems plus the built-in chat, sorted, \ + and skips everything else" + ); + assert_eq!( + discover_agents(&dir.path().join("missing")), + vec!["chat".to_owned()], + "a missing agents directory still offers the built-in chat rather than failing" + ); +} + +#[test] +fn the_built_in_chat_is_always_offered_and_a_dir_file_shadows_its_source() { + let dir = tempfile::TempDir::new().expect("tempdir"); + assert_eq!( + discover_agents(dir.path()), + vec!["chat".to_owned()], + "an empty agents directory still offers the built-in chat" + ); + assert_eq!( + agent_source(dir.path(), "chat").expect("the built-in serves"), + AgentSource::Markdown(BUILTIN_CHAT_SOURCE.to_owned()), + "with no directory file, the embedded source is what launches" + ); + + std::fs::write(dir.path().join("chat.lua"), "-- shadowed").expect("seed the shadow"); + assert_eq!( + discover_agents(dir.path()), + vec!["chat".to_owned()], + "a directory chat.lua lists once, never beside the built-in" + ); + assert_eq!( + agent_source(dir.path(), "chat").expect("the shadow reads"), + AgentSource::Lua("-- shadowed".to_owned()), + "a directory chat.lua shadows the embedded source" + ); + + assert_eq!( + agent_source(dir.path(), "ghost") + .expect_err("only the built-in name falls back to embedded source") + .kind(), + io::ErrorKind::NotFound, + "a non-built-in name surfaces its filesystem error" + ); +} + +#[test] +fn an_unreadable_chat_lua_surfaces_its_error_rather_than_the_built_in() { + let dir = tempfile::TempDir::new().expect("tempdir"); + // A directory named chat.lua cannot be read as a file on any + // platform, and its failure is never NotFound - the one kind + // that falls back to the embedded source. + std::fs::create_dir(dir.path().join("chat.lua")).expect("seed the unreadable shadow"); + agent_source(dir.path(), "chat").expect_err( + "an existing chat.lua that cannot be read surfaces its error; \ + silently serving the built-in would mask the operator's own file", + ); +} + +#[test] +fn the_model_catalog_keeps_chat_entries_and_skips_the_rest() { + let catalog = build_model_catalog(Some(vec![ + serde_json::json!({ + "id": "chat-model", "kind": "chat", "description": "a chat model", + "context": 4096, "thinking": "switchable", + }), + serde_json::json!({ "id": "plain-openai-model" }), + serde_json::json!({ "id": "embed-model", "kind": "embedding" }), + serde_json::json!({ "object": "model" }), + serde_json::json!({ "id": "chat-model" }), + ])); + let names: Vec<&str> = catalog + .models() + .iter() + .map(|descriptor| descriptor.id().name()) + .collect(); + assert_eq!( + names, + vec!["chat-model", "plain-openai-model"], + "chat and kind-less entries stay; embeddings, id-less rows, and duplicates drop" + ); + let chat = &catalog.models()[0]; + assert_eq!(chat.context().get(), 4096); + assert_eq!(chat.thinking(), ThinkingMode::Switchable); + let bare = &catalog.models()[1]; + assert_eq!( + bare.context().get(), + super::session::FALLBACK_CONTEXT, + "an entry without a context window records the fallback" + ); + assert!( + build_model_catalog(None).is_empty(), + "no retained catalog means an empty agent catalog" + ); +} + +#[test] +fn reply_stamps_follow_the_settle_rule() { + let mut rounds = 0; + assert_eq!( + reply_stamp(RuntimeEventKind::UserInput, &mut rounds), + None, + "input events settle nothing" + ); + assert_eq!( + reply_stamp(RuntimeEventKind::Thinking, &mut rounds), + Some(0), + "thinking carries the open round without settling it" + ); + assert_eq!( + reply_stamp(RuntimeEventKind::AssistantReply, &mut rounds), + Some(0) + ); + assert_eq!( + reply_stamp(RuntimeEventKind::AssistantToolCalls, &mut rounds), + Some(1), + "a tool-call batch settles its round exactly as a reply does" + ); + assert_eq!(reply_stamp(RuntimeEventKind::ToolResult, &mut rounds), None); + assert_eq!( + reply_stamp(RuntimeEventKind::Thinking, &mut rounds), + Some(2), + "the next round opens where the last one settled" + ); +} + +#[test] +fn the_ui_snapshot_serves_the_selection_and_first_granted_root() { + let catalog = CatalogBus::default(); + let menu = MenuBus::new(catalog.clone(), None); + let registry = Registry::new(); + let ui = ui_provider(&menu, ®istry); + assert_eq!( + ui(), + serde_json::json!({ "selected_model": null, "workspace_root": null }), + "absent producers serve null, never a missing key" + ); + + catalog.publish(vec![serde_json::json!({ "id": "test-model" })]); + menu.set_selected("test-model") + .expect("the id is in the catalog"); + let dir = tempfile::TempDir::new().expect("tempdir"); + let granted = dir.path().to_path_buf(); + let _roots = registry.workspace_roots().register(Arc::new( + workshop_registry::WorkspaceRootsAdapter::new({ + let granted = granted.clone(); + move || vec![granted.clone()] + }), + )); + let snapshot = ui(); + assert_eq!(snapshot["selected_model"], "test-model"); + assert_eq!( + snapshot["workspace_root"], + serde_json::json!(granted.display().to_string()), + "workspace_root is the first granted root, read through the registry slot" + ); +} + +#[test] +fn a_launch_without_a_usable_client_is_refused() { + let dir = tempfile::TempDir::new().expect("tempdir"); + std::fs::write(dir.path().join("echo.lua"), "return 1").expect("seed echo"); + let catalog = CatalogBus::default(); + let menu = MenuBus::new(catalog.clone(), None); + let registry = Registry::new(); + let sessions = AgentSessions::new( + dir.path().to_path_buf(), + dir.path().join("sessions"), + GatewayBinding::new("http://127.0.0.1:1", "") + .expect("the unusable model binding still builds its HTTP client"), + SessionHost::new(registry, ReconnectBackoff::new(), menu, catalog), + ); + // A plain #[test] doubles as ordering proof: the refusal returns + // before anything is spawned, or this panics outside a runtime. + let refusal = sessions + .launch("echo") + .expect_err("a discovered agent must still refuse without a model client"); + assert!( + matches!(refusal, LaunchRefusal::GatewayUnusable), + "the refusal names the gateway configuration, not the agent: {refusal}" + ); + assert!( + sessions.lock().is_empty(), + "a refused launch registers no session" + ); +} + +#[tokio::test] +async fn a_failed_model_turn_pushes_a_terminal_failure_status() { + let status = workshop_status::StatusBus::new(); + let mut status_rx = status.subscribe(); + let catalog = CatalogBus::new(); + let menu = MenuBus::new(catalog.clone(), None); + let (push, _guards) = wired_push(&status, &catalog, &menu); + let (errors, mut errors_rx) = broadcast::channel(ERROR_CAPACITY); + let (supervisor_events, _events) = mpsc::unbounded_channel(); + let (cancellations, _cancellation_events) = mpsc::channel(lifecycle::CANCELLATION_CAPACITY); + let observer = SessionObserver { + log: Arc::new(WorkshopObserver::new(None).expect("a memory log")), + rounds: Arc::new(AtomicU64::new(0)), + push, + backoff: ReconnectBackoff::new(), + errors, + lifecycle: Arc::new(RunLifecycle::new(supervisor_events, cancellations)), + }; + + observer.observe("run", "chat", Observation::ModelTurnFailed); + + let update = status_rx + .recv() + .await + .expect("the failed round pushes a terminal status"); + assert_eq!(update.severity, workshop_protocol::Severity::Error); + assert_eq!( + update.activity, + Activity::General, + "a non-thinking activity releases the status bar's sustained amber LED" + ); + assert_eq!( + errors_rx.recv().await.expect("the error frame is sent"), + "Model turn failed in agent `chat`" + ); +} + +#[test] +fn the_model_client_requires_a_usable_key_and_url() { + assert!( + workshop_gateway::gateway_binding::model_client("http://127.0.0.1:8081", "k").is_some(), + "a keyed gateway builds the agent model client" + ); + assert!( + workshop_gateway::gateway_binding::model_client("http://127.0.0.1:8081", "").is_none(), + "an empty key cannot authenticate: agents report it at launch" + ); + assert!(workshop_gateway::gateway_binding::model_client("not a url", "k").is_none()); +} + +/// Runs the embedded chat prompt on the unified runtime with the given +/// broker configuration, against a client no model call can survive. +async fn run_builtin_chat( + broker: Option>, +) -> Result { + use promptforge_core::{Prompt, ResolutionContext, RunConfig}; + let observer: Arc = Arc::new(WorkshopObserver::new(None).expect("memory log")); + let prompt = Prompt::parse(BUILTIN_CHAT_SOURCE, "chat-unit", observer.as_ref()) + .expect("the embedded chat prompt parses"); + let picker = promptforge_tool_picker::ToolPicker::build( + promptforge_tool_picker::Catalog::new(Vec::new()), + promptforge_tool_picker::Config::default(), + ) + .expect("the empty picker builds"); + let models = ModelCatalog::empty(); + let tools = promptforge_tools::ToolCatalog::new(&[]).expect("an empty catalog is valid"); + let store = promptforge_vfs::empty(); + let mut config = RunConfig::new("chat-unit").observer(observer); + if let Some(broker) = broker { + config = config.input_broker(broker); + } + promptforge_core::run( + &prompt, + "", + ResolutionContext::new(&picker, &models, &tools), + &store, + config, + ) + .await +} + +#[tokio::test] +async fn the_builtin_chat_returns_without_a_broker_beneath_it() { + // No broker is the unavailable-fallback policy: user_input() + // resumes unavailable, the prompt returns, and no model call is + // ever attempted (the run carries no client at all). + let result = run_builtin_chat(None).await; + assert!( + result.is_ok(), + "the unavailable fallback ends the run cleanly: {result:?}" + ); +} + +#[tokio::test] +async fn a_failing_broker_fails_the_builtin_chat_as_typed_input() { + struct FailingBroker; + + #[async_trait::async_trait] + impl promptforge_core::input::InputBroker for FailingBroker { + async fn user_input( + &self, + _execution: &str, + _section: &str, + ) -> Result + { + Err(promptforge_core::input::InputError::message( + "the input device is gone", + )) + } + } + + let error = run_builtin_chat(Some(Arc::new(FailingBroker))) + .await + .expect_err("the broker failure fails the run"); + assert!( + matches!(error.kind(), promptforge_core::execute::RunErrorKind::Input), + "a broker failure is the typed input failure: {error}" + ); +} diff --git a/crates/workshop-sessions/src/input.rs b/crates/workshop-sessions/src/input.rs new file mode 100644 index 000000000..ab38c1e31 --- /dev/null +++ b/crates/workshop-sessions/src/input.rs @@ -0,0 +1,318 @@ +//! The user-input wait: the [`WaitRegistry`] of single-use wait tokens, +//! the Workshop's `user_input` tool, and the `input_response` producer +//! that completes a wait. +//! +//! An agent program asks its operator for input by calling the +//! `user_input` tool - session-supplied code, never advertised to a +//! model. Its `call()` registers a wait, announces it with a durable +//! `input_required` frame, and suspends on the wait's receiver until the +//! session delivers the operator's answer ([`deliver_input_response`]) or +//! the wait dies. A dying wait is an outcome, never silence: every path +//! out of an unresolved wait - the future dropped by a turn-cancel, the +//! wait cancelled out of the registry - removes the entry and pushes a +//! durable `input_cancelled` frame, so the SPA never pins its input box +//! to a dead token. Unresolved waits are retained across socket loss and +//! re-announced on reconnect: sessions outlive sockets. + +mod tool; + +use std::fmt; +use std::sync::{Mutex, MutexGuard, PoisonError}; + +use promptforge_core_support::observe::Observer; +use tokio::sync::{broadcast, oneshot}; + +use workshop_protocol::{InputFrame, InputResponse}; + +pub use tool::{SessionInputBroker, UserInputTool}; + +/// One unresolved wait: its single-use token, and the sender that resumes +/// the suspended `user_input` call with the operator's text. +struct Wait { + /// The unguessable token an `input_response` must echo. + token: String, + /// Resumes the suspended call; dropping it without a value resolves + /// the call as cancelled. + sender: oneshot::Sender, +} + +/// The registry of unresolved user-input waits, keyed by single-use +/// cryptographic tokens. +/// +/// [`create`](Self::create) opens a wait and returns its token beside the +/// receiving half; [`complete`](Self::complete) resolves the wait with the +/// operator's text and consumes the token; [`cancel`](Self::cancel) kills +/// it. Unresolved waits are retained - sessions outlive sockets - and +/// [`resend_unresolved`](Self::resend_unresolved) re-announces them to a +/// reconnecting client in creation order. +#[derive(Default)] +pub struct WaitRegistry { + /// The unresolved waits in creation order. A `Vec` rather than a map: + /// a session holds at most a handful of waits (in the gate, one), and + /// creation order is exactly the resend order reconnect needs. + waits: Mutex>, +} + +/// Shows the unresolved count, never the tokens: a token in a log would +/// let whoever reads the log answer someone else's prompt. +impl fmt::Debug for WaitRegistry { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("WaitRegistry") + .field("unresolved", &self.lock().len()) + .finish() + } +} + +impl WaitRegistry { + /// Opens an empty registry. + /// + /// # Examples + /// ``` + /// use workshop_sessions::WaitRegistry; + /// + /// let registry = WaitRegistry::new(); + /// assert!(registry.unresolved().is_empty()); + /// ``` + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// The registry lock. Zone two: a peer that panicked mid-mutation + /// cannot wedge the process, and the recovered list is still + /// consistent because every mutation is one push, remove, or retain. + fn lock(&self) -> MutexGuard<'_, Vec> { + self.waits.lock().unwrap_or_else(PoisonError::into_inner) + } + + /// Opens a wait: returns its fresh single-use token and the receiver + /// that resolves with the operator's text. + /// + /// The token is 128 bits from the OS-seeded cryptographic RNG + /// (`rand::rng`, a ChaCha-based CSPRNG), hex-encoded, so it cannot be + /// guessed by anything that has not seen the `input_required` frame. + /// + /// # Examples + /// ``` + /// use workshop_sessions::WaitRegistry; + /// + /// let registry = WaitRegistry::new(); + /// let (token, mut receiver) = registry.create(); + /// registry.complete(&token, "hello".to_owned())?; + /// assert_eq!(receiver.try_recv(), Ok("hello".to_owned())); + /// # Ok::<(), workshop_sessions::WaitError>(()) + /// ``` + #[must_use] + pub fn create(&self) -> (String, oneshot::Receiver) { + use rand::Rng as _; + let mut rng = rand::rng(); + let token = format!("{:016x}{:016x}", rng.random::(), rng.random::()); + let (sender, receiver) = oneshot::channel(); + self.lock().push(Wait { + token: token.clone(), + sender, + }); + (token, receiver) + } + + /// Resolves the wait holding `token` with the operator's text, + /// consuming the token: a second `complete` of the same token fails. + /// + /// # Errors + /// Returns [`WaitError::UnknownToken`] when no unresolved wait holds + /// `token` - never created, already completed, cancelled, or its + /// suspended call dropped concurrently. The undelivered `value` is + /// discarded with the error: a dead wait has no consumer left. + /// + /// # Examples + /// ``` + /// use workshop_sessions::{WaitError, WaitRegistry}; + /// + /// let registry = WaitRegistry::new(); + /// let (token, mut receiver) = registry.create(); + /// registry.complete(&token, "typed".to_owned())?; + /// assert_eq!(receiver.try_recv(), Ok("typed".to_owned())); + /// assert_eq!( + /// registry.complete(&token, "again".to_owned()), + /// Err(WaitError::UnknownToken), + /// ); + /// # Ok::<(), workshop_sessions::WaitError>(()) + /// ``` + pub fn complete(&self, token: &str, value: String) -> Result<(), WaitError> { + let wait = { + let mut waits = self.lock(); + let index = waits + .iter() + .position(|wait| wait.token == token) + .ok_or(WaitError::UnknownToken)?; + waits.remove(index) + }; + wait.sender.send(value).map_err(|_| WaitError::UnknownToken) + } + + /// Kills the wait holding `token`: the entry is removed and the + /// suspended call resolves as cancelled. + /// + /// Cancelling a token with no wait is a no-op, because a cancel + /// racing the wait's own completion is normal, exactly as a chat + /// cancel racing its `done` is. + /// + /// # Examples + /// ``` + /// use workshop_sessions::WaitRegistry; + /// + /// let registry = WaitRegistry::new(); + /// let (token, mut receiver) = registry.create(); + /// registry.cancel(&token); + /// assert!(receiver.try_recv().is_err(), "the wait resolves as dead"); + /// assert!(registry.unresolved().is_empty()); + /// ``` + pub fn cancel(&self, token: &str) { + self.lock().retain(|wait| wait.token != token); + } + + /// Returns the unresolved wait tokens in creation order. + /// + /// This is the retained state behind reconnect resend and the + /// leaked-wait assertion in session teardown tests. + /// + /// # Examples + /// ``` + /// use workshop_sessions::WaitRegistry; + /// + /// let registry = WaitRegistry::new(); + /// let (token, _receiver) = registry.create(); + /// assert_eq!(registry.unresolved(), vec![token]); + /// ``` + #[must_use] + pub fn unresolved(&self) -> Vec { + self.lock().iter().map(|wait| wait.token.clone()).collect() + } + + /// Re-announces every unresolved wait to `frames` as an + /// `input_required` frame, in creation order. + /// + /// The reconnect half of the durable-delivery promise: a client that + /// missed pushes rebuilds its prompt state from this resend - a live + /// wait reappears, and a stale prompt vanishes by its absence. + /// + /// # Examples + /// ``` + /// use workshop_protocol::InputFrame; + /// use workshop_sessions::WaitRegistry; + /// + /// let registry = WaitRegistry::new(); + /// let (token, _receiver) = registry.create(); + /// let (frames, mut socket) = tokio::sync::broadcast::channel(8); + /// registry.resend_unresolved(&frames); + /// assert_eq!(socket.try_recv()?, InputFrame::Required { token }); + /// # Ok::<(), tokio::sync::broadcast::error::TryRecvError>(()) + /// ``` + pub fn resend_unresolved(&self, frames: &broadcast::Sender) { + for token in self.unresolved() { + // No receiver means the client vanished again between + // subscribing and this resend; the registry still holds the + // wait, so the next reconnect resends it once more. + let _ = frames.send(InputFrame::Required { token }); + } + } +} + +/// A [`WaitRegistry`] operation failed. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +#[non_exhaustive] +pub enum WaitError { + /// No unresolved wait holds the token: never created, already + /// completed (tokens are single-use), cancelled, or its suspended + /// call dropped concurrently. + #[error("no unresolved wait holds this token")] + UnknownToken, +} + +/// Fires `on_user_input` for an arrived `input_response`, byte-exact, +/// then completes the wait its token names. +/// +/// This is the producer the session calls when the SPA answers a prompt. +/// The event fires exactly once per response, before completion and +/// regardless of whether the token still names a live wait: the +/// operator's text is history the relaunched agent rebuilds context +/// from, so a response racing a turn-cancel records its text even though +/// the wait it aimed at is gone. +/// +/// # Errors +/// Returns [`WaitError::UnknownToken`] when no unresolved wait holds the +/// response's token; the `on_user_input` event has fired regardless. +/// +/// # Examples +/// ``` +/// use promptforge_core_support::observe::NullObserver; +/// use workshop_protocol::InputResponse; +/// use workshop_sessions::{WaitRegistry, deliver_input_response}; +/// +/// let registry = WaitRegistry::new(); +/// let (token, mut receiver) = registry.create(); +/// deliver_input_response( +/// &NullObserver::default(), +/// ®istry, +/// "run", +/// "chat", +/// InputResponse { token, text: "hello".to_owned() }, +/// )?; +/// assert_eq!(receiver.try_recv(), Ok("hello".to_owned())); +/// # Ok::<(), workshop_sessions::WaitError>(()) +/// ``` +pub fn deliver_input_response( + observer: &dyn Observer, + registry: &WaitRegistry, + execution: &str, + section: &str, + response: InputResponse, +) -> Result<(), WaitError> { + deliver_input_response_before_completion( + observer, + registry, + execution, + section, + response, + || {}, + ) +} + +/// Completes the wait `response` names without recording anything. +/// +/// The unified-runtime half of delivery: a session whose agent runs on +/// the unified runtime records the operator's text consumer-side, when +/// the suspended `user_input` call resumes, so the producer-side +/// observation would double the event. The `before_completion` seam is +/// the same one [`deliver_input_response_before_completion`] offers. +/// +/// # Errors +/// Returns [`WaitError::UnknownToken`] when no unresolved wait holds the +/// response's token. +pub(crate) fn complete_input_response( + registry: &WaitRegistry, + response: InputResponse, + before_completion: impl FnOnce(), +) -> Result<(), WaitError> { + before_completion(); + registry.complete(&response.token, response.text) +} + +/// Delivers one response with a synchronous seam after the durable input +/// observation and before the suspended tool call resumes. +pub(crate) fn deliver_input_response_before_completion( + observer: &dyn Observer, + registry: &WaitRegistry, + execution: &str, + section: &str, + response: InputResponse, + before_completion: impl FnOnce(), +) -> Result<(), WaitError> { + observer.on_user_input(execution, section, &response.text); + before_completion(); + registry.complete(&response.token, response.text) +} + +#[cfg(test)] +mod tests; diff --git a/crates/workshop-sessions/src/input/tests.rs b/crates/workshop-sessions/src/input/tests.rs new file mode 100644 index 000000000..dc6141b83 --- /dev/null +++ b/crates/workshop-sessions/src/input/tests.rs @@ -0,0 +1,448 @@ +use super::*; + +use std::sync::Arc; + +use promptforge_core::input::{InputBroker, InputOutcome}; +use promptforge_core_support::observe::Observation; +use promptforge_tools::{OutputTrust, Tool, ToolErrorKind}; + +/// Hostile operator text covering the bytes most likely to be mangled +/// by an envelope or codec. +const GNARLY: &str = "line1\r\nline2 \"quoted\" {\"text\":\"decoy\"} \\slash \u{1F980}"; + +/// A fresh tool, registry, and channel with no subscribers. +fn tool_fixture() -> ( + UserInputTool, + Arc, + broadcast::Sender, +) { + let registry = Arc::new(WaitRegistry::new()); + let (frames, _) = broadcast::channel(8); + let tool = UserInputTool::new(Arc::clone(®istry), frames.clone()); + (tool, registry, frames) +} + +async fn registered_token(registry: &WaitRegistry) -> String { + for _ in 0..1024 { + if let Some(token) = registry.unresolved().first().cloned() { + return token; + } + tokio::task::yield_now().await; + } + panic!("the tool call never registered its wait"); +} + +async fn required_token(socket: &mut broadcast::Receiver) -> String { + let frame = socket.recv().await.expect("a frame arrives"); + let InputFrame::Required { token } = frame else { + panic!("expected input_required first, got {frame:?}"); + }; + token +} + +#[test] +fn complete_delivers_the_value_and_consumes_the_token() { + let registry = WaitRegistry::new(); + let (token, mut receiver) = registry.create(); + registry + .complete(&token, "hello".to_owned()) + .expect("a live wait completes"); + assert_eq!( + receiver.try_recv().expect("the value arrived"), + "hello", + "completion delivers the value to the waiting receiver" + ); + assert_eq!( + registry.complete(&token, "again".to_owned()), + Err(WaitError::UnknownToken), + "tokens are single-use: a duplicate complete is refused" + ); + assert!(registry.unresolved().is_empty()); +} + +#[test] +fn an_unknown_token_reports_unknown_and_leaves_live_waits_alone() { + let registry = WaitRegistry::new(); + let (token, mut receiver) = registry.create(); + assert_eq!( + registry.complete("not-a-token", "x".to_owned()), + Err(WaitError::UnknownToken) + ); + assert_eq!( + registry.unresolved(), + vec![token.clone()], + "a refused complete must not disturb the live wait" + ); + registry + .complete(&token, "still here".to_owned()) + .expect("the live wait was untouched"); + assert_eq!( + receiver.try_recv().expect("the value arrived"), + "still here" + ); +} + +#[test] +fn cancel_kills_the_wait_and_its_token() { + let registry = WaitRegistry::new(); + let (token, mut receiver) = registry.create(); + registry.cancel(&token); + assert!( + receiver.try_recv().is_err(), + "a cancelled wait's receiver resolves dead rather than hanging" + ); + assert_eq!( + registry.complete(&token, "late".to_owned()), + Err(WaitError::UnknownToken), + "a cancelled token is dead to completion" + ); + // Cancelling again is the normal cancel-races-completion no-op. + registry.cancel(&token); +} + +#[test] +fn tokens_are_distinct_and_unguessably_wide() { + let registry = WaitRegistry::new(); + let mut receivers = Vec::new(); + let mut seen = std::collections::BTreeSet::new(); + for _ in 0..64 { + let (token, receiver) = registry.create(); + receivers.push(receiver); + assert_eq!(token.len(), 32, "128 bits hex-encode to 32 characters"); + assert!( + token + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()), + "tokens are lowercase hex" + ); + assert!(seen.insert(token), "every token is unique"); + } +} + +#[test] +fn the_registry_debug_shows_the_count_and_never_a_token() { + let registry = WaitRegistry::new(); + let (token, _receiver) = registry.create(); + let rendered = format!("{registry:?}"); + assert_eq!( + rendered, "WaitRegistry { unresolved: 1 }", + "Debug reports the pending count" + ); + assert!( + !rendered.contains(&token), + "a token in a log would let the log's reader answer the prompt" + ); +} + +#[test] +fn the_tool_declares_structured_output() { + let (tool, _registry, _frames) = tool_fixture(); + assert!( + tool.structured_output(), + "user_input must bind structured so its JSON resumes as a Lua table" + ); +} + +#[tokio::test] +async fn the_tool_emits_input_required_carrying_its_wait_token() { + let (tool, registry, frames) = tool_fixture(); + let mut socket = frames.subscribe(); + let call = tokio::spawn(async move { tool.call(serde_json::json!({})).await }); + let token = required_token(&mut socket).await; + assert_eq!( + registry.unresolved(), + vec![token.clone()], + "the announced token names the retained wait" + ); + registry + .complete(&token, "done".to_owned()) + .expect("the wait completes"); + let output = call + .await + .expect("the task joins") + .expect("the call succeeds"); + assert_eq!(output.trust(), OutputTrust::Trusted); +} + +#[tokio::test] +async fn the_resumed_output_is_a_trusted_table_with_byte_exact_text_and_empty_images() { + let (tool, registry, frames) = tool_fixture(); + let mut socket = frames.subscribe(); + let call = tokio::spawn(async move { tool.call(serde_json::json!({})).await }); + let token = required_token(&mut socket).await; + registry + .complete(&token, GNARLY.to_owned()) + .expect("the wait completes"); + let output = call + .await + .expect("the task joins") + .expect("the call succeeds"); + assert_eq!( + output.trust(), + OutputTrust::Trusted, + "operator input is first-party: no nonce envelope may wrap it" + ); + let table: serde_json::Value = + serde_json::from_str(output.text()).expect("a structured tool returns JSON"); + assert_eq!( + table["text"].as_str().expect("text is a string"), + GNARLY, + "result.text is the SPA text byte-exact and envelope-free" + ); + assert_eq!( + table["images"], + serde_json::json!([]), + "result.images is present and empty in the gate" + ); + assert!( + matches!( + socket.try_recv(), + Err(broadcast::error::TryRecvError::Empty) + ), + "a completed wait dies silently: no input_cancelled follows" + ); +} + +#[tokio::test] +async fn dropping_the_tool_future_removes_the_wait_and_emits_input_cancelled() { + let (tool, registry, frames) = tool_fixture(); + let mut socket = frames.subscribe(); + let call = tokio::spawn(async move { tool.call(serde_json::json!({})).await }); + let token = required_token(&mut socket).await; + call.abort(); + let joined = call.await; + assert!( + joined.is_err_and(|error| error.is_cancelled()), + "abort drops the suspended call" + ); + assert!( + registry.unresolved().is_empty(), + "a dropped future may not leak its wait" + ); + let frame = socket.recv().await.expect("the cancellation frame arrives"); + assert_eq!( + frame, + InputFrame::Cancelled { token }, + "the SPA is told exactly which prompt died" + ); +} + +#[tokio::test] +async fn a_registry_cancel_fails_the_call_as_cancelled_and_emits_input_cancelled() { + let (tool, registry, frames) = tool_fixture(); + let mut socket = frames.subscribe(); + let call = tokio::spawn(async move { tool.call(serde_json::json!({})).await }); + let token = required_token(&mut socket).await; + registry.cancel(&token); + let error = call + .await + .expect("the task joins") + .expect_err("a cancelled wait fails the call"); + assert_eq!(error.kind(), ToolErrorKind::Cancelled); + let frame = socket.recv().await.expect("the cancellation frame arrives"); + assert_eq!( + frame, + InputFrame::Cancelled { token }, + "cancellation is an outcome on the wire, not silence" + ); +} + +#[tokio::test] +async fn a_disconnected_socket_does_not_cancel_the_wait() { + let (tool, registry, frames) = tool_fixture(); + // No subscriber exists at all: the session's socket is gone. + drop(frames); + let call = tokio::spawn(async move { tool.call(serde_json::json!({})).await }); + let token = registered_token(®istry).await; + assert_eq!( + registry.unresolved(), + vec![token.clone()], + "the wait outlives the absent socket" + ); + registry + .complete(&token, "typed after reconnect".to_owned()) + .expect("the retained wait still completes"); + let output = call + .await + .expect("the task joins") + .expect("the call succeeds"); + let table: serde_json::Value = + serde_json::from_str(output.text()).expect("a structured tool returns JSON"); + assert_eq!(table["text"], "typed after reconnect"); +} + +#[tokio::test] +async fn reconnect_resends_unresolved_waits_in_creation_order() { + let registry = WaitRegistry::new(); + let (first, _first_receiver) = registry.create(); + let (second, _second_receiver) = registry.create(); + // The reconnecting client subscribes, then the session resends. + let (frames, mut socket) = broadcast::channel(8); + registry.resend_unresolved(&frames); + assert_eq!( + socket.recv().await.expect("the first resend arrives"), + InputFrame::Required { token: first }, + "resend replays the retained waits" + ); + assert_eq!( + socket.recv().await.expect("the second resend arrives"), + InputFrame::Required { token: second }, + "resend preserves creation order" + ); +} + +#[derive(Default)] +struct RecordingObserver { + inputs: Mutex>, +} + +impl RecordingObserver { + fn inputs(&self) -> MutexGuard<'_, Vec<(String, String, String)>> { + self.inputs.lock().expect("the recorder mutex stays usable") + } +} + +impl Observer for RecordingObserver { + fn observe(&self, _execution: &str, _section: &str, _event: Observation) {} + + fn on_user_input(&self, execution: &str, section: &str, text: &str) { + self.inputs() + .push((execution.to_owned(), section.to_owned(), text.to_owned())); + } +} + +#[test] +fn on_user_input_fires_exactly_once_per_response_byte_exact_before_completion() { + let registry = WaitRegistry::new(); + let observer = RecordingObserver::default(); + let (token, mut receiver) = registry.create(); + deliver_input_response( + &observer, + ®istry, + "run-1", + "chat", + InputResponse { + token: token.clone(), + text: GNARLY.to_owned(), + }, + ) + .expect("a live wait completes"); + assert_eq!( + receiver.try_recv().expect("the wait resumed"), + GNARLY, + "the completed value is the response text byte-exact" + ); + assert_eq!( + observer.inputs().as_slice(), + &[("run-1".to_owned(), "chat".to_owned(), GNARLY.to_owned())], + "exactly one byte-exact event per response" + ); + // A duplicate response still records the operator's text - one + // event per response - while the dead wait reports as the error. + assert_eq!( + deliver_input_response( + &observer, + ®istry, + "run-1", + "chat", + InputResponse { + token, + text: "again".to_owned(), + }, + ), + Err(WaitError::UnknownToken) + ); + assert_eq!( + observer.inputs().len(), + 2, + "the event fires exactly once per response, even a stale one" + ); +} + +/// A fresh broker, registry, and channel with no subscribers. +fn broker_fixture() -> ( + SessionInputBroker, + Arc, + broadcast::Sender, +) { + let registry = Arc::new(WaitRegistry::new()); + let (frames, _) = broadcast::channel(8); + let broker = SessionInputBroker::new(Arc::clone(®istry), frames.clone()); + (broker, registry, frames) +} + +#[tokio::test] +async fn the_broker_announces_the_wait_and_resolves_with_the_operator_text() { + let (broker, registry, frames) = broker_fixture(); + let mut socket = frames.subscribe(); + let call = tokio::spawn(async move { broker.user_input("run", "chat").await }); + let token = required_token(&mut socket).await; + assert_eq!( + registry.unresolved(), + vec![token.clone()], + "the announced token names the retained wait" + ); + registry + .complete(&token, GNARLY.to_owned()) + .expect("the wait completes"); + let outcome = call + .await + .expect("the task joins") + .expect("the broker answers"); + assert_eq!( + outcome, + InputOutcome::Text(GNARLY.to_owned()), + "the operator's text rides back byte-exact" + ); + assert!( + matches!( + socket.try_recv(), + Err(broadcast::error::TryRecvError::Empty) + ), + "a completed wait dies silently: no input_cancelled follows" + ); +} + +#[tokio::test] +async fn a_dropped_broker_future_removes_the_wait_and_emits_input_cancelled() { + let (broker, registry, frames) = broker_fixture(); + let mut socket = frames.subscribe(); + let call = tokio::spawn(async move { broker.user_input("run", "chat").await }); + let token = required_token(&mut socket).await; + call.abort(); + let joined = call.await; + assert!( + joined.is_err_and(|error| error.is_cancelled()), + "abort drops the suspended call" + ); + assert!( + registry.unresolved().is_empty(), + "a dropped future may not leak its wait" + ); + let frame = socket.recv().await.expect("the cancellation frame arrives"); + assert_eq!( + frame, + InputFrame::Cancelled { token }, + "the SPA is told exactly which prompt died" + ); +} + +#[tokio::test] +async fn a_registry_cancel_fails_the_broker_call_and_emits_input_cancelled() { + let (broker, registry, frames) = broker_fixture(); + let mut socket = frames.subscribe(); + let call = tokio::spawn(async move { broker.user_input("run", "chat").await }); + let token = required_token(&mut socket).await; + registry.cancel(&token); + let error = call + .await + .expect("the task joins") + .expect_err("a cancelled wait fails the broker call"); + assert_eq!(error.to_string(), "the user-input wait was cancelled"); + let frame = socket.recv().await.expect("the cancellation frame arrives"); + assert_eq!( + frame, + InputFrame::Cancelled { token }, + "cancellation is an outcome on the wire, not silence" + ); +} diff --git a/crates/workshop-sessions/src/input/tool.rs b/crates/workshop-sessions/src/input/tool.rs new file mode 100644 index 000000000..43718190c --- /dev/null +++ b/crates/workshop-sessions/src/input/tool.rs @@ -0,0 +1,279 @@ +//! The session's input tools: the Workshop's `user_input` tool and the +//! generic input broker, each suspending an agent program until its +//! operator answers, each guarded so a dying wait is an outcome, never +//! silence. + +use std::sync::Arc; + +use promptforge_core::input::{InputBroker, InputError, InputOutcome}; +use promptforge_tools::{Tool, ToolError, ToolErrorKind, ToolId, ToolOutput}; +use tokio::sync::broadcast; + +use workshop_protocol::InputFrame; + +use super::WaitRegistry; + +/// The Workshop's `user_input` tool: suspends an agent program until its +/// operator types into the session's input box. +/// +/// A host-primitive [`Tool`] the session constructs per agent session - +/// it is never advertised to a model (the agent driver advertises only +/// the aliases a `models.chat` call names, and host primitives are +/// excluded from that set), and only the agent program itself calls it. +/// `call()` opens a wait in the session's [`WaitRegistry`], pushes the +/// `input_required` frame itself, and suspends until the wait resolves; +/// `run_agent` has no user-input awareness because this tool is the +/// caller's own code. +/// +/// The output is **trusted and structured**: a JSON object with `text` +/// (the operator's input, byte-exact - the operator is not an attacker of +/// their own session, so no nonce envelope ever wraps it) and `images` +/// (present and always empty until SPA attachments land). The session +/// binds this tool with the structured output kind, so the object resumes +/// into Lua as a table - `result.text`, `result.images` - through the +/// serde boundary; structured output stays restricted to trusted tools. +/// +/// # Examples +/// ``` +/// use std::sync::Arc; +/// +/// use promptforge_tools::Tool; +/// use workshop_sessions::{UserInputTool, WaitRegistry}; +/// +/// let (frames, _receiver) = tokio::sync::broadcast::channel(8); +/// let tool = UserInputTool::new(Arc::new(WaitRegistry::new()), frames); +/// assert_eq!(tool.wire_name(), "user_input"); +/// ``` +#[derive(Debug)] +pub struct UserInputTool { + /// The session's wait registry, shared with the session loop that + /// completes and cancels waits. + registry: Arc, + /// Where `input_required` and `input_cancelled` frames are pushed; + /// the session's socket loop forwards them to the SPA. + frames: broadcast::Sender, +} + +impl UserInputTool { + /// Builds the tool over the session's wait registry and frame sender. + /// + /// # Examples + /// ``` + /// use std::sync::Arc; + /// + /// use workshop_sessions::{UserInputTool, WaitRegistry}; + /// + /// let registry = Arc::new(WaitRegistry::new()); + /// let (frames, _receiver) = tokio::sync::broadcast::channel(8); + /// let _tool = UserInputTool::new(registry, frames); + /// ``` + #[must_use] + pub fn new(registry: Arc, frames: broadcast::Sender) -> Self { + Self { registry, frames } + } +} + +/// Guarantees a dying wait is an outcome, not silence: unless disarmed by +/// a delivered value, dropping the guard removes the wait from the +/// registry and pushes `input_cancelled` for its token. The tool future +/// is dropped by the shared dispatch's cancel race on turn-cancel, so +/// this guard is what keeps a cancelled turn from leaking its wait or +/// leaving the SPA prompting against a dead token. +struct WaitGuard { + /// The registry the wait entry is removed from. + registry: Arc, + /// Where the `input_cancelled` frame is pushed. + frames: broadcast::Sender, + /// The dying wait's token. + token: String, + /// Cleared when the wait resolved with a value; the guard then does + /// nothing, because `complete` already consumed the entry. + armed: bool, +} + +impl Drop for WaitGuard { + fn drop(&mut self) { + if !self.armed { + return; + } + // On the registry-cancel path the entry is already gone and this + // is a no-op; on the dropped-future path it is the removal. + self.registry.cancel(&self.token); + // No receiver means no socket is attached; the reconnect resend + // repairs the SPA anyway, because this wait is absent from the + // resent set. + let _ = self.frames.send(InputFrame::Cancelled { + token: std::mem::take(&mut self.token), + }); + } +} + +/// The session's wait registry behind the generic input-broker interface: +/// the adapter the unified runtime's `user_input()` and model-visible +/// input tool suspend on. +/// +/// One broker per run: `user_input` opens a wait in the session's +/// [`WaitRegistry`], announces it with the durable `input_required` frame, +/// and suspends on the receiver until the session delivers the operator's +/// answer or the wait dies. A dying wait is an outcome, never silence - +/// the same drop-guard rule as the legacy tool: a future dropped by a +/// turn-cancel removes the entry and pushes `input_cancelled`, so the SPA +/// never pins its input box to a dead token. +/// +/// # Examples +/// ``` +/// use std::sync::Arc; +/// +/// use workshop_sessions::{SessionInputBroker, WaitRegistry}; +/// +/// let (frames, _receiver) = tokio::sync::broadcast::channel(8); +/// let broker = SessionInputBroker::new(Arc::new(WaitRegistry::new()), frames); +/// # drop(broker); +/// ``` +#[derive(Debug)] +pub struct SessionInputBroker { + /// The session's wait registry, shared with the session loop that + /// completes and cancels waits. + registry: Arc, + /// Where `input_required` and `input_cancelled` frames are pushed; + /// the session's socket loop forwards them to the SPA. + frames: broadcast::Sender, +} + +impl SessionInputBroker { + /// Builds the broker over the session's wait registry and frame sender. + /// + /// # Examples + /// ``` + /// use std::sync::Arc; + /// + /// use workshop_sessions::{SessionInputBroker, WaitRegistry}; + /// + /// let registry = Arc::new(WaitRegistry::new()); + /// let (frames, _receiver) = tokio::sync::broadcast::channel(8); + /// let _broker = SessionInputBroker::new(registry, frames); + /// ``` + #[must_use] + pub fn new(registry: Arc, frames: broadcast::Sender) -> Self { + Self { registry, frames } + } +} + +#[async_trait::async_trait] +impl InputBroker for SessionInputBroker { + /// Opens a wait, announces it, and suspends until it resolves. + /// + /// On cancellation - the future dropped mid-await, or the wait + /// cancelled out of the registry - the drop guard removes the wait and + /// pushes `input_cancelled`, so no path leaks a wait or a stale + /// prompt. A wait cancelled out of the registry resolves here as the + /// broker's failure policy. + /// + /// # Errors + /// Returns an [`InputError`] when the wait dies before the operator + /// answers. + async fn user_input( + &self, + _execution: &str, + _section: &str, + ) -> Result { + let (token, receiver) = self.registry.create(); + let mut guard = WaitGuard { + registry: Arc::clone(&self.registry), + frames: self.frames.clone(), + token, + armed: true, + }; + // No receiver means no socket is attached right now. Not a + // failure: the registry retains the wait and the session resends + // it on reconnect, so the lost push is repaired. + let _ = self.frames.send(InputFrame::Required { + token: guard.token.clone(), + }); + match receiver.await { + Ok(text) => { + guard.armed = false; + Ok(InputOutcome::Text(text)) + } + // The sender died without a value: the wait was cancelled out + // of the registry. The still-armed guard pushes + // `input_cancelled` on scope exit, so this path clears the + // SPA prompt too. + Err(_) => Err(InputError::message("the user-input wait was cancelled")), + } + } +} + +#[async_trait::async_trait] +impl Tool for UserInputTool { + fn id(&self) -> ToolId { + ToolId::from_validated("workshop", "user_input") + } + + #[expect( + clippy::unnecessary_literal_bound, + reason = "the Tool trait fixes this return type to &str, so the &'static str suggestion cannot be applied" + )] + fn wire_name(&self) -> &str { + "user_input" + } + + #[expect( + clippy::unnecessary_literal_bound, + reason = "the Tool trait fixes this return type to &str, so the &'static str suggestion cannot be applied" + )] + fn description(&self) -> &str { + "Waits for the workshop operator to type into the session's input box." + } + + fn parameters_schema(&self) -> serde_json::Value { + serde_json::json!({ "type": "object", "properties": {} }) + } + + /// Structured: the JSON object resumes into Lua as a table + /// (`result.text`, `result.images`), which is safe here because the + /// output is trusted - the untrusted wrap that would break a JSON + /// parse never applies. + fn structured_output(&self) -> bool { + true + } + + /// Opens a wait, announces it, and suspends until it resolves. + /// + /// Arguments are ignored: the tool takes none. On cancellation - the + /// future dropped mid-await, or the wait cancelled out of the + /// registry - the drop guard removes the wait and pushes + /// `input_cancelled`, so no path leaks a wait or a stale prompt. + /// + /// # Errors + /// Returns a [`ToolErrorKind::Cancelled`] error when the wait dies + /// before the operator answers. + async fn call(&self, _args: serde_json::Value) -> Result { + let (token, receiver) = self.registry.create(); + let mut guard = WaitGuard { + registry: Arc::clone(&self.registry), + frames: self.frames.clone(), + token, + armed: true, + }; + // No receiver means no socket is attached right now. Not a + // failure: the registry retains the wait and the session resends + // it on reconnect, so the lost push is repaired. + let _ = self.frames.send(InputFrame::Required { + token: guard.token.clone(), + }); + match receiver.await { + Ok(text) => { + guard.armed = false; + let table = serde_json::json!({ "text": text, "images": [] }); + Ok(ToolOutput::trusted(table.to_string())) + } + // The sender died without a value: the wait was cancelled out + // of the registry. The still-armed guard pushes + // `input_cancelled` on scope exit, so this path clears the + // SPA prompt too. + Err(_) => Err(ToolError::message("the user-input wait was cancelled") + .with_kind(ToolErrorKind::Cancelled)), + } + } +} diff --git a/crates/workshop-sessions/src/lib.rs b/crates/workshop-sessions/src/lib.rs new file mode 100644 index 000000000..b1379ef96 --- /dev/null +++ b/crates/workshop-sessions/src/lib.rs @@ -0,0 +1,39 @@ +//! workshop-sessions - the sessions subsystem: the `/ws` workbench +//! socket (status, catalog, and workbench snapshots downstream, +//! Model-menu events inbound), the `/agents/ws` agent-session socket +//! with its run supervision and operator input waits, and the +//! `/v1/models` buffered catalog relay. +//! +//! ## Invariants +//! +//! - Tier: feature; may depend on: `workshop-protocol`, +//! `workshop-registry`, `workshop-support`, and the service crates +//! (`workshop-gateway`, `workshop-menu`, `workshop-status`). Read +//! `AGENTS.md` before adding an import. +//! - Every file in this crate stays under 500 lines; split first, then +//! edit. +//! - One task owns each socket: a single `select!` loop reads inbound +//! frames and writes every outbound frame itself - no outbox channel, +//! no writer task. The agent-session registry is the documented +//! carve-out, because sessions outlive sockets on purpose. +//! - The workspace's granted roots are read through the registry's +//! `WorkspaceRoots` slot, never by naming the workspace crate: feature +//! crates in the same tier meet through the registry. +//! - The shell's WebSocket origin policy is injected into +//! [`SessionsState`] as a plain function and applied to every upgrade; +//! the cross-site guard stays the shell's security boundary. +//! - A dying input wait is an outcome, never silence: every path out of +//! an unresolved wait removes the entry and pushes a durable +//! `input_cancelled` frame. + +pub mod agents; +pub mod input; +mod relay; +mod session; +pub mod state; + +pub use agents::{AgentSessions, SessionHost}; +pub use input::{ + SessionInputBroker, UserInputTool, WaitError, WaitRegistry, deliver_input_response, +}; +pub use state::{SessionsState, register, routes}; diff --git a/crates/workshop-sessions/src/relay.rs b/crates/workshop-sessions/src/relay.rs new file mode 100644 index 000000000..91f73c284 --- /dev/null +++ b/crates/workshop-sessions/src/relay.rs @@ -0,0 +1,107 @@ +//! The buffered gateway relay: the `/v1/models` catalog passthrough and +//! the helpers that shape gateway responses for the wire. + +use axum::extract::State; +use axum::http::{StatusCode, header}; +use axum::response::{IntoResponse, Response}; + +use workshop_gateway::{GatewayError, GatewayResponse}; +use workshop_protocol::{Activity, ErrorEnvelope}; +use workshop_registry::Push; + +use crate::state::SessionsState; + +/// Whether wire bodies carry internal failure detail. Debug builds append +/// the source chain to the envelope message; production bodies stay at +/// the failure's own message. +const LEAK_DETAIL: bool = cfg!(debug_assertions); + +/// Relays the gateway's model catalog to the caller verbatim. +/// +/// While the heartbeat reports the gateway down, the catalog is not +/// attempted: the route answers 502 with a user-visible message instead. +pub(crate) async fn models(State(state): State) -> Response { + if !state.health().is_reachable() { + return envelope(StatusCode::BAD_GATEWAY, "Gateway unreachable".to_string()); + } + let push = state.push(); + push.push_status_update( + "Loading models...", + "fetching the gateway model catalog", + Activity::General, + ); + let gateway = state.gateway_snapshot(); + let result = gateway.client().list_models().await; + report_gateway_outcome(&push, &result, "GET /v1/models"); + relay(result) +} + +/// Reports a gateway call's outcome on the status bus: back to idle on +/// success, otherwise the error label matching the failure shape. +fn report_gateway_outcome( + push: &Push, + result: &Result, + route: &str, +) { + match result { + Ok(upstream) if upstream.status.is_success() => push.push_idle(), + Ok(upstream) => push.push_failure( + format!("Gateway error: {}", upstream.status), + format!("{route} answered a non-success status"), + Activity::General, + ), + Err(error) => push.push_failure("Connection lost", error.to_string(), Activity::General), + } +} + +/// Parses a gateway body as JSON, falling back to a plain string. +pub(crate) fn value_from_bytes(body: &[u8]) -> serde_json::Value { + serde_json::from_slice(body) + .unwrap_or_else(|_| serde_json::Value::String(String::from_utf8_lossy(body).into_owned())) +} + +/// Turns a gateway call outcome into the workshop's HTTP response. +/// +/// Success (any status) is relayed byte-for-byte; a transport failure +/// becomes `502 Bad Gateway` in the `gateway_unreachable` wire envelope. +pub(crate) fn relay(result: Result) -> Response { + match result { + Ok(upstream) => ( + upstream.status, + [(header::CONTENT_TYPE, "application/json")], + upstream.body, + ) + .into_response(), + Err(error) => envelope(StatusCode::BAD_GATEWAY, gateway_message(&error)), + } +} + +/// The wire message of a gateway transport failure: the error's own +/// summary line, with the source chain appended in debug builds only. +fn gateway_message(error: &GatewayError) -> String { + use std::fmt::Write as _; + let mut message = error.to_string(); + if LEAK_DETAIL { + let mut source = std::error::Error::source(error); + while let Some(cause) = source { + // fmt::Write to a String cannot fail; the Result is a trait + // artifact. + let _ = write!(message, ": {cause}"); + source = cause.source(); + } + } + message +} + +/// Renders one `gateway_unreachable` envelope at `status`. +fn envelope(status: StatusCode, message: String) -> Response { + let envelope = ErrorEnvelope::new(message, "gateway_unreachable"); + // Serializing the envelope cannot fail: two strings only. A body + // that somehow cannot serialize degrades to the status line's text. + let body = serde_json::to_string(&envelope) + .unwrap_or_else(|_| status.canonical_reason().unwrap_or("error").to_string()); + (status, [(header::CONTENT_TYPE, "application/json")], body).into_response() +} + +#[cfg(test)] +mod tests; diff --git a/crates/workshop-sessions/src/relay/tests.rs b/crates/workshop-sessions/src/relay/tests.rs new file mode 100644 index 000000000..244883ffe --- /dev/null +++ b/crates/workshop-sessions/src/relay/tests.rs @@ -0,0 +1,159 @@ +use super::*; + +use axum::Router; +use axum::body::Body; +use axum::http::{HeaderMap, Request, header}; +use axum::routing::get; +use tower::ServiceExt as _; +use workshop_gateway::{GatewayBinding, GatewayHealth}; +use workshop_menu::{CatalogBus, MenuBus}; +use workshop_registry::Registry; +use workshop_support::ReconnectBackoff; + +use crate::agents::{AgentSessions, SessionHost}; +use crate::state::routes; + +const CATALOG: &str = r#"{"object":"list","data":[{"id":"test-model","object":"model","created":1,"owned_by":"promptforge"}]}"#; +const UPSTREAM_ERROR: &str = + r#"{"error":{"message":"model unloaded","code":"upstream_unavailable"}}"#; + +/// Collects a response body already buffered in memory. +async fn body_bytes(response: Response) -> axum::body::Bytes { + axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("the body is in memory already") +} + +/// Binds `app` as a mock gateway on a free loopback port and returns its +/// base URL. +async fn spawn_gateway(app: Router) -> String { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind mock gateway"); + let addr = listener.local_addr().expect("mock gateway address"); + tokio::spawn(async move { + axum::serve(listener, app) + .await + .expect("mock gateway serves"); + }); + format!("http://{addr}") +} + +/// Builds the sessions route state against a stub gateway address: the +/// buses unregistered (pushes are graceful no-ops), the state directory +/// a fresh tempdir returned alongside so it outlives the test. +fn state_for(base_url: &str) -> (SessionsState, tempfile::TempDir) { + let dir = tempfile::TempDir::new().expect("tempdir"); + let registry = Registry::new(); + let catalog = CatalogBus::new(); + let menu = MenuBus::new(catalog.clone(), None); + let gateway = GatewayBinding::new(base_url, "test-key").expect("the binding builds"); + let host = SessionHost::new( + registry.clone(), + ReconnectBackoff::new(), + menu.clone(), + catalog.clone(), + ); + let agents = AgentSessions::new( + dir.path().join("agents"), + dir.path().join("sessions"), + gateway.clone(), + host, + ); + let state = SessionsState::new( + agents, + gateway, + GatewayHealth::new(), + catalog, + menu, + registry, + |_| true, + ); + (state, dir) +} + +fn authorized(headers: &HeaderMap) -> bool { + headers + .get(header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + == Some("Bearer test-key") +} + +async fn mock_models(headers: HeaderMap) -> Response { + if !authorized(&headers) { + return StatusCode::UNAUTHORIZED.into_response(); + } + ([(header::CONTENT_TYPE, "application/json")], CATALOG).into_response() +} + +async fn mock_broken_models() -> Response { + ( + StatusCode::SERVICE_UNAVAILABLE, + [(header::CONTENT_TYPE, "application/json")], + UPSTREAM_ERROR, + ) + .into_response() +} + +fn models_request() -> Request { + Request::builder() + .uri("/v1/models") + .body(Body::empty()) + .expect("static request parts are valid") +} + +#[tokio::test] +async fn models_are_relayed_byte_for_byte() { + let base_url = spawn_gateway(Router::new().route("/v1/models", get(mock_models))).await; + let (state, _dir) = state_for(&base_url); + let response = routes(state) + .oneshot(models_request()) + .await + .expect("the router is infallible"); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(&body_bytes(response).await[..], CATALOG.as_bytes()); +} + +#[tokio::test] +async fn gateway_error_status_is_relayed_byte_for_byte() { + let base_url = spawn_gateway(Router::new().route("/v1/models", get(mock_broken_models))).await; + let (state, _dir) = state_for(&base_url); + let response = routes(state) + .oneshot(models_request()) + .await + .expect("the router is infallible"); + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(&body_bytes(response).await[..], UPSTREAM_ERROR.as_bytes()); +} + +#[tokio::test] +async fn unreachable_gateway_becomes_bad_gateway() { + // Port 1 is never listening, so the connect fails deterministically. + let (state, _dir) = state_for("http://127.0.0.1:1"); + let response = routes(state) + .oneshot(models_request()) + .await + .expect("the router is infallible"); + assert_eq!(response.status(), StatusCode::BAD_GATEWAY); + let body = body_bytes(response).await; + let json: serde_json::Value = serde_json::from_slice(&body).expect("error body is JSON"); + assert_eq!(json["error"]["code"], "gateway_unreachable"); +} + +#[tokio::test] +async fn a_gateway_known_down_short_circuits_the_catalog_with_bad_gateway() { + let (state, _dir) = state_for("http://127.0.0.1:1"); + state.health().publish(false); + let response = routes(state) + .oneshot(models_request()) + .await + .expect("the router is infallible"); + assert_eq!(response.status(), StatusCode::BAD_GATEWAY); + let body = body_bytes(response).await; + let json: serde_json::Value = serde_json::from_slice(&body).expect("error body is JSON"); + assert_eq!(json["error"]["code"], "gateway_unreachable"); + assert_eq!( + json["error"]["message"], "Gateway unreachable", + "the short-circuit message is user-visible" + ); +} diff --git a/crates/workshop-server/src/session.rs b/crates/workshop-sessions/src/session.rs similarity index 83% rename from crates/workshop-server/src/session.rs rename to crates/workshop-sessions/src/session.rs index 8230b8eb4..b8ecf71ac 100644 --- a/crates/workshop-server/src/session.rs +++ b/crates/workshop-sessions/src/session.rs @@ -18,24 +18,23 @@ //! frame. Both events echo an `id` on their refusals when the frame //! carried one. A frame that is not a well-formed menu event is answered //! with an `error` frame and the session continues. Chat itself lives on -//! the `/agents/ws` socket ([`crate::session_agents`]); this endpoint +//! the `/agents/ws` socket ([`crate::agents`]); this endpoint //! carries no chat frames. //! //! One task owns the socket: a single `select!` loop reads inbound frames //! and writes every outbound frame itself - no outbox channel, no writer -//! task. Status updates from [`crate::status`], catalog pushes from -//! [`crate::catalog`], and workbench snapshots from [`crate::menu`] flow -//! as they publish. On connect the session first sends the retained -//! status, catalog, and workbench snapshots, honoring the delivery -//! contract's resend promise (see `workshop-protocol`) - the UI boots -//! from this socket alone, with zero HTTP state fetches; after that the -//! buses forward as they publish, and a session too slow to drain them -//! skips ahead to the newest snapshot rather than slowing the producers. +//! task. Status updates from the status subsystem, catalog pushes and +//! workbench snapshots from the menu subsystem flow as they publish. On +//! connect the session first sends the retained status, catalog, and +//! workbench snapshots, honoring the delivery contract's resend promise +//! (see `workshop-protocol`) - the UI boots from this socket alone, with +//! zero HTTP state fetches; after that the buses forward as they publish, +//! and a session too slow to drain them skips ahead to the newest +//! snapshot rather than slowing the producers. //! //! The status channel is reached through the subsystem registry -//! ([`AppState::registry`]), not named directly: the status bus is the -//! proof-of-concept self-registrant, and an unregistered slot degrades -//! the session to no status frames rather than failing it. +//! ([`SessionsState::registry`]), not named directly: an unregistered +//! slot degrades the session to no status frames rather than failing it. mod log; mod menu; @@ -48,11 +47,9 @@ use axum::http::HeaderMap; use axum::response::{IntoResponse, Response}; use tokio::sync::broadcast; -use workshop_protocol::ErrorFrame; +use workshop_protocol::{ErrorEnvelope, ErrorFrame}; -use crate::app::AppState; -use crate::cross_site; -use crate::error::AppError; +use crate::state::SessionsState; use self::log::SessionLog; use self::menu::{select_model, start_switch}; @@ -60,24 +57,40 @@ use self::menu::{select_model, start_switch}; /// Session ids for log correlation, handed out in connection order. static NEXT_SESSION: AtomicU64 = AtomicU64::new(1); +/// The 403 refusal every WebSocket upgrade answers a foreign `Origin` +/// with: the same `cross_site` envelope the shell's guard middleware +/// renders for plain HTTP requests. +pub(crate) fn cross_site_refusal() -> Response { + let envelope = ErrorEnvelope::new("cross-site request refused", "cross_site"); + // Serializing the envelope cannot fail: two strings only. + let body = serde_json::to_string(&envelope) + .unwrap_or_else(|_| "cross-site request refused".to_string()); + ( + axum::http::StatusCode::FORBIDDEN, + [(axum::http::header::CONTENT_TYPE, "application/json")], + body, + ) + .into_response() +} + /// Upgrades a `GET /ws` request to a WebSocket session. A foreign /// `Origin` is refused with 403: WS upgrades bypass Sec-Fetch in older -/// browsers, so the loopback allowlist in [`crate::cross_site`] guards the -/// upgrade itself. +/// browsers, so the shell's loopback origin policy guards the upgrade +/// itself. pub(crate) async fn upgrade( - State(state): State, + State(state): State, headers: HeaderMap, ws: WebSocketUpgrade, ) -> Response { - if !cross_site::origin_allowed(&headers) { - return AppError::CrossSite.into_response(); + if !state.origin_allowed(&headers) { + return cross_site_refusal(); } ws.on_upgrade(move |socket| run_session(socket, state)) } /// Runs one session until the socket closes or fails: a single `select!` /// loop owning the socket for both reading and writing. -async fn run_session(mut socket: WebSocket, state: AppState) { +async fn run_session(mut socket: WebSocket, state: SessionsState) { let session = NEXT_SESSION.fetch_add(1, Ordering::Relaxed); tracing::info!(session, "workshop session opened"); let _closed = SessionLog { session }; @@ -97,7 +110,7 @@ async fn run_session(mut socket: WebSocket, state: AppState) { // The status line is the one exception: a retained heartbeat transition // ("Connected to gateway") describes a past moment, so the join line is // recomputed from the current probe instead of replayed stale. - if let Some(update) = crate::heartbeat::join_status( + if let Some(update) = workshop_gateway::heartbeat::join_status( status.as_ref().and_then(|channel| channel.latest()), state.health(), ) && !send_frame(&mut socket, &update.frame()).await @@ -191,7 +204,7 @@ async fn run_session(mut socket: WebSocket, state: AppState) { /// Handles one inbound text frame: `select_model` and `switch_profile` /// drive the Model menu, and anything else is answered with an `error` /// frame. Refusals echo the frame's `id` when it carried one. -async fn handle_frame(state: &AppState, text: &str, socket: &mut WebSocket) { +async fn handle_frame(state: &SessionsState, text: &str, socket: &mut WebSocket) { let frame: serde_json::Value = match serde_json::from_str(text) { Ok(frame) => frame, Err(error) => { diff --git a/crates/workshop-server/src/session/log.rs b/crates/workshop-sessions/src/session/log.rs similarity index 100% rename from crates/workshop-server/src/session/log.rs rename to crates/workshop-sessions/src/session/log.rs diff --git a/crates/workshop-server/src/session/menu.rs b/crates/workshop-sessions/src/session/menu.rs similarity index 95% rename from crates/workshop-server/src/session/menu.rs rename to crates/workshop-sessions/src/session/menu.rs index bfdc18b3b..7cce8e3e0 100644 --- a/crates/workshop-server/src/session/menu.rs +++ b/crates/workshop-sessions/src/session/menu.rs @@ -1,21 +1,22 @@ //! The socket side of the Model menu: `select_model` and //! `switch_profile` frame handling, plus the profile-switch task that //! drives the gateway's stage stream into status-bar progress. The menu -//! state and bus live in the crate-root [`crate::menu`]; this module is -//! only the session's orchestration of them. +//! state and bus live in the menu subsystem (`workshop-menu`); this +//! module is only the session's orchestration of them. use axum::extract::ws::WebSocket; use futures_util::StreamExt; -use crate::app::AppState; -use crate::gateway::{ +use workshop_gateway::heartbeat::{refresh_catalog, refresh_profiles}; +use workshop_gateway::{ GatewayClient, GatewayError, GatewayResponse, SwitchEvent, SwitchResponse, switch_events, }; -use crate::heartbeat::{refresh_catalog, refresh_profiles}; -use crate::menu::{MenuBus, SwitchOutcome}; -use crate::push::Push; -use crate::relay::value_from_bytes; +use workshop_menu::{MenuBus, SwitchOutcome}; use workshop_protocol::Activity; +use workshop_registry::Push; + +use crate::relay::value_from_bytes; +use crate::state::SessionsState; use super::send_error; @@ -27,7 +28,7 @@ use super::send_error; /// missing field) is answered with an `error` frame and the session /// continues (zone two). pub(super) async fn select_model( - state: &AppState, + state: &SessionsState, id: Option<&serde_json::Value>, frame: &serde_json::Value, socket: &mut WebSocket, @@ -47,7 +48,7 @@ pub(super) async fn select_model( /// switch already in flight, a missing field) is answered with an /// `error` frame and the session continues (zone two). pub(super) async fn start_switch( - state: &AppState, + state: &SessionsState, id: Option<&serde_json::Value>, frame: &serde_json::Value, socket: &mut WebSocket, diff --git a/crates/workshop-sessions/src/state.rs b/crates/workshop-sessions/src/state.rs new file mode 100644 index 000000000..e4bf0fb98 --- /dev/null +++ b/crates/workshop-sessions/src/state.rs @@ -0,0 +1,144 @@ +//! The sessions subsystem's shared route state and its registry +//! registration: the state every sessions route handler draws its +//! handles from, the router constructor, and the `register` entry point +//! the composition root calls. + +use std::sync::Arc; + +use axum::Router; +use axum::http::HeaderMap; +use axum::routing::get; + +use workshop_gateway::{GatewayBinding, GatewayHealth, GatewaySnapshot}; +use workshop_menu::{CatalogBus, MenuBus}; +use workshop_registry::{ + Push, Registration, Registry, RouteRegistrar, RouteRegistrarAdapter, StateProvider, + StateProviderAdapter, +}; +use workshop_support::{RELAY_DEADLINE, with_deadline}; + +use crate::agents::AgentSessions; +use crate::{agents, relay, session}; + +/// The shared state of the sessions subsystem's routes: the agent-session +/// registry, the gateway endpoint binding and reachability flag, the +/// catalog and menu buses, the subsystem registry (the status channel and +/// the push facade), and the shell's WebSocket origin policy. +/// +/// The origin policy is injected by the shell as a plain function: the +/// cross-site guard is the shell's security boundary (its `cross_site` +/// module), and the subsystem applies it to every upgrade without owning +/// the policy. +#[derive(Debug, Clone)] +pub struct SessionsState { + agents: AgentSessions, + gateway: GatewayBinding, + health: GatewayHealth, + catalog: CatalogBus, + menu: MenuBus, + registry: Registry, + origin_allowed: fn(&HeaderMap) -> bool, +} + +impl SessionsState { + /// Builds the route state from the subsystem's handles. + #[must_use] + pub fn new( + agents: AgentSessions, + gateway: GatewayBinding, + health: GatewayHealth, + catalog: CatalogBus, + menu: MenuBus, + registry: Registry, + origin_allowed: fn(&HeaderMap) -> bool, + ) -> Self { + Self { + agents, + gateway, + health, + catalog, + menu, + registry, + origin_allowed, + } + } + + /// The agent-session registry behind `/agents/ws`. + pub(crate) fn agents(&self) -> &AgentSessions { + &self.agents + } + + /// One atomic Gateway endpoint and credential generation. + pub(crate) fn gateway_snapshot(&self) -> Arc { + self.gateway.snapshot() + } + + /// Shared gateway reachability, published by the heartbeat. + pub(crate) fn health(&self) -> &GatewayHealth { + &self.health + } + + /// The catalog bus every `/ws` session forwards from. + pub(crate) fn catalog(&self) -> &CatalogBus { + &self.catalog + } + + /// The menu bus every `/ws` session forwards and drives. + pub(crate) fn menu(&self) -> &MenuBus { + &self.menu + } + + /// The subsystem registry: the status push channel and the push + /// facade are reached through its slots. + pub(crate) fn registry(&self) -> &Registry { + &self.registry + } + + /// The push facade over the status, catalog, and menu sink slots. + pub(crate) fn push(&self) -> Push { + self.registry.push() + } + + /// The shell's WebSocket origin policy, applied to every upgrade. + pub(crate) fn origin_allowed(&self, headers: &HeaderMap) -> bool { + (self.origin_allowed)(headers) + } +} + +/// The sessions subsystem's routes: the `/v1/models` catalog relay on the +/// relay deadline, and the `/ws` and `/agents/ws` WebSocket upgrades, +/// which answer immediately and then outlive any deadline. +pub fn routes(state: SessionsState) -> Router { + with_deadline( + Router::new().route("/v1/models", get(relay::models)), + RELAY_DEADLINE, + ) + .route("/ws", get(session::upgrade)) + .route("/agents/ws", get(agents::socket::upgrade)) + .with_state(state) +} + +/// Registers the sessions subsystem into the registry: its routes, merged +/// into the shell's API router, and the agent-session registry as its +/// state handle. The returned guards keep the registrations alive; the +/// composition root holds them for the process lifetime. +pub fn register( + registry: &Registry, + state: SessionsState, +) -> ( + Registration, + Registration, +) { + let routes = registry + .session_routes() + .register(Arc::new(RouteRegistrarAdapter::new({ + let state = state.clone(); + move || routes(state.clone()) + }))); + let handles = registry + .sessions_state() + .register(Arc::new(StateProviderAdapter::new(move || { + Arc::new(state.agents().clone()) as Arc + }))); + (routes, handles) +} diff --git a/crates/workshop-sessions/tests/it/main.rs b/crates/workshop-sessions/tests/it/main.rs new file mode 100644 index 000000000..f470ec52b --- /dev/null +++ b/crates/workshop-sessions/tests/it/main.rs @@ -0,0 +1,154 @@ +//! Integration tests for `workshop-sessions`: the registration +//! contract - routes and the agent-session state handle served through +//! the registry's slots. + +// clippy.toml's allow-expect-in-tests covers #[test] functions and +// #[cfg(test)] modules only, not integration-test helpers; failing a test +// by panicking with the invariant named is exactly what these are for. +#![expect( + clippy::expect_used, + reason = "test helpers fail by panicking with the invariant named" +)] + +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use tokio_tungstenite::tungstenite::client::IntoClientRequest as _; +use tower::ServiceExt as _; +use workshop_gateway::{GatewayBinding, GatewayHealth}; +use workshop_menu::{CatalogBus, MenuBus}; +use workshop_registry::Registry; +use workshop_sessions::{AgentSessions, SessionHost, SessionsState, register, routes}; +use workshop_support::ReconnectBackoff; + +/// Builds the sessions route state against a stub gateway address, the +/// state directory a fresh tempdir returned alongside so it outlives the +/// test. The origin policy is injected exactly as the shell injects its +/// own. +fn state_for( + base_url: &str, + origin_allowed: fn(&axum::http::HeaderMap) -> bool, +) -> (SessionsState, tempfile::TempDir) { + let dir = tempfile::TempDir::new().expect("tempdir"); + let registry = Registry::new(); + let catalog = CatalogBus::new(); + let menu = MenuBus::new(catalog.clone(), None); + let gateway = GatewayBinding::new(base_url, "test-key").expect("the binding builds"); + let host = SessionHost::new( + registry.clone(), + ReconnectBackoff::new(), + menu.clone(), + catalog.clone(), + ); + let agents = AgentSessions::new( + dir.path().join("agents"), + dir.path().join("sessions"), + gateway.clone(), + host, + ); + let state = SessionsState::new( + agents, + gateway, + GatewayHealth::new(), + catalog, + menu, + registry, + origin_allowed, + ); + (state, dir) +} + +#[tokio::test] +async fn the_registered_routes_serve_the_sessions_api() { + let registry = Registry::new(); + let (state, _dir) = state_for("http://127.0.0.1:1", |_| true); + let _guards = register(®istry, state); + + let registrar = registry + .session_routes() + .get() + .expect("the routes slot is registered"); + let router = registrar.routes(); + + // A plain GET to `/ws` without upgrade headers is rejected with 400, + // which proves the route is mounted; the socket flows are pinned end + // to end by the shell's integration binary over live sockets. + let request = Request::builder() + .uri("/ws") + .body(Body::empty()) + .expect("static request parts are valid"); + let response = router + .clone() + .oneshot(request) + .await + .expect("the router is infallible"); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + // The excised buffered chat endpoint is gone: a `POST /chat` answers + // 404, not a relay response. + let request = Request::builder() + .method("POST") + .uri("/chat") + .header(axum::http::header::CONTENT_TYPE, "application/json") + .body(Body::from( + r#"{"model":"test-model","messages":[{"role":"user","content":"ping"}]}"#, + )) + .expect("static request parts are valid"); + let response = router + .oneshot(request) + .await + .expect("the router is infallible"); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + + // The agent-session registry is served as the state handle. + let agents = registry + .sessions_state() + .get() + .expect("the state slot is registered") + .handles() + .downcast::() + .expect("the sessions handle downcasts"); + assert_eq!(agents.discover(), vec!["chat".to_string()]); +} + +#[tokio::test] +async fn a_foreign_origin_is_refused_on_both_upgrades() { + // The shell's loopback policy, in miniature: an `Origin` header, when + // present, must be a loopback origin. + fn loopback_only(headers: &axum::http::HeaderMap) -> bool { + headers + .get(axum::http::header::ORIGIN) + .and_then(|value| value.to_str().ok()) + .is_none_or(|origin| origin.starts_with("http://127.0.0.1")) + } + let (state, _dir) = state_for("http://127.0.0.1:1", loopback_only); + let router = routes(state); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind the test server"); + let addr = listener.local_addr().expect("the test server address"); + tokio::spawn(async move { + axum::serve(listener, router) + .await + .expect("the test server serves"); + }); + for path in ["/ws", "/agents/ws"] { + let mut request = format!("ws://{addr}{path}") + .into_client_request() + .expect("the handshake request builds"); + request.headers_mut().insert( + axum::http::header::ORIGIN, + "https://evil.example" + .parse() + .expect("a valid header value"), + ); + let outcome = tokio_tungstenite::connect_async(request).await; + let Err(tokio_tungstenite::tungstenite::Error::Http(response)) = outcome else { + panic!("a foreign origin must fail the handshake: {path}"); + }; + assert_eq!(response.status(), StatusCode::FORBIDDEN, "for {path}"); + let json: serde_json::Value = + serde_json::from_slice(response.body().as_deref().expect("the refusal has a body")) + .expect("the refusal is the envelope"); + assert_eq!(json["error"]["code"], "cross_site", "for {path}"); + } +} diff --git a/crates/workshop-status/src/lib.rs b/crates/workshop-status/src/lib.rs index 188d84e08..a98276416 100644 --- a/crates/workshop-status/src/lib.rs +++ b/crates/workshop-status/src/lib.rs @@ -27,20 +27,23 @@ use std::sync::Arc; pub use status::StatusBus; use workshop_registry::{ - Registration, Registry, StatusChannel, StatusChannelAdapter, StatusSink, StatusSinkAdapter, + Registration, Registry, StateProvider, StateProviderAdapter, StatusChannel, + StatusChannelAdapter, StatusSink, StatusSinkAdapter, }; -/// Registers the status subsystem's two channels into the registry: the -/// consumer-side push channel every `/ws` session subscribes through, and -/// the producer-side sink same-tier subsystems emit through. The returned -/// guards keep the registrations alive; the composition root holds them -/// for the process lifetime. +/// Registers the status subsystem into the registry: the consumer-side +/// push channel every `/ws` session subscribes through, the +/// producer-side sink same-tier subsystems emit through, and the bus +/// itself as the subsystem's state handle. The returned guards keep the +/// registrations alive; the composition root holds them for the process +/// lifetime. pub fn register( registry: &Registry, bus: &StatusBus, ) -> ( Registration, Registration, + Registration, ) { let channel = registry .status() @@ -60,5 +63,11 @@ pub fn register( let bus = bus.clone(); move |update| bus.emit(update) }))); - (channel, sink) + let state = registry + .status_state() + .register(Arc::new(StateProviderAdapter::new({ + let bus = bus.clone(); + move || Arc::new(bus.clone()) as Arc + }))); + (channel, sink, state) } diff --git a/crates/workshop-status/src/progress/tests.rs b/crates/workshop-status/src/progress/tests.rs index 39c8c5570..3f4fbc243 100644 --- a/crates/workshop-status/src/progress/tests.rs +++ b/crates/workshop-status/src/progress/tests.rs @@ -19,7 +19,7 @@ fn wired() -> ( let status = StatusBus::new(); let rx = status.subscribe(); let registry = Registry::new(); - let (_channel, sink) = crate::register(®istry, &status); + let (_channel, sink, _state) = crate::register(®istry, &status); (hub, registry.push(), rx, sink) } diff --git a/crates/workshop-workspace/Cargo.toml b/crates/workshop-workspace/Cargo.toml new file mode 100644 index 000000000..70814449e --- /dev/null +++ b/crates/workshop-workspace/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "workshop-workspace" +version = "0.0.0" +publish = false +edition.workspace = true +license.workspace = true +repository.workspace = true + +description = "Workshop workspace subsystem: the jailed filesystem behind /workspace/* - directory trees, file reads, and file writes confined to granted roots" + +[dependencies] +axum.workspace = true +dunce.workspace = true +percent-encoding.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +workshop-protocol.workspace = true +workshop-registry.workspace = true +workshop-support.workspace = true + +[dev-dependencies] +tempfile.workspace = true +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } +tower.workspace = true + +[lints] +workspace = true diff --git a/crates/workshop-workspace/src/error.rs b/crates/workshop-workspace/src/error.rs new file mode 100644 index 000000000..534f32c7e --- /dev/null +++ b/crates/workshop-workspace/src/error.rs @@ -0,0 +1,200 @@ +//! The workspace operation failure type and its wire mapping. +//! +//! [`WorkspaceError`] is the boundary between the jail's zone-two +//! failures and the HTTP response: each variant maps to exactly one +//! status code and one machine-readable envelope code, rendered through +//! `workshop-protocol`'s [`ErrorEnvelope`] at the route boundary. +//! Internal failure detail (the source chain) reaches the response body +//! in debug builds only; production bodies stay at each variant's own +//! message. + +use std::fmt::Write as _; +use std::io; + +use axum::http::{StatusCode, header}; +use axum::response::{IntoResponse, Response}; + +use workshop_protocol::ErrorEnvelope; + +/// Whether wire bodies carry internal failure detail. Debug builds append +/// the source chain to the envelope message; production bodies stay at +/// the variant's own message. +const LEAK_DETAIL: bool = cfg!(debug_assertions); + +/// A workspace operation failure. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum WorkspaceError { + /// A granted path could not be canonicalized. + #[non_exhaustive] + #[error("grant path cannot be resolved")] + ResolveGrant { + /// The underlying I/O failure. + #[source] + source: io::Error, + }, + + /// A requested path could not be canonicalized. + #[non_exhaustive] + #[error("requested path cannot be resolved")] + ResolvePath { + /// The underlying I/O failure. + #[source] + source: io::Error, + }, + + /// Filesystem metadata for a path could not be read. + #[non_exhaustive] + #[error("path cannot be inspected")] + InspectPath { + /// The underlying I/O failure. + #[source] + source: io::Error, + }, + + /// A directory could not be listed. + #[non_exhaustive] + #[error("directory cannot be listed")] + ListDirectory { + /// The underlying I/O failure. + #[source] + source: io::Error, + }, + + /// A file could not be read. + #[non_exhaustive] + #[error("file cannot be read")] + ReadFile { + /// The underlying I/O failure. + #[source] + source: io::Error, + }, + + /// A file could not be written. + #[non_exhaustive] + #[error("file cannot be written")] + WriteFile { + /// The underlying I/O failure. + #[source] + source: io::Error, + }, + + /// The path is not inside any granted root. + #[error("path is outside every granted root")] + OutsideGrants, + + /// The path carries a `..` or an alternate data stream name. + #[error("path contains a forbidden component")] + ForbiddenComponent, + + /// The path does not exist. + #[error("path does not exist")] + NotFound, + + /// A revoke named a path that is not a granted root. + #[error("path is not a granted root")] + NotGranted, + + /// A tree listing was requested for something that is not a directory. + #[error("path is not a directory")] + NotADirectory, + + /// A read or write targeted something that is not a regular file. + #[error("path is not a file")] + NotAFile, + + /// The file contains NUL bytes and is not editable text. + #[error("file is binary, not text")] + BinaryFile, + + /// The file is not valid UTF-8. + #[error("file is not utf-8 text")] + NotUtf8, + + /// The file or body exceeds the size limit. + #[non_exhaustive] + #[error("file exceeds the {limit}-byte size limit")] + FileTooLarge { + /// The size limit that was exceeded. + limit: u64, + }, + + /// The on-disk conflict token does not match the writer's token. + #[error("file changed on disk since it was read")] + ModifiedConflict, +} + +impl WorkspaceError { + /// The one HTTP status this failure answers with. + pub(crate) fn status(&self) -> StatusCode { + match self { + Self::NotADirectory | Self::NotAFile => StatusCode::BAD_REQUEST, + Self::OutsideGrants | Self::ForbiddenComponent => StatusCode::FORBIDDEN, + Self::NotFound | Self::NotGranted => StatusCode::NOT_FOUND, + Self::BinaryFile | Self::NotUtf8 => StatusCode::UNSUPPORTED_MEDIA_TYPE, + Self::FileTooLarge { .. } => StatusCode::PAYLOAD_TOO_LARGE, + Self::ModifiedConflict => StatusCode::CONFLICT, + Self::ResolveGrant { .. } + | Self::ResolvePath { .. } + | Self::InspectPath { .. } + | Self::ListDirectory { .. } + | Self::ReadFile { .. } + | Self::WriteFile { .. } => StatusCode::INTERNAL_SERVER_ERROR, + } + } + + /// The machine-readable code of the JSON error envelope. + pub(crate) fn code(&self) -> &'static str { + match self { + Self::ResolveGrant { .. } => "resolve_grant", + Self::ResolvePath { .. } => "resolve_path", + Self::InspectPath { .. } => "inspect_path", + Self::ListDirectory { .. } => "list_directory", + Self::ReadFile { .. } => "read_file", + Self::WriteFile { .. } => "write_file", + Self::OutsideGrants => "outside_grants", + Self::ForbiddenComponent => "forbidden_component", + Self::NotFound => "not_found", + Self::NotGranted => "not_granted", + Self::NotADirectory => "not_a_directory", + Self::NotAFile => "not_a_file", + Self::BinaryFile => "binary_file", + Self::NotUtf8 => "not_utf8", + Self::FileTooLarge { .. } => "file_too_large", + Self::ModifiedConflict => "modified_conflict", + } + } +} + +impl IntoResponse for WorkspaceError { + fn into_response(self) -> Response { + let status = self.status(); + let envelope = ErrorEnvelope::new(render_message(&self, LEAK_DETAIL), self.code()); + // Serializing the envelope cannot fail: two strings only. + // A body that somehow cannot serialize degrades to the + // status line's own text. + let body = serde_json::to_string(&envelope) + .unwrap_or_else(|_| status.canonical_reason().unwrap_or("error").to_string()); + (status, [(header::CONTENT_TYPE, "application/json")], body).into_response() + } +} + +/// Renders the envelope message for `error`: its own `Display` text, with +/// the source chain appended as `: cause` segments when `leak_detail` is +/// set. +fn render_message(error: &WorkspaceError, leak_detail: bool) -> String { + let mut message = error.to_string(); + if leak_detail { + let mut source = std::error::Error::source(error); + while let Some(cause) = source { + // fmt::Write to a String cannot fail; the Result is a trait + // artifact. + let _ = write!(message, ": {cause}"); + source = cause.source(); + } + } + message +} + +#[cfg(test)] +mod tests; diff --git a/crates/workshop-workspace/src/error/tests.rs b/crates/workshop-workspace/src/error/tests.rs new file mode 100644 index 000000000..7270bbb55 --- /dev/null +++ b/crates/workshop-workspace/src/error/tests.rs @@ -0,0 +1,171 @@ +use super::*; + +/// Collects a response body already buffered in memory. +pub(super) async fn body_bytes(response: Response) -> axum::body::Bytes { + axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("the body is in memory already") +} + +/// A distinctive injected cause for leak-boundary assertions. +fn injected_io() -> io::Error { + io::Error::other("injected disk failure") +} + +/// Every workspace failure keeps the status, code, and message it +/// answered with before the crate split. +#[test] +fn workspace_failures_keep_their_wire_mapping() { + let cases: Vec<(WorkspaceError, StatusCode, &str)> = vec![ + ( + WorkspaceError::ResolveGrant { + source: injected_io(), + }, + StatusCode::INTERNAL_SERVER_ERROR, + "resolve_grant", + ), + ( + WorkspaceError::ResolvePath { + source: injected_io(), + }, + StatusCode::INTERNAL_SERVER_ERROR, + "resolve_path", + ), + ( + WorkspaceError::InspectPath { + source: injected_io(), + }, + StatusCode::INTERNAL_SERVER_ERROR, + "inspect_path", + ), + ( + WorkspaceError::ListDirectory { + source: injected_io(), + }, + StatusCode::INTERNAL_SERVER_ERROR, + "list_directory", + ), + ( + WorkspaceError::ReadFile { + source: injected_io(), + }, + StatusCode::INTERNAL_SERVER_ERROR, + "read_file", + ), + ( + WorkspaceError::WriteFile { + source: injected_io(), + }, + StatusCode::INTERNAL_SERVER_ERROR, + "write_file", + ), + ( + WorkspaceError::OutsideGrants, + StatusCode::FORBIDDEN, + "outside_grants", + ), + ( + WorkspaceError::ForbiddenComponent, + StatusCode::FORBIDDEN, + "forbidden_component", + ), + (WorkspaceError::NotFound, StatusCode::NOT_FOUND, "not_found"), + ( + WorkspaceError::NotGranted, + StatusCode::NOT_FOUND, + "not_granted", + ), + ( + WorkspaceError::NotADirectory, + StatusCode::BAD_REQUEST, + "not_a_directory", + ), + ( + WorkspaceError::NotAFile, + StatusCode::BAD_REQUEST, + "not_a_file", + ), + ( + WorkspaceError::BinaryFile, + StatusCode::UNSUPPORTED_MEDIA_TYPE, + "binary_file", + ), + ( + WorkspaceError::NotUtf8, + StatusCode::UNSUPPORTED_MEDIA_TYPE, + "not_utf8", + ), + ( + WorkspaceError::FileTooLarge { limit: 7 }, + StatusCode::PAYLOAD_TOO_LARGE, + "file_too_large", + ), + ( + WorkspaceError::ModifiedConflict, + StatusCode::CONFLICT, + "modified_conflict", + ), + ]; + for (error, status, code) in cases { + assert_eq!(error.status(), status, "status for {code}"); + assert_eq!(error.code(), code, "code for {code}"); + } +} + +#[tokio::test] +async fn the_json_envelope_carries_message_code_and_content_type() { + let response = WorkspaceError::OutsideGrants.into_response(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + let content_type = response + .headers() + .get(header::CONTENT_TYPE) + .expect("the envelope sets content-type"); + assert_eq!(content_type, "application/json"); + let body = body_bytes(response).await; + let json: serde_json::Value = serde_json::from_slice(&body).expect("the envelope is JSON"); + assert_eq!( + json["error"]["message"], + "path is outside every granted root" + ); + assert_eq!(json["error"]["code"], "outside_grants"); +} + +#[test] +fn production_messages_stay_at_the_variant_text() { + let read = WorkspaceError::ReadFile { + source: injected_io(), + }; + assert_eq!( + render_message(&read, false), + "file cannot be read", + "production bodies carry no source detail" + ); +} + +#[test] +fn debug_messages_append_the_source_chain() { + let read = WorkspaceError::ReadFile { + source: injected_io(), + }; + assert_eq!( + render_message(&read, true), + "file cannot be read: injected disk failure" + ); +} + +/// Tests run under debug assertions, so the live envelope must carry +/// the detail the debug side of the boundary promises. +#[cfg(debug_assertions)] +#[tokio::test] +async fn debug_builds_leak_detail_into_the_live_envelope() { + let response = WorkspaceError::ReadFile { + source: injected_io(), + } + .into_response(); + let body = body_bytes(response).await; + let json: serde_json::Value = serde_json::from_slice(&body).expect("the envelope is JSON"); + assert_eq!( + json["error"]["message"], + "file cannot be read: injected disk failure" + ); +} diff --git a/crates/workshop-workspace/src/handlers.rs b/crates/workshop-workspace/src/handlers.rs new file mode 100644 index 000000000..c58717ba0 --- /dev/null +++ b/crates/workshop-workspace/src/handlers.rs @@ -0,0 +1,164 @@ +//! The `/workspace/*` route handlers: query and body DTOs, the path +//! decoding that defangs double-encoded traversal, and the router +//! constructor the subsystem registers into the registry. + +use std::path::Path; + +use axum::Json; +use axum::extract::{Query, State}; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::routing::{get, post}; +use serde::{Deserialize, Serialize}; + +use workshop_support::{DEFAULT_DEADLINE, with_deadline}; + +use crate::error::WorkspaceError; +use crate::workspace::Workspace; + +/// The workspace routes, narrowed to the [`Workspace`] service - the only +/// state their handlers use. Every route carries the default deadline +/// tier. +pub fn routes(state: Workspace) -> axum::Router { + with_deadline( + axum::Router::new() + .route("/workspace/tree", get(tree)) + .route("/workspace/file", get(read_file).put(write_file)) + .route("/workspace/grant", post(grant)) + .route("/workspace/revoke", post(revoke)) + .with_state(state), + DEFAULT_DEADLINE, + ) +} + +/// The query string of `GET /workspace/tree`. +#[derive(Debug, Deserialize)] +pub(crate) struct TreeQuery { + /// The directory to list; absent or empty lists the granted roots. + path: Option, +} + +/// The query string of `GET /workspace/file`. +#[derive(Debug, Deserialize)] +pub(crate) struct FileQuery { + /// The file to read. + path: String, +} + +/// The JSON body of `PUT /workspace/file`. +#[derive(Debug, Deserialize)] +pub(crate) struct WriteRequest { + /// The file to write. + path: String, + /// The new UTF-8 contents. + text: String, + /// The conflict token the writer last read; required to match when + /// the file already exists. + expected_token: Option, +} + +/// The JSON body of `POST /workspace/grant`. +#[derive(Debug, Deserialize)] +pub(crate) struct GrantRequest { + /// The dropped path: a folder grants itself, a file grants its parent. + path: String, +} + +/// The JSON body of a successful grant. +#[derive(Debug, Serialize)] +pub(crate) struct GrantResponse { + /// The root that was registered. + granted: std::path::PathBuf, +} + +/// The JSON body of `POST /workspace/revoke`. +#[derive(Debug, Deserialize)] +pub(crate) struct RevokeRequest { + /// The granted root to remove, as listed by the roots tree. + path: String, +} + +/// The JSON body of a successful revoke. +#[derive(Debug, Serialize)] +pub(crate) struct RevokeResponse { + /// The root that was removed. + revoked: std::path::PathBuf, +} + +/// Percent-decodes a workspace path parameter before validation. The query +/// layer already decoded once, so any surviving `%XX` sequence is a second +/// encoding layer - decoding it here means an encoded traversal (`%2e%2e`) +/// reaches the lexical `..` check as a literal `..` however the client +/// encoded it. Invalid sequences pass through unchanged. +fn decode_path_param(raw: &str) -> String { + percent_encoding::percent_decode_str(raw) + .decode_utf8_lossy() + .into_owned() +} + +/// Lists one level of a workspace directory, or the granted roots when the +/// query carries no path. +pub(crate) async fn tree( + State(workspace): State, + Query(query): Query, +) -> Response { + let path = query.path.as_deref().map(decode_path_param); + respond(workspace.tree(path.as_deref().map(Path::new))) +} + +/// Reads a confined UTF-8 text file with its metadata. +pub(crate) async fn read_file( + State(workspace): State, + Query(query): Query, +) -> Response { + let path = decode_path_param(&query.path); + respond(workspace.read_file(Path::new(&path))) +} + +/// Writes a confined file after path, size, and conflict-token validation. +pub(crate) async fn write_file( + State(workspace): State, + Json(body): Json, +) -> Response { + respond(workspace.write_file( + Path::new(&body.path), + &body.text, + body.expected_token.as_deref(), + )) +} + +/// Registers a dropped path as a granted root for this process. +pub(crate) async fn grant( + State(workspace): State, + Json(body): Json, +) -> Response { + respond( + workspace + .grant(Path::new(&body.path)) + .map(|granted| GrantResponse { granted }), + ) +} + +/// Removes a granted root; paths under it fail their next operation. +pub(crate) async fn revoke( + State(workspace): State, + Json(body): Json, +) -> Response { + respond( + workspace + .revoke(Path::new(&body.path)) + .map(|revoked| RevokeResponse { revoked }), + ) +} + +/// Renders a workspace result as JSON, routing failures through the +/// [`WorkspaceError`] wire envelope. +fn respond(result: Result) -> Response { + match result { + Ok(value) => (StatusCode::OK, Json(value)).into_response(), + Err(error) => error.into_response(), + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/workshop-workspace/src/handlers/tests.rs b/crates/workshop-workspace/src/handlers/tests.rs new file mode 100644 index 000000000..050d531bc --- /dev/null +++ b/crates/workshop-workspace/src/handlers/tests.rs @@ -0,0 +1,120 @@ +use super::*; + +use axum::body::Body; +use axum::http::Request; +use tower::ServiceExt as _; + +use crate::workspace::Workspace; + +/// Collects a response body already buffered in memory. +async fn body_bytes(response: Response) -> axum::body::Bytes { + axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("the body is in memory already") +} + +/// A workspace with one granted tempdir, returned alongside so the +/// directory outlives the test. +fn granted_dir() -> (Workspace, tempfile::TempDir) { + let dir = tempfile::TempDir::new().expect("tempdir"); + let workspace = Workspace::new(); + workspace.grant(dir.path()).expect("grant the tempdir"); + (workspace, dir) +} + +/// The canonical, verbatim-prefix-free form grants are stored in. +fn simplified(path: &Path) -> std::path::PathBuf { + dunce::simplified(&path.canonicalize().expect("canonical")).to_path_buf() +} + +#[test] +fn percent_sequences_decode_before_validation() { + assert_eq!(decode_path_param("%2e%2e/x"), "../x"); + assert_eq!(decode_path_param("plain.txt"), "plain.txt"); + // An invalid sequence is not an encoding; the literal survives. + assert_eq!(decode_path_param("100%.txt"), "100%.txt"); +} + +/// A double-encoded traversal (`%252e%252e` in the raw query) survives +/// the query layer's single decode as `%2e%2e`; the handler's explicit +/// decode must still reveal it to the lexical `..` check. +#[tokio::test] +async fn an_encoded_traversal_in_the_query_is_rejected() { + let (workspace, _dir) = granted_dir(); + let router = routes(workspace); + for uri in [ + "/workspace/file?path=%252e%252e%2Fsecret.txt", + "/workspace/tree?path=%252e%252e", + ] { + let request = Request::builder() + .uri(uri) + .body(Body::empty()) + .expect("static request parts are valid"); + let response = router + .clone() + .oneshot(request) + .await + .expect("the router is infallible"); + assert_eq!(response.status(), StatusCode::FORBIDDEN, "for {uri}"); + let body = body_bytes(response).await; + let json: serde_json::Value = serde_json::from_slice(&body).expect("the envelope is JSON"); + assert_eq!( + json["error"]["code"], "forbidden_component", + "the traversal must be caught lexically, not by a lookup miss: {uri}" + ); + } +} + +/// Builds a `POST /workspace/revoke` request with a raw JSON body. +fn revoke_request(body: String) -> Request { + Request::builder() + .method("POST") + .uri("/workspace/revoke") + .header(axum::http::header::CONTENT_TYPE, "application/json") + .body(Body::from(body)) + .expect("static request parts are valid") +} + +#[tokio::test] +async fn a_revoke_over_http_removes_the_root() { + let (workspace, dir) = granted_dir(); + let router = routes(workspace.clone()); + let root = simplified(dir.path()); + let body = serde_json::json!({ "path": root }).to_string(); + let response = router + .oneshot(revoke_request(body)) + .await + .expect("the router is infallible"); + assert_eq!(response.status(), StatusCode::OK); + let bytes = body_bytes(response).await; + let json: serde_json::Value = serde_json::from_slice(&bytes).expect("the body is JSON"); + assert_eq!(json["revoked"], serde_json::json!(root)); + assert_eq!(workspace.granted_roots(), Vec::::new()); +} + +#[tokio::test] +async fn an_unknown_root_revoke_answers_not_found() { + let (workspace, _dir) = granted_dir(); + let outside = tempfile::TempDir::new().expect("outside tempdir"); + let router = routes(workspace); + let body = serde_json::json!({ "path": outside.path() }).to_string(); + let response = router + .oneshot(revoke_request(body)) + .await + .expect("the router is infallible"); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + let bytes = body_bytes(response).await; + let json: serde_json::Value = serde_json::from_slice(&bytes).expect("the body is JSON"); + assert_eq!(json["error"]["code"], "not_granted"); +} + +#[tokio::test] +async fn a_malformed_revoke_body_answers_bad_request() { + let (workspace, _dir) = granted_dir(); + let router = routes(workspace); + let response = router + .oneshot(revoke_request("{".to_owned())) + .await + .expect("the router is infallible"); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); +} diff --git a/crates/workshop-workspace/src/lib.rs b/crates/workshop-workspace/src/lib.rs new file mode 100644 index 000000000..2d42eeab8 --- /dev/null +++ b/crates/workshop-workspace/src/lib.rs @@ -0,0 +1,61 @@ +//! workshop-workspace - the workspace subsystem: confined filesystem +//! access behind `/workspace/*` - directory trees, file reads, and file +//! writes jailed to roots explicitly granted through drag and drop. +//! +//! ## Invariants +//! +//! - Tier: feature; may depend on: `workshop-protocol`, +//! `workshop-registry`, `workshop-support`, and the service crates. +//! Read `AGENTS.md` before adding an import. +//! - Every file in this crate stays under 500 lines; split first, then +//! edit. +//! - Every request path is checked lexically (no `..`, and on Windows no +//! NTFS alternate data stream names) and then canonicalized and +//! prefix-matched against the canonical grants before any filesystem +//! operation, so traversal, symlink escapes, and UNC aliases cannot +//! reach outside a grant. +//! - Grants live in memory for the running process only - profile +//! persistence is a separate future consent decision. +//! - The crate maps its own [`WorkspaceError`] to the wire envelope at +//! its route boundary; no shell error type appears here. + +mod error; +mod handlers; +mod workspace; + +use std::sync::Arc; + +pub use error::WorkspaceError; +pub use handlers::routes; +use workshop_registry::{ + Registration, Registry, RouteRegistrar, RouteRegistrarAdapter, WorkspaceRoots, + WorkspaceRootsAdapter, +}; +pub use workspace::{EntryKind, FileContents, TreeEntry, TreeListing, Workspace}; + +/// Registers the workspace subsystem into the registry: its +/// `/workspace/*` routes, merged into the shell's API router, and its +/// granted-roots state handle, which same-tier subsystems read instead +/// of naming this crate. The returned guards keep the registrations +/// alive; the composition root holds them for the process lifetime. +pub fn register( + registry: &Registry, + workspace: &Workspace, +) -> ( + Registration, + Registration, +) { + let routes = registry + .workspace_routes() + .register(Arc::new(RouteRegistrarAdapter::new({ + let workspace = workspace.clone(); + move || handlers::routes(workspace.clone()) + }))); + let roots = registry + .workspace_roots() + .register(Arc::new(WorkspaceRootsAdapter::new({ + let workspace = workspace.clone(); + move || workspace.granted_roots() + }))); + (routes, roots) +} diff --git a/crates/workshop-workspace/src/workspace.rs b/crates/workshop-workspace/src/workspace.rs new file mode 100644 index 000000000..c142d8ad1 --- /dev/null +++ b/crates/workshop-workspace/src/workspace.rs @@ -0,0 +1,477 @@ +//! Confined workspace filesystem access: directory trees, file reads, and +//! file writes jailed to roots explicitly granted through drag and drop. +//! +//! A dropped folder becomes a granted root; a dropped file grants its parent +//! directory. Grants live in memory for the running process only - profile +//! persistence is a separate future consent decision. Every request path is +//! checked lexically (no `..`, and on Windows no NTFS alternate data +//! stream names) and then +//! canonicalized and prefix-matched against the canonical grants before any +//! filesystem operation, so traversal, symlink escapes, and UNC aliases +//! cannot reach outside a grant. This is the same jail shape as the +//! gateway's artifact-cache `confine.rs`, with canonicalization performing +//! the resolution that module's component walk performs by hand. + +use std::collections::BTreeSet; +use std::fs; +use std::hash::{DefaultHasher, Hash, Hasher}; +use std::io; +use std::path::{Component, Path, PathBuf}; +use std::sync::{Arc, PoisonError, RwLock}; +use std::time::UNIX_EPOCH; + +use serde::Serialize; + +use crate::error::WorkspaceError; + +/// The largest file the workspace reads or accepts for a write: the editor +/// targets source text, not media, so one MiB is generous. +const MAX_FILE_BYTES: u64 = 1024 * 1024; + +/// Whether a tree entry is a directory or a regular file. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum EntryKind { + /// A directory. + Directory, + /// A regular file. + File, +} + +/// One entry in a directory listing. +#[derive(Debug, Serialize)] +pub struct TreeEntry { + /// The entry's file name (lossy for non-Unicode names). + name: String, + /// The entry's full path, ready to pass back to the API. + path: PathBuf, + /// Directory or file. + kind: EntryKind, + /// Byte length (0 for directories). + size: u64, + /// Modification time in milliseconds since the Unix epoch. + modified_ms: u64, + /// Whether the entry is currently on disk. Directory listings only + /// enumerate what exists, so their entries are always `true`; a + /// granted root deleted from disk lists as `false` so the panel can + /// flag it for cleanup. + exists: bool, +} + +/// One level of a workspace directory tree. +#[derive(Debug, Serialize)] +pub struct TreeListing { + /// The listed directory; `None` when the listing is the granted roots. + path: Option, + /// Directories before files, each group ordered by name. + entries: Vec, +} + +/// A file's text plus the metadata a writer needs to detect conflicts. +#[derive(Debug, Serialize)] +pub struct FileContents { + /// The canonical file path. + path: PathBuf, + /// Byte length. + size: u64, + /// The opaque conflict token a writer must echo back as + /// `expected_token`; see [`file_token`] for its derivation. + token: String, + /// The file's UTF-8 text. + text: String, +} + +/// The in-memory set of granted workspace roots. +/// +/// Cloning shares the same grant set, so the router state and every handler +/// see grants registered through `POST /workspace/grant` immediately. +#[derive(Debug, Clone, Default)] +pub struct Workspace { + grants: Arc>>, +} + +impl Workspace { + /// Creates a workspace with no grants. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Registers `path` as a granted root: a directory grants itself, a + /// file grants its parent directory. + /// + /// # Errors + /// Returns [`WorkspaceError::ForbiddenComponent`] when the path carries + /// a `..` or stream name, [`WorkspaceError::ResolveGrant`] when it + /// cannot be canonicalized, and [`WorkspaceError::NotFound`] when a + /// file path has no parent directory. + pub fn grant(&self, path: &Path) -> Result { + reject_forbidden(path)?; + let canonical = canonicalize_simplified(path) + .map_err(|source| WorkspaceError::ResolveGrant { source })?; + let root = if canonical.is_dir() { + canonical + } else { + canonical + .parent() + .map(Path::to_owned) + .ok_or(WorkspaceError::NotFound)? + }; + self.grants + .write() + .unwrap_or_else(PoisonError::into_inner) + .insert(root.clone()); + Ok(root) + } + + /// Removes `path` from the granted roots by exact canonical match. + /// A root deleted from disk stays revocable by the literal stored + /// key. Nested grants are independent: revoking a parent leaves a + /// separately granted child intact, and files under the child stay + /// reachable while everything else under the parent loses access on + /// its next operation. + /// + /// # Errors + /// Returns [`WorkspaceError::ForbiddenComponent`] when the path carries + /// a `..` or stream name, [`WorkspaceError::ResolveGrant`] when + /// canonicalization fails for a reason other than absence, and + /// [`WorkspaceError::NotGranted`] when the resolved path is not a + /// granted root. + pub fn revoke(&self, path: &Path) -> Result { + reject_forbidden(path)?; + // A root deleted from disk no longer canonicalizes, but its grant + // must stay removable: fall back to the literal path, which matches + // the stored canonical key the roots listing handed the client. + let canonical = match canonicalize_simplified(path) { + Ok(canonical) => canonical, + Err(source) if source.kind() == io::ErrorKind::NotFound => path.to_path_buf(), + Err(source) => return Err(WorkspaceError::ResolveGrant { source }), + }; + let removed = self + .grants + .write() + .unwrap_or_else(PoisonError::into_inner) + .remove(&canonical); + if removed { + Ok(canonical) + } else { + Err(WorkspaceError::NotGranted) + } + } + + /// The granted roots in stable sorted order. + #[must_use] + pub fn granted_roots(&self) -> Vec { + self.grants + .read() + .unwrap_or_else(PoisonError::into_inner) + .iter() + .cloned() + .collect() + } + + /// Lists one level of `path`, or the granted roots when `path` is + /// `None` or empty. Directories sort before files, each group ordered + /// by name. + /// + /// # Errors + /// Returns [`WorkspaceError`] when the path is forbidden, outside every + /// grant, missing, not a directory, or cannot be listed. + pub fn tree(&self, path: Option<&Path>) -> Result { + match path { + None => Ok(self.grants_listing()), + Some(path) if path.as_os_str().is_empty() => Ok(self.grants_listing()), + Some(path) => self.directory_listing(path), + } + } + + /// Reads a confined UTF-8 text file with its size and conflict + /// token. Binary and oversized files are rejected. + /// + /// # Errors + /// Returns [`WorkspaceError`] when the path is forbidden, outside every + /// grant, missing, not a regular file, binary, not UTF-8, oversized, or + /// cannot be read. + pub fn read_file(&self, path: &Path) -> Result { + let canonical = self.confine_existing(path)?; + let metadata = + fs::metadata(&canonical).map_err(|source| WorkspaceError::InspectPath { source })?; + if !metadata.is_file() { + return Err(WorkspaceError::NotAFile); + } + if metadata.len() > MAX_FILE_BYTES { + return Err(WorkspaceError::FileTooLarge { + limit: MAX_FILE_BYTES, + }); + } + let bytes = fs::read(&canonical).map_err(|source| WorkspaceError::ReadFile { source })?; + if bytes.contains(&0) { + return Err(WorkspaceError::BinaryFile); + } + let token = file_token(&metadata, &bytes); + let text = String::from_utf8(bytes).map_err(|_| WorkspaceError::NotUtf8)?; + Ok(FileContents { + path: canonical, + size: metadata.len(), + token, + text, + }) + } + + /// Writes `text` to a confined path, creating the file when it does not + /// exist. When the file exists, `expected_token` must match its current + /// conflict token or the write is refused as a conflict. + /// + /// # Errors + /// Returns [`WorkspaceError::FileTooLarge`] when the text exceeds the + /// size limit, [`WorkspaceError::ModifiedConflict`] when the token is + /// stale, absent, or underivable for the existing file, and otherwise + /// [`WorkspaceError`] when the path is forbidden, outside every grant, + /// not a regular file, or cannot be written. + pub fn write_file( + &self, + path: &Path, + text: &str, + expected_token: Option<&str>, + ) -> Result { + if text.len() as u64 > MAX_FILE_BYTES { + return Err(WorkspaceError::FileTooLarge { + limit: MAX_FILE_BYTES, + }); + } + let canonical = self.confine_for_write(path)?; + match fs::metadata(&canonical) { + Ok(metadata) => { + if !metadata.is_file() { + return Err(WorkspaceError::NotAFile); + } + // Fail closed: only a derivable on-disk token that equals + // the writer's token proves the file is unchanged. + match (current_token(&canonical, &metadata), expected_token) { + (Some(current), Some(expected)) if current == expected => {} + _ => return Err(WorkspaceError::ModifiedConflict), + } + } + Err(source) if source.kind() == io::ErrorKind::NotFound => {} + Err(source) => return Err(WorkspaceError::InspectPath { source }), + } + workshop_support::write_atomic(&canonical, text.as_bytes()) + .map_err(|source| WorkspaceError::WriteFile { source })?; + let metadata = + fs::metadata(&canonical).map_err(|source| WorkspaceError::InspectPath { source })?; + Ok(FileContents { + path: canonical, + size: metadata.len(), + token: file_token(&metadata, text.as_bytes()), + text: text.to_owned(), + }) + } + + /// The granted roots rendered as a synthetic directory listing. + fn grants_listing(&self) -> TreeListing { + let entries = self + .granted_roots() + .into_iter() + .map(|root| { + let metadata = fs::metadata(&root).ok(); + // The folder's own name reads better than the full path in + // the tree; the path stays available as the row tooltip. A + // drive root (C:\) has no file name and shows the path. + let name = root.file_name().map_or_else( + || root.to_string_lossy().into_owned(), + |name| name.to_string_lossy().into_owned(), + ); + TreeEntry { + name, + path: root, + kind: EntryKind::Directory, + size: 0, + modified_ms: metadata.as_ref().map_or(0, modified_ms), + exists: metadata.is_some(), + } + }) + .collect(); + TreeListing { + path: None, + entries, + } + } + + /// Lists one level of an existing confined directory. + fn directory_listing(&self, path: &Path) -> Result { + let canonical = self.confine_existing(path)?; + let metadata = + fs::metadata(&canonical).map_err(|source| WorkspaceError::InspectPath { source })?; + if !metadata.is_dir() { + return Err(WorkspaceError::NotADirectory); + } + let mut entries = Vec::new(); + for entry in + fs::read_dir(&canonical).map_err(|source| WorkspaceError::ListDirectory { source })? + { + let entry = entry.map_err(|source| WorkspaceError::ListDirectory { source })?; + let metadata = entry + .metadata() + .map_err(|source| WorkspaceError::InspectPath { source })?; + let kind = if metadata.is_dir() { + EntryKind::Directory + } else { + EntryKind::File + }; + entries.push(TreeEntry { + name: entry.file_name().to_string_lossy().into_owned(), + path: entry.path(), + kind, + size: if metadata.is_file() { + metadata.len() + } else { + 0 + }, + modified_ms: modified_ms(&metadata), + exists: true, + }); + } + entries.sort_by(|a, b| { + (a.kind != EntryKind::Directory) + .cmp(&(b.kind != EntryKind::Directory)) + .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())) + .then_with(|| a.name.cmp(&b.name)) + }); + Ok(TreeListing { + path: Some(canonical), + entries, + }) + } + + /// Canonicalizes an existing path and confines it to the grants. + fn confine_existing(&self, path: &Path) -> Result { + reject_forbidden(path)?; + let canonical = canonicalize_simplified(path).map_err(|source| { + if source.kind() == io::ErrorKind::NotFound { + WorkspaceError::NotFound + } else { + WorkspaceError::ResolvePath { source } + } + })?; + self.check_confined(canonical) + } + + /// Confines a write target: an existing path canonicalizes directly; a + /// new file confines its canonicalized parent and reattaches its name. + fn confine_for_write(&self, path: &Path) -> Result { + reject_forbidden(path)?; + match canonicalize_simplified(path) { + Ok(canonical) => self.check_confined(canonical), + Err(source) if source.kind() == io::ErrorKind::NotFound => { + // A dangling symlink canonicalizes as NotFound, but fs::write + // would follow it and create the target outside the grant. + match fs::symlink_metadata(path) { + Ok(_) => return Err(WorkspaceError::OutsideGrants), + Err(source) if source.kind() == io::ErrorKind::NotFound => {} + Err(source) => return Err(WorkspaceError::InspectPath { source }), + } + let parent = path.parent().ok_or(WorkspaceError::NotFound)?; + let canonical_parent = canonicalize_simplified(parent).map_err(|source| { + if source.kind() == io::ErrorKind::NotFound { + WorkspaceError::NotFound + } else { + WorkspaceError::ResolvePath { source } + } + })?; + let name = path.file_name().ok_or(WorkspaceError::ForbiddenComponent)?; + self.check_confined(canonical_parent.join(name)) + } + Err(source) => Err(WorkspaceError::ResolvePath { source }), + } + } + + /// Admits a canonical path that starts with a granted root. + fn check_confined(&self, canonical: PathBuf) -> Result { + let grants = self.grants.read().unwrap_or_else(PoisonError::into_inner); + if grants.iter().any(|root| canonical.starts_with(root)) { + Ok(canonical) + } else { + Err(WorkspaceError::OutsideGrants) + } + } +} + +/// Canonicalizes and strips Windows' `\\?\` verbatim prefix (a no-op on +/// other platforms). Every path the workspace stores, compares, or returns +/// goes through here, so grants and confinement checks stay in one form +/// and the UI never sees the prefix. +fn canonicalize_simplified(path: &Path) -> io::Result { + Ok(dunce::simplified(&path.canonicalize()?).to_path_buf()) +} + +/// Rejects the lexical tricks canonicalization would otherwise hide: `..` +/// traversal everywhere, and `:` alternate data stream names on Windows, +/// where a colon in a name addresses an NTFS stream. Elsewhere a colon is +/// an ordinary filename character and passes. +fn reject_forbidden(path: &Path) -> Result<(), WorkspaceError> { + for component in path.components() { + match component { + Component::ParentDir => return Err(WorkspaceError::ForbiddenComponent), + #[cfg(windows)] + Component::Normal(name) if name.to_string_lossy().contains(':') => { + return Err(WorkspaceError::ForbiddenComponent); + } + _ => {} + } + } + Ok(()) +} + +/// A file's modification time as milliseconds since the Unix epoch. +fn modified_ms(metadata: &fs::Metadata) -> u64 { + metadata + .modified() + .ok() + .and_then(|time| time.duration_since(UNIX_EPOCH).ok()) + .map_or(0, |duration| { + u64::try_from(duration.as_millis()).unwrap_or(u64::MAX) + }) +} + +/// The mtime half of the conflict token: full-precision modified time in +/// nanoseconds since the Unix epoch plus the byte length. `None` when the +/// filesystem reports no usable modified time, which callers cover with +/// [`hash_token`] - collapsing the error to a constant would make every +/// token on such a filesystem equal and no write would ever conflict. +fn mtime_token(metadata: &fs::Metadata) -> Option { + let duration = metadata.modified().ok()?.duration_since(UNIX_EPOCH).ok()?; + Some(format!("{}-{}", duration.as_nanos(), metadata.len())) +} + +/// The content-hash fallback token for filesystems without modified times. +/// `DefaultHasher` is stable within one process run, which is all a token +/// needs: a restart invalidates outstanding tokens toward conflict, never +/// toward a silent overwrite. +fn hash_token(contents: &[u8]) -> String { + let mut hasher = DefaultHasher::new(); + contents.hash(&mut hasher); + format!("h-{:016x}", hasher.finish()) +} + +/// A file's opaque conflict token from its metadata and already-read +/// contents: the mtime form when available, otherwise the hash form. +fn file_token(metadata: &fs::Metadata, contents: &[u8]) -> String { + mtime_token(metadata).unwrap_or_else(|| hash_token(contents)) +} + +/// The current on-disk token of an existing write target, reading the file +/// only when the hash fallback demands it. `None` means no token could be +/// derived - an unreadable or oversized file - and the caller must refuse +/// the write rather than overwrite unverified contents. +fn current_token(path: &Path, metadata: &fs::Metadata) -> Option { + if let Some(token) = mtime_token(metadata) { + return Some(token); + } + if metadata.len() > MAX_FILE_BYTES { + return None; + } + fs::read(path).ok().map(|bytes| hash_token(&bytes)) +} + +#[cfg(test)] +mod tests; diff --git a/crates/workshop-workspace/src/workspace/tests.rs b/crates/workshop-workspace/src/workspace/tests.rs new file mode 100644 index 000000000..dde3b8436 --- /dev/null +++ b/crates/workshop-workspace/src/workspace/tests.rs @@ -0,0 +1,464 @@ +use super::*; + +/// A workspace with one granted tempdir, returned alongside so the +/// directory outlives the test. +fn granted_dir() -> (Workspace, tempfile::TempDir) { + let dir = tempfile::TempDir::new().expect("tempdir"); + let workspace = Workspace::new(); + workspace.grant(dir.path()).expect("grant the tempdir"); + (workspace, dir) +} + +/// The canonical, verbatim-prefix-free form grants are stored in. +fn simplified(path: &Path) -> PathBuf { + canonicalize_simplified(path).expect("canonical") +} + +#[test] +fn a_folder_grant_grants_the_folder_itself() { + let workspace = Workspace::new(); + let dir = tempfile::TempDir::new().expect("tempdir"); + let granted = workspace.grant(dir.path()).expect("grant succeeds"); + assert_eq!(granted, simplified(dir.path())); + assert_eq!(workspace.granted_roots(), vec![granted]); +} + +#[test] +fn a_file_grant_grants_the_parent_directory() { + let workspace = Workspace::new(); + let dir = tempfile::TempDir::new().expect("tempdir"); + let file = dir.path().join("dropped.txt"); + fs::write(&file, "x").expect("seed the dropped file"); + let granted = workspace.grant(&file).expect("grant succeeds"); + assert_eq!(granted, simplified(dir.path())); + assert_eq!(workspace.granted_roots(), vec![granted]); +} + +#[test] +fn files_read_and_write_inside_a_grant() { + let (workspace, dir) = granted_dir(); + let file = dir.path().join("notes.txt"); + let written = workspace + .write_file(&file, "hello", None) + .expect("write inside the grant"); + assert_eq!(written.text, "hello"); + assert_eq!(written.size, 5); + let read = workspace.read_file(&file).expect("read inside the grant"); + assert_eq!(read.text, "hello"); + assert_eq!(read.size, 5); + assert_eq!(read.token, written.token); +} + +#[test] +fn writes_leave_no_temp_file_behind() { + let (workspace, dir) = granted_dir(); + let file = dir.path().join("notes.txt"); + let written = workspace + .write_file(&file, "one", None) + .expect("the create write succeeds"); + workspace + .write_file(&file, "two", Some(&written.token)) + .expect("the overwrite succeeds"); + let names: Vec = fs::read_dir(dir.path()) + .expect("the granted directory is listable") + .map(|entry| { + entry + .expect("the entry is readable") + .file_name() + .to_string_lossy() + .into_owned() + }) + .collect(); + assert_eq!( + names, + ["notes.txt"], + "the atomic write's temp file must not survive the write" + ); +} + +#[test] +fn paths_outside_every_grant_are_rejected() { + let workspace = Workspace::new(); + let dir = tempfile::TempDir::new().expect("tempdir"); + fs::write(dir.path().join("a.txt"), "a").expect("seed outside the grants"); + let error = workspace + .read_file(&dir.path().join("a.txt")) + .expect_err("an ungranted path must be rejected"); + assert!( + matches!(error, WorkspaceError::OutsideGrants), + "expected OutsideGrants, got {error:?}" + ); +} + +#[test] +fn parent_components_are_rejected() { + let (workspace, dir) = granted_dir(); + let escape = dir.path().join("..").join("anything.txt"); + let error = workspace + .read_file(&escape) + .expect_err("a .. component must be rejected"); + assert!( + matches!(error, WorkspaceError::ForbiddenComponent), + "expected ForbiddenComponent, got {error:?}" + ); +} + +#[cfg(windows)] +#[test] +fn alternate_data_stream_names_are_rejected() { + let (workspace, dir) = granted_dir(); + let stream = dir.path().join("notes.txt:secret"); + let error = workspace + .write_file(&stream, "hidden", None) + .expect_err("an alternate data stream name must be rejected"); + assert!( + matches!(error, WorkspaceError::ForbiddenComponent), + "expected ForbiddenComponent, got {error:?}" + ); +} + +#[test] +fn a_symlink_escape_is_rejected() { + let (workspace, dir) = granted_dir(); + let outside = tempfile::TempDir::new().expect("outside tempdir"); + fs::write(outside.path().join("secret.txt"), "secret").expect("seed the secret"); + let link = dir.path().join("link"); + #[cfg(unix)] + let linked = std::os::unix::fs::symlink(outside.path(), &link); + #[cfg(windows)] + let linked = std::os::windows::fs::symlink_dir(outside.path(), &link); + let Ok(()) = linked else { + // Symlink creation needs a privilege some Windows hosts lack. + eprintln!("skipping: symlink creation failed"); + return; + }; + let error = workspace + .read_file(&link.join("secret.txt")) + .expect_err("a symlink escape must be rejected"); + assert!( + matches!(error, WorkspaceError::OutsideGrants), + "expected OutsideGrants, got {error:?}" + ); +} + +#[test] +fn a_dangling_symlink_write_is_rejected() { + let (workspace, dir) = granted_dir(); + let outside = tempfile::TempDir::new().expect("outside tempdir"); + let target = outside.path().join("new.txt"); + let link = dir.path().join("link.txt"); + #[cfg(unix)] + let linked = std::os::unix::fs::symlink(&target, &link); + #[cfg(windows)] + let linked = std::os::windows::fs::symlink_file(&target, &link); + let Ok(()) = linked else { + // Symlink creation needs a privilege some Windows hosts lack. + eprintln!("skipping: symlink creation failed"); + return; + }; + let error = workspace + .write_file(&link, "payload", None) + .expect_err("a write through a dangling symlink must be rejected"); + assert!( + matches!(error, WorkspaceError::OutsideGrants), + "expected OutsideGrants, got {error:?}" + ); + assert!(!target.exists(), "nothing may be written outside the grant"); +} + +#[test] +fn binary_files_are_rejected() { + let (workspace, dir) = granted_dir(); + let file = dir.path().join("bin.dat"); + fs::write(&file, [0x66, 0x00, 0x66]).expect("seed a binary file"); + let error = workspace + .read_file(&file) + .expect_err("a binary file must be rejected"); + assert!( + matches!(error, WorkspaceError::BinaryFile), + "expected BinaryFile, got {error:?}" + ); +} + +#[test] +fn oversized_files_are_rejected() { + let (workspace, dir) = granted_dir(); + let file = dir.path().join("big.txt"); + let big = vec![b'x'; usize::try_from(MAX_FILE_BYTES).expect("the limit fits") + 1]; + fs::write(&file, big).expect("seed an oversized file"); + let error = workspace + .read_file(&file) + .expect_err("an oversized file must be rejected"); + assert!( + matches!(error, WorkspaceError::FileTooLarge { .. }), + "expected FileTooLarge, got {error:?}" + ); +} + +#[test] +fn oversized_writes_are_rejected() { + let (workspace, dir) = granted_dir(); + let text = "x".repeat(usize::try_from(MAX_FILE_BYTES).expect("the limit fits") + 1); + let error = workspace + .write_file(&dir.path().join("big.txt"), &text, None) + .expect_err("an oversized write must be rejected"); + assert!( + matches!(error, WorkspaceError::FileTooLarge { .. }), + "expected FileTooLarge, got {error:?}" + ); +} + +#[test] +fn a_stale_modified_token_conflicts() { + let (workspace, dir) = granted_dir(); + let file = dir.path().join("a.txt"); + let written = workspace + .write_file(&file, "one", None) + .expect("initial write"); + let stale = format!("{}-stale", written.token); + let error = workspace + .write_file(&file, "two", Some(&stale)) + .expect_err("a stale token must conflict"); + assert!( + matches!(error, WorkspaceError::ModifiedConflict), + "expected ModifiedConflict, got {error:?}" + ); + let rewritten = workspace + .write_file(&file, "two", Some(&written.token)) + .expect("the fresh token writes"); + assert_eq!(rewritten.text, "two"); +} + +#[test] +fn a_tokenless_write_to_an_existing_file_conflicts() { + let (workspace, dir) = granted_dir(); + let file = dir.path().join("a.txt"); + workspace + .write_file(&file, "one", None) + .expect("initial write"); + let error = workspace + .write_file(&file, "two", None) + .expect_err("a write with no token over an existing file must conflict"); + assert!( + matches!(error, WorkspaceError::ModifiedConflict), + "expected ModifiedConflict, got {error:?}" + ); +} + +#[test] +fn the_token_tracks_mtime_and_length_not_write_count() { + let (workspace, dir) = granted_dir(); + let file = dir.path().join("t.txt"); + let written = workspace.write_file(&file, "one", None).expect("write"); + let metadata = fs::metadata(&file).expect("metadata"); + // The token is a pure function of full-precision mtime plus length: + // a same-content rewrite changes it exactly when the filesystem + // reports a new mtime or length, and never otherwise. + assert_eq!(Some(written.token.clone()), mtime_token(&metadata)); + let rewritten = workspace + .write_file(&file, "one", Some(&written.token)) + .expect("same-content rewrite"); + let metadata = fs::metadata(&file).expect("metadata after rewrite"); + assert_eq!(Some(rewritten.token), mtime_token(&metadata)); + // Reading without a write in between re-derives the same token. + let reread = workspace.read_file(&file).expect("read"); + let again = workspace.read_file(&file).expect("second read"); + assert_eq!(reread.token, again.token); +} + +#[test] +fn the_hash_fallback_token_round_trips() { + let token = hash_token(b"same contents"); + assert_eq!( + token, + hash_token(b"same contents"), + "the fallback token must be stable for identical contents" + ); + assert!(token.starts_with("h-"), "got {token}"); + assert_ne!(token, hash_token(b"different contents")); +} + +#[cfg(unix)] +#[test] +fn colon_named_files_read_and_write_on_unix() { + let (workspace, dir) = granted_dir(); + let file = dir.path().join("backup-12:30.log"); + let written = workspace + .write_file(&file, "ok", None) + .expect("a colon-named file writes on unix"); + let read = workspace + .read_file(&file) + .expect("a colon-named file reads on unix"); + assert_eq!(read.text, "ok"); + assert_eq!(read.token, written.token); +} + +#[test] +fn tree_lists_directories_before_files_with_stable_ordering() { + let (workspace, dir) = granted_dir(); + fs::create_dir(dir.path().join("zeta")).expect("dir"); + fs::create_dir(dir.path().join("alpha")).expect("dir"); + fs::write(dir.path().join("b.txt"), "b").expect("file"); + fs::write(dir.path().join("a.txt"), "a").expect("file"); + let listing = workspace.tree(Some(dir.path())).expect("tree"); + let names: Vec<&str> = listing + .entries + .iter() + .map(|entry| entry.name.as_str()) + .collect(); + assert_eq!(names, ["alpha", "zeta", "a.txt", "b.txt"]); + assert_eq!(listing.entries[0].kind, EntryKind::Directory); + assert_eq!(listing.entries[3].kind, EntryKind::File); + assert!( + listing.entries.iter().all(|entry| entry.exists), + "an enumerated directory entry is on disk by construction" + ); +} + +#[test] +fn a_tree_without_a_path_lists_the_granted_roots() { + let (workspace, dir) = granted_dir(); + let listing = workspace.tree(None).expect("roots listing"); + assert_eq!(listing.path, None); + assert_eq!(listing.entries.len(), 1); + let root = simplified(dir.path()); + assert_eq!(listing.entries[0].path, root); + // A root row shows the folder's own name, not the whole path. + assert_eq!( + listing.entries[0].name, + root.file_name().expect("leaf").to_string_lossy() + ); + assert_eq!(listing.entries[0].kind, EntryKind::Directory); + assert!(listing.entries[0].exists, "a live root lists as existing"); +} + +#[test] +fn the_roots_listing_flags_a_deleted_root_as_missing() { + let workspace = Workspace::new(); + let kept = tempfile::TempDir::new().expect("tempdir"); + let doomed = tempfile::TempDir::new().expect("tempdir"); + let kept_root = workspace.grant(kept.path()).expect("grant the kept root"); + let doomed_root = workspace + .grant(doomed.path()) + .expect("grant the doomed root"); + doomed.close().expect("delete the doomed directory"); + let listing = workspace.tree(None).expect("roots listing"); + assert_eq!(listing.entries.len(), 2); + for entry in &listing.entries { + if entry.path == doomed_root { + assert!(!entry.exists, "the deleted root must list as missing"); + } else { + assert_eq!(entry.path, kept_root); + assert!(entry.exists, "the live root must list as existing"); + } + } +} + +#[test] +fn a_revoke_removes_the_granted_root() { + let (workspace, dir) = granted_dir(); + let revoked = workspace.revoke(dir.path()).expect("revoke the grant"); + assert_eq!(revoked, simplified(dir.path())); + assert_eq!(workspace.granted_roots(), Vec::::new()); +} + +#[test] +fn revoking_an_unknown_root_errors() { + let workspace = Workspace::new(); + let dir = tempfile::TempDir::new().expect("tempdir"); + let error = workspace + .revoke(dir.path()) + .expect_err("an ungranted root must not revoke"); + assert!( + matches!(error, WorkspaceError::NotGranted), + "expected NotGranted, got {error:?}" + ); +} + +#[test] +fn a_deleted_root_can_still_be_revoked() { + let (workspace, dir) = granted_dir(); + let root = simplified(dir.path()); + dir.close().expect("delete the granted directory"); + let revoked = workspace.revoke(&root).expect("revoke the deleted root"); + assert_eq!(revoked, root); + assert_eq!(workspace.granted_roots(), Vec::::new()); +} + +#[test] +fn a_spelling_variant_revokes_the_same_root() { + let (workspace, dir) = granted_dir(); + let variant = PathBuf::from(format!( + "{}{}", + dir.path().display(), + std::path::MAIN_SEPARATOR + )); + let revoked = workspace + .revoke(&variant) + .expect("a trailing separator names the same root"); + assert_eq!(revoked, simplified(dir.path())); + assert_eq!(workspace.granted_roots(), Vec::::new()); +} + +#[test] +fn reads_and_writes_under_a_revoked_root_are_rejected() { + let (workspace, dir) = granted_dir(); + let file = dir.path().join("notes.txt"); + let written = workspace + .write_file(&file, "hello", None) + .expect("write before the revoke"); + workspace.revoke(dir.path()).expect("revoke the grant"); + let error = workspace + .read_file(&file) + .expect_err("a read under a revoked root must be rejected"); + assert!( + matches!(error, WorkspaceError::OutsideGrants), + "expected OutsideGrants, got {error:?}" + ); + let error = workspace + .write_file(&file, "later", Some(&written.token)) + .expect_err("a write under a revoked root must be rejected"); + assert!( + matches!(error, WorkspaceError::OutsideGrants), + "expected OutsideGrants, got {error:?}" + ); +} + +#[test] +fn a_nested_grant_survives_its_parents_revoke() { + let workspace = Workspace::new(); + let parent = tempfile::TempDir::new().expect("tempdir"); + let child = parent.path().join("child"); + fs::create_dir(&child).expect("create the nested directory"); + fs::write(parent.path().join("outer.txt"), "outer").expect("seed the parent"); + fs::write(child.join("inner.txt"), "inner").expect("seed the child"); + workspace.grant(parent.path()).expect("grant the parent"); + workspace.grant(&child).expect("grant the child"); + workspace.revoke(parent.path()).expect("revoke the parent"); + assert_eq!(workspace.granted_roots(), vec![simplified(&child)]); + let read = workspace + .read_file(&child.join("inner.txt")) + .expect("the nested grant stays usable"); + assert_eq!(read.text, "inner"); + let error = workspace + .read_file(&parent.path().join("outer.txt")) + .expect_err("the parent's own files lose access"); + assert!( + matches!(error, WorkspaceError::OutsideGrants), + "expected OutsideGrants, got {error:?}" + ); +} + +#[test] +fn a_tree_of_a_file_is_rejected() { + let (workspace, dir) = granted_dir(); + let file = dir.path().join("a.txt"); + fs::write(&file, "a").expect("seed"); + let error = workspace + .tree(Some(&file)) + .expect_err("a file cannot be listed"); + assert!( + matches!(error, WorkspaceError::NotADirectory), + "expected NotADirectory, got {error:?}" + ); +} diff --git a/crates/workshop-workspace/tests/it/main.rs b/crates/workshop-workspace/tests/it/main.rs new file mode 100644 index 000000000..31f25fbea --- /dev/null +++ b/crates/workshop-workspace/tests/it/main.rs @@ -0,0 +1,73 @@ +//! Integration tests for `workshop-workspace`: the registration +//! contract - routes and the granted-roots handle served through the +//! registry's slots. + +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use tower::ServiceExt as _; +use workshop_registry::Registry; +use workshop_workspace::{Workspace, register}; + +#[tokio::test] +async fn the_registered_routes_serve_the_workspace_api() { + let registry = Registry::new(); + let workspace = Workspace::new(); + let dir = tempfile::TempDir::new().expect("tempdir"); + std::fs::write(dir.path().join("notes.txt"), "hello").expect("seed a file"); + let _guards = register(®istry, &workspace); + + let registrar = registry + .workspace_routes() + .get() + .expect("the routes slot is registered"); + let router = registrar.routes(); + + // The roots listing answers through the registered routes. + let request = Request::builder() + .uri("/workspace/tree") + .body(Body::empty()) + .expect("static request parts are valid"); + let response = router + .clone() + .oneshot(request) + .await + .expect("the router is infallible"); + assert_eq!(response.status(), StatusCode::OK); + + // A grant over HTTP becomes visible through the registered roots + // handle immediately. + let body = serde_json::json!({ "path": dir.path() }).to_string(); + let request = Request::builder() + .method("POST") + .uri("/workspace/grant") + .header(axum::http::header::CONTENT_TYPE, "application/json") + .body(Body::from(body)) + .expect("static request parts are valid"); + let response = router + .oneshot(request) + .await + .expect("the router is infallible"); + assert_eq!(response.status(), StatusCode::OK); + let roots = registry + .workspace_roots() + .get() + .expect("the roots slot is registered") + .granted_roots(); + assert_eq!(roots.len(), 1, "the grant reached the shared state"); + assert!( + roots[0].ends_with(dir.path().file_name().expect("a named tempdir")), + "the granted root is the dropped directory: {roots:?}" + ); +} + +#[tokio::test] +async fn dropping_the_guards_deregisters_both_slots() { + let registry = Registry::new(); + let workspace = Workspace::new(); + let guards = register(®istry, &workspace); + assert!(registry.workspace_routes().get().is_some()); + assert!(registry.workspace_roots().get().is_some()); + drop(guards); + assert!(registry.workspace_routes().get().is_none()); + assert!(registry.workspace_roots().get().is_none()); +} diff --git a/vibe/2026-09-12-3-workshop-server-decomposition.md b/vibe/2026-09-12-3-workshop-server-decomposition.md index d983106d7..792ac7396 100644 --- a/vibe/2026-09-12-3-workshop-server-decomposition.md +++ b/vibe/2026-09-12-3-workshop-server-decomposition.md @@ -431,7 +431,7 @@ Verification: `cargo clippy --all-targets --all-features -- -D warnings` clean f -### Step 5: Extract feature crates and decompose AppState +### Step 5: Extract feature crates and decompose AppState [completed] - Component: Server Decomposition From 6ab28ff8d1ed669925e75e7de38a0ef72672a70c Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Sat, 12 Sep 2026 15:13:33 -0700 Subject: [PATCH 06/19] Split workshop SPA into feature directories with tokens The client app grew as two flat directories mixing unrelated concerns, so its source files now sort into eleven feature directories, each with a barrel module re-exporting its public API. Every workshop-owned style class carries a shared project prefix for namespace isolation, while library and shared-ui classes keep their names. Raw size and shadow values move out of component styles into a three-tier token sheet of base primitives, semantic intent aliases, and per-component slots, so theming edits one layer instead of every component. Imports, tests, and the shipped markup follow the new paths and class names; no logic changes. - `crates/workshop-server/ui/src/ui/agent/index.ts` re-exports the directory's public API through one barrel; the ten sibling feature directories follow the same pattern. - `crates/workshop-server/ui/src/tokens/base.css` is the only stylesheet holding raw values; `crates/workshop-server/ui/src/tokens/semantic.css` maps intent aliases to those primitives and `crates/workshop-server/ui/src/tokens/component.css` assigns per-component slots, so component styles reference only `var(--ws-border-width)`-style tokens. - `crates/workshop-server/ui/src/main.ts` imports the three token sheets at boot ahead of the component tree and repoints every UI import at the new feature paths. - `crates/workshop-server/ui/index.html` carries the renamed `ws-window-titlebar` and `ws-shell` classes, matching the selectors the moved stylesheets and scripts now query. - `is-editor-empty` keeps its name, as do the other library and shared-ui classes; the prefix applies only to workshop-owned classes. - `crates/workshop-server/ui/test/agent-session-view.mjs` and the other test edits swap selectors and import paths for the renamed classes and locations; assertions are unchanged. Design: new facade @ crates/workshop-server/ui/src/ui/agent/index.ts Design: new facade @ crates/workshop-server/ui/src/ui/chrome/index.ts Design: new facade @ crates/workshop-server/ui/src/ui/editor/index.ts Design: new facade @ crates/workshop-server/ui/src/ui/gateway/index.ts Design: new facade @ crates/workshop-server/ui/src/ui/layout/index.ts Design: new facade @ crates/workshop-server/ui/src/ui/menu/index.ts Design: new facade @ crates/workshop-server/ui/src/ui/shared/index.ts Design: new facade @ crates/workshop-server/ui/src/ui/status/index.ts Design: new facade @ crates/workshop-server/ui/src/ui/stt/index.ts Design: new facade @ crates/workshop-server/ui/src/ui/take/index.ts Design: new facade @ crates/workshop-server/ui/src/ui/workspace/index.ts Plan: vibe/2026-09-12-3-workshop-server-decomposition.md --- crates/workshop-server/ui/index.html | 36 +++--- crates/workshop-server/ui/src/main.ts | 28 +++-- crates/workshop-server/ui/src/tokens/base.css | 34 ++++++ .../ui/src/tokens/component.css | 47 ++++++++ .../ui/src/tokens/semantic.css | 22 ++++ .../ui/src/ui/{ => agent}/agent-menu.ts | 16 +-- .../src/ui/{workshop => agent}/agent-panel.ts | 6 +- .../src/ui/{ => agent}/agent-session-view.ts | 40 +++---- .../ui/src/ui/{ => agent}/agent-session.css | 96 ++++++++-------- .../ui/src/ui/{ => agent}/agent-toolbar.css | 6 +- .../ui/src/ui/{ => agent}/agent-toolbar.ts | 10 +- .../workshop-server/ui/src/ui/agent/index.ts | 11 ++ .../ui/src/ui/{ => agent}/markdown-render.css | 78 ++++++------- .../ui/src/ui/{ => agent}/markdown-render.ts | 4 +- .../ui/{workshop => agent}/mention-chip.ts | 8 +- .../ui/src/ui/{ => agent}/mode-chip.css | 12 +- .../ui/src/ui/{ => agent}/mode-chip.ts | 10 +- .../ui/src/ui/{ => agent}/prompt-input.css | 34 +++--- .../ui/src/ui/{ => agent}/prompt-input.ts | 14 +-- .../ui/src/ui/{ => agent}/tool-call-card.css | 58 +++++----- .../ui/src/ui/{ => agent}/tool-call-card.ts | 30 ++--- .../{workshop => agent}/typeahead-popup.css | 18 +-- .../ui/{workshop => agent}/typeahead-popup.ts | 8 +- .../ui/src/ui/{ => chrome}/about-dialog.css | 22 ++-- .../ui/src/ui/{ => chrome}/about-dialog.ts | 20 ++-- .../workshop-server/ui/src/ui/chrome/index.ts | 7 ++ .../ui/{ => chrome}/model-picker-trigger.css | 14 +-- .../ui/{ => chrome}/model-picker-trigger.ts | 10 +- .../ui/src/ui/{ => chrome}/token-ring.css | 10 +- .../ui/src/ui/{ => chrome}/token-ring.ts | 8 +- .../ui/src/ui/{ => chrome}/update-view.css | 46 ++++---- .../ui/src/ui/{ => chrome}/update-view.ts | 14 +-- .../ui/src/ui/{ => chrome}/window-chrome.css | 30 ++--- .../ui/src/ui/{ => chrome}/window-chrome.ts | 14 +-- .../ui/src/ui/{ => chrome}/zoom.ts | 0 .../ui/{workshop => editor}/editor-dialog.ts | 2 +- .../ui/{workshop => editor}/editor-panel.css | 58 +++++----- .../ui/{workshop => editor}/editor-panel.ts | 10 +- .../ui/{workshop => editor}/editor-surface.ts | 2 +- .../workshop-server/ui/src/ui/editor/index.ts | 4 + .../ui/{ => gateway}/gateway-config-bridge.ts | 4 +- .../gateway-config-panel.css | 4 +- .../gateway-config-panel.ts | 4 +- .../ui/src/ui/gateway/index.ts | 3 + .../workshop-server/ui/src/ui/layout/index.ts | 6 + .../layout-persistence.ts | 0 .../ui/{workshop => layout}/panel-types.ts | 10 +- .../src/ui/{workshop => layout}/shortcuts.ts | 4 +- .../ui/{workshop => layout}/workshop-panel.ts | 40 +++---- .../ui/src/ui/{workshop => layout}/zones.css | 106 +++++++++--------- .../ui/src/ui/{workshop => layout}/zones.ts | 0 .../workshop-server/ui/src/ui/menu/index.ts | 2 + .../ui/src/ui/{ => menu}/window-menu.css | 50 ++++----- .../ui/src/ui/{ => menu}/window-menu.ts | 38 +++---- .../ui/src/ui/{workshop => shared}/icons.ts | 0 .../workshop-server/ui/src/ui/shared/index.ts | 2 + .../workshop-server/ui/src/ui/status/index.ts | 2 + .../ui/src/ui/{ => status}/status-bar.ts | 4 +- crates/workshop-server/ui/src/ui/stt/index.ts | 3 + .../ui/src/ui/{ => stt}/realtime-stt.ts | 10 +- .../ui/src/ui/{ => stt}/stt.css | 14 +-- .../ui/src/ui/{ => stt}/stt.ts | 4 +- .../workshop-server/ui/src/ui/take/index.ts | 5 + .../src/ui/{ => take}/take-registry-events.ts | 2 +- .../src/ui/{ => take}/take-registry-state.ts | 0 .../src/ui/{ => take}/take-registry-types.ts | 4 +- .../ui/src/ui/{ => take}/take-registry.ts | 2 +- .../ui/src/ui/workspace/index.ts | 2 + .../src/ui/{ => workspace}/workspace-drops.ts | 4 +- crates/workshop-server/ui/test/agent-menu.mjs | 14 +-- .../ui/test/agent-session-view.mjs | 100 ++++++++--------- .../ui/test/agent-stt-boot.mjs | 4 +- crates/workshop-server/ui/test/agent-stt.mjs | 14 +-- .../workshop-server/ui/test/agent-toolbar.mjs | 32 +++--- .../ui/test/disposable-adoption.mjs | 12 +- .../workshop-server/ui/test/editor-idioms.mjs | 4 +- .../workshop-server/ui/test/editor-panel.mjs | 30 ++--- .../ui/test/editor-save-race.mjs | 12 +- .../ui/test/gateway-config-bridge.mjs | 8 +- .../ui/test/gateway-config-menu.mjs | 14 +-- .../workshop-server/ui/test/helpers/boot.mjs | 6 +- crates/workshop-server/ui/test/icons.mjs | 4 +- .../ui/test/markdown-render.mjs | 12 +- .../workshop-server/ui/test/mention-chip.mjs | 30 ++--- crates/workshop-server/ui/test/mode-chip.mjs | 26 ++--- .../ui/test/model-picker-trigger.mjs | 14 +-- .../ui/test/model-select-socket-down.mjs | 6 +- .../ui/test/models-push-refresh.mjs | 6 +- .../workshop-server/ui/test/prompt-input.mjs | 16 +-- .../workshop-server/ui/test/shared-modal.mjs | 8 +- crates/workshop-server/ui/test/smoke.mjs | 6 +- crates/workshop-server/ui/test/stt-stream.mjs | 2 +- .../ui/test/take-registry-regressions.mjs | 2 +- .../workshop-server/ui/test/take-registry.mjs | 2 +- .../ui/test/titlebar-browser-mode.mjs | 18 +-- .../ui/test/titlebar-style.mjs | 8 +- crates/workshop-server/ui/test/token-ring.mjs | 20 ++-- .../ui/test/tool-call-card.mjs | 46 ++++---- .../ui/test/typeahead-popup.mjs | 24 ++-- .../workshop-server/ui/test/update-view.mjs | 10 +- .../workshop-server/ui/test/window-chrome.mjs | 16 +-- .../workshop-server/ui/test/window-menu.mjs | 58 +++++----- .../ui/test/workbench-mount.mjs | 22 ++-- .../ui/test/workshop-layout.mjs | 34 +++--- .../ui/test/workshop-panel-menu.mjs | 36 +++--- .../ui/test/workshop-zones.mjs | 22 ++-- .../ui/test/workspace-drops.mjs | 4 +- crates/workshop-server/ui/test/zoom.mjs | 22 ++-- ...6-09-12-3-workshop-server-decomposition.md | 2 +- 109 files changed, 1051 insertions(+), 901 deletions(-) create mode 100644 crates/workshop-server/ui/src/tokens/base.css create mode 100644 crates/workshop-server/ui/src/tokens/component.css create mode 100644 crates/workshop-server/ui/src/tokens/semantic.css rename crates/workshop-server/ui/src/ui/{ => agent}/agent-menu.ts (89%) rename crates/workshop-server/ui/src/ui/{workshop => agent}/agent-panel.ts (92%) rename crates/workshop-server/ui/src/ui/{ => agent}/agent-session-view.ts (92%) rename crates/workshop-server/ui/src/ui/{ => agent}/agent-session.css (74%) rename crates/workshop-server/ui/src/ui/{ => agent}/agent-toolbar.css (62%) rename crates/workshop-server/ui/src/ui/{ => agent}/agent-toolbar.ts (81%) create mode 100644 crates/workshop-server/ui/src/ui/agent/index.ts rename crates/workshop-server/ui/src/ui/{ => agent}/markdown-render.css (62%) rename crates/workshop-server/ui/src/ui/{ => agent}/markdown-render.ts (98%) rename crates/workshop-server/ui/src/ui/{workshop => agent}/mention-chip.ts (95%) rename crates/workshop-server/ui/src/ui/{ => agent}/mode-chip.css (86%) rename crates/workshop-server/ui/src/ui/{ => agent}/mode-chip.ts (94%) rename crates/workshop-server/ui/src/ui/{ => agent}/prompt-input.css (74%) rename crates/workshop-server/ui/src/ui/{ => agent}/prompt-input.ts (96%) rename crates/workshop-server/ui/src/ui/{ => agent}/tool-call-card.css (73%) rename crates/workshop-server/ui/src/ui/{ => agent}/tool-call-card.ts (84%) rename crates/workshop-server/ui/src/ui/{workshop => agent}/typeahead-popup.css (77%) rename crates/workshop-server/ui/src/ui/{workshop => agent}/typeahead-popup.ts (96%) rename crates/workshop-server/ui/src/ui/{ => chrome}/about-dialog.css (68%) rename crates/workshop-server/ui/src/ui/{ => chrome}/about-dialog.ts (91%) create mode 100644 crates/workshop-server/ui/src/ui/chrome/index.ts rename crates/workshop-server/ui/src/ui/{ => chrome}/model-picker-trigger.css (81%) rename crates/workshop-server/ui/src/ui/{ => chrome}/model-picker-trigger.ts (91%) rename crates/workshop-server/ui/src/ui/{ => chrome}/token-ring.css (84%) rename crates/workshop-server/ui/src/ui/{ => chrome}/token-ring.ts (93%) rename crates/workshop-server/ui/src/ui/{ => chrome}/update-view.css (55%) rename crates/workshop-server/ui/src/ui/{ => chrome}/update-view.ts (92%) rename crates/workshop-server/ui/src/ui/{ => chrome}/window-chrome.css (80%) rename crates/workshop-server/ui/src/ui/{ => chrome}/window-chrome.ts (93%) rename crates/workshop-server/ui/src/ui/{ => chrome}/zoom.ts (100%) rename crates/workshop-server/ui/src/ui/{workshop => editor}/editor-dialog.ts (96%) rename crates/workshop-server/ui/src/ui/{workshop => editor}/editor-panel.css (69%) rename crates/workshop-server/ui/src/ui/{workshop => editor}/editor-panel.ts (97%) rename crates/workshop-server/ui/src/ui/{workshop => editor}/editor-surface.ts (99%) create mode 100644 crates/workshop-server/ui/src/ui/editor/index.ts rename crates/workshop-server/ui/src/ui/{ => gateway}/gateway-config-bridge.ts (96%) rename crates/workshop-server/ui/src/ui/{workshop => gateway}/gateway-config-panel.css (84%) rename crates/workshop-server/ui/src/ui/{workshop => gateway}/gateway-config-panel.ts (94%) create mode 100644 crates/workshop-server/ui/src/ui/gateway/index.ts create mode 100644 crates/workshop-server/ui/src/ui/layout/index.ts rename crates/workshop-server/ui/src/ui/{workshop => layout}/layout-persistence.ts (100%) rename crates/workshop-server/ui/src/ui/{workshop => layout}/panel-types.ts (96%) rename crates/workshop-server/ui/src/ui/{workshop => layout}/shortcuts.ts (97%) rename crates/workshop-server/ui/src/ui/{workshop => layout}/workshop-panel.ts (90%) rename crates/workshop-server/ui/src/ui/{workshop => layout}/zones.css (70%) rename crates/workshop-server/ui/src/ui/{workshop => layout}/zones.ts (100%) create mode 100644 crates/workshop-server/ui/src/ui/menu/index.ts rename crates/workshop-server/ui/src/ui/{ => menu}/window-menu.css (70%) rename crates/workshop-server/ui/src/ui/{ => menu}/window-menu.ts (95%) rename crates/workshop-server/ui/src/ui/{workshop => shared}/icons.ts (100%) create mode 100644 crates/workshop-server/ui/src/ui/shared/index.ts create mode 100644 crates/workshop-server/ui/src/ui/status/index.ts rename crates/workshop-server/ui/src/ui/{ => status}/status-bar.ts (98%) create mode 100644 crates/workshop-server/ui/src/ui/stt/index.ts rename crates/workshop-server/ui/src/ui/{ => stt}/realtime-stt.ts (96%) rename crates/workshop-server/ui/src/ui/{ => stt}/stt.css (75%) rename crates/workshop-server/ui/src/ui/{ => stt}/stt.ts (96%) create mode 100644 crates/workshop-server/ui/src/ui/take/index.ts rename crates/workshop-server/ui/src/ui/{ => take}/take-registry-events.ts (99%) rename crates/workshop-server/ui/src/ui/{ => take}/take-registry-state.ts (100%) rename crates/workshop-server/ui/src/ui/{ => take}/take-registry-types.ts (97%) rename crates/workshop-server/ui/src/ui/{ => take}/take-registry.ts (99%) create mode 100644 crates/workshop-server/ui/src/ui/workspace/index.ts rename crates/workshop-server/ui/src/ui/{ => workspace}/workspace-drops.ts (98%) diff --git a/crates/workshop-server/ui/index.html b/crates/workshop-server/ui/index.html index 90989f8e9..89a6ce329 100644 --- a/crates/workshop-server/ui/index.html +++ b/crates/workshop-server/ui/index.html @@ -8,41 +8,41 @@ -