diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e669b594..379b95e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,20 +26,20 @@ jobs: run: cargo fmt --all --check - name: Clippy - run: cargo clippy --workspace --exclude promptforge-wb --exclude promptforge-wb-server --all-targets --all-features -- -D warnings + run: cargo clippy --workspace --exclude promptforge-ws --exclude promptforge-ws-server --all-targets --all-features -- -D warnings - name: Test - run: cargo test --locked --workspace --exclude promptforge-wb --exclude promptforge-wb-server --all-features + run: cargo test --locked --workspace --exclude promptforge-ws --exclude promptforge-ws-server --all-features - name: Doctests - run: cargo test --workspace --exclude promptforge-wb --exclude promptforge-wb-server --doc + run: cargo test --workspace --exclude promptforge-ws --exclude promptforge-ws-server --doc - name: Docs env: RUSTDOCFLAGS: -D warnings - run: cargo doc --workspace --no-deps --all-features --exclude promptforge-wb --exclude promptforge-wb-server + run: cargo doc --workspace --no-deps --all-features --exclude promptforge-ws --exclude promptforge-ws-server - check-workbench: + check-workshop: runs-on: windows-latest steps: - uses: actions/checkout@v4 @@ -56,14 +56,17 @@ jobs: node-version: 22 - name: Install UI dependencies - working-directory: crates/promptforge-wb-server/ui + working-directory: crates/promptforge-ws-server/ui run: npm ci - - name: Clippy (workbench) - run: cargo clippy -p promptforge-wb -p promptforge-wb-server --all-targets -- -D warnings + # --no-default-features: the desktop crate defaults to the cuda + # feature, and windows-latest has no CUDA toolkit; CI builds the + # CPU-only whisper path instead. + - name: Clippy (workshop) + run: cargo clippy -p promptforge-ws -p promptforge-ws-server --all-targets --no-default-features -- -D warnings - - name: Test (workbench) - run: cargo test --locked -p promptforge-wb -p promptforge-wb-server + - name: Test (workshop) + run: cargo test --locked -p promptforge-ws -p promptforge-ws-server --no-default-features msrv: runs-on: ubuntu-latest @@ -77,8 +80,8 @@ jobs: - name: Build and test on MSRV run: | - cargo build --locked --workspace --exclude promptforge-wb --exclude promptforge-wb-server --all-features - cargo test --locked --workspace --exclude promptforge-wb --exclude promptforge-wb-server --all-features + cargo build --locked --workspace --exclude promptforge-ws --exclude promptforge-ws-server --all-features + cargo test --locked --workspace --exclude promptforge-ws --exclude promptforge-ws-server --all-features supply-chain: runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 99d94189..7253589e 100644 --- a/.gitignore +++ b/.gitignore @@ -5,10 +5,10 @@ /local/ /guide/book/ *.env -# Voice test fixtures, downloaded out of band (see design-promptforge-wb-1.md). -/crates/promptforge-wb-server/tests/fixtures/ +# Voice test fixtures, downloaded out of band (see design-promptforge-ws-1.md). +/crates/promptforge-ws-server/tests/fixtures/ # UI build pipeline: npm install target and esbuild output (rebuilt by build.rs). -/crates/promptforge-wb-server/ui/node_modules/ -/crates/promptforge-wb-server/ui/dist/ -# Workbench tape, written to the cwd when the server runs from the repo root. +/crates/promptforge-ws-server/ui/node_modules/ +/crates/promptforge-ws-server/ui/dist/ +# Workshop tape, written to the cwd when the server runs from the repo root. /tape.jsonl diff --git a/AGENTS.md b/AGENTS.md index afeb3fd6..12c2363a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,5 +10,24 @@ This rule outranks every other rule here. Before you add a frontmatter field, a - After completing work (compiles + tests pass), update README.md if the public surface changed. - Every public type, function, and module must have a `///` doc comment. `cargo doc` is the project documentation. +- Each crate's own AGENTS.md binds its subtree. Read the ones governing the paths you touch before writing or reviewing code. +- The existing test suite stays green and intact during refactors: fix forward; never rewrite a test to make it pass. +- Library and serve paths never call `process::exit` or install process-global state; failures return through the spawn handshake. - Do NOT look at files outside this repo for reference. - The plan is the spec. Work from the plan and AGENTS.md only. + +## Comments + +`///` doc comments on public items are mandatory (above) and are not what this section governs. Any other comment earns its place by exactly four things: a non-obvious why, an invisible constraint no type or test enforces, an external-bug workaround, or a subtle ordering requirement. Comments that narrate what the code already says are deleted on sight. A module doc earns its place by documenting the domain, not by restating the file name. + +Every platform or external-bug workaround carries its upstream issue URL inline, in the comment that explains it. When the workaround dies, the URL says when it can be buried. + +```rust +// wry's drag-drop handler suppresses HTML5 drag events on Windows +// (https://github.com/tauri-apps/tauri/issues/15138), so ... +``` + +## Verify + +- Rust: `cargo test` at the workspace root. +- UI: `npm run typecheck && npm test` in `crates/promptforge-ws-server/ui`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 19e45e24..64ae108f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- promptforge-ws / promptforge-gateway / promptforge-ws-server: `metal` / `workshop-metal` / `metal` feature chain building the whisper voice engine with Metal acceleration, making workshop voice available on macOS (`cargo build -p promptforge-ws --no-default-features --features metal`) + +### Fixed + +- promptforge-gateway: the archive extractor now materializes tar symlink entries as regular-file copies of their targets (confined, chain-following, cycle-rejecting), so the macOS llama.cpp release tarballs, which ship dylibs behind versioned symlink chains, provision instead of failing with an unsafe-entry error +- promptforge-ws: microphone capture works in the macOS webview; the shell builds the webview configuration itself to allow capture on the plain-http loopback origin, and embeds an Info.plist with `NSMicrophoneUsageDescription` in the executable so WKWebView exposes `navigator.mediaDevices` + ## [0.1.0] - 2026-08-12 ### Added diff --git a/Cargo.lock b/Cargo.lock index 1f8c6d97..55fc8511 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1448,6 +1448,15 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "field-offset" version = "0.3.6" @@ -3464,6 +3473,16 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-javascript-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a1e6550c4caed348956ce3370c9ffeca70bb1dbed4fa96112e7c6170e074586" +dependencies = [ + "objc2", + "objc2-core-foundation", +] + [[package]] name = "objc2-quartz-core" version = "0.3.2" @@ -3476,6 +3495,17 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "objc2-security" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", +] + [[package]] name = "objc2-system-configuration" version = "0.3.2" @@ -3528,6 +3558,8 @@ dependencies = [ "objc2-app-kit", "objc2-core-foundation", "objc2-foundation", + "objc2-javascript-core", + "objc2-security", ] [[package]] @@ -3767,6 +3799,19 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.1", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "portable-atomic" version = "1.14.0" @@ -3950,8 +3995,10 @@ dependencies = [ "flate2", "futures-util", "indicatif", + "open", "promptforge-core", "promptforge-gateway-config", + "promptforge-ws-server", "rand 0.9.5", "reqwest 0.12.28", "serde", @@ -4027,25 +4074,59 @@ dependencies = [ ] [[package]] -name = "promptforge-wb" +name = "promptforge-webfetch" +version = "0.1.0" +dependencies = [ + "async-trait", + "axum", + "encoding_rs", + "flate2", + "futures-util", + "htmd", + "ipnet", + "mime", + "promptforge-core", + "readabilityrs", + "reqwest 0.12.28", + "serde_json", + "thiserror 2.0.19", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "promptforge-ws" version = "0.1.0" dependencies = [ "anyhow", - "promptforge-wb-server", + "objc2", + "objc2-web-kit", + "open", + "png", + "promptforge-gateway", + "rand 0.9.5", + "serde_json", "tao", "tempfile", + "url", + "webview2-com", + "windows-core 0.61.2", "wry", ] [[package]] -name = "promptforge-wb-server" +name = "promptforge-ws-server" version = "0.1.0" dependencies = [ "anyhow", "axum", + "dunce", "futures-util", "hound", "open", + "percent-encoding", + "promptforge-ws-server", "reqwest 0.12.28", "rust-embed", "serde", @@ -4060,29 +4141,8 @@ dependencies = [ "tower", "tracing", "tracing-subscriber", - "whisper-rs", -] - -[[package]] -name = "promptforge-webfetch" -version = "0.1.0" -dependencies = [ - "async-trait", - "axum", - "encoding_rs", - "flate2", - "futures-util", - "htmd", - "ipnet", - "mime", - "promptforge-core", - "readabilityrs", - "reqwest 0.12.28", - "serde_json", - "thiserror 2.0.19", - "tokio", - "tracing", "url", + "whisper-rs", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index ba0133ee..5f0eda5e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,10 +11,11 @@ repository = "https://github.com/cppalliance/promptforge" [workspace.dependencies] promptforge-core = { path = "crates/promptforge-core", version = "0.1.0" } +promptforge-gateway = { path = "crates/promptforge-gateway", version = "0.1.0" } promptforge-gateway-config = { path = "crates/promptforge-gateway-config", version = "0.1.0" } promptforge-webfetch = { path = "crates/promptforge-webfetch", version = "0.1.0" } promptforge-tool-picker = { path = "crates/promptforge-tool-picker", version = "0.1.0" } -promptforge-wb-server = { path = "crates/promptforge-wb-server", version = "0.1.0" } +promptforge-ws-server = { path = "crates/promptforge-ws-server", version = "0.1.0" } pulldown-cmark = "0.12" serde = { version = "1", features = ["derive"] } serde_yaml_ng = "0.10" @@ -27,7 +28,10 @@ htmd = "0.5" mime = "0.3" encoding_rs = "0.8" url = "2" +# Percent-decoding for path parameters; already in the tree as url's own core. +percent-encoding = "2" ipnet = "2" +dunce = "1" tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "time", "sync"] } thiserror = "2" anyhow = "1" @@ -54,7 +58,7 @@ rmcp = { version = "3.1.0", features = [ ] } tower = { version = "0.5", features = ["util"] } arc-swap = "1" -# Serves the workbench UI: reads ui/dist from disk in debug builds, embeds it +# Serves the workshop UI: reads ui/dist from disk in debug builds, embeds it # into the binary in release builds. rust-embed = "8" glob = "0.3" @@ -93,6 +97,8 @@ wry = "0.56" tao = "0.36" # Opens a URL in the system browser for the windowless server frame. open = "5" +# Decodes the bundled program-icon PNG into RGBA for the tao window icon. +png = "0.18" [workspace.lints.rust] unsafe_code = "forbid" diff --git a/README.md b/README.md index 689c2302..35bd4b90 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ A runtime that executes AI prompt pipelines defined in a single markdown file. The markdown is the program, the model is the CPU. YAML frontmatter for metadata, embedded Lua for logic, prose blocks for model instructions, and a credential-holding gateway that keeps vendor keys off the prompt process. Write a prompt, run it, get a result. -![Workbench](images/banner-01.png) +![Workshop](images/banner-01.png) ## What you get @@ -112,8 +112,8 @@ flowchart LR | [promptforge-tool-picker](crates/promptforge-tool-picker) | Semantic tool resolution via sentence embeddings | [![Crates.io](https://img.shields.io/crates/v/promptforge-tool-picker.svg)](https://crates.io/crates/promptforge-tool-picker) | | [promptforge-webfetch](crates/promptforge-webfetch) | SSRF-safe web fetch tool for model-supplied URLs | [![Crates.io](https://img.shields.io/crates/v/promptforge-webfetch.svg)](https://crates.io/crates/promptforge-webfetch) | | [promptforge-dev](crates/promptforge-dev) | Interactive prompt development with watch mode | [![Crates.io](https://img.shields.io/crates/v/promptforge-dev.svg)](https://crates.io/crates/promptforge-dev) | -| [promptforge-wb-server](crates/promptforge-wb-server) | Workbench HTTP server: chat relay, session tape, voice transcription | not published | -| [promptforge-wb](crates/promptforge-wb) | Workbench desktop window shell (wry/tao) | not published | +| [promptforge-ws-server](crates/promptforge-ws-server) | Workshop HTTP server: chat relay, session tape, voice transcription | not published | +| [promptforge-ws](crates/promptforge-ws) | Workshop desktop window shell (wry/tao) | not published | ## Documentation diff --git a/crates/promptforge-gateway-config/README.md b/crates/promptforge-gateway-config/README.md index bac35898..c98ef4fb 100644 --- a/crates/promptforge-gateway-config/README.md +++ b/crates/promptforge-gateway-config/README.md @@ -14,6 +14,13 @@ What it provides: including file, with cycle detection), keyed-array merging by `id`/`name`, and the [`ProfileName`](src/profile/name.rs) confinement type that keeps a profile selection inside the profiles directory. +- The boot-only `[workshop]` section ([`WorkshopConfig`](src/config/workshop.rs)): + the hosted workshop UI's bind and optional voice/tape sub-tables, with + tape-path anchoring against the boot-config directory, a loopback-adjusted + client URL derived from `[server]` (`ServerConfig::client_url`), and + `load_workshop` for reading the section without full validation. + `load_boot_sections` reads `[server]` and `[workshop]` together in a + single include-resolution pass. - [`Secret`](src/config.rs): a credential wrapper that redacts in `Debug` and `Display` and never serializes. - [`ConfigError`](src/api_error.rs): an opaque, source-preserving error type; diff --git a/crates/promptforge-gateway-config/src/config.rs b/crates/promptforge-gateway-config/src/config.rs index 9ed47ca6..1dde37ae 100644 --- a/crates/promptforge-gateway-config/src/config.rs +++ b/crates/promptforge-gateway-config/src/config.rs @@ -10,10 +10,12 @@ mod accessors; mod imp; mod interpolate; mod validate; +mod workshop; #[cfg(test)] pub(crate) use interpolate::interpolate; pub(crate) use interpolate::interpolate_value; +pub use workshop::{WorkshopConfig, WorkshopTapeConfig, WorkshopVoiceConfig}; #[cfg(test)] use crate::error::ConfigError; @@ -142,6 +144,9 @@ pub struct Config { /// Optional built-in tool configuration. Absent when no `[tools]` section /// is present. tools: Option, + /// Optional hosted-workshop configuration. Absent when no `[workshop]` + /// section is present. Boot-only, like `[server]`. + workshop: Option, } /// Private deserialization DTO for [`Config`]. Holds the raw TOML shape before @@ -166,6 +171,8 @@ struct RawConfig { model_allowlist: Option>, #[serde(default)] tools: Option, + #[serde(default)] + workshop: Option, } impl From for Config { @@ -179,6 +186,7 @@ impl From for Config { local_models: raw.local_models, model_allowlist: raw.model_allowlist, tools: raw.tools, + workshop: raw.workshop, } } } diff --git a/crates/promptforge-gateway-config/src/config/accessors.rs b/crates/promptforge-gateway-config/src/config/accessors.rs index b1add1a3..93a9144e 100644 --- a/crates/promptforge-gateway-config/src/config/accessors.rs +++ b/crates/promptforge-gateway-config/src/config/accessors.rs @@ -10,7 +10,7 @@ use std::net::SocketAddr; use super::{ Capabilities, Config, DominionConfig, DominionKind, EndpointConfig, LocalConfig, LocalModelConfig, ModelConfig, ModelKind, Protocol, QueuePolicy, SearchProvider, Secret, - ServerConfig, ThinkingMode, ToolDialect, ToolsConfig, WebSearchConfig, + ServerConfig, ThinkingMode, ToolDialect, ToolsConfig, WebSearchConfig, WorkshopConfig, }; impl Config { @@ -223,6 +223,29 @@ impl Config { pub fn tools(&self) -> Option<&ToolsConfig> { self.tools.as_ref() } + + /// Returns the `[workshop]` configuration, or `None` when the section is + /// absent. + /// + /// # Examples + /// ``` + /// # use promptforge_gateway_config::Config; + /// # let toml = r#" + /// # [server] + /// # bind = "127.0.0.1:8080" + /// # api_key = "secret" + /// # + /// # [workshop] + /// # bind = "127.0.0.1:7910" + /// # "#; + /// let config = Config::from_toml_str(toml)?; + /// assert!(config.workshop().is_some()); + /// # Ok::<(), promptforge_gateway_config::ConfigError>(()) + /// ``` + #[must_use] + pub fn workshop(&self) -> Option<&WorkshopConfig> { + self.workshop.as_ref() + } } impl ServerConfig { @@ -263,6 +286,44 @@ impl ServerConfig { pub fn api_key(&self) -> &Secret { &self.api_key } + + /// Returns the base URL a same-host client uses to reach this server, + /// loopback-adjusted: an unspecified bind IP (`0.0.0.0` or `::`) is not + /// a reachable destination, so it becomes the matching loopback address; + /// every other address is kept verbatim. + /// + /// This is how the hosted workshop derives its gateway `base_url` from + /// `[server]` at boot (paired with the same `api_key`), so no credential + /// or address is duplicated in `[workshop]`. + /// + /// # Examples + /// ``` + /// # use promptforge_gateway_config::Config; + /// # let toml = r#" + /// # [server] + /// # bind = "0.0.0.0:8081" + /// # api_key = "secret" + /// # "#; + /// let config = Config::from_toml_str(toml)?; + /// assert_eq!(config.server().client_url(), "http://127.0.0.1:8081"); + /// # Ok::<(), promptforge_gateway_config::ConfigError>(()) + /// ``` + #[must_use] + pub fn client_url(&self) -> String { + use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + + let mut addr = self.bind; + match addr.ip() { + IpAddr::V4(ip) if ip.is_unspecified() => { + addr.set_ip(IpAddr::V4(Ipv4Addr::LOCALHOST)); + } + IpAddr::V6(ip) if ip.is_unspecified() => { + addr.set_ip(IpAddr::V6(Ipv6Addr::LOCALHOST)); + } + _ => {} + } + format!("http://{addr}") + } } impl LocalConfig { diff --git a/crates/promptforge-gateway-config/src/config/workshop.rs b/crates/promptforge-gateway-config/src/config/workshop.rs new file mode 100644 index 00000000..32d871b5 --- /dev/null +++ b/crates/promptforge-gateway-config/src/config/workshop.rs @@ -0,0 +1,605 @@ +//! The optional `[workshop]` section: the embedded workshop UI server the +//! gateway can host on a second loopback listener. +//! +//! The section is boot-only: like `[server]`, it lives in the boot config, +//! and the gateway refuses a profile whose merged `[workshop]` differs from +//! the boot file's. There is deliberately no `[workshop.gateway]` sub-table: +//! the hosting gateway derives the workshop's client URL from its own +//! `[server]` bind ([`ServerConfig::client_url`](super::ServerConfig::client_url)) +//! and reuses the same api_key, so no credential is duplicated and none can +//! drift. + +use std::net::SocketAddr; +use std::path::{Path, PathBuf}; + +use serde::Deserialize; + +/// Default sliding-window length for interim transcription, in seconds. +/// Mirrors the workshop server's own default. +const DEFAULT_VOICE_WINDOW_SECONDS: u64 = 15; + +/// Default interval between interim transcriptions, in milliseconds. +/// Mirrors the workshop server's own default. +const DEFAULT_VOICE_INTERVAL_MS: u64 = 500; + +/// The tape filename used when `[workshop.tape]` does not name one. +/// Mirrors the workshop server's own default. +const DEFAULT_TAPE_FILE: &str = "tape.jsonl"; + +fn default_workshop_bind() -> SocketAddr { + SocketAddr::from(([127, 0, 0, 1], 7910)) +} + +/// The `[workshop]` section: settings for the workshop UI server hosted by +/// the gateway. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(deny_unknown_fields)] +#[non_exhaustive] +pub struct WorkshopConfig { + /// The socket address the workshop listener binds. Defaults to + /// `127.0.0.1:7910`. + #[serde(default = "default_workshop_bind")] + bind: SocketAddr, + /// Whether the gateway opens the system browser at the workshop URL once + /// it is serving. Defaults to false. + #[serde(default)] + open_browser: bool, + /// Voice transcription settings. Absent when no `[workshop.voice]` + /// section is present. + #[serde(default)] + voice: Option, + /// Session tape settings. Absent when no `[workshop.tape]` section is + /// present. + #[serde(default)] + tape: Option, +} + +impl WorkshopConfig { + /// Returns the socket address the workshop listener binds. + /// + /// # Examples + /// ``` + /// # use promptforge_gateway_config::Config; + /// # let toml = r#" + /// # [server] + /// # bind = "127.0.0.1:8080" + /// # api_key = "secret" + /// # + /// # [workshop] + /// # "#; + /// let config = Config::from_toml_str(toml)?; + /// let workshop = config.workshop().expect("workshop section present"); + /// assert_eq!(workshop.bind().to_string(), "127.0.0.1:7910"); + /// # Ok::<(), promptforge_gateway_config::ConfigError>(()) + /// ``` + #[must_use] + pub fn bind(&self) -> SocketAddr { + self.bind + } + + /// Returns whether the gateway opens the system browser at the workshop + /// URL once it is serving. + /// + /// # Examples + /// ``` + /// # use promptforge_gateway_config::Config; + /// # let toml = r#" + /// # [server] + /// # bind = "127.0.0.1:8080" + /// # api_key = "secret" + /// # + /// # [workshop] + /// # open_browser = true + /// # "#; + /// let config = Config::from_toml_str(toml)?; + /// assert!(config.workshop().expect("workshop section present").open_browser()); + /// # Ok::<(), promptforge_gateway_config::ConfigError>(()) + /// ``` + #[must_use] + pub fn open_browser(&self) -> bool { + self.open_browser + } + + /// Returns the `[workshop.voice]` settings, or `None` when the section + /// is absent. + /// + /// # Examples + /// ``` + /// # use promptforge_gateway_config::Config; + /// # let toml = r#" + /// # [server] + /// # bind = "127.0.0.1:8080" + /// # api_key = "secret" + /// # + /// # [workshop.voice] + /// # window_seconds = 8 + /// # "#; + /// let config = Config::from_toml_str(toml)?; + /// let workshop = config.workshop().expect("workshop section present"); + /// assert!(workshop.voice().is_some()); + /// # Ok::<(), promptforge_gateway_config::ConfigError>(()) + /// ``` + #[must_use] + pub fn voice(&self) -> Option<&WorkshopVoiceConfig> { + self.voice.as_ref() + } + + /// Returns the `[workshop.tape]` settings, or `None` when the section is + /// absent. + /// + /// # Examples + /// ``` + /// # use promptforge_gateway_config::Config; + /// # let toml = r#" + /// # [server] + /// # bind = "127.0.0.1:8080" + /// # api_key = "secret" + /// # + /// # [workshop.tape] + /// # path = "session.jsonl" + /// # "#; + /// let config = Config::from_toml_str(toml)?; + /// let workshop = config.workshop().expect("workshop section present"); + /// assert!(workshop.tape().is_some()); + /// # Ok::<(), promptforge_gateway_config::ConfigError>(()) + /// ``` + #[must_use] + pub fn tape(&self) -> Option<&WorkshopTapeConfig> { + self.tape.as_ref() + } + + /// Returns the tape file path anchored against `boot_dir`, the directory + /// holding the boot config. + /// + /// An absent `[workshop.tape]` (or an absent path) resolves the default + /// `tape.jsonl` against `boot_dir`; a relative path resolves against + /// `boot_dir`; an absolute path is returned unchanged. The process + /// current directory never participates, so the tape file (and the + /// workshop state persisted beside it) cannot scatter with the embedding + /// binary's start directory. + /// + /// # Examples + /// ``` + /// # use promptforge_gateway_config::Config; + /// # use std::path::{Path, PathBuf}; + /// # let toml = r#" + /// # [server] + /// # bind = "127.0.0.1:8080" + /// # api_key = "secret" + /// # + /// # [workshop] + /// # "#; + /// let config = Config::from_toml_str(toml)?; + /// let workshop = config.workshop().expect("workshop section present"); + /// assert_eq!( + /// workshop.tape_path(Path::new("/etc/pf")), + /// PathBuf::from("/etc/pf").join("tape.jsonl") + /// ); + /// # Ok::<(), promptforge_gateway_config::ConfigError>(()) + /// ``` + #[must_use] + pub fn tape_path(&self, boot_dir: &Path) -> PathBuf { + let path = self.tape.as_ref().map_or_else( + || PathBuf::from(DEFAULT_TAPE_FILE), + |tape| tape.path.clone(), + ); + if path.is_absolute() { + path + } else { + boot_dir.join(path) + } + } +} + +/// The `[workshop.voice]` section: whisper model paths and the interim +/// loop's window and cadence, mirroring the workshop server's own voice +/// settings. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(default, deny_unknown_fields)] +#[non_exhaustive] +pub struct WorkshopVoiceConfig { + /// Path to the whisper model for interim (streaming) transcription. + /// Empty disables transcription. + interim_model: PathBuf, + /// Path to the whisper model for the pipelined final pass. Empty + /// disables the final pass. + final_model: PathBuf, + /// URL the interim model can be downloaded from. Empty means no known + /// source. + interim_source: String, + /// URL the final-pass model can be downloaded from. Empty means no + /// known source. + final_source: String, + /// Seconds of trailing audio each interim pass transcribes. + window_seconds: u64, + /// Milliseconds between interim passes while a take is recording. + interval_ms: u64, + /// Domain terms whisper is biased toward. Empty disables biasing. + vocabulary: Vec, +} + +impl Default for WorkshopVoiceConfig { + fn default() -> WorkshopVoiceConfig { + WorkshopVoiceConfig { + interim_model: PathBuf::new(), + final_model: PathBuf::new(), + interim_source: String::new(), + final_source: String::new(), + window_seconds: DEFAULT_VOICE_WINDOW_SECONDS, + interval_ms: DEFAULT_VOICE_INTERVAL_MS, + vocabulary: Vec::new(), + } + } +} + +impl WorkshopVoiceConfig { + /// Returns the path to the whisper model for interim transcription + /// (empty when transcription is disabled). + /// + /// # Examples + /// ``` + /// # use promptforge_gateway_config::Config; + /// # use std::path::Path; + /// # let toml = r#" + /// # [server] + /// # bind = "127.0.0.1:8080" + /// # api_key = "secret" + /// # + /// # [workshop.voice] + /// # interim_model = "models/ggml-tiny.en.bin" + /// # "#; + /// let config = Config::from_toml_str(toml)?; + /// let voice = config.workshop().and_then(|w| w.voice()).expect("voice present"); + /// assert_eq!(voice.interim_model(), Path::new("models/ggml-tiny.en.bin")); + /// # Ok::<(), promptforge_gateway_config::ConfigError>(()) + /// ``` + #[must_use] + pub fn interim_model(&self) -> &Path { + &self.interim_model + } + + /// Returns the path to the whisper model for the pipelined final pass + /// (empty when the final pass is disabled). + /// + /// # Examples + /// ``` + /// # use promptforge_gateway_config::Config; + /// # use std::path::Path; + /// # let toml = r#" + /// # [server] + /// # bind = "127.0.0.1:8080" + /// # api_key = "secret" + /// # + /// # [workshop.voice] + /// # final_model = "models/ggml-small.en.bin" + /// # "#; + /// let config = Config::from_toml_str(toml)?; + /// let voice = config.workshop().and_then(|w| w.voice()).expect("voice present"); + /// assert_eq!(voice.final_model(), Path::new("models/ggml-small.en.bin")); + /// # Ok::<(), promptforge_gateway_config::ConfigError>(()) + /// ``` + #[must_use] + pub fn final_model(&self) -> &Path { + &self.final_model + } + + /// Returns the URL the interim model can be downloaded from (empty when + /// no source is known). + /// + /// # Examples + /// ``` + /// # use promptforge_gateway_config::Config; + /// # let toml = r#" + /// # [server] + /// # bind = "127.0.0.1:8080" + /// # api_key = "secret" + /// # + /// # [workshop.voice] + /// # interim_source = "https://example.com/tiny.bin" + /// # "#; + /// let config = Config::from_toml_str(toml)?; + /// let voice = config.workshop().and_then(|w| w.voice()).expect("voice present"); + /// assert_eq!(voice.interim_source(), "https://example.com/tiny.bin"); + /// # Ok::<(), promptforge_gateway_config::ConfigError>(()) + /// ``` + #[must_use] + pub fn interim_source(&self) -> &str { + &self.interim_source + } + + /// Returns the URL the final-pass model can be downloaded from (empty + /// when no source is known). + /// + /// # Examples + /// ``` + /// # use promptforge_gateway_config::Config; + /// # let toml = r#" + /// # [server] + /// # bind = "127.0.0.1:8080" + /// # api_key = "secret" + /// # + /// # [workshop.voice] + /// # final_source = "https://example.com/small.bin" + /// # "#; + /// let config = Config::from_toml_str(toml)?; + /// let voice = config.workshop().and_then(|w| w.voice()).expect("voice present"); + /// assert_eq!(voice.final_source(), "https://example.com/small.bin"); + /// # Ok::<(), promptforge_gateway_config::ConfigError>(()) + /// ``` + #[must_use] + pub fn final_source(&self) -> &str { + &self.final_source + } + + /// Returns the seconds of trailing audio each interim pass transcribes. + /// + /// # Examples + /// ``` + /// # use promptforge_gateway_config::Config; + /// # let toml = r#" + /// # [server] + /// # bind = "127.0.0.1:8080" + /// # api_key = "secret" + /// # + /// # [workshop.voice] + /// # window_seconds = 8 + /// # "#; + /// let config = Config::from_toml_str(toml)?; + /// let voice = config.workshop().and_then(|w| w.voice()).expect("voice present"); + /// assert_eq!(voice.window_seconds(), 8); + /// # Ok::<(), promptforge_gateway_config::ConfigError>(()) + /// ``` + #[must_use] + pub fn window_seconds(&self) -> u64 { + self.window_seconds + } + + /// Returns the milliseconds between interim passes while a take is + /// recording. + /// + /// # Examples + /// ``` + /// # use promptforge_gateway_config::Config; + /// # let toml = r#" + /// # [server] + /// # bind = "127.0.0.1:8080" + /// # api_key = "secret" + /// # + /// # [workshop.voice] + /// # interval_ms = 250 + /// # "#; + /// let config = Config::from_toml_str(toml)?; + /// let voice = config.workshop().and_then(|w| w.voice()).expect("voice present"); + /// assert_eq!(voice.interval_ms(), 250); + /// # Ok::<(), promptforge_gateway_config::ConfigError>(()) + /// ``` + #[must_use] + pub fn interval_ms(&self) -> u64 { + self.interval_ms + } + + /// Returns the domain terms whisper is biased toward (empty disables + /// biasing). + /// + /// # Examples + /// ``` + /// # use promptforge_gateway_config::Config; + /// # let toml = r#" + /// # [server] + /// # bind = "127.0.0.1:8080" + /// # api_key = "secret" + /// # + /// # [workshop.voice] + /// # vocabulary = ["MCP", "GGUF"] + /// # "#; + /// let config = Config::from_toml_str(toml)?; + /// let voice = config.workshop().and_then(|w| w.voice()).expect("voice present"); + /// assert_eq!(voice.vocabulary(), ["MCP", "GGUF"]); + /// # Ok::<(), promptforge_gateway_config::ConfigError>(()) + /// ``` + #[must_use] + pub fn vocabulary(&self) -> &[String] { + &self.vocabulary + } +} + +/// The `[workshop.tape]` section: session tape settings, mirroring the +/// workshop server's own tape settings. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(default, deny_unknown_fields)] +#[non_exhaustive] +pub struct WorkshopTapeConfig { + /// Path of the JSONL tape file. + path: PathBuf, +} + +impl Default for WorkshopTapeConfig { + fn default() -> WorkshopTapeConfig { + WorkshopTapeConfig { + path: PathBuf::from(DEFAULT_TAPE_FILE), + } + } +} + +impl WorkshopTapeConfig { + /// Returns the configured tape file path, exactly as written. + /// + /// Anchor it with [`WorkshopConfig::tape_path`] before use: a relative + /// value resolves against the boot-config directory, never the process + /// current directory. + /// + /// # Examples + /// ``` + /// # use promptforge_gateway_config::Config; + /// # use std::path::Path; + /// # let toml = r#" + /// # [server] + /// # bind = "127.0.0.1:8080" + /// # api_key = "secret" + /// # + /// # [workshop.tape] + /// # path = "session.jsonl" + /// # "#; + /// let config = Config::from_toml_str(toml)?; + /// let tape = config.workshop().and_then(|w| w.tape()).expect("tape present"); + /// assert_eq!(tape.path(), Path::new("session.jsonl")); + /// # Ok::<(), promptforge_gateway_config::ConfigError>(()) + /// ``` + #[must_use] + pub fn path(&self) -> &Path { + &self.path + } +} + +#[cfg(test)] +mod tests { + use std::path::{Path, PathBuf}; + + use crate::config::Config; + + /// A minimal valid `[server]` to prefix workshop fixtures with. + const BASE: &str = "[server]\nbind = \"127.0.0.1:8081\"\napi_key = \"k\"\n"; + + fn parse(extra: &str) -> Config { + Config::from_toml_str(&format!("{BASE}{extra}")).expect("fixture parses") + } + + #[test] + fn workshop_absent_is_none() { + assert!(parse("").workshop().is_none()); + } + + #[test] + fn workshop_empty_section_takes_defaults() { + let config = parse("[workshop]\n"); + let workshop = config.workshop().expect("workshop section present"); + assert_eq!(workshop.bind().to_string(), "127.0.0.1:7910"); + assert!(!workshop.open_browser()); + assert!(workshop.voice().is_none()); + assert!(workshop.tape().is_none()); + } + + #[test] + fn workshop_section_parses_explicit_fields() { + let config = parse( + r#" +[workshop] +bind = "127.0.0.1:7999" +open_browser = true + +[workshop.voice] +interim_model = "models/tiny.bin" +final_model = "models/small.bin" +interim_source = "https://example.com/tiny.bin" +final_source = "https://example.com/small.bin" +window_seconds = 8 +interval_ms = 250 +vocabulary = ["MCP", "GGUF"] + +[workshop.tape] +path = "session.jsonl" +"#, + ); + let workshop = config.workshop().expect("workshop section present"); + assert_eq!(workshop.bind().to_string(), "127.0.0.1:7999"); + assert!(workshop.open_browser()); + let voice = workshop.voice().expect("voice present"); + assert_eq!(voice.interim_model(), Path::new("models/tiny.bin")); + assert_eq!(voice.final_model(), Path::new("models/small.bin")); + assert_eq!(voice.interim_source(), "https://example.com/tiny.bin"); + assert_eq!(voice.final_source(), "https://example.com/small.bin"); + assert_eq!(voice.window_seconds(), 8); + assert_eq!(voice.interval_ms(), 250); + assert_eq!(voice.vocabulary(), ["MCP", "GGUF"]); + let tape = workshop.tape().expect("tape present"); + assert_eq!(tape.path(), Path::new("session.jsonl")); + } + + #[test] + fn workshop_voice_defaults_mirror_the_workshop_server() { + let config = parse("[workshop.voice]\n"); + let voice = config + .workshop() + .and_then(|workshop| workshop.voice()) + .expect("voice present"); + assert!(voice.interim_model().as_os_str().is_empty()); + assert!(voice.final_model().as_os_str().is_empty()); + assert!(voice.interim_source().is_empty()); + assert!(voice.final_source().is_empty()); + assert_eq!(voice.window_seconds(), 15); + assert_eq!(voice.interval_ms(), 500); + assert!(voice.vocabulary().is_empty()); + } + + #[test] + fn workshop_rejects_unknown_fields_in_every_sub_table() { + for section in [ + "[workshop]\nbogus = 1\n", + "[workshop.voice]\nbogus = 1\n", + "[workshop.tape]\nbogus = 1\n", + ] { + let error = Config::from_toml_str(&format!("{BASE}{section}")) + .expect_err("an unknown workshop field must fail"); + assert_eq!(error.kind(), crate::ConfigErrorKind::Parse, "in {section}"); + } + } + + #[test] + fn workshop_client_url_swaps_unspecified_bind_for_loopback() { + let url = |bind: &str| { + Config::from_toml_str(&format!("[server]\nbind = \"{bind}\"\napi_key = \"k\"\n")) + .expect("fixture parses") + .server() + .client_url() + }; + assert_eq!(url("0.0.0.0:8081"), "http://127.0.0.1:8081"); + assert_eq!(url("[::]:8081"), "http://[::1]:8081"); + } + + #[test] + fn workshop_client_url_keeps_reachable_binds() { + let url = |bind: &str| { + Config::from_toml_str(&format!("[server]\nbind = \"{bind}\"\napi_key = \"k\"\n")) + .expect("fixture parses") + .server() + .client_url() + }; + assert_eq!(url("127.0.0.1:8081"), "http://127.0.0.1:8081"); + assert_eq!(url("192.168.1.5:9000"), "http://192.168.1.5:9000"); + } + + #[test] + fn workshop_tape_path_anchors_absent_and_relative_against_the_boot_dir() { + let boot_dir = Path::new("boot-dir"); + + let absent = parse("[workshop]\n"); + assert_eq!( + absent.workshop().expect("present").tape_path(boot_dir), + boot_dir.join("tape.jsonl"), + "an absent [workshop.tape] anchors the default filename" + ); + + let relative = parse("[workshop.tape]\npath = \"tapes/session.jsonl\"\n"); + assert_eq!( + relative.workshop().expect("present").tape_path(boot_dir), + boot_dir.join("tapes").join("session.jsonl"), + "a relative path anchors against the boot dir, not the cwd" + ); + } + + #[test] + fn workshop_tape_path_keeps_an_absolute_path() { + let absolute = std::env::temp_dir().join("pf-tape.jsonl"); + let config = parse(&format!( + "[workshop.tape]\npath = {:?}\n", + absolute.display().to_string() + )); + assert_eq!( + config + .workshop() + .expect("present") + .tape_path(Path::new("boot-dir")), + PathBuf::from(&absolute) + ); + } +} diff --git a/crates/promptforge-gateway-config/src/lib.rs b/crates/promptforge-gateway-config/src/lib.rs index 307a034c..6b9fd679 100644 --- a/crates/promptforge-gateway-config/src/lib.rs +++ b/crates/promptforge-gateway-config/src/lib.rs @@ -14,10 +14,11 @@ //! [`Config::load_profile`] loads a named profile from a profiles directory, //! and [`Config::from_toml_str`] parses a TOML string. //! [`Config::load_profile_with_chain`] additionally returns the resolved -//! include chain, and [`load_server`] reads only a boot file's `[server]` -//! section (includes and interpolation, without full validation). Failures -//! are reported as the opaque [`ConfigError`]; classify them with -//! [`ConfigError::kind`]. +//! include chain, and [`load_server`] and [`load_workshop`] read only a boot +//! file's `[server]` and `[workshop]` sections (includes and interpolation, +//! without full validation); [`load_boot_sections`] reads both in one pass. +//! Failures are reported as the opaque +//! [`ConfigError`]; classify them with [`ConfigError::kind`]. //! //! The crate never mutates the process environment: `${VAR}` interpolation //! reads it, and loading env files into it is the calling binary's job. @@ -42,6 +43,9 @@ pub use crate::api_error::{ConfigError, ConfigErrorKind}; pub use crate::config::{ Capabilities, Config, DominionConfig, DominionKind, EndpointConfig, LocalConfig, LocalModelConfig, ModelConfig, ModelKind, Protocol, QueuePolicy, SearchProvider, Secret, - ServerConfig, ThinkingMode, ToolDialect, ToolsConfig, WebSearchConfig, + ServerConfig, ThinkingMode, ToolDialect, ToolsConfig, WebSearchConfig, WorkshopConfig, + WorkshopTapeConfig, WorkshopVoiceConfig, +}; +pub use crate::profile::{ + ProfileName, ProfileNameError, list_profiles, load_boot_sections, load_server, load_workshop, }; -pub use crate::profile::{ProfileName, ProfileNameError, list_profiles, load_server}; diff --git a/crates/promptforge-gateway-config/src/profile.rs b/crates/promptforge-gateway-config/src/profile.rs index 2f68a37b..39153798 100644 --- a/crates/promptforge-gateway-config/src/profile.rs +++ b/crates/promptforge-gateway-config/src/profile.rs @@ -15,7 +15,7 @@ use std::path::{Path, PathBuf}; use toml::Value; -use crate::config::{Config, ServerConfig, interpolate_value}; +use crate::config::{Config, ServerConfig, WorkshopConfig, interpolate_value}; use crate::error::ConfigError; /// Maximum `include` nesting depth (guards against runaway trees). @@ -159,6 +159,90 @@ pub fn load_server(path: &Path) -> Result Result { let (mut value, _chain) = collect_config_chain(path)?; interpolate_value(&mut value)?; + server_section(&value, path) +} + +/// Loads only the `[workshop]` section of a config file: includes are +/// resolved and `${VAR}` references interpolated, but full validation is +/// skipped. Returns `None` when the section is absent, which is the common +/// case for a headless gateway. +/// +/// Like [`load_server`], this serves callers enforcing a boot-owned section +/// rule: the boot file is the catalog and may legitimately fail checks that +/// apply to a loaded profile. +/// +/// # Errors +/// Returns a [`ConfigError`](crate::ConfigError) when the file (or an +/// included file) cannot be read or parsed, an include cycles or exceeds +/// depth, an interpolation fails, or a present `[workshop]` section does not +/// deserialize (for example a malformed `bind` or an unknown field). +/// +/// # Examples +/// ```no_run +/// use promptforge_gateway_config::load_workshop; +/// use std::path::Path; +/// +/// if let Some(workshop) = load_workshop(Path::new("gateway.toml"))? { +/// println!("workshop binds {}", workshop.bind()); +/// } +/// # Ok::<(), promptforge_gateway_config::ConfigError>(()) +/// ``` +pub fn load_workshop(path: &Path) -> Result, crate::api_error::ConfigError> { + load_workshop_repr(path).map_err(crate::api_error::ConfigError::from) +} + +/// The crate-internal form of [`load_workshop`], returning the private +/// representation. +fn load_workshop_repr(path: &Path) -> Result, ConfigError> { + let (mut value, _chain) = collect_config_chain(path)?; + interpolate_value(&mut value)?; + workshop_section(&value, path) +} + +/// Loads both boot-owned sections of a config file in one include-resolution +/// and interpolation pass: [`load_server`] and [`load_workshop`] combined. +/// +/// A caller that needs both sections (the gateway's startup path) avoids +/// parsing the same include tree twice. As with the single-section loaders, +/// full validation is skipped: the boot file is the catalog and may +/// legitimately fail checks that apply to a loaded profile. +/// +/// # Errors +/// Returns a [`ConfigError`](crate::ConfigError) under the same conditions +/// as [`load_server`]: the file (or an included file) cannot be read or +/// parsed, an include cycles or exceeds depth, an interpolation fails, the +/// `[server]` section is absent, or a present section does not deserialize. +/// +/// # Examples +/// ```no_run +/// use promptforge_gateway_config::load_boot_sections; +/// use std::path::Path; +/// +/// let (server, workshop) = load_boot_sections(Path::new("gateway.toml"))?; +/// println!("boot file binds {}", server.bind()); +/// # Ok::<(), promptforge_gateway_config::ConfigError>(()) +/// ``` +pub fn load_boot_sections( + path: &Path, +) -> Result<(ServerConfig, Option), crate::api_error::ConfigError> { + load_boot_sections_repr(path).map_err(crate::api_error::ConfigError::from) +} + +/// The crate-internal form of [`load_boot_sections`], returning the private +/// representation. +fn load_boot_sections_repr( + path: &Path, +) -> Result<(ServerConfig, Option), ConfigError> { + let (mut value, _chain) = collect_config_chain(path)?; + interpolate_value(&mut value)?; + Ok(( + server_section(&value, path)?, + workshop_section(&value, path)?, + )) +} + +/// Extracts the required `[server]` section from an interpolated document. +fn server_section(value: &Value, path: &Path) -> Result { let server = value .as_table() .and_then(|table| table.get("server")) @@ -172,6 +256,24 @@ fn load_server_repr(path: &Path) -> Result { }) } +/// Extracts the optional `[workshop]` section from an interpolated document. +fn workshop_section(value: &Value, path: &Path) -> Result, ConfigError> { + let Some(workshop) = value + .as_table() + .and_then(|table| table.get("workshop")) + .cloned() + else { + return Ok(None); + }; + workshop + .try_into() + .map(Some) + .map_err(|source| ConfigError::Parse { + path: Some(path.to_owned()), + source: Box::new(source), + }) +} + /// Loads a config TOML path with recursive include resolution. /// /// `${VAR}` interpolation reads the process environment as the caller left diff --git a/crates/promptforge-gateway-config/src/profile/tests.rs b/crates/promptforge-gateway-config/src/profile/tests.rs index 799a98df..eab20177 100644 --- a/crates/promptforge-gateway-config/src/profile/tests.rs +++ b/crates/promptforge-gateway-config/src/profile/tests.rs @@ -564,6 +564,62 @@ fn load_server_interpolates_from_the_process_environment() { assert_eq!(err.kind(), crate::ConfigErrorKind::UnresolvedVar); } +#[test] +fn load_workshop_reads_section_without_full_validation() { + // The bare catalog may fail checks that apply to a loaded profile (here a + // model naming an undefined endpoint); load_workshop still extracts + // [workshop]. + let tmp = TempDir::new().unwrap(); + write( + tmp.path(), + "gateway.toml", + r#" +[server] +bind = "127.0.0.1:8081" +api_key = "boot-key" + +[workshop] +bind = "127.0.0.1:7999" + +[[model]] +name = "m" +description = "prose" +context = 1 +upstream = "u" +endpoints = ["missing"] +"#, + ); + + let workshop = load_workshop(&tmp.path().join("gateway.toml")) + .unwrap() + .expect("the [workshop] section is present"); + assert_eq!(workshop.bind().to_string(), "127.0.0.1:7999"); +} + +#[test] +fn load_workshop_returns_none_when_the_section_is_absent() { + let tmp = TempDir::new().unwrap(); + write(tmp.path(), "gateway.toml", MINIMAL_CONFIG); + assert!( + load_workshop(&tmp.path().join("gateway.toml")) + .unwrap() + .is_none() + ); +} + +#[test] +fn load_workshop_resolves_the_boot_files_own_include_chain() { + // The boot file's [workshop] may itself come from an include. + let tmp = TempDir::new().unwrap(); + write(tmp.path(), "base.toml", "[workshop]\nopen_browser = true\n"); + write(tmp.path(), "gateway.toml", "include = [\"base.toml\"]\n"); + + let workshop = load_workshop(&tmp.path().join("gateway.toml")) + .unwrap() + .expect("the included [workshop] section is present"); + assert!(workshop.open_browser()); +} + #[test] fn load_server_requires_a_server_section() { let tmp = TempDir::new().unwrap(); @@ -577,3 +633,64 @@ fn load_server_requires_a_server_section() { assert_eq!(err.kind(), crate::ConfigErrorKind::Validation); assert!(err.to_string().contains("[server]"), "got: {err}"); } + +#[test] +fn load_boot_sections_reads_both_sections_without_full_validation() { + // The bare catalog may fail checks that apply to a loaded profile (here a + // model naming an undefined endpoint); the combined loader still extracts + // both boot sections. + let tmp = TempDir::new().unwrap(); + write( + tmp.path(), + "gateway.toml", + r#" +[server] +bind = "127.0.0.1:8081" +api_key = "boot-key" + +[workshop] +bind = "127.0.0.1:7999" + +[[model]] +name = "m" +description = "prose" +context = 1 +upstream = "u" +endpoints = ["missing"] +"#, + ); + + let (server, workshop) = load_boot_sections(&tmp.path().join("gateway.toml")).unwrap(); + assert_eq!(server.api_key().expose(), "boot-key"); + assert_eq!( + workshop + .expect("the [workshop] section is present") + .bind() + .to_string(), + "127.0.0.1:7999" + ); +} + +#[test] +fn load_boot_sections_returns_none_workshop_when_the_section_is_absent() { + let tmp = TempDir::new().unwrap(); + write(tmp.path(), "gateway.toml", MINIMAL_CONFIG); + + let (server, workshop) = load_boot_sections(&tmp.path().join("gateway.toml")).unwrap(); + assert_eq!(server.bind().to_string(), "127.0.0.1:8081"); + assert!(workshop.is_none()); +} + +#[test] +fn load_boot_sections_requires_a_server_section() { + let tmp = TempDir::new().unwrap(); + write( + tmp.path(), + "gateway.toml", + "[workshop]\nopen_browser = true\n", + ); + + let err = load_boot_sections(&tmp.path().join("gateway.toml")).unwrap_err(); + assert_eq!(err.kind(), crate::ConfigErrorKind::Validation); + assert!(err.to_string().contains("[server]"), "got: {err}"); +} diff --git a/crates/promptforge-gateway/Cargo.toml b/crates/promptforge-gateway/Cargo.toml index 5f331e2a..19cd17b3 100644 --- a/crates/promptforge-gateway/Cargo.toml +++ b/crates/promptforge-gateway/Cargo.toml @@ -23,8 +23,11 @@ dotenvy.workspace = true flate2.workspace = true futures-util.workspace = true indicatif.workspace = true +# Optional: opens the system browser at the hosted workshop URL. +open = { workspace = true, optional = true } promptforge-core.workspace = true promptforge-gateway-config.workspace = true +promptforge-ws-server = { workspace = true, optional = true } rand.workspace = true serde.workspace = true serde_json.workspace = true @@ -39,6 +42,14 @@ tracing.workspace = true tracing-subscriber.workspace = true zip.workspace = true +[features] +# Host the workshop UI server on a second loopback listener. The default +# stays empty so headless gateway builds never pull whisper, CUDA, or the +# Node UI build into the graph. +workshop = ["dep:promptforge-ws-server", "dep:open"] +workshop-cuda = ["workshop", "promptforge-ws-server/cuda"] +workshop-metal = ["workshop", "promptforge-ws-server/metal"] + [dev-dependencies] tempfile.workspace = true diff --git a/crates/promptforge-gateway/README.md b/crates/promptforge-gateway/README.md index 538439e9..1c119c5f 100644 --- a/crates/promptforge-gateway/README.md +++ b/crates/promptforge-gateway/README.md @@ -22,8 +22,70 @@ Boot requires two things: a config path and a profile name. The config path come Configure endpoints, models, and credentials in the TOML catalog. The gateway accepts `POST /v1/chat/completions` and serves a model catalog at `GET /v1/models`. +Embedding hosts use the library API instead of the binary: `spawn` starts the gateway on a dedicated thread with its own runtime and blocks until the listener is bound, returning a `GatewayHandle` that carries the bound URL and a graceful-shutdown switch (`url()`, `shutdown()`, `join()`). + See the [PromptForge User Guide](https://cppalliance.github.io/promptforge/) for full documentation. +## The `[server]` section + +The boot config's `[server]` section is required and has no defaults. Both fields accept `${VAR}` interpolation from the process environment. + +| Field | Default | Meaning | +|---|---|---| +| `bind` | required | Socket address the gateway listener binds. | +| `api_key` | required | Shared bearer key every `/v1/*` request must present. | + +Like `[workshop]`, the section is owned by the boot config: a profile whose merged `[server]` differs from the boot file's is refused at startup or, on a mid-run switch, with the running state left untouched. + +## Hosting the workshop + +Built with the `workshop` feature, the gateway can host the PromptForge Workshop UI server on a second, loopback-only listener in the same process. Hosting is switched on by a `[workshop]` section in the boot config; without the section (or without the feature) the gateway runs headless. + +```bash +cargo build -p promptforge-gateway --features workshop +``` + +Three feature flags exist: + +- `workshop` - compiles the hosted workshop in: the `promptforge-ws-server` crate and system-browser opening. +- `workshop-cuda` - implies `workshop` and builds the whisper voice engine with CUDA acceleration. +- `workshop-metal` - implies `workshop` and builds the whisper voice engine with Metal acceleration (macOS). + +The default feature set is empty, so a headless gateway build never pulls the workshop's toolchain into the graph: Node/esbuild (the workshop UI bundle) and whisper enter the gateway build only with `--features workshop`. + +### The `[workshop]` section + +| Field | Default | Meaning | +|---|---|---| +| `bind` | `127.0.0.1:7910` | Socket address of the workshop listener. Must be a loopback address; a non-loopback bind is refused at startup. | +| `open_browser` | `false` | Open the system browser at the workshop URL once it is serving. Meant for running the gateway (or hosting the UI) without the desktop shell; a browser that fails to open is logged, never fatal. | + +`[workshop.voice]` (optional) configures push-to-talk transcription: + +| Field | Default | Meaning | +|---|---|---| +| `interim_model` | empty | Path to the whisper model for interim (streaming) transcription. Empty disables transcription. | +| `final_model` | empty | Path to the whisper model for the pipelined final pass. Empty disables the final pass. | +| `interim_source` | empty | URL the interim model can be downloaded from. Empty means no known source. | +| `final_source` | empty | URL the final-pass model can be downloaded from. Empty means no known source. | +| `window_seconds` | `15` | Seconds of trailing audio each interim pass transcribes. | +| `interval_ms` | `500` | Milliseconds between interim passes while a take is recording. | +| `vocabulary` | `[]` | Domain terms whisper is biased toward. Empty disables biasing. | + +`[workshop.tape]` (optional) configures the session tape: + +| Field | Default | Meaning | +|---|---|---| +| `path` | `tape.jsonl` | Path of the JSONL tape file. A relative path resolves against the directory holding the boot config, never the process current directory; an absolute path is used unchanged. An absent `[workshop.tape]` anchors the default `tape.jsonl` the same way. | + +### Derived client credentials + +There is no `[workshop.gateway]` sub-table. The hosted workshop reaches the gateway through its own HTTP client, and that client's `base_url` and `api_key` derive from the boot `[server]` section: the URL is the `[server]` bind with an unspecified address swapped for loopback (`0.0.0.0` becomes `127.0.0.1`, `[::]` becomes `[::1]`), and the bearer key is the `[server]` `api_key` itself. No credential is duplicated in `[workshop]`, so none can drift. + +### The boot-only rule + +Like `[server]`, the `[workshop]` section is owned by the boot config. A profile whose merged `[workshop]` differs from the boot file's is refused, and one-sided presence - the section in only one of the two files - is refused the same way. At boot the refusal fails startup; on a mid-run profile switch it reaches the caller as the switch stream's terminal SSE error event and leaves the running state untouched. The workshop's listener, tape, and voice settings are therefore fixed for the process lifetime. + ## Minimum Rust Version Rust 1.89 or later. diff --git a/crates/promptforge-gateway/src/api_error.rs b/crates/promptforge-gateway/src/api_error.rs index 777a7feb..fe73949f 100644 --- a/crates/promptforge-gateway/src/api_error.rs +++ b/crates/promptforge-gateway/src/api_error.rs @@ -43,8 +43,14 @@ pub enum StartupErrorKind { Provisioning, /// Binding the listener failed. Bind, + /// Spawning the gateway thread failed, or the thread failed to build + /// its runtime, exited, or panicked before binding the listener. + Thread, /// Serving requests failed. Serve, + /// Starting the hosted workshop server failed. Produced only by builds + /// with the `workshop` feature. + Workshop, } #[derive(Debug, thiserror::Error)] @@ -55,8 +61,13 @@ enum StartupRepr { Provisioning(#[source] LocalError), #[error("failed to bind the listener")] Bind(#[source] std::io::Error), + #[error("gateway thread error")] + Thread(#[source] std::io::Error), #[error("serve error")] Serve(#[source] ServeReprSource), + #[cfg(feature = "workshop")] + #[error("workshop startup error")] + Workshop(#[source] promptforge_ws_server::SpawnError), } impl StartupError { @@ -67,7 +78,10 @@ impl StartupError { StartupRepr::Config(_) => StartupErrorKind::Config, StartupRepr::Provisioning(_) => StartupErrorKind::Provisioning, StartupRepr::Bind(_) => StartupErrorKind::Bind, + StartupRepr::Thread(_) => StartupErrorKind::Thread, StartupRepr::Serve(_) => StartupErrorKind::Serve, + #[cfg(feature = "workshop")] + StartupRepr::Workshop(_) => StartupErrorKind::Workshop, } } @@ -83,9 +97,18 @@ impl StartupError { StartupError(StartupRepr::Bind(err)) } + pub(crate) fn thread(err: std::io::Error) -> Self { + StartupError(StartupRepr::Thread(err)) + } + pub(crate) fn serve(err: ServeError) -> Self { StartupError(StartupRepr::Serve(err.0)) } + + #[cfg(feature = "workshop")] + pub(crate) fn workshop(err: promptforge_ws_server::SpawnError) -> Self { + StartupError(StartupRepr::Workshop(err)) + } } impl std::fmt::Debug for StartupError { @@ -152,5 +175,9 @@ mod tests { let bind = StartupError::bind(std::io::Error::other("x")); assert_eq!(bind.kind(), StartupErrorKind::Bind); assert!(bind.source().is_some()); + + let thread = StartupError::thread(std::io::Error::other("x")); + assert_eq!(thread.kind(), StartupErrorKind::Thread); + assert!(thread.source().is_some()); } } diff --git a/crates/promptforge-gateway/src/lib.rs b/crates/promptforge-gateway/src/lib.rs index 859392d9..2ef51ed5 100644 --- a/crates/promptforge-gateway/src/lib.rs +++ b/crates/promptforge-gateway/src/lib.rs @@ -14,7 +14,8 @@ //! concurrency pools with bounded, fair waiting queues (`[[dominion]]`), //! gateway-owned local generative inference via a managed `llama-server` //! subprocess (`[[local_model]]`), named profiles with recursive `include` -//! and immediate `POST /admin/switch-profile`, a bearer-authed +//! and immediate `POST /admin/switch-profile` streaming its stages over +//! SSE, a bearer-authed //! `GET /v1/models` catalog, a Brave-backed `POST /v1/tools/web_search` //! configured by `[tools.web_search]`, an on-demand blob cache //! (`POST /v1/cache` with SSE download progress, `GET /v1/cache`, @@ -37,9 +38,10 @@ mod tools; mod upstream; mod web_search_process; mod wire; +mod workshop; pub use crate::api_error::{ServeError, StartupError, StartupErrorKind}; -pub use crate::runner::{Gateway, ProfilesContext, ServeOptions, run}; +pub use crate::runner::{Gateway, GatewayHandle, ProfilesContext, ServeOptions, run, spawn}; pub use promptforge_gateway_config::{ Config, ConfigError, ConfigErrorKind, ProfileName, ProfileNameError, Secret, }; @@ -66,7 +68,7 @@ use crate::wire::{ ChatRequest, EmbeddingRequest, EmbeddingResponse, ModelInfo, ModelsResponse, RerankRequest, RerankResponse, }; -use promptforge_gateway_config::{ModelKind, ServerConfig, WebSearchConfig}; +use promptforge_gateway_config::{ModelKind, ServerConfig, WebSearchConfig, WorkshopConfig}; /// Mutable live configuration held behind a lock so profile switches can swap /// routing and local children without rebuilding the axum router. @@ -98,15 +100,26 @@ pub(crate) struct ProfileSelection { pub(crate) model_allowlist: Option>, } +/// The boot file's boot-owned sections, fixed for the process lifetime and +/// enforced against every profile switch. +#[derive(Debug, Clone)] +pub(crate) struct BootOwned { + /// The boot `[server]`: the socket and the gateway bearer key. + pub(crate) server: ServerConfig, + /// The boot `[workshop]`, when present: the hosted workshop is started + /// once at boot and cannot be moved, reconfigured, or removed mid-run. + pub(crate) workshop: Option, +} + /// Shared handler state: live routing/key/local runtime, the boot-owned -/// `[server]` settings, and optional profiles dir. +/// `[server]` and `[workshop]` settings, and optional profiles dir. #[derive(Debug, Clone)] pub(crate) struct AppState { live: Arc>, profiles: Option>, - /// The boot file's `[server]`, retained so profile switches can enforce - /// the boot-owned `[server]` rule (fixed socket and bearer key). - boot_server: Arc, + /// The boot-owned sections, retained so profile switches can refuse a + /// profile that changes them. + boot: Arc, /// Serializes profile switches so two concurrent switches cannot interleave /// their reads and writes of the live state. switch: Arc>, @@ -122,7 +135,7 @@ impl AppState { web_search: Option<&WebSearchConfig>, profiles_dir: Option, selection: ProfileSelection, - boot_server: ServerConfig, + boot: BootOwned, ) -> AppState { AppState { live: Arc::new(RwLock::new(LiveState { @@ -134,7 +147,7 @@ impl AppState { model_allowlist: selection.model_allowlist, })), profiles: profiles_dir.map(|dir| Arc::new(AdminProfiles { dir })), - boot_server: Arc::new(boot_server), + boot: Arc::new(boot), switch: Arc::new(tokio::sync::Mutex::new(())), } } @@ -423,17 +436,50 @@ async fn admin_status( }))) } -/// Immediately switches to another named profile. +/// Immediately switches to another named profile, streaming its progress. +/// +/// The reply is `text/event-stream`: a `{"stage": ...}` event opens each +/// phase in execution order - `loading-profile` around config load and +/// validation, `stopping-models` before the old local children shut down, +/// `starting-models` before the new children load their weights into VRAM +/// (the long pole) - and the stream ends with exactly one terminal event, +/// `{"status": "ready", "profile": ...}` or `{"status": "error", +/// "message": ...}`. There is no drain stage because the gateway does not +/// drain. A refusal before the switch starts (bad auth, no profiles +/// directory, a malformed name) stays a buffered JSON error envelope. +/// +/// The switch itself runs on its own task ([`run_switch`]) and always runs +/// to completion: a client disconnect drops only the response body and the +/// stage receiver, never the half-finished switch. +async fn admin_switch_profile( + State(state): State, + headers: HeaderMap, + Json(request): Json, +) -> Result { + check_auth(&state, &headers).await?; + let dir = profiles_dir(&state)?.to_path_buf(); + let name = ProfileName::parse(&request.name) + .map_err(|e| GatewayError::switch_failed("parse-name", e))?; + // Three stage markers into a bound of eight: try_send never drops here, + // and even a dropped marker would cost a progress line, not the switch. + let (stages, rx) = tokio::sync::mpsc::channel(8); + let switch = tokio::spawn(run_switch(state, dir, name, stages)); + Ok(switch_sse_response(rx, switch)) +} + +/// Executes a profile switch, marking each phase on the `stages` channel. /// /// Switches are serialized by a dedicated mutex, so two concurrent requests /// cannot interleave. The new profile's env file loads before the profile /// itself (the boot file's env file is already in the process environment /// from startup). Configuration is loaded and validated off the live lock; /// a config or routing failure returns an error and leaves the live state -/// untouched. The boot file owns `[server]`: a profile whose merged -/// `[server]` differs from the boot `[server]` is rejected, so the socket -/// and the gateway bearer key are fixed for the process lifetime and a -/// switch never rotates the admin credential. The routing and web-search +/// untouched. The boot file owns `[server]` and `[workshop]`: a profile +/// whose merged `[server]` or `[workshop]` differs from the boot file's is +/// rejected - the refusal reaches the caller as the switch stream's terminal +/// error event - so the socket, the gateway bearer key, and the hosted +/// workshop's settings are fixed for the process lifetime and a switch never +/// rotates the admin credential. The routing and web-search /// settings of the new profile are committed only after the new local /// runtime starts successfully, via a single atomic swap under the write /// lock. Because old and new `llama-server` children must not both hold @@ -441,19 +487,16 @@ async fn admin_status( /// failure therefore leaves the previous profile authenticated and /// remote-routable but without its local models (a documented degraded /// state) rather than a half-applied new profile. -async fn admin_switch_profile( - State(state): State, - headers: HeaderMap, - Json(request): Json, -) -> Result, GatewayError> { - check_auth(&state, &headers).await?; - let dir = profiles_dir(&state)?.to_path_buf(); - let name = ProfileName::parse(&request.name) - .map_err(|e| GatewayError::switch_failed("parse-name", e))?; - +async fn run_switch( + state: AppState, + dir: PathBuf, + name: ProfileName, + stages: tokio::sync::mpsc::Sender<&'static str>, +) -> Result { // Serialize switches for the whole operation (LIB-008). let _switch = state.switch.lock().await; + let _ = stages.try_send("loading-profile"); let path = dir.join(format!("{name}.toml")); if !path.is_file() { return Err(GatewayError::ProfileNotFound(name.to_string())); @@ -468,8 +511,14 @@ async fn admin_switch_profile( // here returns before mutating live state at all (LIB-009). let config = Config::load_profile(&dir, &name) .map_err(|e| GatewayError::switch_failed("load-profile", e))?; - crate::runner::check_server_matches_boot(&state.boot_server, config.server(), &name) + crate::runner::check_server_matches_boot(&state.boot.server, config.server(), &name) .map_err(|e| GatewayError::switch_failed("server-mismatch", e))?; + crate::runner::check_workshop_matches_boot( + state.boot.workshop.as_ref(), + config.workshop(), + &name, + ) + .map_err(|e| GatewayError::switch_failed("workshop-mismatch", e))?; let remote_routing = Routing::from_config(&config) .map_err(|e| GatewayError::switch_failed("build-routing", e))?; let new_web_search = config @@ -482,6 +531,7 @@ async fn admin_switch_profile( // Stop the previous local children before starting new ones so the two // never hold VRAM simultaneously. The bearer key, routing, and web-search // settings are left untouched here, so auth stays stable if start fails. + let _ = stages.try_send("stopping-models"); let old_local = { let mut live = state.live.write().await; std::mem::replace(&mut live.local, LocalRuntime::empty()) @@ -506,6 +556,7 @@ async fn admin_switch_profile( Err(e) => return Err(GatewayError::switch_failed("shutdown-local-task", e)), } + let _ = stages.try_send("starting-models"); let new_local = match tokio::task::spawn_blocking(move || LocalRuntime::start(&config)).await { Ok(Ok(runtime)) => runtime, Ok(Err(e)) => { @@ -532,10 +583,63 @@ async fn admin_switch_profile( } tracing::info!(profile = %name, "switched profile"); - Ok(Json(serde_json::json!({ - "ok": true, - "profile": name.to_string(), - }))) + Ok(name.to_string()) +} + +/// Builds the switch-profile SSE response: stage events drained from the +/// channel, then the terminal event from the switch task's join result, so +/// the outcome can never be lost to channel backpressure. +/// +/// The channel closes when [`run_switch`] drops its sender, so the stage +/// stream ends before the terminal event is awaited - the same ordering +/// contract as the cache download stream. +fn switch_sse_response( + mut rx: tokio::sync::mpsc::Receiver<&'static str>, + switch: tokio::task::JoinHandle>, +) -> Response { + use futures_util::StreamExt as _; + + let stages = futures_util::stream::poll_fn(move |cx| rx.poll_recv(cx)).map(|stage| { + Ok::<_, std::convert::Infallible>(format!( + "data: {}\n\n", + serde_json::json!({ "stage": stage }) + )) + }); + let terminal = futures_util::stream::once(async move { + let payload = match switch.await { + Ok(Ok(profile)) => serde_json::json!({ "status": "ready", "profile": profile }), + Ok(Err(error)) => serde_json::json!({ + "status": "error", + "message": error_chain(&error), + }), + Err(join_error) => serde_json::json!({ + "status": "error", + "message": format!("switch task failed: {join_error}"), + }), + }; + Ok::<_, std::convert::Infallible>(format!("data: {payload}\n\n")) + }); + let mut response = Response::new(Body::from_stream(stages.chain(terminal))); + let headers = response.headers_mut(); + headers.insert(CONTENT_TYPE, HeaderValue::from_static("text/event-stream")); + headers.insert(CACHE_CONTROL, HeaderValue::from_static("no-cache")); + response +} + +/// Renders `error` with its full source chain for the terminal SSE error +/// event: the stream has a single `message` field where the JSON envelope +/// had `message` plus `code`, and a bare `switch profile failed at +/// load-profile` without its cause tells the operator nothing. +fn error_chain(error: &GatewayError) -> String { + use std::fmt::Write as _; + + let mut message = error.to_string(); + let mut source = std::error::Error::source(error); + while let Some(cause) = source { + let _ = write!(message, ": {cause}"); + source = cause.source(); + } + message } fn profiles_dir(state: &AppState) -> Result<&Path, GatewayError> { diff --git a/crates/promptforge-gateway/src/local/artifacts/archive.rs b/crates/promptforge-gateway/src/local/artifacts/archive.rs index c152be41..f61b9946 100644 --- a/crates/promptforge-gateway/src/local/artifacts/archive.rs +++ b/crates/promptforge-gateway/src/local/artifacts/archive.rs @@ -1,5 +1,12 @@ //! Archive extraction (tar.gz/zip) with traversal and entry-type confinement. +//! +//! Tar symlink entries are materialized as regular-file copies of their +//! targets, never as links: the cache confinement (ART-006) refuses +//! symlinks anywhere under the root, and the macOS llama.cpp release +//! archives ship their dylibs behind versioned symlink chains. Zip +//! symlinks remain unsupported. +use std::collections::HashMap; use std::ffi::OsStr; use std::fs::{self, File}; use std::io::{self, BufReader}; @@ -34,6 +41,7 @@ fn extract_tar_gz(archive: &Path, destination: &Path) -> Result<()> { archive: archive.display().to_string(), source, })?; + let mut links: Vec<(PathBuf, PathBuf)> = Vec::new(); for entry in entries { let mut entry = entry.map_err(|source| LocalError::Archive { archive: archive.display().to_string(), @@ -46,9 +54,30 @@ fn extract_tar_gz(archive: &Path, destination: &Path) -> Result<()> { source, })? .into_owned(); - if !safe_archive_path(&entry_path) - || !(entry.header().entry_type().is_file() || entry.header().entry_type().is_dir()) - { + if !safe_archive_path(&entry_path) { + return Err(LocalError::UnsafeArchiveEntry { + archive: archive.display().to_string(), + entry: entry_path.display().to_string(), + }); + } + if entry.header().entry_type().is_symlink() { + let target = entry.link_name().map_err(|source| LocalError::Archive { + archive: archive.display().to_string(), + source, + })?; + let resolved = target + .as_deref() + .and_then(|target| resolve_link_target(&entry_path, target)); + let Some(resolved) = resolved else { + return Err(LocalError::UnsafeArchiveEntry { + archive: archive.display().to_string(), + entry: entry_path.display().to_string(), + }); + }; + links.push((entry_path, resolved)); + continue; + } + if !(entry.header().entry_type().is_file() || entry.header().entry_type().is_dir()) { return Err(LocalError::UnsafeArchiveEntry { archive: archive.display().to_string(), entry: entry_path.display().to_string(), @@ -73,6 +102,73 @@ fn extract_tar_gz(archive: &Path, destination: &Path) -> Result<()> { } validate_tree_path(destination, &output)?; } + materialize_symlinks(archive, destination, &links) +} + +/// Longest symlink chain the extractor follows before calling it a cycle. +const MAX_LINK_HOPS: usize = 32; + +/// Lexically resolves a symlink entry's target against the entry's parent +/// directory, returning `None` unless both the target and the resolved +/// path are safe relative paths that stay inside the archive root. +fn resolve_link_target(entry_path: &Path, target: &Path) -> Option { + if !safe_archive_path(target) { + return None; + } + let parent = entry_path.parent().unwrap_or(Path::new("")); + let resolved = parent.join(target); + safe_archive_path(&resolved).then_some(resolved) +} + +/// Copies each recorded symlink entry's final target to the link's own +/// path, following chains through other recorded links. Runs after every +/// regular entry has landed, so link-before-target archive order works. +/// +/// # Errors +/// Returns [`LocalError::UnsafeArchiveEntry`] for a dangling target, a +/// chain past [`MAX_LINK_HOPS`] (a cycle), or a target that is not a +/// regular file; copy failures surface as [`LocalError::Io`]. +fn materialize_symlinks( + archive: &Path, + destination: &Path, + links: &[(PathBuf, PathBuf)], +) -> Result<()> { + let targets: HashMap<&Path, &Path> = links + .iter() + .map(|(link, target)| (link.as_path(), target.as_path())) + .collect(); + for (link, target) in links { + let unsafe_entry = || LocalError::UnsafeArchiveEntry { + archive: archive.display().to_string(), + entry: link.display().to_string(), + }; + let mut resolved = target.as_path(); + let mut hops = 0; + while let Some(next) = targets.get(resolved) { + hops += 1; + if hops > MAX_LINK_HOPS { + return Err(unsafe_entry()); + } + resolved = next; + } + let target_path = destination.join(resolved); + validate_tree_path(destination, &target_path)?; + let is_regular_file = + fs::symlink_metadata(&target_path).is_ok_and(|metadata| metadata.is_file()); + if !is_regular_file { + return Err(unsafe_entry()); + } + let output = destination.join(link); + validate_tree_path(destination, &output)?; + if let Some(parent) = output.parent() { + ensure_cache_directory(destination, parent)?; + } + fs::copy(&target_path, &output).map_err(|source| LocalError::Io { + operation: "materialize archive symlink", + path: output.clone(), + source, + })?; + } Ok(()) } diff --git a/crates/promptforge-gateway/src/local/artifacts/tests.rs b/crates/promptforge-gateway/src/local/artifacts/tests.rs index 0aec5f1f..79ad02df 100644 --- a/crates/promptforge-gateway/src/local/artifacts/tests.rs +++ b/crates/promptforge-gateway/src/local/artifacts/tests.rs @@ -92,21 +92,24 @@ fn extract_zip_rejects_traversal_entry_and_cleans_up() { assert!(!dir.path().join("escape.txt").exists()); } -fn tar_gz_with_symlink() -> Vec { +/// Builds a tar.gz from `(entry_type, name, link_target, contents)` rows. +fn tar_gz_with_entries(entries: &[(tar::EntryType, &str, Option<&str>, &[u8])]) -> Vec { use flate2::Compression; use flate2::write::GzEncoder; let mut builder = tar::Builder::new(GzEncoder::new(Vec::new(), Compression::default())); - let mut header = tar::Header::new_gnu(); - header.set_entry_type(tar::EntryType::Symlink); - header.set_size(0); - header.set_mode(0o777); - header.set_path("evil-link").expect("set path"); - header.set_link_name("/etc/passwd").expect("set link"); - header.set_cksum(); - builder - .append(&header, io::empty()) - .expect("append symlink"); + for (entry_type, name, link, contents) in entries { + let mut header = tar::Header::new_gnu(); + header.set_entry_type(*entry_type); + header.set_size(contents.len() as u64); + header.set_mode(0o755); + header.set_path(name).expect("set path"); + if let Some(link) = link { + header.set_link_name(link).expect("set link"); + } + header.set_cksum(); + builder.append(&header, *contents).expect("append entry"); + } builder .into_inner() .expect("finish tar") @@ -115,12 +118,18 @@ fn tar_gz_with_symlink() -> Vec { } #[test] -fn extract_tar_gz_rejects_symlink_entries() { - // ART-007: a tar entry that is neither a regular file nor a directory (here - // a symlink) is rejected rather than materialized in the cache tree. +fn extract_tar_gz_rejects_symlink_with_absolute_target() { + // ART-007: a symlink entry whose target is an absolute path is rejected + // rather than materialized in the cache tree. let dir = TempDir::new().expect("tempdir"); let archive = dir.path().join("evil.tar.gz"); - std::fs::write(&archive, tar_gz_with_symlink()).expect("write archive"); + let entries: &[(tar::EntryType, &str, Option<&str>, &[u8])] = &[( + tar::EntryType::Symlink, + "evil-link", + Some("/etc/passwd"), + b"", + )]; + std::fs::write(&archive, tar_gz_with_entries(entries)).expect("write archive"); let dest = dir.path().join("out"); std::fs::create_dir(&dest).expect("mkdir dest"); let result = extract_archive(&archive, &dest, ArchiveKind::TarGz); @@ -128,6 +137,78 @@ fn extract_tar_gz_rejects_symlink_entries() { assert!(!dest.join("evil-link").exists()); } +#[test] +fn extract_tar_gz_materializes_symlink_chains_as_copies() { + // macOS llama.cpp archives ship dylibs behind versioned symlink chains, + // with the links stored before their targets. Each link lands as a + // regular-file copy of its final target so the extracted tree stays + // symlink-free (ART-006). + let dir = TempDir::new().expect("tempdir"); + let archive = dir.path().join("dylibs.tar.gz"); + let body: &[u8] = b"dylib-bytes"; + let entries: &[(tar::EntryType, &str, Option<&str>, &[u8])] = &[ + ( + tar::EntryType::Symlink, + "pkg/libggml.dylib", + Some("libggml.0.dylib"), + b"", + ), + ( + tar::EntryType::Symlink, + "pkg/libggml.0.dylib", + Some("libggml.0.17.0.dylib"), + b"", + ), + ( + tar::EntryType::Regular, + "pkg/libggml.0.17.0.dylib", + None, + body, + ), + ]; + std::fs::write(&archive, tar_gz_with_entries(entries)).expect("write archive"); + let dest = dir.path().join("out"); + std::fs::create_dir(&dest).expect("mkdir dest"); + extract_archive(&archive, &dest, ArchiveKind::TarGz).expect("extract"); + for name in ["libggml.dylib", "libggml.0.dylib", "libggml.0.17.0.dylib"] { + let path = dest.join("pkg").join(name); + let metadata = std::fs::symlink_metadata(&path).expect("metadata"); + assert!(metadata.is_file(), "{name} must be a regular file"); + assert_eq!(std::fs::read(&path).expect("read"), body, "{name}"); + } +} + +#[test] +fn extract_tar_gz_rejects_traversal_dangling_and_cyclic_symlinks() { + // ART-007: a symlink target that climbs out of the destination, one + // nothing in the archive provides, and a link cycle are each rejected. + let cases: &[&[(tar::EntryType, &str, Option<&str>, &[u8])]] = &[ + &[(tar::EntryType::Symlink, "pkg/evil", Some("../outside"), b"")], + &[( + tar::EntryType::Symlink, + "pkg/dangling", + Some("missing.bin"), + b"", + )], + &[ + (tar::EntryType::Symlink, "pkg/a", Some("b"), b""), + (tar::EntryType::Symlink, "pkg/b", Some("a"), b""), + ], + ]; + for entries in cases { + let dir = TempDir::new().expect("tempdir"); + let archive = dir.path().join("evil.tar.gz"); + std::fs::write(&archive, tar_gz_with_entries(entries)).expect("write archive"); + let dest = dir.path().join("out"); + std::fs::create_dir(&dest).expect("mkdir dest"); + let result = extract_archive(&archive, &dest, ArchiveKind::TarGz); + assert!( + matches!(result, Err(LocalError::UnsafeArchiveEntry { .. })), + "{entries:?} should be rejected, got {result:?}" + ); + } +} + fn tar_gz_entry(entry_type: tar::EntryType, name: &str, link: Option<&str>) -> Vec { use flate2::Compression; use flate2::write::GzEncoder; @@ -167,8 +248,8 @@ fn zip_symlink(name: &str, target: &str) -> Vec { #[test] fn extract_rejects_every_non_regular_entry_class() { // ART-007: table-driven rejection of each unsafe/unsupported archive entry - // class - tar symlink/hardlink/char/block/fifo and a zip symlink - so none - // is materialized in the cache tree. + // class - a tar symlink with an absolute target, hardlink/char/block/fifo, + // and a zip symlink - so none is materialized in the cache tree. let tar_cases: &[(tar::EntryType, Option<&str>)] = &[ (tar::EntryType::Symlink, Some("/etc/passwd")), (tar::EntryType::Link, Some("llama-server")), diff --git a/crates/promptforge-gateway/src/runner.rs b/crates/promptforge-gateway/src/runner.rs index df2cb441..3d36986b 100644 --- a/crates/promptforge-gateway/src/runner.rs +++ b/crates/promptforge-gateway/src/runner.rs @@ -1,21 +1,28 @@ -//! Application entry points: the [`run`] function and the assembled [`Gateway`]. +//! Application entry points: [`spawn`], [`run`], and the assembled [`Gateway`]. //! -//! `run` is the binary path: it loads configuration, provisions local children, -//! binds, and serves until Ctrl-C. `Gateway` is the in-process assembly seam -//! used by `run` and by integration tests, which bind their own listener and -//! drive [`Gateway::serve`] with a caller-owned shutdown signal. +//! [`spawn`] is the embedding seam: it loads configuration, provisions local +//! children, and serves on a dedicated thread with its own tokio runtime, so +//! an embedding binary keeps its main thread. The call blocks until the +//! listener is bound - that bind is the readiness signal - and the returned +//! [`GatewayHandle`] carries the bound URL and a graceful-shutdown switch. +//! [`run`] is the binary path: a thin wrapper that spawns, installs the +//! Ctrl-C handler, and joins. `Gateway` is the in-process assembly seam used +//! by both and by integration tests, which bind their own listener and drive +//! [`Gateway::serve`] with a caller-owned shutdown signal. use std::future::Future; use std::path::{Path, PathBuf}; -use std::sync::Arc; +use std::sync::{Arc, mpsc}; +use std::thread::JoinHandle; use tokio::net::TcpListener; -use promptforge_gateway_config::{Config, ConfigError, ProfileName, ServerConfig}; +use promptforge_gateway_config::{Config, ConfigError, ProfileName, ServerConfig, WorkshopConfig}; use crate::api_error::{ServeError, StartupError}; use crate::local::LocalRuntime; use crate::routing::Routing; +use crate::workshop::{self, WorkshopHandle}; use crate::{AppState, build_router}; /// Options for running the gateway. Built by the binary from parsed args. @@ -72,9 +79,10 @@ pub struct Gateway { impl Gateway { /// Assemble from a validated config. Provisions and starts local models. /// - /// The config's `[server]` is retained as the boot-owned server settings; - /// profile switches are checked against it (the socket and the gateway - /// bearer key are fixed for the process lifetime). + /// The config's `[server]` and `[workshop]` are retained as the + /// boot-owned settings; profile switches are checked against them (the + /// socket, the gateway bearer key, and the hosted workshop's settings + /// are fixed for the process lifetime). /// /// # Errors /// Returns [`StartupError`] when local provisioning or routing construction @@ -125,7 +133,10 @@ impl Gateway { name: profiles.active.map(|name| name.to_string()), model_allowlist: config.model_allowlist().map(<[String]>::to_vec), }, - config.server().clone(), + crate::BootOwned { + server: config.server().clone(), + workshop: config.workshop().cloned(), + }, ); Ok(Gateway { state }) } @@ -159,30 +170,362 @@ impl Gateway { } } +/// A running gateway on its own thread, returned by [`spawn`]. +/// +/// When the boot config carries a `[workshop]` section and the `workshop` +/// feature is compiled in, the handle also holds the hosted workshop +/// server, reachable at [`GatewayHandle::workshop_url`]. +/// +/// Dropping the handle without calling [`GatewayHandle::shutdown`] still +/// signals both servers to stop, but does not wait for them. +#[derive(Debug)] +pub struct GatewayHandle { + url: String, + workshop: Option, + shutdown: Option>, + thread: Option>>, + #[cfg(test)] + observer: Option>, +} + +/// One step in [`GatewayHandle::shutdown`]'s ordering, reported through +/// the [`GatewayHandle::observe_shutdown`] test seam. +#[cfg(test)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ShutdownStep { + /// The hosted workshop finished its bounded drain and is fully stopped. + WorkshopStopped, + /// The gateway's graceful-shutdown signal was sent. + GatewaySignaled, +} + +impl GatewayHandle { + /// Returns the base URL of the bound gateway address, for example + /// `http://127.0.0.1:8081`. + #[must_use] + pub fn url(&self) -> &str { + &self.url + } + + /// Returns the base URL of the hosted workshop listener, or `None` + /// when the gateway hosts no workshop (the `workshop` feature is not + /// compiled in, or the boot config has no `[workshop]` section). + #[must_use] + pub fn workshop_url(&self) -> Option<&str> { + self.workshop.as_ref().map(WorkshopHandle::url) + } + + /// Signals graceful shutdown and waits for the gateway thread to finish. + /// + /// A hosted workshop stops first - waiting out its own bounded drain - + /// while the gateway still serves, so the workshop's final gateway + /// calls never hit a dead socket; only then does the gateway drain. + /// + /// # Errors + /// Returns [`StartupError`] when serving failed or the gateway thread + /// panicked. + pub fn shutdown(mut self) -> Result<(), StartupError> { + if let Some(workshop) = self.workshop.take() { + workshop.shutdown(); + #[cfg(test)] + self.record(ShutdownStep::WorkshopStopped); + } + if let Some(shutdown) = self.shutdown.take() { + let _ = shutdown.send(()); + #[cfg(test)] + self.record(ShutdownStep::GatewaySignaled); + } + self.join_inner() + } + + /// Test seam: records the shutdown sequence, so a test can assert + /// that a hosted workshop is fully stopped before the gateway's own + /// shutdown is signaled. + #[cfg(test)] + pub(crate) fn observe_shutdown(&mut self, observer: std::sync::mpsc::Sender) { + self.observer = Some(observer); + } + + #[cfg(test)] + fn record(&self, step: ShutdownStep) { + if let Some(observer) = &self.observer { + let _ = observer.send(step); + } + } + + /// Waits for the gateway thread to finish on its own, without signaling + /// shutdown. + /// + /// # Errors + /// Returns [`StartupError`] when serving failed or the gateway thread + /// panicked. + pub fn join(mut self) -> Result<(), StartupError> { + self.join_inner() + } + + fn join_inner(&mut self) -> Result<(), StartupError> { + let Some(thread) = self.thread.take() else { + return Ok(()); + }; + match thread.join() { + Ok(result) => result, + Err(_) => Err(StartupError::serve(crate::api_error::ServeError::io( + std::io::Error::other("gateway thread panicked"), + ))), + } + } +} + +impl Drop for GatewayHandle { + fn drop(&mut self) { + // Same order as shutdown(): the workshop's stop is signaled before + // the gateway's, though drop waits for neither. + drop(self.workshop.take()); + if let Some(shutdown) = self.shutdown.take() { + let _ = shutdown.send(()); + } + } +} + +/// What the gateway thread reports through the readiness channel: the +/// bound gateway URL, plus the hosted workshop's handle when the boot +/// config asked for one. +#[derive(Debug)] +struct Ready { + url: String, + workshop: Option, +} + +/// Spawns the gateway on a dedicated thread and blocks until the listener +/// is bound. +/// +/// Config loading, provisioning, and binding all run on the gateway thread; +/// their failures are reported back through this call's return value. The +/// bound listener is the readiness signal: when this returns `Ok`, the +/// gateway is accepting connections at [`GatewayHandle::url`]. +/// +/// # Errors +/// Returns [`StartupError`] when config loading, provisioning, binding, or +/// starting the gateway thread fails; classify with [`StartupError::kind`]. +/// +/// # Examples +/// ```no_run +/// use promptforge_gateway::{ProfileName, ServeOptions, spawn}; +/// use std::path::PathBuf; +/// +/// let options = ServeOptions::new( +/// PathBuf::from("/etc/promptforge/gateway.toml"), +/// ProfileName::parse("dev").unwrap(), +/// ); +/// let gateway = spawn(&options)?; +/// println!("serving on {}", gateway.url()); +/// gateway.shutdown()?; +/// # Ok::<(), promptforge_gateway::StartupError>(()) +/// ``` +pub fn spawn(options: &ServeOptions) -> Result { + let (ready_tx, ready_rx) = mpsc::channel(); + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); + let options = options.clone(); + let thread = std::thread::Builder::new() + .name("promptforge-gateway".to_string()) + .spawn(move || serve_thread(&options, &ready_tx, shutdown_rx)) + .map_err(StartupError::thread)?; + match ready_rx.recv() { + Ok(Ok(ready)) => Ok(GatewayHandle { + url: ready.url, + workshop: ready.workshop, + shutdown: Some(shutdown_tx), + thread: Some(thread), + #[cfg(test)] + observer: None, + }), + Ok(Err(error)) => Err(failed_handshake(thread, Some(error))), + Err(_) => Err(failed_handshake(thread, None)), + } +} + +/// The error [`spawn`] returns after the readiness handshake fails. The +/// gateway thread is joined first, and a panic payload is downcast into +/// the error text: a panicked thread is the one way the readiness channel +/// closes with no message ([`serve_thread`] reports every early exit +/// through it), and a discarded join result would lose the message of the +/// very panic that broke the handshake. +fn failed_handshake( + thread: JoinHandle>, + reported: Option, +) -> StartupError { + match thread.join() { + Ok(_) => reported.unwrap_or_else(|| { + StartupError::thread(std::io::Error::other( + "gateway thread exited before binding without reporting an error", + )) + }), + Err(payload) => { + // `&payload` would unsize the Box itself into `dyn Any`, + // hiding the real payload type from the downcasts. + let panic = panic_message(&*payload); + let message = match &reported { + Some(error) => format!("{error}; the gateway thread then panicked: {panic}"), + None => format!("gateway thread panicked before binding: {panic}"), + }; + StartupError::thread(std::io::Error::other(message)) + } + } +} + +/// The message carried by a panic payload: the `panic!` string when there +/// is one, or a note that the payload is not a string (a `panic_any` +/// call), which carries no displayable message. +fn panic_message(payload: &(dyn std::any::Any + Send)) -> &str { + payload + .downcast_ref::<&str>() + .copied() + .or_else(|| payload.downcast_ref::().map(String::as_str)) + .unwrap_or("non-string panic payload") +} + +/// The gateway thread's body: load config, provision, build a runtime, bind, +/// signal readiness through `ready`, then serve until `shutdown` resolves. +/// +/// Startup failures are reported through `ready`; only serving failures +/// become the thread's return value. +fn serve_thread( + options: &ServeOptions, + ready: &mpsc::Sender>, + shutdown: tokio::sync::oneshot::Receiver<()>, +) -> Result<(), StartupError> { + let (config, profiles) = match load_startup(options) { + Ok(loaded) => loaded, + Err(error) => { + let _ = ready.send(Err(error)); + return Ok(()); + } + }; + let bind = config.bind_addr(); + let gateway = match Gateway::from_config(&config, profiles) { + Ok(gateway) => gateway, + Err(error) => { + let _ = ready.send(Err(error)); + return Ok(()); + } + }; + let runtime = match tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + { + Ok(runtime) => runtime, + Err(error) => { + let _ = ready.send(Err(StartupError::thread(error))); + return Ok(()); + } + }; + // The bind runs apart from the serve future so the workshop can start + // between the two: after the gateway listener exists (a failed gateway + // bind must not leave a workshop running) and on this plain thread, + // where the workshop's blocking startup stalls no executor. + let listener = match runtime.block_on(TcpListener::bind(bind)) { + Ok(listener) => listener, + Err(error) => { + let _ = ready.send(Err(StartupError::bind(error))); + return Ok(()); + } + }; + // The configured bind may carry port 0; the bound local_addr is the + // real address the readiness signal must report. + let address = match listener.local_addr() { + Ok(address) => address, + Err(error) => { + let _ = ready.send(Err(StartupError::bind(error))); + return Ok(()); + } + }; + let workshop = match workshop::spawn_if_configured(&config, &options.config_path, address) { + Ok(workshop) => workshop, + Err(error) => { + let _ = ready.send(Err(error)); + return Ok(()); + } + }; + tracing::info!("promptforge-gateway serving on {address}"); + let _ = ready.send(Ok(Ready { + url: format!("http://{address}"), + workshop, + })); + runtime + .block_on(gateway.serve(listener, shutdown_on_send(shutdown))) + .map_err(StartupError::serve) +} + +/// Resolves only on an explicit shutdown send. A sender dropped without +/// sending - the Ctrl-C handler thread or its runtime failed on the [`run`] +/// path - must keep the server up, never stop it. +async fn shutdown_on_send(shutdown: tokio::sync::oneshot::Receiver<()>) { + if shutdown.await.is_err() { + std::future::pending::<()>().await; + } +} + /// Load config, provision local children, bind, and serve until Ctrl-C. /// -/// Owns the tokio runtime; the binary stays a thin arg-parsing shell. +/// A thin wrapper over [`spawn`]: the gateway runs on its own thread, a +/// Ctrl-C handler signals its graceful shutdown, and this call blocks until +/// serving ends. The binary stays a thin arg-parsing shell. /// /// # Errors /// Returns [`StartupError`] when config loading, provisioning, binding, or /// serving fails; classify with [`StartupError::kind`]. pub fn run(options: &ServeOptions) -> Result<(), StartupError> { - let (config, profiles) = load_startup(options)?; - let bind = config.bind_addr(); - let gateway = Gateway::from_config(&config, profiles)?; + let mut handle = spawn(options)?; + if let Some(shutdown) = handle.shutdown.take() { + install_ctrl_c_handler(handle.workshop.take(), shutdown); + } + handle.join() +} - let runtime = tokio::runtime::Builder::new_multi_thread() - .enable_all() - .build() - .map_err(StartupError::bind)?; - runtime.block_on(async move { - let listener = TcpListener::bind(bind).await.map_err(StartupError::bind)?; - tracing::info!("promptforge-gateway serving on {bind}"); - gateway - .serve(listener, shutdown_signal()) - .await - .map_err(StartupError::serve) - }) +/// Installs the Ctrl-C handler on its own thread: a genuine interrupt stops +/// a hosted workshop first (bounded by the workshop's own drain watchdog) +/// and then sends the gateway's graceful-shutdown signal, while every +/// failure path sends nothing - [`shutdown_signal`] never resolves on a +/// handler-install failure, and a thread or runtime that fails to start +/// merely drops the sender, which [`shutdown_on_send`] ignores - so no +/// failure can masquerade as an interrupt and stop the gateway. +fn install_ctrl_c_handler( + workshop: Option, + shutdown: tokio::sync::oneshot::Sender<()>, +) { + let handler = std::thread::Builder::new() + .name("gateway-ctrl-c".to_string()) + .spawn(move || { + let runtime = match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(runtime) => runtime, + Err(error) => { + tracing::error!( + "failed to build the Ctrl-C signal runtime: {error}; continuing to serve" + ); + // Leak the workshop handle: dropping it would signal a + // workshop stop, and a signal-runtime failure must leave + // both listeners serving until the process is killed. + std::mem::forget(workshop); + return; + } + }; + runtime.block_on(shutdown_signal()); + if let Some(workshop) = workshop { + workshop.shutdown(); + } + let _ = shutdown.send(()); + }); + if let Err(error) = handler { + // The dropped closure signals a hosted workshop's stop, so only the + // gateway listener is certain to continue. + tracing::error!( + "failed to spawn the Ctrl-C handler thread: {error}; the gateway continues to serve \ + (a hosted workshop may stop)" + ); + } } /// What awaiting the Ctrl-C signal produced. @@ -256,15 +599,79 @@ pub(crate) fn check_server_matches_boot( Ok(()) } +/// The boot-only `[workshop]` rule: like `[server]`, the section lives in +/// the boot config, and a profile's merged `[workshop]` must equal the boot +/// file's as values - present with equal settings, or absent on both sides. +/// The hosted workshop is started once at boot, so a switch can never move, +/// reconfigure, or remove it mid-run. +pub(crate) fn check_workshop_matches_boot( + boot: Option<&WorkshopConfig>, + candidate: Option<&WorkshopConfig>, + profile: &ProfileName, +) -> Result<(), ConfigError> { + match (boot, candidate) { + (None, None) => Ok(()), + (Some(boot), Some(candidate)) if boot == candidate => Ok(()), + (Some(boot), Some(candidate)) => Err(ConfigError::validation(format!( + "profile {profile} [workshop] mismatch: {} ([workshop] is boot-only)", + first_workshop_difference(boot, candidate) + ))), + (None, Some(_)) => Err(ConfigError::validation(format!( + "profile {profile} carries a [workshop] section but the boot file has none \ + ([workshop] is boot-only)" + ))), + (Some(_), None) => Err(ConfigError::validation(format!( + "profile {profile} lacks the boot file's [workshop] section ([workshop] is \ + boot-only; include the boot file or replicate the section)" + ))), + } +} + +/// Names the first `[workshop]` field that differs between the boot file +/// and the profile, with both values, mirroring +/// [`check_server_matches_boot`]. Only called when `boot != candidate`, +/// so the fallback is unreachable until the config grows a field this +/// check does not name yet. +fn first_workshop_difference(boot: &WorkshopConfig, candidate: &WorkshopConfig) -> String { + if candidate.bind() != boot.bind() { + format!( + "bind mismatch: profile has {}, boot file has {}", + candidate.bind(), + boot.bind() + ) + } else if candidate.open_browser() != boot.open_browser() { + format!( + "open_browser mismatch: profile sets {}, boot file sets {}", + candidate.open_browser(), + boot.open_browser() + ) + } else if candidate.voice() != boot.voice() { + format!( + "voice mismatch: profile has {:?}, boot file has {:?}", + candidate.voice(), + boot.voice() + ) + } else if candidate.tape() != boot.tape() { + format!( + "tape mismatch: profile has {:?}, boot file has {:?}", + candidate.tape(), + boot.tape() + ) + } else { + "the settings differ in a field this check does not name yet".to_string() + } +} + /// Boot into the named profile and build the admin profiles context. /// /// Order matters: the two env files load first (the profile's `.env`, /// then the boot file's sibling env file; dotenvy never overrides, so the /// earlier file wins and both lose to the process environment), then the /// profile resolves with its include chain, then the boot file's `[server]` -/// is extracted and compared. The resolved chain is logged, with a warning -/// when the boot file is not in it (the likely-mistake case: an operator -/// edits the boot file and nothing changes). +/// and `[workshop]` sections are extracted in one pass and compared. The +/// resolved chain is logged, with a warning when the boot file is not in it +/// (the likely-mistake case: an operator edits the boot file and nothing +/// changes). fn load_startup(options: &ServeOptions) -> Result<(Config, ProfilesContext), StartupError> { let profiles_dir = profiles_dir_for(&options.config_path); @@ -292,10 +699,13 @@ fn load_startup(options: &ServeOptions) -> Result<(Config, ProfilesContext), Sta let (config, chain) = Config::load_profile_with_chain(&profiles_dir, &options.profile) .map_err(StartupError::config)?; - let boot_server = promptforge_gateway_config::load_server(&options.config_path) - .map_err(StartupError::config)?; + let (boot_server, boot_workshop) = + promptforge_gateway_config::load_boot_sections(&options.config_path) + .map_err(StartupError::config)?; check_server_matches_boot(&boot_server, config.server(), &options.profile) .map_err(StartupError::config)?; + check_workshop_matches_boot(boot_workshop.as_ref(), config.workshop(), &options.profile) + .map_err(StartupError::config)?; let rendered = chain .iter() @@ -344,9 +754,11 @@ mod tests { use std::path::{Path, PathBuf}; use super::{ - ServeOptions, ShutdownTrigger, check_server_matches_boot, classify_shutdown, load_startup, - profiles_dir_for, + ServeOptions, ShutdownStep, ShutdownTrigger, check_server_matches_boot, + check_workshop_matches_boot, classify_shutdown, failed_handshake, load_startup, + panic_message, profiles_dir_for, shutdown_on_send, spawn, }; + use crate::api_error::{StartupError, StartupErrorKind}; use promptforge_gateway_config::{Config, ProfileName}; #[test] @@ -560,4 +972,444 @@ endpoints = ["e"] let profile = ProfileName::parse("p").unwrap(); check_server_matches_boot(&boot, &same, &profile).unwrap(); } + + /// Parses the catalog plus `extra` and returns its `[workshop]` section. + fn workshop_of(extra: &str) -> Option { + Config::from_toml_str(&format!("{CATALOG}{extra}")) + .unwrap() + .workshop() + .cloned() + } + + #[test] + fn workshop_check_accepts_equal_or_absent_sections() { + let profile = ProfileName::parse("p").unwrap(); + check_workshop_matches_boot(None, None, &profile).unwrap(); + + let boot = workshop_of("[workshop]\nbind = \"127.0.0.1:7910\"\n"); + let same = workshop_of("[workshop]\nbind = \"127.0.0.1:7910\"\n"); + check_workshop_matches_boot(boot.as_ref(), same.as_ref(), &profile).unwrap(); + } + + #[test] + fn workshop_check_rejects_a_differing_or_one_sided_section() { + let profile = ProfileName::parse("p").unwrap(); + let boot = workshop_of("[workshop]\nbind = \"127.0.0.1:7910\"\n"); + let changed = workshop_of("[workshop]\nbind = \"127.0.0.1:7911\"\n"); + + let differing = + check_workshop_matches_boot(boot.as_ref(), changed.as_ref(), &profile).unwrap_err(); + assert!( + differing.to_string().contains("[workshop] mismatch"), + "got: {differing}" + ); + + let added = check_workshop_matches_boot(None, boot.as_ref(), &profile).unwrap_err(); + assert!( + added.to_string().contains("boot file has none"), + "got: {added}" + ); + + let dropped = check_workshop_matches_boot(boot.as_ref(), None, &profile).unwrap_err(); + assert!( + dropped.to_string().contains("lacks the boot file's"), + "got: {dropped}" + ); + } + + #[test] + fn workshop_mismatch_names_the_first_differing_field() { + let profile = ProfileName::parse("p").unwrap(); + let boot = workshop_of("[workshop]\nbind = \"127.0.0.1:7910\"\n"); + + let changed_bind = workshop_of("[workshop]\nbind = \"127.0.0.1:7911\"\n"); + let error = check_workshop_matches_boot(boot.as_ref(), changed_bind.as_ref(), &profile) + .unwrap_err(); + let text = error.to_string(); + assert!(text.contains("bind mismatch"), "got: {text}"); + assert!(text.contains("127.0.0.1:7911"), "profile value: {text}"); + assert!(text.contains("127.0.0.1:7910"), "boot value: {text}"); + + let changed_open = + workshop_of("[workshop]\nbind = \"127.0.0.1:7910\"\nopen_browser = true\n"); + let error = check_workshop_matches_boot(boot.as_ref(), changed_open.as_ref(), &profile) + .unwrap_err(); + assert!( + error.to_string().contains("open_browser mismatch"), + "got: {error}" + ); + + let changed_voice = workshop_of( + "[workshop]\nbind = \"127.0.0.1:7910\"\n\n[workshop.voice]\nwindow_seconds = 8\n", + ); + let error = check_workshop_matches_boot(boot.as_ref(), changed_voice.as_ref(), &profile) + .unwrap_err(); + assert!(error.to_string().contains("voice mismatch"), "got: {error}"); + + let changed_tape = workshop_of( + "[workshop]\nbind = \"127.0.0.1:7910\"\n\n[workshop.tape]\npath = \"other.jsonl\"\n", + ); + let error = check_workshop_matches_boot(boot.as_ref(), changed_tape.as_ref(), &profile) + .unwrap_err(); + assert!(error.to_string().contains("tape mismatch"), "got: {error}"); + } + + #[test] + fn boot_accepts_a_profile_inheriting_the_boot_workshop() { + let tmp = tempfile::TempDir::new().unwrap(); + write( + tmp.path(), + "gateway.toml", + &format!("{CATALOG}[workshop]\nbind = \"127.0.0.1:7910\"\n"), + ); + let profiles = tmp.path().join("profiles"); + std::fs::create_dir(&profiles).unwrap(); + write(&profiles, "main.toml", "include = [\"../gateway.toml\"]\n"); + + let options = ServeOptions::new( + tmp.path().join("gateway.toml"), + ProfileName::parse("main").unwrap(), + ); + let (config, _context) = load_startup(&options).unwrap(); + assert_eq!( + config + .workshop() + .map(|workshop| workshop.bind().to_string()), + Some("127.0.0.1:7910".to_string()) + ); + } + + #[test] + fn boot_refuses_a_profile_with_a_differing_workshop() { + let tmp = tempfile::TempDir::new().unwrap(); + write( + tmp.path(), + "gateway.toml", + &format!("{CATALOG}[workshop]\nbind = \"127.0.0.1:7910\"\n"), + ); + let profiles = tmp.path().join("profiles"); + std::fs::create_dir(&profiles).unwrap(); + write( + &profiles, + "main.toml", + "include = [\"../gateway.toml\"]\n\n[workshop]\nbind = \"127.0.0.1:7911\"\n", + ); + + let options = ServeOptions::new( + tmp.path().join("gateway.toml"), + ProfileName::parse("main").unwrap(), + ); + let error = load_startup(&options).unwrap_err(); + let text = error_text(&error); + assert!(text.contains("[workshop] mismatch"), "got: {text}"); + } + + /// A boot catalog on an ephemeral port, so spawn tests never collide. + const CATALOG_EPHEMERAL: &str = r#" +[server] +bind = "127.0.0.1:0" +api_key = "boot-key" + +[[endpoint]] +id = "e" +protocol = "openai" +base_url = "http://127.0.0.1:9" +api_key = "" + +[[model]] +name = "m" +description = "prose" +context = 1 +upstream = "u" +endpoints = ["e"] +"#; + + /// A tempdir with the given boot catalog and a `main` profile that + /// includes it, plus the spawn options that boot into it. + fn spawn_fixture(catalog: &str) -> (tempfile::TempDir, ServeOptions) { + let tmp = tempfile::TempDir::new().unwrap(); + write(tmp.path(), "gateway.toml", catalog); + let profiles = tmp.path().join("profiles"); + std::fs::create_dir(&profiles).unwrap(); + write(&profiles, "main.toml", "include = [\"../gateway.toml\"]\n"); + let options = ServeOptions::new( + tmp.path().join("gateway.toml"), + ProfileName::parse("main").unwrap(), + ); + (tmp, options) + } + + /// A raw HTTP/1.1 GET against `url`, returning the full response text. + /// Keeps an HTTP client out of the crate's dev-dependencies. + fn http_get(url: &str, path: &str) -> String { + use std::io::{Read as _, Write as _}; + + let address = url.strip_prefix("http://").expect("the URL is http"); + let mut stream = std::net::TcpStream::connect(address).expect("the gateway accepts"); + write!( + stream, + "GET {path} HTTP/1.1\r\nHost: gateway\r\nConnection: close\r\n\r\n" + ) + .expect("the request sends"); + let mut response = String::new(); + stream + .read_to_string(&mut response) + .expect("the response reads"); + response + } + + #[test] + fn spawn_readiness_means_the_health_endpoint_answers() { + let (_tmp, options) = spawn_fixture(CATALOG_EPHEMERAL); + let gateway = spawn(&options).expect("gateway spawns"); + assert!( + gateway.url().starts_with("http://127.0.0.1:"), + "the URL carries the bound loopback address: {}", + gateway.url() + ); + + let response = http_get(gateway.url(), "/health"); + assert!(response.starts_with("HTTP/1.1 200"), "got: {response}"); + assert!( + response.contains(r#""status":"serving""#), + "got: {response}" + ); + + gateway.shutdown().expect("graceful shutdown succeeds"); + } + + #[test] + fn shutdown_stops_serving_and_releases_the_port() { + let (_tmp, options) = spawn_fixture(CATALOG_EPHEMERAL); + let gateway = spawn(&options).expect("gateway spawns"); + let address = gateway + .url() + .strip_prefix("http://") + .expect("the URL is http") + .to_string(); + + gateway.shutdown().expect("graceful shutdown succeeds"); + + assert!( + std::net::TcpStream::connect(&address).is_err(), + "nothing may accept on {address} after shutdown" + ); + drop(std::net::TcpListener::bind(&address).expect("the port is free after shutdown")); + } + + #[test] + fn dropping_the_handle_signals_shutdown() { + let (_tmp, options) = spawn_fixture(CATALOG_EPHEMERAL); + let gateway = spawn(&options).expect("gateway spawns"); + let address = gateway + .url() + .strip_prefix("http://") + .expect("the URL is http") + .to_string(); + + drop(gateway); + + // Drop signals shutdown but does not wait, so poll with a deadline. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + while std::net::TcpStream::connect(&address).is_ok() { + assert!( + std::time::Instant::now() < deadline, + "the gateway still accepts on {address} after the handle dropped" + ); + std::thread::sleep(std::time::Duration::from_millis(20)); + } + } + + #[test] + fn a_dropped_shutdown_sender_never_resolves_the_serve_future() { + use futures_util::FutureExt as _; + + let (sender, receiver) = tokio::sync::oneshot::channel::<()>(); + drop(sender); + assert!( + shutdown_on_send(receiver).now_or_never().is_none(), + "a sender dropped without sending (a failed Ctrl-C handler) must not stop the server" + ); + } + + #[test] + fn an_explicit_shutdown_send_resolves_the_serve_future() { + use futures_util::FutureExt as _; + + let (sender, receiver) = tokio::sync::oneshot::channel::<()>(); + sender.send(()).expect("the receiver is alive"); + assert!( + shutdown_on_send(receiver).now_or_never().is_some(), + "an explicit send is the shutdown signal" + ); + } + + /// The ephemeral catalog plus a `[workshop]` section on its own + /// ephemeral loopback port. + fn catalog_with_workshop() -> String { + format!("{CATALOG_EPHEMERAL}\n[workshop]\nbind = \"127.0.0.1:0\"\n") + } + + #[test] + #[cfg(feature = "workshop")] + fn the_hosted_workshop_serves_and_stops_with_the_gateway() { + let (_tmp, options) = spawn_fixture(&catalog_with_workshop()); + let gateway = spawn(&options).expect("gateway spawns"); + let workshop_url = gateway + .workshop_url() + .expect("a [workshop] boot hosts a workshop") + .to_string(); + assert!( + workshop_url.starts_with("http://127.0.0.1:"), + "the workshop URL carries the bound loopback address: {workshop_url}" + ); + + let response = http_get(&workshop_url, "/health"); + assert!(response.starts_with("HTTP/1.1 200"), "got: {response}"); + + let strip = |url: &str| { + url.strip_prefix("http://") + .expect("the URL is http") + .to_string() + }; + let gateway_address = strip(gateway.url()); + let workshop_address = strip(&workshop_url); + gateway.shutdown().expect("graceful shutdown succeeds"); + assert!( + std::net::TcpStream::connect(&workshop_address).is_err(), + "nothing may accept on the workshop address {workshop_address} after shutdown" + ); + assert!( + std::net::TcpStream::connect(&gateway_address).is_err(), + "nothing may accept on the gateway address {gateway_address} after shutdown" + ); + } + + #[test] + fn shutdown_without_a_workshop_signals_the_gateway_only() { + let (_tmp, options) = spawn_fixture(CATALOG_EPHEMERAL); + let mut gateway = spawn(&options).expect("gateway spawns"); + let (tx, rx) = std::sync::mpsc::channel(); + gateway.observe_shutdown(tx); + gateway.shutdown().expect("graceful shutdown succeeds"); + let steps: Vec = rx.try_iter().collect(); + assert_eq!( + steps, + [ShutdownStep::GatewaySignaled], + "no workshop is hosted, so only the gateway signal is recorded" + ); + } + + #[test] + #[cfg(feature = "workshop")] + fn shutdown_drains_the_workshop_before_signaling_the_gateway() { + let (_tmp, options) = spawn_fixture(&catalog_with_workshop()); + let mut gateway = spawn(&options).expect("gateway spawns"); + assert!( + gateway.workshop_url().is_some(), + "a [workshop] boot hosts a workshop" + ); + + let (tx, rx) = std::sync::mpsc::channel(); + gateway.observe_shutdown(tx); + gateway.shutdown().expect("graceful shutdown succeeds"); + + // Both steps are recorded synchronously inside shutdown(), before + // it returns, so there is nothing to wait for. + let steps: Vec = rx.try_iter().collect(); + assert_eq!( + steps, + [ShutdownStep::WorkshopStopped, ShutdownStep::GatewaySignaled], + "the workshop's drain completes before the gateway's shutdown is signaled" + ); + } + + #[test] + #[cfg(not(feature = "workshop"))] + fn a_workshop_section_is_ignored_without_the_feature() { + let (_tmp, options) = spawn_fixture(&catalog_with_workshop()); + let gateway = spawn(&options).expect("gateway spawns without hosting"); + assert!( + gateway.workshop_url().is_none(), + "no workshop is hosted without the workshop feature" + ); + gateway.shutdown().expect("graceful shutdown succeeds"); + } + + #[test] + fn spawn_returns_config_errors_through_the_handshake() { + let (_tmp, config_path) = boot_fixture(); + let options = ServeOptions::new(config_path, ProfileName::parse("ghost").unwrap()); + let error = spawn(&options).expect_err("an unknown profile must fail spawn"); + assert_eq!(error.kind(), StartupErrorKind::Config); + } + + #[test] + fn a_bind_conflict_fails_spawn_with_bind_kind() { + let blocker = std::net::TcpListener::bind("127.0.0.1:0").expect("bind blocker"); + let address = blocker.local_addr().expect("blocker address"); + let catalog = CATALOG_EPHEMERAL.replace("127.0.0.1:0", &address.to_string()); + let (_tmp, options) = spawn_fixture(&catalog); + let error = spawn(&options).expect_err("a taken port must fail spawn"); + assert_eq!(error.kind(), StartupErrorKind::Bind); + } + + #[test] + fn a_panicked_gateway_thread_folds_its_panic_message_into_the_error() { + let thread = std::thread::Builder::new() + .name("gateway-panic-fixture".to_string()) + .spawn(|| -> Result<(), StartupError> { + panic!("the gateway thread lost its config"); + }) + .expect("the fixture thread spawns"); + let error = failed_handshake(thread, None); + assert_eq!(error.kind(), StartupErrorKind::Thread); + let text = error_text(&error); + assert!( + text.contains("the gateway thread lost its config"), + "the panic message survives the join: {text}" + ); + assert!( + !text.contains("failed to bind the listener"), + "a pre-bind panic is not misnamed a bind failure: {text}" + ); + } + + #[test] + fn a_silent_thread_exit_is_thread_kind_not_bind_kind() { + let thread = std::thread::Builder::new() + .name("gateway-exit-fixture".to_string()) + .spawn(|| -> Result<(), StartupError> { Ok(()) }) + .expect("the fixture thread spawns"); + let error = failed_handshake(thread, None); + assert_eq!(error.kind(), StartupErrorKind::Thread); + let text = error_text(&error); + assert!(text.contains("exited before binding"), "got: {text}"); + } + + #[test] + fn a_reported_handshake_error_survives_the_join_unchanged() { + let thread = std::thread::Builder::new() + .name("gateway-report-fixture".to_string()) + .spawn(|| -> Result<(), StartupError> { Ok(()) }) + .expect("the fixture thread spawns"); + let reported = StartupError::bind(std::io::Error::other("port taken")); + let error = failed_handshake(thread, Some(reported)); + assert_eq!( + error.kind(), + StartupErrorKind::Bind, + "the handshake's own error stays primary when the thread exits cleanly" + ); + } + + #[test] + fn panic_message_reads_each_payload_shape() { + assert_eq!(panic_message(&"borrowed"), "borrowed"); + assert_eq!(panic_message(&String::from("owned")), "owned"); + assert_eq!( + panic_message(&42_u64), + "non-string panic payload", + "a panic_any payload carries no displayable message" + ); + } } diff --git a/crates/promptforge-gateway/src/workshop.rs b/crates/promptforge-gateway/src/workshop.rs new file mode 100644 index 00000000..e18b764d --- /dev/null +++ b/crates/promptforge-gateway/src/workshop.rs @@ -0,0 +1,440 @@ +//! The hosted workshop: the workshop UI server on a second, loopback-only +//! listener in the gateway process, compiled in behind the `workshop` +//! feature and started when the boot config carries a `[workshop]` section. +//! +//! The workshop reaches the gateway through its own HTTP client over +//! loopback: the client URL and bearer key are derived from the boot +//! `[server]` section, so no credential is duplicated in `[workshop]` and +//! none can drift. Without the feature, this module is a stub whose +//! [`spawn_if_configured`] hosts nothing and whose [`WorkshopHandle`] is +//! never constructed. + +#[cfg(feature = "workshop")] +mod hosted { + use std::net::SocketAddr; + use std::path::Path; + + use promptforge_gateway_config::{ + Config, ConfigError, ServerConfig, WorkshopConfig, WorkshopVoiceConfig, + }; + + use crate::api_error::StartupError; + + /// A running hosted workshop server, held by [`crate::GatewayHandle`]. + #[derive(Debug)] + pub(crate) struct WorkshopHandle { + inner: promptforge_ws_server::ServerHandle, + } + + impl WorkshopHandle { + /// The base URL of the workshop listener. + pub(crate) fn url(&self) -> &str { + self.inner.url() + } + + /// Stops the workshop and waits for it, bounded by the workshop's + /// own drain watchdog. Stop outcomes are logged rather than + /// returned: they are not actionable by the caller and must never + /// preempt the gateway's own shutdown, which runs next. + pub(crate) fn shutdown(self) { + let url = self.inner.url().to_string(); + match self.inner.shutdown() { + Ok(promptforge_ws_server::Termination::Graceful) => { + tracing::info!("workshop at {url} stopped gracefully"); + } + Ok(promptforge_ws_server::Termination::Forced) => { + tracing::warn!("workshop at {url} was forced down after its drain window"); + } + // Termination is non-exhaustive; a future ending is still a + // stop worth one line. + Ok(termination) => { + tracing::info!("workshop at {url} stopped ({termination:?})"); + } + Err(error) => { + tracing::warn!("workshop at {url} stopped with an error: {error}"); + } + } + } + } + + /// Spawns the workshop server when the boot config carries a + /// `[workshop]` section; `bound` is the gateway listener's address, + /// already bound. Logs the workshop URL and, when `open_browser` is + /// set, opens the system browser at it (the headless-server-with-UI + /// frame; the desktop shell drives its own window instead). + /// + /// # Errors + /// Returns a config-kind [`StartupError`] when the workshop bind is not + /// a loopback address, and a workshop-kind one when the server itself + /// fails to start (a bad tape path, a taken port). + pub(crate) fn spawn_if_configured( + config: &Config, + config_path: &Path, + bound: SocketAddr, + ) -> Result, StartupError> { + spawn_with_opener(config, config_path, bound, |url| open::that(url)) + } + + /// The testable core of [`spawn_if_configured`], with the browser + /// opener injected: production opens the system browser, tests record + /// the URL instead of opening a real one. + fn spawn_with_opener( + config: &Config, + config_path: &Path, + bound: SocketAddr, + open_url: impl FnOnce(&str) -> std::io::Result<()>, + ) -> Result, StartupError> { + let Some(workshop) = config.workshop() else { + return Ok(None); + }; + // The workshop UI writes workspace files and drives profile + // switches, so its listener stays loopback-only; only the gateway's + // own listener may bind wider. + if !workshop.bind().ip().is_loopback() { + return Err(StartupError::config(ConfigError::validation(format!( + "[workshop] bind {} is not a loopback address; the workshop listener is loopback-only", + workshop.bind() + )))); + } + // Both routers expose /health and /v1/models. With two listeners the + // duplication is harmless - each port answers with its own - but it + // is the known blocker for the documented future option of nesting + // the workshop under a path on the gateway listener. + let handle = + promptforge_ws_server::spawn(ws_config(config.server(), workshop, config_path, bound)) + .map_err(StartupError::workshop)?; + tracing::info!("workshop serving on {}", handle.url()); + if workshop.open_browser() { + // A browser that will not open is not worth failing a serving + // process over; the URL is logged above either way. + if let Err(error) = open_url(handle.url()) { + tracing::warn!( + "could not open the system browser at {}: {error}", + handle.url() + ); + } + } + Ok(Some(WorkshopHandle { inner: handle })) + } + + /// Builds the workshop server's config from the gateway's boot config: + /// the gateway client derived from `[server]`, the tape path anchored + /// to the boot config's directory, and `[workshop]`'s own listener and + /// voice settings mirrored across. + fn ws_config( + server: &ServerConfig, + workshop: &WorkshopConfig, + config_path: &Path, + bound: SocketAddr, + ) -> promptforge_ws_server::Config { + let boot_dir = config_path.parent().unwrap_or(Path::new(".")); + promptforge_ws_server::Config { + gateway: promptforge_ws_server::GatewayConfig { + base_url: client_url(server, bound), + api_key: server.api_key().expose().to_string(), + }, + tape: promptforge_ws_server::TapeConfig { + path: workshop.tape_path(boot_dir), + }, + server: promptforge_ws_server::ServerConfig { + bind: workshop.bind().to_string(), + open_browser: workshop.open_browser(), + }, + voice: workshop + .voice() + .map_or_else(promptforge_ws_server::VoiceConfig::default, voice_config), + } + } + + /// The gateway URL the workshop's client dials: the boot `[server]`'s + /// loopback-adjusted client URL. A port-0 bind is ephemeral - the + /// derived URL would name the undialable port 0 - so the actually + /// bound port is swapped in. + fn client_url(server: &ServerConfig, bound: SocketAddr) -> String { + let url = server.client_url(); + if server.bind().port() != 0 { + return url; + } + // client_url() always renders as http://:, so the last + // colon separates the port; the None arm cannot occur. + match url.rsplit_once(':') { + Some((head, _)) => format!("{head}:{}", bound.port()), + None => url, + } + } + + /// Mirrors `[workshop.voice]` onto the workshop server's own voice + /// settings, field for field. + fn voice_config(voice: &WorkshopVoiceConfig) -> promptforge_ws_server::VoiceConfig { + promptforge_ws_server::VoiceConfig { + interim_model: voice.interim_model().to_path_buf(), + final_model: voice.final_model().to_path_buf(), + interim_source: voice.interim_source().to_string(), + final_source: voice.final_source().to_string(), + window_seconds: voice.window_seconds(), + interval_ms: voice.interval_ms(), + vocabulary: voice.vocabulary().to_vec(), + } + } + + #[cfg(test)] + mod tests { + use std::net::SocketAddr; + use std::path::Path; + + use super::{client_url, spawn_if_configured, spawn_with_opener, ws_config}; + use crate::api_error::StartupErrorKind; + use promptforge_gateway_config::Config; + + fn config(toml: &str) -> Config { + Config::from_toml_str(toml).expect("fixture parses") + } + + fn bound(address: &str) -> SocketAddr { + address.parse().expect("fixture address parses") + } + + #[test] + fn ws_config_derives_the_client_and_anchors_the_tape() { + let config = config( + r#" +[server] +bind = "0.0.0.0:8081" +api_key = "boot-key" + +[workshop] +bind = "127.0.0.1:7911" +open_browser = true + +[workshop.tape] +path = "tapes/session.jsonl" + +[workshop.voice] +interim_model = "models/tiny.bin" +final_model = "models/small.bin" +interim_source = "https://example.com/tiny.bin" +final_source = "https://example.com/small.bin" +window_seconds = 8 +interval_ms = 250 +vocabulary = ["MCP", "GGUF"] +"#, + ); + let workshop = config.workshop().expect("workshop section present"); + let ws = ws_config( + config.server(), + workshop, + Path::new("/etc/pf/gateway.toml"), + bound("0.0.0.0:8081"), + ); + assert_eq!( + ws.gateway.base_url, "http://127.0.0.1:8081", + "an unspecified gateway bind derives a loopback client URL" + ); + assert_eq!( + ws.gateway.api_key, "boot-key", + "the workshop reuses the gateway bearer key" + ); + assert_eq!( + ws.tape.path, + Path::new("/etc/pf").join("tapes").join("session.jsonl"), + "a relative tape path anchors to the boot config's directory" + ); + assert_eq!(ws.server.bind, "127.0.0.1:7911"); + assert!(ws.server.open_browser); + assert_eq!(ws.voice.interim_model, Path::new("models/tiny.bin")); + assert_eq!(ws.voice.final_model, Path::new("models/small.bin")); + assert_eq!(ws.voice.interim_source, "https://example.com/tiny.bin"); + assert_eq!(ws.voice.final_source, "https://example.com/small.bin"); + assert_eq!(ws.voice.window_seconds, 8); + assert_eq!(ws.voice.interval_ms, 250); + assert_eq!(ws.voice.vocabulary, ["MCP", "GGUF"]); + } + + #[test] + fn an_absent_voice_section_maps_to_the_workshop_defaults() { + let config = + config("[server]\nbind = \"127.0.0.1:8081\"\napi_key = \"k\"\n\n[workshop]\n"); + let workshop = config.workshop().expect("workshop section present"); + let ws = ws_config( + config.server(), + workshop, + Path::new("gateway.toml"), + bound("127.0.0.1:8081"), + ); + assert_eq!(ws.voice, promptforge_ws_server::VoiceConfig::default()); + assert_eq!( + ws.tape.path, + Path::new("").join("tape.jsonl"), + "an absent tape section anchors the default filename to the boot dir" + ); + } + + #[test] + fn the_client_url_swaps_a_port_zero_bind_for_the_bound_port() { + let ephemeral = config("[server]\nbind = \"127.0.0.1:0\"\napi_key = \"k\"\n"); + assert_eq!( + client_url(ephemeral.server(), bound("127.0.0.1:49321")), + "http://127.0.0.1:49321", + "a port-0 bind is undialable; the bound port replaces it" + ); + + let fixed = config("[server]\nbind = \"0.0.0.0:8081\"\napi_key = \"k\"\n"); + assert_eq!( + client_url(fixed.server(), bound("0.0.0.0:8081")), + "http://127.0.0.1:8081", + "a fixed bind keeps the config-derived URL" + ); + } + + #[test] + fn a_non_loopback_workshop_bind_is_refused() { + let config = config( + "[server]\nbind = \"127.0.0.1:8081\"\napi_key = \"k\"\n\n[workshop]\nbind = \"0.0.0.0:7910\"\n", + ); + let error = + spawn_if_configured(&config, Path::new("gateway.toml"), bound("127.0.0.1:8081")) + .expect_err("a non-loopback workshop bind must fail"); + assert_eq!(error.kind(), StartupErrorKind::Config); + } + + #[test] + fn no_workshop_section_hosts_nothing() { + let config = config("[server]\nbind = \"127.0.0.1:8081\"\napi_key = \"k\"\n"); + let hosted = + spawn_if_configured(&config, Path::new("gateway.toml"), bound("127.0.0.1:8081")) + .expect("no workshop section is not an error"); + assert!(hosted.is_none()); + } + + /// An ephemeral workshop config; `open_browser` as given. The + /// tempdir anchors the tape path outside the source tree. + fn opener_fixture( + tmp: &tempfile::TempDir, + open_browser: &str, + ) -> (Config, std::path::PathBuf) { + let config = config(&format!( + "[server]\nbind = \"127.0.0.1:0\"\napi_key = \"k\"\n\n[workshop]\nbind = \"127.0.0.1:0\"\n{open_browser}" + )); + (config, tmp.path().join("gateway.toml")) + } + + #[test] + fn the_open_browser_honor_opens_the_workshop_url() { + let tmp = tempfile::TempDir::new().expect("tempdir"); + let (config, config_path) = opener_fixture(&tmp, "open_browser = true\n"); + let (tx, rx) = std::sync::mpsc::channel(); + let hosted = + spawn_with_opener(&config, &config_path, bound("127.0.0.1:0"), move |url| { + tx.send(url.to_string()).expect("the receiver is alive"); + Ok(()) + }) + .expect("the workshop spawns") + .expect("a [workshop] section hosts a workshop"); + let opened = rx.recv().expect("the opener runs before spawn returns"); + assert_eq!(opened, hosted.url(), "the opener gets the workshop URL"); + hosted.shutdown(); + } + + #[test] + fn the_opener_never_runs_without_the_open_browser_honor() { + let tmp = tempfile::TempDir::new().expect("tempdir"); + let (config, config_path) = opener_fixture(&tmp, ""); + let (tx, rx) = std::sync::mpsc::channel::(); + let hosted = + spawn_with_opener(&config, &config_path, bound("127.0.0.1:0"), move |url| { + tx.send(url.to_string()).expect("the receiver is alive"); + Ok(()) + }) + .expect("the workshop spawns") + .expect("a [workshop] section hosts a workshop"); + // spawn_with_opener is synchronous: an opener call would have + // landed in the channel before it returned. + assert!( + rx.try_recv().is_err(), + "the opener runs only when open_browser is set" + ); + hosted.shutdown(); + } + + #[test] + fn a_failing_opener_does_not_fail_the_spawn() { + let tmp = tempfile::TempDir::new().expect("tempdir"); + let (config, config_path) = opener_fixture(&tmp, "open_browser = true\n"); + let hosted = spawn_with_opener(&config, &config_path, bound("127.0.0.1:0"), |_| { + Err(std::io::Error::other("no display")) + }) + .expect("a browser that will not open is not a startup failure") + .expect("a [workshop] section hosts a workshop"); + hosted.shutdown(); + } + } +} + +#[cfg(feature = "workshop")] +pub(crate) use hosted::{WorkshopHandle, spawn_if_configured}; + +#[cfg(not(feature = "workshop"))] +mod absent { + use std::net::SocketAddr; + use std::path::Path; + + use promptforge_gateway_config::Config; + + use crate::api_error::StartupError; + + /// The never-constructed stand-in for the hosted workshop's handle, so + /// the runner stays feature-blind. + #[derive(Debug)] + pub(crate) struct WorkshopHandle; + + /// The real handle signals its server on drop; the stand-in carries the + /// same `Drop`-ness so the runner's feature-blind `drop` and `forget` + /// calls mean the same thing under both builds. + impl Drop for WorkshopHandle { + fn drop(&mut self) {} + } + + impl WorkshopHandle { + /// The base URL of the workshop listener. + #[expect( + clippy::unused_self, + reason = "the signature mirrors the workshop-feature variant" + )] + pub(crate) fn url(&self) -> &str { + unreachable!("a WorkshopHandle is never constructed without the workshop feature") + } + + /// Stops the workshop. + #[expect( + clippy::unused_self, + reason = "the signature mirrors the workshop-feature variant" + )] + pub(crate) fn shutdown(self) { + unreachable!("a WorkshopHandle is never constructed without the workshop feature") + } + } + + /// Hosts nothing: the `workshop` feature is not compiled in. A boot + /// config that carries `[workshop]` anyway gets a warning, not an + /// error, because the section is legal input for every build. + #[expect( + clippy::unnecessary_wraps, + reason = "the signature mirrors the workshop-feature variant" + )] + pub(crate) fn spawn_if_configured( + config: &Config, + _config_path: &Path, + _bound: SocketAddr, + ) -> Result, StartupError> { + if config.workshop().is_some() { + tracing::warn!( + "the boot config carries a [workshop] section, but this gateway was built \ + without the workshop feature; no workshop is hosted" + ); + } + Ok(None) + } +} + +#[cfg(not(feature = "workshop"))] +pub(crate) use absent::{WorkshopHandle, spawn_if_configured}; diff --git a/crates/promptforge-gateway/tests/it/cache.rs b/crates/promptforge-gateway/tests/it/cache.rs index 1e7a59aa..5742b46c 100644 --- a/crates/promptforge-gateway/tests/it/cache.rs +++ b/crates/promptforge-gateway/tests/it/cache.rs @@ -19,7 +19,7 @@ use serde_json::Value; use sha2::{Digest, Sha256}; use tempfile::TempDir; -use crate::support::{PHASE_TIMEOUT, TestServer, json_within, send_within, spawn_backend}; +use crate::support::{TestServer, json_within, parse_sse, send_within, spawn_backend, text_within}; /// Starts the gateway with `[local].cache_dir` rooted at `cache_dir`. /// @@ -169,17 +169,6 @@ async fn fake_file_server(body: &[u8]) -> (SocketAddr, Arc) { (spawn_backend(router).await, state) } -/// Parses an SSE body into its `data:` JSON payloads. -fn parse_sse(body: &str) -> Vec { - body.split("\n\n") - .filter(|chunk| !chunk.trim().is_empty()) - .map(|chunk| { - let data = chunk.trim().strip_prefix("data: ").expect("data prefix"); - serde_json::from_str(data).expect("json event") - }) - .collect() -} - /// Every regular file under `root`, recursively. fn all_files(root: &Path) -> Vec { let mut files = Vec::new(); @@ -218,10 +207,7 @@ async fn post_cache_streams_progress_then_ready_and_caches_the_blob() { response.headers().get(CONTENT_TYPE).unwrap(), "text/event-stream" ); - let text = tokio::time::timeout(PHASE_TIMEOUT, response.text()) - .await - .expect("SSE body exceeded the phase timeout") - .expect("SSE body read failed"); + let text = text_within(response).await; let events = parse_sse(&text); assert!( events.len() >= 2, @@ -298,10 +284,7 @@ async fn post_cache_digest_mismatch_streams_an_error_event() { response.headers().get(CONTENT_TYPE).unwrap(), "text/event-stream" ); - let text = tokio::time::timeout(PHASE_TIMEOUT, response.text()) - .await - .expect("SSE body exceeded the phase timeout") - .expect("SSE body read failed"); + let text = text_within(response).await; let events = parse_sse(&text); let terminal = events.last().expect("a terminal event"); assert_eq!(terminal["status"], "error"); diff --git a/crates/promptforge-gateway/tests/it/profiles.rs b/crates/promptforge-gateway/tests/it/profiles.rs index c11a18cd..627ea69e 100644 --- a/crates/promptforge-gateway/tests/it/profiles.rs +++ b/crates/promptforge-gateway/tests/it/profiles.rs @@ -5,14 +5,15 @@ use std::fs; use promptforge_gateway::{Config, Gateway, ProfileName, ProfilesContext}; use serde_json::Value; -use crate::support::{TestServer, catalog_ids, fake_backend, json_within, send_within}; +use crate::support::{ + TestServer, catalog_ids, fake_backend, json_within, parse_sse, send_within, text_within, +}; -/// Switch-profile rebuilds the catalog from a remote-only profile (no llama spawn). -#[tokio::test] -async fn switch_profile_updates_models_catalog() { +/// Two remote-only profiles, `alpha` (alpha-model) and `beta` (beta-model), +/// with a gateway started on alpha; the tempdir is the profiles directory. +async fn alpha_beta_server() -> (tempfile::TempDir, TestServer) { let backend = fake_backend().await; let profiles = tempfile::tempdir().unwrap(); - let profile_toml = |model: &str, context: u32| { format!( r#" @@ -49,8 +50,38 @@ endpoints = ["fake"] let alpha = ProfileName::parse("alpha").unwrap(); let config = Config::load_profile(profiles.path(), &alpha).unwrap(); let context = ProfilesContext::new(Some(profiles.path().to_path_buf()), Some(alpha)); - let gateway = Gateway::from_config(&config, context).unwrap(); - let server = TestServer::start(gateway).await; + let server = TestServer::start(Gateway::from_config(&config, context).unwrap()).await; + (profiles, server) +} + +/// Posts a switch for `name` and returns the parsed SSE events, asserting +/// the stream handshake: an accepted switch is 200 `text/event-stream`. +async fn switch_stream_events( + http: &reqwest::Client, + addr: std::net::SocketAddr, + name: &str, +) -> Vec { + let response = send_within( + http.post(format!("http://{addr}/admin/switch-profile")) + .bearer_auth("test-token") + .json(&serde_json::json!({ "name": name })), + ) + .await; + assert_eq!(response.status().as_u16(), 200); + assert_eq!( + response + .headers() + .get("content-type") + .and_then(|value| value.to_str().ok()), + Some("text/event-stream") + ); + parse_sse(&text_within(response).await) +} + +/// Switch-profile rebuilds the catalog from a remote-only profile (no llama spawn). +#[tokio::test] +async fn switch_profile_updates_models_catalog() { + let (_profiles, server) = alpha_beta_server().await; let http = reqwest::Client::new(); let ids = catalog_ids(&http, server.addr).await; @@ -66,13 +97,11 @@ endpoints = ["fake"] .await; assert_eq!(listed["profiles"], serde_json::json!(["alpha", "beta"])); - let switched = send_within( - http.post(format!("http://{}/admin/switch-profile", server.addr)) - .bearer_auth("test-token") - .json(&serde_json::json!({ "name": "beta" })), - ) - .await; - assert_eq!(switched.status().as_u16(), 200); + let events = switch_stream_events(&http, server.addr, "beta").await; + assert_eq!( + events.last(), + Some(&serde_json::json!({ "status": "ready", "profile": "beta" })) + ); let ids = catalog_ids(&http, server.addr).await; assert_eq!(ids, vec!["beta-model"]); @@ -90,18 +119,71 @@ endpoints = ["fake"] server.shutdown().await; } +/// The switch stream carries its stage markers in execution order - +/// loading-profile, then stopping-models, then starting-models - and ends +/// with the terminal ready event naming the new profile. No drain stage +/// exists because the gateway does not drain. +#[tokio::test] +async fn switch_profile_streams_stages_in_order_then_ready() { + let (_profiles, server) = alpha_beta_server().await; + let http = reqwest::Client::new(); + + let events = switch_stream_events(&http, server.addr, "beta").await; + assert_eq!( + events, + vec![ + serde_json::json!({ "stage": "loading-profile" }), + serde_json::json!({ "stage": "stopping-models" }), + serde_json::json!({ "stage": "starting-models" }), + serde_json::json!({ "status": "ready", "profile": "beta" }), + ] + ); + server.shutdown().await; +} + /// A failed switch (missing profile) leaves the live profile fully intact: -/// same catalog, same working bearer key (LIB-009 stable credential), and a -/// stable machine code on the 404 (IT-008). +/// same catalog, same working bearer key (LIB-009 stable credential). The +/// failure arrives as the stream's terminal error event, after only the +/// loading-profile stage - no child was stopped or started. #[tokio::test] async fn failed_switch_leaves_live_profile_intact() { + let (_profiles, server) = alpha_beta_server().await; + let http = reqwest::Client::new(); + + // Switch to a profile that does not exist: a terminal error event, no + // state change. + let events = switch_stream_events(&http, server.addr, "ghost").await; + assert_eq!( + events.first(), + Some(&serde_json::json!({ "stage": "loading-profile" })) + ); + let terminal = events.last().expect("a terminal event"); + assert_eq!(terminal["status"], "error"); + assert_eq!( + terminal["message"].as_str().expect("message"), + "profile not found: ghost" + ); + assert_eq!(events.len(), 2, "no stopping or starting stage: {events:?}"); + + // The original catalog and bearer key still work unchanged. + assert_eq!(catalog_ids(&http, server.addr).await, vec!["alpha-model"]); + server.shutdown().await; +} + +/// The boot file owns `[server]`: a profile whose merged `[server]` differs +/// is rejected at switch time - a terminal error event on the stream - +/// leaving the live profile fully intact. +#[tokio::test] +async fn switch_profile_with_mismatched_server_section_fails() { let backend = fake_backend().await; let profiles = tempfile::tempdir().unwrap(); - let alpha_toml = format!( - r#" + + let profile_toml = |model: &str, key: &str| { + format!( + r#" [server] bind = "127.0.0.1:0" -api_key = "test-token" +api_key = "{key}" [[endpoint]] id = "fake" @@ -110,14 +192,24 @@ base_url = "http://{backend}" api_key = "" [[model]] -name = "alpha-model" -description = "alpha catalog entry" +name = "{model}" +description = "{model} catalog entry" context = 8192 upstream = "backend-model" endpoints = ["fake"] "# - ); - fs::write(profiles.path().join("alpha.toml"), alpha_toml).unwrap(); + ) + }; + fs::write( + profiles.path().join("alpha.toml"), + profile_toml("alpha-model", "test-token"), + ) + .unwrap(); + fs::write( + profiles.path().join("beta.toml"), + profile_toml("beta-model", "other-token"), + ) + .unwrap(); let alpha = ProfileName::parse("alpha").unwrap(); let config = Config::load_profile(profiles.path(), &alpha).unwrap(); @@ -125,38 +217,40 @@ endpoints = ["fake"] let server = TestServer::start(Gateway::from_config(&config, context).unwrap()).await; let http = reqwest::Client::new(); - // Switch to a profile that does not exist: expect 404, no state change. - let missing = send_within( - http.post(format!("http://{}/admin/switch-profile", server.addr)) - .bearer_auth("test-token") - .json(&serde_json::json!({ "name": "ghost" })), - ) - .await; - assert_eq!(missing.status().as_u16(), 404); - let body = json_within(missing).await; - assert_eq!( - body.pointer("/error/code").and_then(Value::as_str), - Some("profile_not_found") + let events = switch_stream_events(&http, server.addr, "beta").await; + let terminal = events.last().expect("a terminal event"); + assert_eq!(terminal["status"], "error"); + assert!( + terminal["message"] + .as_str() + .expect("message") + .contains("switch profile failed at server-mismatch"), + "terminal event: {terminal}" ); - // The original catalog and bearer key still work unchanged. + // The live profile is untouched: same catalog, same working bearer key. assert_eq!(catalog_ids(&http, server.addr).await, vec!["alpha-model"]); server.shutdown().await; } -/// The boot file owns `[server]`: a profile whose merged `[server]` differs -/// is rejected at switch time, leaving the live profile fully intact. +/// The boot config owns `[workshop]`: a profile whose merged `[workshop]` +/// differs is rejected at switch time - a terminal error event on the +/// stream - leaving the live profile fully intact. The hosted workshop is +/// started once at boot, so a switch can never move or reconfigure it. #[tokio::test] -async fn switch_profile_with_mismatched_server_section_fails() { +async fn switch_profile_with_mismatched_workshop_section_fails() { let backend = fake_backend().await; let profiles = tempfile::tempdir().unwrap(); - let profile_toml = |model: &str, key: &str| { + let profile_toml = |model: &str, workshop_bind: &str| { format!( r#" [server] bind = "127.0.0.1:0" -api_key = "{key}" +api_key = "test-token" + +[workshop] +bind = "{workshop_bind}" [[endpoint]] id = "fake" @@ -175,12 +269,12 @@ endpoints = ["fake"] }; fs::write( profiles.path().join("alpha.toml"), - profile_toml("alpha-model", "test-token"), + profile_toml("alpha-model", "127.0.0.1:7910"), ) .unwrap(); fs::write( profiles.path().join("beta.toml"), - profile_toml("beta-model", "other-token"), + profile_toml("beta-model", "127.0.0.1:7911"), ) .unwrap(); @@ -190,17 +284,15 @@ endpoints = ["fake"] let server = TestServer::start(Gateway::from_config(&config, context).unwrap()).await; let http = reqwest::Client::new(); - let response = send_within( - http.post(format!("http://{}/admin/switch-profile", server.addr)) - .bearer_auth("test-token") - .json(&serde_json::json!({ "name": "beta" })), - ) - .await; - assert_eq!(response.status().as_u16(), 400); - let body = json_within(response).await; - assert_eq!( - body.pointer("/error/code").and_then(Value::as_str), - Some("switch_failed") + let events = switch_stream_events(&http, server.addr, "beta").await; + let terminal = events.last().expect("a terminal event"); + assert_eq!(terminal["status"], "error"); + assert!( + terminal["message"] + .as_str() + .expect("message") + .contains("switch profile failed at workshop-mismatch"), + "terminal event: {terminal}" ); // The live profile is untouched: same catalog, same working bearer key. @@ -310,13 +402,11 @@ async fn switch_profile_changes_the_loaded_set() { assert_eq!(status["profile"], "alpha"); assert_eq!(status["model_allowlist"], serde_json::json!(["remote-a"])); - let switched = send_within( - http.post(format!("http://{}/admin/switch-profile", server.addr)) - .bearer_auth("test-token") - .json(&serde_json::json!({ "name": "beta" })), - ) - .await; - assert_eq!(switched.status().as_u16(), 200); + let events = switch_stream_events(&http, server.addr, "beta").await; + assert_eq!( + events.last(), + Some(&serde_json::json!({ "status": "ready", "profile": "beta" })) + ); let ids = catalog_ids(&http, server.addr).await; assert_eq!(ids, vec!["remote-b"]); @@ -355,19 +445,24 @@ async fn switch_profile_runs_vram_check_on_the_new_loaded_set() { let http = reqwest::Client::new(); // gamma selects both local models, over-booking gpu0 (14 + 14 > 24): the - // switch fails at config load, before any llama-server child starts. - let response = send_within( - http.post(format!("http://{}/admin/switch-profile", server.addr)) - .bearer_auth("test-token") - .json(&serde_json::json!({ "name": "gamma" })), - ) - .await; - assert_eq!(response.status().as_u16(), 400); - let body = json_within(response).await; + // switch fails at config load - after the loading-profile stage but + // before any stopping or starting stage, so no llama-server child was + // touched - and the stream ends with a terminal error event. + let events = switch_stream_events(&http, server.addr, "gamma").await; assert_eq!( - body.pointer("/error/code").and_then(Value::as_str), - Some("switch_failed") + events.first(), + Some(&serde_json::json!({ "stage": "loading-profile" })) + ); + let terminal = events.last().expect("a terminal event"); + assert_eq!(terminal["status"], "error"); + assert!( + terminal["message"] + .as_str() + .expect("message") + .contains("switch profile failed at load-profile"), + "terminal event: {terminal}" ); + assert_eq!(events.len(), 2, "no stopping or starting stage: {events:?}"); // The live profile is untouched. assert_eq!(catalog_ids(&http, server.addr).await, vec!["remote-a"]); diff --git a/crates/promptforge-gateway/tests/it/support.rs b/crates/promptforge-gateway/tests/it/support.rs index e23ae2a1..e2e8bfef 100644 --- a/crates/promptforge-gateway/tests/it/support.rs +++ b/crates/promptforge-gateway/tests/it/support.rs @@ -111,6 +111,26 @@ pub(crate) async fn json_within(response: reqwest::Response) -> Value { .expect("HTTP body was not valid JSON") } +/// Reads a full text body bounded by [`PHASE_TIMEOUT`] (IT-003), for SSE +/// responses whose stream ends when the work behind them completes. +pub(crate) async fn text_within(response: reqwest::Response) -> String { + tokio::time::timeout(PHASE_TIMEOUT, response.text()) + .await + .expect("SSE body exceeded the phase timeout") + .expect("SSE body read failed") +} + +/// Parses an SSE body into its `data:` JSON payloads. +pub(crate) fn parse_sse(body: &str) -> Vec { + body.split("\n\n") + .filter(|chunk| !chunk.trim().is_empty()) + .map(|chunk| { + let data = chunk.trim().strip_prefix("data: ").expect("data prefix"); + serde_json::from_str(data).expect("json event") + }) + .collect() +} + /// Joins a spawned task bounded by [`PHASE_TIMEOUT`] (IT-003). pub(crate) async fn join_within(handle: JoinHandle) -> T { tokio::time::timeout(PHASE_TIMEOUT, handle) diff --git a/crates/promptforge-gateway/user-guide-promptforge-gateway.md b/crates/promptforge-gateway/user-guide-promptforge-gateway.md index 8a13723e..47554b33 100644 --- a/crates/promptforge-gateway/user-guide-promptforge-gateway.md +++ b/crates/promptforge-gateway/user-guide-promptforge-gateway.md @@ -568,7 +568,7 @@ When `sha256` is set, the downloaded file is verified against the digest. ### The blob cache API -Three bearer-authenticated routes let a client (the workbench, for example) download arbitrary blobs into the same cache on demand: +Three bearer-authenticated routes let a client (the workshop, for example) download arbitrary blobs into the same cache on demand: - `POST /v1/cache` with `{"source": "", "sha256": ""}` ensures the blob is cached. A cache hit answers immediately with `200` JSON `{"path": "...", "status": "ready"}`. A miss answers with `200` `text/event-stream`: `data: {"status":"downloading","bytes":N,"total":N}` progress events (`total` is `null` when the server sent no Content-Length), terminated by `data: {"status":"ready","path":"..."}` or, on failure, `data: {"status":"error","message":"..."}`. A mid-stream failure is an SSE event, not an HTTP error, because the response is already committed. - `GET /v1/cache` returns `200` JSON `[{"source", "path", "sha256", "size_bytes"}, ...]` sorted by source. diff --git a/crates/promptforge-wb-server/Cargo.toml b/crates/promptforge-wb-server/Cargo.toml deleted file mode 100644 index 801d50f1..00000000 --- a/crates/promptforge-wb-server/Cargo.toml +++ /dev/null @@ -1,46 +0,0 @@ -[package] -name = "promptforge-wb-server" -version = "0.1.0" -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true -publish = false - -description = "PromptForge Workbench HTTP server: serves the workbench API to the desktop shell" - -[[bin]] -name = "promptforge-wb-server" -path = "src/main.rs" - -[dependencies] -anyhow.workspace = true -axum.workspace = true -futures-util.workspace = true -open.workspace = true -reqwest.workspace = true -rust-embed.workspace = true -serde.workspace = true -serde_json.workspace = true -socket2.workspace = true -thiserror.workspace = true -time.workspace = true -tokio.workspace = true -toml.workspace = true -tracing.workspace = true -tracing-subscriber.workspace = true -whisper-rs.workspace = true - -[features] -default = [] -cuda = ["whisper-rs/cuda"] - -[dev-dependencies] -hound.workspace = true -tempfile.workspace = true -time = { workspace = true, features = ["parsing"] } -tokio-tungstenite.workspace = true -tower.workspace = true - -[lints] -workspace = true diff --git a/crates/promptforge-wb-server/src/app.rs b/crates/promptforge-wb-server/src/app.rs deleted file mode 100644 index 7c2694bd..00000000 --- a/crates/promptforge-wb-server/src/app.rs +++ /dev/null @@ -1,1068 +0,0 @@ -//! The axum router, handlers, and shared state for the workbench server. - -use std::sync::Arc; -use std::time::{Duration, Instant}; - -use axum::Router; -use axum::extract::State; -use axum::http::header; -use axum::response::{IntoResponse, Response}; -use axum::routing::{get, post}; - -use crate::catalog::CatalogBus; -use crate::chat_ws; -use crate::config::{Config, VoiceConfig}; -use crate::gateway::{ChatRequest, GatewayClient, GatewayError, GatewayResponse}; -use crate::heartbeat::GatewayHealth; -use crate::status::{Activity, StatusBus}; -use crate::tape::{Tape, TapeError, TapeEvent}; -use crate::transcribe::{TranscribeError, VoiceEngine, VoiceSlot}; -use crate::voice; - -/// Address the server binds to when no override is given. -pub const DEFAULT_ADDR: &str = "127.0.0.1:7910"; - -/// Shared handler state: the authenticated gateway client, the session -/// tape, the status bus, and the voice transcription engine slot, filled -/// at startup from local model files or later by the provisioning task. -#[derive(Debug, Clone)] -pub struct AppState { - gateway: GatewayClient, - tape: Arc, - voice: VoiceSlot, - status: StatusBus, - health: GatewayHealth, - catalog: CatalogBus, -} - -impl AppState { - /// Builds shared state from the loaded configuration. - /// - /// When `[voice]` names an interim model whose file exists, the engine - /// loads here. A configured model that is missing or unloadable never - /// fails startup: when the model has a source URL, activation defers to - /// the provisioning task (which fetches it through the gateway cache); - /// otherwise voice degrades to disabled with a status-bar explanation. - /// - /// # Errors - /// Returns [`AppError::Gateway`] if the HTTP client cannot be built and - /// [`AppError::Tape`] if the session tape cannot be opened. - pub fn new(config: &Config) -> Result { - let status = StatusBus::new(); - // Startup phases are reported as they run; with no client connected - // yet these land on an empty bus, ready for the first session. - status.info( - "Connecting to gateway", - format!("base URL {}", config.gateway.base_url), - Activity::General, - ); - let gateway = GatewayClient::new(&config.gateway.base_url, &config.gateway.api_key) - .map_err(AppError::Gateway)?; - let tape = Tape::open(&config.tape.path).map_err(AppError::Tape)?; - let voice = VoiceSlot::default(); - if let Some(engine) = startup_engine(&config.voice, &status) { - voice.activate(engine); - } - status.idle(); - Ok(Self { - gateway, - tape: Arc::new(tape), - voice, - status, - health: GatewayHealth::new(), - catalog: CatalogBus::new(), - }) - } - - /// The voice transcription engine, when one has loaded. - pub(crate) fn voice_engine(&self) -> Option> { - self.voice.engine() - } - - /// The voice engine slot, shared with the provisioning task, which - /// fills it once the gateway cache has provided the models. - pub(crate) fn voice_slot(&self) -> VoiceSlot { - self.voice.clone() - } - - /// The status bus, shared with every subsystem that reports what it is - /// doing. - pub(crate) fn status(&self) -> StatusBus { - self.status.clone() - } - - /// The gateway client, shared with the chat WebSocket sessions. - pub(crate) fn gateway_client(&self) -> &GatewayClient { - &self.gateway - } - - /// The session tape, shared with the chat WebSocket sessions. - pub(crate) fn tape(&self) -> &Arc { - &self.tape - } - - /// Shared gateway reachability, published by the heartbeat; the - /// gateway-dependent routes read it to short-circuit while the gateway - /// is down. - pub(crate) fn health(&self) -> &GatewayHealth { - &self.health - } - - /// The catalog bus, which the heartbeat publishes the refreshed model - /// catalog to on a gateway reconnect and every `/ws` session forwards - /// from. - pub(crate) fn catalog(&self) -> CatalogBus { - self.catalog.clone() - } -} - -/// A shared-state construction failure. -#[derive(Debug, thiserror::Error)] -#[non_exhaustive] -pub enum AppError { - /// The gateway HTTP client could not be built. - #[non_exhaustive] - #[error("build gateway client")] - Gateway(#[source] GatewayError), - - /// The session tape could not be opened. - #[non_exhaustive] - #[error("open session tape")] - Tape(#[source] TapeError), -} - -/// Builds the startup voice engine from local model files only. -/// -/// Returns `None` when voice is unconfigured, when a missing model has a -/// source URL (the provisioning task fetches and activates it once the -/// gateway answers), or when voice has degraded to disabled with a -/// status-bar explanation. Never fails: a bad model path or invalid -/// `[voice]` tuning costs voice, not startup. -pub(crate) fn startup_engine(config: &VoiceConfig, status: &StatusBus) -> Option { - if !config.enabled() { - return None; - } - status.info( - "Loading whisper model", - "the interim transcription model", - Activity::General, - ); - match VoiceEngine::new(config) { - Ok(engine) => Some(engine), - Err(error) => degrade(config, status, &error), - } -} - -/// Maps a startup engine-load failure to its degraded outcome: defer to -/// the provisioning task when the failed model has a source URL, drop an -/// unsourced final pass and run interim-only, or disable voice with an -/// explanation when the interim model can neither load nor be fetched. -fn degrade( - config: &VoiceConfig, - status: &StatusBus, - error: &TranscribeError, -) -> Option { - if let TranscribeError::LoadModel { path, .. } = error { - let sourced = (path == &config.interim_model && !config.interim_source.is_empty()) - || (path == &config.final_model && !config.final_source.is_empty()); - if sourced { - // The bus is empty at startup and idle() follows, so the - // verdict also goes to the log, where it survives. - tracing::warn!(%error, "voice models not downloaded; deferring to provisioning"); - status.info( - "Voice models not downloaded", - format!("{error}; the gateway cache provides them once connected"), - Activity::General, - ); - return None; - } - if path == &config.final_model { - // The final pass is optional: an unsourced missing final model - // drops to interim-only rather than costing voice entirely. - let mut interim_only = config.clone(); - interim_only.final_model = std::path::PathBuf::new(); - return match VoiceEngine::new(&interim_only) { - Ok(engine) => { - tracing::warn!(%error, "voice final pass unavailable; running interim-only"); - status.info( - "Voice final pass unavailable", - format!("{error}; takes close with the interim model"), - Activity::General, - ); - Some(engine) - } - Err(interim_error) => { - tracing::warn!(error = %interim_error, "voice disabled at startup"); - status.error( - "Voice disabled", - interim_error.to_string(), - Activity::General, - ); - None - } - }; - } - } - tracing::warn!(%error, "voice disabled at startup"); - status.error("Voice disabled", error.to_string(), Activity::General); - None -} - -/// Returns the workbench server router with every route mounted. -pub fn router(state: AppState) -> Router { - Router::new() - .route("/", get(ui_index)) - .route("/app.js", get(ui_app_js)) - .route("/app.css", get(ui_app_css)) - .route("/style.css", get(ui_style_css)) - .route("/pcm-worklet.js", get(ui_pcm_worklet)) - .route("/health", get(health)) - .route("/v1/models", get(models)) - .route("/chat", post(chat)) - .route("/ws", get(chat_ws::upgrade)) - .route("/voice", get(voice::upgrade)) - .with_state(state) -} - -/// Answers the health probe with a static JSON body. -async fn health() -> impl IntoResponse { - ( - [(header::CONTENT_TYPE, "application/json")], - r#"{"status":"serving"}"#, - ) -} - -/// The workbench UI assets under `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. -#[derive(rust_embed::Embed)] -#[folder = "ui/dist/"] -struct UiAssets; - -/// Serves one UI asset from [`UiAssets`] with the given content type. -fn ui_asset(path: &str, content_type: &'static str) -> Response { - match UiAssets::get(path) { - Some(asset) => ( - [(header::CONTENT_TYPE, content_type)], - asset.data.into_owned(), - ) - .into_response(), - None => ( - axum::http::StatusCode::NOT_FOUND, - [(header::CONTENT_TYPE, "text/plain; charset=utf-8")], - format!("ui asset not found: {path}"), - ) - .into_response(), - } -} - -/// Serves the chat UI's `index.html`. -async fn ui_index() -> Response { - ui_asset("index.html", "text/html; charset=utf-8") -} - -/// Serves the chat UI's bundled application script. -async fn ui_app_js() -> Response { - ui_asset("app.js", "text/javascript; charset=utf-8") -} - -/// Serves the stylesheet esbuild extracts from the bundle's CSS imports -/// (the vendored murm-ui and dockview styles). -async fn ui_app_css() -> Response { - ui_asset("app.css", "text/css; charset=utf-8") -} - -/// Serves the chat UI's own stylesheet. -async fn ui_style_css() -> Response { - ui_asset("style.css", "text/css; charset=utf-8") -} - -/// Serves the AudioWorklet PCM capture processor. -async fn ui_pcm_worklet() -> Response { - ui_asset("pcm-worklet.js", "text/javascript; charset=utf-8") -} - -/// 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. -async fn models(State(state): State) -> Response { - if !state.health().is_reachable() { - return gateway_unreachable(); - } - let status = state.status(); - status.info( - "Loading models...", - "fetching the gateway model catalog", - Activity::General, - ); - let result = state.gateway.list_models().await; - report_gateway_outcome(&status, &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( - status: &StatusBus, - result: &Result, - route: &str, -) { - match result { - Ok(upstream) if upstream.status.is_success() => status.idle(), - Ok(upstream) => status.error( - format!("Gateway error: {}", upstream.status), - format!("{route} answered a non-success status"), - Activity::General, - ), - Err(error) => status.error("Connection lost", error.to_string(), Activity::General), - } -} - -/// Forwards a buffered chat completion to the gateway, tapes the -/// round-trip, and relays the reply verbatim. -/// -/// A completed round-trip is recorded on the session tape; a tape failure is -/// logged and never changes the response. Streaming moved to `GET /ws`: a -/// request carrying `"stream": true` is rejected with 400. -async fn chat(State(state): State, body: String) -> Response { - let request_value: serde_json::Value = match serde_json::from_str(&body) { - Ok(value) => value, - Err(error) => return bad_request(&error), - }; - if request_value - .get("stream") - .and_then(serde_json::Value::as_bool) - == Some(true) - { - return stream_unsupported(); - } - let request: ChatRequest = match serde_json::from_value(request_value.clone()) { - Ok(request) => request, - Err(error) => return bad_request(&error), - }; - // A gateway the heartbeat knows is down is not attempted, matching the - // /ws chat short-circuit. - if !state.health().is_reachable() { - return gateway_unreachable(); - } - let status = state.status(); - status.info( - "Submitting request...", - "a buffered chat completion", - Activity::General, - ); - status.info( - "Waiting for response...", - "the gateway has the request", - Activity::General, - ); - let started = Instant::now(); - let result = state.gateway.chat_completion(&request).await; - report_gateway_outcome(&status, &result, "POST /v1/chat/completions"); - let latency = started.elapsed(); - if let Ok(upstream) = &result { - let response_value = value_from_bytes(&upstream.body); - tape_round_trip( - &state.tape, - request.model, - request_value, - response_value, - latency, - ) - .await; - } - relay(result) -} - -/// Renders the 502 envelope for a gateway the heartbeat knows is down: the -/// request is not attempted, and the message is user-visible. -fn gateway_unreachable() -> Response { - ( - axum::http::StatusCode::BAD_GATEWAY, - [(header::CONTENT_TYPE, "application/json")], - serde_json::json!({ - "error": { - "message": "Gateway unreachable", - "code": "gateway_unreachable", - } - }) - .to_string(), - ) - .into_response() -} - -/// Renders the 400 envelope for a chat request that asked for a stream. -fn stream_unsupported() -> Response { - ( - axum::http::StatusCode::BAD_REQUEST, - [(header::CONTENT_TYPE, "application/json")], - serde_json::json!({ - "error": { - "message": "streaming moved to GET /ws; POST /chat is buffered only", - "code": "stream_unsupported", - } - }) - .to_string(), - ) - .into_response() -} - -/// 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())) -} - -/// Records one chat round-trip on the session tape. -/// -/// A tape failure is logged and never changes the response. -pub(crate) async fn tape_round_trip( - tape: &Arc, - model: String, - request: serde_json::Value, - response: serde_json::Value, - latency: Duration, -) { - let written = { - let tape = Arc::clone(tape); - tokio::task::spawn_blocking(move || { - let event = TapeEvent::chat(model, request, response, latency)?; - tape.record(&event) - }) - .await - }; - match written { - Ok(Ok(())) => {} - Ok(Err(error)) => tracing::error!(%error, "session tape event was not recorded"), - Err(error) => tracing::error!(%error, "session tape writer did not finish"), - } -} - -/// Renders the 400 envelope for an unparseable chat body. -fn bad_request(error: &serde_json::Error) -> Response { - ( - axum::http::StatusCode::BAD_REQUEST, - [(header::CONTENT_TYPE, "application/json")], - serde_json::json!({ - "error": { - "message": format!("invalid chat request: {error}"), - "code": "bad_request", - } - }) - .to_string(), - ) - .into_response() -} - -/// Turns a gateway call outcome into the workbench's HTTP response. -/// -/// Success (any status) is relayed byte-for-byte; a transport failure -/// becomes `502 Bad Gateway` with a small JSON error envelope. -fn relay(result: Result) -> Response { - match result { - Ok(upstream) => ( - upstream.status, - [(header::CONTENT_TYPE, "application/json")], - upstream.body, - ) - .into_response(), - Err(error) => ( - axum::http::StatusCode::BAD_GATEWAY, - [(header::CONTENT_TYPE, "application/json")], - serde_json::json!({ - "error": { - "message": error.to_string(), - "code": "gateway_unreachable", - } - }) - .to_string(), - ) - .into_response(), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - use std::path::{Path, PathBuf}; - - use axum::Json; - use axum::body::{Body, to_bytes}; - use axum::http::{HeaderMap, Request, StatusCode}; - use tower::ServiceExt; - - use crate::config::{GatewayConfig, ServerConfig, TapeConfig, VoiceConfig}; - use crate::status::{Severity, StatusBarUpdate}; - use crate::transcribe::fixtures; - - const CATALOG: &str = r#"{"object":"list","data":[{"id":"test-model","object":"model","created":1,"owned_by":"promptforge"}]}"#; - const COMPLETION: &str = r#"{"id":"chatcmpl-1","object":"chat.completion","created":1,"model":"test-model","choices":[{"index":0,"message":{"role":"assistant","content":"pong"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}"#; - const UPSTREAM_ERROR: &str = - r#"{"error":{"message":"model unloaded","code":"upstream_unavailable"}}"#; - const CHAT_BODY: &str = - r#"{"model":"test-model","messages":[{"role":"user","content":"ping"}]}"#; - const STREAM_CHAT_BODY: &str = - r#"{"model":"test-model","messages":[{"role":"user","content":"ping"}],"stream":true}"#; - - fn config_for(base_url: &str, tape_path: &Path) -> Config { - Config { - gateway: GatewayConfig { - base_url: base_url.to_string(), - api_key: "test-key".to_string(), - }, - tape: TapeConfig { - path: tape_path.to_path_buf(), - }, - server: ServerConfig::default(), - voice: VoiceConfig::default(), - } - } - - /// Builds state whose tape lives in a fresh tempdir, returned alongside - /// so the directory outlives the test. - fn state_for(base_url: &str) -> (AppState, tempfile::TempDir) { - let tape_dir = tempfile::TempDir::new().expect("tempdir"); - let config = config_for(base_url, &tape_dir.path().join("tape.jsonl")); - let state = AppState::new(&config).expect("state builds in tests"); - (state, tape_dir) - } - - fn authorized(headers: &HeaderMap) -> bool { - headers - .get(header::AUTHORIZATION) - .and_then(|value| value.to_str().ok()) - == Some("Bearer test-key") - } - - async fn mock_models(headers: HeaderMap) -> Response { - if !authorized(&headers) { - return StatusCode::UNAUTHORIZED.into_response(); - } - ([(header::CONTENT_TYPE, "application/json")], CATALOG).into_response() - } - - async fn mock_chat(headers: HeaderMap, Json(body): Json) -> Response { - if !authorized(&headers) { - return StatusCode::UNAUTHORIZED.into_response(); - } - assert_eq!(body["model"], "test-model"); - assert!(body["messages"].is_array()); - ([(header::CONTENT_TYPE, "application/json")], COMPLETION).into_response() - } - - async fn mock_broken_models() -> Response { - ( - StatusCode::SERVICE_UNAVAILABLE, - [(header::CONTENT_TYPE, "application/json")], - UPSTREAM_ERROR, - ) - .into_response() - } - - async fn mock_chat_not_json(headers: HeaderMap) -> Response { - if !authorized(&headers) { - return StatusCode::UNAUTHORIZED.into_response(); - } - ( - [(header::CONTENT_TYPE, "text/plain")], - "gateway replied in plain text", - ) - .into_response() - } - - /// Binds `app` as a mock gateway on a free loopback port and returns its - /// base URL. - async fn spawn_gateway(app: Router) -> String { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .expect("bind mock gateway"); - let addr = listener.local_addr().expect("mock gateway address"); - tokio::spawn(async move { - axum::serve(listener, app) - .await - .expect("mock gateway serves"); - }); - format!("http://{addr}") - } - - async fn spawn_mock_gateway() -> String { - spawn_gateway( - Router::new() - .route("/v1/models", get(mock_models)) - .route("/v1/chat/completions", post(mock_chat)), - ) - .await - } - - async fn spawn_broken_mock_gateway() -> String { - spawn_gateway(Router::new().route("/v1/models", get(mock_broken_models))).await - } - - /// 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"); - } - - async fn body_bytes(response: Response) -> axum::body::Bytes { - to_bytes(response.into_body(), usize::MAX) - .await - .expect("the body is in memory already") - } - - fn chat_request() -> Request { - Request::builder() - .method("POST") - .uri("/chat") - .header(header::CONTENT_TYPE, "application/json") - .body(Body::from(CHAT_BODY)) - .expect("static request parts are valid") - } - - fn stream_chat_request() -> Request { - Request::builder() - .method("POST") - .uri("/chat") - .header(header::CONTENT_TYPE, "application/json") - .body(Body::from(STREAM_CHAT_BODY)) - .expect("static request parts are valid") - } - - #[test] - fn default_bind_is_loopback_port_7910() { - assert_eq!(DEFAULT_ADDR, "127.0.0.1:7910"); - } - - /// 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) { - let (state, _tape_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("the router is infallible"); - assert_eq!(response.status(), StatusCode::OK, "{uri} serves"); - let content_type = response - .headers() - .get(header::CONTENT_TYPE) - .unwrap_or_else(|| panic!("{uri} sets content-type")); - assert_eq!(content_type, expected_content_type, "{uri} content type"); - assert!( - !body_bytes(response).await.is_empty(), - "{uri} body is non-empty" - ); - } - - #[tokio::test] - async fn index_is_served_at_the_root() { - assert_ui_asset("/", "text/html; charset=utf-8").await; - } - - #[tokio::test] - async fn app_js_is_served_as_javascript() { - assert_ui_asset("/app.js", "text/javascript; charset=utf-8").await; - } - - #[tokio::test] - async fn style_css_is_served_as_css() { - assert_ui_asset("/style.css", "text/css; charset=utf-8").await; - } - - #[tokio::test] - async fn bundled_app_css_is_served_as_css() { - assert_ui_asset("/app.css", "text/css; charset=utf-8").await; - } - - #[tokio::test] - async fn pcm_worklet_is_served_as_javascript() { - assert_ui_asset("/pcm-worklet.js", "text/javascript; charset=utf-8").await; - } - - /// A plain GET to `/ws` without upgrade headers is rejected with 400, - /// which proves the route is mounted; the WebSocket chat flow is covered - /// by the `chat_ws` module's own tests over a live socket. - #[tokio::test] - async fn ws_route_rejects_a_non_upgrade_get() { - let (state, _tape_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); - } - - /// A plain GET to `/voice` without upgrade headers is rejected with 400, - /// which proves the route is mounted; the full WebSocket session flow is - /// covered by the `voice` module's own tests over a live socket. - #[tokio::test] - async fn voice_route_rejects_a_non_upgrade_get() { - let (state, _tape_dir) = state_for("http://127.0.0.1:1"); - let request = Request::builder() - .uri("/voice") - .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); - } - - #[tokio::test] - async fn health_returns_serving() { - let (state, _tape_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("the router is infallible"); - assert_eq!(response.status(), StatusCode::OK); - let content_type = response - .headers() - .get(header::CONTENT_TYPE) - .expect("the health handler sets content-type"); - assert_eq!(content_type, "application/json"); - assert_eq!(&body_bytes(response).await[..], br#"{"status":"serving"}"#); - } - - #[tokio::test] - async fn models_are_relayed_byte_for_byte() { - let base_url = spawn_mock_gateway().await; - let (state, _tape_dir) = state_for(&base_url); - let request = Request::builder() - .uri("/v1/models") - .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::OK); - assert_eq!(&body_bytes(response).await[..], CATALOG.as_bytes()); - } - - #[tokio::test] - async fn chat_completions_are_relayed_byte_for_byte() { - let base_url = spawn_mock_gateway().await; - let (state, _tape_dir) = state_for(&base_url); - let response = router(state) - .oneshot(chat_request()) - .await - .expect("the router is infallible"); - assert_eq!(response.status(), StatusCode::OK); - assert_eq!(&body_bytes(response).await[..], COMPLETION.as_bytes()); - } - - #[tokio::test] - async fn gateway_error_status_is_relayed_byte_for_byte() { - let base_url = spawn_broken_mock_gateway().await; - let (state, _tape_dir) = state_for(&base_url); - let request = Request::builder() - .uri("/v1/models") - .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::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, _tape_dir) = state_for("http://127.0.0.1:1"); - let request = Request::builder() - .uri("/v1/models") - .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_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, _tape_dir) = state_for("http://127.0.0.1:1"); - state.health().publish(false); - let request = Request::builder() - .uri("/v1/models") - .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_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" - ); - } - - #[tokio::test] - async fn a_gateway_known_down_short_circuits_buffered_chat_with_bad_gateway() { - let (state, tape_dir) = state_for("http://127.0.0.1:1"); - state.health().publish(false); - let response = router(state) - .oneshot(chat_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"); - let raw = - std::fs::read_to_string(tape_dir.path().join("tape.jsonl")).expect("the tape exists"); - assert!( - raw.trim().is_empty(), - "no upstream attempt means no tape event" - ); - } - - #[tokio::test] - async fn malformed_chat_body_is_a_bad_request() { - let (state, _tape_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#"{"not_model":true}"#)) - .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); - } - - #[tokio::test] - async fn chat_round_trip_writes_exactly_one_tape_event() { - let base_url = spawn_mock_gateway().await; - let (state, tape_dir) = state_for(&base_url); - let response = router(state) - .oneshot(chat_request()) - .await - .expect("the router is infallible"); - assert_eq!(response.status(), StatusCode::OK); - - let raw = - std::fs::read_to_string(tape_dir.path().join("tape.jsonl")).expect("the tape exists"); - assert!(raw.ends_with('\n'), "the tape line is complete: {raw:?}"); - let lines: Vec<&str> = raw.lines().collect(); - assert_eq!(lines.len(), 1, "exactly one event per round-trip"); - let event: serde_json::Value = - serde_json::from_str(lines[0]).expect("the tape line is valid JSON"); - assert_eq!(event["kind"], "chat"); - assert_eq!(event["model"], "test-model"); - assert_eq!(event["request"]["messages"][0]["content"], "ping"); - assert_eq!(event["response"]["id"], "chatcmpl-1"); - assert!(event["latency_ms"].is_u64(), "latency_ms is an integer"); - let ts = event["ts"].as_str().expect("ts is a string"); - time::OffsetDateTime::parse(ts, &time::format_description::well_known::Rfc3339) - .expect("ts is RFC 3339"); - } - - #[test] - fn unopenable_tape_path_fails_state_construction() { - let dir = tempfile::TempDir::new().expect("tempdir"); - let config = config_for( - "http://127.0.0.1:1", - &dir.path().join("missing").join("tape.jsonl"), - ); - let err = AppState::new(&config).expect_err("an unopenable tape must fail"); - assert!( - matches!(err, AppError::Tape(_)), - "expected Tape, got {err:?}" - ); - } - - /// Drains the startup phase frames emitted before the degradation - /// verdict and returns the verdict frame. - fn degradation(rx: &mut tokio::sync::broadcast::Receiver) -> StatusBarUpdate { - // The first frame is the "Loading whisper model" phase note; the - // verdict follows it. - rx.try_recv().expect("the loading phase is reported"); - rx.try_recv().expect("the degradation verdict is reported") - } - - #[test] - fn a_missing_interim_model_with_no_source_degrades_to_disabled_voice() { - let status = StatusBus::new(); - let mut rx = status.subscribe(); - let config = VoiceConfig { - interim_model: PathBuf::from("definitely-missing-model.bin"), - ..VoiceConfig::default() - }; - let engine = startup_engine(&config, &status); - assert!(engine.is_none(), "voice degrades to disabled, not fatal"); - let verdict = degradation(&mut rx); - assert_eq!(verdict.label, "Voice disabled"); - assert_eq!(verdict.severity, Severity::Error); - assert!( - verdict.description.contains("definitely-missing-model.bin"), - "the explanation names the missing path: {verdict:?}" - ); - } - - #[test] - fn a_missing_model_with_a_source_defers_to_provisioning() { - let status = StatusBus::new(); - let mut rx = status.subscribe(); - let config = VoiceConfig { - interim_model: PathBuf::from("definitely-missing-model.bin"), - interim_source: "https://example.com/ggml.bin".to_string(), - ..VoiceConfig::default() - }; - let engine = startup_engine(&config, &status); - assert!(engine.is_none(), "the engine activates later, not now"); - let verdict = degradation(&mut rx); - assert_eq!(verdict.label, "Voice models not downloaded"); - assert_eq!(verdict.severity, Severity::Info); - assert_eq!(verdict.activity, Activity::General); - } - - #[test] - #[ignore = "requires whisper test fixtures (tests/fixtures/)"] - fn a_missing_unsourced_final_model_drops_the_final_pass() { - let status = StatusBus::new(); - let mut rx = status.subscribe(); - let config = VoiceConfig { - interim_model: fixtures::require_model(), - final_model: PathBuf::from("definitely-missing-final-model.bin"), - ..VoiceConfig::default() - }; - let engine = startup_engine(&config, &status); - let engine = engine.expect("the interim model still loads"); - assert!( - engine.final_pass_absent_for_test(), - "the final pass was dropped" - ); - let verdict = degradation(&mut rx); - assert_eq!(verdict.label, "Voice final pass unavailable"); - assert_eq!(verdict.severity, Severity::Info); - } - - #[test] - fn invalid_voice_tuning_degrades_instead_of_failing_startup() { - let status = StatusBus::new(); - let mut rx = status.subscribe(); - let config = VoiceConfig { - interim_model: PathBuf::from("model.bin"), - window_seconds: 0, - ..VoiceConfig::default() - }; - let engine = startup_engine(&config, &status); - assert!(engine.is_none(), "invalid tuning costs voice, not startup"); - let verdict = degradation(&mut rx); - assert_eq!(verdict.label, "Voice disabled"); - assert!( - verdict.description.contains("window_seconds"), - "the explanation names the bad field: {verdict:?}" - ); - } - - #[tokio::test] - async fn non_json_gateway_body_is_taped_as_a_string() { - let base_url = - spawn_gateway(Router::new().route("/v1/chat/completions", post(mock_chat_not_json))) - .await; - let (state, tape_dir) = state_for(&base_url); - let response = router(state) - .oneshot(chat_request()) - .await - .expect("the router is infallible"); - assert_eq!(response.status(), StatusCode::OK); - - let raw = - std::fs::read_to_string(tape_dir.path().join("tape.jsonl")).expect("the tape exists"); - let event: serde_json::Value = - serde_json::from_str(raw.lines().next().expect("one event per round-trip")) - .expect("the tape line is valid JSON"); - assert_eq!(event["response"], "gateway replied in plain text"); - } - - #[tokio::test] - async fn a_streaming_chat_request_is_rejected_with_bad_request() { - let (state, _tape_dir) = state_for("http://127.0.0.1:1"); - let response = router(state) - .oneshot(stream_chat_request()) - .await - .expect("the router is infallible"); - assert_eq!(response.status(), StatusCode::BAD_REQUEST); - 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"], "stream_unsupported"); - } - - #[tokio::test] - async fn tape_write_failure_does_not_fail_the_chat_response() { - struct FailingWriter; - impl std::io::Write for FailingWriter { - fn write(&mut self, _buf: &[u8]) -> std::io::Result { - Err(std::io::Error::other("injected tape failure")) - } - fn flush(&mut self) -> std::io::Result<()> { - Ok(()) - } - } - - let base_url = spawn_mock_gateway().await; - let gateway = GatewayClient::new(&base_url, "test-key").expect("client builds in tests"); - let state = AppState { - gateway, - tape: Arc::new(Tape::with_writer_for_test(FailingWriter)), - voice: VoiceSlot::default(), - status: StatusBus::new(), - health: GatewayHealth::new(), - catalog: CatalogBus::new(), - }; - let response = router(state) - .oneshot(chat_request()) - .await - .expect("the router is infallible"); - assert_eq!(response.status(), StatusCode::OK); - assert_eq!(&body_bytes(response).await[..], COMPLETION.as_bytes()); - } -} diff --git a/crates/promptforge-wb-server/src/chat_ws.rs b/crates/promptforge-wb-server/src/chat_ws.rs deleted file mode 100644 index 38f3da87..00000000 --- a/crates/promptforge-wb-server/src/chat_ws.rs +++ /dev/null @@ -1,1042 +0,0 @@ -//! The `/ws` WebSocket endpoint: one persistent socket carrying all -//! downstream JSON - browser chat over bidirectional text frames, relayed -//! through the gateway's streaming chat completion, plus unsolicited status -//! updates from the observer. -//! -//! A client upgrades `GET /ws` once and sends chat requests as text frames: -//! `{"type":"chat","id":N,"model":"...","messages":[...]}`. Each chat frame -//! runs one streaming gateway completion; the session answers with -//! `{"type":"delta","content":"...","id":N}` frames as content arrives, a -//! terminal `{"type":"done","id":N}` when the stream completes, or -//! `{"type":"error","message":"...","id":N}` on any failure - transport, -//! mid-stream, or a gateway that declines the stream with a non-success -//! status. The `id` is optional and echoed verbatim on every frame of that -//! chat's reply, so one socket can multiplex requests; a frame without an -//! `id` is answered untagged. A frame that is not a well-formed chat -//! request is answered with an `error` frame and the session continues. -//! Chat frames are answered strictly in order: while one streams, later -//! frames wait. A chat received while the heartbeat knows the gateway is -//! down is answered immediately with a "Gateway unreachable" error frame - -//! no upstream attempt, no tape event. -//! -//! Status updates from [`crate::status`] and model catalog pushes from -//! [`crate::catalog`] are forwarded to the socket as unsolicited -//! `{"type":"status",...}` and `{"type":"models",...}` frames by a -//! dedicated task, so they flow at any time - including while a chat is -//! streaming, when the inbound loop is parked inside the relay. -//! -//! Exactly one tape event is written per chat frame, after the stream -//! settles and before the terminal frame is sent, so a client holding -//! `done` or `error` can trust the tape to hold the exchange. A client -//! that disconnects mid-stream is taped with a `client disconnected` note -//! beside the partial content. - -use std::sync::Arc; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::time::Instant; - -use axum::extract::State; -use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; -use axum::response::Response; -use futures_util::{SinkExt, StreamExt}; -use tokio::sync::broadcast; - -use crate::app::{AppState, tape_round_trip, value_from_bytes}; -use crate::gateway::{ChatRequest, ChatStream, GatewayResponse}; -use crate::status::Activity; -use crate::tape::Tape; - -/// Session ids for log correlation, handed out in connection order. -static NEXT_SESSION: AtomicU64 = AtomicU64::new(1); - -/// Upgrades a `GET /ws` request to a WebSocket chat session. -pub(crate) async fn upgrade(State(state): State, ws: WebSocketUpgrade) -> Response { - let session = NEXT_SESSION.fetch_add(1, Ordering::Relaxed); - ws.on_upgrade(move |socket| run_session(session, socket, state)) -} - -/// Runs one chat session until the socket closes or fails. -async fn run_session(session: u64, socket: WebSocket, state: AppState) { - tracing::info!(session, "chat session opened"); - let (mut sink, mut stream) = socket.split(); - // The receive loop and the status forwarder both speak to the client, - // so outbound messages funnel through one channel into the writer task, - // mirroring the voice session. - let (out_tx, mut out_rx) = tokio::sync::mpsc::channel::(32); - let writer = tokio::spawn(async move { - while let Some(message) = out_rx.recv().await { - if sink.send(message).await.is_err() { - break; - } - } - }); - - // Status frames and catalog pushes are unsolicited and must flow while - // a chat relay has the inbound loop parked, so they get their own task - // off the broadcast buses rather than a branch in that loop. A client - // too slow to keep up lags the rings and skips ahead; the buses never - // block for it. - let mut status_rx = state.status().subscribe(); - let mut catalog_rx = state.catalog().subscribe(); - let status_out = out_tx.clone(); - let forwarder = tokio::spawn(async move { - loop { - let text = tokio::select! { - received = status_rx.recv() => match received { - Ok(update) => { - // Serializing strings and integers cannot fail. - let Ok(text) = serde_json::to_string(&update.frame()) else { - continue; - }; - text - } - Err(broadcast::error::RecvError::Lagged(skipped)) => { - tracing::debug!(session, skipped, "status receiver lagged; skipped updates"); - continue; - } - Err(broadcast::error::RecvError::Closed) => break, - }, - received = catalog_rx.recv() => match received { - Ok(push) => { - // Serializing a JSON value cannot fail. - let Ok(text) = serde_json::to_string(&push.frame()) else { - continue; - }; - text - } - Err(broadcast::error::RecvError::Lagged(skipped)) => { - tracing::debug!(session, skipped, "catalog receiver lagged; skipped pushes"); - continue; - } - Err(broadcast::error::RecvError::Closed) => break, - }, - }; - if status_out.send(Message::Text(text.into())).await.is_err() { - break; - } - } - }); - - while let Some(received) = stream.next().await { - match received { - Ok(Message::Text(text)) => handle_frame(&state, &text, &out_tx).await, - // Binary frames carry no chat meaning; pings and pongs are - // answered by axum itself. - Ok(Message::Ping(_) | Message::Pong(_) | Message::Binary(_)) => {} - Ok(Message::Close(_)) => break, - Err(error) => { - tracing::warn!(session, %error, "chat session socket failed"); - break; - } - } - } - drop(out_tx); - writer.abort(); - forwarder.abort(); - tracing::info!(session, "chat session closed"); -} - -/// Handles one inbound text frame: a well-formed `chat` frame runs a -/// streamed completion, anything else is answered with an `error` frame. -async fn handle_frame(state: &AppState, text: &str, out: &tokio::sync::mpsc::Sender) { - let frame: serde_json::Value = match serde_json::from_str(text) { - Ok(frame) => frame, - Err(error) => { - send_error(out, None, format!("invalid JSON frame: {error}")).await; - return; - } - }; - // The request id, echoed on every frame of this chat's reply so one - // persistent socket can multiplex requests. Absent and null both mean - // untagged. - let id = frame.get("id").cloned().filter(|id| !id.is_null()); - if frame.get("type").and_then(serde_json::Value::as_str) != Some("chat") { - send_error(out, id.as_ref(), "unknown frame type; expected \"chat\"").await; - return; - } - let request: ChatRequest = match serde_json::from_value(frame.clone()) { - Ok(request) => request, - Err(error) => { - send_error(out, id.as_ref(), format!("invalid chat request: {error}")).await; - return; - } - }; - // A gateway the heartbeat knows is down is not attempted: the chat - // fails fast with a user-visible error instead of a transport error, - // and nothing is taped because no exchange happened. - if !state.health().is_reachable() { - send_error(out, id.as_ref(), "Gateway unreachable").await; - return; - } - relay_chat(state, request, frame, id, out).await; -} - -/// Runs one streaming chat completion against the gateway, forwarding -/// content deltas as `delta` frames and settling with `done` or `error`. -async fn relay_chat( - state: &AppState, - request: ChatRequest, - frame: serde_json::Value, - id: Option, - out: &tokio::sync::mpsc::Sender, -) { - let started = Instant::now(); - let status = state.status(); - status.info( - "Submitting request...", - format!("a streaming chat completion from {}", request.model), - Activity::Thinking, - ); - let chat_stream = match state - .gateway_client() - .chat_completion_stream(&request) - .await - { - Ok(chat_stream) => chat_stream, - Err(error) => { - status.error("Connection lost", error.to_string(), Activity::General); - send_error(out, id.as_ref(), error.to_string()).await; - return; - } - }; - let mut payloads = match chat_stream { - ChatStream::Stream { payloads, .. } => { - status.info( - "Streaming response...", - "the gateway is streaming the reply", - Activity::Thinking, - ); - payloads - } - ChatStream::Relay(upstream) => { - declined_stream( - state, - request.model, - frame, - upstream, - started, - id.as_ref(), - out, - ) - .await; - return; - } - }; - let mut finish = StreamTape { - tape: Arc::clone(state.tape()), - model: request.model, - request: frame, - started, - assembled: String::new(), - error: None, - }; - loop { - match payloads.next().await { - Some(Ok(payload)) => { - // The terminal sentinel ends the wire stream but carries no - // content; role-priming and usage events have none either. - if payload == "[DONE]" { - continue; - } - let Some(text) = delta_content(&payload) else { - continue; - }; - finish.assembled.push_str(&text); - // A chunk pulse at Debug: the UI ignores the text, but the - // activity field keeps the generating LED lit. - status.debug( - "Streaming response...", - "a gateway response chunk", - Activity::Generating, - ); - let delta = tagged( - id.as_ref(), - serde_json::json!({"type": "delta", "content": text}), - ); - if !send_frame(out, delta).await { - finish.error = Some("client disconnected mid-stream".to_string()); - finish.record().await; - return; - } - } - Some(Err(error)) => { - let message = error.to_string(); - finish.error = Some(message.clone()); - finish.record().await; - status.error("Connection lost", message.clone(), Activity::General); - send_error(out, id.as_ref(), message).await; - return; - } - None => { - finish.record().await; - status.idle(); - let _ = send_frame( - out, - tagged(id.as_ref(), serde_json::json!({"type": "done"})), - ) - .await; - return; - } - } - } -} - -/// Handles a gateway that declined the stream with an ordinary response: -/// the envelope is taped like a buffered chat and reported as an `error` -/// frame and an error status. -async fn declined_stream( - state: &AppState, - model: String, - frame: serde_json::Value, - upstream: GatewayResponse, - started: Instant, - id: Option<&serde_json::Value>, - out: &tokio::sync::mpsc::Sender, -) { - let response = value_from_bytes(&upstream.body); - tape_round_trip( - state.tape(), - model, - frame, - response.clone(), - started.elapsed(), - ) - .await; - let message = response - .get("error") - .and_then(|error| error.get("message")) - .and_then(serde_json::Value::as_str) - .map_or_else( - || { - format!( - "gateway declined the stream with status {}", - upstream.status - ) - }, - str::to_string, - ); - state.status().error( - format!("Gateway error: {}", upstream.status), - message.clone(), - Activity::General, - ); - send_error(out, id, message).await; -} - -/// Tags a reply frame with the request's `id`, when it carried one. -fn tagged(id: Option<&serde_json::Value>, mut frame: serde_json::Value) -> serde_json::Value { - if let (Some(id), Some(object)) = (id, frame.as_object_mut()) { - object.insert("id".to_string(), id.clone()); - } - frame -} - -/// Extracts the text delta from one gateway SSE payload, if it carries -/// content. -/// -/// Role-priming and usage events have no `choices[0].delta.content` and -/// contribute nothing to the assembled response. -fn delta_content(payload: &str) -> Option { - let value: serde_json::Value = serde_json::from_str(payload).ok()?; - let content = value - .get("choices")? - .as_array()? - .first()? - .get("delta")? - .get("content")? - .as_str()?; - Some(content.to_string()) -} - -/// Tape bookkeeping carried through one streaming chat. -/// -/// The session consumes this exactly once per chat frame, so a streamed chat -/// always tapes exactly one event. -struct StreamTape { - tape: Arc, - model: String, - request: serde_json::Value, - started: Instant, - /// Concatenation of every content delta forwarded so far. - assembled: String, - /// The mid-stream failure note, when the gateway stream errored. - error: Option, -} - -impl StreamTape { - /// Writes the stream's single tape event: the assembled content on - /// success, or an error note plus the partial content on failure. - async fn record(self) { - let Self { - tape, - model, - request, - started, - assembled, - error, - } = self; - let response = match error { - Some(message) => serde_json::json!({ - "error": message, - "content": assembled, - }), - None => serde_json::Value::String(assembled), - }; - tape_round_trip(&tape, model, request, response, started.elapsed()).await; - } -} - -/// Sends one JSON text frame; a false return means the client is gone. -async fn send_frame(out: &tokio::sync::mpsc::Sender, frame: serde_json::Value) -> bool { - out.send(Message::Text(frame.to_string().into())) - .await - .is_ok() -} - -/// Sends one `error` frame carrying `message`, tagged with the request's -/// `id` when there is one, ignoring a dead client. -async fn send_error( - out: &tokio::sync::mpsc::Sender, - id: Option<&serde_json::Value>, - message: impl Into, -) { - let frame = tagged( - id, - serde_json::json!({"type": "error", "message": message.into()}), - ); - let _ = send_frame(out, frame).await; -} - -#[cfg(test)] -mod tests { - use super::*; - - use std::sync::atomic::{AtomicBool, Ordering}; - use std::time::Duration; - - use axum::Router; - use axum::body::Body; - use axum::extract::State; - use axum::http::{HeaderMap, StatusCode, header}; - use axum::response::IntoResponse; - use axum::routing::{get, post}; - use futures_util::stream; - use tokio_tungstenite::tungstenite; - - use crate::app::router; - use crate::config::{Config, GatewayConfig, ServerConfig, TapeConfig, VoiceConfig}; - use crate::status::{Activity, Progress, Severity, StatusBarUpdate}; - - const STREAM_BODY: &str = concat!( - "data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\"}}]}\n\n", - "data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"po\"}}]}\n\n", - "data: {\"id\":\"chatcmpl-1\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"ng\"}}]}\n\n", - "data: [DONE]\n\n", - ); - 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_chat_stream(headers: HeaderMap, body: String) -> Response { - assert!(authorized(&headers)); - let body: serde_json::Value = serde_json::from_str(&body).expect("the request is JSON"); - assert_eq!(body["stream"], true, "the stream flag is forwarded"); - ([(header::CONTENT_TYPE, "text/event-stream")], STREAM_BODY).into_response() - } - - /// Answers with one good SSE event, then aborts the body mid-stream. - /// - /// The pause after the first chunk gives hyper time to flush the headers - /// and the event before the body errors, so the client observes a stream - /// that fails mid-way rather than a connection that never answered. - async fn mock_chat_stream_dies(headers: HeaderMap, body: String) -> Response { - assert!(authorized(&headers)); - let body: serde_json::Value = serde_json::from_str(&body).expect("the request is JSON"); - assert_eq!(body["stream"], true, "the stream flag is forwarded"); - let chunks = stream::unfold(0u8, |step| async move { - match step { - 0 => Some(( - Ok::<_, std::io::Error>(axum::body::Bytes::from_static( - b"data: {\"choices\":[{\"delta\":{\"content\":\"po\"}}]}\n\n", - )), - 1, - )), - 1 => { - tokio::time::sleep(std::time::Duration::from_millis(100)).await; - Some((Err(std::io::Error::other("injected upstream failure")), 2)) - } - _ => None, - } - }); - ( - [(header::CONTENT_TYPE, "text/event-stream")], - Body::from_stream(chunks), - ) - .into_response() - } - - /// Drips one delta every 50ms, giving a client time to disconnect - /// mid-stream before the drip runs out. - async fn mock_chat_stream_drips(headers: HeaderMap, body: String) -> Response { - assert!(authorized(&headers)); - let body: serde_json::Value = serde_json::from_str(&body).expect("the request is JSON"); - assert_eq!(body["stream"], true, "the stream flag is forwarded"); - let chunks = stream::unfold(0u8, |step| async move { - if step >= 40 { - return None; - } - tokio::time::sleep(std::time::Duration::from_millis(50)).await; - let payload = - format!("data: {{\"choices\":[{{\"delta\":{{\"content\":\"x{step}\"}}}}]}}\n\n"); - Some(( - Ok::<_, std::io::Error>(axum::body::Bytes::from(payload)), - step + 1, - )) - }); - ( - [(header::CONTENT_TYPE, "text/event-stream")], - Body::from_stream(chunks), - ) - .into_response() - } - - /// Declines a streaming request with an ordinary JSON error envelope. - async fn mock_chat_declines_stream(headers: HeaderMap, body: String) -> Response { - assert!(authorized(&headers)); - let body: serde_json::Value = serde_json::from_str(&body).expect("the request is JSON"); - assert_eq!(body["stream"], true, "the stream flag is forwarded"); - ( - StatusCode::SERVICE_UNAVAILABLE, - [(header::CONTENT_TYPE, "application/json")], - UPSTREAM_ERROR, - ) - .into_response() - } - - /// Binds `app` as a mock gateway on a free loopback port and returns its - /// base URL. - async fn spawn_gateway(app: Router) -> String { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .expect("bind mock gateway"); - let addr = listener.local_addr().expect("mock gateway address"); - tokio::spawn(async move { - axum::serve(listener, app) - .await - .expect("mock gateway serves"); - }); - format!("http://{addr}") - } - - /// Binds the workbench router against the gateway at `base_url` on a - /// free loopback port and returns the `/ws` URL, the tempdir keeping - /// the tape alive, and a handle on the shared state (for poking the - /// status bus directly). - async fn spawn_chat_server(base_url: &str) -> (String, tempfile::TempDir, AppState) { - let tape_dir = tempfile::TempDir::new().expect("tempdir"); - let config = Config { - gateway: GatewayConfig { - base_url: base_url.to_string(), - api_key: "test-key".to_string(), - }, - tape: TapeConfig { - path: tape_dir.path().join("tape.jsonl"), - }, - server: ServerConfig::default(), - voice: VoiceConfig::default(), - }; - let state = AppState::new(&config).expect("state builds in tests"); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .expect("bind the chat test server"); - let addr = listener.local_addr().expect("chat test server address"); - let served = state.clone(); - tokio::spawn(async move { - axum::serve(listener, router(served)) - .await - .expect("chat test server serves"); - }); - (format!("ws://{addr}/ws"), tape_dir, state) - } - - /// Reads one text frame from the client socket and parses it as JSON. - async fn read_frame(socket: &mut S) -> serde_json::Value - where - S: futures_util::Stream> + Unpin, - { - let message = socket - .next() - .await - .expect("a frame follows") - .expect("the frame is not a socket error"); - let text = message.into_text().expect("the frame is text"); - serde_json::from_str(&text).expect("the frame is JSON") - } - - /// Reads frames until one arrives that is not a status update. Status - /// frames are unsolicited and may interleave with a chat's replies at - /// any point, so reply assertions skip them. - async fn read_non_status_frame(socket: &mut S) -> serde_json::Value - where - S: futures_util::Stream> + Unpin, - { - loop { - let frame = read_frame(socket).await; - if frame["type"] != "status" { - return frame; - } - } - } - - /// Sends one well-formed chat frame naming the test model. - async fn send_chat(socket: &mut S) - where - S: futures_util::Sink + Unpin, - { - let frame = serde_json::json!({ - "type": "chat", - "model": "test-model", - "messages": [{"role": "user", "content": "ping"}], - }) - .to_string(); - socket - .send(tungstenite::Message::Text(frame.into())) - .await - .expect("the chat frame is sent"); - } - - /// Reads every event on the test's tape. - fn tape_events(tape_dir: &tempfile::TempDir) -> Vec { - let raw = - std::fs::read_to_string(tape_dir.path().join("tape.jsonl")).expect("the tape exists"); - raw.lines() - .map(|line| serde_json::from_str(line).expect("the tape line is valid JSON")) - .collect() - } - - #[tokio::test] - async fn chat_frames_relay_deltas_in_order_then_done() { - let base_url = - spawn_gateway(Router::new().route("/v1/chat/completions", post(mock_chat_stream))) - .await; - let (url, _tape_dir, _state) = spawn_chat_server(&base_url).await; - let (mut socket, _) = tokio_tungstenite::connect_async(&url) - .await - .expect("connect to /ws"); - send_chat(&mut socket).await; - - // The role-priming event carries no content and yields no frame. - let first = read_non_status_frame(&mut socket).await; - assert_eq!(first, serde_json::json!({"type": "delta", "content": "po"})); - let second = read_non_status_frame(&mut socket).await; - assert_eq!( - second, - serde_json::json!({"type": "delta", "content": "ng"}) - ); - let third = read_non_status_frame(&mut socket).await; - assert_eq!(third, serde_json::json!({"type": "done"})); - socket.close(None).await.expect("close the socket"); - } - - #[tokio::test] - async fn a_completed_chat_tapes_one_event_with_the_assembled_response() { - let base_url = - spawn_gateway(Router::new().route("/v1/chat/completions", post(mock_chat_stream))) - .await; - let (url, tape_dir, _state) = spawn_chat_server(&base_url).await; - let (mut socket, _) = tokio_tungstenite::connect_async(&url) - .await - .expect("connect to /ws"); - send_chat(&mut socket).await; - // The terminal frame is sent after the tape write, so holding `done` - // means the tape is durable. - loop { - let frame = read_non_status_frame(&mut socket).await; - if frame["type"] == "done" { - break; - } - } - - let events = tape_events(&tape_dir); - assert_eq!(events.len(), 1, "exactly one event per chat frame"); - let event = &events[0]; - assert_eq!(event["kind"], "chat"); - assert_eq!(event["model"], "test-model"); - assert_eq!( - event["request"]["type"], "chat", - "the frame is taped as received" - ); - assert_eq!(event["request"]["messages"][0]["content"], "ping"); - assert_eq!( - event["response"], "pong", - "the tape holds the assembled content, not the raw frames" - ); - assert!(event["latency_ms"].is_u64(), "latency_ms is an integer"); - socket.close(None).await.expect("close the socket"); - } - - #[tokio::test] - async fn a_mid_stream_gateway_error_sends_an_error_frame_and_tapes_the_note() { - let base_url = - spawn_gateway(Router::new().route("/v1/chat/completions", post(mock_chat_stream_dies))) - .await; - let (url, tape_dir, _state) = spawn_chat_server(&base_url).await; - let (mut socket, _) = tokio_tungstenite::connect_async(&url) - .await - .expect("connect to /ws"); - send_chat(&mut socket).await; - - let first = read_non_status_frame(&mut socket).await; - assert_eq!(first, serde_json::json!({"type": "delta", "content": "po"})); - let second = read_non_status_frame(&mut socket).await; - assert_eq!(second["type"], "error"); - let message = second["message"].as_str().expect("the error is a string"); - assert!(!message.is_empty(), "the error frame names the failure"); - - let events = tape_events(&tape_dir); - assert_eq!(events.len(), 1, "an errored stream still tapes one event"); - let note = events[0]["response"]["error"] - .as_str() - .expect("the error note is a string"); - assert!(!note.is_empty(), "the error note names the failure"); - assert_eq!( - events[0]["response"]["content"], "po", - "the partial content is taped alongside the error" - ); - socket.close(None).await.expect("close the socket"); - } - - #[tokio::test] - async fn a_client_disconnect_mid_stream_is_taped_with_the_partial_content() { - let base_url = spawn_gateway( - Router::new().route("/v1/chat/completions", post(mock_chat_stream_drips)), - ) - .await; - let (url, tape_dir, _state) = spawn_chat_server(&base_url).await; - let (mut socket, _) = tokio_tungstenite::connect_async(&url) - .await - .expect("connect to /ws"); - send_chat(&mut socket).await; - let first = read_non_status_frame(&mut socket).await; - assert_eq!(first["type"], "delta"); - // Drop the socket without a close handshake; the server notices when - // a later delta send fails. - drop(socket); - - // The tape write follows the failed send, so poll for it. - let mut events: Vec = Vec::new(); - for _ in 0..100 { - if let Ok(raw) = std::fs::read_to_string(tape_dir.path().join("tape.jsonl")) - && !raw.trim().is_empty() - { - events = raw - .lines() - .map(|line| serde_json::from_str(line).expect("the tape line is valid JSON")) - .collect(); - break; - } - tokio::time::sleep(std::time::Duration::from_millis(50)).await; - } - assert_eq!(events.len(), 1, "a mid-stream disconnect tapes one event"); - assert_eq!( - events[0]["response"]["error"], "client disconnected mid-stream", - "the disconnect is taped as an error note" - ); - let partial = events[0]["response"]["content"] - .as_str() - .expect("the partial content is a string"); - assert!( - partial.starts_with("x0"), - "the partial content is taped alongside: {partial:?}" - ); - } - - #[tokio::test] - async fn a_declined_stream_sends_an_error_frame_and_tapes_the_envelope() { - let base_url = spawn_gateway( - Router::new().route("/v1/chat/completions", post(mock_chat_declines_stream)), - ) - .await; - let (url, tape_dir, _state) = spawn_chat_server(&base_url).await; - let (mut socket, _) = tokio_tungstenite::connect_async(&url) - .await - .expect("connect to /ws"); - send_chat(&mut socket).await; - - let frame = read_non_status_frame(&mut socket).await; - assert_eq!(frame["type"], "error"); - assert_eq!(frame["message"], "model unloaded"); - - let events = tape_events(&tape_dir); - assert_eq!(events.len(), 1, "a declined stream tapes exactly one event"); - assert_eq!( - events[0]["response"]["error"]["code"], "upstream_unavailable", - "the gateway's own envelope is taped" - ); - socket.close(None).await.expect("close the socket"); - } - - #[tokio::test] - async fn malformed_frames_are_answered_with_error_frames() { - let (url, _tape_dir, _state) = spawn_chat_server("http://127.0.0.1:1").await; - let (mut socket, _) = tokio_tungstenite::connect_async(&url) - .await - .expect("connect to /ws"); - - for bad in [ - "not json", - r#"{"type":"bogus"}"#, - r#"{"type":"chat","model":"test-model"}"#, - ] { - socket - .send(tungstenite::Message::Text(bad.into())) - .await - .expect("the frame is sent"); - let frame = read_non_status_frame(&mut socket).await; - assert_eq!( - frame["type"], "error", - "a malformed frame is answered, not fatal: {bad}" - ); - } - // The session survives: a well-formed frame still gets through to - // the (unreachable) gateway and answers with its own error. - send_chat(&mut socket).await; - let frame = read_non_status_frame(&mut socket).await; - assert_eq!(frame["type"], "error"); - socket.close(None).await.expect("close the socket"); - } - - #[tokio::test] - async fn status_updates_reach_connected_sessions_as_status_frames() { - let (url, _tape_dir, state) = spawn_chat_server("http://127.0.0.1:1").await; - let (mut socket, _) = tokio_tungstenite::connect_async(&url) - .await - .expect("connect to /ws"); - // A malformed frame's error reply proves the session's inbound loop - // is running, which means the status subscription before it is live. - socket - .send(tungstenite::Message::Text("not json".into())) - .await - .expect("the frame is sent"); - let reply = read_frame(&mut socket).await; - assert_eq!(reply["type"], "error"); - - state.status().emit(StatusBarUpdate { - label: "Downloading model".to_string(), - description: "ggml-large-v3.bin".to_string(), - progress: Some(Progress { - current: 1, - total: 2, - }), - severity: Severity::Info, - activity: Activity::Generating, - }); - - let frame = read_frame(&mut socket).await; - assert_eq!( - frame, - serde_json::json!({ - "type": "status", - "label": "Downloading model", - "description": "ggml-large-v3.bin", - "progress": {"current": 1, "total": 2}, - "severity": "info", - "activity": "generating", - }), - "the update arrives as one status frame" - ); - socket.close(None).await.expect("close the socket"); - } - - #[tokio::test] - async fn sequential_chats_on_one_socket_both_complete() { - let base_url = - spawn_gateway(Router::new().route("/v1/chat/completions", post(mock_chat_stream))) - .await; - let (url, tape_dir, _state) = spawn_chat_server(&base_url).await; - let (mut socket, _) = tokio_tungstenite::connect_async(&url) - .await - .expect("connect to /ws"); - - for round in 1..=2 { - let frame = serde_json::json!({ - "type": "chat", - "id": round, - "model": "test-model", - "messages": [{"role": "user", "content": "ping"}], - }) - .to_string(); - socket - .send(tungstenite::Message::Text(frame.into())) - .await - .expect("the chat frame is sent"); - let first = read_non_status_frame(&mut socket).await; - assert_eq!( - first, - serde_json::json!({"type": "delta", "content": "po", "id": round}), - "round {round}: the first delta carries the request id" - ); - let second = read_non_status_frame(&mut socket).await; - assert_eq!( - second, - serde_json::json!({"type": "delta", "content": "ng", "id": round}) - ); - let third = read_non_status_frame(&mut socket).await; - assert_eq!(third, serde_json::json!({"type": "done", "id": round})); - } - - let events = tape_events(&tape_dir); - assert_eq!(events.len(), 2, "one tape event per chat frame"); - assert!( - events.iter().all(|event| event["response"] == "pong"), - "both rounds taped the assembled response" - ); - socket.close(None).await.expect("close the socket"); - } - - #[tokio::test] - async fn a_gateway_known_down_short_circuits_chat_with_an_error_frame() { - let (url, tape_dir, state) = spawn_chat_server("http://127.0.0.1:1").await; - state.health().publish(false); - let (mut socket, _) = tokio_tungstenite::connect_async(&url) - .await - .expect("connect to /ws"); - let frame = serde_json::json!({ - "type": "chat", - "id": 7, - "model": "test-model", - "messages": [{"role": "user", "content": "ping"}], - }) - .to_string(); - socket - .send(tungstenite::Message::Text(frame.into())) - .await - .expect("the chat frame is sent"); - - let reply = read_non_status_frame(&mut socket).await; - assert_eq!( - reply, - serde_json::json!({"type": "error", "message": "Gateway unreachable", "id": 7}), - "the chat fails fast, with the request id echoed" - ); - socket.close(None).await.expect("close the socket"); - let raw = - std::fs::read_to_string(tape_dir.path().join("tape.jsonl")).expect("the tape exists"); - assert!( - raw.trim().is_empty(), - "no upstream attempt means no tape event" - ); - } - - 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 reconnect push test. - async fn mock_models() -> Response { - ([(header::CONTENT_TYPE, "application/json")], CATALOG).into_response() - } - - #[tokio::test] - #[ignore = "flaky on CI: the catalog push races the heartbeat transition and never arrives on slow runners"] - async fn a_gateway_reconnect_pushes_the_refreshed_catalog_to_sessions() { - let healthy = Arc::new(AtomicBool::new(false)); - let base_url = spawn_gateway( - Router::new() - .route("/health", get(flippable_health)) - .route("/v1/models", get(mock_models)) - .with_state(Arc::clone(&healthy)), - ) - .await; - let (url, _tape_dir, state) = spawn_chat_server(&base_url).await; - let heartbeat = crate::heartbeat::spawn( - state.gateway_client().clone(), - state.status(), - state.health().clone(), - state.catalog(), - Duration::from_millis(25), - ); - let (mut socket, _) = tokio_tungstenite::connect_async(&url) - .await - .expect("connect to /ws"); - // A malformed frame's error reply proves the session's tasks - - // including its catalog subscription - are live before the flip. - socket - .send(tungstenite::Message::Text("not json".into())) - .await - .expect("the frame is sent"); - let reply = read_frame(&mut socket).await; - assert_eq!(reply["type"], "error"); - - healthy.store(true, Ordering::Relaxed); - // Status frames (the "Connected to gateway" transition) interleave - // with the push; read until the models frame arrives. - let frame = loop { - let frame = tokio::time::timeout(Duration::from_secs(30), read_frame(&mut socket)) - .await - .expect("frames keep arriving within the deadline"); - if frame["type"] == "models" { - break frame; - } - }; - assert_eq!( - frame, - serde_json::json!({ - "type": "models", - "models": [{"id": "test-model", "object": "model", "owned_by": "promptforge"}], - }), - "the refreshed catalog arrives as one models frame" - ); - socket.close(None).await.expect("close the socket"); - heartbeat.shutdown().await; - } - - #[tokio::test] - async fn a_chat_reports_submitting_then_streaming() { - let base_url = - spawn_gateway(Router::new().route("/v1/chat/completions", post(mock_chat_stream))) - .await; - let (url, _tape_dir, _state) = spawn_chat_server(&base_url).await; - let (mut socket, _) = tokio_tungstenite::connect_async(&url) - .await - .expect("connect to /ws"); - send_chat(&mut socket).await; - - let mut labels: Vec = Vec::new(); - loop { - let frame = read_frame(&mut socket).await; - match frame["type"].as_str() { - Some("status") => labels.push( - frame["label"] - .as_str() - .expect("a status frame carries a label") - .to_string(), - ), - Some("done") => break, - _ => {} - } - } - assert!( - labels.iter().any(|label| label.contains("Submitting")), - "a Submitting status frame arrived: {labels:?}" - ); - assert!( - labels.iter().any(|label| label.contains("Streaming")), - "a Streaming status frame arrived: {labels:?}" - ); - socket.close(None).await.expect("close the socket"); - } -} diff --git a/crates/promptforge-wb-server/src/heartbeat.rs b/crates/promptforge-wb-server/src/heartbeat.rs deleted file mode 100644 index b8bb7926..00000000 --- a/crates/promptforge-wb-server/src/heartbeat.rs +++ /dev/null @@ -1,468 +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`]) and loops on the fixed -//! [`HEARTBEAT_INTERVAL`]: each tick probes the gateway through -//! [`GatewayClient::health`] and publishes the outcome to the shared -//! [`GatewayHealth`] flag the gateway-dependent routes read. 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. -//! -//! 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::catalog::CatalogBus; -use crate::gateway::GatewayClient; -use crate::status::{Activity, StatusBus}; - -/// How often the heartbeat probes the gateway. Hardcoded for now; a -/// configuration knob may follow once someone needs one. -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(crate) 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(crate) 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(crate) struct Heartbeat { - stop: Option>, - task: Option>, -} - -impl Heartbeat { - /// Signals the heartbeat 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 heartbeat loop against `client`, reporting transitions -/// through `status` and publishing reachability to `health`. A transition -/// back to reachable also re-fetches the model catalog and pushes it on -/// `catalog`. The first probe runs immediately, before the first interval -/// elapses. -pub(crate) fn spawn( - client: GatewayClient, - status: StatusBus, - health: GatewayHealth, - catalog: CatalogBus, - interval: Duration, -) -> Heartbeat { - let (stop, mut stopped) = oneshot::channel(); - let task = tokio::spawn(async move { - run(&client, &status, &health, &catalog, interval, &mut stopped).await; - }); - Heartbeat { - stop: Some(stop), - task: Some(task), - } -} - -/// The probe loop: one probe per interval, a status update per transition, -/// and the stop signal wins over the tick, an in-flight probe, and an -/// in-flight catalog refresh. -async fn run( - client: &GatewayClient, - status: &StatusBus, - health: &GatewayHealth, - catalog: &CatalogBus, - interval: Duration, - stop: &mut oneshot::Receiver<()>, -) { - let mut ticks = tokio::time::interval(interval); - // A probe slower than the interval (the health timeout bounds it at two - // seconds) must not bunch the missed ticks into a catch-up burst. - ticks.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); - let mut last: Option = None; - loop { - tokio::select! { - _ = &mut *stop => break, - _ = ticks.tick() => {} - } - let reachable = tokio::select! { - _ = &mut *stop => break, - reachable = client.health() => reachable, - }; - health.publish(reachable); - if last == Some(reachable) { - continue; - } - let previous = last.replace(reachable); - if reachable { - status.info( - "Connected to gateway", - "the gateway answers its health probe", - Activity::General, - ); - // A gateway that was down and answers again may serve a - // different catalog than before the outage. The initial - // connect pushes nothing: a fresh UI fetches the catalog - // itself on boot. - if previous == Some(false) { - tokio::select! { - _ = &mut *stop => break, - () = refresh_catalog(client, catalog) => {} - } - } - } else { - status.info( - "Gateway unreachable", - "the gateway does not answer its health probe", - Activity::General, - ); - } - } -} - -/// 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. -async fn refresh_catalog(client: &GatewayClient, catalog: &CatalogBus) { - let response = match client.list_models().await { - Ok(response) => response, - Err(error) => { - tracing::warn!(%error, "catalog refresh after reconnect failed"); - return; - } - }; - if !response.status.is_success() { - tracing::warn!(status = %response.status, "catalog refresh after reconnect was declined"); - return; - } - let body: serde_json::Value = match serde_json::from_slice(&response.body) { - Ok(body) => body, - Err(error) => { - tracing::warn!(%error, "catalog refresh after reconnect was not JSON"); - return; - } - }; - let Some(models) = body.get("data").and_then(serde_json::Value::as_array) else { - tracing::warn!("catalog refresh after reconnect carried no data array"); - return; - }; - catalog.publish(models.clone()); -} - -#[cfg(test)] -mod tests { - use super::*; - - use std::sync::Arc; - use std::sync::atomic::{AtomicBool, 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::CatalogPush; - use crate::status::{Severity, StatusBarUpdate}; - - /// 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() - } - - /// Binds a mock gateway whose `/health` flips with `healthy`, with a - /// static `/v1/models` beside it. - async fn spawn_gateway(healthy: Arc) -> String { - let app = Router::new() - .route("/health", get(flippable_health)) - .route("/v1/models", get(mock_models)) - .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}") - } - - /// Starts a heartbeat against `base_url` on the fast interval, wired to - /// `status` and `catalog`; returns the handle and the shared health - /// flag. - fn heartbeat_on( - base_url: &str, - status: &StatusBus, - catalog: &CatalogBus, - ) -> (Heartbeat, GatewayHealth) { - let client = GatewayClient::new(base_url, "").expect("client builds in tests"); - let health = GatewayHealth::new(); - let heartbeat = spawn( - client, - status.clone(), - health.clone(), - catalog.clone(), - TEST_INTERVAL, - ); - (heartbeat, health) - } - - /// 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:?}" - ); - } - - #[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) = 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) = 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) = 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) = 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 the gateway's data array verbatim" - ); - 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) = 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 the_initial_connect_pushes_no_catalog() { - // A fresh UI fetches the catalog itself on boot; the push exists - // for reconnects only. - 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 status_rx = status.subscribe(); - let mut catalog_rx = catalog.subscribe(); - let (heartbeat, _health) = heartbeat_on(&base_url, &status, &catalog); - - 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(), "no catalog push on the initial connect"); - 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 client = GatewayClient::new("http://127.0.0.1:1", "").expect("client builds in tests"); - let heartbeat = spawn( - client, - status, - GatewayHealth::new(), - CatalogBus::new(), - Duration::from_secs(60), - ); - tokio::time::timeout(Duration::from_secs(5), heartbeat.shutdown()) - .await - .expect("shutdown does not wait out the interval"); - } -} diff --git a/crates/promptforge-wb-server/src/lib.rs b/crates/promptforge-wb-server/src/lib.rs deleted file mode 100644 index b35457a3..00000000 --- a/crates/promptforge-wb-server/src/lib.rs +++ /dev/null @@ -1,34 +0,0 @@ -//! PromptForge Workbench HTTP server. -//! -//! Holds the `workbench.toml` configuration, the PromptForge gateway client, -//! the session tape, and the axum router so `src/main.rs` stays a thin shell. -//! Start at [`Config::load`] for configuration, [`Tape`] for the session -//! tape, and [`router`] for the HTTP API; [`spawn`] runs the whole server -//! in-process on its own thread for embedding binaries. - -mod app; -mod catalog; -mod chat_ws; -mod config; -mod gateway; -mod heartbeat; -mod provision; -mod segment; -mod serve; -mod status; -mod tape; -mod transcribe; -mod voice; - -pub use app::{AppError, AppState, DEFAULT_ADDR, router}; -pub use config::{ - Config, ConfigError, DEFAULT_CONFIG_PATH, DEFAULT_GATEWAY_BASE_URL, DEFAULT_VOICE_INTERVAL_MS, - DEFAULT_VOICE_WINDOW_SECONDS, GatewayConfig, ServerConfig, TapeConfig, VoiceConfig, -}; -pub use gateway::{ - CacheEvent, CacheResponse, ChatRequest, ChatStream, GatewayClient, GatewayError, - GatewayResponse, SsePayloadStream, -}; -pub use serve::{ServerHandle, SpawnError, spawn}; -pub use tape::{Tape, TapeError, TapeEvent}; -pub use transcribe::TranscribeError; diff --git a/crates/promptforge-wb-server/src/serve.rs b/crates/promptforge-wb-server/src/serve.rs deleted file mode 100644 index 99f73fe3..00000000 --- a/crates/promptforge-wb-server/src/serve.rs +++ /dev/null @@ -1,339 +0,0 @@ -//! In-process serving: the workbench server on a dedicated thread. -//! -//! [`spawn`] builds the shared state, binds the listener, and serves on its -//! own thread with its own tokio runtime, so an embedding binary (the -//! desktop shell, or the server binary itself) keeps its main thread. The -//! call blocks until the listener is bound - that bind is the readiness -//! signal - and the returned [`ServerHandle`] carries the base URL and a -//! graceful-shutdown switch. - -use std::sync::mpsc; -use std::thread::JoinHandle; - -use crate::app::{AppError, AppState, router}; -use crate::config::Config; -use crate::heartbeat; -use crate::provision; - -/// A running workbench server on its own thread. -/// -/// Dropping the handle without calling [`ServerHandle::shutdown`] still -/// signals the server to stop, but does not wait for it. -#[derive(Debug)] -pub struct ServerHandle { - url: String, - shutdown: Option>, - thread: Option>>, -} - -impl ServerHandle { - /// Returns the base URL the server is listening on, for example - /// `http://127.0.0.1:7910`. - #[must_use] - pub fn url(&self) -> &str { - &self.url - } - - /// Signals graceful shutdown and waits for the server thread to finish. - /// - /// # Errors - /// Returns `std::io::Error` if the server stopped with an error or the - /// server thread panicked. - pub fn shutdown(mut self) -> std::io::Result<()> { - if let Some(shutdown) = self.shutdown.take() { - let _ = shutdown.send(()); - } - self.join_inner() - } - - /// Waits for the server thread to finish on its own, without signaling - /// shutdown. - /// - /// # Errors - /// Returns `std::io::Error` if the server stopped with an error or the - /// server thread panicked. - pub fn join(mut self) -> std::io::Result<()> { - self.join_inner() - } - - fn join_inner(&mut self) -> std::io::Result<()> { - let Some(thread) = self.thread.take() else { - return Ok(()); - }; - match thread.join() { - Ok(result) => result, - Err(_) => Err(std::io::Error::other("workbench server thread panicked")), - } - } -} - -impl Drop for ServerHandle { - fn drop(&mut self) { - if let Some(shutdown) = self.shutdown.take() { - let _ = shutdown.send(()); - } - } -} - -/// A failure to start the in-process workbench server. -#[derive(Debug, thiserror::Error)] -#[non_exhaustive] -pub enum SpawnError { - /// The shared state (gateway client, tape, voice engine) could not be - /// built. - #[non_exhaustive] - #[error("build shared state")] - State(#[source] AppError), - - /// An I/O failure: the listener bind failed, the bound address could - /// not be read, or the server thread could not be spawned. - #[non_exhaustive] - #[error("start workbench server")] - Io(#[source] std::io::Error), -} - -/// Spawns the workbench server on a dedicated thread and blocks until the -/// listener is bound. -/// -/// The bound listener is the readiness signal: when this returns `Ok`, the -/// server is accepting connections at [`ServerHandle::url`]. -/// -/// # Errors -/// Returns [`SpawnError::State`] if the shared state cannot be built (a bad -/// tape path or whisper model), and [`SpawnError::Io`] if the bind fails or -/// the server thread cannot be spawned. -pub fn spawn(config: Config) -> Result { - let (ready_tx, ready_rx) = mpsc::channel(); - let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); - let thread = std::thread::Builder::new() - .name("promptforge-wb-server".to_string()) - .spawn(move || serve_thread(config, ready_tx, shutdown_rx)) - .map_err(SpawnError::Io)?; - match ready_rx.recv() { - Ok(Ok(url)) => Ok(ServerHandle { - url, - shutdown: Some(shutdown_tx), - thread: Some(thread), - }), - Ok(Err(error)) => { - let _ = thread.join(); - Err(error) - } - Err(_) => { - let _ = thread.join(); - Err(SpawnError::Io(std::io::Error::other( - "workbench server thread exited before binding", - ))) - } - } -} - -/// The server thread's body: build a runtime, build state, bind, signal -/// readiness through `ready`, then serve until `shutdown` resolves. -/// -/// Startup failures are reported through `ready`; only serving failures -/// become the thread's return value. -fn serve_thread( - config: Config, - ready: mpsc::Sender>, - shutdown: tokio::sync::oneshot::Receiver<()>, -) -> std::io::Result<()> { - let runtime = match tokio::runtime::Builder::new_multi_thread() - .enable_all() - .build() - { - Ok(runtime) => runtime, - Err(error) => { - let _ = ready.send(Err(SpawnError::Io(error))); - return Ok(()); - } - }; - runtime.block_on(async move { - let state = match AppState::new(&config) { - Ok(state) => state, - Err(error) => { - let _ = ready.send(Err(SpawnError::State(error))); - return Ok(()); - } - }; - let listener = match reuse_bind(&config.server.bind) { - Ok(listener) => listener, - Err(error) => { - let _ = ready.send(Err(SpawnError::Io(error))); - return Ok(()); - } - }; - let address = listener.local_addr()?; - let _ = ready.send(Ok(format!("http://{address}"))); - // The heartbeat and the voice provisioning task start with serving - // and stop inside the same graceful-shutdown signal, so they never - // outlive the server. - let heartbeat = heartbeat::spawn( - state.gateway_client().clone(), - state.status(), - state.health().clone(), - state.catalog(), - heartbeat::HEARTBEAT_INTERVAL, - ); - let provision = provision::spawn( - state.gateway_client().clone(), - state.status(), - state.health().clone(), - state.voice_slot(), - config.voice.clone(), - ); - axum::serve(listener, router(state)) - .with_graceful_shutdown(async move { - let _ = shutdown.await; - heartbeat.shutdown().await; - provision.shutdown().await; - }) - .await - }) -} - -/// Binds a TCP listener with `SO_REUSEADDR` so a restart doesn't fail on -/// TIME_WAIT sockets from the previous instance. -fn reuse_bind(address: &str) -> std::io::Result { - let addr: std::net::SocketAddr = address - .parse() - .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?; - let socket = socket2::Socket::new( - socket2::Domain::for_address(addr), - socket2::Type::STREAM, - Some(socket2::Protocol::TCP), - )?; - socket.set_reuse_address(true)?; - socket.set_nonblocking(true)?; - socket.bind(&addr.into())?; - socket.listen(1024)?; - tokio::net::TcpListener::from_std(socket.into()) -} - -#[cfg(test)] -mod tests { - use super::*; - - use std::path::Path; - - use crate::config::{GatewayConfig, ServerConfig, TapeConfig, VoiceConfig}; - - fn test_config(bind: &str, tape_dir: &Path) -> Config { - Config { - gateway: GatewayConfig { - base_url: "http://127.0.0.1:1".to_string(), - api_key: "test-key".to_string(), - }, - tape: TapeConfig { - path: tape_dir.join("tape.jsonl"), - }, - server: ServerConfig { - bind: bind.to_string(), - open_browser: false, - }, - voice: VoiceConfig::default(), - } - } - - #[tokio::test] - async fn readiness_means_the_health_endpoint_answers() { - let dir = tempfile::TempDir::new().expect("tempdir"); - let server = spawn(test_config("127.0.0.1:0", dir.path())).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(test_config("127.0.0.1:0", dir.path())).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 configured-but-missing voice model with no source URL degrades to - /// disabled voice with a status-bar explanation; it must never fail - /// startup. - #[tokio::test] - async fn a_missing_voice_model_without_a_source_still_boots() { - let dir = tempfile::TempDir::new().expect("tempdir"); - let mut config = test_config("127.0.0.1:0", dir.path()); - config.voice.interim_model = std::path::PathBuf::from("definitely-missing-model.bin"); - let server = spawn(config).expect("server spawns with voice degraded"); - 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); - - server.shutdown().expect("graceful shutdown succeeds"); - } - - #[tokio::test] - async fn shutdown_releases_the_bound_port() { - let dir = tempfile::TempDir::new().expect("tempdir"); - let server = spawn(test_config("127.0.0.1:0", dir.path())).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 an_unopenable_tape_fails_spawn_with_state_error() { - let dir = tempfile::TempDir::new().expect("tempdir"); - let config = test_config("127.0.0.1:0", &dir.path().join("missing")); - let error = spawn(config).expect_err("an unopenable tape must fail spawn"); - assert!( - matches!(error, SpawnError::State(_)), - "expected State, got {error:?}" - ); - } - - #[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(config).expect_err("a taken port must fail spawn"); - assert!( - matches!(error, SpawnError::Io(_)), - "expected Io, got {error:?}" - ); - } -} diff --git a/crates/promptforge-wb-server/src/transcribe.rs b/crates/promptforge-wb-server/src/transcribe.rs deleted file mode 100644 index 32579e05..00000000 --- a/crates/promptforge-wb-server/src/transcribe.rs +++ /dev/null @@ -1,1291 +0,0 @@ -//! Whisper transcription on dedicated worker threads. -//! -//! [`VoiceEngine`] owns two worker threads: the interim worker holds the -//! streaming model and transcribes sliding windows, and the final-pass -//! worker ([`FinalTranscriber`], present when `[voice].final_model` is -//! configured) holds the larger model and transcribes completed speech -//! segments in the background while the user is still talking. Callers hand -//! owned sample buffers through channels and await transcripts on oneshots, -//! so the blocking CPU-bound inference never touches the tokio executor. -//! The pure helpers ([`rms`], [`is_silence`], [`tail`]) are the session's -//! silence gate: whisper hallucinates plausible text on silent input, so -//! quiet windows are never sent to the model. - -use std::path::{Path, PathBuf}; -use std::sync::{Arc, PoisonError, RwLock}; -use std::time::Duration; - -use whisper_rs::{FullParams, SamplingStrategy, WhisperContext, WhisperContextParameters}; - -use crate::config::VoiceConfig; - -/// PCM sample rate the voice wire format and whisper both require. -pub(crate) const SAMPLE_RATE: usize = 16_000; - -/// Windows below this RMS are treated as silence and never transcribed. -/// -/// 0.001 is -60 dBFS: above the noise floor of a browser-suppressed mic -/// stream, far below conversational speech (typically 0.02 and up). -pub(crate) const SILENCE_RMS: f64 = 0.001; - -/// Minimum audio the interim loop bothers to transcribe; shorter fragments -/// decode to garbage often enough that gating them is cheaper than filtering -/// their output. -pub(crate) const MIN_WINDOW_SAMPLES: usize = SAMPLE_RATE / 2; - -/// Maximum conditioning prompt handed to the final pass, in chars. Whisper -/// keeps at most half its text context for the prompt (224 tokens), and -/// four chars per token is a conservative English estimate; the tail of the -/// accumulated transcript is what matters for continuity, so the cap trims -/// from the front. -const MAX_PROMPT_CHARS: usize = 800; - -/// Whisper's prompt budget in tokens: half the text context -/// (`whisper_n_text_ctx / 2`). A prompt longer than this is truncated by -/// whisper.cpp from the front, which would silently drop a glossary -/// prefix, so prompts are fitted to the budget before being set. -const MAX_PROMPT_TOKENS: usize = 224; - -/// Token budget for the glossary on the final-pass worker; the rest of the -/// prompt budget is reserved for the segment-conditioning transcript. The -/// interim worker passes no transcript and fits its glossary to the full -/// budget. -const GLOSSARY_TOKEN_BUDGET: usize = MAX_PROMPT_TOKENS / 2; - -/// Root-mean-square amplitude of a PCM buffer. -#[expect( - clippy::cast_precision_loss, - reason = "audio buffers are far below 2^53 samples" -)] -pub(crate) fn rms(samples: &[f32]) -> f64 { - if samples.is_empty() { - return 0.0; - } - let energy: f64 = samples.iter().map(|&s| f64::from(s) * f64::from(s)).sum(); - (energy / samples.len() as f64).sqrt() -} - -/// Returns true when the buffer is quiet enough that whisper would -/// hallucinate rather than transcribe. -pub(crate) fn is_silence(samples: &[f32]) -> bool { - rms(samples) < SILENCE_RMS -} - -/// Returns the trailing `window` samples of `buffer`, or the whole buffer -/// when it is shorter than the window. -pub(crate) fn tail(buffer: &[f32], window: usize) -> &[f32] { - &buffer[buffer.len().saturating_sub(window)..] -} - -/// The per-server voice engine: the interim and final-pass whisper workers -/// plus the interim loop's window and cadence, built once at startup from -/// `[voice]` in `workbench.toml`. -#[derive(Debug)] -pub(crate) struct VoiceEngine { - transcriber: Transcriber, - final_pass: Option, - window_samples: usize, - interval: Duration, -} - -impl VoiceEngine { - /// Loads the interim model, and the final model when configured, each - /// onto a fresh worker thread. - /// - /// # Errors - /// Returns [`TranscribeError::InvalidConfig`] when the window or interval - /// is zero, [`TranscribeError::LoadModel`] when a model file cannot be - /// loaded, and [`TranscribeError::SpawnWorker`] when a worker thread - /// cannot be started. - pub(crate) fn new(config: &VoiceConfig) -> Result { - if config.window_seconds == 0 { - return Err(TranscribeError::InvalidConfig( - "voice.window_seconds must be at least 1".to_string(), - )); - } - if config.interval_ms == 0 { - return Err(TranscribeError::InvalidConfig( - "voice.interval_ms must be at least 1".to_string(), - )); - } - let window_seconds = usize::try_from(config.window_seconds).map_err(|_| { - TranscribeError::InvalidConfig("voice.window_seconds is too large".to_string()) - })?; - let Some(window_samples) = window_seconds.checked_mul(SAMPLE_RATE) else { - return Err(TranscribeError::InvalidConfig( - "voice.window_seconds is too large".to_string(), - )); - }; - let transcriber = Transcriber::load(&config.interim_model, &config.vocabulary)?; - let final_pass = if config.final_model.as_os_str().is_empty() { - None - } else { - Some(FinalTranscriber::load( - &config.final_model, - &config.vocabulary, - )?) - }; - Ok(Self { - transcriber, - final_pass, - window_samples, - interval: Duration::from_millis(config.interval_ms), - }) - } - - /// Whether the final pass is configured. Segmentation and - /// crystallization only happen when it is: without it nothing can - /// crystallize, so the segmenter must not consume audio the interim - /// model still needs. - pub(crate) fn has_final_pass(&self) -> bool { - self.final_pass.is_some() - } - - /// Whether the final pass is absent. A test seam for the startup - /// degradation policy, which drops an unsourced missing final model. - #[cfg(test)] - pub(crate) fn final_pass_absent_for_test(&self) -> bool { - !self.has_final_pass() - } - - /// Samples in the sliding interim window. - pub(crate) fn window_samples(&self) -> usize { - self.window_samples - } - - /// Cadence of the interim loop. - pub(crate) fn interval(&self) -> Duration { - self.interval - } - - /// Transcribes one 16 kHz mono f32 buffer, returning the trimmed text. - /// - /// # Errors - /// Returns [`TranscribeError::Inference`] when the model rejects the - /// audio and [`TranscribeError::WorkerGone`] when the worker thread has - /// exited. - pub(crate) async fn transcribe(&self, samples: Vec) -> Result { - self.transcriber.transcribe(samples).await - } - - /// Starts a new take on the final-pass worker, discarding the previous - /// take's accumulated transcript and installing `on_segment` as the - /// take's completion channel: each background segment's text is sent on - /// it as the segment finishes. A no-op without a final model. - pub(crate) fn final_reset(&self, on_segment: std::sync::mpsc::Sender) { - if let Some(final_pass) = &self.final_pass { - final_pass.reset(on_segment); - } - } - - /// Queues a completed speech segment for background final-pass - /// transcription, conditioned on the take's accumulated transcript. A - /// no-op without a final model. - pub(crate) fn final_submit(&self, samples: Vec) { - if let Some(final_pass) = &self.final_pass { - final_pass.submit(samples); - } - } - - /// Queues the take's unprocessed tail and awaits the tail's own - /// transcription - not the take's full assembled transcript, which the - /// session already holds as crystallized segment text. The text is - /// empty when the tail is silent or too short to decode (the worker - /// skips those rather than hallucinating). Returns `None` when no - /// final model is configured and the caller should fall back to the - /// interim model. - /// - /// # Errors - /// Returns [`TranscribeError::Inference`] when the model rejects the - /// audio and [`TranscribeError::WorkerGone`] when the worker thread has - /// exited. - pub(crate) async fn final_finish( - &self, - samples: Vec, - ) -> Option> { - match &self.final_pass { - None => None, - Some(final_pass) => Some(final_pass.finish(samples).await), - } - } -} - -/// Shared holder for the voice engine: empty until the engine loads, then -/// filled exactly once - at startup from local model files, or later by the -/// provisioning task once the gateway cache has provided them. -/// -/// Reads happen per `/voice` session upgrade and writes are one-shot, so a -/// std `RwLock` suffices; no guard ever crosses an `.await`. Lock poisoning -/// recovers the value, matching the tape's posture: a panicking writer -/// cannot wedge voice for the process's life. -#[derive(Debug, Clone, Default)] -pub(crate) struct VoiceSlot { - engine: Arc>>>, -} - -impl VoiceSlot { - /// The engine, when it has loaded. - pub(crate) fn engine(&self) -> Option> { - self.engine - .read() - .unwrap_or_else(PoisonError::into_inner) - .clone() - } - - /// Whether the engine has loaded. - pub(crate) fn is_active(&self) -> bool { - self.engine - .read() - .unwrap_or_else(PoisonError::into_inner) - .is_some() - } - - /// Installs a loaded engine. - pub(crate) fn activate(&self, engine: VoiceEngine) { - *self.engine.write().unwrap_or_else(PoisonError::into_inner) = Some(Arc::new(engine)); - } -} - -/// One transcription request handed to the worker thread. -struct Job { - samples: Vec, - reply: tokio::sync::oneshot::Sender>, -} - -/// Handle to the whisper worker thread. -#[derive(Debug)] -pub(crate) struct Transcriber { - job_tx: std::sync::mpsc::Sender, -} - -impl Transcriber { - /// Spawns the worker thread and blocks until the model is loaded or the - /// load fails. - /// - /// # Errors - /// Returns [`TranscribeError::LoadModel`] when the model file cannot be - /// loaded and [`TranscribeError::SpawnWorker`] when the thread cannot be - /// started. - fn load(model_path: &Path, vocabulary: &[String]) -> Result { - let (job_tx, job_rx) = std::sync::mpsc::channel::(); - let (init_tx, init_rx) = std::sync::mpsc::sync_channel(1); - let path = model_path.to_path_buf(); - let vocabulary = vocabulary.to_vec(); - std::thread::Builder::new() - .name("whisper-transcribe".to_string()) - .spawn(move || worker_loop(&path, &vocabulary, &job_rx, &init_tx)) - .map_err(TranscribeError::SpawnWorker)?; - init_rx.recv().map_err(|_| TranscribeError::WorkerGone)??; - Ok(Self { job_tx }) - } - - /// Queues `samples` for transcription and awaits the trimmed text. - async fn transcribe(&self, samples: Vec) -> Result { - let (reply, reply_rx) = tokio::sync::oneshot::channel(); - self.job_tx - .send(Job { samples, reply }) - .map_err(|_| TranscribeError::WorkerGone)?; - reply_rx.await.map_err(|_| TranscribeError::WorkerGone)? - } -} - -/// The worker thread's body: load the model, fit the glossary prompt, then -/// transcribe jobs in arrival order until every sender is dropped. -fn worker_loop( - path: &Path, - vocabulary: &[String], - job_rx: &std::sync::mpsc::Receiver, - init_tx: &std::sync::mpsc::SyncSender>, -) { - let Some((ctx, mut state)) = load_state(path, init_tx) else { - return; - }; - // The interim pass carries no transcript, so the glossary gets the full - // prompt budget. - let glossary = fit_glossary(&ctx, vocabulary, MAX_PROMPT_TOKENS); - while let Ok(job) = job_rx.recv() { - // The receiver may be gone (session closed mid-pass); the transcript - // is computed anyway and the send failure ignored. - let _ = job.reply.send(transcribe_blocking( - &mut state, - &job.samples, - glossary.as_deref(), - true, - )); - } -} - -/// Loads a whisper context and state from `path`, reporting the outcome on -/// `init_tx` (which the spawning `load` blocks on). Returns `None` after -/// reporting a failure, or when the spawner is already gone. -fn load_state( - path: &Path, - init_tx: &std::sync::mpsc::SyncSender>, -) -> Option<(WhisperContext, whisper_rs::WhisperState)> { - let loaded = WhisperContext::new_with_params(path, WhisperContextParameters::default()) - .map_err(|source| TranscribeError::LoadModel { - path: path.to_path_buf(), - source: Box::new(source), - }) - .and_then(|ctx| { - ctx.create_state() - .map(|state| (ctx, state)) - .map_err(|source| TranscribeError::LoadModel { - path: path.to_path_buf(), - source: Box::new(source), - }) - }); - match loaded { - Ok(pair) => { - let _ = init_tx.send(Ok(())); - Some(pair) - } - Err(error) => { - let _ = init_tx.send(Err(error)); - None - } - } -} - -/// The trailing `max` bytes of `text`, cut at a char boundary. -fn tail_chars(text: &str, max: usize) -> &str { - let mut start = text.len().saturating_sub(max); - while !text.is_char_boundary(start) { - start += 1; - } - &text[start..] -} - -/// The trailing `MAX_PROMPT_CHARS` chars of `prompt` with null bytes -/// stripped: whisper's prompt buffer is bounded, and `set_initial_prompt` -/// panics on null bytes, which a model transcript could in principle -/// contain. -fn sanitize_prompt(prompt: &str) -> String { - let cleaned: String = prompt.chars().filter(|&c| c != '\0').collect(); - tail_chars(&cleaned, MAX_PROMPT_CHARS).to_string() -} - -/// Formats `vocabulary` as a whisper conditioning prompt in glossary form: -/// `Glossary: a, b, c.` Terms are trimmed and null bytes stripped (whisper -/// tokenization rejects them); a vocabulary with no usable terms yields -/// `None`. The glossary format is a soft probabilistic bias, and measurably -/// outperforms a raw keyword list. -pub(crate) fn glossary_prompt(vocabulary: &[String]) -> Option { - let terms: Vec = vocabulary - .iter() - .map(|term| { - term.trim() - .chars() - .filter(|&c| c != '\0') - .collect::() - }) - .filter(|term| !term.is_empty()) - .collect(); - if terms.is_empty() { - return None; - } - Some(format!("Glossary: {}.", terms.join(", "))) -} - -/// Token count of `text` under the model's tokenizer, or `usize::MAX` -/// when tokenization fails (for example on null bytes, though callers -/// strip those first). -/// -/// whisper-rs's `tokenize` cannot be asked "does this fit in N tokens": -/// the underlying `whisper_tokenize` reports overflow by returning the -/// required count, which the wrapper then passes to `Vec::set_len` on a -/// buffer of only `max_tokens` capacity. Tokenizing with one slot per byte -/// (an upper bound on the token count) and reading the real length -/// sidesteps the overflow path entirely. -fn token_count(ctx: &WhisperContext, text: &str) -> usize { - ctx.tokenize(text, text.len().max(1)) - .map_or(usize::MAX, |tokens| tokens.len()) -} - -/// Fits the glossary prompt for `vocabulary` within `budget` whisper tokens -/// (and the prompt char cap), dropping whole terms from the end until it -/// fits. Returns `None` when the vocabulary has no usable terms or no term -/// fits, and logs a warning when terms were dropped. -fn fit_glossary(ctx: &WhisperContext, vocabulary: &[String], budget: usize) -> Option { - let mut len = vocabulary.len(); - let mut fitted = glossary_prompt(vocabulary)?; - while fitted.len() > MAX_PROMPT_CHARS || token_count(ctx, &fitted) > budget { - len -= 1; - if len == 0 { - tracing::warn!("no voice vocabulary term fits the prompt budget"); - return None; - } - fitted = glossary_prompt(&vocabulary[..len])?; - } - if len < vocabulary.len() { - tracing::warn!( - kept = len, - dropped = vocabulary.len() - len, - "voice vocabulary truncated to fit whisper's prompt budget" - ); - } - Some(fitted) -} - -/// Builds the final pass's conditioning prompt: the fitted glossary -/// followed by as much of the accumulated transcript's tail as fits within -/// the char cap and whisper's 224-token prompt budget. The transcript trims -/// from the front (its tail carries the continuity); the glossary is never -/// trimmed here - it was fitted to its own budget at load. -fn final_prompt(ctx: &WhisperContext, glossary: Option<&str>, transcript: &str) -> String { - let Some(glossary) = glossary else { - return sanitize_prompt(transcript); - }; - let cleaned: String = transcript.chars().filter(|&c| c != '\0').collect(); - let char_budget = MAX_PROMPT_CHARS.saturating_sub(glossary.len() + 1); - let mut tail = tail_chars(&cleaned, char_budget).trim_start(); - loop { - if tail.is_empty() { - return glossary.to_string(); - } - let combined = format!("{glossary} {tail}"); - if token_count(ctx, &combined) <= MAX_PROMPT_TOKENS { - return combined; - } - // Drop the tail's first word and retry; a single oversized word is - // dropped whole, which ends the loop on the next iteration. - tail = match tail.find(char::is_whitespace) { - Some(index) => tail[index..].trim_start(), - None => "", - }; - } -} - -/// Runs one blocking whisper pass over `samples` and concatenates the -/// segments. `prompt`, when non-empty after sanitizing, conditions the -/// decoder on the take's transcript so far; `single_segment` forces the -/// whole buffer into one decoding pass (the interim sliding-window case). -fn transcribe_blocking( - state: &mut whisper_rs::WhisperState, - samples: &[f32], - prompt: Option<&str>, - single_segment: bool, -) -> Result { - let mut params = FullParams::new(SamplingStrategy::Greedy { best_of: 1 }); - params.set_language(Some("en")); - params.set_translate(false); - // Decoder state never carries across passes: conditioning travels only - // through the explicit prompt, or a hallucination would compound. - params.set_no_context(true); - params.set_single_segment(single_segment); - params.set_no_timestamps(true); - params.set_print_special(false); - params.set_print_progress(false); - params.set_print_realtime(false); - params.set_print_timestamps(false); - params.set_suppress_blank(true); - params.set_suppress_nst(true); - if let Some(prompt) = prompt { - let prompt = sanitize_prompt(prompt); - if !prompt.is_empty() { - params.set_initial_prompt(&prompt); - } - } - state - .full(params, samples) - .map_err(|source| TranscribeError::Inference(Box::new(source)))?; - let mut text = String::new(); - for segment in state.as_iter() { - let piece = segment - .to_str_lossy() - .map_err(|source| TranscribeError::Inference(Box::new(source)))?; - text.push_str(&piece); - } - Ok(text.trim().to_string()) -} - -/// One take's final-pass state: the large model's whisper context and state -/// plus the take's accumulated transcript, which conditions each new -/// segment so domain vocabulary and phrasing survive segmentation. The -/// glossary prompt (fitted at load from `[voice].vocabulary`) biases every -/// segment toward the configured domain terms. -#[derive(Debug)] -pub(crate) struct FinalPass { - ctx: WhisperContext, - state: whisper_rs::WhisperState, - /// The fitted glossary prompt, `None` when no vocabulary is configured. - glossary: Option, - /// Every segment transcript so far, joined by single spaces. - transcript: String, - /// The conditioning prompt used on the most recent segment, kept so - /// tests can observe that conditioning actually happened. - last_prompt: String, -} - -impl FinalPass { - /// Loads the final model from `path` and fits the vocabulary glossary. - /// - /// # Errors - /// Returns [`TranscribeError::LoadModel`] when the model file cannot be - /// loaded. - fn load(path: &Path, vocabulary: &[String]) -> Result { - let (init_tx, init_rx) = std::sync::mpsc::sync_channel(1); - let Some((ctx, state)) = load_state(path, &init_tx) else { - return match init_rx.recv() { - Ok(Err(error)) => Err(error), - // `load_state` reports every outcome on the channel before - // returning `None`, so a disconnected or Ok(()) result here - // means the invariant broke, not a new failure mode. - _ => Err(TranscribeError::WorkerGone), - }; - }; - let glossary = fit_glossary(&ctx, vocabulary, GLOSSARY_TOKEN_BUDGET); - Ok(Self { - ctx, - state, - glossary, - transcript: String::new(), - last_prompt: String::new(), - }) - } - - /// Forgets the previous take's transcript for a new take. - fn reset(&mut self) { - self.transcript.clear(); - self.last_prompt.clear(); - } - - /// The conditioning prompt the most recent segment was transcribed with. - #[cfg(test)] - pub(crate) fn last_prompt(&self) -> &str { - &self.last_prompt - } - - /// The take's accumulated transcript: every segment so far, joined by - /// single spaces. A test-only observation point for the conditioning - /// chain; the workers consume only each segment's own text. - #[cfg(test)] - pub(crate) fn transcript(&self) -> &str { - &self.transcript - } - - /// Transcribes one segment conditioned on the accumulated transcript, - /// appends the result, and returns the segment's own text. Silent or - /// tiny fragments are skipped (whisper hallucinates on them): the - /// accumulated transcript is left unchanged and `None` comes back. - /// - /// # Errors - /// Returns [`TranscribeError::Inference`] when the model rejects the - /// audio; the accumulated transcript is left unchanged. - fn transcribe_segment(&mut self, samples: &[f32]) -> Result, TranscribeError> { - let mut segment = None; - if samples.len() >= MIN_WINDOW_SAMPLES && !is_silence(samples) { - let prompt = final_prompt(&self.ctx, self.glossary.as_deref(), &self.transcript); - let text = transcribe_blocking(&mut self.state, samples, Some(&prompt), false)?; - if !text.is_empty() { - if !self.transcript.is_empty() { - self.transcript.push(' '); - } - self.transcript.push_str(&text); - segment = Some(text); - } - self.last_prompt = prompt; - } - Ok(segment) - } -} - -/// A command for the final-pass worker thread. -enum FinalJob { - /// Start a new take, discarding the accumulated transcript and - /// installing the take's segment-completion channel. - Reset { - on_segment: std::sync::mpsc::Sender, - }, - /// Transcribe a completed segment (or the closing tail) and reply with - /// the segment's own text, empty when the fragment was skipped. - /// `notify` marks a background submit, whose segment text is also sent - /// on the take's channel; the closing tail reports only through its - /// reply. - Segment { - samples: Vec, - reply: tokio::sync::oneshot::Sender>, - notify: bool, - }, -} - -/// Handle to the final-pass worker thread: the large model transcribing -/// completed segments in the background while a take records. -#[derive(Debug)] -pub(crate) struct FinalTranscriber { - job_tx: std::sync::mpsc::Sender, -} - -impl FinalTranscriber { - /// Spawns the worker thread and blocks until the model is loaded or the - /// load fails. - /// - /// # Errors - /// Returns [`TranscribeError::LoadModel`] when the model file cannot be - /// loaded and [`TranscribeError::SpawnWorker`] when the thread cannot be - /// started. - fn load(model_path: &Path, vocabulary: &[String]) -> Result { - let (job_tx, job_rx) = std::sync::mpsc::channel::(); - let (init_tx, init_rx) = std::sync::mpsc::sync_channel(1); - let path = model_path.to_path_buf(); - let vocabulary = vocabulary.to_vec(); - std::thread::Builder::new() - .name("whisper-final".to_string()) - .spawn(move || final_worker_loop(&path, &vocabulary, &job_rx, &init_tx)) - .map_err(TranscribeError::SpawnWorker)?; - init_rx.recv().map_err(|_| TranscribeError::WorkerGone)??; - Ok(Self { job_tx }) - } - - /// Starts a new take, installing `on_segment` as the channel each - /// background segment's text is reported on. If the worker is gone the - /// next `finish` reports it. - fn reset(&self, on_segment: std::sync::mpsc::Sender) { - let _ = self.job_tx.send(FinalJob::Reset { on_segment }); - } - - /// Queues a completed segment for background transcription; the - /// segment's text is reported on the take's channel. - fn submit(&self, samples: Vec) { - let (reply, _dropped) = tokio::sync::oneshot::channel(); - let _ = self.job_tx.send(FinalJob::Segment { - samples, - reply, - notify: true, - }); - } - - /// Queues the take's tail and awaits the tail's own text, empty when - /// the tail was skipped. Because the channel is FIFO, awaiting this - /// reply also drains every segment submitted earlier in the take. - async fn finish(&self, samples: Vec) -> Result { - let (reply, reply_rx) = tokio::sync::oneshot::channel(); - self.job_tx - .send(FinalJob::Segment { - samples, - reply, - notify: false, - }) - .map_err(|_| TranscribeError::WorkerGone)?; - reply_rx.await.map_err(|_| TranscribeError::WorkerGone)? - } -} - -/// The final-pass worker's body: load the model, then process takes' jobs in -/// arrival order until every sender is dropped. -fn final_worker_loop( - path: &Path, - vocabulary: &[String], - job_rx: &std::sync::mpsc::Receiver, - init_tx: &std::sync::mpsc::SyncSender>, -) { - let mut pass = match FinalPass::load(path, vocabulary) { - Ok(pass) => { - let _ = init_tx.send(Ok(())); - pass - } - Err(error) => { - let _ = init_tx.send(Err(error)); - return; - } - }; - // The current take's completion channel, installed by each `Reset`; - // FIFO job order guarantees a take's segments all precede the next - // take's `Reset`, so a segment can never land on the wrong channel. - let mut on_segment: Option> = None; - while let Ok(job) = job_rx.recv() { - match job { - FinalJob::Reset { - on_segment: channel, - } => { - on_segment = Some(channel); - pass.reset(); - } - FinalJob::Segment { - samples, - reply, - notify, - } => { - let result = pass.transcribe_segment(&samples); - match &result { - Ok(segment) => { - if notify && let (Some(channel), Some(text)) = (&on_segment, segment) { - // A gone session (socket closed mid-take) is - // ordinary; the transcript was computed anyway. - if channel.send(text.clone()).is_err() { - tracing::debug!("segment completion receiver is gone"); - } - } - } - Err(error) => { - tracing::warn!(%error, "final-pass segment transcription failed"); - } - } - // A dropped receiver (a background segment, or a session - // closed mid-take) is fine: the transcript was computed. - let _ = reply.send(result.map(Option::unwrap_or_default)); - } - } - } -} - -/// A voice-engine construction or transcription failure. -#[derive(Debug, thiserror::Error)] -#[non_exhaustive] -pub enum TranscribeError { - /// The whisper model file could not be loaded. - #[non_exhaustive] - #[error("load whisper model {}", path.display())] - LoadModel { - /// The model path that failed to load. - path: PathBuf, - /// The underlying whisper.cpp error, boxed to hide the dependency. - #[source] - source: Box, - }, - - /// The transcription worker thread could not be started. - #[non_exhaustive] - #[error("spawn transcription worker")] - SpawnWorker(#[source] std::io::Error), - - /// The model rejected an audio window. - #[non_exhaustive] - #[error("transcribe audio window")] - Inference(#[source] Box), - - /// The transcription worker exited while requests were in flight. - #[non_exhaustive] - #[error("transcription worker exited")] - WorkerGone, - - /// The `[voice]` configuration is invalid. - #[non_exhaustive] - #[error("invalid voice configuration: {0}")] - InvalidConfig(String), -} - -/// Shared fixtures for the transcription tests: a small GGML whisper model -/// and a 16 kHz mono WAV of known speech, both downloaded out of band (the -/// URLs are recorded in the design log) and gitignored. -#[cfg(test)] -pub(crate) mod fixtures { - use std::path::{Path, PathBuf}; - - /// The directory holding the downloaded fixtures. - pub(crate) fn fixture_dir() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures") - } - - /// Path to the test model, `ggml-tiny.en.bin`. - pub(crate) fn model_path() -> PathBuf { - fixture_dir().join("ggml-tiny.en.bin") - } - - /// Path to the test model, panicking with download instructions when it - /// has not been fetched. - pub(crate) fn require_model() -> PathBuf { - let path = model_path(); - assert!( - path.is_file(), - "test model missing: download ggml-tiny.en.bin from \ - https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-tiny.en.bin \ - into {}", - fixture_dir().display() - ); - path - } - - /// Decodes `jfk.wav` (16 kHz mono s16 PCM, "ask not what your country - /// can do for you") into f32 samples for the wire format. - pub(crate) fn jfk_samples() -> Vec { - let path = fixture_dir().join("jfk.wav"); - let mut reader = - hound::WavReader::open(&path).expect("jfk.wav fixture exists beside the test model"); - let spec = reader.spec(); - assert_eq!(spec.sample_rate, 16_000, "fixture must be 16 kHz"); - assert_eq!(spec.channels, 1, "fixture must be mono"); - assert_eq!(spec.bits_per_sample, 16, "fixture must be 16-bit PCM"); - let samples: Vec = reader - .samples::() - .collect::>() - .expect("fixture decodes as s16 PCM"); - let mut floats = vec![0.0; samples.len()]; - whisper_rs::convert_integer_to_float_audio(&samples, &mut floats) - .expect("s16 to f32 conversion cannot fail"); - floats - } -} - -#[cfg(test)] -mod tests { - use super::*; - - use crate::transcribe::fixtures; - - #[tokio::test] - #[ignore = "requires whisper test fixtures (tests/fixtures/)"] - async fn transcribes_known_speech_fixture() { - let config = VoiceConfig { - interim_model: fixtures::require_model(), - window_seconds: 12, - ..VoiceConfig::default() - }; - let engine = VoiceEngine::new(&config).expect("engine loads the fixture model"); - let text = engine - .transcribe(fixtures::jfk_samples()) - .await - .expect("transcription succeeds"); - assert!( - text.to_lowercase().contains("country"), - "transcript names the fixture's words: {text:?}" - ); - } - - #[test] - fn rms_of_silence_is_zero() { - assert_eq!(rms(&[]).to_bits(), 0.0f64.to_bits()); - assert_eq!(rms(&[0.0; 1600]).to_bits(), 0.0f64.to_bits()); - } - - #[test] - fn rms_of_a_constant_signal_is_its_amplitude() { - assert!((rms(&[0.5; 100]) - 0.5).abs() < 1e-9); - } - - #[test] - fn silence_gate_separates_quiet_from_speech() { - assert!(is_silence(&[0.0; 1600])); - assert!(is_silence(&[0.0005; 1600])); - assert!(!is_silence(&[0.05; 1600])); - } - - #[test] - fn tail_returns_the_trailing_window() { - let buffer: Vec = (0u8..10).map(f32::from).collect(); - assert_eq!(tail(&buffer, 4), &[6.0, 7.0, 8.0, 9.0]); - assert_eq!(tail(&buffer, 100), &buffer[..]); - assert_eq!(tail(&[], 4), &[] as &[f32]); - } - - #[test] - fn invalid_voice_config_is_rejected() { - let config = VoiceConfig { - window_seconds: 0, - ..VoiceConfig::default() - }; - let err = VoiceEngine::new(&config).expect_err("zero window must fail"); - assert!( - matches!(err, TranscribeError::InvalidConfig(_)), - "expected InvalidConfig, got {err:?}" - ); - } - - #[test] - fn sanitize_prompt_strips_nulls_and_caps_length() { - assert_eq!(sanitize_prompt("hello"), "hello"); - assert_eq!(sanitize_prompt("a\0b"), "ab"); - let long = "x".repeat(MAX_PROMPT_CHARS + 100); - assert_eq!(sanitize_prompt(&long).len(), MAX_PROMPT_CHARS); - // Multibyte input is capped at a char boundary, never mid-codepoint. - let multibyte = "é".repeat(MAX_PROMPT_CHARS + 10); - let capped = sanitize_prompt(&multibyte); - assert!(capped.len() <= MAX_PROMPT_CHARS); - assert!(capped.chars().all(|c| c == 'é')); - } - - #[test] - fn glossary_prompt_is_none_without_usable_terms() { - assert_eq!(glossary_prompt(&[]), None); - assert_eq!(glossary_prompt(&[String::new()]), None); - assert_eq!(glossary_prompt(&[" ".to_string()]), None); - assert_eq!(glossary_prompt(&["\0".to_string()]), None); - } - - #[test] - fn glossary_prompt_formats_a_glossary() { - let vocabulary: Vec = ["MCP", "GGUF", "Lua"].map(str::to_string).into(); - assert_eq!( - glossary_prompt(&vocabulary), - Some("Glossary: MCP, GGUF, Lua.".to_string()) - ); - } - - #[test] - fn glossary_prompt_cleans_terms() { - let vocabulary: Vec = [" tokio ", "ax\0um", ""].map(str::to_string).into(); - assert_eq!( - glossary_prompt(&vocabulary), - Some("Glossary: tokio, axum.".to_string()) - ); - } - - #[test] - #[ignore = "requires whisper test fixtures (tests/fixtures/)"] - fn fit_glossary_keeps_a_vocabulary_that_fits() { - let ctx = WhisperContext::new_with_params( - fixtures::require_model(), - WhisperContextParameters::default(), - ) - .expect("fixture model loads"); - let vocabulary: Vec = ["MCP", "GGUF", "Lua"].map(str::to_string).into(); - let fitted = - fit_glossary(&ctx, &vocabulary, GLOSSARY_TOKEN_BUDGET).expect("a short glossary fits"); - assert_eq!(fitted, "Glossary: MCP, GGUF, Lua."); - } - - #[test] - #[ignore = "requires whisper test fixtures (tests/fixtures/)"] - fn fit_glossary_drops_terms_from_the_end_to_fit() { - let ctx = WhisperContext::new_with_params( - fixtures::require_model(), - WhisperContextParameters::default(), - ) - .expect("fixture model loads"); - let mut vocabulary: Vec = ["MCP".to_string()].into(); - for index in 0..200 { - vocabulary.push(format!("internationalization{index}")); - } - let fitted = fit_glossary(&ctx, &vocabulary, GLOSSARY_TOKEN_BUDGET) - .expect("the leading terms still fit"); - assert!( - fitted.starts_with("Glossary: MCP, "), - "truncation keeps the leading terms: {fitted:?}" - ); - assert!( - fitted.len() <= MAX_PROMPT_CHARS, - "the fitted glossary respects the char cap" - ); - assert!( - token_count(&ctx, &fitted) <= GLOSSARY_TOKEN_BUDGET, - "the fitted glossary tokenizes within its budget: {fitted:?}" - ); - let kept = fitted.matches(", ").count(); - assert!( - kept < vocabulary.len(), - "terms were dropped to fit: {kept} of {}", - vocabulary.len() - ); - } - - #[test] - #[ignore = "requires whisper test fixtures (tests/fixtures/)"] - fn final_prompt_without_a_glossary_matches_sanitize() { - let ctx = WhisperContext::new_with_params( - fixtures::require_model(), - WhisperContextParameters::default(), - ) - .expect("fixture model loads"); - let transcript = "the quick brown fox ".repeat(100); - assert_eq!( - final_prompt(&ctx, None, &transcript), - sanitize_prompt(&transcript) - ); - } - - #[test] - #[ignore = "requires whisper test fixtures (tests/fixtures/)"] - fn final_prompt_prepends_the_glossary_and_caps_tokens() { - let ctx = WhisperContext::new_with_params( - fixtures::require_model(), - WhisperContextParameters::default(), - ) - .expect("fixture model loads"); - let glossary = "Glossary: MCP, GGUF, Lua."; - assert_eq!( - final_prompt(&ctx, Some(glossary), ""), - glossary, - "an empty transcript leaves the glossary alone" - ); - let transcript = "the quick brown fox jumps over the lazy dog ".repeat(100); - let prompt = final_prompt(&ctx, Some(glossary), &transcript); - assert!( - prompt.starts_with(glossary), - "the glossary leads the prompt: {prompt:?}" - ); - assert!( - prompt.len() <= MAX_PROMPT_CHARS, - "the combined prompt respects the char cap" - ); - assert!( - token_count(&ctx, &prompt) <= MAX_PROMPT_TOKENS, - "the combined prompt tokenizes within whisper's budget" - ); - assert!( - prompt.contains("lazy dog"), - "the transcript's tail survives the trim: {prompt:?}" - ); - } - - #[test] - #[ignore = "requires whisper test fixtures (tests/fixtures/)"] - fn final_pass_biases_segments_with_the_glossary() { - let vocabulary: Vec = ["MCP", "GGUF"].map(str::to_string).into(); - let mut pass = FinalPass::load(&fixtures::require_model(), &vocabulary) - .expect("final pass loads the fixture model"); - let first = pass - .transcribe_segment(&fixtures::jfk_samples()) - .expect("segment one transcribes") - .expect("segment one appended text"); - assert!( - first.to_lowercase().contains("country"), - "segment one names the fixture's words: {first:?}" - ); - assert!( - pass.last_prompt().starts_with("Glossary: MCP, GGUF."), - "the first segment was conditioned on the glossary: {:?}", - pass.last_prompt() - ); - let second = pass - .transcribe_segment(&fixtures::jfk_samples()) - .expect("segment two transcribes") - .expect("segment two appended text"); - assert!( - second.to_lowercase().contains("country"), - "segment two names the fixture's words: {second:?}" - ); - let prompt = pass.last_prompt(); - assert!( - prompt.starts_with("Glossary: MCP, GGUF. "), - "the glossary leads the conditioning prompt: {prompt:?}" - ); - assert!( - prompt.contains(&first), - "the transcript follows the glossary: {prompt:?}" - ); - } - - #[test] - #[ignore = "requires whisper test fixtures (tests/fixtures/)"] - fn missing_final_model_fails_engine_construction() { - let config = VoiceConfig { - interim_model: fixtures::require_model(), - final_model: PathBuf::from("definitely-missing-final-model.bin"), - ..VoiceConfig::default() - }; - let err = VoiceEngine::new(&config).expect_err("a missing final model must fail"); - assert!( - matches!(err, TranscribeError::LoadModel { .. }), - "expected LoadModel, got {err:?}" - ); - assert!( - err.to_string() - .contains("definitely-missing-final-model.bin"), - "error names the path: {err}" - ); - } - - #[tokio::test] - #[ignore = "requires whisper test fixtures (tests/fixtures/)"] - async fn final_pass_entry_points_are_no_ops_without_a_final_model() { - let config = VoiceConfig { - interim_model: fixtures::require_model(), - ..VoiceConfig::default() - }; - let engine = VoiceEngine::new(&config).expect("engine loads the fixture model"); - let (segment_tx, _segment_rx) = std::sync::mpsc::channel(); - engine.final_reset(segment_tx); - engine.final_submit(fixtures::jfk_samples()); - assert!( - engine.final_finish(fixtures::jfk_samples()).await.is_none(), - "no final model means the caller falls back" - ); - } - - #[tokio::test] - #[ignore = "requires whisper test fixtures (tests/fixtures/)"] - async fn final_submit_reports_the_segment_on_the_take_channel() { - let config = VoiceConfig { - interim_model: fixtures::require_model(), - final_model: fixtures::require_model(), - ..VoiceConfig::default() - }; - let engine = VoiceEngine::new(&config).expect("engine loads the fixture model"); - let (segment_tx, segment_rx) = std::sync::mpsc::channel(); - engine.final_reset(segment_tx); - engine.final_submit(fixtures::jfk_samples()); - - // The timeout only bounds a broken pipeline; the tiny fixture - // model transcribes the clip in seconds. - let segment = segment_rx - .recv_timeout(Duration::from_secs(120)) - .expect("the submitted segment's text arrives on the channel"); - assert!( - segment.to_lowercase().contains("country"), - "the reported segment names the fixture's words: {segment:?}" - ); - - let tail = engine - .final_finish(fixtures::jfk_samples()) - .await - .expect("a final model is configured") - .expect("the final pass succeeds"); - assert!( - tail.to_lowercase().contains("country"), - "the closing tail names the fixture's words: {tail:?}" - ); - let countries = tail.to_lowercase().matches("country").count(); - assert!( - countries < 3, - "the finish returns the tail's text only, not the assembled \ - transcript ({countries} countries): {tail:?}" - ); - assert!( - segment_rx.try_recv().is_err(), - "the closing tail reports only through its reply, not the channel" - ); - } - - #[tokio::test] - #[ignore = "requires whisper test fixtures (tests/fixtures/)"] - async fn final_finish_with_a_silent_tail_returns_empty_after_draining() { - let config = VoiceConfig { - interim_model: fixtures::require_model(), - final_model: fixtures::require_model(), - ..VoiceConfig::default() - }; - let engine = VoiceEngine::new(&config).expect("engine loads the fixture model"); - let (segment_tx, segment_rx) = std::sync::mpsc::channel(); - engine.final_reset(segment_tx); - engine.final_submit(fixtures::jfk_samples()); - - // The tail is pure silence: the worker skips it rather than - // hallucinating, and the FIFO reply still drains the take's - // submitted segment first. - let tail = engine - .final_finish(vec![0.0; SAMPLE_RATE]) - .await - .expect("a final model is configured") - .expect("the final pass succeeds"); - assert!( - tail.is_empty(), - "a silent tail is skipped, not transcribed: {tail:?}" - ); - let segment = segment_rx - .recv_timeout(Duration::from_secs(120)) - .expect("the submitted segment's text arrives on the channel"); - assert!( - segment.to_lowercase().contains("country"), - "the drained segment names the fixture's words: {segment:?}" - ); - } - - #[test] - #[ignore = "requires whisper test fixtures (tests/fixtures/)"] - fn final_pass_conditions_each_segment_on_the_accumulated_transcript() { - let mut pass = FinalPass::load(&fixtures::require_model(), &[]) - .expect("final pass loads the fixture model"); - let jfk = fixtures::jfk_samples(); - - let first = pass - .transcribe_segment(&jfk) - .expect("segment one transcribes") - .expect("segment one appended text"); - assert!( - pass.last_prompt().is_empty(), - "the first segment has nothing to be conditioned on" - ); - let first_lower = first.to_lowercase(); - assert!( - first_lower.contains("country"), - "segment one names the fixture's words: {first:?}" - ); - let first_countries = first_lower.matches("country").count(); - assert_eq!( - pass.transcript(), - first, - "the accumulated transcript is the first segment's text" - ); - - let second = pass - .transcribe_segment(&jfk) - .expect("segment two transcribes") - .expect("segment two appended text"); - assert_eq!( - pass.last_prompt(), - first, - "segment two was conditioned on the accumulated transcript" - ); - assert!( - second.to_lowercase().contains("country"), - "the segment's own text names the fixture's words: {second:?}" - ); - let assembled = pass.transcript(); - assert!( - assembled.starts_with(&first), - "segment transcripts accumulate in order: {assembled:?}" - ); - let second_countries = assembled.to_lowercase().matches("country").count(); - assert!( - second_countries > first_countries, - "the second segment added its own text: {first_countries} then {second_countries}" - ); - } - - #[test] - #[ignore = "requires whisper test fixtures (tests/fixtures/)"] - fn final_pass_reset_forgets_the_accumulated_transcript() { - let mut pass = FinalPass::load(&fixtures::require_model(), &[]) - .expect("final pass loads the fixture model"); - let jfk = fixtures::jfk_samples(); - - let first = pass - .transcribe_segment(&jfk) - .expect("segment one transcribes") - .expect("segment one appended text"); - pass.reset(); - let second = pass - .transcribe_segment(&jfk) - .expect("segment two transcribes") - .expect("segment two appended text"); - assert!( - pass.last_prompt().is_empty(), - "after reset the next segment has nothing to be conditioned on" - ); - assert_eq!( - second, first, - "a new take's transcript holds only its own segments" - ); - assert_eq!( - pass.transcript(), - second, - "the accumulated transcript forgot the previous take" - ); - } - - #[test] - #[ignore = "requires whisper test fixtures (tests/fixtures/)"] - fn final_pass_skips_silence_without_touching_the_transcript() { - let mut pass = FinalPass::load(&fixtures::require_model(), &[]) - .expect("final pass loads the fixture model"); - let segment = pass - .transcribe_segment(&vec![0.0; SAMPLE_RATE * 2]) - .expect("silence is skipped, not an error"); - assert!(segment.is_none(), "a skipped segment reports no text"); - assert!( - pass.transcript().is_empty(), - "silence transcribes to nothing" - ); - assert!( - pass.last_prompt().is_empty(), - "a skipped segment records no conditioning" - ); - } - - #[test] - fn missing_model_file_fails_engine_construction() { - let config = VoiceConfig { - interim_model: PathBuf::from("definitely-missing-model.bin"), - ..VoiceConfig::default() - }; - let err = VoiceEngine::new(&config).expect_err("a missing model must fail"); - assert!( - matches!(err, TranscribeError::LoadModel { .. }), - "expected LoadModel, got {err:?}" - ); - assert!( - err.to_string().contains("definitely-missing-model.bin"), - "error names the path: {err}" - ); - } -} diff --git a/crates/promptforge-wb-server/ui/build.mjs b/crates/promptforge-wb-server/ui/build.mjs deleted file mode 100644 index e46c1f93..00000000 --- a/crates/promptforge-wb-server/ui/build.mjs +++ /dev/null @@ -1,47 +0,0 @@ -// Bundles src/main.ts into dist/app.js and copies the static assets into -// dist/. The server crate's build.rs performs the same two steps on -// `cargo build` (STATIC_FILES is mirrored there); this script exists for the -// fast iteration workflow: `npm run watch` rebuilds on save without a Rust -// recompile. -import { copyFile, mkdir, rm } 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"); - -// Mirrored in ../build.rs. -const STATIC_FILES = ["index.html", "style.css", "pcm-worklet.js"]; - -const options = { - entryPoints: [path.join(uiDir, "src", "main.ts")], - bundle: true, - format: "esm", - target: "es2022", - // `node build.mjs --minify` matches what build.rs does for cargo release - // builds. - minify: process.argv.includes("--minify"), - outfile: path.join(distDir, "app.js"), - logLevel: "info", -}; - -// dist/ is rebuilt from scratch so removed assets never linger into the -// release embed. -async function copyStatic() { - await mkdir(distDir, { recursive: true }); - await Promise.all( - STATIC_FILES.map((file) => copyFile(path.join(uiDir, file), path.join(distDir, file))), - ); -} - -if (process.argv.includes("--watch")) { - const context = await esbuild.context(options); - await copyStatic(); - await context.watch(); - console.log("watching ui/src for changes..."); -} else { - await rm(distDir, { recursive: true, force: true }); - await esbuild.build(options); - await copyStatic(); -} diff --git a/crates/promptforge-wb-server/ui/index.html b/crates/promptforge-wb-server/ui/index.html deleted file mode 100644 index 940fe3b2..00000000 --- a/crates/promptforge-wb-server/ui/index.html +++ /dev/null @@ -1,64 +0,0 @@ - - - - - -PromptForge - - - - -
- -
-
-
-
-
- Ready - - REC - - - - - -
- - - - diff --git a/crates/promptforge-wb-server/ui/package.json b/crates/promptforge-wb-server/ui/package.json deleted file mode 100644 index 8616ce08..00000000 --- a/crates/promptforge-wb-server/ui/package.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "name": "promptforge-wb-ui", - "version": "0.0.0", - "private": true, - "type": "module", - "description": "PromptForge Workbench UI: TypeScript sources bundled by esbuild into dist/ and served by promptforge-wb-server.", - "scripts": { - "build": "node build.mjs", - "watch": "node build.mjs --watch", - "typecheck": "tsc --noEmit", - "test": "node test/smoke.mjs" - }, - "dependencies": { - "dockview": "^8.2.0", - "marked": "^18.0.10" - }, - "devDependencies": { - "esbuild": "^0.28.2", - "jsdom": "^30.0.1", - "typescript": "^7.0.2" - } -} diff --git a/crates/promptforge-wb-server/ui/src/chat/plugins/copy/copy-plugin.ts b/crates/promptforge-wb-server/ui/src/chat/plugins/copy/copy-plugin.ts deleted file mode 100644 index 6382e375..00000000 --- a/crates/promptforge-wb-server/ui/src/chat/plugins/copy/copy-plugin.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { extractPlainText } from "../../core/msg-utils"; -import type { ChatPlugin } from "../../core/types"; -import { ICON_CHECK, ICON_COPY } from "../../utils/icons"; - -export function CopyPlugin(): ChatPlugin { - return { - name: "copy", - getActionButtons: (msg) => { - if (msg.role !== "assistant") return []; - if (typeof navigator === "undefined" || !navigator.clipboard) return []; - if (!extractPlainText(msg).trim()) return []; - - return [ - { - id: "copy", - title: "Copy message", - iconHtml: ICON_COPY, - onClick: async ({ message, buttonEl }) => { - try { - const textToCopy = extractPlainText(message); - await navigator.clipboard.writeText(textToCopy); - buttonEl.innerHTML = ICON_CHECK; - setTimeout(() => { - if (buttonEl.isConnected) { - buttonEl.innerHTML = ICON_COPY; - } - }, 2000); - } catch { - // Ignore - } - }, - }, - ]; - }, - }; -} diff --git a/crates/promptforge-wb-server/ui/src/chat/plugins/thinking/thinking-plugin.ts b/crates/promptforge-wb-server/ui/src/chat/plugins/thinking/thinking-plugin.ts deleted file mode 100644 index c44ba0cf..00000000 --- a/crates/promptforge-wb-server/ui/src/chat/plugins/thinking/thinking-plugin.ts +++ /dev/null @@ -1,86 +0,0 @@ -import "./thinking.css"; -import type { ChatPlugin } from "../../core/types"; -import { el } from "../../utils/dom"; -import { renderSafeHTML } from "../../utils/html"; -import { ICON_CHEVRON } from "../../utils/icons"; - -interface ThinkingState { - isExpanded: boolean; - cacheReasoning: string; - cacheIsGenerating: boolean; - contentEl: HTMLElement; - btnSpan: HTMLElement; -} - -const ENCRYPTED_REASONING_FALLBACK = "Thought process is hidden by the model provider."; - -function getReasoningDisplayContent(block: { text: string; encrypted?: boolean }): string { - if (block.encrypted) return ENCRYPTED_REASONING_FALLBACK; - return block.text; -} - -export function ThinkingPlugin(): ChatPlugin { - const stateMap = new WeakMap(); - - return { - name: "thinking", - onBlockRender: (block, containerEl, isGenerating) => { - if (block.type !== "reasoning") return false; - - let state = stateMap.get(containerEl); - - if (!state) { - const btn = el("button", "mur-think-toggle", { - innerHTML: ICON_CHEVRON + "Thought Process", - }); - - const btnSpan = btn.querySelector("span") as HTMLElement; - btn.setAttribute("aria-expanded", "false"); - - const contentEl = el("div", "mur-think-content"); - contentEl.hidden = true; - const wrapper = el("div", "mur-think-wrapper", {}, [btn, contentEl]); - - containerEl.innerHTML = ""; - containerEl.appendChild(wrapper); - - state = { - isExpanded: false, - cacheReasoning: "", - cacheIsGenerating: false, - contentEl, - btnSpan, - }; - - btn.onclick = () => { - state!.isExpanded = !state!.isExpanded; - contentEl.hidden = !state!.isExpanded; - btn.setAttribute("aria-expanded", String(state!.isExpanded)); - - const displayContent = getReasoningDisplayContent(block); - - if (state!.isExpanded && state!.cacheReasoning !== displayContent) { - renderSafeHTML(contentEl, displayContent); - state!.cacheReasoning = displayContent; - } - }; - - stateMap.set(containerEl, state); - } - - if (state.cacheIsGenerating !== isGenerating) { - state.btnSpan.textContent = isGenerating ? "Thinking..." : "Thought Process"; - state.cacheIsGenerating = isGenerating; - } - - const displayContent = getReasoningDisplayContent(block); - - if (state.isExpanded && state.cacheReasoning !== displayContent) { - renderSafeHTML(state.contentEl, displayContent); - state.cacheReasoning = displayContent; - } - - return true; - }, - }; -} diff --git a/crates/promptforge-wb-server/ui/src/chat/plugins/tools/tools-plugin.ts b/crates/promptforge-wb-server/ui/src/chat/plugins/tools/tools-plugin.ts deleted file mode 100644 index 5d3baf2e..00000000 --- a/crates/promptforge-wb-server/ui/src/chat/plugins/tools/tools-plugin.ts +++ /dev/null @@ -1,451 +0,0 @@ -import "./tools.css"; -import type { BlockRenderContext, ChatPlugin, ContentBlock, Message } from "../../core/types"; -import { el } from "../../utils/dom"; -import { ICON_CHEVRON } from "../../utils/icons"; - -type ToolCallBlock = Extract; -type ToolResultBlock = Extract; - -export interface ToolRenderContext { - toolCall: ToolCallBlock; - toolResult?: ToolResultBlock; - message: Message; - messages: readonly Message[]; - blockIndex: number; - isGenerating: boolean; - args: unknown; - argsText: string; - result: unknown; - outputText: string; -} - -export interface ToolRenderer { - label?: string | ((ctx: ToolRenderContext) => string | undefined); - preview?: (ctx: ToolRenderContext) => string | undefined; - formatArgs?: (ctx: ToolRenderContext) => string | undefined; - formatResult?: (ctx: ToolRenderContext) => string | undefined; -} - -export interface ToolsPluginConfig { - defaultExpanded?: boolean | ((ctx: ToolRenderContext) => boolean); - maxLabelChars?: number; - maxPreviewChars?: number; - tools?: Record; -} - -interface ToolState { - expanded: boolean; - rootEl: HTMLElement; - ctx?: ToolRenderContext; - renderer?: ToolRenderer; - resultCache?: ToolResultCache; - previewText: string; - buttonEl: HTMLButtonElement; - titleEl: HTMLElement; - statusEl: HTMLElement; - previewEl?: HTMLElement; - detailsEl?: HTMLElement; - details?: ToolDetailsState; -} - -interface ToolDetailsState { - argsPre: HTMLPreElement; - resultSectionEl: HTMLElement; - resultTitleEl: HTMLElement; - resultPre: HTMLPreElement; -} - -interface ToolResultCache { - messages: readonly Message[]; - messageId: string; - blockId: string; - toolCallId: string; - result: ToolResultBlock; -} - -const DEFAULT_MAX_LABEL_CHARS = 120; -const DEFAULT_MAX_PREVIEW_CHARS = 240; -const MAX_ARG_SUMMARY_VALUE_CHARS = 40; -const EMPTY_MESSAGE: Message = { id: "", role: "assistant", blocks: [] }; - -export function ToolsPlugin(config: ToolsPluginConfig = {}): ChatPlugin { - const stateMap = new WeakMap(); - - return { - name: "tools", - onBlockRender: (block, containerEl, isGenerating, renderCtx) => { - if (block.type !== "tool_call") return false; - - let state = stateMap.get(containerEl); - const ctx = createToolContext(block, renderCtx, isGenerating, state); - const renderer = config.tools?.[block.name]; - - if (!state) { - state = createToolState(containerEl, resolveDefaultExpanded(config.defaultExpanded, ctx)); - containerEl.replaceChildren(state.buttonEl); - state.buttonEl.addEventListener("click", () => { - state!.expanded = !state!.expanded; - syncExpansion(state!); - }); - stateMap.set(containerEl, state); - } - cacheToolResult(state, block, renderCtx, ctx.toolResult); - - renderTool(containerEl, state, ctx, renderer, config); - return true; - }, - }; -} - -function createToolState(rootEl: HTMLElement, expanded: boolean): ToolState { - const chevronEl = el("span", "mur-tool-chevron", { innerHTML: ICON_CHEVRON }); - const titleEl = el("span", "mur-tool-title"); - const statusEl = el("span", "mur-tool-status"); - const buttonEl = el("button", "mur-tool-summary", { type: "button" }, [statusEl, titleEl, chevronEl]); - - const state = { - expanded, - rootEl, - previewText: "", - buttonEl, - titleEl, - statusEl, - }; - - syncExpansion(state); - return state; -} - -function renderTool( - containerEl: HTMLElement, - state: ToolState, - ctx: ToolRenderContext, - renderer: ToolRenderer | undefined, - config: ToolsPluginConfig, -): void { - const status = ctx.toolResult?.isError ? "error" : ctx.toolCall.status; - containerEl.className = `mur-content-block mur-block-tool_call mur-tool mur-tool-${status}`; - - const label = rendererLabel(renderer, ctx) ?? defaultToolLabel(ctx.toolCall, ctx.args); - const preview = renderer?.preview?.(ctx) ?? defaultPreview(ctx); - const statusText = statusLabel(status); - - state.ctx = ctx; - state.renderer = renderer; - state.titleEl.textContent = truncateText(label, config.maxLabelChars ?? DEFAULT_MAX_LABEL_CHARS); - state.statusEl.textContent = statusSymbol(status); - state.statusEl.title = statusText; - state.statusEl.setAttribute("aria-label", statusText); - state.buttonEl.setAttribute("aria-label", `${label} (${statusText})`); - - state.previewText = truncateText(preview ?? "", config.maxPreviewChars ?? DEFAULT_MAX_PREVIEW_CHARS); - - syncExpansion(state); -} - -function createToolContext( - toolCall: ToolCallBlock, - ctx: BlockRenderContext | undefined, - isGenerating: boolean, - state: ToolState | undefined, -): ToolRenderContext { - const messages = ctx?.messages ?? []; - const toolResult = resolveToolResult(toolCall, ctx, state); - const args = parseJson(toolCall.argsText); - const outputText = toolResult?.outputText ?? ""; - let resultParsed = false; - let parsedResult: unknown; - - return { - toolCall, - toolResult, - message: ctx?.message ?? EMPTY_MESSAGE, - messages, - blockIndex: ctx?.blockIndex ?? -1, - isGenerating, - args, - argsText: toolCall.argsText, - outputText, - get result() { - if (!resultParsed) { - parsedResult = parseJson(outputText); - resultParsed = true; - } - return parsedResult; - }, - }; -} - -function resolveToolResult( - toolCall: ToolCallBlock, - ctx: BlockRenderContext | undefined, - state: ToolState | undefined, -): ToolResultBlock | undefined { - const cached = state?.resultCache; - if ( - cached && - ctx && - cached.messages === ctx.messages && - cached.messageId === ctx.message.id && - cached.blockId === toolCall.id && - cached.toolCallId === toolCall.toolCallId - ) { - return cached.result; - } - - const result = findToolResult(toolCall.toolCallId, ctx); - if (state) cacheToolResult(state, toolCall, ctx, result); - return result; -} - -function cacheToolResult( - state: ToolState, - toolCall: ToolCallBlock, - ctx: BlockRenderContext | undefined, - result: ToolResultBlock | undefined, -): void { - state.resultCache = - result && ctx - ? { - messages: ctx.messages, - messageId: ctx.message.id, - blockId: toolCall.id, - toolCallId: toolCall.toolCallId, - result, - } - : undefined; -} - -function findToolResult(toolCallId: string, ctx: BlockRenderContext | undefined): ToolResultBlock | undefined { - if (!ctx) return undefined; - - const messageIndex = ctx.messages.findIndex((message) => message.id === ctx.message.id); - const startIndex = messageIndex >= 0 ? messageIndex : 0; - - for (let i = startIndex; i < ctx.messages.length; i++) { - const result = ctx.messages[i].blocks.find( - (block): block is ToolResultBlock => block.type === "tool_result" && block.toolCallId === toolCallId, - ); - if (result) return result; - } - - return undefined; -} - -function rendererLabel(renderer: ToolRenderer | undefined, ctx: ToolRenderContext): string | undefined { - if (!renderer?.label) return undefined; - return typeof renderer.label === "function" ? renderer.label(ctx) : renderer.label; -} - -function resolveDefaultExpanded( - defaultExpanded: ToolsPluginConfig["defaultExpanded"], - ctx: ToolRenderContext, -): boolean { - if (typeof defaultExpanded === "function") return defaultExpanded(ctx); - return defaultExpanded ?? false; -} - -function syncExpansion(state: ToolState): void { - state.buttonEl.setAttribute("aria-expanded", String(state.expanded)); - syncPreview(state); - - if (state.expanded && state.ctx) { - renderDetails(state); - return; - } - - clearDetails(state); -} - -function renderDetails(state: ToolState): void { - const ctx = state.ctx; - if (!ctx) return; - const detailsEl = ensureDetailsEl(state); - const details = ensureDetails(state); - - detailsEl.hidden = false; - details.argsPre.textContent = state.renderer?.formatArgs?.(ctx) ?? defaultArgsText(ctx); - details.resultTitleEl.textContent = ctx.toolResult?.isError ? "Error" : "Result"; - details.resultPre.textContent = state.renderer?.formatResult?.(ctx) ?? defaultResultText(ctx); - details.resultSectionEl.hidden = false; -} - -function clearDetails(state: ToolState): void { - if (state.detailsEl) { - state.detailsEl.remove(); - state.detailsEl = undefined; - } - state.details = undefined; -} - -function ensureDetails(state: ToolState): ToolDetailsState { - if (state.details) return state.details; - - const argsTitleEl = el("div", "mur-tool-section-title", { textContent: "Arguments" }); - const argsPre = el("pre", "mur-tool-pre"); - const argsSectionEl = el("section", "mur-tool-section", {}, [argsTitleEl, argsPre]); - - const resultTitleEl = el("div", "mur-tool-section-title", { textContent: "Result" }); - const resultPre = el("pre", "mur-tool-pre"); - const resultSectionEl = el("section", "mur-tool-section", {}, [resultTitleEl, resultPre]); - - ensureDetailsEl(state).replaceChildren(argsSectionEl, resultSectionEl); - state.details = { - argsPre, - resultSectionEl, - resultTitleEl, - resultPre, - }; - return state.details; -} - -function syncPreview(state: ToolState): void { - if (!state.previewText || state.expanded) { - state.previewEl?.remove(); - state.previewEl = undefined; - return; - } - - const previewEl = ensurePreviewEl(state); - previewEl.textContent = state.previewText; -} - -function ensurePreviewEl(state: ToolState): HTMLElement { - if (state.previewEl) return state.previewEl; - - const previewEl = el("div", "mur-tool-preview"); - state.rootEl.insertBefore(previewEl, state.detailsEl ?? null); - state.previewEl = previewEl; - return previewEl; -} - -function ensureDetailsEl(state: ToolState): HTMLElement { - if (state.detailsEl) return state.detailsEl; - - const detailsEl = el("div", "mur-tool-details"); - state.rootEl.appendChild(detailsEl); - state.detailsEl = detailsEl; - return detailsEl; -} - -function defaultToolLabel(toolCall: ToolCallBlock, args: unknown): string { - const name = toolCall.name || "tool"; - const summary = summarizeArgs(args, toolCall.argsText); - return summary ? `${name} ${summary}` : name; -} - -function summarizeArgs(args: unknown, argsText: string): string { - if (args && typeof args === "object" && !Array.isArray(args)) { - const entries = Object.entries(args as Record).filter( - ([, value]) => value !== undefined && value !== null, - ); - if (entries.length === 0) return ""; - - const preferred = [ - "command", - "cmd", - "pattern", - "query", - "path", - "dir_path", - "file", - "filePath", - "filepath", - "url", - "name", - ]; - const preferredEntries: Array<[string, unknown]> = []; - for (const key of preferred) { - const match = entries.find(([entryKey]) => entryKey === key); - if (match) preferredEntries.push(match); - if (preferredEntries.length >= 2) break; - } - - const summaryEntries = preferredEntries.length > 0 ? preferredEntries : entries.slice(0, 2); - if (summaryEntries.length > 0) { - if (summaryEntries.length === 1 && preferredEntries.length === 1) { - return compactValue(summaryEntries[0][1]); - } - return summaryEntries.map(([key, value]) => `${key}=${compactValue(value)}`).join(" "); - } - - return `${entries.length} args`; - } - - if (Array.isArray(args)) return `${args.length} items`; - if (args !== undefined) return compactValue(args); - - const raw = argsText.trim().replace(/\s+/g, " "); - return raw === "{}" ? "" : raw; -} - -function compactValue(value: unknown): string { - const text = - typeof value === "string" - ? value - : typeof value === "number" || typeof value === "boolean" || value === null - ? String(value) - : JSON.stringify(value); - return truncateText(text.replace(/\s+/g, " "), MAX_ARG_SUMMARY_VALUE_CHARS); -} - -function defaultPreview(ctx: ToolRenderContext): string | undefined { - if (!ctx.toolResult?.isError) return undefined; - return ctx.outputText || "Tool failed."; -} - -function defaultArgsText(ctx: ToolRenderContext): string { - if (ctx.args !== undefined) return JSON.stringify(ctx.args, null, 2); - return ctx.argsText.trim() || "{}"; -} - -function defaultResultText(ctx: ToolRenderContext): string { - if (!ctx.toolResult) { - if (ctx.toolCall.status === "running") return "Running..."; - if (ctx.toolCall.status === "pending") return "Waiting for result..."; - return "No result."; - } - - if (ctx.result !== undefined) return JSON.stringify(ctx.result, null, 2); - return ctx.outputText; -} - -function parseJson(text: string): unknown { - const firstChar = firstNonWhitespaceChar(text); - if (!firstChar || !'{["-0123456789tfn'.includes(firstChar)) return undefined; - - try { - return JSON.parse(text); - } catch { - return undefined; - } -} - -function firstNonWhitespaceChar(text: string): string { - for (let i = 0; i < text.length; i++) { - const char = text[i]; - if (char !== " " && char !== "\n" && char !== "\r" && char !== "\t") return char; - } - return ""; -} - -function statusSymbol(status: ToolCallBlock["status"] | "error"): string { - switch (status) { - case "complete": - return "✓"; - case "error": - return "×"; - default: - return "..."; - } -} - -function statusLabel(status: ToolCallBlock["status"] | "error"): string { - return status; -} - -function truncateText(text: string, maxChars: number): string { - if (text.length <= maxChars) return text; - if (maxChars <= 3) return text.slice(0, maxChars); - return `${text.slice(0, maxChars - 3)}...`; -} diff --git a/crates/promptforge-wb-server/ui/src/chat/plugins/tools/tools.css b/crates/promptforge-wb-server/ui/src/chat/plugins/tools/tools.css deleted file mode 100644 index f5ad880e..00000000 --- a/crates/promptforge-wb-server/ui/src/chat/plugins/tools/tools.css +++ /dev/null @@ -1,121 +0,0 @@ -.mur-tool { - margin: 0.18rem 0 0.32rem; - color: var(--mur-text-muted); -} - -.mur-tool + .mur-tool { - margin-top: 0; -} - -.mur-agent-run-steps .mur-tool { - margin: 0; -} - -.mur-tool-summary { - display: inline-grid; - grid-template-columns: auto minmax(0, 1fr) auto; - align-items: center; - width: auto; - max-width: 100%; - gap: 0.35rem; - padding: 0.18rem 0.28rem; - border: 0; - border-radius: 4px; - background: transparent; - color: inherit; - cursor: pointer; - font: inherit; - text-align: left; -} - -.mur-tool-chevron { - display: inline-flex; - color: var(--mur-text-muted); - opacity: 0; - transition: opacity 0.15s ease; -} - -.mur-tool-summary:hover .mur-tool-chevron, -.mur-tool-summary:focus-visible .mur-tool-chevron, -.mur-tool-summary[aria-expanded="true"] .mur-tool-chevron { - opacity: 1; -} - -.mur-agent-run-steps .mur-tool-summary { - min-height: var(--mur-agent-run-control-height, 1.5rem); - padding-top: 0.125rem; - padding-bottom: 0.125rem; -} - -.mur-agent-run-steps .mur-tool-chevron { - opacity: 0.65; -} - -.mur-tool-chevron svg { - transition: transform 0.15s ease; -} - -.mur-tool-summary[aria-expanded="true"] .mur-tool-chevron svg { - transform: rotate(90deg); -} - -.mur-tool-title { - min-width: 0; - overflow: hidden; - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - font-size: 0.8rem; - font-weight: 400; - text-overflow: ellipsis; - white-space: nowrap; -} - -.mur-tool-status { - width: 1.1em; - color: var(--mur-text-muted); - font-size: 0.78rem; - line-height: 1; - text-align: center; -} - -.mur-tool-preview { - margin-left: 1.45rem; - padding: 0.15rem 0.3rem 0.2rem; - color: var(--mur-text-muted); - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - font-size: 0.76rem; - line-height: 1.45; - white-space: pre-wrap; - word-break: break-word; -} - -.mur-tool-details { - margin: 0.18rem 0 0.35rem 0.7rem; - padding: 0.2rem 0 0.2rem 0.65rem; - border-left: 1px solid var(--mur-border); -} - -.mur-tool-section + .mur-tool-section { - margin-top: 0.45rem; -} - -.mur-tool-section-title { - margin-bottom: 0.22rem; - color: var(--mur-text-muted); - font-size: 0.68rem; - font-weight: 650; - text-transform: uppercase; -} - -.mur-tool-pre { - max-height: min(360px, 45vh); - overflow: auto; - border-radius: 6px; - background: var(--mur-bg); - color: var(--mur-text-secondary); - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - font-size: 0.76rem; - line-height: 1.45; - padding: 0.45rem; - white-space: pre-wrap; - word-break: break-word; -} diff --git a/crates/promptforge-wb-server/ui/src/main.ts b/crates/promptforge-wb-server/ui/src/main.ts deleted file mode 100644 index fa9c173e..00000000 --- a/crates/promptforge-wb-server/ui/src/main.ts +++ /dev/null @@ -1,181 +0,0 @@ -// murm-ui's own styles, bundled by esbuild into dist/app.css. Sidebar and -// dropdown styles are skipped: the workbench disables the murm sidebar and -// no plugin renders dropdowns. -import "./chat/styles/base.css"; -import "./chat/styles/feed.css"; -import "./chat/styles/input.css"; -import "dockview/dist/styles/dockview.css"; - -import { createDockview, themeDark } from "dockview"; -import type { IContentRenderer } from "dockview"; - -import type { ChatPlugin } from "./chat/core/types"; -import { ChatUI } from "./chat/main"; -import { MemoryStorage } from "./memory-storage"; -import { StatusBar } from "./status-bar"; -import { setupVoice, type VoiceHandle } from "./voice"; -import { WorkbenchProvider } from "./workbench-provider"; -import { type CatalogModel, WorkbenchSocket } from "./workbench-socket"; - -const pickerEl = document.getElementById("model-picker") as HTMLSelectElement; -const descriptionEl = document.getElementById("model-description") as HTMLDivElement; - -// One persistent socket carries chat frames upstream and every downstream -// JSON frame - chat replies and the observer's status updates, which the -// status bar renders as they arrive. -const statusBarRoot = document.querySelector(".status-bar") as HTMLElement | null; -if (!statusBarRoot) { - throw new Error("DOM Error: .status-bar not found in the page."); -} -const statusBar = new StatusBar(statusBarRoot); -const workbenchSocket = new WorkbenchSocket(); -workbenchSocket.onStatus((frame) => statusBar.render(frame)); -// A dropped socket means every in-flight status is stale; the bar returns -// to its reconnecting state until the observer speaks again. -workbenchSocket.onDisconnect(() => statusBar.reset()); -workbenchSocket.connect(); - -function selectedModel(): string { - return pickerEl.value; -} - -// The mic button joins murm-ui's composer through the plugin seam; the -// voice status message sits below the form. -let voiceHandle: VoiceHandle | null = null; -const voicePlugin: ChatPlugin = { - name: "voice", - onInputMount({ container, form, input }) { - const mic = document.createElement("button"); - mic.type = "button"; - mic.className = "voice-mic mur-form-icon-btn"; - mic.title = "Push to talk"; - mic.setAttribute("aria-label", "Push to talk"); - mic.setAttribute("aria-pressed", "false"); - mic.innerHTML = - ''; - form.insertBefore(mic, form.querySelector(".mur-form-footer-right")); - - const formContainer = container.querySelector(".mur-chat-form-container"); - if (!formContainer) { - throw new Error("DOM Error: .mur-chat-form-container not found inside the container."); - } - const status = document.createElement("div"); - status.className = "voice-status"; - status.setAttribute("role", "status"); - status.setAttribute("aria-live", "polite"); - formContainer.appendChild(status); - - voiceHandle = setupVoice({ mic, status, input }, statusBar); - }, - onUserSubmit() { - voiceHandle?.discardIfRecording(); - }, - // With no model selected there is nothing to send to; the old UI disabled - // the send button in the same situation. - isSubmitBlocked: () => !selectedModel(), -}; - -// The chat lives in dockview's single panel; the panel infrastructure is -// what later stages hang the file tree and editor panes on. The tab bar is -// hidden in style.css: with exactly one panel it is chrome, not information. -class ChatPanel implements IContentRenderer { - readonly element = document.createElement("div"); - - constructor() { - this.element.className = "chat-panel"; - } - - init(): void { - const template = document.getElementById("chat-panel") as HTMLTemplateElement; - this.element.appendChild(template.content.cloneNode(true)); - } -} - -const dockEl = document.getElementById("dock") as HTMLDivElement; -const dock = createDockview(dockEl, { - createComponent: () => new ChatPanel(), - theme: themeDark, - singleTabMode: "fullwidth", - disableFloatingGroups: true, - hideBorders: true, - locked: true, - noPanelsOverlay: "emptyGroup", -}); -dock.addPanel({ id: "chat", component: "chat", title: "Chat" }); - -const chatContainer = dockEl.querySelector(".mur-app"); -if (!chatContainer) { - throw new Error("DOM Error: the chat panel did not mount its .mur-app container."); -} - -const chat = new ChatUI({ - container: chatContainer as HTMLElement, - provider: new WorkbenchProvider(workbenchSocket), - storage: new MemoryStorage(), - enableSidebar: false, - routing: false, - fullscreen: false, - plugins: () => [voicePlugin], -}); - -function applyModel(): void { - chat.engine.setRequestDefaults({ options: { model: selectedModel() } }); -} - -function showDescription(): void { - const option = pickerEl.selectedOptions[0]; - descriptionEl.textContent = (option && option.dataset.description) || ""; -} - -// Rebuilds the model picker from a catalog, keeping the user's selection -// when it survives the refresh. Used by the boot fetch and by the pushed -// catalogs the server sends when the gateway comes back. -function renderModels(entries: CatalogModel[]): void { - const previous = pickerEl.value; - pickerEl.textContent = ""; - if (entries.length === 0) { - pickerEl.appendChild(new Option("No models available", "")); - pickerEl.disabled = true; - return; - } - for (const entry of entries) { - const option = new Option(entry.id, entry.id); - option.dataset.description = entry.description || ""; - pickerEl.appendChild(option); - } - if (entries.some((entry) => entry.id === previous)) { - pickerEl.value = previous; - } - pickerEl.disabled = false; - descriptionEl.classList.remove("sidebar__model-description--error"); - showDescription(); - applyModel(); -} - -async function loadModels(): Promise { - try { - const response = await fetch("/v1/models"); - if (!response.ok) { - throw new Error(`GET /v1/models answered ${response.status}`); - } - const catalog = (await response.json()) as { data?: CatalogModel[] }; - renderModels(Array.isArray(catalog.data) ? catalog.data : []); - } catch (error) { - pickerEl.textContent = ""; - pickerEl.appendChild(new Option("Model catalog unavailable", "")); - pickerEl.disabled = true; - descriptionEl.textContent = `Could not load the model catalog: ${(error as Error).message}`; - descriptionEl.classList.add("sidebar__model-description--error"); - } -} - -// A pushed catalog means the gateway returned after an outage; refresh the -// picker in place so a boot-time "Model catalog unavailable" heals itself. -workbenchSocket.onModels(renderModels); - -pickerEl.addEventListener("change", () => { - showDescription(); - applyModel(); -}); - -void loadModels(); diff --git a/crates/promptforge-wb-server/ui/src/workbench-socket.ts b/crates/promptforge-wb-server/ui/src/workbench-socket.ts deleted file mode 100644 index 05f48d0c..00000000 --- a/crates/promptforge-wb-server/ui/src/workbench-socket.ts +++ /dev/null @@ -1,289 +0,0 @@ -// The persistent workbench socket: one WebSocket to /ws carries every -// downstream JSON frame - chat replies for in-flight generations and -// unsolicited status updates from the server's observer. Chat requests are -// multiplexed by an incrementing id the server echoes on that chat's -// delta/done/error frames; the UI runs one chat at a time, so the pending -// map holds at most one entry in practice. - -/** One observer status update, as sent by the server. */ -export interface StatusFrame { - type: "status"; - label: string; - description: string; - severity: "info" | "debug" | "error"; - activity: "general" | "thinking" | "generating"; - progress: { current: number; total: number } | null; -} - -/** One entry of the gateway's model catalog, as fetched or pushed. */ -export interface CatalogModel { - id: string; - description?: string; -} - -/** A pushed model catalog, sent when the gateway comes back after an outage. */ -export interface ModelsFrame { - type: "models"; - models: CatalogModel[]; -} - -/** The chat payload sent upstream in one `{"type":"chat",...}` frame. */ -export interface ChatPayload { - model: string; - messages: Array<{ role: string; content: string }>; -} - -interface PendingChat { - onDelta: (content: string) => void; - resolve: () => void; - reject: (error: Error) => void; - started: boolean; - settled: boolean; -} - -interface ServerFrame { - type?: unknown; - id?: unknown; - content?: unknown; - message?: unknown; - models?: unknown; -} - -function defaultUrl(): string { - return `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/ws`; -} - -// Reconnect backoff: the first retry waits a second, each failure doubles -// it, and the cap keeps a down server from pushing the wait past 30 s. -const RECONNECT_INITIAL_MS = 1000; -const RECONNECT_MAX_MS = 30_000; - -export class WorkbenchSocket { - private socket: WebSocket | null = null; - private opening: { socket: WebSocket; promise: Promise } | null = null; - private nextId = 1; - private reconnectDelayMs = RECONNECT_INITIAL_MS; - private reconnectTimer: ReturnType | null = null; - private readonly pending = new Map(); - private readonly statusHandlers = new Set<(frame: StatusFrame) => void>(); - private readonly modelsHandlers = new Set<(models: CatalogModel[]) => void>(); - private readonly disconnectHandlers = new Set<() => void>(); - - constructor(private readonly url: string = defaultUrl()) {} - - /** Opens the socket unless it is already open or opening. */ - connect(): void { - // A failed open is ignored here: `onerror` has already reset the state, - // and the next `streamChat` retries through `ensureOpen`. - void this.ensureOpen().catch(() => {}); - } - - /** Registers a handler for unsolicited status frames. */ - onStatus(handler: (frame: StatusFrame) => void): void { - this.statusHandlers.add(handler); - } - - /** Registers a handler for pushed model catalogs. */ - onModels(handler: (models: CatalogModel[]) => void): void { - this.modelsHandlers.add(handler); - } - - /** Registers a handler fired when the socket disconnects. */ - onDisconnect(handler: () => void): void { - this.disconnectHandlers.add(handler); - } - - /** - * Sends one id-tagged chat frame and resolves when its `done` frame - * arrives. Rejects on an `error` frame, or on a socket close before any - * content streamed; a close after content started resolves, mirroring an - * SSE body that ends early. Aborting the signal detaches the chat and - * recycles the socket, which is what makes the server drop the orphaned - * gateway stream. - */ - async streamChat( - payload: ChatPayload, - onDelta: (content: string) => void, - signal: AbortSignal, - ): Promise { - await this.ensureOpen(); - const socket = this.socket; - if (!socket || socket.readyState !== WebSocket.OPEN) { - throw new Error("the workbench socket is not open"); - } - const id = this.nextId++; - await new Promise((resolve, reject) => { - const onAbort = (): void => { - if (!this.pending.has(id)) return; - this.settle(id, (chat) => chat.resolve()); - this.reopen(); - }; - const finish = (): void => signal.removeEventListener("abort", onAbort); - this.pending.set(id, { - onDelta, - resolve: () => { - finish(); - resolve(); - }, - reject: (error: Error) => { - finish(); - reject(error); - }, - started: false, - settled: false, - }); - signal.addEventListener("abort", onAbort, { once: true }); - try { - socket.send(JSON.stringify({ type: "chat", id, ...payload })); - } catch (error) { - this.settle(id, (chat) => - chat.reject(error instanceof Error ? error : new Error(String(error))), - ); - } - }); - } - - private ensureOpen(): Promise { - if (this.socket?.readyState === WebSocket.OPEN) { - return Promise.resolve(); - } - if (this.opening) { - return this.opening.promise; - } - const socket = new WebSocket(this.url); - this.socket = socket; - const entry = { socket, promise: Promise.resolve() }; - entry.promise = new Promise((resolve, reject) => { - socket.onopen = () => { - if (this.opening === entry) this.opening = null; - if (this.reconnectTimer !== null) { - clearTimeout(this.reconnectTimer); - this.reconnectTimer = null; - } - this.reconnectDelayMs = RECONNECT_INITIAL_MS; - resolve(); - }; - // A failure while opening rejects the waiters; a failure on an - // established socket is followed by close, which settles pendings. - socket.onerror = () => { - if (this.socket === socket) this.socket = null; - if (this.opening === entry) this.opening = null; - reject(new Error("the workbench socket failed to open")); - }; - }); - this.opening = entry; - socket.onmessage = (event: MessageEvent) => this.route(event); - socket.onclose = () => { - if (this.socket === socket) this.socket = null; - if (this.opening === entry) this.opening = null; - this.settleAll(); - for (const handler of this.disconnectHandlers) { - handler(); - } - this.scheduleReconnect(); - }; - return entry.promise; - } - - /** - * Schedules the next reconnect attempt with exponential backoff. One - * timer at a time: a close while an attempt is already waiting does not - * stack a second. - */ - private scheduleReconnect(): void { - if (this.reconnectTimer !== null) { - return; - } - const delay = this.reconnectDelayMs; - this.reconnectDelayMs = Math.min(delay * 2, RECONNECT_MAX_MS); - this.reconnectTimer = setTimeout(() => { - this.reconnectTimer = null; - // A failed attempt ends in onclose, which schedules the next one. - void this.ensureOpen().catch(() => {}); - }, delay); - } - - /** Closes the current socket and opens a fresh one. */ - private reopen(): void { - const socket = this.socket; - if (socket) { - // An intentional recycle, not a dropout: skip the disconnect - // handlers and the reconnect backoff for this close. - socket.onclose = null; - socket.close(); - } - // Same contract as `connect`: a failed reopen is retried by the next - // `streamChat`. - void this.ensureOpen().catch(() => {}); - } - - private route(event: MessageEvent): void { - let frame: ServerFrame; - try { - frame = JSON.parse(String(event.data)) as ServerFrame; - } catch { - // A non-JSON frame carries no chat or status event; keep reading. - return; - } - if (frame.type === "status") { - const status = frame as unknown as StatusFrame; - for (const handler of this.statusHandlers) { - handler(status); - } - return; - } - if (frame.type === "models") { - const models = Array.isArray(frame.models) ? (frame.models as CatalogModel[]) : []; - for (const handler of this.modelsHandlers) { - handler(models); - } - return; - } - if (typeof frame.id !== "number") return; - const chat = this.pending.get(frame.id); - // A reply for a detached (aborted) chat is dropped. - if (!chat) return; - if (frame.type === "delta" && typeof frame.content === "string" && frame.content !== "") { - chat.started = true; - chat.onDelta(frame.content); - return; - } - if (frame.type === "done") { - this.settle(frame.id, (c) => c.resolve()); - return; - } - if (frame.type === "error") { - this.settle(frame.id, (c) => - c.reject( - new Error( - typeof frame.message === "string" && frame.message !== "" - ? frame.message - : "the chat stream failed", - ), - ), - ); - } - } - - /** Settles one pending chat exactly once and drops it from the map. */ - private settle(id: number, fn: (chat: PendingChat) => void): void { - const chat = this.pending.get(id); - if (!chat || chat.settled) return; - chat.settled = true; - this.pending.delete(id); - fn(chat); - } - - /** Settles every pending chat after the socket closed under it. */ - private settleAll(): void { - for (const id of [...this.pending.keys()]) { - this.settle(id, (chat) => { - if (chat.started) { - chat.resolve(); - } else { - chat.reject(new Error("the workbench socket closed before the reply completed")); - } - }); - } - } -} diff --git a/crates/promptforge-wb-server/ui/style.css b/crates/promptforge-wb-server/ui/style.css deleted file mode 100644 index 2ea125b8..00000000 --- a/crates/promptforge-wb-server/ui/style.css +++ /dev/null @@ -1,527 +0,0 @@ -/* ========================================================================== - PromptForge workbench skin - - Every visual value the workbench owns is a CSS custom property in the - :root block below: palette, type, spacing, radius, and the status bar's - progress and LED effect. Reskinning the UI means editing this one block - (or overriding it from an additional stylesheet loaded after this one); - no rule below the block hardcodes a color or a themed length. Every - var() use carries a fallback, so deleting a variable degrades to the - stock skin instead of breaking the property. - - The murm-ui bridge (the .mur-app block after :root) maps the vendored - chat UI's --mur-* variables onto the workbench variables, so the chat - panel skins from the same block. It cannot live inside :root: murm-ui - declares its dark-theme variables on .mur-app[data-theme="dark"] itself, - and a custom property set on the element beats anything inherited from - :root. The bridge therefore repeats that selector; style.css loads after - the bundled app.css, so these declarations win the tie. - ========================================================================== */ - -:root { - /* Surfaces */ - --bg: #0d0e12; /* window background, chat background */ - --bg-raised: #14161c; /* raised surfaces: sidebar, status bar, cards */ - --bg-hover: #1a1d25; /* hover washes and user message bubbles */ - --bg-sidebar: var(--bg-raised, #14161c); - --bg-composer: var(--bg, #0d0e12); /* the chat composer form */ - - /* Text and borders */ - --text: #d6d9e0; /* 13:1 on --bg */ - --text-muted: #8b90a0; /* 6.0:1 on --bg, the dimmest legal body text */ - --border: #262a33; - - /* Accent and semantics */ - --accent: #7c7fd4; /* primary action (send button) */ - --accent-dim: #5658a0; /* focus borders */ - --danger: #b0606a; /* recording background, non-text danger accents */ - --danger-text: #cf7f88; /* danger lightened past 4.5:1 for text on --bg */ - --on-danger: #ffffff; /* icon or text on a --danger fill (recording mic) */ - --hover-glow: var(--accent, #5b9cf5); /* ring and bloom on hover */ - - /* Type */ - --font-prose: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; - --code-font: ui-monospace, "Cascadia Code", Consolas, "Courier New", monospace; - - /* Spacing scale and radius (shell chrome) */ - --space-xs: 4px; - --space-sm: 6px; - --space-md: 8px; - --space-lg: 12px; - --space-xl: 16px; - --radius: 6px; - - /* Sidebar */ - --sidebar-width: 220px; - - /* Status bar */ - --status-bar-height: 28px; - --status-bar-bg: var(--bg-raised, #14161c); - --status-bar-text: var(--text-muted, #8b90a0); - --status-bar-text-error: var(--danger-text, #cf7f88); - --status-bar-padding-inline: var(--space-lg, 12px); - --status-bar-gap: var(--space-lg, 12px); - - /* Status bar progress bar */ - --progress-width: 96px; - --progress-height: 6px; - --progress-fill: #4caf7d; - --progress-track: rgba(255, 255, 255, 0.08); - --progress-glow: 4px; /* blur radius of the fill's box-shadow glow */ - - /* Status bar activity LED */ - --led-size: 10px; - --led-green: #4caf7d; /* generating activity */ - --led-amber: #d9a03f; /* thinking activity */ - --led-off: rgba(255, 255, 255, 0.08); /* the unlit lens */ - --led-core: #ffffff; /* hot center of the lit gradient */ - --led-glow-radius: 6px; /* base blur of the layered bloom */ - --led-pulse-ms: 250ms; /* hold window and ease-out decay; read by JS */ - --led-fade-in-ms: 60ms; /* fast ease-in when a pulse lights the LED */ - --led-lens-highlight: rgba(255, 255, 255, 0.18); - --led-lens-shadow: rgba(0, 0, 0, 0.45); - - /* Status bar REC badge */ - --rec-idle: #552222; - --rec-active: #ff0000; - - /* Scrollbars (applied globally below) */ - --scrollbar-width: 8px; /* thin; also the thumb's rounding diameter */ - --scrollbar-thumb: rgba(255, 255, 255, 0.16); /* translucent on any surface */ - --scrollbar-thumb-hover: rgba(255, 255, 255, 0.28); -} - -/* -------------------------------------------------------------------------- - murm-ui skinning bridge. The vendored chat UI themes itself from --mur-* - variables (ui/src/chat/styles/base.css); mapping them here keeps the - whole UI skinned from the :root block above. Workbench var on the right, - murm-ui var on the left: - - --mur-bg <- --bg chat background - --mur-surface <- --bg-raised code blocks, cards - --mur-surface-user <- --bg-hover user message bubble - --mur-hover-bg <- --bg-hover hover washes - --mur-text <- --text - --mur-text-secondary <- --text - --mur-text-muted <- --text-muted - --mur-inverse-text <- --bg icon on the accent send button - --mur-border <- --border - --mur-primary <- --accent send button background - --mur-danger{,-text,-bg,-border,-hover-bg} <- --danger / --danger-text - --mur-success <- --led-green - --mur-code-heading-bg <- --bg-hover - --mur-font <- --font-prose - - murm-ui's dark shadows and overlay scrims are palette-neutral black - alphas and are left as shipped. Only the dark theme is mapped: the - workbench's template always sets data-theme="dark" on .mur-app. - -------------------------------------------------------------------------- */ -.mur-app[data-theme="dark"] { - --mur-bg: var(--bg, #0d0e12); - --mur-surface: var(--bg-raised, #14161c); - --mur-surface-user: var(--bg-hover, #1a1d25); - --mur-hover-bg: var(--bg-hover, #1a1d25); - --mur-text: var(--text, #d6d9e0); - --mur-text-secondary: var(--text, #d6d9e0); - --mur-text-muted: var(--text-muted, #8b90a0); - --mur-inverse-text: var(--bg, #0d0e12); - --mur-border: var(--border, #262a33); - --mur-primary: var(--accent, #7c7fd4); - --mur-danger: var(--danger, #b0606a); - --mur-danger-text: var(--danger-text, #cf7f88); - --mur-danger-bg: color-mix(in oklab, var(--danger, #b0606a) 20%, transparent); - --mur-danger-border: color-mix(in oklab, var(--danger, #b0606a) 38%, transparent); - --mur-danger-hover-bg: color-mix(in oklab, var(--danger, #b0606a) 14%, transparent); - --mur-success: var(--led-green, #4caf7d); - --mur-code-heading-bg: var(--bg-hover, #1a1d25); - --mur-font: var(--font-prose, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif); - --mur-header-button-bg: color-mix(in oklab, var(--bg-raised, #14161c) 82%, transparent); - --mur-header-title-bg: color-mix(in oklab, var(--bg-raised, #14161c) 62%, transparent); - - /* Composer growth cap: murm-ui defaults --mur-input-max-height to 200px, - which clips long voice transcripts; 40vh keeps them visible. It must be - declared on .mur-app, not :root, because murm-ui sets the variable on - .mur-app itself and an element-local declaration beats inheritance. */ - --mur-input-max-height: 40vh; -} - -/* murm-ui hardcodes `font-family: monospace` for code and tool chrome; - route those through --code-font so the skin owns the mono stack. These - selectors tie murm-ui's own, and this stylesheet loads later. */ -.mur-message code, -.mur-code-language, -.mur-block-tool { - font-family: var(--code-font, ui-monospace, "Cascadia Code", Consolas, "Courier New", monospace); -} - -/* The composer form floats over the chat on murm-ui's --mur-bg; give the - skin its own hook so the composer can differ from the chat background. */ -.mur-app .mur-chat-form { - background-color: var(--bg-composer, #0d0e12); -} - -/* Hover glow: icon buttons and the picker trade murm-ui's background wash - for a 1px accent ring plus a soft bloom. */ -.mur-form-icon-btn:hover:not(:disabled), -.sidebar__picker:hover, -.voice-mic:hover { - background-color: transparent; - box-shadow: - 0 0 0 1px var(--hover-glow, #5b9cf5), - 0 0 4px color-mix(in oklab, var(--hover-glow, #5b9cf5) 40%, transparent); -} - -* { - box-sizing: border-box; -} - -/* -------------------------------------------------------------------------- - Custom scrollbars, applied globally: a thin rounded translucent thumb on - a transparent track, so the bar reads as an overlay on whatever surface - scrolls. WebView2 is Chromium, so the ::-webkit-scrollbar pseudoelements - are the styled surface; the standard `scrollbar-width`/`scrollbar-color` - pair carries the same intent to any future non-Chromium host. Widths and - colors are variables so a skin can retune them from the :root block. - -------------------------------------------------------------------------- */ -* { - scrollbar-width: thin; - scrollbar-color: var(--scrollbar-thumb, rgba(255, 255, 255, 0.16)) transparent; -} - -::-webkit-scrollbar { - width: var(--scrollbar-width, 8px); - height: var(--scrollbar-width, 8px); -} - -::-webkit-scrollbar-track { - background: transparent; -} - -::-webkit-scrollbar-thumb { - background: var(--scrollbar-thumb, rgba(255, 255, 255, 0.16)); - border-radius: calc(var(--scrollbar-width, 8px) / 2); -} - -::-webkit-scrollbar-thumb:hover { - background: var(--scrollbar-thumb-hover, rgba(255, 255, 255, 0.28)); -} - -::-webkit-scrollbar-corner { - background: transparent; -} - -html, -body { - margin: 0; - height: 100%; - background: var(--bg, #0d0e12); - color: var(--text, #d6d9e0); - font-family: var(--font-prose, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif); - font-size: 14px; - line-height: 1.55; -} - -/* The window is one column: the shell fills it above the full-width - status bar. */ -body { - display: flex; - flex-direction: column; - height: 100vh; - margin: 0; -} - -.shell { - display: flex; - flex: 1; - min-height: 0; -} - -.sidebar { - width: var(--sidebar-width, 220px); - flex: none; - display: flex; - flex-direction: column; - gap: var(--space-md, 8px); - padding: var(--space-xl, 16px) var(--space-lg, 12px); - background: var(--bg-sidebar, #14161c); - border-right: 1px solid var(--border, #262a33); -} - -.sidebar__brand { - font-size: 15px; - font-weight: 600; - letter-spacing: 0.02em; - color: var(--text, #d6d9e0); - padding: var(--space-xs, 4px) var(--space-sm, 6px) var(--space-lg, 12px); - border-bottom: 1px solid var(--border, #262a33); - margin-bottom: var(--space-md, 8px); -} - -.sidebar__picker-label { - font-size: 11px; - text-transform: uppercase; - letter-spacing: 0.08em; - color: var(--text-muted, #8b90a0); - padding: 0 var(--space-sm, 6px); -} - -.sidebar__picker { - width: 100%; - padding: var(--space-sm, 6px) var(--space-md, 8px); - background: var(--bg, #0d0e12); - color: var(--text, #d6d9e0); - border: 1px solid var(--border, #262a33); - border-radius: var(--radius, 6px); - font-family: var(--font-prose, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif); - font-size: 13px; - outline: none; -} - -.sidebar__picker:focus { - border-color: var(--accent-dim, #5658a0); -} - -.sidebar__picker:disabled { - opacity: 0.5; -} - -.sidebar__model-description { - font-size: 12px; - color: var(--text-muted, #8b90a0); - padding: 2px var(--space-sm, 6px); - overflow-wrap: anywhere; -} - -.sidebar__model-description--error { - color: var(--danger-text, #cf7f88); -} - -/* The dockview column beside the sidebar: the dock fills it. */ -.dock-column { - flex: 1; - display: flex; - flex-direction: column; - min-width: 0; -} - -.dock { - flex: 1; - min-height: 0; -} - -/* One panel fills the window; its tab bar is chrome, not information. */ -.dock .dv-tabs-and-actions-container { - display: none; -} - -.chat-panel { - height: 100%; -} - -/* The status bar: a permanent full-width footer below the shell. The left - text carries the observer's current label; the right group holds the REC - badge and the slot, which holds the progress bar or the activity LED - (never both - the slot's children are mutually exclusive, driven by the - hidden attribute). min-height rather than height so a descender never - clips against a fixed box. */ -.status-bar { - flex: none; - min-height: var(--status-bar-height, 24px); - display: flex; - align-items: center; - gap: var(--status-bar-gap, 12px); - padding-inline: var(--status-bar-padding-inline, 12px); - background: var(--status-bar-bg, #14161c); - border-top: 1px solid var(--border, #262a33); - font-size: 13px; - line-height: 1.4; - color: var(--status-bar-text, #8b90a0); - user-select: none; -} - -.status-bar__text { - flex: 1; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.status-bar__text--error { - color: var(--status-bar-text-error, #cf7f88); -} - -.status-bar__right { - display: flex; - align-items: center; - gap: 4px; -} - -/* The REC badge: always visible, a dim dark-red outline while idle and a - lit bright red while the mic records. */ -.status-bar__rec { - display: inline-flex; - align-items: center; - font-size: 11px; - font-weight: 700; - letter-spacing: 0.05em; - line-height: 1; - padding: 2px 5px; - border: 1px solid var(--rec-idle, #552222); - border-radius: 2px; - color: var(--rec-idle, #552222); - transition: color 0.1s, border-color 0.1s, box-shadow 0.15s; -} - -.status-bar__rec--active { - color: var(--rec-active, #ff0000); - border-color: var(--rec-active, #ff0000); - box-shadow: 0 0 3px var(--rec-active, #ff0000); -} - -.status-bar__slot { - flex: none; - display: flex; - align-items: center; - justify-content: flex-end; - min-width: var(--progress-width, 96px); -} - -.status-bar__progress[hidden], -.status-bar__led[hidden] { - display: none; -} - -/* The progress bar: a thin rounded track with a green fill and a subtle - glow. WebView2 is Chromium, so the webkit progress pseudoelements are the - styled surface. */ -.status-bar__progress { - width: var(--progress-width, 96px); - height: var(--progress-height, 6px); - appearance: none; - border: none; - border-radius: calc(var(--progress-height, 6px) / 2); - background: var(--progress-track, rgba(255, 255, 255, 0.08)); - overflow: hidden; -} - -.status-bar__progress::-webkit-progress-bar { - background: var(--progress-track, rgba(255, 255, 255, 0.08)); - border-radius: calc(var(--progress-height, 6px) / 2); -} - -.status-bar__progress::-webkit-progress-value { - background: var(--progress-fill, #4caf7d); - border-radius: calc(var(--progress-height, 6px) / 2); - box-shadow: 0 0 var(--progress-glow, 4px) var(--progress-fill, #4caf7d); -} - -/* The activity LED: a small circle standing in the slot whenever no frame - carries progress. Idle is an unlit lens - a dark translucent disc with a - subtle inner highlight. A pulse adds the --generating or --thinking - modifier: a bright radial-gradient core with a layered box-shadow bloom. - The idle rule's transition is the slow ease-out decay; the modifier's - own transition makes the fade-in fast. */ -.status-bar__led { - width: var(--led-size, 10px); - height: var(--led-size, 10px); - border-radius: 50%; - background: var(--led-off, rgba(255, 255, 255, 0.08)); - box-shadow: - inset 0 1px 1px var(--led-lens-highlight, rgba(255, 255, 255, 0.18)), - inset 0 -1px 2px var(--led-lens-shadow, rgba(0, 0, 0, 0.45)); - transition: - background var(--led-pulse-ms, 250ms) ease-out, - box-shadow var(--led-pulse-ms, 250ms) ease-out; -} - -.status-bar__led--generating, -.status-bar__led--thinking { - transition: - background var(--led-fade-in-ms, 60ms) ease-in, - box-shadow var(--led-fade-in-ms, 60ms) ease-in; -} - -.status-bar__led--generating { - background: radial-gradient(circle, var(--led-core, #ffffff) 0%, var(--led-green, #4caf7d) 60%); - box-shadow: - 0 0 calc(var(--led-glow-radius, 6px) / 2) var(--led-green, #4caf7d), - 0 0 var(--led-glow-radius, 6px) var(--led-green, #4caf7d), - 0 0 calc(var(--led-glow-radius, 6px) * 2) color-mix(in oklab, var(--led-green, #4caf7d) 55%, transparent); -} - -.status-bar__led--thinking { - background: radial-gradient(circle, var(--led-core, #ffffff) 0%, var(--led-amber, #d9a03f) 60%); - box-shadow: - 0 0 calc(var(--led-glow-radius, 6px) / 2) var(--led-amber, #d9a03f), - 0 0 var(--led-glow-radius, 6px) var(--led-amber, #d9a03f), - 0 0 calc(var(--led-glow-radius, 6px) * 2) color-mix(in oklab, var(--led-amber, #d9a03f) 55%, transparent); -} - -.voice-status { - width: 100%; - max-width: var(--mur-chat-form-width, 768px); - font-size: 12px; - color: var(--text-muted, #8b90a0); - max-height: 0; - overflow: hidden; - transition: max-height 0.15s ease-out, padding 0.15s ease-out; -} - -.voice-status--visible { - max-height: 40px; - padding: 0 var(--space-md, 8px); -} - -.voice-status--error { - color: var(--danger-text, #cf7f88); -} - -.voice-mic { - flex: none; -} - -/* Recording mic: a steady danger fill with a matching ring and bloom. - The hover form needs :not(:disabled) to match the glow rule's specificity - - the mic also carries mur-form-icon-btn, whose hover would otherwise - outrank this and strip the fill. */ -.voice-mic--recording { - color: var(--on-danger, #ffffff); - background: var(--danger, #b0606a); - border-radius: 50%; - box-shadow: - 0 0 0 1px var(--danger, #b0606a), - 0 0 6px color-mix(in oklab, var(--danger, #b0606a) 55%, transparent); -} - -.voice-mic--recording:hover:not(:disabled) { - color: var(--on-danger, #ffffff); - background: var(--danger, #b0606a); - border-radius: 50%; - box-shadow: - 0 0 0 1px var(--danger, #b0606a), - 0 0 8px color-mix(in oklab, var(--danger, #b0606a) 70%, transparent); -} - -/* Send button hover: accent glow in both normal and generating states. */ -.mur-action-btn:hover:not(:disabled) { - box-shadow: - 0 0 0 1px var(--hover-glow, #5b9cf5), - 0 0 4px color-mix(in oklab, var(--hover-glow, #5b9cf5) 40%, transparent); -} - -/* Composer overlap fix: make the form container participate in the column - flex flow so the scroll area shrinks to accommodate it. The embedded - workbench mode only - murm-ui's standalone keeps absolute positioning. */ -.mur-app-embedded .mur-chat-form-container { - position: relative; -} - -.mur-app-embedded.mur-chat-empty .mur-chat-form-container { - bottom: auto; - transform: none; -} - -.mur-app-embedded .mur-chat-history { - padding-bottom: 1rem; -} diff --git a/crates/promptforge-wb-server/ui/test/smoke.mjs b/crates/promptforge-wb-server/ui/test/smoke.mjs deleted file mode 100644 index bebaa575..00000000 --- a/crates/promptforge-wb-server/ui/test/smoke.mjs +++ /dev/null @@ -1,793 +0,0 @@ -// Smoke test: loads dist/index.html into jsdom, imports the bundled -// dist/app.js, asserts the chat UI mounts without throwing, and drives one -// chat round-trip through a scripted WebSocket. Guards the DOM contract -// between index.html and the vendored murm-ui (its components throw when a -// required class is missing) and the wire contract of WorkbenchProvider -// (chat frame shape against /ws, delta frames rendered into the history). -// Run after `npm run build`: `npm test`. -import { readFile } from "node:fs/promises"; -import path from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; -import { JSDOM } from "jsdom"; - -const uiDir = path.dirname(fileURLToPath(import.meta.url)); -const distDir = path.join(uiDir, "..", "dist"); - -const html = await readFile(path.join(distDir, "index.html"), "utf8"); -const dom = new JSDOM(html, { url: "http://127.0.0.1:7910/", pretendToBeVisual: true }); - -const { window } = dom; - -// jsdom lacks layout APIs the feed touches; no-op stubs are enough because -// nothing scrolls in the test. -window.matchMedia = - window.matchMedia || - (() => ({ - matches: false, - media: "", - addEventListener() {}, - removeEventListener() {}, - addListener() {}, - removeListener() {}, - dispatchEvent: () => false, - })); -window.ResizeObserver = class { - observe() {} - unobserve() {} - disconnect() {} -}; -window.IntersectionObserver = class { - observe() {} - unobserve() {} - disconnect() {} - takeRecords() { - return []; - } -}; -window.Element.prototype.scrollTo = () => {}; -window.HTMLElement.prototype.scrollIntoView = () => {}; -// jsdom has no layout engine, so scrollHeight is always 0 and murm-ui's -// adjustHeight would pin the composer at 0px. Simulate line-based metrics -// for textareas so composer auto-growth is observable as inline height. -Object.defineProperty(window.HTMLElement.prototype, "scrollHeight", { - configurable: true, - get() { - if (this instanceof window.HTMLTextAreaElement) { - return 36 + (this.value.split("\n").length - 1) * 21; - } - return 0; - }, -}); -// A scripted WebSocket stands in for the server's persistent /ws route. It -// must live on globalThis: the bundle calls the global `WebSocket`, not -// `window.WebSocket`. The app opens one socket on load; each chat frame -// sent on it is captured and answered with two delta frames and a done -// frame echoing the frame's id, scheduled in order so the provider's -// round-trip runs. The socket stays open after `done` - it is persistent. -const chatSockets = []; -class FakeWebSocket { - static CONNECTING = 0; - static OPEN = 1; - static CLOSING = 2; - static CLOSED = 3; - constructor(url) { - this.url = url; - this.readyState = FakeWebSocket.CONNECTING; - chatSockets.push(this); - setTimeout(() => { - this.readyState = FakeWebSocket.OPEN; - this.onopen?.(); - }, 0); - } - // The voice path attaches with addEventListener; chain listeners onto the - // on* properties the chat path assigns directly. - addEventListener(type, listener) { - const prop = `on${type}`; - const previous = this[prop]; - this[prop] = previous ? (event) => (previous(event), listener(event)) : listener; - } - send(data) { - let frame; - try { - frame = JSON.parse(data); - } catch { - return; // voice control words ("start"/"stop") are not JSON - } - if (frame.type !== "chat") return; - this.chatFrame = frame; - const frames = [ - { type: "delta", content: "Hello", id: frame.id }, - { type: "delta", content: " back", id: frame.id }, - { type: "done", id: frame.id }, - ]; - for (const reply of frames) { - queueMicrotask(() => this.onmessage?.({ data: JSON.stringify(reply) })); - } - } - close() { - this.readyState = FakeWebSocket.CLOSED; - } -} -globalThis.WebSocket = FakeWebSocket; - -// Voice capture stubs: jsdom has no audio stack, so the mic button's -// getUserMedia/AudioContext path is scripted to succeed. The bundle reads -// the globals, so they land on both window and globalThis; `navigator` is -// Node's own global (the key-copy loop below skips keys already present), -// so mediaDevices goes on it directly. -const fakeAudioStream = { getTracks: () => [{ stop() {} }] }; -const fakeMediaDevices = { getUserMedia: () => Promise.resolve(fakeAudioStream) }; -window.navigator.mediaDevices = fakeMediaDevices; -globalThis.navigator.mediaDevices = fakeMediaDevices; -class FakeAudioContext { - constructor() { - this.destination = {}; - this.audioWorklet = { addModule: () => Promise.resolve() }; - } - createMediaStreamSource() { - return { connect() {}, disconnect() {} }; - } - close() { - return Promise.resolve(); - } -} -class FakeAudioWorkletNode { - constructor() { - this.port = { onmessage: null }; - } - connect() {} - disconnect() {} -} -window.AudioContext = FakeAudioContext; -globalThis.AudioContext = FakeAudioContext; -window.AudioWorkletNode = FakeAudioWorkletNode; -globalThis.AudioWorkletNode = FakeAudioWorkletNode; - -// Pushes one observer status frame down the persistent socket, as the -// server's /ws route would. Fields default to a plain idle update. -function emitStatus(overrides = {}) { - const socket = chatSockets[0]; - socket?.onmessage?.({ - data: JSON.stringify({ - type: "status", - label: "Ready", - description: "", - severity: "info", - activity: "general", - progress: null, - ...overrides, - }), - }); -} -// A scripted fetch stands in for the model catalog. The catalog answers with -// one model so the picker enables and submission is unblocked; any other -// fetch - including the retired POST /chat SSE path - rejects the test. -globalThis.fetch = (url) => { - if (url === "/v1/models") { - return Promise.resolve( - new Response(JSON.stringify({ data: [{ id: "test-model", description: "scripted" }] }), { - status: 200, - headers: { "content-type": "application/json" }, - }), - ); - } - return Promise.reject(new Error(`unexpected fetch in the smoke test: ${url}`)); -}; - -for (const key of [ - "document", - "navigator", - "location", - "localStorage", - "HTMLElement", - "HTMLTextAreaElement", - "HTMLButtonElement", - "Node", - "Element", - "Event", - "CustomEvent", - "MutationObserver", - "Option", - "DOMParser", - "NodeFilter", - "ResizeObserver", - "IntersectionObserver", - "getComputedStyle", - "requestAnimationFrame", - "cancelAnimationFrame", -]) { - if (!(key in globalThis) && key in window) { - globalThis[key] = window[key]; - } -} -// Node ships its own Event and CustomEvent globals, so the copy loop skips -// them - but events the bundle dispatches into the jsdom document must be -// jsdom-realm instances: jsdom's dispatchEvent rejects Node's Event with -// "parameter 1 is not of type 'Event'". -globalThis.Event = window.Event; -globalThis.CustomEvent = window.CustomEvent; -globalThis.window = window; -globalThis.document = window.document; - -await import(pathToFileURL(path.join(distDir, "app.js")).href); - -// The bundle mounts dockview on #dock with one chat panel, and ChatUI on -// the .mur-app inside it: a successful mount leaves the murm structure -// intact and renders the empty-chat state. -const dock = window.document.querySelector("#dock"); -const app = window.document.querySelector("#dock .mur-app"); -const history = window.document.querySelector(".mur-chat-history"); -const input = window.document.querySelector(".mur-chat-input"); -const send = window.document.querySelector(".mur-send-btn"); -const mic = window.document.querySelector(".voice-mic"); -const statusBar = window.document.querySelector(".status-bar"); -const statusText = window.document.querySelector(".status-bar__text"); -const statusSlot = window.document.querySelector(".status-bar__slot"); -const progressEl = window.document.querySelector(".status-bar__progress"); -const ledEl = window.document.querySelector(".status-bar__led"); - -const failures = []; -if (!dock) failures.push("#dock missing"); -if (dock && !dock.querySelector(".dv-dockview")) { - failures.push("dockview did not initialize inside #dock"); -} -if (!window.document.querySelector("#dock .dv-groupview")) { - failures.push("dockview rendered no group for the chat panel"); -} -if (!app) failures.push(".mur-app missing inside the dock"); -if (!history) failures.push(".mur-chat-history missing"); -if (!input) failures.push(".mur-chat-input missing"); -if (!send) failures.push(".mur-send-btn missing"); -if (!mic) failures.push("voice plugin did not insert the mic button"); -if (!statusBar) failures.push("status bar placeholder missing"); -if (statusBar && statusBar.tagName !== "FOOTER") { - failures.push("the status bar is not a