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/.cursor/rules/workshop-architecture.mdc b/.cursor/rules/workshop-architecture.mdc new file mode 100644 index 000000000..44fe02562 --- /dev/null +++ b/.cursor/rules/workshop-architecture.mdc @@ -0,0 +1,25 @@ +--- +description: Workshop server crate architecture invariants - one-way dependency tiers, registry self-registration, 500-line file ceiling +globs: crates/workshop*/** +alwaysApply: false +--- + +# Workshop Architecture Invariants + +## One-way dependency graph + +- 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. +- Tiers: vocabulary (`workshop-protocol`, `workshop-support`, `workshop-registry`) <- services (`workshop-gateway`, `workshop-status`, `workshop-menu`) <- features (`workshop-sessions`, `workshop-workspace`) <- shell (`workshop-server`). +- Crates in the same tier never depend on each other. They meet through `workshop-protocol` wire types and `workshop-registry` proxy slots. +- Every `workshop-*` crate's `lib.rs` opens with a `//!` doc carrying a `## Invariants` marker that lists what the crate may depend on and what it may not. Read it before adding an import. +- `lib.rs` is a facade only: crate docs, crate-level attributes, `mod` declarations, and `pub use` re-exports. No logic. + +## Registry pattern + +- Subsystems self-register into `workshop-registry` proxy slots: routes, state handles, background tasks, push channels, and shutdown handles. The shell composes the registry; it does not wire subsystems by name. +- Unregistered slots are graceful no-ops. Never add a hard dependency on an optional subsystem. +- Registration handles are `#[must_use]`; registry traits are sealed so only workshop crates implement them. + +## File ceiling + +- No file exceeds 500 lines. If an edit would push a file past 500, split first, then edit. `cargo test -p xtask` enforces the ceiling, the tier graph, and lint inheritance over the Rust files in the workshop crates carrying the marker; the Tauri shell (the `workshop` crate) is exempt until the headless agent mode plan. diff --git a/.cursor/rules/workshop-spa.mdc b/.cursor/rules/workshop-spa.mdc new file mode 100644 index 000000000..6180accee --- /dev/null +++ b/.cursor/rules/workshop-spa.mdc @@ -0,0 +1,21 @@ +--- +description: Workshop SPA conventions - feature directories, barrel exports, lazy loading, CSS colocation, token-only values +globs: crates/workshop-server/ui/** +alwaysApply: false +--- + +# Workshop SPA Conventions + +## Feature directories and lazy loading + +- Feature-based directories under `ui/` (`ui/agent/`, `ui/editor/`, `ui/layout/`, `ui/menu/`, `ui/take/`, `ui/stt/`, `ui/chrome/`, `ui/status/`, `ui/workspace/`, `ui/gateway/`). Shared code lives in `services/` or `base/`; shared UI assets (boot-loaded icons) live in `ui/shared/`; design tokens live in `tokens/`. +- Barrel exports: every directory has an `index.ts`. Lazy directories export `register()` installing commands, menu items, panel factories, and socket subscriptions. +- The boot shell (`main.ts`, `services/`, `base/`) loads immediately. Feature directories load via dynamic `import()` on first activation. Lazy-loaded panels never import the boot shell. +- Registration, not central wiring: panel types, menu items, services, socket handlers, keyboard shortcuts, and lifecycle disposal self-register through the panel, menu/command, and service registries. + +## CSS colocation and tokens + +- CSS lives beside its TypeScript, imported as a side-effect. Never a separate `styles/` tree. Every feature directory is self-contained: `.ts`, `.css`, and `index.ts` together. A designer finds the styles for the agent chat at `ui/agent/agent-session.css`, not by grepping a flat directory. +- 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`. +- CSS classes use the `.ws-` project prefix (`.ws-agent-toolbar`). Design tokens use the `--ws-` prefix (`--ws-color-bg-surface`). +- Directories and files are kebab-case (`agent-session-view.ts`, `agent-session.css`). diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fdcb1f567..234e171ec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -195,6 +195,12 @@ jobs: - name: Test (workshop, concurrent via nextest) run: cargo nextest run --locked -p workshop -p workshop-server + # The headless feature swaps the webview asset layer for a no-op so + # server-only integration tests run without the UI bundle. It is not + # a default feature, so it needs its own invocation. + - name: Test (workshop-server headless) + run: cargo nextest run --locked -p workshop-server --features headless + # nextest does not run doctests; the workspace doctest step in the # `test` job excludes both workshop crates, so cover them here. - name: Doctests (workshop) diff --git a/AGENTS.md b/AGENTS.md index 54b235da6..9fb4afb35 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 carrying a `## Invariants` marker that lists 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. `cargo test -p xtask` enforces the tier graph, the lint inheritance, and the ceiling over the Rust files in the workshop crates carrying the marker; the Tauri shell (the `workshop` crate) is exempt until the headless agent mode plan. + +## 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..d87c4478d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -561,6 +561,7 @@ name = "build-ui" version = "0.3.0" dependencies = [ "anyhow", + "tempfile", ] [[package]] @@ -8553,19 +8554,74 @@ 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" +dependencies = [ + "promptforge-core-support", + "serde", + "serde_json", +] + +[[package]] +name = "workshop-registry" +version = "0.0.0" +dependencies = [ + "axum", + "serde_json", + "tokio", + "workshop-protocol", +] + [[package]] 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", @@ -8573,7 +8629,6 @@ dependencies = [ "promptforge-tool-picker", "promptforge-tools", "promptforge-vfs", - "rand 0.9.5", "reqwest 0.12.28", "rust-embed", "serde", @@ -8581,18 +8636,99 @@ 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", "url", + "workshop-gateway", + "workshop-menu", + "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", +] + +[[package]] +name = "workshop-status" +version = "0.0.0" +dependencies = [ + "shared-progress", + "tokio", + "workshop-protocol", + "workshop-registry", + "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]] +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]] @@ -8839,6 +8975,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/Cargo.toml b/Cargo.toml index 593c656c5..bdbc1cf4e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,6 +47,14 @@ 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" } +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/build-ui/Cargo.toml b/crates/build-ui/Cargo.toml index db477f4e2..7daa4b303 100644 --- a/crates/build-ui/Cargo.toml +++ b/crates/build-ui/Cargo.toml @@ -11,5 +11,8 @@ description = "Build-script helper that bundles a crate's ui/ sources with esbui [dependencies] anyhow.workspace = true +[dev-dependencies] +tempfile.workspace = true + [lints] workspace = true diff --git a/crates/build-ui/src/lib.rs b/crates/build-ui/src/lib.rs index 78c87942a..d17164ff3 100644 --- a/crates/build-ui/src/lib.rs +++ b/crates/build-ui/src/lib.rs @@ -6,7 +6,9 @@ //! [`build`]: the bundle and copies of the static files land in //! `$OUT_DIR/ui-dist/`, which git never tracks, so no build step can dirty //! the repository. Cargo's own change detection decides when the bundle is -//! rebuilt; there is no manifest file and no hash. Building requires +//! rebuilt. Splitting builds content-hash every bundle file and emit a +//! `manifest.json` plus a stamped `index.html`; non-splitting builds keep +//! the unversioned `app.js`. Building requires //! Node.js 22 and one `npm ci` per `ui/` folder; there is no fallback. use std::path::{Path, PathBuf}; @@ -36,12 +38,23 @@ pub struct UiBuild { /// Bake the crate version into the bundle as the `__APP_VERSION__` /// define. pub define_app_version: bool, + /// Code-split the bundle: dynamic imports become lazily loaded chunks + /// under `chunks/`, and every bundle file is content-hashed - the + /// entry lands at `bundle/app-.js` (plus its extracted + /// `bundle/app-.css`), the chunks at `chunks/-`. + /// The build then writes `manifest.json` (the logical-to-hashed name + /// map the workshop server's asset routes resolve through) and stamps + /// the dist copy of `index.html` with the hashed URLs. The workshop + /// UI splits (its panel registry lazy-loads feature directories); the + /// config UI does not, and keeps its unversioned `app.js`. + pub splitting: bool, } /// Runs the UI build: declares the watched inputs, bundles -/// `ui/src/main.ts` with esbuild into `$OUT_DIR/ui-dist/app.js` (minified +/// `ui/src/main.ts` with esbuild into `$OUT_DIR/ui-dist/` (minified /// in the release profile), and copies the static files next to the -/// bundle. +/// bundle. Splitting builds finish with `finalize_hashing`: the +/// manifest and the stamped index page. /// /// # Errors /// Returns an error when not run through Cargo, when the local @@ -60,15 +73,30 @@ pub fn build(config: UiBuild) -> anyhow::Result<()> { let dist_dir = out_dir.join("ui-dist"); watch(&ui_dir, &config); + build_in(&ui_dir, &dist_dir, config) +} +/// Path-explicit variant of [`build`]: bundles `ui_dir/src/main.ts` into +/// `dist_dir` without reading Cargo's environment. Tests use it to run +/// the Rust implementer against a scratch output directory (setting +/// process environment would require `unsafe`), and to compare the +/// result against the Node build script's `--out` output. +/// +/// # Errors +/// Returns an error when the local esbuild install is missing or fails, +/// or when a static file cannot be copied. +pub fn build_in(ui_dir: &Path, dist_dir: &Path, config: UiBuild) -> anyhow::Result<()> { // The output tree is rebuilt from scratch so removed assets never // linger into what debug builds serve and release builds embed. if dist_dir.exists() { - std::fs::remove_dir_all(&dist_dir) + std::fs::remove_dir_all(dist_dir) .map_err(|error| anyhow::anyhow!("clear {}: {error}", dist_dir.display()))?; } - bundle(&ui_dir, &dist_dir, config.define_app_version)?; - copy_static(&ui_dir, &dist_dir, config.static_files)?; + bundle(ui_dir, dist_dir, &config)?; + copy_static(ui_dir, dist_dir, config.static_files)?; + if config.splitting { + finalize_hashing(dist_dir)?; + } Ok(()) } @@ -103,7 +131,7 @@ fn watch(ui_dir: &Path, config: &UiBuild) { /// Runs the esbuild bundle step from the local `ui/node_modules` install. /// There is no `npx` fallback: `npx` can download a different esbuild /// version and produce different output. -fn bundle(ui_dir: &Path, dist_dir: &Path, define_app_version: bool) -> anyhow::Result<()> { +fn bundle(ui_dir: &Path, dist_dir: &Path, config: &UiBuild) -> anyhow::Result<()> { let mut command = esbuild_command(ui_dir)?; command.current_dir(ui_dir).args([ "src/main.ts", @@ -111,11 +139,23 @@ fn bundle(ui_dir: &Path, dist_dir: &Path, define_app_version: bool) -> anyhow::R "--format=esm", "--target=es2022", ]); - command.arg(format!("--outfile={}", dist_dir.join("app.js").display())); + if config.splitting { + // Every bundle file is content-hashed: the entry lands under + // bundle/ (index.html is stamped with the hashed URLs by + // finalize_hashing, and the server's asset routes resolve the + // logical names through manifest.json); chunks are content-hashed + // under chunks/, served by the workshop server's chunk route. + command.arg("--splitting"); + command.arg(format!("--outdir={}", dist_dir.display())); + command.arg("--entry-names=bundle/app-[hash]"); + command.arg("--chunk-names=chunks/[name]-[hash]"); + } else { + command.arg(format!("--outfile={}", dist_dir.join("app.js").display())); + } if std::env::var("PROFILE").as_deref() == Ok("release") { command.arg("--minify"); } - if define_app_version { + if config.define_app_version { let version = std::env::var("CARGO_PKG_VERSION").map_err(|error| { anyhow::anyhow!("CARGO_PKG_VERSION is not set: {error}; run through cargo") })?; @@ -186,3 +226,53 @@ fn copy_static(ui_dir: &Path, dist_dir: &Path, static_files: &[&str]) -> anyhow: } Ok(()) } + +/// Finishes a content-hashed build: writes `manifest.json` mapping the +/// logical names (`app.js`, `app.css`) to the hashed files under +/// `bundle/`, and stamps the copied `index.html` with the hashed URLs so +/// the page loads the immutable assets directly. The workshop server's +/// asset routes resolve the logical names through the manifest and mark +/// the hashed files `Cache-Control: immutable`. Mirrored in the workshop +/// UI's `build.mjs` stamp step. +fn finalize_hashing(dist_dir: &Path) -> anyhow::Result<()> { + let script = hashed_entry(dist_dir, ".js")?; + let styles = hashed_entry(dist_dir, ".css")?; + let manifest = format!("{{\n \"app.js\": \"{script}\",\n \"app.css\": \"{styles}\"\n}}\n"); + std::fs::write(dist_dir.join("manifest.json"), manifest) + .map_err(|error| anyhow::anyhow!("write the asset manifest: {error}"))?; + let index_path = dist_dir.join("index.html"); + let html = std::fs::read_to_string(&index_path) + .map_err(|error| anyhow::anyhow!("read the copied index.html: {error}"))?; + let stamped = html + .replace("href=\"/app.css\"", &format!("href=\"/{styles}\"")) + .replace("src=\"/app.js\"", &format!("src=\"/{script}\"")); + if stamped == html { + return Err(anyhow::anyhow!( + "index.html did not reference /app.js and /app.css; the stamp found nothing" + )); + } + std::fs::write(&index_path, stamped) + .map_err(|error| anyhow::anyhow!("write the stamped index.html: {error}"))?; + Ok(()) +} + +/// Finds the single hashed entry output of one kind under `bundle/`, +/// returning its dist-relative path. The output tree is rebuilt from +/// scratch on every build, so exactly one match must exist. +fn hashed_entry(dist_dir: &Path, extension: &str) -> anyhow::Result { + let bundle_dir = dist_dir.join("bundle"); + let mut matches: Vec = std::fs::read_dir(&bundle_dir) + .map_err(|error| anyhow::anyhow!("list {}: {error}", bundle_dir.display()))? + .filter_map(std::result::Result::ok) + .filter_map(|entry| entry.file_name().into_string().ok()) + .filter(|name| name.starts_with("app-") && name.ends_with(extension)) + .collect(); + matches.sort_unstable(); + match matches.as_slice() { + [name] => Ok(format!("bundle/{name}")), + _ => Err(anyhow::anyhow!( + "expected exactly one bundle/app-*{extension} output, found {}", + matches.len() + )), + } +} diff --git a/crates/build-ui/tests/it/main.rs b/crates/build-ui/tests/it/main.rs new file mode 100644 index 000000000..c6cca7ff5 --- /dev/null +++ b/crates/build-ui/tests/it/main.rs @@ -0,0 +1,149 @@ +//! Integration tests for the `build-ui` helper. + +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; +use std::process::Command; + +/// Differential behavior test over real build outputs: the Rust +/// implementer (`build_ui::build_in`, what `cargo build` runs through +/// the crate build scripts) and the Node implementer +/// (`ui/build.mjs --out`, the fast-iteration path) must emit the same +/// layout - the same hash-normalized file set, the same manifest, and +/// the same stamped index page. Skips with a message when Node.js or +/// the UI's `node_modules` install is absent. +#[test] +fn both_implementers_emit_the_same_layout() -> anyhow::Result<()> { + let ui_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("workshop-server") + .join("ui"); + if !node_available() { + eprintln!("skipping: node is not on PATH; install Node.js 22 to run this test"); + return Ok(()); + } + if !ui_dir.join("node_modules").is_dir() { + eprintln!( + "skipping: {} is missing; run `npm ci` in {} first", + ui_dir.join("node_modules").display(), + ui_dir.display() + ); + return Ok(()); + } + + let rust_dist = tempfile::TempDir::new()?; + let node_dist = tempfile::TempDir::new()?; + + build_ui::build_in( + &ui_dir, + rust_dist.path(), + build_ui::UiBuild { + static_files: build_ui::WORKSHOP_STATIC_FILES, + define_app_version: true, + splitting: true, + }, + )?; + + let output = Command::new("node") + .arg("build.mjs") + .arg("--out") + .arg(node_dist.path()) + .current_dir(&ui_dir) + .output()?; + anyhow::ensure!( + output.status.success(), + "node build.mjs --out failed (status {}):\n{}", + output.status, + String::from_utf8_lossy(&output.stderr) + ); + + let rust_files = file_set(rust_dist.path())?; + let node_files = file_set(node_dist.path())?; + anyhow::ensure!( + rust_files == node_files, + "the emitted file sets differ:\nonly in the Rust build: {:?}\nonly in the Node build: {:?}", + rust_files.difference(&node_files).collect::>(), + node_files.difference(&rust_files).collect::>(), + ); + + // The manifest maps the logical names to the hashed files; the + // stamped index page references them. Both are byte-identical once + // the content hashes are normalized. + let manifest = normalized_file(rust_dist.path(), "manifest.json")?; + anyhow::ensure!( + manifest.contains("\"app.js\"") && manifest.contains("\"app.css\""), + "manifest.json must map the app.js and app.css logical names, got:\n{manifest}" + ); + for name in ["manifest.json", "index.html"] { + let rust_text = normalized_file(rust_dist.path(), name)?; + let node_text = normalized_file(node_dist.path(), name)?; + anyhow::ensure!( + rust_text == node_text, + "{name} differs between the implementers:\nRust:\n{rust_text}\nNode:\n{node_text}" + ); + } + Ok(()) +} + +/// True when `node` runs on PATH. +fn node_available() -> bool { + Command::new("node") + .arg("--version") + .output() + .is_ok_and(|output| output.status.success()) +} + +/// The set of files under `root`, relative with `/` separators and with +/// esbuild content hashes normalized so the two implementers' different +/// bytes (minification, defines) do not break the comparison. +fn file_set(root: &Path) -> anyhow::Result> { + let mut files = BTreeSet::new(); + let mut stack = vec![root.to_path_buf()]; + while let Some(dir) = stack.pop() { + for entry in std::fs::read_dir(&dir)? { + let path = entry?.path(); + if path.is_dir() { + stack.push(path); + } else { + let relative = path + .strip_prefix(root)? + .to_string_lossy() + .replace('\\', "/"); + files.insert(normalize_hashes(&relative)); + } + } + } + Ok(files) +} + +/// Reads one file under `root` with its content hashes normalized. +fn normalized_file(root: &Path, name: &str) -> anyhow::Result { + Ok(normalize_hashes(&std::fs::read_to_string(root.join(name))?)) +} + +/// Replaces esbuild content hashes (`-XXXXXXXX` in base32, A-Z and 2-7) +/// with a fixed token. +fn normalize_hashes(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + let mut rest = text; + while let Some(c) = rest.chars().next() { + if c == '-' { + let candidate = &rest[1..]; + let hash: String = candidate.chars().take(8).collect(); + let bounded = candidate + .chars() + .nth(8) + .is_none_or(|next| !next.is_ascii_alphanumeric()); + if hash.len() == 8 + && hash.chars().all(|h| matches!(h, 'A'..='Z' | '2'..='7')) + && bounded + { + out.push_str("-HASH"); + rest = &candidate[8..]; + continue; + } + } + out.push(c); + rest = &rest[c.len_utf8()..]; + } + out +} diff --git a/crates/gateway-config-ui/build.rs b/crates/gateway-config-ui/build.rs index 52cf720af..e26892ff3 100644 --- a/crates/gateway-config-ui/build.rs +++ b/crates/gateway-config-ui/build.rs @@ -8,6 +8,7 @@ fn main() -> std::process::ExitCode { match build_ui::build(build_ui::UiBuild { static_files: build_ui::CONFIG_UI_STATIC_FILES, define_app_version: true, + splitting: false, }) { Ok(()) => std::process::ExitCode::SUCCESS, Err(error) => { diff --git a/crates/promptforge-core/AGENTS.md b/crates/promptforge-core/AGENTS.md index 000fa5c0a..931513cc1 100644 --- a/crates/promptforge-core/AGENTS.md +++ b/crates/promptforge-core/AGENTS.md @@ -6,3 +6,4 @@ This crate owns PromptForge document execution and run orchestration. - Concrete providers stay in their provider crates. Core may re-export them under a historical path but never reacquires provider implementation. - Store write scope remains private to Core's execution machinery. - The executor imports parser, Lua, model-client, store, tool, and host-support vocabulary from their owning crates. Those crates never depend on this executor. +- The input broker backs only the script-side `user_input()` function. No `user_input` tool is ever advertised to a model unless a prompt explicitly adds it. diff --git a/crates/promptforge-core/src/execute/scheduler.rs b/crates/promptforge-core/src/execute/scheduler.rs index b43c0f8b3..00510f227 100644 --- a/crates/promptforge-core/src/execute/scheduler.rs +++ b/crates/promptforge-core/src/execute/scheduler.rs @@ -67,19 +67,19 @@ use tokio::task::JoinHandle; use crate::client::GatewayClient; use crate::fanout; use crate::fanout::ArmFinalizer; -use crate::input::{INPUT_UNAVAILABLE_FALLBACK, InputBroker, InputOutcome, InputTool}; +use crate::input::{INPUT_UNAVAILABLE_FALLBACK, InputOutcome}; use crate::lua::{ CoroStep, LuaBlockResult, LuaFanoutResult, LuaProgram, MessageRecord, OverflowReason, - ScriptReport, SectionVm, ToolOutputKind, UserInputOutcome, append_message_record, - current_tool_bindings, dispatch_tool, invoke_selected, project_messages, resolve_model_binding, - run_store_op, shim_live_h1_models, + ScriptReport, SectionVm, UserInputOutcome, append_message_record, current_tool_bindings, + dispatch_tool, invoke_selected, project_messages, resolve_model_binding, run_store_op, + shim_live_h1_models, }; use crate::model::ModelBinding; -use crate::observe::{Observation, Observer, detail}; +use crate::observe::{Observation, detail}; use crate::parser::{Block, Prompt, Section}; use crate::resolve::RuntimeResolution; use crate::store::{Access, Store, StoreError}; -use crate::tools::{Tool, ToolId}; +use crate::tools::ToolId; use crate::{Error, Result, cancel, subst}; use super::context::RunContext; @@ -246,35 +246,6 @@ struct ChainTarget<'a> { child: bool, } -/// Builds the model-visible input tool's binding for one loop scope: the -/// input broker's second surface, advertised under the `user_input` alias, -/// so the model can ask the operator mid-loop and the answer lands as the -/// correlated tool result. Constructed per loop call with the frame's -/// effective reporting handles, exactly as the broker's direct -/// `user_input()` path reports under the chain's own coordinates. -fn input_tool_binding( - broker: &Arc, - execution: &str, - section: &str, - observer: &Arc, -) -> crate::lua::ToolBinding { - let tool: Arc = Arc::new(InputTool::new( - Arc::clone(broker), - execution, - section, - Arc::clone(observer), - )); - crate::lua::ToolBinding { - alias: tool.wire_name().to_owned(), - description: tool.description().to_owned(), - id: tool.id(), - model_description: None, - tool, - conflicts: Vec::new(), - output_kind: ToolOutputKind::Plain, - } -} - /// Resolves `heading` against an at-worker arm's visible set: the fanout /// caller's visible set minus the worker, plus the worker's children - the /// set the legacy arm's control globals resolve over, built with the same @@ -1983,23 +1954,11 @@ impl<'a> Scheduler<'a> { // The scope is read at call time: `tools.add` and // `tools.add_local` calls since the last model operation shape // this call's advertised set. - let mut effective = current_tool_bindings(&tool_set, &frame.vm()?.tool_runtime)?; + let effective = current_tool_bindings(&tool_set, &frame.vm()?.tool_runtime)?; let handles = frame.reporting_handles(); let observer = handles.observer; let debug = handles.debug; let turns = handles.turns; - // The broker's second surface: with an input broker configured, - // the model-visible input tool joins the loop scope, so the - // model can ask the operator mid-loop through the same broker - // the direct `user_input()` suspends on. A prompt-declared - // `user_input` alias wins over the synthetic binding. - if let Some(broker) = chain.ctx.input_broker() - && !effective - .iter() - .any(|binding| binding.alias() == "user_input") - { - effective.push(input_tool_binding(broker, &execution, §ion, &observer)); - } let counts = frame.script_call_counts(&chain.ctx, &effective)?; let local_schemas = frame.vm()?.local_tool_schemas()?; // The shared dispatch body's increment errors on an unseeded diff --git a/crates/promptforge-core/src/execute/tests/input.rs b/crates/promptforge-core/src/execute/tests/input.rs index bc426a52e..38ef6ff49 100644 --- a/crates/promptforge-core/src/execute/tests/input.rs +++ b/crates/promptforge-core/src/execute/tests/input.rs @@ -1,13 +1,12 @@ //! Tests for the generic input broker: direct `user_input()` (operator //! text with the availability flag, the unspoofable fallback sentence, -//! host failure, cancellation) and the model-visible input tool adapting -//! the same broker through the correlated tool protocol inside -//! `models.loop`. +//! host failure, cancellation) and the contract that a configured broker +//! advertises no `user_input` tool to the model. use super::*; use crate::execute::scheduler::Scheduler; -use crate::input::{INPUT_UNAVAILABLE_FALLBACK, InputBroker, InputError, InputOutcome, InputTool}; -use crate::lua::{ToolBinding, ToolSet}; +use crate::input::{INPUT_UNAVAILABLE_FALLBACK, InputBroker, InputError, InputOutcome}; +use crate::lua::ToolSet; use crate::model::{ModelBinding, ModelId}; use promptforge_model_client::model::ModelInvocation; @@ -332,63 +331,26 @@ async fn cancellation_interrupts_a_pending_input_wait() { } #[tokio::test(flavor = "current_thread")] -async fn the_model_visible_input_tool_adapts_the_broker_through_the_correlated_tool_protocol() { - let gateway = ScriptedGateway::start(vec![ - resp_tool_call("call_1", "user_input", "{}"), - resp_text("all done"), - ]) - .await; - let recorder = Arc::new(InputRecorder::default()); - let tool = InputTool::new( - Arc::new(TextBroker("from the operator")), - EXECUTION, - "Only", - Arc::clone(&recorder) as Arc, - ); - let tools = ToolSet::for_test( - vec![ToolBinding::for_test( - "user_input", - "operator input capability", - Arc::new(tool), - )], - vec!["user_input".to_owned()], - ); +async fn a_brokered_loop_with_no_prompt_tools_advertises_no_tools_to_the_model() { + let gateway = ScriptedGateway::start(vec![resp_text("all done")]).await; let md = input_prompt( "local msgs = messages.new()\n\ - msgs:user('ask me something')\n\ + msgs:user('hello')\n\ models.loop(msgs)\n\ - assert(#msgs == 4, 'user, assistant call, correlated result, terminal')\n\ - assert(msgs[2].role == 'assistant', 'the model asked through a tool call')\n\ - assert(msgs[3].role == 'tool', 'the input answer is a tool result')\n\ - assert(msgs[3].tool_call_id == 'call_1', 'the result is correlated to the call')\n\ - assert(msgs[3].content == 'from the operator', 'the operator text is the result content')\n\ - assert(msgs[4].role == 'assistant' and msgs[4].content == 'all done', 'the terminal record closes the loop')\n\ - return 'ok'", + return msgs[#msgs].content", ); let prompt = parse(&md); - let config = RunConfig::new(EXECUTION).observer(Arc::clone(&recorder) as Arc); - let ctx = input_context(&prompt, tools, &config); + let config = RunConfig::new(EXECUTION).input_broker(Arc::new(TextBroker("never asked"))); + let ctx = input_context(&prompt, ToolSet::default(), &config); let out = Scheduler::new(&ctx, Some(gateway_client(gateway.addr()))) .drive() .await - .expect("the loop runs the input tool round to its terminal turn"); - assert_eq!(out, "ok"); + .expect("a tool-free loop runs to its terminal turn"); + assert_eq!(out, "all done"); let bodies = gateway.requests(); - assert_eq!(bodies.len(), 2, "the tool round plus the terminal round"); - let tool_message = bodies[1]["messages"] - .as_array() - .expect("a request body must carry a messages array") - .iter() - .find(|message| message["role"] == "tool") - .expect("the re-sent conversation must include the tool turn"); - assert_eq!( - tool_message["tool_call_id"], "call_1", - "the wire result answers the model's call id" - ); - assert_eq!(tool_message["content"], "from the operator"); - assert_eq!( - recorder.inputs(), - vec!["from the operator".to_owned()], - "the tool path records the response through host observation exactly once" + assert_eq!(bodies.len(), 1, "one terminal turn is one request"); + assert!( + bodies[0].get("tools").is_none() || bodies[0]["tools"].is_null(), + "a configured input broker advertises no user_input tool: {bodies:?}" ); } 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/crates/promptforge-core/src/input.rs b/crates/promptforge-core/src/input.rs index fe9f987c8..69386e756 100644 --- a/crates/promptforge-core/src/input.rs +++ b/crates/promptforge-core/src/input.rs @@ -1,14 +1,14 @@ //! The generic input broker: one host policy behind user input. //! -//! Two surfaces consume the same broker. A section's direct -//! `user_input()` call suspends on the broker and resumes with -//! `(text, available)`: `available` is `true` for real operator text and -//! `false` when the host had no input, in which case `text` is the fixed -//! [`INPUT_UNAVAILABLE_FALLBACK`] sentence. The flag rides beside the -//! text, so a human typing exactly the fallback sentence can never spoof -//! the unavailable state. The model-visible [`InputTool`] adapts the same -//! broker for a `models.loop` tool scope: the model's call suspends on -//! the broker and its answer lands as the correlated tool result. +//! The broker backs the script-side `user_input()` function only. A +//! section's direct `user_input()` call suspends on the broker and +//! resumes with `(text, available)`: `available` is `true` for real +//! operator text and `false` when the host had no input, in which case +//! `text` is the fixed [`INPUT_UNAVAILABLE_FALLBACK`] sentence. The flag +//! rides beside the text, so a human typing exactly the fallback sentence +//! can never spoof the unavailable state. No `user_input` tool is +//! advertised to the model: a `models.loop` scope carries exactly the +//! tools the prompt adds. //! //! The host policies are the broker's: a blocking broker parks the wait //! until the host delivers (the section's VM and message history stay @@ -16,17 +16,13 @@ //! unavailable-fallback policy, and a broker error is the failure policy, //! raising a typed [`RunErrorKind::Input`](crate::RunErrorKind::Input) //! failure at the Lua call site. Waits and responses are recorded through -//! the run's [`Observer`] - a wait-opened observation and +//! the run's [`Observer`](crate::observe::Observer) - a wait-opened observation and //! a byte-exact `on_user_input` report - without any replay machinery. use std::fmt; -use std::sync::Arc; -use crate::observe::{Observer, detail}; -use crate::tools::{Tool, ToolError, ToolId, ToolOutput}; - -/// The fixed sentence a `user_input` call or [`InputTool`] result carries -/// when the host has no input to give. +/// The fixed sentence a `user_input` call carries when the host has no +/// input to give. /// /// The sentence is deliberately unremarkable: the availability flag, not /// the text, distinguishes the fallback from operator input, so the @@ -51,9 +47,8 @@ pub enum InputOutcome { /// A broker's failure to produce input. /// -/// The message is host-authored and safe to surface at the Lua call site -/// and (through [`InputTool`]) to the model; an underlying cause hides -/// behind [`std::error::Error::source`]. +/// The message is host-authored and safe to surface at the Lua call site; +/// an underlying cause hides behind [`std::error::Error::source`]. #[derive(Debug)] #[non_exhaustive] pub struct InputError { @@ -123,8 +118,8 @@ impl std::error::Error for InputError { /// The host policy behind user input: one asynchronous request per wait. /// /// The executor calls [`user_input`](Self::user_input) when a section's -/// `user_input()` runs or the model-visible [`InputTool`] is dispatched, -/// and suspends the caller until the future resolves. An implementation +/// `user_input()` runs, and suspends the caller until the future +/// resolves. An implementation /// that blocks until its host delivers input is the blocking policy; /// answering [`InputOutcome::Unavailable`] is the unavailable-fallback /// policy; an [`InputError`] is the failure policy. Implementations must @@ -140,309 +135,3 @@ pub trait InputBroker: Send + Sync { /// answering or declining it. async fn user_input(&self, execution: &str, section: &str) -> Result; } - -/// The model-visible input tool: adapts the run's [`InputBroker`] into a -/// [`Tool`] a `models.loop` tool scope can advertise, so the model can -/// ask the operator mid-loop and the answer lands as the correlated tool -/// result. -/// -/// The tool is a host primitive, constructed per run (or session) with -/// the reporting coordinates its observations carry. Its output is -/// trusted plain text: the operator's answer byte-exact, or -/// [`INPUT_UNAVAILABLE_FALLBACK`] when the broker declines. A broker -/// failure surfaces as a narrow [`ToolError`]. -pub struct InputTool { - /// The broker every call waits on. - broker: Arc, - /// The execution identifier the tool's observations carry. - execution: String, - /// The section name the tool's observations carry. - section: String, - /// Where waits and responses are recorded. - observer: Arc, -} - -impl fmt::Debug for InputTool { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("InputTool") - .field("execution", &self.execution) - .field("section", &self.section) - .finish_non_exhaustive() - } -} - -impl InputTool { - /// Builds the tool over `broker`, reporting under `execution` and - /// `section` to `observer`. - /// - /// # Examples - /// ``` - /// use std::sync::Arc; - /// - /// use promptforge_core::input::{InputBroker, InputError, InputOutcome, InputTool}; - /// use promptforge_core::observe::NullObserver; - /// - /// struct Console; - /// - /// #[async_trait::async_trait] - /// impl InputBroker for Console { - /// async fn user_input( - /// &self, - /// _execution: &str, - /// _section: &str, - /// ) -> Result { - /// Ok(InputOutcome::Unavailable) - /// } - /// } - /// - /// let tool = InputTool::new( - /// Arc::new(Console), - /// "example-run", - /// "chat", - /// Arc::new(NullObserver::default()), - /// ); - /// ``` - #[must_use] - pub fn new( - broker: Arc, - execution: &str, - section: &str, - observer: Arc, - ) -> InputTool { - InputTool { - broker, - execution: execution.to_owned(), - section: section.to_owned(), - observer, - } - } -} - -#[async_trait::async_trait] -impl Tool for InputTool { - fn id(&self) -> ToolId { - ToolId::from_validated("promptforge", "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 { - "Give the user the opportunity to add a prompt and wait for their typed input." - } - - fn parameters_schema(&self) -> serde_json::Value { - serde_json::json!({ - "type": "object", - "properties": {} - }) - } - - /// Opens one broker wait and suspends until it resolves. - /// - /// The wait is recorded through the observer before the broker is - /// called, and an operator answer is recorded byte-exact through - /// `on_user_input`. Arguments are accepted but unused in the active - /// contract: the broker owns how the wait reaches the operator. - /// - /// # Errors - /// Returns a [`ToolError`] carrying the broker's message and cause - /// when the broker fails the wait. - async fn call(&self, _args: serde_json::Value) -> Result { - self.observer.observe( - &self.execution, - &self.section, - detail::USER_INPUT_WAIT_STARTED, - ); - match self.broker.user_input(&self.execution, &self.section).await { - Ok(InputOutcome::Text(text)) => { - self.observer - .on_user_input(&self.execution, &self.section, &text); - Ok(ToolOutput::trusted(text)) - } - Ok(InputOutcome::Unavailable) => Ok(ToolOutput::trusted(INPUT_UNAVAILABLE_FALLBACK)), - // The whole broker error rides as the source, so its own - // cause stays reachable through the chain. - Err(error) => { - let message = error.to_string(); - Err(ToolError::with_source(message, error)) - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - use crate::observe::{NullObserver, Observation}; - - /// A broker that always answers with the same operator text. - struct TextBroker(&'static str); - - #[async_trait::async_trait] - impl InputBroker for TextBroker { - async fn user_input( - &self, - _execution: &str, - _section: &str, - ) -> Result { - Ok(InputOutcome::Text(self.0.to_owned())) - } - } - - /// A broker reporting the host has no input to give. - struct UnavailableBroker; - - #[async_trait::async_trait] - impl InputBroker for UnavailableBroker { - async fn user_input( - &self, - _execution: &str, - _section: &str, - ) -> Result { - Ok(InputOutcome::Unavailable) - } - } - - /// A broker whose every request fails with a caused error. - struct FailingBroker; - - #[async_trait::async_trait] - impl InputBroker for FailingBroker { - async fn user_input( - &self, - _execution: &str, - _section: &str, - ) -> Result { - Err(InputError::with_source( - "the input device is gone", - std::io::Error::other("socket reset"), - )) - } - } - - /// Records fixed observations and `on_user_input` content reports. - #[derive(Default)] - struct Recorder { - events: std::sync::Mutex>, - inputs: std::sync::Mutex>, - } - - impl Observer for Recorder { - fn observe(&self, _execution: &str, _section: &str, event: Observation) { - self.events - .lock() - .expect("the recorder mutex must not be poisoned") - .push(event); - } - - fn on_user_input(&self, _execution: &str, _section: &str, text: &str) { - self.inputs - .lock() - .expect("the recorder mutex must not be poisoned") - .push(text.to_owned()); - } - } - - fn tool(broker: Arc, observer: Arc) -> InputTool { - InputTool::new(broker, "input-test", "Only", observer) - } - - #[tokio::test] - async fn the_tool_returns_operator_text_as_trusted_output_and_records_it() { - let recorder = Arc::new(Recorder::default()); - let tool = tool( - Arc::new(TextBroker("line1\r\nline2 \"quoted\" \u{1F980}")), - recorder.clone() as Arc, - ); - let output = tool - .call(serde_json::json!({})) - .await - .expect("the broker answers"); - assert_eq!( - output.trust(), - promptforge_tools::OutputTrust::Trusted, - "operator input is first-party: no guard wrap may apply" - ); - assert_eq!(output.text(), "line1\r\nline2 \"quoted\" \u{1F980}"); - assert_eq!( - recorder - .events - .lock() - .expect("the recorder mutex must not be poisoned") - .as_slice(), - &[Observation::UserInputWaitStarted], - "the wait is recorded exactly once" - ); - assert_eq!( - recorder - .inputs - .lock() - .expect("the recorder mutex must not be poisoned") - .as_slice(), - &["line1\r\nline2 \"quoted\" \u{1F980}".to_owned()], - "the response is recorded byte-exact" - ); - } - - #[tokio::test] - async fn an_unavailable_broker_answer_becomes_the_fallback_sentence() { - let tool = tool( - Arc::new(UnavailableBroker), - Arc::new(NullObserver::default()) as Arc, - ); - let output = tool - .call(serde_json::json!({})) - .await - .expect("an unavailable answer is not a failure"); - assert_eq!(output.text(), INPUT_UNAVAILABLE_FALLBACK); - } - - #[test] - fn the_advertised_schema_has_no_question_and_the_description_promises_no_question_channel() { - let tool = tool( - Arc::new(UnavailableBroker), - Arc::new(NullObserver::default()) as Arc, - ); - let schema = tool.parameters_schema(); - let properties = schema["properties"] - .as_object() - .expect("the schema advertises a properties object"); - assert!( - properties.is_empty(), - "the tool takes no arguments: the broker owns how input is gathered, got {properties:?}" - ); - let description = tool.description().to_lowercase(); - assert!( - !description.contains("question") && !description.contains("ask"), - "the description must not promise an ask-a-question channel, got: {description}" - ); - } - - #[tokio::test] - async fn a_broker_failure_is_a_tool_error_with_its_cause() { - let tool = tool( - Arc::new(FailingBroker), - Arc::new(NullObserver::default()) as Arc, - ); - let error = tool - .call(serde_json::json!({})) - .await - .expect_err("the broker failure fails the call"); - assert_eq!(error.to_string(), "the input device is gone"); - assert!( - std::error::Error::source(&error).is_some(), - "the broker's cause survives as the tool error's source" - ); - } -} diff --git a/crates/promptforge-core/src/lua.rs b/crates/promptforge-core/src/lua.rs index de541b67e..c79d090e7 100644 --- a/crates/promptforge-core/src/lua.rs +++ b/crates/promptforge-core/src/lua.rs @@ -21,6 +21,7 @@ pub(crate) use promptforge_lua::{ project_messages, resolve_model_binding, run_store_op, shim_live_h1_models, }; +#[cfg(test)] pub(crate) use promptforge_lua::ToolOutputKind; #[cfg(test)] diff --git a/crates/promptforge/src/lib.rs b/crates/promptforge/src/lib.rs index 4ec1a745c..870bdebe2 100644 --- a/crates/promptforge/src/lib.rs +++ b/crates/promptforge/src/lib.rs @@ -12,7 +12,7 @@ pub mod pipeline { pub use promptforge_core::execute::run; pub use promptforge_core::execute::{RunConfig, RunError}; pub use promptforge_core::input::{ - INPUT_UNAVAILABLE_FALLBACK, InputBroker, InputError, InputOutcome, InputTool, + INPUT_UNAVAILABLE_FALLBACK, InputBroker, InputError, InputOutcome, }; } 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/handles.rs b/crates/workshop-gateway/src/handles.rs new file mode 100644 index 000000000..72cea0c20 --- /dev/null +++ b/crates/workshop-gateway/src/handles.rs @@ -0,0 +1,89 @@ +//! The gateway subsystem's registration: the replaceable endpoint +//! binding and the reachability flag as its state handle set, and its +//! background tasks - the reachability heartbeat and the gateway +//! progress subscriber. + +use std::sync::Arc; + +use shared_progress::ProgressHub; + +use workshop_registry::{BackgroundTaskAdapter, Registration, Registry, ShutdownHandle}; +use workshop_support::ReconnectBackoff; + +use crate::gateway_binding::GatewayBinding; +use crate::gateway_progress; +use crate::heartbeat::{self, 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.register_state::(Arc::new(handles)) +} + +/// Registers the gateway subsystem's background tasks: the +/// reachability heartbeat and the gateway progress subscriber. The +/// tasks spawn when the shell starts serving and stop inside the +/// graceful-shutdown signal. The returned guards keep the registrations +/// alive; the composition root holds them for the process lifetime. +pub fn register_tasks( + registry: &Registry, + handles: &GatewayHandles, + progress: Arc, + backoff: ReconnectBackoff, +) -> (Registration, Registration) { + let heartbeat = registry.register_task(Arc::new(BackgroundTaskAdapter::new({ + let registry = registry.clone(); + let binding = handles.binding().clone(); + let health = handles.health().clone(); + move || { + let task = heartbeat::spawn( + binding.clone(), + registry.push(), + health.clone(), + heartbeat::HEARTBEAT_INTERVAL, + backoff.clone(), + ); + ShutdownHandle::new(move || task.shutdown()) + } + }))); + let subscriber = registry.register_task(Arc::new(BackgroundTaskAdapter::new({ + let binding = handles.binding().clone(); + let health = handles.health().clone(); + move || { + let task = + gateway_progress::spawn(binding.clone(), Arc::clone(&progress), health.clone()); + ShutdownHandle::new(move || task.shutdown()) + } + }))); + (heartbeat, subscriber) +} 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..ded03e039 --- /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.register_sink::(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..98679ecad --- /dev/null +++ b/crates/workshop-gateway/src/lib.rs @@ -0,0 +1,40 @@ +//! 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 handles; +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 handles::{GatewayHandles, register, register_tasks}; +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 53% rename from crates/workshop-server/src/observer.rs rename to crates/workshop-gateway/src/observer.rs index 01a95a98a..63944dc23 100644 --- a/crates/workshop-server/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(); @@ -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..204f4bb2a --- /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.register_sink::(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-menu/src/catalog.rs b/crates/workshop-menu/src/catalog.rs new file mode 100644 index 000000000..cd3c795f6 --- /dev/null +++ b/crates/workshop-menu/src/catalog.rs @@ -0,0 +1,89 @@ +//! The chat-capable model catalog push channel, rebroadcast to every +//! connected `/ws` session as a `{"type":"models",...}` frame. +//! +//! 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. 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 tokio::sync::{broadcast, watch}; + +use workshop_protocol::CatalogPush; +use workshop_support::RetainedBus; + +mod chat; +pub use chat::ChatCatalog; +use chat::ChatCatalogBus; +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 +/// generous. +const CATALOG_CHANNEL_CAPACITY: usize = 4; + +/// The shared catalog bus: a cloneable handle onto the broadcast channel, +/// mirroring the status subsystem's [`RetainedBus`]-backed status bus. +#[derive(Debug, Clone)] +pub struct CatalogBus { + bus: RetainedBus, + chat: ChatCatalogBus, +} + +impl CatalogBus { + /// Creates a bus with no subscribers, an empty ring, and no snapshot. + #[must_use] + pub fn new() -> Self { + Self { + bus: RetainedBus::new(CATALOG_CHANNEL_CAPACITY), + chat: ChatCatalogBus::new(), + } + } + + /// Subscribes to every push sent from this call onward. + #[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. + #[must_use] + pub fn latest(&self) -> Option { + self.bus.latest() + } + + /// The current non-empty chat-capable catalog generation. + #[must_use] + pub fn latest_chat(&self) -> Option { + self.chat.latest() + } + + /// Subscribes to chat-capable catalog generation changes. + #[must_use] + pub fn subscribe_chat_generation(&self) -> watch::Receiver { + self.chat.subscribe() + } + + /// Broadcasts one catalog. With no subscribers this is a no-op; a slow + /// subscriber skips ahead rather than applying backpressure. + pub fn publish(&self, models: Vec) { + let models = models.into_iter().filter(is_chat_capable).collect(); + let push = CatalogPush { models }; + self.chat.publish(&push.models); + self.bus.send(push); + } +} + +impl Default for CatalogBus { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests; 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/handles.rs b/crates/workshop-menu/src/handles.rs new file mode 100644 index 000000000..40dc3a59d --- /dev/null +++ b/crates/workshop-menu/src/handles.rs @@ -0,0 +1,81 @@ +//! 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. + +use std::sync::Arc; + +use workshop_registry::{ + CatalogSink, CatalogSinkAdapter, MenuSink, MenuSinkAdapter, Registration, Registry, +}; + +use crate::{CatalogBus, MenuBus}; + +/// 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) { + let catalog_guard = + registry.register_sink::(Arc::new(CatalogSinkAdapter::new({ + let catalog = catalog.clone(); + move |models| catalog.publish(models) + }))); + let menu_guard = registry.register_sink::(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() + }, + ))); + let state = registry + .register_state::(Arc::new(MenuHandles::new(catalog.clone(), menu.clone()))); + (catalog_guard, menu_guard, state) +} diff --git a/crates/workshop-menu/src/lib.rs b/crates/workshop-menu/src/lib.rs new file mode 100644 index 000000000..7983a6735 --- /dev/null +++ b/crates/workshop-menu/src/lib.rs @@ -0,0 +1,29 @@ +//! 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 handles; +pub mod menu; + +pub use catalog::{CatalogBus, ChatCatalog, is_chat_capable}; +pub use handles::{MenuHandles, register}; +pub use menu::{MenuBus, MenuRefusal, SwitchOutcome}; diff --git a/crates/workshop-menu/src/menu.rs b/crates/workshop-menu/src/menu.rs new file mode 100644 index 000000000..239d215f5 --- /dev/null +++ b/crates/workshop-menu/src/menu.rs @@ -0,0 +1,475 @@ +//! The server-owned Model menu: the workbench snapshot pushed to every +//! `/ws` session as a `{"type":"workbench",...}` frame, its broadcast +//! bus, and the per-profile model memory persisted in the state directory. +//! +//! The server owns all Model-menu state and the UI only renders it; in +//! particular `chat_ready` is computed here - a chat-capable model +//! selected, no switch in flight, gateway reachable - and never derived +//! client-side. Like the catalog 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 menu immediately - +//! the delivery contract's resend-on-reconnect for ephemeral frames. +//! +//! Mutation is zone two throughout: a refused mutation (an unknown model +//! id, a second switch while one runs) is a value returned to the caller, +//! and a missing, unreadable, or corrupt memory file means "no memory +//! yet" - logged and tolerated, never fatal. The memory file holds server +//! state only; the UI's panel layout is view state and stays in the +//! webview's localStorage. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +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}; + +/// Ring capacity of the menu bus. Pushes follow user interactions and +/// heartbeat transitions, so a handful of slots is generous. +const MENU_CHANNEL_CAPACITY: usize = 8; + +/// Name of the persisted server-state file, written in the server's +/// state directory. +const WORKSHOP_STATE_FILE: &str = "workshop-state.json"; + +/// The shared menu bus: the Model-menu state, its mutators, and the +/// broadcast channel their snapshots fan out on, mirroring +/// [`crate::catalog::CatalogBus`]. +/// +/// Clones are cheap (a few `Arc` bumps) and share one state, one retained +/// snapshot, and one channel. +#[derive(Debug, Clone)] +pub struct MenuBus { + bus: RetainedBus, + state: Arc>, + // Selections are validated against the retained catalog and + // `chat_ready` reads its emptiness, so the menu holds its own handle. + catalog: CatalogBus, +} + +/// The mutable Model-menu state behind the bus. +#[derive(Debug)] +struct MenuState { + /// Every gateway profile name, in gateway order. + profiles: Vec, + /// The profile the gateway is serving, once known. + active: Option, + /// The profile a switch is loading, while one is in flight. + switching: Option, + /// The model chat requests go to, once one is selected. + selected_model: Option, + /// The heartbeat's verdict on the gateway. + gateway_reachable: bool, + /// Remembered model selection per profile name, persisted to + /// [`WORKSHOP_STATE_FILE`]. + last_selected: HashMap, + /// Where the memory persists; `None` disables persistence. + memory_path: Option, +} + +impl MenuState { + /// Applies the remembered-else-first selection rule for `profile`: + /// the remembered model when `models` still holds it, else the first + /// catalog model. Records the choice in per-profile memory and + /// returns its pending write when a model was selected. Shared by + /// [`MenuBus::finish_switch`] and [`MenuBus::restore_selection`]. + #[must_use] + fn select_for_profile( + &mut self, + profile: String, + models: &[serde_json::Value], + ) -> Option { + self.selected_model = self + .last_selected + .get(&profile) + .filter(|id| models_contain(models, id)) + .cloned() + .or_else(|| first_model_id(models)); + let id = self.selected_model.clone()?; + self.remember(profile, id) + } + + /// Records `id` as the remembered model for `profile` and snapshots + /// the serialized memory as a [`PendingWrite`] for the caller to + /// perform once the state lock is released - the mutators run on the + /// async runtime, and file IO under the guard would block the + /// executor. `None` when persistence is disabled. + #[must_use] + fn remember(&mut self, profile: String, id: String) -> Option { + self.last_selected.insert(profile, id); + let path = self.memory_path.clone()?; + let payload = serde_json::json!({ "last_selected": &self.last_selected }); + Some(PendingWrite { + path, + bytes: payload.to_string().into_bytes(), + }) + } +} + +/// One serialized memory snapshot awaiting its write: the bytes and path +/// are captured under the state lock, and the write runs after the guard +/// drops, off the async executor. +#[derive(Debug)] +struct PendingWrite { + /// Where the memory persists. + path: PathBuf, + /// The serialized [`WORKSHOP_STATE_FILE`] contents. + bytes: Vec, +} + +/// A refused menu mutation. A refusal is a state to report, not an error +/// 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, + }, + + /// 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, + }, +} + +/// How a profile switch ended, reported by whoever ran it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SwitchOutcome { + /// The gateway finished loading the target profile. + Completed, + /// The switch failed; the previously active profile still serves. + Failed, +} + +impl MenuBus { + /// Creates a bus with no subscribers, an empty ring, and no snapshot, + /// 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 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 { + bus: RetainedBus::new(MENU_CHANNEL_CAPACITY), + state: Arc::new(Mutex::new(MenuState { + profiles: Vec::new(), + active: None, + switching: None, + selected_model: None, + gateway_reachable: false, + last_selected, + memory_path, + })), + catalog, + } + } + + /// Subscribes to every snapshot published from this call onward. + #[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. + #[must_use] + pub fn latest(&self) -> Option { + self.bus.latest() + } + + /// Selects `id` as the chat model and publishes a fresh snapshot, + /// remembering the choice for the active profile. + /// + /// # Errors + /// Returns [`MenuRefusal::UnknownModel`] when `id` is not in the + /// current catalog; the refused selection is not applied. + pub fn set_selected(&self, id: &str) -> Result<(), MenuRefusal> { + if !self.catalog_has(id) { + return Err(MenuRefusal::UnknownModel { id: id.to_string() }); + } + let mut state = self.lock_state(); + state.selected_model = Some(id.to_string()); + let pending = state + .active + .clone() + .and_then(|profile| state.remember(profile, id.to_string())); + self.publish(&state); + drop(state); + store_pending(pending); + Ok(()) + } + + /// Marks a switch to profile `name` as in flight and publishes a + /// fresh snapshot; `chat_ready` is false until the switch finishes. + /// + /// # Errors + /// Returns [`MenuRefusal::SwitchInProgress`] while another switch + /// runs - switches are single-flight because the gateway loads one + /// profile at a time. + pub fn begin_switch(&self, name: &str) -> Result<(), MenuRefusal> { + let mut state = self.lock_state(); + if let Some(running) = &state.switching { + return Err(MenuRefusal::SwitchInProgress { + name: running.clone(), + }); + } + state.switching = Some(name.to_string()); + self.publish(&state); + Ok(()) + } + + /// Ends the in-flight switch and publishes a fresh snapshot. On + /// [`SwitchOutcome::Completed`] the target becomes the active profile + /// and the selection moves to the remembered model for it when the + /// 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 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"); + return; + }; + let mut pending = None; + if outcome == SwitchOutcome::Completed { + state.active = Some(target.clone()); + let models = self.catalog_models(); + pending = state.select_for_profile(target, &models); + } + self.publish(&state); + drop(state); + store_pending(pending); + } + + /// Restores a boot-time selection: when nothing is selected and the + /// catalog holds a model, selects the remembered model for the + /// active profile when the catalog still holds it, else the first + /// catalog model - the rule [`MenuBus::finish_switch`] applies - and + /// publishes a fresh snapshot. With a selection already applied, or + /// no selectable catalog model, this is a no-op and publishes + /// nothing. The heartbeat calls this after its boot and reconnect + /// refreshes settle, so a reconnect whose selection survived the + /// outage changes nothing. + pub fn restore_selection(&self) { + let mut state = self.lock_state(); + if state.selected_model.is_some() { + return; + } + let models = self.catalog_models(); + let pending = if let Some(profile) = state.active.clone() { + state.select_for_profile(profile, &models) + } else { + // No active profile means no memory to consult and none to + // record; fall straight back to the first catalog model. + state.selected_model = first_model_id(&models); + None + }; + if state.selected_model.is_none() { + return; + } + self.publish(&state); + drop(state); + store_pending(pending); + } + + /// Records the heartbeat's verdict on the gateway and publishes a + /// fresh snapshot; `chat_ready` is false while the gateway is down. + pub fn set_gateway_reachable(&self, reachable: bool) { + let mut state = self.lock_state(); + state.gateway_reachable = reachable; + self.publish(&state); + } + + /// Records the gateway's profile list and active profile and + /// publishes a fresh snapshot. The boot and reconnect paths feed + /// this from the gateway's profile endpoints; a gateway without + /// profile support feeds an empty list - a state, not an error. + /// The selection is untouched: catalog reconciliation owns + /// selection validity, not the profile list. + pub fn set_profiles(&self, profiles: Vec, active: Option) { + let mut state = self.lock_state(); + state.profiles = profiles; + state.active = active; + self.publish(&state); + } + + /// Revalidates the selection against the current catalog - a selected + /// model the catalog no longer holds is cleared - and republishes the + /// 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) + { + state.selected_model = None; + } + let snapshot = self.snapshot(&state); + if self.latest().as_ref() != Some(&snapshot) { + self.send(snapshot); + } + } + + /// Revalidates the selection after an integration fixture publishes + /// directly to the catalog bus. + #[cfg(feature = "test-fixtures")] + pub fn reconcile_catalog_for_test(&self) { + self.reconcile_catalog(); + } + + /// The state guard, recovering a lock poisoned by a panicking peer + /// rather than wedging the process (the crate's zone-two policy). + fn lock_state(&self) -> MutexGuard<'_, MenuState> { + self.state.lock().unwrap_or_else(PoisonError::into_inner) + } + + /// Builds the wire snapshot of `state`, computing `chat_ready` from + /// its four conditions. + fn snapshot(&self, state: &MenuState) -> WorkbenchSnapshot { + let catalog_has_chat = self + .catalog + .latest() + .is_some_and(|push| push.models.iter().any(is_chat_capable)); + WorkbenchSnapshot { + profiles: state.profiles.clone(), + active: state.active.clone(), + switching: state.switching.clone(), + selected_model: state.selected_model.clone(), + chat_ready: catalog_has_chat + && state.selected_model.is_some() + && state.switching.is_none() + && state.gateway_reachable, + } + } + + /// Snapshots `state` and broadcasts it. + fn publish(&self, state: &MenuState) { + self.send(self.snapshot(state)); + } + + /// 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) { + self.bus.send(snapshot); + } + + /// Whether `id` names a model in the current catalog snapshot. + fn catalog_has(&self, id: &str) -> bool { + self.catalog + .latest() + .is_some_and(|push| models_contain(&push.models, id)) + } + + /// The current catalog's models array, empty before the first push. + fn catalog_models(&self) -> Vec { + self.catalog + .latest() + .map_or_else(Vec::new, |push| push.models) + } +} + +/// Whether the catalog `models` array holds an entry whose `id` is `id`. +fn models_contain(models: &[serde_json::Value], id: &str) -> bool { + models.iter().any(|model| { + is_chat_capable(model) && model.get("id").and_then(serde_json::Value::as_str) == Some(id) + }) +} + +/// The `id` of the first chat-capable catalog entry, when any does. +fn first_model_id(models: &[serde_json::Value]) -> Option { + models + .iter() + .filter(|model| is_chat_capable(model)) + .find_map(|model| { + model + .get("id") + .and_then(serde_json::Value::as_str) + .map(str::to_string) + }) +} + +/// The persisted shape of [`WORKSHOP_STATE_FILE`]. Server state only: +/// the UI's panel layout is view state and stays in webview localStorage. +#[derive(Debug, Default, serde::Deserialize)] +struct StoredState { + /// Remembered model selection per profile name. + #[serde(default)] + last_selected: HashMap, +} + +/// Loads the per-profile model memory. A missing, unreadable, or corrupt +/// file means "no memory yet": logged and tolerated (zone two). +fn load_memory(path: &Path) -> HashMap { + let raw = match std::fs::read_to_string(path) { + Ok(raw) => raw, + Err(error) => { + if error.kind() != std::io::ErrorKind::NotFound { + tracing::warn!( + %error, + path = %path.display(), + "workshop state unreadable; starting with no model memory" + ); + } + return HashMap::new(); + } + }; + match serde_json::from_str::(&raw) { + Ok(stored) => stored.last_selected, + Err(error) => { + tracing::warn!( + %error, + path = %path.display(), + "workshop state corrupt; starting with no model memory" + ); + HashMap::new() + } + } +} + +/// Performs a pending memory write off the async executor. On a runtime +/// the file IO moves to the blocking pool and completes in the +/// background - the memory file is a best-effort cache, so no caller +/// awaits it. Outside a runtime (the unit tests drive the mutators +/// synchronously) the write runs inline instead. +fn store_pending(pending: Option) { + let Some(pending) = pending else { return }; + match tokio::runtime::Handle::try_current() { + Ok(handle) => { + handle.spawn_blocking(move || store_memory(&pending)); + } + Err(_) => store_memory(&pending), + } +} + +/// Writes one per-profile model-memory snapshot through the shared +/// atomic-write helper, so a crash mid-write cannot leave a truncated +/// [`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) = workshop_support::write_atomic(&pending.path, &pending.bytes) { + tracing::warn!( + %error, + path = %pending.path.display(), + "workshop state write failed; model memory not persisted" + ); + } +} + +#[cfg(test)] +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/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..36e1dfc25 --- /dev/null +++ b/crates/workshop-protocol/src/catalog.rs @@ -0,0 +1,53 @@ +//! 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], +} + +/// 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/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..0443bb326 --- /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, is_chat_capable}; +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..8597bca7d --- /dev/null +++ b/crates/workshop-registry/Cargo.toml @@ -0,0 +1,21 @@ +[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 +serde_json.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..543188019 --- /dev/null +++ b/crates/workshop-registry/src/lib.rs @@ -0,0 +1,41 @@ +//! workshop-registry - the keystone of the workshop server +//! decomposition: four contribution collections into which subsystems +//! self-register - routes and background tasks as ordered vectors of +//! trait objects, state handles and push sinks as maps keyed by type. +//! 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 contributions 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. +//! - Never add a field, slot, or accessor naming a subsystem; a new +//! subsystem changes its own crate and one `register` call, never +//! this crate. +//! - An unregistered contribution is a graceful no-op, never an error: +//! consumers branch on `None` and continue degraded, and the [`Push`] +//! facade drops intents whose sink is unregistered. The composition +//! root alone is total: it calls [`Registry::require`] for the boot +//! set, so a missing required contribution fails startup with a typed +//! error naming the absent type. +//! - A registration is alive exactly as long as its guard: dropping the +//! guard deregisters the contribution. + +mod push; +mod registry; +mod traits; + +pub use push::{MenuPush, Push}; +pub use registry::{MissingContribution, Registration, Registry}; +pub use traits::{ + BackgroundTask, BackgroundTaskAdapter, CatalogSink, CatalogSinkAdapter, MenuSink, + MenuSinkAdapter, RouteRegistrar, RouteRegistrarAdapter, ShutdownHandle, StatusChannel, + StatusChannelAdapter, StatusSink, StatusSinkAdapter, WorkspaceRoots, WorkspaceRootsAdapter, +}; diff --git a/crates/workshop-registry/src/push.rs b/crates/workshop-registry/src/push.rs new file mode 100644 index 000000000..8244cba05 --- /dev/null +++ b/crates/workshop-registry/src/push.rs @@ -0,0 +1,185 @@ +//! 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; +use crate::traits::{CatalogSink, MenuSink, StatusSink}; + +/// 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.sink::() { + catalog.publish(models); + } + if let Some(menu) = self.registry.sink::() { + 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.sink::() 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.sink::() { + 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.sink::() { + 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.sink::() { + 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..e46a54e28 --- /dev/null +++ b/crates/workshop-registry/src/push/tests.rs @@ -0,0 +1,291 @@ +//! 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.register_sink::(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.register_sink::(Arc::new( + CatalogSinkAdapter::new(move |models| { + let _ = catalog_tx.send(models); + }), + )); + let menu_guard = { + let calls = Arc::clone(&menu_calls); + registry.register_sink::(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.register_sink::(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 new file mode 100644 index 000000000..360b55a68 --- /dev/null +++ b/crates/workshop-registry/src/registry.rs @@ -0,0 +1,381 @@ +//! The central registry: four contribution collections - routes and +//! background tasks as ordered vectors of trait objects, state handles +//! and push sinks as maps keyed by `TypeId`. The single downcast lives +//! here; callers see typed `Option`s. + +use std::any::{Any, TypeId}; +use std::collections::HashMap; +use std::fmt; +use std::sync::{Arc, PoisonError, RwLock, RwLockReadGuard, RwLockWriteGuard}; + +use crate::push::Push; +use crate::traits::{BackgroundTask, RouteRegistrar}; + +/// Reads the lock, tolerating a poisoned guard: a panicking registrant +/// must not take down every consumer. +fn read(lock: &RwLock) -> RwLockReadGuard<'_, T> { + lock.read().unwrap_or_else(PoisonError::into_inner) +} + +/// Writes the lock, tolerating a poisoned guard. +fn write(lock: &RwLock) -> RwLockWriteGuard<'_, T> { + lock.write().unwrap_or_else(PoisonError::into_inner) +} + +/// A type-keyed contribution cell: one entry per Rust type, the `Arc` +/// boxed so trait-object handle sets (`Arc`) store beside +/// concrete ones. +#[derive(Default)] +struct TypeMap { + entries: HashMap>, +} + +impl TypeMap { + /// The contribution registered under `T`, downcast back to its + /// `Arc`. + fn get(&self) -> Option> { + self.entries + .get(&TypeId::of::())? + .downcast_ref::>() + .cloned() + } + + /// Registers `contribution` under `T`, replacing any previous + /// occupant. + fn insert(&mut self, contribution: &Arc) { + self.entries + .insert(TypeId::of::(), Box::new(Arc::clone(contribution))); + } + + /// Removes `contribution` from `T`'s key when it is still the + /// occupant, so a stale guard never evicts a newer registration. + fn remove(&mut self, contribution: &Arc) { + let current = self + .entries + .get(&TypeId::of::()) + .and_then(|entry| entry.downcast_ref::>()) + .is_some_and(|current| Arc::ptr_eq(current, contribution)); + if current { + self.entries.remove(&TypeId::of::()); + } + } +} + +impl fmt::Debug for TypeMap { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("TypeMap") + .field("registered", &self.entries.len()) + .finish() + } +} + +/// The central registry subsystems self-register into. +/// +/// Clones are cheap (every collection is an `Arc`) and share the same +/// collections, so every handle the composition root hands out sees the +/// same registrations. +#[derive(Clone, Default)] +pub struct Registry { + routes: Arc>>>, + tasks: Arc>>>, + state: Arc>, + sinks: Arc>, +} + +impl fmt::Debug for Registry { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("Registry") + .field("routes", &read(&self.routes).len()) + .field("tasks", &read(&self.tasks).len()) + .field("state", &read(&self.state)) + .field("sinks", &read(&self.sinks)) + .finish() + } +} + +impl Registry { + /// An empty registry: every collection empty until its subsystems + /// register. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Registers a route contribution; the shell merges every + /// registrant into its API router in registration order. The + /// returned guard keeps the contribution alive: dropping it removes + /// the registrant. + pub fn register_routes(&self, registrar: Arc) -> Registration { + write(&self.routes).push(Arc::clone(®istrar)); + let routes = Arc::downgrade(&self.routes); + Registration::new(move || { + let Some(routes) = routes.upgrade() else { + return; + }; + write(&routes).retain(|current| !Arc::ptr_eq(current, ®istrar)); + }) + } + + /// Registers a background task; the shell spawns every registrant + /// with serving and stops each through its [`ShutdownHandle`](crate::ShutdownHandle) + /// in the graceful-shutdown closure. + pub fn register_task(&self, task: Arc) -> Registration { + write(&self.tasks).push(Arc::clone(&task)); + let tasks = Arc::downgrade(&self.tasks); + Registration::new(move || { + let Some(tasks) = tasks.upgrade() else { + return; + }; + write(&tasks).retain(|current| !Arc::ptr_eq(current, &task)); + }) + } + + /// Registers a state handle set under its type; consumers read it + /// back with [`Registry::state`]. Re-registering `T` replaces the + /// previous occupant, whose stale guard then deregisters nothing. + pub fn register_state(&self, handles: Arc) -> Registration + where + T: ?Sized + Send + Sync + 'static, + { + write(&self.state).insert(&handles); + let state = Arc::downgrade(&self.state); + Registration::new(move || { + let Some(state) = state.upgrade() else { + return; + }; + write(&state).remove(&handles); + }) + } + + /// Registers a producer sink under its trait type; the [`Push`] + /// facade resolves it with [`Registry::sink`]. + pub fn register_sink(&self, sink: Arc) -> Registration + where + T: ?Sized + Send + Sync + 'static, + { + write(&self.sinks).insert(&sink); + let sinks = Arc::downgrade(&self.sinks); + Registration::new(move || { + let Some(sinks) = sinks.upgrade() else { + return; + }; + write(&sinks).remove(&sink); + }) + } + + /// Every registered route contribution, in registration order. + #[must_use] + pub fn routes(&self) -> Vec> { + read(&self.routes).clone() + } + + /// Every registered background task, in registration order. + #[must_use] + pub fn tasks(&self) -> Vec> { + read(&self.tasks).clone() + } + + /// The state handle set registered under `T`, or `None` while its + /// subsystem has not registered - a graceful no-op the consumer + /// branches on. + #[must_use] + pub fn state(&self) -> Option> + where + T: ?Sized + Send + Sync + 'static, + { + read(&self.state).get::() + } + + /// The producer sink registered under `T`, or `None` while its + /// subsystem has not registered. + #[must_use] + pub fn sink(&self) -> Option> + where + T: ?Sized + Send + Sync + 'static, + { + read(&self.sinks).get::() + } + + /// [`Registry::state`] made total at the composition root: a missing + /// contribution is a boot failure naming `T`, not a later panic. + /// + /// # Errors + /// Returns [`MissingContribution`] naming `T` when no subsystem has + /// registered it. + pub fn require(&self) -> Result, MissingContribution> + where + T: ?Sized + Send + Sync + 'static, + { + self.state::().ok_or(MissingContribution { + contribution: std::any::type_name::(), + }) + } + + /// The intent-named push facade over the producer sink collection, + /// for subsystems that report what happened without naming another + /// subsystem's bus. + #[must_use] + pub fn push(&self) -> Push { + Push::new(self.clone()) + } +} + +/// A required contribution is absent from the registry: the composition +/// root failed to register a subsystem before sharing state. +#[derive(Debug)] +pub struct MissingContribution { + contribution: &'static str, +} + +impl MissingContribution { + /// The fully qualified type name of the missing contribution. + #[must_use] + pub fn contribution(&self) -> &'static str { + self.contribution + } +} + +impl fmt::Display for MissingContribution { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "no subsystem registered `{}`", self.contribution) + } +} + +impl std::error::Error for MissingContribution {} + +/// The registration guard: keeps the contribution registered while +/// held. +/// +/// Dropping the guard removes the contribution from its collection, +/// unless the key has since been re-registered - a stale guard never +/// evicts a newer occupant. +#[must_use = "dropping the guard deregisters the contribution"] +pub struct Registration { + remove: Option>, +} + +impl Registration { + /// Builds the guard from its deregistration action. + fn new(remove: impl FnOnce() + Send + Sync + 'static) -> Self { + Self { + remove: Some(Box::new(remove)), + } + } +} + +impl Drop for Registration { + fn drop(&mut self) { + if let Some(remove) = self.remove.take() { + remove(); + } + } +} + +impl fmt::Debug for Registration { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.debug_struct("Registration").finish() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::{ + BackgroundTaskAdapter, RouteRegistrarAdapter, ShutdownHandle, StatusSink, StatusSinkAdapter, + }; + + #[test] + fn registered_state_is_served_by_type() { + let registry = Registry::new(); + let _registration = registry.register_state(Arc::new("handles".to_string())); + let handles = registry + .state::() + .expect("the registered handle set is served"); + assert_eq!(handles.as_str(), "handles"); + } + + #[test] + fn state_of_an_unregistered_type_is_none() { + let registry = Registry::new(); + let _registration = registry.register_state(Arc::new("handles".to_string())); + assert!( + registry.state::().is_none(), + "a type no subsystem registered serves nothing" + ); + } + + #[test] + fn dropping_the_registration_empties_the_key() { + let registry = Registry::new(); + let registration = registry.register_state(Arc::new("handles".to_string())); + assert!(registry.state::().is_some()); + drop(registration); + assert!( + registry.state::().is_none(), + "the key empties when the guard drops" + ); + } + + #[test] + fn route_registrants_are_served_in_registration_order() { + let registry = Registry::new(); + let first: Arc = + Arc::new(RouteRegistrarAdapter::new(axum::Router::new)); + let second: Arc = + Arc::new(RouteRegistrarAdapter::new(axum::Router::new)); + let _first_guard = registry.register_routes(Arc::clone(&first)); + let _second_guard = registry.register_routes(Arc::clone(&second)); + let routes = registry.routes(); + assert_eq!(routes.len(), 2, "both registrants are served"); + assert!( + Arc::ptr_eq(&routes[0], &first) && Arc::ptr_eq(&routes[1], &second), + "registrants are served in registration order" + ); + } + + #[test] + fn a_registered_task_is_served_until_its_guard_drops() { + let registry = Registry::new(); + let task: Arc = Arc::new(BackgroundTaskAdapter::new(|| { + ShutdownHandle::new(|| async {}) + })); + let registration = registry.register_task(Arc::clone(&task)); + let tasks = registry.tasks(); + assert_eq!(tasks.len(), 1, "the registered task is served"); + assert!( + Arc::ptr_eq(&tasks[0], &task), + "the served task is the registered one" + ); + drop(registration); + assert!( + registry.tasks().is_empty(), + "dropping the guard removes the task" + ); + } + + #[test] + fn require_on_a_missing_key_names_the_missing_type() { + let registry = Registry::new(); + let error = registry + .require::() + .expect_err("an unregistered type fails require"); + assert_eq!(error.contribution(), std::any::type_name::()); + assert!( + error.to_string().contains("String"), + "the error message names the missing type: {error}" + ); + } + + #[test] + fn a_sink_round_trips_through_its_trait_type() { + let registry = Registry::new(); + let _registration = + registry.register_sink::(Arc::new(StatusSinkAdapter::new(|_| {}))); + assert!( + registry.sink::().is_some(), + "the sink collection serves the registered trait object" + ); + } +} diff --git a/crates/workshop-registry/src/traits.rs b/crates/workshop-registry/src/traits.rs new file mode 100644 index 000000000..124c01222 --- /dev/null +++ b/crates/workshop-registry/src/traits.rs @@ -0,0 +1,418 @@ +//! The sealed subsystem traits: one per contribution kind in the +//! decomposition's inventory (routes, background tasks, push channels +//! and sinks, shared views). +//! +//! All 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, never by implementing a trait +//! downstream. + +use std::fmt; +use std::path::PathBuf; +use std::pin::Pin; + +use axum::Router; +use tokio::sync::broadcast; + +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; +} + +/// Background task spawning: a subsystem starts one long-lived task, so +/// the composition root holds no `tokio::spawn` calls of its own. +pub trait BackgroundTask: Sealed + Send + Sync { + /// Spawns the task; the returned handle is the shell's shutdown + /// lever. + fn spawn(&self) -> ShutdownHandle; +} + +/// The boxed stop-and-await future one task's shutdown resolves to. +type StopFuture = Pin + Send>>; + +/// The boxed closure producing a task's [`StopFuture`]. +type Stop = Box StopFuture + Send>; + +/// The shutdown lever of one spawned background task: a concrete type, +/// never a trait with an `async fn` method, which would not be +/// dyn-compatible. Signaling and awaiting are one call, so the shell's +/// graceful-shutdown closure cannot fire a stop it forgets to await. +pub struct ShutdownHandle { + stop: Option, +} + +impl ShutdownHandle { + /// Wraps a closure yielding the task's stop-and-await future. + pub fn new(stop: F) -> Self + where + F: FnOnce() -> Fut + Send + 'static, + Fut: Future + Send + 'static, + { + Self { + stop: Some(Box::new(move || Box::pin(stop()))), + } + } + + /// Signals the task to stop and waits for it to finish. + pub async fn shutdown(mut self) { + if let Some(stop) = self.stop.take() { + stop().await; + } + } +} + +impl fmt::Debug for ShutdownHandle { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.debug_struct("ShutdownHandle").finish() + } +} + +/// 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; +} + +/// 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 +/// 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() + } +} + +/// 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() + } +} + +/// 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 [`BackgroundTask`] backed by one closure spawning the subsystem's +/// task: the registration adapter for a background task. The registry's +/// traits are sealed, so the registrant plugs its task in through this +/// adapter rather than implementing the trait itself. +pub struct BackgroundTaskAdapter { + spawn: F, +} + +impl BackgroundTaskAdapter +where + F: Fn() -> ShutdownHandle + Send + Sync, +{ + /// Builds the adapter from the subsystem's spawn closure. + pub fn new(spawn: F) -> Self { + Self { spawn } + } +} + +impl Sealed for BackgroundTaskAdapter where F: Fn() -> ShutdownHandle + Send + Sync {} + +impl BackgroundTask for BackgroundTaskAdapter +where + F: Fn() -> ShutdownHandle + Send + Sync, +{ + fn spawn(&self) -> ShutdownHandle { + (self.spawn)() + } +} + +impl fmt::Debug for BackgroundTaskAdapter { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.debug_struct("BackgroundTaskAdapter").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-registry/tests/it/main.rs b/crates/workshop-registry/tests/it/main.rs new file mode 100644 index 000000000..d3485563c --- /dev/null +++ b/crates/workshop-registry/tests/it/main.rs @@ -0,0 +1,198 @@ +//! Integration tests for `workshop-registry`: the contribution +//! collection contract - routes and tasks as ordered vectors, state +//! handles and push sinks keyed by type. + +use std::sync::{Arc, Mutex, PoisonError}; + +use tokio::sync::broadcast; + +use workshop_protocol::{Activity, Severity, StatusBarUpdate}; +use workshop_registry::{Registry, StatusChannel, 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_empty_registry_serves_no_contributions() { + let registry = Registry::new(); + assert!(registry.routes().is_empty()); + assert!(registry.tasks().is_empty()); + assert!(registry.state::().is_none()); + assert!(registry.state::().is_none()); +} + +#[test] +fn a_registered_status_channel_serves_subscribe_and_latest() { + let registry = Registry::new(); + let bus = status_adapter(); + let _registration = registry.register_state::(Arc::new(bus.adapter)); + let channel = registry + .state::() + .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 collection 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_contribution() { + let registry = Registry::new(); + let bus = status_adapter(); + let registration = registry.register_state::(Arc::new(bus.adapter)); + assert!(registry.state::().is_some()); + drop(registration); + assert!( + registry.state::().is_none(), + "the key empties when the guard drops" + ); +} + +#[test] +fn a_stale_guard_never_evicts_a_newer_occupant() { + let registry = Registry::new(); + let stale = registry.register_state::(Arc::new(status_adapter().adapter)); + let _current = registry.register_state::(Arc::new(status_adapter().adapter)); + drop(stale); + assert!( + registry.state::().is_some(), + "the replacement survives the stale guard's drop" + ); +} + +#[test] +fn registry_clones_share_the_same_collections() { + let registry = Registry::new(); + let clone = registry.clone(); + let _registration = + registry.register_state::(Arc::new(status_adapter().adapter)); + assert!( + clone.state::().is_some(), + "a registration through one handle is visible through every clone" + ); +} + +#[test] +fn the_workspace_roots_handle_serves_the_registrants_grants() { + use std::path::PathBuf; + + use workshop_registry::{WorkspaceRoots, WorkspaceRootsAdapter}; + + let registry = Registry::new(); + assert!( + registry.state::().is_none(), + "an unregistered roots handle is a graceful no-op" + ); + let registration = + registry.register_state::(Arc::new(WorkspaceRootsAdapter::new(|| { + vec![PathBuf::from("/granted")] + }))); + let roots = registry + .state::() + .expect("the registered roots handle is served"); + assert_eq!(roots.granted_roots(), vec![PathBuf::from("/granted")]); + drop(registration); + assert!( + registry.state::().is_none(), + "the key empties when the guard drops" + ); +} + +#[test] +fn route_registrants_build_their_routers_in_registration_order() { + use workshop_registry::RouteRegistrarAdapter; + + let registry = Registry::new(); + assert!(registry.routes().is_empty()); + let first: Arc = + Arc::new(RouteRegistrarAdapter::new(axum::Router::new)); + let second: Arc = + Arc::new(RouteRegistrarAdapter::new(axum::Router::new)); + let first_guard = registry.register_routes(Arc::clone(&first)); + let _second_guard = registry.register_routes(Arc::clone(&second)); + let routes = registry.routes(); + assert_eq!(routes.len(), 2, "both registrants are served"); + assert!( + Arc::ptr_eq(&routes[0], &first) && Arc::ptr_eq(&routes[1], &second), + "registrants are served in registration order" + ); + drop(first_guard); + assert_eq!( + registry.routes().len(), + 1, + "a dropped guard removes only its own registrant" + ); +} + +#[test] +fn a_registered_state_handle_downcasts_to_its_concrete_type() { + let registry = Registry::new(); + assert!(registry.state::().is_none()); + let _registration = registry.register_state(Arc::new("handles".to_string())); + let handles = registry + .state::() + .expect("the registered handle set is served"); + assert_eq!(handles.as_str(), "handles"); + assert!( + registry.state::().is_none(), + "state handles are keyed by type" + ); +} diff --git a/crates/workshop-server/AGENTS.md b/crates/workshop-server/AGENTS.md index 2ce65e307..dba49a5c5 100644 --- a/crates/workshop-server/AGENTS.md +++ b/crates/workshop-server/AGENTS.md @@ -9,6 +9,6 @@ This crate owns the Workshop HTTP and WebSocket server and its host-embeddable s - One task owns each socket, its protocol policy, and its cleanup. Agent sessions may survive socket disconnect; other per-request relay work does not gain a session registry. - Every pushed message type is durable or ephemeral. Durable delivery supports replay and duplicate tolerance; ephemeral delivery may coalesce or drop under lag and restores its latest complete snapshot after reconnect. - Work held for a disconnected client cancels through its ownership guard. -- Application state remains typed and construction-phased. Do not replace it with a service locator or late-bound optional state. +- Application state is composed at boot: each subsystem registers its handles into `workshop-registry`, and the shell asserts the composition at startup. Runtime reads of absent optional contributions degrade to no-ops. Do not pass one subsystem's handles into another subsystem's constructor, and do not reintroduce per-request panics on missing registrations. - Asset construction failures return to the host. API-path misses return 404 instead of the SPA index. - Held sockets and uncooperative clients must not make server shutdown unbounded. diff --git a/crates/workshop-server/Cargo.toml b/crates/workshop-server/Cargo.toml index f8402481a..fbd45ffee 100644 --- a/crates/workshop-server/Cargo.toml +++ b/crates/workshop-server/Cargo.toml @@ -14,42 +14,46 @@ 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 -promptforge-agent.workspace = true +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 +workshop-workspace.workspace = true tempfile = { workspace = true, optional = true } [features] default = [] -test-fixtures = ["dep:tempfile"] +# Drops the webview asset layer: the build script skips the esbuild UI +# build, and the asset routes serve through a no-op implementation, so +# server-only integration tests run without Node.js or the UI bundle. +headless = [] +test-fixtures = [ + "dep:tempfile", + "workshop-gateway/test-fixtures", + "workshop-menu/test-fixtures", + "workshop-sessions/test-fixtures", + "workshop-support/test-fixtures", +] [dev-dependencies] workshop-server = { path = ".", features = ["test-fixtures"] } @@ -57,6 +61,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/build.rs b/crates/workshop-server/build.rs index ce67a4385..8c25a47fe 100644 --- a/crates/workshop-server/build.rs +++ b/crates/workshop-server/build.rs @@ -2,12 +2,19 @@ //! `ui/src/main.ts` plus copies of the static assets, all written to //! `$OUT_DIR/ui-dist/` (never into the repository). The crate version is //! baked into the bundle as `__APP_VERSION__`. Requires Node.js 22 and -//! one `npm ci` in `ui/` per checkout; see the crate README. +//! one `npm ci` in `ui/` per checkout; see the crate README. Under the +//! `headless` feature the UI build is skipped and the asset directory is +//! left empty: the asset routes serve through the no-op implementation, +//! so server-only integration tests need neither Node.js nor the bundle. fn main() -> std::process::ExitCode { + if std::env::var_os("CARGO_FEATURE_HEADLESS").is_some() { + return empty_asset_dir(); + } match build_ui::build(build_ui::UiBuild { static_files: build_ui::WORKSHOP_STATIC_FILES, define_app_version: true, + splitting: true, }) { Ok(()) => std::process::ExitCode::SUCCESS, Err(error) => { @@ -16,3 +23,20 @@ fn main() -> std::process::ExitCode { } } } + +/// Creates the empty `$OUT_DIR/ui-dist/` the headless build embeds; the +/// folder must exist for the rust-embed derive. +fn empty_asset_dir() -> std::process::ExitCode { + let Some(out_dir) = std::env::var_os("OUT_DIR") else { + eprintln!("OUT_DIR is not set; run through cargo"); + return std::process::ExitCode::FAILURE; + }; + let dist_dir = std::path::Path::new(&out_dir).join("ui-dist"); + match std::fs::create_dir_all(&dist_dir) { + Ok(()) => std::process::ExitCode::SUCCESS, + Err(error) => { + eprintln!("create {}: {error}", dist_dir.display()); + std::process::ExitCode::FAILURE + } + } +} diff --git a/crates/workshop-server/src/app.rs b/crates/workshop-server/src/app.rs index 04a016b7b..f58f02825 100644 --- a/crates/workshop-server/src/app.rs +++ b/crates/workshop-server/src/app.rs @@ -1,44 +1,84 @@ //! 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 +//! type-keyed state collection. What remains here is the shell's own +//! runtime infrastructure - the shared reconnect backoff - 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::fmt; use std::sync::Arc; use axum::Router; use shared_progress::ProgressHub; -use crate::backoff::ReconnectBackoff; +use workshop_gateway::GatewayHandles; +use workshop_menu::MenuHandles; +use workshop_registry::{Push, Registration, Registry, WorkspaceRoots}; +use workshop_sessions::{AgentSessions, SessionHost, SessionsState}; +use workshop_status::StatusBus; +use workshop_support::{Config, DEFAULT_DEADLINE, ReconnectBackoff, with_deadline}; +use workshop_workspace::Workspace; + 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; 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 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 -/// 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 state +/// collection, 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, + registry: Registry, + // Keeps the subsystems' self-registrations alive; dropping the last + // state clone deregisters them. + _registrations: Registrations, +} + +/// The registration guards keeping every subsystem's self-registrations +/// alive: the push channels, the producer sinks, the state handles, the +/// route registrars, the background tasks, and the workspace roots view. +#[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() + } } impl AppState { @@ -57,29 +97,42 @@ impl AppState { /// The status bus, which every `/ws` session subscribes to so it can /// forward updates; producers report through [`AppState::push`]. + /// + /// # Panics + /// Panics when the composition root never registered the bus - a bug + /// boot already refuses: [`state_with_gateway`] requires every + /// contribution before sharing state. #[must_use] pub fn status(&self) -> StatusBus { - self.status.clone() + self.registry + .require::() + .map_or_else(|error| panic!("{error}"), |handles| (*handles).clone()) } - /// The process progress hub: operations with bounded lifetimes attach - /// trees to it as they run, and the renderer task (spawned with the - /// server) turns its snapshots into the status bar's progress - /// indicator. - pub(crate) fn progress(&self) -> &Arc { - &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 sinks, 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() + } + + /// The gateway subsystem's registered handles. + fn gateway_handles(&self) -> GatewayHandles { + self.registry + .require::() + .map_or_else(|error| panic!("{error}"), |handles| (*handles).clone()) + } + + /// The menu subsystem's registered handles. + fn menu_handles(&self) -> MenuHandles { + self.registry + .require::() + .map_or_else(|error| panic!("{error}"), |handles| (*handles).clone()) } /// 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. @@ -89,34 +142,38 @@ impl AppState { } /// The replaceable Gateway binding shared with long-lived tasks. - pub(crate) fn gateway_binding(&self) -> &GatewayBinding { - &self.gateway + #[cfg(test)] + 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 @@ -124,42 +181,99 @@ 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 + pub fn menu(&self) -> MenuBus { + self.menu_handles().menu().clone() } - /// 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 + /// The subsystem registry: the contribution collections the + /// subsystems self-register into, so consumers reach them by type + /// instead of by name. + #[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 /// [`AgentSessions::close`]. + /// + /// # Panics + /// Panics when the composition root never registered the registry - a + /// bug boot already refuses: [`state_with_gateway`] requires every + /// contribution before sharing state. #[must_use] - pub fn agents(&self) -> &AgentSessions { - &self.agents + pub fn agents(&self) -> AgentSessions { + self.registry + .require::() + .map_or_else(|error| panic!("{error}"), |handles| (*handles).clone()) } } +/// One subsystem's `register` call, named so a boot-composition test can +/// omit it and watch startup refuse to share state. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Omit { + /// `workshop_status::register`. + Status, + /// `workshop_menu::register`. + Menu, + /// `workshop_gateway::register`. + Gateway, + /// `workshop_workspace::register`. + Workspace, + /// `workshop_sessions::register`. + Sessions, +} + /// Builds shared state against an already-resolved gateway endpoint: the /// 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 collections - routes included, which [`router`] merges +/// from the route vector. +/// /// # Errors -/// Returns [`StateError::Gateway`] if the HTTP client cannot be built. +/// Returns [`StateError::Gateway`] if the HTTP client cannot be built, +/// and [`StateError::Composition`] when a required contribution is +/// absent after every subsystem has registered. pub fn state_with_gateway( config: &Config, gateway: &ResolvedGateway, +) -> Result { + compose(config, gateway, None) +} + +/// [`state_with_gateway`] with one subsystem's `register` call removed: +/// the boot-failure test's seam, proving a missing required contribution +/// fails boot with a typed error naming it. +/// +/// # Errors +/// Returns [`StateError::Gateway`] if the HTTP client cannot be built, +/// and [`StateError::Composition`] naming the omitted subsystem's +/// contribution. +pub fn state_with_gateway_omitting( + config: &Config, + gateway: &ResolvedGateway, + omit: Omit, +) -> Result { + compose(config, gateway, Some(omit)) +} + +/// The composition root behind [`state_with_gateway`]; `omit` removes +/// one subsystem's `register` call for the boot-failure test. +fn compose( + config: &Config, + gateway: &ResolvedGateway, + omit: Option, ) -> Result { let status = StatusBus::new(); let catalog = CatalogBus::new(); @@ -169,9 +283,27 @@ 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 subsystems self-register: consumers reach their channels, + // sinks, state handles, routes, and tasks through the registry's + // collections instead of by name. + let registry = Registry::new(); + let mut registrations = Registrations::new(); + if omit != Some(Omit::Status) { + let (channel, sink, state) = workshop_status::register(®istry, &status); + registrations.hold(channel); + registrations.hold(sink); + registrations.hold(state); + } + if omit != Some(Omit::Menu) { + 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. crate::resolve::report(gateway, &push); @@ -183,30 +315,62 @@ pub fn state_with_gateway( .map_err(StateError::Gateway)?; let progress = Arc::new(ProgressHub::new()); let backoff = ReconnectBackoff::new(); + let health = GatewayHealth::new(); + let gateway_handles = GatewayHandles::new(gateway_binding.clone(), health.clone()); + if omit != Some(Omit::Gateway) { + registrations.hold(workshop_gateway::register( + ®istry, + gateway_handles.clone(), + )); + } + // The background tasks register beside the state handles; the shell + // spawns them from the registry's task vector when it starts + // serving. + registrations.hold(workshop_status::register_tasks( + ®istry, + Arc::clone(&progress), + )); + let (heartbeat, subscriber) = workshop_gateway::register_tasks( + ®istry, + &gateway_handles, + Arc::clone(&progress), + backoff.clone(), + ); + registrations.hold(heartbeat); + registrations.hold(subscriber); let workspace = Workspace::new(); + if omit != Some(Omit::Workspace) { + let (routes, state, roots) = workshop_workspace::register(®istry, &workspace); + registrations.hold(routes); + registrations.hold(state); + registrations.hold(roots); + } let agents = AgentSessions::new( config.agents.path.clone(), - config.server.state_dir.join("sessions"), - gateway_binding.clone(), - SessionHost { - push: push.clone(), - backoff: backoff.clone(), - menu: menu.clone(), - workspace: workspace.clone(), - catalog: catalog.clone(), - }, + state_dir.join("sessions"), + gateway_binding, + SessionHost::new(registry.clone(), backoff.clone(), menu, catalog), ); + let sessions = SessionsState::new(registry.clone(), crate::cross_site::origin_allowed); + if omit != Some(Omit::Sessions) { + let (routes, state) = workshop_sessions::register(®istry, &sessions, &agents); + registrations.hold(routes); + registrations.hold(state); + } + // The boot contract: every subsystem's handle set is present before + // state is shared, so a missing contribution fails here, naming the + // type, instead of panicking later at first use. + registry.require::()?; + registry.require::()?; + registry.require::()?; + registry.require::()?; + registry.require::()?; + registry.require::()?; push.push_idle(); Ok(AppState { - gateway: gateway_binding, - status, - progress, - health: GatewayHealth::new(), backoff, - catalog, - menu, - workspace, - agents, + registry, + _registrations: registrations, }) } @@ -225,30 +389,38 @@ pub enum StateError { #[non_exhaustive] #[error("resolve the gateway endpoint")] Resolution(#[source] crate::resolve::ResolveError), + + /// A required subsystem contribution was never registered: the + /// composition root itself is broken, so boot fails naming the + /// absent type instead of panicking later at first use. + #[non_exhaustive] + #[error("compose the subsystem registry: {0}")] + Composition(#[from] workshop_registry::MissingContribution), } -/// 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 `crate::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 vector in +/// registration order - an empty vector 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)); + // The subsystems' routes merge in registration order; an empty + // vector is a graceful no-op. + for registrar in registry.routes() { + 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)) @@ -259,146 +431,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 crate::config::{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 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/assets.rs b/crates/workshop-server/src/assets.rs index d2d8e20b2..eca2906cd 100644 --- a/crates/workshop-server/src/assets.rs +++ b/crates/workshop-server/src/assets.rs @@ -1,5 +1,6 @@ -//! The embedded workshop UI assets and the file-serving helper; the routes -//! that expose them live in [`crate::routes::assets`]. +//! The embedded workshop UI assets, the narrow [`AssetServer`] interface +//! the shell wires into the asset routes, and the file-serving helper; the +//! routes that expose them live in [`crate::routes::assets`]. use axum::http::header; use axum::response::{IntoResponse, Response}; @@ -9,32 +10,137 @@ use crate::error::AppError; /// The workshop UI assets under `$OUT_DIR/ui-dist/`, written by the crate's /// build script (the esbuild bundle plus copies of the static files). Debug /// builds read the files from disk at request time, so UI edits need no Rust -/// recompile; release builds embed them into the binary. +/// recompile; release builds embed them into the binary. Absent under the +/// `headless` feature, which drops the UI build entirely. +#[cfg(not(feature = "headless"))] #[derive(rust_embed::Embed)] #[folder = "$OUT_DIR/ui-dist/"] pub(crate) struct UiAssets; -/// Serves one UI asset from [`UiAssets`] with the given content type. -/// -/// Every asset goes out with `Cache-Control: no-cache`: the bundle is -/// unversioned (`app.js` keeps its name across builds), so a heuristic -/// cache with no validator would serve a stale script against a newer -/// server. `no-cache` forces revalidation on every load. -pub(crate) fn ui_asset(path: &str, content_type: &'static str) -> Response { - match UiAssets::get(path) { - Some(asset) => ( +/// How long a cache may hold one asset. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum CachePolicy { + /// A stable URL whose content changes between builds (`index.html`, + /// the logical `app.js` route): force revalidation on every load, so + /// a heuristic cache never serves a stale script against a newer + /// server. + Revalidate, + /// A content-hashed URL (`bundle/app-.js`, the chunks): the + /// name changes when the content does, so the response may be cached + /// forever. + Immutable, +} + +impl CachePolicy { + /// The Cache-Control header value for the policy. + fn header_value(self) -> &'static str { + match self { + Self::Revalidate => "no-cache", + Self::Immutable => "public, max-age=31536000, immutable", + } + } +} + +/// The build's logical-to-hashed name map, parsed from the UI build's +/// `manifest.json`. The server resolves the stable routes (`/app.js`, +/// `/app.css`) through it to the current hashed files. +#[derive(Clone, Debug)] +pub(crate) struct AssetManifest { + /// The hashed bundle path serving the logical `app.js`. + app_js: String, + /// The hashed bundle path serving the logical `app.css`. + app_css: String, +} + +impl AssetManifest { + /// The hashed target for one logical name, or None when the manifest + /// does not map it. + pub(crate) fn resolve(&self, logical: &str) -> Option<&str> { + match logical { + "app.js" => Some(&self.app_js), + "app.css" => Some(&self.app_css), + _ => None, + } + } +} + +/// The narrow asset-serving interface of the server's webview asset +/// layer. The shell wires exactly one implementation into the asset +/// routes: [`EmbeddedAssets`] in a normal build, [`NoopAssets`] under the +/// `headless` feature, which drops the UI build so server-only +/// integration tests run without the webview bundle. +pub(crate) trait AssetServer { + /// Returns one asset's bytes, or None when the name is absent. + fn get(&self, path: &str) -> Option>; + + /// The build's asset manifest, or None when the build emitted none. + fn manifest(&self) -> Option; +} + +/// The real asset layer: the embedded UI bundle. +#[cfg(not(feature = "headless"))] +pub(crate) struct EmbeddedAssets; + +#[cfg(not(feature = "headless"))] +impl AssetServer for EmbeddedAssets { + fn get(&self, path: &str) -> Option> { + UiAssets::get(path).map(|asset| asset.data.into_owned()) + } + + fn manifest(&self) -> Option { + let asset = UiAssets::get("manifest.json")?; + // A parse failure is safe to ignore: the build writes the manifest + // itself and embeds it beside the assets, so malformed JSON means a + // corrupt build output rather than user input. Treating it as "no + // manifest" degrades the logical routes to the same AssetMissing + // 404 a missing manifest produces, instead of panicking the server. + let value: serde_json::Value = serde_json::from_slice(&asset.data).ok()?; + let target = |logical: &str| value.get(logical)?.as_str().map(str::to_string); + Some(AssetManifest { + app_js: target("app.js")?, + app_css: target("app.css")?, + }) + } +} + +/// The no-op asset layer of a `headless` build: every name misses, so the +/// asset routes answer 404 while the API surface keeps working. +#[cfg(any(feature = "headless", test))] +pub(crate) struct NoopAssets; + +#[cfg(any(feature = "headless", test))] +impl AssetServer for NoopAssets { + fn get(&self, _path: &str) -> Option> { + None + } + + fn manifest(&self) -> Option { + None + } +} + +/// Serves one UI asset through `server` with the given content type and +/// cache policy. +pub(crate) fn ui_asset( + server: &dyn AssetServer, + path: &str, + content_type: &'static str, + cache: CachePolicy, +) -> Response { + match server.get(path) { + Some(data) => ( [ (header::CONTENT_TYPE, content_type), - (header::CACHE_CONTROL, "no-cache"), + (header::CACHE_CONTROL, cache.header_value()), ], - asset.data.into_owned(), + data, ) .into_response(), None => AppError::AssetMissing(path.to_string()).into_response(), } } -#[cfg(test)] +#[cfg(all(test, not(feature = "headless")))] mod tests { use axum::http::StatusCode; @@ -42,7 +148,12 @@ mod tests { /// Asserts an asset name answers 404 rather than file contents. fn assert_asset_misses(path: &str) { - let response = ui_asset(path, "text/plain; charset=utf-8"); + let response = ui_asset( + &EmbeddedAssets, + path, + "text/plain; charset=utf-8", + CachePolicy::Revalidate, + ); assert_eq!( response.status(), StatusCode::NOT_FOUND, @@ -74,4 +185,27 @@ mod tests { fn absolute_path_answers_not_found() { assert_asset_misses(concat!(env!("CARGO_MANIFEST_DIR"), "/Cargo.toml")); } + + #[test] + fn the_noop_asset_layer_serves_nothing() { + let server = NoopAssets; + assert!(server.get("index.html").is_none()); + assert!(server.manifest().is_none()); + let response = ui_asset( + &server, + "index.html", + "text/html; charset=utf-8", + CachePolicy::Revalidate, + ); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + #[test] + fn the_cache_policies_render_their_header_values() { + assert_eq!(CachePolicy::Revalidate.header_value(), "no-cache"); + assert_eq!( + CachePolicy::Immutable.header_value(), + "public, max-age=31536000, immutable" + ); + } } diff --git a/crates/workshop-server/src/catalog.rs b/crates/workshop-server/src/catalog.rs deleted file mode 100644 index 767c8b639..000000000 --- a/crates/workshop-server/src/catalog.rs +++ /dev/null @@ -1,97 +0,0 @@ -//! The chat-capable model catalog push channel, rebroadcast to every -//! connected `/ws` session as a `{"type":"models",...}` frame. -//! -//! 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 -//! resend-on-reconnect for ephemeral frames. - -use std::sync::{Arc, Mutex, PoisonError}; - -use tokio::sync::{broadcast, watch}; - -use crate::protocol::CatalogPush; - -mod chat; -use chat::ChatCatalogBus; -pub(crate) use chat::{ChatCatalog, 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 -/// generous. -const CATALOG_CHANNEL_CAPACITY: usize = 4; - -/// The shared catalog bus: a cloneable handle onto the broadcast channel, -/// mirroring [`crate::status::StatusBus`]. -#[derive(Debug, Clone)] -pub struct CatalogBus { - sender: broadcast::Sender, - latest: Arc>>, - chat: ChatCatalogBus, -} - -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)), - chat: ChatCatalogBus::new(), - } - } - - /// Subscribes to every push sent from this call onward. - pub(crate) fn subscribe(&self) -> broadcast::Receiver { - self.sender.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() - } - - /// The current non-empty chat-capable catalog generation. - pub(crate) fn latest_chat(&self) -> Option { - self.chat.latest() - } - - /// Subscribes to chat-capable catalog generation changes. - pub(crate) fn subscribe_chat_generation(&self) -> watch::Receiver { - self.chat.subscribe() - } - - /// Broadcasts one catalog. With no subscribers this is a no-op; a slow - /// subscriber skips ahead rather than applying backpressure. - pub fn publish(&self, models: Vec) { - 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); - } -} - -impl Default for CatalogBus { - fn default() -> Self { - Self::new() - } -} - -#[cfg(test)] -mod tests; diff --git a/crates/workshop-server/src/error.rs b/crates/workshop-server/src/error.rs index b350d92ea..56008dffb 100644 --- a/crates/workshop-server/src/error.rs +++ b/crates/workshop-server/src/error.rs @@ -4,23 +4,25 @@ //! 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 -//! ([`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; use axum::http::{StatusCode, header}; 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 @@ -35,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. @@ -63,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), @@ -163,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, } } @@ -186,73 +79,27 @@ 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(); 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, @@ -285,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. @@ -294,10 +143,8 @@ 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(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")); } @@ -309,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) @@ -424,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] @@ -447,15 +191,7 @@ 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(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", @@ -465,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" ); } @@ -480,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/fixtures.rs b/crates/workshop-server/src/fixtures.rs index 22f72d43b..5cb657fe3 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::app::{Omit, state_with_gateway, state_with_gateway_omitting}; 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; @@ -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 b294eac75..000000000 --- a/crates/workshop-server/src/gateway.rs +++ /dev/null @@ -1,1235 +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 crate::backoff::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 57b6b16ba..000000000 --- a/crates/workshop-server/src/heartbeat.rs +++ /dev/null @@ -1,897 +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 crate::backoff::ReconnectBackoff; -use crate::gateway_binding::{GatewayBinding, GatewaySnapshot}; -use crate::protocol::{Activity, Severity, StatusBarUpdate}; -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 crate::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/input.rs b/crates/workshop-server/src/input.rs deleted file mode 100644 index a5e871973..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 crate::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 484cc0da4..600eca028 100644 --- a/crates/workshop-server/src/lib.rs +++ b/crates/workshop-server/src/lib.rs @@ -7,37 +7,51 @@ //! 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. +//! +//! ## Invariants +//! +//! - Tier: shell; may depend on: the vocabulary crates +//! (`workshop-protocol`, `workshop-registry`, `workshop-support`), +//! the service crates (`workshop-gateway`, `workshop-menu`, +//! `workshop-status`), and the feature crates (`workshop-sessions`, +//! `workshop-workspace`). Read `AGENTS.md` before adding an import. +//! - Every file in this crate stays under 500 lines; split first, then +//! edit. 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; -mod gateway_progress; -mod heartbeat; -mod input; -mod menu; -mod observer; -mod progress; -mod protocol; -mod push; -mod relay; -mod resolve; mod routes; mod serve; -mod session; -mod session_agents; -mod status; + +// 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"))] -mod test_gateway; -mod workspace; +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, @@ -51,21 +65,21 @@ 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, 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 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_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/menu.rs b/crates/workshop-server/src/menu.rs deleted file mode 100644 index 498f07a21..000000000 --- a/crates/workshop-server/src/menu.rs +++ /dev/null @@ -1,932 +0,0 @@ -//! The server-owned Model menu: the workbench snapshot pushed to every -//! `/ws` session as a `{"type":"workbench",...}` frame, its broadcast -//! bus, and the per-profile model memory persisted in the state directory. -//! -//! The server owns all Model-menu state and the UI only renders it; in -//! particular `chat_ready` is computed here - a chat-capable model -//! selected, no switch in flight, gateway reachable - and never derived -//! client-side. Like the catalog 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 menu immediately - -//! the delivery contract's resend-on-reconnect for ephemeral frames. -//! -//! Mutation is zone two throughout: a refused mutation (an unknown model -//! id, a second switch while one runs) is a value returned to the caller, -//! and a missing, unreadable, or corrupt memory file means "no memory -//! yet" - logged and tolerated, never fatal. The memory file holds server -//! state only; the UI's panel layout is view state and stays in the -//! webview's localStorage. - -use std::collections::HashMap; -use std::path::{Path, PathBuf}; -use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; - -use tokio::sync::broadcast; - -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. -const MENU_CHANNEL_CAPACITY: usize = 8; - -/// Name of the persisted server-state file, written in the server's -/// state directory. -const WORKSHOP_STATE_FILE: &str = "workshop-state.json"; - -/// The shared menu bus: the Model-menu state, its mutators, and the -/// broadcast channel their snapshots fan out on, mirroring -/// [`crate::catalog::CatalogBus`]. -/// -/// Clones are cheap (a few `Arc` bumps) and share one state, one retained -/// snapshot, and one channel. -#[derive(Debug, Clone)] -pub struct MenuBus { - sender: broadcast::Sender, - latest: Arc>>, - state: Arc>, - // Selections are validated against the retained catalog and - // `chat_ready` reads its emptiness, so the menu holds its own handle. - catalog: CatalogBus, -} - -/// The mutable Model-menu state behind the bus. -#[derive(Debug)] -struct MenuState { - /// Every gateway profile name, in gateway order. - profiles: Vec, - /// The profile the gateway is serving, once known. - active: Option, - /// The profile a switch is loading, while one is in flight. - switching: Option, - /// The model chat requests go to, once one is selected. - selected_model: Option, - /// The heartbeat's verdict on the gateway. - gateway_reachable: bool, - /// Remembered model selection per profile name, persisted to - /// [`WORKSHOP_STATE_FILE`]. - last_selected: HashMap, - /// Where the memory persists; `None` disables persistence. - memory_path: Option, -} - -impl MenuState { - /// Applies the remembered-else-first selection rule for `profile`: - /// the remembered model when `models` still holds it, else the first - /// catalog model. Records the choice in per-profile memory and - /// returns its pending write when a model was selected. Shared by - /// [`MenuBus::finish_switch`] and [`MenuBus::restore_selection`]. - #[must_use] - fn select_for_profile( - &mut self, - profile: String, - models: &[serde_json::Value], - ) -> Option { - self.selected_model = self - .last_selected - .get(&profile) - .filter(|id| models_contain(models, id)) - .cloned() - .or_else(|| first_model_id(models)); - let id = self.selected_model.clone()?; - self.remember(profile, id) - } - - /// Records `id` as the remembered model for `profile` and snapshots - /// the serialized memory as a [`PendingWrite`] for the caller to - /// perform once the state lock is released - the mutators run on the - /// async runtime, and file IO under the guard would block the - /// executor. `None` when persistence is disabled. - #[must_use] - fn remember(&mut self, profile: String, id: String) -> Option { - self.last_selected.insert(profile, id); - let path = self.memory_path.clone()?; - let payload = serde_json::json!({ "last_selected": &self.last_selected }); - Some(PendingWrite { - path, - bytes: payload.to_string().into_bytes(), - }) - } -} - -/// One serialized memory snapshot awaiting its write: the bytes and path -/// are captured under the state lock, and the write runs after the guard -/// drops, off the async executor. -#[derive(Debug)] -struct PendingWrite { - /// Where the memory persists. - path: PathBuf, - /// The serialized [`WORKSHOP_STATE_FILE`] contents. - bytes: Vec, -} - -/// A refused menu mutation. A refusal is a state to report, not an error -/// to escalate (zone two): the caller relays it and the applied state is -/// untouched. -#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] -pub enum MenuRefusal { - /// The requested model id is not in the current catalog. - #[error("unknown model {id:?}: not in the current catalog")] - UnknownModel { - /// The id that was requested. - id: String, - }, - - /// A profile switch is already in flight; switches are single-flight. - #[error("a switch to {name:?} is already in progress")] - SwitchInProgress { - /// The target of the switch already running. - name: String, - }, -} - -/// How a profile switch ended, reported by whoever ran it. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum SwitchOutcome { - /// The gateway finished loading the target profile. - Completed, - /// The switch failed; the previously active profile still serves. - Failed, -} - -impl MenuBus { - /// Creates a bus with no subscribers, an empty ring, and no snapshot, - /// 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 { - 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)), - state: Arc::new(Mutex::new(MenuState { - profiles: Vec::new(), - active: None, - switching: None, - selected_model: None, - gateway_reachable: false, - last_selected, - memory_path, - })), - catalog, - } - } - - /// Subscribes to every snapshot published from this call onward. - pub(crate) fn subscribe(&self) -> broadcast::Receiver { - self.sender.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() - } - - /// Selects `id` as the chat model and publishes a fresh snapshot, - /// remembering the choice for the active profile. - /// - /// # Errors - /// Returns [`MenuRefusal::UnknownModel`] when `id` is not in the - /// current catalog; the refused selection is not applied. - pub fn set_selected(&self, id: &str) -> Result<(), MenuRefusal> { - if !self.catalog_has(id) { - return Err(MenuRefusal::UnknownModel { id: id.to_string() }); - } - let mut state = self.lock_state(); - state.selected_model = Some(id.to_string()); - let pending = state - .active - .clone() - .and_then(|profile| state.remember(profile, id.to_string())); - self.publish(&state); - drop(state); - store_pending(pending); - Ok(()) - } - - /// Marks a switch to profile `name` as in flight and publishes a - /// fresh snapshot; `chat_ready` is false until the switch finishes. - /// - /// # Errors - /// Returns [`MenuRefusal::SwitchInProgress`] while another switch - /// runs - switches are single-flight because the gateway loads one - /// profile at a time. - pub fn begin_switch(&self, name: &str) -> Result<(), MenuRefusal> { - let mut state = self.lock_state(); - if let Some(running) = &state.switching { - return Err(MenuRefusal::SwitchInProgress { - name: running.clone(), - }); - } - state.switching = Some(name.to_string()); - self.publish(&state); - Ok(()) - } - - /// Ends the in-flight switch and publishes a fresh snapshot. On - /// [`SwitchOutcome::Completed`] the target becomes the active profile - /// and the selection moves to the remembered model for it when the - /// 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) { - let mut state = self.lock_state(); - let Some(target) = state.switching.take() else { - tracing::warn!("finish_switch with no switch in flight; ignored"); - return; - }; - let mut pending = None; - if outcome == SwitchOutcome::Completed { - state.active = Some(target.clone()); - let models = self.catalog_models(); - pending = state.select_for_profile(target, &models); - } - self.publish(&state); - drop(state); - store_pending(pending); - } - - /// Restores a boot-time selection: when nothing is selected and the - /// catalog holds a model, selects the remembered model for the - /// active profile when the catalog still holds it, else the first - /// catalog model - the rule [`MenuBus::finish_switch`] applies - and - /// publishes a fresh snapshot. With a selection already applied, or - /// no selectable catalog model, this is a no-op and publishes - /// 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) { - let mut state = self.lock_state(); - if state.selected_model.is_some() { - return; - } - let models = self.catalog_models(); - let pending = if let Some(profile) = state.active.clone() { - state.select_for_profile(profile, &models) - } else { - // No active profile means no memory to consult and none to - // record; fall straight back to the first catalog model. - state.selected_model = first_model_id(&models); - None - }; - if state.selected_model.is_none() { - return; - } - self.publish(&state); - drop(state); - store_pending(pending); - } - - /// Records the heartbeat's verdict on the gateway and publishes a - /// fresh snapshot; `chat_ready` is false while the gateway is down. - pub fn set_gateway_reachable(&self, reachable: bool) { - let mut state = self.lock_state(); - state.gateway_reachable = reachable; - self.publish(&state); - } - - /// Records the gateway's profile list and active profile and - /// publishes a fresh snapshot. The boot and reconnect paths feed - /// this from the gateway's profile endpoints; a gateway without - /// profile support feeds an empty list - a state, not an error. - /// The selection is untouched: catalog reconciliation owns - /// selection validity, not the profile list. - pub fn set_profiles(&self, profiles: Vec, active: Option) { - let mut state = self.lock_state(); - state.profiles = profiles; - state.active = active; - self.publish(&state); - } - - /// 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) { - let mut state = self.lock_state(); - if let Some(selected) = &state.selected_model - && !self.catalog_has(selected) - { - state.selected_model = None; - } - let snapshot = self.snapshot(&state); - if self.latest().as_ref() != Some(&snapshot) { - self.send(snapshot); - } - } - - /// Revalidates the selection after an integration fixture publishes - /// directly to the catalog bus. - #[cfg(feature = "test-fixtures")] - pub fn reconcile_catalog_for_test(&self) { - self.reconcile_catalog(); - } - - /// The state guard, recovering a lock poisoned by a panicking peer - /// rather than wedging the process (the crate's zone-two policy). - fn lock_state(&self) -> MutexGuard<'_, MenuState> { - self.state.lock().unwrap_or_else(PoisonError::into_inner) - } - - /// Builds the wire snapshot of `state`, computing `chat_ready` from - /// its four conditions. - fn snapshot(&self, state: &MenuState) -> WorkbenchSnapshot { - let catalog_has_chat = self - .catalog - .latest() - .is_some_and(|push| push.models.iter().any(is_chat_capable)); - WorkbenchSnapshot { - profiles: state.profiles.clone(), - active: state.active.clone(), - switching: state.switching.clone(), - selected_model: state.selected_model.clone(), - chat_ready: catalog_has_chat - && state.selected_model.is_some() - && state.switching.is_none() - && state.gateway_reachable, - } - } - - /// Snapshots `state` and broadcasts it. - fn publish(&self, state: &MenuState) { - self.send(self.snapshot(state)); - } - - /// 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); - } - - /// Whether `id` names a model in the current catalog snapshot. - fn catalog_has(&self, id: &str) -> bool { - self.catalog - .latest() - .is_some_and(|push| models_contain(&push.models, id)) - } - - /// The current catalog's models array, empty before the first push. - fn catalog_models(&self) -> Vec { - self.catalog - .latest() - .map_or_else(Vec::new, |push| push.models) - } -} - -/// Whether the catalog `models` array holds an entry whose `id` is `id`. -fn models_contain(models: &[serde_json::Value], id: &str) -> bool { - models.iter().any(|model| { - is_chat_capable(model) && model.get("id").and_then(serde_json::Value::as_str) == Some(id) - }) -} - -/// The `id` of the first chat-capable catalog entry, when any does. -fn first_model_id(models: &[serde_json::Value]) -> Option { - models - .iter() - .filter(|model| is_chat_capable(model)) - .find_map(|model| { - model - .get("id") - .and_then(serde_json::Value::as_str) - .map(str::to_string) - }) -} - -/// The persisted shape of [`WORKSHOP_STATE_FILE`]. Server state only: -/// the UI's panel layout is view state and stays in webview localStorage. -#[derive(Debug, Default, serde::Deserialize)] -struct StoredState { - /// Remembered model selection per profile name. - #[serde(default)] - last_selected: HashMap, -} - -/// Loads the per-profile model memory. A missing, unreadable, or corrupt -/// file means "no memory yet": logged and tolerated (zone two). -fn load_memory(path: &Path) -> HashMap { - let raw = match std::fs::read_to_string(path) { - Ok(raw) => raw, - Err(error) => { - if error.kind() != std::io::ErrorKind::NotFound { - tracing::warn!( - %error, - path = %path.display(), - "workshop state unreadable; starting with no model memory" - ); - } - return HashMap::new(); - } - }; - match serde_json::from_str::(&raw) { - Ok(stored) => stored.last_selected, - Err(error) => { - tracing::warn!( - %error, - path = %path.display(), - "workshop state corrupt; starting with no model memory" - ); - HashMap::new() - } - } -} - -/// Performs a pending memory write off the async executor. On a runtime -/// the file IO moves to the blocking pool and completes in the -/// background - the memory file is a best-effort cache, so no caller -/// awaits it. Outside a runtime (the unit tests drive the mutators -/// synchronously) the write runs inline instead. -fn store_pending(pending: Option) { - let Some(pending) = pending else { return }; - match tokio::runtime::Handle::try_current() { - Ok(handle) => { - handle.spawn_blocking(move || store_memory(&pending)); - } - Err(_) => store_memory(&pending), - } -} - -/// Writes one per-profile model-memory snapshot through the shared -/// atomic-write helper, so a crash mid-write cannot leave a truncated -/// [`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) { - tracing::warn!( - %error, - path = %pending.path.display(), - "workshop state write failed; model memory not persisted" - ); - } -} - -#[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" - ); - } -} diff --git a/crates/workshop-server/src/progress.rs b/crates/workshop-server/src/progress.rs deleted file mode 100644 index 039642541..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::protocol::Activity; -use crate::push::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(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::protocol::{Progress, Severity, StatusBarUpdate}; - use crate::status::StatusBus; - - /// 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/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 deleted file mode 100644 index 586bd6bef..000000000 --- a/crates/workshop-server/src/push.rs +++ /dev/null @@ -1,270 +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 [`crate::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; - -/// 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",...}` [`crate::protocol::StatusFrame`] at - /// [`Severity::Info`](crate::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",...}` - /// [`crate::protocol::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 crate::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/relay.rs b/crates/workshop-server/src/relay.rs deleted file mode 100644 index 32ccd50ff..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::protocol::Activity; -use crate::push::Push; - -/// 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/resolve.rs b/crates/workshop-server/src/resolve.rs deleted file mode 100644 index 753b4f9b0..000000000 --- a/crates/workshop-server/src/resolve.rs +++ /dev/null @@ -1,594 +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 crate::config::GatewayConfig; -use crate::protocol::Activity; -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/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/assets.rs b/crates/workshop-server/src/routes/assets.rs index 3b593ada0..f14c28090 100644 --- a/crates/workshop-server/src/routes/assets.rs +++ b/crates/workshop-server/src/routes/assets.rs @@ -1,14 +1,34 @@ //! Routes serving the embedded workshop UI assets. use axum::Router; -use axum::response::Response; +use axum::extract::Path; +use axum::response::{IntoResponse, Response}; use axum::routing::get; -use crate::assets; +use crate::assets::{self, AssetServer, CachePolicy}; +use crate::error::AppError; -/// The UI asset routes: the index page, the bundled script and styles, -/// the PCM worklet, and the program icon at 1x and 2x. Stateless: every -/// response comes straight from [`crate::assets::UiAssets`]. +/// The asset layer the shell wires into these routes: the embedded UI +/// bundle, or the no-op implementation under the `headless` feature, +/// which drops the UI build so server-only integration tests run without +/// the webview assets. +fn asset_server() -> &'static dyn AssetServer { + #[cfg(not(feature = "headless"))] + { + &assets::EmbeddedAssets + } + #[cfg(feature = "headless")] + { + &assets::NoopAssets + } +} + +/// The UI asset routes: the index page, the bundled script and styles +/// (stable logical routes resolving through the build's manifest, plus +/// the content-hashed `bundle/` files the stamped index page loads +/// directly), the code-split chunks the bundle lazy-loads, the PCM +/// worklet, and the program icon at 1x and 2x. Stateless: every response +/// comes straight from the [`AssetServer`]. pub(crate) fn routes() -> Router { Router::new() .route("/", get(ui_index)) @@ -16,47 +36,149 @@ pub(crate) fn routes() -> Router { .route("/app.css", get(ui_app_css)) .route("/style.css", get(ui_style_css)) .route("/pcm-worklet.js", get(ui_pcm_worklet)) + .route("/bundle/{*name}", get(ui_bundle)) + .route("/chunks/{*name}", get(ui_chunk)) .route("/icons/promptforge-icon.png", get(ui_program_icon)) .route("/icons/promptforge-icon@2x.png", get(ui_program_icon_2x)) } /// Serves the chat UI's `index.html`. async fn ui_index() -> Response { - assets::ui_asset("index.html", "text/html; charset=utf-8") + assets::ui_asset( + asset_server(), + "index.html", + "text/html; charset=utf-8", + CachePolicy::Revalidate, + ) } -/// Serves the chat UI's bundled application script. +/// Serves the bundled application script through the stable logical +/// route: the build's manifest maps `app.js` to the current +/// content-hashed file, whose bytes this answers with. The URL keeps its +/// meaning across builds, so the response must revalidate; the stamped +/// index page loads the hashed URL (immutable) directly. async fn ui_app_js() -> Response { - assets::ui_asset("app.js", "text/javascript; charset=utf-8") + logical_asset("app.js", "text/javascript; charset=utf-8") } /// Serves the stylesheet esbuild extracts from the bundle's CSS imports -/// (the dockview styles and the workshop components' colocated CSS). +/// (the dockview styles and the workshop components' colocated CSS), +/// through the same manifest resolution as the script. async fn ui_app_css() -> Response { - assets::ui_asset("app.css", "text/css; charset=utf-8") + logical_asset("app.css", "text/css; charset=utf-8") +} + +/// Serves one logical bundle route by resolving the build manifest to +/// the current hashed file. +fn logical_asset(logical: &str, content_type: &'static str) -> Response { + let server = asset_server(); + let Some(manifest) = server.manifest() else { + return AppError::AssetMissing(logical.to_string()).into_response(); + }; + let Some(target) = manifest.resolve(logical) else { + return AppError::AssetMissing(logical.to_string()).into_response(); + }; + assets::ui_asset(server, target, content_type, CachePolicy::Revalidate) } /// Serves the chat UI's own stylesheet. async fn ui_style_css() -> Response { - assets::ui_asset("style.css", "text/css; charset=utf-8") + assets::ui_asset( + asset_server(), + "style.css", + "text/css; charset=utf-8", + CachePolicy::Revalidate, + ) } /// Serves the AudioWorklet PCM capture processor. async fn ui_pcm_worklet() -> Response { - assets::ui_asset("pcm-worklet.js", "text/javascript; charset=utf-8") + assets::ui_asset( + asset_server(), + "pcm-worklet.js", + "text/javascript; charset=utf-8", + CachePolicy::Revalidate, + ) +} + +/// The content type for one wildcard-served bundle file, or None when +/// the extension is not one esbuild emits. +fn bundle_content_type(name: &str) -> Option<&'static str> { + let extension_is = |expected: &str| { + std::path::Path::new(name) + .extension() + .is_some_and(|extension| extension.eq_ignore_ascii_case(expected)) + }; + if extension_is("js") { + Some("text/javascript; charset=utf-8") + } else if extension_is("css") { + Some("text/css; charset=utf-8") + } else { + None + } +} + +/// Serves one content-hashed entry file. esbuild emits the entry (and +/// its extracted stylesheet) under `bundle/` with content-hashed names, +/// so the routes cannot name them individually; the wildcard stays inside +/// the embedded asset root through [`assets::ui_asset`], and only the two +/// kinds esbuild emits are served. The names change with the content, so +/// the responses are cached immutably. +async fn ui_bundle(Path(name): Path) -> Response { + let Some(content_type) = bundle_content_type(&name) else { + return AppError::AssetMissing(format!("bundle/{name}")).into_response(); + }; + assets::ui_asset( + asset_server(), + &format!("bundle/{name}"), + content_type, + CachePolicy::Immutable, + ) +} + +/// Serves one code-split bundle chunk. esbuild emits the lazily loaded +/// feature chunks (the agent session's Shiki/TipTap graph, the editor's +/// CodeMirror) under `chunks/` with content-hashed names, so the routes +/// cannot name them individually; the wildcard stays inside the embedded +/// asset root through [`assets::ui_asset`], and only the two kinds +/// esbuild emits are served. The names change with the content, so the +/// responses are cached immutably. +async fn ui_chunk(Path(name): Path) -> Response { + let Some(content_type) = bundle_content_type(&name) else { + return AppError::AssetMissing(format!("chunks/{name}")).into_response(); + }; + assets::ui_asset( + asset_server(), + &format!("chunks/{name}"), + content_type, + CachePolicy::Immutable, + ) } /// Serves the program icon shown in the custom title bar at 1x (128 px), /// the `src` of the title bar ``. async fn ui_program_icon() -> Response { - assets::ui_asset("icons/promptforge-icon.png", "image/png") + assets::ui_asset( + asset_server(), + "icons/promptforge-icon.png", + "image/png", + CachePolicy::Revalidate, + ) } /// Serves the program icon at 2x (256 px), the title bar's `srcset` /// entry for high-DPI displays. async fn ui_program_icon_2x() -> Response { - assets::ui_asset("icons/promptforge-icon@2x.png", "image/png") + assets::ui_asset( + asset_server(), + "icons/promptforge-icon@2x.png", + "image/png", + CachePolicy::Revalidate, + ) } -#[cfg(test)] +#[cfg(all(test, not(feature = "headless")))] mod tests; + +#[cfg(all(test, feature = "headless"))] +mod headless_tests; diff --git a/crates/workshop-server/src/routes/assets/headless_tests.rs b/crates/workshop-server/src/routes/assets/headless_tests.rs new file mode 100644 index 000000000..9f007dc55 --- /dev/null +++ b/crates/workshop-server/src/routes/assets/headless_tests.rs @@ -0,0 +1,52 @@ +//! Asset-route behavior under the `headless` feature: the asset layer is +//! the no-op implementation, so every UI route answers 404 and the server +//! runs its API surface without the webview bundle. + +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use tower::ServiceExt; + +use crate::app::fixtures::state_for; +use crate::app::router; + +#[tokio::test] +async fn the_asset_routes_answer_not_found() { + for uri in [ + "/", + "/app.js", + "/app.css", + "/style.css", + "/pcm-worklet.js", + "/chunks/chunk-AAAAAAAA.js", + "/bundle/app-AAAAAAAA.js", + "/icons/promptforge-icon.png", + "/icons/promptforge-icon@2x.png", + ] { + let (state, _state_dir) = state_for("http://127.0.0.1:1"); + let request = Request::builder() + .uri(uri) + .body(Body::empty()) + .expect("static request parts are valid"); + let response = router(state).oneshot(request).await.expect("infallible"); + assert_eq!( + response.status(), + StatusCode::NOT_FOUND, + "{uri} is a no-op under the headless feature" + ); + } +} + +#[tokio::test] +async fn the_api_surface_still_answers() { + let (state, _state_dir) = state_for("http://127.0.0.1:1"); + let request = Request::builder() + .uri("/health") + .body(Body::empty()) + .expect("static request parts are valid"); + let response = router(state).oneshot(request).await.expect("infallible"); + assert_eq!( + response.status(), + StatusCode::OK, + "the headless server still serves its API" + ); +} diff --git a/crates/workshop-server/src/routes/assets/tests.rs b/crates/workshop-server/src/routes/assets/tests.rs index bfb54867a..49a4bebe2 100644 --- a/crates/workshop-server/src/routes/assets/tests.rs +++ b/crates/workshop-server/src/routes/assets/tests.rs @@ -1,10 +1,48 @@ use axum::body::Body; -use axum::http::{Request, StatusCode, header}; +use axum::http::{Request, Response, StatusCode, header}; use tower::ServiceExt; use crate::app::fixtures::{body_bytes, state_for}; use crate::app::router; +/// Performs one GET against the router and returns the response. +async fn get(uri: &str) -> Response { + let (state, _state_dir) = state_for("http://127.0.0.1:1"); + let request = Request::builder() + .uri(uri) + .body(Body::empty()) + .unwrap_or_else(|_| panic!("static request parts are valid")); + router(state) + .oneshot(request) + .await + .unwrap_or_else(|_| panic!("the router is infallible")) +} + +/// The response's Cache-Control value. +fn cache_control(response: &Response) -> &header::HeaderValue { + response + .headers() + .get(header::CACHE_CONTROL) + .unwrap_or_else(|| panic!("the response sets cache-control")) +} + +/// The build's asset manifest: the logical-to-hashed name map the UI +/// build emits next to the bundle. +fn asset_manifest() -> serde_json::Value { + let asset = crate::assets::UiAssets::get("manifest.json") + .unwrap_or_else(|| panic!("the UI build emits manifest.json")); + serde_json::from_slice(&asset.data) + .unwrap_or_else(|error| panic!("manifest.json is valid JSON: {error}")) +} + +/// The manifest's hashed bundle target for one logical name. +fn manifest_target(logical: &str) -> String { + asset_manifest()[logical] + .as_str() + .unwrap_or_else(|| panic!("the manifest maps {logical}")) + .to_string() +} + /// Asserts a static UI route answers 200 with the expected content type /// and a non-empty body. async fn assert_ui_asset(uri: &str, expected_content_type: &str) { @@ -12,11 +50,11 @@ async fn assert_ui_asset(uri: &str, expected_content_type: &str) { let request = Request::builder() .uri(uri) .body(Body::empty()) - .expect("static request parts are valid"); + .unwrap_or_else(|_| panic!("static request parts are valid")); let response = router(state) .oneshot(request) .await - .expect("the router is infallible"); + .unwrap_or_else(|_| panic!("the router is infallible")); assert_eq!(response.status(), StatusCode::OK, "{uri} serves"); let content_type = response .headers() @@ -91,3 +129,157 @@ async fn program_icon_is_served_as_png() { async fn program_icon_2x_is_served_as_png() { assert_ui_asset("/icons/promptforge-icon@2x.png", "image/png").await; } + +/// The code-split chunks have content-hashed names, so the test discovers +/// one through the embedded asset listing rather than naming it. +fn first_chunk_name(extension: &str) -> String { + crate::assets::UiAssets::iter() + .map(std::borrow::Cow::into_owned) + .find(|name| name.starts_with("chunks/") && name.ends_with(extension)) + .unwrap_or_else(|| panic!("the UI build emits a chunks/*{extension} chunk")) +} + +#[tokio::test] +async fn a_code_split_chunk_is_served_as_javascript() { + let name = first_chunk_name(".js"); + assert_ui_asset(&format!("/{name}"), "text/javascript; charset=utf-8").await; +} + +#[tokio::test] +async fn a_chunk_without_a_bundle_extension_is_not_found() { + let (state, _state_dir) = state_for("http://127.0.0.1:1"); + let request = Request::builder() + .uri("/chunks/app.toml") + .body(Body::empty()) + .expect("static request parts are valid"); + let response = router(state).oneshot(request).await.expect("infallible"); + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn a_chunk_name_cannot_escape_the_asset_root() { + // The wildcard captures the remainder raw; ui_asset's own tests pin + // traversal refusal for the names it is handed. Here the route must + // not 500 on a hostile name. + let (state, _state_dir) = state_for("http://127.0.0.1:1"); + let request = Request::builder() + .uri("/chunks/..%2F..%2FCargo.toml") + .body(Body::empty()) + .expect("static request parts are valid"); + let response = router(state).oneshot(request).await.expect("infallible"); + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn a_missing_chunk_is_not_found() { + let (state, _state_dir) = state_for("http://127.0.0.1:1"); + let request = Request::builder() + .uri("/chunks/chunk-DEADBEEF00.js") + .body(Body::empty()) + .expect("static request parts are valid"); + let response = router(state).oneshot(request).await.expect("infallible"); + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn the_manifest_maps_logical_names_to_hashed_bundle_files() { + let manifest = asset_manifest(); + for (logical, extension) in [("app.js", ".js"), ("app.css", ".css")] { + let target = manifest[logical] + .as_str() + .unwrap_or_else(|| panic!("the manifest maps {logical}")); + assert!( + target.starts_with("bundle/app-") && target.ends_with(extension), + "{logical} maps to a content-hashed bundle file, got {target:?}" + ); + assert!( + crate::assets::UiAssets::get(target).is_some(), + "the manifest target {target:?} exists in the bundle" + ); + } +} + +#[tokio::test] +async fn the_index_page_references_the_hashed_bundle() { + let response = get("/").await; + assert_eq!(response.status(), StatusCode::OK); + let html = String::from_utf8(body_bytes(response).await.to_vec()).expect("index.html is UTF-8"); + let script = manifest_target("app.js"); + let styles = manifest_target("app.css"); + assert!( + html.contains(&format!("src=\"/{script}\"")), + "index.html loads the hashed script {script:?}" + ); + assert!( + html.contains(&format!("href=\"/{styles}\"")), + "index.html loads the hashed styles {styles:?}" + ); + assert!( + !html.contains("src=\"/app.js\""), + "index.html never references the unversioned script" + ); +} + +#[tokio::test] +async fn the_logical_app_routes_serve_the_hashed_bytes() { + for (uri, logical) in [("/app.js", "app.js"), ("/app.css", "app.css")] { + let response = get(uri).await; + assert_eq!(response.status(), StatusCode::OK, "{uri} serves"); + assert_eq!( + cache_control(&response), + "no-cache", + "{uri} is a stable URL and must revalidate" + ); + let expected = crate::assets::UiAssets::get(&manifest_target(logical)) + .expect("the manifest target exists"); + assert_eq!( + &body_bytes(response).await[..], + &expected.data[..], + "{uri} serves the manifest target's bytes" + ); + } +} + +#[tokio::test] +async fn hashed_bundle_assets_are_cached_immutably() { + for logical in ["app.js", "app.css"] { + let target = manifest_target(logical); + let response = get(&format!("/{target}")).await; + assert_eq!(response.status(), StatusCode::OK, "/{target} serves"); + assert_eq!( + cache_control(&response), + "public, max-age=31536000, immutable", + "/{target} is content-hashed, so its URL never changes meaning" + ); + } +} + +#[tokio::test] +async fn a_code_split_chunk_is_cached_immutably() { + let name = first_chunk_name(".js"); + let response = get(&format!("/{name}")).await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + cache_control(&response), + "public, max-age=31536000, immutable", + "chunk names are content-hashed, so their URLs never change meaning" + ); +} + +#[tokio::test] +async fn a_bundle_name_without_a_bundle_extension_is_not_found() { + let response = get("/bundle/app.toml").await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn a_bundle_name_cannot_escape_the_asset_root() { + let response = get("/bundle/..%2F..%2FCargo.toml").await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn a_missing_bundle_file_is_not_found() { + let response = get("/bundle/app-DEADBEEF00.js").await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} diff --git a/crates/workshop-server/src/routes/chat.rs b/crates/workshop-server/src/routes/chat.rs deleted file mode 100644 index 017ca5b3c..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::deadline::{RELAY_DEADLINE, with_deadline}; -use crate::{relay, session}; - -/// 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/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/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 40264bed3..2f5b93e61 100644 --- a/crates/workshop-server/src/serve.rs +++ b/crates/workshop-server/src/serve.rs @@ -15,12 +15,9 @@ 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 @@ -270,22 +267,15 @@ fn serve_thread( Err(error) => return (Termination::Graceful, Err(error)), }; let _ = ready.send(Ok((format!("http://{address}"), state.gateway_updater()))); - // The heartbeat, gateway progress subscriber, and progress renderer - // 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.push(), - state.health().clone(), - heartbeat::HEARTBEAT_INTERVAL, - state.backoff().clone(), - ); - let renderer = progress::spawn(std::sync::Arc::clone(state.progress()), state.push()); - let subscriber = gateway_progress::spawn( - state.gateway_binding().clone(), - std::sync::Arc::clone(state.progress()), - state.health().clone(), - ); + // The subsystems' registered background tasks start with serving + // and stop inside the same graceful-shutdown signal, so they + // never outlive the server. + let tasks: Vec<_> = state + .registry() + .tasks() + .iter() + .map(|task| task.spawn()) + .collect(); let (draining_tx, draining_rx) = tokio::sync::oneshot::channel(); let serve = async { axum::serve(listener, app) @@ -294,9 +284,9 @@ fn serve_thread( // Arm the watchdog before draining the background // tasks, so the grace window bounds the whole stop. let _ = draining_tx.send(()); - heartbeat.shutdown().await; - renderer.shutdown().await; - subscriber.shutdown().await; + for task in tasks { + task.shutdown().await; + } }) .await }; @@ -347,280 +337,4 @@ fn reuse_bind(address: &str) -> std::io::Result { } #[cfg(test)] -mod tests { - use super::*; - - use std::path::Path; - - use crate::config::{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("initial-key"); - 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..3751b82e3 --- /dev/null +++ b/crates/workshop-server/src/serve/tests.rs @@ -0,0 +1,281 @@ +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); + // Under the headless feature the asset layer is a no-op by design, so + // the UI index answers 404; the boot-and-health behavior above is + // what this test proves in that configuration. + #[cfg(not(feature = "headless"))] + { + 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 10cb8b485..000000000 --- a/crates/workshop-server/src/session_agents.rs +++ /dev/null @@ -1,1128 +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 crate::backoff::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; - -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::*; - - #[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 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(), - ), - 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 (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), - 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, crate::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 8e174f3f5..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 }), - } - crate::atomic::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/agents.rs b/crates/workshop-server/tests/it/agents.rs index 3e6edaa78..9d7824d84 100644 --- a/crates/workshop-server/tests/it/agents.rs +++ b/crates/workshop-server/tests/it/agents.rs @@ -310,713 +310,7 @@ pub(crate) fn delta_text(turn: &Turn) -> String { .collect() } -#[tokio::test] -async fn a_full_turn_streams_deltas_and_indexed_events_sharing_the_reply_id() { - let (base, _dir, _state) = spawn_agent_server().await; - let mut socket = connect(&base).await; - let _session = launch_echo(&mut socket).await; - - // Turn one. - let token = next_wait_token(&mut socket).await; - answer(&mut socket, &token, "ping").await; - let turn = collect_turn(&mut socket).await; - - assert_eq!( - delta_text(&turn), - "echo:ping", - "the live text deltas assemble the reply" - ); - assert!( - turn.deltas - .iter() - .filter(|delta| delta["kind"] == "text") - .count() - >= 2, - "the mock splits content, so the turn streams multiple live chunks" - ); - assert!( - turn.deltas.iter().all(|delta| delta["reply"] == 0), - "every first-turn delta is stamped with superseding reply id 0: {:?}", - turn.deltas - ); - let kinds: Vec<&str> = turn - .events - .iter() - .filter_map(|event| event["event"]["kind"].as_str()) - .collect(); - assert_eq!( - kinds, - [ - "user_message", - "tool_call_update", - "agent_thought", - "agent_message" - ], - "the durable record of one turn: input, the user_input tool's own \ - result, thinking, reply" - ); - let indices: Vec = turn - .events - .iter() - .filter_map(|event| event["index"].as_u64()) - .collect(); - assert_eq!( - indices, - [0, 1, 2, 3], - "durable frames carry monotonically increasing log indices" - ); - assert_eq!(turn.events[0]["event"]["content"], "ping"); - assert!( - turn.events[0].get("reply").is_none(), - "a user_message settles no deltas and carries no reply id" - ); - assert!( - turn.events[1].get("reply").is_none(), - "a tool result settles no deltas and carries no reply id" - ); - assert_eq!( - turn.events[2]["reply"], 0, - "the thinking event supersedes the reasoning deltas of its round" - ); - assert_eq!(turn.events[3]["event"]["content"], "echo:ping"); - assert_eq!( - turn.events[3]["reply"], 0, - "deltas and the completed reply share the superseding event id" - ); - - // The next input works: the full turn cycle repeats with the next - // reply id and continuing indices. - let token = wait_after(&mut socket, &turn).await; - answer(&mut socket, &token, "pong").await; - let turn = collect_turn(&mut socket).await; - assert_eq!(delta_text(&turn), "echo:pong"); - assert!( - turn.deltas.iter().all(|delta| delta["reply"] == 1), - "the second round's deltas are stamped with the next reply id" - ); - let indices: Vec = turn - .events - .iter() - .filter_map(|event| event["index"].as_u64()) - .collect(); - assert_eq!(indices, [4, 5, 6, 7], "indices continue across turns"); - assert_eq!(turn.events[3]["reply"], 1); - socket.close().await; -} - -#[tokio::test] -async fn reconnect_replays_the_log_and_resends_the_pending_wait() { - let (base, _dir, _state) = spawn_agent_server().await; - let mut socket = connect(&base).await; - let session = launch_echo(&mut socket).await; - let token = next_wait_token(&mut socket).await; - answer(&mut socket, &token, "ping").await; - let live = collect_turn(&mut socket).await; - let pending = wait_after(&mut socket, &live).await; - // The socket dies mid-session; the session survives. - socket.close().await; - - let mut socket = connect(&base).await; - socket - .send_json(&json!({ "type": "attach", "session": session })) - .await; - let frame = socket.recv_json().await; - assert_eq!( - frame["type"], "agent_session", - "attach is acknowledged: {frame}" - ); - let replayed = collect_turn(&mut socket).await; - assert_eq!( - replayed.events, live.events, - "reconnect replays the persisted entries byte-alike: same indices, stamps, events" - ); - let resent = wait_after(&mut socket, &replayed).await; - assert_eq!( - resent, pending, - "the unresolved wait is resent on reconnect with its retained token" - ); - - // The reattached session is live: answering the resent wait runs a - // full turn. - answer(&mut socket, &resent, "again").await; - let turn = collect_turn(&mut socket).await; - assert_eq!(delta_text(&turn), "echo:again"); - socket.close().await; -} - -#[tokio::test] -async fn turn_cancel_returns_to_waiting_with_input_cancelled_and_no_error_frame() { - let (base, _dir, state) = spawn_agent_server().await; - let mut socket = connect(&base).await; - let session = launch_echo(&mut socket).await; - let token = next_wait_token(&mut socket).await; - assert_eq!( - state.agents().unresolved_waits(&session), - Some(vec![token.clone()]), - "the pending wait is retained by the session" - ); - - socket.send_json(&json!({ "type": "cancel" })).await; - let cancelled = socket - .recv_until(Duration::from_secs(10), |frame| { - assert_ne!( - frame["type"], "error", - "cancellation is a stop reason, never an error: {frame}" - ); - frame["type"] == "input_cancelled" - }) - .await; - assert_eq!( - cancelled["token"], *token, - "the pending wait dies as an explicit input_cancelled" - ); - - // The relaunched agent rebuilds from the retained log and returns to - // waiting: a fresh wait opens, and the next input works. - let fresh = next_wait_token(&mut socket).await; - assert_ne!(fresh, token, "the relaunched run opens a fresh wait token"); - answer(&mut socket, &fresh, "after cancel").await; - let turn = collect_turn(&mut socket).await; - assert_eq!( - delta_text(&turn), - "echo:after cancel", - "the next input after a turn-cancel runs a full turn" - ); - socket.close().await; -} - -#[tokio::test] -async fn gateway_replacement_interrupts_a_catalog_wait_on_accepted_input() { - let started = Arc::new(Notify::new()); - let request_started = Arc::clone(&started); - let original_requests = Arc::new(Mutex::new(Vec::new())); - let captured_original = Arc::clone(&original_requests); - let original = spawn_gateway(Router::new().route( - "/v1/chat/completions", - post(move |body: String| { - let request_started = Arc::clone(&request_started); - let captured_original = Arc::clone(&captured_original); - async move { - record_request(&captured_original, &body); - hanging_completions(&request_started) - } - }), - )) - .await; - let (base, _dir, state) = spawn_agent_server_for_gateway(original).await; - state - .catalog() - .publish(vec![json!({ "id": "model-a", "object": "model" })]); - state.menu().reconcile_catalog_for_test(); - state - .menu() - .set_selected("model-a") - .expect("the original model becomes selected"); - let mut socket = connect(&base).await; - let session = launch_agent(&mut socket, "chat").await; - let token = next_wait_token(&mut socket).await; - - let catalog_state = state.clone(); - state - .agents() - .deliver_input_after_acceptance_for_test( - &session, - workshop_server::InputResponse { - token, - text: "accepted across replacements".to_owned(), - }, - move || { - catalog_state.catalog().publish(vec![json!({ - "id": "model-b", - "object": "model", - })]); - }, - ) - .expect("the session remains registered") - .expect("the accepted input resumes its run"); - tokio::time::timeout(Duration::from_secs(10), started.notified()) - .await - .expect("the accepted turn reaches the hanging Gateway"); - assert_eq!( - original_requests - .lock() - .expect("the request capture lock is healthy")[0]["model"], - "model-a", - "the accepted turn reads the still-selected model-a while retirement is deferred" - ); - - state.menu().reconcile_catalog_for_test(); - state - .menu() - .set_selected("model-b") - .expect("the replacement model becomes selected"); - let replacement_requests = Arc::new(Mutex::new(Vec::new())); - let captured_replacement = Arc::clone(&replacement_requests); - let replacement = spawn_gateway(Router::new().route( - "/v1/chat/completions", - post(move |body: String| { - let captured_replacement = Arc::clone(&captured_replacement); - async move { - record_request(&captured_replacement, &body); - echo_completions(body).await - } - }), - )) - .await; - replace_gateway(&state, &replacement, 1_757_000_000); - - let fresh = tokio::time::timeout(Duration::from_secs(10), next_wait_token(&mut socket)) - .await - .expect("Gateway replacement overrides the catalog settlement wait"); - answer(&mut socket, &fresh, "after replacement").await; - let turn = collect_turn(&mut socket).await; - assert_replacement_request(&replacement_requests, "model-b", "after replacement"); - assert_eq!( - delta_text(&turn), - "echo:after replacement", - "the relaunched run uses the replacement Gateway" - ); - socket.close().await; -} - -#[tokio::test] -async fn retained_catalog_generation_replays_on_the_replacement_gateway() { - let started = Arc::new(Notify::new()); - let request_started = Arc::clone(&started); - let original = spawn_gateway(Router::new().route( - "/v1/chat/completions", - post(move |body: String| { - let request_started = Arc::clone(&request_started); - async move { - assert_eq!( - serde_json::from_str::(&body).expect("the request is JSON") - ["model"], - "model-a" - ); - hanging_completions(&request_started) - } - }), - )) - .await; - let (base, _dir, state) = spawn_agent_server_for_gateway(original).await; - state - .catalog() - .publish(vec![json!({ "id": "model-a", "object": "model" })]); - state.menu().reconcile_catalog_for_test(); - state - .menu() - .set_selected("model-a") - .expect("the original model becomes selected"); - let mut socket = connect(&base).await; - let session = launch_agent(&mut socket, "chat").await; - let token = next_wait_token(&mut socket).await; - - let catalog_state = state.clone(); - state - .agents() - .deliver_input_after_acceptance_for_test( - &session, - workshop_server::InputResponse { - token, - text: "retained before replay".to_owned(), - }, - move || { - catalog_state - .catalog() - .publish(vec![json!({ "id": "model-b", "object": "model" })]); - }, - ) - .expect("the session remains registered") - .expect("the accepted input resumes its run"); - tokio::time::timeout(Duration::from_secs(10), started.notified()) - .await - .expect("the replacement generation is observed before the old request starts"); - - state - .catalog() - .publish(vec![json!({ "id": "model-a", "object": "model" })]); - let replacement_requests = Arc::new(Mutex::new(Vec::new())); - let captured_replacement = Arc::clone(&replacement_requests); - let replacement = spawn_gateway(Router::new().route( - "/v1/chat/completions", - post(move |body: String| { - let captured_replacement = Arc::clone(&captured_replacement); - async move { - record_request(&captured_replacement, &body); - echo_completions(body).await - } - }), - )) - .await; - replace_gateway(&state, &replacement, 1_757_000_001); - - let fresh = tokio::time::timeout(Duration::from_secs(10), next_wait_token(&mut socket)) - .await - .expect("the retained generation relaunches instead of resolving stale model-b"); - answer(&mut socket, &fresh, "after retained replay").await; - let turn = collect_turn(&mut socket).await; - assert_eq!(delta_text(&turn), "echo:after retained replay"); - assert_replacement_request(&replacement_requests, "model-a", "after retained replay"); - socket.close().await; -} - -#[tokio::test] -async fn unavailable_catalog_waits_without_relaunching_stale_bindings() { - let started = Arc::new(Notify::new()); - let request_started = Arc::clone(&started); - let original = spawn_gateway(Router::new().route( - "/v1/chat/completions", - post(move || { - let request_started = Arc::clone(&request_started); - async move { hanging_completions(&request_started) } - }), - )) - .await; - let (base, _dir, state) = spawn_agent_server_for_gateway(original).await; - state - .catalog() - .publish(vec![json!({ "id": "model-a", "object": "model" })]); - state.menu().reconcile_catalog_for_test(); - state - .menu() - .set_selected("model-a") - .expect("the original model becomes selected"); - let mut socket = connect(&base).await; - let session = launch_agent(&mut socket, "chat").await; - let token = next_wait_token(&mut socket).await; - - let catalog_state = state.clone(); - state - .agents() - .deliver_input_after_acceptance_for_test( - &session, - workshop_server::InputResponse { - token, - text: "retained while unavailable".to_owned(), - }, - move || { - catalog_state - .catalog() - .publish(vec![json!({ "id": "model-b", "object": "model" })]); - }, - ) - .expect("the session remains registered") - .expect("the accepted input resumes its run"); - tokio::time::timeout(Duration::from_secs(10), started.notified()) - .await - .expect("the replacement generation is observed before the old request starts"); - - state.menu().reconcile_catalog_for_test(); - state - .menu() - .set_selected("model-b") - .expect("the pending replacement model becomes selected"); - state.catalog().publish(Vec::new()); - let replacement_started = Arc::new(Notify::new()); - let replacement_request_started = Arc::clone(&replacement_started); - let replacement_requests = Arc::new(Mutex::new(Vec::new())); - let captured_replacement = Arc::clone(&replacement_requests); - let replacement = spawn_gateway(Router::new().route( - "/v1/chat/completions", - post(move |body: String| { - let replacement_request_started = Arc::clone(&replacement_request_started); - let captured_replacement = Arc::clone(&captured_replacement); - async move { - record_request(&captured_replacement, &body); - replacement_request_started.notify_one(); - echo_completions(body).await - } - }), - )) - .await; - replace_gateway(&state, &replacement, 1_757_000_002); - - assert!( - tokio::time::timeout(Duration::from_millis(250), replacement_started.notified()) - .await - .is_err(), - "an unavailable catalog cannot relaunch model-b on the replacement Gateway" - ); - - state - .catalog() - .publish(vec![json!({ "id": "model-c", "object": "model" })]); - state.menu().reconcile_catalog_for_test(); - state - .menu() - .set_selected("model-c") - .expect("the newly available model becomes selected"); - let fresh = tokio::time::timeout(Duration::from_secs(10), next_wait_token(&mut socket)) - .await - .expect("a later usable catalog relaunches the waiting session"); - answer(&mut socket, &fresh, "after unavailable").await; - let turn = collect_turn(&mut socket).await; - assert_eq!(delta_text(&turn), "echo:after unavailable"); - assert_replacement_request(&replacement_requests, "model-c", "after unavailable"); - socket.close().await; -} - -#[tokio::test] -async fn two_sessions_do_not_cross_talk() { - let (base, _dir, _state) = spawn_agent_server().await; - let mut first = connect(&base).await; - let mut second = connect(&base).await; - let first_id = launch_echo(&mut first).await; - let second_id = launch_echo(&mut second).await; - assert_ne!(first_id, second_id, "every launch is its own session"); - - let first_token = next_wait_token(&mut first).await; - let second_token = next_wait_token(&mut second).await; - answer(&mut first, &first_token, "alpha").await; - answer(&mut second, &second_token, "beta").await; - let first_turn = collect_turn(&mut first).await; - let second_turn = collect_turn(&mut second).await; - - assert_eq!(delta_text(&first_turn), "echo:alpha"); - assert_eq!(delta_text(&second_turn), "echo:beta"); - for turn in [&first_turn, &second_turn] { - let indices: Vec = turn - .events - .iter() - .filter_map(|event| event["index"].as_u64()) - .collect(); - assert_eq!( - indices, - [0, 1, 2, 3], - "each session's log is its own: no foreign entries shift the indices" - ); - } - assert_eq!( - first_turn.events[0]["event"]["content"], "alpha", - "the first session's history holds only its own input" - ); - assert_eq!( - second_turn.events[0]["event"]["content"], "beta", - "the second session's history holds only its own input" - ); - first.close().await; - second.close().await; -} - -#[tokio::test] -async fn status_frames_fire_in_order_and_a_completed_reply_resets_the_backoff() { - let (base, _dir, state) = spawn_agent_server().await; - // The backoff stands escalated, as after an outage; the completed - // reply is the useful work that returns it to base. - let _ = state.backoff().next_delay(); - let _ = state.backoff().next_delay(); - assert!(state.backoff().is_escalated_for_test()); - - // Status updates ride the main `/ws` socket as unsolicited frames. - let mut status = JsonSocket::connect(&format!("{base}/ws")).await; - let mut socket = connect(&base).await; - let _session = launch_echo(&mut socket).await; - let token = next_wait_token(&mut socket).await; - answer(&mut socket, &token, "ping").await; - let turn = collect_turn(&mut socket).await; - assert_eq!(delta_text(&turn), "echo:ping"); - - // Thinking on turn dispatch, Generating on the first answer delta, - // idle on completion - scanned in order through the status stream. - let deadline = Duration::from_secs(10); - let thinking = status - .recv_until(deadline, |frame| { - frame["type"] == "status" && frame["activity"] == "thinking" - }) - .await; - assert_eq!(thinking["label"], "Running agent turn"); - let generating = status - .recv_until(deadline, |frame| { - frame["type"] == "status" && frame["activity"] == "generating" - }) - .await; - assert_eq!(generating["label"], "Streaming response..."); - let idle = status - .recv_until(deadline, |frame| { - frame["type"] == "status" && frame["label"] == "Ready" - }) - .await; - assert_eq!(idle["activity"], "general"); - assert!( - !state.backoff().is_escalated_for_test(), - "a completed reply records useful work and resets the backoff" - ); - socket.close().await; - status.close().await; -} - -#[tokio::test] -async fn teardown_cancels_pending_waits_and_leaks_none() { - let (base, _dir, state) = spawn_agent_server().await; - let mut socket = connect(&base).await; - let session = launch_echo(&mut socket).await; - let token = next_wait_token(&mut socket).await; - assert_eq!( - state.agents().unresolved_waits(&session), - Some(vec![token.clone()]), - "the wait is retained while the session runs" - ); - - assert!(state.agents().close(&session), "the session closes"); - let cancelled = socket - .recv_until(Duration::from_secs(10), |frame| { - frame["type"] == "input_cancelled" - }) - .await; - assert_eq!( - cancelled["token"], *token, - "teardown announces the dying wait instead of leaking it" - ); - assert!( - state.agents().unresolved_waits(&session).is_none(), - "a closed session leaves the registry" - ); - assert!( - !state.agents().close(&session), - "closing an already-closed session is a no-op" - ); - socket.close().await; -} - -#[tokio::test] -async fn a_terminal_agent_failure_reaches_the_socket_as_an_error_frame() { - let (base, dir, state) = spawn_agent_server().await; - // An agent that dies after its first input, so the socket is attached - // and subscribed long before the failure fires. - std::fs::write( - dir.path().join("agents").join("boom.lua"), - "tools.call('user_input', {})\nerror('kaboom')", - ) - .expect("the boom agent writes"); - let mut socket = JsonSocket::connect(&format!("{base}/agents/ws")).await; - assert_eq!( - socket.recv_json().await, - json!({ "type": "agents", "agents": ["boom", "chat", "echo"] }), - "the freshly written agent is discovered on this connect" - ); - socket - .send_json(&json!({ "type": "launch", "agent": "boom" })) - .await; - let frame = socket.recv_json().await; - assert_eq!(frame["type"], "agent_session"); - let session = frame["session"] - .as_str() - .expect("the acknowledgment carries the session id") - .to_owned(); - - let token = next_wait_token(&mut socket).await; - answer(&mut socket, &token, "go").await; - let error = socket - .recv_until(Duration::from_secs(10), |frame| frame["type"] == "error") - .await; - assert!( - error["message"] - .as_str() - .is_some_and(|message| message.contains("kaboom")), - "the run's own failure reaches the SPA as an error frame, not just \ - the status bus: {error}" - ); - // The failed run ends the session; the registry lets it go. - tokio::time::timeout(Duration::from_secs(10), async { - while state.agents().unresolved_waits(&session).is_some() { - tokio::time::sleep(Duration::from_millis(10)).await; - } - }) - .await - .expect("a failed run leaves the registry"); - socket.close().await; -} - -#[tokio::test] -async fn refusals_are_error_frames_and_the_socket_survives() { - let (base, _dir, _state) = spawn_agent_server().await; - let mut socket = connect(&base).await; - - socket - .send_json(&json!({ "type": "launch", "agent": "ghost" })) - .await; - let frame = socket.recv_json().await; - assert_eq!(frame["type"], "error"); - assert!( - frame["message"] - .as_str() - .is_some_and(|message| message.contains("unknown agent")), - "an unknown agent is refused by name: {frame}" - ); - - socket - .send_json(&json!({ "type": "attach", "session": "not-a-session" })) - .await; - assert_eq!(socket.recv_json().await["type"], "error"); - - socket.send_json(&json!({ "type": "cancel" })).await; - assert_eq!( - socket.recv_json().await["type"], - "error", - "a cancel before any session is attached is refused" - ); - - socket.send_text("{ not json").await; - let frame = socket.recv_json().await; - assert_eq!(frame["type"], "error"); - assert!( - frame["message"] - .as_str() - .is_some_and(|message| message.contains("invalid JSON")), - "a malformed frame is refused, not fatal: {frame}" - ); - - socket.send_json(&json!({ "type": "mystery" })).await; - let frame = socket.recv_json().await; - assert_eq!(frame["type"], "error"); - assert!( - frame["message"] - .as_str() - .is_some_and(|message| message.contains("unknown frame type")), - "an unknown type is refused naming the expected ones: {frame}" - ); - - socket - .send_json(&json!({ "type": "input_response", "token": "t", "text": "hi" })) - .await; - let frame = socket.recv_json().await; - assert_eq!(frame["type"], "error"); - assert!( - frame["message"] - .as_str() - .is_some_and(|message| message.contains("before a session is attached")), - "an input_response before any session is attached is refused: {frame}" - ); - - // The socket survives its refusals: a real launch still works, and a - // second launch on the same socket is refused - agent windows are - // modal, one session per socket. - let _session = launch_echo(&mut socket).await; - socket - .send_json(&json!({ "type": "launch", "agent": "echo" })) - .await; - let frame = socket - .recv_until(Duration::from_secs(10), |frame| frame["type"] == "error") - .await; - assert!( - frame["message"] - .as_str() - .is_some_and(|message| message.contains("modal")), - "a second launch on an attached socket is refused: {frame}" - ); - - // Attached, an input_response still validates its shape. - socket - .send_json(&json!({ "type": "input_response", "token": 7 })) - .await; - let frame = socket - .recv_until(Duration::from_secs(10), |frame| frame["type"] == "error") - .await; - assert!( - frame["message"] - .as_str() - .is_some_and(|message| message.contains("invalid input_response")), - "a shapeless input_response is refused, not fatal: {frame}" - ); - socket.close().await; -} +mod lifecycle; +mod refusals; +mod replacement; +mod turns; diff --git a/crates/workshop-server/tests/it/agents/lifecycle.rs b/crates/workshop-server/tests/it/agents/lifecycle.rs new file mode 100644 index 000000000..71074c668 --- /dev/null +++ b/crates/workshop-server/tests/it/agents/lifecycle.rs @@ -0,0 +1,176 @@ +//! Session lifecycle over `/agents/ws`: session isolation, status-bus +//! ordering with the backoff reset, teardown wait cleanup, and a +//! terminal agent failure surfacing as an error frame. + +use super::*; + +#[tokio::test] +async fn two_sessions_do_not_cross_talk() { + let (base, _dir, _state) = spawn_agent_server().await; + let mut first = connect(&base).await; + let mut second = connect(&base).await; + let first_id = launch_echo(&mut first).await; + let second_id = launch_echo(&mut second).await; + assert_ne!(first_id, second_id, "every launch is its own session"); + + let first_token = next_wait_token(&mut first).await; + let second_token = next_wait_token(&mut second).await; + answer(&mut first, &first_token, "alpha").await; + answer(&mut second, &second_token, "beta").await; + let first_turn = collect_turn(&mut first).await; + let second_turn = collect_turn(&mut second).await; + + assert_eq!(delta_text(&first_turn), "echo:alpha"); + assert_eq!(delta_text(&second_turn), "echo:beta"); + for turn in [&first_turn, &second_turn] { + let indices: Vec = turn + .events + .iter() + .filter_map(|event| event["index"].as_u64()) + .collect(); + assert_eq!( + indices, + [0, 1, 2, 3], + "each session's log is its own: no foreign entries shift the indices" + ); + } + assert_eq!( + first_turn.events[0]["event"]["content"], "alpha", + "the first session's history holds only its own input" + ); + assert_eq!( + second_turn.events[0]["event"]["content"], "beta", + "the second session's history holds only its own input" + ); + first.close().await; + second.close().await; +} + +#[tokio::test] +async fn status_frames_fire_in_order_and_a_completed_reply_resets_the_backoff() { + let (base, _dir, state) = spawn_agent_server().await; + // The backoff stands escalated, as after an outage; the completed + // reply is the useful work that returns it to base. + let _ = state.backoff().next_delay(); + let _ = state.backoff().next_delay(); + assert!(state.backoff().is_escalated_for_test()); + + // Status updates ride the main `/ws` socket as unsolicited frames. + let mut status = JsonSocket::connect(&format!("{base}/ws")).await; + let mut socket = connect(&base).await; + let _session = launch_echo(&mut socket).await; + let token = next_wait_token(&mut socket).await; + answer(&mut socket, &token, "ping").await; + let turn = collect_turn(&mut socket).await; + assert_eq!(delta_text(&turn), "echo:ping"); + + // Thinking on turn dispatch, Generating on the first answer delta, + // idle on completion - scanned in order through the status stream. + let deadline = Duration::from_secs(10); + let thinking = status + .recv_until(deadline, |frame| { + frame["type"] == "status" && frame["activity"] == "thinking" + }) + .await; + assert_eq!(thinking["label"], "Running agent turn"); + let generating = status + .recv_until(deadline, |frame| { + frame["type"] == "status" && frame["activity"] == "generating" + }) + .await; + assert_eq!(generating["label"], "Streaming response..."); + let idle = status + .recv_until(deadline, |frame| { + frame["type"] == "status" && frame["label"] == "Ready" + }) + .await; + assert_eq!(idle["activity"], "general"); + assert!( + !state.backoff().is_escalated_for_test(), + "a completed reply records useful work and resets the backoff" + ); + socket.close().await; + status.close().await; +} + +#[tokio::test] +async fn teardown_cancels_pending_waits_and_leaks_none() { + let (base, _dir, state) = spawn_agent_server().await; + let mut socket = connect(&base).await; + let session = launch_echo(&mut socket).await; + let token = next_wait_token(&mut socket).await; + assert_eq!( + state.agents().unresolved_waits(&session), + Some(vec![token.clone()]), + "the wait is retained while the session runs" + ); + + assert!(state.agents().close(&session), "the session closes"); + let cancelled = socket + .recv_until(Duration::from_secs(10), |frame| { + frame["type"] == "input_cancelled" + }) + .await; + assert_eq!( + cancelled["token"], *token, + "teardown announces the dying wait instead of leaking it" + ); + assert!( + state.agents().unresolved_waits(&session).is_none(), + "a closed session leaves the registry" + ); + assert!( + !state.agents().close(&session), + "closing an already-closed session is a no-op" + ); + socket.close().await; +} + +#[tokio::test] +async fn a_terminal_agent_failure_reaches_the_socket_as_an_error_frame() { + let (base, dir, state) = spawn_agent_server().await; + // An agent that dies after its first input, so the socket is attached + // and subscribed long before the failure fires. + std::fs::write( + dir.path().join("agents").join("boom.lua"), + "tools.call('user_input', {})\nerror('kaboom')", + ) + .expect("the boom agent writes"); + let mut socket = JsonSocket::connect(&format!("{base}/agents/ws")).await; + assert_eq!( + socket.recv_json().await, + json!({ "type": "agents", "agents": ["boom", "chat", "echo"] }), + "the freshly written agent is discovered on this connect" + ); + socket + .send_json(&json!({ "type": "launch", "agent": "boom" })) + .await; + let frame = socket.recv_json().await; + assert_eq!(frame["type"], "agent_session"); + let session = frame["session"] + .as_str() + .expect("the acknowledgment carries the session id") + .to_owned(); + + let token = next_wait_token(&mut socket).await; + answer(&mut socket, &token, "go").await; + let error = socket + .recv_until(Duration::from_secs(10), |frame| frame["type"] == "error") + .await; + assert!( + error["message"] + .as_str() + .is_some_and(|message| message.contains("kaboom")), + "the run's own failure reaches the SPA as an error frame, not just \ + the status bus: {error}" + ); + // The failed run ends the session; the registry lets it go. + tokio::time::timeout(Duration::from_secs(10), async { + while state.agents().unresolved_waits(&session).is_some() { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("a failed run leaves the registry"); + socket.close().await; +} diff --git a/crates/workshop-server/tests/it/agents/refusals.rs b/crates/workshop-server/tests/it/agents/refusals.rs new file mode 100644 index 000000000..9ed76009c --- /dev/null +++ b/crates/workshop-server/tests/it/agents/refusals.rs @@ -0,0 +1,98 @@ +//! Protocol refusals over `/agents/ws`: every malformed or out-of-turn +//! frame is an error frame, and the socket survives its refusals. + +use super::*; + +#[tokio::test] +async fn refusals_are_error_frames_and_the_socket_survives() { + let (base, _dir, _state) = spawn_agent_server().await; + let mut socket = connect(&base).await; + + socket + .send_json(&json!({ "type": "launch", "agent": "ghost" })) + .await; + let frame = socket.recv_json().await; + assert_eq!(frame["type"], "error"); + assert!( + frame["message"] + .as_str() + .is_some_and(|message| message.contains("unknown agent")), + "an unknown agent is refused by name: {frame}" + ); + + socket + .send_json(&json!({ "type": "attach", "session": "not-a-session" })) + .await; + assert_eq!(socket.recv_json().await["type"], "error"); + + socket.send_json(&json!({ "type": "cancel" })).await; + assert_eq!( + socket.recv_json().await["type"], + "error", + "a cancel before any session is attached is refused" + ); + + socket.send_text("{ not json").await; + let frame = socket.recv_json().await; + assert_eq!(frame["type"], "error"); + assert!( + frame["message"] + .as_str() + .is_some_and(|message| message.contains("invalid JSON")), + "a malformed frame is refused, not fatal: {frame}" + ); + + socket.send_json(&json!({ "type": "mystery" })).await; + let frame = socket.recv_json().await; + assert_eq!(frame["type"], "error"); + assert!( + frame["message"] + .as_str() + .is_some_and(|message| message.contains("unknown frame type")), + "an unknown type is refused naming the expected ones: {frame}" + ); + + socket + .send_json(&json!({ "type": "input_response", "token": "t", "text": "hi" })) + .await; + let frame = socket.recv_json().await; + assert_eq!(frame["type"], "error"); + assert!( + frame["message"] + .as_str() + .is_some_and(|message| message.contains("before a session is attached")), + "an input_response before any session is attached is refused: {frame}" + ); + + // The socket survives its refusals: a real launch still works, and a + // second launch on the same socket is refused - agent windows are + // modal, one session per socket. + let _session = launch_echo(&mut socket).await; + socket + .send_json(&json!({ "type": "launch", "agent": "echo" })) + .await; + let frame = socket + .recv_until(Duration::from_secs(10), |frame| frame["type"] == "error") + .await; + assert!( + frame["message"] + .as_str() + .is_some_and(|message| message.contains("modal")), + "a second launch on an attached socket is refused: {frame}" + ); + + // Attached, an input_response still validates its shape. + socket + .send_json(&json!({ "type": "input_response", "token": 7 })) + .await; + let frame = socket + .recv_until(Duration::from_secs(10), |frame| frame["type"] == "error") + .await; + assert!( + frame["message"] + .as_str() + .is_some_and(|message| message.contains("invalid input_response")), + "a shapeless input_response is refused, not fatal: {frame}" + ); + socket.close().await; +} diff --git a/crates/workshop-server/tests/it/agents/replacement.rs b/crates/workshop-server/tests/it/agents/replacement.rs new file mode 100644 index 000000000..61ba7a6c5 --- /dev/null +++ b/crates/workshop-server/tests/it/agents/replacement.rs @@ -0,0 +1,277 @@ +//! Gateway replacement against live agent sessions: an accepted input +//! interrupted mid-wait, the retained catalog generation replaying on +//! the replacement Gateway, and an unavailable catalog holding the +//! session instead of relaunching stale bindings. + +use super::*; + +#[tokio::test] +async fn gateway_replacement_interrupts_a_catalog_wait_on_accepted_input() { + let started = Arc::new(Notify::new()); + let request_started = Arc::clone(&started); + let original_requests = Arc::new(Mutex::new(Vec::new())); + let captured_original = Arc::clone(&original_requests); + let original = spawn_gateway(Router::new().route( + "/v1/chat/completions", + post(move |body: String| { + let request_started = Arc::clone(&request_started); + let captured_original = Arc::clone(&captured_original); + async move { + record_request(&captured_original, &body); + hanging_completions(&request_started) + } + }), + )) + .await; + let (base, _dir, state) = spawn_agent_server_for_gateway(original).await; + state + .catalog() + .publish(vec![json!({ "id": "model-a", "object": "model" })]); + state.menu().reconcile_catalog_for_test(); + state + .menu() + .set_selected("model-a") + .expect("the original model becomes selected"); + let mut socket = connect(&base).await; + let session = launch_agent(&mut socket, "chat").await; + let token = next_wait_token(&mut socket).await; + + let catalog_state = state.clone(); + state + .agents() + .deliver_input_after_acceptance_for_test( + &session, + workshop_server::InputResponse { + token, + text: "accepted across replacements".to_owned(), + }, + move || { + catalog_state.catalog().publish(vec![json!({ + "id": "model-b", + "object": "model", + })]); + }, + ) + .expect("the session remains registered") + .expect("the accepted input resumes its run"); + tokio::time::timeout(Duration::from_secs(10), started.notified()) + .await + .expect("the accepted turn reaches the hanging Gateway"); + assert_eq!( + original_requests + .lock() + .expect("the request capture lock is healthy")[0]["model"], + "model-a", + "the accepted turn reads the still-selected model-a while retirement is deferred" + ); + + state.menu().reconcile_catalog_for_test(); + state + .menu() + .set_selected("model-b") + .expect("the replacement model becomes selected"); + let replacement_requests = Arc::new(Mutex::new(Vec::new())); + let captured_replacement = Arc::clone(&replacement_requests); + let replacement = spawn_gateway(Router::new().route( + "/v1/chat/completions", + post(move |body: String| { + let captured_replacement = Arc::clone(&captured_replacement); + async move { + record_request(&captured_replacement, &body); + echo_completions(body).await + } + }), + )) + .await; + replace_gateway(&state, &replacement, 1_757_000_000); + + let fresh = tokio::time::timeout(Duration::from_secs(10), next_wait_token(&mut socket)) + .await + .expect("Gateway replacement overrides the catalog settlement wait"); + answer(&mut socket, &fresh, "after replacement").await; + let turn = collect_turn(&mut socket).await; + assert_replacement_request(&replacement_requests, "model-b", "after replacement"); + assert_eq!( + delta_text(&turn), + "echo:after replacement", + "the relaunched run uses the replacement Gateway" + ); + socket.close().await; +} + +#[tokio::test] +async fn retained_catalog_generation_replays_on_the_replacement_gateway() { + let started = Arc::new(Notify::new()); + let request_started = Arc::clone(&started); + let original = spawn_gateway(Router::new().route( + "/v1/chat/completions", + post(move |body: String| { + let request_started = Arc::clone(&request_started); + async move { + assert_eq!( + serde_json::from_str::(&body).expect("the request is JSON") + ["model"], + "model-a" + ); + hanging_completions(&request_started) + } + }), + )) + .await; + let (base, _dir, state) = spawn_agent_server_for_gateway(original).await; + state + .catalog() + .publish(vec![json!({ "id": "model-a", "object": "model" })]); + state.menu().reconcile_catalog_for_test(); + state + .menu() + .set_selected("model-a") + .expect("the original model becomes selected"); + let mut socket = connect(&base).await; + let session = launch_agent(&mut socket, "chat").await; + let token = next_wait_token(&mut socket).await; + + let catalog_state = state.clone(); + state + .agents() + .deliver_input_after_acceptance_for_test( + &session, + workshop_server::InputResponse { + token, + text: "retained before replay".to_owned(), + }, + move || { + catalog_state + .catalog() + .publish(vec![json!({ "id": "model-b", "object": "model" })]); + }, + ) + .expect("the session remains registered") + .expect("the accepted input resumes its run"); + tokio::time::timeout(Duration::from_secs(10), started.notified()) + .await + .expect("the replacement generation is observed before the old request starts"); + + state + .catalog() + .publish(vec![json!({ "id": "model-a", "object": "model" })]); + let replacement_requests = Arc::new(Mutex::new(Vec::new())); + let captured_replacement = Arc::clone(&replacement_requests); + let replacement = spawn_gateway(Router::new().route( + "/v1/chat/completions", + post(move |body: String| { + let captured_replacement = Arc::clone(&captured_replacement); + async move { + record_request(&captured_replacement, &body); + echo_completions(body).await + } + }), + )) + .await; + replace_gateway(&state, &replacement, 1_757_000_001); + + let fresh = tokio::time::timeout(Duration::from_secs(10), next_wait_token(&mut socket)) + .await + .expect("the retained generation relaunches instead of resolving stale model-b"); + answer(&mut socket, &fresh, "after retained replay").await; + let turn = collect_turn(&mut socket).await; + assert_eq!(delta_text(&turn), "echo:after retained replay"); + assert_replacement_request(&replacement_requests, "model-a", "after retained replay"); + socket.close().await; +} + +#[tokio::test] +async fn unavailable_catalog_waits_without_relaunching_stale_bindings() { + let started = Arc::new(Notify::new()); + let request_started = Arc::clone(&started); + let original = spawn_gateway(Router::new().route( + "/v1/chat/completions", + post(move || { + let request_started = Arc::clone(&request_started); + async move { hanging_completions(&request_started) } + }), + )) + .await; + let (base, _dir, state) = spawn_agent_server_for_gateway(original).await; + state + .catalog() + .publish(vec![json!({ "id": "model-a", "object": "model" })]); + state.menu().reconcile_catalog_for_test(); + state + .menu() + .set_selected("model-a") + .expect("the original model becomes selected"); + let mut socket = connect(&base).await; + let session = launch_agent(&mut socket, "chat").await; + let token = next_wait_token(&mut socket).await; + + let catalog_state = state.clone(); + state + .agents() + .deliver_input_after_acceptance_for_test( + &session, + workshop_server::InputResponse { + token, + text: "retained while unavailable".to_owned(), + }, + move || { + catalog_state + .catalog() + .publish(vec![json!({ "id": "model-b", "object": "model" })]); + }, + ) + .expect("the session remains registered") + .expect("the accepted input resumes its run"); + tokio::time::timeout(Duration::from_secs(10), started.notified()) + .await + .expect("the replacement generation is observed before the old request starts"); + + state.menu().reconcile_catalog_for_test(); + state + .menu() + .set_selected("model-b") + .expect("the pending replacement model becomes selected"); + state.catalog().publish(Vec::new()); + let replacement_started = Arc::new(Notify::new()); + let replacement_request_started = Arc::clone(&replacement_started); + let replacement_requests = Arc::new(Mutex::new(Vec::new())); + let captured_replacement = Arc::clone(&replacement_requests); + let replacement = spawn_gateway(Router::new().route( + "/v1/chat/completions", + post(move |body: String| { + let replacement_request_started = Arc::clone(&replacement_request_started); + let captured_replacement = Arc::clone(&captured_replacement); + async move { + record_request(&captured_replacement, &body); + replacement_request_started.notify_one(); + echo_completions(body).await + } + }), + )) + .await; + replace_gateway(&state, &replacement, 1_757_000_002); + + assert!( + tokio::time::timeout(Duration::from_millis(250), replacement_started.notified()) + .await + .is_err(), + "an unavailable catalog cannot relaunch model-b on the replacement Gateway" + ); + + state + .catalog() + .publish(vec![json!({ "id": "model-c", "object": "model" })]); + state.menu().reconcile_catalog_for_test(); + state + .menu() + .set_selected("model-c") + .expect("the newly available model becomes selected"); + let fresh = tokio::time::timeout(Duration::from_secs(10), next_wait_token(&mut socket)) + .await + .expect("a later usable catalog relaunches the waiting session"); + answer(&mut socket, &fresh, "after unavailable").await; + let turn = collect_turn(&mut socket).await; + assert_eq!(delta_text(&turn), "echo:after unavailable"); + assert_replacement_request(&replacement_requests, "model-c", "after unavailable"); + socket.close().await; +} diff --git a/crates/workshop-server/tests/it/agents/turns.rs b/crates/workshop-server/tests/it/agents/turns.rs new file mode 100644 index 000000000..9d930834b --- /dev/null +++ b/crates/workshop-server/tests/it/agents/turns.rs @@ -0,0 +1,180 @@ +//! The turn cycle over `/agents/ws`: a full turn's deltas and indexed +//! durable events, reconnect replay with the pending wait resent, and +//! turn-cancel returning the session to waiting. + +use super::*; + +#[tokio::test] +async fn a_full_turn_streams_deltas_and_indexed_events_sharing_the_reply_id() { + let (base, _dir, _state) = spawn_agent_server().await; + let mut socket = connect(&base).await; + let _session = launch_echo(&mut socket).await; + + // Turn one. + let token = next_wait_token(&mut socket).await; + answer(&mut socket, &token, "ping").await; + let turn = collect_turn(&mut socket).await; + + assert_eq!( + delta_text(&turn), + "echo:ping", + "the live text deltas assemble the reply" + ); + assert!( + turn.deltas + .iter() + .filter(|delta| delta["kind"] == "text") + .count() + >= 2, + "the mock splits content, so the turn streams multiple live chunks" + ); + assert!( + turn.deltas.iter().all(|delta| delta["reply"] == 0), + "every first-turn delta is stamped with superseding reply id 0: {:?}", + turn.deltas + ); + let kinds: Vec<&str> = turn + .events + .iter() + .filter_map(|event| event["event"]["kind"].as_str()) + .collect(); + assert_eq!( + kinds, + [ + "user_message", + "tool_call_update", + "agent_thought", + "agent_message" + ], + "the durable record of one turn: input, the user_input tool's own \ + result, thinking, reply" + ); + let indices: Vec = turn + .events + .iter() + .filter_map(|event| event["index"].as_u64()) + .collect(); + assert_eq!( + indices, + [0, 1, 2, 3], + "durable frames carry monotonically increasing log indices" + ); + assert_eq!(turn.events[0]["event"]["content"], "ping"); + assert!( + turn.events[0].get("reply").is_none(), + "a user_message settles no deltas and carries no reply id" + ); + assert!( + turn.events[1].get("reply").is_none(), + "a tool result settles no deltas and carries no reply id" + ); + assert_eq!( + turn.events[2]["reply"], 0, + "the thinking event supersedes the reasoning deltas of its round" + ); + assert_eq!(turn.events[3]["event"]["content"], "echo:ping"); + assert_eq!( + turn.events[3]["reply"], 0, + "deltas and the completed reply share the superseding event id" + ); + + // The next input works: the full turn cycle repeats with the next + // reply id and continuing indices. + let token = wait_after(&mut socket, &turn).await; + answer(&mut socket, &token, "pong").await; + let turn = collect_turn(&mut socket).await; + assert_eq!(delta_text(&turn), "echo:pong"); + assert!( + turn.deltas.iter().all(|delta| delta["reply"] == 1), + "the second round's deltas are stamped with the next reply id" + ); + let indices: Vec = turn + .events + .iter() + .filter_map(|event| event["index"].as_u64()) + .collect(); + assert_eq!(indices, [4, 5, 6, 7], "indices continue across turns"); + assert_eq!(turn.events[3]["reply"], 1); + socket.close().await; +} + +#[tokio::test] +async fn reconnect_replays_the_log_and_resends_the_pending_wait() { + let (base, _dir, _state) = spawn_agent_server().await; + let mut socket = connect(&base).await; + let session = launch_echo(&mut socket).await; + let token = next_wait_token(&mut socket).await; + answer(&mut socket, &token, "ping").await; + let live = collect_turn(&mut socket).await; + let pending = wait_after(&mut socket, &live).await; + // The socket dies mid-session; the session survives. + socket.close().await; + + let mut socket = connect(&base).await; + socket + .send_json(&json!({ "type": "attach", "session": session })) + .await; + let frame = socket.recv_json().await; + assert_eq!( + frame["type"], "agent_session", + "attach is acknowledged: {frame}" + ); + let replayed = collect_turn(&mut socket).await; + assert_eq!( + replayed.events, live.events, + "reconnect replays the persisted entries byte-alike: same indices, stamps, events" + ); + let resent = wait_after(&mut socket, &replayed).await; + assert_eq!( + resent, pending, + "the unresolved wait is resent on reconnect with its retained token" + ); + + // The reattached session is live: answering the resent wait runs a + // full turn. + answer(&mut socket, &resent, "again").await; + let turn = collect_turn(&mut socket).await; + assert_eq!(delta_text(&turn), "echo:again"); + socket.close().await; +} + +#[tokio::test] +async fn turn_cancel_returns_to_waiting_with_input_cancelled_and_no_error_frame() { + let (base, _dir, state) = spawn_agent_server().await; + let mut socket = connect(&base).await; + let session = launch_echo(&mut socket).await; + let token = next_wait_token(&mut socket).await; + assert_eq!( + state.agents().unresolved_waits(&session), + Some(vec![token.clone()]), + "the pending wait is retained by the session" + ); + + socket.send_json(&json!({ "type": "cancel" })).await; + let cancelled = socket + .recv_until(Duration::from_secs(10), |frame| { + assert_ne!( + frame["type"], "error", + "cancellation is a stop reason, never an error: {frame}" + ); + frame["type"] == "input_cancelled" + }) + .await; + assert_eq!( + cancelled["token"], *token, + "the pending wait dies as an explicit input_cancelled" + ); + + // The relaunched agent rebuilds from the retained log and returns to + // waiting: a fresh wait opens, and the next input works. + let fresh = next_wait_token(&mut socket).await; + assert_ne!(fresh, token, "the relaunched run opens a fresh wait token"); + answer(&mut socket, &fresh, "after cancel").await; + let turn = collect_turn(&mut socket).await; + assert_eq!( + delta_text(&turn), + "echo:after cancel", + "the next input after a turn-cancel runs a full turn" + ); + socket.close().await; +} diff --git a/crates/workshop-server/tests/it/boot.rs b/crates/workshop-server/tests/it/boot.rs new file mode 100644 index 000000000..589b135f2 --- /dev/null +++ b/crates/workshop-server/tests/it/boot.rs @@ -0,0 +1,44 @@ +//! Boot composition: a subsystem whose `register` call is missing fails +//! state construction at boot, naming the absent contribution, instead +//! of panicking later at first use. + +use workshop_server::fixtures::{Omit, state_with_gateway_omitting}; +use workshop_server::{AgentsConfig, Config, GatewayConfig, ResolvedGateway, ServerConfig}; + +/// A config against a stub gateway address, its state directory a fresh +/// tempdir. +fn config_for(state_dir: &std::path::Path) -> Config { + Config { + gateway: GatewayConfig { + base_url: "http://127.0.0.1:1".to_string(), + api_key: "test-key".to_string(), + }, + server: ServerConfig { + state_dir: state_dir.to_path_buf(), + ..ServerConfig::default() + }, + agents: AgentsConfig::default(), + } +} + +#[test] +fn a_missing_required_contribution_fails_boot_naming_it() { + let state_dir = tempfile::TempDir::new().expect("tempdir"); + let config = config_for(state_dir.path()); + let gateway = ResolvedGateway::from_config(&config.gateway); + let error = state_with_gateway_omitting(&config, &gateway, Omit::Menu) + .expect_err("boot fails when the menu subsystem never registers"); + assert!( + error.to_string().contains("MenuHandles"), + "the failure names the missing contribution: {error}" + ); +} + +#[test] +fn a_complete_composition_boots() { + let state_dir = tempfile::TempDir::new().expect("tempdir"); + let config = config_for(state_dir.path()); + let gateway = ResolvedGateway::from_config(&config.gateway); + workshop_server::fixtures::state_with_gateway(&config, &gateway) + .expect("every subsystem registered: boot succeeds"); +} diff --git a/crates/workshop-server/tests/it/chat_gate.rs b/crates/workshop-server/tests/it/chat_gate.rs index de4f7fe6d..fe9bcc58d 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. @@ -240,56 +240,6 @@ async fn spawn_chat_server_with_selection(models: &[&str], selected: Option<&str } } -/// Binds the workshop router over a mock gateway at `gateway_url`, with -/// `models` in the retained catalog and the first of them selected in the -/// menu. Returns the server's `ws://` base, the shared state handle, and -/// the tempdir keeping the state alive. -async fn serve_chat_over( - gateway_url: String, - models: &[&str], -) -> (String, AppState, tempfile::TempDir) { - let dir = tempfile::TempDir::new().expect("tempdir"); - let config = Config { - gateway: GatewayConfig { - base_url: gateway_url, - api_key: "test-key".to_string(), - }, - server: ServerConfig { - state_dir: dir.path().to_path_buf(), - ..ServerConfig::default() - }, - agents: AgentsConfig { - path: dir.path().join("missing-agents"), - }, - }; - // Discovery is bypassed: a test never consults the real run directory. - let gateway = ResolvedGateway::from_config(&config.gateway); - let state = state_with_gateway(&config, &gateway).expect("state builds in tests"); - state.catalog().publish( - models - .iter() - .map(|id| json!({ "id": id, "object": "model" })) - .collect(), - ); - if let Some(selected) = models.first() { - state - .menu() - .set_selected(selected) - .expect("the selected model is in the retained catalog"); - } - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .expect("bind the gate test server"); - let addr = listener.local_addr().expect("gate test server address"); - let served = state.clone(); - tokio::spawn(async move { - axum::serve(listener, router(served)) - .await - .expect("gate test server serves"); - }); - (format!("ws://{addr}"), state, dir) -} - /// Connects to `/agents/ws`, asserting the connect-time list is exactly /// the built-in: end-to-end proof that a missing agents directory still /// offers `chat`. diff --git a/crates/workshop-server/tests/it/chat_gate/protocol.rs b/crates/workshop-server/tests/it/chat_gate/protocol.rs index 2f5a73b6f..8cd6d53cc 100644 --- a/crates/workshop-server/tests/it/chat_gate/protocol.rs +++ b/crates/workshop-server/tests/it/chat_gate/protocol.rs @@ -52,158 +52,3 @@ async fn gate_streaming_delivers_text_and_reasoning_deltas_then_the_reply() { ); socket.close().await; } - -/// The model-visible-input mock: the first completion request is answered -/// with a `user_input` tool call; a request carrying the tool result is -/// answered with `echo:`. -fn ask_then_echo_completions(captured: &CapturedRequests, body: &str) -> Response { - let request: serde_json::Value = serde_json::from_str(body).expect("the request is JSON"); - captured - .lock() - .expect("the capture lock is healthy") - .push(request.clone()); - let messages = request["messages"] - .as_array() - .expect("the request carries a messages array"); - let null = serde_json::Value::Null; - let mut sse = String::new(); - if let Some(answer) = messages - .iter() - .rev() - .find(|message| message["role"] == "tool") - .and_then(|message| message["content"].as_str()) - { - let reply = format!("echo:{answer}"); - for event in [ - sse_chunk("test-model", &json!({ "role": "assistant" }), &null), - sse_chunk("test-model", &json!({ "content": reply }), &null), - sse_chunk("test-model", &json!({}), &json!("stop")), - ] { - sse.push_str(&sse_line(&event)); - } - } else { - for event in [ - sse_chunk("test-model", &json!({ "role": "assistant" }), &null), - sse_chunk( - "test-model", - &json!({ "tool_calls": [{ - "index": 0, - "id": "call_1", - "type": "function", - "function": { "name": "user_input", "arguments": "{}" }, - }] }), - &null, - ), - sse_chunk("test-model", &json!({}), &json!("tool_calls")), - ] { - sse.push_str(&sse_line(&event)); - } - } - sse.push_str("data: [DONE]\n\n"); - ([(header::CONTENT_TYPE, "text/event-stream")], sse).into_response() -} - -/// GATE 11 - model-visible user input. The model can ask the operator -/// mid-loop through the broker's tool surface: its `user_input` call opens -/// the same durable wait the direct call uses, the operator's answer lands -/// as the correlated tool result, and the loop continues to a reply. -#[tokio::test] -async fn gate_model_visible_user_input_completes_the_tool_exchange() { - let captured = CapturedRequests::default(); - let mock = Arc::clone(&captured); - let gateway_url = spawn_gateway(Router::new().route( - "/v1/chat/completions", - post(move |body: String| { - let captured = Arc::clone(&mock); - async move { ask_then_echo_completions(&captured, &body) } - }), - )) - .await; - let (ws_base, _state, _dir) = serve_chat_over(gateway_url, &["test-model"]).await; - let mut socket = connect_chat(&ws_base).await; - let _session = launch_chat(&mut socket).await; - - let token = next_wait_token(&mut socket).await; - answer(&mut socket, &token, "ask me something").await; - - // The model's tool call opens a mid-turn wait on the same broker; - // answering it lets the loop finish the turn. - let mut events = Vec::new(); - let mut mid_turn_wait = None; - tokio::time::timeout(Duration::from_secs(10), async { - loop { - let frame = socket.recv_json().await; - match frame["type"].as_str() { - Some("input_required") => { - let token = frame["token"] - .as_str() - .expect("the wait announces its token") - .to_owned(); - mid_turn_wait = Some(token.clone()); - answer(&mut socket, &token, "the operator's answer").await; - } - Some("agent_event") => { - let done = frame["event"]["kind"] == "agent_message"; - events.push(frame); - if done { - break; - } - } - Some("error") => panic!("no error frame may interrupt the turn: {frame}"), - _ => {} - } - } - }) - .await - .expect("the turn completes within the deadline"); - assert!( - mid_turn_wait.is_some(), - "the model's user_input call announced its own wait" - ); - let kinds: Vec<&str> = events - .iter() - .filter_map(|event| event["event"]["kind"].as_str()) - .collect(); - assert_eq!( - kinds, - [ - "user_message", - "tool_call", - "user_message", - "tool_call_update", - "agent_message" - ], - "the durable record of the exchange: the turn input, the model's call, \ - the operator's mid-loop answer, the correlated result, the reply" - ); - assert_eq!(events[0]["event"]["content"], "ask me something"); - assert_eq!( - events[4]["event"]["content"], "echo:the operator's answer", - "the reply follows the answered tool exchange" - ); - socket.close().await; - - let requests = captured.lock().expect("the capture lock is healthy"); - assert_eq!( - requests.len(), - 2, - "the loop dispatches before and after the wait" - ); - let second = requests[1]["messages"] - .as_array() - .expect("the second request carries messages"); - assert_eq!(second[1]["role"], "assistant"); - assert_eq!( - second[1]["tool_calls"][0]["function"]["name"], "user_input", - "the assistant record carries the model's call" - ); - assert_eq!(second[2]["role"], "tool"); - assert_eq!( - second[2]["tool_call_id"], "call_1", - "the tool result is correlated to the call" - ); - assert_eq!( - second[2]["content"], "the operator's answer", - "the operator's answer is the tool result, byte-exact" - ); -} 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..6965bb1fa --- /dev/null +++ b/crates/workshop-server/tests/it/heartbeat_loop.rs @@ -0,0 +1,495 @@ +//! 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::{Push, Registration, Registry}; +use workshop_status::StatusBus; +use workshop_support::ReconnectBackoff; + +/// The registration guards keeping the test's contributions alive. +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, 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, + status_state, + catalog_sink, + menu_sink, + menu_state, + ), + ) +} + +/// 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..b971fa4cb 100644 --- a/crates/workshop-server/tests/it/main.rs +++ b/crates/workshop-server/tests/it/main.rs @@ -5,8 +5,10 @@ mod common; mod agents; +mod boot; mod chat_gate; mod heartbeat; +mod heartbeat_loop; mod observer; mod realtime_relay; mod session; diff --git a/crates/workshop-server/ui/build.mjs b/crates/workshop-server/ui/build.mjs index e140895bd..64c09c946 100644 --- a/crates/workshop-server/ui/build.mjs +++ b/crates/workshop-server/ui/build.mjs @@ -1,15 +1,23 @@ -// Bundles src/main.ts into dist/app.js and copies the static assets into -// dist/. The crate's build.rs performs the same steps into OUT_DIR on -// `cargo build` (through the build-ui helper); this script exists for the -// fast iteration workflow (`npm run watch` rebuilds on save without a Rust -// recompile) and for the jsdom tests that import the built dist/app.js. -import { copyFile, mkdir, readFile, rm } from "node:fs/promises"; +// Bundles src/main.ts into dist/bundle/app-.js and copies the static +// assets into dist/. The crate's build.rs performs the same steps into +// OUT_DIR on `cargo build` (through the build-ui helper); this script +// exists for the fast iteration workflow (`npm run watch` rebuilds on save +// without a Rust recompile) and for the jsdom tests that import the built +// bundle. `--out

` redirects the output (default dist/); the build-ui +// crate's drift test uses it to diff this script against the Rust +// implementer without touching the working tree. +import { copyFile, mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; import * as esbuild from "esbuild"; const uiDir = path.dirname(fileURLToPath(import.meta.url)); -const distDir = path.join(uiDir, "dist"); +const outFlag = process.argv.indexOf("--out"); +if (outFlag !== -1 && process.argv[outFlag + 1] === undefined) { + throw new Error("--out requires a directory argument"); +} +const distDir = + outFlag === -1 ? path.join(uiDir, "dist") : path.resolve(uiDir, process.argv[outFlag + 1]); const srcDir = path.join(uiDir, "src"); // The crate version (workspace [workspace.package] version), baked into the @@ -37,22 +45,48 @@ const STATIC_FILES = [ // Always minified: the bundle is never inspected by hand, and matching the // release profile keeps the jsdom tests exercising what ships. +// +// Code splitting is on: the panel registry's import thunks (the agent +// session's Shiki/TipTap graph, the editor's CodeMirror) become lazily +// loaded chunks under dist/chunks/, and the initial bundle carries only +// the boot shell, services, and chrome. Every bundle file is +// content-hashed (the entry under bundle/, the chunks under chunks/), so +// the server can mark them Cache-Control: immutable; dist/manifest.json +// maps the logical names (app.js, app.css) to the hashed files, and the +// dist copy of index.html is stamped with the hashed URLs. const options = { entryPoints: [path.join(srcDir, "main.ts")], bundle: true, format: "esm", + splitting: true, target: "es2022", minify: true, - outfile: path.join(distDir, "app.js"), + entryNames: "bundle/app-[hash]", + chunkNames: "chunks/[name]-[hash]", + outdir: distDir, logLevel: "info", + plugins: [ + { + name: "stamp-hashed-assets", + setup(build) { + build.onEnd(async (result) => { + if (result.errors.length === 0) { + await stampHashedAssets(); + } + }); + }, + }, + ], ...(version !== null && { define: { __APP_VERSION__: JSON.stringify(version) } }), }; -// dist/ is rebuilt from scratch so removed assets never linger. +// dist/ is rebuilt from scratch so removed assets never linger. The index +// page is excluded here: the stamp step writes it from the source with the +// hashed bundle URLs on every build, watch rebuilds included. async function copyStatic() { await mkdir(distDir, { recursive: true }); await Promise.all( - STATIC_FILES.map(async (file) => { + STATIC_FILES.filter((file) => file !== "index.html").map(async (file) => { const target = path.join(distDir, file); await mkdir(path.dirname(target), { recursive: true }); await copyFile(path.join(uiDir, file), target); @@ -60,6 +94,40 @@ async function copyStatic() { ); } +// Finds the single hashed entry output of one kind under dist/bundle/. +async function hashedEntry(extension) { + const bundleDir = path.join(distDir, "bundle"); + const names = (await readdir(bundleDir)).filter( + (name) => name.startsWith("app-") && name.endsWith(extension), + ); + if (names.length !== 1) { + throw new Error(`expected exactly one bundle/app-*${extension} output, found ${names.length}`); + } + return `bundle/${names[0]}`; +} + +// Writes dist/manifest.json (the logical-to-hashed name map the server's +// asset routes resolve through) and stamps the dist copy of index.html +// with the hashed bundle URLs, so the page loads the immutable assets +// directly. The stamp always reads the source index.html, so watch +// rebuilds never double-stamp. Mirrored in the build-ui crate's finalize +// step. +async function stampHashedAssets() { + const manifest = { + "app.js": await hashedEntry(".js"), + "app.css": await hashedEntry(".css"), + }; + await writeFile(path.join(distDir, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`); + const html = await readFile(path.join(uiDir, "index.html"), "utf8"); + const stamped = html + .replace('href="/app.css"', `href="/${manifest["app.css"]}"`) + .replace('src="/app.js"', `src="/${manifest["app.js"]}"`); + if (stamped === html) { + throw new Error("index.html did not reference /app.js and /app.css; the stamp found nothing"); + } + await writeFile(path.join(distDir, "index.html"), stamped); +} + if (process.argv.includes("--watch")) { const context = await esbuild.context(options); await copyStatic(); 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 @@ -