diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e7dd433bd..0b3eec695 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,9 +63,6 @@ jobs: RUSTFLAGS: -D warnings run: cargo check --locked -p gateway-whisper-ffi --lib - - name: Check product dependency boundaries - run: cargo test -p gateway-stt --test it architecture - - name: Clippy run: cargo clippy --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-targets --all-features -- -D warnings diff --git a/AGENTS.md b/AGENTS.md index 9ac8aeaa3..28d872ac3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,14 +23,14 @@ Multi-crate Rust workspace for the PromptForge pipeline engine, the harness that - The four main products are PromptForge, Gateway, Workshop, and Harness - Workshop crates are named workshop-* and must not depend on gateway crates; workshop crates may name the gateway public pair, the promptforge door, and `harness-api` -- Gateway's public surface is two root crates, `gateway-api` and `gateway-api-discovery`; everything else lives under crates/gateway/, a manifestless container private to the family - no outside crate may depend into it, and workshop crates may name only the public pair. Gateway crates must not depend on promptforge or workshop crates +- Gateway's public surface is two root crates, `gateway-api-types` and `gateway-api-discovery`; everything else lives under crates/gateway/, a manifestless container private to the family - no outside crate may depend into it, and workshop crates may name only the public pair. Gateway crates must not depend on promptforge or workshop crates - Workshop crates live under crates/workshop/, a manifestless container private to the family - no outside crate may depend into it; the shell is crates/workshop/shell (package `workshop`), and the server and its subsystems sit beside it with short directory names -- Harness crates are named harness-*. Their public surface is one root crate, `harness-api`; everything else lives under crates/harness/, a fourth manifestless container private to the family, and `harness-api` is its one door - the only outside crate permitted to depend into it. harness-* crates may depend on `promptforge-api-runtime`, `promptforge-api-types`, `gateway-api`, `gateway-api-discovery`, and shared-* crates, never on workshop crates or on a private gateway crate; workshop crates may depend on harness-* only through `harness-api`; promptforge-* and gateway-* crates must not depend on harness crates +- Harness crates are named harness-*. Their public surface is one root crate, `harness-api`; everything else lives under crates/harness/, a fourth manifestless container private to the family, and `harness-api` is its one door - the only outside crate permitted to depend into it. harness-* crates may depend on `promptforge-api-runtime`, `promptforge-api-types`, `gateway-api-types`, `gateway-api-discovery`, and shared-* crates, never on workshop crates or on a private gateway crate; workshop crates may depend on harness-* only through `harness-api`; promptforge-* and gateway-* crates must not depend on harness crates - The composed topology rule: a crate in a family container (crates/promptforge/, crates/gateway/, crates/workshop/, crates/harness/) may depend only on crates at the crates/ root and its own siblings; the root is the public layer. Crates named build-* are meta tooling, exempt from container privacy - PromptForge crates are named promptforge-* and must not depend on gateway, workshop, or harness crates - PromptForge is one door: crates outside the promptforge-* family may depend only on promptforge-api-runtime and promptforge-api-types, never on the internal promptforge-* substrate crates; the crates under crates/promptforge/ are private to the family, and promptforge-api-runtime is the only outside crate permitted to depend into them - The Workshop shell (the `workshop` crate) depends on `workshop-server-api` and never on `workshop-server`; the facade is the shell's entire view of the server -- Shared crates are named shared-*, contain the public API surface across products and downstream crates, and must not depend on any product crates. PromptForge's own public surface is promptforge-api-runtime and promptforge-api-types, named promptforge-* now that the types crate has left shared-*; Gateway's is gateway-api and gateway-api-discovery, named gateway-* now that both have left shared-* +- Shared crates are named shared-*, contain the public API surface across products and downstream crates, and must not depend on any product crates. PromptForge's own public surface is promptforge-api-runtime and promptforge-api-types, named promptforge-* now that the types crate has left shared-*; Gateway's is gateway-api-types and gateway-api-discovery, named gateway-* now that both have left shared-*; the types crate carries the wire vocabulary only, never code - Crates named build-* are for building specific outputs - Dependency rules bind all kinds: normal, dev, build, and target-specific dependencies @@ -41,7 +41,7 @@ Multi-crate Rust workspace for the PromptForge pipeline engine, the harness that - Behavior changes ship with tests in the same change. Preserve product and behavior tests during refactors. Structural tests that an approved plan identifies as unsupported may be removed without replacement by another structural proxy. - A Cargo feature gates a real constraint such as a toolchain requirement or heavy native build. It does not describe product shape. Feature-disabled builds must not leak optional types into core paths. - Runtime and serve paths never compile native dependencies or invoke build tools. Library and serve paths return failures instead of exiting the process or installing process-global state. -- Long-running work reports through `shared-progress`. Producers report operation state, hosts forward it, and renderers format it. +- Long-running gateway work reports through `gateway-progress`, a private gateway family crate: a producer begins an activity with a text, replaces the text as work moves, and drops the guard when done. Consumers outside the family read only the `Progress` wire type from `gateway-api-types`. - Unsafe code stays in its explicitly owned boundary. Every unsafe block documents its safety invariants immediately before the block. - Comments explain a non-obvious constraint, ordering requirement, or workaround. Every platform or external-bug workaround cites its upstream issue URL in the explanatory comment. @@ -56,12 +56,12 @@ Multi-crate Rust workspace for the PromptForge pipeline engine, the harness that ## Structural Rules - Dependencies flow one way: shell -> features -> services -> vocabulary. Never add a dependency from a lower tier to a higher one. If Cargo rejects a cycle, the design is wrong, not the graph. On the SPA side, lazy-loaded panels never import the boot shell; shared code lives in services/ or base/. -- Every workshop-* and harness-* crate's lib.rs opens with a //! doc carrying a `## Invariants` marker that lists what the crate may depend on and what it may not. Read it before adding an import. Every SPA concern directory (ui/editor/, ui/agent/, etc.) has the same in its index.ts. -- No file exceeds 500 lines. If an edit would push a file past 500, split first, then edit. `cargo test -p build-xtask` enforces the tier graph, the lint inheritance, the ceiling over the Rust files in the workshop-* and harness-* crates carrying the marker, and the product-boundary matrix above (including the one-door rules for promptforge and harness and the container privacy rules for crates/promptforge/, crates/gateway/, crates/workshop/, crates/harness/, and the nested crates/gateway/stt/ subsystem, whose only family-visible crate is gateway-stt) across every workspace manifest; `cargo test -p gateway-stt --test it architecture` checks the same product matrix from cargo metadata; the Tauri shell (the `workshop` crate) is exempt until the headless agent mode plan. +- Every workshop-* and harness-* crate's lib.rs (including harness-api) opens with a //! doc carrying a `## Invariants` marker that lists what the crate may depend on and what it may not. The marker is mandatory for those families by package name; a family crate without it fails `cargo test -p build-xtask`. The Tauri shell (the `workshop` crate) is exempt. Read the marker before adding an import. +- No file in a crate carrying the marker exceeds 500 lines. If an edit would push a file past 500, split first, then edit. `cargo test -p build-xtask` enforces the tier graph, the mandatory marker, the lint inheritance, the ceiling over the Rust files in every workshop-* and harness-* crate plus any other crate carrying the marker, and the product-boundary matrix above (including the one-door rules for promptforge and harness and the container privacy rules for crates/promptforge/, crates/gateway/, crates/workshop/, crates/harness/, and the nested crates/gateway/stt/ subsystem, whose only family-visible crate is gateway-stt) across every workspace manifest; the Tauri shell (the `workshop` crate) is exempt from the marker and the ceiling until the headless agent mode plan. - Source directories are flat by default. A subdirectory of source files must contain at least three files; one or two files belong beside the parent module as `foo-bar.rs` (parent stem, dash, kebab label), wired with an explicit path attribute so the module name stays clean: `#[path = "foo-bar.rs"] mod bar;`. The two forms are convertible in both directions: when a `foo-*.rs` sibling group grows to three files, rehydrate it into a `foo/` subdirectory in standard module layout (`foo/bar.rs` beside `foo.rs`) and drop the path attributes; when a subdirectory shrinks below three files, flatten it back to kebab siblings. Apply whichever conversion applies when you touch files in a group on the wrong side of the line. Top-level `tests/` and `benches/` trees are exempt; they follow Cargo target conventions. ## SPA and CSS Rules -- CSS lives beside its TypeScript, never in a separate styles/ tree. A designer finds the styles for the agent chat at ui/agent/agent-session.css, not by grepping a flat directory. Every feature directory is self-contained: .ts, .css, and index.ts together. +- CSS lives beside its TypeScript, never in a separate styles/ tree. A designer finds the styles for the agent chat at parts/agent/agent-session.css, not by grepping a flat directory. Every feature directory is self-contained: .ts, .css, and index.ts together. - No raw color, size, or spacing values in component CSS. Use --ws-* tokens from tokens/. Primitives go in tokens/base.css, intent aliases in tokens/semantic.css, per-component overrides in tokens/component.css. A designer themes the app by editing semantic.css. - No `localStorage`. The SPA never reads or writes browser storage; every persisted value goes through the `ui-storage` adapter to the server. UI state has two homes by scope: account state (preferences and ephemera alike, such as editor toggles, zoom, recent files, and command history) goes to `ui-state.json` in the state directory (the `workshop-user-state` crate, `/user/state`), and workspace-scoped state (anything that should travel with the `.pfwork` document) goes to the workspace file through the server (`/workspace/file/state`). A new persisted value is a new allow-listed key in one of those two buckets, added on the server first. diff --git a/Cargo.lock b/Cargo.lock index 5b85a232c..8a8d6fd49 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1957,12 +1957,13 @@ dependencies = [ "embed-resource", "futures-util", "gateway", - "gateway-api", "gateway-api-discovery", + "gateway-api-types", "gateway-config", "gateway-config-ui", "gateway-local", "gateway-logging", + "gateway-progress", "gateway-protocol", "gateway-routing", "gateway-stt", @@ -1982,7 +1983,6 @@ dependencies = [ "serde_json", "sha2 0.11.0", "shared-loopback", - "shared-progress", "subtle", "sysinfo", "tempfile", @@ -2003,25 +2003,26 @@ dependencies = [ ] [[package]] -name = "gateway-api" +name = "gateway-api-discovery" version = "0.3.0" dependencies = [ + "libc", "serde", "serde_json", - "time", + "shared-error-source", + "tempfile", + "thiserror 2.0.19", + "windows-sys 0.61.2", "workspace-hack", ] [[package]] -name = "gateway-api-discovery" +name = "gateway-api-types" version = "0.3.0" dependencies = [ - "libc", "serde", "serde_json", - "tempfile", - "thiserror 2.0.19", - "windows-sys 0.61.2", + "time", "workspace-hack", ] @@ -2031,12 +2032,13 @@ version = "0.3.0" dependencies = [ "dotenvy", "futures-util", - "gateway-api", + "gateway-api-types", "hmac", "reqwest", "serde", "serde_json", "sha2 0.11.0", + "shared-error-source", "thiserror 2.0.19", "time", "tokio", @@ -2047,7 +2049,7 @@ dependencies = [ name = "gateway-config" version = "0.3.0" dependencies = [ - "gateway-api", + "gateway-api-types", "reqwest", "serde", "serde_json", @@ -2080,6 +2082,7 @@ dependencies = [ "async-trait", "flate2", "gateway-config", + "gateway-progress", "gateway-protocol", "gateway-routing", "minijinja", @@ -2089,7 +2092,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.11.0", - "shared-progress", + "shared-error-source", "tar", "tempfile", "thiserror 2.0.19", @@ -2110,6 +2113,15 @@ dependencies = [ "workspace-hack", ] +[[package]] +name = "gateway-progress" +version = "0.3.0" +dependencies = [ + "gateway-api-types", + "tokio", + "workspace-hack", +] + [[package]] name = "gateway-protocol" version = "0.3.0" @@ -2117,7 +2129,7 @@ dependencies = [ "async-trait", "bytes", "futures-util", - "gateway-api", + "gateway-api-types", "gateway-config", "reqwest", "serde", @@ -2150,6 +2162,7 @@ dependencies = [ "futures-util", "gateway-config", "gateway-local", + "gateway-progress", "gateway-stt", "gateway-stt-backend-whisper", "gateway-stt-engine", @@ -2157,7 +2170,6 @@ dependencies = [ "serde", "serde_json", "sha2 0.11.0", - "shared-progress", "tempfile", "thiserror 2.0.19", "tokio", @@ -2172,10 +2184,10 @@ dependencies = [ name = "gateway-stt-backend-whisper" version = "0.3.0" dependencies = [ + "gateway-progress", "gateway-stt-engine", "gateway-whisper-ffi", "hound", - "shared-progress", "tempfile", "tokio", "tracing", @@ -2602,6 +2614,7 @@ name = "harness-log" version = "0.3.0" dependencies = [ "serde_json", + "shared-error-source", "tempfile", "thiserror 2.0.19", "tokio", @@ -5921,23 +5934,23 @@ dependencies = [ ] [[package]] -name = "shared-loopback" +name = "shared-error-source" version = "0.3.0" dependencies = [ - "axum", - "tokio", - "tower", + "reqwest", + "serde_json", + "thiserror 2.0.19", + "turso", "workspace-hack", ] [[package]] -name = "shared-progress" +name = "shared-loopback" version = "0.3.0" dependencies = [ - "serde", - "serde_json", + "axum", "tokio", - "tracing", + "tower", "workspace-hack", ] @@ -8481,11 +8494,11 @@ dependencies = [ "axum", "futures-util", "gateway-api-discovery", + "gateway-api-types", "promptforge-api-types", "reqwest", "serde", "serde_json", - "shared-progress", "tempfile", "thiserror 2.0.19", "tokio", @@ -8552,7 +8565,6 @@ dependencies = [ "serde", "serde_json", "shared-loopback", - "shared-progress", "socket2", "tempfile", "thiserror 2.0.19", @@ -8587,7 +8599,6 @@ dependencies = [ name = "workshop-status" version = "0.0.0" dependencies = [ - "shared-progress", "tokio", "workshop-protocol", "workshop-registry", @@ -8616,6 +8627,7 @@ version = "0.0.0" dependencies = [ "axum", "serde_json", + "shared-error-source", "tempfile", "thiserror 2.0.19", "tokio", @@ -8638,6 +8650,7 @@ dependencies = [ "promptforge-api-runtime", "serde", "serde_json", + "shared-error-source", "tempfile", "thiserror 2.0.19", "tokio", diff --git a/Cargo.toml b/Cargo.toml index e47abfa42..f2b18f9a1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "3" -members = ["crates/*", "crates/promptforge/lua", "crates/promptforge/parser", "crates/promptforge/store", "crates/promptforge/vfs", "crates/promptforge/model-client", "crates/gateway/app", "crates/gateway/cloud-providers", "crates/gateway/config", "crates/gateway/config-ui", "crates/gateway/local", "crates/gateway/logging", "crates/gateway/protocol", "crates/gateway/routing", "crates/gateway/web-search", "crates/gateway/stt/api", "crates/gateway/stt/engine", "crates/gateway/stt/backend-whisper", "crates/gateway/stt/whisper-ffi", "crates/workshop/shell", "crates/workshop/server", "crates/workshop/server-api", "crates/workshop/gateway", "crates/workshop/menu", "crates/workshop/protocol", "crates/workshop/registry", "crates/workshop/status", "crates/workshop/support", "crates/workshop/user-state", "crates/workshop/workspace", "crates/harness/runner", "crates/harness/models", "crates/harness/capabilities", "crates/harness/log", "crates/harness/sessions", "crates/harness/web", "crates/harness/webfetch", "crates/harness/web-search"] +members = ["crates/*", "crates/promptforge/lua", "crates/promptforge/parser", "crates/promptforge/store", "crates/promptforge/vfs", "crates/promptforge/model-client", "crates/gateway/app", "crates/gateway/cloud-providers", "crates/gateway/config", "crates/gateway/config-ui", "crates/gateway/local", "crates/gateway/logging", "crates/gateway/progress", "crates/gateway/protocol", "crates/gateway/routing", "crates/gateway/web-search", "crates/gateway/stt/api", "crates/gateway/stt/engine", "crates/gateway/stt/backend-whisper", "crates/gateway/stt/whisper-ffi", "crates/workshop/shell", "crates/workshop/server", "crates/workshop/server-api", "crates/workshop/gateway", "crates/workshop/menu", "crates/workshop/protocol", "crates/workshop/registry", "crates/workshop/status", "crates/workshop/support", "crates/workshop/user-state", "crates/workshop/workspace", "crates/harness/runner", "crates/harness/models", "crates/harness/capabilities", "crates/harness/log", "crates/harness/sessions", "crates/harness/web", "crates/harness/webfetch", "crates/harness/web-search"] # crates/shared-ui is not a Rust crate: it is the shared TypeScript+CSS # package both esbuild-built UIs consume, so the crates/* glob skips it. # crates/promptforge, crates/gateway, crates/workshop, and crates/harness @@ -27,13 +27,14 @@ base64 = "0.22" bytes = "1" promptforge-api-runtime = { path = "crates/promptforge-api-runtime", version = "0.3.0" } promptforge-api-types = { path = "crates/promptforge-api-types", version = "0.3.0" } -gateway-api = { path = "crates/gateway-api", version = "0.3.0" } +gateway-api-types = { path = "crates/gateway-api-types", version = "0.3.0" } gateway-cloud-providers = { path = "crates/gateway/cloud-providers", version = "0.3.0" } gateway = { path = "crates/gateway/app", version = "0.3.0" } gateway-config = { path = "crates/gateway/config", version = "0.3.0" } gateway-config-ui = { path = "crates/gateway/config-ui", version = "0.3.0" } gateway-local = { path = "crates/gateway/local", version = "0.3.0" } gateway-logging = { path = "crates/gateway/logging", version = "0.3.0" } +shared-error-source = { path = "crates/shared-error-source", version = "0.3.0" } shared-loopback = { path = "crates/shared-loopback", version = "0.3.0" } gateway-protocol = { path = "crates/gateway/protocol", version = "0.3.0" } gateway-api-discovery = { path = "crates/gateway-api-discovery", version = "0.3.0" } @@ -51,7 +52,7 @@ gateway-routing = { path = "crates/gateway/routing", version = "0.3.0" } promptforge-lua = { path = "crates/promptforge/lua", version = "0.3.0" } promptforge-model-client = { path = "crates/promptforge/model-client", version = "0.3.0" } promptforge-parser = { path = "crates/promptforge/parser", version = "0.3.0" } -shared-progress = { path = "crates/shared-progress", version = "0.3.0" } +gateway-progress = { path = "crates/gateway/progress", version = "0.3.0" } gateway-stt = { path = "crates/gateway/stt/api", version = "0.3.0" } promptforge-store = { path = "crates/promptforge/store", version = "0.3.0" } promptforge-vfs = { path = "crates/promptforge/vfs", version = "0.3.0" } diff --git a/crates/README.md b/crates/README.md index 3bec16a5b..92c3d913a 100644 --- a/crates/README.md +++ b/crates/README.md @@ -1,10 +1,10 @@ # crates/ -`crates/` is the workspace's public and shared layer - the family containers (`promptforge/`, `gateway/`, `workshop/`) are private, and cross-family dependencies resolve only here. +`crates/` is the workspace's public and shared layer - the family containers (`promptforge/`, `gateway/`, `workshop/`, `harness/`) are private, and cross-family dependencies resolve only here. -## gateway-api +## gateway-api-types -The gateway's public vocabulary crate: the versioned provider model sheet schema as pure serde data types. The gateway family reads and writes the sheet through it, and it is one of the two gateway crates outside crates may name. No workspace dependencies. +The gateway's public vocabulary crate: the versioned provider model sheet schema, the model vocabulary, and the `Progress` busy-and-text snapshot, all as pure serde data types. The gateway family reads and writes the sheet through it, the Workshop decodes progress through it, and it is one of the two gateway crates outside crates may name. Types only, no code. No workspace dependencies. ## gateway-api-discovery @@ -18,13 +18,13 @@ The PromptForge runtime: prompt parsing and the sans-IO `Run` state machine that The promptforge public types: untrusted-content guards, cooperative cancellation, run observation, and the model and tool vocabulary. Nearly every promptforge consumer and several workshop crates depend on it; it is the other half of the family's public surface. Depends only on shared-vfs. -## shared-loopback +## shared-error-source -The loopback wall: the `require_loopback` and `require_loopback_host` middleware plus the per-product WebSocket origin policies. The gateway applies it to the admin surface and every loopback-bound build, and config-ui wraps its SPA assets with it. No workspace dependencies; axum is the only third-party crate. +The shared error-source wrappers: `JsonSource`, `HttpSource`, and `DatabaseSource`, one crate-owned newtype per third-party error (`serde_json`, `reqwest`, `turso`) a public error surface would otherwise name. Each sits behind its own feature (`json`, `http`, `database`) so a consumer takes only the third-party dependency it already has. The harness, workshop, and gateway families all wrap their causes through it. No workspace dependencies - that independence is what keeps it off the cross-family edge. -## shared-progress +## shared-loopback -The progress vocabulary: operation-scoped weighted event trees, the process hub, and coalesced broadcast. Producers across the gateway and workshop families report through it, and hosts forward and render its events. No workspace dependencies. +The loopback wall: the `require_loopback` and `require_loopback_host` middleware plus the per-product WebSocket origin policies. The gateway applies it to the admin surface and every loopback-bound build, and config-ui wraps its SPA assets with it. No workspace dependencies; axum is the only third-party crate. ## shared-vfs diff --git a/crates/build-llama-cuda/src/bundle.rs b/crates/build-llama-cuda/src/bundle.rs index 8efbe9628..bb6d8d500 100644 --- a/crates/build-llama-cuda/src/bundle.rs +++ b/crates/build-llama-cuda/src/bundle.rs @@ -33,7 +33,7 @@ pub struct BuildRequest { /// CMake build tree lives under it in `work/` and is not part of the /// published output. pub out: PathBuf, - /// Run the `--list-devices` smoke check after the build. Needs a GPU; + /// Runs the `--list-devices` smoke check after the build. Needs a GPU; /// the GitHub build computer has none, so the workflow passes /// `--no-smoke` and the self-hosted smoke job covers the GPU check. pub smoke: bool, diff --git a/crates/build-llama-cuda/src/probe.rs b/crates/build-llama-cuda/src/probe.rs index 9a1411504..91d0a1892 100644 --- a/crates/build-llama-cuda/src/probe.rs +++ b/crates/build-llama-cuda/src/probe.rs @@ -23,6 +23,7 @@ pub struct CommandRequest { impl CommandRequest { /// Creates a request for `program` with no arguments. + #[must_use] pub fn new(program: impl Into) -> Self { Self { program: program.into(), diff --git a/crates/build-ui/src/lib.rs b/crates/build-ui/src/lib.rs index 9bdadd78d..0a0b313c3 100644 --- a/crates/build-ui/src/lib.rs +++ b/crates/build-ui/src/lib.rs @@ -37,7 +37,7 @@ pub const CONFIG_UI_STATIC_FILES: &[&str] = &[ pub struct UiBuild { /// Files to copy next to the bundle, relative to the ui folder. pub static_files: &'static [&'static str], - /// Bake the crate version into the bundle as the `__APP_VERSION__` + /// Bakes the crate version into the bundle as the `__APP_VERSION__` /// define. pub define_app_version: bool, /// Code-split the bundle: dynamic imports become lazily loaded chunks diff --git a/crates/build-user-guide/src/main.rs b/crates/build-user-guide/src/main.rs index e909108d7..a82f68af7 100644 --- a/crates/build-user-guide/src/main.rs +++ b/crates/build-user-guide/src/main.rs @@ -51,7 +51,7 @@ fn main() { } } -/// Run the full assembly over `guide/`: landing pages, SUMMARY.md, exports, +/// Runs the full assembly over `guide/`: landing pages, SUMMARY.md, exports, /// and the link check. fn assemble(guide: &Path) -> Result<(), AssembleError> { let src = guide.join("src"); @@ -83,7 +83,7 @@ fn assemble(guide: &Path) -> Result<(), AssembleError> { Ok(()) } -/// Reject guide text that presents the removed legacy STT section as usable. +/// Rejects guide text that presents the removed legacy STT section as usable. fn check_removed_workshop_stt_claims(src: &Path) -> Result<(), AssembleError> { for (set, _) in SETS { let set_dir = src.join(set); @@ -106,7 +106,7 @@ fn check_removed_workshop_stt_claims(src: &Path) -> Result<(), AssembleError> { Ok(()) } -/// List a set directory's chapter files in reading order, reading each +/// Lists a set directory's chapter files in reading order, reading each /// chapter's title from its first H1 heading. fn read_chapters(set_dir: &Path) -> Result, AssembleError> { if !set_dir.is_dir() { @@ -153,7 +153,7 @@ fn read_chapters(set_dir: &Path) -> Result, AssembleError> { Ok(chapters) } -/// Render a part landing page: the part title and its chapter list. +/// Renders a part landing page: the part title and its chapter list. fn render_index(part_title: &str, chapters: &[Chapter]) -> String { let mut out = format!("# {part_title}\n"); for chapter in chapters { @@ -163,7 +163,7 @@ fn render_index(part_title: &str, chapters: &[Chapter]) -> String { out } -/// Render SUMMARY.md: the introduction, then the parts in audience order +/// Renders SUMMARY.md: the introduction, then the parts in audience order /// with every chapter linked. fn render_summary(parts: &[(&str, &str, Vec)]) -> String { let mut out = String::from("# Summary\n\n- [Introduction](introduction.md)\n"); @@ -176,7 +176,7 @@ fn render_summary(parts: &[(&str, &str, Vec)]) -> String { out } -/// Render a set's single-file export: the chapters concatenated in reading +/// Renders a set's single-file export: the chapters concatenated in reading /// order. fn render_export( part_title: &str, @@ -195,7 +195,7 @@ fn render_export( Ok(out) } -/// Verify that every relative link target in SUMMARY.md resolves to a file +/// Verifies that every relative link target in SUMMARY.md resolves to a file /// under `src/`. fn check_links(summary: &str, src: &Path) -> Result<(), AssembleError> { for line in summary.lines() { @@ -216,13 +216,13 @@ fn check_links(summary: &str, src: &Path) -> Result<(), AssembleError> { Ok(()) } -/// Write a file, creating no directories and failing loudly on error. +/// Writes a file, creating no directories and failing loudly on error. fn write_file(path: &Path, content: &str) -> Result<(), AssembleError> { fs::write(path, content) .map_err(|e| AssembleError(format!("cannot write {}: {e}", path.display()))) } -/// Walk up from this crate's manifest dir to find the workspace root. +/// Walks up from this crate's manifest dir to find the workspace root. fn workspace_root() -> PathBuf { let mut dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); loop { @@ -244,7 +244,7 @@ fn workspace_root() -> PathBuf { mod tests { use super::*; - /// Build a fake guide tree with two sets and return its root. + /// Builds a fake guide tree with two sets and returns its root. fn fake_guide() -> tempfile::TempDir { let dir = tempfile::tempdir().expect("tempdir"); let src = dir.path().join("src"); diff --git a/crates/build-xtask/src/engine_deps-tests.rs b/crates/build-xtask/src/engine_deps-tests.rs index e467cd3ed..d85fea9b5 100644 --- a/crates/build-xtask/src/engine_deps-tests.rs +++ b/crates/build-xtask/src/engine_deps-tests.rs @@ -5,7 +5,7 @@ use std::path::PathBuf; use super::*; -/// Write one manifest into a fresh temporary directory and return its path +/// Writes one manifest into a fresh temporary directory and returns its path /// beside the directory guard that keeps it alive. fn manifest(text: &str) -> (tempfile::TempDir, PathBuf) { let dir = tempfile::TempDir::new().expect("tempdir"); diff --git a/crates/build-xtask/src/engine_deps.rs b/crates/build-xtask/src/engine_deps.rs index ca3bf3774..0986b1dc8 100644 --- a/crates/build-xtask/src/engine_deps.rs +++ b/crates/build-xtask/src/engine_deps.rs @@ -78,7 +78,7 @@ impl fmt::Display for Violation { } } -/// Scan one engine manifest for forbidden dependencies. A manifest that +/// Scans one engine manifest for forbidden dependencies. A manifest that /// cannot be read or parsed yields one [`Violation::Unreadable`]. #[must_use] pub(crate) fn forbidden_engine_dependencies(manifest: &Path) -> Vec { diff --git a/crates/build-xtask/src/engine_guards-tests.rs b/crates/build-xtask/src/engine_guards-tests.rs index bbb3e7085..44f2b84b6 100644 --- a/crates/build-xtask/src/engine_guards-tests.rs +++ b/crates/build-xtask/src/engine_guards-tests.rs @@ -14,7 +14,7 @@ fn workspace_root() -> PathBuf { .to_path_buf() } -/// Write one crate under `/crates//` with the given manifest +/// Writes one crate under `/crates//` with the given manifest /// body (after `[package]`) and `src/lib.rs` text. fn write_crate(root: &Path, dir: &str, manifest: &str, lib: &str) { let crate_dir = root.join("crates").join(dir); @@ -69,7 +69,7 @@ fn the_engine_crate_set_is_the_two_root_crates_plus_every_container_member() { write_crate(root.path(), "promptforge/lua", "", "pub struct Vm;\n"); write_crate(root.path(), "promptforge/store", "", "pub struct Store;\n"); write_crate(root.path(), "harness/runner", "", "pub struct Runner;\n"); - write_crate(root.path(), "gateway-api", "", "pub struct Api;\n"); + write_crate(root.path(), "gateway-api-types", "", "pub struct Api;\n"); let mut names: Vec = engine_crates(root.path()) .iter() .map(|dir| { diff --git a/crates/build-xtask/src/engine_guards.rs b/crates/build-xtask/src/engine_guards.rs index af64ef4d3..f8636dbe8 100644 --- a/crates/build-xtask/src/engine_guards.rs +++ b/crates/build-xtask/src/engine_guards.rs @@ -66,7 +66,7 @@ pub(crate) fn collect_crates(dir: &Path, crates: &mut Vec) { } } -/// Run the manifest guard over every engine crate. +/// Runs the manifest guard over every engine crate. #[must_use] pub(crate) fn engine_manifest_violations(root: &Path) -> Vec { engine_crates(root) @@ -76,7 +76,7 @@ pub(crate) fn engine_manifest_violations(root: &Path) -> Vec { .collect() } -/// Run the retired-symbol scan over every engine crate's live source. The +/// Runs the retired-symbol scan over every engine crate's live source. The /// scan takes the whole crate directory, so `build.rs`, `benches/`, and /// `examples/` are covered too; it skips `tests/` and test support itself. #[must_use] diff --git a/crates/build-xtask/src/harness_bans-tests.rs b/crates/build-xtask/src/harness_bans-tests.rs index a63ca7f45..ce73b4bba 100644 --- a/crates/build-xtask/src/harness_bans-tests.rs +++ b/crates/build-xtask/src/harness_bans-tests.rs @@ -20,7 +20,7 @@ fn door(root: &Path) -> std::path::PathBuf { root.join("crates").join("harness-api") } -/// Write a crate directory with a manifest and, when given, a `clippy.toml`. +/// Writes a crate directory with a manifest and, when given, a `clippy.toml`. fn write_crate(dir: &Path, clippy: Option<&str>) { std::fs::create_dir_all(dir).expect("the crate directory creates"); std::fs::write(dir.join("Cargo.toml"), "[package]\nname = \"fixture\"\n") diff --git a/crates/build-xtask/src/harness_bans.rs b/crates/build-xtask/src/harness_bans.rs index 9ffef99bc..c1cc6b850 100644 --- a/crates/build-xtask/src/harness_bans.rs +++ b/crates/build-xtask/src/harness_bans.rs @@ -17,7 +17,7 @@ use std::path::{Path, PathBuf}; /// The methods every harness `clippy.toml` must disallow. const BANNED: [&str; 2] = ["tokio::spawn", "tokio::task::spawn_blocking"]; -/// Check every crate under `container` and, when it exists, the `door` +/// Checks every crate under `container` and, when it exists, the `door` /// crate directory for a complete clippy ban list. #[must_use] pub(crate) fn harness_clippy_bans(container: &Path, door: &Path) -> Vec { diff --git a/crates/build-xtask/src/product-test-support.rs b/crates/build-xtask/src/product-test-support.rs index 013d431dd..953983fcf 100644 --- a/crates/build-xtask/src/product-test-support.rs +++ b/crates/build-xtask/src/product-test-support.rs @@ -10,7 +10,7 @@ pub(crate) fn workspace_root() -> PathBuf { .to_path_buf() } -/// Write a minimal crate manifest into a fake workspace; `dir_name` may +/// Writes a minimal crate manifest into a fake workspace; `dir_name` may /// carry a slash to nest the crate under a container (`promptforge/lua`). pub(crate) fn write_crate(root: &Path, dir_name: &str, package: &str, deps: &str) { let dir = root.join("crates").join(dir_name); diff --git a/crates/build-xtask/src/product-tests.rs b/crates/build-xtask/src/product-tests.rs index 0bcc51889..eb82a437e 100644 --- a/crates/build-xtask/src/product-tests.rs +++ b/crates/build-xtask/src/product-tests.rs @@ -17,9 +17,10 @@ fn workspace_respects_the_product_boundary() { #[test] fn workshop_depends_on_workshop_server_api_only() { - let (crates, violations) = workspace_crates(&workspace_root()); - assert!(violations.is_empty(), "{violations:?}"); - let shell = crates + let walk = workspace_crates(&workspace_root()); + assert!(walk.violations.is_empty(), "{:?}", walk.violations); + let shell = walk + .crates .iter() .find(|krate| krate.package == "workshop") .expect("the workshop shell crate is a workspace member"); @@ -237,12 +238,16 @@ fn an_unparseable_manifest_is_reported() { let dir = root.path().join("crates").join("broken"); std::fs::create_dir_all(&dir).expect("the crate directory creates"); std::fs::write(dir.join("Cargo.toml"), "not [valid toml").expect("the manifest writes"); - let violations = product_boundary_violations(root.path()); + let violations = workspace_crates(root.path()).violations; assert_eq!(violations.len(), 1, "{violations:?}"); assert!( violations[0].contains("unparseable manifest"), "the violation reports the parse failure: {violations:?}" ); + assert!( + product_boundary_violations(root.path()).is_empty(), + "the read failure belongs to the marker check, not this one" + ); } #[test] @@ -252,10 +257,10 @@ fn a_workshop_crate_depending_on_the_public_gateway_pair_passes() { root.path(), "workshop-server", "workshop-server", - "[dependencies]\ngateway-api = { path = \"../gateway-api\" }\n\ + "[dependencies]\ngateway-api-types = { path = \"../gateway-api-types\" }\n\ gateway-api-discovery = { path = \"../gateway-api-discovery\" }\n", ); - write_crate(root.path(), "gateway-api", "gateway-api", ""); + write_crate(root.path(), "gateway-api-types", "gateway-api-types", ""); write_crate( root.path(), "gateway-api-discovery", @@ -278,14 +283,14 @@ fn a_harness_crate_depending_on_the_public_doors_and_shared_passes() { "harness-runner", "[dependencies]\npromptforge-api-runtime = { path = \"../../promptforge-api-runtime\" }\n\ promptforge-api-types = { path = \"../../promptforge-api-types\" }\n\ - gateway-api = { path = \"../../gateway-api\" }\n\ + gateway-api-types = { path = \"../../gateway-api-types\" }\n\ gateway-api-discovery = { path = \"../../gateway-api-discovery\" }\n\ shared-vfs = { path = \"../../shared-vfs\" }\n", ); for name in [ "promptforge-api-runtime", "promptforge-api-types", - "gateway-api", + "gateway-api-types", "gateway-api-discovery", "shared-vfs", ] { @@ -331,7 +336,7 @@ fn a_harness_crate_depending_on_a_private_gateway_crate_is_reported() { assert_eq!(violations.len(), 1, "{violations:?}"); assert!( violations[0].starts_with("harness-models depends on gateway-routing:") - && violations[0].contains("gateway-api") + && violations[0].contains("gateway-api-types") && violations[0].contains("gateway-api-discovery"), "the violation names the harness crate and the public pair: {violations:?}" ); diff --git a/crates/build-xtask/src/product.rs b/crates/build-xtask/src/product.rs index 1ca6c964e..29e787c11 100644 --- a/crates/build-xtask/src/product.rs +++ b/crates/build-xtask/src/product.rs @@ -9,7 +9,7 @@ //! - `gateway`/`gateway-*` crates must not depend on promptforge, //! workshop, or harness crates. //! - `workshop`/`workshop-*` crates must not depend on gateway crates, -//! except the family's public pair (`gateway-api`, +//! except the family's public pair (`gateway-api-types`, //! `gateway-api-discovery`), and may depend on `harness-*` only through //! `harness-api`. //! - `harness-*` crates must not depend on workshop crates, and may depend @@ -50,7 +50,7 @@ enum Family { Unaffiliated, } -/// Classify a package name into its product family. +/// Classifies a package name into its product family. fn family(package: &str) -> Family { if package.starts_with("promptforge-") { Family::Promptforge @@ -71,16 +71,35 @@ fn family(package: &str) -> Family { /// A workspace crate: its package name, its manifest directory relative to /// the workspace root, and its dependency package names. -struct CrateInfo { - package: String, - dir: PathBuf, +pub(crate) struct CrateInfo { + pub(crate) package: String, + pub(crate) dir: PathBuf, deps: Vec, } -/// Check every workspace manifest against the product-boundary matrix. +/// Every crate under `crates/`, as the one enumeration the architecture +/// checks share. +pub(crate) struct CrateWalk { + /// The crates whose manifests named a package. + pub(crate) crates: Vec, + /// Crate directories, relative to the workspace root, whose manifest + /// could not be read, parsed, or named. A crate that cannot be read + /// cannot be shown exempt, so the checks that bind by package name + /// bind these too. + pub(crate) unread: Vec, + /// Every read failure, in walk order. + pub(crate) violations: Vec, +} + +/// Checks every workspace manifest against the product-boundary matrix. +/// +/// The walk's read failures belong to `tidy::marker_violations`, the one +/// owner: both checks share the walk, so reporting them here too would +/// name every unreadable manifest twice in `tidy::all_violations`. #[must_use] pub(crate) fn product_boundary_violations(root: &Path) -> Vec { - let (crates, mut violations) = workspace_crates(root); + let CrateWalk { crates, .. } = workspace_crates(root); + let mut violations = Vec::new(); for package in &crates { for dep_name in &package.deps { // Only workspace members are bound by the matrix; a crates.io @@ -107,7 +126,7 @@ const SERVER: &str = "workshop-server"; const PUBLIC_PROMPTFORGE: [&str; 2] = ["promptforge-api-runtime", "promptforge-api-types"]; /// The gateway family's public pair: the only gateway crates workshop /// crates may name. -const PUBLIC_GATEWAY: [&str; 2] = ["gateway-api", "gateway-api-discovery"]; +const PUBLIC_GATEWAY: [&str; 2] = ["gateway-api-types", "gateway-api-discovery"]; /// The harness family's door: the only harness crate workshop crates may /// name, and the one outside crate permitted into `crates/harness/`. const HARNESS_DOOR: &str = "harness-api"; @@ -171,7 +190,7 @@ fn boundary_breach(package: &CrateInfo, dep: &CrateInfo) -> Option { Some("harness crates must not depend on workshop crates") } (Family::Harness, Family::Gateway) if !public_gateway => Some( - "harness crates may depend on gateway-* only through gateway-api and gateway-api-discovery", + "harness crates may depend on gateway-* only through gateway-api-types and gateway-api-discovery", ), ( Family::Shared, @@ -245,25 +264,29 @@ fn container_face(container: &str) -> Option<&'static str> { } /// Every workspace crate's package name, manifest directory, and dependency -/// package names, plus violations for manifests that could not be read or -/// parsed. A directory under `crates/` containing a `Cargo.toml` is a crate -/// and is not descended into; any other directory is a container and the -/// walk descends, so containers may nest (`crates/gateway/stt/`). -fn workspace_crates(root: &Path) -> (Vec, Vec) { - let mut crates = Vec::new(); - let mut violations = Vec::new(); +/// package names, plus the directories and violations for manifests that +/// could not be read or parsed. A directory under `crates/` containing a +/// `Cargo.toml` is a crate and is not descended into; any other directory +/// is a container and the walk descends, so containers may nest +/// (`crates/gateway/stt/`). +pub(crate) fn workspace_crates(root: &Path) -> CrateWalk { + let mut walk = CrateWalk { + crates: Vec::new(), + unread: Vec::new(), + violations: Vec::new(), + }; let crates_dir = root.join("crates"); - walk_crates(root, &crates_dir, &mut crates, &mut violations); - (crates, violations) + walk_crates(root, &crates_dir, &mut walk); + walk } -/// Walk one directory level: crates are read, manifestless containers are +/// Walks one directory level: crates are read, manifestless containers are /// descended into. -fn walk_crates(root: &Path, dir: &Path, crates: &mut Vec, violations: &mut Vec) { +fn walk_crates(root: &Path, dir: &Path, walk: &mut CrateWalk) { let entries = match fs::read_dir(dir) { Ok(entries) => entries, Err(error) => { - violations.push(format!( + walk.violations.push(format!( "{}: unreadable crates directory: {error}", dir.display() )); @@ -274,7 +297,7 @@ fn walk_crates(root: &Path, dir: &Path, crates: &mut Vec, violations: let entry = match entry { Ok(entry) => entry, Err(error) => { - violations.push(format!( + walk.violations.push(format!( "{}: unreadable directory entry: {error}", dir.display() )); @@ -286,20 +309,23 @@ fn walk_crates(root: &Path, dir: &Path, crates: &mut Vec, violations: } let sub = entry.path(); if sub.join("Cargo.toml").exists() { - read_crate(root, &sub, crates, violations); + read_crate(root, &sub, walk); } else { - walk_crates(root, &sub, crates, violations); + walk_crates(root, &sub, walk); } } } -/// Read one crate's manifest into `crates`; failures land in `violations`. -fn read_crate(root: &Path, dir: &Path, crates: &mut Vec, violations: &mut Vec) { +/// Reads one crate's manifest into the walk; a failure names the crate in +/// `unread` and the failure mode in `violations`. +fn read_crate(root: &Path, dir: &Path, walk: &mut CrateWalk) { + let relative = dir.strip_prefix(root).unwrap_or(dir).to_path_buf(); let manifest_path = dir.join("Cargo.toml"); let text = match fs::read_to_string(&manifest_path) { Ok(text) => text, Err(error) => { - violations.push(format!( + walk.unread.push(relative); + walk.violations.push(format!( "{}: unreadable manifest: {error}", manifest_path.display() )); @@ -309,7 +335,8 @@ fn read_crate(root: &Path, dir: &Path, crates: &mut Vec, violations: let manifest = match toml::from_str::(&text) { Ok(manifest) => manifest, Err(error) => { - violations.push(format!( + walk.unread.push(relative); + walk.violations.push(format!( "{}: unparseable manifest: {error}", manifest_path.display() )); @@ -321,15 +348,16 @@ fn read_crate(root: &Path, dir: &Path, crates: &mut Vec, violations: .and_then(|p| p.get("name")) .and_then(toml::Value::as_str) else { - violations.push(format!( + walk.unread.push(relative); + walk.violations.push(format!( "{}: manifest has no package name", manifest_path.display() )); return; }; - crates.push(CrateInfo { + walk.crates.push(CrateInfo { package: package.to_owned(), - dir: dir.strip_prefix(root).unwrap_or(dir).to_path_buf(), + dir: relative, deps: manifest_dependencies(&manifest), }); } diff --git a/crates/build-xtask/src/retired_symbols-tests.rs b/crates/build-xtask/src/retired_symbols-tests.rs index 4ce8c9f27..e17d3c389 100644 --- a/crates/build-xtask/src/retired_symbols-tests.rs +++ b/crates/build-xtask/src/retired_symbols-tests.rs @@ -7,7 +7,7 @@ use super::*; const SEEDS: [&str; 2] = ["Observer", "GatewaySource"]; -/// Write a source tree of `(relative path, contents)` pairs into a fresh +/// Writes a source tree of `(relative path, contents)` pairs into a fresh /// temporary directory. fn tree(files: &[(&str, &str)]) -> tempfile::TempDir { let dir = tempfile::TempDir::new().expect("tempdir"); diff --git a/crates/build-xtask/src/retired_symbols.rs b/crates/build-xtask/src/retired_symbols.rs index 9f959ca56..6ca4ab921 100644 --- a/crates/build-xtask/src/retired_symbols.rs +++ b/crates/build-xtask/src/retired_symbols.rs @@ -49,7 +49,7 @@ impl fmt::Display for Hit { } } -/// Scan every live `.rs` file under `source_root` for the `seeds`, sorted +/// Scans every live `.rs` file under `source_root` for the `seeds`, sorted /// by file then line. An absent or unreadable root yields no hits, and an /// unreadable file is skipped (see the module docs for why that is safe). #[must_use] @@ -137,7 +137,7 @@ fn identifiers(line: &str) -> impl Iterator { .filter(|token| !token.is_empty()) } -/// Replace every character in `start..end` with a space, keeping newlines +/// Replaces every character in `start..end` with a space, keeping newlines /// so line numbers survive. fn blank(code: &mut [char], start: usize, end: usize) { let end = end.min(code.len()); @@ -148,7 +148,7 @@ fn blank(code: &mut [char], start: usize, end: usize) { } } -/// Mask comments (line, doc, and nested block) and literals (strings, raw +/// Masks comments (line, doc, and nested block) and literals (strings, raw /// strings, byte and C strings, chars) in place. fn mask_comments_and_literals(code: &mut [char]) { let mut i = 0; diff --git a/crates/build-xtask/src/test_support_leak-tests.rs b/crates/build-xtask/src/test_support_leak-tests.rs index 0e1241722..8c18fe5c1 100644 --- a/crates/build-xtask/src/test_support_leak-tests.rs +++ b/crates/build-xtask/src/test_support_leak-tests.rs @@ -14,7 +14,7 @@ fn workspace_root() -> PathBuf { .to_path_buf() } -/// Write one crate under `/crates//` named `name`, with the +/// Writes one crate under `/crates//` named `name`, with the /// given manifest body after `[package]`. fn write_crate(root: &Path, dir: &str, name: &str, manifest: &str) { let crate_dir = root.join("crates").join(dir); diff --git a/crates/build-xtask/src/test_support_leak.rs b/crates/build-xtask/src/test_support_leak.rs index bae52e638..d332b4690 100644 --- a/crates/build-xtask/src/test_support_leak.rs +++ b/crates/build-xtask/src/test_support_leak.rs @@ -38,7 +38,7 @@ const CHECKED_KINDS: [&str; 2] = ["dependencies", "build-dependencies"]; /// The feature no non-dev table may enable on an engine crate. const GUARDED_FEATURE: &str = crate::engine_deps::EXEMPTING_FEATURE; -/// Scan the workspace for non-dev dependency tables, and `[features]` +/// Scans the workspace for non-dev dependency tables, and `[features]` /// values, that enable an engine crate's `test-support` feature. #[must_use] pub(crate) fn test_support_leak_violations(root: &Path) -> Vec { @@ -90,7 +90,7 @@ pub(crate) fn test_support_leak_violations(root: &Path) -> Vec { violations } -/// Report every `[features]` value that enables the guarded feature on an +/// Reports every `[features]` value that enables the guarded feature on an /// engine crate through a dependency-feature reference. fn scan_features( manifest_path: &Path, @@ -154,7 +154,7 @@ fn resolve_package<'a>( .unwrap_or(key) } -/// Report every entry in `table` that names an engine crate and lists the +/// Reports every entry in `table` that names an engine crate and lists the /// guarded feature. fn scan_table( manifest: &Path, @@ -204,7 +204,7 @@ fn engine_package_names(root: &Path) -> Vec { .collect() } -/// Read and parse one manifest, or `None` when it cannot be read or parsed. +/// Reads and parses one manifest, or `None` when it cannot be read or parsed. fn parse_manifest(path: &Path) -> Option { fs::read_to_string(path) .ok() diff --git a/crates/build-xtask/src/tidy-tests.rs b/crates/build-xtask/src/tidy-tests.rs new file mode 100644 index 000000000..4aa17b4d9 --- /dev/null +++ b/crates/build-xtask/src/tidy-tests.rs @@ -0,0 +1,408 @@ +use super::*; + +fn workspace_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .ancestors() + .nth(2) + .expect("build-xtask lives at /crates/build-xtask") + .to_path_buf() +} + +#[test] +fn workshop_tier_dependencies_flow_one_way() { + let violations = tier_dependency_violations(&workspace_root()); + assert!( + violations.is_empty(), + "tier violations:\n{}", + violations.join("\n") + ); +} + +#[test] +fn participating_crates_respect_the_file_line_ceiling() { + let violations = file_ceiling_violations(&workspace_root()); + assert!( + violations.is_empty(), + "ceiling violations:\n{}", + violations.join("\n") + ); +} + +#[test] +fn participating_crates_inherit_workspace_lints() { + let violations = lint_inheritance_violations(&workspace_root()); + assert!( + violations.is_empty(), + "lint violations:\n{}", + violations.join("\n") + ); +} + +#[test] +fn family_crates_carry_the_invariants_marker() { + let violations = marker_violations(&workspace_root()); + assert!( + violations.is_empty(), + "marker violations:\n{}", + violations.join("\n") + ); +} + +#[test] +fn a_tiered_crate_whose_manifest_is_missing_is_reported_not_skipped() { + let root = tempfile::TempDir::new().expect("tempdir"); + std::fs::create_dir_all(root.path().join("crates")).expect("the crates directory creates"); + let violations = tier_dependency_violations(root.path()); + let tiered = [VOCABULARY, SERVICES, FEATURES, SHELL].concat(); + assert_eq!( + violations.len(), + tiered.len(), + "every tiered crate's missing manifest is reported: {violations:?}" + ); + for name in tiered { + assert!( + violations.iter().any(|v| v.contains(name)), + "{name} is named in the violations: {violations:?}" + ); + } +} + +/// Writes a crate under `crates//` named `name`, with the given +/// `lib.rs` docs and one source file of `lines` lines. +fn write_crate(root: &Path, dir: &str, name: &str, lib_docs: &str, lines: usize) { + let src = root.join("crates").join(dir).join("src"); + std::fs::create_dir_all(&src).expect("the crate source directory creates"); + std::fs::write( + src.parent().expect("src has a parent").join("Cargo.toml"), + format!("[package]\nname = \"{name}\"\n[lints]\nworkspace = true\n"), + ) + .expect("the manifest writes"); + std::fs::write(src.join("lib.rs"), lib_docs).expect("lib.rs writes"); + std::fs::write(src.join("big.rs"), "// line\n".repeat(lines)).expect("big.rs writes"); +} + +const MARKED: &str = "//! Effect loop.\n//!\n//! ## Invariants\n//!\n//! - none\n"; +const UNMARKED: &str = "//! Effect loop, with no invariants block.\n"; + +#[test] +fn a_harness_crate_carrying_the_marker_is_held_to_the_ceiling() { + let root = tempfile::TempDir::new().expect("tempdir"); + write_crate( + root.path(), + "harness/runner", + "harness-runner", + MARKED, + MAX_FILE_LINES + 1, + ); + let violations = file_ceiling_violations(root.path()); + assert_eq!(violations.len(), 1, "{violations:?}"); + assert!( + violations[0].contains("big.rs") && violations[0].contains("over the 500-line ceiling"), + "the oversized harness file is reported: {violations:?}" + ); + assert!( + marker_violations(root.path()).is_empty(), + "a marked family crate is not a marker violation" + ); +} + +#[test] +fn a_harness_crate_without_the_marker_is_a_violation_and_still_held_to_the_ceiling() { + let root = tempfile::TempDir::new().expect("tempdir"); + write_crate( + root.path(), + "harness/runner", + "harness-runner", + UNMARKED, + MAX_FILE_LINES + 1, + ); + let violations = marker_violations(root.path()); + assert_eq!(violations.len(), 1, "{violations:?}"); + assert!( + violations[0].contains("harness-runner") && violations[0].contains(INVARIANT_MARKER), + "the crate and the missing marker are named: {violations:?}" + ); + let ceiling = file_ceiling_violations(root.path()); + assert_eq!( + ceiling.len(), + 1, + "family membership, not the marker, opts the crate into the ceiling: {ceiling:?}" + ); +} + +#[test] +fn a_harness_crate_whose_manifest_has_no_package_name_is_reported_not_skipped() { + let root = tempfile::TempDir::new().expect("tempdir"); + write_crate( + root.path(), + "harness/runner", + "harness-runner", + UNMARKED, + MAX_FILE_LINES + 1, + ); + let manifest = root + .path() + .join("crates") + .join("harness") + .join("runner") + .join("Cargo.toml"); + std::fs::write(&manifest, "[lints]\nworkspace = true\n").expect("the manifest rewrites"); + let violations = marker_violations(root.path()); + assert_eq!(violations.len(), 1, "{violations:?}"); + assert!( + violations[0].contains("runner") && violations[0].contains("no package name"), + "the directory and the failure mode are named: {violations:?}" + ); + let ceiling = file_ceiling_violations(root.path()); + assert_eq!( + ceiling.len(), + 1, + "a crate with no readable name is not exempt from the ceiling: {ceiling:?}" + ); +} + +#[test] +fn a_manifest_read_failure_is_reported_once_across_the_checks() { + let root = tempfile::TempDir::new().expect("tempdir"); + let dir = root.path().join("crates").join("broken"); + std::fs::create_dir_all(&dir).expect("the crate directory creates"); + std::fs::write(dir.join("Cargo.toml"), "not [valid toml").expect("the manifest writes"); + let violations = marker_violations(root.path()); + assert_eq!(violations.len(), 1, "{violations:?}"); + assert!( + violations[0].contains("unparseable manifest"), + "the marker check owns the walk's read failures: {violations:?}" + ); + assert!( + crate::product::product_boundary_violations(root.path()).is_empty(), + "the two checks share one walk and report its read failures once" + ); +} + +#[test] +fn a_crate_nested_under_a_subsystem_container_is_held_to_the_ceiling() { + let root = tempfile::TempDir::new().expect("tempdir"); + write_crate( + root.path(), + "gateway/stt/engine", + "gateway-stt-engine", + MARKED, + MAX_FILE_LINES + 1, + ); + let violations = file_ceiling_violations(root.path()); + assert_eq!(violations.len(), 1, "{violations:?}"); + assert!( + violations[0].contains("big.rs"), + "the walk reaches a crate three levels under crates/: {violations:?}" + ); +} + +#[test] +fn the_tidy_checks_and_the_product_checks_enumerate_the_same_crates() { + let root = tempfile::TempDir::new().expect("tempdir"); + write_crate(root.path(), "harness/runner", "harness-runner", MARKED, 1); + write_crate( + root.path(), + "gateway/stt/engine", + "gateway-stt-engine", + MARKED, + 1, + ); + assert_eq!( + participating_crates(root.path()).len(), + crate::product::workspace_crates(root.path()).crates.len(), + "the two walks find the same crates" + ); +} + +#[test] +fn the_workshop_shell_without_the_marker_passes_and_stays_outside_the_ceiling() { + let root = tempfile::TempDir::new().expect("tempdir"); + write_crate( + root.path(), + "workshop/shell", + "workshop", + UNMARKED, + MAX_FILE_LINES + 1, + ); + assert!( + marker_violations(root.path()).is_empty(), + "the shell is exempt from the marker" + ); + assert!( + file_ceiling_violations(root.path()).is_empty(), + "the unmarked shell does not participate in the ceiling" + ); +} + +#[test] +fn a_marked_crate_outside_the_families_still_participates_in_the_ceiling() { + let root = tempfile::TempDir::new().expect("tempdir"); + write_crate( + root.path(), + "build-fixture", + "build-fixture", + MARKED, + MAX_FILE_LINES + 1, + ); + let violations = file_ceiling_violations(root.path()); + assert_eq!(violations.len(), 1, "{violations:?}"); + assert!( + violations[0].contains("big.rs"), + "the marker alone opts a crate in: {violations:?}" + ); + assert!( + marker_violations(root.path()).is_empty(), + "a non-family crate is never required to carry the marker" + ); +} + +#[test] +fn an_unmarked_crate_outside_the_families_is_left_alone() { + let root = tempfile::TempDir::new().expect("tempdir"); + write_crate( + root.path(), + "gateway/local", + "gateway-local", + UNMARKED, + MAX_FILE_LINES + 1, + ); + assert!(marker_violations(root.path()).is_empty()); + assert!(file_ceiling_violations(root.path()).is_empty()); +} + +/// Writes `text` into `crates/gateway/app/src/`, creating the +/// gateway app crate's source tree as needed. +fn write_app_file(root: &Path, relative: &str, text: &str) { + let path = root + .join("crates") + .join("gateway") + .join("app") + .join("src") + .join(relative); + std::fs::create_dir_all(path.parent().expect("the file has a parent")) + .expect("the source directory creates"); + std::fs::write(&path, text).expect("the source file writes"); +} + +/// A spelled tier path in code, the shape the rule is meant to catch. +const NAMES_TIER: &str = "use crate::admin::walled::hf::HfProxy;\n"; + +#[test] +fn a_file_outside_the_walled_tier_naming_a_tier_module_is_reported() { + let root = tempfile::TempDir::new().expect("tempdir"); + write_app_file(root.path(), "speech.rs", NAMES_TIER); + let violations = walled_tier_violations(root.path()); + assert_eq!(violations.len(), 1, "{violations:?}"); + assert!( + violations[0].contains("speech.rs") && violations[0].contains(WALLED_PATH), + "the file and the path it names are reported: {violations:?}" + ); +} + +#[test] +fn each_allowlisted_assembly_site_may_name_the_walled_tier() { + for allowed in WALLED_ALLOWLIST { + let root = tempfile::TempDir::new().expect("tempdir"); + let relative = allowed + .strip_prefix("crates/gateway/app/src/") + .expect("the allowlist keys files in the gateway app's source tree"); + write_app_file(root.path(), relative, NAMES_TIER); + let violations = walled_tier_violations(root.path()); + assert!( + violations.is_empty(), + "{allowed} assembles the tier's own state and may name it: {violations:?}" + ); + } +} + +#[test] +fn a_comment_line_that_spells_the_tier_path_is_not_a_dependency() { + let root = tempfile::TempDir::new().expect("tempdir"); + write_app_file( + root.path(), + "commands-apply.rs", + "//! The route side lives in `crate::admin::walled::config_apply`.\n\ + /// See [`crate::admin::walled::config`].\n\ + pub(crate) fn apply() {}\n", + ); + let violations = walled_tier_violations(root.path()); + assert!( + violations.is_empty(), + "prose naming a module path is documentation, not a dependency: {violations:?}" + ); +} + +#[test] +fn the_walled_tiers_own_modules_may_name_each_other() { + let root = tempfile::TempDir::new().expect("tempdir"); + write_app_file(root.path(), "admin/walled/system.rs", NAMES_TIER); + let violations = walled_tier_violations(root.path()); + assert!( + violations.is_empty(), + "a module inside the tier is not outside it: {violations:?}" + ); +} + +/// A name no UTF-8 string can hold: a stray byte on unix, an unpaired +/// surrogate on windows. +#[cfg(unix)] +fn undecodable_name() -> std::ffi::OsString { + use std::os::unix::ffi::OsStringExt; + std::ffi::OsString::from_vec(b"lib\xff".to_vec()) +} + +#[cfg(windows)] +fn undecodable_name() -> std::ffi::OsString { + use std::os::windows::ffi::OsStringExt; + std::ffi::OsString::from_wide(&[u16::from(b'l'), u16::from(b'i'), u16::from(b'b'), 0xd800]) +} + +#[test] +fn an_undecodable_path_component_keeps_its_place_in_the_relative_path() { + let root = Path::new("root"); + let file = root + .join("crates") + .join(undecodable_name()) + .join("src") + .join("lib.rs"); + let relative = slash_path(root, &file); + assert_eq!( + relative.split('/').count(), + 4, + "a component that does not decode must not vanish and let the path \ + collide with an allowlist entry: {relative}" + ); +} + +#[test] +fn no_file_outside_the_walled_tier_names_its_modules() { + let violations = walled_tier_violations(&workspace_root()); + assert!( + violations.is_empty(), + "walled tier violations:\n{}", + violations.join("\n") + ); +} + +#[test] +fn tier_table_grants_each_tier_only_lower_tiers() { + assert_eq!(allowed_dependencies("workshop-protocol"), Some(Vec::new())); + assert_eq!( + allowed_dependencies("workshop-registry"), + Some(vec!["workshop-protocol"]) + ); + assert_eq!( + allowed_dependencies("workshop-gateway"), + Some(VOCABULARY.to_vec()) + ); + assert_eq!( + allowed_dependencies("workshop-workspace"), + Some([VOCABULARY, SERVICES].concat()) + ); + assert_eq!( + allowed_dependencies("workshop-server"), + Some([VOCABULARY, SERVICES, FEATURES].concat()) + ); + assert_eq!(allowed_dependencies("gateway"), None); +} diff --git a/crates/build-xtask/src/tidy.rs b/crates/build-xtask/src/tidy.rs index 521c2f8ed..7d9a8ecd5 100644 --- a/crates/build-xtask/src/tidy.rs +++ b/crates/build-xtask/src/tidy.rs @@ -7,8 +7,12 @@ //! wrappers assert the lists are empty, so `cargo test -p build-xtask` //! enforces the architecture; `cargo xtask tidy` prints the same report //! on demand. The file ceiling and lint inheritance checks bind every -//! crate whose crate docs carry the `## Invariants` marker: the -//! `workshop-*` crates today and the `harness-*` crates as they land. +//! `workshop-*` and `harness-*` crate (plus `harness-api`, minus the +//! `workshop` shell) by package name, every other crate whose crate +//! docs carry the `## Invariants` marker, and every crate directory whose +//! manifest the shared walk could not read, parse, or find a package name +//! in - a crate with no readable name cannot be shown exempt. Those read +//! failures are reported by `marker_violations`, their one owner. use std::fs; use std::path::{Path, PathBuf}; @@ -27,18 +31,21 @@ const SHELL: &[&str] = &["workshop-server"]; /// File-line ceiling from the `AGENTS.md` structural rules. const MAX_FILE_LINES: usize = 500; -/// Marker in a crate's `lib.rs` (or `main.rs`) crate docs opting the crate -/// into the decomposed-architecture checks. The `new-crate` scaffolder emits -/// it; every `workshop-*` and `harness-*` crate carries it, and crates -/// outside those families are left alone. +/// Marker in a crate's `lib.rs` (or `main.rs`) crate docs. Mandatory for +/// every `workshop-*` and `harness-*` crate (see [`family_requires_marker`]); +/// on any other crate it opts that crate into the decomposed-architecture +/// checks (`build-xtask` carries it deliberately). The `new-crate` +/// scaffolder emits it. const INVARIANT_MARKER: &str = "//! ## Invariants"; -/// Run every check and return all violations. +/// Runs every check and returns all violations. #[must_use] pub(crate) fn all_violations(root: &Path) -> Vec { let mut violations = tier_dependency_violations(root); + violations.extend(marker_violations(root)); violations.extend(file_ceiling_violations(root)); violations.extend(lint_inheritance_violations(root)); + violations.extend(walled_tier_violations(root)); violations.extend(crate::product::product_boundary_violations(root)); violations.extend(crate::harness_bans::harness_clippy_bans( &root.join("crates").join("harness"), @@ -76,7 +83,7 @@ fn tiered_crate_dir(root: &Path, name: &str) -> PathBuf { root.join("crates").join("workshop").join(short) } -/// Check that tiered `workshop-*` crates depend only on lower tiers. +/// Checks that tiered `workshop-*` crates depend only on lower tiers. /// /// Every tiered crate has landed, so a missing manifest is a violation, /// not a crate to skip. @@ -120,7 +127,7 @@ pub(crate) fn tier_dependency_violations(root: &Path) -> Vec { violations } -/// Collect the `workshop-*` dependency names of every kind (normal, dev, +/// Collects the `workshop-*` dependency names of every kind (normal, dev, /// build, and target-specific) declared in a manifest. fn workshop_dependencies(manifest: &toml::Value) -> Vec { let mut names = Vec::new(); @@ -143,7 +150,7 @@ fn collect_workshop_deps(table: &toml::map::Map, names: &mu } } -/// Check the 500-line file ceiling on every crate participating in the +/// Checks the 500-line file ceiling on every crate participating in the /// decomposed architecture (its `lib.rs` or `main.rs` carries the invariant /// marker). #[must_use] @@ -166,7 +173,7 @@ pub(crate) fn file_ceiling_violations(root: &Path) -> Vec { violations } -/// Check that every participating crate inherits `[lints] workspace = true` +/// Checks that every participating crate inherits `[lints] workspace = true` /// (which carries `unreachable_pub`) and that the workspace root sets it. #[must_use] pub(crate) fn lint_inheritance_violations(root: &Path) -> Vec { @@ -213,35 +220,50 @@ pub(crate) fn lint_inheritance_violations(root: &Path) -> Vec { violations } -/// Crates under `crates/` whose crate docs carry the invariant marker. A -/// directory containing a `Cargo.toml` is a crate and is not descended -/// into; any other directory is a container and the walk descends one -/// level, so crates nested under `crates/workshop/` and `crates/harness/` -/// stay visible. -fn participating_crates(root: &Path) -> Vec { - let mut crates = Vec::new(); - let Ok(entries) = fs::read_dir(root.join("crates")) else { - return crates; - }; - for entry in entries.flatten() { - let dir = entry.path(); - if !dir.is_dir() { - continue; - } - if dir.join("Cargo.toml").exists() { - if carries_marker(&dir) { - crates.push(dir); - } - } else if let Ok(inner) = fs::read_dir(&dir) { - for entry in inner.flatten() { - let sub = entry.path(); - if sub.is_dir() && sub.join("Cargo.toml").exists() && carries_marker(&sub) { - crates.push(sub); - } - } +/// Check that every crate the families bind by name carries the marker. +/// +/// A manifest the walk could not read, parse, or find a package name in is +/// reported as itself, not skipped: a crate with no readable name cannot be +/// shown exempt from the marker. This check is the one owner of the shared +/// walk's read failures; the product-boundary check shares the walk and +/// leaves them here. +#[must_use] +pub(crate) fn marker_violations(root: &Path) -> Vec { + let walk = crate::product::workspace_crates(root); + let mut violations = walk.violations; + for krate in &walk.crates { + if family_requires_marker(&krate.package) && !carries_marker(&root.join(&krate.dir)) { + violations.push(format!( + "{}: src/lib.rs lacks the `{INVARIANT_MARKER}` marker required of every \ + workshop-* and harness-* crate", + krate.package + )); } } - crates + violations +} + +/// Whether a package name places the crate in a family that must carry the +/// marker: `workshop-*` and `harness-*` (which covers `harness-api`). The +/// Tauri shell (the `workshop` package) is exempt. +fn family_requires_marker(name: &str) -> bool { + name != "workshop" && (name.starts_with("workshop-") || name.starts_with("harness-")) +} + +/// Crates bound by the file ceiling and lint inheritance checks: the union +/// of the crates the families bind by name, every crate carrying the +/// marker, and every crate whose manifest the walk could not read - an +/// unreadable manifest cannot show a crate exempt. +fn participating_crates(root: &Path) -> Vec { + let walk = crate::product::workspace_crates(root); + walk.crates + .iter() + .filter(|krate| { + family_requires_marker(&krate.package) || carries_marker(&root.join(&krate.dir)) + }) + .map(|krate| root.join(&krate.dir)) + .chain(walk.unread.iter().map(|dir| root.join(dir))) + .collect() } /// Whether a crate's `lib.rs` or `main.rs` crate docs carry the marker. @@ -251,6 +273,90 @@ fn carries_marker(dir: &Path) -> bool { }) } +/// The crate-relative path the tier's modules are spelled by. +const WALLED_PATH: &str = "crate::admin::walled::"; +/// The files outside the tier that may name a tier module, keyed on path +/// because a line number moves with any edit: `lib.rs` for the `AppState` +/// field types and the router merge, `registry.rs` for the route +/// enumeration, and `test_support.rs` for the fixture that assembles the +/// same state. `test_support.rs` is `#[cfg(test)]`-gated, but the rule +/// reads source text and never sees a cfg, so it is allowlisted by name +/// like the other two. +const WALLED_ALLOWLIST: [&str; 3] = [ + "crates/gateway/app/src/lib.rs", + "crates/gateway/app/src/registry.rs", + "crates/gateway/app/src/test_support.rs", +]; + +/// Checks that only the walled admin tier's own modules and the three +/// assembly sites in [`WALLED_ALLOWLIST`] name a `crate::admin::walled::` +/// path, so a module that no walled route needs does not sit in a +/// directory defined as routes that read secrets, write files, or launch +/// processes. +/// +/// The rule protects that directory's meaning, not the wall: wall +/// enforcement is structural, at the router merge in the gateway app's +/// `lib.rs`. Its reach is one textual match, so it catches a spelled path +/// and misses an alias, a `super::` path, a re-export, and any path a +/// macro generates. It is a tripwire for the common case, not a proof of +/// the boundary. +/// +/// A source file the check cannot read is reported rather than skipped: a +/// file that was never scanned cannot be shown clean. +#[must_use] +pub(crate) fn walled_tier_violations(root: &Path) -> Vec { + let app_src = root.join("crates").join("gateway").join("app").join("src"); + let tier = app_src.join("admin").join("walled"); + let mut violations = Vec::new(); + for file in rust_files(&app_src) { + if file.starts_with(&tier) { + continue; + } + let relative = slash_path(root, &file); + if WALLED_ALLOWLIST.contains(&relative.as_str()) { + continue; + } + let text = match fs::read_to_string(&file) { + Ok(text) => text, + Err(error) => { + violations.push(format!("{relative}: unreadable source file: {error}")); + continue; + } + }; + for (index, line) in text.lines().enumerate() { + // A doc or line comment naming a route module in prose states + // where the other half of a feature lives; it is not a + // dependency on it, and failing the build on documentation + // would only teach authors to stop writing it. + if line.trim_start().starts_with("//") || !line.contains(WALLED_PATH) { + continue; + } + violations.push(format!( + "{relative}:{} names {WALLED_PATH}, which only the tier's own modules \ + and the assembly sites ({}) may", + index + 1, + WALLED_ALLOWLIST.join(", ") + )); + } + } + violations +} + +/// A file's path relative to the workspace root with `/` separators, so +/// the allowlist and the violations read the same on every platform. +/// +/// A component that is not valid UTF-8 is rendered lossily rather than +/// dropped: dropping it would shorten the path and could make it match an +/// allowlist entry, exempting a file the rule should report. +fn slash_path(root: &Path, file: &Path) -> String { + file.strip_prefix(root) + .unwrap_or(file) + .components() + .map(|component| component.as_os_str().to_string_lossy()) + .collect::>() + .join("/") +} + /// Every `.rs` file under `dir`, recursively. fn rust_files(dir: &Path) -> Vec { let mut files = Vec::new(); @@ -275,131 +381,5 @@ fn collect_rust_files(dir: &Path, files: &mut Vec) { } #[cfg(test)] -mod tests { - use super::*; - - fn workspace_root() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")) - .ancestors() - .nth(2) - .expect("build-xtask lives at /crates/build-xtask") - .to_path_buf() - } - - #[test] - fn workshop_tier_dependencies_flow_one_way() { - let violations = tier_dependency_violations(&workspace_root()); - assert!( - violations.is_empty(), - "tier violations:\n{}", - violations.join("\n") - ); - } - - #[test] - fn participating_crates_respect_the_file_line_ceiling() { - let violations = file_ceiling_violations(&workspace_root()); - assert!( - violations.is_empty(), - "ceiling violations:\n{}", - violations.join("\n") - ); - } - - #[test] - fn participating_crates_inherit_workspace_lints() { - let violations = lint_inheritance_violations(&workspace_root()); - assert!( - violations.is_empty(), - "lint violations:\n{}", - violations.join("\n") - ); - } - - #[test] - fn a_tiered_crate_whose_manifest_is_missing_is_reported_not_skipped() { - let root = tempfile::TempDir::new().expect("tempdir"); - std::fs::create_dir_all(root.path().join("crates")).expect("the crates directory creates"); - let violations = tier_dependency_violations(root.path()); - let tiered = [VOCABULARY, SERVICES, FEATURES, SHELL].concat(); - assert_eq!( - violations.len(), - tiered.len(), - "every tiered crate's missing manifest is reported: {violations:?}" - ); - for name in tiered { - assert!( - violations.iter().any(|v| v.contains(name)), - "{name} is named in the violations: {violations:?}" - ); - } - } - - /// Write a crate under `crates//` with the given `lib.rs` docs and - /// one source file of `lines` lines. - fn write_marked_crate(root: &Path, dir: &str, lib_docs: &str, lines: usize) { - let src = root.join("crates").join(dir).join("src"); - std::fs::create_dir_all(&src).expect("the crate source directory creates"); - std::fs::write( - src.parent().expect("src has a parent").join("Cargo.toml"), - "[package]\nname = \"fixture\"\n[lints]\nworkspace = true\n", - ) - .expect("the manifest writes"); - std::fs::write(src.join("lib.rs"), lib_docs).expect("lib.rs writes"); - std::fs::write(src.join("big.rs"), "// line\n".repeat(lines)).expect("big.rs writes"); - } - - #[test] - fn a_harness_crate_carrying_the_marker_is_held_to_the_ceiling() { - let root = tempfile::TempDir::new().expect("tempdir"); - write_marked_crate( - root.path(), - "harness/runner", - "//! Effect loop.\n//!\n//! ## Invariants\n//!\n//! - none\n", - MAX_FILE_LINES + 1, - ); - let violations = file_ceiling_violations(root.path()); - assert_eq!(violations.len(), 1, "{violations:?}"); - assert!( - violations[0].contains("big.rs") && violations[0].contains("over the 500-line ceiling"), - "the oversized harness file is reported: {violations:?}" - ); - } - - #[test] - fn a_harness_crate_without_the_marker_is_outside_the_ceiling() { - let root = tempfile::TempDir::new().expect("tempdir"); - write_marked_crate( - root.path(), - "harness/runner", - "//! Effect loop, not yet opted in.\n", - MAX_FILE_LINES + 1, - ); - assert!( - file_ceiling_violations(root.path()).is_empty(), - "the marker is what opts a harness crate into the ceiling" - ); - } - - #[test] - fn tier_table_grants_each_tier_only_lower_tiers() { - assert_eq!(allowed_dependencies("workshop-protocol"), Some(Vec::new())); - assert_eq!( - allowed_dependencies("workshop-registry"), - Some(vec!["workshop-protocol"]) - ); - assert_eq!( - allowed_dependencies("workshop-gateway"), - Some(VOCABULARY.to_vec()) - ); - assert_eq!( - allowed_dependencies("workshop-workspace"), - Some([VOCABULARY, SERVICES].concat()) - ); - assert_eq!( - allowed_dependencies("workshop-server"), - Some([VOCABULARY, SERVICES, FEATURES].concat()) - ); - assert_eq!(allowed_dependencies("gateway"), None); - } -} +#[path = "tidy-tests.rs"] +mod tests; diff --git a/crates/gateway-api-discovery/Cargo.toml b/crates/gateway-api-discovery/Cargo.toml index 354656a7e..f747ad95f 100644 --- a/crates/gateway-api-discovery/Cargo.toml +++ b/crates/gateway-api-discovery/Cargo.toml @@ -11,6 +11,7 @@ description = "PromptForge gateway discovery seam: the gateway.json gateway disc [dependencies] serde.workspace = true serde_json.workspace = true +shared-error-source = { workspace = true, features = ["json"] } thiserror.workspace = true workspace-hack.workspace = true diff --git a/crates/gateway-api-discovery/src/error.rs b/crates/gateway-api-discovery/src/error.rs index 00c0df0a8..48dacfba7 100644 --- a/crates/gateway-api-discovery/src/error.rs +++ b/crates/gateway-api-discovery/src/error.rs @@ -6,6 +6,12 @@ use std::path::PathBuf; use std::time::Duration; +// The JSON cause behind a `SidecarError` variant. A caller that needs the +// JSON error itself names `shared_error_source` directly; this crate does not +// re-export the wrapper, so there is one name for the cause across the +// workspace rather than one per crate. +use shared_error_source::JsonSource; + /// A failure of a gateway-discovery-file or launch-lock operation. #[derive(Debug, thiserror::Error)] #[non_exhaustive] @@ -41,7 +47,7 @@ pub enum SidecarError { path: PathBuf, /// The underlying JSON error. #[source] - source: serde_json::Error, + source: JsonSource, }, /// The gateway discovery file failed validation. @@ -58,7 +64,7 @@ pub enum SidecarError { Serialize { /// The underlying JSON error. #[source] - source: serde_json::Error, + source: JsonSource, }, /// The atomic write of the gateway discovery file failed. @@ -98,3 +104,28 @@ pub enum SidecarError { timeout: Duration, }, } + +#[cfg(test)] +mod tests { + use super::{JsonSource, SidecarError}; + use std::error::Error as _; + use std::path::PathBuf; + + #[test] + fn the_parse_variant_reaches_the_json_error_through_the_shared_wrapper() { + let Err(json) = serde_json::from_str::("nope") else { + panic!("`nope` must not parse as a u32"); + }; + let error = SidecarError::Parse { + path: PathBuf::from("gateway.json"), + source: json.into(), + }; + let Some(cause) = error.source() else { + panic!("the parse variant carries its JSON cause as source()"); + }; + let Some(wrapper) = cause.downcast_ref::() else { + panic!("the JSON cause is the shared JsonSource"); + }; + assert!(wrapper.as_inner().is_syntax()); + } +} diff --git a/crates/gateway-api-discovery/src/file.rs b/crates/gateway-api-discovery/src/file.rs index 6955d7864..e233cfdc4 100644 --- a/crates/gateway-api-discovery/src/file.rs +++ b/crates/gateway-api-discovery/src/file.rs @@ -105,7 +105,7 @@ impl GatewayDiscoveryFile { let file: GatewayDiscoveryFile = serde_json::from_str(&raw).map_err(|source| SidecarError::Parse { path: path.clone(), - source, + source: source.into(), })?; if let Some(reason) = file.validation_error() { return Err(SidecarError::Invalid { @@ -140,8 +140,9 @@ impl GatewayDiscoveryFile { path: run_dir.to_owned(), source, })?; - let bytes = - serde_json::to_vec_pretty(self).map_err(|source| SidecarError::Serialize { source })?; + let bytes = serde_json::to_vec_pretty(self).map_err(|source| SidecarError::Serialize { + source: source.into(), + })?; write_atomic_owner_only(&path, &bytes) .map_err(|source| SidecarError::Write { path, source }) } diff --git a/crates/gateway-api/Cargo.toml b/crates/gateway-api-types/Cargo.toml similarity index 74% rename from crates/gateway-api/Cargo.toml rename to crates/gateway-api-types/Cargo.toml index 5b5272e5b..a3965a456 100644 --- a/crates/gateway-api/Cargo.toml +++ b/crates/gateway-api-types/Cargo.toml @@ -1,12 +1,12 @@ [package] -name = "gateway-api" +name = "gateway-api-types" version.workspace = true edition.workspace = true license.workspace = true repository.workspace = true publish = false -description = "PromptForge shared gateway vocabulary: the provider model sheet schema" +description = "PromptForge gateway wire vocabulary: the provider model sheet schema, the model metadata types, and the progress snapshot" keywords = ["prompt", "llm", "gateway", "models"] categories = ["rust-patterns"] documentation = "https://cppalliance.github.io/promptforge/" diff --git a/crates/gateway-api/src/lib.rs b/crates/gateway-api-types/src/lib.rs similarity index 96% rename from crates/gateway-api/src/lib.rs rename to crates/gateway-api-types/src/lib.rs index f48e0a45c..87d192ae0 100644 --- a/crates/gateway-api/src/lib.rs +++ b/crates/gateway-api-types/src/lib.rs @@ -1,6 +1,8 @@ -//! The provider model sheet schema: one versioned JSON snapshot of every -//! provider's models, published as a release artifact and consumed by the -//! Gateway and the Workshop UI. +//! The Gateway's public wire vocabulary (`gateway-api-types`): the provider +//! model sheet schema, one versioned JSON snapshot of every provider's models +//! published as a release artifact and consumed by the Gateway and the +//! Workshop UI; the model metadata types; and the [`Progress`] snapshot the +//! Gateway streams to its status consumers. //! //! This crate is pure vocabulary: it depends only on `serde` and `time` and //! on no other workspace crate, so every product crate may depend on it. @@ -11,8 +13,10 @@ use serde::{Deserialize, Serialize}; use time::{Date, OffsetDateTime}; mod metadata; +pub mod progress; pub use metadata::{Capabilities, ModelInfo, ModelKind, ThinkingMode}; +pub use progress::Progress; /// The sheet schema version this reader accepts: the writer in /// `gateway-cloud-providers` stamps it and the gateway reader gates on it. @@ -76,6 +80,7 @@ pub struct EnvVar { /// How a provider uses an environment variable. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] +#[non_exhaustive] pub enum EnvRole { /// A credential: the variable carries API key material. Key, @@ -86,6 +91,7 @@ pub enum EnvRole { /// Curated product opinion, not a vendor fact. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] +#[non_exhaustive] pub enum Tier { /// The frontier providers. Prime, @@ -100,6 +106,7 @@ pub enum Tier { /// Freshness of one provider's slice. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] +#[non_exhaustive] pub enum SliceStatus { /// Fetched fresh this run. Ok, @@ -114,9 +121,10 @@ pub enum SliceStatus { /// One normalized model entry. Future additive fields carry /// `#[serde(default)]`; the schema version bump is reserved for removals /// and renames. -// The modality and capability booleans are the sheet schema itself; a -// builder or sub-struct would only obscure the wire shape. -#[allow(clippy::struct_excessive_bools)] +#[expect( + clippy::struct_excessive_bools, + reason = "the modality and capability booleans are the sheet schema itself; a builder or sub-struct would only obscure the wire shape" +)] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ModelEntry { /// The upstream slug. diff --git a/crates/gateway-api/src/metadata.rs b/crates/gateway-api-types/src/metadata.rs similarity index 94% rename from crates/gateway-api/src/metadata.rs rename to crates/gateway-api-types/src/metadata.rs index 8668be0f9..275a078b8 100644 --- a/crates/gateway-api/src/metadata.rs +++ b/crates/gateway-api-types/src/metadata.rs @@ -123,7 +123,7 @@ impl Capabilities { /// /// # Examples /// ``` - /// let mut capabilities = gateway_api::Capabilities::default(); + /// let mut capabilities = gateway_api_types::Capabilities::default(); /// capabilities.max_output = Some(4096); /// assert_eq!(capabilities.max_output(), Some(4096)); /// ``` @@ -137,7 +137,7 @@ impl Capabilities { /// /// # Examples /// ``` - /// let mut capabilities = gateway_api::Capabilities::default(); + /// let mut capabilities = gateway_api_types::Capabilities::default(); /// capabilities.default_temperature = Some(0.7); /// assert_eq!(capabilities.default_temperature(), Some(0.7)); /// ``` @@ -150,7 +150,7 @@ impl Capabilities { /// /// # Examples /// ``` - /// let mut capabilities = gateway_api::Capabilities::default(); + /// let mut capabilities = gateway_api_types::Capabilities::default(); /// capabilities.images = true; /// assert!(capabilities.images()); /// ``` @@ -163,7 +163,7 @@ impl Capabilities { /// /// # Examples /// ``` - /// let mut capabilities = gateway_api::Capabilities::default(); + /// let mut capabilities = gateway_api_types::Capabilities::default(); /// capabilities.parallel_tool_calls = true; /// assert!(capabilities.parallel_tool_calls()); /// ``` @@ -177,7 +177,7 @@ impl Capabilities { /// /// # Examples /// ``` - /// let mut capabilities = gateway_api::Capabilities::default(); + /// let mut capabilities = gateway_api_types::Capabilities::default(); /// capabilities.effort_levels = vec!["low".to_owned(), "high".to_owned()]; /// assert_eq!(capabilities.effort_levels(), ["low", "high"]); /// ``` @@ -190,7 +190,7 @@ impl Capabilities { /// /// # Examples /// ``` - /// let mut capabilities = gateway_api::Capabilities::default(); + /// let mut capabilities = gateway_api_types::Capabilities::default(); /// capabilities.default_effort = Some("low".to_owned()); /// assert_eq!(capabilities.default_effort(), Some("low")); /// ``` @@ -204,7 +204,7 @@ impl Capabilities { /// /// # Examples /// ``` - /// let mut capabilities = gateway_api::Capabilities::default(); + /// let mut capabilities = gateway_api_types::Capabilities::default(); /// capabilities.adaptive_thinking = true; /// assert!(capabilities.adaptive_thinking()); /// ``` @@ -218,7 +218,7 @@ impl Capabilities { /// /// # Examples /// ``` - /// let mut capabilities = gateway_api::Capabilities::default(); + /// let mut capabilities = gateway_api_types::Capabilities::default(); /// capabilities.voices = vec!["alloy".to_owned(), "nova".to_owned()]; /// assert_eq!(capabilities.voices(), ["alloy", "nova"]); /// ``` diff --git a/crates/gateway-api-types/src/progress-tests.rs b/crates/gateway-api-types/src/progress-tests.rs new file mode 100644 index 000000000..5d8a1cb16 --- /dev/null +++ b/crates/gateway-api-types/src/progress-tests.rs @@ -0,0 +1,25 @@ +use super::Progress; + +#[test] +fn a_busy_snapshot_round_trips_through_json() { + let wire = r#"{"busy":true,"text":"Downloading qwen3-8b.gguf 45%"}"#; + let snapshot: Progress = serde_json::from_str(wire).expect("the wire shape must parse"); + assert_eq!( + snapshot, + Progress { + busy: true, + text: "Downloading qwen3-8b.gguf 45%".to_owned(), + } + ); + let again = serde_json::to_string(&snapshot).expect("a snapshot must serialize"); + assert_eq!(again, wire, "the round trip must be byte-for-byte lossless"); +} + +#[test] +fn the_default_snapshot_is_idle_with_empty_text() { + let snapshot = Progress::default(); + assert!(!snapshot.busy, "an idle snapshot is not busy"); + assert_eq!(snapshot.text, "", "an idle snapshot carries no text"); + let wire = serde_json::to_string(&snapshot).expect("the default must serialize"); + assert_eq!(wire, r#"{"busy":false,"text":""}"#); +} diff --git a/crates/gateway-api-types/src/progress.rs b/crates/gateway-api-types/src/progress.rs new file mode 100644 index 000000000..a50764d86 --- /dev/null +++ b/crates/gateway-api-types/src/progress.rs @@ -0,0 +1,31 @@ +//! The progress snapshot: the Gateway's one live activity, as a busy flag +//! and a producer-owned text. +//! +//! The Gateway streams one snapshot per change on `GET /admin/progress` and +//! embeds the current one in `GET /admin/status`. There are no fractions, +//! weights, or hierarchy on the wire: a producer that wants to show a +//! percentage formats it into the text itself. +//! +//! The text is user-visible in the Workshop status bar, the config UI, and +//! the tray, so a producer must never place a bearer key, API key, or other +//! credential in it. + +use serde::{Deserialize, Serialize}; + +/// One snapshot of the Gateway's live activity. +/// +/// Future additive fields carry `#[serde(default)]` so a lagging reader +/// survives them; there is no schema version. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct Progress { + /// Whether any activity is live. The UIs show an indeterminate + /// barberpole while this is set. + pub busy: bool, + /// The newest live activity's text, e.g. `"Downloading qwen3-8b.gguf + /// 45%"`; empty when idle. + pub text: String, +} + +#[cfg(test)] +#[path = "progress-tests.rs"] +mod tests; diff --git a/crates/gateway/README.md b/crates/gateway/README.md index 6f68367df..b7d1928b1 100644 --- a/crates/gateway/README.md +++ b/crates/gateway/README.md @@ -1,18 +1,18 @@ # crates/gateway/ -`crates/gateway/` is the gateway family's private container - outside crates may name only `gateway-api` and `gateway-api-discovery` at the `crates/` root. +`crates/gateway/` is the gateway family's private container - outside crates may name only `gateway-api-types` and `gateway-api-discovery` at the `crates/` root. ## gateway -The inference gateway itself (at `app/`): the always-on OpenAI-shaped service, the only process with an edge to an LLM backend. The workshop shell supervises it and the executor calls models through it. Depends on the whole family (api, api-discovery, config, logging, protocol, routing) plus shared-loopback and shared-progress, with local, stt, web-search, and config-ui behind cargo features. +The inference gateway itself (at `app/`): the always-on OpenAI-shaped service, the only process with an edge to an LLM backend. The workshop shell supervises it and the executor calls models through it. Depends on the whole family (api-types, api-discovery, config, logging, progress, protocol, routing) plus shared-loopback, with local, stt, web-search, and config-ui behind cargo features. ## gateway-cloud-providers -The tiered cloud provider registry: per-provider fetch and normalization behind an injected reqwest client, plus the sheet-building binary. Its binary produces the provider model sheet the gateway serves at `/admin/cloud-models`. Depends on gateway-api. +The tiered cloud provider registry: per-provider fetch and normalization behind an injected reqwest client, plus the sheet-building binary. Its binary produces the provider model sheet the gateway serves at `/admin/cloud-models`. Depends on gateway-api-types. ## gateway-config -The gateway's configuration: single-file TOML, profile selection, and validation into a typed Config. Every gateway family crate reads its configuration through it. Depends on gateway-api. +The gateway's configuration: single-file TOML, profile selection, and validation into a typed Config. Every gateway family crate reads its configuration through it. Depends on gateway-api-types. ## gateway-config-ui @@ -20,15 +20,19 @@ The embedded config SPA served at `/config` behind the loopback wall. The gatewa ## gateway-local -Local inference: GGUF provisioning, the artifact store, and the llama-server child lifecycle. The gateway runs local models through it in local builds, and the STT runtime shares its artifact store. Depends on gateway-config, gateway-protocol, gateway-routing, and shared-progress. +Local inference: GGUF provisioning, the artifact store, and the llama-server child lifecycle. The gateway runs local models through it in local builds, and the STT runtime shares its artifact store. Depends on gateway-config, gateway-progress, gateway-protocol, and gateway-routing. ## gateway-logging The logging sink: a bounded priority queue, rotation, and a worker-owned file writer behind `MakeWriter`. The gateway installs its subscriber at boot. No workspace dependencies. +## gateway-progress + +The live-activity hub (at `progress/`): a producer begins an activity with a text, replaces the text as work moves, and drops the guard when done; the hub publishes the newest live text as a `Progress` busy-and-text snapshot over a `watch` channel. Every slow gateway operation reports through it, and the admin SSE stream, the status endpoint, and the tray read from it. Depends on gateway-api-types; tokio `sync` carries the channel. + ## gateway-protocol -The wire protocol: OpenAI wire types, validation, and the Upstream abstraction with the shared HTTP client policy. Every crate that speaks to a backend goes through it. Depends on gateway-api and gateway-config; reqwest carries the transport. +The wire protocol: OpenAI wire types, validation, and the Upstream abstraction with the shared HTTP client policy. Every crate that speaks to a backend goes through it. Depends on gateway-api-types and gateway-config; reqwest carries the transport. ## gateway-routing diff --git a/crates/gateway/app/Cargo.toml b/crates/gateway/app/Cargo.toml index 66cda05ab..22c601ccf 100644 --- a/crates/gateway/app/Cargo.toml +++ b/crates/gateway/app/Cargo.toml @@ -52,16 +52,15 @@ rand.workspace = true # because those endpoints hold secrets in every build. shared-loopback.workspace = true gateway-protocol.workspace = true -# The provider model sheet schema (gateway-api) behind +# The provider model sheet schema (gateway-api-types) behind # `GET /admin/cloud-models` and the launch-time sheet cache # (src/cloud_models.rs). -gateway-api.workspace = true +gateway-api-types.workspace = true # The gateway-discovery-file seam: gateway.json is written after every successful # bind, in every build, so the workshop can discover a running gateway. gateway-api-discovery.workspace = true gateway-routing.workspace = true -# The serde feature wires the progress event stream onto HTTP. -shared-progress = { workspace = true, features = ["serde"] } +gateway-progress.workspace = true # Optional: gateway-hosted speech-to-text (engine lifecycle, the # `/v1/audio/transcriptions` route), behind the default-on `stt` feature. gateway-stt = { workspace = true, optional = true } diff --git a/crates/gateway/app/src/admin.rs b/crates/gateway/app/src/admin.rs index 3ba2ebaee..7de371eea 100644 --- a/crates/gateway/app/src/admin.rs +++ b/crates/gateway/app/src/admin.rs @@ -1,11 +1,16 @@ -//! The admin surface: profile listing and switching, the status readout, -//! the progress stream, the running-config view, and queue cancellation. +//! The admin surface, in its two tiers. +//! +//! [`open`] holds the routes any authenticated caller may reach: profile +//! listing and switching, the status readout, the progress stream, and +//! queue cancellation. [`walled`] holds the routes that read secrets in +//! plaintext, write files, or launch processes; `build_router` mounts its +//! router behind the shared loopback wall in every build, so a +//! non-loopback peer is refused with 403 before bearer auth runs. A module +//! under `walled/` is loopback-only by its path, and its handlers say so +//! again by extracting [`crate::auth::LoopbackCaller`]. -pub(crate) mod config; -pub(crate) mod profiles; -pub(crate) mod progress; -pub(crate) mod queue; -pub(crate) mod status; +pub(crate) mod open; +pub(crate) mod walled; use crate::AppState; use crate::error::GatewayError; diff --git a/crates/gateway/app/src/admin/config.rs b/crates/gateway/app/src/admin/config.rs deleted file mode 100644 index 7bbdf411b..000000000 --- a/crates/gateway/app/src/admin/config.rs +++ /dev/null @@ -1,21 +0,0 @@ -//! The `GET /admin/config` route: the running global configuration as -//! JSON with secrets redacted. - -use axum::Json; -use axum::extract::State; - -use crate::AppState; -use crate::auth::AuthedCaller; -use crate::error::GatewayError; - -/// The `GET /admin/config` route: bearer-authed, renders the running global -/// config in the pending admin shape. The running profile is not part of -/// the document (`GET /admin/status` reports it), so the reply round-trips -/// through `PUT /admin/config` unchanged. -pub(crate) async fn admin_config( - State(state): State, - _caller: AuthedCaller, -) -> Result, GatewayError> { - let config = state.config().await; - Ok(Json(config.to_json())) -} diff --git a/crates/gateway/app/src/admin/open.rs b/crates/gateway/app/src/admin/open.rs new file mode 100644 index 000000000..c52dc1c25 --- /dev/null +++ b/crates/gateway/app/src/admin/open.rs @@ -0,0 +1,34 @@ +//! The open admin tier: bearer-authed routes reachable from any peer the +//! listener admits. Nothing here reads a secret in plaintext, writes a +//! file, or launches a process; a route that would belongs in +//! [`super::walled`]. + +pub(crate) mod profiles; +pub(crate) mod progress; +pub(crate) mod queue; +pub(crate) mod status; + +use axum::Router; + +use crate::AppState; +use crate::registry::RouteInfo; + +/// The open admin routes, merged into the root router without a wall. +pub(crate) fn routes() -> Router { + Router::new() + .merge(profiles::routes()) + .merge(status::routes()) + .merge(progress::routes()) + .merge(queue::routes()) +} + +/// The open admin routes, as the registry sees them. +pub(crate) fn registry() -> Vec { + [ + profiles::ROUTES, + status::ROUTES, + progress::ROUTES, + queue::ROUTES, + ] + .concat() +} diff --git a/crates/gateway/app/src/admin/profiles-tests.rs b/crates/gateway/app/src/admin/open/profiles-tests.rs similarity index 100% rename from crates/gateway/app/src/admin/profiles-tests.rs rename to crates/gateway/app/src/admin/open/profiles-tests.rs diff --git a/crates/gateway/app/src/admin/profiles.rs b/crates/gateway/app/src/admin/open/profiles.rs similarity index 65% rename from crates/gateway/app/src/admin/profiles.rs rename to crates/gateway/app/src/admin/open/profiles.rs index 52f2919ea..35a34b9d5 100644 --- a/crates/gateway/app/src/admin/profiles.rs +++ b/crates/gateway/app/src/admin/open/profiles.rs @@ -1,15 +1,31 @@ //! The profile routes: `GET /admin/profiles` and //! `POST /admin/switch-profile`. -use axum::Json; use axum::extract::State; -use serde::Deserialize; +use axum::http::Method; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use gateway_config::ProfileName; +use serde::{Deserialize, Serialize}; -use super::config_path; use crate::AppState; +use crate::admin::config_path; use crate::auth::AuthedCaller; -use crate::error::{GatewayError, WireJson}; -use gateway_config::ProfileName; +use crate::error::{GatewayError, WireJson, blocking, config_write_error}; +use crate::registry::RouteInfo; + +const PROFILES: RouteInfo = RouteInfo::open("/admin/profiles", &[Method::GET]); +const SWITCH_PROFILE: RouteInfo = RouteInfo::open("/admin/switch-profile", &[Method::POST]); + +/// The profile routes, as the registry sees them. +pub(crate) const ROUTES: &[RouteInfo] = &[PROFILES, SWITCH_PROFILE]; + +/// The profile routes. +pub(crate) fn routes() -> Router { + Router::new() + .route(PROFILES.path, get(admin_list_profiles)) + .route(SWITCH_PROFILE.path, post(admin_switch_profile)) +} /// The `POST /admin/switch-profile` body: a profile name, or `null` (or /// absent) to select no profile. @@ -18,18 +34,35 @@ pub(crate) struct SwitchProfileRequest { name: Option, } +/// The `GET /admin/profiles` reply. +#[derive(Debug, Serialize)] +pub(crate) struct ProfilesReply { + /// Every profile name the loaded catalog defines, in catalog order. + profiles: Vec, +} + +/// The `POST /admin/switch-profile` reply. +#[derive(Debug, Serialize)] +pub(crate) struct SwitchProfileReply { + /// The persisted selection, `null` when no profile is selected. + profile: Option, + /// Whether the selection differs from the running profile, so it + /// takes effect only at the next start. + restart_required: bool, +} + /// Lists profile names from the loaded global catalog. pub(crate) async fn admin_list_profiles( State(state): State, _caller: AuthedCaller, -) -> Result, GatewayError> { +) -> Result, GatewayError> { let config = state.config().await; - let profiles: Vec<&str> = config + let profiles = config .profiles() .iter() - .map(gateway_config::ProfileConfig::name) + .map(|profile| profile.name().to_owned()) .collect(); - Ok(Json(serde_json::json!({ "profiles": profiles }))) + Ok(Json(ProfilesReply { profiles })) } /// Persists the profile selection and reports whether a restart is needed. @@ -50,7 +83,7 @@ pub(crate) async fn admin_switch_profile( State(state): State, _caller: AuthedCaller, WireJson(request): WireJson, -) -> Result, GatewayError> { +) -> Result, GatewayError> { let selected = request .name .as_deref() @@ -77,17 +110,16 @@ pub(crate) async fn admin_switch_profile( selected.as_ref().map(ProfileName::as_str) != live.profile_name.as_deref() }; let persisted = selected.clone(); - tokio::task::spawn_blocking(move || match &persisted { + blocking(move || match &persisted { Some(name) => gateway_config::persist_profile_state(&config_path, name), None => gateway_config::clear_profile_state(&config_path), }) - .await - .map_err(|join| GatewayError::ConfigWriteIo(Box::new(join)))? - .map_err(crate::config_write::config_write_error)?; - Ok(Json(serde_json::json!({ - "profile": selected.as_ref().map(ProfileName::as_str), - "restart_required": restart_required, - }))) + .await? + .map_err(config_write_error)?; + Ok(Json(SwitchProfileReply { + profile: selected.map(|name| name.as_str().to_owned()), + restart_required, + })) } /// The `profile not found` detail for a switch to a name the live catalog diff --git a/crates/gateway/app/src/admin/open/progress-tests.rs b/crates/gateway/app/src/admin/open/progress-tests.rs new file mode 100644 index 000000000..cb0f9ad30 --- /dev/null +++ b/crates/gateway/app/src/admin/open/progress-tests.rs @@ -0,0 +1,174 @@ +//! The progress SSE stream: snapshot first, one line per change, +//! heartbeats, and shutdown. + +use std::sync::Arc; +use std::time::Duration; + +use futures_util::StreamExt as _; +use gateway_api_types::Progress; +use gateway_progress::ProgressHub; + +use super::{PROGRESS_HEARTBEAT, progress_sse_response}; +use crate::shutdown::ShutdownSignal; + +const FRAME_TIMEOUT: Duration = Duration::from_secs(5); + +fn snapshot(busy: bool, text: &str) -> Progress { + Progress { + busy, + text: text.to_owned(), + } +} + +/// Reads `data:` payloads from the body until `count` snapshots arrive, +/// skipping heartbeat comments. Frames may split or coalesce SSE events, +/// so the text accumulates across reads. +async fn read_snapshots(frames: &mut S, count: usize) -> Vec +where + S: futures_util::Stream> + Unpin, +{ + let mut text = String::new(); + let mut snapshots = Vec::new(); + while snapshots.len() < count { + let frame = tokio::time::timeout(FRAME_TIMEOUT, frames.next()) + .await + .expect("the progress stream stalled") + .expect("the progress stream ended early") + .expect("the progress stream errored"); + let chunk = std::str::from_utf8(&frame).expect("SSE frames are UTF-8"); + text.push_str(chunk); + while let Some(end) = text.find("\n\n") { + let block: String = text.drain(..end + 2).collect(); + if let Some(data) = block.trim().strip_prefix("data: ") { + snapshots.push(serde_json::from_str(data).expect("a data line is a Progress")); + } + } + } + snapshots +} + +#[tokio::test] +async fn a_fresh_subscriber_first_receives_the_current_snapshot() { + let hub = Arc::new(ProgressHub::new()); + let activity = hub.begin("Downloading qwen 45%"); + + // The subscriber connects after the work began: the stream must open + // with the current state rather than wait for the next change. + let response = progress_sse_response(&hub, ShutdownSignal::default()); + let mut frames = response.into_body().into_data_stream(); + let snapshots = read_snapshots(&mut frames, 1).await; + assert_eq!(snapshots[0], snapshot(true, "Downloading qwen 45%")); + drop(activity); +} + +#[tokio::test] +async fn an_idle_hub_opens_the_stream_with_the_idle_snapshot() { + let hub = Arc::new(ProgressHub::new()); + let response = progress_sse_response(&hub, ShutdownSignal::default()); + let mut frames = response.into_body().into_data_stream(); + let snapshots = read_snapshots(&mut frames, 1).await; + assert_eq!( + snapshots[0], + Progress::default(), + "the idle snapshot is sent so a subscriber can clear a stale bar at once" + ); +} + +#[tokio::test] +async fn the_stream_carries_one_line_per_change_in_order() { + let hub = Arc::new(ProgressHub::new()); + let response = progress_sse_response(&hub, ShutdownSignal::default()); + let mut frames = response.into_body().into_data_stream(); + // Consume the opening idle snapshot. + let opening = read_snapshots(&mut frames, 1).await; + assert_eq!(opening[0], Progress::default()); + + let activity = hub.begin("Loading profile"); + let begun = read_snapshots(&mut frames, 1).await; + assert_eq!(begun[0], snapshot(true, "Loading profile")); + + activity.set_text("Downloading models"); + let moved = read_snapshots(&mut frames, 1).await; + assert_eq!(moved[0], snapshot(true, "Downloading models")); + + drop(activity); + let ended = read_snapshots(&mut frames, 1).await; + assert_eq!( + ended[0], + Progress::default(), + "the last activity's drop publishes the idle snapshot" + ); +} + +#[tokio::test(start_paused = true)] +async fn an_idle_hub_emits_heartbeat_comments_on_cadence() { + let hub = Arc::new(ProgressHub::new()); + let response = progress_sse_response(&hub, ShutdownSignal::default()); + let mut frames = response.into_body().into_data_stream(); + let opening = read_snapshots(&mut frames, 1).await; + assert_eq!(opening[0], Progress::default()); + + // Nothing is live, so heartbeat comments are the only traffic; two + // ticks pin the cadence, not just the first deadline. + for _ in 0..2 { + let frame = tokio::time::timeout(PROGRESS_HEARTBEAT + FRAME_TIMEOUT, frames.next()) + .await + .expect("the progress stream stalled") + .expect("the progress stream ended early") + .expect("the progress stream errored"); + assert_eq!( + std::str::from_utf8(&frame).expect("SSE frames are UTF-8"), + ": heartbeat\n\n" + ); + } +} + +#[tokio::test(start_paused = true)] +async fn an_activity_drop_reports_idle_then_the_stream_goes_quiet() { + let hub = Arc::new(ProgressHub::new()); + let response = progress_sse_response(&hub, ShutdownSignal::default()); + let mut frames = response.into_body().into_data_stream(); + let opening = read_snapshots(&mut frames, 1).await; + assert_eq!(opening[0], Progress::default()); + + let activity = hub.begin("Downloading qwen"); + let begun = read_snapshots(&mut frames, 1).await; + assert!(begun[0].busy); + + drop(activity); + let ended = read_snapshots(&mut frames, 1).await; + assert!(!ended[0].busy); + // The first heartbeat is 15 s out, so nothing may arrive inside this + // window after the drop: an idle hub is otherwise silent. + assert!( + tokio::time::timeout(Duration::from_millis(300), frames.next()) + .await + .is_err(), + "a dropped activity must leave the stream quiet until the next heartbeat" + ); +} + +/// The shutdown signal ends the open-ended stream, so an attached +/// subscriber cannot hold its connection through the graceful drain. +#[tokio::test] +async fn the_stream_ends_when_the_shutdown_signal_fires() { + let hub = Arc::new(ProgressHub::new()); + let shutdown = ShutdownSignal::default(); + let response = progress_sse_response(&hub, shutdown.clone()); + let mut frames = response.into_body().into_data_stream(); + + // The activity begins before the first poll, so the opening line is + // already the busy snapshot: the watch keeps only the latest state. + let _activity = hub.begin("Downloading qwen"); + let snapshots = read_snapshots(&mut frames, 1).await; + assert_eq!(snapshots[0], snapshot(true, "Downloading qwen")); + + shutdown.fire(); + let end = tokio::time::timeout(FRAME_TIMEOUT, frames.next()) + .await + .expect("the stream reacts to the signal within the frame timeout"); + assert!( + end.is_none(), + "the stream ends on shutdown instead of waiting for the heartbeat: {end:?}" + ); +} diff --git a/crates/gateway/app/src/admin/open/progress.rs b/crates/gateway/app/src/admin/open/progress.rs new file mode 100644 index 000000000..fe82e5620 --- /dev/null +++ b/crates/gateway/app/src/admin/open/progress.rs @@ -0,0 +1,121 @@ +//! The `GET /admin/progress` route: the process activity hub as an SSE +//! stream of [`Progress`] snapshots with heartbeats. + +use axum::Router; +use axum::body::Body; +use axum::extract::State; +use axum::http::header::{CACHE_CONTROL, CONTENT_TYPE}; +use axum::http::{HeaderValue, Method}; +use axum::response::Response; +use axum::routing::get; +use gateway_api_types::Progress; +use gateway_progress::ProgressHub; + +use crate::AppState; +use crate::auth::AuthedCaller; +use crate::error::GatewayError; +use crate::registry::RouteInfo; +use crate::shutdown; + +const PROGRESS: RouteInfo = RouteInfo::open("/admin/progress", &[Method::GET]); + +/// The progress stream route, as the registry sees it. +pub(crate) const ROUTES: &[RouteInfo] = &[PROGRESS]; + +/// The progress stream route. +pub(crate) fn routes() -> Router { + Router::new().route(PROGRESS.path, get(admin_progress)) +} + +/// Heartbeat cadence for the progress stream: SSE comment lines keep an +/// idle connection alive through NAT and firewall timeouts. +pub(crate) const PROGRESS_HEARTBEAT: std::time::Duration = std::time::Duration::from_secs(15); + +/// The `GET /admin/progress` route: bearer-authed, streams the process +/// activity hub as SSE. +/// +/// The reply is `text/event-stream` and never terminates on its own: a +/// freshly connected subscriber first receives the current [`Progress`] +/// snapshot, so it can render state without waiting for the next change, +/// then one `data:` line per change, with heartbeat comment lines every +/// [`PROGRESS_HEARTBEAT`] while nothing changes. The hub keeps only the +/// latest snapshot, so a slow subscriber skips intermediate texts and never +/// falls behind. Client disconnect is Drop all the way down: the response +/// body owns the receiver. The one server-side end is the process shutdown +/// signal: an attached subscriber (the config SPA, the workshop) would +/// otherwise hold its connection open through the graceful drain and pin +/// the process. +pub(crate) async fn admin_progress( + State(state): State, + _caller: AuthedCaller, +) -> Result { + Ok(progress_sse_response(&state.hub, state.shutdown.clone())) +} + +/// Builds the progress SSE response over `hub`: the current snapshot first, +/// then one line per change, heartbeats in the gaps, until `shutdown` +/// fires. +pub(crate) fn progress_sse_response( + hub: &ProgressHub, + shutdown: shutdown::ShutdownSignal, +) -> Response { + // The receiver starts holding the current snapshot as unseen, so the + // first `changed()` resolves at once with the opening line and nothing + // between subscribe and first poll is lost. + let mut rx = hub.subscribe(); + rx.mark_changed(); + let heartbeat_at = tokio::time::Instant::now() + PROGRESS_HEARTBEAT; + let stream = futures_util::stream::unfold( + ( + rx, + tokio::time::interval_at(heartbeat_at, PROGRESS_HEARTBEAT), + shutdown, + ), + |(mut rx, mut heartbeat, shutdown)| async move { + loop { + tokio::select! { + () = shutdown.fired() => return None, + _ = heartbeat.tick() => { + return Some(( + Ok::<_, std::convert::Infallible>(": heartbeat\n\n".to_owned()), + (rx, heartbeat, shutdown), + )); + } + changed = rx.changed() => match changed { + Ok(()) => { + let line = snapshot_line(&rx.borrow_and_update()); + if let Some(line) = line { + return Some((Ok(line), (rx, heartbeat, shutdown))); + } + } + // The hub lives in `AppState` for the process + // lifetime, so its sender never closes first. + Err(_) => return None, + }, + } + } + }, + ); + let mut response = Response::new(Body::from_stream(stream)); + 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 +} + +/// Serializes one snapshot as an SSE `data:` line, or `None` (logged) when +/// serialization fails: the wire type is plain data, so a failure is a +/// schema bug, and one bad snapshot must not kill the stream. +fn snapshot_line(snapshot: &Progress) -> Option { + match serde_json::to_string(snapshot) { + Ok(json) => Some(format!("data: {json}\n\n")), + Err(error) => { + tracing::warn!(%error, "progress snapshot failed to serialize; dropping it"); + None + } + } +} + +#[cfg(test)] +#[path = "progress-tests.rs"] +mod tests; diff --git a/crates/gateway/app/src/admin/queue.rs b/crates/gateway/app/src/admin/open/queue.rs similarity index 54% rename from crates/gateway/app/src/admin/queue.rs rename to crates/gateway/app/src/admin/open/queue.rs index 5ec883f24..25d393d6a 100644 --- a/crates/gateway/app/src/admin/queue.rs +++ b/crates/gateway/app/src/admin/open/queue.rs @@ -1,13 +1,36 @@ //! The queue cancellation routes: `POST /admin/queue/cancel` and //! `POST /admin/queue/cancel-pending`. -use axum::Json; use axum::extract::State; -use serde::Deserialize; +use axum::http::Method; +use axum::routing::post; +use axum::{Json, Router}; +use serde::{Deserialize, Serialize}; use crate::AppState; use crate::auth::AuthedCaller; use crate::error::{GatewayError, WireJson}; +use crate::registry::RouteInfo; + +/// The reply of both cancellation routes. +#[derive(Debug, Serialize)] +pub(crate) struct CancelReply { + /// Whether there was a command to cancel. + cancelled: bool, +} + +const CANCEL: RouteInfo = RouteInfo::open("/admin/queue/cancel", &[Method::POST]); +const CANCEL_PENDING: RouteInfo = RouteInfo::open("/admin/queue/cancel-pending", &[Method::POST]); + +/// The queue cancellation routes, as the registry sees them. +pub(crate) const ROUTES: &[RouteInfo] = &[CANCEL, CANCEL_PENDING]; + +/// The queue cancellation routes. +pub(crate) fn routes() -> Router { + Router::new() + .route(CANCEL.path, post(admin_queue_cancel)) + .route(CANCEL_PENDING.path, post(admin_queue_cancel_pending)) +} /// The `POST /admin/queue/cancel` route: bearer-authed, fires the active /// command's cancellation token. The reply reports whether a command was @@ -16,9 +39,9 @@ use crate::error::{GatewayError, WireJson}; pub(crate) async fn admin_queue_cancel( State(state): State, _caller: AuthedCaller, -) -> Result, GatewayError> { +) -> Result, GatewayError> { let cancelled = state.commands.cancel_active(); - Ok(Json(serde_json::json!({ "cancelled": cancelled }))) + Ok(Json(CancelReply { cancelled })) } /// The `POST /admin/queue/cancel-pending` request body. @@ -34,7 +57,7 @@ pub(crate) async fn admin_queue_cancel_pending( State(state): State, _caller: AuthedCaller, WireJson(request): WireJson, -) -> Result, GatewayError> { +) -> Result, GatewayError> { let cancelled = state.commands.cancel_pending(request.index); - Ok(Json(serde_json::json!({ "cancelled": cancelled }))) + Ok(Json(CancelReply { cancelled })) } diff --git a/crates/gateway/app/src/admin/status-tests.rs b/crates/gateway/app/src/admin/open/status-tests.rs similarity index 95% rename from crates/gateway/app/src/admin/status-tests.rs rename to crates/gateway/app/src/admin/open/status-tests.rs index 7aea7de5a..c22550108 100644 --- a/crates/gateway/app/src/admin/status-tests.rs +++ b/crates/gateway/app/src/admin/open/status-tests.rs @@ -88,6 +88,11 @@ async fn the_status_response_carries_the_queue_and_endpoint_shape() { let body = get_status(state()).await; assert_eq!(body["queue"]["active"], serde_json::Value::Null); assert_eq!(body["queue"]["pending"], serde_json::json!([])); + assert_eq!( + body["progress"], + serde_json::json!({ "busy": false, "text": "" }), + "an idle gateway carries the idle Progress snapshot at the top level: {body}" + ); assert_eq!( body["loading_models"], serde_json::json!([]), @@ -156,13 +161,18 @@ async fn the_status_response_reports_the_active_and_pending_commands() { "the active command is named: {body}" ); assert!( - body["queue"]["active"]["fraction"].is_number(), - "the active command carries its progress fraction: {body}" + body["queue"]["active"].get("fraction").is_none(), + "the active command carries no fraction: {body}" ); assert!( body["queue"]["active"]["started_at"].is_u64(), "the active command carries its start time as epoch seconds: {body}" ); + assert_eq!( + body["progress"], + serde_json::json!({ "busy": true, "text": "load-profile: main" }), + "the running command's activity is the top-level progress object: {body}" + ); let pending_entries = body["queue"]["pending"] .as_array() .expect("pending is an array"); diff --git a/crates/gateway/app/src/admin/open/status.rs b/crates/gateway/app/src/admin/open/status.rs new file mode 100644 index 000000000..83a77c3fa --- /dev/null +++ b/crates/gateway/app/src/admin/open/status.rs @@ -0,0 +1,182 @@ +//! The `GET /admin/status` readout: profile, models, queue, and one +//! readiness entry per capability endpoint. + +use axum::extract::State; +use axum::http::Method; +use axum::routing::get; +use axum::{Json, Router}; +use gateway_api_types::Progress; +use gateway_config::ModelKind; +use serde::Serialize; + +use crate::AppState; +use crate::auth::AuthedCaller; +use crate::error::GatewayError; +#[cfg(feature = "stt")] +use crate::models::with_speech_endpoint; +use crate::models::{EndpointStatus, endpoint_status}; +use crate::registry::RouteInfo; +#[cfg(feature = "stt")] +use crate::speech::SpeechSnapshot; + +const STATUS: RouteInfo = RouteInfo::open("/admin/status", &[Method::GET]); + +/// The status route, as the registry sees it. +pub(crate) const ROUTES: &[RouteInfo] = &[STATUS]; + +/// The status route. +pub(crate) fn routes() -> Router { + Router::new().route(STATUS.path, get(admin_status)) +} + +/// The `GET /admin/status` reply. +#[derive(Debug, Serialize)] +pub(crate) struct StatusReply { + /// The running profile's name, `null` when none is selected. + profile: Option, + /// Every model in the live routing table, in catalog order. + models: Vec, + /// The boot profile's local models whose children are still spawning. + loading_models: Vec, + /// The process-lifetime identifier the config UI uses to detect a + /// restart. + config_generation: String, + /// The active profile's `models` allowlist, when it declared one. + model_allowlist: Option>, + /// Running local children; zero in a headless build. + local_children: usize, + /// The declared VRAM total of the running local and speech models. + vram_gb: f64, + /// The hub's current busy flag and text. + progress: Progress, + /// The command queue's active and waiting commands. + queue: QueueReply, + /// One readiness entry per capability endpoint. + endpoints: Vec, + /// Speech lifecycle facts (`stt` builds). + #[cfg(feature = "stt")] + speech: SpeechSnapshot, +} + +/// The command queue as the status readout reports it. +#[derive(Debug, Serialize)] +pub(crate) struct QueueReply { + /// The command the worker is running, if any. + active: Option, + /// The commands waiting behind it, in queue order. + pending: Vec, +} + +/// The active command: its display name and when it started, as Unix +/// epoch seconds. +#[derive(Debug, Serialize)] +pub(crate) struct ActiveCommandReply { + name: String, + started_at: u64, +} + +/// One waiting command: its display name and when it was queued, as Unix +/// epoch seconds. +#[derive(Debug, Serialize)] +pub(crate) struct PendingCommandReply { + name: String, + queued_at: u64, +} + +/// An `Instant` as Unix epoch seconds for the status wire shape. The +/// conversion goes through the elapsed duration, so a clock that jumped +/// backward clamps to now rather than underflowing. +fn instant_epoch_seconds(instant: std::time::Instant) -> u64 { + let elapsed = instant.elapsed(); + std::time::SystemTime::now() + .checked_sub(elapsed) + .unwrap_or_else(std::time::SystemTime::now) + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |duration| duration.as_secs()) +} + +/// Current profile name, loaded model names, the local models the boot +/// load is still spawning, process config generation, the profile's model +/// allowlist (its local and speech-to-text members), the declared VRAM +/// total, the hub's current [`Progress`](gateway_api_types::Progress) +/// snapshot, the command queue's active and pending commands, and one +/// readiness entry per capability endpoint the gateway can serve. +pub(crate) async fn admin_status( + State(state): State, + _caller: AuthedCaller, +) -> Result, GatewayError> { + let active = state.commands.active_command(); + let pending = state.commands.pending_commands(); + let progress = state.hub.current(); + let live = state.live.read().await; + let models: Vec = live + .routing + .models() + .iter() + .map(|m| m.name.clone()) + .collect(); + // A headless build has no local runtime; it reports zero children rather + // than dropping the field from the status response. + #[cfg(feature = "local")] + let local_children = live.local.child_count(); + #[cfg(not(feature = "local"))] + let local_children = 0; + let (_, vram_gb) = live.model_status(); + let command_active = active.is_some(); + let configured = |kind: ModelKind| { + live.config + .models() + .iter() + .any(|model| model.kind() == kind) + || live + .config + .catalog_local_models() + .iter() + .any(|model| model.kind() == kind) + }; + let routed = |kind: ModelKind| live.routing.models().iter().any(|model| model.kind == kind); + let endpoints = [ + ("/v1/chat/completions", "Chat completions", ModelKind::Chat), + ("/v1/embeddings", "Embeddings", ModelKind::Embedding), + ("/v1/rerank", "Rerank", ModelKind::Classifier), + ("/v1/audio/speech", "Speech synthesis", ModelKind::Speech), + ] + .into_iter() + .map(|(path, name, kind)| { + endpoint_status(path, name, configured(kind), routed(kind), command_active) + }) + .collect::>(); + #[cfg(feature = "stt")] + let (endpoints, speech) = + with_speech_endpoint(endpoints, state.speech.status(), command_active); + Ok(Json(StatusReply { + profile: live.profile_name.clone(), + models, + loading_models: live.loading.iter().cloned().collect(), + config_generation: state.config_generation.to_string(), + model_allowlist: live.model_allowlist.clone(), + local_children, + vram_gb, + progress, + queue: QueueReply { + active: active.map(|status| ActiveCommandReply { + name: status.name, + started_at: instant_epoch_seconds(status.started_at), + }), + pending: pending + .into_iter() + .map(|entry| PendingCommandReply { + name: entry.name, + queued_at: instant_epoch_seconds(entry.queued_at), + }) + .collect(), + }, + endpoints, + #[cfg(feature = "stt")] + speech: SpeechSnapshot::from(speech), + })) +} + +#[cfg(test)] +#[path = "status-tests.rs"] +mod tests; diff --git a/crates/gateway/app/src/admin/progress-tests.rs b/crates/gateway/app/src/admin/progress-tests.rs deleted file mode 100644 index 503d422a8..000000000 --- a/crates/gateway/app/src/admin/progress-tests.rs +++ /dev/null @@ -1,260 +0,0 @@ -//! The progress SSE stream: replay, heartbeats, lag, and shutdown. - -// Fractions are fixed-point millionths, so equality comparisons are exact. -#![expect(clippy::float_cmp, reason = "fixed-point fractions compare exactly")] - -use std::sync::Arc; -use std::time::Duration; - -use futures_util::StreamExt as _; -use shared_progress::{EventState, ProgressEvent, ProgressHub}; - -use super::{PROGRESS_HEARTBEAT, progress_sse_response}; -use crate::shutdown::ShutdownSignal; - -const FRAME_TIMEOUT: Duration = Duration::from_secs(5); - -/// Reads `data:` payloads from the body until `count` events arrive, -/// skipping heartbeat comments. Frames may split or coalesce SSE events, -/// so the text accumulates across reads. -async fn read_events(frames: &mut S, count: usize) -> Vec -where - S: futures_util::Stream> + Unpin, -{ - let mut text = String::new(); - let mut events = Vec::new(); - while events.len() < count { - let frame = tokio::time::timeout(FRAME_TIMEOUT, frames.next()) - .await - .expect("the progress stream stalled") - .expect("the progress stream ended early") - .expect("the progress stream errored"); - let chunk = std::str::from_utf8(&frame).expect("SSE frames are UTF-8"); - text.push_str(chunk); - while let Some(end) = text.find("\n\n") { - let block: String = text.drain(..end + 2).collect(); - if let Some(data) = block.trim().strip_prefix("data: ") { - events.push(serde_json::from_str(data).expect("a data line is a ProgressEvent")); - } - } - } - events -} - -/// Reads `data:` payloads until `stop` matches one, returning everything -/// read, the matching event last. -async fn read_until(frames: &mut S, stop: impl Fn(&ProgressEvent) -> bool) -> Vec -where - S: futures_util::Stream> + Unpin, -{ - let mut text = String::new(); - let mut events = Vec::new(); - loop { - let frame = tokio::time::timeout(FRAME_TIMEOUT, frames.next()) - .await - .expect("the progress stream stalled") - .expect("the progress stream ended early") - .expect("the progress stream errored"); - let chunk = std::str::from_utf8(&frame).expect("SSE frames are UTF-8"); - text.push_str(chunk); - while let Some(end) = text.find("\n\n") { - let block: String = text.drain(..end + 2).collect(); - if let Some(data) = block.trim().strip_prefix("data: ") { - let event: ProgressEvent = - serde_json::from_str(data).expect("a data line is a ProgressEvent"); - let done = stop(&event); - events.push(event); - if done { - return events; - } - } - } - } -} - -#[tokio::test] -async fn the_stream_carries_begun_updated_finished_in_order() { - let hub = Arc::new(ProgressHub::new()); - let response = progress_sse_response(&hub, ShutdownSignal::default()); - let mut frames = response.into_body().into_data_stream(); - - let tree = hub.operation(); - let leaf = tree.register("download", 1.0); - leaf.set_fraction(0.5); - leaf.complete(); - - let events = read_events(&mut frames, 3).await; - assert!(matches!(events[0].state, EventState::Begun { weight } if weight == 1.0)); - assert!( - matches!(events[1].state, EventState::Updated { fraction } if fraction == 0.5), - "the intermediate sample follows Begun: {:?}", - events[1] - ); - assert!(matches!(events[2].state, EventState::Finished { ok: true })); - assert!(events.iter().all(|event| event.path == "download")); -} - -#[tokio::test] -async fn a_fresh_subscriber_first_receives_a_snapshot_of_live_operations() { - let hub = Arc::new(ProgressHub::new()); - let tree = hub.operation(); - let leaf = tree.register("download", 1.0); - leaf.set_fraction(0.5); - - // The subscriber connects after the work began, so the broadcast - // alone would show nothing until the next report: the snapshot must - // carry the current state. - let response = progress_sse_response(&hub, ShutdownSignal::default()); - let mut frames = response.into_body().into_data_stream(); - let events = read_events(&mut frames, 2).await; - assert!(matches!(events[0].state, EventState::Begun { weight } if weight == 1.0)); - assert!(matches!(events[1].state, EventState::Updated { fraction } if fraction == 0.5)); - assert_eq!(events[0].operation, tree.operation()); -} - -#[tokio::test] -async fn a_fresh_subscriber_sees_a_finished_leafs_terminal_state() { - let hub = Arc::new(ProgressHub::new()); - let tree = hub.operation(); - let leaf = tree.register("download", 1.0); - leaf.set_fraction(0.5); - leaf.fail(); - - // The leaf finished before the subscriber connected; without a - // replayed Finished the subscriber would hold it as unfinished until - // the tree detaches. - let response = progress_sse_response(&hub, ShutdownSignal::default()); - let mut frames = response.into_body().into_data_stream(); - let events = read_events(&mut frames, 3).await; - assert!(matches!(events[0].state, EventState::Begun { .. })); - assert!( - matches!(events[1].state, EventState::Updated { fraction } if fraction == 0.5), - "a failed leaf keeps its fraction: {:?}", - events[1] - ); - assert!( - matches!(events[2].state, EventState::Finished { ok: false }), - "the terminal state replays: {:?}", - events[2] - ); -} - -#[tokio::test] -async fn a_subscriber_sees_when_the_complete_operation_detaches() { - let hub = Arc::new(ProgressHub::new()); - let response = progress_sse_response(&hub, ShutdownSignal::default()); - let mut frames = response.into_body().into_data_stream(); - let tree = hub.operation(); - let operation = tree.operation(); - let leaf = tree.register("loading-profile", 1.0); - leaf.complete(); - drop(tree); - - let events = read_until(&mut frames, |event| { - matches!(event.state, EventState::OperationFinished) - }) - .await; - assert_eq!( - events.last().map(|event| event.operation), - Some(operation), - "the terminal lifecycle event names the detached operation" - ); -} - -#[tokio::test] -async fn a_lagged_subscriber_drops_the_overflow_and_carries_on() { - let hub = Arc::new(ProgressHub::new()); - let response = progress_sse_response(&hub, ShutdownSignal::default()); - let mut frames = response.into_body().into_data_stream(); - - let tree = hub.operation(); - // Overflow the hub's 1024-event broadcast ring before the stream's - // first poll, so its receiver lags: the Lagged arm must drop the - // skipped events and continue rather than ending the stream. - let _leaves: Vec<_> = (0..1100) - .map(|index| tree.register(&format!("leaf-{index}"), 1.0)) - .collect(); - let last = tree.register("last", 1.0); - last.complete(); - - let events = read_until(&mut frames, |event| { - event.path == "last" && matches!(event.state, EventState::Finished { ok: true }) - }) - .await; - assert!( - events.len() <= 1024, - "the overflowed prefix is dropped, not delivered: {} events", - events.len() - ); -} - -#[tokio::test(start_paused = true)] -async fn an_idle_hub_emits_heartbeat_comments_on_cadence() { - let hub = Arc::new(ProgressHub::new()); - let response = progress_sse_response(&hub, ShutdownSignal::default()); - let mut frames = response.into_body().into_data_stream(); - - // No operations are live, so heartbeat comments are the only - // traffic; two ticks pin the cadence, not just the first deadline. - for _ in 0..2 { - let frame = tokio::time::timeout(PROGRESS_HEARTBEAT + FRAME_TIMEOUT, frames.next()) - .await - .expect("the progress stream stalled") - .expect("the progress stream ended early") - .expect("the progress stream errored"); - assert_eq!( - std::str::from_utf8(&frame).expect("SSE frames are UTF-8"), - ": heartbeat\n\n" - ); - } -} - -#[tokio::test(start_paused = true)] -async fn a_tree_drop_reports_completion_then_the_stream_goes_quiet() { - let hub = Arc::new(ProgressHub::new()); - let response = progress_sse_response(&hub, ShutdownSignal::default()); - let mut frames = response.into_body().into_data_stream(); - - let tree = hub.operation(); - let operation = tree.operation(); - let _leaf = tree.register("download", 1.0); - let events = read_events(&mut frames, 1).await; - assert!(matches!(events[0].state, EventState::Begun { .. })); - - drop(tree); - let events = read_events(&mut frames, 1).await; - assert_eq!(events[0].operation, operation); - assert!(matches!(events[0].state, EventState::OperationFinished)); - // The first heartbeat is 15 s out, so nothing may arrive inside this - // window after completion: an idle hub is otherwise silent. - assert!( - tokio::time::timeout(Duration::from_millis(300), frames.next()) - .await - .is_err(), - "a dropped tree must leave the stream quiet until the next heartbeat" - ); -} - -/// The shutdown signal ends the open-ended stream, so an attached -/// subscriber cannot hold its connection through the graceful drain. -#[tokio::test] -async fn the_stream_ends_when_the_shutdown_signal_fires() { - let hub = Arc::new(ProgressHub::new()); - let shutdown = ShutdownSignal::default(); - let response = progress_sse_response(&hub, shutdown.clone()); - let mut frames = response.into_body().into_data_stream(); - - let tree = hub.operation(); - let _leaf = tree.register("download", 1.0); - let events = read_events(&mut frames, 1).await; - assert!(matches!(events[0].state, EventState::Begun { .. })); - - shutdown.fire(); - let end = tokio::time::timeout(FRAME_TIMEOUT, frames.next()) - .await - .expect("the stream reacts to the signal within the frame timeout"); - assert!( - end.is_none(), - "the stream ends on shutdown instead of waiting for the heartbeat: {end:?}" - ); -} diff --git a/crates/gateway/app/src/admin/progress.rs b/crates/gateway/app/src/admin/progress.rs deleted file mode 100644 index 1827e0009..000000000 --- a/crates/gateway/app/src/admin/progress.rs +++ /dev/null @@ -1,148 +0,0 @@ -//! The `GET /admin/progress` route: the process progress hub as an SSE -//! stream with heartbeats. - -use axum::body::Body; -use axum::extract::State; -use axum::http::HeaderValue; -use axum::http::header::{CACHE_CONTROL, CONTENT_TYPE}; -use axum::response::Response; - -use crate::AppState; -use crate::auth::AuthedCaller; -use crate::error::GatewayError; -use crate::shutdown; -use shared_progress::{EventState, ProgressEvent, ProgressHub}; - -/// Heartbeat cadence for the progress stream: SSE comment lines keep an -/// idle connection alive through NAT and firewall timeouts. -pub(crate) const PROGRESS_HEARTBEAT: std::time::Duration = std::time::Duration::from_secs(15); - -/// The `GET /admin/progress` route: bearer-authed, streams the process -/// progress hub as SSE. -/// -/// The reply is `text/event-stream` and never terminates on its own: a -/// freshly connected subscriber first receives the live operations replayed -/// as synthetic `Begun`/`Updated` events, plus a `Finished` for each leaf -/// that already reached its terminal state, so it can render current state -/// without waiting for the next event, and then every broadcast -/// [`ProgressEvent`], including one operation-level terminal event when a -/// tree detaches, with heartbeat comment lines every -/// [`PROGRESS_HEARTBEAT`] while the hub is idle. Intermediate events are -/// lossy - a lagging subscriber drops them - and terminal events are never -/// coalesced at the source. Client disconnect is Drop all the way down, as -/// with the switch stream: the response body owns the receiver. The one -/// server-side end is the process shutdown signal: an attached subscriber -/// (the config SPA, the workshop) would otherwise hold its connection open -/// through the graceful drain and pin the process. -pub(crate) async fn admin_progress( - State(state): State, - _caller: AuthedCaller, -) -> Result { - Ok(progress_sse_response(&state.hub, state.shutdown.clone())) -} - -/// Builds the progress SSE response over `hub`: a snapshot of the live -/// operations first, then the broadcast stream, heartbeats in the gaps, -/// until `shutdown` fires. -pub(crate) fn progress_sse_response( - hub: &ProgressHub, - shutdown: shutdown::ShutdownSignal, -) -> Response { - // Subscribe before snapshotting so no event between the two is lost; a - // `Begun` replayed from the snapshot is idempotent for remote import. - let rx = hub.subscribe(); - let mut pending = std::collections::VecDeque::new(); - for operation in hub.snapshot() { - for node in &operation.nodes { - pending.extend(event_line(&ProgressEvent::new( - operation.operation, - node.path.clone(), - node.label.clone(), - EventState::Begun { - weight: node.weight, - }, - ))); - if node.fraction > 0.0 { - pending.extend(event_line(&ProgressEvent::new( - operation.operation, - node.path.clone(), - node.label.clone(), - EventState::Updated { - fraction: node.fraction, - }, - ))); - } - // A leaf that finished before the subscriber connected replays - // its terminal event too, or the subscriber would hold it as - // unfinished until the tree detaches. - if node.finished { - pending.extend(event_line(&ProgressEvent::new( - operation.operation, - node.path.clone(), - node.label.clone(), - EventState::Finished { ok: node.ok }, - ))); - } - } - } - let heartbeat_at = tokio::time::Instant::now() + PROGRESS_HEARTBEAT; - let stream = futures_util::stream::unfold( - ( - pending, - rx, - tokio::time::interval_at(heartbeat_at, PROGRESS_HEARTBEAT), - shutdown, - ), - |(mut pending, mut rx, mut heartbeat, shutdown)| async move { - if let Some(line) = pending.pop_front() { - return Some(( - Ok::<_, std::convert::Infallible>(line), - (pending, rx, heartbeat, shutdown), - )); - } - loop { - tokio::select! { - () = shutdown.fired() => return None, - _ = heartbeat.tick() => { - return Some((Ok(": heartbeat\n\n".to_owned()), (pending, rx, heartbeat, shutdown))); - } - received = rx.recv() => match received { - Ok(event) => { - if let Some(line) = event_line(&event) { - return Some((Ok(line), (pending, rx, heartbeat, shutdown))); - } - } - Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => { - tracing::debug!(skipped, "progress subscriber lagged; events dropped"); - } - // The hub lives in `AppState` for the process - // lifetime, so its sender never closes first. - Err(tokio::sync::broadcast::error::RecvError::Closed) => return None, - }, - } - } - }, - ); - let mut response = Response::new(Body::from_stream(stream)); - 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 -} - -/// Serializes one event as an SSE `data:` line, or `None` (logged) when -/// serialization fails: the wire types are plain data, so a failure is a -/// schema bug, and one bad event must not kill the stream. -fn event_line(event: &ProgressEvent) -> Option { - match serde_json::to_string(event) { - Ok(json) => Some(format!("data: {json}\n\n")), - Err(error) => { - tracing::warn!(%error, "progress event failed to serialize; dropping it"); - None - } - } -} - -#[cfg(test)] -#[path = "progress-tests.rs"] -mod tests; diff --git a/crates/gateway/app/src/admin/status.rs b/crates/gateway/app/src/admin/status.rs deleted file mode 100644 index e443756d9..000000000 --- a/crates/gateway/app/src/admin/status.rs +++ /dev/null @@ -1,122 +0,0 @@ -//! The `GET /admin/status` readout: profile, models, queue, and one -//! readiness entry per capability endpoint. - -use axum::Json; -use axum::extract::State; - -use crate::AppState; -use crate::auth::AuthedCaller; -use crate::error::GatewayError; -use crate::models::endpoint_status; -#[cfg(feature = "stt")] -use crate::models::with_speech_endpoint; -use gateway_config::ModelKind; - -/// An `Instant` as Unix epoch seconds for the status wire shape. The -/// conversion goes through the elapsed duration, so a clock that jumped -/// backward clamps to now rather than underflowing. -fn instant_epoch_seconds(instant: std::time::Instant) -> u64 { - let elapsed = instant.elapsed(); - std::time::SystemTime::now() - .checked_sub(elapsed) - .unwrap_or_else(std::time::SystemTime::now) - .duration_since(std::time::UNIX_EPOCH) - .map_or(0, |duration| duration.as_secs()) -} - -/// Current profile name, loaded model names, the local models the boot -/// load is still spawning, process config generation, the profile's model -/// allowlist (its local and speech-to-text members), the declared VRAM -/// total, the command queue's active and pending commands, and one -/// readiness entry per capability endpoint the gateway can serve. -pub(crate) async fn admin_status( - State(state): State, - _caller: AuthedCaller, -) -> Result, GatewayError> { - let active = state.commands.active_command(); - let pending = state.commands.pending_commands(); - let live = state.live.read().await; - let models: Vec<&str> = live - .routing - .models() - .iter() - .map(|m| m.name.as_str()) - .collect(); - // A headless build has no local runtime; it reports zero children rather - // than dropping the field from the status response. - #[cfg(feature = "local")] - let local_children = live.local.child_count(); - #[cfg(not(feature = "local"))] - let local_children = 0; - let (_, vram_gb) = live.model_status(); - let command_active = active.is_some(); - let configured = |kind: ModelKind| { - live.config - .models() - .iter() - .any(|model| model.kind() == kind) - || live - .config - .catalog_local_models() - .iter() - .any(|model| model.kind() == kind) - }; - let routed = |kind: ModelKind| live.routing.models().iter().any(|model| model.kind == kind); - let endpoints = [ - ("/v1/chat/completions", "Chat completions", ModelKind::Chat), - ("/v1/embeddings", "Embeddings", ModelKind::Embedding), - ("/v1/rerank", "Rerank", ModelKind::Classifier), - ("/v1/audio/speech", "Speech synthesis", ModelKind::Speech), - ] - .into_iter() - .map(|(path, name, kind)| { - endpoint_status(path, name, configured(kind), routed(kind), command_active) - }) - .collect::>(); - #[cfg(feature = "stt")] - let (endpoints, speech) = - with_speech_endpoint(endpoints, state.speech.status(), command_active); - let response = serde_json::json!({ - "profile": live.profile_name, - "models": models, - "loading_models": live.loading.iter().collect::>(), - "config_generation": state.config_generation.as_ref(), - "model_allowlist": live.model_allowlist, - "local_children": local_children, - "vram_gb": vram_gb, - "queue": { - "active": active.map(|status| serde_json::json!({ - "name": status.name, - "fraction": status.progress, - "started_at": instant_epoch_seconds(status.started_at), - })), - "pending": pending - .iter() - .map(|entry| serde_json::json!({ - "name": entry.name, - "queued_at": instant_epoch_seconds(entry.queued_at), - })) - .collect::>(), - }, - "endpoints": endpoints - .iter() - .map(|endpoint| serde_json::json!({ - "path": endpoint.path, - "name": endpoint.name, - "ready": endpoint.ready, - "provisioning": endpoint.provisioning, - })) - .collect::>(), - }); - #[cfg(feature = "stt")] - let response = { - let mut response = response; - response["speech"] = serde_json::json!(crate::system::SpeechSnapshot::from(speech)); - response - }; - Ok(Json(response)) -} - -#[cfg(test)] -#[path = "status-tests.rs"] -mod tests; diff --git a/crates/gateway/app/src/admin/walled.rs b/crates/gateway/app/src/admin/walled.rs new file mode 100644 index 000000000..6d8507e3b --- /dev/null +++ b/crates/gateway/app/src/admin/walled.rs @@ -0,0 +1,97 @@ +//! The walled admin tier: routes that read secrets in plaintext, write +//! files, or launch processes. `build_router` mounts [`routes`] behind +//! the shared loopback wall from `shared-loopback` in every build, so a +//! non-loopback peer is refused with 403 before bearer auth even runs. +//! `POST /shutdown` kills the process and `GET /auth` mints the key's +//! ambient cookie, so both live here with the config surface they serve. +//! +//! Every bearer-authed handler in this tier extracts +//! [`crate::auth::LoopbackCaller`] rather than `AuthedCaller`: the wall is +//! the enforcement, the extractor is the handler's own statement of the +//! tier it belongs to, and a handler that is ever mounted without the wall +//! still refuses a LAN peer. The two `handoff` routes take no caller at +//! all: `/auth` is how a browser earns its credential, and `/config` only +//! redirects. + +#[cfg(feature = "local")] +pub(crate) mod chat_templates; +pub(crate) mod cloud_models; +pub(crate) mod config; +pub(crate) mod config_apply; +pub(crate) mod config_pending; +pub(crate) mod env_file; +// The browser handoff mints the config SPA's cookie, so the module +// exists only where that surface does; its build-independent auth +// primitives live in `crate::auth::primitives`. +#[cfg(feature = "config-ui")] +pub(crate) mod handoff; +pub(crate) mod hf; +#[cfg(feature = "local")] +pub(crate) mod model_info; +#[cfg(feature = "local")] +pub(crate) mod orphans; +pub(crate) mod reveal; +pub(crate) mod shutdown; +pub(crate) mod system; + +use axum::Router; + +use crate::AppState; +use crate::registry::RouteInfo; + +/// The walled admin routes. The caller applies the loopback wall; this +/// router only assembles the tier, feature-gated areas included, so the +/// URL space in a build with a feature off is exactly the space with the +/// feature on minus that area. +pub(crate) fn routes() -> Router { + let router = Router::new() + .merge(shutdown::routes()) + .merge(system::routes()) + .merge(config::routes()) + .merge(config_pending::routes()) + .merge(config_apply::routes()) + .merge(env_file::routes()) + .merge(cloud_models::routes()) + .merge(reveal::routes()) + .merge(hf::routes()); + // The template, orphan, and model-info routes read local-inference + // facilities, so they exist only in builds with local inference. + #[cfg(feature = "local")] + let router = router + .merge(chat_templates::routes()) + .merge(orphans::routes()) + .merge(model_info::routes()); + // The browser handoff onto the config SPA exists only when the SPA does. + #[cfg(feature = "config-ui")] + let router = router.merge(handoff::routes()); + router +} + +/// The walled admin routes, as the registry sees them, under the same +/// feature gates [`routes`] mounts them. +pub(crate) fn registry() -> Vec { + #[cfg_attr( + not(any(feature = "local", feature = "config-ui")), + expect( + unused_mut, + reason = "nothing extends the list when both gated groups are off" + ) + )] + let mut routes = [ + shutdown::ROUTES, + system::ROUTES, + config::ROUTES, + config_pending::ROUTES, + config_apply::ROUTES, + env_file::ROUTES, + cloud_models::ROUTES, + reveal::ROUTES, + hf::ROUTES, + ] + .concat(); + #[cfg(feature = "local")] + routes.extend([chat_templates::ROUTES, orphans::ROUTES, model_info::ROUTES].concat()); + #[cfg(feature = "config-ui")] + routes.extend_from_slice(handoff::ROUTES); + routes +} diff --git a/crates/gateway/app/src/admin/walled/chat_templates-tests.rs b/crates/gateway/app/src/admin/walled/chat_templates-tests.rs new file mode 100644 index 000000000..8540bd791 --- /dev/null +++ b/crates/gateway/app/src/admin/walled/chat_templates-tests.rs @@ -0,0 +1,104 @@ +use gateway_config::Config; + +use crate::test_support::serve; + +const CONFIG: &str = r#" +config-version = 0 + +[server] +bind = "127.0.0.1:0" +api_key = "template-key" +# Strict bearer auth: the tests below pin that a missing key is refused. +trust_loopback = false + +[[local_model]] +name = "mapped-auto" +kind = "chat" +description = "mapped model" +source = "https://huggingface.co/qwen/qwen3-8b/resolve/main/model.gguf" +sha256 = "0000000000000000000000000000000000000000000000000000000000000000" +context = 4096 + +[[local_model]] +name = "known-broken" +kind = "chat" +description = "known override" +source = "https://huggingface.co/unsloth/gemma-4-e2b-it-GGUF/resolve/main/model.gguf" +sha256 = "1111111111111111111111111111111111111111111111111111111111111111" +context = 4096 + +[[local_model]] +name = "builtin" +kind = "chat" +description = "built in" +source = "models/builtin.gguf" +context = 4096 +chat_template_file = "builtin:phi-4" + +[[local_model]] +name = "custom" +kind = "chat" +description = "custom path" +source = "models/custom.gguf" +context = 4096 +chat_template_file = "templates/custom.jinja" +"#; + +#[tokio::test] +async fn catalog_requires_the_gateway_bearer() { + let config = Config::from_toml_str(CONFIG).expect("config parses"); + let addr = serve(config).await; + + let response = reqwest::Client::new() + .get(format!("http://{addr}/admin/chat-templates")) + .send() + .await + .expect("request sends"); + + assert_eq!(response.status(), reqwest::StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn catalog_serializes_labels_mappings_and_effective_resolutions() { + let config = Config::from_toml_str(CONFIG).expect("config parses"); + let addr = serve(config).await; + + let response = reqwest::Client::new() + .get(format!("http://{addr}/admin/chat-templates")) + .bearer_auth("template-key") + .send() + .await + .expect("request sends"); + + assert_eq!(response.status(), reqwest::StatusCode::OK); + let body: serde_json::Value = response.json().await.expect("body is JSON"); + assert_eq!(body["families"][0]["slug"], "chatml"); + assert_eq!(body["families"][0]["label"], "ChatML"); + assert!( + body["mappings"] + .as_array() + .expect("mappings array") + .iter() + .any(|mapping| { + mapping["model_id"] == "qwen/qwen3-8b" && mapping["family"] == "qwen-3" + }) + ); + let models = body["models"].as_array().expect("models array"); + let named = |name: &str| { + models + .iter() + .find(|model| model["name"] == name) + .expect("named model") + }; + assert_eq!(named("mapped-auto")["effective_source"], "embedded"); + assert_eq!(named("mapped-auto")["detected_family"], "qwen-3"); + assert_eq!(named("known-broken")["effective_source"], "known-override"); + assert!( + named("known-broken")["reason"] + .as_str() + .expect("reason") + .contains("Known-broken") + ); + assert_eq!(named("builtin")["effective_family"], "phi-4"); + assert_eq!(named("custom")["effective_source"], "custom"); +} diff --git a/crates/gateway/app/src/chat_templates.rs b/crates/gateway/app/src/admin/walled/chat_templates.rs similarity index 53% rename from crates/gateway/app/src/chat_templates.rs rename to crates/gateway/app/src/admin/walled/chat_templates.rs index a9077bdca..ca037c60a 100644 --- a/crates/gateway/app/src/chat_templates.rs +++ b/crates/gateway/app/src/admin/walled/chat_templates.rs @@ -1,10 +1,12 @@ -//! Read-only chat-template catalog and effective-resolution admin view. +//! Read-only chat-template catalog and effective-resolution admin view. use std::path::Path; use std::sync::Arc; -use axum::Json; use axum::extract::State; +use axum::http::Method; +use axum::routing::get; +use axum::{Json, Router}; use gateway_config::{Config, LocalModelConfig, ModelKind}; use gateway_local::artifacts::existing_model_path; use gateway_local::chat_templates::{Family, model_family_mappings}; @@ -13,10 +15,21 @@ use gateway_local::{ }; use serde::Serialize; +use super::config_pending::load_pending_for_running; use crate::AppState; -use crate::auth::AuthedCaller; -use crate::config_pending::load_pending_for_running; -use crate::error::GatewayError; +use crate::auth::LoopbackCaller; +use crate::error::{GatewayError, blocking}; +use crate::registry::RouteInfo; + +const CHAT_TEMPLATES: RouteInfo = RouteInfo::walled("/admin/chat-templates", &[Method::GET]); + +/// The chat-template catalog route, as the registry sees it. +pub(crate) const ROUTES: &[RouteInfo] = &[CHAT_TEMPLATES]; + +/// The chat-template catalog route. +pub(crate) fn routes() -> Router { + Router::new().route(CHAT_TEMPLATES.path, get(admin_chat_templates)) +} #[derive(Serialize)] struct FamilyReply { @@ -49,22 +62,21 @@ struct CatalogReply { /// Serves bundled families, exact model mappings, and pending-model decisions. pub(crate) async fn admin_chat_templates( State(state): State, - _caller: AuthedCaller, + _caller: LoopbackCaller, ) -> Result, GatewayError> { let (running, running_profile) = { let live = state.live.read().await; (Arc::clone(&live.config), live.profile_name.clone()) }; let config_path = state.config.as_ref().map(|config| config.path.clone()); - let reply = tokio::task::spawn_blocking(move || { + let reply = blocking(move || { let config = match config_path { Some(path) => load_pending_for_running(&path, running_profile.as_deref())?, None => (*running).clone(), }; serialize_catalog(&config) }) - .await - .map_err(|join| GatewayError::PendingConfig(join.to_string()))??; + .await??; Ok(Json(reply)) } @@ -145,109 +157,5 @@ fn resolution_reply( } #[cfg(test)] -mod tests { - use gateway_config::Config; - - use crate::test_support::serve; - - const CONFIG: &str = r#" -config-version = 0 - -[server] -bind = "127.0.0.1:0" -api_key = "template-key" -# Strict bearer auth: the tests below pin that a missing key is refused. -trust_loopback = false - -[[local_model]] -name = "mapped-auto" -kind = "chat" -description = "mapped model" -source = "https://huggingface.co/qwen/qwen3-8b/resolve/main/model.gguf" -sha256 = "0000000000000000000000000000000000000000000000000000000000000000" -context = 4096 - -[[local_model]] -name = "known-broken" -kind = "chat" -description = "known override" -source = "https://huggingface.co/unsloth/gemma-4-e2b-it-GGUF/resolve/main/model.gguf" -sha256 = "1111111111111111111111111111111111111111111111111111111111111111" -context = 4096 - -[[local_model]] -name = "builtin" -kind = "chat" -description = "built in" -source = "models/builtin.gguf" -context = 4096 -chat_template_file = "builtin:phi-4" - -[[local_model]] -name = "custom" -kind = "chat" -description = "custom path" -source = "models/custom.gguf" -context = 4096 -chat_template_file = "templates/custom.jinja" -"#; - - #[tokio::test] - async fn catalog_requires_the_gateway_bearer() { - let config = Config::from_toml_str(CONFIG).expect("config parses"); - let addr = serve(config).await; - - let response = reqwest::Client::new() - .get(format!("http://{addr}/admin/chat-templates")) - .send() - .await - .expect("request sends"); - - assert_eq!(response.status(), reqwest::StatusCode::UNAUTHORIZED); - } - - #[tokio::test] - async fn catalog_serializes_labels_mappings_and_effective_resolutions() { - let config = Config::from_toml_str(CONFIG).expect("config parses"); - let addr = serve(config).await; - - let response = reqwest::Client::new() - .get(format!("http://{addr}/admin/chat-templates")) - .bearer_auth("template-key") - .send() - .await - .expect("request sends"); - - assert_eq!(response.status(), reqwest::StatusCode::OK); - let body: serde_json::Value = response.json().await.expect("body is JSON"); - assert_eq!(body["families"][0]["slug"], "chatml"); - assert_eq!(body["families"][0]["label"], "ChatML"); - assert!( - body["mappings"] - .as_array() - .expect("mappings array") - .iter() - .any(|mapping| { - mapping["model_id"] == "qwen/qwen3-8b" && mapping["family"] == "qwen-3" - }) - ); - let models = body["models"].as_array().expect("models array"); - let named = |name: &str| { - models - .iter() - .find(|model| model["name"] == name) - .expect("named model") - }; - assert_eq!(named("mapped-auto")["effective_source"], "embedded"); - assert_eq!(named("mapped-auto")["detected_family"], "qwen-3"); - assert_eq!(named("known-broken")["effective_source"], "known-override"); - assert!( - named("known-broken")["reason"] - .as_str() - .expect("reason") - .contains("Known-broken") - ); - assert_eq!(named("builtin")["effective_family"], "phi-4"); - assert_eq!(named("custom")["effective_source"], "custom"); - } -} +#[path = "chat_templates-tests.rs"] +mod tests; diff --git a/crates/gateway/app/src/cloud_models.rs b/crates/gateway/app/src/admin/walled/cloud_models.rs similarity index 91% rename from crates/gateway/app/src/cloud_models.rs rename to crates/gateway/app/src/admin/walled/cloud_models.rs index 2bbf33838..534978345 100644 --- a/crates/gateway/app/src/cloud_models.rs +++ b/crates/gateway/app/src/admin/walled/cloud_models.rs @@ -1,6 +1,6 @@ //! The cloud provider model sheet cache and its admin routes. //! -//! The published provider sheet (the gateway-api [`Sheet`]) is a +//! The published provider sheet (the gateway-api-types [`Sheet`]) is a //! release artifact of the promptforge-cloud-providers repository. At //! launch, after the async boot completes and off the serving path, the //! gateway loads `/cloud-provider-models.json` from disk when @@ -25,26 +25,32 @@ use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; -use axum::Json; use axum::extract::State; +use axum::http::Method; use axum::response::{IntoResponse, Response}; -use gateway_api::{ACCEPTED_SHEET_SCHEMA_VERSION, Sheet}; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use gateway_api_types::{ACCEPTED_SHEET_SCHEMA_VERSION, Sheet}; use gateway_protocol::http_util::{MAX_JSON_BODY, bounded_client, read_bytes_capped}; use time::OffsetDateTime; use crate::AppState; -use crate::auth::AuthedCaller; -use crate::error::GatewayError; +use crate::auth::LoopbackCaller; +use crate::error::{GatewayError, blocking}; +use crate::registry::RouteInfo; -/// The release artifact the sheet downloads from. -pub(crate) const DEFAULT_SHEET_URL: &str = "https://github.com/cppalliance/promptforge-cloud-providers/releases/download/models/cloud-provider-models.json"; +const CLOUD_MODELS: RouteInfo = RouteInfo::walled("/admin/cloud-models", &[Method::GET]); +const REFRESH: RouteInfo = RouteInfo::walled("/admin/cloud-models/refresh", &[Method::POST]); -/// The environment override for the sheet URL, matching the repo's -/// `PROMPTFORGE_*` convention; there is no config-schema knob. -pub(crate) const SHEET_URL_ENV: &str = "PROMPTFORGE_MODELS_SHEET_URL"; +/// The cloud provider model sheet routes, as the registry sees them. +pub(crate) const ROUTES: &[RouteInfo] = &[CLOUD_MODELS, REFRESH]; -/// The cache file name inside the profile directory. -pub(crate) const CACHE_FILE_NAME: &str = "cloud-provider-models.json"; +/// The cloud provider model sheet routes. +pub(crate) fn routes() -> Router { + Router::new() + .route(CLOUD_MODELS.path, get(admin_cloud_models)) + .route(REFRESH.path, post(admin_cloud_models_refresh)) +} /// The cache age past which a launch re-downloads: one week, compared /// against the envelope's `generated_at`, which survives file copies and @@ -199,7 +205,7 @@ impl CloudModels { self.spawn_download().started() } } - /// Force a re-download regardless of cache age and await its + /// Forces a re-download regardless of cache age and awaits its /// outcome: the fresh sheet on success, the download's error on /// failure. A refresh asked during an in-flight download joins it /// and awaits the same result instead of starting a second one. @@ -242,7 +248,7 @@ impl CloudModels { self.lock().last_error.clone() } - /// Spawn the one background download, or report why none started. + /// Spawns the one background download, or reports why none started. fn spawn_download(&self) -> Download { let mut inner = self.lock(); self.spawn_download_locked(&mut inner) @@ -285,7 +291,7 @@ impl CloudModels { } } -/// Fetch the sheet and persist it, returning the sheet only after the +/// Fetches the sheet and persists it, returning the sheet only after the /// cache write lands: the in-memory copy never runs ahead of the disk. /// /// The body read is capped at [`MAX_JSON_BODY`] like every other gateway @@ -336,13 +342,8 @@ async fn download_once(cache_path: &Path, url: &str) -> Result Result CacheRead { match std::fs::read(path) { Ok(bytes) => match serde_json::from_slice::(&bytes) { @@ -366,7 +367,7 @@ fn read_cache(path: &Path) -> CacheRead { } } -/// Write `bytes` to `path` by temp-file-plus-rename, so a crash mid-write +/// Writes `bytes` to `path` by temp-file-plus-rename, so a crash mid-write /// never leaves a truncated cache behind. fn write_cache_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> { if let Some(parent) = path.parent() { @@ -384,7 +385,7 @@ fn write_cache_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> { /// arrived, or the last download error. pub(crate) async fn admin_cloud_models( State(state): State, - _caller: AuthedCaller, + _caller: LoopbackCaller, ) -> Result { if let Some(sheet) = state.cloud_models.sheet() { return Ok(Json(Sheet::clone(&sheet)).into_response()); @@ -402,7 +403,7 @@ pub(crate) async fn admin_cloud_models( /// awaits the same download rather than starting a second one. pub(crate) async fn admin_cloud_models_refresh( State(state): State, - _caller: AuthedCaller, + _caller: LoopbackCaller, ) -> Result, GatewayError> { let sheet = state.cloud_models.refresh().await?; Ok(Json(Sheet::clone(&sheet))) diff --git a/crates/gateway/app/src/cloud_models/tests/refresh.rs b/crates/gateway/app/src/admin/walled/cloud_models/tests-refresh.rs similarity index 99% rename from crates/gateway/app/src/cloud_models/tests/refresh.rs rename to crates/gateway/app/src/admin/walled/cloud_models/tests-refresh.rs index 66285754b..fcc7afd00 100644 --- a/crates/gateway/app/src/cloud_models/tests/refresh.rs +++ b/crates/gateway/app/src/admin/walled/cloud_models/tests-refresh.rs @@ -12,6 +12,7 @@ use axum::http::Method; use time::OffsetDateTime; use super::*; +use crate::boot::CACHE_FILE_NAME; type SequenceStubState = ( Arc, diff --git a/crates/gateway/app/src/cloud_models/tests/version_gate.rs b/crates/gateway/app/src/admin/walled/cloud_models/tests-version-gate.rs similarity index 96% rename from crates/gateway/app/src/cloud_models/tests/version_gate.rs rename to crates/gateway/app/src/admin/walled/cloud_models/tests-version-gate.rs index 0506e1939..18b7c4ddd 100644 --- a/crates/gateway/app/src/cloud_models/tests/version_gate.rs +++ b/crates/gateway/app/src/admin/walled/cloud_models/tests-version-gate.rs @@ -4,10 +4,11 @@ use axum::http::Method; use axum::http::StatusCode; -use gateway_api::ACCEPTED_SHEET_SCHEMA_VERSION; +use gateway_api_types::ACCEPTED_SHEET_SCHEMA_VERSION; use time::OffsetDateTime; use super::*; +use crate::boot::CACHE_FILE_NAME; #[tokio::test] async fn a_version_mismatched_cache_is_treated_as_absent() { diff --git a/crates/gateway/app/src/cloud_models/tests/mod.rs b/crates/gateway/app/src/admin/walled/cloud_models/tests.rs similarity index 98% rename from crates/gateway/app/src/cloud_models/tests/mod.rs rename to crates/gateway/app/src/admin/walled/cloud_models/tests.rs index 5b98d82ce..ccd22cfca 100644 --- a/crates/gateway/app/src/cloud_models/tests/mod.rs +++ b/crates/gateway/app/src/admin/walled/cloud_models/tests.rs @@ -1,3 +1,5 @@ +//! Tests for the cloud model sheet cache, its refresh downloads, and the route that serves it. + use std::collections::BTreeMap; use std::net::SocketAddr; use std::sync::Arc; @@ -7,15 +9,18 @@ use axum::body::Body; use axum::extract::ConnectInfo; use axum::http::header::AUTHORIZATION; use axum::http::{Method, Request, StatusCode}; -use gateway_api::{ModelEntry, ModelKind, ProviderSlice, SliceStatus, Thinking, Tier}; +use gateway_api_types::{ModelEntry, ModelKind, ProviderSlice, SliceStatus, Thinking, Tier}; use gateway_config::Config; use gateway_protocol::http_util::MAX_JSON_BODY; use tokio::sync::Notify; use tower::ServiceExt as _; use super::*; +use crate::boot::CACHE_FILE_NAME; +#[path = "tests-refresh.rs"] mod refresh; +#[path = "tests-version-gate.rs"] mod version_gate; /// A one-provider sheet stamped `generated_at`, carrying one model diff --git a/crates/gateway/app/src/admin/walled/config-tests.rs b/crates/gateway/app/src/admin/walled/config-tests.rs new file mode 100644 index 000000000..86b838b0b --- /dev/null +++ b/crates/gateway/app/src/admin/walled/config-tests.rs @@ -0,0 +1,131 @@ +use gateway_config::{Config, ProfileSelection, profile_state_path, shadow_path}; + +use crate::test_support::{AdminPaths, serve_with_paths}; + +const CONFIG: &str = r#" +config-version = 0 + +[server] +bind = "127.0.0.1:0" +api_key = "test-token" + +[[endpoint]] +id = "fake" +protocol = "openai" +base_url = "http://127.0.0.1:9" +api_key = "" + +[[model]] +name = "alpha-model" +description = "alpha" +context = 1024 +upstream = "alpha" +endpoints = ["fake"] + +[[model]] +name = "beta-model" +description = "beta" +context = 1024 +upstream = "beta" +endpoints = ["fake"] + +[[profile]] +name = "alpha" +models = [] + +[[profile]] +name = "beta" +models = [] +"#; + +fn fixture() -> (tempfile::TempDir, Config, AdminPaths) { + let temp = tempfile::TempDir::new().expect("temp dir"); + let config_path = temp.path().join("gateway.toml"); + std::fs::write(&config_path, CONFIG).expect("write config"); + std::fs::write( + profile_state_path(&config_path), + "active_profile = \"alpha\"\n", + ) + .expect("write state"); + let config = Config::load(&config_path, &ProfileSelection::default()).expect("load config"); + let paths = AdminPaths { + fixture_dir: temp.path().to_path_buf(), + active: "alpha".to_owned(), + config_path, + }; + (temp, config, paths) +} + +/// The live config as a save body: `GET /admin/config` also reports the +/// running `active_profile`, which is not a configuration key and never +/// goes back in a save. +async fn save_body(addr: std::net::SocketAddr) -> serde_json::Value { + let mut body: serde_json::Value = reqwest::Client::new() + .get(format!("http://{addr}/admin/config")) + .bearer_auth("test-token") + .send() + .await + .expect("get sends") + .json() + .await + .expect("config json"); + body.as_object_mut() + .expect("the config is an object") + .remove("active_profile"); + body +} + +async fn put_config(addr: std::net::SocketAddr, body: &serde_json::Value) -> reqwest::Response { + reqwest::Client::new() + .put(format!("http://{addr}/admin/config")) + .bearer_auth("test-token") + .json(body) + .send() + .await + .expect("put sends") +} + +#[tokio::test] +async fn a_save_carrying_active_profile_is_rejected_and_stages_nothing() { + let (_temp, config, paths) = fixture(); + let config_path = paths.config_path.clone(); + let addr = serve_with_paths(config, paths).await; + let mut body = save_body(addr).await; + body["active_profile"] = serde_json::json!("beta"); + + let response = put_config(addr, &body).await; + + assert_eq!(response.status(), reqwest::StatusCode::UNPROCESSABLE_ENTITY); + let error: serde_json::Value = response.json().await.expect("error envelope"); + assert_eq!(error["error"]["code"], "config_write_rejected"); + assert!( + error["error"]["message"].as_str().is_some_and(|message| { + message.contains("active_profile is not a configuration key") + && message.contains("POST /admin/switch-profile") + }), + "the message names the switch route: {error}" + ); + assert!(!shadow_path(&config_path).exists()); + assert!(!shadow_path(&profile_state_path(&config_path)).exists()); +} + +#[tokio::test] +async fn a_save_replies_with_the_config_shadow_alone() { + let (_temp, config, paths) = fixture(); + let config_path = paths.config_path.clone(); + let addr = serve_with_paths(config, paths).await; + let mut body = save_body(addr).await; + body["model"][0]["description"] = serde_json::json!("edited"); + + let response = put_config(addr, &body).await; + + assert_eq!(response.status(), reqwest::StatusCode::OK); + let reply: serde_json::Value = response.json().await.expect("save reply"); + assert_eq!( + reply, + serde_json::json!({ "shadow": shadow_path(&config_path).display().to_string() }), + "the reply carries only the config shadow" + ); + assert!(shadow_path(&config_path).is_file()); + assert!(!shadow_path(&profile_state_path(&config_path)).exists()); +} diff --git a/crates/gateway/app/src/admin/walled/config.rs b/crates/gateway/app/src/admin/walled/config.rs new file mode 100644 index 000000000..b086e5f99 --- /dev/null +++ b/crates/gateway/app/src/admin/walled/config.rs @@ -0,0 +1,153 @@ +//! The `/admin/config` routes: `GET` renders the running global +//! configuration as JSON with secrets redacted; `PUT` stages the pending +//! global TOML document as a shadow file. +//! +//! The write stages the document beside its real file (`gateway.toml` +//! gains `gateway.toml.next`) without touching the real file or reloading +//! the gateway. The body is the config JSON shape `GET /admin/config` +//! returns; secrets arriving as `"***"` preserve the existing value, and +//! the merged pending configuration is validated before any shadow is +//! written, so a bad save leaves nothing behind. The shadow mechanics live +//! in `gateway-config`; these handlers own auth, path resolution, and the +//! JSON-to-TOML boundary. + +use std::path::Path; + +use axum::extract::State; +use axum::http::Method; +use axum::routing::get; +use axum::{Json, Router}; +use gateway_config::save_config_shadow; +use serde::Serialize; + +use crate::AppState; +use crate::auth::LoopbackCaller; +use crate::error::{GatewayError, WireJson, blocking, config_write_error}; +use crate::registry::RouteInfo; + +/// The reply of every shadow-write route: the shadow file the save +/// staged, as a display path. +#[derive(Debug, Serialize)] +pub(crate) struct ShadowReply { + shadow: String, +} + +impl ShadowReply { + /// The reply naming `shadow`. + pub(crate) fn staged(shadow: &Path) -> ShadowReply { + ShadowReply { + shadow: shadow.display().to_string(), + } + } +} + +const CONFIG: RouteInfo = RouteInfo::walled("/admin/config", &[Method::GET, Method::PUT]); + +/// The `/admin/config` routes, as the registry sees them. +pub(crate) const ROUTES: &[RouteInfo] = &[CONFIG]; + +/// The `/admin/config` routes. +pub(crate) fn routes() -> Router { + Router::new().route(CONFIG.path, get(admin_config).put(admin_put_config)) +} + +/// The `GET /admin/config` route: bearer-authed, renders the running global +/// config in the pending admin shape. The running profile is not part of +/// the document (`GET /admin/status` reports it), so the reply round-trips +/// through `PUT /admin/config` unchanged. +pub(crate) async fn admin_config( + State(state): State, + _caller: LoopbackCaller, +) -> Result, GatewayError> { + let config = state.config().await; + Ok(Json(config.to_json())) +} + +/// The `PUT /admin/config` route: bearer-authed, stages the global config. +/// +/// The body is the full `GET /admin/config` JSON shape. Redacted `"***"` +/// secrets are restored from the current pending chain, the merged result +/// is validated like a real load, and only then is the shadow written +/// atomically. The real file stays untouched and nothing reloads. The reply +/// is `{"shadow": path}`. A body carrying `active_profile` is rejected as a +/// config-write error: selection belongs to `POST /admin/switch-profile`. +pub(crate) async fn admin_put_config( + State(state): State, + _caller: LoopbackCaller, + WireJson(body): WireJson, +) -> Result, GatewayError> { + // Saves take the apply lock: apply promotes shadows without + // re-validating, so the combination it promotes must be one the latest + // save validated whole - saves serialize with apply, revert, and each + // other. + let _guard = state.apply.lock().await; + let config = crate::admin::config_path(&state)?.to_path_buf(); + let document = toml_document(body)?; + let shadows = blocking(move || save_config_shadow(&config, document)) + .await? + .map_err(config_write_error)?; + Ok(Json(ShadowReply::staged(&shadows.config))) +} + +/// Converts the request body into the TOML document a shadow save takes. +fn toml_document(body: serde_json::Value) -> Result { + let value = json_to_toml(body)?.ok_or_else(|| { + GatewayError::ConfigWriteRejected("the body must be a JSON object".to_owned()) + })?; + if value.is_table() { + Ok(value) + } else { + Err(GatewayError::ConfigWriteRejected( + "the body must be a JSON object".to_owned(), + )) + } +} + +/// Converts a JSON value into a TOML one. `None` means "absent": TOML has +/// no null, so a null object member simply drops out (the serializer skips +/// absent optionals on the way out, and the deserializer defaults them on +/// the way back in). A null inside an array has no such reading and is an +/// error, as is a number outside TOML's ranges. +fn json_to_toml(value: serde_json::Value) -> Result, GatewayError> { + Ok(Some(match value { + serde_json::Value::Null => return Ok(None), + serde_json::Value::Bool(flag) => toml::Value::Boolean(flag), + serde_json::Value::Number(number) => { + if let Some(integer) = number.as_i64() { + toml::Value::Integer(integer) + } else if let Some(float) = number.as_f64() { + toml::Value::Float(float) + } else { + return Err(GatewayError::ConfigWriteRejected(format!( + "number {number} does not fit a TOML value" + ))); + } + } + serde_json::Value::String(text) => toml::Value::String(text), + serde_json::Value::Array(items) => { + let mut converted = Vec::with_capacity(items.len()); + for item in items { + let Some(element) = json_to_toml(item)? else { + return Err(GatewayError::ConfigWriteRejected( + "null inside an array has no TOML form".to_owned(), + )); + }; + converted.push(element); + } + toml::Value::Array(converted) + } + serde_json::Value::Object(members) => { + let mut table = toml::map::Map::new(); + for (key, member) in members { + if let Some(converted) = json_to_toml(member)? { + table.insert(key, converted); + } + } + toml::Value::Table(table) + } + })) +} + +#[cfg(test)] +#[path = "config-tests.rs"] +mod tests; diff --git a/crates/gateway/app/src/admin/walled/config_apply-tests.rs b/crates/gateway/app/src/admin/walled/config_apply-tests.rs new file mode 100644 index 000000000..fb3f24f7f --- /dev/null +++ b/crates/gateway/app/src/admin/walled/config_apply-tests.rs @@ -0,0 +1,701 @@ +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::Duration; + +use gateway_config::{ + Config, ProfileName, ProfileSelection, profile_state_path, shadow_path, write_shadow, +}; +use tokio_util::sync::CancellationToken; + +use crate::AppState; +use crate::commands::Command; +use crate::commands::apply::{ApplyPlan, capture_apply}; +use crate::error::GatewayError; +use crate::park::{Phase, PhasePark}; +use crate::test_support::{AdminPaths, app_state, serve_state, wait_until}; + +const CONFIG: &str = r#" +config-version = 0 + +[server] +bind = "127.0.0.1:0" +api_key = "test-token" + +[[endpoint]] +id = "fake" +protocol = "openai" +base_url = "http://127.0.0.1:9" +api_key = "" + +[[model]] +name = "alpha-model" +description = "alpha" +context = 1024 +upstream = "alpha" +endpoints = ["fake"] + +[[model]] +name = "beta-model" +description = "beta" +context = 1024 +upstream = "beta" +endpoints = ["fake"] + +[[profile]] +name = "alpha" +models = [] + +[[profile]] +name = "beta" +models = [] +"#; + +/// A third remote model appended to `CONFIG`: the shape of the one +/// change an apply reloads live. +const GAMMA_MODEL: &str = "\n[[model]]\nname = \"gamma-model\"\ndescription = \"gamma\"\n\ + context = 1024\nupstream = \"gamma\"\nendpoints = [\"fake\"]\n"; + +fn fixture() -> (tempfile::TempDir, Config, AdminPaths) { + let temp = tempfile::TempDir::new().expect("temp dir"); + let config_path = temp.path().join("gateway.toml"); + std::fs::write(&config_path, CONFIG).expect("write config"); + std::fs::write( + profile_state_path(&config_path), + "active_profile = \"alpha\"\n", + ) + .expect("write state"); + let config = Config::load(&config_path, &ProfileSelection::default()).expect("load config"); + let paths = AdminPaths { + fixture_dir: temp.path().to_path_buf(), + active: "alpha".to_owned(), + config_path, + }; + (temp, config, paths) +} + +/// Serves the fixture with the production queue worker running, so an +/// apply's `ApplyConfig` command actually drains; the state comes back +/// for tests that read the queue or the live table. +async fn serve_fixture(config: Config, paths: AdminPaths) -> (SocketAddr, AppState) { + let state = app_state(config, Some(paths)); + let _worker = state.commands.spawn_worker(&state).expect("worker spawns"); + let addr = serve_state(state.clone()).await; + (addr, state) +} + +/// [`serve_fixture`] with the apply command parked at its commit, so a +/// test can act between the capture and the promotion. +async fn serve_parked_fixture( + config: Config, + paths: AdminPaths, +) -> (SocketAddr, AppState, Arc) { + let mut state = app_state(config, Some(paths)); + let park = Arc::new(PhasePark::at(Phase::ApplyCommit)); + state.park = Some(Arc::clone(&park)); + let _worker = state.commands.spawn_worker(&state).expect("worker spawns"); + let addr = serve_state(state.clone()).await; + (addr, state, park) +} + +/// Stages `CONFIG` plus `GAMMA_MODEL` as the pending config. +fn stage_gamma(config_path: &std::path::Path) { + write_shadow(config_path, &format!("{CONFIG}{GAMMA_MODEL}")).expect("stage config shadow"); +} + +async fn post(addr: SocketAddr, route: &str) -> reqwest::Response { + reqwest::Client::new() + .post(format!("http://{addr}/{route}")) + .bearer_auth("test-token") + .send() + .await + .expect("post sends") +} + +async fn get_json(addr: SocketAddr, route: &str) -> serde_json::Value { + reqwest::Client::new() + .get(format!("http://{addr}/{route}")) + .bearer_auth("test-token") + .send() + .await + .expect("get sends") + .json() + .await + .expect("json body") +} + +/// Saves the live config with `edit` applied through the real save +/// route, so the shadow is exactly what the UI would write: the running +/// `active_profile` that `GET /admin/config` reports is not a +/// configuration key and never goes back in a save. +async fn save_edited( + addr: SocketAddr, + edit: impl FnOnce(&mut serde_json::Value), +) -> reqwest::Response { + let mut body = get_json(addr, "admin/config").await; + body.as_object_mut() + .expect("the config is an object") + .remove("active_profile"); + edit(&mut body); + reqwest::Client::new() + .put(format!("http://{addr}/admin/config")) + .bearer_auth("test-token") + .json(&body) + .send() + .await + .expect("save sends") +} + +/// The live profile name, as `GET /admin/status` would report it. +async fn live_profile(state: &AppState) -> Option { + state.live.read().await.profile_name.clone() +} + +/// Whether the live routing table resolves `name`. +async fn routes(state: &AppState, name: &str) -> bool { + state.live.read().await.routing.model(name).is_ok() +} + +/// Asserts the apply reply is the cancellation envelope the config UI +/// keys on. +async fn assert_apply_cancelled(response: reqwest::Response) { + assert_eq!(response.status(), reqwest::StatusCode::SERVICE_UNAVAILABLE); + let body: serde_json::Value = response.json().await.expect("error envelope"); + assert_eq!(body["error"]["code"], "apply_cancelled"); + assert_eq!(body["error"]["type"], "server_error"); + assert_eq!( + body["error"]["message"], + GatewayError::ApplyCancelled.to_string() + ); + assert!( + body["error"]["message"] + .as_str() + .is_some_and(|message| message.contains("still staged")), + "the message tells the user their changes survive: {body}" + ); +} + +/// A child-free local runtime holding one model named `name`, standing +/// in for a running `llama-server` the apply must keep routing. +#[cfg(feature = "test-fixtures")] +fn running_local(name: &str) -> crate::local::LocalRuntime { + let config = Config::from_toml_str(&format!( + "config-version = 0\n\ + [server]\nbind = \"127.0.0.1:0\"\napi_key = \"test-token\"\n\ + [[endpoint]]\nid = \"local\"\nprotocol = \"openai\"\n\ + base_url = \"http://127.0.0.1:9\"\napi_key = \"\"\n\ + [[model]]\nname = \"{name}\"\ndescription = \"running child\"\n\ + context = 4096\nupstream = \"{name}\"\nendpoints = [\"local\"]\n" + )) + .expect("local fixture config parses"); + let routing = + crate::routing::Routing::from_config(&config).expect("local fixture routing builds"); + crate::local::LocalRuntime::from_test_models(routing.models().to_vec()) +} + +/// The reload an apply performs: a new `[[model]]` enters the live +/// routing table, the running local child keeps its entry, the shadow +/// promotes, and the reply says so without a restart. +#[cfg(feature = "test-fixtures")] +#[tokio::test] +async fn apply_with_a_new_model_swaps_the_routing_live_and_promotes_the_shadow() { + let (_temp, config, paths) = fixture(); + let config_path = paths.config_path.clone(); + let (addr, state) = serve_fixture(config, paths).await; + { + let mut live = state.live.write().await; + live.local = running_local("alpha-local"); + let routing = Arc::clone(&live.routing); + live.routing = Arc::new( + routing + .as_ref() + .clone() + .merge(live.local.models().iter().cloned()) + .expect("the running child routes"), + ); + } + stage_gamma(&config_path); + + let response = post(addr, "admin/config-apply").await; + + assert_eq!(response.status(), reqwest::StatusCode::OK); + let reply: serde_json::Value = response.json().await.expect("apply body"); + assert_eq!(reply["reloaded"], true); + assert_eq!(reply["restart_required"], false); + assert_eq!(reply["applied"], serde_json::json!(["gateway.toml"])); + assert!(routes(&state, "gamma-model").await, "the new model routes"); + assert!( + routes(&state, "alpha-local").await, + "the running local child keeps its routing entry" + ); + assert_eq!( + state.live.read().await.local.child_count(), + 1, + "the local runtime is untouched" + ); + assert_eq!(live_profile(&state).await.as_deref(), Some("alpha")); + assert!(!shadow_path(&config_path).exists(), "the shadow promoted"); + assert!( + std::fs::read_to_string(&config_path) + .expect("read applied config") + .contains("gamma-model"), + "the real file carries the applied change" + ); + let served = get_json(addr, "admin/config").await; + assert_eq!(served["model"][2]["name"], "gamma-model"); + assert!( + !state.hub.current().busy, + "the settled apply released its activity: {:?}", + state.hub.current() + ); +} + +/// A persisted selection that differs from the running profile (a +/// switch that persisted a new name and awaits a restart) never reaches +/// the live document: the applied config carries no selection, the +/// running profile is unchanged, and `GET /admin/config` does not report +/// the persisted name as the running one. +#[tokio::test] +async fn apply_publishes_the_document_without_the_persisted_selection() { + let (_temp, config, paths) = fixture(); + let config_path = paths.config_path.clone(); + let (addr, state) = serve_fixture(config, paths).await; + std::fs::write( + profile_state_path(&config_path), + "active_profile = \"beta\"\n", + ) + .expect("persist a selection awaiting restart"); + stage_gamma(&config_path); + + let response = post(addr, "admin/config-apply").await; + + assert_eq!(response.status(), reqwest::StatusCode::OK); + assert!(routes(&state, "gamma-model").await); + assert!( + state.live.read().await.config.active_profile().is_none(), + "the live document carries no selection" + ); + assert_eq!(live_profile(&state).await.as_deref(), Some("alpha")); + let served = get_json(addr, "admin/config").await; + assert!( + served.get("active_profile").is_none(), + "the persisted name is not reported as running: {served}" + ); +} + +/// Each boot-read section flags a restart; the live-reloadable ones do +/// not. Every case stages a valid config differing from the real one in +/// exactly that section. +#[test] +fn capture_apply_flags_restart_for_boot_read_sections_only() { + let cases: [(&str, &str, bool); 7] = [ + ( + "profile", + "\n[[profile]]\nname = \"gamma\"\nmodels = []\n", + true, + ), + ( + "local_model", + "\n[[local_model]]\nname = \"gamma\"\ndescription = \"g\"\n\ + source = \"/models/gamma.gguf\"\ncontext = 4096\n", + true, + ), + ( + "stt_model", + "\n[[stt_model]]\nname = \"speech\"\nrole = \"interim\"\n\ + source = \"/speech.bin\"\nvram_gb = 1.0\n", + true, + ), + ( + "stt", + "\n[stt]\nwindow_seconds = 8\ninterval_ms = 250\n", + true, + ), + ("model", GAMMA_MODEL, false), + ( + "endpoint", + "\n[[endpoint]]\nid = \"other\"\nprotocol = \"openai\"\n\ + base_url = \"http://127.0.0.1:10\"\napi_key = \"\"\n", + false, + ), + ( + "tools", + "\n[tools.web_search]\nprovider = \"brave\"\napi_key = \"k\"\n", + false, + ), + ]; + for (section, addition, expected) in cases { + let temp = tempfile::TempDir::new().expect("temp dir"); + let config_path = temp.path().join("gateway.toml"); + std::fs::write(&config_path, CONFIG).expect("write config"); + write_shadow(&config_path, &format!("{CONFIG}{addition}")).expect("stage shadow"); + + let plan = capture_apply(&config_path).expect("the pending config captures"); + + let ApplyPlan::Reload(snapshot) = plan else { + panic!("a config shadow always reloads: {section}"); + }; + assert_eq!( + snapshot.restart_required, expected, + "restart_required for a {section} change" + ); + assert_eq!(snapshot.applied_names(), ["gateway.toml"]); + } +} + +#[tokio::test] +async fn invalid_pending_config_is_never_promoted() { + let (_temp, config, paths) = fixture(); + let config_path = paths.config_path.clone(); + let original_config = std::fs::read_to_string(&config_path).expect("read config"); + write_shadow(&config_path, "not valid TOML [[[").expect("stage tampered shadow"); + let (addr, state) = serve_fixture(config, paths).await; + + let response = post(addr, "admin/config-apply").await; + + assert_eq!( + response.status(), + reqwest::StatusCode::INTERNAL_SERVER_ERROR + ); + assert_eq!( + std::fs::read_to_string(&config_path).expect("re-read config"), + original_config + ); + assert!( + shadow_path(&config_path).is_file(), + "the rejected shadow remains available for correction or revert" + ); + assert!( + !state.hub.current().busy, + "the parse failure replies before any command exists" + ); + assert!(state.commands.active_command().is_none()); + assert!(state.commands.pending_commands().is_empty()); +} + +#[tokio::test] +async fn env_only_apply_requires_restart_without_a_command() { + let (_temp, config, paths) = fixture(); + let env_path = paths.config_path.with_extension("env"); + write_shadow(&env_path, "HF_TOKEN=pending\n").expect("stage env shadow"); + let (addr, state) = serve_fixture(config, paths).await; + + let response = post(addr, "admin/config-apply").await; + + assert_eq!(response.status(), reqwest::StatusCode::OK); + let reply: serde_json::Value = response.json().await.expect("apply body"); + assert_eq!(reply["applied"], serde_json::json!(["gateway.env"])); + assert_eq!(reply["reloaded"], false); + assert_eq!(reply["restart_required"], true); + assert_eq!( + std::fs::read_to_string(&env_path).expect("read promoted env"), + "HF_TOKEN=pending\n" + ); + assert!( + !shadow_path(&env_path).exists(), + "the promoted shadow is retired" + ); + assert!( + !state.hub.current().busy && state.commands.active_command().is_none(), + "the no-reload path promotes inline without a command" + ); +} + +#[tokio::test] +async fn server_key_change_waits_for_restart() { + let (_temp, config, paths) = fixture(); + let (addr, _state) = serve_fixture(config, paths).await; + let http = reqwest::Client::new(); + let save = save_edited(addr, |body| { + body["server"]["api_key"] = serde_json::json!("next-token"); + }) + .await; + assert_eq!(save.status(), reqwest::StatusCode::OK); + + let response = post(addr, "admin/config-apply").await; + + assert_eq!(response.status(), reqwest::StatusCode::OK); + let reply: serde_json::Value = response.json().await.expect("apply body"); + assert_eq!(reply["restart_required"], true); + assert_eq!( + http.get(format!("http://{addr}/v1/models")) + .bearer_auth("test-token") + .send() + .await + .expect("old token request sends") + .status(), + reqwest::StatusCode::OK + ); + assert_eq!( + http.get(format!("http://{addr}/v1/models")) + .bearer_auth("next-token") + .send() + .await + .expect("new token request sends") + .status(), + reqwest::StatusCode::UNAUTHORIZED + ); +} + +/// The speech pipeline is configured once at boot: an `[stt]` change +/// promotes and reloads the document but reports a restart. +#[tokio::test] +async fn stt_pipeline_change_promotes_and_requires_restart() { + let (_temp, config, paths) = fixture(); + write_shadow( + &paths.config_path, + &format!( + "{CONFIG}\n[stt]\nwindow_seconds = 8\ninterval_ms = 250\n\ + vocabulary = [\"WG21\"]\n" + ), + ) + .expect("stage STT-only shadow"); + let (addr, _state) = serve_fixture(config, paths).await; + let dirty = get_json(addr, "admin/config-dirty").await; + assert_eq!(dirty["changed_sections"], serde_json::json!(["stt"])); + + let response = post(addr, "admin/config-apply").await; + + assert_eq!(response.status(), reqwest::StatusCode::OK); + let reply: serde_json::Value = response.json().await.expect("apply body"); + assert_eq!(reply["reloaded"], true); + assert_eq!(reply["restart_required"], true); + let applied = get_json(addr, "admin/config").await; + assert_eq!(applied["stt"]["window_seconds"], 8); + assert_eq!(applied["stt"]["interval_ms"], 250); + assert_eq!(applied["stt"]["vocabulary"], serde_json::json!(["WG21"])); +} + +#[tokio::test] +async fn revert_removes_all_shadows_without_touching_real_files() { + let (_temp, config, paths) = fixture(); + let config_path = paths.config_path.clone(); + let env_path = config_path.with_extension("env"); + let original_config = std::fs::read_to_string(&config_path).expect("read config"); + stage_gamma(&config_path); + write_shadow(&env_path, "HF_TOKEN=pending\n").expect("stage env"); + let (addr, _state) = serve_fixture(config, paths).await; + + let response = post(addr, "admin/config-revert").await; + + assert_eq!(response.status(), reqwest::StatusCode::OK); + let reply: serde_json::Value = response.json().await.expect("revert body"); + assert_eq!( + reply["reverted"], + serde_json::json!(["gateway.env.next", "gateway.toml.next"]) + ); + assert_eq!( + std::fs::read_to_string(&config_path).expect("re-read config"), + original_config + ); + assert!(!env_path.exists()); +} + +/// Two applies in flight at once share one command: the second attaches +/// to the first through the debounce, both replies carry the same +/// `applied` list, and the reload runs exactly once. +#[tokio::test] +async fn concurrent_applies_promote_the_pending_config_once() { + let (_temp, config, paths) = fixture(); + let config_path = paths.config_path.clone(); + let (addr, state, park) = serve_parked_fixture(config, paths).await; + stage_gamma(&config_path); + + let first = tokio::spawn(post(addr, "admin/config-apply")); + park.entered().await; + assert_eq!( + state.hub.current().text, + "Applying configuration", + "the parked apply holds the command's activity with its stage text" + ); + let second = tokio::spawn(post(addr, "admin/config-apply")); + wait_until("the second apply to attach to the first", || { + state.commands.active_waiters() == 2 + }) + .await; + assert!( + state.commands.pending_commands().is_empty(), + "the second apply attached to the active one instead of queueing" + ); + park.release(); + + let first = first.await.expect("first apply task"); + let second = second.await.expect("second apply task"); + assert_eq!(first.status(), reqwest::StatusCode::OK); + assert_eq!(second.status(), reqwest::StatusCode::OK); + let first: serde_json::Value = first.json().await.expect("first body"); + let second: serde_json::Value = second.json().await.expect("second body"); + let expected = serde_json::json!(["gateway.toml"]); + assert_eq!(first["applied"], expected); + assert_eq!( + second["applied"], expected, + "both replies report the shared outcome" + ); + assert_eq!(first["reloaded"], true); + assert_eq!(second["reloaded"], true); + assert!( + !state.hub.current().busy, + "the one command settled and released its activity" + ); + assert!(!shadow_path(&config_path).exists()); + assert!(routes(&state, "gamma-model").await); +} + +/// An apply enqueued after the boot `LoadProfile` never displaces it: +/// the boot load settles on its own terms over the production worker, +/// then the apply runs over the table it published and completes. The +/// queue's FIFO rule under an active boot load is pinned in +/// `commands.rs`. +#[tokio::test] +async fn an_apply_after_the_boot_load_reloads_over_the_published_table() { + let (_temp, config, paths) = fixture(); + let config_path = paths.config_path.clone(); + let (addr, state) = serve_fixture(config, paths).await; + stage_gamma(&config_path); + + let boot = state.commands.enqueue(Command::load_profile( + ProfileName::parse("alpha").expect("profile name"), + CancellationToken::new(), + )); + wait_until("the boot load to settle", || { + state.commands.active_command().is_none() + }) + .await; + let outcome = tokio::time::timeout(Duration::from_secs(10), boot.outcome) + .await + .expect("the boot load settles") + .expect("the boot load settles with an outcome"); + assert!(outcome.is_ok(), "a remote-only profile loads: {outcome:?}"); + + let response = post(addr, "admin/config-apply").await; + assert_eq!(response.status(), reqwest::StatusCode::OK); + let reply: serde_json::Value = response.json().await.expect("apply body"); + assert_eq!(reply["reloaded"], true); + assert_eq!(reply["applied"], serde_json::json!(["gateway.toml"])); + assert!(!shadow_path(&config_path).exists()); + assert!(routes(&state, "gamma-model").await); + assert_eq!(live_profile(&state).await.as_deref(), Some("alpha")); +} + +/// A cancelled apply promotes nothing: the shadow stays on disk with its +/// contents, the dirty report is unchanged, the reply is the +/// cancellation envelope, and a retry applies the same change. +#[tokio::test] +async fn a_cancelled_apply_leaves_every_shadow_staged_and_a_retry_succeeds() { + let (_temp, config, paths) = fixture(); + let config_path = paths.config_path.clone(); + let original_config = std::fs::read_to_string(&config_path).expect("read config"); + let (addr, state, park) = serve_parked_fixture(config, paths).await; + stage_gamma(&config_path); + let staged = std::fs::read_to_string(shadow_path(&config_path)).expect("staged shadow"); + let dirty_before = get_json(addr, "admin/config-dirty").await; + assert_eq!(dirty_before["dirty"], true); + + let apply = tokio::spawn(post(addr, "admin/config-apply")); + park.entered().await; + assert!(state.commands.cancel_active()); + park.release(); + + assert_apply_cancelled(apply.await.expect("apply task")).await; + assert_eq!( + std::fs::read_to_string(shadow_path(&config_path)).expect("config shadow"), + staged, + "the config shadow is still staged" + ); + assert_eq!( + std::fs::read_to_string(&config_path).expect("re-read config"), + original_config, + "nothing was promoted" + ); + assert_eq!( + get_json(addr, "admin/config-dirty").await, + dirty_before, + "the dirty report is unchanged" + ); + assert!(!routes(&state, "gamma-model").await, "nothing went live"); + + // The retry parks at the same phase; a stored release lets it through. + park.release(); + let retry = post(addr, "admin/config-apply").await; + assert_eq!(retry.status(), reqwest::StatusCode::OK); + let reply: serde_json::Value = retry.json().await.expect("retry body"); + assert_eq!(reply["reloaded"], true); + assert_eq!(reply["applied"], serde_json::json!(["gateway.toml"])); + assert!(!shadow_path(&config_path).exists()); + assert!(routes(&state, "gamma-model").await); +} + +/// A save that lands mid-apply neither blocks nor is lost: the snapshot's +/// contents land in the real file, and the newer shadow stays pending as +/// the next change. +#[tokio::test] +async fn a_save_landing_mid_apply_stays_pending_while_the_snapshot_lands() { + let (_temp, config, paths) = fixture(); + let config_path = paths.config_path.clone(); + let (addr, state, park) = serve_parked_fixture(config, paths).await; + stage_gamma(&config_path); + + let apply = tokio::spawn(post(addr, "admin/config-apply")); + park.entered().await; + let save = tokio::time::timeout( + Duration::from_secs(10), + save_edited(addr, |body| { + body["model"][0]["description"] = serde_json::json!("edited mid-apply"); + }), + ) + .await + .expect("the save completes while the apply is active"); + assert_eq!(save.status(), reqwest::StatusCode::OK); + park.release(); + + let response = apply.await.expect("apply task"); + assert_eq!(response.status(), reqwest::StatusCode::OK); + let reply: serde_json::Value = response.json().await.expect("apply body"); + assert_eq!(reply["applied"], serde_json::json!(["gateway.toml"])); + let real = std::fs::read_to_string(&config_path).expect("read config"); + assert!( + real.contains("gamma-model") && !real.contains("edited mid-apply"), + "the snapshot's contents landed in the real file" + ); + let pending = std::fs::read_to_string(shadow_path(&config_path)).expect("config shadow"); + assert!( + pending.contains("edited mid-apply"), + "the newer save stays pending instead of being deleted" + ); + assert!(routes(&state, "gamma-model").await); + let dirty = get_json(addr, "admin/config-dirty").await; + assert_eq!(dirty["pending_files"], serde_json::json!(["gateway.toml"])); +} + +/// A revert during an active apply wins: the apply settles as cancelled, +/// its commit writes nothing, and the shadow is gone. +#[tokio::test] +async fn a_revert_during_an_active_apply_cancels_it_and_the_commit_writes_nothing() { + let (_temp, config, paths) = fixture(); + let config_path = paths.config_path.clone(); + let original_config = std::fs::read_to_string(&config_path).expect("read config"); + let (addr, state, park) = serve_parked_fixture(config, paths).await; + stage_gamma(&config_path); + + let apply = tokio::spawn(post(addr, "admin/config-apply")); + park.entered().await; + + let revert = post(addr, "admin/config-revert").await; + assert_eq!(revert.status(), reqwest::StatusCode::OK); + let reply: serde_json::Value = revert.json().await.expect("revert body"); + assert_eq!(reply["reverted"], serde_json::json!(["gateway.toml.next"])); + park.release(); + + assert_apply_cancelled(apply.await.expect("apply task")).await; + assert_eq!( + std::fs::read_to_string(&config_path).expect("re-read config"), + original_config, + "the cancelled apply's commit wrote nothing" + ); + assert!(!shadow_path(&config_path).exists()); + assert!(!routes(&state, "gamma-model").await); + assert_eq!(live_profile(&state).await.as_deref(), Some("alpha")); + wait_until("the queue to go idle", || { + state.commands.active_command().is_none() + }) + .await; +} diff --git a/crates/gateway/app/src/admin/walled/config_apply.rs b/crates/gateway/app/src/admin/walled/config_apply.rs new file mode 100644 index 000000000..63d8e3099 --- /dev/null +++ b/crates/gateway/app/src/admin/walled/config_apply.rs @@ -0,0 +1,192 @@ +//! Apply and revert routes for pending config shadows: +//! `POST /admin/config-apply` and `POST /admin/config-revert`. +//! +//! Apply captures the pending state under the apply lock - a census of the +//! shadows, the parsed shadow-preferred config, and every shadow's current +//! contents - then releases the lock. A change that needs no reload (an env +//! shadow alone) is promoted inline. A config shadow runs as an +//! `ApplyConfig` command on the command queue: the command rebuilds the +//! remote routing table from the pending config, merges the running local +//! models under it, promotes the captured shadows under the apply lock, and +//! swaps the live routing, config, and web-search state in one write. A +//! failed or cancelled apply promotes nothing and leaves every shadow staged +//! for a retry. Sections the process reads once at boot (`[server]`, +//! `[workshop]`, `[[profile]]`, `[[local_model]]`, `[[stt_model]]`, `[stt]`) +//! and env shadows promote the same way but report `restart_required`: the +//! local runtime is fixed for the process lifetime. Revert cancels any apply +//! in flight, then deletes every shadow and touches nothing else. Saves, the +//! capture step, the commit, and revert serialize on one mutex, so apply only +//! captures combinations the latest save validated whole. Both routes reply +//! with plain JSON; the reload's `"Applying configuration"` text reaches +//! `GET /admin/progress` subscribers through the command's activity. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use axum::extract::State; +use axum::http::Method; +use axum::routing::post; +use axum::{Json, Router}; +use gateway_config::shadow_path; +use serde::Serialize; +use tokio_util::sync::CancellationToken; + +use crate::AppState; +use crate::auth::LoopbackCaller; +use crate::commands::Command; +use crate::commands::apply::{ApplyPlan, capture_apply, promote_captures}; +use crate::config_shadow::{config_root, relative_name, shadow_census}; +use crate::error::error_chain; +use crate::error::{GatewayError, blocking}; +use crate::registry::RouteInfo; + +const APPLY: RouteInfo = RouteInfo::walled("/admin/config-apply", &[Method::POST]); +const REVERT: RouteInfo = RouteInfo::walled("/admin/config-revert", &[Method::POST]); + +/// The apply and revert routes, as the registry sees them. +pub(crate) const ROUTES: &[RouteInfo] = &[APPLY, REVERT]; + +/// The apply and revert routes. +pub(crate) fn routes() -> Router { + Router::new() + .route(APPLY.path, post(admin_config_apply)) + .route(REVERT.path, post(admin_config_revert)) +} + +/// The `POST /admin/config-apply` reply. +#[derive(Debug, Serialize)] +pub(crate) struct ApplyReply { + /// The promoted real files, relative to the config root, sorted. + applied: Vec, + /// Whether a config shadow applied and the remote routing reloaded. + reloaded: bool, + /// Whether a promoted change takes effect only at the next start. + restart_required: bool, +} + +/// The `POST /admin/config-revert` reply. +#[derive(Debug, Serialize)] +pub(crate) struct RevertReply { + /// The deleted shadow files, relative to the config root. + reverted: Vec, +} + +/// The `POST /admin/config-apply` route: bearer-authed, applies every +/// staged shadow, reloading the remote routing table when the change needs +/// it. +/// +/// The reply is plain JSON - `{"applied": [...], "reloaded": bool, +/// "restart_required": bool}` - not SSE: the reload runs as a command on +/// the queue, so its `"Applying configuration"` text reaches +/// `GET /admin/progress` subscribers, and the response carries the outcome. +/// `applied` names the promoted real files relative to the config root, +/// sorted. `reloaded` is true when a config shadow applied successfully. +/// `restart_required` is true for an env shadow or a change to a section +/// the process reads once at boot: `[server]`, `[workshop]`, `[[profile]]`, +/// `[[local_model]]`, `[[stt_model]]`, or `[stt]`. With no shadows on disk +/// the reply is the clean no-op +/// `{"applied": [], "reloaded": false, "restart_required": false}`. +/// +/// Nothing is promoted before the command commits. A parse failure replies +/// 500 before any command exists; a reload failure replies +/// [`GatewayError::ApplyReloadFailed`] (500) and a cancelled command - +/// the user's cancel, a revert, or shutdown - replies +/// [`GatewayError::ApplyCancelled`] (503). In both cases every shadow is +/// still staged, so a retry of Apply re-runs the whole thing. +pub(crate) async fn admin_config_apply( + State(state): State, + _caller: LoopbackCaller, +) -> Result, GatewayError> { + let config_path = crate::admin::config_path(&state)?.to_path_buf(); + let (enqueued, applied, restart_required) = { + // The lock spans the census, the parse, and the capture (or the + // inline promotion), so a save cannot land between them and the + // snapshot is one the latest save validated whole. It is released + // before the command runs: the queue serializes the reload itself. + let _guard = state.apply.lock().await; + let plan = blocking(move || capture_apply(&config_path)).await??; + let snapshot = match plan { + ApplyPlan::Inline { + files, + restart_required, + } => { + let applied = blocking(move || promote_captures(&files)).await??; + return Ok(Json(ApplyReply { + applied, + reloaded: false, + restart_required, + })); + } + ApplyPlan::Reload(snapshot) => snapshot, + }; + let applied = snapshot.applied_names(); + let restart_required = snapshot.restart_required; + let enqueued = state.commands.enqueue(Command::ApplyConfig { + snapshot, + token: CancellationToken::new(), + }); + (enqueued, applied, restart_required) + }; + let outcome = enqueued.outcome.await.unwrap_or_else(|_| { + // The worker settles every command it begins, so a dropped sender + // means the worker task itself died. + Arc::new(Err(GatewayError::switch_failed( + "queue", + std::io::Error::other("the command queue dropped the command without settling it"), + ))) + }); + match &*outcome { + Ok(_) => Ok(Json(ApplyReply { + applied, + reloaded: true, + restart_required, + })), + Err(GatewayError::CommandCancelled(_)) => Err(GatewayError::ApplyCancelled), + Err(error) => Err(GatewayError::ApplyReloadFailed(error_chain(error))), + } +} + +/// The `POST /admin/config-revert` route: bearer-authed, cancels any apply +/// in flight, deletes every shadow file, and touches nothing else. +/// +/// The reply is `{"reverted": [...]}` naming the deleted shadow files +/// relative to the config root, sorted. The real files were never touched +/// by a save, so nothing is rewritten: deleting the shadows is the whole +/// revert. An apply cancelled here settles its route with +/// [`GatewayError::ApplyCancelled`]. +pub(crate) async fn admin_config_revert( + State(state): State, + _caller: LoopbackCaller, +) -> Result, GatewayError> { + // A revert issued during an apply wins: cancel the apply before its + // commit can write the snapshot over the files being reverted. The + // commit re-checks the token under the apply lock, so an apply already + // waiting for that lock still stops. + state.commands.cancel_apply(); + // The same guard as apply's capture and commit: a revert must not race + // either. + let _guard = state.apply.lock().await; + let config_path = crate::admin::config_path(&state)?.to_path_buf(); + let reverted = blocking(move || delete_all_shadows(&config_path)).await??; + Ok(Json(RevertReply { reverted })) +} + +/// Deletes every shadow the census finds, returning the deleted shadow +/// files relative to the config root, sorted. +fn delete_all_shadows(config_path: &Path) -> Result, GatewayError> { + let census = shadow_census(config_path)?; + let root = config_root(config_path); + let mut reverted: Vec = Vec::with_capacity(census.files.len()); + for file in &census.files { + let shadow: PathBuf = shadow_path(file); + std::fs::remove_file(&shadow) + .map_err(|source| GatewayError::ConfigWriteIo(Box::new(source)))?; + reverted.push(relative_name(&shadow, root)); + } + reverted.sort_unstable(); + Ok(reverted) +} + +#[cfg(test)] +#[path = "config_apply-tests.rs"] +mod tests; diff --git a/crates/gateway/app/src/admin/walled/config_pending-tests.rs b/crates/gateway/app/src/admin/walled/config_pending-tests.rs new file mode 100644 index 000000000..55bc5baca --- /dev/null +++ b/crates/gateway/app/src/admin/walled/config_pending-tests.rs @@ -0,0 +1,128 @@ +use gateway_config::{Config, ProfileSelection, profile_state_path, shadow_path, write_shadow}; + +use super::*; +use crate::test_support::{AdminPaths, serve_with_paths}; + +const PROFILE_CONFIG: &str = r#" +config-version = 0 + +[server] +bind = "127.0.0.1:0" +api_key = "test-token" + +[[profile]] +name = "alpha" +models = [] + +[[profile]] +name = "beta" +models = [] +"#; + +#[test] +fn dirty_reply_lists_the_config_shadow_and_never_active_profile() { + let temp = tempfile::TempDir::new().expect("temp dir"); + let config = temp.path().join("gateway.toml"); + std::fs::write( + &config, + "config-version = 0\n[server]\nbind = \"127.0.0.1:0\"\napi_key = \"k\"\n", + ) + .expect("write config"); + let state = profile_state_path(&config); + std::fs::write(&state, "active_profile = \"alpha\"\n").expect("write state"); + write_shadow( + &config, + "config-version = 0\n[server]\nbind = \"127.0.0.1:0\"\napi_key = \"changed\"\n", + ) + .expect("write config shadow"); + // A leftover from before selection stopped staging: never reported. + write_shadow(&state, "active_profile = \"beta\"\n").expect("write stale state shadow"); + + let reply = dirty_reply(&config).expect("dirty reply"); + + assert!(reply.dirty); + assert_eq!(reply.pending_files, ["gateway.toml"]); + assert_eq!(reply.changed_sections, ["server"]); + assert!(shadow_path(&config).is_file()); +} + +/// A state file that exists but cannot be read is a server fault whose +/// message names the file, so the 500 body stands on its own. +#[test] +fn an_unreadable_state_file_names_itself_in_the_error() { + let temp = tempfile::TempDir::new().expect("temp dir"); + let config = temp.path().join("gateway.toml"); + let state = profile_state_path(&config); + // A directory in the file's place reads as an error that is not + // `NotFound` on every platform. + std::fs::create_dir(&state).expect("occupy the state path"); + + let error = persisted_selection(&config).expect_err("a directory is not a state file"); + + let GatewayError::PendingConfig(message) = error else { + panic!("a read failure is a pending-config fault: {error:?}"); + }; + assert!( + message.contains(&state.display().to_string()), + "the message names the file: {message}" + ); +} + +/// Serves `PROFILE_CONFIG` from `temp` with `running` as the live +/// profile (a command-line override), leaving the state file to the test. +async fn serve_profiles(temp: &tempfile::TempDir, running: &str) -> std::net::SocketAddr { + let config_path = temp.path().join("gateway.toml"); + std::fs::write(&config_path, PROFILE_CONFIG).expect("write config"); + let config = Config::load(&config_path, &ProfileSelection::new(Some(running), None)) + .expect("load command-line override"); + serve_with_paths( + config, + AdminPaths { + fixture_dir: temp.path().to_path_buf(), + active: running.to_owned(), + config_path, + }, + ) + .await +} + +async fn pending_active_profile(addr: std::net::SocketAddr) -> serde_json::Value { + let response = reqwest::Client::new() + .get(format!("http://{addr}/admin/config-pending")) + .bearer_auth("test-token") + .send() + .await + .expect("pending request sends"); + assert_eq!(response.status(), reqwest::StatusCode::OK); + let mut body: serde_json::Value = response.json().await.expect("pending body is JSON"); + assert!(body["boot"].is_null(), "the envelope keeps its shape"); + body["profile"]["active_profile"].take() +} + +#[tokio::test] +async fn pending_view_reports_null_when_no_selection_is_persisted() { + let temp = tempfile::TempDir::new().expect("temp dir"); + let addr = serve_profiles(&temp, "beta").await; + + assert!( + pending_active_profile(addr).await.is_null(), + "a running command-line override is not a persisted selection" + ); +} + +#[tokio::test] +async fn pending_view_reports_the_state_files_name_even_when_stale() { + let temp = tempfile::TempDir::new().expect("temp dir"); + let addr = serve_profiles(&temp, "beta").await; + let state = profile_state_path(&temp.path().join("gateway.toml")); + + std::fs::write(&state, "active_profile = \"alpha\"\n").expect("write state"); + assert_eq!(pending_active_profile(addr).await, "alpha"); + + std::fs::write(&state, "active_profile = \"retired\"\n").expect("write stale state"); + assert_eq!( + pending_active_profile(addr).await, + "retired", + "the raw persisted name is reported even when the config no longer defines it" + ); +} diff --git a/crates/gateway/app/src/admin/walled/config_pending.rs b/crates/gateway/app/src/admin/walled/config_pending.rs new file mode 100644 index 000000000..ab83e4c7e --- /dev/null +++ b/crates/gateway/app/src/admin/walled/config_pending.rs @@ -0,0 +1,174 @@ +//! Pending-state read routes: `GET /admin/config-pending` and +//! `GET /admin/config-dirty`. +//! +//! The write route (`config.rs`) stages the global config as a +//! `.next` shadow beside the real file; these routes read that pending +//! state back. Profile selection is never staged: `config-pending` reports +//! the selection the real `gateway.state.toml` persists, which may differ +//! from the running profile until the next start. +//! `config-dirty` is the cheap poll: whether any shadow exists, which +//! real files carry one, and which top-level sections the pending view +//! changes. The resolution machinery lives in +//! `gateway-config`; these handlers own auth, path assembly, +//! and the wire shape. + +use std::path::Path; + +use axum::extract::State; +use axum::http::Method; +use axum::routing::get; +use axum::{Json, Router}; +use gateway_config::{ + Config, ProfileSelection, ProfileState, load_pending_config, profile_state_path, +}; +use serde::Serialize; + +use crate::AppState; +use crate::auth::LoopbackCaller; +use crate::config_shadow::{config_root, relative_name, shadow_census}; +use crate::error::{GatewayError, blocking, pending_read_error}; +use crate::registry::RouteInfo; + +/// The `GET /admin/config-pending` reply. +#[derive(Debug, Serialize)] +pub(crate) struct PendingReply { + /// The shadow-preferred global config document plus `active_profile`, + /// the persisted selection. + profile: serde_json::Value, + /// Always `null`: the boot side has no pending view of its own. + boot: Option, +} + +/// The `GET /admin/config-dirty` reply. +#[derive(Debug, Serialize)] +pub(crate) struct DirtyReply { + /// Whether any shadow exists. + dirty: bool, + /// The real files whose shadows exist, relative to the config root, + /// sorted. + pending_files: Vec, + /// The top-level sections the config shadow changes. + changed_sections: Vec, +} + +const PENDING: RouteInfo = RouteInfo::walled("/admin/config-pending", &[Method::GET]); +const DIRTY: RouteInfo = RouteInfo::walled("/admin/config-dirty", &[Method::GET]); + +/// The pending-state read routes, as the registry sees them. +pub(crate) const ROUTES: &[RouteInfo] = &[PENDING, DIRTY]; + +/// The pending-state read routes. +pub(crate) fn routes() -> Router { + Router::new() + .route(PENDING.path, get(admin_config_pending)) + .route(DIRTY.path, get(admin_config_dirty)) +} + +/// The `GET /admin/config-pending` route: bearer-authed, renders the +/// shadow-preferred global config and the persisted profile selection. +/// +/// The reply keeps the existing `{"profile": ..., "boot": null}` envelope +/// for the current UI. `profile` contains the shadow-preferred global config +/// plus `active_profile`, read from the real `gateway.state.toml`: `null` +/// when no selection is persisted, otherwise the raw persisted name, even +/// when the config no longer defines it, so the UI can show a selection +/// that differs from the running profile or has gone stale. Secrets remain +/// redacted. +pub(crate) async fn admin_config_pending( + State(state): State, + _caller: LoopbackCaller, +) -> Result, GatewayError> { + let _publication = state.apply.lock().await; + let config_path = crate::admin::config_path(&state)?.to_path_buf(); + let running_profile = state.profile_name().await; + let reply = blocking(move || { + let config = load_pending_for_running(&config_path, running_profile.as_deref())?; + let mut profile = config.to_json(); + if let Some(table) = profile.as_object_mut() { + table.insert( + "active_profile".to_owned(), + persisted_selection(&config_path)?.map_or(serde_json::Value::Null, |name| { + serde_json::Value::String(name) + }), + ); + } + Ok::<_, GatewayError>(PendingReply { + profile, + boot: None, + }) + }) + .await??; + Ok(Json(reply)) +} + +/// Loads the shadow-preferred config under the running selection, which +/// may have come from a command-line or environment override and +/// therefore differ from persisted state. +pub(crate) fn load_pending_for_running( + config_path: &Path, + running_profile: Option<&str>, +) -> Result { + load_pending_config(config_path, &ProfileSelection::new(running_profile, None)) + .map_err(|error| pending_read_error(&error)) +} + +/// The profile name the real state file persists, `None` when the file is +/// absent. The name is not checked against any config: a stale selection +/// is still the persisted one. +fn persisted_selection(config_path: &Path) -> Result, GatewayError> { + let path = profile_state_path(config_path); + let raw = match std::fs::read_to_string(&path) { + Ok(raw) => raw, + Err(source) if source.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(source) => { + return Err(GatewayError::PendingConfig(format!( + "read profile state {}: {source}", + path.display() + ))); + } + }; + let state = ProfileState::from_toml_str(&raw).map_err(|error| pending_read_error(&error))?; + Ok(Some(state.active_profile().to_owned())) +} + +/// The `GET /admin/config-dirty` route: bearer-authed, reports the +/// pending state from shadow existence and comparison. +/// +/// The reply is `{"dirty", "pending_files", "changed_sections"}`. `dirty` +/// is true when any shadow exists. `pending_files` names the real files +/// whose shadows are present - the global config and the env sibling - +/// rendered relative to the config directory with forward slashes, sorted. +/// `.env` shadows count toward `dirty` and `pending_files` only. Profile +/// selection is never pending, so neither list ever names the state file +/// or `active_profile`. +pub(crate) async fn admin_config_dirty( + State(state): State, + _caller: LoopbackCaller, +) -> Result, GatewayError> { + let _publication = state.apply.lock().await; + let config_path = crate::admin::config_path(&state)?.to_path_buf(); + let reply = blocking(move || dirty_reply(&config_path)).await??; + Ok(Json(reply)) +} + +/// Assembles the `GET /admin/config-dirty` body: the shadowed config file +/// plus the `.env` sibling, and the config shadow's section diff. +fn dirty_reply(config_path: &Path) -> Result { + let census = shadow_census(config_path)?; + let root = config_root(config_path); + let mut pending_files: Vec = census + .files + .iter() + .map(|file| relative_name(file, root)) + .collect(); + pending_files.sort_unstable(); + Ok(DirtyReply { + dirty: !pending_files.is_empty(), + pending_files, + changed_sections: census.sections, + }) +} + +#[cfg(test)] +#[path = "config_pending-tests.rs"] +mod tests; diff --git a/crates/gateway/app/src/admin/walled/env_file-tests.rs b/crates/gateway/app/src/admin/walled/env_file-tests.rs new file mode 100644 index 000000000..0c12874ab --- /dev/null +++ b/crates/gateway/app/src/admin/walled/env_file-tests.rs @@ -0,0 +1,82 @@ +use gateway_config::{Config, ProfileSelection, profile_state_path, shadow_path}; + +use super::render_value; +use crate::test_support::{AdminPaths, serve_with_paths}; + +const CONFIG: &str = r#" +config-version = 0 + +[server] +bind = "127.0.0.1:0" +api_key = "test-token" + +[[profile]] +name = "main" +models = [] +"#; + +fn fixture() -> (tempfile::TempDir, Config, AdminPaths) { + let temp = tempfile::TempDir::new().expect("temp dir"); + let config_path = temp.path().join("gateway.toml"); + std::fs::write(&config_path, CONFIG).expect("write config"); + std::fs::write( + profile_state_path(&config_path), + "active_profile = \"main\"\n", + ) + .expect("write state"); + std::fs::write(config_path.with_extension("env"), "GLOBAL=one\n").expect("write env"); + let config = Config::load(&config_path, &ProfileSelection::default()).expect("load config"); + let paths = AdminPaths { + fixture_dir: temp.path().to_path_buf(), + active: "main".to_owned(), + config_path, + }; + (temp, config, paths) +} + +#[tokio::test] +async fn env_routes_expose_only_the_single_global_file() { + let (_temp, config, paths) = fixture(); + let env_path = paths.config_path.with_extension("env"); + let addr = serve_with_paths(config, paths).await; + let http = reqwest::Client::new(); + + let get: serde_json::Value = http + .get(format!("http://{addr}/admin/env")) + .bearer_auth("test-token") + .send() + .await + .expect("get sends") + .json() + .await + .expect("env json"); + assert_eq!(get["boot"]["vars"], serde_json::json!({"GLOBAL": "one"})); + assert!(get["profile"].is_null()); + + let put = http + .put(format!("http://{addr}/admin/env?scope=global")) + .bearer_auth("test-token") + .json(&serde_json::json!({"GLOBAL": "two"})) + .send() + .await + .expect("put sends"); + assert_eq!(put.status(), reqwest::StatusCode::OK); + assert!(shadow_path(&env_path).is_file()); + + let profile = http + .put(format!("http://{addr}/admin/env?scope=profile")) + .bearer_auth("test-token") + .json(&serde_json::json!({})) + .send() + .await + .expect("profile put sends"); + assert_eq!(profile.status(), reqwest::StatusCode::UNPROCESSABLE_ENTITY); +} + +#[test] +fn render_value_round_trips_supported_forms() { + assert_eq!(render_value("abc-123"), Some("abc-123".to_owned())); + assert_eq!(render_value("two words"), Some("'two words'".to_owned())); + assert_eq!(render_value("it's"), Some("\"it's\"".to_owned())); + assert_eq!(render_value("a\nb"), None); +} diff --git a/crates/gateway/app/src/env_file.rs b/crates/gateway/app/src/admin/walled/env_file.rs similarity index 55% rename from crates/gateway/app/src/env_file.rs rename to crates/gateway/app/src/admin/walled/env_file.rs index 73da0f890..6f8fcaa55 100644 --- a/crates/gateway/app/src/env_file.rs +++ b/crates/gateway/app/src/admin/walled/env_file.rs @@ -1,4 +1,4 @@ -//! The `GET /admin/env` and `PUT /admin/env` routes: read the single `.env` +//! The `GET /admin/env` and `PUT /admin/env` routes: read the single `.env` //! file and stage edits as an `.env.next` shadow. //! //! The gateway loads only the config sibling (`gateway.env`) at boot. `GET` @@ -12,15 +12,48 @@ use std::collections::BTreeMap; use std::fmt::Write as _; use std::path::Path; -use axum::Json; -use axum::extract::rejection::{JsonRejection, QueryRejection}; -use axum::extract::{Query, State}; +use axum::extract::State; +use axum::http::Method; +use axum::routing::get; +use axum::{Json, Router}; use gateway_config::{pending_var_references, write_shadow}; +use serde::Serialize; +use super::config::ShadowReply; use crate::AppState; -use crate::auth::AuthedCaller; -use crate::config_write::config_write_error; -use crate::error::GatewayError; +use crate::auth::LoopbackCaller; +use crate::error::{GatewayError, WireJson, WireQuery, blocking, config_write_error}; +use crate::registry::RouteInfo; + +/// The `GET /admin/env` reply. +#[derive(Debug, Serialize)] +pub(crate) struct EnvReply { + /// The config-sibling `.env` file the gateway boots with. + boot: Option, + /// Always `null`: profiles carry no env file of their own. + profile: Option, + /// Each `${VAR}` name the pending config references, mapped to labels + /// of the referencing fields. + references: BTreeMap>, +} + +/// One side of the `GET /admin/env` reply: an env file's path and its +/// parsed variables. +#[derive(Debug, Serialize)] +pub(crate) struct EnvSection { + path: String, + vars: serde_json::Map, +} + +const ENV: RouteInfo = RouteInfo::walled("/admin/env", &[Method::GET, Method::PUT]); + +/// The `/admin/env` routes, as the registry sees them. +pub(crate) const ROUTES: &[RouteInfo] = &[ENV]; + +/// The `/admin/env` routes. +pub(crate) fn routes() -> Router { + Router::new().route(ENV.path, get(admin_get_env).put(admin_put_env)) +} /// The `GET /admin/env` route: bearer-authed, parses the global `.env` file. /// @@ -34,24 +67,23 @@ use crate::error::GatewayError; /// is an empty `vars` map. pub(crate) async fn admin_get_env( State(state): State, - _caller: AuthedCaller, -) -> Result, GatewayError> { + _caller: LoopbackCaller, +) -> Result, GatewayError> { let config = crate::admin::config_path(&state)?.to_path_buf(); let env = config.with_extension("env"); - let reply = tokio::task::spawn_blocking(move || { + let reply = blocking(move || { // The reference scan parses the pending document without validating // or interpolating it, so a failure means an unreadable or // unparsable config file - surfaced, never hidden. let references = pending_var_references(&config) .map_err(|error| GatewayError::EnvFile(Box::new(error)))?; - Ok::<_, GatewayError>(serde_json::json!({ - "boot": env_section(Some(&env))?, - "profile": null, - "references": references, - })) + Ok::<_, GatewayError>(EnvReply { + boot: Some(env_section(&env)?), + profile: None, + references, + }) }) - .await - .map_err(|join| GatewayError::EnvFile(Box::new(join)))??; + .await??; Ok(Json(reply)) } @@ -72,16 +104,10 @@ pub(crate) struct EnvPutQuery { /// The real `.env` file is never touched. pub(crate) async fn admin_put_env( State(state): State, - _caller: AuthedCaller, - scope: Result, QueryRejection>, - vars: Result>, JsonRejection>, -) -> Result, GatewayError> { - // Deferring the extractors keeps auth first and puts rejections in - // the gateway's JSON error envelope instead of axum's plain-text 400. - let Query(scope) = - scope.map_err(|rejection| GatewayError::MalformedRequest(rejection.body_text()))?; - let Json(vars) = - vars.map_err(|rejection| GatewayError::MalformedRequest(rejection.body_text()))?; + _caller: LoopbackCaller, + WireQuery(scope): WireQuery, + WireJson(vars): WireJson>, +) -> Result, GatewayError> { // Saves take the apply lock; see `admin_put_config` for the why. let _guard = state.apply.lock().await; let env = match scope.scope.as_deref() { @@ -93,25 +119,19 @@ pub(crate) async fn admin_put_env( } }; let contents = render_env(&vars)?; - let shadow = tokio::task::spawn_blocking(move || write_shadow(&env, &contents)) - .await - .map_err(|join| GatewayError::ConfigWriteIo(Box::new(join)))? + let shadow = blocking(move || write_shadow(&env, &contents)) + .await? .map_err(config_write_error)?; - Ok(Json( - serde_json::json!({ "shadow": shadow.display().to_string() }), - )) + Ok(Json(ShadowReply::staged(&shadow))) } /// One side of the `GET /admin/env` reply: the file's path and its parsed -/// variables, or `null` when that side is not configured. -fn env_section(path: Option<&Path>) -> Result { - let Some(path) = path else { - return Ok(serde_json::Value::Null); - }; - Ok(serde_json::json!({ - "path": path.display().to_string(), - "vars": parse_env(path)?, - })) +/// variables. +fn env_section(path: &Path) -> Result { + Ok(EnvSection { + path: path.display().to_string(), + vars: parse_env(path)?, + }) } /// Parses one `.env` file into a map, without touching the process @@ -188,87 +208,5 @@ fn bare_safe(c: char) -> bool { } #[cfg(test)] -mod tests { - use gateway_config::{Config, ProfileSelection, profile_state_path, shadow_path}; - - use super::render_value; - use crate::test_support::{AdminPaths, serve_with_paths}; - - const CONFIG: &str = r#" -config-version = 0 - -[server] -bind = "127.0.0.1:0" -api_key = "test-token" - -[[profile]] -name = "main" -models = [] -"#; - - fn fixture() -> (tempfile::TempDir, Config, AdminPaths) { - let temp = tempfile::TempDir::new().expect("temp dir"); - let config_path = temp.path().join("gateway.toml"); - std::fs::write(&config_path, CONFIG).expect("write config"); - std::fs::write( - profile_state_path(&config_path), - "active_profile = \"main\"\n", - ) - .expect("write state"); - std::fs::write(config_path.with_extension("env"), "GLOBAL=one\n").expect("write env"); - let config = Config::load(&config_path, &ProfileSelection::default()).expect("load config"); - let paths = AdminPaths { - fixture_dir: temp.path().to_path_buf(), - active: "main".to_owned(), - config_path, - }; - (temp, config, paths) - } - - #[tokio::test] - async fn env_routes_expose_only_the_single_global_file() { - let (_temp, config, paths) = fixture(); - let env_path = paths.config_path.with_extension("env"); - let addr = serve_with_paths(config, paths).await; - let http = reqwest::Client::new(); - - let get: serde_json::Value = http - .get(format!("http://{addr}/admin/env")) - .bearer_auth("test-token") - .send() - .await - .expect("get sends") - .json() - .await - .expect("env json"); - assert_eq!(get["boot"]["vars"], serde_json::json!({"GLOBAL": "one"})); - assert!(get["profile"].is_null()); - - let put = http - .put(format!("http://{addr}/admin/env?scope=global")) - .bearer_auth("test-token") - .json(&serde_json::json!({"GLOBAL": "two"})) - .send() - .await - .expect("put sends"); - assert_eq!(put.status(), reqwest::StatusCode::OK); - assert!(shadow_path(&env_path).is_file()); - - let profile = http - .put(format!("http://{addr}/admin/env?scope=profile")) - .bearer_auth("test-token") - .json(&serde_json::json!({})) - .send() - .await - .expect("profile put sends"); - assert_eq!(profile.status(), reqwest::StatusCode::UNPROCESSABLE_ENTITY); - } - - #[test] - fn render_value_round_trips_supported_forms() { - assert_eq!(render_value("abc-123"), Some("abc-123".to_owned())); - assert_eq!(render_value("two words"), Some("'two words'".to_owned())); - assert_eq!(render_value("it's"), Some("\"it's\"".to_owned())); - assert_eq!(render_value("a\nb"), None); - } -} +#[path = "env_file-tests.rs"] +mod tests; diff --git a/crates/gateway/app/src/admin/walled/handoff-tests.rs b/crates/gateway/app/src/admin/walled/handoff-tests.rs new file mode 100644 index 000000000..45b8faa3f --- /dev/null +++ b/crates/gateway/app/src/admin/walled/handoff-tests.rs @@ -0,0 +1,133 @@ +// The compound cfg hides the module from clippy's test detection, so +// the test-code expect/unwrap allowance is restated explicitly. +#![expect( + clippy::expect_used, + reason = "the shared test fixture fails with the invariant named" +)] + +use std::net::SocketAddr; + +use axum::body::Body; +use axum::extract::ConnectInfo; +use axum::http::header::{CACHE_CONTROL, LOCATION, SET_COOKIE}; +use axum::http::{Request, Response, StatusCode}; +use gateway_config::Config; +use tower::ServiceExt; + +use super::{hex_encode, session_token}; +use crate::auth::primitives::hex_decode; +use crate::test_support::app_state; +use crate::{AppState, build_router}; + +fn state() -> AppState { + let config = Config::from_toml_str( + "config-version = 0\n\ + [server]\nbind = \"127.0.0.1:0\"\napi_key = \"test-token\"\n", + ) + .expect("config parses"); + app_state(config, None) +} + +/// Sends one `GET /auth...` through the router with a loopback peer +/// planted, as the walled route requires. +async fn get_auth(state: &AppState, uri: &str) -> Response { + let mut request = Request::builder() + .uri(uri) + .body(Body::empty()) + .expect("request builds"); + let peer: SocketAddr = "127.0.0.1:50000".parse().expect("a socket address"); + request.extensions_mut().insert(ConnectInfo(peer)); + build_router(state.clone(), None) + .oneshot(request) + .await + .expect("the router is infallible") +} + +#[test] +fn hex_decode_round_trips_through_the_encoder() { + // The decoder ships in every build and the encoder only with the + // config surface, so the pair is pinned here, beside the encoder. + let key = b"an arbitrary key/with+odd=chars"; + assert_eq!( + hex_decode(&hex_encode(key)).as_deref(), + Some(key.as_slice()) + ); +} + +#[tokio::test] +async fn a_wrong_or_missing_key_is_rejected_with_401() { + let state = state(); + for uri in ["/auth?key=wrong", "/auth", "/auth?key="] { + let response = get_auth(&state, uri).await; + assert_eq!(response.status(), StatusCode::UNAUTHORIZED, "{uri}"); + } +} + +#[tokio::test] +async fn the_right_key_sets_the_cookie_and_redirects_key_free() { + let state = state(); + let response = get_auth(&state, "/auth?key=test-token").await; + assert_eq!(response.status(), StatusCode::FOUND); + assert_eq!( + response.headers().get(LOCATION).expect("a Location header"), + "/config/", + "the redirect target carries no key" + ); + let cookie = response + .headers() + .get(SET_COOKIE) + .expect("a Set-Cookie header") + .to_str() + .expect("the cookie is header-safe"); + assert!( + cookie.starts_with("promptforge-gateway-session="), + "the handoff cookie: {cookie}" + ); + let value = cookie + .split(';') + .next() + .and_then(|pair| pair.split_once('=')) + .map(|(_, value)| value) + .expect("the cookie carries a value"); + assert_eq!( + value, + hex_encode(&session_token(&state.handoff_salt, b"test-token")), + "the cookie carries the session proof, never the key: {cookie}" + ); + assert!( + cookie.contains("HttpOnly"), + "the cookie is HttpOnly: {cookie}" + ); + assert!( + cookie.contains("SameSite=Lax"), + "the cookie is SameSite=Lax: {cookie}" + ); + assert!( + !cookie.contains("test-token"), + "the cookie never carries the raw key: {cookie}" + ); + assert_eq!( + response + .headers() + .get(CACHE_CONTROL) + .expect("a Cache-Control header"), + "no-store", + "the handoff response is never cached" + ); +} + +#[tokio::test] +async fn the_route_refuses_a_lan_peer_even_with_the_key() { + let state = state(); + let mut request = Request::builder() + .uri("/auth?key=test-token") + .body(Body::empty()) + .expect("request builds"); + let peer: SocketAddr = "198.51.100.7:44821".parse().expect("a socket address"); + request.extensions_mut().insert(ConnectInfo(peer)); + let response = build_router(state, None) + .oneshot(request) + .await + .expect("the router is infallible"); + assert_eq!(response.status(), StatusCode::FORBIDDEN); +} diff --git a/crates/gateway/app/src/admin/walled/handoff.rs b/crates/gateway/app/src/admin/walled/handoff.rs new file mode 100644 index 000000000..425df3a82 --- /dev/null +++ b/crates/gateway/app/src/admin/walled/handoff.rs @@ -0,0 +1,110 @@ +//! The browser handoff: `GET /auth?key=` validates the bearer key, sets a +//! session proof as an HttpOnly cookie, and redirects to the key-free +//! config UI URL, so the tray and shell can open the config surface in a +//! browser without leaving the bearer key in the browser's history. +//! +//! The whole module ships with the config surface it fronts. The cookie +//! it mints outlives that gate - [`crate::auth::check_auth`] accepts it in +//! every build as an alternative to the `Authorization` header - so the +//! cookie's name, its session proof, and the Fetch Metadata rules that +//! admit it live in [`crate::auth::primitives`], and only the minting +//! routes are here. +//! +//! `SameSite=Lax` keeps the cookie off cross-site requests, and the +//! loopback host wall keeps a rebound hostname from reaching the surface +//! at all. The cookie never carries the key itself; the proof is ambient, +//! so [`crate::auth::check_auth`] accepts it only with Fetch Metadata a +//! cross-origin page cannot strip: `SameSite=Lax` does not cover same-site +//! requests, since ports are not part of a site. + +use axum::Router; +use axum::extract::{Query, State}; +use axum::http::Method; +use axum::http::StatusCode; +use axum::http::header::{CACHE_CONTROL, LOCATION, SET_COOKIE}; +use axum::response::{IntoResponse, Redirect, Response}; +use axum::routing::get; + +use crate::AppState; +use crate::auth::primitives::{AUTH_COOKIE, session_token}; +use crate::error::GatewayError; +use crate::registry::RouteInfo; + +const CONFIG_REDIRECT: RouteInfo = RouteInfo::walled("/config", &[Method::GET]); +const AUTH: RouteInfo = RouteInfo::walled("/auth", &[Method::GET]); + +/// The browser-entry routes, as the registry sees them. +pub(crate) const ROUTES: &[RouteInfo] = &[CONFIG_REDIRECT, AUTH]; + +/// The browser entry onto the config SPA: the `/auth` handoff and the +/// `/config` redirect onto the SPA mount. Neither takes an auth extractor: +/// the handoff is how the browser earns its credential, and the redirect +/// carries nothing but a location. Both sit in the walled tier because +/// they exist only for the surface the wall protects. +pub(crate) fn routes() -> Router { + Router::new() + .route(CONFIG_REDIRECT.path, get(config_ui_redirect)) + .route(AUTH.path, get(auth_handoff)) +} + +/// Redirects `GET /config` to `/config/`, where the SPA index is served +/// and its relative asset references resolve. +async fn config_ui_redirect() -> Redirect { + Redirect::permanent("/config/") +} + +/// Hex-encodes bytes for the cookie value: cookie-safe by construction. +fn hex_encode(bytes: &[u8]) -> String { + use std::fmt::Write as _; + let mut out = String::with_capacity(bytes.len() * 2); + for byte in bytes { + let _ = write!(out, "{byte:02x}"); + } + out +} + +/// The `GET /auth?key=` query: the presented bearer key. +#[derive(Debug, serde::Deserialize)] +pub(crate) struct AuthQuery { + key: Option, +} + +/// The `GET /auth` browser-handoff route, walled loopback-only like the +/// config surface it fronts. +/// +/// A wrong or missing key answers `401 Unauthorized` indistinguishably. +/// The right key answers `302 Found` to `/config/` - a clean URL carrying +/// no key - with the key's session proof set as an HttpOnly, +/// `SameSite=Lax` session cookie and `Cache-Control: no-store` so the +/// handoff response itself is never reused from cache. +pub(crate) async fn auth_handoff( + State(state): State, + Query(query): Query, +) -> Result { + let live = state.live.read().await; + let presented = query.key.unwrap_or_default(); + if !crate::auth::secret_eq(presented.as_bytes(), live.key.expose().as_bytes()) { + return Err(GatewayError::Unauthorized); + } + let cookie = format!( + "{AUTH_COOKIE}={}; HttpOnly; SameSite=Lax; Path=/", + hex_encode(&session_token( + &state.handoff_salt, + live.key.expose().as_bytes() + )) + ); + drop(live); + Ok(( + StatusCode::FOUND, + [ + (LOCATION, String::from("/config/")), + (SET_COOKIE, cookie), + (CACHE_CONTROL, String::from("no-store")), + ], + ) + .into_response()) +} + +#[cfg(all(test, feature = "config-ui"))] +#[path = "handoff-tests.rs"] +mod tests; diff --git a/crates/gateway/app/src/admin/walled/hf-tests.rs b/crates/gateway/app/src/admin/walled/hf-tests.rs new file mode 100644 index 000000000..e96c00fc1 --- /dev/null +++ b/crates/gateway/app/src/admin/walled/hf-tests.rs @@ -0,0 +1,472 @@ +use std::net::SocketAddr; +use std::sync::{Arc, Mutex}; + +use axum::http::header::{AUTHORIZATION, CONTENT_TYPE}; +use axum::http::{HeaderMap, StatusCode, Uri}; +use gateway_config::{Config, Secret}; + +use super::HfProxy; +use crate::test_support::serve_with_hf; + +/// A minimal profile: the hub proxy needs nothing beyond `[server]`. +fn hf_config() -> Config { + Config::from_toml_str( + r#" +config-version = 0 + +[server] +bind = "127.0.0.1:0" +api_key = "test-token" +# Strict bearer auth: the tests below pin that a missing key is refused. +trust_loopback = false +"#, + ) + .expect("the fixture profile parses") +} + +/// One request the stub hub observed. +#[derive(Debug, Clone)] +struct Seen { + path: String, + query: String, + authorization: Option, +} + +/// Spawns a stub hub answering every request with `status` and `body`, +/// recording each request it sees. +async fn spawn_stub(status: StatusCode, body: &'static str) -> (String, Arc>>) { + let seen = Arc::new(Mutex::new(Vec::new())); + let recorded = Arc::clone(&seen); + let app = axum::Router::new().fallback(move |uri: Uri, headers: HeaderMap| { + let recorded = Arc::clone(&recorded); + async move { + recorded.lock().expect("the stub log lock").push(Seen { + path: uri.path().to_owned(), + query: uri.query().unwrap_or("").to_owned(), + authorization: headers + .get(AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned), + }); + (status, [(CONTENT_TYPE, "application/json")], body) + } + }); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("the stub listener binds"); + let addr = listener.local_addr().expect("the stub bound address"); + tokio::spawn(async move { + let _ignored = axum::serve(listener, app).await; + }); + (format!("http://{addr}"), seen) +} + +/// Serves the gateway with its hub proxy aimed at a fresh stub. +async fn serve_against_stub( + status: StatusCode, + body: &'static str, + token: Option<&str>, +) -> (SocketAddr, Arc>>) { + let (base_url, seen) = spawn_stub(status, body).await; + let proxy = HfProxy::new(base_url, token.map(|token| Secret::new(token.to_owned()))); + let addr = serve_with_hf(hf_config(), proxy).await; + (addr, seen) +} + +/// GETs `path` on the gateway with the given bearer token. +async fn get(addr: SocketAddr, path: &str, token: &str) -> reqwest::Response { + reqwest::Client::new() + .get(format!("http://{addr}{path}")) + .bearer_auth(token) + .send() + .await + .expect("the request sends") +} + +#[tokio::test] +async fn admin_hf_search_forwards_params_and_body() { + let stub_body = r#"[{"id":"unsloth/Qwen3-8B-GGUF","downloads":123}]"#; + let (addr, seen) = serve_against_stub(StatusCode::OK, stub_body, None).await; + + let response = get( + addr, + "/admin/hf/search?q=qwen&filter=gguf&pipeline_tag=text-generation\ + &sort=downloads&direction=-1&limit=30&full=true", + "test-token", + ) + .await; + assert_eq!(response.status(), reqwest::StatusCode::OK); + assert_eq!(response.text().await.expect("a body"), stub_body); + + let seen = seen.lock().expect("the stub log lock"); + let [request] = seen.as_slice() else { + panic!("expected exactly one upstream request, saw {seen:?}"); + }; + assert_eq!(request.path, "/api/models"); + for pair in [ + "search=qwen", + "filter=gguf", + "pipeline_tag=text-generation", + "sort=downloads", + "direction=-1", + "limit=30", + "full=true", + ] { + assert!( + request.query.contains(pair), + "`{pair}` missing from forwarded query `{}`", + request.query + ); + } +} + +#[tokio::test] +async fn admin_hf_model_targets_the_owner_name_path() { + let stub_body = + r#"{"id":"unsloth/Qwen3-8B-GGUF","siblings":[{"rfilename":"q4.gguf","size":4900000000}]}"#; + let (addr, seen) = serve_against_stub(StatusCode::OK, stub_body, None).await; + + let response = get(addr, "/admin/hf/model/unsloth/Qwen3-8B-GGUF", "test-token").await; + assert_eq!(response.status(), reqwest::StatusCode::OK); + assert_eq!(response.text().await.expect("a body"), stub_body); + + let seen = seen.lock().expect("the stub log lock"); + let [request] = seen.as_slice() else { + panic!("expected exactly one upstream request, saw {seen:?}"); + }; + assert_eq!(request.path, "/api/models/unsloth/Qwen3-8B-GGUF"); + assert!( + request.query.contains("blobs=true"), + "`blobs=true` missing from `{}`: the quant picker needs sibling sizes", + request.query + ); +} + +#[tokio::test] +async fn admin_hf_sends_the_token_only_when_configured() { + let (with_token, seen_with) = serve_against_stub(StatusCode::OK, "[]", Some("hf_secret")).await; + let response = get(with_token, "/admin/hf/search?q=x", "test-token").await; + assert_eq!(response.status(), reqwest::StatusCode::OK); + assert_eq!( + seen_with.lock().expect("the stub log lock")[0] + .authorization + .as_deref(), + Some("Bearer hf_secret"), + "a configured HF_TOKEN must reach the hub" + ); + + let (without_token, seen_without) = serve_against_stub(StatusCode::OK, "[]", None).await; + let response = get(without_token, "/admin/hf/search?q=x", "test-token").await; + assert_eq!(response.status(), reqwest::StatusCode::OK); + assert_eq!( + seen_without.lock().expect("the stub log lock")[0].authorization, + None, + "an anonymous proxy must not invent an Authorization header" + ); +} + +#[tokio::test] +async fn admin_hf_forwards_upstream_client_errors() { + for (upstream, expected) in [ + (StatusCode::UNAUTHORIZED, reqwest::StatusCode::UNAUTHORIZED), + (StatusCode::NOT_FOUND, reqwest::StatusCode::NOT_FOUND), + ] { + let (addr, _seen) = serve_against_stub(upstream, r#"{"error":"denied"}"#, None).await; + let response = get(addr, "/admin/hf/model/owner/name", "test-token").await; + assert_eq!( + response.status(), + expected, + "hub {upstream} must pass through" + ); + let body: serde_json::Value = response.json().await.expect("a JSON error envelope"); + assert_eq!(body["error"]["code"], "upstream_client_error"); + } +} + +/// GETs `path` over a raw socket, bypassing reqwest's client-side URL +/// normalization (which collapses `%2E%2E` dot-segments before they +/// ever leave a well-behaved client). +async fn raw_get(addr: SocketAddr, path: &str, token: &str) -> (u16, String) { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let mut stream = tokio::net::TcpStream::connect(addr) + .await + .expect("the raw client connects"); + let request = format!( + "GET {path} HTTP/1.1\r\nHost: {addr}\r\nAuthorization: Bearer {token}\r\nConnection: close\r\n\r\n" + ); + stream + .write_all(request.as_bytes()) + .await + .expect("the raw request writes"); + let mut response = String::new(); + stream + .read_to_string(&mut response) + .await + .expect("the raw response reads"); + let status = response + .split_whitespace() + .nth(1) + .and_then(|code| code.parse().ok()) + .expect("a status line"); + (status, response) +} + +#[tokio::test] +async fn admin_hf_search_maps_a_rejected_query_into_the_error_envelope() { + let (addr, seen) = serve_against_stub(StatusCode::OK, "[]", None).await; + for query in [ + "q=a&q=b", + "pipeline_tag=not-a-workload", + "pipeline_tag=text-generation&pipeline_tag=automatic-speech-recognition", + "filter=safetensors", + "sort=created", + "direction=1", + "full=false", + "limit=0", + "limit=101", + "limit=many", + ] { + let response = get(addr, &format!("/admin/hf/search?{query}"), "test-token").await; + assert_eq!(response.status(), reqwest::StatusCode::BAD_REQUEST); + let body: serde_json::Value = response.json().await.expect("a JSON error envelope"); + assert_eq!(body["error"]["code"], "malformed_request"); + } + assert!( + seen.lock().expect("the stub log lock").is_empty(), + "a rejected query must never produce an upstream request" + ); +} + +#[tokio::test] +async fn admin_hf_model_maps_a_rejected_path_into_the_error_envelope() { + let (addr, seen) = serve_against_stub(StatusCode::OK, "{}", None).await; + // `%FF` percent-decodes to invalid UTF-8, so `Path` rejects; + // the rejection must land in the JSON envelope, after auth. + let response = get(addr, "/admin/hf/model/%FF%FF/name", "test-token").await; + assert_eq!(response.status(), reqwest::StatusCode::BAD_REQUEST); + let body: serde_json::Value = response.json().await.expect("a JSON error envelope"); + assert_eq!(body["error"]["code"], "malformed_request"); + + let unauthenticated = reqwest::Client::new() + .get(format!("http://{addr}/admin/hf/model/%FF%FF/name")) + .send() + .await + .expect("the request sends"); + assert_eq!( + unauthenticated.status(), + reqwest::StatusCode::UNAUTHORIZED, + "auth must win over a malformed path" + ); + assert!( + seen.lock().expect("the stub log lock").is_empty(), + "a rejected path must never produce an upstream request" + ); +} + +#[tokio::test] +async fn admin_hf_model_rejects_malformed_repos_without_calling_upstream() { + let (addr, seen) = serve_against_stub(StatusCode::OK, "{}", None).await; + // With `{owner}/{name}` segments, repos with spaces or control + // characters match the route but fail `validate_repo`. + for repo in ["owner/na%20me", ".../name", "owner/..."] { + let response = get(addr, &format!("/admin/hf/model/{repo}"), "test-token").await; + assert_eq!( + response.status(), + reqwest::StatusCode::BAD_REQUEST, + "repo `{repo}` must be refused at the boundary" + ); + let body: serde_json::Value = response.json().await.expect("a JSON error envelope"); + assert_eq!(body["error"]["code"], "malformed_request"); + } + // Encoded dot-segments match the route; validate_repo rejects them. + for repo in ["%2E%2E/name", "owner/%2E%2E"] { + let (status, response) = + raw_get(addr, &format!("/admin/hf/model/{repo}"), "test-token").await; + assert_eq!(status, 400, "repo `{repo}` must be refused at the boundary"); + assert!( + response.contains("malformed_request"), + "repo `{repo}` must map to the JSON error envelope, got: {response}" + ); + } + assert!( + seen.lock().expect("the stub log lock").is_empty(), + "a rejected repo must never produce an upstream request" + ); +} + +#[tokio::test] +async fn admin_hf_routes_require_bearer_auth() { + let (addr, seen) = serve_against_stub(StatusCode::OK, "[]", None).await; + for path in [ + "/admin/hf/search?q=x", + "/admin/hf/model/owner/name", + "/admin/hf/model/owner/name/readme", + ] { + let unauthenticated = reqwest::Client::new() + .get(format!("http://{addr}{path}")) + .send() + .await + .expect("the request sends"); + assert_eq!( + unauthenticated.status(), + reqwest::StatusCode::UNAUTHORIZED, + "`{path}` without a bearer token is refused" + ); + + let wrong_key = get(addr, path, "wrong-token").await; + assert_eq!( + wrong_key.status(), + reqwest::StatusCode::UNAUTHORIZED, + "`{path}` with the wrong bearer token is refused" + ); + } + assert!( + seen.lock().expect("the stub log lock").is_empty(), + "an unauthenticated caller must never reach the hub" + ); +} + +/// Spawns a stub hub that serves README and model-detail paths +/// differently, recording each request it sees. +async fn spawn_readme_stub( + readme_status: StatusCode, + readme_body: &'static str, +) -> (String, Arc>>) { + let seen = Arc::new(Mutex::new(Vec::new())); + let recorded = Arc::clone(&seen); + let app = axum::Router::new().fallback(move |uri: Uri, headers: HeaderMap| { + let recorded = Arc::clone(&recorded); + async move { + recorded.lock().expect("the stub log lock").push(Seen { + path: uri.path().to_owned(), + query: uri.query().unwrap_or("").to_owned(), + authorization: headers + .get(AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned), + }); + if uri.path().ends_with("/README.md") { + ( + readme_status, + [(CONTENT_TYPE, "text/markdown")], + readme_body, + ) + } else { + (StatusCode::OK, [(CONTENT_TYPE, "application/json")], "{}") + } + } + }); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("the stub listener binds"); + let addr = listener.local_addr().expect("the stub bound address"); + tokio::spawn(async move { + let _ignored = axum::serve(listener, app).await; + }); + (format!("http://{addr}"), seen) +} + +async fn serve_readme_stub( + readme_status: StatusCode, + readme_body: &'static str, + token: Option<&str>, +) -> (SocketAddr, Arc>>) { + let (base_url, seen) = spawn_readme_stub(readme_status, readme_body).await; + let proxy = HfProxy::new(base_url, token.map(|t| Secret::new(t.to_owned()))); + let addr = serve_with_hf(hf_config(), proxy).await; + (addr, seen) +} + +#[tokio::test] +async fn admin_hf_readme_proxies_to_the_raw_readme_path() { + let (addr, seen) = serve_readme_stub(StatusCode::OK, "# Model Card\nHello", None).await; + let response = get( + addr, + "/admin/hf/model/unsloth/Qwen3-8B-GGUF/readme", + "test-token", + ) + .await; + assert_eq!(response.status(), reqwest::StatusCode::OK); + assert_eq!( + response + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()), + Some("text/markdown; charset=utf-8"), + ); + assert_eq!( + response.text().await.expect("a body"), + "# Model Card\nHello" + ); + + let seen = seen.lock().expect("the stub log lock"); + let [request] = seen.as_slice() else { + panic!("expected one upstream request, saw {seen:?}"); + }; + assert_eq!( + request.path, "/unsloth/Qwen3-8B-GGUF/raw/main/README.md", + "the proxy must hit the hub's raw README path" + ); +} + +#[tokio::test] +async fn admin_hf_readme_returns_404_for_a_missing_readme() { + let (addr, _seen) = serve_readme_stub(StatusCode::NOT_FOUND, "", None).await; + let response = get(addr, "/admin/hf/model/owner/name/readme", "test-token").await; + assert_eq!(response.status(), reqwest::StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn admin_hf_readme_validates_the_repo() { + let (addr, seen) = serve_readme_stub(StatusCode::OK, "# hello", None).await; + let response = get(addr, "/admin/hf/model/.../name/readme", "test-token").await; + assert_eq!( + response.status(), + reqwest::StatusCode::BAD_REQUEST, + "a dot-only owner must be refused" + ); + let body: serde_json::Value = response.json().await.expect("a JSON error envelope"); + assert_eq!(body["error"]["code"], "malformed_request"); + assert!( + seen.lock().expect("the stub log lock").is_empty(), + "a rejected repo must never produce an upstream request" + ); +} + +#[tokio::test] +async fn admin_hf_readme_caps_the_body_at_one_mib() { + let seen = Arc::new(Mutex::new(Vec::::new())); + let recorded = Arc::clone(&seen); + let app = axum::Router::new().fallback(move |uri: Uri, headers: HeaderMap| { + let recorded = Arc::clone(&recorded); + async move { + recorded.lock().expect("the stub log lock").push(Seen { + path: uri.path().to_owned(), + query: uri.query().unwrap_or("").to_owned(), + authorization: headers + .get(AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned), + }); + let big = "x".repeat(2 * 1024 * 1024); + (StatusCode::OK, [(CONTENT_TYPE, "text/markdown")], big) + } + }); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("the stub listener binds"); + let addr = listener.local_addr().expect("the stub bound address"); + tokio::spawn(async move { + let _ignored = axum::serve(listener, app).await; + }); + let proxy = HfProxy::new(format!("http://{addr}"), None); + let gw = serve_with_hf(hf_config(), proxy).await; + let response = get(gw, "/admin/hf/model/owner/name/readme", "test-token").await; + assert_eq!(response.status(), reqwest::StatusCode::OK); + let body = response.bytes().await.expect("a body"); + assert!( + body.len() <= 1024 * 1024, + "the body must be capped at 1 MiB, got {} bytes", + body.len() + ); +} diff --git a/crates/gateway/app/src/admin/walled/hf.rs b/crates/gateway/app/src/admin/walled/hf.rs new file mode 100644 index 000000000..2475f8836 --- /dev/null +++ b/crates/gateway/app/src/admin/walled/hf.rs @@ -0,0 +1,344 @@ +//! The `GET /admin/hf/*` routes: a thin bearer-authed proxy onto the +//! Hugging Face hub API, feeding the config UI's Discover view. +//! +//! The proxy forwards the hub's JSON bodies verbatim - the UI adapts the +//! shape - and attaches the boot-time `HF_TOKEN` when one is present, so +//! the browser never holds the token and public repos keep working +//! without one. Upstream 4xx statuses pass through in the gateway's error +//! envelope via [`ProtocolError::upstream_status`]; nothing is cached. + +use std::time::Duration; + +use axum::Router; +use axum::body::Body; +use axum::extract::{RawQuery, State}; +use axum::http::header::CONTENT_TYPE; +use axum::http::{HeaderValue, Method}; +use axum::response::Response; +use axum::routing::get; +use gateway_config::Secret; +use gateway_protocol::ProtocolError; +use gateway_protocol::http_util::{self, MAX_ERROR_BODY, read_body_capped}; + +use crate::AppState; +use crate::auth::LoopbackCaller; +use crate::error::{GatewayError, WirePath}; +use crate::registry::RouteInfo; + +const SEARCH: RouteInfo = RouteInfo::walled("/admin/hf/search", &[Method::GET]); +const MODEL: RouteInfo = RouteInfo::walled("/admin/hf/model/{owner}/{name}", &[Method::GET]); +const README: RouteInfo = + RouteInfo::walled("/admin/hf/model/{owner}/{name}/readme", &[Method::GET]); + +/// The Hugging Face proxy routes, as the registry sees them. +pub(crate) const ROUTES: &[RouteInfo] = &[SEARCH, MODEL, README]; + +/// The Hugging Face proxy routes. +pub(crate) fn routes() -> Router { + Router::new() + .route(SEARCH.path, get(admin_hf_search)) + .route(MODEL.path, get(admin_hf_model)) + .route(README.path, get(admin_hf_readme)) +} + +/// Whole-request deadline for one hub call, applied per request; reqwest's +/// per-request timeout replaces the bounded client's wider default. +const HF_TIMEOUT: Duration = Duration::from_secs(30); + +/// Caps response body at 1 MiB: large model-card READMEs with embedded +/// base64 images can exceed 10 MiB, and the gateway only shows the text. +const MAX_README_BODY: usize = 1024 * 1024; + +/// Shared Hugging Face hub client: one reqwest client, the hub base URL, +/// and the boot-time `HF_TOKEN` (absent for anonymous access). +#[derive(Debug)] +pub(crate) struct HfProxy { + /// The shared bounded HTTP client; the hub deadline is applied per request. + client: reqwest::Client, + /// The hub origin, `https://huggingface.co` outside tests. + base_url: String, + /// The bearer token sent to the hub, when one was configured. + token: Option, +} + +impl HfProxy { + /// The production hub client: `https://huggingface.co`, with the token + /// read once from the process `HF_TOKEN` (dotenvy has already folded + /// the `.env` files into the process env at boot). + pub(crate) fn from_env() -> HfProxy { + let token = std::env::var("HF_TOKEN") + .ok() + .filter(|token| !token.is_empty()) + .map(Secret::new); + HfProxy::new("https://huggingface.co".to_owned(), token) + } + + /// A hub client aimed at `base_url` with an explicit token, so tests + /// point the proxy at a local stub without touching the process env. + pub(crate) fn new(base_url: String, token: Option) -> HfProxy { + HfProxy { + client: http_util::bounded_client(), + base_url, + token, + } + } + + /// GETs `{base_url}{path}` with `query`, forwarding the hub's JSON body + /// and status verbatim on success and mapping a non-success status or a + /// transport failure into the gateway's error envelope. + async fn forward(&self, path: &str, query: &[(&str, &str)]) -> Result { + let mut request = self + .client + .get(format!("{}{path}", self.base_url)) + .query(query) + .timeout(HF_TIMEOUT); + if let Some(token) = &self.token { + request = request.bearer_auth(token.expose()); + } + let response = request + .send() + .await + .map_err(ProtocolError::upstream_transport)?; + let status = response.status(); + if !status.is_success() { + let body = read_body_capped(response, MAX_ERROR_BODY).await; + let body: String = body.chars().take(2000).collect(); + return Err(ProtocolError::upstream_status(status.as_u16(), body).into()); + } + let content_type = response + .headers() + .get(CONTENT_TYPE) + .cloned() + .unwrap_or(HeaderValue::from_static("application/json")); + // Streaming the body through keeps the gateway's memory use flat no + // matter how large the hub's sibling list gets. + Response::builder() + .status(status) + .header(CONTENT_TYPE, content_type) + .body(Body::from_stream(response.bytes_stream())) + .map_err(GatewayError::upstream_protocol) + } + + /// GETs `{base_url}{path}`, returning the body as `text/markdown` on + /// success, a plain 404 for a missing README, and the error envelope + /// for other failures. The body is capped at [`MAX_README_BODY`]. + async fn forward_readme(&self, path: &str) -> Result { + let mut request = self + .client + .get(format!("{}{path}", self.base_url)) + .timeout(HF_TIMEOUT); + if let Some(token) = &self.token { + request = request.bearer_auth(token.expose()); + } + let response = request + .send() + .await + .map_err(ProtocolError::upstream_transport)?; + let status = response.status(); + if status.as_u16() == 404 { + return Response::builder() + .status(404) + .body(Body::empty()) + .map_err(GatewayError::upstream_protocol); + } + if !status.is_success() { + let body = read_body_capped(response, MAX_ERROR_BODY).await; + let body: String = body.chars().take(2000).collect(); + return Err(ProtocolError::upstream_status(status.as_u16(), body).into()); + } + let body = read_body_capped(response, MAX_README_BODY).await; + Response::builder() + .status(200) + .header(CONTENT_TYPE, "text/markdown; charset=utf-8") + .body(Body::from(body)) + .map_err(GatewayError::upstream_protocol) + } +} + +/// Query parameters accepted by `GET /admin/hf/search`; each present field +/// is forwarded to the hub's model-search API, and everything else is +/// dropped at this boundary. +#[derive(Debug, Default)] +pub(crate) struct HfSearchQuery { + /// Free-text search, forwarded as the hub's `search` parameter. + q: Option, + /// Tag filter; the Discover view pins `gguf`. + filter: Option, + /// Sort field: `downloads`, `trendingScore`, or `lastModified`. + sort: Option, + /// Sort direction, `-1` for descending. + direction: Option, + /// Result page size. + limit: Option, + /// `full=true` asks the hub to include each result's sibling file list. + full: Option, + /// One workload tag. The UI fans out requests to implement OR filters. + pipeline_tag: Option, +} + +/// The `GET /admin/hf/search` route: bearer-authed, proxies the hub's +/// `GET /api/models` search and returns its JSON body verbatim. +pub(crate) async fn admin_hf_search( + State(state): State, + RawQuery(query): RawQuery, + _caller: LoopbackCaller, +) -> Result { + let query = parse_search_query(query.as_deref())?; + let renames = [("search", &query.q)]; + let passthrough = [ + ("filter", &query.filter), + ("sort", &query.sort), + ("direction", &query.direction), + ("limit", &query.limit), + ("full", &query.full), + ]; + let mut params: Vec<(&str, &str)> = renames + .iter() + .chain(passthrough.iter()) + .filter_map(|(name, value)| Some((*name, value.as_deref()?))) + .collect(); + if let Some(tag) = query.pipeline_tag.as_deref() { + params.push(("pipeline_tag", tag)); + } + state.hf.forward("/api/models", ¶ms).await +} + +/// Parses the small search query allowlist. Every field is singular and +/// closed-set values are validated before any upstream request. +fn parse_search_query(raw: Option<&str>) -> Result { + let mut query = HfSearchQuery::default(); + for (key, value) in url::form_urlencoded::parse(raw.unwrap_or_default().as_bytes()) { + let slot = match key.as_ref() { + "q" => &mut query.q, + "filter" => &mut query.filter, + "sort" => &mut query.sort, + "direction" => &mut query.direction, + "limit" => &mut query.limit, + "full" => &mut query.full, + "pipeline_tag" => { + if !matches!( + value.as_ref(), + "text-generation" + | "feature-extraction" + | "sentence-similarity" + | "text-classification" + | "automatic-speech-recognition" + | "text-to-image" + | "text-to-speech" + ) { + return Err(GatewayError::MalformedRequest(format!( + "unsupported pipeline_tag {value:?}" + ))); + } + if query.pipeline_tag.replace(value.into_owned()).is_some() { + return Err(GatewayError::MalformedRequest( + "pipeline_tag must appear at most once".to_owned(), + )); + } + continue; + } + _ => continue, + }; + if slot.replace(value.into_owned()).is_some() { + return Err(GatewayError::MalformedRequest(format!( + "duplicate query field {key}" + ))); + } + } + validate_search_value("filter", query.filter.as_deref(), &["gguf"])?; + validate_search_value( + "sort", + query.sort.as_deref(), + &["downloads", "trendingScore", "lastModified"], + )?; + validate_search_value("direction", query.direction.as_deref(), &["-1"])?; + validate_search_value("full", query.full.as_deref(), &["true"])?; + if let Some(limit) = query.limit.as_deref() + && !limit + .parse::() + .is_ok_and(|parsed| (1..=100).contains(&parsed)) + { + return Err(GatewayError::MalformedRequest( + "limit must be an integer from 1 through 100".to_owned(), + )); + } + Ok(query) +} + +/// Validates one optional search field against its closed value set. +fn validate_search_value( + name: &str, + value: Option<&str>, + accepted: &[&str], +) -> Result<(), GatewayError> { + if let Some(value) = value + && !accepted.contains(&value) + { + return Err(GatewayError::MalformedRequest(format!( + "unsupported {name} value {value:?}" + ))); + } + Ok(()) +} + +/// The `GET /admin/hf/model/{owner}/{name}` route: bearer-authed, proxies +/// the hub's model detail for an `owner/name` repo with `blobs=true`, so +/// the sibling list carries the exact file sizes the quant picker needs. +pub(crate) async fn admin_hf_model( + State(state): State, + _caller: LoopbackCaller, + WirePath((owner, name)): WirePath<(String, String)>, +) -> Result { + let repo = format!("{owner}/{name}"); + validate_repo(&repo)?; + state + .hf + .forward(&format!("/api/models/{repo}"), &[("blobs", "true")]) + .await +} + +/// The `GET /admin/hf/model/{owner}/{name}/readme` route: bearer-authed, +/// proxies the hub's raw README.md for the repo and returns it as +/// `text/markdown; charset=utf-8`. A missing README maps to 404. +pub(crate) async fn admin_hf_readme( + State(state): State, + _caller: LoopbackCaller, + WirePath((owner, name)): WirePath<(String, String)>, +) -> Result { + let repo = format!("{owner}/{name}"); + validate_repo(&repo)?; + state + .hf + .forward_readme(&format!("/{repo}/raw/main/README.md")) + .await +} + +/// Checks that `repo` is exactly `owner/name`: two non-empty segments of +/// hub-legal characters (ASCII alphanumerics, `-`, `_`, `.`), neither made +/// only of dots. +fn validate_repo(repo: &str) -> Result<(), GatewayError> { + let mut segments = repo.split('/'); + if let (Some(owner), Some(name), None) = (segments.next(), segments.next(), segments.next()) + && is_repo_segment(owner) + && is_repo_segment(name) + { + return Ok(()); + } + Err(GatewayError::MalformedRequest(format!( + "repo `{repo}` is not an owner/name pair of path-safe segments" + ))) +} + +/// Whether one repo segment is non-empty, hub-legal, and not a dot run +/// (`.` and `..` are path traversal, not names). +fn is_repo_segment(segment: &str) -> bool { + !segment.is_empty() + && !segment.bytes().all(|byte| byte == b'.') + && segment + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) +} + +#[cfg(test)] +#[path = "hf-tests.rs"] +mod tests; diff --git a/crates/gateway/app/src/admin/walled/model_info-tests.rs b/crates/gateway/app/src/admin/walled/model_info-tests.rs new file mode 100644 index 000000000..59654768c --- /dev/null +++ b/crates/gateway/app/src/admin/walled/model_info-tests.rs @@ -0,0 +1,159 @@ +use std::net::SocketAddr; +use std::path::Path; + +use gateway_config::Config; + +use crate::test_support::serve; + +/// A profile rooting the artifact cache at `cache_dir`. +fn cache_config(cache_dir: &Path) -> Config { + Config::from_toml_str(&format!( + r#" +config-version = 0 + +[server] +bind = "127.0.0.1:0" +api_key = "test-token" +# Strict bearer auth: the tests below pin that a missing key is refused. +trust_loopback = false + +[local] +cache_dir = '{cache_dir}' +"#, + cache_dir = cache_dir.display(), + )) + .expect("the fixture profile parses") +} + +/// Appends a GGUF string (u64 LE length + bytes) to `out`. +fn push_string(out: &mut Vec, value: &str) { + out.extend_from_slice(&(value.len() as u64).to_le_bytes()); + out.extend_from_slice(value.as_bytes()); +} + +/// A minimal GGUF header with a known architecture, block count, and +/// declared parameter count. +fn synthetic_gguf() -> Vec { + let mut out = Vec::new(); + out.extend_from_slice(b"GGUF"); + out.extend_from_slice(&3u32.to_le_bytes()); + out.extend_from_slice(&0u64.to_le_bytes()); + out.extend_from_slice(&3u64.to_le_bytes()); + push_string(&mut out, "general.architecture"); + out.extend_from_slice(&8u32.to_le_bytes()); + push_string(&mut out, "llama"); + push_string(&mut out, "llama.block_count"); + out.extend_from_slice(&4u32.to_le_bytes()); + out.extend_from_slice(&32u32.to_le_bytes()); + push_string(&mut out, "general.parameter_count"); + out.extend_from_slice(&10u32.to_le_bytes()); + out.extend_from_slice(&8_030_000_000u64.to_le_bytes()); + out +} + +/// GETs `/admin/model-info` for `path` with the given bearer token. +async fn get_model_info(addr: SocketAddr, path: &str, token: &str) -> reqwest::Response { + reqwest::Client::new() + .get(format!("http://{addr}/admin/model-info")) + .query(&[("path", path)]) + .bearer_auth(token) + .send() + .await + .expect("the request sends") +} + +#[tokio::test] +async fn admin_model_info_reports_header_facts() { + let temp = tempfile::TempDir::new().expect("tempdir"); + let models = temp.path().join("models"); + std::fs::create_dir_all(&models).expect("mkdir models"); + std::fs::write(models.join("tiny.gguf"), synthetic_gguf()).expect("write fixture"); + + let addr = serve(cache_config(temp.path())).await; + let response = get_model_info(addr, "models/tiny.gguf", "test-token").await; + assert_eq!(response.status(), reqwest::StatusCode::OK); + let body: serde_json::Value = response.json().await.expect("a JSON body"); + assert_eq!( + body, + serde_json::json!({ + "architecture": "llama", + "layer_count": 32, + "parameter_count": 8_030_000_000u64, + "chat_template": null, + }) + ); +} + +#[tokio::test] +async fn admin_model_info_rejects_a_malformed_file_cleanly() { + let temp = tempfile::TempDir::new().expect("tempdir"); + let models = temp.path().join("models"); + std::fs::create_dir_all(&models).expect("mkdir models"); + std::fs::write(models.join("junk.gguf"), b"not a gguf at all").expect("write junk"); + + let addr = serve(cache_config(temp.path())).await; + let response = get_model_info(addr, "models/junk.gguf", "test-token").await; + assert_eq!(response.status(), reqwest::StatusCode::UNPROCESSABLE_ENTITY); + let body: serde_json::Value = response.json().await.expect("a JSON error envelope"); + assert_eq!(body["error"]["code"], "model_info_error"); +} + +#[tokio::test] +async fn admin_model_info_rejects_paths_escaping_the_cache() { + let temp = tempfile::TempDir::new().expect("tempdir"); + std::fs::create_dir_all(temp.path().join("models")).expect("mkdir models"); + let outside = temp.path().join("..").join("outside.gguf"); + + let addr = serve(cache_config(temp.path())).await; + for escape in ["../outside.gguf", &outside.display().to_string()] { + let response = get_model_info(addr, escape, "test-token").await; + assert_eq!( + response.status(), + reqwest::StatusCode::BAD_REQUEST, + "path `{escape}` must be refused at the boundary" + ); + let body: serde_json::Value = response.json().await.expect("a JSON error envelope"); + assert_eq!(body["error"]["code"], "malformed_request"); + } +} + +#[tokio::test] +async fn admin_model_info_rejects_a_missing_path_in_the_error_envelope() { + let temp = tempfile::TempDir::new().expect("tempdir"); + let addr = serve(cache_config(temp.path())).await; + + let response = reqwest::Client::new() + .get(format!("http://{addr}/admin/model-info")) + .bearer_auth("test-token") + .send() + .await + .expect("the request sends"); + assert_eq!(response.status(), reqwest::StatusCode::BAD_REQUEST); + let body: serde_json::Value = response.json().await.expect("a JSON error envelope"); + assert_eq!(body["error"]["code"], "malformed_request"); +} + +#[tokio::test] +async fn admin_model_info_requires_bearer_auth() { + let temp = tempfile::TempDir::new().expect("tempdir"); + let addr = serve(cache_config(temp.path())).await; + + let unauthenticated = reqwest::Client::new() + .get(format!("http://{addr}/admin/model-info")) + .query(&[("path", "models/tiny.gguf")]) + .send() + .await + .expect("the request sends"); + assert_eq!( + unauthenticated.status(), + reqwest::StatusCode::UNAUTHORIZED, + "a request without a bearer token is refused" + ); + + let wrong_key = get_model_info(addr, "models/tiny.gguf", "wrong-token").await; + assert_eq!( + wrong_key.status(), + reqwest::StatusCode::UNAUTHORIZED, + "a request with the wrong bearer token is refused" + ); +} diff --git a/crates/gateway/app/src/admin/walled/model_info.rs b/crates/gateway/app/src/admin/walled/model_info.rs new file mode 100644 index 000000000..75c18cea8 --- /dev/null +++ b/crates/gateway/app/src/admin/walled/model_info.rs @@ -0,0 +1,79 @@ +//! The `GET /admin/model-info` route: architecture, layer count, and +//! parameter count read from a GGUF header in the artifact cache, feeding +//! the UI's `gpu_layers` "N / total" slider readout. +//! +//! The header parse is blocking filesystem work, so it goes through +//! [`crate::error::blocking`] like every store operation (Amendment D). +//! The parser itself lives in the local crate beside the blob cache, which +//! owns GGUF domain knowledge. + +use std::path::PathBuf; + +use axum::extract::State; +use axum::http::Method; +use axum::routing::get; +use axum::{Json, Router}; +use serde::Deserialize; + +use crate::AppState; +use crate::auth::LoopbackCaller; +use crate::error::{GatewayError, WireQuery, blocking}; +use crate::local::{LocalError, gguf, resolve_cache_root}; +use crate::registry::RouteInfo; + +const MODEL_INFO: RouteInfo = RouteInfo::walled("/admin/model-info", &[Method::GET]); + +/// The GGUF header readout route, as the registry sees it. +pub(crate) const ROUTES: &[RouteInfo] = &[MODEL_INFO]; + +/// The GGUF header readout route. +pub(crate) fn routes() -> Router { + Router::new().route(MODEL_INFO.path, get(admin_model_info)) +} + +/// Query parameters for `GET /admin/model-info`. +#[derive(Debug, Deserialize)] +pub(crate) struct ModelInfoQuery { + /// Cache-relative path of the GGUF file to inspect. + path: String, +} + +/// The `GET /admin/model-info?path=` route: bearer-authed, parses the GGUF +/// header of the named cache file and reports +/// `{"architecture", "layer_count", "parameter_count", "chat_template"}` +/// (each nullable). +/// +/// `path` is caller input and is confined to the artifact cache: only a +/// relative path that resolves under the resolved cache root without +/// crossing a link is accepted - the same `/`-separated form +/// `GET /admin/orphans` reports - so the endpoint can never read an +/// arbitrary file. A missing or escaping path maps to 400; a file that is +/// missing or not a well-formed GGUF header maps to 422. The UI treats any +/// failure as "layer count unknown" and falls back to a plain readout. +pub(crate) async fn admin_model_info( + State(state): State, + _caller: LoopbackCaller, + WireQuery(query): WireQuery, +) -> Result, GatewayError> { + // The retained running config carries the `[local].cache_dir` the path + // is confined to, so the boundary and the store agree on the root. + let config = state.config().await; + let info = blocking(move || { + let root = resolve_cache_root(config.local().cache_dir())?; + gguf::read_model_info(&root, &PathBuf::from(query.path)) + }) + .await? + .map_err(|error| match error { + // The rejected boundary check is the caller's fault, not the file's. + LocalError::UnsafeCachePath { path } => GatewayError::MalformedRequest(format!( + "path `{}` is not a relative path inside the artifact cache", + path.display() + )), + other => GatewayError::model_info(other), + })?; + Ok(Json(info)) +} + +#[cfg(test)] +#[path = "model_info-tests.rs"] +mod tests; diff --git a/crates/gateway/app/src/admin/walled/orphans-tests.rs b/crates/gateway/app/src/admin/walled/orphans-tests.rs new file mode 100644 index 000000000..99897e38c --- /dev/null +++ b/crates/gateway/app/src/admin/walled/orphans-tests.rs @@ -0,0 +1,288 @@ +use std::path::Path; +use std::sync::Arc; +use std::time::Duration; + +use gateway_config::{Config, ProfileName}; +use tokio_util::sync::CancellationToken; + +use crate::commands::Command; +use crate::test_support::{app_state, parking_executor, serve, serve_state}; + +/// A profile rooting the cache at `cache_dir` with one `[[local_model]]` +/// whose path source is `configured`. +fn orphan_config(cache_dir: &Path, configured: &Path) -> Config { + Config::from_toml_str(&format!( + r#" +config-version = 0 + +[server] +bind = "127.0.0.1:0" +api_key = "test-token" +# Strict bearer auth: the tests below pin that a missing key is refused. +trust_loopback = false + +[local] +cache_dir = '{cache_dir}' + +[[local_model]] +name = "adopted" +description = "a configured local model" +source = '{configured}' +context = 4096 +"#, + cache_dir = cache_dir.display(), + configured = configured.display(), + )) + .expect("the fixture profile parses") +} + +#[tokio::test] +async fn admin_orphans_lists_only_unconfigured_files() { + let temp = tempfile::TempDir::new().expect("tempdir"); + let models = temp.path().join("models"); + let slot = models.join("0123456789abcdef"); + std::fs::create_dir_all(&slot).expect("mkdir slot"); + let adopted = models.join("adopted.gguf"); + std::fs::write(&adopted, b"adopted-model-bytes").expect("write adopted"); + std::fs::write(models.join("stray.gguf"), b"stray-bytes").expect("write stray"); + let cached_body: &[u8] = b"cached-bytes"; + let cached_digest = "a".repeat(64); + std::fs::write(slot.join("cached.gguf"), cached_body).expect("write cached"); + std::fs::write( + slot.join("cached.gguf.meta.json"), + serde_json::json!({ + "source": "http://seeded.example/cached.gguf", + "sha256": cached_digest, + "size_bytes": cached_body.len(), + }) + .to_string(), + ) + .expect("write sidecar"); + + let addr = serve(orphan_config(temp.path(), &adopted)).await; + let response = reqwest::Client::new() + .get(format!("http://{addr}/admin/orphans")) + .bearer_auth("test-token") + .send() + .await + .expect("the request sends"); + assert_eq!(response.status(), reqwest::StatusCode::OK); + let body: serde_json::Value = response.json().await.expect("a JSON body"); + assert_eq!( + body, + serde_json::json!({ + "orphans": [ + { + "path": "models/0123456789abcdef/cached.gguf", + "size_bytes": cached_body.len(), + "sha256": cached_digest, + }, + { + "path": "models/stray.gguf", + "size_bytes": b"stray-bytes".len(), + "sha256": null, + }, + ] + }) + ); +} + +#[tokio::test] +async fn admin_orphans_with_no_models_directory_is_empty() { + let temp = tempfile::TempDir::new().expect("tempdir"); + let missing = temp.path().join("models").join("never-provisioned.gguf"); + let addr = serve(orphan_config(temp.path(), &missing)).await; + let response = reqwest::Client::new() + .get(format!("http://{addr}/admin/orphans")) + .bearer_auth("test-token") + .send() + .await + .expect("the request sends"); + assert_eq!(response.status(), reqwest::StatusCode::OK); + let body: serde_json::Value = response.json().await.expect("a JSON body"); + assert_eq!(body, serde_json::json!({ "orphans": [] })); +} + +#[tokio::test] +async fn admin_orphans_requires_bearer_auth() { + let temp = tempfile::TempDir::new().expect("tempdir"); + let missing = temp.path().join("models").join("never-provisioned.gguf"); + let addr = serve(orphan_config(temp.path(), &missing)).await; + let http = reqwest::Client::new(); + + let unauthenticated = http + .get(format!("http://{addr}/admin/orphans")) + .send() + .await + .expect("the request sends"); + assert_eq!( + unauthenticated.status(), + reqwest::StatusCode::UNAUTHORIZED, + "a request without a bearer token is refused" + ); + + let wrong_key = http + .get(format!("http://{addr}/admin/orphans")) + .bearer_auth("wrong-token") + .send() + .await + .expect("the request sends"); + assert_eq!( + wrong_key.status(), + reqwest::StatusCode::UNAUTHORIZED, + "a request with the wrong bearer token is refused" + ); +} + +/// A catalog of two `[[local_model]]` entries whose profile `work` +/// selects only `adopted`; `shelved` stays declared but unselected. +fn profiled_toml(cache_dir: &Path, adopted: &Path, shelved: &Path) -> String { + format!( + r#" +config-version = 0 + +[server] +bind = "127.0.0.1:0" +api_key = "test-token" + +[local] +cache_dir = '{cache_dir}' + +[[local_model]] +name = "adopted" +description = "the running profile's local model" +source = '{adopted}' +context = 4096 + +[[local_model]] +name = "shelved" +description = "declared in the catalog, outside the running profile" +source = '{shelved}' +context = 4096 + +[[profile]] +name = "work" +models = ["adopted"] +"#, + cache_dir = cache_dir.display(), + adopted = adopted.display(), + shelved = shelved.display(), + ) +} + +/// Parses `toml` with the `work` profile selected, as the boot does. +fn booted(toml: &str) -> Config { + Config::from_toml_str(toml) + .expect("the fixture profile parses") + .select_profile(Some(&ProfileName::parse("work").expect("profile name"))) + .expect("the work profile selects") +} + +async fn get_json(addr: std::net::SocketAddr, route: &str) -> serde_json::Value { + let response = reqwest::Client::new() + .get(format!("http://{addr}{route}")) + .bearer_auth("test-token") + .send() + .await + .expect("the request sends"); + assert_eq!(response.status(), reqwest::StatusCode::OK); + response.json().await.expect("a JSON body") +} + +/// After an apply, the live document is republished with no profile +/// selected, so `local_models()` is empty for the rest of the process. +/// The orphan scan and the status `configured` flag read the catalog, +/// which the apply does not move. `configured` reaches the wire only as +/// `provisioning` (configured, not ready, and a command active), so the +/// test holds a parked command while it reads the status. +#[tokio::test] +async fn admin_orphans_and_configured_survive_an_apply_with_no_selection() { + let temp = tempfile::TempDir::new().expect("tempdir"); + let models = temp.path().join("models"); + std::fs::create_dir_all(&models).expect("mkdir models"); + let adopted = models.join("adopted.gguf"); + let shelved = models.join("shelved.gguf"); + std::fs::write(&adopted, b"adopted-model-bytes").expect("write adopted"); + std::fs::write(&shelved, b"shelved-model-bytes").expect("write shelved"); + std::fs::write(models.join("stray.gguf"), b"stray-bytes").expect("write stray"); + let toml = profiled_toml(temp.path(), &adopted, &shelved); + + let state = app_state(booted(&toml), None); + let addr = serve_state(state.clone()).await; + + // What `capture_apply` publishes for a `[[model]]`-only shadow: the + // same document, parsed with no profile selected. + let applied = Config::from_toml_str(&toml) + .expect("the applied document parses") + .select_profile(None) + .expect("no selection"); + assert!(applied.local_models().is_empty()); + state.live.write().await.config = Arc::new(applied); + + let orphans = get_json(addr, "/admin/orphans").await; + assert_eq!( + orphans, + serde_json::json!({ + "orphans": [{ + "path": "models/stray.gguf", + "size_bytes": b"stray-bytes".len(), + "sha256": null, + }] + }), + "no declared model's artifact is an orphan after the apply" + ); + + let worker = state + .commands + .spawn_worker_with(&state, parking_executor()) + .expect("worker spawns"); + let _load = state.commands.enqueue(Command::load_profile( + ProfileName::parse("work").expect("profile name"), + CancellationToken::new(), + )); + tokio::time::timeout(Duration::from_secs(10), async { + while state.commands.active_command().is_none() { + tokio::task::yield_now().await; + } + }) + .await + .expect("the command goes active"); + + let status = get_json(addr, "/admin/status").await; + let chat = status["endpoints"] + .as_array() + .expect("endpoints are an array") + .iter() + .find(|entry| entry["path"] == "/v1/chat/completions") + .expect("the chat endpoint is listed"); + assert_eq!( + chat["provisioning"], true, + "the catalog's local chat model keeps the endpoint configured: {status}" + ); + + state.commands.cancel_active(); + state.commands.shutdown(); + worker.await.expect("the worker exits on shutdown"); +} + +/// An orphan is a cache file no catalog entry references: a model the +/// running profile leaves out is still declared, so its artifact stays. +#[tokio::test] +async fn admin_orphans_keeps_catalog_models_outside_the_running_profile() { + let temp = tempfile::TempDir::new().expect("tempdir"); + let models = temp.path().join("models"); + std::fs::create_dir_all(&models).expect("mkdir models"); + let adopted = models.join("adopted.gguf"); + let shelved = models.join("shelved.gguf"); + std::fs::write(&adopted, b"adopted-model-bytes").expect("write adopted"); + std::fs::write(&shelved, b"shelved-model-bytes").expect("write shelved"); + let toml = profiled_toml(temp.path(), &adopted, &shelved); + + let addr = serve(booted(&toml)).await; + let orphans = get_json(addr, "/admin/orphans").await; + assert_eq!( + orphans, + serde_json::json!({ "orphans": [] }), + "a declared model outside the running profile is not an orphan" + ); +} diff --git a/crates/gateway/app/src/admin/walled/orphans.rs b/crates/gateway/app/src/admin/walled/orphans.rs new file mode 100644 index 000000000..063512525 --- /dev/null +++ b/crates/gateway/app/src/admin/walled/orphans.rs @@ -0,0 +1,76 @@ +//! The `GET /admin/orphans` route: files in the artifact cache's `models/` +//! tree that no `[[local_model]]` or `[[stt_model]]` declared in the catalog +//! references, so an operator can adopt or delete leftovers. +//! +//! The scan is blocking filesystem work, so it goes through +//! [`crate::error::blocking`] like every store operation (Amendment D). +//! The diff itself lives in the local crate beside the blob cache, which owns +//! the slot layout and the sidecar records. + +use axum::extract::State; +use axum::http::Method; +use axum::routing::get; +use axum::{Json, Router}; +use gateway_config::SttModelConfig; +use serde::Serialize; + +use crate::AppState; +use crate::auth::LoopbackCaller; +use crate::error::{GatewayError, blocking}; +use crate::local::cache::{OrphanEntry, orphans}; +use crate::local::resolve_cache_root; +use crate::registry::RouteInfo; + +/// The `GET /admin/orphans` reply. +#[derive(Debug, Serialize)] +pub(crate) struct OrphansReply { + /// Every cache file no catalog entry references. + orphans: Vec, +} + +const ORPHANS: RouteInfo = RouteInfo::walled("/admin/orphans", &[Method::GET]); + +/// The orphan-scan route, as the registry sees it. +pub(crate) const ROUTES: &[RouteInfo] = &[ORPHANS]; + +/// The orphan-scan route. +pub(crate) fn routes() -> Router { + Router::new().route(ORPHANS.path, get(admin_orphans)) +} + +/// The `GET /admin/orphans` route: bearer-authed, scans `/models/` +/// and reports every file no `[[local_model]]` or `[[stt_model]]` declared in +/// the catalog references as `{"orphans": [{"path", "size_bytes", "sha256"}]}`. +/// +/// `path` is relative to the resolved cache root (`/`-separated on every +/// platform). `sha256` comes from the blob's cache sidecar and is null for +/// files the cache API never downloaded: blobs are multi-gigabyte, so their +/// bytes are never re-hashed here. A missing cache or `models/` directory +/// reports an empty list. +pub(crate) async fn admin_orphans( + State(state): State, + _caller: LoopbackCaller, +) -> Result, GatewayError> { + // The retained running config carries both the `[local].cache_dir` the + // scan resolves and the catalog it diffs against: every `[[local_model]]` + // and `[[stt_model]]` the document declares, whether or not the running + // profile selects it. The catalog does not move on an apply, which + // republishes the document with no profile selected. + let config = state.config().await; + let entries = blocking(move || { + let root = resolve_cache_root(config.local().cache_dir())?; + let stt_sources: Vec<&str> = config + .catalog_stt_models() + .iter() + .map(SttModelConfig::source) + .collect(); + orphans(&root, config.catalog_local_models(), &stt_sources) + }) + .await? + .map_err(GatewayError::cache)?; + Ok(Json(OrphansReply { orphans: entries })) +} + +#[cfg(test)] +#[path = "orphans-tests.rs"] +mod tests; diff --git a/crates/gateway/app/src/admin/walled/reveal-tests.rs b/crates/gateway/app/src/admin/walled/reveal-tests.rs new file mode 100644 index 000000000..cfa376b0e --- /dev/null +++ b/crates/gateway/app/src/admin/walled/reveal-tests.rs @@ -0,0 +1,339 @@ +use std::net::SocketAddr; +use std::path::Path; +use std::sync::{Arc, Mutex}; + +use axum::body::Body; +use axum::extract::ConnectInfo; +use axum::http::header::{AUTHORIZATION, CONTENT_TYPE}; +use axum::http::{Request, StatusCode}; +use gateway_config::Config; +use tower::ServiceExt; + +use super::{RevealCommand, RevealLauncher}; +use crate::test_support::{AdminPaths, app_state, serve_state}; + +/// A launcher that records every command instead of spawning. +#[derive(Debug, Default)] +struct RecordingLauncher { + commands: Mutex>, +} + +impl RecordingLauncher { + fn commands(&self) -> Vec { + self.commands + .lock() + .expect("the recording mutex is never poisoned") + .clone() + } +} + +impl RevealLauncher for RecordingLauncher { + fn launch(&self, command: RevealCommand) -> std::io::Result<()> { + self.commands + .lock() + .expect("the recording mutex is never poisoned") + .push(command); + Ok(()) + } +} + +/// A tempdir with a cache root holding `models/tiny.gguf`, a profiles +/// directory holding `main.toml`, and an `outside.txt` in neither. +fn fixture() -> (tempfile::TempDir, Config, AdminPaths) { + let temp = tempfile::TempDir::new().expect("tempdir"); + let models = temp.path().join("cache").join("models"); + std::fs::create_dir_all(&models).expect("mkdir cache models"); + std::fs::write(models.join("tiny.gguf"), b"stub").expect("write model"); + let profiles = temp.path().join("profiles"); + std::fs::create_dir(&profiles).expect("mkdir profiles"); + std::fs::write(profiles.join("main.toml"), "").expect("write profile"); + std::fs::write(temp.path().join("outside.txt"), "outside").expect("write outsider"); + let boot = temp.path().join("gateway.toml"); + std::fs::write(&boot, "").expect("write boot"); + let config = Config::from_toml_str(&format!( + r#" +config-version = 0 + +[server] +bind = "127.0.0.1:0" +api_key = "test-token" +# Strict bearer auth: the tests below pin that a missing key is refused. +trust_loopback = false + +[local] +cache_dir = '{cache}' +"#, + cache = temp.path().join("cache").display(), + )) + .expect("the fixture profile parses"); + let paths = AdminPaths { + fixture_dir: profiles, + active: "main".to_owned(), + config_path: boot, + }; + (temp, config, paths) +} + +/// Serves the fixture with a recording launcher injected. +async fn serve_reveal(config: Config, paths: AdminPaths) -> (SocketAddr, Arc) { + let launcher = Arc::new(RecordingLauncher::default()); + let mut state = app_state(config, Some(paths)); + state.reveal = Arc::clone(&launcher) as Arc; + (serve_state(state).await, launcher) +} + +/// POSTs `/admin/reveal` for `path`, with `token` as the bearer when +/// given. +async fn post_reveal(addr: SocketAddr, token: Option<&str>, path: &str) -> reqwest::Response { + let mut request = reqwest::Client::new() + .post(format!("http://{addr}/admin/reveal")) + .json(&serde_json::json!({ "path": path })); + if let Some(token) = token { + request = request.bearer_auth(token); + } + request.send().await.expect("the request sends") +} + +/// The command the platform must construct for `target`, computed +/// independently of the module's own helpers. +fn expected_command(target: &Path) -> RevealCommand { + let canonical = std::fs::canonicalize(target).expect("the target canonicalizes"); + #[cfg(windows)] + { + let plain = canonical + .to_string_lossy() + .strip_prefix(r"\\?\") + .expect("canonicalize returns a verbatim path on Windows") + .to_owned(); + let windir = std::env::var_os("WINDIR").expect("Windows sets WINDIR"); + RevealCommand { + program: Path::new(&windir).join("explorer.exe").into_os_string(), + args: vec!["/select,".into(), plain.into()], + } + } + #[cfg(not(windows))] + { + let parent = canonical + .parent() + .expect("a fixture file has a parent") + .as_os_str() + .to_owned(); + let program = if cfg!(target_os = "macos") { + "open" + } else { + "xdg-open" + }; + RevealCommand { + program: program.into(), + args: vec![parent], + } + } +} + +#[cfg(feature = "local")] +#[tokio::test] +async fn reveal_selects_a_cache_file_in_the_file_manager() { + let (temp, config, paths) = fixture(); + let target = temp.path().join("cache").join("models").join("tiny.gguf"); + let (addr, launcher) = serve_reveal(config, paths).await; + + let response = post_reveal(addr, Some("test-token"), &target.display().to_string()).await; + assert_eq!(response.status(), reqwest::StatusCode::NO_CONTENT); + assert_eq!( + launcher.commands(), + vec![expected_command(&target)], + "the launcher receives exactly the platform's reveal command \ + for the canonical path" + ); +} + +#[tokio::test] +async fn reveal_rejects_a_path_outside_the_safe_roots() { + let (temp, config, paths) = fixture(); + // Both exist on disk; neither is under the cache or profiles root + // (the boot config's own directory is deliberately not a safe root). + let outsiders = [ + temp.path().join("outside.txt"), + temp.path().join("gateway.toml"), + ]; + let (addr, launcher) = serve_reveal(config, paths).await; + + for outsider in outsiders { + let response = post_reveal(addr, Some("test-token"), &outsider.display().to_string()).await; + assert_eq!( + response.status(), + reqwest::StatusCode::BAD_REQUEST, + "`{}` must be refused at the boundary", + outsider.display() + ); + let body: serde_json::Value = response.json().await.expect("a JSON error envelope"); + assert_eq!(body["error"]["code"], "malformed_request"); + } + assert!( + launcher.commands().is_empty(), + "the launcher must never run for a path outside the safe roots" + ); +} + +#[tokio::test] +async fn reveal_refuses_a_traversal_that_resolves_outside() { + let (temp, config, paths) = fixture(); + // A raw component-prefix check would admit this path (it begins + // with the cache root's components); only canonicalizing before + // the comparison resolves the `..` segments to `outside.txt` and + // refuses it. + let traversal = temp + .path() + .join("cache") + .join("models") + .join("..") + .join("..") + .join("outside.txt"); + let (addr, launcher) = serve_reveal(config, paths).await; + + let response = post_reveal(addr, Some("test-token"), &traversal.display().to_string()).await; + assert_eq!(response.status(), reqwest::StatusCode::BAD_REQUEST); + let body: serde_json::Value = response.json().await.expect("a JSON error envelope"); + assert_eq!(body["error"]["code"], "malformed_request"); + assert!( + launcher.commands().is_empty(), + "the launcher must never run for a traversal that resolves outside" + ); +} + +#[tokio::test] +async fn reveal_refuses_the_safe_root_itself() { + let (_temp, config, paths) = fixture(); + let root = paths.fixture_dir.clone(); + let (addr, launcher) = serve_reveal(config, paths).await; + + // Containment is strict: on non-Windows the reveal opens the + // target's parent, so revealing a root would hand the launcher a + // directory outside every root. + let response = post_reveal(addr, Some("test-token"), &root.display().to_string()).await; + assert_eq!(response.status(), reqwest::StatusCode::BAD_REQUEST); + let body: serde_json::Value = response.json().await.expect("a JSON error envelope"); + assert_eq!(body["error"]["code"], "malformed_request"); + assert!( + launcher.commands().is_empty(), + "the launcher must never run for a safe root itself" + ); +} + +#[tokio::test] +async fn a_missing_peer_address_fails_closed_as_non_loopback() { + let (_temp, config, paths) = fixture(); + let target = paths.fixture_dir.join("main.toml"); + let launcher = Arc::new(RecordingLauncher::default()); + let mut state = app_state(config, Some(paths)); + state.reveal = Arc::clone(&launcher) as Arc; + + // Served WITHOUT connect info, as a misassembled embedding host + // would: the peer-address extension is absent, and the shared wall + // must fail closed with its bare 403 rather than admit the caller. + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("the test listener binds"); + let addr = listener.local_addr().expect("the bound address"); + tokio::spawn(async move { + let _ignored = axum::serve(listener, crate::build_router(state, None)).await; + }); + + let response = post_reveal(addr, Some("test-token"), &target.display().to_string()).await; + assert_eq!(response.status(), reqwest::StatusCode::FORBIDDEN); + assert!( + launcher.commands().is_empty(), + "the launcher must never run when the peer address is unknown" + ); +} + +#[tokio::test] +async fn reveal_rejects_a_missing_path() { + let (_temp, config, paths) = fixture(); + let ghost = paths.fixture_dir.join("ghost.toml"); + let (addr, launcher) = serve_reveal(config, paths).await; + + let response = post_reveal(addr, Some("test-token"), &ghost.display().to_string()).await; + assert_eq!(response.status(), reqwest::StatusCode::NOT_FOUND); + let body: serde_json::Value = response.json().await.expect("a JSON error envelope"); + assert_eq!(body["error"]["code"], "reveal_path_not_found"); + assert!( + launcher.commands().is_empty(), + "the launcher must never run for a missing path" + ); +} + +#[tokio::test] +async fn reveal_requires_bearer_auth() { + let (_temp, config, paths) = fixture(); + let target = paths.fixture_dir.join("main.toml"); + let (addr, launcher) = serve_reveal(config, paths).await; + + for token in [None, Some("wrong-token")] { + let response = post_reveal(addr, token, &target.display().to_string()).await; + assert_eq!( + response.status(), + reqwest::StatusCode::UNAUTHORIZED, + "a request with bearer {token:?} is refused" + ); + } + assert!( + launcher.commands().is_empty(), + "the launcher must never run for an unauthenticated caller" + ); +} + +#[tokio::test] +async fn reveal_refuses_a_non_loopback_caller() { + let (_temp, config, paths) = fixture(); + let target = paths.fixture_dir.join("main.toml"); + let launcher = Arc::new(RecordingLauncher::default()); + let mut state = app_state(config, Some(paths)); + state.reveal = Arc::clone(&launcher) as Arc; + + // A LAN peer presenting the valid bearer key: the shared loopback + // wall layered in `build_router` must refuse before auth even + // matters. A real TCP connection to the test listener is always + // loopback, so the router is driven in-process with a forged peer + // address planted in the ConnectInfo extension. + let body = serde_json::json!({ "path": target.display().to_string() }).to_string(); + let mut request = Request::builder() + .method("POST") + .uri("/admin/reveal") + .header(AUTHORIZATION, "Bearer test-token") + .header(CONTENT_TYPE, "application/json") + .body(Body::from(body)) + .expect("static request parts are valid"); + let peer: SocketAddr = "198.51.100.7:44821".parse().expect("a socket address"); + request.extensions_mut().insert(ConnectInfo(peer)); + + let response = crate::build_router(state, None) + .oneshot(request) + .await + .expect("the router is infallible"); + + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert!( + launcher.commands().is_empty(), + "the launcher must never run for a non-loopback caller" + ); +} + +#[cfg(windows)] +#[test] +fn strip_verbatim_unwraps_disk_and_unc_prefixes() { + use std::ffi::OsString; + + assert_eq!( + super::strip_verbatim(Path::new(r"\\?\C:\cache\m.gguf")), + OsString::from(r"C:\cache\m.gguf") + ); + assert_eq!( + super::strip_verbatim(Path::new(r"\\?\UNC\host\share\m.gguf")), + OsString::from(r"\\host\share\m.gguf") + ); + assert_eq!( + super::strip_verbatim(Path::new(r"C:\plain")), + OsString::from(r"C:\plain") + ); +} diff --git a/crates/gateway/app/src/admin/walled/reveal.rs b/crates/gateway/app/src/admin/walled/reveal.rs new file mode 100644 index 000000000..35cc65f83 --- /dev/null +++ b/crates/gateway/app/src/admin/walled/reveal.rs @@ -0,0 +1,229 @@ +//! The `POST /admin/reveal` route: opens the host OS file manager at a +//! cache or profile path, for the UI's "reveal in folder" button on model +//! files and config files. +//! +//! The endpoint launches a process, so it is guarded three ways. The +//! caller must be on the loopback interface: `build_router` places the +//! route behind the shared loopback wall from +//! `shared-loopback`, which refuses any non-loopback or +//! unknown peer with a bare 403 before this handler ever runs (and +//! before auth). The caller must present the bearer key (401). And the named +//! path must canonicalize to strictly inside the artifact cache (the root is +//! refused, so the non-Windows parent-directory reveal can never name a +//! directory outside every root; 400 otherwise, 404 when the path does +//! not exist). The path never crosses a shell: the file manager is +//! spawned directly with separate arguments, through an injectable +//! [`RevealLauncher`] so tests assert the exact constructed command +//! without spawning anything. + +use std::ffi::OsString; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use axum::Router; +use axum::extract::State; +use axum::http::{Method, StatusCode}; +use axum::routing::post; +use serde::Deserialize; + +use crate::AppState; +use crate::auth::LoopbackCaller; +use crate::error::{GatewayError, WireJson, blocking}; +use crate::registry::RouteInfo; + +const REVEAL: RouteInfo = RouteInfo::walled("/admin/reveal", &[Method::POST]); + +/// The reveal route, as the registry sees it. +pub(crate) const ROUTES: &[RouteInfo] = &[REVEAL]; + +/// The reveal route. +pub(crate) fn routes() -> Router { + Router::new().route(REVEAL.path, post(admin_reveal)) +} + +/// The `POST /admin/reveal` body: the filesystem path to reveal. +#[derive(Debug, Deserialize)] +pub(crate) struct RevealRequest { + /// Path of the file or directory to reveal. Must exist and must + /// canonicalize to strictly inside the artifact cache or the profiles + /// directory; the roots themselves are refused. + pub(crate) path: String, +} + +/// The command a reveal resolves to: the file-manager program and its +/// arguments, each a separate `OsString` so no shell ever interprets the +/// path. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct RevealCommand { + /// The program to spawn (the absolute `explorer.exe`, or `open`, or + /// `xdg-open`). + pub(crate) program: OsString, + /// The program's arguments, passed separately, never joined. + pub(crate) args: Vec, +} + +/// Launches a [`RevealCommand`]; injectable so tests observe the +/// constructed command without spawning a process. +pub(crate) trait RevealLauncher: Send + Sync + std::fmt::Debug { + /// Launches `command` without waiting for it to exit. + /// + /// # Errors + /// Returns the spawn failure when the program cannot start. + fn launch(&self, command: RevealCommand) -> std::io::Result<()>; +} + +/// The production launcher: spawns the command and does not wait. +#[derive(Debug, Clone, Copy)] +pub(crate) struct SpawnLauncher; + +impl RevealLauncher for SpawnLauncher { + fn launch(&self, command: RevealCommand) -> std::io::Result<()> { + // Fire and forget: the file manager outlives the request and its + // exit status means nothing to the caller, so the child handle is + // dropped as soon as the spawn succeeds. + std::process::Command::new(&command.program) + .args(&command.args) + .spawn() + .map(drop) + } +} + +/// The `POST /admin/reveal` route: loopback-only and bearer-authed, opens +/// the OS file manager at the request's path and replies 204 without +/// waiting for the spawned process. The loopback wall is not this +/// handler's: `build_router` layers the shared `require_loopback` +/// middleware over the route, so a non-loopback or unknown peer is +/// refused with a bare 403 before auth and before this body runs. +/// +/// # Errors +/// Returns [`GatewayError::Unauthorized`] on a +/// missing or wrong bearer key, [`GatewayError::MalformedRequest`] when +/// the body does not parse or the path resolves outside every safe root +/// (or is a root itself), +/// [`GatewayError::RevealPathNotFound`] when the path does not exist, and +/// [`GatewayError::RevealFailed`] when the file manager cannot spawn. +pub(crate) async fn admin_reveal( + State(state): State, + _caller: LoopbackCaller, + WireJson(request): WireJson, +) -> Result { + #[cfg(feature = "local")] + let roots = { + let mut roots = Vec::new(); + let config = state.config().await; + // An unresolvable cache root (no cache_dir configured and no home + // directory) contributes no safe root. + if let Ok(root) = crate::local::resolve_cache_root(config.local().cache_dir()) { + roots.push(root); + } + roots + }; + #[cfg(not(feature = "local"))] + let roots: Vec = Vec::new(); + // Canonicalization and the spawn are blocking filesystem work. + let launcher = Arc::clone(&state.reveal); + blocking(move || { + let command = resolve_reveal(&roots, Path::new(&request.path))?; + launcher + .launch(command) + .map_err(|error| GatewayError::RevealFailed(Box::new(error))) + }) + .await??; + Ok(StatusCode::NO_CONTENT) +} + +/// Confines `path` to strictly inside the safe roots and builds the +/// platform's reveal command for it. +/// +/// Both sides of the containment check are canonicalized, so `..` +/// segments, relative forms, and symlinks are resolved before comparison. +/// +/// # Errors +/// Returns [`GatewayError::RevealPathNotFound`] when `path` does not +/// exist, [`GatewayError::RevealFailed`] when canonicalization fails for +/// another reason, and [`GatewayError::MalformedRequest`] when the +/// canonical path lies outside every root or is a root itself. +fn resolve_reveal(roots: &[PathBuf], path: &Path) -> Result { + let canonical = fs::canonicalize(path).map_err(|error| { + if error.kind() == std::io::ErrorKind::NotFound { + GatewayError::RevealPathNotFound(path.display().to_string()) + } else { + GatewayError::RevealFailed(Box::new(error)) + } + })?; + // Each root canonicalizes independently so the prefix comparison + // happens in one namespace; a root that cannot canonicalize (a cache + // dir never created, say) confines nothing rather than failing a + // reveal aimed at another root. Containment is strict: a root itself + // is refused, so the non-Windows parent-directory reveal can never + // hand the launcher a directory outside every root. + let confined = roots + .iter() + .filter_map(|root| fs::canonicalize(root).ok()) + .any(|root| canonical.starts_with(&root) && canonical != root); + if !confined { + return Err(GatewayError::MalformedRequest(format!( + "path `{}` is not inside the artifact cache", + path.display() + ))); + } + Ok(reveal_command(&canonical)) +} + +/// The Windows reveal: `explorer.exe /select,` highlights the target +/// in its parent folder. The two tokens stay separate arguments; explorer +/// accepts the split form, and nothing is ever joined through a shell. +/// The program is the absolute `%WINDIR%\explorer.exe`, because +/// `CreateProcess` resolves an unqualified name through the current +/// directory before the system directories, and a planted `explorer.exe` +/// must not win that search. +#[cfg(windows)] +fn reveal_command(target: &Path) -> RevealCommand { + let windir = std::env::var_os("WINDIR").unwrap_or_else(|| OsString::from(r"C:\Windows")); + RevealCommand { + program: Path::new(&windir).join("explorer.exe").into_os_string(), + args: vec![OsString::from("/select,"), strip_verbatim(target)], + } +} + +/// The non-Windows reveal: neither `open` nor `xdg-open` can select a +/// file, so the closest equivalent is opening the target's parent +/// directory. Confinement is strict (a root itself is never revealed), +/// so the parent stays inside a safe root; the fallback to the target +/// itself only keeps the function total for a parentless path. +#[cfg(not(windows))] +fn reveal_command(target: &Path) -> RevealCommand { + let directory = target.parent().unwrap_or(target); + let program = if cfg!(target_os = "macos") { + "open" + } else { + "xdg-open" + }; + RevealCommand { + program: OsString::from(program), + args: vec![directory.as_os_str().to_owned()], + } +} + +/// Rewrites a verbatim path to its plain form for explorer.exe. +/// +/// `fs::canonicalize` returns verbatim (`\\?\`) paths on Windows and +/// explorer.exe does not accept that prefix +/// (), so the command +/// carries `C:\...` or `\\server\share\...` instead. +#[cfg(windows)] +fn strip_verbatim(path: &Path) -> OsString { + let text = path.to_string_lossy(); + if let Some(unc) = text.strip_prefix(r"\\?\UNC\") { + return OsString::from(format!(r"\\{unc}")); + } + if let Some(disk) = text.strip_prefix(r"\\?\") { + return OsString::from(disk.to_owned()); + } + path.as_os_str().to_owned() +} + +#[cfg(test)] +#[path = "reveal-tests.rs"] +mod tests; diff --git a/crates/gateway/app/src/admin/walled/shutdown-tests.rs b/crates/gateway/app/src/admin/walled/shutdown-tests.rs new file mode 100644 index 000000000..5d00e66bc --- /dev/null +++ b/crates/gateway/app/src/admin/walled/shutdown-tests.rs @@ -0,0 +1,103 @@ +use std::net::SocketAddr; +use std::time::Duration; + +use axum::body::Body; +use axum::extract::ConnectInfo; +use axum::http::header::AUTHORIZATION; +use axum::http::{Method, Request, Response, StatusCode}; +use gateway_config::Config; +use tower::ServiceExt; + +use crate::test_support::app_state; +use crate::{AppState, build_router}; + +/// Strict bearer auth (`trust_loopback = false`), so the missing-key +/// case below is refused from the planted loopback peer. +fn state() -> AppState { + let config = Config::from_toml_str( + "config-version = 0\n\ + [server]\nbind = \"127.0.0.1:0\"\napi_key = \"test-token\"\n\ + trust_loopback = false\n", + ) + .expect("config parses"); + app_state(config, None) +} + +/// Sends one request to `/shutdown` through the router with the given +/// bearer key and peer address planted, as the walled route requires. +async fn send(state: &AppState, method: Method, key: Option<&str>, peer: &str) -> Response { + let mut builder = Request::builder().method(method).uri("/shutdown"); + if let Some(key) = key { + builder = builder.header(AUTHORIZATION, format!("Bearer {key}")); + } + let mut request = builder.body(Body::empty()).expect("request builds"); + let peer: SocketAddr = peer.parse().expect("a socket address"); + request.extensions_mut().insert(ConnectInfo(peer)); + build_router(state.clone(), None) + .oneshot(request) + .await + .expect("the router is infallible") +} + +#[tokio::test] +async fn the_route_answers_202_and_fires_the_signal() { + let state = state(); + let response = send(&state, Method::POST, Some("test-token"), "127.0.0.1:50000").await; + assert_eq!(response.status(), StatusCode::ACCEPTED); + tokio::time::timeout(Duration::from_secs(5), state.shutdown.fired()) + .await + .expect("the route fired the shutdown signal"); +} + +#[tokio::test] +async fn the_route_rejects_a_missing_or_wrong_key_without_firing() { + let state = state(); + for key in [None, Some("wrong")] { + let response = send(&state, Method::POST, key, "127.0.0.1:50000").await; + assert_eq!(response.status(), StatusCode::UNAUTHORIZED, "key {key:?}"); + } + assert!( + tokio::time::timeout(Duration::from_millis(100), state.shutdown.fired()) + .await + .is_err(), + "a refused request must leave the server up" + ); +} + +#[tokio::test] +async fn the_route_rejects_non_post_methods() { + let state = state(); + for method in [Method::GET, Method::PUT, Method::DELETE] { + let response = send( + &state, + method.clone(), + Some("test-token"), + "127.0.0.1:50000", + ) + .await; + assert_eq!( + response.status(), + StatusCode::METHOD_NOT_ALLOWED, + "{method} must not reach the handler" + ); + } +} + +#[tokio::test] +async fn the_route_refuses_a_lan_peer_even_with_the_key() { + let state = state(); + let response = send( + &state, + Method::POST, + Some("test-token"), + "198.51.100.7:44821", + ) + .await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert!( + tokio::time::timeout(Duration::from_millis(100), state.shutdown.fired()) + .await + .is_err(), + "a walled-off request must leave the server up" + ); +} diff --git a/crates/gateway/app/src/admin/walled/shutdown.rs b/crates/gateway/app/src/admin/walled/shutdown.rs new file mode 100644 index 000000000..4c62662be --- /dev/null +++ b/crates/gateway/app/src/admin/walled/shutdown.rs @@ -0,0 +1,51 @@ +//! The `POST /shutdown` route, the remote face of +//! [`crate::shutdown::ShutdownSignal`]. +//! +//! The route is the remote face of the graceful shutdown that Ctrl-C and +//! [`GatewayHandle::shutdown`](crate::GatewayHandle::shutdown) drive; the +//! tray's Quit and the shell's Quit-everything call it. It sits behind the +//! shared loopback wall and bearer auth, and it answers `202 Accepted` +//! while its own request is still in flight: axum's graceful shutdown +//! drains in-flight requests before closing their connections, so the +//! response always reaches the caller ahead of the shutdown it asked for. + +use axum::Router; +use axum::extract::State; +use axum::http::{Method, StatusCode}; +use axum::routing::post; + +use crate::AppState; +use crate::auth::LoopbackCaller; +use crate::error::GatewayError; +use crate::registry::RouteInfo; + +const SHUTDOWN: RouteInfo = RouteInfo::walled("/shutdown", &[Method::POST]); + +/// The shutdown route, as the registry sees it. +pub(crate) const ROUTES: &[RouteInfo] = &[SHUTDOWN]; + +/// The shutdown route. +pub(crate) fn routes() -> Router { + Router::new().route(SHUTDOWN.path, post(admin_shutdown)) +} + +/// The `POST /shutdown` route: bearer-authed, loopback-only via the shared +/// wall, answering `202 Accepted` and firing the shutdown signal. +/// +/// Like every bearer route it inherits the configured key, including the +/// deliberately credential-free empty-key configuration. +pub(crate) async fn admin_shutdown( + State(state): State, + _caller: LoopbackCaller, +) -> Result { + // Cancel the active queue command first: a shutdown during provisioning + // stops the download, so the serve loop's drain and the process exit + // stay prompt. + state.commands.cancel_active(); + state.shutdown.fire(); + Ok(StatusCode::ACCEPTED) +} + +#[cfg(test)] +#[path = "shutdown-tests.rs"] +mod tests; diff --git a/crates/gateway/app/src/admin/walled/system-tests.rs b/crates/gateway/app/src/admin/walled/system-tests.rs new file mode 100644 index 000000000..dcb9ad7b6 --- /dev/null +++ b/crates/gateway/app/src/admin/walled/system-tests.rs @@ -0,0 +1,110 @@ +use gateway_config::Config; + +use crate::test_support::serve; + +/// A minimal profile rooting the artifact cache at `cache_dir`. +fn system_config(cache_dir: &std::path::Path) -> Config { + Config::from_toml_str(&format!( + r#" +config-version = 0 + +[server] +bind = "127.0.0.1:0" +api_key = "test-token" +# Strict bearer auth: the tests below pin that a missing key is refused. +trust_loopback = false + +[local] +cache_dir = '{cache_dir}' +"#, + cache_dir = cache_dir.display(), + )) + .expect("the fixture profile parses") +} + +#[tokio::test] +async fn admin_system_reports_plausible_cpu_ram_and_disk() { + let temp = tempfile::TempDir::new().expect("tempdir"); + let addr = serve(system_config(temp.path())).await; + let response = reqwest::Client::new() + .get(format!("http://{addr}/admin/system")) + .bearer_auth("test-token") + .send() + .await + .expect("the request sends"); + assert_eq!(response.status(), reqwest::StatusCode::OK); + let body: serde_json::Value = response.json().await.expect("a JSON body"); + + let cpu = &body["cpu"]; + assert!( + cpu["logical_cores"].as_u64().expect("logical_cores") > 0, + "a running host has at least one logical core" + ); + assert!( + cpu["utilization_percent"].as_f64().is_some(), + "utilization is always a number" + ); + assert!( + cpu["frequency_mhz"].is_u64(), + "frequency is always a number, 0 when unknown" + ); + + let total_ram = body["ram"]["total_bytes"].as_u64().expect("ram total"); + let used_ram = body["ram"]["used_bytes"].as_u64().expect("ram used"); + assert!(total_ram > 0, "a running host has RAM installed"); + assert!( + used_ram > 0 && used_ram <= total_ram, + "used RAM is nonzero and within the installed total" + ); + + // The tempdir cache root sits on a mounted drive, so the disk card + // resolves on every platform the suite runs on. + let total_disk = body["disk"]["total_bytes"].as_u64().expect("disk total"); + let used_disk = body["disk"]["used_bytes"].as_u64().expect("disk used"); + assert!(total_disk > 0, "the cache drive has a capacity"); + assert!(used_disk <= total_disk, "usage cannot exceed capacity"); + + // GPU is genuinely optional: absent on hosts without an NVIDIA + // driver (CI), present with a name and a nonzero VRAM total where + // NVML loads. The endpoint must succeed either way. + if let Some(gpu) = body.get("gpu") { + assert!( + gpu["name"].as_str().is_some_and(|name| !name.is_empty()), + "a reported GPU carries its device name" + ); + assert!( + gpu["vram_total_bytes"].as_u64().expect("vram total") > 0, + "a reported GPU has VRAM" + ); + } +} + +#[tokio::test] +async fn admin_system_requires_bearer_auth() { + let temp = tempfile::TempDir::new().expect("tempdir"); + let addr = serve(system_config(temp.path())).await; + let http = reqwest::Client::new(); + + let unauthenticated = http + .get(format!("http://{addr}/admin/system")) + .send() + .await + .expect("the request sends"); + assert_eq!( + unauthenticated.status(), + reqwest::StatusCode::UNAUTHORIZED, + "a request without a bearer token is refused" + ); + + let wrong_key = http + .get(format!("http://{addr}/admin/system")) + .bearer_auth("wrong-token") + .send() + .await + .expect("the request sends"); + assert_eq!( + wrong_key.status(), + reqwest::StatusCode::UNAUTHORIZED, + "a request with the wrong bearer token is refused" + ); +} diff --git a/crates/gateway/app/src/system.rs b/crates/gateway/app/src/admin/walled/system.rs similarity index 65% rename from crates/gateway/app/src/system.rs rename to crates/gateway/app/src/admin/walled/system.rs index 727ea1a6a..6b22f0849 100644 --- a/crates/gateway/app/src/system.rs +++ b/crates/gateway/app/src/admin/walled/system.rs @@ -1,4 +1,4 @@ -//! The `GET /admin/system` route: live host metrics for the config UI's +//! The `GET /admin/system` route: live host metrics for the config UI's //! Settings > System cards - CPU, RAM, the artifact-cache drive, and the //! NVIDIA GPU when a driver is present. //! @@ -6,41 +6,34 @@ //! process-wide `sysinfo::System` and primes it on first use; every later //! request reports the change since the previous poll (the UI polls every //! 5s). Sampling reads OS counters and the first call sleeps one CPU-update -//! interval, so it runs inside `tokio::task::spawn_blocking` like every -//! store operation (Amendment D). +//! interval, so it goes through [`crate::error::blocking`] like every store +//! operation (Amendment D). use std::fmt; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex, PoisonError}; -use axum::Json; use axum::extract::State; +use axum::http::Method; +use axum::routing::get; +use axum::{Json, Router}; use nvml_wrapper::Nvml; use serde::Serialize; use sysinfo::{CpuRefreshKind, Disks, MemoryRefreshKind, RefreshKind, System}; use crate::AppState; -use crate::auth::AuthedCaller; -use crate::error::GatewayError; +use crate::auth::LoopbackCaller; +use crate::error::{GatewayError, blocking}; +use crate::registry::RouteInfo; -/// Generic speech lifecycle facts included in Gateway operational status. -#[cfg(feature = "stt")] -#[derive(Debug, Clone, Copy, Serialize)] -pub(crate) struct SpeechSnapshot { - configured: bool, - ready: bool, - gpu: bool, -} +const SYSTEM: RouteInfo = RouteInfo::walled("/admin/system", &[Method::GET]); -#[cfg(feature = "stt")] -impl From for SpeechSnapshot { - fn from(status: gateway_stt::SpeechStatus) -> Self { - Self { - configured: status.configured(), - ready: status.ready(), - gpu: status.gpu(), - } - } +/// The host-metrics route, as the registry sees it. +pub(crate) const ROUTES: &[RouteInfo] = &[SYSTEM]; + +/// The host-metrics route. +pub(crate) fn routes() -> Router { + Router::new().route(SYSTEM.path, get(admin_system)) } /// One `GET /admin/system` snapshot. @@ -163,16 +156,15 @@ impl fmt::Debug for SystemSampler { /// rather than link time. pub(crate) async fn admin_system( State(state): State, - _caller: AuthedCaller, + _caller: LoopbackCaller, ) -> Result, GatewayError> { let cache_dir = state.config().await.local().cache_dir().map(str::to_owned); let metrics = Arc::clone(&state.metrics); - let snapshot = tokio::task::spawn_blocking(move || { + let snapshot = blocking(move || { let cache_root = disk_target(cache_dir.as_deref()); sample(&metrics, cache_root.as_deref()) }) - .await - .map_err(GatewayError::system_metrics)?; + .await?; Ok(Json(snapshot)) } @@ -298,130 +290,5 @@ fn all_logical_cores() -> usize { } #[cfg(test)] -mod tests { - use gateway_config::Config; - - use crate::test_support::serve; - - #[cfg(feature = "stt")] - #[test] - fn speech_snapshot_serializes_only_generic_facade_facts() { - let snapshot = super::SpeechSnapshot::from(gateway_stt::SpeechService::new().status()); - - assert_eq!( - serde_json::json!(snapshot), - serde_json::json!({ - "configured": false, - "ready": false, - "gpu": false, - }) - ); - } - - /// A minimal profile rooting the artifact cache at `cache_dir`. - fn system_config(cache_dir: &std::path::Path) -> Config { - Config::from_toml_str(&format!( - r#" -config-version = 0 - -[server] -bind = "127.0.0.1:0" -api_key = "test-token" -# Strict bearer auth: the tests below pin that a missing key is refused. -trust_loopback = false - -[local] -cache_dir = '{cache_dir}' -"#, - cache_dir = cache_dir.display(), - )) - .expect("the fixture profile parses") - } - - #[tokio::test] - async fn admin_system_reports_plausible_cpu_ram_and_disk() { - let temp = tempfile::TempDir::new().expect("tempdir"); - let addr = serve(system_config(temp.path())).await; - let response = reqwest::Client::new() - .get(format!("http://{addr}/admin/system")) - .bearer_auth("test-token") - .send() - .await - .expect("the request sends"); - assert_eq!(response.status(), reqwest::StatusCode::OK); - let body: serde_json::Value = response.json().await.expect("a JSON body"); - - let cpu = &body["cpu"]; - assert!( - cpu["logical_cores"].as_u64().expect("logical_cores") > 0, - "a running host has at least one logical core" - ); - assert!( - cpu["utilization_percent"].as_f64().is_some(), - "utilization is always a number" - ); - assert!( - cpu["frequency_mhz"].is_u64(), - "frequency is always a number, 0 when unknown" - ); - - let total_ram = body["ram"]["total_bytes"].as_u64().expect("ram total"); - let used_ram = body["ram"]["used_bytes"].as_u64().expect("ram used"); - assert!(total_ram > 0, "a running host has RAM installed"); - assert!( - used_ram > 0 && used_ram <= total_ram, - "used RAM is nonzero and within the installed total" - ); - - // The tempdir cache root sits on a mounted drive, so the disk card - // resolves on every platform the suite runs on. - let total_disk = body["disk"]["total_bytes"].as_u64().expect("disk total"); - let used_disk = body["disk"]["used_bytes"].as_u64().expect("disk used"); - assert!(total_disk > 0, "the cache drive has a capacity"); - assert!(used_disk <= total_disk, "usage cannot exceed capacity"); - - // GPU is genuinely optional: absent on hosts without an NVIDIA - // driver (CI), present with a name and a nonzero VRAM total where - // NVML loads. The endpoint must succeed either way. - if let Some(gpu) = body.get("gpu") { - assert!( - gpu["name"].as_str().is_some_and(|name| !name.is_empty()), - "a reported GPU carries its device name" - ); - assert!( - gpu["vram_total_bytes"].as_u64().expect("vram total") > 0, - "a reported GPU has VRAM" - ); - } - } - - #[tokio::test] - async fn admin_system_requires_bearer_auth() { - let temp = tempfile::TempDir::new().expect("tempdir"); - let addr = serve(system_config(temp.path())).await; - let http = reqwest::Client::new(); - - let unauthenticated = http - .get(format!("http://{addr}/admin/system")) - .send() - .await - .expect("the request sends"); - assert_eq!( - unauthenticated.status(), - reqwest::StatusCode::UNAUTHORIZED, - "a request without a bearer token is refused" - ); - - let wrong_key = http - .get(format!("http://{addr}/admin/system")) - .bearer_auth("wrong-token") - .send() - .await - .expect("the request sends"); - assert_eq!( - wrong_key.status(), - reqwest::StatusCode::UNAUTHORIZED, - "a request with the wrong bearer token is refused" - ); - } -} +#[path = "system-tests.rs"] +mod tests; diff --git a/crates/gateway/app/src/api_error.rs b/crates/gateway/app/src/api_error.rs index 085340584..12b1b3dda 100644 --- a/crates/gateway/app/src/api_error.rs +++ b/crates/gateway/app/src/api_error.rs @@ -18,15 +18,14 @@ use gateway_config::ConfigError; /// use gateway::{ProfileName, ServeOptions, StartupErrorKind, run}; /// use std::path::PathBuf; /// -/// # fn demo() { /// let options = ServeOptions::new( /// Some(PathBuf::from("/etc/promptforge/gateway.toml")), -/// ProfileName::parse("dev").unwrap(), +/// ProfileName::parse("dev")?, /// ); /// if let Err(err) = run(&options) { /// assert!(matches!(err.kind(), StartupErrorKind::Config | StartupErrorKind::Bind)); /// } -/// # } +/// # Ok::<(), Box>(()) /// ``` #[non_exhaustive] pub struct StartupError(StartupRepr); @@ -59,7 +58,7 @@ enum StartupRepr { Boot(#[source] crate::boot::BootError), #[error("local provisioning error")] Provisioning(#[source] Box), - #[error("failed to bind the listener")] + #[error("bind the listener")] Bind(#[source] std::io::Error), #[error("gateway thread error")] Thread(#[source] std::io::Error), @@ -68,7 +67,7 @@ enum StartupRepr { } impl StartupError { - /// Classify this failure without matching a private representation. + /// Classifies this failure without matching a private representation. #[must_use] pub fn kind(&self) -> StartupErrorKind { match self.0 { diff --git a/crates/gateway/app/src/auth-loopback-tests.rs b/crates/gateway/app/src/auth-loopback-tests.rs new file mode 100644 index 000000000..3205c42d3 --- /dev/null +++ b/crates/gateway/app/src/auth-loopback-tests.rs @@ -0,0 +1,99 @@ +//! [`LoopbackCaller`] on its own, through a one-route router with no wall +//! in front of it: the extractor refuses a LAN or peerless caller with the +//! wall's bare 403 before auth runs, and admits a loopback caller only +//! when [`check_auth`] does. + +use std::net::SocketAddr; + +use axum::Router; +use axum::body::Body; +use axum::extract::ConnectInfo; +use axum::http::header::AUTHORIZATION; +use axum::http::{Request, StatusCode}; +use axum::routing::get; +use tower::ServiceExt; + +use super::LoopbackCaller; +use crate::AppState; +use crate::test_support::loopback_state; + +const LOOPBACK: &str = "127.0.0.1:50000"; +const LAN: &str = "198.51.100.7:44821"; + +async fn walled_handler(_caller: LoopbackCaller) -> StatusCode { + StatusCode::NO_CONTENT +} + +/// A router mounting the handler with no loopback wall, so the extractor +/// is the only thing standing between the caller and the handler. +fn unwalled_router(state: AppState) -> Router { + Router::new() + .route("/walled", get(walled_handler)) + .with_state(state) +} + +async fn send(state: &AppState, peer: Option<&str>, authorization: Option<&str>) -> StatusCode { + let mut builder = Request::builder().uri("/walled"); + if let Some(authorization) = authorization { + builder = builder.header(AUTHORIZATION, authorization); + } + let mut request = builder + .body(Body::empty()) + .expect("static request parts are valid"); + if let Some(peer) = peer { + let peer: SocketAddr = peer.parse().expect("a socket address"); + request.extensions_mut().insert(ConnectInfo(peer)); + } + unwalled_router(state.clone()) + .oneshot(request) + .await + .expect("the router is infallible") + .status() +} + +#[tokio::test] +async fn a_loopback_peer_with_the_key_is_admitted() { + let state = loopback_state(Some(false)); + assert_eq!( + send(&state, Some(LOOPBACK), Some("Bearer test-token")).await, + StatusCode::NO_CONTENT + ); +} + +#[tokio::test] +async fn a_lan_peer_is_refused_with_the_walls_403_even_with_the_key() { + let state = loopback_state(Some(false)); + assert_eq!( + send(&state, Some(LAN), Some("Bearer test-token")).await, + StatusCode::FORBIDDEN, + "the tier check comes before auth and does not care about the credential" + ); +} + +#[tokio::test] +async fn a_peerless_request_fails_closed_with_403() { + let state = loopback_state(Some(false)); + assert_eq!( + send(&state, None, Some("Bearer test-token")).await, + StatusCode::FORBIDDEN + ); +} + +#[tokio::test] +async fn a_loopback_peer_without_a_credential_is_refused_by_auth_under_strict_mode() { + let state = loopback_state(Some(false)); + assert_eq!( + send(&state, Some(LOOPBACK), None).await, + StatusCode::UNAUTHORIZED, + "past the tier check, the ordinary auth rules decide" + ); +} + +#[tokio::test] +async fn a_loopback_peer_without_a_credential_is_admitted_under_loopback_trust() { + let state = loopback_state(Some(true)); + assert_eq!( + send(&state, Some(LOOPBACK), None).await, + StatusCode::NO_CONTENT + ); +} diff --git a/crates/gateway/app/src/auth-primitives-tests.rs b/crates/gateway/app/src/auth-primitives-tests.rs new file mode 100644 index 000000000..e5720dc46 --- /dev/null +++ b/crates/gateway/app/src/auth-primitives-tests.rs @@ -0,0 +1,259 @@ +use axum::http::HeaderMap; +use axum::http::header::{AUTHORIZATION, COOKIE}; +use gateway_config::Config; + +use super::{ + AUTH_COOKIE, SEC_FETCH_SITE, auth_url, fetch_metadata_allows_ambient, + fetch_metadata_allows_cookie, hex_decode, presented_cookie_proof, session_token, +}; +use crate::AppState; +use crate::auth::Caller; +use crate::test_support::app_state; + +/// A caller with no recorded peer address: the cookie rules are +/// exercised on their own, with loopback trust out of reach. +fn peerless(headers: HeaderMap) -> Caller { + Caller::new(headers, None) +} + +#[test] +fn the_auth_url_targets_the_one_time_handoff() { + assert_eq!( + auth_url("http://127.0.0.1:8081", "abc123"), + "http://127.0.0.1:8081/auth?key=abc123" + ); +} + +#[test] +fn the_auth_url_percent_encodes_a_configured_key() { + // The WHATWG urlencoded byte serializer encodes space as `+`; + // serde_urlencoded decodes it back. + assert_eq!( + auth_url("http://127.0.0.1:8081", "a&b=c d"), + "http://127.0.0.1:8081/auth?key=a%26b%3Dc+d", + "a configured key with query-special characters survives the handoff" + ); +} + +/// Hex-encodes as the route's `hex_encode` does; that encoder is +/// compiled only with the config surface, while these cookie-auth +/// tests run in every build. `hex_decode_round_trips_through_the_encoder` +/// pins the codec pair, beside the encoder; nothing pins this duplicate +/// to either, and `hex_decode` reads both cases, so keep the lowercase +/// two-digit output in step with the encoder by hand. +fn hex(bytes: &[u8]) -> String { + use std::fmt::Write as _; + let mut out = String::with_capacity(bytes.len() * 2); + for byte in bytes { + let _ = write!(out, "{byte:02x}"); + } + out +} + +/// The cookie header value the `/auth` route mints for `state`'s +/// salt and `key`, as the browser would present it back. +fn minted_cookie(state: &AppState, key: &str) -> String { + format!( + "{AUTH_COOKIE}={}", + hex(&session_token(&state.handoff_salt, key.as_bytes())) + ) +} + +/// A state whose configured bearer key is `test-token`. +fn test_token_state() -> AppState { + let config = Config::from_toml_str( + "config-version = 0\n\ + [server]\nbind = \"127.0.0.1:0\"\napi_key = \"test-token\"\n", + ) + .expect("config parses"); + app_state(config, None) +} + +/// Headers presenting `cookie` from a same-origin browser page. +fn same_origin_with(cookie: &str) -> HeaderMap { + HeaderMap::from_iter([ + (COOKIE, cookie.parse().expect("a header value")), + ( + SEC_FETCH_SITE, + "same-origin".parse().expect("a header value"), + ), + ]) +} + +#[test] +fn ambient_fetch_metadata_admits_absent_same_origin_and_none_only() { + let with = |site: &str| { + HeaderMap::from_iter([(SEC_FETCH_SITE, site.parse().expect("a header value"))]) + }; + assert!( + fetch_metadata_allows_ambient(&HeaderMap::new()), + "a non-browser client sends no Sec-Fetch-Site" + ); + assert!( + !fetch_metadata_allows_cookie(&HeaderMap::new()), + "the cookie rule stays strict: absent metadata is refused there" + ); + for site in ["same-origin", "none"] { + assert!(fetch_metadata_allows_ambient(&with(site)), "{site}"); + } + for site in ["cross-site", "same-site", "garbage", ""] { + assert!(!fetch_metadata_allows_ambient(&with(site)), "{site:?}"); + } +} + +#[test] +fn the_cookie_parses_among_others() { + let headers = HeaderMap::from_iter([( + COOKIE, + format!("session=abc; {AUTH_COOKIE}=746573742d746f6b656e; theme=dark") + .parse() + .expect("a header value"), + )]); + assert_eq!( + presented_cookie_proof(&headers).as_deref(), + Some(b"test-token".as_slice()) + ); +} + +#[test] +fn malformed_cookies_present_nothing() { + for cookie in [ + "promptforge-gateway-session=zz", // not hex + "promptforge-gateway-session=abc", // odd length + "other=746573742d746f6b656e", // the wrong name + "promptforge-gateway-session", // no value at all + ] { + let headers = HeaderMap::from_iter([(COOKIE, cookie.parse().expect("a header value"))]); + assert_eq!(presented_cookie_proof(&headers), None, "{cookie}"); + } + assert_eq!(presented_cookie_proof(&HeaderMap::new()), None); +} + +#[test] +fn hex_decode_reads_well_formed_hex() { + assert_eq!(hex_decode("").as_deref(), Some(b"".as_slice())); + assert_eq!( + hex_decode("00ff40").as_deref(), + Some(&[0x00, 0xff, 0x40][..]) + ); +} + +#[tokio::test] +async fn check_auth_accepts_the_cookie_as_the_bearer_keys_ambient_form() { + let state = test_token_state(); + let headers = same_origin_with(&minted_cookie(&state, "test-token")); + assert!( + crate::auth::check_auth(&state, &peerless(headers)) + .await + .is_ok() + ); + + // A wrong cookie and a wrong bearer both stay refused. + let wrong = same_origin_with(&format!("{AUTH_COOKIE}={}", hex(b"wrong"))); + assert!( + crate::auth::check_auth(&state, &peerless(wrong)) + .await + .is_err() + ); + let both = HeaderMap::from_iter([ + ( + AUTHORIZATION, + "Bearer wrong".parse().expect("a header value"), + ), + ( + COOKIE, + minted_cookie(&state, "test-token") + .parse() + .expect("a header value"), + ), + ( + SEC_FETCH_SITE, + "same-origin".parse().expect("a header value"), + ), + ]); + assert!( + crate::auth::check_auth(&state, &peerless(both)) + .await + .is_ok(), + "a valid cookie authenticates even alongside a wrong bearer header" + ); +} + +#[tokio::test] +async fn the_cookie_carries_a_session_proof_never_the_key() { + let state = test_token_state(); + // The key's own hex - what a key-carrying cookie would present - + // must not authenticate. + let bare = same_origin_with(&format!("{AUTH_COOKIE}={}", hex(b"test-token"))); + assert!( + crate::auth::check_auth(&state, &peerless(bare)) + .await + .is_err(), + "the cookie carries a derived proof, so the key itself is refused" + ); + // A proof minted under another process's salt is refused: a + // restart revokes every minted cookie. + let foreign = same_origin_with(&format!( + "{AUTH_COOKIE}={}", + hex(&session_token(&[0xAB; 32], b"test-token")) + )); + assert!( + crate::auth::check_auth(&state, &peerless(foreign)) + .await + .is_err(), + "a proof minted under another salt is refused" + ); +} + +#[tokio::test] +async fn the_cookie_path_requires_same_origin_fetch_metadata() { + let state = test_token_state(); + // A cross-origin rider on another loopback port is same-site + // (ports are not part of a site), so SameSite does not stop it; + // the fetch metadata it cannot strip does. + for site in ["same-site", "cross-site"] { + let headers = HeaderMap::from_iter([ + ( + COOKIE, + minted_cookie(&state, "test-token") + .parse() + .expect("a header value"), + ), + (SEC_FETCH_SITE, site.parse().expect("a header value")), + ]); + assert!( + crate::auth::check_auth(&state, &peerless(headers)) + .await + .is_err(), + "Sec-Fetch-Site: {site} marks a cross-origin rider" + ); + } + // No metadata at all: non-browser clients authenticate with the + // bearer header, never the cookie. + let bare = HeaderMap::from_iter([( + COOKIE, + minted_cookie(&state, "test-token") + .parse() + .expect("a header value"), + )]); + assert!( + crate::auth::check_auth(&state, &peerless(bare)) + .await + .is_err() + ); + // `none` is the user-driven navigation case and is admitted. + let navigation = HeaderMap::from_iter([ + ( + COOKIE, + minted_cookie(&state, "test-token") + .parse() + .expect("a header value"), + ), + (SEC_FETCH_SITE, "none".parse().expect("a header value")), + ]); + assert!( + crate::auth::check_auth(&state, &peerless(navigation)) + .await + .is_ok() + ); +} diff --git a/crates/gateway/app/src/auth-primitives.rs b/crates/gateway/app/src/auth-primitives.rs new file mode 100644 index 000000000..9677dc4e9 --- /dev/null +++ b/crates/gateway/app/src/auth-primitives.rs @@ -0,0 +1,130 @@ +//! The ambient-credential primitives [`check_auth`](super::check_auth) +//! reads: the handoff cookie's name, the session proof it carries, the +//! Fetch Metadata rules that gate ambient access, and the hex codec +//! between them. +//! +//! These exist in every build. The `/auth` route that mints the cookie +//! ships only with the config surface, but the cookie it minted is +//! accepted wherever bearer auth is, and the Fetch Metadata rules also +//! gate keyless loopback trust, which has no config surface at all. So +//! they sit beside the auth rules that read them rather than inside the +//! walled route module that writes them. +//! +//! The cookie never carries the key. Cookies are not port-isolated (RFC +//! 6265), so every local server the browser visits on the same address +//! receives them, and a key-carrying cookie would hand any local process +//! the gateway discovery file's long-term secret on a single navigation. +//! The value is instead the hex of a session proof - SHA-256 over a +//! process-lifetime random salt and the live key - so a harvested cookie +//! authenticates only until a restart or key rotation and reveals +//! nothing. + +use axum::http::header::COOKIE; +use axum::http::{HeaderMap, HeaderName}; + +/// The cookie carrying the session proof for browser sessions. +pub(crate) const AUTH_COOKIE: &str = "promptforge-gateway-session"; + +/// The `Sec-Fetch-Site` header name; the locked `http` crate carries no +/// constant for it. +const SEC_FETCH_SITE: HeaderName = HeaderName::from_static("sec-fetch-site"); + +/// The one-time browser handoff URL for opening the config SPA: +/// `GET /auth` validates the key, sets the session cookie, and redirects to +/// the key-free `/config/`, so the key never sits in browser history. The +/// tray's Settings item, the relaunch handoff, and `--print-url` all build +/// their URL here. The key is percent-encoded: a generated key is hex and +/// passes through unchanged, but a configured key can carry query-special +/// characters (`/auth` decodes through serde_urlencoded). +pub(crate) fn auth_url(base_url: &str, key: &str) -> String { + let key: String = url::form_urlencoded::byte_serialize(key.as_bytes()).collect(); + format!("{base_url}/auth?key={key}") +} + +/// Reads the handoff cookie's presented session proof, when the request +/// carries a well-formed one. +pub(crate) fn presented_cookie_proof(headers: &HeaderMap) -> Option> { + let header = headers.get(COOKIE)?.to_str().ok()?; + header.split(';').map(str::trim).find_map(|pair| { + let (name, value) = pair.split_once('=')?; + if name == AUTH_COOKIE { + hex_decode(value) + } else { + None + } + }) +} + +/// The session proof the cookie carries for `key` under `salt`: SHA-256 +/// over the process-lifetime salt and the live key. The proof, never the +/// key, crosses into the browser, so a harvested cookie authenticates only +/// until a restart or key rotation and reveals nothing about the key. +pub(crate) fn session_token(salt: &[u8; 32], key: &[u8]) -> [u8; 32] { + use sha2::{Digest as _, Sha256}; + let mut digest = Sha256::new(); + digest.update(salt); + digest.update(key); + digest.finalize().into() +} + +/// Whether the request's Fetch Metadata permits cookie authentication. +/// The cookie is ambient - no `Authorization` header to require - so a +/// cross-origin page on another loopback port could otherwise ride it +/// into state-changing routes: `SameSite=Lax` does not cover same-site +/// requests, since ports are not part of a site. Every supported browser +/// attaches `Sec-Fetch-Site` to page-initiated requests, and a page +/// cannot strip or forge it; bearer clients (the shell, the tray, +/// scripts) never take the cookie path. +pub(crate) fn fetch_metadata_allows_cookie(headers: &HeaderMap) -> bool { + matches!( + headers + .get(SEC_FETCH_SITE) + .and_then(|value| value.to_str().ok()), + Some("same-origin" | "none") + ) +} + +/// Whether the request's Fetch Metadata permits ambient, credential-free +/// access from a loopback peer. Unlike the cookie rule, an absent header +/// is admitted: non-browser clients (curl, the SDK, the workshop) never +/// send `Sec-Fetch-Site`, and they are exactly who keyless loopback is +/// for. A browser always sends it, so `cross-site` and `same-site` - a +/// page on any other origin riding the user's loopback peer into +/// `POST /admin/shutdown` - are refused, as is any value the header +/// grammar does not name. `same-origin` (the config SPA) and `none` (a +/// typed URL) pass. +pub(crate) fn fetch_metadata_allows_ambient(headers: &HeaderMap) -> bool { + match headers.get(SEC_FETCH_SITE) { + None => true, + Some(value) => matches!(value.to_str(), Ok("same-origin" | "none")), + } +} + +/// Hex-decodes a cookie value back to the presented key; `None` when the +/// value is not well-formed hex. +pub(crate) fn hex_decode(value: &str) -> Option> { + if !value.len().is_multiple_of(2) { + return None; + } + let mut out = Vec::with_capacity(value.len() / 2); + for pair in value.as_bytes().as_chunks::<2>().0 { + let hi = hex_digit(pair[0])?; + let lo = hex_digit(pair[1])?; + out.push(hi << 4 | lo); + } + Some(out) +} + +/// One lowercase-or-uppercase ASCII hex digit's value. +fn hex_digit(byte: u8) -> Option { + match byte { + b'0'..=b'9' => Some(byte - b'0'), + b'a'..=b'f' => Some(byte - b'a' + 10), + b'A'..=b'F' => Some(byte - b'A' + 10), + _ => None, + } +} + +#[cfg(test)] +#[path = "auth-primitives-tests.rs"] +mod tests; diff --git a/crates/gateway/app/src/auth.rs b/crates/gateway/app/src/auth.rs index 2ccc5ba6c..86485268c 100644 --- a/crates/gateway/app/src/auth.rs +++ b/crates/gateway/app/src/auth.rs @@ -12,16 +12,22 @@ use std::ops::Deref; use axum::extract::State; use axum::extract::{ConnectInfo, FromRequestParts}; use axum::http::HeaderMap; +use axum::http::StatusCode; use axum::http::header::AUTHORIZATION; #[cfg(feature = "stt")] use axum::http::header::ORIGIN; use axum::http::request::Parts; -#[cfg(feature = "stt")] use axum::response::{IntoResponse, Response}; use crate::AppState; use crate::error::GatewayError; -use crate::handoff; + +/// The ambient-credential primitives the auth rules read: the handoff +/// cookie, its session proof, and the Fetch Metadata gates. They ship in +/// every build, while the `/auth` route that mints the cookie ships only +/// with the config surface. +#[path = "auth-primitives.rs"] +pub(crate) mod primitives; /// The request headers plus the peer address, as [`check_auth`] /// needs them. @@ -111,10 +117,53 @@ impl FromRequestParts for AuthedCaller { } } +/// An [`AuthedCaller`] on a connection the server recorded as a loopback +/// peer: the extractor every handler in the walled admin tier takes. +/// +/// `build_router` already mounts that tier behind the shared loopback wall, +/// which refuses a non-loopback peer with a bare 403 before auth runs, so in +/// the assembled router this check never fires. The extractor is the +/// handler's own statement of the tier it belongs to, readable from its +/// signature, and it repeats the wall's question through the same +/// [`shared_loopback::is_loopback_peer`] predicate: a walled handler that +/// is ever mounted without the wall still refuses a LAN or peerless caller +/// with the wall's 403, and refuses before auth, in the wall's order. +pub(crate) struct LoopbackCaller(AuthedCaller); + +impl Deref for LoopbackCaller { + type Target = AuthedCaller; + + fn deref(&self) -> &AuthedCaller { + &self.0 + } +} + +impl FromRequestParts for LoopbackCaller { + type Rejection = Response; + + async fn from_request_parts( + parts: &mut Parts, + state: &AppState, + ) -> Result { + let Ok(caller) = Caller::from_request_parts(parts, state).await; + if !shared_loopback::is_loopback_peer(caller.peer()) { + return Err(StatusCode::FORBIDDEN.into_response()); + } + check_auth(state, &caller) + .await + .map_err(IntoResponse::into_response)?; + Ok(LoopbackCaller(AuthedCaller(caller))) + } +} + #[cfg(test)] #[path = "auth-tests.rs"] mod secret_tests; +#[cfg(test)] +#[path = "auth-loopback-tests.rs"] +mod loopback_caller_tests; + /// Authenticates the caller by any one of three rules, in this order. /// /// 1. A presented bearer token equals the live key. @@ -129,7 +178,7 @@ mod secret_tests; /// 3. Loopback trust: `[server] trust_loopback` is on, the server recorded /// a loopback peer for the connection, the request presents no /// `Authorization` header at all, and its Fetch Metadata permits -/// ambient access ([`handoff::fetch_metadata_allows_ambient`]). +/// ambient access ([`primitives::fetch_metadata_allows_ambient`]). /// /// Two edges of rule 3 are deliberate. A presented-but-wrong bearer is /// refused even on loopback: absence of credentials is what loopback @@ -147,11 +196,11 @@ pub(crate) async fn check_auth(state: &AppState, caller: &Caller) -> Result<(), if secret_eq(presented.as_bytes(), live.key.expose().as_bytes()) { return Ok(()); } - if let Some(cookie) = handoff::presented_cookie_proof(caller) - && handoff::fetch_metadata_allows_cookie(caller) + if let Some(cookie) = primitives::presented_cookie_proof(caller) + && primitives::fetch_metadata_allows_cookie(caller) && secret_eq( &cookie, - &handoff::session_token(&state.handoff_salt, live.key.expose().as_bytes()), + &primitives::session_token(&state.handoff_salt, live.key.expose().as_bytes()), ) { return Ok(()); @@ -159,7 +208,7 @@ pub(crate) async fn check_auth(state: &AppState, caller: &Caller) -> Result<(), if live.trust_loopback && authorization.is_none() && shared_loopback::is_loopback_peer(caller.peer()) - && handoff::fetch_metadata_allows_ambient(caller) + && primitives::fetch_metadata_allows_ambient(caller) { return Ok(()); } diff --git a/crates/gateway/app/src/boot-speech-tests.rs b/crates/gateway/app/src/boot-speech-tests.rs index d632bd77e..53c42f2d3 100644 --- a/crates/gateway/app/src/boot-speech-tests.rs +++ b/crates/gateway/app/src/boot-speech-tests.rs @@ -342,7 +342,7 @@ async fn a_failed_boot_speech_load_leaves_the_gateway_serving_without_speech() { .expect("the worker settles the command"); let chain = match &*outcome { Ok(profile) => panic!("the STT failure fails the boot command, got {profile}"), - Err(error) => crate::config_write::error_chain(error), + Err(error) => crate::error::error_chain(error), }; assert!(chain.contains("load-speech"), "the stage is named: {chain}"); assert!( @@ -376,7 +376,6 @@ async fn a_failed_boot_speech_load_leaves_the_gateway_serving_without_speech() { // A later switch persists its selection without a speech stage and // without retrying the spent initial load. - let mut events = state.hub.subscribe(); let switching = reqwest::Client::new() .post(format!("http://{addr}/admin/switch-profile")) .bearer_auth("test-token") @@ -386,8 +385,9 @@ async fn a_failed_boot_speech_load_leaves_the_gateway_serving_without_speech() { .expect("the switch request sends"); assert_eq!(switching.status(), reqwest::StatusCode::OK); assert!( - !begun_stages_any(&mut events).contains(&"loading-speech".to_owned()), - "a switch emits no speech stage" + !state.hub.current().busy, + "a switch runs no command, so no speech stage begins: {:?}", + state.hub.current() ); assert!(!state.speech.status().ready()); @@ -617,7 +617,6 @@ async fn an_apply_persisting_speech_changes_leaves_boot_speech_untouched() { .expect("the save sends"); assert_eq!(save.status(), reqwest::StatusCode::OK); - let mut events = state.hub.subscribe(); let apply = reqwest::Client::new() .post(format!("http://{addr}/admin/config-apply")) .bearer_auth("test-token") @@ -650,24 +649,12 @@ async fn an_apply_persisting_speech_changes_leaves_boot_speech_untouched() { let statuses = state.commands.active_command(); assert!(statuses.is_none(), "the apply command settled"); assert!( - !begun_stages_any(&mut events).contains(&"loading-speech".to_owned()), - "the apply emits no speech stage" + !state.hub.current().busy, + "the settled apply left no activity behind: {:?}", + state.hub.current() ); state.commands.shutdown(); worker.await.expect("the worker exits on shutdown"); state.speech.shutdown(); } - -/// Labels of every begun progress event, across operations. -fn begun_stages_any( - events: &mut tokio::sync::broadcast::Receiver, -) -> Vec { - let mut labels = Vec::new(); - while let Ok(event) = events.try_recv() { - if matches!(event.state, shared_progress::EventState::Begun { .. }) { - labels.push(event.label.clone()); - } - } - labels -} diff --git a/crates/gateway/app/src/boot.rs b/crates/gateway/app/src/boot.rs index 93a644432..4b554114a 100644 --- a/crates/gateway/app/src/boot.rs +++ b/crates/gateway/app/src/boot.rs @@ -20,6 +20,16 @@ const CONFIG_FILE_NAME: &str = "gateway.toml"; /// The profile the generated default carries and selects. pub(crate) const DEFAULT_PROFILE: &str = "default"; +/// The release artifact the cloud provider model sheet downloads from. +pub(crate) const DEFAULT_SHEET_URL: &str = "https://github.com/cppalliance/promptforge-cloud-providers/releases/download/models/cloud-provider-models.json"; + +/// The environment override for the sheet URL, matching the repo's +/// `PROMPTFORGE_*` convention; there is no config-schema knob. +pub(crate) const SHEET_URL_ENV: &str = "PROMPTFORGE_MODELS_SHEET_URL"; + +/// The sheet cache file name inside the profile directory. +pub(crate) const CACHE_FILE_NAME: &str = "cloud-provider-models.json"; + /// The installer's STT choice for first-run generation. /// /// The NSIS components page records the choice as the `InstallSTT` DWORD diff --git a/crates/gateway/app/src/boot_load-tests.rs b/crates/gateway/app/src/boot_load-tests.rs index 5cf9175bb..dca7b77cf 100644 --- a/crates/gateway/app/src/boot_load-tests.rs +++ b/crates/gateway/app/src/boot_load-tests.rs @@ -27,50 +27,66 @@ fn name(profile: &str) -> ProfileName { #[tokio::test] async fn a_remote_only_profile_loads_without_touching_the_routing_table() { let state = state(); - let tree = state.hub.operation(); + let activity = std::sync::Arc::new(state.hub.begin("load-profile")); let token = CancellationToken::new(); - let outcome = super::load_local(&state, &name("remote"), &tree, &token).await; + let outcome = super::load_local(&state, &name("remote"), &activity, &token).await; assert!(outcome.is_ok(), "nothing local to load: {outcome:?}"); let live = state.live.read().await; assert!(live.routing.model("alpha-model").is_ok()); assert!(live.routing.model("alpha-local").is_err()); assert!(live.loading.is_empty()); + assert_eq!( + state.hub.current().text, + "Loading profile", + "the profile stage is the only text a remote-only load writes" + ); } -/// A name the catalog does not define fails the `loading-profile` leaf -/// and changes nothing. +/// A name the catalog does not define fails under the `"Loading profile"` +/// text and changes nothing. #[tokio::test] async fn an_undefined_profile_is_profile_not_found() { let state = state(); - let tree = state.hub.operation(); + let activity = std::sync::Arc::new(state.hub.begin("load-profile")); let token = CancellationToken::new(); - let outcome = super::load_local(&state, &name("ghost"), &tree, &token).await; + let outcome = super::load_local(&state, &name("ghost"), &activity, &token).await; assert!( matches!(&outcome, Err(GatewayError::ProfileNotFound(missing)) if missing == "ghost"), "the miss names the profile: {outcome:?}" ); assert!(state.live.read().await.loading.is_empty()); + assert_eq!(state.hub.current().text, "Loading profile"); } -/// A token fired before the load begins stops it before any leaf. +/// A token fired before the load begins stops it before any stage text. #[tokio::test] async fn a_pre_cancelled_load_changes_nothing() { let state = state(); - let tree = state.hub.operation(); + let activity = std::sync::Arc::new(state.hub.begin("load-profile")); let token = CancellationToken::new(); token.cancel(); - let outcome = super::load_local(&state, &name("alpha"), &tree, &token).await; + let outcome = super::load_local(&state, &name("alpha"), &activity, &token).await; assert!( matches!(outcome, Err(GatewayError::CommandCancelled(_))), "a fired token is explicit: {outcome:?}" ); assert!(state.live.read().await.loading.is_empty()); + assert_eq!( + state.hub.current().text, + "load-profile", + "the token check precedes the first stage, so no stage text is written" + ); + drop(activity); + assert!( + !state.hub.current().busy, + "the command's guard ends the activity" + ); } /// The commit routes the ready children beside the remote models, diff --git a/crates/gateway/app/src/boot_load.rs b/crates/gateway/app/src/boot_load.rs index 6d56c8658..8eb8e8f75 100644 --- a/crates/gateway/app/src/boot_load.rs +++ b/crates/gateway/app/src/boot_load.rs @@ -23,7 +23,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; use gateway_config::{Config, ProfileName}; -use shared_progress::ProgressTree; +use gateway_progress::Activity; use tokio_util::sync::CancellationToken; use crate::AppState; @@ -49,6 +49,11 @@ pub(crate) const STT_RUNTIME_UNAVAILABLE: &str = /// The `LoadProfile` body: loads `name`'s local members into the live /// runtime, then makes the process's one guarded STT load. /// +/// The command's activity is shared with the blocking stages, each of +/// which writes its own text (`"Loading profile"`, the artifact store's +/// download and verify lines, `"Starting {model}"`, the speech load's +/// stages); the guard drops with this function's return on every path. +/// /// Returns the profile name on success. A partial start (some children /// ready, others failed) commits the ready children and reports the rest /// through [`GatewayError::PartialStart`]; only a fully or partially @@ -59,15 +64,12 @@ pub(crate) const STT_RUNTIME_UNAVAILABLE: &str = pub(crate) async fn run( state: &AppState, name: ProfileName, - tree: ProgressTree, + activity: Activity, token: &CancellationToken, ) -> Result { let label = format!("load-profile: {name}"); - // The speech stage registers first (registration emits the stage's - // begun event) and reports nothing until the local load has settled. - #[cfg(feature = "stt")] - let speech = tree.register("loading-speech", 5.0); - let result = match load_local(state, &name, &tree, token).await { + let activity = Arc::new(activity); + let result = match load_local(state, &name, &activity, token).await { Ok(()) => Ok(name.to_string()), Err(_) if token.is_cancelled() => Err(GatewayError::CommandCancelled(label.clone())), Err(error) => Err(error), @@ -81,9 +83,12 @@ pub(crate) async fn run( Err(_) => false, }; if published { - load_speech(state, speech, token, &label).await?; + load_speech(state, &activity, token, &label).await?; } else { - speech.fail(); + tracing::warn!( + profile = %name, + "the local load did not publish; the speech load is skipped" + ); } } #[cfg(not(feature = "stt"))] @@ -95,13 +100,13 @@ pub(crate) async fn run( async fn load_local( state: &AppState, name: &ProfileName, - tree: &ProgressTree, + activity: &Arc, token: &CancellationToken, ) -> Result<(), GatewayError> { if token.is_cancelled() { return Err(cancelled(name)); } - let config = prepare(state, name, tree).await?; + let config = prepare(state, name, activity).await?; #[cfg(not(feature = "local"))] { // `prepare` refused any local member, so there is nothing to load. @@ -115,14 +120,14 @@ async fn load_local( } #[cfg(test)] state.park_at(crate::park::Phase::Download).await; - download_artifacts(&config, tree, token).await?; + download_artifacts(&config, activity, token).await?; if token.is_cancelled() { return Err(cancelled(name)); } publish_loading(state, &config).await; #[cfg(test)] state.park_at(crate::park::Phase::Spawn).await; - let (runtime, failures) = match spawn_children(&config, tree, token).await { + let (runtime, failures) = match spawn_children(&config, activity, token).await { Ok(outcome) => outcome, Err(error) => { state.live.write().await.loading.clear(); @@ -134,32 +139,27 @@ async fn load_local( } /// Resolves the profile's members from the live catalog under the -/// `loading-profile` leaf, refusing members this build cannot run. +/// `"Loading profile"` text, refusing members this build cannot run. async fn prepare( state: &AppState, name: &ProfileName, - tree: &ProgressTree, + activity: &Activity, ) -> Result { - let loading = tree.register("loading-profile", 1.0); + activity.set_text("Loading profile"); + tracing::info!(profile = %name, "loading profile"); let catalog = Arc::clone(&state.live.read().await.config); if !catalog .profiles() .iter() .any(|profile| profile.name() == name.as_str()) { - loading.fail(); return Err(GatewayError::ProfileNotFound(name.to_string())); } - let config = match catalog.select_profile(Some(name)) { - Ok(config) => config, - Err(error) => { - loading.fail(); - return Err(GatewayError::switch_failed("select-profile", error)); - } - }; + let config = catalog + .select_profile(Some(name)) + .map_err(|error| GatewayError::switch_failed("select-profile", error))?; #[cfg(not(feature = "local"))] if !config.local_models().is_empty() { - loading.fail(); return Err(GatewayError::switch_failed( "start-local", std::io::Error::other(LOCAL_MODELS_UNSUPPORTED), @@ -167,13 +167,11 @@ async fn prepare( } #[cfg(not(feature = "stt"))] if !config.stt_models().is_empty() { - loading.fail(); return Err(GatewayError::switch_failed( "start-stt", std::io::Error::other(STT_RUNTIME_UNAVAILABLE), )); } - loading.complete(); Ok(config) } @@ -188,22 +186,19 @@ fn cancelled(name: &ProfileName) -> GatewayError { #[cfg(feature = "local")] async fn download_artifacts( config: &Config, - tree: &ProgressTree, + activity: &Arc, token: &CancellationToken, ) -> Result<(), GatewayError> { - let downloading = tree.register("downloading-models", 5.0); + activity.set_text("Downloading models"); + tracing::info!("downloading local model artifacts"); let config = config.clone(); - let progress = downloading.clone(); + let progress = Arc::clone(activity); let worker_token = token.clone(); let result = tokio::task::spawn_blocking(move || { LocalRuntime::provision_artifacts_with_cancellation(&config, Some(&progress), &worker_token) }) .await; match result { - Ok(Ok(failures)) if failures.is_empty() => { - downloading.complete(); - Ok(()) - } Ok(Ok(failures)) => { for failure in &failures { tracing::warn!( @@ -212,17 +207,13 @@ async fn download_artifacts( "local model artifact did not provision; the start reports it" ); } - downloading.fail(); + if failures.is_empty() { + tracing::info!("local model artifacts are in the cache"); + } Ok(()) } - Ok(Err(error)) => { - downloading.fail(); - Err(GatewayError::switch_failed("download-models", error)) - } - Err(error) => { - downloading.fail(); - Err(GatewayError::switch_failed("download-models-task", error)) - } + Ok(Err(error)) => Err(GatewayError::switch_failed("download-models", error)), + Err(error) => Err(GatewayError::switch_failed("download-models-task", error)), } } @@ -246,12 +237,13 @@ async fn publish_loading(state: &AppState, config: &Config) { #[cfg(feature = "local")] async fn spawn_children( config: &Config, - tree: &ProgressTree, + activity: &Arc, token: &CancellationToken, ) -> Result<(LocalRuntime, Vec), GatewayError> { - let starting = tree.register("starting-models", 5.0); + activity.set_text("Starting models"); + tracing::info!("starting local models"); let start_config = config.clone(); - let start_progress = starting.clone(); + let start_progress = Arc::clone(activity); let start_token = token.clone(); let interrupted = Arc::new(AtomicBool::new(false)); let worker_interrupted = Arc::clone(&interrupted); @@ -278,17 +270,14 @@ async fn spawn_children( bridge.abort(); let outcome = match started { Ok(Ok(Ok(outcome))) => outcome, - Ok(Ok(Err(error))) => { - starting.fail(); - return Err(GatewayError::switch_failed("start-local", error)); - } - Ok(Err(join)) => { - starting.fail(); - return Err(GatewayError::switch_failed("start-local-task", join)); - } + Ok(Ok(Err(error))) => return Err(GatewayError::switch_failed("start-local", error)), + Ok(Err(join)) => return Err(GatewayError::switch_failed("start-local-task", join)), Err(_) => { interrupted.store(true, Ordering::Release); - starting.fail(); + tracing::error!( + deadline = ?SPAWN_TIMEOUT, + "local model startup exceeded the boot deadline; interrupting it" + ); return Err(GatewayError::switch_failed( "start-local-timeout", std::io::Error::new( @@ -299,10 +288,12 @@ async fn spawn_children( } }; let (runtime, failures) = outcome.into_parts(); - if failures.is_empty() { - starting.complete(); - } else { - starting.fail(); + for failure in &failures { + tracing::warn!( + model = failure.model(), + error = %failure.error(), + "local model did not start" + ); } Ok((runtime, failures)) } @@ -353,13 +344,15 @@ async fn commit( #[cfg(feature = "stt")] async fn load_speech( state: &AppState, - loading: shared_progress::ProgressHandle, + activity: &Arc, token: &CancellationToken, label: &str, ) -> Result<(), GatewayError> { + activity.set_text("Loading speech"); + tracing::info!("loading the speech runtime"); let service = state.speech.clone(); let config = state.live.read().await.config.as_ref().clone(); - let progress = loading.clone(); + let progress = Arc::clone(activity); let worker_token = token.clone(); let result = tokio::task::spawn_blocking(move || { service.load_initial(&config, Some(&progress), &worker_token) @@ -367,21 +360,12 @@ async fn load_speech( .await; match result { Ok(Ok(())) => { - loading.complete(); + tracing::info!("speech runtime loaded"); Ok(()) } - Ok(Err(_)) if token.is_cancelled() => { - loading.fail(); - Err(GatewayError::CommandCancelled(label.to_owned())) - } - Ok(Err(error)) => { - loading.fail(); - Err(GatewayError::switch_failed("load-speech", error)) - } - Err(join) => { - loading.fail(); - Err(GatewayError::switch_failed("load-speech-task", join)) - } + Ok(Err(_)) if token.is_cancelled() => Err(GatewayError::CommandCancelled(label.to_owned())), + Ok(Err(error)) => Err(GatewayError::switch_failed("load-speech", error)), + Err(join) => Err(GatewayError::switch_failed("load-speech-task", join)), } } diff --git a/crates/gateway/app/src/cache-tests.rs b/crates/gateway/app/src/cache-tests.rs new file mode 100644 index 000000000..70e7216eb --- /dev/null +++ b/crates/gateway/app/src/cache-tests.rs @@ -0,0 +1,179 @@ +use futures_util::StreamExt as _; +use gateway_progress::ProgressHub; + +use super::*; + +/// Serves `body` at `/model.bin` with an accurate Content-Length and +/// returns its URL. +async fn fake_file_server(body: &'static [u8]) -> String { + let app = axum::Router::new().route( + "/model.bin", + axum::routing::get(move || async move { + axum::response::Response::new(axum::body::Body::from(body)) + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("the fake server binds"); + let address = listener.local_addr().expect("the bound address"); + tokio::spawn(async move { + axum::serve(listener, app) + .await + .expect("the fake server serves"); + }); + format!("http://{address}/model.bin") +} + +/// Collects a response body's full text. +async fn body_text(response: Response) -> String { + let mut frames = response.into_body().into_data_stream(); + let mut text = String::new(); + while let Some(frame) = frames.next().await { + let frame = frame.expect("the stream errored"); + text.push_str(std::str::from_utf8(&frame).expect("SSE frames are UTF-8")); + } + text +} + +#[tokio::test] +async fn a_download_drives_the_hub_text_and_ends_its_activity_at_completion() { + let body = b"activity-visibility-fixture"; + let url = fake_file_server(body).await; + let temp = tempfile::TempDir::new().expect("tempdir"); + let root = temp.path().to_path_buf(); + let hub = Arc::new(ProgressHub::new()); + + let progress = Arc::new(ChannelProgress::new(hub.begin("model.bin"), "model.bin")); + assert_eq!( + hub.current(), + gateway_api_types::Progress { + busy: true, + text: "Downloading model.bin".to_owned(), + }, + "the reporter names the download before any byte arrives" + ); + + // The blocking reqwest client inside `BlobCache` cannot be built or + // dropped in async context, so the whole store lifecycle runs on the + // blocking pool, as it does in the route. + let reporter = Arc::clone(&progress); + tokio::task::spawn_blocking(move || { + let cache = BlobCache::new(root).expect("the cache opens"); + cache.download_to_cache(&url, None, reporter.as_ref()) + }) + .await + .expect("the download task joins") + .expect("the download succeeds"); + assert_eq!( + hub.current().text, + "Downloading model.bin 100%", + "the last byte drives the text to its final whole percent" + ); + assert_eq!( + progress.sample(), + (body.len() as u64, Some(body.len() as u64)), + "the byte counts stay alongside the text for the SSE payload" + ); + + drop(progress); + assert!( + !hub.current().busy, + "the reporter's drop ends the download's activity" + ); +} + +#[tokio::test] +async fn the_sse_stream_derives_from_byte_samples_and_ends_with_the_join_result() { + let body = b"sse-sample-derived-fixture"; + let url = fake_file_server(body).await; + let temp = tempfile::TempDir::new().expect("tempdir"); + let root = temp.path().to_path_buf(); + let hub = Arc::new(ProgressHub::new()); + + let progress = Arc::new(ChannelProgress::new(hub.begin("model.bin"), "model.bin")); + let rx = progress.subscribe(); + let join = tokio::task::spawn_blocking(move || { + let cache = BlobCache::new(root).expect("the cache opens"); + let result = cache.download_to_cache(&url, None, progress.as_ref()); + drop(progress); + result + }); + + let text = body_text(sse_response(rx, join)).await; + let events: Vec = text + .split("\n\n") + .filter(|block| !block.trim().is_empty()) + .map(|block| { + let data = block.trim().strip_prefix("data: ").expect("a data line"); + serde_json::from_str(data).expect("a data line is JSON") + }) + .collect(); + let (terminal, progress) = events.split_last().expect("the stream has events"); + assert_eq!(terminal["status"], "ready"); + let path = std::path::PathBuf::from(terminal["path"].as_str().expect("path")); + assert_eq!( + std::fs::read(&path).expect("read blob"), + body, + "the terminal event names the cached blob" + ); + assert!( + !progress.is_empty(), + "progress events precede the terminal event: {text}" + ); + let last = progress.last().expect("a progress event"); + assert_eq!(last["status"], "downloading"); + assert_eq!( + last["bytes"], + body.len() as u64, + "the final sample carries every byte: {text}" + ); + assert_eq!(last["total"], body.len() as u64); + assert!( + !hub.current().busy, + "the finished download released its activity" + ); +} + +#[tokio::test] +async fn the_sse_stream_emits_the_latest_sample_when_the_reporter_dropped_before_the_first_poll() { + // The download completes (and the reporter drops) before the + // stream is polled at all: the watch's closed channel must not + // hide the final byte sample, which precedes the terminal event. + let body = b"sse-late-poll-fixture"; + let url = fake_file_server(body).await; + let temp = tempfile::TempDir::new().expect("tempdir"); + let root = temp.path().to_path_buf(); + let hub = Arc::new(ProgressHub::new()); + + let progress = Arc::new(ChannelProgress::new(hub.begin("model.bin"), "model.bin")); + let rx = progress.subscribe(); + let result = tokio::task::spawn_blocking(move || { + let cache = BlobCache::new(root).expect("the cache opens"); + let result = cache.download_to_cache(&url, None, progress.as_ref()); + drop(progress); + result + }) + .await + .expect("the download task joins"); + let join = tokio::task::spawn_blocking(move || result); + + let text = body_text(sse_response(rx, join)).await; + let blocks: Vec<&str> = text + .split("\n\n") + .filter(|block| !block.trim().is_empty()) + .collect(); + assert_eq!( + blocks.len(), + 2, + "one final sample, then the terminal: {text}" + ); + assert!( + blocks[0].contains("\"status\":\"downloading\"") + && blocks[0].contains(&format!("\"bytes\":{}", body.len())), + "the latest sample is emitted even though the channel closed: {text}" + ); + assert!( + blocks[1].contains("\"status\":\"ready\""), + "the stream still ends with the join result's terminal event: {text}" + ); +} diff --git a/crates/gateway/app/src/cache.rs b/crates/gateway/app/src/cache.rs index 3feaf37ce..91b0e88bb 100644 --- a/crates/gateway/app/src/cache.rs +++ b/crates/gateway/app/src/cache.rs @@ -1,38 +1,52 @@ -//! The `/v1/cache` routes: bearer-authenticated on-demand blob downloads into +//! The `/v1/cache` routes: bearer-authenticated on-demand blob downloads into //! the operator cache, with sidecar-based listing and removal. //! //! The store is blocking filesystem plus a reqwest-blocking client, so every //! store operation runs inside `tokio::task::spawn_blocking` and never blocks -//! the executor (Amendment D). Each download attaches a small operation tree -//! to the process progress hub and reports bytes into its leaf; the SSE -//! response is a filtered view of the hub's events for that operation, with -//! intermediate samples lossy under backpressure, while the terminal -//! ready/error event is produced from the download task's join result and is -//! therefore never lost. +//! the executor (Amendment D). Each download begins an activity on the +//! process hub, whose text carries `"Downloading {name} {pct}%"` for the +//! status consumers, and keeps its own byte counts on a `watch` channel the +//! SSE response reads: intermediate samples coalesce under backpressure, +//! while the terminal ready/error event is produced from the download task's +//! join result and is therefore never lost. use std::convert::Infallible; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::{Arc, Mutex, PoisonError}; +use std::sync::Arc; -use axum::Json; use axum::body::Body; use axum::extract::{Path, State}; -use axum::http::HeaderValue; use axum::http::header::{CACHE_CONTROL, CONTENT_TYPE}; +use axum::http::{HeaderValue, Method}; use axum::response::{IntoResponse, Response}; +use axum::routing::{delete, get}; +use axum::{Json, Router}; +use gateway_progress::Activity; use serde::Deserialize; +use tokio::sync::watch; use tokio::task::JoinHandle; -use shared_progress::{EventState, OperationId, ProgressEvent, ProgressHandle}; - use crate::AppState; use crate::auth::AuthedCaller; -use crate::error::{GatewayError, WireJson}; +use crate::error::{GatewayError, WireJson, blocking}; use crate::local::artifacts::{ - DownloadProgress, TreeProgress, filename_from_url, parse_expected_digest, + DownloadProgress, PercentText, filename_from_url, parse_expected_digest, }; use crate::local::cache::{BlobCache, CacheEntry, CachedBlob}; use crate::local::{LocalError, resolve_cache_root}; +use crate::registry::RouteInfo; + +const CACHE: RouteInfo = RouteInfo::open("/v1/cache", &[Method::GET, Method::POST]); +const CACHE_ENTRY: RouteInfo = RouteInfo::open("/v1/cache/{sha256}", &[Method::DELETE]); + +/// The blob-cache routes, as the registry sees them. +pub(crate) const ROUTES: &[RouteInfo] = &[CACHE, CACHE_ENTRY]; + +/// The blob-cache routes. +pub(crate) fn routes() -> Router { + Router::new() + .route(CACHE.path, get(list_cache).post(post_cache)) + .route(CACHE_ENTRY.path, delete(delete_cache)) +} /// Opens the blob cache at the active profile's resolved cache root. fn open_cache(cache_dir: Option<&str>) -> Result { @@ -54,9 +68,8 @@ pub(crate) async fn list_cache( _caller: AuthedCaller, ) -> Result>, GatewayError> { let cache_dir = live_cache_dir(&state).await; - let entries = tokio::task::spawn_blocking(move || open_cache(cache_dir.as_deref())?.list()) - .await - .map_err(GatewayError::cache)? + let entries = blocking(move || open_cache(cache_dir.as_deref())?.list()) + .await? .map_err(GatewayError::cache)?; Ok(Json(entries)) } @@ -74,8 +87,10 @@ pub(crate) struct CacheRequest { /// usable filename segment, which is returned for the download's leaf label. /// Anything else is a 400, never a download attempt. fn validate_source(source: &str) -> Result { - let parsed = url::Url::parse(source).map_err(|_| { - GatewayError::MalformedRequest(format!("cache source `{source}` is not a valid URL")) + let parsed = url::Url::parse(source).map_err(|cause| { + GatewayError::MalformedRequest(format!( + "cache source `{source}` is not a valid URL: {cause}" + )) })?; if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none() { return Err(GatewayError::MalformedRequest(format!( @@ -112,13 +127,12 @@ pub(crate) async fn post_cache( // handed to the download task so the root is enforced exactly once. let lookup_source = source.clone(); let lookup_pin = expected.clone(); - let (cache, hit) = tokio::task::spawn_blocking(move || { + let (cache, hit) = blocking(move || { let cache = open_cache(cache_dir.as_deref())?; let hit = cache.lookup(&lookup_source, lookup_pin.as_deref())?; Ok::<_, LocalError>((cache, hit)) }) - .await - .map_err(GatewayError::cache)? + .await? .map_err(GatewayError::cache)?; if let Some(blob) = hit { @@ -129,22 +143,21 @@ pub(crate) async fn post_cache( .into_response()); } - // Subscribe before the tree attaches so no event of this download is - // missed; the response filters the hub stream to this download's - // operation. - let rx = state.hub.subscribe(); - let tree = state.hub.operation(); - let operation = tree.operation(); - let progress = Arc::new(ChannelProgress::new(tree.register(&label, 1.0))); - let reporter = Arc::clone(&progress); + // The download's activity is owned by the reporter, which the blocking + // task holds until the download ends: its drop ends the activity. + let progress = Arc::new(ChannelProgress::new(state.hub.begin(&label), &label)); + let rx = progress.subscribe(); + tracing::info!(source = %source, "cache download started"); let join = tokio::task::spawn_blocking(move || { - let result = cache.download_to_cache(&source, expected.as_deref(), reporter.as_ref()); - // The tree's Drop detaches it from the hub: the download's operation - // leaves snapshots and the event stream when the download ends. - drop(tree); + let result = cache.download_to_cache(&source, expected.as_deref(), progress.as_ref()); + match &result { + Ok(blob) => tracing::info!(path = %blob.path.display(), "cache download finished"), + Err(error) => tracing::warn!(%error, "cache download failed"), + } + drop(progress); result }); - Ok(sse_response(rx, operation, progress, join)) + Ok(sse_response(rx, join)) } /// `DELETE /v1/cache/{sha256}`: removes the blob and sidecar for a digest. @@ -161,11 +174,9 @@ pub(crate) async fn delete_cache( .map_err(|error| GatewayError::MalformedRequest(error.to_string()))?; let cache_dir = live_cache_dir(&state).await; let lookup = wanted.clone(); - let removed = - tokio::task::spawn_blocking(move || open_cache(cache_dir.as_deref())?.remove(&lookup)) - .await - .map_err(GatewayError::cache)? - .map_err(GatewayError::cache)?; + let removed = blocking(move || open_cache(cache_dir.as_deref())?.remove(&lookup)) + .await? + .map_err(GatewayError::cache)?; if !removed { return Err(GatewayError::CacheEntryNotFound(wanted)); } @@ -175,127 +186,123 @@ pub(crate) async fn delete_cache( }))) } -/// [`DownloadProgress`] for one cache download: reports byte counts into the -/// download's tree leaf and keeps the raw counts alongside, because the -/// tree's events carry fractions while the SSE payload carries bytes. +/// The byte counts of one cache download, as the SSE payload carries them. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +struct Sample { + downloaded: u64, + total: Option, +} + +/// [`DownloadProgress`] for one cache download: publishes the raw byte +/// counts on a `watch` channel for the SSE response and formats +/// `"Downloading {name} {pct}%"` into the download's activity for the +/// status consumers. Owns the activity, so its drop ends it. struct ChannelProgress { - leaf: TreeProgress, - downloaded: AtomicU64, - total: Mutex>, + activity: Activity, + text: PercentText, + samples: watch::Sender, } impl ChannelProgress { - fn new(handle: ProgressHandle) -> Self { + fn new(activity: Activity, name: &str) -> Self { + let text = PercentText::new("Downloading", name); + activity.set_text(text.label()); + let (samples, _rx) = watch::channel(Sample::default()); Self { - leaf: TreeProgress::new(handle), - downloaded: AtomicU64::new(0), - total: Mutex::new(None), + activity, + text, + samples, } } - /// The current `(downloaded, total)` counts for the SSE payload. + /// A receiver over the byte samples. `watch::Sender::subscribe` marks + /// the current sample seen, so the SSE stream never opens with the + /// zero-byte sample the channel was created with. + fn subscribe(&self) -> watch::Receiver { + self.samples.subscribe() + } + + /// The current `(downloaded, total)` counts, as the SSE payload reads + /// them off the receiver. + #[cfg(test)] fn sample(&self) -> (u64, Option) { - // The guarded value is plain data with no panic path; a poisoned lock - // (only possible if a panic landed mid-store) recovers the value. - ( - self.downloaded.load(Ordering::Relaxed), - *self.total.lock().unwrap_or_else(PoisonError::into_inner), - ) + let sample = *self.samples.borrow(); + (sample.downloaded, sample.total) } } impl DownloadProgress for ChannelProgress { fn set_len(&self, total: Option) { - *self.total.lock().unwrap_or_else(PoisonError::into_inner) = total; - self.leaf.set_len(total); + self.samples.send_modify(|sample| sample.total = total); } fn inc(&self, n: u64) { - self.downloaded.fetch_add(n, Ordering::Relaxed); - self.leaf.inc(n); - } - - fn finish(&self) { - self.leaf.finish(); - } - - fn abandon(&self) { - self.leaf.abandon(); + let mut published = Sample::default(); + self.samples.send_modify(|sample| { + sample.downloaded = sample.downloaded.saturating_add(n); + published = *sample; + }); + if let Some(total) = published.total + && total > 0 + { + self.text + .report(&self.activity, published.downloaded, total); + } } } -/// Builds the SSE response: the hub's event stream filtered to this -/// download's operation, each `Updated` re-emitted as the +/// Builds the SSE response: each byte-count change re-emitted as the /// `{"status": "downloading", ...}` event the route has always carried, then /// the terminal event from the download task's join result, so the outcome -/// can never be lost to broadcast lag. +/// can never be lost. /// -/// The download task emits every event before it returns, so once the join -/// handle resolves the remaining events are already queued on the receiver -/// and the latest sample is drained ahead of the terminal event. A client -/// disconnect drops the response body and the receiver; the blocking -/// download itself runs to completion (its staging cleanup still applies) -/// and a later POST for the same source then hits the cache. +/// The download task publishes every sample before it returns, so once the +/// join handle resolves the latest sample is already on the receiver and is +/// drained ahead of the terminal event. A client disconnect drops the +/// response body and the receiver; the blocking download itself runs to +/// completion (its staging cleanup still applies) and a later POST for the +/// same source then hits the cache. fn sse_response( - rx: tokio::sync::broadcast::Receiver, - operation: OperationId, - progress: Arc, + rx: watch::Receiver, join: JoinHandle>, ) -> Response { let stream = futures_util::stream::unfold( - (rx, join, std::collections::VecDeque::new(), false), - move |(mut rx, mut join, mut pending, mut done)| { - let progress = Arc::clone(&progress); - async move { - loop { - if let Some(line) = pending.pop_front() { - return Some((Ok::<_, Infallible>(line), (rx, join, pending, done))); - } - if done { - return None; - } - let result = loop { - tokio::select! { - received = rx.recv() => match received { - Ok(event) => { - if event.operation == operation - && matches!(event.state, EventState::Updated { .. }) - { - return Some(( - Ok(downloading_line(&progress)), - (rx, join, pending, done), - )); - } - } - Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => { - tracing::debug!(skipped, "cache progress subscriber lagged; events dropped"); - } - // The hub lives in `AppState` for the process - // lifetime, so its sender never closes first; the - // join result still carries the outcome if it did. - Err(tokio::sync::broadcast::error::RecvError::Closed) => { - break (&mut join).await; - } - }, - result = &mut join => break result, - } - }; - // Samples are lossy, so only the latest queued one is - // worth emitting ahead of the terminal event. - let mut updated = false; - while let Ok(event) = rx.try_recv() { - if event.operation == operation - && matches!(event.state, EventState::Updated { .. }) - { - updated = true; + (rx, join, std::collections::VecDeque::new(), false, None), + |(mut rx, mut join, mut pending, mut done, mut emitted)| async move { + loop { + if let Some(line) = pending.pop_front() { + return Some(( + Ok::<_, Infallible>(line), + (rx, join, pending, done, emitted), + )); + } + if done { + return None; + } + let result = tokio::select! { + changed = rx.changed() => match changed { + Ok(()) => { + let sample = *rx.borrow_and_update(); + emitted = Some(sample); + return Some(( + Ok(downloading_line(sample)), + (rx, join, pending, done, emitted), + )); } - } - if updated { - pending.push_back(downloading_line(&progress)); - } - done = true; - pending.push_back(terminal_line(result)); + // The reporter dropped with the download task; + // the join result carries the outcome. + Err(_) => (&mut join).await, + }, + result = &mut join => result, + }; + // Samples coalesce, so only the latest one, when it was not + // yet emitted, is worth sending ahead of the terminal event. + let latest = *rx.borrow_and_update(); + if emitted != Some(latest) && latest != Sample::default() { + pending.push_back(downloading_line(latest)); } + done = true; + pending.push_back(terminal_line(result)); } }, ); @@ -306,9 +313,12 @@ fn sse_response( response } -/// Maps the reporter's counters to the route's downloading event. -fn downloading_line(progress: &ChannelProgress) -> String { - let (bytes, total) = progress.sample(); +/// Maps a byte sample to the route's downloading event. +fn downloading_line(sample: Sample) -> String { + let Sample { + downloaded: bytes, + total, + } = sample; format!( "data: {}\n\n", serde_json::json!({ @@ -339,180 +349,5 @@ fn terminal_line(result: Result, tokio::task::Joi } #[cfg(test)] -mod tests { - // Fractions are fixed-point millionths, so equality comparisons are exact. - #![expect(clippy::float_cmp, reason = "fixed-point fractions compare exactly")] - - use futures_util::StreamExt as _; - use shared_progress::ProgressHub; - - use super::*; - - /// Serves `body` at `/model.bin` with an accurate Content-Length and - /// returns its URL. - async fn fake_file_server(body: &'static [u8]) -> String { - let app = axum::Router::new().route( - "/model.bin", - axum::routing::get(move || async move { - axum::response::Response::new(axum::body::Body::from(body)) - }), - ); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .expect("the fake server binds"); - let address = listener.local_addr().expect("the bound address"); - tokio::spawn(async move { - axum::serve(listener, app) - .await - .expect("the fake server serves"); - }); - format!("http://{address}/model.bin") - } - - /// Collects a response body's full text. - async fn body_text(response: Response) -> String { - let mut frames = response.into_body().into_data_stream(); - let mut text = String::new(); - while let Some(frame) = frames.next().await { - let frame = frame.expect("the stream errored"); - text.push_str(std::str::from_utf8(&frame).expect("SSE frames are UTF-8")); - } - text - } - - #[tokio::test] - async fn a_downloads_tree_appears_in_hub_snapshots_and_detaches_at_completion() { - let body = b"tree-visibility-fixture"; - let url = fake_file_server(body).await; - let temp = tempfile::TempDir::new().expect("tempdir"); - let root = temp.path().to_path_buf(); - let hub = Arc::new(ProgressHub::new()); - - let tree = hub.operation(); - let progress = Arc::new(ChannelProgress::new(tree.register("model.bin", 1.0))); - let snapshot = hub.snapshot(); - assert_eq!(snapshot.len(), 1, "the download's tree is attached"); - assert_eq!(snapshot[0].nodes[0].label, "model.bin"); - assert_eq!(snapshot[0].nodes[0].fraction, 0.0); - - // The blocking reqwest client inside `BlobCache` cannot be built or - // dropped in async context, so the whole store lifecycle runs on the - // blocking pool, as it does in the route. - let reporter = Arc::clone(&progress); - tokio::task::spawn_blocking(move || { - let cache = BlobCache::new(root).expect("the cache opens"); - cache.download_to_cache(&url, None, reporter.as_ref()) - }) - .await - .expect("the download task joins") - .expect("the download succeeds"); - let snapshot = hub.snapshot(); - assert_eq!( - snapshot[0].nodes[0].fraction, 1.0, - "finish drives the leaf to 1.0" - ); - assert_eq!( - progress.sample(), - (body.len() as u64, Some(body.len() as u64)) - ); - - drop(progress); - drop(tree); - assert!( - hub.snapshot().is_empty(), - "the tree detaches from the hub at completion" - ); - } - - #[tokio::test] - async fn the_sse_stream_derives_from_tree_events_and_ends_with_the_join_result() { - let body = b"sse-tree-derived-fixture"; - let url = fake_file_server(body).await; - let temp = tempfile::TempDir::new().expect("tempdir"); - let root = temp.path().to_path_buf(); - let hub = Arc::new(ProgressHub::new()); - - let rx = hub.subscribe(); - let tree = hub.operation(); - let operation = tree.operation(); - let progress = Arc::new(ChannelProgress::new(tree.register("model.bin", 1.0))); - let reporter = Arc::clone(&progress); - let join = tokio::task::spawn_blocking(move || { - let cache = BlobCache::new(root).expect("the cache opens"); - let result = cache.download_to_cache(&url, None, reporter.as_ref()); - drop(tree); - result - }); - - let text = body_text(sse_response(rx, operation, progress, join)).await; - let events: Vec = text - .split("\n\n") - .filter(|block| !block.trim().is_empty()) - .map(|block| { - let data = block.trim().strip_prefix("data: ").expect("a data line"); - serde_json::from_str(data).expect("a data line is JSON") - }) - .collect(); - let (terminal, progress) = events.split_last().expect("the stream has events"); - assert_eq!(terminal["status"], "ready"); - let path = std::path::PathBuf::from(terminal["path"].as_str().expect("path")); - assert_eq!( - std::fs::read(&path).expect("read blob"), - body, - "the terminal event names the cached blob" - ); - assert!( - !progress.is_empty(), - "progress events precede the terminal event: {text}" - ); - let last = progress.last().expect("a progress event"); - assert_eq!(last["status"], "downloading"); - assert_eq!( - last["bytes"], - body.len() as u64, - "the final sample carries every byte: {text}" - ); - assert_eq!(last["total"], body.len() as u64); - } - - #[tokio::test] - async fn the_sse_stream_survives_broadcast_lag_and_ends_with_the_join_result() { - let body = b"sse-lag-fixture"; - let url = fake_file_server(body).await; - let temp = tempfile::TempDir::new().expect("tempdir"); - let root = temp.path().to_path_buf(); - let hub = Arc::new(ProgressHub::new()); - - let rx = hub.subscribe(); - let tree = hub.operation(); - let operation = tree.operation(); - let progress = Arc::new(ChannelProgress::new(tree.register("model.bin", 1.0))); - - // Overflow the hub's 1024-event ring before the stream's first poll, - // so the receiver lags: the Lagged arm must drop the skipped events - // and carry on rather than ending the stream. - let noise = hub.operation(); - let _noise_leaves: Vec<_> = (0..1100) - .map(|index| noise.register(&format!("noise-{index}"), 1.0)) - .collect(); - - let reporter = Arc::clone(&progress); - let join = tokio::task::spawn_blocking(move || { - let cache = BlobCache::new(root).expect("the cache opens"); - let result = cache.download_to_cache(&url, None, reporter.as_ref()); - drop(tree); - result - }); - - let text = body_text(sse_response(rx, operation, progress, join)).await; - let terminal = text - .split("\n\n") - .filter(|block| !block.trim().is_empty()) - .last() - .expect("the stream has events"); - assert!( - terminal.contains("\"status\":\"ready\""), - "the download stream still ends with the join result's terminal event: {terminal}" - ); - } -} +#[path = "cache-tests.rs"] +mod tests; diff --git a/crates/gateway/app/src/commands-apply.rs b/crates/gateway/app/src/commands-apply.rs new file mode 100644 index 000000000..9e8c6fc1e --- /dev/null +++ b/crates/gateway/app/src/commands-apply.rs @@ -0,0 +1,259 @@ +//! The `ApplyConfig` command: what an apply captures, and how the command +//! commits it. +//! +//! The `POST /admin/config-apply` route takes the census under +//! the apply lock and calls [`capture_apply`] to turn it into an +//! [`ApplyPlan`]: an inline promotion for shadows that need no reload, or +//! an [`ApplySnapshot`] that rides onto the queue as `Command::ApplyConfig` +//! and runs through [`apply_config`]. The snapshot vocabulary and the +//! promotion step live here beside the command that consumes them, so the +//! route module holds only the two handlers and their replies. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use gateway_config::{Config, ProfileSelection, load_pending_config, shadow_path, write_atomic}; +use gateway_progress::Activity; +#[cfg(feature = "web-search")] +use gateway_web_search::WebSearchState; +use tokio_util::sync::CancellationToken; + +use super::{APPLY_CONFIG_LABEL, Outcome}; +use crate::AppState; +use crate::config_shadow::{canonical_form, config_root, relative_name, shadow_census}; +use crate::error::{GatewayError, blocking, config_write_error}; +use crate::routing::Routing; + +/// Top-level sections the process reads once at boot. A change to one of +/// them promotes to disk but takes effect at the next start, so the apply +/// reports `restart_required`. +const RESTART_SECTIONS: [&str; 6] = [ + "server", + "workshop", + "profile", + "local_model", + "stt_model", + "stt", +]; + +/// One shadow as the Apply route captured it, ready to land in its real +/// file at the command's commit. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ShadowCapture { + /// The real file the shadow stands in for, in canonical form. + pub(crate) real_path: PathBuf, + /// The real file rendered for the wire, relative to the config root. + pub(crate) relative_name: String, + /// The shadow's contents at capture time. + pub(crate) contents: String, +} + +/// What one reloading apply carries onto the command queue: the parsed +/// pending config and every captured shadow. +#[derive(Debug)] +pub(crate) struct ApplySnapshot { + /// The shadow-preferred pending config, parsed and validated, with no + /// profile selected (`active_profile()` is `None` and the local and + /// speech-to-text subsets are empty): the apply swaps the remote + /// catalog and never the local runtime. Boxed so the `Command` enum + /// stays the size of its other variants. + pub(crate) config: Box, + /// Every shadow the census found, with its contents at capture time: + /// the config shadow and any env shadow. + pub(crate) files: Vec, + /// Whether an env or boot-read setting changed. + pub(crate) restart_required: bool, +} + +impl ApplySnapshot { + /// The captured real files rendered for the wire, sorted. + pub(crate) fn applied_names(&self) -> Vec { + let mut names: Vec = self + .files + .iter() + .map(|file| file.relative_name.clone()) + .collect(); + names.sort_unstable(); + names + } +} + +/// What the census decided: promote inline, or reload through the queue. +pub(crate) enum ApplyPlan { + /// No config shadow: the captures are promoted under the route's lock + /// and no command runs. + Inline { + /// The captures to promote. + files: Vec, + /// Whether a promoted change takes effect only at the next start. + restart_required: bool, + }, + /// A config shadow: the reload runs as an `ApplyConfig` command and + /// promotes the captures at its commit. + Reload(ApplySnapshot), +} + +/// Takes the census, parses the pending config when a reload is needed, and +/// reads every shadow's contents. Touches no real file. +pub(crate) fn capture_apply(config_path: &Path) -> Result { + let census = shadow_census(config_path)?; + let root = config_root(config_path); + let config_canonical = canonical_form(config_path); + let env_canonical = canonical_form(&config_path.with_extension("env")); + let needs_reload = census.files.iter().any(|file| file == &config_canonical); + let mut restart_required = census + .sections + .iter() + .any(|section| RESTART_SECTIONS.contains(§ion.as_str())); + let mut files = Vec::with_capacity(census.files.len()); + for file in &census.files { + if file == &env_canonical { + restart_required = true; + } + let shadow = shadow_path(file); + let contents = std::fs::read_to_string(&shadow) + .map_err(|source| GatewayError::ConfigWriteIo(Box::new(source)))?; + files.push(ShadowCapture { + real_path: file.clone(), + relative_name: relative_name(file, root), + contents, + }); + } + if !needs_reload { + return Ok(ApplyPlan::Inline { + files, + restart_required, + }); + } + // The selection is irrelevant to what the apply swaps (the remote + // catalog is the same for every profile). The pending loader resolves + // the state file the way the next boot would, which admits a stale or + // absent one, and the selection it resolved is then dropped: a + // persisted name can differ from the running profile (a switch that + // persisted a new name and is waiting on a restart) and must not be + // published as the live document's selection. + let config = load_pending_config(config_path, &ProfileSelection::default()) + .and_then(|config| config.select_profile(None)) + .map_err(config_write_error)?; + Ok(ApplyPlan::Reload(ApplySnapshot { + config: Box::new(config), + files, + restart_required, + })) +} + +/// Lands every capture in its real file and retires the shadows it came +/// from. The caller holds the apply lock. +/// +/// For each capture the real file is replaced atomically with the captured +/// contents, then the shadow that exists now is compared against them: an +/// equal shadow is deleted (promotion complete), a different one - a save +/// landed since the capture - stays in place as the next pending change, +/// and a missing one needs nothing. The two invariants this keeps exact: +/// the real files always equal what is live, and a shadow always means +/// "not yet applied". Returns the promoted real files rendered for the +/// wire, sorted. +pub(crate) fn promote_captures(captures: &[ShadowCapture]) -> Result, GatewayError> { + let mut applied = Vec::with_capacity(captures.len()); + for capture in captures { + write_atomic(&capture.real_path, &capture.contents).map_err(config_write_error)?; + let shadow = shadow_path(&capture.real_path); + match std::fs::read_to_string(&shadow) { + Ok(current) if current == capture.contents => { + if let Err(source) = std::fs::remove_file(&shadow) + && source.kind() != std::io::ErrorKind::NotFound + { + return Err(GatewayError::ConfigWriteIo(Box::new(source))); + } + } + Ok(_) => {} + Err(source) if source.kind() == std::io::ErrorKind::NotFound => {} + Err(source) => return Err(GatewayError::ConfigWriteIo(Box::new(source))), + } + applied.push(capture.relative_name.clone()); + } + applied.sort_unstable(); + Ok(applied) +} + +/// The `ApplyConfig` command body: under the `"Applying configuration"` +/// text, swaps the remote routing table live and promotes the captured +/// shadows. The activity drops with the return on every path. +/// +/// Any failure under a fired token reports as the cancellation it is, so +/// the route's reply can promise the shadows are still staged. +pub(crate) async fn apply_config( + state: &AppState, + snapshot: ApplySnapshot, + token: CancellationToken, + activity: Activity, +) -> Outcome { + let ApplySnapshot { config, files, .. } = snapshot; + activity.set_text("Applying configuration"); + match apply_snapshot(state, *config, files, &token).await { + Ok(summary) => Ok(summary), + Err(_) if token.is_cancelled() => Err(apply_cancelled()), + Err(error) => Err(error), + } +} + +fn apply_cancelled() -> GatewayError { + GatewayError::CommandCancelled(APPLY_CONFIG_LABEL.to_owned()) +} + +/// Builds the new routing table, then commits under the apply lock: the +/// captures land in their real files first (a failed promotion changes +/// nothing live), and one live write swaps the routing, config, and +/// web-search state. The running local children are never touched; their +/// routing entries carry over under the new remote catalog. +async fn apply_snapshot( + state: &AppState, + config: Config, + files: Vec, + token: &CancellationToken, +) -> Outcome { + if token.is_cancelled() { + return Err(apply_cancelled()); + } + let remote = Routing::from_config(&config) + .map_err(|error| GatewayError::switch_failed("build-routing", error))?; + // Only queue commands change the local runtime, and this is one, so the + // set read here is the set the swap below publishes. + #[cfg(feature = "local")] + let routing = { + let live = state.live.read().await; + remote + .merge(live.local.models().iter().cloned()) + .map_err(|error| GatewayError::switch_failed("merge-routing", error))? + }; + #[cfg(not(feature = "local"))] + let routing = remote; + #[cfg(feature = "web-search")] + let web_search = config + .web_search_config() + .map(WebSearchState::new) + .map(Arc::new); + #[cfg(test)] + state.park_at(crate::park::Phase::ApplyCommit).await; + // The commit holds the apply lock so no save, revert, or pending read + // interleaves with the promotion and the live swap. A revert fires the + // token before taking this lock, so the re-check under it is what keeps + // a cancelled apply from writing over files the user just reverted. + let _publication = tokio::select! { + biased; + () = token.cancelled() => return Err(apply_cancelled()), + guard = state.apply.lock() => guard, + }; + if token.is_cancelled() { + return Err(apply_cancelled()); + } + let applied = blocking(move || promote_captures(&files)).await??; + let mut live = state.live.write().await; + live.routing = Arc::new(routing); + live.config = Arc::new(config); + #[cfg(feature = "web-search")] + { + live.web_search = web_search; + } + Ok(format!("applied {}", applied.join(", "))) +} diff --git a/crates/gateway/app/src/commands.rs b/crates/gateway/app/src/commands.rs index f2eef793a..894c48664 100644 --- a/crates/gateway/app/src/commands.rs +++ b/crates/gateway/app/src/commands.rs @@ -5,9 +5,11 @@ //! worker task draining a shared pending deque FIFO, so downloads never //! fight each other for bandwidth and the listener stays live while they //! run. -//! Each command reports into its own [`ProgressTree`] on the process hub and -//! carries a [`CancellationToken`] the worker honors at chunk and phase -//! boundaries. The in-process status ([`CommandQueue::active_command`] and +//! The worker begins one [`Activity`] on the process hub per command it +//! runs, labelled with the command's name, and hands it to the body, which +//! writes its stages into the text; every command carries a +//! [`CancellationToken`] the worker honors at chunk and phase boundaries. +//! The in-process status ([`CommandQueue::active_command`] and //! [`CommandQueue::pending_commands`]) feeds the tray and the admin routes. use std::collections::VecDeque; @@ -17,13 +19,20 @@ use std::time::Instant; use futures_util::future::BoxFuture; use gateway_config::ProfileName; -use shared_progress::{OperationId, ProgressHub, ProgressTree}; +use gateway_progress::{Activity, ProgressHub}; use tokio::sync::oneshot; use tokio_util::sync::CancellationToken; use crate::AppState; -use crate::config_apply::ApplySnapshot; use crate::error::GatewayError; +// Only the local-inference command bodies leave the async executor. +#[cfg(feature = "local")] +use crate::error::blocking; + +#[path = "commands-apply.rs"] +pub(crate) mod apply; + +use self::apply::ApplySnapshot; /// The `ApplyConfig` command's display name: the status bar and tray show /// it, and the apply's cancellation error names it. @@ -51,8 +60,8 @@ pub(crate) enum Command { /// Cancellation token, checked at chunk and phase boundaries. token: CancellationToken, }, - /// Apply the staged configuration: promote the captured shadows and - /// swap the remote routing table to the snapshot's config in one live + /// Applies the staged configuration: promotes the captured shadows and + /// swaps the remote routing table to the snapshot's config in one live /// write, leaving the local runtime as it is. Nothing touches a real /// file before that commit, so a failed or cancelled apply leaves every /// shadow staged for a retry. @@ -70,7 +79,7 @@ pub(crate) enum Command { /// the apply lock at the commit. token: CancellationToken, }, - /// Download and verify one model into the artifact store. Spawning it + /// Downloads and verifies one model into the artifact store. Spawning it /// into the routing table needs the model's full configuration, which /// this command does not carry; that arrives with the command's first /// producer. @@ -89,7 +98,7 @@ pub(crate) enum Command { /// Cancellation token, checked at chunk and phase boundaries. token: CancellationToken, }, - /// Stop one local model's `llama-server` child and drop it from the + /// Stops one local model's `llama-server` child and drops it from the /// routing table. Not debounced: unloads are fast and order-independent. #[cfg_attr( not(test), @@ -162,13 +171,11 @@ struct PendingEntry { key: Option, label: String, queued_at: Instant, - tree: ProgressTree, waiters: Vec>, } impl PendingEntry { - /// Settles every waiter and drops the tree, detaching the never-started - /// operation from the hub. + /// Settles every waiter of a command that never started. fn settle(self, outcome: Outcome) { let outcome = Arc::new(outcome); for waiter in self.waiters { @@ -183,7 +190,6 @@ struct ActiveEntry { id: u64, key: Option, label: String, - operation: OperationId, started_at: Instant, token: Option, waiters: Vec>, @@ -217,13 +223,13 @@ impl std::fmt::Debug for ExecutorOverride { } } -/// A point-in-time readout of the running command. +/// A point-in-time readout of the running command. What the command is +/// doing right now is the hub's [`Progress`](gateway_api_types::Progress) +/// text, not part of this readout. #[derive(Debug, Clone)] pub(crate) struct CommandStatus { /// The command's display name, for example `load-profile: main`. pub(crate) name: String, - /// The command operation tree's weighted fraction, `0.0..=1.0`. - pub(crate) progress: f64, /// When the worker started the command. pub(crate) started_at: Instant, } @@ -237,27 +243,31 @@ pub(crate) struct CommandSummary { pub(crate) queued_at: Instant, } -/// What an enqueue returns: the operation the command reports under, and -/// the receiver for its settled outcome. A command dropped by the debounce +/// What an enqueue returns: the queue entry the command runs as, and the +/// receiver for its settled outcome. A command dropped by the debounce /// attaches both to the command it duplicated. #[derive(Debug)] pub(crate) struct Enqueued { - /// The progress operation the command reports under. + /// The queue entry the command runs as; a debounced duplicate shares + /// the entry it attached to. #[cfg_attr( not(test), expect( dead_code, - reason = "read only by tests: no route streams a command's stages, and the first producer that needs the operation id lifts this" + reason = "read only by tests, which pin the debounce attaching a duplicate to the entry it duplicated" ) )] - pub(crate) operation: OperationId, + pub(crate) entry: u64, /// Resolves when the command settles. pub(crate) outcome: oneshot::Receiver, } -/// The body the worker runs for one command; swappable in tests. +/// The body the worker runs for one command; swappable in tests. The +/// activity is the command's own, labelled with its name and live until the +/// body returns: the body writes its stages into the text or begins nested +/// activities on the hub. pub(crate) type Executor = - dyn Fn(AppState, Command, ProgressTree) -> BoxFuture<'static, Outcome> + Send + Sync; + dyn Fn(AppState, Command, Activity) -> BoxFuture<'static, Outcome> + Send + Sync; /// The gateway's command queue: one shared pending deque, one worker task, /// and the in-process status the tray and routes read. @@ -314,16 +324,14 @@ impl CommandQueue { let mut state = self.lock(); if state.closed { // The queue is shut down: the command never runs, so settle its - // waiter immediately. The operation id filters nothing; the - // tree detaches at once. - let tree = self.hub.operation(); - let operation = tree.operation(); - drop(tree); + // waiter immediately under an entry id nothing else shares. + let entry = state.next_id; + state.next_id += 1; let _ = waiter_tx.send(Arc::new(Err(GatewayError::CommandCancelled( command.label(), )))); return Enqueued { - operation, + entry, outcome: waiter_rx, }; } @@ -335,7 +343,7 @@ impl CommandQueue { { active.waiters.push(waiter_tx); return Enqueued { - operation: active.operation, + entry: active.id, outcome: waiter_rx, }; } @@ -345,10 +353,10 @@ impl CommandQueue { .iter_mut() .find(|entry| entry.key.as_ref() == Some(key)) { - let operation = pending.tree.operation(); + let entry = pending.id; pending.waiters.push(waiter_tx); return Enqueued { - operation, + entry, outcome: waiter_rx, }; } @@ -356,15 +364,12 @@ impl CommandQueue { let id = state.next_id; state.next_id += 1; let label = command.label(); - let tree = self.hub.operation(); - let operation = tree.operation(); state.pending.push_back(PendingEntry { id, command, key, label, queued_at: Instant::now(), - tree, waiters: vec![waiter_tx], }); // Wake after releasing the lock: the permit is stored, so a worker @@ -372,24 +377,18 @@ impl CommandQueue { drop(state); self.notify.notify_one(); Enqueued { - operation, + entry: id, outcome: waiter_rx, } } - /// The running command's status, or `None` when the worker is idle. The - /// progress fraction is read off the hub's snapshot, so it is current at - /// call time without the worker updating shared state. + /// The running command's status, or `None` when the worker is idle. pub(crate) fn active_command(&self) -> Option { - let (name, operation, started_at) = { - let state = self.lock(); - let active = state.active.as_ref()?; - (active.label.clone(), active.operation, active.started_at) - }; + let state = self.lock(); + let active = state.active.as_ref()?; Some(CommandStatus { - name, - progress: self.operation_fraction(operation), - started_at, + name: active.label.clone(), + started_at: active.started_at, }) } @@ -510,7 +509,7 @@ impl CommandQueue { } self.spawn_worker_with( state, - Arc::new(|state, command, tree| Box::pin(run_command(state, command, tree))), + Arc::new(|state, command, activity| Box::pin(run_command(state, command, activity))), ) } @@ -553,19 +552,22 @@ impl CommandQueue { let Some(entry) = state.pending.pop_front() else { return BeginNext::Wait; }; - let tree = entry.tree; let token = entry.command.token(); let id = entry.id; + // The command's activity begins here, labelled with its name, so + // the hub is busy from the first instant the worker owns it; the + // body refines the text and its return drops the guard. + let activity = self.hub.begin(entry.label.clone()); + tracing::info!(command = %entry.label, "command started"); state.active = Some(ActiveEntry { id, key: entry.key, label: entry.label, - operation: tree.operation(), started_at: Instant::now(), token, waiters: entry.waiters, }); - BeginNext::Run(id, entry.command, tree) + BeginNext::Run(id, entry.command, activity) } /// Clears the active command and settles its waiters, logging the @@ -597,42 +599,15 @@ impl CommandQueue { let _ = waiter.send(Arc::clone(&outcome)); } } - - /// The operation's weighted fraction over its top-level leaves, read - /// from the hub snapshot. `1.0` once the tree has detached: the body has - /// returned and the worker clears the active entry right after. - fn operation_fraction(&self, operation: OperationId) -> f64 { - for snapshot in self.hub.snapshot() { - if snapshot.operation != operation { - continue; - } - // Top-level leaves are the paths with no separator; the tree's - // own fraction aggregates the same set with the same weights. - let mut weighted = 0.0; - let mut weights = 0.0; - for node in &snapshot.nodes { - if node.path.contains('/') { - continue; - } - weighted += node.weight * node.fraction; - weights += node.weight; - } - return if weights > 0.0 { - weighted / weights - } else { - 0.0 - }; - } - 1.0 - } } /// What the worker does next, decided by [`CommandQueue::begin_next`] in /// one critical section so a shutdown cannot slip between the pop and the /// activation. enum BeginNext { - /// Run this command; it is installed as the active entry. - Run(u64, Command, ProgressTree), + /// Runs this command under its activity; it is installed as the active + /// entry. + Run(u64, Command, Activity), /// The deque is empty; park until notified. Wait, /// The queue is closed; exit. @@ -649,76 +624,64 @@ async fn worker_loop(queue: CommandQueue, state: AppState, executor: Arc (id, command, tree), + let (id, command, activity) = match queue.begin_next() { + BeginNext::Run(id, command, activity) => (id, command, activity), BeginNext::Wait => { notified.await; continue; } BeginNext::Exit => break, }; - let outcome = executor(state.clone(), command, tree).await; + let outcome = executor(state.clone(), command, activity).await; queue.finish(id, outcome); } } -/// Runs one command to its end, reporting progress into its operation tree. -async fn run_command(state: AppState, command: Command, tree: ProgressTree) -> Outcome { +/// Runs one command to its end under its activity, which drops with the +/// body's return on every path. +async fn run_command(state: AppState, command: Command, activity: Activity) -> Outcome { match command { Command::LoadProfile { name, token } => { - crate::boot_load::run(&state, name, tree, &token).await + crate::boot_load::run(&state, name, activity, &token).await } Command::ApplyConfig { snapshot, token } => { - crate::config_apply::apply_config(&state, snapshot, token, tree).await + apply::apply_config(&state, snapshot, token, activity).await } Command::ProvisionModel { name, source, token, - } => provision_model(&state, &name, &source, token, &tree).await, - Command::UnloadModel { name } => unload_model(&state, &name, &tree).await, + } => provision_model(&state, &name, &source, token, activity).await, + Command::UnloadModel { name } => unload_model(&state, &name, activity).await, } } /// The `ProvisionModel` body: download and verify one model into the -/// artifact store, off the async executor. +/// artifact store, off the async executor. The activity moves into the +/// blocking task, which writes the download and verify stages into it and +/// drops it when the store returns. #[cfg(feature = "local")] async fn provision_model( state: &AppState, name: &str, source: &str, token: CancellationToken, - tree: &ProgressTree, + activity: Activity, ) -> Outcome { - let leaf = tree.register(name, 1.0); let cache_dir = state.cache_dir().await; let source = source.to_owned(); let label = format!("provision-model: {name}"); - let progress = leaf.clone(); let worker_token = token.clone(); - let result = tokio::task::spawn_blocking(move || { + let result = blocking(move || { let root = crate::local::resolve_cache_root(cache_dir.as_deref())?; let store = crate::local::artifacts::ArtifactStore::new(root)?; - store.ensure_model_with_cancellation(&source, None, Some(&progress), Some(&worker_token)) + store.ensure_model_with_cancellation(&source, None, Some(&activity), Some(&worker_token)) }) - .await; + .await?; match result { - Ok(Ok(_path)) => { - leaf.complete(); - Ok(format!("provisioned {name}")) - } - Ok(Err(_)) if token.is_cancelled() => { - leaf.fail(); - Err(GatewayError::CommandCancelled(label)) - } - Ok(Err(error)) => { - leaf.fail(); - Err(GatewayError::cache(error)) - } - Err(join) => { - leaf.fail(); - Err(GatewayError::cache(join)) - } + Ok(_path) => Ok(format!("provisioned {name}")), + Err(_) if token.is_cancelled() => Err(GatewayError::CommandCancelled(label)), + Err(error) => Err(GatewayError::cache(error)), } } @@ -729,7 +692,7 @@ async fn provision_model( _name: &str, _source: &str, _token: CancellationToken, - _tree: &ProgressTree, + _activity: Activity, ) -> Outcome { Err(GatewayError::switch_failed( "provision-model", @@ -740,40 +703,28 @@ async fn provision_model( /// The `UnloadModel` body: drop the model from the routing table, then tear /// down its child off the async executor. #[cfg(feature = "local")] -async fn unload_model(state: &AppState, name: &str, tree: &ProgressTree) -> Outcome { - let leaf = tree.register("unload-model", 1.0); +async fn unload_model(state: &AppState, name: &str, activity: Activity) -> Outcome { // In-flight requests holding the old table entry keep their connection; // the teardown below ends the child under them, which is what the // caller asked for. let model = { let mut live = state.live.write().await; let Some(model) = live.local.unload_model(name) else { - leaf.fail(); return Err(GatewayError::UnknownModel(name.to_owned())); }; live.routing = Arc::new(live.routing.without(name)); model }; - let result = tokio::task::spawn_blocking(move || model.endpoint.upstream.shutdown()).await; - match result { - Ok(Ok(())) => { - leaf.complete(); - Ok(format!("unloaded {name}")) - } - Ok(Err(error)) => { - leaf.fail(); - Err(GatewayError::switch_failed("unload-model", error)) - } - Err(join) => { - leaf.fail(); - Err(GatewayError::switch_failed("unload-model", join)) - } - } + activity.set_text(format!("Stopping {name}")); + blocking(move || model.endpoint.upstream.shutdown()) + .await? + .map_err(|error| GatewayError::switch_failed("unload-model", error))?; + Ok(format!("unloaded {name}")) } /// The headless `UnloadModel` body: no local runtime exists to hold models. #[cfg(not(feature = "local"))] -async fn unload_model(_state: &AppState, name: &str, _tree: &ProgressTree) -> Outcome { +async fn unload_model(_state: &AppState, name: &str, _activity: Activity) -> Outcome { Err(GatewayError::UnknownModel(name.to_owned())) } @@ -862,8 +813,8 @@ mod tests { let pending = queue.pending_commands(); assert_eq!(pending.len(), 1, "the duplicate never enters the queue"); assert_eq!( - first.operation, second.operation, - "the duplicate attaches to the pending command's operation" + first.entry, second.entry, + "the duplicate attaches to the pending command's entry" ); assert!(queue.active_command().is_none()); } @@ -880,8 +831,8 @@ mod tests { "one apply is pending; the duplicate never enters the queue" ); assert_eq!( - first.operation, second.operation, - "the duplicate attaches to the pending apply's operation" + first.entry, second.entry, + "the duplicate attaches to the pending apply's entry" ); } @@ -1047,7 +998,7 @@ mod tests { let _other = queue.enqueue(provision("n")); assert_eq!( - first.operation, duplicate.operation, + first.entry, duplicate.entry, "a same-model duplicate attaches to the pending command" ); let pending = queue.pending_commands(); @@ -1071,10 +1022,7 @@ mod tests { let second = queue.enqueue(unload("m")); assert_eq!(queue.pending_commands().len(), 2); - assert_ne!( - first.operation, second.operation, - "each unload keeps its own operation" - ); + assert_ne!(first.entry, second.entry, "each unload keeps its own entry"); } #[tokio::test] @@ -1099,7 +1047,7 @@ mod tests { let order = Arc::new(Mutex::new(Vec::new())); let executor: Arc = Arc::new({ let order = Arc::clone(&order); - move |_state, command: Command, _tree| { + move |_state, command: Command, _activity| { let order = Arc::clone(&order); Box::pin(async move { let label = command.label(); @@ -1167,22 +1115,25 @@ mod tests { queue.shutdown(); } + /// The worker begins the command's activity under its label, the body + /// refines the text, and the hub falls idle once the body returns. #[tokio::test] - #[expect( - clippy::float_cmp, - reason = "0.625 = (3 * 0.5 + 1 * 1.0) / 4 is exact in binary floating point" - )] - async fn the_active_commands_progress_reads_off_the_hub() { + async fn the_active_command_drives_the_hubs_busy_text() { let state = state(); let queue = state.commands.clone(); - // The stub reports known leaf fractions on the command's tree, then - // parks until cancelled: 3/4 through the weighted pair. - let executor: Arc = Arc::new(|_state, command, tree| { + let hub = Arc::clone(&state.hub); + assert!(!hub.current().busy, "a pending command is not yet busy"); + let _pending = queue.enqueue(load_profile("alpha")); + assert!( + !hub.current().busy, + "a queued command that has not started reports nothing" + ); + + // The stub writes a stage into the activity, then parks until + // cancelled. + let executor: Arc = Arc::new(|_state, command, activity| { Box::pin(async move { - let download = tree.register("download", 3.0); - let verify = tree.register("verify", 1.0); - download.set_fraction(0.5); - verify.set_fraction(1.0); + activity.set_text("Downloading qwen 45%"); let label = command.label(); let token = command.token().expect("a load command carries a token"); token.cancelled().await; @@ -1194,18 +1145,18 @@ mod tests { .spawn_worker_with(&state, executor) .expect("worker spawns"); - let _handle = queue.enqueue(load_profile("alpha")); - wait_until("the command to go active", || { - queue.active_command().is_some() + wait_until("the command to write its stage", || { + hub.current().text == "Downloading qwen 45%" }) .await; - let status = queue.active_command().expect("active"); - // The tree's own aggregate: (3 * 0.5 + 1 * 1.0) / 4. + assert!(hub.current().busy, "a running command is busy"); + queue.cancel_active(); + wait_until("the queue to go idle", || queue.active_command().is_none()).await; assert_eq!( - status.progress, 0.625, - "the status fraction matches the tree's weighted aggregate" + hub.current(), + gateway_api_types::Progress::default(), + "the body's return drops the activity and the hub falls idle" ); - queue.cancel_active(); queue.shutdown(); } @@ -1214,11 +1165,11 @@ mod tests { let state = state(); let token = CancellationToken::new(); token.cancel(); - let tree = state.hub.operation(); + let activity = state.hub.begin("test"); let outcome = run_command( state.clone(), Command::load_profile(ProfileName::parse("alpha").expect("profile name"), token), - tree, + activity, ) .await; assert!( @@ -1246,7 +1197,7 @@ mod tests { fn recording_executor(order: &Arc>>) -> Arc { Arc::new({ let order = Arc::clone(order); - move |_state, command: Command, _tree| { + move |_state, command: Command, _activity| { let order = Arc::clone(&order); Box::pin(async move { let label = command.label(); @@ -1412,8 +1363,8 @@ mod tests { #[tokio::test] async fn an_unload_of_a_model_the_runtime_does_not_hold_is_unknown_model() { let state = state(); - let tree = state.hub.operation(); - let outcome = run_command(state.clone(), unload("ghost"), tree).await; + let activity = state.hub.begin("test"); + let outcome = run_command(state.clone(), unload("ghost"), activity).await; assert!( matches!(&outcome, Err(GatewayError::UnknownModel(name)) if name == "ghost"), "an unload miss is UnknownModel, not a queue error: {outcome:?}" @@ -1449,8 +1400,8 @@ mod tests { &mut state, ScriptedModelFactory::new(ScriptedDecoder::new()), ); - let tree = state.hub.operation(); - let outcome = run_command(state.clone(), load_profile("alpha"), tree).await; + let activity = state.hub.begin("test"); + let outcome = run_command(state.clone(), load_profile("alpha"), activity).await; assert_eq!( outcome.as_deref().ok(), @@ -1495,7 +1446,7 @@ mod tests { let boot_handle = queue.enqueue(load_profile("alpha")); let attached = queue.enqueue(load_profile("alpha")); assert_eq!( - boot_handle.operation, attached.operation, + boot_handle.entry, attached.entry, "the duplicate attaches to the boot command" ); let worker = state.commands.spawn_worker(&state).expect("worker spawns"); diff --git a/crates/gateway/app/src/config_apply.rs b/crates/gateway/app/src/config_apply.rs deleted file mode 100644 index a08f0b60b..000000000 --- a/crates/gateway/app/src/config_apply.rs +++ /dev/null @@ -1,1126 +0,0 @@ -//! Apply and revert routes for pending config shadows: -//! `POST /admin/config-apply` and `POST /admin/config-revert`. -//! -//! Apply captures the pending state under the apply lock - a census of the -//! shadows, the parsed shadow-preferred config, and every shadow's current -//! contents - then releases the lock. A change that needs no reload (an env -//! shadow alone) is promoted inline. A config shadow runs as an -//! `ApplyConfig` command on the command queue: the command rebuilds the -//! remote routing table from the pending config, merges the running local -//! models under it, promotes the captured shadows under the apply lock, and -//! swaps the live routing, config, and web-search state in one write. A -//! failed or cancelled apply promotes nothing and leaves every shadow staged -//! for a retry. Sections the process reads once at boot (`[server]`, -//! `[workshop]`, `[[profile]]`, `[[local_model]]`, `[[stt_model]]`, `[stt]`) -//! and env shadows promote the same way but report `restart_required`: the -//! local runtime is fixed for the process lifetime. Revert cancels any apply -//! in flight, then deletes every shadow and touches nothing else. Saves, the -//! capture step, the commit, and revert serialize on one mutex, so apply only -//! captures combinations the latest save validated whole. Both routes reply -//! with plain JSON; the reload's one `applying-config` stage streams to -//! `GET /admin/progress` subscribers. - -use std::path::{Path, PathBuf}; -use std::sync::Arc; - -use axum::Json; -use axum::extract::State; -use gateway_config::{Config, ProfileSelection, load_pending_config, shadow_path, write_atomic}; -#[cfg(feature = "web-search")] -use gateway_web_search::WebSearchState; -use shared_progress::ProgressTree; -use tokio_util::sync::CancellationToken; - -use crate::AppState; -use crate::auth::AuthedCaller; -use crate::commands::{APPLY_CONFIG_LABEL, Command, Outcome}; -use crate::config_pending::{canonical_form, config_root, relative_name, shadow_census}; -use crate::config_write::{config_write_error, error_chain}; -use crate::error::GatewayError; -use crate::routing::Routing; - -/// Top-level sections the process reads once at boot. A change to one of -/// them promotes to disk but takes effect at the next start, so the apply -/// reports `restart_required`. -const RESTART_SECTIONS: [&str; 6] = [ - "server", - "workshop", - "profile", - "local_model", - "stt_model", - "stt", -]; - -/// The `POST /admin/config-apply` route: bearer-authed, applies every -/// staged shadow, reloading the remote routing table when the change needs -/// it. -/// -/// The reply is plain JSON - `{"applied": [...], "reloaded": bool, -/// "restart_required": bool}` - not SSE: the reload runs as a command on -/// the queue, so its `applying-config` stage streams to -/// `GET /admin/progress` subscribers, and the response carries the outcome. -/// `applied` names the promoted real files relative to the config root, -/// sorted. `reloaded` is true when a config shadow applied successfully. -/// `restart_required` is true for an env shadow or a change to a section -/// the process reads once at boot: `[server]`, `[workshop]`, `[[profile]]`, -/// `[[local_model]]`, `[[stt_model]]`, or `[stt]`. With no shadows on disk -/// the reply is the clean no-op -/// `{"applied": [], "reloaded": false, "restart_required": false}`. -/// -/// Nothing is promoted before the command commits. A parse failure replies -/// 500 before any command exists; a reload failure replies -/// [`GatewayError::ApplyReloadFailed`] (500) and a cancelled command - -/// the user's cancel, a revert, or shutdown - replies -/// [`GatewayError::ApplyCancelled`] (503). In both cases every shadow is -/// still staged, so a retry of Apply re-runs the whole thing. -pub(crate) async fn admin_config_apply( - State(state): State, - _caller: AuthedCaller, -) -> Result, GatewayError> { - let config_path = crate::admin::config_path(&state)?.to_path_buf(); - let (enqueued, applied, restart_required) = { - // The lock spans the census, the parse, and the capture (or the - // inline promotion), so a save cannot land between them and the - // snapshot is one the latest save validated whole. It is released - // before the command runs: the queue serializes the reload itself. - let _guard = state.apply.lock().await; - let plan = tokio::task::spawn_blocking(move || capture_apply(&config_path)) - .await - .map_err(|join| GatewayError::ConfigWriteIo(Box::new(join)))??; - let snapshot = match plan { - ApplyPlan::Inline { - files, - restart_required, - } => { - let applied = tokio::task::spawn_blocking(move || promote_captures(&files)) - .await - .map_err(|join| GatewayError::ConfigWriteIo(Box::new(join)))??; - return Ok(Json(serde_json::json!({ - "applied": applied, - "reloaded": false, - "restart_required": restart_required, - }))); - } - ApplyPlan::Reload(snapshot) => snapshot, - }; - let applied = snapshot.applied_names(); - let restart_required = snapshot.restart_required; - let enqueued = state.commands.enqueue(Command::ApplyConfig { - snapshot, - token: CancellationToken::new(), - }); - (enqueued, applied, restart_required) - }; - let outcome = enqueued.outcome.await.unwrap_or_else(|_| { - // The worker settles every command it begins, so a dropped sender - // means the worker task itself died. - Arc::new(Err(GatewayError::switch_failed( - "queue", - std::io::Error::other("the command queue dropped the command without settling it"), - ))) - }); - match &*outcome { - Ok(_) => Ok(Json(serde_json::json!({ - "applied": applied, - "reloaded": true, - "restart_required": restart_required, - }))), - Err(GatewayError::CommandCancelled(_)) => Err(GatewayError::ApplyCancelled), - Err(error) => Err(GatewayError::ApplyReloadFailed(error_chain(error))), - } -} - -/// The `POST /admin/config-revert` route: bearer-authed, cancels any apply -/// in flight, deletes every shadow file, and touches nothing else. -/// -/// The reply is `{"reverted": [...]}` naming the deleted shadow files -/// relative to the config root, sorted. The real files were never touched -/// by a save, so nothing is rewritten: deleting the shadows is the whole -/// revert. An apply cancelled here settles its route with -/// [`GatewayError::ApplyCancelled`]. -pub(crate) async fn admin_config_revert( - State(state): State, - _caller: AuthedCaller, -) -> Result, GatewayError> { - // A revert issued during an apply wins: cancel the apply before its - // commit can write the snapshot over the files being reverted. The - // commit re-checks the token under the apply lock, so an apply already - // waiting for that lock still stops. - state.commands.cancel_apply(); - // The same guard as apply's capture and commit: a revert must not race - // either. - let _guard = state.apply.lock().await; - let config_path = crate::admin::config_path(&state)?.to_path_buf(); - let reverted = tokio::task::spawn_blocking(move || delete_all_shadows(&config_path)) - .await - .map_err(|join| GatewayError::ConfigWriteIo(Box::new(join)))??; - Ok(Json(serde_json::json!({ "reverted": reverted }))) -} - -/// One shadow as the Apply route captured it, ready to land in its real -/// file at the command's commit. -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct ShadowCapture { - /// The real file the shadow stands in for, in canonical form. - pub(crate) real_path: PathBuf, - /// The real file rendered for the wire, relative to the config root. - pub(crate) relative_name: String, - /// The shadow's contents at capture time. - pub(crate) contents: String, -} - -/// What one reloading apply carries onto the command queue: the parsed -/// pending config and every captured shadow. -#[derive(Debug)] -pub(crate) struct ApplySnapshot { - /// The shadow-preferred pending config, parsed and validated, with no - /// profile selected (`active_profile()` is `None` and the local and - /// speech-to-text subsets are empty): the apply swaps the remote - /// catalog and never the local runtime. Boxed so the `Command` enum - /// stays the size of its other variants. - pub(crate) config: Box, - /// Every shadow the census found, with its contents at capture time: - /// the config shadow and any env shadow. - pub(crate) files: Vec, - /// Whether an env or boot-read setting changed. - pub(crate) restart_required: bool, -} - -impl ApplySnapshot { - /// The captured real files rendered for the wire, sorted. - pub(crate) fn applied_names(&self) -> Vec { - let mut names: Vec = self - .files - .iter() - .map(|file| file.relative_name.clone()) - .collect(); - names.sort_unstable(); - names - } -} - -/// What the census decided: promote inline, or reload through the queue. -enum ApplyPlan { - /// No config shadow: the captures are promoted under the route's lock - /// and no command runs. - Inline { - files: Vec, - restart_required: bool, - }, - /// A config shadow: the reload runs as an `ApplyConfig` command and - /// promotes the captures at its commit. - Reload(ApplySnapshot), -} - -/// Takes the census, parses the pending config when a reload is needed, and -/// reads every shadow's contents. Touches no real file. -fn capture_apply(config_path: &Path) -> Result { - let census = shadow_census(config_path)?; - let root = config_root(config_path); - let config_canonical = canonical_form(config_path); - let env_canonical = canonical_form(&config_path.with_extension("env")); - let needs_reload = census.files.iter().any(|file| file == &config_canonical); - let mut restart_required = census - .sections - .iter() - .any(|section| RESTART_SECTIONS.contains(§ion.as_str())); - let mut files = Vec::with_capacity(census.files.len()); - for file in &census.files { - if file == &env_canonical { - restart_required = true; - } - let shadow = shadow_path(file); - let contents = std::fs::read_to_string(&shadow) - .map_err(|source| GatewayError::ConfigWriteIo(Box::new(source)))?; - files.push(ShadowCapture { - real_path: file.clone(), - relative_name: relative_name(file, root), - contents, - }); - } - if !needs_reload { - return Ok(ApplyPlan::Inline { - files, - restart_required, - }); - } - // The selection is irrelevant to what the apply swaps (the remote - // catalog is the same for every profile). The pending loader resolves - // the state file the way the next boot would, which admits a stale or - // absent one, and the selection it resolved is then dropped: a - // persisted name can differ from the running profile (a switch that - // persisted a new name and is waiting on a restart) and must not be - // published as the live document's selection. - let config = load_pending_config(config_path, &ProfileSelection::default()) - .and_then(|config| config.select_profile(None)) - .map_err(config_write_error)?; - Ok(ApplyPlan::Reload(ApplySnapshot { - config: Box::new(config), - files, - restart_required, - })) -} - -/// Lands every capture in its real file and retires the shadows it came -/// from. The caller holds the apply lock. -/// -/// For each capture the real file is replaced atomically with the captured -/// contents, then the shadow that exists now is compared against them: an -/// equal shadow is deleted (promotion complete), a different one - a save -/// landed since the capture - stays in place as the next pending change, -/// and a missing one needs nothing. The two invariants this keeps exact: -/// the real files always equal what is live, and a shadow always means -/// "not yet applied". Returns the promoted real files rendered for the -/// wire, sorted. -pub(crate) fn promote_captures(captures: &[ShadowCapture]) -> Result, GatewayError> { - let mut applied = Vec::with_capacity(captures.len()); - for capture in captures { - write_atomic(&capture.real_path, &capture.contents).map_err(config_write_error)?; - let shadow = shadow_path(&capture.real_path); - match std::fs::read_to_string(&shadow) { - Ok(current) if current == capture.contents => { - if let Err(source) = std::fs::remove_file(&shadow) - && source.kind() != std::io::ErrorKind::NotFound - { - return Err(GatewayError::ConfigWriteIo(Box::new(source))); - } - } - Ok(_) => {} - Err(source) if source.kind() == std::io::ErrorKind::NotFound => {} - Err(source) => return Err(GatewayError::ConfigWriteIo(Box::new(source))), - } - applied.push(capture.relative_name.clone()); - } - applied.sort_unstable(); - Ok(applied) -} - -/// Deletes every shadow the census finds, returning the deleted shadow -/// files relative to the config root, sorted. -fn delete_all_shadows(config_path: &Path) -> Result, GatewayError> { - let census = shadow_census(config_path)?; - let root = config_root(config_path); - let mut reverted: Vec = Vec::with_capacity(census.files.len()); - for file in &census.files { - let shadow: PathBuf = shadow_path(file); - std::fs::remove_file(&shadow) - .map_err(|source| GatewayError::ConfigWriteIo(Box::new(source)))?; - reverted.push(relative_name(&shadow, root)); - } - reverted.sort_unstable(); - Ok(reverted) -} - -/// The `ApplyConfig` command body: one `applying-config` leaf that swaps -/// the remote routing table live and promotes the captured shadows. -/// -/// Any failure under a fired token reports as the cancellation it is, so -/// the route's reply can promise the shadows are still staged. -pub(crate) async fn apply_config( - state: &AppState, - snapshot: ApplySnapshot, - token: CancellationToken, - tree: ProgressTree, -) -> Outcome { - let ApplySnapshot { config, files, .. } = snapshot; - let applying = tree.register("applying-config", 1.0); - match apply_snapshot(state, *config, files, &token).await { - Ok(summary) => { - applying.complete(); - Ok(summary) - } - Err(_) if token.is_cancelled() => { - applying.fail(); - Err(apply_cancelled()) - } - Err(error) => { - applying.fail(); - Err(error) - } - } -} - -fn apply_cancelled() -> GatewayError { - GatewayError::CommandCancelled(APPLY_CONFIG_LABEL.to_owned()) -} - -/// Builds the new routing table, then commits under the apply lock: the -/// captures land in their real files first (a failed promotion changes -/// nothing live), and one live write swaps the routing, config, and -/// web-search state. The running local children are never touched; their -/// routing entries carry over under the new remote catalog. -async fn apply_snapshot( - state: &AppState, - config: Config, - files: Vec, - token: &CancellationToken, -) -> Outcome { - if token.is_cancelled() { - return Err(apply_cancelled()); - } - let remote = Routing::from_config(&config) - .map_err(|error| GatewayError::switch_failed("build-routing", error))?; - // Only queue commands change the local runtime, and this is one, so the - // set read here is the set the swap below publishes. - #[cfg(feature = "local")] - let routing = { - let live = state.live.read().await; - remote - .merge(live.local.models().iter().cloned()) - .map_err(|error| GatewayError::switch_failed("merge-routing", error))? - }; - #[cfg(not(feature = "local"))] - let routing = remote; - #[cfg(feature = "web-search")] - let web_search = config - .web_search_config() - .map(WebSearchState::new) - .map(Arc::new); - #[cfg(test)] - state.park_at(crate::park::Phase::ApplyCommit).await; - // The commit holds the apply lock so no save, revert, or pending read - // interleaves with the promotion and the live swap. A revert fires the - // token before taking this lock, so the re-check under it is what keeps - // a cancelled apply from writing over files the user just reverted. - let _publication = tokio::select! { - biased; - () = token.cancelled() => return Err(apply_cancelled()), - guard = state.apply.lock() => guard, - }; - if token.is_cancelled() { - return Err(apply_cancelled()); - } - let applied = tokio::task::spawn_blocking(move || promote_captures(&files)) - .await - .map_err(|join| GatewayError::ConfigWriteIo(Box::new(join)))??; - let mut live = state.live.write().await; - live.routing = Arc::new(routing); - live.config = Arc::new(config); - #[cfg(feature = "web-search")] - { - live.web_search = web_search; - } - Ok(format!("applied {}", applied.join(", "))) -} - -#[cfg(test)] -mod tests { - use std::net::SocketAddr; - use std::sync::Arc; - use std::time::Duration; - - use gateway_config::{ - Config, ProfileName, ProfileSelection, profile_state_path, shadow_path, write_shadow, - }; - use shared_progress::{EventState, ProgressEvent}; - use tokio::sync::broadcast; - use tokio_util::sync::CancellationToken; - - use super::{ApplyPlan, capture_apply}; - use crate::AppState; - use crate::commands::Command; - use crate::error::GatewayError; - use crate::park::{Phase, PhasePark}; - use crate::test_support::{AdminPaths, app_state, serve_state, wait_until}; - - const CONFIG: &str = r#" -config-version = 0 - -[server] -bind = "127.0.0.1:0" -api_key = "test-token" - -[[endpoint]] -id = "fake" -protocol = "openai" -base_url = "http://127.0.0.1:9" -api_key = "" - -[[model]] -name = "alpha-model" -description = "alpha" -context = 1024 -upstream = "alpha" -endpoints = ["fake"] - -[[model]] -name = "beta-model" -description = "beta" -context = 1024 -upstream = "beta" -endpoints = ["fake"] - -[[profile]] -name = "alpha" -models = [] - -[[profile]] -name = "beta" -models = [] -"#; - - /// A third remote model appended to `CONFIG`: the shape of the one - /// change an apply reloads live. - const GAMMA_MODEL: &str = "\n[[model]]\nname = \"gamma-model\"\ndescription = \"gamma\"\n\ - context = 1024\nupstream = \"gamma\"\nendpoints = [\"fake\"]\n"; - - fn fixture() -> (tempfile::TempDir, Config, AdminPaths) { - let temp = tempfile::TempDir::new().expect("temp dir"); - let config_path = temp.path().join("gateway.toml"); - std::fs::write(&config_path, CONFIG).expect("write config"); - std::fs::write( - profile_state_path(&config_path), - "active_profile = \"alpha\"\n", - ) - .expect("write state"); - let config = Config::load(&config_path, &ProfileSelection::default()).expect("load config"); - let paths = AdminPaths { - fixture_dir: temp.path().to_path_buf(), - active: "alpha".to_owned(), - config_path, - }; - (temp, config, paths) - } - - /// Serves the fixture with the production queue worker running, so an - /// apply's `ApplyConfig` command actually drains; the state comes back - /// for tests that read the queue or the live table. - async fn serve_fixture(config: Config, paths: AdminPaths) -> (SocketAddr, AppState) { - let state = app_state(config, Some(paths)); - let _worker = state.commands.spawn_worker(&state).expect("worker spawns"); - let addr = serve_state(state.clone()).await; - (addr, state) - } - - /// [`serve_fixture`] with the apply command parked at its commit, so a - /// test can act between the capture and the promotion. - async fn serve_parked_fixture( - config: Config, - paths: AdminPaths, - ) -> (SocketAddr, AppState, Arc) { - let mut state = app_state(config, Some(paths)); - let park = Arc::new(PhasePark::at(Phase::ApplyCommit)); - state.park = Some(Arc::clone(&park)); - let _worker = state.commands.spawn_worker(&state).expect("worker spawns"); - let addr = serve_state(state.clone()).await; - (addr, state, park) - } - - /// Stages `CONFIG` plus `GAMMA_MODEL` as the pending config. - fn stage_gamma(config_path: &std::path::Path) { - write_shadow(config_path, &format!("{CONFIG}{GAMMA_MODEL}")).expect("stage config shadow"); - } - - async fn post(addr: SocketAddr, route: &str) -> reqwest::Response { - reqwest::Client::new() - .post(format!("http://{addr}/{route}")) - .bearer_auth("test-token") - .send() - .await - .expect("post sends") - } - - async fn get_json(addr: SocketAddr, route: &str) -> serde_json::Value { - reqwest::Client::new() - .get(format!("http://{addr}/{route}")) - .bearer_auth("test-token") - .send() - .await - .expect("get sends") - .json() - .await - .expect("json body") - } - - /// Saves the live config with `edit` applied through the real save - /// route, so the shadow is exactly what the UI would write: the running - /// `active_profile` that `GET /admin/config` reports is not a - /// configuration key and never goes back in a save. - async fn save_edited( - addr: SocketAddr, - edit: impl FnOnce(&mut serde_json::Value), - ) -> reqwest::Response { - let mut body = get_json(addr, "admin/config").await; - body.as_object_mut() - .expect("the config is an object") - .remove("active_profile"); - edit(&mut body); - reqwest::Client::new() - .put(format!("http://{addr}/admin/config")) - .bearer_auth("test-token") - .json(&body) - .send() - .await - .expect("save sends") - } - - /// The live profile name, as `GET /admin/status` would report it. - async fn live_profile(state: &AppState) -> Option { - state.live.read().await.profile_name.clone() - } - - /// Whether the live routing table resolves `name`. - async fn routes(state: &AppState, name: &str) -> bool { - state.live.read().await.routing.model(name).is_ok() - } - - /// How many times the hub saw `label` begin: each apply opens exactly one - /// `applying-config` leaf and each switch one `loading-profile` leaf. - fn stages_begun(events: &mut broadcast::Receiver, label: &str) -> usize { - let mut count = 0; - while let Ok(event) = events.try_recv() { - if event.label == label && matches!(event.state, EventState::Begun { .. }) { - count += 1; - } - } - count - } - - /// Asserts the apply reply is the cancellation envelope the config UI - /// keys on. - async fn assert_apply_cancelled(response: reqwest::Response) { - assert_eq!(response.status(), reqwest::StatusCode::SERVICE_UNAVAILABLE); - let body: serde_json::Value = response.json().await.expect("error envelope"); - assert_eq!(body["error"]["code"], "apply_cancelled"); - assert_eq!(body["error"]["type"], "server_error"); - assert_eq!( - body["error"]["message"], - GatewayError::ApplyCancelled.to_string() - ); - assert!( - body["error"]["message"] - .as_str() - .is_some_and(|message| message.contains("still staged")), - "the message tells the user their changes survive: {body}" - ); - } - - /// A child-free local runtime holding one model named `name`, standing - /// in for a running `llama-server` the apply must keep routing. - #[cfg(feature = "test-fixtures")] - fn running_local(name: &str) -> crate::local::LocalRuntime { - let config = Config::from_toml_str(&format!( - "config-version = 0\n\ - [server]\nbind = \"127.0.0.1:0\"\napi_key = \"test-token\"\n\ - [[endpoint]]\nid = \"local\"\nprotocol = \"openai\"\n\ - base_url = \"http://127.0.0.1:9\"\napi_key = \"\"\n\ - [[model]]\nname = \"{name}\"\ndescription = \"running child\"\n\ - context = 4096\nupstream = \"{name}\"\nendpoints = [\"local\"]\n" - )) - .expect("local fixture config parses"); - let routing = - crate::routing::Routing::from_config(&config).expect("local fixture routing builds"); - crate::local::LocalRuntime::from_test_models(routing.models().to_vec()) - } - - /// The reload an apply performs: a new `[[model]]` enters the live - /// routing table, the running local child keeps its entry, the shadow - /// promotes, and the reply says so without a restart. - #[cfg(feature = "test-fixtures")] - #[tokio::test] - async fn apply_with_a_new_model_swaps_the_routing_live_and_promotes_the_shadow() { - let (_temp, config, paths) = fixture(); - let config_path = paths.config_path.clone(); - let (addr, state) = serve_fixture(config, paths).await; - { - let mut live = state.live.write().await; - live.local = running_local("alpha-local"); - let routing = Arc::clone(&live.routing); - live.routing = Arc::new( - routing - .as_ref() - .clone() - .merge(live.local.models().iter().cloned()) - .expect("the running child routes"), - ); - } - let mut events = state.hub.subscribe(); - stage_gamma(&config_path); - - let response = post(addr, "admin/config-apply").await; - - assert_eq!(response.status(), reqwest::StatusCode::OK); - let reply: serde_json::Value = response.json().await.expect("apply body"); - assert_eq!(reply["reloaded"], true); - assert_eq!(reply["restart_required"], false); - assert_eq!(reply["applied"], serde_json::json!(["gateway.toml"])); - assert!(routes(&state, "gamma-model").await, "the new model routes"); - assert!( - routes(&state, "alpha-local").await, - "the running local child keeps its routing entry" - ); - assert_eq!( - state.live.read().await.local.child_count(), - 1, - "the local runtime is untouched" - ); - assert_eq!(live_profile(&state).await.as_deref(), Some("alpha")); - assert!(!shadow_path(&config_path).exists(), "the shadow promoted"); - assert!( - std::fs::read_to_string(&config_path) - .expect("read applied config") - .contains("gamma-model"), - "the real file carries the applied change" - ); - let served = get_json(addr, "admin/config").await; - assert_eq!(served["model"][2]["name"], "gamma-model"); - assert_eq!(stages_begun(&mut events, "applying-config"), 1); - assert_eq!( - stages_begun(&mut events, "loading-profile"), - 0, - "no switch runs for an apply" - ); - } - - /// A persisted selection that differs from the running profile (a - /// switch that persisted a new name and awaits a restart) never reaches - /// the live document: the applied config carries no selection, the - /// running profile is unchanged, and `GET /admin/config` does not report - /// the persisted name as the running one. - #[tokio::test] - async fn apply_publishes_the_document_without_the_persisted_selection() { - let (_temp, config, paths) = fixture(); - let config_path = paths.config_path.clone(); - let (addr, state) = serve_fixture(config, paths).await; - std::fs::write( - profile_state_path(&config_path), - "active_profile = \"beta\"\n", - ) - .expect("persist a selection awaiting restart"); - stage_gamma(&config_path); - - let response = post(addr, "admin/config-apply").await; - - assert_eq!(response.status(), reqwest::StatusCode::OK); - assert!(routes(&state, "gamma-model").await); - assert!( - state.live.read().await.config.active_profile().is_none(), - "the live document carries no selection" - ); - assert_eq!(live_profile(&state).await.as_deref(), Some("alpha")); - let served = get_json(addr, "admin/config").await; - assert!( - served.get("active_profile").is_none(), - "the persisted name is not reported as running: {served}" - ); - } - - /// Each boot-read section flags a restart; the live-reloadable ones do - /// not. Every case stages a valid config differing from the real one in - /// exactly that section. - #[test] - fn capture_apply_flags_restart_for_boot_read_sections_only() { - let cases: [(&str, &str, bool); 7] = [ - ( - "profile", - "\n[[profile]]\nname = \"gamma\"\nmodels = []\n", - true, - ), - ( - "local_model", - "\n[[local_model]]\nname = \"gamma\"\ndescription = \"g\"\n\ - source = \"/models/gamma.gguf\"\ncontext = 4096\n", - true, - ), - ( - "stt_model", - "\n[[stt_model]]\nname = \"speech\"\nrole = \"interim\"\n\ - source = \"/speech.bin\"\nvram_gb = 1.0\n", - true, - ), - ( - "stt", - "\n[stt]\nwindow_seconds = 8\ninterval_ms = 250\n", - true, - ), - ("model", GAMMA_MODEL, false), - ( - "endpoint", - "\n[[endpoint]]\nid = \"other\"\nprotocol = \"openai\"\n\ - base_url = \"http://127.0.0.1:10\"\napi_key = \"\"\n", - false, - ), - ( - "tools", - "\n[tools.web_search]\nprovider = \"brave\"\napi_key = \"k\"\n", - false, - ), - ]; - for (section, addition, expected) in cases { - let temp = tempfile::TempDir::new().expect("temp dir"); - let config_path = temp.path().join("gateway.toml"); - std::fs::write(&config_path, CONFIG).expect("write config"); - write_shadow(&config_path, &format!("{CONFIG}{addition}")).expect("stage shadow"); - - let plan = capture_apply(&config_path).expect("the pending config captures"); - - let ApplyPlan::Reload(snapshot) = plan else { - panic!("a config shadow always reloads: {section}"); - }; - assert_eq!( - snapshot.restart_required, expected, - "restart_required for a {section} change" - ); - assert_eq!(snapshot.applied_names(), ["gateway.toml"]); - } - } - - #[tokio::test] - async fn invalid_pending_config_is_never_promoted() { - let (_temp, config, paths) = fixture(); - let config_path = paths.config_path.clone(); - let original_config = std::fs::read_to_string(&config_path).expect("read config"); - write_shadow(&config_path, "not valid TOML [[[").expect("stage tampered shadow"); - let (addr, state) = serve_fixture(config, paths).await; - let mut events = state.hub.subscribe(); - - let response = post(addr, "admin/config-apply").await; - - assert_eq!( - response.status(), - reqwest::StatusCode::INTERNAL_SERVER_ERROR - ); - assert_eq!( - std::fs::read_to_string(&config_path).expect("re-read config"), - original_config - ); - assert!( - shadow_path(&config_path).is_file(), - "the rejected shadow remains available for correction or revert" - ); - assert_eq!( - stages_begun(&mut events, "applying-config"), - 0, - "the parse failure replies before any command exists" - ); - assert!(state.commands.active_command().is_none()); - assert!(state.commands.pending_commands().is_empty()); - } - - #[tokio::test] - async fn env_only_apply_requires_restart_without_a_command() { - let (_temp, config, paths) = fixture(); - let env_path = paths.config_path.with_extension("env"); - write_shadow(&env_path, "HF_TOKEN=pending\n").expect("stage env shadow"); - let (addr, state) = serve_fixture(config, paths).await; - let mut events = state.hub.subscribe(); - - let response = post(addr, "admin/config-apply").await; - - assert_eq!(response.status(), reqwest::StatusCode::OK); - let reply: serde_json::Value = response.json().await.expect("apply body"); - assert_eq!(reply["applied"], serde_json::json!(["gateway.env"])); - assert_eq!(reply["reloaded"], false); - assert_eq!(reply["restart_required"], true); - assert_eq!( - std::fs::read_to_string(&env_path).expect("read promoted env"), - "HF_TOKEN=pending\n" - ); - assert!( - !shadow_path(&env_path).exists(), - "the promoted shadow is retired" - ); - assert_eq!( - stages_begun(&mut events, "applying-config"), - 0, - "the no-reload path promotes inline without a command" - ); - } - - #[tokio::test] - async fn server_key_change_waits_for_restart() { - let (_temp, config, paths) = fixture(); - let (addr, _state) = serve_fixture(config, paths).await; - let http = reqwest::Client::new(); - let save = save_edited(addr, |body| { - body["server"]["api_key"] = serde_json::json!("next-token"); - }) - .await; - assert_eq!(save.status(), reqwest::StatusCode::OK); - - let response = post(addr, "admin/config-apply").await; - - assert_eq!(response.status(), reqwest::StatusCode::OK); - let reply: serde_json::Value = response.json().await.expect("apply body"); - assert_eq!(reply["restart_required"], true); - assert_eq!( - http.get(format!("http://{addr}/v1/models")) - .bearer_auth("test-token") - .send() - .await - .expect("old token request sends") - .status(), - reqwest::StatusCode::OK - ); - assert_eq!( - http.get(format!("http://{addr}/v1/models")) - .bearer_auth("next-token") - .send() - .await - .expect("new token request sends") - .status(), - reqwest::StatusCode::UNAUTHORIZED - ); - } - - /// The speech pipeline is configured once at boot: an `[stt]` change - /// promotes and reloads the document but reports a restart. - #[tokio::test] - async fn stt_pipeline_change_promotes_and_requires_restart() { - let (_temp, config, paths) = fixture(); - write_shadow( - &paths.config_path, - &format!( - "{CONFIG}\n[stt]\nwindow_seconds = 8\ninterval_ms = 250\n\ - vocabulary = [\"WG21\"]\n" - ), - ) - .expect("stage STT-only shadow"); - let (addr, _state) = serve_fixture(config, paths).await; - let dirty = get_json(addr, "admin/config-dirty").await; - assert_eq!(dirty["changed_sections"], serde_json::json!(["stt"])); - - let response = post(addr, "admin/config-apply").await; - - assert_eq!(response.status(), reqwest::StatusCode::OK); - let reply: serde_json::Value = response.json().await.expect("apply body"); - assert_eq!(reply["reloaded"], true); - assert_eq!(reply["restart_required"], true); - let applied = get_json(addr, "admin/config").await; - assert_eq!(applied["stt"]["window_seconds"], 8); - assert_eq!(applied["stt"]["interval_ms"], 250); - assert_eq!(applied["stt"]["vocabulary"], serde_json::json!(["WG21"])); - } - - #[tokio::test] - async fn revert_removes_all_shadows_without_touching_real_files() { - let (_temp, config, paths) = fixture(); - let config_path = paths.config_path.clone(); - let env_path = config_path.with_extension("env"); - let original_config = std::fs::read_to_string(&config_path).expect("read config"); - stage_gamma(&config_path); - write_shadow(&env_path, "HF_TOKEN=pending\n").expect("stage env"); - let (addr, _state) = serve_fixture(config, paths).await; - - let response = post(addr, "admin/config-revert").await; - - assert_eq!(response.status(), reqwest::StatusCode::OK); - let reply: serde_json::Value = response.json().await.expect("revert body"); - assert_eq!( - reply["reverted"], - serde_json::json!(["gateway.env.next", "gateway.toml.next"]) - ); - assert_eq!( - std::fs::read_to_string(&config_path).expect("re-read config"), - original_config - ); - assert!(!env_path.exists()); - } - - /// Two applies in flight at once share one command: the second attaches - /// to the first through the debounce, both replies carry the same - /// `applied` list, and the reload runs exactly once. - #[tokio::test] - async fn concurrent_applies_promote_the_pending_config_once() { - let (_temp, config, paths) = fixture(); - let config_path = paths.config_path.clone(); - let (addr, state, park) = serve_parked_fixture(config, paths).await; - let mut events = state.hub.subscribe(); - stage_gamma(&config_path); - - let first = tokio::spawn(post(addr, "admin/config-apply")); - park.entered().await; - let second = tokio::spawn(post(addr, "admin/config-apply")); - wait_until("the second apply to attach to the first", || { - state.commands.active_waiters() == 2 - }) - .await; - assert!( - state.commands.pending_commands().is_empty(), - "the second apply attached to the active one instead of queueing" - ); - park.release(); - - let first = first.await.expect("first apply task"); - let second = second.await.expect("second apply task"); - assert_eq!(first.status(), reqwest::StatusCode::OK); - assert_eq!(second.status(), reqwest::StatusCode::OK); - let first: serde_json::Value = first.json().await.expect("first body"); - let second: serde_json::Value = second.json().await.expect("second body"); - let expected = serde_json::json!(["gateway.toml"]); - assert_eq!(first["applied"], expected); - assert_eq!( - second["applied"], expected, - "both replies report the shared outcome" - ); - assert_eq!(first["reloaded"], true); - assert_eq!(second["reloaded"], true); - assert_eq!( - stages_begun(&mut events, "applying-config"), - 1, - "the attached duplicate never runs a second reload" - ); - assert!(!shadow_path(&config_path).exists()); - assert!(routes(&state, "gamma-model").await); - } - - /// An apply enqueued after the boot `LoadProfile` never displaces it: - /// the boot load settles on its own terms over the production worker, - /// then the apply runs over the table it published and completes. The - /// queue's FIFO rule under an active boot load is pinned in - /// `commands.rs`. - #[tokio::test] - async fn an_apply_after_the_boot_load_reloads_over_the_published_table() { - let (_temp, config, paths) = fixture(); - let config_path = paths.config_path.clone(); - let (addr, state) = serve_fixture(config, paths).await; - stage_gamma(&config_path); - - let boot = state.commands.enqueue(Command::load_profile( - ProfileName::parse("alpha").expect("profile name"), - CancellationToken::new(), - )); - wait_until("the boot load to settle", || { - state.commands.active_command().is_none() - }) - .await; - let outcome = tokio::time::timeout(Duration::from_secs(10), boot.outcome) - .await - .expect("the boot load settles") - .expect("the boot load settles with an outcome"); - assert!(outcome.is_ok(), "a remote-only profile loads: {outcome:?}"); - - let response = post(addr, "admin/config-apply").await; - assert_eq!(response.status(), reqwest::StatusCode::OK); - let reply: serde_json::Value = response.json().await.expect("apply body"); - assert_eq!(reply["reloaded"], true); - assert_eq!(reply["applied"], serde_json::json!(["gateway.toml"])); - assert!(!shadow_path(&config_path).exists()); - assert!(routes(&state, "gamma-model").await); - assert_eq!(live_profile(&state).await.as_deref(), Some("alpha")); - } - - /// A cancelled apply promotes nothing: the shadow stays on disk with its - /// contents, the dirty report is unchanged, the reply is the - /// cancellation envelope, and a retry applies the same change. - #[tokio::test] - async fn a_cancelled_apply_leaves_every_shadow_staged_and_a_retry_succeeds() { - let (_temp, config, paths) = fixture(); - let config_path = paths.config_path.clone(); - let original_config = std::fs::read_to_string(&config_path).expect("read config"); - let (addr, state, park) = serve_parked_fixture(config, paths).await; - stage_gamma(&config_path); - let staged = std::fs::read_to_string(shadow_path(&config_path)).expect("staged shadow"); - let dirty_before = get_json(addr, "admin/config-dirty").await; - assert_eq!(dirty_before["dirty"], true); - - let apply = tokio::spawn(post(addr, "admin/config-apply")); - park.entered().await; - assert!(state.commands.cancel_active()); - park.release(); - - assert_apply_cancelled(apply.await.expect("apply task")).await; - assert_eq!( - std::fs::read_to_string(shadow_path(&config_path)).expect("config shadow"), - staged, - "the config shadow is still staged" - ); - assert_eq!( - std::fs::read_to_string(&config_path).expect("re-read config"), - original_config, - "nothing was promoted" - ); - assert_eq!( - get_json(addr, "admin/config-dirty").await, - dirty_before, - "the dirty report is unchanged" - ); - assert!(!routes(&state, "gamma-model").await, "nothing went live"); - - // The retry parks at the same phase; a stored release lets it through. - park.release(); - let retry = post(addr, "admin/config-apply").await; - assert_eq!(retry.status(), reqwest::StatusCode::OK); - let reply: serde_json::Value = retry.json().await.expect("retry body"); - assert_eq!(reply["reloaded"], true); - assert_eq!(reply["applied"], serde_json::json!(["gateway.toml"])); - assert!(!shadow_path(&config_path).exists()); - assert!(routes(&state, "gamma-model").await); - } - - /// A save that lands mid-apply neither blocks nor is lost: the snapshot's - /// contents land in the real file, and the newer shadow stays pending as - /// the next change. - #[tokio::test] - async fn a_save_landing_mid_apply_stays_pending_while_the_snapshot_lands() { - let (_temp, config, paths) = fixture(); - let config_path = paths.config_path.clone(); - let (addr, state, park) = serve_parked_fixture(config, paths).await; - stage_gamma(&config_path); - - let apply = tokio::spawn(post(addr, "admin/config-apply")); - park.entered().await; - let save = tokio::time::timeout( - Duration::from_secs(10), - save_edited(addr, |body| { - body["model"][0]["description"] = serde_json::json!("edited mid-apply"); - }), - ) - .await - .expect("the save completes while the apply is active"); - assert_eq!(save.status(), reqwest::StatusCode::OK); - park.release(); - - let response = apply.await.expect("apply task"); - assert_eq!(response.status(), reqwest::StatusCode::OK); - let reply: serde_json::Value = response.json().await.expect("apply body"); - assert_eq!(reply["applied"], serde_json::json!(["gateway.toml"])); - let real = std::fs::read_to_string(&config_path).expect("read config"); - assert!( - real.contains("gamma-model") && !real.contains("edited mid-apply"), - "the snapshot's contents landed in the real file" - ); - let pending = std::fs::read_to_string(shadow_path(&config_path)).expect("config shadow"); - assert!( - pending.contains("edited mid-apply"), - "the newer save stays pending instead of being deleted" - ); - assert!(routes(&state, "gamma-model").await); - let dirty = get_json(addr, "admin/config-dirty").await; - assert_eq!(dirty["pending_files"], serde_json::json!(["gateway.toml"])); - } - - /// A revert during an active apply wins: the apply settles as cancelled, - /// its commit writes nothing, and the shadow is gone. - #[tokio::test] - async fn a_revert_during_an_active_apply_cancels_it_and_the_commit_writes_nothing() { - let (_temp, config, paths) = fixture(); - let config_path = paths.config_path.clone(); - let original_config = std::fs::read_to_string(&config_path).expect("read config"); - let (addr, state, park) = serve_parked_fixture(config, paths).await; - stage_gamma(&config_path); - - let apply = tokio::spawn(post(addr, "admin/config-apply")); - park.entered().await; - - let revert = post(addr, "admin/config-revert").await; - assert_eq!(revert.status(), reqwest::StatusCode::OK); - let reply: serde_json::Value = revert.json().await.expect("revert body"); - assert_eq!(reply["reverted"], serde_json::json!(["gateway.toml.next"])); - park.release(); - - assert_apply_cancelled(apply.await.expect("apply task")).await; - assert_eq!( - std::fs::read_to_string(&config_path).expect("re-read config"), - original_config, - "the cancelled apply's commit wrote nothing" - ); - assert!(!shadow_path(&config_path).exists()); - assert!(!routes(&state, "gamma-model").await); - assert_eq!(live_profile(&state).await.as_deref(), Some("alpha")); - wait_until("the queue to go idle", || { - state.commands.active_command().is_none() - }) - .await; - } -} diff --git a/crates/gateway/app/src/config_pending.rs b/crates/gateway/app/src/config_pending.rs deleted file mode 100644 index 9c694bba0..000000000 --- a/crates/gateway/app/src/config_pending.rs +++ /dev/null @@ -1,344 +0,0 @@ -//! Pending-state read routes: `GET /admin/config-pending` and -//! `GET /admin/config-dirty`. -//! -//! The write route (`config_write.rs`) stages the global config as a -//! `.next` shadow beside the real file; these routes read that pending -//! state back. Profile selection is never staged: `config-pending` reports -//! the selection the real `gateway.state.toml` persists, which may differ -//! from the running profile until the next start. -//! `config-dirty` is the cheap poll: whether any shadow exists, which -//! real files carry one, and which top-level sections the pending view -//! changes. The resolution machinery lives in -//! `gateway-config`; these handlers own auth, path assembly, -//! and the wire shape. - -use std::path::{Path, PathBuf}; - -use crate::AppState; -use crate::auth::AuthedCaller; -use crate::error::GatewayError; -use axum::Json; -use axum::extract::State; -use gateway_config::{ - Config, ProfileSelection, ProfileState, load_pending_config, pending_report, - profile_state_path, shadow_path, -}; - -/// The `GET /admin/config-pending` route: bearer-authed, renders the -/// shadow-preferred global config and the persisted profile selection. -/// -/// The reply keeps the existing `{"profile": ..., "boot": null}` envelope -/// for the current UI. `profile` contains the shadow-preferred global config -/// plus `active_profile`, read from the real `gateway.state.toml`: `null` -/// when no selection is persisted, otherwise the raw persisted name, even -/// when the config no longer defines it, so the UI can show a selection -/// that differs from the running profile or has gone stale. Secrets remain -/// redacted. -pub(crate) async fn admin_config_pending( - State(state): State, - _caller: AuthedCaller, -) -> Result, GatewayError> { - let _publication = state.apply.lock().await; - let config_path = crate::admin::config_path(&state)?.to_path_buf(); - let running_profile = state.profile_name().await; - let reply = tokio::task::spawn_blocking(move || { - let config = load_pending_for_running(&config_path, running_profile.as_deref())?; - let mut profile = config.to_json(); - if let Some(table) = profile.as_object_mut() { - table.insert( - "active_profile".to_owned(), - persisted_selection(&config_path)?.map_or(serde_json::Value::Null, |name| { - serde_json::Value::String(name) - }), - ); - } - Ok::<_, GatewayError>(serde_json::json!({ - "profile": profile, - "boot": null, - })) - }) - .await - .map_err(|join| GatewayError::PendingConfig(join.to_string()))??; - Ok(Json(reply)) -} - -/// Loads the shadow-preferred config under the running selection, which -/// may have come from a command-line or environment override and -/// therefore differ from persisted state. -pub(crate) fn load_pending_for_running( - config_path: &Path, - running_profile: Option<&str>, -) -> Result { - load_pending_config(config_path, &ProfileSelection::new(running_profile, None)) - .map_err(|error| pending_read_error(&error)) -} - -/// The profile name the real state file persists, `None` when the file is -/// absent. The name is not checked against any config: a stale selection -/// is still the persisted one. -fn persisted_selection(config_path: &Path) -> Result, GatewayError> { - let path = profile_state_path(config_path); - let raw = match std::fs::read_to_string(&path) { - Ok(raw) => raw, - Err(source) if source.kind() == std::io::ErrorKind::NotFound => return Ok(None), - Err(source) => { - return Err(GatewayError::PendingConfig(format!( - "read profile state {}: {source}", - path.display() - ))); - } - }; - let state = ProfileState::from_toml_str(&raw).map_err(|error| pending_read_error(&error))?; - Ok(Some(state.active_profile().to_owned())) -} - -/// The `GET /admin/config-dirty` route: bearer-authed, reports the -/// pending state from shadow existence and comparison. -/// -/// The reply is `{"dirty", "pending_files", "changed_sections"}`. `dirty` -/// is true when any shadow exists. `pending_files` names the real files -/// whose shadows are present - the global config and the env sibling - -/// rendered relative to the config directory with forward slashes, sorted. -/// `.env` shadows count toward `dirty` and `pending_files` only. Profile -/// selection is never pending, so neither list ever names the state file -/// or `active_profile`. -pub(crate) async fn admin_config_dirty( - State(state): State, - _caller: AuthedCaller, -) -> Result, GatewayError> { - let _publication = state.apply.lock().await; - let config_path = crate::admin::config_path(&state)?.to_path_buf(); - let reply = tokio::task::spawn_blocking(move || dirty_reply(&config_path)) - .await - .map_err(|join| GatewayError::PendingConfig(join.to_string()))??; - Ok(Json(reply)) -} - -/// Maps a config-crate failure on a pending read: saves validate before -/// writing, so an unresolvable pending state is a server fault (500) with -/// the full cause chain in the message. -fn pending_read_error(error: &gateway_config::ConfigError) -> GatewayError { - GatewayError::PendingConfig(crate::config_write::error_chain(error)) -} - -/// Every shadow on disk for one gateway and the sections they change. -pub(crate) struct ShadowCensus { - /// Real files whose shadows exist, in canonical form, without - /// duplicates. - pub(crate) files: Vec, - /// Top-level sections whose merged value the shadows change, sorted - /// and deduplicated. - pub(crate) sections: Vec, -} - -/// Collects the config shadow and one env shadow. -pub(crate) fn shadow_census(config_path: &Path) -> Result { - let profile = pending_report(config_path).map_err(|error| pending_read_error(&error))?; - let mut files: Vec = Vec::new(); - for file in &profile.shadowed_files { - push_unique(&mut files, file); - } - let sections = profile.changed_sections; - let env = config_path.with_extension("env"); - if shadow_path(&env).is_file() { - push_unique(&mut files, &env); - } - Ok(ShadowCensus { files, sections }) -} - -/// The directory config files render relative to. -pub(crate) fn config_root(config_path: &Path) -> Option<&Path> { - config_path.parent() -} - -/// Assembles the `GET /admin/config-dirty` body: the shadowed config file -/// plus the `.env` sibling, and the config shadow's section diff. -fn dirty_reply(config_path: &Path) -> Result { - let census = shadow_census(config_path)?; - let root = config_root(config_path); - let mut pending_files: Vec = census - .files - .iter() - .map(|file| relative_name(file, root)) - .collect(); - pending_files.sort_unstable(); - Ok(serde_json::json!({ - "dirty": !pending_files.is_empty(), - "pending_files": pending_files, - "changed_sections": census.sections, - })) -} - -/// Appends `file` unless its canonical form is already listed. The same -/// file reaches here under different spellings (the profile chain writes -/// `profiles/../gateway.toml`, the boot path is `gateway.toml`), so the -/// list holds canonical forms. -fn push_unique(shadowed: &mut Vec, file: &Path) { - let canonical = canonical_form(file); - if !shadowed.contains(&canonical) { - shadowed.push(canonical); - } -} - -/// A comparable form of `path`: canonicalized when it exists, otherwise -/// its canonicalized parent plus its own name (a real `.env` may not -/// exist while its shadow does), otherwise the path as given. -pub(crate) fn canonical_form(path: &Path) -> PathBuf { - if let Ok(canonical) = path.canonicalize() { - return canonical; - } - if let (Some(parent), Some(name)) = (path.parent(), path.file_name()) - && let Ok(parent) = parent.canonicalize() - { - return parent.join(name); - } - path.to_path_buf() -} - -/// Renders one shadowed real file for the wire: relative to `root` when -/// it sits beneath it, the full path otherwise, always with forward -/// slashes for a stable shape across platforms. -pub(crate) fn relative_name(file: &Path, root: Option<&Path>) -> String { - let file = canonical_form(file); - let relative = root - .map(canonical_form) - .and_then(|root| file.strip_prefix(&root).ok().map(Path::to_path_buf)) - .unwrap_or(file); - let parts: Vec = relative - .components() - .map(|component| component.as_os_str().to_string_lossy().into_owned()) - .collect(); - parts.join("/") -} - -#[cfg(test)] -mod tests { - use gateway_config::{Config, ProfileSelection, profile_state_path, shadow_path, write_shadow}; - - use super::*; - use crate::test_support::{AdminPaths, serve_with_paths}; - - const PROFILE_CONFIG: &str = r#" -config-version = 0 - -[server] -bind = "127.0.0.1:0" -api_key = "test-token" - -[[profile]] -name = "alpha" -models = [] - -[[profile]] -name = "beta" -models = [] -"#; - - #[test] - fn dirty_reply_lists_the_config_shadow_and_never_active_profile() { - let temp = tempfile::TempDir::new().expect("temp dir"); - let config = temp.path().join("gateway.toml"); - std::fs::write( - &config, - "config-version = 0\n[server]\nbind = \"127.0.0.1:0\"\napi_key = \"k\"\n", - ) - .expect("write config"); - let state = profile_state_path(&config); - std::fs::write(&state, "active_profile = \"alpha\"\n").expect("write state"); - write_shadow( - &config, - "config-version = 0\n[server]\nbind = \"127.0.0.1:0\"\napi_key = \"changed\"\n", - ) - .expect("write config shadow"); - // A leftover from before selection stopped staging: never reported. - write_shadow(&state, "active_profile = \"beta\"\n").expect("write stale state shadow"); - - let reply = dirty_reply(&config).expect("dirty reply"); - - assert_eq!(reply["dirty"], true); - assert_eq!(reply["pending_files"], serde_json::json!(["gateway.toml"])); - assert_eq!(reply["changed_sections"], serde_json::json!(["server"])); - assert!(shadow_path(&config).is_file()); - } - - /// A state file that exists but cannot be read is a server fault whose - /// message names the file, so the 500 body stands on its own. - #[test] - fn an_unreadable_state_file_names_itself_in_the_error() { - let temp = tempfile::TempDir::new().expect("temp dir"); - let config = temp.path().join("gateway.toml"); - let state = profile_state_path(&config); - // A directory in the file's place reads as an error that is not - // `NotFound` on every platform. - std::fs::create_dir(&state).expect("occupy the state path"); - - let error = persisted_selection(&config).expect_err("a directory is not a state file"); - - let GatewayError::PendingConfig(message) = error else { - panic!("a read failure is a pending-config fault: {error:?}"); - }; - assert!( - message.contains(&state.display().to_string()), - "the message names the file: {message}" - ); - } - - /// Serves `PROFILE_CONFIG` from `temp` with `running` as the live - /// profile (a command-line override), leaving the state file to the test. - async fn serve_profiles(temp: &tempfile::TempDir, running: &str) -> std::net::SocketAddr { - let config_path = temp.path().join("gateway.toml"); - std::fs::write(&config_path, PROFILE_CONFIG).expect("write config"); - let config = Config::load(&config_path, &ProfileSelection::new(Some(running), None)) - .expect("load command-line override"); - serve_with_paths( - config, - AdminPaths { - fixture_dir: temp.path().to_path_buf(), - active: running.to_owned(), - config_path, - }, - ) - .await - } - - async fn pending_active_profile(addr: std::net::SocketAddr) -> serde_json::Value { - let response = reqwest::Client::new() - .get(format!("http://{addr}/admin/config-pending")) - .bearer_auth("test-token") - .send() - .await - .expect("pending request sends"); - assert_eq!(response.status(), reqwest::StatusCode::OK); - let mut body: serde_json::Value = response.json().await.expect("pending body is JSON"); - assert!(body["boot"].is_null(), "the envelope keeps its shape"); - body["profile"]["active_profile"].take() - } - - #[tokio::test] - async fn pending_view_reports_null_when_no_selection_is_persisted() { - let temp = tempfile::TempDir::new().expect("temp dir"); - let addr = serve_profiles(&temp, "beta").await; - - assert!( - pending_active_profile(addr).await.is_null(), - "a running command-line override is not a persisted selection" - ); - } - - #[tokio::test] - async fn pending_view_reports_the_state_files_name_even_when_stale() { - let temp = tempfile::TempDir::new().expect("temp dir"); - let addr = serve_profiles(&temp, "beta").await; - let state = profile_state_path(&temp.path().join("gateway.toml")); - - std::fs::write(&state, "active_profile = \"alpha\"\n").expect("write state"); - assert_eq!(pending_active_profile(addr).await, "alpha"); - - std::fs::write(&state, "active_profile = \"retired\"\n").expect("write stale state"); - assert_eq!( - pending_active_profile(addr).await, - "retired", - "the raw persisted name is reported even when the config no longer defines it" - ); - } -} diff --git a/crates/gateway/app/src/config_shadow.rs b/crates/gateway/app/src/config_shadow.rs new file mode 100644 index 000000000..497fe3d12 --- /dev/null +++ b/crates/gateway/app/src/config_shadow.rs @@ -0,0 +1,87 @@ +//! Shadow-file bookkeeping: which real config files carry a pending +//! `.next` shadow, and how those paths are rendered for the wire. +//! +//! Three readers share this. `GET /admin/config-dirty` reports the census +//! as pending state, `POST /admin/config-apply` takes it under the apply +//! lock to decide what to promote, and the Apply command renders the same +//! file names into its outcome. The shadow mechanics themselves live in +//! `gateway-config`; this module only assembles the census and puts its +//! paths in comparable and displayable form. + +use std::path::{Path, PathBuf}; + +use gateway_config::{pending_report, shadow_path}; + +use crate::error::{GatewayError, pending_read_error}; + +/// Every shadow on disk for one gateway and the sections they change. +pub(crate) struct ShadowCensus { + /// Real files whose shadows exist, in canonical form, without + /// duplicates. + pub(crate) files: Vec, + /// Top-level sections whose merged value the shadows change, sorted + /// and deduplicated. + pub(crate) sections: Vec, +} + +/// Collects the config shadow and one env shadow. +pub(crate) fn shadow_census(config_path: &Path) -> Result { + let profile = pending_report(config_path).map_err(|error| pending_read_error(&error))?; + let mut files: Vec = Vec::new(); + for file in &profile.shadowed_files { + push_unique(&mut files, file); + } + let sections = profile.changed_sections; + let env = config_path.with_extension("env"); + if shadow_path(&env).is_file() { + push_unique(&mut files, &env); + } + Ok(ShadowCensus { files, sections }) +} + +/// The directory config files render relative to. +pub(crate) fn config_root(config_path: &Path) -> Option<&Path> { + config_path.parent() +} + +/// Appends `file` unless its canonical form is already listed. The same +/// file reaches here under different spellings (the profile chain writes +/// `profiles/../gateway.toml`, the boot path is `gateway.toml`), so the +/// list holds canonical forms. +fn push_unique(shadowed: &mut Vec, file: &Path) { + let canonical = canonical_form(file); + if !shadowed.contains(&canonical) { + shadowed.push(canonical); + } +} + +/// A comparable form of `path`: canonicalized when it exists, otherwise +/// its canonicalized parent plus its own name (a real `.env` may not +/// exist while its shadow does), otherwise the path as given. +pub(crate) fn canonical_form(path: &Path) -> PathBuf { + if let Ok(canonical) = path.canonicalize() { + return canonical; + } + if let (Some(parent), Some(name)) = (path.parent(), path.file_name()) + && let Ok(parent) = parent.canonicalize() + { + return parent.join(name); + } + path.to_path_buf() +} + +/// Renders one shadowed real file for the wire: relative to `root` when +/// it sits beneath it, the full path otherwise, always with forward +/// slashes for a stable shape across platforms. +pub(crate) fn relative_name(file: &Path, root: Option<&Path>) -> String { + let file = canonical_form(file); + let relative = root + .map(canonical_form) + .and_then(|root| file.strip_prefix(&root).ok().map(Path::to_path_buf)) + .unwrap_or(file); + let parts: Vec = relative + .components() + .map(|component| component.as_os_str().to_string_lossy().into_owned()) + .collect(); + parts.join("/") +} diff --git a/crates/gateway/app/src/config_write.rs b/crates/gateway/app/src/config_write.rs deleted file mode 100644 index 0b3860f51..000000000 --- a/crates/gateway/app/src/config_write.rs +++ /dev/null @@ -1,270 +0,0 @@ -//! Shadow-file write route: `PUT /admin/config`. -//! -//! The route stages the pending global TOML document beside its real file -//! (`gateway.toml` gains `gateway.toml.next`) without touching the real -//! file or reloading the gateway. The body is the config JSON shape -//! `GET /admin/config` returns; secrets arriving as `"***"` preserve the -//! existing value, and the merged pending configuration is validated -//! before any shadow is written, so a bad save leaves nothing behind. The -//! shadow mechanics live in `gateway-config`; these handlers -//! own auth, path resolution, and the JSON-to-TOML boundary. - -use axum::Json; -use axum::extract::State; -use axum::extract::rejection::JsonRejection; -use gateway_config::{ConfigErrorKind, save_config_shadow}; - -use crate::AppState; -use crate::auth::AuthedCaller; -use crate::error::GatewayError; - -/// The `PUT /admin/config` route: bearer-authed, stages the global config. -/// -/// The body is the full `GET /admin/config` JSON shape. Redacted `"***"` -/// secrets are restored from the current pending chain, the merged result -/// is validated like a real load, and only then is the shadow written -/// atomically. The real file stays untouched and nothing reloads. The reply -/// is `{"shadow": path}`. A body carrying `active_profile` is rejected as a -/// config-write error: selection belongs to `POST /admin/switch-profile`. -pub(crate) async fn admin_put_config( - State(state): State, - _caller: AuthedCaller, - body: Result, JsonRejection>, -) -> Result, GatewayError> { - // Deferring the extractor keeps auth first and puts the rejection in - // the gateway's JSON error envelope instead of axum's plain-text 400. - let Json(body) = - body.map_err(|rejection| GatewayError::MalformedRequest(rejection.body_text()))?; - // Saves take the apply lock: apply promotes shadows without - // re-validating, so the combination it promotes must be one the latest - // save validated whole - saves serialize with apply, revert, and each - // other. - let _guard = state.apply.lock().await; - let config = crate::admin::config_path(&state)?.to_path_buf(); - let document = toml_document(body)?; - let shadows = tokio::task::spawn_blocking(move || save_config_shadow(&config, document)) - .await - .map_err(|join| GatewayError::ConfigWriteIo(Box::new(join)))? - .map_err(config_write_error)?; - Ok(Json(serde_json::json!({ - "shadow": shadows.config.display().to_string(), - }))) -} - -/// Maps a config-crate failure onto the wire: a failed disk write is a -/// server fault (500), everything else - validation, parse, unresolved -/// `${VAR}`, an unreadable chain file - rejects the payload (422) with the -/// full cause chain so the UI can show why the save failed. -pub(crate) fn config_write_error(error: gateway_config::ConfigError) -> GatewayError { - if error.kind() == ConfigErrorKind::Write { - GatewayError::ConfigWriteIo(Box::new(error)) - } else { - GatewayError::ConfigWriteRejected(error_chain(&error)) - } -} - -/// Renders an error and every source beneath it as one `; `-joined line. -pub(crate) fn error_chain(error: &dyn std::error::Error) -> String { - let mut text = error.to_string(); - let mut source = error.source(); - while let Some(cause) = source { - text.push_str("; "); - text.push_str(&cause.to_string()); - source = cause.source(); - } - text -} - -/// Converts the request body into the TOML document a shadow save takes. -fn toml_document(body: serde_json::Value) -> Result { - let value = json_to_toml(body)?.ok_or_else(|| { - GatewayError::ConfigWriteRejected("the body must be a JSON object".to_owned()) - })?; - if value.is_table() { - Ok(value) - } else { - Err(GatewayError::ConfigWriteRejected( - "the body must be a JSON object".to_owned(), - )) - } -} - -/// Converts a JSON value into a TOML one. `None` means "absent": TOML has -/// no null, so a null object member simply drops out (the serializer skips -/// absent optionals on the way out, and the deserializer defaults them on -/// the way back in). A null inside an array has no such reading and is an -/// error, as is a number outside TOML's ranges. -fn json_to_toml(value: serde_json::Value) -> Result, GatewayError> { - Ok(Some(match value { - serde_json::Value::Null => return Ok(None), - serde_json::Value::Bool(flag) => toml::Value::Boolean(flag), - serde_json::Value::Number(number) => { - if let Some(integer) = number.as_i64() { - toml::Value::Integer(integer) - } else if let Some(float) = number.as_f64() { - toml::Value::Float(float) - } else { - return Err(GatewayError::ConfigWriteRejected(format!( - "number {number} does not fit a TOML value" - ))); - } - } - serde_json::Value::String(text) => toml::Value::String(text), - serde_json::Value::Array(items) => { - let mut converted = Vec::with_capacity(items.len()); - for item in items { - let Some(element) = json_to_toml(item)? else { - return Err(GatewayError::ConfigWriteRejected( - "null inside an array has no TOML form".to_owned(), - )); - }; - converted.push(element); - } - toml::Value::Array(converted) - } - serde_json::Value::Object(members) => { - let mut table = toml::map::Map::new(); - for (key, member) in members { - if let Some(converted) = json_to_toml(member)? { - table.insert(key, converted); - } - } - toml::Value::Table(table) - } - })) -} - -#[cfg(test)] -mod tests { - use gateway_config::{Config, ProfileSelection, profile_state_path, shadow_path}; - - use crate::test_support::{AdminPaths, serve_with_paths}; - - const CONFIG: &str = r#" -config-version = 0 - -[server] -bind = "127.0.0.1:0" -api_key = "test-token" - -[[endpoint]] -id = "fake" -protocol = "openai" -base_url = "http://127.0.0.1:9" -api_key = "" - -[[model]] -name = "alpha-model" -description = "alpha" -context = 1024 -upstream = "alpha" -endpoints = ["fake"] - -[[model]] -name = "beta-model" -description = "beta" -context = 1024 -upstream = "beta" -endpoints = ["fake"] - -[[profile]] -name = "alpha" -models = [] - -[[profile]] -name = "beta" -models = [] -"#; - - fn fixture() -> (tempfile::TempDir, Config, AdminPaths) { - let temp = tempfile::TempDir::new().expect("temp dir"); - let config_path = temp.path().join("gateway.toml"); - std::fs::write(&config_path, CONFIG).expect("write config"); - std::fs::write( - profile_state_path(&config_path), - "active_profile = \"alpha\"\n", - ) - .expect("write state"); - let config = Config::load(&config_path, &ProfileSelection::default()).expect("load config"); - let paths = AdminPaths { - fixture_dir: temp.path().to_path_buf(), - active: "alpha".to_owned(), - config_path, - }; - (temp, config, paths) - } - - /// The live config as a save body: `GET /admin/config` also reports the - /// running `active_profile`, which is not a configuration key and never - /// goes back in a save. - async fn save_body(addr: std::net::SocketAddr) -> serde_json::Value { - let mut body: serde_json::Value = reqwest::Client::new() - .get(format!("http://{addr}/admin/config")) - .bearer_auth("test-token") - .send() - .await - .expect("get sends") - .json() - .await - .expect("config json"); - body.as_object_mut() - .expect("the config is an object") - .remove("active_profile"); - body - } - - async fn put_config(addr: std::net::SocketAddr, body: &serde_json::Value) -> reqwest::Response { - reqwest::Client::new() - .put(format!("http://{addr}/admin/config")) - .bearer_auth("test-token") - .json(body) - .send() - .await - .expect("put sends") - } - - #[tokio::test] - async fn a_save_carrying_active_profile_is_rejected_and_stages_nothing() { - let (_temp, config, paths) = fixture(); - let config_path = paths.config_path.clone(); - let addr = serve_with_paths(config, paths).await; - let mut body = save_body(addr).await; - body["active_profile"] = serde_json::json!("beta"); - - let response = put_config(addr, &body).await; - - assert_eq!(response.status(), reqwest::StatusCode::UNPROCESSABLE_ENTITY); - let error: serde_json::Value = response.json().await.expect("error envelope"); - assert_eq!(error["error"]["code"], "config_write_rejected"); - assert!( - error["error"]["message"].as_str().is_some_and(|message| { - message.contains("active_profile is not a configuration key") - && message.contains("POST /admin/switch-profile") - }), - "the message names the switch route: {error}" - ); - assert!(!shadow_path(&config_path).exists()); - assert!(!shadow_path(&profile_state_path(&config_path)).exists()); - } - - #[tokio::test] - async fn a_save_replies_with_the_config_shadow_alone() { - let (_temp, config, paths) = fixture(); - let config_path = paths.config_path.clone(); - let addr = serve_with_paths(config, paths).await; - let mut body = save_body(addr).await; - body["model"][0]["description"] = serde_json::json!("edited"); - - let response = put_config(addr, &body).await; - - assert_eq!(response.status(), reqwest::StatusCode::OK); - let reply: serde_json::Value = response.json().await.expect("save reply"); - assert_eq!( - reply, - serde_json::json!({ "shadow": shadow_path(&config_path).display().to_string() }), - "the reply carries only the config shadow" - ); - assert!(shadow_path(&config_path).is_file()); - assert!(!shadow_path(&profile_state_path(&config_path)).exists()); - } -} diff --git a/crates/gateway/app/src/dialect.rs b/crates/gateway/app/src/dialect.rs index 91aa0393a..fd09a9fb0 100644 --- a/crates/gateway/app/src/dialect.rs +++ b/crates/gateway/app/src/dialect.rs @@ -25,8 +25,8 @@ use crate::wire::{ChatChunk, ChatChunkChoice, ChatRequest, ChatResponse}; /// The `tool_dialect` config value selecting this dialect. pub(crate) use gateway_routing::GEMMA3_TOOL_CODE; -/// Translate an outgoing request for the emulated dialect: strip the tool -/// surface the backend cannot honor and prepend the tool-code system guide. +/// Translates an outgoing request for the emulated dialect: strips the tool +/// surface the backend cannot honor and prepends the tool-code system guide. /// /// Mutation is atomic: the guide is fully rendered before anything is /// removed, so a preparation failure leaves the request unmodified. @@ -59,7 +59,7 @@ pub(crate) fn prepare_request(request: &mut ChatRequest) -> Result<(), GatewayEr Ok(()) } -/// Parse each choice's message content for tool fences and rewrite the +/// Parses each choice's message content for tool fences and rewrites the /// response in place: well-formed fences become wire `tool_calls` with a /// `tool_calls` finish reason; a malformed fence empties the content and /// attaches a `gateway_warning`, logged at warn; ordinary prose is untouched. @@ -108,7 +108,7 @@ pub(crate) fn apply_response(response: &mut ChatResponse, model: &str) { } } -/// Re-emit a dialect-rewritten buffered response as a synthetic chunk +/// Re-emits a dialect-rewritten buffered response as a synthetic chunk /// stream, so the emulated dialect serves `stream: true` callers. /// /// The tool-code fence can only be parsed from the whole reply, so the @@ -179,7 +179,7 @@ struct ParsedCall { } impl ParsedCall { - /// Render as an OpenAI `tool_calls` entry: `function.arguments` is the + /// Renders as an OpenAI `tool_calls` entry: `function.arguments` is the /// arguments object JSON-encoded into a string, as the wire shape requires. fn to_wire(&self) -> Value { serde_json::json!({ @@ -207,7 +207,7 @@ enum ContentParse { Malformed(String), } -/// Classify model content as prose, tool calls, or malformed protocol. +/// Classifies model content as prose, tool calls, or malformed protocol. /// /// The content is protocol only when it begins with a recognized tool fence; /// prose that merely mentions a fence later stays text. Once protocol intent is @@ -269,7 +269,7 @@ enum Peel<'a> { NotAFence, } -/// Peel one leading ` ```tool_code ` fence into Python-style `name(k=v)` calls. +/// Peels one leading ` ```tool_code ` fence into Python-style `name(k=v)` calls. /// /// `next_id` is a run-wide monotonic counter used to mint each call's synthetic /// id; it is advanced once per parsed call so ids stay unique across fences. @@ -300,7 +300,7 @@ fn peel_tool_code_fence<'a>(input: &'a str, next_id: &mut usize) -> Peel<'a> { Peel::Calls(calls, after) } -/// Peel one leading ` ```json ` / ` ``` ` fence that holds OpenAI `tool_calls`. +/// Peels one leading ` ```json ` / ` ``` ` fence that holds OpenAI `tool_calls`. /// /// A code fence is only tool protocol when its body decodes to a JSON object /// carrying a non-empty `tool_calls` array; anything else is an ordinary data @@ -325,14 +325,15 @@ fn peel_json_tool_calls_fence(input: &str) -> Peel<'_> { } match parse_openai_tool_calls(raw_calls) { Ok(calls) => Peel::Calls(calls, after), - Err(rejection) => Peel::Malformed(rejection.to_string()), + Err(rejection) => Peel::Malformed(crate::error::error_chain(&rejection)), } } /// Why one OpenAI `tool_calls` entry was rejected rather than coerced. /// -/// The display text becomes the turn's `gateway_warning` verbatim, so each -/// variant's message is the exact wire string. +/// The rendered `source()` chain becomes the turn's `gateway_warning`, so +/// each variant's message is the exact wire string and a cause-bearing +/// variant contributes its cause through `source()`, not its message. #[derive(Debug, thiserror::Error)] enum ToolCallRejection { /// The entry was not a JSON object. @@ -363,9 +364,9 @@ enum ToolCallRejection { #[error("tool call name was blank")] BlankName, /// The function's `arguments` string did not decode as JSON. The decode - /// failure is retained as the cause; its text stays in the message - /// because the message is the wire warning. - #[error("tool call arguments were not valid JSON: {0}")] + /// failure is retained as the `source()`; the wire-warning renderer + /// walks the chain to include its text. + #[error("tool call arguments were not valid JSON")] ArgumentsNotJson(#[source] serde_json::Error), /// The decoded `arguments` were not a JSON object. #[error("tool call arguments did not decode to an object")] @@ -375,7 +376,7 @@ enum ToolCallRejection { ArgumentsMissing, } -/// Parse the OpenAI `message.tool_calls` array into [`ParsedCall`]s. +/// Parses the OpenAI `message.tool_calls` array into [`ParsedCall`]s. /// /// Each call must be an object with a nonblank string `id`, a `type` of /// `"function"`, an object `function` carrying a nonblank string `name`, and @@ -452,7 +453,7 @@ fn strip_fence_open<'a>(input: &'a str, language: &str) -> Option<&'a str> { Some(rest) } -/// Split `input` at the first standalone closing fence line (a line whose +/// Splits `input` at the first standalone closing fence line (a line whose /// trimmed content is exactly ```` ``` ````), returning the body before it and /// the text after it. /// @@ -507,7 +508,7 @@ fn is_identifier(s: &str) -> bool { chars.all(|c| c == '_' || c.is_ascii_alphanumeric()) } -/// Parse one `name(args)` call line into a [`ParsedCall`]. +/// Parses one `name(args)` call line into a [`ParsedCall`]. /// /// The name must be an identifier, the arguments live between the first `(` and /// the final `)`, and the `)` must end the non-whitespace input so trailing text @@ -537,7 +538,7 @@ fn parse_tool_code_call(line: &str, index: usize) -> Option { }) } -/// Parse the argument list into a JSON object. +/// Parses the argument list into a JSON object. /// /// Arguments are either all keyword (`key=`) or all positional /// (``); mixing the two forms is rejected, as is a duplicate keyword key. @@ -652,7 +653,7 @@ fn top_level_assignment(part: &str) -> Option { .flatten() } -/// Decode one argument token as a complete JSON value. +/// Decodes one argument token as a complete JSON value. /// /// Strings decode their escapes, and null, numbers, booleans, arrays, and /// objects parse to the same [`Value`] the wire renderer emits. A bare word, an @@ -665,7 +666,7 @@ fn parse_json_value(token: &str) -> Option { serde_json::from_str::(token).ok() } -/// Map positional `tool_code` args onto schema-ish parameter names. +/// Maps positional `tool_code` args onto schema-ish parameter names. /// /// Gemma IT frequently emits `search("...")` / `fetch("https://...")` instead /// of keyword form. Keep this table aligned with shipped tool aliases. @@ -742,7 +743,7 @@ fn render_signature(function: &Value, name: &str) -> String { } } -/// Render a system guide from OpenAI-shaped `tools`, or `None` when the list is +/// Renders a system guide from OpenAI-shaped `tools`, or `None` when the list is /// empty or lists no usable tool. fn render_tool_guide(list: &[Value]) -> Option { if list.is_empty() { @@ -931,6 +932,30 @@ mod tests { )); } + #[test] + fn json_tool_calls_fence_malformed_arguments_warning_carries_the_decode_error() { + // `arguments` is a string, but not JSON: the fence is recognized as + // tool protocol and the wire warning must name the decode failure + // from the rejection's source chain, not just the rejection message. + let content = "```json\n{\"tool_calls\": [{\"id\": \"c1\", \"type\": \"function\", \"function\": {\"name\": \"search\", \"arguments\": \"{not json\"}}]}\n```"; + let expected_cause = serde_json::from_str::("{not json") + .expect_err("the fixture arguments must not decode") + .to_string(); + match parse_content_tool_dialect(content) { + ContentParse::Malformed(warning) => { + assert!( + warning.contains("tool call arguments were not valid JSON"), + "warning must name the rejection: {warning}" + ); + assert!( + warning.contains(&expected_cause), + "warning must carry the decode error {expected_cause:?}: {warning}" + ); + } + other => panic!("expected malformed, got {}", variant_name(&other)), + } + } + fn variant_name(parse: &ContentParse) -> &'static str { match parse { ContentParse::NotProtocol => "not-protocol", diff --git a/crates/gateway/app/src/error.rs b/crates/gateway/app/src/error.rs index 493c5b939..3572e5f58 100644 --- a/crates/gateway/app/src/error.rs +++ b/crates/gateway/app/src/error.rs @@ -58,6 +58,7 @@ pub(crate) enum GatewayError { /// A transport- or protocol-level failure from the upstream seam. The /// variants live in [`ProtocolError`]; the gateway wraps them so a route /// handler deals with one error type. + #[non_exhaustive] #[error(transparent)] Protocol(#[from] ProtocolError), @@ -127,7 +128,7 @@ pub(crate) enum GatewayError { /// Some target-profile local models started while others failed. #[cfg(feature = "local")] #[non_exhaustive] - #[error("profile {profile} started partially; loaded: {loaded:?}; failed: {failed:?}")] + #[error("profile {profile} started partially; loaded: {loaded:?}; not started: {failed:?}")] PartialStart { /// Target profile now active in degraded mode. profile: String, @@ -137,6 +138,14 @@ pub(crate) enum GatewayError { failed: Vec, }, + /// A blocking-pool task the route dispatched through [`blocking`] did + /// not run to completion: it panicked, or the runtime is shutting down. + /// Never the caller's fault, whatever the task was doing, so every + /// route maps a join failure here instead of choosing a domain variant. + #[non_exhaustive] + #[error("blocking task failed")] + BlockingTask(#[source] Box), + /// A file-backed admin route was reached without a known config path. #[error("config path not configured")] ConfigPathUnavailable, @@ -186,11 +195,6 @@ pub(crate) enum GatewayError { #[error("env file unreadable")] EnvFile(#[source] Box), - /// The `GET /admin/system` sampling task did not run to completion. - #[non_exhaustive] - #[error("system metrics sampling failed")] - SystemMetrics(#[source] Box), - /// `POST /admin/reveal` named a path that does not exist. #[non_exhaustive] #[error("reveal path not found: {0}")] @@ -299,7 +303,7 @@ impl From for GatewayError { } impl GatewayError { - /// Wrap a body-decode failure as a protocol error (not a transport error), + /// Wraps a body-decode failure as a protocol error (not a transport error), /// preserving the cause via `source()`. See [`ProtocolError::upstream_protocol`]. #[must_use] pub(crate) fn upstream_protocol( @@ -308,7 +312,7 @@ impl GatewayError { GatewayError::Protocol(ProtocolError::upstream_protocol(source)) } - /// Wrap a command failure (the boot load, an apply, an unload) at + /// Wraps a command failure (the boot load, an apply, an unload) at /// `stage`, preserving the cause. #[must_use] pub(crate) fn switch_failed( @@ -321,14 +325,14 @@ impl GatewayError { } } - /// Wrap a cache-operation failure, preserving the cause. + /// Wraps a cache-operation failure, preserving the cause. #[cfg(feature = "local")] #[must_use] pub(crate) fn cache(source: impl std::error::Error + Send + Sync + 'static) -> GatewayError { GatewayError::Cache(Box::new(source)) } - /// Wrap a model-info read or parse failure, preserving the cause. + /// Wraps a model-info read or parse failure, preserving the cause. #[cfg(feature = "local")] #[must_use] pub(crate) fn model_info( @@ -337,14 +341,6 @@ impl GatewayError { GatewayError::ModelInfo(Box::new(source)) } - /// Wrap a system-metrics sampling failure, preserving the cause. - #[must_use] - pub(crate) fn system_metrics( - source: impl std::error::Error + Send + Sync + 'static, - ) -> GatewayError { - GatewayError::SystemMetrics(Box::new(source)) - } - /// The `(status, type, code)` triple for the OpenAI error envelope. #[expect( clippy::too_many_lines, @@ -433,6 +429,11 @@ impl GatewayError { "server_error", "partial_start", ), + GatewayError::BlockingTask(_) => ( + StatusCode::INTERNAL_SERVER_ERROR, + "server_error", + "blocking_task_failed", + ), GatewayError::ConfigPathUnavailable => ( StatusCode::BAD_REQUEST, "invalid_request_error", @@ -468,11 +469,6 @@ impl GatewayError { "server_error", "env_file_error", ), - GatewayError::SystemMetrics(_) => ( - StatusCode::INTERNAL_SERVER_ERROR, - "server_error", - "system_metrics_error", - ), GatewayError::RevealPathNotFound(_) => ( StatusCode::NOT_FOUND, "invalid_request_error", @@ -562,10 +558,80 @@ impl IntoResponse for GatewayError { } } +/// Renders an error and every source beneath it as one `; `-joined line. +/// +/// The gateway's wire messages carry one line, so a variant that wants +/// the whole chain in its message flattens it here. The multi-line form +/// the binary writes to the log and to stderr is a different rendering +/// and stays in `main.rs`. +/// +/// A cause that renders as nothing, and a cause whose text the +/// accumulated rendering already contains, are both skipped: some +/// variants copy their source's text into their own message, and +/// appending that cause again would print it twice. The check is a plain +/// substring test on the text rendered so far. +pub(crate) fn error_chain(error: &dyn std::error::Error) -> String { + let mut text = error.to_string(); + let mut source = error.source(); + while let Some(cause) = source { + let cause_text = cause.to_string(); + if !cause_text.is_empty() && !text.contains(&cause_text) { + text.push_str("; "); + text.push_str(&cause_text); + } + source = cause.source(); + } + text +} + +/// Maps a config-crate failure onto the wire: a failed disk write is a +/// server fault (500), everything else - validation, parse, unresolved +/// `${VAR}`, an unreadable chain file - rejects the payload (422) with the +/// full cause chain so the UI can show why the save failed. +pub(crate) fn config_write_error(error: gateway_config::ConfigError) -> GatewayError { + if error.kind() == gateway_config::ConfigErrorKind::Write { + GatewayError::ConfigWriteIo(Box::new(error)) + } else { + GatewayError::ConfigWriteRejected(error_chain(&error)) + } +} + +/// Maps a config-crate failure on a pending read: saves validate before +/// writing, so an unresolvable pending state is a server fault (500) with +/// the full cause chain in the message. +pub(crate) fn pending_read_error(error: &gateway_config::ConfigError) -> GatewayError { + GatewayError::PendingConfig(error_chain(error)) +} + +/// Runs `work` on tokio's blocking pool and hands back its value. +/// +/// Every route that touches the filesystem, a blocking client, or an OS +/// counter goes through here, so the one thing that can fail around the +/// work - the join, when the task panicked or the runtime is draining - +/// is mapped in one place to [`GatewayError::BlockingTask`]. A closure +/// that itself returns a `Result` composes as `blocking(..).await??` (or +/// `.await?.map_err(..)?`), keeping the domain error mapping beside the +/// domain code and the join mapping out of it. +pub(crate) async fn blocking(work: F) -> Result +where + F: FnOnce() -> T + Send + 'static, + T: Send + 'static, +{ + tokio::task::spawn_blocking(work) + .await + .map_err(|join| GatewayError::BlockingTask(Box::new(join))) +} + /// A JSON body extractor whose rejections render in the OpenAI error /// envelope: a malformed body, a wrong content type, or a failed /// deserialize all answer [`GatewayError::MalformedRequest`] carrying the /// rejection's detail, never axum's plain-text rejection. +/// +/// [`WireQuery`] and [`WirePath`] are its siblings for the query string +/// and path captures. All three are fallible extractors, so a handler +/// lists them after its auth extractor: extractors run in argument order, +/// and an unauthenticated caller must earn 401 before its malformed input +/// earns 400. pub(crate) struct WireJson(pub(crate) T); impl axum::extract::FromRequest for WireJson @@ -586,16 +652,56 @@ where } } +/// A query-string extractor whose rejection renders in the OpenAI error +/// envelope as [`GatewayError::MalformedRequest`]; see [`WireJson`]. +pub(crate) struct WireQuery(pub(crate) T); + +impl axum::extract::FromRequestParts for WireQuery +where + T: serde::de::DeserializeOwned, + S: Send + Sync, +{ + type Rejection = GatewayError; + + async fn from_request_parts( + parts: &mut axum::http::request::Parts, + state: &S, + ) -> Result, GatewayError> { + match axum::extract::Query::::from_request_parts(parts, state).await { + Ok(axum::extract::Query(value)) => Ok(WireQuery(value)), + Err(rejection) => Err(GatewayError::MalformedRequest(rejection.body_text())), + } + } +} + +/// A path-capture extractor whose rejection renders in the OpenAI error +/// envelope as [`GatewayError::MalformedRequest`]; see [`WireJson`]. +pub(crate) struct WirePath(pub(crate) T); + +impl axum::extract::FromRequestParts for WirePath +where + T: serde::de::DeserializeOwned + Send, + S: Send + Sync, +{ + type Rejection = GatewayError; + + async fn from_request_parts( + parts: &mut axum::http::request::Parts, + state: &S, + ) -> Result, GatewayError> { + match axum::extract::Path::::from_request_parts(parts, state).await { + Ok(axum::extract::Path(value)) => Ok(WirePath(value)), + Err(rejection) => Err(GatewayError::MalformedRequest(rejection.body_text())), + } + } +} + #[cfg(test)] mod tests { use super::*; use std::error::Error as _; #[test] - #[expect( - clippy::too_many_lines, - reason = "a flat status table with one row per error variant" - )] fn gateway_error_classify_is_table_driven() { let cases: Vec<(GatewayError, (StatusCode, &str, &str))> = vec![ ( @@ -677,14 +783,6 @@ mod tests { "switch_failed", ), ), - ( - GatewayError::system_metrics(std::io::Error::other("x")), - ( - StatusCode::INTERNAL_SERVER_ERROR, - "server_error", - "system_metrics_error", - ), - ), ( GatewayError::ModelProvisioning("load-profile: main".to_owned()), ( @@ -862,6 +960,14 @@ mod tests { "reveal_error", ), ), + ( + GatewayError::BlockingTask(Box::new(std::io::Error::other("panicked"))), + ( + StatusCode::INTERNAL_SERVER_ERROR, + "server_error", + "blocking_task_failed", + ), + ), ]; for (error, expected) in cases { assert_eq!(error.classify(), expected); @@ -936,4 +1042,53 @@ mod tests { assert!(matches!(err, GatewayError::Protocol(_))); assert_eq!(err.to_string(), "upstream returned 502"); } + + /// A leaf cause with its own text. + #[derive(Debug, thiserror::Error)] + #[error("disk full")] + struct Leaf; + + /// An outer error that copies its cause's text into its own message, + /// the shape of the variants that carry an [`error_chain`] rendering + /// in their own string. + #[derive(Debug, thiserror::Error)] + #[error("config write rejected: {message}")] + struct Copying { + message: String, + #[source] + source: Leaf, + } + + /// A cause that renders as nothing. + #[derive(Debug, thiserror::Error)] + #[error("")] + struct Silent; + + /// An outer error whose cause renders as nothing. + #[derive(Debug, thiserror::Error)] + #[error("config write rejected")] + struct OverSilent(#[source] Silent); + + #[test] + fn a_cause_the_outer_message_already_carries_renders_once() { + let error = Copying { + message: "disk full".to_owned(), + source: Leaf, + }; + let rendered = error_chain(&error); + assert_eq!( + rendered, "config write rejected: disk full", + "a cause whose text the outer message already carries is skipped" + ); + assert_eq!( + rendered.matches("disk full").count(), + 1, + "the cause text appears exactly once" + ); + assert_eq!( + error_chain(&OverSilent(Silent)), + "config write rejected", + "a cause that renders as nothing adds no trailing separator" + ); + } } diff --git a/crates/gateway/app/src/handoff.rs b/crates/gateway/app/src/handoff.rs deleted file mode 100644 index 247406bc2..000000000 --- a/crates/gateway/app/src/handoff.rs +++ /dev/null @@ -1,589 +0,0 @@ -//! The browser handoff: `GET /auth?key=` validates the bearer key, sets a -//! session proof as an HttpOnly cookie, and redirects to the key-free -//! config UI URL, so the tray and shell can open the config surface in a -//! browser without leaving the bearer key in the browser's history. -//! -//! The cookie is the key's ambient form: [`crate::auth::check_auth`] accepts it -//! in every build as an alternative to the `Authorization` header, so the -//! SPA the redirect lands on can call the admin surface without ever -//! seeing the key. `SameSite=Lax` keeps the cookie off cross-site -//! requests, and the loopback host wall keeps a rebound hostname from -//! reaching the surface at all. -//! -//! The cookie never carries the key. Cookies are not port-isolated (RFC -//! 6265), so every local server the browser visits on the same address -//! receives them, and a key-carrying cookie would hand any local process -//! the gateway discovery file's long-term secret on a single navigation. The -//! value is instead the hex of a session proof - SHA-256 over a -//! process-lifetime random salt and the live key - so a harvested cookie -//! authenticates only until a restart or key rotation and reveals nothing. -//! And because the proof is ambient, [`crate::auth::check_auth`] accepts it only -//! with Fetch Metadata a cross-origin page cannot strip: `SameSite=Lax` -//! does not cover same-site requests, since ports are not part of a site. - -#[cfg(feature = "config-ui")] -use axum::extract::{Query, State}; -#[cfg(feature = "config-ui")] -use axum::http::StatusCode; -use axum::http::header::COOKIE; -#[cfg(feature = "config-ui")] -use axum::http::header::{CACHE_CONTROL, LOCATION, SET_COOKIE}; -use axum::http::{HeaderMap, HeaderName}; -#[cfg(feature = "config-ui")] -use axum::response::{IntoResponse, Response}; - -#[cfg(feature = "config-ui")] -use crate::AppState; -#[cfg(feature = "config-ui")] -use crate::error::GatewayError; - -/// The cookie carrying the session proof for browser sessions. -pub(crate) const AUTH_COOKIE: &str = "promptforge-gateway-session"; - -/// The one-time browser handoff URL for opening the config SPA: -/// `GET /auth` validates the key, sets the session cookie, and redirects to -/// the key-free `/config/`, so the key never sits in browser history. The -/// tray's Settings item, the relaunch handoff, and `--print-url` all build -/// their URL here. The key is percent-encoded: a generated key is hex and -/// passes through unchanged, but a configured key can carry query-special -/// characters (`/auth` decodes through serde_urlencoded). -pub(crate) fn auth_url(base_url: &str, key: &str) -> String { - let key: String = url::form_urlencoded::byte_serialize(key.as_bytes()).collect(); - format!("{base_url}/auth?key={key}") -} - -/// The `Sec-Fetch-Site` header name; the locked `http` crate carries no -/// constant for it. -const SEC_FETCH_SITE: HeaderName = HeaderName::from_static("sec-fetch-site"); - -/// Reads the handoff cookie's presented session proof, when the request -/// carries a well-formed one. -pub(crate) fn presented_cookie_proof(headers: &HeaderMap) -> Option> { - let header = headers.get(COOKIE)?.to_str().ok()?; - header.split(';').map(str::trim).find_map(|pair| { - let (name, value) = pair.split_once('=')?; - if name == AUTH_COOKIE { - hex_decode(value) - } else { - None - } - }) -} - -/// The session proof the cookie carries for `key` under `salt`: SHA-256 -/// over the process-lifetime salt and the live key. The proof, never the -/// key, crosses into the browser, so a harvested cookie authenticates only -/// until a restart or key rotation and reveals nothing about the key. -pub(crate) fn session_token(salt: &[u8; 32], key: &[u8]) -> [u8; 32] { - use sha2::{Digest as _, Sha256}; - let mut digest = Sha256::new(); - digest.update(salt); - digest.update(key); - digest.finalize().into() -} - -/// Whether the request's Fetch Metadata permits cookie authentication. -/// The cookie is ambient - no `Authorization` header to require - so a -/// cross-origin page on another loopback port could otherwise ride it -/// into state-changing routes: `SameSite=Lax` does not cover same-site -/// requests, since ports are not part of a site. Every supported browser -/// attaches `Sec-Fetch-Site` to page-initiated requests, and a page -/// cannot strip or forge it; bearer clients (the shell, the tray, -/// scripts) never take the cookie path. -pub(crate) fn fetch_metadata_allows_cookie(headers: &HeaderMap) -> bool { - matches!( - headers - .get(SEC_FETCH_SITE) - .and_then(|value| value.to_str().ok()), - Some("same-origin" | "none") - ) -} - -/// Whether the request's Fetch Metadata permits ambient, credential-free -/// access from a loopback peer. Unlike the cookie rule, an absent header -/// is admitted: non-browser clients (curl, the SDK, the workshop) never -/// send `Sec-Fetch-Site`, and they are exactly who keyless loopback is -/// for. A browser always sends it, so `cross-site` and `same-site` - a -/// page on any other origin riding the user's loopback peer into -/// `POST /admin/shutdown` - are refused, as is any value the header -/// grammar does not name. `same-origin` (the config SPA) and `none` (a -/// typed URL) pass. -pub(crate) fn fetch_metadata_allows_ambient(headers: &HeaderMap) -> bool { - match headers.get(SEC_FETCH_SITE) { - None => true, - Some(value) => matches!(value.to_str(), Ok("same-origin" | "none")), - } -} - -/// Hex-encodes bytes for the cookie value: cookie-safe by construction. -#[cfg(feature = "config-ui")] -fn hex_encode(bytes: &[u8]) -> String { - use std::fmt::Write as _; - let mut out = String::with_capacity(bytes.len() * 2); - for byte in bytes { - let _ = write!(out, "{byte:02x}"); - } - out -} - -/// Hex-decodes a cookie value back to the presented key; `None` when the -/// value is not well-formed hex. -fn hex_decode(value: &str) -> Option> { - if !value.len().is_multiple_of(2) { - return None; - } - let mut out = Vec::with_capacity(value.len() / 2); - for pair in value.as_bytes().as_chunks::<2>().0 { - let hi = hex_digit(pair[0])?; - let lo = hex_digit(pair[1])?; - out.push(hi << 4 | lo); - } - Some(out) -} - -/// One lowercase-or-uppercase ASCII hex digit's value. -fn hex_digit(byte: u8) -> Option { - match byte { - b'0'..=b'9' => Some(byte - b'0'), - b'a'..=b'f' => Some(byte - b'a' + 10), - b'A'..=b'F' => Some(byte - b'A' + 10), - _ => None, - } -} - -/// The `GET /auth?key=` query: the presented bearer key. -#[cfg(feature = "config-ui")] -#[derive(Debug, serde::Deserialize)] -pub(crate) struct AuthQuery { - key: Option, -} - -/// The `GET /auth` browser-handoff route, walled loopback-only like the -/// config surface it fronts. -/// -/// A wrong or missing key answers `401 Unauthorized` indistinguishably. -/// The right key answers `302 Found` to `/config/` - a clean URL carrying -/// no key - with the key's session proof set as an HttpOnly, -/// `SameSite=Lax` session cookie and `Cache-Control: no-store` so the -/// handoff response itself is never reused from cache. -#[cfg(feature = "config-ui")] -pub(crate) async fn auth_handoff( - State(state): State, - Query(query): Query, -) -> Result { - let live = state.live.read().await; - let presented = query.key.unwrap_or_default(); - if !crate::auth::secret_eq(presented.as_bytes(), live.key.expose().as_bytes()) { - return Err(GatewayError::Unauthorized); - } - let cookie = format!( - "{AUTH_COOKIE}={}; HttpOnly; SameSite=Lax; Path=/", - hex_encode(&session_token( - &state.handoff_salt, - live.key.expose().as_bytes() - )) - ); - drop(live); - Ok(( - StatusCode::FOUND, - [ - (LOCATION, String::from("/config/")), - (SET_COOKIE, cookie), - (CACHE_CONTROL, String::from("no-store")), - ], - ) - .into_response()) -} - -#[cfg(all(test, feature = "config-ui"))] -mod tests { - // The compound cfg hides the module from clippy's test detection, so - // the test-code expect/unwrap allowance is restated explicitly. - #![expect( - clippy::expect_used, - reason = "the shared test fixture fails with the invariant named" - )] - - use std::net::SocketAddr; - - use axum::body::Body; - use axum::extract::ConnectInfo; - use axum::http::header::{CACHE_CONTROL, LOCATION, SET_COOKIE}; - use axum::http::{Request, Response, StatusCode}; - use gateway_config::Config; - use tower::ServiceExt; - - use super::{hex_encode, session_token}; - use crate::test_support::app_state; - use crate::{AppState, build_router}; - - fn state() -> AppState { - let config = Config::from_toml_str( - "config-version = 0\n\ - [server]\nbind = \"127.0.0.1:0\"\napi_key = \"test-token\"\n", - ) - .expect("config parses"); - app_state(config, None) - } - - /// Sends one `GET /auth...` through the router with a loopback peer - /// planted, as the walled route requires. - async fn get_auth(state: &AppState, uri: &str) -> Response { - let mut request = Request::builder() - .uri(uri) - .body(Body::empty()) - .expect("request builds"); - let peer: SocketAddr = "127.0.0.1:50000".parse().expect("a socket address"); - request.extensions_mut().insert(ConnectInfo(peer)); - build_router(state.clone(), None) - .oneshot(request) - .await - .expect("the router is infallible") - } - - #[tokio::test] - async fn a_wrong_or_missing_key_is_rejected_with_401() { - let state = state(); - for uri in ["/auth?key=wrong", "/auth", "/auth?key="] { - let response = get_auth(&state, uri).await; - assert_eq!(response.status(), StatusCode::UNAUTHORIZED, "{uri}"); - } - } - - #[tokio::test] - async fn the_right_key_sets_the_cookie_and_redirects_key_free() { - let state = state(); - let response = get_auth(&state, "/auth?key=test-token").await; - assert_eq!(response.status(), StatusCode::FOUND); - assert_eq!( - response.headers().get(LOCATION).expect("a Location header"), - "/config/", - "the redirect target carries no key" - ); - let cookie = response - .headers() - .get(SET_COOKIE) - .expect("a Set-Cookie header") - .to_str() - .expect("the cookie is header-safe"); - assert!( - cookie.starts_with("promptforge-gateway-session="), - "the handoff cookie: {cookie}" - ); - let value = cookie - .split(';') - .next() - .and_then(|pair| pair.split_once('=')) - .map(|(_, value)| value) - .expect("the cookie carries a value"); - assert_eq!( - value, - hex_encode(&session_token(&state.handoff_salt, b"test-token")), - "the cookie carries the session proof, never the key: {cookie}" - ); - assert!( - cookie.contains("HttpOnly"), - "the cookie is HttpOnly: {cookie}" - ); - assert!( - cookie.contains("SameSite=Lax"), - "the cookie is SameSite=Lax: {cookie}" - ); - assert!( - !cookie.contains("test-token"), - "the cookie never carries the raw key: {cookie}" - ); - assert_eq!( - response - .headers() - .get(CACHE_CONTROL) - .expect("a Cache-Control header"), - "no-store", - "the handoff response is never cached" - ); - } - - #[tokio::test] - async fn the_route_refuses_a_lan_peer_even_with_the_key() { - let state = state(); - let mut request = Request::builder() - .uri("/auth?key=test-token") - .body(Body::empty()) - .expect("request builds"); - let peer: SocketAddr = "198.51.100.7:44821".parse().expect("a socket address"); - request.extensions_mut().insert(ConnectInfo(peer)); - let response = build_router(state, None) - .oneshot(request) - .await - .expect("the router is infallible"); - assert_eq!(response.status(), StatusCode::FORBIDDEN); - } -} - -#[cfg(test)] -mod cookie_tests { - use axum::http::HeaderMap; - use axum::http::header::{AUTHORIZATION, COOKIE}; - use gateway_config::Config; - - use super::{ - AUTH_COOKIE, SEC_FETCH_SITE, auth_url, fetch_metadata_allows_ambient, - fetch_metadata_allows_cookie, hex_decode, presented_cookie_proof, session_token, - }; - use crate::AppState; - use crate::auth::Caller; - use crate::test_support::app_state; - - /// A caller with no recorded peer address: the cookie rules are - /// exercised on their own, with loopback trust out of reach. - fn peerless(headers: HeaderMap) -> Caller { - Caller::new(headers, None) - } - - #[test] - fn the_auth_url_targets_the_one_time_handoff() { - assert_eq!( - auth_url("http://127.0.0.1:8081", "abc123"), - "http://127.0.0.1:8081/auth?key=abc123" - ); - } - - #[test] - fn the_auth_url_percent_encodes_a_configured_key() { - // The WHATWG urlencoded byte serializer encodes space as `+`; - // serde_urlencoded decodes it back. - assert_eq!( - auth_url("http://127.0.0.1:8081", "a&b=c d"), - "http://127.0.0.1:8081/auth?key=a%26b%3Dc+d", - "a configured key with query-special characters survives the handoff" - ); - } - - /// Hex-encodes as the route's `hex_encode` does; that encoder is - /// compiled only with the config surface, while these cookie-auth - /// tests run in every build. - fn hex(bytes: &[u8]) -> String { - use std::fmt::Write as _; - let mut out = String::with_capacity(bytes.len() * 2); - for byte in bytes { - let _ = write!(out, "{byte:02x}"); - } - out - } - - /// The cookie header value the `/auth` route mints for `state`'s - /// salt and `key`, as the browser would present it back. - fn minted_cookie(state: &AppState, key: &str) -> String { - format!( - "{AUTH_COOKIE}={}", - hex(&session_token(&state.handoff_salt, key.as_bytes())) - ) - } - - /// A state whose configured bearer key is `test-token`. - fn test_token_state() -> AppState { - let config = Config::from_toml_str( - "config-version = 0\n\ - [server]\nbind = \"127.0.0.1:0\"\napi_key = \"test-token\"\n", - ) - .expect("config parses"); - app_state(config, None) - } - - /// Headers presenting `cookie` from a same-origin browser page. - fn same_origin_with(cookie: &str) -> HeaderMap { - HeaderMap::from_iter([ - (COOKIE, cookie.parse().expect("a header value")), - ( - SEC_FETCH_SITE, - "same-origin".parse().expect("a header value"), - ), - ]) - } - - #[test] - fn ambient_fetch_metadata_admits_absent_same_origin_and_none_only() { - let with = |site: &str| { - HeaderMap::from_iter([(SEC_FETCH_SITE, site.parse().expect("a header value"))]) - }; - assert!( - fetch_metadata_allows_ambient(&HeaderMap::new()), - "a non-browser client sends no Sec-Fetch-Site" - ); - assert!( - !fetch_metadata_allows_cookie(&HeaderMap::new()), - "the cookie rule stays strict: absent metadata is refused there" - ); - for site in ["same-origin", "none"] { - assert!(fetch_metadata_allows_ambient(&with(site)), "{site}"); - } - for site in ["cross-site", "same-site", "garbage", ""] { - assert!(!fetch_metadata_allows_ambient(&with(site)), "{site:?}"); - } - } - - #[test] - fn the_cookie_parses_among_others() { - let headers = HeaderMap::from_iter([( - COOKIE, - format!("session=abc; {AUTH_COOKIE}=746573742d746f6b656e; theme=dark") - .parse() - .expect("a header value"), - )]); - assert_eq!( - presented_cookie_proof(&headers).as_deref(), - Some(b"test-token".as_slice()) - ); - } - - #[test] - fn malformed_cookies_present_nothing() { - for cookie in [ - "promptforge-gateway-session=zz", // not hex - "promptforge-gateway-session=abc", // odd length - "other=746573742d746f6b656e", // the wrong name - "promptforge-gateway-session", // no value at all - ] { - let headers = HeaderMap::from_iter([(COOKIE, cookie.parse().expect("a header value"))]); - assert_eq!(presented_cookie_proof(&headers), None, "{cookie}"); - } - assert_eq!(presented_cookie_proof(&HeaderMap::new()), None); - } - - #[test] - fn hex_decode_round_trips_through_the_encoder() { - #[cfg(feature = "config-ui")] - { - let key = b"an arbitrary key/with+odd=chars"; - assert_eq!( - hex_decode(&super::hex_encode(key)).as_deref(), - Some(key.as_slice()) - ); - } - assert_eq!(hex_decode("").as_deref(), Some(b"".as_slice())); - assert_eq!( - hex_decode("00ff40").as_deref(), - Some(&[0x00, 0xff, 0x40][..]) - ); - } - - #[tokio::test] - async fn check_auth_accepts_the_cookie_as_the_bearer_keys_ambient_form() { - let state = test_token_state(); - let headers = same_origin_with(&minted_cookie(&state, "test-token")); - assert!( - crate::auth::check_auth(&state, &peerless(headers)) - .await - .is_ok() - ); - - // A wrong cookie and a wrong bearer both stay refused. - let wrong = same_origin_with(&format!("{AUTH_COOKIE}={}", hex(b"wrong"))); - assert!( - crate::auth::check_auth(&state, &peerless(wrong)) - .await - .is_err() - ); - let both = HeaderMap::from_iter([ - ( - AUTHORIZATION, - "Bearer wrong".parse().expect("a header value"), - ), - ( - COOKIE, - minted_cookie(&state, "test-token") - .parse() - .expect("a header value"), - ), - ( - SEC_FETCH_SITE, - "same-origin".parse().expect("a header value"), - ), - ]); - assert!( - crate::auth::check_auth(&state, &peerless(both)) - .await - .is_ok(), - "a valid cookie authenticates even alongside a wrong bearer header" - ); - } - - #[tokio::test] - async fn the_cookie_carries_a_session_proof_never_the_key() { - let state = test_token_state(); - // The key's own hex - what a key-carrying cookie would present - - // must not authenticate. - let bare = same_origin_with(&format!("{AUTH_COOKIE}={}", hex(b"test-token"))); - assert!( - crate::auth::check_auth(&state, &peerless(bare)) - .await - .is_err(), - "the cookie carries a derived proof, so the key itself is refused" - ); - // A proof minted under another process's salt is refused: a - // restart revokes every minted cookie. - let foreign = same_origin_with(&format!( - "{AUTH_COOKIE}={}", - hex(&session_token(&[0xAB; 32], b"test-token")) - )); - assert!( - crate::auth::check_auth(&state, &peerless(foreign)) - .await - .is_err(), - "a proof minted under another salt is refused" - ); - } - - #[tokio::test] - async fn the_cookie_path_requires_same_origin_fetch_metadata() { - let state = test_token_state(); - // A cross-origin rider on another loopback port is same-site - // (ports are not part of a site), so SameSite does not stop it; - // the fetch metadata it cannot strip does. - for site in ["same-site", "cross-site"] { - let headers = HeaderMap::from_iter([ - ( - COOKIE, - minted_cookie(&state, "test-token") - .parse() - .expect("a header value"), - ), - (SEC_FETCH_SITE, site.parse().expect("a header value")), - ]); - assert!( - crate::auth::check_auth(&state, &peerless(headers)) - .await - .is_err(), - "Sec-Fetch-Site: {site} marks a cross-origin rider" - ); - } - // No metadata at all: non-browser clients authenticate with the - // bearer header, never the cookie. - let bare = HeaderMap::from_iter([( - COOKIE, - minted_cookie(&state, "test-token") - .parse() - .expect("a header value"), - )]); - assert!( - crate::auth::check_auth(&state, &peerless(bare)) - .await - .is_err() - ); - // `none` is the user-driven navigation case and is admitted. - let navigation = HeaderMap::from_iter([ - ( - COOKIE, - minted_cookie(&state, "test-token") - .parse() - .expect("a header value"), - ), - (SEC_FETCH_SITE, "none".parse().expect("a header value")), - ]); - assert!( - crate::auth::check_auth(&state, &peerless(navigation)) - .await - .is_ok() - ); - } -} diff --git a/crates/gateway/app/src/health.rs b/crates/gateway/app/src/health.rs new file mode 100644 index 000000000..259123fbf --- /dev/null +++ b/crates/gateway/app/src/health.rs @@ -0,0 +1,23 @@ +//! The `GET /health` liveness probe. + +use axum::http::Method; +use axum::routing::get; +use axum::{Json, Router}; + +use crate::AppState; +use crate::registry::RouteInfo; + +const HEALTH: RouteInfo = RouteInfo::open("/health", &[Method::GET]); + +/// The health route, as the registry sees it. +pub(crate) const ROUTES: &[RouteInfo] = &[HEALTH]; + +/// The health route. +pub(crate) fn routes() -> Router { + Router::new().route(HEALTH.path, get(health)) +} + +/// Liveness probe; unauthenticated and always 200 while serving. +async fn health() -> Json { + Json(serde_json::json!({ "status": "serving" })) +} diff --git a/crates/gateway/app/src/hf.rs b/crates/gateway/app/src/hf.rs deleted file mode 100644 index d4697b2e6..000000000 --- a/crates/gateway/app/src/hf.rs +++ /dev/null @@ -1,802 +0,0 @@ -//! The `GET /admin/hf/*` routes: a thin bearer-authed proxy onto the -//! Hugging Face hub API, feeding the config UI's Discover view. -//! -//! The proxy forwards the hub's JSON bodies verbatim - the UI adapts the -//! shape - and attaches the boot-time `HF_TOKEN` when one is present, so -//! the browser never holds the token and public repos keep working -//! without one. Upstream 4xx statuses pass through in the gateway's error -//! envelope via [`ProtocolError::upstream_status`]; nothing is cached. - -use std::time::Duration; - -use axum::body::Body; -use axum::extract::rejection::PathRejection; -use axum::extract::{Path, RawQuery, State}; -use axum::http::HeaderValue; -use axum::http::header::CONTENT_TYPE; -use axum::response::Response; -use gateway_config::Secret; -use gateway_protocol::ProtocolError; -use gateway_protocol::http_util::{self, MAX_ERROR_BODY, read_body_capped}; - -use crate::AppState; -use crate::auth::AuthedCaller; -use crate::error::GatewayError; - -/// Whole-request deadline for one hub call, applied per request; reqwest's -/// per-request timeout replaces the bounded client's wider default. -const HF_TIMEOUT: Duration = Duration::from_secs(30); - -/// Cap response body at 1 MiB: large model-card READMEs with embedded -/// base64 images can exceed 10 MiB, and the gateway only shows the text. -const MAX_README_BODY: usize = 1024 * 1024; - -/// Shared Hugging Face hub client: one reqwest client, the hub base URL, -/// and the boot-time `HF_TOKEN` (absent for anonymous access). -#[derive(Debug)] -pub(crate) struct HfProxy { - /// The shared bounded HTTP client; the hub deadline is applied per request. - client: reqwest::Client, - /// The hub origin, `https://huggingface.co` outside tests. - base_url: String, - /// The bearer token sent to the hub, when one was configured. - token: Option, -} - -impl HfProxy { - /// The production hub client: `https://huggingface.co`, with the token - /// read once from the process `HF_TOKEN` (dotenvy has already folded - /// the `.env` files into the process env at boot). - pub(crate) fn from_env() -> HfProxy { - let token = std::env::var("HF_TOKEN") - .ok() - .filter(|token| !token.is_empty()) - .map(Secret::new); - HfProxy::new("https://huggingface.co".to_owned(), token) - } - - /// A hub client aimed at `base_url` with an explicit token, so tests - /// point the proxy at a local stub without touching the process env. - pub(crate) fn new(base_url: String, token: Option) -> HfProxy { - HfProxy { - client: http_util::bounded_client(), - base_url, - token, - } - } - - /// GETs `{base_url}{path}` with `query`, forwarding the hub's JSON body - /// and status verbatim on success and mapping a non-success status or a - /// transport failure into the gateway's error envelope. - async fn forward(&self, path: &str, query: &[(&str, &str)]) -> Result { - let mut request = self - .client - .get(format!("{}{path}", self.base_url)) - .query(query) - .timeout(HF_TIMEOUT); - if let Some(token) = &self.token { - request = request.bearer_auth(token.expose()); - } - let response = request - .send() - .await - .map_err(ProtocolError::upstream_transport)?; - let status = response.status(); - if !status.is_success() { - let body = read_body_capped(response, MAX_ERROR_BODY).await; - let body: String = body.chars().take(2000).collect(); - return Err(ProtocolError::upstream_status(status.as_u16(), body).into()); - } - let content_type = response - .headers() - .get(CONTENT_TYPE) - .cloned() - .unwrap_or(HeaderValue::from_static("application/json")); - // Streaming the body through keeps the gateway's memory use flat no - // matter how large the hub's sibling list gets. - Response::builder() - .status(status) - .header(CONTENT_TYPE, content_type) - .body(Body::from_stream(response.bytes_stream())) - .map_err(GatewayError::upstream_protocol) - } - - /// GETs `{base_url}{path}`, returning the body as `text/markdown` on - /// success, a plain 404 for a missing README, and the error envelope - /// for other failures. The body is capped at [`MAX_README_BODY`]. - async fn forward_readme(&self, path: &str) -> Result { - let mut request = self - .client - .get(format!("{}{path}", self.base_url)) - .timeout(HF_TIMEOUT); - if let Some(token) = &self.token { - request = request.bearer_auth(token.expose()); - } - let response = request - .send() - .await - .map_err(ProtocolError::upstream_transport)?; - let status = response.status(); - if status.as_u16() == 404 { - return Response::builder() - .status(404) - .body(Body::empty()) - .map_err(GatewayError::upstream_protocol); - } - if !status.is_success() { - let body = read_body_capped(response, MAX_ERROR_BODY).await; - let body: String = body.chars().take(2000).collect(); - return Err(ProtocolError::upstream_status(status.as_u16(), body).into()); - } - let body = read_body_capped(response, MAX_README_BODY).await; - Response::builder() - .status(200) - .header(CONTENT_TYPE, "text/markdown; charset=utf-8") - .body(Body::from(body)) - .map_err(GatewayError::upstream_protocol) - } -} - -/// Query parameters accepted by `GET /admin/hf/search`; each present field -/// is forwarded to the hub's model-search API, and everything else is -/// dropped at this boundary. -#[derive(Debug, Default)] -pub(crate) struct HfSearchQuery { - /// Free-text search, forwarded as the hub's `search` parameter. - q: Option, - /// Tag filter; the Discover view pins `gguf`. - filter: Option, - /// Sort field: `downloads`, `trendingScore`, or `lastModified`. - sort: Option, - /// Sort direction, `-1` for descending. - direction: Option, - /// Result page size. - limit: Option, - /// `full=true` asks the hub to include each result's sibling file list. - full: Option, - /// One workload tag. The UI fans out requests to implement OR filters. - pipeline_tag: Option, -} - -/// The `GET /admin/hf/search` route: bearer-authed, proxies the hub's -/// `GET /api/models` search and returns its JSON body verbatim. -pub(crate) async fn admin_hf_search( - State(state): State, - RawQuery(query): RawQuery, - _caller: AuthedCaller, -) -> Result { - let query = parse_search_query(query.as_deref())?; - let renames = [("search", &query.q)]; - let passthrough = [ - ("filter", &query.filter), - ("sort", &query.sort), - ("direction", &query.direction), - ("limit", &query.limit), - ("full", &query.full), - ]; - let mut params: Vec<(&str, &str)> = renames - .iter() - .chain(passthrough.iter()) - .filter_map(|(name, value)| Some((*name, value.as_deref()?))) - .collect(); - if let Some(tag) = query.pipeline_tag.as_deref() { - params.push(("pipeline_tag", tag)); - } - state.hf.forward("/api/models", ¶ms).await -} - -/// Parses the small search query allowlist. Every field is singular and -/// closed-set values are validated before any upstream request. -fn parse_search_query(raw: Option<&str>) -> Result { - let mut query = HfSearchQuery::default(); - for (key, value) in url::form_urlencoded::parse(raw.unwrap_or_default().as_bytes()) { - let slot = match key.as_ref() { - "q" => &mut query.q, - "filter" => &mut query.filter, - "sort" => &mut query.sort, - "direction" => &mut query.direction, - "limit" => &mut query.limit, - "full" => &mut query.full, - "pipeline_tag" => { - if !matches!( - value.as_ref(), - "text-generation" - | "feature-extraction" - | "sentence-similarity" - | "text-classification" - | "automatic-speech-recognition" - | "text-to-image" - | "text-to-speech" - ) { - return Err(GatewayError::MalformedRequest(format!( - "unsupported pipeline_tag {value:?}" - ))); - } - if query.pipeline_tag.replace(value.into_owned()).is_some() { - return Err(GatewayError::MalformedRequest( - "pipeline_tag must appear at most once".to_owned(), - )); - } - continue; - } - _ => continue, - }; - if slot.replace(value.into_owned()).is_some() { - return Err(GatewayError::MalformedRequest(format!( - "duplicate query field {key}" - ))); - } - } - validate_search_value("filter", query.filter.as_deref(), &["gguf"])?; - validate_search_value( - "sort", - query.sort.as_deref(), - &["downloads", "trendingScore", "lastModified"], - )?; - validate_search_value("direction", query.direction.as_deref(), &["-1"])?; - validate_search_value("full", query.full.as_deref(), &["true"])?; - if let Some(limit) = query.limit.as_deref() - && !limit - .parse::() - .is_ok_and(|parsed| (1..=100).contains(&parsed)) - { - return Err(GatewayError::MalformedRequest( - "limit must be an integer from 1 through 100".to_owned(), - )); - } - Ok(query) -} - -/// Validates one optional search field against its closed value set. -fn validate_search_value( - name: &str, - value: Option<&str>, - accepted: &[&str], -) -> Result<(), GatewayError> { - if let Some(value) = value - && !accepted.contains(&value) - { - return Err(GatewayError::MalformedRequest(format!( - "unsupported {name} value {value:?}" - ))); - } - Ok(()) -} - -/// The `GET /admin/hf/model/{owner}/{name}` route: bearer-authed, proxies -/// the hub's model detail for an `owner/name` repo with `blobs=true`, so -/// the sibling list carries the exact file sizes the quant picker needs. -pub(crate) async fn admin_hf_model( - State(state): State, - repo: Result, PathRejection>, - _caller: AuthedCaller, -) -> Result { - let Path((owner, name)) = - repo.map_err(|rejection| GatewayError::MalformedRequest(rejection.body_text()))?; - let repo = format!("{owner}/{name}"); - validate_repo(&repo)?; - state - .hf - .forward(&format!("/api/models/{repo}"), &[("blobs", "true")]) - .await -} - -/// The `GET /admin/hf/model/{owner}/{name}/readme` route: bearer-authed, -/// proxies the hub's raw README.md for the repo and returns it as -/// `text/markdown; charset=utf-8`. A missing README maps to 404. -pub(crate) async fn admin_hf_readme( - State(state): State, - repo: Result, PathRejection>, - _caller: AuthedCaller, -) -> Result { - let Path((owner, name)) = - repo.map_err(|rejection| GatewayError::MalformedRequest(rejection.body_text()))?; - let repo = format!("{owner}/{name}"); - validate_repo(&repo)?; - state - .hf - .forward_readme(&format!("/{repo}/raw/main/README.md")) - .await -} - -/// Checks that `repo` is exactly `owner/name`: two non-empty segments of -/// hub-legal characters (ASCII alphanumerics, `-`, `_`, `.`), neither made -/// only of dots. -fn validate_repo(repo: &str) -> Result<(), GatewayError> { - let mut segments = repo.split('/'); - if let (Some(owner), Some(name), None) = (segments.next(), segments.next(), segments.next()) - && is_repo_segment(owner) - && is_repo_segment(name) - { - return Ok(()); - } - Err(GatewayError::MalformedRequest(format!( - "repo `{repo}` is not an owner/name pair of path-safe segments" - ))) -} - -/// Whether one repo segment is non-empty, hub-legal, and not a dot run -/// (`.` and `..` are path traversal, not names). -fn is_repo_segment(segment: &str) -> bool { - !segment.is_empty() - && !segment.bytes().all(|byte| byte == b'.') - && segment - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) -} - -#[cfg(test)] -mod tests { - use std::net::SocketAddr; - use std::sync::{Arc, Mutex}; - - use axum::http::header::{AUTHORIZATION, CONTENT_TYPE}; - use axum::http::{HeaderMap, StatusCode, Uri}; - use gateway_config::{Config, Secret}; - - use super::HfProxy; - use crate::test_support::serve_with_hf; - - /// A minimal profile: the hub proxy needs nothing beyond `[server]`. - fn hf_config() -> Config { - Config::from_toml_str( - r#" -config-version = 0 - -[server] -bind = "127.0.0.1:0" -api_key = "test-token" -# Strict bearer auth: the tests below pin that a missing key is refused. -trust_loopback = false -"#, - ) - .expect("the fixture profile parses") - } - - /// One request the stub hub observed. - #[derive(Debug, Clone)] - struct Seen { - path: String, - query: String, - authorization: Option, - } - - /// Spawns a stub hub answering every request with `status` and `body`, - /// recording each request it sees. - async fn spawn_stub(status: StatusCode, body: &'static str) -> (String, Arc>>) { - let seen = Arc::new(Mutex::new(Vec::new())); - let recorded = Arc::clone(&seen); - let app = axum::Router::new().fallback(move |uri: Uri, headers: HeaderMap| { - let recorded = Arc::clone(&recorded); - async move { - recorded.lock().expect("the stub log lock").push(Seen { - path: uri.path().to_owned(), - query: uri.query().unwrap_or("").to_owned(), - authorization: headers - .get(AUTHORIZATION) - .and_then(|value| value.to_str().ok()) - .map(str::to_owned), - }); - (status, [(CONTENT_TYPE, "application/json")], body) - } - }); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .expect("the stub listener binds"); - let addr = listener.local_addr().expect("the stub bound address"); - tokio::spawn(async move { - let _ignored = axum::serve(listener, app).await; - }); - (format!("http://{addr}"), seen) - } - - /// Serves the gateway with its hub proxy aimed at a fresh stub. - async fn serve_against_stub( - status: StatusCode, - body: &'static str, - token: Option<&str>, - ) -> (SocketAddr, Arc>>) { - let (base_url, seen) = spawn_stub(status, body).await; - let proxy = HfProxy::new(base_url, token.map(|token| Secret::new(token.to_owned()))); - let addr = serve_with_hf(hf_config(), proxy).await; - (addr, seen) - } - - /// GETs `path` on the gateway with the given bearer token. - async fn get(addr: SocketAddr, path: &str, token: &str) -> reqwest::Response { - reqwest::Client::new() - .get(format!("http://{addr}{path}")) - .bearer_auth(token) - .send() - .await - .expect("the request sends") - } - - #[tokio::test] - async fn admin_hf_search_forwards_params_and_body() { - let stub_body = r#"[{"id":"unsloth/Qwen3-8B-GGUF","downloads":123}]"#; - let (addr, seen) = serve_against_stub(StatusCode::OK, stub_body, None).await; - - let response = get( - addr, - "/admin/hf/search?q=qwen&filter=gguf&pipeline_tag=text-generation\ - &sort=downloads&direction=-1&limit=30&full=true", - "test-token", - ) - .await; - assert_eq!(response.status(), reqwest::StatusCode::OK); - assert_eq!(response.text().await.expect("a body"), stub_body); - - let seen = seen.lock().expect("the stub log lock"); - let [request] = seen.as_slice() else { - panic!("expected exactly one upstream request, saw {seen:?}"); - }; - assert_eq!(request.path, "/api/models"); - for pair in [ - "search=qwen", - "filter=gguf", - "pipeline_tag=text-generation", - "sort=downloads", - "direction=-1", - "limit=30", - "full=true", - ] { - assert!( - request.query.contains(pair), - "`{pair}` missing from forwarded query `{}`", - request.query - ); - } - } - - #[tokio::test] - async fn admin_hf_model_targets_the_owner_name_path() { - let stub_body = r#"{"id":"unsloth/Qwen3-8B-GGUF","siblings":[{"rfilename":"q4.gguf","size":4900000000}]}"#; - let (addr, seen) = serve_against_stub(StatusCode::OK, stub_body, None).await; - - let response = get(addr, "/admin/hf/model/unsloth/Qwen3-8B-GGUF", "test-token").await; - assert_eq!(response.status(), reqwest::StatusCode::OK); - assert_eq!(response.text().await.expect("a body"), stub_body); - - let seen = seen.lock().expect("the stub log lock"); - let [request] = seen.as_slice() else { - panic!("expected exactly one upstream request, saw {seen:?}"); - }; - assert_eq!(request.path, "/api/models/unsloth/Qwen3-8B-GGUF"); - assert!( - request.query.contains("blobs=true"), - "`blobs=true` missing from `{}`: the quant picker needs sibling sizes", - request.query - ); - } - - #[tokio::test] - async fn admin_hf_sends_the_token_only_when_configured() { - let (with_token, seen_with) = - serve_against_stub(StatusCode::OK, "[]", Some("hf_secret")).await; - let response = get(with_token, "/admin/hf/search?q=x", "test-token").await; - assert_eq!(response.status(), reqwest::StatusCode::OK); - assert_eq!( - seen_with.lock().expect("the stub log lock")[0] - .authorization - .as_deref(), - Some("Bearer hf_secret"), - "a configured HF_TOKEN must reach the hub" - ); - - let (without_token, seen_without) = serve_against_stub(StatusCode::OK, "[]", None).await; - let response = get(without_token, "/admin/hf/search?q=x", "test-token").await; - assert_eq!(response.status(), reqwest::StatusCode::OK); - assert_eq!( - seen_without.lock().expect("the stub log lock")[0].authorization, - None, - "an anonymous proxy must not invent an Authorization header" - ); - } - - #[tokio::test] - async fn admin_hf_forwards_upstream_client_errors() { - for (upstream, expected) in [ - (StatusCode::UNAUTHORIZED, reqwest::StatusCode::UNAUTHORIZED), - (StatusCode::NOT_FOUND, reqwest::StatusCode::NOT_FOUND), - ] { - let (addr, _seen) = serve_against_stub(upstream, r#"{"error":"denied"}"#, None).await; - let response = get(addr, "/admin/hf/model/owner/name", "test-token").await; - assert_eq!( - response.status(), - expected, - "hub {upstream} must pass through" - ); - let body: serde_json::Value = response.json().await.expect("a JSON error envelope"); - assert_eq!(body["error"]["code"], "upstream_client_error"); - } - } - - /// GETs `path` over a raw socket, bypassing reqwest's client-side URL - /// normalization (which collapses `%2E%2E` dot-segments before they - /// ever leave a well-behaved client). - async fn raw_get(addr: SocketAddr, path: &str, token: &str) -> (u16, String) { - use tokio::io::{AsyncReadExt, AsyncWriteExt}; - let mut stream = tokio::net::TcpStream::connect(addr) - .await - .expect("the raw client connects"); - let request = format!( - "GET {path} HTTP/1.1\r\nHost: {addr}\r\nAuthorization: Bearer {token}\r\nConnection: close\r\n\r\n" - ); - stream - .write_all(request.as_bytes()) - .await - .expect("the raw request writes"); - let mut response = String::new(); - stream - .read_to_string(&mut response) - .await - .expect("the raw response reads"); - let status = response - .split_whitespace() - .nth(1) - .and_then(|code| code.parse().ok()) - .expect("a status line"); - (status, response) - } - - #[tokio::test] - async fn admin_hf_search_maps_a_rejected_query_into_the_error_envelope() { - let (addr, seen) = serve_against_stub(StatusCode::OK, "[]", None).await; - for query in [ - "q=a&q=b", - "pipeline_tag=not-a-workload", - "pipeline_tag=text-generation&pipeline_tag=automatic-speech-recognition", - "filter=safetensors", - "sort=created", - "direction=1", - "full=false", - "limit=0", - "limit=101", - "limit=many", - ] { - let response = get(addr, &format!("/admin/hf/search?{query}"), "test-token").await; - assert_eq!(response.status(), reqwest::StatusCode::BAD_REQUEST); - let body: serde_json::Value = response.json().await.expect("a JSON error envelope"); - assert_eq!(body["error"]["code"], "malformed_request"); - } - assert!( - seen.lock().expect("the stub log lock").is_empty(), - "a rejected query must never produce an upstream request" - ); - } - - #[tokio::test] - async fn admin_hf_model_maps_a_rejected_path_into_the_error_envelope() { - let (addr, seen) = serve_against_stub(StatusCode::OK, "{}", None).await; - // `%FF` percent-decodes to invalid UTF-8, so `Path` rejects; - // the rejection must land in the JSON envelope, after auth. - let response = get(addr, "/admin/hf/model/%FF%FF/name", "test-token").await; - assert_eq!(response.status(), reqwest::StatusCode::BAD_REQUEST); - let body: serde_json::Value = response.json().await.expect("a JSON error envelope"); - assert_eq!(body["error"]["code"], "malformed_request"); - - let unauthenticated = reqwest::Client::new() - .get(format!("http://{addr}/admin/hf/model/%FF%FF/name")) - .send() - .await - .expect("the request sends"); - assert_eq!( - unauthenticated.status(), - reqwest::StatusCode::UNAUTHORIZED, - "auth must win over a malformed path" - ); - assert!( - seen.lock().expect("the stub log lock").is_empty(), - "a rejected path must never produce an upstream request" - ); - } - - #[tokio::test] - async fn admin_hf_model_rejects_malformed_repos_without_calling_upstream() { - let (addr, seen) = serve_against_stub(StatusCode::OK, "{}", None).await; - // With `{owner}/{name}` segments, repos with spaces or control - // characters match the route but fail `validate_repo`. - for repo in ["owner/na%20me", ".../name", "owner/..."] { - let response = get(addr, &format!("/admin/hf/model/{repo}"), "test-token").await; - assert_eq!( - response.status(), - reqwest::StatusCode::BAD_REQUEST, - "repo `{repo}` must be refused at the boundary" - ); - let body: serde_json::Value = response.json().await.expect("a JSON error envelope"); - assert_eq!(body["error"]["code"], "malformed_request"); - } - // Encoded dot-segments match the route; validate_repo rejects them. - for repo in ["%2E%2E/name", "owner/%2E%2E"] { - let (status, response) = - raw_get(addr, &format!("/admin/hf/model/{repo}"), "test-token").await; - assert_eq!(status, 400, "repo `{repo}` must be refused at the boundary"); - assert!( - response.contains("malformed_request"), - "repo `{repo}` must map to the JSON error envelope, got: {response}" - ); - } - assert!( - seen.lock().expect("the stub log lock").is_empty(), - "a rejected repo must never produce an upstream request" - ); - } - - #[tokio::test] - async fn admin_hf_routes_require_bearer_auth() { - let (addr, seen) = serve_against_stub(StatusCode::OK, "[]", None).await; - for path in [ - "/admin/hf/search?q=x", - "/admin/hf/model/owner/name", - "/admin/hf/model/owner/name/readme", - ] { - let unauthenticated = reqwest::Client::new() - .get(format!("http://{addr}{path}")) - .send() - .await - .expect("the request sends"); - assert_eq!( - unauthenticated.status(), - reqwest::StatusCode::UNAUTHORIZED, - "`{path}` without a bearer token is refused" - ); - - let wrong_key = get(addr, path, "wrong-token").await; - assert_eq!( - wrong_key.status(), - reqwest::StatusCode::UNAUTHORIZED, - "`{path}` with the wrong bearer token is refused" - ); - } - assert!( - seen.lock().expect("the stub log lock").is_empty(), - "an unauthenticated caller must never reach the hub" - ); - } - - /// Spawns a stub hub that serves README and model-detail paths - /// differently, recording each request it sees. - async fn spawn_readme_stub( - readme_status: StatusCode, - readme_body: &'static str, - ) -> (String, Arc>>) { - let seen = Arc::new(Mutex::new(Vec::new())); - let recorded = Arc::clone(&seen); - let app = axum::Router::new().fallback(move |uri: Uri, headers: HeaderMap| { - let recorded = Arc::clone(&recorded); - async move { - recorded.lock().expect("the stub log lock").push(Seen { - path: uri.path().to_owned(), - query: uri.query().unwrap_or("").to_owned(), - authorization: headers - .get(AUTHORIZATION) - .and_then(|value| value.to_str().ok()) - .map(str::to_owned), - }); - if uri.path().ends_with("/README.md") { - ( - readme_status, - [(CONTENT_TYPE, "text/markdown")], - readme_body, - ) - } else { - (StatusCode::OK, [(CONTENT_TYPE, "application/json")], "{}") - } - } - }); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .expect("the stub listener binds"); - let addr = listener.local_addr().expect("the stub bound address"); - tokio::spawn(async move { - let _ignored = axum::serve(listener, app).await; - }); - (format!("http://{addr}"), seen) - } - - async fn serve_readme_stub( - readme_status: StatusCode, - readme_body: &'static str, - token: Option<&str>, - ) -> (SocketAddr, Arc>>) { - let (base_url, seen) = spawn_readme_stub(readme_status, readme_body).await; - let proxy = HfProxy::new(base_url, token.map(|t| Secret::new(t.to_owned()))); - let addr = serve_with_hf(hf_config(), proxy).await; - (addr, seen) - } - - #[tokio::test] - async fn admin_hf_readme_proxies_to_the_raw_readme_path() { - let (addr, seen) = serve_readme_stub(StatusCode::OK, "# Model Card\nHello", None).await; - let response = get( - addr, - "/admin/hf/model/unsloth/Qwen3-8B-GGUF/readme", - "test-token", - ) - .await; - assert_eq!(response.status(), reqwest::StatusCode::OK); - assert_eq!( - response - .headers() - .get("content-type") - .and_then(|v| v.to_str().ok()), - Some("text/markdown; charset=utf-8"), - ); - assert_eq!( - response.text().await.expect("a body"), - "# Model Card\nHello" - ); - - let seen = seen.lock().expect("the stub log lock"); - let [request] = seen.as_slice() else { - panic!("expected one upstream request, saw {seen:?}"); - }; - assert_eq!( - request.path, "/unsloth/Qwen3-8B-GGUF/raw/main/README.md", - "the proxy must hit the hub's raw README path" - ); - } - - #[tokio::test] - async fn admin_hf_readme_returns_404_for_a_missing_readme() { - let (addr, _seen) = serve_readme_stub(StatusCode::NOT_FOUND, "", None).await; - let response = get(addr, "/admin/hf/model/owner/name/readme", "test-token").await; - assert_eq!(response.status(), reqwest::StatusCode::NOT_FOUND); - } - - #[tokio::test] - async fn admin_hf_readme_validates_the_repo() { - let (addr, seen) = serve_readme_stub(StatusCode::OK, "# hello", None).await; - let response = get(addr, "/admin/hf/model/.../name/readme", "test-token").await; - assert_eq!( - response.status(), - reqwest::StatusCode::BAD_REQUEST, - "a dot-only owner must be refused" - ); - let body: serde_json::Value = response.json().await.expect("a JSON error envelope"); - assert_eq!(body["error"]["code"], "malformed_request"); - assert!( - seen.lock().expect("the stub log lock").is_empty(), - "a rejected repo must never produce an upstream request" - ); - } - - #[tokio::test] - async fn admin_hf_readme_caps_the_body_at_one_mib() { - let seen = Arc::new(Mutex::new(Vec::::new())); - let recorded = Arc::clone(&seen); - let app = axum::Router::new().fallback(move |uri: Uri, headers: HeaderMap| { - let recorded = Arc::clone(&recorded); - async move { - recorded.lock().expect("the stub log lock").push(Seen { - path: uri.path().to_owned(), - query: uri.query().unwrap_or("").to_owned(), - authorization: headers - .get(AUTHORIZATION) - .and_then(|value| value.to_str().ok()) - .map(str::to_owned), - }); - let big = "x".repeat(2 * 1024 * 1024); - (StatusCode::OK, [(CONTENT_TYPE, "text/markdown")], big) - } - }); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .expect("the stub listener binds"); - let addr = listener.local_addr().expect("the stub bound address"); - tokio::spawn(async move { - let _ignored = axum::serve(listener, app).await; - }); - let proxy = HfProxy::new(format!("http://{addr}"), None); - let gw = serve_with_hf(hf_config(), proxy).await; - let response = get(gw, "/admin/hf/model/owner/name/readme", "test-token").await; - assert_eq!(response.status(), reqwest::StatusCode::OK); - let body = response.bytes().await.expect("a body"); - assert!( - body.len() <= 1024 * 1024, - "the body must be capped at 1 MiB, got {} bytes", - body.len() - ); - } -} diff --git a/crates/gateway/app/src/lib.rs b/crates/gateway/app/src/lib.rs index 737c4d4e5..bf13edfd4 100644 --- a/crates/gateway/app/src/lib.rs +++ b/crates/gateway/app/src/lib.rs @@ -1,4 +1,4 @@ -//! PromptForge inference gateway. +//! PromptForge inference gateway. //! //! A small always-on service that accepts OpenAI-shaped chat completions, holds //! the backend credential, resolves the request's model name to a configured @@ -6,88 +6,63 @@ //! in the system with an edge to an LLM backend, so the executor above it never //! holds a vendor key. //! -//! What ships: one OpenAI passthrough at `POST /v1/chat/completions` with -//! bearer auth, model routing, and a typed SSE relay for `stream: true`, an -//! embeddings passthrough at -//! `POST /v1/embeddings` for `kind = "embedding"` models, a rerank -//! passthrough at `POST /v1/rerank` for `kind = "classifier"` models, shared -//! concurrency pools with bounded, fair waiting queues (`[[dominion]]`), -//! gateway-owned local generative inference via a managed `llama-server` -//! subprocess (`[[local_model]]`), named profile checklists from one loaded -//! catalog with `POST /admin/switch-profile` persisting the selection and -//! reporting `restart_required`, a bearer-authed `GET /admin/status` readout -//! carrying the command queue's active and pending commands plus one -//! readiness entry per capability endpoint, bearer-authed -//! `POST /admin/queue/cancel` and `POST /admin/queue/cancel-pending` -//! cancelling the queue's active and waiting commands, a bearer-authed -//! `GET /v1/models` catalog, a bearer-authed `GET /admin/config` view of the -//! running global configuration as JSON with secrets redacted, a -//! bearer-authed `GET /admin/progress` SSE -//! stream of the process progress hub, 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`, -//! `DELETE /v1/cache/{sha256}`) backed by the local artifact store, a -//! bearer-authed `GET /admin/orphans` listing of cache files no loaded -//! `[[local_model]]` entry references (local builds), a bearer-authed -//! `GET /admin/model-info` GGUF-header readout of a cache file's layer and -//! parameter counts (local builds), a bearer-authed -//! `GET /admin/chat-templates` family catalog and per-model effective -//! resolution view (local builds), a bearer-authed -//! `POST /v1/audio/transcriptions` OpenAI-compatible multipart STT endpoint -//! (stt builds), a bearer-authed `POST /v1/audio/speech` speech-synthesis -//! passthrough for `kind = "speech"` models streaming the upstream's audio -//! bytes unread, a bearer-authed `GET /v1/audio/voices` union catalog of -//! the speech models' configured voices, a bearer-authed -//! `GET /admin/system` snapshot of host CPU, RAM, cache-drive, and GPU -//! metrics, a bearer-authed `GET /admin/hf/search` and -//! `GET /admin/hf/model/{repo}` proxy onto the Hugging Face hub API -//! (attaching the process `HF_TOKEN` when set), bearer-authed shadow-file -//! write routes staging pending edits beside the real files without ever -//! touching them (`PUT /admin/config`, `PUT /admin/env`) plus a bearer-authed -//! `GET /admin/env` readout of the single config-sibling `.env` file, -//! bearer-authed pending-state reads - `GET /admin/config-pending` (the -//! merged real-plus-shadow view in the `GET /admin/config` shape, with a -//! distinct boot side for the restart-required banner) and -//! `GET /admin/config-dirty` (shadow existence, pending files, changed -//! sections) - bearer-authed `POST /admin/config-apply` (promote every -//! shadow to its real file, then reload the active profile, or report -//! restart-required for a promoted boot shadow) and -//! `POST /admin/config-revert` (delete every shadow, touching nothing -//! else), a loopback-only, bearer-authed `POST /admin/reveal` opening the -//! host OS file manager at a path confined to the artifact cache, a -//! bearer-authed `GET /admin/cloud-models` readout of the cached cloud -//! provider model sheet (with `POST /admin/cloud-models/refresh` forcing -//! a re-download and answering with the fresh sheet), a loopback-only, bearer-authed -//! `POST /shutdown` driving the same -//! graceful shutdown Ctrl-C drives - and -//! `GET /health`. The whole admin config surface (config read/write, env, -//! pending state, apply/revert, orphans, system, model-info, the HF -//! proxy, cloud-models, reveal, shutdown) sits behind the shared loopback -//! wall from `shared-loopback` in every build; with the -//! default-on `stt` feature, `WS /v1/realtime?intent=transcription` -//! serves Gateway-owned Realtime transcription beside the batch route; -//! with the -//! `config-ui` feature the embedded config SPA is served at `/config/` -//! behind the same wall, and `GET /auth?key=` sets a session proof -//! derived from the bearer key as an HttpOnly cookie and redirects to the -//! key-free `/config/`, so a browser handoff never leaves the key in -//! browser history. With `[server] trust_loopback` on (the default), a -//! loopback peer presenting no credential is admitted to every route -//! unless its Fetch Metadata marks a cross-origin page; `trust_loopback = -//! false` requires the bearer key from every caller. When the listener is -//! bound to loopback, every route additionally sits behind the shared +//! What ships is an OpenAI-shaped inference surface and an admin surface +//! in two tiers, plus `GET /health`. The inference surface is +//! `POST /v1/chat/completions` (with a typed SSE relay for `stream: +//! true`), `POST /v1/embeddings`, `POST /v1/rerank`, `POST /v1/audio/speech` +//! and `GET /v1/audio/voices`, `GET /v1/models`, the Brave-backed +//! `POST /v1/tools/web_search` (`web-search` builds), the `/v1/cache` blob +//! cache (`local` builds), and, with the default-on `stt` feature, the +//! `POST /v1/audio/transcriptions` batch route and +//! `WS /v1/realtime?intent=transcription` served by the speech crate. All +//! of it is bearer-authed behind model routing and the shared dominion +//! queues (`[[dominion]]`); local generative inference runs as a managed +//! `llama-server` child (`[[local_model]]`). +//! +//! The admin surface's open tier - profiles and the profile switch, the +//! status readout, the progress stream, and queue cancellation - is +//! bearer-authed and reachable from any peer the listener admits. Its +//! walled tier - the config read and shadow-write routes, env, pending +//! state, apply and revert, host metrics, the Hugging Face proxy, the +//! cloud model sheet, reveal, `POST /shutdown`, and (in `config-ui` +//! builds) the embedded SPA at `/config/` with its `GET /auth?key=` +//! browser handoff, plus orphans, model-info, and chat-templates in +//! `local` builds - reads secrets in plaintext, writes files, or launches +//! processes, so it sits behind the shared loopback wall from +//! `shared-loopback` in every build. The one enumerable list of routes, +//! with the tier each belongs to, is the registry (`registry::all`); it +//! is logged at debug level when the router is built and swept by the +//! tests that prove each tier's wall. +//! +//! With `[server] trust_loopback` on (the default), a loopback peer +//! presenting no credential is admitted to every route unless its Fetch +//! Metadata marks a cross-origin page; `trust_loopback = false` requires +//! the bearer key from every caller. When the listener is bound to +//! loopback, every route additionally sits behind the shared //! host-authority wall, which refuses requests whose `Host` is not the -//! bound socket (the DNS-rebinding defense). In-process -//! llama.cpp FFI and endpoint pinning are deferred. +//! bound socket (the DNS-rebinding defense). In-process llama.cpp FFI and +//! endpoint pinning are deferred. //! //! ## Where new route code goes //! //! A route area gets a module named after it (`relay`, `speech`, -//! `models`, `admin`); a module earns a directory at three or more -//! files, the way `admin/` does. A new endpoint never edits this crate -//! root outside the route table in `build_router`: the handler goes in -//! its area's module, and its tests in that module's kebab `-tests.rs` -//! sibling, wired with `#[path]`. +//! `models`, `health`); a module earns a directory at three or more +//! files. Each area module owns its own mounts behind +//! `pub(crate) fn routes() -> Router`, and `build_router` only +//! merges the areas and applies the walls, so a new endpoint never edits +//! this crate root: the handler and its `.route()` line go in the area's +//! module, and its tests in that module's kebab `-tests.rs` sibling, +//! wired with `#[path]`. +//! +//! The admin surface is split by tier, and the tier is the directory. +//! `admin/open/` holds the bearer-authed routes any admitted peer may +//! reach; `admin/walled/` holds the routes that read secrets in +//! plaintext, write files, or launch processes, which `build_router` +//! merges once behind the shared loopback wall. A handler under +//! `walled/` takes `LoopbackCaller` where an open handler takes +//! `AuthedCaller`, so its signature states the tier its path already +//! does. A new admin route picks its directory by that question and +//! nothing else. //! //! Handlers read live state through the `AppState` accessors //! (`config()`, `routing()`, `profile_name()`), never by naming the @@ -102,35 +77,25 @@ mod boot; mod boot_load; #[cfg(feature = "local")] mod cache; -#[cfg(feature = "local")] -mod chat_templates; -mod cloud_models; mod commands; -mod config_apply; -mod config_pending; -mod config_write; +mod config_shadow; mod diagnostics; mod dialect; -mod env_file; mod error; -mod handoff; -mod hf; -mod model_info; +mod health; mod models; -#[cfg(feature = "local")] -mod orphans; +mod registry; mod relaunch; mod relay; -mod render; -mod reveal; mod routing; mod runner; mod shutdown; mod speech; -mod system; #[cfg(test)] mod test_support; mod tray; +#[cfg(feature = "web-search")] +mod web_search; // The wire protocol and upstream abstraction live in the protocol crate; // these re-exports keep every `crate::wire::*` and `crate::upstream::*` @@ -162,30 +127,21 @@ pub use gateway_config::{ use std::collections::BTreeSet; use std::sync::Arc; -use axum::Json; -#[cfg(feature = "web-search")] -use axum::extract::State; -#[cfg(feature = "local")] -use axum::routing::delete; -use axum::routing::{get, post}; -use axum::{Router, response::IntoResponse}; +use axum::Router; use tokio::sync::RwLock; use crate::admin::AdminConfig; -#[cfg(feature = "web-search")] -use crate::auth::AuthedCaller; -#[cfg(feature = "web-search")] -use crate::error::{GatewayError, WireJson}; +use crate::admin::walled::{cloud_models, hf, reveal, system}; #[cfg(feature = "local")] use crate::local::LocalRuntime; use crate::routing::Routing; #[cfg(feature = "web-search")] use gateway_config::WebSearchConfig; +use gateway_progress::ProgressHub; #[cfg(feature = "stt")] use gateway_stt::SpeechService; #[cfg(feature = "web-search")] -use gateway_web_search::{WebSearchRequest, WebSearchResponse, WebSearchState}; -use shared_progress::ProgressHub; +use gateway_web_search::WebSearchState; /// Mutable live configuration held behind a lock so the boot load and a /// config apply can swap routing without rebuilding the axum router. @@ -280,8 +236,9 @@ pub(crate) struct AppState { /// half-promotes one, and no pending read observes a half-written /// selection. Held for those short steps only, never across a download. apply: Arc>, - /// The process-lifetime progress broker: operations attach trees for - /// their own lifetimes, and `GET /admin/progress` streams its events. + /// The process-lifetime activity hub: commands and startup stages begin + /// activities for their own lifetimes, `GET /admin/progress` streams its + /// snapshots, and `GET /admin/status` and the tray read the current one. hub: Arc, /// The command queue: the boot load, config applies, and unloads run /// as serialized, cancellable commands; the tray and routes read its @@ -386,7 +343,7 @@ impl AppState { } } - /// Build full runtime state for `Gateway` and integration tests. + /// Builds full runtime state for `Gateway` and integration tests. #[must_use] #[expect( clippy::too_many_arguments, @@ -489,9 +446,17 @@ impl AppState { let live = self.live.try_read().ok()?; Some(live.model_status()) } + + /// The hub's current activity text for the tray's status line: `Some` + /// while any activity is live, `None` when the gateway is idle. + #[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] + pub(crate) fn tray_busy_text(&self) -> Option { + let progress = self.hub.current(); + progress.busy.then_some(progress.text) + } } -/// Build the gateway's axum router. +/// Builds the gateway's axum router. /// /// `bound` is the socket the server actually bound. When it is loopback, /// the whole surface is wrapped in the shared host-authority wall @@ -500,110 +465,29 @@ impl AppState { /// no loopback allowlist to enforce. The [`Gateway::router`] seam passes /// `None` and carries no host wall: with no bound socket there is no /// authority to allowlist. -#[expect( - clippy::too_many_lines, - reason = "the route table is deliberately one screen: every mount, wall, and feature gate in a single scan" -)] pub(crate) fn build_router(state: AppState, bound: Option) -> Router { + // The open tier: every area any admitted peer may reach, each mounted + // by its own module. Feature-gated areas merge under the same gate + // that compiles them, so a build with a feature off serves exactly + // the space of a build with it on, minus that area. let router = Router::new() - .route("/v1/chat/completions", post(relay::chat_completions)) - .route("/v1/embeddings", post(relay::embeddings)) - .route("/v1/rerank", post(relay::rerank)) - .route("/v1/audio/speech", post(speech::audio_speech)) - .route("/v1/audio/voices", get(speech::audio_voices)) - .route("/v1/models", get(models::list_models)) - .route("/health", get(health)) - .route("/admin/profiles", get(admin::profiles::admin_list_profiles)) - .route("/admin/status", get(admin::status::admin_status)) - .route("/admin/progress", get(admin::progress::admin_progress)) - .route( - "/admin/switch-profile", - post(admin::profiles::admin_switch_profile), - ) - .route( - "/admin/queue/cancel", - post(admin::queue::admin_queue_cancel), - ) - .route( - "/admin/queue/cancel-pending", - post(admin::queue::admin_queue_cancel_pending), - ); - // The web-search tool route delegates to the service crate, so it exists - // only in builds with the `web-search` feature. + .merge(relay::routes()) + .merge(speech::routes()) + .merge(models::routes()) + .merge(health::routes()) + .merge(admin::open::routes()); #[cfg(feature = "web-search")] - let router = router.route("/v1/tools/web_search", post(web_search)); - // The blob-cache routes serve the local artifact store, so they exist - // only in builds with local inference. + let router = router.merge(web_search::routes()); #[cfg(feature = "local")] - let router = router - .route("/v1/cache", get(cache::list_cache).post(cache::post_cache)) - .route("/v1/cache/{sha256}", delete(cache::delete_cache)); - - // The admin config surface reads secrets in plaintext, writes files, - // and launches processes, so every route below sits behind the shared - // loopback wall in every build: a non-loopback peer is refused with - // 403 before bearer auth even runs. `POST /shutdown` kills the process - // and `GET /auth` mints the key's ambient cookie, so both are walled - // with the config surface they serve. - let walled = Router::new() - .route("/shutdown", post(shutdown::admin_shutdown)) - .route("/admin/system", get(system::admin_system)) - .route( - "/admin/config", - get(admin::config::admin_config).put(config_write::admin_put_config), - ) - .route( - "/admin/config-pending", - get(config_pending::admin_config_pending), - ) - .route( - "/admin/config-dirty", - get(config_pending::admin_config_dirty), - ) - .route( - "/admin/config-apply", - post(config_apply::admin_config_apply), - ) - .route( - "/admin/config-revert", - post(config_apply::admin_config_revert), - ) - .route( - "/admin/env", - get(env_file::admin_get_env).put(env_file::admin_put_env), - ) - .route("/admin/cloud-models", get(cloud_models::admin_cloud_models)) - .route( - "/admin/cloud-models/refresh", - post(cloud_models::admin_cloud_models_refresh), - ) - .route("/admin/reveal", post(reveal::admin_reveal)) - .route("/admin/hf/search", get(hf::admin_hf_search)) - .route("/admin/hf/model/{owner}/{name}", get(hf::admin_hf_model)) - .route( - "/admin/hf/model/{owner}/{name}/readme", - get(hf::admin_hf_readme), - ); - // The template, orphan, and model-info routes read local-inference - // facilities, so they exist only in builds with local inference. - #[cfg(feature = "local")] - let walled = walled - .route( - "/admin/chat-templates", - get(chat_templates::admin_chat_templates), - ) - .route("/admin/orphans", get(orphans::admin_orphans)) - .route("/admin/model-info", get(model_info::admin_model_info)); - // `GET /config` (no trailing slash) redirects to `/config/` so the - // SPA's relative asset references resolve against the mount point; - // it is walled like the assets it fronts. `GET /auth` is the browser - // handoff onto that surface, so it exists only when the surface does. - #[cfg(feature = "config-ui")] - let walled = walled - .route("/config", get(config_ui_redirect)) - .route("/auth", get(handoff::auth_handoff)); - let router = router - .merge(walled.route_layer(axum::middleware::from_fn(shared_loopback::require_loopback))); + let router = router.merge(cache::routes()); + // The walled tier reads secrets in plaintext, writes files, and + // launches processes, so it sits behind the shared loopback wall in + // every build: a non-loopback peer is refused with 403 before bearer + // auth even runs. The wall is applied here, once, at the merge. + let router = router.merge( + admin::walled::routes() + .route_layer(axum::middleware::from_fn(shared_loopback::require_loopback)), + ); // The SPA asset router arrives with the same loopback wall already // applied inside `routes()`; `nest_service` because the asset router // carries no gateway state. @@ -619,6 +503,17 @@ pub(crate) fn build_router(state: AppState, bound: Option) auth::authorize_stt_route, )), ); + // The registry is the enumerable form of the table just assembled; + // logging it at debug level puts the mounted surface, tier by tier, + // in the log of every build without anyone maintaining a list. + for route in registry::all() { + tracing::debug!( + path = route.path, + methods = ?route.methods, + tier = ?route.tier, + "route mounted" + ); + } // The host-authority wall is the outermost layer, so a rebound // hostname is refused before any route logic runs. match bound { @@ -630,39 +525,6 @@ pub(crate) fn build_router(state: AppState, bound: Option) } } -/// Redirects `GET /config` to `/config/`, where the SPA index is served -/// and its relative asset references resolve. -#[cfg(feature = "config-ui")] -async fn config_ui_redirect() -> axum::response::Redirect { - axum::response::Redirect::permanent("/config/") -} - -/// The `POST /v1/tools/web_search` route: bearer-authed, delegates to the -/// web-search service crate. -/// -/// # Errors -/// Returns [`GatewayError::Unauthorized`] when the bearer token is absent or -/// wrong, [`GatewayError::ToolNotConfigured`] when no `[tools.web_search]` -/// section is present, [`GatewayError::MalformedRequest`] when the request -/// fails validation, and the upstream variants on a provider failure. -#[cfg(feature = "web-search")] -async fn web_search( - State(state): State, - _caller: AuthedCaller, - WireJson(request): WireJson, -) -> Result, GatewayError> { - let service = state - .web_search() - .await - .ok_or(GatewayError::ToolNotConfigured("web_search"))?; - Ok(Json(service.search(&request).await?)) -} - -/// Liveness probe; unauthenticated and always 200 while serving. -async fn health() -> impl IntoResponse { - Json(serde_json::json!({ "status": "serving" })) -} - #[cfg(test)] #[path = "loopback-tests.rs"] mod loopback_tests; diff --git a/crates/gateway/app/src/loopback-tests.rs b/crates/gateway/app/src/loopback-tests.rs index 13bbf5839..05b9bfbf6 100644 --- a/crates/gateway/app/src/loopback-tests.rs +++ b/crates/gateway/app/src/loopback-tests.rs @@ -1,10 +1,8 @@ -//! The loopback and host-authority walls through the real router and a real listener. -//! The shared loopback wall over the admin config surface: every -//! walled path refuses a LAN peer with 403 even when it presents the -//! valid bearer key, admits a loopback peer past the wall, and fails -//! closed when no peer address exists; the bearer-only routes stay -//! reachable from any source. The `config-ui` feature's `/config` -//! mount and redirect are pinned here too, in both feature states. +//! The host-authority wall and the config SPA mount through the real +//! router. The per-route loopback wall sweeps live beside the registry +//! (`registry-tests.rs`), driven by the declared tiers; this file pins +//! what the registry does not enumerate: the nested SPA asset router, the +//! `config-ui` feature's two states, and the host wall over every route. use std::net::SocketAddr; @@ -12,82 +10,11 @@ use axum::body::Body; use axum::extract::ConnectInfo; use axum::http::header::AUTHORIZATION; use axum::http::{Method, Request, Response, StatusCode}; -use gateway_config::Config; use tower::ServiceExt; -use crate::test_support::{AdminPaths, app_state}; +use crate::test_support::walled_fixture; use crate::{AppState, build_router}; -/// A tempdir-backed state with real profiles and boot files, so every -/// walled handler has something to answer with once past the wall. -fn fixture() -> (tempfile::TempDir, AppState) { - let temp = tempfile::TempDir::new().expect("tempdir"); - let models = temp.path().join("cache").join("models"); - std::fs::create_dir_all(&models).expect("mkdir cache models"); - let boot = temp.path().join("gateway.toml"); - std::fs::write(&boot, "").expect("write boot"); - let config = Config::from_toml_str(&format!( - r#" -config-version = 0 - -[server] -bind = "127.0.0.1:0" -api_key = "test-token" - -[local] -cache_dir = '{cache}' -"#, - cache = temp.path().join("cache").display(), - )) - .expect("the fixture profile parses"); - let state = app_state( - config, - Some(AdminPaths { - fixture_dir: temp.path().to_path_buf(), - active: "main".to_owned(), - config_path: boot, - }), - ); - (temp, state) -} - -/// Every admin config path behind the shared wall, with the method -/// exercised against it. The HF requests are deliberately malformed -/// (a duplicate query key, a slashless repo) so a loopback sweep is -/// refused at validation and never reaches the real hub; every other -/// empty-bodied write fails its own extractor the same way. All of -/// that happens past the wall, so any non-403 status proves -/// admission. -fn walled_requests() -> Vec<(Method, &'static str)> { - let requests = vec![ - (Method::GET, "/admin/config"), - (Method::PUT, "/admin/config"), - (Method::GET, "/admin/env"), - (Method::PUT, "/admin/env"), - (Method::GET, "/admin/config-pending"), - (Method::GET, "/admin/config-dirty"), - (Method::POST, "/admin/config-apply"), - (Method::POST, "/admin/config-revert"), - (Method::GET, "/admin/system"), - (Method::GET, "/admin/cloud-models"), - (Method::POST, "/admin/cloud-models/refresh"), - (Method::GET, "/admin/hf/search?q=a&q=b"), - (Method::GET, "/admin/hf/model/owner/na%20me"), - (Method::POST, "/admin/reveal"), - ]; - #[cfg(feature = "local")] - let requests = { - let mut requests = requests; - requests.extend([ - (Method::GET, "/admin/chat-templates"), - (Method::GET, "/admin/orphans"), - (Method::GET, "/admin/model-info"), - ]); - requests - }; - requests -} - /// Sends one empty-bodied request through `build_router` with the /// valid bearer key and the given peer address planted as the /// `ConnectInfo` extension (or none at all). @@ -113,112 +40,9 @@ async fn send_with_peer( .expect("the router is infallible") } -#[tokio::test] -async fn every_walled_path_refuses_a_lan_peer_with_403() { - let (_temp, state) = fixture(); - for (method, path) in walled_requests() { - let status = send_with_peer( - state.clone(), - method.clone(), - path, - Some("198.51.100.7:44821"), - ) - .await - .status(); - assert_eq!( - status, - StatusCode::FORBIDDEN, - "{method} {path} must refuse a LAN peer even with the valid bearer key" - ); - } -} - -#[tokio::test] -async fn every_walled_path_admits_a_loopback_peer_past_the_wall() { - let (_temp, state) = fixture(); - for (method, path) in walled_requests() { - let status = send_with_peer(state.clone(), method.clone(), path, Some("127.0.0.1:50000")) - .await - .status(); - assert_ne!( - status, - StatusCode::FORBIDDEN, - "{method} {path} must pass the wall for a loopback peer" - ); - } -} - -#[tokio::test] -async fn every_walled_path_fails_closed_without_a_peer_address() { - let (_temp, state) = fixture(); - for (method, path) in walled_requests() { - let status = send_with_peer(state.clone(), method.clone(), path, None) - .await - .status(); - assert_eq!( - status, - StatusCode::FORBIDDEN, - "{method} {path} must fail closed when the peer address is unknown" - ); - } -} - -#[tokio::test] -async fn the_bearer_only_routes_stay_reachable_from_the_lan() { - let (_temp, state) = fixture(); - for path in [ - "/admin/status", - "/admin/profiles", - "/admin/progress", - "/v1/models", - ] { - let status = send_with_peer(state.clone(), Method::GET, path, Some("198.51.100.7:44821")) - .await - .status(); - assert_eq!( - status, - StatusCode::OK, - "GET {path} keeps its bearer-only, any-source behavior" - ); - } - // The switch route stays any-source too; the empty body fails its - // own extractor past auth, so any non-403 status proves the wall - // is absent (the same trick as the loopback-admission sweep). - let status = send_with_peer( - state.clone(), - Method::POST, - "/admin/switch-profile", - Some("198.51.100.7:44821"), - ) - .await - .status(); - assert_ne!( - status, - StatusCode::FORBIDDEN, - "POST /admin/switch-profile keeps its bearer-only, any-source behavior" - ); - // The queue-cancel routes share the bearer-only, any-source - // posture: cancelling a command mutates no configuration. - for path in ["/admin/queue/cancel", "/admin/queue/cancel-pending"] { - let status = send_with_peer( - state.clone(), - Method::POST, - path, - Some("198.51.100.7:44821"), - ) - .await - .status(); - assert_ne!( - status, - StatusCode::FORBIDDEN, - "POST {path} keeps its bearer-only, any-source behavior" - ); - } -} - #[tokio::test] async fn admin_status_reports_a_stable_config_generation() { - let (_temp, state) = fixture(); + let (_temp, state) = walled_fixture(); let first = send_with_peer( state.clone(), Method::GET, @@ -257,7 +81,7 @@ async fn admin_status_reports_a_stable_config_generation() { #[cfg(feature = "config-ui")] #[tokio::test] async fn config_without_a_trailing_slash_redirects_to_the_mount() { - let (_temp, state) = fixture(); + let (_temp, state) = walled_fixture(); let response = send_with_peer(state, Method::GET, "/config", Some("127.0.0.1:50000")).await; assert_eq!(response.status(), StatusCode::PERMANENT_REDIRECT); assert_eq!( @@ -273,7 +97,7 @@ async fn config_without_a_trailing_slash_redirects_to_the_mount() { #[cfg(feature = "config-ui")] #[tokio::test] async fn the_config_ui_is_served_at_the_trailing_slash_mount() { - let (_temp, state) = fixture(); + let (_temp, state) = walled_fixture(); for path in ["/config/", "/config/app.js"] { let status = send_with_peer(state.clone(), Method::GET, path, Some("127.0.0.1:50000")) .await @@ -285,7 +109,7 @@ async fn the_config_ui_is_served_at_the_trailing_slash_mount() { #[cfg(feature = "config-ui")] #[tokio::test] async fn the_config_surface_refuses_a_lan_peer() { - let (_temp, state) = fixture(); + let (_temp, state) = walled_fixture(); for path in ["/config", "/config/", "/config/app.js"] { let status = send_with_peer(state.clone(), Method::GET, path, Some("198.51.100.7:44821")) .await @@ -301,7 +125,7 @@ async fn the_config_surface_refuses_a_lan_peer() { #[cfg(not(feature = "config-ui"))] #[tokio::test] async fn without_the_feature_no_config_routes_exist() { - let (_temp, state) = fixture(); + let (_temp, state) = walled_fixture(); for path in [ "/config", "/config/", @@ -342,7 +166,7 @@ async fn send_with_host(state: AppState, path: &str, host: Option<&str>) -> Stat #[tokio::test] async fn the_host_wall_refuses_a_foreign_host_on_every_route() { - let (_temp, state) = fixture(); + let (_temp, state) = walled_fixture(); // `/health` is deliberately not exempt: the gateway-discovery-file probe // sends the bound address as Host, so the wall keeps it honest. for path in ["/health", "/admin/status", "/v1/models", "/shutdown"] { @@ -356,7 +180,7 @@ async fn the_host_wall_refuses_a_foreign_host_on_every_route() { #[tokio::test] async fn the_host_wall_admits_the_bound_and_localhost_authorities() { - let (_temp, state) = fixture(); + let (_temp, state) = walled_fixture(); for host in ["127.0.0.1:8081", "localhost:8081"] { assert_eq!( send_with_host(state.clone(), "/health", Some(host)).await, @@ -373,7 +197,7 @@ async fn the_host_wall_admits_the_bound_and_localhost_authorities() { #[tokio::test] async fn the_router_seam_without_a_bound_address_carries_no_host_wall() { - let (_temp, state) = fixture(); + let (_temp, state) = walled_fixture(); let response = build_router(state, None) .oneshot( Request::builder() diff --git a/crates/gateway/app/src/main-logging-tests.rs b/crates/gateway/app/src/main-logging-tests.rs index b6cfb411c..7cdbf94c5 100644 --- a/crates/gateway/app/src/main-logging-tests.rs +++ b/crates/gateway/app/src/main-logging-tests.rs @@ -1,3 +1,5 @@ +//! Tests for production logging that redact protected values and honor the process lease. + #[cfg(feature = "stt")] use std::path::PathBuf; #[cfg(feature = "stt")] diff --git a/crates/gateway/app/src/main.rs b/crates/gateway/app/src/main.rs index 2374221c0..a576c5e96 100644 --- a/crates/gateway/app/src/main.rs +++ b/crates/gateway/app/src/main.rs @@ -267,7 +267,7 @@ fn init_logging_for_state(state_dir: Option) -> Option { #[path = "main-logging-tests.rs"] mod logging_tests; -/// Log the error and its full `source()` chain through the subscriber, so +/// Logs the error and its full `source()` chain through the subscriber, so /// the fatal outcome lands in the drained queue. fn log_error_chain(error: &dyn std::error::Error) { tracing::error!("error: {error}"); @@ -278,7 +278,7 @@ fn log_error_chain(error: &dyn std::error::Error) { } } -/// Print the error and its full `source()` chain to stderr: the fallback +/// Prints the error and its full `source()` chain to stderr: the fallback /// when the logger itself never started. fn print_error_chain(error: &dyn std::error::Error) { eprintln!("error: {error}"); @@ -305,7 +305,7 @@ enum ParseError { enum Command { /// Serve (the default and only serving mode). Serve, - /// Print the diagnostics report and exit. + /// Prints the diagnostics report and exits. Diagnostics, } @@ -328,7 +328,7 @@ struct Invocation { print_url: bool, } -/// Parse the command line into a typed [`Invocation`]. +/// Parses the command line into a typed [`Invocation`]. /// /// The bare invocation serves; there are no subcommands. Uses `OsString` /// operands so non-UTF-8 config paths survive. The config path diff --git a/crates/gateway/app/src/model_info.rs b/crates/gateway/app/src/model_info.rs deleted file mode 100644 index 8d123cd1e..000000000 --- a/crates/gateway/app/src/model_info.rs +++ /dev/null @@ -1,330 +0,0 @@ -//! The `GET /admin/model-info` route: architecture, layer count, and -//! parameter count read from a GGUF header in the artifact cache, feeding -//! the UI's `gpu_layers` "N / total" slider readout. -//! -//! The header parse is blocking filesystem work, so it runs inside -//! `tokio::task::spawn_blocking` like every store operation (Amendment D). -//! The parser itself lives in the local crate beside the blob cache, which -//! owns GGUF domain knowledge. - -#[cfg(feature = "local")] -#[cfg(feature = "local")] -use axum::Json; -#[cfg(feature = "local")] -use axum::extract::rejection::QueryRejection; -#[cfg(feature = "local")] -use axum::extract::{Query, State}; -#[cfg(feature = "local")] -use serde::Deserialize; -use serde::Serialize; -#[cfg(feature = "local")] -use std::path::PathBuf; - -#[cfg(feature = "local")] -use crate::AppState; -#[cfg(feature = "local")] -use crate::auth::AuthedCaller; -#[cfg(feature = "local")] -use crate::error::GatewayError; -#[cfg(feature = "local")] -use crate::local::{LocalError, gguf, resolve_cache_root}; -use crate::wire::ModelInfo; - -/// The model-list wire response, including routed and active speech models. -#[derive(Debug, Serialize)] -pub(crate) struct CatalogModelsResponse { - /// Always `"list"`. - pub(crate) object: &'static str, - /// Models currently accepting their respective request shape. - pub(crate) data: Vec, -} - -/// One routed inference model or active speech model. -#[derive(Debug, Serialize)] -#[serde(untagged)] -pub(crate) enum CatalogModelInfo { - /// Existing chat, embedding, classifier, or speech metadata. - Inference(ModelInfo), - /// Generic transcription metadata. - #[cfg(feature = "stt")] - Speech(SpeechCatalogModelInfo), -} - -impl CatalogModelInfo { - pub(crate) fn inference(model: ModelInfo) -> Self { - Self::Inference(model) - } - - #[cfg(feature = "stt")] - pub(crate) fn speech(model: &gateway_stt::SpeechModelInfo) -> Self { - Self::Speech(SpeechCatalogModelInfo { - id: model.name().to_owned(), - object: "model", - kind: "transcription", - }) - } -} - -/// Speech metadata contains only fields meaningful to transcription clients. -#[cfg(feature = "stt")] -#[derive(Debug, Serialize)] -pub(crate) struct SpeechCatalogModelInfo { - id: String, - object: &'static str, - kind: &'static str, -} - -/// Query parameters for `GET /admin/model-info`. -#[cfg(feature = "local")] -#[derive(Debug, Deserialize)] -pub(crate) struct ModelInfoQuery { - /// Cache-relative path of the GGUF file to inspect. - path: String, -} - -/// The `GET /admin/model-info?path=` route: bearer-authed, parses the GGUF -/// header of the named cache file and reports -/// `{"architecture", "layer_count", "parameter_count", "chat_template"}` -/// (each nullable). -/// -/// `path` is caller input and is confined to the artifact cache: only a -/// relative path that resolves under the resolved cache root without -/// crossing a link is accepted - the same `/`-separated form -/// `GET /admin/orphans` reports - so the endpoint can never read an -/// arbitrary file. A missing or escaping path maps to 400; a file that is -/// missing or not a well-formed GGUF header maps to 422. The UI treats any -/// failure as "layer count unknown" and falls back to a plain readout. -#[cfg(feature = "local")] -pub(crate) async fn admin_model_info( - State(state): State, - query: Result, QueryRejection>, - _caller: AuthedCaller, -) -> Result, GatewayError> { - // Deferring the extractor keeps auth first and puts the rejection in - // the gateway's JSON error envelope instead of axum's plain-text 400. - let Query(query) = - query.map_err(|rejection| GatewayError::MalformedRequest(rejection.body_text()))?; - // The retained running config carries the `[local].cache_dir` the path - // is confined to, so the boundary and the store agree on the root. - let config = state.config().await; - let info = tokio::task::spawn_blocking(move || { - let root = resolve_cache_root(config.local().cache_dir())?; - gguf::read_model_info(&root, &PathBuf::from(query.path)) - }) - .await - // A join failure is a panicked server task, not bad client data: 500, - // matching the orphans and system routes. - .map_err(GatewayError::cache)? - .map_err(|error| match error { - // The rejected boundary check is the caller's fault, not the file's. - LocalError::UnsafeCachePath { path } => GatewayError::MalformedRequest(format!( - "path `{}` is not a relative path inside the artifact cache", - path.display() - )), - other => GatewayError::model_info(other), - })?; - Ok(Json(info)) -} - -#[cfg(all(test, feature = "local"))] -mod tests { - #![expect( - clippy::expect_used, - reason = "route fixtures fail with the named setup or transport invariant" - )] - - use std::net::SocketAddr; - use std::path::Path; - - use gateway_config::Config; - - #[cfg(feature = "stt")] - use super::{CatalogModelInfo, CatalogModelsResponse}; - use crate::test_support::serve; - - /// A profile rooting the artifact cache at `cache_dir`. - fn cache_config(cache_dir: &Path) -> Config { - Config::from_toml_str(&format!( - r#" -config-version = 0 - -[server] -bind = "127.0.0.1:0" -api_key = "test-token" -# Strict bearer auth: the tests below pin that a missing key is refused. -trust_loopback = false - -[local] -cache_dir = '{cache_dir}' -"#, - cache_dir = cache_dir.display(), - )) - .expect("the fixture profile parses") - } - - /// Appends a GGUF string (u64 LE length + bytes) to `out`. - fn push_string(out: &mut Vec, value: &str) { - out.extend_from_slice(&(value.len() as u64).to_le_bytes()); - out.extend_from_slice(value.as_bytes()); - } - - /// A minimal GGUF header with a known architecture, block count, and - /// declared parameter count. - fn synthetic_gguf() -> Vec { - let mut out = Vec::new(); - out.extend_from_slice(b"GGUF"); - out.extend_from_slice(&3u32.to_le_bytes()); - out.extend_from_slice(&0u64.to_le_bytes()); - out.extend_from_slice(&3u64.to_le_bytes()); - push_string(&mut out, "general.architecture"); - out.extend_from_slice(&8u32.to_le_bytes()); - push_string(&mut out, "llama"); - push_string(&mut out, "llama.block_count"); - out.extend_from_slice(&4u32.to_le_bytes()); - out.extend_from_slice(&32u32.to_le_bytes()); - push_string(&mut out, "general.parameter_count"); - out.extend_from_slice(&10u32.to_le_bytes()); - out.extend_from_slice(&8_030_000_000u64.to_le_bytes()); - out - } - - /// GETs `/admin/model-info` for `path` with the given bearer token. - async fn get_model_info(addr: SocketAddr, path: &str, token: &str) -> reqwest::Response { - reqwest::Client::new() - .get(format!("http://{addr}/admin/model-info")) - .query(&[("path", path)]) - .bearer_auth(token) - .send() - .await - .expect("the request sends") - } - - #[tokio::test] - async fn admin_model_info_reports_header_facts() { - let temp = tempfile::TempDir::new().expect("tempdir"); - let models = temp.path().join("models"); - std::fs::create_dir_all(&models).expect("mkdir models"); - std::fs::write(models.join("tiny.gguf"), synthetic_gguf()).expect("write fixture"); - - let addr = serve(cache_config(temp.path())).await; - let response = get_model_info(addr, "models/tiny.gguf", "test-token").await; - assert_eq!(response.status(), reqwest::StatusCode::OK); - let body: serde_json::Value = response.json().await.expect("a JSON body"); - assert_eq!( - body, - serde_json::json!({ - "architecture": "llama", - "layer_count": 32, - "parameter_count": 8_030_000_000u64, - "chat_template": null, - }) - ); - } - - #[tokio::test] - async fn admin_model_info_rejects_a_malformed_file_cleanly() { - let temp = tempfile::TempDir::new().expect("tempdir"); - let models = temp.path().join("models"); - std::fs::create_dir_all(&models).expect("mkdir models"); - std::fs::write(models.join("junk.gguf"), b"not a gguf at all").expect("write junk"); - - let addr = serve(cache_config(temp.path())).await; - let response = get_model_info(addr, "models/junk.gguf", "test-token").await; - assert_eq!(response.status(), reqwest::StatusCode::UNPROCESSABLE_ENTITY); - let body: serde_json::Value = response.json().await.expect("a JSON error envelope"); - assert_eq!(body["error"]["code"], "model_info_error"); - } - - #[tokio::test] - async fn admin_model_info_rejects_paths_escaping_the_cache() { - let temp = tempfile::TempDir::new().expect("tempdir"); - std::fs::create_dir_all(temp.path().join("models")).expect("mkdir models"); - let outside = temp.path().join("..").join("outside.gguf"); - - let addr = serve(cache_config(temp.path())).await; - for escape in ["../outside.gguf", &outside.display().to_string()] { - let response = get_model_info(addr, escape, "test-token").await; - assert_eq!( - response.status(), - reqwest::StatusCode::BAD_REQUEST, - "path `{escape}` must be refused at the boundary" - ); - let body: serde_json::Value = response.json().await.expect("a JSON error envelope"); - assert_eq!(body["error"]["code"], "malformed_request"); - } - } - - #[tokio::test] - async fn admin_model_info_rejects_a_missing_path_in_the_error_envelope() { - let temp = tempfile::TempDir::new().expect("tempdir"); - let addr = serve(cache_config(temp.path())).await; - - let response = reqwest::Client::new() - .get(format!("http://{addr}/admin/model-info")) - .bearer_auth("test-token") - .send() - .await - .expect("the request sends"); - assert_eq!(response.status(), reqwest::StatusCode::BAD_REQUEST); - let body: serde_json::Value = response.json().await.expect("a JSON error envelope"); - assert_eq!(body["error"]["code"], "malformed_request"); - } - - #[tokio::test] - async fn admin_model_info_requires_bearer_auth() { - let temp = tempfile::TempDir::new().expect("tempdir"); - let addr = serve(cache_config(temp.path())).await; - - let unauthenticated = reqwest::Client::new() - .get(format!("http://{addr}/admin/model-info")) - .query(&[("path", "models/tiny.gguf")]) - .send() - .await - .expect("the request sends"); - assert_eq!( - unauthenticated.status(), - reqwest::StatusCode::UNAUTHORIZED, - "a request without a bearer token is refused" - ); - - let wrong_key = get_model_info(addr, "models/tiny.gguf", "wrong-token").await; - assert_eq!( - wrong_key.status(), - reqwest::StatusCode::UNAUTHORIZED, - "a request with the wrong bearer token is refused" - ); - } - - #[cfg(feature = "stt")] - #[test] - fn speech_catalog_metadata_is_generic_transcription_metadata() { - use gateway_stt::test_fixtures::{ScriptedDecoder, ScriptedModelFactory, scripted_service}; - - let factory = - ScriptedModelFactory::new(ScriptedDecoder::new()).with_final(ScriptedDecoder::new()); - let service = scripted_service(factory, 15, 500).expect("scripted service starts"); - let data = service - .models() - .iter() - .map(CatalogModelInfo::speech) - .collect(); - let value = serde_json::to_value(CatalogModelsResponse { - object: "list", - data, - }) - .expect("catalog serializes"); - - assert_eq!( - value, - serde_json::json!({ - "object": "list", - "data": [ - {"id": "scripted-interim", "object": "model", "kind": "transcription"}, - {"id": "scripted-final", "object": "model", "kind": "transcription"}, - {"id": "realtime-transcribe", "object": "model", "kind": "transcription"}, - ], - }) - ); - service.shutdown(); - } -} diff --git a/crates/gateway/app/src/models-tests.rs b/crates/gateway/app/src/models-tests.rs new file mode 100644 index 000000000..200e37690 --- /dev/null +++ b/crates/gateway/app/src/models-tests.rs @@ -0,0 +1,33 @@ +use gateway_stt::test_fixtures::{ScriptedDecoder, ScriptedModelFactory, scripted_service}; + +use super::{CatalogModelInfo, CatalogModelsResponse}; + +#[test] +fn speech_catalog_metadata_is_generic_transcription_metadata() { + let factory = + ScriptedModelFactory::new(ScriptedDecoder::new()).with_final(ScriptedDecoder::new()); + let service = scripted_service(factory, 15, 500).expect("scripted service starts"); + let data = service + .models() + .iter() + .map(CatalogModelInfo::speech) + .collect(); + let value = serde_json::to_value(CatalogModelsResponse { + object: "list", + data, + }) + .expect("catalog serializes"); + + assert_eq!( + value, + serde_json::json!({ + "object": "list", + "data": [ + {"id": "scripted-interim", "object": "model", "kind": "transcription"}, + {"id": "scripted-final", "object": "model", "kind": "transcription"}, + {"id": "realtime-transcribe", "object": "model", "kind": "transcription"}, + ], + }) + ); + service.shutdown(); +} diff --git a/crates/gateway/app/src/models.rs b/crates/gateway/app/src/models.rs index b4942ce60..a420dff7b 100644 --- a/crates/gateway/app/src/models.rs +++ b/crates/gateway/app/src/models.rs @@ -1,27 +1,84 @@ -//! The model catalog surface: `GET /v1/models` and the capability-endpoint -//! status entries the admin status readout renders. +//! The model catalog surface: `GET /v1/models`, its wire shape, and the +//! capability-endpoint status entries the admin status readout renders. -use axum::Json; use axum::extract::State; +use axum::http::Method; +use axum::routing::get; +use axum::{Json, Router}; +use serde::Serialize; use crate::AppState; use crate::auth::AuthedCaller; use crate::error::GatewayError; -use crate::model_info; +use crate::registry::RouteInfo; use crate::wire::ModelInfo; +const LIST_MODELS: RouteInfo = RouteInfo::open("/v1/models", &[Method::GET]); + +/// The catalog route, as the registry sees it. +pub(crate) const ROUTES: &[RouteInfo] = &[LIST_MODELS]; + +/// The catalog route. +pub(crate) fn routes() -> Router { + Router::new().route(LIST_MODELS.path, get(list_models)) +} + +/// The model-list wire response, including routed and active speech models. +#[derive(Debug, Serialize)] +pub(crate) struct CatalogModelsResponse { + /// Always `"list"`. + pub(crate) object: &'static str, + /// Models currently accepting their respective request shape. + pub(crate) data: Vec, +} + +/// One routed inference model or active speech model. +#[derive(Debug, Serialize)] +#[serde(untagged)] +pub(crate) enum CatalogModelInfo { + /// Existing chat, embedding, classifier, or speech metadata. + Inference(ModelInfo), + /// Generic transcription metadata. + #[cfg(feature = "stt")] + Speech(SpeechCatalogModelInfo), +} + +impl CatalogModelInfo { + pub(crate) fn inference(model: ModelInfo) -> Self { + Self::Inference(model) + } + + #[cfg(feature = "stt")] + pub(crate) fn speech(model: &gateway_stt::SpeechModelInfo) -> Self { + Self::Speech(SpeechCatalogModelInfo { + id: model.name().to_owned(), + object: "model", + kind: "transcription", + }) + } +} + +/// Speech metadata contains only fields meaningful to transcription clients. +#[cfg(feature = "stt")] +#[derive(Debug, Serialize)] +pub(crate) struct SpeechCatalogModelInfo { + id: String, + object: &'static str, + kind: &'static str, +} + /// Bearer-authed catalog of configured models for host bind. pub(crate) async fn list_models( State(state): State, _caller: AuthedCaller, -) -> Result, GatewayError> { +) -> Result, GatewayError> { let live = state.live.read().await; let data = live .routing .models() .iter() .map(|model| { - model_info::CatalogModelInfo::inference(ModelInfo { + CatalogModelInfo::inference(ModelInfo { id: model.name.clone(), object: "model", kind: model.kind, @@ -37,14 +94,10 @@ pub(crate) async fn list_models( let data = { let mut data = data; let speech_models = state.speech.models(); - data.extend( - speech_models - .iter() - .map(model_info::CatalogModelInfo::speech), - ); + data.extend(speech_models.iter().map(CatalogModelInfo::speech)); data }; - Ok(Json(model_info::CatalogModelsResponse { + Ok(Json(CatalogModelsResponse { object: "list", data, })) @@ -53,7 +106,7 @@ pub(crate) async fn list_models( /// One capability endpoint's readout in the `GET /admin/status` response: /// the route path, a display name, whether the live routing table serves /// it, and whether a queue command is provisioning its models. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub(crate) struct EndpointStatus { pub(crate) path: &'static str, pub(crate) name: &'static str, @@ -97,3 +150,7 @@ pub(crate) fn with_speech_endpoint( )); (endpoints, speech) } + +#[cfg(all(test, feature = "stt"))] +#[path = "models-tests.rs"] +mod tests; diff --git a/crates/gateway/app/src/orphans.rs b/crates/gateway/app/src/orphans.rs deleted file mode 100644 index 7526c0d15..000000000 --- a/crates/gateway/app/src/orphans.rs +++ /dev/null @@ -1,344 +0,0 @@ -//! The `GET /admin/orphans` route: files in the artifact cache's `models/` -//! tree that no `[[local_model]]` or `[[stt_model]]` declared in the catalog -//! references, so an operator can adopt or delete leftovers. -//! -//! The scan is blocking filesystem work, so it runs inside -//! `tokio::task::spawn_blocking` like every store operation (Amendment D). -//! The diff itself lives in the local crate beside the blob cache, which owns -//! the slot layout and the sidecar records. - -use axum::Json; -use axum::extract::State; - -use gateway_config::SttModelConfig; - -use crate::AppState; -use crate::auth::AuthedCaller; -use crate::error::GatewayError; -use crate::local::{cache::orphans, resolve_cache_root}; - -/// The `GET /admin/orphans` route: bearer-authed, scans `/models/` -/// and reports every file no `[[local_model]]` or `[[stt_model]]` declared in -/// the catalog references as `{"orphans": [{"path", "size_bytes", "sha256"}]}`. -/// -/// `path` is relative to the resolved cache root (`/`-separated on every -/// platform). `sha256` comes from the blob's cache sidecar and is null for -/// files the cache API never downloaded: blobs are multi-gigabyte, so their -/// bytes are never re-hashed here. A missing cache or `models/` directory -/// reports an empty list. -pub(crate) async fn admin_orphans( - State(state): State, - _caller: AuthedCaller, -) -> Result, GatewayError> { - // The retained running config carries both the `[local].cache_dir` the - // scan resolves and the catalog it diffs against: every `[[local_model]]` - // and `[[stt_model]]` the document declares, whether or not the running - // profile selects it. The catalog does not move on an apply, which - // republishes the document with no profile selected. - let config = state.config().await; - let entries = tokio::task::spawn_blocking(move || { - let root = resolve_cache_root(config.local().cache_dir())?; - let stt_sources: Vec<&str> = config - .catalog_stt_models() - .iter() - .map(SttModelConfig::source) - .collect(); - orphans(&root, config.catalog_local_models(), &stt_sources) - }) - .await - .map_err(GatewayError::cache)? - .map_err(GatewayError::cache)?; - Ok(Json(serde_json::json!({ "orphans": entries }))) -} - -#[cfg(test)] -mod tests { - use std::path::Path; - use std::sync::Arc; - use std::time::Duration; - - use gateway_config::{Config, ProfileName}; - use tokio_util::sync::CancellationToken; - - use crate::commands::Command; - use crate::test_support::{app_state, parking_executor, serve, serve_state}; - - /// A profile rooting the cache at `cache_dir` with one `[[local_model]]` - /// whose path source is `configured`. - fn orphan_config(cache_dir: &Path, configured: &Path) -> Config { - Config::from_toml_str(&format!( - r#" -config-version = 0 - -[server] -bind = "127.0.0.1:0" -api_key = "test-token" -# Strict bearer auth: the tests below pin that a missing key is refused. -trust_loopback = false - -[local] -cache_dir = '{cache_dir}' - -[[local_model]] -name = "adopted" -description = "a configured local model" -source = '{configured}' -context = 4096 -"#, - cache_dir = cache_dir.display(), - configured = configured.display(), - )) - .expect("the fixture profile parses") - } - - #[tokio::test] - async fn admin_orphans_lists_only_unconfigured_files() { - let temp = tempfile::TempDir::new().expect("tempdir"); - let models = temp.path().join("models"); - let slot = models.join("0123456789abcdef"); - std::fs::create_dir_all(&slot).expect("mkdir slot"); - let adopted = models.join("adopted.gguf"); - std::fs::write(&adopted, b"adopted-model-bytes").expect("write adopted"); - std::fs::write(models.join("stray.gguf"), b"stray-bytes").expect("write stray"); - let cached_body: &[u8] = b"cached-bytes"; - let cached_digest = "a".repeat(64); - std::fs::write(slot.join("cached.gguf"), cached_body).expect("write cached"); - std::fs::write( - slot.join("cached.gguf.meta.json"), - serde_json::json!({ - "source": "http://seeded.example/cached.gguf", - "sha256": cached_digest, - "size_bytes": cached_body.len(), - }) - .to_string(), - ) - .expect("write sidecar"); - - let addr = serve(orphan_config(temp.path(), &adopted)).await; - let response = reqwest::Client::new() - .get(format!("http://{addr}/admin/orphans")) - .bearer_auth("test-token") - .send() - .await - .expect("the request sends"); - assert_eq!(response.status(), reqwest::StatusCode::OK); - let body: serde_json::Value = response.json().await.expect("a JSON body"); - assert_eq!( - body, - serde_json::json!({ - "orphans": [ - { - "path": "models/0123456789abcdef/cached.gguf", - "size_bytes": cached_body.len(), - "sha256": cached_digest, - }, - { - "path": "models/stray.gguf", - "size_bytes": b"stray-bytes".len(), - "sha256": null, - }, - ] - }) - ); - } - - #[tokio::test] - async fn admin_orphans_with_no_models_directory_is_empty() { - let temp = tempfile::TempDir::new().expect("tempdir"); - let missing = temp.path().join("models").join("never-provisioned.gguf"); - let addr = serve(orphan_config(temp.path(), &missing)).await; - let response = reqwest::Client::new() - .get(format!("http://{addr}/admin/orphans")) - .bearer_auth("test-token") - .send() - .await - .expect("the request sends"); - assert_eq!(response.status(), reqwest::StatusCode::OK); - let body: serde_json::Value = response.json().await.expect("a JSON body"); - assert_eq!(body, serde_json::json!({ "orphans": [] })); - } - - #[tokio::test] - async fn admin_orphans_requires_bearer_auth() { - let temp = tempfile::TempDir::new().expect("tempdir"); - let missing = temp.path().join("models").join("never-provisioned.gguf"); - let addr = serve(orphan_config(temp.path(), &missing)).await; - let http = reqwest::Client::new(); - - let unauthenticated = http - .get(format!("http://{addr}/admin/orphans")) - .send() - .await - .expect("the request sends"); - assert_eq!( - unauthenticated.status(), - reqwest::StatusCode::UNAUTHORIZED, - "a request without a bearer token is refused" - ); - - let wrong_key = http - .get(format!("http://{addr}/admin/orphans")) - .bearer_auth("wrong-token") - .send() - .await - .expect("the request sends"); - assert_eq!( - wrong_key.status(), - reqwest::StatusCode::UNAUTHORIZED, - "a request with the wrong bearer token is refused" - ); - } - - /// A catalog of two `[[local_model]]` entries whose profile `work` - /// selects only `adopted`; `shelved` stays declared but unselected. - fn profiled_toml(cache_dir: &Path, adopted: &Path, shelved: &Path) -> String { - format!( - r#" -config-version = 0 - -[server] -bind = "127.0.0.1:0" -api_key = "test-token" - -[local] -cache_dir = '{cache_dir}' - -[[local_model]] -name = "adopted" -description = "the running profile's local model" -source = '{adopted}' -context = 4096 - -[[local_model]] -name = "shelved" -description = "declared in the catalog, outside the running profile" -source = '{shelved}' -context = 4096 - -[[profile]] -name = "work" -models = ["adopted"] -"#, - cache_dir = cache_dir.display(), - adopted = adopted.display(), - shelved = shelved.display(), - ) - } - - /// Parses `toml` with the `work` profile selected, as the boot does. - fn booted(toml: &str) -> Config { - Config::from_toml_str(toml) - .expect("the fixture profile parses") - .select_profile(Some(&ProfileName::parse("work").expect("profile name"))) - .expect("the work profile selects") - } - - async fn get_json(addr: std::net::SocketAddr, route: &str) -> serde_json::Value { - let response = reqwest::Client::new() - .get(format!("http://{addr}{route}")) - .bearer_auth("test-token") - .send() - .await - .expect("the request sends"); - assert_eq!(response.status(), reqwest::StatusCode::OK); - response.json().await.expect("a JSON body") - } - - /// After an apply, the live document is republished with no profile - /// selected, so `local_models()` is empty for the rest of the process. - /// The orphan scan and the status `configured` flag read the catalog, - /// which the apply does not move. `configured` reaches the wire only as - /// `provisioning` (configured, not ready, and a command active), so the - /// test holds a parked command while it reads the status. - #[tokio::test] - async fn admin_orphans_and_configured_survive_an_apply_with_no_selection() { - let temp = tempfile::TempDir::new().expect("tempdir"); - let models = temp.path().join("models"); - std::fs::create_dir_all(&models).expect("mkdir models"); - let adopted = models.join("adopted.gguf"); - let shelved = models.join("shelved.gguf"); - std::fs::write(&adopted, b"adopted-model-bytes").expect("write adopted"); - std::fs::write(&shelved, b"shelved-model-bytes").expect("write shelved"); - std::fs::write(models.join("stray.gguf"), b"stray-bytes").expect("write stray"); - let toml = profiled_toml(temp.path(), &adopted, &shelved); - - let state = app_state(booted(&toml), None); - let addr = serve_state(state.clone()).await; - - // What `capture_apply` publishes for a `[[model]]`-only shadow: the - // same document, parsed with no profile selected. - let applied = Config::from_toml_str(&toml) - .expect("the applied document parses") - .select_profile(None) - .expect("no selection"); - assert!(applied.local_models().is_empty()); - state.live.write().await.config = Arc::new(applied); - - let orphans = get_json(addr, "/admin/orphans").await; - assert_eq!( - orphans, - serde_json::json!({ - "orphans": [{ - "path": "models/stray.gguf", - "size_bytes": b"stray-bytes".len(), - "sha256": null, - }] - }), - "no declared model's artifact is an orphan after the apply" - ); - - let worker = state - .commands - .spawn_worker_with(&state, parking_executor()) - .expect("worker spawns"); - let _load = state.commands.enqueue(Command::load_profile( - ProfileName::parse("work").expect("profile name"), - CancellationToken::new(), - )); - tokio::time::timeout(Duration::from_secs(10), async { - while state.commands.active_command().is_none() { - tokio::task::yield_now().await; - } - }) - .await - .expect("the command goes active"); - - let status = get_json(addr, "/admin/status").await; - let chat = status["endpoints"] - .as_array() - .expect("endpoints are an array") - .iter() - .find(|entry| entry["path"] == "/v1/chat/completions") - .expect("the chat endpoint is listed"); - assert_eq!( - chat["provisioning"], true, - "the catalog's local chat model keeps the endpoint configured: {status}" - ); - - state.commands.cancel_active(); - state.commands.shutdown(); - worker.await.expect("the worker exits on shutdown"); - } - - /// An orphan is a cache file no catalog entry references: a model the - /// running profile leaves out is still declared, so its artifact stays. - #[tokio::test] - async fn admin_orphans_keeps_catalog_models_outside_the_running_profile() { - let temp = tempfile::TempDir::new().expect("tempdir"); - let models = temp.path().join("models"); - std::fs::create_dir_all(&models).expect("mkdir models"); - let adopted = models.join("adopted.gguf"); - let shelved = models.join("shelved.gguf"); - std::fs::write(&adopted, b"adopted-model-bytes").expect("write adopted"); - std::fs::write(&shelved, b"shelved-model-bytes").expect("write shelved"); - let toml = profiled_toml(temp.path(), &adopted, &shelved); - - let addr = serve(booted(&toml)).await; - let orphans = get_json(addr, "/admin/orphans").await; - assert_eq!( - orphans, - serde_json::json!({ "orphans": [] }), - "a declared model outside the running profile is not an orphan" - ); - } -} diff --git a/crates/gateway/app/src/registry-tests.rs b/crates/gateway/app/src/registry-tests.rs new file mode 100644 index 000000000..168ce4596 --- /dev/null +++ b/crates/gateway/app/src/registry-tests.rs @@ -0,0 +1,172 @@ +//! The registry against the assembled router: every route the registry +//! declares walled refuses a LAN peer and a peerless caller with the +//! wall's 403 and admits a loopback peer past it; every route it declares +//! open is mounted and answers a LAN peer with something other than 403. +//! Because the wall only runs on a matched route, a 403 from the LAN is +//! also proof the route is mounted, so a registry entry naming a path no +//! module mounts fails here, as does a route mounted in the wrong tier. + +use std::net::SocketAddr; + +use axum::body::Body; +use axum::extract::ConnectInfo; +use axum::http::header::AUTHORIZATION; +use axum::http::{Method, Request, StatusCode}; +use tower::ServiceExt; + +use super::{RouteInfo, Tier, all}; +use crate::test_support::walled_fixture; +use crate::{AppState, build_router}; + +const LOOPBACK: &str = "127.0.0.1:50000"; +const LAN: &str = "198.51.100.7:44821"; + +/// A concrete request path for a registry template: each capture is +/// filled with a value that fails the handler's own validation, so a +/// sweep that reaches the handler is refused there and never does real +/// work (the HF repo `owner/na me` fails the repo check; the cache digest +/// is not hex). +fn concrete_path(template: &str) -> String { + template + .replace("{owner}", "owner") + .replace("{name}", "na%20me") + .replace("{sha256}", "not-a-digest") +} + +/// Sends one empty-bodied request through `build_router` with the valid +/// bearer key and the given peer planted as `ConnectInfo` (or none). +async fn send(state: &AppState, method: &Method, path: &str, peer: Option<&str>) -> StatusCode { + let mut request = Request::builder() + .method(method.clone()) + .uri(path) + .header(AUTHORIZATION, "Bearer test-token") + .body(Body::empty()) + .expect("static request parts are valid"); + if let Some(peer) = peer { + let peer: SocketAddr = peer.parse().expect("a socket address"); + request.extensions_mut().insert(ConnectInfo(peer)); + } + build_router(state.clone(), None) + .oneshot(request) + .await + .expect("the router is infallible") + .status() +} + +fn routes_in(tier: Tier) -> Vec { + all() + .into_iter() + .filter(|route| route.tier == tier) + .collect() +} + +#[test] +fn the_registry_names_every_path_once_and_both_tiers() { + let routes = all(); + let mut paths: Vec<&str> = routes.iter().map(|route| route.path).collect(); + paths.sort_unstable(); + let before = paths.len(); + paths.dedup(); + assert_eq!( + before, + paths.len(), + "a path is declared by exactly one module" + ); + assert!( + routes.iter().any(|route| route.tier == Tier::Open), + "the open tier is non-empty" + ); + assert!( + routes.iter().any(|route| route.tier == Tier::Walled), + "the walled tier is non-empty" + ); + for route in &routes { + assert!( + !route.methods.is_empty(), + "{}: every route declares at least one method", + route.path + ); + } +} + +#[tokio::test] +async fn every_walled_route_refuses_a_lan_peer_with_403() { + let (_temp, state) = walled_fixture(); + for route in routes_in(Tier::Walled) { + let path = concrete_path(route.path); + for method in route.methods { + assert_eq!( + send(&state, method, &path, Some(LAN)).await, + StatusCode::FORBIDDEN, + "{method} {path} is declared walled: it must refuse a LAN peer even with the valid key" + ); + } + } +} + +#[tokio::test] +async fn every_walled_route_fails_closed_without_a_peer_address() { + let (_temp, state) = walled_fixture(); + for route in routes_in(Tier::Walled) { + let path = concrete_path(route.path); + for method in route.methods { + assert_eq!( + send(&state, method, &path, None).await, + StatusCode::FORBIDDEN, + "{method} {path} is declared walled: it must fail closed with no peer address" + ); + } + } +} + +#[tokio::test] +async fn every_walled_route_admits_a_loopback_peer_past_the_wall() { + let (_temp, state) = walled_fixture(); + for route in routes_in(Tier::Walled) { + let path = concrete_path(route.path); + for method in route.methods { + let status = send(&state, method, &path, Some(LOOPBACK)).await; + assert_ne!( + status, + StatusCode::FORBIDDEN, + "{method} {path} must pass the wall for a loopback peer" + ); + assert_ne!( + status, + StatusCode::NOT_FOUND, + "{method} {path} is declared but nothing mounts it" + ); + assert_ne!( + status, + StatusCode::METHOD_NOT_ALLOWED, + "{method} {path} is declared but the mount does not answer that method" + ); + } + } +} + +#[tokio::test] +async fn every_open_route_is_mounted_and_reachable_from_the_lan() { + let (_temp, state) = walled_fixture(); + for route in routes_in(Tier::Open) { + let path = concrete_path(route.path); + for method in route.methods { + let status = send(&state, method, &path, Some(LAN)).await; + assert_ne!( + status, + StatusCode::FORBIDDEN, + "{method} {path} is declared open: a LAN peer with the key must not hit a wall" + ); + assert_ne!( + status, + StatusCode::NOT_FOUND, + "{method} {path} is declared but nothing mounts it" + ); + assert_ne!( + status, + StatusCode::METHOD_NOT_ALLOWED, + "{method} {path} is declared but the mount does not answer that method" + ); + } + } +} diff --git a/crates/gateway/app/src/registry.rs b/crates/gateway/app/src/registry.rs new file mode 100644 index 000000000..b5e0d175f --- /dev/null +++ b/crates/gateway/app/src/registry.rs @@ -0,0 +1,83 @@ +//! The route registry: every mounted route as data, with the tier that +//! mounts it. +//! +//! Each area module declares one [`RouteInfo`] constant per route and +//! binds `INFO.path` in its `routes()`, never a literal, so the registry +//! and the router cannot name different paths. [`all`] collects every +//! area's `ROUTES` under the same feature gates `build_router` merges them +//! under. The registry is the crate's one enumerable route list: the +//! tests sweep it to prove every walled route refuses a LAN peer and every +//! open route does not, and a route declared in the wrong tier fails that +//! sweep rather than a reader's memory. + +use axum::http::Method; + +use crate::admin; +#[cfg(feature = "local")] +use crate::cache; +#[cfg(feature = "web-search")] +use crate::web_search; +use crate::{health, models, relay, speech}; + +/// Which tier mounts a route. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Tier { + /// Bearer-authed, reachable from any peer the listener admits. + Open, + /// Behind the shared loopback wall: a non-loopback peer earns 403 + /// before auth runs. + Walled, +} + +/// One mounted route: its axum path template, the methods it answers, +/// and the tier that mounts it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct RouteInfo { + /// The path template as `Router::route` receives it, captures in + /// braces. + pub(crate) path: &'static str, + /// The methods mounted on the path. + pub(crate) methods: &'static [Method], + /// The tier that mounts the route. + pub(crate) tier: Tier, +} + +impl RouteInfo { + /// A route in the open tier. + pub(crate) const fn open(path: &'static str, methods: &'static [Method]) -> RouteInfo { + RouteInfo { + path, + methods, + tier: Tier::Open, + } + } + + /// A route in the walled tier. + pub(crate) const fn walled(path: &'static str, methods: &'static [Method]) -> RouteInfo { + RouteInfo { + path, + methods, + tier: Tier::Walled, + } + } +} + +/// Every route the assembled router mounts in this build, in mount order. +pub(crate) fn all() -> Vec { + let mut routes = Vec::new(); + routes.extend_from_slice(relay::ROUTES); + routes.extend_from_slice(speech::ROUTES); + routes.extend_from_slice(models::ROUTES); + routes.extend_from_slice(health::ROUTES); + routes.extend(admin::open::registry()); + #[cfg(feature = "web-search")] + routes.extend_from_slice(web_search::ROUTES); + #[cfg(feature = "local")] + routes.extend_from_slice(cache::ROUTES); + routes.extend(admin::walled::registry()); + routes +} + +#[cfg(test)] +#[path = "registry-tests.rs"] +mod tests; diff --git a/crates/gateway/app/src/relaunch.rs b/crates/gateway/app/src/relaunch.rs index 78037eaae..67e0f2e30 100644 --- a/crates/gateway/app/src/relaunch.rs +++ b/crates/gateway/app/src/relaunch.rs @@ -63,7 +63,7 @@ pub(crate) fn decide(resolution: &gateway_api_discovery::Resolution) -> Relaunch gateway_api_discovery::Resolution::Attach(file) => { // The file carries the real port of the loopback bind; URLs // normalize to a literal 127.0.0.1, never localhost. - Relaunch::OpenSettings(crate::handoff::auth_url( + Relaunch::OpenSettings(crate::auth::primitives::auth_url( &format!("http://127.0.0.1:{}", file.port), &file.api_key, )) @@ -73,7 +73,7 @@ pub(crate) fn decide(resolution: &gateway_api_discovery::Resolution) -> Relaunch } fn settings_url(connection: &gateway_api_discovery::ValidatedConnection) -> String { - crate::handoff::auth_url( + crate::auth::primitives::auth_url( &format!("http://127.0.0.1:{}", connection.port()), connection.api_key(), ) diff --git a/crates/gateway/app/src/relay.rs b/crates/gateway/app/src/relay.rs index 7109109bc..b8022bbd9 100644 --- a/crates/gateway/app/src/relay.rs +++ b/crates/gateway/app/src/relay.rs @@ -4,24 +4,41 @@ use std::sync::Arc; -use axum::Json; use axum::body::Body; use axum::extract::State; -use axum::http::HeaderValue; use axum::http::header::{CACHE_CONTROL, CONTENT_TYPE}; +use axum::http::{HeaderValue, Method}; use axum::response::{IntoResponse, Response}; +use axum::routing::post; +use axum::{Json, Router}; +use gateway_config::ModelKind; use crate::AppState; use crate::auth::AuthedCaller; use crate::error::{GatewayError, WireJson}; +use crate::registry::RouteInfo; use crate::wire::{ ChatRequest, EmbeddingRequest, EmbeddingResponse, RerankRequest, RerankResponse, }; -use gateway_config::ModelKind; /// Header naming the caller for fair queue scheduling. Absent → `"default"`. pub(crate) const CLIENT_HEADER: &str = "X-PromptForge-Client"; +const CHAT_COMPLETIONS: RouteInfo = RouteInfo::open("/v1/chat/completions", &[Method::POST]); +const EMBEDDINGS: RouteInfo = RouteInfo::open("/v1/embeddings", &[Method::POST]); +const RERANK: RouteInfo = RouteInfo::open("/v1/rerank", &[Method::POST]); + +/// The OpenAI passthrough routes, as the registry sees them. +pub(crate) const ROUTES: &[RouteInfo] = &[CHAT_COMPLETIONS, EMBEDDINGS, RERANK]; + +/// The OpenAI passthrough routes. +pub(crate) fn routes() -> Router { + Router::new() + .route(CHAT_COMPLETIONS.path, post(chat_completions)) + .route(EMBEDDINGS.path, post(embeddings)) + .route(RERANK.path, post(rerank)) +} + /// Resolves a request's model name against the live routing table. /// /// A local model the boot load is spawning (one in [`LiveState::loading`]) @@ -137,7 +154,7 @@ pub(crate) async fn chat_completions( Ok(Json(response).into_response()) } -/// Re-emit a validated upstream chunk stream as an SSE response, holding the +/// Re-emits a validated upstream chunk stream as an SSE response, holding the /// dominion queue permit for the stream's lifetime. /// /// The relay is typed: each upstream chunk is validated and re-serialized per diff --git a/crates/gateway/app/src/render.rs b/crates/gateway/app/src/render.rs deleted file mode 100644 index 9ce24dd34..000000000 --- a/crates/gateway/app/src/render.rs +++ /dev/null @@ -1,282 +0,0 @@ -//! The process progress renderer: emits the hub as tracing log lines. -//! -//! The renderer is a plain thread polling [`ProgressHub::snapshot`], so it -//! also covers startup provisioning, which runs before the tokio runtime -//! exists. Producers never format output; this thread is the gateway -//! process's one presentation of the hub. Visual progress lives in the -//! config UI status bar and the tray label; the terminal carries logs only. - -use std::collections::{HashMap, HashSet}; -use std::sync::Arc; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::time::Duration; - -use shared_progress::{OperationId, OperationSnapshot, ProgressHub}; - -/// Snapshot poll cadence of the renderer thread. -const RENDER_INTERVAL: Duration = Duration::from_millis(120); - -/// Log cadence: a line per 5% step per node. -const LOG_STEP_PERCENT: u64 = 5; - -/// A running renderer thread. Dropping it signals the thread and joins it, -/// so every exit path of the serving lifecycle stops rendering. -#[derive(Debug)] -pub(crate) struct Renderer { - stop: Arc, - thread: Option>, -} - -impl Renderer { - /// Starts the renderer thread for `hub`, emitting tracing lines on every - /// stream. A spawn failure degrades to no rendering (logged), never to - /// a boot failure. - pub(crate) fn start(hub: &Arc) -> Renderer { - let stop = Arc::new(AtomicBool::new(false)); - let thread = std::thread::Builder::new() - .name("progress-renderer".to_string()) - .spawn({ - let hub = Arc::clone(hub); - let stop = Arc::clone(&stop); - move || log_loop(&hub, &stop) - }); - match thread { - Ok(thread) => Renderer { - stop, - thread: Some(thread), - }, - Err(error) => { - tracing::error!( - "failed to spawn the progress renderer thread: {error}; progress will not render" - ); - Renderer { stop, thread: None } - } - } - } -} - -impl Drop for Renderer { - fn drop(&mut self) { - self.stop.store(true, Ordering::Relaxed); - if let Some(thread) = self.thread.take() - && thread.join().is_err() - { - tracing::error!("the progress renderer thread panicked"); - } - } -} - -/// The renderer thread's body: each tick's new lines go to `tracing::info!`. -fn log_loop(hub: &ProgressHub, stop: &AtomicBool) { - let mut renderer = LineRenderer::default(); - while !stop.load(Ordering::Relaxed) { - for line in renderer.lines(&hub.snapshot()) { - tracing::info!("{line}"); - } - std::thread::sleep(RENDER_INTERVAL); - } -} - -/// Log rendering as a pure snapshot-to-lines transform: a `started` line -/// on first sight of a node, a percent line per [`LOG_STEP_PERCENT`] step, -/// and a `done` line at completion - a node first seen already complete -/// earns its `started` and `done` lines together. State for a node that -/// leaves the snapshot is dropped, so a later operation reusing the path -/// reports afresh and the map stays bounded by the live operations. -#[derive(Debug, Default)] -pub(crate) struct LineRenderer { - emitted: HashMap<(OperationId, String), u64>, -} - -impl LineRenderer { - /// The lines for one snapshot tick. - #[expect( - clippy::cast_possible_truncation, - clippy::cast_sign_loss, - reason = "snapshot fractions are clamped to 0.0..=1.0" - )] - pub(crate) fn lines(&mut self, snapshot: &[OperationSnapshot]) -> Vec { - let mut lines = Vec::new(); - let mut seen = HashSet::with_capacity(self.emitted.len()); - for operation in snapshot { - for node in &operation.nodes { - let key = (operation.operation, node.path.clone()); - seen.insert(key.clone()); - let percent = (node.fraction * 100.0).round() as u64; - match self.emitted.get(&key) { - None => { - lines.push(format!("{}: started", node.path)); - // A sub-poll-interval operation is first seen - // complete; it still earns its `done` line. - if percent == 100 { - lines.push(format!("{}: done", node.path)); - } - self.emitted.insert(key, percent); - } - Some(&previous) => { - let line = if percent == 100 && previous < 100 { - Some(format!("{}: done", node.path)) - } else if percent >= previous + LOG_STEP_PERCENT { - Some(format!("{}: {}%", node.path, percent)) - } else { - None - }; - if let Some(line) = line { - lines.push(line); - self.emitted.insert(key, percent); - } - } - } - } - } - self.emitted.retain(|key, _| seen.contains(key)); - lines - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn snapshot_to_lines_reports_started_steps_and_done() { - let hub = Arc::new(ProgressHub::new()); - let tree = hub.operation(); - let leaf = tree.register("model.bin", 1.0); - let mut renderer = LineRenderer::default(); - - assert_eq!(renderer.lines(&hub.snapshot()), ["model.bin: started"]); - leaf.set_fraction(0.04); - assert!( - renderer.lines(&hub.snapshot()).is_empty(), - "a move below the 5% step stays quiet" - ); - leaf.set_fraction(0.07); - assert_eq!(renderer.lines(&hub.snapshot()), ["model.bin: 7%"]); - leaf.complete(); - assert_eq!(renderer.lines(&hub.snapshot()), ["model.bin: done"]); - assert!( - renderer.lines(&hub.snapshot()).is_empty(), - "a finished node does not report twice" - ); - } - - #[test] - fn a_node_first_seen_complete_reports_started_and_done() { - let hub = Arc::new(ProgressHub::new()); - let tree = hub.operation(); - let leaf = tree.register("quick.bin", 1.0); - leaf.complete(); - let mut renderer = LineRenderer::default(); - - assert_eq!( - renderer.lines(&hub.snapshot()), - ["quick.bin: started", "quick.bin: done"], - "a sub-poll-interval operation still earns its done line" - ); - assert!( - renderer.lines(&hub.snapshot()).is_empty(), - "a finished node does not report twice" - ); - } - - #[test] - fn a_detached_operation_drops_its_state_and_reports_afresh() { - let hub = Arc::new(ProgressHub::new()); - let mut renderer = LineRenderer::default(); - { - let tree = hub.operation(); - let _leaf = tree.register("model.bin", 1.0); - assert_eq!(renderer.lines(&hub.snapshot()), ["model.bin: started"]); - } - assert!( - renderer.lines(&hub.snapshot()).is_empty(), - "an idle hub emits no lines" - ); - assert!( - renderer.emitted.is_empty(), - "a detached operation drops its cadence state" - ); - let tree = hub.operation(); - let _leaf = tree.register("model.bin", 1.0); - assert_eq!( - renderer.lines(&hub.snapshot()), - ["model.bin: started"], - "a re-attached operation reports from the beginning" - ); - } - - /// A shared buffer that captures what the renderer loop logs, so the - /// test can assert on the emitted lines. - #[derive(Clone, Default)] - struct LogBuffer(std::sync::Arc>>); - - impl LogBuffer { - fn contents(&self) -> String { - String::from_utf8_lossy(&self.0.lock().expect("log buffer")).into_owned() - } - } - - impl std::io::Write for LogBuffer { - fn write(&mut self, buf: &[u8]) -> std::io::Result { - self.0.lock().expect("log buffer").extend_from_slice(buf); - Ok(buf.len()) - } - - fn flush(&mut self) -> std::io::Result<()> { - Ok(()) - } - } - - impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for LogBuffer { - type Writer = LogBuffer; - - fn make_writer(&'a self) -> Self::Writer { - self.clone() - } - } - - #[test] - fn the_render_loop_emits_tracing_lines_for_a_hub_operation() { - let buffer = LogBuffer::default(); - let subscriber = tracing_subscriber::fmt() - .with_writer(buffer.clone()) - .with_ansi(false) - .with_max_level(tracing::Level::INFO) - .finish(); - let _guard = tracing::subscriber::set_default(subscriber); - - let hub = Arc::new(ProgressHub::new()); - let tree = hub.operation(); - let leaf = tree.register("model.bin", 1.0); - leaf.complete(); - let stop = AtomicBool::new(false); - // The loop runs on this thread so the thread-local subscriber - // captures its lines; the scoped helper stops it as soon as the - // done line lands. The deadline only bounds a broken loop - the - // verdict never depends on timing. - let stopper = buffer.clone(); - std::thread::scope(|scope| { - scope.spawn(|| { - let deadline = std::time::Instant::now() + Duration::from_secs(10); - while !stopper.contents().contains("model.bin: done") - && std::time::Instant::now() < deadline - { - std::thread::yield_now(); - } - stop.store(true, Ordering::Relaxed); - }); - log_loop(&hub, &stop); - }); - - let logs = buffer.contents(); - assert!( - logs.contains("model.bin: started"), - "the loop logs the started line, got: {logs}" - ); - assert!( - logs.contains("model.bin: done"), - "the loop logs the done line, got: {logs}" - ); - } -} diff --git a/crates/gateway/app/src/reveal.rs b/crates/gateway/app/src/reveal.rs deleted file mode 100644 index ef011155c..000000000 --- a/crates/gateway/app/src/reveal.rs +++ /dev/null @@ -1,568 +0,0 @@ -//! The `POST /admin/reveal` route: opens the host OS file manager at a -//! cache or profile path, for the UI's "reveal in folder" button on model -//! files and config files. -//! -//! The endpoint launches a process, so it is guarded three ways. The -//! caller must be on the loopback interface: `build_router` places the -//! route behind the shared loopback wall from -//! `shared-loopback`, which refuses any non-loopback or -//! unknown peer with a bare 403 before this handler ever runs (and -//! before auth). The caller must present the bearer key (401). And the named -//! path must canonicalize to strictly inside the artifact cache (the root is -//! refused, so the non-Windows parent-directory reveal can never name a -//! directory outside every root; 400 otherwise, 404 when the path does -//! not exist). The path never crosses a shell: the file manager is -//! spawned directly with separate arguments, through an injectable -//! [`RevealLauncher`] so tests assert the exact constructed command -//! without spawning anything. - -use std::ffi::OsString; -use std::fs; -use std::path::{Path, PathBuf}; -use std::sync::Arc; - -use axum::Json; -use axum::extract::State; -use axum::extract::rejection::JsonRejection; -use axum::http::StatusCode; -use serde::Deserialize; - -use crate::AppState; -use crate::auth::AuthedCaller; -use crate::error::GatewayError; - -/// The `POST /admin/reveal` body: the filesystem path to reveal. -#[derive(Debug, Deserialize)] -pub(crate) struct RevealRequest { - /// Path of the file or directory to reveal. Must exist and must - /// canonicalize to strictly inside the artifact cache or the profiles - /// directory; the roots themselves are refused. - pub(crate) path: String, -} - -/// The command a reveal resolves to: the file-manager program and its -/// arguments, each a separate `OsString` so no shell ever interprets the -/// path. -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct RevealCommand { - /// The program to spawn (the absolute `explorer.exe`, or `open`, or - /// `xdg-open`). - pub(crate) program: OsString, - /// The program's arguments, passed separately, never joined. - pub(crate) args: Vec, -} - -/// Launches a [`RevealCommand`]; injectable so tests observe the -/// constructed command without spawning a process. -pub(crate) trait RevealLauncher: Send + Sync + std::fmt::Debug { - /// Launches `command` without waiting for it to exit. - /// - /// # Errors - /// Returns the spawn failure when the program cannot start. - fn launch(&self, command: RevealCommand) -> std::io::Result<()>; -} - -/// The production launcher: spawns the command and does not wait. -#[derive(Debug, Clone, Copy)] -pub(crate) struct SpawnLauncher; - -impl RevealLauncher for SpawnLauncher { - fn launch(&self, command: RevealCommand) -> std::io::Result<()> { - // Fire and forget: the file manager outlives the request and its - // exit status means nothing to the caller, so the child handle is - // dropped as soon as the spawn succeeds. - std::process::Command::new(&command.program) - .args(&command.args) - .spawn() - .map(drop) - } -} - -/// The `POST /admin/reveal` route: loopback-only and bearer-authed, opens -/// the OS file manager at the request's path and replies 204 without -/// waiting for the spawned process. The loopback wall is not this -/// handler's: `build_router` layers the shared `require_loopback` -/// middleware over the route, so a non-loopback or unknown peer is -/// refused with a bare 403 before auth and before this body runs. -/// -/// # Errors -/// Returns [`GatewayError::Unauthorized`] on a -/// missing or wrong bearer key, [`GatewayError::MalformedRequest`] when -/// the body does not parse or the path resolves outside every safe root -/// (or is a root itself), -/// [`GatewayError::RevealPathNotFound`] when the path does not exist, and -/// [`GatewayError::RevealFailed`] when the file manager cannot spawn. -pub(crate) async fn admin_reveal( - State(state): State, - _caller: AuthedCaller, - body: Result, JsonRejection>, -) -> Result { - // Deferring the extractor keeps the guards first and puts the rejection - // in the gateway's JSON error envelope instead of axum's plain-text 400. - let Json(request) = - body.map_err(|rejection| GatewayError::MalformedRequest(rejection.body_text()))?; - - #[cfg(feature = "local")] - let roots = { - let mut roots = Vec::new(); - let config = state.config().await; - // An unresolvable cache root (no cache_dir configured and no home - // directory) contributes no safe root. - if let Ok(root) = crate::local::resolve_cache_root(config.local().cache_dir()) { - roots.push(root); - } - roots - }; - #[cfg(not(feature = "local"))] - let roots: Vec = Vec::new(); - // Canonicalization and the spawn are blocking filesystem work. - let launcher = Arc::clone(&state.reveal); - tokio::task::spawn_blocking(move || { - let command = resolve_reveal(&roots, Path::new(&request.path))?; - launcher - .launch(command) - .map_err(|error| GatewayError::RevealFailed(Box::new(error))) - }) - .await - .map_err(|join| GatewayError::RevealFailed(Box::new(join)))??; - Ok(StatusCode::NO_CONTENT) -} - -/// Confines `path` to strictly inside the safe roots and builds the -/// platform's reveal command for it. -/// -/// Both sides of the containment check are canonicalized, so `..` -/// segments, relative forms, and symlinks are resolved before comparison. -/// -/// # Errors -/// Returns [`GatewayError::RevealPathNotFound`] when `path` does not -/// exist, [`GatewayError::RevealFailed`] when canonicalization fails for -/// another reason, and [`GatewayError::MalformedRequest`] when the -/// canonical path lies outside every root or is a root itself. -fn resolve_reveal(roots: &[PathBuf], path: &Path) -> Result { - let canonical = fs::canonicalize(path).map_err(|error| { - if error.kind() == std::io::ErrorKind::NotFound { - GatewayError::RevealPathNotFound(path.display().to_string()) - } else { - GatewayError::RevealFailed(Box::new(error)) - } - })?; - // Each root canonicalizes independently so the prefix comparison - // happens in one namespace; a root that cannot canonicalize (a cache - // dir never created, say) confines nothing rather than failing a - // reveal aimed at another root. Containment is strict: a root itself - // is refused, so the non-Windows parent-directory reveal can never - // hand the launcher a directory outside every root. - let confined = roots - .iter() - .filter_map(|root| fs::canonicalize(root).ok()) - .any(|root| canonical.starts_with(&root) && canonical != root); - if !confined { - return Err(GatewayError::MalformedRequest(format!( - "path `{}` is not inside the artifact cache", - path.display() - ))); - } - Ok(reveal_command(&canonical)) -} - -/// The Windows reveal: `explorer.exe /select,` highlights the target -/// in its parent folder. The two tokens stay separate arguments; explorer -/// accepts the split form, and nothing is ever joined through a shell. -/// The program is the absolute `%WINDIR%\explorer.exe`, because -/// `CreateProcess` resolves an unqualified name through the current -/// directory before the system directories, and a planted `explorer.exe` -/// must not win that search. -#[cfg(windows)] -fn reveal_command(target: &Path) -> RevealCommand { - let windir = std::env::var_os("WINDIR").unwrap_or_else(|| OsString::from(r"C:\Windows")); - RevealCommand { - program: Path::new(&windir).join("explorer.exe").into_os_string(), - args: vec![OsString::from("/select,"), strip_verbatim(target)], - } -} - -/// The non-Windows reveal: neither `open` nor `xdg-open` can select a -/// file, so the closest equivalent is opening the target's parent -/// directory. Confinement is strict (a root itself is never revealed), -/// so the parent stays inside a safe root; the fallback to the target -/// itself only keeps the function total for a parentless path. -#[cfg(not(windows))] -fn reveal_command(target: &Path) -> RevealCommand { - let directory = target.parent().unwrap_or(target); - let program = if cfg!(target_os = "macos") { - "open" - } else { - "xdg-open" - }; - RevealCommand { - program: OsString::from(program), - args: vec![directory.as_os_str().to_owned()], - } -} - -/// Rewrites a verbatim path to its plain form for explorer.exe. -/// -/// `fs::canonicalize` returns verbatim (`\\?\`) paths on Windows and -/// explorer.exe does not accept that prefix -/// (), so the command -/// carries `C:\...` or `\\server\share\...` instead. -#[cfg(windows)] -fn strip_verbatim(path: &Path) -> OsString { - let text = path.to_string_lossy(); - if let Some(unc) = text.strip_prefix(r"\\?\UNC\") { - return OsString::from(format!(r"\\{unc}")); - } - if let Some(disk) = text.strip_prefix(r"\\?\") { - return OsString::from(disk.to_owned()); - } - path.as_os_str().to_owned() -} - -#[cfg(test)] -mod tests { - use std::net::SocketAddr; - use std::path::Path; - use std::sync::{Arc, Mutex}; - - use axum::body::Body; - use axum::extract::ConnectInfo; - use axum::http::header::{AUTHORIZATION, CONTENT_TYPE}; - use axum::http::{Request, StatusCode}; - use gateway_config::Config; - use tower::ServiceExt; - - use super::{RevealCommand, RevealLauncher}; - use crate::test_support::{AdminPaths, app_state, serve_state}; - - /// A launcher that records every command instead of spawning. - #[derive(Debug, Default)] - struct RecordingLauncher { - commands: Mutex>, - } - - impl RecordingLauncher { - fn commands(&self) -> Vec { - self.commands - .lock() - .expect("the recording mutex is never poisoned") - .clone() - } - } - - impl RevealLauncher for RecordingLauncher { - fn launch(&self, command: RevealCommand) -> std::io::Result<()> { - self.commands - .lock() - .expect("the recording mutex is never poisoned") - .push(command); - Ok(()) - } - } - - /// A tempdir with a cache root holding `models/tiny.gguf`, a profiles - /// directory holding `main.toml`, and an `outside.txt` in neither. - fn fixture() -> (tempfile::TempDir, Config, AdminPaths) { - let temp = tempfile::TempDir::new().expect("tempdir"); - let models = temp.path().join("cache").join("models"); - std::fs::create_dir_all(&models).expect("mkdir cache models"); - std::fs::write(models.join("tiny.gguf"), b"stub").expect("write model"); - let profiles = temp.path().join("profiles"); - std::fs::create_dir(&profiles).expect("mkdir profiles"); - std::fs::write(profiles.join("main.toml"), "").expect("write profile"); - std::fs::write(temp.path().join("outside.txt"), "outside").expect("write outsider"); - let boot = temp.path().join("gateway.toml"); - std::fs::write(&boot, "").expect("write boot"); - let config = Config::from_toml_str(&format!( - r#" -config-version = 0 - -[server] -bind = "127.0.0.1:0" -api_key = "test-token" -# Strict bearer auth: the tests below pin that a missing key is refused. -trust_loopback = false - -[local] -cache_dir = '{cache}' -"#, - cache = temp.path().join("cache").display(), - )) - .expect("the fixture profile parses"); - let paths = AdminPaths { - fixture_dir: profiles, - active: "main".to_owned(), - config_path: boot, - }; - (temp, config, paths) - } - - /// Serves the fixture with a recording launcher injected. - async fn serve_reveal( - config: Config, - paths: AdminPaths, - ) -> (SocketAddr, Arc) { - let launcher = Arc::new(RecordingLauncher::default()); - let mut state = app_state(config, Some(paths)); - state.reveal = Arc::clone(&launcher) as Arc; - (serve_state(state).await, launcher) - } - - /// POSTs `/admin/reveal` for `path`, with `token` as the bearer when - /// given. - async fn post_reveal(addr: SocketAddr, token: Option<&str>, path: &str) -> reqwest::Response { - let mut request = reqwest::Client::new() - .post(format!("http://{addr}/admin/reveal")) - .json(&serde_json::json!({ "path": path })); - if let Some(token) = token { - request = request.bearer_auth(token); - } - request.send().await.expect("the request sends") - } - - /// The command the platform must construct for `target`, computed - /// independently of the module's own helpers. - fn expected_command(target: &Path) -> RevealCommand { - let canonical = std::fs::canonicalize(target).expect("the target canonicalizes"); - #[cfg(windows)] - { - let plain = canonical - .to_string_lossy() - .strip_prefix(r"\\?\") - .expect("canonicalize returns a verbatim path on Windows") - .to_owned(); - let windir = std::env::var_os("WINDIR").expect("Windows sets WINDIR"); - RevealCommand { - program: Path::new(&windir).join("explorer.exe").into_os_string(), - args: vec!["/select,".into(), plain.into()], - } - } - #[cfg(not(windows))] - { - let parent = canonical - .parent() - .expect("a fixture file has a parent") - .as_os_str() - .to_owned(); - let program = if cfg!(target_os = "macos") { - "open" - } else { - "xdg-open" - }; - RevealCommand { - program: program.into(), - args: vec![parent], - } - } - } - - #[cfg(feature = "local")] - #[tokio::test] - async fn reveal_selects_a_cache_file_in_the_file_manager() { - let (temp, config, paths) = fixture(); - let target = temp.path().join("cache").join("models").join("tiny.gguf"); - let (addr, launcher) = serve_reveal(config, paths).await; - - let response = post_reveal(addr, Some("test-token"), &target.display().to_string()).await; - assert_eq!(response.status(), reqwest::StatusCode::NO_CONTENT); - assert_eq!( - launcher.commands(), - vec![expected_command(&target)], - "the launcher receives exactly the platform's reveal command \ - for the canonical path" - ); - } - - #[tokio::test] - async fn reveal_rejects_a_path_outside_the_safe_roots() { - let (temp, config, paths) = fixture(); - // Both exist on disk; neither is under the cache or profiles root - // (the boot config's own directory is deliberately not a safe root). - let outsiders = [ - temp.path().join("outside.txt"), - temp.path().join("gateway.toml"), - ]; - let (addr, launcher) = serve_reveal(config, paths).await; - - for outsider in outsiders { - let response = - post_reveal(addr, Some("test-token"), &outsider.display().to_string()).await; - assert_eq!( - response.status(), - reqwest::StatusCode::BAD_REQUEST, - "`{}` must be refused at the boundary", - outsider.display() - ); - let body: serde_json::Value = response.json().await.expect("a JSON error envelope"); - assert_eq!(body["error"]["code"], "malformed_request"); - } - assert!( - launcher.commands().is_empty(), - "the launcher must never run for a path outside the safe roots" - ); - } - - #[tokio::test] - async fn reveal_refuses_a_traversal_that_resolves_outside() { - let (temp, config, paths) = fixture(); - // A raw component-prefix check would admit this path (it begins - // with the cache root's components); only canonicalizing before - // the comparison resolves the `..` segments to `outside.txt` and - // refuses it. - let traversal = temp - .path() - .join("cache") - .join("models") - .join("..") - .join("..") - .join("outside.txt"); - let (addr, launcher) = serve_reveal(config, paths).await; - - let response = - post_reveal(addr, Some("test-token"), &traversal.display().to_string()).await; - assert_eq!(response.status(), reqwest::StatusCode::BAD_REQUEST); - let body: serde_json::Value = response.json().await.expect("a JSON error envelope"); - assert_eq!(body["error"]["code"], "malformed_request"); - assert!( - launcher.commands().is_empty(), - "the launcher must never run for a traversal that resolves outside" - ); - } - - #[tokio::test] - async fn reveal_refuses_the_safe_root_itself() { - let (_temp, config, paths) = fixture(); - let root = paths.fixture_dir.clone(); - let (addr, launcher) = serve_reveal(config, paths).await; - - // Containment is strict: on non-Windows the reveal opens the - // target's parent, so revealing a root would hand the launcher a - // directory outside every root. - let response = post_reveal(addr, Some("test-token"), &root.display().to_string()).await; - assert_eq!(response.status(), reqwest::StatusCode::BAD_REQUEST); - let body: serde_json::Value = response.json().await.expect("a JSON error envelope"); - assert_eq!(body["error"]["code"], "malformed_request"); - assert!( - launcher.commands().is_empty(), - "the launcher must never run for a safe root itself" - ); - } - - #[tokio::test] - async fn a_missing_peer_address_fails_closed_as_non_loopback() { - let (_temp, config, paths) = fixture(); - let target = paths.fixture_dir.join("main.toml"); - let launcher = Arc::new(RecordingLauncher::default()); - let mut state = app_state(config, Some(paths)); - state.reveal = Arc::clone(&launcher) as Arc; - - // Served WITHOUT connect info, as a misassembled embedding host - // would: the peer-address extension is absent, and the shared wall - // must fail closed with its bare 403 rather than admit the caller. - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .expect("the test listener binds"); - let addr = listener.local_addr().expect("the bound address"); - tokio::spawn(async move { - let _ignored = axum::serve(listener, crate::build_router(state, None)).await; - }); - - let response = post_reveal(addr, Some("test-token"), &target.display().to_string()).await; - assert_eq!(response.status(), reqwest::StatusCode::FORBIDDEN); - assert!( - launcher.commands().is_empty(), - "the launcher must never run when the peer address is unknown" - ); - } - - #[tokio::test] - async fn reveal_rejects_a_missing_path() { - let (_temp, config, paths) = fixture(); - let ghost = paths.fixture_dir.join("ghost.toml"); - let (addr, launcher) = serve_reveal(config, paths).await; - - let response = post_reveal(addr, Some("test-token"), &ghost.display().to_string()).await; - assert_eq!(response.status(), reqwest::StatusCode::NOT_FOUND); - let body: serde_json::Value = response.json().await.expect("a JSON error envelope"); - assert_eq!(body["error"]["code"], "reveal_path_not_found"); - assert!( - launcher.commands().is_empty(), - "the launcher must never run for a missing path" - ); - } - - #[tokio::test] - async fn reveal_requires_bearer_auth() { - let (_temp, config, paths) = fixture(); - let target = paths.fixture_dir.join("main.toml"); - let (addr, launcher) = serve_reveal(config, paths).await; - - for token in [None, Some("wrong-token")] { - let response = post_reveal(addr, token, &target.display().to_string()).await; - assert_eq!( - response.status(), - reqwest::StatusCode::UNAUTHORIZED, - "a request with bearer {token:?} is refused" - ); - } - assert!( - launcher.commands().is_empty(), - "the launcher must never run for an unauthenticated caller" - ); - } - - #[tokio::test] - async fn reveal_refuses_a_non_loopback_caller() { - let (_temp, config, paths) = fixture(); - let target = paths.fixture_dir.join("main.toml"); - let launcher = Arc::new(RecordingLauncher::default()); - let mut state = app_state(config, Some(paths)); - state.reveal = Arc::clone(&launcher) as Arc; - - // A LAN peer presenting the valid bearer key: the shared loopback - // wall layered in `build_router` must refuse before auth even - // matters. A real TCP connection to the test listener is always - // loopback, so the router is driven in-process with a forged peer - // address planted in the ConnectInfo extension. - let body = serde_json::json!({ "path": target.display().to_string() }).to_string(); - let mut request = Request::builder() - .method("POST") - .uri("/admin/reveal") - .header(AUTHORIZATION, "Bearer test-token") - .header(CONTENT_TYPE, "application/json") - .body(Body::from(body)) - .expect("static request parts are valid"); - let peer: SocketAddr = "198.51.100.7:44821".parse().expect("a socket address"); - request.extensions_mut().insert(ConnectInfo(peer)); - - let response = crate::build_router(state, None) - .oneshot(request) - .await - .expect("the router is infallible"); - - assert_eq!(response.status(), StatusCode::FORBIDDEN); - assert!( - launcher.commands().is_empty(), - "the launcher must never run for a non-loopback caller" - ); - } - - #[cfg(windows)] - #[test] - fn strip_verbatim_unwraps_disk_and_unc_prefixes() { - use std::ffi::OsString; - - assert_eq!( - super::strip_verbatim(Path::new(r"\\?\C:\cache\m.gguf")), - OsString::from(r"C:\cache\m.gguf") - ); - assert_eq!( - super::strip_verbatim(Path::new(r"\\?\UNC\host\share\m.gguf")), - OsString::from(r"\\host\share\m.gguf") - ); - assert_eq!( - super::strip_verbatim(Path::new(r"C:\plain")), - OsString::from(r"C:\plain") - ); - } -} diff --git a/crates/gateway/app/src/routing.rs b/crates/gateway/app/src/routing.rs index 7df93ace9..79a5393f3 100644 --- a/crates/gateway/app/src/routing.rs +++ b/crates/gateway/app/src/routing.rs @@ -47,7 +47,7 @@ impl Routing { } } - /// Build a routing table directly from resolved models. Intended for tests + /// Builds a routing table directly from resolved models. Intended for tests /// and for [`Routing::from_config`]. Order of `models` is the catalog order. /// /// # Errors @@ -76,7 +76,7 @@ impl Routing { &self.models } - /// Build a routing table from a validated [`Config`], constructing one + /// Builds a routing table from a validated [`Config`], constructing one /// upstream per endpoint and one shared [`DominionQueue`] per dominion. /// /// Every endpoint bound to a dominion clones that dominion's queue, so @@ -174,7 +174,7 @@ impl Routing { Ok(self) } - /// Resolve a model name to its routing entry. + /// Resolves a model name to its routing entry. /// /// # Errors /// Returns [`GatewayError::UnknownModel`] when no `[[model]]` matches. @@ -186,7 +186,7 @@ impl Routing { } } -/// Guard that a resolved model serves the workload its route handles, so a +/// Guards that a resolved model serves the workload its route handles, so a /// request never reaches a backend wired for a different kind of work. /// /// # Errors diff --git a/crates/gateway/app/src/runner.rs b/crates/gateway/app/src/runner.rs index 87d178aa8..d3fc64869 100644 --- a/crates/gateway/app/src/runner.rs +++ b/crates/gateway/app/src/runner.rs @@ -137,7 +137,7 @@ pub struct Gateway { } impl Gateway { - /// Assemble the serving shell instantly: the routing table over every + /// Assembles the serving shell instantly: the routing table over every /// `[[model]]`, no local runtime, no provisioning. The selected /// profile's local models arrive when the command queue's boot /// `LoadProfile` merges them into the live table; until then an @@ -182,16 +182,16 @@ impl Gateway { Self::new_with_hub( config, profiles, - Arc::new(shared_progress::ProgressHub::new()), + Arc::new(gateway_progress::ProgressHub::new()), ) } /// [`new`](Self::new) over a caller-provided progress hub, so the - /// serving lifecycle's renderer watches the boot command's progress. + /// serving lifecycle's status consumers see the boot command's progress. pub(crate) fn new_with_hub( config: &Config, profiles: ProfilesContext, - hub: Arc, + hub: Arc, ) -> Result { let routing = Routing::from_config(config).map_err(StartupError::config)?; let active = config @@ -239,7 +239,7 @@ impl Gateway { true } - /// Assemble from a validated config. Provisions and starts local models. + /// Assembles from a validated config. Provisions and starts local models. /// /// The boot selection is fixed for the process lifetime; a later switch /// persists a new selection and reports that a restart is needed. @@ -283,31 +283,26 @@ impl Gateway { Self::from_config_with_hub( config, profiles, - Arc::new(shared_progress::ProgressHub::new()), + Arc::new(gateway_progress::ProgressHub::new()), ) } /// [`from_config`](Self::from_config) over a caller-provided progress - /// hub, so the serving lifecycle's renderer thread can watch startup + /// hub, so the serving lifecycle's status consumers can watch startup /// provisioning. pub(crate) fn from_config_with_hub( config: &Config, profiles: ProfilesContext, - hub: Arc, + hub: Arc, ) -> Result { - // Startup provisioning is the hub's first operation tree: it lives - // for the provisioning call and detaches when the tree drops. + // Startup provisioning is the hub's first activity: it lives for the + // provisioning call and ends when the guard drops. #[cfg(feature = "local")] let local = { - let tree = hub.operation(); - let progress = tree.register("startup", 1.0); + let activity = hub.begin("Starting local models"); let started = - LocalRuntime::start(config, Some(&progress)).map_err(StartupError::provisioning); - match &started { - Ok(_) => progress.complete(), - Err(_) => progress.fail(), - } - drop(tree); + LocalRuntime::start(config, Some(&activity)).map_err(StartupError::provisioning); + drop(activity); started? }; // A headless build cannot honor a config declaring local models; @@ -320,22 +315,17 @@ impl Gateway { } #[cfg(feature = "stt")] let speech = { - let tree = hub.operation(); - let progress = tree.register("startup-stt", 1.0); + let activity = Arc::new(hub.begin("Loading speech")); let service = gateway_stt::SpeechService::new(); let started = service .load_initial( config, - Some(&progress), + Some(&activity), &tokio_util::sync::CancellationToken::new(), ) .map(|()| service) .map_err(StartupError::provisioning); - match &started { - Ok(_) => progress.complete(), - Err(_) => progress.fail(), - } - drop(tree); + drop(activity); started? }; #[cfg(not(feature = "stt"))] @@ -420,7 +410,7 @@ impl Gateway { self.state.live.read().await.local.diagnostics() } - /// Serve on a caller-owned listener until `shutdown` completes or + /// Serves on a caller-owned listener until `shutdown` completes or /// `POST /shutdown` fires the route's own signal, whichever comes /// first; both drive the same graceful drain. /// @@ -825,12 +815,12 @@ struct Ready { /// /// let options = ServeOptions::new( /// Some(PathBuf::from("/etc/promptforge/gateway.toml")), -/// ProfileName::parse("dev").unwrap(), +/// ProfileName::parse("dev")?, /// ); /// let gateway = spawn(&options)?; /// println!("serving on {}", gateway.url()); /// gateway.shutdown()?; -/// # Ok::<(), gateway::StartupError>(()) +/// # Ok::<(), Box>(()) /// ``` pub fn spawn(options: &ServeOptions) -> Result { let browser = options.browser; @@ -865,7 +855,7 @@ pub fn spawn(options: &ServeOptions) -> Result { /// `/auth` redirect so the key never sits in browser history. A browser /// that cannot launch warns; the gateway serves on. fn open_settings_page(handle: &GatewayHandle) { - let url = crate::handoff::auth_url(handle.url(), handle.api_key.expose()); + let url = crate::auth::primitives::auth_url(handle.url(), handle.api_key.expose()); if let Err(error) = open::that(&url) { tracing::warn!("could not open the browser: {error}; the Settings URL is {url}"); } @@ -933,10 +923,8 @@ fn serve_thread( } }; let bind = config.bind_addr(); - let hub = Arc::new(shared_progress::ProgressHub::new()); - // The renderer starts before serving so the boot command's downloads - // log; it is a plain thread, and its Drop stops it on every exit path. - let _renderer = crate::render::Renderer::start(&hub); + // Producers own their own log lines; the hub feeds the UIs only. + let hub = Arc::new(gateway_progress::ProgressHub::new()); let runtime = match tokio::runtime::Builder::new_multi_thread() .enable_all() .build() @@ -1025,10 +1013,10 @@ fn serve_thread( /// The sheet download URL: the `PROMPTFORGE_MODELS_SHEET_URL` override /// when set and non-empty, else the published release artifact. fn cloud_models_sheet_url() -> String { - std::env::var(crate::cloud_models::SHEET_URL_ENV) + std::env::var(crate::boot::SHEET_URL_ENV) .ok() .filter(|value| !value.is_empty()) - .unwrap_or_else(|| crate::cloud_models::DEFAULT_SHEET_URL.to_owned()) + .unwrap_or_else(|| crate::boot::DEFAULT_SHEET_URL.to_owned()) } /// The sheet cache path in the profile directory: the run directory's @@ -1039,7 +1027,7 @@ fn cloud_models_cache_path(options: &ServeOptions) -> Option { .clone() .or_else(gateway_api_discovery::default_run_dir) .and_then(|run_dir| run_dir.parent().map(Path::to_path_buf)) - .map(|state_dir| state_dir.join(crate::cloud_models::CACHE_FILE_NAME)) + .map(|state_dir| state_dir.join(crate::boot::CACHE_FILE_NAME)) } /// Removes the gateway discovery file on drop when it still belongs to this @@ -1109,7 +1097,7 @@ async fn shutdown_on_send(shutdown: tokio::sync::oneshot::Receiver<()>) { } } -/// Load config, provision local children, bind, and serve until Ctrl-C. +/// Loads config, provisions local children, binds, and serves until Ctrl-C. /// /// 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 @@ -1137,7 +1125,7 @@ pub fn run_printing_url(options: &ServeOptions) -> Result<(), StartupError> { // affordance, so it goes to stdout itself, not through the log. println!( "{}", - crate::handoff::auth_url(handle.url(), handle.api_key.expose()) + crate::auth::primitives::auth_url(handle.url(), handle.api_key.expose()) ); run_headless(handle) } @@ -1313,7 +1301,7 @@ fn workshop_section_deprecation(config: &Config) -> Option<&'static str> { ) } -/// Load an env file into the process environment, skipping missing files. +/// Loads an env file into the process environment, skipping missing files. /// dotenvy never overrides variables that are already set. A malformed or /// unreadable file is ignored: any variable it failed to set surfaces at /// interpolation as an unresolved-`${VAR}` error naming the variable. diff --git a/crates/gateway/app/src/shutdown.rs b/crates/gateway/app/src/shutdown.rs index ce4f2f080..ec2c84378 100644 --- a/crates/gateway/app/src/shutdown.rs +++ b/crates/gateway/app/src/shutdown.rs @@ -1,21 +1,13 @@ -//! The `POST /shutdown` route and the process-shutdown signal it fires. +//! The process-shutdown signal every part of the gateway watches. //! -//! The route is the remote face of the graceful shutdown that Ctrl-C and -//! [`GatewayHandle::shutdown`](crate::GatewayHandle::shutdown) drive; the -//! tray's Quit and the shell's Quit-everything call it. It sits behind the -//! shared loopback wall and bearer auth, and it answers `202 Accepted` -//! while its own request is still in flight: axum's graceful shutdown -//! drains in-flight requests before closing their connections, so the -//! response always reaches the caller ahead of the shutdown it asked for. +//! It is shared state, not a route: the serve loop selects on it beside +//! the caller-owned shutdown future, every open-ended response stream ends +//! when it fires so the graceful drain has nothing left to wait for, the +//! tray's status tick peeks at it, and `POST /shutdown` is only one of the +//! things that fires it. -use axum::extract::State; -use axum::http::StatusCode; use tokio_util::sync::CancellationToken; -use crate::AppState; -use crate::auth::AuthedCaller; -use crate::error::GatewayError; - /// The process-shutdown signal shared by the `POST /shutdown` route, the /// serve loop (which selects on it alongside the caller-owned shutdown /// future), and every open-ended response stream, which ends when it fires @@ -47,142 +39,18 @@ impl ShutdownSignal { } } -/// The `POST /shutdown` route: bearer-authed, loopback-only via the shared -/// wall, answering `202 Accepted` and firing the shutdown signal. -/// -/// Like every bearer route it inherits the configured key, including the -/// deliberately credential-free empty-key configuration. -pub(crate) async fn admin_shutdown( - State(state): State, - _caller: AuthedCaller, -) -> Result { - // Cancel the active queue command first: a shutdown during provisioning - // stops the download, so the serve loop's drain and the process exit - // stay prompt. - state.commands.cancel_active(); - state.shutdown.fire(); - Ok(StatusCode::ACCEPTED) -} - #[cfg(test)] mod tests { - use std::net::SocketAddr; - use std::time::Duration; - - use axum::body::Body; - use axum::extract::ConnectInfo; - use axum::http::header::AUTHORIZATION; - use axum::http::{Method, Request, Response, StatusCode}; - use gateway_config::Config; - use tower::ServiceExt; - - use crate::test_support::app_state; - use crate::{AppState, build_router}; - - /// Strict bearer auth (`trust_loopback = false`), so the missing-key - /// case below is refused from the planted loopback peer. - fn state() -> AppState { - let config = Config::from_toml_str( - "config-version = 0\n\ - [server]\nbind = \"127.0.0.1:0\"\napi_key = \"test-token\"\n\ - trust_loopback = false\n", - ) - .expect("config parses"); - app_state(config, None) - } - - /// Sends one request to `/shutdown` through the router with the given - /// bearer key and peer address planted, as the walled route requires. - async fn send( - state: &AppState, - method: Method, - key: Option<&str>, - peer: &str, - ) -> Response { - let mut builder = Request::builder().method(method).uri("/shutdown"); - if let Some(key) = key { - builder = builder.header(AUTHORIZATION, format!("Bearer {key}")); - } - let mut request = builder.body(Body::empty()).expect("request builds"); - let peer: SocketAddr = peer.parse().expect("a socket address"); - request.extensions_mut().insert(ConnectInfo(peer)); - build_router(state.clone(), None) - .oneshot(request) - .await - .expect("the router is infallible") - } + use super::*; /// The tray's status tick reads `is_fired` synchronously to tell a /// requested shutdown apart from a serve-loop failure; the method is /// gated on the tray backends like its only callers. #[test] fn fire_sets_the_synchronous_peek() { - let signal = super::ShutdownSignal::default(); + let signal = ShutdownSignal::default(); assert!(!signal.is_fired(), "a fresh signal reads unfired"); signal.fire(); assert!(signal.is_fired(), "fire sets the peek before any wait"); } - - #[tokio::test] - async fn the_route_answers_202_and_fires_the_signal() { - let state = state(); - let response = send(&state, Method::POST, Some("test-token"), "127.0.0.1:50000").await; - assert_eq!(response.status(), StatusCode::ACCEPTED); - tokio::time::timeout(Duration::from_secs(5), state.shutdown.fired()) - .await - .expect("the route fired the shutdown signal"); - } - - #[tokio::test] - async fn the_route_rejects_a_missing_or_wrong_key_without_firing() { - let state = state(); - for key in [None, Some("wrong")] { - let response = send(&state, Method::POST, key, "127.0.0.1:50000").await; - assert_eq!(response.status(), StatusCode::UNAUTHORIZED, "key {key:?}"); - } - assert!( - tokio::time::timeout(Duration::from_millis(100), state.shutdown.fired()) - .await - .is_err(), - "a refused request must leave the server up" - ); - } - - #[tokio::test] - async fn the_route_rejects_non_post_methods() { - let state = state(); - for method in [Method::GET, Method::PUT, Method::DELETE] { - let response = send( - &state, - method.clone(), - Some("test-token"), - "127.0.0.1:50000", - ) - .await; - assert_eq!( - response.status(), - StatusCode::METHOD_NOT_ALLOWED, - "{method} must not reach the handler" - ); - } - } - - #[tokio::test] - async fn the_route_refuses_a_lan_peer_even_with_the_key() { - let state = state(); - let response = send( - &state, - Method::POST, - Some("test-token"), - "198.51.100.7:44821", - ) - .await; - assert_eq!(response.status(), StatusCode::FORBIDDEN); - assert!( - tokio::time::timeout(Duration::from_millis(100), state.shutdown.fired()) - .await - .is_err(), - "a walled-off request must leave the server up" - ); - } } diff --git a/crates/gateway/app/src/speech-tests.rs b/crates/gateway/app/src/speech-tests.rs index 4e55f5595..3c5a59c8c 100644 --- a/crates/gateway/app/src/speech-tests.rs +++ b/crates/gateway/app/src/speech-tests.rs @@ -7,6 +7,21 @@ use tower::ServiceExt; use crate::build_router; use crate::test_support::workshop_state; +#[cfg(feature = "stt")] +#[test] +fn speech_snapshot_serializes_only_generic_facade_facts() { + let snapshot = super::SpeechSnapshot::from(gateway_stt::SpeechService::new().status()); + + assert_eq!( + serde_json::json!(snapshot), + serde_json::json!({ + "configured": false, + "ready": false, + "gpu": false, + }) + ); +} + #[cfg(feature = "stt")] #[tokio::test] async fn transcription_checks_bearer_auth_before_multipart_extraction() { diff --git a/crates/gateway/app/src/speech.rs b/crates/gateway/app/src/speech.rs index 74296454a..ed961c14b 100644 --- a/crates/gateway/app/src/speech.rs +++ b/crates/gateway/app/src/speech.rs @@ -5,20 +5,35 @@ use std::collections::BTreeSet; use std::time::Duration; -use axum::Json; use axum::body::{Body, Bytes}; use axum::extract::{FromRequest, Request, State}; -use axum::http::HeaderValue; use axum::http::header::CONTENT_TYPE; +use axum::http::{HeaderValue, Method}; use axum::response::Response; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use gateway_config::ModelKind; +use gateway_protocol::ProtocolError; use crate::AppState; use crate::auth::AuthedCaller; use crate::error::{GatewayError, WireJson}; +use crate::registry::RouteInfo; use crate::relay::{CLIENT_HEADER, resolve_routed_model}; use crate::wire::{SpeechRequest, SpeechResponseFormat, SpeechStreamFormat, SpeechVoice}; -use gateway_config::ModelKind; -use gateway_protocol::ProtocolError; + +const AUDIO_SPEECH: RouteInfo = RouteInfo::open("/v1/audio/speech", &[Method::POST]); +const AUDIO_VOICES: RouteInfo = RouteInfo::open("/v1/audio/voices", &[Method::GET]); + +/// The speech routes, as the registry sees them. +pub(crate) const ROUTES: &[RouteInfo] = &[AUDIO_SPEECH, AUDIO_VOICES]; + +/// The speech routes. +pub(crate) fn routes() -> Router { + Router::new() + .route(AUDIO_SPEECH.path, post(audio_speech)) + .route(AUDIO_VOICES.path, get(audio_voices)) +} /// The speech route to a backend: the same auth, routing, kind guard, and /// dominion queue admission as chat, for `kind = "speech"` models. @@ -56,6 +71,13 @@ pub(crate) async fn audio_speech( let requested = match &request.voice { SpeechVoice::Name(name) => name.as_str(), SpeechVoice::Id { id } => id.as_str(), + // `SpeechVoice` is `#[non_exhaustive]` in `gateway-protocol`; a + // form this route cannot name is refused as malformed. + _ => { + return Err(GatewayError::MalformedRequest( + "voice must be a name string or an object with `id`".to_owned(), + )); + } }; if !voices.iter().any(|voice| voice == requested) { return Err(GatewayError::InvalidVoice { @@ -128,7 +150,7 @@ const SPEECH_RELAY_DOWNSTREAM_BLOCKED: Duration = Duration::from_millis(400); /// downstream. const SPEECH_RELAY_CHANNEL_CAPACITY: usize = 4; -/// Re-emit an upstream audio byte stream as the response body, holding the +/// Re-emits an upstream audio byte stream as the response body, holding the /// dominion queue permit for the stream's lifetime. /// /// The relay is untyped on purpose: audio frames are opaque bytes, so the @@ -283,6 +305,9 @@ fn speech_fallback_mime( SpeechResponseFormat::Flac => "audio/flac", SpeechResponseFormat::Wav => "audio/wav", SpeechResponseFormat::Pcm => "audio/pcm", + // `SpeechResponseFormat` is `#[non_exhaustive]` in `gateway-protocol`; + // an encoding without a spelling here is labeled as opaque bytes. + _ => "application/octet-stream", }) } @@ -314,6 +339,26 @@ pub(crate) async fn audio_voices( Ok(Json(serde_json::json!({ "voices": voices }))) } +/// Generic speech lifecycle facts included in Gateway operational status. +#[cfg(feature = "stt")] +#[derive(Debug, Clone, Copy, serde::Serialize)] +pub(crate) struct SpeechSnapshot { + configured: bool, + ready: bool, + gpu: bool, +} + +#[cfg(feature = "stt")] +impl From for SpeechSnapshot { + fn from(status: gateway_stt::SpeechStatus) -> Self { + Self { + configured: status.configured(), + ready: status.ready(), + gpu: status.gpu(), + } + } +} + #[cfg(test)] #[path = "speech-tests.rs"] mod tests; diff --git a/crates/gateway/app/src/test_support.rs b/crates/gateway/app/src/test_support.rs index 725821bac..5cfaca828 100644 --- a/crates/gateway/app/src/test_support.rs +++ b/crates/gateway/app/src/test_support.rs @@ -6,7 +6,7 @@ use std::path::PathBuf; use std::sync::Arc; use gateway_config::Config; -use shared_progress::ProgressHub; +use gateway_progress::ProgressHub; use crate::error::GatewayError; use crate::routing::Routing; @@ -22,6 +22,46 @@ pub(crate) struct AdminPaths { pub(crate) config_path: PathBuf, } +/// A tempdir-backed state with a real config file and a `local` cache +/// root, so every walled handler has something to answer with once past +/// the wall. The Hugging Face proxy points at a dead loopback port, so a +/// sweep that reaches the HF routes fails its connection rather than +/// calling the real hub. +pub(crate) fn walled_fixture() -> (tempfile::TempDir, AppState) { + let temp = tempfile::TempDir::new().expect("tempdir"); + let models = temp.path().join("cache").join("models"); + std::fs::create_dir_all(&models).expect("mkdir cache models"); + let boot = temp.path().join("gateway.toml"); + std::fs::write(&boot, "").expect("write boot"); + let config = Config::from_toml_str(&format!( + r#" +config-version = 0 + +[server] +bind = "127.0.0.1:0" +api_key = "test-token" + +[local] +cache_dir = '{cache}' +"#, + cache = temp.path().join("cache").display(), + )) + .expect("the fixture profile parses"); + let mut state = app_state( + config, + Some(AdminPaths { + fixture_dir: temp.path().to_path_buf(), + active: "main".to_owned(), + config_path: boot, + }), + ); + state.hf = Arc::new(crate::admin::walled::hf::HfProxy::new( + "http://127.0.0.1:9".to_owned(), + None, + )); + (temp, state) +} + /// Serves `build_router` over a state assembled from `config` with no /// running children: the retained config still carries everything the /// admin routes read (the cache root, the `[[local_model]]` entries). @@ -32,7 +72,10 @@ pub(crate) async fn serve(config: Config) -> SocketAddr { /// Serves like [`serve`], but with the Hugging Face proxy replaced, so a /// test can aim the `/admin/hf/*` routes at a local stub hub with an /// explicit token instead of the process env. -pub(crate) async fn serve_with_hf(config: Config, hf: crate::hf::HfProxy) -> SocketAddr { +pub(crate) async fn serve_with_hf( + config: Config, + hf: crate::admin::walled::hf::HfProxy, +) -> SocketAddr { serve_with(config, Some(hf), None).await } @@ -45,7 +88,7 @@ pub(crate) async fn serve_with_paths(config: Config, paths: AdminPaths) -> Socke /// [`serve_with_paths`]. async fn serve_with( config: Config, - hf: Option, + hf: Option, paths: Option, ) -> SocketAddr { let mut state = app_state(config, paths); @@ -233,8 +276,11 @@ pub(crate) fn parking_executor() -> Arc { use crate::commands::Outcome; - Arc::new(|_state, command, _tree| { + Arc::new(|_state, command, activity| { Box::pin(async move { + // The activity lives as long as the parked body, as a real + // command's would, so the hub reads busy while it waits. + let _activity = activity; let label = command.label(); let Some(token) = command.token() else { return Ok(label); diff --git a/crates/gateway/app/src/tray/linux.rs b/crates/gateway/app/src/tray/linux.rs index 209a583da..b0628b8b1 100644 --- a/crates/gateway/app/src/tray/linux.rs +++ b/crates/gateway/app/src/tray/linux.rs @@ -38,7 +38,7 @@ use ksni::TrayMethods as _; use ksni::menu::{CheckmarkItem, StandardItem}; use crate::api_error::StartupError; -use crate::handoff::auth_url; +use crate::auth::primitives::auth_url; use crate::runner::{GatewayHandle, ServeOptions, run_headless, spawn}; use crate::tray::logic::{self, MenuItemSpec, TrayPhase}; @@ -167,20 +167,11 @@ async fn tick(tray: &ksni::Handle, handle: &GatewayHandle) -> Tick { return Tick::Quit; } let phase = logic::next_phase(poll); - let command = handle.tray_state().commands.active_command(); + let busy = handle.tray_state().tray_busy_text(); let label = handle .tray_state() .tray_model_status() - .map(|(models, vram_gb)| { - logic::status_label( - phase, - command - .as_ref() - .map(|active| (active.name.as_str(), active.progress)), - models, - vram_gb, - ) - }); + .map(|(models, vram_gb)| logic::status_label(phase, busy.as_deref(), models, vram_gb)); let updated = tray .update(|tray| { tray.phase = phase; diff --git a/crates/gateway/app/src/tray/logic.rs b/crates/gateway/app/src/tray/logic.rs index b32d8b8a0..0e40c8590 100644 --- a/crates/gateway/app/src/tray/logic.rs +++ b/crates/gateway/app/src/tray/logic.rs @@ -41,16 +41,16 @@ pub(crate) fn next_phase(poll: Poll) -> TrayPhase { } /// The status label at the top of the menu, also used as the tooltip. -/// While the gateway serves, the label reads the command queue: an active -/// command reports its name and rounded percent ("Running - load-profile: -/// main (34%)"); an idle queue reports the loaded models, e.g. "Running - -/// 2 models, 4.1 GB", with the VRAM total omitted when no local or STT -/// model declares any. The icon tints stay phase-driven by the serve -/// poll (grayed Starting, steady Running, red Error): the queue says -/// nothing about a stopped gateway, so the phase machine keeps the icon. +/// While the gateway serves, the label reads the activity hub: a busy hub +/// reports its text ("Running - Downloading qwen 34%"); an idle hub +/// reports the loaded models, e.g. "Running - 2 models, 4.1 GB", with the +/// VRAM total omitted when no local or STT model declares any. The icon +/// tints stay phase-driven by the serve poll (grayed Starting, steady +/// Running, red Error): the hub says nothing about a stopped gateway, so +/// the phase machine keeps the icon. pub(crate) fn status_label( phase: TrayPhase, - active: Option<(&str, f64)>, + busy_text: Option<&str>, models: usize, vram_gb: f64, ) -> String { @@ -58,9 +58,8 @@ pub(crate) fn status_label( TrayPhase::Starting => "Starting".to_owned(), TrayPhase::Error => "Error - serving stopped".to_owned(), TrayPhase::Running => { - if let Some((label, fraction)) = active { - let percent = fraction.clamp(0.0, 1.0) * 100.0; - return format!("Running - {label} ({percent:.0}%)"); + if let Some(text) = busy_text { + return format!("Running - {text}"); } let models = match models { 1 => "1 model".to_owned(), @@ -494,54 +493,29 @@ mod tests { } #[test] - fn an_active_command_drives_the_label_with_its_rounded_percent() { + fn a_busy_hub_drives_the_label_with_its_text() { assert_eq!( - status_label( - TrayPhase::Running, - Some(("load-profile: main", 0.34)), - 0, - 0.0 - ), - "Running - load-profile: main (34%)" - ); - assert_eq!( - status_label( - TrayPhase::Running, - Some(("provision-model: whisper-base-en", 0.996)), - 2, - 4.1 - ), - "Running - provision-model: whisper-base-en (100%)", - "the command report outranks the model count, and the percent rounds" + status_label(TrayPhase::Running, Some("Downloading qwen 34%"), 0, 0.0), + "Running - Downloading qwen 34%" ); assert_eq!( - status_label( - TrayPhase::Running, - Some(("load-profile: main", 1.7)), - 0, - 0.0 - ), - "Running - load-profile: main (100%)", - "an over-1.0 fraction clamps rather than printing nonsense" + status_label(TrayPhase::Running, Some("load-profile: main"), 2, 4.1), + "Running - load-profile: main", + "the activity text outranks the model count and carries no percent of its own" ); } #[test] - fn the_starting_and_error_phases_ignore_the_queue() { + fn the_starting_and_error_phases_ignore_the_hub() { assert_eq!( - status_label( - TrayPhase::Starting, - Some(("load-profile: main", 0.5)), - 0, - 0.0 - ), + status_label(TrayPhase::Starting, Some("load-profile: main"), 0, 0.0), "Starting", - "no poll has reported yet, so the queue readout waits" + "no poll has reported yet, so the activity readout waits" ); assert_eq!( - status_label(TrayPhase::Error, Some(("load-profile: main", 0.5)), 3, 2.0), + status_label(TrayPhase::Error, Some("load-profile: main"), 3, 2.0), "Error - serving stopped", - "a stopped gateway reports the error, not a stale command" + "a stopped gateway reports the error, not a stale activity" ); } diff --git a/crates/gateway/app/src/tray/macos.rs b/crates/gateway/app/src/tray/macos.rs index dfd4aa4cf..7a5a6d79d 100644 --- a/crates/gateway/app/src/tray/macos.rs +++ b/crates/gateway/app/src/tray/macos.rs @@ -234,7 +234,7 @@ impl Tray { let app = NSApplication::sharedApplication(mtm); let glyph = logic::macos::template_glyph(BRAND_RGBA); let glyph = Icon::from_rgba(glyph, ICON_SIZE, ICON_SIZE).map_err(TrayError::Icon)?; - let auth_url = crate::handoff::auth_url(handle.url(), handle.tray_key()); + let auth_url = crate::auth::primitives::auth_url(handle.url(), handle.tray_key()); let workshop_exe = probe_workshop(); let login = LoginService::new(); let login_checked = login @@ -387,16 +387,9 @@ fn tick(tray: &mut Tray) { return; } tray.phase = logic::next_phase(poll); - let command = handle.tray_state().commands.active_command(); + let busy = handle.tray_state().tray_busy_text(); if let Some((models, vram_gb)) = handle.tray_state().tray_model_status() { - let label = logic::status_label( - tray.phase, - command - .as_ref() - .map(|active| (active.name.as_str(), active.progress)), - models, - vram_gb, - ); + let label = logic::status_label(tray.phase, busy.as_deref(), models, vram_gb); if label != tray.label { tray.status_item.set_text(&label); if let Some(icon) = tray.icon.as_ref() diff --git a/crates/gateway/app/src/tray/windows.rs b/crates/gateway/app/src/tray/windows.rs index b62f7b084..ee52d86b8 100644 --- a/crates/gateway/app/src/tray/windows.rs +++ b/crates/gateway/app/src/tray/windows.rs @@ -243,7 +243,7 @@ impl Tray { /// gateway handle is only read, so a failure can hand it back. fn build(handle: &GatewayHandle) -> Result { let icons = PhaseIcons::load()?; - let auth_url = crate::handoff::auth_url(handle.url(), handle.tray_key()); + let auth_url = crate::auth::primitives::auth_url(handle.url(), handle.tray_key()); let workshop_exe = probe_workshop(); let login_checked = logic::launch_at_login(&WindowsRunKey); let label = logic::status_label(TrayPhase::Starting, None, 0, 0.0); @@ -522,16 +522,9 @@ fn tick(tray: &mut Tray) { tracing::debug!("could not update the tray icon: {error}"); } } - let command = handle.tray_state().commands.active_command(); + let busy = handle.tray_state().tray_busy_text(); if let Some((models, vram_gb)) = handle.tray_state().tray_model_status() { - let label = logic::status_label( - phase, - command - .as_ref() - .map(|active| (active.name.as_str(), active.progress)), - models, - vram_gb, - ); + let label = logic::status_label(phase, busy.as_deref(), models, vram_gb); if label != tray.label { tray.status_item.set_text(&label); if let Some(icon) = tray.icon.as_ref() diff --git a/crates/gateway/app/src/web_search.rs b/crates/gateway/app/src/web_search.rs new file mode 100644 index 000000000..c6ae92afe --- /dev/null +++ b/crates/gateway/app/src/web_search.rs @@ -0,0 +1,43 @@ +//! The `POST /v1/tools/web_search` tool route: bearer-authed, delegates +//! to the web-search service crate. Compiled only with the `web-search` +//! feature, since the route has nothing to delegate to without it. + +use axum::extract::State; +use axum::http::Method; +use axum::routing::post; +use axum::{Json, Router}; +use gateway_web_search::{WebSearchRequest, WebSearchResponse}; + +use crate::AppState; +use crate::auth::AuthedCaller; +use crate::error::{GatewayError, WireJson}; +use crate::registry::RouteInfo; + +const WEB_SEARCH: RouteInfo = RouteInfo::open("/v1/tools/web_search", &[Method::POST]); + +/// The web-search tool route, as the registry sees it. +pub(crate) const ROUTES: &[RouteInfo] = &[WEB_SEARCH]; + +/// The web-search tool route. +pub(crate) fn routes() -> Router { + Router::new().route(WEB_SEARCH.path, post(web_search)) +} + +/// The `POST /v1/tools/web_search` route. +/// +/// # Errors +/// Returns [`GatewayError::Unauthorized`] when the bearer token is absent or +/// wrong, [`GatewayError::ToolNotConfigured`] when no `[tools.web_search]` +/// section is present, [`GatewayError::MalformedRequest`] when the request +/// fails validation, and the upstream variants on a provider failure. +async fn web_search( + State(state): State, + _caller: AuthedCaller, + WireJson(request): WireJson, +) -> Result, GatewayError> { + let service = state + .web_search() + .await + .ok_or(GatewayError::ToolNotConfigured("web_search"))?; + Ok(Json(service.search(&request).await?)) +} diff --git a/crates/gateway/app/tests/it/chat.rs b/crates/gateway/app/tests/it/chat.rs index dfcf2c43a..c27579f24 100644 --- a/crates/gateway/app/tests/it/chat.rs +++ b/crates/gateway/app/tests/it/chat.rs @@ -792,7 +792,7 @@ fn sse_line(model: &str, content: &str) -> String { ) } -/// Read a response body to completion, bounded by the phase timeout. +/// Reads a response body to completion, bounded by the phase timeout. async fn text_within(response: reqwest::Response) -> String { tokio::time::timeout(PHASE_TIMEOUT, response.text()) .await diff --git a/crates/gateway/app/tests/it/cloud_models.rs b/crates/gateway/app/tests/it/cloud_models.rs index 37968d0f8..32eaeb1a3 100644 --- a/crates/gateway/app/tests/it/cloud_models.rs +++ b/crates/gateway/app/tests/it/cloud_models.rs @@ -17,7 +17,7 @@ use std::time::Duration; use axum::extract::State; use axum::routing::get; use axum::{Json, Router}; -use gateway_api::{ +use gateway_api_types::{ EnvRole, EnvVar, ModelEntry, ModelKind, ProviderSlice, Sheet, SliceStatus, Thinking, Tier, }; use serde_json::Value; diff --git a/crates/gateway/app/tests/it/embeddings.rs b/crates/gateway/app/tests/it/embeddings.rs index 188f76343..23a59152c 100644 --- a/crates/gateway/app/tests/it/embeddings.rs +++ b/crates/gateway/app/tests/it/embeddings.rs @@ -88,7 +88,7 @@ async fn slow_embeddings_backend() -> (SocketAddr, UnboundedReceiver) (spawn_backend(router).await, receiver) } -/// Start a gateway serving one remote embedding model. With +/// Starts a gateway serving one remote embedding model. With /// `max_concurrency`, the endpoint is bound to a dominion pool capped at that /// many in-flight requests; without it the endpoint is an unlimited /// pass-through. diff --git a/crates/gateway/app/tests/it/rerank.rs b/crates/gateway/app/tests/it/rerank.rs index e4366ee56..e5f22151e 100644 --- a/crates/gateway/app/tests/it/rerank.rs +++ b/crates/gateway/app/tests/it/rerank.rs @@ -60,7 +60,7 @@ async fn recording_rerank_backend() -> (SocketAddr, Recorder) { (spawn_backend(router).await, recorder) } -/// Start a gateway serving one remote classifier model. +/// Starts a gateway serving one remote classifier model. async fn rerank_gateway(backend: SocketAddr) -> TestServer { let toml = format!( r#" diff --git a/crates/gateway/app/tests/it/speech.rs b/crates/gateway/app/tests/it/speech.rs index 245b1fc7d..0b302bb5f 100644 --- a/crates/gateway/app/tests/it/speech.rs +++ b/crates/gateway/app/tests/it/speech.rs @@ -146,7 +146,7 @@ async fn status_speech_backend(status: StatusCode, body: &'static str) -> Socket .await } -/// Start a gateway serving one remote speech model. `voices` renders the +/// Starts a gateway serving one remote speech model. `voices` renders the /// catalog list (`Some(&[])` renders an explicit empty list, `None` omits /// the field). With `pool`, the endpoint binds to a dominion capped at that /// many in-flight requests with the given waiting depth and policy; @@ -841,7 +841,7 @@ async fn models_catalog_shows_the_speech_kind_and_voices() { gateway.shutdown().await; } -/// Start a gateway whose catalog is the given `[[model]]` TOML fragments, +/// Starts a gateway whose catalog is the given `[[model]]` TOML fragments, /// all resolving to one fake backend. The voices route never calls an /// upstream; the backend exists only to satisfy config validation. async fn catalog_gateway(backend: SocketAddr, models: &str) -> TestServer { diff --git a/crates/gateway/app/tests/it/support.rs b/crates/gateway/app/tests/it/support.rs index 071b95ed6..11b0fa29f 100644 --- a/crates/gateway/app/tests/it/support.rs +++ b/crates/gateway/app/tests/it/support.rs @@ -332,7 +332,7 @@ pub(crate) fn wait_for_connection( } } -/// Spawn a plain axum backend on an ephemeral port and return its address. +/// Spawns a plain axum backend on an ephemeral port and returns its address. pub(crate) async fn spawn_backend(router: Router) -> SocketAddr { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); @@ -478,7 +478,7 @@ endpoints = ["fake"] Config::from_toml_str(&toml).unwrap() } -/// Start the gateway wired to the fake backend. +/// Starts the gateway wired to the fake backend. pub(crate) async fn gateway_for(backend: SocketAddr) -> TestServer { let gateway = Gateway::from_config(&gateway_config(backend), ProfilesContext::default()).unwrap(); @@ -504,7 +504,7 @@ pub(crate) async fn fake_brave() -> SocketAddr { spawn_backend(Router::new().route("/web/search", axum::routing::get(search))).await } -/// Start a gateway wired to a fake Brave backend for the web-search tool. +/// Starts a gateway wired to a fake Brave backend for the web-search tool. #[cfg(feature = "web-search")] pub(crate) async fn gateway_with_web_search(brave: SocketAddr) -> TestServer { let toml = format!( diff --git a/crates/gateway/cloud-providers/Cargo.toml b/crates/gateway/cloud-providers/Cargo.toml index 9db7e8bbd..1abd0de41 100644 --- a/crates/gateway/cloud-providers/Cargo.toml +++ b/crates/gateway/cloud-providers/Cargo.toml @@ -26,8 +26,9 @@ hmac.workspace = true reqwest.workspace = true serde.workspace = true serde_json.workspace = true +shared-error-source = { workspace = true, features = ["http"] } sha2.workspace = true -gateway-api.workspace = true +gateway-api-types.workspace = true thiserror.workspace = true time = { workspace = true, features = ["parsing"] } tokio.workspace = true diff --git a/crates/gateway/cloud-providers/src/lib.rs b/crates/gateway/cloud-providers/src/lib.rs index bf9c8e31a..1c26142ba 100644 --- a/crates/gateway/cloud-providers/src/lib.rs +++ b/crates/gateway/cloud-providers/src/lib.rs @@ -6,7 +6,7 @@ //! file. The crate does double duty: a library linked into the Gateway, and //! a binary the aggregation workflow compiles and runs. -use gateway_api::{EnvRole, ModelEntry, Tier}; +use gateway_api_types::{EnvRole, ModelEntry, Tier}; pub mod providers; mod sheet; @@ -14,6 +14,12 @@ mod taxonomy; pub use sheet::{build_sheet, fetch_sheet}; +// The transport cause behind `FetchError::Http`. A caller that needs the +// transport error itself names `shared_error_source` directly; this crate does +// not re-export the wrapper, so there is one name for the cause across the +// workspace rather than one per crate. +use shared_error_source::HttpSource; + /// The const-friendly twin of the schema's `EnvVar`: the schema type /// holds `String`s and cannot sit in a `const` descriptor, so the /// descriptor carries `&'static str` and slice construction converts. @@ -91,6 +97,7 @@ pub fn providers() -> &'static [Provider] { /// A failed provider fetch or sheet download. Never fatal to a sheet /// build: the caller propagates last-known-good data instead. #[derive(Debug, thiserror::Error)] +#[non_exhaustive] pub enum FetchError { /// The registry has no fetch implementation for this provider. #[error("no fetch implementation for provider `{name}`")] @@ -98,9 +105,10 @@ pub enum FetchError { /// The provider registry key. name: String, }, - /// The HTTP request to the provider failed. - #[error("provider request failed: {0}")] - Http(#[from] reqwest::Error), + /// The HTTP request to the provider failed. The transport cause is + /// the `source()`; renderers walk the chain for it. + #[error("the provider request did not complete")] + Http(#[source] HttpSource), /// The previous release's sheet URL answered HTTP 404: the release /// does not exist yet. #[error("no sheet at `{url}` (HTTP 404)")] @@ -118,7 +126,38 @@ pub enum FetchError { }, } -/// Fetch and normalize one provider's model list; the per-provider +impl From for FetchError { + fn from(source: reqwest::Error) -> Self { + FetchError::Http(HttpSource::from(source)) + } +} + +/// Renders an error and its full `source()` chain as one line, each cause +/// separated by `; `. A variant's `Display` carries only its own message, +/// so this is how a person-facing note recovers the transport or decode +/// text underneath. +/// +/// A cause that renders as nothing, and a cause whose text the +/// accumulated rendering already contains, are both skipped: some +/// variants copy their source's text into their own message, and +/// appending that cause again would print it twice. The check is a plain +/// substring test on the text rendered so far. +#[must_use] +pub fn error_chain(error: &dyn std::error::Error) -> String { + let mut text = error.to_string(); + let mut source = error.source(); + while let Some(cause) = source { + let cause_text = cause.to_string(); + if !cause_text.is_empty() && !text.contains(&cause_text) { + text.push_str("; "); + text.push_str(&cause_text); + } + source = cause.source(); + } + text +} + +/// Fetches and normalizes one provider's model list; the per-provider /// variance lives behind this seam. The client is injected by the /// caller (the Gateway's bounded client, or the binary's own). /// @@ -167,11 +206,26 @@ pub async fn fetch_models( mod tests { use std::collections::BTreeSet; - use gateway_api::{ModelEntry, Tier}; + use gateway_api_types::{ModelEntry, Tier}; + + use super::{FetchError, HttpSource, Provider, error_chain, fetch_models, providers}; - use super::{FetchError, Provider, fetch_models, providers}; + #[test] + fn the_http_variant_reaches_the_transport_error_through_the_shared_wrapper() { + let Err(transport) = reqwest::Proxy::all("http://") else { + panic!("a proxy URL with an empty host must not build"); + }; + let error = FetchError::from(transport); + let Some(cause) = std::error::Error::source(&error) else { + panic!("the http variant carries its transport cause as source()"); + }; + let Some(wrapper) = cause.downcast_ref::() else { + panic!("the transport cause is the shared HttpSource"); + }; + assert!(wrapper.as_inner().is_builder()); + } - /// Apply one provider's private taxonomy rules to a list of + /// Applies one provider's private taxonomy rules to a list of /// entries, by registry name. The production path applies the rules /// inside each provider's fetch; this dispatch lets the registry /// tests apply them to fixture entries. @@ -470,4 +524,51 @@ mod tests { "the error must name the provider: {err}" ); } + + /// A leaf cause with its own text. + #[derive(Debug, thiserror::Error)] + #[error("connection reset")] + struct Leaf; + + /// An outer error that copies its cause's text into its own message. + #[derive(Debug, thiserror::Error)] + #[error("model sheet unavailable: {message}")] + struct Copying { + message: String, + #[source] + source: Leaf, + } + + /// A cause that renders as nothing. + #[derive(Debug, thiserror::Error)] + #[error("")] + struct Silent; + + /// An outer error whose cause renders as nothing. + #[derive(Debug, thiserror::Error)] + #[error("model sheet unavailable")] + struct OverSilent(#[source] Silent); + + #[test] + fn a_cause_the_outer_message_already_carries_renders_once() { + let error = Copying { + message: "connection reset".to_owned(), + source: Leaf, + }; + let rendered = error_chain(&error); + assert_eq!( + rendered, "model sheet unavailable: connection reset", + "a cause whose text the outer message already carries is skipped" + ); + assert_eq!( + rendered.matches("connection reset").count(), + 1, + "the cause text appears exactly once" + ); + assert_eq!( + error_chain(&OverSilent(Silent)), + "model sheet unavailable", + "a cause that renders as nothing adds no trailing separator" + ); + } } diff --git a/crates/gateway/cloud-providers/src/main.rs b/crates/gateway/cloud-providers/src/main.rs index 608a33e98..e06e8af1c 100644 --- a/crates/gateway/cloud-providers/src/main.rs +++ b/crates/gateway/cloud-providers/src/main.rs @@ -16,7 +16,7 @@ use std::path::PathBuf; use std::process::ExitCode; use std::time::Duration; -use gateway_api::Sheet; +use gateway_api_types::Sheet; /// Environment variable carrying the previous release's sheet URL. const PREVIOUS_SHEET_URL_ENV: &str = "MODELS_SHEET_PREVIOUS_URL"; @@ -39,7 +39,7 @@ async fn main() -> ExitCode { } } -/// Resolve the operator's home directory, mirroring the ART-009 +/// Resolves the operator's home directory, mirroring the ART-009 /// convention (`USERPROFILE` on Windows, `HOME` otherwise) rather than /// importing `gateway-local`, which would pull the local-inference /// stack into this thin sheet-building binary. @@ -51,7 +51,7 @@ fn home_dir() -> Option { home.filter(|home| !home.is_empty()).map(PathBuf::from) } -/// Load operator secrets from `/.promptforge/cloud-provider-secrets.env`, +/// Loads operator secrets from `/.promptforge/cloud-provider-secrets.env`, /// overriding the process environment so local runs need no exported keys. /// /// A missing file or unresolvable home earns a stderr note and a @@ -104,7 +104,7 @@ fn default_output() -> String { dir.join(DEFAULT_OUTPUT_NAME).to_string_lossy().into_owned() } -/// Build the sheet and write it to the output path, returning the path. +/// Builds the sheet and writes it to the output path, returning the path. async fn run() -> Result> { let output = std::env::args().nth(1).unwrap_or_else(default_output); let client = reqwest::Client::builder() @@ -136,7 +136,7 @@ enum PreviousSheet { Fetched(Sheet), } -/// Resolve the previous release's sheet. An unset URL and an HTTP 404 +/// Resolves the previous release's sheet. An unset URL and an HTTP 404 /// both mean first run; any other failure - transport error, non-404 /// non-success status, unparseable body - is fatal, since silently /// losing history would demote every slice to `unavailable`. @@ -155,7 +155,11 @@ async fn previous_sheet( ); Ok(PreviousSheet::FirstRun) } - Err(err) => Err(format!("previous sheet at {url}: {err}").into()), + Err(err) => Err(format!( + "previous sheet at {url}: {}", + gateway_cloud_providers::error_chain(&err) + ) + .into()), } } @@ -163,7 +167,7 @@ async fn previous_sheet( mod tests { use super::*; - /// Serve one HTTP response with `status` carrying `body`, returning + /// Serves one HTTP response with `status` carrying `body`, returning /// the URL to request. fn serve_once(status: &'static str, body: &'static str) -> String { use std::io::{Read as _, Write as _}; @@ -252,6 +256,10 @@ mod tests { err.to_string().contains(url.as_str()), "the error must name the URL: {err}" ); + assert!( + err.to_string().contains("500"), + "the error must carry the transport cause from the source chain: {err}" + ); } #[tokio::test] diff --git a/crates/gateway/cloud-providers/src/providers/mod.rs b/crates/gateway/cloud-providers/src/providers.rs similarity index 100% rename from crates/gateway/cloud-providers/src/providers/mod.rs rename to crates/gateway/cloud-providers/src/providers.rs diff --git a/crates/gateway/cloud-providers/src/providers/anthropic-taxonomy.rs b/crates/gateway/cloud-providers/src/providers/anthropic-taxonomy.rs index 77d493b3a..9bd076580 100644 --- a/crates/gateway/cloud-providers/src/providers/anthropic-taxonomy.rs +++ b/crates/gateway/cloud-providers/src/providers/anthropic-taxonomy.rs @@ -3,7 +3,7 @@ //! is in the same list. Anthropic's rules live in this sibling module so //! the provider file stays under the workspace's 500-line ceiling. -use gateway_api::ModelEntry; +use gateway_api_types::ModelEntry; use crate::taxonomy::{SnapshotStyle, collapse_variants, strip_snapshot}; @@ -15,7 +15,7 @@ fn family_of(id: &str) -> String { rest.split('-').next().unwrap_or(rest).to_owned() } -/// Set every entry's family, then collapse `-YYYYMMDD` snapshots onto +/// Sets every entry's family, then collapses `-YYYYMMDD` snapshots onto /// their canonical entries. pub(crate) fn apply(entries: &mut [ModelEntry]) { for entry in entries.iter_mut() { @@ -28,7 +28,7 @@ pub(crate) fn apply(entries: &mut [ModelEntry]) { mod tests { use std::collections::BTreeMap; - use gateway_api::ModelEntry; + use gateway_api_types::ModelEntry; use super::apply; diff --git a/crates/gateway/cloud-providers/src/providers/anthropic.rs b/crates/gateway/cloud-providers/src/providers/anthropic.rs index 91b91ec20..979eeec91 100644 --- a/crates/gateway/cloud-providers/src/providers/anthropic.rs +++ b/crates/gateway/cloud-providers/src/providers/anthropic.rs @@ -6,7 +6,7 @@ //! //! Docs: -use gateway_api::{EnvRole, ModelEntry, ModelKind, Thinking, Tier}; +use gateway_api_types::{EnvRole, ModelEntry, ModelKind, Thinking, Tier}; use serde::Deserialize; use time::format_description::well_known::Rfc3339; use time::{Date, OffsetDateTime}; @@ -43,7 +43,7 @@ const ANTHROPIC_VERSION: &str = "2023-06-01"; /// lineup grows. const PAGE_LIMIT: u32 = 1000; -/// Fetch and normalize Anthropic's model list, following the cursor until +/// Fetches and normalizes Anthropic's model list, following the cursor until /// the final page. pub(crate) async fn fetch( client: &reqwest::Client, @@ -177,7 +177,7 @@ fn effort_levels(effort: Option<&WireEffort>) -> Vec { .collect() } -/// Normalize one wire model into a sheet entry. +/// Normalizes one wire model into a sheet entry. fn normalize_model(model: &WireModel) -> ModelEntry { let caps = model.capabilities.as_ref(); let capability = |pick: fn(&WireCapabilities) -> &Option| { @@ -228,7 +228,7 @@ fn normalize_model(model: &WireModel) -> ModelEntry { } } -/// Parse the release date. The endpoint substitutes the epoch when the +/// Parses the release date. The endpoint substitutes the epoch when the /// release date is unknown; that sentinel normalizes to `None`, as does /// an unparseable value. fn parse_release_date(created_at: &str) -> Option { diff --git a/crates/gateway/cloud-providers/src/providers/azure_speech.rs b/crates/gateway/cloud-providers/src/providers/azure_speech.rs index 3d684ea94..7ae1dfedb 100644 --- a/crates/gateway/cloud-providers/src/providers/azure_speech.rs +++ b/crates/gateway/cloud-providers/src/providers/azure_speech.rs @@ -14,7 +14,7 @@ //! //! Docs: -use gateway_api::{Deprecation, EnvRole, ModelEntry, ModelKind, Tier}; +use gateway_api_types::{Deprecation, EnvRole, ModelEntry, ModelKind, Tier}; use serde::Deserialize; use time::format_description::well_known::Rfc3339; use time::{Date, OffsetDateTime}; @@ -55,7 +55,7 @@ pub const PROVIDER: Provider = Provider { /// The list path under the base URL. const MODELS_PATH: &str = "/speechtotext/v3.2/models/base"; -/// Fetch and normalize Azure Speech's base-model list, following +/// Fetches and normalizes Azure Speech's base-model list, following /// `@nextLink` until the final page. pub(crate) async fn fetch( client: &reqwest::Client, @@ -156,7 +156,7 @@ struct WireDeprecationDates { transcription: Option, } -/// Normalize one wire model into a sheet entry. +/// Normalizes one wire model into a sheet entry. fn normalize_model(model: &WireModel) -> ModelEntry { let id = model.self_url.rsplit('/').next().unwrap_or(&model.self_url); let mut entry = base_entry(id, None); @@ -183,7 +183,7 @@ fn normalize_model(model: &WireModel) -> ModelEntry { entry } -/// Parse a wire timestamp into a calendar date; an unparseable value +/// Parses a wire timestamp into a calendar date; an unparseable value /// keeps no date. fn parse_wire_date(value: &str) -> Option { OffsetDateTime::parse(value, &Rfc3339) @@ -191,7 +191,7 @@ fn parse_wire_date(value: &str) -> Option { .map(OffsetDateTime::date) } -/// Set every entry's family: the catalog is per-locale base models, so +/// Sets every entry's family: the catalog is per-locale base models, so /// the locale is the family; a model with no locale is its own family /// (its id is a UUID). There is no snapshot collapse - the ids carry no /// suffixes. @@ -422,10 +422,13 @@ mod tests { assert_eq!(PROVIDER.openai_base_url, None); assert_eq!(PROVIDER.env_vars.len(), 2); assert_eq!(PROVIDER.env_vars[0].name, KEY_ENV); - assert_eq!(PROVIDER.env_vars[0].role, gateway_api::EnvRole::Key); + assert_eq!(PROVIDER.env_vars[0].role, gateway_api_types::EnvRole::Key); assert_eq!(PROVIDER.env_vars[0].default, None); assert_eq!(PROVIDER.env_vars[1].name, REGION_ENV); - assert_eq!(PROVIDER.env_vars[1].role, gateway_api::EnvRole::Config); + assert_eq!( + PROVIDER.env_vars[1].role, + gateway_api_types::EnvRole::Config + ); assert_eq!(PROVIDER.env_vars[1].default, None); } diff --git a/crates/gateway/cloud-providers/src/providers/baidu.rs b/crates/gateway/cloud-providers/src/providers/baidu.rs index 4eddce2d0..a9a5e82bb 100644 --- a/crates/gateway/cloud-providers/src/providers/baidu.rs +++ b/crates/gateway/cloud-providers/src/providers/baidu.rs @@ -11,7 +11,7 @@ //! //! Docs: -use gateway_api::{EnvRole, ModelEntry, ModelKind, Pricing, Tier}; +use gateway_api_types::{EnvRole, ModelEntry, ModelKind, Pricing, Tier}; use serde::Deserialize; use crate::providers::openai_shape::{base_entry, fetch_list}; @@ -39,7 +39,7 @@ pub const PROVIDER: Provider = Provider { /// The list path under the base URL. const MODELS_PATH: &str = "/v2/models"; -/// Fetch and normalize Baidu's model list in a single request. +/// Fetches and normalizes Baidu's model list in a single request. pub(crate) async fn fetch( client: &reqwest::Client, base_url: &str, @@ -130,7 +130,7 @@ fn price_per_mtok(price: &WirePrice) -> Option { flat.parse::().ok().map(|per_1k| per_1k * 1000.0) } -/// Normalize one wire model into a sheet entry. +/// Normalizes one wire model into a sheet entry. fn normalize_model(model: &WireModel) -> ModelEntry { let mut entry = base_entry(&model.id, model.created); entry.kind = model_kind(model.model_type.as_deref()); @@ -185,7 +185,7 @@ fn family_of(id: &str) -> String { id.to_owned() } -/// Set every entry's family. +/// Sets every entry's family. pub(crate) fn apply_taxonomy(entries: &mut [ModelEntry]) { for entry in entries.iter_mut() { entry.family = family_of(&entry.id); @@ -420,7 +420,7 @@ mod tests { ); assert_eq!(PROVIDER.env_vars.len(), 1); assert_eq!(PROVIDER.env_vars[0].name, KEY_ENV); - assert_eq!(PROVIDER.env_vars[0].role, gateway_api::EnvRole::Key); + assert_eq!(PROVIDER.env_vars[0].role, gateway_api_types::EnvRole::Key); assert_eq!(PROVIDER.env_vars[0].default, None); } } diff --git a/crates/gateway/cloud-providers/src/providers/bedrock-sigv4.rs b/crates/gateway/cloud-providers/src/providers/bedrock-sigv4.rs index 34d52f969..3a13176e3 100644 --- a/crates/gateway/cloud-providers/src/providers/bedrock-sigv4.rs +++ b/crates/gateway/cloud-providers/src/providers/bedrock-sigv4.rs @@ -29,10 +29,13 @@ pub(super) fn host_of(url: &str) -> &str { after_scheme.split('/').next().unwrap_or(after_scheme) } -/// Sign a GET request per AWS Signature Version 4, returning the +/// Signs a GET request per AWS Signature Version 4, returning the /// `Authorization` header value. `query` is the canonical query string /// (name-sorted, URI-encoded); the Bedrock list endpoint takes none. -#[allow(clippy::too_many_arguments)] +#[expect( + clippy::too_many_arguments, + reason = "the eight inputs are the SigV4 canonical request fields; a struct would restate them once more" +)] pub(super) fn sign_get( host: &str, path: &str, @@ -89,7 +92,7 @@ fn hex_lower(bytes: &[u8]) -> String { mod tests { use super::sign_get; - /// Sign with the AWS SigV4 test-suite credentials, host, and date. + /// Signs with the AWS SigV4 test-suite credentials, host, and date. fn sign_vector(path: &str, query: &str) -> String { sign_get( "example.amazonaws.com", diff --git a/crates/gateway/cloud-providers/src/providers/bedrock-taxonomy.rs b/crates/gateway/cloud-providers/src/providers/bedrock-taxonomy.rs index 4b3572ee4..68966aa0d 100644 --- a/crates/gateway/cloud-providers/src/providers/bedrock-taxonomy.rs +++ b/crates/gateway/cloud-providers/src/providers/bedrock-taxonomy.rs @@ -4,7 +4,7 @@ //! Bedrock's rules live in this sibling module so the provider file //! stays under the workspace's 500-line ceiling. -use gateway_api::ModelEntry; +use gateway_api_types::ModelEntry; /// The entry's family: the vendor segment before the first dot /// (`amazon`, `anthropic`, `meta`, ...), and the whole id otherwise. @@ -13,7 +13,7 @@ fn family_of(id: &str) -> String { .map_or_else(|| id.to_owned(), |(vendor, _)| vendor.to_owned()) } -/// Set every entry's family. +/// Sets every entry's family. pub(crate) fn apply(entries: &mut [ModelEntry]) { for entry in entries.iter_mut() { entry.family = family_of(&entry.id); @@ -22,7 +22,7 @@ pub(crate) fn apply(entries: &mut [ModelEntry]) { #[cfg(test)] mod tests { - use gateway_api::ModelEntry; + use gateway_api_types::ModelEntry; use super::apply; diff --git a/crates/gateway/cloud-providers/src/providers/bedrock.rs b/crates/gateway/cloud-providers/src/providers/bedrock.rs index 041cc251e..af62de024 100644 --- a/crates/gateway/cloud-providers/src/providers/bedrock.rs +++ b/crates/gateway/cloud-providers/src/providers/bedrock.rs @@ -13,7 +13,7 @@ //! //! Docs: -use gateway_api::{Deprecation, EnvRole, ModelEntry, ModelKind, Tier}; +use gateway_api_types::{Deprecation, EnvRole, ModelEntry, ModelKind, Tier}; use serde::Deserialize; use time::OffsetDateTime; @@ -71,7 +71,7 @@ pub const PROVIDER: Provider = Provider { ], }; -/// Fetch and normalize Bedrock's foundation-model list with a +/// Fetches and normalizes Bedrock's foundation-model list with a /// SigV4-signed request; the secret key and region are private env reads. pub(crate) async fn fetch( client: &reqwest::Client, @@ -178,7 +178,7 @@ struct WireLifecycle { status: String, } -/// Normalize one wire model into a sheet entry. +/// Normalizes one wire model into a sheet entry. fn normalize_model(model: &WireModel) -> ModelEntry { let mut entry = base_entry(&model.model_id, None); if let Some(name) = &model.model_name { @@ -350,12 +350,16 @@ mod tests { PROVIDER.openai_base_url, None, "SigV4 and the Converse API are not OpenAI-shaped" ); - let vars: &[(&str, gateway_api::EnvRole, Option<&str>)] = &[ - ("AWS_ACCESS_KEY_ID", gateway_api::EnvRole::Key, None), - ("AWS_SECRET_ACCESS_KEY", gateway_api::EnvRole::Key, None), + let vars: &[(&str, gateway_api_types::EnvRole, Option<&str>)] = &[ + ("AWS_ACCESS_KEY_ID", gateway_api_types::EnvRole::Key, None), + ( + "AWS_SECRET_ACCESS_KEY", + gateway_api_types::EnvRole::Key, + None, + ), ( "AWS_REGION", - gateway_api::EnvRole::Config, + gateway_api_types::EnvRole::Config, Some("us-east-1"), ), ]; diff --git a/crates/gateway/cloud-providers/src/providers/cohere.rs b/crates/gateway/cloud-providers/src/providers/cohere.rs index a3784471d..1b5b8a4db 100644 --- a/crates/gateway/cloud-providers/src/providers/cohere.rs +++ b/crates/gateway/cloud-providers/src/providers/cohere.rs @@ -8,7 +8,7 @@ //! //! Docs: -use gateway_api::{Deprecation, EnvRole, ModelEntry, ModelKind, Tier}; +use gateway_api_types::{Deprecation, EnvRole, ModelEntry, ModelKind, Tier}; use serde::Deserialize; use crate::providers::openai_shape::base_entry; @@ -38,7 +38,7 @@ pub const PROVIDER: Provider = Provider { /// lineup grows. const PAGE_SIZE: u32 = 1000; -/// Fetch and normalize Cohere's model list, following `next_page_token` +/// Fetches and normalizes Cohere's model list, following `next_page_token` /// until the final page. pub(crate) async fn fetch( client: &reqwest::Client, @@ -114,10 +114,12 @@ fn model_kind(endpoints: &[String]) -> ModelKind { } } -/// Normalize one wire model into a sheet entry. -// The wire reports context_length as a double; the sheet field is a -// whole-token count. -#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] +/// Normalizes one wire model into a sheet entry. +#[expect( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + reason = "the wire reports context_length as a double; the sheet field is a whole-token count" +)] fn normalize_model(model: &WireModel) -> ModelEntry { let mut entry = base_entry(&model.name, None); let endpoints = model.endpoints.as_deref().unwrap_or_default(); @@ -154,7 +156,7 @@ fn family_of(id: &str) -> String { id.to_owned() } -/// Set every entry's family, then collapse `-MM-YYYY` snapshot suffixes +/// Sets every entry's family, then collapses `-MM-YYYY` snapshot suffixes /// onto their canonical entries. pub(crate) fn apply_taxonomy(entries: &mut [ModelEntry]) { for entry in entries.iter_mut() { @@ -387,7 +389,7 @@ mod tests { ); assert_eq!(PROVIDER.env_vars.len(), 1); assert_eq!(PROVIDER.env_vars[0].name, KEY_ENV); - assert_eq!(PROVIDER.env_vars[0].role, gateway_api::EnvRole::Key); + assert_eq!(PROVIDER.env_vars[0].role, gateway_api_types::EnvRole::Key); assert_eq!(PROVIDER.env_vars[0].default, None); } } diff --git a/crates/gateway/cloud-providers/src/providers/deepgram.rs b/crates/gateway/cloud-providers/src/providers/deepgram.rs index dc5d22dce..7383962f2 100644 --- a/crates/gateway/cloud-providers/src/providers/deepgram.rs +++ b/crates/gateway/cloud-providers/src/providers/deepgram.rs @@ -8,7 +8,7 @@ //! //! Docs: -use gateway_api::{EnvRole, ModelEntry, ModelKind, Tier}; +use gateway_api_types::{EnvRole, ModelEntry, ModelKind, Tier}; use serde::Deserialize; use crate::providers::openai_shape::base_entry; @@ -36,7 +36,7 @@ pub const PROVIDER: Provider = Provider { /// The list path under the base URL. const MODELS_PATH: &str = "/v1/models"; -/// Fetch and normalize Deepgram's model list in a single request. +/// Fetches and normalizes Deepgram's model list in a single request. pub(crate) async fn fetch( client: &reqwest::Client, base_url: &str, @@ -91,7 +91,7 @@ struct WireTts { languages: Vec, } -/// Split one payload into STT and TTS entries with distinct kinds, one +/// Splits one payload into STT and TTS entries with distinct kinds, one /// entry per distinct canonical name: the wire repeats each model once /// per language, so rows group by id and their languages collect in /// first-seen order. @@ -114,7 +114,7 @@ fn normalize_list(response: &ListResponse) -> Vec { entries } -/// Merge one row into the grouped list: the first row for an id pushes +/// Merges one row into the grouped list: the first row for an id pushes /// the entry; later rows for the same id contribute only the languages /// the entry does not already carry. fn absorb( @@ -156,7 +156,7 @@ fn family_of(id: &str) -> String { id.to_owned() } -/// Set every entry's family. Deepgram's catalog carries no snapshot +/// Sets every entry's family. Deepgram's catalog carries no snapshot /// suffixes, so there is no collapse pass. pub(crate) fn apply_taxonomy(entries: &mut [ModelEntry]) { for entry in entries.iter_mut() { @@ -357,7 +357,7 @@ mod tests { assert_eq!(PROVIDER.openai_base_url, None); assert_eq!(PROVIDER.env_vars.len(), 1); assert_eq!(PROVIDER.env_vars[0].name, KEY_ENV); - assert_eq!(PROVIDER.env_vars[0].role, gateway_api::EnvRole::Key); + assert_eq!(PROVIDER.env_vars[0].role, gateway_api_types::EnvRole::Key); assert_eq!(PROVIDER.env_vars[0].default, None); } } diff --git a/crates/gateway/cloud-providers/src/providers/deepseek.rs b/crates/gateway/cloud-providers/src/providers/deepseek.rs index 43d3e7854..f7d816db3 100644 --- a/crates/gateway/cloud-providers/src/providers/deepseek.rs +++ b/crates/gateway/cloud-providers/src/providers/deepseek.rs @@ -5,7 +5,7 @@ //! //! Docs: -use gateway_api::{EnvRole, ModelEntry, Tier}; +use gateway_api_types::{EnvRole, ModelEntry, Tier}; use serde::Deserialize; use crate::providers::openai_shape::{base_entry, fetch_list}; @@ -34,7 +34,7 @@ pub const PROVIDER: Provider = Provider { /// not `/v1/models`. const MODELS_PATH: &str = "/models"; -/// Fetch and normalize DeepSeek's model list in a single request. +/// Fetches and normalizes DeepSeek's model list in a single request. pub(crate) async fn fetch( client: &reqwest::Client, base_url: &str, @@ -60,7 +60,7 @@ struct WireModel { created: Option, } -/// Normalize one wire model: the endpoint is IDs-only, so the entry is +/// Normalizes one wire model: the endpoint is IDs-only, so the entry is /// the conservative base. fn normalize_model(model: &WireModel) -> ModelEntry { base_entry(&model.id, model.created) @@ -79,7 +79,7 @@ fn family_of(id: &str) -> String { id.to_owned() } -/// Set every entry's family. +/// Sets every entry's family. pub(crate) fn apply_taxonomy(entries: &mut [ModelEntry]) { for entry in entries.iter_mut() { entry.family = family_of(&entry.id); diff --git a/crates/gateway/cloud-providers/src/providers/elevenlabs.rs b/crates/gateway/cloud-providers/src/providers/elevenlabs.rs index d5b426899..e7d54336f 100644 --- a/crates/gateway/cloud-providers/src/providers/elevenlabs.rs +++ b/crates/gateway/cloud-providers/src/providers/elevenlabs.rs @@ -7,7 +7,7 @@ //! //! Docs: -use gateway_api::{EnvRole, ModelEntry, ModelKind, Tier}; +use gateway_api_types::{EnvRole, ModelEntry, ModelKind, Tier}; use serde::Deserialize; use crate::providers::openai_shape::base_entry; @@ -35,7 +35,7 @@ pub const PROVIDER: Provider = Provider { /// The list path under the base URL. const MODELS_PATH: &str = "/v1/models"; -/// Fetch and normalize ElevenLabs' model list in a single request. +/// Fetches and normalizes ElevenLabs' model list in a single request. pub(crate) async fn fetch( client: &reqwest::Client, base_url: &str, @@ -78,7 +78,7 @@ struct WireLanguage { language_id: String, } -/// Normalize one wire model into a sheet entry. +/// Normalizes one wire model into a sheet entry. fn normalize_model(model: &WireModel) -> ModelEntry { let mut entry = base_entry(&model.model_id, None); if let Some(name) = &model.name { @@ -117,7 +117,7 @@ fn family_of(id: &str) -> String { id.to_owned() } -/// Set every entry's family. ElevenLabs' catalog carries no snapshot +/// Sets every entry's family. ElevenLabs' catalog carries no snapshot /// suffixes, so there is no collapse pass. pub(crate) fn apply_taxonomy(entries: &mut [ModelEntry]) { for entry in entries.iter_mut() { @@ -271,7 +271,7 @@ mod tests { assert_eq!(PROVIDER.openai_base_url, None); assert_eq!(PROVIDER.env_vars.len(), 1); assert_eq!(PROVIDER.env_vars[0].name, KEY_ENV); - assert_eq!(PROVIDER.env_vars[0].role, gateway_api::EnvRole::Key); + assert_eq!(PROVIDER.env_vars[0].role, gateway_api_types::EnvRole::Key); assert_eq!(PROVIDER.env_vars[0].default, None); } } diff --git a/crates/gateway/cloud-providers/src/providers/foundry-taxonomy.rs b/crates/gateway/cloud-providers/src/providers/foundry-taxonomy.rs index 28df87ec4..779dc2292 100644 --- a/crates/gateway/cloud-providers/src/providers/foundry-taxonomy.rs +++ b/crates/gateway/cloud-providers/src/providers/foundry-taxonomy.rs @@ -4,7 +4,7 @@ //! rules live in this sibling module so the provider file stays under //! the workspace's 500-line ceiling. -use gateway_api::{Deprecation, ModelEntry, ModelKind}; +use gateway_api_types::{Deprecation, ModelEntry, ModelKind}; use time::Date; /// The lifecycle labels that mean a model is on its way out. Every @@ -98,7 +98,7 @@ fn kind_from_outputs(output_modalities: &[String]) -> ModelKind { } } -/// Fill the family for entries that carry no publisher - including the +/// Fills the family for entries that carry no publisher - including the /// id-only entries the registry's taxonomy tests build - so every entry /// leaves the fetch with one. Normalization sets the publisher family /// from the card; there is no snapshot collapse, because catalog slugs diff --git a/crates/gateway/cloud-providers/src/providers/foundry.rs b/crates/gateway/cloud-providers/src/providers/foundry.rs index e4f275e6e..21c91e0d8 100644 --- a/crates/gateway/cloud-providers/src/providers/foundry.rs +++ b/crates/gateway/cloud-providers/src/providers/foundry.rs @@ -25,7 +25,7 @@ //! from live responses on 2026-09-15. A request naming an unknown //! filter field gets the valid ones back in the error body. -use gateway_api::{ModelEntry, Tier}; +use gateway_api_types::{ModelEntry, Tier}; use serde::Deserialize; use time::format_description::well_known::Rfc3339; use time::{Date, OffsetDateTime}; @@ -61,7 +61,7 @@ const PAGE_SIZE: u32 = 100; /// opposed to a mirrored registry entry that is merely listed. const HOSTED_OFFER: &str = "standard-paygo"; -/// Fetch and normalize Foundry's catalog, following the continuation +/// Fetches and normalizes Foundry's catalog, following the continuation /// token until the final page; the endpoint is keyless, so no /// credential is read or sent. pub(crate) async fn fetch( @@ -172,7 +172,7 @@ struct WireDeprecation { inference_retirement_date: Option, } -/// Normalize one wire card into a sheet entry. The catalog reports no +/// Normalizes one wire card into a sheet entry. The catalog reports no /// pricing, so that field stays empty. fn normalize_model(model: &WireModel) -> ModelEntry { let mut entry = base_entry(&model.name, None); @@ -218,7 +218,7 @@ fn normalize_model(model: &WireModel) -> ModelEntry { entry } -/// Parse a wire timestamp into a calendar date; an unparseable value +/// Parses a wire timestamp into a calendar date; an unparseable value /// keeps no date. fn parse_wire_date(value: &str) -> Option { OffsetDateTime::parse(value, &Rfc3339) @@ -228,7 +228,7 @@ fn parse_wire_date(value: &str) -> Option { #[cfg(test)] mod tests { - use gateway_api::ModelKind; + use gateway_api_types::ModelKind; use time::Month; use super::*; diff --git a/crates/gateway/cloud-providers/src/providers/gemini.rs b/crates/gateway/cloud-providers/src/providers/gemini.rs index 5cf558ce2..1906fa570 100644 --- a/crates/gateway/cloud-providers/src/providers/gemini.rs +++ b/crates/gateway/cloud-providers/src/providers/gemini.rs @@ -6,7 +6,7 @@ //! //! Docs: -use gateway_api::{EnvRole, ModelEntry, ModelKind, Thinking, Tier}; +use gateway_api_types::{EnvRole, ModelEntry, ModelKind, Thinking, Tier}; use serde::Deserialize; use crate::{EnvVarSpec, FetchError, Provider}; @@ -34,7 +34,7 @@ pub const PROVIDER: Provider = Provider { /// in one page today and pagination only engages as the lineup grows. const PAGE_SIZE: u32 = 1000; -/// Fetch and normalize Gemini's model list, following `nextPageToken` +/// Fetches and normalizes Gemini's model list, following `nextPageToken` /// until the final page. pub(crate) async fn fetch( client: &reqwest::Client, @@ -150,7 +150,7 @@ fn family_of(id: &str) -> String { id.split('-').next().unwrap_or(id).to_owned() } -/// Set every entry's family, then collapse `-MM-YYYY` preview snapshots +/// Sets every entry's family, then collapses `-MM-YYYY` preview snapshots /// onto their canonical entries. pub(crate) fn apply_taxonomy(entries: &mut [ModelEntry]) { for entry in entries.iter_mut() { @@ -161,7 +161,7 @@ pub(crate) fn apply_taxonomy(entries: &mut [ModelEntry]) { }); } -/// Normalize one wire model into a sheet entry. +/// Normalizes one wire model into a sheet entry. fn normalize_model(model: &WireModel) -> ModelEntry { let methods = &model.supported_generation_methods; let generates = methods.iter().any(|m| m == "generateContent"); diff --git a/crates/gateway/cloud-providers/src/providers/groq.rs b/crates/gateway/cloud-providers/src/providers/groq.rs index 24d0b6bff..12049431e 100644 --- a/crates/gateway/cloud-providers/src/providers/groq.rs +++ b/crates/gateway/cloud-providers/src/providers/groq.rs @@ -7,7 +7,7 @@ //! //! Docs: -use gateway_api::{EnvRole, ModelEntry, ModelKind, Tier}; +use gateway_api_types::{EnvRole, ModelEntry, ModelKind, Tier}; use serde::Deserialize; use crate::providers::openai_shape::{base_entry, fetch_list}; @@ -35,7 +35,7 @@ pub const PROVIDER: Provider = Provider { /// The list path under the base URL. const MODELS_PATH: &str = "/models"; -/// Fetch and normalize Groq's model list in a single request. +/// Fetches and normalizes Groq's model list in a single request. pub(crate) async fn fetch( client: &reqwest::Client, base_url: &str, @@ -61,7 +61,7 @@ struct WireModel { created: Option, } -/// Normalize one wire model: the endpoint is IDs-only, so the entry is +/// Normalizes one wire model: the endpoint is IDs-only, so the entry is /// the conservative base; the `whisper-*` family is speech-to-text. fn normalize_model(model: &WireModel) -> ModelEntry { let mut entry = base_entry(&model.id, model.created); @@ -92,7 +92,7 @@ fn family_of(id: &str) -> String { id.to_owned() } -/// Set every entry's family. +/// Sets every entry's family. pub(crate) fn apply_taxonomy(entries: &mut [ModelEntry]) { for entry in entries.iter_mut() { entry.family = family_of(&entry.id); @@ -214,7 +214,7 @@ mod tests { ); assert_eq!(PROVIDER.env_vars.len(), 1); assert_eq!(PROVIDER.env_vars[0].name, KEY_ENV); - assert_eq!(PROVIDER.env_vars[0].role, gateway_api::EnvRole::Key); + assert_eq!(PROVIDER.env_vars[0].role, gateway_api_types::EnvRole::Key); assert_eq!(PROVIDER.env_vars[0].default, None); } } diff --git a/crates/gateway/cloud-providers/src/providers/leonardo.rs b/crates/gateway/cloud-providers/src/providers/leonardo.rs index 629e380b8..1ec50cb70 100644 --- a/crates/gateway/cloud-providers/src/providers/leonardo.rs +++ b/crates/gateway/cloud-providers/src/providers/leonardo.rs @@ -9,7 +9,7 @@ //! //! Docs: -use gateway_api::{EnvRole, ModelEntry, ModelKind, Tier}; +use gateway_api_types::{EnvRole, ModelEntry, ModelKind, Tier}; use serde::Deserialize; use crate::providers::openai_shape::base_entry; @@ -37,7 +37,7 @@ pub const PROVIDER: Provider = Provider { /// The list path under the base URL. const MODELS_PATH: &str = "/platformModels"; -/// Fetch and normalize Leonardo's platform model list in a single +/// Fetches and normalizes Leonardo's platform model list in a single /// request. pub(crate) async fn fetch( client: &reqwest::Client, @@ -77,7 +77,7 @@ struct WireModel { name: Option, } -/// Normalize one wire model: every platform model is image generation, +/// Normalizes one wire model: every platform model is image generation, /// so the entry is the conservative base with the image kind. fn normalize_model(model: &WireModel) -> ModelEntry { let mut entry = base_entry(&model.id, None); @@ -88,7 +88,7 @@ fn normalize_model(model: &WireModel) -> ModelEntry { entry } -/// Set every entry's family: platform model ids are UUIDs, so the +/// Sets every entry's family: platform model ids are UUIDs, so the /// display name - the catalog's only stable label - is the family, /// falling back to the id when the wire reports no name. There is no /// snapshot collapse. @@ -180,7 +180,7 @@ mod tests { assert_eq!(PROVIDER.openai_base_url, None); assert_eq!(PROVIDER.env_vars.len(), 1); assert_eq!(PROVIDER.env_vars[0].name, KEY_ENV); - assert_eq!(PROVIDER.env_vars[0].role, gateway_api::EnvRole::Key); + assert_eq!(PROVIDER.env_vars[0].role, gateway_api_types::EnvRole::Key); assert_eq!(PROVIDER.env_vars[0].default, None); } } diff --git a/crates/gateway/cloud-providers/src/providers/meta.rs b/crates/gateway/cloud-providers/src/providers/meta.rs index d52d0e22a..ed6f7c9b9 100644 --- a/crates/gateway/cloud-providers/src/providers/meta.rs +++ b/crates/gateway/cloud-providers/src/providers/meta.rs @@ -6,7 +6,7 @@ //! //! Docs: -use gateway_api::{EnvRole, ModelEntry, Tier}; +use gateway_api_types::{EnvRole, ModelEntry, Tier}; use serde::Deserialize; use crate::providers::openai_shape::{base_entry, fetch_list}; @@ -34,7 +34,7 @@ pub const PROVIDER: Provider = Provider { /// The list path under the base URL. const MODELS_PATH: &str = "/models"; -/// Fetch and normalize Meta's model list in a single request. +/// Fetches and normalizes Meta's model list in a single request. pub(crate) async fn fetch( client: &reqwest::Client, base_url: &str, @@ -60,7 +60,7 @@ struct WireModel { created: Option, } -/// Normalize one wire model: the response schema is not fully enumerated, +/// Normalizes one wire model: the response schema is not fully enumerated, /// so the entry is the conservative base. fn normalize_model(model: &WireModel) -> ModelEntry { base_entry(&model.id, model.created) @@ -80,7 +80,7 @@ fn family_of(id: &str) -> String { format!("{first}-{second}") } -/// Set every entry's family. +/// Sets every entry's family. pub(crate) fn apply_taxonomy(entries: &mut [ModelEntry]) { for entry in entries.iter_mut() { entry.family = family_of(&entry.id); diff --git a/crates/gateway/cloud-providers/src/providers/minimax.rs b/crates/gateway/cloud-providers/src/providers/minimax.rs index bb90c0cc3..5b27ed8ad 100644 --- a/crates/gateway/cloud-providers/src/providers/minimax.rs +++ b/crates/gateway/cloud-providers/src/providers/minimax.rs @@ -7,7 +7,7 @@ //! //! Docs: -use gateway_api::{EnvRole, ModelEntry, Tier}; +use gateway_api_types::{EnvRole, ModelEntry, Tier}; use serde::Deserialize; use crate::providers::openai_shape::{base_entry, fetch_list}; @@ -35,7 +35,7 @@ pub const PROVIDER: Provider = Provider { /// The list path under the base URL. const MODELS_PATH: &str = "/models"; -/// Fetch and normalize MiniMax's model list in a single request. +/// Fetches and normalizes MiniMax's model list in a single request. pub(crate) async fn fetch( client: &reqwest::Client, base_url: &str, @@ -61,7 +61,7 @@ struct WireModel { created: Option, } -/// Normalize one wire model: the endpoint is IDs-only, so the entry is +/// Normalizes one wire model: the endpoint is IDs-only, so the entry is /// the conservative base. fn normalize_model(model: &WireModel) -> ModelEntry { base_entry(&model.id, model.created) @@ -87,7 +87,7 @@ fn family_of(id: &str) -> String { id.to_owned() } -/// Set every entry's family. +/// Sets every entry's family. pub(crate) fn apply_taxonomy(entries: &mut [ModelEntry]) { for entry in entries.iter_mut() { entry.family = family_of(&entry.id); @@ -136,7 +136,7 @@ mod tests { let entry = &entries[0]; assert_eq!(entry.id, "MiniMax-M3"); assert_eq!(entry.display_name, "MiniMax-M3"); - assert_eq!(entry.kind, gateway_api::ModelKind::Chat); + assert_eq!(entry.kind, gateway_api_types::ModelKind::Chat); assert_eq!(entry.context_window, None, "IDs-only providers omit limits"); assert_eq!(entry.max_output, None); assert!(!entry.images && !entry.tool_calling && !entry.thinking.supported); @@ -209,7 +209,7 @@ mod tests { assert_eq!(PROVIDER.openai_base_url, Some("https://api.minimax.io/v1")); assert_eq!(PROVIDER.env_vars.len(), 1); assert_eq!(PROVIDER.env_vars[0].name, KEY_ENV); - assert_eq!(PROVIDER.env_vars[0].role, gateway_api::EnvRole::Key); + assert_eq!(PROVIDER.env_vars[0].role, gateway_api_types::EnvRole::Key); assert_eq!(PROVIDER.env_vars[0].default, None); } } diff --git a/crates/gateway/cloud-providers/src/providers/mistral-taxonomy.rs b/crates/gateway/cloud-providers/src/providers/mistral-taxonomy.rs index 7a38d11df..1b4d292c9 100644 --- a/crates/gateway/cloud-providers/src/providers/mistral-taxonomy.rs +++ b/crates/gateway/cloud-providers/src/providers/mistral-taxonomy.rs @@ -3,7 +3,7 @@ //! id is in the same list. Mistral's rules live in this sibling module //! so the provider file stays under the workspace's 500-line ceiling. -use gateway_api::ModelEntry; +use gateway_api_types::ModelEntry; use crate::taxonomy::{SnapshotStyle, collapse_variants, strip_snapshot}; @@ -34,7 +34,7 @@ fn family_of(id: &str) -> String { id.to_owned() } -/// Set every entry's family, then collapse `-YYMM` snapshot suffixes +/// Sets every entry's family, then collapses `-YYMM` snapshot suffixes /// onto their canonical entries. pub(crate) fn apply(entries: &mut [ModelEntry]) { for entry in entries.iter_mut() { @@ -47,7 +47,7 @@ pub(crate) fn apply(entries: &mut [ModelEntry]) { mod tests { use std::collections::BTreeMap; - use gateway_api::ModelEntry; + use gateway_api_types::ModelEntry; use super::apply; diff --git a/crates/gateway/cloud-providers/src/providers/mistral.rs b/crates/gateway/cloud-providers/src/providers/mistral.rs index b0a3663f7..4abb9e7d1 100644 --- a/crates/gateway/cloud-providers/src/providers/mistral.rs +++ b/crates/gateway/cloud-providers/src/providers/mistral.rs @@ -10,7 +10,7 @@ //! //! Docs: -use gateway_api::{Deprecation, EnvRole, ModelEntry, ModelKind, Tier}; +use gateway_api_types::{Deprecation, EnvRole, ModelEntry, ModelKind, Tier}; use serde::Deserialize; use time::format_description::well_known::Rfc3339; use time::{Date, Month, OffsetDateTime}; @@ -43,7 +43,7 @@ pub const PROVIDER: Provider = Provider { /// The list path under the base URL. const MODELS_PATH: &str = "/v1/models"; -/// Fetch and normalize Mistral's model list in a single request. +/// Fetches and normalizes Mistral's model list in a single request. pub(crate) async fn fetch( client: &reqwest::Client, base_url: &str, @@ -86,7 +86,7 @@ struct WireCapabilities { vision: Option, } -/// Normalize one wire model into a sheet entry. +/// Normalizes one wire model into a sheet entry. fn normalize_model(model: &WireModel) -> ModelEntry { let mut entry = base_entry(&model.id, model.created); if let Some(name) = &model.name { @@ -112,7 +112,7 @@ fn normalize_model(model: &WireModel) -> ModelEntry { entry } -/// Parse the deprecation timestamp: RFC 3339 first, then a bare +/// Parses the deprecation timestamp: RFC 3339 first, then a bare /// `YYYY-MM-DD` calendar date; an unparseable value keeps the status /// with no date. fn parse_deprecation_date(value: &str) -> Option { @@ -244,7 +244,7 @@ mod tests { entry.display_name, "Mistral Large 3", "the card's name is the display name" ); - assert_eq!(entry.kind, gateway_api::ModelKind::Chat); + assert_eq!(entry.kind, gateway_api_types::ModelKind::Chat); assert_eq!( entry.released_at, Date::from_calendar_date(2026, Month::January, 1).ok(), @@ -268,7 +268,7 @@ mod tests { assert_eq!(entry.id, "codestral-latest"); assert_eq!( entry.kind, - gateway_api::ModelKind::Chat, + gateway_api_types::ModelKind::Chat, "completion_fim has no sheet field; the kind stays chat" ); assert!(!entry.tool_calling); @@ -341,18 +341,30 @@ mod tests { #[test] fn name_patterns_infer_the_kind() { - let table: &[(&str, gateway_api::ModelKind)] = &[ - ("voxtral-mini-tts-latest", gateway_api::ModelKind::Speech), - ("voxtral-mini-tts-2603", gateway_api::ModelKind::Speech), + let table: &[(&str, gateway_api_types::ModelKind)] = &[ + ( + "voxtral-mini-tts-latest", + gateway_api_types::ModelKind::Speech, + ), + ( + "voxtral-mini-tts-2603", + gateway_api_types::ModelKind::Speech, + ), ( "voxtral-mini-transcribe-realtime-2602", - gateway_api::ModelKind::Transcription, + gateway_api_types::ModelKind::Transcription, + ), + ("mistral-embed", gateway_api_types::ModelKind::Embedding), + ( + "codestral-embed-2505", + gateway_api_types::ModelKind::Embedding, + ), + ( + "mistral-ocr-latest", + gateway_api_types::ModelKind::Classifier, ), - ("mistral-embed", gateway_api::ModelKind::Embedding), - ("codestral-embed-2505", gateway_api::ModelKind::Embedding), - ("mistral-ocr-latest", gateway_api::ModelKind::Classifier), - ("mistral-small-latest", gateway_api::ModelKind::Chat), - ("codestral-latest", gateway_api::ModelKind::Chat), + ("mistral-small-latest", gateway_api_types::ModelKind::Chat), + ("codestral-latest", gateway_api_types::ModelKind::Chat), ]; for &(id, kind) in table { assert_eq!(kind_of(id), kind, "{id}"); @@ -375,7 +387,7 @@ mod tests { let entry = normalize_model(&page.data[0]); assert_eq!( entry.kind, - gateway_api::ModelKind::Speech, + gateway_api_types::ModelKind::Speech, "the wire card carries no kind; the name rule supplies it" ); } @@ -385,7 +397,7 @@ mod tests { assert_eq!(PROVIDER.openai_base_url, Some("https://api.mistral.ai/v1")); assert_eq!(PROVIDER.env_vars.len(), 1); assert_eq!(PROVIDER.env_vars[0].name, KEY_ENV); - assert_eq!(PROVIDER.env_vars[0].role, gateway_api::EnvRole::Key); + assert_eq!(PROVIDER.env_vars[0].role, gateway_api_types::EnvRole::Key); assert_eq!(PROVIDER.env_vars[0].default, None); } } diff --git a/crates/gateway/cloud-providers/src/providers/moonshot.rs b/crates/gateway/cloud-providers/src/providers/moonshot.rs index 204151bf2..02809ac53 100644 --- a/crates/gateway/cloud-providers/src/providers/moonshot.rs +++ b/crates/gateway/cloud-providers/src/providers/moonshot.rs @@ -6,7 +6,7 @@ //! //! Docs: -use gateway_api::{EnvRole, ModelEntry, ModelKind, Tier}; +use gateway_api_types::{EnvRole, ModelEntry, ModelKind, Tier}; use serde::Deserialize; use crate::providers::openai_shape::{base_entry, fetch_list}; @@ -34,7 +34,7 @@ pub const PROVIDER: Provider = Provider { /// The list path under the base URL. const MODELS_PATH: &str = "/models"; -/// Fetch and normalize Moonshot's model list in a single request. +/// Fetches and normalizes Moonshot's model list in a single request. pub(crate) async fn fetch( client: &reqwest::Client, base_url: &str, @@ -65,7 +65,7 @@ struct WireModel { supports_reasoning: Option, } -/// Normalize one wire model into a sheet entry. +/// Normalizes one wire model into a sheet entry. fn normalize_model(model: &WireModel) -> ModelEntry { let mut entry = base_entry(&model.id, model.created); entry.kind = kind_of(&model.id); @@ -119,7 +119,7 @@ fn family_of(id: &str) -> String { id.to_owned() } -/// Set every entry's family. +/// Sets every entry's family. pub(crate) fn apply_taxonomy(entries: &mut [ModelEntry]) { for entry in entries.iter_mut() { entry.family = family_of(&entry.id); @@ -259,12 +259,12 @@ mod tests { #[test] fn name_patterns_infer_the_kind() { - let table: &[(&str, gateway_api::ModelKind)] = &[ - ("kimi-k2.6", gateway_api::ModelKind::Chat), - ("moonshot-v1-8k", gateway_api::ModelKind::Chat), - ("kimi-tts-1", gateway_api::ModelKind::Speech), - ("kimi-asr-1", gateway_api::ModelKind::Transcription), - ("kimi-image-1", gateway_api::ModelKind::Image), + let table: &[(&str, gateway_api_types::ModelKind)] = &[ + ("kimi-k2.6", gateway_api_types::ModelKind::Chat), + ("moonshot-v1-8k", gateway_api_types::ModelKind::Chat), + ("kimi-tts-1", gateway_api_types::ModelKind::Speech), + ("kimi-asr-1", gateway_api_types::ModelKind::Transcription), + ("kimi-image-1", gateway_api_types::ModelKind::Image), ]; for &(id, kind) in table { assert_eq!(kind_of(id), kind, "{id}"); @@ -288,7 +288,7 @@ mod tests { ); assert_eq!( entries[0].kind, - gateway_api::ModelKind::Speech, + gateway_api_types::ModelKind::Speech, "the endpoint reports no kind; the name rule supplies it" ); } @@ -298,7 +298,7 @@ mod tests { assert_eq!(PROVIDER.openai_base_url, Some("https://api.moonshot.ai/v1")); assert_eq!(PROVIDER.env_vars.len(), 1); assert_eq!(PROVIDER.env_vars[0].name, KEY_ENV); - assert_eq!(PROVIDER.env_vars[0].role, gateway_api::EnvRole::Key); + assert_eq!(PROVIDER.env_vars[0].role, gateway_api_types::EnvRole::Key); assert_eq!(PROVIDER.env_vars[0].default, None); } } diff --git a/crates/gateway/cloud-providers/src/providers/nvidia.rs b/crates/gateway/cloud-providers/src/providers/nvidia.rs index 457f341bb..dea266cfd 100644 --- a/crates/gateway/cloud-providers/src/providers/nvidia.rs +++ b/crates/gateway/cloud-providers/src/providers/nvidia.rs @@ -8,7 +8,7 @@ //! //! Docs: -use gateway_api::{ModelEntry, Tier}; +use gateway_api_types::{ModelEntry, Tier}; use serde::Deserialize; use crate::providers::openai_shape::{ListResponse, base_entry}; @@ -29,7 +29,7 @@ pub const PROVIDER: Provider = Provider { /// The list path under the base URL. const MODELS_PATH: &str = "/models"; -/// Fetch and normalize NVIDIA's model list in a single request; the +/// Fetches and normalizes NVIDIA's model list in a single request; the /// endpoint is keyless, so no credential is read or sent. pub(crate) async fn fetch( client: &reqwest::Client, @@ -57,13 +57,13 @@ struct WireModel { id: String, } -/// Normalize one wire model: the endpoint is IDs-only, so the entry is +/// Normalizes one wire model: the endpoint is IDs-only, so the entry is /// the conservative base with no release date. fn normalize_model(model: &WireModel) -> ModelEntry { base_entry(&model.id, None) } -/// Set every entry's family to the vendor prefix of its +/// Sets every entry's family to the vendor prefix of its /// `vendor/model` id (`meta`, `nvidia`, `google`, ...), and to the /// whole id when there is no slash. There is no snapshot or SKU /// collapse: the catalog carries neither. @@ -121,7 +121,7 @@ mod tests { entry.display_name, "meta/llama-3.1-8b-instruct", "the id doubles as the display name" ); - assert_eq!(entry.kind, gateway_api::ModelKind::Chat); + assert_eq!(entry.kind, gateway_api_types::ModelKind::Chat); assert_eq!(entry.context_window, None, "IDs-only providers omit limits"); assert_eq!(entry.max_output, None); assert!(!entry.images && !entry.tool_calling && !entry.thinking.supported); diff --git a/crates/gateway/cloud-providers/src/providers/openai.rs b/crates/gateway/cloud-providers/src/providers/openai.rs index 3169a9bf9..2522ec8bc 100644 --- a/crates/gateway/cloud-providers/src/providers/openai.rs +++ b/crates/gateway/cloud-providers/src/providers/openai.rs @@ -5,7 +5,7 @@ //! //! Docs: -use gateway_api::{EnvRole, ModelEntry, ModelKind, Tier}; +use gateway_api_types::{EnvRole, ModelEntry, ModelKind, Tier}; use serde::Deserialize; use crate::providers::openai_shape::{base_entry, fetch_list}; @@ -33,7 +33,7 @@ pub const PROVIDER: Provider = Provider { /// The list path under the base URL. const MODELS_PATH: &str = "/models"; -/// Fetch and normalize OpenAI's model list in a single request. +/// Fetches and normalizes OpenAI's model list in a single request. pub(crate) async fn fetch( client: &reqwest::Client, base_url: &str, @@ -60,7 +60,7 @@ struct WireModel { created: Option, } -/// Normalize one wire model: the endpoint reports no capabilities, so +/// Normalizes one wire model: the endpoint reports no capabilities, so /// the entry is the conservative base plus the name-based kind. fn normalize_model(model: &WireModel) -> ModelEntry { let mut entry = base_entry(&model.id, model.created); @@ -129,7 +129,7 @@ fn family_of(id: &str) -> String { id.to_owned() } -/// Set every entry's family, then collapse `-YYYY-MM-DD` and `-MMDD` +/// Sets every entry's family, then collapses `-YYYY-MM-DD` and `-MMDD` /// dated snapshots onto their canonical entries. pub(crate) fn apply_taxonomy(entries: &mut [ModelEntry]) { for entry in entries.iter_mut() { diff --git a/crates/gateway/cloud-providers/src/providers/openai_shape.rs b/crates/gateway/cloud-providers/src/providers/openai_shape.rs index 7a4880966..18d79f879 100644 --- a/crates/gateway/cloud-providers/src/providers/openai_shape.rs +++ b/crates/gateway/cloud-providers/src/providers/openai_shape.rs @@ -5,7 +5,7 @@ //! only the envelope, the single-request fetch, and the conservative base //! entry every dialect entry starts from. -use gateway_api::{ModelEntry, ModelKind, Thinking}; +use gateway_api_types::{ModelEntry, ModelKind, Thinking}; use serde::Deserialize; use serde::de::DeserializeOwned; use time::OffsetDateTime; @@ -19,7 +19,7 @@ pub(crate) struct ListResponse { pub data: Vec, } -/// Fetch the whole list in one request; the dialect has no pagination. +/// Fetches the whole list in one request; the dialect has no pagination. pub(crate) async fn fetch_list( client: &reqwest::Client, url: &str, diff --git a/crates/gateway/cloud-providers/src/providers/openrouter-taxonomy.rs b/crates/gateway/cloud-providers/src/providers/openrouter-taxonomy.rs index 7fc3d2b56..5af36a4a8 100644 --- a/crates/gateway/cloud-providers/src/providers/openrouter-taxonomy.rs +++ b/crates/gateway/cloud-providers/src/providers/openrouter-taxonomy.rs @@ -5,7 +5,7 @@ //! this sibling module so the provider file stays under the workspace's //! 500-line ceiling. -use gateway_api::{ModelEntry, ModelKind}; +use gateway_api_types::{ModelEntry, ModelKind}; use crate::taxonomy::{collapse_variants, sku_suffix, vendor_prefix}; @@ -32,7 +32,7 @@ pub(crate) fn model_kind(output_modalities: &[String]) -> ModelKind { ModelKind::Chat } -/// Set every entry's family, then collapse `:free`/`:batch` SKU +/// Sets every entry's family, then collapses `:free`/`:batch` SKU /// suffixes onto their canonical entries. pub(crate) fn apply(entries: &mut [ModelEntry]) { for entry in entries.iter_mut() { @@ -45,7 +45,7 @@ pub(crate) fn apply(entries: &mut [ModelEntry]) { mod tests { use std::collections::BTreeMap; - use gateway_api::{ModelEntry, ModelKind}; + use gateway_api_types::{ModelEntry, ModelKind}; use super::{apply, model_kind}; diff --git a/crates/gateway/cloud-providers/src/providers/openrouter.rs b/crates/gateway/cloud-providers/src/providers/openrouter.rs index 22c37bf2e..511685190 100644 --- a/crates/gateway/cloud-providers/src/providers/openrouter.rs +++ b/crates/gateway/cloud-providers/src/providers/openrouter.rs @@ -10,7 +10,7 @@ //! //! Docs: -use gateway_api::{Deprecation, ModelEntry, Pricing, Tier}; +use gateway_api_types::{Deprecation, ModelEntry, Pricing, Tier}; use serde::Deserialize; use time::{Date, Month}; @@ -35,7 +35,7 @@ pub const PROVIDER: Provider = Provider { /// The list path under the base URL. const MODELS_PATH: &str = "/api/v1/models"; -/// Fetch and normalize OpenRouter's model list in a single request; the +/// Fetches and normalizes OpenRouter's model list in a single request; the /// endpoint is keyless, so no credential is read or sent. pub(crate) async fn fetch( client: &reqwest::Client, @@ -106,7 +106,7 @@ fn price_per_mtok(per_token: &str) -> Option { .map(|price| price * 1_000_000.0) } -/// Parse the `YYYY-MM-DD` expiration date; an unparseable value keeps +/// Parses the `YYYY-MM-DD` expiration date; an unparseable value keeps /// the status with no date. fn parse_expiration_date(value: &str) -> Option { let mut parts = value.split('-'); @@ -120,7 +120,7 @@ fn parse_expiration_date(value: &str) -> Option { Date::from_calendar_date(year, Month::try_from(month).ok()?, day).ok() } -/// Normalize one wire model into a sheet entry. +/// Normalizes one wire model into a sheet entry. fn normalize_model(model: &WireModel) -> ModelEntry { let mut entry = base_entry(&model.id, model.created); if let Some(name) = &model.name { @@ -174,7 +174,7 @@ fn normalize_model(model: &WireModel) -> ModelEntry { #[cfg(test)] mod tests { - use gateway_api::ModelKind; + use gateway_api_types::ModelKind; use time::{Date, Month}; use super::*; diff --git a/crates/gateway/cloud-providers/src/providers/qwen.rs b/crates/gateway/cloud-providers/src/providers/qwen.rs index 29e59660c..9e326ad7d 100644 --- a/crates/gateway/cloud-providers/src/providers/qwen.rs +++ b/crates/gateway/cloud-providers/src/providers/qwen.rs @@ -7,7 +7,7 @@ //! //! Docs: -use gateway_api::{EnvRole, ModelEntry, ModelKind, Tier}; +use gateway_api_types::{EnvRole, ModelEntry, ModelKind, Tier}; use serde::Deserialize; use crate::providers::openai_shape::{base_entry, fetch_list}; @@ -35,7 +35,7 @@ pub const PROVIDER: Provider = Provider { /// The list path under the base URL. const MODELS_PATH: &str = "/models"; -/// Fetch and normalize Qwen's model list in a single request. +/// Fetches and normalizes Qwen's model list in a single request. pub(crate) async fn fetch( client: &reqwest::Client, base_url: &str, @@ -61,7 +61,7 @@ struct WireModel { created: Option, } -/// Normalize one wire model: the compatible-mode endpoint is IDs-only, +/// Normalizes one wire model: the compatible-mode endpoint is IDs-only, /// so the entry is the conservative base plus the name-based kind. fn normalize_model(model: &WireModel) -> ModelEntry { let mut entry = base_entry(&model.id, model.created); @@ -127,7 +127,7 @@ fn family_of(id: &str) -> String { id.to_owned() } -/// Set every entry's family, then collapse dated snapshots onto their +/// Sets every entry's family, then collapses dated snapshots onto their /// canonical entries. DashScope uses `-YYYY-MM-DD`, `-MMDD`, and /// `-YYMM` suffixes; the four-digit ambiguity resolves as month-day /// first, then year-month. @@ -292,24 +292,27 @@ mod tests { #[test] fn name_patterns_infer_the_kind() { - let table: &[(&str, gateway_api::ModelKind)] = &[ - ("qwen3-tts-flash", gateway_api::ModelKind::Speech), + let table: &[(&str, gateway_api_types::ModelKind)] = &[ + ("qwen3-tts-flash", gateway_api_types::ModelKind::Speech), ( "qwen3-asr-flash-realtime", - gateway_api::ModelKind::Transcription, + gateway_api_types::ModelKind::Transcription, ), ( "qwen-audio-3.0-asr-flash", - gateway_api::ModelKind::Transcription, + gateway_api_types::ModelKind::Transcription, ), - ("qwen-image-2.0", gateway_api::ModelKind::Image), - ("wan2.7-image", gateway_api::ModelKind::Image), - ("z-image-turbo", gateway_api::ModelKind::Image), - ("text-embedding-v4", gateway_api::ModelKind::Embedding), - ("qwen3.7-text-embedding", gateway_api::ModelKind::Embedding), - ("qwen3-max", gateway_api::ModelKind::Chat), - ("qwen-vl-ocr-2025-11-20", gateway_api::ModelKind::Chat), - ("qwen-mt-flash", gateway_api::ModelKind::Chat), + ("qwen-image-2.0", gateway_api_types::ModelKind::Image), + ("wan2.7-image", gateway_api_types::ModelKind::Image), + ("z-image-turbo", gateway_api_types::ModelKind::Image), + ("text-embedding-v4", gateway_api_types::ModelKind::Embedding), + ( + "qwen3.7-text-embedding", + gateway_api_types::ModelKind::Embedding, + ), + ("qwen3-max", gateway_api_types::ModelKind::Chat), + ("qwen-vl-ocr-2025-11-20", gateway_api_types::ModelKind::Chat), + ("qwen-mt-flash", gateway_api_types::ModelKind::Chat), ]; for &(id, kind) in table { assert_eq!(kind_of(id), kind, "{id}"); @@ -331,7 +334,7 @@ mod tests { let entry = normalize_model(&page.data[0]); assert_eq!( entry.kind, - gateway_api::ModelKind::Speech, + gateway_api_types::ModelKind::Speech, "the compatible-mode endpoint reports no kind; the name rule supplies it" ); } @@ -344,7 +347,7 @@ mod tests { ); assert_eq!(PROVIDER.env_vars.len(), 1); assert_eq!(PROVIDER.env_vars[0].name, KEY_ENV); - assert_eq!(PROVIDER.env_vars[0].role, gateway_api::EnvRole::Key); + assert_eq!(PROVIDER.env_vars[0].role, gateway_api_types::EnvRole::Key); assert_eq!(PROVIDER.env_vars[0].default, None); } } diff --git a/crates/gateway/cloud-providers/src/providers/soniox.rs b/crates/gateway/cloud-providers/src/providers/soniox.rs index 937eef99c..00ee844e3 100644 --- a/crates/gateway/cloud-providers/src/providers/soniox.rs +++ b/crates/gateway/cloud-providers/src/providers/soniox.rs @@ -10,7 +10,7 @@ //! Docs: (OpenAPI: //! ) -use gateway_api::{EnvRole, ModelEntry, ModelKind, Tier}; +use gateway_api_types::{EnvRole, ModelEntry, ModelKind, Tier}; use serde::Deserialize; use crate::providers::openai_shape::base_entry; @@ -38,7 +38,7 @@ pub const PROVIDER: Provider = Provider { /// The list path under the base URL. const MODELS_PATH: &str = "/v1/models"; -/// Fetch and normalize Soniox's model list in a single request. +/// Fetches and normalizes Soniox's model list in a single request. pub(crate) async fn fetch( client: &reqwest::Client, base_url: &str, @@ -85,7 +85,7 @@ struct WireLanguage { code: String, } -/// Normalize one wire model: every Soniox model is speech-to-text, so +/// Normalizes one wire model: every Soniox model is speech-to-text, so /// the entry is the conservative base with the transcription kind and /// the wire's language codes. fn normalize_model(model: &WireModel) -> ModelEntry { @@ -116,7 +116,7 @@ fn family_of(id: &str) -> String { id.to_owned() } -/// Set every entry's family. Soniox's catalog carries no snapshot +/// Sets every entry's family. Soniox's catalog carries no snapshot /// suffixes, so there is no collapse pass. pub(crate) fn apply_taxonomy(entries: &mut [ModelEntry]) { for entry in entries.iter_mut() { @@ -235,7 +235,7 @@ mod tests { assert_eq!(PROVIDER.openai_base_url, None); assert_eq!(PROVIDER.env_vars.len(), 1); assert_eq!(PROVIDER.env_vars[0].name, KEY_ENV); - assert_eq!(PROVIDER.env_vars[0].role, gateway_api::EnvRole::Key); + assert_eq!(PROVIDER.env_vars[0].role, gateway_api_types::EnvRole::Key); assert_eq!(PROVIDER.env_vars[0].default, None); } } diff --git a/crates/gateway/cloud-providers/src/providers/stepfun.rs b/crates/gateway/cloud-providers/src/providers/stepfun.rs index cf78a7a11..742aea00f 100644 --- a/crates/gateway/cloud-providers/src/providers/stepfun.rs +++ b/crates/gateway/cloud-providers/src/providers/stepfun.rs @@ -8,7 +8,7 @@ //! //! Docs: -use gateway_api::{EnvRole, ModelEntry, Tier}; +use gateway_api_types::{EnvRole, ModelEntry, Tier}; use serde::Deserialize; use crate::providers::openai_shape::{base_entry, fetch_list}; @@ -36,7 +36,7 @@ pub const PROVIDER: Provider = Provider { /// The list path under the base URL. const MODELS_PATH: &str = "/models"; -/// Fetch and normalize StepFun's model list in a single request. +/// Fetches and normalizes StepFun's model list in a single request. pub(crate) async fn fetch( client: &reqwest::Client, base_url: &str, @@ -62,7 +62,7 @@ struct WireModel { created: Option, } -/// Normalize one wire model: the endpoint is IDs-only, so the entry is +/// Normalizes one wire model: the endpoint is IDs-only, so the entry is /// the conservative base. fn normalize_model(model: &WireModel) -> ModelEntry { base_entry(&model.id, model.created) @@ -83,7 +83,7 @@ fn family_of(id: &str) -> String { id.to_owned() } -/// Set every entry's family. +/// Sets every entry's family. pub(crate) fn apply_taxonomy(entries: &mut [ModelEntry]) { for entry in entries.iter_mut() { entry.family = family_of(&entry.id); @@ -199,7 +199,7 @@ mod tests { assert_eq!(PROVIDER.openai_base_url, Some("https://api.stepfun.ai/v1")); assert_eq!(PROVIDER.env_vars.len(), 1); assert_eq!(PROVIDER.env_vars[0].name, KEY_ENV); - assert_eq!(PROVIDER.env_vars[0].role, gateway_api::EnvRole::Key); + assert_eq!(PROVIDER.env_vars[0].role, gateway_api_types::EnvRole::Key); assert_eq!(PROVIDER.env_vars[0].default, None); } } diff --git a/crates/gateway/cloud-providers/src/providers/xai.rs b/crates/gateway/cloud-providers/src/providers/xai.rs index 6fd444f61..212728713 100644 --- a/crates/gateway/cloud-providers/src/providers/xai.rs +++ b/crates/gateway/cloud-providers/src/providers/xai.rs @@ -6,7 +6,7 @@ //! //! Docs: -use gateway_api::{EnvRole, ModelEntry, Pricing, Tier}; +use gateway_api_types::{EnvRole, ModelEntry, Pricing, Tier}; use serde::Deserialize; use crate::providers::openai_shape::{base_entry, fetch_list}; @@ -38,7 +38,7 @@ const MODELS_PATH: &str = "/v1/models"; /// factor of 100, cents to dollars another. const CENTS_PER_100M_TO_USD_PER_MTOK: f64 = 10_000.0; -/// Fetch and normalize xAI's model list in a single request. +/// Fetches and normalizes xAI's model list in a single request. pub(crate) async fn fetch( client: &reqwest::Client, base_url: &str, @@ -69,7 +69,7 @@ struct WireModel { completion_text_token_price: Option, } -/// Normalize one wire model into a sheet entry. +/// Normalizes one wire model into a sheet entry. fn normalize_model(model: &WireModel) -> ModelEntry { let mut entry = base_entry(&model.id, model.created); entry.context_window = model.context_length; @@ -77,7 +77,7 @@ fn normalize_model(model: &WireModel) -> ModelEntry { entry } -/// Convert a wire price (USD cents per 100M tokens) to USD per million +/// Converts a wire price (USD cents per 100M tokens) to USD per million /// tokens. fn usd_per_mtok(cents_per_100m: f64) -> f64 { cents_per_100m / CENTS_PER_100M_TO_USD_PER_MTOK @@ -112,7 +112,7 @@ fn family_of(id: &str) -> String { id.to_owned() } -/// Set every entry's family, then collapse `-MMDD` snapshot suffixes +/// Sets every entry's family, then collapses `-MMDD` snapshot suffixes /// onto their canonical entries. Ids carrying the date as an infix /// (`grok-4.20-0309-reasoning`) are not suffixes and stay canonical. pub(crate) fn apply_taxonomy(entries: &mut [ModelEntry]) { diff --git a/crates/gateway/cloud-providers/src/sheet.rs b/crates/gateway/cloud-providers/src/sheet.rs index 4a0c408ea..997b0b344 100644 --- a/crates/gateway/cloud-providers/src/sheet.rs +++ b/crates/gateway/cloud-providers/src/sheet.rs @@ -8,7 +8,7 @@ use std::future::Future; use std::pin::Pin; use futures_util::stream::{FuturesUnordered, StreamExt}; -use gateway_api::{ +use gateway_api_types::{ ACCEPTED_SHEET_SCHEMA_VERSION, EnvVar, ModelEntry, ProviderSlice, Sheet, SliceStatus, Tier, }; use time::OffsetDateTime; @@ -19,9 +19,9 @@ use crate::{FetchError, Provider}; /// failure that triggers last-known-good propagation. type BoxFetch = Pin, FetchError>> + Send>>; -/// Build the complete sheet: fetch every provider, propagate -/// last-known-good slices from `previous` for failed fetches, emit -/// static slices for Niche providers, and assemble the envelope. +/// Builds the complete sheet: fetches every provider, propagates +/// last-known-good slices from `previous` for failed fetches, emits +/// static slices for Niche providers, and assembles the envelope. /// /// A failed fetch never fails the build: a provider with a previous /// slice is copied verbatim with `status` rewritten to `stale`, and a @@ -54,7 +54,7 @@ pub async fn build_sheet( .await } -/// Download and parse the current sheet from the release artifact. +/// Downloads and parses the current sheet from the release artifact. /// /// # Errors /// @@ -116,12 +116,13 @@ async fn build_sheet_with( Err(err) => { // Surface the failure cause: the sheet records only // stale/unavailable, so the stderr note is the run report. - // `FetchError`'s Display carries provider names, env var + // `FetchError`'s chain carries provider names, env var // names, URLs, and reqwest errors only - never key material, // which travels in request headers reqwest does not echo. eprintln!( - "shared-cloud-providers: note: {} fetch failed: {err}", - provider.name + "shared-cloud-providers: note: {} fetch failed: {}", + provider.name, + crate::error_chain(&err) ); stale_or_unavailable(&provider, prior) } @@ -135,7 +136,7 @@ async fn build_sheet_with( } } -/// Propagate a failed fetch: the previous slice verbatim with `status` +/// Propagates a failed fetch: the previous slice verbatim with `status` /// rewritten to `stale`, or `unavailable` with an empty model list when /// there is nothing to propagate. fn stale_or_unavailable(provider: &Provider, prior: Option) -> ProviderSlice { @@ -156,7 +157,7 @@ fn stale_or_unavailable(provider: &Provider, prior: Option) -> Pr } } -/// Convert the descriptor's const-friendly env var specs into the +/// Converts the descriptor's const-friendly env var specs into the /// schema's owned form for the slice. fn env_vars(provider: &Provider) -> Vec { provider @@ -208,7 +209,7 @@ fn static_slice(provider: &Provider) -> ProviderSlice { #[cfg(test)] mod tests { - use gateway_api::{EnvRole, ModelKind, Thinking}; + use gateway_api_types::{EnvRole, ModelKind, Thinking}; use time::format_description::well_known::Rfc3339; use super::*; @@ -294,7 +295,7 @@ mod tests { let sheet = build_sheet_with(®istry, None, &keys, &fetch, &client).await; assert_eq!( sheet.schema_version, - gateway_api::ACCEPTED_SHEET_SCHEMA_VERSION, + gateway_api_types::ACCEPTED_SHEET_SCHEMA_VERSION, "the writer must emit the schema version the gateway reader accepts" ); } @@ -635,6 +636,17 @@ mod tests { matches!(err, FetchError::Http(_)), "expected a transport error, got {err:?}" ); + // The variant renders only its own message; the stderr note walks + // the chain so the transport cause still reaches the run report. + let cause = std::error::Error::source(&err) + .expect("the Http variant carries its transport cause") + .to_string(); + assert!(!err.to_string().contains(&cause)); + assert!( + crate::error_chain(&err).contains(&cause), + "the chain rendering must include the cause: {}", + crate::error_chain(&err) + ); } #[tokio::test] diff --git a/crates/gateway/cloud-providers/src/taxonomy.rs b/crates/gateway/cloud-providers/src/taxonomy.rs index 49e0bc1dd..45f7e08a6 100644 --- a/crates/gateway/cloud-providers/src/taxonomy.rs +++ b/crates/gateway/cloud-providers/src/taxonomy.rs @@ -4,7 +4,7 @@ //! primitives apply, and the family rule itself, stay private to each //! provider file. -use gateway_api::ModelEntry; +use gateway_api_types::ModelEntry; /// The snapshot-suffix styles observed across provider catalogs. Every /// style is fixed-width and hand-parsed; there is no regex dependency. @@ -27,7 +27,7 @@ fn digits(text: &str) -> bool { !text.is_empty() && text.bytes().all(|b| b.is_ascii_digit()) } -/// Parse a digit run already validated by [`digits`]. +/// Parses a digit run already validated by [`digits`]. fn number(text: &str) -> u32 { text.bytes() .fold(0u32, |acc, b| acc * 10 + u32::from(b - b'0')) @@ -57,7 +57,7 @@ pub(crate) fn is_version_token_o(token: &str) -> bool { is_version_token(token.strip_suffix('o').unwrap_or(token)) } -/// Split a snapshot suffix of the given style off `id`, returning the +/// Splits a snapshot suffix of the given style off `id`, returning the /// base id and the suffix text without its leading dash. Returns `None` /// when the trailing bytes are not a well-formed date in the style - a /// wrong width, non-digit bytes, or impossible month or day numbers. A @@ -106,7 +106,7 @@ pub(crate) fn strip_snapshot(id: &str, style: SnapshotStyle) -> Option<(&str, &s valid.then_some((base, suffix)) } -/// Split a `vendor/model` id into its vendor prefix and model id, on the +/// Splits a `vendor/model` id into its vendor prefix and model id, on the /// first slash. Returns `None` when there is no slash or either side is /// empty. pub(crate) fn vendor_prefix(id: &str) -> Option<(&str, &str)> { @@ -114,7 +114,7 @@ pub(crate) fn vendor_prefix(id: &str) -> Option<(&str, &str)> { (!vendor.is_empty() && !model.is_empty()).then_some((vendor, model)) } -/// Split a `:free`/`:batch`-style SKU suffix off `id`, on the last colon, +/// Splits a `:free`/`:batch`-style SKU suffix off `id`, on the last colon, /// returning the base id and the SKU text. Returns `None` when there is /// no colon or either side is empty. pub(crate) fn sku_suffix(id: &str) -> Option<(&str, &str)> { @@ -152,7 +152,7 @@ pub(crate) fn collapse_variants( #[cfg(test)] pub(crate) mod fixture { - use gateway_api::{ModelEntry, ModelKind, Thinking}; + use gateway_api_types::{ModelEntry, ModelKind, Thinking}; use serde::Deserialize; /// A trimmed sheet excerpt: one provider's models reduced to ids. diff --git a/crates/gateway/cloud-providers/tests/sheet_binary.rs b/crates/gateway/cloud-providers/tests/sheet_binary.rs index e364a57a6..32961851c 100644 --- a/crates/gateway/cloud-providers/tests/sheet_binary.rs +++ b/crates/gateway/cloud-providers/tests/sheet_binary.rs @@ -8,7 +8,7 @@ use std::net::TcpListener; use std::path::PathBuf; use std::process::{Command, Output}; -use gateway_api::{Sheet, SliceStatus}; +use gateway_api_types::{Sheet, SliceStatus}; use gateway_cloud_providers::providers; /// The binary under test, built by Cargo alongside the integration test. @@ -63,7 +63,7 @@ const PREVIOUS_SHEET_JSON: &str = r#"{ } }"#; -/// Serve one HTTP response with `status` carrying `body`, returning the +/// Serves one HTTP response with `status` carrying `body`, returning the /// URL to request. fn serve_once(status: &'static str, body: &'static str) -> String { let Ok(listener) = TcpListener::bind("127.0.0.1:0") else { @@ -122,7 +122,7 @@ fn empty_home(test: &str) -> PathBuf { home } -/// Run the binary with every provider key stripped from the environment, +/// Runs the binary with every provider key stripped from the environment, /// so no host credential can turn a fixture run into a live fetch. /// Keyless providers (no `key_env`) have no credential to strip: they /// still fetch live, so their slice status depends on egress and the @@ -147,7 +147,7 @@ fn run_binary(output: &PathBuf, previous_url: Option<&str>) -> Output { output } -/// Read the emitted sheet, failing with the binary's stderr when the +/// Reads the emitted sheet, failing with the binary's stderr when the /// run itself failed. fn read_output(output: &PathBuf, result: &Output) -> Sheet { assert!( diff --git a/crates/gateway/config-ui/ui/src/components/apply-overlay.test.mjs b/crates/gateway/config-ui/ui/src/components/apply-overlay.test.mjs index 790f21217..e35dbb952 100644 --- a/crates/gateway/config-ui/ui/src/components/apply-overlay.test.mjs +++ b/crates/gateway/config-ui/ui/src/components/apply-overlay.test.mjs @@ -1,12 +1,10 @@ // Pins the apply overlay against the gateway's real progress wire: the -// `GET /admin/progress` stream carries raw hub `ProgressEvent` JSON -// (`state` is serde's externally tagged `EventState`), so a `Begun` -// leaf labelled with a switch stage lights that stage and nothing else -// does; a `//download` leaf drives a detail row under the -// active stage (name, integer percent, verify and start labels) until -// the stage ends; the card's Cancel posts the active-command cancel -// once, stays disabled (also once the apply settles), a refused cancel -// toasts on its own without closing the card, and the apply route's +// `GET /admin/progress` stream carries `Progress` snapshots +// (`{"busy": bool, "text": string}`), so a busy snapshot's text becomes +// the card's activity row, updated in place, and an idle one shows the +// waiting text; the card's Cancel posts the active-command cancel once, +// stays disabled (also once the apply settles), a refused cancel toasts +// on its own without closing the card, and the apply route's // `apply_cancelled` refusal words the toast as a cancellation rather // than a failure. import assert from "node:assert/strict"; @@ -23,14 +21,9 @@ import { const CANCELLED_TOAST = "Apply cancelled - your pending changes are still staged"; -/** A hub `ProgressEvent` frame as `event_line` in the gateway serializes it. */ -function hubEvent(label, state) { - return { operation: 7, path: label, label, state }; -} - -/** A hub frame for a leaf path whose label differs from the path. */ -function leafEvent(path, state) { - return { operation: 7, path, label: path.split("/").at(-1), state }; +/** A `Progress` snapshot frame as the gateway's `snapshot_line` serializes it. */ +function snapshot(busy, text) { + return { busy, text }; } /** @@ -71,153 +64,54 @@ async function bootApplying({ cancelReply } = {}) { return { root, stub, progress, overlay, settleApply }; } -/** The `data-stage` ids of every row carrying the `is-active` class. */ -function activeStages(overlay) { - return [...overlay.querySelectorAll(".stage.is-active")].map((row) => row.dataset.stage); -} - -test("a hub Begun event labelled with a switch stage lights that stage; other frames change nothing", async () => { +test("a busy snapshot's text drives the activity row in place; an idle one shows the waiting text", async () => { const { overlay, progress, settleApply } = await bootApplying(); - assert.deepEqual(activeStages(overlay), [], "no stage is active before the gateway reports one"); - assert.deepEqual( - [...overlay.querySelectorAll(".stage")].map((row) => row.dataset.stage), - ["loading-profile", "downloading-models", "starting-models", "applying-config"], - "the card lists the boot-load stages and the apply leaf, nothing else", - ); + const rows = [...overlay.querySelectorAll(".stage")]; + assert.equal(rows.length, 1, "the card carries one activity row"); + const row = rows[0]; + const label = row.querySelector(".stage-label"); + assert.ok(row.classList.contains("is-active"), "the row is active from the opening"); + assert.ok(row.querySelector(".spinner"), "the active row shows the spinner"); + assert.equal(label.textContent, "Waiting for the gateway", "nothing reported yet"); - // The download stage the boot load registers while a profile's - // weights stage into the cache lights like the others. - progress.push(hubEvent("downloading-models", { Begun: { weight: 5.0 } })); + // The stream opens with the current snapshot: idle while the apply + // command waits to start. + progress.push(snapshot(false, "")); await settle(); - const downloading = overlay.querySelector('.stage[data-stage="downloading-models"]'); - assert.deepEqual(activeStages(overlay), ["downloading-models"], "the download stage is active"); - assert.ok(downloading.querySelector(".spinner"), "the active download stage shows the spinner"); + assert.equal(label.textContent, "Waiting for the gateway", "an idle snapshot keeps waiting"); - progress.push(hubEvent("applying-config", { Begun: { weight: 2.0 } })); + progress.push(snapshot(true, "load-profile: main")); await settle(); - const applying = overlay.querySelector('.stage[data-stage="applying-config"]'); - assert.deepEqual(activeStages(overlay), ["applying-config"], "the Begun stage is active"); - assert.ok(applying.querySelector(".spinner"), "the active stage shows the spinner"); - assert.ok(downloading.classList.contains("is-done"), "the earlier stage is marked done"); + assert.equal(label.textContent, "load-profile: main", "a busy snapshot's text is the row"); - // A non-Begun frame for a stage and a Begun frame for a non-stage leaf - // (a model download) leave the stage list exactly as it was. - progress.push(hubEvent("starting-models", { Updated: { fraction: 0.5 } })); - progress.push(hubEvent("downloading-models/qwen/download", { Begun: { weight: 1.0 } })); - progress.push(hubEvent("starting-models", { Finished: { ok: true } })); - progress.push({ stage: "starting-models" }); - // A Begun frame for a stage label the card does not map is ignored. - progress.push(hubEvent("unmapped-stage", { Begun: { weight: 2.0 } })); + progress.push(snapshot(true, "Downloading glm-4-9b 42%")); + progress.push(snapshot(true, "Downloading glm-4-9b 43%")); + progress.push(snapshot(true, "Applying configuration")); await settle(); - assert.deepEqual(activeStages(overlay), ["applying-config"], "only the Begun stage is active"); - assert.equal(overlay.querySelectorAll(".stage").length, 4, "no row was appended"); - assert.equal(overlay.querySelector('.stage[data-stage="unmapped-stage"]'), null); - assert.ok(applying.querySelector(".spinner"), "the spinner is still on the Begun stage"); - - settleApply(jsonResponse({ applied: ["gateway.toml"], reloaded: true, restart_required: false })); - await settle(); - progress.end(); -}); + assert.equal(label.textContent, "Applying configuration", "the newest text wins"); + assert.equal(overlay.querySelector(".stage-label"), label, "the label node is updated in place"); + assert.equal(overlay.querySelectorAll(".stage").length, 1, "no row was appended"); + assert.ok(row.querySelector(".spinner"), "the spinner stays while the apply runs"); -test("a download leaf drives the detail row through percent, verify, and start; the stage end clears it", async () => { - const { overlay, progress, settleApply } = await bootApplying(); - progress.push(hubEvent("downloading-models", { Begun: { weight: 5.0 } })); - await settle(); - assert.equal(overlay.querySelector(".stage-detail"), null, "no detail row before a download leaf"); - - progress.push(leafEvent("downloading-models/glm-4-9b/download", { Begun: { weight: 1.0 } })); - await settle(); - const detail = overlay.querySelector(".stage-detail"); - assert.ok(detail, "the download Begun shows the detail row"); - assert.equal( - detail.querySelector(".stage-detail-label").textContent, - "Downloading glm-4-9b", - "the row names the model", - ); - const bar = detail.querySelector(".progress"); - assert.ok(bar, "the row carries the shared inline progress bar"); - const activeRow = overlay.querySelector('.stage[data-stage="downloading-models"]'); - assert.equal(activeRow.nextElementSibling, detail, "the row sits under the active stage"); - - progress.push(leafEvent("downloading-models/glm-4-9b/download", { Updated: { fraction: 0.42 } })); - await settle(); - assert.equal(detail.querySelector(".stage-detail-percent").textContent, "42%"); - assert.equal(bar.getAttribute("aria-valuenow"), "42", "the bar reports the integer percent"); - - // A fraction outside 0..1 clamps instead of overflowing the bar. - progress.push(leafEvent("downloading-models/glm-4-9b/download", { Updated: { fraction: 1.7 } })); - await settle(); - assert.equal(detail.querySelector(".stage-detail-percent").textContent, "100%"); - assert.equal(bar.getAttribute("aria-valuenow"), "100"); - - // A frame flood updates the existing nodes; nothing is re-created. - assert.equal(detail.querySelector(".progress"), bar, "the bar node is updated in place"); - assert.equal(overlay.querySelector(".stage-detail"), detail, "the row node is updated in place"); - - progress.push(leafEvent("downloading-models/glm-4-9b/download", { Finished: { ok: true } })); - await settle(); - assert.equal( - detail.querySelector(".stage-detail-label").textContent, - "Verifying glm-4-9b", - "the finished download flips the row to verifying", - ); - - progress.push(hubEvent("starting-models", { Begun: { weight: 5.0 } })); - await settle(); - assert.equal(overlay.querySelector(".stage-detail"), null, "the stage change clears the row"); - - progress.push(leafEvent("starting-models/glm-4-9b/ready", { Begun: { weight: 2.0 } })); + // A frame that is not a snapshot reads as idle and never throws. + progress.push({ stage: "starting-models" }); await settle(); - assert.equal( - overlay.querySelector(".stage-detail-label")?.textContent, - "Starting glm-4-9b", - "the ready leaf flips the row to starting", - ); + assert.equal(label.textContent, "Waiting for the gateway", "a malformed frame reads as idle"); settleApply(jsonResponse({ applied: ["gateway.toml"], reloaded: true, restart_required: false })); await settle(); progress.end(); }); -test("only the most recent download leaf drives the row; unrelated paths change nothing", async () => { +test("a busy snapshot with empty text shows the waiting text rather than a blank row", async () => { const { overlay, progress, settleApply } = await bootApplying(); - progress.push(hubEvent("downloading-models", { Begun: { weight: 5.0 } })); - progress.push(leafEvent("downloading-models/glm-4-9b/download", { Begun: { weight: 1.0 } })); - progress.push(leafEvent("downloading-models/glm-4-9b/download", { Updated: { fraction: 0.42 } })); - await settle(); - const detail = overlay.querySelector(".stage-detail"); - assert.equal(detail.querySelector(".stage-detail-percent").textContent, "42%"); - - // Frames for other leaves of the same model and for paths outside the - // known stages leave the row exactly as it was. - progress.push(leafEvent("downloading-models/glm-4-9b/verify", { Updated: { fraction: 0.9 } })); - progress.push(leafEvent("local-models/qwen/download", { Begun: { weight: 1.0 } })); - progress.push(leafEvent("download", { Updated: { fraction: 0.9 } })); - await settle(); - assert.equal(detail.querySelector(".stage-detail-label").textContent, "Downloading glm-4-9b"); - assert.equal(detail.querySelector(".stage-detail-percent").textContent, "42%"); - - // A second model's download Begun wins the row and resets the bar. - progress.push(leafEvent("downloading-models/qwen/download", { Begun: { weight: 1.0 } })); + progress.push(snapshot(true, "Downloading qwen 10%")); await settle(); - assert.equal(detail.querySelector(".stage-detail-label").textContent, "Downloading qwen"); - assert.equal(detail.querySelector(".stage-detail-percent").textContent, "0%"); - - // The earlier leaf's frames no longer drive the bar. - progress.push(leafEvent("downloading-models/glm-4-9b/download", { Updated: { fraction: 0.9 } })); - await settle(); - assert.equal(detail.querySelector(".stage-detail-percent").textContent, "0%"); - - // A model name containing a slash (the config validates only that it - // is non-empty) displays in full: the stage is the first segment, the - // leaf the last, the model everything between. - progress.push(leafEvent("downloading-models/org/model/download", { Begun: { weight: 1.0 } })); + const label = overlay.querySelector(".stage-label"); + assert.equal(label.textContent, "Downloading qwen 10%"); + progress.push(snapshot(true, "")); await settle(); - assert.equal(detail.querySelector(".stage-detail-label").textContent, "Downloading org/model"); - progress.push(leafEvent("downloading-models/org/model/download", { Finished: { ok: true } })); - await settle(); - assert.equal(detail.querySelector(".stage-detail-label").textContent, "Verifying org/model"); - + assert.equal(label.textContent, "Waiting for the gateway", "an empty text never blanks the row"); settleApply(jsonResponse({ applied: ["gateway.toml"], reloaded: true, restart_required: false })); await settle(); progress.end(); diff --git a/crates/gateway/config-ui/ui/src/components/apply-overlay.ts b/crates/gateway/config-ui/ui/src/components/apply-overlay.ts index 2b76b3d3b..5227257f5 100644 --- a/crates/gateway/config-ui/ui/src/components/apply-overlay.ts +++ b/crates/gateway/config-ui/ui/src/components/apply-overlay.ts @@ -1,66 +1,43 @@ // Full-screen apply overlay [Adapted: Unsloth]: a dimmed layer centering -// a card that lists the gateway's progress stages, each with a spinner -// while active, a check when passed, and an error mark when the apply -// dies in it. Stages come from the `GET /admin/progress` hub -// stream through `observe`: a `Begun` leaf whose label is a known stage -// opens that stage. While a stage runs, a `//download` -// leaf drives a detail row under the active stage - "Downloading -// " with the shared inline progress bar set by the leaf's -// `Updated` fractions, "Verifying " once the download leaf -// finishes, "Starting " when the `/ready` leaf begins - -// cleared when the stage ends. The card carries a Cancel button (the -// overlay hides the status bar's own cancel control) that fires the -// caller's cancel hook once. The terminal event closes the overlay - -// instantly on success, after a short hold on failure so the failed -// stage is seen (the toast carries the message onward). +// a card that shows the gateway's live activity while an apply runs - a +// spinner beside the `Progress` text the `GET /admin/progress` stream +// carries ("Downloading qwen 45%", "Applying configuration"), fed +// through `observe` - a check once the apply passed, and an error mark +// when it died. The card carries a Cancel button (the overlay hides the +// status bar's own cancel control) that fires the caller's cancel hook +// once. The terminal event closes the overlay - instantly on success, +// after a short hold on failure so the failed state is seen (the toast +// carries the message onward). import { Check, X, createElement as lucideElement } from "lucide"; -import { createProgressBar, type ProgressBar } from "shared-ui/progress"; import { scheduleTimeout } from "shared-ui/toast"; -import { isRecord } from "../services/json"; +import type { Progress } from "../services/gateway-api"; /** How long a failed overlay stays up before removing itself. */ const ERROR_HOLD_MS = 1500; /** - * The stage leaves the gateway registers on its progress tree, with - * their display labels (the same wording the workshop's status bar - * uses): the boot load's three stages, which run once per process and - * skip the download when the profile names no local model, and the one - * `applying-config` leaf a config apply registers while it swaps remote - * routing. A given operation lights a subset of these rows. Unknown - * stages begun through `beginStage` are appended as they arrive, so a - * gateway that grows stages never breaks the overlay; `observe` only - * maps these four. + * What the activity row says before the gateway reports anything: the + * apply request is in flight but its command has not begun (it may be + * queued behind the boot load) or the stream has not delivered yet. */ -const KNOWN_STAGES: ReadonlyArray = [ - ["loading-profile", "Loading profile"], - ["downloading-models", "Downloading models"], - ["starting-models", "Starting models"], - ["applying-config", "Applying configuration"], -]; +const WAITING_TEXT = "Waiting for the gateway"; /** The overlay controller handed to the composition root. */ export interface ApplyOverlay { - /** Mounts the overlay with the known stages listed as pending. */ + /** Mounts the overlay with the activity row waiting. */ open(title: string): void; - /** Marks `stage` active; the previously active stage becomes done. */ - beginStage(stage: string): void; /** - * Feeds one raw `GET /admin/progress` event. A hub `ProgressEvent` - * whose `state` is `Begun` and whose `label` is a known stage begins - * that stage. A `Begun` whose `path` is `//download` - * opens the detail row under the active stage, the leaf's `Updated` - * fractions set its bar, the leaf's `Finished` flips the row to the - * verifying label, and a `//ready` `Begun` flips it to - * the starting label. Every other shape is ignored. + * Feeds one `GET /admin/progress` snapshot. A busy snapshot puts its + * text in the activity row; an idle one shows the waiting text, since + * the apply the card covers has not settled yet. */ - observe(event: unknown): void; - /** Terminal success: marks every begun stage done and closes. */ + observe(progress: Progress): void; + /** Terminal success: marks the activity done and closes. */ finish(): void; - /** Terminal failure: marks the active stage failed, then closes. */ + /** Terminal failure: marks the activity failed, then closes. */ fail(message: string): void; } @@ -80,31 +57,17 @@ export function createApplyOverlay( options: ApplyOverlayOptions = {}, ): ApplyOverlay { let element: HTMLElement | null = null; - let list: HTMLElement | null = null; - let active: HTMLElement | null = null; + let row: HTMLElement | null = null; + let label: HTMLElement | null = null; let cancel: HTMLButtonElement | null = null; let restoreFocus: HTMLElement | null = null; - let detail: { - row: HTMLElement; - text: HTMLElement; - bar: ProgressBar; - percent: HTMLElement; - } | null = null; - let downloadPath: string | null = null; - - const clearDetail = () => { - detail?.row.remove(); - detail = null; - downloadPath = null; - }; const close = () => { element?.remove(); element = null; - list = null; - active = null; + row = null; + label = null; cancel = null; - clearDetail(); // Hand focus back to where it was when the overlay took it. if (restoreFocus?.isConnected) { restoreFocus.focus(); @@ -112,7 +75,10 @@ export function createApplyOverlay( restoreFocus = null; }; - const setState = (row: HTMLElement, state: "active" | "done" | "failed") => { + const setState = (state: "active" | "done" | "failed") => { + if (!row) { + return; + } row.classList.remove("is-active", "is-done", "is-failed"); row.classList.add(`is-${state}`); const icon = row.querySelector(".stage-icon"); @@ -130,86 +96,6 @@ export function createApplyOverlay( } }; - const stageRow = (stage: string, label: string): HTMLElement => { - const row = document.createElement("li"); - row.className = "stage"; - row.dataset["stage"] = stage; - const icon = document.createElement("span"); - icon.className = "stage-icon"; - const text = document.createElement("span"); - text.className = "stage-label"; - text.textContent = label; - row.append(icon, text); - return row; - }; - - const beginStage = (stage: string): void => { - if (!list) { - return; - } - if (active) { - setState(active, "done"); - // A stage change retires the download/verify/start detail row. - clearDetail(); - } - let row = list.querySelector(`[data-stage="${stage}"]`); - if (!row) { - row = stageRow(stage, stage); - list.append(row); - } - setState(row, "active"); - active = row; - }; - - /** - * Shows the detail row under the active stage with `label`, creating - * it on first use and updating it in place afterwards, so a flood of - * coalesced `Updated` frames never re-creates nodes. - */ - const showDetail = (label: string): void => { - if (!active) { - return; - } - if (!detail) { - const row = document.createElement("li"); - row.className = "stage-detail"; - const text = document.createElement("span"); - text.className = "stage-detail-label"; - const bar = createProgressBar("Model download progress"); - const percent = document.createElement("span"); - percent.className = "stage-detail-percent"; - row.append(text, bar.element, percent); - detail = { row, text, bar, percent }; - } - detail.text.textContent = label; - active.after(detail.row); - }; - - /** Handles a `Begun` frame for a non-stage leaf of a known stage. */ - const observeLeafBegun = (path: string): void => { - const leaf = splitLeafPath(path); - if (!leaf || !KNOWN_STAGES.some(([id]) => id === leaf.stage)) { - return; - } - if (leaf.leaf === "download") { - // The most recent download leaf wins: a switch that stages - // several models shows one row at a time. - downloadPath = path; - showDetail(`Downloading ${leaf.model}`); - if (detail) { - detail.bar.setFraction(0); - detail.percent.textContent = "0%"; - } - } else if (leaf.leaf === "ready") { - downloadPath = null; - showDetail(`Starting ${leaf.model}`); - if (detail) { - detail.bar.setFraction(null); - detail.percent.textContent = ""; - } - } - }; - const cancelButton = (onCancel: () => void | Promise): HTMLButtonElement => { const button = document.createElement("button"); button.type = "button"; @@ -232,9 +118,9 @@ export function createApplyOverlay( element.className = "overlay apply-overlay"; const card = document.createElement("section"); card.className = "modal"; - // A non-dismissable progress dialog: it announces its stage - // changes politely and holds focus while the switch runs, so - // the keyboard never lands on the dimmed chrome behind it. + // A non-dismissable progress dialog: it announces its activity + // changes politely and holds focus while the apply runs, so the + // keyboard never lands on the dimmed chrome behind it. card.setAttribute("role", "alertdialog"); card.setAttribute("aria-modal", "true"); card.setAttribute("aria-live", "polite"); @@ -243,11 +129,18 @@ export function createApplyOverlay( heading.id = "apply-overlay-title"; heading.textContent = title; card.setAttribute("aria-labelledby", heading.id); - list = document.createElement("ul"); + const list = document.createElement("ul"); list.className = "stage-list"; - for (const [stage, label] of KNOWN_STAGES) { - list.append(stageRow(stage, label)); - } + row = document.createElement("li"); + row.className = "stage"; + const icon = document.createElement("span"); + icon.className = "stage-icon"; + label = document.createElement("span"); + label.className = "stage-label"; + label.textContent = WAITING_TEXT; + row.append(icon, label); + list.append(row); + setState("active"); card.append(heading, list); if (options.onCancel) { const actions = document.createElement("div"); @@ -264,57 +157,16 @@ export function createApplyOverlay( card.focus(); }, - beginStage, - - observe(event: unknown): void { - // serde's externally tagged `EventState`: `{"Begun":{"weight":..}}`. - if (!isRecord(event) || !isRecord(event["state"])) { + observe(progress: Progress): void { + if (!label) { return; } - const state = event["state"]; - if ("Begun" in state) { - const label = event["label"]; - if (typeof label === "string" && KNOWN_STAGES.some(([id]) => id === label)) { - beginStage(label); - return; - } - if (typeof event["path"] === "string") { - observeLeafBegun(event["path"]); - } - return; - } - // `Updated` and `Finished` move only the tracked download leaf's - // row; frames for any other path change nothing. - const path = event["path"]; - if (typeof path !== "string" || path !== downloadPath || !detail) { - return; - } - if ("Updated" in state) { - const updated = state["Updated"]; - const fraction = isRecord(updated) ? updated["fraction"] : null; - if (typeof fraction !== "number") { - return; - } - const clamped = Math.min(Math.max(fraction, 0), 1); - detail.bar.setFraction(clamped); - detail.percent.textContent = `${Math.round(clamped * 100)}%`; - } else if ("Finished" in state) { - // The verify leaf runs next; the `ready` leaf's `Begun` flips - // the row to the starting label. - downloadPath = null; - const model = splitLeafPath(path)?.model; - if (model) { - detail.text.textContent = `Verifying ${model}`; - } - detail.bar.setFraction(null); - detail.percent.textContent = ""; - } + // Updated in place: a flood of snapshots never re-creates nodes. + label.textContent = progress.busy && progress.text !== "" ? progress.text : WAITING_TEXT; }, finish(): void { - if (active) { - setState(active, "done"); - } + setState("done"); close(); }, @@ -322,9 +174,7 @@ export function createApplyOverlay( if (!element) { return; } - if (active) { - setState(active, "failed"); - } + setState("failed"); // The operation is over; a cancel during the hold has no target. if (cancel) { cancel.disabled = true; @@ -338,28 +188,6 @@ export function createApplyOverlay( }; } -/** - * Splits a hub leaf path such as `downloading-models/glm-4-9b/download` - * into its stage prefix, model name, and leaf name: the stage is the - * first segment, the leaf the last, and the model everything between, - * so a model name that itself contains a slash (the config validates - * only that it is non-empty) still displays in full. Returns null for - * anything shallower, so unknown path shapes are ignored. - */ -function splitLeafPath(path: string): { stage: string; model: string; leaf: string } | null { - const segments = path.split("/"); - if (segments.length < 3) { - return null; - } - const stage = segments[0] ?? ""; - const model = segments.slice(1, -1).join("/"); - const leaf = segments[segments.length - 1] ?? ""; - if (!stage || !model || !leaf) { - return null; - } - return { stage, model, leaf }; -} - /** Renders a lucide icon as a decorative inline SVG. */ function iconSvg(icon: Parameters[0]): SVGElement { return lucideElement(icon, { "aria-hidden": "true", width: 16, height: 16 }); diff --git a/crates/gateway/config-ui/ui/src/components/status-bar.test.mjs b/crates/gateway/config-ui/ui/src/components/status-bar.test.mjs index 2d0908610..20df0581d 100644 --- a/crates/gateway/config-ui/ui/src/components/status-bar.test.mjs +++ b/crates/gateway/config-ui/ui/src/components/status-bar.test.mjs @@ -1,10 +1,11 @@ // Pins the bottom status bar: the idle LED strip maps each endpoint's // ready/provisioning flags to its LED state beside the model/VRAM -// summary; an active queue command swaps the shared shell's slot to the -// progress bar with the command label in the text region, the pending -// count with per-entry cancel buttons, and a cancel button that calls -// POST /admin/queue/cancel; and panel mode mounts no bar at -// all (the workshop owns status display there). +// summary; a busy Progress snapshot shows the shared shell's barberpole +// beside the still-visible LEDs with the activity text in the text +// region; an active queue command adds the pending count with per-entry +// cancel buttons and a cancel button that calls POST /admin/queue/cancel; +// and panel mode mounts no bar at all (the workshop owns status display +// there). import assert from "node:assert/strict"; import test from "node:test"; @@ -39,7 +40,8 @@ test("the idle bar maps each endpoint to its LED state plus the model summary", "2 models, 4.1 GB", "the summary carries the model count and declared VRAM", ); - assert.equal(bar.querySelector(".status-bar__progress").hidden, true, "no progress bar idle"); + assert.equal(bar.querySelector(".status-bar__barberpole").hidden, true, "no barberpole idle"); + assert.equal(bar.querySelector("progress"), null, "no element remains"); }); test("the idle bar omits the VRAM total when nothing declares any", async () => { @@ -52,30 +54,30 @@ test("the idle bar omits the VRAM total when nothing declares any", async () => ); }); -test("an active command swaps the slot to the progress bar, and cancel calls the route", async (t) => { +test("an active command shows the barberpole beside the LEDs, and cancel calls the route", async (t) => { t.mock.timers.enable({ apis: ["setInterval"] }); const stub = gatewayStub({ key: "k", config: modelsFixture(), endpoints: ENDPOINTS }); const { root } = await bootApp({ key: "k", stub }); const indicators = root.querySelector(".status-bar__indicators"); - const progress = root.querySelector(".status-bar__progress"); + const barberpole = root.querySelector(".status-bar__barberpole"); assert.equal(indicators.hidden, false, "the LED strip shows while the queue is idle"); + assert.equal(barberpole.hidden, true, "the barberpole hides while the queue is idle"); + stub.state.progress = { busy: true, text: "Downloading qwen 34%" }; stub.state.queue = { - active: { name: "load-profile: main", fraction: 0.34, started_at: 1_700_000_000 }, + active: { name: "load-profile: main", started_at: 1_700_000_000 }, pending: [{ name: "provision-model: extra", queued_at: 1_700_000_001 }], }; t.mock.timers.tick(2000); await settle(); - assert.equal(indicators.hidden, true, "the LED strip hides while a command runs"); - assert.equal(progress.hidden, false, "the progress bar takes the slot"); + assert.equal(indicators.hidden, false, "the LED strip stays visible while a command runs"); + assert.equal(barberpole.hidden, false, "the barberpole shows while the snapshot is busy"); assert.equal( root.querySelector(".status-bar__text").textContent, - "load-profile: main (34%)", - "the text carries the command name and rounded percent", + "Downloading qwen 34%", + "the text is the snapshot's activity text, not the command name", ); - assert.equal(progress.value, 34, "the bar reads the rounded percent"); - assert.equal(progress.max, 100); assert.equal( root.querySelector(".status-bar-pending").textContent, "1 queued", @@ -97,12 +99,35 @@ test("an active command swaps the slot to the progress bar, and cancel calls the await settle(); assert.equal(stub.state.cancelActiveCalls, 1, "the cancel button fired the cancel route"); - // The command settled: the next poll swaps back to the LED strip. + // The command settled: the next poll hides the barberpole. + stub.state.progress = { busy: false, text: "" }; stub.state.queue = { active: null, pending: [] }; t.mock.timers.tick(2000); await settle(); - assert.equal(indicators.hidden, false, "the LED strip returns once the queue drains"); - assert.equal(progress.hidden, true); + assert.equal(indicators.hidden, false, "the LED strip is still visible once the queue drains"); + assert.equal(barberpole.hidden, true, "the barberpole hides once the snapshot goes idle"); + assert.equal(root.querySelector(".status-bar__text").textContent, "", "the text clears"); +}); + +test("a busy snapshot with no queue command still shows the barberpole and text", async (t) => { + t.mock.timers.enable({ apis: ["setInterval"] }); + const stub = gatewayStub({ key: "k", config: modelsFixture(), models: ["a"], endpoints: ENDPOINTS }); + const { root } = await bootApp({ key: "k", stub }); + + // Startup provisioning and cache downloads report through the hub + // without a queue command: the snapshot alone drives the busy state. + stub.state.progress = { busy: true, text: "Downloading model.bin 12%" }; + t.mock.timers.tick(2000); + await settle(); + + assert.equal(root.querySelector(".status-bar__barberpole").hidden, false); + assert.equal(root.querySelector(".status-bar__text").textContent, "Downloading model.bin 12%"); + assert.equal( + root.querySelector(".status-bar-summary").hidden, + false, + "with no queue command the model summary stays in the extras region", + ); + assert.equal(root.querySelector(".status-bar-queue").hidden, true, "no cancel controls"); }); test("panel mode mounts no status bar", async () => { diff --git a/crates/gateway/config-ui/ui/src/components/status-bar.ts b/crates/gateway/config-ui/ui/src/components/status-bar.ts index e059e453b..f98befa0d 100644 --- a/crates/gateway/config-ui/ui/src/components/status-bar.ts +++ b/crates/gateway/config-ui/ui/src/components/status-bar.ts @@ -1,16 +1,16 @@ // The fixed bottom status bar [VS Code], built on the shared shell // (shared-ui/status-bar): the shell owns the bar, the text region, and -// the slot's progress/indicators swap; this component populates them -// from the extended GET /admin/status response. Idle shows the endpoint -// LED strip (green ready, amber provisioning, gray unconfigured) in the -// indicators group plus the model count and declared VRAM in the extras -// region; an active queue command swaps the slot to the progress bar, -// puts the command label in the text, and fills the extras region with -// the pending count, one cancel button per pending command (POST -// /admin/queue/cancel-pending), and the active command's cancel button -// (POST /admin/queue/cancel). Self-contained on purpose - it owns its -// poll loop and the body class that keeps page content clear of the -// fixed strip. +// the busy barberpole beside the indicators; this component populates +// them from the extended GET /admin/status response. The endpoint LED +// strip (green ready, amber provisioning, gray unconfigured) stands in +// the indicators group throughout; idle adds the model count and +// declared VRAM in the extras region; a busy Progress snapshot shows the +// barberpole and puts its text in the text region, and an active queue +// command fills the extras region with the pending count, one cancel +// button per pending command (POST /admin/queue/cancel-pending), and the +// active command's cancel button (POST /admin/queue/cancel). +// Self-contained on purpose - it owns its poll loop and the body class +// that keeps page content clear of the fixed strip. import { X, createElement as lucideElement } from "lucide"; import { createStatusBarShell } from "shared-ui/status-bar"; @@ -132,12 +132,12 @@ export function createStatusBar(options: StatusBarOptions): StatusBar { document.body.classList.remove("has-status-bar"); }, update(status: GatewayStatus): void { + // The Progress snapshot is the busy signal and the text; the queue + // readout only adds the cancel controls while a command runs. + shell.setBusy(status.progress.busy); + shell.setText(status.progress.busy ? status.progress.text : ""); const active = status.queue.active; if (active !== null) { - const fraction = Math.min(Math.max(active.fraction, 0), 1); - const percent = Math.round(fraction * 100); - shell.setText(`${active.name} (${percent}%)`); - shell.renderSlot({ current: percent, total: 100 }); summary.hidden = true; queueGroup.hidden = false; const pendingCount = status.queue.pending.length; @@ -177,8 +177,6 @@ export function createStatusBar(options: StatusBarOptions): StatusBar { ); return; } - shell.setText(""); - shell.renderSlot(null); summary.hidden = false; queueGroup.hidden = true; leds.replaceChildren( diff --git a/crates/gateway/config-ui/ui/src/harness.mjs b/crates/gateway/config-ui/ui/src/harness.mjs index ef0e1b548..6d77e7b47 100644 --- a/crates/gateway/config-ui/ui/src/harness.mjs +++ b/crates/gateway/config-ui/ui/src/harness.mjs @@ -552,10 +552,11 @@ function redactSecrets(view) { * report. When `key` is set, gateway requests without that * bearer answer 401; absolute (hub) URLs are exempt. Every call is * recorded in `calls`; the mutable config state is exposed as `state`. - * The queue surface: `/admin/status` returns `queue`, `endpoints`, and - * `vram_gb` from `state` (mutate them to drive the status bar), and the - * cancel routes record into `state.cancelActiveCalls` and - * `state.cancelPendingCalls`. The cloud sheet surface: `GET + * The queue surface: `/admin/status` returns `progress`, `queue`, + * `endpoints`, and `vram_gb` from `state` (mutate them to drive the + * status bar), and the cancel routes record into + * `state.cancelActiveCalls` and `state.cancelPendingCalls`. The cloud + * sheet surface: `GET * /admin/cloud-models` returns `cloudModels` (unstubbed: the route * 404s, putting the sheet store in its error state without polling; * `onCloudModels` overrides the reply entirely, for staged @@ -588,6 +589,7 @@ export function gatewayStub({ cloudModels, onCloudModels, env, + progress, queue, endpoints, vramGb, @@ -612,6 +614,8 @@ export function gatewayStub({ switchCalls: [], /** Process-lifetime config generation returned by admin status. */ configGeneration, + /** The live activity snapshot returned by admin status. */ + progress: progress ?? { busy: false, text: "" }, /** The command queue readout returned by admin status. */ queue: queue ?? { active: null, pending: [] }, /** The endpoint readiness entries returned by admin status. */ @@ -710,6 +714,7 @@ export function gatewayStub({ models, config_generation: state.configGeneration, vram_gb: state.vramGb, + progress: state.progress, queue: state.queue, endpoints: state.endpoints, }); diff --git a/crates/gateway/config-ui/ui/src/main.ts b/crates/gateway/config-ui/ui/src/main.ts index 1dad1858a..f1dc5476c 100644 --- a/crates/gateway/config-ui/ui/src/main.ts +++ b/crates/gateway/config-ui/ui/src/main.ts @@ -443,18 +443,18 @@ function mountLiveShell( }); void store.load(); // The live progress stream: while an apply is in flight, the hub's - // events feed the overlay, which maps stage leaves itself. The - // `applying` guard suffices because the boot load runs once per - // process and an Apply queues behind it, so during an Apply the only - // stage emitter the overlay can see is the Apply. Subscribing at boot - // keeps the shell an independent subscriber whether or not the + // snapshots feed the overlay's activity row. The `applying` guard + // suffices because the boot load runs once per process and an Apply + // queues behind it, so during an Apply the text the overlay shows is + // the Apply's own (or the boot load it waits behind). Subscribing at + // boot keeps the shell an independent subscriber whether or not the // workshop is connected. Panel mode never subscribes: the workshop // already consumes the same stream and owns all progress display. const stopProgress = bridge === null - ? api.subscribeProgress((event) => { + ? api.subscribeProgress((progress) => { if (applying) { - overlay.observe(event); + overlay.observe(progress); } }) : () => undefined; diff --git a/crates/gateway/config-ui/ui/src/services/gateway-api.ts b/crates/gateway/config-ui/ui/src/services/gateway-api.ts index 0b7b25df1..45a590cb4 100644 --- a/crates/gateway/config-ui/ui/src/services/gateway-api.ts +++ b/crates/gateway/config-ui/ui/src/services/gateway-api.ts @@ -25,14 +25,23 @@ export interface EndpointStatus { provisioning: boolean; } +/** + * The gateway's live activity, from `GET /admin/status` and each + * `GET /admin/progress` frame: a busy flag and the newest activity's text. + */ +export interface Progress { + /** Whether any activity is live; the UIs show a barberpole while set. */ + busy: boolean; + /** The newest live activity's text, e.g. `Downloading qwen 45%`; empty idle. */ + text: string; +} + /** The command queue readout from `GET /admin/status`. */ export interface QueueStatus { /** The running command, or null when the worker is idle. */ active: { /** The command's display name, for example `load-profile: main`. */ name: string; - /** The command's progress fraction, 0..1. */ - fraction: number; /** When the worker started the command, in Unix epoch seconds. */ started_at: number; } | null; @@ -55,6 +64,8 @@ export interface GatewayStatus { config_generation: string; /** Declared VRAM total of the active local and STT models, in GiB. */ vram_gb: number; + /** The hub's current activity snapshot. */ + progress: Progress; /** The command queue's active and pending commands. */ queue: QueueStatus; /** One readiness entry per capability endpoint the gateway can serve. */ @@ -443,12 +454,12 @@ export class GatewayApi { config_generation: typeof data["config_generation"] === "string" ? data["config_generation"] : "", vram_gb: numberOrZero(data["vram_gb"]), + progress: parseProgress(data["progress"]), queue: { active: active !== null && typeof active["name"] === "string" ? { name: active["name"], - fraction: numberOrZero(active["fraction"]), started_at: numberOrZero(active["started_at"]), } : null, @@ -831,11 +842,12 @@ export class GatewayApi { /** * Subscribes to the `GET /admin/progress` SSE stream, invoking - * `onEvent` with each parsed progress event. Returns the unsubscribe - * function. A transport failure ends the subscription quietly; a 401 - * flows through the shared unauthorized path. + * `onEvent` with each parsed {@link Progress} snapshot: the current one + * first, then one per change. Returns the unsubscribe function. A + * transport failure ends the subscription quietly; a 401 flows through + * the shared unauthorized path. */ - subscribeProgress(onEvent: (event: unknown) => void): () => void { + subscribeProgress(onEvent: (event: Progress) => void): () => void { const controller = new AbortController(); void (async () => { let response: Response; @@ -851,7 +863,7 @@ export class GatewayApi { try { for await (const payload of ssePayloads(response.body)) { try { - onEvent(JSON.parse(payload)); + onEvent(parseProgress(JSON.parse(payload))); } catch { // A malformed event is dropped; the stream carries on. } @@ -1158,6 +1170,18 @@ function stringRecord(value: unknown): Record { ); } +/** + * Reads a `Progress` snapshot off an external value; anything malformed + * reads as idle, so a stale bar clears rather than sticks. + */ +function parseProgress(value: unknown): Progress { + const record = optionalRecord(value); + return { + busy: record?.["busy"] === true, + text: typeof record?.["text"] === "string" ? record["text"] : "", + }; +} + /** Reads a finite external number, defaulting malformed values to zero. */ function numberOrZero(value: unknown): number { return typeof value === "number" && Number.isFinite(value) ? value : 0; diff --git a/crates/gateway/config-ui/ui/src/styles/layout.css b/crates/gateway/config-ui/ui/src/styles/layout.css index 99f38f098..8ee04d14d 100644 --- a/crates/gateway/config-ui/ui/src/styles/layout.css +++ b/crates/gateway/config-ui/ui/src/styles/layout.css @@ -289,8 +289,9 @@ background: var(--accent-subtle); } - /* Apply overlay stages: pending rows muted, the active row carries - the spinner, done rows the check, a failed row the error mark. */ + /* Apply overlay activity: the one row carries the spinner and the + gateway's activity text while the apply runs, the check once it + finished, the error mark when it failed. */ .stage-list { display: flex; flex-direction: column; @@ -319,23 +320,6 @@ width: 1rem; height: 1rem; } - /* The active stage's detail row: the model download/verify/start - label, the shared inline progress bar, and its integer percent. */ - .stage-detail { - display: flex; - align-items: center; - gap: 0.5rem; - padding-inline-start: 1.5rem; - color: var(--text-secondary); - } - .stage-detail .progress { - flex: 1; - } - .stage-detail-percent { - min-width: 4ch; - text-align: end; - font-variant-numeric: tabular-nums; - } .spinner { width: 14px; height: 14px; diff --git a/crates/gateway/config/Cargo.toml b/crates/gateway/config/Cargo.toml index f771fd00e..828f39c0f 100644 --- a/crates/gateway/config/Cargo.toml +++ b/crates/gateway/config/Cargo.toml @@ -16,7 +16,7 @@ documentation = "https://cppalliance.github.io/promptforge/" serde.workspace = true # JSON rendering of the resolved config for `Config::to_json`. serde_json.workspace = true -gateway-api.workspace = true +gateway-api-types.workspace = true thiserror.workspace = true toml.workspace = true url.workspace = true diff --git a/crates/gateway/config/src/api_error.rs b/crates/gateway/config/src/api_error.rs index 59d3e6c45..82111d85d 100644 --- a/crates/gateway/config/src/api_error.rs +++ b/crates/gateway/config/src/api_error.rs @@ -46,14 +46,14 @@ pub enum ConfigErrorKind { } impl ConfigError { - /// Classify this failure without matching a private representation. + /// Classifies this failure without matching a private representation. #[must_use] pub fn kind(&self) -> ConfigErrorKind { match self.0 { ConfigErrorRepr::Read { .. } => ConfigErrorKind::Read, ConfigErrorRepr::Parse { .. } => ConfigErrorKind::Parse, ConfigErrorRepr::Interpolation(_) => ConfigErrorKind::Interpolation, - ConfigErrorRepr::UnresolvedVar(_) => ConfigErrorKind::UnresolvedVar, + ConfigErrorRepr::UnresolvedVar(..) => ConfigErrorKind::UnresolvedVar, ConfigErrorRepr::Validation(_) => ConfigErrorKind::Validation, ConfigErrorRepr::HardBreak { .. } => ConfigErrorKind::HardBreak, ConfigErrorRepr::Write { .. } => ConfigErrorKind::Write, @@ -124,7 +124,7 @@ mod tests { ConfigErrorKind::Parse, ), ( - ConfigErrorRepr::UnresolvedVar("V".to_owned()), + ConfigErrorRepr::UnresolvedVar("V".to_owned(), std::env::VarError::NotPresent), ConfigErrorKind::UnresolvedVar, ), ( diff --git a/crates/gateway/config/src/config.rs b/crates/gateway/config/src/config.rs index a0c700c78..61c0702a1 100644 --- a/crates/gateway/config/src/config.rs +++ b/crates/gateway/config/src/config.rs @@ -20,9 +20,9 @@ pub use companion::{ }; pub(crate) use imp::reject_profiles_directory; pub(crate) use interpolate::interpolate_value; -// The canonical home of the model-metadata types is `gateway-api`; +// The canonical home of the model-metadata types is `gateway-api-types`; // these re-exports keep the old paths compiling unchanged. -pub use gateway_api::{Capabilities, ModelKind, ThinkingMode}; +pub use gateway_api_types::{Capabilities, ModelKind, ThinkingMode}; use stt::RawSttPipelineConfig; pub use stt::{ RECOMMENDED_STT_MODELS, RecommendedSttModel, SttModelConfig, SttPipelineConfig, SttRole, @@ -74,7 +74,7 @@ fn default_max_queue() -> usize { pub struct Secret(String); impl Secret { - /// Wrap a plaintext secret. + /// Wraps a plaintext secret. /// /// Used by config deserialization and by the gateway's adapters that mint /// an ephemeral loopback credential. @@ -96,7 +96,7 @@ impl Secret { } } -/// Deserialize a [`Secret`] field from a bare TOML string without exposing a +/// Deserializes a [`Secret`] field from a bare TOML string without exposing a /// public `Deserialize` impl on the redacting type. fn de_secret<'de, D>(deserializer: D) -> Result where @@ -106,7 +106,7 @@ where Ok(Secret::new(raw)) } -/// Serialize a [`Secret`] field as `"***"`: a serialized configuration never +/// Serializes a [`Secret`] field as `"***"`: a serialized configuration never /// carries credential material, and a reader treats the marker as "keep the /// existing value" on write. pub(crate) fn ser_redacted(_: &Secret, serializer: S) -> Result @@ -293,10 +293,10 @@ pub enum DominionKind { #[serde(rename_all = "lowercase")] #[non_exhaustive] pub enum QueuePolicy { - /// Wait for a slot up to `max_queue` waiting requests, then reject. + /// Waits for a slot up to `max_queue` waiting requests, then rejects. #[default] Queue, - /// Reject immediately when no concurrency slot is free (fail-fast). + /// Rejects immediately when no concurrency slot is free (fail-fast). Reject, } @@ -337,8 +337,9 @@ pub struct DominionConfig { /// setting is consulted there only. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] +#[non_exhaustive] pub enum LlamaBackend { - /// Pick from the host's GPUs: a Blackwell (compute capability 12.x) gets + /// Picks from the host's GPUs: a Blackwell (compute capability 12.x) gets /// the PromptForge CUDA build, any other NVIDIA GPU gets the upstream /// CUDA build, and anything else gets Vulkan. #[default] @@ -419,7 +420,7 @@ pub struct LocalModelConfig { /// GPU layers offloaded (`-ngl`). Defaults to 99. #[serde(default = "default_gpu_layers")] gpu_layers: u32, - /// Enable flash attention (`--flash-attn on`). Defaults to true. + /// Enables flash attention (`--flash-attn on`). Defaults to true. #[serde(default = "default_true")] flash_attention: bool, /// KV cache type for K. Defaults to `q8_0`. diff --git a/crates/gateway/config/src/config/companion.rs b/crates/gateway/config/src/config/companion.rs index 6a68c53b7..dbb4f4c72 100644 --- a/crates/gateway/config/src/config/companion.rs +++ b/crates/gateway/config/src/config/companion.rs @@ -198,7 +198,7 @@ impl SpeculativeConfig { self.draft_max } - /// Check the companion source rules for the model named `model_name`. + /// Checks the companion source rules for the model named `model_name`. pub(crate) fn validate(&self, model_name: &str) -> Result<(), ConfigError> { validate_artifact_source( &format!("local_model {model_name}"), @@ -269,7 +269,7 @@ impl MultimodalProjectorConfig { self.sha256.as_deref() } - /// Check the companion source rules for the model named `model_name`. + /// Checks the companion source rules for the model named `model_name`. pub(crate) fn validate(&self, model_name: &str) -> Result<(), ConfigError> { validate_artifact_source( &format!("local_model {model_name}"), diff --git a/crates/gateway/config/src/config/imp.rs b/crates/gateway/config/src/config/imp.rs index e45451447..5ca4fb147 100644 --- a/crates/gateway/config/src/config/imp.rs +++ b/crates/gateway/config/src/config/imp.rs @@ -162,7 +162,7 @@ impl Config { }) } - /// Interpolate, parse, and validate a configuration from a TOML string. + /// Interpolates, parses, and validates a configuration from a TOML string. /// /// # Errors /// Returns [`ConfigError`](crate::ConfigError) for a malformed or unresolved @@ -237,7 +237,7 @@ impl Config { Ok(selected) } - /// Parse, interpolate, and validate, returning the internal error type. + /// Parses, interpolates, and validates, returning the internal error type. pub(crate) fn parse_toml(raw: &str) -> Result { Self::parse_toml_at(raw, None) } diff --git a/crates/gateway/config/src/config/interpolate.rs b/crates/gateway/config/src/config/interpolate.rs index 010d431bb..39f4746db 100644 --- a/crates/gateway/config/src/config/interpolate.rs +++ b/crates/gateway/config/src/config/interpolate.rs @@ -7,7 +7,7 @@ use crate::error::ConfigError; -/// Expand `${VAR}` from the environment; `$$` is a literal `$`. +/// Expands `${VAR}` from the environment; `$$` is a literal `$`. /// /// # Errors /// Returns [`ConfigError::Interpolation`] on an unclosed `${...}` and @@ -41,8 +41,8 @@ pub(crate) fn interpolate(input: &str) -> Result { "unclosed ${...} interpolation".to_string(), )); } - let value = - std::env::var(&name).map_err(|_| ConfigError::UnresolvedVar(name.clone()))?; + let value = std::env::var(&name) + .map_err(|source| ConfigError::UnresolvedVar(name.clone(), source))?; out.push_str(&value); } _ => out.push('$'), @@ -51,7 +51,7 @@ pub(crate) fn interpolate(input: &str) -> Result { Ok(out) } -/// Recursively interpolate `${VAR}` in every string leaf of a TOML value, +/// Recursively interpolates `${VAR}` in every string leaf of a TOML value, /// leaving keys, comments (already stripped by the parser), and non-string /// scalars untouched. (CFG-007) pub(crate) fn interpolate_value(value: &mut toml::Value) -> Result<(), ConfigError> { diff --git a/crates/gateway/config/src/config/tests.rs b/crates/gateway/config/src/config/tests.rs index 8b04b132d..27e42f83c 100644 --- a/crates/gateway/config/src/config/tests.rs +++ b/crates/gateway/config/src/config/tests.rs @@ -1,3 +1,5 @@ +//! Tests for config parsing, field defaults, and variable interpolation. + use super::interpolate::interpolate; use super::*; @@ -511,7 +513,7 @@ fn unresolved_variable_is_an_error() { let missing = "${PROMPTFORGE_DEFINITELY_UNSET_VAR_XYZ}"; assert!(matches!( interpolate(missing), - Err(ConfigError::UnresolvedVar(_)) + Err(ConfigError::UnresolvedVar(..)) )); } diff --git a/crates/gateway/config/src/config/tests/schema.rs b/crates/gateway/config/src/config/tests/schema.rs index 31faf61aa..d86a3db44 100644 --- a/crates/gateway/config/src/config/tests/schema.rs +++ b/crates/gateway/config/src/config/tests/schema.rs @@ -1,3 +1,5 @@ +//! Tests for the config schema version, hard-break detection, and profile selection. + use std::fs; use tempfile::TempDir; diff --git a/crates/gateway/config/src/config/tests/serialize.rs b/crates/gateway/config/src/config/tests/serialize.rs index 889dbfe6f..0f784c5d1 100644 --- a/crates/gateway/config/src/config/tests/serialize.rs +++ b/crates/gateway/config/src/config/tests/serialize.rs @@ -1,3 +1,5 @@ +//! Tests for config JSON round-tripping, key naming, and secret redaction. + use super::super::*; /// A fixture exercising every config struct, every enum spelling, and all @@ -237,7 +239,7 @@ fn enums_round_trip_with_their_toml_spellings() { #[test] fn capabilities_round_trip_through_json() { - // `Capabilities` is `#[non_exhaustive]` in `gateway-api`, so the + // `Capabilities` is `#[non_exhaustive]` in `gateway-api-types`, so the // fixture is built from JSON rather than a struct literal. let json = serde_json::json!({ "max_output": 4096, diff --git a/crates/gateway/config/src/config/tests/validation.rs b/crates/gateway/config/src/config/tests/validation.rs index 1a1cb50cb..8433a9451 100644 --- a/crates/gateway/config/src/config/tests/validation.rs +++ b/crates/gateway/config/src/config/tests/validation.rs @@ -1,3 +1,5 @@ +//! Tests for the config validation rules that reject malformed or legacy sections. + use super::super::*; use super::SAMPLE; diff --git a/crates/gateway/config/src/config/validate.rs b/crates/gateway/config/src/config/validate.rs index 03ffeee38..ceee1ca0b 100644 --- a/crates/gateway/config/src/config/validate.rs +++ b/crates/gateway/config/src/config/validate.rs @@ -20,7 +20,7 @@ use crate::error::ConfigError; use crate::profile::ProfileName; impl Config { - /// Advertise `images = true` for every local model with a multimodal + /// Advertises `images = true` for every local model with a multimodal /// projector. /// /// A configured `[local_model.multimodal_projector]` makes the child @@ -40,7 +40,7 @@ impl Config { } } - /// Check names are unique, references resolve, URLs parse, and closed + /// Checks names are unique, references resolve, URLs parse, and closed /// vocabularies hold. /// /// # Errors @@ -76,7 +76,7 @@ impl Config { Ok(()) } - /// Validate `[tools.web_search]` bounds, URL, and closed knobs at load so + /// Validates `[tools.web_search]` bounds, URL, and closed knobs at load so /// downstream code never has to clamp or re-parse operator input (CFG-006). fn validate_tools(&self) -> Result<(), ConfigError> { let Some(web_search) = self.web_search_config() else { @@ -684,7 +684,7 @@ impl Config { } } -/// Validate the capability metadata of one model entry. +/// Validates the capability metadata of one model entry. /// /// `default_effort` requires a non-empty `effort_levels` and must name a /// listed level; the effort knobs are meaningless on a model that never @@ -739,7 +739,7 @@ fn validate_capabilities( Ok(()) } -/// Reject chat-only fields on a non-chat model kind and the speech-only +/// Rejects chat-only fields on a non-chat model kind and the speech-only /// `voices` list on a non-speech kind. /// /// `thinking` and the capability effort knobs (`effort_levels`, @@ -785,7 +785,7 @@ fn validate_kind_scope( Ok(()) } -/// Parse `raw` and require an `http`/`https` scheme with a non-empty host. +/// Parses `raw` and requires an `http`/`https` scheme with a non-empty host. /// /// This is the single URL gate for operator-supplied origins: a value that /// passes here is a real, absolute HTTP(S) URL, so adapters can join a path diff --git a/crates/gateway/config/src/error.rs b/crates/gateway/config/src/error.rs index 5e51a4e5b..1c070127a 100644 --- a/crates/gateway/config/src/error.rs +++ b/crates/gateway/config/src/error.rs @@ -29,10 +29,11 @@ pub(crate) enum ConfigError { source: Box, }, - /// A `${VAR}` referenced an environment variable that was not set. + /// A `${VAR}` referenced an environment variable that was not set, + /// or was set to a value that is not Unicode. #[non_exhaustive] #[error("unresolved environment variable {0}")] - UnresolvedVar(String), + UnresolvedVar(String, #[source] std::env::VarError), /// A `${...}` interpolation was malformed (for example, unclosed). #[non_exhaustive] diff --git a/crates/gateway/config/src/shadow-tests.rs b/crates/gateway/config/src/shadow-tests.rs index b1dc77cba..2b8fe5c2e 100644 --- a/crates/gateway/config/src/shadow-tests.rs +++ b/crates/gateway/config/src/shadow-tests.rs @@ -1,3 +1,5 @@ +//! Tests for shadow config files, pending saves, and atomic profile state writes. + use super::*; const CONFIG: &str = r#" diff --git a/crates/gateway/local/Cargo.toml b/crates/gateway/local/Cargo.toml index 7bec8929e..883a5bb1c 100644 --- a/crates/gateway/local/Cargo.toml +++ b/crates/gateway/local/Cargo.toml @@ -18,11 +18,12 @@ flate2.workspace = true gateway-config.workspace = true gateway-protocol.workspace = true gateway-routing.workspace = true -shared-progress.workspace = true +gateway-progress.workspace = true rand.workspace = true reqwest = { workspace = true, features = ["blocking"] } serde.workspace = true serde_json.workspace = true +shared-error-source = { workspace = true, features = ["http", "json"] } sha2.workspace = true tar.workspace = true thiserror.workspace = true diff --git a/crates/gateway/local/src/artifacts.rs b/crates/gateway/local/src/artifacts.rs index d95b62946..47e4b2b39 100644 --- a/crates/gateway/local/src/artifacts.rs +++ b/crates/gateway/local/src/artifacts.rs @@ -7,17 +7,22 @@ //! //! The module is split into cohesive units: `assets` (release table), //! `digest` (hashing + pin validation), `archive` (extraction), -//! `confine` (cache-root path safety), `progress` (download reporting), -//! `download` (HTTP transfer + scoped HF auth), and `verified` +//! `confine` (cache-root path safety), `download` (HTTP transfer, scoped +//! HF auth, and the activity text reporters), and `verified` //! (verified-digest markers). This file owns `ArtifactStore`, the //! orchestration that ties them together. +//! +//! Progress is one line of text: a caller that runs an +//! [`Activity`] passes it down and each stage writes what it is doing +//! (`"Downloading qwen.gguf 45%"`, `"Verifying qwen.gguf 80%"`, +//! `"Extracting llama-b10082.zip 12%"`) into it; failures surface as +//! errors to the caller, which owns the log line. mod archive; mod assets; pub(crate) mod confine; mod digest; mod download; -mod progress; mod staging; mod verified; @@ -26,9 +31,9 @@ use std::io; use std::path::{Path, PathBuf}; use gateway_config::LlamaBackend; +use gateway_progress::Activity; use reqwest::blocking::Client; use sha2::{Digest, Sha256}; -use shared_progress::ProgressHandle; use tokio_util::sync::CancellationToken; use crate::error::LocalError; @@ -56,8 +61,8 @@ pub(crate) use confine::{ }; pub(crate) use digest::hex_digest; pub use digest::parse_expected_digest; +pub use download::{DownloadProgress, PercentText}; pub(crate) use download::{download_with_progress, hub_bearer_token_from_env}; -pub use progress::{DownloadProgress, TreeProgress}; const INSTALL_MARKER: &str = ".promptforge-install"; /// Connect timeout for artifact downloads (bounds a stalled connect). @@ -193,8 +198,8 @@ impl ArtifactStore { /// Resolves the `llama-server` executable for this host: the configured /// `llama_server_path` first, then the `PROMPTFORGE_LLAMA_SERVER` /// environment variable, then the managed download of the pinned build - /// for the selected backend, reporting the download, verify, and extract - /// stages into child leaves of `progress`, when given. + /// for the selected backend, writing the download, verify, and extract + /// stages into `activity`'s text, when given. /// /// # Errors /// Returns a [`LocalError`] when an explicit path is invalid, the @@ -202,9 +207,9 @@ impl ArtifactStore { pub(crate) fn provision_llama_server_with_progress( &self, selection: &ServerSelection<'_>, - progress: Option<&ProgressHandle>, + activity: Option<&Activity>, ) -> Result { - self.provision_llama_server_with_cancellation(selection, progress, None) + self.provision_llama_server_with_cancellation(selection, activity, None) } /// [`Self::provision_llama_server_with_progress`] variant that stops at @@ -212,7 +217,7 @@ impl ArtifactStore { pub(crate) fn provision_llama_server_with_cancellation( &self, selection: &ServerSelection<'_>, - progress: Option<&ProgressHandle>, + activity: Option<&Activity>, token: Option<&CancellationToken>, ) -> Result { if let Some(path) = selection.server_path { @@ -241,7 +246,7 @@ impl ArtifactStore { selection.backend, gpus.as_deref(), )?; - let executable = self.provision_server(asset, progress, token)?; + let executable = self.provision_server(asset, activity, token)?; Ok(ProvisionedServer { executable, path_prefix: Vec::new(), @@ -258,10 +263,10 @@ impl ArtifactStore { /// # Errors /// Returns a [`LocalError`] when the platform is unsupported or download, /// verification, extraction, or cache publication fails. - pub fn provision_whisper_library(&self, progress: Option<&ProgressHandle>) -> Result { + pub fn provision_whisper_library(&self, activity: Option<&Activity>) -> Result { let asset = whisper_asset(std::env::consts::OS, std::env::consts::ARCH)?; let archives = [asset.archive]; - self.provision_install(whisper_install_asset(asset, &archives), progress, None) + self.provision_install(whisper_install_asset(asset, &archives), activity, None) } /// Ensures a GGUF (or other blob) from `source` is available locally. @@ -275,12 +280,10 @@ impl ArtifactStore { self.ensure_model_with_progress(source, sha256, None) } - /// [`Self::ensure_model`] variant that reports the download and verify stages - /// into child leaves of `progress`, when given. A path source completes - /// the download leaf immediately. An unpinned URL hashes once when an - /// older cache hit lacks listing metadata, then reuses that metadata. - /// Both stages exist in the subtree whether or not they have work. An - /// error exit fails any leaf that has not already finished. + /// [`Self::ensure_model`] variant that writes the download and verify + /// stages into `activity`'s text, when given. A path source has no + /// download to report. An unpinned URL hashes once when an older cache + /// hit lacks listing metadata, then reuses that metadata. /// /// # Errors /// Returns a [`LocalError`] on download, verification, or path failures. @@ -288,9 +291,9 @@ impl ArtifactStore { &self, source: &str, sha256: Option<&str>, - progress: Option<&ProgressHandle>, + activity: Option<&Activity>, ) -> Result { - self.ensure_model_with_cancellation(source, sha256, progress, None) + self.ensure_model_with_cancellation(source, sha256, activity, None) } /// [`Self::ensure_model_with_progress`] variant that stops at download @@ -304,32 +307,7 @@ impl ArtifactStore { &self, source: &str, sha256: Option<&str>, - progress: Option<&ProgressHandle>, - token: Option<&CancellationToken>, - ) -> Result { - let download = progress.map(|handle| handle.child("download", 4.0)); - let verify = progress.map(|handle| handle.child("verify", 1.0)); - let result = - self.ensure_model_reporting(source, sha256, download.as_ref(), verify.as_ref(), token); - // Terminal state is sticky, so leaves already finished inside (a - // verified cache hit, a digest mismatch) are not failed twice. - if result.is_err() { - if let Some(handle) = &download { - handle.fail(); - } - if let Some(handle) = &verify { - handle.fail(); - } - } - result - } - - fn ensure_model_reporting( - &self, - source: &str, - sha256: Option<&str>, - download: Option<&ProgressHandle>, - verify: Option<&ProgressHandle>, + activity: Option<&Activity>, token: Option<&CancellationToken>, ) -> Result { if looks_like_url(source) { @@ -343,13 +321,10 @@ impl ArtifactStore { url: source, sha256, }; - self.ensure_blob_with_progress(asset, &destination, download, verify, token)?; + self.ensure_blob_with_progress(asset, &destination, activity, token)?; return Ok(destination); } - // A path source is already local: the download stage has no work. - if let Some(handle) = download { - handle.complete(); - } + // A path source is already local: there is no download to report. let path = expand_tilde(source)?; if !path.is_file() { return Err(LocalError::InvalidSource { @@ -357,18 +332,11 @@ impl ArtifactStore { reason: "path is not an existing file".to_owned(), }); } - match sha256 { - Some(pin) => { - let expected = parse_expected_digest(pin)?; - let marker = path_source_marker(&self.cache, &path)?; - let _outcome = - verify_blob_with_progress(&self.cache, &path, &expected, &marker, verify)?; - } - None => { - if let Some(handle) = verify { - handle.complete(); - } - } + if let Some(pin) = sha256 { + let expected = parse_expected_digest(pin)?; + let marker = path_source_marker(&self.cache, &path)?; + let _outcome = + verify_blob_with_progress(&self.cache, &path, &expected, &marker, activity)?; } Ok(path) } @@ -376,7 +344,7 @@ impl ArtifactStore { fn provision_server( &self, asset: ServerAsset<'_>, - progress: Option<&ProgressHandle>, + activity: Option<&Activity>, token: Option<&CancellationToken>, ) -> Result { self.provision_install( @@ -388,7 +356,7 @@ impl ArtifactStore { required_name: asset.executable_name, allow_cached_fallback: true, }, - progress, + activity, token, ) } @@ -396,13 +364,9 @@ impl ArtifactStore { fn provision_install( &self, asset: InstallAsset<'_>, - progress: Option<&ProgressHandle>, + activity: Option<&Activity>, token: Option<&CancellationToken>, ) -> Result { - let download = progress.map(|handle| handle.child("download", 4.0)); - let verify = progress.map(|handle| handle.child("verify", 1.0)); - let extract = progress.map(|handle| handle.child("extract", 1.0)); - // Download and verify every archive the asset needs. When a download // fails and an older install is already in the cache, use the cached // one with a warning instead of failing to start. @@ -419,13 +383,9 @@ impl ArtifactStore { url: archive_ref.url, sha256: Some(archive_ref.sha256), }; - if let Err(error) = self.ensure_blob_with_progress( - file_asset, - &archive, - download.as_ref(), - verify.as_ref(), - token, - ) { + if let Err(error) = + self.ensure_blob_with_progress(file_asset, &archive, activity, token) + { if asset.allow_cached_fallback && let Some(cached) = self.cached_install_fallback(asset.family, asset.required_name)? @@ -449,9 +409,6 @@ impl ArtifactStore { validate_cache_path(&self.cache, &install)?; if Self::install_pins_are_valid(&install, asset.archives)? { // A valid install skips extraction entirely. - if let Some(handle) = &extract { - handle.complete(); - } return find_executable(&install, asset.required_name, asset.platform); } @@ -468,12 +425,9 @@ impl ArtifactStore { // CUDA asset pairs the server zip with its runtime zip). for (archive, archive_ref) in downloaded.iter().zip(asset.archives.iter()) { validate_cache_path(&self.cache, archive)?; - if let Err(error) = extract_archive_with_progress( - archive, - &staging, - archive_ref.archive_kind, - extract.as_ref(), - ) { + if let Err(error) = + extract_archive_with_progress(archive, &staging, archive_ref.archive_kind, activity) + { let _ignored = fs::remove_dir_all(&staging); return Err(error); } @@ -614,17 +568,15 @@ impl ArtifactStore { Ok(tree_digest(install)? == lines[archives.len()]) } - /// `ensure_blob` variant that reports the download and verify stages - /// into the given leaves. Both leaves reach their terminal event on every - /// exit path: a cache hit completes the download leaf without work, and - /// the pin check after a download completes the verify leaf whether the - /// digest matches or not. + /// `ensure_blob` variant that writes the download and verify stages into + /// `activity`'s text: a cache hit reports at most the verify hash pass, + /// and the pin check after a download costs no second pass because the + /// digest is computed inline during the transfer. fn ensure_blob_with_progress( &self, asset: FileAsset<'_>, destination: &Path, - download: Option<&ProgressHandle>, - verify: Option<&ProgressHandle>, + activity: Option<&Activity>, token: Option<&CancellationToken>, ) -> Result<()> { let _lock = self.lock_artifact(destination)?; @@ -639,38 +591,28 @@ impl ArtifactStore { // malformed pin fails fast rather than always mismatching. let expected_digest = asset.sha256.map(parse_expected_digest).transpose()?; - // Set once the verify leaf has emitted its terminal event, so the pin - // recheck after a mismatch-repair download never emits it twice. - let mut verify_finished = false; if destination.is_file() { let Some(expected) = expected_digest.as_deref() else { - if let Some(handle) = download { - handle.complete(); - } if !crate::cache::blob_meta_matches(destination, asset.url)? { - let actual = file_digest_with_progress(destination, verify)?; + let actual = file_digest_with_progress(destination, activity)?; crate::cache::write_blob_meta(&self.cache, destination, asset.url, &actual)?; } - if let Some(handle) = verify { - handle.complete(); - } return Ok(()); }; let marker = blob_marker_path(destination); - match verify_blob_with_progress(&self.cache, destination, expected, &marker, verify) { + match verify_blob_with_progress(&self.cache, destination, expected, &marker, activity) { Ok(_) => { - // A verified cache hit leaves the download stage - // with no work. - if let Some(handle) = download { - handle.complete(); - } + // A verified cache hit has no download to run. crate::cache::write_blob_meta(&self.cache, destination, asset.url, expected)?; return Ok(()); } // A pin mismatch on a cached blob is repaired by // re-downloading; every other failure propagates. Err(LocalError::DigestMismatch { .. }) => { - verify_finished = true; + tracing::warn!( + name = asset.name, + "cached artifact no longer matches its pin; downloading it again" + ); remove_cache_entry(&self.cache, destination)?; } Err(error) => return Err(error), @@ -687,20 +629,16 @@ impl ArtifactStore { ensure_cache_directory(&self.cache, parent)?; validate_cache_path(&self.cache, &staging)?; // A failed transfer keeps the staged partial for resume. - let actual = match download::download(&self.client, asset.url, &staging, download, token) { - Ok(actual) => actual, - Err(error) => { - if !verify_finished && let Some(handle) = verify { - handle.complete(); - } - return Err(error); - } - }; + let actual = download::download( + &self.client, + asset.url, + &staging, + asset.name, + activity, + token, + )?; // The pin is checked against the digest computed inline during the - // download, so the verify stage's work ends here on every outcome. - if !verify_finished && let Some(handle) = verify { - handle.complete(); - } + // download, so no separate verify pass runs here. if let Some(expected) = expected_digest.as_deref() && actual != expected { @@ -731,7 +669,7 @@ impl ArtifactStore { &self, url: &str, destination: &Path, - progress: &dyn progress::DownloadProgress, + progress: &dyn DownloadProgress, ) -> Result { download::download_with_progress(&self.client, url, destination, progress, None) } @@ -796,7 +734,7 @@ pub(crate) fn download_client() -> Result { .connect_timeout(DOWNLOAD_CONNECT_TIMEOUT) .timeout(DOWNLOAD_REQUEST_TIMEOUT) .build() - .map_err(LocalError::HttpClient) + .map_err(|source| LocalError::HttpClient(source.into())) } /// Takes the advisory OS lock serializing publishers of `artifact` under diff --git a/crates/gateway/local/src/artifacts/archive.rs b/crates/gateway/local/src/artifacts/archive.rs index 73e5c776c..d8785a0ca 100644 --- a/crates/gateway/local/src/artifacts/archive.rs +++ b/crates/gateway/local/src/artifacts/archive.rs @@ -7,13 +7,34 @@ use std::path::{Path, PathBuf}; use flate2::read::GzDecoder; -use shared_progress::ProgressHandle; +use gateway_progress::Activity; use super::Result; use super::assets::ArchiveKind; use super::confine::{ensure_cache_directory, safe_relative_path, validate_tree_path}; +use super::download::PercentText; use crate::error::LocalError; +/// The `"Extracting {archive} {pct}%"` reporter for one extraction, when +/// the caller runs an activity. +struct Extracting<'a> { + activity: &'a Activity, + text: PercentText, +} + +impl<'a> Extracting<'a> { + fn new(activity: Option<&'a Activity>, archive: &Path) -> Option { + let activity = activity?; + let name = archive.file_name().map_or_else( + || archive.display().to_string(), + |name| name.to_string_lossy().into_owned(), + ); + let text = PercentText::new("Extracting", name); + activity.set_text(text.label()); + Some(Self { activity, text }) + } +} + /// Extracts `archive` into `destination`, dispatching on the archive kind. /// /// # Errors @@ -23,8 +44,8 @@ pub(super) fn extract_archive(archive: &Path, destination: &Path, kind: ArchiveK extract_archive_with_progress(archive, destination, kind, None) } -/// [`extract_archive`] variant that reports extracted-entry counts into -/// `progress`, when given. +/// [`extract_archive`] variant that formats the extracted-entry percent +/// into `activity`, when given. /// /// # Errors /// Returns [`LocalError`] on unsafe entries or extraction failures. @@ -32,26 +53,19 @@ pub(super) fn extract_archive_with_progress( archive: &Path, destination: &Path, kind: ArchiveKind, - progress: Option<&ProgressHandle>, + activity: Option<&Activity>, ) -> Result<()> { - let result = match kind { - ArchiveKind::TarGz => extract_tar_gz(archive, destination, progress), - ArchiveKind::Zip => extract_zip(archive, destination, progress), - }; - // Every exit path owes the leaf its terminal event; the success path - // completed it inside, and terminal state is sticky. - if result.is_err() - && let Some(handle) = progress - { - handle.fail(); + let progress = Extracting::new(activity, archive); + match kind { + ArchiveKind::TarGz => extract_tar_gz(archive, destination, progress.as_ref()), + ArchiveKind::Zip => extract_zip(archive, destination, progress.as_ref()), } - result } /// Reports `done` of `total` entries into `progress`, when both are known. -fn report_entries(progress: Option<&ProgressHandle>, total: Option, done: u64) { - if let (Some(handle), Some(total)) = (progress, total) { - handle.set_units(done, total); +fn report_entries(progress: Option<&Extracting<'_>>, total: Option, done: u64) { + if let (Some(progress), Some(total)) = (progress, total) { + progress.text.report(progress.activity, done, total); } } @@ -82,7 +96,7 @@ fn count_tar_gz_entries(archive: &Path) -> Result { fn extract_tar_gz( archive: &Path, destination: &Path, - progress: Option<&ProgressHandle>, + progress: Option<&Extracting<'_>>, ) -> Result<()> { let total = match progress { Some(_) => Some(count_tar_gz_entries(archive)?), @@ -140,16 +154,13 @@ fn extract_tar_gz( done = done.saturating_add(1); report_entries(progress, total, done); } - if let Some(handle) = progress { - handle.complete(); - } Ok(()) } fn extract_zip( archive: &Path, destination: &Path, - progress: Option<&ProgressHandle>, + progress: Option<&Extracting<'_>>, ) -> Result<()> { let file = File::open(archive).map_err(|source| LocalError::Io { operation: "open archive", @@ -215,9 +226,6 @@ fn extract_zip( done = done.saturating_add(1); report_entries(progress, total, done); } - if let Some(handle) = progress { - handle.complete(); - } Ok(()) } diff --git a/crates/gateway/local/src/artifacts/digest.rs b/crates/gateway/local/src/artifacts/digest.rs index 27c8a58a2..7bd7230e4 100644 --- a/crates/gateway/local/src/artifacts/digest.rs +++ b/crates/gateway/local/src/artifacts/digest.rs @@ -6,8 +6,9 @@ use std::path::{Path, PathBuf}; use sha2::{Digest, Sha256}; -use shared_progress::ProgressHandle; +use gateway_progress::Activity; +use super::download::PercentText; use super::{INSTALL_MARKER, Result}; use crate::error::LocalError; @@ -56,23 +57,30 @@ pub(super) fn file_digest(path: &Path) -> Result { file_digest_with_progress(path, None) } -/// [`file_digest`] variant that reports bytes read into `progress`, when given. +/// [`file_digest`] variant that formats `"Verifying {file} {pct}%"` into +/// `activity`, when given, as the hash pass reads. /// /// # Errors /// Returns [`LocalError::Io`] when the file cannot be opened, inspected, or read. pub(super) fn file_digest_with_progress( path: &Path, - progress: Option<&ProgressHandle>, + activity: Option<&Activity>, ) -> Result { let file = File::open(path).map_err(|source| LocalError::Io { operation: "open cached artifact", path: path.to_owned(), source, })?; - let total = progress - .map(|_| { + let reporter = activity + .map(|activity| { + let name = path.file_name().map_or_else( + || path.display().to_string(), + |name| name.to_string_lossy().into_owned(), + ); + let text = PercentText::new("Verifying", name); + activity.set_text(text.label()); file.metadata() - .map(|metadata| metadata.len()) + .map(|metadata| (activity, text, metadata.len())) .map_err(|source| LocalError::Io { operation: "inspect cached artifact", path: path.to_owned(), @@ -95,8 +103,8 @@ pub(super) fn file_digest_with_progress( } hasher.update(&buffer[..count]); read = read.saturating_add(count as u64); - if let (Some(handle), Some(total)) = (progress, total) { - handle.set_units(read, total); + if let Some((activity, text, total)) = &reporter { + text.report(activity, read, *total); } } Ok(hex_digest(hasher)) diff --git a/crates/gateway/local/src/artifacts/download.rs b/crates/gateway/local/src/artifacts/download.rs index 39b3656d6..5d6844414 100644 --- a/crates/gateway/local/src/artifacts/download.rs +++ b/crates/gateway/local/src/artifacts/download.rs @@ -4,21 +4,121 @@ use std::fs::{self, File, OpenOptions}; use std::io::{BufWriter, Read, Write}; use std::path::Path; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::mpsc; use std::time::Duration; +use gateway_progress::Activity; use reqwest::StatusCode; use reqwest::blocking::{Client, Response}; use sha2::{Digest, Sha256}; -use shared_progress::ProgressHandle; use tokio_util::sync::CancellationToken; use super::Result; use super::confine::source_marker_path; use super::digest::hex_digest; -use super::progress::{DownloadProgress, NoopProgress, TreeProgress}; use crate::error::LocalError; +/// Progress updates for a single HTTP blob download. +pub trait DownloadProgress: Send { + /// Records the total length in bytes, when the server sent one. + fn set_len(&self, total: Option); + /// Adds `n` downloaded bytes to the running total. + fn inc(&self, n: u64); +} + +/// A [`DownloadProgress`] that discards every callback, for callers with no +/// activity to report into. +pub(super) struct NoopProgress; + +impl DownloadProgress for NoopProgress { + fn set_len(&self, _total: Option) {} + + fn inc(&self, _n: u64) {} +} + +/// A `"{verb} {name} {pct}%"` line republished into an activity on each +/// whole-percent change only, so a byte-granular loop never floods the +/// hub's subscribers. +#[derive(Debug)] +pub struct PercentText { + verb: &'static str, + name: String, + /// The last percent published, or `u64::MAX` before the first. + percent: AtomicU64, +} + +impl PercentText { + /// A reporter for `"{verb} {name} ..."`, e.g. `("Downloading", "qwen.gguf")`. + #[must_use] + pub fn new(verb: &'static str, name: impl Into) -> Self { + Self { + verb, + name: name.into(), + percent: AtomicU64::new(u64::MAX), + } + } + + /// The bare `"{verb} {name}"` line, for a phase whose length is unknown. + #[must_use] + pub fn label(&self) -> String { + format!("{} {}", self.verb, self.name) + } + + /// Publishes `done` of `total` as a whole percent when it changed. A + /// zero total publishes the bare label once. + pub fn report(&self, activity: &Activity, done: u64, total: u64) { + if total == 0 { + if self.percent.swap(0, Ordering::Relaxed) != 0 { + activity.set_text(self.label()); + } + return; + } + let percent = done.saturating_mul(100) / total; + let percent = percent.min(100); + if self.percent.swap(percent, Ordering::Relaxed) != percent { + activity.set_text(format!("{} {} {percent}%", self.verb, self.name)); + } + } +} + +/// A [`DownloadProgress`] that formats `"Downloading {name} {pct}%"` into an +/// activity's text on each whole-percent change. Without a Content-Length +/// the text stays at the bare `"Downloading {name}"`. +pub(super) struct ActivityProgress<'a> { + activity: &'a Activity, + text: PercentText, + total: AtomicU64, + downloaded: AtomicU64, +} + +impl<'a> ActivityProgress<'a> { + pub(super) fn new(activity: &'a Activity, name: &str) -> Self { + let text = PercentText::new("Downloading", name); + activity.set_text(text.label()); + Self { + activity, + text, + total: AtomicU64::new(0), + downloaded: AtomicU64::new(0), + } + } +} + +impl DownloadProgress for ActivityProgress<'_> { + fn set_len(&self, total: Option) { + self.total.store(total.unwrap_or(0), Ordering::Relaxed); + } + + fn inc(&self, n: u64) { + let downloaded = self.downloaded.fetch_add(n, Ordering::Relaxed) + n; + let total = self.total.load(Ordering::Relaxed); + if total > 0 { + self.text.report(self.activity, downloaded, total); + } + } +} + /// Hard ceiling on a single artifact, guarding the cache volume against a /// malicious or mistaken endpoint. Generous enough for large GGUF weights. const MAX_ARTIFACT_BYTES: u64 = 256 * 1024 * 1024 * 1024; @@ -72,9 +172,10 @@ pub(super) fn hub_bearer_token(lookup: impl Fn(&str) -> Option) -> Optio None } -/// Downloads `url` to `destination`, reporting byte counts into a leaf of -/// the progress tree when `tree` is given, and returns the SHA-256 hex -/// digest of the streamed bytes. +/// Downloads `url` to `destination`, formatting `"Downloading {name} {pct}%"` +/// into `activity` when given, and returns the SHA-256 hex digest of the +/// streamed bytes. The outcome is the caller's to log: the reporter carries +/// byte counts only. /// /// # Errors /// Returns [`LocalError`] on transport, size-cap, or filesystem failure. @@ -82,35 +183,16 @@ pub(super) fn download( client: &Client, url: &str, destination: &Path, - tree: Option<&ProgressHandle>, - token: Option<&CancellationToken>, -) -> Result { - match tree { - Some(handle) => { - let leaf = TreeProgress::new(handle.clone()); - run_download(client, url, destination, &leaf, token) - } - None => run_download(client, url, destination, &NoopProgress, token), - } -} - -/// Runs the download, reporting the terminal outcome to `progress`. -fn run_download( - client: &Client, - url: &str, - destination: &Path, - progress: &dyn DownloadProgress, + name: &str, + activity: Option<&Activity>, token: Option<&CancellationToken>, ) -> Result { - match download_with_progress(client, url, destination, progress, token) { - Ok(digest) => { - progress.finish(); - Ok(digest) - } - Err(error) => { - progress.abandon(); - Err(error) + match activity { + Some(activity) => { + let progress = ActivityProgress::new(activity, name); + download_with_progress(client, url, destination, &progress, token) } + None => download_with_progress(client, url, destination, &NoopProgress, token), } } @@ -167,7 +249,7 @@ fn send(client: &Client, url: &str, resume_from: u64) -> Result { } request.send().map_err(|source| LocalError::Download { url: url.to_owned(), - source, + source: source.into(), }) } @@ -378,7 +460,7 @@ pub(super) fn download_with_idle( .error_for_status() .map_err(|source| LocalError::Download { url: url.to_owned(), - source, + source: source.into(), })?; let total = response .content_length() diff --git a/crates/gateway/local/src/artifacts/progress.rs b/crates/gateway/local/src/artifacts/progress.rs deleted file mode 100644 index 9825de7d0..000000000 --- a/crates/gateway/local/src/artifacts/progress.rs +++ /dev/null @@ -1,82 +0,0 @@ -//! Download progress reporting into progress-tree leaves. -//! -//! The library never chooses a presentation: the owning process renders the -//! hub (the gateway binary emits tracing lines). - -use std::sync::atomic::{AtomicU64, Ordering}; - -use shared_progress::ProgressHandle; - -/// Progress updates for a single HTTP blob download. -pub trait DownloadProgress: Send { - /// Records the total length in bytes, when the server sent one. - fn set_len(&self, total: Option); - /// Adds `n` downloaded bytes to the running total. - fn inc(&self, n: u64); - /// Marks the download complete. - fn finish(&self); - /// Marks the download abandoned before completion. - fn abandon(&self); -} - -/// A [`DownloadProgress`] that discards every callback, for callers with no -/// progress tree. -pub(super) struct NoopProgress; - -impl DownloadProgress for NoopProgress { - fn set_len(&self, _total: Option) {} - - fn inc(&self, _n: u64) {} - - fn finish(&self) {} - - fn abandon(&self) {} -} - -/// A [`DownloadProgress`] that reports byte counts into a progress-tree leaf. -/// -/// The leaf's fraction is driven by `set_units(downloaded, total)` once -/// [`DownloadProgress::set_len`] supplies the Content-Length; without a -/// length the leaf stays indeterminate until [`DownloadProgress::finish`]. -#[derive(Debug)] -pub struct TreeProgress { - handle: ProgressHandle, - total: AtomicU64, - downloaded: AtomicU64, -} - -impl TreeProgress { - /// Creates a reporter that feeds `handle` from the download's byte counts. - #[must_use] - pub fn new(handle: ProgressHandle) -> Self { - Self { - handle, - total: AtomicU64::new(0), - downloaded: AtomicU64::new(0), - } - } -} - -impl DownloadProgress for TreeProgress { - fn set_len(&self, total: Option) { - self.total.store(total.unwrap_or(0), Ordering::Relaxed); - } - - fn inc(&self, n: u64) { - let downloaded = self.downloaded.fetch_add(n, Ordering::Relaxed) + n; - let total = self.total.load(Ordering::Relaxed); - if total > 0 { - self.handle.set_units(downloaded, total); - } - } - - fn finish(&self) { - self.handle.complete(); - } - - fn abandon(&self) { - // The handle vocabulary has no failure terminal; the operation owner - // carries failure through its own exit path, so the leaf completes. - self.handle.complete(); - } -} diff --git a/crates/gateway/local/src/artifacts/tests.rs b/crates/gateway/local/src/artifacts/tests.rs index e476e824a..bafc3406e 100644 --- a/crates/gateway/local/src/artifacts/tests.rs +++ b/crates/gateway/local/src/artifacts/tests.rs @@ -1,3 +1,5 @@ +//! Tests for artifact digests, archive extraction safety, publication, and cache confinement. + use std::io::{self, Read as _, Write as _}; use std::net::{TcpListener, TcpStream}; use std::sync::atomic::{AtomicU64, Ordering}; @@ -7,15 +9,16 @@ use std::time::Duration; use tempfile::TempDir; -use shared_progress::{EventState, ProgressHub}; +use gateway_progress::ProgressHub; use tokio_util::sync::CancellationToken; use super::archive::{extract_archive, extract_archive_with_progress, safe_archive_path}; use super::assets::ArchiveRef; use super::confine::source_marker_path; use super::digest::file_digest; -use super::download::{hub_bearer_token, is_huggingface_https}; -use super::progress::{DownloadProgress, TreeProgress}; +use super::download::{ + ActivityProgress, DownloadProgress, PercentText, hub_bearer_token, is_huggingface_https, +}; use super::verified::write_marker; use super::verified::{VerifyOutcome, blob_marker_path, verify_blob, verify_blob_with_progress}; use super::*; @@ -627,8 +630,6 @@ fn validate_cache_path_rejects_symlink_component() { struct RecordingProgress { total: Mutex>, bytes: AtomicU64, - finished: AtomicU64, - abandoned: AtomicU64, } impl RecordingProgress { @@ -636,8 +637,6 @@ impl RecordingProgress { Self { total: Mutex::new(None), bytes: AtomicU64::new(0), - finished: AtomicU64::new(0), - abandoned: AtomicU64::new(0), } } } @@ -650,14 +649,6 @@ impl DownloadProgress for RecordingProgress { fn inc(&self, n: u64) { self.bytes.fetch_add(n, Ordering::Relaxed); } - - fn finish(&self) { - self.finished.fetch_add(1, Ordering::Relaxed); - } - - fn abandon(&self) { - self.abandoned.fetch_add(1, Ordering::Relaxed); - } } #[test] @@ -696,8 +687,6 @@ fn download_with_progress_reports_content_length_and_bytes() { Some(body.len() as u64) ); assert_eq!(progress.bytes.load(Ordering::Relaxed), body.len() as u64); - progress.finish(); - assert_eq!(progress.finished.load(Ordering::Relaxed), 1); } /// Seeds an interrupted download: `partial` bytes at `dest` plus the @@ -1307,17 +1296,16 @@ fn second_verification_hits_marker_without_rehash() { } #[test] -#[expect(clippy::float_cmp, reason = "fixed-point fractions compare exactly")] -fn ensure_model_with_progress_reports_download_and_verify_leaves() { - // A URL source flows through `ensure_blob`: the download leaf rides the - // transfer, and the verify leaf completes on the inline pin check. +fn ensure_model_with_progress_writes_the_download_text_and_a_cache_hit_writes_nothing() { + // A URL source flows through `ensure_blob`: the transfer writes its + // percent into the activity, and the pin check rides the inline digest, + // so no verify text follows the download. let body = b"model-bytes"; let server = FakeServer::new(body); let temp = TempDir::new().expect("tempdir"); let store = ArtifactStore::new(temp.path()).expect("store"); - let hub = Arc::new(ProgressHub::new()); - let tree = hub.operation(); - let model = tree.register("model", 1.0); + let hub = ProgressHub::new(); + let model = hub.begin("model"); let url = server.url("model.gguf"); let pin = hex_sha256(body); @@ -1325,50 +1313,32 @@ fn ensure_model_with_progress_reports_download_and_verify_leaves() { .ensure_model_with_progress(&url, Some(&pin), Some(&model)) .expect("ensure model"); assert_eq!(std::fs::read(&path).expect("read model"), body); - let snapshot = hub.snapshot(); - let nodes = &snapshot[0].nodes; - let paths: Vec<&str> = nodes.iter().map(|node| node.path.as_str()).collect(); - assert_eq!(paths, ["model", "model/download", "model/verify"]); - assert!( - nodes.iter().all(|node| node.fraction == 1.0), - "both stages complete after a pinned download: {nodes:?}" + assert_eq!( + hub.current().text, + "Downloading model.gguf 100%", + "the pinned download ends at its last whole percent" ); - // A warm-cache repeat under a fresh subtree: the marker hit completes - // verify without a hash pass, and the download leaf completes with no - // transfer at all. - let cached = tree.register("cached", 1.0); + // A warm-cache repeat under a fresh activity: the marker hit runs no + // hash pass and no transfer, so the activity text is untouched. + let cached = hub.begin("cached"); store .ensure_model_with_progress(&url, Some(&pin), Some(&cached)) .expect("ensure model from cache"); - let snapshot = hub.snapshot(); - let nodes = &snapshot[0].nodes; - let paths: Vec<&str> = nodes.iter().map(|node| node.path.as_str()).collect(); assert_eq!( - paths, - [ - "model", - "model/download", - "model/verify", - "cached", - "cached/download", - "cached/verify", - ] - ); - assert!( - nodes.iter().all(|node| node.fraction == 1.0), - "a cache hit completes both stages without work: {nodes:?}" + hub.current().text, + "cached", + "a cache hit writes no stage text" ); assert_eq!(server.requests(), 1, "the cache hit re-downloads nothing"); } #[test] -#[expect(clippy::float_cmp, reason = "fixed-point fractions compare exactly")] -fn ensure_blob_mismatch_repair_finishes_the_verify_leaf_exactly_once() { +fn ensure_blob_mismatch_repair_hashes_then_downloads() { // A cached blob whose content no longer matches the pin is repaired by - // re-downloading: the first hash pass finishes the verify leaf, and the - // pin recheck against the fresh download's inline digest must not emit - // the leaf's terminal event a second time. + // re-downloading: the hash pass names the verify stage, then the fresh + // transfer names the download, and the pin recheck against the inline + // digest adds no second verify text. let body = b"repaired-blob-bytes"; let server = FakeServer::new(body); let temp = TempDir::new().expect("tempdir"); @@ -1378,12 +1348,8 @@ fn ensure_blob_mismatch_repair_finishes_the_verify_leaf_exactly_once() { .expect("mkdir downloads"); std::fs::write(&destination, b"stale-bytes").expect("write stale blob"); - let hub = Arc::new(ProgressHub::new()); - let mut rx = hub.subscribe(); - let tree = hub.operation(); - let blob = tree.register("blob", 1.0); - let download = blob.child("download", 4.0); - let verify = blob.child("verify", 1.0); + let hub = ProgressHub::new(); + let blob = hub.begin("blob"); let url = server.url("model.gguf"); let pin = hex_sha256(body); let asset = FileAsset { @@ -1393,27 +1359,19 @@ fn ensure_blob_mismatch_repair_finishes_the_verify_leaf_exactly_once() { }; store - .ensure_blob_with_progress(asset, &destination, Some(&download), Some(&verify), None) + .ensure_blob_with_progress(asset, &destination, Some(&blob), None) .expect("mismatch repair re-downloads"); assert_eq!(std::fs::read(&destination).expect("read blob"), body); - assert_eq!(download.fraction(), 1.0); - assert_eq!(verify.fraction(), 1.0); assert_eq!(server.requests(), 1, "the repair downloads once"); - - let mut verify_finished = 0; - while let Ok(event) = rx.try_recv() { - if event.path == "blob/verify" && matches!(event.state, EventState::Finished { .. }) { - verify_finished += 1; - } - } assert_eq!( - verify_finished, 1, - "the pin recheck after repair must not re-emit the terminal event" + hub.current().text, + "Downloading model.gguf 100%", + "the transfer is the last stage; no verify text follows the inline pin check" ); } #[test] -fn extract_failure_fails_the_leaf() { +fn extract_failure_leaves_the_extracting_text_and_propagates() { use zip::write::SimpleFileOptions; let dir = TempDir::new().expect("tempdir"); @@ -1430,38 +1388,29 @@ fn extract_failure_fails_the_leaf() { let dest = dir.path().join("out"); std::fs::create_dir(&dest).expect("mkdir dest"); - let hub = Arc::new(ProgressHub::new()); - let mut rx = hub.subscribe(); - let tree = hub.operation(); - let leaf = tree.register("extract", 1.0); + let hub = ProgressHub::new(); + let activity = hub.begin("extract"); - let result = extract_archive_with_progress(&archive, &dest, ArchiveKind::Zip, Some(&leaf)); + let result = extract_archive_with_progress(&archive, &dest, ArchiveKind::Zip, Some(&activity)); assert!(matches!(result, Err(LocalError::UnsafeArchiveEntry { .. }))); - - let mut failed = false; - while let Ok(event) = rx.try_recv() { - if let EventState::Finished { ok } = event.state { - failed = !ok; - } - } - assert!( - failed, - "an extraction error ends the leaf with a failure terminal" + assert_eq!( + hub.current().text, + "Extracting evil.zip", + "the stage was named before the unsafe entry stopped it; the error carries the failure" ); } #[test] -fn ensure_model_with_progress_fails_the_verify_leaf_on_a_bad_pin() { +fn ensure_model_with_progress_rejects_a_bad_pin_before_any_stage_text() { // A path source whose pin cannot be parsed returns before any verify - // work; the registered verify leaf still owes its terminal event. + // work, so the activity text never moves. let dir = TempDir::new().expect("tempdir"); let model = dir.path().join("model.gguf"); std::fs::write(&model, b"model-bytes").expect("write model"); let store = ArtifactStore::new(dir.path().join("cache")).expect("store"); - let hub = Arc::new(ProgressHub::new()); - let tree = hub.operation(); - let parent = tree.register("model", 1.0); + let hub = ProgressHub::new(); + let parent = hub.begin("model"); let result = store.ensure_model_with_progress( model.to_str().expect("utf8 path"), @@ -1469,32 +1418,14 @@ fn ensure_model_with_progress_fails_the_verify_leaf_on_a_bad_pin() { Some(&parent), ); assert!(matches!(result, Err(LocalError::InvalidDigest { .. }))); - - let nodes = &hub.snapshot()[0].nodes; - let download = nodes - .iter() - .find(|node| node.path == "model/download") - .expect("download leaf"); - let verify = nodes - .iter() - .find(|node| node.path == "model/verify") - .expect("verify leaf"); - assert!( - download.finished && download.ok, - "a path source completes the download leaf: {download:?}" - ); - assert!( - verify.finished && !verify.ok, - "the unparseable pin fails the verify leaf: {verify:?}" - ); + assert_eq!(hub.current().text, "model"); } #[test] -#[expect(clippy::float_cmp, reason = "fixed-point fractions compare exactly")] -fn provision_server_completes_download_verify_extract_leaves_on_a_warm_cache() { +fn provision_server_writes_no_stage_text_on_a_warm_cache() { // A warm cache - the archive blob with a current verified marker and a - // valid install tree - runs no download, hash, or extraction, but every - // stage leaf still reaches its terminal event. + // valid install tree - runs no download, hash, or extraction, so no + // stage names reach the activity. // An explicit backend keeps the test deterministic on GPU machines: // provisioning must not probe nvidia-smi. let asset = server_asset( @@ -1531,9 +1462,8 @@ fn provision_server_completes_download_verify_extract_leaves_on_a_warm_cache() { marker_text.push('\n'); std::fs::write(install.join(INSTALL_MARKER), marker_text).expect("write install marker"); - let hub = Arc::new(ProgressHub::new()); - let tree = hub.operation(); - let server = tree.register("llama-server", 1.0); + let hub = ProgressHub::new(); + let server = hub.begin("llama-server"); let provisioned = store .provision_llama_server_with_progress( @@ -1546,27 +1476,15 @@ fn provision_server_completes_download_verify_extract_leaves_on_a_warm_cache() { .expect("warm-cache provision"); assert_eq!(provisioned.executable, install.join(asset.executable_name)); assert!(provisioned.path_prefix.is_empty()); - - let snapshot = hub.snapshot(); - let nodes = &snapshot[0].nodes; - let paths: Vec<&str> = nodes.iter().map(|node| node.path.as_str()).collect(); assert_eq!( - paths, - [ - "llama-server", - "llama-server/download", - "llama-server/verify", - "llama-server/extract", - ] - ); - assert!( - nodes.iter().all(|node| node.fraction == 1.0), - "a valid install completes every stage without work: {nodes:?}" + hub.current().text, + "llama-server", + "a valid install runs no stage and names none: {:?}", + hub.current() ); } #[test] -#[expect(clippy::float_cmp, reason = "fixed-point fractions compare exactly")] fn provision_whisper_library_reuses_a_verified_install() { let asset = whisper_asset(std::env::consts::OS, std::env::consts::ARCH).expect("host whisper asset"); @@ -1595,28 +1513,16 @@ fn provision_whisper_library_reuses_a_verified_install() { ) .expect("write install marker"); - let hub = Arc::new(ProgressHub::new()); - let tree = hub.operation(); - let whisper = tree.register("whisper-library", 1.0); + let hub = ProgressHub::new(); + let whisper = hub.begin("whisper-library"); let provisioned = store .provision_whisper_library(Some(&whisper)) .expect("warm-cache provision"); assert_eq!(provisioned, install.join(asset.library_name)); - - let nodes = &hub.snapshot()[0].nodes; - let paths: Vec<&str> = nodes.iter().map(|node| node.path.as_str()).collect(); assert_eq!( - paths, - [ - "whisper-library", - "whisper-library/download", - "whisper-library/verify", - "whisper-library/extract", - ] - ); - assert!( - nodes.iter().all(|node| node.fraction == 1.0), - "a verified whisper install completes every stage: {nodes:?}" + hub.current().text, + "whisper-library", + "a verified whisper install runs no stage and names none" ); } @@ -1668,135 +1574,118 @@ fn a_missing_llama_server_path_is_an_error() { } #[test] -#[expect(clippy::float_cmp, reason = "fixed-point fractions compare exactly")] -fn tree_progress_drives_handle_fraction_per_byte() { - let hub = Arc::new(ProgressHub::new()); - let tree = hub.operation(); - let leaf = tree.register("download", 1.0); - let progress = TreeProgress::new(leaf.clone()); - - progress.set_len(Some(200)); - progress.inc(50); - assert_eq!(leaf.fraction(), 0.25); - progress.inc(150); - assert_eq!(leaf.fraction(), 1.0); - progress.finish(); - assert_eq!(leaf.fraction(), 1.0); +fn percent_text_republishes_on_whole_percent_changes_only() { + let hub = ProgressHub::new(); + let mut rx = hub.subscribe(); + let activity = hub.begin("download"); + let text = PercentText::new("Downloading", "m.gguf"); + assert_eq!(text.label(), "Downloading m.gguf"); + + text.report(&activity, 50, 200); + assert_eq!(hub.current().text, "Downloading m.gguf 25%"); + rx.mark_unchanged(); + // Sub-percent movement changes nothing and wakes no subscriber. + text.report(&activity, 51, 200); + assert!( + !rx.has_changed().expect("the hub is alive"), + "a move inside one percent republishes nothing" + ); + text.report(&activity, 200, 200); + assert_eq!(hub.current().text, "Downloading m.gguf 100%"); + // Past-the-end counts clamp rather than printing nonsense. + text.report(&activity, 300, 200); + assert_eq!(hub.current().text, "Downloading m.gguf 100%"); + + // Without a total the bare label is published once. + let unknown = PercentText::new("Downloading", "unknown.bin"); + unknown.report(&activity, 10, 0); + assert_eq!(hub.current().text, "Downloading unknown.bin"); + rx.mark_unchanged(); + unknown.report(&activity, 20, 0); + assert!( + !rx.has_changed().expect("the hub is alive"), + "an unknown length republishes the bare label only once" + ); +} - // Without a Content-Length the leaf stays indeterminate until finish. - let unknown = tree.register("unknown-length", 1.0); - let progress = TreeProgress::new(unknown.clone()); +#[test] +fn download_without_a_content_length_keeps_the_bare_text() { + // A `DownloadProgress` fed no length never sees a percent: the text + // stays at the stage name set when the transfer began. + let hub = ProgressHub::new(); + let activity = hub.begin("download"); + let progress = ActivityProgress::new(&activity, "blob.bin"); + assert_eq!(hub.current().text, "Downloading blob.bin"); progress.set_len(None); progress.inc(10); - assert_eq!(unknown.fraction(), 0.0); - progress.finish(); - assert_eq!(unknown.fraction(), 1.0); - - // Abandon completes the leaf: the handle vocabulary has no failure - // terminal, so the owner carries failure through its own exit path. - let abandoned = tree.register("abandoned", 1.0); - let progress = TreeProgress::new(abandoned.clone()); + assert_eq!(hub.current().text, "Downloading blob.bin"); progress.set_len(Some(100)); progress.inc(40); - progress.abandon(); - assert_eq!(abandoned.fraction(), 1.0); + assert_eq!( + hub.current().text, + "Downloading blob.bin 50%", + "the first length makes every byte so far count" + ); } #[test] -#[expect(clippy::float_cmp, reason = "fixed-point fractions compare exactly")] -fn verify_blob_reports_bytes_read_during_hash() { - // Two full 64 KiB read chunks: 0.5 after the first, 1.0 after the second. +fn verify_blob_writes_the_hash_pass_percent() { + // Two full 64 KiB read chunks: 50% after the first, 100% after the second. let body = vec![0xAB_u8; 128 * 1024]; let (dir, blob, digest, marker) = pinned_blob_fixture(&body); let root = dir.path().join("cache"); - let hub = Arc::new(ProgressHub::new()); - let mut rx = hub.subscribe(); - let tree = hub.operation(); - let leaf = tree.register("verify", 1.0); + let hub = ProgressHub::new(); + let activity = hub.begin("verify"); let outcome = - verify_blob_with_progress(&root, &blob, &digest, &marker, Some(&leaf)).expect("verify"); + verify_blob_with_progress(&root, &blob, &digest, &marker, Some(&activity)).expect("verify"); assert_eq!(outcome, VerifyOutcome::Hashed); - assert_eq!(leaf.fraction(), 1.0); - - let mut updates = Vec::new(); - let mut finished = false; - while let Ok(event) = rx.try_recv() { - match event.state { - EventState::Updated { fraction } => updates.push(fraction), - EventState::Finished { ok } => finished = ok, - _ => {} - } - } - assert_eq!(updates, vec![0.5, 1.0]); - assert!(finished, "the hash pass ends with a terminal event"); + assert_eq!(hub.current().text, "Verifying m.gguf 100%"); } #[test] -#[expect(clippy::float_cmp, reason = "fixed-point fractions compare exactly")] -fn verify_blob_marker_hit_completes_the_leaf_without_updates() { +fn verify_blob_marker_hit_writes_no_text() { let body = b"blob-bytes"; let (dir, blob, digest, marker) = pinned_blob_fixture(body); let root = dir.path().join("cache"); let first = verify_blob(&root, &blob, &digest, &marker).expect("first verify"); assert_eq!(first, VerifyOutcome::Hashed); - let hub = Arc::new(ProgressHub::new()); - let mut rx = hub.subscribe(); - let tree = hub.operation(); - let leaf = tree.register("verify", 1.0); + let hub = ProgressHub::new(); + let activity = hub.begin("verify"); let outcome = - verify_blob_with_progress(&root, &blob, &digest, &marker, Some(&leaf)).expect("verify"); + verify_blob_with_progress(&root, &blob, &digest, &marker, Some(&activity)).expect("verify"); assert_eq!(outcome, VerifyOutcome::MarkerHit); - assert_eq!(leaf.fraction(), 1.0); - - let mut updates = 0; - let mut finished = false; - while let Ok(event) = rx.try_recv() { - match event.state { - EventState::Updated { .. } => updates += 1, - EventState::Finished { ok } => finished = ok, - _ => {} - } - } - assert_eq!(updates, 0, "a marker hit reads nothing and reports nothing"); - assert!(finished, "a marker hit still ends with a terminal event"); + assert_eq!( + hub.current().text, + "verify", + "a marker hit reads nothing and names no stage" + ); } #[test] -#[expect(clippy::float_cmp, reason = "fixed-point fractions compare exactly")] -fn verify_blob_completes_the_leaf_before_a_digest_mismatch() { +fn verify_blob_names_the_hash_pass_before_a_digest_mismatch() { let body = b"blob-bytes"; let (dir, blob, _digest, marker) = pinned_blob_fixture(body); let root = dir.path().join("cache"); let wrong = hex_sha256(b"other-bytes"); - let hub = Arc::new(ProgressHub::new()); - let mut rx = hub.subscribe(); - let tree = hub.operation(); - let leaf = tree.register("verify", 1.0); + let hub = ProgressHub::new(); + let activity = hub.begin("verify"); - let result = verify_blob_with_progress(&root, &blob, &wrong, &marker, Some(&leaf)); + let result = verify_blob_with_progress(&root, &blob, &wrong, &marker, Some(&activity)); assert!(matches!(result, Err(LocalError::DigestMismatch { .. }))); - assert_eq!(leaf.fraction(), 1.0); - - let mut finished = false; - while let Ok(event) = rx.try_recv() { - if let EventState::Finished { ok } = event.state { - finished = ok; - } - } - assert!( - finished, - "the hash pass ends with a terminal event even on mismatch" + assert_eq!( + hub.current().text, + "Verifying m.gguf 100%", + "the hash pass ran to its end; the mismatch error carries the failure" ); } #[test] -#[expect(clippy::float_cmp, reason = "fixed-point fractions compare exactly")] -fn extract_zip_reports_entry_counts() { +fn extract_zip_writes_the_entry_percent() { use zip::write::SimpleFileOptions; let dir = TempDir::new().expect("tempdir"); @@ -1815,30 +1704,16 @@ fn extract_zip_reports_entry_counts() { let dest = dir.path().join("out"); std::fs::create_dir(&dest).expect("mkdir dest"); - let hub = Arc::new(ProgressHub::new()); - let mut rx = hub.subscribe(); - let tree = hub.operation(); - let leaf = tree.register("extract", 1.0); - - extract_archive_with_progress(&archive, &dest, ArchiveKind::Zip, Some(&leaf)).expect("extract"); - assert_eq!(leaf.fraction(), 1.0); - - let mut updates = Vec::new(); - let mut finished = false; - while let Ok(event) = rx.try_recv() { - match event.state { - EventState::Updated { fraction } => updates.push(fraction), - EventState::Finished { ok } => finished = ok, - _ => {} - } - } - assert_eq!(updates, vec![0.25, 0.5, 0.75, 1.0]); - assert!(finished, "extraction ends with a terminal event"); + let hub = ProgressHub::new(); + let activity = hub.begin("extract"); + + extract_archive_with_progress(&archive, &dest, ArchiveKind::Zip, Some(&activity)) + .expect("extract"); + assert_eq!(hub.current().text, "Extracting bundle.zip 100%"); } #[test] -#[expect(clippy::float_cmp, reason = "fixed-point fractions compare exactly")] -fn extract_tar_gz_reports_entry_counts() { +fn extract_tar_gz_writes_the_entry_percent() { use flate2::Compression; use flate2::write::GzEncoder; @@ -1865,26 +1740,12 @@ fn extract_tar_gz_reports_entry_counts() { let dest = dir.path().join("out"); std::fs::create_dir(&dest).expect("mkdir dest"); - let hub = Arc::new(ProgressHub::new()); - let mut rx = hub.subscribe(); - let tree = hub.operation(); - let leaf = tree.register("extract", 1.0); + let hub = ProgressHub::new(); + let activity = hub.begin("extract"); - extract_archive_with_progress(&archive, &dest, ArchiveKind::TarGz, Some(&leaf)) + extract_archive_with_progress(&archive, &dest, ArchiveKind::TarGz, Some(&activity)) .expect("extract"); - assert_eq!(leaf.fraction(), 1.0); - - let mut updates = Vec::new(); - let mut finished = false; - while let Ok(event) = rx.try_recv() { - match event.state { - EventState::Updated { fraction } => updates.push(fraction), - EventState::Finished { ok } => finished = ok, - _ => {} - } - } - assert_eq!(updates, vec![0.5, 1.0]); - assert!(finished, "extraction ends with a terminal event"); + assert_eq!(hub.current().text, "Extracting bundle.tar.gz 100%"); } #[test] diff --git a/crates/gateway/local/src/artifacts/verified.rs b/crates/gateway/local/src/artifacts/verified.rs index a466c20ab..596dd4c1a 100644 --- a/crates/gateway/local/src/artifacts/verified.rs +++ b/crates/gateway/local/src/artifacts/verified.rs @@ -18,7 +18,7 @@ use std::io; use std::path::{Path, PathBuf}; use std::time::UNIX_EPOCH; -use shared_progress::ProgressHandle; +use gateway_progress::Activity; use super::confine::{ensure_cache_directory, validate_cache_path, write_synced}; use super::digest::file_digest_with_progress; @@ -66,9 +66,8 @@ pub(super) fn verify_blob( verify_blob_with_progress(cache_root, path, expected, marker, None) } -/// [`verify_blob`] variant that reports hash-pass bytes read into `progress`, -/// when given. A marker hit reads nothing and reports no updates, but still -/// completes the leaf: every exit path owes the terminal event. +/// [`verify_blob`] variant that formats the hash pass's percent into +/// `activity`, when given. A marker hit reads nothing and changes no text. /// /// # Errors /// Returns [`LocalError::UnsafeCachePath`] when `marker` (or `path`, when it @@ -81,7 +80,7 @@ pub(super) fn verify_blob_with_progress( path: &Path, expected: &str, marker: &Path, - progress: Option<&ProgressHandle>, + activity: Option<&Activity>, ) -> Result { validate_cache_path(cache_root, marker)?; // A path source lives outside the cache by design; only confine the blob @@ -90,19 +89,9 @@ pub(super) fn verify_blob_with_progress( validate_cache_path(cache_root, path)?; } if marker_matches(marker, path, expected)? { - // The verify work is done with nothing to measure; the leaf still - // owes its terminal event on every exit path. - if let Some(handle) = progress { - handle.complete(); - } return Ok(VerifyOutcome::MarkerHit); } - let actual = file_digest_with_progress(path, progress)?; - // The hash pass is the leaf's measurable work and is done whether or not - // the digest matches; the mismatch error carries the failure. - if let Some(handle) = progress { - handle.complete(); - } + let actual = file_digest_with_progress(path, activity)?; if actual != expected { let _ignored = fs::remove_file(marker); return Err(LocalError::DigestMismatch { diff --git a/crates/gateway/local/src/cache.rs b/crates/gateway/local/src/cache.rs index 5c592713c..d9da8d157 100644 --- a/crates/gateway/local/src/cache.rs +++ b/crates/gateway/local/src/cache.rs @@ -264,17 +264,10 @@ impl BlobCache { // A failed transfer keeps the staged partial for resume. The blob // cache routes carry no cancellation token, so the transfer runs to // its own end. - let actual = match download_with_progress(&self.client, source, &staging, progress, None) { - Ok(actual) => actual, - Err(error) => { - progress.abandon(); - return Err(error); - } - }; + let actual = download_with_progress(&self.client, source, &staging, progress, None)?; if let Some(expected) = expected.as_deref() && actual != expected { - progress.abandon(); return Err(LocalError::DigestMismatch { name: filename_from_url(source)?, expected: expected.to_owned(), @@ -304,7 +297,6 @@ impl BlobCache { source: io::Error::other(source_err), })?; write_synced(&meta_path(&destination), &meta_json)?; - progress.finish(); Ok(CachedBlob { path: destination, sha256: actual, @@ -622,8 +614,6 @@ mod tests { struct RecordingProgress { total: Mutex>, bytes: AtomicU64, - finished: AtomicU64, - abandoned: AtomicU64, } impl RecordingProgress { @@ -631,8 +621,6 @@ mod tests { Self { total: Mutex::new(None), bytes: AtomicU64::new(0), - finished: AtomicU64::new(0), - abandoned: AtomicU64::new(0), } } } @@ -645,14 +633,6 @@ mod tests { fn inc(&self, n: u64) { self.bytes.fetch_add(n, Ordering::Relaxed); } - - fn finish(&self) { - self.finished.fetch_add(1, Ordering::Relaxed); - } - - fn abandon(&self) { - self.abandoned.fetch_add(1, Ordering::Relaxed); - } } #[test] @@ -672,7 +652,6 @@ mod tests { assert_eq!(blob.size_bytes, body.len() as u64); assert_eq!(fs::read(&blob.path).expect("read blob"), body); assert_eq!(server.requests(), 1); - assert_eq!(progress.finished.load(Ordering::Relaxed), 1); assert_eq!(progress.bytes.load(Ordering::Relaxed), body.len() as u64); assert_eq!( *progress.total.lock().expect("total"), @@ -701,7 +680,11 @@ mod tests { .download_to_cache(&url, Some(&"0".repeat(64)), &progress) .expect_err("digest mismatch"); assert!(matches!(error, LocalError::DigestMismatch { .. })); - assert_eq!(progress.abandoned.load(Ordering::Relaxed), 1); + assert_eq!( + progress.bytes.load(Ordering::Relaxed), + body.len() as u64, + "the whole body streamed before the digest gate rejected it" + ); let destination = cache.destination(&url).expect("destination"); assert!(!destination.exists(), "mismatched blob must not publish"); diff --git a/crates/gateway/local/src/dialect.rs b/crates/gateway/local/src/dialect.rs index 2b1708db0..1fdb2be45 100644 --- a/crates/gateway/local/src/dialect.rs +++ b/crates/gateway/local/src/dialect.rs @@ -37,6 +37,7 @@ struct DialectEvidence { /// Why dialect resolution failed for a local model. #[derive(Debug, thiserror::Error)] +#[non_exhaustive] pub enum DialectResolveError { /// No dialect scored on the provided evidence. #[error("no tool dialect matched the provided evidence")] @@ -168,7 +169,10 @@ fn read_probe_json( /// Decodes probe body bytes as JSON (pure; unit-tested). fn decode_probe_json(operation: &'static str, bytes: &[u8]) -> Result { - serde_json::from_slice(bytes).map_err(|source| LocalError::DialectDecode { operation, source }) + serde_json::from_slice(bytes).map_err(|source| LocalError::DialectDecode { + operation, + source: source.into(), + }) } /// Fetches `/props` from a ready local llama-server and resolves the tool dialect. @@ -250,7 +254,7 @@ fn fetch_props_evidence(guard: &ServerGuard) -> Result Result() else { + panic!("the transport cause is the shared HttpSource"); + }; + assert!(wrapper.as_inner().is_builder()); + + let Err(json) = serde_json::from_str::("nope") else { + panic!("`nope` must not parse as a u32"); + }; + let decode = LocalError::DialectDecode { + operation: "GET /props", + source: json.into(), + }; + let Some(cause) = decode.source() else { + panic!("the dialect-decode variant carries its JSON cause as source()"); + }; + let Some(wrapper) = cause.downcast_ref::() else { + panic!("the JSON cause is the shared JsonSource"); + }; + assert!(wrapper.as_inner().is_syntax()); + } + #[test] fn source_bearing_variants_preserve_their_cause_without_doubling_display() { let spawn = LocalError::Spawn { diff --git a/crates/gateway/local/src/gguf.rs b/crates/gateway/local/src/gguf.rs index 96e6c4223..d425312e5 100644 --- a/crates/gateway/local/src/gguf.rs +++ b/crates/gateway/local/src/gguf.rs @@ -232,7 +232,8 @@ impl HeaderReader { .map_err(|_| self.malformed(format!("{what} length {length} exceeds the cap")))?; let mut bytes = vec![0u8; capacity]; self.read_exact(&mut bytes)?; - String::from_utf8(bytes).map_err(|_| self.malformed(format!("{what} is not UTF-8"))) + String::from_utf8(bytes) + .map_err(|source| self.malformed(format!("{what} is not UTF-8: {source}"))) } /// Skips a GGUF string without materializing it. diff --git a/crates/gateway/local/src/runtime.rs b/crates/gateway/local/src/runtime.rs index a35b8d3a0..a083e5c5d 100644 --- a/crates/gateway/local/src/runtime.rs +++ b/crates/gateway/local/src/runtime.rs @@ -14,10 +14,10 @@ use std::thread; use std::time::Duration; use gateway_config::{Config, LocalModelConfig, ModelKind, QueuePolicy, ThinkingMode}; +use gateway_progress::Activity; use gateway_protocol::ShutdownError; use gateway_routing::queue::DominionQueue; use gateway_routing::{Endpoint, Model, dominion_queues}; -use shared_progress::ProgressHandle; use tokio_util::sync::CancellationToken; use crate::artifacts::{self, ArtifactStore, ProvisionedServer, ServerSelection}; @@ -207,18 +207,14 @@ impl LocalRuntime { /// When the config declares no `[[local_model]]`, returns an empty runtime /// without downloading anything. /// - /// `progress` is the parent leaf for startup provisioning, when the caller - /// runs an operation tree: the pinned server stages once under a - /// `llama-server` child (download/verify/extract leaves), and each local - /// model gets its own subtree (download/verify leaves from provisioning - /// plus an indeterminate `ready` leaf the spawn poll completes). + /// `progress` is the caller's live activity, when it runs one: the + /// pinned server's download, verify, and extract stages, each model's + /// download and verify, and `"Starting {model}"` before each spawn are + /// written into its text. /// /// # Errors /// Returns [`LocalError`] when download, verification, spawn, or readiness fails. - pub fn start( - config: &Config, - progress: Option<&ProgressHandle>, - ) -> Result { + pub fn start(config: &Config, progress: Option<&Activity>) -> Result { let outcome = start_impl( config, progress, @@ -255,7 +251,7 @@ impl LocalRuntime { /// ``` pub fn start_partial( config: &Config, - progress: Option<&ProgressHandle>, + progress: Option<&Activity>, ) -> Result { start_impl( config, @@ -286,7 +282,7 @@ impl LocalRuntime { /// token fires. pub fn start_partial_with_cancellation( config: &Config, - progress: Option<&ProgressHandle>, + progress: Option<&Activity>, token: &CancellationToken, interrupted: &Arc, ) -> Result { @@ -314,8 +310,8 @@ impl LocalRuntime { /// Per-model provisioning failures are collected and returned, not /// fatal: the start over the same config reports them again as its own /// per-model failures, so the models that did provision still start. - /// `progress`, when given, gains the same `llama-server` and per-model - /// download/verify subtrees the start would register. + /// `progress`, when given, receives the same `llama-server` and + /// per-model download and verify text the start would write. /// /// # Errors /// Returns [`LocalError`] when the shared `llama-server` provisioning @@ -324,7 +320,7 @@ impl LocalRuntime { /// models. pub fn provision_artifacts_with_cancellation( config: &Config, - progress: Option<&ProgressHandle>, + progress: Option<&Activity>, token: &CancellationToken, ) -> Result, LocalError> { provision_artifacts_impl(config, progress, token, |store, selection, server| { @@ -338,12 +334,12 @@ impl LocalRuntime { /// a mock layout exactly as [`start_impl`] is driven. fn provision_artifacts_impl( config: &Config, - progress: Option<&ProgressHandle>, + progress: Option<&Activity>, token: &CancellationToken, provision: impl FnOnce( &ArtifactStore, &ServerSelection<'_>, - Option<&ProgressHandle>, + Option<&Activity>, ) -> Result, ) -> Result, LocalError> { if config.local_models().is_empty() { @@ -375,12 +371,11 @@ fn provision_artifacts_impl( if token.is_cancelled() { return Err(LocalError::Cancelled); } - let model_tree = progress.map(|handle| handle.child(local_model.name(), 3.0)); let provisioned = store .ensure_model_with_cancellation( local_model.source(), local_model.sha256(), - model_tree.as_ref(), + progress, Some(token), ) .and_then(|_path| provision_companion_paths(&store, local_model, Some(token))); @@ -408,11 +403,11 @@ enum StartPolicy { /// variable, or the managed backend download). fn provision_server( config: &Config, - progress: Option<&ProgressHandle>, + progress: Option<&Activity>, provision: impl FnOnce( &ArtifactStore, &ServerSelection<'_>, - Option<&ProgressHandle>, + Option<&Activity>, ) -> Result, ) -> Result<(ArtifactStore, ProvisionedServer), LocalError> { let cache_root = resolve_cache_root(config.local().cache_dir())?; @@ -422,8 +417,10 @@ fn provision_server( server_path: config.local().llama_server_path(), backend: config.local().llama_backend(), }; - let server_tree = progress.map(|handle| handle.child("llama-server", 1.0)); - let server = provision(&store, &selection, server_tree.as_ref())?; + if let Some(activity) = progress { + activity.set_text("Provisioning llama-server"); + } + let server = provision(&store, &selection, progress)?; tracing::info!(path = %server.executable.display(), "provisioned llama-server"); Ok((store, server)) } @@ -439,21 +436,15 @@ fn provision_server( )] fn start_impl( config: &Config, - progress: Option<&ProgressHandle>, + progress: Option<&Activity>, interrupted: &Arc, token: Option<&CancellationToken>, provision: impl FnOnce( &ArtifactStore, &ServerSelection<'_>, - Option<&ProgressHandle>, + Option<&Activity>, ) -> Result, - spawn: impl Fn( - &Path, - &Path, - &LaunchOptions, - &AtomicBool, - Option<&ProgressHandle>, - ) -> Result, + spawn: impl Fn(&Path, &Path, &LaunchOptions, &AtomicBool) -> Result, policy: StartPolicy, ) -> Result { let cache_dir = config.local().cache_dir().map(str::to_owned); @@ -510,12 +501,11 @@ fn start_impl( if token.is_some_and(CancellationToken::is_cancelled) { return Err(LocalError::Cancelled); } - let model_tree = progress.map(|handle| handle.child(local_model.name(), 3.0)); let started = (|| { let model_path = store.ensure_model_with_cancellation( local_model.source(), local_model.sha256(), - model_tree.as_ref(), + progress, token, )?; tracing::info!( @@ -534,13 +524,14 @@ fn start_impl( if token.is_some_and(CancellationToken::is_cancelled) { return Err(LocalError::Cancelled); } - let ready = model_tree.as_ref().map(|tree| tree.child("ready", 2.0)); + if let Some(activity) = progress { + activity.set_text(format!("Starting {}", local_model.name())); + } let guard = spawn( &server.executable, &model_path, &options, interrupted.as_ref(), - ready.as_ref(), )?; let endpoint_id = format!("local-{}", local_model.name()); // A non-chat child has no chat completions to dialect-match: like a @@ -676,7 +667,7 @@ impl LocalRuntime { Some(model) } - /// Explicitly terminate every owned `llama-server` child and disable respawn, + /// Explicitly terminates every owned `llama-server` child and disables respawn, /// returning the first teardown failure after attempting *all* children. /// /// Dropping the runtime does not guarantee child termination, because the @@ -725,7 +716,7 @@ struct LocalAdmission { queue: DominionQueue, } -/// Resolve a local model's admission wiring. +/// Resolves a local model's admission wiring. /// /// The `--parallel` value is `LocalModelConfig::parallel` (default 1). A /// model without a `dominion` gets a per-model queue limited to that same @@ -1179,9 +1170,9 @@ context = 4096 } #[test] - fn start_with_no_local_models_registers_no_leaves() { - // An empty `[[local_model]]` set is a no-op start: the parent leaf - // gains no children at all. + fn start_with_no_local_models_writes_no_activity_text() { + // An empty `[[local_model]]` set is a no-op start: the caller's + // activity text is left exactly as it was. let config = Config::from_toml_str( r#" config-version = 0 @@ -1205,28 +1196,20 @@ endpoints = ["e"] "#, ) .expect("config"); - let hub = Arc::new(shared_progress::ProgressHub::new()); - let tree = hub.operation(); - let parent = tree.register("local-models", 1.0); - let runtime = LocalRuntime::start(&config, Some(&parent)).expect("empty local runtime"); + let hub = gateway_progress::ProgressHub::new(); + let activity = hub.begin("local-models"); + let runtime = LocalRuntime::start(&config, Some(&activity)).expect("empty local runtime"); assert_eq!(runtime.child_count(), 0); - let snapshot = hub.snapshot(); - let paths: Vec<&str> = snapshot[0] - .nodes - .iter() - .map(|node| node.path.as_str()) - .collect(); - assert_eq!(paths, ["local-models"]); + assert_eq!(hub.current().text, "local-models"); } #[test] - #[expect(clippy::float_cmp, reason = "fixed-point fractions compare exactly")] - fn start_over_a_mock_layout_registers_the_expected_subtree_shape() { + fn start_over_a_mock_layout_writes_the_starting_text_before_the_spawn() { use crate::testsupport::hex_sha256; // The mock layout provisions for real - a path source with a true pin // - but has no `llama-server` binary to spawn, so the start fails at - // launch, after the subtree shape is registered. + // launch, after the activity text reached the spawn stage. let temp = tempfile::TempDir::new().expect("tempdir"); let model_file = temp.path().join("mock.gguf"); std::fs::write(&model_file, b"mock-gguf-bytes").expect("write model"); @@ -1255,26 +1238,30 @@ context = 512 )) .expect("config"); - let hub = Arc::new(shared_progress::ProgressHub::new()); - let tree = hub.operation(); - let parent = tree.register("local-models", 1.0); + let hub = gateway_progress::ProgressHub::new(); + let activity = hub.begin("local-models"); + let seen = std::sync::Mutex::new(Vec::new()); let error = start_impl( &config, - Some(&parent), + Some(&activity), &startup_interrupt_flag(), None, |_store, _selection, server| { - // An already-staged server has no download/verify/extract work. - if let Some(handle) = server { - handle.complete(); - } + // An already-staged server has no download/verify/extract + // work; the runtime named the stage before calling in. + seen.lock() + .expect("seen lock") + .push(server.map(|_| hub.current().text)); Ok(ProvisionedServer { executable: PathBuf::from("mock-llama-server"), path_prefix: Vec::new(), }) }, - |_, _, _, _, _| { + |_, _, _, _| { + seen.lock() + .expect("seen lock") + .push(Some(hub.current().text)); Err(LocalError::EarlyExit { status: "the mock layout has no llama-server to spawn".to_owned(), }) @@ -1284,26 +1271,18 @@ context = 512 .expect_err("the mock layout cannot launch a real child"); assert!(matches!(error, LocalError::EarlyExit { .. })); - let snapshot = hub.snapshot(); - assert_eq!(snapshot.len(), 1); - let nodes = &snapshot[0].nodes; - let paths: Vec<&str> = nodes.iter().map(|node| node.path.as_str()).collect(); assert_eq!( - paths, + seen.lock().expect("seen lock").as_slice(), [ - "local-models", - "local-models/llama-server", - "local-models/mock", - "local-models/mock/download", - "local-models/mock/verify", - "local-models/mock/ready", - ] + Some("Provisioning llama-server".to_owned()), + Some("Starting mock".to_owned()), + ], + "the server stage is named before provisioning and the model before its spawn" + ); + assert!( + hub.current().busy, + "the caller's activity is still live after a failed start" ); - // The path source needed no download and the pin ran a real hash - // pass; the readiness poll never ran. - assert_eq!(nodes[3].fraction, 1.0); - assert_eq!(nodes[4].fraction, 1.0); - assert_eq!(nodes[5].fraction, 0.0); } #[test] @@ -1311,11 +1290,10 @@ context = 512 use crate::testsupport::hex_sha256; // The artifact step provisions for real - the pinned path source is - // hashed and its verification marker written - registers the same - // download/verify subtree the start would, and never reaches a - // spawn: no `ready` leaf exists. A model whose source is missing is - // collected as a per-model failure, not a fatal one, so the model - // that did provision is not held back. + // hashed and its verification marker written - and never reaches a + // spawn. A model whose source is missing is collected as a per-model + // failure, not a fatal one, so the model that did provision is not + // held back. let temp = tempfile::TempDir::new().expect("tempdir"); let model_file = temp.path().join("mock.gguf"); std::fs::write(&model_file, b"mock-gguf-bytes").expect("write model"); @@ -1350,17 +1328,13 @@ context = 512 )) .expect("config"); - let hub = Arc::new(shared_progress::ProgressHub::new()); - let tree = hub.operation(); - let parent = tree.register("downloading-models", 1.0); + let hub = gateway_progress::ProgressHub::new(); + let activity = hub.begin("downloading-models"); let failures = provision_artifacts_impl( &config, - Some(&parent), + Some(&activity), &CancellationToken::new(), - |_store, _selection, server| { - if let Some(handle) = server { - handle.complete(); - } + |_store, _selection, _server| { Ok(ProvisionedServer { executable: PathBuf::from("mock-llama-server"), path_prefix: Vec::new(), @@ -1389,25 +1363,10 @@ context = 512 .is_file(), "the pinned blob is verified into the cache, so the start finds it" ); - let snapshot = hub.snapshot(); - let paths: Vec<&str> = snapshot[0] - .nodes - .iter() - .map(|node| node.path.as_str()) - .collect(); assert_eq!( - paths, - [ - "downloading-models", - "downloading-models/llama-server", - "downloading-models/mock", - "downloading-models/mock/download", - "downloading-models/mock/verify", - "downloading-models/absent", - "downloading-models/absent/download", - "downloading-models/absent/verify", - ], - "the artifact step registers no `ready` leaf: nothing spawns" + hub.current().text, + "Verifying mock.gguf 100%", + "the last stage the artifact step wrote is the pinned model's hash pass: nothing spawns" ); } @@ -1670,7 +1629,7 @@ context = 4096 path_prefix: Vec::new(), }) }, - |_, _, _, _, _| panic!("a refused model never spawns"), + |_, _, _, _| panic!("a refused model never spawns"), StartPolicy::KeepReady, ) .expect("a per-model refusal is not fatal under the partial policy"); @@ -1753,7 +1712,7 @@ context = 4096 path_prefix: Vec::new(), }) }, - |_, _, _, _, _| { + |_, _, _, _| { spawns.fetch_add(1, Ordering::Relaxed); Err(LocalError::EarlyExit { status: "the mock layout has no llama-server to spawn".to_owned(), diff --git a/crates/gateway/local/src/server-tests.rs b/crates/gateway/local/src/server-tests.rs index bff35aa65..2de72be39 100644 --- a/crates/gateway/local/src/server-tests.rs +++ b/crates/gateway/local/src/server-tests.rs @@ -1,3 +1,5 @@ +//! Tests for llama-server launch arguments, readiness polling, and child process lifecycle. + use std::collections::VecDeque; use std::io::{Read as _, Write as _}; use std::net::{TcpListener, TcpStream}; @@ -383,7 +385,6 @@ fn companion_args_are_byte_identical_across_respawn_and_shutdown() { Path::new("pinned-model.gguf"), &opts, &interrupted, - None, TEST_POLICY, &mut select_port, &mut make_identity, @@ -562,7 +563,6 @@ fn debug_redacts_api_key() { Path::new("pinned-model.gguf"), &options(false), &interrupted, - None, TEST_POLICY, &mut select_port, &mut make_identity, @@ -576,53 +576,15 @@ fn debug_redacts_api_key() { } #[test] -#[expect(clippy::float_cmp, reason = "fixed-point fractions compare exactly")] -fn ready_leaf_completes_when_the_readiness_poll_succeeds() { - // The readiness leaf is indeterminate: it jumps from 0.0 to 1.0 exactly - // once, when the bounded poll confirms authenticated readiness. - let hub = Arc::new(shared_progress::ProgressHub::new()); - let tree = hub.operation(); - let ready = tree.register("ready", 1.0); - let port = free_port().expect("select free port"); - let mut ports = VecDeque::from([port]); - let mut select_port = || { - ports.pop_front().ok_or_else(|| LocalError::Port { - operation: "unexpected test port selection", - source: std::io::Error::other("test port queue exhausted"), - }) - }; - let mut make_identity = || deterministic_identity(0); - let interrupted = AtomicBool::new(false); - let guard = ServerGuard::start_with( - Path::new("fake-llama-server"), - Path::new("pinned-model.gguf"), - &options(false), - &interrupted, - Some(&ready), - TEST_POLICY, - &mut select_port, - &mut make_identity, - &ChildSpawner::new(spawn_fake_child), - ) - .expect("fake child should become ready"); - assert_eq!(ready.fraction(), 1.0); - drop(guard); -} - -#[test] -#[expect(clippy::float_cmp, reason = "fixed-point fractions compare exactly")] -fn ready_leaf_stays_unfinished_when_readiness_never_arrives() { +fn a_child_serving_a_foreign_alias_never_becomes_ready() { // A child serving another attempt's alias never passes the identity - // check, so the poll times out and the leaf is never completed. + // check, so the bounded poll times out and the start fails. const FAST_POLICY: StartupPolicy = StartupPolicy { attempts: 1, deadline: Duration::from_millis(300), interval: Duration::from_millis(10), http_timeout: Duration::from_millis(50), }; - let hub = Arc::new(shared_progress::ProgressHub::new()); - let tree = hub.operation(); - let ready = tree.register("ready", 1.0); let port = free_port().expect("select free port"); let mut ports = VecDeque::from([port]); let mut select_port = || { @@ -638,7 +600,6 @@ fn ready_leaf_stays_unfinished_when_readiness_never_arrives() { Path::new("pinned-model.gguf"), &options(false), &interrupted, - Some(&ready), FAST_POLICY, &mut select_port, &mut make_identity, @@ -648,7 +609,6 @@ fn ready_leaf_stays_unfinished_when_readiness_never_arrives() { ) .expect_err("a foreign alias must never become ready"); assert!(matches!(error, LocalError::Startup { .. })); - assert_eq!(ready.fraction(), 0.0); } #[test] @@ -677,7 +637,6 @@ fn retries_after_foreign_health_listener_wins_selected_port() { Path::new("pinned-model.gguf"), &options(false), &interrupted, - None, TEST_POLICY, &mut select_port, &mut make_identity, @@ -722,7 +681,6 @@ fn drop_kills_the_child_process() { Path::new("pinned-model.gguf"), &options(false), &interrupted, - None, TEST_POLICY, &mut select_port, &mut make_identity, @@ -769,7 +727,6 @@ fn respawn_reuses_port_and_identity_after_child_death() { Path::new("pinned-model.gguf"), &options(false), &interrupted, - None, TEST_POLICY, &mut select_port, &mut make_identity, @@ -842,7 +799,6 @@ fn local_upstream_send_respawns_dead_child_once() { Path::new("pinned-model.gguf"), &options(false), &interrupted, - None, TEST_POLICY, &mut select_port, &mut make_identity, @@ -934,7 +890,6 @@ fn local_upstream_send_embeddings_routes_through_child() { Path::new("pinned-embed.gguf"), &opts, &interrupted, - None, TEST_POLICY, &mut select_port, &mut make_identity, @@ -1002,7 +957,6 @@ fn local_upstream_send_rerank_routes_through_child() { Path::new("pinned-rerank.gguf"), &opts, &interrupted, - None, TEST_POLICY, &mut select_port, &mut make_identity, @@ -1073,7 +1027,6 @@ fn local_upstream_send_honors_cooldown_after_failed_respawn() { Path::new("pinned-model.gguf"), &options(false), &interrupted, - None, TEST_POLICY, &mut select_port, &mut make_identity, @@ -1172,7 +1125,6 @@ fn local_upstream_concurrent_sends_respawn_child_at_most_once() { Path::new("pinned-model.gguf"), &options(false), &interrupted, - None, TEST_POLICY, &mut select_port, &mut make_identity, @@ -1253,7 +1205,6 @@ fn recover_if_dead_is_a_noop_for_a_live_but_unreachable_child() { Path::new("pinned-model.gguf"), &options(false), &interrupted, - None, TEST_POLICY, &mut select_port, &mut make_identity, @@ -1317,7 +1268,6 @@ fn local_upstream_shutdown_kills_child_and_disables_respawn() { Path::new("pinned-model.gguf"), &options(false), &interrupted, - None, TEST_POLICY, &mut select_port, &mut make_identity, @@ -1447,7 +1397,6 @@ fn switch_shutdown_terminates_an_in_flight_respawned_child() { Path::new("pinned-model.gguf"), &options(false), &interrupted, - None, TEST_POLICY, &mut select_port, &mut make_identity, diff --git a/crates/gateway/local/src/server.rs b/crates/gateway/local/src/server.rs index 414d841a6..8d781fd47 100644 --- a/crates/gateway/local/src/server.rs +++ b/crates/gateway/local/src/server.rs @@ -14,10 +14,8 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::thread::{self, JoinHandle}; use std::time::{Duration, Instant}; -use gateway_config::Secret; -use shared_progress::ProgressHandle; - use crate::error::LocalError; +use gateway_config::Secret; use support::{ ChildSpawner, SharedCapture, capture_reader, display_invocation, free_port, listener_is_present, new_capture, random_identity, readiness_belongs_to, server_args, @@ -131,9 +129,9 @@ enum WaitOutcome { pub(crate) enum ServeMode { /// Chat completions; no extra flag. Chat, - /// Pass `--embeddings` so the child serves embedding requests. + /// Passes `--embeddings` so the child serves embedding requests. Embeddings, - /// Pass `--reranking` so the child serves rerank requests. + /// Passes `--reranking` so the child serves rerank requests. Reranking, } @@ -200,10 +198,6 @@ pub(crate) struct ServerGuard { impl ServerGuard { /// Starts `llama-server` with `options` and verifies authenticated model identity. /// - /// `ready` is the indeterminate readiness leaf for this spawn: the bounded - /// poll reports no fractions, so the leaf only gets `complete()` when - /// authenticated readiness succeeds. - /// /// # Errors /// Returns a [`LocalError`] when spawn, readiness, or identity checks fail. pub(crate) fn start( @@ -211,7 +205,6 @@ impl ServerGuard { model: &Path, options: &LaunchOptions, interrupted: &AtomicBool, - ready: Option<&ProgressHandle>, ) -> Result { let mut select_port = free_port; let mut make_identity = random_identity; @@ -220,7 +213,6 @@ impl ServerGuard { model, options, interrupted, - ready, PRODUCTION_POLICY, &mut select_port, &mut make_identity, @@ -237,7 +229,6 @@ impl ServerGuard { model: &Path, options: &LaunchOptions, interrupted: &AtomicBool, - ready: Option<&ProgressHandle>, policy: StartupPolicy, select_port: &mut dyn FnMut() -> Result, make_identity: &mut dyn FnMut() -> AttemptIdentity, @@ -282,12 +273,7 @@ impl ServerGuard { guard.start_capture()?; match guard.wait_until_ready(interrupted, policy) { - Ok(WaitOutcome::Ready) => { - if let Some(handle) = ready { - handle.complete(); - } - return Ok(guard); - } + Ok(WaitOutcome::Ready) => return Ok(guard), Ok(WaitOutcome::PortCollision(status)) => { collisions.push(format!( "attempt {attempt} on port {port}: child exited with {status}\n{}\n{}", @@ -406,7 +392,9 @@ impl ServerGuard { .connect_timeout(policy.http_timeout) .timeout(policy.http_timeout) .build() - .map_err(|source| LocalError::ReadinessClient { source })?; + .map_err(|source| LocalError::ReadinessClient { + source: source.into(), + })?; loop { if interrupted.load(Ordering::Acquire) { diff --git a/crates/gateway/local/src/sidecar.rs b/crates/gateway/local/src/sidecar.rs index 065dbbf4f..bcad0e735 100644 --- a/crates/gateway/local/src/sidecar.rs +++ b/crates/gateway/local/src/sidecar.rs @@ -368,7 +368,7 @@ pub(crate) fn utc_now_iso() -> String { format_unix_utc(secs) } -/// Format a Unix timestamp (seconds since 1970-01-01 UTC) as `YYYY-MM-DDThh:mm:ssZ`. +/// Formats a Unix timestamp (seconds since 1970-01-01 UTC) as `YYYY-MM-DDThh:mm:ssZ`. /// /// Uses Howard Hinnant's days-to-civil algorithm; valid for all dates at or /// after the Unix epoch. diff --git a/crates/gateway/local/src/upstream.rs b/crates/gateway/local/src/upstream.rs index 77e66cfc7..b5398e64c 100644 --- a/crates/gateway/local/src/upstream.rs +++ b/crates/gateway/local/src/upstream.rs @@ -82,7 +82,7 @@ impl LocalUpstream { } } - /// Terminate the owned child and permanently disable respawn, returning any + /// Terminates the owned child and permanently disables respawn, returning any /// teardown failure to the caller. /// /// Called at profile-switch teardown so the old child is freed @@ -332,7 +332,7 @@ impl LocalUpstream { Ok(gateway_protocol::upstream::sse_chunks(response, requested)) } - /// Run the dead-child recovery after a transport failure. + /// Runs the dead-child recovery after a transport failure. /// /// Recovery runs on a plain OS thread so reqwest::blocking readiness (used /// by [`ServerGuard::respawn`]) never nests a Tokio runtime inside the diff --git a/crates/gateway/progress/AGENTS.md b/crates/gateway/progress/AGENTS.md new file mode 100644 index 000000000..b2b9e6d9e --- /dev/null +++ b/crates/gateway/progress/AGENTS.md @@ -0,0 +1,9 @@ +# gateway-progress + +This crate owns the gateway's live-activity hub: a busy flag and one line of producer-owned text, published as `gateway_api_types::Progress` over a `watch` channel. + +- A private gateway family crate under `crates/gateway/`; only gateway crates depend on it. The Workshop and the harness consume the `Progress` wire type from `gateway-api-types` and never this machinery. +- Depends on `gateway-api-types` (the wire type), `tokio` `sync`, and nothing else in the workspace. Hosts own forwarding tasks; this crate does not spawn work, block a runtime, or log. +- Producers call `ProgressHub::begin(text)` and hold the returned `Activity` for the work's lifetime, updating it with `set_text` and dropping it on every exit path. Failure is not a progress state: the producer logs it and returns the error. +- The snapshot is `busy = any activity live`, `text = the newest live activity's text`. The `watch` channel keeps only the latest snapshot; there is no replay, no event history, and no fractions, weights, or hierarchy. A producer that wants a percentage formats it into the text. +- Activity text is user-visible in every status consumer; a producer never places a credential in it. diff --git a/crates/gateway/progress/Cargo.toml b/crates/gateway/progress/Cargo.toml new file mode 100644 index 000000000..2db2fb908 --- /dev/null +++ b/crates/gateway/progress/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "gateway-progress" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +publish = false + +description = "PromptForge gateway live-activity hub: a busy flag and producer-owned text published over a watch channel" + +[dependencies] +gateway-api-types.workspace = true +# `sync` carries the hub's watch channel; nothing else of tokio is used. +tokio = { workspace = true, features = ["sync"] } +workspace-hack.workspace = true + +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt"] } + +[lints] +workspace = true diff --git a/crates/gateway/progress/src/lib-tests.rs b/crates/gateway/progress/src/lib-tests.rs new file mode 100644 index 000000000..f7b65ea30 --- /dev/null +++ b/crates/gateway/progress/src/lib-tests.rs @@ -0,0 +1,138 @@ +//! The hub's snapshot rule and its `watch` publication. + +use gateway_api_types::Progress; + +use super::ProgressHub; + +fn snapshot(busy: bool, text: &str) -> Progress { + Progress { + busy, + text: text.to_owned(), + } +} + +#[test] +fn a_fresh_hub_is_idle_with_empty_text() { + let hub = ProgressHub::new(); + assert_eq!(hub.current(), Progress::default()); + assert_eq!(*hub.subscribe().borrow(), Progress::default()); +} + +#[test] +fn begin_publishes_busy_with_the_activity_text() { + let hub = ProgressHub::new(); + let _activity = hub.begin("Loading profile"); + assert_eq!(hub.current(), snapshot(true, "Loading profile")); +} + +#[test] +fn the_last_drop_publishes_idle() { + let hub = ProgressHub::new(); + let activity = hub.begin("Loading profile"); + drop(activity); + assert_eq!( + hub.current(), + snapshot(false, ""), + "an ended activity leaves the hub idle with no stale text" + ); +} + +#[test] +fn a_nested_begin_shows_the_newest_and_falls_back_on_drop() { + let hub = ProgressHub::new(); + let _outer = hub.begin("load-profile: main"); + let inner = hub.begin("Downloading qwen 12%"); + assert_eq!(hub.current(), snapshot(true, "Downloading qwen 12%")); + drop(inner); + assert_eq!( + hub.current(), + snapshot(true, "load-profile: main"), + "the enclosing activity's text returns once the nested one ends" + ); +} + +#[test] +fn dropping_an_older_activity_keeps_the_newest_text() { + let hub = ProgressHub::new(); + let outer = hub.begin("outer"); + let _inner = hub.begin("inner"); + drop(outer); + assert_eq!( + hub.current(), + snapshot(true, "inner"), + "ending an activity that is not the newest changes nothing visible" + ); +} + +#[test] +fn set_text_republishes_the_newest_activity() { + let hub = ProgressHub::new(); + let activity = hub.begin("Downloading qwen 0%"); + activity.set_text("Downloading qwen 45%"); + assert_eq!(hub.current(), snapshot(true, "Downloading qwen 45%")); +} + +#[test] +fn set_text_on_an_older_activity_does_not_displace_the_newest() { + let hub = ProgressHub::new(); + let outer = hub.begin("outer"); + let _inner = hub.begin("inner"); + outer.set_text("outer again"); + assert_eq!(hub.current(), snapshot(true, "inner")); +} + +#[tokio::test] +async fn subscribe_receives_each_change() { + let hub = ProgressHub::new(); + let mut rx = hub.subscribe(); + assert!( + !rx.has_changed().expect("the sender is alive"), + "a fresh subscriber has already seen the idle snapshot" + ); + + let activity = hub.begin("Loading profile"); + rx.changed().await.expect("begin publishes"); + assert_eq!(*rx.borrow_and_update(), snapshot(true, "Loading profile")); + + activity.set_text("Downloading models"); + rx.changed().await.expect("set_text publishes"); + assert_eq!( + *rx.borrow_and_update(), + snapshot(true, "Downloading models") + ); + + drop(activity); + rx.changed().await.expect("drop publishes"); + assert_eq!(*rx.borrow_and_update(), snapshot(false, "")); +} + +#[test] +fn an_unchanged_snapshot_is_not_republished() { + let hub = ProgressHub::new(); + let mut rx = hub.subscribe(); + let activity = hub.begin("same"); + assert!(rx.has_changed().expect("the sender is alive")); + rx.mark_unchanged(); + activity.set_text("same"); + assert!( + !rx.has_changed().expect("the sender is alive"), + "a set_text to the identical text wakes no subscriber" + ); +} + +#[test] +fn a_subscriber_outlives_the_hub_owner_through_the_activity() { + let hub = ProgressHub::new(); + let rx = hub.subscribe(); + let activity = hub.begin("held"); + drop(hub); + // The activity keeps the shared state alive; its drop still publishes. + drop(activity); + assert_eq!(*rx.borrow(), snapshot(false, "")); +} + +const _: () = { + const fn assert_send_sync() {} + assert_send_sync::(); + assert_send_sync::(); +}; diff --git a/crates/gateway/progress/src/lib.rs b/crates/gateway/progress/src/lib.rs new file mode 100644 index 000000000..2947684e2 --- /dev/null +++ b/crates/gateway/progress/src/lib.rs @@ -0,0 +1,165 @@ +//! The gateway's live-activity hub: one busy flag and one line of text. +//! +//! A producer that starts slow work calls [`ProgressHub::begin`] with a +//! short user-facing text and holds the returned [`Activity`] for the +//! work's lifetime; it updates the text with [`Activity::set_text`] as the +//! work moves ("Downloading qwen 45%") and drops the guard on every exit +//! path. The hub publishes one [`Progress`] snapshot per change through a +//! `watch` channel: `busy` while any activity is live, `text` from the +//! newest live activity. There are no fractions, weights, or trees, and +//! nothing is replayed: a subscriber sees the current snapshot and every +//! later change. The crate never spawns tasks and never logs; producers +//! own their own tracing lines. + +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; + +use gateway_api_types::Progress; +use tokio::sync::watch; + +/// The process-wide activity broker, one per gateway. +/// +/// Cheap to clone through an `Arc`; the hub lives in the host's +/// application state for the process lifetime and activities register +/// and remove themselves by their own lifetimes. +/// +/// # Examples +/// +/// ``` +/// use gateway_progress::ProgressHub; +/// +/// let hub = ProgressHub::new(); +/// assert!(!hub.current().busy); +/// let activity = hub.begin("Loading profile"); +/// assert_eq!(hub.current().text, "Loading profile"); +/// drop(activity); +/// assert!(!hub.current().busy); +/// ``` +#[derive(Debug)] +pub struct ProgressHub { + inner: Arc, +} + +#[derive(Debug)] +struct Inner { + /// Live activities in begin order: `(id, text)`. The last entry is the + /// one the snapshot shows. + live: Mutex>, + next_id: AtomicU64, + tx: watch::Sender, +} + +impl Default for ProgressHub { + fn default() -> Self { + Self::new() + } +} + +impl ProgressHub { + /// Creates an idle hub. + #[must_use] + pub fn new() -> Self { + let (tx, _rx) = watch::channel(Progress::default()); + Self { + inner: Arc::new(Inner { + live: Mutex::new(Vec::new()), + next_id: AtomicU64::new(0), + tx, + }), + } + } + + /// Starts an activity showing `text` and returns its guard. The hub is + /// busy until every begun activity has dropped. + pub fn begin(&self, text: impl Into) -> Activity { + let id = self.inner.next_id.fetch_add(1, Ordering::Relaxed); + self.inner.live().push((id, text.into())); + self.inner.publish(); + Activity { + inner: Arc::clone(&self.inner), + id, + } + } + + /// The current snapshot. + #[must_use] + pub fn current(&self) -> Progress { + self.inner.tx.borrow().clone() + } + + /// Subscribes to every later change. The receiver starts holding the + /// current snapshot as already seen; `changed()` resolves on the next + /// publication. + #[must_use] + pub fn subscribe(&self) -> watch::Receiver { + self.inner.tx.subscribe() + } +} + +impl Inner { + /// A lock poisoned by a panicking peer recovers the value rather than + /// wedging the process. + fn live(&self) -> MutexGuard<'_, Vec<(u64, String)>> { + self.live.lock().unwrap_or_else(PoisonError::into_inner) + } + + /// Recomputes the snapshot from the live set and publishes it when it + /// differs from the last one. + fn publish(&self) { + let snapshot = { + let live = self.live(); + Progress { + busy: !live.is_empty(), + text: live + .last() + .map(|(_, text)| text.clone()) + .unwrap_or_default(), + } + }; + self.tx.send_if_modified(|current| { + if *current == snapshot { + false + } else { + *current = snapshot; + true + } + }); + } +} + +/// One live activity: an RAII guard whose drop ends it and republishes. +/// +/// `Send + Sync`, so a producer can move it into a blocking task or share +/// it behind an `Arc`; it is not `Clone`, because each guard's drop is the +/// end of exactly one activity. +#[derive(Debug)] +pub struct Activity { + inner: Arc, + id: u64, +} + +impl Activity { + /// Replaces this activity's text. The snapshot changes only when this + /// is the newest live activity. + pub fn set_text(&self, text: impl Into) { + let text = text.into(); + { + let mut live = self.inner.live(); + if let Some(entry) = live.iter_mut().find(|(id, _)| *id == self.id) { + entry.1 = text; + } + } + self.inner.publish(); + } +} + +impl Drop for Activity { + fn drop(&mut self) { + self.inner.live().retain(|(id, _)| *id != self.id); + self.inner.publish(); + } +} + +#[cfg(test)] +#[path = "lib-tests.rs"] +mod tests; diff --git a/crates/gateway/protocol/Cargo.toml b/crates/gateway/protocol/Cargo.toml index a278e60f8..a9484779a 100644 --- a/crates/gateway/protocol/Cargo.toml +++ b/crates/gateway/protocol/Cargo.toml @@ -20,7 +20,7 @@ gateway-config.workspace = true reqwest.workspace = true serde.workspace = true serde_json.workspace = true -gateway-api.workspace = true +gateway-api-types.workspace = true thiserror.workspace = true tokio.workspace = true tracing.workspace = true diff --git a/crates/gateway/protocol/src/error.rs b/crates/gateway/protocol/src/error.rs index 55247a795..dafbb2a03 100644 --- a/crates/gateway/protocol/src/error.rs +++ b/crates/gateway/protocol/src/error.rs @@ -55,7 +55,7 @@ pub enum ProtocolError { } impl ProtocolError { - /// Wrap a reqwest failure, classifying it by where the request died. + /// Wraps a reqwest failure, classifying it by where the request died. /// /// A connect failure (`err.is_connect()`) means the request never left /// the gateway and is classified [`ProtocolError::UpstreamConnect`]; @@ -70,7 +70,7 @@ impl ProtocolError { } } - /// Wrap an already-classified mid-flight transport failure, preserving + /// Wraps an already-classified mid-flight transport failure, preserving /// the cause via `source()`. /// /// The caller asserts the request may have reached the provider; for a @@ -81,14 +81,14 @@ impl ProtocolError { ProtocolError::UpstreamTransport(Box::new(source)) } - /// Wrap an already-classified connect failure, preserving the cause via + /// Wraps an already-classified connect failure, preserving the cause via /// `source()`. #[must_use] pub fn connect(source: impl std::error::Error + Send + Sync + 'static) -> ProtocolError { ProtocolError::UpstreamConnect(Box::new(source)) } - /// Wrap a body-decode failure as a protocol error (not a transport error), + /// Wraps a body-decode failure as a protocol error (not a transport error), /// preserving the cause via `source()`. #[must_use] pub fn upstream_protocol( @@ -97,7 +97,7 @@ impl ProtocolError { ProtocolError::UpstreamProtocol(Box::new(source)) } - /// Build a non-success-status failure from the upstream's status and + /// Builds a non-success-status failure from the upstream's status and /// truncated body. #[must_use] pub fn upstream_status(status: u16, body: String) -> ProtocolError { @@ -169,7 +169,7 @@ pub enum ShutdownError { } impl ShutdownError { - /// Wrap a teardown failure, preserving the cause via `source()`. + /// Wraps a teardown failure, preserving the cause via `source()`. #[must_use] pub fn teardown(source: impl std::error::Error + Send + Sync + 'static) -> ShutdownError { ShutdownError::Teardown(Box::new(source)) diff --git a/crates/gateway/protocol/src/http_util.rs b/crates/gateway/protocol/src/http_util.rs index c8075eba6..48590b66a 100644 --- a/crates/gateway/protocol/src/http_util.rs +++ b/crates/gateway/protocol/src/http_util.rs @@ -19,7 +19,7 @@ const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); /// Whole-request timeout for outbound calls. const REQUEST_TIMEOUT: Duration = Duration::from_secs(120); -/// Build a reqwest client with bounded connect and whole-request timeouts. +/// Builds a reqwest client with bounded connect and whole-request timeouts. #[must_use] pub fn bounded_client() -> reqwest::Client { reqwest::Client::builder() @@ -29,7 +29,7 @@ pub fn bounded_client() -> reqwest::Client { .unwrap_or_else(|_| reqwest::Client::new()) } -/// Build a reqwest client with only a connect timeout for long-lived streams. +/// Builds a reqwest client with only a connect timeout for long-lived streams. /// /// reqwest's whole-request `.timeout()` covers the entire body read, so it /// would kill any SSE stream that outlives it. The streaming path therefore @@ -47,7 +47,7 @@ pub fn streaming_client() -> reqwest::Client { /// idle middlebox drop surfaces instead of hanging the stream forever. const AUDIO_TCP_KEEPALIVE: Duration = Duration::from_secs(60); -/// Build a reqwest client for long-lived binary audio streams. +/// Builds a reqwest client for long-lived binary audio streams. /// /// Like [`streaming_client`] there is no whole-request timeout, which would /// kill any stream that outlives it, and TCP keepalive keeps middleboxes @@ -65,7 +65,7 @@ pub fn audio_streaming_client() -> reqwest::Client { .unwrap_or_else(|_| reqwest::Client::new()) } -/// Read at most `cap` bytes from `response`, stopping early once the cap is hit. +/// Reads at most `cap` bytes from `response`, stopping early once the cap is hit. /// /// The body is streamed chunk by chunk so an oversized or stalled response never /// allocates beyond `cap`. Returns a lossy UTF-8 string of the bytes read. @@ -91,7 +91,7 @@ pub async fn read_body_capped(response: reqwest::Response, cap: usize) -> String String::from_utf8_lossy(&buffer).into_owned() } -/// Read at most `cap` bytes from `response`, propagating a transport error if a +/// Reads at most `cap` bytes from `response`, propagating a transport error if a /// chunk read fails. /// /// Unlike [`read_body_capped`], this surfaces the read result explicitly so a diff --git a/crates/gateway/protocol/src/upstream.rs b/crates/gateway/protocol/src/upstream.rs index 86d93f822..7ae9d370a 100644 --- a/crates/gateway/protocol/src/upstream.rs +++ b/crates/gateway/protocol/src/upstream.rs @@ -67,8 +67,8 @@ impl std::fmt::Debug for StreamedAudio { /// A backend the gateway can forward a chat completion to. #[async_trait] pub trait Upstream: Send + Sync { - /// Forward `req` to the backend, substituting `upstream_model` for the - /// caller's model name, and return the response. + /// Forwards `req` to the backend, substituting `upstream_model` for the + /// caller's model name, and returns the response. /// /// # Errors /// Returns [`ProtocolError::UpstreamConnect`] when the connection itself @@ -81,8 +81,8 @@ pub trait Upstream: Send + Sync { upstream_model: &str, ) -> Result; - /// Forward an embeddings `req` to the backend, substituting - /// `upstream_model` for the caller's model name, and return the response. + /// Forwards an embeddings `req` to the backend, substituting + /// `upstream_model` for the caller's model name, and returns the response. /// /// The default is [`ProtocolError::ModelUnavailable`]: upstreams without an /// embeddings implementation (a local chat server, for example) decline @@ -102,8 +102,8 @@ pub trait Upstream: Send + Sync { Err(ProtocolError::ModelUnavailable(req.model)) } - /// Forward a rerank `req` to the backend, substituting `upstream_model` - /// for the caller's model name, and return the response. + /// Forwards a rerank `req` to the backend, substituting `upstream_model` + /// for the caller's model name, and returns the response. /// /// The default is [`ProtocolError::ModelUnavailable`]: upstreams without a /// rerank implementation (a local chat server, for example) decline the @@ -123,8 +123,8 @@ pub trait Upstream: Send + Sync { Err(ProtocolError::ModelUnavailable(req.model)) } - /// Open a streaming chat completion for `req`, substituting - /// `upstream_model` for the caller's model name, and return the chunk + /// Opens a streaming chat completion for `req`, substituting + /// `upstream_model` for the caller's model name, and returns the chunk /// stream. /// /// The stream is boxed because the trait is used as `Arc`: @@ -153,8 +153,8 @@ pub trait Upstream: Send + Sync { Err(ProtocolError::ModelUnavailable(req.model)) } - /// Forward a speech synthesis `req` to the backend, substituting - /// `upstream_model` for the caller's model name, and return the audio + /// Forwards a speech synthesis `req` to the backend, substituting + /// `upstream_model` for the caller's model name, and returns the audio /// stream. /// /// The default is [`ProtocolError::ModelUnavailable`]: upstreams without a @@ -175,8 +175,8 @@ pub trait Upstream: Send + Sync { Err(ProtocolError::ModelUnavailable(req.model)) } - /// Explicitly release any owned resources (for example a child process) and - /// disable further recovery, surfacing any teardown failure. + /// Explicitly releases any owned resources (for example a child process) and + /// disables further recovery, surfacing any teardown failure. /// /// The default is a no-op for stateless upstreams. The supervised local /// upstream cancels any in-flight recovery, kills its `llama-server` child, @@ -213,7 +213,7 @@ pub struct OpenAiUpstream { } impl OpenAiUpstream { - /// Build an upstream for `base_url` (a trailing slash is trimmed). + /// Builds an upstream for `base_url` (a trailing slash is trimmed). #[must_use] pub fn new(base_url: &str, api_key: Secret) -> OpenAiUpstream { OpenAiUpstream { @@ -225,7 +225,7 @@ impl OpenAiUpstream { } } - /// Build an upstream with a caller-supplied HTTP client (test seam for + /// Builds an upstream with a caller-supplied HTTP client (test seam for /// exercising request deadlines against a stalled server). #[cfg(test)] pub(crate) fn with_client( @@ -308,7 +308,7 @@ impl OpenAiUpstream { } } -/// Parse an upstream SSE byte stream into validated [`ChatChunk`]s. +/// Parses an upstream SSE byte stream into validated [`ChatChunk`]s. /// /// Each `data:` line carries one JSON chunk; blank lines, comments, and the /// `event:`/`id:`/`retry:` fields are skipped, and the terminal `[DONE]` @@ -846,7 +846,7 @@ mod tests { } } - /// Install a WARN-level subscriber writing to a fresh capture buffer for + /// Installs a WARN-level subscriber writing to a fresh capture buffer for /// the current thread (tokio's current-thread test runtime keeps every /// poll on this thread, so the parser's warnings land in the buffer). fn capture_warnings() -> (LogBuffer, tracing::subscriber::DefaultGuard) { diff --git a/crates/gateway/protocol/src/wire.rs b/crates/gateway/protocol/src/wire.rs index 48f810a71..453b0d6ff 100644 --- a/crates/gateway/protocol/src/wire.rs +++ b/crates/gateway/protocol/src/wire.rs @@ -17,7 +17,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; -pub use gateway_api::ModelInfo; +pub use gateway_api_types::ModelInfo; /// An incoming chat completions request. #[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] @@ -58,7 +58,7 @@ impl ChatRequest { /// Reserved top-level keys that must never appear in the passthrough `rest`. const RESERVED: [&'static str; 3] = ["model", "messages", "stream"]; - /// Validate the request shape at the trust boundary, without coercion. + /// Validates the request shape at the trust boundary, without coercion. /// /// Rejects an empty model, an empty `messages` array, any message that is /// not a minimally-shaped chat message (an object with a supported string @@ -90,7 +90,7 @@ impl ChatRequest { } } -/// Validate one chat message's minimal shape without reconstructing it (WIRE-001). +/// Validates one chat message's minimal shape without reconstructing it (WIRE-001). /// /// A message must be a JSON object with a supported string `role` and must carry /// either `content` (any shape: string, array, or null) or a tool/function call. @@ -130,7 +130,7 @@ impl ChatResponse { /// Reserved top-level keys that must never appear in the passthrough `rest`. const RESERVED: [&'static str; 2] = ["model", "choices"]; - /// Validate the upstream response shape, treating structural failure as an + /// Validates the upstream response shape, treating structural failure as an /// upstream-protocol error rather than silently passing it through. /// /// Each choice must be a minimally-shaped object: an `index` plus one of the @@ -155,7 +155,7 @@ impl ChatResponse { } } -/// Validate one response choice's minimal shape (WIRE-002). +/// Validates one response choice's minimal shape (WIRE-002). /// /// A choice must be a JSON object carrying an `index` and one of the supported /// payload fields (`message` for non-streaming, `delta` for streaming, or the @@ -196,7 +196,7 @@ pub struct ChatChunk { } impl ChatChunk { - /// Validate one upstream chunk's minimal shape before it is relayed. + /// Validates one upstream chunk's minimal shape before it is relayed. /// /// A chunk must carry at least one choice; each choice's `index` and /// `delta` are required typed fields, so deserialization has already @@ -234,6 +234,7 @@ pub struct ChatChunkChoice { /// The text to embed: one string or a batch of strings (OpenAI shape). #[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] #[serde(untagged)] +#[non_exhaustive] pub enum EmbeddingInput { /// A single input string. One(String), @@ -261,7 +262,7 @@ impl EmbeddingRequest { /// Reserved top-level keys that must never appear in the passthrough `rest`. const RESERVED: [&'static str; 3] = ["model", "input", "encoding_format"]; - /// Validate the request shape at the trust boundary, without coercion. + /// Validates the request shape at the trust boundary, without coercion. /// /// Rejects an empty model, an empty input batch, and any reserved key /// smuggled into the flattened `rest` map (WIRE-001/003). Everything else @@ -289,6 +290,7 @@ impl EmbeddingRequest { /// An outgoing embeddings response. #[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +#[non_exhaustive] pub struct EmbeddingResponse { /// The model name, rewritten to the caller's requested name. pub model: String, @@ -304,7 +306,7 @@ impl EmbeddingResponse { /// Reserved top-level keys that must never appear in the passthrough `rest`. const RESERVED: [&'static str; 2] = ["model", "data"]; - /// Validate the upstream response shape, treating structural failure as an + /// Validates the upstream response shape, treating structural failure as an /// upstream-protocol error rather than silently passing it through. /// /// Each entry must be a minimally-shaped object carrying an `embedding` @@ -350,6 +352,7 @@ const MAX_SPEECH_SPEED: f32 = 4.0; /// route, never here, because voice sets are per-checkpoint. #[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] #[serde(untagged)] +#[non_exhaustive] pub enum SpeechVoice { /// A plain voice name. Name(String), @@ -366,6 +369,7 @@ pub enum SpeechVoice { /// and `mulaw`) stay unrepresentable until the enum is deliberately widened. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)] #[serde(rename_all = "lowercase")] +#[non_exhaustive] pub enum SpeechResponseFormat { /// MPEG audio. The default: OpenAI defaults to mp3 while Together /// defaults to wav, so the pin lives in the type and an omitted field @@ -387,6 +391,7 @@ pub enum SpeechResponseFormat { /// How a streaming speech response is framed (the OpenAI set). #[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)] #[serde(rename_all = "lowercase")] +#[non_exhaustive] pub enum SpeechStreamFormat { /// Chunked binary audio (the behavior when the field is absent). Audio, @@ -396,6 +401,7 @@ pub enum SpeechStreamFormat { /// An incoming speech synthesis request. #[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +#[non_exhaustive] pub struct SpeechRequest { /// The model name, resolved against the routing table. pub model: String, @@ -435,7 +441,7 @@ impl SpeechRequest { "stream_format", ]; - /// Validate the request shape at the trust boundary, without coercion. + /// Validates the request shape at the trust boundary, without coercion. /// /// Rejects an empty model, an empty or over-cap `input`, an out-of-range /// `speed`, and any reserved key smuggled into the flattened `rest` map @@ -495,7 +501,7 @@ impl RerankRequest { /// Reserved top-level keys that must never appear in the passthrough `rest`. const RESERVED: [&'static str; 4] = ["model", "query", "documents", "top_n"]; - /// Validate the request shape at the trust boundary, without coercion. + /// Validates the request shape at the trust boundary, without coercion. /// /// Rejects an empty model, an empty query, an empty document set, and any /// reserved key smuggled into the flattened `rest` map (WIRE-001/003). @@ -526,6 +532,7 @@ impl RerankRequest { /// An outgoing rerank response. #[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +#[non_exhaustive] pub struct RerankResponse { /// The model name, rewritten to the caller's requested name. pub model: String, @@ -541,7 +548,7 @@ impl RerankResponse { /// Reserved top-level keys that must never appear in the passthrough `rest`. const RESERVED: [&'static str; 2] = ["model", "results"]; - /// Validate the upstream response shape, treating structural failure as an + /// Validates the upstream response shape, treating structural failure as an /// upstream-protocol error rather than silently passing it through. /// /// Each result must be a minimally-shaped object carrying an `index` and a @@ -575,6 +582,7 @@ impl RerankResponse { /// The OpenAI-shaped model list returned by `GET /v1/models`. #[derive(Clone, Debug, PartialEq, Serialize)] +#[non_exhaustive] pub struct ModelsResponse { /// Always `"list"`. pub object: &'static str, @@ -584,7 +592,7 @@ pub struct ModelsResponse { #[cfg(test)] mod tests { - use gateway_api::{Capabilities, ModelKind, ThinkingMode}; + use gateway_api_types::{Capabilities, ModelKind, ThinkingMode}; use super::*; diff --git a/crates/gateway/routing/src/queue-tests.rs b/crates/gateway/routing/src/queue-tests.rs index 2772d8905..aafc1c7ae 100644 --- a/crates/gateway/routing/src/queue-tests.rs +++ b/crates/gateway/routing/src/queue-tests.rs @@ -1,8 +1,10 @@ +//! Tests for the dominion queue admission, capacity policies, and fair scheduling. + use super::*; use std::sync::atomic::{AtomicUsize, Ordering}; use std::time::Duration; -/// Deterministically wait until exactly `n` requests are enqueued as waiters, +/// Deterministically waits until exactly `n` requests are enqueued as waiters, /// yielding to the runtime so spawned admits can register (no sleeps). async fn await_waiters(queue: &DominionQueue, n: usize) { while queue.waiter_count() != n { diff --git a/crates/gateway/routing/src/queue.rs b/crates/gateway/routing/src/queue.rs index db6268750..6bf5d0042 100644 --- a/crates/gateway/routing/src/queue.rs +++ b/crates/gateway/routing/src/queue.rs @@ -40,12 +40,12 @@ impl ClientId { /// The fallback bucket for absent or invalid ids. pub const DEFAULT: &'static str = "default"; - /// Parse an optional header string into a bounded [`ClientId`]. + /// Parses an optional header string into a bounded [`ClientId`]. pub fn from_header(value: Option<&str>) -> ClientId { value.map_or_else(|| ClientId(Self::DEFAULT.to_owned()), Self::parse) } - /// Parse a raw string into a bounded [`ClientId`], falling back to `default`. + /// Parses a raw string into a bounded [`ClientId`], falling back to `default`. #[must_use] pub fn parse(raw: &str) -> ClientId { let trimmed = raw.trim(); @@ -237,7 +237,7 @@ impl DominionQueue { } } - /// Acquire a concurrency permit for `client_key`. + /// Acquires a concurrency permit for `client_key`. /// /// When the queue is unlimited, returns a no-op permit immediately. When /// limited, the policy decides what a full in-flight set means: `Queue` @@ -322,7 +322,7 @@ impl DominionQueue { } } -/// Build one shared [`DominionQueue`] per configured dominion. +/// Builds one shared [`DominionQueue`] per configured dominion. /// /// Cloning a returned queue clones the Arc-backed limit, so everything bound /// to the same dominion competes for one pool of slots. Remote endpoints @@ -436,7 +436,7 @@ impl LimitedQueue { /// from minting many labels to win a larger share of round-robin turns (Q-001). const MAX_DISTINCT_CLIENTS: usize = 32; -/// Enqueue a waiter under its fair-scheduling bucket, returning the *effective* +/// Enqueues a waiter under its fair-scheduling bucket, returning the *effective* /// bucket key actually used (which may be `default` when the distinct-client cap /// is reached). fn enqueue_fair(state: &mut WaitState, client_key: &str, waiter: Waiter) -> String { diff --git a/crates/gateway/stt/README.md b/crates/gateway/stt/README.md index f30870c73..15bf81bba 100644 --- a/crates/gateway/stt/README.md +++ b/crates/gateway/stt/README.md @@ -4,7 +4,7 @@ ## gateway-stt -The gateway-owned speech-to-text runtime and HTTP endpoints (at `api/`): the SpeechService covering artifacts, batch, and Realtime transcription. The gateway mounts its routes in stt builds. Depends on gateway-config, gateway-local, gateway-stt-engine, gateway-stt-backend-whisper, and shared-progress. +The gateway-owned speech-to-text runtime and HTTP endpoints (at `api/`): the SpeechService covering artifacts, batch, and Realtime transcription. The gateway mounts its routes in stt builds. Depends on gateway-config, gateway-local, gateway-progress, gateway-stt-engine, and gateway-stt-backend-whisper. ## gateway-stt-engine @@ -12,7 +12,7 @@ Backend-neutral speech decoding: the SttEngine with interim and final workers an ## gateway-stt-backend-whisper -The safe Whisper decoder backend for the engine. The api crate selects it as the production backend. Depends on gateway-stt-engine and gateway-whisper-ffi. +The safe Whisper decoder backend for the engine. The api crate selects it as the production backend. Depends on gateway-progress, gateway-stt-engine, and gateway-whisper-ffi. ## gateway-whisper-ffi diff --git a/crates/gateway/stt/api/Cargo.toml b/crates/gateway/stt/api/Cargo.toml index 24860828b..65eac24f9 100644 --- a/crates/gateway/stt/api/Cargo.toml +++ b/crates/gateway/stt/api/Cargo.toml @@ -18,7 +18,7 @@ futures-util.workspace = true hound.workspace = true gateway-config.workspace = true gateway-local.workspace = true -shared-progress.workspace = true +gateway-progress.workspace = true gateway-stt-backend-whisper.workspace = true gateway-stt-engine.workspace = true serde.workspace = true diff --git a/crates/gateway/stt/api/src/artifacts.rs b/crates/gateway/stt/api/src/artifacts.rs index 681c98033..a767bb088 100644 --- a/crates/gateway/stt/api/src/artifacts.rs +++ b/crates/gateway/stt/api/src/artifacts.rs @@ -1,10 +1,11 @@ //! Verified speech artifacts and facade error vocabulary. use std::path::PathBuf; +use std::sync::{Arc, Weak}; use gateway_config::{Config, SttRole}; use gateway_local::artifacts::ArtifactStore; -use shared_progress::ProgressHandle; +use gateway_progress::Activity; use crate::model::{ModelNames, REALTIME_TRANSCRIBE_MODEL}; @@ -23,7 +24,10 @@ pub(crate) struct PreparedGeneration { pub(crate) guidance: Vec, pub(crate) window_seconds: u64, pub(crate) interval_ms: u64, - pub(crate) progress: Option, + /// The load's activity, weakly held: the model factory this becomes + /// lives for the process, while the activity ends with the load, so a + /// later decoder rebuild finds nothing to report into. + pub(crate) progress: Option>, } #[derive(Debug, Default)] @@ -34,7 +38,7 @@ struct ProvisionedModels { pub(crate) fn prepare( config: &Config, - progress: Option<&ProgressHandle>, + progress: Option<&Arc>, ) -> Result { if config.stt_models().is_empty() { return Ok(PreparedSpeech { generation: None }); @@ -52,11 +56,14 @@ pub(crate) fn prepare( let cache = gateway_local::resolve_cache_root(config.local().cache_dir()) .map_err(SpeechError::Store)?; let store = ArtifactStore::new(cache).map_err(SpeechError::Store)?; - let library_progress = progress.map(|handle| handle.child("whisper-library", 1.0)); + let activity = progress.map(Arc::as_ref); + if let Some(activity) = activity { + activity.set_text("Provisioning whisper library"); + } let library = store - .provision_whisper_library(library_progress.as_ref()) + .provision_whisper_library(activity) .map_err(SpeechError::WhisperLibrary)?; - let models = provision_models(config, &store, progress)?; + let models = provision_models(config, &store, activity)?; let Some((interim_name, interim_model)) = models.interim else { return Err(SpeechError::MissingInterim); }; @@ -78,7 +85,7 @@ pub(crate) fn prepare( guidance: capture.vocabulary().to_vec(), window_seconds: capture.window_seconds(), interval_ms: capture.interval_ms(), - progress: progress.map(|handle| handle.child("engine", 1.0)), + progress: progress.map(Arc::downgrade), }), }) } @@ -86,13 +93,12 @@ pub(crate) fn prepare( fn provision_models( config: &Config, store: &ArtifactStore, - progress: Option<&ProgressHandle>, + activity: Option<&Activity>, ) -> Result { let mut provisioned = ProvisionedModels::default(); for model in config.stt_models() { - let model_progress = progress.map(|handle| handle.child(model.name(), 4.0)); let path = store - .ensure_model_with_progress(model.source(), model.sha256(), model_progress.as_ref()) + .ensure_model_with_progress(model.source(), model.sha256(), activity) .map_err(|source| SpeechError::Artifact { model: model.name().to_owned(), source, @@ -141,7 +147,7 @@ pub enum SpeechError { /// The logical Realtime identity was used by one physical worker. #[non_exhaustive] - #[error("STT model name {model} is reserved for the logical Realtime model")] + #[error("model name {model} is reserved for the logical Realtime model")] ReservedModelName { /// Physical catalog name that collided with the logical identity. model: String, @@ -149,7 +155,7 @@ pub enum SpeechError { /// A future role reached a service that does not implement it. #[non_exhaustive] - #[error("STT model {model} has an unsupported role")] + #[error("model {model} has an unsupported role")] UnsupportedRole { /// Catalog name carrying the unsupported role. model: String, diff --git a/crates/gateway/stt/api/src/audio.rs b/crates/gateway/stt/api/src/audio.rs index 5631cf896..503cd6534 100644 --- a/crates/gateway/stt/api/src/audio.rs +++ b/crates/gateway/stt/api/src/audio.rs @@ -1,3 +1,5 @@ +//! Base64 PCM16 audio buffering, resampling, and commit validation for realtime input. + use base64::Engine as _; const INPUT_SAMPLE_RATE: u64 = 24_000; @@ -19,7 +21,7 @@ pub(super) enum AudioError { InvalidBase64(#[source] base64::DecodeError), #[error("decoded audio exceeds the {max_bytes} byte append limit")] AppendTooLarge { max_bytes: usize }, - #[error("PCM16 audio ended with an incomplete sample")] + #[error("audio ended with an incomplete PCM16 sample")] IncompletePcm16Sample, #[error("audio buffer exceeds {maximum_seconds} seconds")] BufferTooLong { maximum_seconds: usize }, diff --git a/crates/gateway/stt/api/src/batch-tests.rs b/crates/gateway/stt/api/src/batch-tests.rs index 4bb369ff8..e8126bdf7 100644 --- a/crates/gateway/stt/api/src/batch-tests.rs +++ b/crates/gateway/stt/api/src/batch-tests.rs @@ -1,3 +1,5 @@ +//! Tests for the batch transcription endpoint and its response formats. + use super::*; use axum::body::Body; use axum::http::{Request, StatusCode}; diff --git a/crates/gateway/stt/api/src/generation.rs b/crates/gateway/stt/api/src/generation.rs index d783c0db9..b5b021dd2 100644 --- a/crates/gateway/stt/api/src/generation.rs +++ b/crates/gateway/stt/api/src/generation.rs @@ -4,11 +4,11 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, PoisonError, RwLock}; use gateway_config::Config; +use gateway_progress::Activity; use gateway_stt_backend_whisper::{WhisperConfig, WhisperModelFactory}; #[cfg(feature = "test-fixtures")] use gateway_stt_engine::ModelFactory; use gateway_stt_engine::{DecodeMode, EnginePolicy}; -use shared_progress::ProgressHandle; use tokio_util::sync::CancellationToken; use crate::artifacts::{self, PreparedGeneration, SpeechError}; @@ -63,7 +63,7 @@ impl GenerationState { pub(crate) fn load_initial( &self, config: &Config, - progress: Option<&ProgressHandle>, + progress: Option<&Arc>, cancel: &CancellationToken, ) -> Result<(), SpeechError> { self.claim_initial_load()?; @@ -74,6 +74,11 @@ impl GenerationState { if cancel.is_cancelled() { return Err(SpeechError::InitialLoadCancelled); } + if let Some(activity) = progress + && prepared.generation.is_some() + { + activity.set_text("Loading speech models"); + } let runtime = prepared .generation .map(|prepared| whisper_spec(prepared).and_then(|spec| spec.build())) diff --git a/crates/gateway/stt/api/src/realtime/mod.rs b/crates/gateway/stt/api/src/realtime.rs similarity index 83% rename from crates/gateway/stt/api/src/realtime/mod.rs rename to crates/gateway/stt/api/src/realtime.rs index b591c4732..34fd3ab39 100644 --- a/crates/gateway/stt/api/src/realtime/mod.rs +++ b/crates/gateway/stt/api/src/realtime.rs @@ -1,3 +1,5 @@ +//! Realtime transcription module root re-exporting the session, registry, and route surface. + mod input; mod item; mod query; diff --git a/crates/gateway/stt/api/src/realtime/input.rs b/crates/gateway/stt/api/src/realtime/input.rs index 0f8f71450..6d46efa9c 100644 --- a/crates/gateway/stt/api/src/realtime/input.rs +++ b/crates/gateway/stt/api/src/realtime/input.rs @@ -1,3 +1,5 @@ +//! Uncommitted realtime input holding buffered audio and its sealed commit form. + use std::sync::Arc; use crate::audio::{AudioBuffer, AudioError}; diff --git a/crates/gateway/stt/api/src/realtime/item.rs b/crates/gateway/stt/api/src/realtime/item.rs index 0292e5a3f..e20a7e7c7 100644 --- a/crates/gateway/stt/api/src/realtime/item.rs +++ b/crates/gateway/stt/api/src/realtime/item.rs @@ -1,3 +1,5 @@ +//! Committed realtime items and their finalization task bookkeeping. + use std::sync::Arc; use tokio::task::JoinHandle; diff --git a/crates/gateway/stt/api/src/realtime/query.rs b/crates/gateway/stt/api/src/realtime/query.rs index 0f04f1127..2bd0914b2 100644 --- a/crates/gateway/stt/api/src/realtime/query.rs +++ b/crates/gateway/stt/api/src/realtime/query.rs @@ -1,3 +1,5 @@ +//! Validation of the realtime WebSocket upgrade query string. + #[derive(Debug, Clone, Copy, Eq, PartialEq)] pub(super) enum QueryError { MissingIntent, diff --git a/crates/gateway/stt/api/src/realtime/registry.rs b/crates/gateway/stt/api/src/realtime/registry.rs index 38f93413d..013baa81a 100644 --- a/crates/gateway/stt/api/src/realtime/registry.rs +++ b/crates/gateway/stt/api/src/realtime/registry.rs @@ -1,3 +1,5 @@ +//! Bounded registry admitting realtime sessions and retiring their tasks. + #[cfg(feature = "test-fixtures")] use std::future::Future; use std::sync::atomic::{AtomicUsize, Ordering}; diff --git a/crates/gateway/stt/api/src/realtime/result_mailbox.rs b/crates/gateway/stt/api/src/realtime/result_mailbox.rs index 9a49cb36d..fca7a058c 100644 --- a/crates/gateway/stt/api/src/realtime/result_mailbox.rs +++ b/crates/gateway/stt/api/src/realtime/result_mailbox.rs @@ -1,3 +1,5 @@ +//! Per-item result mailbox buffering interim and terminal transcription outcomes. + use std::collections::{HashMap, VecDeque}; use crate::take::TakeFailure; diff --git a/crates/gateway/stt/api/src/realtime/route.rs b/crates/gateway/stt/api/src/realtime/route.rs index d834f9d2b..d3bdeca60 100644 --- a/crates/gateway/stt/api/src/realtime/route.rs +++ b/crates/gateway/stt/api/src/realtime/route.rs @@ -1,3 +1,5 @@ +//! WebSocket route driving the realtime transcription session loop. + use std::time::Duration; #[cfg(feature = "test-fixtures")] diff --git a/crates/gateway/stt/api/src/realtime/session.rs b/crates/gateway/stt/api/src/realtime/session.rs index 1f0a8b9b6..8c7fb5e83 100644 --- a/crates/gateway/stt/api/src/realtime/session.rs +++ b/crates/gateway/stt/api/src/realtime/session.rs @@ -1,3 +1,5 @@ +//! Realtime session lifecycle for input appends, clears, and interim epochs. + use super::input::{InputSnapshot, UncommittedInput}; use super::item::CommittedItem; use super::registry::SessionRegistration; diff --git a/crates/gateway/stt/api/src/realtime/session/items-tests.rs b/crates/gateway/stt/api/src/realtime/session/items-tests.rs index f072d679e..c0504f556 100644 --- a/crates/gateway/stt/api/src/realtime/session/items-tests.rs +++ b/crates/gateway/stt/api/src/realtime/session/items-tests.rs @@ -1,3 +1,5 @@ +//! Tests for committed item finalization and PCM budget retention. + use std::time::Duration; use base64::Engine as _; diff --git a/crates/gateway/stt/api/src/realtime/session/items.rs b/crates/gateway/stt/api/src/realtime/session/items.rs index 88738926b..32888c573 100644 --- a/crates/gateway/stt/api/src/realtime/session/items.rs +++ b/crates/gateway/stt/api/src/realtime/session/items.rs @@ -1,3 +1,5 @@ +//! Session commit handling and committed item result plumbing. + #[cfg(feature = "test-fixtures")] use std::future::Future; #[cfg(feature = "test-fixtures")] diff --git a/crates/gateway/stt/api/src/realtime/session/route-tests.rs b/crates/gateway/stt/api/src/realtime/session/route-tests.rs index f7af0357b..9ad4d8a1e 100644 --- a/crates/gateway/stt/api/src/realtime/session/route-tests.rs +++ b/crates/gateway/stt/api/src/realtime/session/route-tests.rs @@ -1,3 +1,5 @@ +//! Tests for sample-to-millisecond conversion at the u64 boundary. + use super::sample_millis; #[test] diff --git a/crates/gateway/stt/api/src/realtime/session/route.rs b/crates/gateway/stt/api/src/realtime/session/route.rs index 4d8154d48..06e587d11 100644 --- a/crates/gateway/stt/api/src/realtime/session/route.rs +++ b/crates/gateway/stt/api/src/realtime/session/route.rs @@ -1,3 +1,5 @@ +//! Session-side server event emission and interim decode scheduling. + use super::{Session, SessionError}; use crate::realtime::result_mailbox::{ItemResult, SESSION_RESULT_CAPACITY}; use crate::realtime::session::state::InterimTaskOutput; diff --git a/crates/gateway/stt/api/src/realtime/session/state.rs b/crates/gateway/stt/api/src/realtime/session/state.rs index 7eadc7215..7792eeaba 100644 --- a/crates/gateway/stt/api/src/realtime/session/state.rs +++ b/crates/gateway/stt/api/src/realtime/session/state.rs @@ -1,3 +1,5 @@ +//! Session state struct, error type, and interim task definitions. + use crate::audio::AudioError; use crate::generation::GenerationLease; use crate::realtime::input::UncommittedInput; diff --git a/crates/gateway/stt/api/src/realtime/wire.rs b/crates/gateway/stt/api/src/realtime/wire.rs index 6a4cc3623..f36a177df 100644 --- a/crates/gateway/stt/api/src/realtime/wire.rs +++ b/crates/gateway/stt/api/src/realtime/wire.rs @@ -1,6 +1,8 @@ +//! Wire module root for the realtime client and server event protocol. + mod client; mod server; -mod shared; +mod vocabulary; #[cfg(test)] mod tests; @@ -13,4 +15,4 @@ pub(in crate::realtime) use client::parse_client_event; pub(in crate::realtime) use server::{ ConversationItem, DurationUsage, EffectiveSession, ServerEvent, WireError, }; -pub(in crate::realtime) use shared::{ClientError, ClientEvent, IdGenerator}; +pub(in crate::realtime) use vocabulary::{ClientError, ClientEvent, IdGenerator}; diff --git a/crates/gateway/stt/api/src/realtime/wire/client.rs b/crates/gateway/stt/api/src/realtime/wire/client.rs index d6a91428c..c7b32bcf5 100644 --- a/crates/gateway/stt/api/src/realtime/wire/client.rs +++ b/crates/gateway/stt/api/src/realtime/wire/client.rs @@ -1,6 +1,8 @@ +//! Parsing of client JSON events into typed realtime commands. + use serde_json::{Map, Value}; -use super::shared::{ +use super::vocabulary::{ AUDIO_RATE, AUDIO_TYPE, ClientError, ClientEvent, Correlation, HYPOTHESIS_INCLUDE, MODEL, SESSION_TYPE, SessionPatch, }; diff --git a/crates/gateway/stt/api/src/realtime/wire/server-events.rs b/crates/gateway/stt/api/src/realtime/wire/server-events.rs index 063f62fce..ca10f49ba 100644 --- a/crates/gateway/stt/api/src/realtime/wire/server-events.rs +++ b/crates/gateway/stt/api/src/realtime/wire/server-events.rs @@ -1,8 +1,10 @@ +//! Constructors building server events from session and item outcomes. + use super::{ ConversationItem, DurationUsage, EffectiveSession, InputAudioContent, ServerEvent, WireError, }; use crate::realtime::result_mailbox::{ItemFailure, ItemResult}; -use crate::realtime::wire::shared::{OptionalNullable, RequiredNullable}; +use crate::realtime::wire::vocabulary::{OptionalNullable, RequiredNullable}; use crate::take::InterimSnapshot; impl ServerEvent { pub(in crate::realtime) fn session_created( diff --git a/crates/gateway/stt/api/src/realtime/wire/server.rs b/crates/gateway/stt/api/src/realtime/wire/server.rs index e0f1708e5..9f155b392 100644 --- a/crates/gateway/stt/api/src/realtime/wire/server.rs +++ b/crates/gateway/stt/api/src/realtime/wire/server.rs @@ -1,3 +1,5 @@ +//! Server-to-client wire types for the realtime transcription protocol. + #[cfg(test)] use anyhow::anyhow; use serde::{Deserialize, Serialize}; @@ -5,7 +7,7 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use super::client::parse_client_event; -use super::shared::{ +use super::vocabulary::{ AUDIO_RATE, AUDIO_TYPE, ClientError, ClientEvent, HYPOTHESIS_INCLUDE, MODEL, OptionalNullable, RequiredNullable, SESSION_OBJECT, SESSION_TYPE, deserialize_required_nullable, }; diff --git a/crates/gateway/stt/api/src/realtime/wire/tests.rs b/crates/gateway/stt/api/src/realtime/wire/tests.rs index f1a96a60f..ada947fd0 100644 --- a/crates/gateway/stt/api/src/realtime/wire/tests.rs +++ b/crates/gateway/stt/api/src/realtime/wire/tests.rs @@ -1,3 +1,5 @@ +//! Fixture-driven tests for realtime wire parsing and serialization. + use std::collections::HashSet; use serde_json::Value; diff --git a/crates/gateway/stt/api/src/realtime/wire/shared.rs b/crates/gateway/stt/api/src/realtime/wire/vocabulary.rs similarity index 97% rename from crates/gateway/stt/api/src/realtime/wire/shared.rs rename to crates/gateway/stt/api/src/realtime/wire/vocabulary.rs index f5e6471be..9c4fd2fc3 100644 --- a/crates/gateway/stt/api/src/realtime/wire/shared.rs +++ b/crates/gateway/stt/api/src/realtime/wire/vocabulary.rs @@ -1,3 +1,7 @@ +//! The realtime wire vocabulary both directions speak: protocol constants, +//! the parsed client event, the error envelope, the nullable field +//! wrappers, and the id generator. + use std::sync::atomic::{AtomicU64, Ordering}; use serde::{Deserialize, Serialize}; diff --git a/crates/gateway/stt/api/src/segment-boundary.rs b/crates/gateway/stt/api/src/segment-boundary.rs index d67ad5bad..a75c68339 100644 --- a/crates/gateway/stt/api/src/segment-boundary.rs +++ b/crates/gateway/stt/api/src/segment-boundary.rs @@ -1,3 +1,5 @@ +//! Segment boundary outcomes describing decode ranges and forced overlaps. + use std::ops::Range; #[derive(Clone, Debug, Eq, PartialEq)] diff --git a/crates/gateway/stt/api/src/service.rs b/crates/gateway/stt/api/src/service.rs index 669d2fb42..6a7f79228 100644 --- a/crates/gateway/stt/api/src/service.rs +++ b/crates/gateway/stt/api/src/service.rs @@ -1,10 +1,9 @@ //! Cloneable host facade for speech lifecycle, facts, and routes. -#[cfg(feature = "test-fixtures")] use std::sync::Arc; use gateway_config::Config; -use shared_progress::ProgressHandle; +use gateway_progress::Activity; use tokio_util::sync::CancellationToken; use crate::artifacts::SpeechError; @@ -63,6 +62,9 @@ impl SpeechService { /// The facade starts empty and publishes at most one runtime. The attempt /// is spent whether it publishes, fails, or is cancelled: speech remains /// unavailable until process restart, and every later call is rejected. + /// `progress`, when given, receives the provisioning and model-load + /// stages as text; the backend keeps only a weak reference, so the + /// activity ends with the caller's guard. /// /// # Errors /// Returns a typed store, download, verification, configuration, backend, @@ -72,7 +74,7 @@ impl SpeechService { pub fn load_initial( &self, config: &Config, - progress: Option<&ProgressHandle>, + progress: Option<&Arc>, cancel: &CancellationToken, ) -> Result<(), SpeechError> { #[cfg(feature = "test-fixtures")] diff --git a/crates/gateway/stt/api/src/take/agreement-final-overlap-tests.rs b/crates/gateway/stt/api/src/take/agreement-final-overlap-tests.rs index 2580a5eea..0c6a9fd5c 100644 --- a/crates/gateway/stt/api/src/take/agreement-final-overlap-tests.rs +++ b/crates/gateway/stt/api/src/take/agreement-final-overlap-tests.rs @@ -1,3 +1,5 @@ +//! Tests for range-guided suffix-prefix alignment of overlapping final windows. + use super::*; #[test] diff --git a/crates/gateway/stt/api/src/take/agreement-final-overlap.rs b/crates/gateway/stt/api/src/take/agreement-final-overlap.rs index 56a1e1ba6..79ca90a80 100644 --- a/crates/gateway/stt/api/src/take/agreement-final-overlap.rs +++ b/crates/gateway/stt/api/src/take/agreement-final-overlap.rs @@ -1,3 +1,5 @@ +//! Bounded token alignment that locates where consecutive final windows overlap. + use std::cmp::Ordering; use std::ops::{Range, RangeInclusive}; diff --git a/crates/gateway/stt/api/src/take/agreement-projection.rs b/crates/gateway/stt/api/src/take/agreement-projection.rs index 08457101e..e85919984 100644 --- a/crates/gateway/stt/api/src/take/agreement-projection.rs +++ b/crates/gateway/stt/api/src/take/agreement-projection.rs @@ -1,3 +1,5 @@ +//! Projects an audio-proportional prefix cut of a previous final transcript. + use std::ops::Range; use super::final_overlap::MAX_FINAL_TRANSCRIPT_BYTES; diff --git a/crates/gateway/stt/api/src/take/agreement.rs b/crates/gateway/stt/api/src/take/agreement.rs index df63d268a..6d2ab60fb 100644 --- a/crates/gateway/stt/api/src/take/agreement.rs +++ b/crates/gateway/stt/api/src/take/agreement.rs @@ -1,3 +1,5 @@ +//! Token-level agreement helpers shared by take reconciliation and windowing. + #[path = "agreement-final-overlap.rs"] mod final_overlap; #[path = "agreement-projection.rs"] diff --git a/crates/gateway/stt/api/src/take/final_decode.rs b/crates/gateway/stt/api/src/take/final_decode.rs index f95a0e829..9623b6fd5 100644 --- a/crates/gateway/stt/api/src/take/final_decode.rs +++ b/crates/gateway/stt/api/src/take/final_decode.rs @@ -1,3 +1,5 @@ +//! Decodes natural and forced final windows and records their outcomes. + use std::future::Future; use std::ops::Range; use std::sync::{Arc, Mutex}; diff --git a/crates/gateway/stt/api/src/take/final_outcome.rs b/crates/gateway/stt/api/src/take/final_outcome.rs index 8eb450bda..c520c8cd9 100644 --- a/crates/gateway/stt/api/src/take/final_outcome.rs +++ b/crates/gateway/stt/api/src/take/final_outcome.rs @@ -1,3 +1,5 @@ +//! Final range outcome types and completion assembly for a take. + use std::ops::Range; use crate::segment::ForcedBoundary; diff --git a/crates/gateway/stt/api/src/take/finalization.rs b/crates/gateway/stt/api/src/take/finalization.rs index 7b82ff53e..d880e49ce 100644 --- a/crates/gateway/stt/api/src/take/finalization.rs +++ b/crates/gateway/stt/api/src/take/finalization.rs @@ -1,3 +1,5 @@ +//! Final-decode pipeline that sequences closed segments into a take completion. + use std::future::Future; use std::ops::Range; use std::pin::Pin; diff --git a/crates/gateway/stt/api/src/take/interim.rs b/crates/gateway/stt/api/src/take/interim.rs index 7e70e14da..866b062d9 100644 --- a/crates/gateway/stt/api/src/take/interim.rs +++ b/crates/gateway/stt/api/src/take/interim.rs @@ -1,3 +1,5 @@ +//! Interim transcript snapshot split into finalized, agreed, and tentative parts. + #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct InterimSnapshot { transcript: String, diff --git a/crates/gateway/stt/api/src/take/live_prefix.rs b/crates/gateway/stt/api/src/take/live_prefix.rs index 13bb8d5ba..0d4615817 100644 --- a/crates/gateway/stt/api/src/take/live_prefix.rs +++ b/crates/gateway/stt/api/src/take/live_prefix.rs @@ -1,3 +1,5 @@ +//! Snapshot of the finalized live prefix and any pending forced text. + use std::ops::Range; #[derive(Clone, Debug, Eq, PartialEq)] diff --git a/crates/gateway/stt/api/src/take/pcm-tests.rs b/crates/gateway/stt/api/src/take/pcm-tests.rs index 122419434..165378cde 100644 --- a/crates/gateway/stt/api/src/take/pcm-tests.rs +++ b/crates/gateway/stt/api/src/take/pcm-tests.rs @@ -1,3 +1,5 @@ +//! Tests for the retained PCM budget and rolling buffer accounting. + use super::{RetainedPcm, RetainedPcmBudget, RollingPcm}; #[test] diff --git a/crates/gateway/stt/api/src/take/pcm.rs b/crates/gateway/stt/api/src/take/pcm.rs index 2bf562de1..6de10c738 100644 --- a/crates/gateway/stt/api/src/take/pcm.rs +++ b/crates/gateway/stt/api/src/take/pcm.rs @@ -1,3 +1,5 @@ +//! Budgeted retention of rolling PCM audio for a take. + use std::ops::Range; use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; diff --git a/crates/gateway/stt/api/src/take/state-alignment-tests-adversaries.rs b/crates/gateway/stt/api/src/take/state-alignment-tests-adversaries.rs index 249436cae..cd7af9edf 100644 --- a/crates/gateway/stt/api/src/take/state-alignment-tests-adversaries.rs +++ b/crates/gateway/stt/api/src/take/state-alignment-tests-adversaries.rs @@ -1,3 +1,5 @@ +//! Adversarial tests for projected-prefix reconciliation of weak forced overlaps. + use std::sync::Arc; use super::{FinalRangeOutcome, ForcedBoundary, TakeState}; diff --git a/crates/gateway/stt/api/src/take/state-alignment-tests.rs b/crates/gateway/stt/api/src/take/state-alignment-tests.rs index 1d78447ff..aa9dbcd3a 100644 --- a/crates/gateway/stt/api/src/take/state-alignment-tests.rs +++ b/crates/gateway/stt/api/src/take/state-alignment-tests.rs @@ -1,3 +1,5 @@ +//! Tests reconciling captured forced-window outputs into one take completion. + use super::TakeState; use crate::segment::ForcedBoundary; use crate::take::final_outcome::FinalRangeOutcome; diff --git a/crates/gateway/stt/api/src/take/state-tests-live-prefix.rs b/crates/gateway/stt/api/src/take/state-tests-live-prefix.rs index 2e3aad1ea..c6d7d9ecc 100644 --- a/crates/gateway/stt/api/src/take/state-tests-live-prefix.rs +++ b/crates/gateway/stt/api/src/take/state-tests-live-prefix.rs @@ -1,3 +1,5 @@ +//! Tests for live-prefix snapshots of pending forced take text. + use super::super::TakeState; use crate::segment::ForcedBoundary; use crate::take::final_outcome::FinalRangeOutcome; diff --git a/crates/gateway/stt/api/src/take/state-tests.rs b/crates/gateway/stt/api/src/take/state-tests.rs index 662bfe264..fe3fcb1a0 100644 --- a/crates/gateway/stt/api/src/take/state-tests.rs +++ b/crates/gateway/stt/api/src/take/state-tests.rs @@ -1,3 +1,5 @@ +//! Tests for take state finalization, failures, and snapshot consistency. + use std::sync::Arc; use std::sync::mpsc; use std::time::Duration; diff --git a/crates/gateway/stt/api/src/take/state.rs b/crates/gateway/stt/api/src/take/state.rs index 618dce4ed..7caa0400f 100644 --- a/crates/gateway/stt/api/src/take/state.rs +++ b/crates/gateway/stt/api/src/take/state.rs @@ -1,3 +1,5 @@ +//! Shared take state tracking finalized text, failures, and final outcomes. + use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; use gateway_stt_engine::TranscribeError; diff --git a/crates/gateway/stt/api/src/take/text.rs b/crates/gateway/stt/api/src/take/text.rs index 62635919d..e8162f815 100644 --- a/crates/gateway/stt/api/src/take/text.rs +++ b/crates/gateway/stt/api/src/take/text.rs @@ -1,3 +1,5 @@ +//! Space-separated transcript appending helper. + pub(super) fn append_transcript(text: &mut String, piece: &str) { if piece.is_empty() { return; diff --git a/crates/gateway/stt/api/src/take/window-tests-live-prefix.rs b/crates/gateway/stt/api/src/take/window-tests-live-prefix.rs index bb8551552..b3364f248 100644 --- a/crates/gateway/stt/api/src/take/window-tests-live-prefix.rs +++ b/crates/gateway/stt/api/src/take/window-tests-live-prefix.rs @@ -1,3 +1,5 @@ +//! Tests for whole-window interim emission against live-prefix snapshots. + use super::WholeWindowState; use crate::take::live_prefix::LivePrefixSnapshot; diff --git a/crates/gateway/stt/api/src/take/window.rs b/crates/gateway/stt/api/src/take/window.rs index de1798b60..9fcdcea2d 100644 --- a/crates/gateway/stt/api/src/take/window.rs +++ b/crates/gateway/stt/api/src/take/window.rs @@ -1,3 +1,5 @@ +//! Whole-window interim state that merges hypotheses with the live prefix. + use std::ops::Range; use super::agreement::{equivalent_token, matching_token_prefix_end, token_spans}; diff --git a/crates/gateway/stt/api/tests/it/architecture.rs b/crates/gateway/stt/api/tests/it/architecture.rs deleted file mode 100644 index 3ca122ab7..000000000 --- a/crates/gateway/stt/api/tests/it/architecture.rs +++ /dev/null @@ -1,331 +0,0 @@ -//! Cargo metadata checks for the five approved product dependency -//! boundaries. The Workshop-to-Gateway arm exempts the gateway family's -//! public pair (`gateway-api`, `gateway-api-discovery`): the workshop -//! attaches to a running gateway through exactly those two crates. - -use std::collections::{BTreeMap, BTreeSet}; -use std::path::{Path, PathBuf}; -use std::process::Command; -use std::sync::OnceLock; - -#[derive(serde::Deserialize)] -struct CargoMetadata { - packages: Vec, - workspace_members: Vec, -} - -#[derive(serde::Deserialize)] -struct MetadataPackage { - name: String, - id: String, - manifest_path: PathBuf, - dependencies: Vec, -} - -#[derive(serde::Deserialize)] -struct MetadataDependency { - path: Option, -} - -#[derive(Clone, Copy)] -enum PackageSet { - Gateway, - PromptForge, - Workshop, - Shared, - GatewayOrWorkshop, - AnyProduct, -} - -impl PackageSet { - fn contains(self, package: &str) -> bool { - match self { - Self::Gateway => package == "gateway" || package.starts_with("gateway-"), - Self::PromptForge => package == "promptforge" || package.starts_with("promptforge-"), - Self::Workshop => package == "workshop" || package.starts_with("workshop-"), - Self::Shared => package.starts_with("shared-"), - Self::GatewayOrWorkshop => { - Self::Gateway.contains(package) || Self::Workshop.contains(package) - } - Self::AnyProduct => { - Self::Gateway.contains(package) - || Self::PromptForge.contains(package) - || Self::Workshop.contains(package) - } - } - } -} - -struct DependencyRule { - dependent: PackageSet, - forbidden: PackageSet, - /// Forbidden-set packages a dependent may still name. - allowed: &'static [&'static str], - description: &'static str, -} - -const PRODUCT_DEPENDENCY_RULES: [DependencyRule; 5] = [ - DependencyRule { - dependent: PackageSet::Gateway, - forbidden: PackageSet::Workshop, - allowed: &[], - description: "Gateway cannot depend on Workshop", - }, - DependencyRule { - dependent: PackageSet::PromptForge, - forbidden: PackageSet::GatewayOrWorkshop, - allowed: &[], - description: "PromptForge cannot depend on Gateway or Workshop", - }, - DependencyRule { - dependent: PackageSet::Gateway, - forbidden: PackageSet::PromptForge, - allowed: &[], - description: "Gateway cannot depend on PromptForge", - }, - DependencyRule { - dependent: PackageSet::Workshop, - forbidden: PackageSet::Gateway, - allowed: &["gateway-api", "gateway-api-discovery"], - description: "Workshop cannot depend on Gateway", - }, - DependencyRule { - dependent: PackageSet::Shared, - forbidden: PackageSet::AnyProduct, - allowed: &[], - description: "Shared cannot depend on any product", - }, -]; - -fn workspace_root() -> PathBuf { - // Walk ancestors instead of counting parents: the crate moves between - // container depths, and depth counting has broken on every past move. - for ancestor in Path::new(env!("CARGO_MANIFEST_DIR")).ancestors() { - let manifest = ancestor.join("Cargo.toml"); - if manifest.is_file() - && std::fs::read_to_string(&manifest) - .is_ok_and(|text| text.lines().any(|line| line.trim() == "[workspace]")) - { - return ancestor.to_owned(); - } - } - panic!("no ancestor of CARGO_MANIFEST_DIR carries a workspace manifest"); -} - -fn workspace_metadata() -> &'static CargoMetadata { - static METADATA: OnceLock = OnceLock::new(); - METADATA.get_or_init(|| { - let cargo = std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()); - let output = Command::new(cargo) - .args(["metadata", "--format-version", "1", "--no-deps"]) - .current_dir(workspace_root()) - .output() - .unwrap_or_else(|error| panic!("cargo metadata must start: {error}")); - assert!( - output.status.success(), - "cargo metadata must succeed: {}", - String::from_utf8_lossy(&output.stderr) - ); - serde_json::from_slice(&output.stdout) - .unwrap_or_else(|error| panic!("cargo metadata must return valid JSON: {error}")) - }) -} - -fn workspace_package_names_by_root(metadata: &CargoMetadata) -> BTreeMap { - let members = metadata.workspace_members.iter().collect::>(); - metadata - .packages - .iter() - .filter(|package| members.contains(&package.id)) - .map(|package| { - let root = package - .manifest_path - .parent() - .unwrap_or_else(|| panic!("workspace package manifest has a parent")) - .to_owned(); - (root, package.name.as_str()) - }) - .collect() -} - -fn direct_local_dependencies<'a>( - package: &'a MetadataPackage, - names_by_root: &BTreeMap, -) -> BTreeSet<&'a str> { - package - .dependencies - .iter() - .filter_map(|dependency| dependency.path.as_ref()) - .filter_map(|path| names_by_root.get(path).copied()) - .collect() -} - -fn dependency_violations(metadata: &CargoMetadata) -> Vec { - let members = metadata.workspace_members.iter().collect::>(); - let names_by_root = workspace_package_names_by_root(metadata); - let mut violations = metadata - .packages - .iter() - .filter(|package| members.contains(&package.id)) - .flat_map(|package| { - direct_local_dependencies(package, &names_by_root) - .into_iter() - .flat_map(move |dependency| { - PRODUCT_DEPENDENCY_RULES - .iter() - .filter(move |rule| { - rule.dependent.contains(&package.name) - && rule.forbidden.contains(dependency) - && !rule.allowed.contains(&dependency) - }) - .map(move |rule| { - format!( - "{}: `{}` directly depends on `{dependency}`", - rule.description, package.name - ) - }) - }) - }) - .collect::>(); - violations.sort(); - violations -} - -#[test] -fn workspace_obeys_the_five_product_dependency_rules() { - let violations = dependency_violations(workspace_metadata()); - assert!( - violations.is_empty(), - "forbidden direct local dependencies:\n{}", - violations.join("\n") - ); -} - -#[test] -fn metadata_includes_renamed_target_specific_dependencies_of_every_kind() { - let fixture = r#" - { - "workspace_members": ["source", "normal", "development", "build", "target"], - "packages": [ - { - "name": "source", - "id": "source", - "manifest_path": "C:/workspace/source/Cargo.toml", - "dependencies": [ - {"name": "normal", "path": "C:/workspace/normal", "kind": null, "rename": "renamed"}, - {"name": "development", "path": "C:/workspace/development", "kind": "dev"}, - {"name": "build", "path": "C:/workspace/build", "kind": "build"}, - {"name": "target", "path": "C:/workspace/target", "kind": null, "target": "cfg(unix)"}, - {"name": "external", "path": null, "kind": null} - ] - }, - { - "name": "normal", "id": "normal", - "manifest_path": "C:/workspace/normal/Cargo.toml", "dependencies": [] - }, - { - "name": "development", "id": "development", - "manifest_path": "C:/workspace/development/Cargo.toml", "dependencies": [] - }, - { - "name": "build", "id": "build", - "manifest_path": "C:/workspace/build/Cargo.toml", "dependencies": [] - }, - { - "name": "target", "id": "target", - "manifest_path": "C:/workspace/target/Cargo.toml", "dependencies": [] - } - ] - }"#; - let metadata: CargoMetadata = - serde_json::from_str(fixture).expect("adversarial metadata fixture parses"); - let names = workspace_package_names_by_root(&metadata); - let source = metadata - .packages - .iter() - .find(|package| package.name == "source") - .expect("fixture source exists"); - - assert_eq!( - direct_local_dependencies(source, &names), - ["build", "development", "normal", "target"] - .into_iter() - .collect() - ); -} - -#[test] -fn adversarial_metadata_triggers_each_product_dependency_rule() { - let fixture = r#" - { - "workspace_members": [ - "gateway-source", "promptforge-source", "workshop-source", - "workshop-shell-source", "shared-source", - "gateway-target", "promptforge-target", "workshop-target" - ], - "packages": [ - { - "name": "gateway-source", "id": "gateway-source", - "manifest_path": "C:/workspace/gateway-source/Cargo.toml", - "dependencies": [ - {"path": "C:/workspace/promptforge-target"}, - {"path": "C:/workspace/workshop-target"} - ] - }, - { - "name": "promptforge-source", "id": "promptforge-source", - "manifest_path": "C:/workspace/promptforge-source/Cargo.toml", - "dependencies": [ - {"path": "C:/workspace/gateway-target"}, - {"path": "C:/workspace/workshop-target"} - ] - }, - { - "name": "workshop", "id": "workshop-source", - "manifest_path": "C:/workspace/workshop-source/Cargo.toml", - "dependencies": [{"path": "C:/workspace/gateway-target"}] - }, - { - "name": "workshop-shell", "id": "workshop-shell-source", - "manifest_path": "C:/workspace/workshop-shell-source/Cargo.toml", - "dependencies": [{"path": "C:/workspace/gateway-target"}] - }, - { - "name": "shared-source", "id": "shared-source", - "manifest_path": "C:/workspace/shared-source/Cargo.toml", - "dependencies": [{"path": "C:/workspace/promptforge-target"}] - }, - { - "name": "gateway-target", "id": "gateway-target", - "manifest_path": "C:/workspace/gateway-target/Cargo.toml", "dependencies": [] - }, - { - "name": "promptforge-target", "id": "promptforge-target", - "manifest_path": "C:/workspace/promptforge-target/Cargo.toml", "dependencies": [] - }, - { - "name": "workshop-server", "id": "workshop-target", - "manifest_path": "C:/workspace/workshop-target/Cargo.toml", "dependencies": [] - } - ] - }"#; - let metadata: CargoMetadata = - serde_json::from_str(fixture).expect("adversarial metadata fixture parses"); - let violations = dependency_violations(&metadata); - - for rule in PRODUCT_DEPENDENCY_RULES { - assert!( - violations - .iter() - .any(|violation| violation.starts_with(rule.description)), - "fixture must trigger `{}`: {violations:?}", - rule.description - ); - } - assert_eq!( - violations.len(), - 7, - "PromptForge's combined rule rejects both forbidden product families, \ - the Workshop rule is prefix-based, and Shared rejects every product" - ); -} diff --git a/crates/gateway/stt/api/tests/it/main.rs b/crates/gateway/stt/api/tests/it/main.rs index 245982e08..ff0cc6ab4 100644 --- a/crates/gateway/stt/api/tests/it/main.rs +++ b/crates/gateway/stt/api/tests/it/main.rs @@ -4,8 +4,6 @@ #[path = "../common/mod.rs"] mod common; -#[cfg(not(miri))] -mod architecture; #[cfg(not(miri))] mod batch; #[cfg(not(miri))] diff --git a/crates/gateway/stt/api/tests/it/realtime_fixtures.rs b/crates/gateway/stt/api/tests/it/realtime_fixtures.rs index 8aa7ecb6d..893cd57a7 100644 --- a/crates/gateway/stt/api/tests/it/realtime_fixtures.rs +++ b/crates/gateway/stt/api/tests/it/realtime_fixtures.rs @@ -1,3 +1,5 @@ +//! Characterization tests for the realtime protocol JSON fixture files. + #![expect( clippy::expect_used, clippy::too_many_lines, diff --git a/crates/gateway/stt/api/tests/it/realtime_forced_windows.rs b/crates/gateway/stt/api/tests/it/realtime_forced_windows.rs index c0a2b0780..e47e2514c 100644 --- a/crates/gateway/stt/api/tests/it/realtime_forced_windows.rs +++ b/crates/gateway/stt/api/tests/it/realtime_forced_windows.rs @@ -1,3 +1,5 @@ +//! Integration tests for forced final windows across hour-long realtime sessions. + use std::time::{Duration, Instant}; use base64::Engine as _; diff --git a/crates/gateway/stt/api/tests/it/realtime_session.rs b/crates/gateway/stt/api/tests/it/realtime_session.rs index d4c2a8575..b24c5891e 100644 --- a/crates/gateway/stt/api/tests/it/realtime_session.rs +++ b/crates/gateway/stt/api/tests/it/realtime_session.rs @@ -1,3 +1,5 @@ +//! Integration tests for realtime session lifecycle, commits, and cancellation. + use std::future::{Future, pending}; use std::pin::Pin; use std::sync::atomic::{AtomicBool, Ordering}; diff --git a/crates/gateway/stt/backend-whisper/Cargo.toml b/crates/gateway/stt/backend-whisper/Cargo.toml index c15650add..3c0f3008f 100644 --- a/crates/gateway/stt/backend-whisper/Cargo.toml +++ b/crates/gateway/stt/backend-whisper/Cargo.toml @@ -11,7 +11,7 @@ description = "Safe Whisper decoder backend for the PromptForge STT engine" [dependencies] gateway-stt-engine.workspace = true gateway-whisper-ffi.workspace = true -shared-progress.workspace = true +gateway-progress.workspace = true tracing.workspace = true workspace-hack.workspace = true diff --git a/crates/gateway/stt/backend-whisper/src/config.rs b/crates/gateway/stt/backend-whisper/src/config.rs index 04b7c8f13..b8ea5f7ef 100644 --- a/crates/gateway/stt/backend-whisper/src/config.rs +++ b/crates/gateway/stt/backend-whisper/src/config.rs @@ -1,8 +1,9 @@ //! Safe Whisper backend construction values. use std::path::PathBuf; +use std::sync::{Arc, Weak}; -use shared_progress::ProgressHandle; +use gateway_progress::Activity; /// Provisioned Whisper runtime, model paths, and optional load progress. #[derive(Debug, Clone)] @@ -10,7 +11,9 @@ pub struct WhisperConfig { pub(crate) library: PathBuf, pub(crate) interim_model: PathBuf, pub(crate) final_model: Option, - pub(crate) progress: Option, + /// The load's activity, weakly held: the factory outlives the load, so + /// a decoder built after the caller's guard dropped reports nothing. + pub(crate) progress: Option>, } impl WhisperConfig { @@ -20,7 +23,7 @@ impl WhisperConfig { library: PathBuf, interim_model: PathBuf, final_model: Option, - progress: Option, + progress: Option>, ) -> Self { Self { library, @@ -29,4 +32,11 @@ impl WhisperConfig { progress, } } + + /// The load's activity while its owner's guard is alive, `None` once the + /// guard dropped or no progress was configured, so a decoder built + /// after the load ended reports nothing. + pub(crate) fn live_progress(&self) -> Option> { + self.progress.as_ref().and_then(Weak::upgrade) + } } diff --git a/crates/gateway/stt/backend-whisper/src/model.rs b/crates/gateway/stt/backend-whisper/src/model.rs index 70a1660e0..0efa9d5da 100644 --- a/crates/gateway/stt/backend-whisper/src/model.rs +++ b/crates/gateway/stt/backend-whisper/src/model.rs @@ -3,13 +3,13 @@ use std::io::Read; use std::path::Path; +use gateway_progress::Activity; use gateway_stt_engine::{ DecodeMode, DecodeRequest, Decoder, EnginePolicy, ModelFactory, TranscribeError, }; use gateway_whisper_ffi::{ FullParams, SamplingStrategy, WhisperContext, WhisperLibrary, WhisperState, }; -use shared_progress::ProgressHandle; use crate::WhisperConfig; use crate::prompt::{GLOSSARY_TOKEN_BUDGET, final_prompt, fit_glossary, sanitize_prompt}; @@ -61,7 +61,7 @@ impl WhisperModelFactory { impl ModelFactory for WhisperModelFactory { fn create(&self, mode: DecodeMode) -> Result>, TranscribeError> { - let (path, progress_name) = match mode { + let (path, role) = match mode { DecodeMode::Interim => (&self.config.interim_model, "interim"), DecodeMode::Final => { let Some(path) = &self.config.final_model else { @@ -71,12 +71,8 @@ impl ModelFactory for WhisperModelFactory { } _ => return Ok(None), }; - let progress = self - .config - .progress - .as_ref() - .map(|handle| handle.child(progress_name, 1.0)); - WhisperDecoder::load(&self.library, path, progress.as_ref()) + let progress = self.config.live_progress(); + WhisperDecoder::load(&self.library, path, role, progress.as_deref()) .map(|decoder| Some(Box::new(decoder) as Box)) } } @@ -91,19 +87,18 @@ impl WhisperDecoder { fn load( library: &WhisperLibrary, path: &Path, - progress: Option<&ProgressHandle>, + role: &str, + progress: Option<&Activity>, ) -> Result { - let prewarm_leaf = progress.map(|handle| handle.child("prewarm", 1.0)); - prewarm(path, prewarm_leaf.as_ref())?; - let init_leaf = progress.map(|handle| handle.child("init", 1.0)); + prewarm(path, role, progress)?; + if let Some(activity) = progress { + activity.set_text(format!("Initializing {role} speech model")); + } let context = WhisperContext::new(library, path).map_err(|source| load_model_error(path, source))?; let state = context .create_state() .map_err(|source| load_model_error(path, source))?; - if let Some(leaf) = &init_leaf { - leaf.complete(); - } Ok(Self { context, state }) } } @@ -164,13 +159,20 @@ fn inference_error(source: impl std::error::Error + Send + Sync + 'static) -> Tr TranscribeError::inference(source) } -fn prewarm(path: &Path, progress: Option<&ProgressHandle>) -> Result<(), TranscribeError> { +/// Reads the model file once so the page cache is warm before the context +/// loads it, writing `"Reading {role} speech model {pct}%"` into `progress` +/// on each whole-percent change. +fn prewarm(path: &Path, role: &str, progress: Option<&Activity>) -> Result<(), TranscribeError> { let total = std::fs::metadata(path) .map_err(|source| load_model_error(path, source))? .len(); let mut file = std::fs::File::open(path).map_err(|source| load_model_error(path, source))?; let mut buffer = vec![0u8; PREWARM_CHUNK]; let mut done = 0u64; + let mut last_percent = None; + if let Some(activity) = progress { + activity.set_text(format!("Reading {role} speech model")); + } loop { let read = file .read(&mut buffer) @@ -179,13 +181,16 @@ fn prewarm(path: &Path, progress: Option<&ProgressHandle>) -> Result<(), Transcr break; } done += read as u64; - if let Some(leaf) = progress { - leaf.set_units(done, total); + if let Some(activity) = progress + && total > 0 + { + let percent = (done.saturating_mul(100) / total).min(100); + if last_percent != Some(percent) { + last_percent = Some(percent); + activity.set_text(format!("Reading {role} speech model {percent}%")); + } } } - if let Some(leaf) = progress { - leaf.complete(); - } Ok(()) } @@ -227,26 +232,61 @@ fn transcribe_blocking( mod tests { use std::sync::Arc; - use shared_progress::ProgressHub; + use gateway_progress::ProgressHub; use super::*; #[test] - fn prewarm_of_a_plain_file_completes_progress() { + fn prewarm_of_a_plain_file_writes_the_read_percent() { let directory = tempfile::tempdir().expect("temporary model directory"); let path = directory.path().join("model.bin"); std::fs::write(&path, vec![0u8; 1024]).expect("fake model writes"); - let hub = Arc::new(ProgressHub::new()); - let tree = hub.operation(); - let leaf = tree.register("prewarm", 1.0); - prewarm(&path, Some(&leaf)).expect("prewarm reads the model"); - assert!((leaf.fraction() - 1.0).abs() < f64::EPSILON); + let hub = ProgressHub::new(); + let activity = hub.begin("loading-speech"); + prewarm(&path, "interim", Some(&activity)).expect("prewarm reads the model"); + assert_eq!(hub.current().text, "Reading interim speech model 100%"); + } + + #[test] + fn a_config_whose_load_activity_ended_yields_no_progress_to_a_decoder_build() { + // `create` resolves its activity through `live_progress`: while the + // load's guard is alive the decoder build writes into it, and once + // the guard dropped the same config yields nothing and the hub is + // idle. `WhisperLibrary` needs the packaged runtime, so the resolve + // step is exercised here and the full build in the native tests. + let hub = ProgressHub::new(); + let activity = Arc::new(hub.begin("loading-speech")); + let config = WhisperConfig::new( + "unused-library".into(), + "unused-interim.bin".into(), + None, + Some(Arc::downgrade(&activity)), + ); + let live = config + .live_progress() + .expect("a live guard resolves to its activity"); + live.set_text("Reading interim speech model"); + assert_eq!(hub.current().text, "Reading interim speech model"); + drop(live); + + drop(activity); + assert!(!hub.current().busy, "the load's guard ended the activity"); + assert!( + config.live_progress().is_none(), + "the config's weak handle cannot revive the ended activity" + ); + } + + #[test] + fn a_config_without_progress_yields_none() { + let config = WhisperConfig::new("unused-library".into(), "unused.bin".into(), None, None); + assert!(config.live_progress().is_none()); } #[test] fn prewarm_failure_is_a_model_error_naming_the_path() { let path = Path::new("definitely-missing-prewarm-model.bin"); - let error = prewarm(path, None).expect_err("missing model must fail"); + let error = prewarm(path, "interim", None).expect_err("missing model must fail"); assert!(matches!(error, TranscribeError::LoadModel { .. })); assert!( error diff --git a/crates/gateway/stt/backend-whisper/tests/native_whisper.rs b/crates/gateway/stt/backend-whisper/tests/native_whisper.rs index 99df83968..f22dbc74b 100644 --- a/crates/gateway/stt/backend-whisper/tests/native_whisper.rs +++ b/crates/gateway/stt/backend-whisper/tests/native_whisper.rs @@ -7,12 +7,12 @@ )] use std::path::{Path, PathBuf}; -use std::sync::Arc; +use std::sync::{Arc, Weak}; +use gateway_progress::{Activity, ProgressHub}; use gateway_stt_backend_whisper::{WhisperConfig, WhisperModelFactory}; use gateway_stt_engine::test_fixtures::native::require_fixture; use gateway_stt_engine::{DecodeMode, DecodeRequest, EnginePolicy, SttEngine}; -use shared_progress::{ProgressHandle, ProgressHub}; const JFK_TRANSCRIPT: &str = "And so my fellow Americans ask not what your country can do for you, ask what you can do for your country."; const UNPROMPTED_CLIP_TRANSCRIPT: &str = "country can do for you."; @@ -55,7 +55,7 @@ fn engine_with_progress( library: PathBuf, interim: PathBuf, final_model: Option, - progress: Option, + progress: Option>, ) -> SttEngine { let config = WhisperConfig::new(library, interim, final_model, progress); let factory = WhisperModelFactory::new(config).expect("packaged runtime loads"); @@ -268,7 +268,7 @@ async fn final_decode_is_absent_without_a_final_model() { #[tokio::test] #[ignore = "requires packaged whisper and model fixtures"] -async fn configured_model_branches_finish_prewarm_and_init_progress() { +async fn configured_model_branches_write_their_load_text_then_release_the_activity() { let _guard = NATIVE_TEST.lock().await; let library = require_fixture("PROMPTFORGE_WHISPER_LIBRARY", &fixture_dir(), "whisper.dll"); let model = require_fixture( @@ -276,35 +276,25 @@ async fn configured_model_branches_finish_prewarm_and_init_progress() { &fixture_dir(), "ggml-tiny.en.bin", ); - let hub = Arc::new(ProgressHub::new()); - let tree = hub.operation(); - let models = tree.register("models", 1.0); + let hub = ProgressHub::new(); + let activity = Arc::new(hub.begin("loading-speech")); - let engine = engine_with_progress(library, model.clone(), Some(model), Some(models)); + let engine = engine_with_progress( + library, + model.clone(), + Some(model), + Some(Arc::downgrade(&activity)), + ); assert!(engine.has_final_pass(), "the final branch is configured"); + let text = hub.current().text; + assert!( + text.starts_with("Initializing ") && text.ends_with(" speech model"), + "the last stage written is a branch's context initialization: {text:?}" + ); - let snapshot = hub.snapshot(); - let nodes = &snapshot[0].nodes; - for branch in ["interim", "final"] { - let branch_path = format!("models/{branch}"); - assert!( - nodes.iter().any(|node| node.path == branch_path), - "{branch} model progress branch is present: {nodes:?}" - ); - for stage in ["prewarm", "init"] { - let path = format!("{branch_path}/{stage}"); - let node = nodes - .iter() - .find(|node| node.path == path) - .unwrap_or_else(|| panic!("{path} progress is present: {nodes:?}")); - assert!( - node.finished && node.ok, - "{path} reaches a successful terminal state: {node:?}" - ); - assert!( - (node.fraction - 1.0).abs() < f64::EPSILON, - "{path} completes all work: {node:?}" - ); - } - } + // The factory holds the activity weakly: the load's guard alone keeps + // the hub busy, and dropping it leaves the factory nothing to write to. + drop(activity); + assert!(!hub.current().busy, "the load's guard ended the activity"); + drop(engine); } diff --git a/crates/gateway/stt/engine/src/test_fixtures/scenarios.rs b/crates/gateway/stt/engine/src/test_fixtures/scenarios.rs index d7335a4fd..b087db8b2 100644 --- a/crates/gateway/stt/engine/src/test_fixtures/scenarios.rs +++ b/crates/gateway/stt/engine/src/test_fixtures/scenarios.rs @@ -1,3 +1,5 @@ +//! Scripted decoder and model factory fixtures with parking controls. + use std::collections::VecDeque; use std::future::Future; use std::sync::{Arc, Condvar, Mutex, PoisonError}; diff --git a/crates/gateway/stt/engine/src/test_fixtures/tests-scenario-cleanup-construction.rs b/crates/gateway/stt/engine/src/test_fixtures/tests-scenario-cleanup-construction.rs index 75adb1a8a..75951cc87 100644 --- a/crates/gateway/stt/engine/src/test_fixtures/tests-scenario-cleanup-construction.rs +++ b/crates/gateway/stt/engine/src/test_fixtures/tests-scenario-cleanup-construction.rs @@ -1,3 +1,5 @@ +//! Tests that blocked scripted construction times out and releases cleanly. + use std::panic::{AssertUnwindSafe, catch_unwind}; use super::*; diff --git a/crates/gateway/stt/engine/src/test_fixtures/tests-scenario-cleanup-decode.rs b/crates/gateway/stt/engine/src/test_fixtures/tests-scenario-cleanup-decode.rs index 637d4f094..db7adf8a6 100644 --- a/crates/gateway/stt/engine/src/test_fixtures/tests-scenario-cleanup-decode.rs +++ b/crates/gateway/stt/engine/src/test_fixtures/tests-scenario-cleanup-decode.rs @@ -1,3 +1,5 @@ +//! Tests that blocked scripted decodes release after return or cancellation. + use super::*; fn start_decode( diff --git a/crates/gateway/stt/engine/src/test_fixtures/tests-scenario-cleanup.rs b/crates/gateway/stt/engine/src/test_fixtures/tests-scenario-cleanup.rs index 0e46e9bb7..1320ed075 100644 --- a/crates/gateway/stt/engine/src/test_fixtures/tests-scenario-cleanup.rs +++ b/crates/gateway/stt/engine/src/test_fixtures/tests-scenario-cleanup.rs @@ -1,3 +1,5 @@ +//! Shared helpers for scripted scenario cleanup tests. + use std::sync::Arc; use super::*; diff --git a/crates/gateway/stt/engine/src/test_fixtures/tests.rs b/crates/gateway/stt/engine/src/test_fixtures/tests.rs index a553d5900..4d203cb37 100644 --- a/crates/gateway/stt/engine/src/test_fixtures/tests.rs +++ b/crates/gateway/stt/engine/src/test_fixtures/tests.rs @@ -1,3 +1,5 @@ +//! Tests for the scripted engine fixtures and their thread affinity. + use super::*; use crate::{DecodeRequest, EnginePolicy, SttEngine}; diff --git a/crates/gateway/stt/whisper-ffi/src/context.rs b/crates/gateway/stt/whisper-ffi/src/context.rs index 7411bf6b5..743abe035 100644 --- a/crates/gateway/stt/whisper-ffi/src/context.rs +++ b/crates/gateway/stt/whisper-ffi/src/context.rs @@ -54,8 +54,9 @@ impl WhisperContext { path: model.to_path_buf(), }); }; - let model_text = CString::new(model_text).map_err(|_| WhisperError::InteriorNull { + let model_text = CString::new(model_text).map_err(|source| WhisperError::InteriorNull { value: "whisper model path", + source, })?; // SAFETY: the function pointer matches b4938, model_text is a live // null-terminated path, and params came from the same loaded library. @@ -104,8 +105,9 @@ impl WhisperContext { pub fn tokenize(&self, text: &str, max: usize) -> Result, WhisperError> { let max_c = c_int::try_from(max).map_err(|_| WhisperError::CountOverflow { value: "token" })?; - let text = CString::new(text).map_err(|_| WhisperError::InteriorNull { + let text = CString::new(text).map_err(|source| WhisperError::InteriorNull { value: "tokenization text", + source, })?; let mut tokens = vec![0; max]; // SAFETY: text is null-terminated, tokens has max_c writable entries, diff --git a/crates/gateway/stt/whisper-ffi/src/error.rs b/crates/gateway/stt/whisper-ffi/src/error.rs index 164e60e72..baa478a53 100644 --- a/crates/gateway/stt/whisper-ffi/src/error.rs +++ b/crates/gateway/stt/whisper-ffi/src/error.rs @@ -44,6 +44,9 @@ pub enum WhisperError { InteriorNull { /// Kind of text rejected at the C boundary. value: &'static str, + /// The refusal, naming the null byte's position. + #[source] + source: std::ffi::NulError, }, /// whisper.cpp returned no context for a model. diff --git a/crates/gateway/stt/whisper-ffi/src/params.rs b/crates/gateway/stt/whisper-ffi/src/params.rs index a92114641..f30628ffc 100644 --- a/crates/gateway/stt/whisper-ffi/src/params.rs +++ b/crates/gateway/stt/whisper-ffi/src/params.rs @@ -77,9 +77,10 @@ impl FullParams { /// Returns [`WhisperError::InteriorNull`] when `language` contains a null. pub fn set_language(&mut self, language: Option<&str>) -> Result<(), WhisperError> { self.language = match language { - Some(language) => Language::Explicit(CString::new(language).map_err(|_| { + Some(language) => Language::Explicit(CString::new(language).map_err(|source| { WhisperError::InteriorNull { value: "whisper language", + source, } })?), None => Language::Auto, @@ -144,8 +145,9 @@ impl FullParams { pub fn set_initial_prompt(&mut self, prompt: &str) -> Result<(), WhisperError> { self.initial_prompt = Some( - CString::new(prompt).map_err(|_| WhisperError::InteriorNull { + CString::new(prompt).map_err(|source| WhisperError::InteriorNull { value: "whisper initial prompt", + source, })?, ); Ok(()) diff --git a/crates/gateway/web-search/src/brave.rs b/crates/gateway/web-search/src/brave.rs index 5eaab655d..7a62df0f8 100644 --- a/crates/gateway/web-search/src/brave.rs +++ b/crates/gateway/web-search/src/brave.rs @@ -85,7 +85,7 @@ pub(crate) struct BraveSearchParams<'a> { pub(crate) safesearch: Option<&'a str>, } -/// Compute the Brave over-fetch count from a clamped requested count. +/// Computes the Brave over-fetch count from a clamped requested count. /// /// `brave_count = min(max_count, requested_count.saturating_mul(3).max(requested_count))` #[must_use] @@ -95,12 +95,12 @@ pub(crate) fn brave_overfetch_count(requested_count: u8, max_count: u8) -> u8 { over.min(max_count) } -/// Prefix Brave upstream errors with `web_search: `. +/// Prefixes Brave upstream errors with `web_search: `. pub(crate) fn prefix_web_search_upstream(err: ProtocolError) -> ProtocolError { prefix_protocol(err) } -/// Prefix the protocol-level Brave upstream errors with `web_search: `. +/// Prefixes the protocol-level Brave upstream errors with `web_search: `. fn prefix_protocol(err: ProtocolError) -> ProtocolError { match err { ProtocolError::UpstreamStatus { status, body, .. } => ProtocolError::upstream_status( @@ -140,7 +140,7 @@ impl std::error::Error for WebSearchUpstream { } } -/// Build Brave `/web/search` query pairs from [`BraveSearchParams`]. +/// Builds Brave `/web/search` query pairs from [`BraveSearchParams`]. /// /// Always includes `extra_snippets=true`. Optional knobs are omitted when `None`. #[must_use] @@ -165,7 +165,7 @@ pub(crate) fn brave_search_query(params: &BraveSearchParams<'_>) -> Vec<(&'stati query } -/// Call the Brave Search API and map `web.results` to [`SearchResult`] values. +/// Calls the Brave Search API and maps `web.results` to [`SearchResult`] values. /// /// Always sends `extra_snippets=true`. Optional knobs are omitted when `None`. /// diff --git a/crates/gateway/web-search/src/process.rs b/crates/gateway/web-search/src/process.rs index 1091376c5..998d83e53 100644 --- a/crates/gateway/web-search/src/process.rs +++ b/crates/gateway/web-search/src/process.rs @@ -21,8 +21,8 @@ pub(crate) const MAX_EXTRA_SNIPPETS: usize = 8; /// Max characters kept for a result `age` after sanitisation (WSP-001). pub(crate) const AGE_MAX_CHARS: usize = 64; -/// Sanitize free text: drop most controls, collapse whitespace, trim, decode a -/// fixed entity set, then cap by Unicode scalar count. +/// Sanitizes free text: drops most controls, collapses whitespace, trims, decodes a +/// fixed entity set, then caps by Unicode scalar count. #[must_use] pub(crate) fn sanitize_text(text: &str, max_chars: usize) -> String { // Bound the work up front (WSP-002): entity decoding and the final cap can @@ -44,7 +44,7 @@ pub(crate) fn sanitize_text(text: &str, max_chars: usize) -> String { truncate_chars(&decoded, max_chars) } -/// Drop known tracking query parameters from `url`. Removes a trailing empty `?`. +/// Drops known tracking query parameters from `url`. Removes a trailing empty `?`. /// /// Params removed when the name equals `fbclid`, `gclid`, `mc_cid`, `mc_eid`, /// or starts with `utm_`. Does not truncate: an over-length URL is dropped by @@ -81,7 +81,7 @@ pub(crate) fn strip_tracking_params(url: &str) -> String { out } -/// Extract the hostname from `url` without a URL crate. +/// Extracts the hostname from `url` without a URL crate. /// /// Handles optional scheme, `userinfo@`, and strips a trailing port. Returns /// lowercase host text, or `None` when no host can be parsed. @@ -155,7 +155,7 @@ pub(crate) fn site_name_from_host(host: &str) -> String { .to_string() } -/// Apply include then exclude domain filters. +/// Applies include then exclude domain filters. /// /// Empty `include_domains` means no include filter. Empty `exclude_domains` /// means no exclude filter. A hostname matches a listed domain when they are @@ -192,7 +192,7 @@ pub(crate) fn filter_domains( .collect() } -/// Keep results in order while each host group stays under `max_per_host`, +/// Keeps results in order while each host group stays under `max_per_host`, /// stopping once `count` results are kept. /// /// Host groups use full hostname, lowercase, with one leading `www.` stripped. @@ -224,7 +224,7 @@ pub(crate) fn diversify_hosts( kept } -/// Run the full post-process pipeline on mapped Brave hits. +/// Runs the full post-process pipeline on mapped Brave hits. /// /// Steps: sanitize title/description, optional tracking strip + URL cap, /// set `site_name`, include then exclude domain filters, diversify hosts. diff --git a/crates/gateway/web-search/src/service.rs b/crates/gateway/web-search/src/service.rs index 16414a08a..a4c80a80c 100644 --- a/crates/gateway/web-search/src/service.rs +++ b/crates/gateway/web-search/src/service.rs @@ -33,7 +33,7 @@ pub(crate) struct WebSearchSettings { } impl WebSearchSettings { - /// Build settings from the tool configuration. + /// Builds settings from the tool configuration. #[must_use] pub(crate) fn from_config(cfg: &WebSearchConfig) -> WebSearchSettings { WebSearchSettings { @@ -62,7 +62,7 @@ pub struct WebSearchState { } impl WebSearchState { - /// Build web-search state from its configuration. + /// Builds web-search state from its configuration. #[must_use] pub fn new(cfg: &WebSearchConfig) -> WebSearchState { // v0 supports only the Brave provider; the query path below is @@ -142,7 +142,7 @@ pub struct SearchResult { /// Maximum query length kept, in Unicode scalar values (TOOLS-004). const MAX_QUERY_CHARS: usize = 512; -/// Trim Unicode whitespace from `query`, reject empty values, and cap length. +/// Trims Unicode whitespace from `query`, rejects empty values, and caps length. /// /// # Errors /// Returns [`WebSearchError::MalformedRequest`] with @@ -159,7 +159,7 @@ fn trim_web_search_query(query: &str) -> Result { Ok(trimmed.chars().take(MAX_QUERY_CHARS).collect()) } -/// Validate and canonicalize caller-supplied domain filters (WSP-006). +/// Validates and canonicalizes caller-supplied domain filters (WSP-006). /// /// Each entry must be a bare hostname/domain, not a URL: non-empty, ASCII, no /// scheme, path, port, or whitespace, and standard label syntax. A malformed @@ -178,7 +178,7 @@ fn validate_domain_filters(field: &str, domains: &[String]) -> Result Result { let domain = raw.trim(); let malformed = @@ -219,14 +219,14 @@ fn is_valid_domain_syntax(domain: &str) -> bool { labels >= 1 } -/// Clamp the requested count into `1..=max_count`. +/// Clamps the requested count into `1..=max_count`. #[must_use] fn clamp_count(requested: u8, max_count: u8) -> u8 { let max_count = max_count.max(1); requested.clamp(1, max_count) } -/// Reject malformed request-supplied provider knobs at the boundary (TOOLS-004). +/// Rejects malformed request-supplied provider knobs at the boundary (TOOLS-004). /// /// Empty/absent knobs are omitted downstream and need no validation; the config /// defaults are already validated at load. This validates only caller-supplied, @@ -296,17 +296,17 @@ fn is_alpha_code(value: &str, min: usize, max: usize) -> bool { len >= min && len <= max && value.chars().all(|c| c.is_ascii_alphabetic()) } -/// Resolve an optional string knob: `Some` and non-empty after trim, else `None`. +/// Resolves an optional string knob: `Some` and non-empty after trim, else `None`. fn non_empty_opt(value: Option<&str>) -> Option<&str> { value.map(str::trim).filter(|s| !s.is_empty()) } -/// Resolve freshness: request value, else non-empty settings default, else omit. +/// Resolves freshness: request value, else non-empty settings default, else omit. fn resolve_freshness<'a>(request: Option<&'a str>, default_freshness: &'a str) -> Option<&'a str> { non_empty_opt(request).or_else(|| non_empty_opt(Some(default_freshness))) } -/// Resolve safesearch: request value, else non-empty settings default, else omit. +/// Resolves safesearch: request value, else non-empty settings default, else omit. fn resolve_safesearch<'a>( request: Option<&'a str>, default_safesearch: &'a str, @@ -315,7 +315,7 @@ fn resolve_safesearch<'a>( } impl WebSearchState { - /// Run a web search against the configured provider and post-process the + /// Runs a web search against the configured provider and post-processes the /// results. /// /// The query is trimmed and capped, the closed-vocabulary knobs are diff --git a/crates/harness-api/src/lib.rs b/crates/harness-api/src/lib.rs index 5ec207e4c..4b39ac1b0 100644 --- a/crates/harness-api/src/lib.rs +++ b/crates/harness-api/src/lib.rs @@ -1,16 +1,17 @@ //! harness-api - the public door into the PromptForge harness family: the //! harness configuration, the gateway binding a client pushes at startup //! and on every gateway replacement, the session, event, and delta -//! types a client renders, and the awaitable [`cancel::CancelHandle`] a -//! client selects over. +//! types a client renders, the awaitable [`cancel::CancelHandle`] a +//! client selects over, and [`display_chain`], the renderer that turns a +//! harness error and its cause chain into one line for a person. //! //! ## Invariants //! //! - Family: harness door; may depend on: `promptforge-api-runtime`, -//! `promptforge-api-types`, `gateway-api`, `gateway-api-discovery`, -//! `shared-*`, and the crates under `crates/harness/`. Never on a -//! `workshop-*` crate or a private `gateway-*` crate. Read `AGENTS.md` -//! before adding an import. +//! `promptforge-api-types`, `gateway-api-types`, +//! `gateway-api-discovery`, `shared-*`, and the crates under +//! `crates/harness/`. Never on a `workshop-*` crate or a private +//! `gateway-*` crate. Read `AGENTS.md` before adding an import. //! - Every file in this crate stays under 500 lines; split first, then //! edit. //! - A gateway bearer key is never written to logs or `Debug` output. @@ -25,6 +26,9 @@ mod session; pub use harness::{ CatalogBinding, GatewayBinding, Harness, HarnessConfig, HostSnapshot, LaunchError, }; +// A harness error's `Display` carries only its own message; a client that +// shows one to a person renders the cause chain through this. +pub use harness_runner::display_chain; pub use session::{ Delta, DeltaKind, FailureKind, LaunchRequest, Session, SessionEvent, SessionFailure, SessionId, SessionState, WaitError, WaitFrame, diff --git a/crates/harness/capabilities/src/lib.rs b/crates/harness/capabilities/src/lib.rs index 29ded89c7..e5a36217e 100644 --- a/crates/harness/capabilities/src/lib.rs +++ b/crates/harness/capabilities/src/lib.rs @@ -15,8 +15,9 @@ //! ## Invariants //! //! - Family: harness, private to `crates/harness/`; may depend on: -//! `promptforge-api-runtime`, `promptforge-api-types`, `gateway-api`, -//! `gateway-api-discovery`, `shared-*`, and its container siblings. +//! `promptforge-api-runtime`, `promptforge-api-types`, +//! `gateway-api-types`, `gateway-api-discovery`, `shared-*`, and its +//! container siblings. //! Never on a `workshop-*` crate, a private `gateway-*` crate, or a //! `promptforge-*` crate behind the door. Read `AGENTS.md` before adding //! an import. diff --git a/crates/harness/capabilities/src/tool.rs b/crates/harness/capabilities/src/tool.rs index 0d50ae369..93fa043fc 100644 --- a/crates/harness/capabilities/src/tool.rs +++ b/crates/harness/capabilities/src/tool.rs @@ -144,7 +144,7 @@ pub trait Tool: Send + Sync { .structured(self.structured_output()) } - /// Execute the tool with the given JSON arguments and return its output. + /// Executes the tool with the given JSON arguments and returns its output. /// /// The returned [`ToolOutput`] carries its own /// [`OutputTrust`](promptforge_api_types::tools::OutputTrust), so trust diff --git a/crates/harness/log/Cargo.toml b/crates/harness/log/Cargo.toml index cbc8a5cdd..fb2aeaec4 100644 --- a/crates/harness/log/Cargo.toml +++ b/crates/harness/log/Cargo.toml @@ -14,6 +14,7 @@ documentation = "https://cppalliance.github.io/promptforge/" [dependencies] serde_json.workspace = true +shared-error-source = { workspace = true, features = ["database", "json"] } thiserror.workspace = true turso.workspace = true workspace-hack.workspace = true diff --git a/crates/harness/log/src/error.rs b/crates/harness/log/src/error.rs index 24f15d625..95d444cf0 100644 --- a/crates/harness/log/src/error.rs +++ b/crates/harness/log/src/error.rs @@ -2,32 +2,40 @@ use std::io; +// The engine and serde causes behind the variants below. A caller that +// needs the underlying error names `shared_error_source` directly; this +// crate does not re-export the wrappers, so there is one name for the +// cause across the workspace rather than one per crate. +use shared_error_source::{DatabaseSource, JsonSource}; + use crate::RunId; /// Why a run log operation failed. #[derive(Debug, thiserror::Error)] +#[non_exhaustive] pub enum LogError { - /// The database engine refused an operation. - #[error("run log database: {source}")] + /// The database engine refused an operation; the engine's error is + /// the source. + #[error("run log database")] Database { /// The engine's error. - #[from] - source: turso::Error, + #[source] + source: DatabaseSource, }, - /// The log file could not be addressed. - #[error("run log file: {source}")] + /// The log file could not be addressed; the I/O error is the source. + #[error("run log file")] Io { /// The I/O error. #[from] source: io::Error, }, /// A payload could not be serialized on the way in or parsed on the - /// way out. - #[error("run log payload: {source}")] + /// way out; the serde error is the source. + #[error("run log payload")] Payload { /// The serde error. - #[from] - source: serde_json::Error, + #[source] + source: JsonSource, }, /// No run with this id was ever begun in this log. #[error("run log: unknown run {0}")] @@ -40,3 +48,57 @@ pub enum LogError { #[error("run log: corrupt row: {0}")] Corrupt(String), } + +impl From for LogError { + fn from(source: turso::Error) -> Self { + LogError::Database { + source: source.into(), + } + } +} + +impl From for LogError { + fn from(source: serde_json::Error) -> Self { + LogError::Payload { + source: source.into(), + } + } +} + +#[cfg(test)] +mod tests { + use std::error::Error as _; + + use shared_error_source::{DatabaseSource, JsonSource}; + + use super::LogError; + + #[test] + fn the_database_variant_reaches_the_engine_error_through_the_shared_wrapper() { + let error = LogError::from(turso::Error::Corrupt( + "page 1 is not a b-tree page".to_owned(), + )); + let Some(cause) = error.source() else { + panic!("the database variant carries its engine cause as source()"); + }; + let Some(wrapper) = cause.downcast_ref::() else { + panic!("the engine cause is the shared DatabaseSource"); + }; + assert!(matches!(wrapper.as_inner(), turso::Error::Corrupt(_))); + } + + #[test] + fn the_payload_variant_reaches_the_serde_error_through_the_shared_wrapper() { + let Err(json) = serde_json::from_str::("nope") else { + panic!("`nope` must not parse as a u32"); + }; + let error = LogError::from(json); + let Some(cause) = error.source() else { + panic!("the payload variant carries its serde cause as source()"); + }; + let Some(wrapper) = cause.downcast_ref::() else { + panic!("the serde cause is the shared JsonSource"); + }; + assert!(wrapper.as_inner().is_syntax()); + } +} diff --git a/crates/harness/log/src/lib.rs b/crates/harness/log/src/lib.rs index 2725a6660..2a59f108c 100644 --- a/crates/harness/log/src/lib.rs +++ b/crates/harness/log/src/lib.rs @@ -4,8 +4,9 @@ //! ## Invariants //! //! - Family: harness, private to `crates/harness/`; may depend on: -//! `promptforge-api-runtime`, `promptforge-api-types`, `gateway-api`, -//! `gateway-api-discovery`, `shared-*`, and its container siblings. +//! `promptforge-api-runtime`, `promptforge-api-types`, +//! `gateway-api-types`, `gateway-api-discovery`, `shared-*`, and its +//! container siblings. //! Never on a `workshop-*` crate, a private `gateway-*` crate, or a //! `promptforge-*` crate behind the door. Read `AGENTS.md` before adding //! an import. diff --git a/crates/harness/log/src/record.rs b/crates/harness/log/src/record.rs index 1b29209e4..66094e120 100644 --- a/crates/harness/log/src/record.rs +++ b/crates/harness/log/src/record.rs @@ -141,6 +141,7 @@ pub struct RecordFilter { /// One record as the log returns it. #[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] pub struct StoredRecord { /// The record's position in its run. pub seq: Seq, @@ -183,6 +184,7 @@ impl RunOutcome { /// One run as the log returns it. #[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] pub struct RunRow { /// The run's identity. pub id: RunId, diff --git a/crates/harness/models/AGENTS.md b/crates/harness/models/AGENTS.md index 3e8a713d4..57a95f04a 100644 --- a/crates/harness/models/AGENTS.md +++ b/crates/harness/models/AGENTS.md @@ -1,12 +1,10 @@ # harness-models -This crate owns the harness's model transport: the HTTP client that performs the engine's `Chat` effects against the bound gateway, and the catalog fetch a host resolves model selections against. +This crate owns the harness's model transport: the HTTP client that performs the engine's `Chat` effects against the bound gateway, and the catalog fetch a host resolves model selections against. The dependency rules and the core invariants are in the `## Invariants` block of `src/lib.rs`; this file holds only what that block does not say. -- This is a Gateway model client, not a universal transport. It speaks the one always-streaming `/chat/completions` SSE shape and `GET /v1/models` the gateway serves. Other protocols use separate clients. -- Everything the client exchanges is the engine's vocabulary (`Message`, `ToolSchema`, `CompletionOptions`, `Completion`, `CompletionError`), reached through `promptforge_api_runtime::model`. The request body builder, the SSE reassembly, and the read loop (`read_body_capped`, `read_completion_stream` over a `ChunkSource`) are shared seams behind that door; this crate never rebuilds the body shape, re-judges a turn, or grows its own copy of the byte cap, the `[DONE]` rule, or the timing arithmetic. It owns only what touches the wire: sending, the request timeout, the response as a chunk source, the clock it hands the read loop, and environment loading. -- Metrics vocabulary is canonical in `promptforge-api-types`. `ClientTiming` is measured against this crate's clock by the shared read loop; this crate never defines a parallel metrics model. +- Other protocols use separate clients; this one speaks only the always-streaming `/chat/completions` SSE shape and `GET /v1/models`. +- The request body builder, the SSE reassembly, and the read loop (`read_body_capped`, `read_completion_stream` over a `ChunkSource`) are shared seams behind the `promptforge_api_runtime::model` door; this crate never rebuilds the body shape, re-judges a turn, or grows its own copy of the byte cap, the `[DONE]` rule, or the timing arithmetic. It owns only what touches the wire: sending, the request timeout, the response as a chunk source, the clock it hands the read loop, and environment loading. - Every `reqwest::Error` this crate erases into the substrate (`Http`, `BackendBodyRead`) is boxed through `transport_source`, which applies the timeout marker, so `is_timeout` holds under every variant. -- The client holds only the gateway's URL and the shared bearer key, wrapped in `SecretString` at the boundary. The vendor credential lives in the gateway. A bearer key never appears in `Debug`, `Display`, logs, or error text; `Debug` redacts to a fixed marker so no presence or length signal leaks. +- The shared bearer key is wrapped in `SecretString` at the boundary; `Debug` redacts to a fixed marker so no presence or length signal leaks, and the key never appears in `Display` or error text. - A keyless client is an explicit choice (`GatewayClient::keyless`, or `from_env` against a loopback URL); nothing here checks the endpoint's host on the caller's behalf. - A backend error body is bounded and control-escaped before it is kept, and rides only in the opt-in `backend_body` accessor, never in `Display`. A success stream is refused once it exceeds the run's byte cap, before decoding. -- Family rules: depends on `promptforge-api-runtime`, `promptforge-api-types`, and container siblings only. Never on a `workshop-*` crate, a private `gateway-*` crate, or a `promptforge-*` crate behind the door. Tests spawn their mock gateways through `harness-runner`'s instrumented wrapper, never `tokio::spawn`. diff --git a/crates/harness/models/src/lib.rs b/crates/harness/models/src/lib.rs index d710b4ce5..2b037ebdc 100644 --- a/crates/harness/models/src/lib.rs +++ b/crates/harness/models/src/lib.rs @@ -27,8 +27,9 @@ //! ## Invariants //! //! - Family: harness, private to `crates/harness/`; may depend on: -//! `promptforge-api-runtime`, `promptforge-api-types`, `gateway-api`, -//! `gateway-api-discovery`, `shared-*`, and its container siblings. +//! `promptforge-api-runtime`, `promptforge-api-types`, +//! `gateway-api-types`, `gateway-api-discovery`, `shared-*`, and its +//! container siblings. //! Never on a `workshop-*` crate, a private `gateway-*` crate, or a //! `promptforge-*` crate behind the door. Read `AGENTS.md` before adding //! an import. diff --git a/crates/harness/models/src/transport.rs b/crates/harness/models/src/transport.rs index 02075f542..86dc68014 100644 --- a/crates/harness/models/src/transport.rs +++ b/crates/harness/models/src/transport.rs @@ -96,7 +96,7 @@ impl fmt::Debug for GatewayClient { } impl GatewayClient { - /// Build a client from a validated [`GatewayEndpoint`] and a redacted + /// Builds a client from a validated [`GatewayEndpoint`] and a redacted /// [`SecretString`] bearer key (used by tests and by /// [`GatewayClient::from_env`]). /// @@ -130,7 +130,7 @@ impl GatewayClient { } } - /// Build a client that presents no bearer key. + /// Builds a client that presents no bearer key. /// /// Every request goes out without an `Authorization` header. This fits a /// gateway on the same machine, which trusts keyless loopback callers by @@ -161,7 +161,7 @@ impl GatewayClient { } } - /// Build a client that cannot read gateway configuration or send HTTP. + /// Builds a client that cannot read gateway configuration or send HTTP. /// /// Hosts use this explicit sentinel for hermetic execution paths. Any /// attempted model call fails with a `Disabled`-kind [`CompletionError`]. @@ -259,7 +259,7 @@ impl GatewayClient { .map_err(CompletionError::from) } - /// Send a list of messages and return the model's accumulated outcome. + /// Sends a list of messages and returns the model's accumulated outcome. /// /// The one completion method, always streaming: the request asks for SSE /// with `stream_options.include_usage`, deltas are accumulated into the diff --git a/crates/harness/models/src/transport/tests/mod.rs b/crates/harness/models/src/transport/tests.rs similarity index 100% rename from crates/harness/models/src/transport/tests/mod.rs rename to crates/harness/models/src/transport/tests.rs diff --git a/crates/harness/models/src/transport/tests/limits.rs b/crates/harness/models/src/transport/tests/limits.rs index fe18f5499..f6b1e02ed 100644 --- a/crates/harness/models/src/transport/tests/limits.rs +++ b/crates/harness/models/src/transport/tests/limits.rs @@ -109,8 +109,7 @@ async fn a_request_past_the_timeout_is_a_timeout_transport_failure() { // never answers within it fails as Transport, and the timeout survives // the type erasure so `is_timeout` holds. async fn stall() -> (axum::http::StatusCode, String) { - tokio::time::sleep(Duration::from_secs(30)).await; - (axum::http::StatusCode::OK, String::new()) + std::future::pending().await } let app = Router::new().route("/v1/chat/completions", post(stall)); let client = client_for(app).await.with_request_limits( @@ -146,7 +145,10 @@ async fn a_body_read_timeout_keeps_its_marker_under_backend_body_read() { let header = "HTTP/1.1 500 Internal Server Error\r\n\ Content-Length: 1000000\r\n\r\nabc"; let _ = sock.write_all(header.as_bytes()).await; - tokio::time::sleep(Duration::from_secs(30)).await; + // The stall never ends on its own: the client's read timeout + // is what ends the test, and the runtime's teardown drops + // the socket. + std::future::pending::<()>().await; } }); let response = reqwest::Client::new() diff --git a/crates/harness/runner/clippy.toml b/crates/harness/runner/clippy.toml index a415caed6..0b4d86b78 100644 --- a/crates/harness/runner/clippy.toml +++ b/crates/harness/runner/clippy.toml @@ -6,8 +6,8 @@ allow-expect-in-tests = true # The harness spawns only through the instrumented wrapper in this crate's # `spawn` module, which tags each task with its EffectId and Provenance. # `cargo test -p build-xtask` checks that every harness crate names both -# methods. The two wrapper functions are the only sites allowed to call -# them, each under an explicit `#[allow(clippy::disallowed_methods)]`. +# methods. The wrapper functions in `spawn` are the only sites allowed to +# call them, each under an explicit `#[expect(clippy::disallowed_methods)]`. disallowed-methods = [ { path = "tokio::spawn", reason = "spawn through harness-runner's instrumented wrapper" }, { path = "tokio::task::spawn_blocking", reason = "spawn through harness-runner's instrumented wrapper" }, diff --git a/crates/harness/runner/src/cancel-tests.rs b/crates/harness/runner/src/cancel-tests.rs index 9ceddfada..03757af70 100644 --- a/crates/harness/runner/src/cancel-tests.rs +++ b/crates/harness/runner/src/cancel-tests.rs @@ -1,3 +1,5 @@ +//! Tests for the runner's cancel handle, scopes, and parent-to-child propagation. + use super::*; use std::time::Duration; use tokio::sync::oneshot; diff --git a/crates/harness/runner/src/display_chain-tests.rs b/crates/harness/runner/src/display_chain-tests.rs new file mode 100644 index 000000000..c4041d1f6 --- /dev/null +++ b/crates/harness/runner/src/display_chain-tests.rs @@ -0,0 +1,55 @@ +//! Tests for `display_chain` rendering of an error and its causes. + +use super::display_chain; + +/// A leaf cause with its own text. +#[derive(Debug, thiserror::Error)] +#[error("disk gone")] +struct Leaf; + +/// An outer error whose text does not mention its cause. +#[derive(Debug, thiserror::Error)] +#[error("the prompt could not be read")] +struct Outer(#[source] Leaf); + +/// An outer error that copies its cause's text into its own message, the +/// shape of a variant such as `LuaRuntime { message, source }`. +#[derive(Debug, thiserror::Error)] +#[error("lua runtime error: {message}")] +struct Copying { + message: String, + #[source] + source: Leaf, +} + +#[test] +fn a_two_level_chain_renders_the_cause_after_the_outer_text() { + let rendered = display_chain(&Outer(Leaf)); + assert_eq!( + rendered, "the prompt could not be read: disk gone", + "the cause follows the outer text after a colon" + ); +} + +#[test] +fn a_cause_already_quoted_by_the_outer_text_is_not_appended_twice() { + let error = Copying { + message: "disk gone".to_owned(), + source: Leaf, + }; + let rendered = display_chain(&error); + assert_eq!( + rendered, "lua runtime error: disk gone", + "a cause whose text the outer message already carries is skipped" + ); + assert_eq!( + rendered.matches("disk gone").count(), + 1, + "the cause text appears exactly once" + ); +} + +#[test] +fn a_leaf_renders_as_its_own_text() { + assert_eq!(display_chain(&Leaf), "disk gone"); +} diff --git a/crates/harness/runner/src/display_chain.rs b/crates/harness/runner/src/display_chain.rs new file mode 100644 index 000000000..74deaf698 --- /dev/null +++ b/crates/harness/runner/src/display_chain.rs @@ -0,0 +1,37 @@ +//! Rendering an error and its cause chain as one line of text for a +//! person or a model. +//! +//! A `thiserror` variant renders only its own message; its `#[source]` is +//! reachable through `source()` but not repeated in the text. Where the +//! text leaves the program - a run's failed outcome in the log, a +//! failure pushed to a session's client - the chain is walked here so the +//! reader sees the cause and not just the outermost frame. + +use std::error::Error; + +/// Renders `error`'s text followed by each cause in its `source()` chain, +/// separated by `: `. +/// +/// A cause whose text the accumulated rendering already contains is +/// skipped: some variants copy their source's text into their own +/// message (an engine `LuaRuntime { message, source }`, for one), and +/// appending that cause again would print it twice. The check is a plain +/// substring test on the text rendered so far. +#[must_use] +pub fn display_chain(error: &dyn Error) -> String { + let mut rendered = error.to_string(); + let mut cause = error.source(); + while let Some(current) = cause { + let text = current.to_string(); + if !text.is_empty() && !rendered.contains(&text) { + rendered.push_str(": "); + rendered.push_str(&text); + } + cause = current.source(); + } + rendered +} + +#[cfg(test)] +#[path = "display_chain-tests.rs"] +mod tests; diff --git a/crates/harness/runner/src/effect_loop.rs b/crates/harness/runner/src/effect_loop.rs index 60d368bfe..ac3a70c2c 100644 --- a/crates/harness/runner/src/effect_loop.rs +++ b/crates/harness/runner/src/effect_loop.rs @@ -41,6 +41,7 @@ use promptforge_api_types::ids::Provenance; use tokio::sync::{Mutex, mpsc}; use tokio::task::JoinHandle; +use crate::display_chain::display_chain; use crate::performers::Performers; use crate::spawn::{spawn_blocking_tagged, spawn_tagged}; @@ -56,6 +57,7 @@ pub type SharedLog = Arc>; /// Why the loop stopped without an outcome. #[derive(Debug, thiserror::Error)] +#[non_exhaustive] pub enum DriveError { /// The run log refused a write; the run cannot be recorded, so it is /// not driven further. @@ -448,12 +450,12 @@ fn outcome_of(result: RunResult) -> RunOutcome { } /// The log's failed outcome for an engine error: `runs.error_kind` is the -/// kind's debug name and `runs.error_message` the error's text. The one -/// derivation for a run that failed under the loop and a run preparation -/// refused, so the two agree in the log. +/// kind's debug name and `runs.error_message` the error's text with its +/// cause chain. The one derivation for a run that failed under the loop +/// and a run preparation refused, so the two agree in the log. pub(crate) fn failed_outcome(error: &RunError) -> RunOutcome { RunOutcome::Failed { kind: format!("{:?}", error.kind()), - message: error.to_string(), + message: display_chain(error), } } diff --git a/crates/harness/runner/src/lib.rs b/crates/harness/runner/src/lib.rs index d0eb57188..f4cd88181 100644 --- a/crates/harness/runner/src/lib.rs +++ b/crates/harness/runner/src/lib.rs @@ -8,15 +8,17 @@ //! ## Invariants //! //! - Family: harness, private to `crates/harness/`; may depend on: -//! `promptforge-api-runtime`, `promptforge-api-types`, `gateway-api`, -//! `gateway-api-discovery`, `shared-*`, and its container siblings. +//! `promptforge-api-runtime`, `promptforge-api-types`, +//! `gateway-api-types`, `gateway-api-discovery`, `shared-*`, and its +//! container siblings. //! Never on a `workshop-*` crate, a private `gateway-*` crate, or a //! `promptforge-*` crate behind the door. Read `AGENTS.md` before adding //! an import. //! - Every file in this crate stays under 500 lines; split first, then //! edit. -//! - [`spawn::spawn_tagged`], [`spawn::spawn_blocking_tagged`], and -//! [`spawn::spawn_session`] are the only sites in the harness that call +//! - [`spawn::spawn_tagged`], [`spawn::spawn_blocking_tagged`], +//! [`spawn::spawn_session`], and [`spawn::spawn_blocking_launch`] are +//! the only sites in the harness that call //! `tokio::spawn` and `tokio::task::spawn_blocking`; every other harness crate's //! `clippy.toml` bans the raw calls, and `cargo test -p build-xtask` //! checks the bans are declared. @@ -32,9 +34,12 @@ //! other when it launches a run. `harness-api` re-exports the module. pub mod cancel; +mod display_chain; pub mod effect_loop; pub mod performers; pub mod prepare; pub mod spawn; #[cfg(feature = "test-support")] pub mod test_support; + +pub use display_chain::display_chain; diff --git a/crates/harness/runner/src/prepare.rs b/crates/harness/runner/src/prepare.rs index f7ee3c958..b3c55718c 100644 --- a/crates/harness/runner/src/prepare.rs +++ b/crates/harness/runner/src/prepare.rs @@ -36,6 +36,7 @@ use promptforge_api_types::timestamp::Timestamp; use sha2::{Digest as _, Sha256}; use shared_vfs::VfsRef; +use crate::display_chain::display_chain; use crate::effect_loop::{SharedLog, failed_outcome}; use crate::performers::{ ActivatedTools, ChatPerformer, InputPerformer, LogTaskEvents, Performers, TokioTimer, VfsStore, @@ -110,10 +111,11 @@ pub struct Prepared { /// Why a run could not be prepared. #[derive(Debug, thiserror::Error)] +#[non_exhaustive] pub enum PrepareError { /// The prompt file could not be read; no row is written, since there - /// is no prompt to record. - #[error("the prompt at {path} could not be read: {source}")] + /// is no prompt to record. The read failure is the source. + #[error("the prompt at {path} could not be read")] Read { /// The path that was read. path: PathBuf, @@ -121,8 +123,9 @@ pub enum PrepareError { #[source] source: io::Error, }, - /// The prompt failed to parse. Its row is closed as failed. - #[error("the prompt at {path} failed to parse: {source}")] + /// The prompt does not parse. Its row is closed as failed; the parse + /// failure is the source. + #[error("the prompt at {path} does not parse")] Parse { /// The path that was parsed. path: PathBuf, @@ -134,10 +137,10 @@ pub enum PrepareError { }, /// The environment cannot satisfy the prompt: a required capability /// is missing, two declared capabilities conflict, or the current - /// model falls short of a role's requirements. The message is the - /// engine's model-readable notice, one line per gap. The run's row is - /// closed as failed with that notice. - #[error("{error}")] + /// model falls short of a role's requirements. The engine's + /// model-readable notice, one line per gap, is the source; the run's + /// row is closed as failed with that notice. + #[error("the environment cannot satisfy the prompt")] Refused { /// The run's row, closed with this refusal. run_id: RunId, @@ -237,7 +240,7 @@ pub async fn prepare_source( Err(source) => { let outcome = RunOutcome::Failed { kind: "Parse".to_owned(), - message: source.to_string(), + message: display_chain(&source), }; close_failed(&log, run_id, outcome).await?; return Err(PrepareError::Parse { diff --git a/crates/harness/runner/src/spawn.rs b/crates/harness/runner/src/spawn.rs index 310e62ec0..811386164 100644 --- a/crates/harness/runner/src/spawn.rs +++ b/crates/harness/runner/src/spawn.rs @@ -1,12 +1,14 @@ //! The harness's one spawn site. //! //! Every tokio task the harness starts passes through [`spawn_tagged`], -//! [`spawn_blocking_tagged`], or [`spawn_session`]. The first two open a -//! `tracing` span carrying the effect the task performs - its -//! [`EffectId`] and [`Provenance`] - so a run's tasks trace as a group and -//! slice by task; the third is the one task that performs no effect, a -//! session's supervisor, and its span carries the session id instead. Each -//! is a permitted caller of the raw tokio method it wraps, and no other +//! [`spawn_blocking_tagged`], [`spawn_session`], or +//! [`spawn_blocking_launch`]. The first two open a `tracing` span +//! carrying the effect the task performs - its [`EffectId`] and +//! [`Provenance`] - so a run's tasks trace as a group and slice by task. +//! The last two cover the work that performs no effect: a session's +//! supervisor, whose span carries the session id, and a launch's +//! filesystem probes, whose span carries the agent name. Each is a +//! permitted caller of the raw tokio method it wraps, and no other //! harness code is. use promptforge_api_runtime::EffectId; @@ -18,7 +20,7 @@ use tracing::Instrument; /// provenance the engine stamped on that effect. pub type Tag = (EffectId, Provenance); -/// Spawn `fut` on the tokio runtime inside a span tagged `tag`. +/// Spawns `fut` on the tokio runtime inside a span tagged `tag`. /// /// The span is named `spawn` and carries the effect id under `effect`, /// the task path under `task`, and the task-local sequence under `seq`. @@ -28,7 +30,10 @@ pub type Tag = (EffectId, Provenance); /// # Panics /// /// Panics when called outside a tokio runtime, as `tokio::spawn` does. -#[allow(clippy::disallowed_methods)] +#[expect( + clippy::disallowed_methods, + reason = "this wrapper is the harness's permitted caller of tokio::spawn" +)] pub fn spawn_tagged(tag: Tag, fut: F) -> JoinHandle where F: Future + Send + 'static, @@ -44,7 +49,7 @@ where tokio::spawn(fut.instrument(span)) } -/// Spawn a session's supervisor `fut` inside a span named `session` that +/// Spawns a session's supervisor `fut` inside a span named `session` that /// carries the session id under `session`. /// /// A supervisor performs no effect, so it has no [`Tag`]; it is the one @@ -55,7 +60,10 @@ where /// # Panics /// /// Panics when called outside a tokio runtime, as `tokio::spawn` does. -#[allow(clippy::disallowed_methods)] +#[expect( + clippy::disallowed_methods, + reason = "this wrapper is the harness's permitted caller of tokio::spawn" +)] pub fn spawn_session(session: &str, fut: F) -> JoinHandle where F: Future + Send + 'static, @@ -65,7 +73,7 @@ where tokio::spawn(fut.instrument(span)) } -/// Run `f` on tokio's blocking pool inside a span tagged `tag`. +/// Runs `f` on tokio's blocking pool inside a span tagged `tag`. /// /// The span is named `spawn_blocking` and carries the same fields as /// [`spawn_tagged`]'s; it is entered for the whole of `f`. The closure @@ -76,7 +84,10 @@ where /// /// Panics when called outside a tokio runtime, as /// `tokio::task::spawn_blocking` does. -#[allow(clippy::disallowed_methods)] +#[expect( + clippy::disallowed_methods, + reason = "this wrapper is the harness's permitted caller of tokio::task::spawn_blocking" +)] pub fn spawn_blocking_tagged(tag: Tag, f: F) -> JoinHandle where F: FnOnce() -> R + Send + 'static, @@ -94,3 +105,32 @@ where f() }) } + +/// Runs `f`, a launch's filesystem work, on tokio's blocking pool inside +/// a span named `launch` that carries the agent name under `agent`. +/// +/// A launch walks the agents directory and reads the agent's source +/// before any run or session exists, so the work has no [`Tag`] and no +/// session id; the agent name is what ties it to the launch that asked. +/// The closure runs to completion even if its [`JoinHandle`] is aborted +/// or dropped, exactly as with `tokio::task::spawn_blocking`. +/// +/// # Panics +/// +/// Panics when called outside a tokio runtime, as +/// `tokio::task::spawn_blocking` does. +#[expect( + clippy::disallowed_methods, + reason = "this wrapper is the harness's permitted caller of tokio::task::spawn_blocking" +)] +pub fn spawn_blocking_launch(agent: &str, f: F) -> JoinHandle +where + F: FnOnce() -> R + Send + 'static, + R: Send + 'static, +{ + let span = tracing::info_span!("launch", agent = %agent); + tokio::task::spawn_blocking(move || { + let _entered = span.enter(); + f() + }) +} diff --git a/crates/harness/runner/tests/it/effect_loop.rs b/crates/harness/runner/tests/it/effect_loop.rs index 40df18de7..1a972cc39 100644 --- a/crates/harness/runner/tests/it/effect_loop.rs +++ b/crates/harness/runner/tests/it/effect_loop.rs @@ -197,6 +197,8 @@ fn answers(records: &[StoredRecord]) -> Vec<&StoredRecord> { /// Waits until `flag` is raised, or fails after a bounded wait: an /// aborted task is torn down by the runtime after the abort, not at it. +/// Under paused time each sleep is a yield that lets the teardown run +/// and then advances the clock, so the wait costs no wall time. async fn await_raised(flag: &AtomicBool, what: &str) { for _ in 0..200 { if flag.load(Ordering::SeqCst) { @@ -302,7 +304,7 @@ async fn a_panicking_performer_drops_its_effect_instead_of_stranding_the_run() { assert_eq!(row.outcome, Some(RunOutcome::Cancelled)); } -#[tokio::test] +#[tokio::test(start_paused = true)] async fn a_refused_log_write_returns_the_log_error_and_aborts_the_parked_performers() { let (log, run_id) = begun_log().await; let timer_dropped = Arc::new(AtomicBool::new(false)); diff --git a/crates/harness/runner/tests/it/prepare.rs b/crates/harness/runner/tests/it/prepare.rs index 1f9fd96aa..931b4b9a5 100644 --- a/crates/harness/runner/tests/it/prepare.rs +++ b/crates/harness/runner/tests/it/prepare.rs @@ -13,6 +13,7 @@ use harness_capabilities::{ ToolTable, }; use harness_log::{RunLog, RunOutcome}; +use harness_runner::display_chain; use harness_runner::effect_loop::{SharedLog, drive_run}; use harness_runner::performers::{ActivatedTools, ToolPerformer}; use harness_runner::prepare::{PrepareError, Prepared, Services, prepare_run}; @@ -211,9 +212,9 @@ async fn a_prompt_that_does_not_parse_fails_preparation_and_its_row_closes_as_a_ row.outcome, Some(RunOutcome::Failed { kind: "Parse".to_owned(), - message: source.to_string(), + message: display_chain(&source), }), - "the row records the parse failure under the Parse kind" + "the row records the parse failure and its cause chain under the Parse kind" ); } diff --git a/crates/harness/runner/tests/it/spawn.rs b/crates/harness/runner/tests/it/spawn.rs index abfe84d86..62ca8dc56 100644 --- a/crates/harness/runner/tests/it/spawn.rs +++ b/crates/harness/runner/tests/it/spawn.rs @@ -1,7 +1,7 @@ //! The tagged spawn wrappers run their work to completion under an //! effect's tag. -use harness_runner::spawn::{spawn_blocking_tagged, spawn_tagged}; +use harness_runner::spawn::{spawn_blocking_launch, spawn_blocking_tagged, spawn_tagged}; use promptforge_api_runtime::{EffectId, Step}; use promptforge_api_types::ids::Provenance; @@ -31,3 +31,10 @@ async fn spawn_blocking_tagged_runs_a_closure_to_completion() { let value = handle.await.expect("the blocking closure completes"); assert_eq!(value, "donedone"); } + +#[tokio::test] +async fn spawn_blocking_launch_runs_a_closure_to_completion() { + let handle = spawn_blocking_launch("chat", || "walked".len()); + let value = handle.await.expect("the launch closure completes"); + assert_eq!(value, 6); +} diff --git a/crates/harness/sessions/AGENTS.md b/crates/harness/sessions/AGENTS.md index bc56c924a..63ceca2f5 100644 --- a/crates/harness/sessions/AGENTS.md +++ b/crates/harness/sessions/AGENTS.md @@ -1,12 +1,7 @@ # harness-sessions -This crate owns the harness's session layer: the `Harness` handle and the bindings a client pushes across the door (gateway, chat catalog, host snapshot), agent discovery, the session runtime (launch through `prepare_source` and `drive_run`, input, cancel, close, event and delta subscriptions, transcript reads from the run log), the run lifecycle and supervisor reducer, and the user-input wait registry with the input performer over it. +This crate owns the harness's session layer: the `Harness` handle and the bindings a client pushes across the door (gateway, chat catalog, host snapshot), agent discovery, the session runtime (launch through `prepare_source` and `drive_run`, input, cancel, close, event and delta subscriptions, transcript reads from the run log), the run lifecycle and supervisor reducer, and the user-input wait registry with the input performer over it. The dependency rules and the core invariants are in the `## Invariants` block of `src/lib.rs`; this file holds only what that block does not say. -- Every binding a run reads arrives as data through `harness-api`; this crate never resolves a gateway or reads a client's state. It is the one place a capability provider crate (`harness-web`) is named, at registration; the registry and model client are rebuilt when the gateway generation changes. -- A session's transcript is the run log. The live event broadcast and `Session::transcript` agree index for index, and the reply-id stamp is one rule (`session::reply_stamp`) applied to both. - -- The input broker backs only the script-side `user_input()` function. No `user_input` tool is ever advertised to a model unless a prompt explicitly adds it. -- A dying input wait is an outcome, never silence: every path out of an unresolved wait removes the registry entry and pushes a durable `WaitFrame::Cancelled`. Unresolved waits are retained across socket loss and re-announced on reconnect. +- The capability registry and the model client are rebuilt when the gateway generation changes; a binding update never patches a live registry in place. +- Unresolved input waits are retained across socket loss and re-announced on reconnect; the wait's lifetime is the run's, not the socket's. - Wait frames are harness data, not wire shapes. The client that owns a socket renders them into its own protocol; this crate never names a `workshop-*` frame type. -- The supervisor's state transitions are a pure reducer whose matches stay wildcard-free, so a new variant is a compile error. -- Family rules: depends on `promptforge-api-runtime`, `promptforge-api-types`, and container siblings only. Never on a `workshop-*` crate, a private `gateway-*` crate, or a `promptforge-*` crate behind the door. Tests spawn through `harness-runner`'s instrumented wrapper, never `tokio::spawn`. diff --git a/crates/harness/sessions/src/discovery-tests.rs b/crates/harness/sessions/src/discovery-tests.rs index c84f64de6..ec4d32c79 100644 --- a/crates/harness/sessions/src/discovery-tests.rs +++ b/crates/harness/sessions/src/discovery-tests.rs @@ -1,3 +1,5 @@ +//! Tests for agent discovery in the agents directory and the built-in chat fallback. + use super::*; #[test] diff --git a/crates/harness/sessions/src/environment-tests.rs b/crates/harness/sessions/src/environment-tests.rs index 7cbe2a7c5..8a0c71040 100644 --- a/crates/harness/sessions/src/environment-tests.rs +++ b/crates/harness/sessions/src/environment-tests.rs @@ -1,3 +1,5 @@ +//! Tests for gateway binding changes rebuilding the environment's registry and client. + use super::*; fn binding(generation: u64) -> GatewayBinding { diff --git a/crates/harness/sessions/src/environment.rs b/crates/harness/sessions/src/environment.rs index aed2178e8..340b8ab9f 100644 --- a/crates/harness/sessions/src/environment.rs +++ b/crates/harness/sessions/src/environment.rs @@ -342,9 +342,11 @@ impl Bindings { /// becomes the launch error, reported to the operator instead of binding /// a fabricated fallback descriptor. #[derive(Debug, thiserror::Error)] +#[non_exhaustive] pub enum CurrentModelError { - /// The gateway's model catalog could not be fetched. - #[error("the model catalog fetch failed: {0}")] + /// The gateway's model catalog could not be fetched; the fetch + /// failure is the source. + #[error("the model catalog could not be fetched")] CatalogFetchFailed(#[source] CompletionError), /// The selected id is absent from the fetched catalog. #[error("the selected model `{0}` is absent from the fetched catalog")] diff --git a/crates/harness/sessions/src/input-tests.rs b/crates/harness/sessions/src/input-tests.rs index 3c3167c9f..d686f468f 100644 --- a/crates/harness/sessions/src/input-tests.rs +++ b/crates/harness/sessions/src/input-tests.rs @@ -1,3 +1,5 @@ +//! Tests for the input wait registry, its tokens, and the operator input broker. + use super::*; use std::sync::Arc; diff --git a/crates/harness/sessions/src/lib.rs b/crates/harness/sessions/src/lib.rs index 474cc583a..ad363f06d 100644 --- a/crates/harness/sessions/src/lib.rs +++ b/crates/harness/sessions/src/lib.rs @@ -6,8 +6,9 @@ //! ## Invariants //! //! - Family: harness, private to `crates/harness/`; may depend on: -//! `promptforge-api-runtime`, `promptforge-api-types`, `gateway-api`, -//! `gateway-api-discovery`, `shared-*`, and its container siblings. +//! `promptforge-api-runtime`, `promptforge-api-types`, +//! `gateway-api-types`, `gateway-api-discovery`, `shared-*`, and its +//! container siblings. //! Never on a `workshop-*` crate, a private `gateway-*` crate, or a //! `promptforge-*` crate behind the door. Read `AGENTS.md` before adding //! an import. diff --git a/crates/harness/sessions/src/protocol.rs b/crates/harness/sessions/src/protocol.rs index b33dd4369..384e23640 100644 --- a/crates/harness/sessions/src/protocol.rs +++ b/crates/harness/sessions/src/protocol.rs @@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize}; pub struct SessionId(String); impl SessionId { - /// Wrap an already-minted id. + /// Wraps an already-minted id. #[must_use] pub fn new(id: impl Into) -> Self { Self(id.into()) @@ -79,6 +79,7 @@ pub struct SessionEvent { /// Which streaming side channel one delta belongs to. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] +#[non_exhaustive] pub enum DeltaKind { /// Answer content, superseded by the round's reply event. Text, @@ -94,6 +95,7 @@ pub enum DeltaKind { /// ephemeral: they may drop under lag, and the completed-reply event is /// the repair path. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[non_exhaustive] pub struct Delta { /// Which side channel the chunk belongs to. pub kind: DeltaKind, diff --git a/crates/harness/sessions/src/runtime.rs b/crates/harness/sessions/src/runtime.rs index 8d23c088b..224b04972 100644 --- a/crates/harness/sessions/src/runtime.rs +++ b/crates/harness/sessions/src/runtime.rs @@ -17,7 +17,7 @@ use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; use harness_log::{LogError, RunLog}; use harness_runner::effect_loop::SharedLog; -use harness_runner::spawn::spawn_session; +use harness_runner::spawn::{spawn_blocking_launch, spawn_session}; use tokio::sync::{OnceCell, mpsc}; use crate::discovery::{agent_source, discover_agents}; @@ -209,8 +209,16 @@ impl Harness { let LaunchRequest { agent, args } = request; // Resolving through the discovered list is the trust boundary: a // client-sent name never reaches the filesystem unless it is the - // bare stem of a real `.md` file in the configured directory. - if !self.discover().contains(&agent) { + // bare stem of a real `.md` file in the configured directory. The + // directory walk is filesystem work and runs on the blocking pool, + // through the harness's one spawn site. + let agents_path = self.config.agents_path.clone(); + let known = spawn_blocking_launch(&agent, move || discover_agents(&agents_path)) + .await + .map_err(|join| LaunchError::SessionState { + source: io::Error::other(join), + })?; + if !known.contains(&agent) { return Err(LaunchError::UnknownAgent { name: agent }); } // Subscribe before reading the snapshot: `watch::Sender::subscribe` @@ -227,7 +235,13 @@ impl Harness { .gateway() .filter(|resources| resources.client().is_some()) .ok_or(LaunchError::GatewayUnusable)?; - let source = agent_source(&self.config.agents_path, &agent) + // The source read is filesystem work too; a worker that cannot + // report is the same unavailable state as an unreadable file. + let agents_path = self.config.agents_path.clone(); + let name = agent.clone(); + let source = spawn_blocking_launch(&agent, move || agent_source(&agents_path, &name)) + .await + .unwrap_or_else(|join| Err(io::Error::other(join))) .map_err(|source| LaunchError::SessionState { source })?; let log = self.log().await?; diff --git a/crates/harness/sessions/src/session/run-tests.rs b/crates/harness/sessions/src/session/run-tests.rs new file mode 100644 index 000000000..828d3fc81 --- /dev/null +++ b/crates/harness/sessions/src/session/run-tests.rs @@ -0,0 +1,31 @@ +//! Tests that run failures pushed to the client carry their cause chain. + +use std::io; +use std::path::PathBuf; + +use harness_runner::display_chain; +use harness_runner::prepare::PrepareError; + +use super::RunFailure; + +#[test] +fn a_prepare_failure_pushed_to_the_client_carries_its_cause_chain() { + let failure = RunFailure::Prepare(PrepareError::Read { + path: PathBuf::from("agent.md"), + source: io::Error::other("disk gone"), + }); + let rendered = display_chain(&failure); + assert!( + rendered.contains("could not be read"), + "the outer frame names what happened: {rendered}" + ); + assert!( + rendered.contains("disk gone"), + "the innermost cause reaches the client: {rendered}" + ); + assert_eq!( + rendered.matches("could not be read").count(), + 1, + "the transparent wrapper does not double the preparation text: {rendered}" + ); +} diff --git a/crates/harness/sessions/src/session/run.rs b/crates/harness/sessions/src/session/run.rs index b222596af..1497bf0eb 100644 --- a/crates/harness/sessions/src/session/run.rs +++ b/crates/harness/sessions/src/session/run.rs @@ -32,16 +32,19 @@ use super::SessionCore; /// Why one run produced no outcome of the engine's. #[derive(Debug, thiserror::Error)] pub(crate) enum RunFailure { - /// The client's selected model could not be resolved. - #[error("the chat cannot launch: {0}")] + /// The client's selected model could not be resolved; the resolution + /// failure is the source. + #[error("the chat cannot launch")] Model(#[source] CurrentModelError), - /// The run could not be prepared: the prompt failed to parse, the - /// environment cannot satisfy it, or the log refused it. - #[error("{0}")] - Prepare(#[source] PrepareError), - /// The effect loop stopped without an outcome. - #[error("{0}")] - Drive(#[source] DriveError), + /// The run could not be prepared: the prompt does not parse, the + /// environment cannot satisfy it, or the log refused it. Renders and + /// sources as the preparation error does. + #[error(transparent)] + Prepare(PrepareError), + /// The effect loop stopped without an outcome. Renders and sources as + /// the drive error does. + #[error(transparent)] + Drive(DriveError), } /// What one run needs beyond the session: the frozen bindings the reducer @@ -132,7 +135,9 @@ pub(crate) async fn run_once( fn opened_run(error: &PrepareError) -> Option { match error { PrepareError::Parse { run_id, .. } | PrepareError::Refused { run_id, .. } => Some(*run_id), - PrepareError::Read { .. } | PrepareError::Log(_) => None, + // `Read`, `Log`, or a variant `harness-runner` adds behind its + // `#[non_exhaustive]` `PrepareError`: none of them opened a row. + _ => None, } } @@ -155,3 +160,7 @@ async fn replay_recorded(core: &SessionCore, run_id: LogRunId) { } } } + +#[cfg(test)] +#[path = "run-tests.rs"] +mod tests; diff --git a/crates/harness/sessions/src/session/supervisor.rs b/crates/harness/sessions/src/session/supervisor.rs index f78a3d078..0caadfe30 100644 --- a/crates/harness/sessions/src/session/supervisor.rs +++ b/crates/harness/sessions/src/session/supervisor.rs @@ -259,7 +259,7 @@ impl Supervisor { RunCompletion::Failed } Err(failure) => { - self.report_failure(&failure.to_string()); + self.report_failure(&harness_runner::display_chain(&failure)); RunCompletion::Failed } }; diff --git a/crates/harness/sessions/src/transition-tests.rs b/crates/harness/sessions/src/transition-tests.rs index f31f3b798..230581b4d 100644 --- a/crates/harness/sessions/src/transition-tests.rs +++ b/crates/harness/sessions/src/transition-tests.rs @@ -1,3 +1,5 @@ +//! Tests for the supervisor transition table across catalog, cancel, and close events. + use super::*; const RUN_1: RunId = RunId(1); diff --git a/crates/harness/sessions/src/transition.rs b/crates/harness/sessions/src/transition.rs index 4b85efcd8..23fb8c469 100644 --- a/crates/harness/sessions/src/transition.rs +++ b/crates/harness/sessions/src/transition.rs @@ -92,7 +92,7 @@ pub enum PreserveReason { /// Event-log handling for a launched replacement run. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum HistoryEffect { - /// Reuse the session's retained event log. + /// Reuses the session's retained event log. Preserve, } @@ -123,15 +123,15 @@ pub enum CloseReason { /// One typed action selected by the transition model. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum SupervisorEffect { - /// Await a named condition. + /// Awaits a named condition. Wait(WaitFor), - /// Cancel the current run with provenance. + /// Cancels the current run with provenance. Cancel(CancelOrigin), - /// Keep the named ownership unchanged. + /// Keeps the named ownership unchanged. Preserve(PreserveReason), - /// Launch a replacement over retained history. + /// Launches a replacement over retained history. Relaunch(RelaunchEffect), - /// End supervision. + /// Ends supervision. Close(CloseReason), } diff --git a/crates/harness/web-search/AGENTS.md b/crates/harness/web-search/AGENTS.md index ff7867101..89472766d 100644 --- a/crates/harness/web-search/AGENTS.md +++ b/crates/harness/web-search/AGENTS.md @@ -2,7 +2,7 @@ This crate owns the concrete `web_search` tool provider through the Gateway endpoint. -- The `Tool` trait comes from `harness-capabilities`; tool id, schema, output, and error vocabulary from `promptforge-api-types`. This provider never depends on Core or a Gateway product crate. +- The `Tool` trait comes from `harness-capabilities`; tool id, schema, output, and error vocabulary from `promptforge-api-types`. This provider never depends on `promptforge-api-runtime` or a Gateway product crate. - The bearer credential, endpoint validation, request deadline, argument bounds, and response decoding stay in this provider. - Errors preserve their sources: wrap the underlying cause with `ToolError::with_source` instead of flattening it into the message. - Every request is bounded: a fixed deadline on the HTTP client and each outbound call, capped argument sizes, and response bodies that reject a cap overflow rather than truncating. diff --git a/crates/harness/web-search/src/lib.rs b/crates/harness/web-search/src/lib.rs index a5cf92c8b..a892369bd 100644 --- a/crates/harness/web-search/src/lib.rs +++ b/crates/harness/web-search/src/lib.rs @@ -12,6 +12,23 @@ //! tool vocabulary ([`Tool`](harness_capabilities::Tool), //! [`ToolError`](promptforge_api_types::tools::ToolError), and their kinds) //! comes from `promptforge-api-types`. +//! +//! ## Invariants +//! +//! - Family: harness, private to `crates/harness/`; may depend on: +//! `promptforge-api-runtime`, `promptforge-api-types`, +//! `gateway-api-types`, `gateway-api-discovery`, `shared-*`, and its +//! container siblings. +//! Never on a `workshop-*` crate, a private `gateway-*` crate, or a +//! `promptforge-*` crate behind the door. Read `AGENTS.md` before adding +//! an import. +//! - Every file in this crate stays under 500 lines; split first, then +//! edit. +//! - The gateway bearer token is never written to logs or `Debug` output; +//! only the request builder reads it, to set the `Authorization` header. +//! - Nothing in this crate spawns a tokio task directly; the harness +//! spawns only through the instrumented wrapper in `harness-runner` +//! (enforced by this crate's `clippy.toml`). mod endpoint; mod secret; diff --git a/crates/harness/web-search/src/web_search-request.rs b/crates/harness/web-search/src/web_search-request.rs new file mode 100644 index 000000000..50fc26a93 --- /dev/null +++ b/crates/harness/web-search/src/web_search-request.rs @@ -0,0 +1,140 @@ +//! The validated search request: the closed freshness and SafeSearch +//! enums, the `deny_unknown_fields` argument shape, and the bounds the type +//! alone cannot express. Only a value that passed [`SearchRequest::from_args`] +//! is serialized onto the wire to the gateway. + +use promptforge_api_types::tools::{ToolError, ToolErrorKind}; + +use super::{MAX_COUNT, MAX_DOMAINS, MAX_QUERY_LEN, MAX_STRING_LEN}; + +/// The freshness filter, deserialized as a closed enum so an unknown token is +/// rejected as an invalid argument rather than forwarded. +#[derive(Debug, Clone, Copy, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "lowercase")] +enum Freshness { + /// Past day. + Pd, + /// Past week. + Pw, + /// Past month. + Pm, + /// Past year. + Py, +} + +/// The SafeSearch level, deserialized as a closed enum. +#[derive(Debug, Clone, Copy, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "lowercase")] +enum SafeSearch { + /// No filtering. + Off, + /// Moderate filtering. + Moderate, + /// Strict filtering. + Strict, +} + +/// The validated search request forwarded to the gateway. +/// +/// `deny_unknown_fields` means an argument the tool does not model is rejected +/// (rather than silently forwarded), and the typed optional fields reject a +/// wrong JSON type at deserialization. [`SearchRequest::validate`] then enforces +/// the string, count, and domain bounds. Only this validated value is +/// serialized onto the wire. +#[derive(Debug, serde::Deserialize, serde::Serialize)] +#[serde(deny_unknown_fields)] +pub(super) struct SearchRequest { + /// The search query. + query: String, + /// Maximum number of results. + #[serde(default, skip_serializing_if = "Option::is_none")] + count: Option, + /// Freshness filter. + #[serde(default, skip_serializing_if = "Option::is_none")] + freshness: Option, + /// Country code for the search. + #[serde(default, skip_serializing_if = "Option::is_none")] + country: Option, + /// Search language code. + #[serde(default, skip_serializing_if = "Option::is_none")] + search_lang: Option, + /// SafeSearch level. + #[serde(default, skip_serializing_if = "Option::is_none")] + safesearch: Option, + /// Only keep results from these hostnames. + #[serde(default, skip_serializing_if = "Option::is_none")] + include_domains: Option>, + /// Drops results from these hostnames. + #[serde(default, skip_serializing_if = "Option::is_none")] + exclude_domains: Option>, +} + +impl SearchRequest { + /// Deserializes and validates the raw call arguments. + pub(super) fn from_args(args: serde_json::Value) -> Result { + let request: SearchRequest = serde_json::from_value(args).map_err(|error| { + ToolError::with_source("web_search: invalid arguments", error) + .with_kind(ToolErrorKind::InvalidArguments) + })?; + request.validate()?; + Ok(request) + } + + /// Enforces the bounds the type alone cannot express. + fn validate(&self) -> Result<(), ToolError> { + let invalid = |message: String| { + ToolError::message(message).with_kind(ToolErrorKind::InvalidArguments) + }; + if self.query.trim().is_empty() { + return Err(invalid("web_search: query must not be empty".to_owned())); + } + if self.query.chars().count() > MAX_QUERY_LEN { + return Err(invalid(format!( + "web_search: query exceeds {MAX_QUERY_LEN} characters" + ))); + } + if let Some(count) = self.count + && !(1..=MAX_COUNT).contains(&count) + { + return Err(invalid(format!( + "web_search: count must be between 1 and {MAX_COUNT}" + ))); + } + for (field, value) in [ + ("country", &self.country), + ("search_lang", &self.search_lang), + ] { + if let Some(value) = value + && (value.trim().is_empty() || value.chars().count() > MAX_STRING_LEN) + { + return Err(invalid(format!( + "web_search: {field} must be 1..={MAX_STRING_LEN} characters" + ))); + } + } + for (field, domains) in [ + ("include_domains", &self.include_domains), + ("exclude_domains", &self.exclude_domains), + ] { + if let Some(domains) = domains { + if domains.len() > MAX_DOMAINS { + return Err(invalid(format!( + "web_search: {field} may list at most {MAX_DOMAINS} hostnames" + ))); + } + for domain in domains { + let bad = domain.trim().is_empty() + || domain.chars().count() > MAX_STRING_LEN + || domain.contains('/') + || domain.chars().any(|c| c.is_whitespace() || c.is_control()); + if bad { + return Err(invalid(format!( + "web_search: {field} contains an invalid hostname" + ))); + } + } + } + } + Ok(()) + } +} diff --git a/crates/harness/web-search/src/web_search-tests-responses.rs b/crates/harness/web-search/src/web_search-tests-responses.rs new file mode 100644 index 000000000..e62836b8e --- /dev/null +++ b/crates/harness/web-search/src/web_search-tests-responses.rs @@ -0,0 +1,166 @@ +//! Gateway response handling: a refused connection or a stalled gateway is +//! a transport error with its source, a wrong-shaped or empty-url success +//! body is a backend error, an oversized success body is rejected rather +//! than truncated, and an error body is bounded, sanitized, and keeps a +//! mid-read failure as the error source. + +use super::*; + +use std::time::Duration; + +use crate::web_search::{MAX_ERROR_BODY, MAX_RESPONSE_BODY}; + +#[tokio::test] +async fn transport_failure_is_transport_kind() { + // Bind then drop the listener so the port is closed and the connection + // is refused deterministically. + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + drop(listener); + let tool = + WebSearch::new(&format!("http://{addr}"), "tok").expect("valid web search configuration"); + + let err = tool + .call(serde_json::json!({ "query": "hi" })) + .await + .expect_err("a refused connection must surface as an error"); + assert_eq!(err.kind(), ToolErrorKind::Transport); + assert!(std::error::Error::source(&err).is_some()); +} + +#[tokio::test] +async fn stalling_gateway_times_out_as_transport() { + async fn web_search() -> Json { + std::future::pending().await + } + let mock = MockServer::spawn(Router::new().route("/tools/web_search", post(web_search))).await; + let tool = WebSearch::with_timeout(&mock.url(), "tok", Duration::from_millis(200)) + .expect("valid web search configuration"); + + let err = tool + .call(serde_json::json!({ "query": "hi" })) + .await + .expect_err("a stalled gateway must surface as an error"); + assert_eq!(err.kind(), ToolErrorKind::Transport); + assert!( + std::error::Error::source(&err).is_some(), + "the timeout must be preserved as the error's transport source" + ); +} + +#[tokio::test] +async fn malformed_success_json_is_backend_error_with_source() { + async fn web_search() -> Json { + // Missing the required `results` array: valid JSON, wrong shape. + Json(serde_json::json!({ "unexpected": true })) + } + let mock = MockServer::spawn(Router::new().route("/tools/web_search", post(web_search))).await; + let tool = WebSearch::new(&mock.url(), "tok").expect("valid web search configuration"); + + let err = tool + .call(serde_json::json!({ "query": "hi" })) + .await + .expect_err("a wrong-shaped success body must be rejected"); + assert_eq!(err.kind(), ToolErrorKind::Backend); + assert!( + std::error::Error::source(&err).is_some(), + "a malformed response must preserve its parse source" + ); +} + +#[tokio::test] +async fn success_body_with_empty_url_is_rejected() { + async fn web_search() -> Json { + Json(serde_json::json!({ "results": [{ "url": "" }] })) + } + let mock = MockServer::spawn(Router::new().route("/tools/web_search", post(web_search))).await; + let tool = WebSearch::new(&mock.url(), "tok").expect("valid web search configuration"); + + let err = tool + .call(serde_json::json!({ "query": "hi" })) + .await + .expect_err("an empty result url must be rejected"); + assert_eq!(err.kind(), ToolErrorKind::Backend); +} + +#[tokio::test] +async fn oversized_success_body_is_rejected() { + async fn web_search() -> String { + "x".repeat(MAX_RESPONSE_BODY + 4096) + } + let mock = MockServer::spawn(Router::new().route("/tools/web_search", post(web_search))).await; + let tool = WebSearch::new(&mock.url(), "tok").expect("valid web search configuration"); + + let err = tool + .call(serde_json::json!({ "query": "hi" })) + .await + .expect_err("an oversized success body must be rejected, not truncated"); + assert_eq!(err.kind(), ToolErrorKind::Backend); + assert!( + err.to_string().contains("exceeded"), + "the error must name the cap overflow: {err}" + ); +} + +#[tokio::test] +async fn oversized_error_body_is_bounded_and_sanitized() { + async fn web_search() -> (axum::http::StatusCode, String) { + // Oversized and control-laden so both bounding and sanitization run. + let mut body = "line-one\nline-two\ttab".to_owned(); + body.push_str(&"e".repeat(MAX_ERROR_BODY * 4)); + (axum::http::StatusCode::INTERNAL_SERVER_ERROR, body) + } + let mock = MockServer::spawn(Router::new().route("/tools/web_search", post(web_search))).await; + let tool = WebSearch::new(&mock.url(), "tok").expect("valid web search configuration"); + + let err = tool + .call(serde_json::json!({ "query": "hi" })) + .await + .expect_err("a 500 response must surface as an error"); + let message = err.to_string(); + assert!( + message.contains("backend returned 500"), + "error must name the status: {message}" + ); + assert!( + !message.contains('\n') && !message.contains('\t'), + "control characters must be escaped, got: {message}" + ); + assert!( + message.len() < MAX_ERROR_BODY + 128, + "the error-path body must be bounded, got {} bytes", + message.len() + ); +} + +/// A raw TCP mock that promises a large body via `Content-Length`, sends a +/// few bytes, then drops the connection so the error-body read fails partway. +#[tokio::test] +async fn error_body_read_failure_is_preserved_as_source() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let handle = spawn_tagged(mock_tag(), async move { + use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; + if let Ok((mut socket, _)) = listener.accept().await { + let mut buf = [0u8; 1024]; + let _ = socket.read(&mut buf).await; + let header = "HTTP/1.1 500 Internal Server Error\r\nContent-Length: 100000\r\n\r\n"; + let _ = socket.write_all(header.as_bytes()).await; + let _ = socket.write_all(b"partial").await; + let _ = socket.flush().await; + } + }); + let tool = + WebSearch::new(&format!("http://{addr}"), "tok").expect("valid web search configuration"); + + let err = tool + .call(serde_json::json!({ "query": "hi" })) + .await + .expect_err("a truncated 500 body must surface as an error"); + assert_eq!(err.kind(), ToolErrorKind::Backend); + assert!( + std::error::Error::source(&err).is_some(), + "the body-read failure must be preserved as the error's source, got: {err}" + ); + handle.abort(); +} diff --git a/crates/harness/web-search/src/web_search-tests.rs b/crates/harness/web-search/src/web_search-tests.rs index d2576c87c..80b96b64d 100644 --- a/crates/harness/web-search/src/web_search-tests.rs +++ b/crates/harness/web-search/src/web_search-tests.rs @@ -1,14 +1,12 @@ -use super::{ - MAX_COUNT, MAX_DOMAINS, MAX_ERROR_BODY, MAX_QUERY_LEN, MAX_RESPONSE_BODY, MAX_STRING_LEN, - WebSearch, -}; +//! Tests for the `WebSearch` tool: descriptor, argument validation, and transport. + +use super::{MAX_COUNT, MAX_DOMAINS, MAX_QUERY_LEN, MAX_STRING_LEN, WebSearch}; use harness_capabilities::Tool; use harness_runner::spawn::spawn_tagged; use harness_runner::test_support::mock_tag; use promptforge_api_types::tools::{OutputTrust, ToolErrorKind, ToolId}; use std::net::SocketAddr; -use std::time::Duration; use axum::Json; use axum::Router; @@ -371,158 +369,5 @@ fn constructor_errors_preserve_sources_without_leaking_secrets() { ); } -#[tokio::test] -async fn transport_failure_is_transport_kind() { - // Bind then drop the listener so the port is closed and the connection - // is refused deterministically. - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - drop(listener); - let tool = - WebSearch::new(&format!("http://{addr}"), "tok").expect("valid web search configuration"); - - let err = tool - .call(serde_json::json!({ "query": "hi" })) - .await - .expect_err("a refused connection must surface as an error"); - assert_eq!(err.kind(), ToolErrorKind::Transport); - assert!(std::error::Error::source(&err).is_some()); -} - -#[tokio::test] -async fn stalling_gateway_times_out_as_transport() { - async fn web_search() -> Json { - tokio::time::sleep(Duration::from_secs(30)).await; - Json(serde_json::json!({ "results": [] })) - } - let mock = MockServer::spawn(Router::new().route("/tools/web_search", post(web_search))).await; - let tool = WebSearch::with_timeout(&mock.url(), "tok", Duration::from_millis(200)) - .expect("valid web search configuration"); - - let err = tool - .call(serde_json::json!({ "query": "hi" })) - .await - .expect_err("a stalled gateway must surface as an error"); - assert_eq!(err.kind(), ToolErrorKind::Transport); - assert!( - std::error::Error::source(&err).is_some(), - "the timeout must be preserved as the error's transport source" - ); -} - -#[tokio::test] -async fn malformed_success_json_is_backend_error_with_source() { - async fn web_search() -> Json { - // Missing the required `results` array: valid JSON, wrong shape. - Json(serde_json::json!({ "unexpected": true })) - } - let mock = MockServer::spawn(Router::new().route("/tools/web_search", post(web_search))).await; - let tool = WebSearch::new(&mock.url(), "tok").expect("valid web search configuration"); - - let err = tool - .call(serde_json::json!({ "query": "hi" })) - .await - .expect_err("a wrong-shaped success body must be rejected"); - assert_eq!(err.kind(), ToolErrorKind::Backend); - assert!( - std::error::Error::source(&err).is_some(), - "a malformed response must preserve its parse source" - ); -} - -#[tokio::test] -async fn success_body_with_empty_url_is_rejected() { - async fn web_search() -> Json { - Json(serde_json::json!({ "results": [{ "url": "" }] })) - } - let mock = MockServer::spawn(Router::new().route("/tools/web_search", post(web_search))).await; - let tool = WebSearch::new(&mock.url(), "tok").expect("valid web search configuration"); - - let err = tool - .call(serde_json::json!({ "query": "hi" })) - .await - .expect_err("an empty result url must be rejected"); - assert_eq!(err.kind(), ToolErrorKind::Backend); -} - -#[tokio::test] -async fn oversized_success_body_is_rejected() { - async fn web_search() -> String { - "x".repeat(MAX_RESPONSE_BODY + 4096) - } - let mock = MockServer::spawn(Router::new().route("/tools/web_search", post(web_search))).await; - let tool = WebSearch::new(&mock.url(), "tok").expect("valid web search configuration"); - - let err = tool - .call(serde_json::json!({ "query": "hi" })) - .await - .expect_err("an oversized success body must be rejected, not truncated"); - assert_eq!(err.kind(), ToolErrorKind::Backend); - assert!( - err.to_string().contains("exceeded"), - "the error must name the cap overflow: {err}" - ); -} - -#[tokio::test] -async fn oversized_error_body_is_bounded_and_sanitized() { - async fn web_search() -> (axum::http::StatusCode, String) { - // Oversized and control-laden so both bounding and sanitization run. - let mut body = "line-one\nline-two\ttab".to_owned(); - body.push_str(&"e".repeat(MAX_ERROR_BODY * 4)); - (axum::http::StatusCode::INTERNAL_SERVER_ERROR, body) - } - let mock = MockServer::spawn(Router::new().route("/tools/web_search", post(web_search))).await; - let tool = WebSearch::new(&mock.url(), "tok").expect("valid web search configuration"); - - let err = tool - .call(serde_json::json!({ "query": "hi" })) - .await - .expect_err("a 500 response must surface as an error"); - let message = err.to_string(); - assert!( - message.contains("backend returned 500"), - "error must name the status: {message}" - ); - assert!( - !message.contains('\n') && !message.contains('\t'), - "control characters must be escaped, got: {message}" - ); - assert!( - message.len() < MAX_ERROR_BODY + 128, - "the error-path body must be bounded, got {} bytes", - message.len() - ); -} - -/// A raw TCP mock that promises a large body via `Content-Length`, sends a -/// few bytes, then drops the connection so the error-body read fails partway. -#[tokio::test] -async fn error_body_read_failure_is_preserved_as_source() { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - let handle = spawn_tagged(mock_tag(), async move { - use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; - if let Ok((mut socket, _)) = listener.accept().await { - let mut buf = [0u8; 1024]; - let _ = socket.read(&mut buf).await; - let header = "HTTP/1.1 500 Internal Server Error\r\nContent-Length: 100000\r\n\r\n"; - let _ = socket.write_all(header.as_bytes()).await; - let _ = socket.write_all(b"partial").await; - let _ = socket.flush().await; - } - }); - let tool = - WebSearch::new(&format!("http://{addr}"), "tok").expect("valid web search configuration"); - - let err = tool - .call(serde_json::json!({ "query": "hi" })) - .await - .expect_err("a truncated 500 body must surface as an error"); - assert_eq!(err.kind(), ToolErrorKind::Backend); - assert!( - std::error::Error::source(&err).is_some(), - "the body-read failure must be preserved as the error's source, got: {err}" - ); - handle.abort(); -} +#[path = "web_search-tests-responses.rs"] +mod responses; diff --git a/crates/harness/web-search/src/web_search.rs b/crates/harness/web-search/src/web_search.rs index d795d65fe..a19ee0b36 100644 --- a/crates/harness/web-search/src/web_search.rs +++ b/crates/harness/web-search/src/web_search.rs @@ -15,6 +15,11 @@ use promptforge_api_types::tools::{ToolError, ToolErrorKind, ToolId, ToolOutput} use crate::endpoint::Endpoint; use crate::secret::Token; +#[path = "web_search-request.rs"] +mod request; + +use request::SearchRequest; + /// The largest error body kept for diagnostics, in characters. const MAX_ERROR_BODY: usize = 2000; @@ -81,7 +86,7 @@ impl fmt::Debug for WebSearch { } impl WebSearch { - /// Construct a `WebSearch` bound to a validated gateway API root and a + /// Constructs a `WebSearch` bound to a validated gateway API root and a /// non-empty bearer token. /// /// The root is parsed and normalized at construction and an empty token is @@ -110,7 +115,7 @@ impl WebSearch { Self::with_timeout(base_url, token, REQUEST_TIMEOUT) } - /// Construct a `WebSearch` with an explicit request deadline. + /// Constructs a `WebSearch` with an explicit request deadline. /// /// Shared by [`WebSearch::new`] (default deadline) and tests (short deadline /// against a stalling mock), so the timeout is always injected rather than @@ -143,138 +148,6 @@ impl WebSearch { } } -/// The freshness filter, deserialized as a closed enum so an unknown token is -/// rejected as an invalid argument rather than forwarded. -#[derive(Debug, Clone, Copy, serde::Deserialize, serde::Serialize)] -#[serde(rename_all = "lowercase")] -enum Freshness { - /// Past day. - Pd, - /// Past week. - Pw, - /// Past month. - Pm, - /// Past year. - Py, -} - -/// The SafeSearch level, deserialized as a closed enum. -#[derive(Debug, Clone, Copy, serde::Deserialize, serde::Serialize)] -#[serde(rename_all = "lowercase")] -enum SafeSearch { - /// No filtering. - Off, - /// Moderate filtering. - Moderate, - /// Strict filtering. - Strict, -} - -/// The validated search request forwarded to the gateway. -/// -/// `deny_unknown_fields` means an argument the tool does not model is rejected -/// (rather than silently forwarded), and the typed optional fields reject a -/// wrong JSON type at deserialization. [`SearchRequest::validate`] then enforces -/// the string, count, and domain bounds. Only this validated value is -/// serialized onto the wire. -#[derive(Debug, serde::Deserialize, serde::Serialize)] -#[serde(deny_unknown_fields)] -struct SearchRequest { - /// The search query. - query: String, - /// Maximum number of results. - #[serde(default, skip_serializing_if = "Option::is_none")] - count: Option, - /// Freshness filter. - #[serde(default, skip_serializing_if = "Option::is_none")] - freshness: Option, - /// Country code for the search. - #[serde(default, skip_serializing_if = "Option::is_none")] - country: Option, - /// Search language code. - #[serde(default, skip_serializing_if = "Option::is_none")] - search_lang: Option, - /// SafeSearch level. - #[serde(default, skip_serializing_if = "Option::is_none")] - safesearch: Option, - /// Only keep results from these hostnames. - #[serde(default, skip_serializing_if = "Option::is_none")] - include_domains: Option>, - /// Drop results from these hostnames. - #[serde(default, skip_serializing_if = "Option::is_none")] - exclude_domains: Option>, -} - -impl SearchRequest { - /// Deserializes and validates the raw call arguments. - fn from_args(args: serde_json::Value) -> Result { - let request: SearchRequest = serde_json::from_value(args).map_err(|error| { - ToolError::with_source("web_search: invalid arguments", error) - .with_kind(ToolErrorKind::InvalidArguments) - })?; - request.validate()?; - Ok(request) - } - - /// Enforces the bounds the type alone cannot express. - fn validate(&self) -> Result<(), ToolError> { - let invalid = |message: String| { - ToolError::message(message).with_kind(ToolErrorKind::InvalidArguments) - }; - if self.query.trim().is_empty() { - return Err(invalid("web_search: query must not be empty".to_owned())); - } - if self.query.chars().count() > MAX_QUERY_LEN { - return Err(invalid(format!( - "web_search: query exceeds {MAX_QUERY_LEN} characters" - ))); - } - if let Some(count) = self.count - && !(1..=MAX_COUNT).contains(&count) - { - return Err(invalid(format!( - "web_search: count must be between 1 and {MAX_COUNT}" - ))); - } - for (field, value) in [ - ("country", &self.country), - ("search_lang", &self.search_lang), - ] { - if let Some(value) = value - && (value.trim().is_empty() || value.chars().count() > MAX_STRING_LEN) - { - return Err(invalid(format!( - "web_search: {field} must be 1..={MAX_STRING_LEN} characters" - ))); - } - } - for (field, domains) in [ - ("include_domains", &self.include_domains), - ("exclude_domains", &self.exclude_domains), - ] { - if let Some(domains) = domains { - if domains.len() > MAX_DOMAINS { - return Err(invalid(format!( - "web_search: {field} may list at most {MAX_DOMAINS} hostnames" - ))); - } - for domain in domains { - let bad = domain.trim().is_empty() - || domain.chars().count() > MAX_STRING_LEN - || domain.contains('/') - || domain.chars().any(|c| c.is_whitespace() || c.is_control()); - if bad { - return Err(invalid(format!( - "web_search: {field} contains an invalid hostname" - ))); - } - } - } - } - Ok(()) - } -} - /// The validated shape of a successful gateway response: an array of results, /// each carrying at least a string `url`. Unknown fields are ignored so the /// upstream can evolve, but a response missing `results` or a result missing a diff --git a/crates/harness/web/src/lib.rs b/crates/harness/web/src/lib.rs index ca4d448a5..641e27bee 100644 --- a/crates/harness/web/src/lib.rs +++ b/crates/harness/web/src/lib.rs @@ -12,6 +12,24 @@ //! the vendor credential never leaves the server) and an optional fetch //! policy; the prompt never sees either. Activation clones the pre-built //! tools into the run's [`Contribution`]. +//! +//! ## Invariants +//! +//! - Family: harness, private to `crates/harness/`; may depend on: +//! `promptforge-api-runtime`, `promptforge-api-types`, +//! `gateway-api-types`, `gateway-api-discovery`, `shared-*`, and its +//! container siblings. +//! Never on a `workshop-*` crate, a private `gateway-*` crate, or a +//! `promptforge-*` crate behind the door. Read `AGENTS.md` before adding +//! an import. +//! - Every file in this crate stays under 500 lines; split first, then +//! edit. +//! - Both tools are built once at construction from the gateway root, +//! bearer token, and fetch policy; a prompt never sees any of the +//! three, and activation only clones the pre-built tools. +//! - Nothing in this crate spawns a tokio task directly; the harness +//! spawns only through the instrumented wrapper in `harness-runner` +//! (enforced by this crate's `clippy.toml`). use std::sync::Arc; diff --git a/crates/harness/webfetch/AGENTS.md b/crates/harness/webfetch/AGENTS.md index 93597d91e..8736777a4 100644 --- a/crates/harness/webfetch/AGENTS.md +++ b/crates/harness/webfetch/AGENTS.md @@ -4,5 +4,5 @@ This crate fetches and converts one caller-supplied URL into Markdown. - The caller defines URL scope. This provider does not search, crawl, or discover targets. - Every initial request and redirect hop uses the guarded resolver, address pinning, redirect policy, and bounded body handling. No hop may bypass SSRF validation. -- The `Tool` trait comes from `harness-capabilities`; tool id, schema, output, and error vocabulary from `promptforge-api-types`. This provider never depends on Core or a Gateway product crate. +- The `Tool` trait comes from `harness-capabilities`; tool id, schema, output, and error vocabulary from `promptforge-api-types`. This provider never depends on `promptforge-api-runtime` or a Gateway product crate. - Family rules: a harness crate, private to `crates/harness/`; depends on `harness-capabilities`, `promptforge-api-types`, and container siblings only. Tests spawn their mock servers through `harness-runner`'s instrumented wrapper, never `tokio::spawn`. diff --git a/crates/harness/webfetch/src/config-tests.rs b/crates/harness/webfetch/src/config-tests.rs new file mode 100644 index 000000000..863e53f75 --- /dev/null +++ b/crates/harness/webfetch/src/config-tests.rs @@ -0,0 +1,184 @@ +use std::net::IpAddr; +use std::time::Duration; + +use super::{ + DEFAULT_MAX_BYTES, DEFAULT_MAX_CHARS, DEFAULT_MAX_REDIRECTS, FetchConfig, MAX_BYTES_CEILING, + MAX_CHARS_CEILING, MAX_CONNECT_TIMEOUT, MAX_POOL_IDLE_TIMEOUT, MAX_REDIRECTS_CEILING, + MAX_TIMEOUT, +}; + +#[test] +fn default_policy_is_the_documented_safe_policy() { + let cfg = FetchConfig::default(); + assert!(!cfg.allow_http()); + assert_eq!(cfg.allow_ports(), &[80, 443]); + assert!(!cfg.allow_ip_literals()); + assert!(cfg.deny_extra().is_empty()); + assert!(cfg.allow_exact().is_empty()); + assert_eq!(cfg.max_redirects(), DEFAULT_MAX_REDIRECTS); + assert_eq!(cfg.max_bytes(), DEFAULT_MAX_BYTES); + assert_eq!(cfg.max_chars(), DEFAULT_MAX_CHARS); + assert_eq!(cfg.connect_timeout(), Duration::from_secs(5)); + assert_eq!(cfg.timeout(), Duration::from_secs(20)); + assert_eq!(cfg.pool_idle_timeout(), Duration::from_secs(10)); + assert_eq!(cfg.user_agent(), "harness-webfetch/0.0"); +} + +#[test] +fn builder_default_equals_default() { + assert_eq!( + FetchConfig::builder().build().expect("valid"), + FetchConfig::default() + ); +} + +#[test] +fn rejects_newline_user_agent() { + assert!( + FetchConfig::builder() + .user_agent("bad\r\nagent") + .build() + .is_err() + ); + assert!( + FetchConfig::builder() + .user_agent("bad\nagent") + .build() + .is_err() + ); +} + +#[test] +fn rejects_zero_and_over_ceiling_limits() { + assert!(FetchConfig::builder().max_bytes(0).build().is_err()); + assert!(FetchConfig::builder().max_chars(0).build().is_err()); + assert!( + FetchConfig::builder() + .max_bytes(MAX_BYTES_CEILING + 1) + .build() + .is_err() + ); + assert!( + FetchConfig::builder() + .max_chars(MAX_CHARS_CEILING + 1) + .build() + .is_err() + ); + assert!( + FetchConfig::builder() + .max_redirects(MAX_REDIRECTS_CEILING + 1) + .build() + .is_err() + ); +} + +#[test] +fn accepts_zero_redirects() { + let cfg = FetchConfig::builder() + .max_redirects(0) + .build() + .expect("zero redirects is valid"); + assert_eq!(cfg.max_redirects(), 0); +} + +#[test] +fn rejects_zero_timeouts() { + assert!( + FetchConfig::builder() + .timeout(Duration::ZERO) + .build() + .is_err() + ); + assert!( + FetchConfig::builder() + .connect_timeout(Duration::ZERO) + .build() + .is_err() + ); + assert!( + FetchConfig::builder() + .pool_idle_timeout(Duration::ZERO) + .build() + .is_err() + ); +} + +#[test] +fn rejects_over_ceiling_timeouts() { + assert!( + FetchConfig::builder() + .connect_timeout(MAX_CONNECT_TIMEOUT + Duration::from_secs(1)) + .build() + .is_err(), + "a connect timeout over its ceiling must be rejected" + ); + assert!( + FetchConfig::builder() + .timeout(MAX_TIMEOUT + Duration::from_secs(1)) + .build() + .is_err(), + "a request timeout over its ceiling must be rejected" + ); + assert!( + FetchConfig::builder() + .pool_idle_timeout(MAX_POOL_IDLE_TIMEOUT + Duration::from_secs(1)) + .build() + .is_err(), + "a pool-idle timeout over its ceiling must be rejected" + ); + // Exactly the ceiling is accepted. + assert!( + FetchConfig::builder() + .connect_timeout(MAX_CONNECT_TIMEOUT) + .timeout(MAX_TIMEOUT) + .pool_idle_timeout(MAX_POOL_IDLE_TIMEOUT) + .build() + .is_ok(), + "exactly the ceiling must be accepted" + ); +} + +#[test] +fn rejects_malformed_cidr_and_host() { + assert!( + FetchConfig::builder() + .deny_cidr("not-a-cidr") + .build() + .is_err() + ); + let addr: IpAddr = "127.0.0.1".parse().expect("loopback parses"); + // Every non-domain, non-literal form is rejected by the DNS-host parser. + for bad in [ + "", "bad host", "bad:host", "bad@host", "bad?host", "a/b", "x#y", + ] { + assert!( + FetchConfig::builder() + .allow_host_address(bad, addr) + .build() + .is_err(), + "malformed host {bad:?} must be rejected" + ); + } + // A valid domain and a valid IP literal are both accepted. + let cfg = FetchConfig::builder() + .allow_host_address("example.com", addr) + .allow_host_address("127.0.0.1", addr) + .build() + .expect("a valid domain and IP literal are accepted"); + assert_eq!(cfg.allow_exact().len(), 2); +} + +#[test] +fn deduplicates_cidrs_and_hosts() { + let addr: IpAddr = "127.0.0.1".parse().expect("loopback parses"); + let cfg = FetchConfig::builder() + .deny_cidr("203.0.114.0/24") + .deny_cidr("203.0.114.0/24") + .allow_host_address("Localhost.", addr) + .allow_host_address("localhost", addr) + .build() + .expect("valid"); + assert_eq!(cfg.deny_extra().len(), 1); + assert_eq!(cfg.allow_exact().len(), 1); + assert!(cfg.allow_exact()[0].matches("LOCALHOST", addr)); +} diff --git a/crates/harness/webfetch/src/config-validate.rs b/crates/harness/webfetch/src/config-validate.rs new file mode 100644 index 000000000..4f325c03b --- /dev/null +++ b/crates/harness/webfetch/src/config-validate.rs @@ -0,0 +1,214 @@ +//! The configuration error and the validators behind +//! [`FetchConfigBuilder::build`](super::FetchConfigBuilder::build). +//! +//! Each validator checks one raw builder field against its constraint and +//! returns the validated newtype the policy stores, or the private error +//! representation naming the field and the violated constraint. The public +//! [`ConfigError`] wraps that representation opaquely. + +use std::net::IpAddr; +use std::time::Duration; + +use ipnet::IpNet; +use reqwest::header::HeaderValue; + +use super::{HostAddressException, MAX_REDIRECTS_CEILING, MaxRedirects, UserAgent, canonical_host}; + +/// An opaque configuration error. +/// +/// Its representation is private and free to change. The [`Display`] rendering +/// names the field and the constraint that was violated. +/// +/// [`Display`]: std::fmt::Display +#[derive(Debug, thiserror::Error)] +#[error(transparent)] +pub struct ConfigError(#[from] ConfigErrorRepr); + +/// The private representation behind [`ConfigError`]. +#[derive(Debug, thiserror::Error)] +pub(super) enum ConfigErrorRepr { + /// The user agent is not a legal HTTP header value. + #[error("user agent is not a valid http header value")] + UserAgent(#[source] reqwest::header::InvalidHeaderValue), + + /// A limit was zero, which would disable the bound it governs. + #[error("{field} must be greater than zero")] + ZeroLimit { + /// The name of the offending limit. + field: &'static str, + }, + + /// A limit exceeded its hard operational ceiling. + #[error("{field} ({value}) exceeds the maximum of {ceiling}")] + OverCeiling { + /// The name of the offending limit. + field: &'static str, + /// The rejected value. + value: usize, + /// The ceiling it exceeded. + ceiling: usize, + }, + + /// A timeout was zero, which the policy does not allow. + #[error("{field} must be a positive duration")] + ZeroTimeout { + /// The name of the offending timeout. + field: &'static str, + }, + + /// A timeout exceeded its hard operational ceiling. + #[error("{field} ({value:?}) exceeds the maximum of {ceiling:?}")] + TimeoutOverCeiling { + /// The name of the offending timeout. + field: &'static str, + /// The rejected duration. + value: Duration, + /// The ceiling it exceeded. + ceiling: Duration, + }, + + /// A denied-CIDR string did not parse. + #[error("invalid deny cidr {cidr}")] + Cidr { + /// The rejected CIDR text. + cidr: String, + /// The parse failure. + #[source] + source: ipnet::AddrParseError, + }, + + /// An exact-host exception named an empty or malformed host. + #[error("invalid exact host {host:?}")] + Host { + /// The rejected host text. + host: String, + }, + + /// The HTTP client could not be built for the validated policy. + #[error("http client construction failed")] + ClientBuild(#[source] reqwest::Error), +} + +impl ConfigError { + /// Builds a `ConfigError` from a reqwest client-build failure. + pub(crate) fn client_build(source: reqwest::Error) -> ConfigError { + ConfigError(ConfigErrorRepr::ClientBuild(source)) + } +} + +/// Validates a `User-Agent` string as a legal HTTP header value. +pub(super) fn validate_user_agent(ua: String) -> Result { + HeaderValue::from_str(&ua).map_err(ConfigErrorRepr::UserAgent)?; + Ok(UserAgent(ua)) +} + +/// Validates a positive limit against a hard ceiling. +pub(super) fn validate_limit( + field: &'static str, + value: usize, + ceiling: usize, +) -> Result { + if value == 0 { + return Err(ConfigErrorRepr::ZeroLimit { field }); + } + if value > ceiling { + return Err(ConfigErrorRepr::OverCeiling { + field, + value, + ceiling, + }); + } + Ok(value) +} + +/// Validates the redirect cap against its ceiling; zero is permitted. +pub(super) fn validate_redirects(value: usize) -> Result { + if value > MAX_REDIRECTS_CEILING { + return Err(ConfigErrorRepr::OverCeiling { + field: "max_redirects", + value, + ceiling: MAX_REDIRECTS_CEILING, + }); + } + Ok(MaxRedirects(value)) +} + +/// Validates a timeout as strictly positive and within its ceiling. +pub(super) fn validate_timeout( + field: &'static str, + value: Duration, + ceiling: Duration, +) -> Result { + if value.is_zero() { + return Err(ConfigErrorRepr::ZeroTimeout { field }); + } + if value > ceiling { + return Err(ConfigErrorRepr::TimeoutOverCeiling { + field, + value, + ceiling, + }); + } + Ok(value) +} + +/// Parses and validates the denied-CIDR strings into networks. +pub(super) fn validate_deny_cidrs(cidrs: Vec) -> Result, ConfigErrorRepr> { + let mut nets = Vec::with_capacity(cidrs.len()); + for cidr in cidrs { + let net = cidr + .parse::() + .map_err(|source| ConfigErrorRepr::Cidr { + cidr: cidr.clone(), + source, + })?; + if !nets.contains(&net) { + nets.push(net); + } + } + Ok(nets) +} + +/// Canonicalizes and validates one exact-exception host. +/// +/// Accepts an IP literal (for a literal-host exception) or a syntactically valid +/// DNS domain. Every other form - empty, whitespace-, slash-, colon-, at-, or +/// query-bearing - is rejected by the URL host parser, which enforces the URL +/// forbidden-host-code-point set. Returns the canonical host to store. +pub(super) fn validate_host(raw: &str) -> Result { + let host = canonical_host(raw); + if host.is_empty() { + return Err(ConfigErrorRepr::Host { + host: raw.to_string(), + }); + } + // An IP literal is a legitimate exact-exception host (a literal-host URL). + if host.parse::().is_ok() { + return Ok(host); + } + // Otherwise require a valid DNS domain. `url::Host::parse` rejects every + // forbidden host code point (`:` outside brackets, `@`, `?`, `#`, `/`, + // whitespace, ...), so `bad:host`, `bad@host`, and `bad?host` are refused, + // while a non-domain address form is not a valid exact host here. + match url::Host::parse(&host) { + Ok(url::Host::Domain(domain)) => Ok(domain), + _ => Err(ConfigErrorRepr::Host { + host: raw.to_string(), + }), + } +} + +/// Canonicalizes and validates the exact host-plus-address exceptions. +pub(super) fn validate_allow_hosts( + hosts: Vec<(String, IpAddr)>, +) -> Result, ConfigErrorRepr> { + let mut out: Vec = Vec::with_capacity(hosts.len()); + for (raw, addr) in hosts { + let host = validate_host(&raw)?; + let entry = HostAddressException { host, addr }; + if !out.contains(&entry) { + out.push(entry); + } + } + Ok(out) +} diff --git a/crates/harness/webfetch/src/config.rs b/crates/harness/webfetch/src/config.rs index 87e072923..6213c3403 100644 --- a/crates/harness/webfetch/src/config.rs +++ b/crates/harness/webfetch/src/config.rs @@ -16,7 +16,15 @@ use std::net::IpAddr; use std::time::Duration; use ipnet::IpNet; -use reqwest::header::HeaderValue; + +#[path = "config-validate.rs"] +mod validate; + +pub use validate::ConfigError; +use validate::{ + validate_allow_hosts, validate_deny_cidrs, validate_limit, validate_redirects, + validate_timeout, validate_user_agent, +}; /// The default ports a fetch may target: HTTP and HTTPS. const DEFAULT_ALLOW_PORTS: [u16; 2] = [80, 443]; @@ -110,88 +118,6 @@ fn canonical_host(host: &str) -> String { host.trim().trim_end_matches('.').to_ascii_lowercase() } -/// An opaque configuration error. -/// -/// Its representation is private and free to change. The [`Display`] rendering -/// names the field and the constraint that was violated. -/// -/// [`Display`]: std::fmt::Display -#[derive(Debug, thiserror::Error)] -#[error(transparent)] -pub struct ConfigError(#[from] ConfigErrorRepr); - -/// The private representation behind [`ConfigError`]. -#[derive(Debug, thiserror::Error)] -enum ConfigErrorRepr { - /// The user agent is not a legal HTTP header value. - #[error("user agent is not a valid http header value")] - UserAgent(#[source] reqwest::header::InvalidHeaderValue), - - /// A limit was zero, which would disable the bound it governs. - #[error("{field} must be greater than zero")] - ZeroLimit { - /// The name of the offending limit. - field: &'static str, - }, - - /// A limit exceeded its hard operational ceiling. - #[error("{field} ({value}) exceeds the maximum of {ceiling}")] - OverCeiling { - /// The name of the offending limit. - field: &'static str, - /// The rejected value. - value: usize, - /// The ceiling it exceeded. - ceiling: usize, - }, - - /// A timeout was zero, which the policy does not allow. - #[error("{field} must be a positive duration")] - ZeroTimeout { - /// The name of the offending timeout. - field: &'static str, - }, - - /// A timeout exceeded its hard operational ceiling. - #[error("{field} ({value:?}) exceeds the maximum of {ceiling:?}")] - TimeoutOverCeiling { - /// The name of the offending timeout. - field: &'static str, - /// The rejected duration. - value: Duration, - /// The ceiling it exceeded. - ceiling: Duration, - }, - - /// A denied-CIDR string did not parse. - #[error("invalid deny cidr {cidr}")] - Cidr { - /// The rejected CIDR text. - cidr: String, - /// The parse failure. - #[source] - source: ipnet::AddrParseError, - }, - - /// An exact-host exception named an empty or malformed host. - #[error("invalid exact host {host:?}")] - Host { - /// The rejected host text. - host: String, - }, - - /// The HTTP client could not be built for the validated policy. - #[error("http client construction failed")] - ClientBuild(#[source] reqwest::Error), -} - -impl ConfigError { - /// Builds a `ConfigError` from a reqwest client-build failure. - pub(crate) fn client_build(source: reqwest::Error) -> ConfigError { - ConfigError(ConfigErrorRepr::ClientBuild(source)) - } -} - /// Security policy for the `web_fetch` tool. /// /// A `FetchConfig` is immutable and validated: every field is a private newtype @@ -524,307 +450,6 @@ impl FetchConfigBuilder { } } -/// Validates a `User-Agent` string as a legal HTTP header value. -fn validate_user_agent(ua: String) -> Result { - HeaderValue::from_str(&ua).map_err(ConfigErrorRepr::UserAgent)?; - Ok(UserAgent(ua)) -} - -/// Validates a positive limit against a hard ceiling. -fn validate_limit( - field: &'static str, - value: usize, - ceiling: usize, -) -> Result { - if value == 0 { - return Err(ConfigErrorRepr::ZeroLimit { field }); - } - if value > ceiling { - return Err(ConfigErrorRepr::OverCeiling { - field, - value, - ceiling, - }); - } - Ok(value) -} - -/// Validates the redirect cap against its ceiling; zero is permitted. -fn validate_redirects(value: usize) -> Result { - if value > MAX_REDIRECTS_CEILING { - return Err(ConfigErrorRepr::OverCeiling { - field: "max_redirects", - value, - ceiling: MAX_REDIRECTS_CEILING, - }); - } - Ok(MaxRedirects(value)) -} - -/// Validates a timeout as strictly positive and within its ceiling. -fn validate_timeout( - field: &'static str, - value: Duration, - ceiling: Duration, -) -> Result { - if value.is_zero() { - return Err(ConfigErrorRepr::ZeroTimeout { field }); - } - if value > ceiling { - return Err(ConfigErrorRepr::TimeoutOverCeiling { - field, - value, - ceiling, - }); - } - Ok(value) -} - -/// Parses and validates the denied-CIDR strings into networks. -fn validate_deny_cidrs(cidrs: Vec) -> Result, ConfigErrorRepr> { - let mut nets = Vec::with_capacity(cidrs.len()); - for cidr in cidrs { - let net = cidr - .parse::() - .map_err(|source| ConfigErrorRepr::Cidr { - cidr: cidr.clone(), - source, - })?; - if !nets.contains(&net) { - nets.push(net); - } - } - Ok(nets) -} - -/// Canonicalizes and validates one exact-exception host. -/// -/// Accepts an IP literal (for a literal-host exception) or a syntactically valid -/// DNS domain. Every other form - empty, whitespace-, slash-, colon-, at-, or -/// query-bearing - is rejected by the URL host parser, which enforces the URL -/// forbidden-host-code-point set. Returns the canonical host to store. -fn validate_host(raw: &str) -> Result { - let host = canonical_host(raw); - if host.is_empty() { - return Err(ConfigErrorRepr::Host { - host: raw.to_string(), - }); - } - // An IP literal is a legitimate exact-exception host (a literal-host URL). - if host.parse::().is_ok() { - return Ok(host); - } - // Otherwise require a valid DNS domain. `url::Host::parse` rejects every - // forbidden host code point (`:` outside brackets, `@`, `?`, `#`, `/`, - // whitespace, ...), so `bad:host`, `bad@host`, and `bad?host` are refused, - // while a non-domain address form is not a valid exact host here. - match url::Host::parse(&host) { - Ok(url::Host::Domain(domain)) => Ok(domain), - _ => Err(ConfigErrorRepr::Host { - host: raw.to_string(), - }), - } -} - -/// Canonicalizes and validates the exact host-plus-address exceptions. -fn validate_allow_hosts( - hosts: Vec<(String, IpAddr)>, -) -> Result, ConfigErrorRepr> { - let mut out: Vec = Vec::with_capacity(hosts.len()); - for (raw, addr) in hosts { - let host = validate_host(&raw)?; - let entry = HostAddressException { host, addr }; - if !out.contains(&entry) { - out.push(entry); - } - } - Ok(out) -} - #[cfg(test)] -mod tests { - use std::net::IpAddr; - use std::time::Duration; - - use super::{ - DEFAULT_MAX_BYTES, DEFAULT_MAX_CHARS, DEFAULT_MAX_REDIRECTS, FetchConfig, - MAX_BYTES_CEILING, MAX_CHARS_CEILING, MAX_CONNECT_TIMEOUT, MAX_POOL_IDLE_TIMEOUT, - MAX_REDIRECTS_CEILING, MAX_TIMEOUT, - }; - - #[test] - fn default_policy_is_the_documented_safe_policy() { - let cfg = FetchConfig::default(); - assert!(!cfg.allow_http()); - assert_eq!(cfg.allow_ports(), &[80, 443]); - assert!(!cfg.allow_ip_literals()); - assert!(cfg.deny_extra().is_empty()); - assert!(cfg.allow_exact().is_empty()); - assert_eq!(cfg.max_redirects(), DEFAULT_MAX_REDIRECTS); - assert_eq!(cfg.max_bytes(), DEFAULT_MAX_BYTES); - assert_eq!(cfg.max_chars(), DEFAULT_MAX_CHARS); - assert_eq!(cfg.connect_timeout(), Duration::from_secs(5)); - assert_eq!(cfg.timeout(), Duration::from_secs(20)); - assert_eq!(cfg.pool_idle_timeout(), Duration::from_secs(10)); - assert_eq!(cfg.user_agent(), "harness-webfetch/0.0"); - } - - #[test] - fn builder_default_equals_default() { - assert_eq!( - FetchConfig::builder().build().expect("valid"), - FetchConfig::default() - ); - } - - #[test] - fn rejects_newline_user_agent() { - assert!( - FetchConfig::builder() - .user_agent("bad\r\nagent") - .build() - .is_err() - ); - assert!( - FetchConfig::builder() - .user_agent("bad\nagent") - .build() - .is_err() - ); - } - - #[test] - fn rejects_zero_and_over_ceiling_limits() { - assert!(FetchConfig::builder().max_bytes(0).build().is_err()); - assert!(FetchConfig::builder().max_chars(0).build().is_err()); - assert!( - FetchConfig::builder() - .max_bytes(MAX_BYTES_CEILING + 1) - .build() - .is_err() - ); - assert!( - FetchConfig::builder() - .max_chars(MAX_CHARS_CEILING + 1) - .build() - .is_err() - ); - assert!( - FetchConfig::builder() - .max_redirects(MAX_REDIRECTS_CEILING + 1) - .build() - .is_err() - ); - } - - #[test] - fn accepts_zero_redirects() { - let cfg = FetchConfig::builder() - .max_redirects(0) - .build() - .expect("zero redirects is valid"); - assert_eq!(cfg.max_redirects(), 0); - } - - #[test] - fn rejects_zero_timeouts() { - assert!( - FetchConfig::builder() - .timeout(Duration::ZERO) - .build() - .is_err() - ); - assert!( - FetchConfig::builder() - .connect_timeout(Duration::ZERO) - .build() - .is_err() - ); - assert!( - FetchConfig::builder() - .pool_idle_timeout(Duration::ZERO) - .build() - .is_err() - ); - } - - #[test] - fn rejects_over_ceiling_timeouts() { - assert!( - FetchConfig::builder() - .connect_timeout(MAX_CONNECT_TIMEOUT + Duration::from_secs(1)) - .build() - .is_err(), - "a connect timeout over its ceiling must be rejected" - ); - assert!( - FetchConfig::builder() - .timeout(MAX_TIMEOUT + Duration::from_secs(1)) - .build() - .is_err(), - "a request timeout over its ceiling must be rejected" - ); - assert!( - FetchConfig::builder() - .pool_idle_timeout(MAX_POOL_IDLE_TIMEOUT + Duration::from_secs(1)) - .build() - .is_err(), - "a pool-idle timeout over its ceiling must be rejected" - ); - // Exactly the ceiling is accepted. - assert!( - FetchConfig::builder() - .connect_timeout(MAX_CONNECT_TIMEOUT) - .timeout(MAX_TIMEOUT) - .pool_idle_timeout(MAX_POOL_IDLE_TIMEOUT) - .build() - .is_ok(), - "exactly the ceiling must be accepted" - ); - } - - #[test] - fn rejects_malformed_cidr_and_host() { - assert!( - FetchConfig::builder() - .deny_cidr("not-a-cidr") - .build() - .is_err() - ); - let addr: IpAddr = "127.0.0.1".parse().expect("loopback parses"); - // Every non-domain, non-literal form is rejected by the DNS-host parser. - for bad in [ - "", "bad host", "bad:host", "bad@host", "bad?host", "a/b", "x#y", - ] { - assert!( - FetchConfig::builder() - .allow_host_address(bad, addr) - .build() - .is_err(), - "malformed host {bad:?} must be rejected" - ); - } - // A valid domain and a valid IP literal are both accepted. - let cfg = FetchConfig::builder() - .allow_host_address("example.com", addr) - .allow_host_address("127.0.0.1", addr) - .build() - .expect("a valid domain and IP literal are accepted"); - assert_eq!(cfg.allow_exact().len(), 2); - } - - #[test] - fn deduplicates_cidrs_and_hosts() { - let addr: IpAddr = "127.0.0.1".parse().expect("loopback parses"); - let cfg = FetchConfig::builder() - .deny_cidr("203.0.114.0/24") - .deny_cidr("203.0.114.0/24") - .allow_host_address("Localhost.", addr) - .allow_host_address("localhost", addr) - .build() - .expect("valid"); - assert_eq!(cfg.deny_extra().len(), 1); - assert_eq!(cfg.allow_exact().len(), 1); - assert!(cfg.allow_exact()[0].matches("LOCALHOST", addr)); - } -} +#[path = "config-tests.rs"] +mod tests; diff --git a/crates/harness/webfetch/src/error.rs b/crates/harness/webfetch/src/error.rs index bff49ad51..bb7d66fea 100644 --- a/crates/harness/webfetch/src/error.rs +++ b/crates/harness/webfetch/src/error.rs @@ -19,9 +19,9 @@ use promptforge_api_types::tools::ToolErrorKind; /// the call with the given [`ToolErrorKind`]. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum Disposition { - /// Return the model-facing text as untrusted tool output. + /// Returns the model-facing text as untrusted tool output. SoftOutput, - /// Abort the call with this error kind. + /// Aborts the call with this error kind. Hard(ToolErrorKind), } @@ -141,7 +141,7 @@ pub(crate) enum FetchError { }, /// Reading the response body failed mid-stream. - #[error("failed to read the response body from {url}; try again or use a different URL")] + #[error("the response body from {url} could not be read; try again or use a different URL")] BodyRead { /// The URL whose body read failed. url: SafeUrl, @@ -197,7 +197,7 @@ pub(crate) enum FetchError { }, /// The target URL returned a non-success HTTP status. - #[error("HTTP {status} from {url}; try a different URL")] + #[error("{url} answered HTTP {status}; try a different URL")] HttpStatus { /// The URL (after redirects) that returned the error status. url: SafeUrl, diff --git a/crates/harness/webfetch/src/lib.rs b/crates/harness/webfetch/src/lib.rs index f9fe98057..12eec5641 100644 --- a/crates/harness/webfetch/src/lib.rs +++ b/crates/harness/webfetch/src/lib.rs @@ -14,6 +14,27 @@ //! article-shaped falls back to a whole-page HTML-to-markdown conversion with //! [`htmd`]. A non-HTML text body (JSON, XML, plain text) is returned decoded, //! with no extraction. +//! +//! ## Invariants +//! +//! - Family: harness, private to `crates/harness/`; may depend on: +//! `promptforge-api-runtime`, `promptforge-api-types`, +//! `gateway-api-types`, `gateway-api-discovery`, `shared-*`, and its +//! container siblings. +//! Never on a `workshop-*` crate, a private `gateway-*` crate, or a +//! `promptforge-*` crate behind the door. Read `AGENTS.md` before adding +//! an import. +//! - Every file in this crate stays under 500 lines; split first, then +//! edit. +//! - Every model- or tool-selected URL and every resolved address is +//! revalidated on each redirect hop; a non-global address is denied +//! unless the fetch policy grants an exact host-and-address exception. +//! - No request carries an ambient identity on any hop: the client has +//! no proxy, no cookie store, no automatic `Referer`, and no default +//! credentials. +//! - Nothing in this crate spawns a tokio task directly; the harness +//! spawns only through the instrumented wrapper in `harness-runner` +//! (enforced by this crate's `clippy.toml`). mod address; mod config; diff --git a/crates/harness/webfetch/src/tool-tests-body.rs b/crates/harness/webfetch/src/tool-tests-body.rs new file mode 100644 index 000000000..c333cbecc --- /dev/null +++ b/crates/harness/webfetch/src/tool-tests-body.rs @@ -0,0 +1,356 @@ +//! Body tests for [`WebFetch`]: the byte cap refuses an oversized HTML or +//! structured body (declared, streamed, or decompressed) and truncates a +//! flat-text body, `max_chars` truncates on a character boundary and is +//! clamped to the ceiling, HTML routes through extraction or raw render, +//! JSON is returned verbatim, an unsupported or absent content type is +//! refused naming it, and a declared charset decodes the body. + +use super::*; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn oversized_html_is_refused() { + let (port, _hits) = spawn_server().await; + let config = loopback_builder(port) + .max_bytes(4096) + .build() + .expect("valid"); + let tool = WebFetch::try_with_config(config).expect("client builds"); + + let url = format!("http://localhost:{port}/large"); + let result = tool + .call(serde_json::json!({ "url": url })) + .await + .expect("an oversized HTML body is a soft return") + .text() + .to_owned(); + + assert!( + result.contains("exceeds") && result.contains("4096"), + "got: {result}" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn declared_content_length_over_cap_is_refused_before_read() { + let (port, _hits) = spawn_server().await; + let config = loopback_builder(port) + .max_bytes(4096) + .build() + .expect("valid"); + let tool = WebFetch::try_with_config(config).expect("client builds"); + + let url = format!("http://localhost:{port}/liar"); + let result = tool + .call(serde_json::json!({ "url": url })) + .await + .expect("a declared Content-Length over the cap is a soft return") + .text() + .to_owned(); + + assert!( + result.contains("exceeds") && result.contains("4096"), + "got: {result}" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn gzip_bomb_refused_on_decompressed_count() { + let (port, _hits) = spawn_server().await; + let config = loopback_builder(port) + .max_bytes(4096) + .build() + .expect("valid"); + let tool = WebFetch::try_with_config(config).expect("client builds"); + + let url = format!("http://localhost:{port}/gzip"); + let result = tool + .call(serde_json::json!({ "url": url })) + .await + .expect("a gzip body that decompresses past the cap is a soft return") + .text() + .to_owned(); + + assert!(result.contains("exceeds"), "got: {result}"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn text_over_max_chars_is_truncated_on_char_boundary() { + let (port, _hits) = spawn_server().await; + let tool = loopback_tool(port); + + let max_chars = 25usize; + let url = format!("http://localhost:{port}/unicode"); + let out = tool + .call(serde_json::json!({ "url": url, "max_chars": max_chars })) + .await + .expect("a unicode fetch through allow_exact must succeed") + .text() + .to_owned(); + + let (header, body) = split_header(&out); + assert!(header.contains("truncated: true"), "got: {header}"); + assert_eq!(body.chars().count(), max_chars, "got: {body:?}"); + assert!( + body.contains('é') || body.contains('ï') || body.contains('ç'), + "got: {body:?}" + ); + assert!(!body.contains('\u{FFFD}'), "got: {body:?}"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn per_call_max_chars_is_clamped_to_the_configured_ceiling() { + let (port, _hits) = spawn_server().await; + // A tiny ceiling: a huge per-call request must be clamped to it. + let config = loopback_builder(port).max_chars(10).build().expect("valid"); + let tool = WebFetch::try_with_config(config).expect("client builds"); + + let url = format!("http://localhost:{port}/plainbig"); + let out = tool + .call(serde_json::json!({ "url": url, "max_chars": 1_000_000 })) + .await + .expect("a plain fetch must succeed") + .text() + .to_owned(); + + let (header, body) = split_header(&out); + assert!(header.contains("truncated: true"), "got: {header}"); + assert_eq!( + body.chars().count(), + 10, + "the per-call max_chars must be clamped to the ceiling, got {} chars", + body.chars().count() + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn body_one_byte_under_cap_succeeds_untruncated() { + let (port, _hits) = spawn_server().await; + let config = loopback_builder(port) + .max_bytes(ARTICLE_HTML.len() + 1) + .build() + .expect("valid"); + let tool = WebFetch::try_with_config(config).expect("client builds"); + + let url = format!("http://localhost:{port}/"); + let out = tool + .call(serde_json::json!({ "url": url })) + .await + .expect("a body one byte under the cap must be accepted") + .text() + .to_owned(); + + let (header, body) = split_header(&out); + assert!(header.contains("truncated: false"), "got: {header}"); + assert!(body.contains("substantial paragraph"), "got: {body}"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn html_is_extracted_and_reports_readability() { + let (port, _hits) = spawn_server().await; + let tool = loopback_tool(port); + + let url = format!("http://localhost:{port}/"); + let out = tool + .call(serde_json::json!({ "url": url })) + .await + .expect("a loopback html fetch must succeed") + .text() + .to_owned(); + + let (header, body) = split_header(&out); + assert!(header.contains("extraction: readability"), "got: {header}"); + assert!(body.contains("substantial paragraph"), "got: {body}"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn raw_forces_whole_page_render_keeping_table() { + let (port, _hits) = spawn_server().await; + let tool = loopback_tool(port); + + let url = format!("http://localhost:{port}/table"); + let out = tool + .call(serde_json::json!({ "url": url, "raw": true })) + .await + .expect("a raw table fetch must succeed") + .text() + .to_owned(); + + let (header, body) = split_header(&out); + assert!(header.contains("extraction: raw-html"), "got: {header}"); + assert!( + body.contains("WIDGETROW") && body.contains("GADGETROW"), + "got: {body}" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn json_is_returned_verbatim_as_plain() { + let (port, _hits) = spawn_server().await; + let tool = loopback_tool(port); + + let url = format!("http://localhost:{port}/json"); + let out = tool + .call(serde_json::json!({ "url": url })) + .await + .expect("a json fetch must succeed") + .text() + .to_owned(); + + let (header, body) = split_header(&out); + assert!(header.contains("extraction: plain"), "got: {header}"); + assert_eq!(body, JSON_BODY, "got: {body}"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn oversized_json_is_hard_refused_not_truncated() { + let (port, _hits) = spawn_server().await; + let config = loopback_builder(port) + .max_bytes(4096) + .build() + .expect("valid"); + let tool = WebFetch::try_with_config(config).expect("client builds"); + + let url = format!("http://localhost:{port}/jsonbig"); + let result = tool + .call(serde_json::json!({ "url": url })) + .await + .expect("an oversized json body is a soft return") + .text() + .to_owned(); + + assert!( + result.contains("exceeds") && result.contains("4096"), + "got: {result}" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn flat_text_body_read_failure_is_soft() { + let (port, _hits) = spawn_server().await; + let tool = loopback_tool(port); + + let url = format!("http://localhost:{port}/plainbroken"); + // The mid-stream failure must be a soft (recoverable) return, never a + // hard error: identical to the HTML and structured routes. A `text()` + // return proves the outcome was soft untrusted output. + let outcome = tool + .call(serde_json::json!({ "url": url })) + .await + .expect("a mid-stream flat-text failure must be a soft return, not a hard error"); + assert_eq!( + outcome.trust(), + promptforge_api_types::tools::OutputTrust::Untrusted, + "a soft body-read failure must be untrusted output" + ); + let result = outcome.text().to_owned(); + assert!( + result.contains("could not be read") || result.contains("network error"), + "got: {result}" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn unrecognized_charset_is_refused_naming_the_label() { + let (port, _hits) = spawn_server().await; + let tool = loopback_tool(port); + + let url = format!("http://localhost:{port}/badcharset"); + let result = tool + .call(serde_json::json!({ "url": url })) + .await + .expect("an unrecognized charset is a soft return") + .text() + .to_owned(); + + assert!(result.contains("not-a-charset"), "got: {result}"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn pdf_is_refused_naming_the_type() { + let (port, _hits) = spawn_server().await; + let tool = loopback_tool(port); + + let url = format!("http://localhost:{port}/pdf"); + let result = tool + .call(serde_json::json!({ "url": url })) + .await + .expect("a pdf response is a soft return") + .text() + .to_owned(); + + assert!(result.contains("application/pdf"), "got: {result}"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn octet_stream_is_refused() { + let (port, _hits) = spawn_server().await; + let tool = loopback_tool(port); + + let url = format!("http://localhost:{port}/octet"); + let result = tool + .call(serde_json::json!({ "url": url })) + .await + .expect("an octet-stream response is a soft return") + .text() + .to_owned(); + + assert!(result.contains("application/octet-stream"), "got: {result}"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn absent_content_type_is_refused() { + let (port, _hits) = spawn_server().await; + let tool = loopback_tool(port); + + let url = format!("http://localhost:{port}/notype"); + let result = tool + .call(serde_json::json!({ "url": url })) + .await + .expect("an absent content type is a soft return") + .text() + .to_owned(); + + assert!(result.contains("no content type"), "got: {result}"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn latin1_page_decodes_with_declared_charset() { + let (port, _hits) = spawn_server().await; + let tool = loopback_tool(port); + + let url = format!("http://localhost:{port}/latin1"); + let out = tool + .call(serde_json::json!({ "url": url })) + .await + .expect("a latin-1 fetch must succeed") + .text() + .to_owned(); + + let (header, body) = split_header(&out); + assert!(header.contains("extraction: plain"), "got: {header}"); + assert!(body.contains('é'), "got: {body:?}"); + assert!(!body.contains('\u{FFFD}'), "got: {body:?}"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn plain_text_over_cap_is_truncated_not_refused() { + let (port, _hits) = spawn_server().await; + let config = loopback_builder(port) + .max_bytes(4096) + .build() + .expect("valid"); + let tool = WebFetch::try_with_config(config).expect("client builds"); + + let url = format!("http://localhost:{port}/plainbig"); + let out = tool + .call(serde_json::json!({ "url": url })) + .await + .expect("an oversized flat-text body must be truncated, not refused") + .text() + .to_owned(); + + let (header, body) = split_header(&out); + assert!(header.contains("truncated: true"), "got: {header}"); + assert!(header.contains("extraction: plain"), "got: {header}"); + assert_eq!(body.len(), 4096, "got {} bytes", body.len()); +} diff --git a/crates/harness/webfetch/src/tool-tests-policy.rs b/crates/harness/webfetch/src/tool-tests-policy.rs new file mode 100644 index 000000000..4d026fa6f --- /dev/null +++ b/crates/harness/webfetch/src/tool-tests-policy.rs @@ -0,0 +1,266 @@ +//! Policy tests for [`WebFetch`]: a total timeout is a soft return, no +//! cookie, credential, or `Referer` rides any hop, a redirect to a +//! non-global address is refused before the target is contacted (through +//! the system resolver and an injected lookup alike), a policy-rejected URL +//! never reaches the network, and an HTTP error status is a soft return. + +use super::*; + +use promptforge_api_types::tools::ToolErrorKind; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn slow_server_past_total_timeout_yields_timeout() { + let (port, _recorded) = spawn_recording_server().await; + let config = loopback_builder(port) + .timeout(std::time::Duration::from_millis(200)) + .build() + .expect("valid config"); + let tool = WebFetch::try_with_config(config).expect("client builds"); + + let url = format!("http://localhost:{port}/slow"); + let result = tool + .call(serde_json::json!({ "url": url })) + .await + .expect("a timeout is a soft (recoverable) return") + .text() + .to_owned(); + + assert!( + result.contains("timed out"), + "expected a timeout message, got: {result}" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn request_carries_no_cookie_or_credential() { + let (port, recorded) = spawn_recording_server().await; + let tool = loopback_tool(port); + + let url = format!("http://localhost:{port}/record"); + tool.call(serde_json::json!({ "url": url })) + .await + .expect("a loopback fetch through allow_exact must succeed"); + + let recorded = recorded + .lock() + .expect("the recorded-headers mutex must not be poisoned"); + assert_eq!(recorded.len(), 1); + let headers = &recorded[0]; + assert!(!headers.contains_key(axum::http::header::COOKIE)); + assert!(!headers.contains_key(axum::http::header::AUTHORIZATION)); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn no_credential_or_referer_survives_a_redirect() { + let (port, recorded) = spawn_recording_server().await; + let tool = loopback_tool(port); + + // A query-bearing source URL redirecting to a distinct allowed path: the + // target must receive no Cookie, Authorization, or Referer. + let url = format!("http://localhost:{port}/redir-record?secret=leak-me"); + tool.call(serde_json::json!({ "url": url })) + .await + .expect("a redirect between loopback paths must succeed"); + + let recorded = recorded + .lock() + .expect("the recorded-headers mutex must not be poisoned"); + assert_eq!(recorded.len(), 1); + let headers = &recorded[0]; + assert!(!headers.contains_key(axum::http::header::COOKIE)); + assert!(!headers.contains_key(axum::http::header::AUTHORIZATION)); + assert!( + !headers.contains_key(axum::http::header::REFERER), + "no Referer may survive a redirect, got: {headers:?}" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn fetch_returns_provenance_line_then_content() { + let (port, _hits) = spawn_server().await; + let tool = loopback_tool(port); + + let url = format!("http://localhost:{port}/"); + let out = tool + .call(serde_json::json!({ "url": url })) + .await + .expect("a loopback fetch through allow_exact must succeed") + .text() + .to_owned(); + + let expected = format!("url: http://localhost:{port}/"); + assert!(out.starts_with(&expected), "got: {out}"); + assert!(out.contains("substantial paragraph"), "got: {out}"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn redirect_to_internal_is_refused_and_target_untouched() { + let (port, hits) = spawn_server().await; + let tool = loopback_tool(port); + + let url = format!("http://localhost:{port}/redir"); + let result = tool + .call(serde_json::json!({ "url": url })) + .await + .expect("a redirect-target policy refusal is a soft (recoverable) return") + .text() + .to_owned(); + + assert!( + result.contains("refused") && result.contains("127.0.0.1"), + "got: {result}" + ); + assert_eq!(hits.load(Ordering::SeqCst), 0); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn redirect_to_internal_via_injected_lookup_never_contacts_target() { + let (port, hits) = spawn_server().await; + let loopback: IpAddr = "127.0.0.1".parse().expect("loopback parses"); + let blocked: IpAddr = "10.0.0.1".parse().expect("private parses"); + // allowed.test reaches the loopback server; internal.test resolves only + // to a blocked address and carries no exact exception. + let lookup = MapLookup { + entries: vec![ + ("allowed.test".to_string(), loopback), + ("internal.test".to_string(), blocked), + ], + }; + // The server redirects to internal.test, which resolves only to a + // blocked address, so the redirected target is never contacted. + let redir_state = AppState { + port, + hits: Arc::clone(&hits), + }; + let redir_listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("binding a second loopback listener must succeed"); + let redir_port = redir_listener.local_addr().expect("local addr").port(); + let app = Router::new() + .route( + "/go", + get(move || async move { + Redirect::temporary(&format!("http://internal.test:{port}/target")) + }), + ) + .with_state(redir_state); + spawn_tagged(mock_tag(), async move { + axum::serve(redir_listener, app) + .await + .expect("the redirect server must serve"); + }); + // Reach the redirect server via allowed.test on its own port. + let config = FetchConfig::builder() + .allow_http(true) + .allow_ports([redir_port, port]) + .allow_host_address("allowed.test", loopback) + .build() + .expect("valid config"); + let tool = WebFetch::with_lookup(config, lookup); + + let url = format!("http://allowed.test:{redir_port}/go"); + // The outcome may be a hard error (the redirect address is blocked) or a + // soft return, but it must never carry the internal target's body. + if let Ok(output) = tool.call(serde_json::json!({ "url": url })).await { + assert!( + !output.text().contains("reached the internal target"), + "the internal target body must never be returned" + ); + } + + assert_eq!( + hits.load(Ordering::SeqCst), + 0, + "the internal redirect target must never be contacted" + ); +} + +#[tokio::test] +async fn call_rejects_bad_urls_before_network() { + let tool = WebFetch::new(); + + let hard_cases = [ + ( + "https://user:pass@example.com/", + "url must not contain userinfo", + ), + ("https://example.com:8080/", "port not allowed: 8080"), + ("https://0177.0.0.1/", "ip literal host not allowed"), + ("https://2130706433/", "ip literal host not allowed"), + ("https://[::1]/", "ip literal host not allowed"), + ("https://127.1/", "ip literal host not allowed"), + ]; + + for (raw, reason) in hard_cases { + let err = tool + .call(serde_json::json!({ "url": raw })) + .await + .expect_err(&format!("expected {raw} to be refused before any network")); + assert!( + err.kind() == ToolErrorKind::InvalidArguments, + "expected a policy rejection for {raw}, got: {err:?}" + ); + assert!( + err.to_string().contains(reason), + "expected policy reason {reason:?} for {raw}, got: {err}" + ); + } + + let soft = tool + .call(serde_json::json!({ "url": "http://example.com/" })) + .await + .expect("blocked http scheme must be soft tool text") + .text() + .to_owned(); + assert!( + soft.contains("scheme not allowed: http"), + "expected soft scheme refusal, got: {soft}" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn soft_return_on_404() { + let (port, _hits) = spawn_server().await; + let tool = loopback_tool(port); + + let url = format!("http://localhost:{port}/notfound"); + let result = tool + .call(serde_json::json!({ "url": url })) + .await + .expect("a 404 must be a soft return") + .text() + .to_owned(); + + assert!(result.contains("404"), "got: {result}"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn soft_return_on_500() { + let (port, _hits) = spawn_server().await; + let tool = loopback_tool(port); + + let url = format!("http://localhost:{port}/error500"); + let result = tool + .call(serde_json::json!({ "url": url })) + .await + .expect("a 500 must be a soft return") + .text() + .to_owned(); + + assert!(result.contains("500"), "got: {result}"); +} + +#[tokio::test] +async fn blocked_url_still_hard_fails() { + let tool = WebFetch::new(); + + let err = tool + .call(serde_json::json!({ "url": "https://1.2.3.4/secret" })) + .await + .expect_err("a bare IP literal URL must still be a hard error"); + + assert!( + err.kind() == ToolErrorKind::InvalidArguments && err.to_string().contains("ip literal"), + "got: {err:?}" + ); +} diff --git a/crates/harness/webfetch/src/tool-tests.rs b/crates/harness/webfetch/src/tool-tests.rs new file mode 100644 index 000000000..cba6dd770 --- /dev/null +++ b/crates/harness/webfetch/src/tool-tests.rs @@ -0,0 +1,408 @@ +//! Fixtures and descriptor tests for [`WebFetch`]: the loopback article, +//! table, and JSON pages, the injected [`Lookup`] map, the mock servers +//! and their routes, and the loopback policy builders. The policy tests +//! (redirects, credentials, URL admission, status codes) and the body +//! tests (size caps, truncation, content types, charsets) live in the +//! child modules and share these fixtures. + +use std::io::Write; +use std::net::{IpAddr, SocketAddr}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; + +use axum::Router; +use axum::body::Body; +use axum::extract::State; +use axum::http::HeaderMap; +use axum::http::header::{CONTENT_ENCODING, CONTENT_LENGTH, CONTENT_TYPE}; +use axum::response::{Html, IntoResponse, Redirect, Response}; +use axum::routing::get; +use flate2::Compression; +use flate2::write::GzEncoder; +use harness_capabilities::Tool; +use harness_runner::spawn::spawn_tagged; +use harness_runner::test_support::mock_tag; +use promptforge_api_types::tools::ToolId; + +use super::WebFetch; +use crate::config::{FetchConfig, FetchConfigBuilder}; +use crate::resolver::{Lookup, LookupFuture}; + +/// An article page long enough for readability extraction to fire. +const ARTICLE_HTML: &str = r" + +
+

Loopback Test Article

+

This is the first substantial paragraph of a loopback test page, + deliberately long enough to be treated as real article content.

+

A second paragraph continues the prose so the extractor keeps the + body and the character count stays comfortably above threshold.

+
+ +"; + +/// An article whose prose is full of multibyte characters. +const UNICODE_HTML: &str = r" + +
+

Café Résumé Naïve

+

Café résumé naïve façade jalapeño piñata. Café résumé naïve façade + jalapeño piñata. Café résumé naïve façade jalapeño piñata.

+

Café résumé naïve façade jalapeño piñata. Café résumé naïve façade + jalapeño piñata. Café résumé naïve façade jalapeño piñata.

+
+ +"; + +/// An HTML table page that readability would discard. +const TABLE_HTML: &str = r" + +

Prices.

+ + + + +
ItemCost
WIDGETROW4.20
GADGETROW6.90
+ +"; + +/// A JSON document served with an `application/json` type. +const JSON_BODY: &str = r#"{"key":"value","numbers":[1,2,3],"nested":{"ok":true}}"#; + +/// A [`Lookup`] that maps host names to fixed addresses for injected tests. +struct MapLookup { + entries: Vec<(String, IpAddr)>, +} + +impl Lookup for MapLookup { + fn lookup(&self, host: String) -> LookupFuture { + let addrs: Vec = self + .entries + .iter() + .filter(|(h, _)| *h == host) + .map(|(_, ip)| SocketAddr::new(*ip, 0)) + .collect(); + Box::pin(async move { Ok(addrs) }) + } +} + +#[test] +fn descriptor_is_stable_and_faithful() { + let tool = WebFetch::new(); + + assert_eq!( + tool.id(), + ToolId::parse("promptforge/web/fetch").expect("valid id") + ); + assert_eq!(tool.wire_name(), "web_fetch"); + assert_eq!( + tool.description(), + "Fetch a web page and return its main content as markdown." + ); + let schema = tool.parameters_schema(); + assert_eq!(schema["properties"]["max_chars"]["maximum"], 40_000); + assert_eq!(schema["required"], serde_json::json!(["url"])); + assert_eq!(schema["properties"]["url"]["type"], "string"); +} + +#[test] +fn the_migrated_id_names_its_contributing_capability() { + // promptforge/web_fetch migrated to promptforge/web/fetch: dropping the + // last segment must yield the contributing capability's id. + let id = WebFetch::new().id(); + assert_eq!(id.name(), "fetch"); + assert_eq!( + id.capability(), + promptforge_api_types::capabilities::CapabilityId::parse("promptforge/web") + .expect("a valid capability id") + ); +} + +#[derive(Clone)] +struct AppState { + port: u16, + hits: Arc, +} + +async fn root() -> Html<&'static str> { + Html(ARTICLE_HTML) +} + +async fn redir(State(state): State) -> Redirect { + Redirect::temporary(&format!("http://127.0.0.1:{}/target", state.port)) +} + +async fn target(State(state): State) -> &'static str { + state.hits.fetch_add(1, Ordering::SeqCst); + "reached the internal target" +} + +async fn unicode() -> Html<&'static str> { + Html(UNICODE_HTML) +} + +async fn large() -> Html { + let filler = "x".repeat(200_000); + Html(format!("

{filler}

")) +} + +async fn gzip_bomb() -> impl IntoResponse { + let raw = "A".repeat(200_000); + let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); + encoder + .write_all(raw.as_bytes()) + .expect("writing to an in-memory gzip encoder must succeed"); + let compressed = encoder + .finish() + .expect("finishing an in-memory gzip encoder must succeed"); + ( + [(CONTENT_ENCODING, "gzip"), (CONTENT_TYPE, "text/html")], + compressed, + ) +} + +/// Declares a `Content-Length` far over any cap while its body never ends, +/// so `send` resolves on the headers and the precheck refuses on the +/// declared length alone. The tail never completes, so no timing crutch is +/// needed for the precheck to fire first. +async fn liar_content_length() -> Response { + use futures_util::StreamExt as _; + + let head = futures_util::stream::once(async { + Ok::<_, std::io::Error>("

x

") + }); + let tail = futures_util::stream::once(async { + std::future::pending::<()>().await; + Ok::<_, std::io::Error>("") + }); + Response::builder() + .header(CONTENT_LENGTH, "1000000") + .header(CONTENT_TYPE, "text/html") + .body(Body::from_stream(head.chain(tail))) + .expect("building the oversized-content-length response must succeed") +} + +async fn table() -> Response { + Response::builder() + .header(CONTENT_TYPE, "text/html; charset=utf-8") + .body(Body::from(TABLE_HTML)) + .expect("building the table html response must succeed") +} + +async fn json_route() -> Response { + Response::builder() + .header(CONTENT_TYPE, "application/json") + .body(Body::from(JSON_BODY)) + .expect("building the json response must succeed") +} + +async fn jsonbig_route() -> Response { + let filler = "x".repeat(200_000); + let body = format!(r#"{{"filler":"{filler}"}}"#); + Response::builder() + .header(CONTENT_TYPE, "application/json") + .body(Body::from(body)) + .expect("building the large json response must succeed") +} + +async fn badcharset_route() -> Response { + Response::builder() + .header(CONTENT_TYPE, "text/plain; charset=not-a-charset") + .body(Body::from("some plain body text")) + .expect("building the bad-charset response must succeed") +} + +async fn pdf_route() -> Response { + Response::builder() + .header(CONTENT_TYPE, "application/pdf") + .body(Body::from(&b"%PDF-1.4 not a real pdf"[..])) + .expect("building the pdf response must succeed") +} + +async fn octet_route() -> Response { + Response::builder() + .header(CONTENT_TYPE, "application/octet-stream") + .body(Body::from(vec![0u8, 1, 2, 3, 4, 5])) + .expect("building the octet-stream response must succeed") +} + +async fn notype_route() -> Response { + Response::builder() + .body(Body::from("a body with no declared content type")) + .expect("building the no-content-type response must succeed") +} + +async fn not_found_route() -> Response { + Response::builder() + .status(axum::http::StatusCode::NOT_FOUND) + .header(CONTENT_TYPE, "text/html") + .body(Body::from("Not Found")) + .expect("building the 404 response must succeed") +} + +async fn internal_error_route() -> Response { + Response::builder() + .status(axum::http::StatusCode::INTERNAL_SERVER_ERROR) + .header(CONTENT_TYPE, "text/html") + .body(Body::from("Server Error")) + .expect("building the 500 response must succeed") +} + +async fn latin1_route() -> Response { + let body = vec![b'C', b'a', b'f', 0xE9]; + Response::builder() + .header(CONTENT_TYPE, "text/plain; charset=ISO-8859-1") + .body(Body::from(body)) + .expect("building the latin-1 response must succeed") +} + +async fn plainbig_route() -> Response { + Response::builder() + .header(CONTENT_TYPE, "text/plain; charset=utf-8") + .body(Body::from("y".repeat(200_000))) + .expect("building the large text response must succeed") +} + +/// Serves a `text/plain` body that fails mid-stream: one chunk, then an I/O +/// error, so the client's body read fails deterministically without any +/// timing crutch. +async fn plain_broken_route() -> Response { + use futures_util::StreamExt as _; + + let head = futures_util::stream::once(async { Ok::<_, std::io::Error>("partial body ") }); + let boom = futures_util::stream::once(async { + Err::<&'static str, std::io::Error>(std::io::Error::other("mid-stream failure")) + }); + Response::builder() + .header(CONTENT_TYPE, "text/plain; charset=utf-8") + .body(Body::from_stream(head.chain(boom))) + .expect("building the broken plain response must succeed") +} + +async fn spawn_server() -> (u16, Arc) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("binding a loopback listener must succeed"); + let port = listener + .local_addr() + .expect("the listener must have a local address") + .port(); + let hits = Arc::new(AtomicUsize::new(0)); + let state = AppState { + port, + hits: Arc::clone(&hits), + }; + let app = Router::new() + .route("/", get(root)) + .route("/redir", get(redir)) + .route("/target", get(target)) + .route("/unicode", get(unicode)) + .route("/large", get(large)) + .route("/gzip", get(gzip_bomb)) + .route("/liar", get(liar_content_length)) + .route("/table", get(table)) + .route("/json", get(json_route)) + .route("/jsonbig", get(jsonbig_route)) + .route("/badcharset", get(badcharset_route)) + .route("/pdf", get(pdf_route)) + .route("/octet", get(octet_route)) + .route("/notype", get(notype_route)) + .route("/notfound", get(not_found_route)) + .route("/error500", get(internal_error_route)) + .route("/latin1", get(latin1_route)) + .route("/plainbig", get(plainbig_route)) + .route("/plainbroken", get(plain_broken_route)) + .with_state(state); + spawn_tagged(mock_tag(), async move { + axum::serve(listener, app) + .await + .expect("the loopback server must serve"); + }); + (port, hits) +} + +/// A builder that can reach the loopback server: http allowed, its port on +/// the allowlist, and `localhost` pinned to `127.0.0.1`. +fn loopback_builder(port: u16) -> FetchConfigBuilder { + let loopback: IpAddr = "127.0.0.1".parse().expect("loopback literal parses"); + FetchConfig::builder() + .allow_http(true) + .allow_ports([80, 443, port]) + .allow_host_address("localhost", loopback) +} + +/// The built loopback policy. +fn loopback_config(port: u16) -> FetchConfig { + loopback_builder(port) + .build() + .expect("loopback config is valid") +} + +/// Builds a `WebFetch` over the loopback policy. +fn loopback_tool(port: u16) -> WebFetch { + WebFetch::try_with_config(loopback_config(port)).expect("the loopback client builds") +} + +#[derive(Clone)] +struct RecordingState { + port: u16, + recorded: Arc>>, +} + +async fn record_headers( + State(state): State, + headers: HeaderMap, +) -> Html<&'static str> { + state + .recorded + .lock() + .expect("the recorded-headers mutex must not be poisoned") + .push(headers); + Html(ARTICLE_HTML) +} + +async fn redirect_to_record(State(state): State) -> Redirect { + Redirect::temporary(&format!("http://localhost:{}/record", state.port)) +} + +/// Never responds, so a short total timeout aborts the request. +async fn hang() -> Html<&'static str> { + std::future::pending::<()>().await; + Html(ARTICLE_HTML) +} + +async fn spawn_recording_server() -> (u16, Arc>>) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("binding a loopback listener must succeed"); + let port = listener + .local_addr() + .expect("the listener must have a local address") + .port(); + let recorded = Arc::new(Mutex::new(Vec::new())); + let state = RecordingState { + port, + recorded: Arc::clone(&recorded), + }; + let app = Router::new() + .route("/record", get(record_headers)) + .route("/redir-record", get(redirect_to_record)) + .route("/slow", get(hang)) + .with_state(state); + spawn_tagged(mock_tag(), async move { + axum::serve(listener, app) + .await + .expect("the loopback recording server must serve"); + }); + (port, recorded) +} + +fn split_header(out: &str) -> (&str, &str) { + out.split_once("\n\n") + .expect("the return must carry a header and a blank-line separator") +} + +#[path = "tool-tests-body.rs"] +mod body; +#[path = "tool-tests-policy.rs"] +mod policy; diff --git a/crates/harness/webfetch/src/tool.rs b/crates/harness/webfetch/src/tool.rs index 699f3d922..b0561e2be 100644 --- a/crates/harness/webfetch/src/tool.rs +++ b/crates/harness/webfetch/src/tool.rs @@ -405,1006 +405,5 @@ fn parse_raw(args: &serde_json::Value) -> Result { } #[cfg(test)] -mod tests { - use std::io::Write; - use std::net::{IpAddr, SocketAddr}; - use std::sync::atomic::{AtomicUsize, Ordering}; - use std::sync::{Arc, Mutex}; - - use axum::Router; - use axum::body::Body; - use axum::extract::State; - use axum::http::HeaderMap; - use axum::http::header::{CONTENT_ENCODING, CONTENT_LENGTH, CONTENT_TYPE}; - use axum::response::{Html, IntoResponse, Redirect, Response}; - use axum::routing::get; - use flate2::Compression; - use flate2::write::GzEncoder; - use harness_capabilities::Tool; - use harness_runner::spawn::spawn_tagged; - use harness_runner::test_support::mock_tag; - use promptforge_api_types::tools::{ToolErrorKind, ToolId}; - - use super::WebFetch; - use crate::config::{FetchConfig, FetchConfigBuilder}; - use crate::resolver::{Lookup, LookupFuture}; - - /// An article page long enough for readability extraction to fire. - const ARTICLE_HTML: &str = r" - -
-

Loopback Test Article

-

This is the first substantial paragraph of a loopback test page, - deliberately long enough to be treated as real article content.

-

A second paragraph continues the prose so the extractor keeps the - body and the character count stays comfortably above threshold.

-
- - "; - - /// An article whose prose is full of multibyte characters. - const UNICODE_HTML: &str = r" - -
-

Café Résumé Naïve

-

Café résumé naïve façade jalapeño piñata. Café résumé naïve façade - jalapeño piñata. Café résumé naïve façade jalapeño piñata.

-

Café résumé naïve façade jalapeño piñata. Café résumé naïve façade - jalapeño piñata. Café résumé naïve façade jalapeño piñata.

-
- - "; - - /// An HTML table page that readability would discard. - const TABLE_HTML: &str = r" - -

Prices.

- - - - -
ItemCost
WIDGETROW4.20
GADGETROW6.90
- - "; - - /// A JSON document served with an `application/json` type. - const JSON_BODY: &str = r#"{"key":"value","numbers":[1,2,3],"nested":{"ok":true}}"#; - - /// A [`Lookup`] that maps host names to fixed addresses for injected tests. - struct MapLookup { - entries: Vec<(String, IpAddr)>, - } - - impl Lookup for MapLookup { - fn lookup(&self, host: String) -> LookupFuture { - let addrs: Vec = self - .entries - .iter() - .filter(|(h, _)| *h == host) - .map(|(_, ip)| SocketAddr::new(*ip, 0)) - .collect(); - Box::pin(async move { Ok(addrs) }) - } - } - - #[test] - fn descriptor_is_stable_and_faithful() { - let tool = WebFetch::new(); - - assert_eq!( - tool.id(), - ToolId::parse("promptforge/web/fetch").expect("valid id") - ); - assert_eq!(tool.wire_name(), "web_fetch"); - assert_eq!( - tool.description(), - "Fetch a web page and return its main content as markdown." - ); - let schema = tool.parameters_schema(); - assert_eq!(schema["properties"]["max_chars"]["maximum"], 40_000); - assert_eq!(schema["required"], serde_json::json!(["url"])); - assert_eq!(schema["properties"]["url"]["type"], "string"); - } - - #[test] - fn the_migrated_id_names_its_contributing_capability() { - // promptforge/web_fetch migrated to promptforge/web/fetch: dropping the - // last segment must yield the contributing capability's id. - let id = WebFetch::new().id(); - assert_eq!(id.name(), "fetch"); - assert_eq!( - id.capability(), - promptforge_api_types::capabilities::CapabilityId::parse("promptforge/web") - .expect("a valid capability id") - ); - } - - #[derive(Clone)] - struct AppState { - port: u16, - hits: Arc, - } - - async fn root() -> Html<&'static str> { - Html(ARTICLE_HTML) - } - - async fn redir(State(state): State) -> Redirect { - Redirect::temporary(&format!("http://127.0.0.1:{}/target", state.port)) - } - - async fn target(State(state): State) -> &'static str { - state.hits.fetch_add(1, Ordering::SeqCst); - "reached the internal target" - } - - async fn unicode() -> Html<&'static str> { - Html(UNICODE_HTML) - } - - async fn large() -> Html { - let filler = "x".repeat(200_000); - Html(format!("

{filler}

")) - } - - async fn gzip_bomb() -> impl IntoResponse { - let raw = "A".repeat(200_000); - let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); - encoder - .write_all(raw.as_bytes()) - .expect("writing to an in-memory gzip encoder must succeed"); - let compressed = encoder - .finish() - .expect("finishing an in-memory gzip encoder must succeed"); - ( - [(CONTENT_ENCODING, "gzip"), (CONTENT_TYPE, "text/html")], - compressed, - ) - } - - /// Declares a `Content-Length` far over any cap while its body never ends, - /// so `send` resolves on the headers and the precheck refuses on the - /// declared length alone. The tail never completes, so no timing crutch is - /// needed for the precheck to fire first. - async fn liar_content_length() -> Response { - use futures_util::StreamExt as _; - - let head = futures_util::stream::once(async { - Ok::<_, std::io::Error>("

x

") - }); - let tail = futures_util::stream::once(async { - std::future::pending::<()>().await; - Ok::<_, std::io::Error>("") - }); - Response::builder() - .header(CONTENT_LENGTH, "1000000") - .header(CONTENT_TYPE, "text/html") - .body(Body::from_stream(head.chain(tail))) - .expect("building the oversized-content-length response must succeed") - } - - async fn table() -> Response { - Response::builder() - .header(CONTENT_TYPE, "text/html; charset=utf-8") - .body(Body::from(TABLE_HTML)) - .expect("building the table html response must succeed") - } - - async fn json_route() -> Response { - Response::builder() - .header(CONTENT_TYPE, "application/json") - .body(Body::from(JSON_BODY)) - .expect("building the json response must succeed") - } - - async fn jsonbig_route() -> Response { - let filler = "x".repeat(200_000); - let body = format!(r#"{{"filler":"{filler}"}}"#); - Response::builder() - .header(CONTENT_TYPE, "application/json") - .body(Body::from(body)) - .expect("building the large json response must succeed") - } - - async fn badcharset_route() -> Response { - Response::builder() - .header(CONTENT_TYPE, "text/plain; charset=not-a-charset") - .body(Body::from("some plain body text")) - .expect("building the bad-charset response must succeed") - } - - async fn pdf_route() -> Response { - Response::builder() - .header(CONTENT_TYPE, "application/pdf") - .body(Body::from(&b"%PDF-1.4 not a real pdf"[..])) - .expect("building the pdf response must succeed") - } - - async fn octet_route() -> Response { - Response::builder() - .header(CONTENT_TYPE, "application/octet-stream") - .body(Body::from(vec![0u8, 1, 2, 3, 4, 5])) - .expect("building the octet-stream response must succeed") - } - - async fn notype_route() -> Response { - Response::builder() - .body(Body::from("a body with no declared content type")) - .expect("building the no-content-type response must succeed") - } - - async fn not_found_route() -> Response { - Response::builder() - .status(axum::http::StatusCode::NOT_FOUND) - .header(CONTENT_TYPE, "text/html") - .body(Body::from("Not Found")) - .expect("building the 404 response must succeed") - } - - async fn internal_error_route() -> Response { - Response::builder() - .status(axum::http::StatusCode::INTERNAL_SERVER_ERROR) - .header(CONTENT_TYPE, "text/html") - .body(Body::from("Server Error")) - .expect("building the 500 response must succeed") - } - - async fn latin1_route() -> Response { - let body = vec![b'C', b'a', b'f', 0xE9]; - Response::builder() - .header(CONTENT_TYPE, "text/plain; charset=ISO-8859-1") - .body(Body::from(body)) - .expect("building the latin-1 response must succeed") - } - - async fn plainbig_route() -> Response { - Response::builder() - .header(CONTENT_TYPE, "text/plain; charset=utf-8") - .body(Body::from("y".repeat(200_000))) - .expect("building the large text response must succeed") - } - - /// Serves a `text/plain` body that fails mid-stream: one chunk, then an I/O - /// error, so the client's body read fails deterministically without any - /// timing crutch. - async fn plain_broken_route() -> Response { - use futures_util::StreamExt as _; - - let head = futures_util::stream::once(async { Ok::<_, std::io::Error>("partial body ") }); - let boom = futures_util::stream::once(async { - Err::<&'static str, std::io::Error>(std::io::Error::other("mid-stream failure")) - }); - Response::builder() - .header(CONTENT_TYPE, "text/plain; charset=utf-8") - .body(Body::from_stream(head.chain(boom))) - .expect("building the broken plain response must succeed") - } - - async fn spawn_server() -> (u16, Arc) { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .expect("binding a loopback listener must succeed"); - let port = listener - .local_addr() - .expect("the listener must have a local address") - .port(); - let hits = Arc::new(AtomicUsize::new(0)); - let state = AppState { - port, - hits: Arc::clone(&hits), - }; - let app = Router::new() - .route("/", get(root)) - .route("/redir", get(redir)) - .route("/target", get(target)) - .route("/unicode", get(unicode)) - .route("/large", get(large)) - .route("/gzip", get(gzip_bomb)) - .route("/liar", get(liar_content_length)) - .route("/table", get(table)) - .route("/json", get(json_route)) - .route("/jsonbig", get(jsonbig_route)) - .route("/badcharset", get(badcharset_route)) - .route("/pdf", get(pdf_route)) - .route("/octet", get(octet_route)) - .route("/notype", get(notype_route)) - .route("/notfound", get(not_found_route)) - .route("/error500", get(internal_error_route)) - .route("/latin1", get(latin1_route)) - .route("/plainbig", get(plainbig_route)) - .route("/plainbroken", get(plain_broken_route)) - .with_state(state); - spawn_tagged(mock_tag(), async move { - axum::serve(listener, app) - .await - .expect("the loopback server must serve"); - }); - (port, hits) - } - - /// A builder that can reach the loopback server: http allowed, its port on - /// the allowlist, and `localhost` pinned to `127.0.0.1`. - fn loopback_builder(port: u16) -> FetchConfigBuilder { - let loopback: IpAddr = "127.0.0.1".parse().expect("loopback literal parses"); - FetchConfig::builder() - .allow_http(true) - .allow_ports([80, 443, port]) - .allow_host_address("localhost", loopback) - } - - /// The built loopback policy. - fn loopback_config(port: u16) -> FetchConfig { - loopback_builder(port) - .build() - .expect("loopback config is valid") - } - - /// Builds a `WebFetch` over the loopback policy. - fn loopback_tool(port: u16) -> WebFetch { - WebFetch::try_with_config(loopback_config(port)).expect("the loopback client builds") - } - - #[derive(Clone)] - struct RecordingState { - port: u16, - recorded: Arc>>, - } - - async fn record_headers( - State(state): State, - headers: HeaderMap, - ) -> Html<&'static str> { - state - .recorded - .lock() - .expect("the recorded-headers mutex must not be poisoned") - .push(headers); - Html(ARTICLE_HTML) - } - - async fn redirect_to_record(State(state): State) -> Redirect { - Redirect::temporary(&format!("http://localhost:{}/record", state.port)) - } - - /// Never responds, so a short total timeout aborts the request. - async fn hang() -> Html<&'static str> { - std::future::pending::<()>().await; - Html(ARTICLE_HTML) - } - - async fn spawn_recording_server() -> (u16, Arc>>) { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .expect("binding a loopback listener must succeed"); - let port = listener - .local_addr() - .expect("the listener must have a local address") - .port(); - let recorded = Arc::new(Mutex::new(Vec::new())); - let state = RecordingState { - port, - recorded: Arc::clone(&recorded), - }; - let app = Router::new() - .route("/record", get(record_headers)) - .route("/redir-record", get(redirect_to_record)) - .route("/slow", get(hang)) - .with_state(state); - spawn_tagged(mock_tag(), async move { - axum::serve(listener, app) - .await - .expect("the loopback recording server must serve"); - }); - (port, recorded) - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn slow_server_past_total_timeout_yields_timeout() { - let (port, _recorded) = spawn_recording_server().await; - let config = loopback_builder(port) - .timeout(std::time::Duration::from_millis(200)) - .build() - .expect("valid config"); - let tool = WebFetch::try_with_config(config).expect("client builds"); - - let url = format!("http://localhost:{port}/slow"); - let result = tool - .call(serde_json::json!({ "url": url })) - .await - .expect("a timeout is a soft (recoverable) return") - .text() - .to_owned(); - - assert!( - result.contains("timed out"), - "expected a timeout message, got: {result}" - ); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn request_carries_no_cookie_or_credential() { - let (port, recorded) = spawn_recording_server().await; - let tool = loopback_tool(port); - - let url = format!("http://localhost:{port}/record"); - tool.call(serde_json::json!({ "url": url })) - .await - .expect("a loopback fetch through allow_exact must succeed"); - - let recorded = recorded - .lock() - .expect("the recorded-headers mutex must not be poisoned"); - assert_eq!(recorded.len(), 1); - let headers = &recorded[0]; - assert!(!headers.contains_key(axum::http::header::COOKIE)); - assert!(!headers.contains_key(axum::http::header::AUTHORIZATION)); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn no_credential_or_referer_survives_a_redirect() { - let (port, recorded) = spawn_recording_server().await; - let tool = loopback_tool(port); - - // A query-bearing source URL redirecting to a distinct allowed path: the - // target must receive no Cookie, Authorization, or Referer. - let url = format!("http://localhost:{port}/redir-record?secret=leak-me"); - tool.call(serde_json::json!({ "url": url })) - .await - .expect("a redirect between loopback paths must succeed"); - - let recorded = recorded - .lock() - .expect("the recorded-headers mutex must not be poisoned"); - assert_eq!(recorded.len(), 1); - let headers = &recorded[0]; - assert!(!headers.contains_key(axum::http::header::COOKIE)); - assert!(!headers.contains_key(axum::http::header::AUTHORIZATION)); - assert!( - !headers.contains_key(axum::http::header::REFERER), - "no Referer may survive a redirect, got: {headers:?}" - ); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn fetch_returns_provenance_line_then_content() { - let (port, _hits) = spawn_server().await; - let tool = loopback_tool(port); - - let url = format!("http://localhost:{port}/"); - let out = tool - .call(serde_json::json!({ "url": url })) - .await - .expect("a loopback fetch through allow_exact must succeed") - .text() - .to_owned(); - - let expected = format!("url: http://localhost:{port}/"); - assert!(out.starts_with(&expected), "got: {out}"); - assert!(out.contains("substantial paragraph"), "got: {out}"); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn redirect_to_internal_is_refused_and_target_untouched() { - let (port, hits) = spawn_server().await; - let tool = loopback_tool(port); - - let url = format!("http://localhost:{port}/redir"); - let result = tool - .call(serde_json::json!({ "url": url })) - .await - .expect("a redirect-target policy refusal is a soft (recoverable) return") - .text() - .to_owned(); - - assert!( - result.contains("refused") && result.contains("127.0.0.1"), - "got: {result}" - ); - assert_eq!(hits.load(Ordering::SeqCst), 0); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn redirect_to_internal_via_injected_lookup_never_contacts_target() { - let (port, hits) = spawn_server().await; - let loopback: IpAddr = "127.0.0.1".parse().expect("loopback parses"); - let blocked: IpAddr = "10.0.0.1".parse().expect("private parses"); - // allowed.test reaches the loopback server; internal.test resolves only - // to a blocked address and carries no exact exception. - let lookup = MapLookup { - entries: vec![ - ("allowed.test".to_string(), loopback), - ("internal.test".to_string(), blocked), - ], - }; - // The server redirects to internal.test, which resolves only to a - // blocked address, so the redirected target is never contacted. - let redir_state = AppState { - port, - hits: Arc::clone(&hits), - }; - let redir_listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .expect("binding a second loopback listener must succeed"); - let redir_port = redir_listener.local_addr().expect("local addr").port(); - let app = Router::new() - .route( - "/go", - get(move || async move { - Redirect::temporary(&format!("http://internal.test:{port}/target")) - }), - ) - .with_state(redir_state); - spawn_tagged(mock_tag(), async move { - axum::serve(redir_listener, app) - .await - .expect("the redirect server must serve"); - }); - // Reach the redirect server via allowed.test on its own port. - let config = FetchConfig::builder() - .allow_http(true) - .allow_ports([redir_port, port]) - .allow_host_address("allowed.test", loopback) - .build() - .expect("valid config"); - let tool = WebFetch::with_lookup(config, lookup); - - let url = format!("http://allowed.test:{redir_port}/go"); - // The outcome may be a hard error (the redirect address is blocked) or a - // soft return, but it must never carry the internal target's body. - if let Ok(output) = tool.call(serde_json::json!({ "url": url })).await { - assert!( - !output.text().contains("reached the internal target"), - "the internal target body must never be returned" - ); - } - - assert_eq!( - hits.load(Ordering::SeqCst), - 0, - "the internal redirect target must never be contacted" - ); - } - - #[tokio::test] - async fn call_rejects_bad_urls_before_network() { - let tool = WebFetch::new(); - - let hard_cases = [ - ( - "https://user:pass@example.com/", - "url must not contain userinfo", - ), - ("https://example.com:8080/", "port not allowed: 8080"), - ("https://0177.0.0.1/", "ip literal host not allowed"), - ("https://2130706433/", "ip literal host not allowed"), - ("https://[::1]/", "ip literal host not allowed"), - ("https://127.1/", "ip literal host not allowed"), - ]; - - for (raw, reason) in hard_cases { - let err = tool - .call(serde_json::json!({ "url": raw })) - .await - .expect_err(&format!("expected {raw} to be refused before any network")); - assert!( - err.kind() == ToolErrorKind::InvalidArguments, - "expected a policy rejection for {raw}, got: {err:?}" - ); - assert!( - err.to_string().contains(reason), - "expected policy reason {reason:?} for {raw}, got: {err}" - ); - } - - let soft = tool - .call(serde_json::json!({ "url": "http://example.com/" })) - .await - .expect("blocked http scheme must be soft tool text") - .text() - .to_owned(); - assert!( - soft.contains("scheme not allowed: http"), - "expected soft scheme refusal, got: {soft}" - ); - } - - fn split_header(out: &str) -> (&str, &str) { - out.split_once("\n\n") - .expect("the return must carry a header and a blank-line separator") - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn oversized_html_is_refused() { - let (port, _hits) = spawn_server().await; - let config = loopback_builder(port) - .max_bytes(4096) - .build() - .expect("valid"); - let tool = WebFetch::try_with_config(config).expect("client builds"); - - let url = format!("http://localhost:{port}/large"); - let result = tool - .call(serde_json::json!({ "url": url })) - .await - .expect("an oversized HTML body is a soft return") - .text() - .to_owned(); - - assert!( - result.contains("exceeds") && result.contains("4096"), - "got: {result}" - ); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn declared_content_length_over_cap_is_refused_before_read() { - let (port, _hits) = spawn_server().await; - let config = loopback_builder(port) - .max_bytes(4096) - .build() - .expect("valid"); - let tool = WebFetch::try_with_config(config).expect("client builds"); - - let url = format!("http://localhost:{port}/liar"); - let result = tool - .call(serde_json::json!({ "url": url })) - .await - .expect("a declared Content-Length over the cap is a soft return") - .text() - .to_owned(); - - assert!( - result.contains("exceeds") && result.contains("4096"), - "got: {result}" - ); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn gzip_bomb_refused_on_decompressed_count() { - let (port, _hits) = spawn_server().await; - let config = loopback_builder(port) - .max_bytes(4096) - .build() - .expect("valid"); - let tool = WebFetch::try_with_config(config).expect("client builds"); - - let url = format!("http://localhost:{port}/gzip"); - let result = tool - .call(serde_json::json!({ "url": url })) - .await - .expect("a gzip body that decompresses past the cap is a soft return") - .text() - .to_owned(); - - assert!(result.contains("exceeds"), "got: {result}"); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn text_over_max_chars_is_truncated_on_char_boundary() { - let (port, _hits) = spawn_server().await; - let tool = loopback_tool(port); - - let max_chars = 25usize; - let url = format!("http://localhost:{port}/unicode"); - let out = tool - .call(serde_json::json!({ "url": url, "max_chars": max_chars })) - .await - .expect("a unicode fetch through allow_exact must succeed") - .text() - .to_owned(); - - let (header, body) = split_header(&out); - assert!(header.contains("truncated: true"), "got: {header}"); - assert_eq!(body.chars().count(), max_chars, "got: {body:?}"); - assert!( - body.contains('é') || body.contains('ï') || body.contains('ç'), - "got: {body:?}" - ); - assert!(!body.contains('\u{FFFD}'), "got: {body:?}"); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn per_call_max_chars_is_clamped_to_the_configured_ceiling() { - let (port, _hits) = spawn_server().await; - // A tiny ceiling: a huge per-call request must be clamped to it. - let config = loopback_builder(port).max_chars(10).build().expect("valid"); - let tool = WebFetch::try_with_config(config).expect("client builds"); - - let url = format!("http://localhost:{port}/plainbig"); - let out = tool - .call(serde_json::json!({ "url": url, "max_chars": 1_000_000 })) - .await - .expect("a plain fetch must succeed") - .text() - .to_owned(); - - let (header, body) = split_header(&out); - assert!(header.contains("truncated: true"), "got: {header}"); - assert_eq!( - body.chars().count(), - 10, - "the per-call max_chars must be clamped to the ceiling, got {} chars", - body.chars().count() - ); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn body_one_byte_under_cap_succeeds_untruncated() { - let (port, _hits) = spawn_server().await; - let config = loopback_builder(port) - .max_bytes(ARTICLE_HTML.len() + 1) - .build() - .expect("valid"); - let tool = WebFetch::try_with_config(config).expect("client builds"); - - let url = format!("http://localhost:{port}/"); - let out = tool - .call(serde_json::json!({ "url": url })) - .await - .expect("a body one byte under the cap must be accepted") - .text() - .to_owned(); - - let (header, body) = split_header(&out); - assert!(header.contains("truncated: false"), "got: {header}"); - assert!(body.contains("substantial paragraph"), "got: {body}"); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn html_is_extracted_and_reports_readability() { - let (port, _hits) = spawn_server().await; - let tool = loopback_tool(port); - - let url = format!("http://localhost:{port}/"); - let out = tool - .call(serde_json::json!({ "url": url })) - .await - .expect("a loopback html fetch must succeed") - .text() - .to_owned(); - - let (header, body) = split_header(&out); - assert!(header.contains("extraction: readability"), "got: {header}"); - assert!(body.contains("substantial paragraph"), "got: {body}"); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn raw_forces_whole_page_render_keeping_table() { - let (port, _hits) = spawn_server().await; - let tool = loopback_tool(port); - - let url = format!("http://localhost:{port}/table"); - let out = tool - .call(serde_json::json!({ "url": url, "raw": true })) - .await - .expect("a raw table fetch must succeed") - .text() - .to_owned(); - - let (header, body) = split_header(&out); - assert!(header.contains("extraction: raw-html"), "got: {header}"); - assert!( - body.contains("WIDGETROW") && body.contains("GADGETROW"), - "got: {body}" - ); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn json_is_returned_verbatim_as_plain() { - let (port, _hits) = spawn_server().await; - let tool = loopback_tool(port); - - let url = format!("http://localhost:{port}/json"); - let out = tool - .call(serde_json::json!({ "url": url })) - .await - .expect("a json fetch must succeed") - .text() - .to_owned(); - - let (header, body) = split_header(&out); - assert!(header.contains("extraction: plain"), "got: {header}"); - assert_eq!(body, JSON_BODY, "got: {body}"); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn oversized_json_is_hard_refused_not_truncated() { - let (port, _hits) = spawn_server().await; - let config = loopback_builder(port) - .max_bytes(4096) - .build() - .expect("valid"); - let tool = WebFetch::try_with_config(config).expect("client builds"); - - let url = format!("http://localhost:{port}/jsonbig"); - let result = tool - .call(serde_json::json!({ "url": url })) - .await - .expect("an oversized json body is a soft return") - .text() - .to_owned(); - - assert!( - result.contains("exceeds") && result.contains("4096"), - "got: {result}" - ); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn flat_text_body_read_failure_is_soft() { - let (port, _hits) = spawn_server().await; - let tool = loopback_tool(port); - - let url = format!("http://localhost:{port}/plainbroken"); - // The mid-stream failure must be a soft (recoverable) return, never a - // hard error: identical to the HTML and structured routes. A `text()` - // return proves the outcome was soft untrusted output. - let outcome = tool - .call(serde_json::json!({ "url": url })) - .await - .expect("a mid-stream flat-text failure must be a soft return, not a hard error"); - assert_eq!( - outcome.trust(), - promptforge_api_types::tools::OutputTrust::Untrusted, - "a soft body-read failure must be untrusted output" - ); - let result = outcome.text().to_owned(); - assert!( - result.contains("failed to read the response body") || result.contains("network error"), - "got: {result}" - ); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn unrecognized_charset_is_refused_naming_the_label() { - let (port, _hits) = spawn_server().await; - let tool = loopback_tool(port); - - let url = format!("http://localhost:{port}/badcharset"); - let result = tool - .call(serde_json::json!({ "url": url })) - .await - .expect("an unrecognized charset is a soft return") - .text() - .to_owned(); - - assert!(result.contains("not-a-charset"), "got: {result}"); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn pdf_is_refused_naming_the_type() { - let (port, _hits) = spawn_server().await; - let tool = loopback_tool(port); - - let url = format!("http://localhost:{port}/pdf"); - let result = tool - .call(serde_json::json!({ "url": url })) - .await - .expect("a pdf response is a soft return") - .text() - .to_owned(); - - assert!(result.contains("application/pdf"), "got: {result}"); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn octet_stream_is_refused() { - let (port, _hits) = spawn_server().await; - let tool = loopback_tool(port); - - let url = format!("http://localhost:{port}/octet"); - let result = tool - .call(serde_json::json!({ "url": url })) - .await - .expect("an octet-stream response is a soft return") - .text() - .to_owned(); - - assert!(result.contains("application/octet-stream"), "got: {result}"); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn absent_content_type_is_refused() { - let (port, _hits) = spawn_server().await; - let tool = loopback_tool(port); - - let url = format!("http://localhost:{port}/notype"); - let result = tool - .call(serde_json::json!({ "url": url })) - .await - .expect("an absent content type is a soft return") - .text() - .to_owned(); - - assert!(result.contains("no content type"), "got: {result}"); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn latin1_page_decodes_with_declared_charset() { - let (port, _hits) = spawn_server().await; - let tool = loopback_tool(port); - - let url = format!("http://localhost:{port}/latin1"); - let out = tool - .call(serde_json::json!({ "url": url })) - .await - .expect("a latin-1 fetch must succeed") - .text() - .to_owned(); - - let (header, body) = split_header(&out); - assert!(header.contains("extraction: plain"), "got: {header}"); - assert!(body.contains('é'), "got: {body:?}"); - assert!(!body.contains('\u{FFFD}'), "got: {body:?}"); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn plain_text_over_cap_is_truncated_not_refused() { - let (port, _hits) = spawn_server().await; - let config = loopback_builder(port) - .max_bytes(4096) - .build() - .expect("valid"); - let tool = WebFetch::try_with_config(config).expect("client builds"); - - let url = format!("http://localhost:{port}/plainbig"); - let out = tool - .call(serde_json::json!({ "url": url })) - .await - .expect("an oversized flat-text body must be truncated, not refused") - .text() - .to_owned(); - - let (header, body) = split_header(&out); - assert!(header.contains("truncated: true"), "got: {header}"); - assert!(header.contains("extraction: plain"), "got: {header}"); - assert_eq!(body.len(), 4096, "got {} bytes", body.len()); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn soft_return_on_404() { - let (port, _hits) = spawn_server().await; - let tool = loopback_tool(port); - - let url = format!("http://localhost:{port}/notfound"); - let result = tool - .call(serde_json::json!({ "url": url })) - .await - .expect("a 404 must be a soft return") - .text() - .to_owned(); - - assert!(result.contains("404"), "got: {result}"); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn soft_return_on_500() { - let (port, _hits) = spawn_server().await; - let tool = loopback_tool(port); - - let url = format!("http://localhost:{port}/error500"); - let result = tool - .call(serde_json::json!({ "url": url })) - .await - .expect("a 500 must be a soft return") - .text() - .to_owned(); - - assert!(result.contains("500"), "got: {result}"); - } - - #[tokio::test] - async fn blocked_url_still_hard_fails() { - let tool = WebFetch::new(); - - let err = tool - .call(serde_json::json!({ "url": "https://1.2.3.4/secret" })) - .await - .expect_err("a bare IP literal URL must still be a hard error"); - - assert!( - err.kind() == ToolErrorKind::InvalidArguments && err.to_string().contains("ip literal"), - "got: {err:?}" - ); - } -} +#[path = "tool-tests.rs"] +mod tests; diff --git a/crates/promptforge-api-runtime/AGENTS.md b/crates/promptforge-api-runtime/AGENTS.md index 4a6aabd1b..e7086fde2 100644 --- a/crates/promptforge-api-runtime/AGENTS.md +++ b/crates/promptforge-api-runtime/AGENTS.md @@ -1,9 +1,8 @@ # promptforge-api-runtime -This crate owns PromptForge document execution and run orchestration. +This crate owns PromptForge document execution and run orchestration. The one-door rule that makes it, with `promptforge-api-types`, the only promptforge-* dependency an outside crate may name is stated in the root `AGENTS.md` and enforced by `cargo test -p build-xtask`. - Historical `promptforge_api_runtime` compatibility paths are verbatim re-exports from the owning crates. Do not create new compatibility vocabulary here. -- Concrete providers stay in their provider crates. Core may re-export them under a historical path but never reacquires provider implementation. +- Concrete providers stay in their provider crates. `promptforge-api-runtime` may re-export them under a historical path but never reacquires provider implementation. - Store access is decided only by the executor: every `Access` handle is minted from the chain's claims inside the engine; a host performing a `Store` effect uses the handle it was given and never derives, widens, or retains store scope. - The executor imports parser, Lua, model-client, store, tool, and host-support vocabulary from the private crates under `crates/promptforge/`. Those crates never depend on this executor. -- One door: this crate and `promptforge-api-types` are the only promptforge-* dependencies an outside crate (workshop-*, gateway-*, shared-*, build-*) may name. The crates under `crates/promptforge/` are private to the family, this crate is the only outside crate permitted to depend into the container, and `cargo test -p build-xtask` enforces the boundary. diff --git a/crates/promptforge-api-runtime/src/error.rs b/crates/promptforge-api-runtime/src/error.rs index cc92ccb6b..11c6cc569 100644 --- a/crates/promptforge-api-runtime/src/error.rs +++ b/crates/promptforge-api-runtime/src/error.rs @@ -261,9 +261,11 @@ pub(crate) enum Error { /// /// Carries a typed [`crate::subst::SubstitutionError`] with a stable kind, /// the byte offset of the offending placeholder, a bounded preview, and any - /// preserved serialization source, rather than a flattened string. - #[error("{0}")] - Substitution(#[source] Box), + /// preserved serialization source, rather than a flattened string. The + /// substitution error is the whole message and its cause chain, so the + /// variant is transparent over it. + #[error(transparent)] + Substitution(Box), /// The tool-call loop ran its iteration cap without a final text reply. #[error("tool-call loop did not converge")] @@ -431,7 +433,7 @@ pub(crate) enum Error { /// The host's input broker failed a `user_input` request: the wait /// ended in failure rather than an answer or the unavailable fallback, /// so the call raises this typed error at its Lua call site. - #[error("user input failed: {message}")] + #[error("user input request was not answered: {message}")] Input { /// The broker's host-authored, model-safe failure message. message: String, @@ -443,8 +445,9 @@ pub(crate) enum Error { /// A run-scoped store operation failed at the virtual filesystem layer, /// retaining the concrete [`shared_vfs::VfsError`] as the `#[source]` /// cause so a backend failure survives the public wrappers instead of - /// being flattened to a string. - #[error("store operation failed: {0}")] + /// being flattened to a string. The message names only the operation; + /// a renderer that wants the backend's diagnosis walks `source()`. + #[error("store operation failed")] Store(#[source] shared_vfs::VfsError), /// Two live execution identities claimed one store path: the claims @@ -494,7 +497,7 @@ impl Error { } } - /// Wrap an `mlua` failure as [`Error::LuaRuntime`], preserving it as the + /// Wraps an `mlua` failure as [`Error::LuaRuntime`], preserving it as the /// `#[source]` cause (F4) rather than flattening it to a string. #[cfg(test)] pub(crate) fn lua(source: mlua::Error) -> Error { diff --git a/crates/promptforge-api-runtime/src/execute/config.rs b/crates/promptforge-api-runtime/src/execute/config.rs index 700088c94..e4905915f 100644 --- a/crates/promptforge-api-runtime/src/execute/config.rs +++ b/crates/promptforge-api-runtime/src/execute/config.rs @@ -7,6 +7,7 @@ use std::sync::Arc; #[path = "config-limits.rs"] mod limits; +use promptforge_api_types::emitter::DebugMode; use promptforge_api_types::replay::Flags; use promptforge_api_types::timestamp::Timestamp; @@ -76,7 +77,7 @@ pub struct RunContext { /// bodies already travel in the `Chat` effect and its answer, so a host /// that logs effects has them, and the events are for a host that /// wants the pair in the event stream too. - pub(crate) report_debug: bool, + pub(crate) report_debug: DebugMode, /// The run's cancel flag: minted once at construction, replaced by /// [`cancel`](RunContext::cancel), and shared from here by every /// section VM's instruction hook and the run's own `cancel`, so one @@ -140,7 +141,7 @@ impl RunContext { started_at, provenance_start: 0, depth: 0, - report_debug: false, + report_debug: DebugMode::Off, cancel: CancelHandle::new(), limits: RunLimits::new(), ui: None, @@ -157,11 +158,11 @@ impl RunContext { /// Sets whether the run reports each model round's raw request and /// response bodies as `Request` and `Response` events. The default - /// (`false`) reports neither; a host that wants the pair in the event - /// stream (a debug capture) turns it on. + /// ([`DebugMode::Off`]) reports neither; a host that wants the pair in + /// the event stream (a debug capture) passes [`DebugMode::On`]. #[must_use] - pub fn report_debug(mut self, report: bool) -> RunContext { - self.report_debug = report; + pub fn report_debug(mut self, mode: DebugMode) -> RunContext { + self.report_debug = mode; self } @@ -365,7 +366,7 @@ impl RunContext { mut self, debug: Arc, ) -> RunContext { - self.report_debug = true; + self.report_debug = DebugMode::On; self.test_host = self.test_host.debug(debug); self } diff --git a/crates/promptforge-api-runtime/src/execute/context-tests.rs b/crates/promptforge-api-runtime/src/execute/context-tests.rs index 4f7b1f4c0..99c096b72 100644 --- a/crates/promptforge-api-runtime/src/execute/context-tests.rs +++ b/crates/promptforge-api-runtime/src/execute/context-tests.rs @@ -1,3 +1,5 @@ +//! Tests for `RunContext` construction, forking, and task sequence seeding. + use super::*; fn test_prompt() -> Prompt { diff --git a/crates/promptforge-api-runtime/src/execute/context.rs b/crates/promptforge-api-runtime/src/execute/context.rs index 93cc0be7d..d802b671b 100644 --- a/crates/promptforge-api-runtime/src/execute/context.rs +++ b/crates/promptforge-api-runtime/src/execute/context.rs @@ -114,7 +114,7 @@ pub(crate) struct RunState { /// The run's host-state snapshot; its presence is the Agent-window /// context (the `ui()` global plus raw-id `models.get`). ui: Option>, - /// Test-only: install the raw protocol shims (`models.chat`, + /// Test-only: installs the raw protocol shims (`models.chat`, /// `tools.call_as_model`) in every section VM, so a fixture section /// can yield one raw `chat` round or one model-issued `tool_call` at /// the scheduler's dispatch arms without going through a loop shim. diff --git a/crates/promptforge-api-runtime/src/execute/scheduler.rs b/crates/promptforge-api-runtime/src/execute/scheduler.rs index 84f50ae28..d17a29608 100644 --- a/crates/promptforge-api-runtime/src/execute/scheduler.rs +++ b/crates/promptforge-api-runtime/src/execute/scheduler.rs @@ -71,7 +71,7 @@ mod step; mod task_events; mod tasks; #[cfg(test)] -mod test_hooks; +pub(crate) mod test_hooks; mod timer; mod tool_call; mod waits; @@ -97,8 +97,6 @@ use super::section_context::{SectionContext, TaskSeed}; use await_tasks::AwaitTasks; use pending::{Continuation, Pending, ToolCallContinuation}; use tasks::TaskSlot; -#[cfg(test)] -pub(crate) use tasks::TaskState; /// Where a sibling slice sits in the prompt tree: the index of each /// ancestor section from the top level down to the slice's parent. The diff --git a/crates/promptforge-api-runtime/src/execute/scheduler/builtins.rs b/crates/promptforge-api-runtime/src/execute/scheduler/builtins.rs index 29efedd85..753889b57 100644 --- a/crates/promptforge-api-runtime/src/execute/scheduler/builtins.rs +++ b/crates/promptforge-api-runtime/src/execute/scheduler/builtins.rs @@ -35,6 +35,7 @@ use std::fmt::Write as _; use std::sync::atomic::Ordering; use promptforge_api_types::ids::{TaskId, TaskOrigin}; +use promptforge_api_types::tools::OutputTrust; use serde_json::Value; use crate::execute::protocol::{Answer, TaskStatus, ToolCallOutcome}; @@ -125,8 +126,8 @@ pub(super) struct BuiltinAnswer { pub(super) ok: bool, /// Whether `text` is the engine's own (every answer but a history /// read's, whose events carry model, tool, and user text and arrive - /// nonce-wrapped). - pub(super) trusted: bool, + /// nonce-wrapped as [`OutputTrust::Untrusted`]). + pub(super) trust: OutputTrust, pub(super) started: Option, } @@ -135,7 +136,7 @@ impl BuiltinAnswer { Self { text, ok: true, - trusted: true, + trust: OutputTrust::Trusted, started: None, } } @@ -146,7 +147,7 @@ impl BuiltinAnswer { Self { text, ok: true, - trusted: false, + trust: OutputTrust::Untrusted, started: None, } } @@ -155,7 +156,7 @@ impl BuiltinAnswer { Self { text, ok: false, - trusted: true, + trust: OutputTrust::Trusted, started: None, } } @@ -287,7 +288,7 @@ impl Scheduler { call_id, name, &answer.text, - answer.trusted, + answer.trust, ); Answer::ToolCallResult(Ok(ToolCallOutcome::Plain(answer.text))) } @@ -345,7 +346,7 @@ impl Scheduler { Ok((task, child)) => Ok(BuiltinAnswer { text: format!("Task id={task} started"), ok: true, - trusted: true, + trust: OutputTrust::Trusted, started: Some(child), }), Err(error) => Ok(BuiltinAnswer::refused(format!("task: {error}"))), diff --git a/crates/promptforge-api-runtime/src/execute/scheduler/dispatch.rs b/crates/promptforge-api-runtime/src/execute/scheduler/dispatch.rs index 4a09ebfb1..b59d53343 100644 --- a/crates/promptforge-api-runtime/src/execute/scheduler/dispatch.rs +++ b/crates/promptforge-api-runtime/src/execute/scheduler/dispatch.rs @@ -74,7 +74,10 @@ fn store_observations(op: &StoreOp) -> Option<(Lifecycle, Lifecycle)> { lifecycle::STORE_GLOB_SUCCEEDED, lifecycle::STORE_GLOB_FAILED, ), - StoreOp::Exists { .. } => return None, + // `exists` reports nothing, and so does any op `promptforge-lua` + // adds behind its `#[non_exhaustive]` `StoreOp` before this crate + // names it. + _ => return None, }; Some(pair) } diff --git a/crates/promptforge-api-runtime/src/execute/scheduler/step.rs b/crates/promptforge-api-runtime/src/execute/scheduler/step.rs index 11be1bf10..71a971d04 100644 --- a/crates/promptforge-api-runtime/src/execute/scheduler/step.rs +++ b/crates/promptforge-api-runtime/src/execute/scheduler/step.rs @@ -33,14 +33,14 @@ impl Scheduler { /// What the chain does next, decided under the chain borrow so the /// action phase can touch the scheduler's other fields. enum Advance { - /// Resume the suspended coroutine with its delivered answer. + /// Resumes the suspended coroutine with its delivered answer. Resume(Thread, Answer), /// The chain is between sections: enter the next section, or /// end the chain when the slice is exhausted. EnterSection, - /// Start the current Lua block as a fresh coroutine. + /// Starts the current Lua block as a fresh coroutine. StartLua, - /// Stash the current prose block as the pending Markdown buffer + /// Stashes the current prose block as the pending Markdown buffer /// the next Lua fence consumes. StashProse, /// The section's blocks are exhausted: fall through. diff --git a/crates/promptforge-api-runtime/src/execute/scheduler/tasks.rs b/crates/promptforge-api-runtime/src/execute/scheduler/tasks.rs index 734ddc6c2..9148f9645 100644 --- a/crates/promptforge-api-runtime/src/execute/scheduler/tasks.rs +++ b/crates/promptforge-api-runtime/src/execute/scheduler/tasks.rs @@ -404,10 +404,19 @@ impl Scheduler { } }); match origin { - TaskOrigin::Author => leaked.push(task), TaskOrigin::Model => { self.queue_task_notice(owner, &task, &target, TaskEnd::Abandoned(reason)); } + // `Author`, or an origin `promptforge-api-types` adds behind + // its `#[non_exhaustive]` `TaskOrigin`: treated as the + // author's. Both origins as they stand today are driven + // through this arm by + // `an_ending_owner_leaks_its_author_task_and_never_its_model_task`, + // which is the whole of the guarantee: a variant added to + // `TaskOrigin` lands here silently until someone extends + // that test, because `#[non_exhaustive]` denies this crate + // the exhaustive match that would fail the build instead. + _ => leaked.push(task), } } leaked diff --git a/crates/promptforge-api-runtime/src/execute/scheduler/test_hooks.rs b/crates/promptforge-api-runtime/src/execute/scheduler/test_hooks.rs index 986719ebc..7644704d3 100644 --- a/crates/promptforge-api-runtime/src/execute/scheduler/test_hooks.rs +++ b/crates/promptforge-api-runtime/src/execute/scheduler/test_hooks.rs @@ -5,7 +5,8 @@ use promptforge_api_types::ids::TaskId; -use super::{Scheduler, TaskState}; +use super::Scheduler; +pub(crate) use super::tasks::TaskState; impl Scheduler { /// Shrinks the chain-count bound so a test can drive the diff --git a/crates/promptforge-api-runtime/src/execute/scheduler/tool_call.rs b/crates/promptforge-api-runtime/src/execute/scheduler/tool_call.rs index 21120b21d..f721145c8 100644 --- a/crates/promptforge-api-runtime/src/execute/scheduler/tool_call.rs +++ b/crates/promptforge-api-runtime/src/execute/scheduler/tool_call.rs @@ -29,6 +29,7 @@ use crate::lua::{ScriptReport, SectionVm, ToolCallCounts, current_tool_bindings} use crate::{Error, Result}; use promptforge_api_types::emitter::Emitter; use promptforge_api_types::event::lifecycle; +use promptforge_api_types::tools::OutputTrust; use super::builtins::is_task_builtin; use super::dispatch::unbound_tool_call; @@ -107,7 +108,7 @@ fn answer_local_tool( call_id.unwrap_or(""), alias, &text, - true, + OutputTrust::Trusted, ); Ok(ToolCallOutcome::Plain(text)) } diff --git a/crates/promptforge-api-runtime/src/execute/section_vm.rs b/crates/promptforge-api-runtime/src/execute/section_vm.rs index e9d5e0bd1..52995f96b 100644 --- a/crates/promptforge-api-runtime/src/execute/section_vm.rs +++ b/crates/promptforge-api-runtime/src/execute/section_vm.rs @@ -85,7 +85,7 @@ pub(crate) struct SectionVmSetup<'a> { /// `ui()` global and the raw-id `models.get` fallback. Shared through /// the run's `Arc`, so every section VM serializes the one tree. pub(crate) ui: Option<&'a Arc>, - /// Test-only: install the raw protocol shims (`models.chat`, + /// Test-only: installs the raw protocol shims (`models.chat`, /// `tools.call_as_model`), so a fixture section can yield one raw /// `chat` round or one model-issued `tool_call`. #[cfg(test)] diff --git a/crates/promptforge-api-runtime/src/execute/tests/mod.rs b/crates/promptforge-api-runtime/src/execute/tests.rs similarity index 98% rename from crates/promptforge-api-runtime/src/execute/tests/mod.rs rename to crates/promptforge-api-runtime/src/execute/tests.rs index d6516ca11..b3ea6613e 100644 --- a/crates/promptforge-api-runtime/src/execute/tests/mod.rs +++ b/crates/promptforge-api-runtime/src/execute/tests.rs @@ -21,7 +21,7 @@ use crate::lua::{LuaProgram, SectionVm, current_tool_bindings}; use crate::model::{ModelDescriptor, ModelId, ModelSet, ThinkingMode}; use crate::parser::ParseErrorKind; use crate::parser::Prompt; -use crate::store::{Access, StoreError, StoreExt, VfsRef}; +use crate::store::{Access, StoreError, VfsRef}; use crate::test_support::mock_gateway_client::MockGatewayClient; use crate::test_support::recording::DebugCapture; use crate::test_support::recording::{NullObserver, Observation, Observer, detail, null_emitter}; @@ -30,7 +30,9 @@ use crate::test_support::{RunHost, TestTool, TestToolTable}; use crate::tools::{ToolError, ToolErrorKind, ToolId, ToolOutput}; use crate::untrusted::GuardNonce; use crate::{Error, Result}; +use promptforge_lua::ToolOutputKind; use promptforge_model_client::model::ModelCatalog; +use promptforge_store::StoreExt; /// A fresh stock handle's access capability, for tests that inject host /// values into a standalone VM. @@ -119,7 +121,7 @@ impl TestPrompt { } } -/// Build the tool-free parsed form consumed by the complete lifecycle path. +/// Builds the tool-free parsed form consumed by the complete lifecycle path. fn fixture(md: &str) -> TestPrompt { TestPrompt { prompt: parse(md), @@ -322,7 +324,7 @@ fn gatewayed_with_debug(addr: SocketAddr, capture: Arc) -> Run } } -/// Parse `md` and run it offline with empty `args`, no tools, and a fresh +/// Parses `md` and runs it offline with empty `args`, no tools, and a fresh /// in-memory store created for the run - the ergonomic path for the /// Lua-only tests that do not care about the store's contents. async fn run_offline(md: &str) -> Result { @@ -363,7 +365,7 @@ async fn run( host = host.client(client); } if let Some(debug) = opts.debug { - ctx = ctx.report_debug(true); + ctx = ctx.report_debug(promptforge_api_types::emitter::DebugMode::On); host = host.debug(debug); } match crate::test_support::run_with_host(&env, &test.prompt, args, ctx, host).await { @@ -543,7 +545,7 @@ impl Recorder { } } -/// Run `md` offline under a fresh recorder and return the result together +/// Runs `md` offline under a fresh recorder and returns the result together /// with every complete correlated record the recorder saw. async fn run_recorded(md: &str) -> (Result, Vec<(String, String, String)>) { let recorder = Arc::new(Recorder::default()); @@ -563,7 +565,7 @@ async fn run_recorded(md: &str) -> (Result, Vec<(String, String, String) (result, recorder.records()) } -/// Discard only the execution field when an older ordering regression is +/// Discards only the execution field when an older ordering regression is /// intentionally about section and detail rather than correlation. fn events(records: &[(String, String, String)]) -> Vec<(String, String)> { records @@ -1237,7 +1239,7 @@ fn bind_override_reaches_the_schema_and_add_beats_bind() { id: ToolId::parse("tests/tools/echo").expect("valid id"), model_description: Some("bind override".to_owned()), schema: EchoTool.parameters_schema(), - output_kind: crate::lua::ToolOutputKind::Plain, + output_kind: ToolOutputKind::Plain, conflicts: Vec::new(), }], Vec::new(), @@ -1295,8 +1297,8 @@ fn bind_override_reaches_the_schema_and_add_beats_bind() { vm.teardown(&null_emitter(), "Precedence"); } -/// A tool whose call blocks far longer than the test's cancel deadline, so the -/// test can prove the tool-call loop honors cancellation mid-call. +/// A tool whose call never completes, so the test can prove the tool-call +/// loop honors cancellation mid-call rather than waiting the call out. struct SlowTool; #[async_trait::async_trait] @@ -1327,8 +1329,7 @@ impl TestTool for SlowTool { } async fn call(&self, _args: Value) -> std::result::Result { - tokio::time::sleep(std::time::Duration::from_secs(30)).await; - Ok(ToolOutput::trusted("done")) + std::future::pending().await } } diff --git a/crates/promptforge-api-runtime/src/execute/tests/debug_and_counts.rs b/crates/promptforge-api-runtime/src/execute/tests/debug_and_counts.rs index e4650378b..9a296dca5 100644 --- a/crates/promptforge-api-runtime/src/execute/tests/debug_and_counts.rs +++ b/crates/promptforge-api-runtime/src/execute/tests/debug_and_counts.rs @@ -1,3 +1,5 @@ +//! Tests for debug capture delivery and the `tools.calls` counters. + use super::run; use super::*; diff --git a/crates/promptforge-api-runtime/src/execute/tests/exec_flow.rs b/crates/promptforge-api-runtime/src/execute/tests/exec_flow.rs index 0584ca69b..7909f06b9 100644 --- a/crates/promptforge-api-runtime/src/execute/tests/exec_flow.rs +++ b/crates/promptforge-api-runtime/src/execute/tests/exec_flow.rs @@ -1,3 +1,5 @@ +//! Tests for section walk control flow: `call`, `jump`, `fanout`, `list_from_section`, and `var`. + use super::run; use super::*; use crate::test_support::synthetic_section; diff --git a/crates/promptforge-api-runtime/src/execute/tests/live_infer.rs b/crates/promptforge-api-runtime/src/execute/tests/live_infer.rs index b89982965..d36799748 100644 --- a/crates/promptforge-api-runtime/src/execute/tests/live_infer.rs +++ b/crates/promptforge-api-runtime/src/execute/tests/live_infer.rs @@ -1,3 +1,5 @@ +//! Tests for live `models.infer` against a scripted gateway, including H1 chunks and shared libraries. + use super::*; #[tokio::test(flavor = "multi_thread")] diff --git a/crates/promptforge-api-runtime/src/execute/tests/model_and_reply.rs b/crates/promptforge-api-runtime/src/execute/tests/model_and_reply.rs index f3159d235..a23f71f44 100644 --- a/crates/promptforge-api-runtime/src/execute/tests/model_and_reply.rs +++ b/crates/promptforge-api-runtime/src/execute/tests/model_and_reply.rs @@ -1,3 +1,5 @@ +//! Tests for section model selection, `reply`, `sys.model`, and the prologue and epilog phases. + use super::run; use super::*; @@ -540,7 +542,7 @@ async fn reply_substitution_is_an_unknown_global_error() { // --- models.get / models.infer with a leading handle --- -/// Run a parsed prompt against a scripted gateway with no external tools. +/// Runs a parsed prompt against a scripted gateway with no external tools. async fn run_with_gateway( test: &TestPrompt, addr: SocketAddr, diff --git a/crates/promptforge-api-runtime/src/execute/tests/model_task_acceptance.rs b/crates/promptforge-api-runtime/src/execute/tests/model_task_acceptance.rs index e86142659..8c2062920 100644 --- a/crates/promptforge-api-runtime/src/execute/tests/model_task_acceptance.rs +++ b/crates/promptforge-api-runtime/src/execute/tests/model_task_acceptance.rs @@ -21,7 +21,7 @@ use promptforge_api_types::ids::{TaskId, TaskOrigin}; use super::model_task_notices::{DelayedBroker, NoticeRecorder, loop_owner}; use super::model_tasks::{NeverBroker, PARKED_CHILD, model_task_context_with, owner_prompt, task}; use super::*; -use crate::execute::scheduler::TaskState; +use crate::execute::scheduler::test_hooks::TaskState; /// A broker delay that orders one child's end against another's. The /// scripted rounds between them complete in milliseconds on the loopback diff --git a/crates/promptforge-api-runtime/src/execute/tests/model_task_awaits.rs b/crates/promptforge-api-runtime/src/execute/tests/model_task_awaits.rs index ffedc55d0..52727f92c 100644 --- a/crates/promptforge-api-runtime/src/execute/tests/model_task_awaits.rs +++ b/crates/promptforge-api-runtime/src/execute/tests/model_task_awaits.rs @@ -11,7 +11,7 @@ use std::time::Duration; use super::model_task_notices::{DelayedBroker, NoticeRecorder, loop_owner}; use super::model_tasks::{NeverBroker, PARKED_CHILD, model_task_context_with, owner_prompt, task}; use super::*; -use crate::execute::scheduler::TaskState; +use crate::execute::scheduler::test_hooks::TaskState; #[tokio::test(flavor = "current_thread")] async fn await_tasks_answers_at_once_when_a_notice_is_already_pending() { diff --git a/crates/promptforge-api-runtime/src/execute/tests/model_tasks.rs b/crates/promptforge-api-runtime/src/execute/tests/model_tasks.rs index 462497506..d93fad10c 100644 --- a/crates/promptforge-api-runtime/src/execute/tests/model_tasks.rs +++ b/crates/promptforge-api-runtime/src/execute/tests/model_tasks.rs @@ -3,7 +3,8 @@ //! allowlist on the section; the `tool_call` arm answers the three by name //! before alias lookup, refusing a target outside the allowlist; a model //! task the owner outlives is abandoned (never cancelled) with a reason -//! naming how the owner ended; and the author's `tasks.pending` filter +//! naming how the owner ended; a chain end holding one task of each origin +//! leaks the author's alone; and the author's `tasks.pending` filter //! tells the model's tasks from the author's. A scripted mock gateway plays //! the model. @@ -12,7 +13,7 @@ use promptforge_api_types::ids::{AbandonReason, TaskId, TaskOrigin}; use super::models_loop::loop_models; use super::tasks::TaskRecorder; use super::*; -use crate::execute::scheduler::TaskState; +use crate::execute::scheduler::test_hooks::TaskState; use crate::input::{InputError, InputOutcome}; use crate::lua::ToolSet; use crate::test_support::TestBroker; @@ -251,6 +252,86 @@ async fn an_owner_that_ends_first_leaves_a_model_task_abandoned_not_cancelled() assert_eq!(AbandonReason::OwnerReturned.why(), "the section ended"); } +#[tokio::test(flavor = "current_thread")] +async fn an_ending_owner_leaks_its_author_task_and_never_its_model_task() { + // Both origins live under one owner at one chain end, so the origin + // arm in `abandon_owned_tasks` is driven over every `TaskOrigin` + // variant in a single settle: the author's id is the whole of + // `tasks_live` and the model's is absent, while both slots end + // abandoned. Two same-origin tasks could not tell the arm's two sides + // apart. + let gateway = ScriptedGateway::start(vec![ + resp_tool_call("call_1", "task", "{\"target\":\"## Child\"}"), + resp_text("bye"), + ]) + .await; + let md = owner_prompt( + "", + "tools.allow_tasks()\n\ + tasks.spawn('## Child')\n\ + local msgs = messages.new()\n\ + msgs:user('go')\n\ + models.loop(msgs)\n\ + return 'ok'", + PARKED_CHILD, + ); + let prompt = parse(&md); + let recorder = Arc::new(TaskRecorder::default()); + let ctx = model_task_context(&prompt, &recorder); + let mut scheduler = TokioDriver::new(&ctx, Some(gateway_client(gateway.addr()))); + let error = scheduler + .drive() + .await + .expect_err("the live author task fails the owner it outlived"); + + match &error { + Error::TasksLive { tasks } => assert_eq!( + tasks, + &[task("0.0")], + "only the author-origin task is leaked: {tasks:?}" + ), + other => panic!("expected tasks_live, got {other:?}"), + } + for id in ["0.0", "0.1"] { + assert_eq!( + scheduler.task_state_for_test(&task(id)), + Some(TaskState::Abandoned), + "both origins ended with their owner as abandoned" + ); + } + let records = recorder.records(); + // `abandon_owned_tasks` stamps `Abandoned` before the effect-backed + // branch that skips the origin match, so the state alone would hold + // for a slot that never reached the arm. The terminal observation is + // emitted only past that branch, one statement above the arm itself. + for id in ["0.0", "0.1"] { + assert!( + records.iter().any(|(section, event)| section == "Child" + && *event + == Observation::TaskAbandoned { + task: task(id), + reason: AbandonReason::OwnerReturned, + }), + "task {id} reached the origin arm: {records:?}" + ); + } + let origins: Vec<(TaskId, TaskOrigin)> = records + .iter() + .filter_map(|(_, event)| match event { + Observation::TaskStarted { task, origin, .. } => Some((task.clone(), *origin)), + _ => None, + }) + .collect(); + assert_eq!( + origins, + vec![ + (task("0.0"), TaskOrigin::Author), + (task("0.1"), TaskOrigin::Model), + ], + "the settle saw one task of each origin: {records:?}" + ); +} + #[tokio::test(flavor = "current_thread")] async fn an_exhausted_tool_loop_abandons_the_model_task_for_that_reason() { let gateway = ScriptedGateway::start(vec![resp_tool_call( diff --git a/crates/promptforge-api-runtime/src/execute/tests/observations.rs b/crates/promptforge-api-runtime/src/execute/tests/observations.rs index 75eff6ae7..ca867efeb 100644 --- a/crates/promptforge-api-runtime/src/execute/tests/observations.rs +++ b/crates/promptforge-api-runtime/src/execute/tests/observations.rs @@ -1,3 +1,5 @@ +//! Tests for the observation event sequence a run reports across its lifecycle. + use promptforge_api_types::event::Event; use super::run; diff --git a/crates/promptforge-api-runtime/src/execute/tests/scheduler.rs b/crates/promptforge-api-runtime/src/execute/tests/scheduler.rs index bd9308a59..2d0872854 100644 --- a/crates/promptforge-api-runtime/src/execute/tests/scheduler.rs +++ b/crates/promptforge-api-runtime/src/execute/tests/scheduler.rs @@ -2912,15 +2912,18 @@ async fn two_live_arms_appending_one_path_terminate_with_a_determinism_violation } } -/// A one-shot gate for the winning arm's backend write or append: the -/// first write-intent op the backend serves parks with its write claim -/// held until the losing arm's conflict observation opens the gate, so -/// the cross-arm conflict fires no matter how late the second op's -/// blocking-pool thread starts. The parked wait is bounded: a claims -/// model that stopped conflicting would otherwise strand the run-end -/// drain on the parked op, and the test must fail, never hang. +/// A one-shot gate for the first backend write or append: the first +/// write-intent op the backend serves parks with its write claim held +/// until a [`GateObserver`] opens the gate, so a test's outcome cannot +/// depend on how late that op's blocking-pool thread starts. The +/// arm-conflict tests park the winning arm until the losing arm's +/// conflict observation; the cancel-wait suite parks a child until its +/// owner's cancel is observed. The parked wait is bounded: a claims model +/// that stopped conflicting (or a cancel that stopped reporting) would +/// otherwise strand the run-end drain on the parked op, and the test must +/// fail, never hang. #[derive(Default)] -struct StoreGate { +pub(super) struct StoreGate { released: Mutex, release: Condvar, taken: AtomicBool, @@ -2961,18 +2964,18 @@ impl StoreGate { } } -/// Opens the gate when the losing arm's write or append fails: the -/// conflict's failed observation fires before the answer posts, so the -/// winner's parked op completes ahead of the run-end drain that awaits -/// it. Every observation also forwards to `inner`, so a test can keep -/// its own recorder behind the gate. -struct GateObserver { +/// Opens the gate when the losing arm's write or append fails, or when a +/// task is cancelled: either observation fires before the answer that +/// ends the run posts, so the parked op completes ahead of the run-end +/// drain that awaits it. Every observation also forwards to `inner`, so a +/// test can keep its own recorder behind the gate. +pub(super) struct GateObserver { gate: Arc, inner: Arc, } impl GateObserver { - fn new(gate: &Arc, inner: Arc) -> Arc { + pub(super) fn new(gate: &Arc, inner: Arc) -> Arc { Arc::new(GateObserver { gate: Arc::clone(gate), inner, @@ -2984,7 +2987,9 @@ impl Observer for GateObserver { fn observe(&self, execution: &str, section: &str, event: Observation) { if matches!( event, - Observation::StoreWriteFailed | Observation::StoreAppendFailed + Observation::StoreWriteFailed + | Observation::StoreAppendFailed + | Observation::TaskCancelled { .. } ) { self.gate.open(); } @@ -3001,7 +3006,7 @@ struct GatedStore { } /// A test store mounting a [`GatedStore`] on `gate`. -fn gated_store(gate: &Arc) -> TestStore { +pub(super) fn gated_store(gate: &Arc) -> TestStore { TestStore::from_vfs( VfsRef::builder() .mount( @@ -3681,8 +3686,8 @@ async fn a_script_tools_call_reaches_a_bound_tool_outside_the_section_scope() { assert_eq!(out, "echoed: hi|1"); } -/// A tool that signals its start and then sleeps far past every deadline, -/// so the cancellation test fires only once the dispatch is in flight. +/// A tool that signals its start and then never completes, so the +/// cancellation test fires only once the dispatch is in flight. struct SignallingSlowTool { started: Arc, } @@ -3718,8 +3723,7 @@ impl TestTool for SignallingSlowTool { _args: serde_json::Value, ) -> std::result::Result { self.started.fetch_add(1, Ordering::SeqCst); - tokio::time::sleep(std::time::Duration::from_secs(30)).await; - Ok(crate::tools::ToolOutput::trusted("too late")) + std::future::pending().await } } @@ -3823,7 +3827,7 @@ async fn a_structured_binding_resumes_as_a_lua_table() { trusted: true, }), ); - binding.0.output_kind = crate::lua::ToolOutputKind::Structured; + binding.0.output_kind = promptforge_lua::ToolOutputKind::Structured; arm_tool_set(&ctx, vec![binding]); let out = TokioDriver::new(&ctx, None) .drive() @@ -3848,7 +3852,7 @@ async fn invalid_json_from_a_structured_tool_is_a_tool_error() { trusted: true, }), ); - binding.0.output_kind = crate::lua::ToolOutputKind::Structured; + binding.0.output_kind = promptforge_lua::ToolOutputKind::Structured; arm_tool_set(&ctx, vec![binding]); let error = TokioDriver::new(&ctx, None) .drive() @@ -3885,7 +3889,7 @@ async fn an_untrusted_structured_output_is_wrapped_before_classification() { trusted: false, }), ); - binding.0.output_kind = crate::lua::ToolOutputKind::Structured; + binding.0.output_kind = promptforge_lua::ToolOutputKind::Structured; arm_tool_set(&ctx, vec![binding]); let error = TokioDriver::new(&ctx, None) .drive() diff --git a/crates/promptforge-api-runtime/src/execute/tests/tasks.rs b/crates/promptforge-api-runtime/src/execute/tests/tasks.rs index 7a1506839..f8d1b2901 100644 --- a/crates/promptforge-api-runtime/src/execute/tests/tasks.rs +++ b/crates/promptforge-api-runtime/src/execute/tests/tasks.rs @@ -13,7 +13,7 @@ use promptforge_api_types::ids::{AbandonReason, TaskId, TaskOrigin}; use super::scheduler::scheduler_context_on; use super::*; -use crate::execute::scheduler::TaskState; +use crate::execute::scheduler::test_hooks::TaskState; /// A recorder that keeps the typed observation, so a payload-carrying /// variant (`TaskStarted`) can be matched whole. diff --git a/crates/promptforge-api-runtime/src/execute/tests/timeouts.rs b/crates/promptforge-api-runtime/src/execute/tests/timeouts.rs index da834c398..e830f382e 100644 --- a/crates/promptforge-api-runtime/src/execute/tests/timeouts.rs +++ b/crates/promptforge-api-runtime/src/execute/tests/timeouts.rs @@ -11,7 +11,7 @@ use std::time::Duration; use super::scheduler::scheduler_context_on; use super::waits::{WaitRecorder, task, tasks_prompt}; use super::*; -use crate::execute::scheduler::TaskState; +use crate::execute::scheduler::test_hooks::TaskState; /// The gateway reply a slow child parks on: long enough that a short /// timeout wins, short enough that the test then waits it out. diff --git a/crates/promptforge-api-runtime/src/execute/tests/tool_scoping.rs b/crates/promptforge-api-runtime/src/execute/tests/tool_scoping.rs index bea5ac01f..901b5493f 100644 --- a/crates/promptforge-api-runtime/src/execute/tests/tool_scoping.rs +++ b/crates/promptforge-api-runtime/src/execute/tests/tool_scoping.rs @@ -1,3 +1,5 @@ +//! Tests for model-visible tool scoping through `tools.always` and `tools.add`. + use super::models_loop::{loop_context, loop_prompt}; use super::*; use crate::test_support::tokio_driver::TokioDriver; diff --git a/crates/promptforge-api-runtime/src/execute/tests/waits.rs b/crates/promptforge-api-runtime/src/execute/tests/waits.rs index a52b07980..31c2e38db 100644 --- a/crates/promptforge-api-runtime/src/execute/tests/waits.rs +++ b/crates/promptforge-api-runtime/src/execute/tests/waits.rs @@ -11,9 +11,9 @@ use std::time::Duration; use promptforge_api_types::ids::TaskId; -use super::scheduler::scheduler_context_on; +use super::scheduler::{GateObserver, StoreGate, gated_store, scheduler_context_on}; use super::*; -use crate::execute::scheduler::TaskState; +use crate::execute::scheduler::test_hooks::TaskState; /// A recorder that keeps the typed observation, so a payload-carrying /// variant can be matched whole. Shared with the timeout suite, which @@ -308,9 +308,17 @@ async fn cancel_ends_a_parked_task_idempotently_and_reports_task_cancelled_once( // second is a no-op), reads the terminal state, and ends with no live // task - so no `tasks_live`. A wait on the cancelled slot delivers // `ok = false` with a `cancelled` error value. + // + // Whether the child's write completes before the cancel lands is the + // blocking pool's choice, so the store gate parks the first write the + // backend serves until the child's `TaskCancelled` is observed. The + // owner's own yield before the cancel must therefore not be a write + // or append: `exists` lets the child run without taking the gate. + let gate = Arc::new(StoreGate::default()); + let store = gated_store(&gate); let md = tasks_prompt( "local t = tasks.spawn('## Child')\n\ - store.write('park', 'x')\n\ + store.exists('park')\n\ tasks.cancel(t)\n\ tasks.cancel(t)\n\ local s = tasks.status(t)\n\ @@ -328,8 +336,8 @@ async fn cancel_ends_a_parked_task_idempotently_and_reports_task_cancelled_once( let recorder = Arc::new(WaitRecorder::default()); let ctx = scheduler_context_on( &prompt, - &TestStore::new(), - Arc::clone(&recorder) as Arc, + &store, + GateObserver::new(&gate, Arc::clone(&recorder) as Arc), ); let mut scheduler = TokioDriver::new(&ctx, None); let out = scheduler diff --git a/crates/promptforge-api-runtime/src/fanout/tests.rs b/crates/promptforge-api-runtime/src/fanout-tests.rs similarity index 97% rename from crates/promptforge-api-runtime/src/fanout/tests.rs rename to crates/promptforge-api-runtime/src/fanout-tests.rs index 0a6b31526..bcb0c75c6 100644 --- a/crates/promptforge-api-runtime/src/fanout/tests.rs +++ b/crates/promptforge-api-runtime/src/fanout-tests.rs @@ -1,3 +1,5 @@ +//! Tests for resolving a fanout worker heading among sibling sections. + use super::*; #[test] diff --git a/crates/promptforge-api-runtime/src/fanout/mod.rs b/crates/promptforge-api-runtime/src/fanout.rs similarity index 99% rename from crates/promptforge-api-runtime/src/fanout/mod.rs rename to crates/promptforge-api-runtime/src/fanout.rs index 8f0d39491..f5808cd7a 100644 --- a/crates/promptforge-api-runtime/src/fanout/mod.rs +++ b/crates/promptforge-api-runtime/src/fanout.rs @@ -96,4 +96,5 @@ pub(crate) fn resolve_sibling<'a>(heading: &str, visible: &'a [Section]) -> Resu } #[cfg(test)] +#[path = "fanout-tests.rs"] mod tests; diff --git a/crates/promptforge-api-runtime/src/lua.rs b/crates/promptforge-api-runtime/src/lua.rs index 9bd994579..d5d60766d 100644 --- a/crates/promptforge-api-runtime/src/lua.rs +++ b/crates/promptforge-api-runtime/src/lua.rs @@ -11,8 +11,6 @@ //! The implementation lives in the `promptforge-lua` crate and is re-exported //! here unchanged, so existing `promptforge_api_runtime::lua::*` paths keep working. -#[cfg(test)] -pub(crate) use promptforge_lua::ToolOutputKind; // The store operation behind `execute::perform_store_op`, the door a // host's store performer answers a `Store` effect through. pub(crate) use promptforge_lua::run_store_op; diff --git a/crates/promptforge-api-runtime/src/lua/tests/mod.rs b/crates/promptforge-api-runtime/src/lua/tests.rs similarity index 100% rename from crates/promptforge-api-runtime/src/lua/tests/mod.rs rename to crates/promptforge-api-runtime/src/lua/tests.rs diff --git a/crates/promptforge-api-runtime/src/model/tests/always.rs b/crates/promptforge-api-runtime/src/model/tests-always.rs similarity index 100% rename from crates/promptforge-api-runtime/src/model/tests/always.rs rename to crates/promptforge-api-runtime/src/model/tests-always.rs diff --git a/crates/promptforge-api-runtime/src/model/tests/integration.rs b/crates/promptforge-api-runtime/src/model/tests-integration.rs similarity index 100% rename from crates/promptforge-api-runtime/src/model/tests/integration.rs rename to crates/promptforge-api-runtime/src/model/tests-integration.rs diff --git a/crates/promptforge-api-runtime/src/model/tests/mod.rs b/crates/promptforge-api-runtime/src/model/tests.rs similarity index 94% rename from crates/promptforge-api-runtime/src/model/tests/mod.rs rename to crates/promptforge-api-runtime/src/model/tests.rs index 9bb747442..2295ff231 100644 --- a/crates/promptforge-api-runtime/src/model/tests/mod.rs +++ b/crates/promptforge-api-runtime/src/model/tests.rs @@ -1,3 +1,5 @@ +//! Tests for resolving a section's model binding through the VM and shared model set. + use std::num::NonZeroU32; use std::sync::{Arc, Mutex}; @@ -87,5 +89,7 @@ fn resolve_section_model(vm: &SectionVm) -> Result> { resolve_model_binding(&Mutex::new(models), &runtime).map_err(Error::from) } +#[path = "tests-always.rs"] mod always; +#[path = "tests-integration.rs"] mod integration; diff --git a/crates/promptforge-api-runtime/src/store.rs b/crates/promptforge-api-runtime/src/store.rs index 32b4c82b4..41ebc0dab 100644 --- a/crates/promptforge-api-runtime/src/store.rs +++ b/crates/promptforge-api-runtime/src/store.rs @@ -21,6 +21,4 @@ pub(crate) use promptforge_store::Store; pub(crate) use promptforge_store::StoreError; -#[cfg(test)] -pub(crate) use promptforge_store::StoreExt; pub(crate) use shared_vfs::{Access, VfsRef}; diff --git a/crates/promptforge-api-runtime/src/subst.rs b/crates/promptforge-api-runtime/src/subst.rs index 3ce0fbeaa..d61efd986 100644 --- a/crates/promptforge-api-runtime/src/subst.rs +++ b/crates/promptforge-api-runtime/src/subst.rs @@ -168,7 +168,7 @@ pub(crate) struct Sources<'a> { pub(crate) globals: &'a dyn Fn(&str) -> Result>, } -/// Resolve every `{{ path }}` in `prose` against the [`Sources`]. +/// Resolves every `{{ path }}` in `prose` against the [`Sources`]. /// /// This function receives prose only and does not transform either compiled /// Lua phase. @@ -269,7 +269,7 @@ fn bare_global_root( }) } -/// Resolve a single `{{ }}` path to its rendered string. +/// Resolves a single `{{ }}` path to its rendered string. fn resolve(path: &str, offset: usize, sources: &Sources<'_>) -> SubstResult { if path == "args" { return Ok(sources.args.to_string()); @@ -382,7 +382,7 @@ fn path_preview(path: &str) -> String { out } -/// Render a resolved JSON value as its substituted string. +/// Renders a resolved JSON value as its substituted string. fn render(value: &Value, path: &str, offset: usize) -> SubstResult { if let Some(rendered) = render_scalar(value) { return Ok(rendered); diff --git a/crates/promptforge-api-runtime/src/test_support/recording-forward-tests.rs b/crates/promptforge-api-runtime/src/test_support/recording-forward-tests.rs index b89fe057c..fe7f70e86 100644 --- a/crates/promptforge-api-runtime/src/test_support/recording-forward-tests.rs +++ b/crates/promptforge-api-runtime/src/test_support/recording-forward-tests.rs @@ -1,6 +1,12 @@ +//! Tests for the recording emitter's forwarding of event groups to their +//! seams: a batch reaches its seams in order, a debug event without a +//! capture is dropped, and every `Event` variant the suite names reaches +//! exactly one seam. + use std::sync::Mutex; -use promptforge_api_types::ids::{ChainId, Provenance, TaskId}; +use promptforge_api_types::ids::{AbandonReason, ChainId, Provenance, TaskId, TaskOrigin}; +use promptforge_api_types::metrics::ToolCallEvent; use super::*; @@ -52,6 +58,81 @@ impl Observer for Recorder { .expect("the recorder mutex is not poisoned") .push(format!("{section}: input {text}")); } + + fn on_thinking( + &self, + _execution: &str, + section: &str, + _chain_id: u32, + _depth: u32, + turn: u32, + model: &str, + text: &str, + ) { + self.content + .lock() + .expect("the recorder mutex is not poisoned") + .push(format!( + "{section}: thinking turn={turn} model={model} text={text}" + )); + } + + fn on_assistant_tool_calls( + &self, + _execution: &str, + section: &str, + _chain_id: u32, + _depth: u32, + turn: u32, + model: &str, + calls: &[ToolCallEvent], + ) { + self.content + .lock() + .expect("the recorder mutex is not poisoned") + .push(format!( + "{section}: calls turn={turn} model={model} n={}", + calls.len() + )); + } + + fn on_tool_result( + &self, + _execution: &str, + section: &str, + _chain_id: u32, + _depth: u32, + turn: u32, + tool_call_id: &str, + alias: &str, + content: &str, + trusted: bool, + ) { + self.content + .lock() + .expect("the recorder mutex is not poisoned") + .push(format!( + "{section}: result turn={turn} id={tool_call_id} alias={alias} content={content} trusted={trusted}" + )); + } + + fn on_task_notice( + &self, + _execution: &str, + section: &str, + _chain_id: u32, + _depth: u32, + turn: u32, + task: &TaskId, + text: &str, + ) { + self.content + .lock() + .expect("the recorder mutex is not poisoned") + .push(format!( + "{section}: notice turn={turn} task={task} text={text}" + )); + } } impl DebugCapture for Recorder { @@ -67,6 +148,306 @@ impl DebugCapture for Recorder { } } +/// One of the three sinks an event can land in, as an index into +/// [`Recorder::counts`]. +#[derive(Clone, Copy, Debug)] +enum Seam { + Observed, + Content, + Captured, +} + +impl Recorder { + /// How many records each seam holds, indexed by [`Seam`]. + fn counts(&self) -> [usize; 3] { + [ + self.observed.lock().expect("not poisoned").len(), + self.content.lock().expect("not poisoned").len(), + self.captured.lock().expect("not poisoned").len(), + ] + } +} + +/// Builds one payload-free lifecycle event per named variant, each bound +/// to the observation seam. +macro_rules! unit_events { + ($($variant:ident),* $(,)?) => { + vec![$(( + Event::$variant { + execution: "run".to_owned(), + section: "A".to_owned(), + provenance: provenance(), + }, + Seam::Observed, + ),)*] + }; +} + +/// One value of every [`Event`] variant, beside the seam that variant +/// must reach. +/// +/// Hand-maintained, and deliberately written out rather than derived from +/// the forwarder's own or-patterns: `Event` is `#[non_exhaustive]` outside +/// `promptforge-api-types`, so no match here can be exhaustive and no +/// compiler check can hold this list to the enum. A variant added to +/// `Event` must be added here by hand. +#[expect( + clippy::too_many_lines, + reason = "one flat table of every event variant; splitting it would hide the coverage it exists to show" +)] +fn one_of_every_event_variant() -> Vec<(Event, Seam)> { + let mut events = unit_events![ + ParseStarted, + ParseSucceeded, + ParseFailed, + RunStarted, + RunSucceeded, + RunFailed, + SectionStarted, + SectionFinished, + ModelTurnCompleted, + ModelTurnFailed, + ModelTurnTruncated, + ToolCallSucceeded, + ToolCallFailed, + LuaCompilationStarted, + LuaCompilationSucceeded, + LuaCompilationFailed, + LuaSharedLoadStarted, + LuaSharedLoadSucceeded, + LuaSharedLoadFailed, + LuaChunkStarted, + LuaChunkSucceeded, + LuaChunkFailed, + LuaReplyBindingStarted, + LuaReplyBindingSucceeded, + LuaReplyBindingFailed, + LuaTeardownStarted, + LuaTeardownSucceeded, + ToolScopeValidationStarted, + ToolScopeValidationSucceeded, + ToolScopeValidationFailed, + ModelCatalogValidationStarted, + ModelCatalogValidationSucceeded, + ModelCatalogValidationFailed, + StoreWriteSucceeded, + StoreWriteFailed, + StoreAppendSucceeded, + StoreAppendFailed, + StoreReadSucceeded, + StoreReadFailed, + StoreReadNumberedSucceeded, + StoreReadNumberedFailed, + StoreReplaceSucceeded, + StoreReplaceFailed, + StoreDeleteSucceeded, + StoreDeleteFailed, + StoreGlobSucceeded, + StoreGlobFailed, + UserInputWaitStarted, + ]; + let task: TaskId = "0.1".parse().expect("a task id parses"); + events.extend([ + ( + Event::Lua { + execution: "run".to_owned(), + section: "A".to_owned(), + provenance: provenance(), + message: "note".to_owned(), + }, + Seam::Observed, + ), + ( + Event::TaskStarted { + execution: "run".to_owned(), + section: "A".to_owned(), + provenance: provenance(), + task: task.clone(), + target: "Child".to_owned(), + origin: TaskOrigin::Author, + input: None, + item: None, + index: None, + var: serde_json::json!({}), + }, + Seam::Observed, + ), + ( + Event::TaskSucceeded { + execution: "run".to_owned(), + section: "A".to_owned(), + provenance: provenance(), + task: task.clone(), + }, + Seam::Observed, + ), + ( + Event::TaskFailed { + execution: "run".to_owned(), + section: "A".to_owned(), + provenance: provenance(), + task: task.clone(), + }, + Seam::Observed, + ), + ( + Event::TaskCancelled { + execution: "run".to_owned(), + section: "A".to_owned(), + provenance: provenance(), + task: task.clone(), + }, + Seam::Observed, + ), + ( + Event::TaskAbandoned { + execution: "run".to_owned(), + section: "A".to_owned(), + provenance: provenance(), + task: task.clone(), + reason: AbandonReason::OwnerReturned, + }, + Seam::Observed, + ), + ( + Event::TaskResumed { + execution: "run".to_owned(), + section: "A".to_owned(), + provenance: provenance(), + task: task.clone(), + }, + Seam::Observed, + ), + ( + Event::TaskNote { + execution: "run".to_owned(), + section: "A".to_owned(), + provenance: provenance(), + task: task.clone(), + text: "halfway".to_owned(), + }, + Seam::Observed, + ), + ( + Event::Thinking { + execution: "run".to_owned(), + section: "A".to_owned(), + provenance: provenance(), + turn: 1, + model: "m".to_owned(), + text: "hmm".to_owned(), + }, + Seam::Content, + ), + ( + Event::AssistantReply { + execution: "run".to_owned(), + section: "A".to_owned(), + provenance: provenance(), + turn: 1, + text: "hi".to_owned(), + finish_reason: Some("stop".to_owned()), + model: "m".to_owned(), + metrics: None, + }, + Seam::Content, + ), + ( + Event::AssistantToolCalls { + execution: "run".to_owned(), + section: "A".to_owned(), + provenance: provenance(), + turn: 1, + model: "m".to_owned(), + calls: vec![ToolCallEvent { + id: "call_1".to_owned(), + name: "search".to_owned(), + arguments: serde_json::json!({}), + }], + }, + Seam::Content, + ), + ( + Event::ToolResult { + execution: "run".to_owned(), + section: "A".to_owned(), + provenance: provenance(), + turn: 1, + tool_call_id: "call_1".to_owned(), + alias: "search".to_owned(), + content: "found".to_owned(), + trusted: false, + }, + Seam::Content, + ), + ( + Event::UserInput { + execution: "run".to_owned(), + section: "A".to_owned(), + provenance: provenance(), + text: "typed".to_owned(), + }, + Seam::Content, + ), + ( + Event::TaskNotice { + execution: "run".to_owned(), + section: "A".to_owned(), + provenance: provenance(), + turn: 1, + task, + text: "it ended".to_owned(), + }, + Seam::Content, + ), + ( + Event::Request { + execution: "run".to_owned(), + section: "A".to_owned(), + provenance: provenance(), + turn: 1, + body: serde_json::json!({}), + }, + Seam::Captured, + ), + ( + Event::Response { + execution: "run".to_owned(), + section: "A".to_owned(), + provenance: provenance(), + turn: 1, + body: serde_json::json!({}), + finish_reason: None, + reasoning_content: None, + }, + Seam::Captured, + ), + ]); + events +} + +#[test] +fn every_event_variant_reaches_exactly_one_seam() { + for (event, seam) in one_of_every_event_variant() { + let named = format!("{event:?}"); + let recorder = Recorder::default(); + // A variant no group claims hits `forward_one`'s catch-all and + // panics here; a variant a group claims but does not destructure + // falls through that group's own `_ => {}` and records nothing. + forward_one(event, &recorder, Some(&recorder)); + let counts = recorder.counts(); + assert_eq!( + counts[seam as usize], 1, + "{named} must reach the {seam:?} seam exactly once, saw {counts:?}" + ); + assert_eq!( + counts.iter().sum::(), + 1, + "{named} must reach no seam but {seam:?}, saw {counts:?}" + ); + } +} + #[test] fn each_event_group_reaches_its_seam_in_batch_order() { let recorder = Recorder::default(); diff --git a/crates/promptforge-api-runtime/src/test_support/recording-forward.rs b/crates/promptforge-api-runtime/src/test_support/recording-forward.rs index 994b87702..4af1894a0 100644 --- a/crates/promptforge-api-runtime/src/test_support/recording-forward.rs +++ b/crates/promptforge-api-runtime/src/test_support/recording-forward.rs @@ -16,7 +16,7 @@ use promptforge_api_types::event::Event; use super::{DebugCapture, DebugEvent, Observation, Observer}; /// Declares the payload-free lifecycle pairs once and derives the -/// event-to-observation fold and the or-pattern from the one list. +/// event-to-observation fold from the one list. macro_rules! lifecycle_pairs { ($($variant:ident),* $(,)?) => { /// The payload-free [`Observation`] matching a payload-free @@ -27,13 +27,6 @@ macro_rules! lifecycle_pairs { _ => return None, }) } - - /// The payload-free lifecycle variants as one or-pattern. - macro_rules! unit_lifecycle_variants { - () => { - $(Event::$variant { .. })|* - }; - } }; } @@ -122,20 +115,6 @@ macro_rules! debug_variants { }; } -/// Every variant the named group does not own: the arm each group's -/// match closes with, so it stays exhaustive without a wildcard. -macro_rules! other_groups { - (task_lifecycle) => { - unit_lifecycle_variants!() | content_variants!() | debug_variants!() - }; - (content) => { - unit_lifecycle_variants!() | task_lifecycle_variants!() | debug_variants!() - }; - (debug) => { - unit_lifecycle_variants!() | task_lifecycle_variants!() | content_variants!() - }; -} - /// Replays `events`, in order, onto `observer` and `debug`. pub fn forward(events: Vec, observer: &dyn Observer, debug: Option<&dyn DebugCapture>) { for event in events { @@ -143,20 +122,38 @@ pub fn forward(events: Vec, observer: &dyn Observer, debug: Option<&dyn D } } -/// Routes one event to the seam its group belongs to. The match is -/// exhaustive over [`Event`] with no wildcard, so a new variant fails to -/// compile here until a group claims it. +/// Routes one event to the seam its group belongs to. [`Event`] is +/// `#[non_exhaustive]` in `promptforge-api-types`, so the match cannot be +/// total here and no compiler check holds the groups to the enum. What +/// holds instead is +/// `tests::every_event_variant_reaches_exactly_one_seam`, which drives one +/// value of every variant through this function and asserts each reaches +/// exactly one seam: it covers the variants written into its own list, so +/// a variant added to `Event` is covered only once someone adds it to a +/// group's or-pattern and to that list, both by hand. +/// +/// Within that list the routing is total: a variant no group claims panics +/// naming itself rather than passing a suite vacuously, and a variant a +/// group claims but does not destructure records nothing and fails the +/// test. +/// +/// # Panics +/// +/// When `event` is a variant none of the groups above claims. pub fn forward_one(event: Event, observer: &dyn Observer, debug: Option<&dyn DebugCapture>) { if let Some(observation) = unit_observation(&event) { observer.observe(event.execution(), event.section(), observation); return; } match event { - // Forwarded above; named only to keep the match exhaustive. - unit_lifecycle_variants!() => {} task_lifecycle_variants!() => forward_lifecycle(event, observer), content_variants!() => forward_content(event, observer), debug_variants!() => forward_debug(event, debug), + // The payload-free variants were forwarded above; anything else + // is a variant no group claims (`Event` is `#[non_exhaustive]` in + // `promptforge-api-types`), which the test recorder must never + // drop in silence. + _ => unreachable!("Event variant no group claims: {event:?}"), } } @@ -236,11 +233,9 @@ fn forward_lifecycle(event: Event, observer: &dyn Observer) { §ion, Observation::Other("Task note".to_owned()), ), - #[expect( - clippy::unnested_or_patterns, - reason = "the groups compose as or-patterns from one declaration each" - )] - other_groups!(task_lifecycle) => {} + // Routed here by `forward_one` for this group alone; `Event` is + // `#[non_exhaustive]` in `promptforge-api-types`. + _ => {} } } @@ -317,11 +312,9 @@ fn forward_content(event: Event, observer: &dyn Observer) { text, .. } => observer.on_task_notice(&execution, §ion, 0, 0, turn, &task, &text), - #[expect( - clippy::unnested_or_patterns, - reason = "the groups compose as or-patterns from one declaration each" - )] - other_groups!(content) => {} + // Routed here by `forward_one` for this group alone; `Event` is + // `#[non_exhaustive]` in `promptforge-api-types`. + _ => {} } } @@ -362,11 +355,9 @@ fn forward_debug(event: Event, debug: Option<&dyn DebugCapture>) { ); } } - #[expect( - clippy::unnested_or_patterns, - reason = "the groups compose as or-patterns from one declaration each" - )] - other_groups!(debug) => {} + // Routed here by `forward_one` for this group alone; `Event` is + // `#[non_exhaustive]` in `promptforge-api-types`. + _ => {} } } diff --git a/crates/promptforge-api-runtime/src/test_support/recording.rs b/crates/promptforge-api-runtime/src/test_support/recording.rs index 35a047593..49fe0054d 100644 --- a/crates/promptforge-api-runtime/src/test_support/recording.rs +++ b/crates/promptforge-api-runtime/src/test_support/recording.rs @@ -130,7 +130,7 @@ pub fn null_emitter() -> promptforge_api_types::emitter::Emitter { promptforge_api_types::emitter::Emitter::root( promptforge_api_types::emitter::EventSink::default(), "test", - false, + promptforge_api_types::emitter::DebugMode::Off, ) } diff --git a/crates/promptforge-api-runtime/src/test_support/tokio_driver.rs b/crates/promptforge-api-runtime/src/test_support/tokio_driver.rs index 2556551fc..a9daf4eb4 100644 --- a/crates/promptforge-api-runtime/src/test_support/tokio_driver.rs +++ b/crates/promptforge-api-runtime/src/test_support/tokio_driver.rs @@ -402,7 +402,7 @@ impl<'a> TokioDriver<'a> { pub(crate) fn task_state_for_test( &mut self, task: &promptforge_api_types::ids::TaskId, - ) -> Option { + ) -> Option { self.scheduler_for_test().task_state_for_test(task) } diff --git a/crates/promptforge-api-types/src/cancel-tests.rs b/crates/promptforge-api-types/src/cancel-tests.rs index 1569a2077..9eb859290 100644 --- a/crates/promptforge-api-types/src/cancel-tests.rs +++ b/crates/promptforge-api-types/src/cancel-tests.rs @@ -1,3 +1,5 @@ +//! Tests for `CancelHandle`: idempotence, parent-to-child propagation, and waker behavior. + use std::future::Future; use std::pin::pin; use std::sync::atomic::{AtomicUsize, Ordering}; diff --git a/crates/promptforge-api-types/src/emitter-tests.rs b/crates/promptforge-api-types/src/emitter-tests.rs index 80a9447f3..8419af65d 100644 --- a/crates/promptforge-api-types/src/emitter-tests.rs +++ b/crates/promptforge-api-types/src/emitter-tests.rs @@ -1,15 +1,18 @@ +//! Tests for `Emitter` sequencing, lifecycle reports, and payload events. + use std::sync::Arc; -use super::{Emitter, EventSink}; +use super::{DebugMode, Emitter, EventSink}; use crate::event::{Event, lifecycle}; use crate::ids::{AbandonReason, ChainId, Provenance, TaskId, TaskOrigin}; +use crate::tools::OutputTrust; fn root() -> TaskId { TaskId::from(ChainId::root()) } fn emitter(sink: &EventSink, task: TaskId) -> Emitter { - Emitter::new(sink.clone(), task, Arc::from("run-1"), false) + Emitter::new(sink.clone(), task, Arc::from("run-1"), DebugMode::Off) } #[test] @@ -148,7 +151,7 @@ fn payload_variants_cross_field_for_field() { fn content_reports_land_in_the_buffer_in_order() { let sink = EventSink::default(); let walk = emitter(&sink, root()); - walk.tool_result("Chat", 3, "call_1", "echo", "out", true); + walk.tool_result("Chat", 3, "call_1", "echo", "out", OutputTrust::Trusted); walk.user_input("Chat", "typed"); let events = sink.take(); assert!(matches!( @@ -160,10 +163,41 @@ fn content_reports_land_in_the_buffer_in_order() { assert_eq!(events[1].provenance().seq, 1); } +#[test] +fn an_untrusted_tool_result_reports_the_event_with_trusted_false() { + let sink = EventSink::default(); + let walk = emitter(&sink, root()); + walk.tool_result( + "Chat", + 2, + "call_9", + "fetch", + "", + OutputTrust::Untrusted, + ); + assert_eq!( + sink.take(), + vec![Event::ToolResult { + execution: "run-1".to_owned(), + section: "Chat".to_owned(), + provenance: Provenance { + task: root(), + seq: 0 + }, + turn: 2, + tool_call_id: "call_9".to_owned(), + alias: "fetch".to_owned(), + content: "".to_owned(), + trusted: false, + }], + "an untrusted marking lands on the wire as `trusted: false`" + ); +} + #[test] fn the_root_emitter_reports_under_task_zero_with_its_execution() { let sink = EventSink::default(); - let emitter = Emitter::root(sink.clone(), "parse-1", true); + let emitter = Emitter::root(sink.clone(), "parse-1", DebugMode::On); assert!(emitter.captures_debug()); assert_eq!(emitter.execution(), "parse-1"); assert_eq!(emitter.task(), &root()); diff --git a/crates/promptforge-api-types/src/emitter.rs b/crates/promptforge-api-types/src/emitter.rs index 02421ffd3..eb0d1c0fd 100644 --- a/crates/promptforge-api-types/src/emitter.rs +++ b/crates/promptforge-api-types/src/emitter.rs @@ -30,11 +30,28 @@ use crate::event::Event; use crate::event::lifecycle::Lifecycle; use crate::ids::{ChainId, Provenance, TaskId}; use crate::metrics::{CallMetrics, ToolCallEvent}; +use crate::tools::OutputTrust; #[cfg(test)] #[path = "emitter-tests.rs"] mod tests; +/// Whether a run captures each model round's raw request and response +/// bodies as `Request` and `Response` events. +/// +/// Off by default: the bodies already travel in the `Chat` effect and its +/// answer, so a host that logs effects has them; a host that wants the +/// pair in the event stream too turns it on. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum DebugMode { + /// The model rounds emit no `Request` or `Response` events and never + /// clone a body. + #[default] + Off, + /// Every model round emits its raw request and response bodies. + On, +} + /// The run's event buffer: the events not yet drained, plus one sequence /// counter per task the run has reported under. #[derive(Debug, Default)] @@ -64,11 +81,11 @@ impl EventBuffer { /// /// # Examples /// ``` -/// use promptforge_api_types::emitter::{Emitter, EventSink}; +/// use promptforge_api_types::emitter::{DebugMode, Emitter, EventSink}; /// use promptforge_api_types::event::{Event, lifecycle}; /// /// let sink = EventSink::default(); -/// let emitter = Emitter::root(sink.clone(), "run-1", false); +/// let emitter = Emitter::root(sink.clone(), "run-1", DebugMode::Off); /// emitter.report("Gather", lifecycle::SECTION_STARTED); /// let events = sink.take(); /// assert!(matches!(events.as_slice(), [Event::SectionStarted { section, .. }] if section == "Gather")); @@ -147,15 +164,15 @@ pub struct Emitter { /// The caller-chosen run identifier every event carries. execution: Arc, /// Whether the host asked for raw request/response capture: the model - /// rounds emit `Request` and `Response` only when set, so a run that - /// did not opt in never clones a body. - debug: bool, + /// rounds emit `Request` and `Response` only when [`DebugMode::On`], + /// so a run that did not opt in never clones a body. + debug: DebugMode, } impl Emitter { /// Builds the emitter for `task` over `sink`. #[must_use] - pub fn new(sink: EventSink, task: TaskId, execution: Arc, debug: bool) -> Self { + pub fn new(sink: EventSink, task: TaskId, execution: Arc, debug: DebugMode) -> Self { Self { sink, task, @@ -167,7 +184,7 @@ impl Emitter { /// The root task's emitter over `sink`: the main walk is task `0`, /// and so is a prompt's parse, which happens before any run exists. #[must_use] - pub fn root(sink: EventSink, execution: &str, debug: bool) -> Self { + pub fn root(sink: EventSink, execution: &str, debug: DebugMode) -> Self { Self::new( sink, TaskId::from(ChainId::root()), @@ -203,7 +220,7 @@ impl Emitter { /// Whether the run captures raw model-turn bodies. #[must_use] pub fn captures_debug(&self) -> bool { - self.debug + self.debug == DebugMode::On } /// Stamps one issued effect: this task's next provenance, drawn from @@ -295,7 +312,9 @@ impl Emitter { }); } - /// Reports the result of one dispatched tool call. + /// Reports the result of one dispatched tool call. The event carries + /// `trust` as its `trusted` flag: `true` only for + /// [`OutputTrust::Trusted`]. pub fn tool_result( &self, section: &str, @@ -303,8 +322,9 @@ impl Emitter { tool_call_id: &str, alias: &str, content: &str, - trusted: bool, + trust: OutputTrust, ) { + let trusted = trust == OutputTrust::Trusted; self.emit(section, |execution, section, provenance| { Event::ToolResult { execution, diff --git a/crates/promptforge-api-types/src/event-tests.rs b/crates/promptforge-api-types/src/event-tests.rs index a1e30aae2..258abcebc 100644 --- a/crates/promptforge-api-types/src/event-tests.rs +++ b/crates/promptforge-api-types/src/event-tests.rs @@ -1,3 +1,5 @@ +//! Tests for `Event` serde round trips and coordinate exposure. + use serde_json::json; use super::Event; diff --git a/crates/promptforge-api-types/src/event.rs b/crates/promptforge-api-types/src/event.rs index a0090063c..fe3621f12 100644 --- a/crates/promptforge-api-types/src/event.rs +++ b/crates/promptforge-api-types/src/event.rs @@ -84,6 +84,7 @@ macro_rules! events { $(#[$enum_meta])* #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] + #[non_exhaustive] pub enum $name { $( $(#[$variant_meta])* diff --git a/crates/promptforge-api-types/src/ids-tests.rs b/crates/promptforge-api-types/src/ids-tests.rs index b6e45e385..dbe892985 100644 --- a/crates/promptforge-api-types/src/ids-tests.rs +++ b/crates/promptforge-api-types/src/ids-tests.rs @@ -1,3 +1,5 @@ +//! Tests for `ChainId`, `TaskId`, `TaskOrigin`, and `Provenance` rendering, parsing, and ordering. + use super::{ChainId, Provenance, TaskId, TaskOrigin}; #[test] diff --git a/crates/promptforge-api-types/src/ids.rs b/crates/promptforge-api-types/src/ids.rs index 965edbe53..85b89aeb2 100644 --- a/crates/promptforge-api-types/src/ids.rs +++ b/crates/promptforge-api-types/src/ids.rs @@ -221,6 +221,7 @@ pub struct Provenance { /// tag is the string the Lua shims and the `tasks.pending` filter use. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] +#[non_exhaustive] pub enum TaskOrigin { /// The prompt's author, through `tasks.spawn` (and `fanout` over it). Author, @@ -258,6 +259,7 @@ impl TaskOrigin { /// kind of owner end it was, so the notice can say more than "abandoned". #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] +#[non_exhaustive] pub enum AbandonReason { /// The owner ended normally - a scalar return or an exhausted walk - /// without waiting on or cancelling the task. For an author task this diff --git a/crates/promptforge-api-types/src/names-tests.rs b/crates/promptforge-api-types/src/names-tests.rs index 1e42be483..ab4e9fc7e 100644 --- a/crates/promptforge-api-types/src/names-tests.rs +++ b/crates/promptforge-api-types/src/names-tests.rs @@ -1,3 +1,5 @@ +//! Tests for `GlobalName` parsing and its rejection kinds. + use super::{GlobalName, GlobalNameErrorKind}; fn kind_of(input: &str) -> GlobalNameErrorKind { diff --git a/crates/promptforge-api-types/src/replay-tests.rs b/crates/promptforge-api-types/src/replay-tests.rs index 2bb061bd5..eaee286e2 100644 --- a/crates/promptforge-api-types/src/replay-tests.rs +++ b/crates/promptforge-api-types/src/replay-tests.rs @@ -1,3 +1,5 @@ +//! Tests for replay `Flags` and `ReplayError` rendering. + use super::{Flags, ReplayError}; #[test] diff --git a/crates/promptforge-api-types/src/replay.rs b/crates/promptforge-api-types/src/replay.rs index 8c8d01b1f..69ea2f05b 100644 --- a/crates/promptforge-api-types/src/replay.rs +++ b/crates/promptforge-api-types/src/replay.rs @@ -101,6 +101,7 @@ impl BitOrAssign for Flags { /// effect with two answers, a sequence gap, an unparseable payload), so /// there is nothing sound to replay against. #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +#[non_exhaustive] pub enum ReplayError { /// The re-executed run or task disagreed with its record. #[error("replay diverged from its record: {detail}")] diff --git a/crates/promptforge-api-types/src/timestamp-tests.rs b/crates/promptforge-api-types/src/timestamp-tests.rs index 57925e2c0..99c6fc607 100644 --- a/crates/promptforge-api-types/src/timestamp-tests.rs +++ b/crates/promptforge-api-types/src/timestamp-tests.rs @@ -1,3 +1,5 @@ +//! Tests that the std-only RFC 3339 formatter agrees with the `time` crate. + use time::OffsetDateTime; use time::format_description::well_known::Rfc3339; diff --git a/crates/promptforge-api-types/src/tools/tests.rs b/crates/promptforge-api-types/src/tools/tests.rs index fb9f28748..810bd34e1 100644 --- a/crates/promptforge-api-types/src/tools/tests.rs +++ b/crates/promptforge-api-types/src/tools/tests.rs @@ -1,3 +1,5 @@ +//! Tests for `ToolId`, `ToolDescriptor`, `ToolCatalog`, and tool output trust. + use serde_json::json; use super::{ToolCatalog, ToolCatalogErrorKind, ToolDescriptor, ToolId}; diff --git a/crates/promptforge/lua/benches/surface.rs b/crates/promptforge/lua/benches/surface.rs index cedf6f739..b9e2c41e8 100644 --- a/crates/promptforge/lua/benches/surface.rs +++ b/crates/promptforge/lua/benches/surface.rs @@ -18,7 +18,7 @@ use std::num::NonZeroU32; use std::sync::{Arc, Mutex}; use criterion::{Criterion, criterion_group, criterion_main}; -use promptforge_api_types::emitter::{Emitter, EventSink}; +use promptforge_api_types::emitter::{DebugMode, Emitter, EventSink}; use promptforge_api_types::untrusted::GuardNonce; use promptforge_lua::{ LuaProgram, MessageContent, MessageRecord, MessageRole, SectionVm, ToolCallRecord, ToolSet, @@ -32,7 +32,7 @@ const SECTION: &str = "Bench"; /// An emitter over a sink nobody drains: the bench measures the VM, not /// the reports. fn emitter() -> Emitter { - Emitter::root(EventSink::default(), "bench", false) + Emitter::root(EventSink::default(), "bench", DebugMode::Off) } /// A section VM with host values injected, so the `messages` namespace is diff --git a/crates/promptforge/lua/src/compactors-tests.rs b/crates/promptforge/lua/src/compactors-tests.rs index fd96b1967..1cb82d75b 100644 --- a/crates/promptforge/lua/src/compactors-tests.rs +++ b/crates/promptforge/lua/src/compactors-tests.rs @@ -1,3 +1,5 @@ +//! Tests for the `compactors` namespace, the context-window precheck, and provider overflow detection. + use mlua::Lua; use promptforge_model_client::client::Message; use serde_json::{Value, json}; diff --git a/crates/promptforge/lua/src/dispatch-tests.rs b/crates/promptforge/lua/src/dispatch-tests.rs index 797457fbf..e8cca317f 100644 --- a/crates/promptforge/lua/src/dispatch-tests.rs +++ b/crates/promptforge/lua/src/dispatch-tests.rs @@ -1,7 +1,9 @@ //! Tests for the shared tool-dispatch body: the fixture tools and recorder //! every dispatch test uses, and the synchronous `prepare_dispatch` tests. -use promptforge_api_types::tools::{ToolDescriptor, ToolError, ToolErrorKind, ToolId, ToolOutput}; +use promptforge_api_types::tools::{ + OutputTrust, ToolDescriptor, ToolError, ToolErrorKind, ToolId, ToolOutput, +}; use serde_json::json; use super::*; @@ -60,7 +62,11 @@ fn prepare_dispatch_wraps_a_canned_untrusted_output_counts_it_and_reports_it() { nonce.wrap("canned output"), "an untrusted canned output is nonce-wrapped byte for byte" ); - assert!(!outcome.trusted(), "the untrusted marking survives"); + assert_eq!( + outcome.trust(), + OutputTrust::Untrusted, + "the untrusted marking survives" + ); assert_eq!( counts.get("echo").expect("the counts read"), Some(1), @@ -131,7 +137,11 @@ fn a_model_issued_tool_failure_becomes_untrusted_failure_text_under_its_call_id( &model_report("call_1"), ) .expect("a model-issued call never fails for the tool's own failure"); - assert!(!outcome.trusted(), "the failure text is untrusted"); + assert_eq!( + outcome.trust(), + OutputTrust::Untrusted, + "the failure text is untrusted" + ); assert_eq!( outcome.content(), nonce.wrap("the tool's own backend failed"), diff --git a/crates/promptforge/lua/src/dispatch.rs b/crates/promptforge/lua/src/dispatch.rs index 808f5b883..4dd0f2a51 100644 --- a/crates/promptforge/lua/src/dispatch.rs +++ b/crates/promptforge/lua/src/dispatch.rs @@ -57,7 +57,7 @@ pub struct ModelReport { #[derive(Debug, Clone, PartialEq, Eq)] pub struct ToolDispatch { content: String, - trusted: bool, + trust: OutputTrust, } impl ToolDispatch { @@ -68,10 +68,12 @@ impl ToolDispatch { &self.content } - /// Whether the tool declared its output trusted. + /// The trust marking the content carries: [`OutputTrust::Trusted`] + /// for verbatim output, [`OutputTrust::Untrusted`] for the + /// nonce-wrapped envelope. #[must_use] - pub fn trusted(&self) -> bool { - self.trusted + pub fn trust(&self) -> OutputTrust { + self.trust } /// Dissolves the outcome into its content. @@ -129,17 +131,17 @@ pub fn prepare_dispatch( // byte-identical envelope and KV-cache prefixes stay shared across // rounds and fanout arms; the `<`-escaping is what actually blocks a // forged close tag, so the reuse costs nothing. - let (content, trusted) = match output.trust() { - OutputTrust::Trusted => (output.text().to_owned(), true), + let (content, trust) = match output.trust() { + OutputTrust::Trusted => (output.text().to_owned(), OutputTrust::Trusted), // `OutputTrust` is `#[non_exhaustive]` in the contract crate: an // unknown future variant takes the safe path and is nonce-wrapped // as untrusted. - _ => (nonce.wrap(output.text()), false), + _ => (nonce.wrap(output.text()), OutputTrust::Untrusted), }; if let Some(report) = script { - emitter.tool_result(section, report.turn, "", binding.alias(), &content, trusted); + emitter.tool_result(section, report.turn, "", binding.alias(), &content, trust); } - Ok(ToolDispatch { content, trusted }) + Ok(ToolDispatch { content, trust }) } /// Applies the model-issued dispatch rules to one bound tool call's answer: @@ -171,7 +173,7 @@ pub fn prepare_model_dispatch( Ok(outcome) => outcome, Err(Error::Tool { message, .. }) => ToolDispatch { content: nonce.wrap(&message), - trusted: false, + trust: OutputTrust::Untrusted, }, Err(error) => return Err(error), }; @@ -181,7 +183,7 @@ pub fn prepare_model_dispatch( &report.call_id, binding.alias(), &outcome.content, - outcome.trusted, + outcome.trust, ); Ok(outcome) } diff --git a/crates/promptforge/lua/src/error.rs b/crates/promptforge/lua/src/error.rs index 635d667c4..eeed465d9 100644 --- a/crates/promptforge/lua/src/error.rs +++ b/crates/promptforge/lua/src/error.rs @@ -174,7 +174,7 @@ pub(crate) mod lua_quota { } impl Error { - /// Wrap an `mlua` failure as [`Error::LuaRuntime`], preserving it as the + /// Wraps an `mlua` failure as [`Error::LuaRuntime`], preserving it as the /// `#[source]` cause (F4) rather than flattening it to a string. pub(crate) fn lua(source: mlua::Error) -> Error { Error::LuaRuntime { @@ -194,7 +194,7 @@ impl Error { } } - /// Wrap a tool failure as [`Error::Tool`], preserving the tool's own + /// Wraps a tool failure as [`Error::Tool`], preserving the tool's own /// error as the `#[source]` cause rather than discarding it. pub(crate) fn tool(source: promptforge_api_types::tools::ToolError) -> Error { Error::Tool { diff --git a/crates/promptforge/lua/src/handles.rs b/crates/promptforge/lua/src/handles.rs index 2ce1348db..1081e54f3 100644 --- a/crates/promptforge/lua/src/handles.rs +++ b/crates/promptforge/lua/src/handles.rs @@ -1,3 +1,5 @@ +//! Tool bindings, the shared tool set, and the per-binding output kind that shape how bound tools reach Lua. + use promptforge_api_types::capabilities::CapabilityId; use promptforge_api_types::tools::ToolDescriptor; diff --git a/crates/promptforge/lua/src/hardening.rs b/crates/promptforge/lua/src/hardening.rs index 2a297a1ad..c5f1d58ab 100644 --- a/crates/promptforge/lua/src/hardening.rs +++ b/crates/promptforge/lua/src/hardening.rs @@ -1,3 +1,5 @@ +//! Sandbox hardening for section VMs: global removal, the instruction-budget hook, and scalar return rendering. + use std::sync::OnceLock; use promptforge_api_types::cancel::CancelHandle; @@ -7,7 +9,7 @@ use super::{ Result, Thread, Value, VmState, }; -/// Remove code-loading, direct output, and reflection globals the base library +/// Removes code-loading, direct output, and reflection globals the base library /// provides. The `io`, `os`, `package`, `coroutine`, and `debug` libraries are /// never loaded. /// @@ -154,7 +156,7 @@ fn budget_hook( } } -/// Install the every-Nth-instruction hook that keeps a block cancellable. +/// Installs the every-Nth-instruction hook that keeps a block cancellable. /// /// The hook covers the main state only; coroutines need /// [`InstructionBudget::install_on_thread`] with the returned counter. @@ -171,7 +173,7 @@ pub(crate) fn install_instruction_budget(lua: &Lua) -> Result Ok(budget) } -/// Render a returned Lua scalar as the section's result string. Tables and other +/// Renders a returned Lua scalar as the section's result string. Tables and other /// non-scalar returns are deferred to a later commit. pub(crate) fn value_to_string(value: &Value) -> Result { match value { diff --git a/crates/promptforge/lua/src/host.rs b/crates/promptforge/lua/src/host.rs index efd494b11..fcf1f9177 100644 --- a/crates/promptforge/lua/src/host.rs +++ b/crates/promptforge/lua/src/host.rs @@ -1,3 +1,5 @@ +//! Host callbacks installed into every section VM: `log`, `untrusted`, `ui`, and the `store` table. + use promptforge_api_types::event::lifecycle::Lifecycle; use super::{ @@ -197,7 +199,7 @@ fn read_store_numbered( read_store_bounded(store, path, start, end, true) } -/// Expose an always-on `store` table whose methods (`write`, `append`, +/// Exposes an always-on `store` table whose methods (`write`, `append`, /// `read`, `read_numbered`, `str_replace`, `delete`, /// `glob`, `exists`) are backed by the [`Store`] facade over the caller's /// VFS access capability. diff --git a/crates/promptforge/lua/src/messages-tests.rs b/crates/promptforge/lua/src/messages-tests.rs index 90633dab8..f7fa1d4ea 100644 --- a/crates/promptforge/lua/src/messages-tests.rs +++ b/crates/promptforge/lua/src/messages-tests.rs @@ -1,3 +1,5 @@ +//! Tests for the `messages.new()` builders and their parse through the protocol. + use mlua::{Lua, LuaSerdeExt, Value}; use promptforge_api_types::untrusted::GuardNonce; use serde_json::json; diff --git a/crates/promptforge/lua/src/models/tests.rs b/crates/promptforge/lua/src/models-tests.rs similarity index 98% rename from crates/promptforge/lua/src/models/tests.rs rename to crates/promptforge/lua/src/models-tests.rs index cbd66497a..5cf0a371e 100644 --- a/crates/promptforge/lua/src/models/tests.rs +++ b/crates/promptforge/lua/src/models-tests.rs @@ -1,3 +1,5 @@ +//! Tests for the `models` namespace: `use`, `default`, `get`, and the model runtime selection. + use super::{ModelRuntime, install_models}; use mlua::Lua; use promptforge_model_client::model::ModelBinding; diff --git a/crates/promptforge/lua/src/models/userdata.rs b/crates/promptforge/lua/src/models-userdata.rs similarity index 100% rename from crates/promptforge/lua/src/models/userdata.rs rename to crates/promptforge/lua/src/models-userdata.rs diff --git a/crates/promptforge/lua/src/models/mod.rs b/crates/promptforge/lua/src/models.rs similarity index 99% rename from crates/promptforge/lua/src/models/mod.rs rename to crates/promptforge/lua/src/models.rs index 6670eb279..5f3955695 100644 --- a/crates/promptforge/lua/src/models/mod.rs +++ b/crates/promptforge/lua/src/models.rs @@ -18,6 +18,7 @@ use promptforge_model_client::model::{ModelBinding, ModelId, ModelInvocation, Mo use crate::alias::validate_alias; use crate::{Error, Result}; +#[path = "models-userdata.rs"] mod userdata; pub(crate) use userdata::{LuaModelHandle, ModelsInferHook}; @@ -215,4 +216,5 @@ pub(crate) fn install_models( } #[cfg(test)] +#[path = "models-tests.rs"] mod tests; diff --git a/crates/promptforge/lua/src/program.rs b/crates/promptforge/lua/src/program.rs index 08bc25ac8..a9404b1b1 100644 --- a/crates/promptforge/lua/src/program.rs +++ b/crates/promptforge/lua/src/program.rs @@ -1,3 +1,5 @@ +//! Compiled Lua chunks: bytecode compilation with debug info and chunk-line to prompt-line mapping. + use super::{Emitter, Error, Function, Lua, LuaOptions, NonZeroU32, Result, StdLib, lifecycle}; /// Identifies whether temporary compiler setup or chunk compilation failed. @@ -53,11 +55,11 @@ fn compile_chunk(source: &str, location: &str) -> std::result::Result, C /// ``` /// use std::num::NonZeroU32; /// -/// use promptforge_api_types::emitter::{Emitter, EventSink}; +/// use promptforge_api_types::emitter::{DebugMode, Emitter, EventSink}; /// use promptforge_lua::LuaProgram; /// /// let sink = EventSink::default(); -/// let emitter = Emitter::root(sink.clone(), "doc", false); +/// let emitter = Emitter::root(sink.clone(), "doc", DebugMode::Off); /// let program = LuaProgram::compile( /// "return 1", /// "section `Only` prologue", @@ -104,10 +106,10 @@ impl LuaProgram { /// use std::num::NonZeroU32; /// /// use mlua::Lua; - /// use promptforge_api_types::emitter::{Emitter, EventSink}; + /// use promptforge_api_types::emitter::{DebugMode, Emitter, EventSink}; /// use promptforge_lua::LuaProgram; /// - /// let emitter = Emitter::root(EventSink::default(), "example-run", false); + /// let emitter = Emitter::root(EventSink::default(), "example-run", DebugMode::Off); /// let program = LuaProgram::compile( /// "return 40 + 2", /// "example prologue", diff --git a/crates/promptforge/lua/src/projection-tests.rs b/crates/promptforge/lua/src/projection-tests.rs index 1e607c8ee..26ec170b0 100644 --- a/crates/promptforge/lua/src/projection-tests.rs +++ b/crates/promptforge/lua/src/projection-tests.rs @@ -1,3 +1,5 @@ +//! Tests for projecting Lua message records onto the wire conversation shape. + use mlua::{Lua, Value}; use serde_json::json; diff --git a/crates/promptforge/lua/src/protocol/request.rs b/crates/promptforge/lua/src/protocol/request.rs index 4f1d4e6d9..131a3bccd 100644 --- a/crates/promptforge/lua/src/protocol/request.rs +++ b/crates/promptforge/lua/src/protocol/request.rs @@ -233,6 +233,7 @@ impl Request { /// Plain data, so the executor's effect record can carry an operation /// through serde exactly as the shim yielded it. #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[non_exhaustive] pub enum StoreOp { /// `store.write(path, contents)`. Write { diff --git a/crates/promptforge/lua/src/protocol/tests/mod.rs b/crates/promptforge/lua/src/protocol/tests.rs similarity index 100% rename from crates/promptforge/lua/src/protocol/tests/mod.rs rename to crates/promptforge/lua/src/protocol/tests.rs diff --git a/crates/promptforge/lua/src/scope.rs b/crates/promptforge/lua/src/scope.rs index 58e629e92..e09c79a85 100644 --- a/crates/promptforge/lua/src/scope.rs +++ b/crates/promptforge/lua/src/scope.rs @@ -1,3 +1,5 @@ +//! Per-VM tool runtime state: call counts, the task allowlist, and the scoped tool set a section sees. + use super::{Arc, BTreeMap, Error, Mutex, Result}; /// Shared per-VM tool-call counts, seeded at 0 for every alias the installer diff --git a/crates/promptforge/lua/src/sys.rs b/crates/promptforge/lua/src/sys.rs index 500658703..6abc36cc1 100644 --- a/crates/promptforge/lua/src/sys.rs +++ b/crates/promptforge/lua/src/sys.rs @@ -1,3 +1,5 @@ +//! The sealed `sys` table and the guarded `var` proxy that sandboxed author code reads and writes. + use super::{Error, Json, Lua, LuaSerdeExt, ModelBinding, Result, Value}; /// The registry key holding the `var` proxy's hidden data table. diff --git a/crates/promptforge/lua/src/tests-recording.rs b/crates/promptforge/lua/src/tests-recording.rs index 0f75551c8..e64cd92bd 100644 --- a/crates/promptforge/lua/src/tests-recording.rs +++ b/crates/promptforge/lua/src/tests-recording.rs @@ -9,7 +9,7 @@ use std::sync::Mutex; -use promptforge_api_types::emitter::{Emitter, EventSink}; +use promptforge_api_types::emitter::{DebugMode, Emitter, EventSink}; use promptforge_api_types::event::Event; /// One event folded to what a suite compares: a payload-free boundary by @@ -124,7 +124,7 @@ impl Recorder { /// A recorder whose emitter reports under `execution`. pub(crate) fn for_execution(execution: &str) -> Self { let sink = EventSink::default(); - let emitter = Emitter::root(sink.clone(), execution, false); + let emitter = Emitter::root(sink.clone(), execution, DebugMode::Off); Self { sink, emitter, @@ -140,7 +140,7 @@ impl Recorder { /// A second emitter over the same sink reporting under another /// execution id, for a test that interleaves runs. pub(crate) fn emitter_for(&self, execution: &str) -> Emitter { - Emitter::root(self.sink.clone(), execution, false) + Emitter::root(self.sink.clone(), execution, DebugMode::Off) } /// Every event reported so far, in order. @@ -212,5 +212,5 @@ impl Recorder { /// An emitter whose events nobody reads: the silent stand-in a test passes /// where it has nothing to assert about the boundaries. pub(crate) fn null_emitter() -> Emitter { - Emitter::root(EventSink::default(), "lua-test", false) + Emitter::root(EventSink::default(), "lua-test", DebugMode::Off) } diff --git a/crates/promptforge/lua/src/tests.rs b/crates/promptforge/lua/src/tests.rs index b67501026..31a75e353 100644 --- a/crates/promptforge/lua/src/tests.rs +++ b/crates/promptforge/lua/src/tests.rs @@ -1,3 +1,5 @@ +//! Crate-wide tests for section VMs: sandboxing, logging, tool scoping, store operations, `var`, and `argv`. + use std::sync::{Arc, Mutex}; use super::*; @@ -137,7 +139,7 @@ fn test_nonce() -> GuardNonce { GuardNonce::from_seed(0x6c75_6174_6573) } -/// Run a chunk against a caller-supplied access, so a test can inspect the +/// Runs a chunk against a caller-supplied access, so a test can inspect the /// store through the same identity after the chunk has run. fn run_with(source: &str, access: &Arc) -> Result { run_chunk( diff --git a/crates/promptforge/lua/src/tools/mod.rs b/crates/promptforge/lua/src/tools.rs similarity index 100% rename from crates/promptforge/lua/src/tools/mod.rs rename to crates/promptforge/lua/src/tools.rs diff --git a/crates/promptforge/lua/src/tools/tests.rs b/crates/promptforge/lua/src/tools/tests.rs index 1eb059551..13a0f0a79 100644 --- a/crates/promptforge/lua/src/tools/tests.rs +++ b/crates/promptforge/lua/src/tools/tests.rs @@ -1,3 +1,5 @@ +//! Tests for the `tools` namespace installers, alias decoding, and the local params schema. + use mlua::{Lua, Value, Variadic}; use promptforge_api_types::untrusted::GuardNonce; use serde_json::json; diff --git a/crates/promptforge/lua/src/vm.rs b/crates/promptforge/lua/src/vm.rs index 204d1d71a..10a8896a5 100644 --- a/crates/promptforge/lua/src/vm.rs +++ b/crates/promptforge/lua/src/vm.rs @@ -1,3 +1,5 @@ +//! The per-section Lua VM: construction, host injection, coroutine stepping, and chunk execution. + use super::{ Access, Arc, Argv, AtomicU32, AtomicUsize, BTreeMap, DEFAULT_LUA_LOG_EVENTS, DEFAULT_LUA_MEMORY_BYTES, Emitter, Error, Function, GuardNonce, InstructionBudget, @@ -48,16 +50,16 @@ pub(crate) fn pack_sequence( /// the same explicit observed teardown boundary as later lifecycle failures. /// /// # Examples -/// ```text +/// ```no_run /// use promptforge_lua::SectionVm; -/// use promptforge_api_types::emitter::{Emitter, EventSink}; +/// use promptforge_api_types::emitter::{DebugMode, Emitter, EventSink}; /// use promptforge_api_types::untrusted::GuardNonce; /// /// let nonce = GuardNonce::from_seed(1); -/// let emitter = Emitter::root(EventSink::default(), "example-run", false); +/// let emitter = Emitter::root(EventSink::default(), "example-run", DebugMode::Off); /// let vm = SectionVm::new(&nonce, &emitter, "Example")?; /// vm.teardown(&emitter, "Example"); -/// # Ok::<(), promptforge_lua::Error>(()) +/// # Ok::<(), Box>(()) /// ``` #[derive(Debug)] pub struct SectionVm { @@ -228,16 +230,16 @@ impl SectionVm { /// Returns [`Error::Lua`] if the VM cannot be built or hardened. /// /// # Examples - /// ```text + /// ```no_run /// use promptforge_lua::SectionVm; - /// use promptforge_api_types::emitter::{Emitter, EventSink}; + /// use promptforge_api_types::emitter::{DebugMode, Emitter, EventSink}; /// use promptforge_api_types::untrusted::GuardNonce; /// /// let nonce = GuardNonce::from_seed(1); - /// let emitter = Emitter::root(EventSink::default(), "example-run", false); + /// let emitter = Emitter::root(EventSink::default(), "example-run", DebugMode::Off); /// let vm = SectionVm::new(&nonce, &emitter, "Example")?; /// vm.teardown(&emitter, "Example"); - /// # Ok::<(), promptforge_lua::Error>(()) + /// # Ok::<(), Box>(()) /// ``` pub fn new(nonce: &GuardNonce, emitter: &Emitter, section: &str) -> Result { let lua = Lua::new_with( @@ -430,22 +432,21 @@ impl SectionVm { /// values were already injected. /// /// # Examples - /// ```text + /// ```no_run /// use promptforge_lua::SectionVm; - /// use promptforge_api_types::emitter::{Emitter, EventSink}; + /// use promptforge_api_types::emitter::{DebugMode, Emitter, EventSink}; /// use promptforge_api_types::untrusted::GuardNonce; /// /// let nonce = GuardNonce::from_seed(1); - /// let emitter = Emitter::root(EventSink::default(), "example-run", false); + /// let emitter = Emitter::root(EventSink::default(), "example-run", DebugMode::Off); /// let vfs = promptforge_vfs::empty(); /// let access = std::sync::Arc::new( - /// vfs.acquire(shared_vfs::Origin::new("vm example")) - /// .expect("the stock backend acquires"), + /// vfs.acquire(shared_vfs::Origin::new("vm example"))?, /// ); /// let mut vm = SectionVm::new(&nonce, &emitter, "Example")?; /// vm.inject_host("input", &serde_json::json!({ "id": 1 }), &access)?; /// vm.teardown(&emitter, "Example"); - /// # Ok::<(), promptforge_lua::Error>(()) + /// # Ok::<(), Box>(()) /// ``` pub fn inject_host(&mut self, args: &str, sys: &Json, access: &Arc) -> Result<()> { self.inject_host_with_var(args, sys, access, None, Argv::Frozen(None)) @@ -780,23 +781,22 @@ impl SectionVm { /// cannot be represented as JSON. /// /// # Examples - /// ```text + /// ```no_run /// use promptforge_lua::SectionVm; - /// use promptforge_api_types::emitter::{Emitter, EventSink}; + /// use promptforge_api_types::emitter::{DebugMode, Emitter, EventSink}; /// use promptforge_api_types::untrusted::GuardNonce; /// /// let nonce = GuardNonce::from_seed(1); - /// let emitter = Emitter::root(EventSink::default(), "example-run", false); + /// let emitter = Emitter::root(EventSink::default(), "example-run", DebugMode::Off); /// let vfs = promptforge_vfs::empty(); /// let access = std::sync::Arc::new( - /// vfs.acquire(shared_vfs::Origin::new("vm example")) - /// .expect("the stock backend acquires"), + /// vfs.acquire(shared_vfs::Origin::new("vm example"))?, /// ); /// let mut vm = SectionVm::new(&nonce, &emitter, "Example")?; /// vm.inject_host("", &serde_json::json!({}), &access)?; /// assert_eq!(vm.var()?, serde_json::json!({})); /// vm.teardown(&emitter, "Example"); - /// # Ok::<(), promptforge_lua::Error>(()) + /// # Ok::<(), Box>(()) /// ``` pub fn var(&self) -> Result { if !self.host_injected { @@ -993,16 +993,16 @@ impl SectionVm { /// retained by the VM. /// /// # Examples - /// ```text + /// ```no_run /// use promptforge_lua::SectionVm; - /// use promptforge_api_types::emitter::{Emitter, EventSink}; + /// use promptforge_api_types::emitter::{DebugMode, Emitter, EventSink}; /// use promptforge_api_types::untrusted::GuardNonce; /// /// let nonce = GuardNonce::from_seed(1); - /// let emitter = Emitter::root(EventSink::default(), "example-run", false); + /// let emitter = Emitter::root(EventSink::default(), "example-run", DebugMode::Off); /// let vm = SectionVm::new(&nonce, &emitter, "Example")?; /// vm.teardown(&emitter, "Example"); - /// # Ok::<(), promptforge_lua::Error>(()) + /// # Ok::<(), Box>(()) /// ``` pub fn teardown(self, emitter: &Emitter, section: &str) { emitter.report(section, lifecycle::LUA_TEARDOWN_STARTED); @@ -1275,7 +1275,7 @@ pub(crate) struct LuaOutcome { pub(crate) var: Json, } -/// Run a section's Lua chunk with `args` and `sys` exposed, a writable `var` +/// Runs a section's Lua chunk with `args` and `sys` exposed, a writable `var` /// table available, and a `store` table backed by `store`, returning the /// chunk's return value and the final `var`. Harness-mediated store operations /// report safe outcomes through `emitter` under `section`. diff --git a/crates/promptforge/model-client/src/client/read-tests.rs b/crates/promptforge/model-client/src/client/read-tests.rs index f2f77fd8f..1be33926e 100644 --- a/crates/promptforge/model-client/src/client/read-tests.rs +++ b/crates/promptforge/model-client/src/client/read-tests.rs @@ -1,3 +1,5 @@ +//! Tests for capped body reads and SSE completion-stream reassembly. + use std::cell::Cell; use std::collections::VecDeque; use std::future::Future; diff --git a/crates/promptforge/model-client/src/client/stream-tests.rs b/crates/promptforge/model-client/src/client/stream-tests.rs index cabdc5d30..67b0cb879 100644 --- a/crates/promptforge/model-client/src/client/stream-tests.rs +++ b/crates/promptforge/model-client/src/client/stream-tests.rs @@ -1,3 +1,5 @@ +//! Tests for the streaming accumulator and the SSE data-line scanner. + use serde_json::Value; use super::*; diff --git a/crates/promptforge/model-client/src/client/tests.rs b/crates/promptforge/model-client/src/client/tests.rs index a7fa9cfc7..518c5103b 100644 --- a/crates/promptforge/model-client/src/client/tests.rs +++ b/crates/promptforge/model-client/src/client/tests.rs @@ -1,3 +1,5 @@ +//! Tests for the wire message constructors and tool schema validation. + use serde_json::Value; use super::*; diff --git a/crates/promptforge/model-client/src/client/wire.rs b/crates/promptforge/model-client/src/client/wire.rs index 36d82b8da..48a3aeb0f 100644 --- a/crates/promptforge/model-client/src/client/wire.rs +++ b/crates/promptforge/model-client/src/client/wire.rs @@ -36,7 +36,7 @@ pub struct Message { } impl Message { - /// Construct a `user` message. + /// Constructs a `user` message. /// /// # Examples /// @@ -57,7 +57,7 @@ impl Message { } } - /// Construct a `tool` message carrying the result of a tool call. + /// Constructs a `tool` message carrying the result of a tool call. /// /// `tool_call_id` must match the `id` of the [`ToolCall`] this answers. #[must_use] @@ -70,7 +70,7 @@ impl Message { } } - /// Construct a plain `assistant` text turn (no `tool_calls` field). + /// Constructs a plain `assistant` text turn (no `tool_calls` field). #[must_use] pub fn assistant(content: impl Into) -> Message { Message { @@ -107,7 +107,7 @@ impl Message { } } - /// Construct the `assistant` turn that requested tool calls. + /// Constructs the `assistant` turn that requested tool calls. /// /// `raw_tool_calls` is the backend's `tool_calls` array echoed back /// verbatim so the conversation history matches what the model emitted. diff --git a/crates/promptforge/model-client/src/error.rs b/crates/promptforge/model-client/src/error.rs index 70d349815..07bf3bf8b 100644 --- a/crates/promptforge/model-client/src/error.rs +++ b/crates/promptforge/model-client/src/error.rs @@ -134,7 +134,7 @@ pub enum Error { } impl Error { - /// Wrap a transport-layer error, hiding its concrete type from the API. + /// Wraps a transport-layer error, hiding its concrete type from the API. /// /// A transport that knows the failure was a timeout wraps it in /// [`Timeout`] first, so [`CompletionError::is_timeout`] can say so diff --git a/crates/promptforge/model-client/src/model/tests.rs b/crates/promptforge/model-client/src/model/tests.rs index e589064b1..2bc79c3e7 100644 --- a/crates/promptforge/model-client/src/model/tests.rs +++ b/crates/promptforge/model-client/src/model/tests.rs @@ -1,3 +1,5 @@ +//! Tests for model bindings and invocation identity. + use std::num::NonZeroU32; use super::*; diff --git a/crates/promptforge/model-client/src/normalize.rs b/crates/promptforge/model-client/src/normalize.rs index 17f5d895b..30274266c 100644 --- a/crates/promptforge/model-client/src/normalize.rs +++ b/crates/promptforge/model-client/src/normalize.rs @@ -57,7 +57,7 @@ pub(crate) struct TurnContext<'a> { pub(crate) reasoning_content: Option, } -/// Extract and shape-validate the first choice's per-turn context. +/// Extracts and shape-validates the first choice's per-turn context. /// /// # Errors /// Returns [`Error::MalformedResponse`] when `choices` is missing or not a @@ -181,7 +181,7 @@ pub(crate) fn normalize(body: &Value) -> Result { )) } -/// Parse the OpenAI `message.tool_calls` array into runtime [`ToolCall`]s. +/// Parses the OpenAI `message.tool_calls` array into runtime [`ToolCall`]s. /// /// Each call must be an object with a nonblank string `id`, an object /// `function` carrying a nonblank string `name`, and an `arguments` field that diff --git a/crates/promptforge/parser/AGENTS.md b/crates/promptforge/parser/AGENTS.md index 923a22fa0..c1412a35d 100644 --- a/crates/promptforge/parser/AGENTS.md +++ b/crates/promptforge/parser/AGENTS.md @@ -3,5 +3,5 @@ This crate owns PromptForge prompt-document parsing and compiles each Lua region into a `LuaProgram` without executing it. - General Markdown host utilities stay in the Lua host surface. Moving them here would close the parser-to-Lua dependency cycle. -- Core's executor consumes this crate. This crate never imports an executor. -- Hidden parser error seams used by Core are not host API and must not gain documented status without a design change. +- The `promptforge-api-runtime` executor consumes this crate. This crate never imports an executor. +- Hidden parser error seams used by `promptforge-api-runtime` are not host API and must not gain documented status without a design change. diff --git a/crates/promptforge/parser/src/build.rs b/crates/promptforge/parser/src/build.rs index aed1f3f19..001b0c877 100644 --- a/crates/promptforge/parser/src/build.rs +++ b/crates/promptforge/parser/src/build.rs @@ -256,7 +256,7 @@ pub(crate) struct Heading { pub(crate) span: Range, } -/// Split a file into its YAML frontmatter, its markdown body, and the +/// Splits a file into its YAML frontmatter, its markdown body, and the /// number of lines consumed by the frontmatter block (both `---` delimiters /// and everything between them). /// @@ -340,7 +340,7 @@ pub(crate) fn newlines_before(text: &str, byte_offset: usize) -> Result { .map_err(|_| Error::Internal("parser: newline count exceeded u32 range")) } -/// Convert a `HeadingLevel` to its numeric level. +/// Converts a `HeadingLevel` to its numeric level. fn level_num(level: HeadingLevel) -> u8 { match level { HeadingLevel::H1 => 1, @@ -352,7 +352,7 @@ fn level_num(level: HeadingLevel) -> u8 { } } -/// Walk the markdown body and collect every heading with the content that +/// Walks the markdown body and collects every heading with the content that /// follows it, up to the next heading of any level. pub(crate) fn collect_headings(body: &str) -> Result> { // First pass: find each heading's level, title, and source byte range. diff --git a/crates/promptforge/parser/src/error.rs b/crates/promptforge/parser/src/error.rs new file mode 100644 index 000000000..6897dcf3b --- /dev/null +++ b/crates/promptforge/parser/src/error.rs @@ -0,0 +1,307 @@ +//! The parser's error substrate and its public classification. +//! +//! [`Error`] is the internal substrate every parsing module returns through +//! [`Result`]. [`ParseError`] is the host-facing wrapper returned by +//! [`Prompt::parse`](crate::Prompt::parse): it classifies the substrate into +//! a stable [`ParseErrorKind`] and surfaces the failure's location fields. + +/// A type-erased owned error cause used by the internal substrate. +pub(crate) type BoxedSource = Box; + +/// The parser's internal error substrate, classified into [`ParseError`] at +/// the public boundary. +/// +/// `#[doc(hidden)]`: this type exists in the public item tree only so the +/// companion `promptforge-api-runtime` crate can convert it back onto its own +/// substrate variant-for-variant. It is not host API. +#[derive(Debug, thiserror::Error)] +#[doc(hidden)] +pub enum Error { + /// The prompt frontmatter was not valid YAML, preserving the decode + /// failure as the `#[source]` cause so [`ParseError`] can expose the + /// frontmatter syntax location through [`std::error::Error::source`]. + #[error("invalid frontmatter: {message}")] + ParseFrontmatter { + /// The human-readable diagnostic (no raw source dump). + message: String, + /// The originating YAML parse failure, kept as the cause. + #[source] + source: BoxedSource, + /// The 1-based file line of the YAML failure, surfaced from the + /// retained cause's location when it carries one. + line: Option, + /// The 1-based file column of the YAML failure, when known. + column: Option, + }, + + /// A structurally-classified parse failure carrying a stable kind and an + /// optional source byte span, so [`ParseError`] can expose the + /// classification and location from stored fields instead of inferring + /// them from message text. + #[error("{message}")] + ParseStructured { + /// The stable classification of this parse failure. + kind: ParseErrorKind, + /// The byte span of the offending region within the source, when known. + span: Option<(usize, usize)>, + /// The human-readable diagnostic. + message: String, + /// The prompt's frontmatter name, stamped when the failure postdates + /// the frontmatter (a frontmatter failure predates the name). + name: Option, + /// The 1-based file line of the span's start, computed against the + /// source when a span is known. + line: Option, + /// The 1-based byte column of the span's start, when a span is known. + column: Option, + }, + + /// A Lua region failed to compile at parse time, carried as the + /// `promptforge-lua` substrate so the compiler diagnostic chain survives + /// unchanged. + #[error(transparent)] + Lua(#[from] promptforge_lua::Error), + + /// An internal parser invariant was violated (a state the surrounding code + /// has already guaranteed cannot occur). + #[error("internal invariant violated: {0}")] + Internal(&'static str), +} + +/// The parser's internal result type over the [`Error`] substrate. +pub(crate) type Result = std::result::Result; + +impl Error { + /// Builds a parse failure with a stable classification and no source span. + pub(crate) fn parse(kind: ParseErrorKind, message: impl Into) -> Error { + Error::ParseStructured { + kind, + span: None, + message: message.into(), + name: None, + line: None, + column: None, + } + } + + /// Stamps a structured parse failure with the prompt's frontmatter name + /// and, when the failure carries a source span, the span's 1-based + /// file line and byte column. Every other variant passes through + /// unchanged: a frontmatter failure predates the name, and a Lua + /// compile failure already carries its own position. + pub(crate) fn with_prompt_context( + self, + name: &str, + body: &str, + frontmatter_lines: u32, + ) -> Error { + match self { + Error::ParseStructured { + kind, + span, + message, + .. + } => { + let (line, column) = match span { + Some((start, _)) => body_line_column(body, start, frontmatter_lines), + None => (None, None), + }; + Error::ParseStructured { + kind, + span, + message, + name: Some(name.to_owned()), + line, + column, + } + } + other => other, + } + } +} + +/// The 1-based file line and byte column of `byte_offset` within `body`, +/// offset past the frontmatter lines. Both are `None` when the offset is +/// out of bounds or the arithmetic overflows - an invariant break that must +/// not replace the original parse failure. +fn body_line_column( + body: &str, + byte_offset: usize, + frontmatter_lines: u32, +) -> (Option, Option) { + let Some(prefix) = body.get(..byte_offset) else { + return (None, None); + }; + let line_in_body = u32::try_from(prefix.matches('\n').count()) + .ok() + .and_then(|newlines| newlines.checked_add(1)); + let line = line_in_body.and_then(|line| frontmatter_lines.checked_add(line)); + let column = u32::try_from(prefix.len() - prefix.rfind('\n').map_or(0, |index| index + 1)) + .ok() + .and_then(|offset| offset.checked_add(1)); + (line, column) +} + +/// A stable, matchable classification of a [`ParseError`]. +/// +/// `#[non_exhaustive]` so new kinds do not break a caller's `match`. +#[non_exhaustive] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ParseErrorKind { + /// The YAML frontmatter block was missing, unclosed, or invalid. + Frontmatter, + /// The document structure was invalid (missing/duplicate H1, no sections). + Structure, + /// A reserved `lua`/`lua shared` fence was misplaced or not closed exactly. + Fence, + /// A list-only section contained non-list or empty items. + List, + /// A compiled Lua region was not syntactically valid. + Lua, +} + +/// The error returned by [`Prompt::parse`](crate::Prompt::parse). +/// +/// Carries a stable [`kind`](ParseError::kind) classifier and preserves the +/// underlying cause through [`std::error::Error::source`]. `#[non_exhaustive]` +/// and not constructible outside the crate. +#[derive(Debug)] +#[non_exhaustive] +pub struct ParseError { + kind: ParseErrorKind, + span: Option<(usize, usize)>, + name: Option, + line: Option, + column: Option, + inner: Box, +} + +/// The classified parts of a substrate error: the stable kind plus the +/// location fields the substrate carries (the source span, the prompt's +/// frontmatter name when the failure postdates the frontmatter, and the +/// 1-based line/column - surfaced from the retained YAML failure, or +/// computed from the span). +struct Classification { + kind: ParseErrorKind, + span: Option<(usize, usize)>, + name: Option, + line: Option, + column: Option, +} + +/// Classifies a substrate error into its stable kind and location fields. +fn classify_parse_error(inner: &Error) -> Classification { + const NONE: Classification = Classification { + kind: ParseErrorKind::Structure, + span: None, + name: None, + line: None, + column: None, + }; + match inner { + Error::ParseStructured { + kind, + span, + name, + line, + column, + .. + } => Classification { + kind: *kind, + span: *span, + name: name.clone(), + line: *line, + column: *column, + }, + Error::ParseFrontmatter { line, column, .. } => Classification { + kind: ParseErrorKind::Frontmatter, + line: *line, + column: *column, + ..NONE + }, + Error::Lua(promptforge_lua::Error::LuaCompile { .. }) => Classification { + kind: ParseErrorKind::Lua, + ..NONE + }, + _ => NONE, + } +} + +impl ParseError { + /// Returns the stable classification of this failure. + #[must_use] + pub fn kind(&self) -> ParseErrorKind { + self.kind + } + + /// Returns the byte span of the offending region, when one is available. + /// + /// Structural failures that can locate the offending region (for example a + /// duplicate sibling section) carry a byte span; others return `None`. + #[must_use] + pub fn span(&self) -> Option<(usize, usize)> { + self.span + } + + /// Returns the prompt's frontmatter name when the failure postdates the + /// frontmatter. + /// + /// A frontmatter YAML failure predates the name (the parser learns the + /// name from the frontmatter itself), so it reports `None` and the + /// host's own label for the source takes its place. + #[must_use] + pub fn name(&self) -> Option<&str> { + self.name.as_deref() + } + + /// Returns the 1-based file line of the failure, when known. + /// + /// Frontmatter failures surface the retained YAML error's position; + /// structured failures with a source span carry the span's start line. + #[must_use] + pub fn line(&self) -> Option { + self.line + } + + /// Returns the 1-based column of the failure, when known. + #[must_use] + pub fn column(&self) -> Option { + self.column + } + + /// Unwraps the internal substrate error. + /// + /// `#[doc(hidden)]`: cross-crate seam for `promptforge-api-runtime`'s own error + /// substrate, mirroring the `promptforge-lua` precedent. Not host API. + #[doc(hidden)] + #[must_use] + pub fn into_inner(self) -> Error { + *self.inner + } +} + +impl std::fmt::Display for ParseError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.inner) + } +} + +impl std::error::Error for ParseError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + std::error::Error::source(&self.inner) + } +} + +impl From for ParseError { + fn from(inner: Error) -> Self { + let classified = classify_parse_error(&inner); + ParseError { + kind: classified.kind, + span: classified.span, + name: classified.name, + line: classified.line, + column: classified.column, + inner: Box::new(inner), + } + } +} diff --git a/crates/promptforge/parser/src/lib.rs b/crates/promptforge/parser/src/lib.rs index 838f405ff..d0470946d 100644 --- a/crates/promptforge/parser/src/lib.rs +++ b/crates/promptforge/parser/src/lib.rs @@ -16,15 +16,15 @@ //! //! The parser does no execution. It turns bytes into a [`Prompt`] tree. -use promptforge_api_types::emitter::{Emitter, EventSink}; -use promptforge_api_types::event::{Event, lifecycle}; - pub use promptforge_lua::LuaProgram; mod build; mod contract; +mod error; mod fence; mod list; +mod parse; +mod prompt; #[cfg(feature = "test-support")] pub mod test_support; @@ -32,645 +32,13 @@ pub mod test_support; pub use build::{ FileDecl, Frontmatter, MAX_TOOL_ITERATIONS, MaxToolIterations, promptforge_version, }; -use build::{Heading, build_sections, collect_headings, line_add, split_frontmatter}; pub use contract::{ ArgDecl, ArgType, ArgsDecl, CapabilityDecl, ModelKeyword, ModelRole, ModelRoles, ToolSlot, ToolSlots, }; -use fence::{exact_shared_openings, split_h1}; - -/// A type-erased owned error cause used by the internal substrate. -pub(crate) type BoxedSource = Box; - -/// The parser's internal error substrate, classified into [`ParseError`] at -/// the public boundary. -/// -/// `#[doc(hidden)]`: this type exists in the public item tree only so the -/// companion `promptforge-api-runtime` crate can convert it back onto its own -/// substrate variant-for-variant. It is not host API. -#[derive(Debug, thiserror::Error)] -#[doc(hidden)] -pub enum Error { - /// The prompt frontmatter was not valid YAML, preserving the decode - /// failure as the `#[source]` cause so [`ParseError`] can expose the - /// frontmatter syntax location through [`std::error::Error::source`]. - #[error("invalid frontmatter: {message}")] - ParseFrontmatter { - /// The human-readable diagnostic (no raw source dump). - message: String, - /// The originating YAML parse failure, kept as the cause. - #[source] - source: BoxedSource, - /// The 1-based file line of the YAML failure, surfaced from the - /// retained cause's location when it carries one. - line: Option, - /// The 1-based file column of the YAML failure, when known. - column: Option, - }, - - /// A structurally-classified parse failure carrying a stable kind and an - /// optional source byte span, so [`ParseError`] can expose the - /// classification and location from stored fields instead of inferring - /// them from message text. - #[error("{message}")] - ParseStructured { - /// The stable classification of this parse failure. - kind: ParseErrorKind, - /// The byte span of the offending region within the source, when known. - span: Option<(usize, usize)>, - /// The human-readable diagnostic. - message: String, - /// The prompt's frontmatter name, stamped when the failure postdates - /// the frontmatter (a frontmatter failure predates the name). - name: Option, - /// The 1-based file line of the span's start, computed against the - /// source when a span is known. - line: Option, - /// The 1-based byte column of the span's start, when a span is known. - column: Option, - }, - - /// A Lua region failed to compile at parse time, carried as the - /// `promptforge-lua` substrate so the compiler diagnostic chain survives - /// unchanged. - #[error(transparent)] - Lua(#[from] promptforge_lua::Error), - - /// An internal parser invariant was violated (a state the surrounding code - /// has already guaranteed cannot occur). - #[error("internal invariant violated: {0}")] - Internal(&'static str), -} - -/// The parser's internal result type over the [`Error`] substrate. -pub(crate) type Result = std::result::Result; - -impl Error { - /// Builds a parse failure with a stable classification and no source span. - pub(crate) fn parse(kind: ParseErrorKind, message: impl Into) -> Error { - Error::ParseStructured { - kind, - span: None, - message: message.into(), - name: None, - line: None, - column: None, - } - } - - /// Stamps a structured parse failure with the prompt's frontmatter name - /// and, when the failure carries a source span, the span's 1-based - /// file line and byte column. Every other variant passes through - /// unchanged: a frontmatter failure predates the name, and a Lua - /// compile failure already carries its own position. - fn with_prompt_context(self, name: &str, body: &str, frontmatter_lines: u32) -> Error { - match self { - Error::ParseStructured { - kind, - span, - message, - .. - } => { - let (line, column) = match span { - Some((start, _)) => body_line_column(body, start, frontmatter_lines), - None => (None, None), - }; - Error::ParseStructured { - kind, - span, - message, - name: Some(name.to_owned()), - line, - column, - } - } - other => other, - } - } -} - -/// The 1-based file line and byte column of `byte_offset` within `body`, -/// offset past the frontmatter lines. Both are `None` when the offset is -/// out of bounds or the arithmetic overflows - an invariant break that must -/// not replace the original parse failure. -fn body_line_column( - body: &str, - byte_offset: usize, - frontmatter_lines: u32, -) -> (Option, Option) { - let Some(prefix) = body.get(..byte_offset) else { - return (None, None); - }; - let line_in_body = u32::try_from(prefix.matches('\n').count()) - .ok() - .and_then(|newlines| newlines.checked_add(1)); - let line = line_in_body.and_then(|line| frontmatter_lines.checked_add(line)); - let column = u32::try_from(prefix.len() - prefix.rfind('\n').map_or(0, |index| index + 1)) - .ok() - .and_then(|offset| offset.checked_add(1)); - (line, column) -} - -/// A stable, matchable classification of a [`ParseError`]. -/// -/// `#[non_exhaustive]` so new kinds do not break a caller's `match`. -#[non_exhaustive] -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum ParseErrorKind { - /// The YAML frontmatter block was missing, unclosed, or invalid. - Frontmatter, - /// The document structure was invalid (missing/duplicate H1, no sections). - Structure, - /// A reserved `lua`/`lua shared` fence was misplaced or not closed exactly. - Fence, - /// A list-only section contained non-list or empty items. - List, - /// A compiled Lua region was not syntactically valid. - Lua, -} - -/// The error returned by [`Prompt::parse`]. -/// -/// Carries a stable [`kind`](ParseError::kind) classifier and preserves the -/// underlying cause through [`std::error::Error::source`]. `#[non_exhaustive]` -/// and not constructible outside the crate. -#[derive(Debug)] -#[non_exhaustive] -pub struct ParseError { - kind: ParseErrorKind, - span: Option<(usize, usize)>, - name: Option, - line: Option, - column: Option, - inner: Box, -} - -/// The classified parts of a substrate error: the stable kind plus the -/// location fields the substrate carries (the source span, the prompt's -/// frontmatter name when the failure postdates the frontmatter, and the -/// 1-based line/column - surfaced from the retained YAML failure, or -/// computed from the span). -struct Classification { - kind: ParseErrorKind, - span: Option<(usize, usize)>, - name: Option, - line: Option, - column: Option, -} - -/// Classify a substrate error into its stable kind and location fields. -fn classify_parse_error(inner: &Error) -> Classification { - const NONE: Classification = Classification { - kind: ParseErrorKind::Structure, - span: None, - name: None, - line: None, - column: None, - }; - match inner { - Error::ParseStructured { - kind, - span, - name, - line, - column, - .. - } => Classification { - kind: *kind, - span: *span, - name: name.clone(), - line: *line, - column: *column, - }, - Error::ParseFrontmatter { line, column, .. } => Classification { - kind: ParseErrorKind::Frontmatter, - line: *line, - column: *column, - ..NONE - }, - Error::Lua(promptforge_lua::Error::LuaCompile { .. }) => Classification { - kind: ParseErrorKind::Lua, - ..NONE - }, - _ => NONE, - } -} - -impl ParseError { - /// Returns the stable classification of this failure. - #[must_use] - pub fn kind(&self) -> ParseErrorKind { - self.kind - } - - /// Returns the byte span of the offending region, when one is available. - /// - /// Structural failures that can locate the offending region (for example a - /// duplicate sibling section) carry a byte span; others return `None`. - #[must_use] - pub fn span(&self) -> Option<(usize, usize)> { - self.span - } - - /// Returns the prompt's frontmatter name when the failure postdates the - /// frontmatter. - /// - /// A frontmatter YAML failure predates the name (the parser learns the - /// name from the frontmatter itself), so it reports `None` and the - /// host's own label for the source takes its place. - #[must_use] - pub fn name(&self) -> Option<&str> { - self.name.as_deref() - } - - /// Returns the 1-based file line of the failure, when known. - /// - /// Frontmatter failures surface the retained YAML error's position; - /// structured failures with a source span carry the span's start line. - #[must_use] - pub fn line(&self) -> Option { - self.line - } - - /// Returns the 1-based column of the failure, when known. - #[must_use] - pub fn column(&self) -> Option { - self.column - } - - /// Unwraps the internal substrate error. - /// - /// `#[doc(hidden)]`: cross-crate seam for `promptforge-api-runtime`'s own error - /// substrate, mirroring the `promptforge-lua` precedent. Not host API. - #[doc(hidden)] - #[must_use] - pub fn into_inner(self) -> Error { - *self.inner - } -} - -impl std::fmt::Display for ParseError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.inner) - } -} - -impl std::error::Error for ParseError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - std::error::Error::source(&self.inner) - } -} - -impl From for ParseError { - fn from(inner: Error) -> Self { - let classified = classify_parse_error(&inner); - ParseError { - kind: classified.kind, - span: classified.span, - name: classified.name, - line: classified.line, - column: classified.column, - inner: Box::new(inner), - } - } -} - -/// One executable block inside a section: a compiled Lua fence or prose. -#[derive(Debug, Clone, PartialEq, Eq)] -#[non_exhaustive] -pub enum Block { - /// An exact `lua` fence compiled at parse time. - Lua(LuaProgram), - /// Author prose: the pending Markdown accumulated since the nearest - /// preceding heading, `lua` fence, or thematic break. The executor - /// installs it as the following Lua block's lazy `prose` template. - #[non_exhaustive] - Prose { - /// Captured Markdown, trimmed of surrounding blank lines. - text: String, - }, -} - -/// One section of a prompt: a heading, ordered blocks, and children. -#[derive(Debug, Clone, PartialEq, Eq)] -#[non_exhaustive] -pub struct Section { - /// The heading text (the section's address). - pub(crate) name: String, - /// The heading level, 2 through 6. - pub(crate) level: u8, - /// Ordered lua/prose blocks for this section. - pub(crate) blocks: Vec, - /// Child sections nested under this one (deeper heading levels). - pub(crate) children: Vec
, - /// Pre-parsed bullet items for list-only sections (no lua blocks). - /// Empty for non-list sections. - pub(crate) items: Vec, -} - -impl Section { - /// Returns the heading text (the section's address). - #[must_use] - pub fn name(&self) -> &str { - &self.name - } - - /// Returns the heading level (2 through 6). - #[must_use] - pub fn level(&self) -> u8 { - self.level - } - - /// Returns the ordered Lua and prose blocks of this section. - #[must_use] - pub fn blocks(&self) -> &[Block] { - &self.blocks - } - - /// Returns the child sections nested under this one. - #[must_use] - pub fn children(&self) -> &[Section] { - &self.children - } - - /// Returns the pre-parsed bullet items for a list-only section. - #[must_use] - pub fn items(&self) -> &[String] { - &self.items - } - - /// Classic leading Lua fence when the first block is Lua. - #[must_use] - pub fn prologue(&self) -> Option<&LuaProgram> { - match self.blocks.first() { - Some(Block::Lua(program)) => Some(program), - _ => None, - } - } - - /// Text of the last prose block, or `""` when the section has none. - #[must_use] - pub fn prose(&self) -> &str { - self.blocks - .iter() - .rev() - .find_map(|block| match block { - Block::Prose { text } => Some(text.as_str()), - _ => None, - }) - .unwrap_or("") - } - - /// Classic trailing Lua fence when the last block is Lua and not the sole - /// leading prologue (a section that is only one Lua block has no epilog). - #[must_use] - pub fn epilog(&self) -> Option<&LuaProgram> { - match self.blocks.as_slice() { - [Block::Lua(_)] => None, - [.., Block::Lua(program)] => Some(program), - _ => None, - } - } - - /// True when this section is a validated bullet list. - /// - /// A section is list-only exactly when it parsed into non-empty - /// [`items`](Self::items) - i.e. it had no Lua blocks and every nonblank - /// prose line was a valid list item (PF-PARSER-005). Ordinary prose (even - /// prose that happens to contain a single bullet line) is not list-only. - #[must_use] - pub fn is_list_only(&self) -> bool { - !self.items.is_empty() - } -} - -/// A fully parsed prompt file. -#[derive(Debug, Clone, PartialEq, Eq)] -#[non_exhaustive] -pub struct Prompt { - /// The parsed YAML frontmatter. - pub(crate) frontmatter: Frontmatter, - /// The required H1 title. - pub(crate) title: String, - /// The compiled `lua shared` library loaded into section VMs. - pub(crate) replay: Option, - /// Ordered live Lua and prose blocks from the H1. - pub(crate) h1_blocks: Vec, - /// Human-readable prose from the H1. - pub(crate) description_text: String, - /// Top-level sections (H2s) in file order. - pub(crate) sections: Vec
, -} - -impl Prompt { - /// Returns the parsed frontmatter. - #[must_use] - pub fn frontmatter(&self) -> &Frontmatter { - &self.frontmatter - } - - /// Returns the required H1 title. - #[must_use] - pub fn title(&self) -> &str { - &self.title - } - - /// Returns the compiled `lua shared` library, when the prompt declares one. - #[must_use] - pub fn replay(&self) -> Option<&LuaProgram> { - self.replay.as_ref() - } - - /// Returns the ordered live Lua and prose blocks from the H1. - #[must_use] - pub fn h1_blocks(&self) -> &[Block] { - &self.h1_blocks - } - - /// Returns the top-level H2 sections in file order. - #[must_use] - pub fn sections(&self) -> &[Section] { - &self.sections - } - - /// Removes the human-readable prose from the H1, keeping only its live Lua - /// blocks. - /// - /// This is the invariant-preserving replacement for mutating `h1_blocks` - /// directly: it drops every [`Block::Prose`] from the H1 and clears the - /// derived description text, leaving the compiled H1 Lua blocks and the rest - /// of the prompt tree untouched. Callers use it to run a prompt's live H1 - /// resolution without sending any H1 prose to a model. - pub fn strip_h1_prose(&mut self) { - self.h1_blocks - .retain(|block| matches!(block, Block::Lua(_))); - self.description_text.clear(); - } -} - -impl Prompt { - /// Parse a prompt file's full source text into a [`Prompt`], returning - /// the parse-time events beside the outcome. - /// - /// The events are the parse lifecycle (`ParseStarted`, then - /// `ParseSucceeded` or `ParseFailed`) and each Lua block's compilation - /// boundaries, every one stamped with the caller-provided `execution` - /// identifier and reported under task `0`, since no run exists yet. - /// They are values for the caller to log; nothing is read back. - /// - /// ``` - /// use promptforge_api_types::event::Event; - /// use promptforge_parser::{Prompt, ParseErrorKind}; - /// - /// let source = "---\nname: greeter\ndescription: says hi\n---\n\n# Greeter\n\n## Say hi\n\nSay hello.\n"; - /// let (prompt, events) = Prompt::parse(source, "docs"); - /// let prompt = prompt?; - /// assert_eq!(prompt.frontmatter().name(), "greeter"); - /// assert_eq!(prompt.title(), "Greeter"); - /// assert_eq!(prompt.sections().len(), 1); - /// assert_eq!(prompt.sections()[0].name(), "Say hi"); - /// assert!(matches!(events.first(), Some(Event::ParseStarted { .. }))); - /// assert!(matches!(events.last(), Some(Event::ParseSucceeded { .. }))); - /// - /// // A malformed prompt reports a classified error, and the events say so. - /// let (err, events) = Prompt::parse("no frontmatter here", "docs"); - /// assert_eq!(err.unwrap_err().kind(), ParseErrorKind::Frontmatter); - /// assert!(matches!(events.last(), Some(Event::ParseFailed { .. }))); - /// # Ok::<(), promptforge_parser::ParseError>(()) - /// ``` - /// - /// # Errors - /// The first half of the pair is a [`ParseError`] classified `Frontmatter` when the frontmatter - /// delimiters are missing or the frontmatter is invalid; `Structure` when - /// the required H1 is missing or the body has no `##` sections; `Fence` when - /// the H1 opens with the removed `lua prompt` fence form, a reserved fence - /// is not closed exactly, more than one `lua shared` fence exists, or a - /// `lua shared` fence is outside H1; and `Lua` when the shared library or an - /// H1 or section Lua block is not valid Lua. - pub fn parse( - input: &str, - execution: &str, - ) -> (std::result::Result, Vec) { - let sink = EventSink::default(); - let emitter = Emitter::root(sink.clone(), execution, false); - emitter.report("Prompt", lifecycle::PARSE_STARTED); - let result = Self::parse_inner(input, &emitter); - emitter.report( - "Prompt", - if result.is_ok() { - lifecycle::PARSE_SUCCEEDED - } else { - lifecycle::PARSE_FAILED - }, - ); - (result.map_err(ParseError::from), sink.take()) - } - - fn parse_inner(input: &str, emitter: &Emitter) -> Result { - let (yaml, body, frontmatter_lines) = split_frontmatter(input)?; - let frontmatter: Frontmatter = serde_yaml_ng::from_str(&yaml).map_err(|e| { - // Retain the YAML decode failure as the `#[source]` cause (F3) and - // surface its location, so the public parse error exposes the - // frontmatter syntax position as stored fields. The location is - // relative to the frontmatter block, which starts on file line 2 - // (line 1 is the opening `---` delimiter). - let (line, column) = e.location().map_or((None, None), |location| { - ( - u32::try_from(location.line()) - .ok() - .and_then(|line| line.checked_add(1)), - u32::try_from(location.column()).ok(), - ) - }); - Error::ParseFrontmatter { - message: e.to_string(), - source: Box::new(e), - line, - column, - } - })?; - // Everything past the frontmatter postdates the prompt's name, so a - // failure from here on is stamped with it (and its span's position). - let name = frontmatter.name().to_owned(); - Self::parse_body(frontmatter, &body, frontmatter_lines, emitter) - .map_err(|error| error.with_prompt_context(&name, &body, frontmatter_lines)) - } - - fn parse_body( - frontmatter: Frontmatter, - body: &str, - frontmatter_lines: u32, - emitter: &Emitter, - ) -> Result { - let headings = collect_headings(body)?; - - let h1_positions: Vec = headings - .iter() - .enumerate() - .filter_map(|(index, heading)| (heading.level == 1).then_some(index)) - .collect(); - let [h1_index] = h1_positions.as_slice() else { - return Err(Error::parse( - ParseErrorKind::Structure, - if h1_positions.is_empty() { - "prompt requires an H1 title" - } else { - "prompt must contain exactly one H1 title" - }, - )); - }; - let h1 = &headings[*h1_index]; - if h1.title.trim().is_empty() { - return Err(Error::parse( - ParseErrorKind::Structure, - "prompt H1 title must not be empty", - )); - } - let title = h1.title.clone(); - let h1_content_abs_line = line_add(frontmatter_lines, h1.content_start_line)?; - let shared_fences = exact_shared_openings(body); - let h1_shared_fences = exact_shared_openings(&h1.content); - if shared_fences.len() > 1 { - return Err(Error::parse( - ParseErrorKind::Fence, - "prompt allows at most one `lua shared` fence", - )); - } - if shared_fences.len() != h1_shared_fences.len() { - return Err(Error::parse( - ParseErrorKind::Fence, - "`lua shared` fence is allowed only in H1", - )); - } - let (replay, h1_blocks, description_text) = - split_h1(&h1.content, &title, h1_content_abs_line, emitter)?; - - // Everything before the H1 is preface and has no prompt semantics. - // Sections are headings after the H1 at level 2 or deeper. - let section_headings: Vec = headings - .into_iter() - .skip(*h1_index + 1) - .filter(|h| h.level >= 2) - .collect(); - let mut pos = 0; - let sections = build_sections(§ion_headings, &mut pos, 1, frontmatter_lines, emitter)?; - - Ok(Prompt { - frontmatter, - title, - replay, - h1_blocks, - description_text, - sections, - }) - } - - /// The entry-point section: the first top-level section in file order. - #[must_use] - pub fn entry(&self) -> Option<&Section> { - self.sections.first() - } -} +pub(crate) use error::Result; +pub use error::{Error, ParseError, ParseErrorKind}; +pub use prompt::{Block, Prompt, Section}; #[cfg(test)] mod tests; diff --git a/crates/promptforge/parser/src/list.rs b/crates/promptforge/parser/src/list.rs index e4f5e34a2..c186c6702 100644 --- a/crates/promptforge/parser/src/list.rs +++ b/crates/promptforge/parser/src/list.rs @@ -89,7 +89,7 @@ enum ListLine<'a> { NotAMarker, } -/// Classify one already-trimmed, nonblank line as a list marker. +/// Classifies one already-trimmed, nonblank line as a list marker. fn classify_list_line(trimmed: &str) -> ListLine<'_> { // Unordered: `- item` / `* item`, or a bare `-` / `*`. if let Some(rest) = trimmed diff --git a/crates/promptforge/parser/src/parse.rs b/crates/promptforge/parser/src/parse.rs new file mode 100644 index 000000000..4dbeb5841 --- /dev/null +++ b/crates/promptforge/parser/src/parse.rs @@ -0,0 +1,169 @@ +//! The parse entry point: frontmatter decoding, H1 validation, and the +//! assembly of a [`Prompt`] from its headings, fences, and sections. + +use promptforge_api_types::emitter::{DebugMode, Emitter, EventSink}; +use promptforge_api_types::event::{Event, lifecycle}; + +use crate::build::{ + Frontmatter, Heading, build_sections, collect_headings, line_add, split_frontmatter, +}; +use crate::fence::{exact_shared_openings, split_h1}; +use crate::{Error, ParseError, ParseErrorKind, Prompt, Result}; + +impl Prompt { + /// Parses a prompt file's full source text into a [`Prompt`], returning + /// the parse-time events beside the outcome. + /// + /// The events are the parse lifecycle (`ParseStarted`, then + /// `ParseSucceeded` or `ParseFailed`) and each Lua block's compilation + /// boundaries, every one stamped with the caller-provided `execution` + /// identifier and reported under task `0`, since no run exists yet. + /// They are values for the caller to log; nothing is read back. + /// + /// ``` + /// use promptforge_api_types::event::Event; + /// use promptforge_parser::{Prompt, ParseErrorKind}; + /// + /// let source = "---\nname: greeter\ndescription: says hi\n---\n\n# Greeter\n\n## Say hi\n\nSay hello.\n"; + /// let (prompt, events) = Prompt::parse(source, "docs"); + /// let prompt = prompt?; + /// assert_eq!(prompt.frontmatter().name(), "greeter"); + /// assert_eq!(prompt.title(), "Greeter"); + /// assert_eq!(prompt.sections().len(), 1); + /// assert_eq!(prompt.sections()[0].name(), "Say hi"); + /// assert!(matches!(events.first(), Some(Event::ParseStarted { .. }))); + /// assert!(matches!(events.last(), Some(Event::ParseSucceeded { .. }))); + /// + /// // A malformed prompt reports a classified error, and the events say so. + /// let (err, events) = Prompt::parse("no frontmatter here", "docs"); + /// assert_eq!(err.unwrap_err().kind(), ParseErrorKind::Frontmatter); + /// assert!(matches!(events.last(), Some(Event::ParseFailed { .. }))); + /// # Ok::<(), promptforge_parser::ParseError>(()) + /// ``` + /// + /// # Errors + /// The first half of the pair is a [`ParseError`] classified `Frontmatter` when the frontmatter + /// delimiters are missing or the frontmatter is invalid; `Structure` when + /// the required H1 is missing or the body has no `##` sections; `Fence` when + /// the H1 opens with the removed `lua prompt` fence form, a reserved fence + /// is not closed exactly, more than one `lua shared` fence exists, or a + /// `lua shared` fence is outside H1; and `Lua` when the shared library or an + /// H1 or section Lua block is not valid Lua. + pub fn parse( + input: &str, + execution: &str, + ) -> (std::result::Result, Vec) { + let sink = EventSink::default(); + let emitter = Emitter::root(sink.clone(), execution, DebugMode::Off); + emitter.report("Prompt", lifecycle::PARSE_STARTED); + let result = Self::parse_inner(input, &emitter); + emitter.report( + "Prompt", + if result.is_ok() { + lifecycle::PARSE_SUCCEEDED + } else { + lifecycle::PARSE_FAILED + }, + ); + (result.map_err(ParseError::from), sink.take()) + } + + fn parse_inner(input: &str, emitter: &Emitter) -> Result { + let (yaml, body, frontmatter_lines) = split_frontmatter(input)?; + let frontmatter: Frontmatter = serde_yaml_ng::from_str(&yaml).map_err(|e| { + // Retain the YAML decode failure as the `#[source]` cause (F3) and + // surface its location, so the public parse error exposes the + // frontmatter syntax position as stored fields. The location is + // relative to the frontmatter block, which starts on file line 2 + // (line 1 is the opening `---` delimiter). + let (line, column) = e.location().map_or((None, None), |location| { + ( + u32::try_from(location.line()) + .ok() + .and_then(|line| line.checked_add(1)), + u32::try_from(location.column()).ok(), + ) + }); + Error::ParseFrontmatter { + message: e.to_string(), + source: Box::new(e), + line, + column, + } + })?; + // Everything past the frontmatter postdates the prompt's name, so a + // failure from here on is stamped with it (and its span's position). + let name = frontmatter.name().to_owned(); + Self::parse_body(frontmatter, &body, frontmatter_lines, emitter) + .map_err(|error| error.with_prompt_context(&name, &body, frontmatter_lines)) + } + + fn parse_body( + frontmatter: Frontmatter, + body: &str, + frontmatter_lines: u32, + emitter: &Emitter, + ) -> Result { + let headings = collect_headings(body)?; + + let h1_positions: Vec = headings + .iter() + .enumerate() + .filter_map(|(index, heading)| (heading.level == 1).then_some(index)) + .collect(); + let [h1_index] = h1_positions.as_slice() else { + return Err(Error::parse( + ParseErrorKind::Structure, + if h1_positions.is_empty() { + "prompt requires an H1 title" + } else { + "prompt must contain exactly one H1 title" + }, + )); + }; + let h1 = &headings[*h1_index]; + if h1.title.trim().is_empty() { + return Err(Error::parse( + ParseErrorKind::Structure, + "prompt H1 title must not be empty", + )); + } + let title = h1.title.clone(); + let h1_content_abs_line = line_add(frontmatter_lines, h1.content_start_line)?; + let shared_fences = exact_shared_openings(body); + let h1_shared_fences = exact_shared_openings(&h1.content); + if shared_fences.len() > 1 { + return Err(Error::parse( + ParseErrorKind::Fence, + "prompt allows at most one `lua shared` fence", + )); + } + if shared_fences.len() != h1_shared_fences.len() { + return Err(Error::parse( + ParseErrorKind::Fence, + "`lua shared` fence is allowed only in H1", + )); + } + let (replay, h1_blocks, description_text) = + split_h1(&h1.content, &title, h1_content_abs_line, emitter)?; + + // Everything before the H1 is preface and has no prompt semantics. + // Sections are headings after the H1 at level 2 or deeper. + let section_headings: Vec = headings + .into_iter() + .skip(*h1_index + 1) + .filter(|h| h.level >= 2) + .collect(); + let mut pos = 0; + let sections = build_sections(§ion_headings, &mut pos, 1, frontmatter_lines, emitter)?; + + Ok(Prompt { + frontmatter, + title, + replay, + h1_blocks, + description_text, + sections, + }) + } +} diff --git a/crates/promptforge/parser/src/prompt.rs b/crates/promptforge/parser/src/prompt.rs new file mode 100644 index 000000000..e7ead9d73 --- /dev/null +++ b/crates/promptforge/parser/src/prompt.rs @@ -0,0 +1,187 @@ +//! The parsed prompt tree: [`Prompt`], its [`Section`]s, and their +//! [`Block`]s, with the read-only accessors hosts navigate it through. +//! +//! Construction lives in the parsing modules; this module holds the value +//! types and the invariant-preserving operations on them. + +use crate::LuaProgram; +use crate::build::Frontmatter; + +/// One executable block inside a section: a compiled Lua fence or prose. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub enum Block { + /// An exact `lua` fence compiled at parse time. + Lua(LuaProgram), + /// Author prose: the pending Markdown accumulated since the nearest + /// preceding heading, `lua` fence, or thematic break. The executor + /// installs it as the following Lua block's lazy `prose` template. + #[non_exhaustive] + Prose { + /// Captured Markdown, trimmed of surrounding blank lines. + text: String, + }, +} + +/// One section of a prompt: a heading, ordered blocks, and children. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub struct Section { + /// The heading text (the section's address). + pub(crate) name: String, + /// The heading level, 2 through 6. + pub(crate) level: u8, + /// Ordered lua/prose blocks for this section. + pub(crate) blocks: Vec, + /// Child sections nested under this one (deeper heading levels). + pub(crate) children: Vec
, + /// Pre-parsed bullet items for list-only sections (no lua blocks). + /// Empty for non-list sections. + pub(crate) items: Vec, +} + +impl Section { + /// Returns the heading text (the section's address). + #[must_use] + pub fn name(&self) -> &str { + &self.name + } + + /// Returns the heading level (2 through 6). + #[must_use] + pub fn level(&self) -> u8 { + self.level + } + + /// Returns the ordered Lua and prose blocks of this section. + #[must_use] + pub fn blocks(&self) -> &[Block] { + &self.blocks + } + + /// Returns the child sections nested under this one. + #[must_use] + pub fn children(&self) -> &[Section] { + &self.children + } + + /// Returns the pre-parsed bullet items for a list-only section. + #[must_use] + pub fn items(&self) -> &[String] { + &self.items + } + + /// Classic leading Lua fence when the first block is Lua. + #[must_use] + pub fn prologue(&self) -> Option<&LuaProgram> { + match self.blocks.first() { + Some(Block::Lua(program)) => Some(program), + _ => None, + } + } + + /// Text of the last prose block, or `""` when the section has none. + #[must_use] + pub fn prose(&self) -> &str { + self.blocks + .iter() + .rev() + .find_map(|block| match block { + Block::Prose { text } => Some(text.as_str()), + _ => None, + }) + .unwrap_or("") + } + + /// Classic trailing Lua fence when the last block is Lua and not the sole + /// leading prologue (a section that is only one Lua block has no epilog). + #[must_use] + pub fn epilog(&self) -> Option<&LuaProgram> { + match self.blocks.as_slice() { + [Block::Lua(_)] => None, + [.., Block::Lua(program)] => Some(program), + _ => None, + } + } + + /// True when this section is a validated bullet list. + /// + /// A section is list-only exactly when it parsed into non-empty + /// [`items`](Self::items) - i.e. it had no Lua blocks and every nonblank + /// prose line was a valid list item (PF-PARSER-005). Ordinary prose (even + /// prose that happens to contain a single bullet line) is not list-only. + #[must_use] + pub fn is_list_only(&self) -> bool { + !self.items.is_empty() + } +} + +/// A fully parsed prompt file. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub struct Prompt { + /// The parsed YAML frontmatter. + pub(crate) frontmatter: Frontmatter, + /// The required H1 title. + pub(crate) title: String, + /// The compiled `lua shared` library loaded into section VMs. + pub(crate) replay: Option, + /// Ordered live Lua and prose blocks from the H1. + pub(crate) h1_blocks: Vec, + /// Human-readable prose from the H1. + pub(crate) description_text: String, + /// Top-level sections (H2s) in file order. + pub(crate) sections: Vec
, +} + +impl Prompt { + /// Returns the parsed frontmatter. + #[must_use] + pub fn frontmatter(&self) -> &Frontmatter { + &self.frontmatter + } + + /// Returns the required H1 title. + #[must_use] + pub fn title(&self) -> &str { + &self.title + } + + /// Returns the compiled `lua shared` library, when the prompt declares one. + #[must_use] + pub fn replay(&self) -> Option<&LuaProgram> { + self.replay.as_ref() + } + + /// Returns the ordered live Lua and prose blocks from the H1. + #[must_use] + pub fn h1_blocks(&self) -> &[Block] { + &self.h1_blocks + } + + /// Returns the top-level H2 sections in file order. + #[must_use] + pub fn sections(&self) -> &[Section] { + &self.sections + } + + /// The entry-point section: the first top-level section in file order. + #[must_use] + pub fn entry(&self) -> Option<&Section> { + self.sections.first() + } + + /// Removes the human-readable prose from the H1, keeping only its live Lua + /// blocks. + /// + /// This is the invariant-preserving replacement for mutating `h1_blocks` + /// directly: it drops every [`Block::Prose`] from the H1 and clears the + /// derived description text, leaving the compiled H1 Lua blocks and the rest + /// of the prompt tree untouched. Callers use it to run a prompt's live H1 + /// resolution without sending any H1 prose to a model. + pub fn strip_h1_prose(&mut self) { + self.h1_blocks + .retain(|block| matches!(block, Block::Lua(_))); + self.description_text.clear(); + } +} diff --git a/crates/promptforge/parser/src/tests.rs b/crates/promptforge/parser/src/tests.rs index 785ef7954..d351ba28e 100644 --- a/crates/promptforge/parser/src/tests.rs +++ b/crates/promptforge/parser/src/tests.rs @@ -1,3 +1,5 @@ +//! Crate-wide parser tests: frontmatter, headings, fences, lists, breaks, and line mapping. + use promptforge_api_types::event::Event; use super::list::parse_bullet_items; diff --git a/crates/shared-error-source/Cargo.toml b/crates/shared-error-source/Cargo.toml new file mode 100644 index 000000000..16d98bb08 --- /dev/null +++ b/crates/shared-error-source/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "shared-error-source" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +publish = false + +description = "PromptForge shared error-source wrappers: one crate-owned newtype per third-party error a public error surface would otherwise name" + +[dependencies] +reqwest = { workspace = true, optional = true } +serde_json = { workspace = true, optional = true } +thiserror.workspace = true +turso = { workspace = true, optional = true } +workspace-hack.workspace = true + +# One wrapper per feature, so a consumer takes only the third-party +# dependency it already has: gateway-api-discovery does not acquire turso +# and harness-log does not acquire reqwest. +[features] +json = ["dep:serde_json"] +http = ["dep:reqwest"] +database = ["dep:turso"] + +[lints] +workspace = true diff --git a/crates/shared-error-source/src/lib.rs b/crates/shared-error-source/src/lib.rs new file mode 100644 index 000000000..5a3550e95 --- /dev/null +++ b/crates/shared-error-source/src/lib.rs @@ -0,0 +1,180 @@ +//! The shared error-source wrappers: one crate-owned newtype per +//! third-party error a public error surface would otherwise name. +//! +//! Every product family had grown its own copy of the same newtype, so the +//! same wrapper existed under the same name in crates that could not see +//! each other. This crate depends on no workspace crate, which is what lets +//! the harness, workshop, and gateway families all use it without a +//! cross-family edge. +//! +//! `#[error(transparent)]` delegates both `Display` and `source()` to the +//! wrapped error, so the wrapper is invisible in a rendered chain. That +//! same delegation puts the wrapped value out of reach by type: the chain +//! walks straight past it to the third-party error's own source. The +//! accessors are what restore branching on the third-party error. + +/// The JSON error behind a product error variant, so the public error +/// surface names no `serde_json` type. Renders and sources exactly as the +/// JSON error does. +#[cfg(feature = "json")] +#[derive(Debug, thiserror::Error)] +#[error(transparent)] +pub struct JsonSource(serde_json::Error); + +#[cfg(feature = "json")] +impl JsonSource { + /// The wrapped JSON error. + #[must_use] + pub fn as_inner(&self) -> &serde_json::Error { + &self.0 + } + + /// Takes the wrapped JSON error out of the wrapper. + #[must_use] + pub fn into_inner(self) -> serde_json::Error { + self.0 + } +} + +#[cfg(feature = "json")] +impl From for JsonSource { + fn from(source: serde_json::Error) -> Self { + JsonSource(source) + } +} + +/// The transport error behind a product error variant, so the public error +/// surface names no HTTP client type. Renders and sources exactly as the +/// transport error does. +#[cfg(feature = "http")] +#[derive(Debug, thiserror::Error)] +#[error(transparent)] +pub struct HttpSource(reqwest::Error); + +#[cfg(feature = "http")] +impl HttpSource { + /// The wrapped transport error. + #[must_use] + pub fn as_inner(&self) -> &reqwest::Error { + &self.0 + } + + /// Takes the wrapped transport error out of the wrapper. + #[must_use] + pub fn into_inner(self) -> reqwest::Error { + self.0 + } +} + +#[cfg(feature = "http")] +impl From for HttpSource { + fn from(source: reqwest::Error) -> Self { + HttpSource(source) + } +} + +/// The database engine's error behind a product error variant, so the +/// public error surface names no engine type. Renders and sources exactly +/// as the engine's error does. +#[cfg(feature = "database")] +#[derive(Debug, thiserror::Error)] +#[error(transparent)] +pub struct DatabaseSource(turso::Error); + +#[cfg(feature = "database")] +impl DatabaseSource { + /// The wrapped engine error. + #[must_use] + pub fn as_inner(&self) -> &turso::Error { + &self.0 + } + + /// Takes the wrapped engine error out of the wrapper. + #[must_use] + pub fn into_inner(self) -> turso::Error { + self.0 + } +} + +#[cfg(feature = "database")] +impl From for DatabaseSource { + fn from(source: turso::Error) -> Self { + DatabaseSource(source) + } +} + +#[cfg(test)] +mod tests { + use std::error::Error as _; + + #[cfg(feature = "json")] + #[test] + fn a_json_source_yields_the_serde_error_and_renders_as_it_in_a_chain() { + #[derive(Debug, thiserror::Error)] + #[error("the operation did not complete")] + struct Outer(#[source] crate::JsonSource); + + let Err(error) = serde_json::from_str::("nope") else { + panic!("`nope` must not parse as a u32"); + }; + let rendered = error.to_string(); + let source = crate::JsonSource::from(error); + // Serde's own classification, which `#[error(transparent)]` gives + // no way back to. + assert!(source.as_inner().is_syntax()); + + let outer = Outer(source); + assert_eq!(outer.to_string(), "the operation did not complete"); + assert_eq!( + outer.source().map(ToString::to_string), + Some(rendered.clone()) + ); + assert_eq!(outer.0.into_inner().to_string(), rendered); + } + + #[cfg(feature = "http")] + #[test] + fn an_http_source_yields_the_reqwest_error_and_renders_as_it_in_a_chain() { + #[derive(Debug, thiserror::Error)] + #[error("the request did not complete")] + struct Outer(#[source] crate::HttpSource); + + let Err(error) = reqwest::Proxy::all("http://") else { + panic!("a proxy URL with an empty host must not build"); + }; + let rendered = error.to_string(); + let source = crate::HttpSource::from(error); + // Reqwest's own kind predicate, which the chain cannot answer. + assert!(source.as_inner().is_builder()); + + let outer = Outer(source); + assert_eq!(outer.to_string(), "the request did not complete"); + assert_eq!( + outer.source().map(ToString::to_string), + Some(rendered.clone()) + ); + assert_eq!(outer.0.into_inner().to_string(), rendered); + } + + #[cfg(feature = "database")] + #[test] + fn a_database_source_yields_the_turso_error_and_renders_as_it_in_a_chain() { + #[derive(Debug, thiserror::Error)] + #[error("the run log operation did not complete")] + struct Outer(#[source] crate::DatabaseSource); + + let error = turso::Error::Corrupt("page 1 is not a b-tree page".to_owned()); + let rendered = error.to_string(); + let source = crate::DatabaseSource::from(error); + // The engine's own variant, which the chain flattens to text. + assert!(matches!(source.as_inner(), turso::Error::Corrupt(_))); + + let outer = Outer(source); + assert_eq!(outer.to_string(), "the run log operation did not complete"); + assert_eq!( + outer.source().map(ToString::to_string), + Some(rendered.clone()) + ); + assert_eq!(outer.0.into_inner().to_string(), rendered); + } +} diff --git a/crates/shared-loopback/src/host.rs b/crates/shared-loopback/src/host.rs new file mode 100644 index 000000000..106a9e501 --- /dev/null +++ b/crates/shared-loopback/src/host.rs @@ -0,0 +1,246 @@ +//! The host wall: refusing requests whose authority is not the bound +//! loopback socket, which closes DNS rebinding. + +use std::net::SocketAddr; + +use axum::extract::{Request, State}; +use axum::http::StatusCode; +use axum::http::header::HOST; +use axum::middleware::Next; +use axum::response::{IntoResponse, Response}; + +/// Refuses any request whose authority is not the bound loopback socket +/// with `403 Forbidden`, applied through +/// [`axum::middleware::from_fn_with_state`] with the bound address as the +/// state. +/// +/// This is the DNS-rebinding sibling of +/// [`require_loopback`](crate::require_loopback): a page on a +/// rebound hostname reaches a loopback server with same-origin fetch +/// metadata, but its requests still carry the attacker's name as the +/// authority, the one signal rebinding cannot forge. While the server is +/// bound to a loopback address, the only admitted authorities are the +/// socket's literal form (`127.0.0.1:port` or `[::1]:port`) and +/// `localhost:port` - plus the port-elided bare forms on a port-80 bind, +/// since http clients omit the default port. A server bound to a +/// non-loopback address has no +/// loopback allowlist to enforce, so every request passes: the operator +/// chose network exposure, and refusing non-loopback authorities would +/// break the very clients that bind exists for. +/// +/// The URI authority (HTTP/2, absolute-form) wins over the `Host` header. +/// A request naming no authority at all fails closed with `403 Forbidden`: +/// browsers, the house's HTTP clients, and the gateway-discovery-file health +/// probe all send the bound address as `Host`, so an authority-less +/// request is nothing the wall was built to admit. No route is exempt, +/// `/health` included, which keeps the probe honest against the same check +/// a browser must pass. +pub async fn require_loopback_host( + State(bound): State, + request: Request, + next: Next, +) -> Response { + if !bound.ip().is_loopback() { + return next.run(request).await; + } + let authority = request + .uri() + .authority() + .map(axum::http::uri::Authority::as_str) + .or_else(|| { + request + .headers() + .get(HOST) + .and_then(|value| value.to_str().ok()) + }); + match authority { + Some(authority) if authority_allowed(authority, bound) => next.run(request).await, + _ => StatusCode::FORBIDDEN.into_response(), + } +} + +/// Whether `authority` names the bound loopback socket: its literal +/// `ip:port` form (`[::1]:port` for IPv6) or `localhost:port`, compared +/// ASCII case-insensitively. A client that elides the default http port +/// still names the socket, so a port-80 bind also admits the bare forms +/// (the bare IP, bracketed for IPv6, and bare `localhost`). +fn authority_allowed(authority: &str, bound: SocketAddr) -> bool { + authority.eq_ignore_ascii_case(bound.to_string().as_str()) + || authority.eq_ignore_ascii_case(format!("localhost:{}", bound.port()).as_str()) + || (bound.port() == 80 + && (authority.eq_ignore_ascii_case(bare_host(bound).as_str()) + || authority.eq_ignore_ascii_case("localhost"))) +} + +/// The bound address's host without the port: the bare IP, bracketed for +/// IPv6, as an authority eliding the default port renders it. +fn bare_host(bound: SocketAddr) -> String { + match bound.ip() { + std::net::IpAddr::V4(ip) => ip.to_string(), + std::net::IpAddr::V6(ip) => format!("[{ip}]"), + } +} + +#[cfg(test)] +mod tests { + use axum::Router; + use axum::body::Body; + use axum::http::Request as HttpRequest; + use axum::routing::get; + use tower::ServiceExt; + + use super::*; + + /// A one-route router with the host wall applied for `bound`, + /// mirroring how the gateway layers it over its whole surface. + fn host_guarded_router(bound: SocketAddr) -> Router { + Router::new().route("/", get(|| async { "ok" })).layer( + axum::middleware::from_fn_with_state(bound, require_loopback_host), + ) + } + + /// Sends one request through the host wall with the given `Host` + /// header (or none at all), against a server bound at `bound`. + async fn host_status_for(bound: &str, host: Option<&str>) -> StatusCode { + host_status(bound, "/", host).await + } + + /// [`host_status_for`] with an explicit request URI, so absolute-form + /// URIs can carry an authority the `Host` header disagrees with. + async fn host_status(bound: &str, uri: &str, host: Option<&str>) -> StatusCode { + let bound: SocketAddr = bound.parse().expect("a socket address"); + let mut builder = HttpRequest::builder().uri(uri); + if let Some(host) = host { + builder = builder.header(HOST, host); + } + let request = builder + .body(Body::empty()) + .expect("static request parts are valid"); + host_guarded_router(bound) + .oneshot(request) + .await + .expect("the router is infallible") + .status() + } + + #[tokio::test] + async fn the_bound_ipv4_authority_is_admitted() { + assert_eq!( + host_status_for("127.0.0.1:8081", Some("127.0.0.1:8081")).await, + StatusCode::OK + ); + } + + #[tokio::test] + async fn the_bound_ipv6_authority_is_admitted() { + assert_eq!( + host_status_for("[::1]:8081", Some("[::1]:8081")).await, + StatusCode::OK + ); + } + + #[tokio::test] + async fn localhost_with_the_bound_port_is_admitted() { + for bound in ["127.0.0.1:8081", "[::1]:8081"] { + assert_eq!( + host_status_for(bound, Some("localhost:8081")).await, + StatusCode::OK, + "localhost:{bound}'s port names the bound socket" + ); + } + } + + #[tokio::test] + async fn the_authority_comparison_is_case_insensitive() { + assert_eq!( + host_status_for("127.0.0.1:8081", Some("LOCALHOST:8081")).await, + StatusCode::OK + ); + } + + #[tokio::test] + async fn a_foreign_authority_is_refused_with_403() { + for host in ["attacker.com", "attacker.com:8081"] { + assert_eq!( + host_status_for("127.0.0.1:8081", Some(host)).await, + StatusCode::FORBIDDEN, + "a rebound hostname is refused even on the bound port: {host}" + ); + } + } + + #[tokio::test] + async fn a_loopback_authority_on_the_wrong_port_is_refused() { + assert_eq!( + host_status_for("127.0.0.1:8081", Some("127.0.0.1:9999")).await, + StatusCode::FORBIDDEN + ); + } + + #[tokio::test] + async fn a_missing_authority_fails_closed_with_403() { + assert_eq!( + host_status_for("127.0.0.1:8081", None).await, + StatusCode::FORBIDDEN + ); + } + + #[tokio::test] + async fn the_absolute_form_uri_authority_wins_over_the_host_header() { + assert_eq!( + host_status( + "127.0.0.1:8081", + "http://127.0.0.1:8081/", + Some("attacker.com") + ) + .await, + StatusCode::OK, + "the request line's authority is the addressed one" + ); + assert_eq!( + host_status( + "127.0.0.1:8081", + "http://attacker.com:8081/", + Some("127.0.0.1:8081") + ) + .await, + StatusCode::FORBIDDEN, + "a foreign absolute-form authority is refused despite a loopback Host" + ); + } + + #[tokio::test] + async fn a_default_port_bind_admits_the_port_elided_authority() { + for host in ["127.0.0.1", "localhost", "LOCALHOST"] { + assert_eq!( + host_status_for("127.0.0.1:80", Some(host)).await, + StatusCode::OK, + "http elides the default port: {host}" + ); + } + assert_eq!( + host_status_for("[::1]:80", Some("[::1]")).await, + StatusCode::OK, + "the bracketed bare IPv6 host names the bound socket" + ); + // Elision is admitted only on the default port. + assert_eq!( + host_status_for("127.0.0.1:8081", Some("127.0.0.1")).await, + StatusCode::FORBIDDEN + ); + } + + #[tokio::test] + async fn a_non_loopback_bind_admits_any_authority() { + assert_eq!( + host_status_for("0.0.0.0:8081", Some("gateway.lan:8081")).await, + StatusCode::OK, + "a LAN server has no loopback allowlist to enforce" + ); + assert_eq!( + host_status_for("0.0.0.0:8081", None).await, + StatusCode::OK, + "even an authority-less request passes a non-loopback bind" + ); + } +} diff --git a/crates/shared-loopback/src/lib.rs b/crates/shared-loopback/src/lib.rs index 3ca6a3c23..4a7a464cf 100644 --- a/crates/shared-loopback/src/lib.rs +++ b/crates/shared-loopback/src/lib.rs @@ -17,532 +17,10 @@ //! origins, while [`workshop_same_origin_authority_allowed`] requires browser //! origins to match the Workshop request authority. -use std::net::SocketAddr; +mod host; +mod origin; +mod peer; -use axum::extract::{ConnectInfo, Request, State}; -use axum::http::StatusCode; -use axum::http::header::HOST; -use axum::http::uri::Authority; -use axum::middleware::Next; -use axum::response::{IntoResponse, Response}; - -/// Refuses any request whose peer address is not loopback with -/// `403 Forbidden`, applied through [`axum::middleware::from_fn`]. -/// -/// This is the single shared loopback check for the whole config -/// surface: the config-ui crate's asset router wraps the SPA routes with -/// it, and the gateway applies the same function to its admin config -/// endpoints (the config read and write paths, env, orphans, system, -/// model-info, the HF proxy, profile create and delete, and reveal), so -/// the check exists in exactly one place. Those endpoints hold secrets in -/// plaintext and write files, so they must never be reachable from the -/// LAN even with the bearer key; the wall comes before auth. -/// -/// The peer address is read from the [`ConnectInfo`] request extension, -/// which exists only when the server is started with -/// `into_make_service_with_connect_info::()`. A request with -/// no peer address fails closed: it is refused as non-loopback rather -/// than admitted on a wiring fault. -pub async fn require_loopback(request: Request, next: Next) -> Response { - let peer = request - .extensions() - .get::>() - .map(|ConnectInfo(peer)| *peer); - if is_loopback_peer(peer) { - next.run(request).await - } else { - StatusCode::FORBIDDEN.into_response() - } -} - -/// Whether `peer` is a loopback peer address. -/// -/// This is the one peer predicate behind [`require_loopback`], exposed so -/// a caller that keeps the peer address instead of the request (the -/// gateway's keyless-loopback auth rule) asks the same question rather -/// than spelling its own. `None` - no `ConnectInfo` was recorded - fails -/// closed as non-loopback, exactly as the middleware does. -/// -/// # Examples -/// ``` -/// use std::net::SocketAddr; -/// -/// let loopback: SocketAddr = "127.0.0.1:50000".parse()?; -/// let lan: SocketAddr = "198.51.100.7:44821".parse()?; -/// assert!(shared_loopback::is_loopback_peer(Some(loopback))); -/// assert!(!shared_loopback::is_loopback_peer(Some(lan))); -/// assert!(!shared_loopback::is_loopback_peer(None)); -/// # Ok::<(), std::net::AddrParseError>(()) -/// ``` -#[must_use] -pub fn is_loopback_peer(peer: Option) -> bool { - peer.is_some_and(|peer| peer.ip().is_loopback()) -} - -/// Refuses any request whose authority is not the bound loopback socket -/// with `403 Forbidden`, applied through -/// [`axum::middleware::from_fn_with_state`] with the bound address as the -/// state. -/// -/// This is the DNS-rebinding sibling of [`require_loopback`]: a page on a -/// rebound hostname reaches a loopback server with same-origin fetch -/// metadata, but its requests still carry the attacker's name as the -/// authority, the one signal rebinding cannot forge. While the server is -/// bound to a loopback address, the only admitted authorities are the -/// socket's literal form (`127.0.0.1:port` or `[::1]:port`) and -/// `localhost:port` - plus the port-elided bare forms on a port-80 bind, -/// since http clients omit the default port. A server bound to a -/// non-loopback address has no -/// loopback allowlist to enforce, so every request passes: the operator -/// chose network exposure, and refusing non-loopback authorities would -/// break the very clients that bind exists for. -/// -/// The URI authority (HTTP/2, absolute-form) wins over the `Host` header. -/// A request naming no authority at all fails closed with `403 Forbidden`: -/// browsers, the house's HTTP clients, and the gateway-discovery-file health -/// probe all send the bound address as `Host`, so an authority-less -/// request is nothing the wall was built to admit. No route is exempt, -/// `/health` included, which keeps the probe honest against the same check -/// a browser must pass. -pub async fn require_loopback_host( - State(bound): State, - request: Request, - next: Next, -) -> Response { - if !bound.ip().is_loopback() { - return next.run(request).await; - } - let authority = request - .uri() - .authority() - .map(axum::http::uri::Authority::as_str) - .or_else(|| { - request - .headers() - .get(HOST) - .and_then(|value| value.to_str().ok()) - }); - match authority { - Some(authority) if authority_allowed(authority, bound) => next.run(request).await, - _ => StatusCode::FORBIDDEN.into_response(), - } -} - -/// Whether `authority` names the bound loopback socket: its literal -/// `ip:port` form (`[::1]:port` for IPv6) or `localhost:port`, compared -/// ASCII case-insensitively. A client that elides the default http port -/// still names the socket, so a port-80 bind also admits the bare forms -/// (the bare IP, bracketed for IPv6, and bare `localhost`). -fn authority_allowed(authority: &str, bound: SocketAddr) -> bool { - authority.eq_ignore_ascii_case(bound.to_string().as_str()) - || authority.eq_ignore_ascii_case(format!("localhost:{}", bound.port()).as_str()) - || (bound.port() == 80 - && (authority.eq_ignore_ascii_case(bare_host(bound).as_str()) - || authority.eq_ignore_ascii_case("localhost"))) -} - -/// The bound address's host without the port: the bare IP, bracketed for -/// IPv6, as an authority eliding the default port renders it. -fn bare_host(bound: SocketAddr) -> String { - match bound.ip() { - std::net::IpAddr::V4(ip) => ip.to_string(), - std::net::IpAddr::V6(ip) => format!("[{ip}]"), - } -} - -/// Whether a Gateway WebSocket Origin is allowed. -/// -/// An absent Origin denotes a native client and is admitted. A browser Origin -/// must be an exact HTTP origin whose host is a loopback IP address or -/// `localhost`. HTTPS, foreign hosts, paths, queries, and malformed authorities -/// fail closed. -#[must_use] -pub fn gateway_loopback_origin_allowed(origin: Option<&str>) -> bool { - let Some(origin) = origin else { - return true; - }; - parse_http_origin_authority(origin).is_some_and(|authority| { - let host = authority.host(); - host.eq_ignore_ascii_case("localhost") - || host - .trim_start_matches('[') - .trim_end_matches(']') - .parse::() - .is_ok_and(|ip| ip.is_loopback()) - }) -} - -/// Whether a Workshop WebSocket Origin matches its request authority. -/// -/// An absent Origin denotes a native client, but the request authority must -/// still be present and valid. A browser Origin must be an exact HTTP origin -/// whose normalized authority equals the validated request authority. Missing -/// or malformed values and host or port mismatches fail closed. -#[must_use] -pub fn workshop_same_origin_authority_allowed( - origin: Option<&str>, - request_authority: Option<&str>, -) -> bool { - let Some(request_authority) = request_authority.and_then(parse_authority) else { - return false; - }; - origin.is_none_or(|origin| { - parse_http_origin_authority(origin).is_some_and(|origin_authority| { - same_origin_authority(&origin_authority, &request_authority) - }) - }) -} - -/// Compares normalized hosts while preserving explicit port equality. -fn same_origin_authority(left: &Authority, right: &Authority) -> bool { - left.port_u16() == right.port_u16() && same_authority_host(left.host(), right.host()) -} - -/// Compares IP hosts by value and domain hosts ASCII case-insensitively. -fn same_authority_host(left: &str, right: &str) -> bool { - let parse_ip = |host: &str| { - host.strip_prefix('[') - .and_then(|host| host.strip_suffix(']')) - .unwrap_or(host) - .parse::() - .ok() - }; - match (parse_ip(left), parse_ip(right)) { - (Some(left), Some(right)) => left == right, - (None, None) => left.eq_ignore_ascii_case(right), - _ => false, - } -} - -/// Parses an exact HTTP origin and returns its authority. -fn parse_http_origin_authority(origin: &str) -> Option { - let (scheme, authority) = origin.split_once("://")?; - if !scheme.eq_ignore_ascii_case("http") { - return None; - } - parse_authority(authority) -} - -/// Parses an authority and rejects ports outside the `u16` range. -fn parse_authority(authority: &str) -> Option { - let port = if let Some(bracketed) = authority.strip_prefix('[') { - let close = bracketed.find(']')?; - match &bracketed[close + 1..] { - "" => None, - suffix => Some(suffix.strip_prefix(':')?), - } - } else { - match authority.split_once(':') { - Some((host, port)) if !host.is_empty() && !port.contains(':') => Some(port), - Some(_) => return None, - None => None, - } - }; - if authority.contains('@') - || port.is_some_and(|port| port.is_empty() || port.parse::().is_err()) - { - return None; - } - let authority = authority.parse::().ok()?; - if authority.host().is_empty() { - return None; - } - Some(authority) -} - -#[cfg(test)] -mod tests { - use axum::Router; - use axum::body::Body; - use axum::http::Request as HttpRequest; - use axum::routing::get; - use tower::ServiceExt; - - use super::*; - - /// A one-route router with the loopback wall applied, mirroring how - /// the config-ui asset router and the gateway layer it. - fn guarded_router() -> Router { - Router::new() - .route("/", get(|| async { "ok" })) - .layer(axum::middleware::from_fn(require_loopback)) - } - - /// Sends one request through the guarded router, with the given peer - /// address planted as the `ConnectInfo` extension (or none at all). - async fn status_for(peer: Option<&str>) -> StatusCode { - let mut request = HttpRequest::builder() - .uri("/") - .body(Body::empty()) - .expect("static request parts are valid"); - if let Some(address) = peer { - let address: SocketAddr = address.parse().expect("a socket address"); - request.extensions_mut().insert(ConnectInfo(address)); - } - guarded_router() - .oneshot(request) - .await - .expect("the router is infallible") - .status() - } - - #[tokio::test] - async fn a_loopback_ipv4_peer_is_admitted() { - assert_eq!(status_for(Some("127.0.0.1:50000")).await, StatusCode::OK); - } - - #[tokio::test] - async fn a_loopback_ipv6_peer_is_admitted() { - assert_eq!(status_for(Some("[::1]:50000")).await, StatusCode::OK); - } - - #[tokio::test] - async fn a_lan_peer_is_refused_with_403() { - assert_eq!( - status_for(Some("198.51.100.7:44821")).await, - StatusCode::FORBIDDEN - ); - } - - #[tokio::test] - async fn a_missing_peer_address_fails_closed_with_403() { - assert_eq!(status_for(None).await, StatusCode::FORBIDDEN); - } - - /// A one-route router with the host wall applied for `bound`, - /// mirroring how the gateway layers it over its whole surface. - fn host_guarded_router(bound: SocketAddr) -> Router { - Router::new().route("/", get(|| async { "ok" })).layer( - axum::middleware::from_fn_with_state(bound, require_loopback_host), - ) - } - - /// Sends one request through the host wall with the given `Host` - /// header (or none at all), against a server bound at `bound`. - async fn host_status_for(bound: &str, host: Option<&str>) -> StatusCode { - host_status(bound, "/", host).await - } - - /// [`host_status_for`] with an explicit request URI, so absolute-form - /// URIs can carry an authority the `Host` header disagrees with. - async fn host_status(bound: &str, uri: &str, host: Option<&str>) -> StatusCode { - let bound: SocketAddr = bound.parse().expect("a socket address"); - let mut builder = HttpRequest::builder().uri(uri); - if let Some(host) = host { - builder = builder.header(HOST, host); - } - let request = builder - .body(Body::empty()) - .expect("static request parts are valid"); - host_guarded_router(bound) - .oneshot(request) - .await - .expect("the router is infallible") - .status() - } - - #[tokio::test] - async fn the_bound_ipv4_authority_is_admitted() { - assert_eq!( - host_status_for("127.0.0.1:8081", Some("127.0.0.1:8081")).await, - StatusCode::OK - ); - } - - #[tokio::test] - async fn the_bound_ipv6_authority_is_admitted() { - assert_eq!( - host_status_for("[::1]:8081", Some("[::1]:8081")).await, - StatusCode::OK - ); - } - - #[tokio::test] - async fn localhost_with_the_bound_port_is_admitted() { - for bound in ["127.0.0.1:8081", "[::1]:8081"] { - assert_eq!( - host_status_for(bound, Some("localhost:8081")).await, - StatusCode::OK, - "localhost:{bound}'s port names the bound socket" - ); - } - } - - #[tokio::test] - async fn the_authority_comparison_is_case_insensitive() { - assert_eq!( - host_status_for("127.0.0.1:8081", Some("LOCALHOST:8081")).await, - StatusCode::OK - ); - } - - #[tokio::test] - async fn a_foreign_authority_is_refused_with_403() { - for host in ["attacker.com", "attacker.com:8081"] { - assert_eq!( - host_status_for("127.0.0.1:8081", Some(host)).await, - StatusCode::FORBIDDEN, - "a rebound hostname is refused even on the bound port: {host}" - ); - } - } - - #[tokio::test] - async fn a_loopback_authority_on_the_wrong_port_is_refused() { - assert_eq!( - host_status_for("127.0.0.1:8081", Some("127.0.0.1:9999")).await, - StatusCode::FORBIDDEN - ); - } - - #[tokio::test] - async fn a_missing_authority_fails_closed_with_403() { - assert_eq!( - host_status_for("127.0.0.1:8081", None).await, - StatusCode::FORBIDDEN - ); - } - - #[tokio::test] - async fn the_absolute_form_uri_authority_wins_over_the_host_header() { - assert_eq!( - host_status( - "127.0.0.1:8081", - "http://127.0.0.1:8081/", - Some("attacker.com") - ) - .await, - StatusCode::OK, - "the request line's authority is the addressed one" - ); - assert_eq!( - host_status( - "127.0.0.1:8081", - "http://attacker.com:8081/", - Some("127.0.0.1:8081") - ) - .await, - StatusCode::FORBIDDEN, - "a foreign absolute-form authority is refused despite a loopback Host" - ); - } - - #[tokio::test] - async fn a_default_port_bind_admits_the_port_elided_authority() { - for host in ["127.0.0.1", "localhost", "LOCALHOST"] { - assert_eq!( - host_status_for("127.0.0.1:80", Some(host)).await, - StatusCode::OK, - "http elides the default port: {host}" - ); - } - assert_eq!( - host_status_for("[::1]:80", Some("[::1]")).await, - StatusCode::OK, - "the bracketed bare IPv6 host names the bound socket" - ); - // Elision is admitted only on the default port. - assert_eq!( - host_status_for("127.0.0.1:8081", Some("127.0.0.1")).await, - StatusCode::FORBIDDEN - ); - } - - #[tokio::test] - async fn a_non_loopback_bind_admits_any_authority() { - assert_eq!( - host_status_for("0.0.0.0:8081", Some("gateway.lan:8081")).await, - StatusCode::OK, - "a LAN server has no loopback allowlist to enforce" - ); - assert_eq!( - host_status_for("0.0.0.0:8081", None).await, - StatusCode::OK, - "even an authority-less request passes a non-loopback bind" - ); - } - - #[test] - fn gateway_origin_admits_native_clients_and_http_loopback() { - assert!(gateway_loopback_origin_allowed(None)); - for origin in [ - "http://127.0.0.1", - "http://127.5.0.1:8081", - "http://localhost:8081", - "http://LOCALHOST:8081", - "http://[::1]:8081", - ] { - assert!( - gateway_loopback_origin_allowed(Some(origin)), - "{origin} must be admitted" - ); - } - } - - #[test] - fn gateway_origin_refuses_non_http_foreign_and_malformed_values() { - for origin in [ - "https://localhost:8081", - "http://192.168.1.10:8081", - "http://localhost.evil.example:8081", - "file:///etc/passwd", - "http://localhost:bad", - "http://localhost:8081/path", - "null", - "", - ] { - assert!( - !gateway_loopback_origin_allowed(Some(origin)), - "{origin} must be refused" - ); - } - } - - #[test] - fn workshop_origin_admits_native_clients_with_valid_request_authority() { - assert!(workshop_same_origin_authority_allowed( - None, - Some("127.0.0.1:7910") - )); - assert!(!workshop_same_origin_authority_allowed(None, None)); - assert!(!workshop_same_origin_authority_allowed( - None, - Some("localhost:bad") - )); - } - - #[test] - fn workshop_origin_requires_matching_normalized_authorities() { - for (origin, authority) in [ - ("http://127.0.0.1:7910", "127.0.0.1:7910"), - ("http://localhost:7910", "LOCALHOST:7910"), - ("http://[::1]:7910", "[::1]:7910"), - ("http://[0:0:0:0:0:0:0:1]:7910", "[::1]:7910"), - ] { - assert!( - workshop_same_origin_authority_allowed(Some(origin), Some(authority)), - "{origin} must match {authority}" - ); - } - } - - #[test] - fn workshop_origin_refuses_mismatch_wrong_port_and_malformed_values() { - for (origin, authority) in [ - ("http://127.0.0.1:7910", "localhost:7910"), - ("http://localhost:7910", "localhost:7911"), - ("http://[0:0:0:0:0:0:0:1]:7910", "[::1]:7911"), - ("http://[0:0:0:0:0:0:0:2]:7910", "[::1]:7910"), - ("http://evil.example:7910", "localhost:7910"), - ("http://localhost:bad", "localhost:7910"), - ("http://localhost:7910/path", "localhost:7910"), - ("null", "localhost:7910"), - ("", "localhost:7910"), - ] { - assert!( - !workshop_same_origin_authority_allowed(Some(origin), Some(authority)), - "{origin} must not match {authority}" - ); - } - } -} +pub use host::require_loopback_host; +pub use origin::{gateway_loopback_origin_allowed, workshop_same_origin_authority_allowed}; +pub use peer::{is_loopback_peer, require_loopback}; diff --git a/crates/shared-loopback/src/origin.rs b/crates/shared-loopback/src/origin.rs new file mode 100644 index 000000000..63e322184 --- /dev/null +++ b/crates/shared-loopback/src/origin.rs @@ -0,0 +1,193 @@ +//! WebSocket Origin policy: the product-specific rules deciding which +//! browser origins may open a Gateway or Workshop socket. + +use axum::http::uri::Authority; + +/// Whether a Gateway WebSocket Origin is allowed. +/// +/// An absent Origin denotes a native client and is admitted. A browser Origin +/// must be an exact HTTP origin whose host is a loopback IP address or +/// `localhost`. HTTPS, foreign hosts, paths, queries, and malformed authorities +/// fail closed. +#[must_use] +pub fn gateway_loopback_origin_allowed(origin: Option<&str>) -> bool { + let Some(origin) = origin else { + return true; + }; + parse_http_origin_authority(origin).is_some_and(|authority| { + let host = authority.host(); + host.eq_ignore_ascii_case("localhost") + || host + .trim_start_matches('[') + .trim_end_matches(']') + .parse::() + .is_ok_and(|ip| ip.is_loopback()) + }) +} + +/// Whether a Workshop WebSocket Origin matches its request authority. +/// +/// An absent Origin denotes a native client, but the request authority must +/// still be present and valid. A browser Origin must be an exact HTTP origin +/// whose normalized authority equals the validated request authority. Missing +/// or malformed values and host or port mismatches fail closed. +#[must_use] +pub fn workshop_same_origin_authority_allowed( + origin: Option<&str>, + request_authority: Option<&str>, +) -> bool { + let Some(request_authority) = request_authority.and_then(parse_authority) else { + return false; + }; + origin.is_none_or(|origin| { + parse_http_origin_authority(origin).is_some_and(|origin_authority| { + same_origin_authority(&origin_authority, &request_authority) + }) + }) +} + +/// Compares normalized hosts while preserving explicit port equality. +fn same_origin_authority(left: &Authority, right: &Authority) -> bool { + left.port_u16() == right.port_u16() && same_authority_host(left.host(), right.host()) +} + +/// Compares IP hosts by value and domain hosts ASCII case-insensitively. +fn same_authority_host(left: &str, right: &str) -> bool { + let parse_ip = |host: &str| { + host.strip_prefix('[') + .and_then(|host| host.strip_suffix(']')) + .unwrap_or(host) + .parse::() + .ok() + }; + match (parse_ip(left), parse_ip(right)) { + (Some(left), Some(right)) => left == right, + (None, None) => left.eq_ignore_ascii_case(right), + _ => false, + } +} + +/// Parses an exact HTTP origin and returns its authority. +fn parse_http_origin_authority(origin: &str) -> Option { + let (scheme, authority) = origin.split_once("://")?; + if !scheme.eq_ignore_ascii_case("http") { + return None; + } + parse_authority(authority) +} + +/// Parses an authority and rejects ports outside the `u16` range. +fn parse_authority(authority: &str) -> Option { + let port = if let Some(bracketed) = authority.strip_prefix('[') { + let close = bracketed.find(']')?; + match &bracketed[close + 1..] { + "" => None, + suffix => Some(suffix.strip_prefix(':')?), + } + } else { + match authority.split_once(':') { + Some((host, port)) if !host.is_empty() && !port.contains(':') => Some(port), + Some(_) => return None, + None => None, + } + }; + if authority.contains('@') + || port.is_some_and(|port| port.is_empty() || port.parse::().is_err()) + { + return None; + } + let authority = authority.parse::().ok()?; + if authority.host().is_empty() { + return None; + } + Some(authority) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn gateway_origin_admits_native_clients_and_http_loopback() { + assert!(gateway_loopback_origin_allowed(None)); + for origin in [ + "http://127.0.0.1", + "http://127.5.0.1:8081", + "http://localhost:8081", + "http://LOCALHOST:8081", + "http://[::1]:8081", + ] { + assert!( + gateway_loopback_origin_allowed(Some(origin)), + "{origin} must be admitted" + ); + } + } + + #[test] + fn gateway_origin_refuses_non_http_foreign_and_malformed_values() { + for origin in [ + "https://localhost:8081", + "http://192.168.1.10:8081", + "http://localhost.evil.example:8081", + "file:///etc/passwd", + "http://localhost:bad", + "http://localhost:8081/path", + "null", + "", + ] { + assert!( + !gateway_loopback_origin_allowed(Some(origin)), + "{origin} must be refused" + ); + } + } + + #[test] + fn workshop_origin_admits_native_clients_with_valid_request_authority() { + assert!(workshop_same_origin_authority_allowed( + None, + Some("127.0.0.1:7910") + )); + assert!(!workshop_same_origin_authority_allowed(None, None)); + assert!(!workshop_same_origin_authority_allowed( + None, + Some("localhost:bad") + )); + } + + #[test] + fn workshop_origin_requires_matching_normalized_authorities() { + for (origin, authority) in [ + ("http://127.0.0.1:7910", "127.0.0.1:7910"), + ("http://localhost:7910", "LOCALHOST:7910"), + ("http://[::1]:7910", "[::1]:7910"), + ("http://[0:0:0:0:0:0:0:1]:7910", "[::1]:7910"), + ] { + assert!( + workshop_same_origin_authority_allowed(Some(origin), Some(authority)), + "{origin} must match {authority}" + ); + } + } + + #[test] + fn workshop_origin_refuses_mismatch_wrong_port_and_malformed_values() { + for (origin, authority) in [ + ("http://127.0.0.1:7910", "localhost:7910"), + ("http://localhost:7910", "localhost:7911"), + ("http://[0:0:0:0:0:0:0:1]:7910", "[::1]:7911"), + ("http://[0:0:0:0:0:0:0:2]:7910", "[::1]:7910"), + ("http://evil.example:7910", "localhost:7910"), + ("http://localhost:bad", "localhost:7910"), + ("http://localhost:7910/path", "localhost:7910"), + ("null", "localhost:7910"), + ("", "localhost:7910"), + ] { + assert!( + !workshop_same_origin_authority_allowed(Some(origin), Some(authority)), + "{origin} must not match {authority}" + ); + } + } +} diff --git a/crates/shared-loopback/src/peer.rs b/crates/shared-loopback/src/peer.rs new file mode 100644 index 000000000..939b9b0de --- /dev/null +++ b/crates/shared-loopback/src/peer.rs @@ -0,0 +1,121 @@ +//! The peer wall: refusing requests whose connected peer is not loopback. + +use std::net::SocketAddr; + +use axum::extract::{ConnectInfo, Request}; +use axum::http::StatusCode; +use axum::middleware::Next; +use axum::response::{IntoResponse, Response}; + +/// Refuses any request whose peer address is not loopback with +/// `403 Forbidden`, applied through [`axum::middleware::from_fn`]. +/// +/// This is the single shared loopback check for the whole config +/// surface: the config-ui crate's asset router wraps the SPA routes with +/// it, and the gateway applies the same function to its admin config +/// endpoints (the config read and write paths, env, orphans, system, +/// model-info, the HF proxy, profile create and delete, and reveal), so +/// the check exists in exactly one place. Those endpoints hold secrets in +/// plaintext and write files, so they must never be reachable from the +/// LAN even with the bearer key; the wall comes before auth. +/// +/// The peer address is read from the [`ConnectInfo`] request extension, +/// which exists only when the server is started with +/// `into_make_service_with_connect_info::()`. A request with +/// no peer address fails closed: it is refused as non-loopback rather +/// than admitted on a wiring fault. +pub async fn require_loopback(request: Request, next: Next) -> Response { + let peer = request + .extensions() + .get::>() + .map(|ConnectInfo(peer)| *peer); + if is_loopback_peer(peer) { + next.run(request).await + } else { + StatusCode::FORBIDDEN.into_response() + } +} + +/// Whether `peer` is a loopback peer address. +/// +/// This is the one peer predicate behind [`require_loopback`], exposed so +/// a caller that keeps the peer address instead of the request (the +/// gateway's keyless-loopback auth rule) asks the same question rather +/// than spelling its own. `None` - no `ConnectInfo` was recorded - fails +/// closed as non-loopback, exactly as the middleware does. +/// +/// # Examples +/// ``` +/// use std::net::SocketAddr; +/// +/// let loopback: SocketAddr = "127.0.0.1:50000".parse()?; +/// let lan: SocketAddr = "198.51.100.7:44821".parse()?; +/// assert!(shared_loopback::is_loopback_peer(Some(loopback))); +/// assert!(!shared_loopback::is_loopback_peer(Some(lan))); +/// assert!(!shared_loopback::is_loopback_peer(None)); +/// # Ok::<(), std::net::AddrParseError>(()) +/// ``` +#[must_use] +pub fn is_loopback_peer(peer: Option) -> bool { + peer.is_some_and(|peer| peer.ip().is_loopback()) +} + +#[cfg(test)] +mod tests { + use axum::Router; + use axum::body::Body; + use axum::http::Request as HttpRequest; + use axum::routing::get; + use tower::ServiceExt; + + use super::*; + + /// A one-route router with the loopback wall applied, mirroring how + /// the config-ui asset router and the gateway layer it. + fn guarded_router() -> Router { + Router::new() + .route("/", get(|| async { "ok" })) + .layer(axum::middleware::from_fn(require_loopback)) + } + + /// Sends one request through the guarded router, with the given peer + /// address planted as the `ConnectInfo` extension (or none at all). + async fn status_for(peer: Option<&str>) -> StatusCode { + let mut request = HttpRequest::builder() + .uri("/") + .body(Body::empty()) + .expect("static request parts are valid"); + if let Some(address) = peer { + let address: SocketAddr = address.parse().expect("a socket address"); + request.extensions_mut().insert(ConnectInfo(address)); + } + guarded_router() + .oneshot(request) + .await + .expect("the router is infallible") + .status() + } + + #[tokio::test] + async fn a_loopback_ipv4_peer_is_admitted() { + assert_eq!(status_for(Some("127.0.0.1:50000")).await, StatusCode::OK); + } + + #[tokio::test] + async fn a_loopback_ipv6_peer_is_admitted() { + assert_eq!(status_for(Some("[::1]:50000")).await, StatusCode::OK); + } + + #[tokio::test] + async fn a_lan_peer_is_refused_with_403() { + assert_eq!( + status_for(Some("198.51.100.7:44821")).await, + StatusCode::FORBIDDEN + ); + } + + #[tokio::test] + async fn a_missing_peer_address_fails_closed_with_403() { + assert_eq!(status_for(None).await, StatusCode::FORBIDDEN); + } +} diff --git a/crates/shared-progress/AGENTS.md b/crates/shared-progress/AGENTS.md deleted file mode 100644 index 8adf30b17..000000000 --- a/crates/shared-progress/AGENTS.md +++ /dev/null @@ -1,10 +0,0 @@ -# shared-progress - -This crate owns runtime-agnostic progress vocabulary and delivery semantics. - -- This crate stays at the bottom of the workspace graph with no PromptForge product dependencies. -- Hosts own forwarding tasks. This crate does not spawn work or block a runtime. -- Producers report through `ProgressHandle`; renderers consume hub events or snapshots. Producers never format output or create a parallel progress channel. -- Intermediate events are lossy (coalesced at the source, droppable under receiver lag); terminal `Finished` events are never coalesced. Consumers detect completion only from `Finished`, never from a fraction reaching 1.0. -- Weights are proportional to expected time, not bytes or unit counts: a leaf's byte total is how it computes its own fraction, never its weight. -- Serialization changes are additive so existing wire vocabulary remains valid. diff --git a/crates/shared-progress/Cargo.toml b/crates/shared-progress/Cargo.toml deleted file mode 100644 index eff043116..000000000 --- a/crates/shared-progress/Cargo.toml +++ /dev/null @@ -1,28 +0,0 @@ -[package] -name = "shared-progress" -version.workspace = true -edition.workspace = true -license.workspace = true -repository.workspace = true -publish = false - -description = "PromptForge progress vocabulary: operation-scoped weighted trees, a process hub, coalesced events, and remote import" - -[dependencies] -# `time` backs the emission coalescer's clock so tests can pause it through -# tokio's test-util; `sync` carries the hub's broadcast channel. -tokio = { workspace = true, features = ["sync", "time"] } -tracing.workspace = true -serde = { workspace = true, optional = true } -workspace-hack.workspace = true - -[features] -default = [] -serde = ["dep:serde"] - -[dev-dependencies] -tokio = { workspace = true, features = ["macros", "rt", "test-util"] } -serde_json.workspace = true - -[lints] -workspace = true diff --git a/crates/shared-progress/src/event.rs b/crates/shared-progress/src/event.rs deleted file mode 100644 index 4ac80cacc..000000000 --- a/crates/shared-progress/src/event.rs +++ /dev/null @@ -1,159 +0,0 @@ -//! The wire vocabulary: what a leaf reports and a hub broadcasts. - -use std::fmt; -use std::sync::atomic::{AtomicU64, Ordering}; - -/// Identifies one live operation tree within a process. -/// -/// Ids come from a process-local counter, so an id received from a remote -/// process is meaningful only inside that process's event stream; a -/// [`RemoteOperation`](crate::RemoteOperation) re-issues events under a local -/// id. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[non_exhaustive] -pub struct OperationId(u64); - -impl OperationId { - pub(crate) fn next() -> Self { - static NEXT: AtomicU64 = AtomicU64::new(1); - Self(NEXT.fetch_add(1, Ordering::Relaxed)) - } - - /// The raw numeric id. - /// - /// # Examples - /// - /// ``` - /// use std::sync::Arc; - /// use shared_progress::ProgressHub; - /// - /// let hub = Arc::new(ProgressHub::new()); - /// let tree = hub.operation(); - /// assert!(tree.operation().get() > 0); - /// ``` - #[must_use] - pub const fn get(self) -> u64 { - self.0 - } -} - -impl fmt::Display for OperationId { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "op-{}", self.0) - } -} - -/// One progress or lifecycle observation emitted by an operation tree. -/// -/// Intermediate (`Updated`) events are lossy: handles coalesce them and slow -/// receivers drop them. Terminal (`Finished`) events are never coalesced, and -/// consumers detect leaf completion only from `Finished`, never from a -/// fraction reaching 1.0. `OperationFinished` marks tree detachment after its -/// final leaf event. -#[derive(Debug, Clone, PartialEq)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[non_exhaustive] -pub struct ProgressEvent { - /// The operation tree the observation belongs to. - pub operation: OperationId, - /// Hierarchical leaf id within the operation, or empty for the - /// operation-level terminal event. - pub path: String, - /// Human-readable leaf label, or empty for the operation-level event. - pub label: String, - /// What the leaf reports. - pub state: EventState, -} - -impl ProgressEvent { - /// Creates an event. Producers never construct events (handles emit - /// them); this constructor serves test doubles and remote import. - #[must_use] - pub fn new( - operation: OperationId, - path: impl Into, - label: impl Into, - state: EventState, - ) -> Self { - Self { - operation, - path: path.into(), - label: label.into(), - state, - } - } - - /// Creates the terminal lifecycle event emitted when an operation tree - /// detaches from its source hub. - #[must_use] - pub(crate) fn operation_finished(operation: OperationId) -> Self { - Self { - operation, - path: String::new(), - label: String::new(), - state: EventState::OperationFinished, - } - } -} - -/// The kind of observation a [`ProgressEvent`] carries. -#[derive(Debug, Clone, Copy, PartialEq)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[non_exhaustive] -pub enum EventState { - /// A leaf registered; `weight` is its share of its parent's expected - /// duration. - Begun { - /// Sibling-relative weight, proportional to expected time. - weight: f64, - }, - /// The leaf's fraction moved. Lossy: coalesced at the source and - /// droppable under receiver lag. - Updated { - /// The leaf's fraction in `0.0..=1.0`. - fraction: f64, - }, - /// The leaf finished. Never coalesced; the only authoritative completion - /// signal. - Finished { - /// Whether the leaf's work succeeded. - ok: bool, - }, - /// The complete operation tree detached from its source hub. This - /// lifecycle event follows every leaf event and lets remote importers - /// release operation ownership without closing a process-lifetime stream. - OperationFinished, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn operation_ids_are_unique_and_increasing() { - let a = OperationId::next(); - let b = OperationId::next(); - assert!(a < b, "later ids must sort after earlier ones: {a} vs {b}"); - } -} - -#[cfg(all(test, feature = "serde"))] -mod serde_tests { - use super::*; - - #[test] - fn progress_event_survives_a_serde_json_round_trip() { - for state in [ - EventState::Begun { weight: 2.5 }, - EventState::Updated { fraction: 0.25 }, - EventState::Finished { ok: false }, - EventState::OperationFinished, - ] { - let event = ProgressEvent::new(OperationId::next(), "op/leaf", "leaf", state); - let json = serde_json::to_string(&event).expect("the event serializes"); - let back: ProgressEvent = serde_json::from_str(&json).expect("the event deserializes"); - assert_eq!(event, back, "the wire shape must round-trip"); - } - } -} diff --git a/crates/shared-progress/src/handle.rs b/crates/shared-progress/src/handle.rs deleted file mode 100644 index 305a63d0f..000000000 --- a/crates/shared-progress/src/handle.rs +++ /dev/null @@ -1,277 +0,0 @@ -//! The reporting handle a producer holds for one leaf. - -use std::sync::Arc; - -use crate::tree::{Node, TreeState}; - -/// A cheap-to-clone, `Send + Sync` reporting handle for one leaf of a -/// [`ProgressTree`](crate::ProgressTree). -/// -/// Clones share the leaf: any clone may report. Reporting touches only -/// atomics plus the hub's broadcast channel, never a lock, so worker-thread -/// reporters cannot block each other. Emission is coalesced: an update is -/// broadcast only when the fraction moved at least 1% or 100 ms elapsed since -/// the last emission, and terminal events are never coalesced. -#[derive(Debug, Clone)] -#[non_exhaustive] -pub struct ProgressHandle { - tree: Arc, - slot: usize, - node: Arc, -} - -impl ProgressHandle { - pub(crate) fn new(tree: Arc, slot: usize, node: Arc) -> Self { - Self { tree, slot, node } - } - - /// Sets the leaf's fraction, clamped to `0.0..=1.0`. - /// - /// # Examples - /// - /// ``` - /// use std::sync::Arc; - /// use shared_progress::ProgressHub; - /// - /// let hub = Arc::new(ProgressHub::new()); - /// let tree = hub.operation(); - /// let leaf = tree.register("download", 1.0); - /// leaf.set_fraction(0.5); - /// assert_eq!(leaf.fraction(), 0.5); - /// ``` - pub fn set_fraction(&self, fraction: f64) { - self.tree.set_fraction(&self.node, fraction); - } - - /// Sets the fraction from completed units out of a total, for example - /// bytes downloaded. A zero total reports 0.0 while nothing is done and - /// 1.0 once any unit is done. - /// - /// # Examples - /// - /// ``` - /// use std::sync::Arc; - /// use shared_progress::ProgressHub; - /// - /// let hub = Arc::new(ProgressHub::new()); - /// let tree = hub.operation(); - /// let leaf = tree.register("download", 1.0); - /// leaf.set_units(1, 4); - /// assert_eq!(leaf.fraction(), 0.25); - /// ``` - pub fn set_units(&self, done: u64, total: u64) { - #[expect( - clippy::cast_precision_loss, - reason = "unit counts beyond 2^53 lose resolution a display cannot show" - )] - let fraction = if total == 0 { - f64::from(done > 0) - } else { - done as f64 / total as f64 - }; - self.set_fraction(fraction); - } - - /// Forces the fraction to 1.0 and emits the terminal `Finished` event, - /// bypassing coalescing. Call on every exit path of the leaf's work. - /// Terminal state is sticky: the first of [`complete`](Self::complete) or - /// [`fail`](Self::fail) wins and later calls are no-ops. - /// - /// # Examples - /// - /// ``` - /// use std::sync::Arc; - /// use shared_progress::ProgressHub; - /// - /// let hub = Arc::new(ProgressHub::new()); - /// let tree = hub.operation(); - /// let leaf = tree.register("download", 1.0); - /// leaf.set_fraction(0.3); - /// leaf.complete(); - /// assert_eq!(leaf.fraction(), 1.0); - /// ``` - pub fn complete(&self) { - self.tree.complete(&self.node); - } - - /// Emits the terminal `Finished { ok: false }` event, bypassing - /// coalescing, and keeps the leaf's current fraction. Call on every - /// error exit path of the leaf's work, the failure counterpart of - /// [`complete`](Self::complete). Terminal state is sticky: the first of - /// `complete` or `fail` wins and later calls are no-ops. - /// - /// # Examples - /// - /// ``` - /// use std::sync::Arc; - /// use shared_progress::ProgressHub; - /// - /// let hub = Arc::new(ProgressHub::new()); - /// let tree = hub.operation(); - /// let leaf = tree.register("download", 1.0); - /// leaf.set_fraction(0.3); - /// leaf.fail(); - /// assert_eq!(leaf.fraction(), 0.3); - /// ``` - pub fn fail(&self) { - self.tree.finish(&self.node, false); - } - - /// The leaf's current fraction in `0.0..=1.0`. - #[must_use] - pub fn fraction(&self) -> f64 { - self.node.fraction() - } - - /// Registers a child subtree under this leaf and returns its handle. - /// - /// Once a leaf has children its own fraction is the weighted aggregate of - /// theirs, and `weight` is the child's share of the parent's expected - /// duration. - /// - /// # Examples - /// - /// ``` - /// use std::sync::Arc; - /// use shared_progress::ProgressHub; - /// - /// let hub = Arc::new(ProgressHub::new()); - /// let tree = hub.operation(); - /// let model = tree.register("model", 1.0); - /// let download = model.child("download", 3.0); - /// download.set_fraction(1.0); - /// assert_eq!(tree.fraction(), 1.0); - /// ``` - #[must_use] - pub fn child(&self, label: &str, weight: f64) -> Self { - let (slot, node) = self.tree.register(Some(self.slot), label, weight); - Self::new(Arc::clone(&self.tree), slot, node) - } -} - -#[cfg(test)] -mod tests { - // Fractions are fixed-point millionths, so equality comparisons are exact. - #![expect(clippy::float_cmp, reason = "fixed-point fractions compare exactly")] - - use std::time::Duration; - - use crate::{EventState, ProgressEvent, ProgressHub}; - - use super::*; - - fn begun(rx: &mut tokio::sync::broadcast::Receiver) { - let event = rx.try_recv().expect("register emits Begun"); - assert!(matches!(event.state, EventState::Begun { .. })); - } - - #[test] - fn fraction_is_clamped() { - let hub = Arc::new(ProgressHub::new()); - let tree = hub.operation(); - let leaf = tree.register("leaf", 1.0); - leaf.set_fraction(2.0); - assert_eq!(leaf.fraction(), 1.0); - leaf.set_fraction(-1.0); - assert_eq!(leaf.fraction(), 0.0); - leaf.set_fraction(f64::NAN); - assert_eq!(leaf.fraction(), 0.0); - } - - #[test] - fn set_units_reports_bytes_done_over_total() { - let hub = Arc::new(ProgressHub::new()); - let tree = hub.operation(); - let leaf = tree.register("leaf", 1.0); - leaf.set_units(1, 4); - assert_eq!(leaf.fraction(), 0.25); - leaf.set_units(0, 0); - assert_eq!(leaf.fraction(), 0.0); - leaf.set_units(3, 0); - assert_eq!(leaf.fraction(), 1.0); - } - - #[tokio::test(start_paused = true)] - async fn coalesces_intermediate_updates() { - let hub = Arc::new(ProgressHub::new()); - let mut rx = hub.subscribe(); - let tree = hub.operation(); - let leaf = tree.register("download", 1.0); - begun(&mut rx); - - // The first update always emits; small moves inside 100 ms do not. - leaf.set_fraction(0.001); - assert!(matches!( - rx.try_recv().expect("first update emits").state, - EventState::Updated { .. } - )); - leaf.set_fraction(0.002); - leaf.set_fraction(0.003); - assert!( - rx.try_recv().is_err(), - "sub-1% moves within 100 ms coalesce" - ); - - // After 100 ms the next update emits even though the move is tiny. - tokio::time::advance(Duration::from_millis(100)).await; - leaf.set_fraction(0.004); - assert!(matches!( - rx.try_recv().expect("a stale leaf re-emits").state, - EventState::Updated { fraction } if fraction == 0.004 - )); - - // A move of 1% or more emits immediately. - leaf.set_fraction(0.014); - assert!(matches!( - rx.try_recv().expect("a 1% move emits at once").state, - EventState::Updated { fraction } if fraction == 0.014 - )); - } - - #[tokio::test(start_paused = true)] - async fn terminal_events_are_never_coalesced() { - let hub = Arc::new(ProgressHub::new()); - let mut rx = hub.subscribe(); - let tree = hub.operation(); - let leaf = tree.register("download", 1.0); - begun(&mut rx); - leaf.set_fraction(0.5); - assert!(matches!( - rx.try_recv().expect("first update emits").state, - EventState::Updated { .. } - )); - leaf.complete(); - assert!( - matches!( - rx.try_recv() - .expect("complete emits Finished at once") - .state, - EventState::Finished { ok: true } - ), - "Finished must not wait out the coalescing window" - ); - } - - #[tokio::test(start_paused = true)] - async fn fail_emits_a_terminal_event_and_keeps_the_fraction() { - let hub = Arc::new(ProgressHub::new()); - let mut rx = hub.subscribe(); - let tree = hub.operation(); - let leaf = tree.register("download", 1.0); - begun(&mut rx); - leaf.set_fraction(0.5); - assert!(matches!( - rx.try_recv().expect("first update emits").state, - EventState::Updated { .. } - )); - leaf.fail(); - assert!( - matches!( - rx.try_recv().expect("fail emits Finished at once").state, - EventState::Finished { ok: false } - ), - "a failure terminal must not wait out the coalescing window" - ); - assert_eq!(leaf.fraction(), 0.5, "a failed leaf keeps its fraction"); - } -} diff --git a/crates/shared-progress/src/hub.rs b/crates/shared-progress/src/hub.rs deleted file mode 100644 index 2068dfb07..000000000 --- a/crates/shared-progress/src/hub.rs +++ /dev/null @@ -1,157 +0,0 @@ -//! The process-wide broker that operation trees attach to. - -use std::collections::HashMap; -use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; - -use tokio::sync::broadcast; - -use crate::event::{OperationId, ProgressEvent}; -use crate::tree::{ProgressTree, TreeState}; - -/// Broadcast ring capacity. Receivers that lag past it drop intermediate -/// events, which are lossy by design; snapshots carry the ground truth. -const EVENT_CAPACITY: usize = 1024; - -/// One per process: brokers live operation trees to event subscribers and -/// snapshot readers. -/// -/// The hub lives in the host's application state for the process lifetime; -/// operations install and remove themselves by their own lifetimes. The empty -/// set is the idle state: subscribers then see only silence and snapshots are -/// empty. The membership lock guards attach, detach, and snapshot walks with -/// microscopic critical sections; leaf fractions are atomics inside the -/// trees, so reporters on worker threads never touch it. -/// -/// # Examples -/// -/// ``` -/// use std::sync::Arc; -/// use shared_progress::ProgressHub; -/// -/// let hub = Arc::new(ProgressHub::new()); -/// assert!(hub.snapshot().is_empty()); -/// let tree = hub.operation(); -/// assert_eq!(hub.snapshot().len(), 1); -/// drop(tree); -/// assert!(hub.snapshot().is_empty()); -/// ``` -#[derive(Debug)] -#[non_exhaustive] -pub struct ProgressHub { - live: Mutex>>, - events: broadcast::Sender, -} - -impl Default for ProgressHub { - fn default() -> Self { - Self::new() - } -} - -impl ProgressHub { - /// Creates an empty hub: the idle state, running no operations. - #[must_use] - pub fn new() -> Self { - let (events, _) = broadcast::channel(EVENT_CAPACITY); - Self { - live: Mutex::new(HashMap::new()), - events, - } - } - - /// Attaches a fresh operation tree and returns its owner handle. The tree - /// detaches itself when dropped. - pub fn operation(self: &Arc) -> ProgressTree { - let state = Arc::new(TreeState::new(OperationId::next(), self.events.clone())); - self.lock().insert(state.operation(), Arc::clone(&state)); - tracing::debug!(operation = %state.operation(), "operation attached"); - ProgressTree::new(Arc::clone(self), state) - } - - /// Subscribes to the event stream of every live and future operation. - /// - /// Intermediate events are lossy: a receiver that falls more than the - /// ring capacity behind drops them. Terminal events are never coalesced - /// at the source, but a lagging receiver can still drop them; consumers - /// that must observe completion take it from task join results. - /// - /// # Examples - /// - /// ``` - /// use std::sync::Arc; - /// use shared_progress::ProgressHub; - /// - /// let hub = Arc::new(ProgressHub::new()); - /// let mut events = hub.subscribe(); - /// let tree = hub.operation(); - /// let _leaf = tree.register("download", 1.0); - /// assert!(events.try_recv().is_ok()); - /// ``` - pub fn subscribe(&self) -> broadcast::Receiver { - self.events.subscribe() - } - - /// A lock poisoned by a panicking peer recovers the value rather than - /// wedging the process (the workspace's steady-state posture). - pub(crate) fn lock(&self) -> MutexGuard<'_, HashMap>> { - self.live.lock().unwrap_or_else(PoisonError::into_inner) - } - - pub(crate) fn attach(&self, state: Arc) { - self.lock().insert(state.operation(), state); - } - - pub(crate) fn detach(&self, operation: OperationId) { - self.lock().remove(&operation); - tracing::debug!(%operation, "operation detached"); - } - - pub(crate) fn sender(&self) -> broadcast::Sender { - self.events.clone() - } - - /// The live trees, ordered by operation id (attach order). - pub(crate) fn trees(&self) -> Vec> { - let mut trees: Vec> = self.lock().values().cloned().collect(); - trees.sort_by_key(|t| t.operation()); - trees - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn detach_on_drop_removes_the_tree_from_snapshots() { - let hub = Arc::new(ProgressHub::new()); - let tree = hub.operation(); - let _leaf = tree.register("leaf", 1.0); - assert_eq!(hub.snapshot().len(), 1); - drop(tree); - assert!( - hub.snapshot().is_empty(), - "a dropped tree must leave the hub's snapshots" - ); - } - - #[test] - fn handles_outliving_their_tree_do_not_emit() { - let hub = Arc::new(ProgressHub::new()); - let mut rx = hub.subscribe(); - let tree = hub.operation(); - let leaf = tree.register("leaf", 1.0); - assert!(rx.try_recv().is_ok(), "register emits Begun"); - drop(tree); - let terminal = rx.try_recv().expect("tree drop emits operation completion"); - assert!(matches!( - terminal.state, - crate::event::EventState::OperationFinished - )); - leaf.set_fraction(1.0); - assert!( - rx.try_recv().is_err(), - "a detached tree's handles stay silent" - ); - } -} diff --git a/crates/shared-progress/src/lib.rs b/crates/shared-progress/src/lib.rs deleted file mode 100644 index 2404bddc1..000000000 --- a/crates/shared-progress/src/lib.rs +++ /dev/null @@ -1,29 +0,0 @@ -//! Operation-scoped progress reporting for PromptForge processes. -//! -//! The owner of one operation creates a [`ProgressTree`] on the process-wide -//! [`ProgressHub`], registers every leaf up front with a weight proportional -//! to its share of the operation's expected duration, and reports through the -//! [`ProgressHandle`] it gets back. Renderers subscribe to the hub's event -//! stream or pull snapshots; producers never format output. The crate never -//! spawns tasks and never blocks: hosts own their forwarding and renderer -//! tasks. - -mod event; -mod handle; -mod hub; -mod remote; -mod render; -mod tree; - -pub use crate::event::{EventState, OperationId, ProgressEvent}; -pub use crate::handle::ProgressHandle; -pub use crate::hub::ProgressHub; -pub use crate::remote::RemoteOperation; -pub use crate::render::{NodeSnapshot, OperationSnapshot, ProgressMeter}; -pub use crate::tree::ProgressTree; - -const _: () = { - const fn assert_send_sync() {} - assert_send_sync::(); - assert_send_sync::(); -}; diff --git a/crates/shared-progress/src/remote.rs b/crates/shared-progress/src/remote.rs deleted file mode 100644 index ee16dd45c..000000000 --- a/crates/shared-progress/src/remote.rs +++ /dev/null @@ -1,275 +0,0 @@ -//! Import of another process's event stream as a local operation. - -use std::sync::Arc; - -use crate::event::{EventState, OperationId, ProgressEvent}; -use crate::hub::ProgressHub; -use crate::tree::TreeState; - -/// A hub-attached operation whose leaves are driven by [`ProgressEvent`]s -/// arriving from another process. -/// -/// It serves both a long-lived subscription (the gateway's event endpoint) -/// and per-request streams. Applied events are re-broadcast on the local hub -/// under the local operation id, so local subscribers see remote activity -/// without id collisions. Dropping detaches the operation from the hub. -/// -/// # Examples -/// -/// ``` -/// use std::sync::Arc; -/// use shared_progress::{EventState, ProgressEvent, ProgressHub, RemoteOperation}; -/// -/// let hub = Arc::new(ProgressHub::new()); -/// let remote = RemoteOperation::attach(&hub); -/// # let event = ProgressEvent::new( -/// # remote.operation(), -/// # "download", -/// # "download", -/// # EventState::Updated { fraction: 0.5 }, -/// # ); -/// remote.apply(&event); -/// assert_eq!(hub.snapshot()[0].nodes[0].fraction, 0.5); -/// ``` -#[derive(Debug)] -#[non_exhaustive] -#[must_use = "a dropped remote operation detaches from the hub"] -pub struct RemoteOperation { - hub: Arc, - state: Arc, -} - -impl RemoteOperation { - /// Attaches an empty remote operation to `hub` under a fresh local - /// operation id. - pub fn attach(hub: &Arc) -> Self { - let state = Arc::new(TreeState::new(OperationId::next(), hub.sender())); - hub.attach(Arc::clone(&state)); - Self { - hub: Arc::clone(hub), - state, - } - } - - /// The local operation id the import is attached under. - #[must_use] - pub fn operation(&self) -> OperationId { - self.state.operation() - } - - /// Applies one event from the remote stream. - /// - /// Unknown paths create their leaf on the fly, linked under the longest - /// already-known prefix parent: intermediate events are lossy, so a - /// subscriber can see `Updated` before (or without) `Begun`. Fractions - /// arrive already coalesced and are re-broadcast verbatim. A `Begun` - /// weight that is not finite and positive falls back to 1.0, so a - /// poisoned weight off the wire cannot corrupt the aggregate. - /// - /// # Examples - /// - /// ``` - /// use std::sync::Arc; - /// use shared_progress::{EventState, ProgressEvent, ProgressHub, RemoteOperation}; - /// - /// let hub = Arc::new(ProgressHub::new()); - /// let remote = RemoteOperation::attach(&hub); - /// let event = ProgressEvent::new( - /// remote.operation(), - /// "download", - /// "download", - /// EventState::Finished { ok: true }, - /// ); - /// remote.apply(&event); - /// assert_eq!(hub.snapshot()[0].nodes[0].fraction, 1.0); - /// ``` - pub fn apply(&self, event: &ProgressEvent) { - if matches!(event.state, EventState::OperationFinished) { - return; - } - let (slot, node) = self.state.ensure_remote(&event.path, &event.label); - match event.state { - EventState::Begun { weight } => { - let weight = if weight.is_finite() && weight > 0.0 { - weight - } else { - 1.0 - }; - self.state.set_weight(slot, weight); - self.state.emit_begun(&node, weight); - } - EventState::Updated { fraction } => { - self.state.set_fraction_direct(&node, fraction); - } - EventState::Finished { ok } => { - self.state.finish(&node, ok); - } - EventState::OperationFinished => unreachable!("handled before creating a leaf"), - } - } -} - -impl Drop for RemoteOperation { - fn drop(&mut self) { - self.state.finish_operation(); - self.state.retire(); - self.hub.detach(self.state.operation()); - } -} - -#[cfg(test)] -mod tests { - // Fractions are fixed-point millionths, so equality comparisons are exact. - #![expect(clippy::float_cmp, reason = "fixed-point fractions compare exactly")] - - use super::*; - - fn event(operation: OperationId, path: &str, state: EventState) -> ProgressEvent { - ProgressEvent::new(operation, path, path, state) - } - - #[test] - fn apply_drives_the_local_snapshot() { - let hub = Arc::new(ProgressHub::new()); - let remote = RemoteOperation::attach(&hub); - let source = OperationId::next(); - remote.apply(&event( - source, - "op/download", - EventState::Begun { weight: 1.0 }, - )); - remote.apply(&event( - source, - "op/download", - EventState::Updated { fraction: 0.5 }, - )); - let snapshot = hub.snapshot(); - assert_eq!(snapshot.len(), 1); - assert_eq!(snapshot[0].nodes[0].fraction, 0.5); - remote.apply(&event( - source, - "op/download", - EventState::Finished { ok: true }, - )); - assert_eq!(hub.snapshot()[0].nodes[0].fraction, 1.0); - } - - #[test] - fn apply_reconstructs_hierarchy_from_paths() { - let hub = Arc::new(ProgressHub::new()); - let remote = RemoteOperation::attach(&hub); - let source = OperationId::next(); - remote.apply(&event(source, "model", EventState::Begun { weight: 1.0 })); - remote.apply(&event( - source, - "model/download", - EventState::Begun { weight: 3.0 }, - )); - remote.apply(&event( - source, - "model/verify", - EventState::Begun { weight: 1.0 }, - )); - remote.apply(&event( - source, - "model/download", - EventState::Updated { fraction: 1.0 }, - )); - remote.apply(&event( - source, - "model/verify", - EventState::Updated { fraction: 0.5 }, - )); - let snapshot = hub.snapshot(); - let parent = &snapshot[0].nodes[0]; - assert_eq!(parent.path, "model"); - assert_eq!( - parent.fraction, 0.875, - "the parent aggregates its imported children" - ); - } - - #[test] - fn apply_rebroadcasts_under_the_local_operation_id() { - let hub = Arc::new(ProgressHub::new()); - let mut rx = hub.subscribe(); - let remote = RemoteOperation::attach(&hub); - let source = OperationId::next(); - remote.apply(&event( - source, - "download", - EventState::Updated { fraction: 0.5 }, - )); - let seen = rx.try_recv().expect("applied events re-broadcast locally"); - assert_eq!(seen.operation, remote.operation()); - assert!(matches!(seen.state, EventState::Updated { fraction } if fraction == 0.5)); - } - - #[test] - fn failed_finish_preserves_the_fraction() { - let hub = Arc::new(ProgressHub::new()); - let mut rx = hub.subscribe(); - let remote = RemoteOperation::attach(&hub); - let source = OperationId::next(); - remote.apply(&event( - source, - "download", - EventState::Updated { fraction: 0.4 }, - )); - remote.apply(&event( - source, - "download", - EventState::Finished { ok: false }, - )); - assert_eq!( - hub.snapshot()[0].nodes[0].fraction, - 0.4, - "a failed leaf keeps its fraction instead of being forced to 1.0" - ); - let _updated = rx.try_recv().expect("Updated re-broadcasts"); - let seen = rx.try_recv().expect("Finished re-broadcasts"); - assert!( - matches!(seen.state, EventState::Finished { ok: false }), - "the terminal event carries the failure" - ); - } - - #[test] - fn apply_sanitizes_a_poisoned_begun_weight() { - let hub = Arc::new(ProgressHub::new()); - let remote = RemoteOperation::attach(&hub); - let source = OperationId::next(); - for (path, weight) in [ - ("nan", f64::NAN), - ("negative", -3.0), - ("infinite", f64::INFINITY), - ] { - remote.apply(&event(source, path, EventState::Begun { weight })); - } - let snapshot = hub.snapshot(); - for node in &snapshot[0].nodes { - assert_eq!( - node.weight, 1.0, - "a non-finite or non-positive weight off the wire falls back to 1.0: {}", - node.path - ); - } - } - - #[test] - fn drop_detaches_the_remote_operation() { - let hub = Arc::new(ProgressHub::new()); - let remote = RemoteOperation::attach(&hub); - remote.apply(&event( - OperationId::next(), - "download", - EventState::Begun { weight: 1.0 }, - )); - assert_eq!(hub.snapshot().len(), 1); - drop(remote); - assert!( - hub.snapshot().is_empty(), - "a dropped remote operation must leave the hub's snapshots" - ); - } -} diff --git a/crates/shared-progress/src/render.rs b/crates/shared-progress/src/render.rs deleted file mode 100644 index ea8f82270..000000000 --- a/crates/shared-progress/src/render.rs +++ /dev/null @@ -1,221 +0,0 @@ -//! Pull-based snapshots and the never-backwards aggregate for renderers. - -use crate::event::OperationId; -use crate::hub::ProgressHub; - -/// One node's rendering row. -#[derive(Debug, Clone, PartialEq)] -#[non_exhaustive] -pub struct NodeSnapshot { - /// Hierarchical id within the operation. - pub path: String, - /// Human-readable label. - pub label: String, - /// Sibling-relative weight, proportional to expected duration. - pub weight: f64, - /// Current fraction in `0.0..=1.0`; for a parent, the weighted aggregate - /// of its children. - pub fraction: f64, - /// Whether the node emitted its terminal `Finished` event. - pub finished: bool, - /// The terminal event's success flag; meaningful only when `finished`. - pub ok: bool, -} - -/// One live operation's rendering rows. -#[derive(Debug, Clone, PartialEq)] -#[non_exhaustive] -pub struct OperationSnapshot { - /// The operation these rows belong to. - pub operation: OperationId, - /// Rows in registration order; parents precede their children. - pub nodes: Vec, -} - -impl ProgressHub { - /// Snapshots every live operation, ordered by operation id, each with its - /// nodes in registration order. An idle hub snapshots to an empty vec. - /// - /// # Examples - /// - /// ``` - /// use std::sync::Arc; - /// use shared_progress::ProgressHub; - /// - /// let hub = Arc::new(ProgressHub::new()); - /// let tree = hub.operation(); - /// tree.register("download", 1.0); - /// let snapshot = hub.snapshot(); - /// assert_eq!(snapshot[0].nodes[0].label, "download"); - /// ``` - #[must_use] - pub fn snapshot(&self) -> Vec { - self.trees() - .iter() - .map(|tree| OperationSnapshot { - operation: tree.operation(), - nodes: tree.snapshot_rows(), - }) - .collect() - } - - /// Picks the label of the highest-weight unfinished leaf across live - /// trees, for status-bar text. Weights are compared by effective share of - /// their operation. `None` when every leaf is finished or none exist. - #[must_use] - pub fn headline(&self) -> Option { - self.trees() - .iter() - .filter_map(|tree| tree.headline()) - .max_by(|a, b| a.0.total_cmp(&b.0)) - .map(|(_, label)| label) - } -} - -/// A stateful aggregate fraction that never steps backward while operations -/// are live. -/// -/// When a tree attaches mid-run, the naive mean across live trees would -/// dilute completed work; the meter holds a high-water mark instead, so the -/// displayed aggregate is monotonic. The mark resets only when the hub goes -/// idle, where [`sample`](Self::sample) returns `None`. -/// -/// # Examples -/// -/// ``` -/// use std::sync::Arc; -/// use shared_progress::{ProgressHub, ProgressMeter}; -/// -/// let hub = Arc::new(ProgressHub::new()); -/// let mut meter = ProgressMeter::new(); -/// assert_eq!(meter.sample(&hub), None); -/// let tree = hub.operation(); -/// tree.register("leaf", 1.0).set_fraction(0.5); -/// assert_eq!(meter.sample(&hub), Some(0.5)); -/// ``` -#[derive(Debug, Default, Clone)] -#[non_exhaustive] -pub struct ProgressMeter { - high_water: f64, -} - -impl ProgressMeter { - /// Creates a meter at zero. - #[must_use] - pub fn new() -> Self { - Self::default() - } - - /// Samples the hub: `None` when idle (resetting the high-water mark), - /// otherwise the monotonic mean of the live trees' aggregate fractions. - #[must_use] - pub fn sample(&mut self, hub: &ProgressHub) -> Option { - let trees = hub.trees(); - if trees.is_empty() { - self.high_water = 0.0; - return None; - } - #[expect( - clippy::cast_precision_loss, - reason = "live operation counts are far below 2^53" - )] - let raw = trees.iter().map(|t| t.fraction()).sum::() / trees.len() as f64; - self.high_water = self.high_water.max(raw); - Some(self.high_water) - } -} - -#[cfg(test)] -mod tests { - use std::sync::Arc; - - use super::*; - - #[test] - fn snapshot_orders_operations_by_attach_and_nodes_by_registration() { - let hub = Arc::new(ProgressHub::new()); - let first = hub.operation(); - let second = hub.operation(); - let _b = first.register("b-leaf", 1.0); - let _a = first.register("a-leaf", 1.0); - let _c = second.register("c-leaf", 1.0); - let snapshot = hub.snapshot(); - assert_eq!(snapshot.len(), 2); - assert_eq!(snapshot[0].operation, first.operation()); - assert_eq!(snapshot[1].operation, second.operation()); - let labels: Vec<&str> = snapshot[0].nodes.iter().map(|n| n.label.as_str()).collect(); - assert_eq!( - labels, - ["b-leaf", "a-leaf"], - "registration order, not sorted" - ); - } - - #[test] - fn snapshot_carries_the_terminal_state() { - let hub = Arc::new(ProgressHub::new()); - let tree = hub.operation(); - let done = tree.register("done", 1.0); - let failed = tree.register("failed", 1.0); - let _live = tree.register("live", 1.0); - done.complete(); - failed.fail(); - let nodes = &hub.snapshot()[0].nodes; - assert!( - nodes[0].finished && nodes[0].ok, - "a completed node snapshots as finished and ok" - ); - assert!( - nodes[1].finished && !nodes[1].ok, - "a failed node snapshots as finished and not ok" - ); - assert!(!nodes[2].finished, "a live node snapshots as unfinished"); - } - - #[test] - fn headline_picks_the_highest_weight_unfinished_leaf() { - let hub = Arc::new(ProgressHub::new()); - let tree = hub.operation(); - let heavy = tree.register("heavy", 3.0); - let light = tree.register("light", 1.0); - light.set_fraction(0.5); - assert_eq!(hub.headline().as_deref(), Some("heavy")); - heavy.complete(); - assert_eq!(hub.headline().as_deref(), Some("light")); - light.complete(); - assert_eq!(hub.headline(), None); - } - - #[test] - fn aggregate_never_steps_backward_while_operations_are_live() { - let hub = Arc::new(ProgressHub::new()); - let mut meter = ProgressMeter::new(); - let a = hub.operation(); - a.register("a", 1.0).set_fraction(0.8); - assert_eq!(meter.sample(&hub), Some(0.8)); - - // A tree attaching mid-run dilutes the naive mean to 0.4; the - // high-water mark holds the line instead. - let b = hub.operation(); - let lb = b.register("b", 1.0); - let held = meter.sample(&hub).expect("operations are live"); - assert!( - held >= 0.8, - "aggregate must not dilute completed work: {held}" - ); - lb.set_fraction(1.0); - let risen = meter.sample(&hub).expect("operations are live"); - assert!( - risen > 0.8, - "new work still advances the aggregate: {risen}" - ); - - // The mark resets only when the hub goes idle. - drop(a); - drop(b); - assert_eq!(meter.sample(&hub), None); - let c = hub.operation(); - c.register("c", 1.0).set_fraction(0.1); - assert_eq!(meter.sample(&hub), Some(0.1)); - } -} diff --git a/crates/shared-progress/src/tree.rs b/crates/shared-progress/src/tree.rs deleted file mode 100644 index ede1ee5ab..000000000 --- a/crates/shared-progress/src/tree.rs +++ /dev/null @@ -1,529 +0,0 @@ -//! Operation-scoped weighted progress trees. - -use std::collections::HashMap; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; - -use tokio::sync::broadcast; -use tokio::time::Instant; - -use crate::event::{EventState, OperationId, ProgressEvent}; -use crate::handle::ProgressHandle; -use crate::hub::ProgressHub; -use crate::render::NodeSnapshot; - -/// Fixed-point scale for fractions: millionths, so worker-thread reporters -/// take no locks. -pub(crate) const SCALE: u64 = 1_000_000; -const SCALE_F: f64 = 1_000_000.0; - -/// An update is broadcast only when the fraction moved at least 1% or this -/// many milliseconds elapsed since the leaf's last emission. -const COALESCE_STEP: u64 = SCALE / 100; -const COALESCE_MS: u64 = 100; - -#[expect( - clippy::cast_possible_truncation, - clippy::cast_sign_loss, - reason = "the value is clamped to 0..=1_000_000 before the cast" -)] -pub(crate) fn to_fixed(fraction: f64) -> u64 { - if fraction.is_nan() { - return 0; - } - (fraction.clamp(0.0, 1.0) * SCALE_F).round() as u64 -} - -#[expect( - clippy::cast_precision_loss, - reason = "millionths lose nothing a progress display can show" -)] -pub(crate) fn from_fixed(fixed: u64) -> f64 { - fixed as f64 / SCALE_F -} - -/// One node of a tree: a leaf, or a parent whose fraction aggregates its -/// children. The atomics are the hot reporting path; everything else about a -/// node is immutable or lives in the locked [`TreeInner`]. -#[derive(Debug)] -pub(crate) struct Node { - path: String, - label: String, - fraction: AtomicU64, - finished: AtomicBool, - ok: AtomicBool, - last_emit_ms: AtomicU64, - last_emit_fraction: AtomicU64, -} - -impl Node { - fn new(path: String, label: String) -> Self { - Self { - path, - label, - fraction: AtomicU64::new(0), - finished: AtomicBool::new(false), - ok: AtomicBool::new(false), - last_emit_ms: AtomicU64::new(u64::MAX), - last_emit_fraction: AtomicU64::new(0), - } - } - - pub(crate) fn fraction(&self) -> f64 { - from_fixed(self.fraction.load(Ordering::Relaxed)) - } -} - -#[derive(Debug)] -pub(crate) struct NodeSlot { - node: Arc, - parent: Option, - children: Vec, - weight: f64, -} - -#[derive(Debug, Default)] -pub(crate) struct TreeInner { - slots: Vec, - by_path: HashMap, -} - -/// The shared state behind one operation tree. The hub holds one `Arc`, and -/// every handle of the tree holds another, so handles keep reporting (into -/// their own atomics) even after the tree detaches. -#[derive(Debug)] -pub(crate) struct TreeState { - operation: OperationId, - started: Instant, - live: AtomicBool, - inner: Mutex, - events: broadcast::Sender, -} - -impl TreeState { - pub(crate) fn new(operation: OperationId, events: broadcast::Sender) -> Self { - Self { - operation, - started: Instant::now(), - live: AtomicBool::new(true), - inner: Mutex::new(TreeInner::default()), - events, - } - } - - /// A lock poisoned by a panicking peer recovers the value rather than - /// wedging the process (the workspace's steady-state posture). - fn lock(&self) -> MutexGuard<'_, TreeInner> { - self.inner.lock().unwrap_or_else(PoisonError::into_inner) - } - - pub(crate) fn operation(&self) -> OperationId { - self.operation - } - - /// Stops event emission; called before detaching from the hub. - pub(crate) fn retire(&self) { - self.live.store(false, Ordering::Relaxed); - } - - /// Emits the operation-level terminal signal before the tree detaches. - pub(crate) fn finish_operation(&self) { - if !self.live.load(Ordering::Relaxed) { - return; - } - let event = ProgressEvent::operation_finished(self.operation); - tracing::trace!(operation = %self.operation, "progress operation finished"); - // An absent or lagging receiver is not an error. This terminal event - // is never coalesced at the source, like a leaf's Finished event. - let _ = self.events.send(event); - } - - fn emit(&self, node: &Node, state: EventState) { - if !self.live.load(Ordering::Relaxed) { - return; - } - let event = ProgressEvent { - operation: self.operation, - path: node.path.clone(), - label: node.label.clone(), - state, - }; - tracing::trace!(operation = %self.operation, path = %node.path, "progress event"); - // An absent or lagging receiver is not an error: intermediate - // delivery is lossy by design and snapshots carry the ground truth. - let _ = self.events.send(event); - } - - /// Registers a node and emits its `Begun`. Returns the slot index and the - /// shared node the new handle will report through. - pub(crate) fn register( - &self, - parent: Option, - label: &str, - weight: f64, - ) -> (usize, Arc) { - let (slot, node) = { - let mut inner = self.lock(); - let path = match parent { - Some(p) => format!("{}/{label}", inner.slots[p].node.path), - None => label.to_owned(), - }; - let node = Arc::new(Node::new(path.clone(), label.to_owned())); - let slot = inner.slots.len(); - inner.slots.push(NodeSlot { - node: Arc::clone(&node), - parent, - children: Vec::new(), - weight, - }); - if let Some(p) = parent { - inner.slots[p].children.push(slot); - } - inner.by_path.insert(path, slot); - (slot, node) - }; - self.emit(&node, EventState::Begun { weight }); - (slot, node) - } - - /// Finds or creates the node for a remote path, linking it under the - /// longest already-known prefix parent. Remote import tolerates lost - /// `Begun` events, so any event can be the first sight of a path. - pub(crate) fn ensure_remote(&self, path: &str, label: &str) -> (usize, Arc) { - let mut inner = self.lock(); - if let Some(&slot) = inner.by_path.get(path) { - return (slot, Arc::clone(&inner.slots[slot].node)); - } - let parent = path - .rsplit_once('/') - .and_then(|(parent_path, _)| inner.by_path.get(parent_path).copied()); - let node = Arc::new(Node::new(path.to_owned(), label.to_owned())); - let slot = inner.slots.len(); - inner.slots.push(NodeSlot { - node: Arc::clone(&node), - parent, - children: Vec::new(), - weight: 1.0, - }); - if let Some(p) = parent { - inner.slots[p].children.push(slot); - } - inner.by_path.insert(path.to_owned(), slot); - (slot, node) - } - - pub(crate) fn set_weight(&self, slot: usize, weight: f64) { - self.lock().slots[slot].weight = weight; - } - - /// The hot reporting path: atomics plus a coalesced broadcast, no locks. - pub(crate) fn set_fraction(&self, node: &Node, fraction: f64) { - let fixed = to_fixed(fraction); - node.fraction.store(fixed, Ordering::Relaxed); - let moved = - fixed.abs_diff(node.last_emit_fraction.load(Ordering::Relaxed)) >= COALESCE_STEP; - let now_ms = self.elapsed_ms(); - let last_ms = node.last_emit_ms.load(Ordering::Relaxed); - let elapsed = last_ms == u64::MAX || now_ms.saturating_sub(last_ms) >= COALESCE_MS; - if !moved && !elapsed { - return; - } - node.last_emit_fraction.store(fixed, Ordering::Relaxed); - node.last_emit_ms.store(now_ms, Ordering::Relaxed); - self.emit( - node, - EventState::Updated { - fraction: from_fixed(fixed), - }, - ); - } - - /// Re-broadcasts a remote `Begun` under the local operation id. - pub(crate) fn emit_begun(&self, node: &Node, weight: f64) { - self.emit(node, EventState::Begun { weight }); - } - - /// Remote import: fractions arrive already coalesced, so every one is - /// stored and re-broadcast without a second coalescing pass. - pub(crate) fn set_fraction_direct(&self, node: &Node, fraction: f64) { - let fixed = to_fixed(fraction); - node.fraction.store(fixed, Ordering::Relaxed); - node.last_emit_fraction.store(fixed, Ordering::Relaxed); - node.last_emit_ms - .store(self.elapsed_ms(), Ordering::Relaxed); - self.emit( - node, - EventState::Updated { - fraction: from_fixed(fixed), - }, - ); - } - - /// Forces 1.0 and emits the terminal event, which is never coalesced. - pub(crate) fn complete(&self, node: &Node) { - self.finish(node, true); - } - - pub(crate) fn finish(&self, node: &Node, ok: bool) { - // Terminal state is sticky: the first terminal event wins, so an - // error-path `fail` after a successful `complete` cannot double-emit. - if node.finished.swap(true, Ordering::Relaxed) { - return; - } - if ok { - node.fraction.store(SCALE, Ordering::Relaxed); - node.last_emit_fraction.store(SCALE, Ordering::Relaxed); - } - node.ok.store(ok, Ordering::Relaxed); - node.last_emit_ms - .store(self.elapsed_ms(), Ordering::Relaxed); - self.emit(node, EventState::Finished { ok }); - } - - fn elapsed_ms(&self) -> u64 { - u64::try_from(self.started.elapsed().as_millis()).unwrap_or(u64::MAX) - } - - /// The weighted aggregate fraction across the tree's top-level nodes. - pub(crate) fn fraction(&self) -> f64 { - let inner = self.lock(); - let mut wsum = 0.0; - let mut acc = 0.0; - for (i, slot) in inner.slots.iter().enumerate() { - if slot.parent.is_none() { - acc += slot.weight * slot_fraction(&inner, i); - wsum += slot.weight; - } - } - if wsum > 0.0 { acc / wsum } else { 0.0 } - } - - /// Rendering rows in registration order; parents precede their children. - pub(crate) fn snapshot_rows(&self) -> Vec { - let inner = self.lock(); - (0..inner.slots.len()) - .map(|i| { - let slot = &inner.slots[i]; - NodeSnapshot { - path: slot.node.path.clone(), - label: slot.node.label.clone(), - weight: slot.weight, - fraction: slot_fraction(&inner, i), - finished: slot.node.finished.load(Ordering::Relaxed), - ok: slot.node.ok.load(Ordering::Relaxed), - } - }) - .collect() - } - - /// The best status-bar candidate in this tree: the unfinished leaf with - /// the highest effective weight (its share of the whole operation), - /// returned as `(share, label)`. - pub(crate) fn headline(&self) -> Option<(f64, String)> { - let inner = self.lock(); - let root_weight: f64 = inner - .slots - .iter() - .filter(|s| s.parent.is_none()) - .map(|s| s.weight) - .sum(); - if root_weight <= 0.0 { - return None; - } - let mut best = None; - for (i, slot) in inner.slots.iter().enumerate() { - if slot.parent.is_none() { - headline_walk(&inner, i, slot.weight / root_weight, &mut best); - } - } - best - } -} - -fn slot_fraction(inner: &TreeInner, slot: usize) -> f64 { - let s = &inner.slots[slot]; - if s.children.is_empty() { - return from_fixed(s.node.fraction.load(Ordering::Relaxed)); - } - let mut wsum = 0.0; - let mut acc = 0.0; - for &c in &s.children { - let w = inner.slots[c].weight; - acc += w * slot_fraction(inner, c); - wsum += w; - } - if wsum > 0.0 { acc / wsum } else { 0.0 } -} - -fn headline_walk(inner: &TreeInner, slot: usize, share: f64, best: &mut Option<(f64, String)>) { - let s = &inner.slots[slot]; - if s.children.is_empty() { - let fraction = from_fixed(s.node.fraction.load(Ordering::Relaxed)); - if !s.node.finished.load(Ordering::Relaxed) && fraction < 1.0 { - let replace = best.as_ref().is_none_or(|(w, _)| share > *w); - if replace { - *best = Some((share, s.node.label.clone())); - } - } - return; - } - let wsum: f64 = s.children.iter().map(|&c| inner.slots[c].weight).sum(); - if wsum <= 0.0 { - return; - } - for &c in &s.children { - headline_walk(inner, c, share * inner.slots[c].weight / wsum, best); - } -} - -/// An operation-scoped progress tree attached to a [`ProgressHub`]. -/// -/// The owner of one operation creates the tree, registers every leaf up -/// front, runs its own control flow, and reports through the handles. The -/// tree measures; it does not schedule. Dropping the tree detaches it from -/// the hub, so a panicking operation still unregisters. -/// -/// # Examples -/// -/// ``` -/// use std::sync::Arc; -/// use shared_progress::ProgressHub; -/// -/// let hub = Arc::new(ProgressHub::new()); -/// let tree = hub.operation(); -/// let download = tree.register("download", 3.0); -/// let verify = tree.register("verify", 1.0); -/// download.set_fraction(1.0); -/// verify.set_fraction(0.5); -/// assert_eq!(tree.fraction(), 0.875); -/// ``` -#[derive(Debug)] -#[non_exhaustive] -#[must_use = "a dropped tree detaches from the hub immediately"] -pub struct ProgressTree { - hub: Arc, - state: Arc, -} - -impl ProgressTree { - pub(crate) fn new(hub: Arc, state: Arc) -> Self { - Self { hub, state } - } - - /// The tree's operation id within the hub. - /// - /// # Examples - /// - /// ``` - /// use std::sync::Arc; - /// use shared_progress::ProgressHub; - /// - /// let hub = Arc::new(ProgressHub::new()); - /// let tree = hub.operation(); - /// let id = tree.operation(); - /// ``` - #[must_use] - pub fn operation(&self) -> OperationId { - self.state.operation() - } - - /// The weighted aggregate fraction across the tree's top-level leaves, in - /// `0.0..=1.0`. - #[must_use] - pub fn fraction(&self) -> f64 { - self.state.fraction() - } - - /// Registers a top-level leaf and returns its reporting handle. - /// - /// `weight` is the leaf's proportional share of the operation's expected - /// duration: weights track time, not bytes or unit counts. - /// - /// # Examples - /// - /// ``` - /// use std::sync::Arc; - /// use shared_progress::ProgressHub; - /// - /// let hub = Arc::new(ProgressHub::new()); - /// let tree = hub.operation(); - /// let leaf = tree.register("download", 3.0); - /// leaf.set_fraction(0.5); - /// assert_eq!(tree.fraction(), 0.5); - /// ``` - #[must_use] - pub fn register(&self, label: &str, weight: f64) -> ProgressHandle { - let (slot, node) = self.state.register(None, label, weight); - ProgressHandle::new(Arc::clone(&self.state), slot, node) - } -} - -impl Drop for ProgressTree { - fn drop(&mut self) { - self.state.finish_operation(); - self.state.retire(); - self.hub.detach(self.state.operation()); - } -} - -#[cfg(test)] -mod tests { - // Fractions are fixed-point millionths, so equality comparisons are exact. - #![expect(clippy::float_cmp, reason = "fixed-point fractions compare exactly")] - - use super::*; - - #[test] - fn aggregate_is_weighted_by_expected_duration() { - let hub = Arc::new(ProgressHub::new()); - let tree = hub.operation(); - let a = tree.register("a", 1.0); - let b = tree.register("b", 3.0); - a.set_fraction(1.0); - b.set_fraction(0.5); - assert_eq!(tree.fraction(), 0.625); - } - - #[test] - fn children_aggregate_into_their_parent() { - let hub = Arc::new(ProgressHub::new()); - let tree = hub.operation(); - let parent = tree.register("model", 1.0); - let download = parent.child("download", 3.0); - let verify = parent.child("verify", 1.0); - download.set_fraction(1.0); - verify.set_fraction(0.5); - assert_eq!(tree.fraction(), 0.875); - } - - #[test] - fn completion_forces_one_despite_bad_estimates() { - let hub = Arc::new(ProgressHub::new()); - let tree = hub.operation(); - let leaf = tree.register("download", 1.0); - leaf.set_units(3, 10); - assert_eq!(tree.fraction(), 0.3); - leaf.complete(); - assert_eq!(tree.fraction(), 1.0); - } - - #[test] - fn terminal_state_is_sticky() { - let hub = Arc::new(ProgressHub::new()); - let mut rx = hub.subscribe(); - let tree = hub.operation(); - let leaf = tree.register("leaf", 1.0); - assert!(rx.try_recv().is_ok(), "register emits Begun"); - leaf.complete(); - leaf.fail(); - leaf.complete(); - let mut finished = 0; - while let Ok(event) = rx.try_recv() { - if matches!(event.state, EventState::Finished { .. }) { - finished += 1; - } - } - assert_eq!(finished, 1, "later terminal calls are no-ops"); - assert_eq!(leaf.fraction(), 1.0, "the first terminal event wins"); - } -} diff --git a/crates/shared-ui/status-bar.css b/crates/shared-ui/status-bar.css index 771d01002..715931fde 100644 --- a/crates/shared-ui/status-bar.css +++ b/crates/shared-ui/status-bar.css @@ -1,18 +1,17 @@ /* Styles for status-bar.ts, which imports this file; esbuild bundles it into the consuming UI's app.css. Themed values come from the :root - tokens in tokens.css. Carries the bar itself, the text/extras/slot - regions, the slot's inline progress bar, and the generic status LED - primitive (idle lens, lit modifiers, and the pulse/decay transitions). - Rules live in the components layer so a consumer's own rules (the - gateway's fixed positioning) override these. */ + tokens in tokens.css. Carries the bar itself, the text/extras/right + regions, the busy barberpole, and the generic status LED primitive + (idle lens, lit modifiers, and the pulse/decay transitions). Rules + live in the components layer so a consumer's own rules (the gateway's + fixed positioning) override these. */ /* The status bar: a permanent full-width footer below the shell. The left text carries the current label; the extras region holds consumer - controls; the right group holds the slot, which holds the progress bar - or the consumer's LED indicators group (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. */ + controls; the right group holds the barberpole and then the slot with + the consumer's LED indicators group. The barberpole hides while idle + (the hidden attribute); the indicators never hide. min-height rather + than height so a descender never clips against a fixed box. */ @layer components { .status-bar { flex: none; @@ -70,9 +69,9 @@ gap: var(--space-1); } - /* The LED indicators group: swapped out as one unit whenever the - progress bar occupies the slot. The gap is one LED-width so two - LEDs sit one LED-width apart. */ + /* The LED indicators group: always visible, standing beside the + barberpole. The gap is one LED-width so two LEDs sit one LED-width + apart. */ .status-bar__indicators { display: inline-flex; align-items: center; @@ -84,45 +83,61 @@ display: flex; align-items: center; justify-content: flex-end; - min-width: var(--progress-width); } - .status-bar__progress[hidden], - .status-bar__indicators[hidden] { + .status-bar__barberpole[hidden] { display: none; } - /* The inline progress bar: a thin rounded track with a green fill and - a subtle glow. The hosts are Chromium (WebView2, Electron-class), - so the webkit progress pseudoelements are the styled surface. */ - .status-bar__progress { + /* The busy barberpole: a thin rounded track carrying diagonal green + stripes that slide while work is in flight. One fill stripe plus one + track gap spans one period horizontally, and the period is twice the + bar height so each stripe is as wide as the bar is tall. The tile is + a square of twice the period: on a 45-degree gradient a 25% step of + the gradient line is exactly one period across, so the tile holds two + stripe pairs and joins its neighbors without a seam. The animation + slides the tile one period per loop, which lands on an identical + frame, so the loop is seamless too. */ + .status-bar__barberpole { + --barberpole-period: calc(2 * var(--progress-height)); + flex: none; width: var(--progress-width); height: var(--progress-height); - appearance: none; - border: none; border-radius: calc(var(--progress-height) / 2); - background: var(--progress-track); overflow: hidden; + background-color: var(--progress-track); + background-image: repeating-linear-gradient( + -45deg, + var(--progress-fill) 0 12.5%, + transparent 12.5% 25% + ); + background-size: calc(2 * var(--barberpole-period)) calc(2 * var(--barberpole-period)); + box-shadow: 0 0 var(--progress-glow) var(--progress-fill); + animation: status-bar-barberpole 0.8s linear infinite; } - .status-bar__progress::-webkit-progress-bar { - background: var(--progress-track); - border-radius: calc(var(--progress-height) / 2); + @keyframes status-bar-barberpole { + from { + background-position: 0 0; + } + to { + background-position: var(--barberpole-period) 0; + } } - .status-bar__progress::-webkit-progress-value { - background: var(--progress-fill); - border-radius: calc(var(--progress-height) / 2); - box-shadow: 0 0 var(--progress-glow) var(--progress-fill); + /* Reduced motion: the stripes stay, still. */ + @media (prefers-reduced-motion: reduce) { + .status-bar__barberpole { + animation: none; + } } /* The status LEDs: small circles standing in the indicators group - whenever no progress reading occupies the slot. Idle is an unlit - lens - a dark translucent disc with a subtle inner highlight. A - pulse adds a lit modifier: a bright radial-gradient core with a - layered box-shadow bloom. The idle rule's transition is the slow - ease-out decay; each modifier's own transition makes the fade-in - fast. */ + beside the barberpole. Idle is an unlit lens - a dark translucent + disc with a subtle inner highlight. A pulse adds a lit modifier: a + bright radial-gradient core with a layered box-shadow bloom. The + idle rule's transition is the slow ease-out decay; each modifier's + own transition makes the fade-in fast. */ .status-bar__led { width: var(--led-size); height: var(--led-size); diff --git a/crates/shared-ui/status-bar.ts b/crates/shared-ui/status-bar.ts index b4f708982..427fe7451 100644 --- a/crates/shared-ui/status-bar.ts +++ b/crates/shared-ui/status-bar.ts @@ -1,21 +1,17 @@ // The status bar shell shared by both UIs: a permanent full-width footer -// with a text region on the left and a fixed-width slot on the right that -// holds either the inline progress bar or the indicators group - never -// both. Each UI populates the indicators group with its own LEDs (the -// workshop: recording + activity; the gateway: per-endpoint capability) -// and the extras region with its own controls (the gateway: the model -// summary, the pending-queue count, and the cancel buttons). The shell -// owns no timers, listeners, or polling; the consumer drives it through -// setText and renderSlot and owns every lifecycle. +// with a text region on the left and, on the right, a barberpole beside +// the indicators group. The barberpole is an indeterminate busy signal: +// it shows while work is in flight and hides otherwise, and it never +// displaces the indicators - the LEDs stay visible either way. Each UI +// populates the indicators group with its own LEDs (the workshop: +// recording + activity; the gateway: per-endpoint capability) and the +// extras region with its own controls (the gateway: the model summary, +// the pending-queue count, and the cancel buttons). The shell owns no +// timers, listeners, or polling; the consumer drives it through setText +// and setBusy and owns every lifecycle. import "./status-bar.css"; -/** One progress reading for the slot's bar. */ -export interface SlotProgress { - readonly current: number; - readonly total: number; -} - /** Options for {@link StatusBarShell.setText}. */ export interface StatusBarText { /** Paint the text in the error color. */ @@ -30,22 +26,20 @@ export interface StatusBarShell { readonly element: HTMLElement; /** The left text region. */ readonly text: HTMLElement; - /** The slot's progress bar. */ - readonly progress: HTMLProgressElement; - /** The slot's indicators group; the consumer fills it with its LEDs. */ + /** The animated busy barberpole; hidden while idle. */ + readonly barberpole: HTMLElement; + /** The indicators group; the consumer fills it with its LEDs. */ readonly indicators: HTMLElement; - /** The region between the text and the slot for consumer controls. */ + /** The region between the text and the right group for consumer controls. */ readonly extras: HTMLElement; /** Sets the left text, its error styling, and the bar tooltip. */ setText(label: string, options?: StatusBarText): void; /** - * Swaps the slot between the progress bar and the indicators group. - * Progress wins: a reading shows the bar and hides the group; null - * restores the group. The swap rides the `hidden` attribute and never - * touches the indicators' contents, so a live LED reappears lit; the - * slot's fixed width keeps the bar from reflowing. + * Shows or hides the barberpole. The toggle rides the `hidden` + * attribute on the barberpole alone and never touches the indicators + * group or its contents, so a live LED keeps glowing beside it. */ - renderSlot(progress: SlotProgress | null): void; + setBusy(busy: boolean): void; } /** Creates the status bar shell. */ @@ -63,24 +57,26 @@ export function createStatusBarShell(): StatusBarShell { const right = document.createElement("span"); right.className = "status-bar__right"; + // An indeterminate progressbar: role without aria-valuenow tells + // assistive tech that work is in flight with no known fraction. + const barberpole = document.createElement("span"); + barberpole.className = "status-bar__barberpole"; + barberpole.setAttribute("role", "progressbar"); + barberpole.setAttribute("aria-label", "Busy"); + barberpole.hidden = true; const slot = document.createElement("span"); slot.className = "status-bar__slot"; - const progress = document.createElement("progress"); - progress.className = "status-bar__progress"; - progress.value = 0; - progress.max = 100; - progress.setAttribute("aria-label", "Task progress"); - progress.hidden = true; const indicators = document.createElement("span"); indicators.className = "status-bar__indicators"; - slot.append(progress, indicators); - right.append(slot); + slot.append(indicators); + // The barberpole sits immediately before the indicators group. + right.append(barberpole, slot); element.append(text, extras, right); return { element, text, - progress, + barberpole, indicators, extras, setText(label: string, options?: StatusBarText): void { @@ -88,17 +84,8 @@ export function createStatusBarShell(): StatusBarShell { element.title = options?.tooltip ?? ""; text.classList.toggle("status-bar__text--error", options?.error === true); }, - renderSlot(value: SlotProgress | null): void { - if (value) { - // A zero total is degenerate; clamp so value/max stay valid. - progress.max = value.total > 0 ? value.total : 1; - progress.value = value.current; - progress.hidden = false; - indicators.hidden = true; - } else { - progress.hidden = true; - indicators.hidden = false; - } + setBusy(busy: boolean): void { + barberpole.hidden = !busy; }, }; } diff --git a/crates/shared-ui/tokens.css b/crates/shared-ui/tokens.css index d55e1cc75..2df225e56 100644 --- a/crates/shared-ui/tokens.css +++ b/crates/shared-ui/tokens.css @@ -222,12 +222,13 @@ --status-bar-padding-inline: 7px; --status-bar-gap: 3px; - /* Status bar progress bar */ - --progress-width: 96px; - --progress-height: 6px; - --progress-fill: #3FA266; - --progress-track: #F0F0F011; - --progress-glow: 4px; /* blur radius of the fill's box-shadow glow */ + /* Status bar barberpole (the fill and track also color the block + progress bar in progress.css) */ + --progress-width: 144px; /* 1.5in at CSS 96dpi; px so it composes with sibling px tokens */ + --progress-height: 6px; /* also sets the stripe width: one stripe is one bar height */ + --progress-fill: #3FA266; /* the sliding stripes */ + --progress-track: #F0F0F011; /* the gaps between stripes */ + --progress-glow: 4px; /* blur radius of the barberpole's box-shadow glow */ /* Status bar activity LED */ --led-size: 10px; diff --git a/crates/shared-vfs/src/grep.rs b/crates/shared-vfs/src/grep.rs new file mode 100644 index 000000000..c0cfd4de2 --- /dev/null +++ b/crates/shared-vfs/src/grep.rs @@ -0,0 +1,44 @@ +//! The grep exchange with backends: one request against the namespace and +//! the hits it returns. + +use crate::path::VfsPathBuf; + +/// One grep request against the namespace. +#[non_exhaustive] +#[derive(Debug, Clone)] +pub struct GrepQuery { + /// The text or pattern to search for. + pub pattern: String, + /// The directory the search is rooted at. + pub root: VfsPathBuf, + /// Whether `pattern` is a regular expression. + pub is_regex: bool, + /// Whether matching ignores case. + pub case_insensitive: bool, + /// An optional glob restricting which files are searched. + pub glob_filter: Option, + /// An optional cap on returned matches. + pub max_results: Option, +} + +/// One grep hit. +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GrepMatch { + /// The path of the file containing the hit. + pub path: String, + /// The 1-based line number of the hit. + pub line_number: usize, + /// The full text of the matching line. + pub line: String, +} + +/// The outcome of one grep request. +#[non_exhaustive] +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct GrepResults { + /// The hits, in backend order. + pub matches: Vec, + /// Whether `max_results` cut the result set short. + pub truncated: bool, +} diff --git a/crates/shared-vfs/src/handle.rs b/crates/shared-vfs/src/handle.rs index 50d199f02..783e086e6 100644 --- a/crates/shared-vfs/src/handle.rs +++ b/crates/shared-vfs/src/handle.rs @@ -16,11 +16,12 @@ use std::fmt; use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; use crate::error::VfsError; +use crate::grep::{GrepQuery, GrepResults}; use crate::observe::{OpEvent, OpSink, Origin}; use crate::path::{VfsPath, canonicalize}; use crate::router::{Mounts, Router, VfsRefBuilder}; +use crate::stat::{Entry, Stat}; use crate::traits::{AllowAll, ExecId, Op, Policy, Verdict, Vfs, VfsAccess}; -use crate::types::{Entry, GrepQuery, GrepResults, Stat}; /// Whether an operation claims read or write intent on its path. #[derive(Clone, Copy, PartialEq, Eq)] @@ -178,6 +179,7 @@ impl fmt::Debug for VfsRef { impl VfsRef { /// Returns a handle over `backend` with the [`AllowAll`] policy. + #[must_use] pub fn new(backend: impl Vfs + 'static) -> VfsRef { Self::with_policy(backend, AllowAll) } @@ -186,6 +188,7 @@ impl VfsRef { /// operation. The policy is dynamic through shared state: the host /// holds the same `Arc` and changes behavior mid-run, and the next /// operation sees it. + #[must_use] pub fn with_policy( backend: impl Vfs + 'static, policy: impl Policy + Sync + 'static, @@ -374,8 +377,9 @@ impl Access { /// Returns an error when the file's contents are not UTF-8. pub fn read_string(&self, path: &str) -> Result { let bytes = self.read(path)?; - String::from_utf8(bytes) - .map_err(|_| VfsError::Backend(format!("read_string requires UTF-8 text: {path}"))) + String::from_utf8(bytes).map_err(|source| { + VfsError::Backend(format!("read_string requires UTF-8 text: {path}: {source}")) + }) } /// Reads lines `start..=end` of the file at `path`, 1-based and @@ -764,8 +768,8 @@ mod tests { use crate::error::VfsError; use crate::observe::{OpEvent, Origin}; use crate::path::VfsPath; + use crate::stat::{Entry, Stat}; use crate::traits::{ExecId, Op, Policy, Verdict, Vfs, VfsAccess}; - use crate::types::{Entry, Stat}; /// Minimal in-memory backend shared between the `Vfs` and the access /// objects it vends. Releases are recorded so tests can observe the diff --git a/crates/shared-vfs/src/host.rs b/crates/shared-vfs/src/host.rs index 27af5aace..c419d84f3 100644 --- a/crates/shared-vfs/src/host.rs +++ b/crates/shared-vfs/src/host.rs @@ -23,8 +23,8 @@ use std::sync::atomic::{AtomicU64, Ordering}; use crate::error::VfsError; use crate::glob::{MAX_GLOB_PATTERN_BYTES, compile_glob, matches_tokens, validate_glob_grammar}; use crate::path::{VfsPath, canonicalize}; +use crate::stat::{Entry, FileType, Stat}; use crate::traits::{ExecId, Vfs, VfsAccess}; -use crate::types::{Entry, FileType, Stat}; /// Maps an I/O failure to the error kind the trait surface promises. fn map_io(path: &str, err: &std::io::Error) -> VfsError { @@ -609,8 +609,8 @@ mod tests { use super::{HostBackend, identity_to_virtual, map_io}; use crate::error::VfsError; use crate::path::{VfsPath, canonicalize}; + use crate::stat::FileType; use crate::traits::{ExecId, Vfs, VfsAccess}; - use crate::types::FileType; fn path(s: &str) -> Result { canonicalize(s) diff --git a/crates/shared-vfs/src/lib.rs b/crates/shared-vfs/src/lib.rs index afeb977b7..616ec7810 100644 --- a/crates/shared-vfs/src/lib.rs +++ b/crates/shared-vfs/src/lib.rs @@ -8,24 +8,26 @@ mod error; mod glob; +mod grep; mod handle; mod host; mod memory; mod observe; mod path; mod router; +mod stat; mod traits; -mod types; pub use error::VfsError; +pub use grep::{GrepMatch, GrepQuery, GrepResults}; pub use handle::{Access, VfsRef}; pub use host::HostBackend; pub use memory::MemoryBackend; pub use observe::{OpEvent, OpSink, Origin}; pub use path::{VfsPath, VfsPathBuf}; pub use router::VfsRefBuilder; +pub use stat::{Entry, FileType, Stat}; pub use traits::{AllowAll, ExecId, Op, Policy, Verdict, Vfs, VfsAccess}; -pub use types::{Entry, FileType, GrepMatch, GrepQuery, GrepResults, Stat}; #[cfg(test)] mod tests { diff --git a/crates/shared-vfs/src/memory.rs b/crates/shared-vfs/src/memory.rs index 7a080a391..7f9948e40 100644 --- a/crates/shared-vfs/src/memory.rs +++ b/crates/shared-vfs/src/memory.rs @@ -14,8 +14,8 @@ use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; use crate::error::VfsError; use crate::glob::{MAX_GLOB_PATTERN_BYTES, compile_glob, matches_tokens, validate_glob_grammar}; use crate::path::VfsPath; +use crate::stat::{Entry, FileType, Stat}; use crate::traits::{ExecId, Vfs, VfsAccess}; -use crate::types::{Entry, FileType, Stat}; /// The storage one backend shares with every session it vends. `BTreeMap` /// and `BTreeSet` keep listing and glob results ordered without a sort @@ -435,8 +435,8 @@ mod tests { use super::MemoryBackend; use crate::error::VfsError; use crate::path::{VfsPath, canonicalize}; + use crate::stat::FileType; use crate::traits::{ExecId, Vfs, VfsAccess}; - use crate::types::FileType; fn path(s: &str) -> Result { canonicalize(s) diff --git a/crates/shared-vfs/src/router.rs b/crates/shared-vfs/src/router.rs index 95c5086e8..9aa585c81 100644 --- a/crates/shared-vfs/src/router.rs +++ b/crates/shared-vfs/src/router.rs @@ -14,11 +14,12 @@ use std::fmt; use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; use crate::error::VfsError; +use crate::grep::{GrepQuery, GrepResults}; use crate::handle::VfsRef; use crate::observe::{OpEvent, OpSink}; use crate::path::{VfsPath, VfsPathBuf, canonicalize}; +use crate::stat::{Entry, Stat}; use crate::traits::{AllowAll, ExecId, Policy, Vfs, VfsAccess}; -use crate::types::{Entry, GrepQuery, GrepResults, Stat}; /// One mounted backend behind a shared lock. type Mounted = Arc>>; @@ -412,8 +413,8 @@ mod tests { use crate::handle::VfsRef; use crate::observe::Origin; use crate::path::VfsPath; + use crate::stat::{Entry, Stat}; use crate::traits::{ExecId, Vfs, VfsAccess}; - use crate::types::{Entry, Stat}; /// A recording in-memory stub. Files are keyed by the exact paths /// the backend is handed, so tests observe prefix stripping diff --git a/crates/shared-vfs/src/types.rs b/crates/shared-vfs/src/stat.rs similarity index 58% rename from crates/shared-vfs/src/types.rs rename to crates/shared-vfs/src/stat.rs index 06a93a578..dbabbdee8 100644 --- a/crates/shared-vfs/src/types.rs +++ b/crates/shared-vfs/src/stat.rs @@ -1,9 +1,8 @@ -//! Value types exchanged with backends: entries, metadata, and grep. +//! Node metadata reported by backends: the file kind, its stat record, +//! and the directory entry that pairs a name with one. use std::time::SystemTime; -use crate::path::VfsPathBuf; - /// The seven POSIX kinds, named rather than lumped: a virtual `/dev/null` /// (char device) is a plausible backend, and an `Other` kind would hide it. /// The engine adapter maps the first four directly and the three specials @@ -62,43 +61,3 @@ pub struct Entry { /// Optional annotation shown beside the entry. pub description: Option, } - -/// One grep request against the namespace. -#[non_exhaustive] -#[derive(Debug, Clone)] -pub struct GrepQuery { - /// The text or pattern to search for. - pub pattern: String, - /// The directory the search is rooted at. - pub root: VfsPathBuf, - /// Whether `pattern` is a regular expression. - pub is_regex: bool, - /// Whether matching ignores case. - pub case_insensitive: bool, - /// An optional glob restricting which files are searched. - pub glob_filter: Option, - /// An optional cap on returned matches. - pub max_results: Option, -} - -/// One grep hit. -#[non_exhaustive] -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct GrepMatch { - /// The path of the file containing the hit. - pub path: String, - /// The 1-based line number of the hit. - pub line_number: usize, - /// The full text of the matching line. - pub line: String, -} - -/// The outcome of one grep request. -#[non_exhaustive] -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct GrepResults { - /// The hits, in backend order. - pub matches: Vec, - /// Whether `max_results` cut the result set short. - pub truncated: bool, -} diff --git a/crates/shared-vfs/src/traits.rs b/crates/shared-vfs/src/traits.rs index 77ad05219..9c63234eb 100644 --- a/crates/shared-vfs/src/traits.rs +++ b/crates/shared-vfs/src/traits.rs @@ -8,8 +8,9 @@ use std::sync::atomic::{AtomicU64, Ordering}; use crate::error::VfsError; +use crate::grep::{GrepMatch, GrepQuery, GrepResults}; use crate::path::{VfsPath, VfsPathBuf, canonicalize}; -use crate::types::{Entry, GrepMatch, GrepQuery, GrepResults, Stat}; +use crate::stat::{Entry, Stat}; /// Identity of one serial thread of execution. Process-unique, vended /// from a process-global monotonic counter. Opaque: no public constructor - @@ -190,8 +191,9 @@ pub trait VfsAccess: Send { /// count is not exactly one, or when the read or write fails. fn str_replace(&mut self, path: &VfsPath, old: &str, new: &str) -> Result<(), VfsError> { let bytes = self.read(path)?; - let text = String::from_utf8(bytes) - .map_err(|_| VfsError::Backend(format!("str_replace requires UTF-8 text: {path}")))?; + let text = String::from_utf8(bytes).map_err(|source| { + VfsError::Backend(format!("str_replace requires UTF-8 text: {path}: {source}")) + })?; let count = text.matches(old).count(); if count == 0 { return Err(VfsError::Backend(format!( @@ -387,8 +389,9 @@ mod tests { use super::{AllowAll, Op, Policy, Verdict, VfsAccess}; use crate::error::VfsError; + use crate::grep::{GrepQuery, GrepResults}; use crate::path::{VfsPath, canonicalize}; - use crate::types::{Entry, GrepQuery, GrepResults, Stat}; + use crate::stat::{Entry, Stat}; /// Minimal in-memory backend exercising the trait defaults: the /// required methods are direct map operations, and glob understands diff --git a/crates/workshop/README.md b/crates/workshop/README.md index 15b721a83..16bcfcd12 100644 --- a/crates/workshop/README.md +++ b/crates/workshop/README.md @@ -8,7 +8,7 @@ The desktop app (at `shell/`): hosts the workshop server in-process and opens th ## workshop-server -The workshop HTTP server: serves the workshop API to the desktop shell, loopback-only, with the embedded SPA. The shell hosts it in-process, and it composes every subsystem through the registry. It also holds the sessions subsystem itself: the `/ws` workbench socket, the `/agents/ws` agent-session socket, and the `/v1/models` catalog relay, with agent sessions run in the harness through `harness-api` (the shell constructs the `Harness` at boot, registers it, and pushes the gateway binding, chat catalog, and host snapshot into it as data). Depends on all eight sibling subsystems plus harness-api, promptforge-api-types, shared-loopback, shared-progress, and gateway-api-discovery; build-ui is its build dependency. +The workshop HTTP server: serves the workshop API to the desktop shell, loopback-only, with the embedded SPA. The shell hosts it in-process, and it composes every subsystem through the registry. It also holds the sessions subsystem itself: the `/ws` workbench socket, the `/agents/ws` agent-session socket, and the `/v1/models` catalog relay, with agent sessions run in the harness through `harness-api` (the shell constructs the `Harness` at boot, registers it, and pushes the gateway binding, chat catalog, and host snapshot into it as data). Depends on all eight sibling subsystems plus harness-api, promptforge-api-types, shared-loopback, and gateway-api-discovery; build-ui is its build dependency. ## workshop-server-api @@ -16,7 +16,7 @@ The shell's view of the server: re-exports only, so server internals never resol ## workshop-gateway -The gateway client: bearer-auth HTTP, endpoint binding and discovery, heartbeat, the progress subscriber, and the run event log. The server's subsystems reach the gateway through it. Depends on workshop-protocol, workshop-registry, workshop-support, promptforge-api-types, shared-progress, and gateway-api-discovery. +The gateway client: bearer-auth HTTP, endpoint binding and discovery, heartbeat, the progress subscriber (which decodes the gateway's `Progress` snapshots and drives the status bar's busy frames), and the run event log. The server's subsystems reach the gateway through it. Depends on workshop-protocol, workshop-registry, workshop-support, promptforge-api-types, gateway-api-types, and gateway-api-discovery. ## workshop-menu @@ -32,7 +32,7 @@ The sealed proxy slots subsystems self-register into, so the composition root ne ## workshop-status -The status-bar broadcast bus and the progress renderer driven by the process progress hub. The server mounts it as the status subsystem. Depends on workshop-protocol, workshop-registry, workshop-support, and shared-progress. +The status-bar broadcast bus. The server mounts it as the status subsystem. Depends on workshop-protocol, workshop-registry, and workshop-support. ## workshop-support diff --git a/crates/workshop/gateway/Cargo.toml b/crates/workshop/gateway/Cargo.toml index 2395dd92f..863c20194 100644 --- a/crates/workshop/gateway/Cargo.toml +++ b/crates/workshop/gateway/Cargo.toml @@ -14,12 +14,13 @@ test-fixtures = ["dep:tempfile"] [dependencies] arc-swap.workspace = true futures-util.workspace = true +# The gateway's public wire vocabulary: the progress snapshot the +# `GET /admin/progress` stream carries. +gateway-api-types.workspace = true promptforge-api-types.workspace = true reqwest.workspace = true serde.workspace = true serde_json.workspace = true -# The serde feature decodes the gateway's progress event stream. -shared-progress = { workspace = true, features = ["serde"] } gateway-api-discovery.workspace = true thiserror.workspace = true tokio.workspace = true diff --git a/crates/workshop/gateway/src/gateway.rs b/crates/workshop/gateway/src/gateway.rs index 05a3aa4ae..11750ec47 100644 --- a/crates/workshop/gateway/src/gateway.rs +++ b/crates/workshop/gateway/src/gateway.rs @@ -19,7 +19,7 @@ pub use events::{ CacheEvent, CacheResponse, ForwardedResponse, GatewayResponse, SsePayloadStream, SwitchOutcome, SwitchResponse, }; -pub use progress::ProgressEventStream; +pub use progress::ProgressStream; pub use socket::GatewayRealtimeSocket; use sse::{is_event_stream, payload_stream, read}; @@ -406,7 +406,7 @@ impl GatewayClient { /// [`GatewayError::Status`] on a non-success status, and /// [`GatewayError::ReadBody`] when that answer's body cannot be /// read. - pub async fn subscribe_progress(&self) -> Result { + pub async fn subscribe_progress(&self) -> Result { let request = self.authorize(self.http.get(format!("{}/admin/progress", self.base_url))); let response = self.send_bounded(request).await?; progress::subscribe(response).await diff --git a/crates/workshop/gateway/src/gateway/progress.rs b/crates/workshop/gateway/src/gateway/progress.rs index 79824aa53..9b0b5d03f 100644 --- a/crates/workshop/gateway/src/gateway/progress.rs +++ b/crates/workshop/gateway/src/gateway/progress.rs @@ -1,18 +1,19 @@ //! The `GET /admin/progress` subscription: a long-lived SSE stream of -//! [`ProgressEvent`]s, decoded block-by-block under a hard size bound. +//! [`Progress`] snapshots, decoded block-by-block under a hard size +//! bound. //! //! Unlike the switch and cache streams, a progress subscription never -//! terminates on its own and carries events the workshop imports into -//! the progress hub verbatim, so the decode keeps the stricter posture -//! the subscriber always had: only blank-line-terminated blocks -//! dispatch (an incomplete trailing block is discarded), and a block -//! that grows past `MAX_EVENT_BLOCK` without its terminator is -//! refused rather than buffered unbounded. +//! terminates on its own and carries snapshots the workshop renders +//! verbatim, so the decode keeps the stricter posture the subscriber +//! always had: only blank-line-terminated blocks dispatch (an incomplete +//! trailing block is discarded), and a block that grows past +//! `MAX_EVENT_BLOCK` without its terminator is refused rather than +//! buffered unbounded. use std::pin::Pin; use futures_util::Stream; -use shared_progress::ProgressEvent; +use gateway_api_types::Progress; use super::GatewayError; @@ -25,25 +26,23 @@ pub(crate) const MAX_EVENT_BLOCK: usize = 1024 * 1024; /// The largest error body kept for a subscription diagnostic, in bytes. const MAX_ERROR_BODY: usize = 2000; -/// A stream of decoded [`ProgressEvent`]s from the gateway, in arrival -/// order. +/// A stream of decoded [`Progress`] snapshots from the gateway, in +/// arrival order. /// /// A `data:` block that does not decode is yielded as one error item /// without ending the stream; a read failure or an event block oversized /// beyond `MAX_EVENT_BLOCK` is yielded as one error item that ends the /// stream. The stream ends when the gateway closes the body; whether to /// resubscribe is the caller's decision. -pub type ProgressEventStream = - Pin> + Send>>; +pub type ProgressStream = Pin> + Send>>; -/// Turns an answered `GET /admin/progress` request into the event stream. +/// Turns an answered `GET /admin/progress` request into the snapshot +/// stream. /// /// The endpoint answers only an event stream on success, so a /// non-success status is [`GatewayError::Status`] carrying a bounded, /// control-escaped body rather than a relayed response. -pub(super) async fn subscribe( - response: reqwest::Response, -) -> Result { +pub(super) async fn subscribe(response: reqwest::Response) -> Result { let status = response.status(); if !status.is_success() { return Err(GatewayError::Status { @@ -87,7 +86,7 @@ async fn error_body(mut response: reqwest::Response) -> Result Result ProgressEventStream { +fn decode(response: reqwest::Response) -> ProgressStream { let events = futures_util::stream::unfold( (response, Vec::new(), false), |(mut response, mut buffer, mut failed)| async move { @@ -137,10 +136,10 @@ fn decode(response: reqwest::Response) -> ProgressEventStream { Box::pin(events) } -/// Pops the next decodable event out of `buffer`, or `None` when no +/// Pops the next decodable snapshot out of `buffer`, or `None` when no /// complete block is buffered yet. Comment-only blocks (heartbeats) are /// consumed and skipped. -fn next_buffered_event(buffer: &mut Vec) -> Option> { +fn next_buffered_event(buffer: &mut Vec) -> Option> { loop { let end = block_end(buffer)?; let block: Vec = buffer.drain(..end).collect(); @@ -167,7 +166,7 @@ fn block_end(buffer: &[u8]) -> Option { /// Decodes one SSE event block: `data:` lines join into the payload, /// comment lines and unrecognized fields are ignored, and a block with /// no payload (a heartbeat) yields `None`. -fn parse_event_block(block: &[u8]) -> Option> { +fn parse_event_block(block: &[u8]) -> Option> { let mut data: Vec = Vec::new(); for line in block.split(|byte| *byte == b'\n') { let line = line.strip_suffix(b"\r").unwrap_or(line); @@ -183,7 +182,7 @@ fn parse_event_block(block: &[u8]) -> Option } Some( serde_json::from_slice(&data).map_err(|source| GatewayError::Malformed { - message: "progress event was not valid JSON".to_owned(), + message: "progress snapshot was not a valid {busy, text} JSON object".to_owned(), source: Some(Box::new(source)), }), ) diff --git a/crates/workshop/gateway/src/gateway/tests/progress.rs b/crates/workshop/gateway/src/gateway/tests/progress.rs index 3e0b8fec2..5554ada9f 100644 --- a/crates/workshop/gateway/src/gateway/tests/progress.rs +++ b/crates/workshop/gateway/src/gateway/tests/progress.rs @@ -8,21 +8,21 @@ use super::*; use futures_util::StreamExt as _; -use shared_progress::{EventState, ProgressEvent}; +use gateway_api_types::Progress; use crate::gateway::progress::MAX_EVENT_BLOCK; -/// Serializes a wire-format progress event by hand, so the tests pin -/// the JSON shape the gateway emits rather than the progress crate's -/// constructors. -fn event_json(state: &serde_json::Value) -> String { - serde_json::json!({ - "operation": 7, - "path": "local-models/ggml/download", - "label": "Download", - "state": state, - }) - .to_string() +/// Serializes a wire-format progress snapshot by hand, so the tests pin +/// the JSON shape the gateway emits rather than the types crate's +/// serializer. +fn snapshot_json(busy: bool, text: &str) -> String { + serde_json::json!({"busy": busy, "text": text}).to_string() +} + +/// The decoded snapshot as a `(busy, text)` pair. +fn decoded(item: &Result) -> (bool, String) { + let snapshot = item.as_ref().expect("every item decodes"); + (snapshot.busy, snapshot.text.clone()) } /// A mock `GET /admin/progress` that requires the bearer token and @@ -54,7 +54,7 @@ fn mock_progress(body: String) -> axum::Router { } /// Subscribes against `app` and collects the whole event stream. -async fn collect_events(app: axum::Router) -> Vec> { +async fn collect_events(app: axum::Router) -> Vec> { let base_url = serve(app).await; let client = GatewayClient::new(&base_url, "tok").expect("client builds in tests"); let events = client @@ -65,27 +65,50 @@ async fn collect_events(app: axum::Router) -> Vec = events - .iter() - .map(|item| item.as_ref().expect("every item decodes").state) - .collect(); + let snapshots: Vec<(bool, String)> = events.iter().map(decoded).collect(); assert_eq!( - states, + snapshots, vec![ - EventState::Begun { weight: 2.0 }, - EventState::Updated { fraction: 0.5 }, - EventState::Finished { ok: true }, - ] + (true, "Downloading qwen3-8b.gguf".to_owned()), + (true, "Downloading qwen3-8b.gguf 45%".to_owned()), + (false, String::new()), + ], + "each data block decodes to one busy/text snapshot in arrival order" + ); +} + +#[tokio::test] +async fn subscribe_progress_refuses_the_old_operation_event_shape_as_malformed() { + // A gateway still emitting the retired weighted-tree events is a + // version skew the decoder reports, never renders. + let stale = serde_json::json!({ + "operation": 7, + "path": "local-models/ggml/download", + "label": "Download", + "state": {"Updated": {"fraction": 0.5}}, + }) + .to_string(); + let body = format!("data: {stale}\n\n"); + + let events = collect_events(mock_progress(body)).await; + + assert_eq!(events.len(), 1, "one item per data block"); + let error = events[0] + .as_ref() + .expect_err("a payload without busy and text is not a snapshot"); + assert!( + matches!(error, GatewayError::Malformed { .. }), + "the stale shape is a malformed error, got {error}" ); } @@ -106,8 +129,8 @@ async fn subscribe_progress_classifies_a_non_success_status() { #[tokio::test] async fn subscribe_progress_yields_one_error_per_bad_event_and_continues() { - let begun = event_json(&serde_json::json!({"Begun": {"weight": 1.0}})); - let finished = event_json(&serde_json::json!({"Finished": {"ok": true}})); + let begun = snapshot_json(true, "Loading profile"); + let finished = snapshot_json(false, ""); let body = format!("data: {begun}\n\ndata: {{not json\n\ndata: {finished}\n\n"); let events = collect_events(mock_progress(body)).await; @@ -129,7 +152,7 @@ async fn subscribe_progress_yields_one_error_per_bad_event_and_continues() { #[tokio::test] async fn subscribe_progress_reassembles_an_event_split_across_chunks() { - let begun = event_json(&serde_json::json!({"Begun": {"weight": 1.0}})); + let begun = snapshot_json(true, "Loading profile"); let wire = format!("data: {begun}\n\n"); let (head, tail) = wire.split_at(wire.len() / 2); let (head, tail) = (head.to_owned(), tail.to_owned()); @@ -162,7 +185,7 @@ async fn subscribe_progress_yields_one_error_on_a_mid_stream_read_failure_then_e // The server promises a large body, delivers one complete event, then // drops the connection: the read failure must surface as one error // item that ends the stream, not as a hang or a silent close. - let begun = event_json(&serde_json::json!({"Begun": {"weight": 1.0}})); + let begun = snapshot_json(true, "Loading profile"); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); tokio::spawn(async move { @@ -222,22 +245,16 @@ async fn subscribe_progress_bounds_an_event_block_that_never_terminates() { async fn subscribe_progress_decodes_crlf_terminated_blocks() { // A peer that terminates its lines with CRLF still dispatches: the // blank-line terminator is `\r\n\r\n`, which contains no `\n\n`. - let begun = event_json(&serde_json::json!({"Begun": {"weight": 1.0}})); - let finished = event_json(&serde_json::json!({"Finished": {"ok": true}})); + let begun = snapshot_json(true, "Loading profile"); + let finished = snapshot_json(false, ""); let body = format!("data: {begun}\r\n\r\ndata: {finished}\r\n\r\n"); let events = collect_events(mock_progress(body)).await; - let states: Vec = events - .iter() - .map(|item| item.as_ref().expect("every item decodes").state) - .collect(); + let snapshots: Vec<(bool, String)> = events.iter().map(decoded).collect(); assert_eq!( - states, - vec![ - EventState::Begun { weight: 1.0 }, - EventState::Finished { ok: true }, - ] + snapshots, + vec![(true, "Loading profile".to_owned()), (false, String::new())] ); } @@ -245,8 +262,8 @@ async fn subscribe_progress_decodes_crlf_terminated_blocks() { async fn subscribe_progress_discards_an_incomplete_trailing_block() { // The body ends mid-block: only blank-line-terminated blocks // dispatch, so the partial event is dropped and the stream ends. - let begun = event_json(&serde_json::json!({"Begun": {"weight": 1.0}})); - let finished = event_json(&serde_json::json!({"Finished": {"ok": true}})); + let begun = snapshot_json(true, "Loading profile"); + let finished = snapshot_json(false, ""); let body = format!("data: {begun}\n\ndata: {finished}"); let events = collect_events(mock_progress(body)).await; diff --git a/crates/workshop/gateway/src/gateway_binding/tests.rs b/crates/workshop/gateway/src/gateway_binding/tests.rs index 70e75454a..81f365b38 100644 --- a/crates/workshop/gateway/src/gateway_binding/tests.rs +++ b/crates/workshop/gateway/src/gateway_binding/tests.rs @@ -1,3 +1,5 @@ +//! Gateway binding tests: capability replacement publishes one coherent snapshot and a new identity. + use super::*; mod atomic; diff --git a/crates/workshop/gateway/src/gateway_binding/tests/atomic.rs b/crates/workshop/gateway/src/gateway_binding/tests/atomic.rs index 416d51a67..e4d263f61 100644 --- a/crates/workshop/gateway/src/gateway_binding/tests/atomic.rs +++ b/crates/workshop/gateway/src/gateway_binding/tests/atomic.rs @@ -1,3 +1,5 @@ +//! Binding atomicity: synchronized reads never observe a torn replacement snapshot. + use super::*; use std::sync::Arc; diff --git a/crates/workshop/gateway/src/gateway_binding/tests/publication.rs b/crates/workshop/gateway/src/gateway_binding/tests/publication.rs index ea6a7ecc9..13c772aa8 100644 --- a/crates/workshop/gateway/src/gateway_binding/tests/publication.rs +++ b/crates/workshop/gateway/src/gateway_binding/tests/publication.rs @@ -1,3 +1,5 @@ +//! Binding publication: cancellation wakes contenders and close is a permanent linearization point. + use super::*; use std::sync::atomic::{AtomicBool, Ordering}; diff --git a/crates/workshop/gateway/src/gateway_binding/tests/shutdown.rs b/crates/workshop/gateway/src/gateway_binding/tests/shutdown.rs index d3fdbbb9d..374b7ec7b 100644 --- a/crates/workshop/gateway/src/gateway_binding/tests/shutdown.rs +++ b/crates/workshop/gateway/src/gateway_binding/tests/shutdown.rs @@ -1,3 +1,5 @@ +//! Binding shutdown authority: which identity a quit targets and who may post the shutdown. + use super::*; use std::{sync::mpsc, time::Duration}; diff --git a/crates/workshop/gateway/src/gateway_progress-presenter.rs b/crates/workshop/gateway/src/gateway_progress-presenter.rs new file mode 100644 index 000000000..5deed7466 --- /dev/null +++ b/crates/workshop/gateway/src/gateway_progress-presenter.rs @@ -0,0 +1,161 @@ +//! The anti-flicker policy between the gateway's progress snapshots and +//! the status bar: the barberpole appears only once the gateway has been +//! busy for [`SHOW_DELAY`], stays up at least [`MIN_VISIBLE`] once shown, +//! and follows the newest text while up. The policy lives here, next to +//! the subscriber that feeds it, so the UI stays dumb and the wire type +//! stays a plain busy flag plus text. +//! +//! [`Presenter`] is a pure state machine over explicit instants: the +//! subscriber loop hands it every decoded snapshot and wakes it at +//! [`Presenter::next_wake`], and the tests drive it with instants of +//! their own choosing. + +use std::time::Duration; + +use gateway_api_types::Progress; +use tokio::time::Instant; + +use workshop_protocol::Activity; +use workshop_registry::Push; + +/// How long the gateway must stay busy before the barberpole appears; +/// work shorter than this never disturbs the status bar. +pub(crate) const SHOW_DELAY: Duration = Duration::from_secs(1); + +/// How long the barberpole stays up once shown, so work that ends just +/// past [`SHOW_DELAY`] reads as a completed activity, not a flash. +pub(crate) const MIN_VISIBLE: Duration = Duration::from_millis(500); + +/// The tooltip every gateway busy frame carries. +const DESCRIPTION: &str = "gateway activity"; + +/// The two anti-flicker durations, injectable so the subscriber tests +/// run against a live mock gateway without waiting out the production +/// values. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct Policy { + /// Busy time before the barberpole appears. + pub(crate) show_delay: Duration, + /// Minimum time the barberpole stays up once shown. + pub(crate) min_visible: Duration, +} + +impl Policy { + /// The production policy. + pub(crate) const DEFAULT: Self = Self { + show_delay: SHOW_DELAY, + min_visible: MIN_VISIBLE, + }; +} + +/// The gateway is busy: since when, and its newest text. +#[derive(Debug)] +struct Live { + since: Instant, + text: String, +} + +/// The barberpole is up: since when, and the text last pushed. +#[derive(Debug)] +struct Shown { + at: Instant, + text: String, +} + +/// The anti-flicker state machine over gateway snapshots. +#[derive(Debug)] +pub(crate) struct Presenter { + policy: Policy, + live: Option, + shown: Option, +} + +impl Presenter { + /// A presenter at rest: nothing live, nothing shown. + pub(crate) fn new(policy: Policy) -> Self { + Self { + policy, + live: None, + shown: None, + } + } + + /// Records one decoded snapshot at `now` and pushes whatever + /// transition it calls for. A busy snapshot starts the show delay + /// (or replaces the live text); an idle snapshot ends the busy run + /// and starts the minimum-visible hold if the bar is up. + pub(crate) fn apply(&mut self, snapshot: Progress, now: Instant, push: &Push) { + if snapshot.busy { + match &mut self.live { + Some(live) => live.text = snapshot.text, + None => { + self.live = Some(Live { + since: now, + text: snapshot.text, + }); + } + } + } else { + self.live = None; + } + self.settle(now, push); + } + + /// Re-evaluates the deadlines at `now` without a new snapshot: the + /// show delay lapsing pushes the bar up, the minimum-visible hold + /// lapsing pushes it idle. + pub(crate) fn tick(&mut self, now: Instant, push: &Push) { + self.settle(now, push); + } + + /// The next moment [`Presenter::tick`] can change state without a + /// snapshot: the show deadline while the gateway warms up, or the + /// earliest idle moment once the gateway went idle under a shown bar. + pub(crate) fn next_wake(&self) -> Option { + match (&self.live, &self.shown) { + (Some(live), None) => Some(live.since + self.policy.show_delay), + (None, Some(shown)) => Some(shown.at + self.policy.min_visible), + (Some(_), Some(_)) | (None, None) => None, + } + } + + /// The subscription is gone at `now`: progress from a gateway the + /// workshop can no longer hear is stale, so this reads as an idle + /// snapshot. Any pending show is forgotten, and a shown bar rests once + /// its minimum-visible hold has lapsed, the same hold an idle snapshot + /// gets; the subscriber keeps [`Presenter::next_wake`] armed between + /// subscriptions so the hold lapses on time. + pub(crate) fn detach(&mut self, now: Instant, push: &Push) { + self.live = None; + self.settle(now, push); + } + + fn settle(&mut self, now: Instant, push: &Push) { + let Some(live) = &self.live else { + if let Some(shown) = &self.shown + && now.duration_since(shown.at) >= self.policy.min_visible + { + self.shown = None; + push.push_idle(); + } + return; + }; + match self.shown.as_mut() { + None => { + if now.duration_since(live.since) >= self.policy.show_delay { + push.push_busy(live.text.clone(), DESCRIPTION, Activity::General); + self.shown = Some(Shown { + at: now, + text: live.text.clone(), + }); + } + } + Some(shown) => { + if shown.text != live.text { + push.push_busy(live.text.clone(), DESCRIPTION, Activity::General); + shown.text.clone_from(&live.text); + } + } + } + } +} diff --git a/crates/workshop/gateway/src/gateway_progress-tests-lifecycle.rs b/crates/workshop/gateway/src/gateway_progress-tests-lifecycle.rs deleted file mode 100644 index 8540f71a9..000000000 --- a/crates/workshop/gateway/src/gateway_progress-tests-lifecycle.rs +++ /dev/null @@ -1,71 +0,0 @@ -use super::*; - -fn operation_event_json(operation: u64, path: &str, state: &serde_json::Value) -> String { - serde_json::json!({ - "operation": operation, - "path": path, - "label": path, - "state": state, - }) - .to_string() -} - -#[tokio::test] -async fn a_multi_stage_operation_detaches_only_when_the_operation_finishes() { - let mock = Arc::new(MockProgress::new()); - let base_url = spawn_gateway(Arc::clone(&mock).router()).await; - let hub = Arc::new(ProgressHub::new()); - let subscriber = spawn(binding(&base_url), Arc::clone(&hub), GatewayHealth::new()); - - wait_for_connections(&mock, 1).await; - mock.send(event_json( - "loading-profile", - &serde_json::json!({"Begun": {"weight": 1.0}}), - )); - snapshot_where(&hub, |snapshot| snapshot.len() == 1).await; - mock.send(event_json( - "loading-profile", - &serde_json::json!({"Finished": {"ok": true}}), - )); - snapshot_where(&hub, |snapshot| { - snapshot.len() == 1 - && snapshot[0] - .nodes - .iter() - .any(|node| node.path == "loading-profile" && node.finished) - }) - .await; - mock.send(operation_event_json( - 8, - "download", - &serde_json::json!({"Begun": {"weight": 1.0}}), - )); - mock.send(event_json( - "starting-models", - &serde_json::json!({"Begun": {"weight": 5.0}}), - )); - snapshot_where(&hub, |snapshot| { - snapshot.len() == 2 - && snapshot.iter().any(|operation| { - operation - .nodes - .iter() - .any(|node| node.path == "starting-models" && !node.finished) - }) - }) - .await; - mock.send(operation_event_json( - 7, - "", - &serde_json::json!("OperationFinished"), - )); - let remaining = snapshot_where(&hub, |snapshot| snapshot.len() == 1).await; - assert_eq!(remaining[0].nodes[0].path, "download"); - - assert_eq!( - mock.connections.load(Ordering::Relaxed), - 1, - "operation completion detaches only its import, not the open SSE stream" - ); - subscriber.shutdown().await; -} diff --git a/crates/workshop/gateway/src/gateway_progress-tests-presenter.rs b/crates/workshop/gateway/src/gateway_progress-tests-presenter.rs new file mode 100644 index 000000000..8eb525d11 --- /dev/null +++ b/crates/workshop/gateway/src/gateway_progress-tests-presenter.rs @@ -0,0 +1,261 @@ +//! The anti-flicker presenter pinned with explicit instants: a busy +//! snapshot shows only after the show delay, an idle snapshot before the +//! delay pushes nothing, an idle arriving within the minimum visible time +//! is deferred to it, text changes republish while shown, and a detach +//! rests a shown bar under the same hold. + +use super::*; + +use gateway_api_types::Progress; + +/// A presenter under the production policy, its push, and the recorder. +fn wired() -> (Presenter, Recorder) { + (Presenter::new(Policy::DEFAULT), recorder()) +} + +fn busy(text: &str) -> Progress { + Progress { + busy: true, + text: text.to_owned(), + } +} + +fn idle() -> Progress { + Progress::default() +} + +#[tokio::test] +async fn a_busy_snapshot_pushes_only_once_the_show_delay_has_passed() { + let (mut presenter, recorder) = wired(); + let start = Instant::now(); + presenter.apply(busy("Downloading model"), start, &recorder.push); + assert!( + recorder.pushed().is_empty(), + "a fresh busy snapshot must not flash the bar" + ); + assert_eq!( + presenter.next_wake(), + Some(start + SHOW_DELAY), + "the presenter asks to wake exactly at the show deadline" + ); + + presenter.tick( + start + SHOW_DELAY - Duration::from_millis(1), + &recorder.push, + ); + assert!( + recorder.pushed().is_empty(), + "one millisecond short of the delay is still too soon" + ); + + presenter.tick(start + SHOW_DELAY, &recorder.push); + assert_eq!( + recorder.pushed(), + [("Downloading model".to_owned(), true)], + "the delay lapsing pushes one busy frame carrying the gateway text" + ); + assert_eq!( + presenter.next_wake(), + None, + "a shown bar with live work has no deadline of its own" + ); +} + +#[tokio::test] +async fn an_idle_snapshot_before_the_show_delay_pushes_nothing() { + let (mut presenter, recorder) = wired(); + let start = Instant::now(); + presenter.apply(busy("Loading profile"), start, &recorder.push); + presenter.apply(idle(), start + Duration::from_millis(300), &recorder.push); + presenter.tick(start + SHOW_DELAY * 2, &recorder.push); + assert!( + recorder.pushed().is_empty(), + "sub-second work never disturbs the status bar" + ); + assert_eq!(presenter.next_wake(), None); +} + +#[tokio::test] +async fn an_idle_snapshot_within_the_minimum_visible_time_is_deferred_to_it() { + let (mut presenter, recorder) = wired(); + let start = Instant::now(); + presenter.apply(busy("Downloading model"), start, &recorder.push); + let shown = start + SHOW_DELAY; + presenter.tick(shown, &recorder.push); + assert_eq!(recorder.pushed().len(), 1, "the bar is up"); + + let ended = shown + Duration::from_millis(100); + presenter.apply(idle(), ended, &recorder.push); + assert_eq!( + recorder.pushed().len(), + 1, + "an idle arriving 100ms after showing must not flash the bar off" + ); + assert_eq!( + presenter.next_wake(), + Some(shown + MIN_VISIBLE), + "the presenter asks to wake when the minimum visible time lapses" + ); + + presenter.tick( + shown + MIN_VISIBLE - Duration::from_millis(1), + &recorder.push, + ); + assert_eq!(recorder.pushed().len(), 1, "still inside the hold"); + + presenter.tick(shown + MIN_VISIBLE, &recorder.push); + assert_eq!( + recorder.pushed(), + [ + ("Downloading model".to_owned(), true), + ("Ready".to_owned(), false), + ], + "the hold lapsing rests the bar" + ); + assert_eq!(presenter.next_wake(), None); +} + +#[tokio::test] +async fn an_idle_snapshot_after_the_minimum_visible_time_rests_the_bar_at_once() { + let (mut presenter, recorder) = wired(); + let start = Instant::now(); + presenter.apply(busy("Downloading model"), start, &recorder.push); + let shown = start + SHOW_DELAY; + presenter.tick(shown, &recorder.push); + presenter.apply(idle(), shown + MIN_VISIBLE * 4, &recorder.push); + assert_eq!( + recorder.pushed().last(), + Some(&("Ready".to_owned(), false)), + "an idle past the hold needs no deferral" + ); +} + +#[tokio::test] +async fn a_text_change_while_shown_republishes_and_an_unchanged_text_does_not() { + let (mut presenter, recorder) = wired(); + let start = Instant::now(); + presenter.apply(busy("Downloading model 10%"), start, &recorder.push); + presenter.apply( + busy("Downloading model 20%"), + start + Duration::from_millis(500), + &recorder.push, + ); + assert!( + recorder.pushed().is_empty(), + "text changes before the delay stay pending" + ); + let shown = start + SHOW_DELAY; + presenter.tick(shown, &recorder.push); + assert_eq!( + recorder.pushed(), + [("Downloading model 20%".to_owned(), true)], + "the delay lapsing pushes the newest text, not the first" + ); + + presenter.apply( + busy("Downloading model 20%"), + shown + Duration::from_millis(10), + &recorder.push, + ); + assert_eq!( + recorder.pushed().len(), + 1, + "a snapshot repeating the shown text is not re-pushed" + ); + + presenter.apply( + busy("Downloading model 30%"), + shown + Duration::from_millis(20), + &recorder.push, + ); + assert_eq!( + recorder.pushed().last(), + Some(&("Downloading model 30%".to_owned(), true)), + "a new text republishes while the bar is up" + ); +} + +#[tokio::test] +async fn work_resuming_during_the_hold_keeps_the_bar_up_without_a_new_delay() { + let (mut presenter, recorder) = wired(); + let start = Instant::now(); + presenter.apply(busy("Selecting profile"), start, &recorder.push); + let shown = start + SHOW_DELAY; + presenter.tick(shown, &recorder.push); + presenter.apply(idle(), shown + Duration::from_millis(100), &recorder.push); + presenter.apply( + busy("Starting models"), + shown + Duration::from_millis(200), + &recorder.push, + ); + assert_eq!( + recorder.pushed(), + [ + ("Selecting profile".to_owned(), true), + ("Starting models".to_owned(), true), + ], + "back-to-back work shares one visible run: no idle frame, no second delay" + ); + assert_eq!(presenter.next_wake(), None); +} + +#[tokio::test] +async fn a_detach_within_the_minimum_visible_time_is_deferred_to_it() { + let (mut presenter, recorder) = wired(); + let start = Instant::now(); + presenter.apply(busy("Downloading model"), start, &recorder.push); + let shown = start + SHOW_DELAY; + presenter.tick(shown, &recorder.push); + presenter.detach(shown + Duration::from_millis(100), &recorder.push); + assert_eq!( + recorder.pushed().len(), + 1, + "a subscription lost 100ms after showing must not flash the bar off" + ); + assert_eq!( + presenter.next_wake(), + Some(shown + MIN_VISIBLE), + "the presenter asks to wake when the minimum visible time lapses" + ); + + presenter.tick(shown + MIN_VISIBLE, &recorder.push); + assert_eq!( + recorder.pushed(), + [ + ("Downloading model".to_owned(), true), + ("Ready".to_owned(), false), + ], + "the hold lapsing rests the bar" + ); + assert_eq!(presenter.next_wake(), None); +} + +#[tokio::test] +async fn a_detach_after_the_minimum_visible_time_rests_the_bar_at_once() { + let (mut presenter, recorder) = wired(); + let start = Instant::now(); + presenter.apply(busy("Downloading model"), start, &recorder.push); + let shown = start + SHOW_DELAY; + presenter.tick(shown, &recorder.push); + presenter.detach(shown + MIN_VISIBLE, &recorder.push); + assert_eq!( + recorder.pushed().last(), + Some(&("Ready".to_owned(), false)), + "a lost subscription past the hold rests the bar at once" + ); + assert_eq!(presenter.next_wake(), None); +} + +#[tokio::test] +async fn a_detach_before_the_bar_showed_forgets_the_pending_work() { + let (mut presenter, recorder) = wired(); + let start = Instant::now(); + presenter.apply(busy("Downloading model"), start, &recorder.push); + presenter.detach(start + Duration::from_millis(300), &recorder.push); + assert_eq!(presenter.next_wake(), None); + presenter.tick(start + SHOW_DELAY * 2, &recorder.push); + assert!( + recorder.pushed().is_empty(), + "a detach before the bar showed pushes nothing, then or later" + ); +} diff --git a/crates/workshop/gateway/src/gateway_progress-tests-recovery.rs b/crates/workshop/gateway/src/gateway_progress-tests-recovery.rs index 751c1936e..874dc639a 100644 --- a/crates/workshop/gateway/src/gateway_progress-tests-recovery.rs +++ b/crates/workshop/gateway/src/gateway_progress-tests-recovery.rs @@ -1,3 +1,5 @@ +//! Progress subscription recovery: an endpoint replacement moves the subscription at once. + use super::*; #[tokio::test] @@ -7,28 +9,35 @@ async fn an_endpoint_replacement_moves_the_progress_subscription_immediately() { let replacement = Arc::new(MockProgress::new()); let replacement_url = spawn_gateway(Arc::clone(&replacement).router()).await; let gateway = binding(&original_url); - let hub = Arc::new(ProgressHub::new()); - let subscriber = spawn(gateway.clone(), Arc::clone(&hub), GatewayHealth::new()); + let recorder = recorder(); + let subscriber = spawn_with_timing( + gateway.clone(), + recorder.push.clone(), + GatewayHealth::new(), + FAST_TIMING, + ); wait_for_connections(&original, 1).await; + original.send(true, "original-download"); + recorder.frames_where(|frames| frames.len() == 1).await; + gateway .replace(&replacement_url, "") .expect("the replacement publishes"); + let pushed = recorder.frames_where(|frames| frames.len() == 2).await; + assert_eq!( + pushed[1], + ("Ready".to_owned(), false), + "the old endpoint's progress is stale the moment the binding moves" + ); wait_for_connections(&replacement, 1).await; - replacement.send(event_json( - "replacement-download", - &serde_json::json!({"Begun": {"weight": 1.0}}), - )); - let snapshot = snapshot_where(&hub, |snapshot| { - snapshot.iter().any(|operation| { - operation - .nodes - .iter() - .any(|node| node.path == "replacement-download") - }) - }) - .await; - assert_eq!(snapshot.len(), 1, "only the replacement import remains"); + replacement.send(true, "replacement-download"); + let pushed = recorder.frames_where(|frames| frames.len() == 3).await; + assert_eq!( + pushed[2], + ("replacement-download".to_owned(), true), + "the replacement's snapshots drive the bar" + ); assert_eq!( original.connections.load(Ordering::Relaxed), 1, diff --git a/crates/workshop/gateway/src/gateway_progress-tests.rs b/crates/workshop/gateway/src/gateway_progress-tests.rs index a41c445cf..15e0ca9ec 100644 --- a/crates/workshop/gateway/src/gateway_progress-tests.rs +++ b/crates/workshop/gateway/src/gateway_progress-tests.rs @@ -1,16 +1,38 @@ -// Fractions are fixed-point millionths, so equality comparisons are exact -// (the shared-progress remote.rs test precedent). -#![expect(clippy::float_cmp, reason = "fixed-point fractions compare exactly")] +//! Subscriber tests against a mock `GET /admin/progress`: snapshots +//! reach the status bar as busy frames under the anti-flicker policy, +//! a malformed snapshot is skipped, a closed stream resubscribes after +//! the delay, an unreachable gateway holds no subscription, and a +//! reconnect rests the bar and resubscribes once. A lost subscription +//! rests the bar under the presenter's minimum-visible hold, which the +//! loop keeps ticking between subscriptions. The presenter's timing +//! rules are pinned separately with explicit instants; here the policy +//! runs at millisecond scale so the mock round trips stay fast. use super::*; use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex, PoisonError}; use axum::extract::State; use axum::response::{IntoResponse, Response}; use tokio::sync::broadcast; -use shared_progress::OperationSnapshot; +use workshop_protocol::StatusBarUpdate; +use workshop_registry::{Registration, Registry, StatusSink, StatusSinkAdapter}; + +/// The anti-flicker policy the mock-gateway tests run under: short +/// enough that a test waits milliseconds, long enough that a snapshot +/// still has to outlive the show delay to reach the bar. +const FAST_POLICY: Policy = Policy { + show_delay: Duration::from_millis(40), + min_visible: Duration::from_millis(20), +}; + +/// The production resubscribe delay is seconds; the tests inject this. +const FAST_TIMING: Timing = Timing { + resubscribe_delay: Duration::from_millis(50), + policy: FAST_POLICY, +}; /// Binds `app` as a mock gateway on a free loopback port and returns its /// base URL. @@ -32,6 +54,62 @@ fn binding(base_url: &str) -> GatewayBinding { GatewayBinding::new(base_url, "").expect("the test binding builds") } +/// A recording status sink behind a [`Push`]: every frame the subscriber +/// pushes lands in `frames`, in order. +struct Recorder { + push: Push, + frames: Arc>>, + _guard: Registration, +} + +fn recorder() -> Recorder { + let registry = Registry::new(); + let frames: Arc>> = Arc::new(Mutex::new(Vec::new())); + let sink = Arc::clone(&frames); + let guard = + registry.register_sink::(Arc::new(StatusSinkAdapter::new(move |update| { + sink.lock() + .unwrap_or_else(PoisonError::into_inner) + .push(update); + }))); + Recorder { + push: registry.push(), + frames, + _guard: guard, + } +} + +impl Recorder { + /// The `(label, busy)` of every frame pushed so far. + fn pushed(&self) -> Vec<(String, bool)> { + self.frames + .lock() + .unwrap_or_else(PoisonError::into_inner) + .iter() + .map(|update| (update.label.clone(), update.busy)) + .collect() + } + + /// Polls until `accept` holds over the pushed frames, within a + /// generous deadline (the heartbeat tests' snapshot_where pattern). + async fn frames_where( + &self, + accept: impl Fn(&[(String, bool)]) -> bool, + ) -> Vec<(String, bool)> { + tokio::time::timeout(Duration::from_secs(5), async { + loop { + let pushed = self.pushed(); + if accept(&pushed) { + return pushed; + } + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await + .expect("matching frames arrive within the deadline") + } +} + /// A mock `GET /admin/progress`: every payload published to the feed /// streams to every connected subscriber as an SSE `data:` frame, and /// `connections` counts how often the endpoint was hit. The receiver @@ -40,14 +118,14 @@ fn binding(base_url: &str) -> GatewayBinding { /// ends every live stream, so a test can drive the resubscribe path. struct MockProgress { connections: AtomicUsize, - feeds: std::sync::Mutex>, + feeds: Mutex>, } impl MockProgress { fn new() -> Self { Self { connections: AtomicUsize::new(0), - feeds: std::sync::Mutex::new(broadcast::channel(16).0), + feeds: Mutex::new(broadcast::channel(16).0), } } @@ -57,8 +135,13 @@ impl MockProgress { .with_state(self) } - /// Publishes one payload to every connected subscriber. - fn send(&self, payload: String) { + /// Publishes one snapshot to every connected subscriber. + fn send(&self, busy: bool, text: &str) { + self.send_raw(serde_json::json!({"busy": busy, "text": text}).to_string()); + } + + /// Publishes one raw payload to every connected subscriber. + fn send_raw(&self, payload: String) { self.feeds .lock() .expect("the feed lock is not poisoned") @@ -101,38 +184,6 @@ async fn serve_feed(State(mock): State>) -> Response { .into_response() } -/// Serializes a wire-format progress event by hand, so the tests pin -/// the JSON shape the gateway emits rather than the progress crate's -/// constructors (the gateway-client test pattern). -fn event_json(path: &str, state: &serde_json::Value) -> String { - serde_json::json!({ - "operation": 7, - "path": path, - "label": path, - "state": state, - }) - .to_string() -} - -/// Polls the hub's snapshot until `accept` holds, within a generous -/// deadline (the heartbeat tests' snapshot_where pattern). -async fn snapshot_where( - hub: &ProgressHub, - accept: impl Fn(&[OperationSnapshot]) -> bool, -) -> Vec { - tokio::time::timeout(Duration::from_secs(5), async { - loop { - let snapshot = hub.snapshot(); - if accept(&snapshot) { - return snapshot; - } - tokio::time::sleep(Duration::from_millis(5)).await; - } - }) - .await - .expect("a matching snapshot arrives within the deadline") -} - /// Polls the mock's connection count until it reaches `n`. async fn wait_for_connections(mock: &MockProgress, n: usize) { tokio::time::timeout(Duration::from_secs(5), async { @@ -145,104 +196,118 @@ async fn wait_for_connections(mock: &MockProgress, n: usize) { } #[tokio::test] -async fn events_from_the_gateway_feed_a_remote_operation_on_the_hub() { +async fn a_busy_snapshot_reaches_the_status_bar_as_a_busy_frame_with_the_gateway_text() { let mock = Arc::new(MockProgress::new()); let base_url = spawn_gateway(Arc::clone(&mock).router()).await; - let hub = Arc::new(ProgressHub::new()); + let recorder = recorder(); // The flag starts optimistic, so the subscriber connects at once. - let subscriber = spawn(binding(&base_url), Arc::clone(&hub), GatewayHealth::new()); + let subscriber = spawn_with_timing( + binding(&base_url), + recorder.push.clone(), + GatewayHealth::new(), + FAST_TIMING, + ); wait_for_connections(&mock, 1).await; - mock.send(event_json( - "download", - &serde_json::json!({"Begun": {"weight": 1.0}}), - )); - mock.send(event_json( - "download", - &serde_json::json!({"Updated": {"fraction": 0.5}}), - )); - - let snapshot = snapshot_where(&hub, |s| { - s.len() == 1 && s[0].nodes.iter().any(|n| n.fraction == 0.5) - }) - .await; - assert_eq!(snapshot[0].nodes[0].path, "download"); - assert_eq!(snapshot[0].nodes[0].label, "download"); + mock.send(true, "Downloading qwen3-8b.gguf 45%"); + + let pushed = recorder.frames_where(|frames| !frames.is_empty()).await; + assert_eq!( + pushed, + [("Downloading qwen3-8b.gguf 45%".to_owned(), true)], + "the gateway's text is the bar's label and the frame is busy" + ); + + mock.send(false, ""); + let pushed = recorder.frames_where(|frames| frames.len() == 2).await; + assert_eq!( + pushed[1], + ("Ready".to_owned(), false), + "the idle snapshot rests the bar once the minimum visible time passes" + ); subscriber.shutdown().await; } #[tokio::test] -async fn a_malformed_event_is_skipped_and_the_stream_continues() { +async fn a_malformed_snapshot_is_skipped_and_the_stream_continues() { let mock = Arc::new(MockProgress::new()); let base_url = spawn_gateway(Arc::clone(&mock).router()).await; - let hub = Arc::new(ProgressHub::new()); - let subscriber = spawn(binding(&base_url), Arc::clone(&hub), GatewayHealth::new()); + let recorder = recorder(); + let subscriber = spawn_with_timing( + binding(&base_url), + recorder.push.clone(), + GatewayHealth::new(), + FAST_TIMING, + ); wait_for_connections(&mock, 1).await; - mock.send(event_json( - "download", - &serde_json::json!({"Begun": {"weight": 1.0}}), - )); - // One undecodable `data:` block between two valid events: the + // One undecodable `data:` block ahead of a valid snapshot: the // subscriber warns and continues rather than dropping the stream. - mock.send("{not valid json".to_owned()); - mock.send(event_json( - "download", - &serde_json::json!({"Updated": {"fraction": 0.5}}), - )); - - let snapshot = snapshot_where(&hub, |s| { - s.len() == 1 && s[0].nodes.iter().any(|n| n.fraction == 0.5) - }) - .await; + mock.send_raw("{not valid json".to_owned()); + mock.send(true, "Loading profile"); + + let pushed = recorder.frames_where(|frames| !frames.is_empty()).await; + assert_eq!( + pushed, + [("Loading profile".to_owned(), true)], + "the snapshot after the malformed one still reaches the bar" + ); assert_eq!( - snapshot[0].nodes[0].path, "download", - "the event after the malformed one still lands on the hub" + mock.connections.load(Ordering::Relaxed), + 1, + "a malformed snapshot never drops the subscription" ); subscriber.shutdown().await; } #[tokio::test] -async fn a_stream_that_ends_while_reachable_resubscribes_after_the_delay() { +async fn a_stream_that_ends_while_reachable_rests_the_bar_and_resubscribes_after_the_delay() { let mock = Arc::new(MockProgress::new()); let base_url = spawn_gateway(Arc::clone(&mock).router()).await; - let hub = Arc::new(ProgressHub::new()); - let delay = Duration::from_millis(50); - let subscriber = spawn_with_delay( + let recorder = recorder(); + let subscriber = spawn_with_timing( binding(&base_url), - Arc::clone(&hub), + recorder.push.clone(), GatewayHealth::new(), - delay, + FAST_TIMING, ); wait_for_connections(&mock, 1).await; - mock.send(event_json( - "download", - &serde_json::json!({"Begun": {"weight": 1.0}}), - )); - snapshot_where(&hub, |s| s.len() == 1).await; - - // The stream ends while the gateway still reads reachable: the - // import detaches, and a fresh subscription follows the delay. + mock.send(true, "Downloading"); + recorder.frames_where(|frames| frames.len() == 1).await; + + // The stream ends while the gateway still reads reachable: the bar + // rests once its minimum visible time lapses, which falls inside the + // resubscribe wait, and a fresh subscription follows the delay. let closed = std::time::Instant::now(); mock.close(); - snapshot_where(&hub, <[OperationSnapshot]>::is_empty).await; + let pushed = recorder.frames_where(|frames| frames.len() == 2).await; + assert_eq!( + pushed[1], + ("Ready".to_owned(), false), + "a lost subscription rests the bar: its progress is stale" + ); wait_for_connections(&mock, 2).await; assert!( - closed.elapsed() >= delay, + closed.elapsed() >= FAST_TIMING.resubscribe_delay, "the resubscribe waits out the delay rather than spinning" ); subscriber.shutdown().await; } #[tokio::test] -async fn an_unreachable_gateway_holds_no_subscription_and_no_remote_state() { +async fn an_unreachable_gateway_holds_no_subscription_and_pushes_nothing() { let mock = Arc::new(MockProgress::new()); let base_url = spawn_gateway(Arc::clone(&mock).router()).await; - let hub = Arc::new(ProgressHub::new()); + let recorder = recorder(); let health = GatewayHealth::new(); health.publish(false); - let subscriber = spawn(binding(&base_url), Arc::clone(&hub), health.clone()); + let subscriber = spawn_with_timing( + binding(&base_url), + recorder.push.clone(), + health.clone(), + FAST_TIMING, + ); let quiet = tokio::time::timeout(Duration::from_millis(200), async { wait_for_connections(&mock, 1).await; @@ -252,7 +317,10 @@ async fn an_unreachable_gateway_holds_no_subscription_and_no_remote_state() { quiet.is_err(), "an unreachable gateway must not be subscribed" ); - assert!(hub.snapshot().is_empty()); + assert!( + recorder.pushed().is_empty(), + "a subscriber that never connected pushes nothing" + ); health.publish(true); wait_for_connections(&mock, 1).await; @@ -260,50 +328,48 @@ async fn an_unreachable_gateway_holds_no_subscription_and_no_remote_state() { } #[tokio::test] -async fn a_reconnect_resubscribes_without_duplicating_state() { +async fn a_reconnect_rests_the_bar_and_resubscribes_once() { let mock = Arc::new(MockProgress::new()); let base_url = spawn_gateway(Arc::clone(&mock).router()).await; - let hub = Arc::new(ProgressHub::new()); + let recorder = recorder(); let health = GatewayHealth::new(); - let subscriber = spawn(binding(&base_url), Arc::clone(&hub), health.clone()); + let subscriber = spawn_with_timing( + binding(&base_url), + recorder.push.clone(), + health.clone(), + FAST_TIMING, + ); wait_for_connections(&mock, 1).await; - mock.send(event_json( - "download", - &serde_json::json!({"Begun": {"weight": 1.0}}), - )); - let first = snapshot_where(&hub, |s| s.len() == 1).await; + mock.send(true, "Downloading"); + recorder.frames_where(|frames| frames.len() == 1).await; health.publish(false); - snapshot_where(&hub, <[OperationSnapshot]>::is_empty).await; + let pushed = recorder.frames_where(|frames| frames.len() == 2).await; + assert_eq!( + pushed[1], + ("Ready".to_owned(), false), + "an unreachable verdict rests the bar" + ); health.publish(true); wait_for_connections(&mock, 2).await; - mock.send(event_json( - "download", - &serde_json::json!({"Begun": {"weight": 1.0}}), - )); - mock.send(event_json( - "download", - &serde_json::json!({"Updated": {"fraction": 0.5}}), - )); - let reconnected = snapshot_where(&hub, |s| { - s.len() == 1 && s[0].nodes.iter().any(|n| n.fraction == 0.5) - }) - .await; + mock.send(true, "Starting models"); + let pushed = recorder.frames_where(|frames| frames.len() == 3).await; assert_eq!( - reconnected.len(), - 1, - "the reconnect replaces the import, never stacks a second one" + pushed[2], + ("Starting models".to_owned(), true), + "the fresh subscription's snapshots reach the bar" ); - assert_ne!( - first[0].operation, reconnected[0].operation, - "the resubscription attaches a fresh import under a new local id" + assert_eq!( + mock.connections.load(Ordering::Relaxed), + 2, + "the reconnect resubscribes exactly once" ); subscriber.shutdown().await; } -#[path = "gateway_progress-tests-lifecycle.rs"] -mod lifecycle; +#[path = "gateway_progress-tests-presenter.rs"] +mod presenter; #[path = "gateway_progress-tests-recovery.rs"] mod recovery; diff --git a/crates/workshop/gateway/src/gateway_progress.rs b/crates/workshop/gateway/src/gateway_progress.rs index bd5f8a632..40b0994e2 100644 --- a/crates/workshop/gateway/src/gateway_progress.rs +++ b/crates/workshop/gateway/src/gateway_progress.rs @@ -1,39 +1,64 @@ -//! The gateway progress subscriber: a background task that imports the -//! gateway's `GET /admin/progress` event stream into the workshop -//! [`ProgressHub`] as a [`RemoteOperation`], so gateway-side work (model -//! downloads, profile switches) renders on the status bar through the same -//! renderer task as local operations. +//! The gateway progress subscriber: a background task that reads the +//! gateway's `GET /admin/progress` snapshot stream and drives the status +//! bar's busy indicator through the registry's [`Push`] facade, so +//! gateway-side work (model downloads, profile switches) shows as a +//! barberpole plus the gateway's own text. //! //! The task follows the heartbeat's lifecycle posture: spawned with the //! server, stopped through its [`Subscriber`] handle inside the same //! graceful-shutdown signal, and driven by the shared [`GatewayHealth`] //! verdict rather than by probes of its own. It subscribes while the //! gateway reads reachable and idles while it does not; a reconnect -//! resubscribes, and each subscription tracks one import per upstream -//! operation id, so interleaved work stays separate and a finished operation -//! detaches without closing the long-lived event stream. +//! resubscribes. Each snapshot passes through the anti-flicker +//! `Presenter`, which decides when the bar shows and when it rests. //! When the subscription drops - a lost connection or an unreachable -//! verdict - the import detaches with it, because progress from a gateway -//! the workshop can no longer hear is stale, not informative. +//! verdict - the bar returns to rest, because progress from a gateway +//! the workshop can no longer hear is stale, not informative; a bar that +//! only just appeared still waits out its minimum visible time first, +//! so a dropped stream cannot flash it. -use std::collections::HashMap; -use std::sync::Arc; +#[path = "gateway_progress-presenter.rs"] +mod presenter; + +use std::future::Future; use std::time::Duration; use futures_util::StreamExt; -use tokio::sync::oneshot; +use tokio::sync::{oneshot, watch}; +use tokio::time::Instant; -use shared_progress::{EventState, OperationId, ProgressHub, RemoteOperation}; +use workshop_registry::Push; use crate::gateway_binding::GatewayBinding; use crate::heartbeat::GatewayHealth; +#[cfg(test)] +pub(crate) use presenter::{MIN_VISIBLE, SHOW_DELAY}; +pub(crate) use presenter::{Policy, Presenter}; + /// How long a resubscribe waits when the stream ended while the gateway /// still reads reachable, so an endpoint that accepts and immediately /// closes cannot spin the loop. A reachability flip restarts at once; /// matched to the heartbeat's probe cadence. const RESUBSCRIBE_DELAY: Duration = Duration::from_secs(5); +/// The subscriber's durations, injectable so tests can shorten them. +#[derive(Debug, Clone, Copy)] +pub(crate) struct Timing { + /// Wait before resubscribing to a stream that ended while reachable. + pub(crate) resubscribe_delay: Duration, + /// The anti-flicker policy the presenter runs. + pub(crate) policy: Policy, +} + +impl Timing { + /// The production timing. + pub(crate) const DEFAULT: Self = Self { + resubscribe_delay: RESUBSCRIBE_DELAY, + policy: Policy::DEFAULT, + }; +} + /// A running subscriber task. /// /// [`Subscriber::shutdown`] signals the task to stop and awaits it. @@ -57,24 +82,24 @@ impl Subscriber { } } -/// Spawns the subscriber task against the gateway at `base_url`, -/// importing its progress events into `hub` while `health` reads +/// Spawns the subscriber task against the gateway behind `gateway`, +/// pushing busy and idle frames through `push` while `health` reads /// reachable. #[must_use] -pub fn spawn(gateway: GatewayBinding, hub: Arc, health: GatewayHealth) -> Subscriber { - spawn_with_delay(gateway, hub, health, RESUBSCRIBE_DELAY) +pub fn spawn(gateway: GatewayBinding, push: Push, health: GatewayHealth) -> Subscriber { + spawn_with_timing(gateway, push, health, Timing::DEFAULT) } -/// [`spawn`] with the resubscribe delay injected, so tests can shorten it. -fn spawn_with_delay( +/// [`spawn`] with every duration injected, so tests can shorten them. +pub(crate) fn spawn_with_timing( gateway: GatewayBinding, - hub: Arc, + push: Push, health: GatewayHealth, - resubscribe_delay: Duration, + timing: Timing, ) -> Subscriber { let (stop, mut stopped) = oneshot::channel(); let task = tokio::spawn(async move { - run(&gateway, &hub, &health, resubscribe_delay, &mut stopped).await; + run(&gateway, &push, &health, timing, &mut stopped).await; }); Subscriber { stop: Some(stop), @@ -82,116 +107,162 @@ fn spawn_with_delay( } } +/// Why one wait ended early. +enum Ended { + /// The stop signal fired, or a control channel closed: the task + /// returns. + Stop, + /// The endpoint binding was replaced: subscribe to the new one now. + Rebind, + /// The gateway's reachability changed, or the stream closed: fall + /// back to the reconnect loop. + Lost, +} + +/// The control signals every wait in the loop listens to beside its own +/// future. +struct Signals<'a> { + stop: &'a mut oneshot::Receiver<()>, + reachable: &'a mut watch::Receiver, + gateway_changed: &'a mut watch::Receiver, +} + /// The subscription loop: idle while the gateway is unreachable, and while -/// reachable hold one subscription whose events drive operation-id-keyed -/// [`RemoteOperation`] imports. An operation-level terminal event -/// detaches that import while the subscription remains open. The stop -/// signal wins every select, so shutdown never waits out a stream read, a -/// connect, or a resubscribe delay. +/// reachable hold one subscription whose snapshots drive the presenter. +/// Every wait goes through [`until`], so the stop signal wins each select +/// and the presenter's deadlines keep ticking between subscriptions. async fn run( gateway: &GatewayBinding, - hub: &Arc, + push: &Push, health: &GatewayHealth, - resubscribe_delay: Duration, + timing: Timing, stop: &mut oneshot::Receiver<()>, ) { let mut reachable = health.subscribe(); let mut gateway_changed = gateway.subscribe(); - 'reconnect: loop { - while !*reachable.borrow_and_update() { - tokio::select! { - _ = &mut *stop => return, - changed = gateway_changed.changed() => { - if changed.is_err() { - return; - } - } - changed = reachable.changed() => { - // The sender lives in AppState for the process - // lifetime, so a closed watch means shutdown. - if changed.is_err() { - return; - } - } + let mut signals = Signals { + stop, + reachable: &mut reachable, + gateway_changed: &mut gateway_changed, + }; + let mut presenter = Presenter::new(timing.policy); + loop { + while !*signals.reachable.borrow_and_update() { + match until( + std::future::pending::<()>(), + &mut signals, + &mut presenter, + push, + ) + .await + { + Err(Ended::Stop) => return, + Err(Ended::Lost | Ended::Rebind) | Ok(()) => {} } } let snapshot = gateway.snapshot(); - let stream = tokio::select! { - _ = &mut *stop => return, - _ = reachable.changed() => continue, - changed = gateway_changed.changed() => { - if changed.is_err() { - return; + let stream = match until( + snapshot.client().subscribe_progress(), + &mut signals, + &mut presenter, + push, + ) + .await + { + Err(Ended::Stop) => return, + Err(Ended::Lost | Ended::Rebind) => continue, + Ok(Ok(stream)) => stream, + Ok(Err(error)) => { + tracing::warn!(%error, "gateway progress subscription failed"); + match until( + tokio::time::sleep(timing.resubscribe_delay), + &mut signals, + &mut presenter, + push, + ) + .await + { + Err(Ended::Stop) => return, + Err(Ended::Lost | Ended::Rebind) | Ok(()) => continue, } - continue; } - result = snapshot.client().subscribe_progress() => match result { - Ok(stream) => stream, - Err(error) => { - tracing::warn!(%error, "gateway progress subscription failed"); - tokio::select! { - _ = &mut *stop => return, - _ = reachable.changed() => {} - changed = gateway_changed.changed() => { - if changed.is_err() { - return; - } - } - () = tokio::time::sleep(resubscribe_delay) => {} - } - continue; - } - }, }; - let mut remotes: HashMap = HashMap::new(); tokio::pin!(stream); - loop { - tokio::select! { - _ = &mut *stop => return, - _ = reachable.changed() => break, - changed = gateway_changed.changed() => { - if changed.is_err() { - return; - } - continue 'reconnect; - } - item = stream.next() => match item { - Some(Ok(event)) => { - let operation = event.operation; - if matches!(event.state, EventState::OperationFinished) { - remotes.remove(&operation); - continue; - } - remotes - .entry(operation) - .or_insert_with(|| RemoteOperation::attach(hub)) - .apply(&event); - } - // One malformed event or a terminal read failure; the - // stream itself decides which by continuing or ending. - Some(Err(error)) => { - tracing::warn!(%error, "gateway progress event skipped"); - } - None => break, + let ended = loop { + match until(stream.next(), &mut signals, &mut presenter, push).await { + Err(ended) => break ended, + Ok(Some(Ok(snapshot))) => presenter.apply(snapshot, Instant::now(), push), + // One malformed snapshot or a terminal read failure; the + // stream itself decides which by continuing or ending. + Ok(Some(Err(error))) => { + tracing::warn!(%error, "gateway progress snapshot skipped"); } + Ok(None) => break Ended::Lost, + } + }; + match ended { + Ended::Stop => return, + // The subscription is gone, so its progress is stale: the bar + // rests (after any minimum-visible hold) until the next + // subscription reports work. + Ended::Rebind => { + presenter.detach(Instant::now(), push); + continue; } + Ended::Lost => presenter.detach(Instant::now(), push), } - drop(remotes); - if *reachable.borrow_and_update() { - tokio::select! { - _ = &mut *stop => return, - _ = reachable.changed() => {} - changed = gateway_changed.changed() => { - if changed.is_err() { - return; - } - } - () = tokio::time::sleep(resubscribe_delay) => {} + if *signals.reachable.borrow_and_update() { + match until( + tokio::time::sleep(timing.resubscribe_delay), + &mut signals, + &mut presenter, + push, + ) + .await + { + Err(Ended::Stop) => return, + Err(Ended::Lost | Ended::Rebind) | Ok(()) => {} + } + } + } +} + +/// Awaits `future` with the control signals and the presenter's next +/// deadline armed beside it. A control signal ends the wait early with +/// its [`Ended`]; a presenter deadline ticks the presenter and keeps +/// waiting, so a minimum-visible hold lapses on time even while the loop +/// is between subscriptions. Both watch senders live in `AppState` for +/// the process lifetime, so a closed watch means shutdown. +async fn until( + future: F, + signals: &mut Signals<'_>, + presenter: &mut Presenter, + push: &Push, +) -> Result { + tokio::pin!(future); + loop { + tokio::select! { + _ = &mut *signals.stop => return Err(Ended::Stop), + changed = signals.reachable.changed() => { + return Err(if changed.is_err() { Ended::Stop } else { Ended::Lost }); + } + changed = signals.gateway_changed.changed() => { + return Err(if changed.is_err() { Ended::Stop } else { Ended::Rebind }); } + () = wake_at(presenter.next_wake()) => presenter.tick(Instant::now(), push), + output = &mut future => return Ok(output), } } } +/// Waits for `at`, or forever when there is no pending deadline. +async fn wake_at(at: Option) { + match at { + Some(at) => tokio::time::sleep_until(at).await, + None => std::future::pending().await, + } +} + #[cfg(test)] #[path = "gateway_progress-tests.rs"] mod tests; diff --git a/crates/workshop/gateway/src/handles.rs b/crates/workshop/gateway/src/handles.rs index 72cea0c20..6a663e503 100644 --- a/crates/workshop/gateway/src/handles.rs +++ b/crates/workshop/gateway/src/handles.rs @@ -5,8 +5,6 @@ use std::sync::Arc; -use shared_progress::ProgressHub; - use workshop_registry::{BackgroundTaskAdapter, Registration, Registry, ShutdownHandle}; use workshop_support::ReconnectBackoff; @@ -51,14 +49,14 @@ pub fn register(registry: &Registry, handles: GatewayHandles) -> Registration { } /// Registers the gateway subsystem's background tasks: the -/// reachability heartbeat and the gateway progress subscriber. The -/// tasks spawn when the shell starts serving and stop inside the -/// graceful-shutdown signal. The returned guards keep the registrations -/// alive; the composition root holds them for the process lifetime. +/// reachability heartbeat and the gateway progress subscriber, both +/// reporting through the registry's push facade. The tasks spawn when +/// the shell starts serving and stop inside the graceful-shutdown +/// signal. The returned guards keep the registrations alive; the +/// composition root holds them for the process lifetime. pub fn register_tasks( registry: &Registry, handles: &GatewayHandles, - progress: Arc, backoff: ReconnectBackoff, ) -> (Registration, Registration) { let heartbeat = registry.register_task(Arc::new(BackgroundTaskAdapter::new({ @@ -77,11 +75,11 @@ pub fn register_tasks( } }))); let subscriber = registry.register_task(Arc::new(BackgroundTaskAdapter::new({ + let registry = registry.clone(); let binding = handles.binding().clone(); let health = handles.health().clone(); move || { - let task = - gateway_progress::spawn(binding.clone(), Arc::clone(&progress), health.clone()); + let task = gateway_progress::spawn(binding.clone(), registry.push(), health.clone()); ShutdownHandle::new(move || task.shutdown()) } }))); diff --git a/crates/workshop/gateway/src/heartbeat-tests.rs b/crates/workshop/gateway/src/heartbeat-tests.rs index ded03e039..9a6d3333c 100644 --- a/crates/workshop/gateway/src/heartbeat-tests.rs +++ b/crates/workshop/gateway/src/heartbeat-tests.rs @@ -10,7 +10,7 @@ fn retained(label: &str) -> StatusBarUpdate { StatusBarUpdate { label: label.to_owned(), description: String::new(), - progress: None, + busy: false, severity: Severity::Info, activity: Activity::General, } @@ -50,16 +50,13 @@ fn a_join_replays_a_retained_frame_carrying_real_work() { let working = Some(StatusBarUpdate { label: "Downloading model".to_owned(), description: "ggml-large-v3.bin".to_owned(), - progress: Some(workshop_protocol::Progress { - current: 1, - total: 2, - }), + busy: true, severity: Severity::Info, activity: Activity::General, }); let update = join_status(working, &health).expect("the work frame replays as-is"); assert_eq!(update.label, "Downloading model"); - assert!(update.progress.is_some()); + assert!(update.busy, "the busy flag survives the join recompute"); } #[test] diff --git a/crates/workshop/gateway/src/heartbeat.rs b/crates/workshop/gateway/src/heartbeat.rs index 3c901dcef..3933211da 100644 --- a/crates/workshop/gateway/src/heartbeat.rs +++ b/crates/workshop/gateway/src/heartbeat.rs @@ -78,7 +78,7 @@ pub fn join_status( } else { UNREACHABLE_DESCRIPTION.to_owned() }, - progress: None, + busy: false, severity: Severity::Info, activity: Activity::General, }) diff --git a/crates/workshop/gateway/src/lib.rs b/crates/workshop/gateway/src/lib.rs index a634d1ffe..10cffc47f 100644 --- a/crates/workshop/gateway/src/lib.rs +++ b/crates/workshop/gateway/src/lib.rs @@ -16,6 +16,9 @@ //! - A bearer key is never written to logs or `Debug` output. //! - User-visible reporting flows through the registry's push facade, so //! this crate never names another subsystem's bus. +//! - The gateway's progress reaches this crate as the public wire type +//! `gateway_api_types::Progress` alone: no progress machinery is +//! shared with the gateway family. pub mod gateway; pub mod gateway_binding; @@ -28,7 +31,7 @@ pub mod resolve; pub mod test_gateway; pub use gateway::{ - CacheEvent, CacheResponse, GatewayClient, GatewayError, GatewayResponse, ProgressEventStream, + CacheEvent, CacheResponse, GatewayClient, GatewayError, GatewayResponse, ProgressStream, SsePayloadStream, SwitchOutcome, SwitchResponse, }; pub use gateway_binding::{ diff --git a/crates/workshop/gateway/src/observer-tests.rs b/crates/workshop/gateway/src/observer-tests.rs index bbaa7e972..ebdf02d4d 100644 --- a/crates/workshop/gateway/src/observer-tests.rs +++ b/crates/workshop/gateway/src/observer-tests.rs @@ -1,3 +1,5 @@ +//! Workshop observer tests: concurrent appends, consistent reads, and poisoned-lock recovery. + use std::sync::Arc; use promptforge_api_types::ids::{ChainId, Provenance, TaskId}; diff --git a/crates/workshop/gateway/src/resolve-tests.rs b/crates/workshop/gateway/src/resolve-tests.rs index cd3bd0e42..ea0f7fc19 100644 --- a/crates/workshop/gateway/src/resolve-tests.rs +++ b/crates/workshop/gateway/src/resolve-tests.rs @@ -1,3 +1,5 @@ +//! Gateway resolution tests: discovery file versus explicit config, stale files, and probe failures. + use super::*; use std::io::{Read, Write as _}; diff --git a/crates/workshop/menu/src/catalog-tests.rs b/crates/workshop/menu/src/catalog-tests.rs index e5d9db16b..cf48ab9b6 100644 --- a/crates/workshop/menu/src/catalog-tests.rs +++ b/crates/workshop/menu/src/catalog-tests.rs @@ -1,3 +1,5 @@ +//! Catalog bus tests: publishing without subscribers, snapshot retention, and lagged receivers. + use super::*; #[tokio::test] diff --git a/crates/workshop/menu/src/menu-tests.rs b/crates/workshop/menu/src/menu-tests.rs index fa9b628e6..2a6876aae 100644 --- a/crates/workshop/menu/src/menu-tests.rs +++ b/crates/workshop/menu/src/menu-tests.rs @@ -1,3 +1,5 @@ +//! Menu bus tests: model selection, profile switches, refusals, and the published snapshots. + use super::*; use tokio::sync::broadcast::error::{RecvError, TryRecvError}; diff --git a/crates/workshop/menu/src/menu.rs b/crates/workshop/menu/src/menu.rs index 58ec0561f..26c988e74 100644 --- a/crates/workshop/menu/src/menu.rs +++ b/crates/workshop/menu/src/menu.rs @@ -181,6 +181,7 @@ impl MenuBus { /// loading the per-profile model memory from `state_dir` when one is /// given. A missing, unreadable, or corrupt memory file means "no /// memory yet": logged and tolerated (zone two), never fatal. + #[must_use] pub fn new(catalog: CatalogBus, state_dir: Option<&Path>) -> Self { let memory_path = state_dir.map(|dir| dir.join(WORKSHOP_STATE_FILE)); let last_selected = memory_path.as_deref().map(load_memory).unwrap_or_default(); diff --git a/crates/workshop/protocol/src/agent.rs b/crates/workshop/protocol/src/agent.rs index 95ca75ef3..9ae9bdb02 100644 --- a/crates/workshop/protocol/src/agent.rs +++ b/crates/workshop/protocol/src/agent.rs @@ -72,6 +72,7 @@ impl AgentSessionFrame { /// Exactly the engine's content [`Event`] variants a transcript renders; /// lifecycle, task, and debug events have no wire label and never frame. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)] +#[non_exhaustive] pub enum AgentEventKind { /// A completed assistant reply. #[serde(rename = "agent_message")] @@ -99,6 +100,7 @@ pub enum AgentEventKind { /// [`Event`] locates itself by its provenance (the task and sequence), /// which the wire does not yet expose. #[derive(Debug, Clone, PartialEq, Serialize)] +#[non_exhaustive] pub struct AgentEvent { /// What kind of thing happened. pub kind: AgentEventKind, @@ -232,6 +234,7 @@ impl AgentEventFrame { /// Which streaming side channel one agent delta belongs to. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "lowercase")] +#[non_exhaustive] pub enum AgentDeltaKind { /// Answer content, superseded by the round's `agent_message` event. Text, diff --git a/crates/workshop/protocol/src/input.rs b/crates/workshop/protocol/src/input.rs index 9b004247a..3b697d74a 100644 --- a/crates/workshop/protocol/src/input.rs +++ b/crates/workshop/protocol/src/input.rs @@ -17,6 +17,7 @@ use serde::{Deserialize, Serialize}; /// reappears, and a cancelled one vanishes by its absence. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[serde(tag = "type")] +#[non_exhaustive] pub enum InputFrame { /// A wait opened: the session wants operator input for `token`. #[serde(rename = "input_required")] diff --git a/crates/workshop/protocol/src/lib.rs b/crates/workshop/protocol/src/lib.rs index 3dada235c..711111bef 100644 --- a/crates/workshop/protocol/src/lib.rs +++ b/crates/workshop/protocol/src/lib.rs @@ -144,5 +144,5 @@ pub use catalog::{CatalogFrame, CatalogPush, is_chat_capable}; pub use error::{ErrorEnvelope, ErrorFrame}; pub use input::{InputFrame, InputResponse}; pub use menu::SwitchProfileFrame; -pub use status::{Activity, Progress, Severity, StatusBarUpdate, StatusFrame}; +pub use status::{Activity, Severity, StatusBarUpdate, StatusFrame}; pub use workbench::{WorkbenchFrame, WorkbenchSnapshot}; diff --git a/crates/workshop/protocol/src/menu.rs b/crates/workshop/protocol/src/menu.rs index 5c1c1b20b..4bbed098f 100644 --- a/crates/workshop/protocol/src/menu.rs +++ b/crates/workshop/protocol/src/menu.rs @@ -12,6 +12,7 @@ use serde::{Deserialize, Deserializer}; /// inbound frame it takes no delivery classification, because the server /// pushes none. #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[non_exhaustive] pub struct SwitchProfileFrame { /// The profile to select, or `None` (`null` on the wire) for no /// profile. diff --git a/crates/workshop/protocol/src/status.rs b/crates/workshop/protocol/src/status.rs index 32828668f..dc9eea32d 100644 --- a/crates/workshop/protocol/src/status.rs +++ b/crates/workshop/protocol/src/status.rs @@ -13,8 +13,10 @@ pub struct StatusBarUpdate { pub label: String, /// Longer text shown as the bar's tooltip. pub description: String, - /// Determinate progress, when the activity can report it. - pub progress: Option, + /// Whether work is in flight: the status bar shows its indeterminate + /// barberpole while this is set. The text in `label` says what the + /// work is; there is no fraction on the wire. + pub busy: bool, /// How loudly the update speaks; the UI ignores `Debug` updates. pub severity: Severity, /// Which subsystem is active, driving the bar's activity indicator. @@ -32,15 +34,6 @@ impl StatusBarUpdate { } } -/// A determinate progress report for the status bar's progress slot. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] -pub struct Progress { - /// Units completed so far. - pub current: u64, - /// Units expected in total. - pub total: u64, -} - /// How loudly a status update speaks. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "lowercase")] diff --git a/crates/workshop/protocol/tests/it/frames.rs b/crates/workshop/protocol/tests/it/frames.rs index 0028cce8f..f6d5aa6b1 100644 --- a/crates/workshop/protocol/tests/it/frames.rs +++ b/crates/workshop/protocol/tests/it/frames.rs @@ -5,8 +5,8 @@ use workshop_protocol::{ Activity, AgentDeltaFrame, AgentDeltaKind, AgentEventFrame, AgentSessionFrame, AgentsFrame, - CatalogPush, ErrorEnvelope, ErrorFrame, InputFrame, InputResponse, Progress, Severity, - StatusBarUpdate, SwitchProfileFrame, WorkbenchSnapshot, + CatalogPush, ErrorEnvelope, ErrorFrame, InputFrame, InputResponse, Severity, StatusBarUpdate, + SwitchProfileFrame, WorkbenchSnapshot, }; /// Builds a minimal update with the given label. @@ -14,7 +14,7 @@ fn stub(label: impl Into) -> StatusBarUpdate { StatusBarUpdate { label: label.into(), description: String::new(), - progress: None, + busy: false, severity: Severity::Info, activity: Activity::General, } @@ -29,7 +29,7 @@ fn a_status_update_serializes_as_a_status_frame() { "type": "status", "label": "Ready", "description": "", - "progress": null, + "busy": false, "severity": "info", "activity": "general", }), @@ -38,20 +38,22 @@ fn a_status_update_serializes_as_a_status_frame() { } #[test] -fn progress_and_the_remaining_variants_serialize() { +fn busy_and_the_remaining_variants_serialize() { let update = StatusBarUpdate { - progress: Some(Progress { - current: 1, - total: 2, - }), + busy: true, severity: Severity::Error, activity: Activity::Thinking, ..stub("Working") }; let frame = serde_json::to_value(update.frame()).expect("the frame serializes"); assert_eq!( - frame["progress"], - serde_json::json!({"current": 1, "total": 2}) + frame["busy"], + serde_json::json!(true), + "the busy flag rides the frame as a plain boolean, never a progress object" + ); + assert!( + frame.get("progress").is_none(), + "the determinate progress object is gone from the wire" ); assert_eq!(frame["severity"], "error"); assert_eq!(frame["activity"], "thinking"); diff --git a/crates/workshop/registry/src/push-tests.rs b/crates/workshop/registry/src/push-tests.rs index e46a54e28..ee6566dad 100644 --- a/crates/workshop/registry/src/push-tests.rs +++ b/crates/workshop/registry/src/push-tests.rs @@ -107,7 +107,7 @@ async fn a_status_update_reaches_the_sink_at_info_severity() { StatusBarUpdate { label: "Connected to gateway".to_string(), description: "the probe answered".to_string(), - progress: None, + busy: false, severity: Severity::Info, activity: Activity::General, } @@ -130,7 +130,7 @@ async fn a_failure_reaches_the_sink_at_error_severity() { StatusBarUpdate { label: "Connection lost".to_string(), description: "the gateway hung up".to_string(), - progress: None, + busy: false, severity: Severity::Error, activity: Activity::General, } @@ -155,7 +155,7 @@ async fn an_activity_pulse_reaches_the_sink_at_debug_severity() { StatusBarUpdate { label: "Streaming response...".to_string(), description: "a gateway response chunk".to_string(), - progress: None, + busy: false, severity: Severity::Debug, activity: Activity::Generating, } @@ -163,15 +163,11 @@ async fn an_activity_pulse_reaches_the_sink_at_debug_severity() { } #[tokio::test] -async fn progress_reaches_the_sink_with_its_current_and_total_counts() { +async fn busy_reaches_the_sink_as_an_info_frame_with_the_busy_flag_set() { let mut recording = wired(); - recording.push.push_progress( - "Downloading model", - "ggml-large-v3.bin", - 5, - 12, - Activity::General, - ); + recording + .push + .push_busy("Downloading model", "ggml-large-v3.bin", Activity::General); let update = recording .status_rx .recv() @@ -182,10 +178,7 @@ async fn progress_reaches_the_sink_with_its_current_and_total_counts() { StatusBarUpdate { label: "Downloading model".to_string(), description: "ggml-large-v3.bin".to_string(), - progress: Some(Progress { - current: 5, - total: 12, - }), + busy: true, severity: Severity::Info, activity: Activity::General, } @@ -206,7 +199,7 @@ async fn idle_reaches_the_sink_as_the_resting_update() { StatusBarUpdate { label: "Ready".to_string(), description: "idle".to_string(), - progress: None, + busy: false, severity: Severity::Info, activity: Activity::General, } @@ -259,7 +252,7 @@ fn intents_on_empty_slots_are_no_ops() { push.push_status_update("label", "description", Activity::General); push.push_failure("label", "description", Activity::General); push.push_activity("label", "description", Activity::General); - push.push_progress("label", "description", 1, 2, Activity::General); + push.push_busy("label", "description", Activity::General); push.push_idle(); push.push_models_catalog(Vec::new()); let menu = push.menu(); diff --git a/crates/workshop/registry/src/push.rs b/crates/workshop/registry/src/push.rs index fbc670590..000386e06 100644 --- a/crates/workshop/registry/src/push.rs +++ b/crates/workshop/registry/src/push.rs @@ -3,8 +3,8 @@ //! bus payload (SiYuan's `PushReloadFiletree` pattern). //! //! Producers hold a [`Push`] and speak in intents - a status update, a -//! failure, an activity pulse, determinate progress, idle, a fresh model -//! catalog; workbench producers drive the Model-menu mutators through +//! failure, an activity pulse, busy, idle, a fresh model catalog; +//! workbench producers drive the Model-menu mutators through //! [`Push::menu`], and every mutation publishes its own snapshot. What //! each intent becomes on the wire is decided here and in //! `workshop-protocol`, nowhere else. The sinks stay the transport: @@ -14,7 +14,7 @@ //! Every intent degrades to a no-op while its sink slot is empty, so a //! producer spawned before its subsystem registers never fails. -use workshop_protocol::{Activity, Progress, Severity, StatusBarUpdate}; +use workshop_protocol::{Activity, Severity, StatusBarUpdate}; use crate::Registry; use crate::traits::{CatalogSink, MenuSink, StatusSink}; @@ -38,14 +38,14 @@ impl Push { } /// Pushes a user-visible status update: a `{"type":"status",...}` - /// `StatusFrame` at info severity with no progress. + /// `StatusFrame` at info severity, not busy. pub fn push_status_update( &self, label: impl Into, description: impl Into, activity: Activity, ) { - self.emit(label, description, None, Severity::Info, activity); + self.emit(label, description, false, Severity::Info, activity); } /// Pushes a failure the user should see: a `{"type":"status",...}` @@ -56,7 +56,7 @@ impl Push { description: impl Into, activity: Activity, ) { - self.emit(label, description, None, Severity::Error, activity); + self.emit(label, description, false, Severity::Error, activity); } /// Pushes an activity pulse the UI does not display as text: a @@ -68,28 +68,21 @@ impl Push { description: impl Into, activity: Activity, ) { - self.emit(label, description, None, Severity::Debug, activity); + self.emit(label, description, false, Severity::Debug, activity); } - /// Pushes determinate progress - `current` of `total` units done: a - /// `{"type":"status",...}` `StatusFrame` at [`Severity::Info`] - /// carrying a [`Progress`], which the status bar renders as its - /// progress bar. - pub fn push_progress( + /// Pushes work in flight: a `{"type":"status",...}` `StatusFrame` + /// at [`Severity::Info`] with `busy` set, which the status bar + /// renders as its text plus the indeterminate barberpole. The work + /// ends with any later non-busy frame - [`Push::push_idle`], a + /// status update, or a failure. + pub fn push_busy( &self, label: impl Into, description: impl Into, - current: u64, - total: u64, activity: Activity, ) { - self.emit( - label, - description, - Some(Progress { current, total }), - Severity::Info, - activity, - ); + self.emit(label, description, true, Severity::Info, activity); } /// Pushes the status bar back to its resting state: the @@ -128,7 +121,7 @@ impl Push { &self, label: impl Into, description: impl Into, - progress: Option, + busy: bool, severity: Severity, activity: Activity, ) { @@ -138,7 +131,7 @@ impl Push { status.emit(StatusBarUpdate { label: label.into(), description: description.into(), - progress, + busy, severity, activity, }); diff --git a/crates/workshop/registry/src/traits.rs b/crates/workshop/registry/src/traits.rs index 124c01222..846b8eb3d 100644 --- a/crates/workshop/registry/src/traits.rs +++ b/crates/workshop/registry/src/traits.rs @@ -56,6 +56,7 @@ pub struct ShutdownHandle { impl ShutdownHandle { /// Wraps a closure yielding the task's stop-and-await future. + #[must_use] pub fn new(stop: F) -> Self where F: FnOnce() -> Fut + Send + 'static, @@ -136,6 +137,7 @@ where L: Fn() -> Option + Send + Sync, { /// Builds the adapter from the bus's subscribe and latest closures. + #[must_use] pub fn new(subscribe: S, latest: L) -> Self { Self { subscribe, latest } } @@ -181,6 +183,7 @@ where E: Fn(StatusBarUpdate) + Send + Sync, { /// Builds the adapter from the bus's emit closure. + #[must_use] pub fn new(emit: E) -> Self { Self { emit } } @@ -214,6 +217,7 @@ where P: Fn(Vec) + Send + Sync, { /// Builds the adapter from the bus's publish closure. + #[must_use] pub fn new(publish: P) -> Self { Self { publish } } @@ -258,6 +262,7 @@ where F: Fn() -> Vec + Send + Sync, { /// Builds the adapter from the workspace's granted-roots closure. + #[must_use] pub fn new(roots: F) -> Self { Self { roots } } @@ -293,6 +298,7 @@ where F: Fn() -> Router + Send + Sync, { /// Builds the adapter from the subsystem's router constructor. + #[must_use] pub fn new(build: F) -> Self { Self { build } } @@ -328,6 +334,7 @@ where F: Fn() -> ShutdownHandle + Send + Sync, { /// Builds the adapter from the subsystem's spawn closure. + #[must_use] pub fn new(spawn: F) -> Self { Self { spawn } } @@ -368,6 +375,7 @@ where { /// Builds the adapter from the menu bus's mutator closures, in the /// [`MenuSink`] trait's method order. + #[must_use] pub fn new(reachable: R, profiles: P, restore: S, reconcile: C) -> Self { Self { reachable, diff --git a/crates/workshop/registry/tests/it/main.rs b/crates/workshop/registry/tests/it/main.rs index d3485563c..94f880d9b 100644 --- a/crates/workshop/registry/tests/it/main.rs +++ b/crates/workshop/registry/tests/it/main.rs @@ -51,7 +51,7 @@ fn update(label: &str) -> StatusBarUpdate { StatusBarUpdate { label: label.to_string(), description: String::new(), - progress: None, + busy: false, severity: Severity::Info, activity: Activity::General, } diff --git a/crates/workshop/server-api/src/lib-tests.rs b/crates/workshop/server-api/src/lib-tests.rs index 5304c38a7..46fc13b9b 100644 --- a/crates/workshop/server-api/src/lib-tests.rs +++ b/crates/workshop/server-api/src/lib-tests.rs @@ -1,3 +1,5 @@ +//! Shell-facing surface tests: every re-export is named and the fixtures feature forwards the seams. + use super::*; /// The unqualified type name of `T`, so the assertions read as the diff --git a/crates/workshop/server/Cargo.toml b/crates/workshop/server/Cargo.toml index 978b11a31..4369695bd 100644 --- a/crates/workshop/server/Cargo.toml +++ b/crates/workshop/server/Cargo.toml @@ -29,7 +29,6 @@ rust-embed.workspace = true serde.workspace = true serde_json.workspace = true shared-loopback.workspace = true -shared-progress.workspace = true gateway-api-discovery.workspace = true socket2.workspace = true thiserror.workspace = true diff --git a/crates/workshop/server/src/agents/bindings-tests.rs b/crates/workshop/server/src/agents/bindings-tests.rs index be9710867..0aff99019 100644 --- a/crates/workshop/server/src/agents/bindings-tests.rs +++ b/crates/workshop/server/src/agents/bindings-tests.rs @@ -1,3 +1,5 @@ +//! Host binding tests: the host snapshot serves the selection and the granted roots. + use std::sync::Arc; use workshop_menu::MenuBus; diff --git a/crates/workshop/server/src/agents/relay-tests.rs b/crates/workshop/server/src/agents/relay-tests.rs index 9fac4e8a3..e17e6d14c 100644 --- a/crates/workshop/server/src/agents/relay-tests.rs +++ b/crates/workshop/server/src/agents/relay-tests.rs @@ -1,3 +1,5 @@ +//! Catalog relay tests: gateway responses pass through byte for byte and outages become 502. + use super::*; use axum::Router; diff --git a/crates/workshop/server/src/agents/session-menu.rs b/crates/workshop/server/src/agents/session-menu.rs index ffdc0be45..7988dcdc8 100644 --- a/crates/workshop/server/src/agents/session-menu.rs +++ b/crates/workshop/server/src/agents/session-menu.rs @@ -1,7 +1,7 @@ //! The socket side of the Model menu: `select_model` and //! `switch_profile` frame handling, plus the profile-selection task that //! persists the selection on the gateway, restarts a supervised sidecar -//! to load it, and drives the steps into status-bar progress. The menu +//! to load it, and holds the status bar busy until it settles. The menu //! state and bus live in the menu subsystem (`workshop-menu`); this //! module is only the session's orchestration of them. @@ -93,10 +93,9 @@ pub(super) async fn start_switch( }); } -/// How many steps the selection ladder reports, in execution order: -/// selecting the profile, restarting the gateway, loading models. A -/// selection that needs no restart stops after the first. -const SWITCH_STEPS: u64 = 3; +/// The status-bar text while a selection runs; the bar stays busy under +/// it until the switch settles with its own terminal frame. +const SWITCHING_LABEL: &str = "Switching profile..."; /// How often the ladder re-reads the published gateway generation while /// waiting for the relaunched sidecar. @@ -109,6 +108,10 @@ const REPLACEMENT_POLL: Duration = Duration::from_millis(250); /// must be restarted by hand to load settles deferred, the running /// profile unchanged; a failure restores the truthful pre-switch state /// and reports itself. +/// +/// The status bar goes busy once, here at the start; every settled arm +/// ends with a non-busy frame (idle, the deferred notice, or the +/// failure), so no separate idle push is needed. async fn run_switch( state: &SessionsState, snapshot: Arc, @@ -116,7 +119,12 @@ async fn run_switch( menu: &MenuBus, name: Option<&str>, ) { - match drive_switch(state, &snapshot, push, name).await { + push.push_busy( + SWITCHING_LABEL, + format!("switching to {}", describe(name)), + Activity::General, + ); + match drive_switch(state, &snapshot, name).await { Ok(Settled::Serving(client)) => { // The settled snapshot reads a fresh catalog and profile list // from the gateway that now serves the selection. @@ -179,7 +187,7 @@ enum SwitchFailure { #[error("{0}")] Refused(String), /// The sidecar refused or never received its shutdown request. - #[error("gateway shutdown request failed: {0}")] + #[error("the gateway did not accept its shutdown request: {0}")] Shutdown(String), /// No replacement gateway serving the selection appeared in time. #[error("gateway did not return after restart")] @@ -202,10 +210,8 @@ impl SwitchFailure { async fn drive_switch( state: &SessionsState, snapshot: &Arc, - push: &Push, name: Option<&str>, ) -> Result { - push_step(push, name, "Selecting profile...", 1); let outcome = match snapshot.client().switch_profile(name).await { Ok(SwitchResponse::Selected(outcome)) => outcome, Ok(SwitchResponse::Buffered(refusal)) => { @@ -227,7 +233,6 @@ async fn drive_switch( return Ok(Settled::RestartRequired); } let generation = snapshot.generation(); - push_step(push, name, "Restarting gateway...", 2); // The shutdown request is blocking I/O against the sidecar; the // supervisor relaunches the sibling once the process exits. let shutdown = Arc::clone(snapshot); @@ -240,7 +245,6 @@ async fn drive_switch( // relaunched sidecar binds a fresh port and key, published as a new // generation by the supervisor. let replacement = await_replacement(state, generation, name).await?; - push_step(push, name, "Loading models...", 3); Ok(Settled::Serving(replacement.client().clone())) } @@ -288,18 +292,6 @@ async fn served_profile(client: &GatewayClient) -> Option> { profile.as_str().map(|name| Some(name.to_owned())) } -/// Pushes step `current` of [`SWITCH_STEPS`] as determinate status-bar -/// progress under `label`. -fn push_step(push: &Push, name: Option<&str>, label: &str, current: u64) { - push.push_progress( - label, - format!("switching to {}", describe(name)), - current, - SWITCH_STEPS, - Activity::General, - ); -} - /// The notice for a selection a LAN gateway persisted but must be /// restarted by hand to apply: a named profile is loaded by the restart, /// no profile unloads whatever the gateway is running. diff --git a/crates/workshop/server/src/agents/socket-tests.rs b/crates/workshop/server/src/agents/socket-tests.rs new file mode 100644 index 000000000..f30987340 --- /dev/null +++ b/crates/workshop/server/src/agents/socket-tests.rs @@ -0,0 +1,35 @@ +//! The agent socket's rendering of a refused launch: the error frame's +//! text carries the refusal's cause chain, not just its outermost message. + +use std::io; + +use harness_api::LaunchError; + +use super::*; + +#[test] +fn a_refused_launch_frame_carries_the_cause_text() { + let cause = "agents directory is locked by another process"; + let refusal = LaunchRefusal::Refused(LaunchError::SessionState { + source: io::Error::new(io::ErrorKind::PermissionDenied, cause), + }); + + let rendered = refusal_text(&refusal); + + assert!( + rendered.contains("agent session state unavailable"), + "the refusal's own message is missing: {rendered}" + ); + assert!( + rendered.contains(cause), + "the cause text is missing from the frame: {rendered}" + ); +} + +#[test] +fn an_unavailable_harness_renders_its_own_message_alone() { + assert_eq!( + refusal_text(&LaunchRefusal::Unavailable), + "agent sessions are unavailable" + ); +} diff --git a/crates/workshop/server/src/agents/socket.rs b/crates/workshop/server/src/agents/socket.rs index d5101451f..8e69efd50 100644 --- a/crates/workshop/server/src/agents/socket.rs +++ b/crates/workshop/server/src/agents/socket.rs @@ -27,7 +27,9 @@ use axum::extract::State; use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; use axum::http::HeaderMap; use axum::response::Response; -use harness_api::{Delta, DeltaKind, Session, SessionEvent, SessionFailure, WaitError, WaitFrame}; +use harness_api::{ + Delta, DeltaKind, Session, SessionEvent, SessionFailure, WaitError, WaitFrame, display_chain, +}; use promptforge_api_types::event::Event; use tokio::sync::broadcast; @@ -36,6 +38,7 @@ use workshop_protocol::{ ErrorFrame, InputFrame, InputResponse, }; +use super::LaunchRefusal; use super::session::{cross_site_refusal, send_error, send_frame}; use super::state::SessionsState; @@ -172,7 +175,9 @@ async fn run_socket(mut socket: WebSocket, state: SessionsState) { received = recv_or_pending(&mut deltas_rx) => { match received { Ok(delta) => { - if !send_frame(&mut socket, &delta_frame(delta)).await { + if let Some(frame) = delta_frame(delta) + && !send_frame(&mut socket, &frame).await + { break; } } @@ -221,13 +226,17 @@ fn input_frame(frame: WaitFrame) -> InputFrame { } /// Renders a harness delta as the protocol's delta frame, the reply stamp -/// carried through. -fn delta_frame(delta: Delta) -> AgentDeltaFrame { +/// carried through; `None` for a side channel the wire has no label for, +/// dropped like a lagged delta because the completed-reply event repairs +/// the transcript. +fn delta_frame(delta: Delta) -> Option { let channel = match delta.kind { DeltaKind::Text => AgentDeltaKind::Text, DeltaKind::Reasoning => AgentDeltaKind::Reasoning, + // `DeltaKind` is `#[non_exhaustive]` in `harness-sessions`. + _ => return None, }; - AgentDeltaFrame::new(channel, delta.content, delta.reply) + Some(AgentDeltaFrame::new(channel, delta.content, delta.reply)) } /// Handles one inbound text frame. A `false` return means the client is @@ -343,7 +352,7 @@ async fn handle_open( match agents.launch(agent).await { Ok(session) => session, Err(refusal) => { - send_error(socket, None, refusal.to_string()).await; + send_error(socket, None, refusal_text(&refusal)).await; return true; } } @@ -361,6 +370,14 @@ async fn handle_open( attach(session, attached, subscriptions, socket).await } +/// The text of the error frame reporting a refused launch: the refusal +/// and its cause chain. A refusal's `Display` carries only its own +/// message, so a run log that cannot open would otherwise reach the +/// client as the bare "run log database" with the engine's diagnosis gone. +fn refusal_text(refusal: &LaunchRefusal) -> String { + display_chain(refusal) +} + /// Attaches the socket to `session`: subscribes the four channels /// (before the replay, so nothing lands between them unseen), /// acknowledges with the session frame, replays the session's @@ -469,3 +486,7 @@ async fn resend_unresolved(attached: &Attached, socket: &mut WebSocket) -> bool } true } + +#[cfg(test)] +#[path = "socket-tests.rs"] +mod tests; diff --git a/crates/workshop/server/src/agents/status-tests.rs b/crates/workshop/server/src/agents/status-tests.rs index 24f11ca04..bc25c6b27 100644 --- a/crates/workshop/server/src/agents/status-tests.rs +++ b/crates/workshop/server/src/agents/status-tests.rs @@ -1,3 +1,5 @@ +//! Agent status tests: which session events push a status frame and reset the backoff. + use std::time::Duration; use promptforge_api_types::ids::{ChainId, Provenance, TaskId}; diff --git a/crates/workshop/server/src/agents/status.rs b/crates/workshop/server/src/agents/status.rs index 1099ca1ac..8147c93ff 100644 --- a/crates/workshop/server/src/agents/status.rs +++ b/crates/workshop/server/src/agents/status.rs @@ -66,8 +66,10 @@ async fn relay( /// content, Thinking for the reasoning side channel. fn on_delta(delta: &Delta, push: &Push) { let activity = match delta.kind { - DeltaKind::Text => Activity::Generating, DeltaKind::Reasoning => Activity::Thinking, + // `Text`, or a side channel `harness-sessions` adds behind its + // `#[non_exhaustive]` `DeltaKind`: the agent is producing output. + _ => Activity::Generating, }; push.push_activity("Streaming response...", "an agent response chunk", activity); } diff --git a/crates/workshop/server/src/app-tests.rs b/crates/workshop/server/src/app-tests.rs index 65818230d..cf26dee7e 100644 --- a/crates/workshop/server/src/app-tests.rs +++ b/crates/workshop/server/src/app-tests.rs @@ -1,3 +1,5 @@ +//! App state tests: boot-time workspace reopening, auth headers, defaults, and route refusals. + use super::*; use axum::http::{HeaderMap, header}; diff --git a/crates/workshop/server/src/app.rs b/crates/workshop/server/src/app.rs index 61be9ad49..595fe7d75 100644 --- a/crates/workshop/server/src/app.rs +++ b/crates/workshop/server/src/app.rs @@ -22,7 +22,6 @@ use std::sync::Arc; use axum::Router; use harness_api::Harness; -use shared_progress::ProgressHub; use workshop_gateway::GatewayHandles; use workshop_menu::MenuHandles; @@ -358,7 +357,6 @@ fn compose( gateway.identity().cloned(), ) .map_err(StateError::Gateway)?; - let progress = Arc::new(ProgressHub::new()); let backoff = ReconnectBackoff::new(); let health = GatewayHealth::new(); let gateway_handles = GatewayHandles::new(gateway_binding, health.clone()); @@ -371,16 +369,8 @@ fn compose( // The background tasks register beside the state handles; the shell // spawns them from the registry's task vector when it starts // serving. - registrations.hold(workshop_status::register_tasks( - ®istry, - Arc::clone(&progress), - )); - let (heartbeat, subscriber) = workshop_gateway::register_tasks( - ®istry, - &gateway_handles, - Arc::clone(&progress), - backoff.clone(), - ); + let (heartbeat, subscriber) = + workshop_gateway::register_tasks(®istry, &gateway_handles, backoff.clone()); registrations.hold(heartbeat); registrations.hold(subscriber); // The workspace remembers its last-used file in the state directory; @@ -460,7 +450,7 @@ pub enum StateError { /// composition root itself is broken, so boot fails naming the /// absent type instead of panicking later at first use. #[non_exhaustive] - #[error("compose the subsystem registry: {0}")] + #[error("compose the subsystem registry")] Composition(#[from] workshop_registry::MissingContribution), } diff --git a/crates/workshop/server/src/fixtures.rs b/crates/workshop/server/src/fixtures.rs index b4b83366d..82e890e05 100644 --- a/crates/workshop/server/src/fixtures.rs +++ b/crates/workshop/server/src/fixtures.rs @@ -8,7 +8,7 @@ pub use crate::heartbeat::{GatewayHealth, Heartbeat}; pub use crate::menu::{MenuBus, MenuRefusal}; pub use crate::push::Push; pub use crate::status::StatusBus; -pub use workshop_protocol::{Activity, Progress, Severity, StatusBarUpdate}; +pub use workshop_protocol::{Activity, Severity, StatusBarUpdate}; pub use workshop_support::ReconnectBackoff; #[cfg(feature = "test-fixtures")] diff --git a/crates/workshop/server/src/lib.rs b/crates/workshop/server/src/lib.rs index 7084741b9..803e529bc 100644 --- a/crates/workshop/server/src/lib.rs +++ b/crates/workshop/server/src/lib.rs @@ -64,7 +64,7 @@ pub use workshop_gateway::{ gateway, gateway_binding, gateway_progress, heartbeat, observer, resolve, }; pub use workshop_menu::{catalog, menu}; -pub use workshop_status::{progress, status}; +pub use workshop_status::status; /// The intent-named push facade over the registry's producer sink slots: /// business code reports what happened and never chooses a severity or diff --git a/crates/workshop/server/src/routes/assets-tests.rs b/crates/workshop/server/src/routes/assets-tests.rs index 49a4bebe2..cea0fb70c 100644 --- a/crates/workshop/server/src/routes/assets-tests.rs +++ b/crates/workshop/server/src/routes/assets-tests.rs @@ -1,3 +1,5 @@ +//! Asset route tests: content types, cache headers, hashed bundles, and chunk-path confinement. + use axum::body::Body; use axum::http::{Request, Response, StatusCode, header}; use tower::ServiceExt; diff --git a/crates/workshop/server/src/routes/gateway_config-tests-recovery.rs b/crates/workshop/server/src/routes/gateway_config-tests-recovery.rs index 1a12115b7..dd41ebe96 100644 --- a/crates/workshop/server/src/routes/gateway_config-tests-recovery.rs +++ b/crates/workshop/server/src/routes/gateway_config-tests-recovery.rs @@ -1,3 +1,5 @@ +//! Gateway config recovery: the origin and config proxy follow one replacement snapshot. + use super::*; #[tokio::test] diff --git a/crates/workshop/server/src/routes/gateway_config-tests.rs b/crates/workshop/server/src/routes/gateway_config-tests.rs index 1c03fd4f5..972be79d1 100644 --- a/crates/workshop/server/src/routes/gateway_config-tests.rs +++ b/crates/workshop/server/src/routes/gateway_config-tests.rs @@ -1,3 +1,5 @@ +//! Gateway config proxy tests: the allowlist rule, forwarding with the bearer key, and refusals. + use super::*; use axum::body::Body; diff --git a/crates/workshop/server/src/serve-tests.rs b/crates/workshop/server/src/serve-tests.rs index 15b022681..28cae1a20 100644 --- a/crates/workshop/server/src/serve-tests.rs +++ b/crates/workshop/server/src/serve-tests.rs @@ -1,3 +1,5 @@ +//! Server lifecycle tests: readiness, graceful shutdown under held connections, and port release. + use super::*; use std::path::Path; diff --git a/crates/workshop/server/tests/it/boot.rs b/crates/workshop/server/tests/it/boot.rs index 589b135f2..23b6b512e 100644 --- a/crates/workshop/server/tests/it/boot.rs +++ b/crates/workshop/server/tests/it/boot.rs @@ -28,9 +28,13 @@ fn a_missing_required_contribution_fails_boot_naming_it() { let gateway = ResolvedGateway::from_config(&config.gateway); let error = state_with_gateway_omitting(&config, &gateway, Omit::Menu) .expect_err("boot fails when the menu subsystem never registers"); + // The composition error renders only its own frame; the absent + // contribution is named by its `source()`. + let cause = std::error::Error::source(&error) + .expect("the composition failure carries the registry's cause"); assert!( - error.to_string().contains("MenuHandles"), - "the failure names the missing contribution: {error}" + cause.to_string().contains("MenuHandles"), + "the failure's cause names the missing contribution: {error}: {cause}" ); } diff --git a/crates/workshop/server/tests/it/heartbeat_loop/recovery.rs b/crates/workshop/server/tests/it/heartbeat_loop/recovery.rs index 30315e03c..ca6b01832 100644 --- a/crates/workshop/server/tests/it/heartbeat_loop/recovery.rs +++ b/crates/workshop/server/tests/it/heartbeat_loop/recovery.rs @@ -1,3 +1,5 @@ +//! Heartbeat recovery: a replaced endpoint wakes the heartbeat and refreshes with its new key. + use super::*; /// Catalog route that accepts only the replacement sidecar bearer. diff --git a/crates/workshop/server/tests/it/heartbeat_loop/startup_convergence.rs b/crates/workshop/server/tests/it/heartbeat_loop/startup_convergence.rs index 2b0fd18c8..9f8e6590a 100644 --- a/crates/workshop/server/tests/it/heartbeat_loop/startup_convergence.rs +++ b/crates/workshop/server/tests/it/heartbeat_loop/startup_convergence.rs @@ -1,3 +1,5 @@ +//! Heartbeat startup convergence: the initial connect retries until catalog and profiles are ready. + use super::*; /// Startup state whose health is continuously true while its catalog diff --git a/crates/workshop/server/tests/it/session/menu.rs b/crates/workshop/server/tests/it/session/menu.rs index 9db5177f5..9a8f89cfd 100644 --- a/crates/workshop/server/tests/it/session/menu.rs +++ b/crates/workshop/server/tests/it/session/menu.rs @@ -75,29 +75,35 @@ fn profile_routes(active: Option<&'static str>) -> Router { .route("/v1/models", get(mock_models)) } -/// The `(label, current, total)` of every progress status frame in -/// `frames`, in order. -fn progress_ladder(frames: &[serde_json::Value]) -> Vec<(String, u64, u64)> { +/// The `(label, description)` of every busy status frame in `frames`, in +/// order: the frames that hold the status bar's barberpole up. +fn busy_frames(frames: &[serde_json::Value]) -> Vec<(String, String)> { frames .iter() - .filter(|frame| frame["type"] == "status" && !frame["progress"].is_null()) + .filter(|frame| frame["type"] == "status" && frame["busy"] == true) .map(|frame| { ( frame["label"] .as_str() - .expect("a progress frame carries a label") + .expect("a busy frame carries a label") + .to_string(), + frame["description"] + .as_str() + .expect("a busy frame carries a description") .to_string(), - frame["progress"]["current"] - .as_u64() - .expect("current is an integer"), - frame["progress"]["total"] - .as_u64() - .expect("total is an integer"), ) }) .collect() } +/// The one busy frame a switch to `name` pushes at its start. +fn switching(name: &str) -> (String, String) { + ( + "Switching profile...".to_string(), + format!("switching to {name}"), + ) +} + #[tokio::test] async fn a_select_model_event_round_trips_and_refusals_answer_errors() { let (url, _state_dir, state) = spawn_session_server("http://127.0.0.1:1").await; @@ -193,9 +199,9 @@ async fn a_selection_served_without_a_restart_completes_after_one_step() { "the pending snapshot marks the switch in flight: {pending}" ); assert_eq!( - progress_ladder(&frames), - [("Selecting profile...".to_string(), 1, 3)], - "a selection the gateway already serves climbs no restart steps" + busy_frames(&frames), + [switching("\"beta\"")], + "the switch holds the bar busy once, from its start until it settles" ); assert_eq!( received diff --git a/crates/workshop/server/tests/it/session/menu/restart.rs b/crates/workshop/server/tests/it/session/menu/restart.rs index 5f39818f3..ba0552914 100644 --- a/crates/workshop/server/tests/it/session/menu/restart.rs +++ b/crates/workshop/server/tests/it/session/menu/restart.rs @@ -26,7 +26,7 @@ use workshop_server::{ use crate::common::spawn_gateway; use super::super::{frames_until, spawn_session_server}; -use super::{profile_routes, progress_ladder, recording_switch}; +use super::{busy_frames, profile_routes, recording_switch, switching}; /// The bearer the sidecar fixture expects and the workshop presents. const SIDECAR_KEY: &str = "sidecar-key"; @@ -197,14 +197,13 @@ async fn a_sidecar_restart_climbs_the_ladder_and_refreshes_through_the_replaceme .send(tungstenite::Message::Text(switch.into())) .await .expect("the switch frame is sent"); + // The pending snapshot and the busy frame both precede the shutdown; + // the shutdown observation below is the sync point for the restart. let mut frames = frames_until(&mut socket, |frame| { - frame["type"] == "status" && frame["label"] == "Restarting gateway..." + frame["type"] == "workbench" && frame["switching"] == "beta" }) .await; - let pending = frames - .iter() - .find(|frame| frame["type"] == "workbench" && frame["switching"] == "beta") - .expect("the pending snapshot was pushed before the restart step"); + let pending = frames.last().expect("the pending snapshot was pushed"); assert_eq!( pending["switch_in_flight"], true, "the switch is marked in flight while the ladder climbs: {pending}" @@ -235,13 +234,9 @@ async fn a_sidecar_restart_climbs_the_ladder_and_refreshes_through_the_replaceme .await, ); assert_eq!( - progress_ladder(&frames), - [ - ("Selecting profile...".to_string(), 1, 3), - ("Restarting gateway...".to_string(), 2, 3), - ("Loading models...".to_string(), 3, 3), - ], - "the three steps arrive as determinate progress, in order" + busy_frames(&frames), + [switching("\"beta\"")], + "one busy frame holds the bar up across the whole restart ladder" ); let catalog = frames .iter() @@ -294,7 +289,7 @@ async fn a_replacement_that_never_appears_fails_within_the_bound() { .await .expect("the switch frame is sent"); frames_until(&mut socket, |frame| { - frame["type"] == "status" && frame["label"] == "Restarting gateway..." + frame["type"] == "status" && frame["busy"] == true }) .await; let (_gateway, shutdown_hit) = observe_shutdown(gateway).await; @@ -363,9 +358,13 @@ async fn a_lan_gateway_that_needs_a_restart_settles_with_the_notice() { ); assert_eq!(notice["severity"], "info", "the notice is not a failure"); assert_eq!( - progress_ladder(&frames), - [("Selecting profile...".to_string(), 1, 3)], - "no restart step runs against a LAN gateway" + busy_frames(&frames), + [switching("\"beta\"")], + "the bar goes busy once and the deferred notice ends it" + ); + assert_eq!( + notice["busy"], false, + "the notice is the non-busy frame that rests the bar" ); assert_eq!( received.lock().expect("the recorder lock is healthy").len(), diff --git a/crates/workshop/server/tests/it/session/status.rs b/crates/workshop/server/tests/it/session/status.rs index b0a8a5f79..d0f08fbb7 100644 --- a/crates/workshop/server/tests/it/session/status.rs +++ b/crates/workshop/server/tests/it/session/status.rs @@ -14,7 +14,7 @@ use serde_json::json; use tokio_tungstenite::tungstenite; use workshop_server::fixtures::{ - Activity, Progress, ReconnectBackoff, Severity, StatusBarUpdate, spawn_heartbeat, + Activity, ReconnectBackoff, Severity, StatusBarUpdate, spawn_heartbeat, }; use crate::common::{JsonSocket, TestServer, spawn_gateway}; @@ -47,7 +47,7 @@ async fn a_new_connection_receives_the_current_status_as_its_first_frame() { "type": "status", "label": "Gateway unreachable", "description": "the gateway does not answer its health probe", - "progress": null, + "busy": false, "severity": "info", "activity": "general", }), @@ -75,10 +75,7 @@ async fn status_updates_reach_connected_sessions_as_status_frames() { state.status().emit(StatusBarUpdate { label: "Downloading model".to_string(), description: "ggml-large-v3.bin".to_string(), - progress: Some(Progress { - current: 1, - total: 2, - }), + busy: true, severity: Severity::Info, activity: Activity::Generating, }); @@ -90,11 +87,11 @@ async fn status_updates_reach_connected_sessions_as_status_frames() { "type": "status", "label": "Downloading model", "description": "ggml-large-v3.bin", - "progress": {"current": 1, "total": 2}, + "busy": true, "severity": "info", "activity": "generating", }), - "the update arrives as one status frame" + "the update arrives as one status frame carrying the busy flag" ); socket.close(None).await.expect("close the socket"); } @@ -140,10 +137,7 @@ async fn a_new_session_receives_the_retained_status_and_catalog_snapshots() { state.status().emit(StatusBarUpdate { label: "Downloading model".to_string(), description: "ggml-large-v3.bin".to_string(), - progress: Some(Progress { - current: 1, - total: 2, - }), + busy: true, severity: Severity::Info, activity: Activity::Generating, }); @@ -163,7 +157,7 @@ async fn a_new_session_receives_the_retained_status_and_catalog_snapshots() { "type": "status", "label": "Downloading model", "description": "ggml-large-v3.bin", - "progress": {"current": 1, "total": 2}, + "busy": true, "severity": "info", "activity": "generating", }), diff --git a/crates/workshop/shell/src/main.rs b/crates/workshop/shell/src/main.rs index 3d301a1a7..fb8beb21b 100644 --- a/crates/workshop/shell/src/main.rs +++ b/crates/workshop/shell/src/main.rs @@ -1,9 +1,3 @@ -// Release builds are a GUI app: no console window when launched from the -// installer. Debug builds keep the console so the eprintln diagnostics show. -// The tradeoff: in release those diagnostics (boot errors) have nowhere to -// print. -#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] - //! The `promptforge-workshop` binary: the PromptForge Workshop desktop app. //! //! Hosts the workshop server in-process on a loopback listener with an @@ -19,6 +13,12 @@ //! that also stops a local gateway. Development against the standalone //! `workshop-server` binary flow is unchanged. +// Release builds are a GUI app: no console window when launched from the +// installer. Debug builds keep the console so the eprintln diagnostics show. +// The tradeoff: in release those diagnostics (boot errors) have nowhere to +// print. +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + // The only unsafe module in the crate: the WebView2 COM surface that // reads real OS paths out of dropped File objects and grants the // microphone has no safe wrapper. diff --git a/crates/workshop/status/Cargo.toml b/crates/workshop/status/Cargo.toml index 855d56a1c..48a5d46fb 100644 --- a/crates/workshop/status/Cargo.toml +++ b/crates/workshop/status/Cargo.toml @@ -6,10 +6,9 @@ edition.workspace = true license.workspace = true repository.workspace = true -description = "Workshop status subsystem: the status-bar broadcast bus and the progress renderer that drives the bar's indicator from the process progress hub" +description = "Workshop status subsystem: the status-bar broadcast bus every /ws session forwards from" [dependencies] -shared-progress.workspace = true tokio.workspace = true workshop-protocol.workspace = true workshop-registry.workspace = true diff --git a/crates/workshop/status/src/handles.rs b/crates/workshop/status/src/handles.rs index 12710b305..e21784be1 100644 --- a/crates/workshop/status/src/handles.rs +++ b/crates/workshop/status/src/handles.rs @@ -1,15 +1,12 @@ //! The status subsystem's registration: the consumer-side push channel //! every `/ws` session subscribes through, the producer-side sink -//! same-tier subsystems emit through, the bus itself as the subsystem's -//! state handle, and the progress renderer as its background task. +//! same-tier subsystems emit through, and the bus itself as the +//! subsystem's state handle. use std::sync::Arc; -use shared_progress::ProgressHub; - use workshop_registry::{ - BackgroundTaskAdapter, Registration, Registry, ShutdownHandle, StatusChannel, - StatusChannelAdapter, StatusSink, StatusSinkAdapter, + Registration, Registry, StatusChannel, StatusChannelAdapter, StatusSink, StatusSinkAdapter, }; use crate::StatusBus; @@ -42,19 +39,3 @@ pub fn register( let state = registry.register_state::(Arc::new(bus.clone())); (channel, sink, state) } - -/// Registers the subsystem's background task: the renderer that turns -/// the process progress hub's snapshots into the status bar's progress -/// indicator. The task spawns when the shell starts serving and stops -/// inside the graceful-shutdown signal. The returned guard keeps the -/// registration alive; the composition root holds it for the process -/// lifetime. -pub fn register_tasks(registry: &Registry, progress: Arc) -> Registration { - registry.register_task(Arc::new(BackgroundTaskAdapter::new({ - let registry = registry.clone(); - move || { - let renderer = crate::progress::spawn(Arc::clone(&progress), registry.push()); - ShutdownHandle::new(move || renderer.shutdown()) - } - }))) -} diff --git a/crates/workshop/status/src/lib.rs b/crates/workshop/status/src/lib.rs index d061eb1b3..15a737253 100644 --- a/crates/workshop/status/src/lib.rs +++ b/crates/workshop/status/src/lib.rs @@ -1,7 +1,7 @@ //! workshop-status - the status-bar subsystem: a broadcast bus carrying -//! status updates from every subsystem to every connected `/ws` session, -//! and the renderer task that turns the process progress hub's snapshots -//! into the status bar's progress indicator. +//! status updates from every subsystem to every connected `/ws` session. +//! Work in flight reaches the bar as a busy frame pushed by whichever +//! subsystem owns the work; this crate holds no progress machinery. //! //! ## Invariants //! @@ -22,7 +22,6 @@ pub mod status; pub mod handles; -pub mod progress; -pub use handles::{register, register_tasks}; +pub use handles::register; pub use status::StatusBus; diff --git a/crates/workshop/status/src/progress-tests.rs b/crates/workshop/status/src/progress-tests.rs deleted file mode 100644 index 3f65bbd91..000000000 --- a/crates/workshop/status/src/progress-tests.rs +++ /dev/null @@ -1,289 +0,0 @@ -use super::*; - -use tokio::sync::broadcast; - -use crate::StatusBus; -use workshop_protocol::{Progress, Severity, StatusBarUpdate}; -use workshop_registry::{Registration, Registry}; - -/// A hub, a push handle whose status sink is a real bus, the status -/// receiver the renderer's frames land on, and the registration -/// keeping the sink alive. -fn wired() -> ( - Arc, - Push, - broadcast::Receiver, - Registration, -) { - let hub = Arc::new(ProgressHub::new()); - let status = StatusBus::new(); - let rx = status.subscribe(); - let registry = Registry::new(); - let (_channel, sink, _state) = crate::register(®istry, &status); - (hub, registry.push(), rx, sink) -} - -/// Lets the renderer task run everything currently pending. -async fn settle() { - for _ in 0..3 { - tokio::task::yield_now().await; - } -} - -#[tokio::test(start_paused = true)] -async fn a_sub_second_operation_never_reaches_the_status_bar() { - let (hub, push, mut rx, _sink) = wired(); - let renderer = spawn(Arc::clone(&hub), push); - let tree = hub.operation(); - let leaf = tree.register("download", 1.0); - leaf.set_fraction(0.5); - settle().await; - leaf.complete(); - drop(tree); - settle().await; - tokio::time::advance(SHOW_DELAY * 2).await; - settle().await; - assert!( - rx.try_recv().is_err(), - "a sub-second operation must never show the indicator" - ); - renderer.shutdown().await; -} - -#[tokio::test(start_paused = true)] -async fn an_operation_outliving_the_show_delay_pushes_the_headline_and_aggregate() { - let (hub, push, mut rx, _sink) = wired(); - let renderer = spawn(Arc::clone(&hub), push); - let tree = hub.operation(); - let download = tree.register("download", 3.0); - let verify = tree.register("verify", 1.0); - download.set_fraction(1.0); - verify.set_fraction(0.5); - settle().await; - tokio::time::advance(SHOW_DELAY.saturating_sub(Duration::from_millis(1))).await; - settle().await; - assert!(rx.try_recv().is_err(), "the bar waits out the show delay"); - tokio::time::advance(Duration::from_millis(2)).await; - settle().await; - let update = rx - .try_recv() - .expect("the bar appears once the delay lapses"); - assert_eq!( - update.label, "verify", - "the headline is the unfinished leaf" - ); - assert_eq!( - update.progress, - Some(Progress { - current: 875_000, - total: PROGRESS_TOTAL, - }), - "the weighted aggregate: (3*1.0 + 1*0.5) / 4" - ); - assert_eq!(update.severity, Severity::Info); - assert_eq!(update.activity, Activity::General); - renderer.shutdown().await; -} - -#[tokio::test(start_paused = true)] -async fn the_indicator_holds_for_the_minimum_visible_time_after_the_hub_drains() { - let (hub, push, mut rx, _sink) = wired(); - let renderer = spawn(Arc::clone(&hub), push); - let tree = hub.operation(); - let leaf = tree.register("download", 1.0); - leaf.set_fraction(0.5); - settle().await; - tokio::time::advance(SHOW_DELAY).await; - settle().await; - let shown = rx - .try_recv() - .expect("the bar appears once the delay lapses"); - assert!(shown.progress.is_some()); - - drop(tree); - tokio::time::advance(DETACH_POLL).await; - settle().await; - assert!( - rx.try_recv().is_err(), - "the idle push waits out the minimum visible time" - ); - tokio::time::advance(MIN_VISIBLE).await; - settle().await; - let idle = rx - .try_recv() - .expect("the bar clears once the minimum has passed"); - assert_eq!(idle.label, "Ready"); - assert_eq!(idle.progress, None); - renderer.shutdown().await; -} - -#[tokio::test(start_paused = true)] -async fn a_lapsed_show_deadline_polls_for_the_detach_instead_of_rearming_the_past() { - let live = Instant::now(); - let indicator = Indicator { - live_since: Some(live), - ..Indicator::default() - }; - let now = live + SHOW_DELAY + Duration::from_secs(1); - assert_eq!( - indicator.next_wake(now), - Some(now + DETACH_POLL), - "a past deadline re-armed would resolve instantly and spin the loop" - ); -} - -#[tokio::test(start_paused = true)] -async fn a_tree_finished_before_the_delay_and_held_past_it_never_reaches_the_status_bar() { - let (hub, push, mut rx, _sink) = wired(); - let renderer = spawn(Arc::clone(&hub), push); - let tree = hub.operation(); - let leaf = tree.register("download", 1.0); - leaf.complete(); - settle().await; - tokio::time::advance(SHOW_DELAY * 2).await; - settle().await; - assert!( - rx.try_recv().is_err(), - "a finished tree has no headline, so the bar never shows" - ); - drop(tree); - tokio::time::advance(DETACH_POLL * 2).await; - settle().await; - assert!( - rx.try_recv().is_err(), - "a bar that never showed pushes no idle frame" - ); - renderer.shutdown().await; -} - -#[tokio::test(start_paused = true)] -async fn an_unchanged_sample_is_not_repushed_by_the_detach_poll() { - let (hub, push, mut rx, _sink) = wired(); - let renderer = spawn(Arc::clone(&hub), push); - let tree = hub.operation(); - let leaf = tree.register("download", 1.0); - leaf.set_fraction(0.5); - settle().await; - tokio::time::advance(SHOW_DELAY).await; - settle().await; - let first = rx - .try_recv() - .expect("the bar appears once the delay lapses"); - assert!(first.progress.is_some()); - tokio::time::advance(DETACH_POLL * 5).await; - settle().await; - assert!( - rx.try_recv().is_err(), - "the detach poll re-samples without re-pushing an unchanged frame" - ); - leaf.set_fraction(0.75); - settle().await; - let update = rx.try_recv().expect("a real change pushes"); - assert_eq!( - update.progress, - Some(Progress { - current: 750_000, - total: PROGRESS_TOTAL, - }) - ); - renderer.shutdown().await; -} - -#[tokio::test(start_paused = true)] -async fn an_operation_attached_before_the_spawn_is_caught_by_the_first_sample() { - let (hub, push, mut rx, _sink) = wired(); - // The tree attaches before the renderer subscribes: only the - // initial sample catches it, since its Begun predates the - // subscription. - let tree = hub.operation(); - let leaf = tree.register("download", 1.0); - leaf.set_fraction(0.5); - let renderer = spawn(Arc::clone(&hub), push); - settle().await; - tokio::time::advance(SHOW_DELAY).await; - settle().await; - let update = rx - .try_recv() - .expect("the pre-attached operation still reaches the bar"); - assert_eq!(update.label, "download"); - assert_eq!( - update.progress, - Some(Progress { - current: 500_000, - total: PROGRESS_TOTAL, - }) - ); - renderer.shutdown().await; -} - -#[tokio::test(start_paused = true)] -async fn a_lagged_event_receiver_resamples_from_the_snapshot() { - let (hub, push, mut rx, _sink) = wired(); - let renderer = spawn(Arc::clone(&hub), push); - // Overflow the hub's 1024-event ring so the renderer's receiver - // lags: the lag must fall through to a re-sample, not stall the - // renderer. - let tree = hub.operation(); - let _leaves: Vec<_> = (0..1100) - .map(|index| tree.register(&format!("leaf-{index}"), 1.0)) - .collect(); - settle().await; - tokio::time::advance(SHOW_DELAY).await; - settle().await; - let update = rx - .try_recv() - .expect("the bar still appears after the receiver lagged"); - assert!( - update.progress.is_some(), - "the re-sampled snapshot drives the bar" - ); - renderer.shutdown().await; -} - -#[tokio::test(start_paused = true)] -async fn an_operation_attaching_during_the_minimum_visible_hold_does_not_step_the_bar_backward() { - let (hub, push, mut rx, _sink) = wired(); - let renderer = spawn(Arc::clone(&hub), push); - let tree = hub.operation(); - let leaf = tree.register("first", 1.0); - leaf.set_fraction(0.5); - settle().await; - tokio::time::advance(SHOW_DELAY).await; - settle().await; - let shown = rx - .try_recv() - .expect("the bar appears once the delay lapses"); - assert_eq!( - shown.progress, - Some(Progress { - current: 500_000, - total: PROGRESS_TOTAL, - }) - ); - - // The first operation drains while the bar is up; the minimum - // visible hold keeps the bar showing. - drop(tree); - tokio::time::advance(DETACH_POLL).await; - settle().await; - - // A new operation attaching during the hold continues from the - // drained level: the meter reset on the idle sample, so without - // the floor the visible bar would restart at zero. - let second = hub.operation(); - let _leaf = second.register("second", 1.0); - settle().await; - let continued = rx - .try_recv() - .expect("the new operation pushes under the held bar"); - assert_eq!(continued.label, "second"); - assert_eq!( - continued.progress, - Some(Progress { - current: 500_000, - total: PROGRESS_TOTAL, - }), - "the bar never steps backward" - ); - renderer.shutdown().await; -} diff --git a/crates/workshop/status/src/progress.rs b/crates/workshop/status/src/progress.rs deleted file mode 100644 index ac011fa34..000000000 --- a/crates/workshop/status/src/progress.rs +++ /dev/null @@ -1,215 +0,0 @@ -//! The hub-to-status-bar renderer: a task that samples the process -//! [`ProgressHub`] and drives the status bar's progress indicator through -//! [`Push`], so the anti-flicker policy lives here and the UI stays dumb. -//! -//! The indicator appears only once an operation has been live for -//! `SHOW_DELAY`, stays up at least `MIN_VISIBLE` once shown, and -//! displays the monotonic aggregate from [`ProgressMeter`]: the bar never -//! flashes for sub-second work, never resets mid-operation, and never -//! steps backward. Status texts ("Listening...", failures) stay explicit -//! push calls in the subsystems that own them; the trees own only -//! fractional progress. When the hub's last tree detaches, the renderer -//! returns the bar to rest with [`Push::push_idle`]. - -use std::sync::Arc; -use std::time::Duration; - -use tokio::sync::broadcast::error::RecvError; -use tokio::sync::oneshot; -use tokio::time::Instant; - -use shared_progress::{ProgressHub, ProgressMeter}; - -use workshop_protocol::Activity; -use workshop_registry::Push; - -/// How long an operation must be live before the indicator appears; work -/// shorter than this never disturbs the status bar. -pub(crate) const SHOW_DELAY: Duration = Duration::from_secs(1); - -/// How long the indicator stays up once shown, so an operation that ends -/// just past [`SHOW_DELAY`] still reads as a completed bar, not a flash. -pub(crate) const MIN_VISIBLE: Duration = Duration::from_millis(500); - -/// How often the renderer re-samples while the indicator is up: a tree's -/// detach emits no event, so only a poll notices the last tree leaving. -const DETACH_POLL: Duration = Duration::from_millis(100); - -/// The `total` every pushed frame carries; fractions quantize to -/// millionths of it. -const PROGRESS_TOTAL: u64 = 1_000_000; - -/// A running renderer task. -/// -/// [`Renderer::shutdown`] signals the task to stop and awaits it. Dropping -/// the handle without shutting down still stops the task at its next -/// select point, because the closed channel resolves the stop branch. -#[derive(Debug)] -pub struct Renderer { - stop: Option>, - task: Option>, -} - -impl Renderer { - /// Signals the renderer to stop and waits for its task to finish. - pub async fn shutdown(mut self) { - if let Some(stop) = self.stop.take() { - let _ = stop.send(()); - } - if let Some(task) = self.task.take() { - let _ = task.await; - } - } -} - -/// Spawns the renderer task against `hub`, pushing through `push`. -#[must_use] -pub fn spawn(hub: Arc, push: Push) -> Renderer { - let (stop, mut stopped) = oneshot::channel(); - let task = tokio::spawn(async move { - run(&hub, &push, &mut stopped).await; - }); - Renderer { - stop: Some(stop), - task: Some(task), - } -} - -/// The task loop: re-sample on every hub event and on every anti-flicker -/// deadline. A lagged receiver simply re-samples - snapshots are the -/// ground truth and intermediate events are lossy by design. -async fn run(hub: &ProgressHub, push: &Push, stop: &mut oneshot::Receiver<()>) { - let mut events = hub.subscribe(); - let mut indicator = Indicator::default(); - // Catches operations that attached before the subscription. - indicator.update(hub, push); - loop { - tokio::select! { - _ = &mut *stop => break, - event = events.recv() => { - // The hub lives in AppState for the process lifetime, so - // Closed cannot occur in production; treat it as a stop. - if matches!(event, Err(RecvError::Closed)) { - break; - } - } - () = wake_at(indicator.next_wake(Instant::now())) => {} - } - indicator.update(hub, push); - } -} - -/// Waits for `at`, or forever when there is no pending deadline. -async fn wake_at(at: Option) { - match at { - Some(at) => tokio::time::sleep_until(at).await, - None => std::future::pending().await, - } -} - -/// The anti-flicker state machine over the hub's snapshots. -#[derive(Debug, Default)] -struct Indicator { - meter: ProgressMeter, - /// When the current run of live operations began; back-to-back - /// operations share one run, so the bar never flickers between them. - live_since: Option, - /// When the indicator was shown; held until the idle push lands. - shown_since: Option, - /// The last frame pushed, so the detach poll never re-pushes an - /// unchanged sample. - last_frame: Option<(String, u64)>, -} - -impl Indicator { - /// Samples the hub and pushes whatever transition the sample calls for. - fn update(&mut self, hub: &ProgressHub, push: &Push) { - let now = Instant::now(); - let Some(fraction) = self.meter.sample(hub) else { - self.live_since = None; - if let Some(shown) = self.shown_since - && now.duration_since(shown) >= MIN_VISIBLE - { - self.shown_since = None; - self.last_frame = None; - push.push_idle(); - } - return; - }; - let live_since = *self.live_since.get_or_insert(now); - if self.shown_since.is_none() && now.duration_since(live_since) < SHOW_DELAY { - return; - } - // Every leaf finished but the tree still lives: the detach (and - // the idle push) is imminent, so the last frame stands. - let Some(label) = hub.headline() else { - return; - }; - self.shown_since.get_or_insert(now); - let current = quantize(fraction); - // The meter resets its high-water mark on an idle sample, so a new - // operation attaching while the bar holds for MIN_VISIBLE after a - // drain would restart the visible bar at the new operation's zero. - // The last pushed frame is the floor until the new operation rises - // past it: within a run the meter is already monotonic, so the floor - // only ever bites across a drain. - let current = self - .last_frame - .as_ref() - .map_or(current, |(_, shown)| current.max(*shown)); - if self - .last_frame - .as_ref() - .is_some_and(|(l, c)| l == &label && *c == current) - { - return; - } - self.last_frame = Some((label.clone(), current)); - push.push_progress( - label.clone(), - label, - current, - PROGRESS_TOTAL, - Activity::General, - ); - } - - /// The next moment `update` can change state without a hub event: the - /// show deadline while an operation warms up, the detach poll while - /// the indicator is up, or the earliest idle moment once the hub has - /// drained under a still-visible bar. - fn next_wake(&self, now: Instant) -> Option { - match (self.live_since, self.shown_since) { - (Some(live), None) => { - let deadline = live + SHOW_DELAY; - // A lapsed deadline with the bar unshown means every leaf - // finished but the tree still lives; poll for the detach - // instead of re-arming a past instant, which would spin - // the select loop. - Some(if deadline > now { - deadline - } else { - now + DETACH_POLL - }) - } - (Some(_), Some(_)) => Some(now + DETACH_POLL), - (None, Some(shown)) => Some(shown + MIN_VISIBLE), - (None, None) => None, - } - } -} - -/// Quantizes a `0.0..=1.0` fraction to `current` of [`PROGRESS_TOTAL`]. -#[expect( - clippy::cast_possible_truncation, - clippy::cast_sign_loss, - clippy::cast_precision_loss, - reason = "the clamped fraction lands the cast in 0..=PROGRESS_TOTAL" -)] -fn quantize(fraction: f64) -> u64 { - (fraction.clamp(0.0, 1.0) * PROGRESS_TOTAL as f64).round() as u64 -} - -#[cfg(test)] -#[path = "progress-tests.rs"] -mod tests; diff --git a/crates/workshop/status/src/status.rs b/crates/workshop/status/src/status.rs index 7cb240440..cc5f0db8e 100644 --- a/crates/workshop/status/src/status.rs +++ b/crates/workshop/status/src/status.rs @@ -18,7 +18,7 @@ use tokio::sync::broadcast; -use workshop_protocol::{Activity, Progress, Severity, StatusBarUpdate}; +use workshop_protocol::{Activity, Severity, StatusBarUpdate}; use workshop_support::RetainedBus; /// Ring capacity of the status bus. Covers a startup burst plus an agent @@ -63,7 +63,7 @@ impl StatusBus { self.bus.send(update); } - /// Broadcasts one progress-free update at the given severity. + /// Broadcasts one non-busy update at the given severity. pub fn report( &self, label: impl Into, @@ -74,7 +74,7 @@ impl StatusBus { self.emit(StatusBarUpdate { label: label.into(), description: description.into(), - progress: None, + busy: false, severity, activity, }); @@ -90,24 +90,6 @@ impl StatusBus { self.report(label, description, Severity::Info, activity); } - /// Broadcasts a user-visible update carrying determinate progress, - /// which the status bar renders as its progress bar. - pub fn progress( - &self, - label: impl Into, - description: impl Into, - progress: Progress, - activity: Activity, - ) { - self.emit(StatusBarUpdate { - label: label.into(), - description: description.into(), - progress: Some(progress), - severity: Severity::Info, - activity, - }); - } - /// Broadcasts an internal instrumentation pulse the UI does not /// display. pub fn debug( diff --git a/crates/workshop/ui/src/parts/status/status-bar.ts b/crates/workshop/ui/src/parts/status/status-bar.ts index d7ac54de2..882dc3c5c 100644 --- a/crates/workshop/ui/src/parts/status/status-bar.ts +++ b/crates/workshop/ui/src/parts/status/status-bar.ts @@ -1,11 +1,12 @@ // The status bar renderer: consumes the observer's status frames off the // persistent socket and paints them into the shared status bar shell // (shared-ui/status-bar), which owns the bar, the text region, and the -// slot's progress/indicators swap. Info and error frames set the text -// (the description rides as the tooltip) and drive the slot; debug frames -// are internal instrumentation: they never touch the text or the slot, -// but they do pulse the LED. The workshop's indicators group holds the -// recording and activity LEDs; the shell's extras region stays empty. +// busy barberpole beside the indicators. Info and error frames set the +// text (the description rides as the tooltip) and drive the barberpole; +// debug frames are internal instrumentation: they never touch the text +// or the barberpole, but they do pulse the LED. The workshop's +// indicators group holds the recording and activity LEDs; the shell's +// extras region stays empty. import { createStatusBarShell, type StatusBarShell } from "shared-ui/status-bar"; @@ -86,7 +87,7 @@ export class StatusBar extends Disposable { tooltip: frame.description, error: frame.severity === "error", }); - this.shell.renderSlot(frame.progress); + this.shell.setBusy(frame.busy); } /** @@ -134,7 +135,7 @@ export class StatusBar extends Disposable { /** * Clears every LED activity state - sustained and pulsed - and applies * the idle lens. Only the activity LED is touched: the text, tooltip, - * progress, and recording LED belong to other flows. Used when a chat is + * barberpole, and recording LED belong to other flows. Used when a chat is * aborted, because the recycled socket never sees the server's terminal * status frame for the aborted chat. */ @@ -155,13 +156,13 @@ export class StatusBar extends Disposable { /** * Returns the bar to its reconnecting state after the persistent socket - * drops: neutral text, no tooltip, no error styling, and the indicators - * group back in the slot. + * drops: neutral text, no tooltip, no error styling, and the barberpole + * hidden. */ reset(): void { this.sustained = null; this.shell.setText("Reconnecting..."); - this.shell.renderSlot(null); + this.shell.setBusy(false); } /** Applies the lit set: green wins while generating and thinking coincide. */ diff --git a/crates/workshop/ui/src/services/protocol.ts b/crates/workshop/ui/src/services/protocol.ts index c0be9beac..9de75a444 100644 --- a/crates/workshop/ui/src/services/protocol.ts +++ b/crates/workshop/ui/src/services/protocol.ts @@ -18,7 +18,11 @@ export interface StatusFrame { description: string; severity: "info" | "debug" | "error"; activity: "general" | "thinking" | "generating"; - progress: { current: number; total: number } | null; + /** + * Whether work is in flight: the status bar shows its barberpole while + * set. The label says what the work is; there is no fraction on the wire. + */ + busy: boolean; } /** One entry of the gateway's model catalog, as fetched or pushed. */ diff --git a/crates/workshop/ui/test/barberpole-beside-indicators.mjs b/crates/workshop/ui/test/barberpole-beside-indicators.mjs new file mode 100644 index 000000000..8456c1b2a --- /dev/null +++ b/crates/workshop/ui/test/barberpole-beside-indicators.mjs @@ -0,0 +1,52 @@ +// The recording LED and activity LED stand in one indicators group beside +// the barberpole, never behind it: a busy frame shows the barberpole +// and leaves the group and both LEDs visible, the barberpole precedes the +// group in DOM order, and a non-busy frame hides the barberpole alone. +// Run: node test/barberpole-beside-indicators.mjs (after `npm run build`). +import { bootWorkbench } from "./helpers/boot.mjs"; + +await bootWorkbench("the barberpole shows beside the recording and activity LEDs", async (ctx) => { + const { emitStatus, barberpoleEl, indicatorsEl, recEl, ledEl, failures } = ctx; + if (!indicatorsEl) { + failures.push("status bar indicators group missing"); + return; + } + if (!barberpoleEl) { + failures.push("status bar barberpole missing"); + return; + } + if (indicatorsEl.hidden) { + failures.push("the indicators group must start visible"); + } + if (!barberpoleEl.hidden) { + failures.push("the barberpole must start hidden"); + } + const following = barberpoleEl.compareDocumentPosition(indicatorsEl); + if ((following & barberpoleEl.DOCUMENT_POSITION_FOLLOWING) === 0) { + failures.push("the barberpole does not precede the indicators group in DOM order"); + } + + emitStatus({ + label: "Downloading model", + description: "1 of 2", + activity: "general", + busy: true, + }); + if (barberpoleEl.hidden) { + failures.push("a busy frame did not reveal the barberpole"); + } + if (indicatorsEl.hidden) { + failures.push("a busy frame hid the recording and activity LED group"); + } + if (recEl.hidden || ledEl.hidden) { + failures.push("a busy frame hid an LED individually"); + } + + emitStatus({ label: "Download complete", description: "ready" }); + if (!barberpoleEl.hidden) { + failures.push("a non-busy frame did not hide the barberpole"); + } + if (indicatorsEl.hidden) { + failures.push("a non-busy frame hid the recording and activity LED group"); + } +}); diff --git a/crates/workshop/ui/test/boot-queue.mjs b/crates/workshop/ui/test/boot-queue.mjs index fbb8788df..8a94af089 100644 --- a/crates/workshop/ui/test/boot-queue.mjs +++ b/crates/workshop/ui/test/boot-queue.mjs @@ -82,7 +82,7 @@ function statusFrame(label) { description: "", severity: "info", activity: null, - progress: null, + busy: false, }; } diff --git a/crates/workshop/ui/test/disposable-adoption.mjs b/crates/workshop/ui/test/disposable-adoption.mjs index e44581ce1..edb2bbdf7 100644 --- a/crates/workshop/ui/test/disposable-adoption.mjs +++ b/crates/workshop/ui/test/disposable-adoption.mjs @@ -203,7 +203,7 @@ const generatingFrame = { description: "", severity: "info", activity: "generating", - progress: null, + busy: false, }; statusBar.render(generatingFrame); check( @@ -332,7 +332,7 @@ const statusFrame = { description: "", severity: "info", activity: null, - progress: null, + busy: false, }; fakeSockets[0].message(statusFrame); check( diff --git a/crates/workshop/ui/test/helpers/boot.mjs b/crates/workshop/ui/test/helpers/boot.mjs index 50f0827e8..a7be98602 100644 --- a/crates/workshop/ui/test/helpers/boot.mjs +++ b/crates/workshop/ui/test/helpers/boot.mjs @@ -351,7 +351,7 @@ export async function bootWorkbench(name, run, options = {}) { 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 barberpoleEl = window.document.querySelector(".status-bar__barberpole"); const indicatorsEl = window.document.querySelector(".status-bar__indicators"); const ledEl = window.document.querySelector(".status-bar__led:not(.status-bar__led--rec)"); const recEl = window.document.querySelector(".status-bar__led--rec"); @@ -408,7 +408,7 @@ export async function bootWorkbench(name, run, options = {}) { description: "", severity: "info", activity: "general", - progress: null, + busy: false, ...overrides, }), }); @@ -469,7 +469,7 @@ export async function bootWorkbench(name, run, options = {}) { statusBar, statusText, statusSlot, - progressEl, + barberpoleEl, indicatorsEl, ledEl, recEl, diff --git a/crates/workshop/ui/test/progress-swap-indicators.mjs b/crates/workshop/ui/test/progress-swap-indicators.mjs deleted file mode 100644 index 1846e57f9..000000000 --- a/crates/workshop/ui/test/progress-swap-indicators.mjs +++ /dev/null @@ -1,40 +0,0 @@ -// The recording LED and activity LED live in one indicators group that -// swaps out as a unit behind the progress bar: a progress frame hides the -// group (not its members individually), and clearing progress restores it. -// Run: node test/progress-swap-indicators.mjs (after `npm run build`). -import { bootWorkbench } from "./helpers/boot.mjs"; - -await bootWorkbench("the recording and activity LEDs swap out as one group behind the progress bar", async (ctx) => { - const { emitStatus, progressEl, indicatorsEl, recEl, ledEl, failures } = ctx; - if (!indicatorsEl) { - failures.push("status bar indicators group missing"); - return; - } - if (indicatorsEl.hidden) { - failures.push("the indicators group must start visible"); - } - - emitStatus({ - label: "Downloading model", - description: "1 of 2", - activity: "general", - progress: { current: 1, total: 2 }, - }); - if (!indicatorsEl.hidden) { - failures.push("a progress frame did not hide the recording and activity LED group"); - } - if (progressEl.hidden) { - failures.push("a progress frame did not reveal the progress bar"); - } - if (recEl.hidden || ledEl.hidden) { - failures.push("the swap hid an LED individually instead of the group"); - } - - emitStatus({ label: "Download complete", description: "ready" }); - if (indicatorsEl.hidden) { - failures.push("clearing progress did not restore the recording and activity LED group"); - } - if (!progressEl.hidden) { - failures.push("clearing progress did not hide the progress bar"); - } -}); diff --git a/crates/workshop/ui/test/shared-status-bar.mjs b/crates/workshop/ui/test/shared-status-bar.mjs index 71dc3f48c..ac52dc308 100644 --- a/crates/workshop/ui/test/shared-status-bar.mjs +++ b/crates/workshop/ui/test/shared-status-bar.mjs @@ -1,9 +1,10 @@ // Unit test for the shared status bar shell (shared-ui/status-bar.ts): -// the slot swap between the inline progress bar and the consumer's -// indicators group (progress wins, null restores, the group's contents -// survive the swap), the zero-total clamp, the text region's label, -// tooltip, and error styling, and the extras region the consumers fill. -// Bundles the module with esbuild and drives it against jsdom. +// the barberpole beside the consumer's indicators group (setBusy shows +// and hides the barberpole, the group stays visible throughout and keeps +// its contents, the barberpole precedes the group in DOM order), the +// text region's label, tooltip, and error styling, and the extras region +// the consumers fill. Bundles the module with esbuild and drives it +// against jsdom. // Run: node test/shared-status-bar.mjs. import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -44,7 +45,7 @@ function check(name, condition) { const shell = createStatusBarShell(); window.document.body.append(shell.element); -// A consumer's indicator: the swap must never touch its contents. +// A consumer's indicator: busy toggling must never touch its contents. const led = window.document.createElement("span"); led.className = "status-bar__led"; shell.indicators.append(led); @@ -53,32 +54,45 @@ shell.indicators.append(led); check("the element is the status-bar footer", shell.element.matches("footer.status-bar")); check("the bar is a polite live region", shell.element.getAttribute("aria-live") === "polite"); -check("the progress bar starts hidden", shell.progress.hidden === true); +check( + "the barberpole is the shell's element of that class", + shell.barberpole === shell.element.querySelector(".status-bar__barberpole"), +); +check("the barberpole starts hidden", shell.barberpole.hidden === true); check("the indicators group starts visible", shell.indicators.hidden === false); +check( + "the barberpole sits in the right group", + shell.barberpole.parentElement?.matches(".status-bar__right") === true, +); +check( + "the barberpole precedes the indicators group in DOM order", + (shell.barberpole.compareDocumentPosition(shell.indicators) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0, +); +check( + "the barberpole is an indeterminate progressbar to assistive tech", + shell.barberpole.getAttribute("role") === "progressbar" && + !shell.barberpole.hasAttribute("aria-valuenow"), +); +check("no element remains in the shell", shell.element.querySelector("progress") === null); check("the text region starts empty", shell.text.textContent === ""); check("the extras region is empty until the consumer fills it", shell.extras.childElementCount === 0); -// --- The slot swap -------------------------------------------------------------- - -shell.renderSlot({ current: 1, total: 4 }); -check("a reading reveals the progress bar", shell.progress.hidden === false); -check("a reading hides the indicators group", shell.indicators.hidden === true); -check( - "the bar shows the reading", - shell.progress.value === 1 && shell.progress.max === 4, -); -check("the swap kept the consumer's LED in the group", shell.indicators.contains(led)); +// --- The busy toggle -------------------------------------------------------------- -shell.renderSlot({ current: 2, total: 4 }); -check("a second reading updates the bar in place", shell.progress.value === 2); +shell.setBusy(true); +check("setBusy(true) shows the barberpole", shell.barberpole.hidden === false); +check("setBusy(true) leaves the indicators group visible", shell.indicators.hidden === false); +check("setBusy(true) kept the consumer's LED in the group", shell.indicators.contains(led)); -shell.renderSlot({ current: 0, total: 0 }); -check("a zero total clamps max so value/max stay valid", shell.progress.max === 1); +shell.setBusy(true); +check("a repeated setBusy(true) keeps the barberpole shown", shell.barberpole.hidden === false); -shell.renderSlot(null); -check("clearing progress hides the bar", shell.progress.hidden === true); -check("clearing progress restores the indicators group", shell.indicators.hidden === false); -check("the restored group still carries the consumer's LED", shell.indicators.contains(led)); +shell.setBusy(false); +check("setBusy(false) hides the barberpole", shell.barberpole.hidden === true); +check("setBusy(false) leaves the indicators group visible", shell.indicators.hidden === false); +check("the group still carries the consumer's LED", shell.indicators.contains(led)); +check("the shell exposes no renderSlot", typeof shell.renderSlot === "undefined"); +check("the shell exposes no progress element", typeof shell.progress === "undefined"); // --- The text region -------------------------------------------------------------- diff --git a/crates/workshop/ui/test/status-frames.mjs b/crates/workshop/ui/test/status-frames.mjs index 0fb5ca5ad..3c6c8969f 100644 --- a/crates/workshop/ui/test/status-frames.mjs +++ b/crates/workshop/ui/test/status-frames.mjs @@ -1,15 +1,15 @@ // Status frames render into the status bar. Text and tooltip: info and // error frames set the bar text and description tooltip, error frames style // the text and the styling clears on the next info frame, and debug frames -// are internal instrumentation that must not touch either. Progress: a -// non-null progress renders the bar in the slot at the frame's fraction and -// hides the recording+activity LED indicators group; a null progress removes -// the bar and restores the group; debug frames never disturb the slot. +// are internal instrumentation that must not touch either. Busy: a busy +// frame shows the barberpole beside the recording+activity LED indicators +// group, which stays visible; a non-busy frame hides the barberpole; debug +// frames never disturb it. // Run: node test/status-frames.mjs (after `npm run build`). import { bootWorkbench } from "./helpers/boot.mjs"; await bootWorkbench("status frames render into the bar", async (ctx) => { - const { emitStatus, statusText, statusBar, progressEl, indicatorsEl, failures } = ctx; + const { emitStatus, statusText, statusBar, barberpoleEl, indicatorsEl, failures } = ctx; emitStatus({ label: "Streaming response...", @@ -50,28 +50,25 @@ await bootWorkbench("status frames render into the bar", async (ctx) => { label: "Downloading model", description: "1 of 4", activity: "general", - progress: { current: 1, total: 4 }, + busy: true, }); - if (progressEl.hidden) failures.push("a progress frame did not reveal the progress bar"); - if (progressEl.value !== 1 || progressEl.max !== 4) { - failures.push(`progress bar shows ${progressEl.value}/${progressEl.max}, expected 1/4`); - } - if (!indicatorsEl.hidden) { - failures.push("the recording and activity LED group did not hide while progress is showing"); + if (barberpoleEl.hidden) failures.push("a busy frame did not reveal the barberpole"); + if (indicatorsEl.hidden) { + failures.push("the recording and activity LED group hid while the barberpole is showing"); } emitStatus({ label: "Downloading model", description: "2 of 4", activity: "general", - progress: { current: 2, total: 4 }, + busy: true, }); emitStatus({ label: "per-delta pulse", severity: "debug", activity: "generating" }); - if (progressEl.hidden || progressEl.value !== 2) { - failures.push("a debug frame disturbed the progress bar"); + if (barberpoleEl.hidden) { + failures.push("a debug frame disturbed the barberpole"); } emitStatus({ label: "Download complete", description: "ready" }); - if (!progressEl.hidden) failures.push("a null-progress frame did not hide the progress bar"); + if (!barberpoleEl.hidden) failures.push("a non-busy frame did not hide the barberpole"); if (indicatorsEl.hidden) { - failures.push("the recording and activity LED group did not return when progress cleared"); + failures.push("the recording and activity LED group hid when busy cleared"); } }); diff --git a/crates/workshop/ui/test/workbench-frames.mjs b/crates/workshop/ui/test/workbench-frames.mjs index 46fceecd1..f6a880f29 100644 --- a/crates/workshop/ui/test/workbench-frames.mjs +++ b/crates/workshop/ui/test/workbench-frames.mjs @@ -96,7 +96,7 @@ function statusFrame(label) { description: "", severity: "info", activity: null, - progress: null, + busy: false, }; } diff --git a/crates/workshop/ui/test/workbench-mount.mjs b/crates/workshop/ui/test/workbench-mount.mjs index f68e474bf..0655f8a29 100644 --- a/crates/workshop/ui/test/workbench-mount.mjs +++ b/crates/workshop/ui/test/workbench-mount.mjs @@ -3,14 +3,14 @@ // panel (its menu visible, its session view hidden until a session is // acknowledged, its input pinned closed, its toolbar reading the shared // model service's snapshot selection), and the status bar boots as a -//