From d0eed96ba8117d788a68910fd382a983b996bc42 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Sun, 20 Sep 2026 06:19:28 -0700 Subject: [PATCH 01/39] Gate the child's store write in the cancel-wait test The cancel-wait test that parks a child on a store write raced the blocking pool: whether the child's write finished before the owner's cancel landed was the pool's choice, so the outcome depended on how late that thread started. The test now parks the first backend write behind a one-shot gate that opens only when the child's cancellation is observed, so the run-end drain completes regardless of scheduling. The gate and its observer, previously private to the arm-conflict tests, are shared with the sibling test module, and the observer now opens on a task cancellation as well as on a write or append failure. - `StoreGate`, `GateObserver`, and `gated_store` widen from private to `pub(super)` so the cancel-wait suite reuses the same one-shot gate instead of growing its own. - `GateObserver` opens the gate from `observe` on `Observation::TaskCancelled { .. }` in addition to `Observation::StoreWriteFailed` and `Observation::StoreAppendFailed`; every observation still forwards to `inner`. - `cancel_ends_a_parked_task_idempotently_and_reports_task_cancelled_once` builds its context on `gated_store(&gate)` and wraps the recorder in `GateObserver::new`. The owner's yield before the cancel changes from `store.write('park', 'x')` to `store.exists('park')` so the owner never takes the gate meant for the child's write. - `crates/promptforge-api-runtime/src/execute/tests/scheduler.rs` and `crates/promptforge-api-runtime/src/execute/tests/waits.rs` are the only code touched; no production module changes and no new test is added. Design: new shared-mutable-state @ crates/promptforge-api-runtime/src/execute/tests/waits.rs::cancel_ends_a_parked_task_idempotently_and_reports_task_cancelled_once Plan: vibe/2026-09-20-2-rust-rulebook-sweep.md --- .../src/execute/tests/scheduler.rs | 39 +- .../src/execute/tests/waits.rs | 16 +- vibe/2026-09-20-2-rust-rulebook-sweep.md | 567 ++++++++++++++++++ vibe/ACTIVE | 1 + 4 files changed, 602 insertions(+), 21 deletions(-) create mode 100644 vibe/2026-09-20-2-rust-rulebook-sweep.md create mode 100644 vibe/ACTIVE diff --git a/crates/promptforge-api-runtime/src/execute/tests/scheduler.rs b/crates/promptforge-api-runtime/src/execute/tests/scheduler.rs index bd9308a59..587c0e3dd 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( diff --git a/crates/promptforge-api-runtime/src/execute/tests/waits.rs b/crates/promptforge-api-runtime/src/execute/tests/waits.rs index a52b07980..655e89160 100644 --- a/crates/promptforge-api-runtime/src/execute/tests/waits.rs +++ b/crates/promptforge-api-runtime/src/execute/tests/waits.rs @@ -11,7 +11,7 @@ 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; @@ -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/vibe/2026-09-20-2-rust-rulebook-sweep.md b/vibe/2026-09-20-2-rust-rulebook-sweep.md new file mode 100644 index 000000000..5d48ab631 --- /dev/null +++ b/vibe/2026-09-20-2-rust-rulebook-sweep.md @@ -0,0 +1,567 @@ +--- +name: Rust rulebook sweep +overview: Apply the Rust rulebook across the promptforge workspace on master, one commit per finding class, keeping every repo convention documented in AGENTS.md. Sized from five repo-wide scans of 611 production files. +todos: + - id: step-1 + content: "Step 1 (ci-baseline): gate the child's store write in cancel_ends_a_parked_task_idempotently; 20 consecutive passes" + status: pending + - id: step-2 + content: "Step 2 (async-fs): spawn_blocking for workshop-workspace fs work and harness-sessions Runtime::launch; move UI_STATE_VALUE_CAP re-export" + status: pending + - id: step-3 + content: "Step 3 (emitter-flags): DebugMode and OutputTrust replace Emitter bool params" + status: pending + - id: step-4 + content: "Step 4 (error-model): strip {source} from 15 Display sites and rewire renderers; newtype 5 leaked foreign error types with manual From; 13 message fixes" + status: pending + - id: step-5 + content: "Step 5 (error-model): #[non_exhaustive] on 10 error enums, ~17 wire enums, events! macro, response/event structs" + status: pending + - id: step-6 + content: "Step 6 (error-model): attach causes at ~62 non-lua .map_err(|_|) sites; report sites left" + status: pending + - id: step-7 + content: "Step 7 (layout-lint-hygiene): retire 11 mod.rs files by pure moves, form per three-file rule" + status: pending + - id: step-8 + content: "Step 8 (layout-lint-hygiene): allow->expect with reason, 3 test-only re-exports, 2 doc unwraps, vm.rs fences, get_all rename, must_use, doubled cfg" + status: pending + - id: step-9 + content: "Step 9 (layout-lint-hygiene): hollow parser lib.rs into modules; shared-loopback if over 500; rename types.rs and wire/shared.rs" + status: pending + - id: step-10 + content: "Step 10 (paused-time): 5 sentinels to pending(), start_paused on ~14 in-process tests, Workspace clock injection for wait_past" + status: pending + - id: step-11 + content: "Step 11 (docs-prose): gateway family summaries to third person and //! lines" + status: pending + - id: step-12 + content: "Step 12 (docs-prose): promptforge and harness families summaries and //! lines" + status: pending + - id: step-13 + content: "Step 13 (docs-prose): workshop, shared, build summaries and //! lines; FULL verification block" + status: pending +isProject: false +--- + +# Rust rulebook sweep of promptforge + + + +## Product Requirements + +Bring the promptforge Rust workspace (`C:\Users\Vinnie\cursor\promptforge`, branch `master`) into line with the Rust rulebook at `tools-public/rulebooks/rust-rulebook.md`, applying every rule that makes sense for this repository and leaving untouched every convention the repository has deliberately chosen and documented in `AGENTS.md`. The sweep is sized from five read-only scans over 611 production Rust files and 107 commits from the last five days. + +Requirements: + +- R1. CI is green before any sweep commit lands. CI run 35511172403 on `9530340` failed one nondeterministic test; it must be made deterministic first. +- R2. No library `async fn` performs blocking filesystem work inline on the tokio executor. +- R3. No public `thiserror` type renders its `#[source]` in `Display` and also returns it from `source()`; no public error enum names a third-party error type in its variants; error messages follow the rulebook's lowercase, no-prefix, no-period style. +- R4. Public error enums and wire/protocol enums carry `#[non_exhaustive]`; wire structs that only the owning crate constructs carry it too. Internal state-machine enums stay exhaustive. +- R5. Discarded error causes (`.map_err(|_| ...)`) in non-lua crates attach `#[source]` where a slot exists or can be added without a new variant. +- R6. Lint suppressions carry a reason and use `#[expect]` where the lint fires; test-only re-exports live in test modules; doc examples use `?`, not `.unwrap()`; Rust shown in doc `text` fences compiles as a doctest where the API is reachable. +- R7. No `mod.rs` files except the two rulebook-sanctioned `tests/common/mod.rs`; module layout follows `AGENTS.md`'s three-file rule. +- R8. The engine emitter takes typed enums, not `bool` flags, for `debug` and `trusted`. +- R9. `promptforge/parser/src/lib.rs` is a facade (docs, `mod`, `pub use`) and under 500 lines; junk-drawer module names `types.rs` and `wire/shared.rs` are renamed to their concept. +- R10. Doc summary lines are third-person indicative; every module file opens with a `//!` line. +- R11. Deterministic in-process async tests use paused time; sentinel sleeps used as never-completing race arms become `std::future::pending()`. + +Non-goals (repository conventions that win over the rulebook, unchanged by this plan): + +- Manifestless family containers under `crates/` and short directory names (AGENTS.md Structure). +- The 500-line ceiling enforced only on `## Invariants` crates; `foo-bar.rs` `#[path]` siblings; `*-tests.rs` test files; the flat-directory rule. +- `clippy::pedantic = deny`; the four members (`workshop/shell`, `gateway/app`, `gateway-api-discovery`, `gateway/stt/whisper-ffi`) that mirror lints instead of `workspace = true`, because Cargo forbids combining `workspace = true` with an `unsafe_code` override and those crates hold `unsafe`. +- `workshop/shell` explicit WebView version pins (documented, mirror tauri-runtime-wry); the `turso = "=0.7.2"` workspace pin (documented pre-1.0 API churn; every consumer is `publish = false`). +- `anyhow` in `build-*` lib crates (build tooling consumed only by binaries) and `test-fixtures`-gated `Box` returns. +- Model-facing message design; private `Result<_, String>` refusal-text helpers in the scheduler (data, not errors). +- `models` module names (domain concept: LLM models, not a junk drawer). +- `promptforge/parser` `-> impl Iterator` returns (family-private crate behind the door). +- `whisper-ffi` bool setters (mirror the C API); the `VfsAccess` 11-method trait (filesystem surface). +- Import grouping (repo is mixed 3/4 groups; reordering is churn with no reader benefit). +- Sleeps in tests that drive real subprocesses or loopback sockets (~45 sites: `gateway/app/tests/it`, `gateway-api-discovery/tests/it`, `workshop/server/tests/it`, `workshop/shell/gateway/tests`, `build-workshop/tests`); paused time auto-advances the clock when idle and would fire the server's own timeouts early. Blocking-pool fakes (`harness/runner/tests/it/support.rs:249`, `effect_loop.rs:51`, `performers.rs:146`, `api-runtime/run-tests.rs:373`) likewise stay on real time. +- The 46 `promptforge/lua` `.map_err(|_|` sites (mlua-to-kind-table conversions by design). +- The 57 production files over 500 lines outside the enforced crates (top: `gateway/local/src/runtime.rs` 2016, `gateway/config/src/config/accessors.rs` 1878, `gateway/logging/src/worker.rs` 1558); a follow-up. + +## Functional Specification + +Observable outcomes, per requirement: + +- F1 (R1). `promptforge-api-runtime` `execute::tests::waits::cancel_ends_a_parked_task_idempotently_and_reports_task_cancelled_once` passes on every run because the child's store write is parked behind a gate until the cancel is observed; running it 20 times consecutively produces 20 passes. +- F2 (R2). `workshop-workspace` handlers (`tree`, `read_file`, `write_file`), `WorkspaceFile::{create, open, duplicate_to}`, the actor's `snapshot` and `close_database`, `workspace-backing.rs` (`grant_and_persist`, `revoke_and_persist`, `open_file`, `current`, `swap_backing`, `reload_current`), `workspace-pointer.rs` (`reopen_last`, `remember`), and `harness-sessions` `Runtime::launch` reach `std::fs` only through `tokio::task::spawn_blocking`. Behavior and all existing tests are unchanged. `current` uses one `spawn_blocking` for the whole per-grant loop. +- F3 (R3). The 15 variants listed in Technical Design render only their own message in `Display`; callers that showed the string to a person or model now render the `source()` chain. `LogError`, `WorkspaceFileError`, `SidecarError`, `LocalError`, `FetchError` name only crate-owned types in their public variants; `?` on the underlying `turso::Error` / `serde_json::Error` / `reqwest::Error` still compiles at every existing call site. The 13 flagged `#[error]` messages are lowercase-led (where the acronym is not the subject) with no `failed to` / `failed:` prefix. +- F4 (R4). The 10 error enums, ~17 wire enums, the `events!` macro output, and the response/event structs listed in Technical Design carry `#[non_exhaustive]`. `WaitError` and `FailureKind` remain exhaustive. Request-direction structs built by callers remain literal-constructible. +- F5 (R5). Each non-lua `.map_err(|_|` site either carries its cause or is listed in the step's return with the reason it was left. +- F6 (R6). Zero `#[allow(` without `reason` in `crates/`; the three `#[cfg(test)] pub(crate) use` re-exports in `promptforge-api-runtime` and the one in `workshop/workspace` live inside test modules; the two doc examples in `gateway/app` use `?`; the five `text` fences in `promptforge/lua/src/vm.rs` are compiled doctests or carry a one-line reason for staying text. +- F7 (R7). `rg --files -g mod.rs crates` lists only `gateway/stt/api/tests/common/mod.rs` and `workshop/server/tests/common/mod.rs`. +- F8 (R8). `Emitter::new`, `Emitter::root` take `DebugMode`; `Emitter::tool_result` takes `OutputTrust`; every call site in `promptforge-api-runtime` passes the enum. +- F9 (R9). `promptforge/parser/src/lib.rs` holds only crate docs, attributes, `mod`, and `pub use`, under 500 lines; `shared-vfs/src/types.rs` and `gateway/stt/api/src/realtime/wire/shared.rs` are renamed to the concept they hold. +- F10 (R10). The 254 imperative summary lines read in third person; 48 production and 105 test module files open with `//!`; `workshop/shell/src/main.rs` opens with `//!` before its release-build comment. +- F11 (R11). The 5 sentinel `sleep(30s)` sites use `std::future::pending()`; the listed in-process tests in `promptforge-api-runtime`, `harness/runner/tests/it`, `workshop/gateway`, `workshop/workspace` run under `start_paused = true`; `workspace-tests-grants.rs` no longer spins on the wall clock because `GrantMeta::added_at` takes its timestamp from an injectable clock. +- F12. `cargo fmt --all --check`, both clippy partitions with `-D warnings`, `cargo doc` with `-D warnings`, the full nextest suite, the doctest pass, and `cargo test -p build-xtask` all pass on the final commit. + + + + + +## Technical Design + +Target: `C:\Users\Vinnie\cursor\promptforge` on `master`. Steps run serially so each commit's content is what the final verification sees; parallelism is inside a step, with subagents split by crate over disjoint files. Dependencies: step 1, 2, 5 are mutually disjoint. 3 follows 2 (same error files). 2b follows 3. 4a and 4b follow 1 (the workspace re-export move lands in a file step 1 rewrites). 6 follows 3 (the parser `Error` enum lives in the `lib.rs` being hollowed). 8 follows 1 (workspace tests and `GrantMeta`). 7 is last because it touches every crate. Commit sequence: 0, 1, 2, 5, 3, 2b, 4a, 4b, 6, 8, 7 (7 as one commit per crate group). + +### D0. Green baseline: the flaky wait test + +CI run 35511172403 on `9530340` failed one test: `promptforge-api-runtime` `execute::tests::waits::cancel_ends_a_parked_task_idempotently_and_reports_task_cancelled_once` (`waits.rs` ~305-360). The child is meant to park on a store write, but the test uses ungated `TestStore::new()`, so whether the child completes (`return 'never'`) before `tasks.cancel(t)` lands is the blocking pool's choice; on CI the child won and the Lua assert at prologue line 20 fired with message `never`. Same class of race that `09f29994` fixed for the arm-conflict tests. + +- Expose `StoreGate`, `GateObserver`, and `gated_store` from `execute/tests/scheduler.rs` (~2923-3000) as `pub(super)`. +- Rebuild the context in the wait test on `gated_store(&gate)` so the child's `store.write('child-park', ..)` parks with the gate held; open the gate once the cancel has been observed (via `GateObserver` on the child's `TaskCancelled`) so the run-end drain completes. + +### D1. Blocking filesystem work off the executor (rulebook section 14) + +`crates/workshop/workspace` has ~15 `async fn` doing `std::fs` / `Path::exists` / `canonicalize` inline and zero `spawn_blocking`; `harness/sessions/src/runtime.rs` `Runtime::launch` walks the agent directory inline. Pattern to copy: `workshop/user-state/src/store.rs` ~95 (`spawn_blocking` around `write_atomic`). + +- `workspace_file.rs` `create` / `open` / `duplicate_to`: hoist the sync probes and `plan_siblings`/`copy_siblings` into a sync helper run via `tokio::task::spawn_blocking`. +- `workspace_file-actor.rs` `snapshot` (`fs::copy`) and `close_database` (`remove_empty_wal_sidecar`): `spawn_blocking` inside the actor, awaited so the actor's command ordering is unchanged. +- `workspace-backing.rs` `grant_and_persist` / `revoke_and_persist` / `open_file` / `current` / `swap_backing` / `reload_current`; `workspace-pointer.rs` `reopen_last` and `remember`: same. `current` stats one path per grant; batch the whole loop into one `spawn_blocking`. +- `handlers.rs` `tree` / `read_file` / `write_file`: wrap the `Workspace::*` sync calls. `spawn_blocking` needs `'static`: clone the `Arc` (or whatever the axum `State` holds) and move owned `PathBuf`s in. Keep the confinement `canonicalize` and the read/write inside the same closure so the check-then-use window does not widen. +- `harness/sessions/src/runtime.rs` `launch`: `spawn_blocking(discover)`. +- Also move `#[cfg(test)] pub(crate) use ui_state::UI_STATE_VALUE_CAP;` (`workspace_file.rs` ~34) into the two consuming test modules while the file is open. + +### D2. Error type shapes (section 5) + +Attributes and type definitions only; call-site work is D2b. + +- P1 (15 sites): strip `{source}` / `{error}` / `{0}` from `#[error(...)]` where the field is `#[source]`/`#[from]`. Files: `harness/log/src/error.rs` ~11/~18/~26, `harness/runner/src/prepare.rs` ~116/~125/~140, `harness/sessions/src/environment.rs` ~347 and `session/run.rs` ~36/~40/~43, `promptforge-api-runtime/src/error.rs` ~265/~447, `gateway/cloud-providers/src/lib.rs` ~102, `gateway/app/src/dialect.rs` ~368, `workshop/server/src/app.rs` ~463. For each site, `rg` the enum name to find who renders it. Where the string reaches a person or a model through `{}` or `to_string()` (UI status frames in `workshop/server`, tool output in `harness/runner`), the renderer must walk `source()` instead; if no chain-rendering helper exists in that crate, add a small one (`fn display_chain(&dyn Error) -> String`). Where only `{:?}` / `anyhow` / `tracing` consume it, nothing else changes. Update tests asserting the full string. +- P2 (5 pub enums leaking third-party types): wrap each foreign error in a crate-owned `#[error(transparent)] pub struct XxxSource(turso::Error)` newtype with a private field so the public API names only crate types. `LogError` (turso, serde_json), `WorkspaceFileError` (turso), `SidecarError` (serde_json), `LocalError` (reqwest, serde_json), `FetchError` (reqwest). `?` applies a single `From`, so `#[from]` on the newtype alone does not make `turso::Error -> LogError` work; write `impl From for LogError` by hand through the newtype. Existing `.map_err(LogError::Database)`-style call sites keep compiling if the variant shape stays the same. +- P6 (13 messages): lowercase `HTTP`/`STT`/`PCM16`-led messages where the acronym is not the subject (`harness/webfetch/src/error.rs` ~200, `gateway/stt/api/src/artifacts.rs` ~144/~152, `audio.rs` ~22); drop `failed to` / `failed:` prefixes (webfetch ~144, prepare ~125, environment ~347, api-runtime error ~434/~447, `workshop/server` session-menu ~182, cloud-providers ~102, `gateway/app` error ~130, api_error ~62). + +### D2b. Discarded error causes (section 5) + +`.map_err(|_|`: 108 sites; skip the 46 in `promptforge/lua`. For the remaining ~62 (dominant: `gateway/stt/api` 13, `whisper-ffi` 7, `gateway/local` 7, `promptforge-api-runtime` 6, `workshop/*` ~10): attach `#[source]` where the target variant already carries one or can take one without adding a variant; leave sites where the discarded error carries no information (integer conversions, `TryFrom` on constants, `Option`-like probes). Parallel by crate; each subagent reports the sites it left alone and why. + +### D3. `#[non_exhaustive]` on error and wire types (sections 5, 6) + +- 10 pub error enums: `ReplayError` (`promptforge-api-types/src/replay.rs` ~104), parser `Error` (`promptforge/parser/src/lib.rs` ~53), model-client `Error` (`error.rs` ~27), lua `Error` (`error.rs` ~59), `CurrentModelError` (`harness/sessions/src/environment.rs` ~345), `DriveError` (`harness/runner/src/effect_loop.rs` ~59), `PrepareError` (`prepare.rs` ~113), `LogError` (`harness/log/src/error.rs` ~9), `DialectResolveError` (`gateway/local/src/dialect.rs` ~40), `FetchError` (`gateway/cloud-providers/src/lib.rs` ~94). Skip `WaitError` and `FailureKind` (commented as deliberately exhaustive). +- ~17 serde wire enums: `gateway-api` `EnvRole`/`Tier`/`SliceStatus`; `gateway/protocol` `EmbeddingInput`/`SpeechVoice`/`SpeechResponseFormat`/`SpeechStreamFormat`; `gateway/config` `LlamaBackend`; `harness/sessions` `DeltaKind`; `workshop/protocol` `InputFrame`/`AgentEventKind`/`AgentDeltaKind`; `promptforge-api-types` `TaskOrigin`/`AbandonReason`; lua `StoreOp`; the `events!` macro in `promptforge-api-types/src/event.rs` so `Event` gets it. +- Wire structs: `#[non_exhaustive]` on a struct forbids struct-literal construction from every other crate, including `tests/it/`. Attribute only structs the owning crate constructs and others merely read: `gateway/protocol` `ChatResponse`/`ChatChunk`/`ChatChunkChoice`/`EmbeddingResponse`/`RerankResponse`/`ModelsResponse`, `harness/sessions` `SessionEvent`/`Delta`/`SessionFailure`, `harness/log` `StoredRecord`/`RunRow`, `workshop/protocol` `AgentEvent`/`Progress`/`StatusBarUpdate`/`CatalogPush`. Skip request-direction bags callers build by literal (`ChatRequest`, `EmbeddingRequest`, `SpeechRequest`, `RerankRequest`, `LaunchRequest`, `RunMeta`, `Record`, `SwitchProfileFrame`, `WorkbenchSnapshot`) unless `rg 'StructName \{'` shows no cross-crate literal. +- Variant-level attribute: only fix enums where some data variants already have it and siblings do not; not a blanket 122-variant pass. +- Add `_ =>` or `..` arms only where a downstream crate match now fails; each one gets a one-line comment naming the enum's owner. + +### D4a. Module layout moves (section 7) + +Pure `git mv` plus `mod` path fixes, no content edits. + +- 11 `mod.rs` files: production `promptforge/lua/src/{tools,models}/mod.rs`, `promptforge-api-runtime/src/fanout/mod.rs`, `gateway/cloud-providers/src/providers/mod.rs`, `gateway/stt/api/src/realtime/mod.rs`; test `*/tests/mod.rs` under `promptforge-api-runtime/src/execute`, `promptforge-api-runtime/src/lua`, `harness/models/src/transport`, `promptforge/lua/src/protocol`, `gateway/app/src/cloud_models` and the remaining two found by `rg --files -g mod.rs crates`. Keep the two `tests/common/mod.rs`. +- Target form per AGENTS.md's three-file rule: if the directory holds three or more sibling files after `mod.rs` leaves, convert to `foo.rs` + `foo/`; if fewer, flatten to `foo.rs` + `foo-bar.rs` with `#[path]`. Count first, choose per directory. +- Check each moved parent for existing `#[path]` attributes that point into the directory and fix them. + +### D4b. Lint hygiene and test plumbing (sections 11, 12) + +- 6 `#[allow]` without reason -> `#[expect(lint, reason = "...")]`: `harness/runner/src/spawn.rs` ~31/~58/~79 (`clippy::disallowed_methods`), `gateway-api/src/lib.rs` ~119 (`struct_excessive_bools`), `gateway/cloud-providers/src/providers/cohere.rs` ~120 (cast lints), `bedrock-sigv4.rs` ~35 (`too_many_arguments`). Confirm `build-xtask` `harness_bans` does not grep the literal `allow`. Plain rustc skips expectations for `clippy::` tool lints, so `#[expect]` is safe outside clippy runs. +- 3 test-only re-exports moved into the consuming test modules: `promptforge-api-runtime/src/store.rs` ~24 `StoreExt`, `lua.rs` ~14 `ToolOutputKind`, `execute/scheduler.rs` ~100 `TaskState`. +- 2 doc examples using `.unwrap()` -> `?` with hidden `Ok` tail: `gateway/app/src/api_error.rs` ~24, `runner.rs` ~828. +- 5 ` ```text ` fences holding Rust in `promptforge/lua/src/vm.rs` ~51/~231/~433/~783/~996: convert to compiled doctests (`no_run` plus hidden setup) where the referenced API is reachable from the crate root; leave as `text` where it is not, with a one-line reason comment. +- `workshop/user-state/src/store.rs` `get_all` -> `all` (the two `get_state` are axum GET handlers; keep). +- Verify the 11 `Self`-returning constructors lacking `#[must_use]` (`shared-vfs/src/handle.rs` ~181/~189, `workshop/menu/src/menu.rs` ~184, `workshop/registry/src/traits.rs` x7, `build-llama-cuda/src/probe.rs` ~26); add where the type itself is not already `#[must_use]`. +- Verify `gateway/app/src/model_info.rs` no longer carries the doubled `#[cfg(feature = "local")]` left by `d77a8b48`; remove if present. + +### D5. Bool parameters in the engine emitter (section 6) + +`promptforge-api-types/src/emitter.rs` `Emitter::new` / `root` (`debug: bool`) and `tool_result` (`trusted: bool`). `OutputTrust` already exists in `promptforge_api_types::tools`; use it for `trusted`; add a two-variant `DebugMode { Off, On }` (or the nearest existing name) for `debug`. Update `promptforge-api-runtime` `execute/event_buffer.rs` and `RunContext::report_debug` call sites. Leave the other 24 single-bool pub fns. + +### D6. Facade root and junk-drawer names (section 7) + +- `promptforge/parser/src/lib.rs` (677 lines, 31 fns, 8 impls): move the logic into named modules (`build.rs` already exists; add siblings by concept), leave `lib.rs` as docs + `mod` + `pub use`. `tests.rs` (1317 lines) reaches private items through `use super::*`; moved items need `pub(crate)` visibility and the test module's imports updated, or the affected tests move beside the new module as `-tests.rs`. Read `tests.rs` imports before choosing the split. +- `shared-loopback/src/lib.rs` (35 fns): same treatment if over 500 lines, else defer. +- Rename `shared-vfs/src/types.rs` and `gateway/stt/api/src/realtime/wire/shared.rs` to the concept they hold (read contents first). +- Any other file touched in D0-D5 that is over 500 lines gets split before edit per AGENTS.md. + +### D7. Documentation prose (section 10) + +Docs-only, no code motion, parallel by crate. + +- 254 imperative summary lines -> third-person indicative (`Spawn` -> `Spawns`), verb form only, meaning untouched. Dominant: `gateway/cloud-providers` 69, `gateway/app` 47, `build-xtask` 25, `gateway/protocol` 25. +- 48 production files without a `//!` first line (37 in `gateway/stt`, 7 in `promptforge/lua`) and 105 test files: add one sentence each. +- `workshop/shell/src/main.rs` opens with a `//` comment: move the `//!` above it. + +### D8. Paused time in deterministic async tests (section 11) + +In-process tests only; each conversion is read first, because `start_paused` turns "nothing arrives within 50ms" into "time jumps until something arrives". + +- Sentinel `tokio::time::sleep(30s)` used as a never-completing race arm -> `std::future::pending()` (5): `harness/models/src/transport/tests/limits.rs` ~112/~149, `harness/web-search/src/web_search-tests.rs` ~395, `promptforge-api-runtime/src/execute/tests/mod.rs` ~1330, `execute/tests/scheduler.rs` ~3721. +- `promptforge-api-runtime` (~9): `#[tokio::test(flavor = "current_thread", start_paused = true)]` following the 17 precedents in `timeouts.rs` and `input.rs`. Fixed delays `tool_loop.rs` ~482, `input.rs` ~316; 10ms poll loops `scheduler.rs` ~150/~3412/~3751, `live_infer.rs` ~314; fake-latency `sleep(delay)` in `model_task_notices.rs` ~48, `tests/mod.rs` ~980. `test_support/tokio_driver.rs` ~357 is the timer performer itself and needs no edit. Where a test asserts a slow-vs-fast race, replace the sleep with an explicit `tokio::time::advance`. +- Harness and workshop in-process (~5): `harness/runner/tests/it/effect_loop.rs` ~205, `performers.rs` ~53; `workshop/gateway/src/gateway_progress-tests.rs` ~129/~140; `workshop/workspace/src/workspace-file-tests-mutations.rs` ~417. +- `workshop/workspace/src/workspace-tests-grants.rs` `wait_past` spins on the wall clock until the RFC 3339 second changes. Inject the clock: give `GrantMeta::added_at` its timestamp through a `now` function on `Workspace` (defaulting to `now_rfc3339`) so the test sets two distinct stamps directly. Small production change, ships with the test. + +### Verification commands (PowerShell) + +``` +cargo fmt --all --check +cargo clippy --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-targets --all-features -- -D warnings +cargo clippy -p workshop -p workshop-server -p workshop-server-api --all-targets -- -D warnings +$env:RUSTDOCFLAGS = "-D warnings"; cargo doc --workspace --no-deps --all-features --exclude workshop --exclude workshop-server --exclude workshop-server-api +cargo nextest run --locked --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-features +cargo nextest run --locked -p workshop -p workshop-server -p workshop-server-api +cargo test --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-features --doc +cargo test -p build-xtask +``` + + + + + +## Testing Plan + +Tests are kept light. This sweep is overwhelmingly refactor and attribute work whose regression guard is the existing suite; new tests are written only where a step introduces a behavior a reader could break without noticing. + +- Per step, the coder runs only the focused test command for the crates it touched (`cargo nextest run --locked -p --all-features`, plus `cargo test -p --doc` when a doc example changed). No formatter, linter, docs, or full-suite run per step. +- Full verification runs once, on the final step, using every command in the Technical Design verification block, in order. +- New tests, by step: + - Step 0: none new; the existing test is made deterministic and run 20 times consecutively (`for ($i=0; $i -lt 20; $i++) { cargo nextest run --locked -p promptforge-api-runtime --all-features cancel_ends_a_parked_task }`), all passing. + - D1: none new unless a sync helper is extracted with an edge case of its own; existing `workspace-tests-*.rs`, `workspace-file-tests-*.rs`, and `harness/sessions/tests/it` are the guard. + - D2: for each renderer switched to walk `source()`, one assertion that the rendered string contains the cause text; for each `From for Enum` impl, the existing `?` call sites compiling is the test. + - D2b, D3, D4a, D4b, D6, D7: none new; compilation plus existing tests. + - D5: one test that `Emitter::tool_result` with `OutputTrust::Untrusted` produces the same event the old `trusted: false` did, if no existing test already pins that event. + - D8: the converted tests are the tests; each must pass under `start_paused` and, where a race is asserted, with the explicit `advance`. The clock injection ships with the rewritten `wait_past` test. +- A step whose focused tests fail is not marked complete. +- Review is one round per step, findings fixed in the same commit, per the vibe-coder cycle; verification (Verify dispatch) is skipped on every step except the last, where Scope is `FULL`. + + + + + +## Decision Record + +- Checkout: `promptforge` on `master` (the audit ran here and the harness code lives here), not `promptforge3`/`vibe3`. +- Display and source: the rulebook wins. Models receive their own display string shaped for LLM consumption; the `source()` chain is for the Rust side. Strip `{source}` from `Display` wherever `#[source]`/`#[from]` is present and make human/model renderers walk the chain. +- `#[non_exhaustive]` scope: error enums and wire/protocol types only. Internal state-machine enums stay exhaustive so cross-crate matches keep breaking on new variants. Request-direction data bags stay literal-constructible. +- Foreign error types in public enums: hide behind crate-owned `#[error(transparent)]` newtypes rather than converting each enum to an opaque `struct Error(Repr)`; the newtype is the minimal change that removes the third-party name from the signature. +- Tests light: no new tests for refactor and attribute steps; the existing suite is the guard. Full verification once at the end, not per step. +- Serial steps, parallel within a step: so the final verification sees exactly the committed content and the per-step focused runs are attributable. +- Leave the `turso = "=0.7.2"` pin, the four lint-mirroring manifests, and the family-container layout: each is a documented, forced, or deliberate repo choice. +- Skip the 46 lua `.map_err(|_|` sites: the kind-table error design has no source slot by design. +- Skip converting real-process integration test sleeps: paused time with real sockets fires the system under test's own timeouts early. +- Step 0 uses the existing `StoreGate`/`GatedStore` machinery rather than a new fixture: it is the mechanism `09f29994` already established for this race class. + + + + + +## Project Survey + +- Status: complete +- Build command: `cargo build --locked -p gateway` (the workspace `default-members` is `crates/gateway/app`, so bare `cargo build` builds only the gateway; the desktop app is `cargo build --locked -p workshop`, which first needs `npm ci --prefix crates/workshop/ui` and `npm ci --prefix crates/gateway/config-ui/ui` because the crates' build scripts bundle the UIs into `OUT_DIR`). Toolchain: `rust-toolchain.toml` pins `stable`; edition 2024; resolver 3. On Windows `.cargo/config.toml` links with `rust-lld` and `+crt-static`. +- Focused test command pattern: `cargo nextest run --locked -p --all-features []`; doctests are separate under nextest: `cargo test -p --doc`. Workshop crates (`workshop`, `workshop-server`, `workshop-server-api`) drop `--all-features`; `workshop-server` also has `--features headless`. Plain `cargo test --locked -p --test it ` is used in CI for single integration tests. `.config/nextest.toml` sets a 60s slow-timeout (terminate after 3 periods), a 250ms leak-timeout, and a `heavy` test group (max 8 threads, 4 required per test) for `gateway-stt` and `gateway-stt-backend-whisper`. +- Component test command pattern: the repo tests in two partitions. Workspace partition: `cargo nextest run --locked --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-features`. Workshop partition: `cargo nextest run --locked -p workshop -p workshop-server -p workshop-server-api`, then `cargo nextest run --locked -p workshop-server --features headless`, then `cargo test --doc -p workshop -p workshop-server -p workshop-server-api`. A family (harness, gateway, promptforge) is tested by listing its crates with repeated `-p`, e.g. `cargo nextest run --locked -p harness-runner -p harness-sessions -p harness-log --all-features`. Structural harness: `cargo test -p build-xtask`. Product-boundary matrix from cargo metadata: `cargo test -p gateway-stt --test it architecture`. +- Full-suite test command (in CI order): `cargo nextest run --locked --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-features`; `cargo test --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-features --doc`; `cargo nextest run --locked -p workshop -p workshop-server -p workshop-server-api`; `cargo nextest run --locked -p workshop-server --features headless`; `cargo test --doc -p workshop -p workshop-server -p workshop-server-api`; `cargo test -p build-xtask`. CI additionally runs `cargo check -p gateway --no-default-features` and a clean-tree check (`git status --porcelain` must be empty after the build). +- Linter command: `cargo clippy --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-targets --all-features -- -D warnings`, then `cargo clippy -p workshop -p workshop-server -p workshop-server-api --all-targets -- -D warnings`. Never run standalone `cargo check --workspace` beside these (AGENTS.md); the one sanctioned check is `cargo check -p gateway --no-default-features`. Workspace lints in `Cargo.toml`: `clippy::all` and `clippy::pedantic` deny, `unwrap_used`/`expect_used` deny (`clippy.toml` allows both in tests), `doc_markdown` allow, `unsafe_code` forbid, `missing_docs`/`missing_debug_implementations`/`unreachable_pub` warn, `rustdoc::broken_intra_doc_links`/`private_intra_doc_links` deny. Supply chain: `cargo deny check` (`deny.toml`) and `cargo audit`. Pre-push hook runs the headless check, the workspace clippy partition, and cargo deny. +- Formatter check command: `cargo fmt --all --check` (`rustfmt.toml`: `style_edition = "2024"`). The pre-commit hook in `.githooks/` runs it. +- Docs command: PowerShell `$env:RUSTDOCFLAGS = "-D warnings"; cargo doc --workspace --no-deps --all-features --exclude workshop --exclude workshop-server --exclude workshop-server-api`. User guide: `mdbook build guide` (`guide/book.toml`), assembled by `build-user-guide`. +- Test placement and naming conventions: three shapes coexist. (1) Inline `#[cfg(test)] mod tests` at the bottom of the source file (~322 files). (2) Sibling test file `-tests.rs` next to `.rs`, wired with `#[path = "-tests.rs"] mod tests;` (108 files; ~100 `#[path]` attributes); large groups split further as `-tests- + + + +## Execution Instructions + +Repository `C:\Users\Vinnie\cursor\promptforge`, branch `master`, HEAD `95303402`. Steps run serially in the order below; each step is exactly one commit holding its code and its tests. Parallelism is allowed inside a step only, with workers split by crate over disjoint files. Per step, run only the focused test command for the crates touched (`cargo nextest run --locked -p --all-features`; `workshop`, `workshop-server`, `workshop-server-api` drop `--all-features`; add `cargo test -p --doc` when a doc example changed). Formatter, clippy, docs, and the full suite run once, in Step 13. A step whose focused tests fail is not complete. A completed step adds ` [completed]` to its heading; the tag lines never change. + +Enforced-crate rule for every step: `workshop-*` and `harness-*` crates carry a 500-line file ceiling checked by `cargo test -p build-xtask`. When an edit would push one of their files past 500 lines, split that file first, in the same commit, using the AGENTS.md three-file rule (three or more siblings: `foo.rs` + `foo/`; fewer: `foo.rs` + `foo-bar.rs` with `#[path]`). + +Components in dependency order: + +1. `ci-baseline` (Step 1) - R1 requires green CI before any sweep commit; nothing else can land first. +2. `async-fs` (Step 2) - disjoint from every other component, but Steps 7, 8, and 10 rewrite files it edits (`workspace_file.rs`, `workspace-tests-grants.rs`, `GrantMeta`), so it lands before them. +3. `emitter-flags` (Step 3) - disjoint from everything; placed ahead of `error-model` so it never rebases over error-file churn. This swaps the Technical Design's "2, 5" order; both are stated disjoint, so the swap changes no content. +4. `error-model` (Steps 4-6) - shapes first (variant text and newtypes), then `#[non_exhaustive]` on the same files, then call-site causes once every variant and source slot is final. +5. `layout-lint-hygiene` (Steps 7-9) - `mod.rs` moves and the api-runtime re-export moves follow `async-fs` (the workspace re-export move already landed in Step 2's file); the parser facade follows `error-model` because the parser `Error` enum lives in the `lib.rs` being hollowed. +6. `paused-time` (Step 10) - follows `async-fs` because it rewrites `workspace-tests-grants.rs` and adds the clock to `Workspace`. +7. `docs-prose` (Steps 11-13) - last, because it touches every crate and must see final file names and module paths. + + + +### Step 1: Deterministic cancel-wait test [completed] + +- Component: `ci-baseline` +- Piece: flaky wait test (D0) +- Covers: R1, F1 +- Depends on: none + +Changes: + +- `crates/promptforge-api-runtime/src/execute/tests/scheduler.rs` (~2923-3000): widen `StoreGate`, `GateObserver`, and `gated_store` to `pub(super)`. +- `crates/promptforge-api-runtime/src/execute/tests/waits.rs` (~305-360), test `cancel_ends_a_parked_task_idempotently_and_reports_task_cancelled_once`: replace the ungated `TestStore::new()` context with one built on `gated_store(&gate)` so the child's `store.write('child-park', ..)` parks while the gate is held; attach a `GateObserver` that opens the gate when the child's `TaskCancelled` event is observed so the run-end drain completes. Mirror the shape `09f29994` used for the arm-conflict tests. + +Tests: no new test. Run `for ($i=0; $i -lt 20; $i++) { cargo nextest run --locked -p promptforge-api-runtime --all-features cancel_ends_a_parked_task }`; all 20 must pass. + +Commit: one commit naming the test and the gate. + + + + + +### Step 2: Blocking filesystem work off the tokio executor + +- Component: `async-fs` +- Piece: workshop-workspace and harness-sessions (D1) +- Covers: R2, F2 +- Depends on: Step 1 + +Pattern to copy: `crates/workshop/user-state/src/store.rs` ~95 (`tokio::task::spawn_blocking` around `write_atomic`). `spawn_blocking` closures need `'static`: clone the `Arc` (or whatever the axum `State` holds) and move owned `PathBuf`s in. + +Changes in `crates/workshop/workspace/src/`: + +- `workspace_file.rs` `WorkspaceFile::create` / `open` / `duplicate_to`: hoist the sync probes and `plan_siblings` / `copy_siblings` into a sync helper run via `spawn_blocking`. +- `workspace_file-actor.rs` `snapshot` (`fs::copy`) and `close_database` (`remove_empty_wal_sidecar`): `spawn_blocking` inside the actor, awaited so command ordering is unchanged. +- `workspace-backing.rs` `grant_and_persist` / `revoke_and_persist` / `open_file` / `current` / `swap_backing` / `reload_current`: same; `current` batches its whole per-grant stat loop into one `spawn_blocking`. +- `workspace-pointer.rs` `reopen_last` / `remember`: same. +- `handlers.rs` `tree` / `read_file` / `write_file`: wrap the `Workspace::*` sync calls; keep the confinement `canonicalize` and the read or write inside the same closure so the check-then-use window does not widen. +- `workspace_file.rs` ~34: move `#[cfg(test)] pub(crate) use ui_state::UI_STATE_VALUE_CAP;` into the two consuming test modules. + +Changes in `crates/harness/sessions/src/runtime.rs`: `Runtime::launch` runs its agent-directory walk as `spawn_blocking(discover)`. + +Tests: none new unless an extracted sync helper has an edge case of its own. Guard: `workspace-tests-*.rs`, `workspace-file-tests-*.rs`, `crates/harness/sessions/tests/it`. Focused: `cargo nextest run --locked -p workshop-workspace -p harness-sessions --all-features`. Apply the enforced-crate rule to every file above. + +Commit: one commit. + + + + + +### Step 3: Typed enums for the emitter's debug and trusted flags + +- Component: `emitter-flags` +- Piece: engine emitter (D5) +- Covers: R8, F8 +- Depends on: Step 1 + +Changes: + +- `crates/promptforge-api-types/src/emitter.rs`: `Emitter::new` and `Emitter::root` take `DebugMode` instead of `debug: bool`; `Emitter::tool_result` takes `promptforge_api_types::tools::OutputTrust` instead of `trusted: bool`. Add `pub enum DebugMode { Off, On }` beside `Emitter` unless an equivalent two-variant enum already exists in the crate, in which case reuse it. +- `crates/promptforge-api-runtime/src/execute/event_buffer.rs` and `RunContext::report_debug`: pass the enums at every call site. The other 24 single-bool pub fns in the workspace stay as they are. + +Tests: one test that `Emitter::tool_result` with `OutputTrust::Untrusted` produces the same event the old `trusted: false` did, unless an existing test already pins that event. Focused: `cargo nextest run --locked -p promptforge-api-types -p promptforge-api-runtime --all-features`. + +Commit: one commit. + + + + + +### Step 4: Error shapes - Display, foreign types, message style + +- Component: `error-model` +- Piece: type definitions and renderers (D2) +- Covers: R3, F3 +- Depends on: Step 3 + +Attributes, type definitions, and renderers only; call-site `.map_err` work is Step 6. + +- P1, 15 doubled `Display` + `source()` sites: strip `{source}` / `{error}` / `{0}` from `#[error(...)]` where the field is `#[source]` or `#[from]`. Files: `crates/harness/log/src/error.rs` ~11/~18/~26; `crates/harness/runner/src/prepare.rs` ~116/~125/~140; `crates/harness/sessions/src/environment.rs` ~347 and `session/run.rs` ~36/~40/~43; `crates/promptforge-api-runtime/src/error.rs` ~265/~447; `crates/gateway/cloud-providers/src/lib.rs` ~102; `crates/gateway/app/src/dialect.rs` ~368; `crates/workshop/server/src/app.rs` ~463. For each enum, `rg` its name to find renderers. Where the string reaches a person or model via `{}` or `to_string()` (UI status frames in `workshop/server`, tool output in `harness/runner`), the renderer walks `source()`; add `fn display_chain(&dyn Error) -> String` in that crate if no chain renderer exists. Where only `{:?}`, `anyhow`, or `tracing` consume it, nothing else changes. Update tests asserting the full string. +- P2, 5 public enums naming third-party types: add crate-owned `#[error(transparent)] pub struct XxxSource(inner)` newtypes with a private field for `LogError` (turso, serde_json), `WorkspaceFileError` (turso), `SidecarError` (serde_json), `LocalError` (reqwest, serde_json), `FetchError` (reqwest). Write `impl From for LogError` and the analogous impls by hand through the newtype so every existing `?` site still compiles; keep variant shapes so `.map_err(LogError::Database)`-style sites compile. +- P6, 13 messages: lowercase `HTTP` / `STT` / `PCM16`-led messages where the acronym is not the subject (`crates/harness/webfetch/src/error.rs` ~200, `crates/gateway/stt/api/src/artifacts.rs` ~144/~152, `audio.rs` ~22); drop `failed to` / `failed:` prefixes (webfetch ~144, prepare ~125, environment ~347, api-runtime `error.rs` ~434/~447, `workshop/server` session-menu ~182, cloud-providers ~102, `gateway/app` `error.rs` ~130, `api_error.rs` ~62). + +Tests: for each renderer switched to walk `source()`, one assertion that the rendered string contains the cause text; each hand-written `From` impl is tested by the existing `?` sites compiling. Focused nextest on every crate touched (`harness-log`, `harness-runner`, `harness-sessions`, `harness-webfetch`, `promptforge-api-runtime`, `gateway-cloud-providers`, `gateway`, `gateway-local`, `gateway-stt`, `workshop-workspace`, `workshop-server` without `--all-features`). Apply the enforced-crate rule. + +Commit: one commit. + + + + + +### Step 5: `#[non_exhaustive]` on error and wire types + +- Component: `error-model` +- Piece: exhaustiveness attributes (D3) +- Covers: R4, F4 +- Depends on: Step 4 + +Changes: + +- 10 public error enums: `ReplayError` (`crates/promptforge-api-types/src/replay.rs` ~104), parser `Error` (`crates/promptforge/parser/src/lib.rs` ~53), model-client `Error` (`crates/promptforge/model-client/src/error.rs` ~27), lua `Error` (`crates/promptforge/lua/src/error.rs` ~59), `CurrentModelError` (`crates/harness/sessions/src/environment.rs` ~345), `DriveError` (`crates/harness/runner/src/effect_loop.rs` ~59), `PrepareError` (`prepare.rs` ~113), `LogError` (`crates/harness/log/src/error.rs` ~9), `DialectResolveError` (`crates/gateway/local/src/dialect.rs` ~40), `FetchError` (`crates/gateway/cloud-providers/src/lib.rs` ~94). `WaitError` and `FailureKind` stay exhaustive. +- ~17 serde wire enums: `gateway-api` `EnvRole` / `Tier` / `SliceStatus`; `gateway/protocol` `EmbeddingInput` / `SpeechVoice` / `SpeechResponseFormat` / `SpeechStreamFormat`; `gateway/config` `LlamaBackend`; `harness/sessions` `DeltaKind`; `workshop/protocol` `InputFrame` / `AgentEventKind` / `AgentDeltaKind`; `promptforge-api-types` `TaskOrigin` / `AbandonReason`; lua `StoreOp`; the `events!` macro in `crates/promptforge-api-types/src/event.rs` so `Event` receives it. +- Wire structs the owning crate alone constructs: `gateway/protocol` `ChatResponse` / `ChatChunk` / `ChatChunkChoice` / `EmbeddingResponse` / `RerankResponse` / `ModelsResponse`; `harness/sessions` `SessionEvent` / `Delta` / `SessionFailure`; `harness/log` `StoredRecord` / `RunRow`; `workshop/protocol` `AgentEvent` / `Progress` / `StatusBarUpdate` / `CatalogPush`. Skip request-direction bags (`ChatRequest`, `EmbeddingRequest`, `SpeechRequest`, `RerankRequest`, `LaunchRequest`, `RunMeta`, `Record`, `SwitchProfileFrame`, `WorkbenchSnapshot`) unless `rg 'StructName \{'` shows no cross-crate literal, including under `tests/it/`. +- Variant-level attribute only where an enum already has it on some data variants and not on siblings. +- Add `_ =>` or `..` arms only where a downstream match now fails; each new arm gets a one-line comment naming the enum's owner. + +Tests: none new; compilation plus existing tests. Focused nextest on every crate touched and every downstream crate that gained a wildcard arm. + +Commit: one commit. + + + + + +### Step 6: Attach discarded error causes + +- Component: `error-model` +- Piece: call sites (D2b) +- Covers: R5, F5 +- Depends on: Step 5 + +Changes: of the 108 `.map_err(|_|` sites, skip the 46 in `crates/promptforge/lua`. For the remaining ~62 (`gateway/stt/api` 13, `gateway/stt/whisper-ffi` 7, `gateway/local` 7, `promptforge-api-runtime` 6, `workshop/*` ~10, remainder found by `rg '\.map_err\(\|_\|' crates`), attach the discarded error as `#[source]` where the target variant already carries a source or can take one without adding a variant. Leave sites whose discarded error carries no information (integer conversions, `TryFrom` on constants, `Option`-like probes). Workers split by crate; each reports the sites it left and why, and the step's return lists them. + +Tests: none new; compilation plus existing tests. Focused nextest per crate touched. + +Commit: one commit. + + + + + +### Step 7: Retire `mod.rs` files + +- Component: `layout-lint-hygiene` +- Piece: module layout moves (D4a) +- Covers: R7, F7 +- Depends on: Step 6 + +Pure `git mv` plus `mod` and `#[path]` fixes; no content edits. + +- Targets: production `crates/promptforge/lua/src/tools/mod.rs`, `crates/promptforge/lua/src/models/mod.rs`, `crates/promptforge-api-runtime/src/fanout/mod.rs`, `crates/gateway/cloud-providers/src/providers/mod.rs`, `crates/gateway/stt/api/src/realtime/mod.rs`; test `tests/mod.rs` under `promptforge-api-runtime/src/execute`, `promptforge-api-runtime/src/lua`, `promptforge-api-runtime/src/model`, `harness/models/src/transport`, `promptforge/lua/src/protocol`, `gateway/app/src/cloud_models`, `gateway/config/src/config`. Confirm the list with `rg --files -g mod.rs crates`; keep `gateway/stt/api/tests/common/mod.rs` and `workshop/server/tests/common/mod.rs`. +- Form per directory (count siblings first): three or more sibling files after `mod.rs` leaves -> `foo.rs` + `foo/`; fewer -> flatten to `foo.rs` + `foo-bar.rs` with `#[path]`. +- Fix any existing `#[path]` attribute in the moved parent that points into the directory. + +Tests: none new. `rg --files -g mod.rs crates` lists exactly the two `tests/common/mod.rs` files. Focused nextest per crate touched. + +Commit: one commit. + + + + + +### Step 8: Lint suppressions, test plumbing, doc examples + +- Component: `layout-lint-hygiene` +- Piece: lint hygiene (D4b) +- Covers: R6 (with Step 2's re-export move), F6 +- Depends on: Step 7 + +Changes: + +- 6 `#[allow]` without reason -> `#[expect(lint, reason = "...")]`: `crates/harness/runner/src/spawn.rs` ~31/~58/~79 (`clippy::disallowed_methods`), `crates/gateway-api/src/lib.rs` ~119 (`clippy::struct_excessive_bools`), `crates/gateway/cloud-providers/src/providers/cohere.rs` ~120 (cast lints), `bedrock-sigv4.rs` ~35 (`clippy::too_many_arguments`). Confirm `build-xtask`'s `harness_bans` does not grep the literal `allow`. +- 3 test-only re-exports into their consuming test modules: `crates/promptforge-api-runtime/src/store.rs` ~24 `StoreExt`, `lua.rs` ~14 `ToolOutputKind`, `execute/scheduler.rs` ~100 `TaskState`. +- 2 doc examples `.unwrap()` -> `?` with a hidden `Ok(())` tail: `crates/gateway/app/src/api_error.rs` ~24, `runner.rs` ~828. +- 5 ` ```text ` fences holding Rust in `crates/promptforge/lua/src/vm.rs` ~51/~231/~433/~783/~996: compiled doctests (`no_run` plus hidden setup) where the API is reachable from the crate root; otherwise stay `text` with a one-line reason comment. +- `crates/workshop/user-state/src/store.rs`: rename `get_all` -> `all` (the two `get_state` axum GET handlers keep their names). +- `#[must_use]` on `Self`-returning constructors lacking it, where the type is not already `#[must_use]`: `crates/shared-vfs/src/handle.rs` ~181/~189, `crates/workshop/menu/src/menu.rs` ~184, `crates/workshop/registry/src/traits.rs` (7 sites), `crates/build-llama-cuda/src/probe.rs` ~26. +- `crates/gateway/app/src/model_info.rs`: remove the doubled `#[cfg(feature = "local")]` from `d77a8b48` if still present. + +Tests: none new. `rg '#\[allow\(' crates` shows only attributes carrying `reason`. Focused nextest per crate touched plus `cargo test -p gateway --doc` and `cargo test -p promptforge-lua --doc`. + +Commit: one commit. + + + + + +### Step 9: Parser facade root and junk-drawer renames + +- Component: `layout-lint-hygiene` +- Piece: facade and names (D6) +- Covers: R9, F9 +- Depends on: Step 8 + +Changes: + +- `crates/promptforge/parser/src/lib.rs` (677 lines, 31 fns, 8 impls): read `tests.rs` (1317 lines, reaches private items through `use super::*`) first, then move the logic into sibling modules named by concept (`build.rs` exists; add siblings), leaving `lib.rs` as crate docs, attributes, `mod`, and `pub use` under 500 lines. Moved items gain `pub(crate)` and `tests.rs` imports are updated, or the affected tests move beside their module as `-tests.rs`. The `Error` enum attributed in Step 5 moves with its module. +- `crates/shared-loopback/src/lib.rs` (35 fns): same treatment if over 500 lines; otherwise defer and note it in the return. +- Rename `crates/shared-vfs/src/types.rs` and `crates/gateway/stt/api/src/realtime/wire/shared.rs` to the concept they hold (read contents first); fix `mod` lines and `#[path]` attributes. +- Any enforced-crate file grown past 500 lines by Steps 1-8 that the enforced-crate rule missed: split it here. + +Tests: none new; compilation plus existing tests. Focused: `cargo nextest run --locked -p promptforge-parser -p shared-loopback -p shared-vfs -p gateway-stt --all-features`. + +Commit: one commit. + + + + + +### Step 10: Paused time in deterministic async tests + +- Component: `paused-time` +- Piece: in-process tests and the workspace clock (D8) +- Covers: R11, F11 +- Depends on: Step 9 + +Read each test before converting: `start_paused` turns "nothing arrives within 50ms" into "time jumps until something arrives". + +- 5 sentinel `tokio::time::sleep(30s)` race arms -> `std::future::pending()`: `crates/harness/models/src/transport/tests/limits.rs` ~112/~149 (path as moved in Step 7), `crates/harness/web-search/src/web_search-tests.rs` ~395, `crates/promptforge-api-runtime/src/execute/tests/mod.rs` ~1330 (as moved), `execute/tests/scheduler.rs` ~3721. +- `promptforge-api-runtime` (~9): `#[tokio::test(flavor = "current_thread", start_paused = true)]` following the 17 precedents in `timeouts.rs` and `input.rs`. Fixed delays `tool_loop.rs` ~482, `input.rs` ~316; 10ms poll loops `scheduler.rs` ~150/~3412/~3751, `live_infer.rs` ~314; fake-latency `sleep(delay)` in `model_task_notices.rs` ~48 and `tests/mod.rs` ~980. `test_support/tokio_driver.rs` ~357 is the timer performer and stays. Where a test asserts a slow-vs-fast race, replace the sleep with an explicit `tokio::time::advance`. +- Harness and workshop in-process (~5): `crates/harness/runner/tests/it/effect_loop.rs` ~205, `performers.rs` ~53; `crates/workshop/gateway/src/gateway_progress-tests.rs` ~129/~140; `crates/workshop/workspace/src/workspace-file-tests-mutations.rs` ~417. +- Clock injection: give `Workspace` a `now` function (defaulting to `now_rfc3339`) that `GrantMeta::added_at` reads in `workspace-backing.rs` `grant_and_persist`; rewrite `wait_past` in `crates/workshop/workspace/src/workspace-tests-grants.rs` to set two distinct stamps directly instead of spinning on the wall clock. + +Out of scope: the ~45 real-process or loopback-socket sleeps and the four blocking-pool fakes listed in Non-goals stay on real time. + +Tests: the converted tests are the tests; each passes under `start_paused`, with explicit `advance` where a race is asserted. Focused: `cargo nextest run --locked -p promptforge-api-runtime -p harness-models -p harness-web-search -p harness-runner -p workshop-gateway -p workshop-workspace --all-features`. Apply the enforced-crate rule. + +Commit: one commit (production clock change ships with the rewritten test). + + + + + +### Step 11: Documentation prose - gateway family + +- Component: `docs-prose` +- Piece: gateway crates (D7, group 1) +- Covers: R10, F10 (gateway share) +- Depends on: Step 10 + +Docs-only, no code motion; workers parallel by crate over `crates/gateway/**`, `crates/gateway-api`, `crates/gateway-api-discovery`. + +- Imperative summary lines -> third-person indicative (`Spawn` -> `Spawns`), verb form only, meaning untouched: `gateway/cloud-providers` 69, `gateway/app` 47, `gateway/protocol` 25, plus the remainder in the family found by `rg '^\s*///\s+[A-Z][a-z]+ ' crates/gateway`. +- `//!` first line on the 37 `gateway/stt` production files lacking one and on every gateway-family test module file lacking one; one sentence each. + +Tests: none new. `cargo doc -p --no-deps` with `-D warnings` on each crate touched, plus focused nextest to confirm no doctest fence changed meaning. + +Commit: one commit. + + + + + +### Step 12: Documentation prose - promptforge and harness families + +- Component: `docs-prose` +- Piece: promptforge and harness crates (D7, group 2) +- Covers: R10, F10 (promptforge and harness share) +- Depends on: Step 11 + +Docs-only; workers parallel by crate over `crates/promptforge/**`, `crates/promptforge-api-runtime`, `crates/promptforge-api-types`, `crates/harness/**`, `crates/harness-api`. + +- Imperative summary lines -> third-person indicative across both families. +- `//!` first line on the 7 `promptforge/lua` production files lacking one, on any other production file in these families lacking one, and on every test module file (`*-tests.rs`, `tests/*.rs`, `tests/it/*.rs`) lacking one. + +Tests: none new. `cargo doc -p --no-deps` with `-D warnings` on each crate touched, plus focused nextest. + +Commit: one commit. + + + + + +### Step 13: Documentation prose - workshop, shared, build; full verification + +- Component: `docs-prose` +- Piece: remaining crates (D7, group 3) and the F12 gate +- Covers: R10, F10 (remaining share), F12 +- Depends on: Step 12 + +Docs-only; workers parallel by crate over `crates/workshop/**`, `crates/shared-*`, `crates/build-*`, `crates/workspace-hack` (if any doc lines). + +- Imperative summary lines -> third-person indicative (`build-xtask` 25 and the rest). +- `//!` first line on every production and test module file in these crates lacking one. +- `crates/workshop/shell/src/main.rs`: move the `//!` line above the leading `//` release-build comment. + +After the docs land, run the full verification block from Technical Design in order: `cargo fmt --all --check`; both clippy partitions with `-D warnings`; `$env:RUSTDOCFLAGS = "-D warnings"; cargo doc ...`; both nextest partitions; the workspace doctest pass; `cargo test -p build-xtask`. Any failure is fixed inside this step's commit before it lands, and the fix is named in the return. Verify dispatch runs on this step with Scope `FULL`. + +Tests: none new beyond the verification block. The 254 summary lines, 48 production files, and 105 test files from Technical Design D7 are all covered across Steps 11-13; re-run the D7 counting greps at the end and report any residue. + +Commit: one commit. + + + + diff --git a/vibe/ACTIVE b/vibe/ACTIVE new file mode 100644 index 000000000..1d82d94af --- /dev/null +++ b/vibe/ACTIVE @@ -0,0 +1 @@ +vibe/2026-09-20-2-rust-rulebook-sweep.md \ No newline at end of file From ca820f9cf01a1d10fc411b11e45b155aaba669b2 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Sun, 20 Sep 2026 06:40:47 -0700 Subject: [PATCH 02/39] Move workspace and launch filesystem work off the executor Every async workspace operation that stats, canonicalizes, reads, copies, or writes a path now hands that work to the tokio blocking pool and awaits it, so an executor thread never waits on the disk. The harness launch path does the same for its agent directory walk and source read, through the harness's single instrumented spawn site. A worker that cannot report back surfaces as an I/O error in the caller's existing error shape, or, where the call already tolerated failure, is logged and degraded the same way. Two public summary types move to the crate root, and the sibling-copy cleanup of a duplicate becomes one synchronous helper so it runs whole on a worker. - `crates/workshop/workspace/src/blocking.rs` is the crate's one seam onto `tokio::task::spawn_blocking`: `blocking` folds a join failure into `io::Error`, and `try_blocking` folds it into the caller's error type through a `join_failed` closure, so each call site sees one error type. Turso's database I/O does not pass through it. - `spawn_blocking_launch` joins `spawn_tagged`, `spawn_blocking_tagged`, and `spawn_session` as a permitted caller of the raw tokio method under `#[allow(clippy::disallowed_methods)]`; its span is named `launch` and carries the agent name, since no run or session exists yet to tag. - `GrantEntry` and `WorkspaceSummary` move from `workspace-backing.rs` into `workspace.rs`, and the `pub use backing::{GrantEntry, WorkspaceSummary}` re-export goes away; the backing module now imports them from its parent. - `ui_state` becomes `pub(crate) mod ui_state`, and the two test modules import `UI_STATE_VALUE_CAP` through it; the `#[cfg(test)]` re-export in `workspace_file.rs` is removed. - `copy_siblings_or_clean_up` owns the copy-then-undo sequence of a duplicate, and `copy_siblings` and `remove_sibling` drop to private; `already_taken` moves beside it as `pub(super)` since both create and duplicate use it. - `names_same_file` replaces the inline pair of `canonicalize_simplified` calls in `open_file`, so the whole same-file comparison runs in one blocking closure. - `handlers.rs` `tree`, `read_file`, and `write_file` run the confinement check and the I/O inside one closure, so the check-then-use window is no wider than before; a join failure maps to `ListDirectory`, `ReadFile`, and `WriteFile` respectively. - `discover_agents` and `agent_source` run on the blocking pool during a launch, each under `spawn_blocking_launch`; either join failure becomes `LaunchError::SessionState` with an `io::Error::other` source. - `Workspace::current` stats every granted root in one blocking trip; when the worker cannot report, it warns and lists no grants rather than failing the call. - `remember` is now `async` and awaited, so the last-workspace pointer is on disk when `swap_backing` and `reload_current` return; a write or join failure is still only logged. - `close_database` and `snapshot` in the actor await their blocking work, so the actor handles no other command while a sidecar check or file copy is in flight. - `spawn_blocking_launch_runs_a_closure_to_completion` is the only new test; the workspace crate's tests change only in the two `UI_STATE_VALUE_CAP` import paths, so `blocking` and `try_blocking` have no direct test of their join-failure arms. Design: new surface-growth @ crates/harness/runner/src/spawn.rs::spawn_blocking_launch deps: F,str boundary: pub Design: replaces value-object @ crates/workshop/workspace/src/workspace.rs::GrantEntry boundary: pub was: crates/workshop/workspace/src/workspace-backing.rs::GrantEntry Design: new pure-function @ crates/workshop/workspace/src/workspace_file.rs::io_failure deps: Error Design: replaces pure-function @ crates/workshop/workspace/src/workspace_file-siblings.rs::already_taken deps: str was: crates/workshop/workspace/src/workspace_file.rs::already_taken Design: new swallowed-exception @ crates/workshop/workspace/src/workspace-backing.rs::Workspace::current Design: extends swallowed-exception @ crates/workshop/workspace/src/workspace-pointer.rs::Workspace::reopen_last Design: extends swallowed-exception @ crates/workshop/workspace/src/workspace-pointer.rs::Workspace::remember Design: extends swallowed-exception @ crates/workshop/workspace/src/workspace_file-actor.rs::close_database Plan: vibe/2026-09-20-2-rust-rulebook-sweep.md --- crates/harness/runner/clippy.toml | 4 +- crates/harness/runner/src/lib.rs | 5 +- crates/harness/runner/src/spawn.rs | 40 +++++++-- crates/harness/runner/tests/it/spawn.rs | 9 +- crates/harness/sessions/src/runtime.rs | 22 ++++- crates/workshop/workspace/src/blocking.rs | 39 ++++++++ .../src/handlers-file-state-tests.rs | 2 +- crates/workshop/workspace/src/handlers.rs | 44 +++++++-- crates/workshop/workspace/src/lib.rs | 1 + .../workspace/src/workspace-backing.rs | 89 ++++++++++--------- .../workspace/src/workspace-confine.rs | 9 ++ .../workspace/src/workspace-pointer.rs | 28 ++++-- .../workspace/src/workspace-tests-ui-state.rs | 2 +- crates/workshop/workspace/src/workspace.rs | 27 +++++- .../workspace/src/workspace_file-actor.rs | 26 ++++-- .../workspace/src/workspace_file-siblings.rs | 32 ++++++- .../workshop/workspace/src/workspace_file.rs | 66 ++++++++------ vibe/2026-09-20-2-rust-rulebook-sweep.md | 2 +- 18 files changed, 332 insertions(+), 115 deletions(-) create mode 100644 crates/workshop/workspace/src/blocking.rs diff --git a/crates/harness/runner/clippy.toml b/crates/harness/runner/clippy.toml index a415caed6..64df9bc0a 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 `#[allow(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/lib.rs b/crates/harness/runner/src/lib.rs index d0eb57188..a4fcee024 100644 --- a/crates/harness/runner/src/lib.rs +++ b/crates/harness/runner/src/lib.rs @@ -15,8 +15,9 @@ //! 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. diff --git a/crates/harness/runner/src/spawn.rs b/crates/harness/runner/src/spawn.rs index 310e62ec0..4d7df5ea1 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; @@ -94,3 +96,29 @@ where f() }) } + +/// Run `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. +#[allow(clippy::disallowed_methods)] +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/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/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/workshop/workspace/src/blocking.rs b/crates/workshop/workspace/src/blocking.rs new file mode 100644 index 000000000..d1bcde997 --- /dev/null +++ b/crates/workshop/workspace/src/blocking.rs @@ -0,0 +1,39 @@ +//! The blocking-pool seam for the workspace's filesystem work. +//! +//! Every `async fn` in this crate that touches the disk directly - a +//! stat, a canonicalize, a read, a copy, a pointer write - runs that +//! work through one of the two helpers here so a tokio executor thread +//! never waits on I/O. Turso's own database I/O is async-native and does +//! not come through here. A worker that panics, or is cancelled at +//! runtime shutdown, surfaces as an `io::Error` carrying the join +//! failure, the same shape `workshop-user-state` gives its atomic write. + +use std::io; + +/// Runs `work`, synchronous filesystem work, on tokio's blocking pool +/// and hands back what it returned; a join failure is the `Err`. +pub(crate) async fn blocking(work: impl FnOnce() -> T + Send + 'static) -> io::Result +where + T: Send + 'static, +{ + tokio::task::spawn_blocking(work) + .await + .map_err(io::Error::other) +} + +/// [`blocking`] for work that itself fails with `E`: the worker's own +/// failure passes through, and a join failure is folded into `E` by +/// `join_failed`, so the caller sees one error type. +pub(crate) async fn try_blocking( + work: impl FnOnce() -> Result + Send + 'static, + join_failed: impl FnOnce(io::Error) -> E, +) -> Result +where + T: Send + 'static, + E: Send + 'static, +{ + match blocking(work).await { + Ok(result) => result, + Err(source) => Err(join_failed(source)), + } +} diff --git a/crates/workshop/workspace/src/handlers-file-state-tests.rs b/crates/workshop/workspace/src/handlers-file-state-tests.rs index ca99344af..f8f57dfd3 100644 --- a/crates/workshop/workspace/src/handlers-file-state-tests.rs +++ b/crates/workshop/workspace/src/handlers-file-state-tests.rs @@ -11,7 +11,7 @@ use axum::http::{Request, StatusCode}; use tower::ServiceExt as _; use crate::handlers::routes; -use crate::workspace_file::UI_STATE_VALUE_CAP; +use crate::workspace_file::ui_state::UI_STATE_VALUE_CAP; /// Collects a response body already buffered in memory and parses it. async fn json_body(response: Response) -> serde_json::Value { diff --git a/crates/workshop/workspace/src/handlers.rs b/crates/workshop/workspace/src/handlers.rs index 08ecbda4e..e10574fc3 100644 --- a/crates/workshop/workspace/src/handlers.rs +++ b/crates/workshop/workspace/src/handlers.rs @@ -13,6 +13,7 @@ use serde::{Deserialize, Serialize}; use workshop_support::{DEFAULT_DEADLINE, with_deadline}; +use crate::blocking::try_blocking; use crate::error::WorkspaceError; use crate::workspace::Workspace; @@ -109,34 +110,59 @@ fn decode_path_param(raw: &str) -> String { } /// Lists one level of a workspace directory, or the granted roots when the -/// query carries no path. +/// query carries no path. The listing is filesystem work and runs on the +/// blocking pool; the confinement check runs inside the same call, so the +/// check-then-use window is no wider than before. pub(crate) async fn tree( State(workspace): State, Query(query): Query, ) -> Response { let path = query.path.as_deref().map(decode_path_param); - respond(workspace.tree(path.as_deref().map(Path::new))) + respond( + try_blocking( + move || workspace.tree(path.as_deref().map(Path::new)), + |source| WorkspaceError::ListDirectory { source }, + ) + .await, + ) } -/// Reads a confined UTF-8 text file with its metadata. +/// Reads a confined UTF-8 text file with its metadata. Confinement and +/// the read run together on the blocking pool. pub(crate) async fn read_file( State(workspace): State, Query(query): Query, ) -> Response { let path = decode_path_param(&query.path); - respond(workspace.read_file(Path::new(&path))) + respond( + try_blocking( + move || workspace.read_file(Path::new(&path)), + |source| WorkspaceError::ReadFile { source }, + ) + .await, + ) } /// Writes a confined file after path, size, and conflict-token validation. +/// Confinement, the token check, and the write run together on the +/// blocking pool. pub(crate) async fn write_file( State(workspace): State, Json(body): Json, ) -> Response { - respond(workspace.write_file( - Path::new(&body.path), - &body.text, - body.expected_token.as_deref(), - )) + respond( + try_blocking( + move || { + workspace.write_file( + Path::new(&body.path), + &body.text, + body.expected_token.as_deref(), + ) + }, + |source| WorkspaceError::WriteFile { source }, + ) + .await, + ) } /// Registers a dropped path as a granted root, mirrored into the open diff --git a/crates/workshop/workspace/src/lib.rs b/crates/workshop/workspace/src/lib.rs index 43ac7ea56..4584a6ccf 100644 --- a/crates/workshop/workspace/src/lib.rs +++ b/crates/workshop/workspace/src/lib.rs @@ -27,6 +27,7 @@ //! - The crate maps its own [`WorkspaceError`] to the wire envelope at //! its route boundary; no shell error type appears here. +mod blocking; mod error; mod handlers; pub mod handles; diff --git a/crates/workshop/workspace/src/workspace-backing.rs b/crates/workshop/workspace/src/workspace-backing.rs index e59780464..e1c04a134 100644 --- a/crates/workshop/workspace/src/workspace-backing.rs +++ b/crates/workshop/workspace/src/workspace-backing.rs @@ -11,15 +11,15 @@ use std::path::{Path, PathBuf}; use std::sync::PoisonError; use std::sync::atomic::Ordering; -use serde::Serialize; - +use crate::blocking::{blocking, try_blocking}; use crate::error::WorkspaceError; use crate::workspace_file::{ GrantRow, WindowState, WorkspaceContents, WorkspaceFile, WorkspaceFileError, empty_ui_state, stem_of, }; -use super::{GrantMeta, Workspace, canonicalize_simplified}; +use super::confine::names_same_file; +use super::{GrantEntry, GrantMeta, Workspace, WorkspaceSummary}; #[path = "workspace-ui-state.rs"] mod ui_state; @@ -40,29 +40,6 @@ pub(super) struct Backing { ui_state: BTreeMap<&'static str, Option>, } -/// One granted root as the workspace reports it. -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct GrantEntry { - /// The canonical granted root. - pub path: PathBuf, - /// Whether the root is on disk right now. A vanished root stays - /// granted and listed so the user can see it and revoke it. - pub exists: bool, -} - -/// The workspace as a whole: its file, if any, and what it holds. -#[derive(Debug, Clone, Serialize)] -pub struct WorkspaceSummary { - /// The backing file; `None` while the workspace is ephemeral. - pub path: Option, - /// The display name: the file's own, or `Untitled` while ephemeral. - pub name: String, - /// The granted roots in canonical order. - pub grants: Vec, - /// The saved window geometry; `None` while ephemeral or never saved. - pub window_state: Option, -} - impl Workspace { /// Registers `path` as a granted root and mirrors the grant into the /// backing file when one is open. Memory is updated first and stands @@ -74,7 +51,14 @@ impl Workspace { /// # Errors /// The same as [`Workspace::grant`]; persistence never fails the call. pub async fn grant_and_persist(&self, path: &Path) -> Result { - let (root, meta) = self.grant_with_meta(path)?; + // The grant canonicalizes and stats the path: blocking-pool work. + let workspace = self.clone(); + let requested = path.to_path_buf(); + let (root, meta) = try_blocking( + move || workspace.grant_with_meta(&requested), + |source| WorkspaceError::ResolveGrant { source }, + ) + .await?; if let Some(file) = self.backing_file() { let row = GrantRow { path: root.clone(), @@ -102,7 +86,14 @@ impl Workspace { /// The same as [`Workspace::revoke`]; persistence never fails the /// call. pub async fn revoke_and_persist(&self, path: &Path) -> Result { - let root = self.revoke(path)?; + // The revoke canonicalizes the path: blocking-pool work. + let workspace = self.clone(); + let requested = path.to_path_buf(); + let root = try_blocking( + move || workspace.revoke(&requested), + |source| WorkspaceError::ResolveGrant { source }, + ) + .await?; if let Some(file) = self.backing_file() && let Err(error) = file.remove_grant(&root).await { @@ -148,11 +139,14 @@ impl Workspace { // appending to: every write after that would be lost at quit. // Compare canonical forms so a respelling of the same path (a `.` // segment, a case difference on Windows) takes the same branch. - if let Some((file, current)) = self.backing_parts() - && let Ok(requested) = canonicalize_simplified(path) - && canonicalize_simplified(¤t).is_ok_and(|current| current == requested) - { - return self.reload_current(&file, path).await; + if let Some((file, current)) = self.backing_parts() { + let requested = path.to_path_buf(); + let same = blocking(move || names_same_file(&requested, ¤t)) + .await + .map_err(|source| WorkspaceError::ResolvePath { source })?; + if same { + return self.reload_current(&file, path).await; + } } let file = WorkspaceFile::open(path).await?; let contents = match file.contents().await { @@ -260,14 +254,23 @@ impl Workspace { /// the file. A file that cannot be read degrades to its stem and no /// window state rather than failing the call. pub async fn current(&self) -> WorkspaceSummary { - let grants = self - .granted_roots() - .into_iter() - .map(|path| GrantEntry { - exists: fs::metadata(&path).is_ok(), - path, - }) - .collect(); + // One blocking-pool trip stats every root; a worker that cannot + // report degrades to no grants listed rather than failing the call. + let roots = self.granted_roots(); + let grants = blocking(move || { + roots + .into_iter() + .map(|path| GrantEntry { + exists: fs::metadata(&path).is_ok(), + path, + }) + .collect::>() + }) + .await + .unwrap_or_else(|error| { + tracing::warn!(%error, "granted roots not inspected; reporting none"); + Vec::new() + }); let Some((file, path)) = self.backing_parts() else { return WorkspaceSummary { path: None, @@ -415,7 +418,7 @@ impl Workspace { path: path.to_path_buf(), ui_state, }); - self.remember(path); + self.remember(path).await; if let Some(previous) = previous { previous.file.close().await; } @@ -442,7 +445,7 @@ impl Workspace { { backing.ui_state = contents.ui_state; } - self.remember(path); + self.remember(path).await; Ok(()) } diff --git a/crates/workshop/workspace/src/workspace-confine.rs b/crates/workshop/workspace/src/workspace-confine.rs index fb6c90755..9b1d85d22 100644 --- a/crates/workshop/workspace/src/workspace-confine.rs +++ b/crates/workshop/workspace/src/workspace-confine.rs @@ -81,6 +81,15 @@ pub(super) fn canonicalize_simplified(path: &Path) -> io::Result { Ok(dunce::simplified(&path.canonicalize()?).to_path_buf()) } +/// Whether `requested` and `current` canonicalize to the same file. A +/// path that does not canonicalize is not the same file; the open that +/// follows reports why. +pub(super) fn names_same_file(requested: &Path, current: &Path) -> bool { + canonicalize_simplified(requested).is_ok_and(|requested| { + canonicalize_simplified(current).is_ok_and(|current| current == requested) + }) +} + /// Rejects the lexical tricks canonicalization would otherwise hide: `..` /// traversal everywhere, and `:` alternate data stream names on Windows, /// where a colon in a name addresses an NTFS stream. Elsewhere a colon is diff --git a/crates/workshop/workspace/src/workspace-pointer.rs b/crates/workshop/workspace/src/workspace-pointer.rs index 7521bd132..d34e0c7d6 100644 --- a/crates/workshop/workspace/src/workspace-pointer.rs +++ b/crates/workshop/workspace/src/workspace-pointer.rs @@ -11,6 +11,8 @@ use std::fs; use std::io; use std::path::{Path, PathBuf}; +use crate::blocking::blocking; + use super::Workspace; /// The pointer file's name inside the state directory. @@ -106,9 +108,17 @@ impl Workspace { /// that has vanished, and a file that is refused all log and leave /// the workspace ephemeral, so boot goes on regardless. pub async fn reopen_last(&self) -> bool { - let Some(path) = self.pointer.as_ref().and_then(LastWorkspacePointer::read) else { + let Some(pointer) = self.pointer.clone() else { return false; }; + let path = match blocking(move || pointer.read()).await { + Ok(Some(path)) => path, + Ok(None) => return false, + Err(error) => { + tracing::warn!(%error, "last-workspace pointer not read; starting ephemeral"); + return false; + } + }; match self.open_file(&path).await { Ok(()) => { tracing::info!(file = %path.display(), "last-used workspace reopened"); @@ -126,12 +136,16 @@ impl Workspace { } /// Records `path` as the last-used workspace file when a state - /// directory is configured. A write that fails is logged; the switch - /// that just happened stands. - pub(super) fn remember(&self, path: &Path) { - if let Some(pointer) = &self.pointer - && let Err(error) = pointer.write(path) - { + /// directory is configured. The write runs on the blocking pool and + /// is awaited, so the pointer is on disk when this returns. A write + /// that fails is logged; the switch that just happened stands. + pub(super) async fn remember(&self, path: &Path) { + let Some(pointer) = self.pointer.clone() else { + return; + }; + let target = path.to_path_buf(); + let written = blocking(move || pointer.write(&target)).await; + if let Err(error) = written.unwrap_or_else(Err) { tracing::warn!( %error, file = %path.display(), diff --git a/crates/workshop/workspace/src/workspace-tests-ui-state.rs b/crates/workshop/workspace/src/workspace-tests-ui-state.rs index c3503d676..1199c231d 100644 --- a/crates/workshop/workspace/src/workspace-tests-ui-state.rs +++ b/crates/workshop/workspace/src/workspace-tests-ui-state.rs @@ -271,7 +271,7 @@ async fn an_over_cap_value_is_refused_without_touching_memory_or_file() { let path = home.path().join("cap.pfwork"); let workspace = Workspace::new(); workspace.save_as(&path).await.expect("save as creates"); - let cap = crate::workspace_file::UI_STATE_VALUE_CAP; + let cap = crate::workspace_file::ui_state::UI_STATE_VALUE_CAP; let oversized = string_of_serialized_len(cap + 1); assert_eq!(oversized.to_string().len(), cap + 1); diff --git a/crates/workshop/workspace/src/workspace.rs b/crates/workshop/workspace/src/workspace.rs index 402bd0b3c..90e12144c 100644 --- a/crates/workshop/workspace/src/workspace.rs +++ b/crates/workshop/workspace/src/workspace.rs @@ -23,7 +23,7 @@ use std::sync::{Arc, PoisonError, RwLock}; use serde::Serialize; use crate::error::WorkspaceError; -use crate::workspace_file::now_rfc3339; +use crate::workspace_file::{WindowState, now_rfc3339}; #[path = "workspace-backing.rs"] mod backing; @@ -35,7 +35,6 @@ mod pointer; mod token; use backing::Backing; -pub use backing::{GrantEntry, WorkspaceSummary}; use confine::{canonicalize_simplified, modified_ms, reject_forbidden}; use token::{current_token, file_token}; #[cfg(test)] @@ -98,6 +97,30 @@ pub struct FileContents { text: String, } +/// One granted root as the workspace reports it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct GrantEntry { + /// The canonical granted root. + pub path: PathBuf, + /// Whether the root is on disk right now. A vanished root stays + /// granted and listed so the user can see it and revoke it. + pub exists: bool, +} + +/// The workspace as a whole: its file, if any, and what it holds; built +/// by [`Workspace::current`] in the backing module. +#[derive(Debug, Clone, Serialize)] +pub struct WorkspaceSummary { + /// The backing file; `None` while the workspace is ephemeral. + pub path: Option, + /// The display name: the file's own, or `Untitled` while ephemeral. + pub name: String, + /// The granted roots in canonical order. + pub grants: Vec, + /// The saved window geometry; `None` while ephemeral or never saved. + pub window_state: Option, +} + /// What memory keeps beside each granted root so the file holds the /// workspace's true history: the grant's order among its peers and when /// it was made. Confinement never reads it. Its two readers are in the diff --git a/crates/workshop/workspace/src/workspace_file-actor.rs b/crates/workshop/workspace/src/workspace_file-actor.rs index afc131def..77b099008 100644 --- a/crates/workshop/workspace/src/workspace_file-actor.rs +++ b/crates/workshop/workspace/src/workspace_file-actor.rs @@ -10,8 +10,9 @@ use tokio::sync::{mpsc, oneshot}; use super::ui_state::{put_ui_state_row, read_ui_state_rows}; use super::{ FORMAT_NAME, GrantRow, KV_WINDOW, META_CREATED_AT, META_FORMAT, META_NAME, META_VERSION, - SUPPORTED_VERSION, WindowState, WorkspaceContents, WorkspaceFileError, + SUPPORTED_VERSION, WindowState, WorkspaceContents, WorkspaceFileError, io_failure, }; +use crate::blocking::blocking; /// A reply slot for a command that answers with success or failure. type Ack = oneshot::Sender>; @@ -154,13 +155,18 @@ pub(crate) async fn run(mut rx: mpsc::Receiver, conn: turso::Connection /// exactly the file behind: the WAL is checkpointed into the main file /// and truncated, the connection dropped, and the emptied `-wal` sidecar /// removed. A sidecar that still holds frames is never touched; the -/// engine replays it on the next open. +/// engine replays it on the next open. The sidecar check is filesystem +/// work and runs on the blocking pool, awaited, so the close is complete +/// when this returns. pub(crate) async fn close_database(conn: turso::Connection, path: &Path) { if let Err(error) = checkpoint(&conn).await { tracing::warn!(%error, path = %path.display(), "workspace file checkpoint failed on close"); } drop(conn); - remove_empty_wal_sidecar(path); + let sidecar_of = path.to_path_buf(); + if let Err(error) = blocking(move || remove_empty_wal_sidecar(&sidecar_of)).await { + tracing::warn!(%error, path = %path.display(), "workspace file wal sidecar not checked"); + } } /// Folds every WAL frame into the main file and truncates the WAL, so @@ -177,17 +183,21 @@ async fn checkpoint(conn: &turso::Connection) -> Result<(), WorkspaceFileError> /// `destination`. Running on the actor, between commands, this is the /// one moment the main file is guaranteed complete and still: the /// actor owns the only connection, so nothing writes or checkpoints -/// until the copy returns. The copy is a small synchronous file copy on -/// the actor task, the same blocking discipline as [`close_database`]. +/// until the copy returns. The copy is synchronous filesystem work and +/// runs on the blocking pool, awaited here so the actor handles no other +/// command while it is in flight, the same discipline as +/// [`close_database`]. async fn snapshot( conn: &turso::Connection, path: &Path, destination: &Path, ) -> Result<(), WorkspaceFileError> { checkpoint(conn).await?; - std::fs::copy(path, destination) - .map(|_| ()) - .map_err(|source| WorkspaceFileError::Io { source }) + let (from, to) = (path.to_path_buf(), destination.to_path_buf()); + match blocking(move || std::fs::copy(&from, &to)).await { + Ok(Ok(_)) => Ok(()), + Ok(Err(source)) | Err(source) => Err(io_failure(source)), + } } /// Writes the stamp, the grants, the window state, and any ui-state diff --git a/crates/workshop/workspace/src/workspace_file-siblings.rs b/crates/workshop/workspace/src/workspace_file-siblings.rs index aaa292968..9acce175f 100644 --- a/crates/workshop/workspace/src/workspace_file-siblings.rs +++ b/crates/workshop/workspace/src/workspace_file-siblings.rs @@ -6,7 +6,7 @@ use std::path::{Path, PathBuf}; use std::{fs, io}; -use super::{WorkspaceFileError, already_taken}; +use super::WorkspaceFileError; /// The entries a workspace may grow beside its file, each created /// lazily by its own project; duplicate copies whichever exist, minus @@ -53,8 +53,34 @@ pub(super) fn plan_siblings( Ok(siblings) } +/// Copies every planned sibling of a duplicate; when one copy fails, +/// removes the siblings already copied and the copied workspace file at +/// `destination`, so a failed duplicate leaves nothing behind. A failed +/// removal cannot say more than the copy failure did. +pub(super) fn copy_siblings_or_clean_up( + siblings: &[(PathBuf, PathBuf)], + destination: &Path, +) -> io::Result<()> { + if let Err(source) = copy_siblings(siblings) { + for (_, to) in siblings { + let _ = remove_sibling(to); + } + let _ = fs::remove_file(destination); + return Err(source); + } + Ok(()) +} + +/// An `AlreadyExists` I/O refusal carrying `message`: the shape both +/// create and duplicate use to refuse a path that is already taken. +pub(super) fn already_taken(message: &'static str) -> WorkspaceFileError { + WorkspaceFileError::Io { + source: io::Error::new(io::ErrorKind::AlreadyExists, message), + } +} + /// Copies every planned sibling, a directory tree or a single file. -pub(super) fn copy_siblings(siblings: &[(PathBuf, PathBuf)]) -> io::Result<()> { +fn copy_siblings(siblings: &[(PathBuf, PathBuf)]) -> io::Result<()> { for (from, to) in siblings { if from.is_dir() { copy_dir_recursive(from, to)?; @@ -66,7 +92,7 @@ pub(super) fn copy_siblings(siblings: &[(PathBuf, PathBuf)]) -> io::Result<()> { } /// Removes a copied sibling, a directory tree or a single file. -pub(super) fn remove_sibling(path: &Path) -> io::Result<()> { +fn remove_sibling(path: &Path) -> io::Result<()> { if path.is_dir() { fs::remove_dir_all(path) } else { diff --git a/crates/workshop/workspace/src/workspace_file.rs b/crates/workshop/workspace/src/workspace_file.rs index 4566cbad2..0c0797568 100644 --- a/crates/workshop/workspace/src/workspace_file.rs +++ b/crates/workshop/workspace/src/workspace_file.rs @@ -26,15 +26,15 @@ mod actor; #[path = "workspace_file-siblings.rs"] mod siblings; #[path = "workspace_file-ui-state.rs"] -mod ui_state; +pub(crate) mod ui_state; pub(crate) use actor::now_rfc3339; use actor::{COMMAND_QUEUE_DEPTH, Command, SCHEMA_V1}; -use siblings::{copy_siblings, plan_siblings, remove_sibling}; -#[cfg(test)] -pub(crate) use ui_state::UI_STATE_VALUE_CAP; +use siblings::{already_taken, copy_siblings_or_clean_up, plan_siblings}; pub(crate) use ui_state::{UI_STATE_KEYS, check_ui_state_cap, empty_ui_state, ui_state_key}; +use crate::blocking::{blocking, try_blocking}; + /// Meta key naming the file format; always [`FORMAT_NAME`]. pub(crate) const META_FORMAT: &str = "format"; /// Meta key carrying the schema version; always [`SUPPORTED_VERSION`] @@ -175,7 +175,8 @@ impl WorkspaceFile { path: &Path, contents: &WorkspaceContents, ) -> Result { - if path.exists() { + let probe = path.to_path_buf(); + if blocking(move || probe.exists()).await.map_err(io_failure)? { return Err(already_taken("workspace file path is already taken")); } let conn = open_database(path).await?; @@ -183,8 +184,12 @@ impl WorkspaceFile { // The connection must go before the file can; a failed // removal cannot say more than the write failure did. drop(conn); - let _ = fs::remove_file(actor::wal_sidecar_of(path)); - let _ = fs::remove_file(path); + let created = path.to_path_buf(); + let _ = blocking(move || { + let _ = fs::remove_file(actor::wal_sidecar_of(&created)); + let _ = fs::remove_file(&created); + }) + .await; return Err(error); } Ok(Self::spawn(conn, path)) @@ -197,7 +202,11 @@ impl WorkspaceFile { /// that fails it is left byte-identical. The path must already /// exist: opening never creates. pub(crate) async fn open(path: &Path) -> Result { - if !path.is_file() { + let probe = path.to_path_buf(); + if !blocking(move || probe.is_file()) + .await + .map_err(io_failure)? + { return Err(WorkspaceFileError::Io { source: io::Error::new(io::ErrorKind::NotFound, "workspace file does not exist"), }); @@ -271,28 +280,37 @@ impl WorkspaceFile { /// is written, so another workspace's data is never merged into. A /// failure after the copy appears removes the file and every /// sibling this call created. + /// + /// The probes and the sibling copies are synchronous filesystem work + /// and run on the blocking pool; the snapshot itself runs on the actor. pub(crate) async fn duplicate_to( &self, destination: &Path, ) -> Result { - if destination.exists() { - return Err(already_taken("workspace file path is already taken")); - } - let siblings = plan_siblings(&self.path, destination)?; + let source = self.path.to_path_buf(); + let target = destination.to_path_buf(); + let siblings = try_blocking( + move || { + if target.exists() { + return Err(already_taken("workspace file path is already taken")); + } + plan_siblings(&source, &target) + }, + io_failure, + ) + .await?; let target = destination.to_path_buf(); self.request(|reply| Command::Snapshot { destination: target, reply, }) .await?; - if let Err(source) = copy_siblings(&siblings) { - // A failed removal cannot say more than the copy failure did. - for (_, to) in &siblings { - let _ = remove_sibling(to); - } - let _ = fs::remove_file(destination); - return Err(WorkspaceFileError::Io { source }); - } + let target = destination.to_path_buf(); + try_blocking( + move || copy_siblings_or_clean_up(&siblings, &target).map_err(io_failure), + io_failure, + ) + .await?; Self::open(destination).await } @@ -438,11 +456,9 @@ async fn has_meta_table(conn: &turso::Connection) -> Result WorkspaceFileError { - WorkspaceFileError::Io { - source: io::Error::new(io::ErrorKind::AlreadyExists, message), - } +/// An I/O failure as the file error that carries it. +pub(crate) fn io_failure(source: io::Error) -> WorkspaceFileError { + WorkspaceFileError::Io { source } } /// Whether an engine failure says the file is not a database at all. diff --git a/vibe/2026-09-20-2-rust-rulebook-sweep.md b/vibe/2026-09-20-2-rust-rulebook-sweep.md index 5d48ab631..356771ee9 100644 --- a/vibe/2026-09-20-2-rust-rulebook-sweep.md +++ b/vibe/2026-09-20-2-rust-rulebook-sweep.md @@ -302,7 +302,7 @@ Commit: one commit naming the test and the gate. -### Step 2: Blocking filesystem work off the tokio executor +### Step 2: Blocking filesystem work off the tokio executor [completed] - Component: `async-fs` - Piece: workshop-workspace and harness-sessions (D1) From 0120b33b2f28995c78d2676756a94dc4ce1a6c02 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Sun, 20 Sep 2026 07:01:08 -0700 Subject: [PATCH 03/39] Type the emitter's debug and trust flags as enums Replace the two boolean parameters on the engine emitter with typed enums. The debug capture flag becomes a two-variant mode enum that defaults to off, and the tool-result trust flag reuses the output trust enum the tools module already defines, so a caller names what it means instead of passing a bare true or false. The event stream does not change: the tool result event still carries a boolean trusted field, and the debug accessor still answers with a boolean. Every constructor and reporting call site across the runtime, Lua, parser, and bench crates passes the enums. - `DebugMode` is a new public `Copy` and `Eq` enum with `Off` as its `#[default]`; `Emitter::new`, `Emitter::root`, and the emitter's `debug` field hold it where a bool sat. - `tool_result` takes `OutputTrust` and derives the event's boolean with `trust == OutputTrust::Trusted`; the `Event::ToolResult` shape keeps `trusted: bool`, so nothing downstream of the sink changes. - `ToolDispatch` replaces its public `trusted()` accessor with `trust()` returning `OutputTrust`; `BuiltinAnswer` and `RunContext` retype their fields the same way, and `report_debug` takes the mode. - `Emitter::root` retyping fans out as one-literal edits to seven files outside the emitter, two of them the `null_emitter` helpers that already centralize a silent emitter. - `captures_debug` still returns a bool by comparing against `DebugMode::On`, so the enum is write-side only. - `an_untrusted_tool_result_reports_the_event_with_trusted_false` pins that `OutputTrust::Untrusted` lands on the wire as `trusted: false`; the two dispatch tests compare `trust()` against the enum instead of negating a bool. Design: new value-object @ crates/promptforge-api-types/src/emitter.rs::DebugMode boundary: pub Design: removes flag-parameter @ crates/promptforge-api-types/src/emitter.rs::Emitter::new Design: removes flag-parameter @ crates/promptforge-api-types/src/emitter.rs::Emitter::root Design: removes flag-parameter @ crates/promptforge-api-runtime/src/execute/config.rs::RunContext::report_debug Design: new shotgun-surgery @ crates/promptforge-api-types/src/emitter.rs::Emitter::root Plan: vibe/2026-09-20-2-rust-rulebook-sweep.md --- .../src/execute/config.rs | 15 +++---- .../src/execute/scheduler/builtins.rs | 15 +++---- .../src/execute/scheduler/tool_call.rs | 3 +- .../src/execute/tests/mod.rs | 2 +- .../src/test_support/recording.rs | 2 +- .../src/emitter-tests.rs | 40 +++++++++++++++++-- crates/promptforge-api-types/src/emitter.rs | 40 ++++++++++++++----- crates/promptforge/lua/benches/surface.rs | 4 +- crates/promptforge/lua/src/dispatch-tests.rs | 16 ++++++-- crates/promptforge/lua/src/dispatch.rs | 24 ++++++----- crates/promptforge/lua/src/program.rs | 8 ++-- crates/promptforge/lua/src/tests-recording.rs | 8 ++-- crates/promptforge/lua/src/vm.rs | 20 +++++----- crates/promptforge/parser/src/lib.rs | 4 +- vibe/2026-09-20-2-rust-rulebook-sweep.md | 2 +- 15 files changed, 135 insertions(+), 68 deletions(-) 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/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/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/tests/mod.rs b/crates/promptforge-api-runtime/src/execute/tests/mod.rs index d6516ca11..5cc5a4e00 100644 --- a/crates/promptforge-api-runtime/src/execute/tests/mod.rs +++ b/crates/promptforge-api-runtime/src/execute/tests/mod.rs @@ -363,7 +363,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 { 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-types/src/emitter-tests.rs b/crates/promptforge-api-types/src/emitter-tests.rs index 80a9447f3..30ee9097c 100644 --- a/crates/promptforge-api-types/src/emitter-tests.rs +++ b/crates/promptforge-api-types/src/emitter-tests.rs @@ -1,15 +1,16 @@ 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 +149,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 +161,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/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/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/program.rs b/crates/promptforge/lua/src/program.rs index 08bc25ac8..abfce855a 100644 --- a/crates/promptforge/lua/src/program.rs +++ b/crates/promptforge/lua/src/program.rs @@ -53,11 +53,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 +104,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/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/vm.rs b/crates/promptforge/lua/src/vm.rs index 204d1d71a..1e553c590 100644 --- a/crates/promptforge/lua/src/vm.rs +++ b/crates/promptforge/lua/src/vm.rs @@ -50,11 +50,11 @@ pub(crate) fn pack_sequence( /// # Examples /// ```text /// 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>(()) @@ -230,11 +230,11 @@ impl SectionVm { /// # Examples /// ```text /// 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>(()) @@ -432,11 +432,11 @@ impl SectionVm { /// # Examples /// ```text /// 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")) @@ -782,11 +782,11 @@ impl SectionVm { /// # Examples /// ```text /// 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")) @@ -995,11 +995,11 @@ impl SectionVm { /// # Examples /// ```text /// 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>(()) diff --git a/crates/promptforge/parser/src/lib.rs b/crates/promptforge/parser/src/lib.rs index 838f405ff..271742b72 100644 --- a/crates/promptforge/parser/src/lib.rs +++ b/crates/promptforge/parser/src/lib.rs @@ -16,7 +16,7 @@ //! //! The parser does no execution. It turns bytes into a [`Prompt`] tree. -use promptforge_api_types::emitter::{Emitter, EventSink}; +use promptforge_api_types::emitter::{DebugMode, Emitter, EventSink}; use promptforge_api_types::event::{Event, lifecycle}; pub use promptforge_lua::LuaProgram; @@ -552,7 +552,7 @@ impl Prompt { execution: &str, ) -> (std::result::Result, Vec) { let sink = EventSink::default(); - let emitter = Emitter::root(sink.clone(), execution, false); + let emitter = Emitter::root(sink.clone(), execution, DebugMode::Off); emitter.report("Prompt", lifecycle::PARSE_STARTED); let result = Self::parse_inner(input, &emitter); emitter.report( diff --git a/vibe/2026-09-20-2-rust-rulebook-sweep.md b/vibe/2026-09-20-2-rust-rulebook-sweep.md index 356771ee9..052edb4f6 100644 --- a/vibe/2026-09-20-2-rust-rulebook-sweep.md +++ b/vibe/2026-09-20-2-rust-rulebook-sweep.md @@ -330,7 +330,7 @@ Commit: one commit. -### Step 3: Typed enums for the emitter's debug and trusted flags +### Step 3: Typed enums for the emitter's debug and trusted flags [completed] - Component: `emitter-flags` - Piece: engine emitter (D5) From 734be561cfed4e1e951657c01a5204a36cd39c89 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Sun, 20 Sep 2026 07:59:58 -0700 Subject: [PATCH 04/39] Strip causes from error Display; render chains at exits Error variants that carry a source no longer repeat that source's text in their own message, so a rendered cause chain names each cause once. Where the text leaves the program - the run log's failed outcome, a failure pushed to a session client, the gateway's wire warning, the provider-sheet tool's stderr note, a refused agent launch's error frame - the renderer now walks the source chain instead of reading one message. Public error enums that exposed the database, JSON, and HTTP client libraries' error types now wrap them in crate-owned transparent newtypes with hand-written conversions, so every existing question-mark site still compiles. Thirteen messages drop "failed to" prefixes and acronym-led phrasing. - `display_chain`: one renderer in the harness runner joins an error and its causes with `: ` and skips a cause whose text the accumulated rendering already contains; `harness-api` re-exports it for clients. - `error_chain` in the cloud-providers crate joins with `; ` and does not deduplicate, and the gateway app's fence peel reuses its existing `crate::config_write::error_chain`. The harness-side and gateway-side renderers differ in separator and dedup rule. - `DatabaseSource`, `PayloadSource`, `HttpSource`, `JsonSource`: private-field `#[error(transparent)]` wrappers over the turso, serde_json, and reqwest error types. `LogError`, `FetchError`, and `WorkspaceFileError` gain hand-written `From` impls where `#[from]` was removed; `SidecarError` and `LocalError` call sites convert with `.into()`. - `RunFailure::Prepare`, `RunFailure::Drive`, and the api runtime's `Error::Substitution` become `#[error(transparent)]`, so the inner error is the whole message and chain. - `failed_outcome`: the run log's `runs.error_message` now stores the rendered chain rather than the outermost message; the parse-failure row written by `prepare_source` and its integration test follow. - `refusal_text`: a refused agent launch's error frame carries the refusal's cause text; the socket test builds a session-state refusal over an I/O error and checks the cause reaches the frame. - `StateError`: the `Composition` variant no longer names the missing contribution in its own text; the boot test reads it from `source()`. - `ToolCallRejection`: the `ArgumentsNotJson` message drops the decode error text; a test asserts the wire warning still carries that text through the chain. - `reqwest::Error`, `serde_json::Error`, and `turso::Error` fields on public variants change type to the newtypes; a downstream that matched on those fields must update. Design: new pure-function @ crates/harness/runner/src/display_chain.rs::display_chain deps: &dyn Error boundary: pub Design: extends facade @ crates/harness-api/src/lib.rs boundary: pub Design: new pure-function @ crates/gateway/cloud-providers/src/lib.rs::error_chain deps: &dyn std::error::Error boundary: pub Design: new pure-function @ crates/workshop/server/src/agents/socket.rs::refusal_text deps: &LaunchRefusal Design: new newtype @ crates/harness/log/src/error.rs::DatabaseSource boundary: pub Design: new newtype @ crates/harness/log/src/error.rs::PayloadSource boundary: pub Design: new newtype @ crates/workshop/workspace/src/workspace_file.rs::DatabaseSource boundary: pub Design: new newtype @ crates/gateway-api-discovery/src/error.rs::JsonSource boundary: pub Design: new newtype @ crates/gateway/local/src/error.rs::HttpSource boundary: pub Design: new newtype @ crates/gateway/local/src/error.rs::JsonSource boundary: pub Design: new newtype @ crates/gateway/cloud-providers/src/lib.rs::HttpSource boundary: pub Repairs: error frame text carries the launch refusal's cause chain @ crates/workshop/server/src/agents/socket.rs::refusal_text - a refused launch reached the client as the bare outer message with its I/O cause dropped Plan: vibe/2026-09-20-2-rust-rulebook-sweep.md --- crates/gateway-api-discovery/src/error.rs | 16 +++++- crates/gateway-api-discovery/src/file.rs | 7 +-- crates/gateway-api-discovery/src/lib.rs | 2 +- crates/gateway/app/src/api_error.rs | 2 +- crates/gateway/app/src/dialect.rs | 37 ++++++++++--- crates/gateway/app/src/error.rs | 2 +- crates/gateway/cloud-providers/src/lib.rs | 35 ++++++++++-- crates/gateway/cloud-providers/src/main.rs | 10 +++- crates/gateway/cloud-providers/src/sheet.rs | 18 +++++-- crates/gateway/local/src/artifacts.rs | 2 +- .../gateway/local/src/artifacts/download.rs | 4 +- crates/gateway/local/src/dialect.rs | 11 ++-- crates/gateway/local/src/error.rs | 35 ++++++++++-- crates/gateway/local/src/lib.rs | 2 +- crates/gateway/local/src/server.rs | 4 +- crates/gateway/stt/api/src/artifacts.rs | 4 +- crates/gateway/stt/api/src/audio.rs | 2 +- crates/harness-api/src/lib.rs | 8 ++- crates/harness/log/src/error.rs | 51 ++++++++++++++---- crates/harness/log/src/lib.rs | 2 +- .../harness/runner/src/display_chain-tests.rs | 53 +++++++++++++++++++ crates/harness/runner/src/display_chain.rs | 37 +++++++++++++ crates/harness/runner/src/effect_loop.rs | 9 ++-- crates/harness/runner/src/lib.rs | 3 ++ crates/harness/runner/src/prepare.rs | 20 +++---- crates/harness/runner/tests/it/prepare.rs | 5 +- crates/harness/sessions/src/environment.rs | 5 +- .../harness/sessions/src/session/run-tests.rs | 29 ++++++++++ crates/harness/sessions/src/session/run.rs | 25 +++++---- .../sessions/src/session/supervisor.rs | 2 +- crates/harness/webfetch/src/error.rs | 4 +- crates/harness/webfetch/src/tool.rs | 2 +- crates/promptforge-api-runtime/src/error.rs | 15 +++--- .../server/src/agents/session-menu.rs | 2 +- .../server/src/agents/socket-tests.rs | 35 ++++++++++++ crates/workshop/server/src/agents/socket.rs | 19 ++++++- crates/workshop/server/src/app.rs | 2 +- crates/workshop/server/tests/it/boot.rs | 8 ++- crates/workshop/workspace/src/lib.rs | 2 +- .../workshop/workspace/src/workspace_file.rs | 15 ++++-- vibe/2026-09-20-2-rust-rulebook-sweep.md | 2 +- 41 files changed, 450 insertions(+), 98 deletions(-) create mode 100644 crates/harness/runner/src/display_chain-tests.rs create mode 100644 crates/harness/runner/src/display_chain.rs create mode 100644 crates/harness/sessions/src/session/run-tests.rs create mode 100644 crates/workshop/server/src/agents/socket-tests.rs diff --git a/crates/gateway-api-discovery/src/error.rs b/crates/gateway-api-discovery/src/error.rs index 00c0df0a8..a931431a3 100644 --- a/crates/gateway-api-discovery/src/error.rs +++ b/crates/gateway-api-discovery/src/error.rs @@ -6,6 +6,18 @@ use std::path::PathBuf; use std::time::Duration; +/// A JSON cause behind a [`SidecarError`] variant: a crate-owned wrapper so +/// the public error surface does not name the JSON library's error type. +#[derive(Debug, thiserror::Error)] +#[error(transparent)] +pub struct JsonSource(serde_json::Error); + +impl From for JsonSource { + fn from(source: serde_json::Error) -> Self { + JsonSource(source) + } +} + /// A failure of a gateway-discovery-file or launch-lock operation. #[derive(Debug, thiserror::Error)] #[non_exhaustive] @@ -41,7 +53,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 +70,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. 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-discovery/src/lib.rs b/crates/gateway-api-discovery/src/lib.rs index 2f3e1bfea..769e757e6 100644 --- a/crates/gateway-api-discovery/src/lib.rs +++ b/crates/gateway-api-discovery/src/lib.rs @@ -47,7 +47,7 @@ mod sys; mod validated; pub use crate::cancellation::CancellationToken; -pub use crate::error::SidecarError; +pub use crate::error::{JsonSource, SidecarError}; pub use crate::file::{GatewayDiscoveryFile, remove_if_mine}; pub use crate::health::{HealthError, ProbeError, wait_for_health, wait_for_health_cancellable}; pub use crate::lock::{ diff --git a/crates/gateway/app/src/api_error.rs b/crates/gateway/app/src/api_error.rs index 085340584..9337b3aa4 100644 --- a/crates/gateway/app/src/api_error.rs +++ b/crates/gateway/app/src/api_error.rs @@ -59,7 +59,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), diff --git a/crates/gateway/app/src/dialect.rs b/crates/gateway/app/src/dialect.rs index 91aa0393a..9cd1a30e2 100644 --- a/crates/gateway/app/src/dialect.rs +++ b/crates/gateway/app/src/dialect.rs @@ -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::config_write::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")] @@ -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..e0b640e93 100644 --- a/crates/gateway/app/src/error.rs +++ b/crates/gateway/app/src/error.rs @@ -127,7 +127,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, diff --git a/crates/gateway/cloud-providers/src/lib.rs b/crates/gateway/cloud-providers/src/lib.rs index bf9c8e31a..edfaf5878 100644 --- a/crates/gateway/cloud-providers/src/lib.rs +++ b/crates/gateway/cloud-providers/src/lib.rs @@ -98,9 +98,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,6 +119,34 @@ pub enum FetchError { }, } +impl From for FetchError { + fn from(source: reqwest::Error) -> Self { + FetchError::Http(HttpSource(source)) + } +} + +/// The transport cause behind [`FetchError::Http`]: a crate-owned wrapper +/// so the public error surface does not name the HTTP client's error type. +#[derive(Debug, thiserror::Error)] +#[error(transparent)] +pub struct HttpSource(reqwest::Error); + +/// Render 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. +#[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 { + text.push_str("; "); + text.push_str(&cause.to_string()); + source = cause.source(); + } + text +} + /// Fetch and normalize 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). diff --git a/crates/gateway/cloud-providers/src/main.rs b/crates/gateway/cloud-providers/src/main.rs index 608a33e98..8fb7d4e71 100644 --- a/crates/gateway/cloud-providers/src/main.rs +++ b/crates/gateway/cloud-providers/src/main.rs @@ -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()), } } @@ -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/sheet.rs b/crates/gateway/cloud-providers/src/sheet.rs index 4a0c408ea..956f4d8a4 100644 --- a/crates/gateway/cloud-providers/src/sheet.rs +++ b/crates/gateway/cloud-providers/src/sheet.rs @@ -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) } @@ -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/local/src/artifacts.rs b/crates/gateway/local/src/artifacts.rs index d95b62946..ef583dee3 100644 --- a/crates/gateway/local/src/artifacts.rs +++ b/crates/gateway/local/src/artifacts.rs @@ -796,7 +796,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/download.rs b/crates/gateway/local/src/artifacts/download.rs index 39b3656d6..0eacaaf8e 100644 --- a/crates/gateway/local/src/artifacts/download.rs +++ b/crates/gateway/local/src/artifacts/download.rs @@ -167,7 +167,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 +378,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/dialect.rs b/crates/gateway/local/src/dialect.rs index 2b1708db0..1ec93c1a5 100644 --- a/crates/gateway/local/src/dialect.rs +++ b/crates/gateway/local/src/dialect.rs @@ -168,7 +168,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 +253,7 @@ fn fetch_props_evidence(guard: &ServerGuard) -> Result Result for HttpSource { + fn from(source: reqwest::Error) -> Self { + HttpSource(source) + } +} + +/// A JSON decode cause behind a [`LocalError`] variant: a crate-owned +/// wrapper so the public error surface does not name the JSON library's +/// error type. +#[derive(Debug, thiserror::Error)] +#[error(transparent)] +pub struct JsonSource(serde_json::Error); + +impl From for JsonSource { + fn from(source: serde_json::Error) -> Self { + JsonSource(source) + } +} + /// A failure while downloading, verifying, or launching a local model. #[derive(Debug, thiserror::Error)] #[non_exhaustive] @@ -31,7 +56,7 @@ pub enum LocalError { /// Building the HTTP client failed. #[error("build HTTP client")] - HttpClient(#[source] reqwest::Error), + HttpClient(#[source] HttpSource), /// Downloading a URL failed. #[error("download `{url}`")] @@ -40,7 +65,7 @@ pub enum LocalError { url: String, /// The underlying transport error. #[source] - source: reqwest::Error, + source: HttpSource, }, /// Reading the download body failed. @@ -261,7 +286,7 @@ pub enum LocalError { ReadinessClient { /// The underlying transport error. #[source] - source: reqwest::Error, + source: HttpSource, }, /// Inspecting the child process (`try_wait`) failed. @@ -372,7 +397,7 @@ pub enum LocalError { operation: &'static str, /// The underlying transport error. #[source] - source: reqwest::Error, + source: HttpSource, }, /// A dialect-probe endpoint returned a non-success status. @@ -402,7 +427,7 @@ pub enum LocalError { operation: &'static str, /// The underlying JSON decode error. #[source] - source: serde_json::Error, + source: JsonSource, }, /// Resolving the tool dialect from `/props` evidence failed. diff --git a/crates/gateway/local/src/lib.rs b/crates/gateway/local/src/lib.rs index fb1a7fe6c..ec85c7187 100644 --- a/crates/gateway/local/src/lib.rs +++ b/crates/gateway/local/src/lib.rs @@ -36,7 +36,7 @@ mod testsupport; mod upstream; pub use crate::dialect::DialectResolveError; -pub use crate::error::LocalError; +pub use crate::error::{HttpSource, JsonSource, LocalError}; pub use crate::launch_templates::{ ChatTemplateResolution, ChatTemplateSource, inspect_chat_template, }; diff --git a/crates/gateway/local/src/server.rs b/crates/gateway/local/src/server.rs index 414d841a6..f7f6feb39 100644 --- a/crates/gateway/local/src/server.rs +++ b/crates/gateway/local/src/server.rs @@ -406,7 +406,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/stt/api/src/artifacts.rs b/crates/gateway/stt/api/src/artifacts.rs index 681c98033..68ad25cc9 100644 --- a/crates/gateway/stt/api/src/artifacts.rs +++ b/crates/gateway/stt/api/src/artifacts.rs @@ -141,7 +141,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 +149,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..00853eefe 100644 --- a/crates/gateway/stt/api/src/audio.rs +++ b/crates/gateway/stt/api/src/audio.rs @@ -19,7 +19,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/harness-api/src/lib.rs b/crates/harness-api/src/lib.rs index 5ec207e4c..4260eaa96 100644 --- a/crates/harness-api/src/lib.rs +++ b/crates/harness-api/src/lib.rs @@ -1,8 +1,9 @@ //! 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 //! @@ -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/log/src/error.rs b/crates/harness/log/src/error.rs index 24f15d625..04226d9da 100644 --- a/crates/harness/log/src/error.rs +++ b/crates/harness/log/src/error.rs @@ -7,27 +7,28 @@ use crate::RunId; /// Why a run log operation failed. #[derive(Debug, thiserror::Error)] 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: PayloadSource, }, /// No run with this id was ever begun in this log. #[error("run log: unknown run {0}")] @@ -40,3 +41,33 @@ pub enum LogError { #[error("run log: corrupt row: {0}")] Corrupt(String), } + +/// The database engine's error behind [`LogError::Database`], owned by +/// this crate so the public vocabulary names no third-party type. Renders +/// and sources exactly as the engine's error does. +#[derive(Debug, thiserror::Error)] +#[error(transparent)] +pub struct DatabaseSource(turso::Error); + +/// The serde error behind [`LogError::Payload`], owned by this crate so +/// the public vocabulary names no third-party type. Renders and sources +/// exactly as the serde error does. +#[derive(Debug, thiserror::Error)] +#[error(transparent)] +pub struct PayloadSource(serde_json::Error); + +impl From for LogError { + fn from(source: turso::Error) -> Self { + LogError::Database { + source: DatabaseSource(source), + } + } +} + +impl From for LogError { + fn from(source: serde_json::Error) -> Self { + LogError::Payload { + source: PayloadSource(source), + } + } +} diff --git a/crates/harness/log/src/lib.rs b/crates/harness/log/src/lib.rs index 2725a6660..bf0bc3a68 100644 --- a/crates/harness/log/src/lib.rs +++ b/crates/harness/log/src/lib.rs @@ -32,7 +32,7 @@ mod record; mod schema; pub use append::RunLog; -pub use error::LogError; +pub use error::{DatabaseSource, LogError, PayloadSource}; pub use record::{ Record, RecordFilter, RecordKind, RunId, RunMeta, RunOutcome, RunRow, Seq, StoredRecord, }; 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..0767a2b90 --- /dev/null +++ b/crates/harness/runner/src/display_chain-tests.rs @@ -0,0 +1,53 @@ +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..239c836a4 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}; @@ -448,12 +449,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 a4fcee024..08cc35ee6 100644 --- a/crates/harness/runner/src/lib.rs +++ b/crates/harness/runner/src/lib.rs @@ -33,9 +33,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..22c1f2e9f 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, @@ -112,8 +113,8 @@ pub struct Prepared { #[derive(Debug, thiserror::Error)] 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 +122,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 +136,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 +239,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/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/sessions/src/environment.rs b/crates/harness/sessions/src/environment.rs index aed2178e8..063c4b112 100644 --- a/crates/harness/sessions/src/environment.rs +++ b/crates/harness/sessions/src/environment.rs @@ -343,8 +343,9 @@ impl Bindings { /// a fabricated fallback descriptor. #[derive(Debug, thiserror::Error)] 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/session/run-tests.rs b/crates/harness/sessions/src/session/run-tests.rs new file mode 100644 index 000000000..5d8531f42 --- /dev/null +++ b/crates/harness/sessions/src/session/run-tests.rs @@ -0,0 +1,29 @@ +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..05dc66982 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 @@ -155,3 +158,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/webfetch/src/error.rs b/crates/harness/webfetch/src/error.rs index bff49ad51..6a83bfde4 100644 --- a/crates/harness/webfetch/src/error.rs +++ b/crates/harness/webfetch/src/error.rs @@ -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/tool.rs b/crates/harness/webfetch/src/tool.rs index 699f3d922..0ef6c4ad6 100644 --- a/crates/harness/webfetch/src/tool.rs +++ b/crates/harness/webfetch/src/tool.rs @@ -1250,7 +1250,7 @@ mod tests { ); let result = outcome.text().to_owned(); assert!( - result.contains("failed to read the response body") || result.contains("network error"), + result.contains("could not be read") || result.contains("network error"), "got: {result}" ); } diff --git a/crates/promptforge-api-runtime/src/error.rs b/crates/promptforge-api-runtime/src/error.rs index cc92ccb6b..5860839d5 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 diff --git a/crates/workshop/server/src/agents/session-menu.rs b/crates/workshop/server/src/agents/session-menu.rs index ffdc0be45..5b824a16c 100644 --- a/crates/workshop/server/src/agents/session-menu.rs +++ b/crates/workshop/server/src/agents/session-menu.rs @@ -179,7 +179,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")] 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..fae23a060 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; @@ -343,7 +346,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 +364,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 +480,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/app.rs b/crates/workshop/server/src/app.rs index 61be9ad49..1b970b8e0 100644 --- a/crates/workshop/server/src/app.rs +++ b/crates/workshop/server/src/app.rs @@ -460,7 +460,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/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/workspace/src/lib.rs b/crates/workshop/workspace/src/lib.rs index 4584a6ccf..3499e6424 100644 --- a/crates/workshop/workspace/src/lib.rs +++ b/crates/workshop/workspace/src/lib.rs @@ -42,4 +42,4 @@ pub use workspace::{ }; #[cfg(any(test, feature = "test-fixtures"))] pub use workspace_file::create_alien_database_for_test; -pub use workspace_file::{WindowState, WorkspaceFileError}; +pub use workspace_file::{DatabaseSource, WindowState, WorkspaceFileError}; diff --git a/crates/workshop/workspace/src/workspace_file.rs b/crates/workshop/workspace/src/workspace_file.rs index 0c0797568..1a82f20a9 100644 --- a/crates/workshop/workspace/src/workspace_file.rs +++ b/crates/workshop/workspace/src/workspace_file.rs @@ -78,7 +78,7 @@ pub enum WorkspaceFileError { Database { /// The underlying engine failure. #[source] - source: turso::Error, + source: DatabaseSource, }, /// The file is not a PromptForge workspace: not a database, or a @@ -108,10 +108,19 @@ pub enum WorkspaceFileError { impl From for WorkspaceFileError { fn from(source: turso::Error) -> Self { - Self::Database { source } + Self::Database { + source: DatabaseSource(source), + } } } +/// The database engine's failure as the cause of a +/// [`WorkspaceFileError::Database`], owned by this crate so the public +/// error names no engine type. +#[derive(Debug, thiserror::Error)] +#[error(transparent)] +pub struct DatabaseSource(turso::Error); + /// Everything a workspace file carries between sessions. #[derive(Debug, Clone)] pub(crate) struct WorkspaceContents { @@ -466,7 +475,7 @@ fn is_not_a_database(error: &WorkspaceFileError) -> bool { matches!( error, WorkspaceFileError::Database { - source: turso::Error::NotAdb(_) | turso::Error::Corrupt(_) + source: DatabaseSource(turso::Error::NotAdb(_) | turso::Error::Corrupt(_)) } ) } diff --git a/vibe/2026-09-20-2-rust-rulebook-sweep.md b/vibe/2026-09-20-2-rust-rulebook-sweep.md index 052edb4f6..50125acf0 100644 --- a/vibe/2026-09-20-2-rust-rulebook-sweep.md +++ b/vibe/2026-09-20-2-rust-rulebook-sweep.md @@ -350,7 +350,7 @@ Commit: one commit. -### Step 4: Error shapes - Display, foreign types, message style +### Step 4: Error shapes - Display, foreign types, message style [completed] - Component: `error-model` - Piece: type definitions and renderers (D2) From 4bbab218655f94b72b1dfa932cd16ff8ae26d2c9 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Sun, 20 Sep 2026 08:37:17 -0700 Subject: [PATCH 05/39] Mark public error and wire types non-exhaustive Public error enums, serde wire enums, and response-shaped wire structs across the gateway, harness, engine, and workshop crates now carry the non-exhaustive attribute, so a later variant or field is not a breaking change for dependent crates. Downstream matches that used to name every variant close with a wildcard arm instead, each commented with the enum's owning crate. Where a wildcard has to produce a value the choice is conservative: refuse a malformed request, label unknown audio as opaque bytes, drop a delta the wire cannot frame, or panic inside test support so an unclaimed event cannot pass a suite silently. No tests are added or changed. - `events!` emits `#[non_exhaustive]` on every enum it declares, so `Event` receives the attribute at its one definition site rather than per downstream match. - `ReplayError`, `CurrentModelError`, `DriveError`, `PrepareError`, `LogError`, `DialectResolveError`, and `FetchError` gain `#[non_exhaustive]` at the enum level. - `EnvRole`, `Tier`, `SliceStatus`, `EmbeddingInput`, `SpeechVoice`, `SpeechResponseFormat`, `SpeechStreamFormat`, `LlamaBackend`, `DeltaKind`, `InputFrame`, `AgentEventKind`, `AgentDeltaKind`, `TaskOrigin`, `AbandonReason`, and `StoreOp` gain `#[non_exhaustive]` as serde-derived wire enums. - `EmbeddingResponse`, `RerankResponse`, `ModelsResponse`, `SpeechRequest`, `StoredRecord`, `RunRow`, `Delta`, `AgentEvent`, and `SwitchProfileFrame` gain `#[non_exhaustive]` as structs, which confines struct-literal construction to the owning crate. `SpeechRequest` and `SwitchProfileFrame` are inbound request shapes, not responses. - `GatewayError` takes the attribute on its `Protocol` variant only, and `UserStateError` and `WorkspaceError` take it on single variants. `GatewayError` is `pub(crate)`, so the variant-level attribute there has no cross-crate effect. - `delta_frame` returns `Option` and yields `None` for a `DeltaKind` the wire has no label for; `run_socket` sends only when a frame is present, so such a delta is dropped like a lagged one. - `forward_one` closes with `_ => unreachable!("Event variant no group claims: {event:?}")`, and `forward_lifecycle`, `forward_content`, and `forward_debug` close with `_ => {}`. The `unit_lifecycle_variants!` and `other_groups!` macros and the three `#[expect(clippy::unnested_or_patterns)]` attributes that guarded them are removed, so the compile-time check that every `Event` variant is claimed by a group becomes a runtime panic in test support. - `audio_speech` refuses a `SpeechVoice` form it cannot name with `GatewayError::MalformedRequest`; `speech_fallback_mime` labels an unknown `SpeechResponseFormat` as `application/octet-stream`. - `opened_run`, `store_observations`, and `on_delta` fold their former named arms into `_` with the same result; the `TaskOrigin` match in `Scheduler` moves `Author` into the wildcard, so an origin the engine does not yet name is pushed to `leaked` like the author's. Plan: vibe/2026-09-20-2-rust-rulebook-sweep.md --- crates/gateway-api/src/lib.rs | 3 + crates/gateway/app/src/error.rs | 1 + crates/gateway/app/src/speech.rs | 10 +++ crates/gateway/cloud-providers/src/lib.rs | 1 + crates/gateway/config/src/config.rs | 1 + crates/gateway/local/src/dialect.rs | 1 + crates/gateway/protocol/src/wire.rs | 8 +++ crates/harness/log/src/error.rs | 1 + crates/harness/log/src/record.rs | 2 + crates/harness/runner/src/effect_loop.rs | 1 + crates/harness/runner/src/prepare.rs | 1 + crates/harness/sessions/src/environment.rs | 1 + crates/harness/sessions/src/protocol.rs | 2 + crates/harness/sessions/src/session/run.rs | 4 +- .../src/execute/scheduler/dispatch.rs | 5 +- .../src/execute/scheduler/tasks.rs | 5 +- .../src/test_support/recording-forward.rs | 67 +++++++------------ crates/promptforge-api-types/src/event.rs | 1 + crates/promptforge-api-types/src/ids.rs | 2 + crates/promptforge-api-types/src/replay.rs | 1 + .../promptforge/lua/src/protocol/request.rs | 1 + crates/workshop/protocol/src/agent.rs | 3 + crates/workshop/protocol/src/input.rs | 1 + crates/workshop/protocol/src/menu.rs | 1 + crates/workshop/server/src/agents/socket.rs | 14 ++-- crates/workshop/server/src/agents/status.rs | 4 +- crates/workshop/user-state/src/error.rs | 2 + crates/workshop/workspace/src/error.rs | 1 + vibe/2026-09-20-2-rust-rulebook-sweep.md | 2 +- 29 files changed, 96 insertions(+), 51 deletions(-) diff --git a/crates/gateway-api/src/lib.rs b/crates/gateway-api/src/lib.rs index f48e0a45c..fcef9728e 100644 --- a/crates/gateway-api/src/lib.rs +++ b/crates/gateway-api/src/lib.rs @@ -76,6 +76,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 +87,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 +102,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, diff --git a/crates/gateway/app/src/error.rs b/crates/gateway/app/src/error.rs index e0b640e93..f896cb191 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), diff --git a/crates/gateway/app/src/speech.rs b/crates/gateway/app/src/speech.rs index 74296454a..1f8ee1cd9 100644 --- a/crates/gateway/app/src/speech.rs +++ b/crates/gateway/app/src/speech.rs @@ -56,6 +56,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 { @@ -283,6 +290,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", }) } diff --git a/crates/gateway/cloud-providers/src/lib.rs b/crates/gateway/cloud-providers/src/lib.rs index edfaf5878..2d7d1277f 100644 --- a/crates/gateway/cloud-providers/src/lib.rs +++ b/crates/gateway/cloud-providers/src/lib.rs @@ -91,6 +91,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}`")] diff --git a/crates/gateway/config/src/config.rs b/crates/gateway/config/src/config.rs index a0c700c78..262567a0a 100644 --- a/crates/gateway/config/src/config.rs +++ b/crates/gateway/config/src/config.rs @@ -337,6 +337,7 @@ 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 /// the PromptForge CUDA build, any other NVIDIA GPU gets the upstream diff --git a/crates/gateway/local/src/dialect.rs b/crates/gateway/local/src/dialect.rs index 1ec93c1a5..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")] diff --git a/crates/gateway/protocol/src/wire.rs b/crates/gateway/protocol/src/wire.rs index 48f810a71..f47980493 100644 --- a/crates/gateway/protocol/src/wire.rs +++ b/crates/gateway/protocol/src/wire.rs @@ -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), @@ -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, @@ -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, @@ -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, @@ -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, diff --git a/crates/harness/log/src/error.rs b/crates/harness/log/src/error.rs index 04226d9da..bbc259329 100644 --- a/crates/harness/log/src/error.rs +++ b/crates/harness/log/src/error.rs @@ -6,6 +6,7 @@ use crate::RunId; /// Why a run log operation failed. #[derive(Debug, thiserror::Error)] +#[non_exhaustive] pub enum LogError { /// The database engine refused an operation; the engine's error is /// the source. 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/runner/src/effect_loop.rs b/crates/harness/runner/src/effect_loop.rs index 239c836a4..ac3a70c2c 100644 --- a/crates/harness/runner/src/effect_loop.rs +++ b/crates/harness/runner/src/effect_loop.rs @@ -57,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. diff --git a/crates/harness/runner/src/prepare.rs b/crates/harness/runner/src/prepare.rs index 22c1f2e9f..b3c55718c 100644 --- a/crates/harness/runner/src/prepare.rs +++ b/crates/harness/runner/src/prepare.rs @@ -111,6 +111,7 @@ 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. The read failure is the source. diff --git a/crates/harness/sessions/src/environment.rs b/crates/harness/sessions/src/environment.rs index 063c4b112..340b8ab9f 100644 --- a/crates/harness/sessions/src/environment.rs +++ b/crates/harness/sessions/src/environment.rs @@ -342,6 +342,7 @@ 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; the fetch /// failure is the source. diff --git a/crates/harness/sessions/src/protocol.rs b/crates/harness/sessions/src/protocol.rs index b33dd4369..5d63a5c4b 100644 --- a/crates/harness/sessions/src/protocol.rs +++ b/crates/harness/sessions/src/protocol.rs @@ -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/session/run.rs b/crates/harness/sessions/src/session/run.rs index 05dc66982..1497bf0eb 100644 --- a/crates/harness/sessions/src/session/run.rs +++ b/crates/harness/sessions/src/session/run.rs @@ -135,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, } } 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/tasks.rs b/crates/promptforge-api-runtime/src/execute/scheduler/tasks.rs index 734ddc6c2..7109f9ed6 100644 --- a/crates/promptforge-api-runtime/src/execute/scheduler/tasks.rs +++ b/crates/promptforge-api-runtime/src/execute/scheduler/tasks.rs @@ -404,10 +404,13 @@ 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, like every other `origin == Model` test here. + _ => leaked.push(task), } } leaked 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..78f844512 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,30 @@ 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; the recorder exists to observe every event, so a variant +/// no group claims panics naming itself rather than passing a suite +/// vacuously. Adding a variant means adding it to a group's or-pattern +/// by hand. +/// +/// # 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 +225,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 +304,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 +347,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-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.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/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/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/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/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/server/src/agents/socket.rs b/crates/workshop/server/src/agents/socket.rs index fae23a060..8e69efd50 100644 --- a/crates/workshop/server/src/agents/socket.rs +++ b/crates/workshop/server/src/agents/socket.rs @@ -175,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; } } @@ -224,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 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/user-state/src/error.rs b/crates/workshop/user-state/src/error.rs index 1fc9d6761..c9a292559 100644 --- a/crates/workshop/user-state/src/error.rs +++ b/crates/workshop/user-state/src/error.rs @@ -30,6 +30,7 @@ const LEAK_DETAIL: bool = cfg!(debug_assertions); pub enum UserStateError { /// A put named a key outside the allow-list. The message lists the /// allow-list itself so it cannot drift from the keys. + #[non_exhaustive] #[error( "user-state key {0:?} is not allowed; one of {allowed} is required", allowed = USER_STATE_KEYS.join(", ") @@ -51,6 +52,7 @@ pub enum UserStateError { NotJson, /// The state file could not be written. + #[non_exhaustive] #[error("user-state file cannot be written")] Io(#[source] io::Error), } diff --git a/crates/workshop/workspace/src/error.rs b/crates/workshop/workspace/src/error.rs index 41de13437..c82af2710 100644 --- a/crates/workshop/workspace/src/error.rs +++ b/crates/workshop/workspace/src/error.rs @@ -150,6 +150,7 @@ pub enum WorkspaceError { /// A ui-state put named a key outside the allow-list. The message /// lists the allow-list itself so it cannot drift from the keys. + #[non_exhaustive] #[error( "ui-state key {0:?} is not allowed; one of {allowed} is required", allowed = UI_STATE_KEYS.join(", ") diff --git a/vibe/2026-09-20-2-rust-rulebook-sweep.md b/vibe/2026-09-20-2-rust-rulebook-sweep.md index 50125acf0..0d73ebe3b 100644 --- a/vibe/2026-09-20-2-rust-rulebook-sweep.md +++ b/vibe/2026-09-20-2-rust-rulebook-sweep.md @@ -371,7 +371,7 @@ Commit: one commit. -### Step 5: `#[non_exhaustive]` on error and wire types +### Step 5: `#[non_exhaustive]` on error and wire types [completed] - Component: `error-model` - Piece: exhaustiveness attributes (D3) From d09be7caf10a812a71559fbddbb42bfe5ba1cbce Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Sun, 20 Sep 2026 09:00:20 -0700 Subject: [PATCH 06/39] Attach discarded error causes to error variants Several failure paths threw away the underlying error while converting it into a domain error, leaving callers and logs with only a generic message. This change keeps the cause. Where the target variant can hold a source, the variant now carries it and the error chain exposes it; where the variant holds only a message string, the cause is appended to that string. Two workshop crates wrap the JSON parser's error in a crate-owned transparent type so their public error enums name no third-party library. HTTP statuses, wire codes, and top-level display strings are unchanged. - `JsonSource`: both `crates/workshop/user-state` and `crates/workshop/workspace` define an identical `#[error(transparent)]` newtype over `serde_json::Error` with a hand-written `From`, and re-export it from their crate root. Neither crate reuses the other's. - `WorkspaceError::NotUtf8`, `WorkspaceError::UiStateNotJson`, `UserStateError::NotJson`: unit variants become `#[non_exhaustive]` struct variants holding a `#[source]`; every match arm in the touched files widens to `{ .. }` and the `#[error(...)]` strings are untouched. - `ConfigError::UnresolvedVar`: gains a second field, `std::env::VarError`, marked `#[source]`; the display string still prints only `{0}`. The enum is `pub(crate)`. - `WhisperError::InteriorNull`: gains `source: std::ffi::NulError`; the four `CString::new` sites in `context.rs` and `params.rs` pass it through. - `render_message(¬_json, true)` now starts with `ui-state value is not JSON: ` followed by the parse refusal, asserted by a new check in `ui_state_refusals_map_to_client_errors`. The status and code assertions around it are unchanged. - `VfsError::Backend`, `GatewayError::MalformedRequest`, and `HeaderReader::malformed` take a `String`, so at those four sites the cause is interpolated into the message text rather than chained as a source. - `c_int::try_from(max)` in `tokenize` and the length-cap `map_err(|_|` in `gguf.rs` still discard their error; both sit beside converted sites in the same hunks. - `std::ffi::NulError`, `std::env::VarError`, `std::string::FromUtf8Error`: no test exercises these new sources; existing tests were updated for variant shape only. Design: new newtype @ crates/workshop/user-state/src/error.rs::JsonSource boundary: pub Design: new newtype @ crates/workshop/workspace/src/error.rs::JsonSource boundary: pub Design: new parallel-abstraction @ crates/workshop/workspace/src/error.rs::JsonSource Plan: vibe/2026-09-20-2-rust-rulebook-sweep.md --- crates/gateway/app/src/cache.rs | 6 ++-- crates/gateway/config/src/api_error.rs | 4 +-- .../gateway/config/src/config/interpolate.rs | 4 +-- crates/gateway/config/src/config/tests.rs | 2 +- crates/gateway/config/src/error.rs | 5 +-- crates/gateway/local/src/gguf.rs | 3 +- crates/gateway/stt/whisper-ffi/src/context.rs | 6 ++-- crates/gateway/stt/whisper-ffi/src/error.rs | 3 ++ crates/gateway/stt/whisper-ffi/src/params.rs | 6 ++-- crates/shared-vfs/src/handle.rs | 5 +-- crates/shared-vfs/src/traits.rs | 5 +-- crates/workshop/user-state/src/error.rs | 24 +++++++++++-- crates/workshop/user-state/src/handlers.rs | 4 ++- crates/workshop/user-state/src/lib.rs | 2 +- crates/workshop/workspace/src/error-tests.rs | 13 +++++-- crates/workshop/workspace/src/error.rs | 35 +++++++++++++++---- .../workspace/src/handlers-file-state.rs | 5 ++- crates/workshop/workspace/src/lib.rs | 2 +- .../src/workspace-file-tests-ui-state.rs | 2 +- crates/workshop/workspace/src/workspace.rs | 2 +- .../workspace/src/workspace_file-ui-state.rs | 4 ++- vibe/2026-09-20-2-rust-rulebook-sweep.md | 2 +- 22 files changed, 107 insertions(+), 37 deletions(-) diff --git a/crates/gateway/app/src/cache.rs b/crates/gateway/app/src/cache.rs index 3feaf37ce..0f78f35bb 100644 --- a/crates/gateway/app/src/cache.rs +++ b/crates/gateway/app/src/cache.rs @@ -74,8 +74,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!( diff --git a/crates/gateway/config/src/api_error.rs b/crates/gateway/config/src/api_error.rs index 59d3e6c45..461e1e234 100644 --- a/crates/gateway/config/src/api_error.rs +++ b/crates/gateway/config/src/api_error.rs @@ -53,7 +53,7 @@ impl ConfigError { 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/interpolate.rs b/crates/gateway/config/src/config/interpolate.rs index 010d431bb..6e4e9d86c 100644 --- a/crates/gateway/config/src/config/interpolate.rs +++ b/crates/gateway/config/src/config/interpolate.rs @@ -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('$'), diff --git a/crates/gateway/config/src/config/tests.rs b/crates/gateway/config/src/config/tests.rs index 8b04b132d..9636ebd0d 100644 --- a/crates/gateway/config/src/config/tests.rs +++ b/crates/gateway/config/src/config/tests.rs @@ -511,7 +511,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/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/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/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/shared-vfs/src/handle.rs b/crates/shared-vfs/src/handle.rs index 50d199f02..7e6fb8a32 100644 --- a/crates/shared-vfs/src/handle.rs +++ b/crates/shared-vfs/src/handle.rs @@ -374,8 +374,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 diff --git a/crates/shared-vfs/src/traits.rs b/crates/shared-vfs/src/traits.rs index 77ad05219..6087c7a2e 100644 --- a/crates/shared-vfs/src/traits.rs +++ b/crates/shared-vfs/src/traits.rs @@ -190,8 +190,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!( diff --git a/crates/workshop/user-state/src/error.rs b/crates/workshop/user-state/src/error.rs index c9a292559..1c6327513 100644 --- a/crates/workshop/user-state/src/error.rs +++ b/crates/workshop/user-state/src/error.rs @@ -48,8 +48,13 @@ pub enum UserStateError { }, /// A put body does not parse as JSON. + #[non_exhaustive] #[error("user-state value is not JSON")] - NotJson, + NotJson { + /// The parse refusal. + #[source] + source: JsonSource, + }, /// The state file could not be written. #[non_exhaustive] @@ -57,11 +62,24 @@ pub enum UserStateError { Io(#[source] io::Error), } +/// The JSON parse refusal behind [`UserStateError::NotJson`], owned by +/// this crate so the public error names no JSON library type. Renders and +/// sources exactly as the serde error does. +#[derive(Debug, thiserror::Error)] +#[error(transparent)] +pub struct JsonSource(serde_json::Error); + +impl From for JsonSource { + fn from(source: serde_json::Error) -> Self { + Self(source) + } +} + impl UserStateError { /// The one HTTP status this failure answers with. pub(crate) fn status(&self) -> StatusCode { match self { - Self::Key(_) | Self::NotJson => StatusCode::BAD_REQUEST, + Self::Key(_) | Self::NotJson { .. } => StatusCode::BAD_REQUEST, Self::TooLarge { .. } => StatusCode::PAYLOAD_TOO_LARGE, Self::Io(_) => StatusCode::INTERNAL_SERVER_ERROR, } @@ -72,7 +90,7 @@ impl UserStateError { match self { Self::Key(_) => "user_state_key", Self::TooLarge { .. } => "user_state_too_large", - Self::NotJson => "user_state_not_json", + Self::NotJson { .. } => "user_state_not_json", Self::Io(_) => "user_state_io", } } diff --git a/crates/workshop/user-state/src/handlers.rs b/crates/workshop/user-state/src/handlers.rs index d94faff53..1424d7da5 100644 --- a/crates/workshop/user-state/src/handlers.rs +++ b/crates/workshop/user-state/src/handlers.rs @@ -72,7 +72,9 @@ async fn store_value( ) -> Result { let key = user_state_key(key)?; check_text_cap(body.len())?; - let value: Value = serde_json::from_slice(body).map_err(|_| UserStateError::NotJson)?; + let value: Value = serde_json::from_slice(body).map_err(|source| UserStateError::NotJson { + source: source.into(), + })?; store.put(key, value).await?; Ok(serde_json::json!({ "saved": true })) } diff --git a/crates/workshop/user-state/src/lib.rs b/crates/workshop/user-state/src/lib.rs index f2f58b23b..d013080a3 100644 --- a/crates/workshop/user-state/src/lib.rs +++ b/crates/workshop/user-state/src/lib.rs @@ -32,7 +32,7 @@ use std::sync::Arc; use workshop_registry::{Registration, Registry, RouteRegistrarAdapter}; -pub use error::UserStateError; +pub use error::{JsonSource, UserStateError}; pub use handlers::routes; pub use store::{USER_STATE_KEYS, USER_STATE_VALUE_CAP, UserStateStore}; diff --git a/crates/workshop/workspace/src/error-tests.rs b/crates/workshop/workspace/src/error-tests.rs index cc59aba37..7d897eb12 100644 --- a/crates/workshop/workspace/src/error-tests.rs +++ b/crates/workshop/workspace/src/error-tests.rs @@ -91,7 +91,9 @@ fn workspace_failures_keep_their_wire_mapping() { "binary_file", ), ( - WorkspaceError::NotUtf8, + WorkspaceError::NotUtf8 { + source: String::from_utf8(vec![0xff]).unwrap_err(), + }, StatusCode::UNSUPPORTED_MEDIA_TYPE, "not_utf8", ), @@ -140,13 +142,20 @@ fn ui_state_refusals_map_to_client_errors() { "ui-state value is 9 bytes; at most 8 bytes are allowed" ); - let not_json = WorkspaceError::UiStateNotJson; + let refusal = serde_json::from_str::("{not json").unwrap_err(); + let not_json = WorkspaceError::UiStateNotJson { + source: refusal.into(), + }; assert_eq!(not_json.status(), StatusCode::BAD_REQUEST); assert_eq!(not_json.code(), "ui_state_not_json"); assert_eq!( render_message(¬_json, false), "ui-state value is not JSON" ); + assert!( + render_message(¬_json, true).starts_with("ui-state value is not JSON: "), + "the leaked chain names the parse refusal" + ); } /// The file-backed failures map to their own statuses and codes: a diff --git a/crates/workshop/workspace/src/error.rs b/crates/workshop/workspace/src/error.rs index c82af2710..c9203be5c 100644 --- a/crates/workshop/workspace/src/error.rs +++ b/crates/workshop/workspace/src/error.rs @@ -110,8 +110,13 @@ pub enum WorkspaceError { BinaryFile, /// The file is not valid UTF-8. + #[non_exhaustive] #[error("file is not utf-8 text")] - NotUtf8, + NotUtf8 { + /// Where the bytes stopped being UTF-8. + #[source] + source: std::string::FromUtf8Error, + }, /// The file or body exceeds the size limit. #[non_exhaustive] @@ -168,8 +173,26 @@ pub enum WorkspaceError { }, /// A ui-state value does not parse as JSON. + #[non_exhaustive] #[error("ui-state value is not JSON")] - UiStateNotJson, + UiStateNotJson { + /// The parse refusal. + #[source] + source: JsonSource, + }, +} + +/// The JSON parse refusal behind [`WorkspaceError::UiStateNotJson`], +/// owned by this crate so the public error names no JSON library type. +/// Renders and sources exactly as the serde error does. +#[derive(Debug, thiserror::Error)] +#[error(transparent)] +pub struct JsonSource(serde_json::Error); + +impl From for JsonSource { + fn from(source: serde_json::Error) -> Self { + Self(source) + } } impl From for WorkspaceError { @@ -207,10 +230,10 @@ impl WorkspaceError { | Self::NotAFile | Self::WorkspaceFileRefused { .. } | Self::UiStateKey(_) - | Self::UiStateNotJson => StatusCode::BAD_REQUEST, + | Self::UiStateNotJson { .. } => StatusCode::BAD_REQUEST, Self::OutsideGrants | Self::ForbiddenComponent => StatusCode::FORBIDDEN, Self::NotFound | Self::NotGranted => StatusCode::NOT_FOUND, - Self::BinaryFile | Self::NotUtf8 => StatusCode::UNSUPPORTED_MEDIA_TYPE, + Self::BinaryFile | Self::NotUtf8 { .. } => StatusCode::UNSUPPORTED_MEDIA_TYPE, Self::FileTooLarge { .. } | Self::UiStateTooLarge { .. } => { StatusCode::PAYLOAD_TOO_LARGE } @@ -241,7 +264,7 @@ impl WorkspaceError { Self::NotADirectory => "not_a_directory", Self::NotAFile => "not_a_file", Self::BinaryFile => "binary_file", - Self::NotUtf8 => "not_utf8", + Self::NotUtf8 { .. } => "not_utf8", Self::FileTooLarge { .. } => "file_too_large", Self::ModifiedConflict => "modified_conflict", Self::WorkspaceFileRefused { .. } => "workspace_file_refused", @@ -249,7 +272,7 @@ impl WorkspaceError { Self::WorkspaceFileFailed { .. } => "workspace_file_failed", Self::UiStateKey(_) => "ui_state_key", Self::UiStateTooLarge { .. } => "ui_state_too_large", - Self::UiStateNotJson => "ui_state_not_json", + Self::UiStateNotJson { .. } => "ui_state_not_json", } } } diff --git a/crates/workshop/workspace/src/handlers-file-state.rs b/crates/workshop/workspace/src/handlers-file-state.rs index ae1d41f9e..c9e40626b 100644 --- a/crates/workshop/workspace/src/handlers-file-state.rs +++ b/crates/workshop/workspace/src/handlers-file-state.rs @@ -63,7 +63,10 @@ async fn store( ) -> Result { let key = ui_state_key(key)?; check_ui_state_cap(body.len())?; - let value: Value = serde_json::from_slice(body).map_err(|_| WorkspaceError::UiStateNotJson)?; + let value: Value = + serde_json::from_slice(body).map_err(|source| WorkspaceError::UiStateNotJson { + source: source.into(), + })?; let saved = workspace.put_ui_state(key, value).await?; Ok(SavedResponse { saved }) } diff --git a/crates/workshop/workspace/src/lib.rs b/crates/workshop/workspace/src/lib.rs index 3499e6424..e4d3ccee2 100644 --- a/crates/workshop/workspace/src/lib.rs +++ b/crates/workshop/workspace/src/lib.rs @@ -34,7 +34,7 @@ pub mod handles; mod workspace; mod workspace_file; -pub use error::WorkspaceError; +pub use error::{JsonSource, WorkspaceError}; pub use handlers::routes; pub use handles::{register, register_tasks}; pub use workspace::{ diff --git a/crates/workshop/workspace/src/workspace-file-tests-ui-state.rs b/crates/workshop/workspace/src/workspace-file-tests-ui-state.rs index 7fcf3ed31..d4580d48e 100644 --- a/crates/workshop/workspace/src/workspace-file-tests-ui-state.rs +++ b/crates/workshop/workspace/src/workspace-file-tests-ui-state.rs @@ -196,7 +196,7 @@ async fn non_json_text_is_refused_without_a_write() { .await .expect_err("text that does not parse as JSON is refused"); assert!( - matches!(error, WorkspaceError::UiStateNotJson), + matches!(error, WorkspaceError::UiStateNotJson { .. }), "expected UiStateNotJson for {text:?}, got {error:?}" ); } diff --git a/crates/workshop/workspace/src/workspace.rs b/crates/workshop/workspace/src/workspace.rs index 90e12144c..c3bece3d4 100644 --- a/crates/workshop/workspace/src/workspace.rs +++ b/crates/workshop/workspace/src/workspace.rs @@ -317,7 +317,7 @@ impl Workspace { return Err(WorkspaceError::BinaryFile); } let token = file_token(&metadata, &bytes); - let text = String::from_utf8(bytes).map_err(|_| WorkspaceError::NotUtf8)?; + let text = String::from_utf8(bytes).map_err(|source| WorkspaceError::NotUtf8 { source })?; Ok(FileContents { path: canonical, size: metadata.len(), diff --git a/crates/workshop/workspace/src/workspace_file-ui-state.rs b/crates/workshop/workspace/src/workspace_file-ui-state.rs index 86d66c5f5..0947c8720 100644 --- a/crates/workshop/workspace/src/workspace_file-ui-state.rs +++ b/crates/workshop/workspace/src/workspace_file-ui-state.rs @@ -63,7 +63,9 @@ pub(crate) fn check_ui_state_text(json_text: &str) -> Result<(), WorkspaceError> check_ui_state_cap(json_text.len())?; serde_json::from_str::(json_text) .map(|_| ()) - .map_err(|_| WorkspaceError::UiStateNotJson) + .map_err(|source| WorkspaceError::UiStateNotJson { + source: source.into(), + }) } impl WorkspaceFile { diff --git a/vibe/2026-09-20-2-rust-rulebook-sweep.md b/vibe/2026-09-20-2-rust-rulebook-sweep.md index 0d73ebe3b..2760f0262 100644 --- a/vibe/2026-09-20-2-rust-rulebook-sweep.md +++ b/vibe/2026-09-20-2-rust-rulebook-sweep.md @@ -394,7 +394,7 @@ Commit: one commit. -### Step 6: Attach discarded error causes +### Step 6: Attach discarded error causes [completed] - Component: `error-model` - Piece: call sites (D2b) From 128ff3a3586c21f094c1e7589e830f6b9709a1b0 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Sun, 20 Sep 2026 09:17:12 -0700 Subject: [PATCH 07/39] Retire mod.rs files in favor of named module files Move every module root that lived as a directory-level file up to a sibling file named after the module, so the module name appears in its own filename. Where a directory is left with fewer than three files, those files are flattened into hyphenated siblings and the parent declares their locations with path attributes. File contents are otherwise unchanged; every move is a pure rename. - `crates/promptforge/lua/src/models.rs` Declares `userdata` and `tests` through `#[path = "models-userdata.rs"]` and `#[path = "models-tests.rs"]`, flattening the two-file directory. - `crates/promptforge-api-runtime/src/model/tests.rs` Points `always` and `integration` at `tests-always.rs` and `tests-integration.rs` in the same directory as the parent. - `crates/gateway/app/src/cloud_models/tests.rs` Points `refresh` and `version_gate` at `tests-refresh.rs` and `tests-version-gate.rs`. - `crates/promptforge-api-runtime/src/fanout.rs` Points the `#[cfg(test)]` `mod tests;` at `fanout-tests.rs`. - `crates/promptforge/lua/src/tools.rs`, `crates/gateway/cloud-providers/src/providers.rs`, `crates/gateway/stt/api/src/realtime.rs`, and the remaining `tests.rs` roots are 100% renames with no content change. - `#[cfg(test)]` No test body changes and no new tests; the only added lines are path attributes. Plan: vibe/2026-09-20-2-rust-rulebook-sweep.md --- .../app/src/cloud_models/{tests/refresh.rs => tests-refresh.rs} | 0 .../{tests/version_gate.rs => tests-version-gate.rs} | 0 crates/gateway/app/src/cloud_models/{tests/mod.rs => tests.rs} | 2 ++ .../cloud-providers/src/{providers/mod.rs => providers.rs} | 0 crates/gateway/stt/api/src/{realtime/mod.rs => realtime.rs} | 0 crates/harness/models/src/transport/{tests/mod.rs => tests.rs} | 0 .../src/execute/{tests/mod.rs => tests.rs} | 0 .../src/{fanout/tests.rs => fanout-tests.rs} | 0 crates/promptforge-api-runtime/src/{fanout/mod.rs => fanout.rs} | 1 + .../promptforge-api-runtime/src/lua/{tests/mod.rs => tests.rs} | 0 .../src/model/{tests/always.rs => tests-always.rs} | 0 .../src/model/{tests/integration.rs => tests-integration.rs} | 0 .../src/model/{tests/mod.rs => tests.rs} | 2 ++ crates/promptforge/lua/src/{models/tests.rs => models-tests.rs} | 0 .../lua/src/{models/userdata.rs => models-userdata.rs} | 0 crates/promptforge/lua/src/{models/mod.rs => models.rs} | 2 ++ crates/promptforge/lua/src/protocol/{tests/mod.rs => tests.rs} | 0 crates/promptforge/lua/src/{tools/mod.rs => tools.rs} | 0 vibe/2026-09-20-2-rust-rulebook-sweep.md | 2 +- 19 files changed, 8 insertions(+), 1 deletion(-) rename crates/gateway/app/src/cloud_models/{tests/refresh.rs => tests-refresh.rs} (100%) rename crates/gateway/app/src/cloud_models/{tests/version_gate.rs => tests-version-gate.rs} (100%) rename crates/gateway/app/src/cloud_models/{tests/mod.rs => tests.rs} (99%) rename crates/gateway/cloud-providers/src/{providers/mod.rs => providers.rs} (100%) rename crates/gateway/stt/api/src/{realtime/mod.rs => realtime.rs} (100%) rename crates/harness/models/src/transport/{tests/mod.rs => tests.rs} (100%) rename crates/promptforge-api-runtime/src/execute/{tests/mod.rs => tests.rs} (100%) rename crates/promptforge-api-runtime/src/{fanout/tests.rs => fanout-tests.rs} (100%) rename crates/promptforge-api-runtime/src/{fanout/mod.rs => fanout.rs} (99%) rename crates/promptforge-api-runtime/src/lua/{tests/mod.rs => tests.rs} (100%) rename crates/promptforge-api-runtime/src/model/{tests/always.rs => tests-always.rs} (100%) rename crates/promptforge-api-runtime/src/model/{tests/integration.rs => tests-integration.rs} (100%) rename crates/promptforge-api-runtime/src/model/{tests/mod.rs => tests.rs} (97%) rename crates/promptforge/lua/src/{models/tests.rs => models-tests.rs} (100%) rename crates/promptforge/lua/src/{models/userdata.rs => models-userdata.rs} (100%) rename crates/promptforge/lua/src/{models/mod.rs => models.rs} (99%) rename crates/promptforge/lua/src/protocol/{tests/mod.rs => tests.rs} (100%) rename crates/promptforge/lua/src/{tools/mod.rs => tools.rs} (100%) diff --git a/crates/gateway/app/src/cloud_models/tests/refresh.rs b/crates/gateway/app/src/cloud_models/tests-refresh.rs similarity index 100% rename from crates/gateway/app/src/cloud_models/tests/refresh.rs rename to crates/gateway/app/src/cloud_models/tests-refresh.rs diff --git a/crates/gateway/app/src/cloud_models/tests/version_gate.rs b/crates/gateway/app/src/cloud_models/tests-version-gate.rs similarity index 100% rename from crates/gateway/app/src/cloud_models/tests/version_gate.rs rename to crates/gateway/app/src/cloud_models/tests-version-gate.rs diff --git a/crates/gateway/app/src/cloud_models/tests/mod.rs b/crates/gateway/app/src/cloud_models/tests.rs similarity index 99% rename from crates/gateway/app/src/cloud_models/tests/mod.rs rename to crates/gateway/app/src/cloud_models/tests.rs index 5b98d82ce..8c2025b9d 100644 --- a/crates/gateway/app/src/cloud_models/tests/mod.rs +++ b/crates/gateway/app/src/cloud_models/tests.rs @@ -15,7 +15,9 @@ use tower::ServiceExt as _; use super::*; +#[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/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/stt/api/src/realtime/mod.rs b/crates/gateway/stt/api/src/realtime.rs similarity index 100% rename from crates/gateway/stt/api/src/realtime/mod.rs rename to crates/gateway/stt/api/src/realtime.rs 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/promptforge-api-runtime/src/execute/tests/mod.rs b/crates/promptforge-api-runtime/src/execute/tests.rs similarity index 100% rename from crates/promptforge-api-runtime/src/execute/tests/mod.rs rename to crates/promptforge-api-runtime/src/execute/tests.rs diff --git a/crates/promptforge-api-runtime/src/fanout/tests.rs b/crates/promptforge-api-runtime/src/fanout-tests.rs similarity index 100% rename from crates/promptforge-api-runtime/src/fanout/tests.rs rename to crates/promptforge-api-runtime/src/fanout-tests.rs 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/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 97% rename from crates/promptforge-api-runtime/src/model/tests/mod.rs rename to crates/promptforge-api-runtime/src/model/tests.rs index 9bb747442..a84b12657 100644 --- a/crates/promptforge-api-runtime/src/model/tests/mod.rs +++ b/crates/promptforge-api-runtime/src/model/tests.rs @@ -87,5 +87,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/lua/src/models/tests.rs b/crates/promptforge/lua/src/models-tests.rs similarity index 100% rename from crates/promptforge/lua/src/models/tests.rs rename to crates/promptforge/lua/src/models-tests.rs 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/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/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/vibe/2026-09-20-2-rust-rulebook-sweep.md b/vibe/2026-09-20-2-rust-rulebook-sweep.md index 2760f0262..1ff75a76d 100644 --- a/vibe/2026-09-20-2-rust-rulebook-sweep.md +++ b/vibe/2026-09-20-2-rust-rulebook-sweep.md @@ -411,7 +411,7 @@ Commit: one commit. -### Step 7: Retire `mod.rs` files +### Step 7: Retire `mod.rs` files [completed] - Component: `layout-lint-hygiene` - Piece: module layout moves (D4a) From af40fd70c9729be59fcdefa7896352e007fbcbbd Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Sun, 20 Sep 2026 09:32:38 -0700 Subject: [PATCH 08/39] Tighten lint attributes, doctests, and test re-exports Lint suppressions across the harness spawn wrappers, the gateway model sheet, and two cloud providers now state why they exist and fail the build when the lint stops firing. Test-only re-exports leave the production modules that hosted them: the test suites import the store extension trait and the tool output kind from their upstream crates, and the scheduler's task state reaches tests only through the test hooks module. Doc examples in the gateway and the Lua VM propagate errors instead of unwrapping, and the five VM examples that were plain text now compile as doctests. Eleven constructors returning their own type gain a must-use warning, the user-state store's getter loses its get prefix, and a doubled feature gate is removed. - `#[expect(` replaces `#[allow(` at seven suppression sites in `crates/harness/runner/src/spawn.rs`, `crates/gateway-api/src/lib.rs`, `crates/gateway/cloud-providers/src/providers/cohere.rs`, and `crates/gateway/cloud-providers/src/providers/bedrock-sigv4.rs`, each carrying a `reason`. The comment in `crates/harness/runner/clippy.toml` names the new attribute. - `test_hooks` becomes a `pub(crate) mod` under `#[cfg(test)]` and re-exports `tasks::TaskState`; the re-export in `scheduler.rs` is removed and seven test consumers spell `crate::execute::scheduler::test_hooks::TaskState` in full rather than receiving it from a shared test prelude. - `StoreExt` and `ToolOutputKind` are imported by `execute/tests.rs` and `execute/tests/scheduler.rs` from `promptforge_store` and `promptforge_lua` directly; the `#[cfg(test)]` re-exports in `store.rs` and `lua.rs` are gone. - `get_all` on `UserStateStore` is renamed to `all`; the `get_state` axum handler and nine store tests follow. The method stays `pub`. - `no_run` replaces `text` on the five `SectionVm` examples, which now compile against the crate API but never execute. Each ends in `Box` so `?` covers both the VFS acquire and the VM errors, and the two `.expect("the stock backend acquires")` calls become `?`. - `#[must_use]` lands on eleven `Self`-returning constructors: `VfsRef::new` and `VfsRef::with_policy`, `MenuBus::new`, `ShutdownHandle::new` and six adapter `new` functions in `crates/workshop/registry/src/traits.rs`, and `CommandRequest::new`. - `crates/gateway/app/src/model_info.rs` drops one of two adjacent `#[cfg(feature = "local")]` lines on the `axum::Json` import. - `vm.rs` examples are compiled, not run, so their setup is checked by the type system only. No test in the diff adds an assertion; the rename and the attribute additions are covered by existing tests unchanged. Design: new shotgun-surgery @ crates/promptforge-api-runtime/src/execute/scheduler/test_hooks.rs::TaskState Plan: vibe/2026-09-20-2-rust-rulebook-sweep.md --- crates/build-llama-cuda/src/probe.rs | 1 + crates/gateway-api/src/lib.rs | 7 ++--- crates/gateway/app/src/api_error.rs | 5 ++-- crates/gateway/app/src/model_info.rs | 1 - crates/gateway/app/src/runner.rs | 4 +-- .../src/providers/bedrock-sigv4.rs | 5 +++- .../cloud-providers/src/providers/cohere.rs | 8 +++--- crates/harness/runner/clippy.toml | 2 +- crates/harness/runner/src/spawn.rs | 20 +++++++++++--- .../src/execute/scheduler.rs | 4 +-- .../src/execute/scheduler/test_hooks.rs | 3 ++- .../src/execute/tests.rs | 6 +++-- .../execute/tests/model_task_acceptance.rs | 2 +- .../src/execute/tests/model_task_awaits.rs | 2 +- .../src/execute/tests/model_tasks.rs | 2 +- .../src/execute/tests/scheduler.rs | 6 ++--- .../src/execute/tests/tasks.rs | 2 +- .../src/execute/tests/timeouts.rs | 2 +- .../src/execute/tests/waits.rs | 2 +- crates/promptforge-api-runtime/src/lua.rs | 2 -- crates/promptforge-api-runtime/src/store.rs | 2 -- .../src/test_support/tokio_driver.rs | 2 +- crates/promptforge/lua/src/vm.rs | 26 +++++++++---------- crates/shared-vfs/src/handle.rs | 2 ++ crates/workshop/menu/src/menu.rs | 1 + crates/workshop/registry/src/traits.rs | 8 ++++++ crates/workshop/user-state/src/handlers.rs | 2 +- crates/workshop/user-state/src/store-tests.rs | 18 ++++++------- crates/workshop/user-state/src/store.rs | 2 +- vibe/2026-09-20-2-rust-rulebook-sweep.md | 2 +- 30 files changed, 87 insertions(+), 64 deletions(-) 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/gateway-api/src/lib.rs b/crates/gateway-api/src/lib.rs index fcef9728e..85931f541 100644 --- a/crates/gateway-api/src/lib.rs +++ b/crates/gateway-api/src/lib.rs @@ -117,9 +117,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/app/src/api_error.rs b/crates/gateway/app/src/api_error.rs index 9337b3aa4..71ef02d7d 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); diff --git a/crates/gateway/app/src/model_info.rs b/crates/gateway/app/src/model_info.rs index 8d123cd1e..38390bb9f 100644 --- a/crates/gateway/app/src/model_info.rs +++ b/crates/gateway/app/src/model_info.rs @@ -7,7 +7,6 @@ //! 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")] diff --git a/crates/gateway/app/src/runner.rs b/crates/gateway/app/src/runner.rs index 87d178aa8..5486a24be 100644 --- a/crates/gateway/app/src/runner.rs +++ b/crates/gateway/app/src/runner.rs @@ -825,12 +825,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; diff --git a/crates/gateway/cloud-providers/src/providers/bedrock-sigv4.rs b/crates/gateway/cloud-providers/src/providers/bedrock-sigv4.rs index 34d52f969..15fc6b0e8 100644 --- a/crates/gateway/cloud-providers/src/providers/bedrock-sigv4.rs +++ b/crates/gateway/cloud-providers/src/providers/bedrock-sigv4.rs @@ -32,7 +32,10 @@ pub(super) fn host_of(url: &str) -> &str { /// Sign 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, diff --git a/crates/gateway/cloud-providers/src/providers/cohere.rs b/crates/gateway/cloud-providers/src/providers/cohere.rs index a3784471d..d0cee1c54 100644 --- a/crates/gateway/cloud-providers/src/providers/cohere.rs +++ b/crates/gateway/cloud-providers/src/providers/cohere.rs @@ -115,9 +115,11 @@ 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)] +#[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(); diff --git a/crates/harness/runner/clippy.toml b/crates/harness/runner/clippy.toml index 64df9bc0a..0b4d86b78 100644 --- a/crates/harness/runner/clippy.toml +++ b/crates/harness/runner/clippy.toml @@ -7,7 +7,7 @@ allow-expect-in-tests = true # `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 wrapper functions in `spawn` are the only sites allowed to -# call them, each under an explicit `#[allow(clippy::disallowed_methods)]`. +# 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/spawn.rs b/crates/harness/runner/src/spawn.rs index 4d7df5ea1..fd81cf4ac 100644 --- a/crates/harness/runner/src/spawn.rs +++ b/crates/harness/runner/src/spawn.rs @@ -30,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, @@ -57,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, @@ -78,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, @@ -110,7 +119,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_launch(agent: &str, f: F) -> JoinHandle where F: FnOnce() -> R + Send + 'static, 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/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/tests.rs b/crates/promptforge-api-runtime/src/execute/tests.rs index 5cc5a4e00..68be05274 100644 --- a/crates/promptforge-api-runtime/src/execute/tests.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. @@ -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(), 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..225a9c88c 100644 --- a/crates/promptforge-api-runtime/src/execute/tests/model_tasks.rs +++ b/crates/promptforge-api-runtime/src/execute/tests/model_tasks.rs @@ -12,7 +12,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; diff --git a/crates/promptforge-api-runtime/src/execute/tests/scheduler.rs b/crates/promptforge-api-runtime/src/execute/tests/scheduler.rs index 587c0e3dd..1b83345e4 100644 --- a/crates/promptforge-api-runtime/src/execute/tests/scheduler.rs +++ b/crates/promptforge-api-runtime/src/execute/tests/scheduler.rs @@ -3828,7 +3828,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() @@ -3853,7 +3853,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() @@ -3890,7 +3890,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/waits.rs b/crates/promptforge-api-runtime/src/execute/tests/waits.rs index 655e89160..31c2e38db 100644 --- a/crates/promptforge-api-runtime/src/execute/tests/waits.rs +++ b/crates/promptforge-api-runtime/src/execute/tests/waits.rs @@ -13,7 +13,7 @@ use promptforge_api_types::ids::TaskId; 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 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/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/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/lua/src/vm.rs b/crates/promptforge/lua/src/vm.rs index 1e553c590..fa121f531 100644 --- a/crates/promptforge/lua/src/vm.rs +++ b/crates/promptforge/lua/src/vm.rs @@ -48,7 +48,7 @@ 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::{DebugMode, Emitter, EventSink}; /// use promptforge_api_types::untrusted::GuardNonce; @@ -57,7 +57,7 @@ pub(crate) fn pack_sequence( /// 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,7 +228,7 @@ 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::{DebugMode, Emitter, EventSink}; /// use promptforge_api_types::untrusted::GuardNonce; @@ -237,7 +237,7 @@ impl SectionVm { /// 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,7 +430,7 @@ impl SectionVm { /// values were already injected. /// /// # Examples - /// ```text + /// ```no_run /// use promptforge_lua::SectionVm; /// use promptforge_api_types::emitter::{DebugMode, Emitter, EventSink}; /// use promptforge_api_types::untrusted::GuardNonce; @@ -439,13 +439,12 @@ impl SectionVm { /// 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,7 +779,7 @@ impl SectionVm { /// cannot be represented as JSON. /// /// # Examples - /// ```text + /// ```no_run /// use promptforge_lua::SectionVm; /// use promptforge_api_types::emitter::{DebugMode, Emitter, EventSink}; /// use promptforge_api_types::untrusted::GuardNonce; @@ -789,14 +788,13 @@ impl SectionVm { /// 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,7 +991,7 @@ impl SectionVm { /// retained by the VM. /// /// # Examples - /// ```text + /// ```no_run /// use promptforge_lua::SectionVm; /// use promptforge_api_types::emitter::{DebugMode, Emitter, EventSink}; /// use promptforge_api_types::untrusted::GuardNonce; @@ -1002,7 +1000,7 @@ impl SectionVm { /// 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); diff --git a/crates/shared-vfs/src/handle.rs b/crates/shared-vfs/src/handle.rs index 7e6fb8a32..b032ccb28 100644 --- a/crates/shared-vfs/src/handle.rs +++ b/crates/shared-vfs/src/handle.rs @@ -178,6 +178,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 +187,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, 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/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/user-state/src/handlers.rs b/crates/workshop/user-state/src/handlers.rs index 1424d7da5..03b705617 100644 --- a/crates/workshop/user-state/src/handlers.rs +++ b/crates/workshop/user-state/src/handlers.rs @@ -44,7 +44,7 @@ pub fn routes(store: Arc) -> axum::Router { /// allow-listed name, `null` where nothing has been put. pub(crate) async fn get_state(State(store): State>) -> Response { let document: serde_json::Map = store - .get_all() + .all() .await .into_iter() .map(|(key, value)| (key.to_owned(), value.unwrap_or(Value::Null))) diff --git a/crates/workshop/user-state/src/store-tests.rs b/crates/workshop/user-state/src/store-tests.rs index eb15f5a83..166cf5331 100644 --- a/crates/workshop/user-state/src/store-tests.rs +++ b/crates/workshop/user-state/src/store-tests.rs @@ -35,7 +35,7 @@ fn all_null() -> BTreeMap<&'static str, Option> { async fn a_missing_file_yields_all_null_and_creates_nothing() { let dir = tempfile::TempDir::new().expect("tempdir"); let store = UserStateStore::new(dir.path()); - assert_eq!(store.get_all().await, all_null()); + assert_eq!(store.all().await, all_null()); assert!( dir_names(dir.path()).is_empty(), "construction alone writes no file" @@ -57,7 +57,7 @@ async fn a_put_round_trips_through_a_fresh_store() { .expect("an allow-listed value under the cap is stored"); } let reborn = UserStateStore::new(dir.path()); - let state = reborn.get_all().await; + let state = reborn.all().await; assert_eq!(state["zoom"], Some(json!(1.25))); assert_eq!(state["recent_files"], Some(json!(["C:/a.md", "C:/b.md"]))); assert_eq!( @@ -77,7 +77,7 @@ async fn a_corrupt_file_yields_all_null_and_the_next_put_replaces_it() { std::fs::write(&path, "not json {").expect("write fixture"); let store = UserStateStore::new(dir.path()); assert_eq!( - store.get_all().await, + store.all().await, all_null(), "corrupt state degrades to no state, never to a failure" ); @@ -100,7 +100,7 @@ async fn a_non_object_document_yields_all_null() { std::fs::write(dir.path().join(USER_STATE_FILE), "[1, 2, 3]").expect("write fixture"); let store = UserStateStore::new(dir.path()); assert_eq!( - store.get_all().await, + store.all().await, all_null(), "valid JSON of the wrong shape is corrupt state" ); @@ -157,7 +157,7 @@ async fn a_disallowed_key_is_refused_without_a_write() { "a refused put creates no file" ); assert_eq!( - store.get_all().await, + store.all().await, all_null(), "a refused put stores nothing" ); @@ -185,7 +185,7 @@ async fn an_over_cap_value_is_refused_without_a_write() { "a refused put creates no file" ); assert_eq!( - store.get_all().await, + store.all().await, all_null(), "a refused put stores nothing" ); @@ -201,7 +201,7 @@ async fn a_value_exactly_at_the_cap_is_accepted() { .put("zoom", at_cap.clone()) .await .expect("a value at the cap is under the limit, not over it"); - assert_eq!(store.get_all().await["zoom"], Some(at_cap)); + assert_eq!(store.all().await["zoom"], Some(at_cap)); } #[tokio::test] @@ -220,7 +220,7 @@ async fn an_unwritable_state_dir_reports_io_and_keeps_the_value_in_memory() { "the write failure is reported as I/O: {error:?}" ); assert_eq!( - store.get_all().await["zoom"], + store.all().await["zoom"], Some(json!(1)), "the in-memory state is the source of truth; a failed persist is degradation" ); @@ -232,7 +232,7 @@ async fn unknown_keys_in_the_file_are_preserved_but_not_served() { let path = dir.path().join(USER_STATE_FILE); std::fs::write(&path, r#"{"zoom": 3, "future_key": true}"#).expect("write fixture"); let store = UserStateStore::new(dir.path()); - let state = store.get_all().await; + let state = store.all().await; assert_eq!(state["zoom"], Some(json!(3))); assert_eq!( state.len(), diff --git a/crates/workshop/user-state/src/store.rs b/crates/workshop/user-state/src/store.rs index c382415f7..dbb279253 100644 --- a/crates/workshop/user-state/src/store.rs +++ b/crates/workshop/user-state/src/store.rs @@ -59,7 +59,7 @@ impl UserStateStore { /// Every allow-listed key with its stored value, `None` when never /// saved. - pub async fn get_all(&self) -> BTreeMap<&'static str, Option> { + pub async fn all(&self) -> BTreeMap<&'static str, Option> { let state = self.state.lock().await; USER_STATE_KEYS .iter() diff --git a/vibe/2026-09-20-2-rust-rulebook-sweep.md b/vibe/2026-09-20-2-rust-rulebook-sweep.md index 1ff75a76d..634b58334 100644 --- a/vibe/2026-09-20-2-rust-rulebook-sweep.md +++ b/vibe/2026-09-20-2-rust-rulebook-sweep.md @@ -432,7 +432,7 @@ Commit: one commit. -### Step 8: Lint suppressions, test plumbing, doc examples +### Step 8: Lint suppressions, test plumbing, doc examples [completed] - Component: `layout-lint-hygiene` - Piece: lint hygiene (D4b) From 1e110961da0d480d0220f7b9ba55e4a57a8e1b3c Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Sun, 20 Sep 2026 09:54:54 -0700 Subject: [PATCH 09/39] Split parser and loopback roots; name vfs and wire modules The parser and loopback crate roots each held their whole implementation beside the module list, so neither root read as an index of the crate. Each root now keeps only crate documentation, module declarations, and re-exports, with the logic moved into sibling modules named for the concept they hold and unit tests moved beside the code they cover. Two catch-all modules in the virtual filesystem and realtime transcription crates are renamed for what they contain, and the grep exchange types get a module of their own. The moved code is unchanged apart from imports, module docs, and one visibility widening, and every previously public name is still reachable from its crate root. - `crates/promptforge/parser/src/lib.rs` shrinks from 676 lines to 44: crate docs, `mod` lines, and `pub use` only. The error substrate and its classification move to `error.rs`; the `Prompt::parse` entry point with frontmatter decoding and body assembly moves to `parse.rs`; the `Prompt`, `Section`, and `Block` types with their accessors and `strip_h1_prose` move to `prompt.rs`. - `crates/shared-loopback/src/lib.rs` shrinks from 548 lines to a 26-line re-export root. `peer.rs` holds `require_loopback` and `is_loopback_peer`, `host.rs` holds `require_loopback_host`, and `origin.rs` holds the two WebSocket Origin predicates. The 19 tests that lived in the root move with their modules: 4, 10, and 5. - `crates/shared-vfs/src/types.rs` is renamed `stat.rs` and keeps `FileType`, `Stat`, and `Entry`; `GrepQuery`, `GrepMatch`, and `GrepResults` move to a new `grep.rs`. Five importing files switch paths and the crate root re-exports the same six names. - `crates/gateway/stt/api/src/realtime/wire/shared.rs` is renamed `vocabulary.rs` and gains a module doc naming what it holds. The `mod` line, the root re-export, and three import paths follow. - `with_prompt_context` widens from private to `pub(crate)` because its only caller now lives in `parse.rs`; `Result` is re-exported `pub(crate)` from `error.rs` for the same reason. No other moved item changes visibility, signature, or body. - `mod tests;` in the parser root stays in place; that test module is not in this change and still reaches every name it uses through the root's re-exports. Design: extends facade @ crates/promptforge/parser/src/lib.rs Design: new facade @ crates/shared-loopback/src/lib.rs Design: replaces value-object @ crates/promptforge/parser/src/error.rs::ParseErrorKind boundary: pub was: crates/promptforge/parser/src/lib.rs::ParseErrorKind Design: replaces pure-function @ crates/promptforge/parser/src/error.rs::body_line_column deps: &str,u32,usize was: crates/promptforge/parser/src/lib.rs::body_line_column Design: replaces pure-function @ crates/promptforge/parser/src/error.rs::classify_parse_error deps: &Error was: crates/promptforge/parser/src/lib.rs::classify_parse_error Design: replaces pure-function @ crates/shared-loopback/src/peer.rs::is_loopback_peer deps: Option boundary: pub was: crates/shared-loopback/src/lib.rs::is_loopback_peer Design: replaces pure-function @ crates/shared-loopback/src/origin.rs::gateway_loopback_origin_allowed deps: Option<&str> boundary: pub was: crates/shared-loopback/src/lib.rs::gateway_loopback_origin_allowed Design: replaces pure-function @ crates/shared-loopback/src/origin.rs::workshop_same_origin_authority_allowed deps: Option<&str>,Option<&str> boundary: pub was: crates/shared-loopback/src/lib.rs::workshop_same_origin_authority_allowed Design: replaces bag-of-state @ crates/shared-vfs/src/grep.rs::GrepQuery boundary: pub was: crates/shared-vfs/src/types.rs::GrepQuery Plan: vibe/2026-09-20-2-rust-rulebook-sweep.md --- crates/gateway/stt/api/src/realtime/wire.rs | 4 +- .../stt/api/src/realtime/wire/client.rs | 2 +- .../api/src/realtime/wire/server-events.rs | 2 +- .../stt/api/src/realtime/wire/server.rs | 2 +- .../wire/{shared.rs => vocabulary.rs} | 4 + crates/promptforge/parser/src/error.rs | 307 +++++++++ crates/promptforge/parser/src/lib.rs | 644 +----------------- crates/promptforge/parser/src/parse.rs | 169 +++++ crates/promptforge/parser/src/prompt.rs | 187 +++++ crates/shared-loopback/src/host.rs | 246 +++++++ crates/shared-loopback/src/lib.rs | 534 +-------------- crates/shared-loopback/src/origin.rs | 193 ++++++ crates/shared-loopback/src/peer.rs | 121 ++++ crates/shared-vfs/src/grep.rs | 44 ++ crates/shared-vfs/src/handle.rs | 5 +- crates/shared-vfs/src/host.rs | 4 +- crates/shared-vfs/src/lib.rs | 6 +- crates/shared-vfs/src/memory.rs | 4 +- crates/shared-vfs/src/router.rs | 5 +- crates/shared-vfs/src/{types.rs => stat.rs} | 45 +- crates/shared-vfs/src/traits.rs | 6 +- vibe/2026-09-20-2-rust-rulebook-sweep.md | 2 +- 22 files changed, 1309 insertions(+), 1227 deletions(-) rename crates/gateway/stt/api/src/realtime/wire/{shared.rs => vocabulary.rs} (97%) create mode 100644 crates/promptforge/parser/src/error.rs create mode 100644 crates/promptforge/parser/src/parse.rs create mode 100644 crates/promptforge/parser/src/prompt.rs create mode 100644 crates/shared-loopback/src/host.rs create mode 100644 crates/shared-loopback/src/origin.rs create mode 100644 crates/shared-loopback/src/peer.rs create mode 100644 crates/shared-vfs/src/grep.rs rename crates/shared-vfs/src/{types.rs => stat.rs} (58%) diff --git a/crates/gateway/stt/api/src/realtime/wire.rs b/crates/gateway/stt/api/src/realtime/wire.rs index 6a4cc3623..61a593a74 100644 --- a/crates/gateway/stt/api/src/realtime/wire.rs +++ b/crates/gateway/stt/api/src/realtime/wire.rs @@ -1,6 +1,6 @@ mod client; mod server; -mod shared; +mod vocabulary; #[cfg(test)] mod tests; @@ -13,4 +13,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..d46cfef60 100644 --- a/crates/gateway/stt/api/src/realtime/wire/client.rs +++ b/crates/gateway/stt/api/src/realtime/wire/client.rs @@ -1,6 +1,6 @@ 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..613e816fb 100644 --- a/crates/gateway/stt/api/src/realtime/wire/server-events.rs +++ b/crates/gateway/stt/api/src/realtime/wire/server-events.rs @@ -2,7 +2,7 @@ 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..7ffafa3d0 100644 --- a/crates/gateway/stt/api/src/realtime/wire/server.rs +++ b/crates/gateway/stt/api/src/realtime/wire/server.rs @@ -5,7 +5,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/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/promptforge/parser/src/error.rs b/crates/promptforge/parser/src/error.rs new file mode 100644 index 000000000..a87e4efd6 --- /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, +} + +/// 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), + } + } +} diff --git a/crates/promptforge/parser/src/lib.rs b/crates/promptforge/parser/src/lib.rs index 271742b72..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::{DebugMode, 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, 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, - }) - } - - /// 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/parse.rs b/crates/promptforge/parser/src/parse.rs new file mode 100644 index 000000000..0a9bc7350 --- /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 { + /// 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, 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/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-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 b032ccb28..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)] @@ -767,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 6087c7a2e..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 - @@ -388,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/vibe/2026-09-20-2-rust-rulebook-sweep.md b/vibe/2026-09-20-2-rust-rulebook-sweep.md index 634b58334..8e21da3c0 100644 --- a/vibe/2026-09-20-2-rust-rulebook-sweep.md +++ b/vibe/2026-09-20-2-rust-rulebook-sweep.md @@ -457,7 +457,7 @@ Commit: one commit. -### Step 9: Parser facade root and junk-drawer renames +### Step 9: Parser facade root and junk-drawer renames [completed] - Component: `layout-lint-hygiene` - Piece: facade and names (D6) From 84a234ac1f553d62b65b7439bb3d98a02017c2a5 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Sun, 20 Sep 2026 10:15:37 -0700 Subject: [PATCH 10/39] Inject workspace clock and drop sentinel test sleeps Tests that needed a request or tool call to never finish used a thirty second sleep as the stall, which tied their meaning to wall time and would jump straight to completion under a paused runtime clock. Those stalls now use a future that never resolves, so the timeout or cancellation under test is the only thing that can end them. The workspace's grant timestamps now come from a clock function stored on the workspace, defaulting to the wall clock, so a test can hand consecutive grants distinct stamps instead of spinning until the real second changes. One runner integration test moves to paused time so its bounded wait for task teardown costs no wall time. - `Workspace` gains a private `now: fn() -> String` field that a new grant's `added_at` reads through `(self.now)()`. `Default` is written by hand to seed it with `now_rfc3339`, and `with_clock_for_test` swaps it in test builds only, so the public constructor surface is unchanged. - `TICKS` is a process-wide `AtomicU32` behind `ticking_clock`, so every test in the process draws from one counter and two grants in one test never share a stamp however the tests interleave. - `std::future::pending()` replaces the five `tokio::time::sleep(Duration::from_secs(30))` stall arms in the transport limits, web search, and two runtime tool tests. Each stall can no longer complete on its own, so only the timeout or cancellation under test ends it. - `a_refused_log_write_returns_the_log_error_and_aborts_the_parked_performers` runs with `start_paused = true`; each sleep inside `await_raised` yields to the teardown and then advances the clock. - `wait_past` is removed along with the `now_rfc3339` import from the grants test module; the two save-as tests build on `ticking_workspace()` instead. Design: new strategy @ crates/workshop/workspace/src/workspace.rs::Workspace::now Design: new global-state @ crates/workshop/workspace/src/workspace-tests-grants.rs::TICKS Deferred: The paused-time conversions of the remaining runtime, performer, gateway progress, and workspace mutation tests are not in this commit. Plan: vibe/2026-09-20-2-rust-rulebook-sweep.md --- .../models/src/transport/tests/limits.rs | 8 +++-- crates/harness/runner/tests/it/effect_loop.rs | 4 ++- .../web-search/src/web_search-tests.rs | 3 +- .../src/execute/tests.rs | 7 ++-- .../src/execute/tests/scheduler.rs | 7 ++-- .../workspace/src/workspace-tests-grants.rs | 36 +++++++++++-------- crates/workshop/workspace/src/workspace.rs | 31 ++++++++++++++-- vibe/2026-09-20-2-rust-rulebook-sweep.md | 2 +- 8 files changed, 67 insertions(+), 31 deletions(-) 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/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/web-search/src/web_search-tests.rs b/crates/harness/web-search/src/web_search-tests.rs index d2576c87c..371c3acb4 100644 --- a/crates/harness/web-search/src/web_search-tests.rs +++ b/crates/harness/web-search/src/web_search-tests.rs @@ -392,8 +392,7 @@ async fn transport_failure_is_transport_kind() { #[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": [] })) + 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)) diff --git a/crates/promptforge-api-runtime/src/execute/tests.rs b/crates/promptforge-api-runtime/src/execute/tests.rs index 68be05274..84f836159 100644 --- a/crates/promptforge-api-runtime/src/execute/tests.rs +++ b/crates/promptforge-api-runtime/src/execute/tests.rs @@ -1297,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] @@ -1329,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/scheduler.rs b/crates/promptforge-api-runtime/src/execute/tests/scheduler.rs index 1b83345e4..2d0872854 100644 --- a/crates/promptforge-api-runtime/src/execute/tests/scheduler.rs +++ b/crates/promptforge-api-runtime/src/execute/tests/scheduler.rs @@ -3686,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, } @@ -3723,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 } } diff --git a/crates/workshop/workspace/src/workspace-tests-grants.rs b/crates/workshop/workspace/src/workspace-tests-grants.rs index dd6e11a2e..9f34d53b1 100644 --- a/crates/workshop/workspace/src/workspace-tests-grants.rs +++ b/crates/workshop/workspace/src/workspace-tests-grants.rs @@ -5,9 +5,9 @@ use super::*; -use crate::workspace_file::{ - GrantRow, WorkspaceContents, WorkspaceFile, empty_ui_state, now_rfc3339, -}; +use std::sync::atomic::{AtomicU32, Ordering}; + +use crate::workspace_file::{GrantRow, WorkspaceContents, WorkspaceFile, empty_ui_state}; /// Opens the file at `path` directly, bypassing any `Workspace`, and /// returns the grant rows it holds in file order. @@ -20,12 +20,21 @@ async fn file_rows(path: &Path) -> Vec { contents.grants } -/// Blocks until the clock has moved past `stamp`, so the next grant's -/// `added_at` (whole seconds) differs from the one that produced it. -async fn wait_past(stamp: &str) { - while now_rfc3339() == stamp { - tokio::time::sleep(std::time::Duration::from_millis(25)).await; - } +/// The ticks handed out by [`ticking_clock`] so far, shared by every +/// test in the process: each grant reads the next one, so two grants in +/// one test never share a stamp however the tests interleave. +static TICKS: AtomicU32 = AtomicU32::new(0); + +/// A clock that moves one second per reading, so consecutive grants get +/// distinct `added_at` stamps without waiting for the wall clock. +fn ticking_clock() -> String { + let tick = TICKS.fetch_add(1, Ordering::SeqCst); + format!("2026-09-16T10:{:02}:{:02}Z", (tick / 60) % 60, tick % 60) +} + +/// An ephemeral workspace whose grant times come from [`ticking_clock`]. +fn ticking_workspace() -> Workspace { + Workspace::new().with_clock_for_test(ticking_clock) } /// Grants `z` then `a` (reverse canonical order, so order-by-path and @@ -39,9 +48,8 @@ async fn grant_z_then_a( .grant_and_persist(z.path()) .await .expect("grant z lands"); - // z's `added_at` is the second the grant ran, which is at or before - // this sample; waiting past the sample moves a's time past z's. - wait_past(&now_rfc3339()).await; + // The workspace's clock ticks once per grant, so a's time is one + // second past z's without waiting on the wall clock. let a_root = workspace .grant_and_persist(a.path()) .await @@ -89,7 +97,7 @@ async fn save_as_from_a_file_backed_workspace_keeps_grant_order_and_times() { let first_path = files.path().join("first.pfwork"); let second_path = files.path().join("second.pfwork"); let (_home, z, a) = z_and_a(); - let workspace = Workspace::new(); + let workspace = ticking_workspace(); workspace .save_as(&first_path) .await @@ -114,7 +122,7 @@ async fn save_as_from_an_ephemeral_workspace_keeps_grant_order_and_times() { let files = tempfile::TempDir::new().expect("tempdir"); let path = files.path().join("fresh.pfwork"); let (_home, z, a) = z_and_a(); - let workspace = Workspace::new(); + let workspace = ticking_workspace(); let (z_root, a_root) = grant_z_then_a(&workspace, &z, &a).await; assert!( diff --git a/crates/workshop/workspace/src/workspace.rs b/crates/workshop/workspace/src/workspace.rs index c3bece3d4..bd8613243 100644 --- a/crates/workshop/workspace/src/workspace.rs +++ b/crates/workshop/workspace/src/workspace.rs @@ -143,11 +143,15 @@ pub(crate) struct GrantMeta { /// grant set is the confinement source of truth; the backing file, when /// present, is its persistent mirror and is swapped at runtime by open, /// save-as, and duplicate, each recorded in the last-workspace pointer. -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone)] pub struct Workspace { /// The granted roots, in canonical form, each with its grant order /// and time. Read-hot: its own lock. grants: Arc>>, + /// The clock a new grant's `added_at` reads: the wall clock in + /// production, a scripted one in tests that need distinct stamps + /// without waiting a second between grants. + now: fn() -> String, /// The backing workspace file; `None` while the workspace is /// ephemeral. backing: Arc>>, @@ -173,6 +177,20 @@ pub struct Workspace { pointer: Option, } +impl Default for Workspace { + fn default() -> Self { + Self { + grants: Arc::default(), + now: now_rfc3339, + backing: Arc::default(), + ui_state_puts: Arc::default(), + switches: Arc::default(), + closed: Arc::default(), + pointer: None, + } + } +} + impl Workspace { /// Creates an ephemeral workspace with no grants and no file. #[must_use] @@ -180,6 +198,15 @@ impl Workspace { Self::default() } + /// The same workspace reading grant times from `now` instead of the + /// wall clock, so a test can give consecutive grants distinct stamps. + #[cfg(test)] + #[must_use] + fn with_clock_for_test(mut self, now: fn() -> String) -> Self { + self.now = now; + self + } + /// Registers `path` as a granted root in memory only: a directory /// grants itself, a file grants its parent directory. A new grant /// takes the position one past the current maximum and the current @@ -223,7 +250,7 @@ impl Workspace { .entry(root.clone()) .or_insert_with(|| GrantMeta { position, - added_at: now_rfc3339(), + added_at: (self.now)(), }) .clone(); Ok((root, meta)) diff --git a/vibe/2026-09-20-2-rust-rulebook-sweep.md b/vibe/2026-09-20-2-rust-rulebook-sweep.md index 8e21da3c0..c1f4f0e6e 100644 --- a/vibe/2026-09-20-2-rust-rulebook-sweep.md +++ b/vibe/2026-09-20-2-rust-rulebook-sweep.md @@ -479,7 +479,7 @@ Commit: one commit. -### Step 10: Paused time in deterministic async tests +### Step 10: Paused time in deterministic async tests [completed] - Component: `paused-time` - Piece: in-process tests and the workspace clock (D8) From 854e44bee27152293bb7a3f06c351a9db93f8bee Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Sun, 20 Sep 2026 10:42:13 -0700 Subject: [PATCH 11/39] Reword gateway doc summaries and add module docs Documentation comments across the gateway family of crates now describe what each item does in the third person instead of issuing a command, so generated docs and editor hover text read consistently. Every speech-to-text production module and every gateway test module that opened without a module-level summary now begins with one sentence naming its purpose. The commit touches comment text only: no code line, attribute, signature, or test assertion changes. - `crates/gateway/protocol/src/upstream.rs` The `Upstream` trait's method summaries now read as descriptions (forwards, opens, explicitly releases) rather than instructions; the doc text sits on a public trait, and the verb form is the only change. - `crates/gateway/cloud-providers/src/providers/openai.rs` and every sibling provider module have their `fetch`, `normalize_model`, and `apply_taxonomy` summaries reworded on the same pattern, so the provider set documents itself uniformly. - `crates/gateway/stt/api/src/realtime.rs` and the other speech-to-text production modules under `take/`, `realtime/`, and `wire/` each gain a one-sentence `//!` header where the file formerly opened directly on a `use` line or an item. - `crates/gateway/config/src/config/tests.rs` and the other gateway test modules gain a `//!` header stating what the file tests. - `crates/gateway/app/src/dialect.rs` Reworded summaries keep every doctest fence and intra-doc link such as `ParsedCall` intact; no comment changes meaning, only its grammatical mood. Plan: vibe/2026-09-20-2-rust-rulebook-sweep.md --- crates/gateway/app/src/api_error.rs | 2 +- crates/gateway/app/src/cloud_models.rs | 10 +++--- crates/gateway/app/src/cloud_models/tests.rs | 2 ++ crates/gateway/app/src/commands.rs | 10 +++--- crates/gateway/app/src/dialect.rs | 30 ++++++++--------- crates/gateway/app/src/error.rs | 10 +++--- crates/gateway/app/src/hf.rs | 2 +- crates/gateway/app/src/lib.rs | 4 +-- crates/gateway/app/src/main-logging-tests.rs | 2 ++ crates/gateway/app/src/main.rs | 8 ++--- crates/gateway/app/src/relay.rs | 2 +- crates/gateway/app/src/routing.rs | 8 ++--- crates/gateway/app/src/runner.rs | 10 +++--- crates/gateway/app/src/speech.rs | 2 +- crates/gateway/app/tests/it/chat.rs | 2 +- crates/gateway/app/tests/it/embeddings.rs | 2 +- crates/gateway/app/tests/it/rerank.rs | 2 +- crates/gateway/app/tests/it/speech.rs | 4 +-- crates/gateway/app/tests/it/support.rs | 6 ++-- crates/gateway/cloud-providers/src/lib.rs | 6 ++-- crates/gateway/cloud-providers/src/main.rs | 10 +++--- .../src/providers/anthropic-taxonomy.rs | 2 +- .../src/providers/anthropic.rs | 6 ++-- .../src/providers/azure_speech.rs | 8 ++--- .../cloud-providers/src/providers/baidu.rs | 6 ++-- .../src/providers/bedrock-sigv4.rs | 4 +-- .../src/providers/bedrock-taxonomy.rs | 2 +- .../cloud-providers/src/providers/bedrock.rs | 4 +-- .../cloud-providers/src/providers/cohere.rs | 6 ++-- .../cloud-providers/src/providers/deepgram.rs | 8 ++--- .../cloud-providers/src/providers/deepseek.rs | 6 ++-- .../src/providers/elevenlabs.rs | 6 ++-- .../src/providers/foundry-taxonomy.rs | 2 +- .../cloud-providers/src/providers/foundry.rs | 6 ++-- .../cloud-providers/src/providers/gemini.rs | 6 ++-- .../cloud-providers/src/providers/groq.rs | 6 ++-- .../cloud-providers/src/providers/leonardo.rs | 6 ++-- .../cloud-providers/src/providers/meta.rs | 6 ++-- .../cloud-providers/src/providers/minimax.rs | 6 ++-- .../src/providers/mistral-taxonomy.rs | 2 +- .../cloud-providers/src/providers/mistral.rs | 6 ++-- .../cloud-providers/src/providers/moonshot.rs | 6 ++-- .../cloud-providers/src/providers/nvidia.rs | 6 ++-- .../cloud-providers/src/providers/openai.rs | 6 ++-- .../src/providers/openai_shape.rs | 2 +- .../src/providers/openrouter-taxonomy.rs | 2 +- .../src/providers/openrouter.rs | 6 ++-- .../cloud-providers/src/providers/qwen.rs | 6 ++-- .../cloud-providers/src/providers/soniox.rs | 6 ++-- .../cloud-providers/src/providers/stepfun.rs | 6 ++-- .../cloud-providers/src/providers/xai.rs | 8 ++--- crates/gateway/cloud-providers/src/sheet.rs | 12 +++---- .../gateway/cloud-providers/src/taxonomy.rs | 8 ++--- .../cloud-providers/tests/sheet_binary.rs | 6 ++-- crates/gateway/config/src/api_error.rs | 2 +- crates/gateway/config/src/config.rs | 14 ++++---- crates/gateway/config/src/config/companion.rs | 4 +-- crates/gateway/config/src/config/imp.rs | 4 +-- .../gateway/config/src/config/interpolate.rs | 4 +-- crates/gateway/config/src/config/tests.rs | 2 ++ .../gateway/config/src/config/tests/schema.rs | 2 ++ .../config/src/config/tests/serialize.rs | 2 ++ .../config/src/config/tests/validation.rs | 2 ++ crates/gateway/config/src/config/validate.rs | 12 +++---- crates/gateway/config/src/shadow-tests.rs | 2 ++ crates/gateway/local/src/artifacts/tests.rs | 2 ++ crates/gateway/local/src/runtime.rs | 4 +-- crates/gateway/local/src/server-tests.rs | 2 ++ crates/gateway/local/src/server.rs | 4 +-- crates/gateway/local/src/sidecar.rs | 2 +- crates/gateway/local/src/upstream.rs | 4 +-- crates/gateway/protocol/src/error.rs | 12 +++---- crates/gateway/protocol/src/http_util.rs | 10 +++--- crates/gateway/protocol/src/upstream.rs | 32 +++++++++---------- crates/gateway/protocol/src/wire.rs | 20 ++++++------ crates/gateway/routing/src/queue-tests.rs | 4 ++- crates/gateway/routing/src/queue.rs | 10 +++--- crates/gateway/stt/api/src/audio.rs | 2 ++ crates/gateway/stt/api/src/batch-tests.rs | 2 ++ crates/gateway/stt/api/src/realtime.rs | 2 ++ crates/gateway/stt/api/src/realtime/input.rs | 2 ++ crates/gateway/stt/api/src/realtime/item.rs | 2 ++ crates/gateway/stt/api/src/realtime/query.rs | 2 ++ .../gateway/stt/api/src/realtime/registry.rs | 2 ++ .../stt/api/src/realtime/result_mailbox.rs | 2 ++ crates/gateway/stt/api/src/realtime/route.rs | 2 ++ .../gateway/stt/api/src/realtime/session.rs | 2 ++ .../api/src/realtime/session/items-tests.rs | 2 ++ .../stt/api/src/realtime/session/items.rs | 2 ++ .../api/src/realtime/session/route-tests.rs | 2 ++ .../stt/api/src/realtime/session/route.rs | 2 ++ .../stt/api/src/realtime/session/state.rs | 2 ++ crates/gateway/stt/api/src/realtime/wire.rs | 2 ++ .../stt/api/src/realtime/wire/client.rs | 2 ++ .../api/src/realtime/wire/server-events.rs | 2 ++ .../stt/api/src/realtime/wire/server.rs | 2 ++ .../stt/api/src/realtime/wire/tests.rs | 2 ++ .../gateway/stt/api/src/segment-boundary.rs | 2 ++ .../src/take/agreement-final-overlap-tests.rs | 2 ++ .../api/src/take/agreement-final-overlap.rs | 2 ++ .../stt/api/src/take/agreement-projection.rs | 2 ++ crates/gateway/stt/api/src/take/agreement.rs | 2 ++ .../gateway/stt/api/src/take/final_decode.rs | 2 ++ .../gateway/stt/api/src/take/final_outcome.rs | 2 ++ .../gateway/stt/api/src/take/finalization.rs | 2 ++ crates/gateway/stt/api/src/take/interim.rs | 2 ++ .../gateway/stt/api/src/take/live_prefix.rs | 2 ++ crates/gateway/stt/api/src/take/pcm-tests.rs | 2 ++ crates/gateway/stt/api/src/take/pcm.rs | 2 ++ .../take/state-alignment-tests-adversaries.rs | 2 ++ .../stt/api/src/take/state-alignment-tests.rs | 2 ++ .../api/src/take/state-tests-live-prefix.rs | 2 ++ .../gateway/stt/api/src/take/state-tests.rs | 2 ++ crates/gateway/stt/api/src/take/state.rs | 2 ++ crates/gateway/stt/api/src/take/text.rs | 2 ++ .../api/src/take/window-tests-live-prefix.rs | 2 ++ crates/gateway/stt/api/src/take/window.rs | 2 ++ .../stt/api/tests/it/realtime_fixtures.rs | 2 ++ .../api/tests/it/realtime_forced_windows.rs | 2 ++ .../stt/api/tests/it/realtime_session.rs | 2 ++ .../stt/engine/src/test_fixtures/scenarios.rs | 2 ++ .../tests-scenario-cleanup-construction.rs | 2 ++ .../tests-scenario-cleanup-decode.rs | 2 ++ .../test_fixtures/tests-scenario-cleanup.rs | 2 ++ .../stt/engine/src/test_fixtures/tests.rs | 2 ++ crates/gateway/web-search/src/brave.rs | 10 +++--- crates/gateway/web-search/src/process.rs | 14 ++++---- crates/gateway/web-search/src/service.rs | 22 ++++++------- vibe/2026-09-20-2-rust-rulebook-sweep.md | 2 +- 129 files changed, 367 insertions(+), 251 deletions(-) diff --git a/crates/gateway/app/src/api_error.rs b/crates/gateway/app/src/api_error.rs index 71ef02d7d..12b1b3dda 100644 --- a/crates/gateway/app/src/api_error.rs +++ b/crates/gateway/app/src/api_error.rs @@ -67,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/cloud_models.rs b/crates/gateway/app/src/cloud_models.rs index 2bbf33838..9e372d16c 100644 --- a/crates/gateway/app/src/cloud_models.rs +++ b/crates/gateway/app/src/cloud_models.rs @@ -199,7 +199,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 +242,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 +285,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 @@ -351,7 +351,7 @@ async fn download_once(cache_path: &Path, url: &str) -> Result CacheRead { match std::fs::read(path) { Ok(bytes) => match serde_json::from_slice::(&bytes) { @@ -366,7 +366,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() { diff --git a/crates/gateway/app/src/cloud_models/tests.rs b/crates/gateway/app/src/cloud_models/tests.rs index 8c2025b9d..66b5fde75 100644 --- a/crates/gateway/app/src/cloud_models/tests.rs +++ b/crates/gateway/app/src/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; diff --git a/crates/gateway/app/src/commands.rs b/crates/gateway/app/src/commands.rs index f2eef793a..f22033b74 100644 --- a/crates/gateway/app/src/commands.rs +++ b/crates/gateway/app/src/commands.rs @@ -51,8 +51,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 +70,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 +89,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), @@ -631,7 +631,7 @@ impl CommandQueue { /// 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. + /// Runs this command; it is installed as the active entry. Run(u64, Command, ProgressTree), /// The deque is empty; park until notified. Wait, diff --git a/crates/gateway/app/src/dialect.rs b/crates/gateway/app/src/dialect.rs index 9cd1a30e2..6c1f5e027 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 @@ -376,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 @@ -453,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. /// @@ -508,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 @@ -538,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. @@ -653,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 @@ -666,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. @@ -743,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() { diff --git a/crates/gateway/app/src/error.rs b/crates/gateway/app/src/error.rs index f896cb191..ba64f1873 100644 --- a/crates/gateway/app/src/error.rs +++ b/crates/gateway/app/src/error.rs @@ -300,7 +300,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( @@ -309,7 +309,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( @@ -322,14 +322,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( @@ -338,7 +338,7 @@ impl GatewayError { GatewayError::ModelInfo(Box::new(source)) } - /// Wrap a system-metrics sampling failure, preserving the cause. + /// Wraps a system-metrics sampling failure, preserving the cause. #[must_use] pub(crate) fn system_metrics( source: impl std::error::Error + Send + Sync + 'static, diff --git a/crates/gateway/app/src/hf.rs b/crates/gateway/app/src/hf.rs index d4697b2e6..e74fa7e2d 100644 --- a/crates/gateway/app/src/hf.rs +++ b/crates/gateway/app/src/hf.rs @@ -27,7 +27,7 @@ use crate::error::GatewayError; /// 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 +/// 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; diff --git a/crates/gateway/app/src/lib.rs b/crates/gateway/app/src/lib.rs index 737c4d4e5..62984067d 100644 --- a/crates/gateway/app/src/lib.rs +++ b/crates/gateway/app/src/lib.rs @@ -386,7 +386,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, @@ -491,7 +491,7 @@ impl AppState { } } -/// 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 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/relay.rs b/crates/gateway/app/src/relay.rs index 7109109bc..c885d783a 100644 --- a/crates/gateway/app/src/relay.rs +++ b/crates/gateway/app/src/relay.rs @@ -137,7 +137,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/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 5486a24be..b35fe3773 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 @@ -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. @@ -420,7 +420,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. /// @@ -1109,7 +1109,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 @@ -1313,7 +1313,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/speech.rs b/crates/gateway/app/src/speech.rs index 1f8ee1cd9..920cc8b87 100644 --- a/crates/gateway/app/src/speech.rs +++ b/crates/gateway/app/src/speech.rs @@ -135,7 +135,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 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/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/src/lib.rs b/crates/gateway/cloud-providers/src/lib.rs index 2d7d1277f..9bfb8cc62 100644 --- a/crates/gateway/cloud-providers/src/lib.rs +++ b/crates/gateway/cloud-providers/src/lib.rs @@ -132,7 +132,7 @@ impl From for FetchError { #[error(transparent)] pub struct HttpSource(reqwest::Error); -/// Render an error and its full `source()` chain as one line, each cause +/// 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. @@ -148,7 +148,7 @@ pub fn error_chain(error: &dyn std::error::Error) -> String { text } -/// Fetch and normalize one provider's model list; the per-provider +/// 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). /// @@ -201,7 +201,7 @@ mod tests { use super::{FetchError, Provider, fetch_models, providers}; - /// 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. diff --git a/crates/gateway/cloud-providers/src/main.rs b/crates/gateway/cloud-providers/src/main.rs index 8fb7d4e71..99c5e5f9b 100644 --- a/crates/gateway/cloud-providers/src/main.rs +++ b/crates/gateway/cloud-providers/src/main.rs @@ -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`. @@ -167,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 _}; diff --git a/crates/gateway/cloud-providers/src/providers/anthropic-taxonomy.rs b/crates/gateway/cloud-providers/src/providers/anthropic-taxonomy.rs index 77d493b3a..a95add7e4 100644 --- a/crates/gateway/cloud-providers/src/providers/anthropic-taxonomy.rs +++ b/crates/gateway/cloud-providers/src/providers/anthropic-taxonomy.rs @@ -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() { diff --git a/crates/gateway/cloud-providers/src/providers/anthropic.rs b/crates/gateway/cloud-providers/src/providers/anthropic.rs index 91b91ec20..c5fc7e150 100644 --- a/crates/gateway/cloud-providers/src/providers/anthropic.rs +++ b/crates/gateway/cloud-providers/src/providers/anthropic.rs @@ -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..399fe9f8b 100644 --- a/crates/gateway/cloud-providers/src/providers/azure_speech.rs +++ b/crates/gateway/cloud-providers/src/providers/azure_speech.rs @@ -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. diff --git a/crates/gateway/cloud-providers/src/providers/baidu.rs b/crates/gateway/cloud-providers/src/providers/baidu.rs index 4eddce2d0..f2fc0711f 100644 --- a/crates/gateway/cloud-providers/src/providers/baidu.rs +++ b/crates/gateway/cloud-providers/src/providers/baidu.rs @@ -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); diff --git a/crates/gateway/cloud-providers/src/providers/bedrock-sigv4.rs b/crates/gateway/cloud-providers/src/providers/bedrock-sigv4.rs index 15fc6b0e8..3a13176e3 100644 --- a/crates/gateway/cloud-providers/src/providers/bedrock-sigv4.rs +++ b/crates/gateway/cloud-providers/src/providers/bedrock-sigv4.rs @@ -29,7 +29,7 @@ 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. #[expect( @@ -92,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..049333427 100644 --- a/crates/gateway/cloud-providers/src/providers/bedrock-taxonomy.rs +++ b/crates/gateway/cloud-providers/src/providers/bedrock-taxonomy.rs @@ -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); diff --git a/crates/gateway/cloud-providers/src/providers/bedrock.rs b/crates/gateway/cloud-providers/src/providers/bedrock.rs index 041cc251e..058c82542 100644 --- a/crates/gateway/cloud-providers/src/providers/bedrock.rs +++ b/crates/gateway/cloud-providers/src/providers/bedrock.rs @@ -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 { diff --git a/crates/gateway/cloud-providers/src/providers/cohere.rs b/crates/gateway/cloud-providers/src/providers/cohere.rs index d0cee1c54..3f0418c25 100644 --- a/crates/gateway/cloud-providers/src/providers/cohere.rs +++ b/crates/gateway/cloud-providers/src/providers/cohere.rs @@ -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,7 +114,7 @@ fn model_kind(endpoints: &[String]) -> ModelKind { } } -/// Normalize one wire model into a sheet entry. +/// Normalizes one wire model into a sheet entry. #[expect( clippy::cast_possible_truncation, clippy::cast_sign_loss, @@ -156,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() { diff --git a/crates/gateway/cloud-providers/src/providers/deepgram.rs b/crates/gateway/cloud-providers/src/providers/deepgram.rs index dc5d22dce..3a064cd4f 100644 --- a/crates/gateway/cloud-providers/src/providers/deepgram.rs +++ b/crates/gateway/cloud-providers/src/providers/deepgram.rs @@ -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() { diff --git a/crates/gateway/cloud-providers/src/providers/deepseek.rs b/crates/gateway/cloud-providers/src/providers/deepseek.rs index 43d3e7854..46142600a 100644 --- a/crates/gateway/cloud-providers/src/providers/deepseek.rs +++ b/crates/gateway/cloud-providers/src/providers/deepseek.rs @@ -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..55ec55f06 100644 --- a/crates/gateway/cloud-providers/src/providers/elevenlabs.rs +++ b/crates/gateway/cloud-providers/src/providers/elevenlabs.rs @@ -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() { diff --git a/crates/gateway/cloud-providers/src/providers/foundry-taxonomy.rs b/crates/gateway/cloud-providers/src/providers/foundry-taxonomy.rs index 28df87ec4..e8358a110 100644 --- a/crates/gateway/cloud-providers/src/providers/foundry-taxonomy.rs +++ b/crates/gateway/cloud-providers/src/providers/foundry-taxonomy.rs @@ -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..362bade78 100644 --- a/crates/gateway/cloud-providers/src/providers/foundry.rs +++ b/crates/gateway/cloud-providers/src/providers/foundry.rs @@ -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) diff --git a/crates/gateway/cloud-providers/src/providers/gemini.rs b/crates/gateway/cloud-providers/src/providers/gemini.rs index 5cf558ce2..7cb2d2a50 100644 --- a/crates/gateway/cloud-providers/src/providers/gemini.rs +++ b/crates/gateway/cloud-providers/src/providers/gemini.rs @@ -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..ab0464d72 100644 --- a/crates/gateway/cloud-providers/src/providers/groq.rs +++ b/crates/gateway/cloud-providers/src/providers/groq.rs @@ -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); diff --git a/crates/gateway/cloud-providers/src/providers/leonardo.rs b/crates/gateway/cloud-providers/src/providers/leonardo.rs index 629e380b8..33a6b33a9 100644 --- a/crates/gateway/cloud-providers/src/providers/leonardo.rs +++ b/crates/gateway/cloud-providers/src/providers/leonardo.rs @@ -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. diff --git a/crates/gateway/cloud-providers/src/providers/meta.rs b/crates/gateway/cloud-providers/src/providers/meta.rs index d52d0e22a..fb73e50f6 100644 --- a/crates/gateway/cloud-providers/src/providers/meta.rs +++ b/crates/gateway/cloud-providers/src/providers/meta.rs @@ -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..17d3c5141 100644 --- a/crates/gateway/cloud-providers/src/providers/minimax.rs +++ b/crates/gateway/cloud-providers/src/providers/minimax.rs @@ -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); diff --git a/crates/gateway/cloud-providers/src/providers/mistral-taxonomy.rs b/crates/gateway/cloud-providers/src/providers/mistral-taxonomy.rs index 7a38d11df..6e39cc820 100644 --- a/crates/gateway/cloud-providers/src/providers/mistral-taxonomy.rs +++ b/crates/gateway/cloud-providers/src/providers/mistral-taxonomy.rs @@ -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() { diff --git a/crates/gateway/cloud-providers/src/providers/mistral.rs b/crates/gateway/cloud-providers/src/providers/mistral.rs index b0a3663f7..04d8631af 100644 --- a/crates/gateway/cloud-providers/src/providers/mistral.rs +++ b/crates/gateway/cloud-providers/src/providers/mistral.rs @@ -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 { diff --git a/crates/gateway/cloud-providers/src/providers/moonshot.rs b/crates/gateway/cloud-providers/src/providers/moonshot.rs index 204151bf2..765f77121 100644 --- a/crates/gateway/cloud-providers/src/providers/moonshot.rs +++ b/crates/gateway/cloud-providers/src/providers/moonshot.rs @@ -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); diff --git a/crates/gateway/cloud-providers/src/providers/nvidia.rs b/crates/gateway/cloud-providers/src/providers/nvidia.rs index 457f341bb..1a0ef8403 100644 --- a/crates/gateway/cloud-providers/src/providers/nvidia.rs +++ b/crates/gateway/cloud-providers/src/providers/nvidia.rs @@ -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. diff --git a/crates/gateway/cloud-providers/src/providers/openai.rs b/crates/gateway/cloud-providers/src/providers/openai.rs index 3169a9bf9..d08c6abf7 100644 --- a/crates/gateway/cloud-providers/src/providers/openai.rs +++ b/crates/gateway/cloud-providers/src/providers/openai.rs @@ -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..028cb80b1 100644 --- a/crates/gateway/cloud-providers/src/providers/openai_shape.rs +++ b/crates/gateway/cloud-providers/src/providers/openai_shape.rs @@ -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..5b09e4247 100644 --- a/crates/gateway/cloud-providers/src/providers/openrouter-taxonomy.rs +++ b/crates/gateway/cloud-providers/src/providers/openrouter-taxonomy.rs @@ -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() { diff --git a/crates/gateway/cloud-providers/src/providers/openrouter.rs b/crates/gateway/cloud-providers/src/providers/openrouter.rs index 22c37bf2e..4bd315604 100644 --- a/crates/gateway/cloud-providers/src/providers/openrouter.rs +++ b/crates/gateway/cloud-providers/src/providers/openrouter.rs @@ -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 { diff --git a/crates/gateway/cloud-providers/src/providers/qwen.rs b/crates/gateway/cloud-providers/src/providers/qwen.rs index 29e59660c..91d61b42d 100644 --- a/crates/gateway/cloud-providers/src/providers/qwen.rs +++ b/crates/gateway/cloud-providers/src/providers/qwen.rs @@ -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. diff --git a/crates/gateway/cloud-providers/src/providers/soniox.rs b/crates/gateway/cloud-providers/src/providers/soniox.rs index 937eef99c..174ac0434 100644 --- a/crates/gateway/cloud-providers/src/providers/soniox.rs +++ b/crates/gateway/cloud-providers/src/providers/soniox.rs @@ -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() { diff --git a/crates/gateway/cloud-providers/src/providers/stepfun.rs b/crates/gateway/cloud-providers/src/providers/stepfun.rs index cf78a7a11..fc4c1b6e9 100644 --- a/crates/gateway/cloud-providers/src/providers/stepfun.rs +++ b/crates/gateway/cloud-providers/src/providers/stepfun.rs @@ -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); diff --git a/crates/gateway/cloud-providers/src/providers/xai.rs b/crates/gateway/cloud-providers/src/providers/xai.rs index 6fd444f61..dd93f0899 100644 --- a/crates/gateway/cloud-providers/src/providers/xai.rs +++ b/crates/gateway/cloud-providers/src/providers/xai.rs @@ -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 956f4d8a4..54efd4d4b 100644 --- a/crates/gateway/cloud-providers/src/sheet.rs +++ b/crates/gateway/cloud-providers/src/sheet.rs @@ -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 /// @@ -136,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 { @@ -157,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 diff --git a/crates/gateway/cloud-providers/src/taxonomy.rs b/crates/gateway/cloud-providers/src/taxonomy.rs index 49e0bc1dd..88849c727 100644 --- a/crates/gateway/cloud-providers/src/taxonomy.rs +++ b/crates/gateway/cloud-providers/src/taxonomy.rs @@ -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)> { diff --git a/crates/gateway/cloud-providers/tests/sheet_binary.rs b/crates/gateway/cloud-providers/tests/sheet_binary.rs index e364a57a6..4a127ae33 100644 --- a/crates/gateway/cloud-providers/tests/sheet_binary.rs +++ b/crates/gateway/cloud-providers/tests/sheet_binary.rs @@ -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/src/api_error.rs b/crates/gateway/config/src/api_error.rs index 461e1e234..82111d85d 100644 --- a/crates/gateway/config/src/api_error.rs +++ b/crates/gateway/config/src/api_error.rs @@ -46,7 +46,7 @@ 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 { diff --git a/crates/gateway/config/src/config.rs b/crates/gateway/config/src/config.rs index 262567a0a..01e42587f 100644 --- a/crates/gateway/config/src/config.rs +++ b/crates/gateway/config/src/config.rs @@ -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, } @@ -339,7 +339,7 @@ pub struct DominionConfig { #[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] @@ -420,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 6e4e9d86c..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 @@ -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 9636ebd0d..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::*; 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..662a330e5 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 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/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/src/artifacts/tests.rs b/crates/gateway/local/src/artifacts/tests.rs index e476e824a..9eecfae46 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}; diff --git a/crates/gateway/local/src/runtime.rs b/crates/gateway/local/src/runtime.rs index a35b8d3a0..b75cb9b44 100644 --- a/crates/gateway/local/src/runtime.rs +++ b/crates/gateway/local/src/runtime.rs @@ -676,7 +676,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 +725,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 diff --git a/crates/gateway/local/src/server-tests.rs b/crates/gateway/local/src/server-tests.rs index bff35aa65..0dea6800b 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}; diff --git a/crates/gateway/local/src/server.rs b/crates/gateway/local/src/server.rs index f7f6feb39..04f7f63f9 100644 --- a/crates/gateway/local/src/server.rs +++ b/crates/gateway/local/src/server.rs @@ -131,9 +131,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, } 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/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 f47980493..67df9e63d 100644 --- a/crates/gateway/protocol/src/wire.rs +++ b/crates/gateway/protocol/src/wire.rs @@ -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 @@ -262,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 @@ -306,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` @@ -441,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 @@ -501,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). @@ -548,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 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/api/src/audio.rs b/crates/gateway/stt/api/src/audio.rs index 00853eefe..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; 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/realtime.rs b/crates/gateway/stt/api/src/realtime.rs index b591c4732..34fd3ab39 100644 --- a/crates/gateway/stt/api/src/realtime.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 61a593a74..f36a177df 100644 --- a/crates/gateway/stt/api/src/realtime/wire.rs +++ b/crates/gateway/stt/api/src/realtime/wire.rs @@ -1,3 +1,5 @@ +//! Wire module root for the realtime client and server event protocol. + mod client; mod server; mod vocabulary; diff --git a/crates/gateway/stt/api/src/realtime/wire/client.rs b/crates/gateway/stt/api/src/realtime/wire/client.rs index d46cfef60..c7b32bcf5 100644 --- a/crates/gateway/stt/api/src/realtime/wire/client.rs +++ b/crates/gateway/stt/api/src/realtime/wire/client.rs @@ -1,3 +1,5 @@ +//! Parsing of client JSON events into typed realtime commands. + use serde_json::{Map, Value}; use super::vocabulary::{ 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 613e816fb..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,3 +1,5 @@ +//! Constructors building server events from session and item outcomes. + use super::{ ConversationItem, DurationUsage, EffectiveSession, InputAudioContent, ServerEvent, WireError, }; diff --git a/crates/gateway/stt/api/src/realtime/wire/server.rs b/crates/gateway/stt/api/src/realtime/wire/server.rs index 7ffafa3d0..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}; 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/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/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/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/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/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/vibe/2026-09-20-2-rust-rulebook-sweep.md b/vibe/2026-09-20-2-rust-rulebook-sweep.md index c1f4f0e6e..e4e541782 100644 --- a/vibe/2026-09-20-2-rust-rulebook-sweep.md +++ b/vibe/2026-09-20-2-rust-rulebook-sweep.md @@ -503,7 +503,7 @@ Commit: one commit (production clock change ships with the rewritten test). -### Step 11: Documentation prose - gateway family +### Step 11: Documentation prose - gateway family [completed] - Component: `docs-prose` - Piece: gateway crates (D7, group 1) From bc212bbfe6806cb1adece85d335c99981a7286bb Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Sun, 20 Sep 2026 11:05:49 -0700 Subject: [PATCH 12/39] Reword doc summaries and add module headers, two families Rewords the first line of 56 doc comments across the promptforge and harness crate families from the imperative to the third-person indicative, so each summary reads as a statement of what the item does rather than an instruction. Adds a one-sentence crate-level or module-level doc header to 44 source files that had none: seven production modules in the Lua crate and 37 test modules spread over the runtime, API types, model client, parser, runner, sessions, and web search crates. No code, signature, attribute, or test body changes; every source hunk is a comment line. - `crates/promptforge/lua/src/vm.rs`, `hardening.rs`, `host.rs`, `handles.rs`, `program.rs`, `scope.rs`, and `sys.rs` each gain a `//!` header naming the module's role: section VM lifecycle, sandbox hardening, host callbacks, tool bindings, compiled chunks, per-VM tool scope, and the sealed `sys` table. - `crates/harness/sessions/src/transition.rs` and `crates/harness/webfetch/src/error.rs` reword enum variant summaries on `SupervisorEffect`, `HistoryEffect`, and `Disposition` from commands to descriptions, so variant docs read as what selecting the variant does. - `crates/promptforge-api-runtime/src/execute/scheduler/step.rs` rewords the `Advance` variant summaries the same way; `subst.rs`, `execute/tests.rs`, and `model-client/src/client/wire.rs` reword free-function and constructor summaries. - `crates/promptforge-api-types/src/cancel-tests.rs` and the eight sibling `*-tests.rs` and `tests.rs` files in that crate gain headers; the same applies to ten runtime test modules, four model-client test modules, five Lua test modules, eight harness test modules, and the parser `tests.rs`. Plan: vibe/2026-09-20-2-rust-rulebook-sweep.md --- crates/harness/capabilities/src/tool.rs | 2 +- crates/harness/models/src/transport.rs | 8 ++++---- crates/harness/runner/src/cancel-tests.rs | 2 ++ crates/harness/runner/src/display_chain-tests.rs | 2 ++ crates/harness/runner/src/spawn.rs | 8 ++++---- crates/harness/sessions/src/discovery-tests.rs | 2 ++ crates/harness/sessions/src/environment-tests.rs | 2 ++ crates/harness/sessions/src/input-tests.rs | 2 ++ crates/harness/sessions/src/protocol.rs | 2 +- crates/harness/sessions/src/session/run-tests.rs | 2 ++ crates/harness/sessions/src/transition-tests.rs | 2 ++ crates/harness/sessions/src/transition.rs | 12 ++++++------ crates/harness/web-search/src/web_search-tests.rs | 2 ++ crates/harness/web-search/src/web_search.rs | 6 +++--- crates/harness/webfetch/src/error.rs | 4 ++-- crates/promptforge-api-runtime/src/error.rs | 2 +- .../src/execute/context-tests.rs | 2 ++ .../promptforge-api-runtime/src/execute/context.rs | 2 +- .../src/execute/scheduler/step.rs | 6 +++--- .../src/execute/section_vm.rs | 2 +- crates/promptforge-api-runtime/src/execute/tests.rs | 8 ++++---- .../src/execute/tests/debug_and_counts.rs | 2 ++ .../src/execute/tests/exec_flow.rs | 2 ++ .../src/execute/tests/live_infer.rs | 2 ++ .../src/execute/tests/model_and_reply.rs | 4 +++- .../src/execute/tests/observations.rs | 2 ++ .../src/execute/tests/tool_scoping.rs | 2 ++ crates/promptforge-api-runtime/src/fanout-tests.rs | 2 ++ crates/promptforge-api-runtime/src/model/tests.rs | 2 ++ crates/promptforge-api-runtime/src/subst.rs | 6 +++--- .../src/test_support/recording-forward-tests.rs | 2 ++ crates/promptforge-api-types/src/cancel-tests.rs | 2 ++ crates/promptforge-api-types/src/emitter-tests.rs | 2 ++ crates/promptforge-api-types/src/event-tests.rs | 2 ++ crates/promptforge-api-types/src/ids-tests.rs | 2 ++ crates/promptforge-api-types/src/names-tests.rs | 2 ++ crates/promptforge-api-types/src/replay-tests.rs | 2 ++ crates/promptforge-api-types/src/timestamp-tests.rs | 2 ++ crates/promptforge-api-types/src/tools/tests.rs | 2 ++ crates/promptforge/lua/src/compactors-tests.rs | 2 ++ crates/promptforge/lua/src/error.rs | 4 ++-- crates/promptforge/lua/src/handles.rs | 2 ++ crates/promptforge/lua/src/hardening.rs | 8 +++++--- crates/promptforge/lua/src/host.rs | 4 +++- crates/promptforge/lua/src/messages-tests.rs | 2 ++ crates/promptforge/lua/src/models-tests.rs | 2 ++ crates/promptforge/lua/src/program.rs | 2 ++ crates/promptforge/lua/src/projection-tests.rs | 2 ++ crates/promptforge/lua/src/scope.rs | 2 ++ crates/promptforge/lua/src/sys.rs | 2 ++ crates/promptforge/lua/src/tests.rs | 4 +++- crates/promptforge/lua/src/tools/tests.rs | 2 ++ crates/promptforge/lua/src/vm.rs | 4 +++- .../model-client/src/client/read-tests.rs | 2 ++ .../model-client/src/client/stream-tests.rs | 2 ++ crates/promptforge/model-client/src/client/tests.rs | 2 ++ crates/promptforge/model-client/src/client/wire.rs | 8 ++++---- crates/promptforge/model-client/src/error.rs | 2 +- crates/promptforge/model-client/src/model/tests.rs | 2 ++ crates/promptforge/model-client/src/normalize.rs | 4 ++-- crates/promptforge/parser/src/build.rs | 6 +++--- crates/promptforge/parser/src/error.rs | 2 +- crates/promptforge/parser/src/list.rs | 2 +- crates/promptforge/parser/src/parse.rs | 2 +- crates/promptforge/parser/src/tests.rs | 2 ++ vibe/2026-09-20-2-rust-rulebook-sweep.md | 2 +- 66 files changed, 145 insertions(+), 57 deletions(-) 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/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/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 index 0767a2b90..c4041d1f6 100644 --- a/crates/harness/runner/src/display_chain-tests.rs +++ b/crates/harness/runner/src/display_chain-tests.rs @@ -1,3 +1,5 @@ +//! Tests for `display_chain` rendering of an error and its causes. + use super::display_chain; /// A leaf cause with its own text. diff --git a/crates/harness/runner/src/spawn.rs b/crates/harness/runner/src/spawn.rs index fd81cf4ac..811386164 100644 --- a/crates/harness/runner/src/spawn.rs +++ b/crates/harness/runner/src/spawn.rs @@ -20,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`. @@ -49,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 @@ -73,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 @@ -106,7 +106,7 @@ where }) } -/// Run `f`, a launch's filesystem work, on tokio's blocking pool inside +/// 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 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/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/protocol.rs b/crates/harness/sessions/src/protocol.rs index 5d63a5c4b..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()) diff --git a/crates/harness/sessions/src/session/run-tests.rs b/crates/harness/sessions/src/session/run-tests.rs index 5d8531f42..828d3fc81 100644 --- a/crates/harness/sessions/src/session/run-tests.rs +++ b/crates/harness/sessions/src/session/run-tests.rs @@ -1,3 +1,5 @@ +//! Tests that run failures pushed to the client carry their cause chain. + use std::io; use std::path::PathBuf; 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/src/web_search-tests.rs b/crates/harness/web-search/src/web_search-tests.rs index 371c3acb4..3dcf6bd2f 100644 --- a/crates/harness/web-search/src/web_search-tests.rs +++ b/crates/harness/web-search/src/web_search-tests.rs @@ -1,3 +1,5 @@ +//! Tests for the `WebSearch` tool: descriptor, argument validation, transport, and body bounds. + use super::{ MAX_COUNT, MAX_DOMAINS, MAX_ERROR_BODY, MAX_QUERY_LEN, MAX_RESPONSE_BODY, MAX_STRING_LEN, WebSearch, diff --git a/crates/harness/web-search/src/web_search.rs b/crates/harness/web-search/src/web_search.rs index d795d65fe..8fc00ebcf 100644 --- a/crates/harness/web-search/src/web_search.rs +++ b/crates/harness/web-search/src/web_search.rs @@ -81,7 +81,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 +110,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 @@ -200,7 +200,7 @@ struct SearchRequest { /// Only keep results from these hostnames. #[serde(default, skip_serializing_if = "Option::is_none")] include_domains: Option>, - /// Drop results from these hostnames. + /// Drops results from these hostnames. #[serde(default, skip_serializing_if = "Option::is_none")] exclude_domains: Option>, } diff --git a/crates/harness/webfetch/src/error.rs b/crates/harness/webfetch/src/error.rs index 6a83bfde4..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), } diff --git a/crates/promptforge-api-runtime/src/error.rs b/crates/promptforge-api-runtime/src/error.rs index 5860839d5..11c6cc569 100644 --- a/crates/promptforge-api-runtime/src/error.rs +++ b/crates/promptforge-api-runtime/src/error.rs @@ -497,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/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/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/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.rs b/crates/promptforge-api-runtime/src/execute/tests.rs index 84f836159..b3ea6613e 100644 --- a/crates/promptforge-api-runtime/src/execute/tests.rs +++ b/crates/promptforge-api-runtime/src/execute/tests.rs @@ -121,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), @@ -324,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 { @@ -545,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()); @@ -565,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 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/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/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/fanout-tests.rs b/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/model/tests.rs b/crates/promptforge-api-runtime/src/model/tests.rs index a84b12657..2295ff231 100644 --- a/crates/promptforge-api-runtime/src/model/tests.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}; 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..1428dd336 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,3 +1,5 @@ +//! Tests for the recording emitter's forwarding of event groups to their seams. + use std::sync::Mutex; use promptforge_api_types::ids::{ChainId, Provenance, TaskId}; 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 30ee9097c..8419af65d 100644 --- a/crates/promptforge-api-types/src/emitter-tests.rs +++ b/crates/promptforge-api-types/src/emitter-tests.rs @@ -1,3 +1,5 @@ +//! Tests for `Emitter` sequencing, lifecycle reports, and payload events. + use std::sync::Arc; use super::{DebugMode, Emitter, EventSink}; 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/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/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/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/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/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 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/program.rs b/crates/promptforge/lua/src/program.rs index abfce855a..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. 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/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.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/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 fa121f531..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, @@ -1273,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/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 index a87e4efd6..6897dcf3b 100644 --- a/crates/promptforge/parser/src/error.rs +++ b/crates/promptforge/parser/src/error.rs @@ -189,7 +189,7 @@ struct Classification { column: Option, } -/// Classify a substrate error into its stable kind and location fields. +/// 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, 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 index 0a9bc7350..4dbeb5841 100644 --- a/crates/promptforge/parser/src/parse.rs +++ b/crates/promptforge/parser/src/parse.rs @@ -11,7 +11,7 @@ use crate::fence::{exact_shared_openings, split_h1}; use crate::{Error, ParseError, ParseErrorKind, Prompt, Result}; impl Prompt { - /// Parse a prompt file's full source text into a [`Prompt`], returning + /// 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 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/vibe/2026-09-20-2-rust-rulebook-sweep.md b/vibe/2026-09-20-2-rust-rulebook-sweep.md index e4e541782..960eed419 100644 --- a/vibe/2026-09-20-2-rust-rulebook-sweep.md +++ b/vibe/2026-09-20-2-rust-rulebook-sweep.md @@ -523,7 +523,7 @@ Commit: one commit. -### Step 12: Documentation prose - promptforge and harness families +### Step 12: Documentation prose - promptforge and harness families [completed] - Component: `docs-prose` - Piece: promptforge and harness crates (D7, group 2) From e0c343493a2ea5654fd3c0a9edffd7efd8217d9f Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Sun, 20 Sep 2026 11:24:49 -0700 Subject: [PATCH 13/39] Reword doc summaries and add //! lines in build and workshop Doc comment summary lines in the build and workshop crates now use third-person indicative verbs, so a function's first sentence describes what it does instead of instructing the reader. Every test module file in the workshop crates that lacked a leading module doc line gains one sentence naming what the file covers. The workshop shell binary's crate-level docs now open the file, ahead of the release-build subsystem attribute and its comment. Nothing compiled changes. - `crates/workshop/shell/src/main.rs`: the `#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]` line and its four-line comment now follow the `//!` crate docs instead of preceding them. Both are inner attributes, so the reorder leaves the compiled crate unchanged. - `crates/build-user-guide/src/main.rs` and the 13 touched `crates/build-xtask/src/` files: 37 `///` summary lines switch from imperative to third-person indicative (`Run` -> `Runs`, `Check` -> `Checks`); the wording after the verb is untouched. - `crates/workshop/workspace/src/workspace_file-actor.rs`: the seven `Command` variant docs get the same verb change (`Read` -> `Reads`, `Insert or replace` -> `Inserts or replaces`). - `crates/build-llama-cuda/src/bundle.rs` and `crates/build-ui/src/lib.rs`: one field doc each (`smoke`, `define_app_version`) gets the verb change. - `//!` first lines land on 27 test files across `crates/workshop/gateway`, `crates/workshop/menu`, `crates/workshop/server-api`, `crates/workshop/server`, `crates/workshop/status`, and `crates/workshop/workspace`, including the two `tests/it/heartbeat_loop` integration files. Deferred: The 11 test files under `crates/workshop/server/tests/it/chat_gate/` and `crates/workshop/server/tests/it/realtime_relay/` keep no `//!` line because `include!` splices them into a parent module that already owns the module docs. Plan: vibe/2026-09-20-2-rust-rulebook-sweep.md --- crates/build-llama-cuda/src/bundle.rs | 2 +- crates/build-ui/src/lib.rs | 2 +- crates/build-user-guide/src/main.rs | 20 +++++++++---------- crates/build-xtask/src/engine_deps-tests.rs | 2 +- crates/build-xtask/src/engine_deps.rs | 2 +- crates/build-xtask/src/engine_guards-tests.rs | 2 +- crates/build-xtask/src/engine_guards.rs | 4 ++-- crates/build-xtask/src/harness_bans-tests.rs | 2 +- crates/build-xtask/src/harness_bans.rs | 2 +- .../build-xtask/src/product-test-support.rs | 2 +- crates/build-xtask/src/product.rs | 8 ++++---- .../build-xtask/src/retired_symbols-tests.rs | 2 +- crates/build-xtask/src/retired_symbols.rs | 6 +++--- .../src/test_support_leak-tests.rs | 2 +- crates/build-xtask/src/test_support_leak.rs | 8 ++++---- crates/build-xtask/src/tidy.rs | 12 +++++------ .../gateway/src/gateway_binding/tests.rs | 2 ++ .../src/gateway_binding/tests/atomic.rs | 2 ++ .../src/gateway_binding/tests/publication.rs | 2 ++ .../src/gateway_binding/tests/shutdown.rs | 2 ++ .../src/gateway_progress-tests-lifecycle.rs | 2 ++ .../src/gateway_progress-tests-recovery.rs | 2 ++ .../gateway/src/gateway_progress-tests.rs | 3 +++ crates/workshop/gateway/src/observer-tests.rs | 2 ++ crates/workshop/gateway/src/resolve-tests.rs | 2 ++ crates/workshop/menu/src/catalog-tests.rs | 2 ++ crates/workshop/menu/src/menu-tests.rs | 2 ++ crates/workshop/server-api/src/lib-tests.rs | 2 ++ .../server/src/agents/bindings-tests.rs | 2 ++ .../workshop/server/src/agents/relay-tests.rs | 2 ++ .../server/src/agents/status-tests.rs | 2 ++ crates/workshop/server/src/app-tests.rs | 2 ++ .../server/src/routes/assets-tests.rs | 2 ++ .../routes/gateway_config-tests-recovery.rs | 2 ++ .../server/src/routes/gateway_config-tests.rs | 2 ++ crates/workshop/server/src/serve-tests.rs | 2 ++ .../tests/it/heartbeat_loop/recovery.rs | 2 ++ .../it/heartbeat_loop/startup_convergence.rs | 2 ++ crates/workshop/shell/src/main.rs | 12 +++++------ crates/workshop/status/src/progress-tests.rs | 2 ++ crates/workshop/workspace/src/error-tests.rs | 2 ++ .../workshop/workspace/src/handlers-tests.rs | 2 ++ .../workspace/src/workspace-file-tests.rs | 2 ++ .../workshop/workspace/src/workspace-tests.rs | 2 ++ .../workspace/src/workspace_file-actor.rs | 14 ++++++------- vibe/2026-09-20-2-rust-rulebook-sweep.md | 2 +- 46 files changed, 108 insertions(+), 53 deletions(-) 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-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..54a3593a5 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); 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.rs b/crates/build-xtask/src/product.rs index 1ca6c964e..1fca059c8 100644 --- a/crates/build-xtask/src/product.rs +++ b/crates/build-xtask/src/product.rs @@ -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 @@ -77,7 +77,7 @@ struct CrateInfo { deps: Vec, } -/// Check every workspace manifest against the product-boundary matrix. +/// Checks every workspace manifest against the product-boundary matrix. #[must_use] pub(crate) fn product_boundary_violations(root: &Path) -> Vec { let (crates, mut violations) = workspace_crates(root); @@ -257,7 +257,7 @@ fn workspace_crates(root: &Path) -> (Vec, Vec) { (crates, violations) } -/// 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) { let entries = match fs::read_dir(dir) { @@ -293,7 +293,7 @@ fn walk_crates(root: &Path, dir: &Path, crates: &mut Vec, violations: } } -/// Read one crate's manifest into `crates`; failures land in `violations`. +/// Reads one crate's manifest into `crates`; failures land in `violations`. fn read_crate(root: &Path, dir: &Path, crates: &mut Vec, violations: &mut Vec) { let manifest_path = dir.join("Cargo.toml"); let text = match fs::read_to_string(&manifest_path) { 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.rs b/crates/build-xtask/src/tidy.rs index 521c2f8ed..2720717ad 100644 --- a/crates/build-xtask/src/tidy.rs +++ b/crates/build-xtask/src/tidy.rs @@ -33,7 +33,7 @@ const MAX_FILE_LINES: usize = 500; /// outside those families are left alone. 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); @@ -76,7 +76,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 +120,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 +143,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 +166,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 { @@ -335,7 +335,7 @@ mod tests { } } - /// Write a crate under `crates//` with the given `lib.rs` docs and + /// Writes 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"); 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-tests-lifecycle.rs b/crates/workshop/gateway/src/gateway_progress-tests-lifecycle.rs index 8540f71a9..64f8b03e7 100644 --- a/crates/workshop/gateway/src/gateway_progress-tests-lifecycle.rs +++ b/crates/workshop/gateway/src/gateway_progress-tests-lifecycle.rs @@ -1,3 +1,5 @@ +//! Progress subscription lifecycle: a multi-stage operation detaches only when it finishes. + use super::*; fn operation_event_json(operation: u64, path: &str, state: &serde_json::Value) -> String { diff --git a/crates/workshop/gateway/src/gateway_progress-tests-recovery.rs b/crates/workshop/gateway/src/gateway_progress-tests-recovery.rs index 751c1936e..c52f24f25 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] diff --git a/crates/workshop/gateway/src/gateway_progress-tests.rs b/crates/workshop/gateway/src/gateway_progress-tests.rs index a41c445cf..c704039cc 100644 --- a/crates/workshop/gateway/src/gateway_progress-tests.rs +++ b/crates/workshop/gateway/src/gateway_progress-tests.rs @@ -1,3 +1,6 @@ +//! Gateway progress feed tests: events land on the hub, malformed events are skipped, and +//! the subscriber resubscribes without duplicating state. + // 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")] 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/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/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/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/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/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/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/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/src/progress-tests.rs b/crates/workshop/status/src/progress-tests.rs index 3f65bbd91..823fd0ac2 100644 --- a/crates/workshop/status/src/progress-tests.rs +++ b/crates/workshop/status/src/progress-tests.rs @@ -1,3 +1,5 @@ +//! Progress-to-status-bar tests: show delay, minimum visible hold, and detach polling. + use super::*; use tokio::sync::broadcast; diff --git a/crates/workshop/workspace/src/error-tests.rs b/crates/workshop/workspace/src/error-tests.rs index 7d897eb12..7629774ff 100644 --- a/crates/workshop/workspace/src/error-tests.rs +++ b/crates/workshop/workspace/src/error-tests.rs @@ -1,3 +1,5 @@ +//! Workspace error tests: wire-code mapping, the JSON envelope, and debug-only source chains. + use super::*; /// Collects a response body already buffered in memory. diff --git a/crates/workshop/workspace/src/handlers-tests.rs b/crates/workshop/workspace/src/handlers-tests.rs index 050d531bc..854707565 100644 --- a/crates/workshop/workspace/src/handlers-tests.rs +++ b/crates/workshop/workspace/src/handlers-tests.rs @@ -1,3 +1,5 @@ +//! Workspace handler tests: percent-decoding before validation, traversal refusals, and revokes. + use super::*; use axum::body::Body; diff --git a/crates/workshop/workspace/src/workspace-file-tests.rs b/crates/workshop/workspace/src/workspace-file-tests.rs index 7080dece5..1a83eb312 100644 --- a/crates/workshop/workspace/src/workspace-file-tests.rs +++ b/crates/workshop/workspace/src/workspace-file-tests.rs @@ -1,3 +1,5 @@ +//! Workspace file tests: database open and create, schema refusals, and half-written cleanup. + use std::fs; use std::path::PathBuf; diff --git a/crates/workshop/workspace/src/workspace-tests.rs b/crates/workshop/workspace/src/workspace-tests.rs index 177ac4307..9d97d05d9 100644 --- a/crates/workshop/workspace/src/workspace-tests.rs +++ b/crates/workshop/workspace/src/workspace-tests.rs @@ -1,3 +1,5 @@ +//! Workspace tests: grants, confinement, tokens, tree listings, and revocation. + use super::*; #[path = "workspace-tests-backing.rs"] diff --git a/crates/workshop/workspace/src/workspace_file-actor.rs b/crates/workshop/workspace/src/workspace_file-actor.rs index 77b099008..fc51bd34c 100644 --- a/crates/workshop/workspace/src/workspace_file-actor.rs +++ b/crates/workshop/workspace/src/workspace_file-actor.rs @@ -55,33 +55,33 @@ pub(crate) const COMMAND_QUEUE_DEPTH: usize = 32; /// actor ignores. #[derive(Debug)] pub(crate) enum Command { - /// Read everything the file holds. + /// Reads everything the file holds. Contents { /// Where the contents go. reply: oneshot::Sender>, }, - /// Insert or replace one grant; the file assigns its `position`. + /// Inserts or replaces one grant; the file assigns its `position`. AddGrant { /// The grant to persist. row: GrantRow, /// Fires once the row is written. reply: Ack, }, - /// Delete the grant at `path`; the survivors keep their positions. + /// Deletes the grant at `path`; the survivors keep their positions. RemoveGrant { /// The canonical root to forget. path: PathBuf, /// Fires once the row is gone. reply: Ack, }, - /// Insert or replace the saved window geometry. + /// Inserts or replaces the saved window geometry. PutWindowState { /// The geometry to persist. state: WindowState, /// Fires once the value is written. reply: Ack, }, - /// Insert or replace one opaque ui-state value. The key is already + /// Inserts or replaces one opaque ui-state value. The key is already /// allow-listed and the text already validated by the handle. PutUiState { /// The allow-listed kv key. @@ -91,7 +91,7 @@ pub(crate) enum Command { /// Fires once the row is written. reply: Ack, }, - /// Fold the WAL into the main file, then copy the main file to + /// Folds the WAL into the main file, then copies the main file to /// `destination`, all on the actor so no write lands in between and /// no checkpoint runs during the copy. Snapshot { @@ -100,7 +100,7 @@ pub(crate) enum Command { /// Fires once the copy is on disk. reply: Ack, }, - /// Stop the loop and close the connection; the reply fires once the + /// Stops the loop and closes the connection; the reply fires once the /// connection is dropped, so a caller can reopen the file safely. Shutdown { /// Fires after the connection closes. diff --git a/vibe/2026-09-20-2-rust-rulebook-sweep.md b/vibe/2026-09-20-2-rust-rulebook-sweep.md index 960eed419..5ad00119d 100644 --- a/vibe/2026-09-20-2-rust-rulebook-sweep.md +++ b/vibe/2026-09-20-2-rust-rulebook-sweep.md @@ -543,7 +543,7 @@ Commit: one commit. -### Step 13: Documentation prose - workshop, shared, build; full verification +### Step 13: Documentation prose - workshop, shared, build; full verification [completed] - Component: `docs-prose` - Piece: remaining crates (D7, group 3) and the F12 gate From 7d9fb911a690cfd61de1a6c4645f309329dcfd02 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Sun, 20 Sep 2026 11:45:15 -0700 Subject: [PATCH 14/39] Close plan: rust rulebook sweep Plan: vibe/2026-09-20-2-rust-rulebook-sweep.md --- vibe/ACTIVE | 1 - 1 file changed, 1 deletion(-) delete mode 100644 vibe/ACTIVE diff --git a/vibe/ACTIVE b/vibe/ACTIVE deleted file mode 100644 index 1d82d94af..000000000 --- a/vibe/ACTIVE +++ /dev/null @@ -1 +0,0 @@ -vibe/2026-09-20-2-rust-rulebook-sweep.md \ No newline at end of file From db0f37558bfdbff59191ba3b5dc5ad904cadaad5 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Sun, 20 Sep 2026 10:16:40 -0700 Subject: [PATCH 15/39] Rename gateway-api to gateway-api-types and add Progress Rename the gateway's public vocabulary crate so its name states what it holds, and add a progress snapshot to it: a busy flag and a producer-owned text, with no fractions, weights, or hierarchy on the wire. Every gateway crate that named the old crate now names the new one, and the boundary checker's public pair follows. The metadata-driven dependency boundary test and its CI step are removed, since the build task's checker already enforces the same matrix. - `Progress` is a two-field struct with public `busy` and `text` fields, deriving `Default`, `PartialEq`, `Eq`, `Serialize`, and `Deserialize`. It has no schema version; the doc commits future fields to `#[serde(default)]` so a lagging reader survives them. - `PUBLIC_GATEWAY` in `crates/build-xtask/src/product.rs` becomes `["gateway-api-types", "gateway-api-discovery"]`, and its violation message names the new crate; this matrix is the one left standing once the duplicate test is gone. - `crates/gateway/stt/api/tests/it/architecture.rs` is deleted along with its `mod architecture;` line and the `cargo test -p gateway-stt --test it architecture` CI step. Its `PRODUCT_DEPENDENCY_RULES` restated the family rules the build task already checks. - `progress-tests.rs` asserts the busy snapshot round-trips through JSON byte-for-byte and that the default serializes as `{"busy":false,"text":""}`. - `gateway-api-types` depends on `serde`, `serde_json`, `time`, and `workspace-hack` only; no other workspace crate enters its dependency list. Design: new surface-growth @ crates/gateway-api-types/src/progress.rs::Progress boundary: wire Design: new shotgun-surgery @ crates/gateway-api-types Design: removes parallel-abstraction @ crates/gateway/stt/api/tests/it/architecture.rs Plan: vibe/2026-09-20-2-gateway-api-types-progress.md --- .github/workflows/ci.yml | 3 - Cargo.lock | 22 +- Cargo.toml | 2 +- crates/build-xtask/src/engine_guards-tests.rs | 2 +- crates/build-xtask/src/product-tests.rs | 10 +- crates/build-xtask/src/product.rs | 6 +- .../Cargo.toml | 4 +- .../src/lib.rs | 10 +- .../src/metadata.rs | 16 +- .../gateway-api-types/src/progress-tests.rs | 25 + crates/gateway-api-types/src/progress.rs | 31 ++ crates/gateway/app/Cargo.toml | 4 +- crates/gateway/app/src/cloud_models.rs | 4 +- .../src/cloud_models/tests-version-gate.rs | 2 +- crates/gateway/app/src/cloud_models/tests.rs | 2 +- crates/gateway/app/tests/it/cloud_models.rs | 2 +- crates/gateway/cloud-providers/Cargo.toml | 2 +- crates/gateway/cloud-providers/src/lib.rs | 4 +- crates/gateway/cloud-providers/src/main.rs | 2 +- .../src/providers/anthropic-taxonomy.rs | 4 +- .../src/providers/anthropic.rs | 2 +- .../src/providers/azure_speech.rs | 9 +- .../cloud-providers/src/providers/baidu.rs | 4 +- .../src/providers/bedrock-taxonomy.rs | 4 +- .../cloud-providers/src/providers/bedrock.rs | 14 +- .../cloud-providers/src/providers/cohere.rs | 4 +- .../cloud-providers/src/providers/deepgram.rs | 4 +- .../cloud-providers/src/providers/deepseek.rs | 2 +- .../src/providers/elevenlabs.rs | 4 +- .../src/providers/foundry-taxonomy.rs | 2 +- .../cloud-providers/src/providers/foundry.rs | 4 +- .../cloud-providers/src/providers/gemini.rs | 2 +- .../cloud-providers/src/providers/groq.rs | 4 +- .../cloud-providers/src/providers/leonardo.rs | 4 +- .../cloud-providers/src/providers/meta.rs | 2 +- .../cloud-providers/src/providers/minimax.rs | 6 +- .../src/providers/mistral-taxonomy.rs | 4 +- .../cloud-providers/src/providers/mistral.rs | 40 +- .../cloud-providers/src/providers/moonshot.rs | 18 +- .../cloud-providers/src/providers/nvidia.rs | 4 +- .../cloud-providers/src/providers/openai.rs | 2 +- .../src/providers/openai_shape.rs | 2 +- .../src/providers/openrouter-taxonomy.rs | 4 +- .../src/providers/openrouter.rs | 4 +- .../cloud-providers/src/providers/qwen.rs | 33 +- .../cloud-providers/src/providers/soniox.rs | 4 +- .../cloud-providers/src/providers/stepfun.rs | 4 +- .../cloud-providers/src/providers/xai.rs | 2 +- crates/gateway/cloud-providers/src/sheet.rs | 6 +- .../gateway/cloud-providers/src/taxonomy.rs | 4 +- .../cloud-providers/tests/sheet_binary.rs | 2 +- crates/gateway/config/Cargo.toml | 2 +- crates/gateway/config/src/config.rs | 4 +- .../config/src/config/tests/serialize.rs | 2 +- crates/gateway/protocol/Cargo.toml | 2 +- crates/gateway/protocol/src/wire.rs | 4 +- .../gateway/stt/api/tests/it/architecture.rs | 331 ------------ crates/gateway/stt/api/tests/it/main.rs | 2 - ...2026-09-20-2-gateway-api-types-progress.md | 484 ++++++++++++++++++ vibe/ACTIVE | 1 + 60 files changed, 712 insertions(+), 481 deletions(-) rename crates/{gateway-api => gateway-api-types}/Cargo.toml (74%) rename crates/{gateway-api => gateway-api-types}/src/lib.rs (97%) rename crates/{gateway-api => gateway-api-types}/src/metadata.rs (94%) create mode 100644 crates/gateway-api-types/src/progress-tests.rs create mode 100644 crates/gateway-api-types/src/progress.rs delete mode 100644 crates/gateway/stt/api/tests/it/architecture.rs create mode 100644 vibe/2026-09-20-2-gateway-api-types-progress.md create mode 100644 vibe/ACTIVE 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/Cargo.lock b/Cargo.lock index 5b85a232c..edce77d61 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1957,8 +1957,8 @@ dependencies = [ "embed-resource", "futures-util", "gateway", - "gateway-api", "gateway-api-discovery", + "gateway-api-types", "gateway-config", "gateway-config-ui", "gateway-local", @@ -2003,25 +2003,25 @@ dependencies = [ ] [[package]] -name = "gateway-api" +name = "gateway-api-discovery" version = "0.3.0" dependencies = [ + "libc", "serde", "serde_json", - "time", + "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,7 +2031,7 @@ version = "0.3.0" dependencies = [ "dotenvy", "futures-util", - "gateway-api", + "gateway-api-types", "hmac", "reqwest", "serde", @@ -2047,7 +2047,7 @@ dependencies = [ name = "gateway-config" version = "0.3.0" dependencies = [ - "gateway-api", + "gateway-api-types", "reqwest", "serde", "serde_json", @@ -2117,7 +2117,7 @@ dependencies = [ "async-trait", "bytes", "futures-util", - "gateway-api", + "gateway-api-types", "gateway-config", "reqwest", "serde", diff --git a/Cargo.toml b/Cargo.toml index e47abfa42..85b8abc27 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,7 +27,7 @@ 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" } diff --git a/crates/build-xtask/src/engine_guards-tests.rs b/crates/build-xtask/src/engine_guards-tests.rs index 54a3593a5..44f2b84b6 100644 --- a/crates/build-xtask/src/engine_guards-tests.rs +++ b/crates/build-xtask/src/engine_guards-tests.rs @@ -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/product-tests.rs b/crates/build-xtask/src/product-tests.rs index 0bcc51889..5c283db83 100644 --- a/crates/build-xtask/src/product-tests.rs +++ b/crates/build-xtask/src/product-tests.rs @@ -252,10 +252,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 +278,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 +331,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 1fca059c8..4864759e0 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 @@ -107,7 +107,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 +171,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, 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 97% rename from crates/gateway-api/src/lib.rs rename to crates/gateway-api-types/src/lib.rs index 85931f541..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. 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/app/Cargo.toml b/crates/gateway/app/Cargo.toml index 66cda05ab..5b60a83d2 100644 --- a/crates/gateway/app/Cargo.toml +++ b/crates/gateway/app/Cargo.toml @@ -52,10 +52,10 @@ 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 diff --git a/crates/gateway/app/src/cloud_models.rs b/crates/gateway/app/src/cloud_models.rs index 9e372d16c..92a190cfc 100644 --- a/crates/gateway/app/src/cloud_models.rs +++ b/crates/gateway/app/src/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 @@ -28,7 +28,7 @@ use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; use axum::Json; use axum::extract::State; use axum::response::{IntoResponse, Response}; -use gateway_api::{ACCEPTED_SHEET_SCHEMA_VERSION, Sheet}; +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; diff --git a/crates/gateway/app/src/cloud_models/tests-version-gate.rs b/crates/gateway/app/src/cloud_models/tests-version-gate.rs index 0506e1939..b0f4e4562 100644 --- a/crates/gateway/app/src/cloud_models/tests-version-gate.rs +++ b/crates/gateway/app/src/cloud_models/tests-version-gate.rs @@ -4,7 +4,7 @@ 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::*; diff --git a/crates/gateway/app/src/cloud_models/tests.rs b/crates/gateway/app/src/cloud_models/tests.rs index 66b5fde75..3f06fe4a0 100644 --- a/crates/gateway/app/src/cloud_models/tests.rs +++ b/crates/gateway/app/src/cloud_models/tests.rs @@ -9,7 +9,7 @@ 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; 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/cloud-providers/Cargo.toml b/crates/gateway/cloud-providers/Cargo.toml index 9db7e8bbd..0d3886d57 100644 --- a/crates/gateway/cloud-providers/Cargo.toml +++ b/crates/gateway/cloud-providers/Cargo.toml @@ -27,7 +27,7 @@ reqwest.workspace = true serde.workspace = true serde_json.workspace = true 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 9bfb8cc62..67420aedf 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; @@ -197,7 +197,7 @@ 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, Provider, fetch_models, providers}; diff --git a/crates/gateway/cloud-providers/src/main.rs b/crates/gateway/cloud-providers/src/main.rs index 99c5e5f9b..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"; diff --git a/crates/gateway/cloud-providers/src/providers/anthropic-taxonomy.rs b/crates/gateway/cloud-providers/src/providers/anthropic-taxonomy.rs index a95add7e4..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}; @@ -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 c5fc7e150..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}; diff --git a/crates/gateway/cloud-providers/src/providers/azure_speech.rs b/crates/gateway/cloud-providers/src/providers/azure_speech.rs index 399fe9f8b..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}; @@ -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 f2fc0711f..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}; @@ -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-taxonomy.rs b/crates/gateway/cloud-providers/src/providers/bedrock-taxonomy.rs index 049333427..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. @@ -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 058c82542..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; @@ -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 3f0418c25..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; @@ -389,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 3a064cd4f..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; @@ -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 46142600a..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}; diff --git a/crates/gateway/cloud-providers/src/providers/elevenlabs.rs b/crates/gateway/cloud-providers/src/providers/elevenlabs.rs index 55ec55f06..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; @@ -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 e8358a110..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 diff --git a/crates/gateway/cloud-providers/src/providers/foundry.rs b/crates/gateway/cloud-providers/src/providers/foundry.rs index 362bade78..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}; @@ -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 7cb2d2a50..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}; diff --git a/crates/gateway/cloud-providers/src/providers/groq.rs b/crates/gateway/cloud-providers/src/providers/groq.rs index ab0464d72..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}; @@ -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 33a6b33a9..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; @@ -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 fb73e50f6..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}; diff --git a/crates/gateway/cloud-providers/src/providers/minimax.rs b/crates/gateway/cloud-providers/src/providers/minimax.rs index 17d3c5141..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}; @@ -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 6e39cc820..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}; @@ -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 04d8631af..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}; @@ -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 765f77121..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}; @@ -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 1a0ef8403..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}; @@ -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 d08c6abf7..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}; diff --git a/crates/gateway/cloud-providers/src/providers/openai_shape.rs b/crates/gateway/cloud-providers/src/providers/openai_shape.rs index 028cb80b1..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; diff --git a/crates/gateway/cloud-providers/src/providers/openrouter-taxonomy.rs b/crates/gateway/cloud-providers/src/providers/openrouter-taxonomy.rs index 5b09e4247..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}; @@ -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 4bd315604..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}; @@ -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 91d61b42d..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}; @@ -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 174ac0434..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; @@ -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 fc4c1b6e9..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}; @@ -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 dd93f0899..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}; diff --git a/crates/gateway/cloud-providers/src/sheet.rs b/crates/gateway/cloud-providers/src/sheet.rs index 54efd4d4b..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; @@ -209,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::*; @@ -295,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" ); } diff --git a/crates/gateway/cloud-providers/src/taxonomy.rs b/crates/gateway/cloud-providers/src/taxonomy.rs index 88849c727..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. @@ -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 4a127ae33..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. 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/config.rs b/crates/gateway/config/src/config.rs index 01e42587f..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, diff --git a/crates/gateway/config/src/config/tests/serialize.rs b/crates/gateway/config/src/config/tests/serialize.rs index 662a330e5..0f784c5d1 100644 --- a/crates/gateway/config/src/config/tests/serialize.rs +++ b/crates/gateway/config/src/config/tests/serialize.rs @@ -239,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/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/wire.rs b/crates/gateway/protocol/src/wire.rs index 67df9e63d..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)] @@ -592,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/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/vibe/2026-09-20-2-gateway-api-types-progress.md b/vibe/2026-09-20-2-gateway-api-types-progress.md new file mode 100644 index 000000000..60ebf59aa --- /dev/null +++ b/vibe/2026-09-20-2-gateway-api-types-progress.md @@ -0,0 +1,484 @@ +--- +name: gateway-api-types extraction +overview: Rename gateway-api to gateway-api-types; collapse progress to a busy flag plus producer-owned text; migrate every gateway producer; replace the determinate progress bar with a 144px barberpole left of the LEDs in both UIs; move the slimmed machinery into the gateway family as private gateway-progress; remove the redundant boundary matrix; make the Invariants marker mandatory for workshop-* and harness-* crates. +todos: + - id: types-crate + content: Rename gateway-api to gateway-api-types, add Progress, repoint dependents, update PUBLIC_GATEWAY, delete architecture.rs and its CI step + status: pending + - id: shared-ui-barberpole + content: shared-ui status bar barberpole (144px, left of LEDs), setBusy(bool), tokens + status: pending + - id: gateway-progress-rewrite + content: Rewrite shared-progress to ProgressHub::begin/Activity::set_text over watch; migrate every gateway producer; SSE, /admin/status, tray, config-ui; delete render.rs + status: pending + - id: workshop-busy + content: workshop-gateway decodes Progress and pushes busy/idle; StatusBarUpdate.busy; push_busy; remove hub, renderer, ProgressMeter use; SPA setBusy + status: pending + - id: move-and-docs + content: Move shared-progress to crates/gateway/progress as gateway-progress; AGENTS.md, README, harness Invariants, crate AGENTS.md trims + status: pending + - id: mandatory-marker + content: tidy.rs selects workshop-*/harness-* by name and requires the marker; mark and split harness web crates; scope the 500-line sentence + status: pending +isProject: false +--- + +# Gateway API Types, Busy-Text Progress, and the Barberpole + + + +## Product Requirements + +The PromptForge workspace has two products, the Gateway and the Workshop, that should share only their protocol. Today they share a progress-reporting library with weighted operation trees that no consumer needs, the Gateway's public types crate has no external consumers, the status bar hides its LEDs whenever anything is busy, two mechanisms enforce one dependency matrix, and the top-level policy file describes rules the tree no longer matches. This plan reduces the shared surface to two public crates (types and discovery), collapses progress to a busy flag plus a text, replaces the determinate progress bar with an indeterminate barberpole in both UIs, and brings enforcement and policy back into agreement. + +- Problem and users: maintainers of the `promptforge` repository. The Gateway (`crates/gateway/`) and Workshop (`crates/workshop/`) families are coupled through `crates/shared-progress`, whose hub, weighted tree, handles, remote importer, and meter are used on both sides (`crates/shared-progress/src/{hub,tree,handle,remote,render}.rs`). The public crate `crates/gateway-api` is imported by no crate outside the gateway family (no `gateway-api` dependency in any `crates/harness*/**/Cargo.toml` or `crates/workshop/**/Cargo.toml`). The status bar shell swaps its LED group out for the progress bar (`crates/shared-ui/status-bar.ts` `renderSlot`), so live LEDs disappear while work runs. +- Goals: + - Exactly two public gateway crates: `gateway-api-types` (wire vocabulary) and `gateway-api-discovery` (unchanged). Everything else under `crates/gateway/` is private. + - Progress is one primitive: a producer begins an activity with a text, may replace the text, and ends it. No fractions, weights, leaves, or hierarchy anywhere in the machinery or on the wire. + - Both status bars (Workshop SPA and Gateway config UI) show the activity text and an indeterminate barberpole, 144px wide, placed left of the LED indicators, which are never hidden. + - The progress machinery lives inside the gateway family as private `gateway-progress`; the Workshop depends on the wire type only. + - One dependency-matrix mechanism (`crates/build-xtask/src/product.rs`); the redundant `crates/gateway/stt/api/tests/it/architecture.rs` and its CI step are removed. + - The `//! ## Invariants` marker is mandatory for every `workshop-*` and `harness-*` crate (Tauri shell `workshop` exempt), enforced by `crates/build-xtask/src/tidy.rs`. + - Policy text (`AGENTS.md`, `crates/README.md`, crate-level `AGENTS.md`) matches the tree. +- Non-goals: + - A shared typed HTTP client for the gateway (a client is code; each consumer keeps its own). + - Moving the gateway app's admin and cache wire types into `gateway-api-types`. + - Splitting `crates/shared-loopback`. + - Changing the `gateway-protocol` dependency on `gateway-config`, splitting the gateway app crate, reshaping `crates/gateway/stt/`, or renaming the `shared-cloud-providers` binary. + - Letting `promptforge-*` crates depend on `gateway-api-types`. + - A flat-directory structural check or a `harness-*` scaffold in `new_crate`. +- Success criteria: + - `cargo test -p build-xtask` passes with `PUBLIC_GATEWAY = ["gateway-api-types", "gateway-api-discovery"]` and the name-based marker rule. + - No crate outside `crates/gateway/` depends on `gateway-progress`; no crate anywhere depends on `shared-progress` or `gateway-api`. + - `GET /admin/progress` streams `{"busy":bool,"text":string}` snapshots; `GET /admin/status` reports a top-level `progress` object and no `fraction`. + - The Workshop status frame has `busy: bool` and no `progress` object; the barberpole shows while busy and the LEDs stay visible throughout. + - The full verification gate set passes once, on the final step. +- Constraints: + - Dependency direction rules of the repository hold for every dependency kind (normal, dev, build, target): promptforge-* never depends on gateway/workshop/harness; gateway-* never depends on promptforge/workshop/harness; workshop-* may reach gateway crates only through the public pair; harness-* may reach gateway crates only through the public pair; family containers are private (`crates/build-xtask/src/product.rs`). + - No file in a crate that carries the Invariants marker may exceed 500 lines (`crates/build-xtask/src/tidy.rs`, `MAX_FILE_LINES = 500`). + - Source directories are flat: a `src/` subdirectory needs three or more files; one or two files sit beside the parent as `parent-label.rs` with `#[path]`. + - No new structural enforcement beyond the approved marker rule. + - Component CSS uses tokens, no raw colors or sizes (`crates/shared-ui/tokens.css`). + - Activity text is user-visible and crosses the wire; it must never contain a bearer key, credential, or other secret. + - Tests stay fast and light during execution: each step runs only its focused tests; the full gate set runs once at the end. +- Open questions: None + +## Functional Specification + +Gateway producers report a single live activity text. The gateway publishes the newest live text as a busy/text snapshot to its SSE stream, its status endpoint, and its tray label. The Workshop decodes the snapshot and pushes a busy frame to its status bar; the config UI polls the status endpoint. Both status bars render the text and a barberpole while busy and return the LED group to rest when idle. + +- Actors and workflows: + - Producer (gateway internals: `gateway-local`, `gateway-stt`, `gateway-stt-backend-whisper`, the `gateway` app): calls `hub.begin(text)` to get an `Activity` guard, optionally `activity.set_text(text)` as work proceeds, and drops the guard when done. A download loop formats its own percent into the text on each whole-percent change (`"Downloading qwen3-8b.gguf 45%"`). The producer logs its own `started` and `finished` lines with `tracing::info!` and failures with `tracing::warn!` or `tracing::error!`; no per-percent log lines. + - Gateway SSE (`crates/gateway/app/src/admin/progress.rs`): on subscribe, sends the current snapshot first, then one event per change, plus the existing heartbeat comments. + - Gateway status (`crates/gateway/app/src/admin/status.rs`): adds a top-level `progress` object with the current snapshot; the queue's `active` entry keeps `name` and loses `fraction`. + - Gateway tray (`crates/gateway/app/src/tray/logic.rs` `status_label`): `"Running - {text}"` while busy; the existing model summary when idle. + - Workshop consumer (`crates/workshop/gateway/src/gateway_progress.rs`): decodes each snapshot from the SSE stream, applies the show-delay and minimum-visible policy, and calls `push_busy(text, ...)` or `push_idle()`. + - Config UI (`crates/gateway/config-ui/ui/src/components/status-bar.ts`): on each status poll, `setBusy(progress.busy)` and the text from `progress.text` while busy; existing model summary when idle. + - Enforcement (`crates/build-xtask`): `cargo test -p build-xtask` fails when a `workshop-*` or `harness-*` crate other than `workshop` lacks the marker, or when a marked crate has a file over 500 lines. +- Inputs and outputs: + - Wire type (public, `crates/gateway-api-types/src/progress.rs`): `Progress { busy: bool, text: String }`, serde JSON `{"busy":true,"text":"Downloading qwen3-8b.gguf 45%"}`. `Default` is idle with empty text. + - `GET /admin/progress`: `text/event-stream`, each `data:` line one `Progress` JSON. + - `GET /admin/status`: existing document plus `"progress": {"busy":..,"text":..}`; `active.fraction` removed. + - Workshop `StatusBarUpdate` / `StatusFrame`: `busy: bool` replaces `progress: Option`; `workshop_protocol::Progress` is deleted. + - `Push::push_busy(label, description, activity)` replaces `Push::push_progress(label, description, current, total, activity)` (`crates/workshop/registry/src/push.rs`). + - `POST /v1/cache` SSE download stream: unchanged; it continues to report raw byte counts (`crates/gateway/app/src/cache.rs` `ChannelProgress`). +- States and validation: + - Hub state: an ordered list of live activities `(id, text)` in begin order. Published snapshot: `busy = !list.is_empty()`, `text = last live text` or empty. When the newest ends, the next most recent shows. + - Workshop anti-flicker: the barberpole appears only after an activity has been busy for 1s (`SHOW_DELAY`), stays at least 500ms once shown (`MIN_VISIBLE`); these constants move from `crates/workshop/status/src/progress.rs` to the workshop-gateway consumer. + - Barberpole: hidden when idle (`display: none`), so the LEDs shift left by 144px plus the group gap when it appears; the LEDs are never given `hidden`. + - Reduced motion: the barberpole animation stops and renders static stripes under `prefers-reduced-motion: reduce`. +- Errors and recovery: + - Malformed SSE payload: the Workshop decoder keeps its existing `GatewayError::Malformed` path (`crates/workshop/gateway/src/gateway/progress.rs`). + - Producer failure: the activity guard drops on every exit path (RAII), so a failed operation never leaves the bar busy; the producer logs the failure. + - Version skew: a Workshop attaching to an already-running older Gateway (the tray keeps the gateway alive across Workshop restarts) will fail to decode the old event shape until the Gateway restarts. Accepted; noted for the release. +- Security and privacy behavior: activity text is displayed in the Workshop status bar, the config UI, the tray, and the gateway log; producers must never include a bearer key, API key, or other credential in it. The bearer-key secrecy invariants of `workshop-gateway` and `harness-models` remain unchanged. +- Acceptance criteria: + - Beginning an activity publishes `busy: true` with its text; dropping the last guard publishes `busy: false`; a nested begin shows the newest text and falls back on drop; `set_text` republishes. + - The Workshop status bar shows the barberpole and text after 1s of busy, keeps it at least 500ms, and never hides the LED group. + - The config UI shows the barberpole and text while `progress.busy`. + - The tray label reads `"Running - {text}"` while busy. + - No file under `crates/gateway/local/src/artifacts/` computes a fraction for the hub; the download loop formats its percent into text. + - `cargo test -p build-xtask` fails on a fixture `harness-*` crate without the marker and passes on the `workshop` shell without one. + + + + +## Technical Design + +Two public crates remain at the workspace root for the gateway family. `gateway-api-types` (renamed from `gateway-api`) holds the provider sheet schema, the model vocabulary, and the new `Progress` wire type; it depends on `serde`, `time`, and no workspace crate. `shared-progress` is rewritten in place to about 150 lines around a `watch` channel, then moved into `crates/gateway/progress` as private `gateway-progress`. The Workshop drops every dependency on the machinery and decodes the wire type alone. The shared status bar shell replaces its `` element with a barberpole that sits beside, not over, the LED group. + +- Architecture: + +```mermaid +flowchart LR + Prod[producers] -->|"begin/set_text"| Hub[ProgressHub] + Hub -->|watch| SSE["/admin/progress"] + Hub -->|watch| Status["/admin/status"] + Hub -->|watch| Tray[tray label] + Prod -->|tracing| Log[log] + SSE --> WS[workshop] + Status --> CUI[config-ui] + WS --> Bar1[barberpole] + CUI --> Bar2[barberpole] +``` + + - Public gateway crates: `crates/gateway-api-types` and `crates/gateway-api-discovery`. Private container `crates/gateway/` gains `progress/`. `PUBLIC_GATEWAY` in `crates/build-xtask/src/product.rs` becomes `["gateway-api-types", "gateway-api-discovery"]`; container privacy makes `gateway-progress` private with no further code. + - Progress and logging are separate channels. The hub feeds UIs only. The hub-to-tracing bridge `crates/gateway/app/src/render.rs` is deleted; producers log directly. +- Modules and interfaces: + +```rust +// crates/gateway-api-types/src/progress.rs (public wire type) +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct Progress { pub busy: bool, pub text: String } + +// crates/gateway/progress/src/lib.rs (private machinery, package gateway-progress) +pub struct ProgressHub { /* Mutex> in begin order + watch::Sender */ } +impl ProgressHub { + pub fn new() -> Self; + pub fn begin(&self, text: impl Into) -> Activity; + pub fn current(&self) -> Progress; + pub fn subscribe(&self) -> tokio::sync::watch::Receiver; +} +pub struct Activity { /* Arc, id */ } +impl Activity { pub fn set_text(&self, text: impl Into); } +impl Drop for Activity { /* remove own entry, republish */ } +``` + + - `gateway-progress` depends on `gateway-api-types`, `tokio` (`sync` only), `workspace-hack`. The `time` and `tracing` dependencies and the `serde` feature of `shared-progress` are dropped. + - Shared status bar shell (`crates/shared-ui/status-bar.ts`): `renderSlot(SlotProgress | null)` becomes `setBusy(busy: boolean)`; the `progress` field and `SlotProgress` type are removed; a `barberpole` element is appended to `.status-bar__right` immediately before the indicators group; the indicators group never receives `hidden`. + - Workshop push facade (`crates/workshop/registry/src/push.rs`): `push_busy(label, description, activity)` replaces `push_progress`. `push_idle()` unchanged. + - Workshop protocol (`crates/workshop/protocol`): `StatusBarUpdate.busy: bool` replaces `progress: Option`; `Progress` deleted. SPA `StatusFrame` (`crates/workshop/ui/src/services/protocol.ts`) mirrors it. + - Enforcement (`crates/build-xtask/src/tidy.rs`): `participating_crates` selects the union of (a) every crate whose package name is `workshop-*` or `harness-*` or `harness-api`, excluding the package `workshop`, and (b) every crate carrying the marker (so `build-xtask` stays in deliberately). A crate in set (a) whose `src/lib.rs` lacks `//! ## Invariants` is a violation. +- File and public API changes: + +Before (only what changes): + +``` +crates/ + gateway-api/ public, retires + src/{lib.rs, metadata.rs} sheet schema + model vocabulary + shared-progress/ shared, retires + src/{lib,event,tree,handle,hub,remote,render}.rs ~1,200 lines + shared-ui/ + status-bar.{ts,css} slot swaps progress bar over the LEDs + tokens.css --progress-width: 96px + gateway/ + app/src/{commands,cache,boot_load,config_apply,runner}.rs tree producers + app/src/render.rs hub-to-tracing bridge + app/src/admin/{progress,status}.rs fraction on the wire + app/src/tray/logic.rs "Running - label (34%)" + local/src/artifacts/progress.rs byte-to-fraction adapter + local/src/{artifacts,runtime,server,cache}.rs tree producers + local/src/artifacts/{download,digest,archive,verified}.rs tree producers + stt/api/src/{artifacts,generation,service}.rs tree producers + stt/api/src/realtime/{session,route,registry}.rs tree producers + stt/api/tests/it/architecture.rs redundant boundary matrix + stt/backend-whisper/src/{model,config}.rs tree producers + config-ui/ui/src/components/status-bar.ts renders percent bar + workshop/ + protocol/ StatusBarUpdate.progress: Option + registry/src/push.rs push_progress(current, total) + server/src/app.rs creates the ProgressHub + server/src/agents/session-menu.rs push_step fractions + status/src/{progress,progress-tests,handles}.rs hub renderer, ProgressMeter + gateway/src/gateway_progress.rs RemoteOperation import + ui/src/parts/status/status-bar.ts renderSlot(frame.progress) + harness/webfetch/src/{tool,config}.rs 1,250 and 747 lines, unmarked crate + build-xtask/src/{product,tidy}.rs +.github/workflows/ci.yml "Check product dependency boundaries" step +AGENTS.md, crates/README.md, crates/gateway/README.md +``` + +After: + +``` +crates/ + gateway-api-types/ public, renamed + src/{lib.rs, metadata.rs} unchanged content + src/progress.rs Progress { busy, text } + gateway-api-discovery/ public, untouched + shared-ui/ + status-bar.{ts,css} barberpole left of LEDs; setBusy(bool) + tokens.css --progress-width: 144px + gateway/ + progress/ private, package gateway-progress + src/lib.rs ProgressHub, Activity (~150 lines) + app/src/... begin/set_text producers; own tracing lines + app/src/render.rs deleted + app/src/admin/{progress,status}.rs Progress snapshot; top-level progress + app/src/tray/logic.rs "Running - text" + local/src/artifacts/progress.rs deleted + local, stt/api, stt/backend-whisper begin/set_text producers + stt/api/tests/it/architecture.rs deleted + config-ui/ui/src/components/status-bar.ts text + setBusy + workshop/ + protocol/ StatusBarUpdate.busy: bool + registry/src/push.rs push_busy(label, description, activity) + server/src/app.rs no hub + server/src/agents/session-menu.rs push_busy at switch start + status/ progress.rs and progress-tests.rs deleted; no register_tasks + gateway/src/gateway_progress.rs decode Progress, anti-flicker, Push + ui/src/parts/status/status-bar.ts setBusy(frame.busy) + harness/{web,webfetch,web-search}/src/lib.rs ## Invariants added + harness/webfetch/src/ tool.rs and config.rs split under 500 lines + build-xtask/src/{product,tidy}.rs PUBLIC_GATEWAY renamed; name-based marker rule +.github/workflows/ci.yml boundary step removed +AGENTS.md, crates/README.md, crates/gateway/README.md updated +``` + + - Dependents of `gateway-api` to repoint to `gateway-api-types`: `crates/gateway/{config,protocol,cloud-providers,app}/Cargo.toml`; source `use gateway_api::` in `crates/gateway/config/src/config.rs`, `crates/gateway/protocol/src/wire.rs`, the sheet builder in `crates/gateway/cloud-providers/src/`, `crates/gateway/app/src/cloud_models.rs` and its `tests/`, `crates/gateway/app/tests/it/cloud_models.rs`. Root `Cargo.toml` `[workspace.dependencies]` entry renamed. + - `shared-progress` move: `git mv crates/shared-progress crates/gateway/progress`; package `gateway-progress`; root `Cargo.toml` `members` gains `"crates/gateway/progress"` (containers are excluded from the glob), `[workspace.dependencies]` swaps `shared-progress` for `gateway-progress`; `crates/gateway/{app,local,stt/api,stt/backend-whisper}/Cargo.toml` and `use shared_progress::` repointed. + - Barberpole CSS (`crates/shared-ui/status-bar.css`, `crates/shared-ui/tokens.css`): `--progress-width: 144px` (1.5in at CSS 96dpi, px so it composes with sibling px tokens); `--progress-height: 6px` and the `--progress-track`/`--progress-fill`/`--progress-glow` tokens reused with comments updated; `repeating-linear-gradient(-45deg, ...)` stripes with period `2 * --progress-height`, `background-size` twice the period, `@keyframes` sliding `background-position` one period per loop, linear infinite; `border-radius: calc(var(--progress-height) / 2)`, `overflow: hidden`; the slot's `min-width: var(--progress-width)` is removed. + - Policy text: `AGENTS.md` lines naming the public pair, the harness may-depend list, the public-surface summary, the `shared-progress` reporting rule, the `architecture` test clause, the SPA paths `ui/editor/` and `ui/agent/` (now `parts/editor/`, `parts/agent/`), the example `ui/agent/agent-session.css` (now `parts/agent/agent-session.css`), the SPA `index.ts` marker half of the Invariants sentence (dropped), and the "No file exceeds 500 lines" sentence (scoped to marked crates). Six harness `lib.rs` Invariants blocks (`crates/harness-api`, `crates/harness/{sessions,runner,models,log,capabilities}`) rename `gateway-api`. `crates/harness/{sessions,models}/AGENTS.md` and `crates/promptforge-api-runtime/AGENTS.md` lose prose duplicated from their `lib.rs` or the root file; "Core" becomes `promptforge-api-runtime` in `crates/promptforge/parser/AGENTS.md`, `crates/promptforge-api-runtime/AGENTS.md`, `crates/harness/webfetch/AGENTS.md`, `crates/harness/web-search/AGENTS.md`. `crates/README.md` and `crates/gateway/README.md` follow. Dated records under `vibe/` are not edited. +- Data, persistence, failure, security, and privacy constraints: + - `Progress` is additive-friendly: any future field carries `#[serde(default)]`; no schema version. + - The hub's `watch` channel keeps only the latest snapshot; there is no coalescer and no event replay beyond the current snapshot on subscribe. + - `Activity` is an RAII guard; every producer exit path ends its activity. + - No producer places credentials in activity text. + - `POST /v1/cache` keeps its own byte-count SSE protocol; `ChannelProgress` drops its tree leaf and additionally sets the activity text. + - Workspace lints apply: `unsafe_code = "forbid"`, clippy `all` and `pedantic` deny, `unwrap_used`/`expect_used` deny (root `Cargo.toml` `[workspace.lints]`). + + + + +## Testing Plan + +Each step runs only the focused tests of the crates it touches; the full gate set runs exactly once, on the final step. The behavior change is the progress rewrite; its net is new hub unit tests plus the ported producer, endpoint, tray, and workshop frame tests. Everything else is a move, rename, or deletion covered by existing tests. + +- Unit: + - `gateway-api-types`: `Progress` serde round trip and `Default`. + - `gateway-progress`: begin publishes busy with text; last drop publishes idle; nested begin shows the newest and falls back on drop; `set_text` republishes; `subscribe` receives each change. + - Gateway app: `admin/progress-tests.rs` asserts the snapshot-first stream; `admin/status-tests.rs` asserts the top-level `progress` object and absent `fraction`; `tray/logic.rs` tests assert `"Running - {text}"`; `cache.rs` tests assert byte counts still flow and the activity text is set. + - Producer crates (`gateway-local`, `gateway-stt`, `gateway-stt-backend-whisper`): existing tree-snapshot assertions ported to `hub.current()` / watch assertions; the download loop test asserts percent-in-text on whole-percent change. + - Workshop: `gateway_progress-tests*.rs` ported to pushed busy/idle frames with the anti-flicker timing; `protocol/tests/it/frames.rs`, `registry/src/push-tests.rs`, `registry/tests/it/main.rs` updated for `busy`. + - `build-xtask`: fixtures for the renamed `PUBLIC_GATEWAY`; a fixture `harness-*` crate without the marker fails; the `workshop` shell without one passes. + - shared-ui: `crates/workshop/ui/test/shared-status-bar.mjs` asserts `setBusy(true)` shows the barberpole and leaves the indicators visible; `setBusy(false)` hides it. +- Integration and end-to-end: + - Gateway app `tests/it/{progress,queue,boot}.rs` exercise the SSE stream, the queue status, and boot loading against the new shape. + - Workshop server `tests/it/session/status.rs` asserts `busy` frames on the `/ws` socket. + - Both UI test flows (`npm test` in `crates/workshop/ui` and `crates/gateway/config-ui/ui` where configured). +- Regression, security, and performance: + - No regression net exists for the deleted determinate bar or weights; their removal is the intent. + - Bearer-key secrecy tests in `workshop-gateway` and `harness-models` are unchanged and must keep passing. + - The hub's publish path is a `Mutex` plus `watch::send`; no benchmark is required. +- Exit criteria (final step only): `cargo fmt --all --check`; `cargo clippy --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-targets --all-features -- -D warnings` and `cargo clippy -p workshop -p workshop-server -p workshop-server-api --all-targets -- -D warnings`; `cargo nextest run --locked --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-features` and `cargo test --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-features --doc` and `cargo nextest run --locked -p workshop -p workshop-server -p workshop-server-api`; `RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps --all-features --exclude workshop --exclude workshop-server --exclude workshop-server-api`; `mdbook build guide`; `cargo check -p gateway --no-default-features`; `cargo test -p build-xtask` (`.github/workflows/ci.yml`). + + + + +## Decision Record + +- Decisions: + - Two public gateway crates, types and discovery; all machinery private. Rationale: the protocol is the only legitimate shared surface. User: "I want to have one or two public crates and everything else completely private"; "Types is not code." + - The public types crate is named `gateway-api-types`, parallel to `promptforge-api-types`. User: "gateway-api-types # types-only, no tokio". + - The `Sheet` schema and model vocabulary move with the rename; `gateway-api` retires. User: "yes move Sheet and related, and this will retire gateway-api?" + - Progress is a bool and a producer-owned string; the machinery never sees a number. User: "cut the producer down to a bool for on/off barberpole, and a string of text"; "we can just show a percentage at the end of the string no?" + - No weights, leaves, or hierarchy. User: "the weights were a dumb idea"; "does this get rid of progress owners having leaves and hierarchy and all that shit?" + - Indeterminate barberpole instead of a determinate bar; left of the LEDs; LEDs never hidden; 144px wide. User: "get rid of progress bar completely and just show a perpetually moving horizontal barberpole and it goes to the left of the LEDs, so the LEDs do not get hidden"; "make it about 1.5 inches wide". + - Progress and logging are separate; the hub-to-tracing bridge is deleted; producers log start, finish, and error only. User: "I don't see a point to logging a download beyond 'Download started' and 'Download finished' (or errored)." + - No `fraction` field kept on the wire "just in case"; additive fields are free later under `#[serde(default)]`. + - Concurrent activities: newest live text wins; fall back on drop. Overlap is narrow (the command queue serializes slow work in `crates/gateway/app/src/commands.rs`; only `POST /v1/cache` downloads run outside it). + - The redundant `architecture.rs` matrix and its CI step are removed; crate-level `AGENTS.md` prose duplicated from `lib.rs` Invariants or the root file is trimmed; "Core" is renamed. User: "I like the removals." + - One structural addition: the Invariants marker is mandatory by family name, with `build-xtask` kept under the ceiling deliberately. User approved a small addition budget: "you pick, up to 80 tokens worth of additions." + - `/admin/status` gains a top-level `progress` object; `active.fraction` is removed; `active.name` is kept. + - The Workshop anti-flicker policy (1s show delay, 500ms minimum visible) moves to the workshop-gateway consumer. + - The SPA `index.ts` marker sentence in `AGENTS.md` is dropped rather than enforced. + - Tests stay focused per step; full gates once at the end. User: "keep tests fast and light until the end." +- Rejected alternatives: + - Merge everything into one `gateway` crate for language-level privacy: compile-time regression on a 35k-line app crate; the xtask matrix already gives policy-level privacy. Revisit if the matrix proves insufficient. + - A shared typed gateway client (`gateway-api-client`): a client is code, and code is the coupling being removed; `/v1/chat/completions` is OpenAI's protocol, so reimplementation is the price of compatibility. Revisit if a third in-process consumer of the admin protocol appears. + - Sever the consumer half of `shared-progress` into the Workshop and keep the tree machinery: the hub would have to be duplicated on both sides, and the semantics are the subtle part. Superseded by removing the machinery entirely. + - Keep weights but ignore them in the Workshop: leaves a large producer surface for one internal log renderer. Superseded. + - Keep the hub-to-tracing bridge with 5% cadence: solves a problem the bridge itself created. Superseded. + - Keep `fraction: Option` on `Progress`: an unread field is a promise with no consumer; adding it later is one line. + - A flat-directory structural check and a `harness-*` scaffold in `new_crate`: outside the approved addition budget. + - Removing `retired_symbols`: a documented permanent guard; left in place. +- Assumptions, risks, and notes: + - Producer migration touches roughly 150 call sites across `gateway-local`, `gateway-stt`, `gateway-stt-backend-whisper`, and the gateway app (count from a grep for `.leaf(`, `.register(`, `.child(`, `set_fraction`, `set_units`, `.complete(`, `.fail(`, `ProgressHandle`, `ProgressTree`); mostly deletions plus one `begin` per operation. Each site is read, not mechanically replaced, so producers that used `fail()` as a signal get an explicit `tracing` line. + - A missed `tracing::info!` at a former renderer-logged site makes the log quieter; the review checks each producer for start and finish lines. + - Wire skew between an older running Gateway and a newer Workshop is accepted and noted for release. + - Splitting `crates/harness/webfetch/src/tool.rs` (1,250 lines) and `config.rs` (747 lines) is a behavior-preserving refactor; existing tests are the net. + - `.github/workflows/ci.yml` runs `cargo test -p gateway-stt --test it architecture` at a dedicated step and `cargo nextest run --locked -p gateway-api-discovery ...` at two places; only the former is removed. + - `harness-models` documents that harness crates may depend on `gateway-api`; no harness crate does. The permission is renamed, not exercised. + +### Deferred and Out of Scope + +- Deferred: splitting `crates/shared-loopback` per family; revisit when either product needs a loopback rule the other does not. +- Deferred: moving admin and cache wire types (`/admin/status`, `/v1/cache` events) into `gateway-api-types`; revisit when a second consumer needs them typed. +- Deferred: the flat-directory check (two violations today: `crates/harness/sessions/src/session/` with two files; `crates/promptforge-api-runtime/src/fanout/` with `mod.rs` and `tests.rs`); revisit with the next approved enforcement budget. +- Out of scope: `gateway-api-client`; the `gateway-protocol` dependency on `gateway-config`; splitting the gateway app crate; reshaping `crates/gateway/stt/`; renaming the `shared-cloud-providers` binary; allowing `promptforge-*` to depend on `gateway-api-types`; routing `POST /v1/cache` downloads through the command queue; a `(+N more)` suffix for overlapping activities. + + + + +## Project Survey + +- Status: complete +- Build command: `cargo build --locked -p gateway` (workspace default-members is `crates/gateway/app` only; desktop app is `cargo build --locked -p workshop`; headless gate `cargo check -p gateway --no-default-features`). +- Focused test command pattern: `cargo nextest run --locked -p ` (nextest does not run doctests; use `cargo test --locked -p ` for a single named test or doctest; integration tests use `--test it `). +- Component test command pattern: `cargo nextest run --locked -p --all-features` for non-workshop crates; workshop crates as `cargo nextest run --locked -p workshop -p workshop-server -p workshop-server-api`, plus `cargo nextest run --locked -p workshop-server --features headless`. +- Full-suite test command: `cargo nextest run --locked --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-features` then `cargo test --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-features --doc`; workshop crates separately as above, plus `cargo test --doc -p workshop -p workshop-server -p workshop-server-api`. Structural harness: `cargo test -p build-xtask` (the `gateway-stt` `architecture` integration test exists at survey time and is deleted by Step 1; do not run it). +- Linter command: `cargo clippy --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-targets --all-features -- -D warnings`; workshop: `cargo clippy -p workshop -p workshop-server -p workshop-server-api --all-targets -- -D warnings`. Never run a standalone `cargo check --workspace` beside clippy. Supply chain: `cargo deny check`, `cargo audit`. +- Formatter check command: `cargo fmt --all --check` (pre-commit hook runs it; `rustfmt.toml` sets `style_edition = "2024"`). +- Docs command: `RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps --all-features --exclude workshop --exclude workshop-server --exclude workshop-server-api`; user guide `mdbook build guide`. Rustdoc lints `broken_intra_doc_links` and `private_intra_doc_links` are `deny`. +- Test placement and naming conventions: unit tests live in a sibling file `-tests.rs` wired from the parent with `#[cfg(test)] #[path = "-tests.rs"] mod tests;` (92 such files), or in a `tests/` subdirectory of the module when three or more files exist; integration tests are a single Cargo target `tests/it/main.rs` with one module per concern (`tests/it/.rs`), invoked as `--test it`; benches under `benches/`. Test names are long snake_case sentences (e.g. `a_direct_launch_recovers_the_lease_from_a_terminated_owner`). `clippy.toml` allows `unwrap`/`expect` in tests only. Nextest config in `.config/nextest.toml` (60s slow-timeout, `heavy` test group for the STT crates). UI tests: `npm test` in `crates/workshop/ui` and `crates/gateway/config-ui/ui` (also `npm run typecheck`, `npm run build`). +- Directory map: `Cargo.toml` (workspace, resolver 3, edition 2024, version 0.3.0, shared `[workspace.lints]` with clippy `all` and `pedantic` deny, `unsafe_code = "forbid"`, `unwrap_used`/`expect_used` deny); `crates/` root is the public and shared layer: `gateway-api`, `gateway-api-discovery`, `promptforge-api-runtime`, `promptforge-api-types`, `harness-api`, `shared-loopback`, `shared-progress`, `shared-vfs`, `workspace-hack` (cargo-hakari, `.config/hakari.toml`), `build-*` meta tooling (`build-xtask`, `build-workshop`, `build-ui`, `build-user-guide`, `build-llama-cuda`), and `shared-ui` (TypeScript+CSS, not a Rust crate); manifestless family containers `crates/promptforge/` (lua, parser, store, vfs, model-client), `crates/gateway/` (app, cloud-providers, config, config-ui, local, logging, protocol, routing, web-search, `stt/` with api, engine, backend-whisper, whisper-ffi), `crates/workshop/` (shell = package `workshop`, server, server-api, gateway, menu, protocol, registry, status, support, user-state, workspace, `ui/`), `crates/harness/` (runner, models, capabilities, log, sessions, web, webfetch, web-search); `guide/` (mdbook sources and per-product guide exports); `prompts/` (sample `.md` prompt pipelines); `tools/` (Node `.mjs` scripts: `stage-gateway-sidecar.mjs`, `gateway-tts-live.mjs`, with `.test.mjs` siblings); `vibe/archdoc.md` (architecture doc); `.github/workflows/ci.yml` and siblings (CI); `.githooks/` (pre-commit fmt, pre-push headless check, clippy, deny); `.cargo/config.toml` (aliases `cargo xtask`, `cargo workshop`; Windows `rust-lld` and static CRT); `rust-toolchain.toml` (stable); `deny.toml`, `dist-workspace.toml`, `gateway.local.example.toml`; `AGENTS.md` (repo policy) and `crates/README.md` (crate catalogue). +- Component boundaries: dependencies flow one way, shell -> features -> services -> vocabulary; `shared-*` crates depend on no product crates (`shared-vfs` is std-only). PromptForge is one door: outside crates depend only on `promptforge-api-runtime` and `promptforge-api-types`; `promptforge-api-runtime` is the only crate permitted into `crates/promptforge/`; promptforge-* never depends on gateway, workshop, or harness. Gateway's public pair is `gateway-api` and `gateway-api-discovery`; nothing outside the family depends into `crates/gateway/`; gateway-* never depends on promptforge or workshop; inside `crates/gateway/stt/` only `gateway-stt` is family-visible. Harness's one door is `harness-api`; harness-* may depend on `promptforge-api-runtime`, `promptforge-api-types`, `gateway-api`, `gateway-api-discovery`, and shared-*, never on workshop or private gateway crates; promptforge-* and gateway-* never depend on harness. Workshop-* never depends on gateway crates beyond the public pair, reaches harness only through `harness-api`, and the `workshop` shell depends on `workshop-server-api` never `workshop-server`. Composed rule: a crate inside a family container may depend only on `crates/` root crates and its own siblings; build-* crates are exempt. Rules bind normal, dev, build, and target-specific dependencies; `cargo test -p build-xtask` enforces the matrix. +- Conventions summary: Rust 2024 edition on stable, `--locked` everywhere; clippy `all` + `pedantic` at deny with `-D warnings`, `missing_docs` and `unreachable_pub` warn, `unsafe_code` forbidden except in explicitly owned boundaries with a safety comment before each block; no file exceeds 500 lines (split before editing); source directories flat by default, a subdirectory needs three or more files, otherwise sibling `foo-bar.rs` with `#[path]`; every workshop-* and harness-* `lib.rs` opens with a `//!` doc containing a `## Invariants` marker listing allowed dependencies; behavior changes ship with tests in the same change; no new structural enforcement (parsers, snapshots, allowlists, counts, ceilings) without explicit user approval; a Cargo feature gates a real constraint, not product shape; runtime and serve paths never compile native code or install process-global state, returning failures instead of exiting; long-running work reports via `shared-progress`; comments explain non-obvious constraints and cite upstream issue URLs for workarounds; error messages are concise and model-consumable naming required versus actual; workspace dependency versions live in `[workspace.dependencies]` with path crates at `version = "0.3.0"` (workshop sub-crates at `0.0.0`) and every member inherits `workspace-hack`; build steps never write into the repository (CI checks a clean tree); SPA: CSS beside TypeScript, `--ws-*` tokens only, no `localStorage`. + + + + +## Execution Instructions + +Objective: reduce the Gateway's shared surface to `gateway-api-types` and `gateway-api-discovery`, collapse progress to a busy flag plus producer-owned text rendered as a barberpole in both UIs, move the machinery into the private gateway family, and bring enforcement and policy text back into agreement with the tree. + +Component order and reasons: + +1. `gateway-api-types`: first, because every later step names `gateway_api_types::Progress`, and the boundary matrix must know the new public name before any dependent moves. +2. `shared-ui`: second, because both UI consumers (the Workshop SPA in component 3, the config UI in component 4) call `setBusy`; it depends on nothing else in this plan. +3. `workshop`: third, before the machinery rewrite, because `workshop-status`, `workshop-server`, and `workshop-gateway` import `shared_progress` tree, renderer, and `RemoteOperation` items that component 4 deletes; moving the Workshop to the wire type first keeps every commit compiling across the whole workspace. Between Steps 3 and 4 a Workshop attached to a Gateway that still emits the old event shape takes the decoder's existing `Malformed` path and the bar stays idle; this is the same runtime-only skew the decision record already accepts for release. +4. `gateway-progress`: fourth, once no crate outside the gateway family depends on `shared-progress`. Two sequential pieces: the in-place rewrite with every consumer (Step 4), then the move into `crates/gateway/progress` with the policy text that names it (Step 5). The move follows the Workshop change so no workshop crate ever depends into the container. +5. `enforcement`: last, independent of the progress work, sequenced after component 4 so `AGENTS.md` is edited by one step at a time. Two sequential pieces: the harness web crates are made compliant (Step 6) before the rule that demands compliance lands (Step 7), so `cargo test -p build-xtask` passes at every commit. The final step runs the full gate set. + +Piece construction: components 1, 2, and 3 are single pieces. In component 4, machinery and consumers (producers, endpoints, tray, config UI) are built jointly inside Step 4 because neither compiles without the other. Components 4 and 5 each have two sequential pieces, one step per piece. + +Each step runs only the focused tests listed in it. The full gate set runs once, in Step 7. Each step is one commit containing its code and tests. + + + +### Step 1: Rename the public types crate and add the Progress wire type [completed] + +- Component: gateway-api-types +- Piece: crate rename and wire type +- Depends on: none +- Artifacts: + - `git mv crates/gateway-api crates/gateway-api-types`; package name `gateway-api-types`; crate-level doc names the new crate; root `Cargo.toml` `[workspace.dependencies]` entry renamed (the members glob picks the directory up). + - New `crates/gateway-api-types/src/progress.rs`: `pub struct Progress { pub busy: bool, pub text: String }` deriving `Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize`; `pub mod progress;` and `pub use progress::Progress;` in `lib.rs`; the crate depends on `serde`, `time`, `workspace-hack`, and no other workspace crate. + - Repoint `crates/gateway/{config,protocol,cloud-providers,app}/Cargo.toml` and every `use gateway_api::` in `crates/gateway/config/src/config.rs`, `crates/gateway/protocol/src/wire.rs`, the sheet builder under `crates/gateway/cloud-providers/src/`, `crates/gateway/app/src/cloud_models.rs` and its tests, and `crates/gateway/app/tests/it/cloud_models.rs`. + - `crates/build-xtask/src/product.rs`: `PUBLIC_GATEWAY = ["gateway-api-types", "gateway-api-discovery"]`; fixtures and violation messages renamed. + - Delete `crates/gateway/stt/api/tests/it/architecture.rs` and its `mod architecture;` line in `crates/gateway/stt/api/tests/it/main.rs`; remove the "Check product dependency boundaries" step (`cargo test -p gateway-stt --test it architecture`) from `.github/workflows/ci.yml`; the two `gateway-api-discovery` nextest invocations stay. +- Tests: + - `crates/gateway-api-types/src/progress-tests.rs`: serde round trip of `{"busy":true,"text":"Downloading qwen3-8b.gguf 45%"}`; `Default` is `busy: false` with empty text. + - `cargo nextest run --locked -p gateway-api-types`; `cargo test -p build-xtask`; `cargo check -p gateway --all-targets`; `cargo check -p gateway-stt --tests`. +- Commit: `Rename gateway-api to gateway-api-types and add Progress` + + + + + +### Step 2: Barberpole in the shared status bar shell + +- Component: shared-ui +- Piece: status bar shell +- Depends on: none +- Artifacts: + - `crates/shared-ui/tokens.css`: `--progress-width: 144px` (comment: 1.5in at CSS 96dpi, px so it composes with sibling px tokens); `--progress-height: 6px`; `--progress-track`, `--progress-fill`, `--progress-glow` kept with comments updated for the barberpole. + - `crates/shared-ui/status-bar.css`: `.status-bar__barberpole` with `width: var(--progress-width)`, `height: var(--progress-height)`, `border-radius: calc(var(--progress-height) / 2)`, `overflow: hidden`, `repeating-linear-gradient(-45deg, ...)` stripes with period `calc(2 * var(--progress-height))`, `background-size` twice the period, `@keyframes` sliding `background-position` one period per loop, linear infinite; `display: none` when idle; static stripes with the animation stopped under `@media (prefers-reduced-motion: reduce)`; the slot's `min-width: var(--progress-width)` removed. + - `crates/shared-ui/status-bar.ts`: remove the `` element, the `progress` field, the `SlotProgress` type, and `renderSlot`; add `setBusy(busy: boolean)` toggling the barberpole element, which is appended to `.status-bar__right` immediately before the indicators group; the indicators group never receives `hidden`. + - Interim call sites so both UIs keep type-checking until their own steps: `crates/workshop/ui/src/parts/status/status-bar.ts` calls `setBusy(frame.progress !== null)`; `crates/gateway/config-ui/ui/src/components/status-bar.ts` calls `setBusy` from the presence of `active` in the current status document. Steps 3 and 4 replace these. +- Tests: + - `crates/workshop/ui/test/shared-status-bar.mjs`: `setBusy(true)` shows the barberpole and leaves the indicators visible; `setBusy(false)` hides it; the barberpole precedes the indicators group in DOM order. + - `npm test` and `npm run typecheck` in `crates/workshop/ui`; `npm test` and `npm run typecheck` in `crates/gateway/config-ui/ui` where configured. +- Commit: `Replace the status bar progress element with a barberpole` + + + + + +### Step 3: Workshop consumes the Progress wire type + +- Component: workshop +- Piece: Rust and TypeScript halves, one binary +- Depends on: Step 1 (`gateway_api_types::Progress`), Step 2 (`setBusy`) +- Artifacts: + - `crates/workshop/protocol`: `StatusBarUpdate.busy: bool` replaces `progress: Option`; `workshop_protocol::Progress` deleted. + - `crates/workshop/registry/src/push.rs`: `Push::push_busy(label, description, activity)` replaces `push_progress(label, description, current, total, activity)`; `push_idle()` unchanged. + - `crates/workshop/gateway/src/gateway_progress.rs`: decode each SSE `data:` line as `gateway_api_types::Progress`; `SHOW_DELAY = 1s` and `MIN_VISIBLE = 500ms` move here from `crates/workshop/status/src/progress.rs`; busy calls `push_busy` after the show delay, idle calls `push_idle` no sooner than the minimum visible; the `GatewayError::Malformed` path in `crates/workshop/gateway/src/gateway/progress.rs` kept; the `RemoteOperation` import removed; `crates/workshop/gateway/Cargo.toml` adds `gateway-api-types` and drops `shared-progress`. + - `crates/workshop/status`: delete `src/progress.rs`, `src/progress-tests.rs`, and `register_tasks`; remove the `ProgressMeter` use from `src/handles.rs`; drop `shared-progress` from `Cargo.toml`. + - `crates/workshop/server/src/app.rs`: no `ProgressHub`; `crates/workshop/server/src/agents/session-menu.rs`: `push_step` fractions become one `push_busy` at switch start; the switch's existing terminal `push_status_update` or `push_failure` (both `busy: false`) ends it, so no `push_idle` is added; `shared-progress` removed from every remaining workshop manifest. + - SPA: `crates/workshop/ui/src/services/protocol.ts` `StatusFrame.busy: boolean` with no `progress`; `crates/workshop/ui/src/parts/status/status-bar.ts` `setBusy(frame.busy)`. +- Tests: + - `crates/workshop/gateway/src/gateway_progress-tests*.rs` ported: a busy snapshot pushes after 1s, idle arriving within 500ms of showing is deferred, an idle snapshot before the show delay pushes nothing, a malformed payload errors; `crates/workshop/protocol/tests/it/frames.rs`, `crates/workshop/registry/src/push-tests.rs`, `crates/workshop/registry/tests/it/main.rs` updated for `busy`; `crates/workshop/server/tests/it/session/status.rs` asserts `busy` frames on `/ws`. + - `cargo nextest run --locked -p workshop-gateway -p workshop-protocol -p workshop-registry -p workshop-status`; `cargo nextest run --locked -p workshop-server --features headless`; `cargo test -p build-xtask`; `npm test` and `npm run typecheck` in `crates/workshop/ui`. +- Commit: `Move the Workshop status bar to the Progress wire type` + + + + + +### Step 4: Rewrite the progress machinery and migrate every gateway producer + +- Component: gateway-progress +- Piece: in-place rewrite; machinery (`ProgressHub`, `Activity`) and consumers (producers, endpoints, tray, config UI) built jointly +- Depends on: Step 1, Step 2, Step 3 (no workshop dependency on `shared-progress` remains, so the whole workspace compiles after this commit) +- Artifacts: + - `crates/shared-progress/src/lib.rs` rewritten to about 150 lines: `ProgressHub { inner: Arc }` with `Inner { live: Mutex>, next_id: AtomicU64, tx: watch::Sender }`; `new()`, `begin(text) -> Activity`, `current() -> Progress`, `subscribe() -> watch::Receiver`; `Activity { inner: Arc, id: u64 }` with `set_text` and a `Drop` that removes its entry and republishes; snapshot rule `busy = !live.is_empty()`, `text = last live text or ""`. Delete `event.rs`, `tree.rs`, `handle.rs`, `remote.rs`, `render.rs`. `Cargo.toml`: depends on `gateway-api-types`, `tokio` (`sync`), `workspace-hack`; `time`, `tracing`, and the `serde` feature dropped. + - Producers migrated to `hub.begin` / `activity.set_text` with RAII guards on every exit path and their own `tracing::info!` start and finish lines, `tracing::warn!`/`tracing::error!` on failure, no per-percent logs: `crates/gateway/app/src/{commands,cache,boot_load,config_apply,runner}.rs`; `crates/gateway/local/src/{artifacts,runtime,server,cache}.rs` and `artifacts/{download,digest,archive,verified}.rs`; `crates/gateway/stt/api/src/{artifacts,generation,service}.rs` and `realtime/{session,route,registry}.rs`; `crates/gateway/stt/backend-whisper/src/{model,config}.rs`. Sites to find: `.leaf(`, `.register(`, `.child(`, `set_fraction`, `set_units`, `.complete(`, `.fail(`, `ProgressHandle`, `ProgressTree`; each site is read, not mechanically replaced, and a former `fail()` signal becomes an explicit `tracing` line. No credential ever enters activity text. + - Download loop in `crates/gateway/local/src/artifacts/download.rs` formats `"Downloading {name} {pct}%"` into the text on each whole-percent change; delete `crates/gateway/local/src/artifacts/progress.rs` and `crates/gateway/app/src/render.rs`. + - `crates/gateway/app/src/admin/progress.rs`: subscribe, send the current snapshot first, then one `data:` line per change, heartbeat comments kept. `crates/gateway/app/src/admin/status.rs`: top-level `progress: Progress`; `active.fraction` removed, `active.name` kept. `crates/gateway/app/src/tray/logic.rs` `status_label`: `"Running - {text}"` while busy, the model summary when idle. `crates/gateway/app/src/cache.rs` `ChannelProgress`: byte counts unchanged on the `POST /v1/cache` stream, tree leaf removed, activity text set. + - `crates/gateway/config-ui/ui/src/components/status-bar.ts`: `setBusy(progress.busy)` and `progress.text` while busy, model summary when idle (replaces the Step 2 interim call); `gateway-api.ts` status type gains `progress: { busy: boolean; text: string }` and loses `active.fraction`. +- Tests: + - `crates/shared-progress/src/lib-tests.rs`: begin publishes busy with text; last drop publishes idle; nested begin shows the newest and falls back on drop; `set_text` republishes; `subscribe` receives each change. + - Gateway app: `admin/progress-tests.rs` asserts the snapshot-first stream; `admin/status-tests.rs` asserts the top-level `progress` object and absent `fraction`; `tray/logic` tests assert `"Running - {text}"`; `cache` tests assert byte counts still flow and the text is set; `tests/it/{progress,queue,boot}.rs` against the new shape. + - Producer crates: tree-snapshot assertions ported to `hub.current()` / watch assertions; the download loop test asserts percent-in-text on each whole-percent change. + - `cargo nextest run --locked -p shared-progress -p gateway -p gateway-local -p gateway-stt -p gateway-stt-backend-whisper --all-features`; `cargo check -p gateway --no-default-features`; `npm test` and `npm run typecheck` in `crates/gateway/config-ui/ui`. +- Commit: `Collapse progress to a busy flag and producer-owned text` + + + + + +### Step 5: Move the machinery into the gateway family and align policy text + +- Component: gateway-progress +- Piece: move and policy text +- Depends on: Step 4; Step 3 (no workshop crate depends into the container) +- Artifacts: + - `git mv crates/shared-progress crates/gateway/progress`; package `gateway-progress`; root `Cargo.toml` `members` gains `"crates/gateway/progress"` (containers are excluded from the glob); `[workspace.dependencies]` swaps `shared-progress` for `gateway-progress`; `crates/gateway/{app,local,stt/api,stt/backend-whisper}/Cargo.toml` and every `use shared_progress::` repointed to `gateway_progress`. + - `AGENTS.md`: public pair named `gateway-api-types` and `gateway-api-discovery`; harness may-depend list renamed; public-surface summary updated; the `shared-progress` reporting rule becomes `gateway-progress`, gateway family only; the `architecture` test clause removed; SPA paths `ui/editor/` and `ui/agent/` become `parts/editor/` and `parts/agent/`, the example becomes `parts/agent/agent-session.css`; the SPA `index.ts` marker half of the Invariants sentence dropped. The 500-line sentence is left for Step 7. + - Six harness Invariants blocks rename `gateway-api` to `gateway-api-types`: `crates/harness-api/src/lib.rs`, `crates/harness/{sessions,runner,models,log,capabilities}/src/lib.rs`. + - `crates/harness/{sessions,models}/AGENTS.md` and `crates/promptforge-api-runtime/AGENTS.md` lose prose duplicated from their `lib.rs` or the root file; "Core" becomes `promptforge-api-runtime` in `crates/promptforge/parser/AGENTS.md`, `crates/promptforge-api-runtime/AGENTS.md`, `crates/harness/webfetch/AGENTS.md`, `crates/harness/web-search/AGENTS.md`. + - `crates/README.md` and `crates/gateway/README.md` catalogue the renamed and moved crates. Dated records under `vibe/` are not edited. +- Tests: + - `cargo test -p build-xtask` (container privacy makes `gateway-progress` private with no new code); `cargo nextest run --locked -p gateway-progress`; `cargo check -p gateway --all-targets`; `cargo check -p gateway --no-default-features`. + - `rg -n 'shared[-_]progress' --glob '!vibe/**' --glob '!target/**'` and `rg -nP 'gateway[-_]api(?![-_a-z])' --glob '!vibe/**' --glob '!target/**'` return nothing outside the documented `retired_symbols` guard. +- Commit: `Move progress into the gateway family as gateway-progress` + + + + + +### Step 6: Mark the harness web crates and split webfetch under the ceiling + +- Component: enforcement +- Piece: crates made compliant, before the rule +- Depends on: Step 5 (harness Invariants blocks already name `gateway-api-types`; `AGENTS.md` is untouched here) +- Artifacts: + - `crates/harness/{web,webfetch,web-search}/src/lib.rs` open with a `//!` doc containing `## Invariants` listing allowed dependencies, in the form of the six existing harness blocks. + - `crates/harness/webfetch/src/tool.rs` (1,250 lines) and `config.rs` (747 lines) split into sibling files under 500 lines each, wired with `#[path]` per the flat-directory convention, behavior preserved; invariant A3 (redirect revalidation, non-global address denial, exact host-and-address exceptions) unchanged. +- Tests: + - Existing `harness-webfetch` tests are the net; `cargo nextest run --locked -p harness-webfetch -p harness-web -p harness-web-search --all-features`; `cargo test -p build-xtask` (the three crates now participate through the marker and pass the 500-line rule). +- Commit: `Mark harness web crates and split webfetch files under 500 lines` + + + + + +### Step 7: Make the Invariants marker mandatory by family name + +- Component: enforcement +- Piece: the rule and the final gate +- Depends on: Step 6 +- Artifacts: + - `crates/build-xtask/src/tidy.rs`: `participating_crates` returns the union of (a) every crate whose package name starts with `workshop-` or `harness-` or equals `harness-api`, excluding the package `workshop`, and (b) every crate carrying the marker (so `build-xtask` stays in deliberately); a crate in set (a) whose `src/lib.rs` lacks `//! ## Invariants` is a violation naming the crate and the missing marker. + - Fixtures: a `harness-*` crate without the marker fails; the `workshop` shell without one passes; a marked non-family crate still participates in the 500-line rule. + - `AGENTS.md`: the "No file exceeds 500 lines" sentence scoped to crates carrying the marker; the marker described as mandatory for `workshop-*` and `harness-*` (shell `workshop` exempt). +- Tests: + - `cargo test -p build-xtask`. + - Full gate set (Testing Plan exit criteria), run once here: `cargo fmt --all --check`; `cargo clippy --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-targets --all-features -- -D warnings`; `cargo clippy -p workshop -p workshop-server -p workshop-server-api --all-targets -- -D warnings`; `cargo nextest run --locked --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-features`; `cargo test --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-features --doc`; `cargo nextest run --locked -p workshop -p workshop-server -p workshop-server-api`; `RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps --all-features --exclude workshop --exclude workshop-server --exclude workshop-server-api`; `mdbook build guide`; `cargo check -p gateway --no-default-features`; `cargo test -p build-xtask`; `npm test` in `crates/workshop/ui` and `crates/gateway/config-ui/ui`. +- Commit: `Require the Invariants marker for workshop and harness crates` + + + + diff --git a/vibe/ACTIVE b/vibe/ACTIVE new file mode 100644 index 000000000..13e9adcc5 --- /dev/null +++ b/vibe/ACTIVE @@ -0,0 +1 @@ +vibe/2026-09-20-2-gateway-api-types-progress.md \ No newline at end of file From c232046572afa495fa40e4793a52189a8a577281 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Sun, 20 Sep 2026 10:34:28 -0700 Subject: [PATCH 16/39] Replace the status bar progress element with a barberpole The shared status bar shell no longer carries a determinate progress bar that displaces the LED indicators while work runs. In its place an animated indeterminate barberpole stands to the left of the indicators, shows while work is in flight, and hides otherwise; the LEDs stay visible the whole time. The shell's consumer API shrinks to a single busy toggle, so a fraction can no longer reach the bar. Both UIs derive that toggle from what their current status data already carries, and the tests that pinned the old swap are rewritten or replaced to pin the side-by-side layout. - `setBusy(busy: boolean)` replaces `renderSlot(progress: SlotProgress | null)` on `StatusBarShell`; the `SlotProgress` type and the `progress: HTMLProgressElement` field are removed, so a consumer can only signal in-flight or idle. - `.status-bar__barberpole` is a `span` with `role="progressbar"` and no `aria-valuenow`, appended to `.status-bar__right` immediately before the slot that holds the indicators; the `` element and its webkit pseudoelement rules are gone. - `--progress-width` grows from 96px to 144px and the slot's `min-width: var(--progress-width)` is removed, so the indicators group no longer reserves the bar's width. - `.status-bar__indicators` never receives `hidden`; `setBusy` flips `barberpole.hidden` alone and leaves the group and its LEDs untouched. - `@keyframes status-bar-barberpole` slides `background-position` one period per 0.8s loop over a `repeating-linear-gradient(-45deg, ...)` whose period is twice the bar height; `prefers-reduced-motion: reduce` stops the animation and leaves the stripes static. - `shell.setBusy(true)` in the gateway config UI fires on the presence of an active queue command while the text still carries the rounded percent; `this.shell.setBusy(frame.progress !== null)` in the workshop fires on a non-null progress frame. Both call sites are commented as interim. - `progress-swap-indicators.mjs` is deleted and `barberpole-beside-indicators.mjs` asserts the inverse contract: a progress frame leaves the recording and activity LED group and both LEDs visible, and the barberpole precedes the group in DOM order. - `shared-status-bar.mjs` asserts `typeof shell.renderSlot === "undefined"` and that no `progress` element remains, but no test covers the animation, the reduced-motion rule, or the 144px width. Design: replaces surface-growth @ crates/shared-ui/status-bar.ts::StatusBarShell.setBusy boundary: pub was: crates/shared-ui/status-bar.ts::StatusBarShell.renderSlot Plan: vibe/2026-09-20-2-gateway-api-types-progress.md --- .../ui/src/components/status-bar.test.mjs | 30 +++---- .../config-ui/ui/src/components/status-bar.ts | 26 +++--- crates/shared-ui/status-bar.css | 87 +++++++++++-------- crates/shared-ui/status-bar.ts | 75 +++++++--------- crates/shared-ui/tokens.css | 13 +-- .../ui/src/parts/status/status-bar.ts | 23 ++--- .../ui/test/barberpole-beside-indicators.mjs | 52 +++++++++++ crates/workshop/ui/test/helpers/boot.mjs | 4 +- .../ui/test/progress-swap-indicators.mjs | 40 --------- crates/workshop/ui/test/shared-status-bar.mjs | 64 ++++++++------ crates/workshop/ui/test/status-frames.mjs | 27 +++--- crates/workshop/ui/test/workbench-mount.mjs | 12 +-- ...2026-09-20-2-gateway-api-types-progress.md | 2 +- 13 files changed, 243 insertions(+), 212 deletions(-) create mode 100644 crates/workshop/ui/test/barberpole-beside-indicators.mjs delete mode 100644 crates/workshop/ui/test/progress-swap-indicators.mjs 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..ecb7ead07 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,10 @@ // 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; an active queue command shows the shared shell's barberpole +// beside the still-visible LEDs 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). import assert from "node:assert/strict"; import test from "node:test"; @@ -39,7 +39,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,13 +53,14 @@ 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.queue = { active: { name: "load-profile: main", fraction: 0.34, started_at: 1_700_000_000 }, @@ -67,15 +69,13 @@ test("an active command swaps the slot to the progress bar, and cancel calls the 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 a command runs"); assert.equal( root.querySelector(".status-bar__text").textContent, "load-profile: main (34%)", "the text carries the command name and rounded percent", ); - 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 +97,12 @@ 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.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 queue drains"); }); 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..498102d20 100644 --- a/crates/gateway/config-ui/ui/src/components/status-bar.ts +++ b/crates/gateway/config-ui/ui/src/components/status-bar.ts @@ -1,15 +1,15 @@ // 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 +// 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; an active queue command shows the +// barberpole, 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. import { X, createElement as lucideElement } from "lucide"; @@ -137,7 +137,9 @@ export function createStatusBar(options: StatusBarOptions): StatusBar { 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 }); + // Interim: the presence of an active command is the busy signal + // until the status document carries the Progress snapshot. + shell.setBusy(true); summary.hidden = true; queueGroup.hidden = false; const pendingCount = status.queue.pending.length; @@ -178,7 +180,7 @@ export function createStatusBar(options: StatusBarOptions): StatusBar { return; } shell.setText(""); - shell.renderSlot(null); + shell.setBusy(false); summary.hidden = false; queueGroup.hidden = true; leds.replaceChildren( 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/workshop/ui/src/parts/status/status-bar.ts b/crates/workshop/ui/src/parts/status/status-bar.ts index d7ac54de2..00199de99 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,9 @@ export class StatusBar extends Disposable { tooltip: frame.description, error: frame.severity === "error", }); - this.shell.renderSlot(frame.progress); + // Interim: the frame still carries a fraction; only its presence + // drives the barberpole until the protocol's busy flag lands. + this.shell.setBusy(frame.progress !== null); } /** @@ -134,7 +137,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 +158,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/test/barberpole-beside-indicators.mjs b/crates/workshop/ui/test/barberpole-beside-indicators.mjs new file mode 100644 index 000000000..9e878b3e1 --- /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 progress frame shows the barberpole +// and leaves the group and both LEDs visible, the barberpole precedes the +// group in DOM order, and clearing progress 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", + progress: { current: 1, total: 2 }, + }); + if (barberpoleEl.hidden) { + failures.push("a progress frame did not reveal the barberpole"); + } + if (indicatorsEl.hidden) { + failures.push("a progress frame hid the recording and activity LED group"); + } + if (recEl.hidden || ledEl.hidden) { + failures.push("a progress frame hid an LED individually"); + } + + emitStatus({ label: "Download complete", description: "ready" }); + if (!barberpoleEl.hidden) { + failures.push("clearing progress did not hide the barberpole"); + } + if (indicatorsEl.hidden) { + failures.push("clearing progress hid the recording and activity LED group"); + } +}); diff --git a/crates/workshop/ui/test/helpers/boot.mjs b/crates/workshop/ui/test/helpers/boot.mjs index 50f0827e8..1944b6a87 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"); @@ -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..c966a551f 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 +// non-null progress shows the barberpole beside the recording+activity LED +// indicators group, which stays visible; a null progress 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...", @@ -52,12 +52,9 @@ await bootWorkbench("status frames render into the bar", async (ctx) => { activity: "general", progress: { current: 1, total: 4 }, }); - 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 progress 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", @@ -66,12 +63,12 @@ await bootWorkbench("status frames render into the bar", async (ctx) => { progress: { current: 2, total: 4 }, }); 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 null-progress 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 progress cleared"); } }); 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 -//